Add macOS local playback engine
This commit is contained in:
@@ -4,6 +4,7 @@ import VelodyDomain
|
||||
|
||||
struct MacLibraryView: View {
|
||||
@State private var viewModel = MacLibraryViewModel()
|
||||
@State private var scrubbedPlaybackTime: Double?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
@@ -94,31 +95,54 @@ struct MacLibraryView: View {
|
||||
Text(viewModel.scanStatus)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
List(viewModel.tracks) { track in
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(track.title)
|
||||
.font(.headline)
|
||||
Text(track.artist)
|
||||
.foregroundStyle(.secondary)
|
||||
List {
|
||||
ForEach(viewModel.tracks, id: \.id) { track in
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Button {
|
||||
viewModel.togglePlayback(for: track)
|
||||
} label: {
|
||||
Image(systemName: viewModel.playbackButtonSymbol(for: track))
|
||||
.font(.title2)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
|
||||
if let album = track.album {
|
||||
Text(album)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(track.title)
|
||||
.font(.headline)
|
||||
Text(track.artist)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let album = track.album {
|
||||
Text(album)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let durationSeconds = track.durationSeconds {
|
||||
Text("Duration: \(format(durationSeconds: durationSeconds))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
Text(track.localFilePath)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 12)
|
||||
|
||||
if viewModel.isCurrentTrack(track) {
|
||||
Text(viewModel.nowPlayingState.isPlaying ? "Playing" : "Paused")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.quaternary, in: Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
if let durationSeconds = track.durationSeconds {
|
||||
Text("Duration: \(format(durationSeconds: durationSeconds))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
Text(track.localFilePath)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.overlay {
|
||||
if viewModel.tracks.isEmpty && !viewModel.isScanning {
|
||||
@@ -130,7 +154,7 @@ struct MacLibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
miniPlayer
|
||||
}
|
||||
.padding(24)
|
||||
.task {
|
||||
@@ -145,6 +169,133 @@ struct MacLibraryView: View {
|
||||
return String(format: "%d:%02d", minutes, seconds)
|
||||
}
|
||||
|
||||
private var displayedPlaybackTime: Double {
|
||||
scrubbedPlaybackTime ?? viewModel.nowPlayingState.currentTime
|
||||
}
|
||||
|
||||
private var playbackDuration: Double {
|
||||
max(
|
||||
viewModel.nowPlayingState.duration,
|
||||
viewModel.nowPlayingState.currentTrack?.durationSeconds ?? 0
|
||||
)
|
||||
}
|
||||
|
||||
private var miniPlayer: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(viewModel.nowPlayingState.currentTrack?.title ?? "No track selected")
|
||||
.font(.headline)
|
||||
|
||||
Text(
|
||||
viewModel.nowPlayingState.currentTrack?.artist
|
||||
?? "Select a scanned MP3 to begin local playback."
|
||||
)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer(minLength: 16)
|
||||
|
||||
if let playbackErrorMessage = viewModel.playbackErrorMessage {
|
||||
Text(playbackErrorMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
viewModel.toggleShuffle()
|
||||
} label: {
|
||||
Image(systemName: "shuffle")
|
||||
}
|
||||
.foregroundStyle(
|
||||
viewModel.nowPlayingState.isShuffleEnabled
|
||||
? Color.accentColor
|
||||
: Color.secondary
|
||||
)
|
||||
.disabled(viewModel.tracks.isEmpty)
|
||||
|
||||
Button {
|
||||
viewModel.playPreviousTrack()
|
||||
} label: {
|
||||
Image(systemName: "backward.fill")
|
||||
}
|
||||
.disabled(viewModel.tracks.isEmpty)
|
||||
|
||||
Button {
|
||||
viewModel.togglePlayPause()
|
||||
} label: {
|
||||
Image(systemName: viewModel.nowPlayingState.isPlaying ? "pause.fill" : "play.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(viewModel.tracks.isEmpty)
|
||||
|
||||
Button {
|
||||
viewModel.stopPlayback()
|
||||
} label: {
|
||||
Image(systemName: "stop.fill")
|
||||
}
|
||||
.disabled(viewModel.nowPlayingState.currentTrack == nil)
|
||||
|
||||
Button {
|
||||
viewModel.playNextTrack()
|
||||
} label: {
|
||||
Image(systemName: "forward.fill")
|
||||
}
|
||||
.disabled(viewModel.tracks.isEmpty)
|
||||
|
||||
Button {
|
||||
viewModel.cycleRepeatMode()
|
||||
} label: {
|
||||
Image(systemName: viewModel.repeatButtonSymbol)
|
||||
}
|
||||
.foregroundStyle(
|
||||
viewModel.nowPlayingState.repeatMode == .off
|
||||
? .secondary
|
||||
: Color.accentColor
|
||||
)
|
||||
.disabled(viewModel.tracks.isEmpty)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Text(format(durationSeconds: displayedPlaybackTime))
|
||||
.font(.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Slider(
|
||||
value: Binding(
|
||||
get: {
|
||||
displayedPlaybackTime
|
||||
},
|
||||
set: { newValue in
|
||||
scrubbedPlaybackTime = newValue
|
||||
}
|
||||
),
|
||||
in: 0...max(playbackDuration, 1),
|
||||
onEditingChanged: { isEditing in
|
||||
if !isEditing {
|
||||
let targetTime = scrubbedPlaybackTime ?? viewModel.nowPlayingState.currentTime
|
||||
scrubbedPlaybackTime = nil
|
||||
viewModel.seekPlayback(to: targetTime)
|
||||
}
|
||||
}
|
||||
)
|
||||
.disabled(viewModel.nowPlayingState.currentTrack == nil || playbackDuration <= 0)
|
||||
|
||||
Text(format(durationSeconds: playbackDuration))
|
||||
.font(.caption)
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusRow(title: String, value: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import Foundation
|
||||
import Observation
|
||||
import VelodyDomain
|
||||
import VelodyNetworking
|
||||
import VelodyPlayback
|
||||
import VelodyPersistence
|
||||
import VelodyUtilities
|
||||
|
||||
@@ -13,6 +14,7 @@ final class MacLibraryViewModel {
|
||||
var scanStatus = "Choose a folder to begin local discovery."
|
||||
var discoveredTrackCount = 0
|
||||
var isScanning = false
|
||||
var nowPlayingState = NowPlayingState()
|
||||
|
||||
var serverURLString: String
|
||||
var deviceRegistrationStatus = "Not registered."
|
||||
@@ -28,6 +30,7 @@ final class MacLibraryViewModel {
|
||||
private let folderAccessService: any VelodyPersistence.FolderAccessService
|
||||
private let catalogService: any LocalCatalogService
|
||||
private let localMusicScanner: any LocalMusicScanner
|
||||
private let playbackController: PlaybackController
|
||||
private let keychainService: any KeychainService
|
||||
private let userDefaults: UserDefaults
|
||||
private var hasLoaded = false
|
||||
@@ -41,14 +44,26 @@ final class MacLibraryViewModel {
|
||||
metadataReader: AVFoundationMetadataReader()
|
||||
)
|
||||
let repository = Self.makeTrackRepository()
|
||||
let playbackController = PlaybackController(
|
||||
sessionStore: UserDefaultsPlaybackSessionStore(
|
||||
userDefaults: userDefaults,
|
||||
storageKey: Self.playbackSessionDefaultsKey
|
||||
)
|
||||
)
|
||||
|
||||
self.folderAccessService = folderAccessService
|
||||
self.catalogService = DefaultLocalCatalogService(repository: repository)
|
||||
self.localMusicScanner = localMusicScanner
|
||||
self.playbackController = playbackController
|
||||
self.keychainService = keychainService
|
||||
self.userDefaults = userDefaults
|
||||
self.serverURLString = userDefaults.string(forKey: Self.serverURLDefaultsKey)
|
||||
?? ServerEnvironment.defaultLocalBaseURL.absoluteString
|
||||
self.nowPlayingState = playbackController.nowPlayingState
|
||||
|
||||
playbackController.onStateChange = { [weak self] state in
|
||||
self?.nowPlayingState = state
|
||||
}
|
||||
|
||||
if let url = folderAccessService.storedFolderURL() {
|
||||
selectedFolderPath = url.path
|
||||
@@ -66,6 +81,7 @@ final class MacLibraryViewModel {
|
||||
scanStatus = "Failed to load saved catalog: \(error.localizedDescription)"
|
||||
}
|
||||
|
||||
playbackController.setCatalogTracks(tracks)
|
||||
await restoreDeviceIdentity()
|
||||
}
|
||||
|
||||
@@ -75,6 +91,7 @@ final class MacLibraryViewModel {
|
||||
scanStatus = "Folder selected. Run a manual scan to discover MP3 files."
|
||||
tracks = []
|
||||
discoveredTrackCount = 0
|
||||
playbackController.setCatalogTracks([])
|
||||
Task {
|
||||
do {
|
||||
try await catalogService.resetLocalCatalog()
|
||||
@@ -106,6 +123,7 @@ final class MacLibraryViewModel {
|
||||
)
|
||||
tracks = scanResult.tracks
|
||||
discoveredTrackCount = tracks.count
|
||||
playbackController.setCatalogTracks(tracks)
|
||||
scanStatus = Self.scanStatus(
|
||||
for: scanResult,
|
||||
activeTrackCount: discoveredTrackCount
|
||||
@@ -115,6 +133,75 @@ final class MacLibraryViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func togglePlayback(for track: LibraryTrack) {
|
||||
if nowPlayingState.currentTrackID == track.id {
|
||||
playbackController.playPause()
|
||||
} else {
|
||||
playbackController.play(trackID: track.id)
|
||||
}
|
||||
}
|
||||
|
||||
func togglePlayPause() {
|
||||
playbackController.playPause()
|
||||
}
|
||||
|
||||
func stopPlayback() {
|
||||
playbackController.stop()
|
||||
}
|
||||
|
||||
func seekPlayback(to time: Double) {
|
||||
playbackController.seek(to: time)
|
||||
}
|
||||
|
||||
func playNextTrack() {
|
||||
playbackController.next()
|
||||
}
|
||||
|
||||
func playPreviousTrack() {
|
||||
playbackController.previous()
|
||||
}
|
||||
|
||||
func toggleShuffle() {
|
||||
playbackController.toggleShuffle()
|
||||
}
|
||||
|
||||
func cycleRepeatMode() {
|
||||
playbackController.cycleRepeatMode()
|
||||
}
|
||||
|
||||
func isCurrentTrack(_ track: LibraryTrack) -> Bool {
|
||||
nowPlayingState.currentTrackID == track.id
|
||||
}
|
||||
|
||||
func isPlaying(_ track: LibraryTrack) -> Bool {
|
||||
isCurrentTrack(track) && nowPlayingState.isPlaying
|
||||
}
|
||||
|
||||
func playbackButtonSymbol(for track: LibraryTrack) -> String {
|
||||
if isPlaying(track) {
|
||||
return "pause.circle.fill"
|
||||
}
|
||||
|
||||
if isCurrentTrack(track) {
|
||||
return "play.circle.fill"
|
||||
}
|
||||
|
||||
return "play.circle"
|
||||
}
|
||||
|
||||
var repeatButtonSymbol: String {
|
||||
switch nowPlayingState.repeatMode {
|
||||
case .off, .all:
|
||||
return "repeat"
|
||||
case .one:
|
||||
return "repeat.1"
|
||||
}
|
||||
}
|
||||
|
||||
var playbackErrorMessage: String? {
|
||||
nowPlayingState.error?.errorDescription
|
||||
}
|
||||
|
||||
func persistServerURLSelection() {
|
||||
guard let serverURL = Self.normalizedServerURL(from: serverURLString) else {
|
||||
return
|
||||
@@ -310,6 +397,7 @@ final class MacLibraryViewModel {
|
||||
private static let serverURLDefaultsKey = "velody.server-environment.base-url"
|
||||
private static let deviceIdKey = "velody.device-id"
|
||||
private static let bootstrapTokenKey = "velody.bootstrap-token"
|
||||
private static let playbackSessionDefaultsKey = "velody.playback.session"
|
||||
}
|
||||
|
||||
private enum BackendConnectionError: LocalizedError {
|
||||
|
||||
Reference in New Issue
Block a user