diff --git a/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift b/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift index aa5f65c9..5fe96e15 100644 --- a/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift +++ b/Shared/Coordinators/VideoPlayerCoordinator/iOSLiveTVVideoPlayerCoordinator.swift @@ -27,14 +27,14 @@ final class LiveTVVideoPlayerCoordinator: NavigationCoordinatable { @ViewBuilder func makeStart() -> some View { -// if Defaults[.Experimental.liveTVNativePlayer] { -// LiveTVNativeVideoPlayerView(viewModel: viewModel) -// .navigationBarHidden(true) -// .ignoresSafeArea() -// } else { + if Defaults[.Experimental.liveTVNativePlayer] { + LiveTVNativePlayerView(viewModel: viewModel) + .navigationBarHidden(true) + .ignoresSafeArea() + } else { LiveTVPlayerView(viewModel: viewModel) .navigationBarHidden(true) .ignoresSafeArea() -// } + } } } diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index f5c5fdc8..dcf2d11c 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -263,6 +263,7 @@ C4534981279A3F140045F1E2 /* tvOSLiveTVOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534980279A3F140045F1E2 /* tvOSLiveTVOverlay.swift */; }; C4534983279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */; }; C4534985279A40C60045F1E2 /* LiveTVVideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4534984279A40C50045F1E2 /* LiveTVVideoPlayerView.swift */; }; + C45640D0281A43EF007096DE /* LiveTVNativePlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45640CF281A43EF007096DE /* LiveTVNativePlayerViewController.swift */; }; C45942C527F67DA400C54FE7 /* LiveTVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */; }; C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */; }; C45942C927F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942C827F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift */; }; @@ -751,6 +752,7 @@ C4534980279A3F140045F1E2 /* tvOSLiveTVOverlay.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVOverlay.swift; sourceTree = ""; }; C4534982279A40990045F1E2 /* tvOSLiveTVVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = tvOSLiveTVVideoPlayerCoordinator.swift; sourceTree = ""; }; C4534984279A40C50045F1E2 /* LiveTVVideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVVideoPlayerView.swift; sourceTree = ""; }; + C45640CF281A43EF007096DE /* LiveTVNativePlayerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTVNativePlayerViewController.swift; sourceTree = ""; }; C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVCoordinator.swift; sourceTree = ""; }; C45942C827F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSLiveTVVideoPlayerCoordinator.swift; sourceTree = ""; }; C45942CA27F6984100C54FE7 /* LiveTVPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVPlayerViewController.swift; sourceTree = ""; }; @@ -1742,6 +1744,7 @@ isa = PBXGroup; children = ( E13AD72D2798BC8D00FDCEE8 /* NativePlayerViewController.swift */, + C45640CF281A43EF007096DE /* LiveTVNativePlayerViewController.swift */, E1002B692793E12E00E47059 /* Overlays */, E1C812B5277A8E5D00918266 /* PlayerOverlayDelegate.swift */, E1C812B8277A8E5D00918266 /* VLCPlayerView.swift */, @@ -2515,6 +2518,7 @@ 5D64683D277B1649009E09AE /* PreferenceUIHostingSwizzling.swift in Sources */, C45942C927F697CA00C54FE7 /* iOSLiveTVVideoPlayerCoordinator.swift in Sources */, E13DD3C827164B1E009D4DAF /* UIDeviceExtensions.swift in Sources */, + C45640D0281A43EF007096DE /* LiveTVNativePlayerViewController.swift in Sources */, E10EAA53277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, E1AD104D26D96CE3003E4A08 /* BaseItemDtoExtensions.swift in Sources */, E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */, diff --git a/Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json b/Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json index 5d336734..737e9109 100644 --- a/Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json +++ b/Swiftfin/Assets.xcassets/BackgroundColor.colorset/Contents.json @@ -22,10 +22,10 @@ "color" : { "color-space" : "srgb", "components" : { - "alpha" : "0.100", - "blue" : "0.000", - "green" : "0.000", - "red" : "0.000" + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" } }, "idiom" : "universal" @@ -40,10 +40,10 @@ "color" : { "color-space" : "srgb", "components" : { - "alpha" : "0.100", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" } }, "idiom" : "universal" diff --git a/Swiftfin/Assets.xcassets/BackgroundSecondaryColor.colorset/Contents.json b/Swiftfin/Assets.xcassets/BackgroundSecondaryColor.colorset/Contents.json new file mode 100644 index 00000000..5d336734 --- /dev/null +++ b/Swiftfin/Assets.xcassets/BackgroundSecondaryColor.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.100", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.100", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/ShadowColor.colorset/Contents.json b/Swiftfin/Assets.xcassets/ShadowColor.colorset/Contents.json new file mode 100644 index 00000000..1264d8ba --- /dev/null +++ b/Swiftfin/Assets.xcassets/ShadowColor.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.250", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.250", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Assets.xcassets/TextHighlightColor.colorset/Contents.json b/Swiftfin/Assets.xcassets/TextHighlightColor.colorset/Contents.json new file mode 100644 index 00000000..76b961cc --- /dev/null +++ b/Swiftfin/Assets.xcassets/TextHighlightColor.colorset/Contents.json @@ -0,0 +1,56 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "light" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.000", + "green" : "0.000", + "red" : "0.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Swiftfin/Views/LiveTVChannelItemWideElement.swift b/Swiftfin/Views/LiveTVChannelItemWideElement.swift index fdf9ffd0..f5dd9036 100644 --- a/Swiftfin/Views/LiveTVChannelItemWideElement.swift +++ b/Swiftfin/Views/LiveTVChannelItemWideElement.swift @@ -62,97 +62,91 @@ struct LiveTVChannelItemWideElement: View { var body: some View { ZStack { - HStack { - ZStack(alignment: .center) { - ImageView(channel.getPrimaryImage(maxWidth: 128)) - .aspectRatio(contentMode: .fit) - .padding(.init(top: 0, leading: 0, bottom: 8, trailing: 0)) - VStack(alignment: .center) { - Spacer() - .frame(maxHeight: .infinity) - GeometryReader { gp in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 3) - .fill(Color.gray) - .opacity(0.4) - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 6, maxHeight: 6) - RoundedRectangle(cornerRadius: 6) - .fill(Color.jellyfinPurple) - .frame(width: CGFloat(progressPercent * gp.size.width), height: 6) + ZStack { + HStack { + ZStack(alignment: .center) { + ImageView(channel.getPrimaryImage(maxWidth: 128)) + .aspectRatio(contentMode: .fit) + .padding(.init(top: 0, leading: 0, bottom: 8, trailing: 0)) + VStack(alignment: .center) { + Spacer() + .frame(maxHeight: .infinity) + GeometryReader { gp in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 3) + .fill(Color.gray) + .opacity(0.4) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 6, maxHeight: 6) + RoundedRectangle(cornerRadius: 6) + .fill(Color.jellyfinPurple) + .frame(width: CGFloat(progressPercent * gp.size.width), height: 6) + } } + .frame(height: 6, alignment: .center) + .padding(.init(top: 0, leading: 4, bottom: 0, trailing: 4)) } - .frame(height: 6, alignment: .center) - .padding(.init(top: 0, leading: 4, bottom: 0, trailing: 4)) - } - if loading { - - ProgressView() - - } - } - .aspectRatio(1.0, contentMode: .fit) - VStack(alignment: .leading) { - let channelNumber = channel.number != nil ? "\(channel.number ?? "") " : "" - let channelName = "\(channelNumber)\(channel.name ?? "?")" - Text(channelName) - .font(.body) - .lineLimit(1) - .frame(alignment: .leading) - HStack(alignment: .top) { - Text(currentProgramText.timeDisplay) - .font(.footnote) - .lineLimit(2) - .foregroundColor(.green) - .frame(width: 40) - Text(currentProgramText.title) - .font(.footnote) - .lineLimit(2) - .foregroundColor(.green) - } - if nextProgramsText.count > 0, - let nextItem = nextProgramsText[0] { - HStack(alignment: .top) { - Text(nextItem.timeDisplay) - .font(.footnote) - .lineLimit(2) - .foregroundColor(.gray) - .frame(width: 40) - Text(nextItem.title) - .font(.footnote) - .lineLimit(2) - .foregroundColor(.gray) + if loading { + + ProgressView() + } } - if nextProgramsText.count > 1, - let nextItem2 = nextProgramsText[1] { - HStack(alignment: .top) { - Text(nextItem2.timeDisplay) - .font(.footnote) - .lineLimit(2) - .foregroundColor(.gray) - .frame(width: 40) - Text(nextItem2.title) - .font(.footnote) - .lineLimit(2) - .foregroundColor(.gray) + .aspectRatio(1.0, contentMode: .fit) + VStack(alignment: .leading) { + let channelNumber = channel.number != nil ? "\(channel.number ?? "") " : "" + let channelName = "\(channelNumber)\(channel.name ?? "?")" + Text(channelName) + .font(.body) + .lineLimit(1) + .foregroundColor(Color.jellyfinPurple) + .frame(alignment: .leading) + .padding(.init(top: 0, leading: 0, bottom: 4, trailing: 0)) + programLabel(timeText: currentProgramText.timeDisplay, titleText: currentProgramText.title, color: Color("TextHighlightColor")) + if nextProgramsText.count > 0, + let nextItem = nextProgramsText[0] { + programLabel(timeText: nextItem.timeDisplay, titleText: nextItem.title, color: Color.gray) } + if nextProgramsText.count > 1, + let nextItem2 = nextProgramsText[1] { + programLabel(timeText: nextItem2.timeDisplay, titleText: nextItem2.title, color: Color.gray) + } + Spacer() } Spacer() } - Spacer() + .frame(alignment: .leading) + .padding() + .opacity(loading ? 0.5 : 1.0) + } + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color("BackgroundSecondaryColor")) + ) + .frame(height: 128) + .onTapGesture { + onSelect { loadingState in + loading = loadingState + } } - .frame(alignment: .leading) - .padding() - .opacity(loading ? 0.5 : 1.0) } - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous).fill(Color("BackgroundColor")) - ) - .frame(height: 128) - .onTapGesture { - onSelect { loadingState in - loading = loadingState - } + .background{ + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(Color("BackgroundColor")) + .shadow(color: Color("ShadowColor"), radius: 4, x: 0, y: 0) + } + } + + @ViewBuilder + func programLabel(timeText: String, titleText: String, color: Color) -> some View { + HStack(alignment: .top) { + Text(timeText) + .font(.footnote) + .lineLimit(2) + .foregroundColor(color) + .frame(width: 38, alignment: .leading) + Text(titleText) + .font(.footnote) + .lineLimit(2) + .foregroundColor(color) } } } diff --git a/Swiftfin/Views/LiveTVChannelsView.swift b/Swiftfin/Views/LiveTVChannelsView.swift index 318a819d..02b03e2b 100644 --- a/Swiftfin/Views/LiveTVChannelsView.swift +++ b/Swiftfin/Views/LiveTVChannelsView.swift @@ -103,7 +103,7 @@ struct LiveTVChannelsView: View { ) let groupSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1.0), - heightDimension: .absolute(132) + heightDimension: .absolute(144) ) let group = NSCollectionLayoutGroup.horizontal( layoutSize: groupSize, @@ -114,7 +114,7 @@ struct LiveTVChannelsView: View { } else { if isPortrait { let itemSize = NSCollectionLayoutSize( - widthDimension: .absolute(UIScreen.main.bounds.width - 2), + widthDimension: .absolute(UIScreen.main.bounds.width - 32), heightDimension: .fractionalHeight(1) ) let item = NSCollectionLayoutItem(layoutSize: itemSize) @@ -124,7 +124,7 @@ struct LiveTVChannelsView: View { ) let groupSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1.0), - heightDimension: .absolute(132) + heightDimension: .absolute(144) ) let group = NSCollectionLayoutGroup.horizontal( layoutSize: groupSize, @@ -149,7 +149,7 @@ struct LiveTVChannelsView: View { ) let groupSize = NSCollectionLayoutSize( widthDimension: .fractionalWidth(1.0), - heightDimension: .absolute(132) + heightDimension: .absolute(144) ) let group = NSCollectionLayoutGroup.horizontal( layoutSize: groupSize, diff --git a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift index 1ac48361..f1137b0e 100644 --- a/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift +++ b/Swiftfin/Views/SettingsView/ExperimentalSettingsView.swift @@ -19,7 +19,11 @@ struct ExperimentalSettingsView: View { var nativePlayer @Default(.Experimental.liveTVAlphaEnabled) var liveTVAlphaEnabled - + @Default(.Experimental.liveTVForceDirectPlay) + var liveTVForceDirectPlay + @Default(.Experimental.liveTVNativePlayer) + var liveTVNativePlayer + var body: some View { Form { Section { @@ -38,6 +42,10 @@ struct ExperimentalSettingsView: View { Toggle("Live TV (Alpha)", isOn: $liveTVAlphaEnabled) + Toggle("Live TV Force Direct Play", isOn: $liveTVForceDirectPlay) + + Toggle("Live TV Native Player", isOn: $liveTVNativePlayer) + } header: { Text("Live TV") } diff --git a/Swiftfin/Views/VideoPlayer/LiveTVNativePlayerViewController.swift b/Swiftfin/Views/VideoPlayer/LiveTVNativePlayerViewController.swift new file mode 100644 index 00000000..d5405c04 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/LiveTVNativePlayerViewController.swift @@ -0,0 +1,114 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import AVKit +import Combine +import JellyfinAPI +import UIKit + +class LiveTVNativePlayerViewController: AVPlayerViewController { + + let viewModel: VideoPlayerViewModel + + var timeObserverToken: Any? + + var lastProgressTicks: Int64 = 0 + + private var cancellables = Set() + + init(viewModel: VideoPlayerViewModel) { + + self.viewModel = viewModel + + super.init(nibName: nil, bundle: nil) + + let player: AVPlayer + + if let transcodedStreamURL = viewModel.transcodedStreamURL { + player = AVPlayer(url: transcodedStreamURL) + } else { + player = AVPlayer(url: viewModel.hlsStreamURL) + } + + player.appliesMediaSelectionCriteriaAutomatically = false + + let timeScale = CMTimeScale(NSEC_PER_SEC) + let time = CMTime(seconds: 5, preferredTimescale: timeScale) + + timeObserverToken = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in + if time.seconds != 0 { + self?.sendProgressReport(seconds: time.seconds) + } + } + + self.player = player + + self.allowsPictureInPicturePlayback = true + self.player?.allowsExternalPlayback = true + } + + private func createMetadataItem(for identifier: AVMetadataIdentifier, + value: Any) -> AVMetadataItem + { + let item = AVMutableMetadataItem() + item.identifier = identifier + item.value = value as? NSCopying & NSObjectProtocol + // Specify "und" to indicate an undefined language. + item.extendedLanguageTag = "und" + return item.copy() as! AVMetadataItem + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + stop() + removePeriodicTimeObserver() + } + + func removePeriodicTimeObserver() { + if let timeObserverToken = timeObserverToken { + player?.removeTimeObserver(timeObserverToken) + self.timeObserverToken = nil + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + player?.seek(to: CMTimeMake(value: viewModel.currentSecondTicks, timescale: 10_000_000), + toleranceBefore: CMTimeMake(value: 1, timescale: 1), toleranceAfter: CMTimeMake(value: 1, timescale: 1), + completionHandler: { _ in + self.play() + }) + } + + private func play() { + player?.play() + + viewModel.sendPlayReport() + } + + private func sendProgressReport(seconds: Double) { + viewModel.setSeconds(Int64(seconds)) + viewModel.sendProgressReport() + } + + private func stop() { + self.player?.pause() + viewModel.sendStopReport() + } +} diff --git a/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift b/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift index 499acb2e..d1c96f24 100644 --- a/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift +++ b/Swiftfin/Views/VideoPlayer/LiveTVPlayerView.swift @@ -9,19 +9,19 @@ import SwiftUI import UIKit -//struct NativePlayerView: UIViewControllerRepresentable { -// -// let viewModel: VideoPlayerViewModel -// -// typealias UIViewControllerType = NativePlayerViewController -// -// func makeUIViewController(context: Context) -> NativePlayerViewController { -// -// NativePlayerViewController(viewModel: viewModel) -// } -// -// func updateUIViewController(_ uiViewController: NativePlayerViewController, context: Context) {} -//} +struct LiveTVNativePlayerView: UIViewControllerRepresentable { + + let viewModel: VideoPlayerViewModel + + typealias UIViewControllerType = LiveTVNativePlayerViewController + + func makeUIViewController(context: Context) -> LiveTVNativePlayerViewController { + + LiveTVNativePlayerViewController(viewModel: viewModel) + } + + func updateUIViewController(_ uiViewController: LiveTVNativePlayerViewController, context: Context) {} +} struct LiveTVPlayerView: UIViewControllerRepresentable {