diff --git a/Model/Applications/InvidiousAPI.swift b/Model/Applications/InvidiousAPI.swift index 0b483b05..0ed3c91b 100644 --- a/Model/Applications/InvidiousAPI.swift +++ b/Model/Applications/InvidiousAPI.swift @@ -314,8 +314,8 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { likes: json["likeCount"].int, dislikes: json["dislikeCount"].int, keywords: json["keywords"].arrayValue.map { $0.stringValue }, - streams: extractFormatStreams(from: json["formatStreams"].arrayValue) + - extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue) + streams: extractStreams(from: json), + related: extractRelated(from: json) ) } @@ -349,6 +349,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { } } + private static func extractStreams(from json: JSON) -> [Stream] { + extractFormatStreams(from: json["formatStreams"].arrayValue) + + extractAdaptiveFormats(from: json["adaptiveFormats"].arrayValue) + } + private static func extractFormatStreams(from streams: [JSON]) -> [Stream] { streams.map { SingleAssetStream( @@ -378,4 +383,11 @@ final class InvidiousAPI: Service, ObservableObject, VideosAPI { ) } } + + private static func extractRelated(from content: JSON) -> [Video] { + content + .dictionaryValue["recommendedVideos"]? + .arrayValue + .compactMap(extractVideo(from:)) ?? [] + } } diff --git a/Model/Applications/PipedAPI.swift b/Model/Applications/PipedAPI.swift index fe63ad21..01829573 100644 --- a/Model/Applications/PipedAPI.swift +++ b/Model/Applications/PipedAPI.swift @@ -224,7 +224,8 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { thumbnails: thumbnails, likes: details["likes"]?.int, dislikes: details["dislikes"]?.int, - streams: extractStreams(from: content) + streams: extractStreams(from: content), + related: extractRelated(from: content) ) } @@ -310,6 +311,13 @@ final class PipedAPI: Service, ObservableObject, VideosAPI { return streams } + private static func extractRelated(from content: JSON) -> [Video] { + content + .dictionaryValue["relatedStreams"]? + .arrayValue + .compactMap(extractVideo(from:)) ?? [] + } + private static func compatibleAudioStreams(from content: JSON) -> [JSON] { content .dictionaryValue["audioStreams"]? diff --git a/Model/Video.swift b/Model/Video.swift index 6b09eeb9..a88a51d8 100644 --- a/Model/Video.swift +++ b/Model/Video.swift @@ -30,6 +30,8 @@ struct Video: Identifiable, Equatable, Hashable { var channel: Channel + var related = [Video]() + init( id: String? = nil, videoID: String, @@ -49,7 +51,8 @@ struct Video: Identifiable, Equatable, Hashable { likes: Int? = nil, dislikes: Int? = nil, keywords: [String] = [], - streams: [Stream] = [] + streams: [Stream] = [], + related: [Video] = [] ) { self.id = id ?? UUID().uuidString self.videoID = videoID @@ -70,6 +73,7 @@ struct Video: Identifiable, Equatable, Hashable { self.dislikes = dislikes self.keywords = keywords self.streams = streams + self.related = related } var publishedDate: String? { diff --git a/Pearvidious.xcodeproj/project.pbxproj b/Pearvidious.xcodeproj/project.pbxproj index cb3fff05..80769175 100644 --- a/Pearvidious.xcodeproj/project.pbxproj +++ b/Pearvidious.xcodeproj/project.pbxproj @@ -82,6 +82,8 @@ 372915E72687E3B900F5A35B /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372915E52687E3B900F5A35B /* Defaults.swift */; }; 372915E82687E3B900F5A35B /* Defaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 372915E52687E3B900F5A35B /* Defaults.swift */; }; 3730D8A02712E2B70020ED53 /* NowPlayingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3730D89F2712E2B70020ED53 /* NowPlayingView.swift */; }; + 373197D92732015300EF734F /* RelatedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373197D82732015300EF734F /* RelatedView.swift */; }; + 373197DA2732060100EF734F /* RelatedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373197D82732015300EF734F /* RelatedView.swift */; }; 37319F0527103F94004ECCD0 /* PlayerQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37319F0427103F94004ECCD0 /* PlayerQueue.swift */; }; 37319F0627103F94004ECCD0 /* PlayerQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37319F0427103F94004ECCD0 /* PlayerQueue.swift */; }; 37319F0727103F94004ECCD0 /* PlayerQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37319F0427103F94004ECCD0 /* PlayerQueue.swift */; }; @@ -525,6 +527,7 @@ 371F2F19269B43D300E4A7AB /* NavigationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationModel.swift; sourceTree = ""; }; 372915E52687E3B900F5A35B /* Defaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Defaults.swift; sourceTree = ""; }; 3730D89F2712E2B70020ED53 /* NowPlayingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlayingView.swift; sourceTree = ""; }; + 373197D82732015300EF734F /* RelatedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelatedView.swift; sourceTree = ""; }; 37319F0427103F94004ECCD0 /* PlayerQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerQueue.swift; sourceTree = ""; }; 373CFACA26966264003CB2C6 /* SearchQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchQuery.swift; sourceTree = ""; }; 373CFADA269663F1003CB2C6 /* Thumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thumbnail.swift; sourceTree = ""; }; @@ -783,6 +786,7 @@ 3743CA4D270EFE3400E4D32B /* PlayerQueueRow.swift */, 37CC3F4B270CFE1700608308 /* PlayerQueueView.swift */, 37BE0BD526A1D4A90092E2DB /* PlayerViewController.swift */, + 373197D82732015300EF734F /* RelatedView.swift */, 37B81AFE26D2CA3700675966 /* VideoDetails.swift */, 37B81AFB26D2C9C900675966 /* VideoDetailsPaddingModifier.swift */, 37B81AF826D2C9A700675966 /* VideoPlayerSizeModifier.swift */, @@ -1743,6 +1747,7 @@ 3748186E26A769D60084E870 /* DetailBadge.swift in Sources */, 37AAF2A026741C97007FC770 /* SubscriptionsView.swift in Sources */, 37599F30272B42810087F250 /* FavoriteItem.swift in Sources */, + 373197D92732015300EF734F /* RelatedView.swift in Sources */, 373CFAEB26975CBF003CB2C6 /* PlaylistFormView.swift in Sources */, 37B81B0226D2CAE700675966 /* PlaybackBar.swift in Sources */, 372915E62687E3B900F5A35B /* Defaults.swift in Sources */, @@ -2011,6 +2016,7 @@ 37152EEC26EFEB95004FB96D /* LazyView.swift in Sources */, 37484C2726FC83E000287258 /* InstanceForm.swift in Sources */, 37F49BA826CB0FCE00304AC0 /* PlaylistFormView.swift in Sources */, + 373197DA2732060100EF734F /* RelatedView.swift in Sources */, 37D4B19926717E1500C925CA /* Video.swift in Sources */, 378E50FD26FE8B9F00F49626 /* Instance.swift in Sources */, 37169AA82729E2CC0011DE61 /* AccountsBridge.swift in Sources */, diff --git a/Shared/Player/PlayerQueueView.swift b/Shared/Player/PlayerQueueView.swift index 5eee0a02..57bc4129 100644 --- a/Shared/Player/PlayerQueueView.swift +++ b/Shared/Player/PlayerQueueView.swift @@ -10,12 +10,12 @@ struct PlayerQueueView: View { List { Group { playingNext + related playedPreviously } - .padding(.vertical, 5) - .listRowInsets(EdgeInsets()) - #if os(iOS) - .padding(.horizontal, 10) + #if !os(iOS) + .padding(.vertical, 5) + .listRowInsets(EdgeInsets()) #endif } @@ -71,7 +71,27 @@ struct PlayerQueueView: View { } } - func removeButton(_ item: PlayerQueueItem, history: Bool) -> some View { + private var related: some View { + Group { + if !player.currentVideo.isNil, !player.currentVideo!.related.isEmpty { + Section(header: Text("Related")) { + ForEach(player.currentVideo!.related) { video in + PlayerQueueRow(item: PlayerQueueItem(video), fullScreen: $fullScreen) + .contextMenu { + Button("Play Next") { + player.playNext(video) + } + Button("Play Last") { + player.enqueueVideo(video) + } + } + } + } + } + } + } + + private func removeButton(_ item: PlayerQueueItem, history: Bool) -> some View { Button(role: .destructive) { if history { player.removeHistory(item) @@ -83,7 +103,7 @@ struct PlayerQueueView: View { } } - func removeAllButton(history: Bool) -> some View { + private func removeAllButton(history: Bool) -> some View { Button(role: .destructive) { if history { player.removeHistoryItems() diff --git a/Shared/Player/PlayerViewController.swift b/Shared/Player/PlayerViewController.swift index 792077c7..f49144b0 100644 --- a/Shared/Player/PlayerViewController.swift +++ b/Shared/Player/PlayerViewController.swift @@ -32,23 +32,29 @@ final class PlayerViewController: UIViewController { #if os(tvOS) playerModel.avPlayerViewController = playerViewController - playerViewController.customInfoViewControllers = [playerQueueInfoViewController] + playerViewController.customInfoViewControllers = [ + infoViewController([.related], title: "Related"), + infoViewController([.playingNext, .playedPreviously], title: "Playing Next") + ] #else embedViewController() #endif } #if os(tvOS) - var playerQueueInfoViewController: UIHostingController { + func infoViewController( + _ sections: [NowPlayingView.ViewSection], + title: String + ) -> UIHostingController { let controller = UIHostingController(rootView: AnyView( - NowPlayingView(inInfoViewController: true) + NowPlayingView(sections: sections, inInfoViewController: true) .frame(maxHeight: 600) .environmentObject(playerModel) ) ) - controller.title = "Playing Next" + controller.title = title return controller } diff --git a/Shared/Player/RelatedView.swift b/Shared/Player/RelatedView.swift new file mode 100644 index 00000000..71670ffd --- /dev/null +++ b/Shared/Player/RelatedView.swift @@ -0,0 +1,30 @@ +import SwiftUI + +struct RelatedView: View { + @EnvironmentObject private var player + + var body: some View { + List { + if !player.currentVideo.isNil, !player.currentVideo!.related.isEmpty { + Section(header: Text("Related")) { + ForEach(player.currentVideo!.related) { video in + PlayerQueueRow(item: PlayerQueueItem(video), fullScreen: .constant(false)) + } + } + } + } + #if os(macOS) + .listStyle(.inset) + #elseif os(iOS) + .listStyle(.grouped) + #else + .listStyle(.plain) + #endif + } +} + +struct RelatedView_Previews: PreviewProvider { + static var previews: some View { + RelatedView() + } +} diff --git a/Shared/Player/VideoDetails.swift b/Shared/Player/VideoDetails.swift index daaaac4a..996cdc19 100644 --- a/Shared/Player/VideoDetails.swift +++ b/Shared/Player/VideoDetails.swift @@ -3,7 +3,7 @@ import SwiftUI struct VideoDetails: View { enum Page { - case details, queue + case details, queue, related } @Binding var sidebarQueue: Bool @@ -89,6 +89,14 @@ struct VideoDetails: View { case .queue: PlayerQueueView(fullScreen: $fullScreen) .edgesIgnoringSafeArea(.horizontal) + + case .related: + #if os(macOS) + EmptyView() + #else + RelatedView() + .edgesIgnoringSafeArea(.horizontal) + #endif } } .padding(.top, inNavigationView && fullScreen ? 10 : 0) @@ -109,7 +117,9 @@ struct VideoDetails: View { .onChange(of: sidebarQueue) { queue in #if !os(macOS) if queue { - currentPage = .details + if currentPage == .queue { + currentPage = .details + } } else { currentPage = .queue } @@ -216,6 +226,7 @@ struct VideoDetails: View { var pagePicker: some View { Picker("Page", selection: $currentPage) { Text("Details").tag(Page.details) + Text("Related").tag(Page.related) Text("Queue").tag(Page.queue) } diff --git a/Shared/Player/VideoPlayerView.swift b/Shared/Player/VideoPlayerView.swift index 6e61c521..c68ecec1 100644 --- a/Shared/Player/VideoPlayerView.swift +++ b/Shared/Player/VideoPlayerView.swift @@ -62,8 +62,14 @@ struct VideoPlayerView: View { if player.currentItem.isNil { playerPlaceholder(geometry: geometry) } else { - player.playerView - .modifier(VideoPlayerSizeModifier(geometry: geometry)) + #if os(macOS) + Player() + .modifier(VideoPlayerSizeModifier(geometry: geometry)) + + #else + player.playerView + .modifier(VideoPlayerSizeModifier(geometry: geometry)) + #endif } } #if os(iOS) @@ -107,6 +113,11 @@ struct VideoPlayerView: View { .frame(minWidth: 250) #endif } + .onDisappear { + if !player.playingInPictureInPicture { + player.pause() + } + } } func playerPlaceholder(geometry: GeometryProxy) -> some View { diff --git a/Shared/Views/ShareButton.swift b/Shared/Views/ShareButton.swift index bb0aac77..75620cad 100644 --- a/Shared/Views/ShareButton.swift +++ b/Shared/Views/ShareButton.swift @@ -21,6 +21,7 @@ struct ShareButton: View { var body: some View { Menu { instanceActions + Divider() youtubeActions } label: { Label("Share", systemImage: "square.and.arrow.up") diff --git a/macOS/PictureInPictureDelegate.swift b/macOS/PictureInPictureDelegate.swift index 2b61e001..686ae348 100644 --- a/macOS/PictureInPictureDelegate.swift +++ b/macOS/PictureInPictureDelegate.swift @@ -9,20 +9,26 @@ final class PictureInPictureDelegate: NSObject, AVPlayerViewPictureInPictureDele } func playerViewWillStartPicture(inPicture _: AVPlayerView) { - playerModel.playingInPictureInPicture = true - playerModel.presentingPlayer = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + self?.playerModel.playingInPictureInPicture = true + self?.playerModel.presentingPlayer = false + } } func playerViewWillStopPicture(inPicture _: AVPlayerView) { - playerModel.playingInPictureInPicture = false - playerModel.presentPlayer() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + self?.playerModel.playingInPictureInPicture = false + self?.playerModel.presentPlayer() + } } func playerView( _: AVPlayerView, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: (Bool) -> Void ) { - playerModel.presentingPlayer = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + self?.playerModel.presentingPlayer = true + } completionHandler(true) } } diff --git a/macOS/PlayerViewController.swift b/macOS/PlayerViewController.swift index 8387443f..27248c57 100644 --- a/macOS/PlayerViewController.swift +++ b/macOS/PlayerViewController.swift @@ -7,9 +7,6 @@ final class PlayerViewController: NSViewController { var pictureInPictureDelegate = PictureInPictureDelegate() override func viewDidDisappear() { - if !playerModel.playingInPictureInPicture { - playerModel.pause() - } super.viewDidDisappear() } diff --git a/tvOS/NowPlayingView.swift b/tvOS/NowPlayingView.swift index 754c6b3f..289897e2 100644 --- a/tvOS/NowPlayingView.swift +++ b/tvOS/NowPlayingView.swift @@ -1,6 +1,11 @@ import SwiftUI struct NowPlayingView: View { + enum ViewSection: CaseIterable { + case nowPlaying, playingNext, playedPreviously, related + } + + var sections = ViewSection.allCases var inInfoViewController = false @EnvironmentObject private var player @@ -18,7 +23,7 @@ struct NowPlayingView: View { var content: some View { List { Group { - if !inInfoViewController, let item = player.currentItem { + if sections.contains(.nowPlaying), let item = player.currentItem { Section(header: Text("Now Playing")) { Button { player.presentPlayer() @@ -29,29 +34,53 @@ struct NowPlayingView: View { .onPlayPauseCommand(perform: player.togglePlay) } - Section(header: Text("Playing Next")) { - if player.queue.isEmpty { - Text("Playback queue is empty") - .padding([.vertical, .leading], 40) - .foregroundColor(.secondary) - } - - ForEach(player.queue) { item in - Button { - player.advanceToItem(item) - player.presentPlayer() - } label: { - VideoBanner(video: item.video) + if sections.contains(.playingNext) { + Section(header: Text("Playing Next")) { + if player.queue.isEmpty { + Text("Playback queue is empty") + .padding([.vertical, .leading], 40) + .foregroundColor(.secondary) } - .contextMenu { - Button("Delete", role: .destructive) { - player.remove(item) + + ForEach(player.queue) { item in + Button { + player.advanceToItem(item) + player.presentPlayer() + } label: { + VideoBanner(video: item.video) + } + .contextMenu { + Button("Delete", role: .destructive) { + player.remove(item) + } } } } } - if !player.history.isEmpty { + if sections.contains(.related), !player.currentVideo.isNil, !player.currentVideo!.related.isEmpty { + Section(header: inInfoViewController ? AnyView(EmptyView()) : AnyView(Text("Related"))) { + ForEach(player.currentVideo!.related) { video in + Button { + player.playNow(video) + player.presentPlayer() + } label: { + VideoBanner(video: video) + } + .contextMenu { + Button("Play Next") { + player.playNext(video) + } + Button("Play Last") { + player.enqueueVideo(video) + } + Button("Cancel", role: .cancel) {} + } + } + } + } + + if sections.contains(.playedPreviously), !player.history.isEmpty { Section(header: Text("Played Previously")) { ForEach(player.history) { item in Button { @@ -75,7 +104,6 @@ struct NowPlayingView: View { } .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 20)) .padding(.vertical, 20) -// .padding(.horizontal, 40) } .padding(.horizontal, inInfoViewController ? 40 : 0) .listStyle(.grouped) @@ -86,7 +114,6 @@ struct NowPlayingView: View { Text(text) .font((inInfoViewController ? Font.system(size: 40) : .title3).bold()) .foregroundColor(.secondary) -// .padding(.leading, 40) } }