import SwiftData import SwiftUI /// Main content view — Sidebar (library + playlists) | Central content | Player. struct ContentView: View { @Environment(PlayerViewModel.self) private var playerVM @Environment(PlaylistViewModel.self) private var playlistVM @EnvironmentObject private var libraryManager: LibraryManager @EnvironmentObject private var theme: AppTheme @Environment(\.modelContext) private var modelContext @Environment(\.openWindow) private var openWindow @State private var sidebarSelection: SidebarSection? @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 @AppStorage("playbackMode") private var playbackMode: String = "queue" @Query(sort: \Playlist.dateModified, order: .reverse) private var playlists: [Playlist] @Query private var allTracks: [Track] /// The currently selected playlist, if any. private var selectedPlaylist: Playlist? { if case .playlist(let p) = sidebarSelection { return p } return nil } var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { SidebarView( selection: $sidebarSelection, showNewPlaylistSheet: $showNewPlaylistSheet ) .navigationSplitViewColumnWidth(min: 180, ideal: 220, max: 300) } detail: { VStack(spacing: 0) { MixTargetBar() // ── Central content area ── Group { if showInlineNowPlaying, playerVM.currentTrack != nil { NowPlayingView(displayMode: .inline) } else { switch sidebarSelection { case .library(let dest): CloudBrowserView(initialDestination: dest) .id(dest) case .queue: QueueView() case .playlist(let playlist): PlaylistView(playlist: playlist) case nil: WelcomeView(onNewPlaylist: { showNewPlaylistSheet = true }) } } } .frame(maxWidth: .infinity, maxHeight: .infinity) 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() if theme.isDJBoard { DJPlayerView() } else { PlayerView() } } } .onAppear { libraryManager.setModelContext(modelContext) playerVM.modelContext = modelContext playlistVM.restoreTargetPlaylist(from: playlists) restoreLastState() } .task { 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 if !hasRestoredState { restoreLastState() } } .onChange(of: sidebarSelection) { _, newSelection in if case .playlist(let pl) = newSelection { AppState.saveLastPlaylist(id: pl.id) } } .sheet(isPresented: $showNewPlaylistSheet) { NewPlaylistSheet(sidebarSelection: $sidebarSelection) } .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 } .onReceive(NotificationCenter.default.publisher(for: .toggleBrowsePanel)) { _ in if case .library = sidebarSelection { // Already in library → go back to nil (or could restore last playlist) sidebarSelection = nil } else { sidebarSelection = .library(.browse) } } } 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 }) { sidebarSelection = .playlist(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 { sidebarSelection = .playlist(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 sidebarSelection: SidebarSection? @Environment(PlaylistViewModel.self) private var playlistVM @Environment(\.modelContext) private var modelContext @Environment(\.dismiss) private var dismiss @State private var playlistName = "" @FocusState private var isNameFieldFocused: Bool var body: some View { VStack(spacing: 20) { Text("New Playlist") .font(.headline) TextField("Playlist name", text: $playlistName) .textFieldStyle(.roundedBorder) .frame(width: 300) .accessibilityIdentifier("newPlaylistNameField") .focused($isNameFieldFocused) HStack { Button("Cancel") { dismiss() } .keyboardShortcut(.cancelAction) Button("Create") { guard !playlistName.isEmpty else { return } let pl = playlistVM.createPlaylist(name: playlistName, context: modelContext) sidebarSelection = .playlist(pl) playlistVM.selectedPlaylist = pl dismiss() } .keyboardShortcut(.defaultAction) .disabled(playlistName.isEmpty) } } .padding(30) .onAppear { // Ensure the text field gets focus when the sheet opens DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { isNameFieldFocused = true } } } } // 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() } } }