From cfb3aa1faaefae3b900aa3f74cb421916e2f589f Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Sat, 16 Jul 2022 07:46:25 -0600 Subject: [PATCH] No Tab Characters and Before First for Argument and Parameter Wrapping (#482) --- .github/workflows/lint-pr.yaml | 2 +- .swiftformat | 8 +- .../BasicAppSettingsCoordinator.swift | 26 +- .../ConnectToServerCoodinator.swift | 24 +- Shared/Coordinators/FilterCoordinator.swift | 32 +- Shared/Coordinators/HomeCoordinator.swift | 64 +- Shared/Coordinators/ItemCoordinator.swift | 62 +- .../ItemOverviewCoordinator.swift | 30 +- Shared/Coordinators/LibraryCoordinator.swift | 72 +- .../Coordinators/LibraryListCoordinator.swift | 60 +- .../LiveTVChannelsCoordinator.swift | 48 +- Shared/Coordinators/LiveTVCoordinator.swift | 24 +- .../LiveTVProgramsCoordinator.swift | 24 +- .../Coordinators/LiveTVTabCoordinator.swift | 82 +- .../MainCoordinator/iOSMainCoordinator.swift | 142 +- .../iOSMainTabCoordinator.swift | 88 +- .../MainCoordinator/tvOSMainCoordinator.swift | 90 +- .../tvOSMainTabCoordinator.swift | 130 +- .../MoviesLibrariesCoordinator.swift | 46 +- Shared/Coordinators/SearchCoordinator.swift | 32 +- .../ServerDetailCoordinator.swift | 22 +- .../Coordinators/ServerListCoordinator.swift | 44 +- Shared/Coordinators/SettingsCoordinator.swift | 112 +- .../Coordinators/TVLibrariesCoordinator.swift | 46 +- Shared/Coordinators/UserListCoordinator.swift | 42 +- .../Coordinators/UserSignInCoordinator.swift | 22 +- .../iOSLiveTVVideoPlayerCoordinator.swift | 38 +- .../iOSVideoPlayerCoordinator.swift | 54 +- .../tvOSLiveTVVideoPlayerCoordinator.swift | 38 +- .../tvOSVideoPlayerCoordinator.swift | 38 +- Shared/Errors/ErrorMessage.swift | 28 +- Shared/Errors/NetworkError.swift | 158 +- Shared/Extensions/BlurHashDecode.swift | 204 +- Shared/Extensions/BundleExtensions.swift | 16 +- Shared/Extensions/CGSizeExtensions.swift | 30 +- Shared/Extensions/CollectionExtensions.swift | 20 +- Shared/Extensions/ColorExtension.swift | 26 +- Shared/Extensions/Defaults+Workaround.swift | 18 +- Shared/Extensions/DoubleExtensions.swift | 14 +- Shared/Extensions/ImageExtensions.swift | 18 +- .../BaseItemDto+Stackable.swift | 86 +- .../BaseItemDto+VideoPlayerViewModel.swift | 524 +++-- .../BaseItemDtoExtensions.swift | 610 ++--- .../BaseItemPersonExtensions.swift | 144 +- .../ChapterInfoExtensions.swift | 40 +- .../JellyfinAPIError.swift | 14 +- .../MediaStreamExtension.swift | 16 +- .../NameGUIDPairExtensions.swift | 6 +- Shared/Extensions/StringExtensions.swift | 44 +- .../Extensions/UIApplicationExtensions.swift | 12 +- Shared/Extensions/UIDeviceExtensions.swift | 6 +- .../Extensions/URLComponentsExtensions.swift | 16 +- Shared/Extensions/URLExtensions.swift | 20 +- Shared/Extensions/VLCPlayer+subtitles.swift | 20 +- Shared/Extensions/ViewExtensions.swift | 6 +- Shared/Generated/LocalizedLookup.swift | 22 +- Shared/Objects/AppAppearance.swift | 46 +- Shared/Objects/Bitrates.swift | 4 +- Shared/Objects/DeviceProfileBuilder.swift | 472 ++-- .../Objects/DeviceRotationViewModifier.swift | 22 +- Shared/Objects/HTTPScheme.swift | 4 +- Shared/Objects/OverlaySliderColor.swift | 20 +- Shared/Objects/OverlayType.swift | 20 +- Shared/Objects/PillStackable.swift | 2 +- Shared/Objects/PlaybackSpeed.swift | 136 +- Shared/Objects/PortraitImageStackable.swift | 14 +- Shared/Objects/PosterSize.swift | 4 +- Shared/Objects/SubtitleSize.swift | 72 +- Shared/Objects/TrackLanguage.swift | 6 +- Shared/Objects/Typings.swift | 118 +- Shared/Objects/VideoPlayerJumpLength.swift | 68 +- Shared/ServerDiscovery/ServerDiscovery.swift | 106 +- Shared/Singleton/BackgroundManager.swift | 36 +- Shared/Singleton/LogManager.swift | 76 +- Shared/Singleton/SessionManager.swift | 670 +++--- .../SwiftfinNotificationCenter.swift | 80 +- Shared/SwiftfinStore/SwiftfinStore.swift | 338 +-- .../SwiftfinStore/SwiftfinStoreDefaults.swift | 158 +- .../UIKit/PanDirectionGestureRecognizer.swift | 42 +- .../BasicAppSettingsViewModel.swift | 20 +- .../ViewModels/ConnectToServerViewModel.swift | 224 +- Shared/ViewModels/EpisodesRowManager.swift | 108 +- Shared/ViewModels/HomeViewModel.swift | 382 +-- .../CollectionItemViewModel.swift | 38 +- .../ItemViewModel/EpisodeItemViewModel.swift | 106 +- .../ItemViewModel/ItemViewModel.swift | 282 +-- .../ItemViewModel/MovieItemViewModel.swift | 50 +- .../ItemViewModel/SeasonItemViewModel.swift | 192 +- .../ItemViewModel/SeriesItemViewModel.swift | 133 +- Shared/ViewModels/LatestMediaViewModel.swift | 65 +- .../ViewModels/LibraryFilterViewModel.swift | 118 +- Shared/ViewModels/LibraryListViewModel.swift | 36 +- .../ViewModels/LibrarySearchViewModel.swift | 276 ++- Shared/ViewModels/LibraryViewModel.swift | 306 +-- .../ViewModels/LiveTVChannelsViewModel.swift | 386 +-- .../ViewModels/LiveTVProgramsViewModel.swift | 376 +-- Shared/ViewModels/MainTabViewModel.swift | 34 +- .../ViewModels/MovieLibrariesViewModel.swift | 134 +- .../QuickConnectSettingsViewModel.swift | 60 +- Shared/ViewModels/ServerDetailViewModel.swift | 30 +- Shared/ViewModels/ServerListViewModel.swift | 54 +- Shared/ViewModels/SettingsViewModel.swift | 52 +- Shared/ViewModels/TVLibrariesViewModel.swift | 134 +- Shared/ViewModels/UserListViewModel.swift | 48 +- Shared/ViewModels/UserSignInViewModel.swift | 110 +- Shared/ViewModels/VideoPlayerModel.swift | 26 +- .../ServerStreamType.swift | 4 +- .../VideoPlayerViewModel.swift | 1015 ++++---- Shared/ViewModels/ViewModel.swift | 110 +- Shared/Views/BlurHashView.swift | 72 +- Shared/Views/ImageView.swift | 134 +- Shared/Views/InitialFailureView.swift | 28 +- Shared/Views/LazyView.swift | 8 +- Shared/Views/MultiSelectorView.swift | 104 +- Shared/Views/ParallaxHeader.swift | 67 +- Shared/Views/PlainNavigationLinkButton.swift | 14 +- Shared/Views/PortraitItemSize.swift | 10 +- Shared/Views/SearchBarView.swift | 48 +- Shared/Views/SearchablePickerView.swift | 108 +- .../App/JellyfinPlayer_tvOSApp.swift | 26 +- .../EpisodesRowView/EpisodesRowCard.swift | 90 +- .../EpisodesRowView/EpisodesRowView.swift | 168 +- .../CinematicBackgroundView.swift | 52 +- .../CinematicNextUpCardView.swift | 100 +- .../CinematicResumeCardView.swift | 114 +- .../HomeCinematicView/HomeCinematicView.swift | 200 +- .../UICinematicBackgroundView.swift | 93 +- .../Components/ItemDetailsView.swift | 112 +- .../Components/LandscapeItemElement.swift | 219 +- .../Components/MediaPlayButtonRowView.swift | 81 +- .../Components/MediaViewActionButton.swift | 48 +- .../Components/PlainLinkButton.swift | 34 +- .../Components/PortraitItemElement.swift | 166 +- .../Components/PortraitItemsRowView.swift | 97 +- .../Components/PublicUserButton.swift | 66 +- Swiftfin tvOS/Components/SFSymbolButton.swift | 54 +- Swiftfin tvOS/ImageButtonStyle.swift | 20 +- Swiftfin tvOS/Views/AboutView.swift | 6 +- .../Views/BasicAppSettingsView.swift | 96 +- Swiftfin tvOS/Views/ConnectToServerView.swift | 132 +- .../ContinueWatchingCard.swift | 120 +- .../ContinueWatchingView.swift | 38 +- Swiftfin tvOS/Views/HomeView.swift | 120 +- .../CinematicCollectionItemView.swift | 102 +- .../CinematicEpisodeItemView.swift | 128 +- .../CinematicItemAboutView.swift | 52 +- .../CinematicItemViewTopRow.swift | 333 +-- .../CinematicItemViewTopRowButton.swift | 70 +- .../CinematicMovieItemView.swift | 94 +- .../CinematicSeasonItemView.swift | 126 +- .../CinematicSeriesItemView.swift | 102 +- .../CompactItemView/EpisodeItemView.swift | 276 +-- .../CompactItemView/MovieItemView.swift | 304 +-- .../CompactItemView/SeasonItemView.swift | 226 +- .../CompactItemView/SeriesItemView.swift | 334 +-- Swiftfin tvOS/Views/ItemView/ItemView.swift | 94 +- Swiftfin tvOS/Views/LatestMediaView.swift | 121 +- Swiftfin tvOS/Views/LibraryFilterView.swift | 166 +- Swiftfin tvOS/Views/LibraryListView.swift | 122 +- Swiftfin tvOS/Views/LibrarySearchView.swift | 174 +- Swiftfin tvOS/Views/LibraryView.swift | 148 +- .../Views/LiveTVChannelItemElement.swift | 298 +-- Swiftfin tvOS/Views/LiveTVChannelsView.swift | 258 +- Swiftfin tvOS/Views/LiveTVHomeView.swift | 18 +- Swiftfin tvOS/Views/LiveTVProgramsView.swift | 356 +-- Swiftfin tvOS/Views/MovieLibrariesView.swift | 134 +- .../Views/NextUpView/NextUpCard.swift | 68 +- .../Views/NextUpView/NextUpView.swift | 36 +- Swiftfin tvOS/Views/ServerDetailView.swift | 66 +- Swiftfin tvOS/Views/ServerListView.swift | 212 +- .../SettingsView/CustomizeViewsSettings.swift | 36 +- .../ExperimentalSettingsView.swift | 60 +- .../MissingItemsSettingsView.swift | 28 +- .../SettingsView/OverlaySettingsView.swift | 60 +- .../Views/SettingsView/SettingsView.swift | 292 +-- Swiftfin tvOS/Views/TVLibrariesView.swift | 134 +- Swiftfin tvOS/Views/UserListView.swift | 174 +- Swiftfin tvOS/Views/UserSignInView.swift | 92 +- .../LiveTVNativeVideoPlayerView.swift | 12 +- .../LiveTVPlayerViewController.swift | 1409 +++++------ .../VideoPlayer/LiveTVVideoPlayerView.swift | 12 +- .../NativePlayerViewController.swift | 178 +- .../Overlays/ConfirmCloseOverlay.swift | 42 +- .../Overlays/SmallMenuOverlay.swift | 642 ++--- .../Overlays/tvOSLiveTVOverlay.swift | 266 +-- .../VideoPlayer/Overlays/tvOSVLCOverlay.swift | 266 +-- .../VideoPlayer/PlayerOverlayDelegate.swift | 26 +- .../VideoPlayer/VLCPlayerViewController.swift | 1407 +++++------ .../Views/VideoPlayer/VideoPlayerView.swift | 24 +- .../VideoPlayer/tvOSSLider/SliderView.swift | 42 +- .../VideoPlayer/tvOSSLider/tvOSSlider.swift | 1055 ++++----- Swiftfin/App/AppDelegate.swift | 37 +- Swiftfin/App/JellyfinPlayerApp.swift | 68 +- .../PreferenceUIHostingController.swift | 150 +- .../PreferenceUIHostingSwizzling.swift | 88 +- Swiftfin/AppURLHandler/AppURLHandler.swift | 152 +- Swiftfin/AppURLHandler/DeepLink.swift | 2 +- Swiftfin/Components/AppIcon.swift | 10 +- .../Components/DetectBottomScrollView.swift | 141 +- .../EpisodesRowView/EpisodeRowCard.swift | 106 +- .../EpisodesRowView/EpisodesRowView.swift | 218 +- Swiftfin/Components/PillHStackView.swift | 80 +- Swiftfin/Components/PortraitHStackView.swift | 135 +- Swiftfin/Components/PortraitItemButton.swift | 111 +- Swiftfin/Components/PortraitItemElement.swift | 8 +- Swiftfin/Components/PrimaryButtonView.swift | 48 +- Swiftfin/Components/TruncatedTextView.swift | 193 +- Swiftfin/Objects/RefreshHelper.swift | 36 +- Swiftfin/Views/AboutView.swift | 124 +- Swiftfin/Views/BasicAppSettingsView.swift | 184 +- Swiftfin/Views/ConnectToServerView.swift | 212 +- Swiftfin/Views/ContinueWatchingView.swift | 170 +- Swiftfin/Views/HomeView.swift | 232 +- Swiftfin/Views/ItemOverviewView.swift | 40 +- Swiftfin/Views/ItemView/ItemView.swift | 96 +- Swiftfin/Views/ItemView/ItemViewBody.swift | 281 +-- .../Views/ItemView/ItemViewDetailsView.swift | 122 +- .../Landscape/ItemLandscapeMainView.swift | 180 +- .../Landscape/ItemLandscapeTopBarView.swift | 208 +- .../ItemPortraitHeaderOverlayView.swift | 332 +-- .../Portrait/ItemPortraitMainView.swift | 72 +- Swiftfin/Views/LatestMediaView.swift | 28 +- Swiftfin/Views/LibraryFilterView.swift | 168 +- Swiftfin/Views/LibraryListView.swift | 192 +- Swiftfin/Views/LibrarySearchView.swift | 184 +- Swiftfin/Views/LibraryView.swift | 155 +- Swiftfin/Views/LiveTVChannelItemElement.swift | 208 +- .../Views/LiveTVChannelItemWideElement.swift | 267 +-- Swiftfin/Views/LiveTVChannelsView.swift | 250 +- Swiftfin/Views/LiveTVHomeView.swift | 6 +- Swiftfin/Views/LiveTVProgramsView.swift | 260 ++- Swiftfin/Views/PublicUserSignInCellView.swift | 54 +- Swiftfin/Views/ServerDetailView.swift | 80 +- Swiftfin/Views/ServerListView.swift | 204 +- .../SettingsView/CustomizeViewsSettings.swift | 34 +- .../ExperimentalSettingsView.swift | 60 +- .../MissingItemsSettingsView.swift | 28 +- .../SettingsView/OverlaySettingsView.swift | 96 +- .../QuickConnectSettingsView.swift | 60 +- .../Views/SettingsView/SettingsView.swift | 358 +-- Swiftfin/Views/UserListView.swift | 198 +- Swiftfin/Views/UserSignInView.swift | 142 +- .../LiveTVNativePlayerViewController.swift | 150 +- .../Views/VideoPlayer/LiveTVPlayerView.swift | 24 +- .../LiveTVPlayerViewController.swift | 1699 +++++++------- .../NativePlayerViewController.swift | 150 +- .../VLCPlayerChapterOverlayView.swift | 158 +- .../Overlays/VLCPlayerOverlayView.swift | 892 +++---- .../VideoPlayer/PlayerOverlayDelegate.swift | 42 +- .../Views/VideoPlayer/VLCPlayerView.swift | 24 +- .../VideoPlayer/VLCPlayerViewController.swift | 2078 +++++++++-------- WidgetExtension/JellyfinWidget.swift | 8 +- WidgetExtension/NextUpWidget.swift | 874 +++---- 253 files changed, 19200 insertions(+), 18370 deletions(-) diff --git a/.github/workflows/lint-pr.yaml b/.github/workflows/lint-pr.yaml index 38f3971d..ae6dc2c1 100644 --- a/.github/workflows/lint-pr.yaml +++ b/.github/workflows/lint-pr.yaml @@ -7,7 +7,7 @@ on: jobs: build: name: "Lint 🧹" - runs-on: macos-latest + runs-on: macos-12 steps: - name: Checkout diff --git a/.swiftformat b/.swiftformat index dd2b317b..7b2a75eb 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,16 +1,15 @@ -# version: 0.47.5 +# version: 0.49.11 --swiftversion 5.5 ---indent tab --tabwidth 4 --xcodeindentation enabled --semicolons never --stripunusedargs closure-only --maxwidth 140 --assetliterals visual-width ---wraparguments after-first ---wrapparameters after-first +--wraparguments before-first +--wrapparameters before-first --wrapcollections before-first --wrapconditions after-first --funcattributes prev-line @@ -44,7 +43,6 @@ redundantClosure, \ redundantType ---exclude Pods --exclude Shared/Generated/Strings.swift --header "\nSwiftfin is subject to the terms of the Mozilla Public\nLicense, v2.0. If a copy of the MPL was not distributed with this\nfile, you can obtain one at https://mozilla.org/MPL/2.0/.\n\nCopyright (c) {year} Jellyfin & Jellyfin Contributors\n" diff --git a/Shared/Coordinators/BasicAppSettingsCoordinator.swift b/Shared/Coordinators/BasicAppSettingsCoordinator.swift index 2deb18ec..1033a87d 100644 --- a/Shared/Coordinators/BasicAppSettingsCoordinator.swift +++ b/Shared/Coordinators/BasicAppSettingsCoordinator.swift @@ -12,20 +12,20 @@ import SwiftUI final class BasicAppSettingsCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \BasicAppSettingsCoordinator.start) + let stack = NavigationStack(initial: \BasicAppSettingsCoordinator.start) - @Root - var start = makeStart - @Route(.push) - var about = makeAbout + @Root + var start = makeStart + @Route(.push) + var about = makeAbout - @ViewBuilder - func makeAbout() -> some View { - AboutView() - } + @ViewBuilder + func makeAbout() -> some View { + AboutView() + } - @ViewBuilder - func makeStart() -> some View { - BasicAppSettingsView(viewModel: BasicAppSettingsViewModel()) - } + @ViewBuilder + func makeStart() -> some View { + BasicAppSettingsView(viewModel: BasicAppSettingsViewModel()) + } } diff --git a/Shared/Coordinators/ConnectToServerCoodinator.swift b/Shared/Coordinators/ConnectToServerCoodinator.swift index e6e833c5..071cb689 100644 --- a/Shared/Coordinators/ConnectToServerCoodinator.swift +++ b/Shared/Coordinators/ConnectToServerCoodinator.swift @@ -12,19 +12,19 @@ import SwiftUI final class ConnectToServerCoodinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \ConnectToServerCoodinator.start) + let stack = NavigationStack(initial: \ConnectToServerCoodinator.start) - @Root - var start = makeStart - @Route(.push) - var userSignIn = makeUserSignIn + @Root + var start = makeStart + @Route(.push) + var userSignIn = makeUserSignIn - func makeUserSignIn(server: SwiftfinStore.State.Server) -> UserSignInCoordinator { - UserSignInCoordinator(viewModel: .init(server: server)) - } + func makeUserSignIn(server: SwiftfinStore.State.Server) -> UserSignInCoordinator { + UserSignInCoordinator(viewModel: .init(server: server)) + } - @ViewBuilder - func makeStart() -> some View { - ConnectToServerView(viewModel: ConnectToServerViewModel()) - } + @ViewBuilder + func makeStart() -> some View { + ConnectToServerView(viewModel: ConnectToServerViewModel()) + } } diff --git a/Shared/Coordinators/FilterCoordinator.swift b/Shared/Coordinators/FilterCoordinator.swift index 10a13f05..4d1193d7 100644 --- a/Shared/Coordinators/FilterCoordinator.swift +++ b/Shared/Coordinators/FilterCoordinator.swift @@ -14,24 +14,24 @@ typealias FilterCoordinatorParams = (filters: Binding, enabledFi final class FilterCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \FilterCoordinator.start) + let stack = NavigationStack(initial: \FilterCoordinator.start) - @Root - var start = makeStart + @Root + var start = makeStart - @Binding - var filters: LibraryFilters - var enabledFilterType: [FilterType] - var parentId: String = "" + @Binding + var filters: LibraryFilters + var enabledFilterType: [FilterType] + var parentId: String = "" - init(filters: Binding, enabledFilterType: [FilterType], parentId: String) { - _filters = filters - self.enabledFilterType = enabledFilterType - self.parentId = parentId - } + init(filters: Binding, enabledFilterType: [FilterType], parentId: String) { + _filters = filters + self.enabledFilterType = enabledFilterType + self.parentId = parentId + } - @ViewBuilder - func makeStart() -> some View { - LibraryFilterView(filters: $filters, enabledFilterType: enabledFilterType, parentId: parentId) - } + @ViewBuilder + func makeStart() -> some View { + LibraryFilterView(filters: $filters, enabledFilterType: enabledFilterType, parentId: parentId) + } } diff --git a/Shared/Coordinators/HomeCoordinator.swift b/Shared/Coordinators/HomeCoordinator.swift index 3ce9bc3f..e4501fcf 100644 --- a/Shared/Coordinators/HomeCoordinator.swift +++ b/Shared/Coordinators/HomeCoordinator.swift @@ -13,43 +13,43 @@ import SwiftUI final class HomeCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \HomeCoordinator.start) + let stack = NavigationStack(initial: \HomeCoordinator.start) - @Root - var start = makeStart - @Route(.modal) - var settings = makeSettings - @Route(.push) - var library = makeLibrary - @Route(.push) - var item = makeItem - @Route(.modal) - var modalItem = makeModalItem - @Route(.modal) - var modalLibrary = makeModalLibrary + @Root + var start = makeStart + @Route(.modal) + var settings = makeSettings + @Route(.push) + var library = makeLibrary + @Route(.push) + var item = makeItem + @Route(.modal) + var modalItem = makeModalItem + @Route(.modal) + var modalLibrary = makeModalLibrary - func makeSettings() -> NavigationViewCoordinator { - NavigationViewCoordinator(SettingsCoordinator()) - } + func makeSettings() -> NavigationViewCoordinator { + NavigationViewCoordinator(SettingsCoordinator()) + } - func makeLibrary(params: LibraryCoordinatorParams) -> LibraryCoordinator { - LibraryCoordinator(viewModel: params.viewModel, title: params.title) - } + func makeLibrary(params: LibraryCoordinatorParams) -> LibraryCoordinator { + LibraryCoordinator(viewModel: params.viewModel, title: params.title) + } - func makeItem(item: BaseItemDto) -> ItemCoordinator { - ItemCoordinator(item: item) - } + func makeItem(item: BaseItemDto) -> ItemCoordinator { + ItemCoordinator(item: item) + } - func makeModalItem(item: BaseItemDto) -> NavigationViewCoordinator { - NavigationViewCoordinator(ItemCoordinator(item: item)) - } + func makeModalItem(item: BaseItemDto) -> NavigationViewCoordinator { + NavigationViewCoordinator(ItemCoordinator(item: item)) + } - func makeModalLibrary(params: LibraryCoordinatorParams) -> NavigationViewCoordinator { - NavigationViewCoordinator(LibraryCoordinator(viewModel: params.viewModel, title: params.title)) - } + func makeModalLibrary(params: LibraryCoordinatorParams) -> NavigationViewCoordinator { + NavigationViewCoordinator(LibraryCoordinator(viewModel: params.viewModel, title: params.title)) + } - @ViewBuilder - func makeStart() -> some View { - HomeView() - } + @ViewBuilder + func makeStart() -> some View { + HomeView() + } } diff --git a/Shared/Coordinators/ItemCoordinator.swift b/Shared/Coordinators/ItemCoordinator.swift index 9bb29489..a68679fd 100644 --- a/Shared/Coordinators/ItemCoordinator.swift +++ b/Shared/Coordinators/ItemCoordinator.swift @@ -13,43 +13,43 @@ import SwiftUI final class ItemCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \ItemCoordinator.start) + let stack = NavigationStack(initial: \ItemCoordinator.start) - @Root - var start = makeStart - @Route(.push) - var item = makeItem - @Route(.push) - var library = makeLibrary - @Route(.modal) - var itemOverview = makeItemOverview - @Route(.fullScreen) - var videoPlayer = makeVideoPlayer + @Root + var start = makeStart + @Route(.push) + var item = makeItem + @Route(.push) + var library = makeLibrary + @Route(.modal) + var itemOverview = makeItemOverview + @Route(.fullScreen) + var videoPlayer = makeVideoPlayer - let itemDto: BaseItemDto + let itemDto: BaseItemDto - init(item: BaseItemDto) { - self.itemDto = item - } + init(item: BaseItemDto) { + self.itemDto = item + } - func makeLibrary(params: LibraryCoordinatorParams) -> LibraryCoordinator { - LibraryCoordinator(viewModel: params.viewModel, title: params.title) - } + func makeLibrary(params: LibraryCoordinatorParams) -> LibraryCoordinator { + LibraryCoordinator(viewModel: params.viewModel, title: params.title) + } - func makeItem(item: BaseItemDto) -> ItemCoordinator { - ItemCoordinator(item: item) - } + func makeItem(item: BaseItemDto) -> ItemCoordinator { + ItemCoordinator(item: item) + } - func makeItemOverview(item: BaseItemDto) -> NavigationViewCoordinator { - NavigationViewCoordinator(ItemOverviewCoordinator(item: itemDto)) - } + func makeItemOverview(item: BaseItemDto) -> NavigationViewCoordinator { + NavigationViewCoordinator(ItemOverviewCoordinator(item: itemDto)) + } - func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { - NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel)) - } + func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator(VideoPlayerCoordinator(viewModel: viewModel)) + } - @ViewBuilder - func makeStart() -> some View { - ItemNavigationView(item: itemDto) - } + @ViewBuilder + func makeStart() -> some View { + ItemNavigationView(item: itemDto) + } } diff --git a/Shared/Coordinators/ItemOverviewCoordinator.swift b/Shared/Coordinators/ItemOverviewCoordinator.swift index db093f56..cb01e9bb 100644 --- a/Shared/Coordinators/ItemOverviewCoordinator.swift +++ b/Shared/Coordinators/ItemOverviewCoordinator.swift @@ -12,23 +12,23 @@ import SwiftUI final class ItemOverviewCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \ItemOverviewCoordinator.start) + let stack = NavigationStack(initial: \ItemOverviewCoordinator.start) - @Root - var start = makeStart + @Root + var start = makeStart - let item: BaseItemDto + let item: BaseItemDto - init(item: BaseItemDto) { - self.item = item - } + init(item: BaseItemDto) { + self.item = item + } - @ViewBuilder - func makeStart() -> some View { - #if os(tvOS) - EmptyView() - #else - ItemOverviewView(item: item) - #endif - } + @ViewBuilder + func makeStart() -> some View { + #if os(tvOS) + EmptyView() + #else + ItemOverviewView(item: item) + #endif + } } diff --git a/Shared/Coordinators/LibraryCoordinator.swift b/Shared/Coordinators/LibraryCoordinator.swift index 1678e407..5dd3a52b 100644 --- a/Shared/Coordinators/LibraryCoordinator.swift +++ b/Shared/Coordinators/LibraryCoordinator.swift @@ -15,47 +15,49 @@ typealias LibraryCoordinatorParams = (viewModel: LibraryViewModel, title: String final class LibraryCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \LibraryCoordinator.start) + let stack = NavigationStack(initial: \LibraryCoordinator.start) - @Root - var start = makeStart - @Route(.push) - var search = makeSearch - @Route(.modal) - var filter = makeFilter - @Route(.push) - var item = makeItem - @Route(.modal) - var modalItem = makeModalItem + @Root + var start = makeStart + @Route(.push) + var search = makeSearch + @Route(.modal) + var filter = makeFilter + @Route(.push) + var item = makeItem + @Route(.modal) + var modalItem = makeModalItem - let viewModel: LibraryViewModel - let title: String + let viewModel: LibraryViewModel + let title: String - init(viewModel: LibraryViewModel, title: String) { - self.viewModel = viewModel - self.title = title - } + init(viewModel: LibraryViewModel, title: String) { + self.viewModel = viewModel + self.title = title + } - @ViewBuilder - func makeStart() -> some View { - LibraryView(viewModel: self.viewModel, title: title) - } + @ViewBuilder + func makeStart() -> some View { + LibraryView(viewModel: self.viewModel, title: title) + } - func makeSearch(viewModel: LibrarySearchViewModel) -> SearchCoordinator { - SearchCoordinator(viewModel: viewModel) - } + func makeSearch(viewModel: LibrarySearchViewModel) -> SearchCoordinator { + SearchCoordinator(viewModel: viewModel) + } - func makeFilter(params: FilterCoordinatorParams) -> NavigationViewCoordinator { - NavigationViewCoordinator(FilterCoordinator(filters: params.filters, - enabledFilterType: params.enabledFilterType, - parentId: params.parentId)) - } + func makeFilter(params: FilterCoordinatorParams) -> NavigationViewCoordinator { + NavigationViewCoordinator(FilterCoordinator( + filters: params.filters, + enabledFilterType: params.enabledFilterType, + parentId: params.parentId + )) + } - func makeItem(item: BaseItemDto) -> ItemCoordinator { - ItemCoordinator(item: item) - } + func makeItem(item: BaseItemDto) -> ItemCoordinator { + ItemCoordinator(item: item) + } - func makeModalItem(item: BaseItemDto) -> NavigationViewCoordinator { - NavigationViewCoordinator(ItemCoordinator(item: item)) - } + func makeModalItem(item: BaseItemDto) -> NavigationViewCoordinator { + NavigationViewCoordinator(ItemCoordinator(item: item)) + } } diff --git a/Shared/Coordinators/LibraryListCoordinator.swift b/Shared/Coordinators/LibraryListCoordinator.swift index 5ac1ad91..701f6c7a 100644 --- a/Shared/Coordinators/LibraryListCoordinator.swift +++ b/Shared/Coordinators/LibraryListCoordinator.swift @@ -12,41 +12,41 @@ import SwiftUI final class LibraryListCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \LibraryListCoordinator.start) + let stack = NavigationStack(initial: \LibraryListCoordinator.start) - @Root - var start = makeStart - @Route(.push) - var search = makeSearch - @Route(.push) - var library = makeLibrary - #if os(iOS) - @Route(.push) - var liveTV = makeLiveTV - #endif + @Root + var start = makeStart + @Route(.push) + var search = makeSearch + @Route(.push) + var library = makeLibrary + #if os(iOS) + @Route(.push) + var liveTV = makeLiveTV + #endif - let viewModel: LibraryListViewModel + let viewModel: LibraryListViewModel - init(viewModel: LibraryListViewModel) { - self.viewModel = viewModel - } + init(viewModel: LibraryListViewModel) { + self.viewModel = viewModel + } - func makeLibrary(params: LibraryCoordinatorParams) -> LibraryCoordinator { - LibraryCoordinator(viewModel: params.viewModel, title: params.title) - } + func makeLibrary(params: LibraryCoordinatorParams) -> LibraryCoordinator { + LibraryCoordinator(viewModel: params.viewModel, title: params.title) + } - func makeSearch(viewModel: LibrarySearchViewModel) -> SearchCoordinator { - SearchCoordinator(viewModel: viewModel) - } + func makeSearch(viewModel: LibrarySearchViewModel) -> SearchCoordinator { + SearchCoordinator(viewModel: viewModel) + } - #if os(iOS) - func makeLiveTV() -> LiveTVCoordinator { - LiveTVCoordinator() - } - #endif + #if os(iOS) + func makeLiveTV() -> LiveTVCoordinator { + LiveTVCoordinator() + } + #endif - @ViewBuilder - func makeStart() -> some View { - LibraryListView(viewModel: self.viewModel) - } + @ViewBuilder + func makeStart() -> some View { + LibraryListView(viewModel: self.viewModel) + } } diff --git a/Shared/Coordinators/LiveTVChannelsCoordinator.swift b/Shared/Coordinators/LiveTVChannelsCoordinator.swift index 77f80de8..9e5f17c5 100644 --- a/Shared/Coordinators/LiveTVChannelsCoordinator.swift +++ b/Shared/Coordinators/LiveTVChannelsCoordinator.swift @@ -12,37 +12,37 @@ import Stinsen import SwiftUI final class LiveTVChannelsCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \LiveTVChannelsCoordinator.start) + let stack = NavigationStack(initial: \LiveTVChannelsCoordinator.start) - @Root - var start = makeStart - @Route(.modal) - var modalItem = makeModalItem - @Route(.fullScreen) - var videoPlayer = makeVideoPlayer + @Root + var start = makeStart + @Route(.modal) + var modalItem = makeModalItem + @Route(.fullScreen) + var videoPlayer = makeVideoPlayer - func makeModalItem(item: BaseItemDto) -> NavigationViewCoordinator { - NavigationViewCoordinator(ItemCoordinator(item: item)) - } + func makeModalItem(item: BaseItemDto) -> NavigationViewCoordinator { + NavigationViewCoordinator(ItemCoordinator(item: item)) + } - func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { - NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel)) - } + func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel)) + } - @ViewBuilder - func makeStart() -> some View { - LiveTVChannelsView() - } + @ViewBuilder + func makeStart() -> some View { + LiveTVChannelsView() + } } final class EmptyViewCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \EmptyViewCoordinator.start) + let stack = NavigationStack(initial: \EmptyViewCoordinator.start) - @Root - var start = makeStart + @Root + var start = makeStart - @ViewBuilder - func makeStart() -> some View { - Text("Empty") - } + @ViewBuilder + func makeStart() -> some View { + Text("Empty") + } } diff --git a/Shared/Coordinators/LiveTVCoordinator.swift b/Shared/Coordinators/LiveTVCoordinator.swift index f3a80bcd..c1e8f813 100644 --- a/Shared/Coordinators/LiveTVCoordinator.swift +++ b/Shared/Coordinators/LiveTVCoordinator.swift @@ -12,19 +12,19 @@ import Stinsen import SwiftUI final class LiveTVCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \LiveTVCoordinator.start) + let stack = NavigationStack(initial: \LiveTVCoordinator.start) - @Root - var start = makeStart - @Route(.fullScreen) - var videoPlayer = makeVideoPlayer + @Root + var start = makeStart + @Route(.fullScreen) + var videoPlayer = makeVideoPlayer - @ViewBuilder - func makeStart() -> some View { - LiveTVChannelsView() - } + @ViewBuilder + func makeStart() -> some View { + LiveTVChannelsView() + } - func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { - NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel)) - } + func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel)) + } } diff --git a/Shared/Coordinators/LiveTVProgramsCoordinator.swift b/Shared/Coordinators/LiveTVProgramsCoordinator.swift index 095b1ece..f44885f0 100644 --- a/Shared/Coordinators/LiveTVProgramsCoordinator.swift +++ b/Shared/Coordinators/LiveTVProgramsCoordinator.swift @@ -13,19 +13,19 @@ import SwiftUI final class LiveTVProgramsCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \LiveTVProgramsCoordinator.start) + let stack = NavigationStack(initial: \LiveTVProgramsCoordinator.start) - @Root - var start = makeStart - @Route(.fullScreen) - var videoPlayer = makeVideoPlayer + @Root + var start = makeStart + @Route(.fullScreen) + var videoPlayer = makeVideoPlayer - func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { - NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel)) - } + func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel)) + } - @ViewBuilder - func makeStart() -> some View { - LiveTVProgramsView() - } + @ViewBuilder + func makeStart() -> some View { + LiveTVProgramsView() + } } diff --git a/Shared/Coordinators/LiveTVTabCoordinator.swift b/Shared/Coordinators/LiveTVTabCoordinator.swift index c7f9936e..3d85893c 100644 --- a/Shared/Coordinators/LiveTVTabCoordinator.swift +++ b/Shared/Coordinators/LiveTVTabCoordinator.swift @@ -11,52 +11,52 @@ import Stinsen import SwiftUI final class LiveTVTabCoordinator: TabCoordinatable { - var child = TabChild(startingItems: [ - \LiveTVTabCoordinator.programs, - \LiveTVTabCoordinator.channels, - \LiveTVTabCoordinator.home, - ]) + var child = TabChild(startingItems: [ + \LiveTVTabCoordinator.programs, + \LiveTVTabCoordinator.channels, + \LiveTVTabCoordinator.home, + ]) - @Route(tabItem: makeProgramsTab) - var programs = makePrograms - @Route(tabItem: makeChannelsTab) - var channels = makeChannels - @Route(tabItem: makeHomeTab) - var home = makeHome + @Route(tabItem: makeProgramsTab) + var programs = makePrograms + @Route(tabItem: makeChannelsTab) + var channels = makeChannels + @Route(tabItem: makeHomeTab) + var home = makeHome - func makePrograms() -> NavigationViewCoordinator { - NavigationViewCoordinator(LiveTVProgramsCoordinator()) - } + func makePrograms() -> NavigationViewCoordinator { + NavigationViewCoordinator(LiveTVProgramsCoordinator()) + } - @ViewBuilder - func makeProgramsTab(isActive: Bool) -> some View { - HStack { - Image(systemName: "tv") - L10n.programs.text - } - } + @ViewBuilder + func makeProgramsTab(isActive: Bool) -> some View { + HStack { + Image(systemName: "tv") + L10n.programs.text + } + } - func makeChannels() -> NavigationViewCoordinator { - NavigationViewCoordinator(LiveTVChannelsCoordinator()) - } + func makeChannels() -> NavigationViewCoordinator { + NavigationViewCoordinator(LiveTVChannelsCoordinator()) + } - @ViewBuilder - func makeChannelsTab(isActive: Bool) -> some View { - HStack { - Image(systemName: "square.grid.3x3") - L10n.channels.text - } - } + @ViewBuilder + func makeChannelsTab(isActive: Bool) -> some View { + HStack { + Image(systemName: "square.grid.3x3") + L10n.channels.text + } + } - func makeHome() -> LiveTVHomeView { - LiveTVHomeView() - } + func makeHome() -> LiveTVHomeView { + LiveTVHomeView() + } - @ViewBuilder - func makeHomeTab(isActive: Bool) -> some View { - HStack { - Image(systemName: "house") - L10n.home.text - } - } + @ViewBuilder + func makeHomeTab(isActive: Bool) -> some View { + HStack { + Image(systemName: "house") + L10n.home.text + } + } } diff --git a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift index ec07d8e5..f360f983 100644 --- a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift @@ -15,89 +15,89 @@ import SwiftUI import WidgetKit final class MainCoordinator: NavigationCoordinatable { - var stack: NavigationStack + var stack: NavigationStack - @Root - var mainTab = makeMainTab - @Root - var serverList = makeServerList + @Root + var mainTab = makeMainTab + @Root + var serverList = makeServerList - private var cancellables = Set() + private var cancellables = Set() - init() { - if SessionManager.main.currentLogin != nil { - self.stack = NavigationStack(initial: \MainCoordinator.mainTab) - } else { - self.stack = NavigationStack(initial: \MainCoordinator.serverList) - } + init() { + if SessionManager.main.currentLogin != nil { + self.stack = NavigationStack(initial: \MainCoordinator.mainTab) + } else { + self.stack = NavigationStack(initial: \MainCoordinator.serverList) + } - ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory - DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk + ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory + DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk - WidgetCenter.shared.reloadAllTimelines() - UIScrollView.appearance().keyboardDismissMode = .onDrag + WidgetCenter.shared.reloadAllTimelines() + UIScrollView.appearance().keyboardDismissMode = .onDrag - // Back bar button item setup - let backButtonBackgroundImage = UIImage(systemName: "chevron.backward.circle.fill") - let barAppearance = UINavigationBar.appearance() - barAppearance.backIndicatorImage = backButtonBackgroundImage - barAppearance.backIndicatorTransitionMaskImage = backButtonBackgroundImage - barAppearance.tintColor = UIColor(Color.jellyfinPurple) + // Back bar button item setup + let backButtonBackgroundImage = UIImage(systemName: "chevron.backward.circle.fill") + let barAppearance = UINavigationBar.appearance() + barAppearance.backIndicatorImage = backButtonBackgroundImage + barAppearance.backIndicatorTransitionMaskImage = backButtonBackgroundImage + barAppearance.tintColor = UIColor(Color.jellyfinPurple) - // Notification setup for state - Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn)) - Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut)) - Notifications[.processDeepLink].subscribe(self, selector: #selector(processDeepLink(_:))) - Notifications[.didChangeServerCurrentURI].subscribe(self, selector: #selector(didChangeServerCurrentURI(_:))) + // Notification setup for state + Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn)) + Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut)) + Notifications[.processDeepLink].subscribe(self, selector: #selector(processDeepLink(_:))) + Notifications[.didChangeServerCurrentURI].subscribe(self, selector: #selector(didChangeServerCurrentURI(_:))) - Defaults.publisher(.appAppearance) - .sink { _ in - JellyfinPlayerApp.setupAppearance() - } - .store(in: &cancellables) - } + Defaults.publisher(.appAppearance) + .sink { _ in + JellyfinPlayerApp.setupAppearance() + } + .store(in: &cancellables) + } - @objc - func didSignIn() { - LogManager.log.info("Received `didSignIn` from SwiftfinNotificationCenter.") - root(\.mainTab) - } + @objc + func didSignIn() { + LogManager.log.info("Received `didSignIn` from SwiftfinNotificationCenter.") + root(\.mainTab) + } - @objc - func didSignOut() { - LogManager.log.info("Received `didSignOut` from SwiftfinNotificationCenter.") - root(\.serverList) - } + @objc + func didSignOut() { + LogManager.log.info("Received `didSignOut` from SwiftfinNotificationCenter.") + root(\.serverList) + } - @objc - func processDeepLink(_ notification: Notification) { - guard let deepLink = notification.object as? DeepLink else { return } - if let coordinator = hasRoot(\.mainTab) { - switch deepLink { - case let .item(item): - coordinator.focusFirst(\.home) - .child - .popToRoot() - .route(to: \.item, item) - } - } - } + @objc + func processDeepLink(_ notification: Notification) { + guard let deepLink = notification.object as? DeepLink else { return } + if let coordinator = hasRoot(\.mainTab) { + switch deepLink { + case let .item(item): + coordinator.focusFirst(\.home) + .child + .popToRoot() + .route(to: \.item, item) + } + } + } - @objc - func didChangeServerCurrentURI(_ notification: Notification) { - guard let newCurrentServerState = notification.object as? SwiftfinStore.State.Server - else { fatalError("Need to have new current login state server") } - guard SessionManager.main.currentLogin != nil else { return } - if newCurrentServerState.id == SessionManager.main.currentLogin.server.id { - SessionManager.main.loginUser(server: newCurrentServerState, user: SessionManager.main.currentLogin.user) - } - } + @objc + func didChangeServerCurrentURI(_ notification: Notification) { + guard let newCurrentServerState = notification.object as? SwiftfinStore.State.Server + else { fatalError("Need to have new current login state server") } + guard SessionManager.main.currentLogin != nil else { return } + if newCurrentServerState.id == SessionManager.main.currentLogin.server.id { + SessionManager.main.loginUser(server: newCurrentServerState, user: SessionManager.main.currentLogin.user) + } + } - func makeMainTab() -> MainTabCoordinator { - MainTabCoordinator() - } + func makeMainTab() -> MainTabCoordinator { + MainTabCoordinator() + } - func makeServerList() -> NavigationViewCoordinator { - NavigationViewCoordinator(ServerListCoordinator()) - } + func makeServerList() -> NavigationViewCoordinator { + NavigationViewCoordinator(ServerListCoordinator()) + } } diff --git a/Shared/Coordinators/MainCoordinator/iOSMainTabCoordinator.swift b/Shared/Coordinators/MainCoordinator/iOSMainTabCoordinator.swift index 67f99571..4c547c03 100644 --- a/Shared/Coordinators/MainCoordinator/iOSMainTabCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/iOSMainTabCoordinator.swift @@ -11,56 +11,56 @@ import Stinsen import SwiftUI final class MainTabCoordinator: TabCoordinatable { - var child = TabChild(startingItems: [ - \MainTabCoordinator.home, - \MainTabCoordinator.allMedia, - ]) + var child = TabChild(startingItems: [ + \MainTabCoordinator.home, + \MainTabCoordinator.allMedia, + ]) - @Route(tabItem: makeHomeTab, onTapped: onHomeTapped) - var home = makeHome - @Route(tabItem: makeAllMediaTab, onTapped: onMediaTapped) - var allMedia = makeAllMedia + @Route(tabItem: makeHomeTab, onTapped: onHomeTapped) + var home = makeHome + @Route(tabItem: makeAllMediaTab, onTapped: onMediaTapped) + var allMedia = makeAllMedia - func makeHome() -> NavigationViewCoordinator { - NavigationViewCoordinator(HomeCoordinator()) - } + func makeHome() -> NavigationViewCoordinator { + NavigationViewCoordinator(HomeCoordinator()) + } - func onHomeTapped(isRepeat: Bool, coordinator: NavigationViewCoordinator) { - if isRepeat { - coordinator.child.popToRoot() - } - } + func onHomeTapped(isRepeat: Bool, coordinator: NavigationViewCoordinator) { + if isRepeat { + coordinator.child.popToRoot() + } + } - @ViewBuilder - func makeHomeTab(isActive: Bool) -> some View { - Image(systemName: "house") - L10n.home.text - } + @ViewBuilder + func makeHomeTab(isActive: Bool) -> some View { + Image(systemName: "house") + L10n.home.text + } - func makeAllMedia() -> NavigationViewCoordinator { - NavigationViewCoordinator(LibraryListCoordinator(viewModel: LibraryListViewModel())) - } + func makeAllMedia() -> NavigationViewCoordinator { + NavigationViewCoordinator(LibraryListCoordinator(viewModel: LibraryListViewModel())) + } - func onMediaTapped(isRepeat: Bool, coordinator: NavigationViewCoordinator) { - if isRepeat { - coordinator.child.popToRoot() - } - } + func onMediaTapped(isRepeat: Bool, coordinator: NavigationViewCoordinator) { + if isRepeat { + coordinator.child.popToRoot() + } + } - @ViewBuilder - func makeAllMediaTab(isActive: Bool) -> some View { - Image(systemName: "folder") - L10n.allMedia.text - } + @ViewBuilder + func makeAllMediaTab(isActive: Bool) -> some View { + Image(systemName: "folder") + L10n.allMedia.text + } - @ViewBuilder - func customize(_ view: AnyView) -> some View { - view.onAppear { - AppURLHandler.shared.appURLState = .allowed - // TODO: todo - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - AppURLHandler.shared.processLaunchedURLIfNeeded() - } - } - } + @ViewBuilder + func customize(_ view: AnyView) -> some View { + view.onAppear { + AppURLHandler.shared.appURLState = .allowed + // TODO: todo + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + AppURLHandler.shared.processLaunchedURLIfNeeded() + } + } + } } diff --git a/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift index 3f7e48a9..9406f69d 100644 --- a/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/tvOSMainCoordinator.swift @@ -12,59 +12,59 @@ import Stinsen import SwiftUI final class MainCoordinator: NavigationCoordinatable { - var stack = NavigationStack(initial: \MainCoordinator.mainTab) + var stack = NavigationStack(initial: \MainCoordinator.mainTab) - @Root - var mainTab = makeMainTab - @Root - var serverList = makeServerList - @Root - var liveTV = makeLiveTV + @Root + var mainTab = makeMainTab + @Root + var serverList = makeServerList + @Root + var liveTV = makeLiveTV - @ViewBuilder - func customize(_ view: AnyView) -> some View { - view.background { - Color.black - .ignoresSafeArea() - } - } + @ViewBuilder + func customize(_ view: AnyView) -> some View { + view.background { + Color.black + .ignoresSafeArea() + } + } - init() { - if SessionManager.main.currentLogin != nil { - self.stack = NavigationStack(initial: \MainCoordinator.mainTab) - } else { - self.stack = NavigationStack(initial: \MainCoordinator.serverList) - } + init() { + if SessionManager.main.currentLogin != nil { + self.stack = NavigationStack(initial: \MainCoordinator.mainTab) + } else { + self.stack = NavigationStack(initial: \MainCoordinator.serverList) + } - ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory - DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk + ImageCache.shared.costLimit = 125 * 1024 * 1024 // 125MB memory + DataLoader.sharedUrlCache.diskCapacity = 1000 * 1024 * 1024 // 1000MB disk - // Notification setup for state - Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn)) - Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut)) - } + // Notification setup for state + Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn)) + Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut)) + } - @objc - func didSignIn() { - LogManager.log.info("Received `didSignIn` from NSNotificationCenter.") - root(\.mainTab) - } + @objc + func didSignIn() { + LogManager.log.info("Received `didSignIn` from NSNotificationCenter.") + root(\.mainTab) + } - @objc - func didSignOut() { - LogManager.log.info("Received `didSignOut` from NSNotificationCenter.") - root(\.serverList) - } + @objc + func didSignOut() { + LogManager.log.info("Received `didSignOut` from NSNotificationCenter.") + root(\.serverList) + } - func makeMainTab() -> MainTabCoordinator { - MainTabCoordinator() - } + func makeMainTab() -> MainTabCoordinator { + MainTabCoordinator() + } - func makeServerList() -> NavigationViewCoordinator { - NavigationViewCoordinator(ServerListCoordinator()) - } + func makeServerList() -> NavigationViewCoordinator { + NavigationViewCoordinator(ServerListCoordinator()) + } - func makeLiveTV() -> LiveTVTabCoordinator { - LiveTVTabCoordinator() - } + func makeLiveTV() -> LiveTVTabCoordinator { + LiveTVTabCoordinator() + } } diff --git a/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift b/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift index 122cc829..8a5f0c5c 100644 --- a/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/tvOSMainTabCoordinator.swift @@ -11,80 +11,80 @@ import Stinsen import SwiftUI final class MainTabCoordinator: TabCoordinatable { - var child = TabChild(startingItems: [ - \MainTabCoordinator.home, - \MainTabCoordinator.tv, - \MainTabCoordinator.movies, - \MainTabCoordinator.other, - \MainTabCoordinator.settings, - ]) + var child = TabChild(startingItems: [ + \MainTabCoordinator.home, + \MainTabCoordinator.tv, + \MainTabCoordinator.movies, + \MainTabCoordinator.other, + \MainTabCoordinator.settings, + ]) - @Route(tabItem: makeHomeTab) - var home = makeHome - @Route(tabItem: makeTvTab) - var tv = makeTv - @Route(tabItem: makeMoviesTab) - var movies = makeMovies - @Route(tabItem: makeOtherTab) - var other = makeOther - @Route(tabItem: makeSettingsTab) - var settings = makeSettings + @Route(tabItem: makeHomeTab) + var home = makeHome + @Route(tabItem: makeTvTab) + var tv = makeTv + @Route(tabItem: makeMoviesTab) + var movies = makeMovies + @Route(tabItem: makeOtherTab) + var other = makeOther + @Route(tabItem: makeSettingsTab) + var settings = makeSettings - func makeHome() -> NavigationViewCoordinator { - NavigationViewCoordinator(HomeCoordinator()) - } + func makeHome() -> NavigationViewCoordinator { + NavigationViewCoordinator(HomeCoordinator()) + } - @ViewBuilder - func makeHomeTab(isActive: Bool) -> some View { - HStack { - Image(systemName: "house") - L10n.home.text - } - } + @ViewBuilder + func makeHomeTab(isActive: Bool) -> some View { + HStack { + Image(systemName: "house") + L10n.home.text + } + } - func makeTv() -> NavigationViewCoordinator { - NavigationViewCoordinator(TVLibrariesCoordinator(viewModel: TVLibrariesViewModel(), title: L10n.tvShows)) - } + func makeTv() -> NavigationViewCoordinator { + NavigationViewCoordinator(TVLibrariesCoordinator(viewModel: TVLibrariesViewModel(), title: L10n.tvShows)) + } - @ViewBuilder - func makeTvTab(isActive: Bool) -> some View { - HStack { - Image(systemName: "tv") - L10n.tvShows.text - } - } + @ViewBuilder + func makeTvTab(isActive: Bool) -> some View { + HStack { + Image(systemName: "tv") + L10n.tvShows.text + } + } - func makeMovies() -> NavigationViewCoordinator { - NavigationViewCoordinator(MovieLibrariesCoordinator(viewModel: MovieLibrariesViewModel(), title: L10n.movies)) - } + func makeMovies() -> NavigationViewCoordinator { + NavigationViewCoordinator(MovieLibrariesCoordinator(viewModel: MovieLibrariesViewModel(), title: L10n.movies)) + } - @ViewBuilder - func makeMoviesTab(isActive: Bool) -> some View { - HStack { - Image(systemName: "film") - L10n.movies.text - } - } + @ViewBuilder + func makeMoviesTab(isActive: Bool) -> some View { + HStack { + Image(systemName: "film") + L10n.movies.text + } + } - func makeOther() -> NavigationViewCoordinator { - NavigationViewCoordinator(LibraryListCoordinator(viewModel: LibraryListViewModel())) - } + func makeOther() -> NavigationViewCoordinator { + NavigationViewCoordinator(LibraryListCoordinator(viewModel: LibraryListViewModel())) + } - @ViewBuilder - func makeOtherTab(isActive: Bool) -> some View { - HStack { - Image(systemName: "folder") - L10n.other.text - } - } + @ViewBuilder + func makeOtherTab(isActive: Bool) -> some View { + HStack { + Image(systemName: "folder") + L10n.other.text + } + } - func makeSettings() -> NavigationViewCoordinator { - NavigationViewCoordinator(SettingsCoordinator()) - } + func makeSettings() -> NavigationViewCoordinator { + NavigationViewCoordinator(SettingsCoordinator()) + } - @ViewBuilder - func makeSettingsTab(isActive: Bool) -> some View { - Image(systemName: "gearshape.fill") - .accessibilityLabel(L10n.settings) - } + @ViewBuilder + func makeSettingsTab(isActive: Bool) -> some View { + Image(systemName: "gearshape.fill") + .accessibilityLabel(L10n.settings) + } } diff --git a/Shared/Coordinators/MoviesLibrariesCoordinator.swift b/Shared/Coordinators/MoviesLibrariesCoordinator.swift index 6e30392f..22f53ca3 100644 --- a/Shared/Coordinators/MoviesLibrariesCoordinator.swift +++ b/Shared/Coordinators/MoviesLibrariesCoordinator.swift @@ -13,33 +13,33 @@ import SwiftUI final class MovieLibrariesCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \MovieLibrariesCoordinator.start) + let stack = NavigationStack(initial: \MovieLibrariesCoordinator.start) - @Root - var start = makeStart - @Root - var rootLibrary = makeRootLibrary - @Route(.push) - var library = makeLibrary + @Root + var start = makeStart + @Root + var rootLibrary = makeRootLibrary + @Route(.push) + var library = makeLibrary - let viewModel: MovieLibrariesViewModel - let title: String + let viewModel: MovieLibrariesViewModel + let title: String - init(viewModel: MovieLibrariesViewModel, title: String) { - self.viewModel = viewModel - self.title = title - } + init(viewModel: MovieLibrariesViewModel, title: String) { + self.viewModel = viewModel + self.title = title + } - @ViewBuilder - func makeStart() -> some View { - MovieLibrariesView(viewModel: self.viewModel, title: title) - } + @ViewBuilder + func makeStart() -> some View { + MovieLibrariesView(viewModel: self.viewModel, title: title) + } - func makeLibrary(library: BaseItemDto) -> LibraryCoordinator { - LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title) - } + func makeLibrary(library: BaseItemDto) -> LibraryCoordinator { + LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title) + } - func makeRootLibrary(library: BaseItemDto) -> LibraryCoordinator { - LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title) - } + func makeRootLibrary(library: BaseItemDto) -> LibraryCoordinator { + LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title) + } } diff --git a/Shared/Coordinators/SearchCoordinator.swift b/Shared/Coordinators/SearchCoordinator.swift index 7a8160f4..6e264f7b 100644 --- a/Shared/Coordinators/SearchCoordinator.swift +++ b/Shared/Coordinators/SearchCoordinator.swift @@ -13,25 +13,25 @@ import SwiftUI final class SearchCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \SearchCoordinator.start) + let stack = NavigationStack(initial: \SearchCoordinator.start) - @Root - var start = makeStart - @Route(.push) - var item = makeItem + @Root + var start = makeStart + @Route(.push) + var item = makeItem - let viewModel: LibrarySearchViewModel + let viewModel: LibrarySearchViewModel - init(viewModel: LibrarySearchViewModel) { - self.viewModel = viewModel - } + init(viewModel: LibrarySearchViewModel) { + self.viewModel = viewModel + } - func makeItem(item: BaseItemDto) -> ItemCoordinator { - ItemCoordinator(item: item) - } + func makeItem(item: BaseItemDto) -> ItemCoordinator { + ItemCoordinator(item: item) + } - @ViewBuilder - func makeStart() -> some View { - LibrarySearchView(viewModel: self.viewModel) - } + @ViewBuilder + func makeStart() -> some View { + LibrarySearchView(viewModel: self.viewModel) + } } diff --git a/Shared/Coordinators/ServerDetailCoordinator.swift b/Shared/Coordinators/ServerDetailCoordinator.swift index 6b16cba0..60f59b61 100644 --- a/Shared/Coordinators/ServerDetailCoordinator.swift +++ b/Shared/Coordinators/ServerDetailCoordinator.swift @@ -12,19 +12,19 @@ import SwiftUI final class ServerDetailCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \ServerDetailCoordinator.start) + let stack = NavigationStack(initial: \ServerDetailCoordinator.start) - @Root - var start = makeStart + @Root + var start = makeStart - let viewModel: ServerDetailViewModel + let viewModel: ServerDetailViewModel - init(viewModel: ServerDetailViewModel) { - self.viewModel = viewModel - } + init(viewModel: ServerDetailViewModel) { + self.viewModel = viewModel + } - @ViewBuilder - func makeStart() -> some View { - ServerDetailView(viewModel: viewModel) - } + @ViewBuilder + func makeStart() -> some View { + ServerDetailView(viewModel: viewModel) + } } diff --git a/Shared/Coordinators/ServerListCoordinator.swift b/Shared/Coordinators/ServerListCoordinator.swift index 94a444f6..fea08265 100644 --- a/Shared/Coordinators/ServerListCoordinator.swift +++ b/Shared/Coordinators/ServerListCoordinator.swift @@ -12,31 +12,31 @@ import SwiftUI final class ServerListCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \ServerListCoordinator.start) + let stack = NavigationStack(initial: \ServerListCoordinator.start) - @Root - var start = makeStart - @Route(.push) - var connectToServer = makeConnectToServer - @Route(.push) - var userList = makeUserList - @Route(.modal) - var basicAppSettings = makeBasicAppSettings + @Root + var start = makeStart + @Route(.push) + var connectToServer = makeConnectToServer + @Route(.push) + var userList = makeUserList + @Route(.modal) + var basicAppSettings = makeBasicAppSettings - func makeConnectToServer() -> ConnectToServerCoodinator { - ConnectToServerCoodinator() - } + func makeConnectToServer() -> ConnectToServerCoodinator { + ConnectToServerCoodinator() + } - func makeUserList(server: SwiftfinStore.State.Server) -> UserListCoordinator { - UserListCoordinator(viewModel: .init(server: server)) - } + func makeUserList(server: SwiftfinStore.State.Server) -> UserListCoordinator { + UserListCoordinator(viewModel: .init(server: server)) + } - func makeBasicAppSettings() -> NavigationViewCoordinator { - NavigationViewCoordinator(BasicAppSettingsCoordinator()) - } + func makeBasicAppSettings() -> NavigationViewCoordinator { + NavigationViewCoordinator(BasicAppSettingsCoordinator()) + } - @ViewBuilder - func makeStart() -> some View { - ServerListView(viewModel: ServerListViewModel()) - } + @ViewBuilder + func makeStart() -> some View { + ServerListView(viewModel: ServerListViewModel()) + } } diff --git a/Shared/Coordinators/SettingsCoordinator.swift b/Shared/Coordinators/SettingsCoordinator.swift index d1c8b037..f540f219 100644 --- a/Shared/Coordinators/SettingsCoordinator.swift +++ b/Shared/Coordinators/SettingsCoordinator.swift @@ -12,70 +12,70 @@ import SwiftUI final class SettingsCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \SettingsCoordinator.start) + let stack = NavigationStack(initial: \SettingsCoordinator.start) - @Root - var start = makeStart - @Route(.push) - var serverDetail = makeServerDetail - @Route(.push) - var overlaySettings = makeOverlaySettings - @Route(.push) - var experimentalSettings = makeExperimentalSettings - @Route(.push) - var customizeViewsSettings = makeCustomizeViewsSettings - @Route(.push) - var missingSettings = makeMissingSettings - @Route(.push) - var about = makeAbout + @Root + var start = makeStart + @Route(.push) + var serverDetail = makeServerDetail + @Route(.push) + var overlaySettings = makeOverlaySettings + @Route(.push) + var experimentalSettings = makeExperimentalSettings + @Route(.push) + var customizeViewsSettings = makeCustomizeViewsSettings + @Route(.push) + var missingSettings = makeMissingSettings + @Route(.push) + var about = makeAbout - #if !os(tvOS) - @Route(.push) - var quickConnect = makeQuickConnectSettings - #endif + #if !os(tvOS) + @Route(.push) + var quickConnect = makeQuickConnectSettings + #endif - @ViewBuilder - func makeServerDetail() -> some View { - let viewModel = ServerDetailViewModel(server: SessionManager.main.currentLogin.server) - ServerDetailView(viewModel: viewModel) - } + @ViewBuilder + func makeServerDetail() -> some View { + let viewModel = ServerDetailViewModel(server: SessionManager.main.currentLogin.server) + ServerDetailView(viewModel: viewModel) + } - @ViewBuilder - func makeOverlaySettings() -> some View { - OverlaySettingsView() - } + @ViewBuilder + func makeOverlaySettings() -> some View { + OverlaySettingsView() + } - @ViewBuilder - func makeExperimentalSettings() -> some View { - ExperimentalSettingsView() - } + @ViewBuilder + func makeExperimentalSettings() -> some View { + ExperimentalSettingsView() + } - @ViewBuilder - func makeCustomizeViewsSettings() -> some View { - CustomizeViewsSettings() - } + @ViewBuilder + func makeCustomizeViewsSettings() -> some View { + CustomizeViewsSettings() + } - @ViewBuilder - func makeMissingSettings() -> some View { - MissingItemsSettingsView() - } + @ViewBuilder + func makeMissingSettings() -> some View { + MissingItemsSettingsView() + } - @ViewBuilder - func makeAbout() -> some View { - AboutView() - } + @ViewBuilder + func makeAbout() -> some View { + AboutView() + } - #if !os(tvOS) - @ViewBuilder - func makeQuickConnectSettings() -> some View { - let viewModel = QuickConnectSettingsViewModel() - QuickConnectSettingsView(viewModel: viewModel) - } - #endif + #if !os(tvOS) + @ViewBuilder + func makeQuickConnectSettings() -> some View { + let viewModel = QuickConnectSettingsViewModel() + QuickConnectSettingsView(viewModel: viewModel) + } + #endif - @ViewBuilder - func makeStart() -> some View { - let viewModel = SettingsViewModel(server: SessionManager.main.currentLogin.server, user: SessionManager.main.currentLogin.user) - SettingsView(viewModel: viewModel) - } + @ViewBuilder + func makeStart() -> some View { + let viewModel = SettingsViewModel(server: SessionManager.main.currentLogin.server, user: SessionManager.main.currentLogin.user) + SettingsView(viewModel: viewModel) + } } diff --git a/Shared/Coordinators/TVLibrariesCoordinator.swift b/Shared/Coordinators/TVLibrariesCoordinator.swift index 4c6e63a7..28f3ddaa 100644 --- a/Shared/Coordinators/TVLibrariesCoordinator.swift +++ b/Shared/Coordinators/TVLibrariesCoordinator.swift @@ -13,33 +13,33 @@ import SwiftUI final class TVLibrariesCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \TVLibrariesCoordinator.start) + let stack = NavigationStack(initial: \TVLibrariesCoordinator.start) - @Root - var start = makeStart - @Root - var rootLibrary = makeRootLibrary - @Route(.push) - var library = makeLibrary + @Root + var start = makeStart + @Root + var rootLibrary = makeRootLibrary + @Route(.push) + var library = makeLibrary - let viewModel: TVLibrariesViewModel - let title: String + let viewModel: TVLibrariesViewModel + let title: String - init(viewModel: TVLibrariesViewModel, title: String) { - self.viewModel = viewModel - self.title = title - } + init(viewModel: TVLibrariesViewModel, title: String) { + self.viewModel = viewModel + self.title = title + } - @ViewBuilder - func makeStart() -> some View { - TVLibrariesView(viewModel: self.viewModel, title: title) - } + @ViewBuilder + func makeStart() -> some View { + TVLibrariesView(viewModel: self.viewModel, title: title) + } - func makeLibrary(library: BaseItemDto) -> LibraryCoordinator { - LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title) - } + func makeLibrary(library: BaseItemDto) -> LibraryCoordinator { + LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title) + } - func makeRootLibrary(library: BaseItemDto) -> LibraryCoordinator { - LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title) - } + func makeRootLibrary(library: BaseItemDto) -> LibraryCoordinator { + LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title) + } } diff --git a/Shared/Coordinators/UserListCoordinator.swift b/Shared/Coordinators/UserListCoordinator.swift index 2a08f9fa..99b40f2d 100644 --- a/Shared/Coordinators/UserListCoordinator.swift +++ b/Shared/Coordinators/UserListCoordinator.swift @@ -12,31 +12,31 @@ import SwiftUI final class UserListCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \UserListCoordinator.start) + let stack = NavigationStack(initial: \UserListCoordinator.start) - @Root - var start = makeStart - @Route(.push) - var userSignIn = makeUserSignIn - @Route(.push) - var serverDetail = makeServerDetail + @Root + var start = makeStart + @Route(.push) + var userSignIn = makeUserSignIn + @Route(.push) + var serverDetail = makeServerDetail - let viewModel: UserListViewModel + let viewModel: UserListViewModel - init(viewModel: UserListViewModel) { - self.viewModel = viewModel - } + init(viewModel: UserListViewModel) { + self.viewModel = viewModel + } - func makeUserSignIn(server: SwiftfinStore.State.Server) -> UserSignInCoordinator { - UserSignInCoordinator(viewModel: .init(server: server)) - } + func makeUserSignIn(server: SwiftfinStore.State.Server) -> UserSignInCoordinator { + UserSignInCoordinator(viewModel: .init(server: server)) + } - func makeServerDetail(server: SwiftfinStore.State.Server) -> ServerDetailCoordinator { - ServerDetailCoordinator(viewModel: .init(server: server)) - } + func makeServerDetail(server: SwiftfinStore.State.Server) -> ServerDetailCoordinator { + ServerDetailCoordinator(viewModel: .init(server: server)) + } - @ViewBuilder - func makeStart() -> some View { - UserListView(viewModel: viewModel) - } + @ViewBuilder + func makeStart() -> some View { + UserListView(viewModel: viewModel) + } } diff --git a/Shared/Coordinators/UserSignInCoordinator.swift b/Shared/Coordinators/UserSignInCoordinator.swift index c47dad49..5f82964c 100644 --- a/Shared/Coordinators/UserSignInCoordinator.swift +++ b/Shared/Coordinators/UserSignInCoordinator.swift @@ -12,19 +12,19 @@ import SwiftUI final class UserSignInCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \UserSignInCoordinator.start) + let stack = NavigationStack(initial: \UserSignInCoordinator.start) - @Root - var start = makeStart + @Root + var start = makeStart - let viewModel: UserSignInViewModel + let viewModel: UserSignInViewModel - init(viewModel: UserSignInViewModel) { - self.viewModel = viewModel - } + init(viewModel: UserSignInViewModel) { + self.viewModel = viewModel + } - @ViewBuilder - func makeStart() -> some View { - UserSignInView(viewModel: viewModel) - } + @ViewBuilder + func makeStart() -> some View { + UserSignInView(viewModel: viewModel) + } } diff --git a/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift index 6c94547f..85e641c6 100644 --- a/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift @@ -14,27 +14,27 @@ import SwiftUI final class LiveTVVideoPlayerCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \LiveTVVideoPlayerCoordinator.start) + let stack = NavigationStack(initial: \LiveTVVideoPlayerCoordinator.start) - @Root - var start = makeStart + @Root + var start = makeStart - let viewModel: VideoPlayerViewModel + let viewModel: VideoPlayerViewModel - init(viewModel: VideoPlayerViewModel) { - self.viewModel = viewModel - } + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + } - @ViewBuilder - func makeStart() -> some View { - if Defaults[.Experimental.liveTVNativePlayer] { - LiveTVNativePlayerView(viewModel: viewModel) - .navigationBarHidden(true) - .ignoresSafeArea() - } else { - LiveTVPlayerView(viewModel: viewModel) - .navigationBarHidden(true) - .ignoresSafeArea() - } - } + @ViewBuilder + func makeStart() -> some View { + if Defaults[.Experimental.liveTVNativePlayer] { + LiveTVNativePlayerView(viewModel: viewModel) + .navigationBarHidden(true) + .ignoresSafeArea() + } else { + LiveTVPlayerView(viewModel: viewModel) + .navigationBarHidden(true) + .ignoresSafeArea() + } + } } diff --git a/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift index 94efc82b..cb9d2725 100644 --- a/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerCoordinator/iOSVideoPlayerCoordinator.swift @@ -14,35 +14,35 @@ import SwiftUI final class VideoPlayerCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \VideoPlayerCoordinator.start) + let stack = NavigationStack(initial: \VideoPlayerCoordinator.start) - @Root - var start = makeStart + @Root + var start = makeStart - let viewModel: VideoPlayerViewModel + let viewModel: VideoPlayerViewModel - init(viewModel: VideoPlayerViewModel) { - self.viewModel = viewModel - } + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + } - @ViewBuilder - func makeStart() -> some View { - PreferenceUIHostingControllerView { - if Defaults[.Experimental.nativePlayer] { - NativePlayerView(viewModel: self.viewModel) - .navigationBarHidden(true) - .statusBar(hidden: true) - .ignoresSafeArea() - .prefersHomeIndicatorAutoHidden(true) - .supportedOrientations(UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape) - } else { - VLCPlayerView(viewModel: self.viewModel) - .navigationBarHidden(true) - .statusBar(hidden: true) - .ignoresSafeArea() - .prefersHomeIndicatorAutoHidden(true) - .supportedOrientations(UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape) - } - }.ignoresSafeArea() - } + @ViewBuilder + func makeStart() -> some View { + PreferenceUIHostingControllerView { + if Defaults[.Experimental.nativePlayer] { + NativePlayerView(viewModel: self.viewModel) + .navigationBarHidden(true) + .statusBar(hidden: true) + .ignoresSafeArea() + .prefersHomeIndicatorAutoHidden(true) + .supportedOrientations(UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape) + } else { + VLCPlayerView(viewModel: self.viewModel) + .navigationBarHidden(true) + .statusBar(hidden: true) + .ignoresSafeArea() + .prefersHomeIndicatorAutoHidden(true) + .supportedOrientations(UIDevice.current.userInterfaceIdiom == .pad ? .all : .landscape) + } + }.ignoresSafeArea() + } } diff --git a/Shared/Coordinators/VideoPlayerCoordinator/tvOSLiveTVVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/tvOSLiveTVVideoPlayerCoordinator.swift index 0e1247ec..593cdd28 100644 --- a/Shared/Coordinators/VideoPlayerCoordinator/tvOSLiveTVVideoPlayerCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerCoordinator/tvOSLiveTVVideoPlayerCoordinator.swift @@ -14,27 +14,27 @@ import SwiftUI final class LiveTVVideoPlayerCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \LiveTVVideoPlayerCoordinator.start) + let stack = NavigationStack(initial: \LiveTVVideoPlayerCoordinator.start) - @Root - var start = makeStart + @Root + var start = makeStart - let viewModel: VideoPlayerViewModel + let viewModel: VideoPlayerViewModel - init(viewModel: VideoPlayerViewModel) { - self.viewModel = viewModel - } + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + } - @ViewBuilder - func makeStart() -> some View { - if Defaults[.Experimental.liveTVNativePlayer] { - LiveTVNativeVideoPlayerView(viewModel: viewModel) - .navigationBarHidden(true) - .ignoresSafeArea() - } else { - LiveTVVideoPlayerView(viewModel: viewModel) - .navigationBarHidden(true) - .ignoresSafeArea() - } - } + @ViewBuilder + func makeStart() -> some View { + if Defaults[.Experimental.liveTVNativePlayer] { + LiveTVNativeVideoPlayerView(viewModel: viewModel) + .navigationBarHidden(true) + .ignoresSafeArea() + } else { + LiveTVVideoPlayerView(viewModel: viewModel) + .navigationBarHidden(true) + .ignoresSafeArea() + } + } } diff --git a/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift index 97136ed7..b7d1c82c 100644 --- a/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerCoordinator/tvOSVideoPlayerCoordinator.swift @@ -14,27 +14,27 @@ import SwiftUI final class VideoPlayerCoordinator: NavigationCoordinatable { - let stack = NavigationStack(initial: \VideoPlayerCoordinator.start) + let stack = NavigationStack(initial: \VideoPlayerCoordinator.start) - @Root - var start = makeStart + @Root + var start = makeStart - let viewModel: VideoPlayerViewModel + let viewModel: VideoPlayerViewModel - init(viewModel: VideoPlayerViewModel) { - self.viewModel = viewModel - } + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + } - @ViewBuilder - func makeStart() -> some View { - if Defaults[.Experimental.nativePlayer] { - NativePlayerView(viewModel: viewModel) - .navigationBarHidden(true) - .ignoresSafeArea() - } else { - VLCPlayerView(viewModel: viewModel) - .navigationBarHidden(true) - .ignoresSafeArea() - } - } + @ViewBuilder + func makeStart() -> some View { + if Defaults[.Experimental.nativePlayer] { + NativePlayerView(viewModel: viewModel) + .navigationBarHidden(true) + .ignoresSafeArea() + } else { + VLCPlayerView(viewModel: viewModel) + .navigationBarHidden(true) + .ignoresSafeArea() + } + } } diff --git a/Shared/Errors/ErrorMessage.swift b/Shared/Errors/ErrorMessage.swift index 75b72f5b..5c818ca5 100644 --- a/Shared/Errors/ErrorMessage.swift +++ b/Shared/Errors/ErrorMessage.swift @@ -11,21 +11,21 @@ import JellyfinAPI struct ErrorMessage: Identifiable { - let code: Int - let title: String - let message: String + let code: Int + let title: String + let message: String - // Chosen value such that if an error has this code, don't show the code to the UI - // This was chosen because of its unlikelyhood to ever be used - static let noShowErrorCode = -69420 + // Chosen value such that if an error has this code, don't show the code to the UI + // This was chosen because of its unlikelyhood to ever be used + static let noShowErrorCode = -69420 - var id: String { - "\(code)\(title)\(message)" - } + var id: String { + "\(code)\(title)\(message)" + } - init(code: Int, title: String, message: String) { - self.code = code - self.title = title - self.message = message - } + init(code: Int, title: String, message: String) { + self.code = code + self.title = title + self.message = message + } } diff --git a/Shared/Errors/NetworkError.swift b/Shared/Errors/NetworkError.swift index f2e91809..6794de30 100644 --- a/Shared/Errors/NetworkError.swift +++ b/Shared/Errors/NetworkError.swift @@ -11,90 +11,104 @@ import JellyfinAPI enum NetworkError: Error { - /// For the case that the ErrorResponse object has a code of -1 - case URLError(response: ErrorResponse, displayMessage: String?) + /// For the case that the ErrorResponse object has a code of -1 + case URLError(response: ErrorResponse, displayMessage: String?) - /// For the case that the ErrorRespones object has a code of -2 - case HTTPURLError(response: ErrorResponse, displayMessage: String?) + /// For the case that the ErrorRespones object has a code of -2 + case HTTPURLError(response: ErrorResponse, displayMessage: String?) - /// For the case that the ErrorResponse object has a positive code - case JellyfinError(response: ErrorResponse, displayMessage: String?) + /// For the case that the ErrorResponse object has a positive code + case JellyfinError(response: ErrorResponse, displayMessage: String?) - var errorMessage: ErrorMessage { - switch self { - case let .URLError(response, displayMessage): - return NetworkError.parseURLError(from: response, displayMessage: displayMessage) - case let .HTTPURLError(response, displayMessage): - return NetworkError.parseHTTPURLError(from: response, displayMessage: displayMessage) - case let .JellyfinError(response, displayMessage): - return NetworkError.parseJellyfinError(from: response, displayMessage: displayMessage) - } - } + var errorMessage: ErrorMessage { + switch self { + case let .URLError(response, displayMessage): + return NetworkError.parseURLError(from: response, displayMessage: displayMessage) + case let .HTTPURLError(response, displayMessage): + return NetworkError.parseHTTPURLError(from: response, displayMessage: displayMessage) + case let .JellyfinError(response, displayMessage): + return NetworkError.parseJellyfinError(from: response, displayMessage: displayMessage) + } + } - private static func parseURLError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage { - let errorMessage: ErrorMessage + private static func parseURLError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage { + let errorMessage: ErrorMessage - switch response { - case let .error(_, _, _, err): + switch response { + case let .error(_, _, _, err): - // Code references: - // https://developer.apple.com/documentation/foundation/1508628-url_loading_system_error_codes - switch err._code { - case -1001: - errorMessage = ErrorMessage(code: err._code, - title: L10n.error, - message: L10n.networkTimedOut) - case -1003: - errorMessage = ErrorMessage(code: err._code, - title: L10n.error, - message: L10n.unableToFindHost) - case -1004: - errorMessage = ErrorMessage(code: err._code, - title: L10n.error, - message: L10n.cannotConnectToHost) - default: - errorMessage = ErrorMessage(code: err._code, - title: L10n.error, - message: L10n.unknownError) - } - } + // Code references: + // https://developer.apple.com/documentation/foundation/1508628-url_loading_system_error_codes + switch err._code { + case -1001: + errorMessage = ErrorMessage( + code: err._code, + title: L10n.error, + message: L10n.networkTimedOut + ) + case -1003: + errorMessage = ErrorMessage( + code: err._code, + title: L10n.error, + message: L10n.unableToFindHost + ) + case -1004: + errorMessage = ErrorMessage( + code: err._code, + title: L10n.error, + message: L10n.cannotConnectToHost + ) + default: + errorMessage = ErrorMessage( + code: err._code, + title: L10n.error, + message: L10n.unknownError + ) + } + } - return errorMessage - } + return errorMessage + } - private static func parseHTTPURLError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage { - let errorMessage: ErrorMessage + private static func parseHTTPURLError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage { + let errorMessage: ErrorMessage - // Not implemented as has not run into one of these errors as time of writing - switch response { - case .error: - errorMessage = ErrorMessage(code: 0, - title: L10n.error, - message: "An HTTP URL error has occurred") - } + // Not implemented as has not run into one of these errors as time of writing + switch response { + case .error: + errorMessage = ErrorMessage( + code: 0, + title: L10n.error, + message: "An HTTP URL error has occurred" + ) + } - return errorMessage - } + return errorMessage + } - private static func parseJellyfinError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage { - let errorMessage: ErrorMessage + private static func parseJellyfinError(from response: ErrorResponse, displayMessage: String?) -> ErrorMessage { + let errorMessage: ErrorMessage - switch response { - case let .error(code, _, _, _): + switch response { + case let .error(code, _, _, _): - // Generic HTTP status codes - switch code { - case 401: - errorMessage = ErrorMessage(code: code, - title: L10n.unauthorized, - message: L10n.unauthorizedUser) - default: - errorMessage = ErrorMessage(code: code, - title: L10n.error, - message: displayMessage ?? L10n.unknownError) - } - } + // Generic HTTP status codes + switch code { + case 401: + errorMessage = ErrorMessage( + code: code, + title: L10n.unauthorized, + message: L10n.unauthorizedUser + ) + default: + errorMessage = ErrorMessage( + code: code, + title: L10n.error, + message: displayMessage ?? L10n.unknownError + ) + } + } - return errorMessage - } + return errorMessage + } } diff --git a/Shared/Extensions/BlurHashDecode.swift b/Shared/Extensions/BlurHashDecode.swift index 2e8513fb..925c8b21 100644 --- a/Shared/Extensions/BlurHashDecode.swift +++ b/Shared/Extensions/BlurHashDecode.swift @@ -11,143 +11,155 @@ import UIKit // https://github.com/woltapp/blurhash/tree/master/Swift public extension UIImage { - convenience init?(blurHash: String, size: CGSize, punch: Float = 1) { - guard blurHash.count >= 6 else { return nil } + convenience init?(blurHash: String, size: CGSize, punch: Float = 1) { + guard blurHash.count >= 6 else { return nil } - let sizeFlag = String(blurHash[0]).decode83() - let numY = (sizeFlag / 9) + 1 - let numX = (sizeFlag % 9) + 1 + let sizeFlag = String(blurHash[0]).decode83() + let numY = (sizeFlag / 9) + 1 + let numX = (sizeFlag % 9) + 1 - let quantisedMaximumValue = String(blurHash[1]).decode83() - let maximumValue = Float(quantisedMaximumValue + 1) / 166 + let quantisedMaximumValue = String(blurHash[1]).decode83() + let maximumValue = Float(quantisedMaximumValue + 1) / 166 - guard blurHash.count == 4 + 2 * numX * numY else { return nil } + guard blurHash.count == 4 + 2 * numX * numY else { return nil } - let colours: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in - if i == 0 { - let value = String(blurHash[2 ..< 6]).decode83() - return decodeDC(value) - } else { - let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83() - return decodeAC(value, maximumValue: maximumValue * punch) - } - } + let colours: [(Float, Float, Float)] = (0 ..< numX * numY).map { i in + if i == 0 { + let value = String(blurHash[2 ..< 6]).decode83() + return decodeDC(value) + } else { + let value = String(blurHash[4 + i * 2 ..< 4 + i * 2 + 2]).decode83() + return decodeAC(value, maximumValue: maximumValue * punch) + } + } - let width = Int(size.width) - let height = Int(size.height) - let bytesPerRow = width * 3 - guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil } - CFDataSetLength(data, bytesPerRow * height) - guard let pixels = CFDataGetMutableBytePtr(data) else { return nil } + let width = Int(size.width) + let height = Int(size.height) + let bytesPerRow = width * 3 + guard let data = CFDataCreateMutable(kCFAllocatorDefault, bytesPerRow * height) else { return nil } + CFDataSetLength(data, bytesPerRow * height) + guard let pixels = CFDataGetMutableBytePtr(data) else { return nil } - for y in 0 ..< height { - for x in 0 ..< width { - var r: Float = 0 - var g: Float = 0 - var b: Float = 0 + for y in 0 ..< height { + for x in 0 ..< width { + var r: Float = 0 + var g: Float = 0 + var b: Float = 0 - for j in 0 ..< numY { - for i in 0 ..< numX { - let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height)) - let colour = colours[i + j * numX] - r += colour.0 * basis - g += colour.1 * basis - b += colour.2 * basis - } - } + for j in 0 ..< numY { + for i in 0 ..< numX { + let basis = cos(Float.pi * Float(x) * Float(i) / Float(width)) * cos(Float.pi * Float(y) * Float(j) / Float(height)) + let colour = colours[i + j * numX] + r += colour.0 * basis + g += colour.1 * basis + b += colour.2 * basis + } + } - let intR = UInt8(linearTosRGB(r)) - let intG = UInt8(linearTosRGB(g)) - let intB = UInt8(linearTosRGB(b)) + let intR = UInt8(linearTosRGB(r)) + let intG = UInt8(linearTosRGB(g)) + let intB = UInt8(linearTosRGB(b)) - pixels[3 * x + 0 + y * bytesPerRow] = intR - pixels[3 * x + 1 + y * bytesPerRow] = intG - pixels[3 * x + 2 + y * bytesPerRow] = intB - } - } + pixels[3 * x + 0 + y * bytesPerRow] = intR + pixels[3 * x + 1 + y * bytesPerRow] = intG + pixels[3 * x + 2 + y * bytesPerRow] = intB + } + } - let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue) + let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue) - guard let provider = CGDataProvider(data: data) else { return nil } - guard let cgImage = CGImage(width: width, height: height, bitsPerComponent: 8, bitsPerPixel: 24, bytesPerRow: bytesPerRow, - space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: bitmapInfo, provider: provider, decode: nil, - shouldInterpolate: true, intent: .defaultIntent) else { return nil } + guard let provider = CGDataProvider(data: data) else { return nil } + guard let cgImage = CGImage( + width: width, + height: height, + bitsPerComponent: 8, + bitsPerPixel: 24, + bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: bitmapInfo, + provider: provider, + decode: nil, + shouldInterpolate: true, + intent: .defaultIntent + ) else { return nil } - self.init(cgImage: cgImage) - } + self.init(cgImage: cgImage) + } } private func decodeDC(_ value: Int) -> (Float, Float, Float) { - let intR = value >> 16 - let intG = (value >> 8) & 255 - let intB = value & 255 - return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB)) + let intR = value >> 16 + let intG = (value >> 8) & 255 + let intB = value & 255 + return (sRGBToLinear(intR), sRGBToLinear(intG), sRGBToLinear(intB)) } private func decodeAC(_ value: Int, maximumValue: Float) -> (Float, Float, Float) { - let quantR = value / (19 * 19) - let quantG = (value / 19) % 19 - let quantB = value % 19 + let quantR = value / (19 * 19) + let quantG = (value / 19) % 19 + let quantB = value % 19 - let rgb = (signPow((Float(quantR) - 9) / 9, 2) * maximumValue, - signPow((Float(quantG) - 9) / 9, 2) * maximumValue, - signPow((Float(quantB) - 9) / 9, 2) * maximumValue) + let rgb = ( + signPow((Float(quantR) - 9) / 9, 2) * maximumValue, + signPow((Float(quantG) - 9) / 9, 2) * maximumValue, + signPow((Float(quantB) - 9) / 9, 2) * maximumValue + ) - return rgb + return rgb } private func signPow(_ value: Float, _ exp: Float) -> Float { - copysign(pow(abs(value), exp), value) + copysign(pow(abs(value), exp), value) } private func linearTosRGB(_ value: Float) -> Int { - let v = max(0, min(1, value)) - if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) } else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) } + let v = max(0, min(1, value)) + if v <= 0.0031308 { return Int(v * 12.92 * 255 + 0.5) } else { return Int((1.055 * pow(v, 1 / 2.4) - 0.055) * 255 + 0.5) } } private func sRGBToLinear(_ value: Type) -> Float { - let v = Float(Int64(value)) / 255 - if v <= 0.04045 { return v / 12.92 } else { return pow((v + 0.055) / 1.055, 2.4) } + let v = Float(Int64(value)) / 255 + if v <= 0.04045 { return v / 12.92 } else { return pow((v + 0.055) / 1.055, 2.4) } } private let encodeCharacters: [String] = { - "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) } + "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~".map { String($0) } }() private let decodeCharacters: [String: Int] = { - var dict: [String: Int] = [:] - for (index, character) in encodeCharacters.enumerated() { - dict[character] = index - } - return dict + var dict: [String: Int] = [:] + for (index, character) in encodeCharacters.enumerated() { + dict[character] = index + } + return dict }() extension String { - func decode83() -> Int { - var value: Int = 0 - for character in self { - if let digit = decodeCharacters[String(character)] { - value = value * 83 + digit - } - } - return value - } + func decode83() -> Int { + var value: Int = 0 + for character in self { + if let digit = decodeCharacters[String(character)] { + value = value * 83 + digit + } + } + return value + } } private extension String { - subscript(offset: Int) -> Character { - self[index(startIndex, offsetBy: offset)] - } + subscript(offset: Int) -> Character { + self[index(startIndex, offsetBy: offset)] + } - subscript(bounds: CountableClosedRange) -> Substring { - let start = index(startIndex, offsetBy: bounds.lowerBound) - let end = index(startIndex, offsetBy: bounds.upperBound) - return self[start ... end] - } + subscript(bounds: CountableClosedRange) -> Substring { + let start = index(startIndex, offsetBy: bounds.lowerBound) + let end = index(startIndex, offsetBy: bounds.upperBound) + return self[start ... end] + } - subscript(bounds: CountableRange) -> Substring { - let start = index(startIndex, offsetBy: bounds.lowerBound) - let end = index(startIndex, offsetBy: bounds.upperBound) - return self[start ..< end] - } + subscript(bounds: CountableRange) -> Substring { + let start = index(startIndex, offsetBy: bounds.lowerBound) + let end = index(startIndex, offsetBy: bounds.upperBound) + return self[start ..< end] + } } diff --git a/Shared/Extensions/BundleExtensions.swift b/Shared/Extensions/BundleExtensions.swift index d6b8916e..16380fb6 100644 --- a/Shared/Extensions/BundleExtensions.swift +++ b/Shared/Extensions/BundleExtensions.swift @@ -9,12 +9,12 @@ import Foundation extension Bundle { - var iconFileName: String? { - guard let icons = infoDictionary?["CFBundleIcons"] as? [String: Any], - let primaryIcon = icons["CFBundlePrimaryIcon"] as? [String: Any], - let iconFiles = primaryIcon["CFBundleIconFiles"] as? [String], - let iconFileName = iconFiles.last - else { return nil } - return iconFileName - } + var iconFileName: String? { + guard let icons = infoDictionary?["CFBundleIcons"] as? [String: Any], + let primaryIcon = icons["CFBundlePrimaryIcon"] as? [String: Any], + let iconFiles = primaryIcon["CFBundleIconFiles"] as? [String], + let iconFileName = iconFiles.last + else { return nil } + return iconFileName + } } diff --git a/Shared/Extensions/CGSizeExtensions.swift b/Shared/Extensions/CGSizeExtensions.swift index 664eb5d0..952d0567 100644 --- a/Shared/Extensions/CGSizeExtensions.swift +++ b/Shared/Extensions/CGSizeExtensions.swift @@ -10,22 +10,22 @@ import UIKit extension CGSize { - static func Circle(radius: CGFloat) -> CGSize { - CGSize(width: radius, height: radius) - } + static func Circle(radius: CGFloat) -> CGSize { + CGSize(width: radius, height: radius) + } - // From https://gist.github.com/jkosoy/c835fea2c03e76720c77 - static func aspectFill(aspectRatio: CGSize, minimumSize: CGSize) -> CGSize { - var minimumSize = minimumSize - let mW = minimumSize.width / aspectRatio.width - let mH = minimumSize.height / aspectRatio.height + // From https://gist.github.com/jkosoy/c835fea2c03e76720c77 + static func aspectFill(aspectRatio: CGSize, minimumSize: CGSize) -> CGSize { + var minimumSize = minimumSize + let mW = minimumSize.width / aspectRatio.width + let mH = minimumSize.height / aspectRatio.height - if mH > mW { - minimumSize.width = minimumSize.height / aspectRatio.height * aspectRatio.width - } else if mW > mH { - minimumSize.height = minimumSize.width / aspectRatio.width * aspectRatio.height - } + if mH > mW { + minimumSize.width = minimumSize.height / aspectRatio.height * aspectRatio.width + } else if mW > mH { + minimumSize.height = minimumSize.width / aspectRatio.width * aspectRatio.height + } - return minimumSize - } + return minimumSize + } } diff --git a/Shared/Extensions/CollectionExtensions.swift b/Shared/Extensions/CollectionExtensions.swift index 2157aecf..d6d681dd 100644 --- a/Shared/Extensions/CollectionExtensions.swift +++ b/Shared/Extensions/CollectionExtensions.swift @@ -10,14 +10,14 @@ import Foundation public extension Collection { - /// SwifterSwift: Safe protects the array from out of bounds by use of optional. - /// - /// let arr = [1, 2, 3, 4, 5] - /// arr[safe: 1] -> 2 - /// arr[safe: 10] -> nil - /// - /// - Parameter index: index of element to access element. - subscript(safe index: Index) -> Element? { - indices.contains(index) ? self[index] : nil - } + /// SwifterSwift: Safe protects the array from out of bounds by use of optional. + /// + /// let arr = [1, 2, 3, 4, 5] + /// arr[safe: 1] -> 2 + /// arr[safe: 10] -> nil + /// + /// - Parameter index: index of element to access element. + subscript(safe index: Index) -> Element? { + indices.contains(index) ? self[index] : nil + } } diff --git a/Shared/Extensions/ColorExtension.swift b/Shared/Extensions/ColorExtension.swift index 059e9966..558aee22 100644 --- a/Shared/Extensions/ColorExtension.swift +++ b/Shared/Extensions/ColorExtension.swift @@ -10,21 +10,21 @@ import SwiftUI public extension Color { - internal static let jellyfinPurple = Color(uiColor: .jellyfinPurple) + internal static let jellyfinPurple = Color(uiColor: .jellyfinPurple) - #if os(tvOS) // tvOS doesn't have these - static let systemFill = Color(UIColor.white) - static let secondarySystemFill = Color(UIColor.gray) - static let tertiarySystemFill = Color(UIColor.black) - static let lightGray = Color(UIColor.lightGray) - #else - static let systemFill = Color(UIColor.systemFill) - static let systemBackground = Color(UIColor.systemBackground) - static let secondarySystemFill = Color(UIColor.secondarySystemBackground) - static let tertiarySystemFill = Color(UIColor.tertiarySystemBackground) - #endif + #if os(tvOS) // tvOS doesn't have these + static let systemFill = Color(UIColor.white) + static let secondarySystemFill = Color(UIColor.gray) + static let tertiarySystemFill = Color(UIColor.black) + static let lightGray = Color(UIColor.lightGray) + #else + static let systemFill = Color(UIColor.systemFill) + static let systemBackground = Color(UIColor.systemBackground) + static let secondarySystemFill = Color(UIColor.secondarySystemBackground) + static let tertiarySystemFill = Color(UIColor.tertiarySystemBackground) + #endif } extension UIColor { - static let jellyfinPurple = UIColor(red: 172 / 255, green: 92 / 255, blue: 195 / 255, alpha: 1) + static let jellyfinPurple = UIColor(red: 172 / 255, green: 92 / 255, blue: 195 / 255, alpha: 1) } diff --git a/Shared/Extensions/Defaults+Workaround.swift b/Shared/Extensions/Defaults+Workaround.swift index b6162dfa..11bc3b9f 100644 --- a/Shared/Extensions/Defaults+Workaround.swift +++ b/Shared/Extensions/Defaults+Workaround.swift @@ -10,37 +10,37 @@ import Defaults import Foundation public extension Defaults.Serializable where Self: Codable { - static var bridge: Defaults.TopLevelCodableBridge { Defaults.TopLevelCodableBridge() } + static var bridge: Defaults.TopLevelCodableBridge { Defaults.TopLevelCodableBridge() } } public extension Defaults.Serializable where Self: Codable & NSSecureCoding { - static var bridge: Defaults.CodableNSSecureCodingBridge { Defaults.CodableNSSecureCodingBridge() } + static var bridge: Defaults.CodableNSSecureCodingBridge { Defaults.CodableNSSecureCodingBridge() } } public extension Defaults.Serializable where Self: Codable & NSSecureCoding & Defaults.PreferNSSecureCoding { - static var bridge: Defaults.NSSecureCodingBridge { Defaults.NSSecureCodingBridge() } + static var bridge: Defaults.NSSecureCodingBridge { Defaults.NSSecureCodingBridge() } } public extension Defaults.Serializable where Self: Codable & RawRepresentable { - static var bridge: Defaults.RawRepresentableCodableBridge { Defaults.RawRepresentableCodableBridge() } + static var bridge: Defaults.RawRepresentableCodableBridge { Defaults.RawRepresentableCodableBridge() } } public extension Defaults.Serializable where Self: Codable & RawRepresentable & Defaults.PreferRawRepresentable { - static var bridge: Defaults.RawRepresentableBridge { Defaults.RawRepresentableBridge() } + static var bridge: Defaults.RawRepresentableBridge { Defaults.RawRepresentableBridge() } } public extension Defaults.Serializable where Self: RawRepresentable { - static var bridge: Defaults.RawRepresentableBridge { Defaults.RawRepresentableBridge() } + static var bridge: Defaults.RawRepresentableBridge { Defaults.RawRepresentableBridge() } } public extension Defaults.Serializable where Self: NSSecureCoding { - static var bridge: Defaults.NSSecureCodingBridge { Defaults.NSSecureCodingBridge() } + static var bridge: Defaults.NSSecureCodingBridge { Defaults.NSSecureCodingBridge() } } public extension Defaults.CollectionSerializable where Element: Defaults.Serializable { - static var bridge: Defaults.CollectionBridge { Defaults.CollectionBridge() } + static var bridge: Defaults.CollectionBridge { Defaults.CollectionBridge() } } public extension Defaults.SetAlgebraSerializable where Element: Defaults.Serializable & Hashable { - static var bridge: Defaults.SetAlgebraBridge { Defaults.SetAlgebraBridge() } + static var bridge: Defaults.SetAlgebraBridge { Defaults.SetAlgebraBridge() } } diff --git a/Shared/Extensions/DoubleExtensions.swift b/Shared/Extensions/DoubleExtensions.swift index 9231a5eb..391c539f 100644 --- a/Shared/Extensions/DoubleExtensions.swift +++ b/Shared/Extensions/DoubleExtensions.swift @@ -10,13 +10,13 @@ import Foundation extension Double { - func subtract(_ other: Double, floor: Double) -> Double { - var v = self - other + func subtract(_ other: Double, floor: Double) -> Double { + var v = self - other - if v < floor { - v += abs(floor - v) - } + if v < floor { + v += abs(floor - v) + } - return v - } + return v + } } diff --git a/Shared/Extensions/ImageExtensions.swift b/Shared/Extensions/ImageExtensions.swift index ec0f476f..6870fc36 100644 --- a/Shared/Extensions/ImageExtensions.swift +++ b/Shared/Extensions/ImageExtensions.swift @@ -10,13 +10,13 @@ import Foundation import SwiftUI extension Image { - func centerCropped() -> some View { - GeometryReader { geo in - self - .resizable() - .scaledToFill() - .frame(width: geo.size.width, height: geo.size.height) - .clipped() - } - } + func centerCropped() -> some View { + GeometryReader { geo in + self + .resizable() + .scaledToFill() + .frame(width: geo.size.width, height: geo.size.height) + .clipped() + } + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift index 61d8d2f8..c713e11d 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+Stackable.swift @@ -13,53 +13,53 @@ import JellyfinAPI // MARK: PortraitImageStackable extension BaseItemDto: PortraitImageStackable { - public var portraitImageID: String { - id ?? "no id" - } + public var portraitImageID: String { + id ?? "no id" + } - public func imageURLConstructor(maxWidth: Int) -> URL { - switch self.itemType { - case .episode: - return getSeriesPrimaryImage(maxWidth: maxWidth) - default: - return self.getPrimaryImage(maxWidth: maxWidth) - } - } + public func imageURLConstructor(maxWidth: Int) -> URL { + switch self.itemType { + case .episode: + return getSeriesPrimaryImage(maxWidth: maxWidth) + default: + return self.getPrimaryImage(maxWidth: maxWidth) + } + } - public var title: String { - switch self.itemType { - case .episode: - return self.seriesName ?? self.name ?? "" - default: - return self.name ?? "" - } - } + public var title: String { + switch self.itemType { + case .episode: + return self.seriesName ?? self.name ?? "" + default: + return self.name ?? "" + } + } - public var subtitle: String? { - switch self.itemType { - case .episode: - return getEpisodeLocator() - default: - return nil - } - } + public var subtitle: String? { + switch self.itemType { + case .episode: + return getEpisodeLocator() + default: + return nil + } + } - public var blurHash: String { - self.getPrimaryImageBlurHash() - } + public var blurHash: String { + self.getPrimaryImageBlurHash() + } - public var failureInitials: String { - guard let name = self.name else { return "" } - let initials = name.split(separator: " ").compactMap { String($0).first } - return String(initials) - } + public var failureInitials: String { + guard let name = self.name else { return "" } + let initials = name.split(separator: " ").compactMap { String($0).first } + return String(initials) + } - public var showTitle: Bool { - switch self.itemType { - case .episode, .series, .movie, .boxset: - return Defaults[.showPosterLabels] - default: - return true - } - } + public var showTitle: Bool { + switch self.itemType { + case .episode, .series, .movie, .boxset: + return Defaults[.showPosterLabels] + default: + return true + } + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift index d8a7be36..833f3bbd 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDto+VideoPlayerViewModel.swift @@ -12,313 +12,337 @@ import JellyfinAPI import UIKit extension BaseItemDto { - func createVideoPlayerViewModel() -> AnyPublisher<[VideoPlayerViewModel], Error> { + func createVideoPlayerViewModel() -> AnyPublisher<[VideoPlayerViewModel], Error> { - LogManager.log.debug("Creating video player view model for item: \(id ?? "")") + LogManager.log.debug("Creating video player view model for item: \(id ?? "")") - let builder = DeviceProfileBuilder() - // TODO: fix bitrate settings - let tempOverkillBitrate = 360_000_000 - builder.setMaxBitrate(bitrate: tempOverkillBitrate) - let profile = builder.buildProfile() + let builder = DeviceProfileBuilder() + // TODO: fix bitrate settings + let tempOverkillBitrate = 360_000_000 + builder.setMaxBitrate(bitrate: tempOverkillBitrate) + let profile = builder.buildProfile() - let getPostedPlaybackInfoRequest = GetPostedPlaybackInfoRequest(userId: SessionManager.main.currentLogin.user.id, - maxStreamingBitrate: tempOverkillBitrate, - startTimeTicks: self.userData?.playbackPositionTicks ?? 0, - deviceProfile: profile, - autoOpenLiveStream: true) + let getPostedPlaybackInfoRequest = GetPostedPlaybackInfoRequest( + userId: SessionManager.main.currentLogin.user.id, + maxStreamingBitrate: tempOverkillBitrate, + startTimeTicks: self.userData?.playbackPositionTicks ?? 0, + deviceProfile: profile, + autoOpenLiveStream: true + ) - return MediaInfoAPI.getPostedPlaybackInfo(itemId: self.id!, - userId: SessionManager.main.currentLogin.user.id, - maxStreamingBitrate: tempOverkillBitrate, - startTimeTicks: self.userData?.playbackPositionTicks ?? 0, - autoOpenLiveStream: true, - getPostedPlaybackInfoRequest: getPostedPlaybackInfoRequest) - .map { response -> [VideoPlayerViewModel] in - let mediaSources = response.mediaSources! + return MediaInfoAPI.getPostedPlaybackInfo( + itemId: self.id!, + userId: SessionManager.main.currentLogin.user.id, + maxStreamingBitrate: tempOverkillBitrate, + startTimeTicks: self.userData?.playbackPositionTicks ?? 0, + autoOpenLiveStream: true, + getPostedPlaybackInfoRequest: getPostedPlaybackInfoRequest + ) + .map { response -> [VideoPlayerViewModel] in + let mediaSources = response.mediaSources! - var viewModels: [VideoPlayerViewModel] = [] + var viewModels: [VideoPlayerViewModel] = [] - for currentMediaSource in mediaSources { - let videoStream = currentMediaSource.mediaStreams?.filter { $0.type == .video }.first - let audioStreams = currentMediaSource.mediaStreams?.filter { $0.type == .audio } ?? [] - let subtitleStreams = currentMediaSource.mediaStreams?.filter { $0.type == .subtitle } ?? [] + for currentMediaSource in mediaSources { + let videoStream = currentMediaSource.mediaStreams?.filter { $0.type == .video }.first + let audioStreams = currentMediaSource.mediaStreams?.filter { $0.type == .audio } ?? [] + let subtitleStreams = currentMediaSource.mediaStreams?.filter { $0.type == .subtitle } ?? [] - let defaultAudioStream = audioStreams.first(where: { $0.index! == currentMediaSource.defaultAudioStreamIndex! }) + let defaultAudioStream = audioStreams.first(where: { $0.index! == currentMediaSource.defaultAudioStreamIndex! }) - let defaultSubtitleStream = subtitleStreams - .first(where: { $0.index! == currentMediaSource.defaultSubtitleStreamIndex ?? -1 }) + let defaultSubtitleStream = subtitleStreams + .first(where: { $0.index! == currentMediaSource.defaultSubtitleStreamIndex ?? -1 }) - // MARK: Build Streams + // MARK: Build Streams - let directStreamURL: URL - let transcodedStreamURL: URLComponents? - var hlsStreamURL: URL - let mediaSourceID: String - let streamType: ServerStreamType + let directStreamURL: URL + let transcodedStreamURL: URLComponents? + var hlsStreamURL: URL + let mediaSourceID: String + let streamType: ServerStreamType - if mediaSources.count > 1 { - mediaSourceID = currentMediaSource.id! - } else { - mediaSourceID = self.id! - } + if mediaSources.count > 1 { + mediaSourceID = currentMediaSource.id! + } else { + mediaSourceID = self.id! + } - let directStreamBuilder = VideosAPI.getVideoStreamWithRequestBuilder(itemId: self.id!, - _static: true, - tag: self.etag, - playSessionId: response.playSessionId, - minSegments: 6, - mediaSourceId: mediaSourceID) - directStreamURL = URL(string: directStreamBuilder.URLString)! + let directStreamBuilder = VideosAPI.getVideoStreamWithRequestBuilder( + itemId: self.id!, + _static: true, + tag: self.etag, + playSessionId: response.playSessionId, + minSegments: 6, + mediaSourceId: mediaSourceID + ) + directStreamURL = URL(string: directStreamBuilder.URLString)! - if let transcodeURL = currentMediaSource.transcodingUrl { - streamType = .transcode - transcodedStreamURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI - .appending(transcodeURL))! - } else { - streamType = .direct - transcodedStreamURL = nil - } + if let transcodeURL = currentMediaSource.transcodingUrl { + streamType = .transcode + transcodedStreamURL = URLComponents( + string: SessionManager.main.currentLogin.server.currentURI + .appending(transcodeURL) + )! + } else { + streamType = .direct + transcodedStreamURL = nil + } - let hlsStreamBuilder = DynamicHlsAPI.getMasterHlsVideoPlaylistWithRequestBuilder(itemId: id ?? "", - mediaSourceId: id ?? "", - _static: true, - tag: currentMediaSource.eTag, - deviceProfileId: nil, - playSessionId: response.playSessionId, - segmentContainer: "ts", - segmentLength: nil, - minSegments: 2, - deviceId: UIDevice.vendorUUIDString, - audioCodec: audioStreams - .compactMap(\.codec) - .joined(separator: ","), - breakOnNonKeyFrames: true, - requireAvc: true, - transcodingMaxAudioChannels: 6, - videoCodec: videoStream?.codec, - videoStreamIndex: videoStream?.index, - enableAdaptiveBitrateStreaming: true) + let hlsStreamBuilder = DynamicHlsAPI.getMasterHlsVideoPlaylistWithRequestBuilder( + itemId: id ?? "", + mediaSourceId: id ?? "", + _static: true, + tag: currentMediaSource.eTag, + deviceProfileId: nil, + playSessionId: response.playSessionId, + segmentContainer: "ts", + segmentLength: nil, + minSegments: 2, + deviceId: UIDevice.vendorUUIDString, + audioCodec: audioStreams + .compactMap(\.codec) + .joined(separator: ","), + breakOnNonKeyFrames: true, + requireAvc: true, + transcodingMaxAudioChannels: 6, + videoCodec: videoStream?.codec, + videoStreamIndex: videoStream?.index, + enableAdaptiveBitrateStreaming: true + ) - var hlsStreamComponents = URLComponents(string: hlsStreamBuilder.URLString)! - hlsStreamComponents.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken) + var hlsStreamComponents = URLComponents(string: hlsStreamBuilder.URLString)! + hlsStreamComponents.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken) - hlsStreamURL = hlsStreamComponents.url! + hlsStreamURL = hlsStreamComponents.url! - // MARK: VidoPlayerViewModel Creation + // MARK: VidoPlayerViewModel Creation - var subtitle: String? + var subtitle: String? - // MARK: Attach media content to self + // MARK: Attach media content to self - var modifiedSelfItem = self - modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams + var modifiedSelfItem = self + modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams - // TODO: other forms of media subtitle - if self.itemType == .episode { - if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() { - subtitle = "\(seriesName) - \(episodeLocator)" - } - } + // TODO: other forms of media subtitle + if self.itemType == .episode { + if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() { + subtitle = "\(seriesName) - \(episodeLocator)" + } + } - let subtitlesEnabled = defaultSubtitleStream != nil + let subtitlesEnabled = defaultSubtitleStream != nil - let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode - let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay + let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode + let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay - let overlayType = Defaults[.overlayType] + let overlayType = Defaults[.overlayType] - let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode - let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode + let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode + let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode - var fileName: String? - if let lastInPath = currentMediaSource.path?.split(separator: "/").last { - fileName = String(lastInPath) - } + var fileName: String? + if let lastInPath = currentMediaSource.path?.split(separator: "/").last { + fileName = String(lastInPath) + } - let videoPlayerViewModel = VideoPlayerViewModel(item: modifiedSelfItem, - title: modifiedSelfItem.name ?? "", - subtitle: subtitle, - directStreamURL: directStreamURL, - transcodedStreamURL: transcodedStreamURL?.url, - hlsStreamURL: hlsStreamURL, - streamType: streamType, - response: response, - audioStreams: audioStreams, - subtitleStreams: subtitleStreams, - chapters: modifiedSelfItem.chapters ?? [], - selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, - selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, - subtitlesEnabled: subtitlesEnabled, - autoplayEnabled: autoplayEnabled, - overlayType: overlayType, - shouldShowPlayPreviousItem: shouldShowPlayPreviousItem, - shouldShowPlayNextItem: shouldShowPlayNextItem, - shouldShowAutoPlay: shouldShowAutoPlay, - container: currentMediaSource.container ?? "", - filename: fileName, - versionName: currentMediaSource.name) + let videoPlayerViewModel = VideoPlayerViewModel( + item: modifiedSelfItem, + title: modifiedSelfItem.name ?? "", + subtitle: subtitle, + directStreamURL: directStreamURL, + transcodedStreamURL: transcodedStreamURL?.url, + hlsStreamURL: hlsStreamURL, + streamType: streamType, + response: response, + audioStreams: audioStreams, + subtitleStreams: subtitleStreams, + chapters: modifiedSelfItem.chapters ?? [], + selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, + selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, + subtitlesEnabled: subtitlesEnabled, + autoplayEnabled: autoplayEnabled, + overlayType: overlayType, + shouldShowPlayPreviousItem: shouldShowPlayPreviousItem, + shouldShowPlayNextItem: shouldShowPlayNextItem, + shouldShowAutoPlay: shouldShowAutoPlay, + container: currentMediaSource.container ?? "", + filename: fileName, + versionName: currentMediaSource.name + ) - viewModels.append(videoPlayerViewModel) - } + viewModels.append(videoPlayerViewModel) + } - return viewModels - } - .eraseToAnyPublisher() - } + return viewModels + } + .eraseToAnyPublisher() + } - func createLiveTVVideoPlayerViewModel() -> AnyPublisher<[VideoPlayerViewModel], Error> { + func createLiveTVVideoPlayerViewModel() -> AnyPublisher<[VideoPlayerViewModel], Error> { - LogManager.log.debug("Creating liveTV video player view model for item: \(id ?? "")") + LogManager.log.debug("Creating liveTV video player view model for item: \(id ?? "")") - let builder = DeviceProfileBuilder() - // TODO: fix bitrate settings - let tempOverkillBitrate = 360_000_000 - builder.setMaxBitrate(bitrate: tempOverkillBitrate) - let profile = builder.buildProfile() + let builder = DeviceProfileBuilder() + // TODO: fix bitrate settings + let tempOverkillBitrate = 360_000_000 + builder.setMaxBitrate(bitrate: tempOverkillBitrate) + let profile = builder.buildProfile() - let getPostedPlaybackInfoRequest = GetPostedPlaybackInfoRequest(userId: SessionManager.main.currentLogin.user.id, - maxStreamingBitrate: tempOverkillBitrate, - startTimeTicks: self.userData?.playbackPositionTicks ?? 0, - deviceProfile: profile, - autoOpenLiveStream: true) + let getPostedPlaybackInfoRequest = GetPostedPlaybackInfoRequest( + userId: SessionManager.main.currentLogin.user.id, + maxStreamingBitrate: tempOverkillBitrate, + startTimeTicks: self.userData?.playbackPositionTicks ?? 0, + deviceProfile: profile, + autoOpenLiveStream: true + ) - return MediaInfoAPI.getPostedPlaybackInfo(itemId: self.id!, - userId: SessionManager.main.currentLogin.user.id, - maxStreamingBitrate: tempOverkillBitrate, - startTimeTicks: self.userData?.playbackPositionTicks ?? 0, - autoOpenLiveStream: true, - getPostedPlaybackInfoRequest: getPostedPlaybackInfoRequest) - .map { response -> [VideoPlayerViewModel] in - let mediaSources = response.mediaSources! + return MediaInfoAPI.getPostedPlaybackInfo( + itemId: self.id!, + userId: SessionManager.main.currentLogin.user.id, + maxStreamingBitrate: tempOverkillBitrate, + startTimeTicks: self.userData?.playbackPositionTicks ?? 0, + autoOpenLiveStream: true, + getPostedPlaybackInfoRequest: getPostedPlaybackInfoRequest + ) + .map { response -> [VideoPlayerViewModel] in + let mediaSources = response.mediaSources! - var viewModels: [VideoPlayerViewModel] = [] + var viewModels: [VideoPlayerViewModel] = [] - for currentMediaSource in mediaSources { - let videoStream = currentMediaSource.mediaStreams?.filter { $0.type == .video }.first - let audioStreams = currentMediaSource.mediaStreams?.filter { $0.type == .audio } ?? [] - let subtitleStreams = currentMediaSource.mediaStreams?.filter { $0.type == .subtitle } ?? [] + for currentMediaSource in mediaSources { + let videoStream = currentMediaSource.mediaStreams?.filter { $0.type == .video }.first + let audioStreams = currentMediaSource.mediaStreams?.filter { $0.type == .audio } ?? [] + let subtitleStreams = currentMediaSource.mediaStreams?.filter { $0.type == .subtitle } ?? [] - let defaultAudioStream = audioStreams.first(where: { $0.index! == currentMediaSource.defaultAudioStreamIndex! }) + let defaultAudioStream = audioStreams.first(where: { $0.index! == currentMediaSource.defaultAudioStreamIndex! }) - let defaultSubtitleStream = subtitleStreams - .first(where: { $0.index! == currentMediaSource.defaultSubtitleStreamIndex ?? -1 }) + let defaultSubtitleStream = subtitleStreams + .first(where: { $0.index! == currentMediaSource.defaultSubtitleStreamIndex ?? -1 }) - // MARK: Build Streams + // MARK: Build Streams - let directStreamURL: URL - let transcodedStreamURL: URLComponents? - var hlsStreamURL: URL - let mediaSourceID: String - let streamType: ServerStreamType + let directStreamURL: URL + let transcodedStreamURL: URLComponents? + var hlsStreamURL: URL + let mediaSourceID: String + let streamType: ServerStreamType - if mediaSources.count > 1 { - mediaSourceID = currentMediaSource.id! - } else { - mediaSourceID = self.id! - } + if mediaSources.count > 1 { + mediaSourceID = currentMediaSource.id! + } else { + mediaSourceID = self.id! + } - let directStreamBuilder = VideosAPI.getVideoStreamWithRequestBuilder(itemId: self.id!, - _static: true, - tag: self.etag, - playSessionId: response.playSessionId, - minSegments: 6, - mediaSourceId: mediaSourceID) - directStreamURL = URL(string: directStreamBuilder.URLString)! + let directStreamBuilder = VideosAPI.getVideoStreamWithRequestBuilder( + itemId: self.id!, + _static: true, + tag: self.etag, + playSessionId: response.playSessionId, + minSegments: 6, + mediaSourceId: mediaSourceID + ) + directStreamURL = URL(string: directStreamBuilder.URLString)! - if let transcodeURL = currentMediaSource.transcodingUrl, !Defaults[.Experimental.liveTVForceDirectPlay] { - streamType = .transcode - transcodedStreamURL = URLComponents(string: SessionManager.main.currentLogin.server.currentURI - .appending(transcodeURL))! - } else { - streamType = .direct - transcodedStreamURL = nil - } + if let transcodeURL = currentMediaSource.transcodingUrl, !Defaults[.Experimental.liveTVForceDirectPlay] { + streamType = .transcode + transcodedStreamURL = URLComponents( + string: SessionManager.main.currentLogin.server.currentURI + .appending(transcodeURL) + )! + } else { + streamType = .direct + transcodedStreamURL = nil + } - let hlsStreamBuilder = DynamicHlsAPI.getMasterHlsVideoPlaylistWithRequestBuilder(itemId: id ?? "", - mediaSourceId: id ?? "", - _static: true, - tag: currentMediaSource.eTag, - deviceProfileId: nil, - playSessionId: response.playSessionId, - segmentContainer: "ts", - segmentLength: nil, - minSegments: 2, - deviceId: UIDevice.vendorUUIDString, - audioCodec: audioStreams - .compactMap(\.codec) - .joined(separator: ","), - breakOnNonKeyFrames: true, - requireAvc: true, - transcodingMaxAudioChannels: 6, - videoCodec: videoStream?.codec, - videoStreamIndex: videoStream?.index, - enableAdaptiveBitrateStreaming: true) + let hlsStreamBuilder = DynamicHlsAPI.getMasterHlsVideoPlaylistWithRequestBuilder( + itemId: id ?? "", + mediaSourceId: id ?? "", + _static: true, + tag: currentMediaSource.eTag, + deviceProfileId: nil, + playSessionId: response.playSessionId, + segmentContainer: "ts", + segmentLength: nil, + minSegments: 2, + deviceId: UIDevice.vendorUUIDString, + audioCodec: audioStreams + .compactMap(\.codec) + .joined(separator: ","), + breakOnNonKeyFrames: true, + requireAvc: true, + transcodingMaxAudioChannels: 6, + videoCodec: videoStream?.codec, + videoStreamIndex: videoStream?.index, + enableAdaptiveBitrateStreaming: true + ) - var hlsStreamComponents = URLComponents(string: hlsStreamBuilder.URLString)! - hlsStreamComponents.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken) + var hlsStreamComponents = URLComponents(string: hlsStreamBuilder.URLString)! + hlsStreamComponents.addQueryItem(name: "api_key", value: SessionManager.main.currentLogin.user.accessToken) - hlsStreamURL = hlsStreamComponents.url! + hlsStreamURL = hlsStreamComponents.url! - // MARK: VidoPlayerViewModel Creation + // MARK: VidoPlayerViewModel Creation - var subtitle: String? + var subtitle: String? - // MARK: Attach media content to self + // MARK: Attach media content to self - var modifiedSelfItem = self - modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams + var modifiedSelfItem = self + modifiedSelfItem.mediaStreams = currentMediaSource.mediaStreams - // TODO: other forms of media subtitle - if self.itemType == .episode { - if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() { - subtitle = "\(seriesName) - \(episodeLocator)" - } - } + // TODO: other forms of media subtitle + if self.itemType == .episode { + if let seriesName = self.seriesName, let episodeLocator = self.getEpisodeLocator() { + subtitle = "\(seriesName) - \(episodeLocator)" + } + } - let subtitlesEnabled = defaultSubtitleStream != nil + let subtitlesEnabled = defaultSubtitleStream != nil - let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode - let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay + let shouldShowAutoPlay = Defaults[.shouldShowAutoPlay] && itemType == .episode + let autoplayEnabled = Defaults[.autoplayEnabled] && shouldShowAutoPlay - let overlayType = Defaults[.overlayType] + let overlayType = Defaults[.overlayType] - let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode - let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode + let shouldShowPlayPreviousItem = Defaults[.shouldShowPlayPreviousItem] && itemType == .episode + let shouldShowPlayNextItem = Defaults[.shouldShowPlayNextItem] && itemType == .episode - var fileName: String? - if let lastInPath = currentMediaSource.path?.split(separator: "/").last { - fileName = String(lastInPath) - } + var fileName: String? + if let lastInPath = currentMediaSource.path?.split(separator: "/").last { + fileName = String(lastInPath) + } - let videoPlayerViewModel = VideoPlayerViewModel(item: modifiedSelfItem, - title: modifiedSelfItem.name ?? "", - subtitle: subtitle, - directStreamURL: directStreamURL, - transcodedStreamURL: transcodedStreamURL?.url, - hlsStreamURL: hlsStreamURL, - streamType: streamType, - response: response, - audioStreams: audioStreams, - subtitleStreams: subtitleStreams, - chapters: modifiedSelfItem.chapters ?? [], - selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, - selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, - subtitlesEnabled: subtitlesEnabled, - autoplayEnabled: autoplayEnabled, - overlayType: overlayType, - shouldShowPlayPreviousItem: shouldShowPlayPreviousItem, - shouldShowPlayNextItem: shouldShowPlayNextItem, - shouldShowAutoPlay: shouldShowAutoPlay, - container: currentMediaSource.container ?? "", - filename: fileName, - versionName: currentMediaSource.name) + let videoPlayerViewModel = VideoPlayerViewModel( + item: modifiedSelfItem, + title: modifiedSelfItem.name ?? "", + subtitle: subtitle, + directStreamURL: directStreamURL, + transcodedStreamURL: transcodedStreamURL?.url, + hlsStreamURL: hlsStreamURL, + streamType: streamType, + response: response, + audioStreams: audioStreams, + subtitleStreams: subtitleStreams, + chapters: modifiedSelfItem.chapters ?? [], + selectedAudioStreamIndex: defaultAudioStream?.index ?? -1, + selectedSubtitleStreamIndex: defaultSubtitleStream?.index ?? -1, + subtitlesEnabled: subtitlesEnabled, + autoplayEnabled: autoplayEnabled, + overlayType: overlayType, + shouldShowPlayPreviousItem: shouldShowPlayPreviousItem, + shouldShowPlayNextItem: shouldShowPlayNextItem, + shouldShowAutoPlay: shouldShowAutoPlay, + container: currentMediaSource.container ?? "", + filename: fileName, + versionName: currentMediaSource.name + ) - viewModels.append(videoPlayerViewModel) - } + viewModels.append(videoPlayerViewModel) + } - return viewModels - } - .eraseToAnyPublisher() - } + return viewModels + } + .eraseToAnyPublisher() + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift index 4ed88455..fb786b9f 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift @@ -13,366 +13,380 @@ import UIKit // 001fC^ = dark grey plain blurhash public extension BaseItemDto { - // MARK: Images + // MARK: Images - func getSeriesBackdropImageBlurHash() -> String { - let imgURL = getSeriesBackdropImage(maxWidth: 1) - guard let imgTag = imgURL.queryParameters?["tag"], - let hash = imageBlurHashes?.backdrop?[imgTag] - else { - return "001fC^" - } + func getSeriesBackdropImageBlurHash() -> String { + let imgURL = getSeriesBackdropImage(maxWidth: 1) + guard let imgTag = imgURL.queryParameters?["tag"], + let hash = imageBlurHashes?.backdrop?[imgTag] + else { + return "001fC^" + } - return hash - } + return hash + } - func getSeriesPrimaryImageBlurHash() -> String { - let imgURL = getSeriesPrimaryImage(maxWidth: 1) - guard let imgTag = imgURL.queryParameters?["tag"], - let hash = imageBlurHashes?.primary?[imgTag] - else { - return "001fC^" - } + func getSeriesPrimaryImageBlurHash() -> String { + let imgURL = getSeriesPrimaryImage(maxWidth: 1) + guard let imgTag = imgURL.queryParameters?["tag"], + let hash = imageBlurHashes?.primary?[imgTag] + else { + return "001fC^" + } - return hash - } + return hash + } - func getPrimaryImageBlurHash() -> String { - let imgURL = getPrimaryImage(maxWidth: 1) - guard let imgTag = imgURL.queryParameters?["tag"], - let hash = imageBlurHashes?.primary?[imgTag] - else { - return "001fC^" - } + func getPrimaryImageBlurHash() -> String { + let imgURL = getPrimaryImage(maxWidth: 1) + guard let imgTag = imgURL.queryParameters?["tag"], + let hash = imageBlurHashes?.primary?[imgTag] + else { + return "001fC^" + } - return hash - } + return hash + } - func getBackdropImageBlurHash() -> String { - let imgURL = getBackdropImage(maxWidth: 1) - guard let imgTag = imgURL.queryParameters?["tag"] else { - return "001fC^" - } + func getBackdropImageBlurHash() -> String { + let imgURL = getBackdropImage(maxWidth: 1) + guard let imgTag = imgURL.queryParameters?["tag"] else { + return "001fC^" + } - if imgURL.queryParameters?[ImageType.backdrop.rawValue] == nil { - if itemType == .episode { - return imageBlurHashes?.backdrop?.values.first ?? "001fC^" - } else { - return imageBlurHashes?.backdrop?[imgTag] ?? "001fC^" - } - } else { - return imageBlurHashes?.primary?[imgTag] ?? "001fC^" - } - } + if imgURL.queryParameters?[ImageType.backdrop.rawValue] == nil { + if itemType == .episode { + return imageBlurHashes?.backdrop?.values.first ?? "001fC^" + } else { + return imageBlurHashes?.backdrop?[imgTag] ?? "001fC^" + } + } else { + return imageBlurHashes?.primary?[imgTag] ?? "001fC^" + } + } - func getBackdropImage(maxWidth: Int) -> URL { - var imageType = ImageType.backdrop - var imageTag: String? - var imageItemId = id ?? "" + func getBackdropImage(maxWidth: Int) -> URL { + var imageType = ImageType.backdrop + var imageTag: String? + var imageItemId = id ?? "" - if primaryImageAspectRatio ?? 0.0 < 1.0 { - if !(backdropImageTags?.isEmpty ?? true) { - imageTag = backdropImageTags?.first - } - } else { - imageType = .primary - imageTag = imageTags?[ImageType.primary.rawValue] ?? "" - } + if primaryImageAspectRatio ?? 0.0 < 1.0 { + if !(backdropImageTags?.isEmpty ?? true) { + imageTag = backdropImageTags?.first + } + } else { + imageType = .primary + imageTag = imageTags?[ImageType.primary.rawValue] ?? "" + } - if imageTag == nil || imageItemId.isEmpty { - if !(parentBackdropImageTags?.isEmpty ?? true) { - imageTag = parentBackdropImageTags?.first - imageItemId = parentBackdropItemId ?? "" - } - } + if imageTag == nil || imageItemId.isEmpty { + if !(parentBackdropImageTags?.isEmpty ?? true) { + imageTag = parentBackdropImageTags?.first + imageItemId = parentBackdropItemId ?? "" + } + } - let x = UIScreen.main.nativeScale * CGFloat(maxWidth) + let x = UIScreen.main.nativeScale * CGFloat(maxWidth) - let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: imageItemId, - imageType: imageType, - maxWidth: Int(x), - quality: 96, - tag: imageTag).URLString - return URL(string: urlString)! - } + let urlString = ImageAPI.getItemImageWithRequestBuilder( + itemId: imageItemId, + imageType: imageType, + maxWidth: Int(x), + quality: 96, + tag: imageTag + ).URLString + return URL(string: urlString)! + } - func getThumbImage(maxWidth: Int) -> URL { - let x = UIScreen.main.nativeScale * CGFloat(maxWidth) + func getThumbImage(maxWidth: Int) -> URL { + let x = UIScreen.main.nativeScale * CGFloat(maxWidth) - let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: id ?? "", - imageType: .thumb, - maxWidth: Int(x), - quality: 96).URLString - return URL(string: urlString)! - } + let urlString = ImageAPI.getItemImageWithRequestBuilder( + itemId: id ?? "", + imageType: .thumb, + maxWidth: Int(x), + quality: 96 + ).URLString + return URL(string: urlString)! + } - func getEpisodeLocator() -> String? { - if let seasonNo = parentIndexNumber, let episodeNo = indexNumber { - return L10n.seasonAndEpisode(String(seasonNo), String(episodeNo)) - } - return nil - } + func getEpisodeLocator() -> String? { + if let seasonNo = parentIndexNumber, let episodeNo = indexNumber { + return L10n.seasonAndEpisode(String(seasonNo), String(episodeNo)) + } + return nil + } - func getSeriesBackdropImage(maxWidth: Int) -> URL { - let x = UIScreen.main.nativeScale * CGFloat(maxWidth) - let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: parentBackdropItemId ?? "", - imageType: .backdrop, - maxWidth: Int(x), - quality: 96, - tag: parentBackdropImageTags?.first).URLString - return URL(string: urlString)! - } + func getSeriesBackdropImage(maxWidth: Int) -> URL { + let x = UIScreen.main.nativeScale * CGFloat(maxWidth) + let urlString = ImageAPI.getItemImageWithRequestBuilder( + itemId: parentBackdropItemId ?? "", + imageType: .backdrop, + maxWidth: Int(x), + quality: 96, + tag: parentBackdropImageTags?.first + ).URLString + return URL(string: urlString)! + } - func getSeriesPrimaryImage(maxWidth: Int) -> URL { - guard let seriesId = seriesId else { - return getPrimaryImage(maxWidth: maxWidth) - } + func getSeriesPrimaryImage(maxWidth: Int) -> URL { + guard let seriesId = seriesId else { + return getPrimaryImage(maxWidth: maxWidth) + } - let x = UIScreen.main.nativeScale * CGFloat(maxWidth) - let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: seriesId, - imageType: .primary, - maxWidth: Int(x), - quality: 96, - tag: seriesPrimaryImageTag).URLString - return URL(string: urlString)! - } + let x = UIScreen.main.nativeScale * CGFloat(maxWidth) + let urlString = ImageAPI.getItemImageWithRequestBuilder( + itemId: seriesId, + imageType: .primary, + maxWidth: Int(x), + quality: 96, + tag: seriesPrimaryImageTag + ).URLString + return URL(string: urlString)! + } - func getSeriesThumbImage(maxWidth: Int) -> URL { - let x = UIScreen.main.nativeScale * CGFloat(maxWidth) - let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: seriesId ?? "", - imageType: .thumb, - maxWidth: Int(x), - quality: 96, - tag: seriesPrimaryImageTag).URLString - return URL(string: urlString)! - } + func getSeriesThumbImage(maxWidth: Int) -> URL { + let x = UIScreen.main.nativeScale * CGFloat(maxWidth) + let urlString = ImageAPI.getItemImageWithRequestBuilder( + itemId: seriesId ?? "", + imageType: .thumb, + maxWidth: Int(x), + quality: 96, + tag: seriesPrimaryImageTag + ).URLString + return URL(string: urlString)! + } - func getPrimaryImage(maxWidth: Int) -> URL { - let imageType = ImageType.primary - var imageTag = imageTags?[ImageType.primary.rawValue] ?? "" - var imageItemId = id ?? "" + func getPrimaryImage(maxWidth: Int) -> URL { + let imageType = ImageType.primary + var imageTag = imageTags?[ImageType.primary.rawValue] ?? "" + var imageItemId = id ?? "" - if imageTag.isEmpty || imageItemId.isEmpty { - imageTag = seriesPrimaryImageTag ?? "" - imageItemId = seriesId ?? "" - } + if imageTag.isEmpty || imageItemId.isEmpty { + imageTag = seriesPrimaryImageTag ?? "" + imageItemId = seriesId ?? "" + } - let x = UIScreen.main.nativeScale * CGFloat(maxWidth) + let x = UIScreen.main.nativeScale * CGFloat(maxWidth) - let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: imageItemId, - imageType: imageType, - maxWidth: Int(x), - quality: 96, - tag: imageTag).URLString - return URL(string: urlString)! - } + let urlString = ImageAPI.getItemImageWithRequestBuilder( + itemId: imageItemId, + imageType: imageType, + maxWidth: Int(x), + quality: 96, + tag: imageTag + ).URLString + return URL(string: urlString)! + } - // MARK: Calculations + // MARK: Calculations - func getItemRuntime() -> String? { - let timeHMSFormatter: DateComponentsFormatter = { - let formatter = DateComponentsFormatter() - formatter.unitsStyle = .abbreviated - formatter.allowedUnits = [.hour, .minute] - return formatter - }() + func getItemRuntime() -> String? { + let timeHMSFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .abbreviated + formatter.allowedUnits = [.hour, .minute] + return formatter + }() - guard let runTimeTicks = runTimeTicks, - let text = timeHMSFormatter.string(from: Double(runTimeTicks / 10_000_000)) else { return nil } + guard let runTimeTicks = runTimeTicks, + let text = timeHMSFormatter.string(from: Double(runTimeTicks / 10_000_000)) else { return nil } - return text - } + return text + } - func getItemProgressString() -> String? { - if userData?.playbackPositionTicks == nil || userData?.playbackPositionTicks == 0 { - return nil - } + func getItemProgressString() -> String? { + if userData?.playbackPositionTicks == nil || userData?.playbackPositionTicks == 0 { + return nil + } - let remainingSecs = ((runTimeTicks ?? 0) - (userData?.playbackPositionTicks ?? 0)) / 10_000_000 - let proghours = Int(remainingSecs / 3600) - let progminutes = Int((Int(remainingSecs) - (proghours * 3600)) / 60) - if proghours != 0 { - return "\(proghours)h \(String(progminutes).leftPad(toWidth: 2, withString: "0"))m" - } else { - return "\(String(progminutes))m" - } - } + let remainingSecs = ((runTimeTicks ?? 0) - (userData?.playbackPositionTicks ?? 0)) / 10_000_000 + let proghours = Int(remainingSecs / 3600) + let progminutes = Int((Int(remainingSecs) - (proghours * 3600)) / 60) + if proghours != 0 { + return "\(proghours)h \(String(progminutes).leftPad(toWidth: 2, withString: "0"))m" + } else { + return "\(String(progminutes))m" + } + } - func getLiveStartTimeString(formatter: DateFormatter) -> String { - if let startDate = self.startDate { - return formatter.string(from: startDate) - } - return " " - } + func getLiveStartTimeString(formatter: DateFormatter) -> String { + if let startDate = self.startDate { + return formatter.string(from: startDate) + } + return " " + } - func getLiveEndTimeString(formatter: DateFormatter) -> String { - if let endDate = self.endDate { - return formatter.string(from: endDate) - } - return " " - } + func getLiveEndTimeString(formatter: DateFormatter) -> String { + if let endDate = self.endDate { + return formatter.string(from: endDate) + } + return " " + } - func getLiveProgressPercentage() -> Double { - if let startDate = self.startDate, - let endDate = self.endDate - { - let start = startDate.timeIntervalSinceReferenceDate - let end = endDate.timeIntervalSinceReferenceDate - let now = Date().timeIntervalSinceReferenceDate - let length = end - start - let progress = now - start - return progress / length - } - return 0 - } + func getLiveProgressPercentage() -> Double { + if let startDate = self.startDate, + let endDate = self.endDate + { + let start = startDate.timeIntervalSinceReferenceDate + let end = endDate.timeIntervalSinceReferenceDate + let now = Date().timeIntervalSinceReferenceDate + let length = end - start + let progress = now - start + return progress / length + } + return 0 + } - // MARK: ItemType + // MARK: ItemType - enum ItemType: String { - case movie = "Movie" - case season = "Season" - case episode = "Episode" - case series = "Series" - case boxset = "BoxSet" - case collectionFolder = "CollectionFolder" - case folder = "Folder" - case liveTV = "LiveTV" + enum ItemType: String { + case movie = "Movie" + case season = "Season" + case episode = "Episode" + case series = "Series" + case boxset = "BoxSet" + case collectionFolder = "CollectionFolder" + case folder = "Folder" + case liveTV = "LiveTV" - case unknown + case unknown - var showDetails: Bool { - switch self { - case .season, .series: - return false - default: - return true - } - } + var showDetails: Bool { + switch self { + case .season, .series: + return false + default: + return true + } + } - public init?(rawValue: String) { - let lowerCase = rawValue.lowercased() - switch lowerCase { - case "movie": self = .movie - case "season": self = .season - case "episode": self = .episode - case "series": self = .series - case "boxset": self = .boxset - case "collectionfolder": self = .collectionFolder - case "folder": self = .folder - case "livetv": self = .liveTV - default: self = .unknown - } - } - } + public init?(rawValue: String) { + let lowerCase = rawValue.lowercased() + switch lowerCase { + case "movie": self = .movie + case "season": self = .season + case "episode": self = .episode + case "series": self = .series + case "boxset": self = .boxset + case "collectionfolder": self = .collectionFolder + case "folder": self = .folder + case "livetv": self = .liveTV + default: self = .unknown + } + } + } - var itemType: ItemType { - guard let originalType = type, let knownType = ItemType(rawValue: originalType.rawValue) else { return .unknown } - return knownType - } + var itemType: ItemType { + guard let originalType = type, let knownType = ItemType(rawValue: originalType.rawValue) else { return .unknown } + return knownType + } - // MARK: PortraitHeaderViewURL + // MARK: PortraitHeaderViewURL - func portraitHeaderViewURL(maxWidth: Int) -> URL { - switch itemType { - case .movie, .season, .series, .boxset, .collectionFolder, .folder, .liveTV: - return getPrimaryImage(maxWidth: maxWidth) - case .episode: - return getSeriesPrimaryImage(maxWidth: maxWidth) - case .unknown: - return getPrimaryImage(maxWidth: maxWidth) - } - } + func portraitHeaderViewURL(maxWidth: Int) -> URL { + switch itemType { + case .movie, .season, .series, .boxset, .collectionFolder, .folder, .liveTV: + return getPrimaryImage(maxWidth: maxWidth) + case .episode: + return getSeriesPrimaryImage(maxWidth: maxWidth) + case .unknown: + return getPrimaryImage(maxWidth: maxWidth) + } + } - // MARK: ItemDetail + // MARK: ItemDetail - struct ItemDetail { - let title: String - let content: String - } + struct ItemDetail { + let title: String + let content: String + } - func createInformationItems() -> [ItemDetail] { - var informationItems: [ItemDetail] = [] + func createInformationItems() -> [ItemDetail] { + var informationItems: [ItemDetail] = [] - if let productionYear = productionYear { - informationItems.append(ItemDetail(title: L10n.released, content: "\(productionYear)")) - } + if let productionYear = productionYear { + informationItems.append(ItemDetail(title: L10n.released, content: "\(productionYear)")) + } - if let rating = officialRating { - informationItems.append(ItemDetail(title: L10n.rated, content: "\(rating)")) - } + if let rating = officialRating { + informationItems.append(ItemDetail(title: L10n.rated, content: "\(rating)")) + } - if let runtime = getItemRuntime() { - informationItems.append(ItemDetail(title: L10n.runtime, content: runtime)) - } + if let runtime = getItemRuntime() { + informationItems.append(ItemDetail(title: L10n.runtime, content: runtime)) + } - return informationItems - } + return informationItems + } - func createMediaItems() -> [ItemDetail] { - var mediaItems: [ItemDetail] = [] + func createMediaItems() -> [ItemDetail] { + var mediaItems: [ItemDetail] = [] - if let mediaStreams = mediaStreams { - let audioStreams = mediaStreams.filter { $0.type == .audio } - let subtitleStreams = mediaStreams.filter { $0.type == .subtitle } + if let mediaStreams = mediaStreams { + let audioStreams = mediaStreams.filter { $0.type == .audio } + let subtitleStreams = mediaStreams.filter { $0.type == .subtitle } - if !audioStreams.isEmpty { - let audioList = audioStreams.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" } - .joined(separator: ", ") - mediaItems.append(ItemDetail(title: L10n.audio, content: audioList)) - } + if !audioStreams.isEmpty { + let audioList = audioStreams.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" } + .joined(separator: ", ") + mediaItems.append(ItemDetail(title: L10n.audio, content: audioList)) + } - if !subtitleStreams.isEmpty { - let subtitleList = subtitleStreams.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" } - .joined(separator: ", ") - mediaItems.append(ItemDetail(title: L10n.subtitles, content: subtitleList)) - } - } + if !subtitleStreams.isEmpty { + let subtitleList = subtitleStreams.compactMap { "\($0.displayTitle ?? L10n.noTitle) (\($0.codec ?? L10n.noCodec))" } + .joined(separator: ", ") + mediaItems.append(ItemDetail(title: L10n.subtitles, content: subtitleList)) + } + } - return mediaItems - } + return mediaItems + } - // MARK: Missing and Unaired + // MARK: Missing and Unaired - var missing: Bool { - locationType == .virtual - } + var missing: Bool { + locationType == .virtual + } - var unaired: Bool { - if let premierDate = premiereDate { - return premierDate > Date() - } else { - return false - } - } + var unaired: Bool { + if let premierDate = premiereDate { + return premierDate > Date() + } else { + return false + } + } - var airDateLabel: String? { - guard let premiereDateFormatted = premiereDateFormatted else { return nil } - return L10n.airWithDate(premiereDateFormatted) - } + var airDateLabel: String? { + guard let premiereDateFormatted = premiereDateFormatted else { return nil } + return L10n.airWithDate(premiereDateFormatted) + } - var premiereDateFormatted: String? { - guard let premiereDate = premiereDate else { return nil } + var premiereDateFormatted: String? { + guard let premiereDate = premiereDate else { return nil } - let dateFormatter = DateFormatter() - dateFormatter.dateStyle = .medium - return dateFormatter.string(from: premiereDate) - } + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + return dateFormatter.string(from: premiereDate) + } - // MARK: Chapter Images + // MARK: Chapter Images - func getChapterImage(maxWidth: Int) -> [URL] { - guard let chapters = chapters, !chapters.isEmpty else { return [] } + func getChapterImage(maxWidth: Int) -> [URL] { + guard let chapters = chapters, !chapters.isEmpty else { return [] } - var chapterImageURLs: [URL] = [] + var chapterImageURLs: [URL] = [] - for chapterIndex in 0 ..< chapters.count { - let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: id ?? "", - imageType: .chapter, - maxWidth: maxWidth, - imageIndex: chapterIndex).URLString - chapterImageURLs.append(URL(string: urlString)!) - } + for chapterIndex in 0 ..< chapters.count { + let urlString = ImageAPI.getItemImageWithRequestBuilder( + itemId: id ?? "", + imageType: .chapter, + maxWidth: maxWidth, + imageIndex: chapterIndex + ).URLString + chapterImageURLs.append(URL(string: urlString)!) + } - return chapterImageURLs - } + return chapterImageURLs + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift index b26fef16..338840d5 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemPersonExtensions.swift @@ -12,103 +12,105 @@ import UIKit extension BaseItemPerson { - // MARK: Get Image + // MARK: Get Image - func getImage(baseURL: String, maxWidth: Int) -> URL { - let x = UIScreen.main.nativeScale * CGFloat(maxWidth) + func getImage(baseURL: String, maxWidth: Int) -> URL { + let x = UIScreen.main.nativeScale * CGFloat(maxWidth) - let urlString = ImageAPI.getItemImageWithRequestBuilder(itemId: id ?? "", - imageType: .primary, - maxWidth: Int(x), - quality: 96, - tag: primaryImageTag).URLString - return URL(string: urlString)! - } + let urlString = ImageAPI.getItemImageWithRequestBuilder( + itemId: id ?? "", + imageType: .primary, + maxWidth: Int(x), + quality: 96, + tag: primaryImageTag + ).URLString + return URL(string: urlString)! + } - func getBlurHash() -> String { - let imgURL = getImage(baseURL: "", maxWidth: 1) - guard let imgTag = imgURL.queryParameters?["tag"], - let hash = imageBlurHashes?.primary?[imgTag] - else { - return "001fC^" - } + func getBlurHash() -> String { + let imgURL = getImage(baseURL: "", maxWidth: 1) + guard let imgTag = imgURL.queryParameters?["tag"], + let hash = imageBlurHashes?.primary?[imgTag] + else { + return "001fC^" + } - return hash - } + return hash + } - // MARK: First Role + // MARK: First Role - // Jellyfin will grab all roles the person played in the show which makes the role - // text too long. This will grab the first role which: - // - assumes that the most important role is the first - // - will also grab the last "()" instance, like "(voice)" - func firstRole() -> String? { - guard let role = self.role else { return nil } - let split = role.split(separator: "/") - guard split.count > 1 else { return role } + // Jellyfin will grab all roles the person played in the show which makes the role + // text too long. This will grab the first role which: + // - assumes that the most important role is the first + // - will also grab the last "()" instance, like "(voice)" + func firstRole() -> String? { + guard let role = self.role else { return nil } + let split = role.split(separator: "/") + guard split.count > 1 else { return role } - guard let firstRole = split.first?.trimmingCharacters(in: CharacterSet(charactersIn: " ")), - let lastRole = split.last?.trimmingCharacters(in: CharacterSet(charactersIn: " ")) else { return role } + guard let firstRole = split.first?.trimmingCharacters(in: CharacterSet(charactersIn: " ")), + let lastRole = split.last?.trimmingCharacters(in: CharacterSet(charactersIn: " ")) else { return role } - var final = firstRole + var final = firstRole - if let lastOpenIndex = lastRole.lastIndex(of: "("), let lastClosingIndex = lastRole.lastIndex(of: ")") { - let roleText = lastRole[lastOpenIndex ... lastClosingIndex] - final.append(" \(roleText)") - } + if let lastOpenIndex = lastRole.lastIndex(of: "("), let lastClosingIndex = lastRole.lastIndex(of: ")") { + let roleText = lastRole[lastOpenIndex ... lastClosingIndex] + final.append(" \(roleText)") + } - return final - } + return final + } } // MARK: PortraitImageStackable extension BaseItemPerson: PortraitImageStackable { - public var portraitImageID: String { - (id ?? "noid") + title + (subtitle ?? "nodescription") + blurHash + failureInitials - } + public var portraitImageID: String { + (id ?? "noid") + title + (subtitle ?? "nodescription") + blurHash + failureInitials + } - public func imageURLConstructor(maxWidth: Int) -> URL { - self.getImage(baseURL: SessionManager.main.currentLogin.server.currentURI, maxWidth: maxWidth) - } + public func imageURLConstructor(maxWidth: Int) -> URL { + self.getImage(baseURL: SessionManager.main.currentLogin.server.currentURI, maxWidth: maxWidth) + } - public var title: String { - self.name ?? "" - } + public var title: String { + self.name ?? "" + } - public var subtitle: String? { - self.firstRole() - } + public var subtitle: String? { + self.firstRole() + } - public var blurHash: String { - self.getBlurHash() - } + public var blurHash: String { + self.getBlurHash() + } - public var failureInitials: String { - guard let name = self.name else { return "" } - let initials = name.split(separator: " ").compactMap { String($0).first } - return String(initials) - } + public var failureInitials: String { + guard let name = self.name else { return "" } + let initials = name.split(separator: " ").compactMap { String($0).first } + return String(initials) + } - public var showTitle: Bool { - true - } + public var showTitle: Bool { + true + } } // MARK: DiplayedType extension BaseItemPerson { - // Only displayed person types. - // Will ignore people like "GuestStar" - enum DisplayedType: String, CaseIterable { - case actor = "Actor" - case director = "Director" - case writer = "Writer" - case producer = "Producer" + // Only displayed person types. + // Will ignore people like "GuestStar" + enum DisplayedType: String, CaseIterable { + case actor = "Actor" + case director = "Director" + case writer = "Writer" + case producer = "Producer" - static var allCasesRaw: [String] { - self.allCases.map(\.rawValue) - } - } + static var allCasesRaw: [String] { + self.allCases.map(\.rawValue) + } + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/ChapterInfoExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/ChapterInfoExtensions.swift index 5d5fb50e..2e1625e0 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/ChapterInfoExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/ChapterInfoExtensions.swift @@ -11,34 +11,34 @@ import JellyfinAPI extension ChapterInfo { - var timestampLabel: String { - let seconds = (startPositionTicks ?? 0) / 10_000_000 - return seconds.toReadableString() - } + var timestampLabel: String { + let seconds = (startPositionTicks ?? 0) / 10_000_000 + return seconds.toReadableString() + } } extension Int64 { - func toReadableString() -> String { + func toReadableString() -> String { - let s = Int(self) % 60 - let mn = (Int(self) / 60) % 60 - let hr = (Int(self) / 3600) + let s = Int(self) % 60 + let mn = (Int(self) / 60) % 60 + let hr = (Int(self) / 3600) - var final = "" + var final = "" - if hr != 0 { - final += "\(hr):" - } + if hr != 0 { + final += "\(hr):" + } - if mn != 0 { - final += String(format: "%0.2d:", mn) - } else { - final += "00:" - } + if mn != 0 { + final += String(format: "%0.2d:", mn) + } else { + final += "00:" + } - final += String(format: "%0.2d", s) + final += String(format: "%0.2d", s) - return final - } + return final + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/JellyfinAPIError.swift b/Shared/Extensions/JellyfinAPIExtensions/JellyfinAPIError.swift index 8fa3df29..6142ccbd 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/JellyfinAPIError.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/JellyfinAPIError.swift @@ -10,13 +10,13 @@ import Foundation struct JellyfinAPIError: Error { - private let message: String + private let message: String - init(_ message: String) { - self.message = message - } + init(_ message: String) { + self.message = message + } - var localizedDescription: String { - message - } + var localizedDescription: String { + message + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/MediaStreamExtension.swift b/Shared/Extensions/JellyfinAPIExtensions/MediaStreamExtension.swift index 9361f41b..c1a0e6a2 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/MediaStreamExtension.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/MediaStreamExtension.swift @@ -10,12 +10,12 @@ import Foundation import JellyfinAPI extension MediaStream { - func externalURL(base: String) -> URL? { - var base = base - while base.last == Character("/") { - base.removeLast() - } - guard let deliveryURL = deliveryUrl else { return nil } - return URL(string: base + deliveryURL) - } + func externalURL(base: String) -> URL? { + var base = base + while base.last == Character("/") { + base.removeLast() + } + guard let deliveryURL = deliveryUrl else { return nil } + return URL(string: base + deliveryURL) + } } diff --git a/Shared/Extensions/JellyfinAPIExtensions/NameGUIDPairExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/NameGUIDPairExtensions.swift index 44e8b177..69e5e2a2 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/NameGUIDPairExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/NameGUIDPairExtensions.swift @@ -10,7 +10,7 @@ import Foundation import JellyfinAPI extension NameGuidPair: PillStackable { - var title: String { - self.name ?? "" - } + var title: String { + self.name ?? "" + } } diff --git a/Shared/Extensions/StringExtensions.swift b/Shared/Extensions/StringExtensions.swift index 2421bc2c..00bd1755 100644 --- a/Shared/Extensions/StringExtensions.swift +++ b/Shared/Extensions/StringExtensions.swift @@ -10,31 +10,31 @@ import Foundation import SwiftUI extension String { - func removeRegexMatches(pattern: String, replaceWith: String = "") -> String { - do { - let regex = try NSRegularExpression(pattern: pattern, options: .caseInsensitive) - let range = NSRange(location: 0, length: count) - return regex.stringByReplacingMatches(in: self, options: [], range: range, withTemplate: replaceWith) - } catch { return self } - } + func removeRegexMatches(pattern: String, replaceWith: String = "") -> String { + do { + let regex = try NSRegularExpression(pattern: pattern, options: .caseInsensitive) + let range = NSRange(location: 0, length: count) + return regex.stringByReplacingMatches(in: self, options: [], range: range, withTemplate: replaceWith) + } catch { return self } + } - func leftPad(toWidth width: Int, withString string: String?) -> String { - let paddingString = string ?? " " + func leftPad(toWidth width: Int, withString string: String?) -> String { + let paddingString = string ?? " " - if self.count >= width { - return self - } + if self.count >= width { + return self + } - let remainingLength: Int = width - self.count - var padString = String() - for _ in 0 ..< remainingLength { - padString += paddingString - } + let remainingLength: Int = width - self.count + var padString = String() + for _ in 0 ..< remainingLength { + padString += paddingString + } - return "\(padString)\(self)" - } + return "\(padString)\(self)" + } - var text: Text { - Text(self) - } + var text: Text { + Text(self) + } } diff --git a/Shared/Extensions/UIApplicationExtensions.swift b/Shared/Extensions/UIApplicationExtensions.swift index 7601aac2..94f90c73 100644 --- a/Shared/Extensions/UIApplicationExtensions.swift +++ b/Shared/Extensions/UIApplicationExtensions.swift @@ -9,11 +9,11 @@ import UIKit extension UIApplication { - static var appVersion: String? { - Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String - } + static var appVersion: String? { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + } - static var bundleVersion: String? { - Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String - } + static var bundleVersion: String? { + Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + } } diff --git a/Shared/Extensions/UIDeviceExtensions.swift b/Shared/Extensions/UIDeviceExtensions.swift index 05a495f0..40467175 100644 --- a/Shared/Extensions/UIDeviceExtensions.swift +++ b/Shared/Extensions/UIDeviceExtensions.swift @@ -9,7 +9,7 @@ import UIKit extension UIDevice { - static var vendorUUIDString: String { - current.identifierForVendor!.uuidString - } + static var vendorUUIDString: String { + current.identifierForVendor!.uuidString + } } diff --git a/Shared/Extensions/URLComponentsExtensions.swift b/Shared/Extensions/URLComponentsExtensions.swift index d1436a91..2345c94a 100644 --- a/Shared/Extensions/URLComponentsExtensions.swift +++ b/Shared/Extensions/URLComponentsExtensions.swift @@ -10,12 +10,12 @@ import Foundation extension URLComponents { - mutating func addQueryItem(name: String, value: String?) { - if let _ = self.queryItems { - self.queryItems?.append(URLQueryItem(name: name, value: value)) - } else { - self.queryItems = [] - self.queryItems?.append(URLQueryItem(name: name, value: value)) - } - } + mutating func addQueryItem(name: String, value: String?) { + if let _ = self.queryItems { + self.queryItems?.append(URLQueryItem(name: name, value: value)) + } else { + self.queryItems = [] + self.queryItems?.append(URLQueryItem(name: name, value: value)) + } + } } diff --git a/Shared/Extensions/URLExtensions.swift b/Shared/Extensions/URLExtensions.swift index 6c1251a6..56abb3b6 100644 --- a/Shared/Extensions/URLExtensions.swift +++ b/Shared/Extensions/URLExtensions.swift @@ -9,17 +9,17 @@ import Foundation public extension URL { - /// Dictionary of the URL's query parameters - var queryParameters: [String: String]? { - guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false), - let queryItems = components.queryItems else { return nil } + /// Dictionary of the URL's query parameters + var queryParameters: [String: String]? { + guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { return nil } - var items: [String: String] = [:] + var items: [String: String] = [:] - for queryItem in queryItems { - items[queryItem.name] = queryItem.value - } + for queryItem in queryItems { + items[queryItem.name] = queryItem.value + } - return items - } + return items + } } diff --git a/Shared/Extensions/VLCPlayer+subtitles.swift b/Shared/Extensions/VLCPlayer+subtitles.swift index 76d46c9a..fc9b4151 100644 --- a/Shared/Extensions/VLCPlayer+subtitles.swift +++ b/Shared/Extensions/VLCPlayer+subtitles.swift @@ -7,17 +7,19 @@ // #if os(tvOS) - import TVVLCKit + import TVVLCKit #else - import MobileVLCKit + import MobileVLCKit #endif extension VLCMediaPlayer { - /// Applies font size to the player - /// - /// This is pretty hacky until VLCKit 4 has a public API to support this - func setSubtitleSize(_ size: SubtitleSize) { - perform(Selector(("setTextRendererFontSize:")), - with: size.textRendererFontSize) - } + /// Applies font size to the player + /// + /// This is pretty hacky until VLCKit 4 has a public API to support this + func setSubtitleSize(_ size: SubtitleSize) { + perform( + Selector(("setTextRendererFontSize:")), + with: size.textRendererFontSize + ) + } } diff --git a/Shared/Extensions/ViewExtensions.swift b/Shared/Extensions/ViewExtensions.swift index 23b4a0bd..a636b664 100644 --- a/Shared/Extensions/ViewExtensions.swift +++ b/Shared/Extensions/ViewExtensions.swift @@ -10,7 +10,7 @@ import Foundation import SwiftUI extension View { - func eraseToAnyView() -> AnyView { - AnyView(self) - } + func eraseToAnyView() -> AnyView { + AnyView(self) + } } diff --git a/Shared/Generated/LocalizedLookup.swift b/Shared/Generated/LocalizedLookup.swift index bd21ddf4..e42cc58b 100644 --- a/Shared/Generated/LocalizedLookup.swift +++ b/Shared/Generated/LocalizedLookup.swift @@ -10,19 +10,19 @@ import Foundation class TranslationService { - static let shared = TranslationService() + static let shared = TranslationService() - func lookupTranslation(forKey key: String, inTable table: String) -> String { + func lookupTranslation(forKey key: String, inTable table: String) -> String { - let expectedValue = Bundle.main.localizedString(forKey: key, value: nil, table: table) + let expectedValue = Bundle.main.localizedString(forKey: key, value: nil, table: table) - if expectedValue == key || NSLocale.preferredLanguages.first == "en" { - guard let path = Bundle.main.path(forResource: "en", ofType: "lproj"), - let bundle = Bundle(path: path) else { return expectedValue } + if expectedValue == key || NSLocale.preferredLanguages.first == "en" { + guard let path = Bundle.main.path(forResource: "en", ofType: "lproj"), + let bundle = Bundle(path: path) else { return expectedValue } - return NSLocalizedString(key, bundle: bundle, comment: "") - } else { - return expectedValue - } - } + return NSLocalizedString(key, bundle: bundle, comment: "") + } else { + return expectedValue + } + } } diff --git a/Shared/Objects/AppAppearance.swift b/Shared/Objects/AppAppearance.swift index 0b5d0b5c..4b59cafb 100644 --- a/Shared/Objects/AppAppearance.swift +++ b/Shared/Objects/AppAppearance.swift @@ -10,29 +10,29 @@ import Defaults import SwiftUI enum AppAppearance: String, CaseIterable, Defaults.Serializable { - case system - case dark - case light + case system + case dark + case light - var localizedName: String { - switch self { - case .system: - return L10n.system - case .dark: - return L10n.dark - case .light: - return L10n.light - } - } + var localizedName: String { + switch self { + case .system: + return L10n.system + case .dark: + return L10n.dark + case .light: + return L10n.light + } + } - var style: UIUserInterfaceStyle { - switch self { - case .system: - return .unspecified - case .dark: - return .dark - case .light: - return .light - } - } + var style: UIUserInterfaceStyle { + switch self { + case .system: + return .unspecified + case .dark: + return .dark + case .light: + return .light + } + } } diff --git a/Shared/Objects/Bitrates.swift b/Shared/Objects/Bitrates.swift index 435a00e1..9105d745 100644 --- a/Shared/Objects/Bitrates.swift +++ b/Shared/Objects/Bitrates.swift @@ -9,6 +9,6 @@ import Foundation struct Bitrates: Codable, Hashable { - public var name: String - public var value: Int + public var name: String + public var value: Int } diff --git a/Shared/Objects/DeviceProfileBuilder.swift b/Shared/Objects/DeviceProfileBuilder.swift index 7c94a452..f67a33dc 100644 --- a/Shared/Objects/DeviceProfileBuilder.swift +++ b/Shared/Objects/DeviceProfileBuilder.swift @@ -12,246 +12,288 @@ import Foundation import JellyfinAPI enum CPUModel { - case A4 - case A5 - case A5X - case A6 - case A6X - case A7 - case A7X - case A8 - case A8X - case A9 - case A9X - case A10 - case A10X - case A11 - case A12 - case A12X - case A12Z - case A13 - case A14 - case M1 - case A99 + case A4 + case A5 + case A5X + case A6 + case A6X + case A7 + case A7X + case A8 + case A8X + case A9 + case A9X + case A10 + case A10X + case A11 + case A12 + case A12X + case A12Z + case A13 + case A14 + case M1 + case A99 } class DeviceProfileBuilder { - public var bitrate: Int = 0 + public var bitrate: Int = 0 - public func setMaxBitrate(bitrate: Int) { - self.bitrate = bitrate - } + public func setMaxBitrate(bitrate: Int) { + self.bitrate = bitrate + } - public func buildProfile() -> ClientCapabilitiesDeviceProfile { - let maxStreamingBitrate = bitrate - let maxStaticBitrate = bitrate - let musicStreamingTranscodingBitrate = bitrate + public func buildProfile() -> ClientCapabilitiesDeviceProfile { + let maxStreamingBitrate = bitrate + let maxStaticBitrate = bitrate + let musicStreamingTranscodingBitrate = bitrate - // Build direct play profiles - var directPlayProfiles: [DirectPlayProfile] = [] - directPlayProfiles = - [DirectPlayProfile(container: "mov,mp4,mkv,webm", audioCodec: "aac,mp3,wav", videoCodec: "h264,mpeg4,vp9", type: .video)] + // Build direct play profiles + var directPlayProfiles: [DirectPlayProfile] = [] + directPlayProfiles = + [DirectPlayProfile(container: "mov,mp4,mkv,webm", audioCodec: "aac,mp3,wav", videoCodec: "h264,mpeg4,vp9", type: .video)] - // Device supports Dolby Digital (AC3, EAC3) - if supportsFeature(minimumSupported: .A8X) { - if supportsFeature(minimumSupported: .A9) { - directPlayProfiles = [DirectPlayProfile(container: "mov,mp4,mkv,webm", audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus", - videoCodec: "hevc,h264,hev1,mpeg4,vp9", - type: .video)] // HEVC/H.264 with Dolby Digital - } else { - directPlayProfiles = [DirectPlayProfile(container: "mov,mp4,mkv,webm", audioCodec: "ac3,eac3,aac,mp3,wav,opus", - videoCodec: "h264,mpeg4,vp9", type: .video)] // H.264 with Dolby Digital - } - } + // Device supports Dolby Digital (AC3, EAC3) + if supportsFeature(minimumSupported: .A8X) { + if supportsFeature(minimumSupported: .A9) { + directPlayProfiles = [DirectPlayProfile( + container: "mov,mp4,mkv,webm", + audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus", + videoCodec: "hevc,h264,hev1,mpeg4,vp9", + type: .video + )] // HEVC/H.264 with Dolby Digital + } else { + directPlayProfiles = [DirectPlayProfile( + container: "mov,mp4,mkv,webm", + audioCodec: "ac3,eac3,aac,mp3,wav,opus", + videoCodec: "h264,mpeg4,vp9", + type: .video + )] // H.264 with Dolby Digital + } + } - // Device supports Dolby Vision? - if supportsFeature(minimumSupported: .A10X) { - directPlayProfiles = [DirectPlayProfile(container: "mov,mp4,mkv,webm", audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus", - videoCodec: "dvhe,dvh1,h264,hevc,hev1,mpeg4,vp9", - type: .video)] // H.264/HEVC with Dolby Digital - No Atmos - Vision - } + // Device supports Dolby Vision? + if supportsFeature(minimumSupported: .A10X) { + directPlayProfiles = [DirectPlayProfile( + container: "mov,mp4,mkv,webm", + audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus", + videoCodec: "dvhe,dvh1,h264,hevc,hev1,mpeg4,vp9", + type: .video + )] // H.264/HEVC with Dolby Digital - No Atmos - Vision + } - // Device supports Dolby Atmos? - if supportsFeature(minimumSupported: .A12) { - directPlayProfiles = [DirectPlayProfile(container: "mov,mp4,mkv,webm", - audioCodec: "aac,mp3,wav,ac3,eac3,flac,truehd,dts,dca,opus", - videoCodec: "h264,hevc,dvhe,dvh1,h264,hevc,hev1,mpeg4,vp9", - type: .video)] // H.264/HEVC with Dolby Digital & Atmos - Vision - } + // Device supports Dolby Atmos? + if supportsFeature(minimumSupported: .A12) { + directPlayProfiles = [DirectPlayProfile( + container: "mov,mp4,mkv,webm", + audioCodec: "aac,mp3,wav,ac3,eac3,flac,truehd,dts,dca,opus", + videoCodec: "h264,hevc,dvhe,dvh1,h264,hevc,hev1,mpeg4,vp9", + type: .video + )] // H.264/HEVC with Dolby Digital & Atmos - Vision + } - // Build transcoding profiles - var transcodingProfiles: [TranscodingProfile] = [] - transcodingProfiles = [TranscodingProfile(container: "ts", type: .video, videoCodec: "h264,mpeg4", audioCodec: "aac,mp3,wav")] + // Build transcoding profiles + var transcodingProfiles: [TranscodingProfile] = [] + transcodingProfiles = [TranscodingProfile(container: "ts", type: .video, videoCodec: "h264,mpeg4", audioCodec: "aac,mp3,wav")] - // Device supports Dolby Digital (AC3, EAC3) - if supportsFeature(minimumSupported: .A8X) { - if supportsFeature(minimumSupported: .A9) { - transcodingProfiles = [TranscodingProfile(container: "ts", type: .video, videoCodec: "h264,hevc,mpeg4", - audioCodec: "aac,mp3,wav,eac3,ac3,flac,opus", _protocol: "hls", - context: .streaming, maxAudioChannels: "6", minSegments: 2, - breakOnNonKeyFrames: true)] - } else { - transcodingProfiles = [TranscodingProfile(container: "ts", type: .video, videoCodec: "h264,mpeg4", - audioCodec: "aac,mp3,wav,eac3,ac3,opus", _protocol: "hls", - context: .streaming, maxAudioChannels: "6", minSegments: 2, - breakOnNonKeyFrames: true)] - } - } + // Device supports Dolby Digital (AC3, EAC3) + if supportsFeature(minimumSupported: .A8X) { + if supportsFeature(minimumSupported: .A9) { + transcodingProfiles = [TranscodingProfile( + container: "ts", + type: .video, + videoCodec: "h264,hevc,mpeg4", + audioCodec: "aac,mp3,wav,eac3,ac3,flac,opus", + _protocol: "hls", + context: .streaming, + maxAudioChannels: "6", + minSegments: 2, + breakOnNonKeyFrames: true + )] + } else { + transcodingProfiles = [TranscodingProfile( + container: "ts", + type: .video, + videoCodec: "h264,mpeg4", + audioCodec: "aac,mp3,wav,eac3,ac3,opus", + _protocol: "hls", + context: .streaming, + maxAudioChannels: "6", + minSegments: 2, + breakOnNonKeyFrames: true + )] + } + } - // Device supports FLAC? - if supportsFeature(minimumSupported: .A10X) { - transcodingProfiles = [TranscodingProfile(container: "ts", type: .video, videoCodec: "hevc,h264,mpeg4", - audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus", _protocol: "hls", - context: .streaming, maxAudioChannels: "6", minSegments: 2, - breakOnNonKeyFrames: true)] - } + // Device supports FLAC? + if supportsFeature(minimumSupported: .A10X) { + transcodingProfiles = [TranscodingProfile( + container: "ts", + type: .video, + videoCodec: "hevc,h264,mpeg4", + audioCodec: "aac,mp3,wav,ac3,eac3,flac,opus", + _protocol: "hls", + context: .streaming, + maxAudioChannels: "6", + minSegments: 2, + breakOnNonKeyFrames: true + )] + } - var codecProfiles: [CodecProfile] = [] + var codecProfiles: [CodecProfile] = [] - let h264CodecConditions: [ProfileCondition] = [ - ProfileCondition(condition: .notEquals, property: .isAnamorphic, value: "true", isRequired: false), - ProfileCondition(condition: .equalsAny, property: .videoProfile, value: "high|main|baseline|constrained baseline", - isRequired: false), - ProfileCondition(condition: .lessThanEqual, property: .videoLevel, value: "80", isRequired: false), - ProfileCondition(condition: .notEquals, property: .isInterlaced, value: "true", isRequired: false), - ] - let hevcCodecConditions: [ProfileCondition] = [ - ProfileCondition(condition: .notEquals, property: .isAnamorphic, value: "true", isRequired: false), - ProfileCondition(condition: .equalsAny, property: .videoProfile, value: "high|main|main 10", isRequired: false), - ProfileCondition(condition: .lessThanEqual, property: .videoLevel, value: "175", isRequired: false), - ProfileCondition(condition: .notEquals, property: .isInterlaced, value: "true", isRequired: false), - ] + let h264CodecConditions: [ProfileCondition] = [ + ProfileCondition(condition: .notEquals, property: .isAnamorphic, value: "true", isRequired: false), + ProfileCondition( + condition: .equalsAny, + property: .videoProfile, + value: "high|main|baseline|constrained baseline", + isRequired: false + ), + ProfileCondition(condition: .lessThanEqual, property: .videoLevel, value: "80", isRequired: false), + ProfileCondition(condition: .notEquals, property: .isInterlaced, value: "true", isRequired: false), + ] + let hevcCodecConditions: [ProfileCondition] = [ + ProfileCondition(condition: .notEquals, property: .isAnamorphic, value: "true", isRequired: false), + ProfileCondition(condition: .equalsAny, property: .videoProfile, value: "high|main|main 10", isRequired: false), + ProfileCondition(condition: .lessThanEqual, property: .videoLevel, value: "175", isRequired: false), + ProfileCondition(condition: .notEquals, property: .isInterlaced, value: "true", isRequired: false), + ] - codecProfiles.append(CodecProfile(type: .video, applyConditions: h264CodecConditions, codec: "h264")) + codecProfiles.append(CodecProfile(type: .video, applyConditions: h264CodecConditions, codec: "h264")) - if supportsFeature(minimumSupported: .A9) { - codecProfiles.append(CodecProfile(type: .video, applyConditions: hevcCodecConditions, codec: "hevc")) - } + if supportsFeature(minimumSupported: .A9) { + codecProfiles.append(CodecProfile(type: .video, applyConditions: hevcCodecConditions, codec: "hevc")) + } - var subtitleProfiles: [SubtitleProfile] = [] + var subtitleProfiles: [SubtitleProfile] = [] - subtitleProfiles.append(SubtitleProfile(format: "ass", method: .embed)) - subtitleProfiles.append(SubtitleProfile(format: "ssa", method: .embed)) - subtitleProfiles.append(SubtitleProfile(format: "subrip", method: .embed)) - subtitleProfiles.append(SubtitleProfile(format: "sub", method: .embed)) - subtitleProfiles.append(SubtitleProfile(format: "pgssub", method: .embed)) + subtitleProfiles.append(SubtitleProfile(format: "ass", method: .embed)) + subtitleProfiles.append(SubtitleProfile(format: "ssa", method: .embed)) + subtitleProfiles.append(SubtitleProfile(format: "subrip", method: .embed)) + subtitleProfiles.append(SubtitleProfile(format: "sub", method: .embed)) + subtitleProfiles.append(SubtitleProfile(format: "pgssub", method: .embed)) - // These need to be filtered. Most subrips are embedded. I hate subtitles. - subtitleProfiles.append(SubtitleProfile(format: "subrip", method: .external)) - subtitleProfiles.append(SubtitleProfile(format: "sub", method: .external)) - subtitleProfiles.append(SubtitleProfile(format: "ass", method: .external)) - subtitleProfiles.append(SubtitleProfile(format: "ssa", method: .external)) - subtitleProfiles.append(SubtitleProfile(format: "vtt", method: .external)) - subtitleProfiles.append(SubtitleProfile(format: "ass", method: .external)) - subtitleProfiles.append(SubtitleProfile(format: "ssa", method: .external)) + // These need to be filtered. Most subrips are embedded. I hate subtitles. + subtitleProfiles.append(SubtitleProfile(format: "subrip", method: .external)) + subtitleProfiles.append(SubtitleProfile(format: "sub", method: .external)) + subtitleProfiles.append(SubtitleProfile(format: "ass", method: .external)) + subtitleProfiles.append(SubtitleProfile(format: "ssa", method: .external)) + subtitleProfiles.append(SubtitleProfile(format: "vtt", method: .external)) + subtitleProfiles.append(SubtitleProfile(format: "ass", method: .external)) + subtitleProfiles.append(SubtitleProfile(format: "ssa", method: .external)) - let responseProfiles: [ResponseProfile] = [ResponseProfile(container: "m4v", type: .video, mimeType: "video/mp4")] + let responseProfiles: [ResponseProfile] = [ResponseProfile(container: "m4v", type: .video, mimeType: "video/mp4")] - let profile = ClientCapabilitiesDeviceProfile(maxStreamingBitrate: maxStreamingBitrate, maxStaticBitrate: maxStaticBitrate, - musicStreamingTranscodingBitrate: musicStreamingTranscodingBitrate, - directPlayProfiles: directPlayProfiles, transcodingProfiles: transcodingProfiles, - containerProfiles: [], - codecProfiles: codecProfiles, responseProfiles: responseProfiles, - subtitleProfiles: subtitleProfiles) + let profile = ClientCapabilitiesDeviceProfile( + maxStreamingBitrate: maxStreamingBitrate, + maxStaticBitrate: maxStaticBitrate, + musicStreamingTranscodingBitrate: musicStreamingTranscodingBitrate, + directPlayProfiles: directPlayProfiles, + transcodingProfiles: transcodingProfiles, + containerProfiles: [], + codecProfiles: codecProfiles, + responseProfiles: responseProfiles, + subtitleProfiles: subtitleProfiles + ) - return profile - } + return profile + } - private func supportsFeature(minimumSupported: CPUModel) -> Bool { - let intValues: [CPUModel: Int] = [ - .A4: 1, - .A5: 2, - .A5X: 3, - .A6: 4, - .A6X: 5, - .A7: 6, - .A7X: 7, - .A8: 8, - .A8X: 9, - .A9: 10, - .A9X: 11, - .A10: 12, - .A10X: 13, - .A11: 14, - .A12: 15, - .A12X: 16, - .A12Z: 16, - .A13: 17, - .A14: 18, - .M1: 19, - .A99: 99, - ] - return intValues[CPUinfo()] ?? 0 >= intValues[minimumSupported] ?? 0 - } + private func supportsFeature(minimumSupported: CPUModel) -> Bool { + let intValues: [CPUModel: Int] = [ + .A4: 1, + .A5: 2, + .A5X: 3, + .A6: 4, + .A6X: 5, + .A7: 6, + .A7X: 7, + .A8: 8, + .A8X: 9, + .A9: 10, + .A9X: 11, + .A10: 12, + .A10X: 13, + .A11: 14, + .A12: 15, + .A12X: 16, + .A12Z: 16, + .A13: 17, + .A14: 18, + .M1: 19, + .A99: 99, + ] + return intValues[CPUinfo()] ?? 0 >= intValues[minimumSupported] ?? 0 + } - /********************************************** - * CPUInfo(): - * Returns a hardcoded value of the current - * devices CPU name. - ***********************************************/ - private func CPUinfo() -> CPUModel { + /********************************************** + * CPUInfo(): + * Returns a hardcoded value of the current + * devices CPU name. + ***********************************************/ + private func CPUinfo() -> CPUModel { - #if targetEnvironment(simulator) - let identifier = ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"]! - #else + #if targetEnvironment(simulator) + let identifier = ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"]! + #else - var systemInfo = utsname() - uname(&systemInfo) - let machineMirror = Mirror(reflecting: systemInfo.machine) - let identifier = machineMirror.children.reduce("") { identifier, element in - guard let value = element.value as? Int8, value != 0 else { return identifier } - return identifier + String(UnicodeScalar(UInt8(value))) - } - #endif + var systemInfo = utsname() + uname(&systemInfo) + let machineMirror = Mirror(reflecting: systemInfo.machine) + let identifier = machineMirror.children.reduce("") { identifier, element in + guard let value = element.value as? Int8, value != 0 else { return identifier } + return identifier + String(UnicodeScalar(UInt8(value))) + } + #endif - switch identifier { - case "iPod5,1": return .A5 - case "iPod7,1": return .A8 - case "iPod9,1": return .A10 - case "iPhone3,1", "iPhone3,2", "iPhone3,3": return .A4 - case "iPhone4,1": return .A5 - case "iPhone5,1", "iPhone5,2": return .A6 - case "iPhone5,3", "iPhone5,4": return .A6 - case "iPhone6,1", "iPhone6,2": return .A7 - case "iPhone7,2": return .A8 - case "iPhone7,1": return .A8 - case "iPhone8,1": return .A9 - case "iPhone8,2", "iPhone8,4": return .A9 - case "iPhone9,1", "iPhone9,3": return .A10 - case "iPhone9,2", "iPhone9,4": return .A10 - case "iPhone10,1", "iPhone10,4": return .A11 - case "iPhone10,2", "iPhone10,5": return .A11 - case "iPhone10,3", "iPhone10,6": return .A11 - case "iPhone11,2", "iPhone11,6", "iPhone11,8": return .A12 - case "iPhone12,1", "iPhone12,3", "iPhone12,5", "iPhone12,8": return .A13 - case "iPhone13,1", "iPhone13,2", "iPhone13,3", "iPhone13,4": return .A14 - case "iPad2,1", "iPad2,2", "iPad2,3", "iPad2,4": return .A5 - case "iPad3,1", "iPad3,2", "iPad3,3": return .A5X - case "iPad3,4", "iPad3,5", "iPad3,6": return .A6X - case "iPad4,1", "iPad4,2", "iPad4,3": return .A7 - case "iPad5,3", "iPad5,4": return .A8X - case "iPad6,11", "iPad6,12": return .A9 - case "iPad2,5", "iPad2,6", "iPad2,7": return .A5 - case "iPad4,4", "iPad4,5", "iPad4,6": return .A7 - case "iPad4,7", "iPad4,8", "iPad4,9": return .A7 - case "iPad5,1", "iPad5,2": return .A8 - case "iPad11,1", "iPad11,2": return .A12 - case "iPad6,3", "iPad6,4": return .A9X - case "iPad6,7", "iPad6,8": return .A9X - case "iPad7,1", "iPad7,2": return .A10X - case "iPad7,3", "iPad7,4": return .A10X - case "iPad7,5", "iPad7,6", "iPad7,11", "iPad7,12": return .A10 - case "iPad8,1", "iPad8,2", "iPad8,3", "iPad8,4": return .A12X - case "iPad8,5", "iPad8,6", "iPad8,7", "iPad8,8": return .A12X - case "iPad8,9", "iPad8,10", "iPad8,11", "iPad8,12": return .A12Z - case "iPad11,3", "iPad11,4", "iPad11,6", "iPad11,7": return .A12 - case "iPad13,1", "iPad13,2": return .A14 - case "iPad13,8", "iPad13,9", "iPad13,10", "iPad13,11": return .M1 - case "AppleTV5,3": return .A8 - case "AppleTV6,2": return .A10X - case "AppleTV11,1": return .A12 - case "AudioAccessory1,1": return .A8 - default: return .A99 - } - } + switch identifier { + case "iPod5,1": return .A5 + case "iPod7,1": return .A8 + case "iPod9,1": return .A10 + case "iPhone3,1", "iPhone3,2", "iPhone3,3": return .A4 + case "iPhone4,1": return .A5 + case "iPhone5,1", "iPhone5,2": return .A6 + case "iPhone5,3", "iPhone5,4": return .A6 + case "iPhone6,1", "iPhone6,2": return .A7 + case "iPhone7,2": return .A8 + case "iPhone7,1": return .A8 + case "iPhone8,1": return .A9 + case "iPhone8,2", "iPhone8,4": return .A9 + case "iPhone9,1", "iPhone9,3": return .A10 + case "iPhone9,2", "iPhone9,4": return .A10 + case "iPhone10,1", "iPhone10,4": return .A11 + case "iPhone10,2", "iPhone10,5": return .A11 + case "iPhone10,3", "iPhone10,6": return .A11 + case "iPhone11,2", "iPhone11,6", "iPhone11,8": return .A12 + case "iPhone12,1", "iPhone12,3", "iPhone12,5", "iPhone12,8": return .A13 + case "iPhone13,1", "iPhone13,2", "iPhone13,3", "iPhone13,4": return .A14 + case "iPad2,1", "iPad2,2", "iPad2,3", "iPad2,4": return .A5 + case "iPad3,1", "iPad3,2", "iPad3,3": return .A5X + case "iPad3,4", "iPad3,5", "iPad3,6": return .A6X + case "iPad4,1", "iPad4,2", "iPad4,3": return .A7 + case "iPad5,3", "iPad5,4": return .A8X + case "iPad6,11", "iPad6,12": return .A9 + case "iPad2,5", "iPad2,6", "iPad2,7": return .A5 + case "iPad4,4", "iPad4,5", "iPad4,6": return .A7 + case "iPad4,7", "iPad4,8", "iPad4,9": return .A7 + case "iPad5,1", "iPad5,2": return .A8 + case "iPad11,1", "iPad11,2": return .A12 + case "iPad6,3", "iPad6,4": return .A9X + case "iPad6,7", "iPad6,8": return .A9X + case "iPad7,1", "iPad7,2": return .A10X + case "iPad7,3", "iPad7,4": return .A10X + case "iPad7,5", "iPad7,6", "iPad7,11", "iPad7,12": return .A10 + case "iPad8,1", "iPad8,2", "iPad8,3", "iPad8,4": return .A12X + case "iPad8,5", "iPad8,6", "iPad8,7", "iPad8,8": return .A12X + case "iPad8,9", "iPad8,10", "iPad8,11", "iPad8,12": return .A12Z + case "iPad11,3", "iPad11,4", "iPad11,6", "iPad11,7": return .A12 + case "iPad13,1", "iPad13,2": return .A14 + case "iPad13,8", "iPad13,9", "iPad13,10", "iPad13,11": return .M1 + case "AppleTV5,3": return .A8 + case "AppleTV6,2": return .A10X + case "AppleTV11,1": return .A12 + case "AudioAccessory1,1": return .A8 + default: return .A99 + } + } } diff --git a/Shared/Objects/DeviceRotationViewModifier.swift b/Shared/Objects/DeviceRotationViewModifier.swift index afd0050a..e9ac5bb6 100644 --- a/Shared/Objects/DeviceRotationViewModifier.swift +++ b/Shared/Objects/DeviceRotationViewModifier.swift @@ -13,20 +13,20 @@ import SwiftUI // Our custom view modifier to track rotation and // call our action struct DeviceRotationViewModifier: ViewModifier { - let action: (UIDeviceOrientation) -> Void + let action: (UIDeviceOrientation) -> Void - func body(content: Content) -> some View { - content - .onAppear() - .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in - action(UIDevice.current.orientation) - } - } + func body(content: Content) -> some View { + content + .onAppear() + .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in + action(UIDevice.current.orientation) + } + } } // A View wrapper to make the modifier easier to use extension View { - func onRotate(perform action: @escaping (UIDeviceOrientation) -> Void) -> some View { - self.modifier(DeviceRotationViewModifier(action: action)) - } + func onRotate(perform action: @escaping (UIDeviceOrientation) -> Void) -> some View { + self.modifier(DeviceRotationViewModifier(action: action)) + } } diff --git a/Shared/Objects/HTTPScheme.swift b/Shared/Objects/HTTPScheme.swift index 3f81fa39..5d717222 100644 --- a/Shared/Objects/HTTPScheme.swift +++ b/Shared/Objects/HTTPScheme.swift @@ -10,6 +10,6 @@ import Defaults import Foundation enum HTTPScheme: String, Defaults.Serializable, CaseIterable { - case http - case https + case http + case https } diff --git a/Shared/Objects/OverlaySliderColor.swift b/Shared/Objects/OverlaySliderColor.swift index b5b59286..9200b5c5 100644 --- a/Shared/Objects/OverlaySliderColor.swift +++ b/Shared/Objects/OverlaySliderColor.swift @@ -10,15 +10,15 @@ import Defaults import UIKit enum OverlaySliderColor: String, CaseIterable, DefaultsSerializable { - case white - case jellyfinPurple + case white + case jellyfinPurple - var displayLabel: String { - switch self { - case .white: - return "White" - case .jellyfinPurple: - return "Jellyfin Purple" - } - } + var displayLabel: String { + switch self { + case .white: + return "White" + case .jellyfinPurple: + return "Jellyfin Purple" + } + } } diff --git a/Shared/Objects/OverlayType.swift b/Shared/Objects/OverlayType.swift index 408748d8..bae92b38 100644 --- a/Shared/Objects/OverlayType.swift +++ b/Shared/Objects/OverlayType.swift @@ -10,15 +10,15 @@ import Defaults import Foundation enum OverlayType: String, CaseIterable, Defaults.Serializable { - case normal - case compact + case normal + case compact - var label: String { - switch self { - case .normal: - return L10n.normal - case .compact: - return L10n.compact - } - } + var label: String { + switch self { + case .normal: + return L10n.normal + case .compact: + return L10n.compact + } + } } diff --git a/Shared/Objects/PillStackable.swift b/Shared/Objects/PillStackable.swift index 6321219d..071adbb9 100644 --- a/Shared/Objects/PillStackable.swift +++ b/Shared/Objects/PillStackable.swift @@ -9,5 +9,5 @@ import Foundation protocol PillStackable { - var title: String { get } + var title: String { get } } diff --git a/Shared/Objects/PlaybackSpeed.swift b/Shared/Objects/PlaybackSpeed.swift index 35624b72..237c35c1 100644 --- a/Shared/Objects/PlaybackSpeed.swift +++ b/Shared/Objects/PlaybackSpeed.swift @@ -9,75 +9,75 @@ import Foundation enum PlaybackSpeed: Double, CaseIterable { - case quarter = 0.25 - case half = 0.5 - case threeQuarter = 0.75 - case one = 1.0 - case oneQuarter = 1.25 - case oneHalf = 1.5 - case oneThreeQuarter = 1.75 - case two = 2.0 + case quarter = 0.25 + case half = 0.5 + case threeQuarter = 0.75 + case one = 1.0 + case oneQuarter = 1.25 + case oneHalf = 1.5 + case oneThreeQuarter = 1.75 + case two = 2.0 - var displayTitle: String { - switch self { - case .quarter: - return "0.25x" - case .half: - return "0.5x" - case .threeQuarter: - return "0.75x" - case .one: - return "1x" - case .oneQuarter: - return "1.25x" - case .oneHalf: - return "1.5x" - case .oneThreeQuarter: - return "1.75x" - case .two: - return "2x" - } - } + var displayTitle: String { + switch self { + case .quarter: + return "0.25x" + case .half: + return "0.5x" + case .threeQuarter: + return "0.75x" + case .one: + return "1x" + case .oneQuarter: + return "1.25x" + case .oneHalf: + return "1.5x" + case .oneThreeQuarter: + return "1.75x" + case .two: + return "2x" + } + } - var previous: PlaybackSpeed? { - switch self { - case .quarter: - return nil - case .half: - return .quarter - case .threeQuarter: - return .half - case .one: - return .threeQuarter - case .oneQuarter: - return .one - case .oneHalf: - return .oneQuarter - case .oneThreeQuarter: - return .oneHalf - case .two: - return .oneThreeQuarter - } - } + var previous: PlaybackSpeed? { + switch self { + case .quarter: + return nil + case .half: + return .quarter + case .threeQuarter: + return .half + case .one: + return .threeQuarter + case .oneQuarter: + return .one + case .oneHalf: + return .oneQuarter + case .oneThreeQuarter: + return .oneHalf + case .two: + return .oneThreeQuarter + } + } - var next: PlaybackSpeed? { - switch self { - case .quarter: - return .half - case .half: - return .threeQuarter - case .threeQuarter: - return .one - case .one: - return .oneQuarter - case .oneQuarter: - return .oneHalf - case .oneHalf: - return .oneThreeQuarter - case .oneThreeQuarter: - return .two - case .two: - return nil - } - } + var next: PlaybackSpeed? { + switch self { + case .quarter: + return .half + case .half: + return .threeQuarter + case .threeQuarter: + return .one + case .one: + return .oneQuarter + case .oneQuarter: + return .oneHalf + case .oneHalf: + return .oneThreeQuarter + case .oneThreeQuarter: + return .two + case .two: + return nil + } + } } diff --git a/Shared/Objects/PortraitImageStackable.swift b/Shared/Objects/PortraitImageStackable.swift index 9d4c7d30..1245e1ef 100644 --- a/Shared/Objects/PortraitImageStackable.swift +++ b/Shared/Objects/PortraitImageStackable.swift @@ -9,11 +9,11 @@ import Foundation public protocol PortraitImageStackable { - func imageURLConstructor(maxWidth: Int) -> URL - var title: String { get } - var subtitle: String? { get } - var blurHash: String { get } - var failureInitials: String { get } - var portraitImageID: String { get } - var showTitle: Bool { get } + func imageURLConstructor(maxWidth: Int) -> URL + var title: String { get } + var subtitle: String? { get } + var blurHash: String { get } + var failureInitials: String { get } + var portraitImageID: String { get } + var showTitle: Bool { get } } diff --git a/Shared/Objects/PosterSize.swift b/Shared/Objects/PosterSize.swift index 85b3f365..b4b9fd17 100644 --- a/Shared/Objects/PosterSize.swift +++ b/Shared/Objects/PosterSize.swift @@ -9,6 +9,6 @@ import Foundation enum PosterSize { - case small - case normal + case small + case normal } diff --git a/Shared/Objects/SubtitleSize.swift b/Shared/Objects/SubtitleSize.swift index 9e919efb..ef94d13b 100644 --- a/Shared/Objects/SubtitleSize.swift +++ b/Shared/Objects/SubtitleSize.swift @@ -9,50 +9,50 @@ import Defaults enum SubtitleSize: Int32, CaseIterable, Defaults.Serializable { - case smallest - case smaller - case regular - case larger - case largest + case smallest + case smaller + case regular + case larger + case largest } // MARK: - appearance extension SubtitleSize { - var label: String { - switch self { - case .smallest: - return L10n.smallest - case .smaller: - return L10n.smaller - case .regular: - return L10n.regular - case .larger: - return L10n.larger - case .largest: - return L10n.largest - } - } + var label: String { + switch self { + case .smallest: + return L10n.smallest + case .smaller: + return L10n.smaller + case .regular: + return L10n.regular + case .larger: + return L10n.larger + case .largest: + return L10n.largest + } + } } // MARK: - sizing for VLC extension SubtitleSize { - /// Value to be passed to VLCKit (via hacky internal property, until VLCKit 4) - /// - /// note that it doesn't correspond to actual font sizes; a smaller int creates bigger text - var textRendererFontSize: Int { - switch self { - case .smallest: - return 24 - case .smaller: - return 20 - case .regular: - return 16 - case .larger: - return 12 - case .largest: - return 8 - } - } + /// Value to be passed to VLCKit (via hacky internal property, until VLCKit 4) + /// + /// note that it doesn't correspond to actual font sizes; a smaller int creates bigger text + var textRendererFontSize: Int { + switch self { + case .smallest: + return 24 + case .smaller: + return 20 + case .regular: + return 16 + case .larger: + return 12 + case .largest: + return 8 + } + } } diff --git a/Shared/Objects/TrackLanguage.swift b/Shared/Objects/TrackLanguage.swift index 28b062ae..8d95e63a 100644 --- a/Shared/Objects/TrackLanguage.swift +++ b/Shared/Objects/TrackLanguage.swift @@ -9,8 +9,8 @@ import Foundation struct TrackLanguage: Hashable { - var name: String - var isoCode: String + var name: String + var isoCode: String - static let auto = TrackLanguage(name: "Auto", isoCode: "Auto") + static let auto = TrackLanguage(name: "Auto", isoCode: "Auto") } diff --git a/Shared/Objects/Typings.swift b/Shared/Objects/Typings.swift index 4858c823..5be73dba 100644 --- a/Shared/Objects/Typings.swift +++ b/Shared/Objects/Typings.swift @@ -11,80 +11,80 @@ import Foundation import JellyfinAPI struct LibraryFilters: Codable, Hashable { - var filters: [ItemFilter] = [] - var sortOrder: [APISortOrder] = [.descending] - var withGenres: [NameGuidPair] = [] - var tags: [String] = [] - var sortBy: [SortBy] = [.name] + var filters: [ItemFilter] = [] + var sortOrder: [APISortOrder] = [.descending] + var withGenres: [NameGuidPair] = [] + var tags: [String] = [] + var sortBy: [SortBy] = [.name] } public enum SortBy: String, Codable, CaseIterable { - case premiereDate = "PremiereDate" - case name = "SortName" - case dateAdded = "DateCreated" + case premiereDate = "PremiereDate" + case name = "SortName" + case dateAdded = "DateCreated" } extension SortBy { - var localized: String { - switch self { - case .premiereDate: - return "Premiere date" - case .name: - return "Name" - case .dateAdded: - return "Date added" - } - } + var localized: String { + switch self { + case .premiereDate: + return "Premiere date" + case .name: + return "Name" + case .dateAdded: + return "Date added" + } + } } extension ItemFilter { - static var supportedTypes: [ItemFilter] { - [.isUnplayed, isPlayed, .isFavorite, .likes] - } + static var supportedTypes: [ItemFilter] { + [.isUnplayed, isPlayed, .isFavorite, .likes] + } - var localized: String { - switch self { - case .isUnplayed: - return "Unplayed" - case .isPlayed: - return "Played" - case .isFavorite: - return "Favorites" - case .likes: - return "Liked Items" - default: - return "" - } - } + var localized: String { + switch self { + case .isUnplayed: + return "Unplayed" + case .isPlayed: + return "Played" + case .isFavorite: + return "Favorites" + case .likes: + return "Liked Items" + default: + return "" + } + } } extension APISortOrder { - var localized: String { - switch self { - case .ascending: - return "Ascending" - case .descending: - return "Descending" - } - } + var localized: String { + switch self { + case .ascending: + return "Ascending" + case .descending: + return "Descending" + } + } } enum ItemType: String { - case episode = "Episode" - case movie = "Movie" - case series = "Series" - case season = "Season" + case episode = "Episode" + case movie = "Movie" + case series = "Series" + case season = "Season" - var localized: String { - switch self { - case .episode: - return L10n.episodes - case .movie: - return "Movies" - case .series: - return "Shows" - default: - return "" - } - } + var localized: String { + switch self { + case .episode: + return L10n.episodes + case .movie: + return "Movies" + case .series: + return "Shows" + default: + return "" + } + } } diff --git a/Shared/Objects/VideoPlayerJumpLength.swift b/Shared/Objects/VideoPlayerJumpLength.swift index 10acb966..5c337301 100644 --- a/Shared/Objects/VideoPlayerJumpLength.swift +++ b/Shared/Objects/VideoPlayerJumpLength.swift @@ -10,42 +10,42 @@ import Defaults import UIKit enum VideoPlayerJumpLength: Int32, CaseIterable, Defaults.Serializable { - case thirty = 30 - case fifteen = 15 - case ten = 10 - case five = 5 + case thirty = 30 + case fifteen = 15 + case ten = 10 + case five = 5 - var label: String { - L10n.jumpLengthSeconds("\(self.rawValue)") - } + var label: String { + L10n.jumpLengthSeconds("\(self.rawValue)") + } - var shortLabel: String { - "\(self.rawValue)s" - } + var shortLabel: String { + "\(self.rawValue)s" + } - var forwardImageLabel: String { - switch self { - case .thirty: - return "goforward.30" - case .fifteen: - return "goforward.15" - case .ten: - return "goforward.10" - case .five: - return "goforward.5" - } - } + var forwardImageLabel: String { + switch self { + case .thirty: + return "goforward.30" + case .fifteen: + return "goforward.15" + case .ten: + return "goforward.10" + case .five: + return "goforward.5" + } + } - var backwardImageLabel: String { - switch self { - case .thirty: - return "gobackward.30" - case .fifteen: - return "gobackward.15" - case .ten: - return "gobackward.10" - case .five: - return "gobackward.5" - } - } + var backwardImageLabel: String { + switch self { + case .thirty: + return "gobackward.30" + case .fifteen: + return "gobackward.15" + case .ten: + return "gobackward.10" + case .five: + return "gobackward.5" + } + } } diff --git a/Shared/ServerDiscovery/ServerDiscovery.swift b/Shared/ServerDiscovery/ServerDiscovery.swift index 2a571d80..26ba7486 100644 --- a/Shared/ServerDiscovery/ServerDiscovery.swift +++ b/Shared/ServerDiscovery/ServerDiscovery.swift @@ -10,69 +10,69 @@ import Foundation import UDPBroadcast public class ServerDiscovery { - public struct ServerLookupResponse: Codable, Hashable, Identifiable { + public struct ServerLookupResponse: Codable, Hashable, Identifiable { - public func hash(into hasher: inout Hasher) { - hasher.combine(id) - } + public func hash(into hasher: inout Hasher) { + hasher.combine(id) + } - private let address: String - public let id: String - public let name: String + private let address: String + public let id: String + public let name: String - public var url: URL { - URL(string: self.address)! - } + public var url: URL { + URL(string: self.address)! + } - public var host: String { - let components = URLComponents(string: self.address) - if let host = components?.host { - return host - } - return self.address - } + public var host: String { + let components = URLComponents(string: self.address) + if let host = components?.host { + return host + } + return self.address + } - public var port: Int { - let components = URLComponents(string: self.address) - if let port = components?.port { - return port - } - return 7359 - } + public var port: Int { + let components = URLComponents(string: self.address) + if let port = components?.port { + return port + } + return 7359 + } - enum CodingKeys: String, CodingKey { - case address = "Address" - case id = "Id" - case name = "Name" - } - } + enum CodingKeys: String, CodingKey { + case address = "Address" + case id = "Id" + case name = "Name" + } + } - private var connection: UDPBroadcastConnection? + private var connection: UDPBroadcastConnection? - init() {} + init() {} - public func locateServer(completion: @escaping (ServerLookupResponse?) -> Void) { + public func locateServer(completion: @escaping (ServerLookupResponse?) -> Void) { - func receiveHandler(_ ipAddress: String, _ port: Int, _ data: Data) { - do { - let response = try JSONDecoder().decode(ServerLookupResponse.self, from: data) - LogManager.log.debug("Received JellyfinServer from \"\(response.name)\"", tag: "ServerDiscovery") - completion(response) - } catch { - completion(nil) - } - } + func receiveHandler(_ ipAddress: String, _ port: Int, _ data: Data) { + do { + let response = try JSONDecoder().decode(ServerLookupResponse.self, from: data) + LogManager.log.debug("Received JellyfinServer from \"\(response.name)\"", tag: "ServerDiscovery") + completion(response) + } catch { + completion(nil) + } + } - func errorHandler(error: UDPBroadcastConnection.ConnectionError) { - LogManager.log.error("Error handling response: \(error.localizedDescription)", tag: "ServerDiscovery") - } + func errorHandler(error: UDPBroadcastConnection.ConnectionError) { + LogManager.log.error("Error handling response: \(error.localizedDescription)", tag: "ServerDiscovery") + } - do { - self.connection = try! UDPBroadcastConnection(port: 7359, handler: receiveHandler, errorHandler: errorHandler) - try self.connection?.sendBroadcast("Who is JellyfinServer?") - LogManager.log.debug("Discovery broadcast sent", tag: "ServerDiscovery") - } catch { - LogManager.log.error("Error sending discovery broadcast", tag: "ServerDiscovery") - } - } + do { + self.connection = try! UDPBroadcastConnection(port: 7359, handler: receiveHandler, errorHandler: errorHandler) + try self.connection?.sendBroadcast("Who is JellyfinServer?") + LogManager.log.debug("Discovery broadcast sent", tag: "ServerDiscovery") + } catch { + LogManager.log.error("Error sending discovery broadcast", tag: "ServerDiscovery") + } + } } diff --git a/Shared/Singleton/BackgroundManager.swift b/Shared/Singleton/BackgroundManager.swift index 79aaf7fa..817e925b 100644 --- a/Shared/Singleton/BackgroundManager.swift +++ b/Shared/Singleton/BackgroundManager.swift @@ -9,27 +9,27 @@ import Foundation final class BackgroundManager { - static let current = BackgroundManager() - fileprivate(set) var backgroundURL: URL? - fileprivate(set) var blurhash: String = "001fC^" + static let current = BackgroundManager() + fileprivate(set) var backgroundURL: URL? + fileprivate(set) var blurhash: String = "001fC^" - init() { - backgroundURL = nil - } + init() { + backgroundURL = nil + } - func setBackground(to: URL, hash: String) { - self.backgroundURL = to - self.blurhash = hash + func setBackground(to: URL, hash: String) { + self.backgroundURL = to + self.blurhash = hash - let nc = NotificationCenter.default - nc.post(name: Notification.Name("backgroundDidChange"), object: nil) - } + let nc = NotificationCenter.default + nc.post(name: Notification.Name("backgroundDidChange"), object: nil) + } - func clearBackground() { - self.backgroundURL = nil - self.blurhash = "001fC^" + func clearBackground() { + self.backgroundURL = nil + self.blurhash = "001fC^" - let nc = NotificationCenter.default - nc.post(name: Notification.Name("backgroundDidChange"), object: nil) - } + let nc = NotificationCenter.default + nc.post(name: Notification.Name("backgroundDidChange"), object: nil) + } } diff --git a/Shared/Singleton/LogManager.swift b/Shared/Singleton/LogManager.swift index 9b3dbaca..a54457fb 100644 --- a/Shared/Singleton/LogManager.swift +++ b/Shared/Singleton/LogManager.swift @@ -11,48 +11,60 @@ import Puppy class LogManager { - static let log = Puppy() + static let log = Puppy() - static func setup() { + static func setup() { - let logsDirectory = getDocumentsDirectory().appendingPathComponent("logs", isDirectory: true) + let logsDirectory = getDocumentsDirectory().appendingPathComponent("logs", isDirectory: true) - do { - try FileManager.default.createDirectory(atPath: logsDirectory.path, - withIntermediateDirectories: true, - attributes: nil) - } catch { - // logs directory already created - } + do { + try FileManager.default.createDirectory( + atPath: logsDirectory.path, + withIntermediateDirectories: true, + attributes: nil + ) + } catch { + // logs directory already created + } - let logFileURL = logsDirectory.appendingPathComponent("swiftfin_log.log") + let logFileURL = logsDirectory.appendingPathComponent("swiftfin_log.log") - let fileRotationLogger = try! FileRotationLogger("org.jellyfin.swiftfin.logger.file-rotation", - fileURL: logFileURL) - fileRotationLogger.format = LogFormatter() + let fileRotationLogger = try! FileRotationLogger( + "org.jellyfin.swiftfin.logger.file-rotation", + fileURL: logFileURL + ) + fileRotationLogger.format = LogFormatter() - let consoleLogger = ConsoleLogger("org.jellyfin.swiftfin.logger.console") - consoleLogger.format = LogFormatter() + let consoleLogger = ConsoleLogger("org.jellyfin.swiftfin.logger.console") + consoleLogger.format = LogFormatter() - log.add(fileRotationLogger, withLevel: .debug) - log.add(consoleLogger, withLevel: .debug) - } + log.add(fileRotationLogger, withLevel: .debug) + log.add(consoleLogger, withLevel: .debug) + } - private static func getDocumentsDirectory() -> URL { - // find all possible documents directories for this user - let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) + private static func getDocumentsDirectory() -> URL { + // find all possible documents directories for this user + let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask) - // just send back the first one, which ought to be the only one - return paths[0] - } + // just send back the first one, which ought to be the only one + return paths[0] + } } class LogFormatter: LogFormattable { - func formatMessage(_ level: LogLevel, message: String, tag: String, function: String, - file: String, line: UInt, swiftLogInfo: [String: String], - label: String, date: Date, threadID: UInt64) -> String - { - let file = shortFileName(file).replacingOccurrences(of: ".swift", with: "") - return " [\(level.emoji) \(level)] \(file)#\(line):\(function) \(message)" - } + func formatMessage( + _ level: LogLevel, + message: String, + tag: String, + function: String, + file: String, + line: UInt, + swiftLogInfo: [String: String], + label: String, + date: Date, + threadID: UInt64 + ) -> String { + let file = shortFileName(file).replacingOccurrences(of: ".swift", with: "") + return " [\(level.emoji) \(level)] \(file)#\(line):\(function) \(message)" + } } diff --git a/Shared/Singleton/SessionManager.swift b/Shared/Singleton/SessionManager.swift index c17c45b8..e4e8e063 100644 --- a/Shared/Singleton/SessionManager.swift +++ b/Shared/Singleton/SessionManager.swift @@ -20,325 +20,353 @@ typealias CurrentLogin = (server: SwiftfinStore.State.Server, user: SwiftfinStor final class SessionManager { - // MARK: currentLogin - - private(set) var currentLogin: CurrentLogin! - - // MARK: main - - static let main = SessionManager() - - // MARK: init - - private init() { - if let lastUserID = Defaults[.lastServerUserID], - let user = try? SwiftfinStore.dataStack.fetchOne(From(), - [Where("id == %@", lastUserID)]) - { - - guard let server = user.server, - let accessToken = user.accessToken else { fatalError("No associated server or access token for last user?") } - guard let existingServer = SwiftfinStore.dataStack.fetchExisting(server) else { return } - - JellyfinAPIAPI.basePath = server.currentURI - setAuthHeader(with: accessToken.value) - currentLogin = (server: existingServer.state, user: user.state) - } - } - - // MARK: fetchServers - - func fetchServers() -> [SwiftfinStore.State.Server] { - let servers = try! SwiftfinStore.dataStack.fetchAll(From()) - return servers.map(\.state) - } - - // MARK: fetchUsers - - func fetchUsers(for server: SwiftfinStore.State.Server) -> [SwiftfinStore.State.User] { - guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(From(), - Where("id == %@", server.id)) - else { fatalError("No stored server associated with given state server?") } - return storedServer.users.map(\.state).sorted(by: { $0.username < $1.username }) - } - - // MARK: connectToServer publisher - - // Connects to a server at the given uri, storing if successful - func connectToServer(with uri: String) -> AnyPublisher { - var uriComponents = URLComponents(string: uri) ?? URLComponents() - - if uriComponents.scheme == nil { - uriComponents.scheme = Defaults[.defaultHTTPScheme].rawValue - } - - var uri = uriComponents.string ?? "" - - if uri.last == "/" { - uri = String(uri.dropLast()) - } - - JellyfinAPIAPI.basePath = uri - - return SystemAPI.getPublicSystemInfo() - .tryMap { response -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in - - let transaction = SwiftfinStore.dataStack.beginUnsafe() - let newServer = transaction.create(Into()) - - guard let name = response.serverName, - let id = response.id, - let os = response.operatingSystem, - let version = response.version else { throw JellyfinAPIError("Missing server data from network call") } - - newServer.uris = [uri] - newServer.currentURI = uri - newServer.name = name - newServer.id = id - newServer.os = os - newServer.version = version - newServer.users = [] - - // Check for existing server on device - if let existingServer = try? SwiftfinStore.dataStack.fetchOne(From(), - [Where("id == %@", - newServer.id)]) - { - throw SwiftfinStore.Error.existingServer(existingServer.state) - } - - return (newServer, transaction) - } - .handleEvents(receiveOutput: { _, transaction in - try? transaction.commitAndWait() - }) - .map { server, _ in - server.state - } - .eraseToAnyPublisher() - } - - // MARK: addURIToServer publisher - - func addURIToServer(server: SwiftfinStore.State.Server, uri: String) -> AnyPublisher { - Just(server) - .tryMap { server -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in - - let transaction = SwiftfinStore.dataStack.beginUnsafe() - - guard let existingServer = try? SwiftfinStore.dataStack.fetchOne(From(), - [Where("id == %@", - server.id)]) - else { - fatalError("No stored server associated with given state server?") - } - - guard let editServer = transaction.edit(existingServer) else { fatalError("Can't get proxy for existing object?") } - editServer.uris.insert(uri) - - return (editServer, transaction) - } - .handleEvents(receiveOutput: { _, transaction in - try? transaction.commitAndWait() - }) - .map { server, _ in - server.state - } - .eraseToAnyPublisher() - } - - // MARK: setServerCurrentURI publisher - - func setServerCurrentURI(server: SwiftfinStore.State.Server, uri: String) -> AnyPublisher { - Just(server) - .tryMap { server -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in - - let transaction = SwiftfinStore.dataStack.beginUnsafe() - - guard let existingServer = try? SwiftfinStore.dataStack.fetchOne(From(), - [Where("id == %@", - server.id)]) - else { - fatalError("No stored server associated with given state server?") - } - - if !existingServer.uris.contains(uri) { - fatalError("Attempting to set current uri while server doesn't contain it?") - } - - guard let editServer = transaction.edit(existingServer) else { fatalError("Can't get proxy for existing object?") } - editServer.currentURI = uri - - return (editServer, transaction) - } - .handleEvents(receiveOutput: { _, transaction in - try? transaction.commitAndWait() - }) - .map { server, _ in - server.state - } - .eraseToAnyPublisher() - } - - // MARK: loginUser publisher - - // Logs in a user with an associated server, storing if successful - func loginUser(server: SwiftfinStore.State.Server, username: String, - password: String) -> AnyPublisher - { - setAuthHeader(with: "") - - JellyfinAPIAPI.basePath = server.currentURI - - return UserAPI.authenticateUserByName(authenticateUserByNameRequest: .init(username: username, pw: password)) - .tryMap { response -> (SwiftfinStore.Models.StoredServer, SwiftfinStore.Models.StoredUser, UnsafeDataTransaction) in - - guard let accessToken = response.accessToken else { throw JellyfinAPIError("Access token missing from network call") } - - let transaction = SwiftfinStore.dataStack.beginUnsafe() - let newUser = transaction.create(Into()) - - guard let username = response.user?.name, - let id = response.user?.id else { throw JellyfinAPIError("Missing user data from network call") } - - newUser.username = username - newUser.id = id - newUser.appleTVID = "" - - // Check for existing user on device - if let existingUser = try? SwiftfinStore.dataStack.fetchOne(From(), - [Where("id == %@", - newUser.id)]) - { - throw SwiftfinStore.Error.existingUser(existingUser.state) - } - - let newAccessToken = transaction.create(Into()) - newAccessToken.value = accessToken - newUser.accessToken = newAccessToken - - guard let userServer = try? SwiftfinStore.dataStack.fetchOne(From(), - [ - Where("id == %@", - server.id), - ]) - else { fatalError("No stored server associated with given state server?") } - - guard let editUserServer = transaction.edit(userServer) else { fatalError("Can't get proxy for existing object?") } - editUserServer.users.insert(newUser) - - return (editUserServer, newUser, transaction) - } - .handleEvents(receiveOutput: { [unowned self] server, user, transaction in - setAuthHeader(with: user.accessToken?.value ?? "") - try? transaction.commitAndWait() - - // Fetch for the right queue - let currentServer = SwiftfinStore.dataStack.fetchExisting(server)! - let currentUser = SwiftfinStore.dataStack.fetchExisting(user)! - - Defaults[.lastServerUserID] = user.id - - currentLogin = (server: currentServer.state, user: currentUser.state) - Notifications[.didSignIn].post() - }) - .map { _, user, _ in - user.state - } - .eraseToAnyPublisher() - } - - // MARK: loginUser - - func loginUser(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) { - JellyfinAPIAPI.basePath = server.currentURI - Defaults[.lastServerUserID] = user.id - setAuthHeader(with: user.accessToken) - currentLogin = (server: server, user: user) - Notifications[.didSignIn].post() - } - - // MARK: logout - - func logout() { - currentLogin = nil - JellyfinAPIAPI.basePath = "" - setAuthHeader(with: "") - Defaults[.lastServerUserID] = nil - Notifications[.didSignOut].post() - } - - // MARK: purge - - func purge() { - // Delete all servers - let servers = fetchServers() - - for server in servers { - delete(server: server) - } - - Notifications[.didPurge].post() - } - - // MARK: delete user - - func delete(user: SwiftfinStore.State.User) { - guard let storedUser = try? SwiftfinStore.dataStack.fetchOne(From(), - [Where("id == %@", user.id)]) - else { fatalError("No stored user for state user?") } - _delete(user: storedUser, transaction: nil) - } - - // MARK: delete server - - func delete(server: SwiftfinStore.State.Server) { - guard let storedServer = try? SwiftfinStore.dataStack.fetchOne(From(), - [Where("id == %@", server.id)]) - else { fatalError("No stored server for state server?") } - _delete(server: storedServer, transaction: nil) - } - - private func _delete(user: SwiftfinStore.Models.StoredUser, transaction: UnsafeDataTransaction?) { - guard let storedAccessToken = user.accessToken else { fatalError("No access token for stored user?") } - - let transaction = transaction == nil ? SwiftfinStore.dataStack.beginUnsafe() : transaction! - transaction.delete(storedAccessToken) - transaction.delete(user) - try? transaction.commitAndWait() - } - - private func _delete(server: SwiftfinStore.Models.StoredServer, transaction: UnsafeDataTransaction?) { - let transaction = transaction == nil ? SwiftfinStore.dataStack.beginUnsafe() : transaction! - - for user in server.users { - _delete(user: user, transaction: transaction) - } - - transaction.delete(server) - try? transaction.commitAndWait() - } - - private func setAuthHeader(with accessToken: String) { - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String - var deviceName = UIDevice.current.name - deviceName = deviceName.folding(options: .diacriticInsensitive, locale: .current) - deviceName = String(deviceName.unicodeScalars.filter { CharacterSet.urlQueryAllowed.contains($0) }) - - let platform: String - #if os(tvOS) - platform = "tvOS" - #else - platform = "iOS" - #endif - - var header = "MediaBrowser " - header.append("Client=\"Jellyfin \(platform)\", ") - header.append("Device=\"\(deviceName)\", ") - header.append("DeviceId=\"\(platform)_\(UIDevice.vendorUUIDString)_\(String(Date().timeIntervalSince1970))\", ") - header.append("Version=\"\(appVersion ?? "0.0.1")\", ") - header.append("Token=\"\(accessToken)\"") - - JellyfinAPIAPI.customHeaders["X-Emby-Authorization"] = header - } + // MARK: currentLogin + + private(set) var currentLogin: CurrentLogin! + + // MARK: main + + static let main = SessionManager() + + // MARK: init + + private init() { + if let lastUserID = Defaults[.lastServerUserID], + let user = try? SwiftfinStore.dataStack.fetchOne( + From(), + [Where("id == %@", lastUserID)] + ) + { + + guard let server = user.server, + let accessToken = user.accessToken else { fatalError("No associated server or access token for last user?") } + guard let existingServer = SwiftfinStore.dataStack.fetchExisting(server) else { return } + + JellyfinAPIAPI.basePath = server.currentURI + setAuthHeader(with: accessToken.value) + currentLogin = (server: existingServer.state, user: user.state) + } + } + + // MARK: fetchServers + + func fetchServers() -> [SwiftfinStore.State.Server] { + let servers = try! SwiftfinStore.dataStack.fetchAll(From()) + return servers.map(\.state) + } + + // MARK: fetchUsers + + func fetchUsers(for server: SwiftfinStore.State.Server) -> [SwiftfinStore.State.User] { + guard let storedServer = try? SwiftfinStore.dataStack.fetchOne( + From(), + Where("id == %@", server.id) + ) + else { fatalError("No stored server associated with given state server?") } + return storedServer.users.map(\.state).sorted(by: { $0.username < $1.username }) + } + + // MARK: connectToServer publisher + + // Connects to a server at the given uri, storing if successful + func connectToServer(with uri: String) -> AnyPublisher { + var uriComponents = URLComponents(string: uri) ?? URLComponents() + + if uriComponents.scheme == nil { + uriComponents.scheme = Defaults[.defaultHTTPScheme].rawValue + } + + var uri = uriComponents.string ?? "" + + if uri.last == "/" { + uri = String(uri.dropLast()) + } + + JellyfinAPIAPI.basePath = uri + + return SystemAPI.getPublicSystemInfo() + .tryMap { response -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in + + let transaction = SwiftfinStore.dataStack.beginUnsafe() + let newServer = transaction.create(Into()) + + guard let name = response.serverName, + let id = response.id, + let os = response.operatingSystem, + let version = response.version else { throw JellyfinAPIError("Missing server data from network call") } + + newServer.uris = [uri] + newServer.currentURI = uri + newServer.name = name + newServer.id = id + newServer.os = os + newServer.version = version + newServer.users = [] + + // Check for existing server on device + if let existingServer = try? SwiftfinStore.dataStack.fetchOne( + From(), + [Where( + "id == %@", + newServer.id + )] + ) { + throw SwiftfinStore.Error.existingServer(existingServer.state) + } + + return (newServer, transaction) + } + .handleEvents(receiveOutput: { _, transaction in + try? transaction.commitAndWait() + }) + .map { server, _ in + server.state + } + .eraseToAnyPublisher() + } + + // MARK: addURIToServer publisher + + func addURIToServer(server: SwiftfinStore.State.Server, uri: String) -> AnyPublisher { + Just(server) + .tryMap { server -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in + + let transaction = SwiftfinStore.dataStack.beginUnsafe() + + guard let existingServer = try? SwiftfinStore.dataStack.fetchOne( + From(), + [Where( + "id == %@", + server.id + )] + ) + else { + fatalError("No stored server associated with given state server?") + } + + guard let editServer = transaction.edit(existingServer) else { fatalError("Can't get proxy for existing object?") } + editServer.uris.insert(uri) + + return (editServer, transaction) + } + .handleEvents(receiveOutput: { _, transaction in + try? transaction.commitAndWait() + }) + .map { server, _ in + server.state + } + .eraseToAnyPublisher() + } + + // MARK: setServerCurrentURI publisher + + func setServerCurrentURI(server: SwiftfinStore.State.Server, uri: String) -> AnyPublisher { + Just(server) + .tryMap { server -> (SwiftfinStore.Models.StoredServer, UnsafeDataTransaction) in + + let transaction = SwiftfinStore.dataStack.beginUnsafe() + + guard let existingServer = try? SwiftfinStore.dataStack.fetchOne( + From(), + [Where( + "id == %@", + server.id + )] + ) + else { + fatalError("No stored server associated with given state server?") + } + + if !existingServer.uris.contains(uri) { + fatalError("Attempting to set current uri while server doesn't contain it?") + } + + guard let editServer = transaction.edit(existingServer) else { fatalError("Can't get proxy for existing object?") } + editServer.currentURI = uri + + return (editServer, transaction) + } + .handleEvents(receiveOutput: { _, transaction in + try? transaction.commitAndWait() + }) + .map { server, _ in + server.state + } + .eraseToAnyPublisher() + } + + // MARK: loginUser publisher + + // Logs in a user with an associated server, storing if successful + func loginUser( + server: SwiftfinStore.State.Server, + username: String, + password: String + ) -> AnyPublisher { + setAuthHeader(with: "") + + JellyfinAPIAPI.basePath = server.currentURI + + return UserAPI.authenticateUserByName(authenticateUserByNameRequest: .init(username: username, pw: password)) + .tryMap { response -> (SwiftfinStore.Models.StoredServer, SwiftfinStore.Models.StoredUser, UnsafeDataTransaction) in + + guard let accessToken = response.accessToken else { throw JellyfinAPIError("Access token missing from network call") } + + let transaction = SwiftfinStore.dataStack.beginUnsafe() + let newUser = transaction.create(Into()) + + guard let username = response.user?.name, + let id = response.user?.id else { throw JellyfinAPIError("Missing user data from network call") } + + newUser.username = username + newUser.id = id + newUser.appleTVID = "" + + // Check for existing user on device + if let existingUser = try? SwiftfinStore.dataStack.fetchOne( + From(), + [Where( + "id == %@", + newUser.id + )] + ) { + throw SwiftfinStore.Error.existingUser(existingUser.state) + } + + let newAccessToken = transaction.create(Into()) + newAccessToken.value = accessToken + newUser.accessToken = newAccessToken + + guard let userServer = try? SwiftfinStore.dataStack.fetchOne( + From(), + [ + Where( + "id == %@", + server.id + ), + ] + ) + else { fatalError("No stored server associated with given state server?") } + + guard let editUserServer = transaction.edit(userServer) else { fatalError("Can't get proxy for existing object?") } + editUserServer.users.insert(newUser) + + return (editUserServer, newUser, transaction) + } + .handleEvents(receiveOutput: { [unowned self] server, user, transaction in + setAuthHeader(with: user.accessToken?.value ?? "") + try? transaction.commitAndWait() + + // Fetch for the right queue + let currentServer = SwiftfinStore.dataStack.fetchExisting(server)! + let currentUser = SwiftfinStore.dataStack.fetchExisting(user)! + + Defaults[.lastServerUserID] = user.id + + currentLogin = (server: currentServer.state, user: currentUser.state) + Notifications[.didSignIn].post() + }) + .map { _, user, _ in + user.state + } + .eraseToAnyPublisher() + } + + // MARK: loginUser + + func loginUser(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) { + JellyfinAPIAPI.basePath = server.currentURI + Defaults[.lastServerUserID] = user.id + setAuthHeader(with: user.accessToken) + currentLogin = (server: server, user: user) + Notifications[.didSignIn].post() + } + + // MARK: logout + + func logout() { + currentLogin = nil + JellyfinAPIAPI.basePath = "" + setAuthHeader(with: "") + Defaults[.lastServerUserID] = nil + Notifications[.didSignOut].post() + } + + // MARK: purge + + func purge() { + // Delete all servers + let servers = fetchServers() + + for server in servers { + delete(server: server) + } + + Notifications[.didPurge].post() + } + + // MARK: delete user + + func delete(user: SwiftfinStore.State.User) { + guard let storedUser = try? SwiftfinStore.dataStack.fetchOne( + From(), + [Where("id == %@", user.id)] + ) + else { fatalError("No stored user for state user?") } + _delete(user: storedUser, transaction: nil) + } + + // MARK: delete server + + func delete(server: SwiftfinStore.State.Server) { + guard let storedServer = try? SwiftfinStore.dataStack.fetchOne( + From(), + [Where("id == %@", server.id)] + ) + else { fatalError("No stored server for state server?") } + _delete(server: storedServer, transaction: nil) + } + + private func _delete(user: SwiftfinStore.Models.StoredUser, transaction: UnsafeDataTransaction?) { + guard let storedAccessToken = user.accessToken else { fatalError("No access token for stored user?") } + + let transaction = transaction == nil ? SwiftfinStore.dataStack.beginUnsafe() : transaction! + transaction.delete(storedAccessToken) + transaction.delete(user) + try? transaction.commitAndWait() + } + + private func _delete(server: SwiftfinStore.Models.StoredServer, transaction: UnsafeDataTransaction?) { + let transaction = transaction == nil ? SwiftfinStore.dataStack.beginUnsafe() : transaction! + + for user in server.users { + _delete(user: user, transaction: transaction) + } + + transaction.delete(server) + try? transaction.commitAndWait() + } + + private func setAuthHeader(with accessToken: String) { + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + var deviceName = UIDevice.current.name + deviceName = deviceName.folding(options: .diacriticInsensitive, locale: .current) + deviceName = String(deviceName.unicodeScalars.filter { CharacterSet.urlQueryAllowed.contains($0) }) + + let platform: String + #if os(tvOS) + platform = "tvOS" + #else + platform = "iOS" + #endif + + var header = "MediaBrowser " + header.append("Client=\"Jellyfin \(platform)\", ") + header.append("Device=\"\(deviceName)\", ") + header.append("DeviceId=\"\(platform)_\(UIDevice.vendorUUIDString)_\(String(Date().timeIntervalSince1970))\", ") + header.append("Version=\"\(appVersion ?? "0.0.1")\", ") + header.append("Token=\"\(accessToken)\"") + + JellyfinAPIAPI.customHeaders["X-Emby-Authorization"] = header + } } diff --git a/Shared/Singleton/SwiftfinNotificationCenter.swift b/Shared/Singleton/SwiftfinNotificationCenter.swift index 23ec8c4d..78712cbc 100644 --- a/Shared/Singleton/SwiftfinNotificationCenter.swift +++ b/Shared/Singleton/SwiftfinNotificationCenter.swift @@ -10,61 +10,61 @@ import Foundation class SwiftfinNotification { - private let notificationName: Notification.Name + private let notificationName: Notification.Name - fileprivate init(_ notificationName: Notification.Name) { - self.notificationName = notificationName - } + fileprivate init(_ notificationName: Notification.Name) { + self.notificationName = notificationName + } - func post(object: Any? = nil) { - Notifications.main.post(name: notificationName, object: object) - } + func post(object: Any? = nil) { + Notifications.main.post(name: notificationName, object: object) + } - func subscribe(_ observer: Any, selector: Selector) { - Notifications.main.addObserver(observer, selector: selector, name: notificationName, object: nil) - } + func subscribe(_ observer: Any, selector: Selector) { + Notifications.main.addObserver(observer, selector: selector, name: notificationName, object: nil) + } - func unsubscribe(_ observer: Any) { - Notifications.main.removeObserver(self, name: notificationName, object: nil) - } + func unsubscribe(_ observer: Any) { + Notifications.main.removeObserver(self, name: notificationName, object: nil) + } } enum Notifications { - static let main: NotificationCenter = { - NotificationCenter() - }() + static let main: NotificationCenter = { + NotificationCenter() + }() - final class Key { - public typealias NotificationKey = Notifications.Key + final class Key { + public typealias NotificationKey = Notifications.Key - public let key: String - public let underlyingNotification: SwiftfinNotification + public let key: String + public let underlyingNotification: SwiftfinNotification - public init(_ key: String) { - self.key = key - self.underlyingNotification = SwiftfinNotification(Notification.Name(key)) - } - } + public init(_ key: String) { + self.key = key + self.underlyingNotification = SwiftfinNotification(Notification.Name(key)) + } + } - static subscript(key: Key) -> SwiftfinNotification { - key.underlyingNotification - } + static subscript(key: Key) -> SwiftfinNotification { + key.underlyingNotification + } - static func unsubscribe(_ observer: Any) { - main.removeObserver(observer) - } + static func unsubscribe(_ observer: Any) { + main.removeObserver(observer) + } } extension Notifications.Key { - static let didSignIn = NotificationKey("didSignIn") - static let didSignOut = NotificationKey("didSignOut") - static let processDeepLink = NotificationKey("processDeepLink") - static let didPurge = NotificationKey("didPurge") - static let didChangeServerCurrentURI = NotificationKey("didChangeCurrentLoginURI") - static let toggleOfflineMode = NotificationKey("toggleOfflineMode") - static let didDeleteOfflineItem = NotificationKey("didDeleteOfflineItem") - static let didAddDownload = NotificationKey("didAddDownload") - static let didSendStopReport = NotificationKey("didSendStopReport") + static let didSignIn = NotificationKey("didSignIn") + static let didSignOut = NotificationKey("didSignOut") + static let processDeepLink = NotificationKey("processDeepLink") + static let didPurge = NotificationKey("didPurge") + static let didChangeServerCurrentURI = NotificationKey("didChangeCurrentLoginURI") + static let toggleOfflineMode = NotificationKey("toggleOfflineMode") + static let didDeleteOfflineItem = NotificationKey("didDeleteOfflineItem") + static let didAddDownload = NotificationKey("didAddDownload") + static let didSendStopReport = NotificationKey("didSendStopReport") } diff --git a/Shared/SwiftfinStore/SwiftfinStore.swift b/Shared/SwiftfinStore/SwiftfinStore.swift index 578af623..16f78ad4 100644 --- a/Shared/SwiftfinStore/SwiftfinStore.swift +++ b/Shared/SwiftfinStore/SwiftfinStore.swift @@ -12,204 +12,222 @@ import Foundation enum SwiftfinStore { - // MARK: State + // MARK: State - // Safe, copyable representations of their underlying CoreStoredObject - // Relationships are represented by the related object's IDs or value - enum State { + // Safe, copyable representations of their underlying CoreStoredObject + // Relationships are represented by the related object's IDs or value + enum State { - struct Server { - let uris: Set - let currentURI: String - let name: String - let id: String - let os: String - let version: String - let userIDs: [String] + struct Server { + let uris: Set + let currentURI: String + let name: String + let id: String + let os: String + let version: String + let userIDs: [String] - fileprivate init(uris: Set, currentURI: String, name: String, id: String, os: String, version: String, - usersIDs: [String]) - { - self.uris = uris - self.currentURI = currentURI - self.name = name - self.id = id - self.os = os - self.version = version - self.userIDs = usersIDs - } + fileprivate init( + uris: Set, + currentURI: String, + name: String, + id: String, + os: String, + version: String, + usersIDs: [String] + ) { + self.uris = uris + self.currentURI = currentURI + self.name = name + self.id = id + self.os = os + self.version = version + self.userIDs = usersIDs + } - static var sample: Server { - Server(uris: ["https://www.notaurl.com", "http://www.maybeaurl.org"], - currentURI: "https://www.notaurl.com", - name: "Johnny's Tree", - id: "123abc", - os: "macOS", - version: "1.1.1", - usersIDs: ["1", "2"]) - } - } + static var sample: Server { + Server( + uris: ["https://www.notaurl.com", "http://www.maybeaurl.org"], + currentURI: "https://www.notaurl.com", + name: "Johnny's Tree", + id: "123abc", + os: "macOS", + version: "1.1.1", + usersIDs: ["1", "2"] + ) + } + } - struct User { - let username: String - let id: String - let serverID: String - let accessToken: String + struct User { + let username: String + let id: String + let serverID: String + let accessToken: String - fileprivate init(username: String, id: String, serverID: String, accessToken: String) { - self.username = username - self.id = id - self.serverID = serverID - self.accessToken = accessToken - } + fileprivate init(username: String, id: String, serverID: String, accessToken: String) { + self.username = username + self.id = id + self.serverID = serverID + self.accessToken = accessToken + } - static var sample: User { - User(username: "JohnnyAppleseed", - id: "123abc", - serverID: "123abc", - accessToken: "open-sesame") - } - } - } + static var sample: User { + User( + username: "JohnnyAppleseed", + id: "123abc", + serverID: "123abc", + accessToken: "open-sesame" + ) + } + } + } - // MARK: Models + // MARK: Models - enum Models { + enum Models { - final class StoredServer: CoreStoreObject { + final class StoredServer: CoreStoreObject { - @Field.Coded("uris", coder: FieldCoders.Json.self) - var uris: Set = [] + @Field.Coded("uris", coder: FieldCoders.Json.self) + var uris: Set = [] - @Field.Stored("currentURI") - var currentURI: String = "" + @Field.Stored("currentURI") + var currentURI: String = "" - @Field.Stored("name") - var name: String = "" + @Field.Stored("name") + var name: String = "" - @Field.Stored("id") - var id: String = "" + @Field.Stored("id") + var id: String = "" - @Field.Stored("os") - var os: String = "" + @Field.Stored("os") + var os: String = "" - @Field.Stored("version") - var version: String = "" + @Field.Stored("version") + var version: String = "" - @Field.Relationship("users", inverse: \StoredUser.$server) - var users: Set + @Field.Relationship("users", inverse: \StoredUser.$server) + var users: Set - var state: State.Server { - State.Server(uris: uris, - currentURI: currentURI, - name: name, - id: id, - os: os, - version: version, - usersIDs: users.map(\.id)) - } - } + var state: State.Server { + State.Server( + uris: uris, + currentURI: currentURI, + name: name, + id: id, + os: os, + version: version, + usersIDs: users.map(\.id) + ) + } + } - final class StoredUser: CoreStoreObject { + final class StoredUser: CoreStoreObject { - @Field.Stored("username") - var username: String = "" + @Field.Stored("username") + var username: String = "" - @Field.Stored("id") - var id: String = "" + @Field.Stored("id") + var id: String = "" - @Field.Stored("appleTVID") - var appleTVID: String = "" + @Field.Stored("appleTVID") + var appleTVID: String = "" - @Field.Relationship("server") - var server: StoredServer? + @Field.Relationship("server") + var server: StoredServer? - @Field.Relationship("accessToken", inverse: \StoredAccessToken.$user) - var accessToken: StoredAccessToken? + @Field.Relationship("accessToken", inverse: \StoredAccessToken.$user) + var accessToken: StoredAccessToken? - var state: State.User { - guard let server = server else { fatalError("No server associated with user") } - guard let accessToken = accessToken else { fatalError("No access token associated with user") } - return State.User(username: username, - id: id, - serverID: server.id, - accessToken: accessToken.value) - } - } + var state: State.User { + guard let server = server else { fatalError("No server associated with user") } + guard let accessToken = accessToken else { fatalError("No access token associated with user") } + return State.User( + username: username, + id: id, + serverID: server.id, + accessToken: accessToken.value + ) + } + } - final class StoredAccessToken: CoreStoreObject { + final class StoredAccessToken: CoreStoreObject { - @Field.Stored("value") - var value: String = "" + @Field.Stored("value") + var value: String = "" - @Field.Relationship("user") - var user: StoredUser? - } - } + @Field.Relationship("user") + var user: StoredUser? + } + } - // MARK: Error + // MARK: Error - enum Error { - case existingServer(State.Server) - case existingUser(State.User) - } + enum Error { + case existingServer(State.Server) + case existingUser(State.User) + } - // MARK: dataStack + // MARK: dataStack - static let dataStack: DataStack = { - let schema = CoreStoreSchema(modelVersion: "V1", - entities: [ - Entity("Server"), - Entity("User"), - Entity("AccessToken"), - ], - versionLock: [ - "AccessToken": [ - 0xA8C4_75E8_7449_4BB1, - 0x7948_6E93_449F_0B3D, - 0xA7DC_4A00_0354_1EDB, - 0x9418_3FAE_7580_EF72, - ], - "Server": [ - 0x936B_46AC_D8E8_F0E3, - 0x5989_0D4D_9F3F_885F, - 0x819C_F7A4_ABF9_8B22, - 0xE161_25C5_AF88_5A06, - ], - "User": [ - 0x845D_E08A_74BC_53ED, - 0xE95A_406A_29F3_A5D0, - 0x9EDA_7328_21A1_5EA9, - 0xB5A_FA53_1E41_CE8A, - ], - ]) + static let dataStack: DataStack = { + let schema = CoreStoreSchema( + modelVersion: "V1", + entities: [ + Entity("Server"), + Entity("User"), + Entity("AccessToken"), + ], + versionLock: [ + "AccessToken": [ + 0xA8C4_75E8_7449_4BB1, + 0x7948_6E93_449F_0B3D, + 0xA7DC_4A00_0354_1EDB, + 0x9418_3FAE_7580_EF72, + ], + "Server": [ + 0x936B_46AC_D8E8_F0E3, + 0x5989_0D4D_9F3F_885F, + 0x819C_F7A4_ABF9_8B22, + 0xE161_25C5_AF88_5A06, + ], + "User": [ + 0x845D_E08A_74BC_53ED, + 0xE95A_406A_29F3_A5D0, + 0x9EDA_7328_21A1_5EA9, + 0xB5A_FA53_1E41_CE8A, + ], + ] + ) - let _dataStack = DataStack(schema) - try! _dataStack.addStorageAndWait(SQLiteStore(fileName: "Swiftfin.sqlite", - localStorageOptions: .recreateStoreOnModelMismatch)) - return _dataStack - }() + let _dataStack = DataStack(schema) + try! _dataStack.addStorageAndWait(SQLiteStore( + fileName: "Swiftfin.sqlite", + localStorageOptions: .recreateStoreOnModelMismatch + )) + return _dataStack + }() } // MARK: LocalizedError extension SwiftfinStore.Error: LocalizedError { - var title: String { - switch self { - case .existingServer: - return L10n.existingServer - case .existingUser: - return L10n.existingUser - } - } + var title: String { + switch self { + case .existingServer: + return L10n.existingServer + case .existingUser: + return L10n.existingUser + } + } - var errorDescription: String? { - switch self { - case let .existingServer(server): - return L10n.serverAlreadyConnected(server.name) - case let .existingUser(user): - return L10n.userAlreadySignedIn(user.username) - } - } + var errorDescription: String? { + switch self { + case let .existingServer(server): + return L10n.serverAlreadyConnected(server.name) + case let .existingUser(user): + return L10n.userAlreadySignedIn(user.username) + } + } } diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index c25b0153..29b4180c 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -10,87 +10,105 @@ import Defaults import Foundation extension SwiftfinStore { - enum Defaults { - static let generalSuite: UserDefaults = .init(suiteName: "swiftfinstore-general-defaults")! + enum Defaults { + static let generalSuite: UserDefaults = .init(suiteName: "swiftfinstore-general-defaults")! - static let universalSuite: UserDefaults = .init(suiteName: "swiftfinstore-universal-defaults")! - } + static let universalSuite: UserDefaults = .init(suiteName: "swiftfinstore-universal-defaults")! + } } extension Defaults.Keys { - // Universal settings - static let defaultHTTPScheme = Key("defaultHTTPScheme", default: .http, suite: SwiftfinStore.Defaults.universalSuite) - static let appAppearance = Key("appAppearance", default: .system, suite: SwiftfinStore.Defaults.universalSuite) + // Universal settings + static let defaultHTTPScheme = Key("defaultHTTPScheme", default: .http, suite: SwiftfinStore.Defaults.universalSuite) + static let appAppearance = Key("appAppearance", default: .system, suite: SwiftfinStore.Defaults.universalSuite) - // General settings - static let lastServerUserID = Defaults.Key("lastServerUserID", suite: SwiftfinStore.Defaults.generalSuite) - static let inNetworkBandwidth = Key("InNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.generalSuite) - static let outOfNetworkBandwidth = Key("OutOfNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.generalSuite) - static let isAutoSelectSubtitles = Key("isAutoSelectSubtitles", default: false, suite: SwiftfinStore.Defaults.generalSuite) - static let autoSelectSubtitlesLangCode = Key("AutoSelectSubtitlesLangCode", - default: "Auto", - suite: SwiftfinStore.Defaults.generalSuite) - static let autoSelectAudioLangCode = Key("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite) + // General settings + static let lastServerUserID = Defaults.Key("lastServerUserID", suite: SwiftfinStore.Defaults.generalSuite) + static let inNetworkBandwidth = Key("InNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.generalSuite) + static let outOfNetworkBandwidth = Key("OutOfNetworkBandwidth", default: 40_000_000, suite: SwiftfinStore.Defaults.generalSuite) + static let isAutoSelectSubtitles = Key("isAutoSelectSubtitles", default: false, suite: SwiftfinStore.Defaults.generalSuite) + static let autoSelectSubtitlesLangCode = Key( + "AutoSelectSubtitlesLangCode", + default: "Auto", + suite: SwiftfinStore.Defaults.generalSuite + ) + static let autoSelectAudioLangCode = Key("AutoSelectAudioLangCode", default: "Auto", suite: SwiftfinStore.Defaults.generalSuite) - // Customize settings - static let showPosterLabels = Key("showPosterLabels", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let showCastAndCrew = Key("showCastAndCrew", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let showFlattenView = Key("showFlattenView", default: true, suite: SwiftfinStore.Defaults.generalSuite) + // Customize settings + static let showPosterLabels = Key("showPosterLabels", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let showCastAndCrew = Key("showCastAndCrew", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let showFlattenView = Key("showFlattenView", default: true, suite: SwiftfinStore.Defaults.generalSuite) - // Video player / overlay settings - static let overlayType = Key("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite) - static let jumpGesturesEnabled = Key("gesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let systemControlGesturesEnabled = Key("systemControlGesturesEnabled", - default: true, - suite: SwiftfinStore.Defaults.generalSuite) - static let playerGesturesLockGestureEnabled = Key("playerGesturesLockGestureEnabled", - default: true, - suite: SwiftfinStore.Defaults.generalSuite) - static let seekSlideGestureEnabled = Key("seekSlideGestureEnabled", - default: true, - suite: SwiftfinStore.Defaults.generalSuite) - static let videoPlayerJumpForward = Key("videoPlayerJumpForward", - default: .fifteen, - suite: SwiftfinStore.Defaults.generalSuite) - static let videoPlayerJumpBackward = Key("videoPlayerJumpBackward", - default: .fifteen, - suite: SwiftfinStore.Defaults.generalSuite) - static let autoplayEnabled = Key("autoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let resumeOffset = Key("resumeOffset", default: false, suite: SwiftfinStore.Defaults.generalSuite) - static let subtitleSize = Key("subtitleSize", default: .regular, suite: SwiftfinStore.Defaults.generalSuite) + // Video player / overlay settings + static let overlayType = Key("overlayType", default: .normal, suite: SwiftfinStore.Defaults.generalSuite) + static let jumpGesturesEnabled = Key("gesturesEnabled", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let systemControlGesturesEnabled = Key( + "systemControlGesturesEnabled", + default: true, + suite: SwiftfinStore.Defaults.generalSuite + ) + static let playerGesturesLockGestureEnabled = Key( + "playerGesturesLockGestureEnabled", + default: true, + suite: SwiftfinStore.Defaults.generalSuite + ) + static let seekSlideGestureEnabled = Key( + "seekSlideGestureEnabled", + default: true, + suite: SwiftfinStore.Defaults.generalSuite + ) + static let videoPlayerJumpForward = Key( + "videoPlayerJumpForward", + default: .fifteen, + suite: SwiftfinStore.Defaults.generalSuite + ) + static let videoPlayerJumpBackward = Key( + "videoPlayerJumpBackward", + default: .fifteen, + suite: SwiftfinStore.Defaults.generalSuite + ) + static let autoplayEnabled = Key("autoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let resumeOffset = Key("resumeOffset", default: false, suite: SwiftfinStore.Defaults.generalSuite) + static let subtitleSize = Key("subtitleSize", default: .regular, suite: SwiftfinStore.Defaults.generalSuite) - // Should show video player items - static let shouldShowPlayPreviousItem = Key("shouldShowPreviousItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let shouldShowPlayNextItem = Key("shouldShowNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let shouldShowAutoPlay = Key("shouldShowAutoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) + // Should show video player items + static let shouldShowPlayPreviousItem = Key("shouldShowPreviousItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let shouldShowPlayNextItem = Key("shouldShowNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let shouldShowAutoPlay = Key("shouldShowAutoPlayNextItem", default: true, suite: SwiftfinStore.Defaults.generalSuite) - // Should show missing seasons and episodes - static let shouldShowMissingSeasons = Key("shouldShowMissingSeasons", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let shouldShowMissingEpisodes = Key("shouldShowMissingEpisodes", default: true, suite: SwiftfinStore.Defaults.generalSuite) + // Should show missing seasons and episodes + static let shouldShowMissingSeasons = Key("shouldShowMissingSeasons", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let shouldShowMissingEpisodes = Key("shouldShowMissingEpisodes", default: true, suite: SwiftfinStore.Defaults.generalSuite) - // Should show video player items in overlay menu - static let shouldShowJumpButtonsInOverlayMenu = Key("shouldShowJumpButtonsInMenu", - default: true, - suite: SwiftfinStore.Defaults.generalSuite) + // Should show video player items in overlay menu + static let shouldShowJumpButtonsInOverlayMenu = Key( + "shouldShowJumpButtonsInMenu", + default: true, + suite: SwiftfinStore.Defaults.generalSuite + ) - static let shouldShowChaptersInfoInBottomOverlay = Key("shouldShowChaptersInfoInBottomOverlay", - default: true, - suite: SwiftfinStore.Defaults.generalSuite) + static let shouldShowChaptersInfoInBottomOverlay = Key( + "shouldShowChaptersInfoInBottomOverlay", + default: true, + suite: SwiftfinStore.Defaults.generalSuite + ) - // Experimental settings - enum Experimental { - static let syncSubtitleStateWithAdjacent = Key("experimental.syncSubtitleState", - default: false, - suite: SwiftfinStore.Defaults.generalSuite) - static let forceDirectPlay = Key("forceDirectPlay", default: false, suite: SwiftfinStore.Defaults.generalSuite) - static let nativePlayer = Key("nativePlayer", default: false, suite: SwiftfinStore.Defaults.generalSuite) - static let liveTVAlphaEnabled = Key("liveTVAlphaEnabled", default: false, suite: SwiftfinStore.Defaults.generalSuite) - static let liveTVForceDirectPlay = Key("liveTVForceDirectPlay", default: false, suite: SwiftfinStore.Defaults.generalSuite) - static let liveTVNativePlayer = Key("liveTVNativePlayer", default: false, suite: SwiftfinStore.Defaults.generalSuite) - } + // Experimental settings + enum Experimental { + static let syncSubtitleStateWithAdjacent = Key( + "experimental.syncSubtitleState", + default: false, + suite: SwiftfinStore.Defaults.generalSuite + ) + static let forceDirectPlay = Key("forceDirectPlay", default: false, suite: SwiftfinStore.Defaults.generalSuite) + static let nativePlayer = Key("nativePlayer", default: false, suite: SwiftfinStore.Defaults.generalSuite) + static let liveTVAlphaEnabled = Key("liveTVAlphaEnabled", default: false, suite: SwiftfinStore.Defaults.generalSuite) + static let liveTVForceDirectPlay = Key("liveTVForceDirectPlay", default: false, suite: SwiftfinStore.Defaults.generalSuite) + static let liveTVNativePlayer = Key("liveTVNativePlayer", default: false, suite: SwiftfinStore.Defaults.generalSuite) + } - // tvos specific - static let downActionShowsMenu = Key("downActionShowsMenu", default: true, suite: SwiftfinStore.Defaults.generalSuite) - static let confirmClose = Key("confirmClose", default: false, suite: SwiftfinStore.Defaults.generalSuite) - static let tvOSCinematicViews = Key("tvOSCinematicViews", default: false, suite: SwiftfinStore.Defaults.generalSuite) + // tvos specific + static let downActionShowsMenu = Key("downActionShowsMenu", default: true, suite: SwiftfinStore.Defaults.generalSuite) + static let confirmClose = Key("confirmClose", default: false, suite: SwiftfinStore.Defaults.generalSuite) + static let tvOSCinematicViews = Key("tvOSCinematicViews", default: false, suite: SwiftfinStore.Defaults.generalSuite) } diff --git a/Shared/UIKit/PanDirectionGestureRecognizer.swift b/Shared/UIKit/PanDirectionGestureRecognizer.swift index 724ee962..edccabbe 100644 --- a/Shared/UIKit/PanDirectionGestureRecognizer.swift +++ b/Shared/UIKit/PanDirectionGestureRecognizer.swift @@ -9,31 +9,31 @@ import UIKit.UIGestureRecognizerSubclass enum PanDirection { - case vertical - case horizontal + case vertical + case horizontal } class PanDirectionGestureRecognizer: UIPanGestureRecognizer { - let direction: PanDirection + let direction: PanDirection - init(direction: PanDirection, target: AnyObject, action: Selector) { - self.direction = direction - super.init(target: target, action: action) - } + init(direction: PanDirection, target: AnyObject, action: Selector) { + self.direction = direction + super.init(target: target, action: action) + } - override func touchesMoved(_ touches: Set, with event: UIEvent) { - super.touchesMoved(touches, with: event) + override func touchesMoved(_ touches: Set, with event: UIEvent) { + super.touchesMoved(touches, with: event) - if state == .began { - let vel = velocity(in: view) - switch direction { - case .horizontal where abs(vel.y) > abs(vel.x): - state = .cancelled - case .vertical where abs(vel.x) > abs(vel.y): - state = .cancelled - default: - break - } - } - } + if state == .began { + let vel = velocity(in: view) + switch direction { + case .horizontal where abs(vel.y) > abs(vel.x): + state = .cancelled + case .vertical where abs(vel.x) > abs(vel.y): + state = .cancelled + default: + break + } + } + } } diff --git a/Shared/ViewModels/BasicAppSettingsViewModel.swift b/Shared/ViewModels/BasicAppSettingsViewModel.swift index b70c5346..64972b44 100644 --- a/Shared/ViewModels/BasicAppSettingsViewModel.swift +++ b/Shared/ViewModels/BasicAppSettingsViewModel.swift @@ -10,17 +10,17 @@ import SwiftUI final class BasicAppSettingsViewModel: ViewModel { - let appearances = AppAppearance.allCases + let appearances = AppAppearance.allCases - func resetUserSettings() { - SwiftfinStore.Defaults.generalSuite.removeAll() - } + func resetUserSettings() { + SwiftfinStore.Defaults.generalSuite.removeAll() + } - func resetAppSettings() { - SwiftfinStore.Defaults.universalSuite.removeAll() - } + func resetAppSettings() { + SwiftfinStore.Defaults.universalSuite.removeAll() + } - func removeAllUsers() { - SessionManager.main.purge() - } + func removeAllUsers() { + SessionManager.main.purge() + } } diff --git a/Shared/ViewModels/ConnectToServerViewModel.swift b/Shared/ViewModels/ConnectToServerViewModel.swift index 14d33e6a..2b606748 100644 --- a/Shared/ViewModels/ConnectToServerViewModel.swift +++ b/Shared/ViewModels/ConnectToServerViewModel.swift @@ -13,132 +13,134 @@ import Stinsen struct AddServerURIPayload: Identifiable { - let server: SwiftfinStore.State.Server - let uri: String + let server: SwiftfinStore.State.Server + let uri: String - var id: String { - server.id.appending(uri) - } + var id: String { + server.id.appending(uri) + } } final class ConnectToServerViewModel: ViewModel { - @RouterObject - var router: ConnectToServerCoodinator.Router? - @Published - var discoveredServers: Set = [] - @Published - var searching = false - @Published - var addServerURIPayload: AddServerURIPayload? - var backAddServerURIPayload: AddServerURIPayload? + @RouterObject + var router: ConnectToServerCoodinator.Router? + @Published + var discoveredServers: Set = [] + @Published + var searching = false + @Published + var addServerURIPayload: AddServerURIPayload? + var backAddServerURIPayload: AddServerURIPayload? - private let discovery = ServerDiscovery() + private let discovery = ServerDiscovery() - var alertTitle: String { - var message: String = "" - if errorMessage?.code != ErrorMessage.noShowErrorCode { - message.append(contentsOf: "\(errorMessage?.code ?? ErrorMessage.noShowErrorCode)\n") - } - message.append(contentsOf: "\(errorMessage?.title ?? L10n.unknownError)") - return message - } + var alertTitle: String { + var message: String = "" + if errorMessage?.code != ErrorMessage.noShowErrorCode { + message.append(contentsOf: "\(errorMessage?.code ?? ErrorMessage.noShowErrorCode)\n") + } + message.append(contentsOf: "\(errorMessage?.title ?? L10n.unknownError)") + return message + } - func connectToServer(uri: String, redirectCount: Int = 0) { + func connectToServer(uri: String, redirectCount: Int = 0) { - #if targetEnvironment(simulator) - var uri = uri - if uri == "http://localhost" || uri == "localhost" { - uri = "http://localhost:8096" - } - #endif + #if targetEnvironment(simulator) + var uri = uri + if uri == "http://localhost" || uri == "localhost" { + uri = "http://localhost:8096" + } + #endif - let trimmedURI = uri.trimmingCharacters(in: .whitespaces) + let trimmedURI = uri.trimmingCharacters(in: .whitespaces) - LogManager.log.debug("Attempting to connect to server at \"\(trimmedURI)\"", tag: "connectToServer") - SessionManager.main.connectToServer(with: trimmedURI) - .trackActivity(loading) - .sink(receiveCompletion: { completion in - // This is disgusting. ViewModel Error handling overall needs to be refactored - switch completion { - case .finished: () - case let .failure(error): - switch error { - case is ErrorResponse: - let errorResponse = error as! ErrorResponse - switch errorResponse { - case let .error(_, _, response, _): - // a url in the response is the result if a redirect - if let newURL = response?.url { - if redirectCount > 2 { - self.handleAPIRequestError(displayMessage: L10n.tooManyRedirects, completion: completion) - } else { - self - .connectToServer(uri: newURL.absoluteString - .removeRegexMatches(pattern: "/web/index.html"), - redirectCount: redirectCount + 1) - } - } else { - self.handleAPIRequestError(completion: completion) - } - } - case is SwiftfinStore.Error: - let swiftfinError = error as! SwiftfinStore.Error - switch swiftfinError { - case let .existingServer(server): - self.addServerURIPayload = AddServerURIPayload(server: server, uri: uri) - self.backAddServerURIPayload = AddServerURIPayload(server: server, uri: uri) - default: - self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion) - } - default: - self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion) - } - } - }, receiveValue: { server in - LogManager.log.debug("Connected to server at \"\(uri)\"", tag: "connectToServer") - self.router?.route(to: \.userSignIn, server) - }) - .store(in: &cancellables) - } + LogManager.log.debug("Attempting to connect to server at \"\(trimmedURI)\"", tag: "connectToServer") + SessionManager.main.connectToServer(with: trimmedURI) + .trackActivity(loading) + .sink(receiveCompletion: { completion in + // This is disgusting. ViewModel Error handling overall needs to be refactored + switch completion { + case .finished: () + case let .failure(error): + switch error { + case is ErrorResponse: + let errorResponse = error as! ErrorResponse + switch errorResponse { + case let .error(_, _, response, _): + // a url in the response is the result if a redirect + if let newURL = response?.url { + if redirectCount > 2 { + self.handleAPIRequestError(displayMessage: L10n.tooManyRedirects, completion: completion) + } else { + self + .connectToServer( + uri: newURL.absoluteString + .removeRegexMatches(pattern: "/web/index.html"), + redirectCount: redirectCount + 1 + ) + } + } else { + self.handleAPIRequestError(completion: completion) + } + } + case is SwiftfinStore.Error: + let swiftfinError = error as! SwiftfinStore.Error + switch swiftfinError { + case let .existingServer(server): + self.addServerURIPayload = AddServerURIPayload(server: server, uri: uri) + self.backAddServerURIPayload = AddServerURIPayload(server: server, uri: uri) + default: + self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion) + } + default: + self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion) + } + } + }, receiveValue: { server in + LogManager.log.debug("Connected to server at \"\(uri)\"", tag: "connectToServer") + self.router?.route(to: \.userSignIn, server) + }) + .store(in: &cancellables) + } - func discoverServers() { - discoveredServers.removeAll() - searching = true + func discoverServers() { + discoveredServers.removeAll() + searching = true - // Timeout after 3 seconds - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { - self.searching = false - } + // Timeout after 3 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 3) { + self.searching = false + } - discovery.locateServer { [self] server in - if let server = server { - discoveredServers.insert(server) - } - } - } + discovery.locateServer { [self] server in + if let server = server { + discoveredServers.insert(server) + } + } + } - func addURIToServer(addServerURIPayload: AddServerURIPayload) { - SessionManager.main.addURIToServer(server: addServerURIPayload.server, uri: addServerURIPayload.uri) - .sink { completion in - self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion) - } receiveValue: { server in - SessionManager.main.setServerCurrentURI(server: server, uri: addServerURIPayload.uri) - .sink { completion in - self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion) - } receiveValue: { _ in - self.router?.dismissCoordinator() - } - .store(in: &self.cancellables) - } - .store(in: &cancellables) - } + func addURIToServer(addServerURIPayload: AddServerURIPayload) { + SessionManager.main.addURIToServer(server: addServerURIPayload.server, uri: addServerURIPayload.uri) + .sink { completion in + self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion) + } receiveValue: { server in + SessionManager.main.setServerCurrentURI(server: server, uri: addServerURIPayload.uri) + .sink { completion in + self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion) + } receiveValue: { _ in + self.router?.dismissCoordinator() + } + .store(in: &self.cancellables) + } + .store(in: &cancellables) + } - func cancelConnection() { - for cancellable in cancellables { - cancellable.cancel() - } + func cancelConnection() { + for cancellable in cancellables { + cancellable.cancel() + } - self.isLoading = false - } + self.isLoading = false + } } diff --git a/Shared/ViewModels/EpisodesRowManager.swift b/Shared/ViewModels/EpisodesRowManager.swift index 47bbd2e3..711988cc 100644 --- a/Shared/ViewModels/EpisodesRowManager.swift +++ b/Shared/ViewModels/EpisodesRowManager.swift @@ -11,66 +11,70 @@ import JellyfinAPI import SwiftUI protocol EpisodesRowManager: ViewModel { - var item: BaseItemDto { get } - var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] { get set } - var selectedSeason: BaseItemDto? { get set } - func retrieveSeasons() - func retrieveEpisodesForSeason(_ season: BaseItemDto) - func select(season: BaseItemDto) + var item: BaseItemDto { get } + var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] { get set } + var selectedSeason: BaseItemDto? { get set } + func retrieveSeasons() + func retrieveEpisodesForSeason(_ season: BaseItemDto) + func select(season: BaseItemDto) } extension EpisodesRowManager { - var sortedSeasons: [BaseItemDto] { - Array(seasonsEpisodes.keys).sorted(by: { $0.indexNumber ?? 0 < $1.indexNumber ?? 0 }) - } + var sortedSeasons: [BaseItemDto] { + Array(seasonsEpisodes.keys).sorted(by: { $0.indexNumber ?? 0 < $1.indexNumber ?? 0 }) + } - // Also retrieves the current season episodes if available - func retrieveSeasons() { - TvShowsAPI.getSeasons(seriesId: item.seriesId ?? "", - userId: SessionManager.main.currentLogin.user.id, - isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false) - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { response in - let seasons = response.items ?? [] - seasons.forEach { season in - self.seasonsEpisodes[season] = [] + // Also retrieves the current season episodes if available + func retrieveSeasons() { + TvShowsAPI.getSeasons( + seriesId: item.seriesId ?? "", + userId: SessionManager.main.currentLogin.user.id, + isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false + ) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { response in + let seasons = response.items ?? [] + seasons.forEach { season in + self.seasonsEpisodes[season] = [] - if season.id == self.item.seasonId ?? "" { - self.selectedSeason = season - self.retrieveEpisodesForSeason(season) - } else if season.id == self.item.id ?? "" { - self.selectedSeason = season - self.retrieveEpisodesForSeason(season) - } - } - } - .store(in: &cancellables) - } + if season.id == self.item.seasonId ?? "" { + self.selectedSeason = season + self.retrieveEpisodesForSeason(season) + } else if season.id == self.item.id ?? "" { + self.selectedSeason = season + self.retrieveEpisodesForSeason(season) + } + } + } + .store(in: &cancellables) + } - func retrieveEpisodesForSeason(_ season: BaseItemDto) { - guard let seasonID = season.id else { return } + func retrieveEpisodesForSeason(_ season: BaseItemDto) { + guard let seasonID = season.id else { return } - TvShowsAPI.getEpisodes(seriesId: item.seriesId ?? "", - userId: SessionManager.main.currentLogin.user.id, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], - seasonId: seasonID, - isMissing: Defaults[.shouldShowMissingEpisodes] ? nil : false) - .trackActivity(loading) - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { episodes in - self.seasonsEpisodes[season] = episodes.items ?? [] - } - .store(in: &cancellables) - } + TvShowsAPI.getEpisodes( + seriesId: item.seriesId ?? "", + userId: SessionManager.main.currentLogin.user.id, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + seasonId: seasonID, + isMissing: Defaults[.shouldShowMissingEpisodes] ? nil : false + ) + .trackActivity(loading) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { episodes in + self.seasonsEpisodes[season] = episodes.items ?? [] + } + .store(in: &cancellables) + } - func select(season: BaseItemDto) { - self.selectedSeason = season + func select(season: BaseItemDto) { + self.selectedSeason = season - if seasonsEpisodes[season]!.isEmpty { - retrieveEpisodesForSeason(season) - } - } + if seasonsEpisodes[season]!.isEmpty { + retrieveEpisodesForSeason(season) + } + } } diff --git a/Shared/ViewModels/HomeViewModel.swift b/Shared/ViewModels/HomeViewModel.swift index cb455dde..984ffad4 100644 --- a/Shared/ViewModels/HomeViewModel.swift +++ b/Shared/ViewModels/HomeViewModel.swift @@ -13,220 +13,228 @@ import JellyfinAPI final class HomeViewModel: ViewModel { - @Published - var latestAddedItems: [BaseItemDto] = [] - @Published - var resumeItems: [BaseItemDto] = [] - @Published - var nextUpItems: [BaseItemDto] = [] - @Published - var librariesShowRecentlyAddedIDs: [String] = [] - @Published - var libraries: [BaseItemDto] = [] + @Published + var latestAddedItems: [BaseItemDto] = [] + @Published + var resumeItems: [BaseItemDto] = [] + @Published + var nextUpItems: [BaseItemDto] = [] + @Published + var librariesShowRecentlyAddedIDs: [String] = [] + @Published + var libraries: [BaseItemDto] = [] - // temp - var recentFilterSet = LibraryFilters(filters: [], sortOrder: [.descending], sortBy: [.dateAdded]) + // temp + var recentFilterSet = LibraryFilters(filters: [], sortOrder: [.descending], sortBy: [.dateAdded]) - override init() { - super.init() - refresh() + override init() { + super.init() + refresh() - // Nov. 6, 2021 - // This is a workaround since Stinsen doesn't have the ability to rebuild a root at the time of writing. - // See ServerDetailViewModel.swift for feature request issue - Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn)) - Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut)) - } + // Nov. 6, 2021 + // This is a workaround since Stinsen doesn't have the ability to rebuild a root at the time of writing. + // See ServerDetailViewModel.swift for feature request issue + Notifications[.didSignIn].subscribe(self, selector: #selector(didSignIn)) + Notifications[.didSignOut].subscribe(self, selector: #selector(didSignOut)) + } - @objc - private func didSignIn() { - for cancellable in cancellables { - cancellable.cancel() - } + @objc + private func didSignIn() { + for cancellable in cancellables { + cancellable.cancel() + } - librariesShowRecentlyAddedIDs = [] - libraries = [] - resumeItems = [] - nextUpItems = [] + librariesShowRecentlyAddedIDs = [] + libraries = [] + resumeItems = [] + nextUpItems = [] - refresh() - } + refresh() + } - @objc - private func didSignOut() { - for cancellable in cancellables { - cancellable.cancel() - } + @objc + private func didSignOut() { + for cancellable in cancellables { + cancellable.cancel() + } - cancellables.removeAll() - } + cancellables.removeAll() + } - @objc - func refresh() { - LogManager.log.debug("Refresh called.") + @objc + func refresh() { + LogManager.log.debug("Refresh called.") - refreshLibrariesLatest() - refreshLatestAddedItems() - refreshResumeItems() - refreshNextUpItems() - } + refreshLibrariesLatest() + refreshLatestAddedItems() + refreshResumeItems() + refreshNextUpItems() + } - // MARK: Libraries Latest Items + // MARK: Libraries Latest Items - private func refreshLibrariesLatest() { - UserViewsAPI.getUserViews(userId: SessionManager.main.currentLogin.user.id) - .trackActivity(loading) - .sink(receiveCompletion: { completion in - switch completion { - case .finished: () - case .failure: - self.libraries = [] - } + private func refreshLibrariesLatest() { + UserViewsAPI.getUserViews(userId: SessionManager.main.currentLogin.user.id) + .trackActivity(loading) + .sink(receiveCompletion: { completion in + switch completion { + case .finished: () + case .failure: + self.libraries = [] + } - self.handleAPIRequestError(completion: completion) - }, receiveValue: { response in + self.handleAPIRequestError(completion: completion) + }, receiveValue: { response in - var newLibraries: [BaseItemDto] = [] + var newLibraries: [BaseItemDto] = [] - response.items!.forEach { item in - LogManager.log - .debug("Retrieved user view: \(item.id!) (\(item.name ?? "nil")) with type \(item.collectionType ?? "nil")") - if item.collectionType == "movies" || item.collectionType == "tvshows" { - newLibraries.append(item) - } - } + response.items!.forEach { item in + LogManager.log + .debug("Retrieved user view: \(item.id!) (\(item.name ?? "nil")) with type \(item.collectionType ?? "nil")") + if item.collectionType == "movies" || item.collectionType == "tvshows" { + newLibraries.append(item) + } + } - UserAPI.getCurrentUser() - .trackActivity(self.loading) - .sink(receiveCompletion: { completion in - switch completion { - case .finished: () - case .failure: - self.libraries = [] - self.handleAPIRequestError(completion: completion) - } - }, receiveValue: { response in - let excludeIDs = response.configuration?.latestItemsExcludes != nil ? response.configuration! - .latestItemsExcludes! : [] + UserAPI.getCurrentUser() + .trackActivity(self.loading) + .sink(receiveCompletion: { completion in + switch completion { + case .finished: () + case .failure: + self.libraries = [] + self.handleAPIRequestError(completion: completion) + } + }, receiveValue: { response in + let excludeIDs = response.configuration?.latestItemsExcludes != nil ? response.configuration! + .latestItemsExcludes! : [] - for excludeID in excludeIDs { - newLibraries.removeAll { library in - library.id == excludeID - } - } + for excludeID in excludeIDs { + newLibraries.removeAll { library in + library.id == excludeID + } + } - self.libraries = newLibraries - }) - .store(in: &self.cancellables) - }) - .store(in: &cancellables) - } + self.libraries = newLibraries + }) + .store(in: &self.cancellables) + }) + .store(in: &cancellables) + } - // MARK: Latest Added Items + // MARK: Latest Added Items - private func refreshLatestAddedItems() { - UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, - fields: [ - .primaryImageAspectRatio, - .seriesPrimaryImage, - .seasonUserData, - .overview, - .genres, - .people, - .chapters, - ], - includeItemTypes: [.movie, .series], - enableImageTypes: [.primary, .backdrop, .thumb], - enableUserData: true, - limit: 8) - .sink { completion in - switch completion { - case .finished: () - case .failure: - self.nextUpItems = [] - self.handleAPIRequestError(completion: completion) - } - } receiveValue: { items in - LogManager.log.debug("Retrieved \(String(items.count)) resume items") + private func refreshLatestAddedItems() { + UserLibraryAPI.getLatestMedia( + userId: SessionManager.main.currentLogin.user.id, + fields: [ + .primaryImageAspectRatio, + .seriesPrimaryImage, + .seasonUserData, + .overview, + .genres, + .people, + .chapters, + ], + includeItemTypes: [.movie, .series], + enableImageTypes: [.primary, .backdrop, .thumb], + enableUserData: true, + limit: 8 + ) + .sink { completion in + switch completion { + case .finished: () + case .failure: + self.nextUpItems = [] + self.handleAPIRequestError(completion: completion) + } + } receiveValue: { items in + LogManager.log.debug("Retrieved \(String(items.count)) resume items") - self.latestAddedItems = items - } - .store(in: &cancellables) - } + self.latestAddedItems = items + } + .store(in: &cancellables) + } - // MARK: Resume Items + // MARK: Resume Items - private func refreshResumeItems() { - ItemsAPI.getResumeItems(userId: SessionManager.main.currentLogin.user.id, - limit: 6, - fields: [ - .primaryImageAspectRatio, - .seriesPrimaryImage, - .seasonUserData, - .overview, - .genres, - .people, - .chapters, - ], - enableUserData: true) - .trackActivity(loading) - .sink(receiveCompletion: { completion in - switch completion { - case .finished: () - case .failure: - self.resumeItems = [] - self.handleAPIRequestError(completion: completion) - } - }, receiveValue: { response in - LogManager.log.debug("Retrieved \(String(response.items!.count)) resume items") + private func refreshResumeItems() { + ItemsAPI.getResumeItems( + userId: SessionManager.main.currentLogin.user.id, + limit: 6, + fields: [ + .primaryImageAspectRatio, + .seriesPrimaryImage, + .seasonUserData, + .overview, + .genres, + .people, + .chapters, + ], + enableUserData: true + ) + .trackActivity(loading) + .sink(receiveCompletion: { completion in + switch completion { + case .finished: () + case .failure: + self.resumeItems = [] + self.handleAPIRequestError(completion: completion) + } + }, receiveValue: { response in + LogManager.log.debug("Retrieved \(String(response.items!.count)) resume items") - self.resumeItems = response.items ?? [] - }) - .store(in: &cancellables) - } + self.resumeItems = response.items ?? [] + }) + .store(in: &cancellables) + } - func removeItemFromResume(_ item: BaseItemDto) { - guard let itemID = item.id, resumeItems.contains(where: { $0.id == itemID }) else { return } + func removeItemFromResume(_ item: BaseItemDto) { + guard let itemID = item.id, resumeItems.contains(where: { $0.id == itemID }) else { return } - PlaystateAPI.markUnplayedItem(userId: SessionManager.main.currentLogin.user.id, - itemId: item.id!) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { _ in - self.refreshResumeItems() - self.refreshNextUpItems() - }) - .store(in: &cancellables) - } + PlaystateAPI.markUnplayedItem( + userId: SessionManager.main.currentLogin.user.id, + itemId: item.id! + ) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { _ in + self.refreshResumeItems() + self.refreshNextUpItems() + }) + .store(in: &cancellables) + } - // MARK: Next Up Items + // MARK: Next Up Items - private func refreshNextUpItems() { - TvShowsAPI.getNextUp(userId: SessionManager.main.currentLogin.user.id, - limit: 6, - fields: [ - .primaryImageAspectRatio, - .seriesPrimaryImage, - .seasonUserData, - .overview, - .genres, - .people, - .chapters, - ], - enableUserData: true) - .trackActivity(loading) - .sink(receiveCompletion: { completion in - switch completion { - case .finished: () - case .failure: - self.nextUpItems = [] - self.handleAPIRequestError(completion: completion) - } - }, receiveValue: { response in - LogManager.log.debug("Retrieved \(String(response.items!.count)) nextup items") + private func refreshNextUpItems() { + TvShowsAPI.getNextUp( + userId: SessionManager.main.currentLogin.user.id, + limit: 6, + fields: [ + .primaryImageAspectRatio, + .seriesPrimaryImage, + .seasonUserData, + .overview, + .genres, + .people, + .chapters, + ], + enableUserData: true + ) + .trackActivity(loading) + .sink(receiveCompletion: { completion in + switch completion { + case .finished: () + case .failure: + self.nextUpItems = [] + self.handleAPIRequestError(completion: completion) + } + }, receiveValue: { response in + LogManager.log.debug("Retrieved \(String(response.items!.count)) nextup items") - self.nextUpItems = response.items ?? [] - }) - .store(in: &cancellables) - } + self.nextUpItems = response.items ?? [] + }) + .store(in: &cancellables) + } } diff --git a/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift b/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift index 5d094b9c..a56f35e9 100644 --- a/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/CollectionItemViewModel.swift @@ -12,25 +12,27 @@ import JellyfinAPI final class CollectionItemViewModel: ItemViewModel { - @Published - var collectionItems: [BaseItemDto] = [] + @Published + var collectionItems: [BaseItemDto] = [] - override init(item: BaseItemDto) { - super.init(item: item) + override init(item: BaseItemDto) { + super.init(item: item) - getCollectionItems() - } + getCollectionItems() + } - private func getCollectionItems() { - ItemsAPI.getItems(userId: SessionManager.main.currentLogin.user.id, - parentId: item.id, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]) - .trackActivity(loading) - .sink { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - } receiveValue: { [weak self] response in - self?.collectionItems = response.items ?? [] - } - .store(in: &cancellables) - } + private func getCollectionItems() { + ItemsAPI.getItems( + userId: SessionManager.main.currentLogin.user.id, + parentId: item.id, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people] + ) + .trackActivity(loading) + .sink { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + } receiveValue: { [weak self] response in + self?.collectionItems = response.items ?? [] + } + .store(in: &cancellables) + } } diff --git a/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift b/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift index 364f12db..e4e379cf 100644 --- a/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/EpisodeItemViewModel.swift @@ -13,61 +13,63 @@ import Stinsen final class EpisodeItemViewModel: ItemViewModel, EpisodesRowManager { - @RouterObject - var itemRouter: ItemCoordinator.Router? - @Published - var series: BaseItemDto? - @Published - var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:] - @Published - var selectedSeason: BaseItemDto? + @RouterObject + var itemRouter: ItemCoordinator.Router? + @Published + var series: BaseItemDto? + @Published + var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:] + @Published + var selectedSeason: BaseItemDto? - override init(item: BaseItemDto) { - super.init(item: item) + override init(item: BaseItemDto) { + super.init(item: item) - getEpisodeSeries() - retrieveSeasons() - } + getEpisodeSeries() + retrieveSeasons() + } - override func getItemDisplayName() -> String { - guard let episodeLocator = item.getEpisodeLocator() else { return item.name ?? "" } - return "\(episodeLocator)\n\(item.name ?? "")" - } + override func getItemDisplayName() -> String { + guard let episodeLocator = item.getEpisodeLocator() else { return item.name ?? "" } + return "\(episodeLocator)\n\(item.name ?? "")" + } - func getEpisodeSeries() { - guard let id = item.seriesId else { return } - UserLibraryAPI.getItem(userId: SessionManager.main.currentLogin.user.id, itemId: id) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] item in - self?.series = item - }) - .store(in: &cancellables) - } + func getEpisodeSeries() { + guard let id = item.seriesId else { return } + UserLibraryAPI.getItem(userId: SessionManager.main.currentLogin.user.id, itemId: id) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] item in + self?.series = item + }) + .store(in: &cancellables) + } - override func updateItem() { - ItemsAPI.getItems(userId: SessionManager.main.currentLogin.user.id, - limit: 1, - fields: [ - .primaryImageAspectRatio, - .seriesPrimaryImage, - .seasonUserData, - .overview, - .genres, - .people, - .chapters, - ], - enableUserData: true, - ids: [item.id ?? ""]) - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { response in - if let item = response.items?.first { - self.item = item - self.playButtonItem = item - } - } - .store(in: &cancellables) - } + override func updateItem() { + ItemsAPI.getItems( + userId: SessionManager.main.currentLogin.user.id, + limit: 1, + fields: [ + .primaryImageAspectRatio, + .seriesPrimaryImage, + .seasonUserData, + .overview, + .genres, + .people, + .chapters, + ], + enableUserData: true, + ids: [item.id ?? ""] + ) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { response in + if let item = response.items?.first { + self.item = item + self.playButtonItem = item + } + } + .store(in: &cancellables) + } } diff --git a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift index b147763f..2f73c129 100644 --- a/Shared/ViewModels/ItemViewModel/ItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/ItemViewModel.swift @@ -13,165 +13,171 @@ import UIKit class ItemViewModel: ViewModel { - @Published - var item: BaseItemDto - @Published - var playButtonItem: BaseItemDto? { - didSet { - if let playButtonItem = playButtonItem { - refreshItemVideoPlayerViewModel(for: playButtonItem) - } - } - } + @Published + var item: BaseItemDto + @Published + var playButtonItem: BaseItemDto? { + didSet { + if let playButtonItem = playButtonItem { + refreshItemVideoPlayerViewModel(for: playButtonItem) + } + } + } - @Published - var similarItems: [BaseItemDto] = [] - @Published - var isWatched = false - @Published - var isFavorited = false - @Published - var informationItems: [BaseItemDto.ItemDetail] - @Published - var selectedVideoPlayerViewModel: VideoPlayerViewModel? - var videoPlayerViewModels: [VideoPlayerViewModel] = [] + @Published + var similarItems: [BaseItemDto] = [] + @Published + var isWatched = false + @Published + var isFavorited = false + @Published + var informationItems: [BaseItemDto.ItemDetail] + @Published + var selectedVideoPlayerViewModel: VideoPlayerViewModel? + var videoPlayerViewModels: [VideoPlayerViewModel] = [] - init(item: BaseItemDto) { - self.item = item + init(item: BaseItemDto) { + self.item = item - switch item.itemType { - case .episode, .movie: - if !item.missing && !item.unaired { - self.playButtonItem = item - } - default: () - } + switch item.itemType { + case .episode, .movie: + if !item.missing && !item.unaired { + self.playButtonItem = item + } + default: () + } - informationItems = item.createInformationItems() + informationItems = item.createInformationItems() - isFavorited = item.userData?.isFavorite ?? false - isWatched = item.userData?.played ?? false - super.init() + isFavorited = item.userData?.isFavorite ?? false + isWatched = item.userData?.played ?? false + super.init() - getSimilarItems() + getSimilarItems() - Notifications[.didSendStopReport].subscribe(self, selector: #selector(receivedStopReport(_:))) + Notifications[.didSendStopReport].subscribe(self, selector: #selector(receivedStopReport(_:))) - refreshItemVideoPlayerViewModel(for: item) - } + refreshItemVideoPlayerViewModel(for: item) + } - @objc - private func receivedStopReport(_ notification: NSNotification) { - guard let itemID = notification.object as? String else { return } + @objc + private func receivedStopReport(_ notification: NSNotification) { + guard let itemID = notification.object as? String else { return } - if itemID == item.id { - updateItem() - } else { - // Remove if necessary. Note that this cannot be in deinit as - // holding as an observer won't allow the object to be deinit-ed - Notifications.unsubscribe(self) - } - } + if itemID == item.id { + updateItem() + } else { + // Remove if necessary. Note that this cannot be in deinit as + // holding as an observer won't allow the object to be deinit-ed + Notifications.unsubscribe(self) + } + } - func refreshItemVideoPlayerViewModel(for item: BaseItemDto) { - guard item.itemType == .episode || item.itemType == .movie else { return } - guard !item.missing, !item.unaired else { return } + func refreshItemVideoPlayerViewModel(for item: BaseItemDto) { + guard item.itemType == .episode || item.itemType == .movie else { return } + guard !item.missing, !item.unaired else { return } - item.createVideoPlayerViewModel() - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { viewModels in - self.videoPlayerViewModels = viewModels - self.selectedVideoPlayerViewModel = viewModels.first - } - .store(in: &cancellables) - } + item.createVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { viewModels in + self.videoPlayerViewModels = viewModels + self.selectedVideoPlayerViewModel = viewModels.first + } + .store(in: &cancellables) + } - func playButtonText() -> String { + func playButtonText() -> String { - if item.unaired { - return L10n.unaired - } + if item.unaired { + return L10n.unaired + } - if item.missing { - return L10n.missing - } + if item.missing { + return L10n.missing + } - if let itemProgressString = item.getItemProgressString() { - return itemProgressString - } + if let itemProgressString = item.getItemProgressString() { + return itemProgressString + } - return L10n.play - } + return L10n.play + } - func getItemDisplayName() -> String { - item.name ?? "" - } + func getItemDisplayName() -> String { + item.name ?? "" + } - func shouldDisplayRuntime() -> Bool { - true - } + func shouldDisplayRuntime() -> Bool { + true + } - func getSimilarItems() { - LibraryAPI.getSimilarItems(itemId: item.id!, - userId: SessionManager.main.currentLogin.user.id, - limit: 10, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people]) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - self?.similarItems = response.items ?? [] - }) - .store(in: &cancellables) - } + func getSimilarItems() { + LibraryAPI.getSimilarItems( + itemId: item.id!, + userId: SessionManager.main.currentLogin.user.id, + limit: 10, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people] + ) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + self?.similarItems = response.items ?? [] + }) + .store(in: &cancellables) + } - func updateWatchState() { - if isWatched { - PlaystateAPI.markUnplayedItem(userId: SessionManager.main.currentLogin.user.id, - itemId: item.id!) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] _ in - self?.isWatched = false - }) - .store(in: &cancellables) - } else { - PlaystateAPI.markPlayedItem(userId: SessionManager.main.currentLogin.user.id, - itemId: item.id!) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] _ in - self?.isWatched = true - }) - .store(in: &cancellables) - } - } + func updateWatchState() { + if isWatched { + PlaystateAPI.markUnplayedItem( + userId: SessionManager.main.currentLogin.user.id, + itemId: item.id! + ) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] _ in + self?.isWatched = false + }) + .store(in: &cancellables) + } else { + PlaystateAPI.markPlayedItem( + userId: SessionManager.main.currentLogin.user.id, + itemId: item.id! + ) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] _ in + self?.isWatched = true + }) + .store(in: &cancellables) + } + } - func updateFavoriteState() { - if isFavorited { - UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] _ in - self?.isFavorited = false - }) - .store(in: &cancellables) - } else { - UserLibraryAPI.markFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] _ in - self?.isFavorited = true - }) - .store(in: &cancellables) - } - } + func updateFavoriteState() { + if isFavorited { + UserLibraryAPI.unmarkFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] _ in + self?.isFavorited = false + }) + .store(in: &cancellables) + } else { + UserLibraryAPI.markFavoriteItem(userId: SessionManager.main.currentLogin.user.id, itemId: item.id!) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] _ in + self?.isFavorited = true + }) + .store(in: &cancellables) + } + } - // Overridden by subclasses - func updateItem() {} + // Overridden by subclasses + func updateItem() {} } diff --git a/Shared/ViewModels/ItemViewModel/MovieItemViewModel.swift b/Shared/ViewModels/ItemViewModel/MovieItemViewModel.swift index 08755376..652b4f54 100644 --- a/Shared/ViewModels/ItemViewModel/MovieItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/MovieItemViewModel.swift @@ -12,28 +12,30 @@ import JellyfinAPI final class MovieItemViewModel: ItemViewModel { - override func updateItem() { - ItemsAPI.getItems(userId: SessionManager.main.currentLogin.user.id, - limit: 1, - fields: [ - .primaryImageAspectRatio, - .seriesPrimaryImage, - .seasonUserData, - .overview, - .genres, - .people, - .chapters, - ], - enableUserData: true, - ids: [item.id ?? ""]) - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { response in - if let item = response.items?.first { - self.item = item - self.playButtonItem = item - } - } - .store(in: &cancellables) - } + override func updateItem() { + ItemsAPI.getItems( + userId: SessionManager.main.currentLogin.user.id, + limit: 1, + fields: [ + .primaryImageAspectRatio, + .seriesPrimaryImage, + .seasonUserData, + .overview, + .genres, + .people, + .chapters, + ], + enableUserData: true, + ids: [item.id ?? ""] + ) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { response in + if let item = response.items?.first { + self.item = item + self.playButtonItem = item + } + } + .store(in: &cancellables) + } } diff --git a/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift b/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift index 3c2da092..777cc4f2 100644 --- a/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/SeasonItemViewModel.swift @@ -13,110 +13,118 @@ import Stinsen final class SeasonItemViewModel: ItemViewModel, EpisodesRowManager { - @RouterObject - var itemRouter: ItemCoordinator.Router? - @Published - var episodes: [BaseItemDto] = [] - @Published - var seriesItem: BaseItemDto? - @Published - var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:] - @Published - var selectedSeason: BaseItemDto? + @RouterObject + var itemRouter: ItemCoordinator.Router? + @Published + var episodes: [BaseItemDto] = [] + @Published + var seriesItem: BaseItemDto? + @Published + var seasonsEpisodes: [BaseItemDto: [BaseItemDto]] = [:] + @Published + var selectedSeason: BaseItemDto? - override init(item: BaseItemDto) { - super.init(item: item) + override init(item: BaseItemDto) { + super.init(item: item) - getSeriesItem() - selectedSeason = item - retrieveSeasons() - requestEpisodes() - } + getSeriesItem() + selectedSeason = item + retrieveSeasons() + requestEpisodes() + } - override func playButtonText() -> String { + override func playButtonText() -> String { - if item.unaired { - return L10n.unaired - } + if item.unaired { + return L10n.unaired + } - guard let playButtonItem = playButtonItem, let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play } - return episodeLocator - } + guard let playButtonItem = playButtonItem, let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play } + return episodeLocator + } - private func requestEpisodes() { - LogManager.log - .debug("Getting episodes in season \(item.id!) (\(item.name!)) of show \(item.seriesId!) (\(item.seriesName!))") - TvShowsAPI.getEpisodes(seriesId: item.seriesId ?? "", userId: SessionManager.main.currentLogin.user.id, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], - seasonId: item.id ?? "") - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - guard let self = self else { return } - self.episodes = response.items ?? [] - LogManager.log.debug("Retrieved \(String(self.episodes.count)) episodes") + private func requestEpisodes() { + LogManager.log + .debug("Getting episodes in season \(item.id!) (\(item.name!)) of show \(item.seriesId!) (\(item.seriesName!))") + TvShowsAPI.getEpisodes( + seriesId: item.seriesId ?? "", + userId: SessionManager.main.currentLogin.user.id, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + seasonId: item.id ?? "" + ) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + guard let self = self else { return } + self.episodes = response.items ?? [] + LogManager.log.debug("Retrieved \(String(self.episodes.count)) episodes") - self.setNextUpInSeason() - }) - .store(in: &cancellables) - } + self.setNextUpInSeason() + }) + .store(in: &cancellables) + } - private func setNextUpInSeason() { + private func setNextUpInSeason() { - TvShowsAPI.getNextUp(userId: SessionManager.main.currentLogin.user.id, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], - seriesId: item.seriesId ?? "", enableUserData: true) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - guard let self = self else { return } + TvShowsAPI.getNextUp( + userId: SessionManager.main.currentLogin.user.id, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + seriesId: item.seriesId ?? "", + enableUserData: true + ) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + guard let self = self else { return } - // Find the nextup item that belongs to current season. - if let nextUpItem = (response.items ?? []).first(where: { episode in - !episode.unaired && !episode.missing && episode.seasonId ?? "" == self.item.id! - }) { - self.playButtonItem = nextUpItem - LogManager.log.debug("Nextup in season \(self.item.id!) (\(self.item.name!)): \(nextUpItem.id!)") - } + // Find the nextup item that belongs to current season. + if let nextUpItem = (response.items ?? []).first(where: { episode in + !episode.unaired && !episode.missing && episode.seasonId ?? "" == self.item.id! + }) { + self.playButtonItem = nextUpItem + LogManager.log.debug("Nextup in season \(self.item.id!) (\(self.item.name!)): \(nextUpItem.id!)") + } - if self.playButtonItem == nil && !self.episodes.isEmpty { - // Fallback to the old mechanism: - // Sets the play button item to the "Next up" in the season based upon - // the watched status of episodes in the season. - // Default to the first episode of the season if all have been watched. - var firstUnwatchedSearch: BaseItemDto? + if self.playButtonItem == nil && !self.episodes.isEmpty { + // Fallback to the old mechanism: + // Sets the play button item to the "Next up" in the season based upon + // the watched status of episodes in the season. + // Default to the first episode of the season if all have been watched. + var firstUnwatchedSearch: BaseItemDto? - for episode in self.episodes { - guard let played = episode.userData?.played else { continue } - if !played { - firstUnwatchedSearch = episode - break - } - } + for episode in self.episodes { + guard let played = episode.userData?.played else { continue } + if !played { + firstUnwatchedSearch = episode + break + } + } - if let firstUnwatched = firstUnwatchedSearch { - self.playButtonItem = firstUnwatched - } else { - guard let firstEpisode = self.episodes.first else { return } - self.playButtonItem = firstEpisode - } - } - }) - .store(in: &cancellables) - } + if let firstUnwatched = firstUnwatchedSearch { + self.playButtonItem = firstUnwatched + } else { + guard let firstEpisode = self.episodes.first else { return } + self.playButtonItem = firstEpisode + } + } + }) + .store(in: &cancellables) + } - private func getSeriesItem() { - guard let seriesID = item.seriesId else { return } - UserLibraryAPI.getItem(userId: SessionManager.main.currentLogin.user.id, - itemId: seriesID) - .trackActivity(loading) - .sink { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - } receiveValue: { [weak self] seriesItem in - self?.seriesItem = seriesItem - } - .store(in: &cancellables) - } + private func getSeriesItem() { + guard let seriesID = item.seriesId else { return } + UserLibraryAPI.getItem( + userId: SessionManager.main.currentLogin.user.id, + itemId: seriesID + ) + .trackActivity(loading) + .sink { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + } receiveValue: { [weak self] seriesItem in + self?.seriesItem = seriesItem + } + .store(in: &cancellables) + } } diff --git a/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift b/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift index 55ac91ea..e035b8a0 100644 --- a/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift +++ b/Shared/ViewModels/ItemViewModel/SeriesItemViewModel.swift @@ -13,83 +13,88 @@ import JellyfinAPI final class SeriesItemViewModel: ItemViewModel { - @Published - var seasons: [BaseItemDto] = [] + @Published + var seasons: [BaseItemDto] = [] - override init(item: BaseItemDto) { - super.init(item: item) + override init(item: BaseItemDto) { + super.init(item: item) - requestSeasons() - getNextUp() - } + requestSeasons() + getNextUp() + } - override func playButtonText() -> String { + override func playButtonText() -> String { - if item.unaired { - return L10n.unaired - } + if item.unaired { + return L10n.unaired + } - if item.missing { - return L10n.missing - } + if item.missing { + return L10n.missing + } - guard let playButtonItem = playButtonItem, let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play } - return episodeLocator - } + guard let playButtonItem = playButtonItem, let episodeLocator = playButtonItem.getEpisodeLocator() else { return L10n.play } + return episodeLocator + } - override func shouldDisplayRuntime() -> Bool { - false - } + override func shouldDisplayRuntime() -> Bool { + false + } - private func getNextUp() { + private func getNextUp() { - LogManager.log.debug("Getting next up for show \(self.item.id!) (\(self.item.name!))") - TvShowsAPI.getNextUp(userId: SessionManager.main.currentLogin.user.id, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], - seriesId: self.item.id!, - enableUserData: true) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - if let nextUpItem = response.items?.first, !nextUpItem.unaired, !nextUpItem.missing { - self?.playButtonItem = nextUpItem - } - }) - .store(in: &cancellables) - } + LogManager.log.debug("Getting next up for show \(self.item.id!) (\(self.item.name!))") + TvShowsAPI.getNextUp( + userId: SessionManager.main.currentLogin.user.id, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + seriesId: self.item.id!, + enableUserData: true + ) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + if let nextUpItem = response.items?.first, !nextUpItem.unaired, !nextUpItem.missing { + self?.playButtonItem = nextUpItem + } + }) + .store(in: &cancellables) + } - private func getRunYears() -> String { - let dateFormatter = DateFormatter() - dateFormatter.dateFormat = "yyyy" + private func getRunYears() -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy" - var startYear: String? - var endYear: String? + var startYear: String? + var endYear: String? - if item.premiereDate != nil { - startYear = dateFormatter.string(from: item.premiereDate!) - } + if item.premiereDate != nil { + startYear = dateFormatter.string(from: item.premiereDate!) + } - if item.endDate != nil { - endYear = dateFormatter.string(from: item.endDate!) - } + if item.endDate != nil { + endYear = dateFormatter.string(from: item.endDate!) + } - return "\(startYear ?? L10n.unknown) - \(endYear ?? L10n.present)" - } + return "\(startYear ?? L10n.unknown) - \(endYear ?? L10n.present)" + } - private func requestSeasons() { - LogManager.log.debug("Getting seasons of show \(self.item.id!) (\(self.item.name!))") - TvShowsAPI.getSeasons(seriesId: item.id ?? "", userId: SessionManager.main.currentLogin.user.id, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], - isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false, - enableUserData: true) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - self?.seasons = response.items ?? [] - LogManager.log.debug("Retrieved \(String(self?.seasons.count ?? 0)) seasons") - }) - .store(in: &cancellables) - } + private func requestSeasons() { + LogManager.log.debug("Getting seasons of show \(self.item.id!) (\(self.item.name!))") + TvShowsAPI.getSeasons( + seriesId: item.id ?? "", + userId: SessionManager.main.currentLogin.user.id, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + isMissing: Defaults[.shouldShowMissingSeasons] ? nil : false, + enableUserData: true + ) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + self?.seasons = response.items ?? [] + LogManager.log.debug("Retrieved \(String(self?.seasons.count ?? 0)) seasons") + }) + .store(in: &cancellables) + } } diff --git a/Shared/ViewModels/LatestMediaViewModel.swift b/Shared/ViewModels/LatestMediaViewModel.swift index 84bdef4d..d2f701d2 100644 --- a/Shared/ViewModels/LatestMediaViewModel.swift +++ b/Shared/ViewModels/LatestMediaViewModel.swift @@ -12,39 +12,42 @@ import JellyfinAPI final class LatestMediaViewModel: ViewModel { - @Published - var items = [BaseItemDto]() + @Published + var items = [BaseItemDto]() - let library: BaseItemDto + let library: BaseItemDto - init(library: BaseItemDto) { - self.library = library - super.init() + init(library: BaseItemDto) { + self.library = library + super.init() - requestLatestMedia() - } + requestLatestMedia() + } - func requestLatestMedia() { - LogManager.log.debug("Requesting latest media for user id \(SessionManager.main.currentLogin.user.id)") - UserLibraryAPI.getLatestMedia(userId: SessionManager.main.currentLogin.user.id, - parentId: library.id ?? "", - fields: [ - .primaryImageAspectRatio, - .seriesPrimaryImage, - .seasonUserData, - .overview, - .genres, - .people, - ], - includeItemTypes: [.series, .movie], - enableUserData: true, limit: 12) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - self?.items = response - LogManager.log.debug("Retrieved \(String(self?.items.count ?? 0)) items") - }) - .store(in: &cancellables) - } + func requestLatestMedia() { + LogManager.log.debug("Requesting latest media for user id \(SessionManager.main.currentLogin.user.id)") + UserLibraryAPI.getLatestMedia( + userId: SessionManager.main.currentLogin.user.id, + parentId: library.id ?? "", + fields: [ + .primaryImageAspectRatio, + .seriesPrimaryImage, + .seasonUserData, + .overview, + .genres, + .people, + ], + includeItemTypes: [.series, .movie], + enableUserData: true, + limit: 12 + ) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + self?.items = response + LogManager.log.debug("Retrieved \(String(self?.items.count ?? 0)) items") + }) + .store(in: &cancellables) + } } diff --git a/Shared/ViewModels/LibraryFilterViewModel.swift b/Shared/ViewModels/LibraryFilterViewModel.swift index 527cacfa..621ba81d 100644 --- a/Shared/ViewModels/LibraryFilterViewModel.swift +++ b/Shared/ViewModels/LibraryFilterViewModel.swift @@ -11,72 +11,76 @@ import Foundation import JellyfinAPI enum FilterType { - case tag - case genre - case sortOrder - case sortBy - case filter + case tag + case genre + case sortOrder + case sortBy + case filter } final class LibraryFilterViewModel: ViewModel { - @Published - var modifiedFilters = LibraryFilters() + @Published + var modifiedFilters = LibraryFilters() - @Published - var possibleGenres = [NameGuidPair]() - @Published - var possibleTags = [String]() - @Published - var possibleSortOrders = APISortOrder.allCases - @Published - var possibleSortBys = SortBy.allCases - @Published - var possibleItemFilters = ItemFilter.supportedTypes - @Published - var enabledFilterType: [FilterType] - @Published - var selectedSortOrder: APISortOrder = .descending - @Published - var selectedSortBy: SortBy = .name + @Published + var possibleGenres = [NameGuidPair]() + @Published + var possibleTags = [String]() + @Published + var possibleSortOrders = APISortOrder.allCases + @Published + var possibleSortBys = SortBy.allCases + @Published + var possibleItemFilters = ItemFilter.supportedTypes + @Published + var enabledFilterType: [FilterType] + @Published + var selectedSortOrder: APISortOrder = .descending + @Published + var selectedSortBy: SortBy = .name - var parentId: String = "" + var parentId: String = "" - func updateModifiedFilter() { - modifiedFilters.sortOrder = [selectedSortOrder] - modifiedFilters.sortBy = [selectedSortBy] - } + func updateModifiedFilter() { + modifiedFilters.sortOrder = [selectedSortOrder] + modifiedFilters.sortBy = [selectedSortBy] + } - func resetFilters() { - modifiedFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name]) - } + func resetFilters() { + modifiedFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name]) + } - init(filters: LibraryFilters? = nil, - enabledFilterType: [FilterType] = [.tag, .genre, .sortBy, .sortOrder, .filter], parentId: String) - { - self.enabledFilterType = enabledFilterType - self.selectedSortBy = filters?.sortBy.first ?? .name - self.selectedSortOrder = filters?.sortOrder.first ?? .descending - self.parentId = parentId + init( + filters: LibraryFilters? = nil, + enabledFilterType: [FilterType] = [.tag, .genre, .sortBy, .sortOrder, .filter], + parentId: String + ) { + self.enabledFilterType = enabledFilterType + self.selectedSortBy = filters?.sortBy.first ?? .name + self.selectedSortOrder = filters?.sortOrder.first ?? .descending + self.parentId = parentId - super.init() - if let filters = filters { - self.modifiedFilters = filters - } - requestQueryFilters() - } + super.init() + if let filters = filters { + self.modifiedFilters = filters + } + requestQueryFilters() + } - func requestQueryFilters() { - FilterAPI.getQueryFilters(userId: SessionManager.main.currentLogin.user.id, - parentId: self.parentId) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] queryFilters in - guard let self = self else { return } - self.possibleGenres = queryFilters.genres ?? [] - self.possibleTags = queryFilters.tags ?? [] - }) - .store(in: &cancellables) - } + func requestQueryFilters() { + FilterAPI.getQueryFilters( + userId: SessionManager.main.currentLogin.user.id, + parentId: self.parentId + ) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] queryFilters in + guard let self = self else { return } + self.possibleGenres = queryFilters.genres ?? [] + self.possibleTags = queryFilters.tags ?? [] + }) + .store(in: &cancellables) + } } diff --git a/Shared/ViewModels/LibraryListViewModel.swift b/Shared/ViewModels/LibraryListViewModel.swift index 25ed0557..95536a8f 100644 --- a/Shared/ViewModels/LibraryListViewModel.swift +++ b/Shared/ViewModels/LibraryListViewModel.swift @@ -11,26 +11,26 @@ import JellyfinAPI final class LibraryListViewModel: ViewModel { - @Published - var libraries: [BaseItemDto] = [] + @Published + var libraries: [BaseItemDto] = [] - // temp - var withFavorites = LibraryFilters(filters: [.isFavorite], sortOrder: [], withGenres: [], sortBy: []) + // temp + var withFavorites = LibraryFilters(filters: [.isFavorite], sortOrder: [], withGenres: [], sortBy: []) - override init() { - super.init() + override init() { + super.init() - requestLibraries() - } + requestLibraries() + } - func requestLibraries() { - UserViewsAPI.getUserViews(userId: SessionManager.main.currentLogin.user.id) - .trackActivity(loading) - .sink(receiveCompletion: { completion in - self.handleAPIRequestError(completion: completion) - }, receiveValue: { response in - self.libraries = response.items ?? [] - }) - .store(in: &cancellables) - } + func requestLibraries() { + UserViewsAPI.getUserViews(userId: SessionManager.main.currentLogin.user.id) + .trackActivity(loading) + .sink(receiveCompletion: { completion in + self.handleAPIRequestError(completion: completion) + }, receiveValue: { response in + self.libraries = response.items ?? [] + }) + .store(in: &cancellables) + } } diff --git a/Shared/ViewModels/LibrarySearchViewModel.swift b/Shared/ViewModels/LibrarySearchViewModel.swift index 3e843f21..94a999cc 100644 --- a/Shared/ViewModels/LibrarySearchViewModel.swift +++ b/Shared/ViewModels/LibrarySearchViewModel.swift @@ -14,140 +14,166 @@ import SwiftUI final class LibrarySearchViewModel: ViewModel { - @Published - var supportedItemTypeList = [ItemType]() + @Published + var supportedItemTypeList = [ItemType]() - @Published - var selectedItemType: ItemType = .movie + @Published + var selectedItemType: ItemType = .movie - @Published - var movieItems = [BaseItemDto]() - @Published - var showItems = [BaseItemDto]() - @Published - var episodeItems = [BaseItemDto]() + @Published + var movieItems = [BaseItemDto]() + @Published + var showItems = [BaseItemDto]() + @Published + var episodeItems = [BaseItemDto]() - @Published - var suggestions = [BaseItemDto]() + @Published + var suggestions = [BaseItemDto]() - var searchQuerySubject = CurrentValueSubject("") - var parentID: String? + var searchQuerySubject = CurrentValueSubject("") + var parentID: String? - init(parentID: String?) { - self.parentID = parentID - super.init() + init(parentID: String?) { + self.parentID = parentID + super.init() - searchQuerySubject - .filter { !$0.isEmpty } - .debounce(for: 0.25, scheduler: DispatchQueue.main) - .sink(receiveValue: search) - .store(in: &cancellables) - setupPublishersForSupportedItemType() + searchQuerySubject + .filter { !$0.isEmpty } + .debounce(for: 0.25, scheduler: DispatchQueue.main) + .sink(receiveValue: search) + .store(in: &cancellables) + setupPublishersForSupportedItemType() - requestSuggestions() - } + requestSuggestions() + } - func setupPublishersForSupportedItemType() { - Publishers.CombineLatest3($movieItems, $showItems, $episodeItems) - .debounce(for: 0.25, scheduler: DispatchQueue.main) - .map { arg -> [ItemType] in - var typeList = [ItemType]() - if !arg.0.isEmpty { - typeList.append(.movie) - } - if !arg.1.isEmpty { - typeList.append(.series) - } - if !arg.2.isEmpty { - typeList.append(.episode) - } - return typeList - } - .receive(on: DispatchQueue.main) - .sink(receiveValue: { [weak self] typeList in - withAnimation { - self?.supportedItemTypeList = typeList - } - }) - .store(in: &cancellables) + func setupPublishersForSupportedItemType() { + Publishers.CombineLatest3($movieItems, $showItems, $episodeItems) + .debounce(for: 0.25, scheduler: DispatchQueue.main) + .map { arg -> [ItemType] in + var typeList = [ItemType]() + if !arg.0.isEmpty { + typeList.append(.movie) + } + if !arg.1.isEmpty { + typeList.append(.series) + } + if !arg.2.isEmpty { + typeList.append(.episode) + } + return typeList + } + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] typeList in + withAnimation { + self?.supportedItemTypeList = typeList + } + }) + .store(in: &cancellables) - $supportedItemTypeList - .receive(on: DispatchQueue.main) - .withLatestFrom($selectedItemType) - .compactMap { selectedItemType in - if self.supportedItemTypeList.contains(selectedItemType) { - return selectedItemType - } else { - return self.supportedItemTypeList.first - } - } - .sink(receiveValue: { [weak self] itemType in - withAnimation { - self?.selectedItemType = itemType - } - }) - .store(in: &cancellables) - } + $supportedItemTypeList + .receive(on: DispatchQueue.main) + .withLatestFrom($selectedItemType) + .compactMap { selectedItemType in + if self.supportedItemTypeList.contains(selectedItemType) { + return selectedItemType + } else { + return self.supportedItemTypeList.first + } + } + .sink(receiveValue: { [weak self] itemType in + withAnimation { + self?.selectedItemType = itemType + } + }) + .store(in: &cancellables) + } - func requestSuggestions() { - ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, - limit: 20, - recursive: true, - parentId: parentID, - includeItemTypes: [.movie, .series], - sortBy: ["IsFavoriteOrLiked", "Random"], - imageTypeLimit: 0, - enableTotalRecordCount: false, - enableImages: false) - .trackActivity(loading) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - self?.suggestions = response.items ?? [] - }) - .store(in: &cancellables) - } + func requestSuggestions() { + ItemsAPI.getItemsByUserId( + userId: SessionManager.main.currentLogin.user.id, + limit: 20, + recursive: true, + parentId: parentID, + includeItemTypes: [.movie, .series], + sortBy: ["IsFavoriteOrLiked", "Random"], + imageTypeLimit: 0, + enableTotalRecordCount: false, + enableImages: false + ) + .trackActivity(loading) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + self?.suggestions = response.items ?? [] + }) + .store(in: &cancellables) + } - func search(with query: String) { - ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, limit: 50, recursive: true, searchTerm: query, - sortOrder: [.ascending], parentId: parentID, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], - includeItemTypes: [.movie], sortBy: ["SortName"], enableUserData: true, - enableImages: true) - .trackActivity(loading) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - self?.movieItems = response.items ?? [] - }) - .store(in: &cancellables) - ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, limit: 50, recursive: true, searchTerm: query, - sortOrder: [.ascending], parentId: parentID, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], - includeItemTypes: [.series], sortBy: ["SortName"], enableUserData: true, - enableImages: true) - .trackActivity(loading) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - self?.showItems = response.items ?? [] - }) - .store(in: &cancellables) - ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, limit: 50, recursive: true, searchTerm: query, - sortOrder: [.ascending], parentId: parentID, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], - includeItemTypes: [.episode], sortBy: ["SortName"], enableUserData: true, - enableImages: true) - .trackActivity(loading) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - self?.episodeItems = response.items ?? [] - }) - .store(in: &cancellables) - } + func search(with query: String) { + ItemsAPI.getItemsByUserId( + userId: SessionManager.main.currentLogin.user.id, + limit: 50, + recursive: true, + searchTerm: query, + sortOrder: [.ascending], + parentId: parentID, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + includeItemTypes: [.movie], + sortBy: ["SortName"], + enableUserData: true, + enableImages: true + ) + .trackActivity(loading) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + self?.movieItems = response.items ?? [] + }) + .store(in: &cancellables) + ItemsAPI.getItemsByUserId( + userId: SessionManager.main.currentLogin.user.id, + limit: 50, + recursive: true, + searchTerm: query, + sortOrder: [.ascending], + parentId: parentID, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + includeItemTypes: [.series], + sortBy: ["SortName"], + enableUserData: true, + enableImages: true + ) + .trackActivity(loading) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + self?.showItems = response.items ?? [] + }) + .store(in: &cancellables) + ItemsAPI.getItemsByUserId( + userId: SessionManager.main.currentLogin.user.id, + limit: 50, + recursive: true, + searchTerm: query, + sortOrder: [.ascending], + parentId: parentID, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + includeItemTypes: [.episode], + sortBy: ["SortName"], + enableUserData: true, + enableImages: true + ) + .trackActivity(loading) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + self?.episodeItems = response.items ?? [] + }) + .store(in: &cancellables) + } } diff --git a/Shared/ViewModels/LibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel.swift index 09114026..7d6a8caf 100644 --- a/Shared/ViewModels/LibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel.swift @@ -16,182 +16,188 @@ import UIKit typealias LibraryRow = CollectionRow struct LibraryRowCell: Hashable { - let id = UUID() - let item: BaseItemDto? - var loadingCell: Bool = false + let id = UUID() + let item: BaseItemDto? + var loadingCell: Bool = false } final class LibraryViewModel: ViewModel { - @Published - var items: [BaseItemDto] = [] - @Published - var rows: [LibraryRow] = [] + @Published + var items: [BaseItemDto] = [] + @Published + var rows: [LibraryRow] = [] - @Published - var totalPages = 0 - @Published - var currentPage = 0 - @Published - var hasNextPage = false + @Published + var totalPages = 0 + @Published + var currentPage = 0 + @Published + var hasNextPage = false - // temp - @Published - var filters: LibraryFilters + // temp + @Published + var filters: LibraryFilters - var parentID: String? - var person: BaseItemPerson? - var genre: NameGuidPair? - var studio: NameGuidPair? - private let columns: Int - private let pageItemSize: Int + var parentID: String? + var person: BaseItemPerson? + var genre: NameGuidPair? + var studio: NameGuidPair? + private let columns: Int + private let pageItemSize: Int - var enabledFilterType: [FilterType] { - if genre == nil { - return [.tag, .genre, .sortBy, .sortOrder, .filter] - } else { - return [.tag, .sortBy, .sortOrder, .filter] - } - } + var enabledFilterType: [FilterType] { + if genre == nil { + return [.tag, .genre, .sortBy, .sortOrder, .filter] + } else { + return [.tag, .sortBy, .sortOrder, .filter] + } + } - init(parentID: String? = nil, - person: BaseItemPerson? = nil, - genre: NameGuidPair? = nil, - studio: NameGuidPair? = nil, - filters: LibraryFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], sortBy: [.name]), - columns: Int = 7) - { - self.parentID = parentID - self.person = person - self.genre = genre - self.studio = studio - self.filters = filters - self.columns = columns + init( + parentID: String? = nil, + person: BaseItemPerson? = nil, + genre: NameGuidPair? = nil, + studio: NameGuidPair? = nil, + filters: LibraryFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], sortBy: [.name]), + columns: Int = 7 + ) { + self.parentID = parentID + self.person = person + self.genre = genre + self.studio = studio + self.filters = filters + self.columns = columns - // Size is typical size of portrait items - self.pageItemSize = UIScreen.itemsFillableOnScreen(width: 130, height: 185) + // Size is typical size of portrait items + self.pageItemSize = UIScreen.itemsFillableOnScreen(width: 130, height: 185) - super.init() + super.init() - $filters - .sink(receiveValue: { newFilters in - self.requestItemsAsync(with: newFilters, replaceCurrentItems: true) - }) - .store(in: &cancellables) - } + $filters + .sink(receiveValue: { newFilters in + self.requestItemsAsync(with: newFilters, replaceCurrentItems: true) + }) + .store(in: &cancellables) + } - func requestItemsAsync(with filters: LibraryFilters, replaceCurrentItems: Bool = false) { + func requestItemsAsync(with filters: LibraryFilters, replaceCurrentItems: Bool = false) { - if replaceCurrentItems { - self.items = [] - } + if replaceCurrentItems { + self.items = [] + } - let personIDs: [String] = [person].compactMap(\.?.id) - let studioIDs: [String] = [studio].compactMap(\.?.id) - let genreIDs: [String] - if filters.withGenres.isEmpty { - genreIDs = [genre].compactMap(\.?.id) - } else { - genreIDs = filters.withGenres.compactMap(\.id) - } - let sortBy = filters.sortBy.map(\.rawValue) - let queryRecursive = Defaults[.showFlattenView] || filters.filters.contains(.isFavorite) || - self.person != nil || - self.genre != nil || - self.studio != nil - let includeItemTypes: [BaseItemKind] - if filters.filters.contains(.isFavorite) { - includeItemTypes = [.movie, .series, .season, .episode, .boxSet] - } else { - includeItemTypes = [.movie, .series, .boxSet] + (Defaults[.showFlattenView] ? [] : [.folder]) - } + let personIDs: [String] = [person].compactMap(\.?.id) + let studioIDs: [String] = [studio].compactMap(\.?.id) + let genreIDs: [String] + if filters.withGenres.isEmpty { + genreIDs = [genre].compactMap(\.?.id) + } else { + genreIDs = filters.withGenres.compactMap(\.id) + } + let sortBy = filters.sortBy.map(\.rawValue) + let queryRecursive = Defaults[.showFlattenView] || filters.filters.contains(.isFavorite) || + self.person != nil || + self.genre != nil || + self.studio != nil + let includeItemTypes: [BaseItemKind] + if filters.filters.contains(.isFavorite) { + includeItemTypes = [.movie, .series, .season, .episode, .boxSet] + } else { + includeItemTypes = [.movie, .series, .boxSet] + (Defaults[.showFlattenView] ? [] : [.folder]) + } - ItemsAPI.getItemsByUserId(userId: SessionManager.main.currentLogin.user.id, startIndex: currentPage * pageItemSize, - limit: pageItemSize, - recursive: queryRecursive, - searchTerm: nil, - sortOrder: filters.sortOrder.compactMap { SortOrder(rawValue: $0.rawValue) }, - parentId: parentID, - fields: [ - .primaryImageAspectRatio, - .seriesPrimaryImage, - .seasonUserData, - .overview, - .genres, - .people, - .chapters, - ], - includeItemTypes: includeItemTypes, - filters: filters.filters, - sortBy: sortBy, - tags: filters.tags, - enableUserData: true, - personIds: personIDs, - studioIds: studioIDs, - genreIds: genreIDs, - enableImages: true) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in + ItemsAPI.getItemsByUserId( + userId: SessionManager.main.currentLogin.user.id, + startIndex: currentPage * pageItemSize, + limit: pageItemSize, + recursive: queryRecursive, + searchTerm: nil, + sortOrder: filters.sortOrder.compactMap { SortOrder(rawValue: $0.rawValue) }, + parentId: parentID, + fields: [ + .primaryImageAspectRatio, + .seriesPrimaryImage, + .seasonUserData, + .overview, + .genres, + .people, + .chapters, + ], + includeItemTypes: includeItemTypes, + filters: filters.filters, + sortBy: sortBy, + tags: filters.tags, + enableUserData: true, + personIds: personIDs, + studioIds: studioIDs, + genreIds: genreIDs, + enableImages: true + ) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in - guard let self = self else { return } - let totalPages = ceil(Double(response.totalRecordCount ?? 0) / Double(self.pageItemSize)) + guard let self = self else { return } + let totalPages = ceil(Double(response.totalRecordCount ?? 0) / Double(self.pageItemSize)) - self.totalPages = Int(totalPages) - self.hasNextPage = self.currentPage < self.totalPages - 1 - self.items.append(contentsOf: response.items ?? []) - self.rows = self.calculateRows(for: self.items) - }) - .store(in: &cancellables) - } + self.totalPages = Int(totalPages) + self.hasNextPage = self.currentPage < self.totalPages - 1 + self.items.append(contentsOf: response.items ?? []) + self.rows = self.calculateRows(for: self.items) + }) + .store(in: &cancellables) + } - func requestNextPageAsync() { - currentPage += 1 - requestItemsAsync(with: filters) - } + func requestNextPageAsync() { + currentPage += 1 + requestItemsAsync(with: filters) + } - // tvOS calculations for collection view - private func calculateRows(for itemList: [BaseItemDto]) -> [LibraryRow] { - guard !itemList.isEmpty else { return [] } - let rowCount = itemList.count / columns - var calculatedRows = [LibraryRow]() - for i in 0 ... rowCount { - let firstItemIndex = i * columns - var lastItemIndex = firstItemIndex + columns - if lastItemIndex > itemList.count { - lastItemIndex = itemList.count - } + // tvOS calculations for collection view + private func calculateRows(for itemList: [BaseItemDto]) -> [LibraryRow] { + guard !itemList.isEmpty else { return [] } + let rowCount = itemList.count / columns + var calculatedRows = [LibraryRow]() + for i in 0 ... rowCount { + let firstItemIndex = i * columns + var lastItemIndex = firstItemIndex + columns + if lastItemIndex > itemList.count { + lastItemIndex = itemList.count + } - var rowCells = [LibraryRowCell]() - for item in itemList[firstItemIndex ..< lastItemIndex] { - let newCell = LibraryRowCell(item: item) - rowCells.append(newCell) - } - if i == rowCount, hasNextPage { - var loadingCell = LibraryRowCell(item: nil) - loadingCell.loadingCell = true - rowCells.append(loadingCell) - } + var rowCells = [LibraryRowCell]() + for item in itemList[firstItemIndex ..< lastItemIndex] { + let newCell = LibraryRowCell(item: item) + rowCells.append(newCell) + } + if i == rowCount, hasNextPage { + var loadingCell = LibraryRowCell(item: nil) + loadingCell.loadingCell = true + rowCells.append(loadingCell) + } - calculatedRows.append(LibraryRow(section: i, - items: rowCells)) - } - return calculatedRows - } + calculatedRows.append(LibraryRow( + section: i, + items: rowCells + )) + } + return calculatedRows + } } extension UIScreen { - static func itemsFillableOnScreen(width: CGFloat, height: CGFloat) -> Int { + static func itemsFillableOnScreen(width: CGFloat, height: CGFloat) -> Int { - let screenSize = UIScreen.main.bounds.height * UIScreen.main.bounds.width - let itemSize = width * height + let screenSize = UIScreen.main.bounds.height * UIScreen.main.bounds.width + let itemSize = width * height - #if os(tvOS) - return Int(screenSize / itemSize) * 2 - #else - return Int(screenSize / itemSize) - #endif - } + #if os(tvOS) + return Int(screenSize / itemSize) * 2 + #else + return Int(screenSize / itemSize) + #endif + } } diff --git a/Shared/ViewModels/LiveTVChannelsViewModel.swift b/Shared/ViewModels/LiveTVChannelsViewModel.swift index 8f5f1b0b..55621897 100644 --- a/Shared/ViewModels/LiveTVChannelsViewModel.swift +++ b/Shared/ViewModels/LiveTVChannelsViewModel.swift @@ -13,230 +13,234 @@ import SwiftUICollection typealias LiveTVChannelRow = CollectionRow struct LiveTVChannelRowCell: Hashable { - let id = UUID() - let item: LiveTVChannelProgram + let id = UUID() + let item: LiveTVChannelProgram } struct LiveTVChannelProgram: Hashable { - let id = UUID() - let channel: BaseItemDto - let currentProgram: BaseItemDto? - let programs: [BaseItemDto] + let id = UUID() + let channel: BaseItemDto + let currentProgram: BaseItemDto? + let programs: [BaseItemDto] } final class LiveTVChannelsViewModel: ViewModel { - @Published - var channels = [BaseItemDto]() - @Published - var channelPrograms = [LiveTVChannelProgram]() { - didSet { - rows = [] - let rowChannels = channelPrograms.chunked(into: 4) - for (index, rowChans) in rowChannels.enumerated() { - rows.append(LiveTVChannelRow(section: index, items: rowChans.map { LiveTVChannelRowCell(item: $0) })) - } - } - } + @Published + var channels = [BaseItemDto]() + @Published + var channelPrograms = [LiveTVChannelProgram]() { + didSet { + rows = [] + let rowChannels = channelPrograms.chunked(into: 4) + for (index, rowChans) in rowChannels.enumerated() { + rows.append(LiveTVChannelRow(section: index, items: rowChans.map { LiveTVChannelRowCell(item: $0) })) + } + } + } - @Published - var rows = [LiveTVChannelRow]() + @Published + var rows = [LiveTVChannelRow]() - private var programs = [BaseItemDto]() - private var channelProgramsList = [BaseItemDto: [BaseItemDto]]() - private var timer: Timer? + private var programs = [BaseItemDto]() + private var channelProgramsList = [BaseItemDto: [BaseItemDto]]() + private var timer: Timer? - var timeFormatter: DateFormatter { - let df = DateFormatter() - df.dateFormat = "h:mm" - return df - } + var timeFormatter: DateFormatter { + let df = DateFormatter() + df.dateFormat = "h:mm" + return df + } - override init() { - super.init() + override init() { + super.init() - getChannels() - startScheduleCheckTimer() - } + getChannels() + startScheduleCheckTimer() + } - deinit { - stopScheduleCheckTimer() - } + deinit { + stopScheduleCheckTimer() + } - private func getGuideInfo() { - LiveTvAPI.getGuideInfo() - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] _ in - LogManager.log.debug("Received Guide Info") - guard let self = self else { return } - self.getChannels() - }) - .store(in: &cancellables) - } + private func getGuideInfo() { + LiveTvAPI.getGuideInfo() + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] _ in + LogManager.log.debug("Received Guide Info") + guard let self = self else { return } + self.getChannels() + }) + .store(in: &cancellables) + } - func getChannels() { - LiveTvAPI.getLiveTvChannels(userId: SessionManager.main.currentLogin.user.id, - startIndex: 0, - limit: 1000, - enableImageTypes: [.primary], - enableUserData: false, - enableFavoriteSorting: true) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - LogManager.log.debug("Received \(response.items?.count ?? 0) Channels") - guard let self = self else { return } - self.channels = response.items ?? [] - self.getPrograms() - }) - .store(in: &cancellables) - } + func getChannels() { + LiveTvAPI.getLiveTvChannels( + userId: SessionManager.main.currentLogin.user.id, + startIndex: 0, + limit: 1000, + enableImageTypes: [.primary], + enableUserData: false, + enableFavoriteSorting: true + ) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + LogManager.log.debug("Received \(response.items?.count ?? 0) Channels") + guard let self = self else { return } + self.channels = response.items ?? [] + self.getPrograms() + }) + .store(in: &cancellables) + } - private func getPrograms() { - // http://192.168.1.50:8096/LiveTv/Programs - guard !channels.isEmpty else { - LogManager.log.debug("Cannot get programs, channels list empty. ") - return - } - let channelIds = channels.compactMap(\.id) + private func getPrograms() { + // http://192.168.1.50:8096/LiveTv/Programs + guard !channels.isEmpty else { + LogManager.log.debug("Cannot get programs, channels list empty. ") + return + } + let channelIds = channels.compactMap(\.id) - let minEndDate = Date.now.addComponentsToDate(hours: -1) - let maxStartDate = minEndDate.addComponentsToDate(hours: 6) + let minEndDate = Date.now.addComponentsToDate(hours: -1) + let maxStartDate = minEndDate.addComponentsToDate(hours: 6) - let getProgramsRequest = GetProgramsRequest(channelIds: channelIds, - userId: SessionManager.main.currentLogin.user.id, - maxStartDate: maxStartDate, - minEndDate: minEndDate, - sortBy: ["StartDate"], - enableImages: true, - enableTotalRecordCount: false, - imageTypeLimit: 1, - enableImageTypes: [.primary], - enableUserData: false) + let getProgramsRequest = GetProgramsRequest( + channelIds: channelIds, + userId: SessionManager.main.currentLogin.user.id, + maxStartDate: maxStartDate, + minEndDate: minEndDate, + sortBy: ["StartDate"], + enableImages: true, + enableTotalRecordCount: false, + imageTypeLimit: 1, + enableImageTypes: [.primary], + enableUserData: false + ) - LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - LogManager.log.debug("Received \(response.items?.count ?? 0) Programs") - guard let self = self else { return } - self.programs = response.items ?? [] - self.channelPrograms = self.processChannelPrograms() - }) - .store(in: &cancellables) - } + LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + LogManager.log.debug("Received \(response.items?.count ?? 0) Programs") + guard let self = self else { return } + self.programs = response.items ?? [] + self.channelPrograms = self.processChannelPrograms() + }) + .store(in: &cancellables) + } - private func processChannelPrograms() -> [LiveTVChannelProgram] { - var channelPrograms = [LiveTVChannelProgram]() - let now = Date() - for channel in self.channels { - let prgs = self.programs.filter { item in - item.channelId == channel.id - } - DispatchQueue.main.async { - self.channelProgramsList[channel] = prgs - } + private func processChannelPrograms() -> [LiveTVChannelProgram] { + var channelPrograms = [LiveTVChannelProgram]() + let now = Date() + for channel in self.channels { + let prgs = self.programs.filter { item in + item.channelId == channel.id + } + DispatchQueue.main.async { + self.channelProgramsList[channel] = prgs + } - var currentPrg: BaseItemDto? - for prg in prgs { - if let startDate = prg.startDate, - let endDate = prg.endDate, - now.timeIntervalSinceReferenceDate > startDate.timeIntervalSinceReferenceDate && - now.timeIntervalSinceReferenceDate < endDate.timeIntervalSinceReferenceDate - { - currentPrg = prg - } - } + var currentPrg: BaseItemDto? + for prg in prgs { + if let startDate = prg.startDate, + let endDate = prg.endDate, + now.timeIntervalSinceReferenceDate > startDate.timeIntervalSinceReferenceDate && + now.timeIntervalSinceReferenceDate < endDate.timeIntervalSinceReferenceDate + { + currentPrg = prg + } + } - channelPrograms.append(LiveTVChannelProgram(channel: channel, currentProgram: currentPrg, programs: prgs)) - } - return channelPrograms - } + channelPrograms.append(LiveTVChannelProgram(channel: channel, currentProgram: currentPrg, programs: prgs)) + } + return channelPrograms + } - func startScheduleCheckTimer() { - let date = Date() - let calendar = Calendar.current - var components = calendar.dateComponents([.era, .year, .month, .day, .hour, .minute], from: date) + func startScheduleCheckTimer() { + let date = Date() + let calendar = Calendar.current + var components = calendar.dateComponents([.era, .year, .month, .day, .hour, .minute], from: date) - // Run on 10th min of every hour - guard let minute = components.minute else { return } - components.second = 0 - components.minute = minute + (10 - (minute % 10)) + // Run on 10th min of every hour + guard let minute = components.minute else { return } + components.second = 0 + components.minute = minute + (10 - (minute % 10)) - guard let nextMinute = calendar.date(from: components) else { return } + guard let nextMinute = calendar.date(from: components) else { return } - if let existingTimer = timer { - existingTimer.invalidate() - } - timer = Timer(fire: nextMinute, interval: 60 * 10, repeats: true) { [weak self] _ in - guard let self = self else { return } - LogManager.log.debug("LiveTVChannels schedule check...") - DispatchQueue.global(qos: .background).async { - let newChanPrgs = self.processChannelPrograms() - DispatchQueue.main.async { - self.channelPrograms = newChanPrgs - } - } - } - if let timer = timer { - RunLoop.main.add(timer, forMode: .default) - } - } + if let existingTimer = timer { + existingTimer.invalidate() + } + timer = Timer(fire: nextMinute, interval: 60 * 10, repeats: true) { [weak self] _ in + guard let self = self else { return } + LogManager.log.debug("LiveTVChannels schedule check...") + DispatchQueue.global(qos: .background).async { + let newChanPrgs = self.processChannelPrograms() + DispatchQueue.main.async { + self.channelPrograms = newChanPrgs + } + } + } + if let timer = timer { + RunLoop.main.add(timer, forMode: .default) + } + } - func stopScheduleCheckTimer() { - timer?.invalidate() - } + func stopScheduleCheckTimer() { + timer?.invalidate() + } - func fetchVideoPlayerViewModel(item: BaseItemDto, completion: @escaping (VideoPlayerViewModel) -> Void) { - item.createLiveTVVideoPlayerViewModel() - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { videoPlayerViewModels in - if let viewModel = videoPlayerViewModels.first { - completion(viewModel) - } - } - .store(in: &self.cancellables) - } + func fetchVideoPlayerViewModel(item: BaseItemDto, completion: @escaping (VideoPlayerViewModel) -> Void) { + item.createLiveTVVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { videoPlayerViewModels in + if let viewModel = videoPlayerViewModels.first { + completion(viewModel) + } + } + .store(in: &self.cancellables) + } } extension Array { - func chunked(into size: Int) -> [[Element]] { - stride(from: 0, to: count, by: size).map { - Array(self[$0 ..< Swift.min($0 + size, count)]) - } - } + func chunked(into size: Int) -> [[Element]] { + stride(from: 0, to: count, by: size).map { + Array(self[$0 ..< Swift.min($0 + size, count)]) + } + } } extension Date { - func addComponentsToDate(seconds sec: Int? = nil, minutes min: Int? = nil, hours hrs: Int? = nil, days d: Int? = nil) -> Date { - var dc = DateComponents() - if let sec = sec { - dc.second = sec - } - if let min = min { - dc.minute = min - } - if let hrs = hrs { - dc.hour = hrs - } - if let d = d { - dc.day = d - } - return Calendar.current.date(byAdding: dc, to: self)! - } + func addComponentsToDate(seconds sec: Int? = nil, minutes min: Int? = nil, hours hrs: Int? = nil, days d: Int? = nil) -> Date { + var dc = DateComponents() + if let sec = sec { + dc.second = sec + } + if let min = min { + dc.minute = min + } + if let hrs = hrs { + dc.hour = hrs + } + if let d = d { + dc.day = d + } + return Calendar.current.date(byAdding: dc, to: self)! + } - func midnightUTCDate() -> Date { - var dc: DateComponents = Calendar.current.dateComponents([.year, .month, .day], from: self) - dc.hour = 0 - dc.minute = 0 - dc.second = 0 - dc.nanosecond = 0 - dc.timeZone = TimeZone(secondsFromGMT: 0) - return Calendar.current.date(from: dc)! - } + func midnightUTCDate() -> Date { + var dc: DateComponents = Calendar.current.dateComponents([.year, .month, .day], from: self) + dc.hour = 0 + dc.minute = 0 + dc.second = 0 + dc.nanosecond = 0 + dc.timeZone = TimeZone(secondsFromGMT: 0) + return Calendar.current.date(from: dc)! + } } diff --git a/Shared/ViewModels/LiveTVProgramsViewModel.swift b/Shared/ViewModels/LiveTVProgramsViewModel.swift index b0b94946..3d983882 100644 --- a/Shared/ViewModels/LiveTVProgramsViewModel.swift +++ b/Shared/ViewModels/LiveTVProgramsViewModel.swift @@ -11,202 +11,216 @@ import JellyfinAPI final class LiveTVProgramsViewModel: ViewModel { - @Published - var recommendedItems = [BaseItemDto]() - @Published - var seriesItems = [BaseItemDto]() - @Published - var movieItems = [BaseItemDto]() - @Published - var sportsItems = [BaseItemDto]() - @Published - var kidsItems = [BaseItemDto]() - @Published - var newsItems = [BaseItemDto]() + @Published + var recommendedItems = [BaseItemDto]() + @Published + var seriesItems = [BaseItemDto]() + @Published + var movieItems = [BaseItemDto]() + @Published + var sportsItems = [BaseItemDto]() + @Published + var kidsItems = [BaseItemDto]() + @Published + var newsItems = [BaseItemDto]() - private var channels = [String: BaseItemDto]() + private var channels = [String: BaseItemDto]() - override init() { - super.init() + override init() { + super.init() - getChannels() - } + getChannels() + } - func findChannel(id: String) -> BaseItemDto? { - channels[id] - } + func findChannel(id: String) -> BaseItemDto? { + channels[id] + } - private func getChannels() { - LiveTvAPI.getLiveTvChannels(userId: SessionManager.main.currentLogin.user.id, - startIndex: 0, - limit: 1000, - enableImageTypes: [.primary], - enableUserData: false, - enableFavoriteSorting: true) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - LogManager.log.debug("Received \(response.items?.count ?? 0) Channels") - guard let self = self else { return } - if let chans = response.items { - for chan in chans { - if let chanId = chan.id { - self.channels[chanId] = chan - } - } - self.getRecommendedPrograms() - self.getSeries() - self.getMovies() - self.getSports() - self.getKids() - self.getNews() - } - }) - .store(in: &cancellables) - } + private func getChannels() { + LiveTvAPI.getLiveTvChannels( + userId: SessionManager.main.currentLogin.user.id, + startIndex: 0, + limit: 1000, + enableImageTypes: [.primary], + enableUserData: false, + enableFavoriteSorting: true + ) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + LogManager.log.debug("Received \(response.items?.count ?? 0) Channels") + guard let self = self else { return } + if let chans = response.items { + for chan in chans { + if let chanId = chan.id { + self.channels[chanId] = chan + } + } + self.getRecommendedPrograms() + self.getSeries() + self.getMovies() + self.getSports() + self.getKids() + self.getNews() + } + }) + .store(in: &cancellables) + } - private func getRecommendedPrograms() { - LiveTvAPI.getRecommendedPrograms(userId: SessionManager.main.currentLogin.user.id, - limit: 9, - isAiring: true, - imageTypeLimit: 1, - enableImageTypes: [.primary, .thumb], - fields: [.channelInfo, .primaryImageAspectRatio], - enableTotalRecordCount: false) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - LogManager.log.debug("Received \(String(response.items?.count ?? 0)) Recommended Programs") - guard let self = self else { return } - self.recommendedItems = response.items ?? [] - }) - .store(in: &cancellables) - } + private func getRecommendedPrograms() { + LiveTvAPI.getRecommendedPrograms( + userId: SessionManager.main.currentLogin.user.id, + limit: 9, + isAiring: true, + imageTypeLimit: 1, + enableImageTypes: [.primary, .thumb], + fields: [.channelInfo, .primaryImageAspectRatio], + enableTotalRecordCount: false + ) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + LogManager.log.debug("Received \(String(response.items?.count ?? 0)) Recommended Programs") + guard let self = self else { return } + self.recommendedItems = response.items ?? [] + }) + .store(in: &cancellables) + } - private func getSeries() { - let getProgramsRequest = GetProgramsRequest(userId: SessionManager.main.currentLogin.user.id, - hasAired: false, - isMovie: false, - isSeries: true, - isNews: false, - isKids: false, - isSports: false, - limit: 9, - enableTotalRecordCount: false, - enableImageTypes: [.primary, .thumb], - fields: [.channelInfo, .primaryImageAspectRatio]) + private func getSeries() { + let getProgramsRequest = GetProgramsRequest( + userId: SessionManager.main.currentLogin.user.id, + hasAired: false, + isMovie: false, + isSeries: true, + isNews: false, + isKids: false, + isSports: false, + limit: 9, + enableTotalRecordCount: false, + enableImageTypes: [.primary, .thumb], + fields: [.channelInfo, .primaryImageAspectRatio] + ) - LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - LogManager.log.debug("Received \(String(response.items?.count ?? 0)) Series Items") - guard let self = self else { return } - self.seriesItems = response.items ?? [] - }) - .store(in: &cancellables) - } + LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + LogManager.log.debug("Received \(String(response.items?.count ?? 0)) Series Items") + guard let self = self else { return } + self.seriesItems = response.items ?? [] + }) + .store(in: &cancellables) + } - private func getMovies() { - let getProgramsRequest = GetProgramsRequest(userId: SessionManager.main.currentLogin.user.id, - hasAired: false, - isMovie: true, - isSeries: false, - isNews: false, - isKids: false, - isSports: false, - limit: 9, - enableTotalRecordCount: false, - enableImageTypes: [.primary, .thumb], - fields: [.channelInfo, .primaryImageAspectRatio]) + private func getMovies() { + let getProgramsRequest = GetProgramsRequest( + userId: SessionManager.main.currentLogin.user.id, + hasAired: false, + isMovie: true, + isSeries: false, + isNews: false, + isKids: false, + isSports: false, + limit: 9, + enableTotalRecordCount: false, + enableImageTypes: [.primary, .thumb], + fields: [.channelInfo, .primaryImageAspectRatio] + ) - LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - LogManager.log.debug("Received \(String(response.items?.count ?? 0)) Movie Items") - guard let self = self else { return } - self.movieItems = response.items ?? [] - }) - .store(in: &cancellables) - } + LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + LogManager.log.debug("Received \(String(response.items?.count ?? 0)) Movie Items") + guard let self = self else { return } + self.movieItems = response.items ?? [] + }) + .store(in: &cancellables) + } - private func getSports() { - let getProgramsRequest = GetProgramsRequest(userId: SessionManager.main.currentLogin.user.id, - hasAired: false, - isSports: true, - limit: 9, - enableTotalRecordCount: false, - enableImageTypes: [.primary, .thumb], - fields: [.channelInfo, .primaryImageAspectRatio]) + private func getSports() { + let getProgramsRequest = GetProgramsRequest( + userId: SessionManager.main.currentLogin.user.id, + hasAired: false, + isSports: true, + limit: 9, + enableTotalRecordCount: false, + enableImageTypes: [.primary, .thumb], + fields: [.channelInfo, .primaryImageAspectRatio] + ) - LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - LogManager.log.debug("Received \(String(response.items?.count ?? 0)) Sports Items") - guard let self = self else { return } - self.sportsItems = response.items ?? [] - }) - .store(in: &cancellables) - } + LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + LogManager.log.debug("Received \(String(response.items?.count ?? 0)) Sports Items") + guard let self = self else { return } + self.sportsItems = response.items ?? [] + }) + .store(in: &cancellables) + } - private func getKids() { - let getProgramsRequest = GetProgramsRequest(userId: SessionManager.main.currentLogin.user.id, - hasAired: false, - isKids: true, - limit: 9, - enableTotalRecordCount: false, - enableImageTypes: [.primary, .thumb], - fields: [.channelInfo, .primaryImageAspectRatio]) + private func getKids() { + let getProgramsRequest = GetProgramsRequest( + userId: SessionManager.main.currentLogin.user.id, + hasAired: false, + isKids: true, + limit: 9, + enableTotalRecordCount: false, + enableImageTypes: [.primary, .thumb], + fields: [.channelInfo, .primaryImageAspectRatio] + ) - LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - LogManager.log.debug("Received \(String(response.items?.count ?? 0)) Kids Items") - guard let self = self else { return } - self.kidsItems = response.items ?? [] - }) - .store(in: &cancellables) - } + LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + LogManager.log.debug("Received \(String(response.items?.count ?? 0)) Kids Items") + guard let self = self else { return } + self.kidsItems = response.items ?? [] + }) + .store(in: &cancellables) + } - private func getNews() { - let getProgramsRequest = GetProgramsRequest(userId: SessionManager.main.currentLogin.user.id, - hasAired: false, - isNews: true, - limit: 9, - enableTotalRecordCount: false, - enableImageTypes: [.primary, .thumb], - fields: [.channelInfo, .primaryImageAspectRatio]) + private func getNews() { + let getProgramsRequest = GetProgramsRequest( + userId: SessionManager.main.currentLogin.user.id, + hasAired: false, + isNews: true, + limit: 9, + enableTotalRecordCount: false, + enableImageTypes: [.primary, .thumb], + fields: [.channelInfo, .primaryImageAspectRatio] + ) - LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest) - .trackActivity(loading) - .sink(receiveCompletion: { [weak self] completion in - self?.handleAPIRequestError(completion: completion) - }, receiveValue: { [weak self] response in - LogManager.log.debug("Received \(String(response.items?.count ?? 0)) News Items") - guard let self = self else { return } - self.newsItems = response.items ?? [] - }) - .store(in: &cancellables) - } + LiveTvAPI.getPrograms(getProgramsRequest: getProgramsRequest) + .trackActivity(loading) + .sink(receiveCompletion: { [weak self] completion in + self?.handleAPIRequestError(completion: completion) + }, receiveValue: { [weak self] response in + LogManager.log.debug("Received \(String(response.items?.count ?? 0)) News Items") + guard let self = self else { return } + self.newsItems = response.items ?? [] + }) + .store(in: &cancellables) + } - func fetchVideoPlayerViewModel(item: BaseItemDto, completion: @escaping (VideoPlayerViewModel) -> Void) { - item.createLiveTVVideoPlayerViewModel() - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { videoPlayerViewModels in - if let viewModel = videoPlayerViewModels.first { - completion(viewModel) - } - } - .store(in: &self.cancellables) - } + func fetchVideoPlayerViewModel(item: BaseItemDto, completion: @escaping (VideoPlayerViewModel) -> Void) { + item.createLiveTVVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { videoPlayerViewModels in + if let viewModel = videoPlayerViewModels.first { + completion(viewModel) + } + } + .store(in: &self.cancellables) + } } diff --git a/Shared/ViewModels/MainTabViewModel.swift b/Shared/ViewModels/MainTabViewModel.swift index dc80ccf7..4581cbf7 100644 --- a/Shared/ViewModels/MainTabViewModel.swift +++ b/Shared/ViewModels/MainTabViewModel.swift @@ -10,24 +10,24 @@ import Foundation import JellyfinAPI final class MainTabViewModel: ViewModel { - @Published - var backgroundURL: URL? - @Published - var lastBackgroundURL: URL? - @Published - var backgroundBlurHash: String = "001fC^" + @Published + var backgroundURL: URL? + @Published + var lastBackgroundURL: URL? + @Published + var backgroundBlurHash: String = "001fC^" - override init() { - super.init() + override init() { + super.init() - let nc = NotificationCenter.default - nc.addObserver(self, selector: #selector(backgroundDidChange), name: Notification.Name("backgroundDidChange"), object: nil) - } + let nc = NotificationCenter.default + nc.addObserver(self, selector: #selector(backgroundDidChange), name: Notification.Name("backgroundDidChange"), object: nil) + } - @objc - func backgroundDidChange() { - self.lastBackgroundURL = self.backgroundURL - self.backgroundURL = BackgroundManager.current.backgroundURL - self.backgroundBlurHash = BackgroundManager.current.blurhash - } + @objc + func backgroundDidChange() { + self.lastBackgroundURL = self.backgroundURL + self.backgroundURL = BackgroundManager.current.backgroundURL + self.backgroundBlurHash = BackgroundManager.current.blurhash + } } diff --git a/Shared/ViewModels/MovieLibrariesViewModel.swift b/Shared/ViewModels/MovieLibrariesViewModel.swift index 4d6fc934..09ca6d4b 100644 --- a/Shared/ViewModels/MovieLibrariesViewModel.swift +++ b/Shared/ViewModels/MovieLibrariesViewModel.swift @@ -14,79 +14,81 @@ import SwiftUICollection final class MovieLibrariesViewModel: ViewModel { - @Published - var rows = [LibraryRow]() - @Published - var totalPages = 0 - @Published - var currentPage = 0 - @Published - var hasNextPage = false - @Published - var hasPreviousPage = false + @Published + var rows = [LibraryRow]() + @Published + var totalPages = 0 + @Published + var currentPage = 0 + @Published + var hasNextPage = false + @Published + var hasPreviousPage = false - private var libraries = [BaseItemDto]() - private let columns: Int + private var libraries = [BaseItemDto]() + private let columns: Int - @RouterObject - var router: MovieLibrariesCoordinator.Router? + @RouterObject + var router: MovieLibrariesCoordinator.Router? - init(columns: Int = 7) { - self.columns = columns - super.init() + init(columns: Int = 7) { + self.columns = columns + super.init() - requestLibraries() - } + requestLibraries() + } - func requestLibraries() { + func requestLibraries() { - UserViewsAPI.getUserViews(userId: SessionManager.main.currentLogin.user.id) - .trackActivity(loading) - .sink(receiveCompletion: { completion in - self.handleAPIRequestError(completion: completion) - }, receiveValue: { response in - if let responseItems = response.items { - self.libraries = [] - for library in responseItems { - if library.collectionType == "movies" { - self.libraries.append(library) - } - } - self.rows = self.calculateRows() - if self.libraries.count == 1, let library = self.libraries.first { - // make this library the root of this stack - self.router?.coordinator.root(\.rootLibrary, library) - } - } - }) - .store(in: &cancellables) - } + UserViewsAPI.getUserViews(userId: SessionManager.main.currentLogin.user.id) + .trackActivity(loading) + .sink(receiveCompletion: { completion in + self.handleAPIRequestError(completion: completion) + }, receiveValue: { response in + if let responseItems = response.items { + self.libraries = [] + for library in responseItems { + if library.collectionType == "movies" { + self.libraries.append(library) + } + } + self.rows = self.calculateRows() + if self.libraries.count == 1, let library = self.libraries.first { + // make this library the root of this stack + self.router?.coordinator.root(\.rootLibrary, library) + } + } + }) + .store(in: &cancellables) + } - private func calculateRows() -> [LibraryRow] { - guard !libraries.isEmpty else { return [] } - let rowCount = libraries.count / columns - var calculatedRows = [LibraryRow]() - for i in 0 ... rowCount { - let firstItemIndex = i * columns - var lastItemIndex = firstItemIndex + columns - if lastItemIndex > libraries.count { - lastItemIndex = libraries.count - } + private func calculateRows() -> [LibraryRow] { + guard !libraries.isEmpty else { return [] } + let rowCount = libraries.count / columns + var calculatedRows = [LibraryRow]() + for i in 0 ... rowCount { + let firstItemIndex = i * columns + var lastItemIndex = firstItemIndex + columns + if lastItemIndex > libraries.count { + lastItemIndex = libraries.count + } - var rowCells = [LibraryRowCell]() - for item in libraries[firstItemIndex ..< lastItemIndex] { - let newCell = LibraryRowCell(item: item) - rowCells.append(newCell) - } - if i == rowCount && hasNextPage { - var loadingCell = LibraryRowCell(item: nil) - loadingCell.loadingCell = true - rowCells.append(loadingCell) - } + var rowCells = [LibraryRowCell]() + for item in libraries[firstItemIndex ..< lastItemIndex] { + let newCell = LibraryRowCell(item: item) + rowCells.append(newCell) + } + if i == rowCount && hasNextPage { + var loadingCell = LibraryRowCell(item: nil) + loadingCell.loadingCell = true + rowCells.append(loadingCell) + } - calculatedRows.append(LibraryRow(section: i, - items: rowCells)) - } - return calculatedRows - } + calculatedRows.append(LibraryRow( + section: i, + items: rowCells + )) + } + return calculatedRows + } } diff --git a/Shared/ViewModels/QuickConnectSettingsViewModel.swift b/Shared/ViewModels/QuickConnectSettingsViewModel.swift index eea1f7d2..f575824c 100644 --- a/Shared/ViewModels/QuickConnectSettingsViewModel.swift +++ b/Shared/ViewModels/QuickConnectSettingsViewModel.swift @@ -11,36 +11,36 @@ import JellyfinAPI final class QuickConnectSettingsViewModel: ViewModel { - @Published - var quickConnectCode = "" - @Published - var showSuccessMessage = false + @Published + var quickConnectCode = "" + @Published + var showSuccessMessage = false - var alertTitle: String { - var message: String = "" - if errorMessage?.code != ErrorMessage.noShowErrorCode { - message.append(contentsOf: "\(errorMessage?.code ?? ErrorMessage.noShowErrorCode)\n") - } - message.append(contentsOf: "\(errorMessage?.title ?? L10n.unknownError)") - return message - } + var alertTitle: String { + var message: String = "" + if errorMessage?.code != ErrorMessage.noShowErrorCode { + message.append(contentsOf: "\(errorMessage?.code ?? ErrorMessage.noShowErrorCode)\n") + } + message.append(contentsOf: "\(errorMessage?.title ?? L10n.unknownError)") + return message + } - func sendQuickConnect() { - QuickConnectAPI.authorize(code: self.quickConnectCode) - .trackActivity(loading) - .sink(receiveCompletion: { completion in - self.handleAPIRequestError(displayMessage: L10n.quickConnectInvalidError, completion: completion) - switch completion { - case .failure: - LogManager.log.debug("Invalid Quick Connect code entered") - default: - break - } - }, receiveValue: { _ in - // receiving a successful HTTP response indicates a valid code - LogManager.log.debug("Valid Quick connect code entered") - self.showSuccessMessage = true - }) - .store(in: &cancellables) - } + func sendQuickConnect() { + QuickConnectAPI.authorize(code: self.quickConnectCode) + .trackActivity(loading) + .sink(receiveCompletion: { completion in + self.handleAPIRequestError(displayMessage: L10n.quickConnectInvalidError, completion: completion) + switch completion { + case .failure: + LogManager.log.debug("Invalid Quick Connect code entered") + default: + break + } + }, receiveValue: { _ in + // receiving a successful HTTP response indicates a valid code + LogManager.log.debug("Valid Quick connect code entered") + self.showSuccessMessage = true + }) + .store(in: &cancellables) + } } diff --git a/Shared/ViewModels/ServerDetailViewModel.swift b/Shared/ViewModels/ServerDetailViewModel.swift index 69d0ded3..05910aea 100644 --- a/Shared/ViewModels/ServerDetailViewModel.swift +++ b/Shared/ViewModels/ServerDetailViewModel.swift @@ -11,22 +11,22 @@ import JellyfinAPI class ServerDetailViewModel: ViewModel { - @Published - var server: SwiftfinStore.State.Server + @Published + var server: SwiftfinStore.State.Server - init(server: SwiftfinStore.State.Server) { - self.server = server - } + init(server: SwiftfinStore.State.Server) { + self.server = server + } - func setServerCurrentURI(uri: String) { - SessionManager.main.setServerCurrentURI(server: server, uri: uri) - .sink { c in - print(c) - } receiveValue: { newServerState in - self.server = newServerState + func setServerCurrentURI(uri: String) { + SessionManager.main.setServerCurrentURI(server: server, uri: uri) + .sink { c in + print(c) + } receiveValue: { newServerState in + self.server = newServerState - Notifications[.didChangeServerCurrentURI].post(object: newServerState) - } - .store(in: &cancellables) - } + Notifications[.didChangeServerCurrentURI].post(object: newServerState) + } + .store(in: &cancellables) + } } diff --git a/Shared/ViewModels/ServerListViewModel.swift b/Shared/ViewModels/ServerListViewModel.swift index 86b7cd59..34be22b8 100644 --- a/Shared/ViewModels/ServerListViewModel.swift +++ b/Shared/ViewModels/ServerListViewModel.swift @@ -11,37 +11,37 @@ import SwiftUI class ServerListViewModel: ObservableObject { - @Published - var servers: [SwiftfinStore.State.Server] = [] + @Published + var servers: [SwiftfinStore.State.Server] = [] - init() { + init() { - // Oct. 15, 2021 - // This is a workaround since Stinsen doesn't have the ability to rebuild a root at the time of writing. - // Feature request issue: https://github.com/rundfunk47/stinsen/issues/33 - // Go to each MainCoordinator and implement the rebuild of the root when receiving the notification - Notifications[.didPurge].subscribe(self, selector: #selector(didPurge)) - } + // Oct. 15, 2021 + // This is a workaround since Stinsen doesn't have the ability to rebuild a root at the time of writing. + // Feature request issue: https://github.com/rundfunk47/stinsen/issues/33 + // Go to each MainCoordinator and implement the rebuild of the root when receiving the notification + Notifications[.didPurge].subscribe(self, selector: #selector(didPurge)) + } - func fetchServers() { - self.servers = SessionManager.main.fetchServers() - } + func fetchServers() { + self.servers = SessionManager.main.fetchServers() + } - func userTextFor(server: SwiftfinStore.State.Server) -> String { - if server.userIDs.count == 1 { - return L10n.oneUser - } else { - return L10n.multipleUsers(server.userIDs.count) - } - } + func userTextFor(server: SwiftfinStore.State.Server) -> String { + if server.userIDs.count == 1 { + return L10n.oneUser + } else { + return L10n.multipleUsers(server.userIDs.count) + } + } - func remove(server: SwiftfinStore.State.Server) { - SessionManager.main.delete(server: server) - fetchServers() - } + func remove(server: SwiftfinStore.State.Server) { + SessionManager.main.delete(server: server) + fetchServers() + } - @objc - private func didPurge() { - fetchServers() - } + @objc + private func didPurge() { + fetchServers() + } } diff --git a/Shared/ViewModels/SettingsViewModel.swift b/Shared/ViewModels/SettingsViewModel.swift index 6a9b535d..59b78899 100644 --- a/Shared/ViewModels/SettingsViewModel.swift +++ b/Shared/ViewModels/SettingsViewModel.swift @@ -14,36 +14,36 @@ import SwiftUI final class SettingsViewModel: ViewModel { - var bitrates: [Bitrates] = [] - var langs: [TrackLanguage] = [] + var bitrates: [Bitrates] = [] + var langs: [TrackLanguage] = [] - let server: SwiftfinStore.State.Server - let user: SwiftfinStore.State.User + let server: SwiftfinStore.State.Server + let user: SwiftfinStore.State.User - init(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) { + init(server: SwiftfinStore.State.Server, user: SwiftfinStore.State.User) { - self.server = server - self.user = user + self.server = server + self.user = user - // Bitrates - let url = Bundle.main.url(forResource: "bitrates", withExtension: "json")! + // Bitrates + let url = Bundle.main.url(forResource: "bitrates", withExtension: "json")! - do { - let jsonData = try Data(contentsOf: url, options: .mappedIfSafe) - do { - self.bitrates = try JSONDecoder().decode([Bitrates].self, from: jsonData) - } catch { - LogManager.log.error("Error converting processed JSON into Swift compatible schema.") - } - } catch { - LogManager.log.error("Error processing JSON file `bitrates.json`") - } + do { + let jsonData = try Data(contentsOf: url, options: .mappedIfSafe) + do { + self.bitrates = try JSONDecoder().decode([Bitrates].self, from: jsonData) + } catch { + LogManager.log.error("Error converting processed JSON into Swift compatible schema.") + } + } catch { + LogManager.log.error("Error processing JSON file `bitrates.json`") + } - // Track languages - self.langs = Locale.isoLanguageCodes.compactMap { - guard let name = Locale.current.localizedString(forLanguageCode: $0) else { return nil } - return TrackLanguage(name: name, isoCode: $0) - }.sorted(by: { $0.name < $1.name }) - self.langs.insert(.auto, at: 0) - } + // Track languages + self.langs = Locale.isoLanguageCodes.compactMap { + guard let name = Locale.current.localizedString(forLanguageCode: $0) else { return nil } + return TrackLanguage(name: name, isoCode: $0) + }.sorted(by: { $0.name < $1.name }) + self.langs.insert(.auto, at: 0) + } } diff --git a/Shared/ViewModels/TVLibrariesViewModel.swift b/Shared/ViewModels/TVLibrariesViewModel.swift index aca00158..7109bfff 100644 --- a/Shared/ViewModels/TVLibrariesViewModel.swift +++ b/Shared/ViewModels/TVLibrariesViewModel.swift @@ -14,79 +14,81 @@ import SwiftUICollection final class TVLibrariesViewModel: ViewModel { - @Published - var rows = [LibraryRow]() - @Published - var totalPages = 0 - @Published - var currentPage = 0 - @Published - var hasNextPage = false - @Published - var hasPreviousPage = false + @Published + var rows = [LibraryRow]() + @Published + var totalPages = 0 + @Published + var currentPage = 0 + @Published + var hasNextPage = false + @Published + var hasPreviousPage = false - private var libraries = [BaseItemDto]() - private let columns: Int + private var libraries = [BaseItemDto]() + private let columns: Int - @RouterObject - var router: TVLibrariesCoordinator.Router? + @RouterObject + var router: TVLibrariesCoordinator.Router? - init(columns: Int = 7) { - self.columns = columns - super.init() + init(columns: Int = 7) { + self.columns = columns + super.init() - requestLibraries() - } + requestLibraries() + } - func requestLibraries() { + func requestLibraries() { - UserViewsAPI.getUserViews(userId: SessionManager.main.currentLogin.user.id) - .trackActivity(loading) - .sink(receiveCompletion: { completion in - self.handleAPIRequestError(completion: completion) - }, receiveValue: { response in - if let responseItems = response.items { - self.libraries = [] - for library in responseItems { - if library.collectionType == "tvshows" { - self.libraries.append(library) - } - } - self.rows = self.calculateRows() - if self.libraries.count == 1, let library = self.libraries.first { - // make this library the root of this stack - self.router?.coordinator.root(\.rootLibrary, library) - } - } - }) - .store(in: &cancellables) - } + UserViewsAPI.getUserViews(userId: SessionManager.main.currentLogin.user.id) + .trackActivity(loading) + .sink(receiveCompletion: { completion in + self.handleAPIRequestError(completion: completion) + }, receiveValue: { response in + if let responseItems = response.items { + self.libraries = [] + for library in responseItems { + if library.collectionType == "tvshows" { + self.libraries.append(library) + } + } + self.rows = self.calculateRows() + if self.libraries.count == 1, let library = self.libraries.first { + // make this library the root of this stack + self.router?.coordinator.root(\.rootLibrary, library) + } + } + }) + .store(in: &cancellables) + } - private func calculateRows() -> [LibraryRow] { - guard !libraries.isEmpty else { return [] } - let rowCount = libraries.count / columns - var calculatedRows = [LibraryRow]() - for i in 0 ... rowCount { - let firstItemIndex = i * columns - var lastItemIndex = firstItemIndex + columns - if lastItemIndex > libraries.count { - lastItemIndex = libraries.count - } + private func calculateRows() -> [LibraryRow] { + guard !libraries.isEmpty else { return [] } + let rowCount = libraries.count / columns + var calculatedRows = [LibraryRow]() + for i in 0 ... rowCount { + let firstItemIndex = i * columns + var lastItemIndex = firstItemIndex + columns + if lastItemIndex > libraries.count { + lastItemIndex = libraries.count + } - var rowCells = [LibraryRowCell]() - for item in libraries[firstItemIndex ..< lastItemIndex] { - let newCell = LibraryRowCell(item: item) - rowCells.append(newCell) - } - if i == rowCount && hasNextPage { - var loadingCell = LibraryRowCell(item: nil) - loadingCell.loadingCell = true - rowCells.append(loadingCell) - } + var rowCells = [LibraryRowCell]() + for item in libraries[firstItemIndex ..< lastItemIndex] { + let newCell = LibraryRowCell(item: item) + rowCells.append(newCell) + } + if i == rowCount && hasNextPage { + var loadingCell = LibraryRowCell(item: nil) + loadingCell.loadingCell = true + rowCells.append(loadingCell) + } - calculatedRows.append(LibraryRow(section: i, - items: rowCells)) - } - return calculatedRows - } + calculatedRows.append(LibraryRow( + section: i, + items: rowCells + )) + } + return calculatedRows + } } diff --git a/Shared/ViewModels/UserListViewModel.swift b/Shared/ViewModels/UserListViewModel.swift index 7d087323..1e32a812 100644 --- a/Shared/ViewModels/UserListViewModel.swift +++ b/Shared/ViewModels/UserListViewModel.swift @@ -11,36 +11,36 @@ import SwiftUI class UserListViewModel: ViewModel { - @Published - var users: [SwiftfinStore.State.User] = [] + @Published + var users: [SwiftfinStore.State.User] = [] - var server: SwiftfinStore.State.Server + var server: SwiftfinStore.State.Server - init(server: SwiftfinStore.State.Server) { - self.server = server + init(server: SwiftfinStore.State.Server) { + self.server = server - super.init() + super.init() - Notifications[.didChangeServerCurrentURI].subscribe(self, selector: #selector(didChangeCurrentLoginURI(_:))) - } + Notifications[.didChangeServerCurrentURI].subscribe(self, selector: #selector(didChangeCurrentLoginURI(_:))) + } - @objc - func didChangeCurrentLoginURI(_ notification: Notification) { - guard let newServerState = notification.object as? SwiftfinStore.State.Server else { fatalError("Need to have new state server") } - self.server = newServerState - } + @objc + func didChangeCurrentLoginURI(_ notification: Notification) { + guard let newServerState = notification.object as? SwiftfinStore.State.Server else { fatalError("Need to have new state server") } + self.server = newServerState + } - func fetchUsers() { - self.users = SessionManager.main.fetchUsers(for: server) - } + func fetchUsers() { + self.users = SessionManager.main.fetchUsers(for: server) + } - func login(user: SwiftfinStore.State.User) { - self.isLoading = true - SessionManager.main.loginUser(server: server, user: user) - } + func login(user: SwiftfinStore.State.User) { + self.isLoading = true + SessionManager.main.loginUser(server: server, user: user) + } - func remove(user: SwiftfinStore.State.User) { - SessionManager.main.delete(user: user) - fetchUsers() - } + func remove(user: SwiftfinStore.State.User) { + SessionManager.main.delete(user: user) + fetchUsers() + } } diff --git a/Shared/ViewModels/UserSignInViewModel.swift b/Shared/ViewModels/UserSignInViewModel.swift index 32e037b3..ce39f84d 100644 --- a/Shared/ViewModels/UserSignInViewModel.swift +++ b/Shared/ViewModels/UserSignInViewModel.swift @@ -13,68 +13,70 @@ import Stinsen final class UserSignInViewModel: ViewModel { - @RouterObject - var router: UserSignInCoordinator.Router? - let server: SwiftfinStore.State.Server + @RouterObject + var router: UserSignInCoordinator.Router? + let server: SwiftfinStore.State.Server - @Published - var publicUsers: [UserDto] = [] + @Published + var publicUsers: [UserDto] = [] - init(server: SwiftfinStore.State.Server) { - self.server = server - JellyfinAPIAPI.basePath = server.currentURI - } + init(server: SwiftfinStore.State.Server) { + self.server = server + JellyfinAPIAPI.basePath = server.currentURI + } - var alertTitle: String { - var message: String = "" - if errorMessage?.code != ErrorMessage.noShowErrorCode { - message.append(contentsOf: "\(errorMessage?.code ?? ErrorMessage.noShowErrorCode)\n") - } - message.append(contentsOf: "\(errorMessage?.title ?? L10n.unknownError)") - return message - } + var alertTitle: String { + var message: String = "" + if errorMessage?.code != ErrorMessage.noShowErrorCode { + message.append(contentsOf: "\(errorMessage?.code ?? ErrorMessage.noShowErrorCode)\n") + } + message.append(contentsOf: "\(errorMessage?.title ?? L10n.unknownError)") + return message + } - func login(username: String, password: String) { - LogManager.log.debug("Attempting to login to server at \"\(server.currentURI)\"", tag: "login") + func login(username: String, password: String) { + LogManager.log.debug("Attempting to login to server at \"\(server.currentURI)\"", tag: "login") - SessionManager.main.loginUser(server: server, username: username, password: password) - .trackActivity(loading) - .sink { completion in - self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion) - } receiveValue: { _ in - } - .store(in: &cancellables) - } + SessionManager.main.loginUser(server: server, username: username, password: password) + .trackActivity(loading) + .sink { completion in + self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion) + } receiveValue: { _ in + } + .store(in: &cancellables) + } - func cancelSignIn() { - for cancellable in cancellables { - cancellable.cancel() - } + func cancelSignIn() { + for cancellable in cancellables { + cancellable.cancel() + } - self.isLoading = false - } + self.isLoading = false + } - func loadUsers() { - UserAPI.getPublicUsers() - .trackActivity(loading) - .sink(receiveCompletion: { completion in - self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion) - }, receiveValue: { response in - self.publicUsers = response - }) - .store(in: &cancellables) - } + func loadUsers() { + UserAPI.getPublicUsers() + .trackActivity(loading) + .sink(receiveCompletion: { completion in + self.handleAPIRequestError(displayMessage: L10n.unableToConnectServer, completion: completion) + }, receiveValue: { response in + self.publicUsers = response + }) + .store(in: &cancellables) + } - func getProfileImageUrl(user: UserDto) -> URL? { - let urlString = ImageAPI.getUserImageWithRequestBuilder(userId: user.id ?? "--", - imageType: .primary, - width: 200, - quality: 90).URLString - return URL(string: urlString) - } + func getProfileImageUrl(user: UserDto) -> URL? { + let urlString = ImageAPI.getUserImageWithRequestBuilder( + userId: user.id ?? "--", + imageType: .primary, + width: 200, + quality: 90 + ).URLString + return URL(string: urlString) + } - func getSplashscreenUrl() -> URL? { - let urlString = ImageAPI.getSplashscreenWithRequestBuilder().URLString - return URL(string: urlString) - } + func getSplashscreenUrl() -> URL? { + let urlString = ImageAPI.getSplashscreenWithRequestBuilder().URLString + return URL(string: urlString) + } } diff --git a/Shared/ViewModels/VideoPlayerModel.swift b/Shared/ViewModels/VideoPlayerModel.swift index 0f28541d..553bcc31 100644 --- a/Shared/ViewModels/VideoPlayerModel.swift +++ b/Shared/ViewModels/VideoPlayerModel.swift @@ -10,23 +10,23 @@ import JellyfinAPI import SwiftUI struct Subtitle { - var name: String - var id: Int32 - var url: URL? - var delivery: SubtitleDeliveryMethod - var codec: String - var languageCode: String + var name: String + var id: Int32 + var url: URL? + var delivery: SubtitleDeliveryMethod + var codec: String + var languageCode: String } struct AudioTrack { - var name: String - var languageCode: String - var id: Int32 + var name: String + var languageCode: String + var id: Int32 } class PlaybackItem: ObservableObject { - @Published - var videoType: PlayMethod = .directPlay - @Published - var videoUrl = URL(string: "https://example.com")! + @Published + var videoType: PlayMethod = .directPlay + @Published + var videoUrl = URL(string: "https://example.com")! } diff --git a/Shared/ViewModels/VideoPlayerViewModel/ServerStreamType.swift b/Shared/ViewModels/VideoPlayerViewModel/ServerStreamType.swift index b3f4032f..182fdee2 100644 --- a/Shared/ViewModels/VideoPlayerViewModel/ServerStreamType.swift +++ b/Shared/ViewModels/VideoPlayerViewModel/ServerStreamType.swift @@ -9,6 +9,6 @@ import Foundation enum ServerStreamType { - case direct - case transcode + case direct + case transcode } diff --git a/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift index 69763874..99565b82 100644 --- a/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel/VideoPlayerViewModel.swift @@ -14,638 +14,651 @@ import JellyfinAPI import UIKit #if os(tvOS) - import TVVLCKit + import TVVLCKit #else - import MobileVLCKit + import MobileVLCKit #endif final class VideoPlayerViewModel: ViewModel { - // MARK: Published + // MARK: Published - // Manually kept state because VLCKit doesn't properly set "played" - // on the VLCMediaPlayer object - @Published - var playerState: VLCMediaPlayerState = .buffering - @Published - var leftLabelText: String = "--:--" - @Published - var rightLabelText: String = "--:--" - @Published - var scrubbingTimeLabelText: String = "--:--" - @Published - var playbackSpeed: PlaybackSpeed = .one - @Published - var subtitlesEnabled: Bool { - didSet { - if syncSubtitleStateWithAdjacent { - previousItemVideoPlayerViewModel?.matchSubtitlesEnabled(with: self) - nextItemVideoPlayerViewModel?.matchSubtitlesEnabled(with: self) - } - } - } + // Manually kept state because VLCKit doesn't properly set "played" + // on the VLCMediaPlayer object + @Published + var playerState: VLCMediaPlayerState = .buffering + @Published + var leftLabelText: String = "--:--" + @Published + var rightLabelText: String = "--:--" + @Published + var scrubbingTimeLabelText: String = "--:--" + @Published + var playbackSpeed: PlaybackSpeed = .one + @Published + var subtitlesEnabled: Bool { + didSet { + if syncSubtitleStateWithAdjacent { + previousItemVideoPlayerViewModel?.matchSubtitlesEnabled(with: self) + nextItemVideoPlayerViewModel?.matchSubtitlesEnabled(with: self) + } + } + } - @Published - var selectedAudioStreamIndex: Int - @Published - var selectedSubtitleStreamIndex: Int { - didSet { - if syncSubtitleStateWithAdjacent { - previousItemVideoPlayerViewModel?.matchSubtitleStream(with: self) - nextItemVideoPlayerViewModel?.matchSubtitleStream(with: self) - } - } - } + @Published + var selectedAudioStreamIndex: Int + @Published + var selectedSubtitleStreamIndex: Int { + didSet { + if syncSubtitleStateWithAdjacent { + previousItemVideoPlayerViewModel?.matchSubtitleStream(with: self) + nextItemVideoPlayerViewModel?.matchSubtitleStream(with: self) + } + } + } - @Published - var previousItemVideoPlayerViewModel: VideoPlayerViewModel? - @Published - var nextItemVideoPlayerViewModel: VideoPlayerViewModel? - @Published - var jumpBackwardLength: VideoPlayerJumpLength { - willSet { - Defaults[.videoPlayerJumpBackward] = newValue - } - } + @Published + var previousItemVideoPlayerViewModel: VideoPlayerViewModel? + @Published + var nextItemVideoPlayerViewModel: VideoPlayerViewModel? + @Published + var jumpBackwardLength: VideoPlayerJumpLength { + willSet { + Defaults[.videoPlayerJumpBackward] = newValue + } + } - @Published - var jumpForwardLength: VideoPlayerJumpLength { - willSet { - Defaults[.videoPlayerJumpForward] = newValue - } - } + @Published + var jumpForwardLength: VideoPlayerJumpLength { + willSet { + Defaults[.videoPlayerJumpForward] = newValue + } + } - @Published - var isHiddenCenterViews = false + @Published + var isHiddenCenterViews = false - @Published - var sliderIsScrubbing: Bool = false { - didSet { - isHiddenCenterViews = sliderIsScrubbing - beganScrubbingCurrentSeconds = currentSeconds - } - } + @Published + var sliderIsScrubbing: Bool = false { + didSet { + isHiddenCenterViews = sliderIsScrubbing + beganScrubbingCurrentSeconds = currentSeconds + } + } - @Published - var sliderPercentage: Double = 0 { - willSet { - sliderScrubbingSubject.send(self) - sliderPercentageChanged(newValue: newValue) - } - } + @Published + var sliderPercentage: Double = 0 { + willSet { + sliderScrubbingSubject.send(self) + sliderPercentageChanged(newValue: newValue) + } + } - @Published - var autoplayEnabled: Bool { - willSet { - previousItemVideoPlayerViewModel?.autoplayEnabled = newValue - nextItemVideoPlayerViewModel?.autoplayEnabled = newValue - Defaults[.autoplayEnabled] = newValue - } - } + @Published + var autoplayEnabled: Bool { + willSet { + previousItemVideoPlayerViewModel?.autoplayEnabled = newValue + nextItemVideoPlayerViewModel?.autoplayEnabled = newValue + Defaults[.autoplayEnabled] = newValue + } + } - @Published - var mediaItems: [BaseItemDto.ItemDetail] + @Published + var mediaItems: [BaseItemDto.ItemDetail] - @Published - var isHiddenOverlay = false + @Published + var isHiddenOverlay = false - // MARK: ShouldShowItems + // MARK: ShouldShowItems - let shouldShowPlayPreviousItem: Bool - let shouldShowPlayNextItem: Bool - let shouldShowAutoPlay: Bool - let shouldShowJumpButtonsInOverlayMenu: Bool + let shouldShowPlayPreviousItem: Bool + let shouldShowPlayNextItem: Bool + let shouldShowAutoPlay: Bool + let shouldShowJumpButtonsInOverlayMenu: Bool - // MARK: General + // MARK: General - private(set) var item: BaseItemDto - let title: String - let subtitle: String? - let directStreamURL: URL - let transcodedStreamURL: URL? - let hlsStreamURL: URL - let audioStreams: [MediaStream] - let subtitleStreams: [MediaStream] - let chapters: [ChapterInfo] - let overlayType: OverlayType - let jumpGesturesEnabled: Bool - let systemControlGesturesEnabled: Bool - let seekSlideGestureEnabled: Bool - let playerGesturesLockGestureEnabled: Bool - let shouldShowChaptersInfoInBottomOverlay: Bool - let resumeOffset: Bool - let streamType: ServerStreamType - let container: String - let filename: String? - let versionName: String? + private(set) var item: BaseItemDto + let title: String + let subtitle: String? + let directStreamURL: URL + let transcodedStreamURL: URL? + let hlsStreamURL: URL + let audioStreams: [MediaStream] + let subtitleStreams: [MediaStream] + let chapters: [ChapterInfo] + let overlayType: OverlayType + let jumpGesturesEnabled: Bool + let systemControlGesturesEnabled: Bool + let seekSlideGestureEnabled: Bool + let playerGesturesLockGestureEnabled: Bool + let shouldShowChaptersInfoInBottomOverlay: Bool + let resumeOffset: Bool + let streamType: ServerStreamType + let container: String + let filename: String? + let versionName: String? - // MARK: Experimental + // MARK: Experimental - let syncSubtitleStateWithAdjacent: Bool + let syncSubtitleStateWithAdjacent: Bool - // MARK: tvOS + // MARK: tvOS - let confirmClose: Bool + let confirmClose: Bool - // Full response kept for convenience - let response: PlaybackInfoResponse + // Full response kept for convenience + let response: PlaybackInfoResponse - var playerOverlayDelegate: PlayerOverlayDelegate? + var playerOverlayDelegate: PlayerOverlayDelegate? - // Ticks of the time the media began playing - private var startTimeTicks: Int64 = 0 + // Ticks of the time the media began playing + private var startTimeTicks: Int64 = 0 - // MARK: Current Time + // MARK: Current Time - private var beganScrubbingCurrentSeconds: Double = 0 + private var beganScrubbingCurrentSeconds: Double = 0 - var currentSeconds: Double { - let runTimeTicks = item.runTimeTicks ?? 0 - let videoDuration = Double(runTimeTicks / 10_000_000) - return round(sliderPercentage * videoDuration) - } + var currentSeconds: Double { + let runTimeTicks = item.runTimeTicks ?? 0 + let videoDuration = Double(runTimeTicks / 10_000_000) + return round(sliderPercentage * videoDuration) + } - var currentSecondTicks: Int64 { - Int64(currentSeconds) * 10_000_000 - } + var currentSecondTicks: Int64 { + Int64(currentSeconds) * 10_000_000 + } - func setSeconds(_ seconds: Int64) { - guard let runTimeTicks = item.runTimeTicks else { return } - let videoDuration = runTimeTicks - let percentage = Double(seconds * 10_000_000) / Double(videoDuration) + func setSeconds(_ seconds: Int64) { + guard let runTimeTicks = item.runTimeTicks else { return } + let videoDuration = runTimeTicks + let percentage = Double(seconds * 10_000_000) / Double(videoDuration) - sliderPercentage = percentage - } + sliderPercentage = percentage + } - // MARK: Helpers + // MARK: Helpers - var currentAudioStream: MediaStream? { - audioStreams.first(where: { $0.index == selectedAudioStreamIndex }) - } + var currentAudioStream: MediaStream? { + audioStreams.first(where: { $0.index == selectedAudioStreamIndex }) + } - var currentSubtitleStream: MediaStream? { - subtitleStreams.first(where: { $0.index == selectedSubtitleStreamIndex }) - } + var currentSubtitleStream: MediaStream? { + subtitleStreams.first(where: { $0.index == selectedSubtitleStreamIndex }) + } - var currentChapter: ChapterInfo? { - let chapterPairs = chapters.adjacentPairs().map { ($0, $1) } - let chapterRanges = chapterPairs.map { ($0.startPositionTicks ?? 0, ($1.startPositionTicks ?? 1) - 1) } + var currentChapter: ChapterInfo? { + let chapterPairs = chapters.adjacentPairs().map { ($0, $1) } + let chapterRanges = chapterPairs.map { ($0.startPositionTicks ?? 0, ($1.startPositionTicks ?? 1) - 1) } - for chapterRangeIndex in 0 ..< chapterRanges.count { - if chapterRanges[chapterRangeIndex].0 <= currentSecondTicks, - currentSecondTicks < chapterRanges[chapterRangeIndex].1 - { - return chapterPairs[chapterRangeIndex].0 - } - } + for chapterRangeIndex in 0 ..< chapterRanges.count { + if chapterRanges[chapterRangeIndex].0 <= currentSecondTicks, + currentSecondTicks < chapterRanges[chapterRangeIndex].1 + { + return chapterPairs[chapterRangeIndex].0 + } + } - return nil - } + return nil + } - // Necessary PassthroughSubject to capture manual scrubbing from sliders - let sliderScrubbingSubject = PassthroughSubject() + // Necessary PassthroughSubject to capture manual scrubbing from sliders + let sliderScrubbingSubject = PassthroughSubject() - // During scrubbing, many progress reports were spammed - // Send only the current report after a delay - private var progressReportTimer: Timer? - private var lastProgressReport: ReportPlaybackProgressRequest? + // During scrubbing, many progress reports were spammed + // Send only the current report after a delay + private var progressReportTimer: Timer? + private var lastProgressReport: ReportPlaybackProgressRequest? - // MARK: init + // MARK: init - init(item: BaseItemDto, - title: String, - subtitle: String?, - directStreamURL: URL, - transcodedStreamURL: URL?, - hlsStreamURL: URL, - streamType: ServerStreamType, - response: PlaybackInfoResponse, - audioStreams: [MediaStream], - subtitleStreams: [MediaStream], - chapters: [ChapterInfo], - selectedAudioStreamIndex: Int, - selectedSubtitleStreamIndex: Int, - subtitlesEnabled: Bool, - autoplayEnabled: Bool, - overlayType: OverlayType, - shouldShowPlayPreviousItem: Bool, - shouldShowPlayNextItem: Bool, - shouldShowAutoPlay: Bool, - container: String, - filename: String?, - versionName: String?) - { - self.item = item - self.title = title - self.subtitle = subtitle - self.directStreamURL = directStreamURL - self.transcodedStreamURL = transcodedStreamURL - self.hlsStreamURL = hlsStreamURL - self.streamType = streamType - self.response = response - self.audioStreams = audioStreams - self.subtitleStreams = subtitleStreams - self.chapters = chapters - self.selectedAudioStreamIndex = selectedAudioStreamIndex - self.selectedSubtitleStreamIndex = selectedSubtitleStreamIndex - self.subtitlesEnabled = subtitlesEnabled - self.autoplayEnabled = autoplayEnabled - self.overlayType = overlayType - self.shouldShowPlayPreviousItem = shouldShowPlayPreviousItem - self.shouldShowPlayNextItem = shouldShowPlayNextItem - self.shouldShowAutoPlay = shouldShowAutoPlay - self.container = container - self.filename = filename - self.versionName = versionName + init( + item: BaseItemDto, + title: String, + subtitle: String?, + directStreamURL: URL, + transcodedStreamURL: URL?, + hlsStreamURL: URL, + streamType: ServerStreamType, + response: PlaybackInfoResponse, + audioStreams: [MediaStream], + subtitleStreams: [MediaStream], + chapters: [ChapterInfo], + selectedAudioStreamIndex: Int, + selectedSubtitleStreamIndex: Int, + subtitlesEnabled: Bool, + autoplayEnabled: Bool, + overlayType: OverlayType, + shouldShowPlayPreviousItem: Bool, + shouldShowPlayNextItem: Bool, + shouldShowAutoPlay: Bool, + container: String, + filename: String?, + versionName: String? + ) { + self.item = item + self.title = title + self.subtitle = subtitle + self.directStreamURL = directStreamURL + self.transcodedStreamURL = transcodedStreamURL + self.hlsStreamURL = hlsStreamURL + self.streamType = streamType + self.response = response + self.audioStreams = audioStreams + self.subtitleStreams = subtitleStreams + self.chapters = chapters + self.selectedAudioStreamIndex = selectedAudioStreamIndex + self.selectedSubtitleStreamIndex = selectedSubtitleStreamIndex + self.subtitlesEnabled = subtitlesEnabled + self.autoplayEnabled = autoplayEnabled + self.overlayType = overlayType + self.shouldShowPlayPreviousItem = shouldShowPlayPreviousItem + self.shouldShowPlayNextItem = shouldShowPlayNextItem + self.shouldShowAutoPlay = shouldShowAutoPlay + self.container = container + self.filename = filename + self.versionName = versionName - self.jumpBackwardLength = Defaults[.videoPlayerJumpBackward] - self.jumpForwardLength = Defaults[.videoPlayerJumpForward] - self.jumpGesturesEnabled = Defaults[.jumpGesturesEnabled] - self.systemControlGesturesEnabled = Defaults[.systemControlGesturesEnabled] - self.playerGesturesLockGestureEnabled = Defaults[.playerGesturesLockGestureEnabled] - self.seekSlideGestureEnabled = Defaults[.seekSlideGestureEnabled] - self.shouldShowJumpButtonsInOverlayMenu = Defaults[.shouldShowJumpButtonsInOverlayMenu] - self.shouldShowChaptersInfoInBottomOverlay = Defaults[.shouldShowChaptersInfoInBottomOverlay] + self.jumpBackwardLength = Defaults[.videoPlayerJumpBackward] + self.jumpForwardLength = Defaults[.videoPlayerJumpForward] + self.jumpGesturesEnabled = Defaults[.jumpGesturesEnabled] + self.systemControlGesturesEnabled = Defaults[.systemControlGesturesEnabled] + self.playerGesturesLockGestureEnabled = Defaults[.playerGesturesLockGestureEnabled] + self.seekSlideGestureEnabled = Defaults[.seekSlideGestureEnabled] + self.shouldShowJumpButtonsInOverlayMenu = Defaults[.shouldShowJumpButtonsInOverlayMenu] + self.shouldShowChaptersInfoInBottomOverlay = Defaults[.shouldShowChaptersInfoInBottomOverlay] - self.resumeOffset = Defaults[.resumeOffset] + self.resumeOffset = Defaults[.resumeOffset] - self.syncSubtitleStateWithAdjacent = Defaults[.Experimental.syncSubtitleStateWithAdjacent] + self.syncSubtitleStateWithAdjacent = Defaults[.Experimental.syncSubtitleStateWithAdjacent] - self.confirmClose = Defaults[.confirmClose] + self.confirmClose = Defaults[.confirmClose] - self.mediaItems = item.createMediaItems() + self.mediaItems = item.createMediaItems() - super.init() + super.init() - self.sliderPercentage = (item.userData?.playedPercentage ?? 0) / 100 - } + self.sliderPercentage = (item.userData?.playedPercentage ?? 0) / 100 + } - private func sliderPercentageChanged(newValue: Double) { - let runTimeTicks = item.runTimeTicks ?? 0 - let videoDuration = Double(runTimeTicks / 10_000_000) - let secondsScrubbedRemaining = videoDuration - currentSeconds + private func sliderPercentageChanged(newValue: Double) { + let runTimeTicks = item.runTimeTicks ?? 0 + let videoDuration = Double(runTimeTicks / 10_000_000) + let secondsScrubbedRemaining = videoDuration - currentSeconds - leftLabelText = calculateTimeText(from: currentSeconds) - rightLabelText = calculateTimeText(from: secondsScrubbedRemaining) - scrubbingTimeLabelText = calculateTimeText(from: currentSeconds - beganScrubbingCurrentSeconds, isScrubbing: true) - } + leftLabelText = calculateTimeText(from: currentSeconds) + rightLabelText = calculateTimeText(from: secondsScrubbedRemaining) + scrubbingTimeLabelText = calculateTimeText(from: currentSeconds - beganScrubbingCurrentSeconds, isScrubbing: true) + } - private func calculateTimeText(from duration: Double, isScrubbing: Bool = false) -> String { - let isNegative = duration < 0 - let duration = abs(duration) - let hours = floor(duration / 3600) - let minutes = duration.truncatingRemainder(dividingBy: 3600) / 60 - let seconds = duration.truncatingRemainder(dividingBy: 3600).truncatingRemainder(dividingBy: 60) + private func calculateTimeText(from duration: Double, isScrubbing: Bool = false) -> String { + let isNegative = duration < 0 + let duration = abs(duration) + let hours = floor(duration / 3600) + let minutes = duration.truncatingRemainder(dividingBy: 3600) / 60 + let seconds = duration.truncatingRemainder(dividingBy: 3600).truncatingRemainder(dividingBy: 60) - let timeText: String + let timeText: String - if hours != 0 { - timeText = - "\(Int(hours)):\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" - } else { - timeText = - "\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" - } + if hours != 0 { + timeText = + "\(Int(hours)):\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" + } else { + timeText = + "\(String(Int(floor(minutes))).leftPad(toWidth: 2, withString: "0")):\(String(Int(floor(seconds))).leftPad(toWidth: 2, withString: "0"))" + } - if isScrubbing { - return "\(isNegative ? "-" : "+") \(timeText)" - } else { - return "\(isNegative ? "-" : "") \(timeText)" - } - } + if isScrubbing { + return "\(isNegative ? "-" : "+") \(timeText)" + } else { + return "\(isNegative ? "-" : "") \(timeText)" + } + } } // MARK: Injected Values extension VideoPlayerViewModel { - // Injects custom values that override certain settings - func injectCustomValues(startFromBeginning: Bool = false) { - if startFromBeginning { - item.userData?.playbackPositionTicks = 0 - item.userData?.playedPercentage = 0 - sliderPercentage = 0 - sliderPercentageChanged(newValue: 0) - } - } + // Injects custom values that override certain settings + func injectCustomValues(startFromBeginning: Bool = false) { + if startFromBeginning { + item.userData?.playbackPositionTicks = 0 + item.userData?.playedPercentage = 0 + sliderPercentage = 0 + sliderPercentageChanged(newValue: 0) + } + } } // MARK: Adjacent Items extension VideoPlayerViewModel { - func getAdjacentEpisodes() { - guard let seriesID = item.seriesId, item.itemType == .episode else { return } + func getAdjacentEpisodes() { + guard let seriesID = item.seriesId, item.itemType == .episode else { return } - TvShowsAPI.getEpisodes(seriesId: seriesID, - userId: SessionManager.main.currentLogin.user.id, - fields: [.chapters], - adjacentTo: item.id, - limit: 3) - .sink(receiveCompletion: { completion in - self.handleAPIRequestError(completion: completion) - }, receiveValue: { response in + TvShowsAPI.getEpisodes( + seriesId: seriesID, + userId: SessionManager.main.currentLogin.user.id, + fields: [.chapters], + adjacentTo: item.id, + limit: 3 + ) + .sink(receiveCompletion: { completion in + self.handleAPIRequestError(completion: completion) + }, receiveValue: { response in - // 4 possible states: - // 1 - only current episode - // 2 - two episodes with next episode - // 3 - two episodes with previous episode - // 4 - three episodes with current in middle + // 4 possible states: + // 1 - only current episode + // 2 - two episodes with next episode + // 3 - two episodes with previous episode + // 4 - three episodes with current in middle - // State 1 - guard let items = response.items, items.count > 1 else { return } + // State 1 + guard let items = response.items, items.count > 1 else { return } - if items.count == 2 { - if items[0].id == self.item.id { - // State 2 - let nextItem = items[1] + if items.count == 2 { + if items[0].id == self.item.id { + // State 2 + let nextItem = items[1] - nextItem.createVideoPlayerViewModel() - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { viewModels in - for viewModel in viewModels { - viewModel.matchSubtitleStream(with: self) - viewModel.matchAudioStream(with: self) - } + nextItem.createVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { viewModels in + for viewModel in viewModels { + viewModel.matchSubtitleStream(with: self) + viewModel.matchAudioStream(with: self) + } - self.nextItemVideoPlayerViewModel = viewModels.first - } - .store(in: &self.cancellables) - } else { - // State 3 - let previousItem = items[0] + self.nextItemVideoPlayerViewModel = viewModels.first + } + .store(in: &self.cancellables) + } else { + // State 3 + let previousItem = items[0] - previousItem.createVideoPlayerViewModel() - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { viewModels in - for viewModel in viewModels { - viewModel.matchSubtitleStream(with: self) - viewModel.matchAudioStream(with: self) - } + previousItem.createVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { viewModels in + for viewModel in viewModels { + viewModel.matchSubtitleStream(with: self) + viewModel.matchAudioStream(with: self) + } - self.previousItemVideoPlayerViewModel = viewModels.first - } - .store(in: &self.cancellables) - } - } else { - // State 4 + self.previousItemVideoPlayerViewModel = viewModels.first + } + .store(in: &self.cancellables) + } + } else { + // State 4 - let previousItem = items[0] - let nextItem = items[2] + let previousItem = items[0] + let nextItem = items[2] - previousItem.createVideoPlayerViewModel() - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { viewModels in - for viewModel in viewModels { - viewModel.matchSubtitleStream(with: self) - viewModel.matchAudioStream(with: self) - } + previousItem.createVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { viewModels in + for viewModel in viewModels { + viewModel.matchSubtitleStream(with: self) + viewModel.matchAudioStream(with: self) + } - self.previousItemVideoPlayerViewModel = viewModels.first - } - .store(in: &self.cancellables) + self.previousItemVideoPlayerViewModel = viewModels.first + } + .store(in: &self.cancellables) - nextItem.createVideoPlayerViewModel() - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { viewModels in - for viewModel in viewModels { - viewModel.matchSubtitleStream(with: self) - viewModel.matchAudioStream(with: self) - } + nextItem.createVideoPlayerViewModel() + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { viewModels in + for viewModel in viewModels { + viewModel.matchSubtitleStream(with: self) + viewModel.matchAudioStream(with: self) + } - self.nextItemVideoPlayerViewModel = viewModels.first - } - .store(in: &self.cancellables) - } - }) - .store(in: &cancellables) - } + self.nextItemVideoPlayerViewModel = viewModels.first + } + .store(in: &self.cancellables) + } + }) + .store(in: &cancellables) + } - // Potential for experimental feature of syncing subtitle states among adjacent episodes - // when using previous & next item buttons and auto-play + // Potential for experimental feature of syncing subtitle states among adjacent episodes + // when using previous & next item buttons and auto-play - private func matchSubtitleStream(with masterViewModel: VideoPlayerViewModel) { - if !masterViewModel.subtitlesEnabled { - matchSubtitlesEnabled(with: masterViewModel) - } + private func matchSubtitleStream(with masterViewModel: VideoPlayerViewModel) { + if !masterViewModel.subtitlesEnabled { + matchSubtitlesEnabled(with: masterViewModel) + } - guard let masterSubtitleStream = masterViewModel.subtitleStreams - .first(where: { $0.index == masterViewModel.selectedSubtitleStreamIndex }), - let matchingSubtitleStream = subtitleStreams.first(where: { mediaStreamAboutEqual($0, masterSubtitleStream) }), - let matchingSubtitleStreamIndex = matchingSubtitleStream.index else { return } + guard let masterSubtitleStream = masterViewModel.subtitleStreams + .first(where: { $0.index == masterViewModel.selectedSubtitleStreamIndex }), + let matchingSubtitleStream = subtitleStreams.first(where: { mediaStreamAboutEqual($0, masterSubtitleStream) }), + let matchingSubtitleStreamIndex = matchingSubtitleStream.index else { return } - selectedSubtitleStreamIndex = matchingSubtitleStreamIndex - } + selectedSubtitleStreamIndex = matchingSubtitleStreamIndex + } - private func matchAudioStream(with masterViewModel: VideoPlayerViewModel) { - guard let currentAudioStream = masterViewModel.audioStreams.first(where: { $0.index == masterViewModel.selectedAudioStreamIndex }), - let matchingAudioStream = audioStreams.first(where: { mediaStreamAboutEqual($0, currentAudioStream) }) else { return } + private func matchAudioStream(with masterViewModel: VideoPlayerViewModel) { + guard let currentAudioStream = masterViewModel.audioStreams.first(where: { $0.index == masterViewModel.selectedAudioStreamIndex }), + let matchingAudioStream = audioStreams.first(where: { mediaStreamAboutEqual($0, currentAudioStream) }) else { return } - selectedAudioStreamIndex = matchingAudioStream.index ?? -1 - } + selectedAudioStreamIndex = matchingAudioStream.index ?? -1 + } - private func matchSubtitlesEnabled(with masterViewModel: VideoPlayerViewModel) { - subtitlesEnabled = masterViewModel.subtitlesEnabled - } + private func matchSubtitlesEnabled(with masterViewModel: VideoPlayerViewModel) { + subtitlesEnabled = masterViewModel.subtitlesEnabled + } - private func mediaStreamAboutEqual(_ lhs: MediaStream, _ rhs: MediaStream) -> Bool { - lhs.displayTitle == rhs.displayTitle && lhs.language == rhs.language - } + private func mediaStreamAboutEqual(_ lhs: MediaStream, _ rhs: MediaStream) -> Bool { + lhs.displayTitle == rhs.displayTitle && lhs.language == rhs.language + } } // MARK: Progress Report Timer extension VideoPlayerViewModel { - private func sendNewProgressReportWithTimer() { - progressReportTimer?.invalidate() - progressReportTimer = Timer.scheduledTimer(timeInterval: 0.7, - target: self, - selector: #selector(_sendProgressReport), - userInfo: nil, - repeats: false) - } + private func sendNewProgressReportWithTimer() { + progressReportTimer?.invalidate() + progressReportTimer = Timer.scheduledTimer( + timeInterval: 0.7, + target: self, + selector: #selector(_sendProgressReport), + userInfo: nil, + repeats: false + ) + } } // MARK: Updates extension VideoPlayerViewModel { - // MARK: sendPlayReport + // MARK: sendPlayReport - func sendPlayReport() { - startTimeTicks = Int64(Date().timeIntervalSince1970) * 10_000_000 + func sendPlayReport() { + startTimeTicks = Int64(Date().timeIntervalSince1970) * 10_000_000 - let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil + let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil - let reportPlaybackStartRequest = ReportPlaybackStartRequest(canSeek: true, - itemId: item.id, - sessionId: response.playSessionId, - mediaSourceId: item.id, - audioStreamIndex: selectedAudioStreamIndex, - subtitleStreamIndex: subtitleStreamIndex, - isPaused: false, - isMuted: false, - positionTicks: item.userData?.playbackPositionTicks, - playbackStartTimeTicks: startTimeTicks, - volumeLevel: 100, - brightness: 100, - aspectRatio: nil, - playMethod: .directPlay, - liveStreamId: nil, - playSessionId: response.playSessionId, - repeatMode: .repeatNone, - nowPlayingQueue: nil, - playlistItemId: "playlistItem0") + let reportPlaybackStartRequest = ReportPlaybackStartRequest( + canSeek: true, + itemId: item.id, + sessionId: response.playSessionId, + mediaSourceId: item.id, + audioStreamIndex: selectedAudioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + isPaused: false, + isMuted: false, + positionTicks: item.userData?.playbackPositionTicks, + playbackStartTimeTicks: startTimeTicks, + volumeLevel: 100, + brightness: 100, + aspectRatio: nil, + playMethod: .directPlay, + liveStreamId: nil, + playSessionId: response.playSessionId, + repeatMode: .repeatNone, + nowPlayingQueue: nil, + playlistItemId: "playlistItem0" + ) - PlaystateAPI.reportPlaybackStart(reportPlaybackStartRequest: reportPlaybackStartRequest) - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { _ in - LogManager.log.debug("Start report sent for item: \(self.item.id ?? "No ID")") - } - .store(in: &cancellables) - } + PlaystateAPI.reportPlaybackStart(reportPlaybackStartRequest: reportPlaybackStartRequest) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { _ in + LogManager.log.debug("Start report sent for item: \(self.item.id ?? "No ID")") + } + .store(in: &cancellables) + } - // MARK: sendPauseReport + // MARK: sendPauseReport - func sendPauseReport(paused: Bool) { - let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil + func sendPauseReport(paused: Bool) { + let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil - let reportPlaybackStartRequest = ReportPlaybackStartRequest(canSeek: true, - itemId: item.id, - sessionId: response.playSessionId, - mediaSourceId: item.id, - audioStreamIndex: selectedAudioStreamIndex, - subtitleStreamIndex: subtitleStreamIndex, - isPaused: paused, - isMuted: false, - positionTicks: currentSecondTicks, - playbackStartTimeTicks: startTimeTicks, - volumeLevel: 100, - brightness: 100, - aspectRatio: nil, - playMethod: .directPlay, - liveStreamId: nil, - playSessionId: response.playSessionId, - repeatMode: .repeatNone, - nowPlayingQueue: nil, - playlistItemId: "playlistItem0") + let reportPlaybackStartRequest = ReportPlaybackStartRequest( + canSeek: true, + itemId: item.id, + sessionId: response.playSessionId, + mediaSourceId: item.id, + audioStreamIndex: selectedAudioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + isPaused: paused, + isMuted: false, + positionTicks: currentSecondTicks, + playbackStartTimeTicks: startTimeTicks, + volumeLevel: 100, + brightness: 100, + aspectRatio: nil, + playMethod: .directPlay, + liveStreamId: nil, + playSessionId: response.playSessionId, + repeatMode: .repeatNone, + nowPlayingQueue: nil, + playlistItemId: "playlistItem0" + ) - PlaystateAPI.reportPlaybackStart(reportPlaybackStartRequest: reportPlaybackStartRequest) - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { _ in - LogManager.log.debug("Pause report sent for item: \(self.item.id ?? "No ID")") - } - .store(in: &cancellables) - } + PlaystateAPI.reportPlaybackStart(reportPlaybackStartRequest: reportPlaybackStartRequest) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { _ in + LogManager.log.debug("Pause report sent for item: \(self.item.id ?? "No ID")") + } + .store(in: &cancellables) + } - // MARK: sendProgressReport + // MARK: sendProgressReport - func sendProgressReport() { - let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil + func sendProgressReport() { + let subtitleStreamIndex = subtitlesEnabled ? selectedSubtitleStreamIndex : nil - let progressInfo = ReportPlaybackProgressRequest(canSeek: true, - itemId: item.id, - sessionId: response.playSessionId, - mediaSourceId: item.id, - audioStreamIndex: selectedAudioStreamIndex, - subtitleStreamIndex: subtitleStreamIndex, - isPaused: false, - isMuted: false, - positionTicks: currentSecondTicks, - playbackStartTimeTicks: startTimeTicks, - volumeLevel: nil, - brightness: nil, - aspectRatio: nil, - playMethod: .directPlay, - liveStreamId: nil, - playSessionId: response.playSessionId, - repeatMode: .repeatNone, - nowPlayingQueue: nil, - playlistItemId: "playlistItem0") + let progressInfo = ReportPlaybackProgressRequest( + canSeek: true, + itemId: item.id, + sessionId: response.playSessionId, + mediaSourceId: item.id, + audioStreamIndex: selectedAudioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + isPaused: false, + isMuted: false, + positionTicks: currentSecondTicks, + playbackStartTimeTicks: startTimeTicks, + volumeLevel: nil, + brightness: nil, + aspectRatio: nil, + playMethod: .directPlay, + liveStreamId: nil, + playSessionId: response.playSessionId, + repeatMode: .repeatNone, + nowPlayingQueue: nil, + playlistItemId: "playlistItem0" + ) - lastProgressReport = progressInfo + lastProgressReport = progressInfo - sendNewProgressReportWithTimer() - } + sendNewProgressReportWithTimer() + } - @objc - private func _sendProgressReport() { - guard let lastProgressReport = lastProgressReport else { return } + @objc + private func _sendProgressReport() { + guard let lastProgressReport = lastProgressReport else { return } - PlaystateAPI.reportPlaybackProgress(reportPlaybackProgressRequest: lastProgressReport) - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { _ in - LogManager.log.debug("Playback progress sent for item: \(self.item.id ?? "No ID")") - } - .store(in: &cancellables) + PlaystateAPI.reportPlaybackProgress(reportPlaybackProgressRequest: lastProgressReport) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { _ in + LogManager.log.debug("Playback progress sent for item: \(self.item.id ?? "No ID")") + } + .store(in: &cancellables) - self.lastProgressReport = nil - } + self.lastProgressReport = nil + } - // MARK: sendStopReport + // MARK: sendStopReport - func sendStopReport() { - let reportPlaybackStoppedRequest = ReportPlaybackStoppedRequest(itemId: item.id, - sessionId: response.playSessionId, - mediaSourceId: item.id, - positionTicks: currentSecondTicks, - liveStreamId: nil, - playSessionId: response.playSessionId, - failed: nil, - nextMediaType: nil, - playlistItemId: "playlistItem0", - nowPlayingQueue: nil) + func sendStopReport() { + let reportPlaybackStoppedRequest = ReportPlaybackStoppedRequest( + itemId: item.id, + sessionId: response.playSessionId, + mediaSourceId: item.id, + positionTicks: currentSecondTicks, + liveStreamId: nil, + playSessionId: response.playSessionId, + failed: nil, + nextMediaType: nil, + playlistItemId: "playlistItem0", + nowPlayingQueue: nil + ) - PlaystateAPI.reportPlaybackStopped(reportPlaybackStoppedRequest: reportPlaybackStoppedRequest) - .sink { completion in - self.handleAPIRequestError(completion: completion) - } receiveValue: { _ in - LogManager.log.debug("Stop report sent for item: \(self.item.id ?? "No ID")") - Notifications[.didSendStopReport].post(object: self.item.id) - } - .store(in: &cancellables) - } + PlaystateAPI.reportPlaybackStopped(reportPlaybackStoppedRequest: reportPlaybackStoppedRequest) + .sink { completion in + self.handleAPIRequestError(completion: completion) + } receiveValue: { _ in + LogManager.log.debug("Stop report sent for item: \(self.item.id ?? "No ID")") + Notifications[.didSendStopReport].post(object: self.item.id) + } + .store(in: &cancellables) + } } // MARK: Embedded/Normal Subtitle Streams extension VideoPlayerViewModel { - func createEmbeddedSubtitleStream(with subtitleStream: MediaStream) -> URL { - guard let baseURL = URLComponents(url: directStreamURL, resolvingAgainstBaseURL: false) else { fatalError() } - guard let queryItems = baseURL.queryItems else { fatalError() } + func createEmbeddedSubtitleStream(with subtitleStream: MediaStream) -> URL { + guard let baseURL = URLComponents(url: directStreamURL, resolvingAgainstBaseURL: false) else { fatalError() } + guard let queryItems = baseURL.queryItems else { fatalError() } - var newURL = baseURL - var newQueryItems = queryItems + var newURL = baseURL + var newQueryItems = queryItems - newQueryItems.removeAll(where: { $0.name == "SubtitleStreamIndex" }) - newQueryItems.removeAll(where: { $0.name == "SubtitleMethod" }) + newQueryItems.removeAll(where: { $0.name == "SubtitleStreamIndex" }) + newQueryItems.removeAll(where: { $0.name == "SubtitleMethod" }) - newURL.addQueryItem(name: "SubtitleMethod", value: "Encode") - newURL.addQueryItem(name: "SubtitleStreamIndex", value: "\(subtitleStream.index ?? -1)") + newURL.addQueryItem(name: "SubtitleMethod", value: "Encode") + newURL.addQueryItem(name: "SubtitleStreamIndex", value: "\(subtitleStream.index ?? -1)") - return newURL.url! - } + return newURL.url! + } } // MARK: Equatable extension VideoPlayerViewModel: Equatable { - static func == (lhs: VideoPlayerViewModel, rhs: VideoPlayerViewModel) -> Bool { - lhs.item.id == rhs.item.id && - lhs.item.userData?.playbackPositionTicks == rhs.item.userData?.playbackPositionTicks - } + static func == (lhs: VideoPlayerViewModel, rhs: VideoPlayerViewModel) -> Bool { + lhs.item.id == rhs.item.id && + lhs.item.userData?.playbackPositionTicks == rhs.item.userData?.playbackPositionTicks + } } // MARK: Hashable extension VideoPlayerViewModel: Hashable { - func hash(into hasher: inout Hasher) { - hasher.combine(item) - hasher.combine(directStreamURL) - hasher.combine(filename) - hasher.combine(versionName) - } + func hash(into hasher: inout Hasher) { + hasher.combine(item) + hasher.combine(directStreamURL) + hasher.combine(filename) + hasher.combine(versionName) + } } diff --git a/Shared/ViewModels/ViewModel.swift b/Shared/ViewModels/ViewModel.swift index f96488cd..ca634b16 100644 --- a/Shared/ViewModels/ViewModel.swift +++ b/Shared/ViewModels/ViewModel.swift @@ -13,62 +13,70 @@ import JellyfinAPI class ViewModel: ObservableObject { - @Published - var isLoading = false - @Published - var errorMessage: ErrorMessage? + @Published + var isLoading = false + @Published + var errorMessage: ErrorMessage? - let loading = ActivityIndicator() - var cancellables = Set() + let loading = ActivityIndicator() + var cancellables = Set() - init() { - loading.loading.assign(to: \.isLoading, on: self).store(in: &cancellables) - } + init() { + loading.loading.assign(to: \.isLoading, on: self).store(in: &cancellables) + } - func handleAPIRequestError(displayMessage: String? = nil, completion: Subscribers.Completion) { - switch completion { - case .finished: - self.errorMessage = nil - case let .failure(error): - switch error { - case is ErrorResponse: - let networkError: NetworkError - let errorResponse = error as! ErrorResponse + func handleAPIRequestError(displayMessage: String? = nil, completion: Subscribers.Completion) { + switch completion { + case .finished: + self.errorMessage = nil + case let .failure(error): + switch error { + case is ErrorResponse: + let networkError: NetworkError + let errorResponse = error as! ErrorResponse - switch errorResponse { - case .error(-1, _, _, _): - networkError = .URLError(response: errorResponse, displayMessage: displayMessage) - // Use the errorResponse description for debugging, rather than the user-facing friendly description which may not be implemented - LogManager.log - .error("Request failed: URL request failed with error \(networkError.errorMessage.code): \(errorResponse.localizedDescription)") - case .error(-2, _, _, _): - networkError = .HTTPURLError(response: errorResponse, displayMessage: displayMessage) - LogManager.log - .error("Request failed: HTTP URL request failed with description: \(errorResponse.localizedDescription)") - default: - networkError = .JellyfinError(response: errorResponse, displayMessage: displayMessage) - // Able to use user-facing friendly description here since just HTTP status codes - LogManager.log - .error("Request failed: \(networkError.errorMessage.code) - \(networkError.errorMessage.title): \(networkError.errorMessage.message)\n\(error.localizedDescription)") - } + switch errorResponse { + case .error(-1, _, _, _): + networkError = .URLError(response: errorResponse, displayMessage: displayMessage) + // Use the errorResponse description for debugging, rather than the user-facing friendly description which may not be implemented + LogManager.log + .error( + "Request failed: URL request failed with error \(networkError.errorMessage.code): \(errorResponse.localizedDescription)" + ) + case .error(-2, _, _, _): + networkError = .HTTPURLError(response: errorResponse, displayMessage: displayMessage) + LogManager.log + .error("Request failed: HTTP URL request failed with description: \(errorResponse.localizedDescription)") + default: + networkError = .JellyfinError(response: errorResponse, displayMessage: displayMessage) + // Able to use user-facing friendly description here since just HTTP status codes + LogManager.log + .error( + "Request failed: \(networkError.errorMessage.code) - \(networkError.errorMessage.title): \(networkError.errorMessage.message)\n\(error.localizedDescription)" + ) + } - self.errorMessage = networkError.errorMessage + self.errorMessage = networkError.errorMessage - case is SwiftfinStore.Error: - let swiftfinError = error as! SwiftfinStore.Error - let errorMessage = ErrorMessage(code: ErrorMessage.noShowErrorCode, - title: swiftfinError.title, - message: swiftfinError.errorDescription ?? "") - self.errorMessage = errorMessage - LogManager.log.error("Request failed: \(swiftfinError.errorDescription ?? "")") + case is SwiftfinStore.Error: + let swiftfinError = error as! SwiftfinStore.Error + let errorMessage = ErrorMessage( + code: ErrorMessage.noShowErrorCode, + title: swiftfinError.title, + message: swiftfinError.errorDescription ?? "" + ) + self.errorMessage = errorMessage + LogManager.log.error("Request failed: \(swiftfinError.errorDescription ?? "")") - default: - let genericErrorMessage = ErrorMessage(code: ErrorMessage.noShowErrorCode, - title: "Generic Error", - message: error.localizedDescription) - self.errorMessage = genericErrorMessage - LogManager.log.error("Request failed: Generic error - \(error.localizedDescription)") - } - } - } + default: + let genericErrorMessage = ErrorMessage( + code: ErrorMessage.noShowErrorCode, + title: "Generic Error", + message: error.localizedDescription + ) + self.errorMessage = genericErrorMessage + LogManager.log.error("Request failed: Generic error - \(error.localizedDescription)") + } + } + } } diff --git a/Shared/Views/BlurHashView.swift b/Shared/Views/BlurHashView.swift index c5f608d6..65e94ca6 100644 --- a/Shared/Views/BlurHashView.swift +++ b/Shared/Views/BlurHashView.swift @@ -11,53 +11,53 @@ import UIKit struct BlurHashView: UIViewRepresentable { - let blurHash: String + let blurHash: String - func makeUIView(context: Context) -> UIBlurHashView { - UIBlurHashView(blurHash) - } + func makeUIView(context: Context) -> UIBlurHashView { + UIBlurHashView(blurHash) + } - func updateUIView(_ uiView: UIBlurHashView, context: Context) {} + func updateUIView(_ uiView: UIBlurHashView, context: Context) {} } class UIBlurHashView: UIView { - private let imageView: UIImageView + private let imageView: UIImageView - init(_ blurHash: String) { - let imageView = UIImageView() - imageView.translatesAutoresizingMaskIntoConstraints = false - self.imageView = imageView + init(_ blurHash: String) { + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + self.imageView = imageView - super.init(frame: .zero) + super.init(frame: .zero) - computeBlurHashImageAsync(blurHash: blurHash) { [weak self] blurImage in - guard let self = self else { return } - DispatchQueue.main.async { - self.imageView.image = blurImage - self.imageView.setNeedsDisplay() - } - } + computeBlurHashImageAsync(blurHash: blurHash) { [weak self] blurImage in + guard let self = self else { return } + DispatchQueue.main.async { + self.imageView.image = blurImage + self.imageView.setNeedsDisplay() + } + } - addSubview(imageView) + addSubview(imageView) - NSLayoutConstraint.activate([ - imageView.topAnchor.constraint(equalTo: topAnchor), - imageView.bottomAnchor.constraint(equalTo: bottomAnchor), - imageView.leftAnchor.constraint(equalTo: leftAnchor), - imageView.rightAnchor.constraint(equalTo: rightAnchor), - ]) - } + NSLayoutConstraint.activate([ + imageView.topAnchor.constraint(equalTo: topAnchor), + imageView.bottomAnchor.constraint(equalTo: bottomAnchor), + imageView.leftAnchor.constraint(equalTo: leftAnchor), + imageView.rightAnchor.constraint(equalTo: rightAnchor), + ]) + } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } - private func computeBlurHashImageAsync(blurHash: String, _ completion: @escaping (UIImage?) -> Void) { - DispatchQueue.global(qos: .utility).async { - let image = UIImage(blurHash: blurHash, size: .Circle(radius: 12)) - completion(image) - } - } + private func computeBlurHashImageAsync(blurHash: String, _ completion: @escaping (UIImage?) -> Void) { + DispatchQueue.global(qos: .utility).async { + let image = UIImage(blurHash: blurHash, size: .Circle(radius: 12)) + completion(image) + } + } } diff --git a/Shared/Views/ImageView.swift b/Shared/Views/ImageView.swift index c932cd97..31c98fc5 100644 --- a/Shared/Views/ImageView.swift +++ b/Shared/Views/ImageView.swift @@ -14,92 +14,92 @@ import UIKit // TODO: Fix 100+ inits struct ImageViewSource { - let url: URL? - let blurHash: String? + let url: URL? + let blurHash: String? - init(url: URL? = nil, blurHash: String? = nil) { - self.url = url - self.blurHash = blurHash - } + init(url: URL? = nil, blurHash: String? = nil) { + self.url = url + self.blurHash = blurHash + } } struct DefaultFailureView: View { - var body: some View { - Color.secondary - } + var body: some View { + Color.secondary + } } struct ImageView: View { - @State - private var sources: [ImageViewSource] - private var currentURL: URL? { sources.first?.url } - private var currentBlurHash: String? { sources.first?.blurHash } - private var failureView: FailureView + @State + private var sources: [ImageViewSource] + private var currentURL: URL? { sources.first?.url } + private var currentBlurHash: String? { sources.first?.blurHash } + private var failureView: FailureView - init(_ source: URL?, blurHash: String? = nil, @ViewBuilder failureView: () -> FailureView) { - let imageViewSource = ImageViewSource(url: source, blurHash: blurHash) - _sources = State(initialValue: [imageViewSource]) - self.failureView = failureView() - } + init(_ source: URL?, blurHash: String? = nil, @ViewBuilder failureView: () -> FailureView) { + let imageViewSource = ImageViewSource(url: source, blurHash: blurHash) + _sources = State(initialValue: [imageViewSource]) + self.failureView = failureView() + } - init(_ source: ImageViewSource, @ViewBuilder failureView: () -> FailureView) { - _sources = State(initialValue: [source]) - self.failureView = failureView() - } + init(_ source: ImageViewSource, @ViewBuilder failureView: () -> FailureView) { + _sources = State(initialValue: [source]) + self.failureView = failureView() + } - init(_ sources: [ImageViewSource], @ViewBuilder failureView: () -> FailureView) { - _sources = State(initialValue: sources) - self.failureView = failureView() - } + init(_ sources: [ImageViewSource], @ViewBuilder failureView: () -> FailureView) { + _sources = State(initialValue: sources) + self.failureView = failureView() + } - @ViewBuilder - private var placeholderView: some View { - if let currentBlurHash = currentBlurHash { - BlurHashView(blurHash: currentBlurHash) - .id(currentBlurHash) - } else { - Color.secondary - } - } + @ViewBuilder + private var placeholderView: some View { + if let currentBlurHash = currentBlurHash { + BlurHashView(blurHash: currentBlurHash) + .id(currentBlurHash) + } else { + Color.secondary + } + } - var body: some View { + var body: some View { - if let currentURL = currentURL { - LazyImage(source: currentURL) { state in - if let image = state.image { - image - } else if state.error != nil { - placeholderView.onAppear { sources.removeFirst() } - } else { - placeholderView - } - } - .pipeline(ImagePipeline(configuration: .withDataCache)) - .id(currentURL) - } else { - failureView - } - } + if let currentURL = currentURL { + LazyImage(source: currentURL) { state in + if let image = state.image { + image + } else if state.error != nil { + placeholderView.onAppear { sources.removeFirst() } + } else { + placeholderView + } + } + .pipeline(ImagePipeline(configuration: .withDataCache)) + .id(currentURL) + } else { + failureView + } + } } extension ImageView where FailureView == DefaultFailureView { - init(_ source: URL?, blurHash: String? = nil) { - let imageViewSource = ImageViewSource(url: source, blurHash: blurHash) - self.init(imageViewSource, failureView: { DefaultFailureView() }) - } + init(_ source: URL?, blurHash: String? = nil) { + let imageViewSource = ImageViewSource(url: source, blurHash: blurHash) + self.init(imageViewSource, failureView: { DefaultFailureView() }) + } - init(_ source: ImageViewSource) { - self.init(source, failureView: { DefaultFailureView() }) - } + init(_ source: ImageViewSource) { + self.init(source, failureView: { DefaultFailureView() }) + } - init(_ sources: [ImageViewSource]) { - self.init(sources, failureView: { DefaultFailureView() }) - } + init(_ sources: [ImageViewSource]) { + self.init(sources, failureView: { DefaultFailureView() }) + } - init(sources: [URL]) { - let imageViewSources = sources.compactMap { ImageViewSource(url: $0, blurHash: nil) } - self.init(imageViewSources, failureView: { DefaultFailureView() }) - } + init(sources: [URL]) { + let imageViewSources = sources.compactMap { ImageViewSource(url: $0, blurHash: nil) } + self.init(imageViewSources, failureView: { DefaultFailureView() }) + } } diff --git a/Shared/Views/InitialFailureView.swift b/Shared/Views/InitialFailureView.swift index 8a9bed09..fe8ce630 100644 --- a/Shared/Views/InitialFailureView.swift +++ b/Shared/Views/InitialFailureView.swift @@ -10,21 +10,21 @@ import SwiftUI struct InitialFailureView: View { - let initials: String + let initials: String - init(_ initials: String) { - self.initials = initials - } + init(_ initials: String) { + self.initials = initials + } - var body: some View { - ZStack { - Rectangle() - .foregroundColor(Color(UIColor.darkGray)) + var body: some View { + ZStack { + Rectangle() + .foregroundColor(Color(UIColor.darkGray)) - Text(initials) - .font(.largeTitle) - .foregroundColor(.secondary) - .accessibilityHidden(true) - } - } + Text(initials) + .font(.largeTitle) + .foregroundColor(.secondary) + .accessibilityHidden(true) + } + } } diff --git a/Shared/Views/LazyView.swift b/Shared/Views/LazyView.swift index 480b5f6b..e3eea6ec 100644 --- a/Shared/Views/LazyView.swift +++ b/Shared/Views/LazyView.swift @@ -10,8 +10,8 @@ import Foundation import SwiftUI struct LazyView: View { - var content: () -> Content - var body: some View { - self.content() - } + var content: () -> Content + var body: some View { + self.content() + } } diff --git a/Shared/Views/MultiSelectorView.swift b/Shared/Views/MultiSelectorView.swift index 8306ba40..75da9898 100644 --- a/Shared/Views/MultiSelectorView.swift +++ b/Shared/Views/MultiSelectorView.swift @@ -9,65 +9,67 @@ import SwiftUI private struct MultiSelectionView: View { - let options: [Selectable] - let optionToString: (Selectable) -> String - let label: String + let options: [Selectable] + let optionToString: (Selectable) -> String + let label: String - @Binding - var selected: [Selectable] + @Binding + var selected: [Selectable] - var body: some View { - List { - ForEach(options, id: \.self) { selectable in - Button(action: { toggleSelection(selectable: selectable) }) { - HStack { - Text(optionToString(selectable)).foregroundColor(Color.primary) - Spacer() - if selected.contains { $0 == selectable } { - Image(systemName: "checkmark").foregroundColor(.accentColor) - } - } - }.tag(selectable) - } - }.listStyle(GroupedListStyle()) - } + var body: some View { + List { + ForEach(options, id: \.self) { selectable in + Button(action: { toggleSelection(selectable: selectable) }) { + HStack { + Text(optionToString(selectable)).foregroundColor(Color.primary) + Spacer() + if selected.contains { $0 == selectable } { + Image(systemName: "checkmark").foregroundColor(.accentColor) + } + } + }.tag(selectable) + } + }.listStyle(GroupedListStyle()) + } - private func toggleSelection(selectable: Selectable) { - if let existingIndex = selected.firstIndex(where: { $0 == selectable }) { - selected.remove(at: existingIndex) - } else { - selected.append(selectable) - } - } + private func toggleSelection(selectable: Selectable) { + if let existingIndex = selected.firstIndex(where: { $0 == selectable }) { + selected.remove(at: existingIndex) + } else { + selected.append(selectable) + } + } } struct MultiSelector: View { - let label: String - let options: [Selectable] - let optionToString: (Selectable) -> String + let label: String + let options: [Selectable] + let optionToString: (Selectable) -> String - var selected: Binding<[Selectable]> + var selected: Binding<[Selectable]> - private var formattedSelectedListString: String { - ListFormatter.localizedString(byJoining: selected.wrappedValue.map { optionToString($0) }) - } + private var formattedSelectedListString: String { + ListFormatter.localizedString(byJoining: selected.wrappedValue.map { optionToString($0) }) + } - var body: some View { - NavigationLink(destination: multiSelectionView()) { - HStack { - Text(label) - Spacer() - Text(formattedSelectedListString) - .foregroundColor(.gray) - .multilineTextAlignment(.trailing) - } - } - } + var body: some View { + NavigationLink(destination: multiSelectionView()) { + HStack { + Text(label) + Spacer() + Text(formattedSelectedListString) + .foregroundColor(.gray) + .multilineTextAlignment(.trailing) + } + } + } - private func multiSelectionView() -> some View { - MultiSelectionView(options: options, - optionToString: optionToString, - label: self.label, - selected: selected) - } + private func multiSelectionView() -> some View { + MultiSelectionView( + options: options, + optionToString: optionToString, + label: self.label, + selected: selected + ) + } } diff --git a/Shared/Views/ParallaxHeader.swift b/Shared/Views/ParallaxHeader.swift index c0c88955..3fa72e7a 100644 --- a/Shared/Views/ParallaxHeader.swift +++ b/Shared/Views/ParallaxHeader.swift @@ -10,40 +10,41 @@ import Foundation import SwiftUI struct ParallaxHeaderScrollView: View { - var header: Header - var staticOverlayView: StaticOverlayView - var overlayAlignment: Alignment - var headerHeight: CGFloat - var content: () -> Content + var header: Header + var staticOverlayView: StaticOverlayView + var overlayAlignment: Alignment + var headerHeight: CGFloat + var content: () -> Content - init(header: Header, - staticOverlayView: StaticOverlayView, - overlayAlignment: Alignment = .center, - headerHeight: CGFloat, - content: @escaping () -> Content) - { - self.header = header - self.staticOverlayView = staticOverlayView - self.overlayAlignment = overlayAlignment - self.headerHeight = headerHeight - self.content = content - } + init( + header: Header, + staticOverlayView: StaticOverlayView, + overlayAlignment: Alignment = .center, + headerHeight: CGFloat, + content: @escaping () -> Content + ) { + self.header = header + self.staticOverlayView = staticOverlayView + self.overlayAlignment = overlayAlignment + self.headerHeight = headerHeight + self.content = content + } - var body: some View { - ScrollView(showsIndicators: false) { - GeometryReader { proxy in - let yOffset = proxy.frame(in: .global).minY > 0 ? -proxy.frame(in: .global).minY : 0 - header - .frame(width: proxy.size.width, height: proxy.size.height - yOffset) - .overlay(staticOverlayView, alignment: overlayAlignment) - .offset(y: yOffset) - } - .frame(height: headerHeight) + var body: some View { + ScrollView(showsIndicators: false) { + GeometryReader { proxy in + let yOffset = proxy.frame(in: .global).minY > 0 ? -proxy.frame(in: .global).minY : 0 + header + .frame(width: proxy.size.width, height: proxy.size.height - yOffset) + .overlay(staticOverlayView, alignment: overlayAlignment) + .offset(y: yOffset) + } + .frame(height: headerHeight) - HStack { - content() - Spacer(minLength: 0) - } - } - } + HStack { + content() + Spacer(minLength: 0) + } + } + } } diff --git a/Shared/Views/PlainNavigationLinkButton.swift b/Shared/Views/PlainNavigationLinkButton.swift index f47fdc1c..a967c107 100644 --- a/Shared/Views/PlainNavigationLinkButton.swift +++ b/Shared/Views/PlainNavigationLinkButton.swift @@ -9,15 +9,15 @@ import SwiftUI struct PlainNavigationLinkButtonStyle: ButtonStyle { - func makeBody(configuration: Self.Configuration) -> some View { - PlainNavigationLinkButton(configuration: configuration) - } + func makeBody(configuration: Self.Configuration) -> some View { + PlainNavigationLinkButton(configuration: configuration) + } } struct PlainNavigationLinkButton: View { - let configuration: ButtonStyle.Configuration + let configuration: ButtonStyle.Configuration - var body: some View { - configuration.label - } + var body: some View { + configuration.label + } } diff --git a/Shared/Views/PortraitItemSize.swift b/Shared/Views/PortraitItemSize.swift index b9fbad53..4c06f4c7 100644 --- a/Shared/Views/PortraitItemSize.swift +++ b/Shared/Views/PortraitItemSize.swift @@ -10,9 +10,9 @@ import SwiftUI extension View { - /// Applies Portrait Poster frame with proper corner radius ratio against the width - func portraitPoster(width: CGFloat) -> some View { - self.frame(width: width, height: width * 1.5) - .cornerRadius((width * 1.5) / 40) - } + /// Applies Portrait Poster frame with proper corner radius ratio against the width + func portraitPoster(width: CGFloat) -> some View { + self.frame(width: width, height: width * 1.5) + .cornerRadius((width * 1.5) / 40) + } } diff --git a/Shared/Views/SearchBarView.swift b/Shared/Views/SearchBarView.swift index 4b848372..66dc899a 100644 --- a/Shared/Views/SearchBarView.swift +++ b/Shared/Views/SearchBarView.swift @@ -9,30 +9,30 @@ import SwiftUI struct SearchBar: View { - @Binding - var text: String + @Binding + var text: String - @State - private var isEditing = false + @State + private var isEditing = false - var body: some View { - HStack(spacing: 8) { - TextField(L10n.searchDots, text: $text) - .padding(8) - .padding(.horizontal, 16) - #if os(iOS) - .background(Color(.systemGray6)) - #endif - .cornerRadius(8) - if !text.isEmpty { - Button(action: { - self.text = "" - }) { - Image(systemName: "xmark.circle.fill") - .foregroundColor(.secondary) - } - } - } - .padding(.horizontal, 16) - } + var body: some View { + HStack(spacing: 8) { + TextField(L10n.searchDots, text: $text) + .padding(8) + .padding(.horizontal, 16) + #if os(iOS) + .background(Color(.systemGray6)) + #endif + .cornerRadius(8) + if !text.isEmpty { + Button(action: { + self.text = "" + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + } + } + .padding(.horizontal, 16) + } } diff --git a/Shared/Views/SearchablePickerView.swift b/Shared/Views/SearchablePickerView.swift index 2800ed24..93ea9e8d 100644 --- a/Shared/Views/SearchablePickerView.swift +++ b/Shared/Views/SearchablePickerView.swift @@ -10,66 +10,68 @@ import Foundation import SwiftUI private struct SearchablePickerView: View { - @Environment(\.presentationMode) - var presentationMode + @Environment(\.presentationMode) + var presentationMode - let options: [Selectable] - let optionToString: (Selectable) -> String - let label: String + let options: [Selectable] + let optionToString: (Selectable) -> String + let label: String - @State - var text = "" - @Binding - var selected: Selectable + @State + var text = "" + @Binding + var selected: Selectable - var body: some View { - VStack { - SearchBar(text: $text) - List(options.filter { - guard !text.isEmpty else { return true } - return optionToString($0).lowercased().contains(text.lowercased()) - }, id: \.self) { selectable in - Button(action: { - selected = selectable - presentationMode.wrappedValue.dismiss() - }) { - HStack { - Text(optionToString(selectable)).foregroundColor(Color.primary) - Spacer() - if selected == selectable { - Image(systemName: "checkmark").foregroundColor(.accentColor) - } - } - } - }.listStyle(GroupedListStyle()) - } - } + var body: some View { + VStack { + SearchBar(text: $text) + List(options.filter { + guard !text.isEmpty else { return true } + return optionToString($0).lowercased().contains(text.lowercased()) + }, id: \.self) { selectable in + Button(action: { + selected = selectable + presentationMode.wrappedValue.dismiss() + }) { + HStack { + Text(optionToString(selectable)).foregroundColor(Color.primary) + Spacer() + if selected == selectable { + Image(systemName: "checkmark").foregroundColor(.accentColor) + } + } + } + }.listStyle(GroupedListStyle()) + } + } } struct SearchablePicker: View { - let label: String - let options: [Selectable] - let optionToString: (Selectable) -> String + let label: String + let options: [Selectable] + let optionToString: (Selectable) -> String - @Binding - var selected: Selectable + @Binding + var selected: Selectable - var body: some View { - NavigationLink(destination: searchablePickerView()) { - HStack { - Text(label) - Spacer() - Text(optionToString(selected)) - .foregroundColor(.gray) - .multilineTextAlignment(.trailing) - } - } - } + var body: some View { + NavigationLink(destination: searchablePickerView()) { + HStack { + Text(label) + Spacer() + Text(optionToString(selected)) + .foregroundColor(.gray) + .multilineTextAlignment(.trailing) + } + } + } - private func searchablePickerView() -> some View { - SearchablePickerView(options: options, - optionToString: optionToString, - label: label, - selected: $selected) - } + private func searchablePickerView() -> some View { + SearchablePickerView( + options: options, + optionToString: optionToString, + label: label, + selected: $selected + ) + } } diff --git a/Swiftfin tvOS/App/JellyfinPlayer_tvOSApp.swift b/Swiftfin tvOS/App/JellyfinPlayer_tvOSApp.swift index ffe2d417..398721b2 100644 --- a/Swiftfin tvOS/App/JellyfinPlayer_tvOSApp.swift +++ b/Swiftfin tvOS/App/JellyfinPlayer_tvOSApp.swift @@ -12,18 +12,18 @@ import UIKit @main struct JellyfinPlayer_tvOSApp: App { - var body: some Scene { - WindowGroup { - MainCoordinator().view() - .onAppear { - JellyfinPlayer_tvOSApp.setupAppearance() - } - } - } + var body: some Scene { + WindowGroup { + MainCoordinator().view() + .onAppear { + JellyfinPlayer_tvOSApp.setupAppearance() + } + } + } - static func setupAppearance() { - let scenes = UIApplication.shared.connectedScenes - let windowScene = scenes.first as? UIWindowScene - windowScene?.windows.first?.overrideUserInterfaceStyle = .dark - } + static func setupAppearance() { + let scenes = UIApplication.shared.connectedScenes + let windowScene = scenes.first as? UIWindowScene + windowScene?.windows.first?.overrideUserInterfaceStyle = .dark + } } diff --git a/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowCard.swift b/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowCard.swift index 5d00f1e2..1fa33862 100644 --- a/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowCard.swift +++ b/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowCard.swift @@ -11,53 +11,55 @@ import SwiftUI struct EpisodeRowCard: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - let viewModel: EpisodesRowManager - let episode: BaseItemDto + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + let viewModel: EpisodesRowManager + let episode: BaseItemDto - var body: some View { - VStack { - Button { - itemRouter.route(to: \.item, episode) - } label: { - ImageView(episode.getBackdropImage(maxWidth: 550), - blurHash: episode.getBackdropImageBlurHash()) - .mask(Rectangle().frame(width: 550, height: 308)) - .frame(width: 550, height: 308) - } - .buttonStyle(CardButtonStyle()) + var body: some View { + VStack { + Button { + itemRouter.route(to: \.item, episode) + } label: { + ImageView( + episode.getBackdropImage(maxWidth: 550), + blurHash: episode.getBackdropImageBlurHash() + ) + .mask(Rectangle().frame(width: 550, height: 308)) + .frame(width: 550, height: 308) + } + .buttonStyle(CardButtonStyle()) - VStack(alignment: .leading) { + VStack(alignment: .leading) { - VStack(alignment: .leading) { - Text(episode.getEpisodeLocator() ?? "") - .font(.caption) - .foregroundColor(.secondary) - Text(episode.name ?? "") - .font(.footnote) - .padding(.bottom, 1) + VStack(alignment: .leading) { + Text(episode.getEpisodeLocator() ?? "") + .font(.caption) + .foregroundColor(.secondary) + Text(episode.name ?? "") + .font(.footnote) + .padding(.bottom, 1) - if episode.unaired { - Text(episode.airDateLabel ?? L10n.noOverviewAvailable) - .font(.caption) - .foregroundColor(.secondary) - .fontWeight(.light) - .lineLimit(3) - } else { - Text(episode.overview ?? "") - .font(.caption) - .fontWeight(.light) - .lineLimit(4) - .fixedSize(horizontal: false, vertical: true) - } - } + if episode.unaired { + Text(episode.airDateLabel ?? L10n.noOverviewAvailable) + .font(.caption) + .foregroundColor(.secondary) + .fontWeight(.light) + .lineLimit(3) + } else { + Text(episode.overview ?? "") + .font(.caption) + .fontWeight(.light) + .lineLimit(4) + .fixedSize(horizontal: false, vertical: true) + } + } - Spacer() - } - .padding() - .frame(width: 550) - } - .focusSection() - } + Spacer() + } + .padding() + .frame(width: 550) + } + .focusSection() + } } diff --git a/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowView.swift b/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowView.swift index d9e5c42d..c834e6d6 100644 --- a/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowView.swift +++ b/Swiftfin tvOS/Components/EpisodesRowView/EpisodesRowView.swift @@ -11,98 +11,98 @@ import SwiftUI struct EpisodesRowView: View where RowManager: EpisodesRowManager { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: RowManager - let onlyCurrentSeason: Bool + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: RowManager + let onlyCurrentSeason: Bool - var body: some View { - VStack(alignment: .leading) { + var body: some View { + VStack(alignment: .leading) { - Text(viewModel.selectedSeason?.name ?? L10n.episodes) - .font(.title3) - .padding(.horizontal, 50) + Text(viewModel.selectedSeason?.name ?? L10n.episodes) + .font(.title3) + .padding(.horizontal, 50) - ScrollView(.horizontal) { - ScrollViewReader { reader in - HStack(alignment: .top) { - if viewModel.isLoading { - VStack(alignment: .leading) { + ScrollView(.horizontal) { + ScrollViewReader { reader in + HStack(alignment: .top) { + if viewModel.isLoading { + VStack(alignment: .leading) { - ZStack { - Color.secondary.ignoresSafeArea() + ZStack { + Color.secondary.ignoresSafeArea() - ProgressView() - } - .mask(Rectangle().frame(width: 500, height: 280)) - .frame(width: 500, height: 280) + ProgressView() + } + .mask(Rectangle().frame(width: 500, height: 280)) + .frame(width: 500, height: 280) - VStack(alignment: .leading) { - Text("S-E-") - .font(.caption) - .foregroundColor(.secondary) - Text("--") - .font(.footnote) - .padding(.bottom, 1) - Text("--") - .font(.caption) - .fontWeight(.light) - .lineLimit(4) - } - .padding(.horizontal) + VStack(alignment: .leading) { + Text("S-E-") + .font(.caption) + .foregroundColor(.secondary) + Text("--") + .font(.footnote) + .padding(.bottom, 1) + Text("--") + .font(.caption) + .fontWeight(.light) + .lineLimit(4) + } + .padding(.horizontal) - Spacer() - } - .frame(width: 500) - .focusable() - } else if let selectedSeason = viewModel.selectedSeason { - if let seasonEpisodes = viewModel.seasonsEpisodes[selectedSeason] { - if seasonEpisodes.isEmpty { - VStack(alignment: .leading) { + Spacer() + } + .frame(width: 500) + .focusable() + } else if let selectedSeason = viewModel.selectedSeason { + if let seasonEpisodes = viewModel.seasonsEpisodes[selectedSeason] { + if seasonEpisodes.isEmpty { + VStack(alignment: .leading) { - Color.secondary - .mask(Rectangle().frame(width: 500, height: 280)) - .frame(width: 500, height: 280) + Color.secondary + .mask(Rectangle().frame(width: 500, height: 280)) + .frame(width: 500, height: 280) - VStack(alignment: .leading) { - Text("--") - .font(.caption) - .foregroundColor(.secondary) - L10n.noEpisodesAvailable.text - .font(.footnote) - .padding(.bottom, 1) - } - .padding(.horizontal) + VStack(alignment: .leading) { + Text("--") + .font(.caption) + .foregroundColor(.secondary) + L10n.noEpisodesAvailable.text + .font(.footnote) + .padding(.bottom, 1) + } + .padding(.horizontal) - Spacer() - } - .frame(width: 500) - .focusable() - } else { - ForEach(viewModel.seasonsEpisodes[selectedSeason]!, id: \.self) { episode in - EpisodeRowCard(viewModel: viewModel, episode: episode) - .id(episode.id) - } - } - } - } - } - .padding(.horizontal, 50) - .padding(.vertical) - .onChange(of: viewModel.selectedSeason) { _ in - if viewModel.selectedSeason?.id == viewModel.item.seasonId { - reader.scrollTo(viewModel.item.id) - } - } - .onChange(of: viewModel.seasonsEpisodes) { _ in - if viewModel.selectedSeason?.id == viewModel.item.seasonId { - reader.scrollTo(viewModel.item.id) - } - } - } - .edgesIgnoringSafeArea(.horizontal) - } - } - } + Spacer() + } + .frame(width: 500) + .focusable() + } else { + ForEach(viewModel.seasonsEpisodes[selectedSeason]!, id: \.self) { episode in + EpisodeRowCard(viewModel: viewModel, episode: episode) + .id(episode.id) + } + } + } + } + } + .padding(.horizontal, 50) + .padding(.vertical) + .onChange(of: viewModel.selectedSeason) { _ in + if viewModel.selectedSeason?.id == viewModel.item.seasonId { + reader.scrollTo(viewModel.item.id) + } + } + .onChange(of: viewModel.seasonsEpisodes) { _ in + if viewModel.selectedSeason?.id == viewModel.item.seasonId { + reader.scrollTo(viewModel.item.id) + } + } + } + .edgesIgnoringSafeArea(.horizontal) + } + } + } } diff --git a/Swiftfin tvOS/Components/HomeCinematicView/CinematicBackgroundView.swift b/Swiftfin tvOS/Components/HomeCinematicView/CinematicBackgroundView.swift index 884d282b..7baf9608 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/CinematicBackgroundView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/CinematicBackgroundView.swift @@ -13,45 +13,45 @@ import UIKit class DynamicCinematicBackgroundViewModel: ObservableObject { - @Published - var currentItem: BaseItemDto? - @Published - var currentImageView: UIImageView? + @Published + var currentItem: BaseItemDto? + @Published + var currentImageView: UIImageView? - func select(item: BaseItemDto) { + func select(item: BaseItemDto) { - guard item.id != currentItem?.id else { return } + guard item.id != currentItem?.id else { return } - currentItem = item + currentItem = item - let itemImageView = UIImageView() + let itemImageView = UIImageView() - let backdropImage: URL + let backdropImage: URL - if item.itemType == .episode { - backdropImage = item.getSeriesBackdropImage(maxWidth: 1920) - } else { - backdropImage = item.getBackdropImage(maxWidth: 1920) - } + if item.itemType == .episode { + backdropImage = item.getSeriesBackdropImage(maxWidth: 1920) + } else { + backdropImage = item.getBackdropImage(maxWidth: 1920) + } - let options = ImageLoadingOptions(transition: .fadeIn(duration: 0.2)) + let options = ImageLoadingOptions(transition: .fadeIn(duration: 0.2)) - Nuke.loadImage(with: backdropImage, options: options, into: itemImageView, completion: { _ in }) + Nuke.loadImage(with: backdropImage, options: options, into: itemImageView, completion: { _ in }) - currentImageView = itemImageView - } + currentImageView = itemImageView + } } struct CinematicBackgroundView: UIViewRepresentable { - @ObservedObject - var viewModel: DynamicCinematicBackgroundViewModel + @ObservedObject + var viewModel: DynamicCinematicBackgroundViewModel - func updateUIView(_ uiView: UICinematicBackgroundView, context: Context) { - uiView.update(imageView: viewModel.currentImageView ?? UIImageView()) - } + func updateUIView(_ uiView: UICinematicBackgroundView, context: Context) { + uiView.update(imageView: viewModel.currentImageView ?? UIImageView()) + } - func makeUIView(context: Context) -> UICinematicBackgroundView { - UICinematicBackgroundView(initialImageView: viewModel.currentImageView ?? UIImageView()) - } + func makeUIView(context: Context) -> UICinematicBackgroundView { + UICinematicBackgroundView(initialImageView: viewModel.currentImageView ?? UIImageView()) + } } diff --git a/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift b/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift index 3cab0897..7d41417f 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift @@ -11,58 +11,60 @@ import SwiftUI struct CinematicNextUpCardView: View { - @EnvironmentObject - var homeRouter: HomeCoordinator.Router - let item: BaseItemDto - let showOverlay: Bool + @EnvironmentObject + var homeRouter: HomeCoordinator.Router + let item: BaseItemDto + let showOverlay: Bool - var body: some View { - VStack(alignment: .leading) { - Button { - homeRouter.route(to: \.modalItem, item) - } label: { - ZStack(alignment: .bottomLeading) { + var body: some View { + VStack(alignment: .leading) { + Button { + homeRouter.route(to: \.modalItem, item) + } label: { + ZStack(alignment: .bottomLeading) { - if item.itemType == .episode { - ImageView(sources: [ - item.getSeriesThumbImage(maxWidth: 350), - item.getSeriesBackdropImage(maxWidth: 350), - ]) - .frame(width: 350, height: 210) - } else { - ImageView([ - .init(url: item.getThumbImage(maxWidth: 350)), - .init(url: item.getBackdropImage(maxWidth: 350), blurHash: item.getBackdropImageBlurHash()), - ]) - .frame(width: 350, height: 210) - } + if item.itemType == .episode { + ImageView(sources: [ + item.getSeriesThumbImage(maxWidth: 350), + item.getSeriesBackdropImage(maxWidth: 350), + ]) + .frame(width: 350, height: 210) + } else { + ImageView([ + .init(url: item.getThumbImage(maxWidth: 350)), + .init(url: item.getBackdropImage(maxWidth: 350), blurHash: item.getBackdropImageBlurHash()), + ]) + .frame(width: 350, height: 210) + } - LinearGradient(colors: [.clear, .black], - startPoint: .top, - endPoint: .bottom) - .frame(height: 105) - .ignoresSafeArea() + LinearGradient( + colors: [.clear, .black], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 105) + .ignoresSafeArea() - if showOverlay { - VStack(alignment: .leading, spacing: 0) { - L10n.next.text - .font(.subheadline) - .padding(.vertical, 5) - .padding(.leading, 10) - .foregroundColor(.white) + if showOverlay { + VStack(alignment: .leading, spacing: 0) { + L10n.next.text + .font(.subheadline) + .padding(.vertical, 5) + .padding(.leading, 10) + .foregroundColor(.white) - HStack { - Color.clear - .frame(width: 1, height: 7) - } - } - } - } - .frame(width: 350, height: 210) - } - .buttonStyle(CardButtonStyle()) - .padding(.top) - } - .padding(.vertical) - } + HStack { + Color.clear + .frame(width: 1, height: 7) + } + } + } + } + .frame(width: 350, height: 210) + } + .buttonStyle(CardButtonStyle()) + .padding(.top) + } + .padding(.vertical) + } } diff --git a/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift b/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift index 52b3cf88..8b325750 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift @@ -11,66 +11,68 @@ import SwiftUI struct CinematicResumeCardView: View { - @EnvironmentObject - var homeRouter: HomeCoordinator.Router - @ObservedObject - var viewModel: HomeViewModel - let item: BaseItemDto + @EnvironmentObject + var homeRouter: HomeCoordinator.Router + @ObservedObject + var viewModel: HomeViewModel + let item: BaseItemDto - var body: some View { - VStack(alignment: .leading) { - Button { - homeRouter.route(to: \.modalItem, item) - } label: { - ZStack(alignment: .bottom) { + var body: some View { + VStack(alignment: .leading) { + Button { + homeRouter.route(to: \.modalItem, item) + } label: { + ZStack(alignment: .bottom) { - if item.itemType == .episode { - ImageView(sources: [ - item.getSeriesThumbImage(maxWidth: 350), - item.getSeriesBackdropImage(maxWidth: 350), - ]) - .frame(width: 350, height: 210) - } else { - ImageView([ - .init(url: item.getThumbImage(maxWidth: 350)), - .init(url: item.getBackdropImage(maxWidth: 350), blurHash: item.getBackdropImageBlurHash()), - ]) - .frame(width: 350, height: 210) - } + if item.itemType == .episode { + ImageView(sources: [ + item.getSeriesThumbImage(maxWidth: 350), + item.getSeriesBackdropImage(maxWidth: 350), + ]) + .frame(width: 350, height: 210) + } else { + ImageView([ + .init(url: item.getThumbImage(maxWidth: 350)), + .init(url: item.getBackdropImage(maxWidth: 350), blurHash: item.getBackdropImageBlurHash()), + ]) + .frame(width: 350, height: 210) + } - LinearGradient(colors: [.clear, .black], - startPoint: .top, - endPoint: .bottom) - .frame(height: 105) - .ignoresSafeArea() + LinearGradient( + colors: [.clear, .black], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 105) + .ignoresSafeArea() - VStack(alignment: .leading, spacing: 0) { - Text(item.getItemProgressString() ?? "") - .font(.subheadline) - .padding(.vertical, 5) - .padding(.leading, 10) - .foregroundColor(.white) + VStack(alignment: .leading, spacing: 0) { + Text(item.getItemProgressString() ?? "") + .font(.subheadline) + .padding(.vertical, 5) + .padding(.leading, 10) + .foregroundColor(.white) - HStack { - Color(UIColor.systemPurple) - .frame(width: 350 * (item.userData?.playedPercentage ?? 0) / 100, height: 7) + HStack { + Color(UIColor.systemPurple) + .frame(width: 350 * (item.userData?.playedPercentage ?? 0) / 100, height: 7) - Spacer(minLength: 0) - } - } - } - .frame(width: 350, height: 210) - } - .buttonStyle(CardButtonStyle()) - .padding(.top) - .contextMenu { - Button(role: .destructive) { - viewModel.removeItemFromResume(item) - } label: { - L10n.removeFromResume.text - } - } - } - .padding(.vertical) - } + Spacer(minLength: 0) + } + } + } + .frame(width: 350, height: 210) + } + .buttonStyle(CardButtonStyle()) + .padding(.top) + .contextMenu { + Button(role: .destructive) { + viewModel.removeItemFromResume(item) + } label: { + L10n.removeFromResume.text + } + } + } + .padding(.vertical) + } } diff --git a/Swiftfin tvOS/Components/HomeCinematicView/HomeCinematicView.swift b/Swiftfin tvOS/Components/HomeCinematicView/HomeCinematicView.swift index 902220b0..f32d594e 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/HomeCinematicView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/HomeCinematicView.swift @@ -14,119 +14,121 @@ import UIKit struct HomeCinematicViewItem: Hashable { - enum TopRowType { - case resume - case nextUp - case plain - } + enum TopRowType { + case resume + case nextUp + case plain + } - let item: BaseItemDto - let type: TopRowType + let item: BaseItemDto + let type: TopRowType - func hash(into hasher: inout Hasher) { - hasher.combine(item) - hasher.combine(type) - } + func hash(into hasher: inout Hasher) { + hasher.combine(item) + hasher.combine(type) + } } struct HomeCinematicView: View { - @FocusState - var selectedItem: BaseItemDto? - @ObservedObject - var viewModel: HomeViewModel - @State - private var updatedSelectedItem: BaseItemDto? - @State - private var initiallyAppeared = false - private let forcedItemSubtitle: String? - private let items: [HomeCinematicViewItem] - private let backgroundViewModel = DynamicCinematicBackgroundViewModel() + @FocusState + var selectedItem: BaseItemDto? + @ObservedObject + var viewModel: HomeViewModel + @State + private var updatedSelectedItem: BaseItemDto? + @State + private var initiallyAppeared = false + private let forcedItemSubtitle: String? + private let items: [HomeCinematicViewItem] + private let backgroundViewModel = DynamicCinematicBackgroundViewModel() - init(viewModel: HomeViewModel, items: [HomeCinematicViewItem], forcedItemSubtitle: String? = nil) { - self.viewModel = viewModel - self.items = items - self.forcedItemSubtitle = forcedItemSubtitle - } + init(viewModel: HomeViewModel, items: [HomeCinematicViewItem], forcedItemSubtitle: String? = nil) { + self.viewModel = viewModel + self.items = items + self.forcedItemSubtitle = forcedItemSubtitle + } - var body: some View { + var body: some View { - ZStack(alignment: .bottom) { + ZStack(alignment: .bottom) { - CinematicBackgroundView(viewModel: backgroundViewModel) - .frame(height: UIScreen.main.bounds.height - 10) + CinematicBackgroundView(viewModel: backgroundViewModel) + .frame(height: UIScreen.main.bounds.height - 10) - LinearGradient(stops: [ - .init(color: .clear, location: 0.5), - .init(color: .black.opacity(0.6), location: 0.7), - .init(color: .black, location: 1), - ], - startPoint: .top, - endPoint: .bottom) - .ignoresSafeArea() + LinearGradient( + stops: [ + .init(color: .clear, location: 0.5), + .init(color: .black.opacity(0.6), location: 0.7), + .init(color: .black, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 0) { - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 0) { - if let forcedItemSubtitle = forcedItemSubtitle { - Text(forcedItemSubtitle) - .font(.callout) - .fontWeight(.medium) - .foregroundColor(Color.secondary) - } else { - if updatedSelectedItem?.itemType == .episode { - Text(updatedSelectedItem?.getEpisodeLocator() ?? "") - .font(.callout) - .fontWeight(.medium) - .foregroundColor(Color.secondary) - } else { - Text("") - } - } + if let forcedItemSubtitle = forcedItemSubtitle { + Text(forcedItemSubtitle) + .font(.callout) + .fontWeight(.medium) + .foregroundColor(Color.secondary) + } else { + if updatedSelectedItem?.itemType == .episode { + Text(updatedSelectedItem?.getEpisodeLocator() ?? "") + .font(.callout) + .fontWeight(.medium) + .foregroundColor(Color.secondary) + } else { + Text("") + } + } - Text("\(updatedSelectedItem?.seriesName ?? updatedSelectedItem?.name ?? "")") - .font(.title) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.horizontal, 50) + Text("\(updatedSelectedItem?.seriesName ?? updatedSelectedItem?.name ?? "")") + .font(.title) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 50) - ScrollView(.horizontal, showsIndicators: false) { - HStack { - ForEach(items, id: \.self) { item in - switch item.type { - case .nextUp: - CinematicNextUpCardView(item: item.item, showOverlay: true) - .focused($selectedItem, equals: item.item) - case .resume: - CinematicResumeCardView(viewModel: viewModel, item: item.item) - .focused($selectedItem, equals: item.item) - case .plain: - CinematicNextUpCardView(item: item.item, showOverlay: false) - .focused($selectedItem, equals: item.item) - } - } - } - .padding(.horizontal, 50) - .padding(.bottom) - } - .focusSection() - } - } - .onChange(of: selectedItem) { newValue in - if let newItem = newValue { - backgroundViewModel.select(item: newItem) - updatedSelectedItem = newItem - } - } - .onAppear { - guard !initiallyAppeared else { return } - selectedItem = items.first?.item - updatedSelectedItem = items.first?.item - initiallyAppeared = true - } - } + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(items, id: \.self) { item in + switch item.type { + case .nextUp: + CinematicNextUpCardView(item: item.item, showOverlay: true) + .focused($selectedItem, equals: item.item) + case .resume: + CinematicResumeCardView(viewModel: viewModel, item: item.item) + .focused($selectedItem, equals: item.item) + case .plain: + CinematicNextUpCardView(item: item.item, showOverlay: false) + .focused($selectedItem, equals: item.item) + } + } + } + .padding(.horizontal, 50) + .padding(.bottom) + } + .focusSection() + } + } + .onChange(of: selectedItem) { newValue in + if let newItem = newValue { + backgroundViewModel.select(item: newItem) + updatedSelectedItem = newItem + } + } + .onAppear { + guard !initiallyAppeared else { return } + selectedItem = items.first?.item + updatedSelectedItem = items.first?.item + initiallyAppeared = true + } + } } diff --git a/Swiftfin tvOS/Components/HomeCinematicView/UICinematicBackgroundView.swift b/Swiftfin tvOS/Components/HomeCinematicView/UICinematicBackgroundView.swift index 002b402f..71d54879 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/UICinematicBackgroundView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/UICinematicBackgroundView.swift @@ -11,61 +11,66 @@ import UIKit class UICinematicBackgroundView: UIView { - private var currentImageView: UIView? + private var currentImageView: UIView? - private var selectDelayTimer: Timer? + private var selectDelayTimer: Timer? - init(initialImageView: UIImageView) { - super.init(frame: .zero) + init(initialImageView: UIImageView) { + super.init(frame: .zero) - initialImageView.translatesAutoresizingMaskIntoConstraints = false - initialImageView.alpha = 0 + initialImageView.translatesAutoresizingMaskIntoConstraints = false + initialImageView.alpha = 0 - addSubview(initialImageView) - NSLayoutConstraint.activate([ - initialImageView.topAnchor.constraint(equalTo: topAnchor), - initialImageView.bottomAnchor.constraint(equalTo: bottomAnchor), - initialImageView.leftAnchor.constraint(equalTo: leftAnchor), - initialImageView.rightAnchor.constraint(equalTo: rightAnchor), - ]) + addSubview(initialImageView) + NSLayoutConstraint.activate([ + initialImageView.topAnchor.constraint(equalTo: topAnchor), + initialImageView.bottomAnchor.constraint(equalTo: bottomAnchor), + initialImageView.leftAnchor.constraint(equalTo: leftAnchor), + initialImageView.rightAnchor.constraint(equalTo: rightAnchor), + ]) - self.currentImageView = initialImageView - } + self.currentImageView = initialImageView + } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } - func update(imageView: UIImageView) { + func update(imageView: UIImageView) { - selectDelayTimer?.invalidate() + selectDelayTimer?.invalidate() - selectDelayTimer = Timer.scheduledTimer(timeInterval: 0.5, target: self, selector: #selector(delayTimerTimed), userInfo: imageView, - repeats: false) - } + selectDelayTimer = Timer.scheduledTimer( + timeInterval: 0.5, + target: self, + selector: #selector(delayTimerTimed), + userInfo: imageView, + repeats: false + ) + } - @objc - private func delayTimerTimed(timer: Timer) { - let newImageView = timer.userInfo as! UIImageView + @objc + private func delayTimerTimed(timer: Timer) { + let newImageView = timer.userInfo as! UIImageView - newImageView.translatesAutoresizingMaskIntoConstraints = false - newImageView.alpha = 0 + newImageView.translatesAutoresizingMaskIntoConstraints = false + newImageView.alpha = 0 - addSubview(newImageView) - NSLayoutConstraint.activate([ - newImageView.topAnchor.constraint(equalTo: topAnchor), - newImageView.bottomAnchor.constraint(equalTo: bottomAnchor), - newImageView.leftAnchor.constraint(equalTo: leftAnchor), - newImageView.rightAnchor.constraint(equalTo: rightAnchor), - ]) + addSubview(newImageView) + NSLayoutConstraint.activate([ + newImageView.topAnchor.constraint(equalTo: topAnchor), + newImageView.bottomAnchor.constraint(equalTo: bottomAnchor), + newImageView.leftAnchor.constraint(equalTo: leftAnchor), + newImageView.rightAnchor.constraint(equalTo: rightAnchor), + ]) - UIView.animate(withDuration: 0.2) { - newImageView.alpha = 1 - self.currentImageView?.alpha = 0 - } completion: { _ in - self.currentImageView?.removeFromSuperview() - self.currentImageView = newImageView - } - } + UIView.animate(withDuration: 0.2) { + newImageView.alpha = 1 + self.currentImageView?.alpha = 0 + } completion: { _ in + self.currentImageView?.removeFromSuperview() + self.currentImageView = newImageView + } + } } diff --git a/Swiftfin tvOS/Components/ItemDetailsView.swift b/Swiftfin tvOS/Components/ItemDetailsView.swift index 394bab32..e31d3fb1 100644 --- a/Swiftfin tvOS/Components/ItemDetailsView.swift +++ b/Swiftfin tvOS/Components/ItemDetailsView.swift @@ -10,82 +10,82 @@ import SwiftUI struct ItemDetailsView: View { - @ObservedObject - var viewModel: ItemViewModel - @FocusState - private var focused: Bool + @ObservedObject + var viewModel: ItemViewModel + @FocusState + private var focused: Bool - var body: some View { + var body: some View { - ZStack(alignment: .leading) { + ZStack(alignment: .leading) { - Color(UIColor.darkGray).opacity(focused ? 0.2 : 0) - .cornerRadius(30, corners: [.topLeft, .topRight]) + Color(UIColor.darkGray).opacity(focused ? 0.2 : 0) + .cornerRadius(30, corners: [.topLeft, .topRight]) - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 20) { - L10n.information.text - .font(.title3) - .padding(.bottom, 5) + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 20) { + L10n.information.text + .font(.title3) + .padding(.bottom, 5) - ForEach(viewModel.informationItems, id: \.self.title) { informationItem in - ItemDetail(title: informationItem.title, content: informationItem.content) - } - } + ForEach(viewModel.informationItems, id: \.self.title) { informationItem in + ItemDetail(title: informationItem.title, content: informationItem.content) + } + } - Spacer() + Spacer() - if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { - VStack(alignment: .leading, spacing: 20) { - L10n.media.text - .font(.title3) - .padding(.bottom, 5) + if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { + VStack(alignment: .leading, spacing: 20) { + L10n.media.text + .font(.title3) + .padding(.bottom, 5) - ForEach(selectedVideoPlayerViewModel.mediaItems, id: \.self.title) { mediaItem in - ItemDetail(title: mediaItem.title, content: mediaItem.content) - } - } - } + ForEach(selectedVideoPlayerViewModel.mediaItems, id: \.self.title) { mediaItem in + ItemDetail(title: mediaItem.title, content: mediaItem.content) + } + } + } - Spacer() - } - .ignoresSafeArea() - .focusable() - .focused($focused) - .padding(50) - } - } + Spacer() + } + .ignoresSafeArea() + .focusable() + .focused($focused) + .padding(50) + } + } } fileprivate struct ItemDetail: View { - let title: String - let content: String + let title: String + let content: String - var body: some View { - VStack(alignment: .leading) { - Text(title) - .font(.body) - Text(content) - .font(.footnote) - .foregroundColor(.secondary) - } - } + var body: some View { + VStack(alignment: .leading) { + Text(title) + .font(.body) + Text(content) + .font(.footnote) + .foregroundColor(.secondary) + } + } } struct RoundedCorner: Shape { - var radius: CGFloat = .infinity - var corners: UIRectCorner = .allCorners + var radius: CGFloat = .infinity + var corners: UIRectCorner = .allCorners - func path(in rect: CGRect) -> Path { - let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)) - return Path(path.cgPath) - } + func path(in rect: CGRect) -> Path { + let path = UIBezierPath(roundedRect: rect, byRoundingCorners: corners, cornerRadii: CGSize(width: radius, height: radius)) + return Path(path.cgPath) + } } extension View { - func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { - clipShape(RoundedCorner(radius: radius, corners: corners)) - } + func cornerRadius(_ radius: CGFloat, corners: UIRectCorner) -> some View { + clipShape(RoundedCorner(radius: radius, corners: corners)) + } } diff --git a/Swiftfin tvOS/Components/LandscapeItemElement.swift b/Swiftfin tvOS/Components/LandscapeItemElement.swift index a379bc8d..7a030398 100644 --- a/Swiftfin tvOS/Components/LandscapeItemElement.swift +++ b/Swiftfin tvOS/Components/LandscapeItemElement.swift @@ -10,115 +10,130 @@ import JellyfinAPI import SwiftUI struct CutOffShadow: Shape { - func path(in rect: CGRect) -> Path { - var path = Path() + func path(in rect: CGRect) -> Path { + var path = Path() - let tl = CGPoint(x: rect.minX, y: rect.minY) - let tr = CGPoint(x: rect.maxX, y: rect.minY) - let brs = CGPoint(x: rect.maxX, y: rect.maxY - 6) - let brc = CGPoint(x: rect.maxX - 6, y: rect.maxY - 6) - let bls = CGPoint(x: rect.minX + 6, y: rect.maxY) - let blc = CGPoint(x: rect.minX + 6, y: rect.maxY - 6) + let tl = CGPoint(x: rect.minX, y: rect.minY) + let tr = CGPoint(x: rect.maxX, y: rect.minY) + let brs = CGPoint(x: rect.maxX, y: rect.maxY - 6) + let brc = CGPoint(x: rect.maxX - 6, y: rect.maxY - 6) + let bls = CGPoint(x: rect.minX + 6, y: rect.maxY) + let blc = CGPoint(x: rect.minX + 6, y: rect.maxY - 6) - path.move(to: tl) - path.addLine(to: tr) - path.addLine(to: brs) - path.addRelativeArc(center: brc, radius: 6, - startAngle: Angle.degrees(0), delta: Angle.degrees(90)) - path.addLine(to: bls) - path.addRelativeArc(center: blc, radius: 6, - startAngle: Angle.degrees(90), delta: Angle.degrees(90)) + path.move(to: tl) + path.addLine(to: tr) + path.addLine(to: brs) + path.addRelativeArc( + center: brc, + radius: 6, + startAngle: Angle.degrees(0), + delta: Angle.degrees(90) + ) + path.addLine(to: bls) + path.addRelativeArc( + center: blc, + radius: 6, + startAngle: Angle.degrees(90), + delta: Angle.degrees(90) + ) - return path - } + return path + } } struct LandscapeItemElement: View { - @Environment(\.isFocused) - var envFocused: Bool - @State - var focused: Bool = false - @State - var backgroundURL: URL? + @Environment(\.isFocused) + var envFocused: Bool + @State + var focused: Bool = false + @State + var backgroundURL: URL? - var item: BaseItemDto - var inSeasonView: Bool? + var item: BaseItemDto + var inSeasonView: Bool? - var body: some View { - VStack { - ImageView(item.type == .episode && !(inSeasonView ?? false) ? item.getSeriesBackdropImage(maxWidth: 445) : item - .getBackdropImage(maxWidth: 445), - blurHash: item.type == .episode ? item.getSeriesBackdropImageBlurHash() : item.getBackdropImageBlurHash()) - .frame(width: 445, height: 250) - .cornerRadius(10) - .ignoresSafeArea() - .overlay(ZStack { - if item.userData?.played ?? false { - Image(systemName: "circle.fill") - .foregroundColor(.white) - Image(systemName: "checkmark.circle.fill") - .foregroundColor(Color(.systemBlue)) - } - }.padding(2) - .opacity(1), alignment: .topTrailing).opacity(1) - .overlay(ZStack(alignment: .leading) { - if focused && item.userData?.playedPercentage != nil { - Rectangle() - .fill(LinearGradient(gradient: Gradient(colors: [.black, .clear]), - startPoint: .bottom, - endPoint: .top)) - .frame(width: 445, height: 90) - .mask(CutOffShadow()) - VStack(alignment: .leading) { - Text("CONTINUE • \(item.getItemProgressString() ?? "")") - .font(.caption) - .fontWeight(.medium) - .offset(y: 5) - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 6) - .fill(Color.gray) - .opacity(0.4) - .frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12) - RoundedRectangle(cornerRadius: 6) - .fill(Color.jellyfinPurple) - .frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 4.45 - 0.16), height: 12) - } - }.padding(12) - } else { - EmptyView() - } - }, alignment: .bottomLeading) - .shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0) - .shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0) - if inSeasonView ?? false { - Text("\(item.getEpisodeLocator() ?? "") • \(item.name ?? "")") - .font(.callout) - .fontWeight(.semibold) - .lineLimit(1) - .frame(width: 445) - } else { - Text(item.type == .episode ? "\(item.seriesName ?? "") • \(item.getEpisodeLocator() ?? "")" : item.name ?? "") - .font(.callout) - .fontWeight(.semibold) - .lineLimit(1) - .frame(width: 445) - } - } - .onChange(of: envFocused) { envFocus in - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } + var body: some View { + VStack { + ImageView( + item.type == .episode && !(inSeasonView ?? false) ? item.getSeriesBackdropImage(maxWidth: 445) : item + .getBackdropImage(maxWidth: 445), + blurHash: item.type == .episode ? item.getSeriesBackdropImageBlurHash() : item.getBackdropImageBlurHash() + ) + .frame(width: 445, height: 250) + .cornerRadius(10) + .ignoresSafeArea() + .overlay( + ZStack { + if item.userData?.played ?? false { + Image(systemName: "circle.fill") + .foregroundColor(.white) + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color(.systemBlue)) + } + }.padding(2) + .opacity(1), + alignment: .topTrailing + ).opacity(1) + .overlay(ZStack(alignment: .leading) { + if focused && item.userData?.playedPercentage != nil { + Rectangle() + .fill(LinearGradient( + gradient: Gradient(colors: [.black, .clear]), + startPoint: .bottom, + endPoint: .top + )) + .frame(width: 445, height: 90) + .mask(CutOffShadow()) + VStack(alignment: .leading) { + Text("CONTINUE • \(item.getItemProgressString() ?? "")") + .font(.caption) + .fontWeight(.medium) + .offset(y: 5) + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 6) + .fill(Color.gray) + .opacity(0.4) + .frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12) + RoundedRectangle(cornerRadius: 6) + .fill(Color.jellyfinPurple) + .frame(width: CGFloat(item.userData?.playedPercentage ?? 0 * 4.45 - 0.16), height: 12) + } + }.padding(12) + } else { + EmptyView() + } + }, alignment: .bottomLeading) + .shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0) + .shadow(radius: focused ? 10.0 : 0, y: focused ? 10.0 : 0) + if inSeasonView ?? false { + Text("\(item.getEpisodeLocator() ?? "") • \(item.name ?? "")") + .font(.callout) + .fontWeight(.semibold) + .lineLimit(1) + .frame(width: 445) + } else { + Text(item.type == .episode ? "\(item.seriesName ?? "") • \(item.getEpisodeLocator() ?? "")" : item.name ?? "") + .font(.callout) + .fontWeight(.semibold) + .lineLimit(1) + .frame(width: 445) + } + } + .onChange(of: envFocused) { envFocus in + withAnimation(.linear(duration: 0.15)) { + self.focused = envFocus + } - if envFocus == true { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - // your code here - if focused == true { - backgroundURL = item.getBackdropImage(maxWidth: 1080) - BackgroundManager.current.setBackground(to: backgroundURL!, hash: item.getBackdropImageBlurHash()) - } - } - } - } - .scaleEffect(focused ? 1.1 : 1) - } + if envFocus == true { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + // your code here + if focused == true { + backgroundURL = item.getBackdropImage(maxWidth: 1080) + BackgroundManager.current.setBackground(to: backgroundURL!, hash: item.getBackdropImageBlurHash()) + } + } + } + } + .scaleEffect(focused ? 1.1 : 1) + } } diff --git a/Swiftfin tvOS/Components/MediaPlayButtonRowView.swift b/Swiftfin tvOS/Components/MediaPlayButtonRowView.swift index 96c412cb..5ef2baeb 100644 --- a/Swiftfin tvOS/Components/MediaPlayButtonRowView.swift +++ b/Swiftfin tvOS/Components/MediaPlayButtonRowView.swift @@ -9,45 +9,48 @@ import SwiftUI struct MediaPlayButtonRowView: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: ItemViewModel - @State - var wrappedScrollView: UIScrollView? + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: ItemViewModel + @State + var wrappedScrollView: UIScrollView? - var body: some View { - HStack { - VStack { - Button { - itemRouter.route(to: \.videoPlayer, viewModel.selectedVideoPlayerViewModel!) - } label: { - MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView) - } + var body: some View { + HStack { + VStack { + Button { + itemRouter.route(to: \.videoPlayer, viewModel.selectedVideoPlayerViewModel!) + } label: { + MediaViewActionButton(icon: "play.fill", scrollView: $wrappedScrollView) + } - Text((viewModel.item.getItemProgressString() != nil) ? "\(viewModel.item.getItemProgressString() ?? "") left" : L10n.play) - .font(.caption) - } - VStack { - Button { - viewModel.updateWatchState() - } label: { - MediaViewActionButton(icon: "eye.fill", scrollView: $wrappedScrollView, iconColor: viewModel.isWatched ? .red : .white) - } - Text(viewModel.isWatched ? "Unwatch" : "Mark Watched") - .font(.caption) - } - VStack { - Button { - viewModel.updateFavoriteState() - } label: { - MediaViewActionButton(icon: "heart.fill", scrollView: $wrappedScrollView, - iconColor: viewModel.isFavorited ? .red : .white) - } - Text(viewModel.isFavorited ? "Unfavorite" : "Favorite") - .font(.caption) - } - Spacer() - } - } + Text((viewModel.item.getItemProgressString() != nil) ? "\(viewModel.item.getItemProgressString() ?? "") left" : L10n.play) + .font(.caption) + } + VStack { + Button { + viewModel.updateWatchState() + } label: { + MediaViewActionButton(icon: "eye.fill", scrollView: $wrappedScrollView, iconColor: viewModel.isWatched ? .red : .white) + } + Text(viewModel.isWatched ? "Unwatch" : "Mark Watched") + .font(.caption) + } + VStack { + Button { + viewModel.updateFavoriteState() + } label: { + MediaViewActionButton( + icon: "heart.fill", + scrollView: $wrappedScrollView, + iconColor: viewModel.isFavorited ? .red : .white + ) + } + Text(viewModel.isFavorited ? "Unfavorite" : "Favorite") + .font(.caption) + } + Spacer() + } + } } diff --git a/Swiftfin tvOS/Components/MediaViewActionButton.swift b/Swiftfin tvOS/Components/MediaViewActionButton.swift index 8ed635c4..2b696273 100644 --- a/Swiftfin tvOS/Components/MediaViewActionButton.swift +++ b/Swiftfin tvOS/Components/MediaViewActionButton.swift @@ -9,30 +9,30 @@ import SwiftUI struct MediaViewActionButton: View { - @Environment(\.isFocused) - var envFocused: Bool - @State - var focused: Bool = false - var icon: String - var scrollView: Binding? - var iconColor: Color? + @Environment(\.isFocused) + var envFocused: Bool + @State + var focused: Bool = false + var icon: String + var scrollView: Binding? + var iconColor: Color? - var body: some View { - Image(systemName: icon) - .foregroundColor(focused ? .black : iconColor ?? .white) - .onChange(of: envFocused) { envFocus in - if envFocus == true { - scrollView?.wrappedValue?.scrollToTop() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - scrollView?.wrappedValue?.scrollToTop() - } - } + var body: some View { + Image(systemName: icon) + .foregroundColor(focused ? .black : iconColor ?? .white) + .onChange(of: envFocused) { envFocus in + if envFocus == true { + scrollView?.wrappedValue?.scrollToTop() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + scrollView?.wrappedValue?.scrollToTop() + } + } - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } - } - .font(.system(size: 40)) - .padding(.vertical, 12).padding(.horizontal, 20) - } + withAnimation(.linear(duration: 0.15)) { + self.focused = envFocus + } + } + .font(.system(size: 40)) + .padding(.vertical, 12).padding(.horizontal, 20) + } } diff --git a/Swiftfin tvOS/Components/PlainLinkButton.swift b/Swiftfin tvOS/Components/PlainLinkButton.swift index 607433bd..236882ad 100644 --- a/Swiftfin tvOS/Components/PlainLinkButton.swift +++ b/Swiftfin tvOS/Components/PlainLinkButton.swift @@ -10,22 +10,22 @@ import JellyfinAPI import SwiftUI struct PlainLinkButton: View { - @Environment(\.isFocused) - var envFocused: Bool - @State - var focused: Bool = false - @State - var label: String + @Environment(\.isFocused) + var envFocused: Bool + @State + var focused: Bool = false + @State + var label: String - var body: some View { - Text(label) - .fontWeight(focused ? .bold : .regular) - .foregroundColor(.blue) - .onChange(of: envFocused) { envFocus in - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } - } - .scaleEffect(focused ? 1.1 : 1) - } + var body: some View { + Text(label) + .fontWeight(focused ? .bold : .regular) + .foregroundColor(.blue) + .onChange(of: envFocused) { envFocus in + withAnimation(.linear(duration: 0.15)) { + self.focused = envFocus + } + } + .scaleEffect(focused ? 1.1 : 1) + } } diff --git a/Swiftfin tvOS/Components/PortraitItemElement.swift b/Swiftfin tvOS/Components/PortraitItemElement.swift index 555a1acb..2981c214 100644 --- a/Swiftfin tvOS/Components/PortraitItemElement.swift +++ b/Swiftfin tvOS/Components/PortraitItemElement.swift @@ -10,86 +10,94 @@ import JellyfinAPI import SwiftUI struct PortraitItemElement: View { - @Environment(\.isFocused) - var envFocused: Bool - @State - var focused: Bool = false - @State - var backgroundURL: URL? + @Environment(\.isFocused) + var envFocused: Bool + @State + var focused: Bool = false + @State + var backgroundURL: URL? - var item: BaseItemDto + var item: BaseItemDto - var body: some View { - VStack { - ImageView(item.type == .episode ? item.getSeriesPrimaryImage(maxWidth: 200) : item.getPrimaryImage(maxWidth: 200), - blurHash: item.type == .episode ? item.getSeriesPrimaryImageBlurHash() : item.getPrimaryImageBlurHash()) - .frame(width: 200, height: 300) - .cornerRadius(10) - .shadow(radius: focused ? 10.0 : 0) - .shadow(radius: focused ? 10.0 : 0) - .overlay(ZStack { - if item.userData?.isFavorite ?? false { - Image(systemName: "circle.fill") - .foregroundColor(.white) - .opacity(0.6) - Image(systemName: "heart.fill") - .foregroundColor(Color(.systemRed)) - .font(.system(size: 10)) - } - } - .padding(2) - .opacity(1), alignment: .bottomLeading) - .overlay(ZStack { - if item.userData?.played ?? false { - Image(systemName: "circle.fill") - .foregroundColor(.white) - Image(systemName: "checkmark.circle.fill") - .foregroundColor(Color(.systemBlue)) - } else { - if item.userData?.unplayedItemCount != nil { - Image(systemName: "circle.fill") - .foregroundColor(Color(.systemBlue)) - Text(String(item.userData!.unplayedItemCount ?? 0)) - .foregroundColor(.white) - .font(.caption2) - } - } - }.padding(2) - .opacity(1), alignment: .topTrailing).opacity(1) - Text(item.title) - .frame(width: 200, height: 30, alignment: .center) - if item.type == .movie || item.type == .series { - Text("\(String(item.productionYear ?? 0)) • \(item.officialRating ?? "N/A")") - .foregroundColor(.secondary) - .font(.caption) - .fontWeight(.medium) - } else if item.type == .season { - Text("\(item.name ?? "") • \(String(item.productionYear ?? 0))") - .foregroundColor(.secondary) - .font(.caption) - .fontWeight(.medium) - } else { - Text(L10n.seasonAndEpisode(String(item.parentIndexNumber ?? 0), String(item.indexNumber ?? 0))) - .foregroundColor(.secondary) - .font(.caption) - .fontWeight(.medium) - } - } - .onChange(of: envFocused) { envFocus in - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } + var body: some View { + VStack { + ImageView( + item.type == .episode ? item.getSeriesPrimaryImage(maxWidth: 200) : item.getPrimaryImage(maxWidth: 200), + blurHash: item.type == .episode ? item.getSeriesPrimaryImageBlurHash() : item.getPrimaryImageBlurHash() + ) + .frame(width: 200, height: 300) + .cornerRadius(10) + .shadow(radius: focused ? 10.0 : 0) + .shadow(radius: focused ? 10.0 : 0) + .overlay( + ZStack { + if item.userData?.isFavorite ?? false { + Image(systemName: "circle.fill") + .foregroundColor(.white) + .opacity(0.6) + Image(systemName: "heart.fill") + .foregroundColor(Color(.systemRed)) + .font(.system(size: 10)) + } + } + .padding(2) + .opacity(1), + alignment: .bottomLeading + ) + .overlay( + ZStack { + if item.userData?.played ?? false { + Image(systemName: "circle.fill") + .foregroundColor(.white) + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color(.systemBlue)) + } else { + if item.userData?.unplayedItemCount != nil { + Image(systemName: "circle.fill") + .foregroundColor(Color(.systemBlue)) + Text(String(item.userData!.unplayedItemCount ?? 0)) + .foregroundColor(.white) + .font(.caption2) + } + } + }.padding(2) + .opacity(1), + alignment: .topTrailing + ).opacity(1) + Text(item.title) + .frame(width: 200, height: 30, alignment: .center) + if item.type == .movie || item.type == .series { + Text("\(String(item.productionYear ?? 0)) • \(item.officialRating ?? "N/A")") + .foregroundColor(.secondary) + .font(.caption) + .fontWeight(.medium) + } else if item.type == .season { + Text("\(item.name ?? "") • \(String(item.productionYear ?? 0))") + .foregroundColor(.secondary) + .font(.caption) + .fontWeight(.medium) + } else { + Text(L10n.seasonAndEpisode(String(item.parentIndexNumber ?? 0), String(item.indexNumber ?? 0))) + .foregroundColor(.secondary) + .font(.caption) + .fontWeight(.medium) + } + } + .onChange(of: envFocused) { envFocus in + withAnimation(.linear(duration: 0.15)) { + self.focused = envFocus + } - if envFocus == true { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - // your code here - if focused == true { - backgroundURL = item.getBackdropImage(maxWidth: 1080) - BackgroundManager.current.setBackground(to: backgroundURL!, hash: item.getBackdropImageBlurHash()) - } - } - } - } - .scaleEffect(focused ? 1.1 : 1) - } + if envFocus == true { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + // your code here + if focused == true { + backgroundURL = item.getBackdropImage(maxWidth: 1080) + BackgroundManager.current.setBackground(to: backgroundURL!, hash: item.getBackdropImageBlurHash()) + } + } + } + } + .scaleEffect(focused ? 1.1 : 1) + } } diff --git a/Swiftfin tvOS/Components/PortraitItemsRowView.swift b/Swiftfin tvOS/Components/PortraitItemsRowView.swift index a3e89660..9cfa6cd9 100644 --- a/Swiftfin tvOS/Components/PortraitItemsRowView.swift +++ b/Swiftfin tvOS/Components/PortraitItemsRowView.swift @@ -11,59 +11,60 @@ import SwiftUI struct PortraitItemsRowView: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router + @EnvironmentObject + var itemRouter: ItemCoordinator.Router - let rowTitle: String - let items: [BaseItemDto] - let showItemTitles: Bool - let selectedAction: (BaseItemDto) -> Void + let rowTitle: String + let items: [BaseItemDto] + let showItemTitles: Bool + let selectedAction: (BaseItemDto) -> Void - init(rowTitle: String, - items: [BaseItemDto], - showItemTitles: Bool = true, - selectedAction: @escaping (BaseItemDto) -> Void) - { - self.rowTitle = rowTitle - self.items = items - self.showItemTitles = showItemTitles - self.selectedAction = selectedAction - } + init( + rowTitle: String, + items: [BaseItemDto], + showItemTitles: Bool = true, + selectedAction: @escaping (BaseItemDto) -> Void + ) { + self.rowTitle = rowTitle + self.items = items + self.showItemTitles = showItemTitles + self.selectedAction = selectedAction + } - var body: some View { - VStack(alignment: .leading) { + var body: some View { + VStack(alignment: .leading) { - Text(rowTitle) - .font(.title3) - .padding(.horizontal, 50) + Text(rowTitle) + .font(.title3) + .padding(.horizontal, 50) - ScrollView(.horizontal) { - HStack(alignment: .top) { - ForEach(items, id: \.self) { item in + ScrollView(.horizontal) { + HStack(alignment: .top) { + ForEach(items, id: \.self) { item in - VStack(spacing: 15) { - Button { - selectedAction(item) - } label: { - ImageView(item.portraitHeaderViewURL(maxWidth: 257)) - .frame(width: 257, height: 380) - } - .frame(height: 380) - .buttonStyle(PlainButtonStyle()) + VStack(spacing: 15) { + Button { + selectedAction(item) + } label: { + ImageView(item.portraitHeaderViewURL(maxWidth: 257)) + .frame(width: 257, height: 380) + } + .frame(height: 380) + .buttonStyle(PlainButtonStyle()) - if showItemTitles { - Text(item.title) - .lineLimit(2) - .frame(width: 257) - } - } - } - } - .padding(.horizontal, 50) - .padding(.vertical) - } - .edgesIgnoringSafeArea(.horizontal) - } - .focusSection() - } + if showItemTitles { + Text(item.title) + .lineLimit(2) + .frame(width: 257) + } + } + } + } + .padding(.horizontal, 50) + .padding(.vertical) + } + .edgesIgnoringSafeArea(.horizontal) + } + .focusSection() + } } diff --git a/Swiftfin tvOS/Components/PublicUserButton.swift b/Swiftfin tvOS/Components/PublicUserButton.swift index 1f4868d9..af8af7e3 100644 --- a/Swiftfin tvOS/Components/PublicUserButton.swift +++ b/Swiftfin tvOS/Components/PublicUserButton.swift @@ -11,36 +11,40 @@ import JellyfinAPI import SwiftUI struct PublicUserButton: View { - @Environment(\.isFocused) - var envFocused: Bool - @State - var focused: Bool = false - var publicUser: UserDto + @Environment(\.isFocused) + var envFocused: Bool + @State + var focused: Bool = false + var publicUser: UserDto - var body: some View { - VStack { - if publicUser.primaryImageTag != nil { - ImageView(URL(string: "\(SessionManager.main.currentLogin.server.currentURI)/Users/\(publicUser.id ?? "")/Images/Primary?width=500&quality=80&tag=\(publicUser.primaryImageTag!)")!) - .frame(width: 250, height: 250) - .cornerRadius(125.0) - } else { - Image(systemName: "person.fill") - .foregroundColor(Color(red: 1, green: 1, blue: 1).opacity(0.8)) - .font(.system(size: 35)) - .frame(width: 250, height: 250) - .background(Color(red: 98 / 255, green: 121 / 255, blue: 205 / 255)) - .cornerRadius(125.0) - .shadow(radius: 6) - } - if focused { - Text(publicUser.name ?? "").font(.headline).fontWeight(.semibold) - } else { - Spacer().frame(height: 60) - } - }.onChange(of: envFocused) { envFocus in - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } - }.scaleEffect(focused ? 1.1 : 1) - } + var body: some View { + VStack { + if publicUser.primaryImageTag != nil { + ImageView( + URL( + string: "\(SessionManager.main.currentLogin.server.currentURI)/Users/\(publicUser.id ?? "")/Images/Primary?width=500&quality=80&tag=\(publicUser.primaryImageTag!)" + )! + ) + .frame(width: 250, height: 250) + .cornerRadius(125.0) + } else { + Image(systemName: "person.fill") + .foregroundColor(Color(red: 1, green: 1, blue: 1).opacity(0.8)) + .font(.system(size: 35)) + .frame(width: 250, height: 250) + .background(Color(red: 98 / 255, green: 121 / 255, blue: 205 / 255)) + .cornerRadius(125.0) + .shadow(radius: 6) + } + if focused { + Text(publicUser.name ?? "").font(.headline).fontWeight(.semibold) + } else { + Spacer().frame(height: 60) + } + }.onChange(of: envFocused) { envFocus in + withAnimation(.linear(duration: 0.15)) { + self.focused = envFocus + } + }.scaleEffect(focused ? 1.1 : 1) + } } diff --git a/Swiftfin tvOS/Components/SFSymbolButton.swift b/Swiftfin tvOS/Components/SFSymbolButton.swift index bffd314e..4f6d5af5 100644 --- a/Swiftfin tvOS/Components/SFSymbolButton.swift +++ b/Swiftfin tvOS/Components/SFSymbolButton.swift @@ -11,43 +11,43 @@ import UIKit struct SFSymbolButton: UIViewRepresentable { - let systemName: String - let action: () -> Void - private let pointSize: CGFloat + let systemName: String + let action: () -> Void + private let pointSize: CGFloat - init(systemName: String, pointSize: CGFloat = 24, action: @escaping () -> Void) { - self.systemName = systemName - self.action = action - self.pointSize = pointSize - } + init(systemName: String, pointSize: CGFloat = 24, action: @escaping () -> Void) { + self.systemName = systemName + self.action = action + self.pointSize = pointSize + } - func makeUIView(context: Context) -> some UIButton { - var configuration = UIButton.Configuration.plain() - configuration.cornerStyle = .capsule + func makeUIView(context: Context) -> some UIButton { + var configuration = UIButton.Configuration.plain() + configuration.cornerStyle = .capsule - let buttonAction = UIAction(title: "") { _ in - self.action() - } + let buttonAction = UIAction(title: "") { _ in + self.action() + } - let button = UIButton(configuration: configuration, primaryAction: buttonAction) + let button = UIButton(configuration: configuration, primaryAction: buttonAction) - let symbolImageConfig = UIImage.SymbolConfiguration(pointSize: pointSize) - let symbolImage = UIImage(systemName: systemName, withConfiguration: symbolImageConfig) + let symbolImageConfig = UIImage.SymbolConfiguration(pointSize: pointSize) + let symbolImage = UIImage(systemName: systemName, withConfiguration: symbolImageConfig) - button.setImage(symbolImage, for: .normal) + button.setImage(symbolImage, for: .normal) - return button - } + return button + } - func updateUIView(_ uiView: UIViewType, context: Context) {} + func updateUIView(_ uiView: UIViewType, context: Context) {} } extension SFSymbolButton: Hashable { - static func == (lhs: SFSymbolButton, rhs: SFSymbolButton) -> Bool { - lhs.systemName == rhs.systemName - } + static func == (lhs: SFSymbolButton, rhs: SFSymbolButton) -> Bool { + lhs.systemName == rhs.systemName + } - func hash(into hasher: inout Hasher) { - hasher.combine(systemName) - } + func hash(into hasher: inout Hasher) { + hasher.combine(systemName) + } } diff --git a/Swiftfin tvOS/ImageButtonStyle.swift b/Swiftfin tvOS/ImageButtonStyle.swift index e60f6ce0..5a286a6f 100644 --- a/Swiftfin tvOS/ImageButtonStyle.swift +++ b/Swiftfin tvOS/ImageButtonStyle.swift @@ -8,14 +8,14 @@ struct ImageButtonStyle: ButtonStyle { - let focused: Bool - func makeBody(configuration: Configuration) -> some View { - configuration - .label - .padding(6) - .foregroundColor(Color.white) - .background(Color.blue) - .cornerRadius(100) - .shadow(color: .black, radius: self.focused ? 20 : 0, x: 0, y: 0) // 0 - } + let focused: Bool + func makeBody(configuration: Configuration) -> some View { + configuration + .label + .padding(6) + .foregroundColor(Color.white) + .background(Color.blue) + .cornerRadius(100) + .shadow(color: .black, radius: self.focused ? 20 : 0, x: 0, y: 0) // 0 + } } diff --git a/Swiftfin tvOS/Views/AboutView.swift b/Swiftfin tvOS/Views/AboutView.swift index 4e1bbf6a..fbe2fa7a 100644 --- a/Swiftfin tvOS/Views/AboutView.swift +++ b/Swiftfin tvOS/Views/AboutView.swift @@ -10,7 +10,7 @@ import SwiftUI struct AboutView: View { - var body: some View { - Text("dud") - } + var body: some View { + Text("dud") + } } diff --git a/Swiftfin tvOS/Views/BasicAppSettingsView.swift b/Swiftfin tvOS/Views/BasicAppSettingsView.swift index 88b84e48..9f111845 100644 --- a/Swiftfin tvOS/Views/BasicAppSettingsView.swift +++ b/Swiftfin tvOS/Views/BasicAppSettingsView.swift @@ -12,57 +12,57 @@ import SwiftUI struct BasicAppSettingsView: View { - @EnvironmentObject - var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router - @ObservedObject - var viewModel: BasicAppSettingsViewModel - @State - var resetTapped: Bool = false + @EnvironmentObject + var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router + @ObservedObject + var viewModel: BasicAppSettingsViewModel + @State + var resetTapped: Bool = false - @Default(.appAppearance) - var appAppearance + @Default(.appAppearance) + var appAppearance - var body: some View { - Form { + var body: some View { + Form { - Section { - Button {} label: { - HStack { - L10n.version.text - Spacer() - Text("\(UIApplication.appVersion ?? "--") (\(UIApplication.bundleVersion ?? "--"))") - .foregroundColor(.secondary) - } - } - } header: { - L10n.about.text - } + Section { + Button {} label: { + HStack { + L10n.version.text + Spacer() + Text("\(UIApplication.appVersion ?? "--") (\(UIApplication.bundleVersion ?? "--"))") + .foregroundColor(.secondary) + } + } + } header: { + L10n.about.text + } - // TODO: Implement once design is theme appearance friendly -// Section { -// Picker(L10n.appearance, selection: $appAppearance) { -// ForEach(self.viewModel.appearances, id: \.self) { appearance in -// Text(appearance.localizedName).tag(appearance.rawValue) -// } -// } -// } header: { -// L10n.accessibility.text -// } + // TODO: Implement once design is theme appearance friendly + // Section { + // Picker(L10n.appearance, selection: $appAppearance) { + // ForEach(self.viewModel.appearances, id: \.self) { appearance in + // Text(appearance.localizedName).tag(appearance.rawValue) + // } + // } + // } header: { + // L10n.accessibility.text + // } - Button { - resetTapped = true - } label: { - L10n.reset.text - } - } - .alert(L10n.reset, isPresented: $resetTapped, actions: { - Button(role: .destructive) { - viewModel.resetAppSettings() - basicAppSettingsRouter.dismissCoordinator() - } label: { - L10n.reset.text - } - }) - .navigationTitle(L10n.settings) - } + Button { + resetTapped = true + } label: { + L10n.reset.text + } + } + .alert(L10n.reset, isPresented: $resetTapped, actions: { + Button(role: .destructive) { + viewModel.resetAppSettings() + basicAppSettingsRouter.dismissCoordinator() + } label: { + L10n.reset.text + } + }) + .navigationTitle(L10n.settings) + } } diff --git a/Swiftfin tvOS/Views/ConnectToServerView.swift b/Swiftfin tvOS/Views/ConnectToServerView.swift index 690777bd..20080245 100644 --- a/Swiftfin tvOS/Views/ConnectToServerView.swift +++ b/Swiftfin tvOS/Views/ConnectToServerView.swift @@ -12,74 +12,76 @@ import SwiftUI struct ConnectToServerView: View { - @StateObject - var viewModel: ConnectToServerViewModel - @State - var uri = "" + @StateObject + var viewModel: ConnectToServerViewModel + @State + var uri = "" - @Default(.defaultHTTPScheme) - var defaultHTTPScheme + @Default(.defaultHTTPScheme) + var defaultHTTPScheme - var body: some View { - List { - Section { - TextField(L10n.serverURL, text: $uri) - .disableAutocorrection(true) - .autocapitalization(.none) - .keyboardType(.URL) - .onAppear { - if uri == "" { - uri = "\(defaultHTTPScheme.rawValue)://" - } - } + var body: some View { + List { + Section { + TextField(L10n.serverURL, text: $uri) + .disableAutocorrection(true) + .autocapitalization(.none) + .keyboardType(.URL) + .onAppear { + if uri == "" { + uri = "\(defaultHTTPScheme.rawValue)://" + } + } - Button { - viewModel.connectToServer(uri: uri) - } label: { - HStack { - L10n.connect.text - Spacer() - if viewModel.isLoading { - ProgressView() - } - } - } - .disabled(viewModel.isLoading || uri.isEmpty) - } header: { - L10n.connectToJellyfinServer.text - } + Button { + viewModel.connectToServer(uri: uri) + } label: { + HStack { + L10n.connect.text + Spacer() + if viewModel.isLoading { + ProgressView() + } + } + } + .disabled(viewModel.isLoading || uri.isEmpty) + } header: { + L10n.connectToJellyfinServer.text + } - Section(header: L10n.localServers.text) { - if viewModel.searching { - ProgressView() - } - ForEach(viewModel.discoveredServers.sorted(by: { $0.name < $1.name }), id: \.id) { discoveredServer in - Button(action: { - viewModel.connectToServer(uri: discoveredServer.url.absoluteString) - }, label: { - HStack { - Text(discoveredServer.name) - .font(.headline) - Text("• \(discoveredServer.host)") - .font(.subheadline) - .foregroundColor(.secondary) - Spacer() - if viewModel.isLoading { - ProgressView() - } - } + Section(header: L10n.localServers.text) { + if viewModel.searching { + ProgressView() + } + ForEach(viewModel.discoveredServers.sorted(by: { $0.name < $1.name }), id: \.id) { discoveredServer in + Button(action: { + viewModel.connectToServer(uri: discoveredServer.url.absoluteString) + }, label: { + HStack { + Text(discoveredServer.name) + .font(.headline) + Text("• \(discoveredServer.host)") + .font(.subheadline) + .foregroundColor(.secondary) + Spacer() + if viewModel.isLoading { + ProgressView() + } + } - }) - } - } - .onAppear(perform: self.viewModel.discoverServers) - .headerProminence(.increased) - } - .alert(item: $viewModel.errorMessage) { _ in - Alert(title: Text(viewModel.alertTitle), - message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), - dismissButton: .cancel()) - } - .navigationTitle(L10n.connect) - } + }) + } + } + .onAppear(perform: self.viewModel.discoverServers) + .headerProminence(.increased) + } + .alert(item: $viewModel.errorMessage) { _ in + Alert( + title: Text(viewModel.alertTitle), + message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), + dismissButton: .cancel() + ) + } + .navigationTitle(L10n.connect) + } } diff --git a/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift b/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift index 2b7cf408..560e3232 100644 --- a/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift +++ b/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingCard.swift @@ -11,70 +11,72 @@ import SwiftUI struct ContinueWatchingCard: View { - @EnvironmentObject - var homeRouter: HomeCoordinator.Router - let item: BaseItemDto + @EnvironmentObject + var homeRouter: HomeCoordinator.Router + let item: BaseItemDto - var body: some View { - VStack(alignment: .leading) { - Button { - homeRouter.route(to: \.modalItem, item) - } label: { - ZStack(alignment: .bottom) { + var body: some View { + VStack(alignment: .leading) { + Button { + homeRouter.route(to: \.modalItem, item) + } label: { + ZStack(alignment: .bottom) { - if item.itemType == .episode { - ImageView(item.getSeriesBackdropImage(maxWidth: 500)) - .frame(width: 500, height: 281.25) - } else { - ImageView(item.getBackdropImage(maxWidth: 500)) - .frame(width: 500, height: 281.25) - } + if item.itemType == .episode { + ImageView(item.getSeriesBackdropImage(maxWidth: 500)) + .frame(width: 500, height: 281.25) + } else { + ImageView(item.getBackdropImage(maxWidth: 500)) + .frame(width: 500, height: 281.25) + } - VStack(alignment: .leading, spacing: 0) { - Text(item.getItemProgressString() ?? "") - .font(.subheadline) - .padding(.vertical, 5) - .padding(.leading, 10) - .foregroundColor(.white) + VStack(alignment: .leading, spacing: 0) { + Text(item.getItemProgressString() ?? "") + .font(.subheadline) + .padding(.vertical, 5) + .padding(.leading, 10) + .foregroundColor(.white) - HStack { - Color(UIColor.systemPurple) - .frame(width: 500 * (item.userData?.playedPercentage ?? 0) / 100, height: 13) + HStack { + Color(UIColor.systemPurple) + .frame(width: 500 * (item.userData?.playedPercentage ?? 0) / 100, height: 13) - Spacer(minLength: 0) - } - } - .background { - LinearGradient(colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)], - startPoint: .top, - endPoint: .bottom) - .ignoresSafeArea() - } - } - .frame(width: 500, height: 281.25) - } - .buttonStyle(CardButtonStyle()) - .padding(.top) + Spacer(minLength: 0) + } + } + .background { + LinearGradient( + colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + } + } + .frame(width: 500, height: 281.25) + } + .buttonStyle(CardButtonStyle()) + .padding(.top) - VStack(alignment: .leading) { - Text("\(item.seriesName ?? item.name ?? "")") - .font(.callout) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) - .frame(width: 500, alignment: .leading) + VStack(alignment: .leading) { + Text("\(item.seriesName ?? item.name ?? "")") + .font(.callout) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) + .frame(width: 500, alignment: .leading) - if item.itemType == .episode { - Text(item.getEpisodeLocator() ?? "") - .font(.callout) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } else { - Text("") - } - } - } - .padding(.vertical) - } + if item.itemType == .episode { + Text(item.getEpisodeLocator() ?? "") + .font(.callout) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } else { + Text("") + } + } + } + .padding(.vertical) + } } diff --git a/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingView.swift b/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingView.swift index 107a8b9f..84138a66 100644 --- a/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingView.swift +++ b/Swiftfin tvOS/Views/ContinueWatchingView/ContinueWatchingView.swift @@ -13,26 +13,26 @@ import SwiftUI struct ContinueWatchingView: View { - @EnvironmentObject - var homeRouter: HomeCoordinator.Router - let items: [BaseItemDto] + @EnvironmentObject + var homeRouter: HomeCoordinator.Router + let items: [BaseItemDto] - var body: some View { - VStack(alignment: .leading) { + var body: some View { + VStack(alignment: .leading) { - L10n.continueWatching.text - .font(.title3) - .fontWeight(.semibold) - .padding(.leading, 50) + L10n.continueWatching.text + .font(.title3) + .fontWeight(.semibold) + .padding(.leading, 50) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack(alignment: .top) { - ForEach(items, id: \.self) { item in - ContinueWatchingCard(item: item) - } - } - .padding(.horizontal, 50) - } - } - } + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(alignment: .top) { + ForEach(items, id: \.self) { item in + ContinueWatchingCard(item: item) + } + } + .padding(.horizontal, 50) + } + } + } } diff --git a/Swiftfin tvOS/Views/HomeView.swift b/Swiftfin tvOS/Views/HomeView.swift index 1d341e2c..8a41da9b 100644 --- a/Swiftfin tvOS/Views/HomeView.swift +++ b/Swiftfin tvOS/Views/HomeView.swift @@ -13,72 +13,78 @@ import SwiftUI struct HomeView: View { - @EnvironmentObject - var homeRouter: HomeCoordinator.Router - @StateObject - var viewModel = HomeViewModel() - @Default(.showPosterLabels) - var showPosterLabels + @EnvironmentObject + var homeRouter: HomeCoordinator.Router + @StateObject + var viewModel = HomeViewModel() + @Default(.showPosterLabels) + var showPosterLabels - @State - var showingSettings = false + @State + var showingSettings = false - var body: some View { - if viewModel.isLoading { - ProgressView() - .scaleEffect(2) - } else { - ScrollView { - LazyVStack(alignment: .leading) { + var body: some View { + if viewModel.isLoading { + ProgressView() + .scaleEffect(2) + } else { + ScrollView { + LazyVStack(alignment: .leading) { - if viewModel.resumeItems.isEmpty { - HomeCinematicView(viewModel: viewModel, - items: viewModel.latestAddedItems.map { .init(item: $0, type: .plain) }, - forcedItemSubtitle: L10n.recentlyAdded) + if viewModel.resumeItems.isEmpty { + HomeCinematicView( + viewModel: viewModel, + items: viewModel.latestAddedItems.map { .init(item: $0, type: .plain) }, + forcedItemSubtitle: L10n.recentlyAdded + ) - if !viewModel.nextUpItems.isEmpty { - NextUpView(items: viewModel.nextUpItems) - .focusSection() - } - } else { - HomeCinematicView(viewModel: viewModel, - items: viewModel.resumeItems.map { .init(item: $0, type: .resume) }) + if !viewModel.nextUpItems.isEmpty { + NextUpView(items: viewModel.nextUpItems) + .focusSection() + } + } else { + HomeCinematicView( + viewModel: viewModel, + items: viewModel.resumeItems.map { .init(item: $0, type: .resume) } + ) - if !viewModel.nextUpItems.isEmpty { - NextUpView(items: viewModel.nextUpItems) - .focusSection() - } + if !viewModel.nextUpItems.isEmpty { + NextUpView(items: viewModel.nextUpItems) + .focusSection() + } - PortraitItemsRowView(rowTitle: L10n.recentlyAdded, - items: viewModel.latestAddedItems, - showItemTitles: showPosterLabels) { item in - homeRouter.route(to: \.modalItem, item) - } - } + PortraitItemsRowView( + rowTitle: L10n.recentlyAdded, + items: viewModel.latestAddedItems, + showItemTitles: showPosterLabels + ) { item in + homeRouter.route(to: \.modalItem, item) + } + } - ForEach(viewModel.libraries, id: \.self) { library in - LatestMediaView(viewModel: LatestMediaViewModel(library: library)) - .focusSection() - } + ForEach(viewModel.libraries, id: \.self) { library in + LatestMediaView(viewModel: LatestMediaViewModel(library: library)) + .focusSection() + } - Spacer(minLength: 100) + Spacer(minLength: 100) - HStack { - Spacer() + HStack { + Spacer() - Button { - viewModel.refresh() - } label: { - L10n.refresh.text - } + Button { + viewModel.refresh() + } label: { + L10n.refresh.text + } - Spacer() - } - .focusSection() - } - } - .edgesIgnoringSafeArea(.top) - .edgesIgnoringSafeArea(.horizontal) - } - } + Spacer() + } + .focusSection() + } + } + .edgesIgnoringSafeArea(.top) + .edgesIgnoringSafeArea(.horizontal) + } + } } diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicCollectionItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicCollectionItemView.swift index d82fb6a7..f2dbace1 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicCollectionItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicCollectionItemView.swift @@ -12,61 +12,69 @@ import SwiftUI struct CinematicCollectionItemView: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: CollectionItemViewModel - @State - var wrappedScrollView: UIScrollView? - @Default(.showPosterLabels) - var showPosterLabels + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: CollectionItemViewModel + @State + var wrappedScrollView: UIScrollView? + @Default(.showPosterLabels) + var showPosterLabels - var body: some View { - ZStack { + var body: some View { + ZStack { - ImageView(viewModel.item.getBackdropImage(maxWidth: 1920), - blurHash: viewModel.item.getBackdropImageBlurHash()) - .ignoresSafeArea() + ImageView( + viewModel.item.getBackdropImage(maxWidth: 1920), + blurHash: viewModel.item.getBackdropImageBlurHash() + ) + .ignoresSafeArea() - ScrollView { - VStack(spacing: 0) { + ScrollView { + VStack(spacing: 0) { - CinematicItemViewTopRow(viewModel: viewModel, - wrappedScrollView: wrappedScrollView, - title: viewModel.item.name ?? "", - showDetails: false) - .focusSection() - .frame(height: UIScreen.main.bounds.height - 10) + CinematicItemViewTopRow( + viewModel: viewModel, + wrappedScrollView: wrappedScrollView, + title: viewModel.item.name ?? "", + showDetails: false + ) + .focusSection() + .frame(height: UIScreen.main.bounds.height - 10) - ZStack(alignment: .topLeading) { + ZStack(alignment: .topLeading) { - Color.black.ignoresSafeArea() + Color.black.ignoresSafeArea() - VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 20) { - CinematicItemAboutView(viewModel: viewModel) + CinematicItemAboutView(viewModel: viewModel) - PortraitItemsRowView(rowTitle: L10n.items, - items: viewModel.collectionItems) { item in - itemRouter.route(to: \.item, item) - } + PortraitItemsRowView( + rowTitle: L10n.items, + items: viewModel.collectionItems + ) { item in + itemRouter.route(to: \.item, item) + } - if !viewModel.similarItems.isEmpty { - PortraitItemsRowView(rowTitle: L10n.recommended, - items: viewModel.similarItems, - showItemTitles: showPosterLabels) { item in - itemRouter.route(to: \.item, item) - } - } - } - .padding(.vertical, 50) - } - } - } - .introspectScrollView { scrollView in - wrappedScrollView = scrollView - } - .ignoresSafeArea() - } - } + if !viewModel.similarItems.isEmpty { + PortraitItemsRowView( + rowTitle: L10n.recommended, + items: viewModel.similarItems, + showItemTitles: showPosterLabels + ) { item in + itemRouter.route(to: \.item, item) + } + } + } + .padding(.vertical, 50) + } + } + } + .introspectScrollView { scrollView in + wrappedScrollView = scrollView + } + .ignoresSafeArea() + } + } } diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift index 13d1360f..cd924aee 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicEpisodeItemView.swift @@ -12,78 +12,86 @@ import SwiftUI struct CinematicEpisodeItemView: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: EpisodeItemViewModel - @State - var wrappedScrollView: UIScrollView? - @Default(.showPosterLabels) - var showPosterLabels + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: EpisodeItemViewModel + @State + var wrappedScrollView: UIScrollView? + @Default(.showPosterLabels) + var showPosterLabels - func generateSubtitle() -> String? { - guard let seriesName = viewModel.item.seriesName, let episodeLocator = viewModel.item.getEpisodeLocator() else { - return nil - } + func generateSubtitle() -> String? { + guard let seriesName = viewModel.item.seriesName, let episodeLocator = viewModel.item.getEpisodeLocator() else { + return nil + } - return "\(seriesName) - \(episodeLocator)" - } + return "\(seriesName) - \(episodeLocator)" + } - var body: some View { - ZStack { + var body: some View { + ZStack { - ImageView(viewModel.item.getBackdropImage(maxWidth: 1920), - blurHash: viewModel.item.getBackdropImageBlurHash()) - .frame(height: UIScreen.main.bounds.height - 10) - .ignoresSafeArea() + ImageView( + viewModel.item.getBackdropImage(maxWidth: 1920), + blurHash: viewModel.item.getBackdropImageBlurHash() + ) + .frame(height: UIScreen.main.bounds.height - 10) + .ignoresSafeArea() - ScrollView { - VStack(spacing: 0) { + ScrollView { + VStack(spacing: 0) { - CinematicItemViewTopRow(viewModel: viewModel, - wrappedScrollView: wrappedScrollView, - title: viewModel.item.name ?? "", - subtitle: generateSubtitle()) - .focusSection() - .frame(height: UIScreen.main.bounds.height - 10) + CinematicItemViewTopRow( + viewModel: viewModel, + wrappedScrollView: wrappedScrollView, + title: viewModel.item.name ?? "", + subtitle: generateSubtitle() + ) + .focusSection() + .frame(height: UIScreen.main.bounds.height - 10) - ZStack(alignment: .topLeading) { + ZStack(alignment: .topLeading) { - Color.black.ignoresSafeArea() - .frame(minHeight: UIScreen.main.bounds.height) + Color.black.ignoresSafeArea() + .frame(minHeight: UIScreen.main.bounds.height) - VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 20) { - CinematicItemAboutView(viewModel: viewModel) + CinematicItemAboutView(viewModel: viewModel) - EpisodesRowView(viewModel: viewModel, onlyCurrentSeason: true) - .focusSection() + EpisodesRowView(viewModel: viewModel, onlyCurrentSeason: true) + .focusSection() - if let seriesItem = viewModel.series { - PortraitItemsRowView(rowTitle: L10n.series, - items: [seriesItem]) { seriesItem in - itemRouter.route(to: \.item, seriesItem) - } - } + if let seriesItem = viewModel.series { + PortraitItemsRowView( + rowTitle: L10n.series, + items: [seriesItem] + ) { seriesItem in + itemRouter.route(to: \.item, seriesItem) + } + } - if !viewModel.similarItems.isEmpty { - PortraitItemsRowView(rowTitle: L10n.recommended, - items: viewModel.similarItems, - showItemTitles: showPosterLabels) { item in - itemRouter.route(to: \.item, item) - } - } + if !viewModel.similarItems.isEmpty { + PortraitItemsRowView( + rowTitle: L10n.recommended, + items: viewModel.similarItems, + showItemTitles: showPosterLabels + ) { item in + itemRouter.route(to: \.item, item) + } + } - ItemDetailsView(viewModel: viewModel) - } - .padding(.top, 50) - } - } - } - .introspectScrollView { scrollView in - wrappedScrollView = scrollView - } - .ignoresSafeArea() - } - } + ItemDetailsView(viewModel: viewModel) + } + .padding(.top, 50) + } + } + } + .introspectScrollView { scrollView in + wrappedScrollView = scrollView + } + .ignoresSafeArea() + } + } } diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift index 151b0e20..0957c290 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemAboutView.swift @@ -10,34 +10,34 @@ import SwiftUI struct CinematicItemAboutView: View { - @ObservedObject - var viewModel: ItemViewModel - @FocusState - private var focused: Bool + @ObservedObject + var viewModel: ItemViewModel + @FocusState + private var focused: Bool - var body: some View { - HStack(alignment: .top, spacing: 10) { - ImageView(viewModel.item.portraitHeaderViewURL(maxWidth: 257)) - .portraitPoster(width: 257) + var body: some View { + HStack(alignment: .top, spacing: 10) { + ImageView(viewModel.item.portraitHeaderViewURL(maxWidth: 257)) + .portraitPoster(width: 257) - ZStack(alignment: .topLeading) { - Color(UIColor.darkGray).opacity(focused ? 0.2 : 0) - .cornerRadius(9.5) - .frame(height: 385.5) + ZStack(alignment: .topLeading) { + Color(UIColor.darkGray).opacity(focused ? 0.2 : 0) + .cornerRadius(9.5) + .frame(height: 385.5) - VStack(alignment: .leading) { - L10n.about.text - .font(.title3) + VStack(alignment: .leading) { + L10n.about.text + .font(.title3) - Text(viewModel.item.overview ?? L10n.noOverviewAvailable) - .padding(.top, 2) - .lineLimit(7) - } - .padding() - } - } - .focusable() - .focused($focused) - .padding(.horizontal, 50) - } + Text(viewModel.item.overview ?? L10n.noOverviewAvailable) + .padding(.top, 2) + .lineLimit(7) + } + .padding() + } + } + .focusable() + .focused($focused) + .padding(.horizontal, 50) + } } diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift index 38099a00..7a031001 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRow.swift @@ -10,190 +10,195 @@ import SwiftUI struct CinematicItemViewTopRow: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: ItemViewModel - @Environment(\.isFocused) - var envFocused: Bool - @State - var focused: Bool = false - @State - var wrappedScrollView: UIScrollView? - @State - var title: String - @State - var subtitle: String? - @State - private var playButtonText: String = "" - let showDetails: Bool + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: ItemViewModel + @Environment(\.isFocused) + var envFocused: Bool + @State + var focused: Bool = false + @State + var wrappedScrollView: UIScrollView? + @State + var title: String + @State + var subtitle: String? + @State + private var playButtonText: String = "" + let showDetails: Bool - init(viewModel: ItemViewModel, - wrappedScrollView: UIScrollView? = nil, - title: String, - subtitle: String? = nil, - showDetails: Bool = true) - { - self.viewModel = viewModel - self.wrappedScrollView = wrappedScrollView - self.title = title - self.subtitle = subtitle - self.showDetails = showDetails - } + init( + viewModel: ItemViewModel, + wrappedScrollView: UIScrollView? = nil, + title: String, + subtitle: String? = nil, + showDetails: Bool = true + ) { + self.viewModel = viewModel + self.wrappedScrollView = wrappedScrollView + self.title = title + self.subtitle = subtitle + self.showDetails = showDetails + } - var body: some View { - ZStack(alignment: .bottom) { - LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), - startPoint: .top, - endPoint: .bottom) - .ignoresSafeArea() - .frame(height: 210) + var body: some View { + ZStack(alignment: .bottom) { + LinearGradient( + gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + .frame(height: 210) - VStack { - Spacer() + VStack { + Spacer() - HStack(alignment: .bottom) { - VStack(alignment: .leading) { - HStack(alignment: .PlayInformationAlignmentGuide) { + HStack(alignment: .bottom) { + VStack(alignment: .leading) { + HStack(alignment: .PlayInformationAlignmentGuide) { - // MARK: Play + // MARK: Play - Button { - if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { - itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) - } else { - LogManager.log.error("Attempted to play item but no playback information available") - } - } label: { - HStack(spacing: 15) { - Image(systemName: "play.fill") - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black) - .font(.title3) - Text(playButtonText) - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black) - .fontWeight(.semibold) - } - .frame(width: 230, height: 100) - .background(viewModel.playButtonItem == nil ? Color.secondarySystemFill : Color.white) - .cornerRadius(10) - } - .buttonStyle(CardButtonStyle()) - .contextMenu { - if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 { - Button { - if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { - selectedVideoPlayerViewModel.injectCustomValues(startFromBeginning: true) - itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) - } else { - LogManager.log.error("Attempted to play item but no playback information available") - } - } label: { - Label(L10n.playFromBeginning, systemImage: "gobackward") - } + Button { + if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { + itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) + } else { + LogManager.log.error("Attempted to play item but no playback information available") + } + } label: { + HStack(spacing: 15) { + Image(systemName: "play.fill") + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black) + .font(.title3) + Text(playButtonText) + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.black) + .fontWeight(.semibold) + } + .frame(width: 230, height: 100) + .background(viewModel.playButtonItem == nil ? Color.secondarySystemFill : Color.white) + .cornerRadius(10) + } + .buttonStyle(CardButtonStyle()) + .contextMenu { + if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 { + Button { + if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { + selectedVideoPlayerViewModel.injectCustomValues(startFromBeginning: true) + itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) + } else { + LogManager.log.error("Attempted to play item but no playback information available") + } + } label: { + Label(L10n.playFromBeginning, systemImage: "gobackward") + } - Button(role: .cancel) {} label: { - L10n.cancel.text - } - } - } - } - } + Button(role: .cancel) {} label: { + L10n.cancel.text + } + } + } + } + } - VStack(alignment: .leading, spacing: 5) { - Text(title) - .font(.title2) - .lineLimit(2) + VStack(alignment: .leading, spacing: 5) { + Text(title) + .font(.title2) + .lineLimit(2) - if let subtitle = subtitle { - Text(subtitle) - } + if let subtitle = subtitle { + Text(subtitle) + } - HStack(alignment: .PlayInformationAlignmentGuide, spacing: 20) { + HStack(alignment: .PlayInformationAlignmentGuide, spacing: 20) { - if showDetails { - if viewModel.item.itemType == .series { - if let airTime = viewModel.item.airTime { - Text(airTime) - .font(.subheadline) - .fontWeight(.medium) - } - } else { - if let runtime = viewModel.item.getItemRuntime() { - Text(runtime) - .font(.subheadline) - .fontWeight(.medium) - } + if showDetails { + if viewModel.item.itemType == .series { + if let airTime = viewModel.item.airTime { + Text(airTime) + .font(.subheadline) + .fontWeight(.medium) + } + } else { + if let runtime = viewModel.item.getItemRuntime() { + Text(runtime) + .font(.subheadline) + .fontWeight(.medium) + } - if let productionYear = viewModel.item.productionYear { - Text(String(productionYear)) - .font(.subheadline) - .fontWeight(.medium) - .lineLimit(1) - } - } + if let productionYear = viewModel.item.productionYear { + Text(String(productionYear)) + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + } + } - if let officialRating = viewModel.item.officialRating { - Text(officialRating) - .font(.subheadline) - .fontWeight(.semibold) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } + if let officialRating = viewModel.item.officialRating { + Text(officialRating) + .font(.subheadline) + .fontWeight(.semibold) + .lineLimit(1) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1) + ) + } - if viewModel.item.unaired { - if let premiereDate = viewModel.item.airDateLabel { - Text(premiereDate) - .font(.subheadline) - .fontWeight(.medium) - .lineLimit(1) - } - } + if viewModel.item.unaired { + if let premiereDate = viewModel.item.airDateLabel { + Text(premiereDate) + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + } + } - // Dud text in case nothing was shown, something is necessary for proper alignment - Text("") - } else { - Text("") - } - } - .foregroundColor(.secondary) - } + // Dud text in case nothing was shown, something is necessary for proper alignment + Text("") + } else { + Text("") + } + } + .foregroundColor(.secondary) + } - Spacer() - } - .padding(.horizontal, 50) - .padding(.bottom, 50) - } - } - .onAppear { - playButtonText = viewModel.playButtonText() - } - .onChange(of: viewModel.item, perform: { _ in - playButtonText = viewModel.playButtonText() - }) - .onChange(of: envFocused) { envFocus in - if envFocus == true { - wrappedScrollView?.scrollToTop() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - wrappedScrollView?.scrollToTop() - } - } + Spacer() + } + .padding(.horizontal, 50) + .padding(.bottom, 50) + } + } + .onAppear { + playButtonText = viewModel.playButtonText() + } + .onChange(of: viewModel.item, perform: { _ in + playButtonText = viewModel.playButtonText() + }) + .onChange(of: envFocused) { envFocus in + if envFocus == true { + wrappedScrollView?.scrollToTop() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + wrappedScrollView?.scrollToTop() + } + } - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } - } - } + withAnimation(.linear(duration: 0.15)) { + self.focused = envFocus + } + } + } } extension VerticalAlignment { - private struct PlayInformationAlignment: AlignmentID { - static func defaultValue(in context: ViewDimensions) -> CGFloat { - context[VerticalAlignment.bottom] - } - } + private struct PlayInformationAlignment: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + context[VerticalAlignment.bottom] + } + } - static let PlayInformationAlignmentGuide = VerticalAlignment(PlayInformationAlignment.self) + static let PlayInformationAlignmentGuide = VerticalAlignment(PlayInformationAlignment.self) } diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRowButton.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRowButton.swift index d6775d7f..16566153 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRowButton.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicItemViewTopRowButton.swift @@ -9,43 +9,43 @@ import SwiftUI struct CinematicItemViewTopRowButton: View { - @Environment(\.isFocused) - var envFocused: Bool - @State - var focused: Bool = false - @State - var wrappedScrollView: UIScrollView? - var content: () -> Content + @Environment(\.isFocused) + var envFocused: Bool + @State + var focused: Bool = false + @State + var wrappedScrollView: UIScrollView? + var content: () -> Content - @FocusState - private var buttonFocused: Bool + @FocusState + private var buttonFocused: Bool - var body: some View { - content() - .focused($buttonFocused) - .onChange(of: envFocused) { envFocus in - if envFocus == true { - wrappedScrollView?.scrollToTop() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - wrappedScrollView?.scrollToTop() - } - } + var body: some View { + content() + .focused($buttonFocused) + .onChange(of: envFocused) { envFocus in + if envFocus == true { + wrappedScrollView?.scrollToTop() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + wrappedScrollView?.scrollToTop() + } + } - withAnimation(.linear(duration: 0.15)) { - self.focused = envFocus - } - } - .onChange(of: buttonFocused) { newValue in - if newValue { - wrappedScrollView?.scrollToTop() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { - wrappedScrollView?.scrollToTop() - } + withAnimation(.linear(duration: 0.15)) { + self.focused = envFocus + } + } + .onChange(of: buttonFocused) { newValue in + if newValue { + wrappedScrollView?.scrollToTop() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { + wrappedScrollView?.scrollToTop() + } - withAnimation(.linear(duration: 0.15)) { - self.focused = newValue - } - } - } - } + withAnimation(.linear(duration: 0.15)) { + self.focused = newValue + } + } + } + } } diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift index efd04c5a..b6ca0df4 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicMovieItemView.swift @@ -12,58 +12,64 @@ import SwiftUI struct CinematicMovieItemView: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: MovieItemViewModel - @State - var wrappedScrollView: UIScrollView? - @Default(.showPosterLabels) - var showPosterLabels + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: MovieItemViewModel + @State + var wrappedScrollView: UIScrollView? + @Default(.showPosterLabels) + var showPosterLabels - var body: some View { - ZStack { + var body: some View { + ZStack { - ImageView(viewModel.item.getBackdropImage(maxWidth: 1920), - blurHash: viewModel.item.getBackdropImageBlurHash()) - .ignoresSafeArea() + ImageView( + viewModel.item.getBackdropImage(maxWidth: 1920), + blurHash: viewModel.item.getBackdropImageBlurHash() + ) + .ignoresSafeArea() - ScrollView { - VStack(spacing: 0) { + ScrollView { + VStack(spacing: 0) { - CinematicItemViewTopRow(viewModel: viewModel, - wrappedScrollView: wrappedScrollView, - title: viewModel.item.name ?? "", - subtitle: nil) - .focusSection() - .frame(height: UIScreen.main.bounds.height - 10) + CinematicItemViewTopRow( + viewModel: viewModel, + wrappedScrollView: wrappedScrollView, + title: viewModel.item.name ?? "", + subtitle: nil + ) + .focusSection() + .frame(height: UIScreen.main.bounds.height - 10) - ZStack(alignment: .topLeading) { + ZStack(alignment: .topLeading) { - Color.black.ignoresSafeArea() + Color.black.ignoresSafeArea() - VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 20) { - CinematicItemAboutView(viewModel: viewModel) + CinematicItemAboutView(viewModel: viewModel) - if !viewModel.similarItems.isEmpty { - PortraitItemsRowView(rowTitle: L10n.recommended, - items: viewModel.similarItems, - showItemTitles: showPosterLabels) { item in - itemRouter.route(to: \.item, item) - } - } + if !viewModel.similarItems.isEmpty { + PortraitItemsRowView( + rowTitle: L10n.recommended, + items: viewModel.similarItems, + showItemTitles: showPosterLabels + ) { item in + itemRouter.route(to: \.item, item) + } + } - ItemDetailsView(viewModel: viewModel) - } - .padding(.top, 50) - } - } - } - .introspectScrollView { scrollView in - wrappedScrollView = scrollView - } - .ignoresSafeArea() - } - } + ItemDetailsView(viewModel: viewModel) + } + .padding(.top, 50) + } + } + } + .introspectScrollView { scrollView in + wrappedScrollView = scrollView + } + .ignoresSafeArea() + } + } } diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeasonItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeasonItemView.swift index aec16c2a..582ba340 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeasonItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeasonItemView.swift @@ -11,74 +11,82 @@ import SwiftUI struct CinematicSeasonItemView: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: SeasonItemViewModel - @State - var wrappedScrollView: UIScrollView? - @Default(.showPosterLabels) - var showPosterLabels + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: SeasonItemViewModel + @State + var wrappedScrollView: UIScrollView? + @Default(.showPosterLabels) + var showPosterLabels - var body: some View { - ZStack { + var body: some View { + ZStack { - ImageView(viewModel.item.getBackdropImage(maxWidth: 1920), blurHash: viewModel.item.getBackdropImageBlurHash()) - .ignoresSafeArea() + ImageView(viewModel.item.getBackdropImage(maxWidth: 1920), blurHash: viewModel.item.getBackdropImageBlurHash()) + .ignoresSafeArea() - ScrollView { - VStack(spacing: 0) { + ScrollView { + VStack(spacing: 0) { - if let seriesItem = viewModel.seriesItem { - CinematicItemViewTopRow(viewModel: viewModel, - wrappedScrollView: wrappedScrollView, - title: viewModel.item.name ?? "", - subtitle: seriesItem.name) - .focusSection() - .frame(height: UIScreen.main.bounds.height - 10) - } else { - CinematicItemViewTopRow(viewModel: viewModel, - wrappedScrollView: wrappedScrollView, - title: viewModel.item.name ?? "") - .focusSection() - .frame(height: UIScreen.main.bounds.height - 10) - } + if let seriesItem = viewModel.seriesItem { + CinematicItemViewTopRow( + viewModel: viewModel, + wrappedScrollView: wrappedScrollView, + title: viewModel.item.name ?? "", + subtitle: seriesItem.name + ) + .focusSection() + .frame(height: UIScreen.main.bounds.height - 10) + } else { + CinematicItemViewTopRow( + viewModel: viewModel, + wrappedScrollView: wrappedScrollView, + title: viewModel.item.name ?? "" + ) + .focusSection() + .frame(height: UIScreen.main.bounds.height - 10) + } - ZStack(alignment: .topLeading) { + ZStack(alignment: .topLeading) { - Color.black.ignoresSafeArea() - .frame(minHeight: UIScreen.main.bounds.height) + Color.black.ignoresSafeArea() + .frame(minHeight: UIScreen.main.bounds.height) - VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 20) { - CinematicItemAboutView(viewModel: viewModel) + CinematicItemAboutView(viewModel: viewModel) - EpisodesRowView(viewModel: viewModel, onlyCurrentSeason: true) - .focusSection() + EpisodesRowView(viewModel: viewModel, onlyCurrentSeason: true) + .focusSection() - if let seriesItem = viewModel.seriesItem { - PortraitItemsRowView(rowTitle: L10n.series, - items: [seriesItem]) { seriesItem in - itemRouter.route(to: \.item, seriesItem) - } - } + if let seriesItem = viewModel.seriesItem { + PortraitItemsRowView( + rowTitle: L10n.series, + items: [seriesItem] + ) { seriesItem in + itemRouter.route(to: \.item, seriesItem) + } + } - if !viewModel.similarItems.isEmpty { - PortraitItemsRowView(rowTitle: L10n.recommended, - items: viewModel.similarItems, - showItemTitles: showPosterLabels) { item in - itemRouter.route(to: \.item, item) - } - } - } - .padding(.vertical, 50) - } - } - } - .introspectScrollView { scrollView in - wrappedScrollView = scrollView - } - .ignoresSafeArea() - } - } + if !viewModel.similarItems.isEmpty { + PortraitItemsRowView( + rowTitle: L10n.recommended, + items: viewModel.similarItems, + showItemTitles: showPosterLabels + ) { item in + itemRouter.route(to: \.item, item) + } + } + } + .padding(.vertical, 50) + } + } + } + .introspectScrollView { scrollView in + wrappedScrollView = scrollView + } + .ignoresSafeArea() + } + } } diff --git a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeriesItemView.swift b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeriesItemView.swift index 06930f7a..7be04155 100644 --- a/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeriesItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CinematicItemView/CinematicSeriesItemView.swift @@ -11,62 +11,68 @@ import SwiftUI struct CinematicSeriesItemView: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: SeriesItemViewModel - @State - var wrappedScrollView: UIScrollView? - @Default(.showPosterLabels) - var showPosterLabels + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: SeriesItemViewModel + @State + var wrappedScrollView: UIScrollView? + @Default(.showPosterLabels) + var showPosterLabels - var body: some View { - ZStack { + var body: some View { + ZStack { - ImageView(viewModel.item.getBackdropImage(maxWidth: 1920), blurHash: viewModel.item.getBackdropImageBlurHash()) - .ignoresSafeArea() + ImageView(viewModel.item.getBackdropImage(maxWidth: 1920), blurHash: viewModel.item.getBackdropImageBlurHash()) + .ignoresSafeArea() - ScrollView { - VStack(spacing: 0) { + ScrollView { + VStack(spacing: 0) { - CinematicItemViewTopRow(viewModel: viewModel, - wrappedScrollView: wrappedScrollView, - title: viewModel.item.name ?? "", - subtitle: nil) - .focusSection() - .frame(height: UIScreen.main.bounds.height - 10) + CinematicItemViewTopRow( + viewModel: viewModel, + wrappedScrollView: wrappedScrollView, + title: viewModel.item.name ?? "", + subtitle: nil + ) + .focusSection() + .frame(height: UIScreen.main.bounds.height - 10) - ZStack(alignment: .topLeading) { + ZStack(alignment: .topLeading) { - Color.black.ignoresSafeArea() - .frame(minHeight: UIScreen.main.bounds.height) + Color.black.ignoresSafeArea() + .frame(minHeight: UIScreen.main.bounds.height) - VStack(alignment: .leading, spacing: 20) { + VStack(alignment: .leading, spacing: 20) { - CinematicItemAboutView(viewModel: viewModel) + CinematicItemAboutView(viewModel: viewModel) - PortraitItemsRowView(rowTitle: L10n.seasons, - items: viewModel.seasons, - showItemTitles: showPosterLabels) { season in - itemRouter.route(to: \.item, season) - } + PortraitItemsRowView( + rowTitle: L10n.seasons, + items: viewModel.seasons, + showItemTitles: showPosterLabels + ) { season in + itemRouter.route(to: \.item, season) + } - if !viewModel.similarItems.isEmpty { - PortraitItemsRowView(rowTitle: L10n.recommended, - items: viewModel.similarItems, - showItemTitles: showPosterLabels) { item in - itemRouter.route(to: \.item, item) - } - } - } - .padding(.vertical, 50) - } - } - } - .introspectScrollView { scrollView in - wrappedScrollView = scrollView - } - .ignoresSafeArea() - } - } + if !viewModel.similarItems.isEmpty { + PortraitItemsRowView( + rowTitle: L10n.recommended, + items: viewModel.similarItems, + showItemTitles: showPosterLabels + ) { item in + itemRouter.route(to: \.item, item) + } + } + } + .padding(.vertical, 50) + } + } + } + .introspectScrollView { scrollView in + wrappedScrollView = scrollView + } + .ignoresSafeArea() + } + } } diff --git a/Swiftfin tvOS/Views/ItemView/CompactItemView/EpisodeItemView.swift b/Swiftfin tvOS/Views/ItemView/CompactItemView/EpisodeItemView.swift index e50c2698..36bb84cc 100644 --- a/Swiftfin tvOS/Views/ItemView/CompactItemView/EpisodeItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CompactItemView/EpisodeItemView.swift @@ -11,151 +11,153 @@ import SwiftUI struct EpisodeItemView: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: EpisodeItemViewModel + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: EpisodeItemViewModel - @State - var actors: [BaseItemPerson] = [] - @State - var studio: String? - @State - var director: String? + @State + var actors: [BaseItemPerson] = [] + @State + var studio: String? + @State + var director: String? - func onAppear() { - actors = [] - director = nil - studio = nil - var actor_index = 0 - viewModel.item.people?.forEach { person in - if person.type == "Actor" { - if actor_index < 4 { - actors.append(person) - } - actor_index = actor_index + 1 - } - if person.type == "Director" { - director = person.name ?? "" - } - } + func onAppear() { + actors = [] + director = nil + studio = nil + var actor_index = 0 + viewModel.item.people?.forEach { person in + if person.type == "Actor" { + if actor_index < 4 { + actors.append(person) + } + actor_index = actor_index + 1 + } + if person.type == "Director" { + director = person.name ?? "" + } + } - studio = viewModel.item.studios?.first?.name ?? nil - } + studio = viewModel.item.studios?.first?.name ?? nil + } - var body: some View { - ZStack { - ImageView(viewModel.item.getBackdropImage(maxWidth: 1920), blurHash: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.4) - .ignoresSafeArea() - LazyVStack(alignment: .leading) { - Text(viewModel.item.name ?? "") - .font(.title) - .fontWeight(.bold) - .foregroundColor(.primary) - Text(viewModel.item.seriesName ?? "") - .fontWeight(.bold) - .foregroundColor(.primary) - HStack { - if viewModel.item.productionYear != nil { - Text(String(viewModel.item.productionYear!)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - if let runtime = viewModel.item.getItemRuntime() { - Text(runtime).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } + var body: some View { + ZStack { + ImageView(viewModel.item.getBackdropImage(maxWidth: 1920), blurHash: viewModel.item.getBackdropImageBlurHash()) + .opacity(0.4) + .ignoresSafeArea() + LazyVStack(alignment: .leading) { + Text(viewModel.item.name ?? "") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.primary) + Text(viewModel.item.seriesName ?? "") + .fontWeight(.bold) + .foregroundColor(.primary) + HStack { + if viewModel.item.productionYear != nil { + Text(String(viewModel.item.productionYear!)).font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + if let runtime = viewModel.item.getItemRuntime() { + Text(runtime).font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } - if viewModel.item.officialRating != nil { - Text(viewModel.item.officialRating!).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - Spacer() - }.padding(.top, -15) + if viewModel.item.officialRating != nil { + Text(viewModel.item.officialRating!).font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .lineLimit(1) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1) + ) + } + Spacer() + }.padding(.top, -15) - HStack(alignment: .top) { - VStack(alignment: .trailing) { - if studio != nil { - L10n.studio.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - Text(studio!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(.bottom, 40) - } + HStack(alignment: .top) { + VStack(alignment: .trailing) { + if studio != nil { + L10n.studio.text + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.primary) + Text(studio!) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .padding(.bottom, 40) + } - if director != nil { - L10n.director.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - Text(director!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(.bottom, 40) - } + if director != nil { + L10n.director.text + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.primary) + Text(director!) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .padding(.bottom, 40) + } - if !actors.isEmpty { - L10n.cast.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - ForEach(actors, id: \.id) { person in - Text(person.name!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - } - } - Spacer() - } - VStack(alignment: .leading) { - Text(viewModel.item.overview ?? "") - .font(.body) - .fontWeight(.medium) - .foregroundColor(.primary) + if !actors.isEmpty { + L10n.cast.text + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.primary) + ForEach(actors, id: \.id) { person in + Text(person.name!) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.secondary) + } + } + Spacer() + } + VStack(alignment: .leading) { + Text(viewModel.item.overview ?? "") + .font(.body) + .fontWeight(.medium) + .foregroundColor(.primary) - MediaPlayButtonRowView(viewModel: viewModel) - .environmentObject(itemRouter) - } - }.padding(.top, 50) + MediaPlayButtonRowView(viewModel: viewModel) + .environmentObject(itemRouter) + } + }.padding(.top, 50) - if !viewModel.similarItems.isEmpty { - L10n.moreLikeThis.text - .font(.headline) - .fontWeight(.semibold) - ScrollView(.horizontal) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(viewModel.similarItems, id: \.id) { similarItem in - Button { - itemRouter.route(to: \.item, similarItem) - } label: { - PortraitItemElement(item: similarItem) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) - .frame(height: 360) - } - Spacer() - Spacer() - }.padding(EdgeInsets(top: 90, leading: 90, bottom: 0, trailing: 90)) - }.onAppear(perform: onAppear) - } + if !viewModel.similarItems.isEmpty { + L10n.moreLikeThis.text + .font(.headline) + .fontWeight(.semibold) + ScrollView(.horizontal) { + LazyHStack { + Spacer().frame(width: 45) + ForEach(viewModel.similarItems, id: \.id) { similarItem in + Button { + itemRouter.route(to: \.item, similarItem) + } label: { + PortraitItemElement(item: similarItem) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) + .frame(height: 360) + } + Spacer() + Spacer() + }.padding(EdgeInsets(top: 90, leading: 90, bottom: 0, trailing: 90)) + }.onAppear(perform: onAppear) + } } diff --git a/Swiftfin tvOS/Views/ItemView/CompactItemView/MovieItemView.swift b/Swiftfin tvOS/Views/ItemView/CompactItemView/MovieItemView.swift index e52498e5..c30c247c 100644 --- a/Swiftfin tvOS/Views/ItemView/CompactItemView/MovieItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CompactItemView/MovieItemView.swift @@ -11,168 +11,170 @@ import SwiftUI struct MovieItemView: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: MovieItemViewModel + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: MovieItemViewModel - @State - var actors: [BaseItemPerson] = [] - @State - var studio: String? - @State - var director: String? - @State - var wrappedScrollView: UIScrollView? + @State + var actors: [BaseItemPerson] = [] + @State + var studio: String? + @State + var director: String? + @State + var wrappedScrollView: UIScrollView? - @Namespace - private var namespace + @Namespace + private var namespace - func onAppear() { - actors = [] - director = nil - studio = nil - var actor_index = 0 - viewModel.item.people?.forEach { person in - if person.type == "Actor" { - if actor_index < 4 { - actors.append(person) - } - actor_index = actor_index + 1 - } - if person.type == "Director" { - director = person.name ?? "" - } - } + func onAppear() { + actors = [] + director = nil + studio = nil + var actor_index = 0 + viewModel.item.people?.forEach { person in + if person.type == "Actor" { + if actor_index < 4 { + actors.append(person) + } + actor_index = actor_index + 1 + } + if person.type == "Director" { + director = person.name ?? "" + } + } - studio = viewModel.item.studios?.first?.name ?? nil - } + studio = viewModel.item.studios?.first?.name ?? nil + } - var body: some View { - ZStack { - ImageView(viewModel.item.getBackdropImage(maxWidth: 1920), blurHash: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.4) - .ignoresSafeArea() - ScrollView { - LazyVStack(alignment: .leading) { - Text(viewModel.item.name ?? "") - .font(.title) - .fontWeight(.bold) - .foregroundColor(.primary) - HStack { - if viewModel.item.productionYear != nil { - Text(String(viewModel.item.productionYear!)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - if let runtime = viewModel.item.getItemRuntime() { - Text(runtime).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - if viewModel.item.officialRating != nil { - Text(viewModel.item.officialRating!).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - } + var body: some View { + ZStack { + ImageView(viewModel.item.getBackdropImage(maxWidth: 1920), blurHash: viewModel.item.getBackdropImageBlurHash()) + .opacity(0.4) + .ignoresSafeArea() + ScrollView { + LazyVStack(alignment: .leading) { + Text(viewModel.item.name ?? "") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.primary) + HStack { + if viewModel.item.productionYear != nil { + Text(String(viewModel.item.productionYear!)).font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + if let runtime = viewModel.item.getItemRuntime() { + Text(runtime).font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + if viewModel.item.officialRating != nil { + Text(viewModel.item.officialRating!).font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .lineLimit(1) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1) + ) + } + } - HStack { - VStack(alignment: .trailing) { - if studio != nil { - L10n.studio.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - Text(studio!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(.bottom, 40) - } + HStack { + VStack(alignment: .trailing) { + if studio != nil { + L10n.studio.text + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.primary) + Text(studio!) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .padding(.bottom, 40) + } - if director != nil { - L10n.director.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - Text(director!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(.bottom, 40) - } + if director != nil { + L10n.director.text + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.primary) + Text(director!) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .padding(.bottom, 40) + } - if !actors.isEmpty { - L10n.cast.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - ForEach(actors, id: \.id) { person in - Text(person.name!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - } - } - Spacer() - } - VStack(alignment: .leading) { - if !(viewModel.item.taglines ?? []).isEmpty { - Text(viewModel.item.taglines?.first ?? "") - .font(.body) - .italic() - .fontWeight(.medium) - .foregroundColor(.primary) - } - Text(viewModel.item.overview ?? "") - .font(.body) - .fontWeight(.medium) - .foregroundColor(.primary) + if !actors.isEmpty { + L10n.cast.text + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.primary) + ForEach(actors, id: \.id) { person in + Text(person.name!) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.secondary) + } + } + Spacer() + } + VStack(alignment: .leading) { + if !(viewModel.item.taglines ?? []).isEmpty { + Text(viewModel.item.taglines?.first ?? "") + .font(.body) + .italic() + .fontWeight(.medium) + .foregroundColor(.primary) + } + Text(viewModel.item.overview ?? "") + .font(.body) + .fontWeight(.medium) + .foregroundColor(.primary) - MediaPlayButtonRowView(viewModel: viewModel, wrappedScrollView: wrappedScrollView) - .padding(.top, 15) - } - }.padding(.top, 50) + MediaPlayButtonRowView(viewModel: viewModel, wrappedScrollView: wrappedScrollView) + .padding(.top, 15) + } + }.padding(.top, 50) - if !viewModel.similarItems.isEmpty { - L10n.moreLikeThis.text - .font(.headline) - .fontWeight(.semibold) - ScrollView(.horizontal) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(viewModel.similarItems, id: \.id) { similarItem in - Button { - itemRouter.route(to: \.item, similarItem) - } label: { - PortraitItemElement(item: similarItem) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) - .frame(height: 360) - } - }.padding(EdgeInsets(top: 90, leading: 90, bottom: 0, trailing: 90)) - }.introspectScrollView { scrollView in - wrappedScrollView = scrollView - } - }.onAppear(perform: onAppear) - .focusScope(namespace) - } + if !viewModel.similarItems.isEmpty { + L10n.moreLikeThis.text + .font(.headline) + .fontWeight(.semibold) + ScrollView(.horizontal) { + LazyHStack { + Spacer().frame(width: 45) + ForEach(viewModel.similarItems, id: \.id) { similarItem in + Button { + itemRouter.route(to: \.item, similarItem) + } label: { + PortraitItemElement(item: similarItem) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) + .frame(height: 360) + } + }.padding(EdgeInsets(top: 90, leading: 90, bottom: 0, trailing: 90)) + }.introspectScrollView { scrollView in + wrappedScrollView = scrollView + } + }.onAppear(perform: onAppear) + .focusScope(namespace) + } } extension UIScrollView { - func scrollToTop() { - let desiredOffset = CGPoint(x: 0, y: 0) - setContentOffset(desiredOffset, animated: true) - } + func scrollToTop() { + let desiredOffset = CGPoint(x: 0, y: 0) + setContentOffset(desiredOffset, animated: true) + } } diff --git a/Swiftfin tvOS/Views/ItemView/CompactItemView/SeasonItemView.swift b/Swiftfin tvOS/Views/ItemView/CompactItemView/SeasonItemView.swift index b01aba83..4c05691a 100644 --- a/Swiftfin tvOS/Views/ItemView/CompactItemView/SeasonItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CompactItemView/SeasonItemView.swift @@ -11,121 +11,129 @@ import SwiftUI struct SeasonItemView: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: SeasonItemViewModel - @State - var wrappedScrollView: UIScrollView? + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: SeasonItemViewModel + @State + var wrappedScrollView: UIScrollView? - @Environment(\.resetFocus) - var resetFocus - @Namespace - private var namespace + @Environment(\.resetFocus) + var resetFocus + @Namespace + private var namespace - var body: some View { - ZStack { - ImageView(viewModel.item.getSeriesBackdropImage(maxWidth: 1920), blurHash: viewModel.item.getSeriesBackdropImageBlurHash()) - .opacity(0.4) - .ignoresSafeArea() - ScrollView { - LazyVStack(alignment: .leading) { - Text("\(viewModel.item.seriesName ?? "") • \(viewModel.item.name ?? "")") - .font(.title) - .fontWeight(.bold) - .foregroundColor(.primary) - HStack { - if viewModel.item.productionYear != nil { - Text(String(viewModel.item.productionYear!)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - if viewModel.item.officialRating != nil { - Text(viewModel.item.officialRating!).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - if viewModel.item.communityRating != nil { - HStack { - Image(systemName: "star.fill") - .foregroundColor(.yellow) - .font(.subheadline) - Text(String(viewModel.item.communityRating!)).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - } + var body: some View { + ZStack { + ImageView(viewModel.item.getSeriesBackdropImage(maxWidth: 1920), blurHash: viewModel.item.getSeriesBackdropImageBlurHash()) + .opacity(0.4) + .ignoresSafeArea() + ScrollView { + LazyVStack(alignment: .leading) { + Text("\(viewModel.item.seriesName ?? "") • \(viewModel.item.name ?? "")") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.primary) + HStack { + if viewModel.item.productionYear != nil { + Text(String(viewModel.item.productionYear!)).font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + if viewModel.item.officialRating != nil { + Text(viewModel.item.officialRating!).font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .lineLimit(1) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1) + ) + } + if viewModel.item.communityRating != nil { + HStack { + Image(systemName: "star.fill") + .foregroundColor(.yellow) + .font(.subheadline) + Text(String(viewModel.item.communityRating!)).font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } - VStack(alignment: .leading) { - if !(viewModel.item.taglines ?? []).isEmpty { - Text(viewModel.item.taglines?.first ?? "") - .font(.body) - .italic() - .fontWeight(.medium) - .foregroundColor(.primary) - } - Text(viewModel.item.overview ?? "") - .font(.body) - .fontWeight(.medium) - .foregroundColor(.primary) + VStack(alignment: .leading) { + if !(viewModel.item.taglines ?? []).isEmpty { + Text(viewModel.item.taglines?.first ?? "") + .font(.body) + .italic() + .fontWeight(.medium) + .foregroundColor(.primary) + } + Text(viewModel.item.overview ?? "") + .font(.body) + .fontWeight(.medium) + .foregroundColor(.primary) - HStack { - VStack { - Button { - viewModel.updateFavoriteState() - } label: { - MediaViewActionButton(icon: "heart.fill", scrollView: $wrappedScrollView, - iconColor: viewModel.isFavorited ? .red : .white) - }.prefersDefaultFocus(in: namespace) - Text(viewModel.isFavorited ? "Unfavorite" : "Favorite") - .font(.caption) - } + HStack { + VStack { + Button { + viewModel.updateFavoriteState() + } label: { + MediaViewActionButton( + icon: "heart.fill", + scrollView: $wrappedScrollView, + iconColor: viewModel.isFavorited ? .red : .white + ) + }.prefersDefaultFocus(in: namespace) + Text(viewModel.isFavorited ? "Unfavorite" : "Favorite") + .font(.caption) + } - VStack { - Button { - viewModel.updateWatchState() - } label: { - MediaViewActionButton(icon: "eye.fill", scrollView: $wrappedScrollView, - iconColor: viewModel.isWatched ? .red : .white) - } - Text(viewModel.isWatched ? "Unwatch" : "Mark Watched") - .font(.caption) - } - }.padding(.top, 15) - Spacer() - }.padding(.top, 50) + VStack { + Button { + viewModel.updateWatchState() + } label: { + MediaViewActionButton( + icon: "eye.fill", + scrollView: $wrappedScrollView, + iconColor: viewModel.isWatched ? .red : .white + ) + } + Text(viewModel.isWatched ? "Unwatch" : "Mark Watched") + .font(.caption) + } + }.padding(.top, 15) + Spacer() + }.padding(.top, 50) - if !viewModel.episodes.isEmpty { - L10n.episodes.text - .font(.headline) - .fontWeight(.semibold) - ScrollView(.horizontal) { - LazyHStack { - Spacer().frame(width: 45) + if !viewModel.episodes.isEmpty { + L10n.episodes.text + .font(.headline) + .fontWeight(.semibold) + ScrollView(.horizontal) { + LazyHStack { + Spacer().frame(width: 45) - ForEach(viewModel.episodes, id: \.id) { episode in + ForEach(viewModel.episodes, id: \.id) { episode in - Button { - itemRouter.route(to: \.item, episode) - } label: { - LandscapeItemElement(item: episode, inSeasonView: true) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) - .frame(height: 360) - } - }.padding(EdgeInsets(top: 90, leading: 90, bottom: 45, trailing: 90)) - } - } - } + Button { + itemRouter.route(to: \.item, episode) + } label: { + LandscapeItemElement(item: episode, inSeasonView: true) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) + .frame(height: 360) + } + }.padding(EdgeInsets(top: 90, leading: 90, bottom: 45, trailing: 90)) + } + } + } } diff --git a/Swiftfin tvOS/Views/ItemView/CompactItemView/SeriesItemView.swift b/Swiftfin tvOS/Views/ItemView/CompactItemView/SeriesItemView.swift index b9752602..86886313 100644 --- a/Swiftfin tvOS/Views/ItemView/CompactItemView/SeriesItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/CompactItemView/SeriesItemView.swift @@ -11,182 +11,184 @@ import SwiftUI struct SeriesItemView: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: SeriesItemViewModel + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: SeriesItemViewModel - @State - var actors: [BaseItemPerson] = [] - @State - var studio: String? - @State - var director: String? + @State + var actors: [BaseItemPerson] = [] + @State + var studio: String? + @State + var director: String? - @State - var wrappedScrollView: UIScrollView? + @State + var wrappedScrollView: UIScrollView? - @Environment(\.resetFocus) - var resetFocus - @Namespace - private var namespace + @Environment(\.resetFocus) + var resetFocus + @Namespace + private var namespace - func onAppear() { - actors = [] - director = nil - studio = nil - var actor_index = 0 - viewModel.item.people?.forEach { person in - if person.type == "Actor" { - if actor_index < 4 { - actors.append(person) - } - actor_index = actor_index + 1 - } - if person.type == "Director" { - director = person.name ?? "" - } - } + func onAppear() { + actors = [] + director = nil + studio = nil + var actor_index = 0 + viewModel.item.people?.forEach { person in + if person.type == "Actor" { + if actor_index < 4 { + actors.append(person) + } + actor_index = actor_index + 1 + } + if person.type == "Director" { + director = person.name ?? "" + } + } - studio = viewModel.item.studios?.first?.name ?? nil - } + studio = viewModel.item.studios?.first?.name ?? nil + } - var body: some View { - ZStack { - ImageView(viewModel.item.getBackdropImage(maxWidth: 1920), blurHash: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.4) - .ignoresSafeArea() - ScrollView { - LazyVStack(alignment: .leading) { - Text(viewModel.item.name ?? "") - .font(.title) - .fontWeight(.bold) - .foregroundColor(.primary) - HStack { - if viewModel.item.officialRating != nil { - Text(viewModel.item.officialRating!).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - if viewModel.item.communityRating != nil { - HStack { - Image(systemName: "star.fill") - .foregroundColor(.yellow) - .font(.subheadline) - Text(String(viewModel.item.communityRating!)).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - } + var body: some View { + ZStack { + ImageView(viewModel.item.getBackdropImage(maxWidth: 1920), blurHash: viewModel.item.getBackdropImageBlurHash()) + .opacity(0.4) + .ignoresSafeArea() + ScrollView { + LazyVStack(alignment: .leading) { + Text(viewModel.item.name ?? "") + .font(.title) + .fontWeight(.bold) + .foregroundColor(.primary) + HStack { + if viewModel.item.officialRating != nil { + Text(viewModel.item.officialRating!).font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .lineLimit(1) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1) + ) + } + if viewModel.item.communityRating != nil { + HStack { + Image(systemName: "star.fill") + .foregroundColor(.yellow) + .font(.subheadline) + Text(String(viewModel.item.communityRating!)).font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } - HStack { - VStack(alignment: .trailing) { - if studio != nil { - L10n.studio.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - Text(studio!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(.bottom, 40) - } + HStack { + VStack(alignment: .trailing) { + if studio != nil { + L10n.studio.text + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.primary) + Text(studio!) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .padding(.bottom, 40) + } - if director != nil { - L10n.director.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - Text(director!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(.bottom, 40) - } + if director != nil { + L10n.director.text + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.primary) + Text(director!) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .padding(.bottom, 40) + } - if !actors.isEmpty { - L10n.cast.text - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.primary) - ForEach(actors, id: \.id) { person in - Text(person.name!) - .font(.body) - .fontWeight(.semibold) - .foregroundColor(.secondary) - } - } - Spacer() - } - VStack(alignment: .leading) { - if !(viewModel.item.taglines ?? []).isEmpty { - Text(viewModel.item.taglines?.first ?? "") - .font(.body) - .italic() - .fontWeight(.medium) - .foregroundColor(.primary) - } - Text(viewModel.item.overview ?? "") - .font(.body) - .fontWeight(.medium) - .foregroundColor(.primary) + if !actors.isEmpty { + L10n.cast.text + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.primary) + ForEach(actors, id: \.id) { person in + Text(person.name!) + .font(.body) + .fontWeight(.semibold) + .foregroundColor(.secondary) + } + } + Spacer() + } + VStack(alignment: .leading) { + if !(viewModel.item.taglines ?? []).isEmpty { + Text(viewModel.item.taglines?.first ?? "") + .font(.body) + .italic() + .fontWeight(.medium) + .foregroundColor(.primary) + } + Text(viewModel.item.overview ?? "") + .font(.body) + .fontWeight(.medium) + .foregroundColor(.primary) - MediaPlayButtonRowView(viewModel: viewModel, wrappedScrollView: wrappedScrollView) - .padding(.top, 15) - Spacer() - } - }.padding(.top, 50) - if !viewModel.seasons.isEmpty { - L10n.seasons.text - .font(.headline) - .fontWeight(.semibold) - ScrollView(.horizontal) { - LazyHStack { - Spacer().frame(width: 45) + MediaPlayButtonRowView(viewModel: viewModel, wrappedScrollView: wrappedScrollView) + .padding(.top, 15) + Spacer() + } + }.padding(.top, 50) + if !viewModel.seasons.isEmpty { + L10n.seasons.text + .font(.headline) + .fontWeight(.semibold) + ScrollView(.horizontal) { + LazyHStack { + Spacer().frame(width: 45) - ForEach(viewModel.seasons, id: \.id) { season in - Button { - itemRouter.route(to: \.item, season) - } label: { - PortraitItemElement(item: season) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) - .frame(height: 360) - } + ForEach(viewModel.seasons, id: \.id) { season in + Button { + itemRouter.route(to: \.item, season) + } label: { + PortraitItemElement(item: season) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) + .frame(height: 360) + } - if !viewModel.similarItems.isEmpty { - L10n.moreLikeThis.text - .font(.headline) - .fontWeight(.semibold) - ScrollView(.horizontal) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(viewModel.similarItems, id: \.id) { similarItems in - NavigationLink(destination: ItemView(item: similarItems)) { - PortraitItemElement(item: similarItems) - }.buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) - .frame(height: 360) - } - }.padding(EdgeInsets(top: 90, leading: 90, bottom: 45, trailing: 90)) - }.focusScope(namespace) - .introspectScrollView { scrollView in - wrappedScrollView = scrollView - } - }.onAppear(perform: onAppear) - } + if !viewModel.similarItems.isEmpty { + L10n.moreLikeThis.text + .font(.headline) + .fontWeight(.semibold) + ScrollView(.horizontal) { + LazyHStack { + Spacer().frame(width: 45) + ForEach(viewModel.similarItems, id: \.id) { similarItems in + NavigationLink(destination: ItemView(item: similarItems)) { + PortraitItemElement(item: similarItems) + }.buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.padding(EdgeInsets(top: -30, leading: -90, bottom: 0, trailing: -90)) + .frame(height: 360) + } + }.padding(EdgeInsets(top: 90, leading: 90, bottom: 45, trailing: 90)) + }.focusScope(namespace) + .introspectScrollView { scrollView in + wrappedScrollView = scrollView + } + }.onAppear(perform: onAppear) + } } diff --git a/Swiftfin tvOS/Views/ItemView/ItemView.swift b/Swiftfin tvOS/Views/ItemView/ItemView.swift index 1667deec..5f51f3e3 100644 --- a/Swiftfin tvOS/Views/ItemView/ItemView.swift +++ b/Swiftfin tvOS/Views/ItemView/ItemView.swift @@ -13,60 +13,60 @@ import SwiftUI // Useless view necessary in tvOS because of iOS's implementation struct ItemNavigationView: View { - private let item: BaseItemDto + private let item: BaseItemDto - init(item: BaseItemDto) { - self.item = item - } + init(item: BaseItemDto) { + self.item = item + } - var body: some View { - ItemView(item: item) - } + var body: some View { + ItemView(item: item) + } } struct ItemView: View { - @Default(.tvOSCinematicViews) - var tvOSCinematicViews + @Default(.tvOSCinematicViews) + var tvOSCinematicViews - private var item: BaseItemDto + private var item: BaseItemDto - init(item: BaseItemDto) { - self.item = item - } + init(item: BaseItemDto) { + self.item = item + } - var body: some View { - Group { - switch item.itemType { - case .movie: - if tvOSCinematicViews { - CinematicMovieItemView(viewModel: MovieItemViewModel(item: item)) - } else { - MovieItemView(viewModel: MovieItemViewModel(item: item)) - } - case .episode: - if tvOSCinematicViews { - CinematicEpisodeItemView(viewModel: EpisodeItemViewModel(item: item)) - } else { - EpisodeItemView(viewModel: EpisodeItemViewModel(item: item)) - } - case .season: - if tvOSCinematicViews { - CinematicSeasonItemView(viewModel: SeasonItemViewModel(item: item)) - } else { - SeasonItemView(viewModel: .init(item: item)) - } - case .series: - if tvOSCinematicViews { - CinematicSeriesItemView(viewModel: SeriesItemViewModel(item: item)) - } else { - SeriesItemView(viewModel: SeriesItemViewModel(item: item)) - } - case .boxset, .folder: - CinematicCollectionItemView(viewModel: CollectionItemViewModel(item: item)) - default: - Text(L10n.notImplementedYetWithType(item.type ?? "")) - } - } - } + var body: some View { + Group { + switch item.itemType { + case .movie: + if tvOSCinematicViews { + CinematicMovieItemView(viewModel: MovieItemViewModel(item: item)) + } else { + MovieItemView(viewModel: MovieItemViewModel(item: item)) + } + case .episode: + if tvOSCinematicViews { + CinematicEpisodeItemView(viewModel: EpisodeItemViewModel(item: item)) + } else { + EpisodeItemView(viewModel: EpisodeItemViewModel(item: item)) + } + case .season: + if tvOSCinematicViews { + CinematicSeasonItemView(viewModel: SeasonItemViewModel(item: item)) + } else { + SeasonItemView(viewModel: .init(item: item)) + } + case .series: + if tvOSCinematicViews { + CinematicSeriesItemView(viewModel: SeriesItemViewModel(item: item)) + } else { + SeriesItemView(viewModel: SeriesItemViewModel(item: item)) + } + case .boxset, .folder: + CinematicCollectionItemView(viewModel: CollectionItemViewModel(item: item)) + default: + Text(L10n.notImplementedYetWithType(item.type ?? "")) + } + } + } } diff --git a/Swiftfin tvOS/Views/LatestMediaView.swift b/Swiftfin tvOS/Views/LatestMediaView.swift index 07a3af34..0f1d9b53 100644 --- a/Swiftfin tvOS/Views/LatestMediaView.swift +++ b/Swiftfin tvOS/Views/LatestMediaView.swift @@ -12,69 +12,76 @@ import SwiftUI struct LatestMediaView: View { - @EnvironmentObject - var homeRouter: HomeCoordinator.Router - @StateObject - var viewModel: LatestMediaViewModel - @Default(.showPosterLabels) - var showPosterLabels + @EnvironmentObject + var homeRouter: HomeCoordinator.Router + @StateObject + var viewModel: LatestMediaViewModel + @Default(.showPosterLabels) + var showPosterLabels - var body: some View { - VStack(alignment: .leading) { + var body: some View { + VStack(alignment: .leading) { - L10n.latestWithString(viewModel.library.name ?? "").text - .font(.title3) - .padding(.horizontal, 50) + L10n.latestWithString(viewModel.library.name ?? "").text + .font(.title3) + .padding(.horizontal, 50) - ScrollView(.horizontal) { - HStack(alignment: .top) { - ForEach(viewModel.items, id: \.self) { item in + ScrollView(.horizontal) { + HStack(alignment: .top) { + ForEach(viewModel.items, id: \.self) { item in - VStack(spacing: 15) { - Button { - homeRouter.route(to: \.modalItem, item) - } label: { - ImageView(item.portraitHeaderViewURL(maxWidth: 257)) - .frame(width: 257, height: 380) - } - .frame(height: 380) - .buttonStyle(PlainButtonStyle()) + VStack(spacing: 15) { + Button { + homeRouter.route(to: \.modalItem, item) + } label: { + ImageView(item.portraitHeaderViewURL(maxWidth: 257)) + .frame(width: 257, height: 380) + } + .frame(height: 380) + .buttonStyle(PlainButtonStyle()) - if showPosterLabels { - Text(item.title) - .lineLimit(2) - .frame(width: 257) - } - } - } + if showPosterLabels { + Text(item.title) + .lineLimit(2) + .frame(width: 257) + } + } + } - Button { - homeRouter.route(to: \.library, (viewModel: .init(parentID: viewModel.library.id!, - filters: LibraryFilters(filters: [], sortOrder: [.descending], - sortBy: [.dateAdded])), - title: viewModel.library.name ?? "")) - } label: { - ZStack { - Color(UIColor.darkGray) - .opacity(0.5) + Button { + homeRouter.route(to: \.library, ( + viewModel: .init( + parentID: viewModel.library.id!, + filters: LibraryFilters( + filters: [], + sortOrder: [.descending], + sortBy: [.dateAdded] + ) + ), + title: viewModel.library.name ?? "" + )) + } label: { + ZStack { + Color(UIColor.darkGray) + .opacity(0.5) - VStack(spacing: 20) { - Image(systemName: "chevron.right") - .font(.title) + VStack(spacing: 20) { + Image(systemName: "chevron.right") + .font(.title) - L10n.seeAll.text - .font(.title3) - } - } - } - .frame(width: 257, height: 380) - .buttonStyle(PlainButtonStyle()) - } - .padding(.horizontal, 50) - .padding(.vertical) - } - .edgesIgnoringSafeArea(.horizontal) - } - .focusSection() - } + L10n.seeAll.text + .font(.title3) + } + } + } + .frame(width: 257, height: 380) + .buttonStyle(PlainButtonStyle()) + } + .padding(.horizontal, 50) + .padding(.vertical) + } + .edgesIgnoringSafeArea(.horizontal) + } + .focusSection() + } } diff --git a/Swiftfin tvOS/Views/LibraryFilterView.swift b/Swiftfin tvOS/Views/LibraryFilterView.swift index 50af13ab..be385783 100644 --- a/Swiftfin tvOS/Views/LibraryFilterView.swift +++ b/Swiftfin tvOS/Views/LibraryFilterView.swift @@ -12,87 +12,93 @@ import SwiftUI struct LibraryFilterView: View { - @EnvironmentObject - var filterRouter: FilterCoordinator.Router - @Binding - var filters: LibraryFilters - var parentId: String = "" + @EnvironmentObject + var filterRouter: FilterCoordinator.Router + @Binding + var filters: LibraryFilters + var parentId: String = "" - @StateObject - var viewModel: LibraryFilterViewModel + @StateObject + var viewModel: LibraryFilterViewModel - init(filters: Binding, enabledFilterType: [FilterType], parentId: String) { - _filters = filters - self.parentId = parentId - _viewModel = - StateObject(wrappedValue: .init(filters: filters.wrappedValue, enabledFilterType: enabledFilterType, parentId: parentId)) - } + init(filters: Binding, enabledFilterType: [FilterType], parentId: String) { + _filters = filters + self.parentId = parentId + _viewModel = + StateObject(wrappedValue: .init(filters: filters.wrappedValue, enabledFilterType: enabledFilterType, parentId: parentId)) + } - var body: some View { - VStack { - if viewModel.isLoading { - ProgressView() - } else { - Form { - if viewModel.enabledFilterType.contains(.genre) { - MultiSelector(label: L10n.genres, - options: viewModel.possibleGenres, - optionToString: { $0.name ?? "" }, - selected: $viewModel.modifiedFilters.withGenres) - } - if viewModel.enabledFilterType.contains(.filter) { - MultiSelector(label: L10n.filters, - options: viewModel.possibleItemFilters, - optionToString: { $0.localized }, - selected: $viewModel.modifiedFilters.filters) - } - if viewModel.enabledFilterType.contains(.tag) { - MultiSelector(label: L10n.tags, - options: viewModel.possibleTags, - optionToString: { $0 }, - selected: $viewModel.modifiedFilters.tags) - } - if viewModel.enabledFilterType.contains(.sortBy) { - Picker(selection: $viewModel.selectedSortBy, label: L10n.sortBy.text) { - ForEach(viewModel.possibleSortBys, id: \.self) { so in - Text(so.localized).tag(so) - } - } - } - if viewModel.enabledFilterType.contains(.sortOrder) { - Picker(selection: $viewModel.selectedSortOrder, label: L10n.displayOrder.text) { - ForEach(viewModel.possibleSortOrders, id: \.self) { so in - Text(so.rawValue).tag(so) - } - } - } - } - Button { - viewModel.resetFilters() - self.filters = viewModel.modifiedFilters - filterRouter.dismissCoordinator() - } label: { - L10n.reset.text - } - } - } - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - Button { - filterRouter.dismissCoordinator() - } label: { - Image(systemName: "xmark") - } - } - ToolbarItemGroup(placement: .navigationBarTrailing) { - Button { - viewModel.updateModifiedFilter() - self.filters = viewModel.modifiedFilters - filterRouter.dismissCoordinator() - } label: { - L10n.apply.text - } - } - } - } + var body: some View { + VStack { + if viewModel.isLoading { + ProgressView() + } else { + Form { + if viewModel.enabledFilterType.contains(.genre) { + MultiSelector( + label: L10n.genres, + options: viewModel.possibleGenres, + optionToString: { $0.name ?? "" }, + selected: $viewModel.modifiedFilters.withGenres + ) + } + if viewModel.enabledFilterType.contains(.filter) { + MultiSelector( + label: L10n.filters, + options: viewModel.possibleItemFilters, + optionToString: { $0.localized }, + selected: $viewModel.modifiedFilters.filters + ) + } + if viewModel.enabledFilterType.contains(.tag) { + MultiSelector( + label: L10n.tags, + options: viewModel.possibleTags, + optionToString: { $0 }, + selected: $viewModel.modifiedFilters.tags + ) + } + if viewModel.enabledFilterType.contains(.sortBy) { + Picker(selection: $viewModel.selectedSortBy, label: L10n.sortBy.text) { + ForEach(viewModel.possibleSortBys, id: \.self) { so in + Text(so.localized).tag(so) + } + } + } + if viewModel.enabledFilterType.contains(.sortOrder) { + Picker(selection: $viewModel.selectedSortOrder, label: L10n.displayOrder.text) { + ForEach(viewModel.possibleSortOrders, id: \.self) { so in + Text(so.rawValue).tag(so) + } + } + } + } + Button { + viewModel.resetFilters() + self.filters = viewModel.modifiedFilters + filterRouter.dismissCoordinator() + } label: { + L10n.reset.text + } + } + } + .toolbar { + ToolbarItemGroup(placement: .navigationBarLeading) { + Button { + filterRouter.dismissCoordinator() + } label: { + Image(systemName: "xmark") + } + } + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + viewModel.updateModifiedFilter() + self.filters = viewModel.modifiedFilters + filterRouter.dismissCoordinator() + } label: { + L10n.apply.text + } + } + } + } } diff --git a/Swiftfin tvOS/Views/LibraryListView.swift b/Swiftfin tvOS/Views/LibraryListView.swift index 67e4e44c..0dc81467 100644 --- a/Swiftfin tvOS/Views/LibraryListView.swift +++ b/Swiftfin tvOS/Views/LibraryListView.swift @@ -13,69 +13,71 @@ import Stinsen import SwiftUI struct LibraryListView: View { - @EnvironmentObject - var mainCoordinator: MainCoordinator.Router - @EnvironmentObject - var libraryListRouter: LibraryListCoordinator.Router - @StateObject - var viewModel = LibraryListViewModel() + @EnvironmentObject + var mainCoordinator: MainCoordinator.Router + @EnvironmentObject + var libraryListRouter: LibraryListCoordinator.Router + @StateObject + var viewModel = LibraryListViewModel() - @Default(.Experimental.liveTVAlphaEnabled) - var liveTVAlphaEnabled + @Default(.Experimental.liveTVAlphaEnabled) + var liveTVAlphaEnabled - var supportedCollectionTypes: [BaseItemDto.ItemType] { - if liveTVAlphaEnabled { - return [.movie, .season, .series, .liveTV, .boxset, .unknown] - } else { - return [.movie, .season, .series, .boxset, .unknown] - } - } + var supportedCollectionTypes: [BaseItemDto.ItemType] { + if liveTVAlphaEnabled { + return [.movie, .season, .series, .liveTV, .boxset, .unknown] + } else { + return [.movie, .season, .series, .boxset, .unknown] + } + } - var body: some View { - ScrollView { - LazyVStack { - if !viewModel.isLoading { + var body: some View { + ScrollView { + LazyVStack { + if !viewModel.isLoading { - ForEach(viewModel.libraries.filter { [self] library in - let collectionType = library.collectionType ?? "other" - let itemType = BaseItemDto.ItemType(rawValue: collectionType) ?? .unknown - return self.supportedCollectionTypes.contains(itemType) - }, id: \.id) { library in - Button { - let itemType = BaseItemDto.ItemType(rawValue: library.collectionType ?? "other") ?? .unknown - if itemType == .liveTV { - self.mainCoordinator.root(\.liveTV) - } else { - self.libraryListRouter.route(to: \.library, - (viewModel: LibraryViewModel(parentID: library.id), title: library.name ?? "")) - } - } + ForEach(viewModel.libraries.filter { [self] library in + let collectionType = library.collectionType ?? "other" + let itemType = BaseItemDto.ItemType(rawValue: collectionType) ?? .unknown + return self.supportedCollectionTypes.contains(itemType) + }, id: \.id) { library in + Button { + let itemType = BaseItemDto.ItemType(rawValue: library.collectionType ?? "other") ?? .unknown + if itemType == .liveTV { + self.mainCoordinator.root(\.liveTV) + } else { + self.libraryListRouter.route( + to: \.library, + (viewModel: LibraryViewModel(parentID: library.id), title: library.name ?? "") + ) + } + } label: { - ZStack { - HStack { - Spacer() - VStack { - Text(library.name ?? "") - .foregroundColor(.white) - .font(.title2) - .fontWeight(.semibold) - } - Spacer() - }.padding(32) - } - .frame(minWidth: 100, maxWidth: .infinity) - .frame(height: 100) - } - .cornerRadius(10) - .shadow(radius: 5) - .padding(.bottom, 5) - } - } else { - ProgressView() - } - }.padding(.leading, 16) - .padding(.trailing, 16) - .padding(.top, 8) - } - } + ZStack { + HStack { + Spacer() + VStack { + Text(library.name ?? "") + .foregroundColor(.white) + .font(.title2) + .fontWeight(.semibold) + } + Spacer() + }.padding(32) + } + .frame(minWidth: 100, maxWidth: .infinity) + .frame(height: 100) + } + .cornerRadius(10) + .shadow(radius: 5) + .padding(.bottom, 5) + } + } else { + ProgressView() + } + }.padding(.leading, 16) + .padding(.trailing, 16) + .padding(.top, 8) + } + } } diff --git a/Swiftfin tvOS/Views/LibrarySearchView.swift b/Swiftfin tvOS/Views/LibrarySearchView.swift index 6dd7e9b4..64aa6b88 100644 --- a/Swiftfin tvOS/Views/LibrarySearchView.swift +++ b/Swiftfin tvOS/Views/LibrarySearchView.swift @@ -11,97 +11,97 @@ import JellyfinAPI import SwiftUI struct LibrarySearchView: View { - @StateObject - var viewModel: LibrarySearchViewModel - @State - var searchQuery = "" + @StateObject + var viewModel: LibrarySearchViewModel + @State + var searchQuery = "" - @State - private var tracks: [GridItem] = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) + @State + private var tracks: [GridItem] = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) - func recalcTracks() { - tracks = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) - } + func recalcTracks() { + tracks = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) + } - var body: some View { - ZStack { - VStack { - SearchBar(text: $searchQuery) - .padding(.top, 16) - .padding(.bottom, 8) - if searchQuery.isEmpty { - suggestionsListView - } else { - resultView - } - } - if viewModel.isLoading { - ProgressView() - } - } - .onChange(of: searchQuery) { query in - viewModel.searchQuerySubject.send(query) - } - .navigationBarTitle(L10n.search) - } + var body: some View { + ZStack { + VStack { + SearchBar(text: $searchQuery) + .padding(.top, 16) + .padding(.bottom, 8) + if searchQuery.isEmpty { + suggestionsListView + } else { + resultView + } + } + if viewModel.isLoading { + ProgressView() + } + } + .onChange(of: searchQuery) { query in + viewModel.searchQuerySubject.send(query) + } + .navigationBarTitle(L10n.search) + } - var suggestionsListView: some View { - ScrollView { - LazyVStack(spacing: 8) { - L10n.suggestions.text - .font(.headline) - .fontWeight(.bold) - .foregroundColor(.primary) - .padding(.bottom, 8) - ForEach(viewModel.suggestions, id: \.id) { item in - Button { - searchQuery = item.name ?? "" - } label: { - Text(item.name ?? "") - .font(.body) - } - } - } - .padding(.horizontal, 16) - } - } + var suggestionsListView: some View { + ScrollView { + LazyVStack(spacing: 8) { + L10n.suggestions.text + .font(.headline) + .fontWeight(.bold) + .foregroundColor(.primary) + .padding(.bottom, 8) + ForEach(viewModel.suggestions, id: \.id) { item in + Button { + searchQuery = item.name ?? "" + } label: { + Text(item.name ?? "") + .font(.body) + } + } + } + .padding(.horizontal, 16) + } + } - var resultView: some View { - let items = items(for: viewModel.selectedItemType) - return VStack(alignment: .leading, spacing: 16) { - Picker("ItemType", selection: $viewModel.selectedItemType) { - ForEach(viewModel.supportedItemTypeList, id: \.self) { - Text($0.localized) - .tag($0) - } - } - .pickerStyle(SegmentedPickerStyle()) - .padding(.horizontal, 16) - ScrollView { - LazyVStack(alignment: .leading, spacing: 16) { - if !items.isEmpty { - LazyVGrid(columns: tracks) { - ForEach(items, id: \.id) { item in - ItemView(item: item) - } - } - .padding(.bottom, 16) - } - } - } - } - } + var resultView: some View { + let items = items(for: viewModel.selectedItemType) + return VStack(alignment: .leading, spacing: 16) { + Picker("ItemType", selection: $viewModel.selectedItemType) { + ForEach(viewModel.supportedItemTypeList, id: \.self) { + Text($0.localized) + .tag($0) + } + } + .pickerStyle(SegmentedPickerStyle()) + .padding(.horizontal, 16) + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + if !items.isEmpty { + LazyVGrid(columns: tracks) { + ForEach(items, id: \.id) { item in + ItemView(item: item) + } + } + .padding(.bottom, 16) + } + } + } + } + } - func items(for type: ItemType) -> [BaseItemDto] { - switch type { - case .episode: - return viewModel.episodeItems - case .movie: - return viewModel.movieItems - case .series: - return viewModel.showItems - default: - return [] - } - } + func items(for type: ItemType) -> [BaseItemDto] { + switch type { + case .episode: + return viewModel.episodeItems + case .movie: + return viewModel.movieItems + case .series: + return viewModel.showItems + default: + return [] + } + } } diff --git a/Swiftfin tvOS/Views/LibraryView.swift b/Swiftfin tvOS/Views/LibraryView.swift index 5e42872f..23db4df4 100644 --- a/Swiftfin tvOS/Views/LibraryView.swift +++ b/Swiftfin tvOS/Views/LibraryView.swift @@ -11,81 +11,91 @@ import SwiftUI import SwiftUICollection struct LibraryView: View { - @EnvironmentObject - var libraryRouter: LibraryCoordinator.Router - @StateObject - var viewModel: LibraryViewModel - var title: String + @EnvironmentObject + var libraryRouter: LibraryCoordinator.Router + @StateObject + var viewModel: LibraryViewModel + var title: String - // MARK: tracks for grid + // MARK: tracks for grid - var defaultFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name]) + var defaultFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name]) - @State - var isShowingSearchView = false - @State - var isShowingFilterView = false + @State + var isShowingSearchView = false + @State + var isShowingFilterView = false - var body: some View { - if viewModel.rows.isEmpty && viewModel.isLoading == true { - ProgressView() - } else if !viewModel.rows.isEmpty { - CollectionView(rows: viewModel.rows) { _, _ in - let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), - heightDimension: .fractionalHeight(1)) - let item = NSCollectionLayoutItem(layoutSize: itemSize) + var body: some View { + if viewModel.rows.isEmpty && viewModel.isLoading == true { + ProgressView() + } else if !viewModel.rows.isEmpty { + CollectionView(rows: viewModel.rows) { _, _ in + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .fractionalHeight(1) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) - let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(200), - heightDimension: .absolute(300)) - let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, - subitems: [item]) + let groupSize = NSCollectionLayoutSize( + widthDimension: .absolute(200), + heightDimension: .absolute(300) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + subitems: [item] + ) - let header = - NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), - heightDimension: .absolute(44)), - elementKind: UICollectionView.elementKindSectionHeader, - alignment: .topLeading) + let header = + NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(44) + ), + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .topLeading + ) - let section = NSCollectionLayoutSection(group: group) + let section = NSCollectionLayoutSection(group: group) - section.contentInsets = NSDirectionalEdgeInsets(top: 30, leading: 0, bottom: 80, trailing: 80) - section.interGroupSpacing = 48 - section.orthogonalScrollingBehavior = .continuous - section.boundarySupplementaryItems = [header] - return section - } cell: { _, cell in - GeometryReader { _ in - if let item = cell.item { - Button { - libraryRouter.route(to: \.modalItem, item) - } label: { - PortraitItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - .onAppear { - if item == viewModel.items.last && viewModel.hasNextPage { - viewModel.requestNextPageAsync() - } - } - } else if cell.loadingCell { - ProgressView() - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center) - } - } - } supplementaryView: { _, indexPath in - HStack { - Spacer() - }.accessibilityIdentifier("\(indexPath.section).\(indexPath.row)") - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .ignoresSafeArea(.all) - } else { - VStack { - L10n.noResults.text - Button {} label: { - L10n.refresh.text - } - } - } - } + section.contentInsets = NSDirectionalEdgeInsets(top: 30, leading: 0, bottom: 80, trailing: 80) + section.interGroupSpacing = 48 + section.orthogonalScrollingBehavior = .continuous + section.boundarySupplementaryItems = [header] + return section + } cell: { _, cell in + GeometryReader { _ in + if let item = cell.item { + Button { + libraryRouter.route(to: \.modalItem, item) + } label: { + PortraitItemElement(item: item) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + .onAppear { + if item == viewModel.items.last && viewModel.hasNextPage { + viewModel.requestNextPageAsync() + } + } + } else if cell.loadingCell { + ProgressView() + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center) + } + } + } supplementaryView: { _, indexPath in + HStack { + Spacer() + }.accessibilityIdentifier("\(indexPath.section).\(indexPath.row)") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea(.all) + } else { + VStack { + L10n.noResults.text + Button {} label: { + L10n.refresh.text + } + } + } + } } diff --git a/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift b/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift index 90dc1727..38c5d61c 100644 --- a/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift +++ b/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift @@ -10,155 +10,169 @@ import JellyfinAPI import SwiftUI struct LiveTVChannelItemElement: View { - @FocusState - private var focused: Bool - @State - private var loading: Bool = false - @State - private var isFocused: Bool = false + @FocusState + private var focused: Bool + @State + private var loading: Bool = false + @State + private var isFocused: Bool = false - var channel: BaseItemDto - var currentProgram: BaseItemDto? - var currentProgramText: LiveTVChannelViewProgram - var nextProgramsText: [LiveTVChannelViewProgram] - var onSelect: (@escaping (Bool) -> Void) -> Void + var channel: BaseItemDto + var currentProgram: BaseItemDto? + var currentProgramText: LiveTVChannelViewProgram + var nextProgramsText: [LiveTVChannelViewProgram] + var onSelect: (@escaping (Bool) -> Void) -> Void - var progressPercent: Double { - if let currentProgram = currentProgram { - let progressPercent = currentProgram.getLiveProgressPercentage() - if progressPercent > 1.0 { - return 1.0 - } else { - return progressPercent - } - } - return 0 - } + var progressPercent: Double { + if let currentProgram = currentProgram { + let progressPercent = currentProgram.getLiveProgressPercentage() + if progressPercent > 1.0 { + return 1.0 + } else { + return progressPercent + } + } + return 0 + } - private var detailText: String { - guard let program = currentProgram else { - return "" - } - var text = "" - if let season = program.parentIndexNumber, - let episode = program.indexNumber - { - text.append("\(season)x\(episode) ") - } else if let episode = program.indexNumber { - text.append("\(episode) ") - } - if let title = program.episodeTitle { - text.append("\(title) ") - } - if let year = program.productionYear { - text.append("\(year) ") - } - if let rating = program.officialRating { - text.append("\(rating)") - } - return text - } + private var detailText: String { + guard let program = currentProgram else { + return "" + } + var text = "" + if let season = program.parentIndexNumber, + let episode = program.indexNumber + { + text.append("\(season)x\(episode) ") + } else if let episode = program.indexNumber { + text.append("\(episode) ") + } + if let title = program.episodeTitle { + text.append("\(title) ") + } + if let year = program.productionYear { + text.append("\(year) ") + } + if let rating = program.officialRating { + text.append("\(rating)") + } + return text + } - var body: some View { - ZStack { - VStack { - HStack { - Text(channel.number ?? "") - .font(.footnote) - .frame(alignment: .leading) - .padding() - Spacer() - }.frame(alignment: .top) - Spacer() - } + var body: some View { + ZStack { + VStack { + HStack { + Text(channel.number ?? "") + .font(.footnote) + .frame(alignment: .leading) + .padding() + Spacer() + }.frame(alignment: .top) + Spacer() + } - GeometryReader { gp in - VStack { - ImageView(channel.getPrimaryImage(maxWidth: 192)) - .aspectRatio(contentMode: .fit) - .frame(width: 192, alignment: .center) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding(.init(top: 16, leading: 8, bottom: gp.size.height / 2, trailing: 0)) - VStack { - Text(channel.name ?? "?") - .font(.footnote) - .lineLimit(1) - .frame(alignment: .center) - .foregroundColor(Color.jellyfinPurple) - .padding(.init(top: 0, leading: 0, bottom: 8, trailing: 0)) + GeometryReader { gp in + VStack { + ImageView(channel.getPrimaryImage(maxWidth: 192)) + .aspectRatio(contentMode: .fit) + .frame(width: 192, alignment: .center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.init(top: 16, leading: 8, bottom: gp.size.height / 2, trailing: 0)) + VStack { + Text(channel.name ?? "?") + .font(.footnote) + .lineLimit(1) + .frame(alignment: .center) + .foregroundColor(Color.jellyfinPurple) + .padding(.init(top: 0, leading: 0, bottom: 8, trailing: 0)) - programLabel(timeText: currentProgramText.timeDisplay, titleText: currentProgramText.title, - color: Color("TextHighlightColor"), font: Font.system(size: 20, weight: .bold, design: .default)) - if !nextProgramsText.isEmpty, - let nextItem = nextProgramsText[0] - { - programLabel(timeText: nextItem.timeDisplay, titleText: nextItem.title, color: Color.gray, - font: Font.system(size: 20, design: .default)) - } - if nextProgramsText.count > 1, - let nextItem2 = nextProgramsText[1] - { - programLabel(timeText: nextItem2.timeDisplay, titleText: nextItem2.title, color: Color.gray, - font: Font.system(size: 20, design: .default)) - } - } - .frame(maxHeight: .infinity, alignment: .top) - .padding(.init(top: gp.size.height / 2, leading: 16, bottom: 56, trailing: 16)) - .opacity(loading ? 0.5 : 1.0) - } + programLabel( + timeText: currentProgramText.timeDisplay, + titleText: currentProgramText.title, + color: Color("TextHighlightColor"), + font: Font.system(size: 20, weight: .bold, design: .default) + ) + if !nextProgramsText.isEmpty, + let nextItem = nextProgramsText[0] + { + programLabel( + timeText: nextItem.timeDisplay, + titleText: nextItem.title, + color: Color.gray, + font: Font.system(size: 20, design: .default) + ) + } + if nextProgramsText.count > 1, + let nextItem2 = nextProgramsText[1] + { + programLabel( + timeText: nextItem2.timeDisplay, + titleText: nextItem2.title, + color: Color.gray, + font: Font.system(size: 20, design: .default) + ) + } + } + .frame(maxHeight: .infinity, alignment: .top) + .padding(.init(top: gp.size.height / 2, leading: 16, bottom: 56, trailing: 16)) + .opacity(loading ? 0.5 : 1.0) + } - if loading { - ProgressView() - } + if loading { + ProgressView() + } - VStack { - GeometryReader { gp in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 6) - .fill(Color.gray) - .opacity(0.4) - .frame(minWidth: 100, maxWidth: .infinity, minHeight: 8, maxHeight: 8) - RoundedRectangle(cornerRadius: 6) - .fill(Color.jellyfinPurple) - .frame(width: CGFloat(progressPercent * gp.size.width), height: 8) - } - .frame(maxHeight: .infinity, alignment: .bottom) - .padding(.init(top: 0, leading: 16, bottom: 32, trailing: 16)) - } - } - } - .overlay(RoundedRectangle(cornerRadius: 20) - .stroke(isFocused ? Color.blue : Color.clear, lineWidth: 4)) - .cornerRadius(20) - .scaleEffect(isFocused ? 1.1 : 1) - .focusable(true) - .focused($focused) - .onChange(of: focused) { foc in - withAnimation(.linear(duration: 0.15)) { - self.isFocused = foc - } - } - .onLongPressGesture(minimumDuration: 0.01, pressing: { _ in }) { - onSelect { loadingState in - loading = loadingState - } - } - } + VStack { + GeometryReader { gp in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 6) + .fill(Color.gray) + .opacity(0.4) + .frame(minWidth: 100, maxWidth: .infinity, minHeight: 8, maxHeight: 8) + RoundedRectangle(cornerRadius: 6) + .fill(Color.jellyfinPurple) + .frame(width: CGFloat(progressPercent * gp.size.width), height: 8) + } + .frame(maxHeight: .infinity, alignment: .bottom) + .padding(.init(top: 0, leading: 16, bottom: 32, trailing: 16)) + } + } + } + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke(isFocused ? Color.blue : Color.clear, lineWidth: 4) + ) + .cornerRadius(20) + .scaleEffect(isFocused ? 1.1 : 1) + .focusable(true) + .focused($focused) + .onChange(of: focused) { foc in + withAnimation(.linear(duration: 0.15)) { + self.isFocused = foc + } + } + .onLongPressGesture(minimumDuration: 0.01, pressing: { _ in }) { + onSelect { loadingState in + loading = loadingState + } + } + } - @ViewBuilder - func programLabel(timeText: String, titleText: String, color: Color, font: Font) -> some View { - HStack(alignment: .top, spacing: 4) { - Text(timeText) - .font(font) - .lineLimit(1) - .foregroundColor(color) - .frame(width: 54, alignment: .leading) - Text(titleText) - .font(font) - .lineLimit(2) - .foregroundColor(color) - .frame(maxWidth: .infinity, alignment: .leading) - } - } + @ViewBuilder + func programLabel(timeText: String, titleText: String, color: Color, font: Font) -> some View { + HStack(alignment: .top, spacing: 4) { + Text(timeText) + .font(font) + .lineLimit(1) + .foregroundColor(color) + .frame(width: 54, alignment: .leading) + Text(titleText) + .font(font) + .lineLimit(2) + .foregroundColor(color) + .frame(maxWidth: .infinity, alignment: .leading) + } + } } diff --git a/Swiftfin tvOS/Views/LiveTVChannelsView.swift b/Swiftfin tvOS/Views/LiveTVChannelsView.swift index 050f8833..f2aa5efa 100644 --- a/Swiftfin tvOS/Views/LiveTVChannelsView.swift +++ b/Swiftfin tvOS/Views/LiveTVChannelsView.swift @@ -14,138 +14,150 @@ import SwiftUICollection typealias LiveTVChannelViewProgram = (timeDisplay: String, title: String) struct LiveTVChannelsView: View { - @EnvironmentObject - var router: LiveTVChannelsCoordinator.Router - @StateObject - var viewModel = LiveTVChannelsViewModel() + @EnvironmentObject + var router: LiveTVChannelsCoordinator.Router + @StateObject + var viewModel = LiveTVChannelsViewModel() - var body: some View { - if viewModel.isLoading == true { - ProgressView() - } else if !viewModel.rows.isEmpty { - CollectionView(rows: viewModel.rows) { _, _ in - createGridLayout() - } cell: { indexPath, cell in - makeCellView(indexPath: indexPath, cell: cell) - } supplementaryView: { _, indexPath in - EmptyView() - .accessibilityIdentifier("\(indexPath.section).\(indexPath.row)") - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .ignoresSafeArea() - .onAppear { - viewModel.startScheduleCheckTimer() - } - .onDisappear { - viewModel.stopScheduleCheckTimer() - } - } else { - VStack { - Text("No results.") - Button { - viewModel.getChannels() - } label: { - Text("Reload") - } - } - } - } + var body: some View { + if viewModel.isLoading == true { + ProgressView() + } else if !viewModel.rows.isEmpty { + CollectionView(rows: viewModel.rows) { _, _ in + createGridLayout() + } cell: { indexPath, cell in + makeCellView(indexPath: indexPath, cell: cell) + } supplementaryView: { _, indexPath in + EmptyView() + .accessibilityIdentifier("\(indexPath.section).\(indexPath.row)") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + .onAppear { + viewModel.startScheduleCheckTimer() + } + .onDisappear { + viewModel.stopScheduleCheckTimer() + } + } else { + VStack { + Text("No results.") + Button { + viewModel.getChannels() + } label: { + Text("Reload") + } + } + } + } - @ViewBuilder - func makeCellView(indexPath: IndexPath, cell: LiveTVChannelRowCell) -> some View { - let item = cell.item - let channel = item.channel - let currentProgramDisplayText = item.currentProgram? - .programDisplayText(timeFormatter: viewModel.timeFormatter) ?? LiveTVChannelViewProgram(timeDisplay: "", title: "") - let nextItems = item.programs.filter { program in - guard let start = program.startDate else { - return false - } - guard let currentStart = item.currentProgram?.startDate else { - return false - } - return start > currentStart - } - LiveTVChannelItemElement(channel: channel, - currentProgram: item.currentProgram, - currentProgramText: currentProgramDisplayText, - nextProgramsText: nextProgramsDisplayText(nextItems: nextItems, timeFormatter: viewModel.timeFormatter), - onSelect: { loadingAction in - loadingAction(true) - self.viewModel.fetchVideoPlayerViewModel(item: channel) { playerViewModel in - self.router.route(to: \.videoPlayer, playerViewModel) - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - loadingAction(false) - } - } - }) - } + @ViewBuilder + func makeCellView(indexPath: IndexPath, cell: LiveTVChannelRowCell) -> some View { + let item = cell.item + let channel = item.channel + let currentProgramDisplayText = item.currentProgram? + .programDisplayText(timeFormatter: viewModel.timeFormatter) ?? LiveTVChannelViewProgram(timeDisplay: "", title: "") + let nextItems = item.programs.filter { program in + guard let start = program.startDate else { + return false + } + guard let currentStart = item.currentProgram?.startDate else { + return false + } + return start > currentStart + } + LiveTVChannelItemElement( + channel: channel, + currentProgram: item.currentProgram, + currentProgramText: currentProgramDisplayText, + nextProgramsText: nextProgramsDisplayText(nextItems: nextItems, timeFormatter: viewModel.timeFormatter), + onSelect: { loadingAction in + loadingAction(true) + self.viewModel.fetchVideoPlayerViewModel(item: channel) { playerViewModel in + self.router.route(to: \.videoPlayer, playerViewModel) + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + loadingAction(false) + } + } + } + ) + } - private func createGridLayout() -> NSCollectionLayoutSection { - // I don't know why tvOS has a margin on the sides of a collection view - // But it does, even with contentInset = .zero and ignoreSafeArea. - let sideMargin = CGFloat(30) - let itemWidth = (UIScreen.main.bounds.width / 4) - (sideMargin * 2) - let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(itemWidth), - heightDimension: .absolute(itemWidth)) - let item = NSCollectionLayoutItem(layoutSize: itemSize) - item.edgeSpacing = .init(leading: .fixed(8), - top: .fixed(8), - trailing: .fixed(8), - bottom: .fixed(8)) + private func createGridLayout() -> NSCollectionLayoutSection { + // I don't know why tvOS has a margin on the sides of a collection view + // But it does, even with contentInset = .zero and ignoreSafeArea. + let sideMargin = CGFloat(30) + let itemWidth = (UIScreen.main.bounds.width / 4) - (sideMargin * 2) + let itemSize = NSCollectionLayoutSize( + widthDimension: .absolute(itemWidth), + heightDimension: .absolute(itemWidth) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.edgeSpacing = .init( + leading: .fixed(8), + top: .fixed(8), + trailing: .fixed(8), + bottom: .fixed(8) + ) - let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), - heightDimension: .absolute(itemWidth)) - let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, - subitems: [item]) - group.edgeSpacing = .init(leading: .fixed(0), - top: .fixed(16), - trailing: .fixed(0), - bottom: .fixed(16)) - group.contentInsets = .zero + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(itemWidth) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + subitems: [item] + ) + group.edgeSpacing = .init( + leading: .fixed(0), + top: .fixed(16), + trailing: .fixed(0), + bottom: .fixed(16) + ) + group.contentInsets = .zero - let section = NSCollectionLayoutSection(group: group) - section.contentInsets = .zero + let section = NSCollectionLayoutSection(group: group) + section.contentInsets = .zero - return section - } + return section + } - private func nextProgramsDisplayText(nextItems: [BaseItemDto], timeFormatter: DateFormatter) -> [LiveTVChannelViewProgram] { - var programsDisplayText: [LiveTVChannelViewProgram] = [] - for item in nextItems { - programsDisplayText.append(item.programDisplayText(timeFormatter: timeFormatter)) - } - return programsDisplayText - } + private func nextProgramsDisplayText(nextItems: [BaseItemDto], timeFormatter: DateFormatter) -> [LiveTVChannelViewProgram] { + var programsDisplayText: [LiveTVChannelViewProgram] = [] + for item in nextItems { + programsDisplayText.append(item.programDisplayText(timeFormatter: timeFormatter)) + } + return programsDisplayText + } } private extension BaseItemDto { - func programDisplayText(timeFormatter: DateFormatter) -> LiveTVChannelViewProgram { - var timeText = "" - if let start = self.startDate { - timeText.append(timeFormatter.string(from: start) + " ") - } - var displayText = "" - if let season = self.parentIndexNumber, - let episode = self.indexNumber - { - displayText.append("\(season)x\(episode) ") - } else if let episode = self.indexNumber { - displayText.append("\(episode) ") - } - if let name = self.name { - displayText.append("\(name) ") - } - if let title = self.episodeTitle { - displayText.append("\(title) ") - } - if let year = self.productionYear { - displayText.append("\(year) ") - } - if let rating = self.officialRating { - displayText.append("\(rating)") - } + func programDisplayText(timeFormatter: DateFormatter) -> LiveTVChannelViewProgram { + var timeText = "" + if let start = self.startDate { + timeText.append(timeFormatter.string(from: start) + " ") + } + var displayText = "" + if let season = self.parentIndexNumber, + let episode = self.indexNumber + { + displayText.append("\(season)x\(episode) ") + } else if let episode = self.indexNumber { + displayText.append("\(episode) ") + } + if let name = self.name { + displayText.append("\(name) ") + } + if let title = self.episodeTitle { + displayText.append("\(title) ") + } + if let year = self.productionYear { + displayText.append("\(year) ") + } + if let rating = self.officialRating { + displayText.append("\(rating)") + } - return LiveTVChannelViewProgram(timeDisplay: timeText, title: displayText) - } + return LiveTVChannelViewProgram(timeDisplay: timeText, title: displayText) + } } diff --git a/Swiftfin tvOS/Views/LiveTVHomeView.swift b/Swiftfin tvOS/Views/LiveTVHomeView.swift index 83780803..475c64a3 100644 --- a/Swiftfin tvOS/Views/LiveTVHomeView.swift +++ b/Swiftfin tvOS/Views/LiveTVHomeView.swift @@ -10,14 +10,14 @@ import Foundation import SwiftUI struct LiveTVHomeView: View { - @EnvironmentObject - var mainCoordinator: MainCoordinator.Router + @EnvironmentObject + var mainCoordinator: MainCoordinator.Router - var body: some View { - Button {} label: { - Text("Return Home") - }.onAppear { - self.mainCoordinator.root(\.mainTab) - } - } + var body: some View { + Button {} label: { + Text("Return Home") + }.onAppear { + self.mainCoordinator.root(\.mainTab) + } + } } diff --git a/Swiftfin tvOS/Views/LiveTVProgramsView.swift b/Swiftfin tvOS/Views/LiveTVProgramsView.swift index 3a926316..6fadf822 100644 --- a/Swiftfin tvOS/Views/LiveTVProgramsView.swift +++ b/Swiftfin tvOS/Views/LiveTVProgramsView.swift @@ -10,183 +10,183 @@ import Foundation import SwiftUI struct LiveTVProgramsView: View { - @EnvironmentObject - var programsRouter: LiveTVProgramsCoordinator.Router - @StateObject - var viewModel = LiveTVProgramsViewModel() + @EnvironmentObject + var programsRouter: LiveTVProgramsCoordinator.Router + @StateObject + var viewModel = LiveTVProgramsViewModel() - var body: some View { - ScrollView { - LazyVStack(alignment: .leading) { - if !viewModel.recommendedItems.isEmpty, - let items = viewModel.recommendedItems - { - Text("On Now") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - Button { - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } - } label: { - LandscapeItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - } - if !viewModel.seriesItems.isEmpty, - let items = viewModel.seriesItems - { - Text("Shows") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - Button { - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } - } label: { - LandscapeItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - } - if !viewModel.movieItems.isEmpty, - let items = viewModel.movieItems - { - Text("Movies") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - Button { - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } - } label: { - LandscapeItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - } - if !viewModel.sportsItems.isEmpty, - let items = viewModel.sportsItems - { - Text("Sports") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - Button { - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } - } label: { - LandscapeItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - } - if !viewModel.kidsItems.isEmpty, - let items = viewModel.kidsItems - { - Text("Kids") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - Button { - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } - } label: { - LandscapeItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - } - if !viewModel.newsItems.isEmpty, - let items = viewModel.newsItems - { - Text("News") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - Spacer().frame(width: 45) - ForEach(items, id: \.id) { item in - Button { - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } - } label: { - LandscapeItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - } - } - } - } + var body: some View { + ScrollView { + LazyVStack(alignment: .leading) { + if !viewModel.recommendedItems.isEmpty, + let items = viewModel.recommendedItems + { + Text("On Now") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + Spacer().frame(width: 45) + ForEach(items, id: \.id) { item in + Button { + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } + } + } label: { + LandscapeItemElement(item: item) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.frame(height: 350) + } + if !viewModel.seriesItems.isEmpty, + let items = viewModel.seriesItems + { + Text("Shows") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + Spacer().frame(width: 45) + ForEach(items, id: \.id) { item in + Button { + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } + } + } label: { + LandscapeItemElement(item: item) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.frame(height: 350) + } + if !viewModel.movieItems.isEmpty, + let items = viewModel.movieItems + { + Text("Movies") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + Spacer().frame(width: 45) + ForEach(items, id: \.id) { item in + Button { + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } + } + } label: { + LandscapeItemElement(item: item) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.frame(height: 350) + } + if !viewModel.sportsItems.isEmpty, + let items = viewModel.sportsItems + { + Text("Sports") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + Spacer().frame(width: 45) + ForEach(items, id: \.id) { item in + Button { + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } + } + } label: { + LandscapeItemElement(item: item) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.frame(height: 350) + } + if !viewModel.kidsItems.isEmpty, + let items = viewModel.kidsItems + { + Text("Kids") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + Spacer().frame(width: 45) + ForEach(items, id: \.id) { item in + Button { + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } + } + } label: { + LandscapeItemElement(item: item) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.frame(height: 350) + } + if !viewModel.newsItems.isEmpty, + let items = viewModel.newsItems + { + Text("News") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + Spacer().frame(width: 45) + ForEach(items, id: \.id) { item in + Button { + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } + } + } label: { + LandscapeItemElement(item: item) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.frame(height: 350) + } + } + } + } } diff --git a/Swiftfin tvOS/Views/MovieLibrariesView.swift b/Swiftfin tvOS/Views/MovieLibrariesView.swift index 21886620..b378a9d8 100644 --- a/Swiftfin tvOS/Views/MovieLibrariesView.swift +++ b/Swiftfin tvOS/Views/MovieLibrariesView.swift @@ -11,71 +11,81 @@ import SwiftUI import SwiftUICollection struct MovieLibrariesView: View { - @EnvironmentObject - var movieLibrariesRouter: MovieLibrariesCoordinator.Router - @StateObject - var viewModel: MovieLibrariesViewModel - var title: String + @EnvironmentObject + var movieLibrariesRouter: MovieLibrariesCoordinator.Router + @StateObject + var viewModel: MovieLibrariesViewModel + var title: String - var body: some View { - if viewModel.isLoading == true { - ProgressView() - } else if !viewModel.rows.isEmpty { - CollectionView(rows: viewModel.rows) { _, _ in - let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), - heightDimension: .fractionalHeight(1)) - let item = NSCollectionLayoutItem(layoutSize: itemSize) + var body: some View { + if viewModel.isLoading == true { + ProgressView() + } else if !viewModel.rows.isEmpty { + CollectionView(rows: viewModel.rows) { _, _ in + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .fractionalHeight(1) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) - let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(200), - heightDimension: .absolute(300)) - let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, - subitems: [item]) + let groupSize = NSCollectionLayoutSize( + widthDimension: .absolute(200), + heightDimension: .absolute(300) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + subitems: [item] + ) - let header = - NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), - heightDimension: .absolute(44)), - elementKind: UICollectionView.elementKindSectionHeader, - alignment: .topLeading) + let header = + NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(44) + ), + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .topLeading + ) - let section = NSCollectionLayoutSection(group: group) + let section = NSCollectionLayoutSection(group: group) - section.contentInsets = NSDirectionalEdgeInsets(top: 30, leading: 0, bottom: 80, trailing: 80) - section.interGroupSpacing = 48 - section.orthogonalScrollingBehavior = .continuous - section.boundarySupplementaryItems = [header] - return section - } cell: { _, cell in - GeometryReader { _ in - if let item = cell.item { - if item.type != .folder { - Button { - self.movieLibrariesRouter.route(to: \.library, item) - } label: { - PortraitItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - } else if cell.loadingCell { - ProgressView() - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center) - } - } - } supplementaryView: { _, indexPath in - HStack { - Spacer() - }.accessibilityIdentifier("\(indexPath.section).\(indexPath.row)") - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .ignoresSafeArea(.all) - } else { - VStack { - L10n.noResults.text - Button { - print("movieLibraries reload") - } label: { - L10n.refresh.text - } - } - } - } + section.contentInsets = NSDirectionalEdgeInsets(top: 30, leading: 0, bottom: 80, trailing: 80) + section.interGroupSpacing = 48 + section.orthogonalScrollingBehavior = .continuous + section.boundarySupplementaryItems = [header] + return section + } cell: { _, cell in + GeometryReader { _ in + if let item = cell.item { + if item.type != .folder { + Button { + self.movieLibrariesRouter.route(to: \.library, item) + } label: { + PortraitItemElement(item: item) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + } else if cell.loadingCell { + ProgressView() + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center) + } + } + } supplementaryView: { _, indexPath in + HStack { + Spacer() + }.accessibilityIdentifier("\(indexPath.section).\(indexPath.row)") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea(.all) + } else { + VStack { + L10n.noResults.text + Button { + print("movieLibraries reload") + } label: { + L10n.refresh.text + } + } + } + } } diff --git a/Swiftfin tvOS/Views/NextUpView/NextUpCard.swift b/Swiftfin tvOS/Views/NextUpView/NextUpCard.swift index 27ffcc68..50a4324b 100644 --- a/Swiftfin tvOS/Views/NextUpView/NextUpCard.swift +++ b/Swiftfin tvOS/Views/NextUpView/NextUpCard.swift @@ -11,41 +11,41 @@ import SwiftUI struct NextUpCard: View { - @EnvironmentObject - var homeRouter: HomeCoordinator.Router - let item: BaseItemDto + @EnvironmentObject + var homeRouter: HomeCoordinator.Router + let item: BaseItemDto - var body: some View { - VStack(alignment: .leading) { - Button { - homeRouter.route(to: \.modalItem, item) - } label: { - if item.itemType == .episode { - ImageView(item.getSeriesBackdropImage(maxWidth: 500)) - .frame(width: 500, height: 281.25) - } else { - ImageView(item.getBackdropImage(maxWidth: 500)) - .frame(width: 500, height: 281.25) - } - } - .buttonStyle(CardButtonStyle()) - .padding(.top) + var body: some View { + VStack(alignment: .leading) { + Button { + homeRouter.route(to: \.modalItem, item) + } label: { + if item.itemType == .episode { + ImageView(item.getSeriesBackdropImage(maxWidth: 500)) + .frame(width: 500, height: 281.25) + } else { + ImageView(item.getBackdropImage(maxWidth: 500)) + .frame(width: 500, height: 281.25) + } + } + .buttonStyle(CardButtonStyle()) + .padding(.top) - VStack(alignment: .leading) { - Text("\(item.seriesName ?? item.name ?? "")") - .font(.callout) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) + VStack(alignment: .leading) { + Text("\(item.seriesName ?? item.name ?? "")") + .font(.callout) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) - if item.itemType == .episode { - Text(item.getEpisodeLocator() ?? "") - .font(.callout) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - } - } + if item.itemType == .episode { + Text(item.getEpisodeLocator() ?? "") + .font(.callout) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + } } diff --git a/Swiftfin tvOS/Views/NextUpView/NextUpView.swift b/Swiftfin tvOS/Views/NextUpView/NextUpView.swift index 24f3bee7..a814f713 100644 --- a/Swiftfin tvOS/Views/NextUpView/NextUpView.swift +++ b/Swiftfin tvOS/Views/NextUpView/NextUpView.swift @@ -12,26 +12,26 @@ import Stinsen import SwiftUI struct NextUpView: View { - var items: [BaseItemDto] + var items: [BaseItemDto] - var homeRouter: HomeCoordinator.Router? = RouterStore.shared.retrieve() + var homeRouter: HomeCoordinator.Router? = RouterStore.shared.retrieve() - var body: some View { - VStack(alignment: .leading) { + var body: some View { + VStack(alignment: .leading) { - L10n.nextUp.text - .font(.title3) - .fontWeight(.semibold) - .padding(.leading, 50) + L10n.nextUp.text + .font(.title3) + .fontWeight(.semibold) + .padding(.leading, 50) - ScrollView(.horizontal, showsIndicators: false) { - LazyHStack { - ForEach(items, id: \.id) { item in - NextUpCard(item: item) - } - } - .padding(.horizontal, 50) - } - } - } + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack { + ForEach(items, id: \.id) { item in + NextUpCard(item: item) + } + } + .padding(.horizontal, 50) + } + } + } } diff --git a/Swiftfin tvOS/Views/ServerDetailView.swift b/Swiftfin tvOS/Views/ServerDetailView.swift index a04055d8..05ab8fbc 100644 --- a/Swiftfin tvOS/Views/ServerDetailView.swift +++ b/Swiftfin tvOS/Views/ServerDetailView.swift @@ -10,41 +10,41 @@ import SwiftUI struct ServerDetailView: View { - @ObservedObject - var viewModel: ServerDetailViewModel + @ObservedObject + var viewModel: ServerDetailViewModel - var body: some View { - Form { - Section(header: L10n.serverDetails.text) { - HStack { - L10n.name.text - Spacer() - Text(SessionManager.main.currentLogin.server.name) - .foregroundColor(.secondary) - } - .focusable() + var body: some View { + Form { + Section(header: L10n.serverDetails.text) { + HStack { + L10n.name.text + Spacer() + Text(SessionManager.main.currentLogin.server.name) + .foregroundColor(.secondary) + } + .focusable() - HStack { - L10n.url.text - Spacer() - Text(SessionManager.main.currentLogin.server.currentURI) - .foregroundColor(.secondary) - } + HStack { + L10n.url.text + Spacer() + Text(SessionManager.main.currentLogin.server.currentURI) + .foregroundColor(.secondary) + } - HStack { - L10n.version.text - Spacer() - Text(SessionManager.main.currentLogin.server.version) - .foregroundColor(.secondary) - } + HStack { + L10n.version.text + Spacer() + Text(SessionManager.main.currentLogin.server.version) + .foregroundColor(.secondary) + } - HStack { - L10n.operatingSystem.text - Spacer() - Text(SessionManager.main.currentLogin.server.os) - .foregroundColor(.secondary) - } - } - } - } + HStack { + L10n.operatingSystem.text + Spacer() + Text(SessionManager.main.currentLogin.server.os) + .foregroundColor(.secondary) + } + } + } + } } diff --git a/Swiftfin tvOS/Views/ServerListView.swift b/Swiftfin tvOS/Views/ServerListView.swift index b8e98d3e..81f3dbbf 100644 --- a/Swiftfin tvOS/Views/ServerListView.swift +++ b/Swiftfin tvOS/Views/ServerListView.swift @@ -11,120 +11,120 @@ import SwiftUI struct ServerListView: View { - @EnvironmentObject - var serverListRouter: ServerListCoordinator.Router - @ObservedObject - var viewModel: ServerListViewModel + @EnvironmentObject + var serverListRouter: ServerListCoordinator.Router + @ObservedObject + var viewModel: ServerListViewModel - @ViewBuilder - private var listView: some View { - ScrollView { - LazyVStack { - ForEach(viewModel.servers, id: \.id) { server in - Button { - serverListRouter.route(to: \.userList, server) - } label: { - HStack { - Image(systemName: "server.rack") - .font(.system(size: 72)) - .foregroundColor(.primary) + @ViewBuilder + private var listView: some View { + ScrollView { + LazyVStack { + ForEach(viewModel.servers, id: \.id) { server in + Button { + serverListRouter.route(to: \.userList, server) + } label: { + HStack { + Image(systemName: "server.rack") + .font(.system(size: 72)) + .foregroundColor(.primary) - VStack(alignment: .leading, spacing: 5) { - Text(server.name) - .font(.title2) - .foregroundColor(.primary) + VStack(alignment: .leading, spacing: 5) { + Text(server.name) + .font(.title2) + .foregroundColor(.primary) - Text(server.currentURI) - .font(.footnote) - .disabled(true) - .foregroundColor(.secondary) + Text(server.currentURI) + .font(.footnote) + .disabled(true) + .foregroundColor(.secondary) - Text(viewModel.userTextFor(server: server)) - .font(.footnote) - .foregroundColor(.primary) - } + Text(viewModel.userTextFor(server: server)) + .font(.footnote) + .foregroundColor(.primary) + } - Spacer() - } - } - .padding(.horizontal, 100) - .contextMenu { - Button(role: .destructive) { - viewModel.remove(server: server) - } label: { - Label(L10n.remove, systemImage: "trash") - } - } - } - } - .padding(.top, 50) - } - .padding(.top, 50) - } + Spacer() + } + } + .padding(.horizontal, 100) + .contextMenu { + Button(role: .destructive) { + viewModel.remove(server: server) + } label: { + Label(L10n.remove, systemImage: "trash") + } + } + } + } + .padding(.top, 50) + } + .padding(.top, 50) + } - @ViewBuilder - private var noServerView: some View { - VStack { - L10n.connectToJellyfinServerStart.text - .frame(minWidth: 50, maxWidth: 500) - .multilineTextAlignment(.center) - .font(.body) + @ViewBuilder + private var noServerView: some View { + VStack { + L10n.connectToJellyfinServerStart.text + .frame(minWidth: 50, maxWidth: 500) + .multilineTextAlignment(.center) + .font(.body) - Button { - serverListRouter.route(to: \.connectToServer) - } label: { - L10n.connect.text - .bold() - .font(.callout) - .padding(.vertical) - .padding(.horizontal, 30) - .background(Color.jellyfinPurple) - } - .padding(.top, 40) - .buttonStyle(CardButtonStyle()) - } - } + Button { + serverListRouter.route(to: \.connectToServer) + } label: { + L10n.connect.text + .bold() + .font(.callout) + .padding(.vertical) + .padding(.horizontal, 30) + .background(Color.jellyfinPurple) + } + .padding(.top, 40) + .buttonStyle(CardButtonStyle()) + } + } - @ViewBuilder - private var innerBody: some View { - if viewModel.servers.isEmpty { - noServerView - .offset(y: -50) - } else { - listView - } - } + @ViewBuilder + private var innerBody: some View { + if viewModel.servers.isEmpty { + noServerView + .offset(y: -50) + } else { + listView + } + } - @ViewBuilder - private var trailingToolbarContent: some View { - if viewModel.servers.isEmpty { - EmptyView() - } else { - Button { - serverListRouter.route(to: \.connectToServer) - } label: { - Image(systemName: "plus.circle.fill") - } - .contextMenu { - Button { - serverListRouter.route(to: \.basicAppSettings) - } label: { - L10n.settings.text - } - } - } - } + @ViewBuilder + private var trailingToolbarContent: some View { + if viewModel.servers.isEmpty { + EmptyView() + } else { + Button { + serverListRouter.route(to: \.connectToServer) + } label: { + Image(systemName: "plus.circle.fill") + } + .contextMenu { + Button { + serverListRouter.route(to: \.basicAppSettings) + } label: { + L10n.settings.text + } + } + } + } - var body: some View { - innerBody - .navigationTitle(L10n.servers) - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - trailingToolbarContent - } - } - .onAppear { - viewModel.fetchServers() - } - } + var body: some View { + innerBody + .navigationTitle(L10n.servers) + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + trailingToolbarContent + } + } + .onAppear { + viewModel.fetchServers() + } + } } diff --git a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift index 78ccb5b6..bf4b6cf8 100644 --- a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift +++ b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings.swift @@ -11,26 +11,26 @@ import SwiftUI struct CustomizeViewsSettings: View { - @Default(.showPosterLabels) - var showPosterLabels - @Default(.showCastAndCrew) - var showCastAndCrew - @Default(.showFlattenView) - var showFlattenView + @Default(.showPosterLabels) + var showPosterLabels + @Default(.showCastAndCrew) + var showCastAndCrew + @Default(.showFlattenView) + var showFlattenView - var body: some View { - Form { - Section { + var body: some View { + Form { + Section { - Toggle(L10n.showPosterLabels, isOn: $showPosterLabels) + Toggle(L10n.showPosterLabels, isOn: $showPosterLabels) - // TODO: Uncomment when cast and crew implemented in item views - // Toggle(L10n.showCastAndCrew, isOn: $showCastAndCrew) - Toggle(L10n.showFlattenView, isOn: $showFlattenView) + // TODO: Uncomment when cast and crew implemented in item views + // Toggle(L10n.showCastAndCrew, isOn: $showCastAndCrew) + Toggle(L10n.showFlattenView, isOn: $showFlattenView) - } header: { - L10n.customize.text - } - } - } + } header: { + L10n.customize.text + } + } + } } diff --git a/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift index 14d636dd..f50c9ac5 100644 --- a/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/ExperimentalSettingsView.swift @@ -11,45 +11,45 @@ import SwiftUI struct ExperimentalSettingsView: View { - @Default(.Experimental.forceDirectPlay) - var forceDirectPlay - @Default(.Experimental.syncSubtitleStateWithAdjacent) - var syncSubtitleStateWithAdjacent - @Default(.Experimental.nativePlayer) - var nativePlayer + @Default(.Experimental.forceDirectPlay) + var forceDirectPlay + @Default(.Experimental.syncSubtitleStateWithAdjacent) + var syncSubtitleStateWithAdjacent + @Default(.Experimental.nativePlayer) + var nativePlayer - @Default(.Experimental.liveTVAlphaEnabled) - var liveTVAlphaEnabled - @Default(.Experimental.liveTVForceDirectPlay) - var liveTVForceDirectPlay - @Default(.Experimental.liveTVNativePlayer) - var liveTVNativePlayer + @Default(.Experimental.liveTVAlphaEnabled) + var liveTVAlphaEnabled + @Default(.Experimental.liveTVForceDirectPlay) + var liveTVForceDirectPlay + @Default(.Experimental.liveTVNativePlayer) + var liveTVNativePlayer - var body: some View { - Form { - Section { + var body: some View { + Form { + Section { - Toggle("Force Direct Play", isOn: $forceDirectPlay) + Toggle("Force Direct Play", isOn: $forceDirectPlay) - Toggle("Sync Subtitles with Adjacent Episodes", isOn: $syncSubtitleStateWithAdjacent) + Toggle("Sync Subtitles with Adjacent Episodes", isOn: $syncSubtitleStateWithAdjacent) - Toggle("Native Player", isOn: $nativePlayer) + Toggle("Native Player", isOn: $nativePlayer) - } header: { - L10n.experimental.text - } + } header: { + L10n.experimental.text + } - Section { + Section { - Toggle("Live TV (Alpha)", isOn: $liveTVAlphaEnabled) + Toggle("Live TV (Alpha)", isOn: $liveTVAlphaEnabled) - Toggle("Live TV Force Direct Play", isOn: $liveTVForceDirectPlay) + Toggle("Live TV Force Direct Play", isOn: $liveTVForceDirectPlay) - Toggle("Live TV Native Player", isOn: $liveTVNativePlayer) + Toggle("Live TV Native Player", isOn: $liveTVNativePlayer) - } header: { - Text("Live TV") - } - } - } + } header: { + Text("Live TV") + } + } + } } diff --git a/Swiftfin tvOS/Views/SettingsView/MissingItemsSettingsView.swift b/Swiftfin tvOS/Views/SettingsView/MissingItemsSettingsView.swift index 983a9604..cfc1a06f 100644 --- a/Swiftfin tvOS/Views/SettingsView/MissingItemsSettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/MissingItemsSettingsView.swift @@ -11,20 +11,20 @@ import SwiftUI struct MissingItemsSettingsView: View { - @Default(.shouldShowMissingSeasons) - var shouldShowMissingSeasons + @Default(.shouldShowMissingSeasons) + var shouldShowMissingSeasons - @Default(.shouldShowMissingEpisodes) - var shouldShowMissingEpisodes + @Default(.shouldShowMissingEpisodes) + var shouldShowMissingEpisodes - var body: some View { - Form { - Section { - Toggle(L10n.showMissingSeasons, isOn: $shouldShowMissingSeasons) - Toggle(L10n.showMissingEpisodes, isOn: $shouldShowMissingEpisodes) - } header: { - L10n.missingItems.text - } - } - } + var body: some View { + Form { + Section { + Toggle(L10n.showMissingSeasons, isOn: $shouldShowMissingSeasons) + Toggle(L10n.showMissingEpisodes, isOn: $shouldShowMissingEpisodes) + } header: { + L10n.missingItems.text + } + } + } } diff --git a/Swiftfin tvOS/Views/SettingsView/OverlaySettingsView.swift b/Swiftfin tvOS/Views/SettingsView/OverlaySettingsView.swift index 4f9f33c4..032d309f 100644 --- a/Swiftfin tvOS/Views/SettingsView/OverlaySettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/OverlaySettingsView.swift @@ -11,38 +11,38 @@ import SwiftUI struct OverlaySettingsView: View { - @Default(.shouldShowPlayPreviousItem) - var shouldShowPlayPreviousItem - @Default(.shouldShowPlayNextItem) - var shouldShowPlayNextItem - @Default(.shouldShowAutoPlay) - var shouldShowAutoPlay + @Default(.shouldShowPlayPreviousItem) + var shouldShowPlayPreviousItem + @Default(.shouldShowPlayNextItem) + var shouldShowPlayNextItem + @Default(.shouldShowAutoPlay) + var shouldShowAutoPlay - var body: some View { - Form { - Section(header: L10n.overlay.text) { + var body: some View { + Form { + Section(header: L10n.overlay.text) { - Toggle(isOn: $shouldShowPlayPreviousItem) { - HStack { - Image(systemName: "chevron.left.circle") - L10n.playPreviousItem.text - } - } + Toggle(isOn: $shouldShowPlayPreviousItem) { + HStack { + Image(systemName: "chevron.left.circle") + L10n.playPreviousItem.text + } + } - Toggle(isOn: $shouldShowPlayNextItem) { - HStack { - Image(systemName: "chevron.right.circle") - L10n.playNextItem.text - } - } + Toggle(isOn: $shouldShowPlayNextItem) { + HStack { + Image(systemName: "chevron.right.circle") + L10n.playNextItem.text + } + } - Toggle(isOn: $shouldShowAutoPlay) { - HStack { - Image(systemName: "play.circle.fill") - L10n.autoPlay.text - } - } - } - } - } + Toggle(isOn: $shouldShowAutoPlay) { + HStack { + Image(systemName: "play.circle.fill") + L10n.autoPlay.text + } + } + } + } + } } diff --git a/Swiftfin tvOS/Views/SettingsView/SettingsView.swift b/Swiftfin tvOS/Views/SettingsView/SettingsView.swift index 6602038c..5972c47a 100644 --- a/Swiftfin tvOS/Views/SettingsView/SettingsView.swift +++ b/Swiftfin tvOS/Views/SettingsView/SettingsView.swift @@ -13,173 +13,173 @@ import SwiftUI struct SettingsView: View { - @EnvironmentObject - var settingsRouter: SettingsCoordinator.Router - @ObservedObject - var viewModel: SettingsViewModel + @EnvironmentObject + var settingsRouter: SettingsCoordinator.Router + @ObservedObject + var viewModel: SettingsViewModel - @Default(.autoSelectAudioLangCode) - var autoSelectAudioLangcode - @Default(.videoPlayerJumpForward) - var jumpForwardLength - @Default(.videoPlayerJumpBackward) - var jumpBackwardLength - @Default(.downActionShowsMenu) - var downActionShowsMenu - @Default(.confirmClose) - var confirmClose - @Default(.tvOSCinematicViews) - var tvOSCinematicViews - @Default(.showPosterLabels) - var showPosterLabels - @Default(.resumeOffset) - var resumeOffset - @Default(.subtitleSize) - var subtitleSize + @Default(.autoSelectAudioLangCode) + var autoSelectAudioLangcode + @Default(.videoPlayerJumpForward) + var jumpForwardLength + @Default(.videoPlayerJumpBackward) + var jumpBackwardLength + @Default(.downActionShowsMenu) + var downActionShowsMenu + @Default(.confirmClose) + var confirmClose + @Default(.tvOSCinematicViews) + var tvOSCinematicViews + @Default(.showPosterLabels) + var showPosterLabels + @Default(.resumeOffset) + var resumeOffset + @Default(.subtitleSize) + var subtitleSize - var body: some View { - GeometryReader { reader in - HStack { + var body: some View { + GeometryReader { reader in + HStack { - Image(uiImage: UIImage(named: "App Icon")!) - .cornerRadius(30) - .scaleEffect(2) - .frame(width: reader.size.width / 2) + Image(uiImage: UIImage(named: "App Icon")!) + .cornerRadius(30) + .scaleEffect(2) + .frame(width: reader.size.width / 2) - Form { - Section(header: EmptyView()) { + Form { + Section(header: EmptyView()) { - Button {} label: { - HStack { - L10n.user.text - Spacer() - Text(viewModel.user.username) - .foregroundColor(.jellyfinPurple) - } - } + Button {} label: { + HStack { + L10n.user.text + Spacer() + Text(viewModel.user.username) + .foregroundColor(.jellyfinPurple) + } + } - Button { - settingsRouter.route(to: \.serverDetail) - } label: { - HStack { - L10n.server.text - .foregroundColor(.primary) - Spacer() - Text(viewModel.server.name) - .foregroundColor(.jellyfinPurple) + Button { + settingsRouter.route(to: \.serverDetail) + } label: { + HStack { + L10n.server.text + .foregroundColor(.primary) + Spacer() + Text(viewModel.server.name) + .foregroundColor(.jellyfinPurple) - Image(systemName: "chevron.right") - .foregroundColor(.jellyfinPurple) - } - } + Image(systemName: "chevron.right") + .foregroundColor(.jellyfinPurple) + } + } - Button { - SessionManager.main.logout() - } label: { - L10n.switchUser.text - .foregroundColor(Color.jellyfinPurple) - .font(.callout) - } - } + Button { + SessionManager.main.logout() + } label: { + L10n.switchUser.text + .foregroundColor(Color.jellyfinPurple) + .font(.callout) + } + } - Section(header: L10n.videoPlayer.text) { - Picker(L10n.jumpForwardLength, selection: $jumpForwardLength) { - ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in - Text(length.label).tag(length.rawValue) - } - } + Section(header: L10n.videoPlayer.text) { + Picker(L10n.jumpForwardLength, selection: $jumpForwardLength) { + ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in + Text(length.label).tag(length.rawValue) + } + } - Picker(L10n.jumpBackwardLength, selection: $jumpBackwardLength) { - ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in - Text(length.label).tag(length.rawValue) - } - } + Picker(L10n.jumpBackwardLength, selection: $jumpBackwardLength) { + ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in + Text(length.label).tag(length.rawValue) + } + } - Toggle(L10n.resume5SecondOffset, isOn: $resumeOffset) + Toggle(L10n.resume5SecondOffset, isOn: $resumeOffset) - Toggle(L10n.pressDownForMenu, isOn: $downActionShowsMenu) + Toggle(L10n.pressDownForMenu, isOn: $downActionShowsMenu) - Toggle(L10n.confirmClose, isOn: $confirmClose) + Toggle(L10n.confirmClose, isOn: $confirmClose) - Button { - settingsRouter.route(to: \.overlaySettings) - } label: { - HStack { - L10n.overlay.text - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - } - } + Button { + settingsRouter.route(to: \.overlaySettings) + } label: { + HStack { + L10n.overlay.text + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.right") + } + } - Button { - settingsRouter.route(to: \.experimentalSettings) - } label: { - HStack { - L10n.experimental.text - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - } - } - } + Button { + settingsRouter.route(to: \.experimentalSettings) + } label: { + HStack { + L10n.experimental.text + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.right") + } + } + } - Section { - Toggle(L10n.cinematicViews, isOn: $tvOSCinematicViews) - } header: { - L10n.appearance.text - } + Section { + Toggle(L10n.cinematicViews, isOn: $tvOSCinematicViews) + } header: { + L10n.appearance.text + } - Section(header: L10n.accessibility.text) { - Button { - settingsRouter.route(to: \.customizeViewsSettings) - } label: { - HStack { - L10n.customize.text - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - } - } + Section(header: L10n.accessibility.text) { + Button { + settingsRouter.route(to: \.customizeViewsSettings) + } label: { + HStack { + L10n.customize.text + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.right") + } + } - Button { - settingsRouter.route(to: \.missingSettings) - } label: { - HStack { - L10n.missingItems.text - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - } - } + Button { + settingsRouter.route(to: \.missingSettings) + } label: { + HStack { + L10n.missingItems.text + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.right") + } + } - Picker(L10n.subtitleSize, selection: $subtitleSize) { - ForEach(SubtitleSize.allCases, id: \.self) { size in - Text(size.label).tag(size.rawValue) - } - } - } + Picker(L10n.subtitleSize, selection: $subtitleSize) { + ForEach(SubtitleSize.allCases, id: \.self) { size in + Text(size.label).tag(size.rawValue) + } + } + } - Section { - Button {} label: { - HStack { - L10n.version.text - Spacer() - Text("\(UIApplication.appVersion ?? "--") (\(UIApplication.bundleVersion ?? "--"))") - .foregroundColor(.secondary) - } - } - } header: { - L10n.about.text - } - } - } - } - } + Section { + Button {} label: { + HStack { + L10n.version.text + Spacer() + Text("\(UIApplication.appVersion ?? "--") (\(UIApplication.bundleVersion ?? "--"))") + .foregroundColor(.secondary) + } + } + } header: { + L10n.about.text + } + } + } + } + } } struct SettingsView_Previews: PreviewProvider { - static var previews: some View { - SettingsView(viewModel: SettingsViewModel(server: .sample, user: .sample)) - } + static var previews: some View { + SettingsView(viewModel: SettingsViewModel(server: .sample, user: .sample)) + } } diff --git a/Swiftfin tvOS/Views/TVLibrariesView.swift b/Swiftfin tvOS/Views/TVLibrariesView.swift index 66d04128..5fb17aae 100644 --- a/Swiftfin tvOS/Views/TVLibrariesView.swift +++ b/Swiftfin tvOS/Views/TVLibrariesView.swift @@ -11,71 +11,81 @@ import SwiftUI import SwiftUICollection struct TVLibrariesView: View { - @EnvironmentObject - var tvLibrariesRouter: TVLibrariesCoordinator.Router - @StateObject - var viewModel: TVLibrariesViewModel - var title: String + @EnvironmentObject + var tvLibrariesRouter: TVLibrariesCoordinator.Router + @StateObject + var viewModel: TVLibrariesViewModel + var title: String - var body: some View { - if viewModel.isLoading == true { - ProgressView() - } else if !viewModel.rows.isEmpty { - CollectionView(rows: viewModel.rows) { _, _ in - let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), - heightDimension: .fractionalHeight(1)) - let item = NSCollectionLayoutItem(layoutSize: itemSize) + var body: some View { + if viewModel.isLoading == true { + ProgressView() + } else if !viewModel.rows.isEmpty { + CollectionView(rows: viewModel.rows) { _, _ in + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .fractionalHeight(1) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) - let groupSize = NSCollectionLayoutSize(widthDimension: .absolute(200), - heightDimension: .absolute(300)) - let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, - subitems: [item]) + let groupSize = NSCollectionLayoutSize( + widthDimension: .absolute(200), + heightDimension: .absolute(300) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + subitems: [item] + ) - let header = - NSCollectionLayoutBoundarySupplementaryItem(layoutSize: NSCollectionLayoutSize(widthDimension: .fractionalWidth(1), - heightDimension: .absolute(44)), - elementKind: UICollectionView.elementKindSectionHeader, - alignment: .topLeading) + let header = + NSCollectionLayoutBoundarySupplementaryItem( + layoutSize: NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1), + heightDimension: .absolute(44) + ), + elementKind: UICollectionView.elementKindSectionHeader, + alignment: .topLeading + ) - let section = NSCollectionLayoutSection(group: group) + let section = NSCollectionLayoutSection(group: group) - section.contentInsets = NSDirectionalEdgeInsets(top: 30, leading: 0, bottom: 80, trailing: 80) - section.interGroupSpacing = 48 - section.orthogonalScrollingBehavior = .continuous - section.boundarySupplementaryItems = [header] - return section - } cell: { _, cell in - GeometryReader { _ in - if let item = cell.item { - if item.type != .folder { - Button { - self.tvLibrariesRouter.route(to: \.library, item) - } label: { - PortraitItemElement(item: item) - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - } else if cell.loadingCell { - ProgressView() - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center) - } - } - } supplementaryView: { _, indexPath in - HStack { - Spacer() - }.accessibilityIdentifier("\(indexPath.section).\(indexPath.row)") - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .ignoresSafeArea(.all) - } else { - VStack { - L10n.noResults.text - Button { - print("tvLibraries reload") - } label: { - L10n.refresh.text - } - } - } - } + section.contentInsets = NSDirectionalEdgeInsets(top: 30, leading: 0, bottom: 80, trailing: 80) + section.interGroupSpacing = 48 + section.orthogonalScrollingBehavior = .continuous + section.boundarySupplementaryItems = [header] + return section + } cell: { _, cell in + GeometryReader { _ in + if let item = cell.item { + if item.type != .folder { + Button { + self.tvLibrariesRouter.route(to: \.library, item) + } label: { + PortraitItemElement(item: item) + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + } else if cell.loadingCell { + ProgressView() + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center) + } + } + } supplementaryView: { _, indexPath in + HStack { + Spacer() + }.accessibilityIdentifier("\(indexPath.section).\(indexPath.row)") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea(.all) + } else { + VStack { + L10n.noResults.text + Button { + print("tvLibraries reload") + } label: { + L10n.refresh.text + } + } + } + } } diff --git a/Swiftfin tvOS/Views/UserListView.swift b/Swiftfin tvOS/Views/UserListView.swift index e1c52b93..5f7f1e0e 100644 --- a/Swiftfin tvOS/Views/UserListView.swift +++ b/Swiftfin tvOS/Views/UserListView.swift @@ -10,99 +10,99 @@ import SwiftUI struct UserListView: View { - @EnvironmentObject - var userListRouter: UserListCoordinator.Router - @ObservedObject - var viewModel: UserListViewModel + @EnvironmentObject + var userListRouter: UserListCoordinator.Router + @ObservedObject + var viewModel: UserListViewModel - @ViewBuilder - private var listView: some View { - ScrollView { - LazyVStack { - ForEach(viewModel.users, id: \.id) { user in - Button { - viewModel.login(user: user) - } label: { - HStack { - Text(user.username) - .font(.title2) + @ViewBuilder + private var listView: some View { + ScrollView { + LazyVStack { + ForEach(viewModel.users, id: \.id) { user in + Button { + viewModel.login(user: user) + } label: { + HStack { + Text(user.username) + .font(.title2) - Spacer() + Spacer() - if viewModel.isLoading { - ProgressView() - } - } - } - .padding(.horizontal, 100) - .contextMenu { - Button(role: .destructive) { - viewModel.remove(user: user) - } label: { - Label(L10n.remove, systemImage: "trash") - } - } - } - } - .padding(.top, 50) - } - .padding(.top, 50) - } + if viewModel.isLoading { + ProgressView() + } + } + } + .padding(.horizontal, 100) + .contextMenu { + Button(role: .destructive) { + viewModel.remove(user: user) + } label: { + Label(L10n.remove, systemImage: "trash") + } + } + } + } + .padding(.top, 50) + } + .padding(.top, 50) + } - @ViewBuilder - private var noUserView: some View { - VStack { - L10n.signInGetStarted.text - .frame(minWidth: 50, maxWidth: 500) - .multilineTextAlignment(.center) - .font(.callout) + @ViewBuilder + private var noUserView: some View { + VStack { + L10n.signInGetStarted.text + .frame(minWidth: 50, maxWidth: 500) + .multilineTextAlignment(.center) + .font(.callout) - Button { - userListRouter.route(to: \.userSignIn, viewModel.server) - } label: { - L10n.signIn.text - .bold() - .font(.callout) - } - .padding(.top, 40) - } - } + Button { + userListRouter.route(to: \.userSignIn, viewModel.server) + } label: { + L10n.signIn.text + .bold() + .font(.callout) + } + .padding(.top, 40) + } + } - @ViewBuilder - private var innerBody: some View { - if viewModel.users.isEmpty { - noUserView - .offset(y: -50) - } else { - listView - } - } + @ViewBuilder + private var innerBody: some View { + if viewModel.users.isEmpty { + noUserView + .offset(y: -50) + } else { + listView + } + } - @ViewBuilder - private var toolbarContent: some View { - if viewModel.users.isEmpty { - EmptyView() - } else { - HStack { - Button { - userListRouter.route(to: \.userSignIn, viewModel.server) - } label: { - Image(systemName: "person.crop.circle.fill.badge.plus") - } - } - } - } + @ViewBuilder + private var toolbarContent: some View { + if viewModel.users.isEmpty { + EmptyView() + } else { + HStack { + Button { + userListRouter.route(to: \.userSignIn, viewModel.server) + } label: { + Image(systemName: "person.crop.circle.fill.badge.plus") + } + } + } + } - var body: some View { - innerBody - .navigationTitle(viewModel.server.name) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - toolbarContent - } - } - .onAppear { - viewModel.fetchUsers() - } - } + var body: some View { + innerBody + .navigationTitle(viewModel.server.name) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + toolbarContent + } + } + .onAppear { + viewModel.fetchUsers() + } + } } diff --git a/Swiftfin tvOS/Views/UserSignInView.swift b/Swiftfin tvOS/Views/UserSignInView.swift index 03f7aa0d..8eff7d09 100644 --- a/Swiftfin tvOS/Views/UserSignInView.swift +++ b/Swiftfin tvOS/Views/UserSignInView.swift @@ -11,55 +11,57 @@ import SwiftUI struct UserSignInView: View { - @ObservedObject - var viewModel: UserSignInViewModel - @State - private var username: String = "" - @State - private var password: String = "" + @ObservedObject + var viewModel: UserSignInViewModel + @State + private var username: String = "" + @State + private var password: String = "" - var body: some View { - ZStack { - ImageView(viewModel.getSplashscreenUrl()) - .ignoresSafeArea() + var body: some View { + ZStack { + ImageView(viewModel.getSplashscreenUrl()) + .ignoresSafeArea() - Color.black - .opacity(0.9) - .ignoresSafeArea() + Color.black + .opacity(0.9) + .ignoresSafeArea() - Form { - Section { - TextField(L10n.username, text: $username) - .disableAutocorrection(true) - .autocapitalization(.none) + Form { + Section { + TextField(L10n.username, text: $username) + .disableAutocorrection(true) + .autocapitalization(.none) - SecureField(L10n.password, text: $password) - .disableAutocorrection(true) - .autocapitalization(.none) + SecureField(L10n.password, text: $password) + .disableAutocorrection(true) + .autocapitalization(.none) - Button { - viewModel.login(username: username, password: password) - } label: { - HStack { - L10n.connect.text - Spacer() - if viewModel.isLoading { - ProgressView() - } - } - } - .disabled(viewModel.isLoading || username.isEmpty) + Button { + viewModel.login(username: username, password: password) + } label: { + HStack { + L10n.connect.text + Spacer() + if viewModel.isLoading { + ProgressView() + } + } + } + .disabled(viewModel.isLoading || username.isEmpty) - } header: { - L10n.signInToServer(viewModel.server.name).text - } - } - .alert(item: $viewModel.errorMessage) { _ in - Alert(title: Text(viewModel.alertTitle), - message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), - dismissButton: .cancel()) - } - .navigationTitle(L10n.signIn) - } - } + } header: { + L10n.signInToServer(viewModel.server.name).text + } + } + .alert(item: $viewModel.errorMessage) { _ in + Alert( + title: Text(viewModel.alertTitle), + message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), + dismissButton: .cancel() + ) + } + .navigationTitle(L10n.signIn) + } + } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/LiveTVNativeVideoPlayerView.swift b/Swiftfin tvOS/Views/VideoPlayer/LiveTVNativeVideoPlayerView.swift index 0c3fb97e..89ba1563 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/LiveTVNativeVideoPlayerView.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/LiveTVNativeVideoPlayerView.swift @@ -11,13 +11,13 @@ import UIKit struct LiveTVNativeVideoPlayerView: UIViewControllerRepresentable { - let viewModel: VideoPlayerViewModel + let viewModel: VideoPlayerViewModel - typealias UIViewControllerType = NativePlayerViewController + typealias UIViewControllerType = NativePlayerViewController - func makeUIViewController(context: Context) -> NativePlayerViewController { - NativePlayerViewController(viewModel: viewModel) - } + func makeUIViewController(context: Context) -> NativePlayerViewController { + NativePlayerViewController(viewModel: viewModel) + } - func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) {} + func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) {} } diff --git a/Swiftfin tvOS/Views/VideoPlayer/LiveTVPlayerViewController.swift b/Swiftfin tvOS/Views/VideoPlayer/LiveTVPlayerViewController.swift index 0878a873..e53cd431 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/LiveTVPlayerViewController.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/LiveTVPlayerViewController.swift @@ -20,882 +20,903 @@ import UIKit class LiveTVPlayerViewController: UIViewController { - // MARK: variables + // MARK: variables - private var viewModel: VideoPlayerViewModel - private var vlcMediaPlayer: VLCMediaPlayer - private var lastPlayerTicks: Int64 = 0 - private var lastProgressReportTicks: Int64 = 0 - private var viewModelListeners = Set() - private var overlayDismissTimer: Timer? - private var confirmCloseOverlayDismissTimer: Timer? + private var viewModel: VideoPlayerViewModel + private var vlcMediaPlayer: VLCMediaPlayer + private var lastPlayerTicks: Int64 = 0 + private var lastProgressReportTicks: Int64 = 0 + private var viewModelListeners = Set() + private var overlayDismissTimer: Timer? + private var confirmCloseOverlayDismissTimer: Timer? - private var currentPlayerTicks: Int64 { - Int64(vlcMediaPlayer.time.intValue) * 100_000 - } + private var currentPlayerTicks: Int64 { + Int64(vlcMediaPlayer.time.intValue) * 100_000 + } + + private var displayingOverlay: Bool { + currentOverlayHostingController?.view.alpha ?? 0 > 0 + } + + private var displayingContentOverlay: Bool { + currentOverlayContentHostingController?.view.alpha ?? 0 > 0 + } + + private var displayingConfirmClose: Bool { + currentConfirmCloseHostingController?.view.alpha ?? 0 > 0 + } + + private lazy var videoContentView = makeVideoContentView() + private lazy var jumpBackwardOverlayView = makeJumpBackwardOverlayView() + private lazy var jumpForwardOverlayView = makeJumpForwardOverlayView() + private var currentOverlayHostingController: UIHostingController? + private var currentOverlayContentHostingController: UIHostingController? + private var currentConfirmCloseHostingController: UIHostingController? + + // MARK: init + + init(viewModel: VideoPlayerViewModel) { + + self.viewModel = viewModel + self.vlcMediaPlayer = VLCMediaPlayer() + + super.init(nibName: nil, bundle: nil) + + viewModel.playerOverlayDelegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupSubviews() { + view.addSubview(videoContentView) + view.addSubview(jumpForwardOverlayView) + view.addSubview(jumpBackwardOverlayView) + + jumpBackwardOverlayView.alpha = 0 + jumpForwardOverlayView.alpha = 0 + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + videoContentView.topAnchor.constraint(equalTo: view.topAnchor), + videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), + videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor), + ]) + NSLayoutConstraint.activate([ + jumpBackwardOverlayView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 300), + jumpBackwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + NSLayoutConstraint.activate([ + jumpForwardOverlayView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -300), + jumpForwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } + + // MARK: viewWillDisappear + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + didSelectClose() + + let defaultNotificationCenter = NotificationCenter.default + defaultNotificationCenter.removeObserver(self, name: UIApplication.willTerminateNotification, object: nil) + defaultNotificationCenter.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil) + defaultNotificationCenter.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + // MARK: viewDidLoad + + override func viewDidLoad() { + super.viewDidLoad() + + setupSubviews() + setupConstraints() + + view.backgroundColor = .black + + setupMediaPlayer(newViewModel: viewModel) + + setupPanGestureRecognizer() + + addButtonPressRecognizer(pressType: .menu, action: #selector(didPressMenu)) + + let defaultNotificationCenter = NotificationCenter.default + defaultNotificationCenter.addObserver( + self, + selector: #selector(appWillTerminate), + name: UIApplication.willTerminateNotification, + object: nil + ) + defaultNotificationCenter.addObserver( + self, + selector: #selector(appWillResignActive), + name: UIApplication.willResignActiveNotification, + object: nil + ) + defaultNotificationCenter.addObserver( + self, + selector: #selector(appWillResignActive), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + } + + @objc + private func appWillTerminate() { + viewModel.sendStopReport() + } + + @objc + private func appWillResignActive() { + showOverlay() + + stopOverlayDismissTimer() + + vlcMediaPlayer.pause() + + viewModel.sendPauseReport(paused: true) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + startPlayback() + } + + // MARK: subviews + + private func makeVideoContentView() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .black + + return view + } + + private func makeJumpBackwardOverlayView() -> UIImageView { + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) + let forwardSymbolImage = UIImage(systemName: viewModel.jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) + let imageView = UIImageView(image: forwardSymbolImage) + imageView.translatesAutoresizingMaskIntoConstraints = false + + return imageView + } + + private func makeJumpForwardOverlayView() -> UIImageView { + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) + let forwardSymbolImage = UIImage(systemName: viewModel.jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) + let imageView = UIImageView(image: forwardSymbolImage) + imageView.translatesAutoresizingMaskIntoConstraints = false + + return imageView + } + + private func setupPanGestureRecognizer() { + let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(userPanned(panGestureRecognizer:))) + view.addGestureRecognizer(panGestureRecognizer) + } + + // MARK: pressesBegan + + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + guard let buttonPress = presses.first?.type else { return } + + switch buttonPress { + case .menu: () // Captured by other recognizer + case .playPause: + hideConfirmCloseOverlay() + + didSelectMain() + case .select: + hideConfirmCloseOverlay() + + didGenerallyTap() + case .upArrow: + hideConfirmCloseOverlay() + case .downArrow: + hideConfirmCloseOverlay() + + if Defaults[.downActionShowsMenu] { + if !displayingContentOverlay && !displayingOverlay { + didSelectMenu() + } + } + case .leftArrow: + hideConfirmCloseOverlay() + + if !displayingContentOverlay && !displayingOverlay { + didSelectBackward() + } + case .rightArrow: + hideConfirmCloseOverlay() + + if !displayingContentOverlay && !displayingOverlay { + didSelectForward() + } + case .pageUp: () + case .pageDown: () + @unknown default: () + } + } + + private func addButtonPressRecognizer(pressType: UIPress.PressType, action: Selector) { + let pressRecognizer = UITapGestureRecognizer() + pressRecognizer.addTarget(self, action: action) + pressRecognizer.allowedPressTypes = [NSNumber(value: pressType.rawValue)] + view.addGestureRecognizer(pressRecognizer) + } + + // MARK: didPressMenu + + @objc + private func didPressMenu() { + if displayingOverlay { + hideOverlay() + } else if displayingContentOverlay { + hideOverlayContent() + } else if viewModel.confirmClose && !displayingConfirmClose { + + showConfirmCloseOverlay() + restartConfirmCloseDismissTimer() + + } else { + vlcMediaPlayer.pause() + + dismiss(animated: true, completion: nil) + } + } + + @objc + private func userPanned(panGestureRecognizer: UIPanGestureRecognizer) { + if displayingOverlay { + restartOverlayDismissTimer() + } + } + + // MARK: setupOverlayHostingController + + private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { + + // TODO: Look at injecting viewModel into the environment so it updates the current overlay - private var displayingOverlay: Bool { - currentOverlayHostingController?.view.alpha ?? 0 > 0 - } - - private var displayingContentOverlay: Bool { - currentOverlayContentHostingController?.view.alpha ?? 0 > 0 - } - - private var displayingConfirmClose: Bool { - currentConfirmCloseHostingController?.view.alpha ?? 0 > 0 - } + // Main overlay + if let currentOverlayHostingController = currentOverlayHostingController { + // UX fade-out + UIView.animate(withDuration: 0.5) { + currentOverlayHostingController.view.alpha = 0 + } completion: { _ in + currentOverlayHostingController.view.isHidden = true + + currentOverlayHostingController.view.removeFromSuperview() + currentOverlayHostingController.removeFromParent() + } + } + + let newOverlayView = tvOSLiveTVOverlay(viewModel: viewModel) + let newOverlayHostingController = UIHostingController(rootView: newOverlayView) + + newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newOverlayHostingController.view.backgroundColor = UIColor.clear + + // UX fade-in + newOverlayHostingController.view.alpha = 0 + + addChild(newOverlayHostingController) + view.addSubview(newOverlayHostingController.view) + newOverlayHostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) + + // UX fade-in + UIView.animate(withDuration: 0.5) { + newOverlayHostingController.view.alpha = 1 + } + + self.currentOverlayHostingController = newOverlayHostingController + + // Media Stream selection + if let currentOverlayContentHostingController = currentOverlayContentHostingController { + currentOverlayContentHostingController.view.isHidden = true - private lazy var videoContentView = makeVideoContentView() - private lazy var jumpBackwardOverlayView = makeJumpBackwardOverlayView() - private lazy var jumpForwardOverlayView = makeJumpForwardOverlayView() - private var currentOverlayHostingController: UIHostingController? - private var currentOverlayContentHostingController: UIHostingController? - private var currentConfirmCloseHostingController: UIHostingController? + currentOverlayContentHostingController.view.removeFromSuperview() + currentOverlayContentHostingController.removeFromParent() + } - // MARK: init + let newSmallMenuOverlayView = SmallMediaStreamSelectionView(viewModel: viewModel) - init(viewModel: VideoPlayerViewModel) { + let newOverlayContentHostingController = UIHostingController(rootView: newSmallMenuOverlayView) - self.viewModel = viewModel - self.vlcMediaPlayer = VLCMediaPlayer() + newOverlayContentHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newOverlayContentHostingController.view.backgroundColor = UIColor.clear - super.init(nibName: nil, bundle: nil) + newOverlayContentHostingController.view.alpha = 0 - viewModel.playerOverlayDelegate = self - } + addChild(newOverlayContentHostingController) + view.addSubview(newOverlayContentHostingController.view) + newOverlayContentHostingController.didMove(toParent: self) - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + NSLayoutConstraint.activate([ + newOverlayContentHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newOverlayContentHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newOverlayContentHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newOverlayContentHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) - private func setupSubviews() { - view.addSubview(videoContentView) - view.addSubview(jumpForwardOverlayView) - view.addSubview(jumpBackwardOverlayView) + self.currentOverlayContentHostingController = newOverlayContentHostingController - jumpBackwardOverlayView.alpha = 0 - jumpForwardOverlayView.alpha = 0 - } + // Confirm close + if let currentConfirmCloseHostingController = currentConfirmCloseHostingController { + currentConfirmCloseHostingController.view.isHidden = true - private func setupConstraints() { - NSLayoutConstraint.activate([ - videoContentView.topAnchor.constraint(equalTo: view.topAnchor), - videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), - videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor), - ]) - NSLayoutConstraint.activate([ - jumpBackwardOverlayView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 300), - jumpBackwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - NSLayoutConstraint.activate([ - jumpForwardOverlayView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -300), - jumpForwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - } + currentConfirmCloseHostingController.view.removeFromSuperview() + currentConfirmCloseHostingController.removeFromParent() + } - // MARK: viewWillDisappear + let newConfirmCloseOverlay = ConfirmCloseOverlay() - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) + let newConfirmCloseHostingController = UIHostingController(rootView: newConfirmCloseOverlay) - didSelectClose() + newConfirmCloseHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newConfirmCloseHostingController.view.backgroundColor = UIColor.clear - let defaultNotificationCenter = NotificationCenter.default - defaultNotificationCenter.removeObserver(self, name: UIApplication.willTerminateNotification, object: nil) - defaultNotificationCenter.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil) - defaultNotificationCenter.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil) - } + newConfirmCloseHostingController.view.alpha = 0 - // MARK: viewDidLoad + addChild(newConfirmCloseHostingController) + view.addSubview(newConfirmCloseHostingController.view) + newConfirmCloseHostingController.didMove(toParent: self) - override func viewDidLoad() { - super.viewDidLoad() + NSLayoutConstraint.activate([ + newConfirmCloseHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newConfirmCloseHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newConfirmCloseHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newConfirmCloseHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) - setupSubviews() - setupConstraints() + self.currentConfirmCloseHostingController = newConfirmCloseHostingController - view.backgroundColor = .black - - setupMediaPlayer(newViewModel: viewModel) - - setupPanGestureRecognizer() - - addButtonPressRecognizer(pressType: .menu, action: #selector(didPressMenu)) - - let defaultNotificationCenter = NotificationCenter.default - defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, - object: nil) - defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), - name: UIApplication.willResignActiveNotification, object: nil) - defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), - name: UIApplication.didEnterBackgroundNotification, object: nil) - } - - @objc - private func appWillTerminate() { - viewModel.sendStopReport() - } - - @objc - private func appWillResignActive() { - showOverlay() - - stopOverlayDismissTimer() - - vlcMediaPlayer.pause() - - viewModel.sendPauseReport(paused: true) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - startPlayback() - } - - // MARK: subviews - - private func makeVideoContentView() -> UIView { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .black - - return view - } - - private func makeJumpBackwardOverlayView() -> UIImageView { - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) - let forwardSymbolImage = UIImage(systemName: viewModel.jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) - let imageView = UIImageView(image: forwardSymbolImage) - imageView.translatesAutoresizingMaskIntoConstraints = false - - return imageView - } - - private func makeJumpForwardOverlayView() -> UIImageView { - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) - let forwardSymbolImage = UIImage(systemName: viewModel.jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) - let imageView = UIImageView(image: forwardSymbolImage) - imageView.translatesAutoresizingMaskIntoConstraints = false - - return imageView - } - - private func setupPanGestureRecognizer() { - let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(userPanned(panGestureRecognizer:))) - view.addGestureRecognizer(panGestureRecognizer) - } - - // MARK: pressesBegan - - override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - guard let buttonPress = presses.first?.type else { return } - - switch buttonPress { - case .menu: () // Captured by other recognizer - case .playPause: - hideConfirmCloseOverlay() - - didSelectMain() - case .select: - hideConfirmCloseOverlay() - - didGenerallyTap() - case .upArrow: - hideConfirmCloseOverlay() - case .downArrow: - hideConfirmCloseOverlay() - - if Defaults[.downActionShowsMenu] { - if !displayingContentOverlay && !displayingOverlay { - didSelectMenu() - } - } - case .leftArrow: - hideConfirmCloseOverlay() - - if !displayingContentOverlay && !displayingOverlay { - didSelectBackward() - } - case .rightArrow: - hideConfirmCloseOverlay() - - if !displayingContentOverlay && !displayingOverlay { - didSelectForward() - } - case .pageUp: () - case .pageDown: () - @unknown default: () - } - } - - private func addButtonPressRecognizer(pressType: UIPress.PressType, action: Selector) { - let pressRecognizer = UITapGestureRecognizer() - pressRecognizer.addTarget(self, action: action) - pressRecognizer.allowedPressTypes = [NSNumber(value: pressType.rawValue)] - view.addGestureRecognizer(pressRecognizer) - } - - // MARK: didPressMenu - - @objc - private func didPressMenu() { - if displayingOverlay { - hideOverlay() - } else if displayingContentOverlay { - hideOverlayContent() - } else if viewModel.confirmClose && !displayingConfirmClose { - - showConfirmCloseOverlay() - restartConfirmCloseDismissTimer() - - } else { - vlcMediaPlayer.pause() - - dismiss(animated: true, completion: nil) - } - } - - @objc - private func userPanned(panGestureRecognizer: UIPanGestureRecognizer) { - if displayingOverlay { - restartOverlayDismissTimer() - } - } - - // MARK: setupOverlayHostingController - - private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { - - // TODO: Look at injecting viewModel into the environment so it updates the current overlay - - // Main overlay - if let currentOverlayHostingController = currentOverlayHostingController { - // UX fade-out - UIView.animate(withDuration: 0.5) { - currentOverlayHostingController.view.alpha = 0 - } completion: { _ in - currentOverlayHostingController.view.isHidden = true - - currentOverlayHostingController.view.removeFromSuperview() - currentOverlayHostingController.removeFromParent() - } - } - - let newOverlayView = tvOSLiveTVOverlay(viewModel: viewModel) - let newOverlayHostingController = UIHostingController(rootView: newOverlayView) - - newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newOverlayHostingController.view.backgroundColor = UIColor.clear - - // UX fade-in - newOverlayHostingController.view.alpha = 0 - - addChild(newOverlayHostingController) - view.addSubview(newOverlayHostingController.view) - newOverlayHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - - // UX fade-in - UIView.animate(withDuration: 0.5) { - newOverlayHostingController.view.alpha = 1 - } - - self.currentOverlayHostingController = newOverlayHostingController - - // Media Stream selection - if let currentOverlayContentHostingController = currentOverlayContentHostingController { - currentOverlayContentHostingController.view.isHidden = true - - currentOverlayContentHostingController.view.removeFromSuperview() - currentOverlayContentHostingController.removeFromParent() - } - - let newSmallMenuOverlayView = SmallMediaStreamSelectionView(viewModel: viewModel) - - let newOverlayContentHostingController = UIHostingController(rootView: newSmallMenuOverlayView) - - newOverlayContentHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newOverlayContentHostingController.view.backgroundColor = UIColor.clear - - newOverlayContentHostingController.view.alpha = 0 - - addChild(newOverlayContentHostingController) - view.addSubview(newOverlayContentHostingController.view) - newOverlayContentHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newOverlayContentHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newOverlayContentHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newOverlayContentHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newOverlayContentHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - - self.currentOverlayContentHostingController = newOverlayContentHostingController - - // Confirm close - if let currentConfirmCloseHostingController = currentConfirmCloseHostingController { - currentConfirmCloseHostingController.view.isHidden = true - - currentConfirmCloseHostingController.view.removeFromSuperview() - currentConfirmCloseHostingController.removeFromParent() - } - - let newConfirmCloseOverlay = ConfirmCloseOverlay() - - let newConfirmCloseHostingController = UIHostingController(rootView: newConfirmCloseOverlay) - - newConfirmCloseHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newConfirmCloseHostingController.view.backgroundColor = UIColor.clear - - newConfirmCloseHostingController.view.alpha = 0 - - addChild(newConfirmCloseHostingController) - view.addSubview(newConfirmCloseHostingController.view) - newConfirmCloseHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newConfirmCloseHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newConfirmCloseHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newConfirmCloseHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newConfirmCloseHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - - self.currentConfirmCloseHostingController = newConfirmCloseHostingController - - // There is a behavior when setting this that the navigation bar - // on the current navigation controller pops up, re-hide it - self.navigationController?.isNavigationBarHidden = true - } + // There is a behavior when setting this that the navigation bar + // on the current navigation controller pops up, re-hide it + self.navigationController?.isNavigationBarHidden = true + } } // MARK: setupMediaPlayer extension LiveTVPlayerViewController { - /// Main function that handles setting up the media player with the current VideoPlayerViewModel - /// and also takes the role of setting the 'viewModel' property with the given viewModel - /// - /// Use case for this is setting new media within the same VLCPlayerViewController - func setupMediaPlayer(newViewModel: VideoPlayerViewModel) { + /// Main function that handles setting up the media player with the current VideoPlayerViewModel + /// and also takes the role of setting the 'viewModel' property with the given viewModel + /// + /// Use case for this is setting new media within the same VLCPlayerViewController + func setupMediaPlayer(newViewModel: VideoPlayerViewModel) { - // remove old player + // remove old player - if vlcMediaPlayer.media != nil { - viewModelListeners.forEach { $0.cancel() } + if vlcMediaPlayer.media != nil { + viewModelListeners.forEach { $0.cancel() } - vlcMediaPlayer.stop() - viewModel.sendStopReport() - viewModel.playerOverlayDelegate = nil - } + vlcMediaPlayer.stop() + viewModel.sendStopReport() + viewModel.playerOverlayDelegate = nil + } - vlcMediaPlayer = VLCMediaPlayer() + vlcMediaPlayer = VLCMediaPlayer() - // setup with new player and view model + // setup with new player and view model - vlcMediaPlayer = VLCMediaPlayer() + vlcMediaPlayer = VLCMediaPlayer() - vlcMediaPlayer.delegate = self - vlcMediaPlayer.drawable = videoContentView + vlcMediaPlayer.delegate = self + vlcMediaPlayer.drawable = videoContentView - vlcMediaPlayer.setSubtitleSize(Defaults[.subtitleSize]) + vlcMediaPlayer.setSubtitleSize(Defaults[.subtitleSize]) - stopOverlayDismissTimer() + stopOverlayDismissTimer() - // Stop current media if there is one - if vlcMediaPlayer.media != nil { - viewModelListeners.forEach { $0.cancel() } + // Stop current media if there is one + if vlcMediaPlayer.media != nil { + viewModelListeners.forEach { $0.cancel() } - vlcMediaPlayer.stop() - viewModel.sendStopReport() - viewModel.playerOverlayDelegate = nil - } + vlcMediaPlayer.stop() + viewModel.sendStopReport() + viewModel.playerOverlayDelegate = nil + } - lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - // TODO: Custom buffer/cache amounts + // TODO: Custom buffer/cache amounts - let media: VLCMedia + let media: VLCMedia - if let transcodedURL = newViewModel.transcodedStreamURL, - !Defaults[.Experimental.forceDirectPlay] - { - media = VLCMedia(url: transcodedURL) - } else { - media = VLCMedia(url: newViewModel.directStreamURL) - } + if let transcodedURL = newViewModel.transcodedStreamURL, + !Defaults[.Experimental.forceDirectPlay] + { + media = VLCMedia(url: transcodedURL) + } else { + media = VLCMedia(url: newViewModel.directStreamURL) + } - media.addOption("--prefetch-buffer-size=1048576") - media.addOption("--network-caching=5000") + media.addOption("--prefetch-buffer-size=1048576") + media.addOption("--network-caching=5000") - vlcMediaPlayer.media = media + vlcMediaPlayer.media = media - setupOverlayHostingController(viewModel: newViewModel) - setupViewModelListeners(viewModel: newViewModel) + setupOverlayHostingController(viewModel: newViewModel) + setupViewModelListeners(viewModel: newViewModel) - newViewModel.getAdjacentEpisodes() - newViewModel.playerOverlayDelegate = self + newViewModel.getAdjacentEpisodes() + newViewModel.playerOverlayDelegate = self - let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 + let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 - if startPercentage > 0 { - if viewModel.resumeOffset { - let runTimeTicks = viewModel.item.runTimeTicks ?? 0 - let videoDurationSeconds = Double(runTimeTicks / 10_000_000) - var startSeconds = round((startPercentage / 100) * videoDurationSeconds) - startSeconds = startSeconds.subtract(5, floor: 0) - let newStartPercentage = startSeconds / videoDurationSeconds - newViewModel.sliderPercentage = newStartPercentage - } else { - newViewModel.sliderPercentage = startPercentage / 100 - } - } + if startPercentage > 0 { + if viewModel.resumeOffset { + let runTimeTicks = viewModel.item.runTimeTicks ?? 0 + let videoDurationSeconds = Double(runTimeTicks / 10_000_000) + var startSeconds = round((startPercentage / 100) * videoDurationSeconds) + startSeconds = startSeconds.subtract(5, floor: 0) + let newStartPercentage = startSeconds / videoDurationSeconds + newViewModel.sliderPercentage = newStartPercentage + } else { + newViewModel.sliderPercentage = startPercentage / 100 + } + } - viewModel = newViewModel + viewModel = newViewModel - if viewModel.streamType == .direct { - LogManager.log.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? "--")") - } else if viewModel.streamType == .transcode && Defaults[.Experimental.forceDirectPlay] { - LogManager.log.debug("Player set up with forced direct stream for item: \(viewModel.item.id ?? "--")") - } else { - LogManager.log.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? "--")") - } - } + if viewModel.streamType == .direct { + LogManager.log.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? "--")") + } else if viewModel.streamType == .transcode && Defaults[.Experimental.forceDirectPlay] { + LogManager.log.debug("Player set up with forced direct stream for item: \(viewModel.item.id ?? "--")") + } else { + LogManager.log.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? "--")") + } + } - // MARK: startPlayback + // MARK: startPlayback - func startPlayback() { - vlcMediaPlayer.play() + func startPlayback() { + vlcMediaPlayer.play() - // Setup external subtitles - for externalSubtitle in viewModel.subtitleStreams.filter({ $0.deliveryMethod == .external }) { - if let deliveryURL = externalSubtitle.externalURL(base: SessionManager.main.currentLogin.server.currentURI) { - vlcMediaPlayer.addPlaybackSlave(deliveryURL, type: .subtitle, enforce: false) - } - } + // Setup external subtitles + for externalSubtitle in viewModel.subtitleStreams.filter({ $0.deliveryMethod == .external }) { + if let deliveryURL = externalSubtitle.externalURL(base: SessionManager.main.currentLogin.server.currentURI) { + vlcMediaPlayer.addPlaybackSlave(deliveryURL, type: .subtitle, enforce: false) + } + } - setMediaPlayerTimeAtCurrentSlider() + setMediaPlayerTimeAtCurrentSlider() - viewModel.sendPlayReport() + viewModel.sendPlayReport() - restartOverlayDismissTimer(interval: 5) - } + restartOverlayDismissTimer(interval: 5) + } - // MARK: setupViewModelListeners + // MARK: setupViewModelListeners - private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { - viewModel.$playbackSpeed.sink { newSpeed in - self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) - }.store(in: &viewModelListeners) + private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { + viewModel.$playbackSpeed.sink { newSpeed in + self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) + }.store(in: &viewModelListeners) - viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in - if sliderIsScrubbing { - self.didBeginScrubbing() - } else { - self.didEndScrubbing() - } - }.store(in: &viewModelListeners) + viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in + if sliderIsScrubbing { + self.didBeginScrubbing() + } else { + self.didEndScrubbing() + } + }.store(in: &viewModelListeners) - viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in - self.didSelectAudioStream(index: newAudioStreamIndex) - }.store(in: &viewModelListeners) + viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in + self.didSelectAudioStream(index: newAudioStreamIndex) + }.store(in: &viewModelListeners) - viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in - self.didSelectSubtitleStream(index: newSubtitleStreamIndex) - }.store(in: &viewModelListeners) + viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in + self.didSelectSubtitleStream(index: newSubtitleStreamIndex) + }.store(in: &viewModelListeners) - viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in - self.didToggleSubtitles(newValue: newSubtitlesEnabled) - }.store(in: &viewModelListeners) - } + viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in + self.didToggleSubtitles(newValue: newSubtitlesEnabled) + }.store(in: &viewModelListeners) + } - func setMediaPlayerTimeAtCurrentSlider() { - // Necessary math as VLCMediaPlayer doesn't work well - // by just setting the position - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let runTimeTicks = viewModel.item.runTimeTicks ?? 0 - let videoDuration = Double(runTimeTicks / 10_000_000) - let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) - let newPositionOffset = secondsScrubbedTo - videoPosition + func setMediaPlayerTimeAtCurrentSlider() { + // Necessary math as VLCMediaPlayer doesn't work well + // by just setting the position + let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) + let runTimeTicks = viewModel.item.runTimeTicks ?? 0 + let videoDuration = Double(runTimeTicks / 10_000_000) + let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) + let newPositionOffset = secondsScrubbedTo - videoPosition - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } - } + if newPositionOffset > 0 { + vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) + } else { + vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) + } + } } // MARK: Show/Hide Overlay extension LiveTVPlayerViewController { - private func showOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } + private func showOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } - guard overlayHostingController.view.alpha != 1 else { return } + guard overlayHostingController.view.alpha != 1 else { return } - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 1 - } - } + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 1 + } + } - private func hideOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } + private func hideOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } - guard overlayHostingController.view.alpha != 0 else { return } + guard overlayHostingController.view.alpha != 0 else { return } - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 0 - } - } + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 0 + } + } - private func toggleOverlay() { - if displayingOverlay { - hideOverlay() - } else { - showOverlay() - } - } + private func toggleOverlay() { + if displayingOverlay { + hideOverlay() + } else { + showOverlay() + } + } - private func showOverlayContent() { - guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return } + private func showOverlayContent() { + guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return } - guard currentOverlayContentHostingController.view.alpha != 1 else { return } + guard currentOverlayContentHostingController.view.alpha != 1 else { return } - currentOverlayContentHostingController.view.setNeedsFocusUpdate() - currentOverlayContentHostingController.setNeedsFocusUpdate() - setNeedsFocusUpdate() + currentOverlayContentHostingController.view.setNeedsFocusUpdate() + currentOverlayContentHostingController.setNeedsFocusUpdate() + setNeedsFocusUpdate() - UIView.animate(withDuration: 0.2) { - currentOverlayContentHostingController.view.alpha = 1 - } - } + UIView.animate(withDuration: 0.2) { + currentOverlayContentHostingController.view.alpha = 1 + } + } - private func hideOverlayContent() { - guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return } + private func hideOverlayContent() { + guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return } - guard currentOverlayContentHostingController.view.alpha != 0 else { return } + guard currentOverlayContentHostingController.view.alpha != 0 else { return } - setNeedsFocusUpdate() + setNeedsFocusUpdate() - UIView.animate(withDuration: 0.2) { - currentOverlayContentHostingController.view.alpha = 0 - } - } + UIView.animate(withDuration: 0.2) { + currentOverlayContentHostingController.view.alpha = 0 + } + } } // MARK: Show/Hide Jump extension LiveTVPlayerViewController { - private func flashJumpBackwardOverlay() { - jumpBackwardOverlayView.layer.removeAllAnimations() + private func flashJumpBackwardOverlay() { + jumpBackwardOverlayView.layer.removeAllAnimations() - UIView.animate(withDuration: 0.1) { - self.jumpBackwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpBackwardOverlay() - } - } + UIView.animate(withDuration: 0.1) { + self.jumpBackwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpBackwardOverlay() + } + } - private func hideJumpBackwardOverlay() { - UIView.animate(withDuration: 0.3) { - self.jumpBackwardOverlayView.alpha = 0 - } - } + private func hideJumpBackwardOverlay() { + UIView.animate(withDuration: 0.3) { + self.jumpBackwardOverlayView.alpha = 0 + } + } - private func flashJumpFowardOverlay() { - jumpForwardOverlayView.layer.removeAllAnimations() + private func flashJumpFowardOverlay() { + jumpForwardOverlayView.layer.removeAllAnimations() - UIView.animate(withDuration: 0.1) { - self.jumpForwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpForwardOverlay() - } - } + UIView.animate(withDuration: 0.1) { + self.jumpForwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpForwardOverlay() + } + } - private func hideJumpForwardOverlay() { - UIView.animate(withDuration: 0.3) { - self.jumpForwardOverlayView.alpha = 0 - } - } + private func hideJumpForwardOverlay() { + UIView.animate(withDuration: 0.3) { + self.jumpForwardOverlayView.alpha = 0 + } + } } // MARK: Show/Hide Confirm close extension LiveTVPlayerViewController { - private func showConfirmCloseOverlay() { - guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return } + private func showConfirmCloseOverlay() { + guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return } - UIView.animate(withDuration: 0.2) { - currentConfirmCloseHostingController.view.alpha = 1 - } - } + UIView.animate(withDuration: 0.2) { + currentConfirmCloseHostingController.view.alpha = 1 + } + } - private func hideConfirmCloseOverlay() { - guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return } + private func hideConfirmCloseOverlay() { + guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return } - UIView.animate(withDuration: 0.5) { - currentConfirmCloseHostingController.view.alpha = 0 - } - } + UIView.animate(withDuration: 0.5) { + currentConfirmCloseHostingController.view.alpha = 0 + } + } } // MARK: OverlayTimer extension LiveTVPlayerViewController { - private func restartOverlayDismissTimer(interval: Double = 5) { - self.overlayDismissTimer?.invalidate() - self.overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), - userInfo: nil, repeats: false) - } + private func restartOverlayDismissTimer(interval: Double = 5) { + self.overlayDismissTimer?.invalidate() + self.overlayDismissTimer = Timer.scheduledTimer( + timeInterval: interval, + target: self, + selector: #selector(dismissTimerFired), + userInfo: nil, + repeats: false + ) + } - @objc - private func dismissTimerFired() { - hideOverlay() - } + @objc + private func dismissTimerFired() { + hideOverlay() + } - private func stopOverlayDismissTimer() { - overlayDismissTimer?.invalidate() - } + private func stopOverlayDismissTimer() { + overlayDismissTimer?.invalidate() + } } // MARK: Confirm Close Overlay Timer extension LiveTVPlayerViewController { - private func restartConfirmCloseDismissTimer() { - self.confirmCloseOverlayDismissTimer?.invalidate() - self.confirmCloseOverlayDismissTimer = Timer.scheduledTimer(timeInterval: 5, target: self, - selector: #selector(confirmCloseTimerFired), userInfo: nil, - repeats: false) - } + private func restartConfirmCloseDismissTimer() { + self.confirmCloseOverlayDismissTimer?.invalidate() + self.confirmCloseOverlayDismissTimer = Timer.scheduledTimer( + timeInterval: 5, + target: self, + selector: #selector(confirmCloseTimerFired), + userInfo: nil, + repeats: false + ) + } - @objc - private func confirmCloseTimerFired() { - hideConfirmCloseOverlay() - } + @objc + private func confirmCloseTimerFired() { + hideConfirmCloseOverlay() + } - private func stopConfirmCloseDismissTimer() { - confirmCloseOverlayDismissTimer?.invalidate() - } + private func stopConfirmCloseDismissTimer() { + confirmCloseOverlayDismissTimer?.invalidate() + } } // MARK: VLCMediaPlayerDelegate extension LiveTVPlayerViewController: VLCMediaPlayerDelegate { - // MARK: mediaPlayerStateChanged + // MARK: mediaPlayerStateChanged - func mediaPlayerStateChanged(_ aNotification: Notification) { + func mediaPlayerStateChanged(_ aNotification: Notification) { - // Don't show buffering if paused, usually here while scrubbing - if vlcMediaPlayer.state == .buffering && viewModel.playerState == .paused { - return - } + // Don't show buffering if paused, usually here while scrubbing + if vlcMediaPlayer.state == .buffering && viewModel.playerState == .paused { + return + } - viewModel.playerState = vlcMediaPlayer.state + viewModel.playerState = vlcMediaPlayer.state - if vlcMediaPlayer.state == VLCMediaPlayerState.ended { - if viewModel.autoplayEnabled && viewModel.nextItemVideoPlayerViewModel != nil { - didSelectPlayNextItem() - } else { - didSelectClose() - } - } - } + if vlcMediaPlayer.state == VLCMediaPlayerState.ended { + if viewModel.autoplayEnabled && viewModel.nextItemVideoPlayerViewModel != nil { + didSelectPlayNextItem() + } else { + didSelectClose() + } + } + } - // MARK: mediaPlayerTimeChanged + // MARK: mediaPlayerTimeChanged - func mediaPlayerTimeChanged(_ aNotification: Notification) { + func mediaPlayerTimeChanged(_ aNotification: Notification) { - if !viewModel.sliderIsScrubbing { - viewModel.sliderPercentage = Double(vlcMediaPlayer.position) - } + if !viewModel.sliderIsScrubbing { + viewModel.sliderPercentage = Double(vlcMediaPlayer.position) + } - // Have to manually set playing because VLCMediaPlayer doesn't - // properly set it itself - if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 { - viewModel.playerState = VLCMediaPlayerState.playing - } + // Have to manually set playing because VLCMediaPlayer doesn't + // properly set it itself + if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 { + viewModel.playerState = VLCMediaPlayerState.playing + } - // If needing to fix subtitle streams during playback - if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.selectedSubtitleStreamIndex && - viewModel.subtitlesEnabled - { - didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) - } + // If needing to fix subtitle streams during playback + if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.selectedSubtitleStreamIndex && + viewModel.subtitlesEnabled + { + didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) + } - if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { - didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) - } + if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { + didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) + } - lastPlayerTicks = currentPlayerTicks + lastPlayerTicks = currentPlayerTicks - // Send progress report every 5 seconds - if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { - viewModel.sendProgressReport() + // Send progress report every 5 seconds + if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } - } + lastProgressReportTicks = currentPlayerTicks + } + } } // MARK: PlayerOverlayDelegate extension LiveTVPlayerViewController: PlayerOverlayDelegate { - func didSelectAudioStream(index: Int) { - // on live tv, it seems this gets set to -1 which disables the audio track. -// vlcMediaPlayer.currentAudioTrackIndex = Int32(index) + func didSelectAudioStream(index: Int) { + // on live tv, it seems this gets set to -1 which disables the audio track. + // vlcMediaPlayer.currentAudioTrackIndex = Int32(index) - viewModel.sendProgressReport() + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } + lastProgressReportTicks = currentPlayerTicks + } - /// Do not call when setting to index -1 - func didSelectSubtitleStream(index: Int) { + /// Do not call when setting to index -1 + func didSelectSubtitleStream(index: Int) { - viewModel.subtitlesEnabled = true - vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) + viewModel.subtitlesEnabled = true + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) - viewModel.sendProgressReport() + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } + lastProgressReportTicks = currentPlayerTicks + } - func didSelectClose() { - vlcMediaPlayer.stop() + func didSelectClose() { + vlcMediaPlayer.stop() - viewModel.sendStopReport() + viewModel.sendStopReport() - dismiss(animated: true, completion: nil) - } + dismiss(animated: true, completion: nil) + } - func didToggleSubtitles(newValue: Bool) { - if newValue { - vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex) - } else { - vlcMediaPlayer.currentVideoSubTitleIndex = -1 - } - } + func didToggleSubtitles(newValue: Bool) { + if newValue { + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex) + } else { + vlcMediaPlayer.currentVideoSubTitleIndex = -1 + } + } - func didSelectMenu() { - stopOverlayDismissTimer() + func didSelectMenu() { + stopOverlayDismissTimer() - hideOverlay() - showOverlayContent() - } + hideOverlay() + showOverlayContent() + } - func didSelectBackward() { + func didSelectBackward() { - flashJumpBackwardOverlay() + flashJumpBackwardOverlay() - vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) + vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) - if displayingOverlay { - restartOverlayDismissTimer() - } + if displayingOverlay { + restartOverlayDismissTimer() + } - viewModel.sendProgressReport() + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } + lastProgressReportTicks = currentPlayerTicks + } - func didSelectForward() { + func didSelectForward() { - flashJumpFowardOverlay() + flashJumpFowardOverlay() - vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) + vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) - if displayingOverlay { - restartOverlayDismissTimer() - } + if displayingOverlay { + restartOverlayDismissTimer() + } - viewModel.sendProgressReport() + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } + lastProgressReportTicks = currentPlayerTicks + } - func didSelectMain() { + func didSelectMain() { - switch viewModel.playerState { - case .buffering: - vlcMediaPlayer.play() - restartOverlayDismissTimer() - case .playing: - viewModel.sendPauseReport(paused: true) - vlcMediaPlayer.pause() + switch viewModel.playerState { + case .buffering: + vlcMediaPlayer.play() + restartOverlayDismissTimer() + case .playing: + viewModel.sendPauseReport(paused: true) + vlcMediaPlayer.pause() - showOverlay() - restartOverlayDismissTimer(interval: 5) - case .paused: - viewModel.sendPauseReport(paused: false) - vlcMediaPlayer.play() - restartOverlayDismissTimer() - default: () - } - } + showOverlay() + restartOverlayDismissTimer(interval: 5) + case .paused: + viewModel.sendPauseReport(paused: false) + vlcMediaPlayer.play() + restartOverlayDismissTimer() + default: () + } + } - func didGenerallyTap() { - toggleOverlay() + func didGenerallyTap() { + toggleOverlay() - restartOverlayDismissTimer(interval: 5) - } + restartOverlayDismissTimer(interval: 5) + } - func didBeginScrubbing() { - stopOverlayDismissTimer() - } + func didBeginScrubbing() { + stopOverlayDismissTimer() + } - func didEndScrubbing() { - setMediaPlayerTimeAtCurrentSlider() + func didEndScrubbing() { + setMediaPlayerTimeAtCurrentSlider() - restartOverlayDismissTimer() + restartOverlayDismissTimer() - viewModel.sendProgressReport() + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } + lastProgressReportTicks = currentPlayerTicks + } - func didSelectPlayPreviousItem() { - if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel { - setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel) - startPlayback() - } - } + func didSelectPlayPreviousItem() { + if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel { + setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel) + startPlayback() + } + } - func didSelectPlayNextItem() { - if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel { - setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel) - startPlayback() - } - } + func didSelectPlayNextItem() { + if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel { + setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel) + startPlayback() + } + } - func didSelectChapter(_ chapter: ChapterInfo) { - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let chapterSeconds = Double((chapter.startPositionTicks ?? 0) / 10_000_000) - let newPositionOffset = chapterSeconds - videoPosition + func didSelectChapter(_ chapter: ChapterInfo) { + let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) + let chapterSeconds = Double((chapter.startPositionTicks ?? 0) / 10_000_000) + let newPositionOffset = chapterSeconds - videoPosition - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } + if newPositionOffset > 0 { + vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) + } else { + vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) + } - viewModel.sendProgressReport() - } + viewModel.sendProgressReport() + } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/LiveTVVideoPlayerView.swift b/Swiftfin tvOS/Views/VideoPlayer/LiveTVVideoPlayerView.swift index 0aefe9cb..e4f3ef56 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/LiveTVVideoPlayerView.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/LiveTVVideoPlayerView.swift @@ -11,14 +11,14 @@ import UIKit struct LiveTVVideoPlayerView: UIViewControllerRepresentable { - let viewModel: VideoPlayerViewModel + let viewModel: VideoPlayerViewModel - typealias UIViewControllerType = LiveTVPlayerViewController + typealias UIViewControllerType = LiveTVPlayerViewController - func makeUIViewController(context: Context) -> LiveTVPlayerViewController { + func makeUIViewController(context: Context) -> LiveTVPlayerViewController { - LiveTVPlayerViewController(viewModel: viewModel) - } + LiveTVPlayerViewController(viewModel: viewModel) + } - func updateUIViewController(_ uiViewController: LiveTVPlayerViewController, context: Context) {} + func updateUIViewController(_ uiViewController: LiveTVPlayerViewController, context: Context) {} } diff --git a/Swiftfin tvOS/Views/VideoPlayer/NativePlayerViewController.swift b/Swiftfin tvOS/Views/VideoPlayer/NativePlayerViewController.swift index d0ffba8c..ed6f8251 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/NativePlayerViewController.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/NativePlayerViewController.swift @@ -13,118 +13,122 @@ import UIKit class NativePlayerViewController: AVPlayerViewController { - let viewModel: VideoPlayerViewModel + let viewModel: VideoPlayerViewModel - var timeObserverToken: Any? + var timeObserverToken: Any? - var lastProgressTicks: Int64 = 0 + var lastProgressTicks: Int64 = 0 - private var cancellables = Set() + private var cancellables = Set() - init(viewModel: VideoPlayerViewModel) { + init(viewModel: VideoPlayerViewModel) { - self.viewModel = viewModel + self.viewModel = viewModel - super.init(nibName: nil, bundle: nil) + super.init(nibName: nil, bundle: nil) - let player: AVPlayer + let player: AVPlayer - if let transcodedStreamURL = viewModel.transcodedStreamURL { - player = AVPlayer(url: transcodedStreamURL) - } else { - player = AVPlayer(url: viewModel.hlsStreamURL) - } + if let transcodedStreamURL = viewModel.transcodedStreamURL { + player = AVPlayer(url: transcodedStreamURL) + } else { + player = AVPlayer(url: viewModel.hlsStreamURL) + } - player.appliesMediaSelectionCriteriaAutomatically = false - player.currentItem?.externalMetadata = createMetadata() + player.appliesMediaSelectionCriteriaAutomatically = false + player.currentItem?.externalMetadata = createMetadata() - let timeScale = CMTimeScale(NSEC_PER_SEC) - let time = CMTime(seconds: 5, preferredTimescale: timeScale) + let timeScale = CMTimeScale(NSEC_PER_SEC) + let time = CMTime(seconds: 5, preferredTimescale: timeScale) - timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in - if time.seconds != 0 { - self?.sendProgressReport(seconds: time.seconds) - } - } + timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in + if time.seconds != 0 { + self?.sendProgressReport(seconds: time.seconds) + } + } - self.player = player + self.player = player - self.allowsPictureInPicturePlayback = true - self.player?.allowsExternalPlayback = true - } + self.allowsPictureInPicturePlayback = true + self.player?.allowsExternalPlayback = true + } - private func createMetadata() -> [AVMetadataItem] { - let allMetadata: [AVMetadataIdentifier: Any] = [ - .commonIdentifierTitle: viewModel.title, - .iTunesMetadataTrackSubTitle: viewModel.subtitle ?? "", - // Need to fix against an image that doesn't exist -// .commonIdentifierArtwork: UIImage(data: try! Data(contentsOf: viewModel.item.getBackdropImage(maxWidth: 200)))? -// .pngData() as Any, -// .commonIdentifierDescription: viewModel.item.overview ?? "", -// .iTunesMetadataContentRating: viewModel.item.officialRating ?? "", -// .quickTimeMetadataGenre: viewModel.item.genres?.first ?? "", - ] + private func createMetadata() -> [AVMetadataItem] { + let allMetadata: [AVMetadataIdentifier: Any] = [ + .commonIdentifierTitle: viewModel.title, + .iTunesMetadataTrackSubTitle: viewModel.subtitle ?? "", + // Need to fix against an image that doesn't exist + // .commonIdentifierArtwork: UIImage(data: try! Data(contentsOf: viewModel.item.getBackdropImage(maxWidth: 200)))? + // .pngData() as Any, + // .commonIdentifierDescription: viewModel.item.overview ?? "", + // .iTunesMetadataContentRating: viewModel.item.officialRating ?? "", + // .quickTimeMetadataGenre: viewModel.item.genres?.first ?? "", + ] - return allMetadata.compactMap { createMetadataItem(for: $0, value: $1) } - } + return allMetadata.compactMap { createMetadataItem(for: $0, value: $1) } + } - private func createMetadataItem(for identifier: AVMetadataIdentifier, - value: Any) -> AVMetadataItem - { - let item = AVMutableMetadataItem() - item.identifier = identifier - item.value = value as? NSCopying & NSObjectProtocol - // Specify "und" to indicate an undefined language. - item.extendedLanguageTag = "und" - return item.copy() as! AVMetadataItem - } + private func createMetadataItem( + for identifier: AVMetadataIdentifier, + value: Any + ) -> AVMetadataItem { + let item = AVMutableMetadataItem() + item.identifier = identifier + item.value = value as? NSCopying & NSObjectProtocol + // Specify "und" to indicate an undefined language. + item.extendedLanguageTag = "und" + return item.copy() as! AVMetadataItem + } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } - override func viewDidLoad() { - super.viewDidLoad() - } + override func viewDidLoad() { + super.viewDidLoad() + } - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) - stop() - removePeriodicTimeObserver() - } + stop() + removePeriodicTimeObserver() + } - func removePeriodicTimeObserver() { - if let timeObserverToken = timeObserverToken { - player?.removeTimeObserver(timeObserverToken) - self.timeObserverToken = nil - } - } + func removePeriodicTimeObserver() { + if let timeObserverToken = timeObserverToken { + player?.removeTimeObserver(timeObserverToken) + self.timeObserverToken = nil + } + } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) - player?.seek(to: CMTimeMake(value: viewModel.currentSecondTicks, timescale: 10_000_000), - toleranceBefore: CMTimeMake(value: 1, timescale: 1), toleranceAfter: CMTimeMake(value: 1, timescale: 1), - completionHandler: { _ in - self.play() - }) - } + player?.seek( + to: CMTimeMake(value: viewModel.currentSecondTicks, timescale: 10_000_000), + toleranceBefore: CMTimeMake(value: 1, timescale: 1), + toleranceAfter: CMTimeMake(value: 1, timescale: 1), + completionHandler: { _ in + self.play() + } + ) + } - private func play() { - player?.play() + private func play() { + player?.play() - viewModel.sendPlayReport() - } + viewModel.sendPlayReport() + } - private func sendProgressReport(seconds: Double) { - viewModel.setSeconds(Int64(seconds)) - viewModel.sendProgressReport() - } + private func sendProgressReport(seconds: Double) { + viewModel.setSeconds(Int64(seconds)) + viewModel.sendProgressReport() + } - private func stop() { - self.player?.pause() - viewModel.sendStopReport() - } + private func stop() { + self.player?.pause() + viewModel.sendStopReport() + } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/ConfirmCloseOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/ConfirmCloseOverlay.swift index d115e5f4..9145609b 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/ConfirmCloseOverlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/ConfirmCloseOverlay.swift @@ -9,31 +9,31 @@ import SwiftUI struct ConfirmCloseOverlay: View { - var body: some View { - VStack { - HStack { - Image(systemName: "chevron.left.circle.fill") - .font(.system(size: 96)) - .padding(3) - .background(Color.black.opacity(0.4).mask(Circle())) + var body: some View { + VStack { + HStack { + Image(systemName: "chevron.left.circle.fill") + .font(.system(size: 96)) + .padding(3) + .background(Color.black.opacity(0.4).mask(Circle())) - Spacer() - } - .padding() + Spacer() + } + .padding() - Spacer() - } - .padding() - } + Spacer() + } + .padding() + } } struct ConfirmCloseOverlay_Previews: PreviewProvider { - static var previews: some View { - ZStack { - Color.red.ignoresSafeArea() + static var previews: some View { + ZStack { + Color.red.ignoresSafeArea() - ConfirmCloseOverlay() - .ignoresSafeArea() - } - } + ConfirmCloseOverlay() + .ignoresSafeArea() + } + } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift index 1bebee07..0058df64 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/SmallMenuOverlay.swift @@ -12,355 +12,357 @@ import SwiftUI // TODO: Needs replacement/reworking struct SmallMediaStreamSelectionView: View { - enum Layer: Hashable { - case subtitles - case audio - case playbackSpeed - case chapters - } + enum Layer: Hashable { + case subtitles + case audio + case playbackSpeed + case chapters + } - enum MediaSection: Hashable { - case titles - case items - } + enum MediaSection: Hashable { + case titles + case items + } - @ObservedObject - var viewModel: VideoPlayerViewModel - private let chapterImages: [URL] + @ObservedObject + var viewModel: VideoPlayerViewModel + private let chapterImages: [URL] - @State - private var updateFocusedLayer: Layer = .subtitles - @State - private var lastFocusedLayer: Layer = .subtitles + @State + private var updateFocusedLayer: Layer = .subtitles + @State + private var lastFocusedLayer: Layer = .subtitles - @FocusState - private var subtitlesFocused: Bool - @FocusState - private var audioFocused: Bool - @FocusState - private var playbackSpeedFocused: Bool - @FocusState - private var chaptersFocused: Bool - @FocusState - private var focusedSection: MediaSection? - @FocusState - private var focusedLayer: Layer? { - willSet { - updateFocusedLayer = newValue! + @FocusState + private var subtitlesFocused: Bool + @FocusState + private var audioFocused: Bool + @FocusState + private var playbackSpeedFocused: Bool + @FocusState + private var chaptersFocused: Bool + @FocusState + private var focusedSection: MediaSection? + @FocusState + private var focusedLayer: Layer? { + willSet { + updateFocusedLayer = newValue! - if focusedSection == .titles { - lastFocusedLayer = newValue! - } - } - } + if focusedSection == .titles { + lastFocusedLayer = newValue! + } + } + } - init(viewModel: VideoPlayerViewModel) { - self.viewModel = viewModel - self.chapterImages = viewModel.item.getChapterImage(maxWidth: 500) - } + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + self.chapterImages = viewModel.item.getChapterImage(maxWidth: 500) + } - var body: some View { - ZStack(alignment: .bottom) { - LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), - startPoint: .top, - endPoint: .bottom) - .ignoresSafeArea() - .frame(height: 300) + var body: some View { + ZStack(alignment: .bottom) { + LinearGradient( + gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + .frame(height: 300) - VStack { + VStack { - Spacer() + Spacer() - HStack { + HStack { - // MARK: Subtitle Header + // MARK: Subtitle Header - Button { - updateFocusedLayer = .subtitles - focusedLayer = .subtitles - } label: { - if updateFocusedLayer == .subtitles { - HStack(spacing: 15) { - Image(systemName: "captions.bubble") - L10n.subtitles.text - } - .padding() - .background(Color.white) - .foregroundColor(.black) - } else { - HStack(spacing: 15) { - Image(systemName: "captions.bubble") - L10n.subtitles.text - } - .padding() - } - } - .buttonStyle(PlainButtonStyle()) - .background(Color.clear) - .focused($focusedLayer, equals: .subtitles) - .focused($subtitlesFocused) - .onChange(of: subtitlesFocused) { isFocused in - if isFocused { - focusedLayer = .subtitles - } - } + Button { + updateFocusedLayer = .subtitles + focusedLayer = .subtitles + } label: { + if updateFocusedLayer == .subtitles { + HStack(spacing: 15) { + Image(systemName: "captions.bubble") + L10n.subtitles.text + } + .padding() + .background(Color.white) + .foregroundColor(.black) + } else { + HStack(spacing: 15) { + Image(systemName: "captions.bubble") + L10n.subtitles.text + } + .padding() + } + } + .buttonStyle(PlainButtonStyle()) + .background(Color.clear) + .focused($focusedLayer, equals: .subtitles) + .focused($subtitlesFocused) + .onChange(of: subtitlesFocused) { isFocused in + if isFocused { + focusedLayer = .subtitles + } + } - // MARK: Audio Header + // MARK: Audio Header - Button { - updateFocusedLayer = .audio - focusedLayer = .audio - } label: { - if updateFocusedLayer == .audio { - HStack(spacing: 15) { - Image(systemName: "speaker.wave.3") - L10n.audio.text - } - .padding() - .background(Color.white) - .foregroundColor(.black) - } else { - HStack(spacing: 15) { - Image(systemName: "speaker.wave.3") - L10n.audio.text - } - .padding() - } - } - .buttonStyle(PlainButtonStyle()) - .background(Color.clear) - .focused($focusedLayer, equals: .audio) - .focused($audioFocused) - .onChange(of: audioFocused) { isFocused in - if isFocused { - focusedLayer = .audio - } - } + Button { + updateFocusedLayer = .audio + focusedLayer = .audio + } label: { + if updateFocusedLayer == .audio { + HStack(spacing: 15) { + Image(systemName: "speaker.wave.3") + L10n.audio.text + } + .padding() + .background(Color.white) + .foregroundColor(.black) + } else { + HStack(spacing: 15) { + Image(systemName: "speaker.wave.3") + L10n.audio.text + } + .padding() + } + } + .buttonStyle(PlainButtonStyle()) + .background(Color.clear) + .focused($focusedLayer, equals: .audio) + .focused($audioFocused) + .onChange(of: audioFocused) { isFocused in + if isFocused { + focusedLayer = .audio + } + } - // MARK: Playback Speed Header + // MARK: Playback Speed Header - Button { - updateFocusedLayer = .playbackSpeed - focusedLayer = .playbackSpeed - } label: { - if updateFocusedLayer == .playbackSpeed { - HStack(spacing: 15) { - Image(systemName: "speedometer") - L10n.playbackSpeed.text - } - .padding() - .background(Color.white) - .foregroundColor(.black) - } else { - HStack(spacing: 15) { - Image(systemName: "speedometer") - L10n.playbackSpeed.text - } - .padding() - } - } - .buttonStyle(PlainButtonStyle()) - .background(Color.clear) - .focused($focusedLayer, equals: .playbackSpeed) - .focused($playbackSpeedFocused) - .onChange(of: playbackSpeedFocused) { isFocused in - if isFocused { - focusedLayer = .playbackSpeed - } - } + Button { + updateFocusedLayer = .playbackSpeed + focusedLayer = .playbackSpeed + } label: { + if updateFocusedLayer == .playbackSpeed { + HStack(spacing: 15) { + Image(systemName: "speedometer") + L10n.playbackSpeed.text + } + .padding() + .background(Color.white) + .foregroundColor(.black) + } else { + HStack(spacing: 15) { + Image(systemName: "speedometer") + L10n.playbackSpeed.text + } + .padding() + } + } + .buttonStyle(PlainButtonStyle()) + .background(Color.clear) + .focused($focusedLayer, equals: .playbackSpeed) + .focused($playbackSpeedFocused) + .onChange(of: playbackSpeedFocused) { isFocused in + if isFocused { + focusedLayer = .playbackSpeed + } + } - // MARK: Chapters Header + // MARK: Chapters Header - if !viewModel.chapters.isEmpty { - Button { - updateFocusedLayer = .chapters - focusedLayer = .chapters - } label: { - if updateFocusedLayer == .chapters { - HStack(spacing: 15) { - Image(systemName: "list.dash") - L10n.chapters.text - } - .padding() - .background(Color.white) - .foregroundColor(.black) - } else { - HStack(spacing: 15) { - Image(systemName: "list.dash") - L10n.chapters.text - } - .padding() - } - } - .buttonStyle(PlainButtonStyle()) - .background(Color.clear) - .focused($focusedLayer, equals: .chapters) - .focused($chaptersFocused) - .onChange(of: chaptersFocused) { isFocused in - if isFocused { - focusedLayer = .chapters - } - } - } + if !viewModel.chapters.isEmpty { + Button { + updateFocusedLayer = .chapters + focusedLayer = .chapters + } label: { + if updateFocusedLayer == .chapters { + HStack(spacing: 15) { + Image(systemName: "list.dash") + L10n.chapters.text + } + .padding() + .background(Color.white) + .foregroundColor(.black) + } else { + HStack(spacing: 15) { + Image(systemName: "list.dash") + L10n.chapters.text + } + .padding() + } + } + .buttonStyle(PlainButtonStyle()) + .background(Color.clear) + .focused($focusedLayer, equals: .chapters) + .focused($chaptersFocused) + .onChange(of: chaptersFocused) { isFocused in + if isFocused { + focusedLayer = .chapters + } + } + } - Spacer() - } - .padding() - .focusSection() - .focused($focusedSection, equals: .titles) - .onChange(of: focusedSection) { _ in - if focusedSection == .titles { - if lastFocusedLayer == .subtitles { - subtitlesFocused = true - } else if lastFocusedLayer == .audio { - audioFocused = true - } else if lastFocusedLayer == .playbackSpeed { - playbackSpeedFocused = true - } - } - } + Spacer() + } + .padding() + .focusSection() + .focused($focusedSection, equals: .titles) + .onChange(of: focusedSection) { _ in + if focusedSection == .titles { + if lastFocusedLayer == .subtitles { + subtitlesFocused = true + } else if lastFocusedLayer == .audio { + audioFocused = true + } else if lastFocusedLayer == .playbackSpeed { + playbackSpeedFocused = true + } + } + } - if updateFocusedLayer == .subtitles && lastFocusedLayer == .subtitles { - // MARK: Subtitles + if updateFocusedLayer == .subtitles && lastFocusedLayer == .subtitles { + // MARK: Subtitles - subtitleMenuView - } else if updateFocusedLayer == .audio && lastFocusedLayer == .audio { - // MARK: Audio + subtitleMenuView + } else if updateFocusedLayer == .audio && lastFocusedLayer == .audio { + // MARK: Audio - audioMenuView - } else if updateFocusedLayer == .playbackSpeed && lastFocusedLayer == .playbackSpeed { - // MARK: Playback Speed + audioMenuView + } else if updateFocusedLayer == .playbackSpeed && lastFocusedLayer == .playbackSpeed { + // MARK: Playback Speed - playbackSpeedMenuView - } else if updateFocusedLayer == .chapters && lastFocusedLayer == .chapters { - // MARK: Chapters + playbackSpeedMenuView + } else if updateFocusedLayer == .chapters && lastFocusedLayer == .chapters { + // MARK: Chapters - chaptersMenuView - } - } - } - } + chaptersMenuView + } + } + } + } - @ViewBuilder - private var subtitleMenuView: some View { - ScrollView(.horizontal) { - HStack { - if viewModel.subtitleStreams.isEmpty { - Button {} label: { - L10n.none.text - } - } else { - ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in - Button { - viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1 - } label: { - if subtitleStream.index == viewModel.selectedSubtitleStreamIndex { - Label(subtitleStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark") - } else { - Text(subtitleStream.displayTitle ?? L10n.noTitle) - } - } - } - } - } - .padding(.vertical) - .focusSection() - .focused($focusedSection, equals: .items) - } - } + @ViewBuilder + private var subtitleMenuView: some View { + ScrollView(.horizontal) { + HStack { + if viewModel.subtitleStreams.isEmpty { + Button {} label: { + L10n.none.text + } + } else { + ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in + Button { + viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1 + } label: { + if subtitleStream.index == viewModel.selectedSubtitleStreamIndex { + Label(subtitleStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark") + } else { + Text(subtitleStream.displayTitle ?? L10n.noTitle) + } + } + } + } + } + .padding(.vertical) + .focusSection() + .focused($focusedSection, equals: .items) + } + } - @ViewBuilder - private var audioMenuView: some View { - ScrollView(.horizontal) { - HStack { - if viewModel.audioStreams.isEmpty { - Button {} label: { - Text("None") - } - } else { - ForEach(viewModel.audioStreams, id: \.self) { audioStream in - Button { - viewModel.selectedAudioStreamIndex = audioStream.index ?? -1 - } label: { - if audioStream.index == viewModel.selectedAudioStreamIndex { - Label(audioStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark") - } else { - Text(audioStream.displayTitle ?? L10n.noTitle) - } - } - } - } - } - .padding(.vertical) - .focusSection() - .focused($focusedSection, equals: .items) - } - } + @ViewBuilder + private var audioMenuView: some View { + ScrollView(.horizontal) { + HStack { + if viewModel.audioStreams.isEmpty { + Button {} label: { + Text("None") + } + } else { + ForEach(viewModel.audioStreams, id: \.self) { audioStream in + Button { + viewModel.selectedAudioStreamIndex = audioStream.index ?? -1 + } label: { + if audioStream.index == viewModel.selectedAudioStreamIndex { + Label(audioStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark") + } else { + Text(audioStream.displayTitle ?? L10n.noTitle) + } + } + } + } + } + .padding(.vertical) + .focusSection() + .focused($focusedSection, equals: .items) + } + } - @ViewBuilder - private var playbackSpeedMenuView: some View { - ScrollView(.horizontal) { - HStack { - ForEach(PlaybackSpeed.allCases, id: \.self) { playbackSpeed in - Button { - viewModel.playbackSpeed = playbackSpeed - } label: { - if playbackSpeed == viewModel.playbackSpeed { - Label(playbackSpeed.displayTitle, systemImage: "checkmark") - } else { - Text(playbackSpeed.displayTitle) - } - } - } - } - .padding(.vertical) - .focusSection() - .focused($focusedSection, equals: .items) - } - } + @ViewBuilder + private var playbackSpeedMenuView: some View { + ScrollView(.horizontal) { + HStack { + ForEach(PlaybackSpeed.allCases, id: \.self) { playbackSpeed in + Button { + viewModel.playbackSpeed = playbackSpeed + } label: { + if playbackSpeed == viewModel.playbackSpeed { + Label(playbackSpeed.displayTitle, systemImage: "checkmark") + } else { + Text(playbackSpeed.displayTitle) + } + } + } + } + .padding(.vertical) + .focusSection() + .focused($focusedSection, equals: .items) + } + } - @ViewBuilder - private var chaptersMenuView: some View { - ScrollView(.horizontal, showsIndicators: false) { - ScrollViewReader { reader in - HStack { - ForEach(0 ..< viewModel.chapters.count, id: \.self) { chapterIndex in - VStack(alignment: .leading) { - Button { - viewModel.playerOverlayDelegate?.didSelectChapter(viewModel.chapters[chapterIndex]) - } label: { - ImageView(chapterImages[chapterIndex]) - .cornerRadius(10) - .frame(width: 350, height: 210) - } - .buttonStyle(CardButtonStyle()) + @ViewBuilder + private var chaptersMenuView: some View { + ScrollView(.horizontal, showsIndicators: false) { + ScrollViewReader { reader in + HStack { + ForEach(0 ..< viewModel.chapters.count, id: \.self) { chapterIndex in + VStack(alignment: .leading) { + Button { + viewModel.playerOverlayDelegate?.didSelectChapter(viewModel.chapters[chapterIndex]) + } label: { + ImageView(chapterImages[chapterIndex]) + .cornerRadius(10) + .frame(width: 350, height: 210) + } + .buttonStyle(CardButtonStyle()) - VStack(alignment: .leading, spacing: 5) { + VStack(alignment: .leading, spacing: 5) { - Text(viewModel.chapters[chapterIndex].name ?? L10n.noTitle) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.white) + Text(viewModel.chapters[chapterIndex].name ?? L10n.noTitle) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.white) - Text(viewModel.chapters[chapterIndex].timestampLabel) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(Color(UIColor.systemBlue)) - .padding(.vertical, 2) - .padding(.horizontal, 4) - .background { - Color(UIColor.darkGray).opacity(0.2).cornerRadius(4) - } - } - } - .id(viewModel.chapters[chapterIndex]) - } - } - .padding(.top) - .onAppear { - reader.scrollTo(viewModel.currentChapter) - } - } - } - } + Text(viewModel.chapters[chapterIndex].timestampLabel) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(Color(UIColor.systemBlue)) + .padding(.vertical, 2) + .padding(.horizontal, 4) + .background { + Color(UIColor.darkGray).opacity(0.2).cornerRadius(4) + } + } + } + .id(viewModel.chapters[chapterIndex]) + } + } + .padding(.top) + .onAppear { + reader.scrollTo(viewModel.currentChapter) + } + } + } + } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift index dace960b..3839a716 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift @@ -12,161 +12,165 @@ import SwiftUI struct tvOSLiveTVOverlay: View { - @ObservedObject - var viewModel: VideoPlayerViewModel - @Default(.downActionShowsMenu) - var downActionShowsMenu + @ObservedObject + var viewModel: VideoPlayerViewModel + @Default(.downActionShowsMenu) + var downActionShowsMenu - @ViewBuilder - private var mainButtonView: some View { - switch viewModel.playerState { - case .stopped, .paused: - Image(systemName: "play.circle") - case .playing: - Image(systemName: "pause.circle") - default: - ProgressView() - } - } + @ViewBuilder + private var mainButtonView: some View { + switch viewModel.playerState { + case .stopped, .paused: + Image(systemName: "play.circle") + case .playing: + Image(systemName: "pause.circle") + default: + ProgressView() + } + } - var body: some View { - ZStack(alignment: .bottom) { + var body: some View { + ZStack(alignment: .bottom) { - LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), - startPoint: .top, - endPoint: .bottom) - .ignoresSafeArea() - .frame(height: viewModel.subtitle == nil ? 180 : 210) + LinearGradient( + gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + .frame(height: viewModel.subtitle == nil ? 180 : 210) - VStack { + VStack { - Spacer() + Spacer() - HStack(alignment: .bottom) { + HStack(alignment: .bottom) { - VStack(alignment: .leading) { - if let subtitle = viewModel.subtitle { - Text(subtitle) - .font(.subheadline) - .foregroundColor(.white) - } + VStack(alignment: .leading) { + if let subtitle = viewModel.subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundColor(.white) + } - Text(viewModel.title) - .font(.title3) - .fontWeight(.bold) - } + Text(viewModel.title) + .font(.title3) + .fontWeight(.bold) + } - Spacer() + Spacer() - if viewModel.shouldShowPlayPreviousItem { - SFSymbolButton(systemName: "chevron.left.circle", action: { - viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() - }) - .frame(maxWidth: 30, maxHeight: 30) - .disabled(viewModel.previousItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) - } + if viewModel.shouldShowPlayPreviousItem { + SFSymbolButton(systemName: "chevron.left.circle", action: { + viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() + }) + .frame(maxWidth: 30, maxHeight: 30) + .disabled(viewModel.previousItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + } - if viewModel.shouldShowPlayNextItem { - SFSymbolButton(systemName: "chevron.right.circle", action: { - viewModel.playerOverlayDelegate?.didSelectPlayNextItem() - }) - .frame(maxWidth: 30, maxHeight: 30) - .disabled(viewModel.nextItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) - } + if viewModel.shouldShowPlayNextItem { + SFSymbolButton(systemName: "chevron.right.circle", action: { + viewModel.playerOverlayDelegate?.didSelectPlayNextItem() + }) + .frame(maxWidth: 30, maxHeight: 30) + .disabled(viewModel.nextItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + } - if viewModel.shouldShowAutoPlay { - if viewModel.autoplayEnabled { - SFSymbolButton(systemName: "play.circle.fill") { - viewModel.autoplayEnabled.toggle() - } - .frame(maxWidth: 30, maxHeight: 30) - } else { - SFSymbolButton(systemName: "stop.circle") { - viewModel.autoplayEnabled.toggle() - } - .frame(maxWidth: 30, maxHeight: 30) - } - } + if viewModel.shouldShowAutoPlay { + if viewModel.autoplayEnabled { + SFSymbolButton(systemName: "play.circle.fill") { + viewModel.autoplayEnabled.toggle() + } + .frame(maxWidth: 30, maxHeight: 30) + } else { + SFSymbolButton(systemName: "stop.circle") { + viewModel.autoplayEnabled.toggle() + } + .frame(maxWidth: 30, maxHeight: 30) + } + } - if !viewModel.subtitleStreams.isEmpty { - if viewModel.subtitlesEnabled { - SFSymbolButton(systemName: "captions.bubble.fill") { - viewModel.subtitlesEnabled.toggle() - } - .frame(maxWidth: 30, maxHeight: 30) - } else { - SFSymbolButton(systemName: "captions.bubble") { - viewModel.subtitlesEnabled.toggle() - } - .frame(maxWidth: 30, maxHeight: 30) - } - } + if !viewModel.subtitleStreams.isEmpty { + if viewModel.subtitlesEnabled { + SFSymbolButton(systemName: "captions.bubble.fill") { + viewModel.subtitlesEnabled.toggle() + } + .frame(maxWidth: 30, maxHeight: 30) + } else { + SFSymbolButton(systemName: "captions.bubble") { + viewModel.subtitlesEnabled.toggle() + } + .frame(maxWidth: 30, maxHeight: 30) + } + } - if !downActionShowsMenu { - SFSymbolButton(systemName: "ellipsis.circle") { - viewModel.playerOverlayDelegate?.didSelectMenu() - } - .frame(maxWidth: 30, maxHeight: 30) - } - } - .offset(x: 0, y: 10) + if !downActionShowsMenu { + SFSymbolButton(systemName: "ellipsis.circle") { + viewModel.playerOverlayDelegate?.didSelectMenu() + } + .frame(maxWidth: 30, maxHeight: 30) + } + } + .offset(x: 0, y: 10) - SliderView(viewModel: viewModel) - .frame(maxHeight: 40) + SliderView(viewModel: viewModel) + .frame(maxHeight: 40) - HStack { + HStack { - HStack(spacing: 10) { - mainButtonView - .frame(maxWidth: 40, maxHeight: 40) + HStack(spacing: 10) { + mainButtonView + .frame(maxWidth: 40, maxHeight: 40) - Text(viewModel.leftLabelText) - } + Text(viewModel.leftLabelText) + } - Spacer() + Spacer() - Text(viewModel.rightLabelText) - } - .offset(x: 0, y: -10) - } - } - .foregroundColor(.white) - } + Text(viewModel.rightLabelText) + } + .offset(x: 0, y: -10) + } + } + .foregroundColor(.white) + } } struct tvOSLiveTVOverlay_Previews: PreviewProvider { - static let videoPlayerViewModel = VideoPlayerViewModel(item: BaseItemDto(), - title: "Glorious Purpose", - subtitle: "Loki - S1E1", - directStreamURL: URL(string: "www.apple.com")!, - transcodedStreamURL: nil, - hlsStreamURL: URL(string: "www.apple.com")!, - streamType: .direct, - response: PlaybackInfoResponse(), - audioStreams: [MediaStream(displayTitle: "English", index: -1)], - subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], - chapters: [], - selectedAudioStreamIndex: -1, - selectedSubtitleStreamIndex: -1, - subtitlesEnabled: true, - autoplayEnabled: false, - overlayType: .compact, - shouldShowPlayPreviousItem: true, - shouldShowPlayNextItem: true, - shouldShowAutoPlay: true, - container: "", - filename: nil, - versionName: nil) + static let videoPlayerViewModel = VideoPlayerViewModel( + item: BaseItemDto(), + title: "Glorious Purpose", + subtitle: "Loki - S1E1", + directStreamURL: URL(string: "www.apple.com")!, + transcodedStreamURL: nil, + hlsStreamURL: URL(string: "www.apple.com")!, + streamType: .direct, + response: PlaybackInfoResponse(), + audioStreams: [MediaStream(displayTitle: "English", index: -1)], + subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], + chapters: [], + selectedAudioStreamIndex: -1, + selectedSubtitleStreamIndex: -1, + subtitlesEnabled: true, + autoplayEnabled: false, + overlayType: .compact, + shouldShowPlayPreviousItem: true, + shouldShowPlayNextItem: true, + shouldShowAutoPlay: true, + container: "", + filename: nil, + versionName: nil + ) - static var previews: some View { - ZStack { - Color.red - .ignoresSafeArea() + static var previews: some View { + ZStack { + Color.red + .ignoresSafeArea() - tvOSLiveTVOverlay(viewModel: videoPlayerViewModel) - } - } + tvOSLiveTVOverlay(viewModel: videoPlayerViewModel) + } + } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift index aa480d1a..1857cbe5 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift @@ -12,161 +12,165 @@ import SwiftUI struct tvOSVLCOverlay: View { - @ObservedObject - var viewModel: VideoPlayerViewModel - @Default(.downActionShowsMenu) - var downActionShowsMenu + @ObservedObject + var viewModel: VideoPlayerViewModel + @Default(.downActionShowsMenu) + var downActionShowsMenu - @ViewBuilder - private var mainButtonView: some View { - switch viewModel.playerState { - case .stopped, .paused: - Image(systemName: "play.circle") - case .playing: - Image(systemName: "pause.circle") - default: - ProgressView() - } - } + @ViewBuilder + private var mainButtonView: some View { + switch viewModel.playerState { + case .stopped, .paused: + Image(systemName: "play.circle") + case .playing: + Image(systemName: "pause.circle") + default: + ProgressView() + } + } - var body: some View { - ZStack(alignment: .bottom) { + var body: some View { + ZStack(alignment: .bottom) { - LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), - startPoint: .top, - endPoint: .bottom) - .ignoresSafeArea() - .frame(height: viewModel.subtitle == nil ? 180 : 210) + LinearGradient( + gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + .frame(height: viewModel.subtitle == nil ? 180 : 210) - VStack { + VStack { - Spacer() + Spacer() - HStack(alignment: .bottom) { + HStack(alignment: .bottom) { - VStack(alignment: .leading) { - if let subtitle = viewModel.subtitle { - Text(subtitle) - .font(.subheadline) - .foregroundColor(.white) - } + VStack(alignment: .leading) { + if let subtitle = viewModel.subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundColor(.white) + } - Text(viewModel.title) - .font(.title3) - .fontWeight(.bold) - } + Text(viewModel.title) + .font(.title3) + .fontWeight(.bold) + } - Spacer() + Spacer() - if viewModel.shouldShowPlayPreviousItem { - SFSymbolButton(systemName: "chevron.left.circle", action: { - viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() - }) - .frame(maxWidth: 30, maxHeight: 30) - .disabled(viewModel.previousItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) - } + if viewModel.shouldShowPlayPreviousItem { + SFSymbolButton(systemName: "chevron.left.circle", action: { + viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() + }) + .frame(maxWidth: 30, maxHeight: 30) + .disabled(viewModel.previousItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + } - if viewModel.shouldShowPlayNextItem { - SFSymbolButton(systemName: "chevron.right.circle", action: { - viewModel.playerOverlayDelegate?.didSelectPlayNextItem() - }) - .frame(maxWidth: 30, maxHeight: 30) - .disabled(viewModel.nextItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) - } + if viewModel.shouldShowPlayNextItem { + SFSymbolButton(systemName: "chevron.right.circle", action: { + viewModel.playerOverlayDelegate?.didSelectPlayNextItem() + }) + .frame(maxWidth: 30, maxHeight: 30) + .disabled(viewModel.nextItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + } - if viewModel.shouldShowAutoPlay { - if viewModel.autoplayEnabled { - SFSymbolButton(systemName: "play.circle.fill") { - viewModel.autoplayEnabled.toggle() - } - .frame(maxWidth: 30, maxHeight: 30) - } else { - SFSymbolButton(systemName: "stop.circle") { - viewModel.autoplayEnabled.toggle() - } - .frame(maxWidth: 30, maxHeight: 30) - } - } + if viewModel.shouldShowAutoPlay { + if viewModel.autoplayEnabled { + SFSymbolButton(systemName: "play.circle.fill") { + viewModel.autoplayEnabled.toggle() + } + .frame(maxWidth: 30, maxHeight: 30) + } else { + SFSymbolButton(systemName: "stop.circle") { + viewModel.autoplayEnabled.toggle() + } + .frame(maxWidth: 30, maxHeight: 30) + } + } - if !viewModel.subtitleStreams.isEmpty { - if viewModel.subtitlesEnabled { - SFSymbolButton(systemName: "captions.bubble.fill") { - viewModel.subtitlesEnabled.toggle() - } - .frame(maxWidth: 30, maxHeight: 30) - } else { - SFSymbolButton(systemName: "captions.bubble") { - viewModel.subtitlesEnabled.toggle() - } - .frame(maxWidth: 30, maxHeight: 30) - } - } + if !viewModel.subtitleStreams.isEmpty { + if viewModel.subtitlesEnabled { + SFSymbolButton(systemName: "captions.bubble.fill") { + viewModel.subtitlesEnabled.toggle() + } + .frame(maxWidth: 30, maxHeight: 30) + } else { + SFSymbolButton(systemName: "captions.bubble") { + viewModel.subtitlesEnabled.toggle() + } + .frame(maxWidth: 30, maxHeight: 30) + } + } - if !downActionShowsMenu { - SFSymbolButton(systemName: "ellipsis.circle") { - viewModel.playerOverlayDelegate?.didSelectMenu() - } - .frame(maxWidth: 30, maxHeight: 30) - } - } - .offset(x: 0, y: 10) + if !downActionShowsMenu { + SFSymbolButton(systemName: "ellipsis.circle") { + viewModel.playerOverlayDelegate?.didSelectMenu() + } + .frame(maxWidth: 30, maxHeight: 30) + } + } + .offset(x: 0, y: 10) - SliderView(viewModel: viewModel) - .frame(maxHeight: 40) + SliderView(viewModel: viewModel) + .frame(maxHeight: 40) - HStack { + HStack { - HStack(spacing: 10) { - mainButtonView - .frame(maxWidth: 40, maxHeight: 40) + HStack(spacing: 10) { + mainButtonView + .frame(maxWidth: 40, maxHeight: 40) - Text(viewModel.leftLabelText) - } + Text(viewModel.leftLabelText) + } - Spacer() + Spacer() - Text(viewModel.rightLabelText) - } - .offset(x: 0, y: -10) - } - } - .foregroundColor(.white) - } + Text(viewModel.rightLabelText) + } + .offset(x: 0, y: -10) + } + } + .foregroundColor(.white) + } } struct tvOSVLCOverlay_Previews: PreviewProvider { - static let videoPlayerViewModel = VideoPlayerViewModel(item: BaseItemDto(), - title: "Glorious Purpose", - subtitle: "Loki - S1E1", - directStreamURL: URL(string: "www.apple.com")!, - transcodedStreamURL: nil, - hlsStreamURL: URL(string: "www.apple.com")!, - streamType: .direct, - response: PlaybackInfoResponse(), - audioStreams: [MediaStream(displayTitle: "English", index: -1)], - subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], - chapters: [], - selectedAudioStreamIndex: -1, - selectedSubtitleStreamIndex: -1, - subtitlesEnabled: true, - autoplayEnabled: false, - overlayType: .compact, - shouldShowPlayPreviousItem: true, - shouldShowPlayNextItem: true, - shouldShowAutoPlay: true, - container: "", - filename: nil, - versionName: nil) + static let videoPlayerViewModel = VideoPlayerViewModel( + item: BaseItemDto(), + title: "Glorious Purpose", + subtitle: "Loki - S1E1", + directStreamURL: URL(string: "www.apple.com")!, + transcodedStreamURL: nil, + hlsStreamURL: URL(string: "www.apple.com")!, + streamType: .direct, + response: PlaybackInfoResponse(), + audioStreams: [MediaStream(displayTitle: "English", index: -1)], + subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], + chapters: [], + selectedAudioStreamIndex: -1, + selectedSubtitleStreamIndex: -1, + subtitlesEnabled: true, + autoplayEnabled: false, + overlayType: .compact, + shouldShowPlayPreviousItem: true, + shouldShowPlayNextItem: true, + shouldShowAutoPlay: true, + container: "", + filename: nil, + versionName: nil + ) - static var previews: some View { - ZStack { - Color.red - .ignoresSafeArea() + static var previews: some View { + ZStack { + Color.red + .ignoresSafeArea() - tvOSVLCOverlay(viewModel: videoPlayerViewModel) - } - } + tvOSVLCOverlay(viewModel: videoPlayerViewModel) + } + } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift b/Swiftfin tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift index ac507f02..7cbc961a 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -11,23 +11,23 @@ import JellyfinAPI protocol PlayerOverlayDelegate { - func didSelectClose() - func didSelectMenu() + func didSelectClose() + func didSelectMenu() - func didSelectBackward() - func didSelectForward() - func didSelectMain() + func didSelectBackward() + func didSelectForward() + func didSelectMain() - func didGenerallyTap() + func didGenerallyTap() - func didBeginScrubbing() - func didEndScrubbing() + func didBeginScrubbing() + func didEndScrubbing() - func didSelectAudioStream(index: Int) - func didSelectSubtitleStream(index: Int) + func didSelectAudioStream(index: Int) + func didSelectSubtitleStream(index: Int) - func didSelectPlayPreviousItem() - func didSelectPlayNextItem() + func didSelectPlayPreviousItem() + func didSelectPlayNextItem() - func didSelectChapter(_ chapter: ChapterInfo) + func didSelectChapter(_ chapter: ChapterInfo) } diff --git a/Swiftfin tvOS/Views/VideoPlayer/VLCPlayerViewController.swift b/Swiftfin tvOS/Views/VideoPlayer/VLCPlayerViewController.swift index 64c7d643..0a844cc0 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/VLCPlayerViewController.swift @@ -20,881 +20,902 @@ import UIKit class VLCPlayerViewController: UIViewController { - // MARK: variables + // MARK: variables - private var viewModel: VideoPlayerViewModel - private var vlcMediaPlayer: VLCMediaPlayer - private var lastPlayerTicks: Int64 = 0 - private var lastProgressReportTicks: Int64 = 0 - private var viewModelListeners = Set() - private var overlayDismissTimer: Timer? - private var confirmCloseOverlayDismissTimer: Timer? + private var viewModel: VideoPlayerViewModel + private var vlcMediaPlayer: VLCMediaPlayer + private var lastPlayerTicks: Int64 = 0 + private var lastProgressReportTicks: Int64 = 0 + private var viewModelListeners = Set() + private var overlayDismissTimer: Timer? + private var confirmCloseOverlayDismissTimer: Timer? - private var currentPlayerTicks: Int64 { - Int64(vlcMediaPlayer.time.intValue) * 100_000 - } + private var currentPlayerTicks: Int64 { + Int64(vlcMediaPlayer.time.intValue) * 100_000 + } + + private var displayingOverlay: Bool { + currentOverlayHostingController?.view.alpha ?? 0 > 0 + } + + private var displayingContentOverlay: Bool { + currentOverlayContentHostingController?.view.alpha ?? 0 > 0 + } + + private var displayingConfirmClose: Bool { + currentConfirmCloseHostingController?.view.alpha ?? 0 > 0 + } + + private lazy var videoContentView = makeVideoContentView() + private lazy var jumpBackwardOverlayView = makeJumpBackwardOverlayView() + private lazy var jumpForwardOverlayView = makeJumpForwardOverlayView() + private var currentOverlayHostingController: UIHostingController? + private var currentOverlayContentHostingController: UIHostingController? + private var currentConfirmCloseHostingController: UIHostingController? + + // MARK: init + + init(viewModel: VideoPlayerViewModel) { + + self.viewModel = viewModel + self.vlcMediaPlayer = VLCMediaPlayer() + + super.init(nibName: nil, bundle: nil) + + viewModel.playerOverlayDelegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupSubviews() { + view.addSubview(videoContentView) + view.addSubview(jumpForwardOverlayView) + view.addSubview(jumpBackwardOverlayView) + + jumpBackwardOverlayView.alpha = 0 + jumpForwardOverlayView.alpha = 0 + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + videoContentView.topAnchor.constraint(equalTo: view.topAnchor), + videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), + videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor), + ]) + NSLayoutConstraint.activate([ + jumpBackwardOverlayView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 300), + jumpBackwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + NSLayoutConstraint.activate([ + jumpForwardOverlayView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -300), + jumpForwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } + + // MARK: viewWillDisappear + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + didSelectClose() + + let defaultNotificationCenter = NotificationCenter.default + defaultNotificationCenter.removeObserver(self, name: UIApplication.willTerminateNotification, object: nil) + defaultNotificationCenter.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil) + defaultNotificationCenter.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + // MARK: viewDidLoad + + override func viewDidLoad() { + super.viewDidLoad() + + setupSubviews() + setupConstraints() + + view.backgroundColor = .black + + setupMediaPlayer(newViewModel: viewModel) + + setupPanGestureRecognizer() + + addButtonPressRecognizer(pressType: .menu, action: #selector(didPressMenu)) + + let defaultNotificationCenter = NotificationCenter.default + defaultNotificationCenter.addObserver( + self, + selector: #selector(appWillTerminate), + name: UIApplication.willTerminateNotification, + object: nil + ) + defaultNotificationCenter.addObserver( + self, + selector: #selector(appWillResignActive), + name: UIApplication.willResignActiveNotification, + object: nil + ) + defaultNotificationCenter.addObserver( + self, + selector: #selector(appWillResignActive), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + } + + @objc + private func appWillTerminate() { + viewModel.sendStopReport() + } + + @objc + private func appWillResignActive() { + showOverlay() + + stopOverlayDismissTimer() + + vlcMediaPlayer.pause() + + viewModel.sendPauseReport(paused: true) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + startPlayback() + } + + // MARK: subviews + + private func makeVideoContentView() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .black + + return view + } + + private func makeJumpBackwardOverlayView() -> UIImageView { + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) + let forwardSymbolImage = UIImage(systemName: viewModel.jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) + let imageView = UIImageView(image: forwardSymbolImage) + imageView.translatesAutoresizingMaskIntoConstraints = false + + return imageView + } + + private func makeJumpForwardOverlayView() -> UIImageView { + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) + let forwardSymbolImage = UIImage(systemName: viewModel.jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) + let imageView = UIImageView(image: forwardSymbolImage) + imageView.translatesAutoresizingMaskIntoConstraints = false + + return imageView + } + + private func setupPanGestureRecognizer() { + let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(userPanned(panGestureRecognizer:))) + view.addGestureRecognizer(panGestureRecognizer) + } + + // MARK: pressesBegan + + override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + guard let buttonPress = presses.first?.type else { return } + + switch buttonPress { + case .menu: () // Captured by other recognizer + case .playPause: + hideConfirmCloseOverlay() + + didSelectMain() + case .select: + hideConfirmCloseOverlay() + + didGenerallyTap() + case .upArrow: + hideConfirmCloseOverlay() + case .downArrow: + hideConfirmCloseOverlay() + + if Defaults[.downActionShowsMenu] { + if !displayingContentOverlay && !displayingOverlay { + didSelectMenu() + } + } + case .leftArrow: + hideConfirmCloseOverlay() + + if !displayingContentOverlay && !displayingOverlay { + didSelectBackward() + } + case .rightArrow: + hideConfirmCloseOverlay() + + if !displayingContentOverlay && !displayingOverlay { + didSelectForward() + } + case .pageUp: () + case .pageDown: () + @unknown default: () + } + } + + private func addButtonPressRecognizer(pressType: UIPress.PressType, action: Selector) { + let pressRecognizer = UITapGestureRecognizer() + pressRecognizer.addTarget(self, action: action) + pressRecognizer.allowedPressTypes = [NSNumber(value: pressType.rawValue)] + view.addGestureRecognizer(pressRecognizer) + } + + // MARK: didPressMenu + + @objc + private func didPressMenu() { + if displayingOverlay { + hideOverlay() + } else if displayingContentOverlay { + hideOverlayContent() + } else if viewModel.confirmClose && !displayingConfirmClose { + + showConfirmCloseOverlay() + restartConfirmCloseDismissTimer() + + } else { + vlcMediaPlayer.pause() + + dismiss(animated: true, completion: nil) + } + } + + @objc + private func userPanned(panGestureRecognizer: UIPanGestureRecognizer) { + if displayingOverlay { + restartOverlayDismissTimer() + } + } + + // MARK: setupOverlayHostingController + + private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { + + // TODO: Look at injecting viewModel into the environment so it updates the current overlay - private var displayingOverlay: Bool { - currentOverlayHostingController?.view.alpha ?? 0 > 0 - } - - private var displayingContentOverlay: Bool { - currentOverlayContentHostingController?.view.alpha ?? 0 > 0 - } - - private var displayingConfirmClose: Bool { - currentConfirmCloseHostingController?.view.alpha ?? 0 > 0 - } + // Main overlay + if let currentOverlayHostingController = currentOverlayHostingController { + // UX fade-out + UIView.animate(withDuration: 0.5) { + currentOverlayHostingController.view.alpha = 0 + } completion: { _ in + currentOverlayHostingController.view.isHidden = true + + currentOverlayHostingController.view.removeFromSuperview() + currentOverlayHostingController.removeFromParent() + } + } + + let newOverlayView = tvOSVLCOverlay(viewModel: viewModel) + let newOverlayHostingController = UIHostingController(rootView: newOverlayView) + + newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newOverlayHostingController.view.backgroundColor = UIColor.clear + + // UX fade-in + newOverlayHostingController.view.alpha = 0 + + addChild(newOverlayHostingController) + view.addSubview(newOverlayHostingController.view) + newOverlayHostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) + + // UX fade-in + UIView.animate(withDuration: 0.5) { + newOverlayHostingController.view.alpha = 1 + } + + self.currentOverlayHostingController = newOverlayHostingController + + // Media Stream selection + if let currentOverlayContentHostingController = currentOverlayContentHostingController { + currentOverlayContentHostingController.view.isHidden = true - private lazy var videoContentView = makeVideoContentView() - private lazy var jumpBackwardOverlayView = makeJumpBackwardOverlayView() - private lazy var jumpForwardOverlayView = makeJumpForwardOverlayView() - private var currentOverlayHostingController: UIHostingController? - private var currentOverlayContentHostingController: UIHostingController? - private var currentConfirmCloseHostingController: UIHostingController? + currentOverlayContentHostingController.view.removeFromSuperview() + currentOverlayContentHostingController.removeFromParent() + } - // MARK: init + let newSmallMenuOverlayView = SmallMediaStreamSelectionView(viewModel: viewModel) - init(viewModel: VideoPlayerViewModel) { + let newOverlayContentHostingController = UIHostingController(rootView: newSmallMenuOverlayView) - self.viewModel = viewModel - self.vlcMediaPlayer = VLCMediaPlayer() + newOverlayContentHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newOverlayContentHostingController.view.backgroundColor = UIColor.clear - super.init(nibName: nil, bundle: nil) + newOverlayContentHostingController.view.alpha = 0 - viewModel.playerOverlayDelegate = self - } + addChild(newOverlayContentHostingController) + view.addSubview(newOverlayContentHostingController.view) + newOverlayContentHostingController.didMove(toParent: self) - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + NSLayoutConstraint.activate([ + newOverlayContentHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newOverlayContentHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newOverlayContentHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newOverlayContentHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) - private func setupSubviews() { - view.addSubview(videoContentView) - view.addSubview(jumpForwardOverlayView) - view.addSubview(jumpBackwardOverlayView) + self.currentOverlayContentHostingController = newOverlayContentHostingController - jumpBackwardOverlayView.alpha = 0 - jumpForwardOverlayView.alpha = 0 - } + // Confirm close + if let currentConfirmCloseHostingController = currentConfirmCloseHostingController { + currentConfirmCloseHostingController.view.isHidden = true - private func setupConstraints() { - NSLayoutConstraint.activate([ - videoContentView.topAnchor.constraint(equalTo: view.topAnchor), - videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), - videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor), - ]) - NSLayoutConstraint.activate([ - jumpBackwardOverlayView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 300), - jumpBackwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - NSLayoutConstraint.activate([ - jumpForwardOverlayView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -300), - jumpForwardOverlayView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - } + currentConfirmCloseHostingController.view.removeFromSuperview() + currentConfirmCloseHostingController.removeFromParent() + } - // MARK: viewWillDisappear + let newConfirmCloseOverlay = ConfirmCloseOverlay() - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) + let newConfirmCloseHostingController = UIHostingController(rootView: newConfirmCloseOverlay) - didSelectClose() + newConfirmCloseHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newConfirmCloseHostingController.view.backgroundColor = UIColor.clear - let defaultNotificationCenter = NotificationCenter.default - defaultNotificationCenter.removeObserver(self, name: UIApplication.willTerminateNotification, object: nil) - defaultNotificationCenter.removeObserver(self, name: UIApplication.willResignActiveNotification, object: nil) - defaultNotificationCenter.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil) - } + newConfirmCloseHostingController.view.alpha = 0 - // MARK: viewDidLoad + addChild(newConfirmCloseHostingController) + view.addSubview(newConfirmCloseHostingController.view) + newConfirmCloseHostingController.didMove(toParent: self) - override func viewDidLoad() { - super.viewDidLoad() + NSLayoutConstraint.activate([ + newConfirmCloseHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newConfirmCloseHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newConfirmCloseHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newConfirmCloseHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) - setupSubviews() - setupConstraints() + self.currentConfirmCloseHostingController = newConfirmCloseHostingController - view.backgroundColor = .black - - setupMediaPlayer(newViewModel: viewModel) - - setupPanGestureRecognizer() - - addButtonPressRecognizer(pressType: .menu, action: #selector(didPressMenu)) - - let defaultNotificationCenter = NotificationCenter.default - defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, - object: nil) - defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), - name: UIApplication.willResignActiveNotification, object: nil) - defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), - name: UIApplication.didEnterBackgroundNotification, object: nil) - } - - @objc - private func appWillTerminate() { - viewModel.sendStopReport() - } - - @objc - private func appWillResignActive() { - showOverlay() - - stopOverlayDismissTimer() - - vlcMediaPlayer.pause() - - viewModel.sendPauseReport(paused: true) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - startPlayback() - } - - // MARK: subviews - - private func makeVideoContentView() -> UIView { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .black - - return view - } - - private func makeJumpBackwardOverlayView() -> UIImageView { - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) - let forwardSymbolImage = UIImage(systemName: viewModel.jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) - let imageView = UIImageView(image: forwardSymbolImage) - imageView.translatesAutoresizingMaskIntoConstraints = false - - return imageView - } - - private func makeJumpForwardOverlayView() -> UIImageView { - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 72) - let forwardSymbolImage = UIImage(systemName: viewModel.jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) - let imageView = UIImageView(image: forwardSymbolImage) - imageView.translatesAutoresizingMaskIntoConstraints = false - - return imageView - } - - private func setupPanGestureRecognizer() { - let panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(userPanned(panGestureRecognizer:))) - view.addGestureRecognizer(panGestureRecognizer) - } - - // MARK: pressesBegan - - override func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - guard let buttonPress = presses.first?.type else { return } - - switch buttonPress { - case .menu: () // Captured by other recognizer - case .playPause: - hideConfirmCloseOverlay() - - didSelectMain() - case .select: - hideConfirmCloseOverlay() - - didGenerallyTap() - case .upArrow: - hideConfirmCloseOverlay() - case .downArrow: - hideConfirmCloseOverlay() - - if Defaults[.downActionShowsMenu] { - if !displayingContentOverlay && !displayingOverlay { - didSelectMenu() - } - } - case .leftArrow: - hideConfirmCloseOverlay() - - if !displayingContentOverlay && !displayingOverlay { - didSelectBackward() - } - case .rightArrow: - hideConfirmCloseOverlay() - - if !displayingContentOverlay && !displayingOverlay { - didSelectForward() - } - case .pageUp: () - case .pageDown: () - @unknown default: () - } - } - - private func addButtonPressRecognizer(pressType: UIPress.PressType, action: Selector) { - let pressRecognizer = UITapGestureRecognizer() - pressRecognizer.addTarget(self, action: action) - pressRecognizer.allowedPressTypes = [NSNumber(value: pressType.rawValue)] - view.addGestureRecognizer(pressRecognizer) - } - - // MARK: didPressMenu - - @objc - private func didPressMenu() { - if displayingOverlay { - hideOverlay() - } else if displayingContentOverlay { - hideOverlayContent() - } else if viewModel.confirmClose && !displayingConfirmClose { - - showConfirmCloseOverlay() - restartConfirmCloseDismissTimer() - - } else { - vlcMediaPlayer.pause() - - dismiss(animated: true, completion: nil) - } - } - - @objc - private func userPanned(panGestureRecognizer: UIPanGestureRecognizer) { - if displayingOverlay { - restartOverlayDismissTimer() - } - } - - // MARK: setupOverlayHostingController - - private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { - - // TODO: Look at injecting viewModel into the environment so it updates the current overlay - - // Main overlay - if let currentOverlayHostingController = currentOverlayHostingController { - // UX fade-out - UIView.animate(withDuration: 0.5) { - currentOverlayHostingController.view.alpha = 0 - } completion: { _ in - currentOverlayHostingController.view.isHidden = true - - currentOverlayHostingController.view.removeFromSuperview() - currentOverlayHostingController.removeFromParent() - } - } - - let newOverlayView = tvOSVLCOverlay(viewModel: viewModel) - let newOverlayHostingController = UIHostingController(rootView: newOverlayView) - - newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newOverlayHostingController.view.backgroundColor = UIColor.clear - - // UX fade-in - newOverlayHostingController.view.alpha = 0 - - addChild(newOverlayHostingController) - view.addSubview(newOverlayHostingController.view) - newOverlayHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - - // UX fade-in - UIView.animate(withDuration: 0.5) { - newOverlayHostingController.view.alpha = 1 - } - - self.currentOverlayHostingController = newOverlayHostingController - - // Media Stream selection - if let currentOverlayContentHostingController = currentOverlayContentHostingController { - currentOverlayContentHostingController.view.isHidden = true - - currentOverlayContentHostingController.view.removeFromSuperview() - currentOverlayContentHostingController.removeFromParent() - } - - let newSmallMenuOverlayView = SmallMediaStreamSelectionView(viewModel: viewModel) - - let newOverlayContentHostingController = UIHostingController(rootView: newSmallMenuOverlayView) - - newOverlayContentHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newOverlayContentHostingController.view.backgroundColor = UIColor.clear - - newOverlayContentHostingController.view.alpha = 0 - - addChild(newOverlayContentHostingController) - view.addSubview(newOverlayContentHostingController.view) - newOverlayContentHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newOverlayContentHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newOverlayContentHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newOverlayContentHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newOverlayContentHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - - self.currentOverlayContentHostingController = newOverlayContentHostingController - - // Confirm close - if let currentConfirmCloseHostingController = currentConfirmCloseHostingController { - currentConfirmCloseHostingController.view.isHidden = true - - currentConfirmCloseHostingController.view.removeFromSuperview() - currentConfirmCloseHostingController.removeFromParent() - } - - let newConfirmCloseOverlay = ConfirmCloseOverlay() - - let newConfirmCloseHostingController = UIHostingController(rootView: newConfirmCloseOverlay) - - newConfirmCloseHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newConfirmCloseHostingController.view.backgroundColor = UIColor.clear - - newConfirmCloseHostingController.view.alpha = 0 - - addChild(newConfirmCloseHostingController) - view.addSubview(newConfirmCloseHostingController.view) - newConfirmCloseHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newConfirmCloseHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newConfirmCloseHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newConfirmCloseHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newConfirmCloseHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - - self.currentConfirmCloseHostingController = newConfirmCloseHostingController - - // There is a behavior when setting this that the navigation bar - // on the current navigation controller pops up, re-hide it - self.navigationController?.isNavigationBarHidden = true - } + // There is a behavior when setting this that the navigation bar + // on the current navigation controller pops up, re-hide it + self.navigationController?.isNavigationBarHidden = true + } } // MARK: setupMediaPlayer extension VLCPlayerViewController { - /// Main function that handles setting up the media player with the current VideoPlayerViewModel - /// and also takes the role of setting the 'viewModel' property with the given viewModel - /// - /// Use case for this is setting new media within the same VLCPlayerViewController - func setupMediaPlayer(newViewModel: VideoPlayerViewModel) { + /// Main function that handles setting up the media player with the current VideoPlayerViewModel + /// and also takes the role of setting the 'viewModel' property with the given viewModel + /// + /// Use case for this is setting new media within the same VLCPlayerViewController + func setupMediaPlayer(newViewModel: VideoPlayerViewModel) { - // remove old player + // remove old player - if vlcMediaPlayer.media != nil { - viewModelListeners.forEach { $0.cancel() } + if vlcMediaPlayer.media != nil { + viewModelListeners.forEach { $0.cancel() } - vlcMediaPlayer.stop() - viewModel.sendStopReport() - viewModel.playerOverlayDelegate = nil - } + vlcMediaPlayer.stop() + viewModel.sendStopReport() + viewModel.playerOverlayDelegate = nil + } - vlcMediaPlayer = VLCMediaPlayer() + vlcMediaPlayer = VLCMediaPlayer() - // setup with new player and view model + // setup with new player and view model - vlcMediaPlayer = VLCMediaPlayer() + vlcMediaPlayer = VLCMediaPlayer() - vlcMediaPlayer.delegate = self - vlcMediaPlayer.drawable = videoContentView + vlcMediaPlayer.delegate = self + vlcMediaPlayer.drawable = videoContentView - vlcMediaPlayer.setSubtitleSize(Defaults[.subtitleSize]) + vlcMediaPlayer.setSubtitleSize(Defaults[.subtitleSize]) - stopOverlayDismissTimer() + stopOverlayDismissTimer() - // Stop current media if there is one - if vlcMediaPlayer.media != nil { - viewModelListeners.forEach { $0.cancel() } + // Stop current media if there is one + if vlcMediaPlayer.media != nil { + viewModelListeners.forEach { $0.cancel() } - vlcMediaPlayer.stop() - viewModel.sendStopReport() - viewModel.playerOverlayDelegate = nil - } + vlcMediaPlayer.stop() + viewModel.sendStopReport() + viewModel.playerOverlayDelegate = nil + } - lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - // TODO: Custom buffer/cache amounts + // TODO: Custom buffer/cache amounts - let media: VLCMedia + let media: VLCMedia - if let transcodedURL = newViewModel.transcodedStreamURL, - !Defaults[.Experimental.forceDirectPlay] - { - media = VLCMedia(url: transcodedURL) - } else { - media = VLCMedia(url: newViewModel.directStreamURL) - } + if let transcodedURL = newViewModel.transcodedStreamURL, + !Defaults[.Experimental.forceDirectPlay] + { + media = VLCMedia(url: transcodedURL) + } else { + media = VLCMedia(url: newViewModel.directStreamURL) + } - media.addOption("--prefetch-buffer-size=1048576") - media.addOption("--network-caching=5000") + media.addOption("--prefetch-buffer-size=1048576") + media.addOption("--network-caching=5000") - vlcMediaPlayer.media = media + vlcMediaPlayer.media = media - setupOverlayHostingController(viewModel: newViewModel) - setupViewModelListeners(viewModel: newViewModel) + setupOverlayHostingController(viewModel: newViewModel) + setupViewModelListeners(viewModel: newViewModel) - newViewModel.getAdjacentEpisodes() - newViewModel.playerOverlayDelegate = self + newViewModel.getAdjacentEpisodes() + newViewModel.playerOverlayDelegate = self - let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 + let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 - if startPercentage > 0 { - if viewModel.resumeOffset { - let runTimeTicks = viewModel.item.runTimeTicks ?? 0 - let videoDurationSeconds = Double(runTimeTicks / 10_000_000) - var startSeconds = round((startPercentage / 100) * videoDurationSeconds) - startSeconds = startSeconds.subtract(5, floor: 0) - let newStartPercentage = startSeconds / videoDurationSeconds - newViewModel.sliderPercentage = newStartPercentage - } else { - newViewModel.sliderPercentage = startPercentage / 100 - } - } + if startPercentage > 0 { + if viewModel.resumeOffset { + let runTimeTicks = viewModel.item.runTimeTicks ?? 0 + let videoDurationSeconds = Double(runTimeTicks / 10_000_000) + var startSeconds = round((startPercentage / 100) * videoDurationSeconds) + startSeconds = startSeconds.subtract(5, floor: 0) + let newStartPercentage = startSeconds / videoDurationSeconds + newViewModel.sliderPercentage = newStartPercentage + } else { + newViewModel.sliderPercentage = startPercentage / 100 + } + } - viewModel = newViewModel + viewModel = newViewModel - if viewModel.streamType == .direct { - LogManager.log.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? "--")") - } else if viewModel.streamType == .transcode && Defaults[.Experimental.forceDirectPlay] { - LogManager.log.debug("Player set up with forced direct stream for item: \(viewModel.item.id ?? "--")") - } else { - LogManager.log.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? "--")") - } - } + if viewModel.streamType == .direct { + LogManager.log.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? "--")") + } else if viewModel.streamType == .transcode && Defaults[.Experimental.forceDirectPlay] { + LogManager.log.debug("Player set up with forced direct stream for item: \(viewModel.item.id ?? "--")") + } else { + LogManager.log.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? "--")") + } + } - // MARK: startPlayback + // MARK: startPlayback - func startPlayback() { - vlcMediaPlayer.play() + func startPlayback() { + vlcMediaPlayer.play() - // Setup external subtitles - for externalSubtitle in viewModel.subtitleStreams.filter({ $0.deliveryMethod == .external }) { - if let deliveryURL = externalSubtitle.externalURL(base: SessionManager.main.currentLogin.server.currentURI) { - vlcMediaPlayer.addPlaybackSlave(deliveryURL, type: .subtitle, enforce: false) - } - } + // Setup external subtitles + for externalSubtitle in viewModel.subtitleStreams.filter({ $0.deliveryMethod == .external }) { + if let deliveryURL = externalSubtitle.externalURL(base: SessionManager.main.currentLogin.server.currentURI) { + vlcMediaPlayer.addPlaybackSlave(deliveryURL, type: .subtitle, enforce: false) + } + } - setMediaPlayerTimeAtCurrentSlider() + setMediaPlayerTimeAtCurrentSlider() - viewModel.sendPlayReport() + viewModel.sendPlayReport() - restartOverlayDismissTimer(interval: 5) - } + restartOverlayDismissTimer(interval: 5) + } - // MARK: setupViewModelListeners + // MARK: setupViewModelListeners - private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { - viewModel.$playbackSpeed.sink { newSpeed in - self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) - }.store(in: &viewModelListeners) + private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { + viewModel.$playbackSpeed.sink { newSpeed in + self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) + }.store(in: &viewModelListeners) - viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in - if sliderIsScrubbing { - self.didBeginScrubbing() - } else { - self.didEndScrubbing() - } - }.store(in: &viewModelListeners) + viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in + if sliderIsScrubbing { + self.didBeginScrubbing() + } else { + self.didEndScrubbing() + } + }.store(in: &viewModelListeners) - viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in - self.didSelectAudioStream(index: newAudioStreamIndex) - }.store(in: &viewModelListeners) + viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in + self.didSelectAudioStream(index: newAudioStreamIndex) + }.store(in: &viewModelListeners) - viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in - self.didSelectSubtitleStream(index: newSubtitleStreamIndex) - }.store(in: &viewModelListeners) + viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in + self.didSelectSubtitleStream(index: newSubtitleStreamIndex) + }.store(in: &viewModelListeners) - viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in - self.didToggleSubtitles(newValue: newSubtitlesEnabled) - }.store(in: &viewModelListeners) - } + viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in + self.didToggleSubtitles(newValue: newSubtitlesEnabled) + }.store(in: &viewModelListeners) + } - func setMediaPlayerTimeAtCurrentSlider() { - // Necessary math as VLCMediaPlayer doesn't work well - // by just setting the position - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let runTimeTicks = viewModel.item.runTimeTicks ?? 0 - let videoDuration = Double(runTimeTicks / 10_000_000) - let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) - let newPositionOffset = secondsScrubbedTo - videoPosition + func setMediaPlayerTimeAtCurrentSlider() { + // Necessary math as VLCMediaPlayer doesn't work well + // by just setting the position + let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) + let runTimeTicks = viewModel.item.runTimeTicks ?? 0 + let videoDuration = Double(runTimeTicks / 10_000_000) + let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) + let newPositionOffset = secondsScrubbedTo - videoPosition - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } - } + if newPositionOffset > 0 { + vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) + } else { + vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) + } + } } // MARK: Show/Hide Overlay extension VLCPlayerViewController { - private func showOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } + private func showOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } - guard overlayHostingController.view.alpha != 1 else { return } + guard overlayHostingController.view.alpha != 1 else { return } - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 1 - } - } + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 1 + } + } - private func hideOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } + private func hideOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } - guard overlayHostingController.view.alpha != 0 else { return } + guard overlayHostingController.view.alpha != 0 else { return } - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 0 - } - } + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 0 + } + } - private func toggleOverlay() { - if displayingOverlay { - hideOverlay() - } else { - showOverlay() - } - } + private func toggleOverlay() { + if displayingOverlay { + hideOverlay() + } else { + showOverlay() + } + } - private func showOverlayContent() { - guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return } + private func showOverlayContent() { + guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return } - guard currentOverlayContentHostingController.view.alpha != 1 else { return } + guard currentOverlayContentHostingController.view.alpha != 1 else { return } - currentOverlayContentHostingController.view.setNeedsFocusUpdate() - currentOverlayContentHostingController.setNeedsFocusUpdate() - setNeedsFocusUpdate() + currentOverlayContentHostingController.view.setNeedsFocusUpdate() + currentOverlayContentHostingController.setNeedsFocusUpdate() + setNeedsFocusUpdate() - UIView.animate(withDuration: 0.2) { - currentOverlayContentHostingController.view.alpha = 1 - } - } + UIView.animate(withDuration: 0.2) { + currentOverlayContentHostingController.view.alpha = 1 + } + } - private func hideOverlayContent() { - guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return } + private func hideOverlayContent() { + guard let currentOverlayContentHostingController = currentOverlayContentHostingController else { return } - guard currentOverlayContentHostingController.view.alpha != 0 else { return } + guard currentOverlayContentHostingController.view.alpha != 0 else { return } - setNeedsFocusUpdate() + setNeedsFocusUpdate() - UIView.animate(withDuration: 0.2) { - currentOverlayContentHostingController.view.alpha = 0 - } - } + UIView.animate(withDuration: 0.2) { + currentOverlayContentHostingController.view.alpha = 0 + } + } } // MARK: Show/Hide Jump extension VLCPlayerViewController { - private func flashJumpBackwardOverlay() { - jumpBackwardOverlayView.layer.removeAllAnimations() + private func flashJumpBackwardOverlay() { + jumpBackwardOverlayView.layer.removeAllAnimations() - UIView.animate(withDuration: 0.1) { - self.jumpBackwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpBackwardOverlay() - } - } + UIView.animate(withDuration: 0.1) { + self.jumpBackwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpBackwardOverlay() + } + } - private func hideJumpBackwardOverlay() { - UIView.animate(withDuration: 0.3) { - self.jumpBackwardOverlayView.alpha = 0 - } - } + private func hideJumpBackwardOverlay() { + UIView.animate(withDuration: 0.3) { + self.jumpBackwardOverlayView.alpha = 0 + } + } - private func flashJumpFowardOverlay() { - jumpForwardOverlayView.layer.removeAllAnimations() + private func flashJumpFowardOverlay() { + jumpForwardOverlayView.layer.removeAllAnimations() - UIView.animate(withDuration: 0.1) { - self.jumpForwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpForwardOverlay() - } - } + UIView.animate(withDuration: 0.1) { + self.jumpForwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpForwardOverlay() + } + } - private func hideJumpForwardOverlay() { - UIView.animate(withDuration: 0.3) { - self.jumpForwardOverlayView.alpha = 0 - } - } + private func hideJumpForwardOverlay() { + UIView.animate(withDuration: 0.3) { + self.jumpForwardOverlayView.alpha = 0 + } + } } // MARK: Show/Hide Confirm close extension VLCPlayerViewController { - private func showConfirmCloseOverlay() { - guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return } + private func showConfirmCloseOverlay() { + guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return } - UIView.animate(withDuration: 0.2) { - currentConfirmCloseHostingController.view.alpha = 1 - } - } + UIView.animate(withDuration: 0.2) { + currentConfirmCloseHostingController.view.alpha = 1 + } + } - private func hideConfirmCloseOverlay() { - guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return } + private func hideConfirmCloseOverlay() { + guard let currentConfirmCloseHostingController = currentConfirmCloseHostingController else { return } - UIView.animate(withDuration: 0.5) { - currentConfirmCloseHostingController.view.alpha = 0 - } - } + UIView.animate(withDuration: 0.5) { + currentConfirmCloseHostingController.view.alpha = 0 + } + } } // MARK: OverlayTimer extension VLCPlayerViewController { - private func restartOverlayDismissTimer(interval: Double = 5) { - self.overlayDismissTimer?.invalidate() - self.overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), - userInfo: nil, repeats: false) - } + private func restartOverlayDismissTimer(interval: Double = 5) { + self.overlayDismissTimer?.invalidate() + self.overlayDismissTimer = Timer.scheduledTimer( + timeInterval: interval, + target: self, + selector: #selector(dismissTimerFired), + userInfo: nil, + repeats: false + ) + } - @objc - private func dismissTimerFired() { - hideOverlay() - } + @objc + private func dismissTimerFired() { + hideOverlay() + } - private func stopOverlayDismissTimer() { - overlayDismissTimer?.invalidate() - } + private func stopOverlayDismissTimer() { + overlayDismissTimer?.invalidate() + } } // MARK: Confirm Close Overlay Timer extension VLCPlayerViewController { - private func restartConfirmCloseDismissTimer() { - self.confirmCloseOverlayDismissTimer?.invalidate() - self.confirmCloseOverlayDismissTimer = Timer.scheduledTimer(timeInterval: 5, target: self, - selector: #selector(confirmCloseTimerFired), userInfo: nil, - repeats: false) - } + private func restartConfirmCloseDismissTimer() { + self.confirmCloseOverlayDismissTimer?.invalidate() + self.confirmCloseOverlayDismissTimer = Timer.scheduledTimer( + timeInterval: 5, + target: self, + selector: #selector(confirmCloseTimerFired), + userInfo: nil, + repeats: false + ) + } - @objc - private func confirmCloseTimerFired() { - hideConfirmCloseOverlay() - } + @objc + private func confirmCloseTimerFired() { + hideConfirmCloseOverlay() + } - private func stopConfirmCloseDismissTimer() { - confirmCloseOverlayDismissTimer?.invalidate() - } + private func stopConfirmCloseDismissTimer() { + confirmCloseOverlayDismissTimer?.invalidate() + } } // MARK: VLCMediaPlayerDelegate extension VLCPlayerViewController: VLCMediaPlayerDelegate { - // MARK: mediaPlayerStateChanged + // MARK: mediaPlayerStateChanged - func mediaPlayerStateChanged(_ aNotification: Notification) { + func mediaPlayerStateChanged(_ aNotification: Notification) { - // Don't show buffering if paused, usually here while scrubbing - if vlcMediaPlayer.state == .buffering && viewModel.playerState == .paused { - return - } + // Don't show buffering if paused, usually here while scrubbing + if vlcMediaPlayer.state == .buffering && viewModel.playerState == .paused { + return + } - viewModel.playerState = vlcMediaPlayer.state + viewModel.playerState = vlcMediaPlayer.state - if vlcMediaPlayer.state == VLCMediaPlayerState.ended { - if viewModel.autoplayEnabled && viewModel.nextItemVideoPlayerViewModel != nil { - didSelectPlayNextItem() - } else { - didSelectClose() - } - } - } + if vlcMediaPlayer.state == VLCMediaPlayerState.ended { + if viewModel.autoplayEnabled && viewModel.nextItemVideoPlayerViewModel != nil { + didSelectPlayNextItem() + } else { + didSelectClose() + } + } + } - // MARK: mediaPlayerTimeChanged + // MARK: mediaPlayerTimeChanged - func mediaPlayerTimeChanged(_ aNotification: Notification) { + func mediaPlayerTimeChanged(_ aNotification: Notification) { - if !viewModel.sliderIsScrubbing { - viewModel.sliderPercentage = Double(vlcMediaPlayer.position) - } + if !viewModel.sliderIsScrubbing { + viewModel.sliderPercentage = Double(vlcMediaPlayer.position) + } - // Have to manually set playing because VLCMediaPlayer doesn't - // properly set it itself - if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 { - viewModel.playerState = VLCMediaPlayerState.playing - } + // Have to manually set playing because VLCMediaPlayer doesn't + // properly set it itself + if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 { + viewModel.playerState = VLCMediaPlayerState.playing + } - // If needing to fix subtitle streams during playback - if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.selectedSubtitleStreamIndex && - viewModel.subtitlesEnabled - { - didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) - } + // If needing to fix subtitle streams during playback + if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.selectedSubtitleStreamIndex && + viewModel.subtitlesEnabled + { + didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) + } - if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { - didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) - } + if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { + didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) + } - lastPlayerTicks = currentPlayerTicks + lastPlayerTicks = currentPlayerTicks - // Send progress report every 5 seconds - if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { - viewModel.sendProgressReport() + // Send progress report every 5 seconds + if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } - } + lastProgressReportTicks = currentPlayerTicks + } + } } // MARK: PlayerOverlayDelegate extension VLCPlayerViewController: PlayerOverlayDelegate { - func didSelectAudioStream(index: Int) { - vlcMediaPlayer.currentAudioTrackIndex = Int32(index) + func didSelectAudioStream(index: Int) { + vlcMediaPlayer.currentAudioTrackIndex = Int32(index) - viewModel.sendProgressReport() + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } + lastProgressReportTicks = currentPlayerTicks + } - /// Do not call when setting to index -1 - func didSelectSubtitleStream(index: Int) { + /// Do not call when setting to index -1 + func didSelectSubtitleStream(index: Int) { - viewModel.subtitlesEnabled = true - vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) + viewModel.subtitlesEnabled = true + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) - viewModel.sendProgressReport() + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } + lastProgressReportTicks = currentPlayerTicks + } - func didSelectClose() { - vlcMediaPlayer.stop() + func didSelectClose() { + vlcMediaPlayer.stop() - viewModel.sendStopReport() + viewModel.sendStopReport() - dismiss(animated: true, completion: nil) - } + dismiss(animated: true, completion: nil) + } - func didToggleSubtitles(newValue: Bool) { - if newValue { - vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex) - } else { - vlcMediaPlayer.currentVideoSubTitleIndex = -1 - } - } + func didToggleSubtitles(newValue: Bool) { + if newValue { + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex) + } else { + vlcMediaPlayer.currentVideoSubTitleIndex = -1 + } + } - func didSelectMenu() { - stopOverlayDismissTimer() + func didSelectMenu() { + stopOverlayDismissTimer() - hideOverlay() - showOverlayContent() - } + hideOverlay() + showOverlayContent() + } - func didSelectBackward() { + func didSelectBackward() { - flashJumpBackwardOverlay() + flashJumpBackwardOverlay() - vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) + vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) - if displayingOverlay { - restartOverlayDismissTimer() - } + if displayingOverlay { + restartOverlayDismissTimer() + } - viewModel.sendProgressReport() + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } + lastProgressReportTicks = currentPlayerTicks + } - func didSelectForward() { + func didSelectForward() { - flashJumpFowardOverlay() + flashJumpFowardOverlay() - vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) + vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) - if displayingOverlay { - restartOverlayDismissTimer() - } + if displayingOverlay { + restartOverlayDismissTimer() + } - viewModel.sendProgressReport() + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } + lastProgressReportTicks = currentPlayerTicks + } - func didSelectMain() { + func didSelectMain() { - switch viewModel.playerState { - case .buffering: - vlcMediaPlayer.play() - restartOverlayDismissTimer() - case .playing: - viewModel.sendPauseReport(paused: true) - vlcMediaPlayer.pause() + switch viewModel.playerState { + case .buffering: + vlcMediaPlayer.play() + restartOverlayDismissTimer() + case .playing: + viewModel.sendPauseReport(paused: true) + vlcMediaPlayer.pause() - showOverlay() - restartOverlayDismissTimer(interval: 5) - case .paused: - viewModel.sendPauseReport(paused: false) - vlcMediaPlayer.play() - restartOverlayDismissTimer() - default: () - } - } + showOverlay() + restartOverlayDismissTimer(interval: 5) + case .paused: + viewModel.sendPauseReport(paused: false) + vlcMediaPlayer.play() + restartOverlayDismissTimer() + default: () + } + } - func didGenerallyTap() { - toggleOverlay() + func didGenerallyTap() { + toggleOverlay() - restartOverlayDismissTimer(interval: 5) - } + restartOverlayDismissTimer(interval: 5) + } - func didBeginScrubbing() { - stopOverlayDismissTimer() - } + func didBeginScrubbing() { + stopOverlayDismissTimer() + } - func didEndScrubbing() { - setMediaPlayerTimeAtCurrentSlider() + func didEndScrubbing() { + setMediaPlayerTimeAtCurrentSlider() - restartOverlayDismissTimer() + restartOverlayDismissTimer() - viewModel.sendProgressReport() + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } + lastProgressReportTicks = currentPlayerTicks + } - func didSelectPlayPreviousItem() { - if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel { - setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel) - startPlayback() - } - } + func didSelectPlayPreviousItem() { + if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel { + setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel) + startPlayback() + } + } - func didSelectPlayNextItem() { - if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel { - setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel) - startPlayback() - } - } + func didSelectPlayNextItem() { + if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel { + setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel) + startPlayback() + } + } - func didSelectChapter(_ chapter: ChapterInfo) { - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let chapterSeconds = Double((chapter.startPositionTicks ?? 0) / 10_000_000) - let newPositionOffset = chapterSeconds - videoPosition + func didSelectChapter(_ chapter: ChapterInfo) { + let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) + let chapterSeconds = Double((chapter.startPositionTicks ?? 0) / 10_000_000) + let newPositionOffset = chapterSeconds - videoPosition - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } + if newPositionOffset > 0 { + vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) + } else { + vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) + } - viewModel.sendProgressReport() - } + viewModel.sendProgressReport() + } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/VideoPlayerView.swift b/Swiftfin tvOS/Views/VideoPlayer/VideoPlayerView.swift index 9a2908db..7d3a2cd1 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/VideoPlayerView.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/VideoPlayerView.swift @@ -11,28 +11,28 @@ import UIKit struct NativePlayerView: UIViewControllerRepresentable { - let viewModel: VideoPlayerViewModel + let viewModel: VideoPlayerViewModel - typealias UIViewControllerType = NativePlayerViewController + typealias UIViewControllerType = NativePlayerViewController - func makeUIViewController(context: Context) -> NativePlayerViewController { + func makeUIViewController(context: Context) -> NativePlayerViewController { - NativePlayerViewController(viewModel: viewModel) - } + NativePlayerViewController(viewModel: viewModel) + } - func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) {} + func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) {} } struct VLCPlayerView: UIViewControllerRepresentable { - let viewModel: VideoPlayerViewModel + let viewModel: VideoPlayerViewModel - typealias UIViewControllerType = VLCPlayerViewController + typealias UIViewControllerType = VLCPlayerViewController - func makeUIViewController(context: Context) -> VLCPlayerViewController { + func makeUIViewController(context: Context) -> VLCPlayerViewController { - VLCPlayerViewController(viewModel: viewModel) - } + VLCPlayerViewController(viewModel: viewModel) + } - func updateUIViewController(_ uiViewController: VLCPlayerViewController, context: Context) {} + func updateUIViewController(_ uiViewController: VLCPlayerViewController, context: Context) {} } diff --git a/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift b/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift index b5a8b0e5..10bab22e 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/SliderView.swift @@ -10,30 +10,30 @@ import SwiftUI struct SliderView: UIViewRepresentable { - @ObservedObject - var viewModel: VideoPlayerViewModel + @ObservedObject + var viewModel: VideoPlayerViewModel - // TODO: look at adjusting value dependent on item runtime - private let maxValue: Double = 1000 + // TODO: look at adjusting value dependent on item runtime + private let maxValue: Double = 1000 - func updateUIView(_ uiView: TvOSSlider, context: Context) { - guard !viewModel.sliderIsScrubbing else { return } - uiView.value = Float(maxValue * viewModel.sliderPercentage) - } + func updateUIView(_ uiView: TvOSSlider, context: Context) { + guard !viewModel.sliderIsScrubbing else { return } + uiView.value = Float(maxValue * viewModel.sliderPercentage) + } - func makeUIView(context: Context) -> TvOSSlider { - let slider = TvOSSlider(viewModel: viewModel) + func makeUIView(context: Context) -> TvOSSlider { + let slider = TvOSSlider(viewModel: viewModel) - slider.minimumValue = 0 - slider.maximumValue = Float(maxValue) - slider.value = Float(maxValue * viewModel.sliderPercentage) - slider.thumbSize = 25 - slider.thumbTintColor = .white - slider.minimumTrackTintColor = .white - slider.focusScaleFactor = 1.4 - slider.panDampingValue = 50 - slider.fineTunningVelocityThreshold = 1000 + slider.minimumValue = 0 + slider.maximumValue = Float(maxValue) + slider.value = Float(maxValue * viewModel.sliderPercentage) + slider.thumbSize = 25 + slider.thumbTintColor = .white + slider.minimumTrackTintColor = .white + slider.focusScaleFactor = 1.4 + slider.panDampingValue = 50 + slider.fineTunningVelocityThreshold = 1000 - return slider - } + return slider + } } diff --git a/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift b/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift index 7c531348..32e7e153 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/tvOSSLider/tvOSSlider.swift @@ -12,11 +12,11 @@ import GameController import UIKit enum DPadState { - case select - case right - case left - case up - case down + case select + case right + case left + case up + case down } private let trackViewHeight: CGFloat = 5 @@ -36,523 +36,530 @@ private let decelerationMaxVelocity: Float = 1000 /// A control used to select a single value from a continuous range of values. public final class TvOSSlider: UIControl { - // MARK: - Public - - /// The slider’s current value. - @IBInspectable - public var value: Float { - get { - storedValue - } - set { - storedValue = min(maximumValue, newValue) - storedValue = max(minimumValue, storedValue) - - var offset = trackView.bounds.width * CGFloat((storedValue - minimumValue) / (maximumValue - minimumValue)) - offset = min(trackView.bounds.width, offset) - thumbViewCenterXConstraint.constant = offset - } - } - - /// The minimum value of the slider. - @IBInspectable - public var minimumValue: Float = defaultMinimumValue { - didSet { - value = max(value, minimumValue) - } - } - - /// The maximum value of the slider. - @IBInspectable - public var maximumValue: Float = defaultMaximumValue { - didSet { - value = min(value, maximumValue) - } - } - - /// A Boolean value indicating whether changes in the slider’s value generate continuous update events. - @IBInspectable - public var isContinuous: Bool = defaultIsContinuous - - /// The color used to tint the default minimum track images. - @IBInspectable - public var minimumTrackTintColor: UIColor? = defaultMininumTrackTintColor { - didSet { - minimumTrackView.backgroundColor = minimumTrackTintColor - } - } - - /// The color used to tint the default maximum track images. - @IBInspectable - public var maximumTrackTintColor: UIColor? { - didSet { - maximumTrackView.backgroundColor = maximumTrackTintColor - } - } - - /// The color used to tint the default thumb images. - @IBInspectable - public var thumbTintColor: UIColor = defaultThumbTintColor { - didSet { - thumbView.backgroundColor = thumbTintColor - } - } - - /// Scale factor applied to the slider when receiving the focus - @IBInspectable - public var focusScaleFactor: CGFloat = defaultFocusScaleFactor { - didSet { - updateStateDependantViews() - } - } - - /// Value added or subtracted from the current value on steps left or right updates - public var stepValue: Float = defaultStepValue - - /// Damping value for panning gestures - public var panDampingValue: Float = 5 - - // Size for thumb view - public var thumbSize: CGFloat = 30 - - public var fineTunningVelocityThreshold: Float = 600 - - /** - Sets the slider’s current value, allowing you to animate the change visually. - - - Parameters: - - value: The new value to assign to the value property - - animated: Specify true to animate the change in value; otherwise, specify false to update the slider’s appearance immediately. Animations are performed asynchronously and do not block the calling thread. - */ - public func setValue(_ value: Float, animated: Bool) { - self.value = value - stopDeceleratingTimer() - - if animated { - UIView.animate(withDuration: animationDuration) { - self.setNeedsLayout() - self.layoutIfNeeded() - } - } - } - - /** - Assigns a minimum track image to the specified control states. - - - Parameters: - - image: The minimum track image to associate with the specified states. - - state: The control state with which to associate the image. - */ - public func setMinimumTrackImage(_ image: UIImage?, for state: UIControl.State) { - minimumTrackViewImages[state.rawValue] = image - updateStateDependantViews() - } - - /** - Assigns a maximum track image to the specified control states. - - - Parameters: - - image: The maximum track image to associate with the specified states. - - state: The control state with which to associate the image. - */ - public func setMaximumTrackImage(_ image: UIImage?, for state: UIControl.State) { - maximumTrackViewImages[state.rawValue] = image - updateStateDependantViews() - } - - /** - Assigns a thumb image to the specified control states. - - - Parameters: - - image: The thumb image to associate with the specified states. - - state: The control state with which to associate the image. - */ - public func setThumbImage(_ image: UIImage?, for state: UIControl.State) { - thumbViewImages[state.rawValue] = image - updateStateDependantViews() - } - - /// The minimum track image currently being used to render the slider. - public var currentMinimumTrackImage: UIImage? { - minimumTrackView.image - } - - /// Contains the maximum track image currently being used to render the slider. - public var currentMaximumTrackImage: UIImage? { - maximumTrackView.image - } - - /// The thumb image currently being used to render the slider. - public var currentThumbImage: UIImage? { - thumbView.image - } - - /** - Returns the minimum track image associated with the specified control state. - - - Parameters: - - state: The control state whose minimum track image you want to use. Specify a single control state value for this parameter. - - - Returns: The minimum track image associated with the specified state, or nil if no image has been set. This method might also return nil if you specify multiple control states in the state parameter. For a description of track images, see Customizing the Slider’s Appearance. - */ - public func minimumTrackImage(for state: UIControl.State) -> UIImage? { - minimumTrackViewImages[state.rawValue] - } - - /** - Returns the maximum track image associated with the specified control state. - - - Parameters: - - state: The control state whose maximum track image you want to use. Specify a single control state value for this parameter. - - - Returns: The maximum track image associated with the specified state, or nil if an appropriate image could not be retrieved. This method might return nil if you specify multiple control states in the state parameter. For a description of track images, see Customizing the Slider’s Appearance. - */ - public func maximumTrackImage(for state: UIControl.State) -> UIImage? { - maximumTrackViewImages[state.rawValue] - } - - /** - Returns the thumb image associated with the specified control state. - - - Parameters: - - state: The control state whose thumb image you want to use. Specify a single control state value for this parameter. - - - Returns: The thumb image associated with the specified state, or nil if an appropriate image could not be retrieved. This method might return nil if you specify multiple control states in the state parameter. For a description of track and thumb images, see Customizing the Slider’s Appearance. - */ - public func thumbImage(for state: UIControl.State) -> UIImage? { - thumbViewImages[state.rawValue] - } - - // MARK: - Initializers - - /// :nodoc: - // public override init(frame: CGRect) { - // super.init(frame: frame) - // setUpView() - // } - - /// :nodoc: - // public required init?(coder aDecoder: NSCoder) { - // super.init(coder: aDecoder) - // setUpView() - // } - - // MARK: VideoPlayerVieModel init - - init(viewModel: VideoPlayerViewModel) { - self.viewModel = viewModel - super.init(frame: .zero) - setUpView() - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - NotificationCenter.default.removeObserver(self) - } - - // MARK: - UIControlStates - - /// :nodoc: - override public var isEnabled: Bool { - didSet { - panGestureRecognizer.isEnabled = isEnabled - updateStateDependantViews() - } - } - - /// :nodoc: - override public var isSelected: Bool { - didSet { - updateStateDependantViews() - } - } - - /// :nodoc: - override public var isHighlighted: Bool { - didSet { - updateStateDependantViews() - } - } - - /// :nodoc: - override public func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { - coordinator.addCoordinatedAnimations({ - self.updateStateDependantViews() - }, completion: nil) - } - - // MARK: - Private - - private let viewModel: VideoPlayerViewModel! - - private typealias ControlState = UInt - - public var storedValue: Float = defaultValue - - private var thumbViewImages: [ControlState: UIImage] = [:] - private var thumbView: UIImageView! - - private var trackViewImages: [ControlState: UIImage] = [:] - private var trackView: UIImageView! - - private var minimumTrackViewImages: [ControlState: UIImage] = [:] - private var minimumTrackView: UIImageView! - - private var maximumTrackViewImages: [ControlState: UIImage] = [:] - private var maximumTrackView: UIImageView! - - private var panGestureRecognizer: UIPanGestureRecognizer! - private var leftTapGestureRecognizer: UITapGestureRecognizer! - private var rightTapGestureRecognizer: UITapGestureRecognizer! - - private var thumbViewCenterXConstraint: NSLayoutConstraint! - - private var dPadState: DPadState = .select - - private weak var deceleratingTimer: Timer? - private var deceleratingVelocity: Float = 0 - - private var thumbViewCenterXConstraintConstant: Float = 0 - - private func setUpView() { - setUpTrackView() - setUpMinimumTrackView() - setUpMaximumTrackView() - setUpThumbView() - - setUpTrackViewConstraints() - setUpMinimumTrackViewConstraints() - setUpMaximumTrackViewConstraints() - setUpThumbViewConstraints() - - setUpGestures() - - NotificationCenter.default.addObserver(self, selector: #selector(controllerConnected(note:)), name: .GCControllerDidConnect, - object: nil) - updateStateDependantViews() - } - - private func setUpThumbView() { - thumbView = UIImageView() - thumbView.layer.cornerRadius = thumbSize / 6 - thumbView.backgroundColor = thumbTintColor - addSubview(thumbView) - } - - private func setUpTrackView() { - trackView = UIImageView() - trackView.layer.cornerRadius = trackViewHeight / 2 - trackView.backgroundColor = defaultTrackColor.withAlphaComponent(0.3) - addSubview(trackView) - } - - private func setUpMinimumTrackView() { - minimumTrackView = UIImageView() - minimumTrackView.layer.cornerRadius = trackViewHeight / 2 - minimumTrackView.backgroundColor = minimumTrackTintColor - addSubview(minimumTrackView) - } - - private func setUpMaximumTrackView() { - maximumTrackView = UIImageView() - maximumTrackView.layer.cornerRadius = trackViewHeight / 2 - maximumTrackView.backgroundColor = maximumTrackTintColor - addSubview(maximumTrackView) - } - - private func setUpTrackViewConstraints() { - trackView.translatesAutoresizingMaskIntoConstraints = false - trackView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true - trackView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true - trackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true - trackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true - } - - private func setUpMinimumTrackViewConstraints() { - minimumTrackView.translatesAutoresizingMaskIntoConstraints = false - minimumTrackView.leadingAnchor.constraint(equalTo: trackView.leadingAnchor).isActive = true - minimumTrackView.trailingAnchor.constraint(equalTo: thumbView.centerXAnchor).isActive = true - minimumTrackView.centerYAnchor.constraint(equalTo: trackView.centerYAnchor).isActive = true - minimumTrackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true - } - - private func setUpMaximumTrackViewConstraints() { - maximumTrackView.translatesAutoresizingMaskIntoConstraints = false - maximumTrackView.leadingAnchor.constraint(equalTo: thumbView.centerXAnchor).isActive = true - maximumTrackView.trailingAnchor.constraint(equalTo: trackView.trailingAnchor).isActive = true - maximumTrackView.centerYAnchor.constraint(equalTo: trackView.centerYAnchor).isActive = true - maximumTrackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true - } - - private func setUpThumbViewConstraints() { - thumbView.translatesAutoresizingMaskIntoConstraints = false - thumbView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true - thumbView.widthAnchor.constraint(equalToConstant: thumbSize / 3).isActive = true - thumbView.heightAnchor.constraint(equalToConstant: thumbSize).isActive = true - thumbViewCenterXConstraint = thumbView.centerXAnchor.constraint(equalTo: trackView.leadingAnchor, constant: CGFloat(value)) - thumbViewCenterXConstraint.isActive = true - } - - private func setUpGestures() { - panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureWasTriggered(panGestureRecognizer:))) - addGestureRecognizer(panGestureRecognizer) - - leftTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(leftTapWasTriggered)) - leftTapGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.leftArrow.rawValue)] - leftTapGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)] - addGestureRecognizer(leftTapGestureRecognizer) - - rightTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(rightTapWasTriggered)) - rightTapGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.rightArrow.rawValue)] - rightTapGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)] - addGestureRecognizer(rightTapGestureRecognizer) - } - - private func updateStateDependantViews() { - thumbView.image = thumbViewImages[state.rawValue] ?? thumbViewImages[UIControl.State.normal.rawValue] - - if isFocused { - thumbView.transform = CGAffineTransform(scaleX: focusScaleFactor, y: focusScaleFactor) - } else { - thumbView.transform = CGAffineTransform.identity - } - } - - @objc - private func controllerConnected(note: NSNotification) { - guard let controller = note.object as? GCController else { return } - guard let micro = controller.microGamepad else { return } - - let threshold: Float = 0.7 - micro.reportsAbsoluteDpadValues = true - micro.dpad.valueChangedHandler = { - [weak self] _, x, _ in - - if x < -threshold { - self?.dPadState = .left - } else if x > threshold { - self?.dPadState = .right - } else { - self?.dPadState = .select - } - } - } - - @objc - private func handleDeceleratingTimer(timer: Timer) { - let centerX = thumbViewCenterXConstraintConstant + deceleratingVelocity * 0.01 - let percent = centerX / Float(trackView.frame.width) - value = minimumValue + ((maximumValue - minimumValue) * percent) - - if isContinuous { - sendActions(for: .valueChanged) - } - - thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) - - deceleratingVelocity *= decelerationRate - if !isFocused || abs(deceleratingVelocity) < 1 { - stopDeceleratingTimer() - } - - viewModel.sliderPercentage = Double(percent) - viewModel.sliderIsScrubbing = false - } - - private func stopDeceleratingTimer() { - deceleratingTimer?.invalidate() - deceleratingTimer = nil - deceleratingVelocity = 0 - sendActions(for: .valueChanged) - } - - private func isVerticalGesture(_ recognizer: UIPanGestureRecognizer) -> Bool { - let translation = recognizer.translation(in: self) - if abs(translation.y) > abs(translation.x) { - return true - } - return false - } - - // MARK: - Actions - - @objc - private func panGestureWasTriggered(panGestureRecognizer: UIPanGestureRecognizer) { - - if self.isVerticalGesture(panGestureRecognizer) { - return - } - - let translation = Float(panGestureRecognizer.translation(in: self).x) - let velocity = Float(panGestureRecognizer.velocity(in: self).x) - - switch panGestureRecognizer.state { - case .began: - viewModel.sliderIsScrubbing = true - - stopDeceleratingTimer() - thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) - case .changed: - viewModel.sliderIsScrubbing = true - - let centerX = thumbViewCenterXConstraintConstant + translation / panDampingValue - let percent = centerX / Float(trackView.frame.width) - value = minimumValue + ((maximumValue - minimumValue) * percent) - if isContinuous { - sendActions(for: .valueChanged) - } - - viewModel.sliderPercentage = Double(percent) - case .ended, .cancelled: - - thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) - - if abs(velocity) > fineTunningVelocityThreshold { - let direction: Float = velocity > 0 ? 1 : -1 - deceleratingVelocity = abs(velocity) > decelerationMaxVelocity ? decelerationMaxVelocity * direction : velocity - deceleratingTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self, - selector: #selector(handleDeceleratingTimer(timer:)), userInfo: nil, repeats: true) - } else { - viewModel.sliderIsScrubbing = false - stopDeceleratingTimer() - } - default: - break - } - } - - @objc - private func leftTapWasTriggered() { - // setValue(value-stepValue, animated: true) - viewModel.playerOverlayDelegate?.didSelectBackward() - } - - @objc - private func rightTapWasTriggered() { - // setValue(value+stepValue, animated: true) - viewModel.playerOverlayDelegate?.didSelectForward() - } - - override public func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { - for press in presses { - switch press.type { - case .select where dPadState == .left: - panGestureRecognizer.isEnabled = false - leftTapWasTriggered() - case .select where dPadState == .right: - panGestureRecognizer.isEnabled = false - rightTapWasTriggered() - case .select: - panGestureRecognizer.isEnabled = false - default: - break - } - } - panGestureRecognizer.isEnabled = true - super.pressesBegan(presses, with: event) - } + // MARK: - Public + + /// The slider’s current value. + @IBInspectable + public var value: Float { + get { + storedValue + } + set { + storedValue = min(maximumValue, newValue) + storedValue = max(minimumValue, storedValue) + + var offset = trackView.bounds.width * CGFloat((storedValue - minimumValue) / (maximumValue - minimumValue)) + offset = min(trackView.bounds.width, offset) + thumbViewCenterXConstraint.constant = offset + } + } + + /// The minimum value of the slider. + @IBInspectable + public var minimumValue: Float = defaultMinimumValue { + didSet { + value = max(value, minimumValue) + } + } + + /// The maximum value of the slider. + @IBInspectable + public var maximumValue: Float = defaultMaximumValue { + didSet { + value = min(value, maximumValue) + } + } + + /// A Boolean value indicating whether changes in the slider’s value generate continuous update events. + @IBInspectable + public var isContinuous: Bool = defaultIsContinuous + + /// The color used to tint the default minimum track images. + @IBInspectable + public var minimumTrackTintColor: UIColor? = defaultMininumTrackTintColor { + didSet { + minimumTrackView.backgroundColor = minimumTrackTintColor + } + } + + /// The color used to tint the default maximum track images. + @IBInspectable + public var maximumTrackTintColor: UIColor? { + didSet { + maximumTrackView.backgroundColor = maximumTrackTintColor + } + } + + /// The color used to tint the default thumb images. + @IBInspectable + public var thumbTintColor: UIColor = defaultThumbTintColor { + didSet { + thumbView.backgroundColor = thumbTintColor + } + } + + /// Scale factor applied to the slider when receiving the focus + @IBInspectable + public var focusScaleFactor: CGFloat = defaultFocusScaleFactor { + didSet { + updateStateDependantViews() + } + } + + /// Value added or subtracted from the current value on steps left or right updates + public var stepValue: Float = defaultStepValue + + /// Damping value for panning gestures + public var panDampingValue: Float = 5 + + // Size for thumb view + public var thumbSize: CGFloat = 30 + + public var fineTunningVelocityThreshold: Float = 600 + + /** + Sets the slider’s current value, allowing you to animate the change visually. + + - Parameters: + - value: The new value to assign to the value property + - animated: Specify true to animate the change in value; otherwise, specify false to update the slider’s appearance immediately. Animations are performed asynchronously and do not block the calling thread. + */ + public func setValue(_ value: Float, animated: Bool) { + self.value = value + stopDeceleratingTimer() + + if animated { + UIView.animate(withDuration: animationDuration) { + self.setNeedsLayout() + self.layoutIfNeeded() + } + } + } + + /** + Assigns a minimum track image to the specified control states. + + - Parameters: + - image: The minimum track image to associate with the specified states. + - state: The control state with which to associate the image. + */ + public func setMinimumTrackImage(_ image: UIImage?, for state: UIControl.State) { + minimumTrackViewImages[state.rawValue] = image + updateStateDependantViews() + } + + /** + Assigns a maximum track image to the specified control states. + + - Parameters: + - image: The maximum track image to associate with the specified states. + - state: The control state with which to associate the image. + */ + public func setMaximumTrackImage(_ image: UIImage?, for state: UIControl.State) { + maximumTrackViewImages[state.rawValue] = image + updateStateDependantViews() + } + + /** + Assigns a thumb image to the specified control states. + + - Parameters: + - image: The thumb image to associate with the specified states. + - state: The control state with which to associate the image. + */ + public func setThumbImage(_ image: UIImage?, for state: UIControl.State) { + thumbViewImages[state.rawValue] = image + updateStateDependantViews() + } + + /// The minimum track image currently being used to render the slider. + public var currentMinimumTrackImage: UIImage? { + minimumTrackView.image + } + + /// Contains the maximum track image currently being used to render the slider. + public var currentMaximumTrackImage: UIImage? { + maximumTrackView.image + } + + /// The thumb image currently being used to render the slider. + public var currentThumbImage: UIImage? { + thumbView.image + } + + /** + Returns the minimum track image associated with the specified control state. + + - Parameters: + - state: The control state whose minimum track image you want to use. Specify a single control state value for this parameter. + + - Returns: The minimum track image associated with the specified state, or nil if no image has been set. This method might also return nil if you specify multiple control states in the state parameter. For a description of track images, see Customizing the Slider’s Appearance. + */ + public func minimumTrackImage(for state: UIControl.State) -> UIImage? { + minimumTrackViewImages[state.rawValue] + } + + /** + Returns the maximum track image associated with the specified control state. + + - Parameters: + - state: The control state whose maximum track image you want to use. Specify a single control state value for this parameter. + + - Returns: The maximum track image associated with the specified state, or nil if an appropriate image could not be retrieved. This method might return nil if you specify multiple control states in the state parameter. For a description of track images, see Customizing the Slider’s Appearance. + */ + public func maximumTrackImage(for state: UIControl.State) -> UIImage? { + maximumTrackViewImages[state.rawValue] + } + + /** + Returns the thumb image associated with the specified control state. + + - Parameters: + - state: The control state whose thumb image you want to use. Specify a single control state value for this parameter. + + - Returns: The thumb image associated with the specified state, or nil if an appropriate image could not be retrieved. This method might return nil if you specify multiple control states in the state parameter. For a description of track and thumb images, see Customizing the Slider’s Appearance. + */ + public func thumbImage(for state: UIControl.State) -> UIImage? { + thumbViewImages[state.rawValue] + } + + // MARK: - Initializers + + /// :nodoc: + // public override init(frame: CGRect) { + // super.init(frame: frame) + // setUpView() + // } + + /// :nodoc: + // public required init?(coder aDecoder: NSCoder) { + // super.init(coder: aDecoder) + // setUpView() + // } + + // MARK: VideoPlayerVieModel init + + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + super.init(frame: .zero) + setUpView() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - UIControlStates + + /// :nodoc: + override public var isEnabled: Bool { + didSet { + panGestureRecognizer.isEnabled = isEnabled + updateStateDependantViews() + } + } + + /// :nodoc: + override public var isSelected: Bool { + didSet { + updateStateDependantViews() + } + } + + /// :nodoc: + override public var isHighlighted: Bool { + didSet { + updateStateDependantViews() + } + } + + /// :nodoc: + override public func didUpdateFocus(in context: UIFocusUpdateContext, with coordinator: UIFocusAnimationCoordinator) { + coordinator.addCoordinatedAnimations({ + self.updateStateDependantViews() + }, completion: nil) + } + + // MARK: - Private + + private let viewModel: VideoPlayerViewModel! + + private typealias ControlState = UInt + + public var storedValue: Float = defaultValue + + private var thumbViewImages: [ControlState: UIImage] = [:] + private var thumbView: UIImageView! + + private var trackViewImages: [ControlState: UIImage] = [:] + private var trackView: UIImageView! + + private var minimumTrackViewImages: [ControlState: UIImage] = [:] + private var minimumTrackView: UIImageView! + + private var maximumTrackViewImages: [ControlState: UIImage] = [:] + private var maximumTrackView: UIImageView! + + private var panGestureRecognizer: UIPanGestureRecognizer! + private var leftTapGestureRecognizer: UITapGestureRecognizer! + private var rightTapGestureRecognizer: UITapGestureRecognizer! + + private var thumbViewCenterXConstraint: NSLayoutConstraint! + + private var dPadState: DPadState = .select + + private weak var deceleratingTimer: Timer? + private var deceleratingVelocity: Float = 0 + + private var thumbViewCenterXConstraintConstant: Float = 0 + + private func setUpView() { + setUpTrackView() + setUpMinimumTrackView() + setUpMaximumTrackView() + setUpThumbView() + + setUpTrackViewConstraints() + setUpMinimumTrackViewConstraints() + setUpMaximumTrackViewConstraints() + setUpThumbViewConstraints() + + setUpGestures() + + NotificationCenter.default.addObserver( + self, + selector: #selector(controllerConnected(note:)), + name: .GCControllerDidConnect, + object: nil + ) + updateStateDependantViews() + } + + private func setUpThumbView() { + thumbView = UIImageView() + thumbView.layer.cornerRadius = thumbSize / 6 + thumbView.backgroundColor = thumbTintColor + addSubview(thumbView) + } + + private func setUpTrackView() { + trackView = UIImageView() + trackView.layer.cornerRadius = trackViewHeight / 2 + trackView.backgroundColor = defaultTrackColor.withAlphaComponent(0.3) + addSubview(trackView) + } + + private func setUpMinimumTrackView() { + minimumTrackView = UIImageView() + minimumTrackView.layer.cornerRadius = trackViewHeight / 2 + minimumTrackView.backgroundColor = minimumTrackTintColor + addSubview(minimumTrackView) + } + + private func setUpMaximumTrackView() { + maximumTrackView = UIImageView() + maximumTrackView.layer.cornerRadius = trackViewHeight / 2 + maximumTrackView.backgroundColor = maximumTrackTintColor + addSubview(maximumTrackView) + } + + private func setUpTrackViewConstraints() { + trackView.translatesAutoresizingMaskIntoConstraints = false + trackView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true + trackView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true + trackView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true + trackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true + } + + private func setUpMinimumTrackViewConstraints() { + minimumTrackView.translatesAutoresizingMaskIntoConstraints = false + minimumTrackView.leadingAnchor.constraint(equalTo: trackView.leadingAnchor).isActive = true + minimumTrackView.trailingAnchor.constraint(equalTo: thumbView.centerXAnchor).isActive = true + minimumTrackView.centerYAnchor.constraint(equalTo: trackView.centerYAnchor).isActive = true + minimumTrackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true + } + + private func setUpMaximumTrackViewConstraints() { + maximumTrackView.translatesAutoresizingMaskIntoConstraints = false + maximumTrackView.leadingAnchor.constraint(equalTo: thumbView.centerXAnchor).isActive = true + maximumTrackView.trailingAnchor.constraint(equalTo: trackView.trailingAnchor).isActive = true + maximumTrackView.centerYAnchor.constraint(equalTo: trackView.centerYAnchor).isActive = true + maximumTrackView.heightAnchor.constraint(equalToConstant: trackViewHeight).isActive = true + } + + private func setUpThumbViewConstraints() { + thumbView.translatesAutoresizingMaskIntoConstraints = false + thumbView.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true + thumbView.widthAnchor.constraint(equalToConstant: thumbSize / 3).isActive = true + thumbView.heightAnchor.constraint(equalToConstant: thumbSize).isActive = true + thumbViewCenterXConstraint = thumbView.centerXAnchor.constraint(equalTo: trackView.leadingAnchor, constant: CGFloat(value)) + thumbViewCenterXConstraint.isActive = true + } + + private func setUpGestures() { + panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(panGestureWasTriggered(panGestureRecognizer:))) + addGestureRecognizer(panGestureRecognizer) + + leftTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(leftTapWasTriggered)) + leftTapGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.leftArrow.rawValue)] + leftTapGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)] + addGestureRecognizer(leftTapGestureRecognizer) + + rightTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(rightTapWasTriggered)) + rightTapGestureRecognizer.allowedPressTypes = [NSNumber(value: UIPress.PressType.rightArrow.rawValue)] + rightTapGestureRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)] + addGestureRecognizer(rightTapGestureRecognizer) + } + + private func updateStateDependantViews() { + thumbView.image = thumbViewImages[state.rawValue] ?? thumbViewImages[UIControl.State.normal.rawValue] + + if isFocused { + thumbView.transform = CGAffineTransform(scaleX: focusScaleFactor, y: focusScaleFactor) + } else { + thumbView.transform = CGAffineTransform.identity + } + } + + @objc + private func controllerConnected(note: NSNotification) { + guard let controller = note.object as? GCController else { return } + guard let micro = controller.microGamepad else { return } + + let threshold: Float = 0.7 + micro.reportsAbsoluteDpadValues = true + micro.dpad.valueChangedHandler = { [weak self] _, x, _ in + if x < -threshold { + self?.dPadState = .left + } else if x > threshold { + self?.dPadState = .right + } else { + self?.dPadState = .select + } + } + } + + @objc + private func handleDeceleratingTimer(timer: Timer) { + let centerX = thumbViewCenterXConstraintConstant + deceleratingVelocity * 0.01 + let percent = centerX / Float(trackView.frame.width) + value = minimumValue + ((maximumValue - minimumValue) * percent) + + if isContinuous { + sendActions(for: .valueChanged) + } + + thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) + + deceleratingVelocity *= decelerationRate + if !isFocused || abs(deceleratingVelocity) < 1 { + stopDeceleratingTimer() + } + + viewModel.sliderPercentage = Double(percent) + viewModel.sliderIsScrubbing = false + } + + private func stopDeceleratingTimer() { + deceleratingTimer?.invalidate() + deceleratingTimer = nil + deceleratingVelocity = 0 + sendActions(for: .valueChanged) + } + + private func isVerticalGesture(_ recognizer: UIPanGestureRecognizer) -> Bool { + let translation = recognizer.translation(in: self) + if abs(translation.y) > abs(translation.x) { + return true + } + return false + } + + // MARK: - Actions + + @objc + private func panGestureWasTriggered(panGestureRecognizer: UIPanGestureRecognizer) { + + if self.isVerticalGesture(panGestureRecognizer) { + return + } + + let translation = Float(panGestureRecognizer.translation(in: self).x) + let velocity = Float(panGestureRecognizer.velocity(in: self).x) + + switch panGestureRecognizer.state { + case .began: + viewModel.sliderIsScrubbing = true + + stopDeceleratingTimer() + thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) + case .changed: + viewModel.sliderIsScrubbing = true + + let centerX = thumbViewCenterXConstraintConstant + translation / panDampingValue + let percent = centerX / Float(trackView.frame.width) + value = minimumValue + ((maximumValue - minimumValue) * percent) + if isContinuous { + sendActions(for: .valueChanged) + } + + viewModel.sliderPercentage = Double(percent) + case .ended, .cancelled: + + thumbViewCenterXConstraintConstant = Float(thumbViewCenterXConstraint.constant) + + if abs(velocity) > fineTunningVelocityThreshold { + let direction: Float = velocity > 0 ? 1 : -1 + deceleratingVelocity = abs(velocity) > decelerationMaxVelocity ? decelerationMaxVelocity * direction : velocity + deceleratingTimer = Timer.scheduledTimer( + timeInterval: 0.01, + target: self, + selector: #selector(handleDeceleratingTimer(timer:)), + userInfo: nil, + repeats: true + ) + } else { + viewModel.sliderIsScrubbing = false + stopDeceleratingTimer() + } + default: + break + } + } + + @objc + private func leftTapWasTriggered() { + // setValue(value-stepValue, animated: true) + viewModel.playerOverlayDelegate?.didSelectBackward() + } + + @objc + private func rightTapWasTriggered() { + // setValue(value+stepValue, animated: true) + viewModel.playerOverlayDelegate?.didSelectForward() + } + + override public func pressesBegan(_ presses: Set, with event: UIPressesEvent?) { + for press in presses { + switch press.type { + case .select where dPadState == .left: + panGestureRecognizer.isEnabled = false + leftTapWasTriggered() + case .select where dPadState == .right: + panGestureRecognizer.isEnabled = false + rightTapWasTriggered() + case .select: + panGestureRecognizer.isEnabled = false + default: + break + } + } + panGestureRecognizer.isEnabled = true + super.pressesBegan(presses, with: event) + } } diff --git a/Swiftfin/App/AppDelegate.swift b/Swiftfin/App/AppDelegate.swift index 5ed7e036..d86d6261 100644 --- a/Swiftfin/App/AppDelegate.swift +++ b/Swiftfin/App/AppDelegate.swift @@ -11,27 +11,28 @@ import SwiftUI import UIKit class AppDelegate: NSObject, UIApplicationDelegate { - static var orientationLock = UIInterfaceOrientationMask.all + static var orientationLock = UIInterfaceOrientationMask.all - func application(_ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool - { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { - // Lazily initialize datastack - _ = SwiftfinStore.dataStack - LogManager.setup() + // Lazily initialize datastack + _ = SwiftfinStore.dataStack + LogManager.setup() - let audioSession = AVAudioSession.sharedInstance() - do { - try audioSession.setCategory(.playback) - } catch { - print("setting category AVAudioSessionCategoryPlayback failed") - } + let audioSession = AVAudioSession.sharedInstance() + do { + try audioSession.setCategory(.playback) + } catch { + print("setting category AVAudioSessionCategoryPlayback failed") + } - return true - } + return true + } - func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { - AppDelegate.orientationLock - } + func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { + AppDelegate.orientationLock + } } diff --git a/Swiftfin/App/JellyfinPlayerApp.swift b/Swiftfin/App/JellyfinPlayerApp.swift index c9453e09..0dfcfae6 100644 --- a/Swiftfin/App/JellyfinPlayerApp.swift +++ b/Swiftfin/App/JellyfinPlayerApp.swift @@ -16,50 +16,50 @@ import SwiftUI @main struct JellyfinPlayerApp: App { - @UIApplicationDelegateAdaptor(AppDelegate.self) - var appDelegate + @UIApplicationDelegateAdaptor(AppDelegate.self) + var appDelegate - var body: some Scene { - WindowGroup { - EmptyView() - .ignoresSafeArea() - .withHostingWindow { window in - window?.rootViewController = PreferenceUIHostingController(wrappedView: MainCoordinator().view()) - } - .onAppear { - JellyfinPlayerApp.setupAppearance() - } - .onOpenURL { url in - AppURLHandler.shared.processDeepLink(url: url) - } - } - } + var body: some Scene { + WindowGroup { + EmptyView() + .ignoresSafeArea() + .withHostingWindow { window in + window?.rootViewController = PreferenceUIHostingController(wrappedView: MainCoordinator().view()) + } + .onAppear { + JellyfinPlayerApp.setupAppearance() + } + .onOpenURL { url in + AppURLHandler.shared.processDeepLink(url: url) + } + } + } - static func setupAppearance() { - let scenes = UIApplication.shared.connectedScenes - let windowScene = scenes.first as? UIWindowScene - windowScene?.windows.first?.overrideUserInterfaceStyle = Defaults[.appAppearance].style - } + static func setupAppearance() { + let scenes = UIApplication.shared.connectedScenes + let windowScene = scenes.first as? UIWindowScene + windowScene?.windows.first?.overrideUserInterfaceStyle = Defaults[.appAppearance].style + } } // MARK: Hosting Window struct HostingWindowFinder: UIViewRepresentable { - var callback: (UIWindow?) -> Void + var callback: (UIWindow?) -> Void - func makeUIView(context: Context) -> UIView { - let view = UIView() - DispatchQueue.main.async { [weak view] in - callback(view?.window) - } - return view - } + func makeUIView(context: Context) -> UIView { + let view = UIView() + DispatchQueue.main.async { [weak view] in + callback(view?.window) + } + return view + } - func updateUIView(_ uiView: UIView, context: Context) {} + func updateUIView(_ uiView: UIView, context: Context) {} } extension View { - func withHostingWindow(_ callback: @escaping (UIWindow?) -> Void) -> some View { - background(HostingWindowFinder(callback: callback)) - } + func withHostingWindow(_ callback: @escaping (UIWindow?) -> Void) -> some View { + background(HostingWindowFinder(callback: callback)) + } } diff --git a/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingController.swift b/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingController.swift index 7aba3ae3..5b89490b 100644 --- a/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingController.swift +++ b/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingController.swift @@ -12,110 +12,112 @@ import UIKit // MARK: PreferenceUIHostingController class PreferenceUIHostingController: UIHostingController { - init(wrappedView: V) { - let box = Box() - super.init(rootView: AnyView(wrappedView - .onPreferenceChange(PrefersHomeIndicatorAutoHiddenPreferenceKey.self) { - box.value?._prefersHomeIndicatorAutoHidden = $0 - }.onPreferenceChange(SupportedOrientationsPreferenceKey.self) { - box.value?._orientations = $0 - }.onPreferenceChange(ViewPreferenceKey.self) { - box.value?._viewPreference = $0 - })) - box.value = self - } + init(wrappedView: V) { + let box = Box() + super.init(rootView: AnyView( + wrappedView + .onPreferenceChange(PrefersHomeIndicatorAutoHiddenPreferenceKey.self) { + box.value?._prefersHomeIndicatorAutoHidden = $0 + }.onPreferenceChange(SupportedOrientationsPreferenceKey.self) { + box.value?._orientations = $0 + }.onPreferenceChange(ViewPreferenceKey.self) { + box.value?._viewPreference = $0 + } + )) + box.value = self + } - @objc - dynamic required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - super.modalPresentationStyle = .fullScreen - } + @objc + dynamic required init?(coder aDecoder: NSCoder) { + super.init(coder: aDecoder) + super.modalPresentationStyle = .fullScreen + } - private class Box { - weak var value: PreferenceUIHostingController? - init() {} - } + private class Box { + weak var value: PreferenceUIHostingController? + init() {} + } - // MARK: Prefers Home Indicator Auto Hidden + // MARK: Prefers Home Indicator Auto Hidden - public var _prefersHomeIndicatorAutoHidden = false { - didSet { setNeedsUpdateOfHomeIndicatorAutoHidden() } - } + public var _prefersHomeIndicatorAutoHidden = false { + didSet { setNeedsUpdateOfHomeIndicatorAutoHidden() } + } - override var prefersHomeIndicatorAutoHidden: Bool { - _prefersHomeIndicatorAutoHidden - } + override var prefersHomeIndicatorAutoHidden: Bool { + _prefersHomeIndicatorAutoHidden + } - // MARK: Lock orientation + // MARK: Lock orientation - public var _orientations: UIInterfaceOrientationMask = .allButUpsideDown { - didSet { - if _orientations == .landscape { - let value = UIInterfaceOrientation.landscapeRight.rawValue - UIDevice.current.setValue(value, forKey: "orientation") - UIViewController.attemptRotationToDeviceOrientation() - } - } - } + public var _orientations: UIInterfaceOrientationMask = .allButUpsideDown { + didSet { + if _orientations == .landscape { + let value = UIInterfaceOrientation.landscapeRight.rawValue + UIDevice.current.setValue(value, forKey: "orientation") + UIViewController.attemptRotationToDeviceOrientation() + } + } + } - override var supportedInterfaceOrientations: UIInterfaceOrientationMask { - _orientations - } + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + _orientations + } - public var _viewPreference: UIUserInterfaceStyle = .unspecified { - didSet { - overrideUserInterfaceStyle = _viewPreference - } - } + public var _viewPreference: UIUserInterfaceStyle = .unspecified { + didSet { + overrideUserInterfaceStyle = _viewPreference + } + } } // MARK: Preference Keys struct PrefersHomeIndicatorAutoHiddenPreferenceKey: PreferenceKey { - typealias Value = Bool + typealias Value = Bool - static var defaultValue: Value = false + static var defaultValue: Value = false - static func reduce(value: inout Value, nextValue: () -> Value) { - value = nextValue() || value - } + static func reduce(value: inout Value, nextValue: () -> Value) { + value = nextValue() || value + } } struct ViewPreferenceKey: PreferenceKey { - typealias Value = UIUserInterfaceStyle + typealias Value = UIUserInterfaceStyle - static var defaultValue: UIUserInterfaceStyle = .unspecified + static var defaultValue: UIUserInterfaceStyle = .unspecified - static func reduce(value: inout UIUserInterfaceStyle, nextValue: () -> UIUserInterfaceStyle) { - value = nextValue() - } + static func reduce(value: inout UIUserInterfaceStyle, nextValue: () -> UIUserInterfaceStyle) { + value = nextValue() + } } struct SupportedOrientationsPreferenceKey: PreferenceKey { - typealias Value = UIInterfaceOrientationMask - static var defaultValue: UIInterfaceOrientationMask = .allButUpsideDown + typealias Value = UIInterfaceOrientationMask + static var defaultValue: UIInterfaceOrientationMask = .allButUpsideDown - static func reduce(value: inout UIInterfaceOrientationMask, nextValue: () -> UIInterfaceOrientationMask) { - // use the most restrictive set from the stack - value.formIntersection(nextValue()) - } + static func reduce(value: inout UIInterfaceOrientationMask, nextValue: () -> UIInterfaceOrientationMask) { + // use the most restrictive set from the stack + value.formIntersection(nextValue()) + } } // MARK: Preference Key View Extension extension View { - // Controls the application's preferred home indicator auto-hiding when this view is shown. - func prefersHomeIndicatorAutoHidden(_ value: Bool) -> some View { - preference(key: PrefersHomeIndicatorAutoHiddenPreferenceKey.self, value: value) - } + // Controls the application's preferred home indicator auto-hiding when this view is shown. + func prefersHomeIndicatorAutoHidden(_ value: Bool) -> some View { + preference(key: PrefersHomeIndicatorAutoHiddenPreferenceKey.self, value: value) + } - func supportedOrientations(_ supportedOrientations: UIInterfaceOrientationMask) -> some View { - // When rendered, export the requested orientations upward to Root - preference(key: SupportedOrientationsPreferenceKey.self, value: supportedOrientations) - } + func supportedOrientations(_ supportedOrientations: UIInterfaceOrientationMask) -> some View { + // When rendered, export the requested orientations upward to Root + preference(key: SupportedOrientationsPreferenceKey.self, value: supportedOrientations) + } - func overrideViewPreference(_ viewPreference: UIUserInterfaceStyle) -> some View { - // When rendered, export the requested orientations upward to Root - preference(key: ViewPreferenceKey.self, value: viewPreference) - } + func overrideViewPreference(_ viewPreference: UIUserInterfaceStyle) -> some View { + // When rendered, export the requested orientations upward to Root + preference(key: ViewPreferenceKey.self, value: viewPreference) + } } diff --git a/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift b/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift index 304dcd1e..dacfa882 100644 --- a/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift +++ b/Swiftfin/App/PreferenceUIHosting/PreferenceUIHostingSwizzling.swift @@ -17,63 +17,63 @@ import UIKit /// /// Source: https://gist.github.com/Amzd/01e1f69ecbc4c82c8586dcd292b1d30d struct PreferenceUIHostingControllerView: UIViewControllerRepresentable { - init(@ViewBuilder wrappedView: @escaping () -> Wrapped) { - _ = UIViewController.preferenceSwizzling - self.wrappedView = wrappedView - } + init(@ViewBuilder wrappedView: @escaping () -> Wrapped) { + _ = UIViewController.preferenceSwizzling + self.wrappedView = wrappedView + } - var wrappedView: () -> Wrapped + var wrappedView: () -> Wrapped - func makeUIViewController(context: Context) -> PreferenceUIHostingController { - PreferenceUIHostingController(wrappedView: wrappedView()) - } + func makeUIViewController(context: Context) -> PreferenceUIHostingController { + PreferenceUIHostingController(wrappedView: wrappedView()) + } - func updateUIViewController(_ uiViewController: PreferenceUIHostingController, context: Context) {} + func updateUIViewController(_ uiViewController: PreferenceUIHostingController, context: Context) {} } // MARK: - swizzling uiviewcontroller extensions extension UIViewController { - static var preferenceSwizzling: Void = { - Swizzle(UIViewController.self) { - #selector(getter: childForScreenEdgesDeferringSystemGestures) <-> #selector(swizzled_childForScreenEdgesDeferringSystemGestures) - #selector(getter: childForHomeIndicatorAutoHidden) <-> #selector(swizzled_childForHomeIndicatorAutoHidden) - } - }() + static var preferenceSwizzling: Void = { + Swizzle(UIViewController.self) { + #selector(getter: childForScreenEdgesDeferringSystemGestures) <-> #selector(swizzled_childForScreenEdgesDeferringSystemGestures) + #selector(getter: childForHomeIndicatorAutoHidden) <-> #selector(swizzled_childForHomeIndicatorAutoHidden) + } + }() } extension UIViewController { - @objc - func swizzled_childForScreenEdgesDeferringSystemGestures() -> UIViewController? { - if self is PreferenceUIHostingController { - // dont continue searching - return nil - } else { - return search() - } - } + @objc + func swizzled_childForScreenEdgesDeferringSystemGestures() -> UIViewController? { + if self is PreferenceUIHostingController { + // dont continue searching + return nil + } else { + return search() + } + } - @objc - func swizzled_childForHomeIndicatorAutoHidden() -> UIViewController? { - if self is PreferenceUIHostingController { - // dont continue searching - return nil - } else { - return search() - } - } + @objc + func swizzled_childForHomeIndicatorAutoHidden() -> UIViewController? { + if self is PreferenceUIHostingController { + // dont continue searching + return nil + } else { + return search() + } + } - private func search() -> PreferenceUIHostingController? { - if let result = children.compactMap({ $0 as? PreferenceUIHostingController }).first { - return result - } + private func search() -> PreferenceUIHostingController? { + if let result = children.compactMap({ $0 as? PreferenceUIHostingController }).first { + return result + } - for child in children { - if let result = child.search() { - return result - } - } + for child in children { + if let result = child.search() { + return result + } + } - return nil - } + return nil + } } diff --git a/Swiftfin/AppURLHandler/AppURLHandler.swift b/Swiftfin/AppURLHandler/AppURLHandler.swift index 7db83b2c..e1e06f03 100644 --- a/Swiftfin/AppURLHandler/AppURLHandler.swift +++ b/Swiftfin/AppURLHandler/AppURLHandler.swift @@ -12,98 +12,98 @@ import JellyfinAPI import Stinsen final class AppURLHandler { - static let deepLinkScheme = "jellyfin" + static let deepLinkScheme = "jellyfin" - enum AppURLState { - case launched - case allowedInLogin - case allowed + enum AppURLState { + case launched + case allowedInLogin + case allowed - func allowedScheme(with url: URL) -> Bool { - switch self { - case .launched: - return false - case .allowed: - return true - case .allowedInLogin: - return false - } - } - } + func allowedScheme(with url: URL) -> Bool { + switch self { + case .launched: + return false + case .allowed: + return true + case .allowedInLogin: + return false + } + } + } - static let shared = AppURLHandler() + static let shared = AppURLHandler() - var cancellables = Set() + var cancellables = Set() - var appURLState: AppURLState = .launched - var launchURL: URL? + var appURLState: AppURLState = .launched + var launchURL: URL? } extension AppURLHandler { - @discardableResult - func processDeepLink(url: URL) -> Bool { - guard url.scheme == Self.deepLinkScheme || url.scheme == "widget-extension" else { - return false - } - if AppURLHandler.shared.appURLState.allowedScheme(with: url) { - return processURL(url) - } else { - launchURL = url - } - return true - } + @discardableResult + func processDeepLink(url: URL) -> Bool { + guard url.scheme == Self.deepLinkScheme || url.scheme == "widget-extension" else { + return false + } + if AppURLHandler.shared.appURLState.allowedScheme(with: url) { + return processURL(url) + } else { + launchURL = url + } + return true + } - func processLaunchedURLIfNeeded() { - guard let launchURL = launchURL, - !launchURL.absoluteString.isEmpty else { return } - if processDeepLink(url: launchURL) { - self.launchURL = nil - } - } + func processLaunchedURLIfNeeded() { + guard let launchURL = launchURL, + !launchURL.absoluteString.isEmpty else { return } + if processDeepLink(url: launchURL) { + self.launchURL = nil + } + } - private func processURL(_ url: URL) -> Bool { - if processURLForUser(url: url) { - return true - } + private func processURL(_ url: URL) -> Bool { + if processURLForUser(url: url) { + return true + } - return false - } + return false + } - private func processURLForUser(url: URL) -> Bool { - guard url.host?.lowercased() == "users", - url.pathComponents[safe: 1]?.isEmpty == false else { return false } + private func processURLForUser(url: URL) -> Bool { + guard url.host?.lowercased() == "users", + url.pathComponents[safe: 1]?.isEmpty == false else { return false } - // /Users/{UserID}/Items/{ItemID} - if url.pathComponents[safe: 2]?.lowercased() == "items", - let userID = url.pathComponents[safe: 1], - let itemID = url.pathComponents[safe: 3] - { - // It would be nice if the ItemViewModel could be initialized to id later. - getItem(userID: userID, itemID: itemID) { item in - guard let item = item else { return } - Notifications[.processDeepLink].post(object: DeepLink.item(item)) - } + // /Users/{UserID}/Items/{ItemID} + if url.pathComponents[safe: 2]?.lowercased() == "items", + let userID = url.pathComponents[safe: 1], + let itemID = url.pathComponents[safe: 3] + { + // It would be nice if the ItemViewModel could be initialized to id later. + getItem(userID: userID, itemID: itemID) { item in + guard let item = item else { return } + Notifications[.processDeepLink].post(object: DeepLink.item(item)) + } - return true - } + return true + } - return false - } + return false + } } extension AppURLHandler { - func getItem(userID: String, itemID: String, completion: @escaping (BaseItemDto?) -> Void) { - UserLibraryAPI.getItem(userId: userID, itemId: itemID) - .sink(receiveCompletion: { innerCompletion in - switch innerCompletion { - case .failure: - completion(nil) - default: - break - } - }, receiveValue: { item in - completion(item) - }) - .store(in: &cancellables) - } + func getItem(userID: String, itemID: String, completion: @escaping (BaseItemDto?) -> Void) { + UserLibraryAPI.getItem(userId: userID, itemId: itemID) + .sink(receiveCompletion: { innerCompletion in + switch innerCompletion { + case .failure: + completion(nil) + default: + break + } + }, receiveValue: { item in + completion(item) + }) + .store(in: &cancellables) + } } diff --git a/Swiftfin/AppURLHandler/DeepLink.swift b/Swiftfin/AppURLHandler/DeepLink.swift index ae224f54..4d4631d5 100644 --- a/Swiftfin/AppURLHandler/DeepLink.swift +++ b/Swiftfin/AppURLHandler/DeepLink.swift @@ -10,5 +10,5 @@ import Foundation import JellyfinAPI enum DeepLink { - case item(BaseItemDto) + case item(BaseItemDto) } diff --git a/Swiftfin/Components/AppIcon.swift b/Swiftfin/Components/AppIcon.swift index 67194ddb..b72eb577 100644 --- a/Swiftfin/Components/AppIcon.swift +++ b/Swiftfin/Components/AppIcon.swift @@ -9,9 +9,9 @@ import SwiftUI struct AppIcon: View { - var body: some View { - Bundle.main.iconFileName - .flatMap { UIImage(named: $0) } - .map { Image(uiImage: $0).resizable() } - } + var body: some View { + Bundle.main.iconFileName + .flatMap { UIImage(named: $0) } + .map { Image(uiImage: $0).resizable() } + } } diff --git a/Swiftfin/Components/DetectBottomScrollView.swift b/Swiftfin/Components/DetectBottomScrollView.swift index 45a2500c..b0880302 100644 --- a/Swiftfin/Components/DetectBottomScrollView.swift +++ b/Swiftfin/Components/DetectBottomScrollView.swift @@ -11,86 +11,91 @@ import SwiftUI // https://stackoverflow.com/questions/56573373/swiftui-get-size-of-child struct ChildSizeReader: View { - @Binding - var size: CGSize - let content: () -> Content - var body: some View { - ZStack { - content() - .background(GeometryReader { proxy in - Color.clear - .preference(key: SizePreferenceKey.self, value: proxy.size) - }) - } - .onPreferenceChange(SizePreferenceKey.self) { preferences in - self.size = preferences - } - } + @Binding + var size: CGSize + let content: () -> Content + var body: some View { + ZStack { + content() + .background(GeometryReader { proxy in + Color.clear + .preference(key: SizePreferenceKey.self, value: proxy.size) + }) + } + .onPreferenceChange(SizePreferenceKey.self) { preferences in + self.size = preferences + } + } } struct SizePreferenceKey: PreferenceKey { - typealias Value = CGSize - static var defaultValue: Value = .zero + typealias Value = CGSize + static var defaultValue: Value = .zero - static func reduce(value _: inout Value, nextValue: () -> Value) { - _ = nextValue() - } + static func reduce(value _: inout Value, nextValue: () -> Value) { + _ = nextValue() + } } struct ViewOffsetKey: PreferenceKey { - typealias Value = CGFloat - static var defaultValue = CGFloat.zero - static func reduce(value: inout Value, nextValue: () -> Value) { - value += nextValue() - } + typealias Value = CGFloat + static var defaultValue = CGFloat.zero + static func reduce(value: inout Value, nextValue: () -> Value) { + value += nextValue() + } } struct DetectBottomScrollView: View { - private let spaceName = "scroll" + private let spaceName = "scroll" - @State - private var wholeSize: CGSize = .zero - @State - private var scrollViewSize: CGSize = .zero - @State - private var previousDidReachBottom = false - let content: () -> Content - let didReachBottom: (Bool) -> Void + @State + private var wholeSize: CGSize = .zero + @State + private var scrollViewSize: CGSize = .zero + @State + private var previousDidReachBottom = false + let content: () -> Content + let didReachBottom: (Bool) -> Void - init(content: @escaping () -> Content, - didReachBottom: @escaping (Bool) -> Void) - { - self.content = content - self.didReachBottom = didReachBottom - } + init( + content: @escaping () -> Content, + didReachBottom: @escaping (Bool) -> Void + ) { + self.content = content + self.didReachBottom = didReachBottom + } - var body: some View { - ChildSizeReader(size: $wholeSize) { - ScrollView { - ChildSizeReader(size: $scrollViewSize) { - content() - .background(GeometryReader { proxy in - Color.clear.preference(key: ViewOffsetKey.self, - value: -1 * proxy.frame(in: .named(spaceName)).origin.y) - }) - .onPreferenceChange(ViewOffsetKey.self, - perform: { value in + var body: some View { + ChildSizeReader(size: $wholeSize) { + ScrollView { + ChildSizeReader(size: $scrollViewSize) { + content() + .background(GeometryReader { proxy in + Color.clear.preference( + key: ViewOffsetKey.self, + value: -1 * proxy.frame(in: .named(spaceName)).origin.y + ) + }) + .onPreferenceChange( + ViewOffsetKey.self, + perform: { value in - if value >= scrollViewSize.height - wholeSize.height { - if !previousDidReachBottom { - previousDidReachBottom = true - didReachBottom(true) - } - } else { - if previousDidReachBottom { - previousDidReachBottom = false - didReachBottom(false) - } - } - }) - } - } - .coordinateSpace(name: spaceName) - } - } + if value >= scrollViewSize.height - wholeSize.height { + if !previousDidReachBottom { + previousDidReachBottom = true + didReachBottom(true) + } + } else { + if previousDidReachBottom { + previousDidReachBottom = false + didReachBottom(false) + } + } + } + ) + } + } + .coordinateSpace(name: spaceName) + } + } } diff --git a/Swiftfin/Components/EpisodesRowView/EpisodeRowCard.swift b/Swiftfin/Components/EpisodesRowView/EpisodeRowCard.swift index f18e2604..97a3ef51 100644 --- a/Swiftfin/Components/EpisodesRowView/EpisodeRowCard.swift +++ b/Swiftfin/Components/EpisodesRowView/EpisodeRowCard.swift @@ -11,61 +11,63 @@ import SwiftUI struct EpisodeRowCard: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - let viewModel: EpisodesRowManager - let episode: BaseItemDto + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + let viewModel: EpisodesRowManager + let episode: BaseItemDto - var body: some View { - Button { - itemRouter.route(to: \.item, episode) - } label: { - HStack(alignment: .top) { - VStack(alignment: .leading) { + var body: some View { + Button { + itemRouter.route(to: \.item, episode) + } label: { + HStack(alignment: .top) { + VStack(alignment: .leading) { - ImageView(episode.getBackdropImage(maxWidth: 200), - blurHash: episode.getBackdropImageBlurHash()) - .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) - .frame(width: 200, height: 112) - .overlay { - if episode.id == viewModel.item.id { - RoundedRectangle(cornerRadius: 6) - .stroke(Color.jellyfinPurple, lineWidth: 4) - } - } - .padding(.top) - .accessibilityIgnoresInvertColors() + ImageView( + episode.getBackdropImage(maxWidth: 200), + blurHash: episode.getBackdropImageBlurHash() + ) + .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) + .frame(width: 200, height: 112) + .overlay { + if episode.id == viewModel.item.id { + RoundedRectangle(cornerRadius: 6) + .stroke(Color.jellyfinPurple, lineWidth: 4) + } + } + .padding(.top) + .accessibilityIgnoresInvertColors() - VStack(alignment: .leading) { - Text(episode.getEpisodeLocator() ?? "S-:E-") - .font(.footnote) - .foregroundColor(.secondary) - Text(episode.name ?? L10n.noTitle) - .font(.body) - .padding(.bottom, 1) - .lineLimit(2) + VStack(alignment: .leading) { + Text(episode.getEpisodeLocator() ?? "S-:E-") + .font(.footnote) + .foregroundColor(.secondary) + Text(episode.name ?? L10n.noTitle) + .font(.body) + .padding(.bottom, 1) + .lineLimit(2) - if episode.unaired { - Text(episode.airDateLabel ?? L10n.noOverviewAvailable) - .font(.caption) - .foregroundColor(.secondary) - .fontWeight(.light) - .lineLimit(3) - } else { - Text(episode.overview ?? "") - .font(.caption) - .foregroundColor(.secondary) - .fontWeight(.light) - .lineLimit(3) - } - } + if episode.unaired { + Text(episode.airDateLabel ?? L10n.noOverviewAvailable) + .font(.caption) + .foregroundColor(.secondary) + .fontWeight(.light) + .lineLimit(3) + } else { + Text(episode.overview ?? "") + .font(.caption) + .foregroundColor(.secondary) + .fontWeight(.light) + .lineLimit(3) + } + } - Spacer() - } - .frame(width: 200) - .shadow(radius: 4, y: 2) - } - } - .buttonStyle(PlainButtonStyle()) - } + Spacer() + } + .frame(width: 200) + .shadow(radius: 4, y: 2) + } + } + .buttonStyle(PlainButtonStyle()) + } } diff --git a/Swiftfin/Components/EpisodesRowView/EpisodesRowView.swift b/Swiftfin/Components/EpisodesRowView/EpisodesRowView.swift index 1ba76a1a..13dcb0fe 100644 --- a/Swiftfin/Components/EpisodesRowView/EpisodesRowView.swift +++ b/Swiftfin/Components/EpisodesRowView/EpisodesRowView.swift @@ -11,125 +11,127 @@ import SwiftUI struct EpisodesRowView: View where RowManager: EpisodesRowManager { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @ObservedObject - var viewModel: RowManager - let onlyCurrentSeason: Bool + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @ObservedObject + var viewModel: RowManager + let onlyCurrentSeason: Bool - var body: some View { - VStack(alignment: .leading, spacing: 0) { + var body: some View { + VStack(alignment: .leading, spacing: 0) { - HStack { + HStack { - if onlyCurrentSeason { - if let currentSeason = Array(viewModel.seasonsEpisodes.keys).first(where: { $0.id == viewModel.item.id }) { - Text(currentSeason.name ?? L10n.noTitle) - .accessibility(addTraits: [.isHeader]) - } - } else { - Menu { - ForEach(viewModel.sortedSeasons, - id: \.self) { season in - Button { - viewModel.select(season: season) - } label: { - if season.id == viewModel.selectedSeason?.id { - Label(season.name ?? L10n.season, systemImage: "checkmark") - } else { - Text(season.name ?? L10n.season) - } - } - } - } label: { - HStack(spacing: 5) { - Text(viewModel.selectedSeason?.name ?? L10n.unknown) - .fontWeight(.semibold) - .fixedSize() - Image(systemName: "chevron.down") - } - } - } + if onlyCurrentSeason { + if let currentSeason = Array(viewModel.seasonsEpisodes.keys).first(where: { $0.id == viewModel.item.id }) { + Text(currentSeason.name ?? L10n.noTitle) + .accessibility(addTraits: [.isHeader]) + } + } else { + Menu { + ForEach( + viewModel.sortedSeasons, + id: \.self + ) { season in + Button { + viewModel.select(season: season) + } label: { + if season.id == viewModel.selectedSeason?.id { + Label(season.name ?? L10n.season, systemImage: "checkmark") + } else { + Text(season.name ?? L10n.season) + } + } + } + } label: { + HStack(spacing: 5) { + Text(viewModel.selectedSeason?.name ?? L10n.unknown) + .fontWeight(.semibold) + .fixedSize() + Image(systemName: "chevron.down") + } + } + } - Spacer() - } - .padding() + Spacer() + } + .padding() - ScrollView(.horizontal, showsIndicators: false) { - ScrollViewReader { reader in - HStack(alignment: .top, spacing: 15) { - if viewModel.isLoading { - VStack(alignment: .leading) { + ScrollView(.horizontal, showsIndicators: false) { + ScrollViewReader { reader in + HStack(alignment: .top, spacing: 15) { + if viewModel.isLoading { + VStack(alignment: .leading) { - ZStack { - Color.gray.ignoresSafeArea() + ZStack { + Color.gray.ignoresSafeArea() - ProgressView() - } - .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) - .frame(width: 200, height: 112) + ProgressView() + } + .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) + .frame(width: 200, height: 112) - VStack(alignment: .leading) { - Text("S-:E-") - .font(.footnote) - .foregroundColor(.secondary) - Text("--") - .font(.body) - .padding(.bottom, 1) - .lineLimit(2) - } + VStack(alignment: .leading) { + Text("S-:E-") + .font(.footnote) + .foregroundColor(.secondary) + Text("--") + .font(.body) + .padding(.bottom, 1) + .lineLimit(2) + } - Spacer() - } - .frame(width: 200) - .shadow(radius: 4, y: 2) - } else if let selectedSeason = viewModel.selectedSeason { - if let seasonEpisodes = viewModel.seasonsEpisodes[selectedSeason] { - if seasonEpisodes.isEmpty { - VStack(alignment: .leading) { + Spacer() + } + .frame(width: 200) + .shadow(radius: 4, y: 2) + } else if let selectedSeason = viewModel.selectedSeason { + if let seasonEpisodes = viewModel.seasonsEpisodes[selectedSeason] { + if seasonEpisodes.isEmpty { + VStack(alignment: .leading) { - Color.gray.ignoresSafeArea() - .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) - .frame(width: 200, height: 112) + Color.gray.ignoresSafeArea() + .mask(Rectangle().frame(width: 200, height: 112).cornerRadius(10)) + .frame(width: 200, height: 112) - VStack(alignment: .leading) { - Text("--") - .font(.footnote) - .foregroundColor(.secondary) + VStack(alignment: .leading) { + Text("--") + .font(.footnote) + .foregroundColor(.secondary) - L10n.noEpisodesAvailable.text - .font(.body) - .padding(.bottom, 1) - .lineLimit(2) - } + L10n.noEpisodesAvailable.text + .font(.body) + .padding(.bottom, 1) + .lineLimit(2) + } - Spacer() - } - .frame(width: 200) - .shadow(radius: 4, y: 2) - } else { - ForEach(seasonEpisodes, id: \.self) { episode in - EpisodeRowCard(viewModel: viewModel, episode: episode) - .id(episode.id) - } - } - } - } - } - .padding(.horizontal) - .onChange(of: viewModel.selectedSeason) { _ in - if viewModel.selectedSeason?.id == viewModel.item.seasonId { - reader.scrollTo(viewModel.item.id) - } - } - .onChange(of: viewModel.seasonsEpisodes) { _ in - if viewModel.selectedSeason?.id == viewModel.item.seasonId { - reader.scrollTo(viewModel.item.id) - } - } - } - .edgesIgnoringSafeArea(.horizontal) - } - } - } + Spacer() + } + .frame(width: 200) + .shadow(radius: 4, y: 2) + } else { + ForEach(seasonEpisodes, id: \.self) { episode in + EpisodeRowCard(viewModel: viewModel, episode: episode) + .id(episode.id) + } + } + } + } + } + .padding(.horizontal) + .onChange(of: viewModel.selectedSeason) { _ in + if viewModel.selectedSeason?.id == viewModel.item.seasonId { + reader.scrollTo(viewModel.item.id) + } + } + .onChange(of: viewModel.seasonsEpisodes) { _ in + if viewModel.selectedSeason?.id == viewModel.item.seasonId { + reader.scrollTo(viewModel.item.id) + } + } + } + .edgesIgnoringSafeArea(.horizontal) + } + } + } } diff --git a/Swiftfin/Components/PillHStackView.swift b/Swiftfin/Components/PillHStackView.swift index f519c855..4ae9f8b4 100644 --- a/Swiftfin/Components/PillHStackView.swift +++ b/Swiftfin/Components/PillHStackView.swift @@ -10,47 +10,47 @@ import SwiftUI struct PillHStackView: View { - let title: String - let items: [ItemType] - let selectedAction: (ItemType) -> Void + let title: String + let items: [ItemType] + let selectedAction: (ItemType) -> Void - var body: some View { - VStack(alignment: .leading) { - Text(title) - .font(.callout) - .fontWeight(.semibold) - .padding(.top, 3) - .padding(.leading, 16) - .accessibility(addTraits: [.isHeader]) + var body: some View { + VStack(alignment: .leading) { + Text(title) + .font(.callout) + .fontWeight(.semibold) + .padding(.top, 3) + .padding(.leading, 16) + .accessibility(addTraits: [.isHeader]) - ScrollView(.horizontal, showsIndicators: false) { - HStack { - ForEach(items, id: \.title) { item in - Button { - selectedAction(item) - } label: { - ZStack { - Color(UIColor.systemFill) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .cornerRadius(10) + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(items, id: \.title) { item in + Button { + selectedAction(item) + } label: { + ZStack { + Color(UIColor.systemFill) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .cornerRadius(10) - Text(item.title) - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.primary) - .fixedSize() - .padding(.leading, 10) - .padding(.trailing, 10) - .padding(.top, 10) - .padding(.bottom, 10) - } - .fixedSize() - } - } - } - .padding(.leading, 16) - .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - } - } - } + Text(item.title) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.primary) + .fixedSize() + .padding(.leading, 10) + .padding(.trailing, 10) + .padding(.top, 10) + .padding(.bottom, 10) + } + .fixedSize() + } + } + } + .padding(.leading, 16) + .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + } + } + } } diff --git a/Swiftfin/Components/PortraitHStackView.swift b/Swiftfin/Components/PortraitHStackView.swift index 79261be4..41b64c8c 100644 --- a/Swiftfin/Components/PortraitHStackView.swift +++ b/Swiftfin/Components/PortraitHStackView.swift @@ -10,75 +10,78 @@ import SwiftUI struct PortraitImageHStackView: View { - let items: [ItemType] - let maxWidth: CGFloat - let horizontalAlignment: HorizontalAlignment - let textAlignment: TextAlignment - let topBarView: () -> TopBarView - let selectedAction: (ItemType) -> Void + let items: [ItemType] + let maxWidth: CGFloat + let horizontalAlignment: HorizontalAlignment + let textAlignment: TextAlignment + let topBarView: () -> TopBarView + let selectedAction: (ItemType) -> Void - init(items: [ItemType], - maxWidth: CGFloat = 110, - horizontalAlignment: HorizontalAlignment = .leading, - textAlignment: TextAlignment = .leading, - topBarView: @escaping () -> TopBarView, - selectedAction: @escaping (ItemType) -> Void) - { - self.items = items - self.maxWidth = maxWidth - self.horizontalAlignment = horizontalAlignment - self.textAlignment = textAlignment - self.topBarView = topBarView - self.selectedAction = selectedAction - } + init( + items: [ItemType], + maxWidth: CGFloat = 110, + horizontalAlignment: HorizontalAlignment = .leading, + textAlignment: TextAlignment = .leading, + topBarView: @escaping () -> TopBarView, + selectedAction: @escaping (ItemType) -> Void + ) { + self.items = items + self.maxWidth = maxWidth + self.horizontalAlignment = horizontalAlignment + self.textAlignment = textAlignment + self.topBarView = topBarView + self.selectedAction = selectedAction + } - var body: some View { - VStack(alignment: .leading, spacing: 0) { - topBarView() + var body: some View { + VStack(alignment: .leading, spacing: 0) { + topBarView() - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 15) { - ForEach(items, id: \.self.portraitImageID) { item in - Button { - selectedAction(item) - } label: { - VStack(alignment: horizontalAlignment) { - ImageView(item.imageURLConstructor(maxWidth: Int(maxWidth)), - blurHash: item.blurHash, - failureView: { - InitialFailureView(item.failureInitials) - }) - .portraitPoster(width: maxWidth) - .shadow(radius: 4, y: 2) - .accessibilityIgnoresInvertColors() + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 15) { + ForEach(items, id: \.self.portraitImageID) { item in + Button { + selectedAction(item) + } label: { + VStack(alignment: horizontalAlignment) { + ImageView( + item.imageURLConstructor(maxWidth: Int(maxWidth)), + blurHash: item.blurHash, + failureView: { + InitialFailureView(item.failureInitials) + } + ) + .portraitPoster(width: maxWidth) + .shadow(radius: 4, y: 2) + .accessibilityIgnoresInvertColors() - if item.showTitle { - Text(item.title) - .font(.footnote) - .fontWeight(.regular) - .foregroundColor(.primary) - .multilineTextAlignment(textAlignment) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(2) - } + if item.showTitle { + Text(item.title) + .font(.footnote) + .fontWeight(.regular) + .foregroundColor(.primary) + .multilineTextAlignment(textAlignment) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(2) + } - if let description = item.subtitle { - Text(description) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(.secondary) - .multilineTextAlignment(textAlignment) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(2) - } - } - .frame(width: maxWidth) - } - .padding(.bottom) - } - } - .padding(.horizontal) - } - } - } + if let description = item.subtitle { + Text(description) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.secondary) + .multilineTextAlignment(textAlignment) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(2) + } + } + .frame(width: maxWidth) + } + .padding(.bottom) + } + } + .padding(.horizontal) + } + } + } } diff --git a/Swiftfin/Components/PortraitItemButton.swift b/Swiftfin/Components/PortraitItemButton.swift index 80d2d434..2e4aeda4 100644 --- a/Swiftfin/Components/PortraitItemButton.swift +++ b/Swiftfin/Components/PortraitItemButton.swift @@ -11,62 +11,65 @@ import SwiftUI struct PortraitItemButton: View { - let item: ItemType - let maxWidth: CGFloat - let horizontalAlignment: HorizontalAlignment - let textAlignment: TextAlignment - let selectedAction: (ItemType) -> Void + let item: ItemType + let maxWidth: CGFloat + let horizontalAlignment: HorizontalAlignment + let textAlignment: TextAlignment + let selectedAction: (ItemType) -> Void - init(item: ItemType, - maxWidth: CGFloat = 110, - horizontalAlignment: HorizontalAlignment = .leading, - textAlignment: TextAlignment = .leading, - selectedAction: @escaping (ItemType) -> Void) - { - self.item = item - self.maxWidth = maxWidth - self.horizontalAlignment = horizontalAlignment - self.textAlignment = textAlignment - self.selectedAction = selectedAction - } + init( + item: ItemType, + maxWidth: CGFloat = 110, + horizontalAlignment: HorizontalAlignment = .leading, + textAlignment: TextAlignment = .leading, + selectedAction: @escaping (ItemType) -> Void + ) { + self.item = item + self.maxWidth = maxWidth + self.horizontalAlignment = horizontalAlignment + self.textAlignment = textAlignment + self.selectedAction = selectedAction + } - var body: some View { - Button { - selectedAction(item) - } label: { - VStack(alignment: horizontalAlignment) { - ImageView(item.imageURLConstructor(maxWidth: Int(maxWidth)), - blurHash: item.blurHash, - failureView: { - InitialFailureView(item.failureInitials) - }) - .portraitPoster(width: maxWidth) - .shadow(radius: 4, y: 2) - .accessibilityIgnoresInvertColors() + var body: some View { + Button { + selectedAction(item) + } label: { + VStack(alignment: horizontalAlignment) { + ImageView( + item.imageURLConstructor(maxWidth: Int(maxWidth)), + blurHash: item.blurHash, + failureView: { + InitialFailureView(item.failureInitials) + } + ) + .portraitPoster(width: maxWidth) + .shadow(radius: 4, y: 2) + .accessibilityIgnoresInvertColors() - if item.showTitle { - Text(item.title) - .font(.footnote) - .fontWeight(.regular) - .foregroundColor(.primary) - .multilineTextAlignment(textAlignment) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(2) - } + if item.showTitle { + Text(item.title) + .font(.footnote) + .fontWeight(.regular) + .foregroundColor(.primary) + .multilineTextAlignment(textAlignment) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(2) + } - if let description = item.subtitle { - Text(description) - .font(.caption) - .fontWeight(.medium) - .foregroundColor(.secondary) - .multilineTextAlignment(textAlignment) - .fixedSize(horizontal: false, vertical: true) - .lineLimit(2) - } - } - .frame(width: maxWidth) - } - .frame(alignment: .top) - .padding(.bottom) - } + if let description = item.subtitle { + Text(description) + .font(.caption) + .fontWeight(.medium) + .foregroundColor(.secondary) + .multilineTextAlignment(textAlignment) + .fixedSize(horizontal: false, vertical: true) + .lineLimit(2) + } + } + .frame(width: maxWidth) + } + .frame(alignment: .top) + .padding(.bottom) + } } diff --git a/Swiftfin/Components/PortraitItemElement.swift b/Swiftfin/Components/PortraitItemElement.swift index f2e767c2..c04c5dc0 100644 --- a/Swiftfin/Components/PortraitItemElement.swift +++ b/Swiftfin/Components/PortraitItemElement.swift @@ -11,9 +11,9 @@ import SwiftUI // Not implemented on iOS, but used by a shared Coordinator. struct PortraitItemElement: View { - var item: BaseItemDto + var item: BaseItemDto - var body: some View { - EmptyView() - } + var body: some View { + EmptyView() + } } diff --git a/Swiftfin/Components/PrimaryButtonView.swift b/Swiftfin/Components/PrimaryButtonView.swift index 7595bf37..6fd9b752 100644 --- a/Swiftfin/Components/PrimaryButtonView.swift +++ b/Swiftfin/Components/PrimaryButtonView.swift @@ -10,31 +10,31 @@ import SwiftUI struct PrimaryButtonView: View { - private let title: String - private let action: () -> Void + private let title: String + private let action: () -> Void - init(title: String, _ action: @escaping () -> Void) { - self.title = title - self.action = action - } + init(title: String, _ action: @escaping () -> Void) { + self.title = title + self.action = action + } - var body: some View { - Button { - action() - } label: { - ZStack { - Rectangle() - .foregroundColor(Color(UIColor.systemPurple)) - .frame(maxWidth: 400, maxHeight: 50) - .frame(height: 50) - .cornerRadius(10) - .padding(.horizontal, 30) - .padding([.top, .bottom], 20) + var body: some View { + Button { + action() + } label: { + ZStack { + Rectangle() + .foregroundColor(Color(UIColor.systemPurple)) + .frame(maxWidth: 400, maxHeight: 50) + .frame(height: 50) + .cornerRadius(10) + .padding(.horizontal, 30) + .padding([.top, .bottom], 20) - Text(title) - .foregroundColor(Color.white) - .bold() - } - } - } + Text(title) + .foregroundColor(Color.white) + .bold() + } + } + } } diff --git a/Swiftfin/Components/TruncatedTextView.swift b/Swiftfin/Components/TruncatedTextView.swift index 2d3fe759..54195865 100644 --- a/Swiftfin/Components/TruncatedTextView.swift +++ b/Swiftfin/Components/TruncatedTextView.swift @@ -10,104 +10,109 @@ import SwiftUI struct TruncatedTextView: View { - @State - private var truncated: Bool = false - @State - private var shrinkText: String - private var text: String - let font: UIFont - let lineLimit: Int - let seeMoreAction: () -> Void + @State + private var truncated: Bool = false + @State + private var shrinkText: String + private var text: String + let font: UIFont + let lineLimit: Int + let seeMoreAction: () -> Void - private var moreLessText: String { - if !truncated { - return "" - } else { - return L10n.seeMore - } - } + private var moreLessText: String { + if !truncated { + return "" + } else { + return L10n.seeMore + } + } - init(_ text: String, - lineLimit: Int, - font: UIFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body), - seeMoreAction: @escaping () -> Void) - { - self.text = text - self.lineLimit = lineLimit - _shrinkText = State(wrappedValue: text) - self.font = font - self.seeMoreAction = seeMoreAction - } + init( + _ text: String, + lineLimit: Int, + font: UIFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body), + seeMoreAction: @escaping () -> Void + ) { + self.text = text + self.lineLimit = lineLimit + _shrinkText = State(wrappedValue: text) + self.font = font + self.seeMoreAction = seeMoreAction + } - var body: some View { - VStack(alignment: .center) { - Group { - Text(shrinkText) - .overlay { - if truncated { - LinearGradient(stops: [ - .init(color: .systemBackground.opacity(0), location: 0.5), - .init(color: .systemBackground.opacity(0.8), location: 0.7), - .init(color: .systemBackground, location: 1), - ], - startPoint: .top, - endPoint: .bottom) - } - } - } - .lineLimit(lineLimit) - .background { - // Render the limited text and measure its size - Text(text) - .lineLimit(lineLimit + 2) - .background { - GeometryReader { visibleTextGeometry in - Color.clear - .onAppear { - let size = CGSize(width: visibleTextGeometry.size.width, height: .greatestFiniteMagnitude) - let attributes: [NSAttributedString.Key: Any] = [NSAttributedString.Key.font: font] - var low = 0 - var heigh = shrinkText.count - var mid = heigh - while (heigh - low) > 1 { - let attributedText = NSAttributedString(string: shrinkText, attributes: attributes) - let boundingRect = attributedText.boundingRect(with: size, - options: NSStringDrawingOptions - .usesLineFragmentOrigin, - context: nil) - if boundingRect.size.height > visibleTextGeometry.size.height { - truncated = true - heigh = mid - mid = (heigh + low) / 2 + var body: some View { + VStack(alignment: .center) { + Group { + Text(shrinkText) + .overlay { + if truncated { + LinearGradient( + stops: [ + .init(color: .systemBackground.opacity(0), location: 0.5), + .init(color: .systemBackground.opacity(0.8), location: 0.7), + .init(color: .systemBackground, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + } + } + } + .lineLimit(lineLimit) + .background { + // Render the limited text and measure its size + Text(text) + .lineLimit(lineLimit + 2) + .background { + GeometryReader { visibleTextGeometry in + Color.clear + .onAppear { + let size = CGSize(width: visibleTextGeometry.size.width, height: .greatestFiniteMagnitude) + let attributes: [NSAttributedString.Key: Any] = [NSAttributedString.Key.font: font] + var low = 0 + var heigh = shrinkText.count + var mid = heigh + while (heigh - low) > 1 { + let attributedText = NSAttributedString(string: shrinkText, attributes: attributes) + let boundingRect = attributedText.boundingRect( + with: size, + options: NSStringDrawingOptions + .usesLineFragmentOrigin, + context: nil + ) + if boundingRect.size.height > visibleTextGeometry.size.height { + truncated = true + heigh = mid + mid = (heigh + low) / 2 - } else { - if mid == text.count { - break - } else { - low = mid - mid = (low + heigh) / 2 - } - } - shrinkText = String(text.prefix(mid)) - } + } else { + if mid == text.count { + break + } else { + low = mid + mid = (low + heigh) / 2 + } + } + shrinkText = String(text.prefix(mid)) + } - if truncated { - shrinkText = String(shrinkText.prefix(shrinkText.count - 2)) - } - } - } - } - .hidden() - } - .font(Font(font)) + if truncated { + shrinkText = String(shrinkText.prefix(shrinkText.count - 2)) + } + } + } + } + .hidden() + } + .font(Font(font)) - if truncated { - Button { - seeMoreAction() - } label: { - Text(moreLessText) - } - } - } - } + if truncated { + Button { + seeMoreAction() + } label: { + Text(moreLessText) + } + } + } + } } diff --git a/Swiftfin/Objects/RefreshHelper.swift b/Swiftfin/Objects/RefreshHelper.swift index 9a5c95b0..eba76c04 100644 --- a/Swiftfin/Objects/RefreshHelper.swift +++ b/Swiftfin/Objects/RefreshHelper.swift @@ -11,30 +11,30 @@ import UIKit // A more general derivative of // https://stackoverflow.com/questions/65812080/introspect-library-uirefreshcontrol-with-swiftui-not-working final class RefreshHelper { - var refreshControl: UIRefreshControl? - var refreshAction: (() -> Void)? - private var lastAutomaticRefresh = Date() + var refreshControl: UIRefreshControl? + var refreshAction: (() -> Void)? + private var lastAutomaticRefresh = Date() - @objc - func didRefresh() { - guard let refreshControl = refreshControl else { return } - refreshAction?() - refreshControl.endRefreshing() - } + @objc + func didRefresh() { + guard let refreshControl = refreshControl else { return } + refreshAction?() + refreshControl.endRefreshing() + } } // MARK: - automatic refreshing extension RefreshHelper { - private static let timeUntilStale = TimeInterval(60) + private static let timeUntilStale = TimeInterval(60) - func refreshStaleData() { - guard isStale else { return } - lastAutomaticRefresh = .now - refreshAction?() - } + func refreshStaleData() { + guard isStale else { return } + lastAutomaticRefresh = .now + refreshAction?() + } - private var isStale: Bool { - lastAutomaticRefresh.addingTimeInterval(Self.timeUntilStale) < .now - } + private var isStale: Bool { + lastAutomaticRefresh.addingTimeInterval(Self.timeUntilStale) < .now + } } diff --git a/Swiftfin/Views/AboutView.swift b/Swiftfin/Views/AboutView.swift index 62cd434f..681a1882 100644 --- a/Swiftfin/Views/AboutView.swift +++ b/Swiftfin/Views/AboutView.swift @@ -10,77 +10,83 @@ import SwiftUI struct AboutView: View { - var body: some View { - List { - Section { - HStack { - Spacer() + var body: some View { + List { + Section { + HStack { + Spacer() - VStack(alignment: .center) { - AppIcon() - .cornerRadius(11) - .frame(width: 150, height: 150) + VStack(alignment: .center) { + AppIcon() + .cornerRadius(11) + .frame(width: 150, height: 150) - // App name, not to be localized - Text("Swiftfin") - .fontWeight(.semibold) - .font(.title2) - } + // App name, not to be localized + Text("Swiftfin") + .fontWeight(.semibold) + .font(.title2) + } - Spacer() - } - .listRowBackground(Color.clear) - } + Spacer() + } + .listRowBackground(Color.clear) + } - Section { + Section { - HStack { - L10n.about.text - Spacer() - Text("\(UIApplication.appVersion ?? "--") (\(UIApplication.bundleVersion ?? "--"))") - .foregroundColor(.secondary) - } + HStack { + L10n.about.text + Spacer() + Text("\(UIApplication.appVersion ?? "--") (\(UIApplication.bundleVersion ?? "--"))") + .foregroundColor(.secondary) + } - HStack { - Image("github-logo") - .renderingMode(.template) - .resizable() - .frame(width: 20, height: 20) - .foregroundColor(.primary) - Link(L10n.sourceCode, - destination: URL(string: "https://github.com/jellyfin/Swiftfin")!) - .foregroundColor(.primary) + HStack { + Image("github-logo") + .renderingMode(.template) + .resizable() + .frame(width: 20, height: 20) + .foregroundColor(.primary) + Link( + L10n.sourceCode, + destination: URL(string: "https://github.com/jellyfin/Swiftfin")! + ) + .foregroundColor(.primary) - Spacer() + Spacer() - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - } + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + } - HStack { - Image(systemName: "plus.circle.fill") - Link(L10n.requestFeature, - destination: URL(string: "https://github.com/jellyfin/Swiftfin/issues")!) - .foregroundColor(.primary) + HStack { + Image(systemName: "plus.circle.fill") + Link( + L10n.requestFeature, + destination: URL(string: "https://github.com/jellyfin/Swiftfin/issues")! + ) + .foregroundColor(.primary) - Spacer() + Spacer() - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - } + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + } - HStack { - Image(systemName: "xmark.circle.fill") - Link(L10n.reportIssue, - destination: URL(string: "https://github.com/jellyfin/Swiftfin/issues")!) - .foregroundColor(.primary) + HStack { + Image(systemName: "xmark.circle.fill") + Link( + L10n.reportIssue, + destination: URL(string: "https://github.com/jellyfin/Swiftfin/issues")! + ) + .foregroundColor(.primary) - Spacer() + Spacer() - Image(systemName: "chevron.right") - .foregroundColor(.secondary) - } - } - } - } + Image(systemName: "chevron.right") + .foregroundColor(.secondary) + } + } + } + } } diff --git a/Swiftfin/Views/BasicAppSettingsView.swift b/Swiftfin/Views/BasicAppSettingsView.swift index 75bf5da1..124f1ed0 100644 --- a/Swiftfin/Views/BasicAppSettingsView.swift +++ b/Swiftfin/Views/BasicAppSettingsView.swift @@ -12,104 +12,104 @@ import SwiftUI struct BasicAppSettingsView: View { - @EnvironmentObject - var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router - @ObservedObject - var viewModel: BasicAppSettingsViewModel - @State - var resetUserSettingsTapped: Bool = false - @State - var resetAppSettingsTapped: Bool = false - @State - var removeAllUsersTapped: Bool = false + @EnvironmentObject + var basicAppSettingsRouter: BasicAppSettingsCoordinator.Router + @ObservedObject + var viewModel: BasicAppSettingsViewModel + @State + var resetUserSettingsTapped: Bool = false + @State + var resetAppSettingsTapped: Bool = false + @State + var removeAllUsersTapped: Bool = false - @Default(.appAppearance) - var appAppearance - @Default(.defaultHTTPScheme) - var defaultHTTPScheme + @Default(.appAppearance) + var appAppearance + @Default(.defaultHTTPScheme) + var defaultHTTPScheme - var body: some View { - Form { + var body: some View { + Form { - Button { - basicAppSettingsRouter.route(to: \.about) - } label: { - HStack { - L10n.about.text - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - } - } + Button { + basicAppSettingsRouter.route(to: \.about) + } label: { + HStack { + L10n.about.text + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.right") + } + } - Section { - Picker(L10n.appearance, selection: $appAppearance) { - ForEach(self.viewModel.appearances, id: \.self) { appearance in - Text(appearance.localizedName).tag(appearance.rawValue) - } - } - } header: { - L10n.accessibility.text - } + Section { + Picker(L10n.appearance, selection: $appAppearance) { + ForEach(self.viewModel.appearances, id: \.self) { appearance in + Text(appearance.localizedName).tag(appearance.rawValue) + } + } + } header: { + L10n.accessibility.text + } - Section { - Picker(L10n.defaultScheme, selection: $defaultHTTPScheme) { - ForEach(HTTPScheme.allCases, id: \.self) { scheme in - Text("\(scheme.rawValue)") - } - } - } header: { - L10n.networking.text - } + Section { + Picker(L10n.defaultScheme, selection: $defaultHTTPScheme) { + ForEach(HTTPScheme.allCases, id: \.self) { scheme in + Text("\(scheme.rawValue)") + } + } + } header: { + L10n.networking.text + } - Button { - resetUserSettingsTapped = true - } label: { - L10n.resetUserSettings.text - } + Button { + resetUserSettingsTapped = true + } label: { + L10n.resetUserSettings.text + } - Button { - resetAppSettingsTapped = true - } label: { - L10n.resetAppSettings.text - } + Button { + resetAppSettingsTapped = true + } label: { + L10n.resetAppSettings.text + } - Button { - removeAllUsersTapped = true - } label: { - L10n.removeAllUsers.text - } - } - .alert(L10n.resetUserSettings, isPresented: $resetUserSettingsTapped, actions: { - Button(role: .destructive) { - viewModel.resetUserSettings() - } label: { - L10n.reset.text - } - }) - .alert(L10n.resetAppSettings, isPresented: $resetAppSettingsTapped, actions: { - Button(role: .destructive) { - viewModel.resetAppSettings() - } label: { - L10n.reset.text - } - }) - .alert(L10n.removeAllUsers, isPresented: $removeAllUsersTapped, actions: { - Button(role: .destructive) { - viewModel.removeAllUsers() - } label: { - L10n.reset.text - } - }) - .navigationBarTitle(L10n.settings, displayMode: .inline) - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - Button { - basicAppSettingsRouter.dismissCoordinator() - } label: { - Image(systemName: "xmark.circle.fill") - } - } - } - } + Button { + removeAllUsersTapped = true + } label: { + L10n.removeAllUsers.text + } + } + .alert(L10n.resetUserSettings, isPresented: $resetUserSettingsTapped, actions: { + Button(role: .destructive) { + viewModel.resetUserSettings() + } label: { + L10n.reset.text + } + }) + .alert(L10n.resetAppSettings, isPresented: $resetAppSettingsTapped, actions: { + Button(role: .destructive) { + viewModel.resetAppSettings() + } label: { + L10n.reset.text + } + }) + .alert(L10n.removeAllUsers, isPresented: $removeAllUsersTapped, actions: { + Button(role: .destructive) { + viewModel.removeAllUsers() + } label: { + L10n.reset.text + } + }) + .navigationBarTitle(L10n.settings, displayMode: .inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarLeading) { + Button { + basicAppSettingsRouter.dismissCoordinator() + } label: { + Image(systemName: "xmark.circle.fill") + } + } + } + } } diff --git a/Swiftfin/Views/ConnectToServerView.swift b/Swiftfin/Views/ConnectToServerView.swift index 0c09d6d1..256310c5 100644 --- a/Swiftfin/Views/ConnectToServerView.swift +++ b/Swiftfin/Views/ConnectToServerView.swift @@ -12,113 +12,117 @@ import SwiftUI struct ConnectToServerView: View { - @ObservedObject - var viewModel: ConnectToServerViewModel - @State - var uri = "" + @ObservedObject + var viewModel: ConnectToServerViewModel + @State + var uri = "" - @Default(.defaultHTTPScheme) - var defaultHTTPScheme + @Default(.defaultHTTPScheme) + var defaultHTTPScheme - var body: some View { - List { - Section { - TextField(L10n.serverURL, text: $uri) - .disableAutocorrection(true) - .autocapitalization(.none) - .keyboardType(.URL) - .onAppear { - if uri == "" { - uri = "\(defaultHTTPScheme.rawValue)://" - } - } + var body: some View { + List { + Section { + TextField(L10n.serverURL, text: $uri) + .disableAutocorrection(true) + .autocapitalization(.none) + .keyboardType(.URL) + .onAppear { + if uri == "" { + uri = "\(defaultHTTPScheme.rawValue)://" + } + } - if viewModel.isLoading { - Button(role: .destructive) { - viewModel.cancelConnection() - } label: { - L10n.cancel.text - } - } else { - Button { - viewModel.connectToServer(uri: uri) - } label: { - L10n.connect.text - } - .disabled(uri.isEmpty) - } - } header: { - L10n.connectToJellyfinServer.text - } + if viewModel.isLoading { + Button(role: .destructive) { + viewModel.cancelConnection() + } label: { + L10n.cancel.text + } + } else { + Button { + viewModel.connectToServer(uri: uri) + } label: { + L10n.connect.text + } + .disabled(uri.isEmpty) + } + } header: { + L10n.connectToJellyfinServer.text + } - Section { - if viewModel.searching { - HStack(alignment: .center, spacing: 5) { - Spacer() - L10n.searchingDots.text - .foregroundColor(.secondary) - Spacer() - } - } else { - if viewModel.discoveredServers.isEmpty { - HStack(alignment: .center) { - Spacer() - L10n.noLocalServersFound.text - .font(.callout) - .foregroundColor(.secondary) - Spacer() - } - } else { - ForEach(viewModel.discoveredServers.sorted(by: { $0.name < $1.name }), id: \.id) { discoveredServer in - Button { - uri = discoveredServer.url.absoluteString - viewModel.connectToServer(uri: discoveredServer.url.absoluteString) - } label: { - VStack(alignment: .leading, spacing: 5) { - Text(discoveredServer.name) - .font(.title3) - Text(discoveredServer.host) - .font(.subheadline) - .foregroundColor(.secondary) - } - } - .disabled(viewModel.isLoading) - } - } - } - } header: { - HStack { - L10n.localServers.text - Spacer() + Section { + if viewModel.searching { + HStack(alignment: .center, spacing: 5) { + Spacer() + L10n.searchingDots.text + .foregroundColor(.secondary) + Spacer() + } + } else { + if viewModel.discoveredServers.isEmpty { + HStack(alignment: .center) { + Spacer() + L10n.noLocalServersFound.text + .font(.callout) + .foregroundColor(.secondary) + Spacer() + } + } else { + ForEach(viewModel.discoveredServers.sorted(by: { $0.name < $1.name }), id: \.id) { discoveredServer in + Button { + uri = discoveredServer.url.absoluteString + viewModel.connectToServer(uri: discoveredServer.url.absoluteString) + } label: { + VStack(alignment: .leading, spacing: 5) { + Text(discoveredServer.name) + .font(.title3) + Text(discoveredServer.host) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + .disabled(viewModel.isLoading) + } + } + } + } header: { + HStack { + L10n.localServers.text + Spacer() - Button { - viewModel.discoverServers() - } label: { - Image(systemName: "arrow.clockwise.circle.fill") - } - .disabled(viewModel.searching || viewModel.isLoading) - } - } - .headerProminence(.increased) - } - .alert(item: $viewModel.errorMessage) { _ in - Alert(title: Text(viewModel.alertTitle), - message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), - dismissButton: .cancel()) - } - .alert(item: $viewModel.addServerURIPayload) { _ in - Alert(title: L10n.existingServer.text, - message: L10n.serverAlreadyExistsPrompt(viewModel.addServerURIPayload?.server.name ?? "").text, - primaryButton: .default(L10n.addURL.text, action: { - viewModel.addURIToServer(addServerURIPayload: viewModel.backAddServerURIPayload!) - }), - secondaryButton: .cancel()) - } - .navigationTitle(L10n.connect) - .onAppear { - viewModel.discoverServers() - AppURLHandler.shared.appURLState = .allowedInLogin - } - .navigationBarBackButtonHidden(viewModel.isLoading) - } + Button { + viewModel.discoverServers() + } label: { + Image(systemName: "arrow.clockwise.circle.fill") + } + .disabled(viewModel.searching || viewModel.isLoading) + } + } + .headerProminence(.increased) + } + .alert(item: $viewModel.errorMessage) { _ in + Alert( + title: Text(viewModel.alertTitle), + message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), + dismissButton: .cancel() + ) + } + .alert(item: $viewModel.addServerURIPayload) { _ in + Alert( + title: L10n.existingServer.text, + message: L10n.serverAlreadyExistsPrompt(viewModel.addServerURIPayload?.server.name ?? "").text, + primaryButton: .default(L10n.addURL.text, action: { + viewModel.addURIToServer(addServerURIPayload: viewModel.backAddServerURIPayload!) + }), + secondaryButton: .cancel() + ) + } + .navigationTitle(L10n.connect) + .onAppear { + viewModel.discoverServers() + AppURLHandler.shared.appURLState = .allowedInLogin + } + .navigationBarBackButtonHidden(viewModel.isLoading) + } } diff --git a/Swiftfin/Views/ContinueWatchingView.swift b/Swiftfin/Views/ContinueWatchingView.swift index 505e187f..2fcac8e7 100644 --- a/Swiftfin/Views/ContinueWatchingView.swift +++ b/Swiftfin/Views/ContinueWatchingView.swift @@ -11,100 +11,102 @@ import SwiftUI struct ContinueWatchingView: View { - @EnvironmentObject - var homeRouter: HomeCoordinator.Router - @ObservedObject - var viewModel: HomeViewModel + @EnvironmentObject + var homeRouter: HomeCoordinator.Router + @ObservedObject + var viewModel: HomeViewModel - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 20) { - ForEach(viewModel.resumeItems, id: \.id) { item in + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 20) { + ForEach(viewModel.resumeItems, id: \.id) { item in - Button { - homeRouter.route(to: \.item, item) - } label: { - VStack(alignment: .leading) { + Button { + homeRouter.route(to: \.item, item) + } label: { + VStack(alignment: .leading) { - ZStack { - Group { - if item.itemType == .episode { - ImageView(sources: [ - item.getSeriesThumbImage(maxWidth: 320), - item.getSeriesBackdropImage(maxWidth: 320), - ]) - .frame(width: 320, height: 180) - } else { - ImageView(sources: [ - item.getThumbImage(maxWidth: 320), - item.getBackdropImage(maxWidth: 320), - ]) - .frame(width: 320, height: 180) - } - } - .accessibilityIgnoresInvertColors() + ZStack { + Group { + if item.itemType == .episode { + ImageView(sources: [ + item.getSeriesThumbImage(maxWidth: 320), + item.getSeriesBackdropImage(maxWidth: 320), + ]) + .frame(width: 320, height: 180) + } else { + ImageView(sources: [ + item.getThumbImage(maxWidth: 320), + item.getBackdropImage(maxWidth: 320), + ]) + .frame(width: 320, height: 180) + } + } + .accessibilityIgnoresInvertColors() - HStack { - VStack { + HStack { + VStack { - Spacer() + Spacer() - ZStack(alignment: .bottom) { + ZStack(alignment: .bottom) { - LinearGradient(colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)], - startPoint: .top, - endPoint: .bottom) - .frame(height: 35) + LinearGradient( + colors: [.clear, .black.opacity(0.5), .black.opacity(0.7)], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 35) - VStack(alignment: .leading, spacing: 0) { - Text(item.getItemProgressString() ?? L10n.continue) - .font(.subheadline) - .padding(.bottom, 5) - .padding(.leading, 10) - .foregroundColor(.white) + VStack(alignment: .leading, spacing: 0) { + Text(item.getItemProgressString() ?? L10n.continue) + .font(.subheadline) + .padding(.bottom, 5) + .padding(.leading, 10) + .foregroundColor(.white) - HStack { - Color.jellyfinPurple - .frame(width: 320 * (item.userData?.playedPercentage ?? 0) / 100, height: 7) + HStack { + Color.jellyfinPurple + .frame(width: 320 * (item.userData?.playedPercentage ?? 0) / 100, height: 7) - Spacer(minLength: 0) - } - } - } - } - } - } - .frame(width: 320, height: 180) - .mask(Rectangle().cornerRadius(10)) - .shadow(radius: 4, y: 2) + Spacer(minLength: 0) + } + } + } + } + } + } + .frame(width: 320, height: 180) + .mask(Rectangle().cornerRadius(10)) + .shadow(radius: 4, y: 2) - VStack(alignment: .leading) { - Text("\(item.seriesName ?? item.name ?? "")") - .font(.callout) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) + VStack(alignment: .leading) { + Text("\(item.seriesName ?? item.name ?? "")") + .font(.callout) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) - if item.itemType == .episode { - Text(item.getEpisodeLocator() ?? "") - .font(.callout) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - } - } - } - .contextMenu { - Button(role: .destructive) { - viewModel.removeItemFromResume(item) - } label: { - Label(L10n.removeFromResume, systemImage: "minus.circle") - } - } - } - } - .padding(.horizontal) - } - } + if item.itemType == .episode { + Text(item.getEpisodeLocator() ?? "") + .font(.callout) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + } + .contextMenu { + Button(role: .destructive) { + viewModel.removeItemFromResume(item) + } label: { + Label(L10n.removeFromResume, systemImage: "minus.circle") + } + } + } + } + .padding(.horizontal) + } + } } diff --git a/Swiftfin/Views/HomeView.swift b/Swiftfin/Views/HomeView.swift index a7d1fbe1..b1f8f02b 100644 --- a/Swiftfin/Views/HomeView.swift +++ b/Swiftfin/Views/HomeView.swift @@ -12,130 +12,136 @@ import SwiftUI struct HomeView: View { - @EnvironmentObject - var homeRouter: HomeCoordinator.Router - @StateObject - var viewModel = HomeViewModel() + @EnvironmentObject + var homeRouter: HomeCoordinator.Router + @StateObject + var viewModel = HomeViewModel() - private let refreshHelper = RefreshHelper() + private let refreshHelper = RefreshHelper() - @ViewBuilder - var innerBody: some View { - if let errorMessage = viewModel.errorMessage { - VStack(spacing: 5) { - if viewModel.isLoading { - ProgressView() - .frame(width: 100, height: 100) - .scaleEffect(2) - } else { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 72)) - .foregroundColor(Color.red) - .frame(width: 100, height: 100) - } + @ViewBuilder + var innerBody: some View { + if let errorMessage = viewModel.errorMessage { + VStack(spacing: 5) { + if viewModel.isLoading { + ProgressView() + .frame(width: 100, height: 100) + .scaleEffect(2) + } else { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 72)) + .foregroundColor(Color.red) + .frame(width: 100, height: 100) + } - Text("\(errorMessage.code)") - Text(errorMessage.message) - .frame(minWidth: 50, maxWidth: 240) - .multilineTextAlignment(.center) + Text("\(errorMessage.code)") + Text(errorMessage.message) + .frame(minWidth: 50, maxWidth: 240) + .multilineTextAlignment(.center) - PrimaryButtonView(title: L10n.retry) { - viewModel.refresh() - } - } - .offset(y: -50) - } else if viewModel.isLoading { - ProgressView() - .frame(width: 100, height: 100) - .scaleEffect(2) - } else { - ScrollView { - VStack(alignment: .leading) { - if !viewModel.resumeItems.isEmpty { - ContinueWatchingView(viewModel: viewModel) - } + PrimaryButtonView(title: L10n.retry) { + viewModel.refresh() + } + } + .offset(y: -50) + } else if viewModel.isLoading { + ProgressView() + .frame(width: 100, height: 100) + .scaleEffect(2) + } else { + ScrollView { + VStack(alignment: .leading) { + if !viewModel.resumeItems.isEmpty { + ContinueWatchingView(viewModel: viewModel) + } - if !viewModel.nextUpItems.isEmpty { - PortraitImageHStackView(items: viewModel.nextUpItems, - horizontalAlignment: .leading) { - L10n.nextUp.text - .font(.title2) - .fontWeight(.bold) - .padding() - .accessibility(addTraits: [.isHeader]) - } selectedAction: { item in - homeRouter.route(to: \.item, item) - } - } + if !viewModel.nextUpItems.isEmpty { + PortraitImageHStackView( + items: viewModel.nextUpItems, + horizontalAlignment: .leading + ) { + L10n.nextUp.text + .font(.title2) + .fontWeight(.bold) + .padding() + .accessibility(addTraits: [.isHeader]) + } selectedAction: { item in + homeRouter.route(to: \.item, item) + } + } - if !viewModel.latestAddedItems.isEmpty { - PortraitImageHStackView(items: viewModel.latestAddedItems) { - L10n.recentlyAdded.text - .font(.title2) - .fontWeight(.bold) - .padding() - .accessibility(addTraits: [.isHeader]) - } selectedAction: { item in - homeRouter.route(to: \.item, item) - } - } + if !viewModel.latestAddedItems.isEmpty { + PortraitImageHStackView(items: viewModel.latestAddedItems) { + L10n.recentlyAdded.text + .font(.title2) + .fontWeight(.bold) + .padding() + .accessibility(addTraits: [.isHeader]) + } selectedAction: { item in + homeRouter.route(to: \.item, item) + } + } - ForEach(viewModel.libraries, id: \.self) { library in + ForEach(viewModel.libraries, id: \.self) { library in - LatestMediaView(viewModel: LatestMediaViewModel(library: library)) { - HStack { - Text(L10n.latestWithString(library.name ?? "")) - .font(.title2) - .fontWeight(.bold) - .accessibility(addTraits: [.isHeader]) + LatestMediaView(viewModel: LatestMediaViewModel(library: library)) { + HStack { + Text(L10n.latestWithString(library.name ?? "")) + .font(.title2) + .fontWeight(.bold) + .accessibility(addTraits: [.isHeader]) - Spacer() + Spacer() - Button { - homeRouter - .route(to: \.library, (viewModel: .init(parentID: library.id!, - filters: viewModel.recentFilterSet), - title: library.name ?? "")) - } label: { - HStack { - L10n.seeAll.text.font(.subheadline).fontWeight(.bold) - Image(systemName: "chevron.right").font(Font.subheadline.bold()) - } - } - } - .padding() - } - } - } - .padding(.bottom, 50) - } - .introspectScrollView { scrollView in - let control = UIRefreshControl() + Button { + homeRouter + .route(to: \.library, ( + viewModel: .init( + parentID: library.id!, + filters: viewModel.recentFilterSet + ), + title: library.name ?? "" + )) + } label: { + HStack { + L10n.seeAll.text.font(.subheadline).fontWeight(.bold) + Image(systemName: "chevron.right").font(Font.subheadline.bold()) + } + } + } + .padding() + } + } + } + .padding(.bottom, 50) + } + .introspectScrollView { scrollView in + let control = UIRefreshControl() - refreshHelper.refreshControl = control - refreshHelper.refreshAction = viewModel.refresh + refreshHelper.refreshControl = control + refreshHelper.refreshAction = viewModel.refresh - control.addTarget(refreshHelper, action: #selector(RefreshHelper.didRefresh), for: .valueChanged) - scrollView.refreshControl = control - } - } - } + control.addTarget(refreshHelper, action: #selector(RefreshHelper.didRefresh), for: .valueChanged) + scrollView.refreshControl = control + } + } + } - var body: some View { - innerBody - .navigationTitle(L10n.home) - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - Button { - homeRouter.route(to: \.settings) - } label: { - Image(systemName: "gearshape.fill") - .accessibilityLabel(L10n.settings) - } - } - } - .onAppear { - refreshHelper.refreshStaleData() - } - } + var body: some View { + innerBody + .navigationTitle(L10n.home) + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + homeRouter.route(to: \.settings) + } label: { + Image(systemName: "gearshape.fill") + .accessibilityLabel(L10n.settings) + } + } + } + .onAppear { + refreshHelper.refreshStaleData() + } + } } diff --git a/Swiftfin/Views/ItemOverviewView.swift b/Swiftfin/Views/ItemOverviewView.swift index d0d4b220..b4a3d700 100644 --- a/Swiftfin/Views/ItemOverviewView.swift +++ b/Swiftfin/Views/ItemOverviewView.swift @@ -11,25 +11,25 @@ import SwiftUI struct ItemOverviewView: View { - @EnvironmentObject - var itemOverviewRouter: ItemOverviewCoordinator.Router - let item: BaseItemDto + @EnvironmentObject + var itemOverviewRouter: ItemOverviewCoordinator.Router + let item: BaseItemDto - var body: some View { - ScrollView(showsIndicators: false) { - Text(item.overview ?? "") - .font(.footnote) - .padding() - } - .navigationBarTitle(L10n.overview, displayMode: .inline) - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - Button { - itemOverviewRouter.dismissCoordinator() - } label: { - Image(systemName: "xmark.circle.fill") - } - } - } - } + var body: some View { + ScrollView(showsIndicators: false) { + Text(item.overview ?? "") + .font(.footnote) + .padding() + } + .navigationBarTitle(L10n.overview, displayMode: .inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarLeading) { + Button { + itemOverviewRouter.dismissCoordinator() + } label: { + Image(systemName: "xmark.circle.fill") + } + } + } + } } diff --git a/Swiftfin/Views/ItemView/ItemView.swift b/Swiftfin/Views/ItemView/ItemView.swift index 4090aa3d..35ab3db0 100644 --- a/Swiftfin/Views/ItemView/ItemView.swift +++ b/Swiftfin/Views/ItemView/ItemView.swift @@ -12,61 +12,61 @@ import SwiftUI // Intermediary view for ItemView to set navigation bar settings struct ItemNavigationView: View { - private let item: BaseItemDto + private let item: BaseItemDto - init(item: BaseItemDto) { - self.item = item - } + init(item: BaseItemDto) { + self.item = item + } - var body: some View { - ItemView(item: item) - .navigationBarTitle(item.name ?? "", displayMode: .inline) - .introspectNavigationController { navigationController in - let textAttributes = [NSAttributedString.Key.foregroundColor: UIColor.clear] - navigationController.navigationBar.titleTextAttributes = textAttributes - } - } + var body: some View { + ItemView(item: item) + .navigationBarTitle(item.name ?? "", displayMode: .inline) + .introspectNavigationController { navigationController in + let textAttributes = [NSAttributedString.Key.foregroundColor: UIColor.clear] + navigationController.navigationBar.titleTextAttributes = textAttributes + } + } } private struct ItemView: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router + @EnvironmentObject + var itemRouter: ItemCoordinator.Router - @State - private var orientation: UIDeviceOrientation = .unknown - @Environment(\.horizontalSizeClass) - private var hSizeClass - @Environment(\.verticalSizeClass) - private var vSizeClass + @State + private var orientation: UIDeviceOrientation = .unknown + @Environment(\.horizontalSizeClass) + private var hSizeClass + @Environment(\.verticalSizeClass) + private var vSizeClass - private let viewModel: ItemViewModel + private let viewModel: ItemViewModel - init(item: BaseItemDto) { - switch item.itemType { - case .movie: - self.viewModel = MovieItemViewModel(item: item) - case .season: - self.viewModel = SeasonItemViewModel(item: item) - case .episode: - self.viewModel = EpisodeItemViewModel(item: item) - case .series: - self.viewModel = SeriesItemViewModel(item: item) - case .boxset, .folder: - self.viewModel = CollectionItemViewModel(item: item) - default: - self.viewModel = ItemViewModel(item: item) - } - } + init(item: BaseItemDto) { + switch item.itemType { + case .movie: + self.viewModel = MovieItemViewModel(item: item) + case .season: + self.viewModel = SeasonItemViewModel(item: item) + case .episode: + self.viewModel = EpisodeItemViewModel(item: item) + case .series: + self.viewModel = SeriesItemViewModel(item: item) + case .boxset, .folder: + self.viewModel = CollectionItemViewModel(item: item) + default: + self.viewModel = ItemViewModel(item: item) + } + } - var body: some View { - Group { - if hSizeClass == .compact && vSizeClass == .regular { - ItemPortraitMainView() - .environmentObject(viewModel) - } else { - ItemLandscapeMainView() - .environmentObject(viewModel) - } - } - } + var body: some View { + Group { + if hSizeClass == .compact && vSizeClass == .regular { + ItemPortraitMainView() + .environmentObject(viewModel) + } else { + ItemLandscapeMainView() + .environmentObject(viewModel) + } + } + } } diff --git a/Swiftfin/Views/ItemView/ItemViewBody.swift b/Swiftfin/Views/ItemView/ItemViewBody.swift index 5e9b959d..1fd32492 100644 --- a/Swiftfin/Views/ItemView/ItemViewBody.swift +++ b/Swiftfin/Views/ItemView/ItemViewBody.swift @@ -12,158 +12,171 @@ import SwiftUI struct ItemViewBody: View { - @Environment(\.horizontalSizeClass) - private var hSizeClass - @Environment(\.verticalSizeClass) - private var vSizeClass - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @EnvironmentObject - private var viewModel: ItemViewModel - @Default(.showCastAndCrew) - var showCastAndCrew + @Environment(\.horizontalSizeClass) + private var hSizeClass + @Environment(\.verticalSizeClass) + private var vSizeClass + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @EnvironmentObject + private var viewModel: ItemViewModel + @Default(.showCastAndCrew) + var showCastAndCrew - var body: some View { - VStack(alignment: .leading) { - // MARK: Overview + var body: some View { + VStack(alignment: .leading) { + // MARK: Overview - if let itemOverview = viewModel.item.overview { - if hSizeClass == .compact && vSizeClass == .regular { - TruncatedTextView(itemOverview, - lineLimit: 5, - font: UIFont.preferredFont(forTextStyle: .footnote)) { - itemRouter.route(to: \.itemOverview, viewModel.item) - } - .padding(.horizontal) - .padding(.top) - } else { - Text(itemOverview) - .font(.footnote) - .padding() - } - } else { - L10n.noOverviewAvailable.text - .font(.footnote) - .padding() - } + if let itemOverview = viewModel.item.overview { + if hSizeClass == .compact && vSizeClass == .regular { + TruncatedTextView( + itemOverview, + lineLimit: 5, + font: UIFont.preferredFont(forTextStyle: .footnote) + ) { + itemRouter.route(to: \.itemOverview, viewModel.item) + } + .padding(.horizontal) + .padding(.top) + } else { + Text(itemOverview) + .font(.footnote) + .padding() + } + } else { + L10n.noOverviewAvailable.text + .font(.footnote) + .padding() + } - // MARK: Seasons + // MARK: Seasons - if let seriesViewModel = viewModel as? SeriesItemViewModel { - PortraitImageHStackView(items: seriesViewModel.seasons, - topBarView: { - L10n.seasons.text - .fontWeight(.semibold) - .padding() - .accessibility(addTraits: [.isHeader]) - }, selectedAction: { season in - itemRouter.route(to: \.item, season) - }) - } + if let seriesViewModel = viewModel as? SeriesItemViewModel { + PortraitImageHStackView( + items: seriesViewModel.seasons, + topBarView: { + L10n.seasons.text + .fontWeight(.semibold) + .padding() + .accessibility(addTraits: [.isHeader]) + }, + selectedAction: { season in + itemRouter.route(to: \.item, season) + } + ) + } - // MARK: Genres + // MARK: Genres - if let genres = viewModel.item.genreItems, !genres.isEmpty { - PillHStackView(title: L10n.genres, - items: genres, - selectedAction: { genre in - itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) - }) - .padding(.bottom) - } + if let genres = viewModel.item.genreItems, !genres.isEmpty { + PillHStackView( + title: L10n.genres, + items: genres, + selectedAction: { genre in + itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) + } + ) + .padding(.bottom) + } - // MARK: Studios + // MARK: Studios - if let studios = viewModel.item.studios { - PillHStackView(title: L10n.studios, - items: studios) { studio in - itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) - } - .padding(.bottom) - } + if let studios = viewModel.item.studios { + PillHStackView( + title: L10n.studios, + items: studios + ) { studio in + itemRouter.route(to: \.library, (viewModel: .init(studio: studio), title: studio.name ?? "")) + } + .padding(.bottom) + } - // MARK: Episodes + // MARK: Episodes - if let episodeViewModel = viewModel as? EpisodeItemViewModel { - EpisodesRowView(viewModel: episodeViewModel, onlyCurrentSeason: false) - } else if let seasonViewModel = viewModel as? SeasonItemViewModel { - EpisodesRowView(viewModel: seasonViewModel, onlyCurrentSeason: true) - } + if let episodeViewModel = viewModel as? EpisodeItemViewModel { + EpisodesRowView(viewModel: episodeViewModel, onlyCurrentSeason: false) + } else if let seasonViewModel = viewModel as? SeasonItemViewModel { + EpisodesRowView(viewModel: seasonViewModel, onlyCurrentSeason: true) + } - // MARK: Series + // MARK: Series - if let episodeViewModel = viewModel as? EpisodeItemViewModel { - if let seriesItem = episodeViewModel.series { - let a = [seriesItem] - PortraitImageHStackView(items: a) { - L10n.series.text - .fontWeight(.semibold) - .padding(.bottom) - .padding(.horizontal) - } selectedAction: { seriesItem in - itemRouter.route(to: \.item, seriesItem) - } - } - } + if let episodeViewModel = viewModel as? EpisodeItemViewModel { + if let seriesItem = episodeViewModel.series { + let a = [seriesItem] + PortraitImageHStackView(items: a) { + L10n.series.text + .fontWeight(.semibold) + .padding(.bottom) + .padding(.horizontal) + } selectedAction: { seriesItem in + itemRouter.route(to: \.item, seriesItem) + } + } + } - // MARK: Collection Items + // MARK: Collection Items - if let collectionViewModel = viewModel as? CollectionItemViewModel { - PortraitImageHStackView(items: collectionViewModel.collectionItems) { - L10n.items.text - .fontWeight(.semibold) - .padding(.bottom) - .padding(.horizontal) - .accessibility(addTraits: [.isHeader]) - } selectedAction: { collectionItem in - itemRouter.route(to: \.item, collectionItem) - } - } + if let collectionViewModel = viewModel as? CollectionItemViewModel { + PortraitImageHStackView(items: collectionViewModel.collectionItems) { + L10n.items.text + .fontWeight(.semibold) + .padding(.bottom) + .padding(.horizontal) + .accessibility(addTraits: [.isHeader]) + } selectedAction: { collectionItem in + itemRouter.route(to: \.item, collectionItem) + } + } - // MARK: Cast & Crew + // MARK: Cast & Crew - if showCastAndCrew { - if let castAndCrew = viewModel.item.people, !castAndCrew.isEmpty { - PortraitImageHStackView(items: castAndCrew.filter { BaseItemPerson.DisplayedType.allCasesRaw.contains($0.type ?? "") }, - topBarView: { - L10n.castAndCrew.text - .fontWeight(.semibold) - .padding(.bottom) - .padding(.horizontal) - .accessibility(addTraits: [.isHeader]) - }, - selectedAction: { person in - itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) - }) - } - } + if showCastAndCrew { + if let castAndCrew = viewModel.item.people, !castAndCrew.isEmpty { + PortraitImageHStackView( + items: castAndCrew.filter { BaseItemPerson.DisplayedType.allCasesRaw.contains($0.type ?? "") }, + topBarView: { + L10n.castAndCrew.text + .fontWeight(.semibold) + .padding(.bottom) + .padding(.horizontal) + .accessibility(addTraits: [.isHeader]) + }, + selectedAction: { person in + itemRouter.route(to: \.library, (viewModel: .init(person: person), title: person.title)) + } + ) + } + } - // MARK: Recommended + // MARK: Recommended - if !viewModel.similarItems.isEmpty { - PortraitImageHStackView(items: viewModel.similarItems, - topBarView: { - L10n.recommended.text - .fontWeight(.semibold) - .padding(.bottom) - .padding(.horizontal) - .accessibility(addTraits: [.isHeader]) - }, - selectedAction: { item in - itemRouter.route(to: \.item, item) - }) - } + if !viewModel.similarItems.isEmpty { + PortraitImageHStackView( + items: viewModel.similarItems, + topBarView: { + L10n.recommended.text + .fontWeight(.semibold) + .padding(.bottom) + .padding(.horizontal) + .accessibility(addTraits: [.isHeader]) + }, + selectedAction: { item in + itemRouter.route(to: \.item, item) + } + ) + } - // MARK: Details + // MARK: Details - switch viewModel.item.itemType { - case .movie, .episode: - ItemViewDetailsView(viewModel: viewModel) - .padding() - default: - EmptyView() - .frame(height: 50) - } - } - } + switch viewModel.item.itemType { + case .movie, .episode: + ItemViewDetailsView(viewModel: viewModel) + .padding() + default: + EmptyView() + .frame(height: 50) + } + } + } } diff --git a/Swiftfin/Views/ItemView/ItemViewDetailsView.swift b/Swiftfin/Views/ItemView/ItemViewDetailsView.swift index ffd3847c..73ee4975 100644 --- a/Swiftfin/Views/ItemView/ItemViewDetailsView.swift +++ b/Swiftfin/Views/ItemView/ItemViewDetailsView.swift @@ -11,72 +11,72 @@ import SwiftUI struct ItemViewDetailsView: View { - @ObservedObject - var viewModel: ItemViewModel + @ObservedObject + var viewModel: ItemViewModel - var body: some View { - VStack(alignment: .leading) { + var body: some View { + VStack(alignment: .leading) { - if !viewModel.informationItems.isEmpty { - VStack(alignment: .leading, spacing: 20) { - L10n.information.text - .font(.title3) - .fontWeight(.bold) - .accessibility(addTraits: [.isHeader]) + if !viewModel.informationItems.isEmpty { + VStack(alignment: .leading, spacing: 20) { + L10n.information.text + .font(.title3) + .fontWeight(.bold) + .accessibility(addTraits: [.isHeader]) - ForEach(viewModel.informationItems, id: \.self.title) { informationItem in - VStack(alignment: .leading, spacing: 2) { - Text(informationItem.title) - .font(.subheadline) - Text(informationItem.content) - .font(.subheadline) - .foregroundColor(Color.secondary) - } - .accessibilityElement(children: .combine) - } - } - .padding(.bottom, 20) - } + ForEach(viewModel.informationItems, id: \.self.title) { informationItem in + VStack(alignment: .leading, spacing: 2) { + Text(informationItem.title) + .font(.subheadline) + Text(informationItem.content) + .font(.subheadline) + .foregroundColor(Color.secondary) + } + .accessibilityElement(children: .combine) + } + } + .padding(.bottom, 20) + } - VStack(alignment: .leading, spacing: 20) { - L10n.media.text - .font(.title3) - .fontWeight(.bold) - .accessibility(addTraits: [.isHeader]) + VStack(alignment: .leading, spacing: 20) { + L10n.media.text + .font(.title3) + .fontWeight(.bold) + .accessibility(addTraits: [.isHeader]) - VStack(alignment: .leading, spacing: 2) { - L10n.file.text - .font(.subheadline) - Text(viewModel.selectedVideoPlayerViewModel?.filename ?? "--") - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .font(.subheadline) - .foregroundColor(Color.secondary) - } - .accessibilityElement(children: .combine) + VStack(alignment: .leading, spacing: 2) { + L10n.file.text + .font(.subheadline) + Text(viewModel.selectedVideoPlayerViewModel?.filename ?? "--") + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .font(.subheadline) + .foregroundColor(Color.secondary) + } + .accessibilityElement(children: .combine) - VStack(alignment: .leading, spacing: 2) { - L10n.containers.text - .font(.subheadline) - Text(viewModel.selectedVideoPlayerViewModel?.container ?? "--") - .font(.subheadline) - .foregroundColor(Color.secondary) - } - .accessibilityElement(children: .combine) + VStack(alignment: .leading, spacing: 2) { + L10n.containers.text + .font(.subheadline) + Text(viewModel.selectedVideoPlayerViewModel?.container ?? "--") + .font(.subheadline) + .foregroundColor(Color.secondary) + } + .accessibilityElement(children: .combine) - ForEach(viewModel.selectedVideoPlayerViewModel?.mediaItems ?? [], id: \.self.title) { mediaItem in - VStack(alignment: .leading, spacing: 2) { - Text(mediaItem.title) - .font(.subheadline) - Text(mediaItem.content) - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .font(.subheadline) - .foregroundColor(Color.secondary) - } - .accessibilityElement(children: .combine) - } - } - } - } + ForEach(viewModel.selectedVideoPlayerViewModel?.mediaItems ?? [], id: \.self.title) { mediaItem in + VStack(alignment: .leading, spacing: 2) { + Text(mediaItem.title) + .font(.subheadline) + Text(mediaItem.content) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .font(.subheadline) + .foregroundColor(Color.secondary) + } + .accessibilityElement(children: .combine) + } + } + } + } } diff --git a/Swiftfin/Views/ItemView/Landscape/ItemLandscapeMainView.swift b/Swiftfin/Views/ItemView/Landscape/ItemLandscapeMainView.swift index 3ab2a04b..5865135a 100644 --- a/Swiftfin/Views/ItemView/Landscape/ItemLandscapeMainView.swift +++ b/Swiftfin/Views/ItemView/Landscape/ItemLandscapeMainView.swift @@ -10,107 +10,111 @@ import Stinsen import SwiftUI struct ItemLandscapeMainView: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @EnvironmentObject - private var viewModel: ItemViewModel - @State - private var playButtonText: String = "" + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @EnvironmentObject + private var viewModel: ItemViewModel + @State + private var playButtonText: String = "" - // MARK: innerBody + // MARK: innerBody - private var innerBody: some View { - HStack { - // MARK: Sidebar Image + private var innerBody: some View { + HStack { + // MARK: Sidebar Image - VStack { - ImageView(viewModel.item.portraitHeaderViewURL(maxWidth: 130), - blurHash: viewModel.item.getPrimaryImageBlurHash()) - .frame(width: 130, height: 195) - .cornerRadius(10) - .accessibilityIgnoresInvertColors() + VStack { + ImageView( + viewModel.item.portraitHeaderViewURL(maxWidth: 130), + blurHash: viewModel.item.getPrimaryImageBlurHash() + ) + .frame(width: 130, height: 195) + .cornerRadius(10) + .accessibilityIgnoresInvertColors() - Spacer().frame(height: 15) + Spacer().frame(height: 15) - // MARK: Play + // MARK: Play - Button { - self.itemRouter.route(to: \.videoPlayer, viewModel.selectedVideoPlayerViewModel!) - } label: { - HStack { - Image(systemName: "play.fill") - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) - .font(.system(size: 20)) - Text(viewModel.playButtonText()) - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) - .font(.callout) - .fontWeight(.semibold) - } - .frame(width: 130, height: 40) - .background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple) - .cornerRadius(10) - } - .disabled(viewModel.playButtonItem == nil || viewModel.selectedVideoPlayerViewModel == nil) - .contextMenu { - if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 { - Button { - if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { - selectedVideoPlayerViewModel.injectCustomValues(startFromBeginning: true) - itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) - } else { - LogManager.log.error("Attempted to play item but no playback information available") - } - } label: { - Label(L10n.playFromBeginning, systemImage: "gobackward") - } - } - } + Button { + self.itemRouter.route(to: \.videoPlayer, viewModel.selectedVideoPlayerViewModel!) + } label: { + HStack { + Image(systemName: "play.fill") + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) + .font(.system(size: 20)) + Text(viewModel.playButtonText()) + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) + .font(.callout) + .fontWeight(.semibold) + } + .frame(width: 130, height: 40) + .background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple) + .cornerRadius(10) + } + .disabled(viewModel.playButtonItem == nil || viewModel.selectedVideoPlayerViewModel == nil) + .contextMenu { + if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 { + Button { + if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { + selectedVideoPlayerViewModel.injectCustomValues(startFromBeginning: true) + itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) + } else { + LogManager.log.error("Attempted to play item but no playback information available") + } + } label: { + Label(L10n.playFromBeginning, systemImage: "gobackward") + } + } + } - Spacer() - } + Spacer() + } - ScrollView { - VStack(alignment: .leading) { - // MARK: ItemLandscapeTopBarView + ScrollView { + VStack(alignment: .leading) { + // MARK: ItemLandscapeTopBarView - ItemLandscapeTopBarView() - .environmentObject(viewModel) + ItemLandscapeTopBarView() + .environmentObject(viewModel) - // MARK: ItemViewBody + // MARK: ItemViewBody - ItemViewBody() - .environmentObject(viewModel) - } - } - } - .onAppear { - playButtonText = viewModel.playButtonText() - } - } + ItemViewBody() + .environmentObject(viewModel) + } + } + } + .onAppear { + playButtonText = viewModel.playButtonText() + } + } - // MARK: body + // MARK: body - var body: some View { - VStack { - ZStack { - // MARK: Backdrop + var body: some View { + VStack { + ZStack { + // MARK: Backdrop - ImageView(viewModel.item.getBackdropImage(maxWidth: 200), - blurHash: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.3) - .edgesIgnoringSafeArea(.all) - .blur(radius: 8) - .layoutPriority(-1) - .accessibilityIgnoresInvertColors() + ImageView( + viewModel.item.getBackdropImage(maxWidth: 200), + blurHash: viewModel.item.getBackdropImageBlurHash() + ) + .opacity(0.3) + .edgesIgnoringSafeArea(.all) + .blur(radius: 8) + .layoutPriority(-1) + .accessibilityIgnoresInvertColors() - // iPadOS is making the view go all the way to the edge. - // We have to accomodate this here - if UIDevice.current.userInterfaceIdiom == .pad { - innerBody.padding(.horizontal, 25) - } else { - innerBody - } - } - } - } + // iPadOS is making the view go all the way to the edge. + // We have to accomodate this here + if UIDevice.current.userInterfaceIdiom == .pad { + innerBody.padding(.horizontal, 25) + } else { + innerBody + } + } + } + } } diff --git a/Swiftfin/Views/ItemView/Landscape/ItemLandscapeTopBarView.swift b/Swiftfin/Views/ItemView/Landscape/ItemLandscapeTopBarView.swift index 6ee32c2a..79b9e827 100644 --- a/Swiftfin/Views/ItemView/Landscape/ItemLandscapeTopBarView.swift +++ b/Swiftfin/Views/ItemView/Landscape/ItemLandscapeTopBarView.swift @@ -10,122 +10,124 @@ import SwiftUI struct ItemLandscapeTopBarView: View { - @EnvironmentObject - private var viewModel: ItemViewModel + @EnvironmentObject + private var viewModel: ItemViewModel - var body: some View { - HStack { - VStack(alignment: .leading) { + var body: some View { + HStack { + VStack(alignment: .leading) { - // MARK: Name + // MARK: Name - Text(viewModel.getItemDisplayName()) - .font(.title) - .fontWeight(.semibold) - .foregroundColor(.primary) - .padding(.leading, 16) - .padding(.bottom, 10) - .accessibility(addTraits: [.isHeader]) + Text(viewModel.getItemDisplayName()) + .font(.title) + .fontWeight(.semibold) + .foregroundColor(.primary) + .padding(.leading, 16) + .padding(.bottom, 10) + .accessibility(addTraits: [.isHeader]) - // MARK: Details + // MARK: Details - HStack { + HStack { - if viewModel.item.unaired { - if let premiereDateLabel = viewModel.item.airDateLabel { - Text(premiereDateLabel) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - } + if viewModel.item.unaired { + if let premiereDateLabel = viewModel.item.airDateLabel { + Text(premiereDateLabel) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + } - if let runtime = viewModel.item.getItemRuntime() { - Text(runtime) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .padding(.leading, 16) - } + if let runtime = viewModel.item.getItemRuntime() { + Text(runtime) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .padding(.leading, 16) + } - if viewModel.item.productionYear != nil { - Text(String(viewModel.item.productionYear ?? 0)) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - } + if viewModel.item.productionYear != nil { + Text(String(viewModel.item.productionYear ?? 0)) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + } - if viewModel.item.officialRating != nil { - Text(viewModel.item.officialRating!) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } + if viewModel.item.officialRating != nil { + Text(viewModel.item.officialRating!) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1) + ) + } - Spacer() + Spacer() - if viewModel.item.itemType.showDetails { - // MARK: Favorite + if viewModel.item.itemType.showDetails { + // MARK: Favorite - Button { - viewModel.updateFavoriteState() - } label: { - if viewModel.isFavorited { - Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) - .font(.system(size: 20)) - } else { - Image(systemName: "heart").foregroundColor(Color.primary) - .font(.system(size: 20)) - } - } - .disabled(viewModel.isLoading) + Button { + viewModel.updateFavoriteState() + } label: { + if viewModel.isFavorited { + Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) + .font(.system(size: 20)) + } else { + Image(systemName: "heart").foregroundColor(Color.primary) + .font(.system(size: 20)) + } + } + .disabled(viewModel.isLoading) - // MARK: Watched + // MARK: Watched - Button { - viewModel.updateWatchState() - } label: { - if viewModel.isWatched { - Image(systemName: "checkmark.circle.fill").foregroundColor(Color.primary) - .font(.system(size: 20)) - } else { - Image(systemName: "checkmark.circle").foregroundColor(Color.primary) - .font(.system(size: 20)) - } - } - .disabled(viewModel.isLoading) - } - } - .padding(.leading) + Button { + viewModel.updateWatchState() + } label: { + if viewModel.isWatched { + Image(systemName: "checkmark.circle.fill").foregroundColor(Color.primary) + .font(.system(size: 20)) + } else { + Image(systemName: "checkmark.circle").foregroundColor(Color.primary) + .font(.system(size: 20)) + } + } + .disabled(viewModel.isLoading) + } + } + .padding(.leading) - if viewModel.videoPlayerViewModels.count > 1 { - Menu { - ForEach(viewModel.videoPlayerViewModels, id: \.versionName) { viewModelOption in - Button { - viewModel.selectedVideoPlayerViewModel = viewModelOption - } label: { - if viewModelOption.versionName == viewModel.selectedVideoPlayerViewModel?.versionName { - Label(viewModelOption.versionName ?? L10n.noTitle, systemImage: "checkmark") - } else { - Text(viewModelOption.versionName ?? L10n.noTitle) - } - } - } - } label: { - HStack(spacing: 5) { - Text(viewModel.selectedVideoPlayerViewModel?.versionName ?? L10n.noTitle) - .fontWeight(.semibold) - .fixedSize() - Image(systemName: "chevron.down") - } - } - .padding(.leading) - } - } - } - } + if viewModel.videoPlayerViewModels.count > 1 { + Menu { + ForEach(viewModel.videoPlayerViewModels, id: \.versionName) { viewModelOption in + Button { + viewModel.selectedVideoPlayerViewModel = viewModelOption + } label: { + if viewModelOption.versionName == viewModel.selectedVideoPlayerViewModel?.versionName { + Label(viewModelOption.versionName ?? L10n.noTitle, systemImage: "checkmark") + } else { + Text(viewModelOption.versionName ?? L10n.noTitle) + } + } + } + } label: { + HStack(spacing: 5) { + Text(viewModel.selectedVideoPlayerViewModel?.versionName ?? L10n.noTitle) + .fontWeight(.semibold) + .fixedSize() + Image(systemName: "chevron.down") + } + } + .padding(.leading) + } + } + } + } } diff --git a/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift b/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift index bc700c0b..865e6bff 100644 --- a/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift +++ b/Swiftfin/Views/ItemView/Portrait/ItemPortraitHeaderOverlayView.swift @@ -11,188 +11,192 @@ import SwiftUI struct PortraitHeaderOverlayView: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @EnvironmentObject - private var viewModel: ItemViewModel - @State - private var playButtonText: String = "" + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @EnvironmentObject + private var viewModel: ItemViewModel + @State + private var playButtonText: String = "" - var body: some View { - VStack(alignment: .leading) { - HStack(alignment: .bottom, spacing: 12) { + var body: some View { + VStack(alignment: .leading) { + HStack(alignment: .bottom, spacing: 12) { - // MARK: Portrait Image + // MARK: Portrait Image - ImageView(viewModel.item.portraitHeaderViewURL(maxWidth: 130), - blurHash: viewModel.item.getPrimaryImageBlurHash()) - .portraitPoster(width: 130) - .accessibilityIgnoresInvertColors() + ImageView( + viewModel.item.portraitHeaderViewURL(maxWidth: 130), + blurHash: viewModel.item.getPrimaryImageBlurHash() + ) + .portraitPoster(width: 130) + .accessibilityIgnoresInvertColors() - VStack(alignment: .leading, spacing: 1) { - Spacer() + VStack(alignment: .leading, spacing: 1) { + Spacer() - // MARK: Name + // MARK: Name - Text(viewModel.getItemDisplayName()) - .font(.title2) - .fontWeight(.semibold) - .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) - .padding(.bottom, 10) + Text(viewModel.getItemDisplayName()) + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.primary) + .fixedSize(horizontal: false, vertical: true) + .padding(.bottom, 10) - // MARK: Details + // MARK: Details - HStack { - if viewModel.item.unaired { - if let premiereDateLabel = viewModel.item.airDateLabel { - Text(premiereDateLabel) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - } + HStack { + if viewModel.item.unaired { + if let premiereDateLabel = viewModel.item.airDateLabel { + Text(premiereDateLabel) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + } - if viewModel.shouldDisplayRuntime() { - if let runtime = viewModel.item.getItemRuntime() { - Text(runtime) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } - } + if viewModel.shouldDisplayRuntime() { + if let runtime = viewModel.item.getItemRuntime() { + Text(runtime) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } + } - if let productionYear = viewModel.item.productionYear { - Text(String(productionYear)) - .font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - } + if let productionYear = viewModel.item.productionYear { + Text(String(productionYear)) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + } - if let officialRating = viewModel.item.officialRating { - Text(officialRating) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay(RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1)) - } - } + if let officialRating = viewModel.item.officialRating { + Text(officialRating) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .lineLimit(1) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay( + RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1) + ) + } + } - if viewModel.videoPlayerViewModels.count > 1 { - Menu { - ForEach(viewModel.videoPlayerViewModels, id: \.versionName) { viewModelOption in - Button { - viewModel.selectedVideoPlayerViewModel = viewModelOption - } label: { - if viewModelOption.versionName == viewModel.selectedVideoPlayerViewModel?.versionName { - Label(viewModelOption.versionName ?? L10n.noTitle, systemImage: "checkmark") - } else { - Text(viewModelOption.versionName ?? L10n.noTitle) - } - } - } - } label: { - HStack(spacing: 5) { - Text(viewModel.selectedVideoPlayerViewModel?.versionName ?? L10n.noTitle) - .fontWeight(.semibold) - .fixedSize() - Image(systemName: "chevron.down") - } - } - } - } - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 98 : 30) - } + if viewModel.videoPlayerViewModels.count > 1 { + Menu { + ForEach(viewModel.videoPlayerViewModels, id: \.versionName) { viewModelOption in + Button { + viewModel.selectedVideoPlayerViewModel = viewModelOption + } label: { + if viewModelOption.versionName == viewModel.selectedVideoPlayerViewModel?.versionName { + Label(viewModelOption.versionName ?? L10n.noTitle, systemImage: "checkmark") + } else { + Text(viewModelOption.versionName ?? L10n.noTitle) + } + } + } + } label: { + HStack(spacing: 5) { + Text(viewModel.selectedVideoPlayerViewModel?.versionName ?? L10n.noTitle) + .fontWeight(.semibold) + .fixedSize() + Image(systemName: "chevron.down") + } + } + } + } + .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 98 : 30) + } - HStack { + HStack { - // MARK: Play + // MARK: Play - Button { - if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { - itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) - } else { - LogManager.log.error("Attempted to play item but no playback information available") - } - } label: { - HStack { - Image(systemName: "play.fill") - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) - .font(.system(size: 20)) - Text(playButtonText) - .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) - .font(.callout) - .fontWeight(.semibold) - } - .frame(width: 130, height: 40) - .background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple) - .cornerRadius(10) - } - .disabled(viewModel.playButtonItem == nil || viewModel.selectedVideoPlayerViewModel == nil) - .contextMenu { - if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 { - Button { - if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { - selectedVideoPlayerViewModel.injectCustomValues(startFromBeginning: true) - itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) - } else { - LogManager.log.error("Attempted to play item but no playback information available") - } - } label: { - Label(L10n.playFromBeginning, systemImage: "gobackward") - } - } - } + Button { + if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { + itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) + } else { + LogManager.log.error("Attempted to play item but no playback information available") + } + } label: { + HStack { + Image(systemName: "play.fill") + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) + .font(.system(size: 20)) + Text(playButtonText) + .foregroundColor(viewModel.playButtonItem == nil ? Color(UIColor.secondaryLabel) : Color.white) + .font(.callout) + .fontWeight(.semibold) + } + .frame(width: 130, height: 40) + .background(viewModel.playButtonItem == nil ? Color(UIColor.secondarySystemFill) : Color.jellyfinPurple) + .cornerRadius(10) + } + .disabled(viewModel.playButtonItem == nil || viewModel.selectedVideoPlayerViewModel == nil) + .contextMenu { + if viewModel.playButtonItem != nil, viewModel.item.userData?.playbackPositionTicks ?? 0 > 0 { + Button { + if let selectedVideoPlayerViewModel = viewModel.selectedVideoPlayerViewModel { + selectedVideoPlayerViewModel.injectCustomValues(startFromBeginning: true) + itemRouter.route(to: \.videoPlayer, selectedVideoPlayerViewModel) + } else { + LogManager.log.error("Attempted to play item but no playback information available") + } + } label: { + Label(L10n.playFromBeginning, systemImage: "gobackward") + } + } + } - Spacer() + Spacer() - if viewModel.item.itemType.showDetails { - // MARK: Favorite + if viewModel.item.itemType.showDetails { + // MARK: Favorite - Button { - viewModel.updateFavoriteState() - } label: { - if viewModel.isFavorited { - Image(systemName: "heart.fill") - .foregroundColor(Color(UIColor.systemRed)) - .font(.system(size: 20)) - } else { - Image(systemName: "heart") - .foregroundColor(Color.primary) - .font(.system(size: 20)) - } - } - .disabled(viewModel.isLoading) + Button { + viewModel.updateFavoriteState() + } label: { + if viewModel.isFavorited { + Image(systemName: "heart.fill") + .foregroundColor(Color(UIColor.systemRed)) + .font(.system(size: 20)) + } else { + Image(systemName: "heart") + .foregroundColor(Color.primary) + .font(.system(size: 20)) + } + } + .disabled(viewModel.isLoading) - // MARK: Watched + // MARK: Watched - Button { - viewModel.updateWatchState() - } label: { - if viewModel.isWatched { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(Color.jellyfinPurple) - .font(.system(size: 20)) - } else { - Image(systemName: "checkmark.circle") - .foregroundColor(Color.primary) - .font(.system(size: 20)) - } - } - .disabled(viewModel.isLoading) - } - }.padding(.top, 8) - } - .onAppear { - playButtonText = viewModel.playButtonText() - } - .padding(.horizontal) - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? -189 : -64) - } + Button { + viewModel.updateWatchState() + } label: { + if viewModel.isWatched { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color.jellyfinPurple) + .font(.system(size: 20)) + } else { + Image(systemName: "checkmark.circle") + .foregroundColor(Color.primary) + .font(.system(size: 20)) + } + } + .disabled(viewModel.isLoading) + } + }.padding(.top, 8) + } + .onAppear { + playButtonText = viewModel.playButtonText() + } + .padding(.horizontal) + .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? -189 : -64) + } } diff --git a/Swiftfin/Views/ItemView/Portrait/ItemPortraitMainView.swift b/Swiftfin/Views/ItemView/Portrait/ItemPortraitMainView.swift index da47995c..dee7b781 100644 --- a/Swiftfin/Views/ItemView/Portrait/ItemPortraitMainView.swift +++ b/Swiftfin/Views/ItemView/Portrait/ItemPortraitMainView.swift @@ -11,46 +11,50 @@ import SwiftUI struct ItemPortraitMainView: View { - @EnvironmentObject - var itemRouter: ItemCoordinator.Router - @EnvironmentObject - private var viewModel: ItemViewModel + @EnvironmentObject + var itemRouter: ItemCoordinator.Router + @EnvironmentObject + private var viewModel: ItemViewModel - // MARK: portraitHeaderView + // MARK: portraitHeaderView - var portraitHeaderView: some View { - ImageView(viewModel.item.getBackdropImage(maxWidth: Int(UIScreen.main.bounds.width)), - blurHash: viewModel.item.getBackdropImageBlurHash()) - .opacity(0.4) - .blur(radius: 2.0) - .accessibilityIgnoresInvertColors() - } + var portraitHeaderView: some View { + ImageView( + viewModel.item.getBackdropImage(maxWidth: Int(UIScreen.main.bounds.width)), + blurHash: viewModel.item.getBackdropImageBlurHash() + ) + .opacity(0.4) + .blur(radius: 2.0) + .accessibilityIgnoresInvertColors() + } - // MARK: portraitStaticOverlayView + // MARK: portraitStaticOverlayView - var portraitStaticOverlayView: some View { - PortraitHeaderOverlayView() - .environmentObject(viewModel) - } + var portraitStaticOverlayView: some View { + PortraitHeaderOverlayView() + .environmentObject(viewModel) + } - // MARK: body + // MARK: body - var body: some View { - VStack(alignment: .leading) { - // MARK: ParallaxScrollView + var body: some View { + VStack(alignment: .leading) { + // MARK: ParallaxScrollView - ParallaxHeaderScrollView(header: portraitHeaderView, - staticOverlayView: portraitStaticOverlayView, - overlayAlignment: .bottomLeading, - headerHeight: UIScreen.main.bounds.width * 0.5625) { - VStack { - Spacer() - .frame(height: 70) + ParallaxHeaderScrollView( + header: portraitHeaderView, + staticOverlayView: portraitStaticOverlayView, + overlayAlignment: .bottomLeading, + headerHeight: UIScreen.main.bounds.width * 0.5625 + ) { + VStack { + Spacer() + .frame(height: 70) - ItemViewBody() - .environmentObject(viewModel) - } - } - } - } + ItemViewBody() + .environmentObject(viewModel) + } + } + } + } } diff --git a/Swiftfin/Views/LatestMediaView.swift b/Swiftfin/Views/LatestMediaView.swift index 5c1be150..d76658b4 100644 --- a/Swiftfin/Views/LatestMediaView.swift +++ b/Swiftfin/Views/LatestMediaView.swift @@ -11,18 +11,20 @@ import SwiftUI struct LatestMediaView: View { - @EnvironmentObject - var homeRouter: HomeCoordinator.Router - @StateObject - var viewModel: LatestMediaViewModel - var topBarView: () -> TopBarView + @EnvironmentObject + var homeRouter: HomeCoordinator.Router + @StateObject + var viewModel: LatestMediaViewModel + var topBarView: () -> TopBarView - var body: some View { - PortraitImageHStackView(items: viewModel.items, - horizontalAlignment: .leading) { - topBarView() - } selectedAction: { item in - homeRouter.route(to: \.item, item) - } - } + var body: some View { + PortraitImageHStackView( + items: viewModel.items, + horizontalAlignment: .leading + ) { + topBarView() + } selectedAction: { item in + homeRouter.route(to: \.item, item) + } + } } diff --git a/Swiftfin/Views/LibraryFilterView.swift b/Swiftfin/Views/LibraryFilterView.swift index 280ee180..9ac7c584 100644 --- a/Swiftfin/Views/LibraryFilterView.swift +++ b/Swiftfin/Views/LibraryFilterView.swift @@ -12,88 +12,94 @@ import SwiftUI struct LibraryFilterView: View { - @EnvironmentObject - var filterRouter: FilterCoordinator.Router - @Binding - var filters: LibraryFilters - var parentId: String = "" + @EnvironmentObject + var filterRouter: FilterCoordinator.Router + @Binding + var filters: LibraryFilters + var parentId: String = "" - @StateObject - var viewModel: LibraryFilterViewModel + @StateObject + var viewModel: LibraryFilterViewModel - init(filters: Binding, enabledFilterType: [FilterType], parentId: String) { - _filters = filters - self.parentId = parentId - _viewModel = - StateObject(wrappedValue: .init(filters: filters.wrappedValue, enabledFilterType: enabledFilterType, parentId: parentId)) - } + init(filters: Binding, enabledFilterType: [FilterType], parentId: String) { + _filters = filters + self.parentId = parentId + _viewModel = + StateObject(wrappedValue: .init(filters: filters.wrappedValue, enabledFilterType: enabledFilterType, parentId: parentId)) + } - var body: some View { - VStack { - if viewModel.isLoading { - ProgressView() - } else { - Form { - if viewModel.enabledFilterType.contains(.genre) { - MultiSelector(label: L10n.genres, - options: viewModel.possibleGenres, - optionToString: { $0.name ?? "" }, - selected: $viewModel.modifiedFilters.withGenres) - } - if viewModel.enabledFilterType.contains(.filter) { - MultiSelector(label: L10n.filters, - options: viewModel.possibleItemFilters, - optionToString: { $0.localized }, - selected: $viewModel.modifiedFilters.filters) - } - if viewModel.enabledFilterType.contains(.tag) { - MultiSelector(label: L10n.tags, - options: viewModel.possibleTags, - optionToString: { $0 }, - selected: $viewModel.modifiedFilters.tags) - } - if viewModel.enabledFilterType.contains(.sortBy) { - Picker(selection: $viewModel.selectedSortBy, label: L10n.sortBy.text) { - ForEach(viewModel.possibleSortBys, id: \.self) { so in - Text(so.localized).tag(so) - } - } - } - if viewModel.enabledFilterType.contains(.sortOrder) { - Picker(selection: $viewModel.selectedSortOrder, label: L10n.displayOrder.text) { - ForEach(viewModel.possibleSortOrders, id: \.self) { so in - Text(so.rawValue).tag(so) - } - } - } - } - Button { - viewModel.resetFilters() - self.filters = viewModel.modifiedFilters - filterRouter.dismissCoordinator() - } label: { - L10n.reset.text - } - } - } - .navigationBarTitle(L10n.filterResults, displayMode: .inline) - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - Button { - filterRouter.dismissCoordinator() - } label: { - Image(systemName: "xmark") - } - } - ToolbarItemGroup(placement: .navigationBarTrailing) { - Button { - viewModel.updateModifiedFilter() - self.filters = viewModel.modifiedFilters - filterRouter.dismissCoordinator() - } label: { - L10n.apply.text - } - } - } - } + var body: some View { + VStack { + if viewModel.isLoading { + ProgressView() + } else { + Form { + if viewModel.enabledFilterType.contains(.genre) { + MultiSelector( + label: L10n.genres, + options: viewModel.possibleGenres, + optionToString: { $0.name ?? "" }, + selected: $viewModel.modifiedFilters.withGenres + ) + } + if viewModel.enabledFilterType.contains(.filter) { + MultiSelector( + label: L10n.filters, + options: viewModel.possibleItemFilters, + optionToString: { $0.localized }, + selected: $viewModel.modifiedFilters.filters + ) + } + if viewModel.enabledFilterType.contains(.tag) { + MultiSelector( + label: L10n.tags, + options: viewModel.possibleTags, + optionToString: { $0 }, + selected: $viewModel.modifiedFilters.tags + ) + } + if viewModel.enabledFilterType.contains(.sortBy) { + Picker(selection: $viewModel.selectedSortBy, label: L10n.sortBy.text) { + ForEach(viewModel.possibleSortBys, id: \.self) { so in + Text(so.localized).tag(so) + } + } + } + if viewModel.enabledFilterType.contains(.sortOrder) { + Picker(selection: $viewModel.selectedSortOrder, label: L10n.displayOrder.text) { + ForEach(viewModel.possibleSortOrders, id: \.self) { so in + Text(so.rawValue).tag(so) + } + } + } + } + Button { + viewModel.resetFilters() + self.filters = viewModel.modifiedFilters + filterRouter.dismissCoordinator() + } label: { + L10n.reset.text + } + } + } + .navigationBarTitle(L10n.filterResults, displayMode: .inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarLeading) { + Button { + filterRouter.dismissCoordinator() + } label: { + Image(systemName: "xmark") + } + } + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + viewModel.updateModifiedFilter() + self.filters = viewModel.modifiedFilters + filterRouter.dismissCoordinator() + } label: { + L10n.apply.text + } + } + } + } } diff --git a/Swiftfin/Views/LibraryListView.swift b/Swiftfin/Views/LibraryListView.swift index 06ae5a5f..26f22e89 100644 --- a/Swiftfin/Views/LibraryListView.swift +++ b/Swiftfin/Views/LibraryListView.swift @@ -13,101 +13,107 @@ import Stinsen import SwiftUI struct LibraryListView: View { - @EnvironmentObject - var libraryListRouter: LibraryListCoordinator.Router - @StateObject - var viewModel = LibraryListViewModel() + @EnvironmentObject + var libraryListRouter: LibraryListCoordinator.Router + @StateObject + var viewModel = LibraryListViewModel() - @Default(.Experimental.liveTVAlphaEnabled) - var liveTVAlphaEnabled + @Default(.Experimental.liveTVAlphaEnabled) + var liveTVAlphaEnabled - var supportedCollectionTypes: [BaseItemDto.ItemType] { - if liveTVAlphaEnabled { - return [.movie, .season, .series, .liveTV, .boxset, .unknown] - } else { - return [.movie, .season, .series, .boxset, .unknown] - } - } + var supportedCollectionTypes: [BaseItemDto.ItemType] { + if liveTVAlphaEnabled { + return [.movie, .season, .series, .liveTV, .boxset, .unknown] + } else { + return [.movie, .season, .series, .boxset, .unknown] + } + } - var body: some View { - ScrollView { - LazyVStack { - Button { - libraryListRouter.route(to: \.library, - (viewModel: LibraryViewModel(filters: viewModel.withFavorites), title: L10n.favorites)) - } label: { - ZStack { - HStack { - Spacer() - L10n.yourFavorites.text - .foregroundColor(.black) - .font(.subheadline) - .fontWeight(.semibold) - Spacer() - } - } - .padding(16) - .background(Color.white) - .frame(minWidth: 100, maxWidth: .infinity) - } - .cornerRadius(10) - .shadow(radius: 5) - .padding(.bottom, 5) + var body: some View { + ScrollView { + LazyVStack { + Button { + libraryListRouter.route( + to: \.library, + (viewModel: LibraryViewModel(filters: viewModel.withFavorites), title: L10n.favorites) + ) + } label: { + ZStack { + HStack { + Spacer() + L10n.yourFavorites.text + .foregroundColor(.black) + .font(.subheadline) + .fontWeight(.semibold) + Spacer() + } + } + .padding(16) + .background(Color.white) + .frame(minWidth: 100, maxWidth: .infinity) + } + .cornerRadius(10) + .shadow(radius: 5) + .padding(.bottom, 5) - if !viewModel.isLoading { - ForEach(viewModel.libraries.filter { [self] library in - let collectionType = library.collectionType ?? "other" - let itemType = BaseItemDto.ItemType(rawValue: collectionType) ?? .unknown - return self.supportedCollectionTypes.contains(itemType) - }, id: \.id) { library in - Button { - let itemType = BaseItemDto.ItemType(rawValue: library.collectionType ?? "other") ?? .unknown - if itemType == .liveTV { - libraryListRouter.route(to: \.liveTV) - } else { - libraryListRouter.route(to: \.library, - (viewModel: LibraryViewModel(parentID: library.id), - title: library.name ?? "")) - } - } label: { - ZStack { - ImageView(library.getPrimaryImage(maxWidth: 500), blurHash: library.getPrimaryImageBlurHash()) - .opacity(0.4) - .accessibilityIgnoresInvertColors() - HStack { - Spacer() - VStack { - Text(library.name ?? "") - .foregroundColor(.white) - .font(.title2) - .fontWeight(.semibold) - } - Spacer() - }.padding(32) - }.background(Color.black) - .frame(minWidth: 100, maxWidth: .infinity) - .frame(height: 100) - } - .cornerRadius(10) - .shadow(radius: 5) - .padding(.bottom, 5) - } - } else { - ProgressView() - } - }.padding(.leading, 16) - .padding(.trailing, 16) - .padding(.top, 8) - } - .navigationTitle(L10n.allMedia) - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - Button { - libraryListRouter.route(to: \.search, LibrarySearchViewModel(parentID: nil)) - } label: { - Image(systemName: "magnifyingglass") - } - } - } - } + if !viewModel.isLoading { + ForEach(viewModel.libraries.filter { [self] library in + let collectionType = library.collectionType ?? "other" + let itemType = BaseItemDto.ItemType(rawValue: collectionType) ?? .unknown + return self.supportedCollectionTypes.contains(itemType) + }, id: \.id) { library in + Button { + let itemType = BaseItemDto.ItemType(rawValue: library.collectionType ?? "other") ?? .unknown + if itemType == .liveTV { + libraryListRouter.route(to: \.liveTV) + } else { + libraryListRouter.route( + to: \.library, + ( + viewModel: LibraryViewModel(parentID: library.id), + title: library.name ?? "" + ) + ) + } + } label: { + ZStack { + ImageView(library.getPrimaryImage(maxWidth: 500), blurHash: library.getPrimaryImageBlurHash()) + .opacity(0.4) + .accessibilityIgnoresInvertColors() + HStack { + Spacer() + VStack { + Text(library.name ?? "") + .foregroundColor(.white) + .font(.title2) + .fontWeight(.semibold) + } + Spacer() + }.padding(32) + }.background(Color.black) + .frame(minWidth: 100, maxWidth: .infinity) + .frame(height: 100) + } + .cornerRadius(10) + .shadow(radius: 5) + .padding(.bottom, 5) + } + } else { + ProgressView() + } + }.padding(.leading, 16) + .padding(.trailing, 16) + .padding(.top, 8) + } + .navigationTitle(L10n.allMedia) + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + libraryListRouter.route(to: \.search, LibrarySearchViewModel(parentID: nil)) + } label: { + Image(systemName: "magnifyingglass") + } + } + } + } } diff --git a/Swiftfin/Views/LibrarySearchView.swift b/Swiftfin/Views/LibrarySearchView.swift index 6784b209..a82f7214 100644 --- a/Swiftfin/Views/LibrarySearchView.swift +++ b/Swiftfin/Views/LibrarySearchView.swift @@ -12,102 +12,102 @@ import Stinsen import SwiftUI struct LibrarySearchView: View { - @EnvironmentObject - var searchRouter: SearchCoordinator.Router - @StateObject - var viewModel: LibrarySearchViewModel - @State - private var searchQuery = "" + @EnvironmentObject + var searchRouter: SearchCoordinator.Router + @StateObject + var viewModel: LibrarySearchViewModel + @State + private var searchQuery = "" - @State - private var tracks: [GridItem] = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) + @State + private var tracks: [GridItem] = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) - func recalcTracks() { - tracks = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) - } + func recalcTracks() { + tracks = Array(repeating: .init(.flexible()), count: Int(UIScreen.main.bounds.size.width) / 125) + } - var body: some View { - ZStack { - VStack { - SearchBar(text: $searchQuery) - .padding(.top, 16) - .padding(.bottom, 8) - if searchQuery.isEmpty { - suggestionsListView - } else { - resultView - } - } - if viewModel.isLoading { - ProgressView() - } - } - .onChange(of: searchQuery) { query in - viewModel.searchQuerySubject.send(query) - } - .navigationBarTitle(L10n.search, displayMode: .inline) - } + var body: some View { + ZStack { + VStack { + SearchBar(text: $searchQuery) + .padding(.top, 16) + .padding(.bottom, 8) + if searchQuery.isEmpty { + suggestionsListView + } else { + resultView + } + } + if viewModel.isLoading { + ProgressView() + } + } + .onChange(of: searchQuery) { query in + viewModel.searchQuerySubject.send(query) + } + .navigationBarTitle(L10n.search, displayMode: .inline) + } - var suggestionsListView: some View { - ScrollView { - LazyVStack(spacing: 8) { - L10n.suggestions.text - .font(.headline) - .fontWeight(.bold) - .foregroundColor(.primary) - .padding(.bottom, 8) - ForEach(viewModel.suggestions, id: \.id) { item in - Button { - searchQuery = item.name ?? "" - } label: { - Text(item.name ?? "") - .font(.body) - } - } - } - .padding(.horizontal, 16) - } - } + var suggestionsListView: some View { + ScrollView { + LazyVStack(spacing: 8) { + L10n.suggestions.text + .font(.headline) + .fontWeight(.bold) + .foregroundColor(.primary) + .padding(.bottom, 8) + ForEach(viewModel.suggestions, id: \.id) { item in + Button { + searchQuery = item.name ?? "" + } label: { + Text(item.name ?? "") + .font(.body) + } + } + } + .padding(.horizontal, 16) + } + } - var resultView: some View { - let items = items(for: viewModel.selectedItemType) - return VStack(alignment: .leading, spacing: 16) { - Picker("ItemType", selection: $viewModel.selectedItemType) { - ForEach(viewModel.supportedItemTypeList, id: \.self) { - Text($0.localized) - .tag($0) - } - } - .pickerStyle(SegmentedPickerStyle()) - ScrollView { - LazyVStack(alignment: .leading, spacing: 16) { - if !items.isEmpty { - LazyVGrid(columns: tracks) { - ForEach(items, id: \.id) { item in - PortraitItemButton(item: item) { item in - searchRouter.route(to: \.item, item) - } - } - } - } - } - } - } - .onRotate { _ in - recalcTracks() - } - } + var resultView: some View { + let items = items(for: viewModel.selectedItemType) + return VStack(alignment: .leading, spacing: 16) { + Picker("ItemType", selection: $viewModel.selectedItemType) { + ForEach(viewModel.supportedItemTypeList, id: \.self) { + Text($0.localized) + .tag($0) + } + } + .pickerStyle(SegmentedPickerStyle()) + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + if !items.isEmpty { + LazyVGrid(columns: tracks) { + ForEach(items, id: \.id) { item in + PortraitItemButton(item: item) { item in + searchRouter.route(to: \.item, item) + } + } + } + } + } + } + } + .onRotate { _ in + recalcTracks() + } + } - func items(for type: ItemType) -> [BaseItemDto] { - switch type { - case .episode: - return viewModel.episodeItems - case .movie: - return viewModel.movieItems - case .series: - return viewModel.showItems - default: - return [] - } - } + func items(for type: ItemType) -> [BaseItemDto] { + switch type { + case .episode: + return viewModel.episodeItems + case .movie: + return viewModel.movieItems + case .series: + return viewModel.showItems + default: + return [] + } + } } diff --git a/Swiftfin/Views/LibraryView.swift b/Swiftfin/Views/LibraryView.swift index 57fa4ede..89337214 100644 --- a/Swiftfin/Views/LibraryView.swift +++ b/Swiftfin/Views/LibraryView.swift @@ -11,90 +11,95 @@ import SwiftUI struct LibraryView: View { - @EnvironmentObject - var libraryRouter: LibraryCoordinator.Router - @StateObject - var viewModel: LibraryViewModel - var title: String + @EnvironmentObject + var libraryRouter: LibraryCoordinator.Router + @StateObject + var viewModel: LibraryViewModel + var title: String - // MARK: tracks for grid + // MARK: tracks for grid - var defaultFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name]) + var defaultFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name]) - @State - private var tracks: [GridItem] = Array(repeating: .init(.flexible(), alignment: .top), - count: Int(UIScreen.main.bounds.size.width) / 125) + @State + private var tracks: [GridItem] = Array( + repeating: .init(.flexible(), alignment: .top), + count: Int(UIScreen.main.bounds.size.width) / 125 + ) - func recalcTracks() { - tracks = Array(repeating: .init(.flexible(), alignment: .top), count: Int(UIScreen.main.bounds.size.width) / 125) - } + func recalcTracks() { + tracks = Array(repeating: .init(.flexible(), alignment: .top), count: Int(UIScreen.main.bounds.size.width) / 125) + } - @ViewBuilder - private var loadingView: some View { - ProgressView() - } + @ViewBuilder + private var loadingView: some View { + ProgressView() + } - @ViewBuilder - private var noResultsView: some View { - L10n.noResults.text - } + @ViewBuilder + private var noResultsView: some View { + L10n.noResults.text + } - @ViewBuilder - private var libraryItemsView: some View { - DetectBottomScrollView { - VStack { - LazyVGrid(columns: tracks) { - ForEach(viewModel.items, id: \.id) { item in - PortraitItemButton(item: item) { item in - libraryRouter.route(to: \.item, item) - } - } - } - .ignoresSafeArea() - .listRowSeparator(.hidden) - .onRotate { _ in - recalcTracks() - } + @ViewBuilder + private var libraryItemsView: some View { + DetectBottomScrollView { + VStack { + LazyVGrid(columns: tracks) { + ForEach(viewModel.items, id: \.id) { item in + PortraitItemButton(item: item) { item in + libraryRouter.route(to: \.item, item) + } + } + } + .ignoresSafeArea() + .listRowSeparator(.hidden) + .onRotate { _ in + recalcTracks() + } - Spacer() - .frame(height: 30) - } - } didReachBottom: { newValue in - if newValue && viewModel.hasNextPage { - viewModel.requestNextPageAsync() - } - } - } + Spacer() + .frame(height: 30) + } + } didReachBottom: { newValue in + if newValue && viewModel.hasNextPage { + viewModel.requestNextPageAsync() + } + } + } - var body: some View { - Group { - if viewModel.isLoading && viewModel.items.isEmpty { - ProgressView() - } else if !viewModel.items.isEmpty { - libraryItemsView - } else { - noResultsView - } - } - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { + var body: some View { + Group { + if viewModel.isLoading && viewModel.items.isEmpty { + ProgressView() + } else if !viewModel.items.isEmpty { + libraryItemsView + } else { + noResultsView + } + } + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { - Button { - libraryRouter - .route(to: \.filter, (filters: $viewModel.filters, enabledFilterType: viewModel.enabledFilterType, - parentId: viewModel.parentID ?? "")) - } label: { - Image(systemName: "line.horizontal.3.decrease.circle") - } - .foregroundColor(viewModel.filters == defaultFilters ? .accentColor : Color(UIColor.systemOrange)) + Button { + libraryRouter + .route(to: \.filter, ( + filters: $viewModel.filters, + enabledFilterType: viewModel.enabledFilterType, + parentId: viewModel.parentID ?? "" + )) + } label: { + Image(systemName: "line.horizontal.3.decrease.circle") + } + .foregroundColor(viewModel.filters == defaultFilters ? .accentColor : Color(UIColor.systemOrange)) - Button { - libraryRouter.route(to: \.search, .init(parentID: viewModel.parentID)) - } label: { - Image(systemName: "magnifyingglass") - } - } - } - } + Button { + libraryRouter.route(to: \.search, .init(parentID: viewModel.parentID)) + } label: { + Image(systemName: "magnifyingglass") + } + } + } + } } diff --git a/Swiftfin/Views/LiveTVChannelItemElement.swift b/Swiftfin/Views/LiveTVChannelItemElement.swift index 1cc2fa53..941d63be 100644 --- a/Swiftfin/Views/LiveTVChannelItemElement.swift +++ b/Swiftfin/Views/LiveTVChannelItemElement.swift @@ -10,113 +10,115 @@ import JellyfinAPI import SwiftUI struct LiveTVChannelItemElement: View { - @FocusState - private var focused: Bool - @State - private var loading: Bool = false - @State - private var isFocused: Bool = false + @FocusState + private var focused: Bool + @State + private var loading: Bool = false + @State + private var isFocused: Bool = false - var channel: BaseItemDto - var program: BaseItemDto? - var startString = " " - var endString = " " - var progressPercent = Double(0) - var onSelect: (@escaping (Bool) -> Void) -> Void + var channel: BaseItemDto + var program: BaseItemDto? + var startString = " " + var endString = " " + var progressPercent = Double(0) + var onSelect: (@escaping (Bool) -> Void) -> Void - private var detailText: String { - guard let program = program else { - return "" - } - var text = "" - if let season = program.parentIndexNumber, - let episode = program.indexNumber - { - text.append("\(season)x\(episode) ") - } else if let episode = program.indexNumber { - text.append("\(episode) ") - } - if let title = program.episodeTitle { - text.append("\(title) ") - } - if let year = program.productionYear { - text.append("\(year) ") - } - if let rating = program.officialRating { - text.append("\(rating)") - } - return text - } + private var detailText: String { + guard let program = program else { + return "" + } + var text = "" + if let season = program.parentIndexNumber, + let episode = program.indexNumber + { + text.append("\(season)x\(episode) ") + } else if let episode = program.indexNumber { + text.append("\(episode) ") + } + if let title = program.episodeTitle { + text.append("\(title) ") + } + if let year = program.productionYear { + text.append("\(year) ") + } + if let rating = program.officialRating { + text.append("\(rating)") + } + return text + } - var body: some View { - ZStack { - VStack { - HStack { - Text(channel.number ?? "") - .font(.footnote) - .frame(alignment: .leading) - .padding() - Spacer() - }.frame(alignment: .top) - Spacer() - } - VStack { - ImageView(channel.getPrimaryImage(maxWidth: 128)) - .aspectRatio(contentMode: .fit) - .frame(width: 128, alignment: .center) - .padding(.init(top: 8, leading: 0, bottom: 0, trailing: 0)) - Text(channel.name ?? "?") - .font(.footnote) - .lineLimit(1) - .frame(alignment: .center) - Text(program?.name ?? L10n.notAvailableSlash) - .font(.body) - .lineLimit(1) - .foregroundColor(.green) - Text(detailText) - .font(.body) - .lineLimit(1) - .foregroundColor(.green) - Spacer() - HStack(alignment: .bottom) { - VStack { - Spacer() - HStack { - Text(startString) - .font(.footnote) - .lineLimit(1) - .frame(alignment: .leading) + var body: some View { + ZStack { + VStack { + HStack { + Text(channel.number ?? "") + .font(.footnote) + .frame(alignment: .leading) + .padding() + Spacer() + }.frame(alignment: .top) + Spacer() + } + VStack { + ImageView(channel.getPrimaryImage(maxWidth: 128)) + .aspectRatio(contentMode: .fit) + .frame(width: 128, alignment: .center) + .padding(.init(top: 8, leading: 0, bottom: 0, trailing: 0)) + Text(channel.name ?? "?") + .font(.footnote) + .lineLimit(1) + .frame(alignment: .center) + Text(program?.name ?? L10n.notAvailableSlash) + .font(.body) + .lineLimit(1) + .foregroundColor(.green) + Text(detailText) + .font(.body) + .lineLimit(1) + .foregroundColor(.green) + Spacer() + HStack(alignment: .bottom) { + VStack { + Spacer() + HStack { + Text(startString) + .font(.footnote) + .lineLimit(1) + .frame(alignment: .leading) - Spacer() + Spacer() - Text(endString) - .font(.footnote) - .lineLimit(1) - .frame(alignment: .trailing) - } - GeometryReader { gp in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 6) - .fill(Color.gray) - .opacity(0.4) - .frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12) - RoundedRectangle(cornerRadius: 6) - .fill(Color.jellyfinPurple) - .frame(width: CGFloat(progressPercent * gp.size.width), height: 12) - } - .frame(alignment: .bottom) - } - } - } - } - .padding() - .opacity(loading ? 0.5 : 1.0) + Text(endString) + .font(.footnote) + .lineLimit(1) + .frame(alignment: .trailing) + } + GeometryReader { gp in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 6) + .fill(Color.gray) + .opacity(0.4) + .frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12) + RoundedRectangle(cornerRadius: 6) + .fill(Color.jellyfinPurple) + .frame(width: CGFloat(progressPercent * gp.size.width), height: 12) + } + .frame(alignment: .bottom) + } + } + } + } + .padding() + .opacity(loading ? 0.5 : 1.0) - if loading { - ProgressView() - } - } - .overlay(RoundedRectangle(cornerRadius: 0) - .stroke(Color.blue, lineWidth: 0)) - } + if loading { + ProgressView() + } + } + .overlay( + RoundedRectangle(cornerRadius: 0) + .stroke(Color.blue, lineWidth: 0) + ) + } } diff --git a/Swiftfin/Views/LiveTVChannelItemWideElement.swift b/Swiftfin/Views/LiveTVChannelItemWideElement.swift index da7c021d..4481b361 100644 --- a/Swiftfin/Views/LiveTVChannelItemWideElement.swift +++ b/Swiftfin/Views/LiveTVChannelItemWideElement.swift @@ -10,142 +10,145 @@ import JellyfinAPI import SwiftUI struct LiveTVChannelItemWideElement: View { - @FocusState - private var focused: Bool - @State - private var loading: Bool = false - @State - private var isFocused: Bool = false + @FocusState + private var focused: Bool + @State + private var loading: Bool = false + @State + private var isFocused: Bool = false - var channel: BaseItemDto - var currentProgram: BaseItemDto? - var currentProgramText: LiveTVChannelViewProgram - var nextProgramsText: [LiveTVChannelViewProgram] - var onSelect: (@escaping (Bool) -> Void) -> Void + var channel: BaseItemDto + var currentProgram: BaseItemDto? + var currentProgramText: LiveTVChannelViewProgram + var nextProgramsText: [LiveTVChannelViewProgram] + var onSelect: (@escaping (Bool) -> Void) -> Void - var progressPercent: Double { - if let currentProgram = currentProgram { - let progressPercent = currentProgram.getLiveProgressPercentage() - if progressPercent > 1.0 { - return 1.0 - } else { - return progressPercent - } - } - return 0 - } + var progressPercent: Double { + if let currentProgram = currentProgram { + let progressPercent = currentProgram.getLiveProgressPercentage() + if progressPercent > 1.0 { + return 1.0 + } else { + return progressPercent + } + } + return 0 + } - private var detailText: String { - guard let program = currentProgram else { - return "" - } - var text = "" - if let season = program.parentIndexNumber, - let episode = program.indexNumber - { - text.append("\(season)x\(episode) ") - } else if let episode = program.indexNumber { - text.append("\(episode) ") - } - if let title = program.episodeTitle { - text.append("\(title) ") - } - if let year = program.productionYear { - text.append("\(year) ") - } - if let rating = program.officialRating { - text.append("\(rating)") - } - return text - } + private var detailText: String { + guard let program = currentProgram else { + return "" + } + var text = "" + if let season = program.parentIndexNumber, + let episode = program.indexNumber + { + text.append("\(season)x\(episode) ") + } else if let episode = program.indexNumber { + text.append("\(episode) ") + } + if let title = program.episodeTitle { + text.append("\(title) ") + } + if let year = program.productionYear { + text.append("\(year) ") + } + if let rating = program.officialRating { + text.append("\(rating)") + } + return text + } - var body: some View { - ZStack { - ZStack { - HStack { - ZStack(alignment: .center) { - ImageView(channel.getPrimaryImage(maxWidth: 128)) - .aspectRatio(contentMode: .fit) - .padding(.init(top: 0, leading: 0, bottom: 8, trailing: 0)) - VStack(alignment: .center) { - Spacer() - .frame(maxHeight: .infinity) - GeometryReader { gp in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 3) - .fill(Color.gray) - .opacity(0.4) - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 6, maxHeight: 6) - RoundedRectangle(cornerRadius: 6) - .fill(Color.jellyfinPurple) - .frame(width: CGFloat(progressPercent * gp.size.width), height: 6) - } - } - .frame(height: 6, alignment: .center) - .padding(.init(top: 0, leading: 4, bottom: 0, trailing: 4)) - } - if loading { + var body: some View { + ZStack { + ZStack { + HStack { + ZStack(alignment: .center) { + ImageView(channel.getPrimaryImage(maxWidth: 128)) + .aspectRatio(contentMode: .fit) + .padding(.init(top: 0, leading: 0, bottom: 8, trailing: 0)) + VStack(alignment: .center) { + Spacer() + .frame(maxHeight: .infinity) + GeometryReader { gp in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 3) + .fill(Color.gray) + .opacity(0.4) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 6, maxHeight: 6) + RoundedRectangle(cornerRadius: 6) + .fill(Color.jellyfinPurple) + .frame(width: CGFloat(progressPercent * gp.size.width), height: 6) + } + } + .frame(height: 6, alignment: .center) + .padding(.init(top: 0, leading: 4, bottom: 0, trailing: 4)) + } + if loading { - ProgressView() - } - } - .aspectRatio(1.0, contentMode: .fit) - VStack(alignment: .leading) { - let channelNumber = channel.number != nil ? "\(channel.number ?? "") " : "" - let channelName = "\(channelNumber)\(channel.name ?? "?")" - Text(channelName) - .font(.body) - .lineLimit(1) - .foregroundColor(Color.jellyfinPurple) - .frame(alignment: .leading) - .padding(.init(top: 0, leading: 0, bottom: 4, trailing: 0)) - programLabel(timeText: currentProgramText.timeDisplay, titleText: currentProgramText.title, - color: Color("TextHighlightColor")) - if !nextProgramsText.isEmpty, - let nextItem = nextProgramsText[0] - { - programLabel(timeText: nextItem.timeDisplay, titleText: nextItem.title, color: Color.gray) - } - if nextProgramsText.count > 1, - let nextItem2 = nextProgramsText[1] - { - programLabel(timeText: nextItem2.timeDisplay, titleText: nextItem2.title, color: Color.gray) - } - Spacer() - } - Spacer() - } - .frame(alignment: .leading) - .padding() - .opacity(loading ? 0.5 : 1.0) - } - .background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color("BackgroundSecondaryColor"))) - .frame(height: 128) - .onTapGesture { - onSelect { loadingState in - loading = loadingState - } - } - } - .background { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color("BackgroundColor")) - .shadow(color: Color("ShadowColor"), radius: 4, x: 0, y: 0) - } - } + ProgressView() + } + } + .aspectRatio(1.0, contentMode: .fit) + VStack(alignment: .leading) { + let channelNumber = channel.number != nil ? "\(channel.number ?? "") " : "" + let channelName = "\(channelNumber)\(channel.name ?? "?")" + Text(channelName) + .font(.body) + .lineLimit(1) + .foregroundColor(Color.jellyfinPurple) + .frame(alignment: .leading) + .padding(.init(top: 0, leading: 0, bottom: 4, trailing: 0)) + programLabel( + timeText: currentProgramText.timeDisplay, + titleText: currentProgramText.title, + color: Color("TextHighlightColor") + ) + if !nextProgramsText.isEmpty, + let nextItem = nextProgramsText[0] + { + programLabel(timeText: nextItem.timeDisplay, titleText: nextItem.title, color: Color.gray) + } + if nextProgramsText.count > 1, + let nextItem2 = nextProgramsText[1] + { + programLabel(timeText: nextItem2.timeDisplay, titleText: nextItem2.title, color: Color.gray) + } + Spacer() + } + Spacer() + } + .frame(alignment: .leading) + .padding() + .opacity(loading ? 0.5 : 1.0) + } + .background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color("BackgroundSecondaryColor"))) + .frame(height: 128) + .onTapGesture { + onSelect { loadingState in + loading = loadingState + } + } + } + .background { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color("BackgroundColor")) + .shadow(color: Color("ShadowColor"), radius: 4, x: 0, y: 0) + } + } - @ViewBuilder - func programLabel(timeText: String, titleText: String, color: Color) -> some View { - HStack(alignment: .top) { - Text(timeText) - .font(.footnote) - .lineLimit(2) - .foregroundColor(color) - .frame(width: 38, alignment: .leading) - Text(titleText) - .font(.footnote) - .lineLimit(2) - .foregroundColor(color) - } - } + @ViewBuilder + func programLabel(timeText: String, titleText: String, color: Color) -> some View { + HStack(alignment: .top) { + Text(timeText) + .font(.footnote) + .lineLimit(2) + .foregroundColor(color) + .frame(width: 38, alignment: .leading) + Text(titleText) + .font(.footnote) + .lineLimit(2) + .foregroundColor(color) + } + } } diff --git a/Swiftfin/Views/LiveTVChannelsView.swift b/Swiftfin/Views/LiveTVChannelsView.swift index f0840687..96164f65 100644 --- a/Swiftfin/Views/LiveTVChannelsView.swift +++ b/Swiftfin/Views/LiveTVChannelsView.swift @@ -15,134 +15,140 @@ import SwiftUICollection typealias LiveTVChannelViewProgram = (timeDisplay: String, title: String) struct LiveTVChannelsView: View { - @EnvironmentObject - var router: LiveTVCoordinator.Router - @StateObject - var viewModel = LiveTVChannelsViewModel() - @State - private var isPortrait = false - private var columns: Int { - if UIDevice.current.userInterfaceIdiom == .pad { - return 2 - } else { - if isPortrait { - return 1 - } else { - return 2 - } - } - } + @EnvironmentObject + var router: LiveTVCoordinator.Router + @StateObject + var viewModel = LiveTVChannelsViewModel() + @State + private var isPortrait = false + private var columns: Int { + if UIDevice.current.userInterfaceIdiom == .pad { + return 2 + } else { + if isPortrait { + return 1 + } else { + return 2 + } + } + } - var body: some View { - if viewModel.isLoading == true { - ProgressView() - } else if !viewModel.channelPrograms.isEmpty { - ASCollectionView(data: viewModel.channelPrograms, dataID: \.self) { channelProgram, _ in - makeCellView(channelProgram) - } - .layout { - .grid(layoutMode: .fixedNumberOfColumns(columns), - itemSpacing: 16, - lineSpacing: 4, - itemSize: .absolute(144)) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .ignoresSafeArea() - .onAppear { - viewModel.startScheduleCheckTimer() - self.checkOrientation() - } - .onDisappear { - viewModel.stopScheduleCheckTimer() - } - .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in - self.checkOrientation() - } - } else { - VStack { - Text("No results.") - Button { - viewModel.getChannels() - } label: { - Text("Reload") - } - } - } - } + var body: some View { + if viewModel.isLoading == true { + ProgressView() + } else if !viewModel.channelPrograms.isEmpty { + ASCollectionView(data: viewModel.channelPrograms, dataID: \.self) { channelProgram, _ in + makeCellView(channelProgram) + } + .layout { + .grid( + layoutMode: .fixedNumberOfColumns(columns), + itemSpacing: 16, + lineSpacing: 4, + itemSize: .absolute(144) + ) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + .onAppear { + viewModel.startScheduleCheckTimer() + self.checkOrientation() + } + .onDisappear { + viewModel.stopScheduleCheckTimer() + } + .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in + self.checkOrientation() + } + } else { + VStack { + Text("No results.") + Button { + viewModel.getChannels() + } label: { + Text("Reload") + } + } + } + } - @ViewBuilder - func makeCellView(_ channelProgram: LiveTVChannelProgram) -> some View { - let channel = channelProgram.channel - let currentProgramDisplayText = channelProgram.currentProgram? - .programDisplayText(timeFormatter: viewModel.timeFormatter) ?? LiveTVChannelViewProgram(timeDisplay: "", title: "") - let nextItems = channelProgram.programs.filter { program in - guard let start = program.startDate else { - return false - } - guard let currentStart = channelProgram.currentProgram?.startDate else { - return false - } - return start > currentStart - } - LiveTVChannelItemWideElement(channel: channel, - currentProgram: channelProgram.currentProgram, - currentProgramText: currentProgramDisplayText, - nextProgramsText: nextProgramsDisplayText(nextItems: nextItems, - timeFormatter: viewModel.timeFormatter), - onSelect: { loadingAction in - loadingAction(true) - self.viewModel.fetchVideoPlayerViewModel(item: channel) { playerViewModel in - self.router.route(to: \.videoPlayer, playerViewModel) - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - loadingAction(false) - } - } - }) - } + @ViewBuilder + func makeCellView(_ channelProgram: LiveTVChannelProgram) -> some View { + let channel = channelProgram.channel + let currentProgramDisplayText = channelProgram.currentProgram? + .programDisplayText(timeFormatter: viewModel.timeFormatter) ?? LiveTVChannelViewProgram(timeDisplay: "", title: "") + let nextItems = channelProgram.programs.filter { program in + guard let start = program.startDate else { + return false + } + guard let currentStart = channelProgram.currentProgram?.startDate else { + return false + } + return start > currentStart + } + LiveTVChannelItemWideElement( + channel: channel, + currentProgram: channelProgram.currentProgram, + currentProgramText: currentProgramDisplayText, + nextProgramsText: nextProgramsDisplayText( + nextItems: nextItems, + timeFormatter: viewModel.timeFormatter + ), + onSelect: { loadingAction in + loadingAction(true) + self.viewModel.fetchVideoPlayerViewModel(item: channel) { playerViewModel in + self.router.route(to: \.videoPlayer, playerViewModel) + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + loadingAction(false) + } + } + } + ) + } - private func checkOrientation() { - let scenes = UIApplication.shared.connectedScenes - let windowScene = scenes.first as? UIWindowScene - guard let scene = windowScene else { return } - self.isPortrait = scene.interfaceOrientation.isPortrait - } + private func checkOrientation() { + let scenes = UIApplication.shared.connectedScenes + let windowScene = scenes.first as? UIWindowScene + guard let scene = windowScene else { return } + self.isPortrait = scene.interfaceOrientation.isPortrait + } - private func nextProgramsDisplayText(nextItems: [BaseItemDto], timeFormatter: DateFormatter) -> [LiveTVChannelViewProgram] { - var programsDisplayText: [LiveTVChannelViewProgram] = [] - for item in nextItems { - programsDisplayText.append(item.programDisplayText(timeFormatter: timeFormatter)) - } - return programsDisplayText - } + private func nextProgramsDisplayText(nextItems: [BaseItemDto], timeFormatter: DateFormatter) -> [LiveTVChannelViewProgram] { + var programsDisplayText: [LiveTVChannelViewProgram] = [] + for item in nextItems { + programsDisplayText.append(item.programDisplayText(timeFormatter: timeFormatter)) + } + return programsDisplayText + } } private extension BaseItemDto { - func programDisplayText(timeFormatter: DateFormatter) -> LiveTVChannelViewProgram { - var timeText = "" - if let start = self.startDate { - timeText.append(timeFormatter.string(from: start) + " ") - } - var displayText = "" - if let season = self.parentIndexNumber, - let episode = self.indexNumber - { - displayText.append("\(season)x\(episode) ") - } else if let episode = self.indexNumber { - displayText.append("\(episode) ") - } - if let name = self.name { - displayText.append("\(name) ") - } - if let title = self.episodeTitle { - displayText.append("\(title) ") - } - if let year = self.productionYear { - displayText.append("\(year) ") - } - if let rating = self.officialRating { - displayText.append("\(rating)") - } + func programDisplayText(timeFormatter: DateFormatter) -> LiveTVChannelViewProgram { + var timeText = "" + if let start = self.startDate { + timeText.append(timeFormatter.string(from: start) + " ") + } + var displayText = "" + if let season = self.parentIndexNumber, + let episode = self.indexNumber + { + displayText.append("\(season)x\(episode) ") + } else if let episode = self.indexNumber { + displayText.append("\(episode) ") + } + if let name = self.name { + displayText.append("\(name) ") + } + if let title = self.episodeTitle { + displayText.append("\(title) ") + } + if let year = self.productionYear { + displayText.append("\(year) ") + } + if let rating = self.officialRating { + displayText.append("\(rating)") + } - return LiveTVChannelViewProgram(timeDisplay: timeText, title: displayText) - } + return LiveTVChannelViewProgram(timeDisplay: timeText, title: displayText) + } } diff --git a/Swiftfin/Views/LiveTVHomeView.swift b/Swiftfin/Views/LiveTVHomeView.swift index f78f8f5d..535e36fd 100644 --- a/Swiftfin/Views/LiveTVHomeView.swift +++ b/Swiftfin/Views/LiveTVHomeView.swift @@ -10,7 +10,7 @@ import Stinsen import SwiftUI struct LiveTVHomeView: View { - var body: some View { - Text("Coming Soon") - } + var body: some View { + Text("Coming Soon") + } } diff --git a/Swiftfin/Views/LiveTVProgramsView.swift b/Swiftfin/Views/LiveTVProgramsView.swift index 97329c9c..3cdea18b 100644 --- a/Swiftfin/Views/LiveTVProgramsView.swift +++ b/Swiftfin/Views/LiveTVProgramsView.swift @@ -10,129 +10,141 @@ import Stinsen import SwiftUI struct LiveTVProgramsView: View { - @EnvironmentObject - var programsRouter: LiveTVProgramsCoordinator.Router - @StateObject - var viewModel = LiveTVProgramsViewModel() + @EnvironmentObject + var programsRouter: LiveTVProgramsCoordinator.Router + @StateObject + var viewModel = LiveTVProgramsViewModel() - var body: some View { - ScrollView { - LazyVStack(alignment: .leading) { - if !viewModel.recommendedItems.isEmpty, - let items = viewModel.recommendedItems - { - PortraitImageHStackView(items: items, - horizontalAlignment: .leading) { - Text("On Now") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - } selectedAction: { item in - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } - } - } - if !viewModel.seriesItems.isEmpty, - let items = viewModel.seriesItems - { - PortraitImageHStackView(items: items, - horizontalAlignment: .leading) { - Text("Shows") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - } selectedAction: { item in - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } - } - } - if !viewModel.movieItems.isEmpty, - let items = viewModel.movieItems - { - PortraitImageHStackView(items: items, - horizontalAlignment: .leading) { - Text("Movies") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - } selectedAction: { item in - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } - } - } - if !viewModel.sportsItems.isEmpty, - let items = viewModel.sportsItems - { - PortraitImageHStackView(items: items, - horizontalAlignment: .leading) { - Text("Sports") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - } selectedAction: { item in - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } - } - } - if !viewModel.kidsItems.isEmpty, - let items = viewModel.kidsItems - { - PortraitImageHStackView(items: items, - horizontalAlignment: .leading) { - Text("Kids") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - } selectedAction: { item in - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } - } - } - if !viewModel.newsItems.isEmpty, - let items = viewModel.newsItems - { - PortraitImageHStackView(items: items, - horizontalAlignment: .leading) { - Text("News") - .font(.headline) - .fontWeight(.semibold) - .padding(.leading, 90) - } selectedAction: { item in - if let chanId = item.channelId, - let chan = viewModel.findChannel(id: chanId) - { - self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in - self.programsRouter.route(to: \.videoPlayer, playerViewModel) - } - } - } - } - } - } - } + var body: some View { + ScrollView { + LazyVStack(alignment: .leading) { + if !viewModel.recommendedItems.isEmpty, + let items = viewModel.recommendedItems + { + PortraitImageHStackView( + items: items, + horizontalAlignment: .leading + ) { + Text("On Now") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + } selectedAction: { item in + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } + } + } + } + if !viewModel.seriesItems.isEmpty, + let items = viewModel.seriesItems + { + PortraitImageHStackView( + items: items, + horizontalAlignment: .leading + ) { + Text("Shows") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + } selectedAction: { item in + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } + } + } + } + if !viewModel.movieItems.isEmpty, + let items = viewModel.movieItems + { + PortraitImageHStackView( + items: items, + horizontalAlignment: .leading + ) { + Text("Movies") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + } selectedAction: { item in + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } + } + } + } + if !viewModel.sportsItems.isEmpty, + let items = viewModel.sportsItems + { + PortraitImageHStackView( + items: items, + horizontalAlignment: .leading + ) { + Text("Sports") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + } selectedAction: { item in + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } + } + } + } + if !viewModel.kidsItems.isEmpty, + let items = viewModel.kidsItems + { + PortraitImageHStackView( + items: items, + horizontalAlignment: .leading + ) { + Text("Kids") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + } selectedAction: { item in + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } + } + } + } + if !viewModel.newsItems.isEmpty, + let items = viewModel.newsItems + { + PortraitImageHStackView( + items: items, + horizontalAlignment: .leading + ) { + Text("News") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + } selectedAction: { item in + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } + } + } + } + } + } + } } diff --git a/Swiftfin/Views/PublicUserSignInCellView.swift b/Swiftfin/Views/PublicUserSignInCellView.swift index 184b270c..cd462305 100644 --- a/Swiftfin/Views/PublicUserSignInCellView.swift +++ b/Swiftfin/Views/PublicUserSignInCellView.swift @@ -11,35 +11,35 @@ import SwiftUI struct UserLoginCellView: View { - @ObservedObject - var viewModel: UserSignInViewModel + @ObservedObject + var viewModel: UserSignInViewModel - @State - private var enteredPassword: String = "" + @State + private var enteredPassword: String = "" - var user: UserDto + var user: UserDto - var body: some View { - DisclosureGroup { - SecureField(L10n.password, text: $enteredPassword) - Button { - viewModel.login(username: user.name ?? "--", password: enteredPassword) - } label: { - L10n.signIn.text - } - } label: { - HStack { - ImageView(viewModel.getProfileImageUrl(user: user)) { - Image(systemName: "person.circle") - .resizable() - .frame(width: 50, height: 50) - } - .frame(width: 50, height: 50) - .clipShape(Circle()) + var body: some View { + DisclosureGroup { + SecureField(L10n.password, text: $enteredPassword) + Button { + viewModel.login(username: user.name ?? "--", password: enteredPassword) + } label: { + L10n.signIn.text + } + } label: { + HStack { + ImageView(viewModel.getProfileImageUrl(user: user)) { + Image(systemName: "person.circle") + .resizable() + .frame(width: 50, height: 50) + } + .frame(width: 50, height: 50) + .clipShape(Circle()) - Text(user.name ?? "--") - Spacer() - } - } - } + Text(user.name ?? "--") + Spacer() + } + } + } } diff --git a/Swiftfin/Views/ServerDetailView.swift b/Swiftfin/Views/ServerDetailView.swift index eeb81639..ce56dcb9 100644 --- a/Swiftfin/Views/ServerDetailView.swift +++ b/Swiftfin/Views/ServerDetailView.swift @@ -10,49 +10,49 @@ import SwiftUI struct ServerDetailView: View { - @ObservedObject - var viewModel: ServerDetailViewModel - @State - var currentServerURI: String + @ObservedObject + var viewModel: ServerDetailViewModel + @State + var currentServerURI: String - init(viewModel: ServerDetailViewModel) { - self.viewModel = viewModel - self.currentServerURI = viewModel.server.currentURI - } + init(viewModel: ServerDetailViewModel) { + self.viewModel = viewModel + self.currentServerURI = viewModel.server.currentURI + } - var body: some View { - Form { - Section(header: L10n.serverDetails.text) { - HStack { - L10n.name.text - Spacer() - Text(viewModel.server.name) - .foregroundColor(.secondary) - } + var body: some View { + Form { + Section(header: L10n.serverDetails.text) { + HStack { + L10n.name.text + Spacer() + Text(viewModel.server.name) + .foregroundColor(.secondary) + } - Picker(L10n.url, selection: $currentServerURI) { - ForEach(viewModel.server.uris.sorted(), id: \.self) { uri in - Text(uri).tag(uri) - .foregroundColor(.secondary) - }.onChange(of: currentServerURI) { newValue in - viewModel.setServerCurrentURI(uri: newValue) - } - } + Picker(L10n.url, selection: $currentServerURI) { + ForEach(viewModel.server.uris.sorted(), id: \.self) { uri in + Text(uri).tag(uri) + .foregroundColor(.secondary) + }.onChange(of: currentServerURI) { newValue in + viewModel.setServerCurrentURI(uri: newValue) + } + } - HStack { - L10n.version.text - Spacer() - Text(viewModel.server.version) - .foregroundColor(.secondary) - } + HStack { + L10n.version.text + Spacer() + Text(viewModel.server.version) + .foregroundColor(.secondary) + } - HStack { - L10n.operatingSystem.text - Spacer() - Text(viewModel.server.os) - .foregroundColor(.secondary) - } - } - } - } + HStack { + L10n.operatingSystem.text + Spacer() + Text(viewModel.server.os) + .foregroundColor(.secondary) + } + } + } + } } diff --git a/Swiftfin/Views/ServerListView.swift b/Swiftfin/Views/ServerListView.swift index fc47f664..8f110cd5 100644 --- a/Swiftfin/Views/ServerListView.swift +++ b/Swiftfin/Views/ServerListView.swift @@ -11,117 +11,117 @@ import SwiftUI struct ServerListView: View { - @EnvironmentObject - var serverListRouter: ServerListCoordinator.Router - @ObservedObject - var viewModel: ServerListViewModel + @EnvironmentObject + var serverListRouter: ServerListCoordinator.Router + @ObservedObject + var viewModel: ServerListViewModel - private var listView: some View { - ScrollView { - LazyVStack { - ForEach(viewModel.servers, id: \.id) { server in - Button { - serverListRouter.route(to: \.userList, server) - } label: { - ZStack(alignment: Alignment.leading) { - Rectangle() - .foregroundColor(Color(UIColor.secondarySystemFill)) - .cornerRadius(10) + private var listView: some View { + ScrollView { + LazyVStack { + ForEach(viewModel.servers, id: \.id) { server in + Button { + serverListRouter.route(to: \.userList, server) + } label: { + ZStack(alignment: Alignment.leading) { + Rectangle() + .foregroundColor(Color(UIColor.secondarySystemFill)) + .cornerRadius(10) - HStack(spacing: 10) { - Image(systemName: "server.rack") - .font(.system(size: 36)) - .foregroundColor(.primary) + HStack(spacing: 10) { + Image(systemName: "server.rack") + .font(.system(size: 36)) + .foregroundColor(.primary) - VStack(alignment: .leading, spacing: 5) { - Text(server.name) - .font(.title2) - .foregroundColor(.primary) + VStack(alignment: .leading, spacing: 5) { + Text(server.name) + .font(.title2) + .foregroundColor(.primary) - Text(server.currentURI) - .font(.footnote) - .disabled(true) - .foregroundColor(.secondary) + Text(server.currentURI) + .font(.footnote) + .disabled(true) + .foregroundColor(.secondary) - Text(viewModel.userTextFor(server: server)) - .font(.footnote) - .foregroundColor(.primary) - } - }.padding() - } - .padding() - } - .contextMenu { - Button(role: .destructive) { - viewModel.remove(server: server) - } label: { - Label(L10n.remove, systemImage: "trash") - } - } - } - } - } - } + Text(viewModel.userTextFor(server: server)) + .font(.footnote) + .foregroundColor(.primary) + } + }.padding() + } + .padding() + } + .contextMenu { + Button(role: .destructive) { + viewModel.remove(server: server) + } label: { + Label(L10n.remove, systemImage: "trash") + } + } + } + } + } + } - private var noServerView: some View { - VStack { - L10n.connectToJellyfinServerStart.text - .frame(minWidth: 50, maxWidth: 240) - .multilineTextAlignment(.center) + private var noServerView: some View { + VStack { + L10n.connectToJellyfinServerStart.text + .frame(minWidth: 50, maxWidth: 240) + .multilineTextAlignment(.center) - PrimaryButtonView(title: L10n.connect.stringValue) { - serverListRouter.route(to: \.connectToServer) - } - } - } + PrimaryButtonView(title: L10n.connect.stringValue) { + serverListRouter.route(to: \.connectToServer) + } + } + } - @ViewBuilder - private var innerBody: some View { - if viewModel.servers.isEmpty { - noServerView - .offset(y: -50) - } else { - listView - } - } + @ViewBuilder + private var innerBody: some View { + if viewModel.servers.isEmpty { + noServerView + .offset(y: -50) + } else { + listView + } + } - @ViewBuilder - private var trailingToolbarContent: some View { - if viewModel.servers.isEmpty { - EmptyView() - } else { - Button { - serverListRouter.route(to: \.connectToServer) - } label: { - Image(systemName: "plus.circle.fill") - } - } - } + @ViewBuilder + private var trailingToolbarContent: some View { + if viewModel.servers.isEmpty { + EmptyView() + } else { + Button { + serverListRouter.route(to: \.connectToServer) + } label: { + Image(systemName: "plus.circle.fill") + } + } + } - private var leadingToolbarContent: some View { - Button { - serverListRouter.route(to: \.basicAppSettings) - } label: { - Image(systemName: "gearshape.fill") - .accessibilityLabel(L10n.settings) - } - } + private var leadingToolbarContent: some View { + Button { + serverListRouter.route(to: \.basicAppSettings) + } label: { + Image(systemName: "gearshape.fill") + .accessibilityLabel(L10n.settings) + } + } - var body: some View { - innerBody - .navigationTitle(L10n.servers) - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - trailingToolbarContent - } - } - .toolbar(content: { - ToolbarItemGroup(placement: .navigationBarLeading) { - leadingToolbarContent - } - }) - .onAppear { - viewModel.fetchServers() - } - } + var body: some View { + innerBody + .navigationTitle(L10n.servers) + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + trailingToolbarContent + } + } + .toolbar(content: { + ToolbarItemGroup(placement: .navigationBarLeading) { + leadingToolbarContent + } + }) + .onAppear { + viewModel.fetchServers() + } + } } diff --git a/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift b/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift index e443a738..ee86e523 100644 --- a/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift +++ b/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift @@ -11,24 +11,24 @@ import SwiftUI struct CustomizeViewsSettings: View { - @Default(.showPosterLabels) - var showPosterLabels - @Default(.showCastAndCrew) - var showCastAndCrew - @Default(.showFlattenView) - var showFlattenView + @Default(.showPosterLabels) + var showPosterLabels + @Default(.showCastAndCrew) + var showCastAndCrew + @Default(.showFlattenView) + var showFlattenView - var body: some View { - Form { - Section { + var body: some View { + Form { + Section { - Toggle(L10n.showPosterLabels, isOn: $showPosterLabels) - Toggle(L10n.showCastAndCrew, isOn: $showCastAndCrew) - Toggle(L10n.showFlattenView, isOn: $showFlattenView) + Toggle(L10n.showPosterLabels, isOn: $showPosterLabels) + Toggle(L10n.showCastAndCrew, isOn: $showCastAndCrew) + Toggle(L10n.showFlattenView, isOn: $showFlattenView) - } header: { - L10n.customize.text - } - } - } + } header: { + L10n.customize.text + } + } + } } diff --git a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift index e0192df0..c22fa51e 100644 --- a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift +++ b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift @@ -11,44 +11,44 @@ import SwiftUI struct ExperimentalSettingsView: View { - @Default(.Experimental.forceDirectPlay) - var forceDirectPlay - @Default(.Experimental.syncSubtitleStateWithAdjacent) - var syncSubtitleStateWithAdjacent - @Default(.Experimental.nativePlayer) - var nativePlayer - @Default(.Experimental.liveTVAlphaEnabled) - var liveTVAlphaEnabled - @Default(.Experimental.liveTVForceDirectPlay) - var liveTVForceDirectPlay - @Default(.Experimental.liveTVNativePlayer) - var liveTVNativePlayer + @Default(.Experimental.forceDirectPlay) + var forceDirectPlay + @Default(.Experimental.syncSubtitleStateWithAdjacent) + var syncSubtitleStateWithAdjacent + @Default(.Experimental.nativePlayer) + var nativePlayer + @Default(.Experimental.liveTVAlphaEnabled) + var liveTVAlphaEnabled + @Default(.Experimental.liveTVForceDirectPlay) + var liveTVForceDirectPlay + @Default(.Experimental.liveTVNativePlayer) + var liveTVNativePlayer - var body: some View { - Form { - Section { + var body: some View { + Form { + Section { - Toggle("Force Direct Play", isOn: $forceDirectPlay) + Toggle("Force Direct Play", isOn: $forceDirectPlay) - Toggle("Sync Subtitles with Adjacent Episodes", isOn: $syncSubtitleStateWithAdjacent) + Toggle("Sync Subtitles with Adjacent Episodes", isOn: $syncSubtitleStateWithAdjacent) - Toggle("Native Player", isOn: $nativePlayer) + Toggle("Native Player", isOn: $nativePlayer) - } header: { - L10n.experimental.text - } + } header: { + L10n.experimental.text + } - Section { + Section { - Toggle("Live TV (Alpha)", isOn: $liveTVAlphaEnabled) + Toggle("Live TV (Alpha)", isOn: $liveTVAlphaEnabled) - Toggle("Live TV Force Direct Play", isOn: $liveTVForceDirectPlay) + Toggle("Live TV Force Direct Play", isOn: $liveTVForceDirectPlay) - Toggle("Live TV Native Player", isOn: $liveTVNativePlayer) + Toggle("Live TV Native Player", isOn: $liveTVNativePlayer) - } header: { - Text("Live TV") - } - } - } + } header: { + Text("Live TV") + } + } + } } diff --git a/Swiftfin/Views/SettingsView/MissingItemsSettingsView.swift b/Swiftfin/Views/SettingsView/MissingItemsSettingsView.swift index 983a9604..cfc1a06f 100644 --- a/Swiftfin/Views/SettingsView/MissingItemsSettingsView.swift +++ b/Swiftfin/Views/SettingsView/MissingItemsSettingsView.swift @@ -11,20 +11,20 @@ import SwiftUI struct MissingItemsSettingsView: View { - @Default(.shouldShowMissingSeasons) - var shouldShowMissingSeasons + @Default(.shouldShowMissingSeasons) + var shouldShowMissingSeasons - @Default(.shouldShowMissingEpisodes) - var shouldShowMissingEpisodes + @Default(.shouldShowMissingEpisodes) + var shouldShowMissingEpisodes - var body: some View { - Form { - Section { - Toggle(L10n.showMissingSeasons, isOn: $shouldShowMissingSeasons) - Toggle(L10n.showMissingEpisodes, isOn: $shouldShowMissingEpisodes) - } header: { - L10n.missingItems.text - } - } - } + var body: some View { + Form { + Section { + Toggle(L10n.showMissingSeasons, isOn: $shouldShowMissingSeasons) + Toggle(L10n.showMissingEpisodes, isOn: $shouldShowMissingEpisodes) + } header: { + L10n.missingItems.text + } + } + } } diff --git a/Swiftfin/Views/SettingsView/OverlaySettingsView.swift b/Swiftfin/Views/SettingsView/OverlaySettingsView.swift index 537af6a6..af620221 100644 --- a/Swiftfin/Views/SettingsView/OverlaySettingsView.swift +++ b/Swiftfin/Views/SettingsView/OverlaySettingsView.swift @@ -11,58 +11,58 @@ import SwiftUI struct OverlaySettingsView: View { - @Default(.overlayType) - var overlayType - @Default(.shouldShowPlayPreviousItem) - var shouldShowPlayPreviousItem - @Default(.shouldShowPlayNextItem) - var shouldShowPlayNextItem - @Default(.shouldShowAutoPlay) - var shouldShowAutoPlay - @Default(.shouldShowJumpButtonsInOverlayMenu) - var shouldShowJumpButtonsInOverlayMenu - @Default(.shouldShowChaptersInfoInBottomOverlay) - var shouldShowChaptersInfoInBottomOverlay + @Default(.overlayType) + var overlayType + @Default(.shouldShowPlayPreviousItem) + var shouldShowPlayPreviousItem + @Default(.shouldShowPlayNextItem) + var shouldShowPlayNextItem + @Default(.shouldShowAutoPlay) + var shouldShowAutoPlay + @Default(.shouldShowJumpButtonsInOverlayMenu) + var shouldShowJumpButtonsInOverlayMenu + @Default(.shouldShowChaptersInfoInBottomOverlay) + var shouldShowChaptersInfoInBottomOverlay - var body: some View { - Form { - Section(header: L10n.overlay.text) { - Picker(L10n.overlayType, selection: $overlayType) { - ForEach(OverlayType.allCases, id: \.self) { overlay in - Text(overlay.label).tag(overlay) - } - } + var body: some View { + Form { + Section(header: L10n.overlay.text) { + Picker(L10n.overlayType, selection: $overlayType) { + ForEach(OverlayType.allCases, id: \.self) { overlay in + Text(overlay.label).tag(overlay) + } + } - Toggle(isOn: $shouldShowPlayPreviousItem) { - HStack { - Image(systemName: "chevron.left.circle") - L10n.playPreviousItem.text - } - } + Toggle(isOn: $shouldShowPlayPreviousItem) { + HStack { + Image(systemName: "chevron.left.circle") + L10n.playPreviousItem.text + } + } - Toggle(isOn: $shouldShowPlayNextItem) { - HStack { - Image(systemName: "chevron.right.circle") - L10n.playNextItem.text - } - } + Toggle(isOn: $shouldShowPlayNextItem) { + HStack { + Image(systemName: "chevron.right.circle") + L10n.playNextItem.text + } + } - Toggle(isOn: $shouldShowAutoPlay) { - HStack { - Image(systemName: "play.circle.fill") - L10n.autoPlay.text - } - } + Toggle(isOn: $shouldShowAutoPlay) { + HStack { + Image(systemName: "play.circle.fill") + L10n.autoPlay.text + } + } - Toggle(isOn: $shouldShowChaptersInfoInBottomOverlay) { - HStack { - Image(systemName: "photo.on.rectangle") - L10n.showChaptersInfoInBottomOverlay.text - } - } + Toggle(isOn: $shouldShowChaptersInfoInBottomOverlay) { + HStack { + Image(systemName: "photo.on.rectangle") + L10n.showChaptersInfoInBottomOverlay.text + } + } - Toggle(L10n.editJumpLengths, isOn: $shouldShowJumpButtonsInOverlayMenu) - } - } - } + Toggle(L10n.editJumpLengths, isOn: $shouldShowJumpButtonsInOverlayMenu) + } + } + } } diff --git a/Swiftfin/Views/SettingsView/QuickConnectSettingsView.swift b/Swiftfin/Views/SettingsView/QuickConnectSettingsView.swift index a6e47215..520ab619 100644 --- a/Swiftfin/Views/SettingsView/QuickConnectSettingsView.swift +++ b/Swiftfin/Views/SettingsView/QuickConnectSettingsView.swift @@ -11,34 +11,38 @@ import SwiftUI struct QuickConnectSettingsView: View { - @ObservedObject - var viewModel: QuickConnectSettingsViewModel + @ObservedObject + var viewModel: QuickConnectSettingsViewModel - var body: some View { - Form { - Section(header: L10n.quickConnect.text) { - TextField(L10n.quickConnectCode, text: $viewModel.quickConnectCode) - .keyboardType(.numberPad) - .disabled(viewModel.isLoading) + var body: some View { + Form { + Section(header: L10n.quickConnect.text) { + TextField(L10n.quickConnectCode, text: $viewModel.quickConnectCode) + .keyboardType(.numberPad) + .disabled(viewModel.isLoading) - Button { - viewModel.sendQuickConnect() - } label: { - L10n.authorize.text - .font(.callout) - .disabled((viewModel.quickConnectCode.count != 6) || viewModel.isLoading) - } - } - .alert(isPresented: $viewModel.showSuccessMessage) { - Alert(title: L10n.quickConnect.text, - message: L10n.quickConnectSuccessMessage.text, - dismissButton: .default(L10n.ok.text)) - } - } - .alert(item: $viewModel.errorMessage) { _ in - Alert(title: Text(viewModel.alertTitle), - message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), - dismissButton: .cancel()) - } - } + Button { + viewModel.sendQuickConnect() + } label: { + L10n.authorize.text + .font(.callout) + .disabled((viewModel.quickConnectCode.count != 6) || viewModel.isLoading) + } + } + .alert(isPresented: $viewModel.showSuccessMessage) { + Alert( + title: L10n.quickConnect.text, + message: L10n.quickConnectSuccessMessage.text, + dismissButton: .default(L10n.ok.text) + ) + } + } + .alert(item: $viewModel.errorMessage) { _ in + Alert( + title: Text(viewModel.alertTitle), + message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), + dismissButton: .cancel() + ) + } + } } diff --git a/Swiftfin/Views/SettingsView/SettingsView.swift b/Swiftfin/Views/SettingsView/SettingsView.swift index 9d03aeba..3e62f128 100644 --- a/Swiftfin/Views/SettingsView/SettingsView.swift +++ b/Swiftfin/Views/SettingsView/SettingsView.swift @@ -13,205 +13,205 @@ import SwiftUI struct SettingsView: View { - @EnvironmentObject - var settingsRouter: SettingsCoordinator.Router - @ObservedObject - var viewModel: SettingsViewModel + @EnvironmentObject + var settingsRouter: SettingsCoordinator.Router + @ObservedObject + var viewModel: SettingsViewModel - @Default(.inNetworkBandwidth) - var inNetworkStreamBitrate - @Default(.outOfNetworkBandwidth) - var outOfNetworkStreamBitrate - @Default(.isAutoSelectSubtitles) - var isAutoSelectSubtitles - @Default(.autoSelectSubtitlesLangCode) - var autoSelectSubtitlesLangcode - @Default(.autoSelectAudioLangCode) - var autoSelectAudioLangcode - @Default(.appAppearance) - var appAppearance - @Default(.overlayType) - var overlayType - @Default(.videoPlayerJumpForward) - var jumpForwardLength - @Default(.videoPlayerJumpBackward) - var jumpBackwardLength - @Default(.jumpGesturesEnabled) - var jumpGesturesEnabled - @Default(.systemControlGesturesEnabled) - var systemControlGesturesEnabled - @Default(.playerGesturesLockGestureEnabled) - var playerGesturesLockGestureEnabled - @Default(.seekSlideGestureEnabled) - var seekSlideGestureEnabled - @Default(.resumeOffset) - var resumeOffset - @Default(.subtitleSize) - var subtitleSize + @Default(.inNetworkBandwidth) + var inNetworkStreamBitrate + @Default(.outOfNetworkBandwidth) + var outOfNetworkStreamBitrate + @Default(.isAutoSelectSubtitles) + var isAutoSelectSubtitles + @Default(.autoSelectSubtitlesLangCode) + var autoSelectSubtitlesLangcode + @Default(.autoSelectAudioLangCode) + var autoSelectAudioLangcode + @Default(.appAppearance) + var appAppearance + @Default(.overlayType) + var overlayType + @Default(.videoPlayerJumpForward) + var jumpForwardLength + @Default(.videoPlayerJumpBackward) + var jumpBackwardLength + @Default(.jumpGesturesEnabled) + var jumpGesturesEnabled + @Default(.systemControlGesturesEnabled) + var systemControlGesturesEnabled + @Default(.playerGesturesLockGestureEnabled) + var playerGesturesLockGestureEnabled + @Default(.seekSlideGestureEnabled) + var seekSlideGestureEnabled + @Default(.resumeOffset) + var resumeOffset + @Default(.subtitleSize) + var subtitleSize - var body: some View { - Form { - Section(header: EmptyView()) { - HStack { - L10n.user.text - Spacer() - Text(viewModel.user.username) - .foregroundColor(.jellyfinPurple) - } + var body: some View { + Form { + Section(header: EmptyView()) { + HStack { + L10n.user.text + Spacer() + Text(viewModel.user.username) + .foregroundColor(.jellyfinPurple) + } - Button { - settingsRouter.route(to: \.serverDetail) - } label: { - HStack { - L10n.server.text - .foregroundColor(.primary) - Spacer() - Text(viewModel.server.name) - .foregroundColor(.jellyfinPurple) + Button { + settingsRouter.route(to: \.serverDetail) + } label: { + HStack { + L10n.server.text + .foregroundColor(.primary) + Spacer() + Text(viewModel.server.name) + .foregroundColor(.jellyfinPurple) - Image(systemName: "chevron.right") - } - } + Image(systemName: "chevron.right") + } + } - Button { - settingsRouter.dismissCoordinator { - SessionManager.main.logout() - } - } label: { - L10n.switchUser.text - .font(.callout) - } + Button { + settingsRouter.dismissCoordinator { + SessionManager.main.logout() + } + } label: { + L10n.switchUser.text + .font(.callout) + } - Button { - settingsRouter.route(to: \.quickConnect) - } label: { - HStack { - L10n.quickConnect.text - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - } - } - } + Button { + settingsRouter.route(to: \.quickConnect) + } label: { + HStack { + L10n.quickConnect.text + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.right") + } + } + } - // TODO: Implement these for playback - // Section(header: Text("Networking")) { - // Picker("Default local quality", selection: $inNetworkStreamBitrate) { - // ForEach(self.viewModel.bitrates, id: \.self) { bitrate in - // Text(bitrate.name).tag(bitrate.value) - // } - // } + // TODO: Implement these for playback + // Section(header: Text("Networking")) { + // Picker("Default local quality", selection: $inNetworkStreamBitrate) { + // ForEach(self.viewModel.bitrates, id: \.self) { bitrate in + // Text(bitrate.name).tag(bitrate.value) + // } + // } // - // Picker("Default remote quality", selection: $outOfNetworkStreamBitrate) { - // ForEach(self.viewModel.bitrates, id: \.self) { bitrate in - // Text(bitrate.name).tag(bitrate.value) - // } - // } - // } + // Picker("Default remote quality", selection: $outOfNetworkStreamBitrate) { + // ForEach(self.viewModel.bitrates, id: \.self) { bitrate in + // Text(bitrate.name).tag(bitrate.value) + // } + // } + // } - Section(header: L10n.videoPlayer.text) { - Picker(L10n.jumpForwardLength, selection: $jumpForwardLength) { - ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in - Text(length.label).tag(length.rawValue) - } - } + Section(header: L10n.videoPlayer.text) { + Picker(L10n.jumpForwardLength, selection: $jumpForwardLength) { + ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in + Text(length.label).tag(length.rawValue) + } + } - Picker(L10n.jumpBackwardLength, selection: $jumpBackwardLength) { - ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in - Text(length.label).tag(length.rawValue) - } - } + Picker(L10n.jumpBackwardLength, selection: $jumpBackwardLength) { + ForEach(VideoPlayerJumpLength.allCases, id: \.self) { length in + Text(length.label).tag(length.rawValue) + } + } - Toggle(L10n.jumpGesturesEnabled, isOn: $jumpGesturesEnabled) + Toggle(L10n.jumpGesturesEnabled, isOn: $jumpGesturesEnabled) - Toggle(L10n.systemControlGesturesEnabled, isOn: $systemControlGesturesEnabled) + Toggle(L10n.systemControlGesturesEnabled, isOn: $systemControlGesturesEnabled) - Toggle(L10n.seekSlideGestureEnabled, isOn: $seekSlideGestureEnabled) + Toggle(L10n.seekSlideGestureEnabled, isOn: $seekSlideGestureEnabled) - Toggle(L10n.playerGesturesLockGestureEnabled, isOn: $playerGesturesLockGestureEnabled) + Toggle(L10n.playerGesturesLockGestureEnabled, isOn: $playerGesturesLockGestureEnabled) - Toggle(L10n.resume5SecondOffset, isOn: $resumeOffset) + Toggle(L10n.resume5SecondOffset, isOn: $resumeOffset) - Button { - settingsRouter.route(to: \.overlaySettings) - } label: { - HStack { - L10n.overlay.text - .foregroundColor(.primary) - Spacer() - Text(overlayType.label) - Image(systemName: "chevron.right") - } - } + Button { + settingsRouter.route(to: \.overlaySettings) + } label: { + HStack { + L10n.overlay.text + .foregroundColor(.primary) + Spacer() + Text(overlayType.label) + Image(systemName: "chevron.right") + } + } - Button { - settingsRouter.route(to: \.experimentalSettings) - } label: { - HStack { - L10n.experimental.text - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - } - } - } + Button { + settingsRouter.route(to: \.experimentalSettings) + } label: { + HStack { + L10n.experimental.text + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.right") + } + } + } - Section(header: L10n.accessibility.text) { + Section(header: L10n.accessibility.text) { - Button { - settingsRouter.route(to: \.customizeViewsSettings) - } label: { - HStack { - L10n.customize.text - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - } - } + Button { + settingsRouter.route(to: \.customizeViewsSettings) + } label: { + HStack { + L10n.customize.text + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.right") + } + } - Button { - settingsRouter.route(to: \.missingSettings) - } label: { - HStack { - L10n.missingItems.text - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - } - } + Button { + settingsRouter.route(to: \.missingSettings) + } label: { + HStack { + L10n.missingItems.text + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.right") + } + } - Picker(L10n.appearance, selection: $appAppearance) { - ForEach(AppAppearance.allCases, id: \.self) { appearance in - Text(appearance.localizedName).tag(appearance.rawValue) - } - } - Picker(L10n.subtitleSize, selection: $subtitleSize) { - ForEach(SubtitleSize.allCases, id: \.self) { size in - Text(size.label).tag(size.rawValue) - } - } - } + Picker(L10n.appearance, selection: $appAppearance) { + ForEach(AppAppearance.allCases, id: \.self) { appearance in + Text(appearance.localizedName).tag(appearance.rawValue) + } + } + Picker(L10n.subtitleSize, selection: $subtitleSize) { + ForEach(SubtitleSize.allCases, id: \.self) { size in + Text(size.label).tag(size.rawValue) + } + } + } - Button { - settingsRouter.route(to: \.about) - } label: { - HStack { - L10n.about.text - .foregroundColor(.primary) - Spacer() - Image(systemName: "chevron.right") - } - } - } - .navigationBarTitle(L10n.settings, displayMode: .inline) - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - Button { - settingsRouter.dismissCoordinator() - } label: { - Image(systemName: "xmark.circle.fill") - } - } - } - } + Button { + settingsRouter.route(to: \.about) + } label: { + HStack { + L10n.about.text + .foregroundColor(.primary) + Spacer() + Image(systemName: "chevron.right") + } + } + } + .navigationBarTitle(L10n.settings, displayMode: .inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarLeading) { + Button { + settingsRouter.dismissCoordinator() + } label: { + Image(systemName: "xmark.circle.fill") + } + } + } + } } diff --git a/Swiftfin/Views/UserListView.swift b/Swiftfin/Views/UserListView.swift index b9809656..6c210b4e 100644 --- a/Swiftfin/Views/UserListView.swift +++ b/Swiftfin/Views/UserListView.swift @@ -10,114 +10,114 @@ import SwiftUI struct UserListView: View { - @EnvironmentObject - var userListRouter: UserListCoordinator.Router - @ObservedObject - var viewModel: UserListViewModel + @EnvironmentObject + var userListRouter: UserListCoordinator.Router + @ObservedObject + var viewModel: UserListViewModel - private var listView: some View { - ScrollView { - LazyVStack { - ForEach(viewModel.users, id: \.id) { user in - Button { - viewModel.login(user: user) - } label: { - ZStack(alignment: Alignment.leading) { - Rectangle() - .foregroundColor(Color(UIColor.secondarySystemFill)) - .frame(height: 50) - .cornerRadius(10) + private var listView: some View { + ScrollView { + LazyVStack { + ForEach(viewModel.users, id: \.id) { user in + Button { + viewModel.login(user: user) + } label: { + ZStack(alignment: Alignment.leading) { + Rectangle() + .foregroundColor(Color(UIColor.secondarySystemFill)) + .frame(height: 50) + .cornerRadius(10) - HStack { - Text(user.username) - .font(.title2) + HStack { + Text(user.username) + .font(.title2) - Spacer() + Spacer() - if viewModel.isLoading { - ProgressView() - } - }.padding(.leading) - } - .padding() - } - .contextMenu { - Button(role: .destructive) { - viewModel.remove(user: user) - } label: { - Label(L10n.remove, systemImage: "trash") - } - } - } - } - } - } + if viewModel.isLoading { + ProgressView() + } + }.padding(.leading) + } + .padding() + } + .contextMenu { + Button(role: .destructive) { + viewModel.remove(user: user) + } label: { + Label(L10n.remove, systemImage: "trash") + } + } + } + } + } + } - private var noUserView: some View { - VStack { - L10n.signInGetStarted.text - .frame(minWidth: 50, maxWidth: 240) - .multilineTextAlignment(.center) + private var noUserView: some View { + VStack { + L10n.signInGetStarted.text + .frame(minWidth: 50, maxWidth: 240) + .multilineTextAlignment(.center) - Button { - userListRouter.route(to: \.userSignIn, viewModel.server) - } label: { - ZStack { - Rectangle() - .foregroundColor(Color.jellyfinPurple) - .frame(maxWidth: 400, maxHeight: 50) - .frame(height: 50) - .cornerRadius(10) - .padding(.horizontal, 30) - .padding([.top, .bottom], 20) + Button { + userListRouter.route(to: \.userSignIn, viewModel.server) + } label: { + ZStack { + Rectangle() + .foregroundColor(Color.jellyfinPurple) + .frame(maxWidth: 400, maxHeight: 50) + .frame(height: 50) + .cornerRadius(10) + .padding(.horizontal, 30) + .padding([.top, .bottom], 20) - L10n.signIn.text - .foregroundColor(Color.white) - .bold() - } - } - } - } + L10n.signIn.text + .foregroundColor(Color.white) + .bold() + } + } + } + } - @ViewBuilder - private var innerBody: some View { - if viewModel.users.isEmpty { - noUserView - .offset(y: -50) - } else { - listView - } - } + @ViewBuilder + private var innerBody: some View { + if viewModel.users.isEmpty { + noUserView + .offset(y: -50) + } else { + listView + } + } - @ViewBuilder - private var toolbarContent: some View { - HStack { - Button { - userListRouter.route(to: \.serverDetail, viewModel.server) - } label: { - Image(systemName: "info.circle.fill") - } + @ViewBuilder + private var toolbarContent: some View { + HStack { + Button { + userListRouter.route(to: \.serverDetail, viewModel.server) + } label: { + Image(systemName: "info.circle.fill") + } - if !viewModel.users.isEmpty { - Button { - userListRouter.route(to: \.userSignIn, viewModel.server) - } label: { - Image(systemName: "person.crop.circle.fill.badge.plus") - } - } - } - } + if !viewModel.users.isEmpty { + Button { + userListRouter.route(to: \.userSignIn, viewModel.server) + } label: { + Image(systemName: "person.crop.circle.fill.badge.plus") + } + } + } + } - var body: some View { - innerBody - .navigationTitle(viewModel.server.name) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - toolbarContent - } - } - .onAppear { - viewModel.fetchUsers() - } - } + var body: some View { + innerBody + .navigationTitle(viewModel.server.name) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + toolbarContent + } + } + .onAppear { + viewModel.fetchUsers() + } + } } diff --git a/Swiftfin/Views/UserSignInView.swift b/Swiftfin/Views/UserSignInView.swift index 9f86944a..7d5998ec 100644 --- a/Swiftfin/Views/UserSignInView.swift +++ b/Swiftfin/Views/UserSignInView.swift @@ -11,78 +11,80 @@ import SwiftUI struct UserSignInView: View { - @ObservedObject - var viewModel: UserSignInViewModel - @State - private var username: String = "" - @State - private var password: String = "" + @ObservedObject + var viewModel: UserSignInViewModel + @State + private var username: String = "" + @State + private var password: String = "" - var body: some View { - List { - Section { - TextField(L10n.username, text: $username) - .disableAutocorrection(true) - .autocapitalization(.none) + var body: some View { + List { + Section { + TextField(L10n.username, text: $username) + .disableAutocorrection(true) + .autocapitalization(.none) - SecureField(L10n.password, text: $password) - .disableAutocorrection(true) - .autocapitalization(.none) + SecureField(L10n.password, text: $password) + .disableAutocorrection(true) + .autocapitalization(.none) - if viewModel.isLoading { - Button(role: .destructive) { - viewModel.cancelSignIn() - } label: { - L10n.cancel.text - } - } else { - Button { - viewModel.login(username: username, password: password) - } label: { - L10n.signIn.text - } - .disabled(username.isEmpty) - } - } header: { - L10n.signInToServer(viewModel.server.name).text - } + if viewModel.isLoading { + Button(role: .destructive) { + viewModel.cancelSignIn() + } label: { + L10n.cancel.text + } + } else { + Button { + viewModel.login(username: username, password: password) + } label: { + L10n.signIn.text + } + .disabled(username.isEmpty) + } + } header: { + L10n.signInToServer(viewModel.server.name).text + } - Section { - if !viewModel.publicUsers.isEmpty { - ForEach(viewModel.publicUsers, id: \.id) { user in - UserLoginCellView(viewModel: viewModel, user: user) - .disabled(viewModel.isLoading) - } - } else { - HStack(alignment: .center) { - Spacer() - L10n.noPublicUsers.text - .font(.callout) - .foregroundColor(.secondary) - Spacer() - } - } - } header: { - HStack { - L10n.publicUsers.text - Spacer() - Button { - viewModel.loadUsers() - } label: { - Image(systemName: "arrow.clockwise.circle.fill") - } - .disabled(viewModel.isLoading) - } - } - .headerProminence(.increased) - } - .alert(item: $viewModel.errorMessage) { _ in - Alert(title: Text(viewModel.alertTitle), - message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), - dismissButton: .cancel()) - } - .navigationTitle(L10n.signIn) - .navigationBarBackButtonHidden(viewModel.isLoading) - .onAppear(perform: viewModel.loadUsers) - } + Section { + if !viewModel.publicUsers.isEmpty { + ForEach(viewModel.publicUsers, id: \.id) { user in + UserLoginCellView(viewModel: viewModel, user: user) + .disabled(viewModel.isLoading) + } + } else { + HStack(alignment: .center) { + Spacer() + L10n.noPublicUsers.text + .font(.callout) + .foregroundColor(.secondary) + Spacer() + } + } + } header: { + HStack { + L10n.publicUsers.text + Spacer() + Button { + viewModel.loadUsers() + } label: { + Image(systemName: "arrow.clockwise.circle.fill") + } + .disabled(viewModel.isLoading) + } + } + .headerProminence(.increased) + } + .alert(item: $viewModel.errorMessage) { _ in + Alert( + title: Text(viewModel.alertTitle), + message: Text(viewModel.errorMessage?.message ?? L10n.unknownError), + dismissButton: .cancel() + ) + } + .navigationTitle(L10n.signIn) + .navigationBarBackButtonHidden(viewModel.isLoading) + .onAppear(perform: viewModel.loadUsers) + } } diff --git a/Swiftfin/Views/VideoPlayer/LiveTVNativePlayerViewController.swift b/Swiftfin/Views/VideoPlayer/LiveTVNativePlayerViewController.swift index d5405c04..eb7fa3bc 100644 --- a/Swiftfin/Views/VideoPlayer/LiveTVNativePlayerViewController.swift +++ b/Swiftfin/Views/VideoPlayer/LiveTVNativePlayerViewController.swift @@ -13,102 +13,106 @@ import UIKit class LiveTVNativePlayerViewController: AVPlayerViewController { - let viewModel: VideoPlayerViewModel + let viewModel: VideoPlayerViewModel - var timeObserverToken: Any? + var timeObserverToken: Any? - var lastProgressTicks: Int64 = 0 + var lastProgressTicks: Int64 = 0 - private var cancellables = Set() + private var cancellables = Set() - init(viewModel: VideoPlayerViewModel) { + init(viewModel: VideoPlayerViewModel) { - self.viewModel = viewModel + self.viewModel = viewModel - super.init(nibName: nil, bundle: nil) + super.init(nibName: nil, bundle: nil) - let player: AVPlayer + let player: AVPlayer - if let transcodedStreamURL = viewModel.transcodedStreamURL { - player = AVPlayer(url: transcodedStreamURL) - } else { - player = AVPlayer(url: viewModel.hlsStreamURL) - } + if let transcodedStreamURL = viewModel.transcodedStreamURL { + player = AVPlayer(url: transcodedStreamURL) + } else { + player = AVPlayer(url: viewModel.hlsStreamURL) + } - player.appliesMediaSelectionCriteriaAutomatically = false + player.appliesMediaSelectionCriteriaAutomatically = false - let timeScale = CMTimeScale(NSEC_PER_SEC) - let time = CMTime(seconds: 5, preferredTimescale: timeScale) + let timeScale = CMTimeScale(NSEC_PER_SEC) + let time = CMTime(seconds: 5, preferredTimescale: timeScale) - timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in - if time.seconds != 0 { - self?.sendProgressReport(seconds: time.seconds) - } - } + timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in + if time.seconds != 0 { + self?.sendProgressReport(seconds: time.seconds) + } + } - self.player = player + self.player = player - self.allowsPictureInPicturePlayback = true - self.player?.allowsExternalPlayback = true - } + self.allowsPictureInPicturePlayback = true + self.player?.allowsExternalPlayback = true + } - private func createMetadataItem(for identifier: AVMetadataIdentifier, - value: Any) -> AVMetadataItem - { - let item = AVMutableMetadataItem() - item.identifier = identifier - item.value = value as? NSCopying & NSObjectProtocol - // Specify "und" to indicate an undefined language. - item.extendedLanguageTag = "und" - return item.copy() as! AVMetadataItem - } + private func createMetadataItem( + for identifier: AVMetadataIdentifier, + value: Any + ) -> AVMetadataItem { + let item = AVMutableMetadataItem() + item.identifier = identifier + item.value = value as? NSCopying & NSObjectProtocol + // Specify "und" to indicate an undefined language. + item.extendedLanguageTag = "und" + return item.copy() as! AVMetadataItem + } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } - override func viewDidLoad() { - super.viewDidLoad() - } + override func viewDidLoad() { + super.viewDidLoad() + } - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) - stop() - removePeriodicTimeObserver() - } + stop() + removePeriodicTimeObserver() + } - func removePeriodicTimeObserver() { - if let timeObserverToken = timeObserverToken { - player?.removeTimeObserver(timeObserverToken) - self.timeObserverToken = nil - } - } + func removePeriodicTimeObserver() { + if let timeObserverToken = timeObserverToken { + player?.removeTimeObserver(timeObserverToken) + self.timeObserverToken = nil + } + } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) - player?.seek(to: CMTimeMake(value: viewModel.currentSecondTicks, timescale: 10_000_000), - toleranceBefore: CMTimeMake(value: 1, timescale: 1), toleranceAfter: CMTimeMake(value: 1, timescale: 1), - completionHandler: { _ in - self.play() - }) - } + player?.seek( + to: CMTimeMake(value: viewModel.currentSecondTicks, timescale: 10_000_000), + toleranceBefore: CMTimeMake(value: 1, timescale: 1), + toleranceAfter: CMTimeMake(value: 1, timescale: 1), + completionHandler: { _ in + self.play() + } + ) + } - private func play() { - player?.play() + private func play() { + player?.play() - viewModel.sendPlayReport() - } + viewModel.sendPlayReport() + } - private func sendProgressReport(seconds: Double) { - viewModel.setSeconds(Int64(seconds)) - viewModel.sendProgressReport() - } + private func sendProgressReport(seconds: Double) { + viewModel.setSeconds(Int64(seconds)) + viewModel.sendProgressReport() + } - private func stop() { - self.player?.pause() - viewModel.sendStopReport() - } + private func stop() { + self.player?.pause() + viewModel.sendStopReport() + } } diff --git a/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift b/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift index 5b21e0f2..addc9b3b 100644 --- a/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift +++ b/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift @@ -11,28 +11,28 @@ import UIKit struct LiveTVNativePlayerView: UIViewControllerRepresentable { - let viewModel: VideoPlayerViewModel + let viewModel: VideoPlayerViewModel - typealias UIViewControllerType = LiveTVNativePlayerViewController + typealias UIViewControllerType = LiveTVNativePlayerViewController - func makeUIViewController(context: Context) -> LiveTVNativePlayerViewController { + func makeUIViewController(context: Context) -> LiveTVNativePlayerViewController { - LiveTVNativePlayerViewController(viewModel: viewModel) - } + LiveTVNativePlayerViewController(viewModel: viewModel) + } - func updateUIViewController(_ uiViewController: LiveTVNativePlayerViewController, context: Context) {} + func updateUIViewController(_ uiViewController: LiveTVNativePlayerViewController, context: Context) {} } struct LiveTVPlayerView: UIViewControllerRepresentable { - let viewModel: VideoPlayerViewModel + let viewModel: VideoPlayerViewModel - typealias UIViewControllerType = LiveTVPlayerViewController + typealias UIViewControllerType = LiveTVPlayerViewController - func makeUIViewController(context: Context) -> LiveTVPlayerViewController { + func makeUIViewController(context: Context) -> LiveTVPlayerViewController { - LiveTVPlayerViewController(viewModel: viewModel) - } + LiveTVPlayerViewController(viewModel: viewModel) + } - func updateUIViewController(_ uiViewController: LiveTVPlayerViewController, context: Context) {} + func updateUIViewController(_ uiViewController: LiveTVPlayerViewController, context: Context) {} } diff --git a/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift b/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift index 64b56d1a..375de4c2 100644 --- a/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift +++ b/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift @@ -19,1015 +19,1052 @@ import UIKit // TODO: Look at making the VLC player layer a view class LiveTVPlayerViewController: UIViewController { - // MARK: variables - - private var viewModel: VideoPlayerViewModel - private var vlcMediaPlayer: VLCMediaPlayer - private var lastPlayerTicks: Int64 = 0 - private var lastProgressReportTicks: Int64 = 0 - private var viewModelListeners = Set() - private var overlayDismissTimer: Timer? - private var isScreenFilled: Bool = false - private var pinchScale: CGFloat = 1 - - private var currentPlayerTicks: Int64 { - Int64(vlcMediaPlayer.time.intValue) * 100_000 - } - - private var displayingOverlay: Bool { - currentOverlayHostingController?.view.alpha ?? 0 > 0 - } - - private var displayingChapterOverlay: Bool { - currentChapterOverlayHostingController?.view.alpha ?? 0 > 0 - } - - private var panBeganBrightness = CGFloat.zero - private var panBeganVolumeValue = Float.zero - private var panBeganPoint = CGPoint.zero - - private lazy var videoContentView = makeVideoContentView() - private lazy var mainGestureView = makeMainGestureView() - private lazy var systemControlOverlayLabel = makeSystemControlOverlayLabel() - private var currentOverlayHostingController: UIHostingController? - private var currentChapterOverlayHostingController: UIHostingController? - private var currentJumpBackwardOverlayView: UIImageView? - private var currentJumpForwardOverlayView: UIImageView? - private var volumeView = MPVolumeView() - - override var keyCommands: [UIKeyCommand]? { - var commands = [ - UIKeyCommand(title: L10n.playAndPause, action: #selector(didSelectMain), input: " "), - UIKeyCommand(title: L10n.jumpForward, action: #selector(didSelectForward), input: UIKeyCommand.inputRightArrow), - UIKeyCommand(title: L10n.jumpBackward, action: #selector(didSelectBackward), input: UIKeyCommand.inputLeftArrow), - UIKeyCommand(title: L10n.nextItem, action: #selector(didSelectPlayNextItem), input: UIKeyCommand.inputRightArrow, - modifierFlags: .command), - UIKeyCommand(title: L10n.previousItem, action: #selector(didSelectPlayPreviousItem), input: UIKeyCommand.inputLeftArrow, - modifierFlags: .command), - UIKeyCommand(title: L10n.close, action: #selector(didSelectClose), input: UIKeyCommand.inputEscape), - ] - if let previous = viewModel.playbackSpeed.previous { - commands.append(.init(title: "\(L10n.playbackSpeed) \(previous.displayTitle)", - action: #selector(didSelectPreviousPlaybackSpeed), input: "[", modifierFlags: .command)) - } - if let next = viewModel.playbackSpeed.next { - commands.append(.init(title: "\(L10n.playbackSpeed) \(next.displayTitle)", action: #selector(didSelectNextPlaybackSpeed), - input: "]", modifierFlags: .command)) - } - if viewModel.playbackSpeed != .one { - commands.append(.init(title: "\(L10n.playbackSpeed) \(PlaybackSpeed.one.displayTitle)", - action: #selector(didSelectNormalPlaybackSpeed), input: "\\", modifierFlags: .command)) - } - commands.forEach { $0.wantsPriorityOverSystemBehavior = true } - return commands - } - - // MARK: init - - init(viewModel: VideoPlayerViewModel) { - self.viewModel = viewModel - self.vlcMediaPlayer = VLCMediaPlayer() - - super.init(nibName: nil, bundle: nil) - - viewModel.playerOverlayDelegate = self - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupSubviews() { - view.addSubview(videoContentView) - view.addSubview(mainGestureView) - view.addSubview(systemControlOverlayLabel) - } - - private func setupConstraints() { - NSLayoutConstraint.activate([ - videoContentView.topAnchor.constraint(equalTo: view.topAnchor), - videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), - videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor), - ]) - NSLayoutConstraint.activate([ - mainGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor), - mainGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - mainGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - mainGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - NSLayoutConstraint.activate([ - systemControlOverlayLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), - systemControlOverlayLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - } - - // MARK: viewWillDisappear - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - NotificationCenter.default.removeObserver(self) - } - - // MARK: viewDidLoad - - override func viewDidLoad() { - super.viewDidLoad() - - setupSubviews() - setupConstraints() - - view.backgroundColor = .black - view.accessibilityIgnoresInvertColors = true - - setupMediaPlayer(newViewModel: viewModel) - - refreshJumpBackwardOverlayView(with: viewModel.jumpBackwardLength) - refreshJumpForwardOverlayView(with: viewModel.jumpForwardLength) - - let defaultNotificationCenter = NotificationCenter.default - defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, - object: nil) - defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), - name: UIApplication.willResignActiveNotification, object: nil) - defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), - name: UIApplication.didEnterBackgroundNotification, object: nil) - } - - @objc - private func appWillTerminate() { - viewModel.sendStopReport() - } - - @objc - private func appWillResignActive() { - hideChaptersOverlay() - - showOverlay() - - stopOverlayDismissTimer() - - vlcMediaPlayer.pause() - - viewModel.sendPauseReport(paused: true) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - startPlayback() - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - if isScreenFilled { - fillScreen(screenSize: size) - } - super.viewWillTransition(to: size, with: coordinator) - } - - // MARK: VideoContentView - - private func makeVideoContentView() -> UIView { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .black - - return view - } - - // MARK: MainGestureView - - private func makeMainGestureView() -> UIView { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - - let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) - - let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe)) - rightSwipeGesture.direction = .right - - let leftSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didLeftSwipe)) - leftSwipeGesture.direction = .left - - let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(didPinch(_:))) - - let panGesture = UIPanGestureRecognizer(target: self, action: #selector(didPan(_:))) - - view.addGestureRecognizer(singleTapGesture) - view.addGestureRecognizer(pinchGesture) - - if viewModel.jumpGesturesEnabled { - view.addGestureRecognizer(rightSwipeGesture) - view.addGestureRecognizer(leftSwipeGesture) - } - - if viewModel.systemControlGesturesEnabled { - view.addGestureRecognizer(panGesture) - } - - return view - } - - // MARK: SystemControlOverlayLabel - - private func makeSystemControlOverlayLabel() -> UILabel { - let label = UILabel() - label.alpha = 0 - label.translatesAutoresizingMaskIntoConstraints = false - label.font = .systemFont(ofSize: 48) - return label - } - - @objc - private func didTap() { - didGenerallyTap(point: nil) - } - - @objc - private func didRightSwipe() { - didSelectForward() - } - - @objc - private func didLeftSwipe() { - didSelectBackward() - } - - @objc - private func didPinch(_ gestureRecognizer: UIPinchGestureRecognizer) { - if gestureRecognizer.state == .began || gestureRecognizer.state == .changed { - pinchScale = gestureRecognizer.scale - } else { - if pinchScale > 1, !isScreenFilled { - isScreenFilled.toggle() - fillScreen() - } else if pinchScale < 1, isScreenFilled { - isScreenFilled.toggle() - shrinkScreen() - } - } - } - - @objc - private func didPan(_ gestureRecognizer: UIPanGestureRecognizer) { - switch gestureRecognizer.state { - case .began: - panBeganBrightness = UIScreen.main.brightness - if let view = volumeView.subviews.first as? UISlider { - panBeganVolumeValue = view.value - } - panBeganPoint = gestureRecognizer.location(in: mainGestureView) - case .changed: - let mainGestureViewHalfWidth = mainGestureView.frame.width * 0.5 - let mainGestureViewHalfHeight = mainGestureView.frame.height * 0.5 - - let pos = gestureRecognizer.location(in: mainGestureView) - let moveDelta = pos.y - panBeganPoint.y - let changedValue = moveDelta / mainGestureViewHalfHeight - - if panBeganPoint.x < mainGestureViewHalfWidth { - UIScreen.main.brightness = panBeganBrightness - changedValue - showBrightnessOverlay() - } else if let view = volumeView.subviews.first as? UISlider { - view.value = panBeganVolumeValue - Float(changedValue) - showVolumeOverlay() - } - default: - hideSystemControlOverlay() - } - } - - // MARK: setupOverlayHostingController - - private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { - // TODO: Look at injecting viewModel into the environment so it updates the current overlay - if let currentOverlayHostingController = currentOverlayHostingController { - // UX fade-out - UIView.animate(withDuration: 0.5) { - currentOverlayHostingController.view.alpha = 0 - } completion: { _ in - currentOverlayHostingController.view.isHidden = true - - currentOverlayHostingController.view.removeFromSuperview() - currentOverlayHostingController.removeFromParent() - } - } - - let newOverlayView = VLCPlayerOverlayView(viewModel: viewModel) - let newOverlayHostingController = UIHostingController(rootView: newOverlayView) - - newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newOverlayHostingController.view.backgroundColor = UIColor.clear - - // UX fade-in - newOverlayHostingController.view.alpha = 0 - - addChild(newOverlayHostingController) - view.addSubview(newOverlayHostingController.view) - newOverlayHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - - // UX fade-in - UIView.animate(withDuration: 0.5) { - newOverlayHostingController.view.alpha = 1 - } - - currentOverlayHostingController = newOverlayHostingController - - if let currentChapterOverlayHostingController = currentChapterOverlayHostingController { - UIView.animate(withDuration: 0.5) { - currentChapterOverlayHostingController.view.alpha = 0 - } completion: { _ in - currentChapterOverlayHostingController.view.isHidden = true - - currentChapterOverlayHostingController.view.removeFromSuperview() - currentChapterOverlayHostingController.removeFromParent() - } - } - - let newChapterOverlayView = VLCPlayerChapterOverlayView(viewModel: viewModel) - let newChapterOverlayHostingController = UIHostingController(rootView: newChapterOverlayView) - - newChapterOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newChapterOverlayHostingController.view.backgroundColor = UIColor.clear - - newChapterOverlayHostingController.view.alpha = 0 - - addChild(newChapterOverlayHostingController) - view.addSubview(newChapterOverlayHostingController.view) - newChapterOverlayHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newChapterOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newChapterOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newChapterOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newChapterOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - - currentChapterOverlayHostingController = newChapterOverlayHostingController - - // There is a weird behavior when after setting the new overlays that the navigation bar pops up, re-hide it - navigationController?.isNavigationBarHidden = true - } - - private func refreshJumpBackwardOverlayView(with jumpBackwardLength: VideoPlayerJumpLength) { - if let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView { - currentJumpBackwardOverlayView.removeFromSuperview() - } + // MARK: variables + + private var viewModel: VideoPlayerViewModel + private var vlcMediaPlayer: VLCMediaPlayer + private var lastPlayerTicks: Int64 = 0 + private var lastProgressReportTicks: Int64 = 0 + private var viewModelListeners = Set() + private var overlayDismissTimer: Timer? + private var isScreenFilled: Bool = false + private var pinchScale: CGFloat = 1 + + private var currentPlayerTicks: Int64 { + Int64(vlcMediaPlayer.time.intValue) * 100_000 + } + + private var displayingOverlay: Bool { + currentOverlayHostingController?.view.alpha ?? 0 > 0 + } + + private var displayingChapterOverlay: Bool { + currentChapterOverlayHostingController?.view.alpha ?? 0 > 0 + } + + private var panBeganBrightness = CGFloat.zero + private var panBeganVolumeValue = Float.zero + private var panBeganPoint = CGPoint.zero + + private lazy var videoContentView = makeVideoContentView() + private lazy var mainGestureView = makeMainGestureView() + private lazy var systemControlOverlayLabel = makeSystemControlOverlayLabel() + private var currentOverlayHostingController: UIHostingController? + private var currentChapterOverlayHostingController: UIHostingController? + private var currentJumpBackwardOverlayView: UIImageView? + private var currentJumpForwardOverlayView: UIImageView? + private var volumeView = MPVolumeView() + + override var keyCommands: [UIKeyCommand]? { + var commands = [ + UIKeyCommand(title: L10n.playAndPause, action: #selector(didSelectMain), input: " "), + UIKeyCommand(title: L10n.jumpForward, action: #selector(didSelectForward), input: UIKeyCommand.inputRightArrow), + UIKeyCommand(title: L10n.jumpBackward, action: #selector(didSelectBackward), input: UIKeyCommand.inputLeftArrow), + UIKeyCommand( + title: L10n.nextItem, + action: #selector(didSelectPlayNextItem), + input: UIKeyCommand.inputRightArrow, + modifierFlags: .command + ), + UIKeyCommand( + title: L10n.previousItem, + action: #selector(didSelectPlayPreviousItem), + input: UIKeyCommand.inputLeftArrow, + modifierFlags: .command + ), + UIKeyCommand(title: L10n.close, action: #selector(didSelectClose), input: UIKeyCommand.inputEscape), + ] + if let previous = viewModel.playbackSpeed.previous { + commands.append(.init( + title: "\(L10n.playbackSpeed) \(previous.displayTitle)", + action: #selector(didSelectPreviousPlaybackSpeed), + input: "[", + modifierFlags: .command + )) + } + if let next = viewModel.playbackSpeed.next { + commands.append(.init( + title: "\(L10n.playbackSpeed) \(next.displayTitle)", + action: #selector(didSelectNextPlaybackSpeed), + input: "]", + modifierFlags: .command + )) + } + if viewModel.playbackSpeed != .one { + commands.append(.init( + title: "\(L10n.playbackSpeed) \(PlaybackSpeed.one.displayTitle)", + action: #selector(didSelectNormalPlaybackSpeed), + input: "\\", + modifierFlags: .command + )) + } + commands.forEach { $0.wantsPriorityOverSystemBehavior = true } + return commands + } + + // MARK: init + + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + self.vlcMediaPlayer = VLCMediaPlayer() + + super.init(nibName: nil, bundle: nil) + + viewModel.playerOverlayDelegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupSubviews() { + view.addSubview(videoContentView) + view.addSubview(mainGestureView) + view.addSubview(systemControlOverlayLabel) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + videoContentView.topAnchor.constraint(equalTo: view.topAnchor), + videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), + videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor), + ]) + NSLayoutConstraint.activate([ + mainGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor), + mainGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + mainGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + mainGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) + NSLayoutConstraint.activate([ + systemControlOverlayLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + systemControlOverlayLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } + + // MARK: viewWillDisappear + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + NotificationCenter.default.removeObserver(self) + } + + // MARK: viewDidLoad + + override func viewDidLoad() { + super.viewDidLoad() + + setupSubviews() + setupConstraints() + + view.backgroundColor = .black + view.accessibilityIgnoresInvertColors = true + + setupMediaPlayer(newViewModel: viewModel) + + refreshJumpBackwardOverlayView(with: viewModel.jumpBackwardLength) + refreshJumpForwardOverlayView(with: viewModel.jumpForwardLength) + + let defaultNotificationCenter = NotificationCenter.default + defaultNotificationCenter.addObserver( + self, + selector: #selector(appWillTerminate), + name: UIApplication.willTerminateNotification, + object: nil + ) + defaultNotificationCenter.addObserver( + self, + selector: #selector(appWillResignActive), + name: UIApplication.willResignActiveNotification, + object: nil + ) + defaultNotificationCenter.addObserver( + self, + selector: #selector(appWillResignActive), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + } + + @objc + private func appWillTerminate() { + viewModel.sendStopReport() + } + + @objc + private func appWillResignActive() { + hideChaptersOverlay() + + showOverlay() + + stopOverlayDismissTimer() + + vlcMediaPlayer.pause() + + viewModel.sendPauseReport(paused: true) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + startPlayback() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + if isScreenFilled { + fillScreen(screenSize: size) + } + super.viewWillTransition(to: size, with: coordinator) + } + + // MARK: VideoContentView + + private func makeVideoContentView() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .black + + return view + } + + // MARK: MainGestureView + + private func makeMainGestureView() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + + let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) + + let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe)) + rightSwipeGesture.direction = .right + + let leftSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didLeftSwipe)) + leftSwipeGesture.direction = .left + + let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(didPinch(_:))) + + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(didPan(_:))) + + view.addGestureRecognizer(singleTapGesture) + view.addGestureRecognizer(pinchGesture) + + if viewModel.jumpGesturesEnabled { + view.addGestureRecognizer(rightSwipeGesture) + view.addGestureRecognizer(leftSwipeGesture) + } + + if viewModel.systemControlGesturesEnabled { + view.addGestureRecognizer(panGesture) + } + + return view + } + + // MARK: SystemControlOverlayLabel + + private func makeSystemControlOverlayLabel() -> UILabel { + let label = UILabel() + label.alpha = 0 + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 48) + return label + } + + @objc + private func didTap() { + didGenerallyTap(point: nil) + } + + @objc + private func didRightSwipe() { + didSelectForward() + } + + @objc + private func didLeftSwipe() { + didSelectBackward() + } + + @objc + private func didPinch(_ gestureRecognizer: UIPinchGestureRecognizer) { + if gestureRecognizer.state == .began || gestureRecognizer.state == .changed { + pinchScale = gestureRecognizer.scale + } else { + if pinchScale > 1, !isScreenFilled { + isScreenFilled.toggle() + fillScreen() + } else if pinchScale < 1, isScreenFilled { + isScreenFilled.toggle() + shrinkScreen() + } + } + } + + @objc + private func didPan(_ gestureRecognizer: UIPanGestureRecognizer) { + switch gestureRecognizer.state { + case .began: + panBeganBrightness = UIScreen.main.brightness + if let view = volumeView.subviews.first as? UISlider { + panBeganVolumeValue = view.value + } + panBeganPoint = gestureRecognizer.location(in: mainGestureView) + case .changed: + let mainGestureViewHalfWidth = mainGestureView.frame.width * 0.5 + let mainGestureViewHalfHeight = mainGestureView.frame.height * 0.5 + + let pos = gestureRecognizer.location(in: mainGestureView) + let moveDelta = pos.y - panBeganPoint.y + let changedValue = moveDelta / mainGestureViewHalfHeight + + if panBeganPoint.x < mainGestureViewHalfWidth { + UIScreen.main.brightness = panBeganBrightness - changedValue + showBrightnessOverlay() + } else if let view = volumeView.subviews.first as? UISlider { + view.value = panBeganVolumeValue - Float(changedValue) + showVolumeOverlay() + } + default: + hideSystemControlOverlay() + } + } + + // MARK: setupOverlayHostingController + + private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { + // TODO: Look at injecting viewModel into the environment so it updates the current overlay + if let currentOverlayHostingController = currentOverlayHostingController { + // UX fade-out + UIView.animate(withDuration: 0.5) { + currentOverlayHostingController.view.alpha = 0 + } completion: { _ in + currentOverlayHostingController.view.isHidden = true + + currentOverlayHostingController.view.removeFromSuperview() + currentOverlayHostingController.removeFromParent() + } + } + + let newOverlayView = VLCPlayerOverlayView(viewModel: viewModel) + let newOverlayHostingController = UIHostingController(rootView: newOverlayView) + + newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newOverlayHostingController.view.backgroundColor = UIColor.clear + + // UX fade-in + newOverlayHostingController.view.alpha = 0 + + addChild(newOverlayHostingController) + view.addSubview(newOverlayHostingController.view) + newOverlayHostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) + + // UX fade-in + UIView.animate(withDuration: 0.5) { + newOverlayHostingController.view.alpha = 1 + } + + currentOverlayHostingController = newOverlayHostingController + + if let currentChapterOverlayHostingController = currentChapterOverlayHostingController { + UIView.animate(withDuration: 0.5) { + currentChapterOverlayHostingController.view.alpha = 0 + } completion: { _ in + currentChapterOverlayHostingController.view.isHidden = true + + currentChapterOverlayHostingController.view.removeFromSuperview() + currentChapterOverlayHostingController.removeFromParent() + } + } + + let newChapterOverlayView = VLCPlayerChapterOverlayView(viewModel: viewModel) + let newChapterOverlayHostingController = UIHostingController(rootView: newChapterOverlayView) + + newChapterOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newChapterOverlayHostingController.view.backgroundColor = UIColor.clear + + newChapterOverlayHostingController.view.alpha = 0 + + addChild(newChapterOverlayHostingController) + view.addSubview(newChapterOverlayHostingController.view) + newChapterOverlayHostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + newChapterOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newChapterOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newChapterOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newChapterOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) + + currentChapterOverlayHostingController = newChapterOverlayHostingController + + // There is a weird behavior when after setting the new overlays that the navigation bar pops up, re-hide it + navigationController?.isNavigationBarHidden = true + } + + private func refreshJumpBackwardOverlayView(with jumpBackwardLength: VideoPlayerJumpLength) { + if let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView { + currentJumpBackwardOverlayView.removeFromSuperview() + } - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) - let backwardSymbolImage = UIImage(systemName: jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) - let newJumpBackwardImageView = UIImageView(image: backwardSymbolImage) + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) + let backwardSymbolImage = UIImage(systemName: jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) + let newJumpBackwardImageView = UIImageView(image: backwardSymbolImage) - newJumpBackwardImageView.translatesAutoresizingMaskIntoConstraints = false - newJumpBackwardImageView.tintColor = .white - - newJumpBackwardImageView.alpha = 0 + newJumpBackwardImageView.translatesAutoresizingMaskIntoConstraints = false + newJumpBackwardImageView.tintColor = .white + + newJumpBackwardImageView.alpha = 0 - view.addSubview(newJumpBackwardImageView) + view.addSubview(newJumpBackwardImageView) - NSLayoutConstraint.activate([ - newJumpBackwardImageView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 150), - newJumpBackwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) + NSLayoutConstraint.activate([ + newJumpBackwardImageView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 150), + newJumpBackwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) - currentJumpBackwardOverlayView = newJumpBackwardImageView - } + currentJumpBackwardOverlayView = newJumpBackwardImageView + } - private func refreshJumpForwardOverlayView(with jumpForwardLength: VideoPlayerJumpLength) { - if let currentJumpForwardOverlayView = currentJumpForwardOverlayView { - currentJumpForwardOverlayView.removeFromSuperview() - } + private func refreshJumpForwardOverlayView(with jumpForwardLength: VideoPlayerJumpLength) { + if let currentJumpForwardOverlayView = currentJumpForwardOverlayView { + currentJumpForwardOverlayView.removeFromSuperview() + } - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) - let forwardSymbolImage = UIImage(systemName: jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) - let newJumpForwardImageView = UIImageView(image: forwardSymbolImage) + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) + let forwardSymbolImage = UIImage(systemName: jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) + let newJumpForwardImageView = UIImageView(image: forwardSymbolImage) - newJumpForwardImageView.translatesAutoresizingMaskIntoConstraints = false - newJumpForwardImageView.tintColor = .white + newJumpForwardImageView.translatesAutoresizingMaskIntoConstraints = false + newJumpForwardImageView.tintColor = .white - newJumpForwardImageView.alpha = 0 + newJumpForwardImageView.alpha = 0 - view.addSubview(newJumpForwardImageView) + view.addSubview(newJumpForwardImageView) - NSLayoutConstraint.activate([ - newJumpForwardImageView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -150), - newJumpForwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) + NSLayoutConstraint.activate([ + newJumpForwardImageView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -150), + newJumpForwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) - currentJumpForwardOverlayView = newJumpForwardImageView - } + currentJumpForwardOverlayView = newJumpForwardImageView + } } // MARK: setupMediaPlayer extension LiveTVPlayerViewController { - /// Main function that handles setting up the media player with the current VideoPlayerViewModel - /// and also takes the role of setting the 'viewModel' property with the given viewModel - /// - /// Use case for this is setting new media within the same VLCPlayerViewController - func setupMediaPlayer(newViewModel: VideoPlayerViewModel) { - // remove old player + /// Main function that handles setting up the media player with the current VideoPlayerViewModel + /// and also takes the role of setting the 'viewModel' property with the given viewModel + /// + /// Use case for this is setting new media within the same VLCPlayerViewController + func setupMediaPlayer(newViewModel: VideoPlayerViewModel) { + // remove old player - if vlcMediaPlayer.media != nil { - viewModelListeners.forEach { $0.cancel() } + if vlcMediaPlayer.media != nil { + viewModelListeners.forEach { $0.cancel() } - vlcMediaPlayer.stop() - viewModel.sendStopReport() - viewModel.playerOverlayDelegate = nil - } + vlcMediaPlayer.stop() + viewModel.sendStopReport() + viewModel.playerOverlayDelegate = nil + } - vlcMediaPlayer = VLCMediaPlayer() + vlcMediaPlayer = VLCMediaPlayer() - // setup with new player and view model + // setup with new player and view model - vlcMediaPlayer = VLCMediaPlayer() + vlcMediaPlayer = VLCMediaPlayer() - vlcMediaPlayer.delegate = self - vlcMediaPlayer.drawable = videoContentView + vlcMediaPlayer.delegate = self + vlcMediaPlayer.drawable = videoContentView - vlcMediaPlayer.setSubtitleSize(Defaults[.subtitleSize]) + vlcMediaPlayer.setSubtitleSize(Defaults[.subtitleSize]) - stopOverlayDismissTimer() + stopOverlayDismissTimer() - lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - let media: VLCMedia + let media: VLCMedia - if let transcodedURL = newViewModel.transcodedStreamURL, - !Defaults[.Experimental.forceDirectPlay] - { - media = VLCMedia(url: transcodedURL) - } else { - media = VLCMedia(url: newViewModel.directStreamURL) - } + if let transcodedURL = newViewModel.transcodedStreamURL, + !Defaults[.Experimental.forceDirectPlay] + { + media = VLCMedia(url: transcodedURL) + } else { + media = VLCMedia(url: newViewModel.directStreamURL) + } - media.addOption("--prefetch-buffer-size=1048576") - media.addOption("--network-caching=5000") + media.addOption("--prefetch-buffer-size=1048576") + media.addOption("--network-caching=5000") - vlcMediaPlayer.media = media + vlcMediaPlayer.media = media - setupOverlayHostingController(viewModel: newViewModel) - setupViewModelListeners(viewModel: newViewModel) + setupOverlayHostingController(viewModel: newViewModel) + setupViewModelListeners(viewModel: newViewModel) - newViewModel.getAdjacentEpisodes() - newViewModel.playerOverlayDelegate = self + newViewModel.getAdjacentEpisodes() + newViewModel.playerOverlayDelegate = self - let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 + let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 - if startPercentage > 0 { - if viewModel.resumeOffset { - let runTimeTicks = viewModel.item.runTimeTicks ?? 0 - let videoDurationSeconds = Double(runTimeTicks / 10_000_000) - var startSeconds = round((startPercentage / 100) * videoDurationSeconds) - startSeconds = startSeconds.subtract(5, floor: 0) - let newStartPercentage = startSeconds / videoDurationSeconds - newViewModel.sliderPercentage = newStartPercentage - } else { - newViewModel.sliderPercentage = startPercentage / 100 - } - } + if startPercentage > 0 { + if viewModel.resumeOffset { + let runTimeTicks = viewModel.item.runTimeTicks ?? 0 + let videoDurationSeconds = Double(runTimeTicks / 10_000_000) + var startSeconds = round((startPercentage / 100) * videoDurationSeconds) + startSeconds = startSeconds.subtract(5, floor: 0) + let newStartPercentage = startSeconds / videoDurationSeconds + newViewModel.sliderPercentage = newStartPercentage + } else { + newViewModel.sliderPercentage = startPercentage / 100 + } + } - viewModel = newViewModel + viewModel = newViewModel - if viewModel.streamType == .direct { - LogManager.log.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? "--")") - } else if viewModel.streamType == .transcode && Defaults[.Experimental.forceDirectPlay] { - LogManager.log.debug("Player set up with forced direct stream for item: \(viewModel.item.id ?? "--")") - } else { - LogManager.log.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? "--")") - } - } + if viewModel.streamType == .direct { + LogManager.log.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? "--")") + } else if viewModel.streamType == .transcode && Defaults[.Experimental.forceDirectPlay] { + LogManager.log.debug("Player set up with forced direct stream for item: \(viewModel.item.id ?? "--")") + } else { + LogManager.log.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? "--")") + } + } - // MARK: startPlayback + // MARK: startPlayback - func startPlayback() { - vlcMediaPlayer.play() + func startPlayback() { + vlcMediaPlayer.play() - // Setup external subtitles - for externalSubtitle in viewModel.subtitleStreams.filter({ $0.deliveryMethod == .external }) { - if let deliveryURL = externalSubtitle.externalURL(base: SessionManager.main.currentLogin.server.currentURI) { - vlcMediaPlayer.addPlaybackSlave(deliveryURL, type: .subtitle, enforce: false) - } - } + // Setup external subtitles + for externalSubtitle in viewModel.subtitleStreams.filter({ $0.deliveryMethod == .external }) { + if let deliveryURL = externalSubtitle.externalURL(base: SessionManager.main.currentLogin.server.currentURI) { + vlcMediaPlayer.addPlaybackSlave(deliveryURL, type: .subtitle, enforce: false) + } + } - setMediaPlayerTimeAtCurrentSlider() + setMediaPlayerTimeAtCurrentSlider() - viewModel.sendPlayReport() + viewModel.sendPlayReport() - restartOverlayDismissTimer() - } + restartOverlayDismissTimer() + } - // MARK: setupViewModelListeners + // MARK: setupViewModelListeners - private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { - viewModel.$playbackSpeed.sink { newSpeed in - self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) - }.store(in: &viewModelListeners) + private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { + viewModel.$playbackSpeed.sink { newSpeed in + self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) + }.store(in: &viewModelListeners) - viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in - if sliderIsScrubbing { - self.didBeginScrubbing() - } else { - self.didEndScrubbing() - } - }.store(in: &viewModelListeners) + viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in + if sliderIsScrubbing { + self.didBeginScrubbing() + } else { + self.didEndScrubbing() + } + }.store(in: &viewModelListeners) - viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in - self.didSelectAudioStream(index: newAudioStreamIndex) - }.store(in: &viewModelListeners) + viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in + self.didSelectAudioStream(index: newAudioStreamIndex) + }.store(in: &viewModelListeners) - viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in - self.didSelectSubtitleStream(index: newSubtitleStreamIndex) - }.store(in: &viewModelListeners) + viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in + self.didSelectSubtitleStream(index: newSubtitleStreamIndex) + }.store(in: &viewModelListeners) - viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in - self.didToggleSubtitles(newValue: newSubtitlesEnabled) - }.store(in: &viewModelListeners) + viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in + self.didToggleSubtitles(newValue: newSubtitlesEnabled) + }.store(in: &viewModelListeners) - viewModel.$jumpBackwardLength.sink { newJumpBackwardLength in - self.refreshJumpBackwardOverlayView(with: newJumpBackwardLength) - }.store(in: &viewModelListeners) + viewModel.$jumpBackwardLength.sink { newJumpBackwardLength in + self.refreshJumpBackwardOverlayView(with: newJumpBackwardLength) + }.store(in: &viewModelListeners) - viewModel.$jumpForwardLength.sink { newJumpForwardLength in - self.refreshJumpForwardOverlayView(with: newJumpForwardLength) - }.store(in: &viewModelListeners) - } + viewModel.$jumpForwardLength.sink { newJumpForwardLength in + self.refreshJumpForwardOverlayView(with: newJumpForwardLength) + }.store(in: &viewModelListeners) + } - func setMediaPlayerTimeAtCurrentSlider() { - // Necessary math as VLCMediaPlayer doesn't work well - // by just setting the position - let runTimeTicks = viewModel.item.runTimeTicks ?? 0 - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let videoDuration = Double(runTimeTicks / 10_000_000) - let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) - let newPositionOffset = secondsScrubbedTo - videoPosition + func setMediaPlayerTimeAtCurrentSlider() { + // Necessary math as VLCMediaPlayer doesn't work well + // by just setting the position + let runTimeTicks = viewModel.item.runTimeTicks ?? 0 + let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) + let videoDuration = Double(runTimeTicks / 10_000_000) + let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) + let newPositionOffset = secondsScrubbedTo - videoPosition - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } - } + if newPositionOffset > 0 { + vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) + } else { + vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) + } + } } // MARK: Show/Hide Overlay extension LiveTVPlayerViewController { - private func showOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } + private func showOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } - guard overlayHostingController.view.alpha != 1 else { return } + guard overlayHostingController.view.alpha != 1 else { return } - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 1 - } - } + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 1 + } + } - private func hideOverlay() { - guard !UIAccessibility.isVoiceOverRunning else { return } + private func hideOverlay() { + guard !UIAccessibility.isVoiceOverRunning else { return } - guard let overlayHostingController = currentOverlayHostingController else { return } + guard let overlayHostingController = currentOverlayHostingController else { return } - guard overlayHostingController.view.alpha != 0 else { return } + guard overlayHostingController.view.alpha != 0 else { return } - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 0 - } - } + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 0 + } + } - private func toggleOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } + private func toggleOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } - if overlayHostingController.view.alpha < 1 { - showOverlay() - } else { - hideOverlay() - } - } + if overlayHostingController.view.alpha < 1 { + showOverlay() + } else { + hideOverlay() + } + } } // MARK: Show/Hide System Control extension LiveTVPlayerViewController { - private func showBrightnessOverlay() { - guard !displayingOverlay else { return } + private func showBrightnessOverlay() { + guard !displayingOverlay else { return } - let imageAttachment = NSTextAttachment() - imageAttachment.image = UIImage(systemName: "sun.max", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? - .withTintColor(.white) + let imageAttachment = NSTextAttachment() + imageAttachment.image = UIImage(systemName: "sun.max", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? + .withTintColor(.white) - let attributedString = NSMutableAttributedString() - attributedString.append(.init(attachment: imageAttachment)) - attributedString.append(.init(string: " \(String(format: "%.0f", UIScreen.main.brightness * 100))%")) - systemControlOverlayLabel.attributedText = attributedString - systemControlOverlayLabel.layer.removeAllAnimations() + let attributedString = NSMutableAttributedString() + attributedString.append(.init(attachment: imageAttachment)) + attributedString.append(.init(string: " \(String(format: "%.0f", UIScreen.main.brightness * 100))%")) + systemControlOverlayLabel.attributedText = attributedString + systemControlOverlayLabel.layer.removeAllAnimations() - UIView.animate(withDuration: 0.1) { - self.systemControlOverlayLabel.alpha = 1 - } - } + UIView.animate(withDuration: 0.1) { + self.systemControlOverlayLabel.alpha = 1 + } + } - private func showVolumeOverlay() { - guard !displayingOverlay, - let value = (volumeView.subviews.first as? UISlider)?.value else { return } + private func showVolumeOverlay() { + guard !displayingOverlay, + let value = (volumeView.subviews.first as? UISlider)?.value else { return } - let imageAttachment = NSTextAttachment() - imageAttachment.image = UIImage(systemName: "speaker.wave.2", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? - .withTintColor(.white) + let imageAttachment = NSTextAttachment() + imageAttachment.image = UIImage(systemName: "speaker.wave.2", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? + .withTintColor(.white) - let attributedString = NSMutableAttributedString() - attributedString.append(.init(attachment: imageAttachment)) - attributedString.append(.init(string: " \(String(format: "%.0f", value * 100))%")) - systemControlOverlayLabel.attributedText = attributedString - systemControlOverlayLabel.layer.removeAllAnimations() + let attributedString = NSMutableAttributedString() + attributedString.append(.init(attachment: imageAttachment)) + attributedString.append(.init(string: " \(String(format: "%.0f", value * 100))%")) + systemControlOverlayLabel.attributedText = attributedString + systemControlOverlayLabel.layer.removeAllAnimations() - UIView.animate(withDuration: 0.1) { - self.systemControlOverlayLabel.alpha = 1 - } - } + UIView.animate(withDuration: 0.1) { + self.systemControlOverlayLabel.alpha = 1 + } + } - private func hideSystemControlOverlay() { - UIView.animate(withDuration: 0.75) { - self.systemControlOverlayLabel.alpha = 0 - } - } + private func hideSystemControlOverlay() { + UIView.animate(withDuration: 0.75) { + self.systemControlOverlayLabel.alpha = 0 + } + } } // MARK: Show/Hide Jump extension LiveTVPlayerViewController { - private func flashJumpBackwardOverlay() { - guard !displayingOverlay, let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } + private func flashJumpBackwardOverlay() { + guard !displayingOverlay, let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } - currentJumpBackwardOverlayView.layer.removeAllAnimations() + currentJumpBackwardOverlayView.layer.removeAllAnimations() - UIView.animate(withDuration: 0.1) { - currentJumpBackwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpBackwardOverlay() - } - } + UIView.animate(withDuration: 0.1) { + currentJumpBackwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpBackwardOverlay() + } + } - private func hideJumpBackwardOverlay() { - guard let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } + private func hideJumpBackwardOverlay() { + guard let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } - UIView.animate(withDuration: 0.3) { - currentJumpBackwardOverlayView.alpha = 0 - } - } + UIView.animate(withDuration: 0.3) { + currentJumpBackwardOverlayView.alpha = 0 + } + } - private func flashJumpFowardOverlay() { - guard !displayingOverlay, let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } + private func flashJumpFowardOverlay() { + guard !displayingOverlay, let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } - currentJumpForwardOverlayView.layer.removeAllAnimations() + currentJumpForwardOverlayView.layer.removeAllAnimations() - UIView.animate(withDuration: 0.1) { - currentJumpForwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpForwardOverlay() - } - } + UIView.animate(withDuration: 0.1) { + currentJumpForwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpForwardOverlay() + } + } - private func hideJumpForwardOverlay() { - guard let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } + private func hideJumpForwardOverlay() { + guard let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } - UIView.animate(withDuration: 0.3) { - currentJumpForwardOverlayView.alpha = 0 - } - } + UIView.animate(withDuration: 0.3) { + currentJumpForwardOverlayView.alpha = 0 + } + } } // MARK: Hide/Show Chapters extension LiveTVPlayerViewController { - private func showChaptersOverlay() { - guard let overlayHostingController = currentChapterOverlayHostingController else { return } + private func showChaptersOverlay() { + guard let overlayHostingController = currentChapterOverlayHostingController else { return } - guard overlayHostingController.view.alpha != 1 else { return } + guard overlayHostingController.view.alpha != 1 else { return } - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 1 - } - } + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 1 + } + } - private func hideChaptersOverlay() { - guard let overlayHostingController = currentChapterOverlayHostingController else { return } + private func hideChaptersOverlay() { + guard let overlayHostingController = currentChapterOverlayHostingController else { return } - guard overlayHostingController.view.alpha != 0 else { return } + guard overlayHostingController.view.alpha != 0 else { return } - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 0 - } - } + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 0 + } + } } // MARK: OverlayTimer extension LiveTVPlayerViewController { - private func restartOverlayDismissTimer(interval: Double = 3) { - overlayDismissTimer?.invalidate() - overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), - userInfo: nil, repeats: false) - } + private func restartOverlayDismissTimer(interval: Double = 3) { + overlayDismissTimer?.invalidate() + overlayDismissTimer = Timer.scheduledTimer( + timeInterval: interval, + target: self, + selector: #selector(dismissTimerFired), + userInfo: nil, + repeats: false + ) + } - @objc - private func dismissTimerFired() { - hideOverlay() - } + @objc + private func dismissTimerFired() { + hideOverlay() + } - private func stopOverlayDismissTimer() { - overlayDismissTimer?.invalidate() - } + private func stopOverlayDismissTimer() { + overlayDismissTimer?.invalidate() + } } // MARK: VLCMediaPlayerDelegate extension LiveTVPlayerViewController: VLCMediaPlayerDelegate { - // MARK: mediaPlayerStateChanged + // MARK: mediaPlayerStateChanged - func mediaPlayerStateChanged(_ aNotification: Notification) { - // Don't show buffering if paused, usually here while scrubbing - if vlcMediaPlayer.state == .buffering, viewModel.playerState == .paused { - return - } + func mediaPlayerStateChanged(_ aNotification: Notification) { + // Don't show buffering if paused, usually here while scrubbing + if vlcMediaPlayer.state == .buffering, viewModel.playerState == .paused { + return + } - viewModel.playerState = vlcMediaPlayer.state + viewModel.playerState = vlcMediaPlayer.state - if vlcMediaPlayer.state == VLCMediaPlayerState.ended { - if viewModel.autoplayEnabled, viewModel.nextItemVideoPlayerViewModel != nil { - didSelectPlayNextItem() - } else { - didSelectClose() - } - } - } + if vlcMediaPlayer.state == VLCMediaPlayerState.ended { + if viewModel.autoplayEnabled, viewModel.nextItemVideoPlayerViewModel != nil { + didSelectPlayNextItem() + } else { + didSelectClose() + } + } + } - // MARK: mediaPlayerTimeChanged + // MARK: mediaPlayerTimeChanged - func mediaPlayerTimeChanged(_ aNotification: Notification) { - if !viewModel.sliderIsScrubbing { - viewModel.sliderPercentage = Double(vlcMediaPlayer.position) - } + func mediaPlayerTimeChanged(_ aNotification: Notification) { + if !viewModel.sliderIsScrubbing { + viewModel.sliderPercentage = Double(vlcMediaPlayer.position) + } - // Have to manually set playing because VLCMediaPlayer doesn't - // properly set it itself - if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 { - viewModel.playerState = VLCMediaPlayerState.playing - } + // Have to manually set playing because VLCMediaPlayer doesn't + // properly set it itself + if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 { + viewModel.playerState = VLCMediaPlayerState.playing + } - // If needing to fix subtitle streams during playback - if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.selectedSubtitleStreamIndex, - viewModel.subtitlesEnabled - { - didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) - } + // If needing to fix subtitle streams during playback + if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.selectedSubtitleStreamIndex, + viewModel.subtitlesEnabled + { + didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) + } - // If needing to fix audio stream during playback - if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { - didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) - } + // If needing to fix audio stream during playback + if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { + didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) + } - lastPlayerTicks = currentPlayerTicks + lastPlayerTicks = currentPlayerTicks - // Send progress report every 5 seconds - if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { - viewModel.sendProgressReport() + // Send progress report every 5 seconds + if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } - } + lastProgressReportTicks = currentPlayerTicks + } + } } // MARK: PlayerOverlayDelegate and more extension LiveTVPlayerViewController: PlayerOverlayDelegate { - func didSelectAudioStream(index: Int) { - vlcMediaPlayer.currentAudioTrackIndex = Int32(index) + func didSelectAudioStream(index: Int) { + vlcMediaPlayer.currentAudioTrackIndex = Int32(index) - viewModel.sendProgressReport() + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } + lastProgressReportTicks = currentPlayerTicks + } - /// Do not call when setting to index -1 - func didSelectSubtitleStream(index: Int) { - viewModel.subtitlesEnabled = true - vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) + /// Do not call when setting to index -1 + func didSelectSubtitleStream(index: Int) { + viewModel.subtitlesEnabled = true + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) - viewModel.sendProgressReport() + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } + lastProgressReportTicks = currentPlayerTicks + } - @objc - func didSelectClose() { - vlcMediaPlayer.stop() + @objc + func didSelectClose() { + vlcMediaPlayer.stop() - viewModel.sendStopReport() + viewModel.sendStopReport() - dismiss(animated: true, completion: nil) - } + dismiss(animated: true, completion: nil) + } - func didToggleSubtitles(newValue: Bool) { - if newValue { - vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex) - } else { - vlcMediaPlayer.currentVideoSubTitleIndex = -1 - } - } + func didToggleSubtitles(newValue: Bool) { + if newValue { + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex) + } else { + vlcMediaPlayer.currentVideoSubTitleIndex = -1 + } + } - // TODO: Implement properly in overlays - func didSelectMenu() { - stopOverlayDismissTimer() - } + // TODO: Implement properly in overlays + func didSelectMenu() { + stopOverlayDismissTimer() + } - // TODO: Implement properly in overlays - func didDeselectMenu() { - restartOverlayDismissTimer() - } + // TODO: Implement properly in overlays + func didDeselectMenu() { + restartOverlayDismissTimer() + } - @objc - func didSelectBackward() { - flashJumpBackwardOverlay() + @objc + func didSelectBackward() { + flashJumpBackwardOverlay() - vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) + vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) - if displayingOverlay { - restartOverlayDismissTimer() - } + if displayingOverlay { + restartOverlayDismissTimer() + } - viewModel.sendProgressReport() + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } + lastProgressReportTicks = currentPlayerTicks + } - @objc - func didSelectForward() { - flashJumpFowardOverlay() + @objc + func didSelectForward() { + flashJumpFowardOverlay() - vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) + vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) - if displayingOverlay { - restartOverlayDismissTimer() - } + if displayingOverlay { + restartOverlayDismissTimer() + } - viewModel.sendProgressReport() + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } + lastProgressReportTicks = currentPlayerTicks + } - @objc - func didSelectMain() { - switch viewModel.playerState { - case .buffering: - vlcMediaPlayer.play() - restartOverlayDismissTimer() - case .playing: - viewModel.sendPauseReport(paused: true) - vlcMediaPlayer.pause() - restartOverlayDismissTimer(interval: 5) - case .paused: - viewModel.sendPauseReport(paused: false) - vlcMediaPlayer.play() - restartOverlayDismissTimer() - default: () - } - } + @objc + func didSelectMain() { + switch viewModel.playerState { + case .buffering: + vlcMediaPlayer.play() + restartOverlayDismissTimer() + case .playing: + viewModel.sendPauseReport(paused: true) + vlcMediaPlayer.pause() + restartOverlayDismissTimer(interval: 5) + case .paused: + viewModel.sendPauseReport(paused: false) + vlcMediaPlayer.play() + restartOverlayDismissTimer() + default: () + } + } - func didGenerallyTap(point: CGPoint?) { - toggleOverlay() + func didGenerallyTap(point: CGPoint?) { + toggleOverlay() - restartOverlayDismissTimer(interval: 5) - } + restartOverlayDismissTimer(interval: 5) + } - func didLongPress() {} + func didLongPress() {} - func didBeginScrubbing() { - stopOverlayDismissTimer() - } + func didBeginScrubbing() { + stopOverlayDismissTimer() + } - func didEndScrubbing() { - setMediaPlayerTimeAtCurrentSlider() + func didEndScrubbing() { + setMediaPlayerTimeAtCurrentSlider() - restartOverlayDismissTimer() + restartOverlayDismissTimer() - viewModel.sendProgressReport() + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } + lastProgressReportTicks = currentPlayerTicks + } - @objc - func didSelectPlayPreviousItem() { - if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel { - setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel) - startPlayback() - } - } + @objc + func didSelectPlayPreviousItem() { + if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel { + setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel) + startPlayback() + } + } - @objc - func didSelectPlayNextItem() { - if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel { - setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel) - startPlayback() - } - } + @objc + func didSelectPlayNextItem() { + if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel { + setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel) + startPlayback() + } + } - @objc - func didSelectPreviousPlaybackSpeed() { - if let previousPlaybackSpeed = viewModel.playbackSpeed.previous { - viewModel.playbackSpeed = previousPlaybackSpeed - } - } + @objc + func didSelectPreviousPlaybackSpeed() { + if let previousPlaybackSpeed = viewModel.playbackSpeed.previous { + viewModel.playbackSpeed = previousPlaybackSpeed + } + } - @objc - func didSelectNextPlaybackSpeed() { - if let nextPlaybackSpeed = viewModel.playbackSpeed.next { - viewModel.playbackSpeed = nextPlaybackSpeed - } - } + @objc + func didSelectNextPlaybackSpeed() { + if let nextPlaybackSpeed = viewModel.playbackSpeed.next { + viewModel.playbackSpeed = nextPlaybackSpeed + } + } - @objc - func didSelectNormalPlaybackSpeed() { - viewModel.playbackSpeed = .one - } + @objc + func didSelectNormalPlaybackSpeed() { + viewModel.playbackSpeed = .one + } - func didSelectChapters() { - if displayingChapterOverlay { - hideChaptersOverlay() - } else { - hideOverlay() - showChaptersOverlay() - } - } + func didSelectChapters() { + if displayingChapterOverlay { + hideChaptersOverlay() + } else { + hideOverlay() + showChaptersOverlay() + } + } - func didSelectChapter(_ chapter: ChapterInfo) { - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let chapterSeconds = Double((chapter.startPositionTicks ?? 0) / 10_000_000) - let newPositionOffset = chapterSeconds - videoPosition + func didSelectChapter(_ chapter: ChapterInfo) { + let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) + let chapterSeconds = Double((chapter.startPositionTicks ?? 0) / 10_000_000) + let newPositionOffset = chapterSeconds - videoPosition - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } + if newPositionOffset > 0 { + vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) + } else { + vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) + } - viewModel.sendProgressReport() - } + viewModel.sendProgressReport() + } - func didSelectScreenFill() { - isScreenFilled.toggle() + func didSelectScreenFill() { + isScreenFilled.toggle() - if isScreenFilled { - fillScreen() - } else { - shrinkScreen() - } - } + if isScreenFilled { + fillScreen() + } else { + shrinkScreen() + } + } - private func fillScreen(screenSize: CGSize = UIScreen.main.bounds.size) { - let videoSize = vlcMediaPlayer.videoSize - let fillSize = CGSize.aspectFill(aspectRatio: videoSize, minimumSize: screenSize) + private func fillScreen(screenSize: CGSize = UIScreen.main.bounds.size) { + let videoSize = vlcMediaPlayer.videoSize + let fillSize = CGSize.aspectFill(aspectRatio: videoSize, minimumSize: screenSize) - let scale: CGFloat + let scale: CGFloat - if fillSize.height > screenSize.height { - scale = fillSize.height / screenSize.height - } else { - scale = fillSize.width / screenSize.width - } + if fillSize.height > screenSize.height { + scale = fillSize.height / screenSize.height + } else { + scale = fillSize.width / screenSize.width + } - UIView.animate(withDuration: 0.2) { - self.videoContentView.transform = CGAffineTransform(scaleX: scale, y: scale) - } - } + UIView.animate(withDuration: 0.2) { + self.videoContentView.transform = CGAffineTransform(scaleX: scale, y: scale) + } + } - private func shrinkScreen() { - UIView.animate(withDuration: 0.2) { - self.videoContentView.transform = .identity - } - } + private func shrinkScreen() { + UIView.animate(withDuration: 0.2) { + self.videoContentView.transform = .identity + } + } - func getScreenFilled() -> Bool { - isScreenFilled - } + func getScreenFilled() -> Bool { + isScreenFilled + } - func isVideoAspectRatioGreater() -> Bool { - let screenSize = UIScreen.main.bounds.size - let videoSize = vlcMediaPlayer.videoSize + func isVideoAspectRatioGreater() -> Bool { + let screenSize = UIScreen.main.bounds.size + let videoSize = vlcMediaPlayer.videoSize - let screenAspectRatio = screenSize.width / screenSize.height - let videoAspectRatio = videoSize.width / videoSize.height + let screenAspectRatio = screenSize.width / screenSize.height + let videoAspectRatio = videoSize.width / videoSize.height - return videoAspectRatio > screenAspectRatio - } + return videoAspectRatio > screenAspectRatio + } } diff --git a/Swiftfin/Views/VideoPlayer/NativePlayerViewController.swift b/Swiftfin/Views/VideoPlayer/NativePlayerViewController.swift index 07e31d5c..f2711b34 100644 --- a/Swiftfin/Views/VideoPlayer/NativePlayerViewController.swift +++ b/Swiftfin/Views/VideoPlayer/NativePlayerViewController.swift @@ -13,102 +13,106 @@ import UIKit class NativePlayerViewController: AVPlayerViewController { - let viewModel: VideoPlayerViewModel + let viewModel: VideoPlayerViewModel - var timeObserverToken: Any? + var timeObserverToken: Any? - var lastProgressTicks: Int64 = 0 + var lastProgressTicks: Int64 = 0 - private var cancellables = Set() + private var cancellables = Set() - init(viewModel: VideoPlayerViewModel) { + init(viewModel: VideoPlayerViewModel) { - self.viewModel = viewModel + self.viewModel = viewModel - super.init(nibName: nil, bundle: nil) + super.init(nibName: nil, bundle: nil) - let player: AVPlayer + let player: AVPlayer - if let transcodedStreamURL = viewModel.transcodedStreamURL { - player = AVPlayer(url: transcodedStreamURL) - } else { - player = AVPlayer(url: viewModel.hlsStreamURL) - } + if let transcodedStreamURL = viewModel.transcodedStreamURL { + player = AVPlayer(url: transcodedStreamURL) + } else { + player = AVPlayer(url: viewModel.hlsStreamURL) + } - player.appliesMediaSelectionCriteriaAutomatically = false + player.appliesMediaSelectionCriteriaAutomatically = false - let timeScale = CMTimeScale(NSEC_PER_SEC) - let time = CMTime(seconds: 5, preferredTimescale: timeScale) + let timeScale = CMTimeScale(NSEC_PER_SEC) + let time = CMTime(seconds: 5, preferredTimescale: timeScale) - timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in - if time.seconds != 0 { - self?.sendProgressReport(seconds: time.seconds) - } - } + timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in + if time.seconds != 0 { + self?.sendProgressReport(seconds: time.seconds) + } + } - self.player = player + self.player = player - self.allowsPictureInPicturePlayback = true - self.player?.allowsExternalPlayback = true - } + self.allowsPictureInPicturePlayback = true + self.player?.allowsExternalPlayback = true + } - private func createMetadataItem(for identifier: AVMetadataIdentifier, - value: Any) -> AVMetadataItem - { - let item = AVMutableMetadataItem() - item.identifier = identifier - item.value = value as? NSCopying & NSObjectProtocol - // Specify "und" to indicate an undefined language. - item.extendedLanguageTag = "und" - return item.copy() as! AVMetadataItem - } + private func createMetadataItem( + for identifier: AVMetadataIdentifier, + value: Any + ) -> AVMetadataItem { + let item = AVMutableMetadataItem() + item.identifier = identifier + item.value = value as? NSCopying & NSObjectProtocol + // Specify "und" to indicate an undefined language. + item.extendedLanguageTag = "und" + return item.copy() as! AVMetadataItem + } - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } - override func viewDidLoad() { - super.viewDidLoad() - } + override func viewDidLoad() { + super.viewDidLoad() + } - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) - stop() - removePeriodicTimeObserver() - } + stop() + removePeriodicTimeObserver() + } - func removePeriodicTimeObserver() { - if let timeObserverToken = timeObserverToken { - player?.removeTimeObserver(timeObserverToken) - self.timeObserverToken = nil - } - } + func removePeriodicTimeObserver() { + if let timeObserverToken = timeObserverToken { + player?.removeTimeObserver(timeObserverToken) + self.timeObserverToken = nil + } + } - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) - player?.seek(to: CMTimeMake(value: viewModel.currentSecondTicks, timescale: 10_000_000), - toleranceBefore: CMTimeMake(value: 1, timescale: 1), toleranceAfter: CMTimeMake(value: 1, timescale: 1), - completionHandler: { _ in - self.play() - }) - } + player?.seek( + to: CMTimeMake(value: viewModel.currentSecondTicks, timescale: 10_000_000), + toleranceBefore: CMTimeMake(value: 1, timescale: 1), + toleranceAfter: CMTimeMake(value: 1, timescale: 1), + completionHandler: { _ in + self.play() + } + ) + } - private func play() { - player?.play() + private func play() { + player?.play() - viewModel.sendPlayReport() - } + viewModel.sendPlayReport() + } - private func sendProgressReport(seconds: Double) { - viewModel.setSeconds(Int64(seconds)) - viewModel.sendProgressReport() - } + private func sendProgressReport(seconds: Double) { + viewModel.setSeconds(Int64(seconds)) + viewModel.sendProgressReport() + } - private func stop() { - self.player?.pause() - viewModel.sendStopReport() - } + private func stop() { + self.player?.pause() + viewModel.sendStopReport() + } } diff --git a/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerChapterOverlayView.swift b/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerChapterOverlayView.swift index 0ffc67e6..4a12f772 100644 --- a/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerChapterOverlayView.swift +++ b/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerChapterOverlayView.swift @@ -11,93 +11,95 @@ import SwiftUI struct VLCPlayerChapterOverlayView: View { - @ObservedObject - var viewModel: VideoPlayerViewModel - private let chapterImages: [URL] + @ObservedObject + var viewModel: VideoPlayerViewModel + private let chapterImages: [URL] - init(viewModel: VideoPlayerViewModel) { - self.viewModel = viewModel - self.chapterImages = viewModel.item.getChapterImage(maxWidth: 500) - } + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + self.chapterImages = viewModel.item.getChapterImage(maxWidth: 500) + } - @ViewBuilder - private var mainBody: some View { - ZStack(alignment: .bottom) { + @ViewBuilder + private var mainBody: some View { + ZStack(alignment: .bottom) { - LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), - startPoint: .top, - endPoint: .bottom) - .ignoresSafeArea() - .frame(height: 300) + LinearGradient( + gradient: Gradient(colors: [.clear, .black.opacity(0.8), .black]), + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + .frame(height: 300) - VStack { - Spacer() + VStack { + Spacer() - VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 0) { - L10n.chapters.text - .font(.title3) - .fontWeight(.bold) - .padding(.leading) + L10n.chapters.text + .font(.title3) + .fontWeight(.bold) + .padding(.leading) - ScrollView(.horizontal, showsIndicators: false) { - ScrollViewReader { reader in - HStack { - ForEach(0 ..< viewModel.chapters.count) { chapterIndex in - VStack(alignment: .leading) { - Button { - viewModel.playerOverlayDelegate?.didSelectChapter(viewModel.chapters[chapterIndex]) - } label: { - ImageView(chapterImages[chapterIndex]) - .cornerRadius(10) - .frame(width: 150, height: 100) - .overlay { - if viewModel.chapters[chapterIndex] == viewModel.currentChapter { - RoundedRectangle(cornerRadius: 6) - .stroke(Color.jellyfinPurple, lineWidth: 4) - } - } - } + ScrollView(.horizontal, showsIndicators: false) { + ScrollViewReader { reader in + HStack { + ForEach(0 ..< viewModel.chapters.count) { chapterIndex in + VStack(alignment: .leading) { + Button { + viewModel.playerOverlayDelegate?.didSelectChapter(viewModel.chapters[chapterIndex]) + } label: { + ImageView(chapterImages[chapterIndex]) + .cornerRadius(10) + .frame(width: 150, height: 100) + .overlay { + if viewModel.chapters[chapterIndex] == viewModel.currentChapter { + RoundedRectangle(cornerRadius: 6) + .stroke(Color.jellyfinPurple, lineWidth: 4) + } + } + } - VStack(alignment: .leading, spacing: 5) { + VStack(alignment: .leading, spacing: 5) { - Text(viewModel.chapters[chapterIndex].name ?? L10n.noTitle) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.white) + Text(viewModel.chapters[chapterIndex].name ?? L10n.noTitle) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.white) - Text(viewModel.chapters[chapterIndex].timestampLabel) - .font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(Color(UIColor.systemBlue)) - .padding(.vertical, 2) - .padding(.horizontal, 4) - .background { - Color(UIColor.darkGray).opacity(0.2).cornerRadius(4) - } - } - } - .id(viewModel.chapters[chapterIndex]) - } - } - .padding(.top) - .onAppear { - reader.scrollTo(viewModel.currentChapter) - } - } - } - } - .padding(.bottom) - } - } - } + Text(viewModel.chapters[chapterIndex].timestampLabel) + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(Color(UIColor.systemBlue)) + .padding(.vertical, 2) + .padding(.horizontal, 4) + .background { + Color(UIColor.darkGray).opacity(0.2).cornerRadius(4) + } + } + } + .id(viewModel.chapters[chapterIndex]) + } + } + .padding(.top) + .onAppear { + reader.scrollTo(viewModel.currentChapter) + } + } + } + } + .padding(.bottom) + } + } + } - var body: some View { - mainBody - .edgesIgnoringSafeArea(.bottom) - .contentShape(Rectangle()) - .onTapGesture { - viewModel.playerOverlayDelegate?.didSelectChapters() - } - } + var body: some View { + mainBody + .edgesIgnoringSafeArea(.bottom) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.playerOverlayDelegate?.didSelectChapters() + } + } } diff --git a/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift b/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift index 79e21cf5..ffcc0be4 100644 --- a/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift +++ b/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift @@ -14,502 +14,514 @@ import Sliders import SwiftUI struct VLCPlayerOverlayView: View { - @ObservedObject - var viewModel: VideoPlayerViewModel + @ObservedObject + var viewModel: VideoPlayerViewModel - @ViewBuilder - private var mainButtonView: some View { - if viewModel.overlayType == .normal { - switch viewModel.playerState { - case .stopped, .paused: - Image(systemName: "play.fill") - .font(.system(size: 56, weight: .semibold, design: .default)) - case .playing: - Image(systemName: "pause") - .font(.system(size: 56, weight: .semibold, design: .default)) - default: - ProgressView() - .scaleEffect(2) - } - } else if viewModel.overlayType == .compact { - switch viewModel.playerState { - case .stopped, .paused: - Image(systemName: "play.fill") - .font(.system(size: 28, weight: .heavy, design: .default)) - case .playing: - Image(systemName: "pause") - .font(.system(size: 28, weight: .heavy, design: .default)) - default: - ProgressView() - } - } - } + @ViewBuilder + private var mainButtonView: some View { + if viewModel.overlayType == .normal { + switch viewModel.playerState { + case .stopped, .paused: + Image(systemName: "play.fill") + .font(.system(size: 56, weight: .semibold, design: .default)) + case .playing: + Image(systemName: "pause") + .font(.system(size: 56, weight: .semibold, design: .default)) + default: + ProgressView() + .scaleEffect(2) + } + } else if viewModel.overlayType == .compact { + switch viewModel.playerState { + case .stopped, .paused: + Image(systemName: "play.fill") + .font(.system(size: 28, weight: .heavy, design: .default)) + case .playing: + Image(systemName: "pause") + .font(.system(size: 28, weight: .heavy, design: .default)) + default: + ProgressView() + } + } + } - @ViewBuilder - private var mainBody: some View { - VStack { - // MARK: Top Bar + @ViewBuilder + private var mainBody: some View { + VStack { + // MARK: Top Bar - ZStack(alignment: .top) { - if viewModel.overlayType == .compact { - LinearGradient(gradient: Gradient(colors: [.black.opacity(0.8), .clear]), - startPoint: .top, - endPoint: .bottom) - .ignoresSafeArea() - .frame(height: 70) - } + ZStack(alignment: .top) { + if viewModel.overlayType == .compact { + LinearGradient( + gradient: Gradient(colors: [.black.opacity(0.8), .clear]), + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + .frame(height: 70) + } - VStack(alignment: .EpisodeSeriesAlignmentGuide) { - HStack(alignment: .center) { - HStack { - Button { - viewModel.playerOverlayDelegate?.didSelectClose() - } label: { - Image(systemName: "chevron.backward") - .padding() - .padding(.trailing, -10) - } + VStack(alignment: .EpisodeSeriesAlignmentGuide) { + HStack(alignment: .center) { + HStack { + Button { + viewModel.playerOverlayDelegate?.didSelectClose() + } label: { + Image(systemName: "chevron.backward") + .padding() + .padding(.trailing, -10) + } - Text(viewModel.title) - .font(.title3) - .fontWeight(.bold) - .lineLimit(1) - .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in - context[.leading] - } - } + Text(viewModel.title) + .font(.title3) + .fontWeight(.bold) + .lineLimit(1) + .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in + context[.leading] + } + } - Spacer() + Spacer() - HStack(spacing: 20) { - // MARK: Previous Item + HStack(spacing: 20) { + // MARK: Previous Item - if viewModel.shouldShowPlayPreviousItem { - Button { - viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() - } label: { - Image(systemName: "chevron.left.circle") - } - .disabled(viewModel.previousItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) - } + if viewModel.shouldShowPlayPreviousItem { + Button { + viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() + } label: { + Image(systemName: "chevron.left.circle") + } + .disabled(viewModel.previousItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + } - // MARK: Next Item + // MARK: Next Item - if viewModel.shouldShowPlayNextItem { - Button { - viewModel.playerOverlayDelegate?.didSelectPlayNextItem() - } label: { - Image(systemName: "chevron.right.circle") - } - .disabled(viewModel.nextItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) - } + if viewModel.shouldShowPlayNextItem { + Button { + viewModel.playerOverlayDelegate?.didSelectPlayNextItem() + } label: { + Image(systemName: "chevron.right.circle") + } + .disabled(viewModel.nextItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + } - // MARK: Autoplay + // MARK: Autoplay - if viewModel.shouldShowAutoPlay { - Button { - viewModel.autoplayEnabled.toggle() - } label: { - if viewModel.autoplayEnabled { - Image(systemName: "play.circle.fill") - } else { - Image(systemName: "stop.circle") - } - } - } + if viewModel.shouldShowAutoPlay { + Button { + viewModel.autoplayEnabled.toggle() + } label: { + if viewModel.autoplayEnabled { + Image(systemName: "play.circle.fill") + } else { + Image(systemName: "stop.circle") + } + } + } - // MARK: Subtitle Toggle + // MARK: Subtitle Toggle - if !viewModel.subtitleStreams.isEmpty { - Button { - viewModel.subtitlesEnabled.toggle() - } label: { - if viewModel.subtitlesEnabled { - Image(systemName: "captions.bubble.fill") - } else { - Image(systemName: "captions.bubble") - } - } - .disabled(viewModel.selectedSubtitleStreamIndex == -1) - .foregroundColor(viewModel.selectedSubtitleStreamIndex == -1 ? .gray : .white) - } + if !viewModel.subtitleStreams.isEmpty { + Button { + viewModel.subtitlesEnabled.toggle() + } label: { + if viewModel.subtitlesEnabled { + Image(systemName: "captions.bubble.fill") + } else { + Image(systemName: "captions.bubble") + } + } + .disabled(viewModel.selectedSubtitleStreamIndex == -1) + .foregroundColor(viewModel.selectedSubtitleStreamIndex == -1 ? .gray : .white) + } - // MARK: Screen Fill + // MARK: Screen Fill - Button { - viewModel.playerOverlayDelegate?.didSelectScreenFill() - } label: { - if viewModel.playerOverlayDelegate?.getScreenFilled() ?? true { - if viewModel.playerOverlayDelegate?.isVideoAspectRatioGreater() ?? true { - Image(systemName: "rectangle.arrowtriangle.2.inward") - } else { - Image(systemName: "rectangle.portrait.arrowtriangle.2.inward") - } - } else { - if viewModel.playerOverlayDelegate?.isVideoAspectRatioGreater() ?? true { - Image(systemName: "rectangle.arrowtriangle.2.outward") - } else { - Image(systemName: "rectangle.portrait.arrowtriangle.2.outward") - } - } - } + Button { + viewModel.playerOverlayDelegate?.didSelectScreenFill() + } label: { + if viewModel.playerOverlayDelegate?.getScreenFilled() ?? true { + if viewModel.playerOverlayDelegate?.isVideoAspectRatioGreater() ?? true { + Image(systemName: "rectangle.arrowtriangle.2.inward") + } else { + Image(systemName: "rectangle.portrait.arrowtriangle.2.inward") + } + } else { + if viewModel.playerOverlayDelegate?.isVideoAspectRatioGreater() ?? true { + Image(systemName: "rectangle.arrowtriangle.2.outward") + } else { + Image(systemName: "rectangle.portrait.arrowtriangle.2.outward") + } + } + } - // MARK: Settings Menu + // MARK: Settings Menu - Menu { - // MARK: Audio Streams + Menu { + // MARK: Audio Streams - Menu { - ForEach(viewModel.audioStreams, id: \.self) { audioStream in - Button { - viewModel.selectedAudioStreamIndex = audioStream.index ?? -1 - } label: { - if audioStream.index == viewModel.selectedAudioStreamIndex { - Label(audioStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark") - } else { - Text(audioStream.displayTitle ?? L10n.noTitle) - } - } - } - } label: { - HStack { - Image(systemName: "speaker.wave.3") - L10n.audio.text - } - } + Menu { + ForEach(viewModel.audioStreams, id: \.self) { audioStream in + Button { + viewModel.selectedAudioStreamIndex = audioStream.index ?? -1 + } label: { + if audioStream.index == viewModel.selectedAudioStreamIndex { + Label(audioStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark") + } else { + Text(audioStream.displayTitle ?? L10n.noTitle) + } + } + } + } label: { + HStack { + Image(systemName: "speaker.wave.3") + L10n.audio.text + } + } - // MARK: Subtitle Streams + // MARK: Subtitle Streams - Menu { - ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in - Button { - viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1 - } label: { - if subtitleStream.index == viewModel.selectedSubtitleStreamIndex { - Label(subtitleStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark") - } else { - Text(subtitleStream.displayTitle ?? L10n.noTitle) - } - } - } - } label: { - HStack { - Image(systemName: "captions.bubble") - L10n.subtitles.text - } - } + Menu { + ForEach(viewModel.subtitleStreams, id: \.self) { subtitleStream in + Button { + viewModel.selectedSubtitleStreamIndex = subtitleStream.index ?? -1 + } label: { + if subtitleStream.index == viewModel.selectedSubtitleStreamIndex { + Label(subtitleStream.displayTitle ?? L10n.noTitle, systemImage: "checkmark") + } else { + Text(subtitleStream.displayTitle ?? L10n.noTitle) + } + } + } + } label: { + HStack { + Image(systemName: "captions.bubble") + L10n.subtitles.text + } + } - // MARK: Playback Speed + // MARK: Playback Speed - Menu { - ForEach(PlaybackSpeed.allCases, id: \.self) { speed in - Button { - viewModel.playbackSpeed = speed - } label: { - if speed == viewModel.playbackSpeed { - Label(speed.displayTitle, systemImage: "checkmark") - } else { - Text(speed.displayTitle) - } - } - } - } label: { - HStack { - Image(systemName: "speedometer") - L10n.playbackSpeed.text - } - } + Menu { + ForEach(PlaybackSpeed.allCases, id: \.self) { speed in + Button { + viewModel.playbackSpeed = speed + } label: { + if speed == viewModel.playbackSpeed { + Label(speed.displayTitle, systemImage: "checkmark") + } else { + Text(speed.displayTitle) + } + } + } + } label: { + HStack { + Image(systemName: "speedometer") + L10n.playbackSpeed.text + } + } - // MARK: Chapters + // MARK: Chapters - if !viewModel.chapters.isEmpty { - Button { - viewModel.playerOverlayDelegate?.didSelectChapters() - } label: { - HStack { - Image(systemName: "list.dash") - L10n.chapters.text - } - } - } + if !viewModel.chapters.isEmpty { + Button { + viewModel.playerOverlayDelegate?.didSelectChapters() + } label: { + HStack { + Image(systemName: "list.dash") + L10n.chapters.text + } + } + } - // MARK: Jump Button Lengths + // MARK: Jump Button Lengths - if viewModel.shouldShowJumpButtonsInOverlayMenu { - Menu { - ForEach(VideoPlayerJumpLength.allCases, id: \.self) { forwardLength in - Button { - viewModel.jumpForwardLength = forwardLength - } label: { - if forwardLength == viewModel.jumpForwardLength { - Label(forwardLength.shortLabel, systemImage: "checkmark") - } else { - Text(forwardLength.shortLabel) - } - } - } - } label: { - HStack { - Image(systemName: "goforward") - L10n.jumpForwardLength.text - } - } + if viewModel.shouldShowJumpButtonsInOverlayMenu { + Menu { + ForEach(VideoPlayerJumpLength.allCases, id: \.self) { forwardLength in + Button { + viewModel.jumpForwardLength = forwardLength + } label: { + if forwardLength == viewModel.jumpForwardLength { + Label(forwardLength.shortLabel, systemImage: "checkmark") + } else { + Text(forwardLength.shortLabel) + } + } + } + } label: { + HStack { + Image(systemName: "goforward") + L10n.jumpForwardLength.text + } + } - Menu { - ForEach(VideoPlayerJumpLength.allCases, id: \.self) { backwardLength in - Button { - viewModel.jumpBackwardLength = backwardLength - } label: { - if backwardLength == viewModel.jumpBackwardLength { - Label(backwardLength.shortLabel, systemImage: "checkmark") - } else { - Text(backwardLength.shortLabel) - } - } - } - } label: { - HStack { - Image(systemName: "gobackward") - L10n.jumpBackwardLength.text - } - } - } - } label: { - Image(systemName: "ellipsis.circle") - } - } - } - .font(.system(size: 24)) - .frame(height: 50) + Menu { + ForEach(VideoPlayerJumpLength.allCases, id: \.self) { backwardLength in + Button { + viewModel.jumpBackwardLength = backwardLength + } label: { + if backwardLength == viewModel.jumpBackwardLength { + Label(backwardLength.shortLabel, systemImage: "checkmark") + } else { + Text(backwardLength.shortLabel) + } + } + } + } label: { + HStack { + Image(systemName: "gobackward") + L10n.jumpBackwardLength.text + } + } + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .font(.system(size: 24)) + .frame(height: 50) - if let seriesTitle = viewModel.subtitle { - Text(seriesTitle) - .font(.subheadline) - .foregroundColor(Color.gray) - .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in - context[.leading] - } - .offset(y: -18) - } - } - .padding(.horizontal, UIDevice.current.userInterfaceIdiom == .pad ? 30 : 0) - } + if let seriesTitle = viewModel.subtitle { + Text(seriesTitle) + .font(.subheadline) + .foregroundColor(Color.gray) + .alignmentGuide(.EpisodeSeriesAlignmentGuide) { context in + context[.leading] + } + .offset(y: -18) + } + } + .padding(.horizontal, UIDevice.current.userInterfaceIdiom == .pad ? 30 : 0) + } - // MARK: Center + // MARK: Center - Spacer() + Spacer() - if viewModel.overlayType == .normal { - HStack(spacing: 80) { - Button { - viewModel.playerOverlayDelegate?.didSelectBackward() - } label: { - Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel) - } + if viewModel.overlayType == .normal { + HStack(spacing: 80) { + Button { + viewModel.playerOverlayDelegate?.didSelectBackward() + } label: { + Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel) + } - Button { - viewModel.playerOverlayDelegate?.didSelectMain() - } label: { - mainButtonView - } - .frame(width: 200) + Button { + viewModel.playerOverlayDelegate?.didSelectMain() + } label: { + mainButtonView + } + .frame(width: 200) - Button { - viewModel.playerOverlayDelegate?.didSelectForward() - } label: { - Image(systemName: viewModel.jumpForwardLength.forwardImageLabel) - } - } - .font(.system(size: 48)) - .opacity(viewModel.isHiddenCenterViews ? 0 : 1) - } + Button { + viewModel.playerOverlayDelegate?.didSelectForward() + } label: { + Image(systemName: viewModel.jumpForwardLength.forwardImageLabel) + } + } + .font(.system(size: 48)) + .opacity(viewModel.isHiddenCenterViews ? 0 : 1) + } - Spacer() + Spacer() - // MARK: Bottom Bar + // MARK: Bottom Bar - ZStack(alignment: .center) { - if viewModel.overlayType == .compact { - LinearGradient(gradient: Gradient(colors: [.clear, .black.opacity(0.8)]), - startPoint: .top, - endPoint: .bottom) - .ignoresSafeArea() - .frame(height: 70) - } + ZStack(alignment: .center) { + if viewModel.overlayType == .compact { + LinearGradient( + gradient: Gradient(colors: [.clear, .black.opacity(0.8)]), + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + .frame(height: 70) + } - VStack(alignment: .leading, spacing: 0) { - if viewModel.shouldShowChaptersInfoInBottomOverlay, - let currentChapter = viewModel.currentChapter - { - Button { - viewModel.playerOverlayDelegate?.didSelectChapters() - } label: { - HStack { - Text(currentChapter.name ?? "--") - Image(systemName: "chevron.right") - } - .font(.system(size: 16, weight: .semibold, design: .default)) - } - } + VStack(alignment: .leading, spacing: 0) { + if viewModel.shouldShowChaptersInfoInBottomOverlay, + let currentChapter = viewModel.currentChapter + { + Button { + viewModel.playerOverlayDelegate?.didSelectChapters() + } label: { + HStack { + Text(currentChapter.name ?? "--") + Image(systemName: "chevron.right") + } + .font(.system(size: 16, weight: .semibold, design: .default)) + } + } - HStack { - if viewModel.overlayType == .compact { - HStack { - Button { - viewModel.playerOverlayDelegate?.didSelectBackward() - } label: { - Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel) - .padding(.horizontal, 5) - } + HStack { + if viewModel.overlayType == .compact { + HStack { + Button { + viewModel.playerOverlayDelegate?.didSelectBackward() + } label: { + Image(systemName: viewModel.jumpBackwardLength.backwardImageLabel) + .padding(.horizontal, 5) + } - Button { - viewModel.playerOverlayDelegate?.didSelectMain() - } label: { - mainButtonView - .frame(minWidth: 30, maxWidth: 30) - .padding(.horizontal, 10) - } + Button { + viewModel.playerOverlayDelegate?.didSelectMain() + } label: { + mainButtonView + .frame(minWidth: 30, maxWidth: 30) + .padding(.horizontal, 10) + } - Button { - viewModel.playerOverlayDelegate?.didSelectForward() - } label: { - Image(systemName: viewModel.jumpForwardLength.forwardImageLabel) - .padding(.horizontal, 5) - } - } - .font(.system(size: 24, weight: .semibold, design: .default)) - } + Button { + viewModel.playerOverlayDelegate?.didSelectForward() + } label: { + Image(systemName: viewModel.jumpForwardLength.forwardImageLabel) + .padding(.horizontal, 5) + } + } + .font(.system(size: 24, weight: .semibold, design: .default)) + } - Text(viewModel.leftLabelText) - .font(.system(size: 18, weight: .semibold, design: .default)) - .frame(minWidth: 70, maxWidth: 70) - .accessibilityLabel(L10n.currentPosition) - .accessibilityValue(viewModel.leftLabelText) + Text(viewModel.leftLabelText) + .font(.system(size: 18, weight: .semibold, design: .default)) + .frame(minWidth: 70, maxWidth: 70) + .accessibilityLabel(L10n.currentPosition) + .accessibilityValue(viewModel.leftLabelText) - ValueSlider(value: $viewModel.sliderPercentage, onEditingChanged: { editing in - viewModel.sliderIsScrubbing = editing - }) - .valueSliderStyle(HorizontalValueSliderStyle(track: - GeometryReader { proxy in - ZStack(alignment: .leading) { - HorizontalValueTrack(view: - Capsule().foregroundColor(.purple)) - .background(Capsule().foregroundColor(Color.gray.opacity(0.75))) + ValueSlider(value: $viewModel.sliderPercentage, onEditingChanged: { editing in + viewModel.sliderIsScrubbing = editing + }) + .valueSliderStyle(HorizontalValueSliderStyle( + track: + GeometryReader { proxy in + ZStack(alignment: .leading) { + HorizontalValueTrack( + view: + Capsule().foregroundColor(.purple) + ) + .background(Capsule().foregroundColor(Color.gray.opacity(0.75))) - if viewModel.shouldShowChaptersInfoInBottomOverlay { - // Chapters seek masks - ForEach(viewModel.chapters, id: \.startPositionTicks) { chapter in - let ticksRatio = CGFloat(chapter.startPositionTicks ?? 0) / - CGFloat(viewModel.item.runTimeTicks ?? 0) - let x = proxy.size.width * ticksRatio - if x != 0 { - Rectangle() - .blendMode(.destinationOut) - .offset(x: x - 1.5) - .frame(width: 3) - } - } - } - } - .compositingGroup() - } - .frame(height: 4), - thumb: Circle().foregroundColor(.purple), - thumbSize: CGSize.Circle(radius: viewModel.sliderIsScrubbing ? 20 : 15), - thumbInteractiveSize: CGSize.Circle(radius: 40), - options: .defaultOptions)) - .frame(maxHeight: 50) + if viewModel.shouldShowChaptersInfoInBottomOverlay { + // Chapters seek masks + ForEach(viewModel.chapters, id: \.startPositionTicks) { chapter in + let ticksRatio = CGFloat(chapter.startPositionTicks ?? 0) / + CGFloat(viewModel.item.runTimeTicks ?? 0) + let x = proxy.size.width * ticksRatio + if x != 0 { + Rectangle() + .blendMode(.destinationOut) + .offset(x: x - 1.5) + .frame(width: 3) + } + } + } + } + .compositingGroup() + } + .frame(height: 4), + thumb: Circle().foregroundColor(.purple), + thumbSize: CGSize.Circle(radius: viewModel.sliderIsScrubbing ? 20 : 15), + thumbInteractiveSize: CGSize.Circle(radius: 40), + options: .defaultOptions + )) + .frame(maxHeight: 50) - Text(viewModel.rightLabelText) - .font(.system(size: 18, weight: .semibold, design: .default)) - .frame(minWidth: 70, maxWidth: 70) - .accessibilityLabel(L10n.remainingTime) - .accessibilityValue(viewModel.rightLabelText) - } - } - .padding(.horizontal, UIDevice.current.userInterfaceIdiom == .pad ? 30 : 0) - .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 10 : 0) - } - } - .tint(Color.white) - .foregroundColor(Color.white) - } + Text(viewModel.rightLabelText) + .font(.system(size: 18, weight: .semibold, design: .default)) + .frame(minWidth: 70, maxWidth: 70) + .accessibilityLabel(L10n.remainingTime) + .accessibilityValue(viewModel.rightLabelText) + } + } + .padding(.horizontal, UIDevice.current.userInterfaceIdiom == .pad ? 30 : 0) + .padding(.bottom, UIDevice.current.userInterfaceIdiom == .pad ? 10 : 0) + } + } + .tint(Color.white) + .foregroundColor(Color.white) + } - @ViewBuilder - var contents: some View { - if viewModel.overlayType == .normal { - mainBody - .contentShape(Rectangle()) - .background { - Color(uiColor: .black.withAlphaComponent(0.5)) - .ignoresSafeArea() - } - } else { - mainBody - .contentShape(Rectangle()) - } - } + @ViewBuilder + var contents: some View { + if viewModel.overlayType == .normal { + mainBody + .contentShape(Rectangle()) + .background { + Color(uiColor: .black.withAlphaComponent(0.5)) + .ignoresSafeArea() + } + } else { + mainBody + .contentShape(Rectangle()) + } + } - var body: some View { - contents - .onLongPressGesture { - guard viewModel.playerGesturesLockGestureEnabled else { return } - viewModel.playerOverlayDelegate?.didGenerallyTap(point: nil) - viewModel.playerOverlayDelegate?.didLongPress() - } - .gesture(DragGesture(minimumDistance: 0) - .onEnded { value in - viewModel.playerOverlayDelegate?.didGenerallyTap(point: value.location) - }) - .opacity(viewModel.isHiddenOverlay ? 0 : 1) - } + var body: some View { + contents + .onLongPressGesture { + guard viewModel.playerGesturesLockGestureEnabled else { return } + viewModel.playerOverlayDelegate?.didGenerallyTap(point: nil) + viewModel.playerOverlayDelegate?.didLongPress() + } + .gesture( + DragGesture(minimumDistance: 0) + .onEnded { value in + viewModel.playerOverlayDelegate?.didGenerallyTap(point: value.location) + } + ) + .opacity(viewModel.isHiddenOverlay ? 0 : 1) + } } struct VLCPlayerCompactOverlayView_Previews: PreviewProvider { - static let videoPlayerViewModel = VideoPlayerViewModel(item: BaseItemDto(), - title: "Glorious Purpose", - subtitle: "Loki - S1E1", - directStreamURL: URL(string: "www.apple.com")!, - transcodedStreamURL: nil, - hlsStreamURL: URL(string: "www.apple.com")!, - streamType: .direct, - response: PlaybackInfoResponse(), - audioStreams: [MediaStream(displayTitle: "English", index: -1)], - subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], - chapters: [], - selectedAudioStreamIndex: -1, - selectedSubtitleStreamIndex: -1, - subtitlesEnabled: true, - autoplayEnabled: false, - overlayType: .compact, - shouldShowPlayPreviousItem: true, - shouldShowPlayNextItem: true, - shouldShowAutoPlay: true, - container: "", - filename: nil, - versionName: nil) + static let videoPlayerViewModel = VideoPlayerViewModel( + item: BaseItemDto(), + title: "Glorious Purpose", + subtitle: "Loki - S1E1", + directStreamURL: URL(string: "www.apple.com")!, + transcodedStreamURL: nil, + hlsStreamURL: URL(string: "www.apple.com")!, + streamType: .direct, + response: PlaybackInfoResponse(), + audioStreams: [MediaStream(displayTitle: "English", index: -1)], + subtitleStreams: [MediaStream(displayTitle: "None", index: -1)], + chapters: [], + selectedAudioStreamIndex: -1, + selectedSubtitleStreamIndex: -1, + subtitlesEnabled: true, + autoplayEnabled: false, + overlayType: .compact, + shouldShowPlayPreviousItem: true, + shouldShowPlayNextItem: true, + shouldShowAutoPlay: true, + container: "", + filename: nil, + versionName: nil + ) - static var previews: some View { - ZStack { - Color.red - .ignoresSafeArea() + static var previews: some View { + ZStack { + Color.red + .ignoresSafeArea() - VLCPlayerOverlayView(viewModel: videoPlayerViewModel) - } - .previewInterfaceOrientation(.landscapeLeft) - } + VLCPlayerOverlayView(viewModel: videoPlayerViewModel) + } + .previewInterfaceOrientation(.landscapeLeft) + } } // MARK: TitleSubtitleAlignment extension HorizontalAlignment { - private struct TitleSubtitleAlignment: AlignmentID { - static func defaultValue(in context: ViewDimensions) -> CGFloat { - context[HorizontalAlignment.leading] - } - } + private struct TitleSubtitleAlignment: AlignmentID { + static func defaultValue(in context: ViewDimensions) -> CGFloat { + context[HorizontalAlignment.leading] + } + } - static let EpisodeSeriesAlignmentGuide = HorizontalAlignment(TitleSubtitleAlignment.self) + static let EpisodeSeriesAlignmentGuide = HorizontalAlignment(TitleSubtitleAlignment.self) } diff --git a/Swiftfin/Views/VideoPlayer/PlayerOverlayDelegate.swift b/Swiftfin/Views/VideoPlayer/PlayerOverlayDelegate.swift index 466ca609..b2f99170 100644 --- a/Swiftfin/Views/VideoPlayer/PlayerOverlayDelegate.swift +++ b/Swiftfin/Views/VideoPlayer/PlayerOverlayDelegate.swift @@ -12,32 +12,32 @@ import UIKit protocol PlayerOverlayDelegate { - func didSelectClose() - func didSelectMenu() - func didDeselectMenu() + func didSelectClose() + func didSelectMenu() + func didDeselectMenu() - func didSelectBackward() - func didSelectForward() - func didSelectMain() + func didSelectBackward() + func didSelectForward() + func didSelectMain() - func didGenerallyTap(point: CGPoint?) - func didLongPress() + func didGenerallyTap(point: CGPoint?) + func didLongPress() - func didBeginScrubbing() - func didEndScrubbing() + func didBeginScrubbing() + func didEndScrubbing() - func didSelectAudioStream(index: Int) - func didSelectSubtitleStream(index: Int) + func didSelectAudioStream(index: Int) + func didSelectSubtitleStream(index: Int) - func didSelectPlayPreviousItem() - func didSelectPlayNextItem() + func didSelectPlayPreviousItem() + func didSelectPlayNextItem() - func didSelectChapters() - func didSelectChapter(_ chapter: ChapterInfo) + func didSelectChapters() + func didSelectChapter(_ chapter: ChapterInfo) - func didSelectScreenFill() - func getScreenFilled() -> Bool - // Returns whether the aspect ratio of the video - // is greater than the aspect ratio of the screen - func isVideoAspectRatioGreater() -> Bool + func didSelectScreenFill() + func getScreenFilled() -> Bool + // Returns whether the aspect ratio of the video + // is greater than the aspect ratio of the screen + func isVideoAspectRatioGreater() -> Bool } diff --git a/Swiftfin/Views/VideoPlayer/VLCPlayerView.swift b/Swiftfin/Views/VideoPlayer/VLCPlayerView.swift index 9a2908db..7d3a2cd1 100644 --- a/Swiftfin/Views/VideoPlayer/VLCPlayerView.swift +++ b/Swiftfin/Views/VideoPlayer/VLCPlayerView.swift @@ -11,28 +11,28 @@ import UIKit struct NativePlayerView: UIViewControllerRepresentable { - let viewModel: VideoPlayerViewModel + let viewModel: VideoPlayerViewModel - typealias UIViewControllerType = NativePlayerViewController + typealias UIViewControllerType = NativePlayerViewController - func makeUIViewController(context: Context) -> NativePlayerViewController { + func makeUIViewController(context: Context) -> NativePlayerViewController { - NativePlayerViewController(viewModel: viewModel) - } + NativePlayerViewController(viewModel: viewModel) + } - func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) {} + func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) {} } struct VLCPlayerView: UIViewControllerRepresentable { - let viewModel: VideoPlayerViewModel + let viewModel: VideoPlayerViewModel - typealias UIViewControllerType = VLCPlayerViewController + typealias UIViewControllerType = VLCPlayerViewController - func makeUIViewController(context: Context) -> VLCPlayerViewController { + func makeUIViewController(context: Context) -> VLCPlayerViewController { - VLCPlayerViewController(viewModel: viewModel) - } + VLCPlayerViewController(viewModel: viewModel) + } - func updateUIViewController(_ uiViewController: VLCPlayerViewController, context: Context) {} + func updateUIViewController(_ uiViewController: VLCPlayerViewController, context: Context) {} } diff --git a/Swiftfin/Views/VideoPlayer/VLCPlayerViewController.swift b/Swiftfin/Views/VideoPlayer/VLCPlayerViewController.swift index 554aadd0..52dc9d4d 100644 --- a/Swiftfin/Views/VideoPlayer/VLCPlayerViewController.swift +++ b/Swiftfin/Views/VideoPlayer/VLCPlayerViewController.swift @@ -19,1218 +19,1242 @@ import UIKit // TODO: Look at making the VLC player layer a view class VLCPlayerViewController: UIViewController { - // MARK: variables - - private var viewModel: VideoPlayerViewModel - private var vlcMediaPlayer: VLCMediaPlayer - private var lastPlayerTicks: Int64 = 0 - private var lastProgressReportTicks: Int64 = 0 - private var viewModelListeners = Set() - private var overlayDismissTimer: Timer? - private var isScreenFilled: Bool = false - private var isGesturesLocked = false - private var pinchScale: CGFloat = 1 - - private var currentPlayerTicks: Int64 { - Int64(vlcMediaPlayer.time.intValue) * 100_000 - } - - private var displayingOverlay: Bool { - currentOverlayHostingController?.view.alpha ?? 0 > 0 - } - - private var displayingChapterOverlay: Bool { - currentChapterOverlayHostingController?.view.alpha ?? 0 > 0 - } - - private var panBeganBrightness = CGFloat.zero - private var panBeganVolumeValue = Float.zero - private var panBeganSliderPercentage: Double = 0 - private var panBeganPoint = CGPoint.zero - private var tapLocationStack = [CGPoint]() - private var isJumping = false - private var jumpingCompletionWork: DispatchWorkItem? - private var isTapWhenJumping = false - - private lazy var videoContentView = makeVideoContentView() - private lazy var mainGestureView = makeMainGestureView() - private lazy var systemControlOverlayLabel = makeSystemControlOverlayLabel() - private lazy var lockedOverlayView = makeGestureLockedOverlayView() - private var currentOverlayHostingController: UIHostingController? - private var currentChapterOverlayHostingController: UIHostingController? - private var currentJumpBackwardOverlayView: UIImageView? - private var currentJumpForwardOverlayView: UIImageView? - private var volumeView = MPVolumeView() - - override var keyCommands: [UIKeyCommand]? { - var commands = [ - UIKeyCommand(title: L10n.playAndPause, action: #selector(didSelectMain), input: " "), - UIKeyCommand(title: L10n.jumpForward, action: #selector(didSelectForward), input: UIKeyCommand.inputRightArrow), - UIKeyCommand(title: L10n.jumpBackward, action: #selector(didSelectBackward), input: UIKeyCommand.inputLeftArrow), - UIKeyCommand(title: L10n.nextItem, - action: #selector(didSelectPlayNextItem), - input: UIKeyCommand.inputRightArrow, - modifierFlags: .command), - UIKeyCommand(title: L10n.previousItem, - action: #selector(didSelectPlayPreviousItem), - input: UIKeyCommand.inputLeftArrow, - modifierFlags: .command), - UIKeyCommand(title: L10n.close, action: #selector(didSelectClose), input: UIKeyCommand.inputEscape), - ] - if let previous = viewModel.playbackSpeed.previous { - commands.append(.init(title: "\(L10n.playbackSpeed) \(previous.displayTitle)", - action: #selector(didSelectPreviousPlaybackSpeed), - input: "[", - modifierFlags: .command)) - } - if let next = viewModel.playbackSpeed.next { - commands.append(.init(title: "\(L10n.playbackSpeed) \(next.displayTitle)", - action: #selector(didSelectNextPlaybackSpeed), - input: "]", - modifierFlags: .command)) - } - if viewModel.playbackSpeed != .one { - commands.append(.init(title: "\(L10n.playbackSpeed) \(PlaybackSpeed.one.displayTitle)", - action: #selector(didSelectNormalPlaybackSpeed), - input: "\\", - modifierFlags: .command)) - } - commands.forEach { $0.wantsPriorityOverSystemBehavior = true } - return commands - } - - // MARK: init - - init(viewModel: VideoPlayerViewModel) { - self.viewModel = viewModel - self.vlcMediaPlayer = VLCMediaPlayer() - - super.init(nibName: nil, bundle: nil) - - viewModel.playerOverlayDelegate = self - } - - @available(*, unavailable) - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupSubviews() { - view.addSubview(videoContentView) - view.addSubview(mainGestureView) - view.addSubview(systemControlOverlayLabel) - view.addSubview(lockedOverlayView) - } - - private func setupConstraints() { - NSLayoutConstraint.activate([ - videoContentView.topAnchor.constraint(equalTo: view.topAnchor), - videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), - videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor), - ]) - NSLayoutConstraint.activate([ - mainGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor), - mainGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - mainGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - mainGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - NSLayoutConstraint.activate([ - systemControlOverlayLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), - systemControlOverlayLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - NSLayoutConstraint.activate([ - lockedOverlayView.topAnchor.constraint(equalTo: view.topAnchor), - lockedOverlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor), - lockedOverlayView.leftAnchor.constraint(equalTo: view.leftAnchor), - lockedOverlayView.rightAnchor.constraint(equalTo: view.rightAnchor), - ]) - } - - // MARK: viewWillDisappear - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - NotificationCenter.default.removeObserver(self) - } - - // MARK: viewDidLoad - - override func viewDidLoad() { - super.viewDidLoad() - - setupSubviews() - setupConstraints() - - view.backgroundColor = .black - view.accessibilityIgnoresInvertColors = true - - setupMediaPlayer(newViewModel: viewModel) - - refreshJumpBackwardOverlayView(with: viewModel.jumpBackwardLength) - refreshJumpForwardOverlayView(with: viewModel.jumpForwardLength) - - let defaultNotificationCenter = NotificationCenter.default - defaultNotificationCenter.addObserver(self, - selector: #selector(appWillTerminate), - name: UIApplication.willTerminateNotification, - object: nil) - defaultNotificationCenter.addObserver(self, - selector: #selector(appWillResignActive), - name: UIApplication.willResignActiveNotification, - object: nil) - defaultNotificationCenter.addObserver(self, - selector: #selector(appWillResignActive), - name: UIApplication.didEnterBackgroundNotification, - object: nil) - } - - @objc - private func appWillTerminate() { - viewModel.sendStopReport() - } - - @objc - private func appWillResignActive() { - hideChaptersOverlay() - - showOverlay() - - stopOverlayDismissTimer() - - vlcMediaPlayer.pause() - - viewModel.sendPauseReport(paused: true) - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - startPlayback() - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - if isScreenFilled { - fillScreen(screenSize: size) - } - super.viewWillTransition(to: size, with: coordinator) - } - - // MARK: VideoContentView - - private func makeVideoContentView() -> UIView { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - view.backgroundColor = .black - - return view - } - - // MARK: MainGestureView - - private func makeMainGestureView() -> UIView { - let view = UIView() - view.translatesAutoresizingMaskIntoConstraints = false - - let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) - - let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(didPinch(_:))) - - let verticalGesture = PanDirectionGestureRecognizer(direction: .vertical, target: self, action: #selector(didVerticalPan(_:))) - let horizontalGesture = PanDirectionGestureRecognizer(direction: .horizontal, target: self, action: #selector(didHorizontalPan(_:))) - - let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(didLongPress)) - - view.addGestureRecognizer(singleTapGesture) - view.addGestureRecognizer(pinchGesture) - - if viewModel.playerGesturesLockGestureEnabled { - view.addGestureRecognizer(longPressGesture) - } - - if viewModel.systemControlGesturesEnabled { - view.addGestureRecognizer(verticalGesture) - } - - if viewModel.seekSlideGestureEnabled { - view.addGestureRecognizer(horizontalGesture) - } - - return view - } - - // MARK: SystemControlOverlayLabel - - private func makeSystemControlOverlayLabel() -> UILabel { - let label = UILabel() - label.alpha = 0 - label.translatesAutoresizingMaskIntoConstraints = false - label.font = .systemFont(ofSize: 48) - label.layer.zPosition = 1 - return label - } - - // MARK: GestureLockedOverlayView - - private func makeGestureLockedOverlayView() -> UIView { - let backgroundView = UIView() - backgroundView.layer.zPosition = 1 - backgroundView.alpha = 0 - backgroundView.translatesAutoresizingMaskIntoConstraints = false - let button = UIButton(type: .custom, primaryAction: UIAction(handler: { [weak self] _ in - self?.isGesturesLocked = false - self?.hideLockedOverlay() - self?.didGenerallyTap() - })) - button.translatesAutoresizingMaskIntoConstraints = false - button.setImage(UIImage(systemName: "lock.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? - .withTintColor(.white), - for: .normal) - backgroundView.addSubview(button) - - NSLayoutConstraint.activate([ - button.centerXAnchor.constraint(equalTo: backgroundView.centerXAnchor), - button.centerYAnchor.constraint(equalTo: backgroundView.centerYAnchor), - ]) - - let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) - backgroundView.addGestureRecognizer(singleTapGesture) - - return backgroundView - } - - @objc - private func didTap(_ gestureRecognizer: UITapGestureRecognizer) { - didGenerallyTap(point: gestureRecognizer.location(in: mainGestureView)) - } - - @objc - func didLongPress() { - guard !isGesturesLocked else { return } - isGesturesLocked = true - didGenerallyTap() - } - - @objc - private func didPinch(_ gestureRecognizer: UIPinchGestureRecognizer) { - guard !isGesturesLocked else { return } - if gestureRecognizer.state == .began || gestureRecognizer.state == .changed { - pinchScale = gestureRecognizer.scale - } else { - if pinchScale > 1, !isScreenFilled { - isScreenFilled.toggle() - fillScreen() - } else if pinchScale < 1, isScreenFilled { - isScreenFilled.toggle() - shrinkScreen() - } - } - } - - @objc - private func didVerticalPan(_ gestureRecognizer: UIPanGestureRecognizer) { - guard !isGesturesLocked else { return } - switch gestureRecognizer.state { - case .began: - panBeganBrightness = UIScreen.main.brightness - if let view = volumeView.subviews.first as? UISlider { - panBeganVolumeValue = view.value - } - panBeganPoint = gestureRecognizer.location(in: mainGestureView) - case .changed: - let mainGestureViewHalfWidth = mainGestureView.frame.width * 0.5 - let mainGestureViewHalfHeight = mainGestureView.frame.height * 0.5 - - let pos = gestureRecognizer.location(in: mainGestureView) - let moveDelta = pos.y - panBeganPoint.y - let changedValue = moveDelta / mainGestureViewHalfHeight - - if panBeganPoint.x < mainGestureViewHalfWidth { - UIScreen.main.brightness = panBeganBrightness - changedValue - showBrightnessOverlay() - } else if let view = volumeView.subviews.first as? UISlider { - view.value = panBeganVolumeValue - Float(changedValue) - showVolumeOverlay() - } - default: - hideSystemControlOverlay() - } - } - - @objc - private func didHorizontalPan(_ gestureRecognizer: UIPanGestureRecognizer) { - guard !isGesturesLocked else { return } - switch gestureRecognizer.state { - case .began: - exchangeOverlayView(isBringToFrontThanGestureView: false) - panBeganPoint = gestureRecognizer.location(in: mainGestureView) - panBeganSliderPercentage = viewModel.sliderPercentage - viewModel.sliderIsScrubbing = true - case .changed: - let pos = gestureRecognizer.location(in: mainGestureView) - let moveDelta = panBeganPoint.x - pos.x - let changedValue = (moveDelta / mainGestureView.frame.width) - - viewModel.sliderPercentage = min(max(0, panBeganSliderPercentage - changedValue), 1) - showSliderOverlay() - showOverlay() - default: - viewModel.sliderIsScrubbing = false - hideOverlay() - hideSystemControlOverlay() - } - } - - // MARK: setupOverlayHostingController - - private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { - // TODO: Look at injecting viewModel into the environment so it updates the current overlay - if let currentOverlayHostingController = currentOverlayHostingController { - // UX fade-out - UIView.animate(withDuration: 0.5) { - currentOverlayHostingController.view.alpha = 0 - } completion: { _ in - currentOverlayHostingController.view.isHidden = true - - currentOverlayHostingController.view.removeFromSuperview() - currentOverlayHostingController.removeFromParent() - } - } - - let newOverlayView = VLCPlayerOverlayView(viewModel: viewModel) - let newOverlayHostingController = UIHostingController(rootView: newOverlayView) - - newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newOverlayHostingController.view.backgroundColor = UIColor.clear - - // UX fade-in - newOverlayHostingController.view.alpha = 0 - - addChild(newOverlayHostingController) - view.addSubview(newOverlayHostingController.view) - newOverlayHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - - // UX fade-in - UIView.animate(withDuration: 0.5) { - newOverlayHostingController.view.alpha = 1 - } - - currentOverlayHostingController = newOverlayHostingController - - if let currentChapterOverlayHostingController = currentChapterOverlayHostingController { - UIView.animate(withDuration: 0.5) { - currentChapterOverlayHostingController.view.alpha = 0 - } completion: { _ in - currentChapterOverlayHostingController.view.isHidden = true - - currentChapterOverlayHostingController.view.removeFromSuperview() - currentChapterOverlayHostingController.removeFromParent() - } - } - - let newChapterOverlayView = VLCPlayerChapterOverlayView(viewModel: viewModel) - let newChapterOverlayHostingController = UIHostingController(rootView: newChapterOverlayView) - - newChapterOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false - newChapterOverlayHostingController.view.backgroundColor = UIColor.clear - - newChapterOverlayHostingController.view.alpha = 0 - - addChild(newChapterOverlayHostingController) - view.addSubview(newChapterOverlayHostingController.view) - newChapterOverlayHostingController.didMove(toParent: self) - - NSLayoutConstraint.activate([ - newChapterOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), - newChapterOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), - newChapterOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), - newChapterOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), - ]) - - currentChapterOverlayHostingController = newChapterOverlayHostingController - - // There is a weird behavior when after setting the new overlays that the navigation bar pops up, re-hide it - navigationController?.isNavigationBarHidden = true - } - - private func refreshJumpBackwardOverlayView(with jumpBackwardLength: VideoPlayerJumpLength) { - if let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView { - currentJumpBackwardOverlayView.removeFromSuperview() - } - - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) - let backwardSymbolImage = UIImage(systemName: jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) - let newJumpBackwardImageView = UIImageView(image: backwardSymbolImage) - - newJumpBackwardImageView.translatesAutoresizingMaskIntoConstraints = false - newJumpBackwardImageView.tintColor = .white - - newJumpBackwardImageView.alpha = 0 - - view.addSubview(newJumpBackwardImageView) - - NSLayoutConstraint.activate([ - newJumpBackwardImageView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 150), - newJumpBackwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) + // MARK: variables + + private var viewModel: VideoPlayerViewModel + private var vlcMediaPlayer: VLCMediaPlayer + private var lastPlayerTicks: Int64 = 0 + private var lastProgressReportTicks: Int64 = 0 + private var viewModelListeners = Set() + private var overlayDismissTimer: Timer? + private var isScreenFilled: Bool = false + private var isGesturesLocked = false + private var pinchScale: CGFloat = 1 + + private var currentPlayerTicks: Int64 { + Int64(vlcMediaPlayer.time.intValue) * 100_000 + } + + private var displayingOverlay: Bool { + currentOverlayHostingController?.view.alpha ?? 0 > 0 + } + + private var displayingChapterOverlay: Bool { + currentChapterOverlayHostingController?.view.alpha ?? 0 > 0 + } + + private var panBeganBrightness = CGFloat.zero + private var panBeganVolumeValue = Float.zero + private var panBeganSliderPercentage: Double = 0 + private var panBeganPoint = CGPoint.zero + private var tapLocationStack = [CGPoint]() + private var isJumping = false + private var jumpingCompletionWork: DispatchWorkItem? + private var isTapWhenJumping = false + + private lazy var videoContentView = makeVideoContentView() + private lazy var mainGestureView = makeMainGestureView() + private lazy var systemControlOverlayLabel = makeSystemControlOverlayLabel() + private lazy var lockedOverlayView = makeGestureLockedOverlayView() + private var currentOverlayHostingController: UIHostingController? + private var currentChapterOverlayHostingController: UIHostingController? + private var currentJumpBackwardOverlayView: UIImageView? + private var currentJumpForwardOverlayView: UIImageView? + private var volumeView = MPVolumeView() + + override var keyCommands: [UIKeyCommand]? { + var commands = [ + UIKeyCommand(title: L10n.playAndPause, action: #selector(didSelectMain), input: " "), + UIKeyCommand(title: L10n.jumpForward, action: #selector(didSelectForward), input: UIKeyCommand.inputRightArrow), + UIKeyCommand(title: L10n.jumpBackward, action: #selector(didSelectBackward), input: UIKeyCommand.inputLeftArrow), + UIKeyCommand( + title: L10n.nextItem, + action: #selector(didSelectPlayNextItem), + input: UIKeyCommand.inputRightArrow, + modifierFlags: .command + ), + UIKeyCommand( + title: L10n.previousItem, + action: #selector(didSelectPlayPreviousItem), + input: UIKeyCommand.inputLeftArrow, + modifierFlags: .command + ), + UIKeyCommand(title: L10n.close, action: #selector(didSelectClose), input: UIKeyCommand.inputEscape), + ] + if let previous = viewModel.playbackSpeed.previous { + commands.append(.init( + title: "\(L10n.playbackSpeed) \(previous.displayTitle)", + action: #selector(didSelectPreviousPlaybackSpeed), + input: "[", + modifierFlags: .command + )) + } + if let next = viewModel.playbackSpeed.next { + commands.append(.init( + title: "\(L10n.playbackSpeed) \(next.displayTitle)", + action: #selector(didSelectNextPlaybackSpeed), + input: "]", + modifierFlags: .command + )) + } + if viewModel.playbackSpeed != .one { + commands.append(.init( + title: "\(L10n.playbackSpeed) \(PlaybackSpeed.one.displayTitle)", + action: #selector(didSelectNormalPlaybackSpeed), + input: "\\", + modifierFlags: .command + )) + } + commands.forEach { $0.wantsPriorityOverSystemBehavior = true } + return commands + } + + // MARK: init + + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + self.vlcMediaPlayer = VLCMediaPlayer() + + super.init(nibName: nil, bundle: nil) + + viewModel.playerOverlayDelegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupSubviews() { + view.addSubview(videoContentView) + view.addSubview(mainGestureView) + view.addSubview(systemControlOverlayLabel) + view.addSubview(lockedOverlayView) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + videoContentView.topAnchor.constraint(equalTo: view.topAnchor), + videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), + videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor), + ]) + NSLayoutConstraint.activate([ + mainGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor), + mainGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + mainGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + mainGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) + NSLayoutConstraint.activate([ + systemControlOverlayLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + systemControlOverlayLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + NSLayoutConstraint.activate([ + lockedOverlayView.topAnchor.constraint(equalTo: view.topAnchor), + lockedOverlayView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + lockedOverlayView.leftAnchor.constraint(equalTo: view.leftAnchor), + lockedOverlayView.rightAnchor.constraint(equalTo: view.rightAnchor), + ]) + } + + // MARK: viewWillDisappear + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + NotificationCenter.default.removeObserver(self) + } + + // MARK: viewDidLoad + + override func viewDidLoad() { + super.viewDidLoad() + + setupSubviews() + setupConstraints() + + view.backgroundColor = .black + view.accessibilityIgnoresInvertColors = true + + setupMediaPlayer(newViewModel: viewModel) + + refreshJumpBackwardOverlayView(with: viewModel.jumpBackwardLength) + refreshJumpForwardOverlayView(with: viewModel.jumpForwardLength) + + let defaultNotificationCenter = NotificationCenter.default + defaultNotificationCenter.addObserver( + self, + selector: #selector(appWillTerminate), + name: UIApplication.willTerminateNotification, + object: nil + ) + defaultNotificationCenter.addObserver( + self, + selector: #selector(appWillResignActive), + name: UIApplication.willResignActiveNotification, + object: nil + ) + defaultNotificationCenter.addObserver( + self, + selector: #selector(appWillResignActive), + name: UIApplication.didEnterBackgroundNotification, + object: nil + ) + } + + @objc + private func appWillTerminate() { + viewModel.sendStopReport() + } + + @objc + private func appWillResignActive() { + hideChaptersOverlay() + + showOverlay() + + stopOverlayDismissTimer() + + vlcMediaPlayer.pause() + + viewModel.sendPauseReport(paused: true) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + startPlayback() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + if isScreenFilled { + fillScreen(screenSize: size) + } + super.viewWillTransition(to: size, with: coordinator) + } + + // MARK: VideoContentView + + private func makeVideoContentView() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .black + + return view + } + + // MARK: MainGestureView + + private func makeMainGestureView() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + + let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) + + let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(didPinch(_:))) + + let verticalGesture = PanDirectionGestureRecognizer(direction: .vertical, target: self, action: #selector(didVerticalPan(_:))) + let horizontalGesture = PanDirectionGestureRecognizer(direction: .horizontal, target: self, action: #selector(didHorizontalPan(_:))) + + let longPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(didLongPress)) + + view.addGestureRecognizer(singleTapGesture) + view.addGestureRecognizer(pinchGesture) + + if viewModel.playerGesturesLockGestureEnabled { + view.addGestureRecognizer(longPressGesture) + } + + if viewModel.systemControlGesturesEnabled { + view.addGestureRecognizer(verticalGesture) + } + + if viewModel.seekSlideGestureEnabled { + view.addGestureRecognizer(horizontalGesture) + } + + return view + } + + // MARK: SystemControlOverlayLabel + + private func makeSystemControlOverlayLabel() -> UILabel { + let label = UILabel() + label.alpha = 0 + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 48) + label.layer.zPosition = 1 + return label + } + + // MARK: GestureLockedOverlayView + + private func makeGestureLockedOverlayView() -> UIView { + let backgroundView = UIView() + backgroundView.layer.zPosition = 1 + backgroundView.alpha = 0 + backgroundView.translatesAutoresizingMaskIntoConstraints = false + let button = UIButton(type: .custom, primaryAction: UIAction(handler: { [weak self] _ in + self?.isGesturesLocked = false + self?.hideLockedOverlay() + self?.didGenerallyTap() + })) + button.translatesAutoresizingMaskIntoConstraints = false + button.setImage( + UIImage(systemName: "lock.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? + .withTintColor(.white), + for: .normal + ) + backgroundView.addSubview(button) + + NSLayoutConstraint.activate([ + button.centerXAnchor.constraint(equalTo: backgroundView.centerXAnchor), + button.centerYAnchor.constraint(equalTo: backgroundView.centerYAnchor), + ]) + + let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) + backgroundView.addGestureRecognizer(singleTapGesture) + + return backgroundView + } + + @objc + private func didTap(_ gestureRecognizer: UITapGestureRecognizer) { + didGenerallyTap(point: gestureRecognizer.location(in: mainGestureView)) + } + + @objc + func didLongPress() { + guard !isGesturesLocked else { return } + isGesturesLocked = true + didGenerallyTap() + } + + @objc + private func didPinch(_ gestureRecognizer: UIPinchGestureRecognizer) { + guard !isGesturesLocked else { return } + if gestureRecognizer.state == .began || gestureRecognizer.state == .changed { + pinchScale = gestureRecognizer.scale + } else { + if pinchScale > 1, !isScreenFilled { + isScreenFilled.toggle() + fillScreen() + } else if pinchScale < 1, isScreenFilled { + isScreenFilled.toggle() + shrinkScreen() + } + } + } + + @objc + private func didVerticalPan(_ gestureRecognizer: UIPanGestureRecognizer) { + guard !isGesturesLocked else { return } + switch gestureRecognizer.state { + case .began: + panBeganBrightness = UIScreen.main.brightness + if let view = volumeView.subviews.first as? UISlider { + panBeganVolumeValue = view.value + } + panBeganPoint = gestureRecognizer.location(in: mainGestureView) + case .changed: + let mainGestureViewHalfWidth = mainGestureView.frame.width * 0.5 + let mainGestureViewHalfHeight = mainGestureView.frame.height * 0.5 + + let pos = gestureRecognizer.location(in: mainGestureView) + let moveDelta = pos.y - panBeganPoint.y + let changedValue = moveDelta / mainGestureViewHalfHeight + + if panBeganPoint.x < mainGestureViewHalfWidth { + UIScreen.main.brightness = panBeganBrightness - changedValue + showBrightnessOverlay() + } else if let view = volumeView.subviews.first as? UISlider { + view.value = panBeganVolumeValue - Float(changedValue) + showVolumeOverlay() + } + default: + hideSystemControlOverlay() + } + } + + @objc + private func didHorizontalPan(_ gestureRecognizer: UIPanGestureRecognizer) { + guard !isGesturesLocked else { return } + switch gestureRecognizer.state { + case .began: + exchangeOverlayView(isBringToFrontThanGestureView: false) + panBeganPoint = gestureRecognizer.location(in: mainGestureView) + panBeganSliderPercentage = viewModel.sliderPercentage + viewModel.sliderIsScrubbing = true + case .changed: + let pos = gestureRecognizer.location(in: mainGestureView) + let moveDelta = panBeganPoint.x - pos.x + let changedValue = (moveDelta / mainGestureView.frame.width) + + viewModel.sliderPercentage = min(max(0, panBeganSliderPercentage - changedValue), 1) + showSliderOverlay() + showOverlay() + default: + viewModel.sliderIsScrubbing = false + hideOverlay() + hideSystemControlOverlay() + } + } + + // MARK: setupOverlayHostingController + + private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { + // TODO: Look at injecting viewModel into the environment so it updates the current overlay + if let currentOverlayHostingController = currentOverlayHostingController { + // UX fade-out + UIView.animate(withDuration: 0.5) { + currentOverlayHostingController.view.alpha = 0 + } completion: { _ in + currentOverlayHostingController.view.isHidden = true + + currentOverlayHostingController.view.removeFromSuperview() + currentOverlayHostingController.removeFromParent() + } + } + + let newOverlayView = VLCPlayerOverlayView(viewModel: viewModel) + let newOverlayHostingController = UIHostingController(rootView: newOverlayView) + + newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newOverlayHostingController.view.backgroundColor = UIColor.clear + + // UX fade-in + newOverlayHostingController.view.alpha = 0 + + addChild(newOverlayHostingController) + view.addSubview(newOverlayHostingController.view) + newOverlayHostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) + + // UX fade-in + UIView.animate(withDuration: 0.5) { + newOverlayHostingController.view.alpha = 1 + } + + currentOverlayHostingController = newOverlayHostingController + + if let currentChapterOverlayHostingController = currentChapterOverlayHostingController { + UIView.animate(withDuration: 0.5) { + currentChapterOverlayHostingController.view.alpha = 0 + } completion: { _ in + currentChapterOverlayHostingController.view.isHidden = true + + currentChapterOverlayHostingController.view.removeFromSuperview() + currentChapterOverlayHostingController.removeFromParent() + } + } + + let newChapterOverlayView = VLCPlayerChapterOverlayView(viewModel: viewModel) + let newChapterOverlayHostingController = UIHostingController(rootView: newChapterOverlayView) + + newChapterOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newChapterOverlayHostingController.view.backgroundColor = UIColor.clear + + newChapterOverlayHostingController.view.alpha = 0 + + addChild(newChapterOverlayHostingController) + view.addSubview(newChapterOverlayHostingController.view) + newChapterOverlayHostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + newChapterOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newChapterOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newChapterOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newChapterOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) + + currentChapterOverlayHostingController = newChapterOverlayHostingController + + // There is a weird behavior when after setting the new overlays that the navigation bar pops up, re-hide it + navigationController?.isNavigationBarHidden = true + } + + private func refreshJumpBackwardOverlayView(with jumpBackwardLength: VideoPlayerJumpLength) { + if let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView { + currentJumpBackwardOverlayView.removeFromSuperview() + } + + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) + let backwardSymbolImage = UIImage(systemName: jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) + let newJumpBackwardImageView = UIImageView(image: backwardSymbolImage) + + newJumpBackwardImageView.translatesAutoresizingMaskIntoConstraints = false + newJumpBackwardImageView.tintColor = .white + + newJumpBackwardImageView.alpha = 0 + + view.addSubview(newJumpBackwardImageView) + + NSLayoutConstraint.activate([ + newJumpBackwardImageView.leftAnchor.constraint(equalTo: view.leftAnchor, constant: 150), + newJumpBackwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + currentJumpBackwardOverlayView = newJumpBackwardImageView + } + + private func refreshJumpForwardOverlayView(with jumpForwardLength: VideoPlayerJumpLength) { + if let currentJumpForwardOverlayView = currentJumpForwardOverlayView { + currentJumpForwardOverlayView.removeFromSuperview() + } + + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) + let forwardSymbolImage = UIImage(systemName: jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) + let newJumpForwardImageView = UIImageView(image: forwardSymbolImage) + + newJumpForwardImageView.translatesAutoresizingMaskIntoConstraints = false + newJumpForwardImageView.tintColor = .white - currentJumpBackwardOverlayView = newJumpBackwardImageView - } - - private func refreshJumpForwardOverlayView(with jumpForwardLength: VideoPlayerJumpLength) { - if let currentJumpForwardOverlayView = currentJumpForwardOverlayView { - currentJumpForwardOverlayView.removeFromSuperview() - } + newJumpForwardImageView.alpha = 0 - let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) - let forwardSymbolImage = UIImage(systemName: jumpForwardLength.forwardImageLabel, withConfiguration: symbolConfig) - let newJumpForwardImageView = UIImageView(image: forwardSymbolImage) + view.addSubview(newJumpForwardImageView) - newJumpForwardImageView.translatesAutoresizingMaskIntoConstraints = false - newJumpForwardImageView.tintColor = .white - - newJumpForwardImageView.alpha = 0 - - view.addSubview(newJumpForwardImageView) - - NSLayoutConstraint.activate([ - newJumpForwardImageView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -150), - newJumpForwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - - currentJumpForwardOverlayView = newJumpForwardImageView - } - - private var isOverlayViewBringToFrontThanGestureView = true - private func exchangeOverlayView(isBringToFrontThanGestureView: Bool) { - guard isBringToFrontThanGestureView != isOverlayViewBringToFrontThanGestureView, - let currentOverlayView = currentOverlayHostingController?.view, - let mainGestureViewIndex = view.subviews.firstIndex(of: mainGestureView), - let currentOVerlayViewIndex = view.subviews.firstIndex(of: currentOverlayView) else { return } - isOverlayViewBringToFrontThanGestureView = isBringToFrontThanGestureView - view.exchangeSubview(at: mainGestureViewIndex, - withSubviewAt: currentOVerlayViewIndex) - } + NSLayoutConstraint.activate([ + newJumpForwardImageView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -150), + newJumpForwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + currentJumpForwardOverlayView = newJumpForwardImageView + } + + private var isOverlayViewBringToFrontThanGestureView = true + private func exchangeOverlayView(isBringToFrontThanGestureView: Bool) { + guard isBringToFrontThanGestureView != isOverlayViewBringToFrontThanGestureView, + let currentOverlayView = currentOverlayHostingController?.view, + let mainGestureViewIndex = view.subviews.firstIndex(of: mainGestureView), + let currentOVerlayViewIndex = view.subviews.firstIndex(of: currentOverlayView) else { return } + isOverlayViewBringToFrontThanGestureView = isBringToFrontThanGestureView + view.exchangeSubview( + at: mainGestureViewIndex, + withSubviewAt: currentOVerlayViewIndex + ) + } } // MARK: setupMediaPlayer extension VLCPlayerViewController { - /// Main function that handles setting up the media player with the current VideoPlayerViewModel - /// and also takes the role of setting the 'viewModel' property with the given viewModel - /// - /// Use case for this is setting new media within the same VLCPlayerViewController - func setupMediaPlayer(newViewModel: VideoPlayerViewModel) { - // remove old player + /// Main function that handles setting up the media player with the current VideoPlayerViewModel + /// and also takes the role of setting the 'viewModel' property with the given viewModel + /// + /// Use case for this is setting new media within the same VLCPlayerViewController + func setupMediaPlayer(newViewModel: VideoPlayerViewModel) { + // remove old player - if vlcMediaPlayer.media != nil { - viewModelListeners.forEach { $0.cancel() } + if vlcMediaPlayer.media != nil { + viewModelListeners.forEach { $0.cancel() } - vlcMediaPlayer.stop() - viewModel.sendStopReport() - viewModel.playerOverlayDelegate = nil - } + vlcMediaPlayer.stop() + viewModel.sendStopReport() + viewModel.playerOverlayDelegate = nil + } - vlcMediaPlayer = VLCMediaPlayer() + vlcMediaPlayer = VLCMediaPlayer() - // setup with new player and view model + // setup with new player and view model - vlcMediaPlayer = VLCMediaPlayer() + vlcMediaPlayer = VLCMediaPlayer() - vlcMediaPlayer.delegate = self - vlcMediaPlayer.drawable = videoContentView + vlcMediaPlayer.delegate = self + vlcMediaPlayer.drawable = videoContentView - vlcMediaPlayer.setSubtitleSize(Defaults[.subtitleSize]) + vlcMediaPlayer.setSubtitleSize(Defaults[.subtitleSize]) - stopOverlayDismissTimer() + stopOverlayDismissTimer() - lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - let media: VLCMedia + let media: VLCMedia - if let transcodedURL = newViewModel.transcodedStreamURL, - !Defaults[.Experimental.forceDirectPlay] - { - media = VLCMedia(url: transcodedURL) - } else { - media = VLCMedia(url: newViewModel.directStreamURL) - } + if let transcodedURL = newViewModel.transcodedStreamURL, + !Defaults[.Experimental.forceDirectPlay] + { + media = VLCMedia(url: transcodedURL) + } else { + media = VLCMedia(url: newViewModel.directStreamURL) + } - media.addOption("--prefetch-buffer-size=1048576") - media.addOption("--network-caching=5000") + media.addOption("--prefetch-buffer-size=1048576") + media.addOption("--network-caching=5000") - vlcMediaPlayer.media = media + vlcMediaPlayer.media = media - setupOverlayHostingController(viewModel: newViewModel) - setupViewModelListeners(viewModel: newViewModel) + setupOverlayHostingController(viewModel: newViewModel) + setupViewModelListeners(viewModel: newViewModel) - newViewModel.getAdjacentEpisodes() - newViewModel.playerOverlayDelegate = self + newViewModel.getAdjacentEpisodes() + newViewModel.playerOverlayDelegate = self - let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 + let startPercentage = newViewModel.item.userData?.playedPercentage ?? 0 - if startPercentage > 0 { - if viewModel.resumeOffset { - let runTimeTicks = viewModel.item.runTimeTicks ?? 0 - let videoDurationSeconds = Double(runTimeTicks / 10_000_000) - var startSeconds = round((startPercentage / 100) * videoDurationSeconds) - startSeconds = startSeconds.subtract(5, floor: 0) - let newStartPercentage = startSeconds / videoDurationSeconds - newViewModel.sliderPercentage = newStartPercentage - } else { - newViewModel.sliderPercentage = startPercentage / 100 - } - } + if startPercentage > 0 { + if viewModel.resumeOffset { + let runTimeTicks = viewModel.item.runTimeTicks ?? 0 + let videoDurationSeconds = Double(runTimeTicks / 10_000_000) + var startSeconds = round((startPercentage / 100) * videoDurationSeconds) + startSeconds = startSeconds.subtract(5, floor: 0) + let newStartPercentage = startSeconds / videoDurationSeconds + newViewModel.sliderPercentage = newStartPercentage + } else { + newViewModel.sliderPercentage = startPercentage / 100 + } + } - viewModel = newViewModel + viewModel = newViewModel - if viewModel.streamType == .direct { - LogManager.log.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? "--")") - } else if viewModel.streamType == .transcode && Defaults[.Experimental.forceDirectPlay] { - LogManager.log.debug("Player set up with forced direct stream for item: \(viewModel.item.id ?? "--")") - } else { - LogManager.log.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? "--")") - } - } + if viewModel.streamType == .direct { + LogManager.log.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? "--")") + } else if viewModel.streamType == .transcode && Defaults[.Experimental.forceDirectPlay] { + LogManager.log.debug("Player set up with forced direct stream for item: \(viewModel.item.id ?? "--")") + } else { + LogManager.log.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? "--")") + } + } - // MARK: startPlayback + // MARK: startPlayback - func startPlayback() { - vlcMediaPlayer.play() + func startPlayback() { + vlcMediaPlayer.play() - // Setup external subtitles - for externalSubtitle in viewModel.subtitleStreams.filter({ $0.deliveryMethod == .external }) { - if let deliveryURL = externalSubtitle.externalURL(base: SessionManager.main.currentLogin.server.currentURI) { - vlcMediaPlayer.addPlaybackSlave(deliveryURL, type: .subtitle, enforce: false) - } - } + // Setup external subtitles + for externalSubtitle in viewModel.subtitleStreams.filter({ $0.deliveryMethod == .external }) { + if let deliveryURL = externalSubtitle.externalURL(base: SessionManager.main.currentLogin.server.currentURI) { + vlcMediaPlayer.addPlaybackSlave(deliveryURL, type: .subtitle, enforce: false) + } + } - setMediaPlayerTimeAtCurrentSlider() + setMediaPlayerTimeAtCurrentSlider() - viewModel.sendPlayReport() + viewModel.sendPlayReport() - restartOverlayDismissTimer() - } + restartOverlayDismissTimer() + } - // MARK: setupViewModelListeners + // MARK: setupViewModelListeners - private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { - viewModel.$playbackSpeed.sink { newSpeed in - self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) - }.store(in: &viewModelListeners) + private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { + viewModel.$playbackSpeed.sink { newSpeed in + self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) + }.store(in: &viewModelListeners) - viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in - if sliderIsScrubbing { - self.didBeginScrubbing() - } else { - self.didEndScrubbing() - } - }.store(in: &viewModelListeners) + viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in + if sliderIsScrubbing { + self.didBeginScrubbing() + } else { + self.didEndScrubbing() + } + }.store(in: &viewModelListeners) - viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in - self.didSelectAudioStream(index: newAudioStreamIndex) - }.store(in: &viewModelListeners) + viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in + self.didSelectAudioStream(index: newAudioStreamIndex) + }.store(in: &viewModelListeners) - viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in - self.didSelectSubtitleStream(index: newSubtitleStreamIndex) - }.store(in: &viewModelListeners) + viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in + self.didSelectSubtitleStream(index: newSubtitleStreamIndex) + }.store(in: &viewModelListeners) - viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in - self.didToggleSubtitles(newValue: newSubtitlesEnabled) - }.store(in: &viewModelListeners) + viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in + self.didToggleSubtitles(newValue: newSubtitlesEnabled) + }.store(in: &viewModelListeners) - viewModel.$jumpBackwardLength.sink { newJumpBackwardLength in - self.refreshJumpBackwardOverlayView(with: newJumpBackwardLength) - }.store(in: &viewModelListeners) + viewModel.$jumpBackwardLength.sink { newJumpBackwardLength in + self.refreshJumpBackwardOverlayView(with: newJumpBackwardLength) + }.store(in: &viewModelListeners) - viewModel.$jumpForwardLength.sink { newJumpForwardLength in - self.refreshJumpForwardOverlayView(with: newJumpForwardLength) - }.store(in: &viewModelListeners) - } + viewModel.$jumpForwardLength.sink { newJumpForwardLength in + self.refreshJumpForwardOverlayView(with: newJumpForwardLength) + }.store(in: &viewModelListeners) + } - func setMediaPlayerTimeAtCurrentSlider() { - // Necessary math as VLCMediaPlayer doesn't work well - // by just setting the position - let runTimeTicks = viewModel.item.runTimeTicks ?? 0 - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let videoDuration = Double(runTimeTicks / 10_000_000) - let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) - let newPositionOffset = secondsScrubbedTo - videoPosition + func setMediaPlayerTimeAtCurrentSlider() { + // Necessary math as VLCMediaPlayer doesn't work well + // by just setting the position + let runTimeTicks = viewModel.item.runTimeTicks ?? 0 + let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) + let videoDuration = Double(runTimeTicks / 10_000_000) + let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) + let newPositionOffset = secondsScrubbedTo - videoPosition - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } - } + if newPositionOffset > 0 { + vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) + } else { + vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) + } + } } // MARK: Show/Hide Overlay extension VLCPlayerViewController { - private func showOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } + private func showOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } - guard overlayHostingController.view.alpha != 1 else { return } - overlayHostingController.view.alpha = 1 + guard overlayHostingController.view.alpha != 1 else { return } + overlayHostingController.view.alpha = 1 - withAnimation(.easeInOut(duration: 0.2)) { [weak self] in - self?.viewModel.isHiddenOverlay = false - } - } + withAnimation(.easeInOut(duration: 0.2)) { [weak self] in + self?.viewModel.isHiddenOverlay = false + } + } - private func hideOverlay() { - guard !UIAccessibility.isVoiceOverRunning else { return } + private func hideOverlay() { + guard !UIAccessibility.isVoiceOverRunning else { return } - guard let overlayHostingController = currentOverlayHostingController else { return } + guard let overlayHostingController = currentOverlayHostingController else { return } - guard overlayHostingController.view.alpha != 0 else { return } + guard overlayHostingController.view.alpha != 0 else { return } - // for gestures UX - exchangeOverlayView(isBringToFrontThanGestureView: false) - UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) { - overlayHostingController.view.alpha = 0 - } completion: { [weak self] _ in - guard let self = self else { return } - self.exchangeOverlayView(isBringToFrontThanGestureView: true) - self.viewModel.isHiddenOverlay = true - } - } + // for gestures UX + exchangeOverlayView(isBringToFrontThanGestureView: false) + UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut) { + overlayHostingController.view.alpha = 0 + } completion: { [weak self] _ in + guard let self = self else { return } + self.exchangeOverlayView(isBringToFrontThanGestureView: true) + self.viewModel.isHiddenOverlay = true + } + } - private func toggleOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } + private func toggleOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } - if overlayHostingController.view.alpha < 1 { - showOverlay() - } else { - hideOverlay() - } - } + if overlayHostingController.view.alpha < 1 { + showOverlay() + } else { + hideOverlay() + } + } } // MARK: Show/Hide Locked Overlay extension VLCPlayerViewController { - private func showLockedOverlay() { - guard lockedOverlayView.alpha != 1 else { return } + private func showLockedOverlay() { + guard lockedOverlayView.alpha != 1 else { return } - UIView.animate(withDuration: 0.2) { - self.lockedOverlayView.alpha = 1 - } - } + UIView.animate(withDuration: 0.2) { + self.lockedOverlayView.alpha = 1 + } + } - private func hideLockedOverlay() { - guard !UIAccessibility.isVoiceOverRunning else { return } + private func hideLockedOverlay() { + guard !UIAccessibility.isVoiceOverRunning else { return } - guard lockedOverlayView.alpha != 0 else { return } + guard lockedOverlayView.alpha != 0 else { return } - UIView.animate(withDuration: 0.2) { - self.lockedOverlayView.alpha = 0 - } - } + UIView.animate(withDuration: 0.2) { + self.lockedOverlayView.alpha = 0 + } + } - private func toggleLockedOverlay() { - if lockedOverlayView.alpha < 1 { - showLockedOverlay() - } else { - hideLockedOverlay() - } - } + private func toggleLockedOverlay() { + if lockedOverlayView.alpha < 1 { + showLockedOverlay() + } else { + hideLockedOverlay() + } + } } // MARK: Show/Hide System Control extension VLCPlayerViewController { - private func showBrightnessOverlay() { - guard !displayingOverlay else { return } + private func showBrightnessOverlay() { + guard !displayingOverlay else { return } - let imageAttachment = NSTextAttachment() - imageAttachment.image = UIImage(systemName: "sun.max", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? - .withTintColor(.white) + let imageAttachment = NSTextAttachment() + imageAttachment.image = UIImage(systemName: "sun.max", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? + .withTintColor(.white) - let attributedString = NSMutableAttributedString() - attributedString.append(.init(attachment: imageAttachment)) - attributedString.append(.init(string: " \(String(format: "%.0f", UIScreen.main.brightness * 100))%")) - systemControlOverlayLabel.attributedText = attributedString - systemControlOverlayLabel.layer.removeAllAnimations() + let attributedString = NSMutableAttributedString() + attributedString.append(.init(attachment: imageAttachment)) + attributedString.append(.init(string: " \(String(format: "%.0f", UIScreen.main.brightness * 100))%")) + systemControlOverlayLabel.attributedText = attributedString + systemControlOverlayLabel.layer.removeAllAnimations() - UIView.animate(withDuration: 0.1) { - self.systemControlOverlayLabel.alpha = 1 - } - } + UIView.animate(withDuration: 0.1) { + self.systemControlOverlayLabel.alpha = 1 + } + } - private func showVolumeOverlay() { - guard !displayingOverlay, - let value = (volumeView.subviews.first as? UISlider)?.value else { return } + private func showVolumeOverlay() { + guard !displayingOverlay, + let value = (volumeView.subviews.first as? UISlider)?.value else { return } - let imageAttachment = NSTextAttachment() - imageAttachment.image = UIImage(systemName: "speaker.wave.2", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? - .withTintColor(.white) + let imageAttachment = NSTextAttachment() + imageAttachment.image = UIImage(systemName: "speaker.wave.2", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? + .withTintColor(.white) - let attributedString = NSMutableAttributedString() - attributedString.append(.init(attachment: imageAttachment)) - attributedString.append(.init(string: " \(String(format: "%.0f", value * 100))%")) - systemControlOverlayLabel.attributedText = attributedString - systemControlOverlayLabel.layer.removeAllAnimations() + let attributedString = NSMutableAttributedString() + attributedString.append(.init(attachment: imageAttachment)) + attributedString.append(.init(string: " \(String(format: "%.0f", value * 100))%")) + systemControlOverlayLabel.attributedText = attributedString + systemControlOverlayLabel.layer.removeAllAnimations() - UIView.animate(withDuration: 0.1) { - self.systemControlOverlayLabel.alpha = 1 - } - } + UIView.animate(withDuration: 0.1) { + self.systemControlOverlayLabel.alpha = 1 + } + } - private func showSliderOverlay() { - let imageAttachment = NSTextAttachment() - imageAttachment.image = UIImage(systemName: "clock.arrow.circlepath", - withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? - .withTintColor(.white) + private func showSliderOverlay() { + let imageAttachment = NSTextAttachment() + imageAttachment.image = UIImage( + systemName: "clock.arrow.circlepath", + withConfiguration: UIImage.SymbolConfiguration(pointSize: 48) + )? + .withTintColor(.white) - let attributedString = NSMutableAttributedString() - attributedString.append(.init(attachment: imageAttachment)) - attributedString.append(.init(string: " \(viewModel.scrubbingTimeLabelText) (\(viewModel.leftLabelText))")) - systemControlOverlayLabel.attributedText = attributedString - systemControlOverlayLabel.layer.removeAllAnimations() + let attributedString = NSMutableAttributedString() + attributedString.append(.init(attachment: imageAttachment)) + attributedString.append(.init(string: " \(viewModel.scrubbingTimeLabelText) (\(viewModel.leftLabelText))")) + systemControlOverlayLabel.attributedText = attributedString + systemControlOverlayLabel.layer.removeAllAnimations() - UIView.animate(withDuration: 0.1) { - self.systemControlOverlayLabel.alpha = 1 - } - } + UIView.animate(withDuration: 0.1) { + self.systemControlOverlayLabel.alpha = 1 + } + } - private func hideSystemControlOverlay() { - UIView.animate(withDuration: 0.75) { - self.systemControlOverlayLabel.alpha = 0 - } - } + private func hideSystemControlOverlay() { + UIView.animate(withDuration: 0.75) { + self.systemControlOverlayLabel.alpha = 0 + } + } } // MARK: Show/Hide Jump extension VLCPlayerViewController { - private func flashJumpBackwardOverlay() { - guard !displayingOverlay, let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } + private func flashJumpBackwardOverlay() { + guard !displayingOverlay, let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } - currentJumpBackwardOverlayView.layer.removeAllAnimations() + currentJumpBackwardOverlayView.layer.removeAllAnimations() - UIView.animate(withDuration: 0.1) { - currentJumpBackwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpBackwardOverlay() - } - } + UIView.animate(withDuration: 0.1) { + currentJumpBackwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpBackwardOverlay() + } + } - private func hideJumpBackwardOverlay() { - guard let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } + private func hideJumpBackwardOverlay() { + guard let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } - UIView.animate(withDuration: 0.3) { - currentJumpBackwardOverlayView.alpha = 0 - } - } + UIView.animate(withDuration: 0.3) { + currentJumpBackwardOverlayView.alpha = 0 + } + } - private func flashJumpFowardOverlay() { - guard !displayingOverlay, let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } + private func flashJumpFowardOverlay() { + guard !displayingOverlay, let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } - currentJumpForwardOverlayView.layer.removeAllAnimations() + currentJumpForwardOverlayView.layer.removeAllAnimations() - UIView.animate(withDuration: 0.1) { - currentJumpForwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpForwardOverlay() - } - } + UIView.animate(withDuration: 0.1) { + currentJumpForwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpForwardOverlay() + } + } - private func hideJumpForwardOverlay() { - guard let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } + private func hideJumpForwardOverlay() { + guard let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } - UIView.animate(withDuration: 0.3) { - currentJumpForwardOverlayView.alpha = 0 - } - } + UIView.animate(withDuration: 0.3) { + currentJumpForwardOverlayView.alpha = 0 + } + } } // MARK: Hide/Show Chapters extension VLCPlayerViewController { - private func showChaptersOverlay() { - guard let overlayHostingController = currentChapterOverlayHostingController else { return } + private func showChaptersOverlay() { + guard let overlayHostingController = currentChapterOverlayHostingController else { return } - guard overlayHostingController.view.alpha != 1 else { return } + guard overlayHostingController.view.alpha != 1 else { return } - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 1 - } - } + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 1 + } + } - private func hideChaptersOverlay() { - guard let overlayHostingController = currentChapterOverlayHostingController else { return } + private func hideChaptersOverlay() { + guard let overlayHostingController = currentChapterOverlayHostingController else { return } - guard overlayHostingController.view.alpha != 0 else { return } + guard overlayHostingController.view.alpha != 0 else { return } - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 0 - } - } + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 0 + } + } } // MARK: OverlayTimer extension VLCPlayerViewController { - private func restartOverlayDismissTimer(interval: Double = 3) { - overlayDismissTimer?.invalidate() - overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, - target: self, - selector: #selector(dismissTimerFired), - userInfo: nil, - repeats: false) - } + private func restartOverlayDismissTimer(interval: Double = 3) { + overlayDismissTimer?.invalidate() + overlayDismissTimer = Timer.scheduledTimer( + timeInterval: interval, + target: self, + selector: #selector(dismissTimerFired), + userInfo: nil, + repeats: false + ) + } - @objc - private func dismissTimerFired() { - hideOverlay() - hideLockedOverlay() - } + @objc + private func dismissTimerFired() { + hideOverlay() + hideLockedOverlay() + } - private func stopOverlayDismissTimer() { - overlayDismissTimer?.invalidate() - } + private func stopOverlayDismissTimer() { + overlayDismissTimer?.invalidate() + } } // MARK: VLCMediaPlayerDelegate extension VLCPlayerViewController: VLCMediaPlayerDelegate { - // MARK: mediaPlayerStateChanged + // MARK: mediaPlayerStateChanged - func mediaPlayerStateChanged(_ aNotification: Notification) { - // Don't show buffering if paused, usually here while scrubbing - if vlcMediaPlayer.state == .buffering, viewModel.playerState == .paused { - return - } + func mediaPlayerStateChanged(_ aNotification: Notification) { + // Don't show buffering if paused, usually here while scrubbing + if vlcMediaPlayer.state == .buffering, viewModel.playerState == .paused { + return + } - viewModel.playerState = vlcMediaPlayer.state + viewModel.playerState = vlcMediaPlayer.state - if vlcMediaPlayer.state == VLCMediaPlayerState.ended { - if viewModel.autoplayEnabled, viewModel.nextItemVideoPlayerViewModel != nil { - didSelectPlayNextItem() - } else { - didSelectClose() - } - } - } + if vlcMediaPlayer.state == VLCMediaPlayerState.ended { + if viewModel.autoplayEnabled, viewModel.nextItemVideoPlayerViewModel != nil { + didSelectPlayNextItem() + } else { + didSelectClose() + } + } + } - // MARK: mediaPlayerTimeChanged + // MARK: mediaPlayerTimeChanged - func mediaPlayerTimeChanged(_ aNotification: Notification) { - if !viewModel.sliderIsScrubbing { - viewModel.sliderPercentage = Double(vlcMediaPlayer.position) - } + func mediaPlayerTimeChanged(_ aNotification: Notification) { + if !viewModel.sliderIsScrubbing { + viewModel.sliderPercentage = Double(vlcMediaPlayer.position) + } - // Have to manually set playing because VLCMediaPlayer doesn't - // properly set it itself - if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 { - viewModel.playerState = VLCMediaPlayerState.playing - } + // Have to manually set playing because VLCMediaPlayer doesn't + // properly set it itself + if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 { + viewModel.playerState = VLCMediaPlayerState.playing + } - // If needing to fix subtitle streams during playback - if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.selectedSubtitleStreamIndex, - viewModel.subtitlesEnabled - { - didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) - } + // If needing to fix subtitle streams during playback + if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.selectedSubtitleStreamIndex, + viewModel.subtitlesEnabled + { + didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) + } - // If needing to fix audio stream during playback - if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { - didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) - } + // If needing to fix audio stream during playback + if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { + didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) + } - lastPlayerTicks = currentPlayerTicks + lastPlayerTicks = currentPlayerTicks - // Send progress report every 5 seconds - if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { - viewModel.sendProgressReport() + // Send progress report every 5 seconds + if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } - } + lastProgressReportTicks = currentPlayerTicks + } + } } // MARK: PlayerOverlayDelegate and more extension VLCPlayerViewController: PlayerOverlayDelegate { - func didSelectAudioStream(index: Int) { - vlcMediaPlayer.currentAudioTrackIndex = Int32(index) + func didSelectAudioStream(index: Int) { + vlcMediaPlayer.currentAudioTrackIndex = Int32(index) - viewModel.sendProgressReport() + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } + lastProgressReportTicks = currentPlayerTicks + } - /// Do not call when setting to index -1 - func didSelectSubtitleStream(index: Int) { - viewModel.subtitlesEnabled = true - vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) + /// Do not call when setting to index -1 + func didSelectSubtitleStream(index: Int) { + viewModel.subtitlesEnabled = true + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) - viewModel.sendProgressReport() + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } + lastProgressReportTicks = currentPlayerTicks + } - @objc - func didSelectClose() { - vlcMediaPlayer.stop() + @objc + func didSelectClose() { + vlcMediaPlayer.stop() - viewModel.sendStopReport() + viewModel.sendStopReport() - dismiss(animated: true, completion: nil) - } + dismiss(animated: true, completion: nil) + } - func didToggleSubtitles(newValue: Bool) { - if newValue { - vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex) - } else { - vlcMediaPlayer.currentVideoSubTitleIndex = -1 - } - } + func didToggleSubtitles(newValue: Bool) { + if newValue { + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex) + } else { + vlcMediaPlayer.currentVideoSubTitleIndex = -1 + } + } - // TODO: Implement properly in overlays - func didSelectMenu() { - stopOverlayDismissTimer() - } + // TODO: Implement properly in overlays + func didSelectMenu() { + stopOverlayDismissTimer() + } - // TODO: Implement properly in overlays - func didDeselectMenu() { - restartOverlayDismissTimer() - } + // TODO: Implement properly in overlays + func didDeselectMenu() { + restartOverlayDismissTimer() + } - @objc - func didSelectBackward() { - flashJumpBackwardOverlay() + @objc + func didSelectBackward() { + flashJumpBackwardOverlay() - vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) + vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) - if displayingOverlay { - restartOverlayDismissTimer() - } + if displayingOverlay { + restartOverlayDismissTimer() + } - viewModel.sendProgressReport() + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } + lastProgressReportTicks = currentPlayerTicks + } - @objc - func didSelectForward() { - flashJumpFowardOverlay() + @objc + func didSelectForward() { + flashJumpFowardOverlay() - vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) + vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) - if displayingOverlay { - restartOverlayDismissTimer() - } + if displayingOverlay { + restartOverlayDismissTimer() + } - viewModel.sendProgressReport() + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } + lastProgressReportTicks = currentPlayerTicks + } - @objc - func didSelectMain() { - switch viewModel.playerState { - case .buffering: - vlcMediaPlayer.play() - restartOverlayDismissTimer() - case .playing: - viewModel.sendPauseReport(paused: true) - vlcMediaPlayer.pause() - restartOverlayDismissTimer(interval: 5) - case .paused: - viewModel.sendPauseReport(paused: false) - vlcMediaPlayer.play() - restartOverlayDismissTimer() - default: () - } - } + @objc + func didSelectMain() { + switch viewModel.playerState { + case .buffering: + vlcMediaPlayer.play() + restartOverlayDismissTimer() + case .playing: + viewModel.sendPauseReport(paused: true) + vlcMediaPlayer.pause() + restartOverlayDismissTimer(interval: 5) + case .paused: + viewModel.sendPauseReport(paused: false) + vlcMediaPlayer.play() + restartOverlayDismissTimer() + default: () + } + } - func didGenerallyTap(point: CGPoint? = nil) { - if isGesturesLocked { - toggleLockedOverlay() - } else { - if viewModel.jumpGesturesEnabled, - let point = point - { - let tempStack = tapLocationStack - tapLocationStack.append(point) + func didGenerallyTap(point: CGPoint? = nil) { + if isGesturesLocked { + toggleLockedOverlay() + } else { + if viewModel.jumpGesturesEnabled, + let point = point + { + let tempStack = tapLocationStack + tapLocationStack.append(point) - if isSameLocationWithLast(point: point, in: tempStack) { - isTapWhenJumping = false - isJumping = true - tapLocationStack.removeAll() - jumpingCompletionWork?.cancel() - jumpingCompletionWork = DispatchWorkItem(block: { [weak self] in - guard let self = self else { return } - self.isJumping = false - guard self.isTapWhenJumping else { return } - self.isTapWhenJumping = false - self.toggleOverlay() - }) - DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: jumpingCompletionWork!) + if isSameLocationWithLast(point: point, in: tempStack) { + isTapWhenJumping = false + isJumping = true + tapLocationStack.removeAll() + jumpingCompletionWork?.cancel() + jumpingCompletionWork = DispatchWorkItem(block: { [weak self] in + guard let self = self else { return } + self.isJumping = false + guard self.isTapWhenJumping else { return } + self.isTapWhenJumping = false + self.toggleOverlay() + }) + DispatchQueue.main.asyncAfter(deadline: .now() + 1, execute: jumpingCompletionWork!) - hideOverlay() - if point.x > (mainGestureView.frame.width / 2) { - didSelectForward() - } else { - didSelectBackward() - } - return - } else { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.33) { [weak self] in - guard let self = self else { return } - guard !self.tapLocationStack.isEmpty else { return } - self.tapLocationStack.removeFirst() - } - } - } - guard !isJumping else { - isTapWhenJumping = true - return - } + hideOverlay() + if point.x > (mainGestureView.frame.width / 2) { + didSelectForward() + } else { + didSelectBackward() + } + return + } else { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.33) { [weak self] in + guard let self = self else { return } + guard !self.tapLocationStack.isEmpty else { return } + self.tapLocationStack.removeFirst() + } + } + } + guard !isJumping else { + isTapWhenJumping = true + return + } - toggleOverlay() - } + toggleOverlay() + } - restartOverlayDismissTimer(interval: 5) - } + restartOverlayDismissTimer(interval: 5) + } - private func isSameLocationWithLast(point: CGPoint, in stack: [CGPoint]) -> Bool { - guard let last = stack.last else { return false } - if last.x > (mainGestureView.frame.width / 2) { - return point.x > (mainGestureView.frame.width / 2) - } else { - return point.x <= (mainGestureView.frame.width / 2) - } - } + private func isSameLocationWithLast(point: CGPoint, in stack: [CGPoint]) -> Bool { + guard let last = stack.last else { return false } + if last.x > (mainGestureView.frame.width / 2) { + return point.x > (mainGestureView.frame.width / 2) + } else { + return point.x <= (mainGestureView.frame.width / 2) + } + } - func didBeginScrubbing() { - stopOverlayDismissTimer() - } + func didBeginScrubbing() { + stopOverlayDismissTimer() + } - func didEndScrubbing() { - setMediaPlayerTimeAtCurrentSlider() + func didEndScrubbing() { + setMediaPlayerTimeAtCurrentSlider() - restartOverlayDismissTimer() + restartOverlayDismissTimer() - viewModel.sendProgressReport() + viewModel.sendProgressReport() - lastProgressReportTicks = currentPlayerTicks - } + lastProgressReportTicks = currentPlayerTicks + } - @objc - func didSelectPlayPreviousItem() { - if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel { - setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel) - startPlayback() - } - } + @objc + func didSelectPlayPreviousItem() { + if let previousItemVideoPlayerViewModel = viewModel.previousItemVideoPlayerViewModel { + setupMediaPlayer(newViewModel: previousItemVideoPlayerViewModel) + startPlayback() + } + } - @objc - func didSelectPlayNextItem() { - if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel { - setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel) - startPlayback() - } - } + @objc + func didSelectPlayNextItem() { + if let nextItemVideoPlayerViewModel = viewModel.nextItemVideoPlayerViewModel { + setupMediaPlayer(newViewModel: nextItemVideoPlayerViewModel) + startPlayback() + } + } - @objc - func didSelectPreviousPlaybackSpeed() { - if let previousPlaybackSpeed = viewModel.playbackSpeed.previous { - viewModel.playbackSpeed = previousPlaybackSpeed - } - } + @objc + func didSelectPreviousPlaybackSpeed() { + if let previousPlaybackSpeed = viewModel.playbackSpeed.previous { + viewModel.playbackSpeed = previousPlaybackSpeed + } + } - @objc - func didSelectNextPlaybackSpeed() { - if let nextPlaybackSpeed = viewModel.playbackSpeed.next { - viewModel.playbackSpeed = nextPlaybackSpeed - } - } + @objc + func didSelectNextPlaybackSpeed() { + if let nextPlaybackSpeed = viewModel.playbackSpeed.next { + viewModel.playbackSpeed = nextPlaybackSpeed + } + } - @objc - func didSelectNormalPlaybackSpeed() { - viewModel.playbackSpeed = .one - } + @objc + func didSelectNormalPlaybackSpeed() { + viewModel.playbackSpeed = .one + } - func didSelectChapters() { - if displayingChapterOverlay { - hideChaptersOverlay() - } else { - hideOverlay() - showChaptersOverlay() - } - } + func didSelectChapters() { + if displayingChapterOverlay { + hideChaptersOverlay() + } else { + hideOverlay() + showChaptersOverlay() + } + } - func didSelectChapter(_ chapter: ChapterInfo) { - let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) - let chapterSeconds = Double((chapter.startPositionTicks ?? 0) / 10_000_000) - let newPositionOffset = chapterSeconds - videoPosition + func didSelectChapter(_ chapter: ChapterInfo) { + let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) + let chapterSeconds = Double((chapter.startPositionTicks ?? 0) / 10_000_000) + let newPositionOffset = chapterSeconds - videoPosition - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } + if newPositionOffset > 0 { + vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) + } else { + vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) + } - viewModel.sendProgressReport() - } + viewModel.sendProgressReport() + } - func didSelectScreenFill() { - isScreenFilled.toggle() + func didSelectScreenFill() { + isScreenFilled.toggle() - if isScreenFilled { - fillScreen() - } else { - shrinkScreen() - } - } + if isScreenFilled { + fillScreen() + } else { + shrinkScreen() + } + } - private func fillScreen(screenSize: CGSize = UIScreen.main.bounds.size) { - let videoSize = vlcMediaPlayer.videoSize - let fillSize = CGSize.aspectFill(aspectRatio: videoSize, minimumSize: screenSize) + private func fillScreen(screenSize: CGSize = UIScreen.main.bounds.size) { + let videoSize = vlcMediaPlayer.videoSize + let fillSize = CGSize.aspectFill(aspectRatio: videoSize, minimumSize: screenSize) - let scale: CGFloat + let scale: CGFloat - if fillSize.height > screenSize.height { - scale = fillSize.height / screenSize.height - } else { - scale = fillSize.width / screenSize.width - } + if fillSize.height > screenSize.height { + scale = fillSize.height / screenSize.height + } else { + scale = fillSize.width / screenSize.width + } - UIView.animate(withDuration: 0.2) { - self.videoContentView.transform = CGAffineTransform(scaleX: scale, y: scale) - } - } + UIView.animate(withDuration: 0.2) { + self.videoContentView.transform = CGAffineTransform(scaleX: scale, y: scale) + } + } - private func shrinkScreen() { - UIView.animate(withDuration: 0.2) { - self.videoContentView.transform = .identity - } - } + private func shrinkScreen() { + UIView.animate(withDuration: 0.2) { + self.videoContentView.transform = .identity + } + } - func getScreenFilled() -> Bool { - isScreenFilled - } + func getScreenFilled() -> Bool { + isScreenFilled + } - func isVideoAspectRatioGreater() -> Bool { - let screenSize = UIScreen.main.bounds.size - let videoSize = vlcMediaPlayer.videoSize + func isVideoAspectRatioGreater() -> Bool { + let screenSize = UIScreen.main.bounds.size + let videoSize = vlcMediaPlayer.videoSize - let screenAspectRatio = screenSize.width / screenSize.height - let videoAspectRatio = videoSize.width / videoSize.height + let screenAspectRatio = screenSize.width / screenSize.height + let videoAspectRatio = videoSize.width / videoSize.height - return videoAspectRatio > screenAspectRatio - } + return videoAspectRatio > screenAspectRatio + } } diff --git a/WidgetExtension/JellyfinWidget.swift b/WidgetExtension/JellyfinWidget.swift index e59c03c9..2764028b 100644 --- a/WidgetExtension/JellyfinWidget.swift +++ b/WidgetExtension/JellyfinWidget.swift @@ -11,8 +11,8 @@ import WidgetKit @main struct JellyfinWidgetBundle: WidgetBundle { - @WidgetBundleBuilder - var body: some Widget { - NextUpWidget() - } + @WidgetBundleBuilder + var body: some Widget { + NextUpWidget() + } } diff --git a/WidgetExtension/NextUpWidget.swift b/WidgetExtension/NextUpWidget.swift index c22d8b6d..dee25aeb 100644 --- a/WidgetExtension/NextUpWidget.swift +++ b/WidgetExtension/NextUpWidget.swift @@ -13,458 +13,530 @@ import SwiftUI import WidgetKit enum WidgetError: String, Error { - case unknown - case emptyServer - case emptyUser - case emptyHeader + case unknown + case emptyServer + case emptyUser + case emptyHeader } struct NextUpWidgetProvider: TimelineProvider { - func placeholder(in context: Context) -> NextUpEntry { - NextUpEntry(date: Date(), items: [], error: nil) - } + func placeholder(in context: Context) -> NextUpEntry { + NextUpEntry(date: Date(), items: [], error: nil) + } - func getSnapshot(in context: Context, completion: @escaping (NextUpEntry) -> Void) { - guard let currentLogin = SessionManager.main.currentLogin else { return } + func getSnapshot(in context: Context, completion: @escaping (NextUpEntry) -> Void) { + guard let currentLogin = SessionManager.main.currentLogin else { return } - let currentDate = Date() - let server = currentLogin.server - let savedUser = currentLogin.user - var tempCancellables = Set() + let currentDate = Date() + let server = currentLogin.server + let savedUser = currentLogin.user + var tempCancellables = Set() - JellyfinAPIAPI.basePath = server.currentURI - TvShowsAPI.getNextUp(userId: savedUser.id, limit: 3, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], - imageTypeLimit: 1, enableImageTypes: [.primary, .backdrop, .thumb]) - .subscribe(on: DispatchQueue.global(qos: .background)) - .sink(receiveCompletion: { result in - switch result { - case .finished: - break - case let .failure(error): - completion(NextUpEntry(date: currentDate, items: [], error: error)) - } - }, receiveValue: { response in - let dispatchGroup = DispatchGroup() - let items = response.items ?? [] - var downloadedItems = [(BaseItemDto, UIImage?)]() - items.enumerated().forEach { _, item in - dispatchGroup.enter() - ImagePipeline.shared.loadImage(with: item.getBackdropImage(maxWidth: 320)) { result in - guard case let .success(image) = result else { - dispatchGroup.leave() - return - } - downloadedItems.append((item, image.image)) - dispatchGroup.leave() - } - } + JellyfinAPIAPI.basePath = server.currentURI + TvShowsAPI.getNextUp( + userId: savedUser.id, + limit: 3, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + imageTypeLimit: 1, + enableImageTypes: [.primary, .backdrop, .thumb] + ) + .subscribe(on: DispatchQueue.global(qos: .background)) + .sink(receiveCompletion: { result in + switch result { + case .finished: + break + case let .failure(error): + completion(NextUpEntry(date: currentDate, items: [], error: error)) + } + }, receiveValue: { response in + let dispatchGroup = DispatchGroup() + let items = response.items ?? [] + var downloadedItems = [(BaseItemDto, UIImage?)]() + items.enumerated().forEach { _, item in + dispatchGroup.enter() + ImagePipeline.shared.loadImage(with: item.getBackdropImage(maxWidth: 320)) { result in + guard case let .success(image) = result else { + dispatchGroup.leave() + return + } + downloadedItems.append((item, image.image)) + dispatchGroup.leave() + } + } - dispatchGroup.notify(queue: .main) { - completion(NextUpEntry(date: currentDate, items: downloadedItems, error: nil)) - } - }) - .store(in: &tempCancellables) - } + dispatchGroup.notify(queue: .main) { + completion(NextUpEntry(date: currentDate, items: downloadedItems, error: nil)) + } + }) + .store(in: &tempCancellables) + } - func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - guard let currentLogin = SessionManager.main.currentLogin else { return } + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + guard let currentLogin = SessionManager.main.currentLogin else { return } - let currentDate = Date() - let entryDate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)! - let server = currentLogin.server - let savedUser = currentLogin.user + let currentDate = Date() + let entryDate = Calendar.current.date(byAdding: .hour, value: 1, to: currentDate)! + let server = currentLogin.server + let savedUser = currentLogin.user - var tempCancellables = Set() + var tempCancellables = Set() - JellyfinAPIAPI.basePath = server.currentURI - TvShowsAPI.getNextUp(userId: savedUser.id, limit: 3, - fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], - imageTypeLimit: 1, enableImageTypes: [.primary, .backdrop, .thumb]) - .subscribe(on: DispatchQueue.global(qos: .background)) - .sink(receiveCompletion: { result in - switch result { - case .finished: - break - case let .failure(error): - completion(Timeline(entries: [NextUpEntry(date: currentDate, items: [], error: error)], policy: .after(entryDate))) - } - }, receiveValue: { response in - let dispatchGroup = DispatchGroup() - let items = response.items ?? [] - var downloadedItems = [(BaseItemDto, UIImage?)]() - items.enumerated().forEach { _, item in - dispatchGroup.enter() - ImagePipeline.shared.loadImage(with: item.getBackdropImage(maxWidth: 320)) { result in - guard case let .success(image) = result else { - dispatchGroup.leave() - return - } - downloadedItems.append((item, image.image)) - dispatchGroup.leave() - } - } + JellyfinAPIAPI.basePath = server.currentURI + TvShowsAPI.getNextUp( + userId: savedUser.id, + limit: 3, + fields: [.primaryImageAspectRatio, .seriesPrimaryImage, .seasonUserData, .overview, .genres, .people], + imageTypeLimit: 1, + enableImageTypes: [.primary, .backdrop, .thumb] + ) + .subscribe(on: DispatchQueue.global(qos: .background)) + .sink(receiveCompletion: { result in + switch result { + case .finished: + break + case let .failure(error): + completion(Timeline(entries: [NextUpEntry(date: currentDate, items: [], error: error)], policy: .after(entryDate))) + } + }, receiveValue: { response in + let dispatchGroup = DispatchGroup() + let items = response.items ?? [] + var downloadedItems = [(BaseItemDto, UIImage?)]() + items.enumerated().forEach { _, item in + dispatchGroup.enter() + ImagePipeline.shared.loadImage(with: item.getBackdropImage(maxWidth: 320)) { result in + guard case let .success(image) = result else { + dispatchGroup.leave() + return + } + downloadedItems.append((item, image.image)) + dispatchGroup.leave() + } + } - dispatchGroup.notify(queue: .main) { - completion(Timeline(entries: [NextUpEntry(date: currentDate, items: downloadedItems, error: nil)], - policy: .after(entryDate))) - } - }) - .store(in: &tempCancellables) - } + dispatchGroup.notify(queue: .main) { + completion(Timeline( + entries: [NextUpEntry(date: currentDate, items: downloadedItems, error: nil)], + policy: .after(entryDate) + )) + } + }) + .store(in: &tempCancellables) + } } struct NextUpEntry: TimelineEntry { - let date: Date - let items: [(BaseItemDto, UIImage?)] - let error: Error? + let date: Date + let items: [(BaseItemDto, UIImage?)] + let error: Error? } struct NextUpEntryView: View { - var entry: NextUpWidgetProvider.Entry + var entry: NextUpWidgetProvider.Entry - @Environment(\.widgetFamily) - var family + @Environment(\.widgetFamily) + var family - @ViewBuilder - var body: some View { - Group { - if let error = entry.error { - HStack { - Image(systemName: "exclamationmark.octagon") - Text((error as? WidgetError)?.rawValue ?? "") - } - .background(Color.blue) - } else if entry.items.isEmpty { - L10n.emptyNextUp.text - .font(.body) - .bold() - .foregroundColor(.primary) - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) - } else { - switch family { - case .systemSmall: - small(item: entry.items.first) - case .systemMedium: - medium(items: entry.items) - case .systemLarge: - large(items: entry.items) - default: - EmptyView() - } - } - } - .background(Color(.secondarySystemBackground)) - } + @ViewBuilder + var body: some View { + Group { + if let error = entry.error { + HStack { + Image(systemName: "exclamationmark.octagon") + Text((error as? WidgetError)?.rawValue ?? "") + } + .background(Color.blue) + } else if entry.items.isEmpty { + L10n.emptyNextUp.text + .font(.body) + .bold() + .foregroundColor(.primary) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + } else { + switch family { + case .systemSmall: + small(item: entry.items.first) + case .systemMedium: + medium(items: entry.items) + case .systemLarge: + large(items: entry.items) + default: + EmptyView() + } + } + } + .background(Color(.secondarySystemBackground)) + } } extension NextUpEntryView { - var smallVideoPlaceholderView: some View { - VStack(alignment: .leading) { - Color(.systemGray) - .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) - .cornerRadius(8) - .shadow(radius: 8) - Color(.systemGray2) - .frame(width: 100, height: 10) - Color(.systemGray3) - .frame(width: 80, height: 10) - } - } + var smallVideoPlaceholderView: some View { + VStack(alignment: .leading) { + Color(.systemGray) + .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) + .cornerRadius(8) + .shadow(radius: 8) + Color(.systemGray2) + .frame(width: 100, height: 10) + Color(.systemGray3) + .frame(width: 80, height: 10) + } + } - var largeVideoPlaceholderView: some View { - HStack(spacing: 20) { - Color(.systemGray) - .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) - .cornerRadius(8) - .shadow(radius: 8) - VStack(alignment: .leading, spacing: 8) { - Color(.systemGray2) - .frame(width: 100, height: 10) - Color(.systemGray3) - .frame(width: 80, height: 10) - } - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - } - } + var largeVideoPlaceholderView: some View { + HStack(spacing: 20) { + Color(.systemGray) + .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) + .cornerRadius(8) + .shadow(radius: 8) + VStack(alignment: .leading, spacing: 8) { + Color(.systemGray2) + .frame(width: 100, height: 10) + Color(.systemGray3) + .frame(width: 80, height: 10) + } + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + } } extension NextUpEntryView { - var headerSymbol: some View { - Image("WidgetHeaderSymbol") - .resizable() - .frame(width: 12, height: 12) - .cornerRadius(4) - .shadow(radius: 8) - } + var headerSymbol: some View { + Image("WidgetHeaderSymbol") + .resizable() + .frame(width: 12, height: 12) + .cornerRadius(4) + .shadow(radius: 8) + } - func smallVideoView(item: (BaseItemDto, UIImage?)) -> some View { - let url = URL(string: "widget-extension://Users/\(SessionManager.main.currentLogin.user.id)/Items/\(item.0.id!)")! - return Link(destination: url, label: { - VStack(alignment: .leading) { - if let image = item.1 { - Image(uiImage: image) - .resizable() - .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) - .clipped() - .cornerRadius(8) - .shadow(radius: 8) - } - Text(item.0.seriesName ?? "") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.primary) - .lineLimit(1) - Text("\(item.0.name ?? "") · \(L10n.seasonAndEpisode(String(item.0.parentIndexNumber ?? 0), String(item.0.indexNumber ?? 0)))") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - } - }) - } + func smallVideoView(item: (BaseItemDto, UIImage?)) -> some View { + let url = URL(string: "widget-extension://Users/\(SessionManager.main.currentLogin.user.id)/Items/\(item.0.id!)")! + return Link(destination: url, label: { + VStack(alignment: .leading) { + if let image = item.1 { + Image(uiImage: image) + .resizable() + .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) + .clipped() + .cornerRadius(8) + .shadow(radius: 8) + } + Text(item.0.seriesName ?? "") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) + Text( + "\(item.0.name ?? "") · \(L10n.seasonAndEpisode(String(item.0.parentIndexNumber ?? 0), String(item.0.indexNumber ?? 0)))" + ) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .lineLimit(1) + } + }) + } - func largeVideoView(item: (BaseItemDto, UIImage?)) -> some View { - let url = URL(string: "widget-extension://Users/\(SessionManager.main.currentLogin.user.id)/Items/\(item.0.id!)")! - return Link(destination: url, label: { - HStack(spacing: 20) { - if let image = item.1 { - Image(uiImage: image) - .resizable() - .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) - .clipped() - .cornerRadius(8) - .shadow(radius: 8) - } - VStack(alignment: .leading, spacing: 8) { - Text(item.0.seriesName ?? "") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.primary) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + func largeVideoView(item: (BaseItemDto, UIImage?)) -> some View { + let url = URL(string: "widget-extension://Users/\(SessionManager.main.currentLogin.user.id)/Items/\(item.0.id!)")! + return Link(destination: url, label: { + HStack(spacing: 20) { + if let image = item.1 { + Image(uiImage: image) + .resizable() + .aspectRatio(.init(width: 1, height: 0.5625), contentMode: .fill) + .clipped() + .cornerRadius(8) + .shadow(radius: 8) + } + VStack(alignment: .leading, spacing: 8) { + Text(item.0.seriesName ?? "") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.primary) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - Text("\(item.0.name ?? "") · \(L10n.seasonAndEpisode(String(item.0.parentIndexNumber ?? 0), String(item.0.indexNumber ?? 0)))") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - } - } - }) - } + Text( + "\(item.0.name ?? "") · \(L10n.seasonAndEpisode(String(item.0.parentIndexNumber ?? 0), String(item.0.indexNumber ?? 0)))" + ) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + } + }) + } } extension NextUpEntryView { - func small(item: (BaseItemDto, UIImage?)?) -> some View { - VStack(alignment: .trailing) { - headerSymbol - if let item = item { - smallVideoView(item: item) - } else { - smallVideoPlaceholderView - } - } - .padding(12) - } + func small(item: (BaseItemDto, UIImage?)?) -> some View { + VStack(alignment: .trailing) { + headerSymbol + if let item = item { + smallVideoView(item: item) + } else { + smallVideoPlaceholderView + } + } + .padding(12) + } - func medium(items: [(BaseItemDto, UIImage?)]) -> some View { - VStack(alignment: .trailing) { - headerSymbol - HStack(spacing: 16) { - if let firstItem = items[safe: 0] { - smallVideoView(item: firstItem) - } else { - smallVideoPlaceholderView - } - if let secondItem = items[safe: 1] { - smallVideoView(item: secondItem) - } else { - smallVideoPlaceholderView - } - } - } - .padding(12) - } + func medium(items: [(BaseItemDto, UIImage?)]) -> some View { + VStack(alignment: .trailing) { + headerSymbol + HStack(spacing: 16) { + if let firstItem = items[safe: 0] { + smallVideoView(item: firstItem) + } else { + smallVideoPlaceholderView + } + if let secondItem = items[safe: 1] { + smallVideoView(item: secondItem) + } else { + smallVideoPlaceholderView + } + } + } + .padding(12) + } - func large(items: [(BaseItemDto, UIImage?)]) -> some View { - VStack(spacing: 0) { - if let firstItem = items[safe: 0] { - let url = URL(string: "widget-extension://Users/\(SessionManager.main.currentLogin.user.id)/Items/\(firstItem.0.id!)")! - Link(destination: url, - label: { - ZStack(alignment: .topTrailing) { - ZStack(alignment: .bottomLeading) { - if let image = firstItem.1 { - Image(uiImage: image) - .centerCropped() - .innerShadow(color: Color.black.opacity(0.5), radius: 0.5) - } - VStack(alignment: .leading, spacing: 8) { - Text(firstItem.0.seriesName ?? "") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.white) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - Text("\(firstItem.0.name ?? "") · \(L10n.seasonAndEpisode(String(firstItem.0.parentIndexNumber ?? 0), String(firstItem.0.indexNumber ?? 0)))") - .font(.caption) - .fontWeight(.semibold) - .foregroundColor(.gray) - .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) - } - .shadow(radius: 8) - .padding(12) - } - headerSymbol - .padding(12) - } - .clipped() - .shadow(radius: 8) - }) - } - VStack(spacing: 8) { - if let secondItem = items[safe: 1] { - largeVideoView(item: secondItem) - } else { - largeVideoPlaceholderView - } - Divider() - if let thirdItem = items[safe: 2] { - largeVideoView(item: thirdItem) - } else { - largeVideoPlaceholderView - } - } - .padding(12) - } - } + func large(items: [(BaseItemDto, UIImage?)]) -> some View { + VStack(spacing: 0) { + if let firstItem = items[safe: 0] { + let url = URL(string: "widget-extension://Users/\(SessionManager.main.currentLogin.user.id)/Items/\(firstItem.0.id!)")! + Link( + destination: url, + label: { + ZStack(alignment: .topTrailing) { + ZStack(alignment: .bottomLeading) { + if let image = firstItem.1 { + Image(uiImage: image) + .centerCropped() + .innerShadow(color: Color.black.opacity(0.5), radius: 0.5) + } + VStack(alignment: .leading, spacing: 8) { + Text(firstItem.0.seriesName ?? "") + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.white) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + Text( + "\(firstItem.0.name ?? "") · \(L10n.seasonAndEpisode(String(firstItem.0.parentIndexNumber ?? 0), String(firstItem.0.indexNumber ?? 0)))" + ) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.gray) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + .shadow(radius: 8) + .padding(12) + } + headerSymbol + .padding(12) + } + .clipped() + .shadow(radius: 8) + } + ) + } + VStack(spacing: 8) { + if let secondItem = items[safe: 1] { + largeVideoView(item: secondItem) + } else { + largeVideoPlaceholderView + } + Divider() + if let thirdItem = items[safe: 2] { + largeVideoView(item: thirdItem) + } else { + largeVideoPlaceholderView + } + } + .padding(12) + } + } } struct NextUpWidget: Widget { - let kind: String = "NextUpWidget" + let kind: String = "NextUpWidget" - var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, - provider: NextUpWidgetProvider()) { entry in - NextUpEntryView(entry: entry) - } - .configurationDisplayName(L10n.nextUp) - .description("Keep watching where you left off or see what's up next.") - .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) - } + var body: some WidgetConfiguration { + StaticConfiguration( + kind: kind, + provider: NextUpWidgetProvider() + ) { entry in + NextUpEntryView(entry: entry) + } + .configurationDisplayName(L10n.nextUp) + .description("Keep watching where you left off or see what's up next.") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge]) + } } struct NextUpWidget_Previews: PreviewProvider { - static var previews: some View { - Group { - NextUpEntryView(entry: .init(date: Date(), - items: [(.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol"))], - error: nil)) - .previewContext(WidgetPreviewContext(family: .systemSmall)) - NextUpEntryView(entry: .init(date: Date(), - items: [ - (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol")), - (.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), - UIImage(named: "WidgetHeaderSymbol")), - ], - error: nil)) - .previewContext(WidgetPreviewContext(family: .systemMedium)) - NextUpEntryView(entry: .init(date: Date(), - items: [ - (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol")), - (.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), - UIImage(named: "WidgetHeaderSymbol")), - (.init(name: "Name2", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series2"), - UIImage(named: "WidgetHeaderSymbol")), - ], - error: nil)) - .previewContext(WidgetPreviewContext(family: .systemLarge)) - NextUpEntryView(entry: .init(date: Date(), - items: [(.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol"))], - error: nil)) - .previewContext(WidgetPreviewContext(family: .systemSmall)) - .preferredColorScheme(.dark) - NextUpEntryView(entry: .init(date: Date(), - items: [ - (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol")), - (.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), - UIImage(named: "WidgetHeaderSymbol")), - ], - error: nil)) - .previewContext(WidgetPreviewContext(family: .systemMedium)) - .preferredColorScheme(.dark) - NextUpEntryView(entry: .init(date: Date(), - items: [ - (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol")), - (.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), - UIImage(named: "WidgetHeaderSymbol")), - (.init(name: "Name2", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series2"), - UIImage(named: "WidgetHeaderSymbol")), - ], - error: nil)) - .previewContext(WidgetPreviewContext(family: .systemLarge)) - .preferredColorScheme(.dark) - NextUpEntryView(entry: .init(date: Date(), - items: [], - error: nil)) - .previewContext(WidgetPreviewContext(family: .systemSmall)) - .preferredColorScheme(.dark) - NextUpEntryView(entry: .init(date: Date(), - items: [ - (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol")), - ], - error: nil)) - .previewContext(WidgetPreviewContext(family: .systemMedium)) - .preferredColorScheme(.dark) - NextUpEntryView(entry: .init(date: Date(), - items: [ - (.init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), - UIImage(named: "WidgetHeaderSymbol")), - (.init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), - UIImage(named: "WidgetHeaderSymbol")), - ], - error: nil)) - .previewContext(WidgetPreviewContext(family: .systemLarge)) - .preferredColorScheme(.dark) - } - } + static var previews: some View { + Group { + NextUpEntryView(entry: .init( + date: Date(), + items: [( + .init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), + UIImage(named: "WidgetHeaderSymbol") + )], + error: nil + )) + .previewContext(WidgetPreviewContext(family: .systemSmall)) + NextUpEntryView(entry: .init( + date: Date(), + items: [ + ( + .init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), + UIImage(named: "WidgetHeaderSymbol") + ), + ( + .init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), + UIImage(named: "WidgetHeaderSymbol") + ), + ], + error: nil + )) + .previewContext(WidgetPreviewContext(family: .systemMedium)) + NextUpEntryView(entry: .init( + date: Date(), + items: [ + ( + .init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), + UIImage(named: "WidgetHeaderSymbol") + ), + ( + .init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), + UIImage(named: "WidgetHeaderSymbol") + ), + ( + .init(name: "Name2", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series2"), + UIImage(named: "WidgetHeaderSymbol") + ), + ], + error: nil + )) + .previewContext(WidgetPreviewContext(family: .systemLarge)) + NextUpEntryView(entry: .init( + date: Date(), + items: [( + .init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), + UIImage(named: "WidgetHeaderSymbol") + )], + error: nil + )) + .previewContext(WidgetPreviewContext(family: .systemSmall)) + .preferredColorScheme(.dark) + NextUpEntryView(entry: .init( + date: Date(), + items: [ + ( + .init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), + UIImage(named: "WidgetHeaderSymbol") + ), + ( + .init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), + UIImage(named: "WidgetHeaderSymbol") + ), + ], + error: nil + )) + .previewContext(WidgetPreviewContext(family: .systemMedium)) + .preferredColorScheme(.dark) + NextUpEntryView(entry: .init( + date: Date(), + items: [ + ( + .init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), + UIImage(named: "WidgetHeaderSymbol") + ), + ( + .init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), + UIImage(named: "WidgetHeaderSymbol") + ), + ( + .init(name: "Name2", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series2"), + UIImage(named: "WidgetHeaderSymbol") + ), + ], + error: nil + )) + .previewContext(WidgetPreviewContext(family: .systemLarge)) + .preferredColorScheme(.dark) + NextUpEntryView(entry: .init( + date: Date(), + items: [], + error: nil + )) + .previewContext(WidgetPreviewContext(family: .systemSmall)) + .preferredColorScheme(.dark) + NextUpEntryView(entry: .init( + date: Date(), + items: [ + ( + .init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), + UIImage(named: "WidgetHeaderSymbol") + ), + ], + error: nil + )) + .previewContext(WidgetPreviewContext(family: .systemMedium)) + .preferredColorScheme(.dark) + NextUpEntryView(entry: .init( + date: Date(), + items: [ + ( + .init(name: "Name0", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series0"), + UIImage(named: "WidgetHeaderSymbol") + ), + ( + .init(name: "Name1", indexNumber: 10, parentIndexNumber: 0, seriesName: "Series1"), + UIImage(named: "WidgetHeaderSymbol") + ), + ], + error: nil + )) + .previewContext(WidgetPreviewContext(family: .systemLarge)) + .preferredColorScheme(.dark) + } + } } import SwiftUI private extension View { - func innerShadow(color: Color, radius: CGFloat = 0.1) -> some View { - modifier(InnerShadow(color: color, radius: min(max(0, radius), 1))) - } + func innerShadow(color: Color, radius: CGFloat = 0.1) -> some View { + modifier(InnerShadow(color: color, radius: min(max(0, radius), 1))) + } } private struct InnerShadow: ViewModifier { - var color: Color = .gray - var radius: CGFloat = 0.1 + var color: Color = .gray + var radius: CGFloat = 0.1 - private var colors: [Color] { - [color.opacity(0.75), color.opacity(0.0), .clear] - } + private var colors: [Color] { + [color.opacity(0.75), color.opacity(0.0), .clear] + } - func body(content: Content) -> some View { - GeometryReader { geo in - content - .overlay(LinearGradient(gradient: Gradient(colors: self.colors), startPoint: .top, endPoint: .bottom) - .frame(height: self.radius * self.minSide(geo)), - alignment: .top) - .overlay(LinearGradient(gradient: Gradient(colors: self.colors), startPoint: .bottom, endPoint: .top) - .frame(height: self.radius * self.minSide(geo)), - alignment: .bottom) - } - } + func body(content: Content) -> some View { + GeometryReader { geo in + content + .overlay( + LinearGradient(gradient: Gradient(colors: self.colors), startPoint: .top, endPoint: .bottom) + .frame(height: self.radius * self.minSide(geo)), + alignment: .top + ) + .overlay( + LinearGradient(gradient: Gradient(colors: self.colors), startPoint: .bottom, endPoint: .top) + .frame(height: self.radius * self.minSide(geo)), + alignment: .bottom + ) + } + } - func minSide(_ geo: GeometryProxy) -> CGFloat { - CGFloat(3) * min(geo.size.width, geo.size.height) / 2 - } + func minSide(_ geo: GeometryProxy) -> CGFloat { + CGFloat(3) * min(geo.size.width, geo.size.height) / 2 + } }