feat: assemble UI with search bar, track table, player controls, folder picker, and drag-and-drop

feat/music-streaming
Laurent 1 month ago
parent c374cfa9eb
commit 0a8ad2aa91
  1. 93
      Music/ContentView.swift
  2. 90
      Music/MusicApp.swift
  3. 153
      Music/Views/PlayerControlsView.swift
  4. 40
      Music/Views/SearchBarView.swift

@ -1,24 +1,85 @@
//
// ContentView.swift
// Music
//
// Created by Laurent Morvillier on 23/05/2026.
//
import SwiftUI import SwiftUI
import UniformTypeIdentifiers
struct ContentView: View { struct ContentView: View {
var library: LibraryViewModel
var player: PlayerViewModel
var scanner: ScannerService
var audio: AudioService
var body: some View { var body: some View {
VStack { VStack(spacing: 0) {
Image(systemName: "globe") SearchBarView(
.imageScale(.large) trackCount: library.trackCount,
.foregroundStyle(.tint) onSearch: { library.search($0) }
Text("Hello, world!") )
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 { private func handleDrop(_ providers: [NSItemProvider]) {
ContentView() 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)
}
}
}
}
}
}
} }

@ -1,17 +1,91 @@
//
// MusicApp.swift
// Music
//
// Created by Laurent Morvillier on 23/05/2026.
//
import SwiftUI import SwiftUI
@main @main
struct MusicApp: App { 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 { var body: some Scene {
WindowGroup { 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)
}
} }
} }
} }

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

@ -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)
}
}
Loading…
Cancel
Save