diff --git a/Music/ContentView.swift b/Music/ContentView.swift index fd21793..da3a1cf 100644 --- a/Music/ContentView.swift +++ b/Music/ContentView.swift @@ -7,6 +7,10 @@ struct ContentView: View { var scanner: ScannerService var audio: AudioService var playlist: PlaylistViewModel + @Binding var showNewPlaylistAlert: Bool + @State private var showRenameAlert = false + @State private var playlistNameInput = "" + @State private var playlistToRename: Playlist? var body: some View { VStack(spacing: 0) { @@ -31,6 +35,31 @@ struct ContentView: View { .padding(.vertical, 4) } + if let selected = playlist.selectedPlaylist { + HStack(spacing: 4) { + Button(action: { playlist.deselectPlaylist() }) { + HStack(spacing: 2) { + Image(systemName: "chevron.left") + .font(.system(size: 10)) + Text("Library") + .font(.system(size: 12)) + } + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + + Text("/") + .font(.system(size: 12)) + .foregroundStyle(.quaternary) + + Text(selected.name) + .font(.system(size: 12, weight: .medium)) + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) + } + TrackTableView( tracks: playlist.selectedPlaylist != nil ? playlist.playlistTracks : library.tracks, playingTrackId: player.currentTrack?.id, @@ -47,6 +76,21 @@ struct ContentView: View { onPlayPause: { audio.togglePlayPause() } ) + PlaylistBarView( + playlists: playlist.playlists, + selectedPlaylist: playlist.selectedPlaylist, + onSelect: { playlist.selectPlaylist($0) }, + onDeselect: { playlist.deselectPlaylist() }, + onRename: { p in + playlistToRename = p + playlistNameInput = p.name + showRenameAlert = true + }, + onDelete: { p in + try? playlist.deletePlaylist(p) + } + ) + PlayerControlsView( currentTrack: player.currentTrack, isPlaying: audio.isPlaying, @@ -69,6 +113,29 @@ struct ContentView: View { .onChange(of: audio.currentTime) { _, _ in player.checkHalfway() } + .alert("New Playlist", isPresented: $showNewPlaylistAlert) { + TextField("Playlist name", text: $playlistNameInput) + Button("Cancel", role: .cancel) { playlistNameInput = "" } + Button("Create") { + let name = playlistNameInput.trimmingCharacters(in: .whitespaces) + if !name.isEmpty { + try? playlist.createPlaylist(name: name) + } + playlistNameInput = "" + } + } + .alert("Rename Playlist", isPresented: $showRenameAlert) { + TextField("Playlist name", text: $playlistNameInput) + Button("Cancel", role: .cancel) { playlistNameInput = "" } + Button("Rename") { + let name = playlistNameInput.trimmingCharacters(in: .whitespaces) + if !name.isEmpty, let p = playlistToRename { + try? playlist.renamePlaylist(p, to: name) + } + playlistNameInput = "" + playlistToRename = nil + } + } } private func handleDrop(_ providers: [NSItemProvider]) { diff --git a/Music/MusicApp.swift b/Music/MusicApp.swift index 13fc8df..2534c1b 100644 --- a/Music/MusicApp.swift +++ b/Music/MusicApp.swift @@ -8,6 +8,7 @@ struct MusicApp: App { @State private var scannerService: ScannerService? @State private var audioService = AudioService() @State private var playlistVM: PlaylistViewModel? + @State private var showNewPlaylistAlert = false @State private var initError: String? var body: some Scene { @@ -23,7 +24,8 @@ struct MusicApp: App { player: player, scanner: scanner, audio: audioService, - playlist: playlist + playlist: playlist, + showNewPlaylistAlert: $showNewPlaylistAlert ) } else if let error = initError { Text("Failed to initialize database: \(error)") @@ -41,6 +43,11 @@ struct MusicApp: App { pickFolder() } .keyboardShortcut("o") + + Button("New Playlist...") { + showNewPlaylistAlert = true + } + .keyboardShortcut("n") } } } diff --git a/Music/Views/FlowLayout.swift b/Music/Views/FlowLayout.swift new file mode 100644 index 0000000..be3f1df --- /dev/null +++ b/Music/Views/FlowLayout.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct FlowLayout: Layout { + var spacing: CGFloat = 6 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let rows = computeRows(proposal: proposal, subviews: subviews) + let height = rows.enumerated().reduce(CGFloat.zero) { total, entry in + let (i, row) = entry + let rowHeight = row.map { $0.size.height }.max() ?? 0 + return total + rowHeight + (i > 0 ? spacing : 0) + } + return CGSize(width: proposal.width ?? 0, height: height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let rows = computeRows(proposal: proposal, subviews: subviews) + var y = bounds.minY + + for row in rows { + let rowHeight = row.map { $0.size.height }.max() ?? 0 + var x = bounds.minX + + for item in row { + item.subview.place( + at: CGPoint(x: x, y: y), + proposal: ProposedViewSize(item.size) + ) + x += item.size.width + spacing + } + y += rowHeight + spacing + } + } + + private struct LayoutItem { + let subview: LayoutSubview + let size: CGSize + } + + private func computeRows(proposal: ProposedViewSize, subviews: Subviews) -> [[LayoutItem]] { + let maxWidth = proposal.width ?? .infinity + var rows: [[LayoutItem]] = [[]] + var currentRowWidth: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + let widthWithSpacing = currentRowWidth > 0 ? size.width + spacing : size.width + + if currentRowWidth + widthWithSpacing > maxWidth, !rows[rows.count - 1].isEmpty { + rows.append([]) + currentRowWidth = 0 + } + + rows[rows.count - 1].append(LayoutItem(subview: subview, size: size)) + currentRowWidth += currentRowWidth > 0 ? size.width + spacing : size.width + } + + return rows + } +} diff --git a/Music/Views/PlaylistBarView.swift b/Music/Views/PlaylistBarView.swift new file mode 100644 index 0000000..f6fb55e --- /dev/null +++ b/Music/Views/PlaylistBarView.swift @@ -0,0 +1,59 @@ +import SwiftUI + +struct PlaylistBarView: View { + var playlists: [Playlist] + var selectedPlaylist: Playlist? + var onSelect: (Playlist) -> Void + var onDeselect: () -> Void + var onRename: (Playlist) -> Void + var onDelete: (Playlist) -> Void + + var body: some View { + if !playlists.isEmpty { + FlowLayout(spacing: 6) { + ForEach(playlists) { playlist in + PlaylistButton( + name: playlist.name, + isSelected: selectedPlaylist?.id == playlist.id, + action: { + if selectedPlaylist?.id == playlist.id { + onDeselect() + } else { + onSelect(playlist) + } + } + ) + .contextMenu { + Button("Rename...") { onRename(playlist) } + Button("Delete") { onDelete(playlist) } + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + } +} + +private struct PlaylistButton: View { + let name: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(name) + .font(.system(size: 11)) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(isSelected ? Color.accentColor.opacity(0.2) : Color.secondary.opacity(0.1)) + .foregroundStyle(isSelected ? Color.accentColor : .secondary) + .overlay( + RoundedRectangle(cornerRadius: 4) + .stroke(isSelected ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1) + ) + .cornerRadius(4) + } + .buttonStyle(.plain) + } +}