diff --git a/Shared/Coordinators/LiveTVChannelsCoordinator.swift b/Shared/Coordinators/LiveTVChannelsCoordinator.swift index 27aa9d81..ad836c92 100644 --- a/Shared/Coordinators/LiveTVChannelsCoordinator.swift +++ b/Shared/Coordinators/LiveTVChannelsCoordinator.swift @@ -21,24 +21,12 @@ final class LiveTVChannelsCoordinator: NavigationCoordinatable { #if os(tvOS) @Route(.fullScreen) - var videoPlayer = makeVideoPlayer + var liveVideoPlayer = makeLiveVideoPlayer #endif #if os(tvOS) - func makeVideoPlayer(manager: VideoPlayerManager) -> NavigationViewCoordinator { - BasicNavigationViewCoordinator { - Group { - if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin { - VideoPlayer(manager: manager) - .overlay { - VideoPlayer.Overlay() - } - } else { - NativeVideoPlayer(manager: manager) - } - } - } - .inNavigationViewCoordinator() + func makeLiveVideoPlayer(manager: LiveVideoPlayerManager) -> NavigationViewCoordinator { + NavigationViewCoordinator(LiveVideoPlayerCoordinator(manager: manager)) } #endif diff --git a/Shared/Coordinators/LiveTVProgramsCoordinator.swift b/Shared/Coordinators/LiveTVProgramsCoordinator.swift index cfb1d53c..b6a89314 100644 --- a/Shared/Coordinators/LiveTVProgramsCoordinator.swift +++ b/Shared/Coordinators/LiveTVProgramsCoordinator.swift @@ -26,20 +26,8 @@ final class LiveTVProgramsCoordinator: NavigationCoordinatable { #endif #if os(tvOS) - func makeVideoPlayer(manager: VideoPlayerManager) -> NavigationViewCoordinator { - BasicNavigationViewCoordinator { - Group { - if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin { - VideoPlayer(manager: manager) - .overlay { - VideoPlayer.Overlay() - } - } else { - NativeVideoPlayer(manager: manager) - } - } - } - .inNavigationViewCoordinator() + func makeVideoPlayer(manager: LiveVideoPlayerManager) -> NavigationViewCoordinator { + NavigationViewCoordinator(LiveVideoPlayerCoordinator(manager: manager)) } #endif diff --git a/Shared/Coordinators/LiveTVTabCoordinator.swift b/Shared/Coordinators/LiveTVTabCoordinator.swift index f95164a4..58981f0a 100644 --- a/Shared/Coordinators/LiveTVTabCoordinator.swift +++ b/Shared/Coordinators/LiveTVTabCoordinator.swift @@ -13,30 +13,18 @@ import SwiftUI final class LiveTVTabCoordinator: TabCoordinatable { var child = TabChild(startingItems: [ - \LiveTVTabCoordinator.programs, \LiveTVTabCoordinator.channels, + \LiveTVTabCoordinator.programs, \LiveTVTabCoordinator.home, ]) - @Route(tabItem: makeProgramsTab) - var programs = makePrograms @Route(tabItem: makeChannelsTab) var channels = makeChannels + @Route(tabItem: makeProgramsTab) + var programs = makePrograms @Route(tabItem: makeHomeTab) var home = makeHome - func makePrograms() -> NavigationViewCoordinator { - NavigationViewCoordinator(LiveTVProgramsCoordinator()) - } - - @ViewBuilder - func makeProgramsTab(isActive: Bool) -> some View { - HStack { - Image(systemName: "tv") - L10n.programs.text - } - } - func makeChannels() -> NavigationViewCoordinator { NavigationViewCoordinator(LiveTVChannelsCoordinator()) } @@ -49,6 +37,18 @@ final class LiveTVTabCoordinator: TabCoordinatable { } } + func makePrograms() -> NavigationViewCoordinator { + NavigationViewCoordinator(LiveTVProgramsCoordinator()) + } + + @ViewBuilder + func makeProgramsTab(isActive: Bool) -> some View { + HStack { + Image(systemName: "tv") + L10n.programs.text + } + } + func makeHome() -> LiveTVHomeView { LiveTVHomeView() } diff --git a/Shared/Coordinators/LiveVideoPlayerCoordinator.swift b/Shared/Coordinators/LiveVideoPlayerCoordinator.swift new file mode 100644 index 00000000..8915fa40 --- /dev/null +++ b/Shared/Coordinators/LiveVideoPlayerCoordinator.swift @@ -0,0 +1,59 @@ +// +// 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) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Foundation +import JellyfinAPI +import PreferencesView +import Stinsen +import SwiftUI + +final class LiveVideoPlayerCoordinator: NavigationCoordinatable { + + let stack = NavigationStack(initial: \LiveVideoPlayerCoordinator.start) + + @Root + var start = makeStart + + let videoPlayerManager: LiveVideoPlayerManager + + init(manager: LiveVideoPlayerManager) { + self.videoPlayerManager = manager + } + + @ViewBuilder + func makeStart() -> some View { + #if os(iOS) + + PreferencesView { + Group { + if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin { + LiveVideoPlayer(manager: self.videoPlayerManager) + } else { + LiveNativeVideoPlayer(manager: self.videoPlayerManager) + } + } + } + .ignoresSafeArea() + + #else + + PreferencesView { + Group { + if Defaults[.VideoPlayer.videoPlayerType] == .swiftfin { + LiveVideoPlayer(manager: self.videoPlayerManager) + } else { + LiveNativeVideoPlayer(manager: self.videoPlayerManager) + } + } + } + .ignoresSafeArea() + + #endif + } +} diff --git a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift index e0ec1e73..6a33e3a2 100644 --- a/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift +++ b/Shared/Coordinators/MainCoordinator/iOSMainCoordinator.swift @@ -29,6 +29,8 @@ final class MainCoordinator: NavigationCoordinatable { var serverList = makeServerList @Route(.fullScreen) var videoPlayer = makeVideoPlayer + @Route(.fullScreen) + var liveVideoPlayer = makeLiveVideoPlayer private var cancellables = Set() @@ -99,4 +101,8 @@ final class MainCoordinator: NavigationCoordinatable { func makeVideoPlayer(manager: VideoPlayerManager) -> VideoPlayerCoordinator { VideoPlayerCoordinator(manager: manager) } + + func makeLiveVideoPlayer(manager: LiveVideoPlayerManager) -> LiveVideoPlayerCoordinator { + LiveVideoPlayerCoordinator(manager: manager) + } } diff --git a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+VideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+VideoPlayerViewModel.swift index 0e615566..d70913b3 100644 --- a/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+VideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPI/BaseItemDto/BaseItemDto+VideoPlayerViewModel.swift @@ -10,6 +10,7 @@ import Defaults import Factory import Foundation import JellyfinAPI +import Logging extension BaseItemDto { @@ -38,8 +39,65 @@ extension BaseItemDto { guard let matchingMediaSource = response.value.mediaSources? .first(where: { $0.eTag == mediaSource.eTag && $0.id == mediaSource.id }) - else { throw JellyfinAPIError("Matching media source not in playback info") } + else { + throw JellyfinAPIError("Matching media source not in playback info") + } return try matchingMediaSource.videoPlayerViewModel(with: self, playSessionID: response.value.playSessionID!) } + + func liveVideoPlayerViewModel(with mediaSource: MediaSourceInfo, logger: Logger) async throws -> VideoPlayerViewModel { + + let currentVideoPlayerType = Defaults[.VideoPlayer.videoPlayerType] + // TODO: fix bitrate settings + let tempOverkillBitrate = 360_000_000 + var profile = DeviceProfile.build(for: currentVideoPlayerType, maxBitrate: tempOverkillBitrate) + if Defaults[.Experimental.liveTVForceDirectPlay] { + profile.directPlayProfiles = [DirectPlayProfile(type: .video)] + } + + let userSession = Container.userSession.callAsFunction() + + let playbackInfo = PlaybackInfoDto(deviceProfile: profile) + let playbackInfoParameters = Paths.GetPostedPlaybackInfoParameters( + userID: userSession.user.id, + maxStreamingBitrate: tempOverkillBitrate + ) + + let request = Paths.getPostedPlaybackInfo( + itemID: self.id!, + parameters: playbackInfoParameters, + playbackInfo + ) + + let response = try await userSession.client.send(request) + logger.debug("liveVideoPlayerViewModel response received") + + var matchingMediaSource: MediaSourceInfo? + if let responseMediaSources = response.value.mediaSources { + for responseMediaSource in responseMediaSources { + if let openToken = responseMediaSource.openToken, let mediaSourceId = mediaSource.id { + if openToken.contains(mediaSourceId) { + logger.debug("liveVideoPlayerViewModel found mediaSource with through openToken mediaSourceId match") + matchingMediaSource = responseMediaSource + } + } + } + if matchingMediaSource == nil && !responseMediaSources.isEmpty { + // Didn't find a match, but maybe we can just grab the first item in the response + matchingMediaSource = responseMediaSources.first + logger.debug("liveVideoPlayerViewModel resorting to first media source in the response") + } + } + guard let matchingMediaSource else { + logger.debug("liveVideoPlayerViewModel no matchingMediaSource found, throwing error") + throw JellyfinAPIError("Matching media source not in playback info") + } + + logger.debug("liveVideoPlayerViewModel matchingMediaSource being returned") + return try matchingMediaSource.liveVideoPlayerViewModel( + with: self, + playSessionID: response.value.playSessionID! + ) + } } diff --git a/Shared/Extensions/JellyfinAPI/MediaSourceInfo/MediaSourceInfo+ItemVideoPlayerViewModel.swift b/Shared/Extensions/JellyfinAPI/MediaSourceInfo/MediaSourceInfo+ItemVideoPlayerViewModel.swift index 757a2911..f67f43a8 100644 --- a/Shared/Extensions/JellyfinAPI/MediaSourceInfo/MediaSourceInfo+ItemVideoPlayerViewModel.swift +++ b/Shared/Extensions/JellyfinAPI/MediaSourceInfo/MediaSourceInfo+ItemVideoPlayerViewModel.swift @@ -28,7 +28,6 @@ extension MediaSourceInfo { playbackURL = fullTranscodeURL streamType = .transcode } else { - let videoStreamParameters = Paths.GetVideoStreamParameters( isStatic: true, tag: item.etag, @@ -66,4 +65,56 @@ extension MediaSourceInfo { streamType: streamType ) } + + func liveVideoPlayerViewModel(with item: BaseItemDto, playSessionID: String) throws -> VideoPlayerViewModel { + let userSession = Container.userSession.callAsFunction() + let playbackURL: URL + let streamType: StreamType + + if let transcodingURL, !Defaults[.Experimental.liveTVForceDirectPlay] { + guard let fullTranscodeURL = URL(string: transcodingURL, relativeTo: userSession.server.currentURL) + else { throw JellyfinAPIError("Unable to construct transcoded url") } + playbackURL = fullTranscodeURL + streamType = .transcode + } else if self.isSupportsDirectPlay ?? false, let path = self.path, let playbackUrl = URL(string: path) { + playbackURL = playbackUrl + streamType = .direct + } else { + let videoStreamParameters = Paths.GetVideoStreamParameters( + isStatic: true, + tag: item.etag, + playSessionID: playSessionID, + mediaSourceID: id + ) + + let videoStreamRequest = Paths.getVideoStream( + itemID: item.id!, + parameters: videoStreamParameters + ) + + guard let fullURL = userSession.client.fullURL(with: videoStreamRequest) else { + throw JellyfinAPIError("Unable to construct transcoded url") + } + playbackURL = fullURL + streamType = .direct + } + + let videoStreams = mediaStreams?.filter { $0.type == .video } ?? [] + let audioStreams = mediaStreams?.filter { $0.type == .audio } ?? [] + let subtitleStreams = mediaStreams?.filter { $0.type == .subtitle } ?? [] + + return .init( + playbackURL: playbackURL, + item: item, + mediaSource: self, + playSessionID: playSessionID, + videoStreams: videoStreams, + audioStreams: audioStreams, + subtitleStreams: subtitleStreams, + selectedAudioStreamIndex: defaultAudioStreamIndex ?? -1, + selectedSubtitleStreamIndex: defaultSubtitleStreamIndex ?? -1, + chapters: item.fullChapterInfo, + streamType: streamType + ) + } } diff --git a/Shared/Objects/LiveTVChannelProgram.swift b/Shared/Objects/LiveTVChannelProgram.swift new file mode 100644 index 00000000..6c0956c1 --- /dev/null +++ b/Shared/Objects/LiveTVChannelProgram.swift @@ -0,0 +1,120 @@ +// +// 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) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Foundation +import JellyfinAPI +import UIKit + +struct LiveTVChannelProgram: Hashable { + let id = UUID() + let channel: BaseItemDto + let currentProgram: BaseItemDto? + let programs: [BaseItemDto] +} + +extension LiveTVChannelProgram: Poster { + var displayTitle: String { + guard let currentProgram else { return "None" } + return currentProgram.displayTitle + } + + var title: String { + guard let currentProgram else { return "None" } + switch currentProgram.type { + case .episode: + return currentProgram.seriesName ?? currentProgram.displayTitle + default: + return currentProgram.displayTitle + } + } + + var subtitle: String? { + guard let currentProgram else { return "" } + switch currentProgram.type { + case .episode: + return currentProgram.seasonEpisodeLabel + case .video: + return currentProgram.extraType?.displayTitle + default: + return nil + } + } + + var showTitle: Bool { + guard let currentProgram else { return false } + switch currentProgram.type { + case .episode, .series, .movie, .boxSet, .collectionFolder: + return Defaults[.Customization.showPosterLabels] + default: + return true + } + } + + var typeSystemImage: String? { + guard let currentProgram else { return nil } + switch currentProgram.type { + case .episode, .movie, .series: + return "film" + case .folder: + return "folder.fill" + case .person: + return "person.fill" + case .boxSet: + return "film.stack" + default: return nil + } + } + + func portraitPosterImageSource(maxWidth: CGFloat) -> ImageSource { + guard let currentProgram else { return ImageSource() } + switch currentProgram.type { + case .episode: + return currentProgram.seriesImageSource(.primary, maxWidth: maxWidth) + case .folder: + return ImageSource() + default: + return currentProgram.imageSource(.primary, maxWidth: maxWidth) + } + } + + func landscapePosterImageSources(maxWidth: CGFloat, single: Bool = false) -> [ImageSource] { + guard let currentProgram else { return [] } + switch currentProgram.type { + case .episode: + if single || !Defaults[.Customization.Episodes.useSeriesLandscapeBackdrop] { + return [currentProgram.imageSource(.primary, maxWidth: maxWidth)] + } else { + return [ + currentProgram.seriesImageSource(.thumb, maxWidth: maxWidth), + currentProgram.seriesImageSource(.backdrop, maxWidth: maxWidth), + currentProgram.imageSource(.primary, maxWidth: maxWidth), + ] + } + case .folder: + return [currentProgram.imageSource(.primary, maxWidth: maxWidth)] + case .video: + return [currentProgram.imageSource(.primary, maxWidth: maxWidth)] + default: + return [ + currentProgram.imageSource(.thumb, maxWidth: maxWidth), + currentProgram.imageSource(.backdrop, maxWidth: maxWidth), + ] + } + } + + func cinematicPosterImageSources() -> [ImageSource] { + guard let currentProgram else { return [] } + switch currentProgram.type { + case .episode: + return [currentProgram.seriesImageSource(.backdrop, maxWidth: UIScreen.main.bounds.width)] + default: + return [currentProgram.imageSource(.backdrop, maxWidth: UIScreen.main.bounds.width)] + } + } +} diff --git a/Shared/ViewModels/LiveTVChannelsViewModel.swift b/Shared/ViewModels/LiveTVChannelsViewModel.swift index 9b09b925..9152ea2d 100644 --- a/Shared/ViewModels/LiveTVChannelsViewModel.swift +++ b/Shared/ViewModels/LiveTVChannelsViewModel.swift @@ -8,36 +8,20 @@ import Factory import Foundation +import Get import JellyfinAPI -struct LiveTVChannelProgram: Hashable { - let id = UUID() - let channel: BaseItemDto - let currentProgram: BaseItemDto? - let programs: [BaseItemDto] +extension Notification.Name { + static let livePlayerDismissed = Notification.Name("livePlayerDismissed") } -final class LiveTVChannelsViewModel: ViewModel { +final class LiveTVChannelsViewModel: PagingLibraryViewModel { @Published var channels: [BaseItemDto] = [] @Published var channelPrograms: [LiveTVChannelProgram] = [] -// @Published -// var channelPrograms = [LiveTVChannelProgram]() { -// didSet { -// rows = [] -// let rowChannels = channelPrograms.chunked(into: 4) -// for (index, rowChans) in rowChannels.enumerated() { -// rows.append(LiveTVChannelRow(section: index, items: rowChans.map { LiveTVChannelRowCell(item: $0) })) -// } -// } -// } - -// @Published -// var rows = [LiveTVChannelRow]() - private var programs = [BaseItemDto]() private var channelProgramsList = [BaseItemDto: [BaseItemDto]]() private var timer: Timer? @@ -48,94 +32,35 @@ final class LiveTVChannelsViewModel: ViewModel { return df } - override init() { + init() { super.init() + } - getChannels() - startScheduleCheckTimer() + override func get(page: Int) async throws -> [LiveTVChannelProgram] { + try await getChannelPrograms() } deinit { stopScheduleCheckTimer() } - private func getGuideInfo() { - Task { - let request = Paths.getGuideInfo - guard let _ = try? await userSession.client.send(request) else { return } - - await MainActor.run { - self.getChannels() - } + private func getChannelPrograms() async throws -> [LiveTVChannelProgram] { + let _ = try await getGuideInfo() + let channelsResponse = try await getChannels() + guard let channels = channelsResponse.value.items, !channels.isEmpty else { + return [] } - } - - func getChannels() { - Task { - let parameters = Paths.GetLiveTvChannelsParameters( - userID: userSession.user.id, - startIndex: 0, - limit: 100, - enableImageTypes: [.primary], - fields: .MinimumFields, - enableUserData: false, - enableFavoriteSorting: true - ) - - let request = Paths.getLiveTvChannels(parameters: parameters) - guard let response = try? await userSession.client.send(request) else { return } - - await MainActor.run { - self.channels = response.value.items ?? [] - self.getPrograms() - } + let programsResponse = try await getPrograms(channelIds: channels.compactMap(\.id)) + let fetchedPrograms = programsResponse.value.items ?? [] + await MainActor.run { + self.programs.append(contentsOf: fetchedPrograms) } - } - - private func getPrograms() { - guard channels.isNotEmpty else { - logger.debug("Cannot get programs, channels list empty.") - return - } - let channelIds = channels.compactMap(\.id) - - let minEndDate = Date.now.addComponentsToDate(hours: -1) - let maxStartDate = minEndDate.addComponentsToDate(hours: 6) - - Task { - let parameters = Paths.GetLiveTvProgramsParameters( - channelIDs: channelIds, - userID: userSession.user.id, - maxStartDate: maxStartDate, - minEndDate: minEndDate, - sortBy: ["StartDate"] - ) - - let request = Paths.getLiveTvPrograms(parameters: parameters) - - do { - let response = try await userSession.client.send(request) - - await MainActor.run { - self.programs = response.value.items ?? [] - self.channelPrograms = self.processChannelPrograms() - } - } catch { - print(error.localizedDescription) - } - } - } - - private func processChannelPrograms() -> [LiveTVChannelProgram] { - var channelPrograms = [LiveTVChannelProgram]() + var newChannelPrograms = [LiveTVChannelProgram]() let now = Date() - for channel in self.channels { - let prgs = self.programs.filter { item in + for channel in channels { + let prgs = programs.filter { item in item.channelID == channel.id } - DispatchQueue.main.async { - self.channelProgramsList[channel] = prgs - } var currentPrg: BaseItemDto? for prg in prgs { @@ -148,33 +73,86 @@ final class LiveTVChannelsViewModel: ViewModel { } } - channelPrograms.append(LiveTVChannelProgram(channel: channel, currentProgram: currentPrg, programs: prgs)) + newChannelPrograms.append(LiveTVChannelProgram(channel: channel, currentProgram: currentPrg, programs: prgs)) } - return channelPrograms + + return newChannelPrograms + } + + private func getGuideInfo() async throws -> Response { + let request = Paths.getGuideInfo + return try await userSession.client.send(request) + } + + func getChannels() async throws -> Response { + let parameters = Paths.GetLiveTvChannelsParameters( + userID: userSession.user.id, + startIndex: currentPage * pageSize, + limit: pageSize, + enableImageTypes: [.primary], + fields: ItemFields.MinimumFields, + enableUserData: false, + enableFavoriteSorting: true + ) + let request = Paths.getLiveTvChannels(parameters: parameters) + return try await userSession.client.send(request) + } + + private func getPrograms(channelIds: [String]) async throws -> Response { + let minEndDate = Date.now.addComponentsToDate(hours: -1) + let maxStartDate = minEndDate.addComponentsToDate(hours: 6) + let parameters = Paths.GetLiveTvProgramsParameters( + channelIDs: channelIds, + userID: userSession.user.id, + maxStartDate: maxStartDate, + minEndDate: minEndDate, + sortBy: ["StartDate"] + ) + let request = Paths.getLiveTvPrograms(parameters: parameters) + return try await userSession.client.send(request) } func startScheduleCheckTimer() { let date = Date() let calendar = Calendar.current var components = calendar.dateComponents([.era, .year, .month, .day, .hour, .minute], from: date) - - // Run on 10th min of every hour + // Run every minute guard let minute = components.minute else { return } components.second = 0 - components.minute = minute + (10 - (minute % 10)) - + components.minute = minute + (1 - (minute % 1)) guard let nextMinute = calendar.date(from: components) else { return } - if let existingTimer = timer { existingTimer.invalidate() } - timer = Timer(fire: nextMinute, interval: 60 * 10, repeats: true) { [weak self] _ in + timer = Timer(fire: nextMinute, interval: 60, repeats: true) { [weak self] _ in guard let self = self else { return } self.logger.debug("LiveTVChannels schedule check...") - DispatchQueue.global(qos: .background).async { - let newChanPrgs = self.processChannelPrograms() - DispatchQueue.main.async { - self.channelPrograms = newChanPrgs + + Task { + await MainActor.run { + let channelProgramsCopy = self.channelPrograms + var refreshedChannelPrograms: [LiveTVChannelProgram] = [] + for channelProgram in channelProgramsCopy { + var currentPrg: BaseItemDto? + let now = Date() + for prg in channelProgram.programs { + if let startDate = prg.startDate, + let endDate = prg.endDate, + now.timeIntervalSinceReferenceDate > startDate.timeIntervalSinceReferenceDate && + now.timeIntervalSinceReferenceDate < endDate.timeIntervalSinceReferenceDate + { + currentPrg = prg + } + } + + refreshedChannelPrograms + .append(LiveTVChannelProgram( + channel: channelProgram.channel, + currentProgram: currentPrg, + programs: channelProgram.programs + )) + } + self.channelPrograms = refreshedChannelPrograms } } } diff --git a/Shared/ViewModels/LiveVideoPlayerManager.swift b/Shared/ViewModels/LiveVideoPlayerManager.swift new file mode 100644 index 00000000..990893fb --- /dev/null +++ b/Shared/ViewModels/LiveVideoPlayerManager.swift @@ -0,0 +1,31 @@ +// +// 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) 2024 Jellyfin & Jellyfin Contributors +// + +import Foundation +import JellyfinAPI + +class LiveVideoPlayerManager: VideoPlayerManager { + + @Published + var program: LiveTVChannelProgram? + @Published + var dateFormatter = DateFormatter() + + init(item: BaseItemDto, mediaSource: MediaSourceInfo, program: LiveTVChannelProgram? = nil) { + self.program = program + super.init() + + Task { + let viewModel = try await item.liveVideoPlayerViewModel(with: mediaSource, logger: logger) + + await MainActor.run { + self.currentViewModel = viewModel + } + } + } +} diff --git a/Shared/ViewModels/MediaViewModel/MediaViewModel.swift b/Shared/ViewModels/MediaViewModel/MediaViewModel.swift index edae7066..6b9f188f 100644 --- a/Shared/ViewModels/MediaViewModel/MediaViewModel.swift +++ b/Shared/ViewModels/MediaViewModel/MediaViewModel.swift @@ -72,14 +72,10 @@ final class MediaViewModel: ViewModel, Stateful { mediaItems.removeAll() } - // TODO: atow, liveTV is removed because it wasn't fixed in time - // after a giant refactor and to push an update let media: [MediaType] = try await getUserViews() .compactMap { userView in if userView.collectionType == "livetv" { -// return .liveTV(userView) - - return nil + return .liveTV(userView) } return .collectionFolder(userView) diff --git a/Shared/ViewModels/VideoPlayerViewModel.swift b/Shared/ViewModels/VideoPlayerViewModel.swift index a9c7d594..3f0d890f 100644 --- a/Shared/ViewModels/VideoPlayerViewModel.swift +++ b/Shared/ViewModels/VideoPlayerViewModel.swift @@ -68,7 +68,9 @@ class VideoPlayerViewModel: ViewModel { let configuration = VLCVideoPlayer.Configuration(url: playbackURL) configuration.autoPlay = true configuration.startTime = .seconds(max(0, item.startTimeSeconds - Defaults[.VideoPlayer.resumeOffset])) - configuration.audioIndex = .absolute(selectedAudioStreamIndex) + if self.audioStreams[0].path != nil { + configuration.audioIndex = .absolute(selectedAudioStreamIndex) + } configuration.subtitleIndex = .absolute(selectedSubtitleStreamIndex) configuration.subtitleSize = .absolute(Defaults[.VideoPlayer.Subtitle.subtitleSize]) configuration.subtitleColor = .absolute(Defaults[.VideoPlayer.Subtitle.subtitleColor].uiColor) diff --git a/Swiftfin tvOS/Views/LiveTVChannelsView.swift b/Swiftfin tvOS/Views/LiveTVChannelsView.swift index 2ab25c66..1c1765ae 100644 --- a/Swiftfin tvOS/Views/LiveTVChannelsView.swift +++ b/Swiftfin tvOS/Views/LiveTVChannelsView.swift @@ -6,7 +6,7 @@ // Copyright (c) 2024 Jellyfin & Jellyfin Contributors // -import CollectionView +import CollectionVGrid import Foundation import JellyfinAPI import SwiftUI @@ -34,18 +34,35 @@ struct LiveTVChannelsView: View { @ViewBuilder private var channelsView: some View { - CollectionView(items: viewModel.channelPrograms) { _, channelProgram, _ in - channelCell(for: channelProgram) - } - .layout { _, layoutEnvironment in - .grid( - layoutEnvironment: layoutEnvironment, - layoutMode: .fixedNumberOfColumns(4), - itemSpacing: 8, - lineSpacing: 16, - itemSize: .estimated(400), - sectionInsets: .zero - ) + Group { + if viewModel.isLoading { + ProgressView() + } else if viewModel.elements.isNotEmpty { + CollectionVGrid( + $viewModel.elements, + layout: .minWidth(400, itemSpacing: 16, lineSpacing: 4) + ) { program in + channelCell(for: program) + } + .onReachedBottomEdge(offset: .offset(300)) { + viewModel.send(.getNextPage) + } + .onAppear { + viewModel.startScheduleCheckTimer() + } + .onDisappear { + viewModel.stopScheduleCheckTimer() + } + } else { + VStack { + Text(L10n.noResults) + Button { + viewModel.send(.refresh) + } label: { + Text(L10n.reload) + } + } + } } .ignoresSafeArea() } @@ -74,18 +91,32 @@ struct LiveTVChannelsView: View { timeFormatter: viewModel.timeFormatter ), onSelect: { _ in - router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: channel.mediaSources!.first!)) + guard let mediaSource = channel.mediaSources?.first else { + return + } + viewModel.stopScheduleCheckTimer() + router.route( + to: \.liveVideoPlayer, + LiveVideoPlayerManager(item: channel, mediaSource: mediaSource, program: channelProgram) + ) } ) } var body: some View { - if viewModel.isLoading && viewModel.channels.isEmpty { - loadingView - } else if viewModel.channels.isEmpty { - noResultsView - } else { - channelsView + Group { + if viewModel.isLoading && viewModel.elements.isEmpty { + loadingView + } else if viewModel.elements.isEmpty { + noResultsView + } else { + channelsView + } + } + .onFirstAppear { + if viewModel.state == .initial { + viewModel.send(.refresh) + } } } @@ -98,7 +129,7 @@ struct LiveTVChannelsView: View { } } -private extension BaseItemDto { +extension BaseItemDto { func programDisplayText(timeFormatter: DateFormatter) -> LiveTVChannelViewProgram { var timeText = "" if let start = self.startDate { diff --git a/Swiftfin tvOS/Views/LiveTVProgramsView.swift b/Swiftfin tvOS/Views/LiveTVProgramsView.swift index 321e8a3a..b11b7fe9 100644 --- a/Swiftfin tvOS/Views/LiveTVProgramsView.swift +++ b/Swiftfin tvOS/Views/LiveTVProgramsView.swift @@ -36,7 +36,7 @@ struct LiveTVProgramsView: View { let channel = viewModel.findChannel(id: channelID), let mediaSource = channel.mediaSources?.first else { return } - router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: mediaSource)) + router.route(to: \.videoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource)) } label: { LandscapeItemElement(item: item) } @@ -61,7 +61,7 @@ struct LiveTVProgramsView: View { let channel = viewModel.findChannel(id: channelID), let mediaSource = channel.mediaSources?.first else { return } - router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: mediaSource)) + router.route(to: \.videoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource)) } label: { LandscapeItemElement(item: item) } @@ -86,7 +86,7 @@ struct LiveTVProgramsView: View { let channel = viewModel.findChannel(id: channelID), let mediaSource = channel.mediaSources?.first else { return } - router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: mediaSource)) + router.route(to: \.videoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource)) } label: { LandscapeItemElement(item: item) } @@ -111,7 +111,7 @@ struct LiveTVProgramsView: View { let channel = viewModel.findChannel(id: channelID), let mediaSource = channel.mediaSources?.first else { return } - router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: mediaSource)) + router.route(to: \.videoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource)) } label: { LandscapeItemElement(item: item) } @@ -136,7 +136,7 @@ struct LiveTVProgramsView: View { let channel = viewModel.findChannel(id: channelID), let mediaSource = channel.mediaSources?.first else { return } - router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: mediaSource)) + router.route(to: \.videoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource)) } label: { LandscapeItemElement(item: item) } @@ -161,7 +161,7 @@ struct LiveTVProgramsView: View { let channel = viewModel.findChannel(id: channelID), let mediaSource = channel.mediaSources?.first else { return } - router.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: mediaSource)) + router.route(to: \.videoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource)) } label: { LandscapeItemElement(item: item) } diff --git a/Swiftfin tvOS/Views/VideoPlayer/LiveNativeVideoPlayer.swift b/Swiftfin tvOS/Views/VideoPlayer/LiveNativeVideoPlayer.swift new file mode 100644 index 00000000..4359774b --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/LiveNativeVideoPlayer.swift @@ -0,0 +1,179 @@ +// +// 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) 2024 Jellyfin & Jellyfin Contributors +// + +import AVKit +import Combine +import Defaults +import JellyfinAPI +import SwiftUI + +struct LiveNativeVideoPlayer: View { + + @EnvironmentObject + private var router: LiveVideoPlayerCoordinator.Router + + @ObservedObject + private var videoPlayerManager: LiveVideoPlayerManager + + @State + private var isPresentingOverlay: Bool = false + + init(manager: LiveVideoPlayerManager) { + self.videoPlayerManager = manager + } + + @ViewBuilder + private var playerView: some View { + NativeVideoPlayerView(videoPlayerManager: videoPlayerManager) + } + + var body: some View { + Group { + ZStack { + if let _ = videoPlayerManager.currentViewModel { + playerView + } else { + VideoPlayer.LoadingView() + } + } + } + .navigationBarHidden(true) + .ignoresSafeArea() + .onDisappear { + NotificationCenter.default.post(name: .livePlayerDismissed, object: nil) + } + } +} + +struct LiveNativeVideoPlayerView: UIViewControllerRepresentable { + + let videoPlayerManager: VideoPlayerManager + + func makeUIViewController(context: Context) -> UILiveNativeVideoPlayerViewController { + UILiveNativeVideoPlayerViewController(manager: videoPlayerManager) + } + + func updateUIViewController(_ uiViewController: UILiveNativeVideoPlayerViewController, context: Context) {} +} + +class UILiveNativeVideoPlayerViewController: AVPlayerViewController { + + let videoPlayerManager: VideoPlayerManager + + private var rateObserver: NSKeyValueObservation! + private var timeObserverToken: Any! + + init(manager: VideoPlayerManager) { + + self.videoPlayerManager = manager + + super.init(nibName: nil, bundle: nil) + + let newPlayer: AVPlayer = .init(url: manager.currentViewModel.hlsPlaybackURL) + + newPlayer.allowsExternalPlayback = true + newPlayer.appliesMediaSelectionCriteriaAutomatically = false + newPlayer.currentItem?.externalMetadata = createMetadata() + + rateObserver = newPlayer.observe(\.rate, options: .new) { _, change in + guard let newValue = change.newValue else { return } + + if newValue == 0 { + self.videoPlayerManager.onStateUpdated(newState: .paused) + } else { + self.videoPlayerManager.onStateUpdated(newState: .playing) + } + } + + let time = CMTime(seconds: 0.1, preferredTimescale: 1000) + + timeObserverToken = newPlayer.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in + + guard let self else { return } + + if time.seconds >= 0 { + let newSeconds = Int(time.seconds) + let progress = CGFloat(newSeconds) / CGFloat(self.videoPlayerManager.currentViewModel.item.runTimeSeconds) + + self.videoPlayerManager.currentProgressHandler.progress = progress + self.videoPlayerManager.currentProgressHandler.scrubbedProgress = progress + self.videoPlayerManager.currentProgressHandler.seconds = newSeconds + self.videoPlayerManager.currentProgressHandler.scrubbedSeconds = newSeconds + } + } + + player = newPlayer + } + + @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() + guard let timeObserverToken else { return } + player?.removeTimeObserver(timeObserverToken) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + player?.seek( + to: CMTimeMake( + value: Int64(videoPlayerManager.currentViewModel.item.startTimeSeconds - Defaults[.VideoPlayer.resumeOffset]), + timescale: 1 + ), + toleranceBefore: .zero, + toleranceAfter: .zero, + completionHandler: { _ in + self.play() + } + ) + } + + private func createMetadata() -> [AVMetadataItem] { + let allMetadata: [AVMetadataIdentifier: Any?] = [ + .commonIdentifierTitle: videoPlayerManager.currentViewModel.item.displayTitle, + .iTunesMetadataTrackSubTitle: videoPlayerManager.currentViewModel.item.subtitle, + ] + + return allMetadata.compactMap { createMetadataItem(for: $0, value: $1) } + } + + private func createMetadataItem( + for identifier: AVMetadataIdentifier, + value: Any? + ) -> AVMetadataItem? { + guard let value else { return nil } + 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 + } + + private func play() { + player?.play() + + videoPlayerManager.sendStartReport() + } + + private func stop() { + player?.pause() + + videoPlayerManager.sendStopReport() + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift b/Swiftfin tvOS/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift new file mode 100644 index 00000000..285439e0 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.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) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +extension LiveVideoPlayer.Overlay { + + struct LiveBottomBarView: View { + + @Environment(\.currentOverlayType) + @Binding + private var currentOverlayType + @Environment(\.isPresentingOverlay) + @Binding + private var isPresentingOverlay + @Environment(\.isScrubbing) + @Binding + private var isScrubbing: Bool + + @EnvironmentObject + private var currentProgressHandler: LiveVideoPlayerManager.CurrentProgressHandler + @EnvironmentObject + private var overlayTimer: TimerProxy + @EnvironmentObject + private var videoPlayerManager: LiveVideoPlayerManager + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + @FocusState + private var isBarFocused: Bool + + @ViewBuilder + private var playbackStateView: some View { +// if videoPlayerManager.state == .playing { +// Image(systemName: "pause.circle") +// } else if videoPlayerManager.state == .paused { +// Image(systemName: "play.circle") +// } else { +// ProgressView() +// } + // videoPLayerManager access is giving an error here: + // Fatal error: No ObservableObject of type LiveVideoPlayerManager found. A View.environmentObject(_:) for + // LiveVideoPlayerManager may be missing as an ancestor of this view. + EmptyView() + } + + var body: some View { + VStack(alignment: .VideoPlayerTitleAlignmentGuide, spacing: 10) { + + if let subtitle = videoPlayerManager.program?.currentProgram?.programDisplayText(timeFormatter: DateFormatter()) { + Text(subtitle.title) + .font(.subheadline) + .foregroundColor(.white) + .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in + dimensions[.leading] + } + } + + HStack { + + Text(viewModel.item.displayTitle) + .font(.largeTitle) + .fontWeight(.bold) + .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in + dimensions[.leading] + } + + Spacer() + + VideoPlayer.Overlay.BarActionButtons() + } + + tvOSSliderView(value: $currentProgressHandler.scrubbedProgress) + .onEditingChanged { isEditing in + isScrubbing = isEditing + + if isEditing { + overlayTimer.pause() + } else { + overlayTimer.start(5) + } + } + .focused($isBarFocused) + .frame(height: 60) + // .visible(isScrubbing || isPresentingOverlay) + + HStack(spacing: 15) { + + Text(currentProgressHandler.scrubbedSeconds.timeLabel) + .monospacedDigit() + .foregroundColor(.white) + + playbackStateView + .frame(maxWidth: 40, maxHeight: 40) + + Spacer() + + Text((viewModel.item.runTimeSeconds - currentProgressHandler.scrubbedSeconds).timeLabel.prepending("-")) + .monospacedDigit() + .foregroundColor(.white) + } + } + .onChange(of: isPresentingOverlay) { newValue in + guard newValue else { return } + } + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/LiveOverlays/LiveLoadingOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/LiveOverlays/LiveLoadingOverlay.swift new file mode 100644 index 00000000..6d31f2c4 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/LiveOverlays/LiveLoadingOverlay.swift @@ -0,0 +1,92 @@ +// +// 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) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI +import VLCUI + +extension LiveVideoPlayer { + +// struct LoadingOverlay: View { +// +// @Environment(\.isPresentingOverlay) +// @Binding +// private var isPresentingOverlay +// +// @EnvironmentObject +// private var proxy: VLCVideoPlayer.Proxy +// @EnvironmentObject +// private var router: LiveVideoPlayerCoordinator.Router +// +// @State +// private var confirmCloseWorkItem: DispatchWorkItem? +// @State +// private var currentOverlayType: VideoPlayer.OverlayType = .main +// +// @StateObject +// private var overlayTimer: TimerProxy = .init() +// +// var body: some View { +// ZStack { +// +// ConfirmCloseOverlay() +// .visible(currentOverlayType == .confirmClose) +// } +// .visible(isPresentingOverlay) +// .animation(.linear(duration: 0.1), value: currentOverlayType) +// .environment(\.currentOverlayType, $currentOverlayType) +// .environmentObject(overlayTimer) +// .onChange(of: currentOverlayType) { newValue in +// if [.smallMenu, .chapters].contains(newValue) { +// overlayTimer.pause() +// } else if isPresentingOverlay { +// overlayTimer.start(5) +// } +// } +// .onChange(of: overlayTimer.isActive) { isActive in +// guard !isActive else { return } +// +// withAnimation(.linear(duration: 0.3)) { +// isPresentingOverlay = false +// } +// } +// .onSelectPressed { +// currentOverlayType = .main +// isPresentingOverlay = true +// overlayTimer.start(5) +// } +// .onMenuPressed { +// +// overlayTimer.start(5) +// confirmCloseWorkItem?.cancel() +// +// if isPresentingOverlay && currentOverlayType == .confirmClose { +// proxy.stop() +// router.dismissCoordinator() +// } else if isPresentingOverlay && currentOverlayType == .smallMenu { +// currentOverlayType = .main +// } else { +// withAnimation { +// currentOverlayType = .confirmClose +// isPresentingOverlay = true +// } +// +// let task = DispatchWorkItem { +// withAnimation { +// isPresentingOverlay = false +// overlayTimer.stop() +// } +// } +// +// confirmCloseWorkItem = task +// +// DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) +// } +// } +// } +// } +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/LiveOverlays/LiveMainOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/LiveOverlays/LiveMainOverlay.swift new file mode 100644 index 00000000..0c353ca6 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/LiveOverlays/LiveMainOverlay.swift @@ -0,0 +1,53 @@ +// +// 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) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +extension LiveVideoPlayer { + + struct LiveMainOverlay: View { + + @Environment(\.currentOverlayType) + @Binding + private var currentOverlayType + @Environment(\.isPresentingOverlay) + @Binding + private var isPresentingOverlay + @Environment(\.isScrubbing) + @Binding + private var isScrubbing: Bool + + @EnvironmentObject + private var currentProgressHandler: LiveVideoPlayerManager.CurrentProgressHandler + @EnvironmentObject + private var overlayTimer: TimerProxy + + var body: some View { + VStack { + + Spacer() + + Overlay.LiveBottomBarView() + .padding2() + .padding2() + .background { + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .black.opacity(0.8), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + } + } + .environmentObject(overlayTimer) + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/LiveOverlays/LiveOverlay.swift b/Swiftfin tvOS/Views/VideoPlayer/LiveOverlays/LiveOverlay.swift new file mode 100644 index 00000000..8fc72da5 --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/LiveOverlays/LiveOverlay.swift @@ -0,0 +1,101 @@ +// +// 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) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI +import VLCUI + +extension LiveVideoPlayer { + + struct Overlay: View { + + @Environment(\.isPresentingOverlay) + @Binding + private var isPresentingOverlay + + @EnvironmentObject + private var proxy: VLCVideoPlayer.Proxy + @EnvironmentObject + private var router: LiveVideoPlayerCoordinator.Router + + @State + private var confirmCloseWorkItem: DispatchWorkItem? + @State + private var currentOverlayType: VideoPlayer.OverlayType = .main + + @StateObject + private var overlayTimer: TimerProxy = .init() + + var body: some View { + ZStack { + + LiveMainOverlay() + .visible(currentOverlayType == .main) + + ConfirmCloseOverlay() + .visible(currentOverlayType == .confirmClose) + + VideoPlayer.SmallMenuOverlay() + .visible(currentOverlayType == .smallMenu) + + VideoPlayer.ChapterOverlay() + .visible(currentOverlayType == .chapters) + } + .visible(isPresentingOverlay) + .animation(.linear(duration: 0.1), value: currentOverlayType) + .environment(\.currentOverlayType, $currentOverlayType) + .environmentObject(overlayTimer) + .onChange(of: currentOverlayType) { newValue in + if [.smallMenu, .chapters].contains(newValue) { + overlayTimer.pause() + } else if isPresentingOverlay { + overlayTimer.start(5) + } + } + .onChange(of: overlayTimer.isActive) { isActive in + guard !isActive else { return } + + withAnimation(.linear(duration: 0.3)) { + isPresentingOverlay = false + } + } +// .onSelectPressed { +// currentOverlayType = .main +// isPresentingOverlay = true +// overlayTimer.start(5) +// } +// .onMenuPressed { +// +// overlayTimer.start(5) +// confirmCloseWorkItem?.cancel() +// +// if isPresentingOverlay && currentOverlayType == .confirmClose { +// proxy.stop() +// router.dismissCoordinator() +// } else if isPresentingOverlay && currentOverlayType == .smallMenu { +// currentOverlayType = .main +// } else { +// withAnimation { +// currentOverlayType = .confirmClose +// isPresentingOverlay = true +// } +// +// let task = DispatchWorkItem { +// withAnimation { +// isPresentingOverlay = false +// overlayTimer.stop() +// } +// } +// +// confirmCloseWorkItem = task +// +// DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) +// } +// } + } + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/LiveVideoPlayer.swift b/Swiftfin tvOS/Views/VideoPlayer/LiveVideoPlayer.swift new file mode 100644 index 00000000..2ef283be --- /dev/null +++ b/Swiftfin tvOS/Views/VideoPlayer/LiveVideoPlayer.swift @@ -0,0 +1,120 @@ +// +// 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) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI +import VLCUI + +struct LiveVideoPlayer: View { + + enum OverlayType { + case chapters + case confirmClose + case main + case smallMenu + } + + @EnvironmentObject + private var router: LiveVideoPlayerCoordinator.Router + + @ObservedObject + private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler + @ObservedObject + private var videoPlayerManager: LiveVideoPlayerManager + + @State + private var isPresentingOverlay: Bool = false + @State + private var isScrubbing: Bool = false + + @ViewBuilder + private var playerView: some View { + ZStack { + VLCVideoPlayer(configuration: videoPlayerManager.currentViewModel.vlcVideoPlayerConfiguration) + .proxy(videoPlayerManager.proxy) + .onTicksUpdated { ticks, _ in + + let newSeconds = ticks / 1000 + let newProgress = CGFloat(newSeconds) / CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) + currentProgressHandler.progress = newProgress + currentProgressHandler.seconds = newSeconds + + guard !isScrubbing else { return } + currentProgressHandler.scrubbedProgress = newProgress + } + .onStateUpdated { state, _ in + + videoPlayerManager.onStateUpdated(newState: state) + + if state == .ended { + if let _ = videoPlayerManager.nextViewModel, + Defaults[.VideoPlayer.autoPlayEnabled] + { + videoPlayerManager.selectNextViewModel() + } else { + router.dismissCoordinator() + } + } + } + + LiveVideoPlayer.Overlay() + .eraseToAnyView() + .environmentObject(videoPlayerManager) + .environmentObject(videoPlayerManager.currentProgressHandler) + .environmentObject(videoPlayerManager.currentViewModel!) + .environmentObject(videoPlayerManager.proxy) + .environment(\.isPresentingOverlay, $isPresentingOverlay) + .environment(\.isScrubbing, $isScrubbing) + } + .onChange(of: videoPlayerManager.currentProgressHandler.scrubbedProgress) { newValue in + guard !newValue.isNaN && !newValue.isInfinite else { + return + } + DispatchQueue.main.async { + videoPlayerManager.currentProgressHandler + .scrubbedSeconds = Int(CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) * newValue) + } + } + .onDisappear { + NotificationCenter.default.post(name: .livePlayerDismissed, object: nil) + } + } + + @ViewBuilder + private var loadingView: some View { + VideoPlayer.LoadingView() + } + + var body: some View { + ZStack { + + Color.black + + if let _ = videoPlayerManager.currentViewModel { + playerView + } else { + loadingView + } + } + .ignoresSafeArea() + .onChange(of: isScrubbing) { newValue in + guard !newValue else { return } + videoPlayerManager.proxy.setTime(.seconds(currentProgressHandler.scrubbedSeconds)) + } + } +} + +extension LiveVideoPlayer { + + init(manager: LiveVideoPlayerManager) { + self.init( + currentProgressHandler: manager.currentProgressHandler, + videoPlayerManager: manager + ) + } +} diff --git a/Swiftfin tvOS/Views/VideoPlayer/NativeVideoPlayer.swift b/Swiftfin tvOS/Views/VideoPlayer/NativeVideoPlayer.swift index de4bb6b5..be8947b8 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/NativeVideoPlayer.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/NativeVideoPlayer.swift @@ -37,8 +37,7 @@ struct NativeVideoPlayer: View { if let _ = videoPlayerManager.currentViewModel { playerView } else { -// VideoPlayer.LoadingView() - Text("Loading") + VideoPlayer.LoadingView() } } .navigationBarHidden(true) diff --git a/Swiftfin tvOS/Views/VideoPlayer/VideoPlayer.swift b/Swiftfin tvOS/Views/VideoPlayer/VideoPlayer.swift index 43f902ca..c053b9ca 100644 --- a/Swiftfin tvOS/Views/VideoPlayer/VideoPlayer.swift +++ b/Swiftfin tvOS/Views/VideoPlayer/VideoPlayer.swift @@ -75,6 +75,9 @@ struct VideoPlayer: View { .environment(\.isScrubbing, $isScrubbing) } .onChange(of: videoPlayerManager.currentProgressHandler.scrubbedProgress) { newValue in + guard !newValue.isNaN && !newValue.isInfinite else { + return + } DispatchQueue.main.async { videoPlayerManager.currentProgressHandler .scrubbedSeconds = Int(CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) * newValue) diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 1a5c4d16..944ad40d 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -153,9 +153,29 @@ C400DB6B27FE8C97007B65FE /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E52304272CE68800654268 /* LiveTVChannelItemElement.swift */; }; C400DB6D27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */; }; C40CD926271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */; }; + C44FA6E02AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44FA6DE2AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift */; }; + C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = C44FA6DF2AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift */; }; C45942C527F67DA400C54FE7 /* LiveTVCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */; }; C45942C627F695FB00C54FE7 /* LiveTVProgramsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */; }; C45942D027F69C2400C54FE7 /* LiveTVChannelsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */; }; + C45C36542A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */; }; + C45C36552A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */; }; + C46008742A97DFF2002B1C7A /* LiveLoadingOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46008732A97DFF2002B1C7A /* LiveLoadingOverlay.swift */; }; + C46DD8D22A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */; }; + C46DD8D32A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */; }; + C46DD8D72A8DC2990046A504 /* LiveVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8D42A8DC2980046A504 /* LiveVideoPlayer.swift */; }; + C46DD8D92A8DC2990046A504 /* LiveNativeVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8D52A8DC2980046A504 /* LiveNativeVideoPlayer.swift */; }; + C46DD8DC2A8DC3420046A504 /* LiveVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8DA2A8DC3410046A504 /* LiveVideoPlayer.swift */; }; + C46DD8DD2A8DC3420046A504 /* LiveNativeVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8DB2A8DC3410046A504 /* LiveNativeVideoPlayer.swift */; }; + C46DD8E02A8DC7790046A504 /* LiveOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8DF2A8DC7790046A504 /* LiveOverlay.swift */; }; + C46DD8E22A8DC7FB0046A504 /* LiveMainOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8E12A8DC7FB0046A504 /* LiveMainOverlay.swift */; }; + C46DD8E52A8FA6510046A504 /* LiveTopBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8E42A8FA6510046A504 /* LiveTopBarView.swift */; }; + C46DD8E72A8FA77F0046A504 /* LiveBottomBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8E62A8FA77F0046A504 /* LiveBottomBarView.swift */; }; + C46DD8EA2A8FB45C0046A504 /* LiveOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8E92A8FB45C0046A504 /* LiveOverlay.swift */; }; + C46DD8EC2A8FB49A0046A504 /* LiveMainOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8EB2A8FB49A0046A504 /* LiveMainOverlay.swift */; }; + C46DD8EF2A8FB56E0046A504 /* LiveBottomBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46DD8EE2A8FB56E0046A504 /* LiveBottomBarView.swift */; }; + C49AE1182BC191BB004562B2 /* LiveTVChannelProgram.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49AE1172BC191BB004562B2 /* LiveTVChannelProgram.swift */; }; + C49AE1192BC191BB004562B2 /* LiveTVChannelProgram.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49AE1172BC191BB004562B2 /* LiveTVChannelProgram.swift */; }; C4AE2C3027498D2300AE13CF /* LiveTVHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AE2C2F27498D2300AE13CF /* LiveTVHomeView.swift */; }; C4AE2C3227498D6A00AE13CF /* LiveTVProgramsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4AE2C3127498D6A00AE13CF /* LiveTVProgramsView.swift */; }; C4BE076F2720FEFF003F4AD1 /* PlainNavigationLinkButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 531690F9267AD6EC005D8AB9 /* PlainNavigationLinkButton.swift */; }; @@ -183,7 +203,6 @@ E10706102942F57D00646DAF /* Pulse in Frameworks */ = {isa = PBXBuildFile; productRef = E107060F2942F57D00646DAF /* Pulse */; }; E10706122942F57D00646DAF /* PulseLogHandler in Frameworks */ = {isa = PBXBuildFile; productRef = E10706112942F57D00646DAF /* PulseLogHandler */; }; E10706142942F57D00646DAF /* PulseUI in Frameworks */ = {isa = PBXBuildFile; productRef = E10706132942F57D00646DAF /* PulseUI */; }; - E10706172943F2F900646DAF /* (null) in Sources */ = {isa = PBXBuildFile; }; E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */; }; E107BB9427880A8F00354E07 /* CollectionItemViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E107BB9227880A8F00354E07 /* CollectionItemViewModel.swift */; }; E1092F4C29106F9F00163F57 /* GestureAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1092F4B29106F9F00163F57 /* GestureAction.swift */; }; @@ -935,7 +954,24 @@ C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelsView.swift; sourceTree = ""; }; C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelItemWideElement.swift; sourceTree = ""; }; C40CD924271F8D1E000FB198 /* ItemTypeLibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemTypeLibraryViewModel.swift; sourceTree = ""; }; + C44FA6DE2AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveSmallPlaybackButton.swift; sourceTree = ""; }; + C44FA6DF2AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveLargePlaybackButtons.swift; sourceTree = ""; }; C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVCoordinator.swift; sourceTree = ""; }; + C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveVideoPlayerManager.swift; sourceTree = ""; }; + C46008732A97DFF2002B1C7A /* LiveLoadingOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveLoadingOverlay.swift; sourceTree = ""; }; + C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveVideoPlayerCoordinator.swift; sourceTree = ""; }; + C46DD8D42A8DC2980046A504 /* LiveVideoPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveVideoPlayer.swift; sourceTree = ""; }; + C46DD8D52A8DC2980046A504 /* LiveNativeVideoPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveNativeVideoPlayer.swift; sourceTree = ""; }; + C46DD8DA2A8DC3410046A504 /* LiveVideoPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveVideoPlayer.swift; sourceTree = ""; }; + C46DD8DB2A8DC3410046A504 /* LiveNativeVideoPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveNativeVideoPlayer.swift; sourceTree = ""; }; + C46DD8DF2A8DC7790046A504 /* LiveOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveOverlay.swift; sourceTree = ""; }; + C46DD8E12A8DC7FB0046A504 /* LiveMainOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveMainOverlay.swift; sourceTree = ""; }; + C46DD8E42A8FA6510046A504 /* LiveTopBarView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveTopBarView.swift; sourceTree = ""; }; + C46DD8E62A8FA77F0046A504 /* LiveBottomBarView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveBottomBarView.swift; sourceTree = ""; }; + C46DD8E92A8FB45C0046A504 /* LiveOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveOverlay.swift; sourceTree = ""; }; + C46DD8EB2A8FB49A0046A504 /* LiveMainOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveMainOverlay.swift; sourceTree = ""; }; + C46DD8EE2A8FB56E0046A504 /* LiveBottomBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveBottomBarView.swift; sourceTree = ""; }; + C49AE1172BC191BB004562B2 /* LiveTVChannelProgram.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVChannelProgram.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 = ""; }; C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTVProgramsCoordinator.swift; sourceTree = ""; }; @@ -1453,9 +1489,12 @@ 5310694F2684E7EE00CFFDBA /* VideoPlayer */ = { isa = PBXGroup; children = ( + C46DD8E82A8FB4230046A504 /* LiveOverlays */, E10E842829A587090064EA49 /* Components */, E18A17F3298C68BF00C22F62 /* Overlays */, E1575EA5293E7D40001665B1 /* VideoPlayer.swift */, + C46DD8D52A8DC2980046A504 /* LiveNativeVideoPlayer.swift */, + C46DD8D42A8DC2980046A504 /* LiveVideoPlayer.swift */, E1D9F474296E86D400129AF3 /* NativeVideoPlayer.swift */, ); path = VideoPlayer; @@ -1481,6 +1520,7 @@ E13DD3F82717E961009D4DAF /* UserListViewModel.swift */, E13DD3EB27178A54009D4DAF /* UserSignInViewModel.swift */, BD0BA2292AD6501300306A8D /* VideoPlayerManager */, + C45C36532A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift */, E14A08CA28E6831D004FC984 /* VideoPlayerViewModel.swift */, 625CB57B2678CE1000530A6E /* ViewModel.swift */, ); @@ -1598,6 +1638,7 @@ E1BDF2E42951475300CC0294 /* VideoPlayerActionButton.swift */, E1F0204D26CCCA74001C1C3B /* VideoPlayerJumpLength.swift */, E15756352936856700976E1F /* VideoPlayerType.swift */, + C49AE1172BC191BB004562B2 /* LiveTVChannelProgram.swift */, ); path = Objects; sourceTree = ""; @@ -1928,6 +1969,7 @@ C45942C427F67DA400C54FE7 /* LiveTVCoordinator.swift */, C4BE07702725EB06003F4AD1 /* LiveTVProgramsCoordinator.swift */, C4BE07782726EE82003F4AD1 /* LiveTVTabCoordinator.swift */, + C46DD8D12A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift */, E193D5412719404B00900D82 /* MainCoordinator */, 62C29EA726D103D500C1D2E7 /* MediaCoordinator.swift */, E170D106294D23BA0017224C /* MediaSourceInfoCoordinator.swift */, @@ -1962,6 +2004,54 @@ path = VideoPlayerManager; sourceTree = ""; }; + C44FA6DD2AACD15300EDEB56 /* PlaybackButtons */ = { + isa = PBXGroup; + children = ( + C44FA6DF2AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift */, + C44FA6DE2AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift */, + ); + path = PlaybackButtons; + sourceTree = ""; + }; + C46DD8DE2A8DC7600046A504 /* LiveOverlays */ = { + isa = PBXGroup; + children = ( + C46DD8E32A8FA5C00046A504 /* Components */, + C46DD8DF2A8DC7790046A504 /* LiveOverlay.swift */, + C46DD8E12A8DC7FB0046A504 /* LiveMainOverlay.swift */, + ); + path = LiveOverlays; + sourceTree = ""; + }; + C46DD8E32A8FA5C00046A504 /* Components */ = { + isa = PBXGroup; + children = ( + C44FA6DD2AACD15300EDEB56 /* PlaybackButtons */, + C46DD8E42A8FA6510046A504 /* LiveTopBarView.swift */, + C46DD8E62A8FA77F0046A504 /* LiveBottomBarView.swift */, + ); + path = Components; + sourceTree = ""; + }; + C46DD8E82A8FB4230046A504 /* LiveOverlays */ = { + isa = PBXGroup; + children = ( + C46DD8ED2A8FB4C60046A504 /* Components */, + C46DD8E92A8FB45C0046A504 /* LiveOverlay.swift */, + C46008732A97DFF2002B1C7A /* LiveLoadingOverlay.swift */, + C46DD8EB2A8FB49A0046A504 /* LiveMainOverlay.swift */, + ); + path = LiveOverlays; + sourceTree = ""; + }; + C46DD8ED2A8FB4C60046A504 /* Components */ = { + isa = PBXGroup; + children = ( + C46DD8EE2A8FB56E0046A504 /* LiveBottomBarView.swift */, + ); + path = Components; + sourceTree = ""; + }; E107BB9127880A4000354E07 /* ItemViewModel */ = { isa = PBXGroup; children = ( @@ -2569,9 +2659,12 @@ E193D5452719418B00900D82 /* VideoPlayer */ = { isa = PBXGroup; children = ( + C46DD8DE2A8DC7600046A504 /* LiveOverlays */, E1559A74294D910A00C1FFBC /* Components */, E11245B228D97D4A00D8A977 /* Overlays */, E1D842162932AB8F00D1041A /* NativeVideoPlayer.swift */, + C46DD8DB2A8DC3410046A504 /* LiveNativeVideoPlayer.swift */, + C46DD8DA2A8DC3410046A504 /* LiveVideoPlayer.swift */, E18A8E8228D60BC400333B9A /* VideoPlayer.swift */, E170D0E1294CC8000017224C /* VideoPlayer+Actions.swift */, E170D0E3294CC8AB0017224C /* VideoPlayer+KeyCommands.swift */, @@ -3291,6 +3384,11 @@ 53ABFDE9267974EF00886593 /* HomeViewModel.swift in Sources */, E1575E99293E7B1E001665B1 /* UIColor.swift in Sources */, E1575E92293E7B1E001665B1 /* CGSize.swift in Sources */, + E1575E7E293E77B5001665B1 /* ItemFilterCollection.swift in Sources */, + E1575E7E293E77B5001665B1 /* ItemFilterCollection.swift in Sources */, + C46DD8EF2A8FB56E0046A504 /* LiveBottomBarView.swift in Sources */, + C46DD8EA2A8FB45C0046A504 /* LiveOverlay.swift in Sources */, + E1575E96293E7B1E001665B1 /* UIScrollView.swift in Sources */, E11E376D293E9CC1009EF240 /* VideoPlayerCoordinator.swift in Sources */, E1575E6F293E77B5001665B1 /* GestureAction.swift in Sources */, E18A8E7E28D606BE00333B9A /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, @@ -3317,6 +3415,7 @@ E1E6C45429B1304E0064123F /* ChaptersActionButton.swift in Sources */, E1E6C44229AECCD50064123F /* ActionButtons.swift in Sources */, E1575E78293E77B5001665B1 /* TrailingTimestampType.swift in Sources */, + C49AE1192BC191BB004562B2 /* LiveTVChannelProgram.swift in Sources */, C4BE07772725EBEA003F4AD1 /* LiveTVProgramsViewModel.swift in Sources */, E11CEB9128999D84003E74C7 /* EpisodeItemView.swift in Sources */, E1C9260C2887565C002A7A66 /* MovieItemContentView.swift in Sources */, @@ -3333,6 +3432,7 @@ E122A9142788EAAD0060FA63 /* MediaStream.swift in Sources */, E1575E74293E77B5001665B1 /* PanDirectionGestureRecognizer.swift in Sources */, E1575E85293E7A00001665B1 /* DarkAppIcon.swift in Sources */, + C45C36552A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */, E178859E2780F53B0094FBCF /* SliderView.swift in Sources */, E1575E95293E7B1E001665B1 /* Font.swift in Sources */, E172D3AE2BAC9DF8007B4647 /* SeasonItemViewModel.swift in Sources */, @@ -3349,6 +3449,7 @@ E1EA9F6B28F8A79E00BEC442 /* VideoPlayerManager.swift in Sources */, BD0BA22F2AD6508C00306A8D /* DownloadVideoPlayerManager.swift in Sources */, E1FCD08926C35A0D007C8DCF /* NetworkError.swift in Sources */, + C46DD8D32A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift in Sources */, E1549661296CA2EF00C4EF88 /* SwiftfinDefaults.swift in Sources */, E158C8D12A31947500C527C5 /* MediaSourceInfoView.swift in Sources */, E11BDF782B8513B40045C54A /* ItemGenre.swift in Sources */, @@ -3458,6 +3559,8 @@ E18E021F2887492B0022598C /* SystemImageContentView.swift in Sources */, E11BDF7B2B85529D0045C54A /* SupportedCaseIterable.swift in Sources */, E1575E8C293E7B1E001665B1 /* UIScreen.swift in Sources */, + C46DD8EC2A8FB49A0046A504 /* LiveMainOverlay.swift in Sources */, + C46DD8D72A8DC2990046A504 /* LiveVideoPlayer.swift in Sources */, E1575E88293E7A00001665B1 /* LightAppIcon.swift in Sources */, E1549678296CB22B00C4EF88 /* InlineEnumToggle.swift in Sources */, E193D5432719407E00900D82 /* tvOSMainCoordinator.swift in Sources */, @@ -3466,6 +3569,7 @@ E129428628F080B500796AC6 /* OnReceiveNotificationModifier.swift in Sources */, 53ABFDE7267974EF00886593 /* ConnectToServerViewModel.swift in Sources */, 62E632E4267D3BA60063E547 /* MovieItemViewModel.swift in Sources */, + C46DD8D92A8DC2990046A504 /* LiveNativeVideoPlayer.swift in Sources */, E1575E9F293E7B1E001665B1 /* Int.swift in Sources */, E1D9F475296E86D400129AF3 /* NativeVideoPlayer.swift in Sources */, E1575E7D293E77B5001665B1 /* PosterType.swift in Sources */, @@ -3479,6 +3583,7 @@ E1575E9B293E7B1E001665B1 /* EnvironmentValue+Keys.swift in Sources */, E133328929538D8D00EE76AB /* Files.swift in Sources */, E154967A296CB4B000C4EF88 /* VideoPlayerSettingsView.swift in Sources */, + C46008742A97DFF2002B1C7A /* LiveLoadingOverlay.swift in Sources */, E1575EA0293E7B1E001665B1 /* CGPoint.swift in Sources */, E1C926132887565C002A7A66 /* EpisodeSelector.swift in Sources */, E12CC1CD28D135C700678D5D /* NextUpView.swift in Sources */, @@ -3574,6 +3679,7 @@ files = ( E11245B428D97D5D00D8A977 /* BottomBarView.swift in Sources */, E17FB55528C1250B00311DFE /* SimilarItemsHStack.swift in Sources */, + C44FA6E02AACD19C00EDEB56 /* LiveSmallPlaybackButton.swift in Sources */, 5364F455266CA0DC0026ECBA /* BaseItemPerson.swift in Sources */, E18845F526DD631E00B0C5B7 /* BaseItemDto+Poster.swift in Sources */, E1B33ECF28EB6EA90073B0FD /* OverlayMenu.swift in Sources */, @@ -3588,7 +3694,6 @@ E13DD3F227179378009D4DAF /* UserSignInCoordinator.swift in Sources */, 621338932660107500A81A2A /* String.swift in Sources */, E17AC96F2954EE4B003D2BC2 /* DownloadListViewModel.swift in Sources */, - E10706172943F2F900646DAF /* (null) in Sources */, 62C83B08288C6A630004ED0C /* FontPickerView.swift in Sources */, E122A9132788EAAD0060FA63 /* MediaStream.swift in Sources */, E1E9017F28DAB15F001B1594 /* BarActionButtons.swift in Sources */, @@ -3613,6 +3718,7 @@ E18E0208288749200022598C /* BlurView.swift in Sources */, E18E01E7288747230022598C /* CollectionItemContentView.swift in Sources */, E1E1643F28BB075C00323B0A /* SelectorView.swift in Sources */, + C46DD8D22A8DC1F60046A504 /* LiveVideoPlayerCoordinator.swift in Sources */, E18ACA8B2A14301800BB4F35 /* ScalingButtonStyle.swift in Sources */, E18E01DF288747230022598C /* iPadOSMovieItemView.swift in Sources */, E168BD13289A4162001A6922 /* ContinueWatchingView.swift in Sources */, @@ -3644,12 +3750,15 @@ E154966A296CA2EF00C4EF88 /* DownloadManager.swift in Sources */, C4BE07852728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */, E133328829538D8D00EE76AB /* Files.swift in Sources */, + E1D3043228D175CE00587289 /* StaticLibraryViewModel.swift in Sources */, + C44FA6E12AACD19C00EDEB56 /* LiveLargePlaybackButtons.swift in Sources */, E1401CA02937DFF500E8B599 /* AppIconSelectorView.swift in Sources */, E1092F4C29106F9F00163F57 /* GestureAction.swift in Sources */, E11BDF772B8513B40045C54A /* ItemGenre.swift in Sources */, E16DEAC228EFCF590058F196 /* EnvironmentValue+Keys.swift in Sources */, E1BDF2F129524AB700CC0294 /* AutoPlayActionButton.swift in Sources */, E1BDF2F929524FDA00CC0294 /* PlayPreviousItemActionButton.swift in Sources */, + C46DD8E02A8DC7790046A504 /* LiveOverlay.swift in Sources */, E111D8F828D03BF900400001 /* PagingLibraryView.swift in Sources */, E187F7672B8E6A1C005400FE /* EnvironmentValue+Values.swift in Sources */, E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */, @@ -3706,6 +3815,7 @@ 6264E88C273850380081A12A /* Strings.swift in Sources */, C4AE2C3227498D6A00AE13CF /* LiveTVProgramsView.swift in Sources */, E1BDF31729525F0400CC0294 /* AdvancedActionButton.swift in Sources */, + C49AE1182BC191BB004562B2 /* LiveTVChannelProgram.swift in Sources */, E1ED91152B95897500802036 /* LatestInLibraryViewModel.swift in Sources */, 62ECA01826FA685A00E8EBB7 /* DeepLink.swift in Sources */, 62E632E6267D3F5B0063E547 /* EpisodeItemViewModel.swift in Sources */, @@ -3718,6 +3828,7 @@ E129428828F0831F00796AC6 /* SplitTimestamp.swift in Sources */, E1171A1928A2212600FA1AF5 /* QuickConnectView.swift in Sources */, E1DC9819296DD1CD00982F06 /* CinematicBackgroundView.swift in Sources */, + C46DD8E72A8FA77F0046A504 /* LiveBottomBarView.swift in Sources */, E11CEB8D28999B4A003E74C7 /* Font.swift in Sources */, E139CC1F28EC83E400688DE2 /* Int.swift in Sources */, E11895A9289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */, @@ -3737,6 +3848,8 @@ E18E01E4288747230022598C /* CompactLogoScrollView.swift in Sources */, E18E0207288749200022598C /* AttributeStyleModifier.swift in Sources */, E1002B642793CEE800E47059 /* ChapterInfo.swift in Sources */, + C46DD8E52A8FA6510046A504 /* LiveTopBarView.swift in Sources */, + E1D3043A28D189C500587289 /* CastAndCrewLibraryView.swift in Sources */, E18E01AD288746AF0022598C /* DotHStack.swift in Sources */, E170D107294D23BA0017224C /* MediaSourceInfoCoordinator.swift in Sources */, E1937A61288F32DB00CB80AA /* Poster.swift in Sources */, @@ -3750,6 +3863,8 @@ E1A1528228FD126C00600579 /* VerticalAlignment.swift in Sources */, E13DD3F5271793BB009D4DAF /* UserSignInView.swift in Sources */, E1D3044428D1991900587289 /* LibraryViewTypeToggle.swift in Sources */, + C45C36542A8B1F2C003DAE46 /* LiveVideoPlayerManager.swift in Sources */, + E148128B28C15526003B8787 /* ItemSortBy.swift in Sources */, E148128B28C15526003B8787 /* ItemSortBy.swift in Sources */, E1F0204E26CCCA74001C1C3B /* VideoPlayerJumpLength.swift in Sources */, E1A3E4CB2BB74EFD005C59F8 /* EpisodeHStack.swift in Sources */, @@ -3766,6 +3881,7 @@ E1937A3B288E54AD00CB80AA /* BaseItemDto+Images.swift in Sources */, E1401CA5293813F400E8B599 /* InvertedDarkAppIcon.swift in Sources */, E1C8CE7C28FF015000DF5D7B /* TrailingTimestampType.swift in Sources */, + C46DD8E22A8DC7FB0046A504 /* LiveMainOverlay.swift in Sources */, E1FE69A728C29B720021BC93 /* ProgressBar.swift in Sources */, E13332912953B91000EE76AB /* DownloadTaskCoordinator.swift in Sources */, E43918662AD5C8310045A18C /* ScenePhaseChangeModifier.swift in Sources */, @@ -3873,6 +3989,8 @@ E1E750682A33E9B400B2C1EE /* OverviewCard.swift in Sources */, E1CCF13128AC07EC006CAC9E /* PosterHStack.swift in Sources */, E13DD3C827164B1E009D4DAF /* UIDevice.swift in Sources */, + E1D3044228D1976600587289 /* CastAndCrewItemRow.swift in Sources */, + C46DD8DD2A8DC3420046A504 /* LiveNativeVideoPlayer.swift in Sources */, E1AD104D26D96CE3003E4A08 /* BaseItemDto.swift in Sources */, E13DD3BF27163DD7009D4DAF /* AppDelegate.swift in Sources */, 535870AD2669D8DD00D05A09 /* ItemFilterCollection.swift in Sources */, @@ -3894,6 +4012,7 @@ E111D8F528D03B7500400001 /* PagingLibraryViewModel.swift in Sources */, E16AF11C292C98A7001422A8 /* GestureSettingsView.swift in Sources */, E1581E27291EF59800D6C640 /* SplitContentView.swift in Sources */, + C46DD8DC2A8DC3420046A504 /* LiveVideoPlayer.swift in Sources */, E11BDF972B865F550045C54A /* ItemTag.swift in Sources */, E1D4BF8A2719D3D000A11E64 /* BasicAppSettingsCoordinator.swift in Sources */, E1D37F482B9C648E00343D2B /* MaxHeightText.swift in Sources */, diff --git a/Swiftfin/Views/LiveTVChannelItemWideElement.swift b/Swiftfin/Views/LiveTVChannelItemWideElement.swift index 4a43ce19..b71359a1 100644 --- a/Swiftfin/Views/LiveTVChannelItemWideElement.swift +++ b/Swiftfin/Views/LiveTVChannelItemWideElement.swift @@ -62,77 +62,100 @@ struct LiveTVChannelItemWideElement: View { var body: some View { ZStack { - ZStack { - HStack { + HStack(spacing: 0) { + VStack(spacing: 0) { ZStack(alignment: .center) { - ImageView(channel.imageURL(.primary, maxWidth: 128)) + ImageView(channel.imageURL(.primary, maxWidth: 56)) .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(width: 56, height: 56) + if loading { ProgressView() } } - .aspectRatio(1.0, contentMode: .fit) + .padding(.top, 4) + .padding(.leading, 4) + VStack(alignment: .leading) { - let channelNumber = channel.number != nil ? "\(channel.number ?? "") " : "" - let channelName = "\(channelNumber)\(channel.name ?? "?")" - Text(channelName) + Text(channel.number != nil ? "\(channel.number ?? "") " : "") .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(.textHighlight) - ) - if nextProgramsText.isNotEmpty { - 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() + .padding(.top, 4) } .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 + + VStack(alignment: .leading, spacing: 0) { + Text("\(channel.name ?? "")") + .font(.body) + .bold() + .lineLimit(1) + .foregroundColor(Color.jellyfinPurple) + .frame(alignment: .leading) + + progressBar() + .padding(.top, 4) + + HStack { + Text(currentProgramText.timeDisplay) + .font(.footnote) + .bold() + .lineLimit(1) + .foregroundColor(Color("TextHighlightColor")) + .frame(width: 38, alignment: .leading) + + Text(currentProgramText.title) + .font(.footnote) + .bold() + .lineLimit(1) + .foregroundColor(Color("TextHighlightColor")) + } + .padding(.top, 4) + + 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() } + .padding(8) + + Spacer() } } - .background { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color("BackgroundColor")) - .shadow(color: Color(.shadow), radius: 4, x: 0, y: 0) + .background(RoundedRectangle(cornerRadius: 10, style: .continuous).fill(Color.secondarySystemFill)) + .onTapGesture { + onSelect { loadingState in + loading = loadingState + } + } + } + + @ViewBuilder + func progressBar() -> some View { + VStack(alignment: .center) { + 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) } } @@ -141,12 +164,12 @@ struct LiveTVChannelItemWideElement: View { HStack(alignment: .top) { Text(timeText) .font(.footnote) - .lineLimit(2) + .lineLimit(1) .foregroundColor(color) .frame(width: 38, alignment: .leading) Text(titleText) .font(.footnote) - .lineLimit(2) + .lineLimit(1) .foregroundColor(color) } } diff --git a/Swiftfin/Views/LiveTVChannelsView.swift b/Swiftfin/Views/LiveTVChannelsView.swift index 148e68dd..4500c49a 100644 --- a/Swiftfin/Views/LiveTVChannelsView.swift +++ b/Swiftfin/Views/LiveTVChannelsView.swift @@ -47,7 +47,11 @@ struct LiveTVChannelsView: View { timeFormatter: viewModel.timeFormatter ), onSelect: { _ in - mainRouter.route(to: \.videoPlayer, OnlineVideoPlayerManager(item: channel, mediaSource: channel.mediaSources!.first!)) + guard let mediaSource = channel.mediaSources?.first else { + return + } + viewModel.stopScheduleCheckTimer() + mainRouter.route(to: \.liveVideoPlayer, LiveVideoPlayerManager(item: channel, mediaSource: mediaSource)) } ) } @@ -56,13 +60,16 @@ struct LiveTVChannelsView: View { Group { if viewModel.isLoading { ProgressView() - } else if viewModel.channelPrograms.isNotEmpty { + } else if viewModel.elements.isNotEmpty { CollectionVGrid( - viewModel.channelPrograms, + $viewModel.elements, layout: .minWidth(250, itemSpacing: 16, lineSpacing: 4) ) { program in channelCell(for: program) } + .onReachedBottomEdge(offset: .offset(300)) { + viewModel.send(.getNextPage) + } .onAppear { viewModel.startScheduleCheckTimer() } @@ -73,13 +80,18 @@ struct LiveTVChannelsView: View { VStack { Text(L10n.noResults) Button { - viewModel.getChannels() + viewModel.send(.refresh) } label: { Text(L10n.reload) } } } } + .onFirstAppear { + if viewModel.state == .initial { + viewModel.send(.refresh) + } + } .navigationBarTitleDisplayMode(.inline) } diff --git a/Swiftfin/Views/VideoPlayer/LiveNativeVideoPlayer.swift b/Swiftfin/Views/VideoPlayer/LiveNativeVideoPlayer.swift new file mode 100644 index 00000000..d5b1691d --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/LiveNativeVideoPlayer.swift @@ -0,0 +1,177 @@ +// +// 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) 2024 Jellyfin & Jellyfin Contributors +// + +import AVKit +import Combine +import Defaults +import JellyfinAPI +import SwiftUI + +struct LiveNativeVideoPlayer: View { + + @EnvironmentObject + private var router: LiveVideoPlayerCoordinator.Router + + @ObservedObject + private var videoPlayerManager: LiveVideoPlayerManager + + init(manager: LiveVideoPlayerManager) { + self.videoPlayerManager = manager + } + + @ViewBuilder + private var playerView: some View { + LiveNativeVideoPlayerView(videoPlayerManager: videoPlayerManager) + } + + var body: some View { + Group { + if let _ = videoPlayerManager.currentViewModel { + playerView + } else { + VideoPlayer.LoadingView() + } + } + .navigationBarHidden() + .statusBarHidden() + .ignoresSafeArea() + .onDisappear { + NotificationCenter.default.post(name: .livePlayerDismissed, object: nil) + } + } +} + +struct LiveNativeVideoPlayerView: UIViewControllerRepresentable { + + let videoPlayerManager: LiveVideoPlayerManager + + func makeUIViewController(context: Context) -> UILiveNativeVideoPlayerViewController { + UILiveNativeVideoPlayerViewController(manager: videoPlayerManager) + } + + func updateUIViewController(_ uiViewController: UILiveNativeVideoPlayerViewController, context: Context) {} +} + +class UILiveNativeVideoPlayerViewController: AVPlayerViewController { + + let videoPlayerManager: LiveVideoPlayerManager + + private var rateObserver: NSKeyValueObservation! + private var timeObserverToken: Any! + + init(manager: LiveVideoPlayerManager) { + + self.videoPlayerManager = manager + + super.init(nibName: nil, bundle: nil) + + let newPlayer: AVPlayer = .init(url: manager.currentViewModel.hlsPlaybackURL) + + newPlayer.allowsExternalPlayback = true + newPlayer.appliesMediaSelectionCriteriaAutomatically = false + newPlayer.currentItem?.externalMetadata = createMetadata() + + // enable pip + allowsPictureInPicturePlayback = true + + rateObserver = newPlayer.observe(\.rate, options: .new) { _, change in + guard let newValue = change.newValue else { return } + + if newValue == 0 { + self.videoPlayerManager.onStateUpdated(newState: .paused) + } else { + self.videoPlayerManager.onStateUpdated(newState: .playing) + } + } + + let time = CMTime(seconds: 0.1, preferredTimescale: 1000) + + timeObserverToken = newPlayer.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] time in + + guard let self else { return } + + if time.seconds >= 0 { + let newSeconds = Int(time.seconds) + let progress = CGFloat(newSeconds) / CGFloat(self.videoPlayerManager.currentViewModel.item.runTimeSeconds) + self.videoPlayerManager.currentProgressHandler.progress = progress + self.videoPlayerManager.currentProgressHandler.scrubbedProgress = progress + self.videoPlayerManager.currentProgressHandler.seconds = newSeconds + self.videoPlayerManager.currentProgressHandler.scrubbedSeconds = newSeconds + } + } + + player = newPlayer + } + + @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() + guard let timeObserverToken else { return } + player?.removeTimeObserver(timeObserverToken) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + player?.seek( + to: CMTimeMake( + value: Int64(videoPlayerManager.currentViewModel.item.startTimeSeconds - Defaults[.VideoPlayer.resumeOffset]), + timescale: 1 + ), + toleranceBefore: .zero, + toleranceAfter: .zero, + completionHandler: { _ in + self.play() + } + ) + } + + private func createMetadata() -> [AVMetadataItem] { + let allMetadata: [AVMetadataIdentifier: Any?] = [ + .commonIdentifierTitle: videoPlayerManager.currentViewModel.item.displayTitle, + .iTunesMetadataTrackSubTitle: videoPlayerManager.currentViewModel.item.subtitle, + ] + + return allMetadata.compactMap { createMetadataItem(for: $0, value: $1) } + } + + private func createMetadataItem( + for identifier: AVMetadataIdentifier, + value: Any? + ) -> AVMetadataItem? { + guard let value else { return nil } + 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 + } + + private func play() { + player?.play() + + videoPlayerManager.sendStartReport() + } + + private func stop() { + player?.pause() + + videoPlayerManager.sendStopReport() + } +} diff --git a/Swiftfin/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift b/Swiftfin/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift new file mode 100644 index 00000000..0d217faf --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/LiveOverlays/Components/LiveBottomBarView.swift @@ -0,0 +1,166 @@ +// +// 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) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import SwiftUI +import VLCUI + +extension LiveVideoPlayer.Overlay { + + struct LiveBottomBarView: View { + + @Default(.VideoPlayer.Overlay.chapterSlider) + private var chapterSlider + @Default(.VideoPlayer.jumpBackwardLength) + private var jumpBackwardLength + @Default(.VideoPlayer.jumpForwardLength) + private var jumpForwardLength + @Default(.VideoPlayer.Overlay.playbackButtonType) + private var playbackButtonType + @Default(.VideoPlayer.Overlay.sliderType) + private var sliderType + @Default(.VideoPlayer.Overlay.timestampType) + private var timestampType + + @Environment(\.currentOverlayType) + @Binding + private var currentOverlayType + + @Environment(\.isPresentingOverlay) + @Binding + private var isPresentingOverlay + @Environment(\.isScrubbing) + @Binding + private var isScrubbing: Bool + + @EnvironmentObject + private var currentProgressHandler: LiveVideoPlayerManager.CurrentProgressHandler + @EnvironmentObject + private var overlayTimer: TimerProxy + @EnvironmentObject + private var videoPlayerProxy: VLCVideoPlayer.Proxy + @EnvironmentObject + private var videoPlayerManager: LiveVideoPlayerManager + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + @State + private var currentChapter: ChapterInfo.FullInfo? + + @ViewBuilder + private var capsuleSlider: some View { + CapsuleSlider(progress: $currentProgressHandler.scrubbedProgress) + .isEditing(_isScrubbing.wrappedValue) + .trackMask { + if chapterSlider && !viewModel.chapters.isEmpty { + VideoPlayer.Overlay.ChapterTrack() + .clipShape(Capsule()) + } else { + Color.white + } + } + .bottomContent { + Group { + switch timestampType { + case .split: + VideoPlayer.Overlay.SplitTimeStamp() + case .compact: + VideoPlayer.Overlay.CompactTimeStamp() + } + } + .padding(5) + } + .leadingContent { + if playbackButtonType == .compact { + VideoPlayer.Overlay.SmallPlaybackButtons() + .padding(.trailing) + .disabled(isScrubbing) + } + } + .frame(height: 50) + } + + @ViewBuilder + private var thumbSlider: some View { + ThumbSlider(progress: $currentProgressHandler.scrubbedProgress) + .isEditing(_isScrubbing.wrappedValue) + .trackMask { + if chapterSlider && !viewModel.chapters.isEmpty { + VideoPlayer.Overlay.ChapterTrack() + .clipShape(Capsule()) + } else { + Color.white + } + } + .bottomContent { + Group { + switch timestampType { + case .split: + VideoPlayer.Overlay.SplitTimeStamp() + case .compact: + VideoPlayer.Overlay.CompactTimeStamp() + } + } + .padding(5) + } + .leadingContent { + if playbackButtonType == .compact { + VideoPlayer.Overlay.SmallPlaybackButtons() + .padding(.trailing) + .disabled(isScrubbing) + } + } + } + + var body: some View { + VStack(spacing: 0) { + HStack { + if chapterSlider, let currentChapter { + Button { + currentOverlayType = .chapters + overlayTimer.stop() + } label: { + HStack { + Text(currentChapter.displayTitle) + .monospacedDigit() + + Image(systemName: "chevron.right") + } + .foregroundColor(.white) + .font(.subheadline.weight(.medium)) + } + .disabled(isScrubbing) + } + + Spacer() + } + .padding(.leading, 5) + .padding(.bottom, 15) + + Group { + switch sliderType { + case .capsule: capsuleSlider + case .thumb: thumbSlider + } + } + } + .onChange(of: currentProgressHandler.scrubbedSeconds) { newValue in + guard chapterSlider else { return } + let newChapter = viewModel.chapter(from: newValue) + if newChapter != currentChapter { + if isScrubbing && Defaults[.hapticFeedback] { + UIDevice.impact(.light) + } + + self.currentChapter = newChapter + } + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/LiveOverlays/Components/LiveTopBarView.swift b/Swiftfin/Views/VideoPlayer/LiveOverlays/Components/LiveTopBarView.swift new file mode 100644 index 00000000..7a81d438 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/LiveOverlays/Components/LiveTopBarView.swift @@ -0,0 +1,68 @@ +// +// 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) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Stinsen +import SwiftUI +import VLCUI + +extension LiveVideoPlayer.Overlay { + + struct LiveTopBarView: View { + + @EnvironmentObject + private var router: LiveVideoPlayerCoordinator.Router + @EnvironmentObject + private var splitContentViewProxy: SplitContentViewProxy + @EnvironmentObject + private var videoPlayerProxy: VLCVideoPlayer.Proxy + @EnvironmentObject + private var viewModel: VideoPlayerViewModel + + var body: some View { + VStack(alignment: .VideoPlayerTitleAlignmentGuide, spacing: 0) { + HStack(alignment: .center) { + Button { + videoPlayerProxy.stop() + router.dismissCoordinator {} + } label: { + Image(systemName: "xmark") + .padding() + } + .buttonStyle(ScalingButtonStyle(scale: 0.8)) + + Text(viewModel.item.displayTitle) + .font(.title3) + .fontWeight(.bold) + .lineLimit(1) + .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in + dimensions[.leading] + } + + Spacer() + + VideoPlayer.Overlay.BarActionButtons() + .buttonStyle(ScalingButtonStyle(scale: 0.8)) + } + .font(.system(size: 24)) + .tint(Color.white) + .foregroundColor(Color.white) + + if let subtitle = viewModel.item.subtitle { + Text(subtitle) + .font(.subheadline) + .foregroundColor(.white) + .alignmentGuide(.VideoPlayerTitleAlignmentGuide) { dimensions in + dimensions[.leading] + } + .offset(y: -10) + } + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/LiveOverlays/Components/PlaybackButtons/LiveLargePlaybackButtons.swift b/Swiftfin/Views/VideoPlayer/LiveOverlays/Components/PlaybackButtons/LiveLargePlaybackButtons.swift new file mode 100644 index 00000000..d7861d6d --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/LiveOverlays/Components/PlaybackButtons/LiveLargePlaybackButtons.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) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI +import VLCUI + +extension LiveVideoPlayer.Overlay { + + struct LiveLargePlaybackButtons: View { + + @Default(.VideoPlayer.jumpBackwardLength) + private var jumpBackwardLength + @Default(.VideoPlayer.jumpForwardLength) + private var jumpForwardLength + @Default(.VideoPlayer.showJumpButtons) + private var showJumpButtons + + @EnvironmentObject + private var timerProxy: TimerProxy + @EnvironmentObject + private var videoPlayerManager: LiveVideoPlayerManager + @EnvironmentObject + private var videoPlayerProxy: VLCVideoPlayer.Proxy + + @ViewBuilder + private var jumpBackwardButton: some View { + Button { + videoPlayerProxy.jumpBackward(Int(jumpBackwardLength.rawValue)) + timerProxy.start(5) + } label: { + Image(systemName: jumpBackwardLength.backwardImageLabel) + .font(.system(size: 36, weight: .regular, design: .default)) + .padding() + .contentShape(Rectangle()) + } + .contentShape(Rectangle()) + .buttonStyle(ScalingButtonStyle(scale: 0.9)) + } + + @ViewBuilder + private var playButton: some View { + Button { + switch videoPlayerManager.state { + case .playing: + videoPlayerProxy.pause() + default: + videoPlayerProxy.play() + } + timerProxy.start(5) + } label: { + Group { + switch videoPlayerManager.state { + case .stopped, .paused: + Image(systemName: "play.fill") + case .playing: + Image(systemName: "pause.fill") + default: + ProgressView() + .scaleEffect(2) + } + } + .font(.system(size: 56, weight: .bold, design: .default)) + .padding() + .transition(.opacity) + .contentShape(Rectangle()) + } + .contentShape(Rectangle()) + .buttonStyle(ScalingButtonStyle(scale: 0.9)) + } + + @ViewBuilder + private var jumpForwardButton: some View { + Button { + videoPlayerProxy.jumpForward(Int(jumpForwardLength.rawValue)) + timerProxy.start(5) + } label: { + Image(systemName: jumpForwardLength.forwardImageLabel) + .font(.system(size: 36, weight: .regular, design: .default)) + .padding() + .contentShape(Rectangle()) + } + .contentShape(Rectangle()) + .buttonStyle(ScalingButtonStyle(scale: 0.9)) + } + + var body: some View { + HStack(spacing: 0) { + + Spacer(minLength: 100) + + if showJumpButtons { + jumpBackwardButton + } + + playButton + .frame(minWidth: 100, maxWidth: 300) + + if showJumpButtons { + jumpForwardButton + } + + Spacer(minLength: 100) + } + .tint(Color.white) + .foregroundColor(Color.white) + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/LiveOverlays/Components/PlaybackButtons/LiveSmallPlaybackButton.swift b/Swiftfin/Views/VideoPlayer/LiveOverlays/Components/PlaybackButtons/LiveSmallPlaybackButton.swift new file mode 100644 index 00000000..d7f01388 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/LiveOverlays/Components/PlaybackButtons/LiveSmallPlaybackButton.swift @@ -0,0 +1,99 @@ +// +// 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) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI +import VLCUI + +extension LiveVideoPlayer.Overlay { + + struct LiveSmallPlaybackButtons: View { + + @Default(.VideoPlayer.jumpBackwardLength) + private var jumpBackwardLength + @Default(.VideoPlayer.jumpForwardLength) + private var jumpForwardLength + @Default(.VideoPlayer.showJumpButtons) + private var showJumpButtons + + @EnvironmentObject + private var timerProxy: TimerProxy + @EnvironmentObject + private var videoPlayerManager: VideoPlayerManager + @EnvironmentObject + private var videoPlayerProxy: VLCVideoPlayer.Proxy + + @ViewBuilder + private var jumpBackwardButton: some View { + Button { + videoPlayerProxy.jumpBackward(Int(jumpBackwardLength.rawValue)) + timerProxy.start(5) + } label: { + Image(systemName: jumpBackwardLength.backwardImageLabel) + .font(.system(size: 24, weight: .bold, design: .default)) + } + .contentShape(Rectangle()) + } + + @ViewBuilder + private var playButton: some View { + Button { + switch videoPlayerManager.state { + case .playing: + videoPlayerProxy.pause() + default: + videoPlayerProxy.play() + } + timerProxy.start(5) + } label: { + Group { + switch videoPlayerManager.state { + case .stopped, .paused: + Image(systemName: "play.fill") + case .playing: + Image(systemName: "pause.fill") + default: + ProgressView() + } + } + .font(.system(size: 28, weight: .bold, design: .default)) + .frame(width: 50, height: 50) + } + .contentShape(Rectangle()) + } + + @ViewBuilder + private var jumpForwardButton: some View { + Button { + videoPlayerProxy.jumpForward(Int(jumpForwardLength.rawValue)) + timerProxy.start(5) + } label: { + Image(systemName: jumpForwardLength.forwardImageLabel) + .font(.system(size: 24, weight: .bold, design: .default)) + } + .contentShape(Rectangle()) + } + + var body: some View { + HStack(spacing: 15) { + + if showJumpButtons { + jumpBackwardButton + } + + playButton + + if showJumpButtons { + jumpForwardButton + } + } + .tint(Color.white) + .foregroundColor(Color.white) + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/LiveOverlays/LiveMainOverlay.swift b/Swiftfin/Views/VideoPlayer/LiveOverlays/LiveMainOverlay.swift new file mode 100644 index 00000000..7cc44e7d --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/LiveOverlays/LiveMainOverlay.swift @@ -0,0 +1,127 @@ +// +// 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) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import SwiftUI + +extension LiveVideoPlayer { + + struct LiveMainOverlay: View { + + @Default(.VideoPlayer.Overlay.playbackButtonType) + private var playbackButtonType + + @Environment(\.currentOverlayType) + @Binding + private var currentOverlayType + @Environment(\.isPresentingOverlay) + @Binding + private var isPresentingOverlay + @Environment(\.isScrubbing) + @Binding + private var isScrubbing: Bool + @Environment(\.safeAreaInsets) + private var safeAreaInsets + + @EnvironmentObject + private var splitContentViewProxy: SplitContentViewProxy + + @StateObject + private var overlayTimer: TimerProxy = .init() + + var body: some View { + ZStack { + VStack { + Overlay.LiveTopBarView() + .if(UIDevice.hasNotch) { view in + view.padding(safeAreaInsets.mutating(\.trailing, with: 0)) + .padding(.trailing, splitContentViewProxy.isPresentingSplitView ? 0 : safeAreaInsets.trailing) + } + .if(UIDevice.isPad) { view in + view.padding(.top) + .padding2(.horizontal) + } + .background { + LinearGradient( + stops: [ + .init(color: .black.opacity(0.9), location: 0), + .init(color: .clear, location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .visible(playbackButtonType == .compact) + } + .visible(!isScrubbing && isPresentingOverlay) + + Spacer() + .allowsHitTesting(false) + + Overlay.LiveBottomBarView() + .if(UIDevice.hasNotch) { view in + view.padding(safeAreaInsets.mutating(\.trailing, with: 0)) + .padding(.trailing, splitContentViewProxy.isPresentingSplitView ? 0 : safeAreaInsets.trailing) + } + .if(UIDevice.isPad) { view in + view.padding2(.bottom) + .padding2(.horizontal) + } + .background { + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .black.opacity(0.5), location: 0.5), + .init(color: .black.opacity(0.5), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + .visible(isScrubbing || playbackButtonType == .compact) + } + .background { + Color.clear + .allowsHitTesting(true) + .contentShape(Rectangle()) + .allowsHitTesting(true) + } + .visible(isScrubbing || isPresentingOverlay) + } + + if playbackButtonType == .large { + LiveVideoPlayer.Overlay.LiveLargePlaybackButtons() + .visible(!isScrubbing && isPresentingOverlay) + } + } + .environmentObject(overlayTimer) + .background { + Color.black + .opacity(!isScrubbing && playbackButtonType == .large && isPresentingOverlay ? 0.5 : 0) + .allowsHitTesting(false) + } + .animation(.linear(duration: 0.1), value: isScrubbing) + .onChange(of: isPresentingOverlay) { newValue in + guard newValue, !isScrubbing else { return } + overlayTimer.start(5) + } + .onChange(of: isScrubbing) { newValue in + if newValue { + overlayTimer.stop() + } else { + overlayTimer.start(5) + } + } + .onChange(of: overlayTimer.isActive) { newValue in + guard !newValue, !isScrubbing else { return } + + withAnimation(.linear(duration: 0.3)) { + isPresentingOverlay = false + } + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/LiveOverlays/LiveOverlay.swift b/Swiftfin/Views/VideoPlayer/LiveOverlays/LiveOverlay.swift new file mode 100644 index 00000000..a013ec8d --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/LiveOverlays/LiveOverlay.swift @@ -0,0 +1,36 @@ +// +// 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) 2024 Jellyfin & Jellyfin Contributors +// + +import SwiftUI + +extension LiveVideoPlayer { + + struct Overlay: View { + + @Environment(\.isPresentingOverlay) + @Binding + private var isPresentingOverlay + + @State + private var currentOverlayType: VideoPlayer.OverlayType = .main + + var body: some View { + ZStack { + + LiveMainOverlay() + .visible(currentOverlayType == .main) + } + .animation(.linear(duration: 0.1), value: currentOverlayType) + .environment(\.currentOverlayType, $currentOverlayType) + .onChange(of: isPresentingOverlay) { newValue in + guard newValue else { return } + currentOverlayType = .main + } + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/LiveVideoPlayer.swift b/Swiftfin/Views/VideoPlayer/LiveVideoPlayer.swift new file mode 100644 index 00000000..3dd77a66 --- /dev/null +++ b/Swiftfin/Views/VideoPlayer/LiveVideoPlayer.swift @@ -0,0 +1,546 @@ +// +// 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) 2024 Jellyfin & Jellyfin Contributors +// + +import Defaults +import JellyfinAPI +import MediaPlayer +import Stinsen +import SwiftUI +import VLCUI + +// TODO: organize +// TODO: localization necessary for toast text? +// TODO: entire gesture layer should be separate + +struct LiveVideoPlayer: View { + + @Default(.VideoPlayer.jumpBackwardLength) + private var jumpBackwardLength + @Default(.VideoPlayer.jumpForwardLength) + private var jumpForwardLength + + @Default(.VideoPlayer.Gesture.horizontalPanGesture) + private var horizontalPanGesture + @Default(.VideoPlayer.Gesture.horizontalSwipeGesture) + private var horizontalSwipeGesture + @Default(.VideoPlayer.Gesture.longPressGesture) + private var longPressGesture + @Default(.VideoPlayer.Gesture.multiTapGesture) + private var multiTapGesture + @Default(.VideoPlayer.Gesture.doubleTouchGesture) + private var doubleTouchGesture + @Default(.VideoPlayer.Gesture.pinchGesture) + private var pinchGesture + @Default(.VideoPlayer.Gesture.verticalPanGestureLeft) + private var verticalGestureLeft + @Default(.VideoPlayer.Gesture.verticalPanGestureRight) + private var verticalGestureRight + + @Default(.VideoPlayer.Subtitle.subtitleColor) + private var subtitleColor + @Default(.VideoPlayer.Subtitle.subtitleFontName) + private var subtitleFontName + @Default(.VideoPlayer.Subtitle.subtitleSize) + private var subtitleSize + + @EnvironmentObject + private var router: LiveVideoPlayerCoordinator.Router + + @ObservedObject + private var currentProgressHandler: VideoPlayerManager.CurrentProgressHandler + @StateObject + private var splitContentViewProxy: SplitContentViewProxy = .init() + @ObservedObject + private var videoPlayerManager: LiveVideoPlayerManager + + @State + private var audioOffset: Int = 0 + @State + private var isAspectFilled: Bool = false + @State + private var isGestureLocked: Bool = false + @State + private var isPresentingOverlay: Bool = false + @State + private var isScrubbing: Bool = false + @State + private var playbackSpeed: Double = 1 + @State + private var subtitleOffset: Int = 0 + + private let gestureStateHandler: VideoPlayer.GestureStateHandler = .init() + private let updateViewProxy: UpdateViewProxy = .init() + + @ViewBuilder + private var playerView: some View { + SplitContentView(splitContentWidth: 400) + .proxy(splitContentViewProxy) + .content { + ZStack { + VLCVideoPlayer(configuration: videoPlayerManager.currentViewModel.vlcVideoPlayerConfiguration) + .proxy(videoPlayerManager.proxy) + .onTicksUpdated { ticks, _ in + + let newSeconds = ticks / 1000 + var newProgress = CGFloat(newSeconds) / CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) + if newProgress.isInfinite || newProgress.isNaN { + newProgress = 0 + } + currentProgressHandler.progress = newProgress + currentProgressHandler.seconds = newSeconds + + guard !isScrubbing else { return } + currentProgressHandler.scrubbedProgress = newProgress + } + .onStateUpdated { state, _ in + + videoPlayerManager.onStateUpdated(newState: state) + + if state == .ended { + if let _ = videoPlayerManager.nextViewModel, + Defaults[.VideoPlayer.autoPlayEnabled] + { + videoPlayerManager.selectNextViewModel() + } else { + router.dismissCoordinator {} + } + } + } + + GestureView() + .onHorizontalPan { + handlePan(action: horizontalPanGesture, state: $0, point: $1.x, velocity: $2, translation: $3) + } + .onHorizontalSwipe(translation: 100, velocity: 1500, sameSwipeDirectionTimeout: 1, handleHorizontalSwipe) + .onLongPress(minimumDuration: 2, handleLongPress) + .onPinch(handlePinchGesture) + .onTap(samePointPadding: 10, samePointTimeout: 0.7, handleTapGesture) + .onDoubleTouch(handleDoubleTouchGesture) + .onVerticalPan { + if $1.x <= 0.5 { + handlePan(action: verticalGestureLeft, state: $0, point: -$1.y, velocity: $2, translation: $3) + } else { + handlePan(action: verticalGestureRight, state: $0, point: -$1.y, velocity: $2, translation: $3) + } + } + + LiveVideoPlayer.Overlay() + .environmentObject(splitContentViewProxy) + .environmentObject(videoPlayerManager) + .environmentObject(videoPlayerManager.currentProgressHandler) + .environmentObject(videoPlayerManager.currentViewModel!) + .environmentObject(videoPlayerManager.proxy) + .environment(\.aspectFilled, $isAspectFilled) + .environment(\.isPresentingOverlay, $isPresentingOverlay) + .environment(\.isScrubbing, $isScrubbing) + .environment(\.playbackSpeed, $playbackSpeed) + } + } + .splitContent { + // Wrapped due to navigation controller popping due to published changes + WrappedView { + NavigationViewCoordinator(PlaybackSettingsCoordinator()).view() + } + .cornerRadius(20, corners: [.topLeft, .bottomLeft]) + .environmentObject(splitContentViewProxy) + .environmentObject(videoPlayerManager) + .environmentObject(videoPlayerManager.currentViewModel) + .environment(\.audioOffset, $audioOffset) + .environment(\.subtitleOffset, $subtitleOffset) + } + .onChange(of: videoPlayerManager.currentProgressHandler.scrubbedProgress) { newValue in + guard !newValue.isNaN && !newValue.isInfinite else { + return + } + DispatchQueue.main.async { + videoPlayerManager.currentProgressHandler + .scrubbedSeconds = Int(CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) * newValue) + } + } + .overlay(alignment: .top) { + UpdateView(proxy: updateViewProxy) + .padding(.top) + } + .videoPlayerKeyCommands( + gestureStateHandler: gestureStateHandler, + updateViewProxy: updateViewProxy + ) + .onDisappear { + NotificationCenter.default.post(name: .livePlayerDismissed, object: nil) + } + } + + var body: some View { + Group { + if let _ = videoPlayerManager.currentViewModel { + playerView + } else { + VideoPlayer.LoadingView() + } + } + .navigationBarHidden(true) + .statusBar(hidden: true) + .ignoresSafeArea() + .onChange(of: audioOffset) { newValue in + videoPlayerManager.proxy.setAudioDelay(.ticks(newValue)) + } + .onChange(of: isGestureLocked) { newValue in + if newValue { + updateViewProxy.present(systemName: "lock.fill", title: "Gestures Locked") + } else { + updateViewProxy.present(systemName: "lock.open.fill", title: "Gestures Unlocked") + } + } + .onChange(of: isScrubbing) { newValue in + guard !newValue else { return } + videoPlayerManager.proxy.setTime(.seconds(currentProgressHandler.scrubbedSeconds)) + } + .onChange(of: subtitleColor) { newValue in + videoPlayerManager.proxy.setSubtitleColor(.absolute(newValue.uiColor)) + } + .onChange(of: subtitleFontName) { newValue in + videoPlayerManager.proxy.setSubtitleFont(newValue) + } + .onChange(of: subtitleOffset) { newValue in + videoPlayerManager.proxy.setSubtitleDelay(.ticks(newValue)) + } + .onChange(of: subtitleSize) { newValue in + videoPlayerManager.proxy.setSubtitleSize(.absolute(24 - newValue)) + } + .onChange(of: videoPlayerManager.currentViewModel) { newViewModel in + guard let newViewModel else { return } + + videoPlayerManager.proxy.playNewMedia(newViewModel.vlcVideoPlayerConfiguration) + + isAspectFilled = false + audioOffset = 0 + subtitleOffset = 0 + } + .onDisappear { + NotificationCenter.default.post(name: .livePlayerDismissed, object: nil) + } + } +} + +extension LiveVideoPlayer { + + init(manager: LiveVideoPlayerManager) { + self.init( + currentProgressHandler: manager.currentProgressHandler, + videoPlayerManager: manager + ) + } +} + +// MARK: Gestures + +// TODO: refactor to be split into other files +// TODO: refactor so that actions are separate from the gesture calculations, so that actions are more general + +extension LiveVideoPlayer { + + private func handlePan( + action: PanAction, + state: UIGestureRecognizer.State, + point: CGFloat, + velocity: CGFloat, + translation: CGFloat + ) { + guard !isGestureLocked else { return } + + switch action { + case .none: + return + case .audioffset: + audioOffsetAction(state: state, point: point, velocity: velocity, translation: translation) + case .brightness: + brightnessAction(state: state, point: point, velocity: velocity, translation: translation) + case .playbackSpeed: + playbackSpeedAction(state: state, point: point, velocity: velocity, translation: translation) + case .scrub: + scrubAction(state: state, point: point, velocity: velocity, translation: translation, rate: 1) + case .slowScrub: + scrubAction(state: state, point: point, velocity: velocity, translation: translation, rate: 0.1) + case .subtitleOffset: + subtitleOffsetAction(state: state, point: point, velocity: velocity, translation: translation) + case .volume: + volumeAction(state: state, point: point, velocity: velocity, translation: translation) + } + } + + private func handleHorizontalSwipe( + unitPoint: UnitPoint, + direction: Bool, + amount: Int + ) { + guard !isGestureLocked else { return } + + switch horizontalSwipeGesture { + case .none: + return + case .jump: + jumpAction(unitPoint: .init(x: direction ? 1 : 0, y: 0), amount: amount) + } + } + + private func handleLongPress(point: UnitPoint) { + switch longPressGesture { + case .none: + return + case .gestureLock: + guard !isPresentingOverlay else { return } + isGestureLocked.toggle() + } + } + + private func handlePinchGesture(state: UIGestureRecognizer.State, unitPoint: UnitPoint, scale: CGFloat) { + guard !isGestureLocked else { return } + + switch pinchGesture { + case .none: + return + case .aspectFill: + aspectFillAction(state: state, unitPoint: unitPoint, scale: scale) + } + } + + private func handleTapGesture(unitPoint: UnitPoint, taps: Int) { + guard !isGestureLocked else { + updateViewProxy.present(systemName: "lock.fill", title: "Gestures Locked") + return + } + + if taps > 1 && multiTapGesture != .none { + + withAnimation(.linear(duration: 0.1)) { + isPresentingOverlay = false + } + + switch multiTapGesture { + case .none: + return + case .jump: + jumpAction(unitPoint: unitPoint, amount: taps - 1) + } + } else { + withAnimation(.linear(duration: 0.1)) { + isPresentingOverlay = !isPresentingOverlay + } + } + } + + private func handleDoubleTouchGesture(unitPoint: UnitPoint, taps: Int) { + guard !isGestureLocked else { + updateViewProxy.present(systemName: "lock.fill", title: "Gestures Locked") + return + } + + switch doubleTouchGesture { + case .none: + return + case .aspectFill: () +// aspectFillAction(state: state, unitPoint: unitPoint, scale: <#T##CGFloat#>) + case .gestureLock: + guard !isPresentingOverlay else { return } + isGestureLocked.toggle() + case .pausePlay: () + } + } +} + +// MARK: Actions + +extension LiveVideoPlayer { + + private func aspectFillAction(state: UIGestureRecognizer.State, unitPoint: UnitPoint, scale: CGFloat) { + guard state == .began || state == .changed else { return } + if scale > 1, !isAspectFilled { + isAspectFilled = true + UIView.animate(withDuration: 0.2) { + videoPlayerManager.proxy.aspectFill(1) + } + } else if scale < 1, isAspectFilled { + isAspectFilled = false + UIView.animate(withDuration: 0.2) { + videoPlayerManager.proxy.aspectFill(0) + } + } + } + + private func audioOffsetAction( + state: UIGestureRecognizer.State, + point: CGFloat, + velocity: CGFloat, + translation: CGFloat + ) { + if state == .began { + gestureStateHandler.beginningPanProgress = currentProgressHandler.progress + gestureStateHandler.beginningHorizontalPanUnit = point + gestureStateHandler.beginningAudioOffset = audioOffset + } else if state == .ended { + return + } + + let newOffset = gestureStateHandler.beginningAudioOffset - round( + Int((gestureStateHandler.beginningHorizontalPanUnit - point) * 2000), + toNearest: 100 + ) + + updateViewProxy.present(systemName: "speaker.wave.2.fill", title: newOffset.millisecondLabel) + audioOffset = clamp(newOffset, min: -30000, max: 30000) + } + + private func brightnessAction( + state: UIGestureRecognizer.State, + point: CGFloat, + velocity: CGFloat, + translation: CGFloat + ) { + if state == .began { + gestureStateHandler.beginningPanProgress = currentProgressHandler.progress + gestureStateHandler.beginningHorizontalPanUnit = point + gestureStateHandler.beginningBrightnessValue = UIScreen.main.brightness + } else if state == .ended { + return + } + + let newBrightness = gestureStateHandler.beginningBrightnessValue - (gestureStateHandler.beginningHorizontalPanUnit - point) + let clampedBrightness = clamp(newBrightness, min: 0, max: 1.0) + let flashPercentage = Int(clampedBrightness * 100) + + if flashPercentage >= 67 { + updateViewProxy.present(systemName: "sun.max.fill", title: "\(flashPercentage)%", iconSize: .init(width: 30, height: 30)) + } else if flashPercentage >= 33 { + updateViewProxy.present(systemName: "sun.max.fill", title: "\(flashPercentage)%") + } else { + updateViewProxy.present(systemName: "sun.min.fill", title: "\(flashPercentage)%", iconSize: .init(width: 20, height: 20)) + } + + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.01) { + UIScreen.main.brightness = clampedBrightness + } + } + + // TODO: decide on overlay behavior? + private func jumpAction( + unitPoint: UnitPoint, + amount: Int + ) { + if unitPoint.x <= 0.5 { + videoPlayerManager.proxy.jumpBackward(Int(jumpBackwardLength.rawValue)) + + updateViewProxy.present(systemName: "gobackward", title: "\(amount * Int(jumpBackwardLength.rawValue))s") + } else { + videoPlayerManager.proxy.jumpForward(Int(jumpForwardLength.rawValue)) + + updateViewProxy.present(systemName: "goforward", title: "\(amount * Int(jumpForwardLength.rawValue))s") + } + } + + private func playbackSpeedAction( + state: UIGestureRecognizer.State, + point: CGFloat, + velocity: CGFloat, + translation: CGFloat + ) { + if state == .began { + gestureStateHandler.beginningPanProgress = currentProgressHandler.progress + gestureStateHandler.beginningHorizontalPanUnit = point + gestureStateHandler.beginningPlaybackSpeed = playbackSpeed + } else if state == .ended { + return + } + + let newPlaybackSpeed = round( + gestureStateHandler.beginningPlaybackSpeed - Double(gestureStateHandler.beginningHorizontalPanUnit - point) * 2, + toNearest: 0.25 + ) + let clampedPlaybackSpeed = clamp(newPlaybackSpeed, min: 0.25, max: 5.0) + + updateViewProxy.present(systemName: "speedometer", title: clampedPlaybackSpeed.rateLabel) + + playbackSpeed = clampedPlaybackSpeed + videoPlayerManager.proxy.setRate(.absolute(Float(clampedPlaybackSpeed))) + } + + private func scrubAction( + state: UIGestureRecognizer.State, + point: CGFloat, + velocity: CGFloat, + translation: CGFloat, + rate: CGFloat + ) { + if state == .began { + isScrubbing = true + + gestureStateHandler.beginningPanProgress = currentProgressHandler.progress + gestureStateHandler.beginningHorizontalPanUnit = point + gestureStateHandler.beganPanWithOverlay = isPresentingOverlay + } else if state == .ended { + if !gestureStateHandler.beganPanWithOverlay { + isPresentingOverlay = false + } + + isScrubbing = false + + return + } + + let newProgress = gestureStateHandler.beginningPanProgress - (gestureStateHandler.beginningHorizontalPanUnit - point) * rate + currentProgressHandler.scrubbedProgress = clamp(newProgress, min: 0, max: 1) + } + + private func subtitleOffsetAction( + state: UIGestureRecognizer.State, + point: CGFloat, + velocity: CGFloat, + translation: CGFloat + ) { + if state == .began { + gestureStateHandler.beginningPanProgress = currentProgressHandler.progress + gestureStateHandler.beginningHorizontalPanUnit = point + gestureStateHandler.beginningSubtitleOffset = subtitleOffset + } else if state == .ended { + return + } + + let newOffset = gestureStateHandler.beginningSubtitleOffset - round( + Int((gestureStateHandler.beginningHorizontalPanUnit - point) * 2000), + toNearest: 100 + ) + let clampedOffset = clamp(newOffset, min: -30000, max: 30000) + + updateViewProxy.present(systemName: "captions.bubble.fill", title: clampedOffset.millisecondLabel) + + subtitleOffset = clampedOffset + } + + private func volumeAction( + state: UIGestureRecognizer.State, + point: CGFloat, + velocity: CGFloat, + translation: CGFloat + ) { + let volumeView = MPVolumeView() + guard let slider = volumeView.subviews.first(where: { $0 is UISlider }) as? UISlider else { return } + + if state == .began { + gestureStateHandler.beginningPanProgress = currentProgressHandler.progress + gestureStateHandler.beginningHorizontalPanUnit = point + gestureStateHandler.beginningVolumeValue = AVAudioSession.sharedInstance().outputVolume + } else if state == .ended { + return + } + + let newVolume = gestureStateHandler.beginningVolumeValue - Float(gestureStateHandler.beginningHorizontalPanUnit - point) + + DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.01) { + slider.value = newVolume + } + } +} diff --git a/Swiftfin/Views/VideoPlayer/VideoPlayer.swift b/Swiftfin/Views/VideoPlayer/VideoPlayer.swift index a3db41dd..d2363121 100644 --- a/Swiftfin/Views/VideoPlayer/VideoPlayer.swift +++ b/Swiftfin/Views/VideoPlayer/VideoPlayer.swift @@ -171,6 +171,9 @@ struct VideoPlayer: View { .environment(\.subtitleOffset, $subtitleOffset) } .onChange(of: videoPlayerManager.currentProgressHandler.scrubbedProgress) { newValue in + guard !newValue.isNaN && !newValue.isInfinite else { + return + } DispatchQueue.main.async { videoPlayerManager.currentProgressHandler .scrubbedSeconds = Int(CGFloat(videoPlayerManager.currentViewModel.item.runTimeSeconds) * newValue)