feat: add PlaylistBarView, FlowLayout, breadcrumb, and New Playlist menu

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
feat/music-streaming
Laurent 1 month ago
parent 8f4e330c92
commit a0b95681e2
  1. 67
      Music/ContentView.swift
  2. 9
      Music/MusicApp.swift
  3. 60
      Music/Views/FlowLayout.swift
  4. 59
      Music/Views/PlaylistBarView.swift

@ -7,6 +7,10 @@ struct ContentView: View {
var scanner: ScannerService var scanner: ScannerService
var audio: AudioService var audio: AudioService
var playlist: PlaylistViewModel 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 { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@ -31,6 +35,31 @@ struct ContentView: View {
.padding(.vertical, 4) .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( TrackTableView(
tracks: playlist.selectedPlaylist != nil ? playlist.playlistTracks : library.tracks, tracks: playlist.selectedPlaylist != nil ? playlist.playlistTracks : library.tracks,
playingTrackId: player.currentTrack?.id, playingTrackId: player.currentTrack?.id,
@ -47,6 +76,21 @@ struct ContentView: View {
onPlayPause: { audio.togglePlayPause() } 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( PlayerControlsView(
currentTrack: player.currentTrack, currentTrack: player.currentTrack,
isPlaying: audio.isPlaying, isPlaying: audio.isPlaying,
@ -69,6 +113,29 @@ struct ContentView: View {
.onChange(of: audio.currentTime) { _, _ in .onChange(of: audio.currentTime) { _, _ in
player.checkHalfway() 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]) { private func handleDrop(_ providers: [NSItemProvider]) {

@ -8,6 +8,7 @@ struct MusicApp: App {
@State private var scannerService: ScannerService? @State private var scannerService: ScannerService?
@State private var audioService = AudioService() @State private var audioService = AudioService()
@State private var playlistVM: PlaylistViewModel? @State private var playlistVM: PlaylistViewModel?
@State private var showNewPlaylistAlert = false
@State private var initError: String? @State private var initError: String?
var body: some Scene { var body: some Scene {
@ -23,7 +24,8 @@ struct MusicApp: App {
player: player, player: player,
scanner: scanner, scanner: scanner,
audio: audioService, audio: audioService,
playlist: playlist playlist: playlist,
showNewPlaylistAlert: $showNewPlaylistAlert
) )
} else if let error = initError { } else if let error = initError {
Text("Failed to initialize database: \(error)") Text("Failed to initialize database: \(error)")
@ -41,6 +43,11 @@ struct MusicApp: App {
pickFolder() pickFolder()
} }
.keyboardShortcut("o") .keyboardShortcut("o")
Button("New Playlist...") {
showNewPlaylistAlert = true
}
.keyboardShortcut("n")
} }
} }
} }

@ -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
}
}

@ -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)
}
}
Loading…
Cancel
Save