Initial commit
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
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")
|
||||
}
|
||||
.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()
|
||||
}
|
||||
Reference in New Issue
Block a user