Files
music-assistant/Sources/MusicAssistantApp/Presentation/ProjectBrowserView.swift
T
2026-09-13 20:21:10 +02:00

237 lines
7.1 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")
}
.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()
}