From 4dac5dd0b9153fca9fcc20321b1d7751f3945c18 Mon Sep 17 00:00:00 2001 From: jhays Date: Thu, 31 Mar 2022 21:37:57 -0500 Subject: [PATCH] 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 + } +} +