feat/music-streaming
parent
c374cfa9eb
commit
0a8ad2aa91
@ -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) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ -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) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ -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…
Reference in new issue