From 4dac5dd0b9153fca9fcc20321b1d7751f3945c18 Mon Sep 17 00:00:00 2001 From: jhays Date: Thu, 31 Mar 2022 21:37:57 -0500 Subject: [PATCH 01/18] initial iOS LiveTV coordination --- .../Coordinators/LibraryListCoordinator.swift | 6 + .../LiveTVChannelsCoordinator.swift | 2 +- Shared/Coordinators/LiveTVCoordinator.swift | 30 + .../iOSLiveTVVideoPlayerCoordinator.swift | 40 + .../Views/LiveTVChannelItemElement.swift | 2 + Swiftfin.xcodeproj/project.pbxproj | 24 + Swiftfin/Views/LibraryListView.swift | 20 +- Swiftfin/Views/LiveTVProgramsView.swift | 200 +++- .../ExperimentalSettingsView.swift | 10 + .../Views/VideoPlayer/LiveTVPlayerView.swift | 38 + .../LiveTVPlayerViewController.swift | 1032 +++++++++++++++++ 11 files changed, 1397 insertions(+), 7 deletions(-) create mode 100644 Shared/Coordinators/LiveTVCoordinator.swift create mode 100644 Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift create mode 100644 Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift create mode 100644 Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift diff --git a/Shared/Coordinators/LibraryListCoordinator.swift b/Shared/Coordinators/LibraryListCoordinator.swift index a413ff83..7892af36 100644 --- a/Shared/Coordinators/LibraryListCoordinator.swift +++ b/Shared/Coordinators/LibraryListCoordinator.swift @@ -20,6 +20,8 @@ final class LibraryListCoordinator: NavigationCoordinatable { var search = makeSearch @Route(.push) var library = makeLibrary + @Route(.push) + var liveTV = makeLiveTV let viewModel: LibraryListViewModel @@ -34,6 +36,10 @@ final class LibraryListCoordinator: NavigationCoordinatable { func makeSearch(viewModel: LibrarySearchViewModel) -> SearchCoordinator { SearchCoordinator(viewModel: viewModel) } + + func makeLiveTV() -> LiveTVCoordinator { + LiveTVCoordinator() + } @ViewBuilder func makeStart() -> some View { diff --git a/Shared/Coordinators/LiveTVChannelsCoordinator.swift b/Shared/Coordinators/LiveTVChannelsCoordinator.swift index 77f80de8..343da7c4 100644 --- a/Shared/Coordinators/LiveTVChannelsCoordinator.swift +++ b/Shared/Coordinators/LiveTVChannelsCoordinator.swift @@ -24,7 +24,7 @@ final class LiveTVChannelsCoordinator: NavigationCoordinatable { func makeModalItem(item: BaseItemDto) -> NavigationViewCoordinator { NavigationViewCoordinator(ItemCoordinator(item: item)) } - + func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel)) } diff --git a/Shared/Coordinators/LiveTVCoordinator.swift b/Shared/Coordinators/LiveTVCoordinator.swift new file mode 100644 index 00000000..09eb9e05 --- /dev/null +++ b/Shared/Coordinators/LiveTVCoordinator.swift @@ -0,0 +1,30 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class LiveTVCoordinator: NavigationCoordinatable { + let stack = NavigationStack(initial: \LiveTVCoordinator.start) + + @Root + var start = makeStart +// @Route(.push) +// var search = makeSearch + + @ViewBuilder + func makeStart() -> some View { + LiveTVChannelsView() + } + +// func makeSearch(viewModel: LibrarySearchViewModel) -> SearchCoordinator { +// SearchCoordinator(viewModel: viewModel) +// } +} diff --git a/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift new file mode 100644 index 00000000..aa5f65c9 --- /dev/null +++ b/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift @@ -0,0 +1,40 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class LiveTVVideoPlayerCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \LiveTVVideoPlayerCoordinator.start) + + @Root + var start = makeStart + + let viewModel: VideoPlayerViewModel + + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + } + + @ViewBuilder + func makeStart() -> some View { +// if Defaults[.Experimental.liveTVNativePlayer] { +// LiveTVNativeVideoPlayerView(viewModel: viewModel) +// .navigationBarHidden(true) +// .ignoresSafeArea() +// } else { + LiveTVPlayerView(viewModel: viewModel) + .navigationBarHidden(true) + .ignoresSafeArea() +// } + } +} diff --git a/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift b/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift index f36aa1bf..9c300ccd 100644 --- a/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift +++ b/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift @@ -120,7 +120,9 @@ struct LiveTVChannelItemElement: View { .stroke(isFocused ? Color.blue : Color.clear, lineWidth: 4)) .cornerRadius(20) .scaleEffect(isFocused ? 1.1 : 1) +#if os(tvOS) .focusable(true) +#endif .focused($focused) .onChange(of: focused) { foc in withAnimation(.linear(duration: 0.15)) { diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index b945bfaa..26029f37 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -259,6 +259,14 @@ C4534981279A3F140045F1E2 /* tvOSLiveTVOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534980279A3F140045F1E2 /* tvOSLiveTVOverlay.swift */; }; C4534983279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */; }; C4534985279A40C60045F1E2 /* LiveTVVideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534984279A40C50045F1E2 /* LiveTVVideoPlayerView.swift */; }; + C45942C527F67DA400C54FE7 /* LiveTVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */; }; + C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */; }; + C45942C927F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942C827F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift */; }; + C45942CB27F6984100C54FE7 /* LiveTVPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942CA27F6984100C54FE7 /* LiveTVPlayerViewController.swift */; }; + C45942CD27F6994A00C54FE7 /* LiveTVPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942CC27F6994A00C54FE7 /* LiveTVPlayerView.swift */; }; + C45942CE27F69BF300C54FE7 /* LiveTVChannelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */; }; + C45942CF27F69BF500C54FE7 /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */; }; + C45942D027F69C2400C54FE7 /* LiveTVChannelsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */; }; C45B29BB26FAC5B600CEF5E0 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */; }; C4AE2C3027498D2300AE13CF /* LiveTVHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */; }; C4AE2C3227498D6A00AE13CF /* LiveTVProgramsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */; }; @@ -739,6 +747,10 @@ C4534980279A3F140045F1E2 /* tvOSLiveTVOverlay.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVOverlay.swift; sourceTree = ""; }; C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVVideoPlayerCoordinator.swift; sourceTree = ""; }; C4534984279A40C50045F1E2 /* LiveTVVideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVVideoPlayerView.swift; sourceTree = ""; }; + C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVCoordinator.swift; sourceTree = ""; }; + C45942C827F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSLiveTVVideoPlayerCoordinator.swift; sourceTree = ""; }; + C45942CA27F6984100C54FE7 /* LiveTVPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVPlayerViewController.swift; sourceTree = ""; }; + C45942CC27F6994A00C54FE7 /* LiveTVPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVPlayerView.swift; sourceTree = ""; }; C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVHomeView.swift; sourceTree = ""; }; C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsView.swift; sourceTree = ""; }; C4B9B91327E1921B0063535C /* LiveTVNativeVideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVNativeVideoPlayerView.swift; sourceTree = ""; }; @@ -1466,6 +1478,7 @@ C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */, C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */, C4BE07782726EE82003F4AD1 /* LiveTVTabCoordinator.swift */, + C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */, E193D5412719404B00900D82 /* MainCoordinator */, C40CD921271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift */, 6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */, @@ -1724,7 +1737,9 @@ E1002B692793E12E00E47059 /* Overlays */, E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */, E1C812B8277A8E5D00918266 /* VLCPlayerView.swift */, + C45942CC27F6994A00C54FE7 /* LiveTVPlayerView.swift */, E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */, + C45942CA27F6984100C54FE7 /* LiveTVPlayerViewController.swift */, ); path = VideoPlayer; sourceTree = ""; @@ -1801,6 +1816,7 @@ E1C812CF277AE4C700918266 /* VideoPlayerCoordinator */ = { isa = PBXGroup; children = ( + C45942C827F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift */, 6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */, C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */, E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */, @@ -2374,6 +2390,7 @@ 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */, 5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */, 53F866442687A45F00DCD1D7 /* PortraitItemButton.swift in Sources */, + C45942CD27F6994A00C54FE7 /* LiveTVPlayerView.swift in Sources */, E1AD105626D981CE003E4A08 /* PortraitHStackView.swift in Sources */, 62C29EA126D102A500C1D2E7 /* iOSMainTabCoordinator.swift in Sources */, C4BE076E2720FEA8003F4AD1 /* PortraitItemElement.swift in Sources */, @@ -2392,9 +2409,11 @@ 5D160403278A41FD00D22B99 /* VLCPlayer+subtitles.swift in Sources */, 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, E1A2C158279A7D76005EC829 /* BundleExtensions.swift in Sources */, + C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */, E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */, E13AD72E2798BC8D00FDCEE8 /* NativePlayerViewController.swift in Sources */, E13DD3E527177D15009D4DAF /* ServerListView.swift in Sources */, + C45942CB27F6984100C54FE7 /* LiveTVPlayerViewController.swift in Sources */, E18845F826DEA9C900B0C5B7 /* ItemViewBody.swift in Sources */, E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */, E19169CE272514760085832A /* HTTPScheme.swift in Sources */, @@ -2412,6 +2431,7 @@ E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */, E1E5D5492783CDD700692DFE /* OverlaySettingsView.swift in Sources */, E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */, + C45942D027F69C2400C54FE7 /* LiveTVChannelsCoordinator.swift in Sources */, E11D224227378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */, 6264E88C273850380081A12A /* Strings.swift in Sources */, C4AE2C3227498D6A00AE13CF /* LiveTVProgramsView.swift in Sources */, @@ -2463,6 +2483,7 @@ C4BE07762725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */, E13DD3E927177ED6009D4DAF /* ServerListCoordinator.swift in Sources */, E1C812BD277A8E5D00918266 /* PlayerOverlayDelegate.swift in Sources */, + C45942C527F67DA400C54FE7 /* LiveTVCoordinator.swift in Sources */, E13DD3C227164941009D4DAF /* SwiftfinStore.swift in Sources */, 62E632E0267D30CA0063E547 /* LibraryViewModel.swift in Sources */, E193D4DB27193CCA00900D82 /* PillStackable.swift in Sources */, @@ -2480,6 +2501,7 @@ 091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */, 62E632EF267D43320063E547 /* LibraryFilterViewModel.swift in Sources */, 5D64683D277B1649009E09AE /* PreferenceUIHostingSwizzling.swift in Sources */, + C45942C927F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift in Sources */, E13DD3C827164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */, E10EAA53277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, E1AD104D26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */, @@ -2496,10 +2518,12 @@ 62EC353426766B03000E9F2D /* DeviceRotationViewModifier.swift in Sources */, 5389277C263CC3DB0035E14B /* BlurHashDecode.swift in Sources */, E1D4BF8A2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */, + C45942CE27F69BF300C54FE7 /* LiveTVChannelsView.swift in Sources */, E13DD3F92717E961009D4DAF /* UserListViewModel.swift in Sources */, E126F741278A656C00A522BF /* ServerStreamType.swift in Sources */, 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */, 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */, + C45942CF27F69BF500C54FE7 /* LiveTVChannelItemElement.swift in Sources */, 09389CC726819B4600AE350E /* VideoPlayerModel.swift in Sources */, E1D4BF872719D27100A11E64 /* Bitrates.swift in Sources */, 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */, diff --git a/Swiftfin/Views/LibraryListView.swift b/Swiftfin/Views/LibraryListView.swift index eedbe6c1..0229d143 100644 --- a/Swiftfin/Views/LibraryListView.swift +++ b/Swiftfin/Views/LibraryListView.swift @@ -6,6 +6,7 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // +import Defaults import Foundation import Stinsen import SwiftUI @@ -15,8 +16,17 @@ struct LibraryListView: View { var libraryListRouter: LibraryListCoordinator.Router @StateObject var viewModel = LibraryListViewModel() - - let supportedCollectionTypes = ["movies", "tvshows", "boxsets", "other"] + + @Default(.Experimental.liveTVAlphaEnabled) + var liveTVAlphaEnabled + + var supportedCollectionTypes: [String] { + if liveTVAlphaEnabled { + return ["movies", "tvshows", "livetv", "boxsets", "other"] + } else { + return ["movies", "tvshows", "boxsets", "other"] + } + } var body: some View { ScrollView { @@ -49,9 +59,13 @@ struct LibraryListView: View { return self.supportedCollectionTypes.contains(collectionType) }, id: \.id) { library in Button { - libraryListRouter.route(to: \.library, + if library.collectionType == "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()) diff --git a/Swiftfin/Views/LiveTVProgramsView.swift b/Swiftfin/Views/LiveTVProgramsView.swift index fba31aba..b33ba735 100644 --- a/Swiftfin/Views/LiveTVProgramsView.swift +++ b/Swiftfin/Views/LiveTVProgramsView.swift @@ -10,7 +10,201 @@ import Stinsen import SwiftUI struct LiveTVProgramsView: View { - var body: some View { - Text("Coming Soon") - } + @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: { +#if os(iOS) +#elseif os(tvOS) + LandscapeItemElement(item: item) +#endif + } + .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: { +#if os(iOS) +#elseif os(tvOS) + LandscapeItemElement(item: item) +#endif + } + .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: { +#if os(iOS) +#elseif os(tvOS) + LandscapeItemElement(item: item) +#endif + } + .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: { +#if os(iOS) +#elseif os(tvOS) + LandscapeItemElement(item: item) +#endif + } + .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: { +#if os(iOS) +#elseif os(tvOS) + LandscapeItemElement(item: item) +#endif + } + .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: { +#if os(iOS) +#elseif os(tvOS) + LandscapeItemElement(item: item) +#endif + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.frame(height: 350) + } + } + } + } } diff --git a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift index 57c5ca7b..1ac48361 100644 --- a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift +++ b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift @@ -17,6 +17,8 @@ struct ExperimentalSettingsView: View { var syncSubtitleStateWithAdjacent @Default(.Experimental.nativePlayer) var nativePlayer + @Default(.Experimental.liveTVAlphaEnabled) + var liveTVAlphaEnabled var body: some View { Form { @@ -31,6 +33,14 @@ struct ExperimentalSettingsView: View { } header: { L10n.experimental.text } + + Section { + + Toggle("Live TV (Alpha)", isOn: $liveTVAlphaEnabled) + + } header: { + Text("Live TV") + } } } } diff --git a/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift b/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift new file mode 100644 index 00000000..499acb2e --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift @@ -0,0 +1,38 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI +import UIKit + +//struct NativePlayerView: UIViewControllerRepresentable { +// +// let viewModel: VideoPlayerViewModel +// +// typealias UIViewControllerType = NativePlayerViewController +// +// func makeUIViewController(context: Context) -> NativePlayerViewController { +// +// NativePlayerViewController(viewModel: viewModel) +// } +// +// func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) {} +//} + +struct LiveTVPlayerView: UIViewControllerRepresentable { + + let viewModel: VideoPlayerViewModel + + typealias UIViewControllerType = LiveTVPlayerViewController + + func makeUIViewController(context: Context) -> LiveTVPlayerViewController { + + LiveTVPlayerViewController(viewModel: viewModel) + } + + func updateUIViewController(_ uiViewController: LiveTVPlayerViewController, context: Context) {} +} diff --git a/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift b/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift new file mode 100644 index 00000000..7f81cf16 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift @@ -0,0 +1,1032 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import AVFoundation +import AVKit +import Combine +import Defaults +import JellyfinAPI +import MediaPlayer +import MobileVLCKit +import SwiftUI +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() + } + + @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) + + 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 + + newJumpForwardImageView.alpha = 0 + + view.addSubview(newJumpForwardImageView) + + NSLayoutConstraint.activate([ + newJumpForwardImageView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -150), + newJumpForwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + 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 + + if vlcMediaPlayer.media != nil { + viewModelListeners.forEach { $0.cancel() } + + vlcMediaPlayer.stop() + viewModel.sendStopReport() + viewModel.playerOverlayDelegate = nil + } + + vlcMediaPlayer = VLCMediaPlayer() + + // setup with new player and view model + + vlcMediaPlayer = VLCMediaPlayer() + + vlcMediaPlayer.delegate = self + vlcMediaPlayer.drawable = videoContentView + + vlcMediaPlayer.setSubtitleSize(Defaults[.subtitleSize]) + + stopOverlayDismissTimer() + + lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + + let media: VLCMedia + + 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") + + vlcMediaPlayer.media = media + + setupOverlayHostingController(viewModel: newViewModel) + setupViewModelListeners(viewModel: newViewModel) + + newViewModel.getAdjacentEpisodes() + newViewModel.playerOverlayDelegate = self + + 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 + } + } + + viewModel = newViewModel + + if viewModel.streamType == .direct { + LogManager.shared.log.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? "--")") + } else if viewModel.streamType == .transcode && Defaults[.Experimental.forceDirectPlay] { + LogManager.shared.log.debug("Player set up with forced direct stream for item: \(viewModel.item.id ?? "--")") + } else { + LogManager.shared.log.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? "--")") + } + } + + // MARK: startPlayback + + 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) + } + } + + setMediaPlayerTimeAtCurrentSlider() + + viewModel.sendPlayReport() + + restartOverlayDismissTimer() + } + + // MARK: setupViewModelListeners + + 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.$selectedAudioStreamIndex.sink { newAudioStreamIndex in + self.didSelectAudioStream(index: newAudioStreamIndex) + }.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.$jumpBackwardLength.sink { newJumpBackwardLength in + self.refreshJumpBackwardOverlayView(with: newJumpBackwardLength) + }.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 + + 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 } + + guard overlayHostingController.view.alpha != 1 else { return } + + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 1 + } + } + + private func hideOverlay() { + guard !UIAccessibility.isVoiceOverRunning else { return } + + guard let overlayHostingController = currentOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 0 else { return } + + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 0 + } + } + + private func toggleOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } + + if overlayHostingController.view.alpha < 1 { + showOverlay() + } else { + hideOverlay() + } + } +} + +// MARK: Show/Hide System Control + +extension LiveTVPlayerViewController { + private func showBrightnessOverlay() { + guard !displayingOverlay else { return } + + 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() + + UIView.animate(withDuration: 0.1) { + self.systemControlOverlayLabel.alpha = 1 + } + } + + 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 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 + } + } + + 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 } + + currentJumpBackwardOverlayView.layer.removeAllAnimations() + + UIView.animate(withDuration: 0.1) { + currentJumpBackwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpBackwardOverlay() + } + } + + private func hideJumpBackwardOverlay() { + guard let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } + + UIView.animate(withDuration: 0.3) { + currentJumpBackwardOverlayView.alpha = 0 + } + } + + private func flashJumpFowardOverlay() { + guard !displayingOverlay, let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } + + currentJumpForwardOverlayView.layer.removeAllAnimations() + + UIView.animate(withDuration: 0.1) { + currentJumpForwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpForwardOverlay() + } + } + + private func hideJumpForwardOverlay() { + guard let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } + + UIView.animate(withDuration: 0.3) { + currentJumpForwardOverlayView.alpha = 0 + } + } +} + +// MARK: Hide/Show Chapters + +extension LiveTVPlayerViewController { + private func showChaptersOverlay() { + guard let overlayHostingController = currentChapterOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 1 else { return } + + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 1 + } + } + + private func hideChaptersOverlay() { + guard let overlayHostingController = currentChapterOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 0 else { return } + + 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) + } + + @objc + private func dismissTimerFired() { + hideOverlay() + } + + private func stopOverlayDismissTimer() { + overlayDismissTimer?.invalidate() + } +} + +// MARK: VLCMediaPlayerDelegate + +extension LiveTVPlayerViewController: VLCMediaPlayerDelegate { + // MARK: mediaPlayerStateChanged + + 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 + + if vlcMediaPlayer.state == VLCMediaPlayerState.ended { + if viewModel.autoplayEnabled, viewModel.nextItemVideoPlayerViewModel != nil { + didSelectPlayNextItem() + } else { + didSelectClose() + } + } + } + + // MARK: mediaPlayerTimeChanged + + 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 + } + + // 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) + } + + lastPlayerTicks = currentPlayerTicks + + // Send progress report every 5 seconds + if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + } +} + +// MARK: PlayerOverlayDelegate and more + +extension LiveTVPlayerViewController: PlayerOverlayDelegate { + func didSelectAudioStream(index: Int) { + vlcMediaPlayer.currentAudioTrackIndex = Int32(index) + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + /// Do not call when setting to index -1 + func didSelectSubtitleStream(index: Int) { + viewModel.subtitlesEnabled = true + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + @objc + func didSelectClose() { + vlcMediaPlayer.stop() + + viewModel.sendStopReport() + + dismiss(animated: true, completion: nil) + } + + 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 didDeselectMenu() { + restartOverlayDismissTimer() + } + + @objc + func didSelectBackward() { + flashJumpBackwardOverlay() + + vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) + + if displayingOverlay { + restartOverlayDismissTimer() + } + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + @objc + func didSelectForward() { + flashJumpFowardOverlay() + + vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) + + if displayingOverlay { + restartOverlayDismissTimer() + } + + viewModel.sendProgressReport() + + 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: () + } + } + + func didGenerallyTap() { + toggleOverlay() + + restartOverlayDismissTimer(interval: 5) + } + + func didBeginScrubbing() { + stopOverlayDismissTimer() + } + + func didEndScrubbing() { + setMediaPlayerTimeAtCurrentSlider() + + restartOverlayDismissTimer() + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + @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 didSelectPreviousPlaybackSpeed() { + if let previousPlaybackSpeed = viewModel.playbackSpeed.previous { + viewModel.playbackSpeed = previousPlaybackSpeed + } + } + + @objc + func didSelectNextPlaybackSpeed() { + if let nextPlaybackSpeed = viewModel.playbackSpeed.next { + viewModel.playbackSpeed = nextPlaybackSpeed + } + } + + @objc + func didSelectNormalPlaybackSpeed() { + viewModel.playbackSpeed = .one + } + + 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 + + if newPositionOffset > 0 { + vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) + } else { + vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) + } + + viewModel.sendProgressReport() + } + + func didSelectScreenFill() { + isScreenFilled.toggle() + + 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) + + let scale: CGFloat + + 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) + } + } + + private func shrinkScreen() { + UIView.animate(withDuration: 0.2) { + self.videoContentView.transform = .identity + } + } + + func getScreenFilled() -> Bool { + isScreenFilled + } + + 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 + + return videoAspectRatio > screenAspectRatio + } +} + From 081857262c6842374774178c8f1a6972793d34b5 Mon Sep 17 00:00:00 2001 From: jhays Date: Sun, 24 Apr 2022 19:19:15 -0500 Subject: [PATCH 02/18] live tv channels layout ui --- .../ViewModels/LiveTVChannelsViewModel.swift | 5 +- .../Views/LiveTVChannelItemElement.swift | 2 - Swiftfin.xcodeproj/project.pbxproj | 22 +- .../BackgroundColor.colorset/Contents.json | 56 +++++ Swiftfin/Views/LiveTVChannelItemElement.swift | 122 ++++++++++ .../Views/LiveTVChannelItemWideElement.swift | 152 +++++++++++++ Swiftfin/Views/LiveTVChannelsView.swift | 210 ++++++++++++++++++ 7 files changed, 559 insertions(+), 10 deletions(-) create mode 100644 Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json create mode 100644 Swiftfin/Views/LiveTVChannelItemElement.swift create mode 100644 Swiftfin/Views/LiveTVChannelItemWideElement.swift create mode 100644 Swiftfin/Views/LiveTVChannelsView.swift diff --git a/Shared/ViewModels/LiveTVChannelsViewModel.swift b/Shared/ViewModels/LiveTVChannelsViewModel.swift index e425af55..8bddf1c0 100644 --- a/Shared/ViewModels/LiveTVChannelsViewModel.swift +++ b/Shared/ViewModels/LiveTVChannelsViewModel.swift @@ -20,7 +20,8 @@ struct LiveTVChannelRowCell: Hashable { struct LiveTVChannelProgram: Hashable { let id = UUID() let channel: BaseItemDto - let program: BaseItemDto? + let currentProgram: BaseItemDto? + let programs: [BaseItemDto] } final class LiveTVChannelsViewModel: ViewModel { @@ -151,7 +152,7 @@ final class LiveTVChannelsViewModel: ViewModel { } } - channelPrograms.append(LiveTVChannelProgram(channel: channel, program: currentPrg)) + channelPrograms.append(LiveTVChannelProgram(channel: channel, currentProgram: currentPrg, programs: prgs)) } return channelPrograms } diff --git a/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift b/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift index 9c300ccd..f36aa1bf 100644 --- a/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift +++ b/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift @@ -120,9 +120,7 @@ struct LiveTVChannelItemElement: View { .stroke(isFocused ? Color.blue : Color.clear, lineWidth: 4)) .cornerRadius(20) .scaleEffect(isFocused ? 1.1 : 1) -#if os(tvOS) .focusable(true) -#endif .focused($focused) .onChange(of: focused) { foc in withAnimation(.linear(duration: 0.15)) { diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 26029f37..f5c5fdc8 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -252,9 +252,13 @@ 62EC353426766B03000E9F2D /* DeviceRotationViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */; }; 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62ECA01726FA685A00E8EBB7 /* DeepLink.swift */; }; AE8C3159265D6F90008AA076 /* bitrates.json in Resources */ = {isa = PBXBuildFile; fileRef = AE8C3158265D6F90008AA076 /* bitrates.json */; }; + C400DB6A27FE894F007B65FE /* LiveTVChannelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */; }; + C400DB6B27FE8C97007B65FE /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */; }; + C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */; }; C40CD923271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD921271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift */; }; C40CD926271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */; }; C40CD929271F8DAB000FB198 /* MovieLibrariesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD927271F8DAB000FB198 /* MovieLibrariesView.swift */; }; + C4464953281616AE00DDB461 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBF8263B596B003A4E83 /* Assets.xcassets */; }; C453497F279A2DA50045F1E2 /* LiveTVPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C453497E279A2DA50045F1E2 /* LiveTVPlayerViewController.swift */; }; C4534981279A3F140045F1E2 /* tvOSLiveTVOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534980279A3F140045F1E2 /* tvOSLiveTVOverlay.swift */; }; C4534983279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */; }; @@ -264,8 +268,6 @@ C45942C927F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942C827F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift */; }; C45942CB27F6984100C54FE7 /* LiveTVPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942CA27F6984100C54FE7 /* LiveTVPlayerViewController.swift */; }; C45942CD27F6994A00C54FE7 /* LiveTVPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942CC27F6994A00C54FE7 /* LiveTVPlayerView.swift */; }; - C45942CE27F69BF300C54FE7 /* LiveTVChannelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */; }; - C45942CF27F69BF500C54FE7 /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */; }; C45942D027F69C2400C54FE7 /* LiveTVChannelsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */; }; C45B29BB26FAC5B600CEF5E0 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */; }; C4AE2C3027498D2300AE13CF /* LiveTVHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */; }; @@ -288,7 +290,7 @@ C4BE078E27298818003F4AD1 /* LiveTVHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */; }; C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E508172703E8190045C9AB /* LibraryListView.swift */; }; C4E5081D2703F8370045C9AB /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */; }; - C4E52305272CE68800654268 /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */; }; + C4E5598928124C10003DECA5 /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */; }; E1002B5F2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B5E2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift */; }; E1002B642793CEE800E47059 /* ChapterInfoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */; }; E1002B652793CEE800E47059 /* ChapterInfoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */; }; @@ -740,6 +742,8 @@ 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = ""; }; 62ECA01726FA685A00E8EBB7 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; AE8C3158265D6F90008AA076 /* bitrates.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = bitrates.json; sourceTree = ""; }; + C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelsView.swift; sourceTree = ""; }; + C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemWideElement.swift; sourceTree = ""; }; C40CD921271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoviesLibrariesCoordinator.swift; sourceTree = ""; }; C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieLibrariesViewModel.swift; sourceTree = ""; }; C40CD927271F8DAB000FB198 /* MovieLibrariesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieLibrariesView.swift; sourceTree = ""; }; @@ -769,6 +773,7 @@ C4E508172703E8190045C9AB /* LibraryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListView.swift; sourceTree = ""; }; C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = ""; }; C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = ""; }; + C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = ""; }; E1002B5E2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerChapterOverlayView.swift; sourceTree = ""; }; E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfoExtensions.swift; sourceTree = ""; }; E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = ""; }; @@ -1636,6 +1641,9 @@ 53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */, 6213388F265F83A900A81A2A /* LibraryListView.swift */, 53EE24E5265060780068F029 /* LibrarySearchView.swift */, + C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */, + C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */, + C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */, 53DF641D263D9C0600A7CD1A /* LibraryView.swift */, C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */, C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */, @@ -2077,6 +2085,7 @@ 53913C0526D323FE00EB3286 /* Localizable.strings in Resources */, 53913BFF26D323FE00EB3286 /* Localizable.strings in Resources */, 53913C0E26D323FE00EB3286 /* Localizable.strings in Resources */, + C4464953281616AE00DDB461 /* Assets.xcassets in Resources */, 53913BF026D323FE00EB3286 /* Localizable.strings in Resources */, 53913C0826D323FE00EB3286 /* Localizable.strings in Resources */, 53913C1126D323FE00EB3286 /* Localizable.strings in Resources */, @@ -2251,6 +2260,7 @@ 62E632F0267D43320063E547 /* LibraryFilterViewModel.swift in Sources */, E193D54B271941D300900D82 /* ServerListView.swift in Sources */, 53ABFDE6267974EF00886593 /* SettingsViewModel.swift in Sources */, + C400DB6B27FE8C97007B65FE /* LiveTVChannelItemElement.swift in Sources */, 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */, 6267B3D826710B9800A7371D /* CollectionExtensions.swift in Sources */, 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, @@ -2276,7 +2286,6 @@ E193D5502719430400900D82 /* ServerDetailView.swift in Sources */, E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, E1C812D1277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift in Sources */, - C4E52305272CE68800654268 /* LiveTVChannelItemElement.swift in Sources */, E1A2C156279A7D5A005EC829 /* UIApplicationExtensions.swift in Sources */, E1A2C15E279A7D9F005EC829 /* AppIcon.swift in Sources */, E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */, @@ -2387,6 +2396,7 @@ 625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */, 62C29EA826D103D500C1D2E7 /* LibraryListCoordinator.swift in Sources */, 62E632DC267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */, + C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */, 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */, 5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */, 53F866442687A45F00DCD1D7 /* PortraitItemButton.swift in Sources */, @@ -2425,6 +2435,7 @@ E1047E2027E584AF00CB0D4A /* BlurHashView.swift in Sources */, 62E632DA267D2BC40063E547 /* LatestMediaViewModel.swift in Sources */, E1AD105C26D9ABDD003E4A08 /* PillHStackView.swift in Sources */, + C400DB6A27FE894F007B65FE /* LiveTVChannelsView.swift in Sources */, 625CB56F2678C23300530A6E /* HomeView.swift in Sources */, E1047E2327E5880000CB0D4A /* InitialFailureView.swift in Sources */, E1CEFBF527914C7700F60429 /* CustomizeViewsSettings.swift in Sources */, @@ -2490,6 +2501,7 @@ E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */, E1A2C154279A7D5A005EC829 /* UIApplicationExtensions.swift in Sources */, E193D4D827193CAC00900D82 /* PortraitImageStackable.swift in Sources */, + C4E5598928124C10003DECA5 /* LiveTVChannelItemElement.swift in Sources */, 624C21752685CF60007F1390 /* SearchablePickerView.swift in Sources */, E1D4BF7C2719D05000A11E64 /* BasicAppSettingsView.swift in Sources */, E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */, @@ -2518,12 +2530,10 @@ 62EC353426766B03000E9F2D /* DeviceRotationViewModifier.swift in Sources */, 5389277C263CC3DB0035E14B /* BlurHashDecode.swift in Sources */, E1D4BF8A2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */, - C45942CE27F69BF300C54FE7 /* LiveTVChannelsView.swift in Sources */, E13DD3F92717E961009D4DAF /* UserListViewModel.swift in Sources */, E126F741278A656C00A522BF /* ServerStreamType.swift in Sources */, 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */, 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */, - C45942CF27F69BF500C54FE7 /* LiveTVChannelItemElement.swift in Sources */, 09389CC726819B4600AE350E /* VideoPlayerModel.swift in Sources */, E1D4BF872719D27100A11E64 /* Bitrates.swift in Sources */, 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */, diff --git a/Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json b/Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json new file mode 100644 index 00000000..5d336734 --- /dev/null +++ b/Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.100", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.100", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Views/LiveTVChannelItemElement.swift b/Swiftfin/Views/LiveTVChannelItemElement.swift new file mode 100644 index 00000000..0ff8dc06 --- /dev/null +++ b/Swiftfin/Views/LiveTVChannelItemElement.swift @@ -0,0 +1,122 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct LiveTVChannelItemElement: View { + @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 + + 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) + + 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) + + if loading { + ProgressView() + } + } + .overlay(RoundedRectangle(cornerRadius: 0) + .stroke(Color.blue, lineWidth: 0)) + } +} diff --git a/Swiftfin/Views/LiveTVChannelItemWideElement.swift b/Swiftfin/Views/LiveTVChannelItemWideElement.swift new file mode 100644 index 00000000..6dba9684 --- /dev/null +++ b/Swiftfin/Views/LiveTVChannelItemWideElement.swift @@ -0,0 +1,152 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct LiveTVChannelItemWideElement: View { + @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 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 + } + + var body: some View { + ZStack { + HStack { + ZStack(alignment: .bottomLeading) { + ImageView(channel.getPrimaryImage(maxWidth: 128)) + .aspectRatio(contentMode: .fit) + .padding(.init(top: 0, leading: 0, bottom: 8, trailing: 0)) + VStack { + 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: .bottomLeading) + .padding(.init(top: 0, leading: 0, bottom: 0, trailing: 8)) + } + } + .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) + .frame(alignment: .leading) + HStack(alignment: .top) { + Text(currentProgramText.timeDisplay) + .font(.footnote) + .lineLimit(2) + .foregroundColor(.green) + .frame(width: 40) + Text(currentProgramText.title) + .font(.footnote) + .lineLimit(2) + .foregroundColor(.green) + } + if nextProgramsText.count > 0, + let nextItem = nextProgramsText[0] { + HStack(alignment: .top) { + Text(nextItem.timeDisplay) + .font(.footnote) + .lineLimit(2) + .foregroundColor(.gray) + .frame(width: 40) + Text(nextItem.title) + .font(.footnote) + .lineLimit(2) + .foregroundColor(.gray) + } + } + if nextProgramsText.count > 1, + let nextItem2 = nextProgramsText[1] { + HStack(alignment: .top) { + Text(nextItem2.timeDisplay) + .font(.footnote) + .lineLimit(2) + .foregroundColor(.gray) + .frame(width: 40) + Text(nextItem2.title) + .font(.footnote) + .lineLimit(2) + .foregroundColor(.gray) + } + } + Spacer() + } + Spacer() + } + .frame(alignment: .leading) + .padding() + .opacity(loading ? 0.5 : 1.0) + + if loading { + ProgressView() + } + } + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous).fill(Color("BackgroundColor")) + ) + .frame(height: 128) + } +} diff --git a/Swiftfin/Views/LiveTVChannelsView.swift b/Swiftfin/Views/LiveTVChannelsView.swift new file mode 100644 index 00000000..c6102e42 --- /dev/null +++ b/Swiftfin/Views/LiveTVChannelsView.swift @@ -0,0 +1,210 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI +import SwiftUI +import SwiftUICollection + +typealias LiveTVChannelViewProgram = (timeDisplay: String, title: String) + +struct LiveTVChannelsView: View { + @EnvironmentObject + var router: LiveTVChannelsCoordinator.Router + @StateObject + var viewModel = LiveTVChannelsViewModel() + @State private var isPortrait = false + + 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() + 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(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 + } + LiveTVChannelItemWideElement(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 { + if UIDevice.current.userInterfaceIdiom == .pad { + let itemSize = NSCollectionLayoutSize( + widthDimension: .absolute((UIScreen.main.bounds.width / 2) - 2), + heightDimension: .fractionalHeight(1) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.edgeSpacing = NSCollectionLayoutEdgeSpacing( + leading: .flexible(0), top: nil, + trailing: .flexible(2), bottom: .flexible(2) + ) + let item2 = NSCollectionLayoutItem(layoutSize: itemSize) + item2.edgeSpacing = NSCollectionLayoutEdgeSpacing( + leading: nil, top: nil, + trailing: .flexible(0), bottom: .flexible(2) + ) + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(132) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + subitems: [item, item2] + ) + let section = NSCollectionLayoutSection(group: group) + return section + } else { + if isPortrait { + let itemSize = NSCollectionLayoutSize( + widthDimension: .absolute(UIScreen.main.bounds.width - 2), + heightDimension: .fractionalHeight(1) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.edgeSpacing = NSCollectionLayoutEdgeSpacing( + leading: .flexible(0), top: nil, + trailing: .flexible(2), bottom: .flexible(2) + ) + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(132) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + subitems: [item] + ) + let section = NSCollectionLayoutSection(group: group) + return section + } else { + let itemSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(0.49), + heightDimension: .fractionalHeight(1) + ) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.edgeSpacing = NSCollectionLayoutEdgeSpacing( + leading: .flexible(0), top: nil, + trailing: .flexible(2), bottom: .flexible(2) + ) + let item2 = NSCollectionLayoutItem(layoutSize: itemSize) + item2.edgeSpacing = NSCollectionLayoutEdgeSpacing( + leading: nil, top: nil, + trailing: .flexible(0), bottom: .flexible(2) + ) + let groupSize = NSCollectionLayoutSize( + widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(132) + ) + let group = NSCollectionLayoutGroup.horizontal( + layoutSize: groupSize, + subitems: [item, item2] + ) + let section = NSCollectionLayoutSection(group: group) + return section + } + } + } + + 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 + print("orientationDidChange: isPortrait? \(self.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 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)") + } + + return LiveTVChannelViewProgram(timeDisplay: timeText, title: displayText) + } +} From 5531c912ea5f80b4b42e4da4377d2d1b27ffd665 Mon Sep 17 00:00:00 2001 From: jhays Date: Sun, 24 Apr 2022 19:44:56 -0500 Subject: [PATCH 03/18] LiveTV iOS route to playback --- Shared/Coordinators/LiveTVCoordinator.swift | 10 ++++----- .../Views/LiveTVChannelItemWideElement.swift | 22 ++++++++++++------- Swiftfin/Views/LiveTVChannelsView.swift | 17 +++++++------- 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/Shared/Coordinators/LiveTVCoordinator.swift b/Shared/Coordinators/LiveTVCoordinator.swift index 09eb9e05..77d21362 100644 --- a/Shared/Coordinators/LiveTVCoordinator.swift +++ b/Shared/Coordinators/LiveTVCoordinator.swift @@ -16,15 +16,15 @@ final class LiveTVCoordinator: NavigationCoordinatable { @Root var start = makeStart -// @Route(.push) -// var search = makeSearch + @Route(.fullScreen) + var videoPlayer = makeVideoPlayer @ViewBuilder func makeStart() -> some View { LiveTVChannelsView() } -// func makeSearch(viewModel: LibrarySearchViewModel) -> SearchCoordinator { -// SearchCoordinator(viewModel: viewModel) -// } + func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel)) + } } diff --git a/Swiftfin/Views/LiveTVChannelItemWideElement.swift b/Swiftfin/Views/LiveTVChannelItemWideElement.swift index 6dba9684..fdf9ffd0 100644 --- a/Swiftfin/Views/LiveTVChannelItemWideElement.swift +++ b/Swiftfin/Views/LiveTVChannelItemWideElement.swift @@ -63,11 +63,11 @@ struct LiveTVChannelItemWideElement: View { var body: some View { ZStack { HStack { - ZStack(alignment: .bottomLeading) { + ZStack(alignment: .center) { ImageView(channel.getPrimaryImage(maxWidth: 128)) .aspectRatio(contentMode: .fit) .padding(.init(top: 0, leading: 0, bottom: 8, trailing: 0)) - VStack { + VStack(alignment: .center) { Spacer() .frame(maxHeight: .infinity) GeometryReader { gp in @@ -81,8 +81,13 @@ struct LiveTVChannelItemWideElement: View { .frame(width: CGFloat(progressPercent * gp.size.width), height: 6) } } - .frame(height: 6, alignment: .bottomLeading) - .padding(.init(top: 0, leading: 0, bottom: 0, trailing: 8)) + .frame(height: 6, alignment: .center) + .padding(.init(top: 0, leading: 4, bottom: 0, trailing: 4)) + } + if loading { + + ProgressView() + } } .aspectRatio(1.0, contentMode: .fit) @@ -139,14 +144,15 @@ struct LiveTVChannelItemWideElement: View { .frame(alignment: .leading) .padding() .opacity(loading ? 0.5 : 1.0) - - if loading { - ProgressView() - } } .background( RoundedRectangle(cornerRadius: 16, style: .continuous).fill(Color("BackgroundColor")) ) .frame(height: 128) + .onTapGesture { + onSelect { loadingState in + loading = loadingState + } + } } } diff --git a/Swiftfin/Views/LiveTVChannelsView.swift b/Swiftfin/Views/LiveTVChannelsView.swift index c6102e42..318a819d 100644 --- a/Swiftfin/Views/LiveTVChannelsView.swift +++ b/Swiftfin/Views/LiveTVChannelsView.swift @@ -15,7 +15,7 @@ typealias LiveTVChannelViewProgram = (timeDisplay: String, title: String) struct LiveTVChannelsView: View { @EnvironmentObject - var router: LiveTVChannelsCoordinator.Router + var router: LiveTVCoordinator.Router @StateObject var viewModel = LiveTVChannelsViewModel() @State private var isPortrait = false @@ -75,12 +75,12 @@ struct LiveTVChannelsView: View { 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) - // } + loadingAction(true) + self.viewModel.fetchVideoPlayerViewModel(item: channel) { playerViewModel in + self.router.route(to: \.videoPlayer, playerViewModel) + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + loadingAction(false) + } } }) } @@ -165,8 +165,7 @@ struct LiveTVChannelsView: View { let scenes = UIApplication.shared.connectedScenes let windowScene = scenes.first as? UIWindowScene guard let scene = windowScene else { return } - self.isPortrait = scene.interfaceOrientation.isPortrait - print("orientationDidChange: isPortrait? \(self.isPortrait)") + self.isPortrait = scene.interfaceOrientation.isPortrait } private func nextProgramsDisplayText(nextItems: [BaseItemDto], timeFormatter: DateFormatter) -> [LiveTVChannelViewProgram] { From d649dd88cfa653faccaf9a77bac9abbacc896f5e Mon Sep 17 00:00:00 2001 From: jhays Date: Wed, 27 Apr 2022 22:46:14 -0500 Subject: [PATCH 04/18] ios live tv and experimental settings --- .../iOSLiveTVVideoPlayerCoordinator.swift | 12 +- Swiftfin.xcodeproj/project.pbxproj | 4 + .../BackgroundColor.colorset/Contents.json | 16 +- .../Contents.json | 56 +++++++ .../ShadowColor.colorset/Contents.json | 56 +++++++ .../TextHighlightColor.colorset/Contents.json | 56 +++++++ .../Views/LiveTVChannelItemWideElement.swift | 156 +++++++++--------- Swiftfin/Views/LiveTVChannelsView.swift | 8 +- .../ExperimentalSettingsView.swift | 10 +- .../LiveTVNativePlayerViewController.swift | 114 +++++++++++++ .../Views/VideoPlayer/LiveTVPlayerView.swift | 26 +-- 11 files changed, 401 insertions(+), 113 deletions(-) create mode 100644 Swiftfin/Assets.xcassets/BackgroundSecondaryColor.colorset/Contents.json create mode 100644 Swiftfin/Assets.xcassets/ShadowColor.colorset/Contents.json create mode 100644 Swiftfin/Assets.xcassets/TextHighlightColor.colorset/Contents.json create mode 100644 Swiftfin/Views/VideoPlayer/LiveTVNativePlayerViewController.swift diff --git a/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift index aa5f65c9..5fe96e15 100644 --- a/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift @@ -27,14 +27,14 @@ final class LiveTVVideoPlayerCoordinator: NavigationCoordinatable { @ViewBuilder func makeStart() -> some View { -// if Defaults[.Experimental.liveTVNativePlayer] { -// LiveTVNativeVideoPlayerView(viewModel: viewModel) -// .navigationBarHidden(true) -// .ignoresSafeArea() -// } else { + if Defaults[.Experimental.liveTVNativePlayer] { + LiveTVNativePlayerView(viewModel: viewModel) + .navigationBarHidden(true) + .ignoresSafeArea() + } else { LiveTVPlayerView(viewModel: viewModel) .navigationBarHidden(true) .ignoresSafeArea() -// } + } } } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index f5c5fdc8..dcf2d11c 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -263,6 +263,7 @@ C4534981279A3F140045F1E2 /* tvOSLiveTVOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534980279A3F140045F1E2 /* tvOSLiveTVOverlay.swift */; }; C4534983279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */; }; C4534985279A40C60045F1E2 /* LiveTVVideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534984279A40C50045F1E2 /* LiveTVVideoPlayerView.swift */; }; + C45640D0281A43EF007096DE /* LiveTVNativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45640CF281A43EF007096DE /* LiveTVNativePlayerViewController.swift */; }; C45942C527F67DA400C54FE7 /* LiveTVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */; }; C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */; }; C45942C927F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942C827F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift */; }; @@ -751,6 +752,7 @@ C4534980279A3F140045F1E2 /* tvOSLiveTVOverlay.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVOverlay.swift; sourceTree = ""; }; C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVVideoPlayerCoordinator.swift; sourceTree = ""; }; C4534984279A40C50045F1E2 /* LiveTVVideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVVideoPlayerView.swift; sourceTree = ""; }; + C45640CF281A43EF007096DE /* LiveTVNativePlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVNativePlayerViewController.swift; sourceTree = ""; }; C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVCoordinator.swift; sourceTree = ""; }; C45942C827F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSLiveTVVideoPlayerCoordinator.swift; sourceTree = ""; }; C45942CA27F6984100C54FE7 /* LiveTVPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVPlayerViewController.swift; sourceTree = ""; }; @@ -1742,6 +1744,7 @@ isa = PBXGroup; children = ( E13AD72D2798BC8D00FDCEE8 /* NativePlayerViewController.swift */, + C45640CF281A43EF007096DE /* LiveTVNativePlayerViewController.swift */, E1002B692793E12E00E47059 /* Overlays */, E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */, E1C812B8277A8E5D00918266 /* VLCPlayerView.swift */, @@ -2515,6 +2518,7 @@ 5D64683D277B1649009E09AE /* PreferenceUIHostingSwizzling.swift in Sources */, C45942C927F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift in Sources */, E13DD3C827164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */, + C45640D0281A43EF007096DE /* LiveTVNativePlayerViewController.swift in Sources */, E10EAA53277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, E1AD104D26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */, E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */, diff --git a/Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json b/Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json index 5d336734..737e9109 100644 --- a/Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json +++ b/Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json @@ -22,10 +22,10 @@ "color" : { "color-space" : "srgb", "components" : { - "alpha" : "0.100", - "blue" : "0.000", - "green" : "0.000", - "red" : "0.000" + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" } }, "idiom" : "universal" @@ -40,10 +40,10 @@ "color" : { "color-space" : "srgb", "components" : { - "alpha" : "0.100", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" } }, "idiom" : "universal" diff --git a/Swiftfin/Assets.xcassets/BackgroundSecondaryColor.colorset/Contents.json b/Swiftfin/Assets.xcassets/BackgroundSecondaryColor.colorset/Contents.json new file mode 100644 index 00000000..5d336734 --- /dev/null +++ b/Swiftfin/Assets.xcassets/BackgroundSecondaryColor.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.100", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.100", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/ShadowColor.colorset/Contents.json b/Swiftfin/Assets.xcassets/ShadowColor.colorset/Contents.json new file mode 100644 index 00000000..1264d8ba --- /dev/null +++ b/Swiftfin/Assets.xcassets/ShadowColor.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.250", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.250", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/TextHighlightColor.colorset/Contents.json b/Swiftfin/Assets.xcassets/TextHighlightColor.colorset/Contents.json new file mode 100644 index 00000000..76b961cc --- /dev/null +++ b/Swiftfin/Assets.xcassets/TextHighlightColor.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Views/LiveTVChannelItemWideElement.swift b/Swiftfin/Views/LiveTVChannelItemWideElement.swift index fdf9ffd0..f5dd9036 100644 --- a/Swiftfin/Views/LiveTVChannelItemWideElement.swift +++ b/Swiftfin/Views/LiveTVChannelItemWideElement.swift @@ -62,97 +62,91 @@ struct LiveTVChannelItemWideElement: View { var body: some View { 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) + 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)) } - .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) - .frame(alignment: .leading) - HStack(alignment: .top) { - Text(currentProgramText.timeDisplay) - .font(.footnote) - .lineLimit(2) - .foregroundColor(.green) - .frame(width: 40) - Text(currentProgramText.title) - .font(.footnote) - .lineLimit(2) - .foregroundColor(.green) - } - if nextProgramsText.count > 0, - let nextItem = nextProgramsText[0] { - HStack(alignment: .top) { - Text(nextItem.timeDisplay) - .font(.footnote) - .lineLimit(2) - .foregroundColor(.gray) - .frame(width: 40) - Text(nextItem.title) - .font(.footnote) - .lineLimit(2) - .foregroundColor(.gray) + if loading { + + ProgressView() + } } - if nextProgramsText.count > 1, - let nextItem2 = nextProgramsText[1] { - HStack(alignment: .top) { - Text(nextItem2.timeDisplay) - .font(.footnote) - .lineLimit(2) - .foregroundColor(.gray) - .frame(width: 40) - Text(nextItem2.title) - .font(.footnote) - .lineLimit(2) - .foregroundColor(.gray) + .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.count > 0, + 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() } - 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 + } } - .frame(alignment: .leading) - .padding() - .opacity(loading ? 0.5 : 1.0) } - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous).fill(Color("BackgroundColor")) - ) - .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) } } } diff --git a/Swiftfin/Views/LiveTVChannelsView.swift b/Swiftfin/Views/LiveTVChannelsView.swift index 318a819d..02b03e2b 100644 --- a/Swiftfin/Views/LiveTVChannelsView.swift +++ b/Swiftfin/Views/LiveTVChannelsView.swift @@ -103,7 +103,7 @@ struct LiveTVChannelsView: View { ) let groupSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1.0), - heightDimension: .absolute(132) + heightDimension: .absolute(144) ) let group = NSCollectionLayoutGroup.horizontal( layoutSize: groupSize, @@ -114,7 +114,7 @@ struct LiveTVChannelsView: View { } else { if isPortrait { let itemSize = NSCollectionLayoutSize( - widthDimension: .absolute(UIScreen.main.bounds.width - 2), + widthDimension: .absolute(UIScreen.main.bounds.width - 32), heightDimension: .fractionalHeight(1) ) let item = NSCollectionLayoutItem(layoutSize: itemSize) @@ -124,7 +124,7 @@ struct LiveTVChannelsView: View { ) let groupSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1.0), - heightDimension: .absolute(132) + heightDimension: .absolute(144) ) let group = NSCollectionLayoutGroup.horizontal( layoutSize: groupSize, @@ -149,7 +149,7 @@ struct LiveTVChannelsView: View { ) let groupSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1.0), - heightDimension: .absolute(132) + heightDimension: .absolute(144) ) let group = NSCollectionLayoutGroup.horizontal( layoutSize: groupSize, diff --git a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift index 1ac48361..f1137b0e 100644 --- a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift +++ b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift @@ -19,7 +19,11 @@ struct ExperimentalSettingsView: View { var nativePlayer @Default(.Experimental.liveTVAlphaEnabled) var liveTVAlphaEnabled - + @Default(.Experimental.liveTVForceDirectPlay) + var liveTVForceDirectPlay + @Default(.Experimental.liveTVNativePlayer) + var liveTVNativePlayer + var body: some View { Form { Section { @@ -38,6 +42,10 @@ struct ExperimentalSettingsView: View { Toggle("Live TV (Alpha)", isOn: $liveTVAlphaEnabled) + Toggle("Live TV Force Direct Play", isOn: $liveTVForceDirectPlay) + + Toggle("Live TV Native Player", isOn: $liveTVNativePlayer) + } header: { Text("Live TV") } diff --git a/Swiftfin/Views/VideoPlayer/LiveTVNativePlayerViewController.swift b/Swiftfin/Views/VideoPlayer/LiveTVNativePlayerViewController.swift new file mode 100644 index 00000000..d5405c04 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/LiveTVNativePlayerViewController.swift @@ -0,0 +1,114 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import AVKit +import Combine +import JellyfinAPI +import UIKit + +class LiveTVNativePlayerViewController: AVPlayerViewController { + + let viewModel: VideoPlayerViewModel + + var timeObserverToken: Any? + + var lastProgressTicks: Int64 = 0 + + private var cancellables = Set() + + init(viewModel: VideoPlayerViewModel) { + + self.viewModel = viewModel + + super.init(nibName: nil, bundle: nil) + + let player: AVPlayer + + if let transcodedStreamURL = viewModel.transcodedStreamURL { + player = AVPlayer(url: transcodedStreamURL) + } else { + player = AVPlayer(url: viewModel.hlsStreamURL) + } + + player.appliesMediaSelectionCriteriaAutomatically = false + + 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) + } + } + + self.player = player + + 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 + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + stop() + removePeriodicTimeObserver() + } + + func removePeriodicTimeObserver() { + if let timeObserverToken = timeObserverToken { + player?.removeTimeObserver(timeObserverToken) + self.timeObserverToken = nil + } + } + + 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() + }) + } + + private func play() { + player?.play() + + viewModel.sendPlayReport() + } + + private func sendProgressReport(seconds: Double) { + viewModel.setSeconds(Int64(seconds)) + viewModel.sendProgressReport() + } + + private func stop() { + self.player?.pause() + viewModel.sendStopReport() + } +} diff --git a/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift b/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift index 499acb2e..d1c96f24 100644 --- a/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift +++ b/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift @@ -9,19 +9,19 @@ import SwiftUI import UIKit -//struct NativePlayerView: UIViewControllerRepresentable { -// -// let viewModel: VideoPlayerViewModel -// -// typealias UIViewControllerType = NativePlayerViewController -// -// func makeUIViewController(context: Context) -> NativePlayerViewController { -// -// NativePlayerViewController(viewModel: viewModel) -// } -// -// func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) {} -//} +struct LiveTVNativePlayerView: UIViewControllerRepresentable { + + let viewModel: VideoPlayerViewModel + + typealias UIViewControllerType = LiveTVNativePlayerViewController + + func makeUIViewController(context: Context) -> LiveTVNativePlayerViewController { + + LiveTVNativePlayerViewController(viewModel: viewModel) + } + + func updateUIViewController(_ uiViewController: LiveTVNativePlayerViewController, context: Context) {} +} struct LiveTVPlayerView: UIViewControllerRepresentable { From a8f8a93efc22849f65b48d36f29498091d926c9b Mon Sep 17 00:00:00 2001 From: jhays Date: Thu, 28 Apr 2022 14:27:52 -0500 Subject: [PATCH 05/18] sizing --- Swiftfin/Views/LiveTVChannelsView.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Swiftfin/Views/LiveTVChannelsView.swift b/Swiftfin/Views/LiveTVChannelsView.swift index 02b03e2b..5a978812 100644 --- a/Swiftfin/Views/LiveTVChannelsView.swift +++ b/Swiftfin/Views/LiveTVChannelsView.swift @@ -88,7 +88,7 @@ struct LiveTVChannelsView: View { private func createGridLayout() -> NSCollectionLayoutSection { if UIDevice.current.userInterfaceIdiom == .pad { let itemSize = NSCollectionLayoutSize( - widthDimension: .absolute((UIScreen.main.bounds.width / 2) - 2), + widthDimension: .absolute((UIScreen.main.bounds.width / 2) - 16), heightDimension: .fractionalHeight(1) ) let item = NSCollectionLayoutItem(layoutSize: itemSize) @@ -133,8 +133,16 @@ struct LiveTVChannelsView: View { let section = NSCollectionLayoutSection(group: group) return section } else { + + let scenes = UIApplication.shared.connectedScenes + let windowScene = scenes.first as? UIWindowScene + var width = (UIScreen.main.bounds.width / 2) - 32 + if let safeArea = windowScene?.keyWindow?.safeAreaInsets { + width = (UIScreen.main.bounds.width / 2) - safeArea.left - safeArea.right + } + let itemSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(0.49), + widthDimension: .absolute(width), heightDimension: .fractionalHeight(1) ) let item = NSCollectionLayoutItem(layoutSize: itemSize) From 80477c4bbd07916d1130afc42e7baec88c452cbb Mon Sep 17 00:00:00 2001 From: jhays Date: Thu, 28 Apr 2022 14:29:43 -0500 Subject: [PATCH 06/18] swiftformat --- .../Coordinators/LibraryListCoordinator.swift | 12 +- .../LiveTVChannelsCoordinator.swift | 2 +- Shared/Coordinators/LiveTVCoordinator.swift | 30 +- .../iOSLiveTVVideoPlayerCoordinator.swift | 48 +- .../ViewModels/LiveTVChannelsViewModel.swift | 2 +- Swiftfin/Views/LibraryListView.swift | 36 +- Swiftfin/Views/LiveTVChannelItemElement.swift | 218 +- .../Views/LiveTVChannelItemWideElement.swift | 277 ++- Swiftfin/Views/LiveTVChannelsView.swift | 365 ++-- Swiftfin/Views/LiveTVProgramsView.swift | 394 ++-- .../ExperimentalSettingsView.swift | 38 +- .../Views/VideoPlayer/LiveTVPlayerView.swift | 34 +- .../LiveTVPlayerViewController.swift | 1939 ++++++++--------- 13 files changed, 1684 insertions(+), 1711 deletions(-) diff --git a/Shared/Coordinators/LibraryListCoordinator.swift b/Shared/Coordinators/LibraryListCoordinator.swift index 7892af36..da36cc92 100644 --- a/Shared/Coordinators/LibraryListCoordinator.swift +++ b/Shared/Coordinators/LibraryListCoordinator.swift @@ -20,8 +20,8 @@ final class LibraryListCoordinator: NavigationCoordinatable { var search = makeSearch @Route(.push) var library = makeLibrary - @Route(.push) - var liveTV = makeLiveTV + @Route(.push) + var liveTV = makeLiveTV let viewModel: LibraryListViewModel @@ -36,10 +36,10 @@ final class LibraryListCoordinator: NavigationCoordinatable { func makeSearch(viewModel: LibrarySearchViewModel) -> SearchCoordinator { SearchCoordinator(viewModel: viewModel) } - - func makeLiveTV() -> LiveTVCoordinator { - LiveTVCoordinator() - } + + func makeLiveTV() -> LiveTVCoordinator { + LiveTVCoordinator() + } @ViewBuilder func makeStart() -> some View { diff --git a/Shared/Coordinators/LiveTVChannelsCoordinator.swift b/Shared/Coordinators/LiveTVChannelsCoordinator.swift index 343da7c4..77f80de8 100644 --- a/Shared/Coordinators/LiveTVChannelsCoordinator.swift +++ b/Shared/Coordinators/LiveTVChannelsCoordinator.swift @@ -24,7 +24,7 @@ final class LiveTVChannelsCoordinator: NavigationCoordinatable { func makeModalItem(item: BaseItemDto) -> NavigationViewCoordinator { NavigationViewCoordinator(ItemCoordinator(item: item)) } - + func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel)) } diff --git a/Shared/Coordinators/LiveTVCoordinator.swift b/Shared/Coordinators/LiveTVCoordinator.swift index 77d21362..f3a80bcd 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) - - @Root - var start = makeStart - @Route(.fullScreen) - var videoPlayer = makeVideoPlayer - - @ViewBuilder - func makeStart() -> some View { - LiveTVChannelsView() - } - - func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { - NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel)) - } + let stack = NavigationStack(initial: \LiveTVCoordinator.start) + + @Root + var start = makeStart + @Route(.fullScreen) + var videoPlayer = makeVideoPlayer + + @ViewBuilder + func makeStart() -> some View { + LiveTVChannelsView() + } + + func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel)) + } } diff --git a/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift index 5fe96e15..6c94547f 100644 --- a/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift @@ -13,28 +13,28 @@ import Stinsen import SwiftUI final class LiveTVVideoPlayerCoordinator: NavigationCoordinatable { - - let stack = NavigationStack(initial: \LiveTVVideoPlayerCoordinator.start) - - @Root - var start = makeStart - - let viewModel: VideoPlayerViewModel - - 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() - } - } + + let stack = NavigationStack(initial: \LiveTVVideoPlayerCoordinator.start) + + @Root + var start = makeStart + + let viewModel: VideoPlayerViewModel + + 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() + } + } } diff --git a/Shared/ViewModels/LiveTVChannelsViewModel.swift b/Shared/ViewModels/LiveTVChannelsViewModel.swift index 8bddf1c0..6e4fe06d 100644 --- a/Shared/ViewModels/LiveTVChannelsViewModel.swift +++ b/Shared/ViewModels/LiveTVChannelsViewModel.swift @@ -21,7 +21,7 @@ struct LiveTVChannelProgram: Hashable { let id = UUID() let channel: BaseItemDto let currentProgram: BaseItemDto? - let programs: [BaseItemDto] + let programs: [BaseItemDto] } final class LiveTVChannelsViewModel: ViewModel { diff --git a/Swiftfin/Views/LibraryListView.swift b/Swiftfin/Views/LibraryListView.swift index 0229d143..3f686477 100644 --- a/Swiftfin/Views/LibraryListView.swift +++ b/Swiftfin/Views/LibraryListView.swift @@ -16,17 +16,17 @@ struct LibraryListView: View { var libraryListRouter: LibraryListCoordinator.Router @StateObject var viewModel = LibraryListViewModel() - - @Default(.Experimental.liveTVAlphaEnabled) - var liveTVAlphaEnabled - - var supportedCollectionTypes: [String] { - if liveTVAlphaEnabled { - return ["movies", "tvshows", "livetv", "boxsets", "other"] - } else { - return ["movies", "tvshows", "boxsets", "other"] - } - } + + @Default(.Experimental.liveTVAlphaEnabled) + var liveTVAlphaEnabled + + var supportedCollectionTypes: [String] { + if liveTVAlphaEnabled { + return ["movies", "tvshows", "livetv", "boxsets", "other"] + } else { + return ["movies", "tvshows", "boxsets", "other"] + } + } var body: some View { ScrollView { @@ -59,13 +59,13 @@ struct LibraryListView: View { return self.supportedCollectionTypes.contains(collectionType) }, id: \.id) { library in Button { - if library.collectionType == "livetv" { - libraryListRouter.route(to: \.liveTV) - } else { - libraryListRouter.route(to: \.library, - (viewModel: LibraryViewModel(parentID: library.id), - title: library.name ?? "")) - } + if library.collectionType == "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()) diff --git a/Swiftfin/Views/LiveTVChannelItemElement.swift b/Swiftfin/Views/LiveTVChannelItemElement.swift index 0ff8dc06..1cc2fa53 100644 --- a/Swiftfin/Views/LiveTVChannelItemElement.swift +++ b/Swiftfin/Views/LiveTVChannelItemElement.swift @@ -10,113 +10,113 @@ import JellyfinAPI import SwiftUI struct LiveTVChannelItemElement: View { - @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 - - 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) - - 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) - - if loading { - ProgressView() - } - } - .overlay(RoundedRectangle(cornerRadius: 0) - .stroke(Color.blue, lineWidth: 0)) - } + @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 + + 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) + + 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) + + 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 f5dd9036..da7c021d 100644 --- a/Swiftfin/Views/LiveTVChannelItemWideElement.swift +++ b/Swiftfin/Views/LiveTVChannelItemWideElement.swift @@ -10,143 +10,142 @@ import JellyfinAPI import SwiftUI struct LiveTVChannelItemWideElement: View { - @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 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 - } - - 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.count > 0, - 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) - } - } + @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 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 + } + + 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) + } + } + + @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 5a978812..6b56f97b 100644 --- a/Swiftfin/Views/LiveTVChannelsView.swift +++ b/Swiftfin/Views/LiveTVChannelsView.swift @@ -14,204 +14,179 @@ 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 - - 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() - 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") - } - } - } - } + @EnvironmentObject + var router: LiveTVCoordinator.Router + @StateObject + var viewModel = LiveTVChannelsViewModel() + @State + private var isPortrait = 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 - } - LiveTVChannelItemWideElement(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 { - if UIDevice.current.userInterfaceIdiom == .pad { - let itemSize = NSCollectionLayoutSize( - widthDimension: .absolute((UIScreen.main.bounds.width / 2) - 16), - heightDimension: .fractionalHeight(1) - ) - let item = NSCollectionLayoutItem(layoutSize: itemSize) - item.edgeSpacing = NSCollectionLayoutEdgeSpacing( - leading: .flexible(0), top: nil, - trailing: .flexible(2), bottom: .flexible(2) - ) - let item2 = NSCollectionLayoutItem(layoutSize: itemSize) - item2.edgeSpacing = NSCollectionLayoutEdgeSpacing( - leading: nil, top: nil, - trailing: .flexible(0), bottom: .flexible(2) - ) - let groupSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1.0), - heightDimension: .absolute(144) - ) - let group = NSCollectionLayoutGroup.horizontal( - layoutSize: groupSize, - subitems: [item, item2] - ) - let section = NSCollectionLayoutSection(group: group) - return section - } else { - if isPortrait { - let itemSize = NSCollectionLayoutSize( - widthDimension: .absolute(UIScreen.main.bounds.width - 32), - heightDimension: .fractionalHeight(1) - ) - let item = NSCollectionLayoutItem(layoutSize: itemSize) - item.edgeSpacing = NSCollectionLayoutEdgeSpacing( - leading: .flexible(0), top: nil, - trailing: .flexible(2), bottom: .flexible(2) - ) - let groupSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1.0), - heightDimension: .absolute(144) - ) - let group = NSCollectionLayoutGroup.horizontal( - layoutSize: groupSize, - subitems: [item] - ) - let section = NSCollectionLayoutSection(group: group) - return section - } else { - - let scenes = UIApplication.shared.connectedScenes - let windowScene = scenes.first as? UIWindowScene - var width = (UIScreen.main.bounds.width / 2) - 32 - if let safeArea = windowScene?.keyWindow?.safeAreaInsets { - width = (UIScreen.main.bounds.width / 2) - safeArea.left - safeArea.right - } + 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() + 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") + } + } + } + } - let itemSize = NSCollectionLayoutSize( - widthDimension: .absolute(width), - heightDimension: .fractionalHeight(1) - ) - let item = NSCollectionLayoutItem(layoutSize: itemSize) - item.edgeSpacing = NSCollectionLayoutEdgeSpacing( - leading: .flexible(0), top: nil, - trailing: .flexible(2), bottom: .flexible(2) - ) - let item2 = NSCollectionLayoutItem(layoutSize: itemSize) - item2.edgeSpacing = NSCollectionLayoutEdgeSpacing( - leading: nil, top: nil, - trailing: .flexible(0), bottom: .flexible(2) - ) - let groupSize = NSCollectionLayoutSize( - widthDimension: .fractionalWidth(1.0), - heightDimension: .absolute(144) - ) - let group = NSCollectionLayoutGroup.horizontal( - layoutSize: groupSize, - subitems: [item, item2] - ) - let section = NSCollectionLayoutSection(group: group) - return section - } - } - } - - 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 - } + @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 + } + LiveTVChannelItemWideElement(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 { + if UIDevice.current.userInterfaceIdiom == .pad { + let itemSize = NSCollectionLayoutSize(widthDimension: .absolute((UIScreen.main.bounds.width / 2) - 16), + heightDimension: .fractionalHeight(1)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .flexible(0), top: nil, + trailing: .flexible(2), bottom: .flexible(2)) + let item2 = NSCollectionLayoutItem(layoutSize: itemSize) + item2.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: nil, top: nil, + trailing: .flexible(0), bottom: .flexible(2)) + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(144)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, + subitems: [item, item2]) + let section = NSCollectionLayoutSection(group: group) + return section + } else { + if isPortrait { + let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(UIScreen.main.bounds.width - 32), + heightDimension: .fractionalHeight(1)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .flexible(0), top: nil, + trailing: .flexible(2), bottom: .flexible(2)) + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(144)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, + subitems: [item]) + let section = NSCollectionLayoutSection(group: group) + return section + } else { + + let scenes = UIApplication.shared.connectedScenes + let windowScene = scenes.first as? UIWindowScene + var width = (UIScreen.main.bounds.width / 2) - 32 + if let safeArea = windowScene?.keyWindow?.safeAreaInsets { + width = (UIScreen.main.bounds.width / 2) - safeArea.left - safeArea.right + } + + let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(width), + heightDimension: .fractionalHeight(1)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + item.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .flexible(0), top: nil, + trailing: .flexible(2), bottom: .flexible(2)) + let item2 = NSCollectionLayoutItem(layoutSize: itemSize) + item2.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: nil, top: nil, + trailing: .flexible(0), bottom: .flexible(2)) + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), + heightDimension: .absolute(144)) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, + subitems: [item, item2]) + let section = NSCollectionLayoutSection(group: group) + return section + } + } + } + + 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 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)") - } - - return LiveTVChannelViewProgram(timeDisplay: timeText, title: displayText) - } + 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) + } } diff --git a/Swiftfin/Views/LiveTVProgramsView.swift b/Swiftfin/Views/LiveTVProgramsView.swift index b33ba735..31dcdab2 100644 --- a/Swiftfin/Views/LiveTVProgramsView.swift +++ b/Swiftfin/Views/LiveTVProgramsView.swift @@ -10,201 +10,201 @@ import Stinsen import SwiftUI struct LiveTVProgramsView: View { - @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: { -#if os(iOS) -#elseif os(tvOS) - LandscapeItemElement(item: item) -#endif - } - .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: { -#if os(iOS) -#elseif os(tvOS) - LandscapeItemElement(item: item) -#endif - } - .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: { -#if os(iOS) -#elseif os(tvOS) - LandscapeItemElement(item: item) -#endif - } - .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: { -#if os(iOS) -#elseif os(tvOS) - LandscapeItemElement(item: item) -#endif - } - .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: { -#if os(iOS) -#elseif os(tvOS) - LandscapeItemElement(item: item) -#endif - } - .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: { -#if os(iOS) -#elseif os(tvOS) - LandscapeItemElement(item: item) -#endif - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) - } - } - } - } + @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: { + #if os(iOS) + #elseif os(tvOS) + LandscapeItemElement(item: item) + #endif + } + .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: { + #if os(iOS) + #elseif os(tvOS) + LandscapeItemElement(item: item) + #endif + } + .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: { + #if os(iOS) + #elseif os(tvOS) + LandscapeItemElement(item: item) + #endif + } + .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: { + #if os(iOS) + #elseif os(tvOS) + LandscapeItemElement(item: item) + #endif + } + .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: { + #if os(iOS) + #elseif os(tvOS) + LandscapeItemElement(item: item) + #endif + } + .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: { + #if os(iOS) + #elseif os(tvOS) + LandscapeItemElement(item: item) + #endif + } + .buttonStyle(PlainNavigationLinkButtonStyle()) + } + Spacer().frame(width: 45) + } + }.frame(height: 350) + } + } + } + } } diff --git a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift index f1137b0e..e0192df0 100644 --- a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift +++ b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift @@ -17,13 +17,13 @@ struct ExperimentalSettingsView: View { 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 { @@ -37,18 +37,18 @@ struct ExperimentalSettingsView: View { } header: { L10n.experimental.text } - - Section { - - Toggle("Live TV (Alpha)", isOn: $liveTVAlphaEnabled) - - Toggle("Live TV Force Direct Play", isOn: $liveTVForceDirectPlay) - - Toggle("Live TV Native Player", isOn: $liveTVNativePlayer) - - } header: { - Text("Live TV") - } + + Section { + + Toggle("Live TV (Alpha)", isOn: $liveTVAlphaEnabled) + + Toggle("Live TV Force Direct Play", isOn: $liveTVForceDirectPlay) + + Toggle("Live TV Native Player", isOn: $liveTVNativePlayer) + + } header: { + Text("Live TV") + } } } } diff --git a/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift b/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift index d1c96f24..5b21e0f2 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 - - typealias UIViewControllerType = LiveTVPlayerViewController - - func makeUIViewController(context: Context) -> LiveTVPlayerViewController { - - LiveTVPlayerViewController(viewModel: viewModel) - } - - func updateUIViewController(_ uiViewController: LiveTVPlayerViewController, context: Context) {} + + let viewModel: VideoPlayerViewModel + + typealias UIViewControllerType = LiveTVPlayerViewController + + func makeUIViewController(context: Context) -> LiveTVPlayerViewController { + + LiveTVPlayerViewController(viewModel: viewModel) + } + + func updateUIViewController(_ uiViewController: LiveTVPlayerViewController, context: Context) {} } diff --git a/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift b/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift index 7f81cf16..93d4cef6 100644 --- a/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift +++ b/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift @@ -19,1014 +19,1013 @@ 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() - } - - @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) - - 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 - - newJumpForwardImageView.alpha = 0 - - view.addSubview(newJumpForwardImageView) - - NSLayoutConstraint.activate([ - newJumpForwardImageView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -150), - newJumpForwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), - ]) - - currentJumpForwardOverlayView = newJumpForwardImageView - } + // 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() + } + + @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) + + 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 + + newJumpForwardImageView.alpha = 0 + + view.addSubview(newJumpForwardImageView) + + NSLayoutConstraint.activate([ + newJumpForwardImageView.rightAnchor.constraint(equalTo: view.rightAnchor, constant: -150), + newJumpForwardImageView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + + 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 - - if vlcMediaPlayer.media != nil { - viewModelListeners.forEach { $0.cancel() } - - vlcMediaPlayer.stop() - viewModel.sendStopReport() - viewModel.playerOverlayDelegate = nil - } - - vlcMediaPlayer = VLCMediaPlayer() - - // setup with new player and view model - - vlcMediaPlayer = VLCMediaPlayer() - - vlcMediaPlayer.delegate = self - vlcMediaPlayer.drawable = videoContentView - - vlcMediaPlayer.setSubtitleSize(Defaults[.subtitleSize]) - - stopOverlayDismissTimer() - - lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 - - let media: VLCMedia - - 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") - - vlcMediaPlayer.media = media - - setupOverlayHostingController(viewModel: newViewModel) - setupViewModelListeners(viewModel: newViewModel) - - newViewModel.getAdjacentEpisodes() - newViewModel.playerOverlayDelegate = self - - 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 - } - } - - viewModel = newViewModel - - if viewModel.streamType == .direct { - LogManager.shared.log.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? "--")") - } else if viewModel.streamType == .transcode && Defaults[.Experimental.forceDirectPlay] { - LogManager.shared.log.debug("Player set up with forced direct stream for item: \(viewModel.item.id ?? "--")") - } else { - LogManager.shared.log.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? "--")") - } - } - - // MARK: startPlayback - - 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) - } - } - - setMediaPlayerTimeAtCurrentSlider() - - viewModel.sendPlayReport() - - restartOverlayDismissTimer() - } - - // MARK: setupViewModelListeners - - 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.$selectedAudioStreamIndex.sink { newAudioStreamIndex in - self.didSelectAudioStream(index: newAudioStreamIndex) - }.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.$jumpBackwardLength.sink { newJumpBackwardLength in - self.refreshJumpBackwardOverlayView(with: newJumpBackwardLength) - }.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 - - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } - } + /// 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() } + + vlcMediaPlayer.stop() + viewModel.sendStopReport() + viewModel.playerOverlayDelegate = nil + } + + vlcMediaPlayer = VLCMediaPlayer() + + // setup with new player and view model + + vlcMediaPlayer = VLCMediaPlayer() + + vlcMediaPlayer.delegate = self + vlcMediaPlayer.drawable = videoContentView + + vlcMediaPlayer.setSubtitleSize(Defaults[.subtitleSize]) + + stopOverlayDismissTimer() + + lastPlayerTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + lastProgressReportTicks = newViewModel.item.userData?.playbackPositionTicks ?? 0 + + let media: VLCMedia + + 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") + + vlcMediaPlayer.media = media + + setupOverlayHostingController(viewModel: newViewModel) + setupViewModelListeners(viewModel: newViewModel) + + newViewModel.getAdjacentEpisodes() + newViewModel.playerOverlayDelegate = self + + 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 + } + } + + viewModel = newViewModel + + if viewModel.streamType == .direct { + LogManager.shared.log.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? "--")") + } else if viewModel.streamType == .transcode && Defaults[.Experimental.forceDirectPlay] { + LogManager.shared.log.debug("Player set up with forced direct stream for item: \(viewModel.item.id ?? "--")") + } else { + LogManager.shared.log.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? "--")") + } + } + + // MARK: startPlayback + + 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) + } + } + + setMediaPlayerTimeAtCurrentSlider() + + viewModel.sendPlayReport() + + restartOverlayDismissTimer() + } + + // MARK: setupViewModelListeners + + 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.$selectedAudioStreamIndex.sink { newAudioStreamIndex in + self.didSelectAudioStream(index: newAudioStreamIndex) + }.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.$jumpBackwardLength.sink { newJumpBackwardLength in + self.refreshJumpBackwardOverlayView(with: newJumpBackwardLength) + }.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 + + 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 } - - guard overlayHostingController.view.alpha != 1 else { return } - - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 1 - } - } - - private func hideOverlay() { - guard !UIAccessibility.isVoiceOverRunning else { return } - - guard let overlayHostingController = currentOverlayHostingController else { return } - - guard overlayHostingController.view.alpha != 0 else { return } - - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 0 - } - } - - private func toggleOverlay() { - guard let overlayHostingController = currentOverlayHostingController else { return } - - if overlayHostingController.view.alpha < 1 { - showOverlay() - } else { - hideOverlay() - } - } + private func showOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 1 else { return } + + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 1 + } + } + + private func hideOverlay() { + guard !UIAccessibility.isVoiceOverRunning else { return } + + guard let overlayHostingController = currentOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 0 else { return } + + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 0 + } + } + + private func toggleOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } + + if overlayHostingController.view.alpha < 1 { + showOverlay() + } else { + hideOverlay() + } + } } // MARK: Show/Hide System Control extension LiveTVPlayerViewController { - private func showBrightnessOverlay() { - guard !displayingOverlay else { return } - - 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() - - UIView.animate(withDuration: 0.1) { - self.systemControlOverlayLabel.alpha = 1 - } - } - - 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 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 - } - } - - private func hideSystemControlOverlay() { - UIView.animate(withDuration: 0.75) { - self.systemControlOverlayLabel.alpha = 0 - } - } + private func showBrightnessOverlay() { + guard !displayingOverlay else { return } + + 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() + + UIView.animate(withDuration: 0.1) { + self.systemControlOverlayLabel.alpha = 1 + } + } + + 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 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 + } + } + + 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 } - - currentJumpBackwardOverlayView.layer.removeAllAnimations() - - UIView.animate(withDuration: 0.1) { - currentJumpBackwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpBackwardOverlay() - } - } - - private func hideJumpBackwardOverlay() { - guard let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } - - UIView.animate(withDuration: 0.3) { - currentJumpBackwardOverlayView.alpha = 0 - } - } - - private func flashJumpFowardOverlay() { - guard !displayingOverlay, let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } - - currentJumpForwardOverlayView.layer.removeAllAnimations() - - UIView.animate(withDuration: 0.1) { - currentJumpForwardOverlayView.alpha = 1 - } completion: { _ in - self.hideJumpForwardOverlay() - } - } - - private func hideJumpForwardOverlay() { - guard let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } - - UIView.animate(withDuration: 0.3) { - currentJumpForwardOverlayView.alpha = 0 - } - } + private func flashJumpBackwardOverlay() { + guard !displayingOverlay, let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } + + currentJumpBackwardOverlayView.layer.removeAllAnimations() + + UIView.animate(withDuration: 0.1) { + currentJumpBackwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpBackwardOverlay() + } + } + + private func hideJumpBackwardOverlay() { + guard let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } + + UIView.animate(withDuration: 0.3) { + currentJumpBackwardOverlayView.alpha = 0 + } + } + + private func flashJumpFowardOverlay() { + guard !displayingOverlay, let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } + + currentJumpForwardOverlayView.layer.removeAllAnimations() + + UIView.animate(withDuration: 0.1) { + currentJumpForwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpForwardOverlay() + } + } + + private func hideJumpForwardOverlay() { + guard let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } + + UIView.animate(withDuration: 0.3) { + currentJumpForwardOverlayView.alpha = 0 + } + } } // MARK: Hide/Show Chapters extension LiveTVPlayerViewController { - private func showChaptersOverlay() { - guard let overlayHostingController = currentChapterOverlayHostingController else { return } - - guard overlayHostingController.view.alpha != 1 else { return } - - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 1 - } - } - - private func hideChaptersOverlay() { - guard let overlayHostingController = currentChapterOverlayHostingController else { return } - - guard overlayHostingController.view.alpha != 0 else { return } - - UIView.animate(withDuration: 0.2) { - overlayHostingController.view.alpha = 0 - } - } + private func showChaptersOverlay() { + guard let overlayHostingController = currentChapterOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 1 else { return } + + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 1 + } + } + + private func hideChaptersOverlay() { + guard let overlayHostingController = currentChapterOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 0 else { return } + + 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) - } - - @objc - private func dismissTimerFired() { - hideOverlay() - } - - private func stopOverlayDismissTimer() { - overlayDismissTimer?.invalidate() - } + 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() + } + + private func stopOverlayDismissTimer() { + overlayDismissTimer?.invalidate() + } } // MARK: VLCMediaPlayerDelegate extension LiveTVPlayerViewController: VLCMediaPlayerDelegate { - // MARK: mediaPlayerStateChanged - - 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 - - if vlcMediaPlayer.state == VLCMediaPlayerState.ended { - if viewModel.autoplayEnabled, viewModel.nextItemVideoPlayerViewModel != nil { - didSelectPlayNextItem() - } else { - didSelectClose() - } - } - } - - // MARK: mediaPlayerTimeChanged - - 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 - } - - // 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) - } - - lastPlayerTicks = currentPlayerTicks - - // Send progress report every 5 seconds - if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - } + // MARK: mediaPlayerStateChanged + + 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 + + if vlcMediaPlayer.state == VLCMediaPlayerState.ended { + if viewModel.autoplayEnabled, viewModel.nextItemVideoPlayerViewModel != nil { + didSelectPlayNextItem() + } else { + didSelectClose() + } + } + } + + // MARK: mediaPlayerTimeChanged + + 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 + } + + // 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) + } + + lastPlayerTicks = currentPlayerTicks + + // Send progress report every 5 seconds + if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + } } // MARK: PlayerOverlayDelegate and more extension LiveTVPlayerViewController: PlayerOverlayDelegate { - func didSelectAudioStream(index: Int) { - vlcMediaPlayer.currentAudioTrackIndex = Int32(index) - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - /// Do not call when setting to index -1 - func didSelectSubtitleStream(index: Int) { - viewModel.subtitlesEnabled = true - vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - @objc - func didSelectClose() { - vlcMediaPlayer.stop() - - viewModel.sendStopReport() - - dismiss(animated: true, completion: nil) - } - - 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 didDeselectMenu() { - restartOverlayDismissTimer() - } - - @objc - func didSelectBackward() { - flashJumpBackwardOverlay() - - vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) - - if displayingOverlay { - restartOverlayDismissTimer() - } - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - @objc - func didSelectForward() { - flashJumpFowardOverlay() - - vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) - - if displayingOverlay { - restartOverlayDismissTimer() - } - - viewModel.sendProgressReport() - - 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: () - } - } - - func didGenerallyTap() { - toggleOverlay() - - restartOverlayDismissTimer(interval: 5) - } - - func didBeginScrubbing() { - stopOverlayDismissTimer() - } - - func didEndScrubbing() { - setMediaPlayerTimeAtCurrentSlider() - - restartOverlayDismissTimer() - - viewModel.sendProgressReport() - - lastProgressReportTicks = currentPlayerTicks - } - - @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 didSelectPreviousPlaybackSpeed() { - if let previousPlaybackSpeed = viewModel.playbackSpeed.previous { - viewModel.playbackSpeed = previousPlaybackSpeed - } - } - - @objc - func didSelectNextPlaybackSpeed() { - if let nextPlaybackSpeed = viewModel.playbackSpeed.next { - viewModel.playbackSpeed = nextPlaybackSpeed - } - } - - @objc - func didSelectNormalPlaybackSpeed() { - viewModel.playbackSpeed = .one - } - - 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 - - if newPositionOffset > 0 { - vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) - } else { - vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) - } - - viewModel.sendProgressReport() - } - - func didSelectScreenFill() { - isScreenFilled.toggle() - - 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) - - let scale: CGFloat - - 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) - } - } - - private func shrinkScreen() { - UIView.animate(withDuration: 0.2) { - self.videoContentView.transform = .identity - } - } - - func getScreenFilled() -> Bool { - isScreenFilled - } - - 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 - - return videoAspectRatio > screenAspectRatio - } -} + func didSelectAudioStream(index: Int) { + vlcMediaPlayer.currentAudioTrackIndex = Int32(index) + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + /// Do not call when setting to index -1 + func didSelectSubtitleStream(index: Int) { + viewModel.subtitlesEnabled = true + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + @objc + func didSelectClose() { + vlcMediaPlayer.stop() + + viewModel.sendStopReport() + + dismiss(animated: true, completion: nil) + } + + 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 didDeselectMenu() { + restartOverlayDismissTimer() + } + + @objc + func didSelectBackward() { + flashJumpBackwardOverlay() + + vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) + + if displayingOverlay { + restartOverlayDismissTimer() + } + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + @objc + func didSelectForward() { + flashJumpFowardOverlay() + + vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) + + if displayingOverlay { + restartOverlayDismissTimer() + } + + viewModel.sendProgressReport() + + 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: () + } + } + + func didGenerallyTap() { + toggleOverlay() + + restartOverlayDismissTimer(interval: 5) + } + + func didBeginScrubbing() { + stopOverlayDismissTimer() + } + + func didEndScrubbing() { + setMediaPlayerTimeAtCurrentSlider() + + restartOverlayDismissTimer() + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + @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 didSelectPreviousPlaybackSpeed() { + if let previousPlaybackSpeed = viewModel.playbackSpeed.previous { + viewModel.playbackSpeed = previousPlaybackSpeed + } + } + + @objc + func didSelectNextPlaybackSpeed() { + if let nextPlaybackSpeed = viewModel.playbackSpeed.next { + viewModel.playbackSpeed = nextPlaybackSpeed + } + } + + @objc + func didSelectNormalPlaybackSpeed() { + viewModel.playbackSpeed = .one + } + + 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 + + if newPositionOffset > 0 { + vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) + } else { + vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) + } + + viewModel.sendProgressReport() + } + + func didSelectScreenFill() { + isScreenFilled.toggle() + + 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) + + let scale: CGFloat + + 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) + } + } + + private func shrinkScreen() { + UIView.animate(withDuration: 0.2) { + self.videoContentView.transform = .identity + } + } + + func getScreenFilled() -> Bool { + isScreenFilled + } + + 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 + + return videoAspectRatio > screenAspectRatio + } +} From 8bc87282eec97dedd4df414cdd12b3e93326ae40 Mon Sep 17 00:00:00 2001 From: jhays Date: Sun, 1 May 2022 21:21:06 -0500 Subject: [PATCH 07/18] update live tv cells for tvOS --- .../Coordinators/LibraryListCoordinator.swift | 14 +- .../Views/LiveTVChannelItemElement.swift | 130 +++++++++++------- Swiftfin tvOS/Views/LiveTVChannelsView.swift | 80 ++++++++--- 3 files changed, 152 insertions(+), 72 deletions(-) diff --git a/Shared/Coordinators/LibraryListCoordinator.swift b/Shared/Coordinators/LibraryListCoordinator.swift index da36cc92..5ac1ad91 100644 --- a/Shared/Coordinators/LibraryListCoordinator.swift +++ b/Shared/Coordinators/LibraryListCoordinator.swift @@ -20,8 +20,10 @@ final class LibraryListCoordinator: NavigationCoordinatable { var search = makeSearch @Route(.push) var library = makeLibrary - @Route(.push) - var liveTV = makeLiveTV + #if os(iOS) + @Route(.push) + var liveTV = makeLiveTV + #endif let viewModel: LibraryListViewModel @@ -37,9 +39,11 @@ final class LibraryListCoordinator: NavigationCoordinatable { SearchCoordinator(viewModel: viewModel) } - func makeLiveTV() -> LiveTVCoordinator { - LiveTVCoordinator() - } + #if os(iOS) + func makeLiveTV() -> LiveTVCoordinator { + LiveTVCoordinator() + } + #endif @ViewBuilder func makeStart() -> some View { diff --git a/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift b/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift index f36aa1bf..90dc1727 100644 --- a/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift +++ b/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift @@ -18,14 +18,25 @@ struct LiveTVChannelItemElement: View { private var isFocused: Bool = false var channel: BaseItemDto - var program: BaseItemDto? - var startString = " " - var endString = " " - var progressPercent = Double(0) + 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 + } + private var detailText: String { - guard let program = program else { + guard let program = currentProgram else { return "" } var text = "" @@ -60,61 +71,62 @@ struct LiveTVChannelItemElement: View { }.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() + 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)) - 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) - } + 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) } - .padding() - .opacity(loading ? 0.5 : 1.0) 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)) @@ -133,4 +145,20 @@ struct LiveTVChannelItemElement: View { } } } + + @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 19d66649..050f8833 100644 --- a/Swiftfin tvOS/Views/LiveTVChannelsView.swift +++ b/Swiftfin tvOS/Views/LiveTVChannelsView.swift @@ -11,6 +11,8 @@ import JellyfinAPI import SwiftUI import SwiftUICollection +typealias LiveTVChannelViewProgram = (timeDisplay: String, title: String) + struct LiveTVChannelsView: View { @EnvironmentObject var router: LiveTVChannelsCoordinator.Router @@ -53,23 +55,30 @@ struct LiveTVChannelsView: View { func makeCellView(indexPath: IndexPath, cell: LiveTVChannelRowCell) -> some View { let item = cell.item let channel = item.channel - if channel.type != "Folder" { - let progressPercent = item.program?.getLiveProgressPercentage() ?? 0 - LiveTVChannelItemElement(channel: channel, - program: item.program, - startString: item.program?.getLiveStartTimeString(formatter: viewModel.timeFormatter) ?? " ", - endString: item.program?.getLiveEndTimeString(formatter: viewModel.timeFormatter) ?? " ", - progressPercent: progressPercent > 1.0 ? 1.0 : progressPercent, - 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) - } - } - }) + 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 { @@ -100,4 +109,43 @@ struct LiveTVChannelsView: View { 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 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)") + } + + return LiveTVChannelViewProgram(timeDisplay: timeText, title: displayText) + } } From 158fbb106464ff41410eb1f1d1f5ff9914a075ab Mon Sep 17 00:00:00 2001 From: jhays Date: Tue, 3 May 2022 22:30:43 -0500 Subject: [PATCH 08/18] fix merge --- .../VideoPlayer/LiveTVPlayerViewController.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift b/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift index 93d4cef6..21348eb0 100644 --- a/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift +++ b/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift @@ -242,7 +242,7 @@ class LiveTVPlayerViewController: UIViewController { @objc private func didTap() { - didGenerallyTap() + didGenerallyTap(point: nil) } @objc @@ -500,11 +500,11 @@ extension LiveTVPlayerViewController { viewModel = newViewModel if viewModel.streamType == .direct { - LogManager.shared.log.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? "--")") + LogManager.log.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? "--")") } else if viewModel.streamType == .transcode && Defaults[.Experimental.forceDirectPlay] { - LogManager.shared.log.debug("Player set up with forced direct stream for item: \(viewModel.item.id ?? "--")") + LogManager.log.debug("Player set up with forced direct stream for item: \(viewModel.item.id ?? "--")") } else { - LogManager.shared.log.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? "--")") + LogManager.log.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? "--")") } } @@ -904,11 +904,15 @@ extension LiveTVPlayerViewController: PlayerOverlayDelegate { } } - func didGenerallyTap() { + func didGenerallyTap(point: CGPoint?) { toggleOverlay() restartOverlayDismissTimer(interval: 5) } + + func didLongPress() { + + } func didBeginScrubbing() { stopOverlayDismissTimer() From d0978ff1ae7bc0f8fab9410b8920a75af5160308 Mon Sep 17 00:00:00 2001 From: jhays Date: Tue, 3 May 2022 22:31:35 -0500 Subject: [PATCH 09/18] swiftformat --- .../CinematicNextUpCardView.swift | 4 ++-- .../CinematicResumeCardView.swift | 4 ++-- .../Overlays/tvOSLiveTVOverlay.swift | 12 +++++------ .../VideoPlayer/Overlays/tvOSVLCOverlay.swift | 12 +++++------ Swiftfin/Components/PortraitHStackView.swift | 6 +++--- Swiftfin/Components/PortraitItemButton.swift | 6 +++--- Swiftfin/Views/ContinueWatchingView.swift | 4 ++-- Swiftfin/Views/ItemView/ItemViewBody.swift | 2 +- .../LiveTVPlayerViewController.swift | 10 ++++------ .../Overlays/VLCPlayerOverlayView.swift | 20 +++++++++---------- 10 files changed, 39 insertions(+), 41 deletions(-) diff --git a/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift b/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift index 3cab0897..fa95e274 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift @@ -28,13 +28,13 @@ struct CinematicNextUpCardView: View { item.getSeriesThumbImage(maxWidth: 350), item.getSeriesBackdropImage(maxWidth: 350), ]) - .frame(width: 350, height: 210) + .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) + .frame(width: 350, height: 210) } LinearGradient(colors: [.clear, .black], diff --git a/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift b/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift index 52b3cf88..8aa188c5 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift @@ -29,13 +29,13 @@ struct CinematicResumeCardView: View { item.getSeriesThumbImage(maxWidth: 350), item.getSeriesBackdropImage(maxWidth: 350), ]) - .frame(width: 350, height: 210) + .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) + .frame(width: 350, height: 210) } LinearGradient(colors: [.clear, .black], diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift index dace960b..1a0f3814 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift @@ -62,18 +62,18 @@ struct tvOSLiveTVOverlay: View { SFSymbolButton(systemName: "chevron.left.circle", action: { viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() }) - .frame(maxWidth: 30, maxHeight: 30) - .disabled(viewModel.previousItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + .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) + .frame(maxWidth: 30, maxHeight: 30) + .disabled(viewModel.nextItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) } if viewModel.shouldShowAutoPlay { diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift index aa480d1a..20db21e6 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift @@ -62,18 +62,18 @@ struct tvOSVLCOverlay: View { SFSymbolButton(systemName: "chevron.left.circle", action: { viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() }) - .frame(maxWidth: 30, maxHeight: 30) - .disabled(viewModel.previousItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + .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) + .frame(maxWidth: 30, maxHeight: 30) + .disabled(viewModel.nextItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) } if viewModel.shouldShowAutoPlay { diff --git a/Swiftfin/Components/PortraitHStackView.swift b/Swiftfin/Components/PortraitHStackView.swift index 79261be4..a9aa57c0 100644 --- a/Swiftfin/Components/PortraitHStackView.swift +++ b/Swiftfin/Components/PortraitHStackView.swift @@ -48,9 +48,9 @@ struct PortraitImageHStackView: View { failureView: { InitialFailureView(item.failureInitials) }) - .portraitPoster(width: maxWidth) - .shadow(radius: 4, y: 2) - .accessibilityIgnoresInvertColors() + .portraitPoster(width: maxWidth) + .shadow(radius: 4, y: 2) + .accessibilityIgnoresInvertColors() if item.showTitle { Text(item.title) diff --git a/Swiftfin/Views/ContinueWatchingView.swift b/Swiftfin/Views/ContinueWatchingView.swift index 505e187f..e4f55910 100644 --- a/Swiftfin/Views/ContinueWatchingView.swift +++ b/Swiftfin/Views/ContinueWatchingView.swift @@ -33,13 +33,13 @@ struct ContinueWatchingView: View { item.getSeriesThumbImage(maxWidth: 320), item.getSeriesBackdropImage(maxWidth: 320), ]) - .frame(width: 320, height: 180) + .frame(width: 320, height: 180) } else { ImageView(sources: [ item.getThumbImage(maxWidth: 320), item.getBackdropImage(maxWidth: 320), ]) - .frame(width: 320, height: 180) + .frame(width: 320, height: 180) } } .accessibilityIgnoresInvertColors() diff --git a/Swiftfin/Views/ItemView/ItemViewBody.swift b/Swiftfin/Views/ItemView/ItemViewBody.swift index 5e9b959d..55dfc8a5 100644 --- a/Swiftfin/Views/ItemView/ItemViewBody.swift +++ b/Swiftfin/Views/ItemView/ItemViewBody.swift @@ -69,7 +69,7 @@ struct ItemViewBody: View { selectedAction: { genre in itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) }) - .padding(.bottom) + .padding(.bottom) } // MARK: Studios diff --git a/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift b/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift index 21348eb0..64b56d1a 100644 --- a/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift +++ b/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift @@ -242,7 +242,7 @@ class LiveTVPlayerViewController: UIViewController { @objc private func didTap() { - didGenerallyTap(point: nil) + didGenerallyTap(point: nil) } @objc @@ -904,15 +904,13 @@ extension LiveTVPlayerViewController: PlayerOverlayDelegate { } } - func didGenerallyTap(point: CGPoint?) { + func didGenerallyTap(point: CGPoint?) { toggleOverlay() restartOverlayDismissTimer(interval: 5) } - - func didLongPress() { - - } + + func didLongPress() {} func didBeginScrubbing() { stopOverlayDismissTimer() diff --git a/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift b/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift index 3ec74867..9c067405 100644 --- a/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift +++ b/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift @@ -375,16 +375,16 @@ struct VLCPlayerOverlayView: View { ValueSlider(value: $viewModel.sliderPercentage, onEditingChanged: { editing in viewModel.sliderIsScrubbing = editing }) - .valueSliderStyle(HorizontalValueSliderStyle(track: - HorizontalValueTrack(view: - Capsule().foregroundColor(.purple)) - .background(Capsule().foregroundColor(Color.gray.opacity(0.25))) - .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) + .valueSliderStyle(HorizontalValueSliderStyle(track: + HorizontalValueTrack(view: + Capsule().foregroundColor(.purple)) + .background(Capsule().foregroundColor(Color.gray.opacity(0.25))) + .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)) From 456492170120776212b8a579596db2d4eda5468b Mon Sep 17 00:00:00 2001 From: jhays Date: Tue, 3 May 2022 22:42:15 -0500 Subject: [PATCH 10/18] fix format --- .../CinematicNextUpCardView.swift | 4 ++-- .../CinematicResumeCardView.swift | 4 ++-- .../Overlays/tvOSLiveTVOverlay.swift | 12 +++++------ .../VideoPlayer/Overlays/tvOSVLCOverlay.swift | 12 +++++------ Swiftfin/Components/PortraitHStackView.swift | 6 +++--- Swiftfin/Components/PortraitItemButton.swift | 6 +++--- Swiftfin/Views/ContinueWatchingView.swift | 4 ++-- Swiftfin/Views/ItemView/ItemViewBody.swift | 2 +- .../Overlays/VLCPlayerOverlayView.swift | 20 +++++++++---------- 9 files changed, 35 insertions(+), 35 deletions(-) diff --git a/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift b/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift index fa95e274..3cab0897 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/CinematicNextUpCardView.swift @@ -28,13 +28,13 @@ struct CinematicNextUpCardView: View { item.getSeriesThumbImage(maxWidth: 350), item.getSeriesBackdropImage(maxWidth: 350), ]) - .frame(width: 350, height: 210) + .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) + .frame(width: 350, height: 210) } LinearGradient(colors: [.clear, .black], diff --git a/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift b/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift index 8aa188c5..52b3cf88 100644 --- a/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift +++ b/Swiftfin tvOS/Components/HomeCinematicView/CinematicResumeCardView.swift @@ -29,13 +29,13 @@ struct CinematicResumeCardView: View { item.getSeriesThumbImage(maxWidth: 350), item.getSeriesBackdropImage(maxWidth: 350), ]) - .frame(width: 350, height: 210) + .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) + .frame(width: 350, height: 210) } LinearGradient(colors: [.clear, .black], diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift index 1a0f3814..dace960b 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSLiveTVOverlay.swift @@ -62,18 +62,18 @@ struct tvOSLiveTVOverlay: View { SFSymbolButton(systemName: "chevron.left.circle", action: { viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() }) - .frame(maxWidth: 30, maxHeight: 30) - .disabled(viewModel.previousItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + .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) + .frame(maxWidth: 30, maxHeight: 30) + .disabled(viewModel.nextItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) } if viewModel.shouldShowAutoPlay { diff --git a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift index 20db21e6..aa480d1a 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/Overlays/tvOSVLCOverlay.swift @@ -62,18 +62,18 @@ struct tvOSVLCOverlay: View { SFSymbolButton(systemName: "chevron.left.circle", action: { viewModel.playerOverlayDelegate?.didSelectPlayPreviousItem() }) - .frame(maxWidth: 30, maxHeight: 30) - .disabled(viewModel.previousItemVideoPlayerViewModel == nil) - .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) + .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) + .frame(maxWidth: 30, maxHeight: 30) + .disabled(viewModel.nextItemVideoPlayerViewModel == nil) + .foregroundColor(viewModel.nextItemVideoPlayerViewModel == nil ? .gray : .white) } if viewModel.shouldShowAutoPlay { diff --git a/Swiftfin/Components/PortraitHStackView.swift b/Swiftfin/Components/PortraitHStackView.swift index a9aa57c0..79261be4 100644 --- a/Swiftfin/Components/PortraitHStackView.swift +++ b/Swiftfin/Components/PortraitHStackView.swift @@ -48,9 +48,9 @@ struct PortraitImageHStackView: View { failureView: { InitialFailureView(item.failureInitials) }) - .portraitPoster(width: maxWidth) - .shadow(radius: 4, y: 2) - .accessibilityIgnoresInvertColors() + .portraitPoster(width: maxWidth) + .shadow(radius: 4, y: 2) + .accessibilityIgnoresInvertColors() if item.showTitle { Text(item.title) diff --git a/Swiftfin/Views/ContinueWatchingView.swift b/Swiftfin/Views/ContinueWatchingView.swift index e4f55910..505e187f 100644 --- a/Swiftfin/Views/ContinueWatchingView.swift +++ b/Swiftfin/Views/ContinueWatchingView.swift @@ -33,13 +33,13 @@ struct ContinueWatchingView: View { item.getSeriesThumbImage(maxWidth: 320), item.getSeriesBackdropImage(maxWidth: 320), ]) - .frame(width: 320, height: 180) + .frame(width: 320, height: 180) } else { ImageView(sources: [ item.getThumbImage(maxWidth: 320), item.getBackdropImage(maxWidth: 320), ]) - .frame(width: 320, height: 180) + .frame(width: 320, height: 180) } } .accessibilityIgnoresInvertColors() diff --git a/Swiftfin/Views/ItemView/ItemViewBody.swift b/Swiftfin/Views/ItemView/ItemViewBody.swift index 55dfc8a5..5e9b959d 100644 --- a/Swiftfin/Views/ItemView/ItemViewBody.swift +++ b/Swiftfin/Views/ItemView/ItemViewBody.swift @@ -69,7 +69,7 @@ struct ItemViewBody: View { selectedAction: { genre in itemRouter.route(to: \.library, (viewModel: .init(genre: genre), title: genre.title)) }) - .padding(.bottom) + .padding(.bottom) } // MARK: Studios diff --git a/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift b/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift index 9c067405..3ec74867 100644 --- a/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift +++ b/Swiftfin/Views/VideoPlayer/Overlays/VLCPlayerOverlayView.swift @@ -375,16 +375,16 @@ struct VLCPlayerOverlayView: View { ValueSlider(value: $viewModel.sliderPercentage, onEditingChanged: { editing in viewModel.sliderIsScrubbing = editing }) - .valueSliderStyle(HorizontalValueSliderStyle(track: - HorizontalValueTrack(view: - Capsule().foregroundColor(.purple)) - .background(Capsule().foregroundColor(Color.gray.opacity(0.25))) - .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) + .valueSliderStyle(HorizontalValueSliderStyle(track: + HorizontalValueTrack(view: + Capsule().foregroundColor(.purple)) + .background(Capsule().foregroundColor(Color.gray.opacity(0.25))) + .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)) From 0d6b9acb79fe1c389affdba1c0cffebb2a8fb94a Mon Sep 17 00:00:00 2001 From: jhays Date: Thu, 26 May 2022 08:21:15 -0500 Subject: [PATCH 11/18] clean up iOS LiveTVProgramsView --- Swiftfin/Views/LiveTVProgramsView.swift | 252 +++++++++--------------- 1 file changed, 90 insertions(+), 162 deletions(-) diff --git a/Swiftfin/Views/LiveTVProgramsView.swift b/Swiftfin/Views/LiveTVProgramsView.swift index 31dcdab2..da46bd49 100644 --- a/Swiftfin/Views/LiveTVProgramsView.swift +++ b/Swiftfin/Views/LiveTVProgramsView.swift @@ -21,188 +21,116 @@ struct LiveTVProgramsView: View { 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: { - #if os(iOS) - #elseif os(tvOS) - LandscapeItemElement(item: item) - #endif - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) + 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 { - 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: { - #if os(iOS) - #elseif os(tvOS) - LandscapeItemElement(item: item) - #endif - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) + 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 { - 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: { - #if os(iOS) - #elseif os(tvOS) - LandscapeItemElement(item: item) - #endif - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) + 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 { - 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: { - #if os(iOS) - #elseif os(tvOS) - LandscapeItemElement(item: item) - #endif - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) + 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 { - 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: { - #if os(iOS) - #elseif os(tvOS) - LandscapeItemElement(item: item) - #endif - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) + 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 { - 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: { - #if os(iOS) - #elseif os(tvOS) - LandscapeItemElement(item: item) - #endif - } - .buttonStyle(PlainNavigationLinkButtonStyle()) - } - Spacer().frame(width: 45) - } - }.frame(height: 350) + 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) + } + } + } } } } From bc8f5e95d96ace590b06364e9e9e3ecc3273af0f Mon Sep 17 00:00:00 2001 From: jhays Date: Thu, 26 May 2022 09:26:10 -0500 Subject: [PATCH 12/18] BaseItemDto ItemType usage --- .../BaseItemDtoExtensions.swift | 18 +++- Swiftfin tvOS/Views/LibraryListView.swift | 97 ++++++++----------- Swiftfin/Views/LibraryListView.swift | 13 ++- 3 files changed, 67 insertions(+), 61 deletions(-) diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift index 3564c841..13ae8c13 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift @@ -236,6 +236,7 @@ public extension BaseItemDto { case boxset = "BoxSet" case collectionFolder = "CollectionFolder" case folder = "Folder" + case liveTV = "LiveTV" case unknown @@ -247,6 +248,21 @@ public extension BaseItemDto { 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 + } + } } var itemType: ItemType { @@ -258,7 +274,7 @@ public extension BaseItemDto { func portraitHeaderViewURL(maxWidth: Int) -> URL { switch itemType { - case .movie, .season, .series, .boxset, .collectionFolder, .folder: + case .movie, .season, .series, .boxset, .collectionFolder, .folder, .liveTV: return getPrimaryImage(maxWidth: maxWidth) case .episode: return getSeriesPrimaryImage(maxWidth: maxWidth) diff --git a/Swiftfin tvOS/Views/LibraryListView.swift b/Swiftfin tvOS/Views/LibraryListView.swift index 4760e74e..d6cd299e 100644 --- a/Swiftfin tvOS/Views/LibraryListView.swift +++ b/Swiftfin tvOS/Views/LibraryListView.swift @@ -8,7 +8,9 @@ import Defaults import Foundation +import Stinsen import SwiftUI +import JellyfinAPI struct LibraryListView: View { @EnvironmentObject @@ -20,8 +22,14 @@ struct LibraryListView: View { @Default(.Experimental.liveTVAlphaEnabled) var liveTVAlphaEnabled - - let supportedCollectionTypes = ["movies", "tvshows", "boxsets", "livetv", "other"] + + 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 { @@ -29,60 +37,39 @@ struct LibraryListView: View { if !viewModel.isLoading { ForEach(viewModel.libraries.filter { [self] library in - let collectionType = library.collectionType ?? "other" - return self.supportedCollectionTypes.contains(collectionType) + let collectionType = library.collectionType ?? "other" + let itemType = BaseItemDto.ItemType(rawValue: collectionType) ?? .unknown + return self.supportedCollectionTypes.contains(itemType) }, id: \.id) { library in - if library.collectionType == "livetv" { - if liveTVAlphaEnabled { - Button { - self.mainCoordinator.root(\.liveTV) - } - 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 { - Button { - 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) - } - } + 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() } diff --git a/Swiftfin/Views/LibraryListView.swift b/Swiftfin/Views/LibraryListView.swift index 3f686477..48c4553a 100644 --- a/Swiftfin/Views/LibraryListView.swift +++ b/Swiftfin/Views/LibraryListView.swift @@ -10,6 +10,7 @@ import Defaults import Foundation import Stinsen import SwiftUI +import JellyfinAPI struct LibraryListView: View { @EnvironmentObject @@ -20,11 +21,11 @@ struct LibraryListView: View { @Default(.Experimental.liveTVAlphaEnabled) var liveTVAlphaEnabled - var supportedCollectionTypes: [String] { + var supportedCollectionTypes: [BaseItemDto.ItemType] { if liveTVAlphaEnabled { - return ["movies", "tvshows", "livetv", "boxsets", "other"] + return [.movie, .season, .series, .liveTV, .boxset, .unknown] } else { - return ["movies", "tvshows", "boxsets", "other"] + return [.movie, .season, .series, .boxset, .unknown] } } @@ -56,10 +57,12 @@ struct LibraryListView: View { if !viewModel.isLoading { ForEach(viewModel.libraries.filter { [self] library in let collectionType = library.collectionType ?? "other" - return self.supportedCollectionTypes.contains(collectionType) + let itemType = BaseItemDto.ItemType(rawValue: collectionType) ?? .unknown + return self.supportedCollectionTypes.contains(itemType) }, id: \.id) { library in Button { - if library.collectionType == "livetv" { + let itemType = BaseItemDto.ItemType(rawValue: library.collectionType ?? "other") ?? .unknown + if itemType == .liveTV { libraryListRouter.route(to: \.liveTV) } else { libraryListRouter.route(to: \.library, From aba2e48072eb2de1d9a9a7e19398eaa79ede6d71 Mon Sep 17 00:00:00 2001 From: jhays Date: Mon, 6 Jun 2022 15:24:35 -0500 Subject: [PATCH 13/18] ASCollectionView for iOS Live TV channnels --- Swiftfin.xcodeproj/project.pbxproj | 17 +++ .../xcshareddata/swiftpm/Package.resolved | 18 +++ Swiftfin/Views/LiveTVChannelsView.swift | 105 ++++++------------ 3 files changed, 67 insertions(+), 73 deletions(-) diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index a48a49af..85610cd8 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -286,6 +286,7 @@ C4BE07892728448B003F4AD1 /* LiveTVChannelsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */; }; C4BE078C272844AF003F4AD1 /* LiveTVChannelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */; }; C4BE078E27298818003F4AD1 /* LiveTVHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */; }; + C4D0CE4B2848570700345D11 /* ASCollectionView in Frameworks */ = {isa = PBXBuildFile; productRef = C4D0CE4A2848570700345D11 /* ASCollectionView */; }; C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E508172703E8190045C9AB /* LibraryListView.swift */; }; C4E5081D2703F8370045C9AB /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */; }; C4E5598928124C10003DECA5 /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */; }; @@ -945,6 +946,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C4D0CE4B2848570700345D11 /* ASCollectionView in Frameworks */, 62666E3E27E503FA00EC0ECD /* MediaAccessibility.framework in Frameworks */, 62666DFF27E5016400EC0ECD /* CFNetwork.framework in Frameworks */, E13DD3D327168E65009D4DAF /* Defaults in Frameworks */, @@ -1960,6 +1962,7 @@ E1002B672793CFBA00E47059 /* Algorithms */, 62666E3827E502CE00EC0ECD /* SwizzleSwift */, E1101176281B1E8A006A3584 /* Puppy */, + C4D0CE4A2848570700345D11 /* ASCollectionView */, ); productName = JellyfinPlayer; productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */; @@ -2054,6 +2057,7 @@ E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */, 62666E3727E502CE00EC0ECD /* XCRemoteSwiftPackageReference "SwizzleSwift" */, E1101175281B1E8A006A3584 /* XCRemoteSwiftPackageReference "Puppy" */, + C4D0CE492848570700345D11 /* XCRemoteSwiftPackageReference "ASCollectionView" */, ); productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; projectDirPath = ""; @@ -3117,6 +3121,14 @@ kind = branch; }; }; + C4D0CE492848570700345D11 /* XCRemoteSwiftPackageReference "ASCollectionView" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apptekstudios/ASCollectionView"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-algorithms.git"; @@ -3253,6 +3265,11 @@ package = 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */; productName = Stinsen; }; + C4D0CE4A2848570700345D11 /* ASCollectionView */ = { + isa = XCSwiftPackageProductDependency; + package = C4D0CE492848570700345D11 /* XCRemoteSwiftPackageReference "ASCollectionView" */; + productName = ASCollectionView; + }; E1002B672793CFBA00E47059 /* Algorithms */ = { isa = XCSwiftPackageProductDependency; package = E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */; diff --git a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b8168ffe..71388939 100644 --- a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -18,6 +18,15 @@ "version" : "0.6.4" } }, + { + "identity" : "ascollectionview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apptekstudios/ASCollectionView", + "state" : { + "revision" : "4288744ba484c1062c109c0f28d72b629d321d55", + "version" : "2.1.1" + } + }, { "identity" : "combineext", "kind" : "remoteSourceControl", @@ -45,6 +54,15 @@ "version" : "6.2.1" } }, + { + "identity" : "differencekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ra1028/DifferenceKit", + "state" : { + "revision" : "62745d7780deef4a023a792a1f8f763ec7bf9705", + "version" : "1.2.0" + } + }, { "identity" : "gifu", "kind" : "remoteSourceControl", diff --git a/Swiftfin/Views/LiveTVChannelsView.swift b/Swiftfin/Views/LiveTVChannelsView.swift index 6b56f97b..241c374d 100644 --- a/Swiftfin/Views/LiveTVChannelsView.swift +++ b/Swiftfin/Views/LiveTVChannelsView.swift @@ -6,6 +6,7 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // +import ASCollectionView import Foundation import JellyfinAPI import SwiftUI @@ -20,19 +21,34 @@ struct LiveTVChannelsView: View { 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.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)") - } + } 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 { @@ -58,22 +74,21 @@ struct LiveTVChannelsView: View { } @ViewBuilder - func makeCellView(indexPath: IndexPath, cell: LiveTVChannelRowCell) -> some View { - let item = cell.item - let channel = item.channel - let currentProgramDisplayText = item.currentProgram? + func makeCellView(_ channelProgram: LiveTVChannelProgram) -> some View { + let channel = channelProgram.channel + let currentProgramDisplayText = channelProgram.currentProgram? .programDisplayText(timeFormatter: viewModel.timeFormatter) ?? LiveTVChannelViewProgram(timeDisplay: "", title: "") - let nextItems = item.programs.filter { program in + let nextItems = channelProgram.programs.filter { program in guard let start = program.startDate else { return false } - guard let currentStart = item.currentProgram?.startDate else { + guard let currentStart = channelProgram.currentProgram?.startDate else { return false } return start > currentStart } LiveTVChannelItemWideElement(channel: channel, - currentProgram: item.currentProgram, + currentProgram: channelProgram.currentProgram, currentProgramText: currentProgramDisplayText, nextProgramsText: nextProgramsDisplayText(nextItems: nextItems, timeFormatter: viewModel.timeFormatter), @@ -88,62 +103,6 @@ struct LiveTVChannelsView: View { }) } - private func createGridLayout() -> NSCollectionLayoutSection { - if UIDevice.current.userInterfaceIdiom == .pad { - let itemSize = NSCollectionLayoutSize(widthDimension: .absolute((UIScreen.main.bounds.width / 2) - 16), - heightDimension: .fractionalHeight(1)) - let item = NSCollectionLayoutItem(layoutSize: itemSize) - item.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .flexible(0), top: nil, - trailing: .flexible(2), bottom: .flexible(2)) - let item2 = NSCollectionLayoutItem(layoutSize: itemSize) - item2.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: nil, top: nil, - trailing: .flexible(0), bottom: .flexible(2)) - let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), - heightDimension: .absolute(144)) - let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, - subitems: [item, item2]) - let section = NSCollectionLayoutSection(group: group) - return section - } else { - if isPortrait { - let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(UIScreen.main.bounds.width - 32), - heightDimension: .fractionalHeight(1)) - let item = NSCollectionLayoutItem(layoutSize: itemSize) - item.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .flexible(0), top: nil, - trailing: .flexible(2), bottom: .flexible(2)) - let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), - heightDimension: .absolute(144)) - let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, - subitems: [item]) - let section = NSCollectionLayoutSection(group: group) - return section - } else { - - let scenes = UIApplication.shared.connectedScenes - let windowScene = scenes.first as? UIWindowScene - var width = (UIScreen.main.bounds.width / 2) - 32 - if let safeArea = windowScene?.keyWindow?.safeAreaInsets { - width = (UIScreen.main.bounds.width / 2) - safeArea.left - safeArea.right - } - - let itemSize = NSCollectionLayoutSize(widthDimension: .absolute(width), - heightDimension: .fractionalHeight(1)) - let item = NSCollectionLayoutItem(layoutSize: itemSize) - item.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: .flexible(0), top: nil, - trailing: .flexible(2), bottom: .flexible(2)) - let item2 = NSCollectionLayoutItem(layoutSize: itemSize) - item2.edgeSpacing = NSCollectionLayoutEdgeSpacing(leading: nil, top: nil, - trailing: .flexible(0), bottom: .flexible(2)) - let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), - heightDimension: .absolute(144)) - let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, - subitems: [item, item2]) - let section = NSCollectionLayoutSection(group: group) - return section - } - } - } - private func checkOrientation() { let scenes = UIApplication.shared.connectedScenes let windowScene = scenes.first as? UIWindowScene From f2920363ec3e5b68dae8ca93d3c4235dca81530c Mon Sep 17 00:00:00 2001 From: jhays Date: Mon, 6 Jun 2022 15:25:06 -0500 Subject: [PATCH 14/18] Swiftformat --- .../BaseItemDtoExtensions.swift | 34 ++-- Swiftfin tvOS/Views/LibraryListView.swift | 80 ++++---- Swiftfin/Views/LibraryListView.swift | 14 +- Swiftfin/Views/LiveTVChannelsView.swift | 47 +++-- Swiftfin/Views/LiveTVProgramsView.swift | 180 +++++++++--------- 5 files changed, 176 insertions(+), 179 deletions(-) diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift index 13ae8c13..4e5c2308 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift @@ -236,7 +236,7 @@ public extension BaseItemDto { case boxset = "BoxSet" case collectionFolder = "CollectionFolder" case folder = "Folder" - case liveTV = "LiveTV" + case liveTV = "LiveTV" case unknown @@ -248,21 +248,21 @@ public extension BaseItemDto { 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 { @@ -274,7 +274,7 @@ public extension BaseItemDto { func portraitHeaderViewURL(maxWidth: Int) -> URL { switch itemType { - case .movie, .season, .series, .boxset, .collectionFolder, .folder, .liveTV: + case .movie, .season, .series, .boxset, .collectionFolder, .folder, .liveTV: return getPrimaryImage(maxWidth: maxWidth) case .episode: return getSeriesPrimaryImage(maxWidth: maxWidth) diff --git a/Swiftfin tvOS/Views/LibraryListView.swift b/Swiftfin tvOS/Views/LibraryListView.swift index d6cd299e..67e4e44c 100644 --- a/Swiftfin tvOS/Views/LibraryListView.swift +++ b/Swiftfin tvOS/Views/LibraryListView.swift @@ -8,9 +8,9 @@ import Defaults import Foundation +import JellyfinAPI import Stinsen import SwiftUI -import JellyfinAPI struct LibraryListView: View { @EnvironmentObject @@ -22,14 +22,14 @@ struct LibraryListView: View { @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 { @@ -37,39 +37,39 @@ struct LibraryListView: View { 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) + 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 ?? "")) - } - } + 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) - } + 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() } diff --git a/Swiftfin/Views/LibraryListView.swift b/Swiftfin/Views/LibraryListView.swift index 48c4553a..06ae5a5f 100644 --- a/Swiftfin/Views/LibraryListView.swift +++ b/Swiftfin/Views/LibraryListView.swift @@ -8,9 +8,9 @@ import Defaults import Foundation +import JellyfinAPI import Stinsen import SwiftUI -import JellyfinAPI struct LibraryListView: View { @EnvironmentObject @@ -21,9 +21,9 @@ struct LibraryListView: View { @Default(.Experimental.liveTVAlphaEnabled) var liveTVAlphaEnabled - var supportedCollectionTypes: [BaseItemDto.ItemType] { + var supportedCollectionTypes: [BaseItemDto.ItemType] { if liveTVAlphaEnabled { - return [.movie, .season, .series, .liveTV, .boxset, .unknown] + return [.movie, .season, .series, .liveTV, .boxset, .unknown] } else { return [.movie, .season, .series, .boxset, .unknown] } @@ -57,12 +57,12 @@ struct LibraryListView: View { 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) + 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 { + let itemType = BaseItemDto.ItemType(rawValue: library.collectionType ?? "other") ?? .unknown + if itemType == .liveTV { libraryListRouter.route(to: \.liveTV) } else { libraryListRouter.route(to: \.library, diff --git a/Swiftfin/Views/LiveTVChannelsView.swift b/Swiftfin/Views/LiveTVChannelsView.swift index 241c374d..f0840687 100644 --- a/Swiftfin/Views/LiveTVChannelsView.swift +++ b/Swiftfin/Views/LiveTVChannelsView.swift @@ -21,34 +21,31 @@ struct LiveTVChannelsView: View { 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 - } - } - } - + 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)) - } + 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 { @@ -75,7 +72,7 @@ struct LiveTVChannelsView: View { @ViewBuilder func makeCellView(_ channelProgram: LiveTVChannelProgram) -> some View { - let channel = channelProgram.channel + let channel = channelProgram.channel let currentProgramDisplayText = channelProgram.currentProgram? .programDisplayText(timeFormatter: viewModel.timeFormatter) ?? LiveTVChannelViewProgram(timeDisplay: "", title: "") let nextItems = channelProgram.programs.filter { program in diff --git a/Swiftfin/Views/LiveTVProgramsView.swift b/Swiftfin/Views/LiveTVProgramsView.swift index da46bd49..97329c9c 100644 --- a/Swiftfin/Views/LiveTVProgramsView.swift +++ b/Swiftfin/Views/LiveTVProgramsView.swift @@ -21,116 +21,116 @@ struct LiveTVProgramsView: View { 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) - } - } - } + 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) - } - } - } + 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) - } - } - } + 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) - } - } - } + 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) - } - } - } + 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) - } - } - } + 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) + } + } + } } } } From 54439de06fadd5814f25c6aeefc6e55631bd9013 Mon Sep 17 00:00:00 2001 From: jhays Date: Mon, 6 Jun 2022 16:27:41 -0500 Subject: [PATCH 15/18] swap SwiftUICollection package used by tvOS --- Swiftfin.xcodeproj/project.pbxproj | 30 +++++++------------ .../xcshareddata/swiftpm/Package.resolved | 4 +-- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 85610cd8..d98825a6 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -252,6 +252,7 @@ C400DB6A27FE894F007B65FE /* LiveTVChannelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */; }; C400DB6B27FE8C97007B65FE /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */; }; C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */; }; + C409CE9C284EA6EA00CABC12 /* SwiftUICollection in Frameworks */ = {isa = PBXBuildFile; productRef = C409CE9B284EA6EA00CABC12 /* SwiftUICollection */; }; C40CD923271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD921271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift */; }; C40CD926271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */; }; C40CD929271F8DAB000FB198 /* MovieLibrariesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD927271F8DAB000FB198 /* MovieLibrariesView.swift */; }; @@ -436,8 +437,6 @@ E1A2C15D279A7D9F005EC829 /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C15B279A7D9F005EC829 /* AppIcon.swift */; }; E1A2C15E279A7D9F005EC829 /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C15B279A7D9F005EC829 /* AppIcon.swift */; }; E1A2C160279A7DCA005EC829 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C15F279A7DCA005EC829 /* AboutView.swift */; }; - E1A99999271A3429008E78C0 /* SwiftUICollection in Frameworks */ = {isa = PBXBuildFile; productRef = E1A99998271A3429008E78C0 /* SwiftUICollection */; }; - E1A9999B271A343C008E78C0 /* SwiftUICollection in Frameworks */ = {isa = PBXBuildFile; productRef = E1A9999A271A343C008E78C0 /* SwiftUICollection */; }; E1AA331D2782541500F6439C /* PrimaryButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331C2782541500F6439C /* PrimaryButtonView.swift */; }; E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331E2782639D00F6439C /* OverlayType.swift */; }; E1AA33202782639D00F6439C /* OverlayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331E2782639D00F6439C /* OverlayType.swift */; }; @@ -912,6 +911,7 @@ files = ( 62666E1727E501CC00EC0ECD /* CFNetwork.framework in Frameworks */, E11D83AF278FA998006E9776 /* NukeUI in Frameworks */, + C409CE9C284EA6EA00CABC12 /* SwiftUICollection in Frameworks */, 62666DFA27E5013700EC0ECD /* TVVLCKit.xcframework in Frameworks */, 62666E3227E5021E00EC0ECD /* UIKit.framework in Frameworks */, E1218C9E271A2CD600EA0737 /* CombineExt in Frameworks */, @@ -929,7 +929,6 @@ 62666E1927E501D000EC0ECD /* CoreFoundation.framework in Frameworks */, 62666E2E27E5021400EC0ECD /* Security.framework in Frameworks */, 53ABFDDC267972BF00886593 /* TVServices.framework in Frameworks */, - E1A9999B271A343C008E78C0 /* SwiftUICollection in Frameworks */, 62666E1F27E501DF00EC0ECD /* CoreText.framework in Frameworks */, E13DD3CD27164CA7009D4DAF /* CoreStore in Frameworks */, 62666E1527E501C800EC0ECD /* AVFoundation.framework in Frameworks */, @@ -963,7 +962,6 @@ 62666E1027E501B400EC0ECD /* VideoToolbox.framework in Frameworks */, 62666E3C27E503F200EC0ECD /* GoogleCastSDK.xcframework in Frameworks */, 62666E0C27E501A500EC0ECD /* OpenGLES.framework in Frameworks */, - E1A99999271A3429008E78C0 /* SwiftUICollection in Frameworks */, 62666E0127E5016900EC0ECD /* CoreFoundation.framework in Frameworks */, E1B6DCEA271A23880015B715 /* SwiftyJSON in Frameworks */, 62666E2427E501F300EC0ECD /* Foundation.framework in Frameworks */, @@ -1917,12 +1915,12 @@ E13DD3CC27164CA7009D4DAF /* CoreStore */, E12186DD2718F1C50010884C /* Defaults */, E1218C9D271A2CD600EA0737 /* CombineExt */, - E1A9999A271A343C008E78C0 /* SwiftUICollection */, E178857C278037FD0094FBCF /* JellyfinAPI */, E1AE8E7D2789136D00FBDDAA /* Nuke */, E11D83AE278FA998006E9776 /* NukeUI */, E1002B6A2793E36600E47059 /* Algorithms */, E1347DB5279E3CA500BC6161 /* Puppy */, + C409CE9B284EA6EA00CABC12 /* SwiftUICollection */, ); productName = "JellyfinPlayer tvOS"; productReference = 535870602669D21600D05A09 /* Swiftfin tvOS.app */; @@ -1954,7 +1952,6 @@ E13DD3D227168E65009D4DAF /* Defaults */, E1B6DCE7271A23780015B715 /* CombineExt */, E1B6DCE9271A23880015B715 /* SwiftyJSON */, - E1A99998271A3429008E78C0 /* SwiftUICollection */, E10EAA44277BB646000269ED /* JellyfinAPI */, E10EAA4C277BB716000269ED /* Sliders */, E1AE8E7B2789135A00FBDDAA /* Nuke */, @@ -2049,7 +2046,6 @@ E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */, E1267D42271A212C003C492E /* XCRemoteSwiftPackageReference "CombineExt" */, E1C16B89271A2180009A5D25 /* XCRemoteSwiftPackageReference "SwiftyJSON" */, - C4BFD4E327167B63007739E3 /* XCRemoteSwiftPackageReference "SwiftUICollection" */, E10EAA43277BB646000269ED /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */, E10EAA4B277BB716000269ED /* XCRemoteSwiftPackageReference "swiftui-sliders" */, E1AE8E7A2789135A00FBDDAA /* XCRemoteSwiftPackageReference "Nuke" */, @@ -2058,6 +2054,7 @@ 62666E3727E502CE00EC0ECD /* XCRemoteSwiftPackageReference "SwizzleSwift" */, E1101175281B1E8A006A3584 /* XCRemoteSwiftPackageReference "Puppy" */, C4D0CE492848570700345D11 /* XCRemoteSwiftPackageReference "ASCollectionView" */, + C409CE9A284EA6EA00CABC12 /* XCRemoteSwiftPackageReference "SwiftUICollection" */, ); productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; projectDirPath = ""; @@ -3113,9 +3110,9 @@ minimumVersion = 2.0.2; }; }; - C4BFD4E327167B63007739E3 /* XCRemoteSwiftPackageReference "SwiftUICollection" */ = { + C409CE9A284EA6EA00CABC12 /* XCRemoteSwiftPackageReference "SwiftUICollection" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/ABJC/SwiftUICollection"; + repositoryURL = "https://github.com/defagos/SwiftUICollection"; requirement = { branch = master; kind = branch; @@ -3265,6 +3262,11 @@ package = 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */; productName = Stinsen; }; + C409CE9B284EA6EA00CABC12 /* SwiftUICollection */ = { + isa = XCSwiftPackageProductDependency; + package = C409CE9A284EA6EA00CABC12 /* XCRemoteSwiftPackageReference "SwiftUICollection" */; + productName = SwiftUICollection; + }; C4D0CE4A2848570700345D11 /* ASCollectionView */ = { isa = XCSwiftPackageProductDependency; package = C4D0CE492848570700345D11 /* XCRemoteSwiftPackageReference "ASCollectionView" */; @@ -3365,16 +3367,6 @@ package = E10EAA43277BB646000269ED /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; productName = JellyfinAPI; }; - E1A99998271A3429008E78C0 /* SwiftUICollection */ = { - isa = XCSwiftPackageProductDependency; - package = C4BFD4E327167B63007739E3 /* XCRemoteSwiftPackageReference "SwiftUICollection" */; - productName = SwiftUICollection; - }; - E1A9999A271A343C008E78C0 /* SwiftUICollection */ = { - isa = XCSwiftPackageProductDependency; - package = C4BFD4E327167B63007739E3 /* XCRemoteSwiftPackageReference "SwiftUICollection" */; - productName = SwiftUICollection; - }; E1AE8E7B2789135A00FBDDAA /* Nuke */ = { isa = XCSwiftPackageProductDependency; package = E1AE8E7A2789135A00FBDDAA /* XCRemoteSwiftPackageReference "Nuke" */; diff --git a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 71388939..d0bf750d 100644 --- a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -174,10 +174,10 @@ { "identity" : "swiftuicollection", "kind" : "remoteSourceControl", - "location" : "https://github.com/ABJC/SwiftUICollection", + "location" : "https://github.com/defagos/SwiftUICollection", "state" : { "branch" : "master", - "revision" : "e27149382ce8ec21995069c8aab7ca83d61a3120" + "revision" : "5b9f14eb3ec5d48cec8b3e4462dcc554d4bff2a8" } }, { From b049572a89d302760c7669bd95180ce18123b54b Mon Sep 17 00:00:00 2001 From: jhays Date: Tue, 7 Jun 2022 21:19:22 -0500 Subject: [PATCH 16/18] fix library progress view, increase tvOS page size --- Shared/ViewModels/LibraryViewModel.swift | 6 +++++- Swiftfin tvOS/Views/LibraryView.swift | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Shared/ViewModels/LibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel.swift index 0a9bd1db..ca88cd82 100644 --- a/Shared/ViewModels/LibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel.swift @@ -188,6 +188,10 @@ extension UIScreen { let screenSize = UIScreen.main.bounds.height * UIScreen.main.bounds.width let itemSize = width * height - return Int(screenSize / itemSize) + #if os(tvOS) + return Int(screenSize / itemSize) * 2 + #else + return Int(screenSize / itemSize) + #endif } } diff --git a/Swiftfin tvOS/Views/LibraryView.swift b/Swiftfin tvOS/Views/LibraryView.swift index 5c1f397b..5e42872f 100644 --- a/Swiftfin tvOS/Views/LibraryView.swift +++ b/Swiftfin tvOS/Views/LibraryView.swift @@ -27,7 +27,7 @@ struct LibraryView: View { var isShowingFilterView = false var body: some View { - if viewModel.isLoading == true { + if viewModel.rows.isEmpty && viewModel.isLoading == true { ProgressView() } else if !viewModel.rows.isEmpty { CollectionView(rows: viewModel.rows) { _, _ in From eb51237d545c1ae3ddd479a6a1a4c229b04ff934 Mon Sep 17 00:00:00 2001 From: jhays Date: Tue, 7 Jun 2022 21:38:57 -0500 Subject: [PATCH 17/18] fix single root library nav issue --- Shared/Coordinators/MoviesLibrariesCoordinator.swift | 6 ++++++ Shared/Coordinators/TVLibrariesCoordinator.swift | 6 ++++++ Shared/ViewModels/MovieLibrariesViewModel.swift | 4 ++-- Shared/ViewModels/TVLibrariesViewModel.swift | 4 ++-- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/Shared/Coordinators/MoviesLibrariesCoordinator.swift b/Shared/Coordinators/MoviesLibrariesCoordinator.swift index 15e14888..6e30392f 100644 --- a/Shared/Coordinators/MoviesLibrariesCoordinator.swift +++ b/Shared/Coordinators/MoviesLibrariesCoordinator.swift @@ -17,6 +17,8 @@ final class MovieLibrariesCoordinator: NavigationCoordinatable { @Root var start = makeStart + @Root + var rootLibrary = makeRootLibrary @Route(.push) var library = makeLibrary @@ -36,4 +38,8 @@ final class MovieLibrariesCoordinator: NavigationCoordinatable { 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) + } } diff --git a/Shared/Coordinators/TVLibrariesCoordinator.swift b/Shared/Coordinators/TVLibrariesCoordinator.swift index b2ec1121..4c6e63a7 100644 --- a/Shared/Coordinators/TVLibrariesCoordinator.swift +++ b/Shared/Coordinators/TVLibrariesCoordinator.swift @@ -17,6 +17,8 @@ final class TVLibrariesCoordinator: NavigationCoordinatable { @Root var start = makeStart + @Root + var rootLibrary = makeRootLibrary @Route(.push) var library = makeLibrary @@ -36,4 +38,8 @@ final class TVLibrariesCoordinator: NavigationCoordinatable { 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) + } } diff --git a/Shared/ViewModels/MovieLibrariesViewModel.swift b/Shared/ViewModels/MovieLibrariesViewModel.swift index 77f84536..4d6fc934 100644 --- a/Shared/ViewModels/MovieLibrariesViewModel.swift +++ b/Shared/ViewModels/MovieLibrariesViewModel.swift @@ -54,8 +54,8 @@ final class MovieLibrariesViewModel: ViewModel { } self.rows = self.calculateRows() if self.libraries.count == 1, let library = self.libraries.first { - // show library - self.router?.route(to: \.library, library) + // make this library the root of this stack + self.router?.coordinator.root(\.rootLibrary, library) } } }) diff --git a/Shared/ViewModels/TVLibrariesViewModel.swift b/Shared/ViewModels/TVLibrariesViewModel.swift index 4fc7c53c..aca00158 100644 --- a/Shared/ViewModels/TVLibrariesViewModel.swift +++ b/Shared/ViewModels/TVLibrariesViewModel.swift @@ -54,8 +54,8 @@ final class TVLibrariesViewModel: ViewModel { } self.rows = self.calculateRows() if self.libraries.count == 1, let library = self.libraries.first { - // show library - self.router?.route(to: \.library, library) + // make this library the root of this stack + self.router?.coordinator.root(\.rootLibrary, library) } } }) From 63be4c05f927d70c25118304abf86f30c9a3e18d Mon Sep 17 00:00:00 2001 From: jhays Date: Tue, 7 Jun 2022 21:46:35 -0500 Subject: [PATCH 18/18] SwiftUICollection still needed in iOS due to import on a shared file. --- Swiftfin.xcodeproj/project.pbxproj | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index d98825a6..02cd089e 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -253,6 +253,7 @@ C400DB6B27FE8C97007B65FE /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */; }; C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */; }; C409CE9C284EA6EA00CABC12 /* SwiftUICollection in Frameworks */ = {isa = PBXBuildFile; productRef = C409CE9B284EA6EA00CABC12 /* SwiftUICollection */; }; + C409CE9E285044C800CABC12 /* SwiftUICollection in Frameworks */ = {isa = PBXBuildFile; productRef = C409CE9D285044C800CABC12 /* SwiftUICollection */; }; C40CD923271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD921271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift */; }; C40CD926271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */; }; C40CD929271F8DAB000FB198 /* MovieLibrariesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD927271F8DAB000FB198 /* MovieLibrariesView.swift */; }; @@ -962,6 +963,7 @@ 62666E1027E501B400EC0ECD /* VideoToolbox.framework in Frameworks */, 62666E3C27E503F200EC0ECD /* GoogleCastSDK.xcframework in Frameworks */, 62666E0C27E501A500EC0ECD /* OpenGLES.framework in Frameworks */, + C409CE9E285044C800CABC12 /* SwiftUICollection in Frameworks */, 62666E0127E5016900EC0ECD /* CoreFoundation.framework in Frameworks */, E1B6DCEA271A23880015B715 /* SwiftyJSON in Frameworks */, 62666E2427E501F300EC0ECD /* Foundation.framework in Frameworks */, @@ -1960,6 +1962,7 @@ 62666E3827E502CE00EC0ECD /* SwizzleSwift */, E1101176281B1E8A006A3584 /* Puppy */, C4D0CE4A2848570700345D11 /* ASCollectionView */, + C409CE9D285044C800CABC12 /* SwiftUICollection */, ); productName = JellyfinPlayer; productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */; @@ -3267,6 +3270,11 @@ package = C409CE9A284EA6EA00CABC12 /* XCRemoteSwiftPackageReference "SwiftUICollection" */; productName = SwiftUICollection; }; + C409CE9D285044C800CABC12 /* SwiftUICollection */ = { + isa = XCSwiftPackageProductDependency; + package = C409CE9A284EA6EA00CABC12 /* XCRemoteSwiftPackageReference "SwiftUICollection" */; + productName = SwiftUICollection; + }; C4D0CE4A2848570700345D11 /* ASCollectionView */ = { isa = XCSwiftPackageProductDependency; package = C4D0CE492848570700345D11 /* XCRemoteSwiftPackageReference "ASCollectionView" */;