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 + } +}