diff --git a/Model/Applications/VideosAPI.swift b/Model/Applications/VideosAPI.swift index ade65e22..464108e5 100644 --- a/Model/Applications/VideosAPI.swift +++ b/Model/Applications/VideosAPI.swift @@ -55,11 +55,12 @@ extension VideosAPI { } func shareURL(_ item: ContentItem, frontendHost: String? = nil, time: CMTime? = nil) -> URL? { - guard let frontendHost = frontendHost ?? account.instance.frontendHost else { + guard let frontendHost = frontendHost ?? account?.instance?.frontendHost, + var urlComponents = account?.instance?.urlComponents + else { return nil } - var urlComponents = account.instance.urlComponents urlComponents.host = frontendHost var queryItems = [URLQueryItem]() diff --git a/Model/Channel.swift b/Model/Channel.swift index 9c7d27e9..e4c72100 100644 --- a/Model/Channel.swift +++ b/Model/Channel.swift @@ -28,6 +28,10 @@ struct Channel: Identifiable, Hashable { self.videos = videos } + var detailsLoaded: Bool { + !subscriptionsString.isNil + } + var subscriptionsString: String? { if subscriptionsCount != nil, subscriptionsCount! > 0 { return subscriptionsCount!.formattedAsAbbreviation() diff --git a/Model/Player/PlayerModel.swift b/Model/Player/PlayerModel.swift index 6fd8a14c..134256a6 100644 --- a/Model/Player/PlayerModel.swift +++ b/Model/Player/PlayerModel.swift @@ -33,15 +33,17 @@ final class PlayerModel: ObservableObject { @Published var currentItem: PlayerQueueItem! { didSet { Defaults[.lastPlayed] = currentItem } } @Published var history = [PlayerQueueItem]() { didSet { Defaults[.history] = history } } - @Published var savedTime: CMTime? + @Published var preservedTime: CMTime? - @Published var playerNavigationLinkActive = false + @Published var playerNavigationLinkActive = false { didSet { pauseOnChannelPlayerDismiss() } } @Published var sponsorBlock = SponsorBlockAPI() @Published var segmentRestorationTime: CMTime? @Published var lastSkipped: Segment? { didSet { rebuildTVMenu() } } @Published var restoredSegments = [Segment]() + @Published var channelWithDetails: Channel? + var accounts: AccountsModel var comments: CommentsModel @@ -177,6 +179,14 @@ final class PlayerModel: ObservableObject { } } + private func pauseOnChannelPlayerDismiss() { + if !playingInPictureInPicture, !playerNavigationLinkActive { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.pause() + } + } + } + private func insertPlayerItem( _ stream: Stream, for video: Video, @@ -212,18 +222,18 @@ final class PlayerModel: ObservableObject { let replaceItemAndSeek = { self.player.replaceCurrentItem(with: playerItem) - self.seekToSavedTime { finished in + self.seekToPreservedTime { finished in guard finished else { return } - self.savedTime = nil + self.preservedTime = nil startPlaying() } } if preservingTime { - if savedTime.isNil { + if preservedTime.isNil { saveTime { replaceItemAndSeek() startPlaying() @@ -394,13 +404,13 @@ final class PlayerModel: ObservableObject { } DispatchQueue.main.async { [weak self] in - self?.savedTime = currentTime + self?.preservedTime = currentTime completionHandler() } } - private func seekToSavedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) { - guard let time = savedTime else { + private func seekToPreservedTime(completionHandler: @escaping (Bool) -> Void = { _ in }) { + guard let time = preservedTime else { return } @@ -528,6 +538,36 @@ final class PlayerModel: ObservableObject { currentArtwork = MPMediaItemArtwork(boundsSize: image!.size) { _ in image! } } + func loadCurrentItemChannelDetails() { + guard let video = currentVideo, + !video.channel.detailsLoaded + else { + return + } + + if restoreLoadedChannel() { + return + } + + accounts.api.channel(video.channel.id).load().onSuccess { [weak self] response in + if let channel: Channel = response.typedContent() { + self?.channelWithDetails = channel + withAnimation { + self?.currentItem.video.channel = channel + } + } + } + } + + @discardableResult func restoreLoadedChannel() -> Bool { + if !currentVideo.isNil, channelWithDetails?.id == currentVideo!.channel.id { + currentItem.video.channel = channelWithDetails! + return true + } + + return false + } + func rateLabel(_ rate: Float) -> String { let formatter = NumberFormatter() formatter.minimumFractionDigits = 0 diff --git a/Model/Player/PlayerQueue.swift b/Model/Player/PlayerQueue.swift index d43a0dec..f325668c 100644 --- a/Model/Player/PlayerQueue.swift +++ b/Model/Player/PlayerQueue.swift @@ -51,7 +51,8 @@ extension PlayerModel { currentItem.video = video! } - savedTime = currentItem.playbackTime + preservedTime = currentItem.playbackTime + restoreLoadedChannel() loadAvailableStreams(currentVideo!) { streams in guard let stream = self.preferredStream(streams) else { @@ -126,7 +127,7 @@ extension PlayerModel { } func isAutoplaying(_ item: AVPlayerItem) -> Bool { - player.currentItem == item && presentingPlayer + player.currentItem == item && (presentingPlayer || playerNavigationLinkActive || playingInPictureInPicture) } @discardableResult func enqueueVideo( diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index b03205bb..9fad8b86 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -33,6 +33,7 @@ extension Defaults.Keys { static let playerSidebar = Key("playerSidebar", default: PlayerSidebarSetting.defaultValue) static let playerInstanceID = Key("playerInstance") static let showKeywords = Key("showKeywords", default: false) + static let showChannelSubscribers = Key("showChannelSubscribers", default: true) static let commentsInstanceID = Key("commentsInstance", default: kavinPipedInstanceID) #if !os(tvOS) static let commentsPlacement = Key("commentsPlacement", default: .separate) diff --git a/Shared/Player/Player.swift b/Shared/Player/Player.swift index c0b204b5..596dad00 100644 --- a/Shared/Player/Player.swift +++ b/Shared/Player/Player.swift @@ -5,6 +5,7 @@ struct Player: UIViewControllerRepresentable { @EnvironmentObject private var comments @EnvironmentObject private var navigation @EnvironmentObject private var player + @EnvironmentObject private var subscriptions var controller: PlayerViewController? @@ -22,6 +23,7 @@ struct Player: UIViewControllerRepresentable { controller.commentsModel = comments controller.navigationModel = navigation controller.playerModel = player + controller.subscriptionsModel = subscriptions player.controller = controller return controller diff --git a/Shared/Player/PlayerViewController.swift b/Shared/Player/PlayerViewController.swift index 5dcc8970..27ca744b 100644 --- a/Shared/Player/PlayerViewController.swift +++ b/Shared/Player/PlayerViewController.swift @@ -1,5 +1,4 @@ import AVKit -import Logging import SwiftUI final class PlayerViewController: UIViewController { @@ -7,6 +6,7 @@ final class PlayerViewController: UIViewController { var commentsModel: CommentsModel! var navigationModel: NavigationModel! var playerModel: PlayerModel! + var subscriptionsModel: SubscriptionsModel! var playerViewController = AVPlayerViewController() #if !os(tvOS) @@ -71,6 +71,7 @@ final class PlayerViewController: UIViewController { .frame(maxHeight: 600) .environmentObject(commentsModel) .environmentObject(playerModel) + .environmentObject(subscriptionsModel) ) ) diff --git a/Shared/Player/VideoDetails.swift b/Shared/Player/VideoDetails.swift index e0b36893..8ec2a7bd 100644 --- a/Shared/Player/VideoDetails.swift +++ b/Shared/Player/VideoDetails.swift @@ -1,5 +1,6 @@ import Defaults import Foundation +import SDWebImageSwiftUI import SwiftUI struct VideoDetails: View { @@ -20,11 +21,15 @@ struct VideoDetails: View { @Environment(\.presentationMode) private var presentationMode @Environment(\.inNavigationView) private var inNavigationView + @Environment(\.navigationStyle) private var navigationStyle @EnvironmentObject private var accounts + @EnvironmentObject private var navigation @EnvironmentObject private var player + @EnvironmentObject private var recents @EnvironmentObject private var subscriptions + @Default(.showChannelSubscribers) private var showChannelSubscribers @Default(.showKeywords) private var showKeywords init( @@ -65,7 +70,9 @@ struct VideoDetails: View { } .padding(.horizontal) - if CommentsModel.enabled, CommentsModel.placement == .separate { + if !sidebarQueue || + (CommentsModel.enabled && CommentsModel.placement == .separate) + { pagePicker .padding(.horizontal) } @@ -178,21 +185,52 @@ struct VideoDetails: View { Group { if video != nil { HStack(alignment: .center) { - HStack(spacing: 4) { - if subscribed { - Image(systemName: "star.circle.fill") - } - VStack(alignment: .leading) { - Text(video!.channel.name) - .font(.system(size: 13)) - .bold() - if let subscribers = video!.channel.subscriptionsString { - Text("\(subscribers) subscribers") + HStack(spacing: 10) { + Group { + ZStack(alignment: .bottomTrailing) { + authorAvatar + + if subscribed { + Image(systemName: "star.circle.fill") + .background(Color.background) + .clipShape(Circle()) + .foregroundColor(.secondary) + } + } + + VStack(alignment: .leading) { + Text(video!.channel.name) + .font(.system(size: 14)) + .bold() + + if showChannelSubscribers { + Group { + if let subscribers = video!.channel.subscriptionsString { + Text("\(subscribers) subscribers") + } + } + .foregroundColor(.secondary) .font(.caption2) + } + } + } + } + .contentShape(RoundedRectangle(cornerRadius: 12)) + .contextMenu { + if let video = video { + Button(action: { + NavigationModel.openChannel( + video.channel, + player: player, + recents: recents, + navigation: navigation, + navigationStyle: navigationStyle + ) + }) { + Label("\(video.channel.name) Channel", systemImage: "rectangle.stack.fill.badge.person.crop") } } } - .foregroundColor(.secondary) if accounts.app.supportsSubscriptions { Spacer() @@ -209,7 +247,7 @@ struct VideoDetails: View { .alert(isPresented: $presentingUnsubscribeAlert) { Alert( title: Text( - "Are you you want to unsubscribe from \(video!.channel.name)?" + "Are you sure you want to unsubscribe from \(video!.channel.name)?" ), primaryButton: .destructive(Text("Unsubscribe")) { subscriptions.unsubscribe(video!.channel.id) @@ -364,6 +402,22 @@ struct VideoDetails: View { ContentItem(video: player.currentVideo!) } + private var authorAvatar: some View { + Group { + if let video = video, let url = video.channel.thumbnailURL { + WebImage(url: url) + .resizable() + .placeholder { + Rectangle().fill(Color("PlaceholderColor")) + } + .retryOnAppear(false) + .indicator(.activity) + .clipShape(Circle()) + .frame(width: 45, height: 45, alignment: .leading) + } + } + } + var detailsPage: some View { Group { Group { diff --git a/Shared/Settings/PlaybackSettings.swift b/Shared/Settings/PlaybackSettings.swift index 04d7aff7..253a101e 100644 --- a/Shared/Settings/PlaybackSettings.swift +++ b/Shared/Settings/PlaybackSettings.swift @@ -7,6 +7,7 @@ struct PlaybackSettings: View { @Default(.quality) private var quality @Default(.playerSidebar) private var playerSidebar @Default(.showKeywords) private var showKeywords + @Default(.showChannelSubscribers) private var channelSubscribers @Default(.saveHistory) private var saveHistory #if os(iOS) @@ -27,6 +28,7 @@ struct PlaybackSettings: View { } keywordsToggle + channelSubscribersToggle } #else Section(header: SettingsHeader(text: "Source")) { @@ -44,6 +46,7 @@ struct PlaybackSettings: View { #endif keywordsToggle + channelSubscribersToggle #endif } @@ -107,6 +110,10 @@ struct PlaybackSettings: View { private var keywordsToggle: some View { Toggle("Show video keywords", isOn: $showKeywords) } + + private var channelSubscribersToggle: some View { + Toggle("Show channel subscribers count", isOn: $channelSubscribers) + } } struct PlaybackSettings_Previews: PreviewProvider {