import SwiftData import SwiftUI /// Main content view — Sidebar with playlists | Playlist detail | Player. struct ContentView: View { @Environment(PlayerViewModel.self) private var playerVM @Environment(PlaylistViewModel.self) private var playlistVM @EnvironmentObject private var libraryManager: LibraryManager @Environment(\.modelContext) private var modelContext @Environment(\.openWindow) private var openWindow @State private var selectedPlaylist: Playlist? @State private var showNewPlaylistSheet = false @State private var columnVisibility: NavigationSplitViewVisibility = .all @State private var hasRestoredState = false @State private var showGlobalSearch = false @State private var showInlineNowPlaying = false @State private var showCloudBrowser = false @Query(sort: \Playlist.dateModified, order: .reverse) private var playlists: [Playlist] @Query private var allTracks: [Track] var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { SidebarView( selectedPlaylist: $selectedPlaylist, showNewPlaylistSheet: $showNewPlaylistSheet, showCloudBrowser: $showCloudBrowser ) .navigationSplitViewColumnWidth(min: 180, ideal: 220, max: 300) } detail: { VStack(spacing: 0) { // Mix target buttons bar MixTargetBar() if showInlineNowPlaying, playerVM.currentTrack != nil { NowPlayingView(displayMode: .inline) } else if showCloudBrowser { NavigationStack { CloudBrowserView() } } else if let playlist = selectedPlaylist { PlaylistView(playlist: playlist) } else { WelcomeView(onNewPlaylist: { showNewPlaylistSheet = true }) } // Status message toast if let status = playlistVM.statusMessage { HStack(spacing: 6) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 10)) .foregroundStyle(.green) Text(status) .font(.system(size: 11)) .foregroundStyle(.secondary) } .padding(.horizontal, 12) .padding(.vertical, 4) .background(.bar) .transition(.move(edge: .bottom).combined(with: .opacity)) .animation(.easeInOut(duration: 0.3), value: playlistVM.statusMessage) } Divider() PlayerView() } } .onAppear { libraryManager.setModelContext(modelContext) playlistVM.restoreTargetPlaylist(from: playlists) restoreLastState() } .task { // One-time migration: backfill year metadata for tracks missing or with invalid year let descriptor = FetchDescriptor(predicate: #Predicate { $0.year == nil || $0.year! < 1900 || $0.year! > 2100 }) if let needsYear = try? modelContext.fetch(descriptor), !needsYear.isEmpty { print("Backfilling year for \(needsYear.count) tracks...") await libraryManager.rescanAllMetadata(tracks: needsYear) print("Year backfill complete.") } } .onChange(of: playlists) { _, _ in // Retry restore when @Query results load (they may be empty on first onAppear) if !hasRestoredState { restoreLastState() } } .onChange(of: selectedPlaylist) { _, newPlaylist in if let id = newPlaylist?.id { AppState.saveLastPlaylist(id: id) showCloudBrowser = false } } .sheet(isPresented: $showNewPlaylistSheet) { NewPlaylistSheet(selectedPlaylist: $selectedPlaylist) } .sheet(isPresented: $showGlobalSearch) { GlobalSearchSheet(playlists: playlists) } .onReceive(NotificationCenter.default.publisher(for: .newPlaylist)) { _ in showNewPlaylistSheet = true } .onReceive(NotificationCenter.default.publisher(for: .quickAddToTarget)) { notification in if let track = notification.object as? Track { _ = playlistVM.quickAddToTarget(track: track, context: modelContext) } } .onReceive(NotificationCenter.default.publisher(for: .quickAddToMix)) { notification in guard let info = notification.userInfo, let slot = info["slot"] as? Int, let track = info["track"] as? Track else { return } _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext) } .onReceive(NotificationCenter.default.publisher(for: .globalSearch)) { _ in showGlobalSearch = true } .onReceive(NotificationCenter.default.publisher(for: .toggleNowPlaying)) { _ in if playerVM.currentTrack != nil { showInlineNowPlaying.toggle() } } .onReceive(NotificationCenter.default.publisher(for: .popOutNowPlaying)) { _ in showInlineNowPlaying = false openWindow(id: "now-playing") } .onReceive(NotificationCenter.default.publisher(for: .closeInlineNowPlaying)) { _ in showInlineNowPlaying = false } } private func restoreLastState() { guard !hasRestoredState else { return } guard !playlists.isEmpty else { return } // Restore last selected playlist if let lastPlaylistID = AppState.lastPlaylistID, let lastPlaylist = playlists.first(where: { $0.id == lastPlaylistID }) { selectedPlaylist = lastPlaylist hasRestoredState = true // Restore last playing entry in that playlist if let lastEntryID = AppState.lastEntryID, let entry = lastPlaylist.sortedEntries.first(where: { $0.id == lastEntryID }), let track = entry.track { // Load the track paused at the last position do { try playerVM.audioEngine.loadTrack(track) playerVM.currentPlayingEntryID = entry.id playerVM.currentPlaylist = lastPlaylist let lastTime = AppState.lastPlaybackTime if lastTime > 0 { playerVM.audioEngine.seek(to: lastTime) } // Sync state without playing playerVM.audioEngine.updateCurrentTime() playerVM.duration = playerVM.audioEngine.duration playerVM.currentTime = playerVM.audioEngine.currentTime playerVM.currentTrack = playerVM.audioEngine.currentTrack playerVM.loadWaveform(for: track) } catch { print("Failed to restore last track: \(error)") } } } else if let first = playlists.first { selectedPlaylist = first hasRestoredState = true } } } // MARK: - Welcome View (no playlist selected) private struct WelcomeView: View { let onNewPlaylist: () -> Void var body: some View { VStack(spacing: 16) { Spacer() Image(systemName: "music.note.house") .font(.system(size: 64)) .foregroundStyle(.tertiary) Text("Welcome to MixBoard") .font(.title2) .foregroundStyle(.secondary) Text("Create a playlist to get started") .foregroundStyle(.tertiary) Button("New Playlist") { onNewPlaylist() } Spacer() } .frame(maxWidth: .infinity, maxHeight: .infinity) } } // MARK: - New Playlist Sheet struct NewPlaylistSheet: View { @Binding var selectedPlaylist: Playlist? @Environment(PlaylistViewModel.self) private var playlistVM @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss @State private var playlistName = "" var body: some View { VStack(spacing: 20) { Text("New Playlist") .font(.headline) TextField("Playlist name", text: $playlistName) .textFieldStyle(.roundedBorder) .frame(width: 300) HStack { Button("Cancel") { dismiss() } .keyboardShortcut(.cancelAction) Button("Create") { guard !playlistName.isEmpty else { return } let pl = playlistVM.createPlaylist(name: playlistName, context: modelContext) selectedPlaylist = pl playlistVM.selectedPlaylist = pl dismiss() } .keyboardShortcut(.defaultAction) .disabled(playlistName.isEmpty) } } .padding(30) } } // MARK: - Mix Target Buttons Bar /// Colored numbered buttons at the top of the detail area for quick-adding to mix slots. private struct MixTargetBar: View { @Environment(PlayerViewModel.self) private var playerVM @Environment(PlaylistViewModel.self) private var playlistVM @EnvironmentObject private var theme: AppTheme @Environment(\.modelContext) private var modelContext @ObservedObject private var shortcutConfig = KeyboardShortcutConfig.shared private let mixActions: [ShortcutAction] = [.addToMix1, .addToMix2, .addToMix3] var body: some View { HStack(spacing: 0) { ForEach(0..<3, id: \.self) { slot in let hasTarget = playlistVM.mixTargets[slot] != nil let color = mixTargetColors[slot] Button { guard let track = playerVM.currentTrack else { playlistVM.showStatus("No track playing") return } _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext) } label: { HStack(spacing: 6) { Text("\(slot + 1)") .font(.system(size: 11, weight: .bold, design: .rounded)) .foregroundStyle(hasTarget ? color : theme.tertiaryText) Text(playlistVM.mixTargetName(slot)) .font(.system(size: 11)) .foregroundStyle(hasTarget ? theme.primaryText : theme.tertiaryText) .lineLimit(1) } .padding(.horizontal, 10) .padding(.vertical, 4) .frame(maxWidth: .infinity) .background(hasTarget ? color.opacity(0.08) : Color.clear) .contentShape(Rectangle()) } .buttonStyle(.plain) .help(hasTarget ? "Add to \(playlistVM.mixTargetName(slot)) (\(shortcutConfig.binding(for: mixActions[slot]).displayString))" : "Mix \(slot + 1) not set — configure in Settings") if slot < 2 { Divider().frame(height: 20) } } } .frame(height: 28) .background(theme.toolbarBackground.opacity(0.5)) .overlay(alignment: .bottom) { Divider() } } }