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

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)
}
}
}
}
}
}
}