364 lines
12 KiB
Swift
364 lines
12 KiB
Swift
import Foundation
|
|
import MusicAssistantCore
|
|
import SwiftUI
|
|
|
|
struct ProjectBrowserView: View {
|
|
@StateObject private var viewModel: ProjectBrowserViewModel
|
|
@State private var selectedProjectID: String?
|
|
@State private var projectPendingDeletion: SongProjectListItem?
|
|
|
|
init(store: any ProjectStore = LocalSongProjectStore(directoryURL: ProjectBrowserStorage.projectsDirectoryURL)) {
|
|
_viewModel = StateObject(wrappedValue: ProjectBrowserViewModel(store: store))
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationSplitView {
|
|
VStack(spacing: 0) {
|
|
ProjectLibraryHeader(
|
|
projectCount: viewModel.projects.count,
|
|
isCreatingProject: viewModel.isCreatingProject
|
|
) {
|
|
Task {
|
|
if let projectID = await viewModel.createProject() {
|
|
selectedProjectID = projectID
|
|
}
|
|
}
|
|
}
|
|
|
|
List(selection: $selectedProjectID) {
|
|
Section("Projects") {
|
|
ForEach(viewModel.projects) { project in
|
|
ProjectListRow(project: project) {
|
|
projectPendingDeletion = project
|
|
}
|
|
.tag(project.id)
|
|
.contextMenu {
|
|
Button(role: .destructive) {
|
|
projectPendingDeletion = project
|
|
} label: {
|
|
Label("Delete Project", systemImage: "trash")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.overlay {
|
|
if viewModel.isLoading {
|
|
ProgressView()
|
|
} else if viewModel.projects.isEmpty {
|
|
ContentUnavailableView(
|
|
"No Projects",
|
|
systemImage: "music.note.list",
|
|
description: Text("Create a song project to begin.")
|
|
)
|
|
}
|
|
}
|
|
}
|
|
} detail: {
|
|
ProjectBrowserDetailView(
|
|
project: viewModel.selectedProject,
|
|
errorMessage: viewModel.errorMessage,
|
|
isLoading: viewModel.isOpeningProject,
|
|
saveProject: viewModel.saveProject
|
|
)
|
|
}
|
|
.task {
|
|
await viewModel.loadProjects()
|
|
selectedProjectID = selectedProjectID ?? viewModel.projects.first?.id
|
|
if let selectedProjectID {
|
|
await viewModel.openProject(id: selectedProjectID)
|
|
}
|
|
}
|
|
.onChange(of: selectedProjectID) { _, projectID in
|
|
guard let projectID else {
|
|
viewModel.clearSelection()
|
|
return
|
|
}
|
|
|
|
Task {
|
|
await viewModel.openProject(id: projectID)
|
|
}
|
|
}
|
|
.alert(
|
|
deletionAlertTitle,
|
|
isPresented: deletionAlertBinding
|
|
) {
|
|
Button("Delete", role: .destructive) {
|
|
guard let project = projectPendingDeletion else { return }
|
|
Task {
|
|
await deleteProject(project)
|
|
}
|
|
}
|
|
Button("Cancel", role: .cancel) {}
|
|
} message: {
|
|
Text("This permanently removes the project and its saved versions from this Mac.")
|
|
}
|
|
.navigationSplitViewStyle(.balanced)
|
|
}
|
|
|
|
private var deletionAlertBinding: Binding<Bool> {
|
|
Binding(
|
|
get: { projectPendingDeletion != nil },
|
|
set: { isPresented in
|
|
if !isPresented {
|
|
projectPendingDeletion = nil
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
private var deletionAlertTitle: String {
|
|
guard let projectPendingDeletion else { return "Delete Project?" }
|
|
return "Delete \(projectPendingDeletion.title)?"
|
|
}
|
|
|
|
private func deleteProject(_ project: SongProjectListItem) async {
|
|
guard await viewModel.deleteProject(id: project.id) else { return }
|
|
|
|
if selectedProjectID == project.id {
|
|
selectedProjectID = viewModel.projects.first?.id
|
|
}
|
|
projectPendingDeletion = nil
|
|
}
|
|
}
|
|
|
|
private struct ProjectLibraryHeader: View {
|
|
let projectCount: Int
|
|
let isCreatingProject: Bool
|
|
let createAction: () -> Void
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: "music.note.list")
|
|
.font(.title3.weight(.semibold))
|
|
.foregroundStyle(.tint)
|
|
.frame(width: 34, height: 34)
|
|
.background(.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("AI Music Studio")
|
|
.font(.headline)
|
|
Text("\(projectCount) saved \(projectCount == 1 ? "project" : "projects")")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
Button(action: createAction) {
|
|
Image(systemName: "plus")
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.small)
|
|
.keyboardShortcut("n", modifiers: .command)
|
|
.disabled(isCreatingProject)
|
|
.help("New project")
|
|
.accessibilityLabel("New project")
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 12)
|
|
.background(.bar)
|
|
}
|
|
}
|
|
|
|
private struct ProjectListRow: View {
|
|
let project: SongProjectListItem
|
|
let deleteAction: () -> Void
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 8) {
|
|
Image(systemName: project.idea.isEmpty ? "music.note" : "music.note.house")
|
|
.font(.body.weight(.semibold))
|
|
.foregroundStyle(project.idea.isEmpty ? Color.secondary : Color.accentColor)
|
|
.frame(width: 32, height: 32)
|
|
.background(.quaternary, in: RoundedRectangle(cornerRadius: 8))
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(project.title)
|
|
.font(.headline)
|
|
.lineLimit(1)
|
|
|
|
Text(project.idea.isEmpty ? "No idea yet" : project.idea)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
|
|
Text(project.updatedAt.formatted(date: .abbreviated, time: .shortened))
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
|
|
Spacer(minLength: 4)
|
|
|
|
Button(action: deleteAction) {
|
|
Image(systemName: "trash")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.foregroundStyle(.secondary)
|
|
.help("Delete project")
|
|
.accessibilityLabel("Delete \(project.title)")
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
private struct ProjectBrowserDetailView: View {
|
|
let project: SongProject?
|
|
let errorMessage: String?
|
|
let isLoading: Bool
|
|
let saveProject: (SongProject) async -> Bool
|
|
|
|
var body: some View {
|
|
Group {
|
|
if isLoading {
|
|
ProgressView()
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if let project {
|
|
ChatWorkspaceView(project: project, saveProject: saveProject)
|
|
.id(project.id)
|
|
} else {
|
|
ContentUnavailableView(
|
|
"Select a Project",
|
|
systemImage: "music.note",
|
|
description: Text("Choose a saved song project from the browser.")
|
|
)
|
|
}
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
if let errorMessage {
|
|
Text(errorMessage)
|
|
.font(.callout)
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 8)
|
|
.background(.red, in: RoundedRectangle(cornerRadius: 8))
|
|
.padding()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class ProjectBrowserViewModel: ObservableObject {
|
|
@Published private(set) var projects: [SongProjectListItem] = []
|
|
@Published private(set) var selectedProject: SongProject?
|
|
@Published private(set) var isLoading = false
|
|
@Published private(set) var isOpeningProject = false
|
|
@Published private(set) var isCreatingProject = false
|
|
@Published private(set) var isDeletingProject = false
|
|
@Published private(set) var errorMessage: String?
|
|
|
|
private let store: any ProjectStore
|
|
|
|
init(store: any ProjectStore) {
|
|
self.store = store
|
|
}
|
|
|
|
func loadProjects() async {
|
|
isLoading = true
|
|
errorMessage = nil
|
|
|
|
do {
|
|
projects = try await store.loadProjectList()
|
|
} catch {
|
|
errorMessage = "Unable to load projects."
|
|
}
|
|
|
|
isLoading = false
|
|
}
|
|
|
|
func openProject(id projectID: String) async {
|
|
isOpeningProject = true
|
|
errorMessage = nil
|
|
|
|
do {
|
|
selectedProject = try await store.open(id: projectID)
|
|
} catch {
|
|
selectedProject = nil
|
|
errorMessage = "Unable to open this project."
|
|
}
|
|
|
|
isOpeningProject = false
|
|
}
|
|
|
|
func createProject() async -> String? {
|
|
isCreatingProject = true
|
|
errorMessage = nil
|
|
|
|
let now = Date()
|
|
let project = SongProject(
|
|
title: "Untitled Song",
|
|
idea: "",
|
|
createdAt: now,
|
|
updatedAt: now
|
|
)
|
|
|
|
do {
|
|
try await store.create(project)
|
|
projects = try await store.loadProjectList()
|
|
selectedProject = project
|
|
isCreatingProject = false
|
|
return project.id
|
|
} catch {
|
|
errorMessage = "Unable to create a new project."
|
|
isCreatingProject = false
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func saveProject(_ project: SongProject) async -> Bool {
|
|
var projectToSave = project
|
|
projectToSave.updatedAt = Date()
|
|
errorMessage = nil
|
|
|
|
do {
|
|
try await store.save(projectToSave)
|
|
projects = try await store.loadProjectList()
|
|
selectedProject = projectToSave
|
|
return true
|
|
} catch {
|
|
errorMessage = "Unable to save this project."
|
|
return false
|
|
}
|
|
}
|
|
|
|
func deleteProject(id projectID: String) async -> Bool {
|
|
guard !isDeletingProject else { return false }
|
|
isDeletingProject = true
|
|
errorMessage = nil
|
|
|
|
do {
|
|
try await store.delete(id: projectID)
|
|
projects = try await store.loadProjectList()
|
|
if selectedProject?.id == projectID {
|
|
selectedProject = nil
|
|
}
|
|
isDeletingProject = false
|
|
return true
|
|
} catch {
|
|
errorMessage = "Unable to delete this project."
|
|
isDeletingProject = false
|
|
return false
|
|
}
|
|
}
|
|
|
|
func clearSelection() {
|
|
selectedProject = nil
|
|
}
|
|
}
|
|
|
|
enum ProjectBrowserStorage {
|
|
static var projectsDirectoryURL: URL {
|
|
let baseURL = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
|
?? FileManager.default.temporaryDirectory
|
|
|
|
return baseURL
|
|
.appendingPathComponent("AI Music Studio", isDirectory: true)
|
|
.appendingPathComponent("Projects", isDirectory: true)
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
ProjectBrowserView()
|
|
}
|