Add macOS local playback engine
This commit is contained in:
@@ -9,14 +9,14 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
0AF7078E8BD078ECE18B6C0A /* VelodyiPhoneApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2AEECC422FC77F78C45B17D /* VelodyiPhoneApp.swift */; };
|
||||
151772779307EFC3B3A17477 /* MacLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD72F644E3F2E28758E68D63 /* MacLibraryViewModel.swift */; };
|
||||
2E9DD262BF65832378F37DD4 /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = 4DD09D7C123E184CEC0A2F4D /* VelodyPersistence */; };
|
||||
2E9DD262BF65832378F37DD4 /* VelodyPlayback in Frameworks */ = {isa = PBXBuildFile; productRef = EA45DE5B08D71D666009BB5E /* VelodyPlayback */; };
|
||||
2F9E426A66F4887C301AB13C /* FolderAccessService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EC9AED651FE0AB193AAFE94 /* FolderAccessService.swift */; };
|
||||
3B9765B34963F430467B7527 /* LocalMusicScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CAD2FE907C9C90FBE01E7D4 /* LocalMusicScanner.swift */; };
|
||||
3D22DE55C4A27A4DE68E6359 /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = AAD3F903A475AA0B0159C79E /* VelodyUtilities */; };
|
||||
3D22DE55C4A27A4DE68E6359 /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = DA5EBADF45BC0977F73F241C /* VelodySync */; };
|
||||
3D2A2E7D9371C62F8F86DD84 /* VelodyDomain in Frameworks */ = {isa = PBXBuildFile; productRef = 3C910108376E6ECEF152DCE1 /* VelodyDomain */; };
|
||||
5D2616BFA3DD5E131EC928F2 /* iPhoneLibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE56EE8AD38DC563003A7979 /* iPhoneLibraryViewModel.swift */; };
|
||||
7174D80FB45839E82F150613 /* VelodyMacApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96A45EA6FB9536D0CD91874C /* VelodyMacApp.swift */; };
|
||||
783EBF82108F7A04B6DD33B4 /* VelodySync in Frameworks */ = {isa = PBXBuildFile; productRef = DA5EBADF45BC0977F73F241C /* VelodySync */; };
|
||||
783EBF82108F7A04B6DD33B4 /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = 4DD09D7C123E184CEC0A2F4D /* VelodyPersistence */; };
|
||||
8A3C2C7EB14F364D682A92B7 /* VelodyNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = 713279722B202FB1CF4A869E /* VelodyNetworking */; };
|
||||
93F386D6A8B0131EAB50E2B9 /* VelodyDomain in Frameworks */ = {isa = PBXBuildFile; productRef = 48BF8F8596E7A86383A9CCD1 /* VelodyDomain */; };
|
||||
A54D8AD8A59D8B77FCA0794F /* MacLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB28FE17346E100F697C1BF4 /* MacLibraryView.swift */; };
|
||||
@@ -24,12 +24,14 @@
|
||||
D0D65CE73B9DFF3C73F432DB /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = B15F842ACBB110CC8A766669 /* VelodyUtilities */; };
|
||||
D4B554447B262C7B946ED21F /* VelodyPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = C8F5FF593C4DB829D1CDD497 /* VelodyPersistence */; };
|
||||
EE48EF0688C7E33CDA783234 /* VelodyNetworking in Frameworks */ = {isa = PBXBuildFile; productRef = 0682A261A6F2F050F4B83AF6 /* VelodyNetworking */; };
|
||||
FB68EF710F2B6FBAF80F63F0 /* VelodyUtilities in Frameworks */ = {isa = PBXBuildFile; productRef = AAD3F903A475AA0B0159C79E /* VelodyUtilities */; };
|
||||
FB76843BB27CCCD2B0CFF11D /* iPhoneLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD8848AEA828FE5B2607EB46 /* iPhoneLibraryView.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
07508485E10C6E2942FE29AB /* VelodySync */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodySync; path = ../../packages/apple/VelodySync; sourceTree = SOURCE_ROOT; };
|
||||
0F6993844F6FD7E86D52EC25 /* VelodyNetworking */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyNetworking; path = ../../packages/apple/VelodyNetworking; sourceTree = SOURCE_ROOT; };
|
||||
15A17C02F8CBB0A492A82C14 /* VelodyPlayback */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyPlayback; path = ../../packages/apple/VelodyPlayback; sourceTree = SOURCE_ROOT; };
|
||||
3CAD2FE907C9C90FBE01E7D4 /* LocalMusicScanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalMusicScanner.swift; sourceTree = "<group>"; };
|
||||
5EC9AED651FE0AB193AAFE94 /* FolderAccessService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FolderAccessService.swift; sourceTree = "<group>"; };
|
||||
6E7BCB85A35B8286E4472822 /* VelodyPersistence */ = {isa = PBXFileReference; lastKnownFileType = folder; name = VelodyPersistence; path = ../../packages/apple/VelodyPersistence; sourceTree = SOURCE_ROOT; };
|
||||
@@ -52,9 +54,10 @@
|
||||
files = (
|
||||
3D2A2E7D9371C62F8F86DD84 /* VelodyDomain in Frameworks */,
|
||||
EE48EF0688C7E33CDA783234 /* VelodyNetworking in Frameworks */,
|
||||
2E9DD262BF65832378F37DD4 /* VelodyPersistence in Frameworks */,
|
||||
783EBF82108F7A04B6DD33B4 /* VelodySync in Frameworks */,
|
||||
3D22DE55C4A27A4DE68E6359 /* VelodyUtilities in Frameworks */,
|
||||
2E9DD262BF65832378F37DD4 /* VelodyPlayback in Frameworks */,
|
||||
783EBF82108F7A04B6DD33B4 /* VelodyPersistence in Frameworks */,
|
||||
3D22DE55C4A27A4DE68E6359 /* VelodySync in Frameworks */,
|
||||
FB68EF710F2B6FBAF80F63F0 /* VelodyUtilities in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -90,6 +93,7 @@
|
||||
F7EF830CE1ABB841E624A660 /* VelodyDomain */,
|
||||
0F6993844F6FD7E86D52EC25 /* VelodyNetworking */,
|
||||
6E7BCB85A35B8286E4472822 /* VelodyPersistence */,
|
||||
15A17C02F8CBB0A492A82C14 /* VelodyPlayback */,
|
||||
07508485E10C6E2942FE29AB /* VelodySync */,
|
||||
89FE56E825FC42D026EAC784 /* VelodyUtilities */,
|
||||
);
|
||||
@@ -146,6 +150,7 @@
|
||||
packageProductDependencies = (
|
||||
3C910108376E6ECEF152DCE1 /* VelodyDomain */,
|
||||
0682A261A6F2F050F4B83AF6 /* VelodyNetworking */,
|
||||
EA45DE5B08D71D666009BB5E /* VelodyPlayback */,
|
||||
4DD09D7C123E184CEC0A2F4D /* VelodyPersistence */,
|
||||
DA5EBADF45BC0977F73F241C /* VelodySync */,
|
||||
AAD3F903A475AA0B0159C79E /* VelodyUtilities */,
|
||||
@@ -200,6 +205,7 @@
|
||||
43AF8C7D2C6F46AB2748AAD9 /* XCLocalSwiftPackageReference "../../packages/apple/VelodyDomain" */,
|
||||
5DC2C05FD0EC7BD980C0BF69 /* XCLocalSwiftPackageReference "../../packages/apple/VelodyNetworking" */,
|
||||
E9AE6C97FDD54CE81E584DDC /* XCLocalSwiftPackageReference "../../packages/apple/VelodyPersistence" */,
|
||||
2FCEB9E0A092D6C2F4B66645 /* XCLocalSwiftPackageReference "../../packages/apple/VelodyPlayback" */,
|
||||
60A72563C2E87FC2BA78729A /* XCLocalSwiftPackageReference "../../packages/apple/VelodySync" */,
|
||||
4290F294795FE24C6BF03B5B /* XCLocalSwiftPackageReference "../../packages/apple/VelodyUtilities" */,
|
||||
);
|
||||
@@ -457,6 +463,10 @@
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCLocalSwiftPackageReference section */
|
||||
2FCEB9E0A092D6C2F4B66645 /* XCLocalSwiftPackageReference "../../packages/apple/VelodyPlayback" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = ../../packages/apple/VelodyPlayback;
|
||||
};
|
||||
4290F294795FE24C6BF03B5B /* XCLocalSwiftPackageReference "../../packages/apple/VelodyUtilities" */ = {
|
||||
isa = XCLocalSwiftPackageReference;
|
||||
relativePath = ../../packages/apple/VelodyUtilities;
|
||||
@@ -520,6 +530,10 @@
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = VelodySync;
|
||||
};
|
||||
EA45DE5B08D71D666009BB5E /* VelodyPlayback */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = VelodyPlayback;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = D14070EEF27F35DD4F5F547B /* Project object */;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,6 +10,8 @@ packages:
|
||||
path: ../../packages/apple/VelodyDomain
|
||||
VelodyNetworking:
|
||||
path: ../../packages/apple/VelodyNetworking
|
||||
VelodyPlayback:
|
||||
path: ../../packages/apple/VelodyPlayback
|
||||
VelodyPersistence:
|
||||
path: ../../packages/apple/VelodyPersistence
|
||||
VelodySync:
|
||||
@@ -34,6 +36,8 @@ targets:
|
||||
product: VelodyDomain
|
||||
- package: VelodyNetworking
|
||||
product: VelodyNetworking
|
||||
- package: VelodyPlayback
|
||||
product: VelodyPlayback
|
||||
- package: VelodyPersistence
|
||||
product: VelodyPersistence
|
||||
- package: VelodySync
|
||||
|
||||
Reference in New Issue
Block a user