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.
552 lines
24 KiB
552 lines
24 KiB
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
// Identifiable wrapper so the Get Info sheet can be driven by `.sheet(item:)`
|
|
// with one or many target tracks.
|
|
struct TrackInfoRequest: Identifiable {
|
|
let id = UUID()
|
|
let tracks: [Track]
|
|
}
|
|
|
|
struct ContentView: View {
|
|
var library: LibraryViewModel
|
|
var player: PlayerViewModel
|
|
var scanner: ScannerService
|
|
var playlist: PlaylistViewModel
|
|
var shazam: ShazamService
|
|
var db: DatabaseService
|
|
@Binding var showNewPlaylistAlert: Bool
|
|
@Binding var showSmartPlaylistBuilder: Bool
|
|
var networkStatus: NetworkStatus?
|
|
@State private var infoRequest: TrackInfoRequest?
|
|
@State private var showRenameAlert = false
|
|
@State private var showEditQueryAlert = false
|
|
@State private var smartPlaylistBuilderEditing: SmartPlaylist?
|
|
@State private var playlistNameInput = ""
|
|
@State private var newPlaylistTrack: Track?
|
|
@State private var newPlaylistNameInput = ""
|
|
@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 showQueue = false
|
|
@State private var recentTracks: [Track] = []
|
|
@State private var totalDuration: Double = 0
|
|
@State private var monthlyAdditions: [MonthlyCount] = []
|
|
|
|
/// The remote/streaming connection status banner. Extracted from `body` so the
|
|
/// type-checker doesn't have to solve the whole (very large) view in one expression
|
|
/// — without this, a clean build fails with "unable to type-check in reasonable time".
|
|
@ViewBuilder
|
|
private var networkBanner: some View {
|
|
if let status = networkStatus {
|
|
switch status.mode {
|
|
case .remote(let hostName):
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
|
.font(.system(size: 10)).foregroundStyle(.blue)
|
|
Text("Connected to \(hostName)")
|
|
.font(.system(size: 11, weight: .medium)).foregroundStyle(.blue)
|
|
Spacer()
|
|
Button("Refresh") { status.onRefreshLibrary?() }
|
|
.font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.secondary)
|
|
Button("Disconnect") { status.onDisconnect?() }
|
|
.font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.red)
|
|
}
|
|
.padding(.horizontal, 12).padding(.vertical, 4)
|
|
.background(Color.blue.opacity(0.08))
|
|
case .hosting(let remoteName):
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "antenna.radiowaves.left.and.right")
|
|
.font(.system(size: 10)).foregroundStyle(.green)
|
|
Text(remoteName != nil ? "Hosting · \(remoteName!) connected" : "Hosting")
|
|
.font(.system(size: 11, weight: .medium)).foregroundStyle(.green)
|
|
Spacer()
|
|
}
|
|
.padding(.horizontal, 12).padding(.vertical, 4)
|
|
.background(Color.green.opacity(0.08))
|
|
case .streamHosting, .streamClient:
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "cloud")
|
|
.font(.system(size: 10)).foregroundStyle(.purple)
|
|
Text(status.statusMessage)
|
|
.font(.system(size: 11, weight: .medium)).foregroundStyle(.purple)
|
|
Spacer()
|
|
if case .streamClient = status.mode {
|
|
Button("Refresh") { status.onRefreshLibrary?() }
|
|
.font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.secondary)
|
|
}
|
|
Button("Disconnect") { status.onDisconnect?() }
|
|
.font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.red)
|
|
}
|
|
.padding(.horizontal, 12).padding(.vertical, 4)
|
|
.background(Color.purple.opacity(0.08))
|
|
}
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
networkBanner
|
|
|
|
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)
|
|
}
|
|
|
|
HStack(spacing: 0) {
|
|
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, contextName: "Recently Added")
|
|
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, contextName: playlist.selectedItem?.name ?? "Library")
|
|
player.play(track)
|
|
},
|
|
contextMenuConfig: trackContextMenuConfig,
|
|
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)
|
|
|
|
if showQueue && !isDrivingRemoteDevice {
|
|
Divider()
|
|
QueueView(player: player)
|
|
}
|
|
}
|
|
|
|
PlaylistBarView(
|
|
playlists: playlist.allPlaylists,
|
|
selectedItem: showHome ? nil : playlist.selectedItem,
|
|
isHomeSelected: showHome,
|
|
isRemoteMode: networkStatus?.isRemoteMode ?? false,
|
|
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
|
|
},
|
|
onEditConditions: { smart in
|
|
smartPlaylistBuilderEditing = smart
|
|
},
|
|
onDropTrack: { trackId, targetPlaylist in
|
|
guard let track = library.tracks.first(where: { $0.id == trackId }) else { return }
|
|
try? playlist.addTrack(track, to: targetPlaylist)
|
|
}
|
|
)
|
|
|
|
playerControls
|
|
}
|
|
.onAppear { installKeyboardMonitor() }
|
|
.onDisappear { removeKeyboardMonitor() }
|
|
.onDrop(of: [.fileURL], isTargeted: nil) { providers in
|
|
handleDrop(providers)
|
|
return true
|
|
}
|
|
.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("New Playlist", isPresented: Binding(
|
|
get: { newPlaylistTrack != nil },
|
|
set: { if !$0 { newPlaylistTrack = nil; newPlaylistNameInput = "" } }
|
|
)) {
|
|
TextField("Playlist name", text: $newPlaylistNameInput)
|
|
Button("Cancel", role: .cancel) {
|
|
newPlaylistNameInput = ""
|
|
newPlaylistTrack = nil
|
|
}
|
|
Button("Create") {
|
|
let name = newPlaylistNameInput.trimmingCharacters(in: .whitespaces)
|
|
if !name.isEmpty, let track = newPlaylistTrack {
|
|
try? playlist.createPlaylistAndAddTrack(name: name, track: track)
|
|
}
|
|
newPlaylistNameInput = ""
|
|
newPlaylistTrack = nil
|
|
}
|
|
}
|
|
.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("Search MP3") {
|
|
let query = [shazam.matchedTitle, shazam.matchedArtist]
|
|
.compactMap { $0 }
|
|
.joined(separator: " ")
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(query, forType: .string)
|
|
if let url = URL(string: "https://freemp3music.org/") {
|
|
NSWorkspace.shared.open(url)
|
|
}
|
|
shazam.clearResult()
|
|
}
|
|
Button("Close", role: .cancel) { 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)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showSmartPlaylistBuilder) {
|
|
SmartPlaylistBuilderSheet(
|
|
editingPlaylist: nil,
|
|
onSave: { name, conditions in
|
|
try? playlist.createSmartPlaylist(name: name, conditions: conditions)
|
|
showSmartPlaylistBuilder = false
|
|
},
|
|
onCancel: { showSmartPlaylistBuilder = false }
|
|
)
|
|
}
|
|
.sheet(item: $smartPlaylistBuilderEditing) { smart in
|
|
SmartPlaylistBuilderSheet(
|
|
editingPlaylist: smart,
|
|
onSave: { name, conditions in
|
|
// Two separate writes: consistent with how mutations are handled throughout the UI.
|
|
// Partial failure (rename succeeds, conditions fail) is accepted given error
|
|
// feedback is not implemented at the UI layer.
|
|
if name != smart.name {
|
|
try? playlist.renameSmartPlaylist(smart, to: name)
|
|
}
|
|
try? playlist.updateSmartPlaylistConditions(smart, to: conditions)
|
|
smartPlaylistBuilderEditing = nil
|
|
},
|
|
onCancel: { smartPlaylistBuilderEditing = nil }
|
|
)
|
|
}
|
|
.sheet(item: $infoRequest) { req in
|
|
TrackInfoSheet(
|
|
tracks: req.tracks,
|
|
onSave: { values, edited in
|
|
let targets = req.tracks
|
|
infoRequest = nil
|
|
Task {
|
|
_ = await library.applyTrackEdits(values, editing: edited, to: targets)
|
|
}
|
|
},
|
|
onCancel: { infoRequest = nil }
|
|
)
|
|
}
|
|
}
|
|
|
|
/// True only when driving a separate remote device (RemotePlaybackProvider is active).
|
|
/// Stream-client mode plays locally, so the manual queue is available there and is NOT gated.
|
|
private var isDrivingRemoteDevice: Bool {
|
|
guard let mode = networkStatus?.mode else { return false }
|
|
if case .remote = mode { return true }
|
|
return false
|
|
}
|
|
|
|
private var trackContextMenuConfig: TrackContextMenuConfig {
|
|
// Queue actions are local-only for v1: hidden when driving a remote device.
|
|
let queueEnabled = !isDrivingRemoteDevice
|
|
return TrackContextMenuConfig(
|
|
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)
|
|
},
|
|
// Outer nil hides the "Remove from Playlist" menu item when not in a playlist view.
|
|
// Inner re-check defends against the playlist being deselected between menu display and action.
|
|
onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in
|
|
if let selected = playlist.selectedPlaylist {
|
|
try? playlist.removeTrack(track, from: selected)
|
|
}
|
|
} : nil,
|
|
onPlayNext: queueEnabled ? { track in player.playNext(track) } : nil,
|
|
onAddToQueue: queueEnabled ? { track in player.addToQueue(track) } : nil,
|
|
onAddToNewPlaylist: { track in newPlaylistTrack = track },
|
|
onGetInfo: { tracks in
|
|
if !tracks.isEmpty { infoRequest = TrackInfoRequest(tracks: tracks) }
|
|
}
|
|
)
|
|
}
|
|
|
|
private var playerControls: some View {
|
|
PlayerControlsView(
|
|
currentTrack: player.currentTrack,
|
|
isPlaying: player.isPlaying,
|
|
currentTime: player.currentTime,
|
|
duration: player.duration,
|
|
volume: player.volume,
|
|
isShuffled: player.isShuffled,
|
|
isBuffering: player.isBuffering,
|
|
streamingError: player.streamingError,
|
|
onPlayPause: {
|
|
if player.currentTrack == nil {
|
|
let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks
|
|
if let first = trackList.first {
|
|
player.setQueue(trackList, contextName: playlist.selectedItem?.name ?? "Library")
|
|
player.play(first)
|
|
}
|
|
} else {
|
|
player.togglePlayPause()
|
|
}
|
|
},
|
|
onNext: { player.next() },
|
|
onPrevious: { player.previous() },
|
|
onSeek: { player.seek(to: $0) },
|
|
onScrubStart: { player.beginScrubbing() },
|
|
onScrub: { player.scrub(to: $0) },
|
|
onScrubEnd: { player.endScrubbing(at: $0) },
|
|
onVolumeChange: { player.setVolume($0) },
|
|
onShuffleToggle: { player.toggleShuffle() },
|
|
onNowPlayingTap: { scrollToPlayingTrigger = UUID() },
|
|
contextMenuConfig: trackContextMenuConfig,
|
|
isQueueVisible: showQueue,
|
|
showQueueButton: !isDrivingRemoteDevice,
|
|
onToggleQueue: { showQueue.toggle() }
|
|
)
|
|
}
|
|
|
|
private func installKeyboardMonitor() {
|
|
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [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, contextName: playlist.selectedItem?.name ?? "Library")
|
|
player.play(first)
|
|
}
|
|
} else {
|
|
player.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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|