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.
376 lines
15 KiB
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|