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 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…
Reference in new issue