diff --git a/Shared/Coordinators/LibraryListCoordinator.swift b/Shared/Coordinators/LibraryListCoordinator.swift index a413ff83..5ac1ad91 100644 --- a/Shared/Coordinators/LibraryListCoordinator.swift +++ b/Shared/Coordinators/LibraryListCoordinator.swift @@ -20,6 +20,10 @@ final class LibraryListCoordinator: NavigationCoordinatable { var search = makeSearch @Route(.push) var library = makeLibrary + #if os(iOS) + @Route(.push) + var liveTV = makeLiveTV + #endif let viewModel: LibraryListViewModel @@ -35,6 +39,12 @@ final class LibraryListCoordinator: NavigationCoordinatable { SearchCoordinator(viewModel: viewModel) } + #if os(iOS) + func makeLiveTV() -> LiveTVCoordinator { + LiveTVCoordinator() + } + #endif + @ViewBuilder func makeStart() -> some View { LibraryListView(viewModel: self.viewModel) diff --git a/Shared/Coordinators/LiveTVCoordinator.swift b/Shared/Coordinators/LiveTVCoordinator.swift new file mode 100644 index 00000000..f3a80bcd --- /dev/null +++ b/Shared/Coordinators/LiveTVCoordinator.swift @@ -0,0 +1,30 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class LiveTVCoordinator: NavigationCoordinatable { + let stack = NavigationStack(initial: \LiveTVCoordinator.start) + + @Root + var start = makeStart + @Route(.fullScreen) + var videoPlayer = makeVideoPlayer + + @ViewBuilder + func makeStart() -> some View { + LiveTVChannelsView() + } + + func makeVideoPlayer(viewModel: VideoPlayerViewModel) -> NavigationViewCoordinator { + NavigationViewCoordinator(LiveTVVideoPlayerCoordinator(viewModel: viewModel)) + } +} diff --git a/Shared/Coordinators/MoviesLibrariesCoordinator.swift b/Shared/Coordinators/MoviesLibrariesCoordinator.swift index 15e14888..6e30392f 100644 --- a/Shared/Coordinators/MoviesLibrariesCoordinator.swift +++ b/Shared/Coordinators/MoviesLibrariesCoordinator.swift @@ -17,6 +17,8 @@ final class MovieLibrariesCoordinator: NavigationCoordinatable { @Root var start = makeStart + @Root + var rootLibrary = makeRootLibrary @Route(.push) var library = makeLibrary @@ -36,4 +38,8 @@ final class MovieLibrariesCoordinator: NavigationCoordinatable { func makeLibrary(library: BaseItemDto) -> LibraryCoordinator { LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title) } + + func makeRootLibrary(library: BaseItemDto) -> LibraryCoordinator { + LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title) + } } diff --git a/Shared/Coordinators/TVLibrariesCoordinator.swift b/Shared/Coordinators/TVLibrariesCoordinator.swift index b2ec1121..4c6e63a7 100644 --- a/Shared/Coordinators/TVLibrariesCoordinator.swift +++ b/Shared/Coordinators/TVLibrariesCoordinator.swift @@ -17,6 +17,8 @@ final class TVLibrariesCoordinator: NavigationCoordinatable { @Root var start = makeStart + @Root + var rootLibrary = makeRootLibrary @Route(.push) var library = makeLibrary @@ -36,4 +38,8 @@ final class TVLibrariesCoordinator: NavigationCoordinatable { func makeLibrary(library: BaseItemDto) -> LibraryCoordinator { LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title) } + + func makeRootLibrary(library: BaseItemDto) -> LibraryCoordinator { + LibraryCoordinator(viewModel: LibraryViewModel(parentID: library.id), title: library.title) + } } diff --git a/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift new file mode 100644 index 00000000..6c94547f --- /dev/null +++ b/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift @@ -0,0 +1,40 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Foundation +import JellyfinAPI +import Stinsen +import SwiftUI + +final class LiveTVVideoPlayerCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \LiveTVVideoPlayerCoordinator.start) + + @Root + var start = makeStart + + let viewModel: VideoPlayerViewModel + + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + } + + @ViewBuilder + func makeStart() -> some View { + if Defaults[.Experimental.liveTVNativePlayer] { + LiveTVNativePlayerView(viewModel: viewModel) + .navigationBarHidden(true) + .ignoresSafeArea() + } else { + LiveTVPlayerView(viewModel: viewModel) + .navigationBarHidden(true) + .ignoresSafeArea() + } + } +} diff --git a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift index 3564c841..4e5c2308 100644 --- a/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift +++ b/Shared/Extensions/JellyfinAPIExtensions/BaseItemDtoExtensions.swift @@ -236,6 +236,7 @@ public extension BaseItemDto { case boxset = "BoxSet" case collectionFolder = "CollectionFolder" case folder = "Folder" + case liveTV = "LiveTV" case unknown @@ -247,6 +248,21 @@ public extension BaseItemDto { return true } } + + public init?(rawValue: String) { + let lowerCase = rawValue.lowercased() + switch lowerCase { + case "movie": self = .movie + case "season": self = .season + case "episode": self = .episode + case "series": self = .series + case "boxset": self = .boxset + case "collectionfolder": self = .collectionFolder + case "folder": self = .folder + case "livetv": self = .liveTV + default: self = .unknown + } + } } var itemType: ItemType { @@ -258,7 +274,7 @@ public extension BaseItemDto { func portraitHeaderViewURL(maxWidth: Int) -> URL { switch itemType { - case .movie, .season, .series, .boxset, .collectionFolder, .folder: + case .movie, .season, .series, .boxset, .collectionFolder, .folder, .liveTV: return getPrimaryImage(maxWidth: maxWidth) case .episode: return getSeriesPrimaryImage(maxWidth: maxWidth) diff --git a/Shared/ViewModels/LibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel.swift index 0a9bd1db..ca88cd82 100644 --- a/Shared/ViewModels/LibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel.swift @@ -188,6 +188,10 @@ extension UIScreen { let screenSize = UIScreen.main.bounds.height * UIScreen.main.bounds.width let itemSize = width * height - return Int(screenSize / itemSize) + #if os(tvOS) + return Int(screenSize / itemSize) * 2 + #else + return Int(screenSize / itemSize) + #endif } } diff --git a/Shared/ViewModels/LiveTVChannelsViewModel.swift b/Shared/ViewModels/LiveTVChannelsViewModel.swift index ef59115f..d66b8fb4 100644 --- a/Shared/ViewModels/LiveTVChannelsViewModel.swift +++ b/Shared/ViewModels/LiveTVChannelsViewModel.swift @@ -20,7 +20,8 @@ struct LiveTVChannelRowCell: Hashable { struct LiveTVChannelProgram: Hashable { let id = UUID() let channel: BaseItemDto - let program: BaseItemDto? + let currentProgram: BaseItemDto? + let programs: [BaseItemDto] } final class LiveTVChannelsViewModel: ViewModel { @@ -151,7 +152,7 @@ final class LiveTVChannelsViewModel: ViewModel { } } - channelPrograms.append(LiveTVChannelProgram(channel: channel, program: currentPrg)) + channelPrograms.append(LiveTVChannelProgram(channel: channel, currentProgram: currentPrg, programs: prgs)) } return channelPrograms } diff --git a/Shared/ViewModels/MovieLibrariesViewModel.swift b/Shared/ViewModels/MovieLibrariesViewModel.swift index 77f84536..4d6fc934 100644 --- a/Shared/ViewModels/MovieLibrariesViewModel.swift +++ b/Shared/ViewModels/MovieLibrariesViewModel.swift @@ -54,8 +54,8 @@ final class MovieLibrariesViewModel: ViewModel { } self.rows = self.calculateRows() if self.libraries.count == 1, let library = self.libraries.first { - // show library - self.router?.route(to: \.library, library) + // make this library the root of this stack + self.router?.coordinator.root(\.rootLibrary, library) } } }) diff --git a/Shared/ViewModels/TVLibrariesViewModel.swift b/Shared/ViewModels/TVLibrariesViewModel.swift index 4fc7c53c..aca00158 100644 --- a/Shared/ViewModels/TVLibrariesViewModel.swift +++ b/Shared/ViewModels/TVLibrariesViewModel.swift @@ -54,8 +54,8 @@ final class TVLibrariesViewModel: ViewModel { } self.rows = self.calculateRows() if self.libraries.count == 1, let library = self.libraries.first { - // show library - self.router?.route(to: \.library, library) + // make this library the root of this stack + self.router?.coordinator.root(\.rootLibrary, library) } } }) diff --git a/Swiftfin tvOS/Views/LibraryListView.swift b/Swiftfin tvOS/Views/LibraryListView.swift index 4760e74e..67e4e44c 100644 --- a/Swiftfin tvOS/Views/LibraryListView.swift +++ b/Swiftfin tvOS/Views/LibraryListView.swift @@ -8,6 +8,8 @@ import Defaults import Foundation +import JellyfinAPI +import Stinsen import SwiftUI struct LibraryListView: View { @@ -21,7 +23,13 @@ struct LibraryListView: View { @Default(.Experimental.liveTVAlphaEnabled) var liveTVAlphaEnabled - let supportedCollectionTypes = ["movies", "tvshows", "boxsets", "livetv", "other"] + var supportedCollectionTypes: [BaseItemDto.ItemType] { + if liveTVAlphaEnabled { + return [.movie, .season, .series, .liveTV, .boxset, .unknown] + } else { + return [.movie, .season, .series, .boxset, .unknown] + } + } var body: some View { ScrollView { @@ -30,58 +38,37 @@ struct LibraryListView: View { ForEach(viewModel.libraries.filter { [self] library in let collectionType = library.collectionType ?? "other" - return self.supportedCollectionTypes.contains(collectionType) + let itemType = BaseItemDto.ItemType(rawValue: collectionType) ?? .unknown + return self.supportedCollectionTypes.contains(itemType) }, id: \.id) { library in - if library.collectionType == "livetv" { - if liveTVAlphaEnabled { - Button { - self.mainCoordinator.root(\.liveTV) - } - label: { - ZStack { - HStack { - Spacer() - VStack { - Text(library.name ?? "") - .foregroundColor(.white) - .font(.title2) - .fontWeight(.semibold) - } - Spacer() - }.padding(32) - } - .frame(minWidth: 100, maxWidth: .infinity) - .frame(height: 100) - } - .cornerRadius(10) - .shadow(radius: 5) - .padding(.bottom, 5) - } - } else { - Button { + Button { + let itemType = BaseItemDto.ItemType(rawValue: library.collectionType ?? "other") ?? .unknown + if itemType == .liveTV { + self.mainCoordinator.root(\.liveTV) + } else { self.libraryListRouter.route(to: \.library, (viewModel: LibraryViewModel(parentID: library.id), title: library.name ?? "")) } - label: { - ZStack { - HStack { - Spacer() - VStack { - Text(library.name ?? "") - .foregroundColor(.white) - .font(.title2) - .fontWeight(.semibold) - } - Spacer() - }.padding(32) - } - .frame(minWidth: 100, maxWidth: .infinity) - .frame(height: 100) - } - .cornerRadius(10) - .shadow(radius: 5) - .padding(.bottom, 5) } + label: { + ZStack { + HStack { + Spacer() + VStack { + Text(library.name ?? "") + .foregroundColor(.white) + .font(.title2) + .fontWeight(.semibold) + } + Spacer() + }.padding(32) + } + .frame(minWidth: 100, maxWidth: .infinity) + .frame(height: 100) + } + .cornerRadius(10) + .shadow(radius: 5) + .padding(.bottom, 5) } } else { ProgressView() diff --git a/Swiftfin tvOS/Views/LibraryView.swift b/Swiftfin tvOS/Views/LibraryView.swift index 5c1f397b..5e42872f 100644 --- a/Swiftfin tvOS/Views/LibraryView.swift +++ b/Swiftfin tvOS/Views/LibraryView.swift @@ -27,7 +27,7 @@ struct LibraryView: View { var isShowingFilterView = false var body: some View { - if viewModel.isLoading == true { + if viewModel.rows.isEmpty && viewModel.isLoading == true { ProgressView() } else if !viewModel.rows.isEmpty { CollectionView(rows: viewModel.rows) { _, _ in diff --git a/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift b/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift index f36aa1bf..90dc1727 100644 --- a/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift +++ b/Swiftfin tvOS/Views/LiveTVChannelItemElement.swift @@ -18,14 +18,25 @@ struct LiveTVChannelItemElement: View { private var isFocused: Bool = false var channel: BaseItemDto - var program: BaseItemDto? - var startString = " " - var endString = " " - var progressPercent = Double(0) + var currentProgram: BaseItemDto? + var currentProgramText: LiveTVChannelViewProgram + var nextProgramsText: [LiveTVChannelViewProgram] var onSelect: (@escaping (Bool) -> Void) -> Void + var progressPercent: Double { + if let currentProgram = currentProgram { + let progressPercent = currentProgram.getLiveProgressPercentage() + if progressPercent > 1.0 { + return 1.0 + } else { + return progressPercent + } + } + return 0 + } + private var detailText: String { - guard let program = program else { + guard let program = currentProgram else { return "" } var text = "" @@ -60,61 +71,62 @@ struct LiveTVChannelItemElement: View { }.frame(alignment: .top) Spacer() } - VStack { - ImageView(channel.getPrimaryImage(maxWidth: 128)) - .aspectRatio(contentMode: .fit) - .frame(width: 128, alignment: .center) - .padding(.init(top: 8, leading: 0, bottom: 0, trailing: 0)) - Text(channel.name ?? "?") - .font(.footnote) - .lineLimit(1) - .frame(alignment: .center) - Text(program?.name ?? L10n.notAvailableSlash) - .font(.body) - .lineLimit(1) - .foregroundColor(.green) - Text(detailText) - .font(.body) - .lineLimit(1) - .foregroundColor(.green) - Spacer() - HStack(alignment: .bottom) { - VStack { - Spacer() - HStack { - Text(startString) - .font(.footnote) - .lineLimit(1) - .frame(alignment: .leading) - Spacer() + GeometryReader { gp in + VStack { + ImageView(channel.getPrimaryImage(maxWidth: 192)) + .aspectRatio(contentMode: .fit) + .frame(width: 192, alignment: .center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.init(top: 16, leading: 8, bottom: gp.size.height / 2, trailing: 0)) + VStack { + Text(channel.name ?? "?") + .font(.footnote) + .lineLimit(1) + .frame(alignment: .center) + .foregroundColor(Color.jellyfinPurple) + .padding(.init(top: 0, leading: 0, bottom: 8, trailing: 0)) - Text(endString) - .font(.footnote) - .lineLimit(1) - .frame(alignment: .trailing) - } - GeometryReader { gp in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 6) - .fill(Color.gray) - .opacity(0.4) - .frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12) - RoundedRectangle(cornerRadius: 6) - .fill(Color.jellyfinPurple) - .frame(width: CGFloat(progressPercent * gp.size.width), height: 12) - } - .frame(alignment: .bottom) - } + programLabel(timeText: currentProgramText.timeDisplay, titleText: currentProgramText.title, + color: Color("TextHighlightColor"), font: Font.system(size: 20, weight: .bold, design: .default)) + if !nextProgramsText.isEmpty, + let nextItem = nextProgramsText[0] + { + programLabel(timeText: nextItem.timeDisplay, titleText: nextItem.title, color: Color.gray, + font: Font.system(size: 20, design: .default)) + } + if nextProgramsText.count > 1, + let nextItem2 = nextProgramsText[1] + { + programLabel(timeText: nextItem2.timeDisplay, titleText: nextItem2.title, color: Color.gray, + font: Font.system(size: 20, design: .default)) } } + .frame(maxHeight: .infinity, alignment: .top) + .padding(.init(top: gp.size.height / 2, leading: 16, bottom: 56, trailing: 16)) + .opacity(loading ? 0.5 : 1.0) } - .padding() - .opacity(loading ? 0.5 : 1.0) if loading { ProgressView() } + + VStack { + GeometryReader { gp in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 6) + .fill(Color.gray) + .opacity(0.4) + .frame(minWidth: 100, maxWidth: .infinity, minHeight: 8, maxHeight: 8) + RoundedRectangle(cornerRadius: 6) + .fill(Color.jellyfinPurple) + .frame(width: CGFloat(progressPercent * gp.size.width), height: 8) + } + .frame(maxHeight: .infinity, alignment: .bottom) + .padding(.init(top: 0, leading: 16, bottom: 32, trailing: 16)) + } + } } .overlay(RoundedRectangle(cornerRadius: 20) .stroke(isFocused ? Color.blue : Color.clear, lineWidth: 4)) @@ -133,4 +145,20 @@ struct LiveTVChannelItemElement: View { } } } + + @ViewBuilder + func programLabel(timeText: String, titleText: String, color: Color, font: Font) -> some View { + HStack(alignment: .top, spacing: 4) { + Text(timeText) + .font(font) + .lineLimit(1) + .foregroundColor(color) + .frame(width: 54, alignment: .leading) + Text(titleText) + .font(font) + .lineLimit(2) + .foregroundColor(color) + .frame(maxWidth: .infinity, alignment: .leading) + } + } } diff --git a/Swiftfin tvOS/Views/LiveTVChannelsView.swift b/Swiftfin tvOS/Views/LiveTVChannelsView.swift index 19d66649..050f8833 100644 --- a/Swiftfin tvOS/Views/LiveTVChannelsView.swift +++ b/Swiftfin tvOS/Views/LiveTVChannelsView.swift @@ -11,6 +11,8 @@ import JellyfinAPI import SwiftUI import SwiftUICollection +typealias LiveTVChannelViewProgram = (timeDisplay: String, title: String) + struct LiveTVChannelsView: View { @EnvironmentObject var router: LiveTVChannelsCoordinator.Router @@ -53,23 +55,30 @@ struct LiveTVChannelsView: View { func makeCellView(indexPath: IndexPath, cell: LiveTVChannelRowCell) -> some View { let item = cell.item let channel = item.channel - if channel.type != "Folder" { - let progressPercent = item.program?.getLiveProgressPercentage() ?? 0 - LiveTVChannelItemElement(channel: channel, - program: item.program, - startString: item.program?.getLiveStartTimeString(formatter: viewModel.timeFormatter) ?? " ", - endString: item.program?.getLiveEndTimeString(formatter: viewModel.timeFormatter) ?? " ", - progressPercent: progressPercent > 1.0 ? 1.0 : progressPercent, - onSelect: { loadingAction in - loadingAction(true) - self.viewModel.fetchVideoPlayerViewModel(item: channel) { playerViewModel in - self.router.route(to: \.videoPlayer, playerViewModel) - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - loadingAction(false) - } - } - }) + let currentProgramDisplayText = item.currentProgram? + .programDisplayText(timeFormatter: viewModel.timeFormatter) ?? LiveTVChannelViewProgram(timeDisplay: "", title: "") + let nextItems = item.programs.filter { program in + guard let start = program.startDate else { + return false + } + guard let currentStart = item.currentProgram?.startDate else { + return false + } + return start > currentStart } + LiveTVChannelItemElement(channel: channel, + currentProgram: item.currentProgram, + currentProgramText: currentProgramDisplayText, + nextProgramsText: nextProgramsDisplayText(nextItems: nextItems, timeFormatter: viewModel.timeFormatter), + onSelect: { loadingAction in + loadingAction(true) + self.viewModel.fetchVideoPlayerViewModel(item: channel) { playerViewModel in + self.router.route(to: \.videoPlayer, playerViewModel) + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + loadingAction(false) + } + } + }) } private func createGridLayout() -> NSCollectionLayoutSection { @@ -100,4 +109,43 @@ struct LiveTVChannelsView: View { return section } + + private func nextProgramsDisplayText(nextItems: [BaseItemDto], timeFormatter: DateFormatter) -> [LiveTVChannelViewProgram] { + var programsDisplayText: [LiveTVChannelViewProgram] = [] + for item in nextItems { + programsDisplayText.append(item.programDisplayText(timeFormatter: timeFormatter)) + } + return programsDisplayText + } +} + +private extension BaseItemDto { + func programDisplayText(timeFormatter: DateFormatter) -> LiveTVChannelViewProgram { + var timeText = "" + if let start = self.startDate { + timeText.append(timeFormatter.string(from: start) + " ") + } + var displayText = "" + if let season = self.parentIndexNumber, + let episode = self.indexNumber + { + displayText.append("\(season)x\(episode) ") + } else if let episode = self.indexNumber { + displayText.append("\(episode) ") + } + if let name = self.name { + displayText.append("\(name) ") + } + if let title = self.episodeTitle { + displayText.append("\(title) ") + } + if let year = self.productionYear { + displayText.append("\(year) ") + } + if let rating = self.officialRating { + displayText.append("\(rating)") + } + + return LiveTVChannelViewProgram(timeDisplay: timeText, title: displayText) + } } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 0f309690..50d35fd9 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -250,13 +250,26 @@ 62EC353426766B03000E9F2D /* DeviceRotationViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */; }; 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62ECA01726FA685A00E8EBB7 /* DeepLink.swift */; }; AE8C3159265D6F90008AA076 /* bitrates.json in Resources */ = {isa = PBXBuildFile; fileRef = AE8C3158265D6F90008AA076 /* bitrates.json */; }; + C400DB6A27FE894F007B65FE /* LiveTVChannelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */; }; + C400DB6B27FE8C97007B65FE /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */; }; + C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */; }; + C409CE9C284EA6EA00CABC12 /* SwiftUICollection in Frameworks */ = {isa = PBXBuildFile; productRef = C409CE9B284EA6EA00CABC12 /* SwiftUICollection */; }; + C409CE9E285044C800CABC12 /* SwiftUICollection in Frameworks */ = {isa = PBXBuildFile; productRef = C409CE9D285044C800CABC12 /* SwiftUICollection */; }; C40CD923271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD921271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift */; }; C40CD926271F8D1E000FB198 /* MovieLibrariesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */; }; C40CD929271F8DAB000FB198 /* MovieLibrariesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD927271F8DAB000FB198 /* MovieLibrariesView.swift */; }; + C4464953281616AE00DDB461 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5377CBF8263B596B003A4E83 /* Assets.xcassets */; }; C453497F279A2DA50045F1E2 /* LiveTVPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C453497E279A2DA50045F1E2 /* LiveTVPlayerViewController.swift */; }; C4534981279A3F140045F1E2 /* tvOSLiveTVOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534980279A3F140045F1E2 /* tvOSLiveTVOverlay.swift */; }; C4534983279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */; }; C4534985279A40C60045F1E2 /* LiveTVVideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534984279A40C50045F1E2 /* LiveTVVideoPlayerView.swift */; }; + C45640D0281A43EF007096DE /* LiveTVNativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45640CF281A43EF007096DE /* LiveTVNativePlayerViewController.swift */; }; + C45942C527F67DA400C54FE7 /* LiveTVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */; }; + C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */; }; + C45942C927F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942C827F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift */; }; + C45942CB27F6984100C54FE7 /* LiveTVPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942CA27F6984100C54FE7 /* LiveTVPlayerViewController.swift */; }; + C45942CD27F6994A00C54FE7 /* LiveTVPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942CC27F6994A00C54FE7 /* LiveTVPlayerView.swift */; }; + C45942D027F69C2400C54FE7 /* LiveTVChannelsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */; }; C45B29BB26FAC5B600CEF5E0 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */; }; C4AE2C3027498D2300AE13CF /* LiveTVHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */; }; C4AE2C3227498D6A00AE13CF /* LiveTVProgramsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */; }; @@ -276,9 +289,10 @@ C4BE07892728448B003F4AD1 /* LiveTVChannelsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */; }; C4BE078C272844AF003F4AD1 /* LiveTVChannelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */; }; C4BE078E27298818003F4AD1 /* LiveTVHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */; }; + C4D0CE4B2848570700345D11 /* ASCollectionView in Frameworks */ = {isa = PBXBuildFile; productRef = C4D0CE4A2848570700345D11 /* ASCollectionView */; }; C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E508172703E8190045C9AB /* LibraryListView.swift */; }; C4E5081D2703F8370045C9AB /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */; }; - C4E52305272CE68800654268 /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */; }; + C4E5598928124C10003DECA5 /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */; }; E1002B5F2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B5E2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift */; }; E1002B642793CEE800E47059 /* ChapterInfoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */; }; E1002B652793CEE800E47059 /* ChapterInfoExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */; }; @@ -425,8 +439,6 @@ E1A2C15D279A7D9F005EC829 /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C15B279A7D9F005EC829 /* AppIcon.swift */; }; E1A2C15E279A7D9F005EC829 /* AppIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C15B279A7D9F005EC829 /* AppIcon.swift */; }; E1A2C160279A7DCA005EC829 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A2C15F279A7DCA005EC829 /* AboutView.swift */; }; - E1A99999271A3429008E78C0 /* SwiftUICollection in Frameworks */ = {isa = PBXBuildFile; productRef = E1A99998271A3429008E78C0 /* SwiftUICollection */; }; - E1A9999B271A343C008E78C0 /* SwiftUICollection in Frameworks */ = {isa = PBXBuildFile; productRef = E1A9999A271A343C008E78C0 /* SwiftUICollection */; }; E1AA331D2782541500F6439C /* PrimaryButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331C2782541500F6439C /* PrimaryButtonView.swift */; }; E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331E2782639D00F6439C /* OverlayType.swift */; }; E1AA33202782639D00F6439C /* OverlayType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1AA331E2782639D00F6439C /* OverlayType.swift */; }; @@ -732,6 +744,8 @@ 62EC353326766B03000E9F2D /* DeviceRotationViewModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRotationViewModifier.swift; sourceTree = ""; }; 62ECA01726FA685A00E8EBB7 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; AE8C3158265D6F90008AA076 /* bitrates.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = bitrates.json; sourceTree = ""; }; + C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelsView.swift; sourceTree = ""; }; + C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemWideElement.swift; sourceTree = ""; }; C40CD921271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoviesLibrariesCoordinator.swift; sourceTree = ""; }; C40CD924271F8D1E000FB198 /* MovieLibrariesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieLibrariesViewModel.swift; sourceTree = ""; }; C40CD927271F8DAB000FB198 /* MovieLibrariesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovieLibrariesView.swift; sourceTree = ""; }; @@ -739,6 +753,11 @@ C4534980279A3F140045F1E2 /* tvOSLiveTVOverlay.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVOverlay.swift; sourceTree = ""; }; C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVVideoPlayerCoordinator.swift; sourceTree = ""; }; C4534984279A40C50045F1E2 /* LiveTVVideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVVideoPlayerView.swift; sourceTree = ""; }; + C45640CF281A43EF007096DE /* LiveTVNativePlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVNativePlayerViewController.swift; sourceTree = ""; }; + C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVCoordinator.swift; sourceTree = ""; }; + C45942C827F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSLiveTVVideoPlayerCoordinator.swift; sourceTree = ""; }; + C45942CA27F6984100C54FE7 /* LiveTVPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVPlayerViewController.swift; sourceTree = ""; }; + C45942CC27F6994A00C54FE7 /* LiveTVPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVPlayerView.swift; sourceTree = ""; }; C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVHomeView.swift; sourceTree = ""; }; C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsView.swift; sourceTree = ""; }; C4B9B91327E1921B0063535C /* LiveTVNativeVideoPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVNativeVideoPlayerView.swift; sourceTree = ""; }; @@ -757,6 +776,7 @@ C4E508172703E8190045C9AB /* LibraryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListView.swift; sourceTree = ""; }; C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = ""; }; C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = ""; }; + C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemElement.swift; sourceTree = ""; }; E1002B5E2793C3BE00E47059 /* VLCPlayerChapterOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayerChapterOverlayView.swift; sourceTree = ""; }; E1002B632793CEE700E47059 /* ChapterInfoExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterInfoExtensions.swift; sourceTree = ""; }; E100720626BDABC100CE3E31 /* MediaPlayButtonRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayButtonRowView.swift; sourceTree = ""; }; @@ -894,6 +914,7 @@ files = ( 62666E1727E501CC00EC0ECD /* CFNetwork.framework in Frameworks */, E11D83AF278FA998006E9776 /* NukeUI in Frameworks */, + C409CE9C284EA6EA00CABC12 /* SwiftUICollection in Frameworks */, 62666DFA27E5013700EC0ECD /* TVVLCKit.xcframework in Frameworks */, 62666E3227E5021E00EC0ECD /* UIKit.framework in Frameworks */, E1218C9E271A2CD600EA0737 /* CombineExt in Frameworks */, @@ -911,7 +932,6 @@ 62666E1927E501D000EC0ECD /* CoreFoundation.framework in Frameworks */, 62666E2E27E5021400EC0ECD /* Security.framework in Frameworks */, 53ABFDDC267972BF00886593 /* TVServices.framework in Frameworks */, - E1A9999B271A343C008E78C0 /* SwiftUICollection in Frameworks */, 62666E1F27E501DF00EC0ECD /* CoreText.framework in Frameworks */, E13DD3CD27164CA7009D4DAF /* CoreStore in Frameworks */, 62666E1527E501C800EC0ECD /* AVFoundation.framework in Frameworks */, @@ -928,6 +948,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C4D0CE4B2848570700345D11 /* ASCollectionView in Frameworks */, 62666E3E27E503FA00EC0ECD /* MediaAccessibility.framework in Frameworks */, 62666DFF27E5016400EC0ECD /* CFNetwork.framework in Frameworks */, E13DD3D327168E65009D4DAF /* Defaults in Frameworks */, @@ -944,7 +965,7 @@ 62666E1027E501B400EC0ECD /* VideoToolbox.framework in Frameworks */, 62666E3C27E503F200EC0ECD /* GoogleCastSDK.xcframework in Frameworks */, 62666E0C27E501A500EC0ECD /* OpenGLES.framework in Frameworks */, - E1A99999271A3429008E78C0 /* SwiftUICollection in Frameworks */, + C409CE9E285044C800CABC12 /* SwiftUICollection in Frameworks */, 62666E0127E5016900EC0ECD /* CoreFoundation.framework in Frameworks */, E1B6DCEA271A23880015B715 /* SwiftyJSON in Frameworks */, 62666E2427E501F300EC0ECD /* Foundation.framework in Frameworks */, @@ -1475,6 +1496,7 @@ C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */, C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */, C4BE07782726EE82003F4AD1 /* LiveTVTabCoordinator.swift */, + C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */, E193D5412719404B00900D82 /* MainCoordinator */, C40CD921271F8CD8000FB198 /* MoviesLibrariesCoordinator.swift */, 6220D0B626D5EE1100B8E046 /* SearchCoordinator.swift */, @@ -1632,6 +1654,9 @@ 53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */, 6213388F265F83A900A81A2A /* LibraryListView.swift */, 53EE24E5265060780068F029 /* LibrarySearchView.swift */, + C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */, + C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */, + C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */, 53DF641D263D9C0600A7CD1A /* LibraryView.swift */, C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */, C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */, @@ -1730,10 +1755,13 @@ isa = PBXGroup; children = ( E13AD72D2798BC8D00FDCEE8 /* NativePlayerViewController.swift */, + C45640CF281A43EF007096DE /* LiveTVNativePlayerViewController.swift */, E1002B692793E12E00E47059 /* Overlays */, E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */, E1C812B8277A8E5D00918266 /* VLCPlayerView.swift */, + C45942CC27F6994A00C54FE7 /* LiveTVPlayerView.swift */, E1C812B6277A8E5D00918266 /* VLCPlayerViewController.swift */, + C45942CA27F6984100C54FE7 /* LiveTVPlayerViewController.swift */, ); path = VideoPlayer; sourceTree = ""; @@ -1810,6 +1838,7 @@ E1C812CF277AE4C700918266 /* VideoPlayerCoordinator */ = { isa = PBXGroup; children = ( + C45942C827F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift */, 6220D0C526D62D8700B8E046 /* iOSVideoPlayerCoordinator.swift */, C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */, E1C812D0277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift */, @@ -1899,12 +1928,12 @@ E13DD3CC27164CA7009D4DAF /* CoreStore */, E12186DD2718F1C50010884C /* Defaults */, E1218C9D271A2CD600EA0737 /* CombineExt */, - E1A9999A271A343C008E78C0 /* SwiftUICollection */, E178857C278037FD0094FBCF /* JellyfinAPI */, E1AE8E7D2789136D00FBDDAA /* Nuke */, E11D83AE278FA998006E9776 /* NukeUI */, E1002B6A2793E36600E47059 /* Algorithms */, E1347DB5279E3CA500BC6161 /* Puppy */, + C409CE9B284EA6EA00CABC12 /* SwiftUICollection */, ); productName = "JellyfinPlayer tvOS"; productReference = 535870602669D21600D05A09 /* Swiftfin tvOS.app */; @@ -1936,7 +1965,6 @@ E13DD3D227168E65009D4DAF /* Defaults */, E1B6DCE7271A23780015B715 /* CombineExt */, E1B6DCE9271A23880015B715 /* SwiftyJSON */, - E1A99998271A3429008E78C0 /* SwiftUICollection */, E10EAA44277BB646000269ED /* JellyfinAPI */, E10EAA4C277BB716000269ED /* Sliders */, E1AE8E7B2789135A00FBDDAA /* Nuke */, @@ -1944,6 +1972,8 @@ E1002B672793CFBA00E47059 /* Algorithms */, 62666E3827E502CE00EC0ECD /* SwizzleSwift */, E1101176281B1E8A006A3584 /* Puppy */, + C4D0CE4A2848570700345D11 /* ASCollectionView */, + C409CE9D285044C800CABC12 /* SwiftUICollection */, ); productName = JellyfinPlayer; productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */; @@ -2030,7 +2060,6 @@ E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */, E1267D42271A212C003C492E /* XCRemoteSwiftPackageReference "CombineExt" */, E1C16B89271A2180009A5D25 /* XCRemoteSwiftPackageReference "SwiftyJSON" */, - C4BFD4E327167B63007739E3 /* XCRemoteSwiftPackageReference "SwiftUICollection" */, E10EAA43277BB646000269ED /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */, E10EAA4B277BB716000269ED /* XCRemoteSwiftPackageReference "swiftui-sliders" */, E1AE8E7A2789135A00FBDDAA /* XCRemoteSwiftPackageReference "Nuke" */, @@ -2038,6 +2067,8 @@ E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */, 62666E3727E502CE00EC0ECD /* XCRemoteSwiftPackageReference "SwizzleSwift" */, E1101175281B1E8A006A3584 /* XCRemoteSwiftPackageReference "Puppy" */, + C4D0CE492848570700345D11 /* XCRemoteSwiftPackageReference "ASCollectionView" */, + C409CE9A284EA6EA00CABC12 /* XCRemoteSwiftPackageReference "SwiftUICollection" */, ); productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; projectDirPath = ""; @@ -2069,6 +2100,7 @@ 53913C0526D323FE00EB3286 /* Localizable.strings in Resources */, 53913BFF26D323FE00EB3286 /* Localizable.strings in Resources */, 53913C0E26D323FE00EB3286 /* Localizable.strings in Resources */, + C4464953281616AE00DDB461 /* Assets.xcassets in Resources */, 53913BF026D323FE00EB3286 /* Localizable.strings in Resources */, 53913C0826D323FE00EB3286 /* Localizable.strings in Resources */, 53913C1126D323FE00EB3286 /* Localizable.strings in Resources */, @@ -2243,6 +2275,7 @@ 62E632F0267D43320063E547 /* LibraryFilterViewModel.swift in Sources */, E193D54B271941D300900D82 /* ServerListView.swift in Sources */, 53ABFDE6267974EF00886593 /* SettingsViewModel.swift in Sources */, + C400DB6B27FE8C97007B65FE /* LiveTVChannelItemElement.swift in Sources */, 62E632F4267D54030063E547 /* ItemViewModel.swift in Sources */, 6267B3D826710B9800A7371D /* CollectionExtensions.swift in Sources */, 62E632E7267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, @@ -2268,7 +2301,6 @@ E193D5502719430400900D82 /* ServerDetailView.swift in Sources */, E11B1B6D2718CD68006DA3E8 /* JellyfinAPIError.swift in Sources */, E1C812D1277AE4E300918266 /* tvOSVideoPlayerCoordinator.swift in Sources */, - C4E52305272CE68800654268 /* LiveTVChannelItemElement.swift in Sources */, E1A2C156279A7D5A005EC829 /* UIApplicationExtensions.swift in Sources */, E1A2C15E279A7D9F005EC829 /* AppIcon.swift in Sources */, E193D53D27193F9700900D82 /* UserSignInCoordinator.swift in Sources */, @@ -2378,9 +2410,11 @@ 625CB5732678C32A00530A6E /* HomeViewModel.swift in Sources */, 62C29EA826D103D500C1D2E7 /* LibraryListCoordinator.swift in Sources */, 62E632DC267D2E130063E547 /* LibrarySearchViewModel.swift in Sources */, + C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */, 62C29E9F26D1016600C1D2E7 /* iOSMainCoordinator.swift in Sources */, 5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */, 53F866442687A45F00DCD1D7 /* PortraitItemButton.swift in Sources */, + C45942CD27F6994A00C54FE7 /* LiveTVPlayerView.swift in Sources */, E1AD105626D981CE003E4A08 /* PortraitHStackView.swift in Sources */, 62C29EA126D102A500C1D2E7 /* iOSMainTabCoordinator.swift in Sources */, C4BE076E2720FEA8003F4AD1 /* PortraitItemElement.swift in Sources */, @@ -2399,9 +2433,11 @@ 5D160403278A41FD00D22B99 /* VLCPlayer+subtitles.swift in Sources */, 536D3D78267BD5C30004248C /* ViewModel.swift in Sources */, E1A2C158279A7D76005EC829 /* BundleExtensions.swift in Sources */, + C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */, E1FCD08826C35A0D007C8DCF /* NetworkError.swift in Sources */, E13AD72E2798BC8D00FDCEE8 /* NativePlayerViewController.swift in Sources */, E13DD3E527177D15009D4DAF /* ServerListView.swift in Sources */, + C45942CB27F6984100C54FE7 /* LiveTVPlayerViewController.swift in Sources */, E18845F826DEA9C900B0C5B7 /* ItemViewBody.swift in Sources */, E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */, E19169CE272514760085832A /* HTTPScheme.swift in Sources */, @@ -2413,12 +2449,14 @@ E1047E2027E584AF00CB0D4A /* BlurHashView.swift in Sources */, 62E632DA267D2BC40063E547 /* LatestMediaViewModel.swift in Sources */, E1AD105C26D9ABDD003E4A08 /* PillHStackView.swift in Sources */, + C400DB6A27FE894F007B65FE /* LiveTVChannelsView.swift in Sources */, 625CB56F2678C23300530A6E /* HomeView.swift in Sources */, E1047E2327E5880000CB0D4A /* InitialFailureView.swift in Sources */, E1CEFBF527914C7700F60429 /* CustomizeViewsSettings.swift in Sources */, E1C812BC277A8E5D00918266 /* PlaybackSpeed.swift in Sources */, E1E5D5492783CDD700692DFE /* OverlaySettingsView.swift in Sources */, E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */, + C45942D027F69C2400C54FE7 /* LiveTVChannelsCoordinator.swift in Sources */, E11D224227378428003F9CB3 /* ServerDetailCoordinator.swift in Sources */, 6264E88C273850380081A12A /* Strings.swift in Sources */, C4AE2C3227498D6A00AE13CF /* LiveTVProgramsView.swift in Sources */, @@ -2469,12 +2507,14 @@ C4BE07762725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */, E13DD3E927177ED6009D4DAF /* ServerListCoordinator.swift in Sources */, E1C812BD277A8E5D00918266 /* PlayerOverlayDelegate.swift in Sources */, + C45942C527F67DA400C54FE7 /* LiveTVCoordinator.swift in Sources */, E13DD3C227164941009D4DAF /* SwiftfinStore.swift in Sources */, 62E632E0267D30CA0063E547 /* LibraryViewModel.swift in Sources */, E193D4DB27193CCA00900D82 /* PillStackable.swift in Sources */, E1AA331F2782639D00F6439C /* OverlayType.swift in Sources */, E1A2C154279A7D5A005EC829 /* UIApplicationExtensions.swift in Sources */, E193D4D827193CAC00900D82 /* PortraitImageStackable.swift in Sources */, + C4E5598928124C10003DECA5 /* LiveTVChannelItemElement.swift in Sources */, 624C21752685CF60007F1390 /* SearchablePickerView.swift in Sources */, E1D4BF7C2719D05000A11E64 /* BasicAppSettingsView.swift in Sources */, E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */, @@ -2486,7 +2526,9 @@ 091B5A8A2683142E00D78B61 /* ServerDiscovery.swift in Sources */, 62E632EF267D43320063E547 /* LibraryFilterViewModel.swift in Sources */, 5D64683D277B1649009E09AE /* PreferenceUIHostingSwizzling.swift in Sources */, + C45942C927F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift in Sources */, E13DD3C827164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */, + C45640D0281A43EF007096DE /* LiveTVNativePlayerViewController.swift in Sources */, E10EAA53277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, E1AD104D26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */, E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */, @@ -3083,14 +3125,22 @@ minimumVersion = 2.0.2; }; }; - C4BFD4E327167B63007739E3 /* XCRemoteSwiftPackageReference "SwiftUICollection" */ = { + C409CE9A284EA6EA00CABC12 /* XCRemoteSwiftPackageReference "SwiftUICollection" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/ABJC/SwiftUICollection"; + repositoryURL = "https://github.com/defagos/SwiftUICollection"; requirement = { branch = master; kind = branch; }; }; + C4D0CE492848570700345D11 /* XCRemoteSwiftPackageReference "ASCollectionView" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apptekstudios/ASCollectionView"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/apple/swift-algorithms.git"; @@ -3227,6 +3277,21 @@ package = 62C29E9A26D0FE4100C1D2E7 /* XCRemoteSwiftPackageReference "stinsen" */; productName = Stinsen; }; + C409CE9B284EA6EA00CABC12 /* SwiftUICollection */ = { + isa = XCSwiftPackageProductDependency; + package = C409CE9A284EA6EA00CABC12 /* XCRemoteSwiftPackageReference "SwiftUICollection" */; + productName = SwiftUICollection; + }; + C409CE9D285044C800CABC12 /* SwiftUICollection */ = { + isa = XCSwiftPackageProductDependency; + package = C409CE9A284EA6EA00CABC12 /* XCRemoteSwiftPackageReference "SwiftUICollection" */; + productName = SwiftUICollection; + }; + C4D0CE4A2848570700345D11 /* ASCollectionView */ = { + isa = XCSwiftPackageProductDependency; + package = C4D0CE492848570700345D11 /* XCRemoteSwiftPackageReference "ASCollectionView" */; + productName = ASCollectionView; + }; E1002B672793CFBA00E47059 /* Algorithms */ = { isa = XCSwiftPackageProductDependency; package = E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */; @@ -3322,16 +3387,6 @@ package = E10EAA43277BB646000269ED /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */; productName = JellyfinAPI; }; - E1A99998271A3429008E78C0 /* SwiftUICollection */ = { - isa = XCSwiftPackageProductDependency; - package = C4BFD4E327167B63007739E3 /* XCRemoteSwiftPackageReference "SwiftUICollection" */; - productName = SwiftUICollection; - }; - E1A9999A271A343C008E78C0 /* SwiftUICollection */ = { - isa = XCSwiftPackageProductDependency; - package = C4BFD4E327167B63007739E3 /* XCRemoteSwiftPackageReference "SwiftUICollection" */; - productName = SwiftUICollection; - }; E1AE8E7B2789135A00FBDDAA /* Nuke */ = { isa = XCSwiftPackageProductDependency; package = E1AE8E7A2789135A00FBDDAA /* XCRemoteSwiftPackageReference "Nuke" */; diff --git a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b8168ffe..d0bf750d 100644 --- a/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Swiftfin.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -18,6 +18,15 @@ "version" : "0.6.4" } }, + { + "identity" : "ascollectionview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apptekstudios/ASCollectionView", + "state" : { + "revision" : "4288744ba484c1062c109c0f28d72b629d321d55", + "version" : "2.1.1" + } + }, { "identity" : "combineext", "kind" : "remoteSourceControl", @@ -45,6 +54,15 @@ "version" : "6.2.1" } }, + { + "identity" : "differencekit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ra1028/DifferenceKit", + "state" : { + "revision" : "62745d7780deef4a023a792a1f8f763ec7bf9705", + "version" : "1.2.0" + } + }, { "identity" : "gifu", "kind" : "remoteSourceControl", @@ -156,10 +174,10 @@ { "identity" : "swiftuicollection", "kind" : "remoteSourceControl", - "location" : "https://github.com/ABJC/SwiftUICollection", + "location" : "https://github.com/defagos/SwiftUICollection", "state" : { "branch" : "master", - "revision" : "e27149382ce8ec21995069c8aab7ca83d61a3120" + "revision" : "5b9f14eb3ec5d48cec8b3e4462dcc554d4bff2a8" } }, { diff --git a/Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json b/Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json new file mode 100644 index 00000000..737e9109 --- /dev/null +++ b/Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/BackgroundSecondaryColor.colorset/Contents.json b/Swiftfin/Assets.xcassets/BackgroundSecondaryColor.colorset/Contents.json new file mode 100644 index 00000000..5d336734 --- /dev/null +++ b/Swiftfin/Assets.xcassets/BackgroundSecondaryColor.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.100", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.100", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/ShadowColor.colorset/Contents.json b/Swiftfin/Assets.xcassets/ShadowColor.colorset/Contents.json new file mode 100644 index 00000000..1264d8ba --- /dev/null +++ b/Swiftfin/Assets.xcassets/ShadowColor.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.250", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.250", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/TextHighlightColor.colorset/Contents.json b/Swiftfin/Assets.xcassets/TextHighlightColor.colorset/Contents.json new file mode 100644 index 00000000..76b961cc --- /dev/null +++ b/Swiftfin/Assets.xcassets/TextHighlightColor.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Views/LibraryListView.swift b/Swiftfin/Views/LibraryListView.swift index eedbe6c1..06ae5a5f 100644 --- a/Swiftfin/Views/LibraryListView.swift +++ b/Swiftfin/Views/LibraryListView.swift @@ -6,7 +6,9 @@ // Copyright (c) 2022 Jellyfin & Jellyfin Contributors // +import Defaults import Foundation +import JellyfinAPI import Stinsen import SwiftUI @@ -16,7 +18,16 @@ struct LibraryListView: View { @StateObject var viewModel = LibraryListViewModel() - let supportedCollectionTypes = ["movies", "tvshows", "boxsets", "other"] + @Default(.Experimental.liveTVAlphaEnabled) + var liveTVAlphaEnabled + + var supportedCollectionTypes: [BaseItemDto.ItemType] { + if liveTVAlphaEnabled { + return [.movie, .season, .series, .liveTV, .boxset, .unknown] + } else { + return [.movie, .season, .series, .boxset, .unknown] + } + } var body: some View { ScrollView { @@ -46,12 +57,18 @@ struct LibraryListView: View { if !viewModel.isLoading { ForEach(viewModel.libraries.filter { [self] library in let collectionType = library.collectionType ?? "other" - return self.supportedCollectionTypes.contains(collectionType) + let itemType = BaseItemDto.ItemType(rawValue: collectionType) ?? .unknown + return self.supportedCollectionTypes.contains(itemType) }, id: \.id) { library in Button { - libraryListRouter.route(to: \.library, - (viewModel: LibraryViewModel(parentID: library.id), - title: library.name ?? "")) + let itemType = BaseItemDto.ItemType(rawValue: library.collectionType ?? "other") ?? .unknown + if itemType == .liveTV { + libraryListRouter.route(to: \.liveTV) + } else { + libraryListRouter.route(to: \.library, + (viewModel: LibraryViewModel(parentID: library.id), + title: library.name ?? "")) + } } label: { ZStack { ImageView(library.getPrimaryImage(maxWidth: 500), blurHash: library.getPrimaryImageBlurHash()) diff --git a/Swiftfin/Views/LiveTVChannelItemElement.swift b/Swiftfin/Views/LiveTVChannelItemElement.swift new file mode 100644 index 00000000..1cc2fa53 --- /dev/null +++ b/Swiftfin/Views/LiveTVChannelItemElement.swift @@ -0,0 +1,122 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct LiveTVChannelItemElement: View { + @FocusState + private var focused: Bool + @State + private var loading: Bool = false + @State + private var isFocused: Bool = false + + var channel: BaseItemDto + var program: BaseItemDto? + var startString = " " + var endString = " " + var progressPercent = Double(0) + var onSelect: (@escaping (Bool) -> Void) -> Void + + private var detailText: String { + guard let program = program else { + return "" + } + var text = "" + if let season = program.parentIndexNumber, + let episode = program.indexNumber + { + text.append("\(season)x\(episode) ") + } else if let episode = program.indexNumber { + text.append("\(episode) ") + } + if let title = program.episodeTitle { + text.append("\(title) ") + } + if let year = program.productionYear { + text.append("\(year) ") + } + if let rating = program.officialRating { + text.append("\(rating)") + } + return text + } + + var body: some View { + ZStack { + VStack { + HStack { + Text(channel.number ?? "") + .font(.footnote) + .frame(alignment: .leading) + .padding() + Spacer() + }.frame(alignment: .top) + Spacer() + } + VStack { + ImageView(channel.getPrimaryImage(maxWidth: 128)) + .aspectRatio(contentMode: .fit) + .frame(width: 128, alignment: .center) + .padding(.init(top: 8, leading: 0, bottom: 0, trailing: 0)) + Text(channel.name ?? "?") + .font(.footnote) + .lineLimit(1) + .frame(alignment: .center) + Text(program?.name ?? L10n.notAvailableSlash) + .font(.body) + .lineLimit(1) + .foregroundColor(.green) + Text(detailText) + .font(.body) + .lineLimit(1) + .foregroundColor(.green) + Spacer() + HStack(alignment: .bottom) { + VStack { + Spacer() + HStack { + Text(startString) + .font(.footnote) + .lineLimit(1) + .frame(alignment: .leading) + + Spacer() + + Text(endString) + .font(.footnote) + .lineLimit(1) + .frame(alignment: .trailing) + } + GeometryReader { gp in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 6) + .fill(Color.gray) + .opacity(0.4) + .frame(minWidth: 100, maxWidth: .infinity, minHeight: 12, maxHeight: 12) + RoundedRectangle(cornerRadius: 6) + .fill(Color.jellyfinPurple) + .frame(width: CGFloat(progressPercent * gp.size.width), height: 12) + } + .frame(alignment: .bottom) + } + } + } + } + .padding() + .opacity(loading ? 0.5 : 1.0) + + if loading { + ProgressView() + } + } + .overlay(RoundedRectangle(cornerRadius: 0) + .stroke(Color.blue, lineWidth: 0)) + } +} diff --git a/Swiftfin/Views/LiveTVChannelItemWideElement.swift b/Swiftfin/Views/LiveTVChannelItemWideElement.swift new file mode 100644 index 00000000..da7c021d --- /dev/null +++ b/Swiftfin/Views/LiveTVChannelItemWideElement.swift @@ -0,0 +1,151 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct LiveTVChannelItemWideElement: View { + @FocusState + private var focused: Bool + @State + private var loading: Bool = false + @State + private var isFocused: Bool = false + + var channel: BaseItemDto + var currentProgram: BaseItemDto? + var currentProgramText: LiveTVChannelViewProgram + var nextProgramsText: [LiveTVChannelViewProgram] + var onSelect: (@escaping (Bool) -> Void) -> Void + + var progressPercent: Double { + if let currentProgram = currentProgram { + let progressPercent = currentProgram.getLiveProgressPercentage() + if progressPercent > 1.0 { + return 1.0 + } else { + return progressPercent + } + } + return 0 + } + + private var detailText: String { + guard let program = currentProgram else { + return "" + } + var text = "" + if let season = program.parentIndexNumber, + let episode = program.indexNumber + { + text.append("\(season)x\(episode) ") + } else if let episode = program.indexNumber { + text.append("\(episode) ") + } + if let title = program.episodeTitle { + text.append("\(title) ") + } + if let year = program.productionYear { + text.append("\(year) ") + } + if let rating = program.officialRating { + text.append("\(rating)") + } + return text + } + + var body: some View { + ZStack { + 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 new file mode 100644 index 00000000..f0840687 --- /dev/null +++ b/Swiftfin/Views/LiveTVChannelsView.swift @@ -0,0 +1,148 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import ASCollectionView +import Foundation +import JellyfinAPI +import SwiftUI +import SwiftUICollection + +typealias LiveTVChannelViewProgram = (timeDisplay: String, title: String) + +struct LiveTVChannelsView: View { + @EnvironmentObject + var router: LiveTVCoordinator.Router + @StateObject + var viewModel = LiveTVChannelsViewModel() + @State + private var isPortrait = false + private var columns: Int { + if UIDevice.current.userInterfaceIdiom == .pad { + return 2 + } else { + if isPortrait { + return 1 + } else { + return 2 + } + } + } + + var body: some View { + if viewModel.isLoading == true { + ProgressView() + } else if !viewModel.channelPrograms.isEmpty { + ASCollectionView(data: viewModel.channelPrograms, dataID: \.self) { channelProgram, _ in + makeCellView(channelProgram) + } + .layout { + .grid(layoutMode: .fixedNumberOfColumns(columns), + itemSpacing: 16, + lineSpacing: 4, + itemSize: .absolute(144)) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .ignoresSafeArea() + .onAppear { + viewModel.startScheduleCheckTimer() + self.checkOrientation() + } + .onDisappear { + viewModel.stopScheduleCheckTimer() + } + .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in + self.checkOrientation() + } + } else { + VStack { + Text("No results.") + Button { + viewModel.getChannels() + } label: { + Text("Reload") + } + } + } + } + + @ViewBuilder + func makeCellView(_ channelProgram: LiveTVChannelProgram) -> some View { + let channel = channelProgram.channel + let currentProgramDisplayText = channelProgram.currentProgram? + .programDisplayText(timeFormatter: viewModel.timeFormatter) ?? LiveTVChannelViewProgram(timeDisplay: "", title: "") + let nextItems = channelProgram.programs.filter { program in + guard let start = program.startDate else { + return false + } + guard let currentStart = channelProgram.currentProgram?.startDate else { + return false + } + return start > currentStart + } + LiveTVChannelItemWideElement(channel: channel, + currentProgram: channelProgram.currentProgram, + currentProgramText: currentProgramDisplayText, + nextProgramsText: nextProgramsDisplayText(nextItems: nextItems, + timeFormatter: viewModel.timeFormatter), + onSelect: { loadingAction in + loadingAction(true) + self.viewModel.fetchVideoPlayerViewModel(item: channel) { playerViewModel in + self.router.route(to: \.videoPlayer, playerViewModel) + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + loadingAction(false) + } + } + }) + } + + 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) + } +} diff --git a/Swiftfin/Views/LiveTVProgramsView.swift b/Swiftfin/Views/LiveTVProgramsView.swift index fba31aba..97329c9c 100644 --- a/Swiftfin/Views/LiveTVProgramsView.swift +++ b/Swiftfin/Views/LiveTVProgramsView.swift @@ -10,7 +10,129 @@ import Stinsen import SwiftUI struct LiveTVProgramsView: View { + @EnvironmentObject + var programsRouter: LiveTVProgramsCoordinator.Router + @StateObject + var viewModel = LiveTVProgramsViewModel() + var body: some View { - Text("Coming Soon") + ScrollView { + LazyVStack(alignment: .leading) { + if !viewModel.recommendedItems.isEmpty, + let items = viewModel.recommendedItems + { + PortraitImageHStackView(items: items, + horizontalAlignment: .leading) { + Text("On Now") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + } selectedAction: { item in + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } + } + } + } + if !viewModel.seriesItems.isEmpty, + let items = viewModel.seriesItems + { + PortraitImageHStackView(items: items, + horizontalAlignment: .leading) { + Text("Shows") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + } selectedAction: { item in + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } + } + } + } + if !viewModel.movieItems.isEmpty, + let items = viewModel.movieItems + { + PortraitImageHStackView(items: items, + horizontalAlignment: .leading) { + Text("Movies") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + } selectedAction: { item in + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } + } + } + } + if !viewModel.sportsItems.isEmpty, + let items = viewModel.sportsItems + { + PortraitImageHStackView(items: items, + horizontalAlignment: .leading) { + Text("Sports") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + } selectedAction: { item in + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } + } + } + } + if !viewModel.kidsItems.isEmpty, + let items = viewModel.kidsItems + { + PortraitImageHStackView(items: items, + horizontalAlignment: .leading) { + Text("Kids") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + } selectedAction: { item in + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } + } + } + } + if !viewModel.newsItems.isEmpty, + let items = viewModel.newsItems + { + PortraitImageHStackView(items: items, + horizontalAlignment: .leading) { + Text("News") + .font(.headline) + .fontWeight(.semibold) + .padding(.leading, 90) + } selectedAction: { item in + if let chanId = item.channelId, + let chan = viewModel.findChannel(id: chanId) + { + self.viewModel.fetchVideoPlayerViewModel(item: chan) { playerViewModel in + self.programsRouter.route(to: \.videoPlayer, playerViewModel) + } + } + } + } + } + } } } diff --git a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift index 57c5ca7b..e0192df0 100644 --- a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift +++ b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift @@ -17,6 +17,12 @@ 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 var body: some View { Form { @@ -31,6 +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") + } } } } diff --git a/Swiftfin/Views/VideoPlayer/LiveTVNativePlayerViewController.swift b/Swiftfin/Views/VideoPlayer/LiveTVNativePlayerViewController.swift new file mode 100644 index 00000000..d5405c04 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/LiveTVNativePlayerViewController.swift @@ -0,0 +1,114 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import AVKit +import Combine +import JellyfinAPI +import UIKit + +class LiveTVNativePlayerViewController: AVPlayerViewController { + + let viewModel: VideoPlayerViewModel + + var timeObserverToken: Any? + + var lastProgressTicks: Int64 = 0 + + private var cancellables = Set() + + init(viewModel: VideoPlayerViewModel) { + + self.viewModel = viewModel + + super.init(nibName: nil, bundle: nil) + + let player: AVPlayer + + if let transcodedStreamURL = viewModel.transcodedStreamURL { + player = AVPlayer(url: transcodedStreamURL) + } else { + player = AVPlayer(url: viewModel.hlsStreamURL) + } + + player.appliesMediaSelectionCriteriaAutomatically = false + + let timeScale = CMTimeScale(NSEC_PER_SEC) + let time = CMTime(seconds: 5, preferredTimescale: timeScale) + + timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in + if time.seconds != 0 { + self?.sendProgressReport(seconds: time.seconds) + } + } + + self.player = player + + self.allowsPictureInPicturePlayback = true + self.player?.allowsExternalPlayback = true + } + + private func createMetadataItem(for identifier: AVMetadataIdentifier, + value: Any) -> AVMetadataItem + { + let item = AVMutableMetadataItem() + item.identifier = identifier + item.value = value as? NSCopying & NSObjectProtocol + // Specify "und" to indicate an undefined language. + item.extendedLanguageTag = "und" + return item.copy() as! AVMetadataItem + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + stop() + removePeriodicTimeObserver() + } + + func removePeriodicTimeObserver() { + if let timeObserverToken = timeObserverToken { + player?.removeTimeObserver(timeObserverToken) + self.timeObserverToken = nil + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + player?.seek(to: CMTimeMake(value: viewModel.currentSecondTicks, timescale: 10_000_000), + toleranceBefore: CMTimeMake(value: 1, timescale: 1), toleranceAfter: CMTimeMake(value: 1, timescale: 1), + completionHandler: { _ in + self.play() + }) + } + + private func play() { + player?.play() + + viewModel.sendPlayReport() + } + + private func sendProgressReport(seconds: Double) { + viewModel.setSeconds(Int64(seconds)) + viewModel.sendProgressReport() + } + + private func stop() { + self.player?.pause() + viewModel.sendStopReport() + } +} diff --git a/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift b/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift new file mode 100644 index 00000000..5b21e0f2 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift @@ -0,0 +1,38 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import SwiftUI +import UIKit + +struct LiveTVNativePlayerView: UIViewControllerRepresentable { + + let viewModel: VideoPlayerViewModel + + typealias UIViewControllerType = LiveTVNativePlayerViewController + + func makeUIViewController(context: Context) -> LiveTVNativePlayerViewController { + + LiveTVNativePlayerViewController(viewModel: viewModel) + } + + func updateUIViewController(_ uiViewController: LiveTVNativePlayerViewController, context: Context) {} +} + +struct LiveTVPlayerView: UIViewControllerRepresentable { + + let viewModel: VideoPlayerViewModel + + typealias UIViewControllerType = LiveTVPlayerViewController + + func makeUIViewController(context: Context) -> LiveTVPlayerViewController { + + LiveTVPlayerViewController(viewModel: viewModel) + } + + func updateUIViewController(_ uiViewController: LiveTVPlayerViewController, context: Context) {} +} diff --git a/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift b/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift new file mode 100644 index 00000000..64b56d1a --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/LiveTVPlayerViewController.swift @@ -0,0 +1,1033 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import AVFoundation +import AVKit +import Combine +import Defaults +import JellyfinAPI +import MediaPlayer +import MobileVLCKit +import SwiftUI +import UIKit + +// TODO: Look at making the VLC player layer a view + +class LiveTVPlayerViewController: UIViewController { + // MARK: variables + + private var viewModel: VideoPlayerViewModel + private var vlcMediaPlayer: VLCMediaPlayer + private var lastPlayerTicks: Int64 = 0 + private var lastProgressReportTicks: Int64 = 0 + private var viewModelListeners = Set() + private var overlayDismissTimer: Timer? + private var isScreenFilled: Bool = false + private var pinchScale: CGFloat = 1 + + private var currentPlayerTicks: Int64 { + Int64(vlcMediaPlayer.time.intValue) * 100_000 + } + + private var displayingOverlay: Bool { + currentOverlayHostingController?.view.alpha ?? 0 > 0 + } + + private var displayingChapterOverlay: Bool { + currentChapterOverlayHostingController?.view.alpha ?? 0 > 0 + } + + private var panBeganBrightness = CGFloat.zero + private var panBeganVolumeValue = Float.zero + private var panBeganPoint = CGPoint.zero + + private lazy var videoContentView = makeVideoContentView() + private lazy var mainGestureView = makeMainGestureView() + private lazy var systemControlOverlayLabel = makeSystemControlOverlayLabel() + private var currentOverlayHostingController: UIHostingController? + private var currentChapterOverlayHostingController: UIHostingController? + private var currentJumpBackwardOverlayView: UIImageView? + private var currentJumpForwardOverlayView: UIImageView? + private var volumeView = MPVolumeView() + + override var keyCommands: [UIKeyCommand]? { + var commands = [ + UIKeyCommand(title: L10n.playAndPause, action: #selector(didSelectMain), input: " "), + UIKeyCommand(title: L10n.jumpForward, action: #selector(didSelectForward), input: UIKeyCommand.inputRightArrow), + UIKeyCommand(title: L10n.jumpBackward, action: #selector(didSelectBackward), input: UIKeyCommand.inputLeftArrow), + UIKeyCommand(title: L10n.nextItem, action: #selector(didSelectPlayNextItem), input: UIKeyCommand.inputRightArrow, + modifierFlags: .command), + UIKeyCommand(title: L10n.previousItem, action: #selector(didSelectPlayPreviousItem), input: UIKeyCommand.inputLeftArrow, + modifierFlags: .command), + UIKeyCommand(title: L10n.close, action: #selector(didSelectClose), input: UIKeyCommand.inputEscape), + ] + if let previous = viewModel.playbackSpeed.previous { + commands.append(.init(title: "\(L10n.playbackSpeed) \(previous.displayTitle)", + action: #selector(didSelectPreviousPlaybackSpeed), input: "[", modifierFlags: .command)) + } + if let next = viewModel.playbackSpeed.next { + commands.append(.init(title: "\(L10n.playbackSpeed) \(next.displayTitle)", action: #selector(didSelectNextPlaybackSpeed), + input: "]", modifierFlags: .command)) + } + if viewModel.playbackSpeed != .one { + commands.append(.init(title: "\(L10n.playbackSpeed) \(PlaybackSpeed.one.displayTitle)", + action: #selector(didSelectNormalPlaybackSpeed), input: "\\", modifierFlags: .command)) + } + commands.forEach { $0.wantsPriorityOverSystemBehavior = true } + return commands + } + + // MARK: init + + init(viewModel: VideoPlayerViewModel) { + self.viewModel = viewModel + self.vlcMediaPlayer = VLCMediaPlayer() + + super.init(nibName: nil, bundle: nil) + + viewModel.playerOverlayDelegate = self + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupSubviews() { + view.addSubview(videoContentView) + view.addSubview(mainGestureView) + view.addSubview(systemControlOverlayLabel) + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + videoContentView.topAnchor.constraint(equalTo: view.topAnchor), + videoContentView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + videoContentView.leftAnchor.constraint(equalTo: view.leftAnchor), + videoContentView.rightAnchor.constraint(equalTo: view.rightAnchor), + ]) + NSLayoutConstraint.activate([ + mainGestureView.topAnchor.constraint(equalTo: videoContentView.topAnchor), + mainGestureView.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + mainGestureView.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + mainGestureView.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) + NSLayoutConstraint.activate([ + systemControlOverlayLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor), + systemControlOverlayLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor), + ]) + } + + // MARK: viewWillDisappear + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + NotificationCenter.default.removeObserver(self) + } + + // MARK: viewDidLoad + + override func viewDidLoad() { + super.viewDidLoad() + + setupSubviews() + setupConstraints() + + view.backgroundColor = .black + view.accessibilityIgnoresInvertColors = true + + setupMediaPlayer(newViewModel: viewModel) + + refreshJumpBackwardOverlayView(with: viewModel.jumpBackwardLength) + refreshJumpForwardOverlayView(with: viewModel.jumpForwardLength) + + let defaultNotificationCenter = NotificationCenter.default + defaultNotificationCenter.addObserver(self, selector: #selector(appWillTerminate), name: UIApplication.willTerminateNotification, + object: nil) + defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), + name: UIApplication.willResignActiveNotification, object: nil) + defaultNotificationCenter.addObserver(self, selector: #selector(appWillResignActive), + name: UIApplication.didEnterBackgroundNotification, object: nil) + } + + @objc + private func appWillTerminate() { + viewModel.sendStopReport() + } + + @objc + private func appWillResignActive() { + hideChaptersOverlay() + + showOverlay() + + stopOverlayDismissTimer() + + vlcMediaPlayer.pause() + + viewModel.sendPauseReport(paused: true) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + startPlayback() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + if isScreenFilled { + fillScreen(screenSize: size) + } + super.viewWillTransition(to: size, with: coordinator) + } + + // MARK: VideoContentView + + private func makeVideoContentView() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .black + + return view + } + + // MARK: MainGestureView + + private func makeMainGestureView() -> UIView { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + + let singleTapGesture = UITapGestureRecognizer(target: self, action: #selector(didTap)) + + let rightSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didRightSwipe)) + rightSwipeGesture.direction = .right + + let leftSwipeGesture = UISwipeGestureRecognizer(target: self, action: #selector(didLeftSwipe)) + leftSwipeGesture.direction = .left + + let pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(didPinch(_:))) + + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(didPan(_:))) + + view.addGestureRecognizer(singleTapGesture) + view.addGestureRecognizer(pinchGesture) + + if viewModel.jumpGesturesEnabled { + view.addGestureRecognizer(rightSwipeGesture) + view.addGestureRecognizer(leftSwipeGesture) + } + + if viewModel.systemControlGesturesEnabled { + view.addGestureRecognizer(panGesture) + } + + return view + } + + // MARK: SystemControlOverlayLabel + + private func makeSystemControlOverlayLabel() -> UILabel { + let label = UILabel() + label.alpha = 0 + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .systemFont(ofSize: 48) + return label + } + + @objc + private func didTap() { + didGenerallyTap(point: nil) + } + + @objc + private func didRightSwipe() { + didSelectForward() + } + + @objc + private func didLeftSwipe() { + didSelectBackward() + } + + @objc + private func didPinch(_ gestureRecognizer: UIPinchGestureRecognizer) { + if gestureRecognizer.state == .began || gestureRecognizer.state == .changed { + pinchScale = gestureRecognizer.scale + } else { + if pinchScale > 1, !isScreenFilled { + isScreenFilled.toggle() + fillScreen() + } else if pinchScale < 1, isScreenFilled { + isScreenFilled.toggle() + shrinkScreen() + } + } + } + + @objc + private func didPan(_ gestureRecognizer: UIPanGestureRecognizer) { + switch gestureRecognizer.state { + case .began: + panBeganBrightness = UIScreen.main.brightness + if let view = volumeView.subviews.first as? UISlider { + panBeganVolumeValue = view.value + } + panBeganPoint = gestureRecognizer.location(in: mainGestureView) + case .changed: + let mainGestureViewHalfWidth = mainGestureView.frame.width * 0.5 + let mainGestureViewHalfHeight = mainGestureView.frame.height * 0.5 + + let pos = gestureRecognizer.location(in: mainGestureView) + let moveDelta = pos.y - panBeganPoint.y + let changedValue = moveDelta / mainGestureViewHalfHeight + + if panBeganPoint.x < mainGestureViewHalfWidth { + UIScreen.main.brightness = panBeganBrightness - changedValue + showBrightnessOverlay() + } else if let view = volumeView.subviews.first as? UISlider { + view.value = panBeganVolumeValue - Float(changedValue) + showVolumeOverlay() + } + default: + hideSystemControlOverlay() + } + } + + // MARK: setupOverlayHostingController + + private func setupOverlayHostingController(viewModel: VideoPlayerViewModel) { + // TODO: Look at injecting viewModel into the environment so it updates the current overlay + if let currentOverlayHostingController = currentOverlayHostingController { + // UX fade-out + UIView.animate(withDuration: 0.5) { + currentOverlayHostingController.view.alpha = 0 + } completion: { _ in + currentOverlayHostingController.view.isHidden = true + + currentOverlayHostingController.view.removeFromSuperview() + currentOverlayHostingController.removeFromParent() + } + } + + let newOverlayView = VLCPlayerOverlayView(viewModel: viewModel) + let newOverlayHostingController = UIHostingController(rootView: newOverlayView) + + newOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newOverlayHostingController.view.backgroundColor = UIColor.clear + + // UX fade-in + newOverlayHostingController.view.alpha = 0 + + addChild(newOverlayHostingController) + view.addSubview(newOverlayHostingController.view) + newOverlayHostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + newOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) + + // UX fade-in + UIView.animate(withDuration: 0.5) { + newOverlayHostingController.view.alpha = 1 + } + + currentOverlayHostingController = newOverlayHostingController + + if let currentChapterOverlayHostingController = currentChapterOverlayHostingController { + UIView.animate(withDuration: 0.5) { + currentChapterOverlayHostingController.view.alpha = 0 + } completion: { _ in + currentChapterOverlayHostingController.view.isHidden = true + + currentChapterOverlayHostingController.view.removeFromSuperview() + currentChapterOverlayHostingController.removeFromParent() + } + } + + let newChapterOverlayView = VLCPlayerChapterOverlayView(viewModel: viewModel) + let newChapterOverlayHostingController = UIHostingController(rootView: newChapterOverlayView) + + newChapterOverlayHostingController.view.translatesAutoresizingMaskIntoConstraints = false + newChapterOverlayHostingController.view.backgroundColor = UIColor.clear + + newChapterOverlayHostingController.view.alpha = 0 + + addChild(newChapterOverlayHostingController) + view.addSubview(newChapterOverlayHostingController.view) + newChapterOverlayHostingController.didMove(toParent: self) + + NSLayoutConstraint.activate([ + newChapterOverlayHostingController.view.topAnchor.constraint(equalTo: videoContentView.topAnchor), + newChapterOverlayHostingController.view.bottomAnchor.constraint(equalTo: videoContentView.bottomAnchor), + newChapterOverlayHostingController.view.leftAnchor.constraint(equalTo: videoContentView.leftAnchor), + newChapterOverlayHostingController.view.rightAnchor.constraint(equalTo: videoContentView.rightAnchor), + ]) + + currentChapterOverlayHostingController = newChapterOverlayHostingController + + // There is a weird behavior when after setting the new overlays that the navigation bar pops up, re-hide it + navigationController?.isNavigationBarHidden = true + } + + private func refreshJumpBackwardOverlayView(with jumpBackwardLength: VideoPlayerJumpLength) { + if let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView { + currentJumpBackwardOverlayView.removeFromSuperview() + } + + let symbolConfig = UIImage.SymbolConfiguration(pointSize: 48) + let backwardSymbolImage = UIImage(systemName: jumpBackwardLength.backwardImageLabel, withConfiguration: symbolConfig) + let newJumpBackwardImageView = UIImageView(image: backwardSymbolImage) + + 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.log.debug("Player set up with direct play stream for item: \(viewModel.item.id ?? "--")") + } else if viewModel.streamType == .transcode && Defaults[.Experimental.forceDirectPlay] { + LogManager.log.debug("Player set up with forced direct stream for item: \(viewModel.item.id ?? "--")") + } else { + LogManager.log.debug("Player set up with transcoded stream for item: \(viewModel.item.id ?? "--")") + } + } + + // MARK: startPlayback + + func startPlayback() { + vlcMediaPlayer.play() + + // Setup external subtitles + for externalSubtitle in viewModel.subtitleStreams.filter({ $0.deliveryMethod == .external }) { + if let deliveryURL = externalSubtitle.externalURL(base: SessionManager.main.currentLogin.server.currentURI) { + vlcMediaPlayer.addPlaybackSlave(deliveryURL, type: .subtitle, enforce: false) + } + } + + setMediaPlayerTimeAtCurrentSlider() + + viewModel.sendPlayReport() + + restartOverlayDismissTimer() + } + + // MARK: setupViewModelListeners + + private func setupViewModelListeners(viewModel: VideoPlayerViewModel) { + viewModel.$playbackSpeed.sink { newSpeed in + self.vlcMediaPlayer.rate = Float(newSpeed.rawValue) + }.store(in: &viewModelListeners) + + viewModel.$sliderIsScrubbing.sink { sliderIsScrubbing in + if sliderIsScrubbing { + self.didBeginScrubbing() + } else { + self.didEndScrubbing() + } + }.store(in: &viewModelListeners) + + viewModel.$selectedAudioStreamIndex.sink { newAudioStreamIndex in + self.didSelectAudioStream(index: newAudioStreamIndex) + }.store(in: &viewModelListeners) + + viewModel.$selectedSubtitleStreamIndex.sink { newSubtitleStreamIndex in + self.didSelectSubtitleStream(index: newSubtitleStreamIndex) + }.store(in: &viewModelListeners) + + viewModel.$subtitlesEnabled.sink { newSubtitlesEnabled in + self.didToggleSubtitles(newValue: newSubtitlesEnabled) + }.store(in: &viewModelListeners) + + viewModel.$jumpBackwardLength.sink { newJumpBackwardLength in + self.refreshJumpBackwardOverlayView(with: newJumpBackwardLength) + }.store(in: &viewModelListeners) + + viewModel.$jumpForwardLength.sink { newJumpForwardLength in + self.refreshJumpForwardOverlayView(with: newJumpForwardLength) + }.store(in: &viewModelListeners) + } + + func setMediaPlayerTimeAtCurrentSlider() { + // Necessary math as VLCMediaPlayer doesn't work well + // by just setting the position + let runTimeTicks = viewModel.item.runTimeTicks ?? 0 + let videoPosition = Double(vlcMediaPlayer.time.intValue / 1000) + let videoDuration = Double(runTimeTicks / 10_000_000) + let secondsScrubbedTo = round(viewModel.sliderPercentage * videoDuration) + let newPositionOffset = secondsScrubbedTo - videoPosition + + if newPositionOffset > 0 { + vlcMediaPlayer.jumpForward(Int32(newPositionOffset)) + } else { + vlcMediaPlayer.jumpBackward(Int32(abs(newPositionOffset))) + } + } +} + +// MARK: Show/Hide Overlay + +extension LiveTVPlayerViewController { + private func showOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 1 else { return } + + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 1 + } + } + + private func hideOverlay() { + guard !UIAccessibility.isVoiceOverRunning else { return } + + guard let overlayHostingController = currentOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 0 else { return } + + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 0 + } + } + + private func toggleOverlay() { + guard let overlayHostingController = currentOverlayHostingController else { return } + + if overlayHostingController.view.alpha < 1 { + showOverlay() + } else { + hideOverlay() + } + } +} + +// MARK: Show/Hide System Control + +extension LiveTVPlayerViewController { + private func showBrightnessOverlay() { + guard !displayingOverlay else { return } + + let imageAttachment = NSTextAttachment() + imageAttachment.image = UIImage(systemName: "sun.max", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? + .withTintColor(.white) + + let attributedString = NSMutableAttributedString() + attributedString.append(.init(attachment: imageAttachment)) + attributedString.append(.init(string: " \(String(format: "%.0f", UIScreen.main.brightness * 100))%")) + systemControlOverlayLabel.attributedText = attributedString + systemControlOverlayLabel.layer.removeAllAnimations() + + UIView.animate(withDuration: 0.1) { + self.systemControlOverlayLabel.alpha = 1 + } + } + + private func showVolumeOverlay() { + guard !displayingOverlay, + let value = (volumeView.subviews.first as? UISlider)?.value else { return } + + let imageAttachment = NSTextAttachment() + imageAttachment.image = UIImage(systemName: "speaker.wave.2", withConfiguration: UIImage.SymbolConfiguration(pointSize: 48))? + .withTintColor(.white) + + let attributedString = NSMutableAttributedString() + attributedString.append(.init(attachment: imageAttachment)) + attributedString.append(.init(string: " \(String(format: "%.0f", value * 100))%")) + systemControlOverlayLabel.attributedText = attributedString + systemControlOverlayLabel.layer.removeAllAnimations() + + UIView.animate(withDuration: 0.1) { + self.systemControlOverlayLabel.alpha = 1 + } + } + + private func hideSystemControlOverlay() { + UIView.animate(withDuration: 0.75) { + self.systemControlOverlayLabel.alpha = 0 + } + } +} + +// MARK: Show/Hide Jump + +extension LiveTVPlayerViewController { + private func flashJumpBackwardOverlay() { + guard !displayingOverlay, let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } + + currentJumpBackwardOverlayView.layer.removeAllAnimations() + + UIView.animate(withDuration: 0.1) { + currentJumpBackwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpBackwardOverlay() + } + } + + private func hideJumpBackwardOverlay() { + guard let currentJumpBackwardOverlayView = currentJumpBackwardOverlayView else { return } + + UIView.animate(withDuration: 0.3) { + currentJumpBackwardOverlayView.alpha = 0 + } + } + + private func flashJumpFowardOverlay() { + guard !displayingOverlay, let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } + + currentJumpForwardOverlayView.layer.removeAllAnimations() + + UIView.animate(withDuration: 0.1) { + currentJumpForwardOverlayView.alpha = 1 + } completion: { _ in + self.hideJumpForwardOverlay() + } + } + + private func hideJumpForwardOverlay() { + guard let currentJumpForwardOverlayView = currentJumpForwardOverlayView else { return } + + UIView.animate(withDuration: 0.3) { + currentJumpForwardOverlayView.alpha = 0 + } + } +} + +// MARK: Hide/Show Chapters + +extension LiveTVPlayerViewController { + private func showChaptersOverlay() { + guard let overlayHostingController = currentChapterOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 1 else { return } + + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 1 + } + } + + private func hideChaptersOverlay() { + guard let overlayHostingController = currentChapterOverlayHostingController else { return } + + guard overlayHostingController.view.alpha != 0 else { return } + + UIView.animate(withDuration: 0.2) { + overlayHostingController.view.alpha = 0 + } + } +} + +// MARK: OverlayTimer + +extension LiveTVPlayerViewController { + private func restartOverlayDismissTimer(interval: Double = 3) { + overlayDismissTimer?.invalidate() + overlayDismissTimer = Timer.scheduledTimer(timeInterval: interval, target: self, selector: #selector(dismissTimerFired), + userInfo: nil, repeats: false) + } + + @objc + private func dismissTimerFired() { + hideOverlay() + } + + private func stopOverlayDismissTimer() { + overlayDismissTimer?.invalidate() + } +} + +// MARK: VLCMediaPlayerDelegate + +extension LiveTVPlayerViewController: VLCMediaPlayerDelegate { + // MARK: mediaPlayerStateChanged + + func mediaPlayerStateChanged(_ aNotification: Notification) { + // Don't show buffering if paused, usually here while scrubbing + if vlcMediaPlayer.state == .buffering, viewModel.playerState == .paused { + return + } + + viewModel.playerState = vlcMediaPlayer.state + + if vlcMediaPlayer.state == VLCMediaPlayerState.ended { + if viewModel.autoplayEnabled, viewModel.nextItemVideoPlayerViewModel != nil { + didSelectPlayNextItem() + } else { + didSelectClose() + } + } + } + + // MARK: mediaPlayerTimeChanged + + func mediaPlayerTimeChanged(_ aNotification: Notification) { + if !viewModel.sliderIsScrubbing { + viewModel.sliderPercentage = Double(vlcMediaPlayer.position) + } + + // Have to manually set playing because VLCMediaPlayer doesn't + // properly set it itself + if abs(currentPlayerTicks - lastPlayerTicks) >= 10000 { + viewModel.playerState = VLCMediaPlayerState.playing + } + + // If needing to fix subtitle streams during playback + if vlcMediaPlayer.currentVideoSubTitleIndex != viewModel.selectedSubtitleStreamIndex, + viewModel.subtitlesEnabled + { + didSelectSubtitleStream(index: viewModel.selectedSubtitleStreamIndex) + } + + // If needing to fix audio stream during playback + if vlcMediaPlayer.currentAudioTrackIndex != viewModel.selectedAudioStreamIndex { + didSelectAudioStream(index: viewModel.selectedAudioStreamIndex) + } + + lastPlayerTicks = currentPlayerTicks + + // Send progress report every 5 seconds + if abs(lastProgressReportTicks - currentPlayerTicks) >= 500_000_000 { + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + } +} + +// MARK: PlayerOverlayDelegate and more + +extension LiveTVPlayerViewController: PlayerOverlayDelegate { + func didSelectAudioStream(index: Int) { + vlcMediaPlayer.currentAudioTrackIndex = Int32(index) + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + /// Do not call when setting to index -1 + func didSelectSubtitleStream(index: Int) { + viewModel.subtitlesEnabled = true + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(index) + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + @objc + func didSelectClose() { + vlcMediaPlayer.stop() + + viewModel.sendStopReport() + + dismiss(animated: true, completion: nil) + } + + func didToggleSubtitles(newValue: Bool) { + if newValue { + vlcMediaPlayer.currentVideoSubTitleIndex = Int32(viewModel.selectedSubtitleStreamIndex) + } else { + vlcMediaPlayer.currentVideoSubTitleIndex = -1 + } + } + + // TODO: Implement properly in overlays + func didSelectMenu() { + stopOverlayDismissTimer() + } + + // TODO: Implement properly in overlays + func didDeselectMenu() { + restartOverlayDismissTimer() + } + + @objc + func didSelectBackward() { + flashJumpBackwardOverlay() + + vlcMediaPlayer.jumpBackward(viewModel.jumpBackwardLength.rawValue) + + if displayingOverlay { + restartOverlayDismissTimer() + } + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + @objc + func didSelectForward() { + flashJumpFowardOverlay() + + vlcMediaPlayer.jumpForward(viewModel.jumpForwardLength.rawValue) + + if displayingOverlay { + restartOverlayDismissTimer() + } + + viewModel.sendProgressReport() + + lastProgressReportTicks = currentPlayerTicks + } + + @objc + func didSelectMain() { + switch viewModel.playerState { + case .buffering: + vlcMediaPlayer.play() + restartOverlayDismissTimer() + case .playing: + viewModel.sendPauseReport(paused: true) + vlcMediaPlayer.pause() + restartOverlayDismissTimer(interval: 5) + case .paused: + viewModel.sendPauseReport(paused: false) + vlcMediaPlayer.play() + restartOverlayDismissTimer() + default: () + } + } + + func didGenerallyTap(point: CGPoint?) { + toggleOverlay() + + restartOverlayDismissTimer(interval: 5) + } + + func didLongPress() {} + + 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 + } +}