You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
Music/Music/ContentView.swift

376 lines
15 KiB

import SwiftUI
import UniformTypeIdentifiers
struct ContentView: View {
var library: LibraryViewModel
var player: PlayerViewModel
var scanner: ScannerService
var audio: AudioService
var playlist: PlaylistViewModel
var shazam: ShazamService
var db: DatabaseService
@Binding var showNewPlaylistAlert: Bool
@State private var showRenameAlert = false
@State private var showEditQueryAlert = false
@State private var playlistNameInput = ""
@State private var editQueryInput = ""
@State private var itemToRename: (any PlaylistRepresentable)?
@State private var smartPlaylistToEdit: SmartPlaylist?
@State private var scrollToPlayingTrigger = UUID()
@State private var searchText = ""
@State private var keyMonitor: Any?
@State private var showHome = false
@State private var recentTracks: [Track] = []
@State private var totalDuration: Double = 0
@State private var monthlyAdditions: [MonthlyCount] = []
var body: some View {
VStack(spacing: 0) {
SearchBarView(
searchText: $searchText,
trackCount: playlist.selectedItem != nil ? playlist.playlistTracks.count : library.trackCount,
onSearch: { text in
if !text.isEmpty {
showHome = false
}
if !text.isEmpty && library.searchText.isEmpty {
library.sortColumn = "album"
library.sortAscending = true
}
library.search(text)
if playlist.selectedPlaylist != nil {
playlist.search(text)
}
},
onSaveSearch: { query in
try? playlist.createSmartPlaylist(searchQuery: query)
},
isShazamListening: shazam.isListening,
onShazam: { shazam.isListening ? shazam.stopListening() : shazam.startListening() }
)
if scanner.isScanning {
HStack(spacing: 8) {
ProgressView()
.controlSize(.small)
Text("Scanning... \(scanner.scanProgress.current) / \(scanner.scanProgress.total) tracks")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
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)
}
.buttonStyle(.plain)
Text("/")
.font(.system(size: 12))
.foregroundStyle(.quaternary)
Text(showHome ? "Home" : (playlist.selectedItem?.name ?? ""))
.font(.system(size: 12, weight: .medium))
}
.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)
}
},
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,
selectedItem: showHome ? nil : playlist.selectedItem,
isHomeSelected: showHome,
onHomeSelect: {
if showHome {
showHome = false
} else {
playlist.deselectPlaylist()
searchText = ""
showHome = true
}
},
onSelect: { item in
showHome = false
if item is SmartPlaylist {
playlist.sortColumn = "album"
playlist.sortAscending = true
}
playlist.selectItem(item)
if let smart = item as? SmartPlaylist {
searchText = smart.searchQuery
library.search(smart.searchQuery)
}
},
onDeselect: {
playlist.deselectPlaylist()
searchText = ""
},
onRename: { item in
itemToRename = item
playlistNameInput = item.name
showRenameAlert = true
},
onDelete: { item in
if let p = item as? Playlist {
try? playlist.deletePlaylist(p)
} else if let sp = item as? SmartPlaylist {
try? playlist.deleteSmartPlaylist(sp)
}
},
onEditQuery: { smart in
smartPlaylistToEdit = smart
editQueryInput = smart.searchQuery
showEditQueryAlert = true
}
)
playerControls
}
.onAppear { installKeyboardMonitor() }
.onDisappear { removeKeyboardMonitor() }
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
handleDrop(providers)
return true
}
.onChange(of: audio.currentTime) { _, _ in
player.checkHalfway()
}
.onChange(of: library.trackCount) { _, _ in
if showHome { loadHomeData() }
}
.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", isPresented: $showRenameAlert) {
TextField("Name", text: $playlistNameInput)
Button("Cancel", role: .cancel) { playlistNameInput = "" }
Button("Rename") {
let name = playlistNameInput.trimmingCharacters(in: .whitespaces)
if !name.isEmpty, let item = itemToRename {
if let p = item as? Playlist {
try? playlist.renamePlaylist(p, to: name)
} else if let sp = item as? SmartPlaylist {
try? playlist.renameSmartPlaylist(sp, to: name)
}
}
playlistNameInput = ""
itemToRename = nil
}
}
.alert("Edit Search Query", isPresented: $showEditQueryAlert) {
TextField("Search query", text: $editQueryInput)
Button("Cancel", role: .cancel) { editQueryInput = "" }
Button("Save") {
let query = editQueryInput.trimmingCharacters(in: .whitespaces)
if !query.isEmpty, let sp = smartPlaylistToEdit {
try? playlist.updateSmartPlaylistQuery(sp, to: query)
if playlist.selectedSmartPlaylist?.id == sp.id {
searchText = query
library.search(query)
}
}
editQueryInput = ""
smartPlaylistToEdit = nil
}
}
.alert("Song Identified", isPresented: Binding(
get: { shazam.matchedTitle != nil },
set: { if !$0 { shazam.clearResult() } }
)) {
Button("OK") { shazam.clearResult() }
} message: {
if let title = shazam.matchedTitle {
Text("\(title)\(shazam.matchedArtist ?? "Unknown Artist")")
}
}
.alert("Shazam Error", isPresented: Binding(
get: { shazam.errorMessage != nil },
set: { if !$0 { shazam.clearResult() } }
)) {
Button("OK") { shazam.clearResult() }
} message: {
if let error = shazam.errorMessage {
Text(error)
}
}
}
private var playerControls: some View {
PlayerControlsView(
currentTrack: player.currentTrack,
isPlaying: audio.isPlaying,
currentTime: audio.currentTime,
duration: audio.duration,
volume: audio.volume,
isShuffled: player.isShuffled,
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) },
onScrubStart: { audio.beginScrubbing() },
onScrub: { audio.scrub(to: $0) },
onScrubEnd: { audio.endScrubbing(at: $0) },
onVolumeChange: { audio.volume = $0 },
onShuffleToggle: { player.toggleShuffle() },
onNowPlayingTap: { scrollToPlayingTrigger = UUID() }
)
}
private func installKeyboardMonitor() {
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [audio, player, library, playlist] event in
guard event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty else {
return event
}
guard let responder = NSApp.keyWindow?.firstResponder,
!(responder is NSTextView) else {
return event
}
switch event.keyCode {
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()
}
return nil
case 123: // left arrow
player.previous()
return nil
case 124: // right arrow
player.next()
return nil
default:
return event
}
}
}
private func removeKeyboardMonitor() {
if let monitor = keyMonitor {
NSEvent.removeMonitor(monitor)
keyMonitor = nil
}
}
private func loadHomeData() {
recentTracks = (try? db.fetchRecentlyAdded(limit: 50)) ?? []
totalDuration = (try? db.totalDuration()) ?? 0
monthlyAdditions = (try? db.fetchMonthlyAdditions(months: 12)) ?? []
}
private func handleDrop(_ providers: [NSItemProvider]) {
for provider in providers {
provider.loadItem(forTypeIdentifier: "public.file-url") { data, _ in
guard let data = data as? Data,
let url = URL(dataRepresentation: data, relativeTo: nil) else { return }
var isDir: ObjCBool = false
FileManager.default.fileExists(atPath: url.path, isDirectory: &isDir)
Task {
if isDir.boolValue {
await scanner.scanFolder(url)
} else if ScannerService.audioExtensions.contains(url.pathExtension.lowercased()) {
if var track = await ScannerService.extractMetadata(from: url) {
try? scanner.db.insert(&track)
}
}
}
}
}
}
}