From 0a8ad2aa91fc4fa286b29183338d0c065992f260 Mon Sep 17 00:00:00 2001 From: Laurent Date: Sun, 24 May 2026 00:02:15 +0200 Subject: [PATCH] feat: assemble UI with search bar, track table, player controls, folder picker, and drag-and-drop --- Music/ContentView.swift | 93 +++++++++++++--- Music/MusicApp.swift | 90 ++++++++++++++-- Music/Views/PlayerControlsView.swift | 153 +++++++++++++++++++++++++++ Music/Views/SearchBarView.swift | 40 +++++++ 4 files changed, 352 insertions(+), 24 deletions(-) create mode 100644 Music/Views/PlayerControlsView.swift create mode 100644 Music/Views/SearchBarView.swift diff --git a/Music/ContentView.swift b/Music/ContentView.swift index bd67b5b..dfd6886 100644 --- a/Music/ContentView.swift +++ b/Music/ContentView.swift @@ -1,24 +1,85 @@ -// -// ContentView.swift -// Music -// -// Created by Laurent Morvillier on 23/05/2026. -// - import SwiftUI +import UniformTypeIdentifiers struct ContentView: View { + var library: LibraryViewModel + var player: PlayerViewModel + var scanner: ScannerService + var audio: AudioService + var body: some View { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") + VStack(spacing: 0) { + SearchBarView( + trackCount: library.trackCount, + onSearch: { library.search($0) } + ) + + 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) + } + + TrackTableView( + tracks: library.tracks, + playingTrackId: player.currentTrack?.id, + onSort: { column in + library.sort(by: column) + }, + onDoubleClick: { track in + player.setQueue(library.tracks) + player.play(track) + } + ) + + PlayerControlsView( + currentTrack: player.currentTrack, + isPlaying: audio.isPlaying, + currentTime: audio.currentTime, + duration: audio.duration, + volume: audio.volume, + isShuffled: player.isShuffled, + onPlayPause: { audio.togglePlayPause() }, + onNext: { player.next() }, + onPrevious: { player.previous() }, + onSeek: { audio.seek(to: $0) }, + onVolumeChange: { audio.volume = $0 }, + onShuffleToggle: { player.toggleShuffle() } + ) + } + .onDrop(of: [.fileURL], isTargeted: nil) { providers in + handleDrop(providers) + return true + } + .onChange(of: audio.currentTime) { _, _ in + player.checkHalfway() } - .padding() } -} -#Preview { - ContentView() + 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) + } + } + } + } + } + } } diff --git a/Music/MusicApp.swift b/Music/MusicApp.swift index 12ed818..81f35b6 100644 --- a/Music/MusicApp.swift +++ b/Music/MusicApp.swift @@ -1,17 +1,91 @@ -// -// MusicApp.swift -// Music -// -// Created by Laurent Morvillier on 23/05/2026. -// - import SwiftUI @main struct MusicApp: App { + @State private var dbService: DatabaseService? + @State private var libraryVM: LibraryViewModel? + @State private var playerVM: PlayerViewModel? + @State private var scannerService: ScannerService? + @State private var audioService = AudioService() + @State private var initError: String? + var body: some Scene { WindowGroup { - ContentView() + Group { + if let db = dbService, + let library = libraryVM, + let player = playerVM, + let scanner = scannerService { + ContentView( + library: library, + player: player, + scanner: scanner, + audio: audioService + ) + } else if let error = initError { + Text("Failed to initialize database: \(error)") + .padding() + } else { + ProgressView("Loading...") + .onAppear { initialize() } + } + } + .frame(minWidth: 800, minHeight: 500) + } + .commands { + CommandGroup(after: .newItem) { + Button("Open Music Folder...") { + pickFolder() + } + .keyboardShortcut("o") + } + } + } + + private func initialize() { + do { + let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, in: .userDomainMask + ).first!.appendingPathComponent("Music", isDirectory: true) + try FileManager.default.createDirectory(at: appSupport, withIntermediateDirectories: true) + + let dbPath = appSupport.appendingPathComponent("db.sqlite").path + let db = try DatabaseService(path: dbPath) + let scanner = ScannerService(db: db) + let library = LibraryViewModel(db: db) + let player = PlayerViewModel(audio: audioService, db: db) + + self.dbService = db + self.scannerService = scanner + self.libraryVM = library + self.playerVM = player + + if let savedFolder = UserDefaults.standard.string(forKey: "musicFolderPath"), + let url = URL(string: savedFolder) { + Task { + await scanner.rescan(url) + } + } + } catch { + initError = error.localizedDescription + } + } + + private func pickFolder() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.message = "Select your music folder" + + guard panel.runModal() == .OK, let url = panel.url else { return } + + UserDefaults.standard.set(url.absoluteString, forKey: "musicFolderPath") + + if let scanner = scannerService { + Task { + await scanner.scanFolder(url) + } } } } diff --git a/Music/Views/PlayerControlsView.swift b/Music/Views/PlayerControlsView.swift new file mode 100644 index 0000000..ce7989b --- /dev/null +++ b/Music/Views/PlayerControlsView.swift @@ -0,0 +1,153 @@ +import SwiftUI + +struct PlayerControlsView: View { + let currentTrack: Track? + let isPlaying: Bool + let currentTime: Double + let duration: Double + let volume: Float + let isShuffled: Bool + let onPlayPause: () -> Void + let onNext: () -> Void + let onPrevious: () -> Void + let onSeek: (Double) -> Void + let onVolumeChange: (Float) -> Void + let onShuffleToggle: () -> Void + + var body: some View { + HStack(spacing: 0) { + nowPlayingSection + .frame(maxWidth: .infinity, alignment: .leading) + + transportSection + .frame(maxWidth: .infinity) + + volumeSection + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(.bar) + } + + private var nowPlayingSection: some View { + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 6) + .fill(.quaternary) + .frame(width: 44, height: 44) + .overlay { + if let data = currentTrack?.artworkData, + let nsImage = NSImage(data: data) { + Image(nsImage: nsImage) + .resizable() + .aspectRatio(contentMode: .fill) + } else { + Image(systemName: "music.note") + .foregroundStyle(.secondary) + } + } + .clipShape(RoundedRectangle(cornerRadius: 6)) + + if let track = currentTrack { + VStack(alignment: .leading, spacing: 2) { + Text(track.title) + .font(.system(size: 13, weight: .medium)) + .lineLimit(1) + Text("\(track.artist) — \(track.album)") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + } + + 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) + } + .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)) + } + .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(currentTime)) + .font(.system(size: 10).monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 35, alignment: .trailing) + + Slider( + value: Binding( + get: { currentTime }, + set: { onSeek($0) } + ), + in: 0...max(duration, 1) + ) + .controlSize(.small) + + Text(Self.formatTime(duration)) + .font(.system(size: 10).monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 35, alignment: .leading) + } + } + .frame(maxWidth: 400) + } + + private var volumeSection: some View { + HStack(spacing: 8) { + Image(systemName: volumeIconName) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .frame(width: 16) + + Slider( + value: Binding( + get: { Double(volume) }, + set: { onVolumeChange(Float($0)) } + ), + in: 0...1 + ) + .controlSize(.small) + .frame(width: 80) + } + } + + private var volumeIconName: String { + if volume == 0 { return "speaker.slash.fill" } + if volume < 0.33 { return "speaker.wave.1.fill" } + if volume < 0.66 { return "speaker.wave.2.fill" } + return "speaker.wave.3.fill" + } + + static func formatTime(_ seconds: Double) -> String { + guard seconds.isFinite, seconds >= 0 else { return "0:00" } + let mins = Int(seconds) / 60 + let secs = Int(seconds) % 60 + return "\(mins):\(String(format: "%02d", secs))" + } +} diff --git a/Music/Views/SearchBarView.swift b/Music/Views/SearchBarView.swift new file mode 100644 index 0000000..c23e422 --- /dev/null +++ b/Music/Views/SearchBarView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct SearchBarView: View { + @State private var searchText = "" + let trackCount: Int + let onSearch: (String) -> Void + + var body: some View { + HStack(spacing: 12) { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField("Search by title, artist, album, genre...", text: $searchText) + .textFieldStyle(.plain) + .onChange(of: searchText) { _, newValue in + onSearch(newValue) + } + if !searchText.isEmpty { + Button { + searchText = "" + onSearch("") + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(8) + .background(.quaternary) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + Text("\(trackCount) tracks") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } +}