fix: stabilize layout at minimum window height and refine player controls

Wrap breadcrumb + content area in a constrained VStack so search bar,
playlist bar, and player controls stay pinned when toggling Home view.
Refactor PlayerControlsView to use a full-width progress track above
transport buttons. Allow play/pause to start from first track when
nothing is queued.
feat/music-streaming
Laurent 1 month ago
parent d20bb2fef4
commit 2f1b9b537c
  1. 181
      Music/ContentView.swift
  2. 155
      Music/Views/PlayerControlsView.swift

@ -60,90 +60,93 @@ struct ContentView: View {
.padding(.vertical, 4) .padding(.vertical, 4)
} }
if showHome || playlist.selectedItem != nil { VStack(spacing: 0) {
HStack(spacing: 4) { if showHome || playlist.selectedItem != nil {
Button(action: { HStack(spacing: 4) {
playlist.deselectPlaylist() Button(action: {
searchText = "" playlist.deselectPlaylist()
showHome = false searchText = ""
}) { showHome = false
HStack(spacing: 2) { }) {
Image(systemName: "chevron.left") HStack(spacing: 2) {
.font(.system(size: 10)) Image(systemName: "chevron.left")
Text("Library") .font(.system(size: 10))
.font(.system(size: 12)) Text("Library")
.font(.system(size: 12))
}
.foregroundStyle(.secondary)
} }
.foregroundStyle(.secondary) .buttonStyle(.plain)
}
.buttonStyle(.plain)
Text("/") Text("/")
.font(.system(size: 12)) .font(.system(size: 12))
.foregroundStyle(.quaternary) .foregroundStyle(.quaternary)
Text(showHome ? "Home" : (playlist.selectedItem?.name ?? "")) Text(showHome ? "Home" : (playlist.selectedItem?.name ?? ""))
.font(.system(size: 12, weight: .medium)) .font(.system(size: 12, weight: .medium))
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
} }
.padding(.horizontal, 12)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
}
if showHome && playlist.selectedItem == nil { if showHome && playlist.selectedItem == nil {
HomeView( HomeView(
recentTracks: recentTracks, recentTracks: recentTracks,
trackCount: library.trackCount, trackCount: library.trackCount,
totalDuration: totalDuration, totalDuration: totalDuration,
monthlyAdditions: monthlyAdditions, monthlyAdditions: monthlyAdditions,
onTrackDoubleClick: { track in onTrackDoubleClick: { track in
player.setQueue(recentTracks) player.setQueue(recentTracks)
player.play(track) player.play(track)
}, },
onShowAll: { onShowAll: {
showHome = false showHome = false
}
)
.onAppear { loadHomeData() }
} else {
TrackTableView(
tracks: playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks,
playingTrackId: player.currentTrack?.id,
sortColumn: playlist.selectedSmartPlaylist != nil ? playlist.sortColumn : library.sortColumn,
sortAscending: playlist.selectedSmartPlaylist != nil ? playlist.sortAscending : library.sortAscending,
onSort: { column in
if playlist.selectedSmartPlaylist != nil {
playlist.sort(by: column)
} else if playlist.selectedItem == nil {
library.sort(by: column)
} }
}, )
onDoubleClick: { track in .onAppear { loadHomeData() }
let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks } else {
player.setQueue(trackList) TrackTableView(
player.play(track) tracks: playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks,
}, playingTrackId: player.currentTrack?.id,
playlists: playlist.playlists, sortColumn: playlist.selectedSmartPlaylist != nil ? playlist.sortColumn : library.sortColumn,
lastUsedPlaylistName: playlist.lastUsedPlaylistName, sortAscending: playlist.selectedSmartPlaylist != nil ? playlist.sortAscending : library.sortAscending,
selectedPlaylist: playlist.selectedPlaylist, onSort: { column in
onAddToPlaylist: { track, targetPlaylist in if playlist.selectedSmartPlaylist != nil {
try? playlist.addTrack(track, to: targetPlaylist) playlist.sort(by: column)
}, } else if playlist.selectedItem == nil {
onAddToLastPlaylist: { track in library.sort(by: column)
try? playlist.addTrackToLastUsedPlaylist(track) }
}, },
onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in onDoubleClick: { track in
if let selected = playlist.selectedPlaylist { let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks
try? playlist.removeTrack(track, from: selected) player.setQueue(trackList)
} player.play(track)
} : nil, },
onReorder: playlist.selectedPlaylist != nil ? { from, to in playlists: playlist.playlists,
if let selected = playlist.selectedPlaylist { lastUsedPlaylistName: playlist.lastUsedPlaylistName,
try? playlist.moveTrack(in: selected, from: from, to: to) selectedPlaylist: playlist.selectedPlaylist,
} onAddToPlaylist: { track, targetPlaylist in
} : nil, try? playlist.addTrack(track, to: targetPlaylist)
scrollToPlayingTrigger: scrollToPlayingTrigger },
) onAddToLastPlaylist: { track in
try? playlist.addTrackToLastUsedPlaylist(track)
},
onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in
if let selected = playlist.selectedPlaylist {
try? playlist.removeTrack(track, from: selected)
}
} : nil,
onReorder: playlist.selectedPlaylist != nil ? { from, to in
if let selected = playlist.selectedPlaylist {
try? playlist.moveTrack(in: selected, from: from, to: to)
}
} : nil,
scrollToPlayingTrigger: scrollToPlayingTrigger
)
}
} }
.frame(maxHeight: .infinity)
PlaylistBarView( PlaylistBarView(
playlists: playlist.allPlaylists, playlists: playlist.allPlaylists,
@ -280,7 +283,17 @@ struct ContentView: View {
duration: audio.duration, duration: audio.duration,
volume: audio.volume, volume: audio.volume,
isShuffled: player.isShuffled, isShuffled: player.isShuffled,
onPlayPause: { audio.togglePlayPause() }, onPlayPause: {
if player.currentTrack == nil {
let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks
if let first = trackList.first {
player.setQueue(trackList)
player.play(first)
}
} else {
audio.togglePlayPause()
}
},
onNext: { player.next() }, onNext: { player.next() },
onPrevious: { player.previous() }, onPrevious: { player.previous() },
onSeek: { audio.seek(to: $0) }, onSeek: { audio.seek(to: $0) },
@ -294,7 +307,7 @@ struct ContentView: View {
} }
private func installKeyboardMonitor() { private func installKeyboardMonitor() {
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [audio, player] event in keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [audio, player, library, playlist] event in
guard event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty else { guard event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty else {
return event return event
} }
@ -304,7 +317,15 @@ struct ContentView: View {
} }
switch event.keyCode { switch event.keyCode {
case 49: // space case 49: // space
audio.togglePlayPause() if player.currentTrack == nil {
let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks
if let first = trackList.first {
player.setQueue(trackList)
player.play(first)
}
} else {
audio.togglePlayPause()
}
return nil return nil
case 123: // left arrow case 123: // left arrow
player.previous() player.previous()

@ -22,18 +22,21 @@ struct PlayerControlsView: View {
@State private var dragValue: Double = 0 @State private var dragValue: Double = 0
var body: some View { var body: some View {
HStack(spacing: 0) { VStack(spacing: 0) {
nowPlayingSection progressTrack
.frame(maxWidth: .infinity, alignment: .leading) HStack(spacing: 0) {
nowPlayingSection
.frame(maxWidth: .infinity, alignment: .leading)
transportSection transportSection
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
volumeSection volumeSection
.frame(maxWidth: .infinity, alignment: .trailing) .frame(maxWidth: .infinity, alignment: .trailing)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
} }
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.bar) .background(.bar)
} }
@ -75,74 +78,94 @@ struct PlayerControlsView: View {
} }
} }
private var transportSection: some View { private var progressTrack: some View {
VStack(spacing: 4) { let trackHeight: CGFloat = 4
HStack(spacing: 20) { let thumbWidth: CGFloat = 4
Button(action: onShuffleToggle) { let thumbHeight: CGFloat = 12
Image(systemName: "shuffle") let displayedTime = isDragging ? dragValue : currentTime
.font(.system(size: 12)) let maxDuration = max(duration, 1)
.foregroundStyle(isShuffled ? .blue : .secondary) let fraction = displayedTime / maxDuration
return VStack(spacing: 2) {
GeometryReader { geo in
let trackWidth = geo.size.width
ZStack(alignment: .leading) {
Rectangle()
.fill(.quaternary)
.frame(height: trackHeight)
Rectangle()
.fill(.blue)
.frame(width: trackWidth * fraction, height: trackHeight)
RoundedRectangle(cornerRadius: 1)
.fill(.blue)
.frame(width: thumbWidth, height: thumbHeight)
.offset(x: trackWidth * fraction - thumbWidth / 2)
} }
.buttonStyle(.plain) .frame(maxHeight: .infinity)
.contentShape(Rectangle())
Button(action: onPrevious) { .gesture(
Image(systemName: "backward.fill") DragGesture(minimumDistance: 0)
.font(.system(size: 14)) .onChanged { value in
} let newValue = min(max(Double(value.location.x / trackWidth) * maxDuration, 0), maxDuration)
.buttonStyle(.plain) if !isDragging {
isDragging = true
Button(action: onPlayPause) { dragValue = currentTime
Image(systemName: isPlaying ? "pause.fill" : "play.fill") onScrubStart()
.font(.system(size: 22))
.frame(width: 24, height: 24)
}
.buttonStyle(.plain)
Button(action: onNext) {
Image(systemName: "forward.fill")
.font(.system(size: 14))
}
.buttonStyle(.plain)
Spacer()
.frame(width: 12)
}
HStack(spacing: 8) {
Text(Self.formatTime(isDragging ? dragValue : currentTime))
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 45, alignment: .trailing)
Slider(
value: Binding(
get: { isDragging ? dragValue : currentTime },
set: { newValue in
dragValue = newValue
if isDragging {
onScrub(newValue)
} }
dragValue = newValue
onScrub(newValue)
} }
), .onEnded { value in
in: 0...max(duration, 1), let newValue = min(max(Double(value.location.x / trackWidth) * maxDuration, 0), maxDuration)
onEditingChanged: { editing in onScrubEnd(newValue)
if editing {
isDragging = true
dragValue = currentTime
onScrubStart()
} else {
onScrubEnd(dragValue)
isDragging = false isDragging = false
} }
}
) )
.controlSize(.small) }
.frame(height: thumbHeight)
HStack {
Text(Self.formatTime(displayedTime))
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.secondary)
Spacer()
Text(Self.formatTime(duration)) Text(Self.formatTime(duration))
.font(.system(size: 10).monospacedDigit()) .font(.system(size: 10).monospacedDigit())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(width: 45, alignment: .leading)
} }
.padding(.horizontal, 8)
}
}
private var transportSection: some View {
HStack(spacing: 20) {
Button(action: onShuffleToggle) {
Image(systemName: "shuffle")
.font(.system(size: 12))
.foregroundStyle(isShuffled ? .blue : .secondary)
}
.buttonStyle(.plain)
Button(action: onPrevious) {
Image(systemName: "backward.fill")
.font(.system(size: 14))
}
.buttonStyle(.plain)
Button(action: onPlayPause) {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 22))
.frame(width: 24, height: 24)
}
.buttonStyle(.plain)
Button(action: onNext) {
Image(systemName: "forward.fill")
.font(.system(size: 14))
}
.buttonStyle(.plain)
} }
.frame(maxWidth: 400) .frame(maxWidth: 400)
} }

Loading…
Cancel
Save