diff --git a/boringNotch/ContentView.swift b/boringNotch/ContentView.swift index b8e8e235..915ad112 100644 --- a/boringNotch/ContentView.swift +++ b/boringNotch/ContentView.swift @@ -372,10 +372,13 @@ struct ContentView: View { switch coordinator.currentView { case .home: NotchHomeView(albumArtNamespace: albumArtNamespace) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) case .shelf: ShelfView() + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .transition( .scale(scale: 0.8, anchor: .top) .combined(with: .opacity) diff --git a/boringNotch/components/Calendar/BoringCalendar.swift b/boringNotch/components/Calendar/BoringCalendar.swift index 93c67fd1..d6075e72 100644 --- a/boringNotch/components/Calendar/BoringCalendar.swift +++ b/boringNotch/components/Calendar/BoringCalendar.swift @@ -8,6 +8,11 @@ import Defaults import SwiftUI +enum CalendarLayoutStyle { + case compact + case monthAndEvents +} + struct Config: Equatable { // var count: Int = 10 // 3 days past + today + 7 days future var past: Int = 7 @@ -233,46 +238,61 @@ struct CalendarView: View { @EnvironmentObject var vm: BoringViewModel @ObservedObject private var calendarManager = CalendarManager.shared @State private var selectedDate = Date() + var layoutStyle: CalendarLayoutStyle = .compact var body: some View { - VStack(spacing: 0) { - HStack(alignment: .center, spacing: 8) { - VStack(alignment: .leading) { - Text(selectedDate.formatted(.dateTime.month(.abbreviated))) - .font(.title3) - .fontWeight(.semibold) - .foregroundColor(.white) - Text(selectedDate.formatted(.dateTime.year())) - .font(.title3) - .fontWeight(.light) - .foregroundColor(Color(white: 0.65)) - } + Group { + switch layoutStyle { + case .compact: + VStack(spacing: 0) { + HStack(alignment: .center, spacing: 8) { + VStack(alignment: .leading) { + Text(selectedDate.formatted(.dateTime.month(.abbreviated))) + .font(.title3) + .fontWeight(.semibold) + .foregroundColor(.white) + Text(selectedDate.formatted(.dateTime.year())) + .font(.title3) + .fontWeight(.light) + .foregroundColor(Color(white: 0.65)) + } - ZStack(alignment: .top) { - WheelPicker(selectedDate: $selectedDate, config: Config()) - HStack(alignment: .top) { - LinearGradient( - colors: [Color.black, .clear], startPoint: .leading, endPoint: .trailing - ) - .frame(width: 20) - Spacer() - LinearGradient( - colors: [.clear, Color.black], startPoint: .leading, endPoint: .trailing - ) - .frame(width: 20) + ZStack(alignment: .top) { + WheelPicker(selectedDate: $selectedDate, config: Config()) + HStack(alignment: .top) { + LinearGradient( + colors: [Color.black, .clear], startPoint: .leading, endPoint: .trailing + ) + .frame(width: 20) + Spacer() + LinearGradient( + colors: [.clear, Color.black], startPoint: .leading, endPoint: .trailing + ) + .frame(width: 20) + } + } } + .fixedSize(horizontal: false, vertical: true) + + eventsSection } - } - .fixedSize(horizontal: false, vertical: true) + case .monthAndEvents: + GeometryReader { geometry in + let monthWidth: CGFloat = 220 + let columnSpacing: CGFloat = 12 + let eventsWidth = max(0, geometry.size.width - monthWidth - columnSpacing) - let filteredEvents = EventListView.filteredEvents( - events: calendarManager.events - ) - if filteredEvents.isEmpty { - EmptyEventsView(selectedDate: selectedDate) - .frame(maxHeight: .infinity, alignment: .center) - } else { - EventListView(events: calendarManager.events) + HStack(alignment: .top, spacing: columnSpacing) { + FullMonthCalendarView(selectedDate: $selectedDate) + .frame(width: monthWidth) + + eventsSection + .frame(width: eventsWidth, alignment: .topLeading) + .frame(maxHeight: .infinity, alignment: .topLeading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) } } .onChange(of: selectedDate) { @@ -293,6 +313,104 @@ struct CalendarView: View { } } } + + @ViewBuilder + private var eventsSection: some View { + if filteredEvents.isEmpty { + EmptyEventsView(selectedDate: selectedDate) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } else { + EventListView(events: calendarManager.events) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + } + + private var filteredEvents: [EventModel] { + EventListView.filteredEvents(events: calendarManager.events) + } +} + +struct FullMonthCalendarView: View { + @Binding var selectedDate: Date + private let calendar = Calendar.current + private let dayColumns = Array(repeating: GridItem(.flexible(minimum: 22), spacing: 4), count: 7) + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(selectedDate.formatted(.dateTime.month(.abbreviated).year())) + .font(.headline) + .foregroundColor(.white) + + LazyVGrid(columns: dayColumns, spacing: 4) { + ForEach(shortWeekdaySymbols, id: \.self) { symbol in + Text(symbol) + .font(.caption2) + .foregroundColor(Color(white: 0.65)) + .frame(maxWidth: .infinity) + } + + ForEach(Array(monthGridDates.enumerated()), id: \.offset) { _, value in + if let day = value { + dayButton(day) + } else { + Color.clear + .frame(height: 22) + } + } + } + } + } + + private var shortWeekdaySymbols: [String] { + let formatter = DateFormatter() + formatter.locale = .current + let symbols = formatter.veryShortStandaloneWeekdaySymbols ?? formatter.shortStandaloneWeekdaySymbols ?? ["S", "M", "T", "W", "T", "F", "S"] + guard !symbols.isEmpty else { return ["S", "M", "T", "W", "T", "F", "S"] } + + let shift = max(0, min(calendar.firstWeekday - 1, symbols.count - 1)) + let head = Array(symbols[shift...]) + let tail = Array(symbols[.. some View { + let isSelected = calendar.isDate(day, inSameDayAs: selectedDate) + let isToday = calendar.isDateInToday(day) + + return Button { + selectedDate = day + } label: { + Text("\(calendar.component(.day, from: day))") + .font(.caption) + .fontWeight(.medium) + .foregroundColor(isSelected ? .white : Color(white: isToday ? 0.95 : 0.7)) + .frame(maxWidth: .infinity, minHeight: 22) + .background(isSelected ? Color.effectiveAccent : .clear) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(PlainButtonStyle()) + } } struct EmptyEventsView: View { @@ -381,6 +499,7 @@ struct EventListView: View { .scrollIndicators(.never) .scrollContentBackground(.hidden) .background(Color.clear) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .onAppear { scrollToRelevantEvent(proxy: proxy) } @@ -388,7 +507,7 @@ struct EventListView: View { scrollToRelevantEvent(proxy: proxy) } } - Spacer(minLength: 0) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private func eventRow(_ event: EventModel) -> some View { @@ -443,6 +562,7 @@ struct EventListView: View { ) } .padding(.vertical, 4) + .frame(maxWidth: .infinity, alignment: .leading) ) } else { return AnyView( @@ -487,6 +607,7 @@ struct EventListView: View { .opacity( event.eventStatus == .ended && Calendar.current.isDateInToday(event.start) ? 0.6 : 1.0) + .frame(maxWidth: .infinity, alignment: .leading) ) } } diff --git a/boringNotch/components/Notch/NotchHomeView.swift b/boringNotch/components/Notch/NotchHomeView.swift index 7bbfaec8..ae123f46 100644 --- a/boringNotch/components/Notch/NotchHomeView.swift +++ b/boringNotch/components/Notch/NotchHomeView.swift @@ -414,6 +414,8 @@ struct NotchHomeView: View { @ObservedObject var webcamManager = WebcamManager.shared @ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var coordinator = BoringViewCoordinator.shared + @ObservedObject var musicManager = MusicManager.shared + @Default(.musicPlayerVisibilityMode) private var musicPlayerVisibilityMode let albumArtNamespace: Namespace.ID var body: some View { @@ -424,34 +426,78 @@ struct NotchHomeView: View { } // simplified: use a straightforward opacity transition .transition(.opacity) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } private var shouldShowCamera: Bool { Defaults[.showMirror] && webcamManager.cameraAvailable && vm.isCameraExpanded } + private var shouldShowMusicPlayer: Bool { + switch musicPlayerVisibilityMode { + case .always: + return true + case .onlyWhenPlaying: + return musicManager.isPlaying + case .never: + return false + } + } + + private var shouldUseMonthAndEventsCalendar: Bool { + !shouldShowMusicPlayer && Defaults[.showCalendar] && !shouldShowCamera + } + + private var shouldUseExpandedHomeLayout: Bool { + !shouldShowMusicPlayer + } + + private var compactCameraWidth: CGFloat { + max(150, min(220, vm.notchSize.height - 24)) + } + private var mainContent: some View { - HStack(alignment: .top, spacing: (shouldShowCamera && Defaults[.showCalendar]) ? 10 : 15) { - MusicPlayerView(albumArtNamespace: albumArtNamespace) - - if Defaults[.showCalendar] { - CalendarView() - .frame(width: shouldShowCamera ? 170 : 215) - .onHover { isHovering in - vm.isHoveringCalendar = isHovering - } - .environmentObject(vm) - .transition(.opacity) - } + GeometryReader { geometry in + let spacing: CGFloat = (shouldShowCamera && Defaults[.showCalendar]) ? 10 : 15 + let shouldExpandCalendarForCamera = !shouldShowMusicPlayer && Defaults[.showCalendar] && shouldShowCamera + let calendarWidth: CGFloat? = { + guard Defaults[.showCalendar] else { return nil } + if shouldUseMonthAndEventsCalendar { + return geometry.size.width + } + if shouldExpandCalendarForCamera { + return max(220, geometry.size.width - compactCameraWidth - spacing) + } + return (shouldShowMusicPlayer && shouldShowCamera) ? 170 : 215 + }() + + HStack(alignment: .top, spacing: spacing) { + if shouldShowMusicPlayer { + MusicPlayerView(albumArtNamespace: albumArtNamespace) + } - if shouldShowCamera { - CameraPreviewView(webcamManager: webcamManager) - .scaledToFit() - .opacity(vm.notchState == .closed ? 0 : 1) - .blur(radius: vm.notchState == .closed ? 20 : 0) - .animation(.interactiveSpring(response: 0.32, dampingFraction: 0.76, blendDuration: 0), value: shouldShowCamera) + if Defaults[.showCalendar] { + CalendarView(layoutStyle: shouldUseMonthAndEventsCalendar ? .monthAndEvents : .compact) + .frame(width: calendarWidth, alignment: .leading) + .onHover { isHovering in + vm.isHoveringCalendar = isHovering + } + .environmentObject(vm) + .transition(.opacity) + } + + if shouldShowCamera { + CameraPreviewView(webcamManager: webcamManager) + .scaledToFit() + .frame(width: shouldShowMusicPlayer ? nil : compactCameraWidth, alignment: .topLeading) + .opacity(vm.notchState == .closed ? 0 : 1) + .blur(radius: vm.notchState == .closed ? 20 : 0) + .animation(.interactiveSpring(response: 0.32, dampingFraction: 0.76, blendDuration: 0), value: shouldShowCamera) + } } + .frame(maxWidth: shouldUseExpandedHomeLayout ? .infinity : nil, maxHeight: .infinity, alignment: .leading) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .transition(.asymmetric(insertion: .opacity.combined(with: .move(edge: .top)), removal: .opacity)) .blur(radius: vm.notchState == .closed ? 30 : 0) } diff --git a/boringNotch/components/Settings/Views/MediaSettingsView.swift b/boringNotch/components/Settings/Views/MediaSettingsView.swift index 06263f12..60d44fd1 100644 --- a/boringNotch/components/Settings/Views/MediaSettingsView.swift +++ b/boringNotch/components/Settings/Views/MediaSettingsView.swift @@ -17,6 +17,11 @@ struct Media: View { @Default(.sneakPeekStyles) var sneakPeekStyles @Default(.enableLyrics) var enableLyrics + @Default(.musicPlayerVisibilityMode) private var musicPlayerVisibilityMode + + private var areMusicControlsDisabled: Bool { + musicPlayerVisibilityMode == .never + } var body: some View { Form { @@ -95,17 +100,26 @@ struct Media: View { } Section { + Picker("Open-notch music player", selection: $musicPlayerVisibilityMode) { + ForEach(MusicPlayerVisibilityMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } MusicSlotConfigurationView() + .disabled(areMusicControlsDisabled) + .opacity(areMusicControlsDisabled ? 0.5 : 1) Defaults.Toggle(key: .enableLyrics) { HStack { Text("Show lyrics below artist name") customBadge(text: "Beta") } } + .disabled(areMusicControlsDisabled) + .opacity(areMusicControlsDisabled ? 0.5 : 1) } header: { Text("Media controls") } footer: { - Text("Customize which controls appear in the music player. Volume expands when active.") + Text("Choose when the open-notch music player appears. Volume expands when active.") .font(.caption) .foregroundStyle(.secondary) } diff --git a/boringNotch/models/Constants.swift b/boringNotch/models/Constants.swift index f7e7ba4d..b9f8fd26 100644 --- a/boringNotch/models/Constants.swift +++ b/boringNotch/models/Constants.swift @@ -30,6 +30,14 @@ enum HideNotchOption: String, Defaults.Serializable { case never } +enum MusicPlayerVisibilityMode: String, CaseIterable, Identifiable, Defaults.Serializable { + case always = "Always" + case onlyWhenPlaying = "Only when music is playing" + case never = "Never" + + var id: String { self.rawValue } +} + // Define notification names at file scope extension Notification.Name { // MARK: - Media @@ -138,6 +146,10 @@ extension Defaults.Keys { static let waitInterval = Key("waitInterval", default: 3) static let showShuffleAndRepeat = Key("showShuffleAndRepeat", default: false) static let enableLyrics = Key("enableLyrics", default: false) + static let musicPlayerVisibilityMode = Key( + "musicPlayerVisibilityMode", + default: .always + ) static let musicControlSlots = Key<[MusicControlButton]>( "musicControlSlots", default: MusicControlButton.defaultLayout