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. 25
      Music/ContentView.swift
  2. 105
      Music/Views/PlayerControlsView.swift

@ -60,6 +60,7 @@ struct ContentView: View {
.padding(.vertical, 4) .padding(.vertical, 4)
} }
VStack(spacing: 0) {
if showHome || playlist.selectedItem != nil { if showHome || playlist.selectedItem != nil {
HStack(spacing: 4) { HStack(spacing: 4) {
Button(action: { Button(action: {
@ -144,6 +145,8 @@ struct ContentView: View {
scrollToPlayingTrigger: scrollToPlayingTrigger 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
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() audio.togglePlayPause()
}
return nil return nil
case 123: // left arrow case 123: // left arrow
player.previous() player.previous()

@ -22,6 +22,8 @@ struct PlayerControlsView: View {
@State private var dragValue: Double = 0 @State private var dragValue: Double = 0
var body: some View { var body: some View {
VStack(spacing: 0) {
progressTrack
HStack(spacing: 0) { HStack(spacing: 0) {
nowPlayingSection nowPlayingSection
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
@ -34,6 +36,7 @@ struct PlayerControlsView: View {
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 8) .padding(.vertical, 8)
}
.background(.bar) .background(.bar)
} }
@ -75,8 +78,68 @@ struct PlayerControlsView: View {
} }
} }
private var progressTrack: some View {
let trackHeight: CGFloat = 4
let thumbWidth: CGFloat = 4
let thumbHeight: CGFloat = 12
let displayedTime = isDragging ? dragValue : currentTime
let maxDuration = max(duration, 1)
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)
}
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
let newValue = min(max(Double(value.location.x / trackWidth) * maxDuration, 0), maxDuration)
if !isDragging {
isDragging = true
dragValue = currentTime
onScrubStart()
}
dragValue = newValue
onScrub(newValue)
}
.onEnded { value in
let newValue = min(max(Double(value.location.x / trackWidth) * maxDuration, 0), maxDuration)
onScrubEnd(newValue)
isDragging = false
}
)
}
.frame(height: thumbHeight)
HStack {
Text(Self.formatTime(displayedTime))
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.secondary)
Spacer()
Text(Self.formatTime(duration))
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
}
}
private var transportSection: some View { private var transportSection: some View {
VStack(spacing: 4) {
HStack(spacing: 20) { HStack(spacing: 20) {
Button(action: onShuffleToggle) { Button(action: onShuffleToggle) {
Image(systemName: "shuffle") Image(systemName: "shuffle")
@ -103,46 +166,6 @@ struct PlayerControlsView: View {
.font(.system(size: 14)) .font(.system(size: 14))
} }
.buttonStyle(.plain) .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)
}
}
),
in: 0...max(duration, 1),
onEditingChanged: { editing in
if editing {
isDragging = true
dragValue = currentTime
onScrubStart()
} else {
onScrubEnd(dragValue)
isDragging = false
}
}
)
.controlSize(.small)
Text(Self.formatTime(duration))
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: 45, alignment: .leading)
}
} }
.frame(maxWidth: 400) .frame(maxWidth: 400)
} }

Loading…
Cancel
Save