238 lines
7.2 KiB
Swift
238 lines
7.2 KiB
Swift
import Foundation
|
|
import MusicAssistantCore
|
|
import SwiftUI
|
|
|
|
struct ProjectBrowserView: View {
|
|
@StateObject private var viewModel: ProjectBrowserViewModel
|
|
@State private var selectedProjectID: String?
|
|
|
|
init(store: any ProjectStore = LocalSongProjectStore(directoryURL: ProjectBrowserStorage.projectsDirectoryURL)) {
|
|
_viewModel = StateObject(wrappedValue: ProjectBrowserViewModel(store: store))
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationSplitView {
|
|
List(selection: $selectedProjectID) {
|
|
Section("Projects") {
|
|
ForEach(viewModel.projects) { project in
|
|
ProjectListRow(project: project)
|
|
.tag(project.id)
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("AI Music Studio")
|
|
.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.")
|
|
)
|
|
}
|
|
}
|
|
.toolbar {
|
|
ToolbarItem {
|
|
Button {
|
|
Task {
|
|
if let projectID = await viewModel.createProject() {
|
|
selectedProjectID = projectID
|
|
}
|
|
}
|
|
} label: {
|
|
Label("New Project", systemImage: "plus")
|
|
}
|
|
.keyboardShortcut("n", modifiers: .command)
|
|
.disabled(viewModel.isCreatingProject)
|
|
}
|
|
}
|
|
} 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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ProjectListRow: View {
|
|
let project: SongProjectListItem
|
|
|
|
var body: some View {
|
|
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)
|
|
}
|
|
.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 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 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()
|
|
}
|