mirror of
				https://github.com/yattee/yattee.git
				synced 2025-10-30 20:22:06 +00:00 
			
		
		
		
	
		
			
				
	
	
		
			496 lines
		
	
	
		
			18 KiB
		
	
	
	
		
			Swift
		
	
	
	
	
	
			
		
		
	
	
			496 lines
		
	
	
		
			18 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)
 | |
| 
 | |
|                 VStack(alignment: .leading, spacing: 2) {
 | |
|                     HStack {
 | |
|                         if let name = model.videoForDisplay?.channel.name, !name.isEmpty {
 | |
|                             Text(name)
 | |
|                                 .font(.subheadline)
 | |
|                                 .fontWeight(.semibold)
 | |
|                                 .lineLimit(1)
 | |
|                         } 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(.showRelated) private var showRelated
 | |
|     #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)
 | |
|             #if !os(tvOS)
 | |
|                 .tapRecognizer(
 | |
|                     tapSensitivity: 0.2,
 | |
|                     doubleTapAction: {
 | |
|                         withAnimation(.default) {
 | |
|                             fullScreen.toggle()
 | |
|                         }
 | |
|                     }
 | |
|                 )
 | |
|             #endif
 | |
| 
 | |
|             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)
 | |
| 
 | |
|             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
 | |
|         default:
 | |
|             return !video.isLocal
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     var pageView: some View {
 | |
|         ScrollView(.vertical) {
 | |
|             LazyVStack {
 | |
|                 pageMenu
 | |
|                     .id(Self.pageMenuID)
 | |
|                     .padding(5)
 | |
| 
 | |
|                 switch page {
 | |
|                 case .info:
 | |
|                     Group {
 | |
|                         if let video {
 | |
|                             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)
 | |
|                                         }
 | |
|                                     }
 | |
| 
 | |
|                                     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 video != nil, !pageAvailable(page) {
 | |
|                             page = .info
 | |
|                         }
 | |
|                     }
 | |
|                     .transition(.opacity)
 | |
|                     .animation(nil, value: player.currentItem)
 | |
|                     #if os(iOS)
 | |
|                         .frame(maxWidth: YatteeApp.isForPreviews ? .infinity : maxWidth)
 | |
|                     #endif
 | |
| 
 | |
|                 case .queue:
 | |
|                     PlayerQueueView(sidebarQueue: false)
 | |
|                         .padding(.horizontal)
 | |
| 
 | |
|                 case .comments:
 | |
|                     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 chaptersHeader: some View {
 | |
|         Group {
 | |
|             if !chaptersHaveImages {
 | |
|                 #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))
 | |
|     }
 | |
| }
 | 
