From 25f15a1b3e90a73388c6e547869218038a42a780 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sat, 1 Aug 2026 21:34:50 +0200 Subject: [PATCH] Add view options to playlists list view List/grid layout toggle with row size and grid columns options, persisted per-view. PlaylistRowView now scales with VideoRowStyle; new LocalPlaylistCardView provides the grid card with edit/delete context menu. --- Yattee/Views/Home/LocalPlaylistCardView.swift | 97 +++++++++++ Yattee/Views/Home/PlaylistRowView.swift | 11 +- Yattee/Views/Home/PlaylistsListView.swift | 151 ++++++++++++++++-- 3 files changed, 241 insertions(+), 18 deletions(-) create mode 100644 Yattee/Views/Home/LocalPlaylistCardView.swift diff --git a/Yattee/Views/Home/LocalPlaylistCardView.swift b/Yattee/Views/Home/LocalPlaylistCardView.swift new file mode 100644 index 00000000..088d7f35 --- /dev/null +++ b/Yattee/Views/Home/LocalPlaylistCardView.swift @@ -0,0 +1,97 @@ +// +// LocalPlaylistCardView.swift +// Yattee +// +// A local playlist card component for grid layouts. +// + +import SwiftUI +import NukeUI + +/// A local playlist card for grid layouts. +/// +/// Displays thumbnail with video count badge, title, and count/duration line. +struct LocalPlaylistCardView: View { + let playlist: LocalPlaylist + var isCompact: Bool = false + + private var titleFont: Font { isCompact ? .caption : .subheadline } + private var metadataFont: Font { isCompact ? .caption2 : .caption } + + private var metadataHeight: CGFloat { + #if os(tvOS) + isCompact ? 90 : 110 + #else + isCompact ? 50 : 58 + #endif + } + + var body: some View { + VStack(alignment: .leading, spacing: isCompact ? 4 : 8) { + // Thumbnail with video count badge - fixed 16:9 aspect ratio container + Color.clear + .aspectRatio(16/9, contentMode: .fit) + .overlay { + LazyImage(url: playlist.thumbnailURL) { state in + if let image = state.image { + image + .resizable() + .aspectRatio(contentMode: .fill) + } else { + thumbnailPlaceholder + } + } + } + .clipped() + .clipShape(RoundedRectangle(cornerRadius: isCompact ? 6 : 8)) + .overlay(alignment: .bottomTrailing) { + if playlist.videoCount > 0 { + HStack(spacing: 4) { + Image(systemName: "play.square.stack") + .font(.caption2) + Text("\(playlist.videoCount)") + .font(.caption2) + .fontWeight(.medium) + } + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(.black.opacity(0.75)) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .padding(6) + } + } + + // Metadata - fixed height to ensure consistent card sizes in grid + VStack(alignment: .leading, spacing: 2) { + Text(playlist.title) + .font(titleFont) + .fontWeight(.medium) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + + Text("playlist.videoCountDuration \(playlist.videoCount) \(playlist.formattedTotalDuration)") + .font(metadataFont.monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer(minLength: 0) + } + .frame(height: metadataHeight) + } + .contentShape(Rectangle()) + } + + private var thumbnailPlaceholder: some View { + RoundedRectangle(cornerRadius: isCompact ? 6 : 8) + .fill(.quaternary) + .aspectRatio(16/9, contentMode: .fill) + .overlay { + Image(systemName: "music.note.list") + .font(.title2) + .foregroundStyle(.secondary) + } + } +} diff --git a/Yattee/Views/Home/PlaylistRowView.swift b/Yattee/Views/Home/PlaylistRowView.swift index ed536a8a..bb80c8e1 100644 --- a/Yattee/Views/Home/PlaylistRowView.swift +++ b/Yattee/Views/Home/PlaylistRowView.swift @@ -10,6 +10,11 @@ import NukeUI struct PlaylistRowView: View { let playlist: LocalPlaylist + var style: VideoRowStyle = .regular + + private var titleFont: Font { + style == .compact ? .subheadline : .headline + } var body: some View { HStack(spacing: 12) { @@ -28,14 +33,14 @@ struct PlaylistRowView: View { } } } - .frame(width: 80, height: 45) + .frame(width: style.thumbnailWidth, height: style.thumbnailHeight) .clipShape(RoundedRectangle(cornerRadius: 6)) // Info VStack(alignment: .leading, spacing: 4) { Text(playlist.title) - .font(.headline) - .lineLimit(1) + .font(titleFont) + .lineLimit(style == .large ? 2 : 1) Text("playlist.videoCountDuration \(playlist.videoCount) \(playlist.formattedTotalDuration)") .font(.caption.monospacedDigit()) diff --git a/Yattee/Views/Home/PlaylistsListView.swift b/Yattee/Views/Home/PlaylistsListView.swift index a2e55789..36ba6b29 100644 --- a/Yattee/Views/Home/PlaylistsListView.swift +++ b/Yattee/Views/Home/PlaylistsListView.swift @@ -9,6 +9,7 @@ import SwiftUI struct PlaylistsListView: View { @Environment(\.appEnvironment) private var appEnvironment + @Namespace private var sheetTransition @State private var playlists: [LocalPlaylist] = [] @State private var searchText = "" @State private var showingNewPlaylist = false @@ -17,6 +18,20 @@ struct PlaylistsListView: View { @FocusState private var focusedPlaylistID: UUID? #endif + // View options (persisted) + @AppStorage("playlists.layout") private var layout: VideoListLayout = .list + @AppStorage("playlists.rowStyle") private var rowStyle: VideoRowStyle = .regular + @AppStorage("playlists.gridColumns") private var gridColumns = 2 + + // UI state + @State private var showViewOptions = false + @State private var viewWidth: CGFloat = 0 + + // Grid layout configuration + private var gridConfig: GridLayoutConfiguration { + GridLayoutConfiguration(viewWidth: viewWidth, gridColumns: gridColumns) + } + private var dataManager: DataManager? { appEnvironment?.dataManager } /// List style from centralized settings. @@ -24,6 +39,24 @@ struct PlaylistsListView: View { appEnvironment?.settingsManager.listStyle ?? .inset } + private var viewOptionsSheetContent: some View { + ViewOptionsSheet( + layout: $layout, + rowStyle: $rowStyle, + gridColumns: $gridColumns, + maxGridColumns: gridConfig.maxColumns + ) + } + + /// View options button lives on the leading edge on macOS, trailing elsewhere. + private var viewOptionsPlacement: ToolbarItemPlacement { + #if os(macOS) + .navigation + #else + .primaryAction + #endif + } + /// Playlists filtered by search. private var filteredPlaylists: [LocalPlaylist] { guard !searchText.isEmpty else { return playlists } @@ -35,16 +68,26 @@ struct PlaylistsListView: View { } var body: some View { - Group { - #if os(tvOS) - tvOSContent - #else - if filteredPlaylists.isEmpty { - emptyView - } else { - listContent + GeometryReader { geometry in + Group { + #if os(tvOS) + tvOSContent + #else + if filteredPlaylists.isEmpty { + emptyView + } else { + switch layout { + case .list: + listContent + case .grid: + gridContent + } + } + #endif + } + .onChange(of: geometry.size.width, initial: true) { _, newWidth in + viewWidth = newWidth } - #endif } #if !os(tvOS) .navigationTitle(String(localized: "home.playlists.title")) @@ -58,6 +101,19 @@ struct PlaylistsListView: View { ToolbarSpacer(.flexible, placement: .primaryAction) } #endif + ToolbarItem(placement: viewOptionsPlacement) { + Button { + showViewOptions = true + } label: { + Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3") + } + .liquidGlassTransitionSource(id: "playlistsViewOptions", in: sheetTransition) + #if os(macOS) + .popover(isPresented: $showViewOptions, arrowEdge: .bottom) { + viewOptionsSheetContent + } + #endif + } ToolbarItem(placement: .primaryAction) { Button { showingNewPlaylist = true @@ -67,6 +123,12 @@ struct PlaylistsListView: View { } } #endif + #if !os(macOS) + .sheet(isPresented: $showViewOptions) { + viewOptionsSheetContent + .liquidGlassSheetContent(sourceID: "playlistsViewOptions", in: sheetTransition) + } + #endif .sheet(isPresented: $showingNewPlaylist) { PlaylistFormSheet(mode: .create) { title, description in _ = dataManager?.createPlaylist(title: title, description: description) @@ -101,6 +163,12 @@ struct PlaylistsListView: View { } label: { Label(String(localized: "home.playlists.new"), systemImage: "plus") } + + Button { + showViewOptions = true + } label: { + Label(String(localized: "viewOptions.title"), systemImage: "slider.horizontal.3") + } } .focusSection() .padding(.horizontal, 48) @@ -111,7 +179,12 @@ struct PlaylistsListView: View { if filteredPlaylists.isEmpty { emptyView } else { - listContent + switch layout { + case .list: + listContent + case .grid: + gridContent + } } } .focusSection() @@ -157,16 +230,16 @@ struct PlaylistsListView: View { // MARK: - List Content private var listContent: some View { - VideoListContainer(listStyle: listStyle, rowStyle: .regular) { + VideoListContainer(listStyle: listStyle, rowStyle: rowStyle) { Spacer() .frame(height: 16) } content: { ForEach(Array(filteredPlaylists.enumerated()), id: \.element.id) { index, playlist in VideoListRow( isLast: index == filteredPlaylists.count - 1, - rowStyle: .regular, + rowStyle: rowStyle, listStyle: listStyle, - contentWidth: 80 // PlaylistRowView thumbnail width + contentWidth: rowStyle.thumbnailWidth ) { playlistRow(playlist: playlist) } @@ -195,13 +268,28 @@ struct PlaylistsListView: View { } } + // MARK: - Grid Layout + + private var gridContent: some View { + ScrollView { + VideoGridContent(columns: gridConfig.effectiveColumns) { + ForEach(filteredPlaylists, id: \.id) { playlist in + playlistCard(playlist: playlist) + } + } + } + #if os(tvOS) + .scrollClipDisabled() + #endif + } + // MARK: - Helper Views @ViewBuilder private func playlistRow(playlist: LocalPlaylist) -> some View { #if os(tvOS) NavigationLink(value: NavigationDestination.playlist(.local(playlist.id, title: playlist.title))) { - PlaylistRowView(playlist: playlist) + PlaylistRowView(playlist: playlist, style: rowStyle) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } @@ -209,7 +297,7 @@ struct PlaylistsListView: View { .zoomTransitionSource(id: playlist.id) .focused($focusedPlaylistID, equals: playlist.id) #else - PlaylistRowView(playlist: playlist) + PlaylistRowView(playlist: playlist, style: rowStyle) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) .onTapGesture { @@ -219,6 +307,39 @@ struct PlaylistsListView: View { #endif } + @ViewBuilder + private func playlistCard(playlist: LocalPlaylist) -> some View { + #if os(tvOS) + NavigationLink(value: NavigationDestination.playlist(.local(playlist.id, title: playlist.title))) { + LocalPlaylistCardView(playlist: playlist, isCompact: gridConfig.isCompactCards) + .frame(maxHeight: .infinity, alignment: .top) + } + .buttonStyle(.plain) + .zoomTransitionSource(id: playlist.id) + .focused($focusedPlaylistID, equals: playlist.id) + #else + LocalPlaylistCardView(playlist: playlist, isCompact: gridConfig.isCompactCards) + .frame(maxHeight: .infinity, alignment: .top) + .onTapGesture { + appEnvironment?.navigationCoordinator.navigate(to: .playlist(.local(playlist.id, title: playlist.title))) + } + .zoomTransitionSource(id: playlist.id) + .contextMenu { + Button { + playlistToEdit = playlist + } label: { + Label(String(localized: "playlist.edit"), systemImage: "pencil") + } + Button(role: .destructive) { + dataManager?.deletePlaylist(playlist) + loadPlaylists() + } label: { + Label(String(localized: "playlist.delete"), systemImage: "trash") + } + } + #endif + } + private func loadPlaylists() { playlists = (dataManager?.playlists() ?? []).sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending } }