diff --git a/Music/ContentView.swift b/Music/ContentView.swift index 62dbdb5..02e6bdc 100644 --- a/Music/ContentView.swift +++ b/Music/ContentView.swift @@ -60,90 +60,93 @@ struct ContentView: View { .padding(.vertical, 4) } - if showHome || playlist.selectedItem != nil { - HStack(spacing: 4) { - Button(action: { - playlist.deselectPlaylist() - searchText = "" - showHome = false - }) { - HStack(spacing: 2) { - Image(systemName: "chevron.left") - .font(.system(size: 10)) - Text("Library") - .font(.system(size: 12)) + VStack(spacing: 0) { + if showHome || playlist.selectedItem != nil { + HStack(spacing: 4) { + Button(action: { + playlist.deselectPlaylist() + searchText = "" + showHome = false + }) { + HStack(spacing: 2) { + Image(systemName: "chevron.left") + .font(.system(size: 10)) + Text("Library") + .font(.system(size: 12)) + } + .foregroundStyle(.secondary) } - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) + .buttonStyle(.plain) - Text("/") - .font(.system(size: 12)) - .foregroundStyle(.quaternary) + Text("/") + .font(.system(size: 12)) + .foregroundStyle(.quaternary) - Text(showHome ? "Home" : (playlist.selectedItem?.name ?? "")) - .font(.system(size: 12, weight: .medium)) + Text(showHome ? "Home" : (playlist.selectedItem?.name ?? "")) + .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 { - HomeView( - recentTracks: recentTracks, - trackCount: library.trackCount, - totalDuration: totalDuration, - monthlyAdditions: monthlyAdditions, - onTrackDoubleClick: { track in - player.setQueue(recentTracks) - player.play(track) - }, - onShowAll: { - 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) + if showHome && playlist.selectedItem == nil { + HomeView( + recentTracks: recentTracks, + trackCount: library.trackCount, + totalDuration: totalDuration, + monthlyAdditions: monthlyAdditions, + onTrackDoubleClick: { track in + player.setQueue(recentTracks) + player.play(track) + }, + onShowAll: { + showHome = false } - }, - onDoubleClick: { track in - let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks - player.setQueue(trackList) - player.play(track) - }, - playlists: playlist.playlists, - lastUsedPlaylistName: playlist.lastUsedPlaylistName, - selectedPlaylist: playlist.selectedPlaylist, - onAddToPlaylist: { track, targetPlaylist in - try? playlist.addTrack(track, to: targetPlaylist) - }, - 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 - ) + ) + .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 + let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks + player.setQueue(trackList) + player.play(track) + }, + playlists: playlist.playlists, + lastUsedPlaylistName: playlist.lastUsedPlaylistName, + selectedPlaylist: playlist.selectedPlaylist, + onAddToPlaylist: { track, targetPlaylist in + try? playlist.addTrack(track, to: targetPlaylist) + }, + 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( playlists: playlist.allPlaylists, @@ -280,7 +283,17 @@ struct ContentView: View { duration: audio.duration, volume: audio.volume, 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() }, onPrevious: { player.previous() }, onSeek: { audio.seek(to: $0) }, @@ -294,7 +307,7 @@ struct ContentView: View { } 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 { return event } @@ -304,7 +317,15 @@ struct ContentView: View { } switch event.keyCode { 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 case 123: // left arrow player.previous() diff --git a/Music/Views/PlayerControlsView.swift b/Music/Views/PlayerControlsView.swift index a9ebd1f..2fb488c 100644 --- a/Music/Views/PlayerControlsView.swift +++ b/Music/Views/PlayerControlsView.swift @@ -22,18 +22,21 @@ struct PlayerControlsView: View { @State private var dragValue: Double = 0 var body: some View { - HStack(spacing: 0) { - nowPlayingSection - .frame(maxWidth: .infinity, alignment: .leading) + VStack(spacing: 0) { + progressTrack + HStack(spacing: 0) { + nowPlayingSection + .frame(maxWidth: .infinity, alignment: .leading) - transportSection - .frame(maxWidth: .infinity) + transportSection + .frame(maxWidth: .infinity) - volumeSection - .frame(maxWidth: .infinity, alignment: .trailing) + volumeSection + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) } - .padding(.horizontal, 16) - .padding(.vertical, 8) .background(.bar) } @@ -75,74 +78,94 @@ struct PlayerControlsView: View { } } - private var transportSection: some View { - VStack(spacing: 4) { - HStack(spacing: 20) { - Button(action: onShuffleToggle) { - Image(systemName: "shuffle") - .font(.system(size: 12)) - .foregroundStyle(isShuffled ? .blue : .secondary) + 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) } - .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) - - 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) + .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) } - ), - in: 0...max(duration, 1), - onEditingChanged: { editing in - if editing { - isDragging = true - dragValue = currentTime - onScrubStart() - } else { - onScrubEnd(dragValue) + .onEnded { value in + let newValue = min(max(Double(value.location.x / trackWidth) * maxDuration, 0), maxDuration) + onScrubEnd(newValue) isDragging = false } - } ) - .controlSize(.small) + } + .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) - .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) }