From 58f1b8c1ad10c87dcc7c01edd3f4653039a60a00 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Thu, 16 Apr 2026 06:06:38 +0200 Subject: [PATCH] Use two-column layout for tvOS playlist detail view --- Yattee/Localizable.xcstrings | 3 - Yattee/Views/Components/VideoRowView.swift | 5 + .../Playlist/UnifiedPlaylistDetailView.swift | 226 ++++++++++++------ 3 files changed, 163 insertions(+), 71 deletions(-) diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 116fe4de..302f793f 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -1000,9 +1000,6 @@ } } } - }, - "common.downloading" : { - }, "common.edit" : { "localizations" : { diff --git a/Yattee/Views/Components/VideoRowView.swift b/Yattee/Views/Components/VideoRowView.swift index 1a4108a6..3b2e7fe5 100644 --- a/Yattee/Views/Components/VideoRowView.swift +++ b/Yattee/Views/Components/VideoRowView.swift @@ -67,7 +67,12 @@ struct VideoRowView: View { Text("\(index)") .font(.subheadline) .foregroundStyle(.secondary) + #if os(tvOS) + .lineLimit(1) + .frame(width: 60, alignment: .trailing) + #else .frame(width: 32) + #endif } // Thumbnail diff --git a/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift b/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift index 753b5a69..49088cd1 100644 --- a/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift +++ b/Yattee/Views/Playlist/UnifiedPlaylistDetailView.swift @@ -80,6 +80,10 @@ struct UnifiedPlaylistDetailView: View { @State private var showingDeleteConfirmation = false @State private var isDescriptionExpanded = false + #if os(tvOS) + @State private var isDescriptionScrollLocked = false + #endif + #if !os(tvOS) @State private var downloadCoordinator = BatchDownloadCoordinator() // Cache download state to avoid triggering @Observable tracking on every render. @@ -139,19 +143,10 @@ struct UnifiedPlaylistDetailView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } } - .navigationTitle(title.isEmpty ? String(localized: "playlist.title") : title) #if !os(tvOS) + .navigationTitle(title.isEmpty ? String(localized: "playlist.title") : title) .toolbarTitleDisplayMode(.inlineLarge) - #endif .navigationSubtitleIfAvailable(playlistSummaryText) - #if os(tvOS) - .toolbar { - if !videos.isEmpty || localPlaylist != nil { - ToolbarItem(placement: .primaryAction) { - toolbarMenu - } - } - } #endif .sheet(isPresented: $showingEditSheet) { if let localPlaylist { @@ -194,24 +189,23 @@ struct UnifiedPlaylistDetailView: View { @ViewBuilder private var playlistContent: some View { + #if os(tvOS) + tvOSTwoColumnContent + #else + scrollablePlaylistContent() + #endif + } + + @ViewBuilder + private func scrollablePlaylistContent() -> some View { ScrollView { LazyVStack(alignment: .leading, spacing: 0) { - playlistHeader + playlistHeader() Divider() .padding(.horizontal) - if videos.isEmpty { - if isLoading { - ProgressView() - .frame(maxWidth: .infinity) - .padding(.top, 40) - } else { - emptyPlaylistView - } - } else { - videoList - } + playlistBody } } .refreshable { @@ -221,6 +215,21 @@ struct UnifiedPlaylistDetailView: View { } } + @ViewBuilder + private var playlistBody: some View { + if videos.isEmpty { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.top, 40) + } else { + emptyPlaylistView + } + } else { + videoList + } + } + /// Shows cached header with a spinner below while loading full playlist data. private func loadingContent(_ cached: CachedPlaylistHeader) -> some View { ScrollView { @@ -241,7 +250,7 @@ struct UnifiedPlaylistDetailView: View { } } - private var playlistHeader: some View { + private func playlistHeader() -> some View { playlistHeader( thumbnailURL: thumbnailURL, videoCount: videoCount, @@ -278,8 +287,7 @@ struct UnifiedPlaylistDetailView: View { .foregroundStyle(.secondary) } } - #else - // Show on non-iOS platforms (macOS, tvOS) + #elseif os(macOS) if let summaryText { Text(summaryText) .font(.subheadline) @@ -294,8 +302,8 @@ struct UnifiedPlaylistDetailView: View { .foregroundStyle(.secondary) } - #if !os(tvOS) // Action buttons row (iOS/macOS only) + #if !os(tvOS) if !videos.isEmpty || localPlaylist != nil { playlistActionButtons } @@ -404,82 +412,159 @@ struct UnifiedPlaylistDetailView: View { } #endif - // MARK: - Toolbar Menu + // MARK: - tvOS Two-Column Layout + + #if os(tvOS) + @ViewBuilder + private var tvOSTwoColumnContent: some View { + GeometryReader { geometry in + let leftWidth = geometry.size.width * 0.30 + HStack(alignment: .top, spacing: 40) { + tvOSLeftColumn + .frame(width: leftWidth, alignment: .leading) + .focusSection() + + tvOSRightColumn + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .focusSection() + } + .padding(.horizontal, 60) + .padding(.vertical, 40) + } + } @ViewBuilder - private var toolbarMenu: some View { - Menu { + private var tvOSLeftColumn: some View { + VStack(alignment: .leading, spacing: 24) { + tvOSPlaylistThumbnail + .frame(maxWidth: .infinity) + + if !title.isEmpty { + Text(title) + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(.white) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let summaryText = playlistSummaryText { + Text(summaryText) + .font(.subheadline) + .foregroundStyle(.secondary) + } + + if !isDescriptionScrollLocked, !videos.isEmpty || localPlaylist != nil { + tvOSPlaylistActionButtons + } + + if let descriptionText, !descriptionText.isEmpty { + TVScrollableDescription( + description: descriptionText, + isScrollLocked: $isDescriptionScrollLocked, + showsHeader: false + ) + .frame(maxHeight: .infinity) + } else { + Spacer(minLength: 0) + } + } + .padding(.vertical, 8) + .frame(maxHeight: .infinity, alignment: .top) + .animation(.easeInOut(duration: 0.25), value: isDescriptionScrollLocked) + } + + @ViewBuilder + private var tvOSPlaylistThumbnail: some View { + let url = videos.first?.bestThumbnail?.url ?? thumbnailURL + LazyImage(url: url) { state in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Rectangle() + .fill(.ultraThinMaterial) + .overlay { + Image(systemName: "music.note.list") + .font(.system(size: 56, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + } + .aspectRatio(16.0 / 9.0, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: .black.opacity(0.4), radius: 10, y: 5) + } + + @ViewBuilder + private var tvOSRightColumn: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + playlistBody + } + .padding(.vertical, 20) + } + .scrollClipDisabled() + } + + @ViewBuilder + private var tvOSPlaylistActionButtons: some View { + VStack(spacing: 12) { // Play (only when queue is enabled) if isQueueEnabled { Button { playAll() } label: { Label(String(localized: "playlist.play"), systemImage: "play.fill") + .frame(maxWidth: .infinity, minHeight: 50) + .font(.headline) } + .buttonStyle(.borderedProminent) } - #if !os(tvOS) - // Download All - if downloadCoordinator.isDownloading { - Label { - if let progress = downloadCoordinator.progress { - Text("\(progress.current)/\(progress.total)") - } else { - Text(String(localized: "common.downloading")) - } - } icon: { - Image(systemName: "arrow.down.circle") - } - } else { - Button { - downloadCoordinator.startDownload(videos: videos) - } label: { - Label(String(localized: "playlist.downloadAll"), systemImage: "arrow.down.circle") - } - .disabled(cachedAllVideosDownloaded) - } - #endif - - // Remote-only: Save to Library, Share + // Remote-only: Save to Library if case .remote = source, let remotePlaylist, !remotePlaylist.isLocal { Button { Task { await importToLocal() } } label: { - if isImporting, let progress = importProgress { - Label(String(localized: "playlist.savingToLibrary \(progress.current) \(progress.total)"), systemImage: "plus.rectangle.on.folder") - } else { - Label(String(localized: "playlist.saveToLibrary"), systemImage: "plus.rectangle.on.folder") + Group { + if isImporting, let progress = importProgress { + Label(String(localized: "playlist.savingToLibrary \(progress.current) \(progress.total)"), systemImage: "plus.rectangle.on.folder") + } else { + Label(String(localized: "playlist.saveToLibrary"), systemImage: "plus.rectangle.on.folder") + } } + .frame(maxWidth: .infinity, minHeight: 50) + .font(.headline) } + .buttonStyle(.bordered) .disabled(isImporting) - - #if !os(tvOS) - ShareLink(item: playlistShareURL()) { - Label(String(localized: "common.share"), systemImage: "square.and.arrow.up") - } - #endif } // Local-only: Edit, Delete if isLocal, localPlaylist != nil { - Divider() - Button { showingEditSheet = true } label: { Label(String(localized: "playlist.edit"), systemImage: "pencil") + .frame(maxWidth: .infinity, minHeight: 50) + .font(.headline) } + .buttonStyle(.bordered) Button(role: .destructive) { showingDeleteConfirmation = true } label: { Label(String(localized: "playlist.delete"), systemImage: "trash") + .frame(maxWidth: .infinity, minHeight: 50) + .font(.headline) } + .buttonStyle(.bordered) } - } label: { - Image(systemName: "ellipsis") } } + #endif #if !os(tvOS) /// Loads download state once on appear to avoid continuous re-renders from @Observable. @@ -514,13 +599,18 @@ struct UnifiedPlaylistDetailView: View { } private var videoList: some View { - LazyVStack(spacing: 0) { + #if os(tvOS) + let indexColumnWidth: CGFloat = 60 + #else + let indexColumnWidth: CGFloat = 32 + #endif + return LazyVStack(spacing: 0) { ForEach(Array(videos.enumerated()), id: \.element.id) { index, video in VideoListRow( isLast: index == videos.count - 1, rowStyle: .regular, listStyle: .plain, - indexWidth: 32 // Index column width in VideoRowView + indexWidth: indexColumnWidth // Index column width in VideoRowView ) { Button { playFromIndex(index)