mirror of
https://github.com/yattee/yattee.git
synced 2024-12-23 05:53:41 +00:00
9510d91d61
Signed-off-by: Toni Förster <toni.foerster@gmail.com>
544 lines
19 KiB
Swift
544 lines
19 KiB
Swift
import Defaults
|
|
import Foundation
|
|
import SDWebImageSwiftUI
|
|
import SwiftUI
|
|
|
|
struct VideoDetails: View {
|
|
static let pageMenuID = "pageMenu"
|
|
|
|
struct TitleView: View {
|
|
@ObservedObject private var model = PlayerModel.shared
|
|
@State private var titleSize = CGSize.zero
|
|
|
|
var video: Video? { model.videoForDisplay }
|
|
|
|
var body: some View {
|
|
HStack(spacing: 0) {
|
|
Text(model.videoForDisplay?.displayTitle ?? "Not playing")
|
|
.font(.title3.bold())
|
|
.lineLimit(4)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
struct ChannelView: View {
|
|
@ObservedObject private var model = PlayerModel.shared
|
|
@Binding var detailsVisibility: Bool
|
|
|
|
var video: Video? { model.videoForDisplay }
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Button {
|
|
guard let channel = video?.channel else { return }
|
|
NavigationModel.shared.openChannel(channel, navigationStyle: .sidebar)
|
|
} label: {
|
|
if detailsVisibility {
|
|
ChannelAvatarView(
|
|
channel: video?.channel,
|
|
video: video
|
|
)
|
|
} else {
|
|
Circle()
|
|
.foregroundColor(Color("PlaceholderColor"))
|
|
}
|
|
}
|
|
.frame(width: 40, height: 40)
|
|
.buttonStyle(.plain)
|
|
.padding(.trailing, 5)
|
|
// TODO: when setting tvOS minimum to 16, the platform modifier can be removed
|
|
#if !os(tvOS)
|
|
.simultaneousGesture(
|
|
TapGesture() // Ensures the button tap is recognized
|
|
)
|
|
#endif
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack {
|
|
if let name = model.videoForDisplay?.channel.name, !name.isEmpty {
|
|
Text(name)
|
|
.font(.subheadline)
|
|
.fontWeight(.semibold)
|
|
.lineLimit(1)
|
|
// TODO: when setting tvOS minimum to 16, the platform modifier can be removed
|
|
#if !os(tvOS)
|
|
.onTapGesture {
|
|
guard let channel = video?.channel else { return }
|
|
NavigationModel.shared.openChannel(channel, navigationStyle: .sidebar)
|
|
}
|
|
.accessibilityAddTraits(.isButton)
|
|
#endif
|
|
} else if model.videoBeingOpened != nil {
|
|
Text("Yattee")
|
|
.font(.subheadline)
|
|
.redacted(reason: .placeholder)
|
|
}
|
|
|
|
if let video, !video.isLocal {
|
|
HStack(spacing: 2) {
|
|
Image(systemName: "person.2.fill")
|
|
|
|
if let channel = model.videoForDisplay?.channel {
|
|
if let subscriptions = channel.subscriptionsString {
|
|
Text(subscriptions)
|
|
} else {
|
|
Text("1234").redacted(reason: .placeholder)
|
|
}
|
|
}
|
|
}
|
|
.font(.caption2)
|
|
}
|
|
}
|
|
.foregroundColor(.secondary)
|
|
|
|
if video != nil {
|
|
VideoMetadataView()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct VideoMetadataView: View {
|
|
@ObservedObject private var model = PlayerModel.shared
|
|
@Default(.enableReturnYouTubeDislike) private var enableReturnYouTubeDislike
|
|
|
|
var video: Video? { model.videoForDisplay }
|
|
|
|
var body: some View {
|
|
HStack(spacing: 4) {
|
|
publishedDateSection
|
|
|
|
HStack(spacing: 4) {
|
|
if model.videoBeingOpened != nil || video?.viewsCount != nil {
|
|
Image(systemName: "eye")
|
|
}
|
|
|
|
if let views = video?.viewsCount {
|
|
Text(views)
|
|
} else if model.videoBeingOpened != nil {
|
|
Text("123").redacted(reason: .placeholder)
|
|
}
|
|
|
|
if model.videoBeingOpened != nil || video?.likesCount != nil {
|
|
Image(systemName: "hand.thumbsup")
|
|
}
|
|
|
|
if let likes = video?.likesCount, !likes.isEmpty {
|
|
Text(likes)
|
|
} else {
|
|
Text("123").redacted(reason: .placeholder)
|
|
}
|
|
|
|
if enableReturnYouTubeDislike {
|
|
if model.videoBeingOpened != nil || video?.dislikesCount != nil {
|
|
Image(systemName: "hand.thumbsdown")
|
|
}
|
|
|
|
if let dislikes = video?.dislikesCount, !dislikes.isEmpty {
|
|
Text(dislikes)
|
|
} else {
|
|
Text("123").redacted(reason: .placeholder)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.font(.caption2)
|
|
.foregroundColor(.secondary)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
var publishedDateSection: some View {
|
|
Group {
|
|
if let video {
|
|
HStack(spacing: 4) {
|
|
if let published = video.publishedDate {
|
|
Text(published)
|
|
} else {
|
|
Text("1 wk ago").redacted(reason: .placeholder)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
enum DetailsPage: String, CaseIterable, Defaults.Serializable {
|
|
case info, comments, queue
|
|
|
|
var title: String {
|
|
rawValue.capitalized.localized()
|
|
}
|
|
}
|
|
|
|
var video: Video?
|
|
|
|
@Binding var fullScreen: Bool
|
|
@Binding var sidebarQueue: Bool
|
|
|
|
@State private var detailsSize = CGSize.zero
|
|
@State private var detailsVisibility = Constants.detailsVisibility
|
|
@State private var subscribed = false
|
|
@State private var subscriptionToggleButtonDisabled = false
|
|
@State private var page = DetailsPage.info
|
|
@State private var descriptionExpanded = false
|
|
@State private var chaptersExpanded = false
|
|
|
|
@Environment(\.navigationStyle) private var navigationStyle
|
|
#if os(iOS)
|
|
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
|
#endif
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
@ObservedObject private var accounts = AccountsModel.shared
|
|
@ObservedObject private var comments = CommentsModel.shared
|
|
@ObservedObject private var player = PlayerModel.shared
|
|
|
|
@Default(.enableReturnYouTubeDislike) private var enableReturnYouTubeDislike
|
|
@Default(.playerSidebar) private var playerSidebar
|
|
@Default(.showInspector) private var showInspector
|
|
@Default(.showChapters) private var showChapters
|
|
@Default(.showChapterThumbnails) private var showChapterThumbnails
|
|
@Default(.showChapterThumbnailsOnlyWhenDifferent) private var showChapterThumbnailsOnlyWhenDifferent
|
|
@Default(.showRelated) private var showRelated
|
|
@Default(.showComments) private var showComments
|
|
#if !os(tvOS)
|
|
@Default(.showScrollToTopInComments) private var showScrollToTopInComments
|
|
#endif
|
|
@Default(.expandVideoDescription) private var expandVideoDescription
|
|
@Default(.expandChapters) private var expandChapters
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
TitleView()
|
|
if video != nil, !video!.isLocal {
|
|
ChannelView(detailsVisibility: $detailsVisibility)
|
|
.layoutPriority(1)
|
|
.padding(.bottom, 6)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
.padding(.horizontal, 16)
|
|
// swiftlint:disable trailing_closure
|
|
// TODO: when setting tvOS minimum to 16, the platform modifier can be removed
|
|
#if !os(tvOS)
|
|
.simultaneousGesture( // Simultaneous gesture to prioritize button tap
|
|
TapGesture(count: 2).onEnded {
|
|
withAnimation(.default) {
|
|
fullScreen.toggle()
|
|
}
|
|
}
|
|
)
|
|
#endif
|
|
// swiftlint:enable trailing_closure
|
|
if VideoActions().isAnyActionVisible() {
|
|
VideoActions(video: player.videoForDisplay)
|
|
.padding(.vertical, 5)
|
|
.frame(maxHeight: 50)
|
|
.frame(maxWidth: .infinity)
|
|
.borderTop(height: 0.5, color: Color("ControlsBorderColor"))
|
|
.borderBottom(height: 0.5, color: Color("ControlsBorderColor"))
|
|
.animation(nil, value: player.currentItem)
|
|
.frame(minWidth: 0, maxWidth: .infinity)
|
|
} else {
|
|
Rectangle()
|
|
.fill(Color.clear)
|
|
.frame(height: 0.5)
|
|
.frame(maxWidth: .infinity)
|
|
.background(Color("ControlsBorderColor"))
|
|
}
|
|
|
|
ScrollViewReader { proxy in
|
|
pageView
|
|
.overlay(scrollToTopButton(proxy), alignment: .bottomTrailing)
|
|
}
|
|
#if os(iOS)
|
|
.opacity(detailsVisibility ? 1 : 0)
|
|
#endif
|
|
}
|
|
.overlay(GeometryReader { proxy in
|
|
Color.clear
|
|
.onAppear {
|
|
detailsSize = proxy.size
|
|
}
|
|
.onChange(of: proxy.size) { newSize in
|
|
guard !player.playingFullScreen else { return }
|
|
detailsSize = newSize
|
|
}
|
|
})
|
|
.background(colorScheme == .dark ? Color.black : .white)
|
|
.onAppear {
|
|
descriptionExpanded = expandVideoDescription
|
|
chaptersExpanded = expandChapters
|
|
}
|
|
}
|
|
|
|
#if os(iOS)
|
|
private var maxWidth: Double {
|
|
let width = min(detailsSize.width, player.playerSize.width)
|
|
if width.isNormal, width > 0 {
|
|
return width
|
|
}
|
|
|
|
return 0
|
|
}
|
|
#endif
|
|
|
|
private var contentItem: ContentItem {
|
|
ContentItem(video: player.currentVideo)
|
|
}
|
|
|
|
@ViewBuilder var pageMenu: some View {
|
|
Picker("Page", selection: $page) {
|
|
ForEach(DetailsPage.allCases.filter { pageAvailable($0) }, id: \.rawValue) { page in
|
|
Text(page.title).tag(page)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.labelsHidden()
|
|
}
|
|
|
|
func pageAvailable(_ page: DetailsPage) -> Bool {
|
|
guard let video else { return false }
|
|
|
|
switch page {
|
|
case .queue:
|
|
return !sidebarQueue && player.isAdvanceToNextItemAvailable
|
|
case .comments:
|
|
return showComments
|
|
default:
|
|
return !video.isLocal
|
|
}
|
|
}
|
|
|
|
func infoView(video: Video) -> some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
if !player.videoBeingOpened.isNil && (video.description.isNil || video.description!.isEmpty) {
|
|
VStack {
|
|
ProgressView()
|
|
.progressViewStyle(.circular)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
} else if let description = video.description, !description.isEmpty {
|
|
Section(header: descriptionHeader) {
|
|
VideoDescription(video: video, detailsSize: detailsSize, expand: $descriptionExpanded)
|
|
.padding(.horizontal)
|
|
}
|
|
} else if !video.isLocal {
|
|
Text("No description")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.padding(.horizontal)
|
|
}
|
|
|
|
if player.videoBeingOpened.isNil {
|
|
if showChapters,
|
|
!video.isLocal,
|
|
!video.chapters.isEmpty
|
|
{
|
|
Section(header: chaptersHeader) {
|
|
ChaptersView(expand: $chaptersExpanded, chaptersHaveImages: chaptersHaveImages, showThumbnails: showThumbnails)
|
|
}
|
|
}
|
|
|
|
if showInspector == .always || video.isLocal {
|
|
InspectorView(video: player.videoForDisplay)
|
|
.padding(.horizontal)
|
|
}
|
|
|
|
if showRelated,
|
|
!sidebarQueue,
|
|
!(player.videoForDisplay?.related.isEmpty ?? true)
|
|
{
|
|
RelatedView()
|
|
.padding(.horizontal)
|
|
.padding(.top, 20)
|
|
}
|
|
}
|
|
}
|
|
.onAppear {
|
|
if !pageAvailable(page) {
|
|
page = .info
|
|
}
|
|
}
|
|
.transition(.opacity)
|
|
.animation(nil, value: player.currentItem)
|
|
#if os(iOS)
|
|
.frame(maxWidth: YatteeApp.isForPreviews ? .infinity : maxWidth)
|
|
#endif
|
|
}
|
|
|
|
var pageView: some View {
|
|
ScrollView(.vertical) {
|
|
LazyVStack {
|
|
pageMenu
|
|
.id(Self.pageMenuID)
|
|
.padding(5)
|
|
|
|
switch page {
|
|
case .info:
|
|
if let video = self.video {
|
|
infoView(video: video)
|
|
}
|
|
case .queue:
|
|
PlayerQueueView(sidebarQueue: false)
|
|
.padding(.horizontal)
|
|
case .comments:
|
|
if showComments {
|
|
CommentsView()
|
|
.onAppear {
|
|
comments.loadIfNeeded()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.bottom, 60)
|
|
}
|
|
#if os(iOS)
|
|
.onAppear {
|
|
if fullScreen {
|
|
if let video, video.isLocal {
|
|
page = .info
|
|
}
|
|
detailsVisibility = true
|
|
return
|
|
}
|
|
Delay.by(0.8) { withAnimation(.easeIn(duration: 0.25)) { self.detailsVisibility = true } }
|
|
}
|
|
#endif
|
|
|
|
.onChange(of: player.queue) { _ in
|
|
if video != nil, !pageAvailable(page) {
|
|
page = .info
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder func scrollToTopButton(_ proxy: ScrollViewProxy) -> some View {
|
|
#if !os(tvOS)
|
|
if showScrollToTopInComments,
|
|
page == .comments,
|
|
comments.loaded,
|
|
comments.all.count > 3
|
|
{
|
|
Button {
|
|
withAnimation {
|
|
proxy.scrollTo(Self.pageMenuID)
|
|
}
|
|
} label: {
|
|
Label("Scroll to top", systemImage: "arrow.up")
|
|
.padding(8)
|
|
.foregroundColor(.white)
|
|
.background(Circle().opacity(0.8).foregroundColor(.accentColor))
|
|
}
|
|
.padding()
|
|
.labelStyle(.iconOnly)
|
|
.buttonStyle(.plain)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
var descriptionHeader: some View {
|
|
#if canImport(UIKit)
|
|
Button(action: {
|
|
descriptionExpanded.toggle()
|
|
}) {
|
|
HStack {
|
|
Text("Description".localized())
|
|
Spacer()
|
|
Image(systemName: descriptionExpanded ? "chevron.up" : "chevron.down")
|
|
.imageScale(.small)
|
|
}
|
|
.padding(.horizontal)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
#elseif canImport(AppKit)
|
|
HStack {
|
|
Text("Description".localized())
|
|
Spacer()
|
|
Button { descriptionExpanded.toggle()
|
|
} label: {
|
|
Image(systemName: descriptionExpanded ? "chevron.up" : "chevron.down")
|
|
.imageScale(.small)
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
#endif
|
|
}
|
|
|
|
var chaptersHaveImages: Bool {
|
|
player.videoForDisplay?.chapters.allSatisfy { $0.image != nil } ?? false
|
|
}
|
|
|
|
var chapterImagesTheSame: Bool {
|
|
guard let firstChapterURL = player.videoForDisplay?.chapters.first?.image else {
|
|
return false
|
|
}
|
|
|
|
return player.videoForDisplay?.chapters.allSatisfy { $0.image == firstChapterURL } ?? false
|
|
}
|
|
|
|
var showThumbnails: Bool {
|
|
if !chaptersHaveImages || !showChapterThumbnails {
|
|
return false
|
|
}
|
|
if showChapterThumbnailsOnlyWhenDifferent {
|
|
return !chapterImagesTheSame
|
|
}
|
|
return true
|
|
}
|
|
|
|
var chaptersHeader: some View {
|
|
Group {
|
|
if !chaptersHaveImages || !showThumbnails {
|
|
#if canImport(UIKit)
|
|
Button(action: {
|
|
chaptersExpanded.toggle()
|
|
}) {
|
|
HStack {
|
|
Text("Chapters".localized())
|
|
Spacer()
|
|
Image(systemName: chaptersExpanded ? "chevron.up" : "chevron.down")
|
|
.imageScale(.small)
|
|
}
|
|
.padding(.horizontal)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
#elseif canImport(AppKit)
|
|
HStack {
|
|
Text("Chapters".localized())
|
|
Spacer()
|
|
Button(action: { chaptersExpanded.toggle() }) {
|
|
Image(systemName: chaptersExpanded ? "chevron.up" : "chevron.down")
|
|
.imageScale(.small)
|
|
}
|
|
}
|
|
.padding(.horizontal)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
#endif
|
|
} else {
|
|
// No button, just the title when there are images
|
|
Text("Chapters".localized())
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct VideoDetails_Previews: PreviewProvider {
|
|
static var previews: some View {
|
|
VideoDetails(video: .fixture, fullScreen: .constant(false), sidebarQueue: .constant(false))
|
|
}
|
|
}
|