| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326 |
- import SwiftData
- import SwiftUI
- enum BrowsePanelTab: String, CaseIterable {
- case cloud = "Cloud"
- case queue = "Queue"
- }
- /// 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 isBrowsePanelOpen = false
- @State private var browsePanelTab: BrowsePanelTab = .cloud
- @AppStorage("playbackMode") private var playbackMode: String = "queue"
- @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,
- isBrowsePanelOpen: $isBrowsePanelOpen,
- browsePanelTab: $browsePanelTab
- )
- .navigationSplitViewColumnWidth(min: 180, ideal: 220, max: 300)
- } detail: {
- VStack(spacing: 0) {
- HStack(spacing: 0) {
- // Main content area — playlist is always visible here
- VStack(spacing: 0) {
- MixTargetBar()
- if showInlineNowPlaying, playerVM.currentTrack != nil {
- NowPlayingView(displayMode: .inline)
- } else if let playlist = selectedPlaylist {
- PlaylistView(playlist: playlist, isBrowsePanelOpen: isBrowsePanelOpen)
- } else {
- WelcomeView(onNewPlaylist: { showNewPlaylistSheet = true })
- }
- 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)
- }
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- // Slide-out browse panel
- if isBrowsePanelOpen {
- Divider()
- BrowsePanel(
- browsePanelTab: $browsePanelTab,
- isBrowsePanelOpen: $isBrowsePanelOpen
- )
- .frame(minWidth: 280, idealWidth: 340, maxWidth: 420)
- .transition(.move(edge: .trailing).combined(with: .opacity))
- }
- }
- .animation(.easeOut(duration: 0.2), value: isBrowsePanelOpen)
- Divider()
- PlayerView()
- }
- .background {
- Button("") { isBrowsePanelOpen.toggle() }
- .keyboardShortcut("b", modifiers: .command)
- .hidden()
- }
- }
- .onAppear {
- libraryManager.setModelContext(modelContext)
- playerVM.modelContext = modelContext
- playlistVM.restoreTargetPlaylist(from: playlists)
- restoreLastState()
- }
- .task {
- let descriptor = FetchDescriptor<Track>(predicate: #Predicate<Track> { $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: selectedPlaylist) { _, newPlaylist in
- if let id = newPlaylist?.id {
- AppState.saveLastPlaylist(id: id)
- }
- }
- .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
- }
- .onReceive(NotificationCenter.default.publisher(for: .toggleBrowsePanel)) { _ in
- if isBrowsePanelOpen && browsePanelTab == .cloud {
- isBrowsePanelOpen = false
- } else {
- browsePanelTab = .cloud
- isBrowsePanelOpen = true
- }
- }
- }
- 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()
- }
- }
- }
|