diff --git a/Shared/EnvironmentValues.swift b/Shared/EnvironmentValues.swift index 11848a42..3e1394be 100644 --- a/Shared/EnvironmentValues.swift +++ b/Shared/EnvironmentValues.swift @@ -26,6 +26,10 @@ private struct ListingStyleKey: EnvironmentKey { static let defaultValue = ListingStyle.cells } +private struct InNavigationViewKey: EnvironmentKey { + static let defaultValue = true +} + enum ListingStyle: String, CaseIterable, Defaults.Serializable { case cells case list @@ -94,4 +98,9 @@ extension EnvironmentValues { get { self[ListingStyleKey.self] } set { self[ListingStyleKey.self] = newValue } } + + var inNavigationView: Bool { + get { self[InNavigationViewKey.self] } + set { self[InNavigationViewKey.self] = newValue } + } } diff --git a/Shared/Player/RelatedView.swift b/Shared/Player/RelatedView.swift index d923f3d5..175bab28 100644 --- a/Shared/Player/RelatedView.swift +++ b/Shared/Player/RelatedView.swift @@ -23,14 +23,15 @@ struct RelatedView: View { } } } + .environment(\.inNavigationView, false) #if os(macOS) - .listStyle(.inset) + .listStyle(.inset) #elseif os(iOS) - .listStyle(.grouped) - .backport - .scrollContentBackground(false) + .listStyle(.grouped) + .backport + .scrollContentBackground(false) #else - .listStyle(.plain) + .listStyle(.plain) #endif } } diff --git a/Shared/Player/Video Details/PlayerQueueView.swift b/Shared/Player/Video Details/PlayerQueueView.swift index 5948dd4b..b36b69b5 100644 --- a/Shared/Player/Video Details/PlayerQueueView.swift +++ b/Shared/Player/Video Details/PlayerQueueView.swift @@ -34,14 +34,15 @@ struct PlayerQueueView: View { .backport .listRowSeparator(false) } + .environment(\.inNavigationView, false) #if os(macOS) - .listStyle(.inset) + .listStyle(.inset) #elseif os(iOS) - .listStyle(.grouped) - .backport - .scrollContentBackground(false) + .listStyle(.grouped) + .backport + .scrollContentBackground(false) #else - .listStyle(.plain) + .listStyle(.plain) #endif } diff --git a/Shared/Videos/VideoBanner.swift b/Shared/Videos/VideoBanner.swift index 99bb4022..d1acd3e4 100644 --- a/Shared/Videos/VideoBanner.swift +++ b/Shared/Videos/VideoBanner.swift @@ -5,26 +5,44 @@ import SDWebImageSwiftUI import SwiftUI struct VideoBanner: View { + var id: String? let video: Video? var playbackTime: CMTime? var videoDuration: TimeInterval? + var watch: Watch? - init(video: Video? = nil, playbackTime: CMTime? = nil, videoDuration: TimeInterval? = nil) { + @Default(.saveHistory) private var saveHistory + @Default(.watchedVideoStyle) private var watchedVideoStyle + @Default(.watchedVideoBadgeColor) private var watchedVideoBadgeColor + + @Environment(\.inChannelView) private var inChannelView + @Environment(\.inNavigationView) private var inNavigationView + @Environment(\.navigationStyle) private var navigationStyle + + init( + id: String? = nil, + video: Video? = nil, + playbackTime: CMTime? = nil, + videoDuration: TimeInterval? = nil, + watch: Watch? = nil + ) { + self.id = id self.video = video self.playbackTime = playbackTime self.videoDuration = videoDuration + self.watch = watch } var body: some View { HStack(alignment: .top, spacing: 12) { - VStack(spacing: thumbnailStackSpacing) { + VStack(spacing: 2) { smallThumbnail #if !os(tvOS) progressView #endif } - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 2) { Group { if let video { HStack(alignment: .top) { @@ -44,11 +62,13 @@ struct VideoBanner: View { .lineLimit(5) .font(.headline) - HStack(alignment: .top) { + Spacer() + + HStack { Group { if let video { if !video.isLocal || video.localStreamIsRemoteURL { - Text(video.displayAuthor) + channelControl } else { #if os(iOS) if DocumentsModel.shared.isDocument(video) { @@ -74,54 +94,88 @@ struct VideoBanner: View { } .lineLimit(1) - Spacer() - #if os(tvOS) progressView #endif + } + .foregroundColor(.secondary) - if !(video?.localStreamIsDirectory ?? false) { - Text(videoDurationLabel) - .fontWeight(.light) + HStack(spacing: 16) { + if let video { + if let date = video.publishedDate { + HStack(spacing: 2) { + Text(date) + .allowsTightening(true) + } + } + + if video.views > 0 { + HStack(spacing: 2) { + Image(systemName: "eye") + Text(video.viewsCount!) + } + } + } + + if timeInfo { + Spacer() + + timeLabel + .layoutPriority(1) } } - + .font(.caption) + .lineLimit(1) .foregroundColor(.secondary) } - .padding(.vertical, playbackTime.isNil ? 0 : 5) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxHeight: .infinity) + #if os(tvOS) + .padding(.vertical) + #endif } + .fixedSize(horizontal: false, vertical: true) .contentShape(Rectangle()) #if os(tvOS) .buttonStyle(.card) #else .buttonStyle(.plain) #endif - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: 150, alignment: .center) #if os(tvOS) - .padding(.vertical, 20) - .padding(.trailing, 10) - #endif - } - - private var thumbnailStackSpacing: Double { - #if os(tvOS) - 8 - #else - 2 + .padding(.trailing, 10) #endif + .opacity(contentOpacity) + .id(id ?? video?.videoID ?? video?.id) } @ViewBuilder private var smallThumbnail: some View { - ZStack { - Color("PlaceholderColor") - if let video { - if let thumbnail = video.thumbnailURL(quality: .medium) { - ThumbnailView(url: thumbnail) - } else if video.isLocal { - Image(systemName: video.localStreamImageSystemName) + ZStack(alignment: .bottomLeading) { + ZStack { + Color("PlaceholderColor") + if let video { + if let thumbnail = video.thumbnailURL(quality: .medium) { + ThumbnailView(url: thumbnail) + } else if video.isLocal { + Image(systemName: video.localStreamImageSystemName) + } + } else { + Image(systemName: "ellipsis") } - } else { - Image(systemName: "ellipsis") + } + + if saveHistory, + watchedVideoStyle.isShowingBadge, + watch?.finished ?? false + { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(Color( + watchedVideoBadgeColor == .colorSchemeBased ? "WatchProgressBarColor" : + watchedVideoBadgeColor == .red ? "AppRedColor" : "AppBlueColor" + )) + .background(Color.white) + .clipShape(Circle()) + .imageScale(.medium) + .offset(x: 5, y: -5) } } #if os(tvOS) @@ -133,6 +187,17 @@ struct VideoBanner: View { #endif } + private var contentOpacity: Double { + guard saveHistory, + !watch.isNil, + watchedVideoStyle == .decreasedOpacity || watchedVideoStyle == .both + else { + return 1 + } + + return watch!.finished ? 0.5 : 1 + } + private var thumbnailWidth: Double { #if os(tvOS) 250 @@ -149,19 +214,50 @@ struct VideoBanner: View { #endif } - private var videoDurationLabel: String { - guard videoDuration != 0 else { return "" } - return (videoDuration ?? video?.length ?? 0).formattedAsPlaybackTime() ?? "" + private var videoDurationLabel: String? { + guard videoDuration != 0 else { return nil } + return (videoDuration ?? video?.length)?.formattedAsPlaybackTime() + } + + private var watchStoppedAtLabel: String? { + guard let watch else { return nil } + + return watch.stoppedAt.formattedAsPlaybackTime(allowZero: true) + } + + var timeInfo: Bool { + videoDurationLabel != nil && (video == nil || !video!.localStreamIsDirectory) + } + + @ViewBuilder private var timeLabel: some View { + Group { + if let watch, let watchStoppedAtLabel, let videoDurationLabel, !watch.finished { + Text("\(watchStoppedAtLabel) / \(videoDurationLabel)") + } else if let videoDurationLabel { + Text(videoDurationLabel) + } else { + EmptyView() + } + } } private var progressView: some View { - Group { - if !playbackTime.isNil, !(video?.live ?? false) { - ProgressView(value: watchValue, total: progressViewTotal) - .progressViewStyle(.linear) - .frame(maxWidth: thumbnailWidth) - } + ProgressView(value: watchValue, total: progressViewTotal) + .progressViewStyle(.linear) + .frame(maxWidth: thumbnailWidth) + .opacity(showProgressView ? 1 : 0) + .frame(height: 12) + } + + private var showProgressView: Bool { + guard playbackTime != nil, + let video, + !video.live + else { + return false } + + return true } private var watchValue: Double { @@ -183,17 +279,68 @@ struct VideoBanner: View { private var finished: Bool { (progressViewValue / progressViewTotal) * 100 > Double(Defaults[.watchedThreshold]) } + + @ViewBuilder private var channelControl: some View { + if let video, !video.displayAuthor.isEmpty { + #if os(tvOS) + displayAuthor + #else + if navigationStyle == .tab, inNavigationView { + channelNavigationLink + } else { + channelButton + } + #endif + } + } + + @ViewBuilder private var channelNavigationLink: some View { + if let channel = video?.channel { + NavigationLink(destination: ChannelVideosView(channel: channel)) { + displayAuthor + } + } + } + + @ViewBuilder private var channelButton: some View { + if let video { + Button { + guard !inChannelView else { return } + + NavigationModel.shared.openChannel( + video.channel, + navigationStyle: navigationStyle + ) + } label: { + displayAuthor + } + #if os(tvOS) + .buttonStyle(.card) + #else + .buttonStyle(.plain) + #endif + .help("\(video.channel.name) Channel") + } + } + + @ViewBuilder private var displayAuthor: some View { + if let video, !video.displayAuthor.isEmpty { + Text(video.displayAuthor) + .fontWeight(.semibold) + .foregroundColor(.secondary) + } + } } struct VideoBanner_Previews: PreviewProvider { static var previews: some View { - VStack(spacing: 20) { + VStack(spacing: 2) { VideoBanner(video: Video.fixture, playbackTime: CMTime(seconds: 400, preferredTimescale: 10000)) VideoBanner(video: Video.fixtureUpcomingWithoutPublishedOrViews) VideoBanner(video: .local(URL(string: "https://apple.com/a/directory/of/video+that+has+very+long+title+that+will+likely.mp4")!)) VideoBanner(video: .local(URL(string: "file://a/b/c/d/e/f.mkv")!)) VideoBanner() } - .frame(maxWidth: 900) + .frame(maxWidth: 1300) } }