Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ development artifact builds use `vMAJOR.MINOR.PATCH-dev.N`.

## Unreleased

- Added a collapsible Notation track in the main timeline, collapsed by default and saved per project.
- Changed notation harmony entry so beat slashes are selectable and Cmd+K opens chord input for the selected beat.
- Added notation measure range selection with Shift-click, Cmd+C/Cmd+V measure copy and replace-paste for harmony contents, and Esc to clear the selection.
- Improved notation measure range selection so adjacent selected measures draw as one continuous frame.
Expand Down
35 changes: 28 additions & 7 deletions JammLab/DesignSystem/AppTheme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ enum AppTheme {
static let tempoTrackHeight: CGFloat = 38
static let waveformTrackHeight: CGFloat = 110
static let notationTrackHeight: CGFloat = 124
static let notationTrackCollapsedHeight: CGFloat = 36
static let notationMaximumVisibleMeasureCount = 8
static let notationMeasureMinWidth: CGFloat = 148
static let notationStaffLineSpacing: CGFloat = 8
Expand Down Expand Up @@ -288,8 +289,14 @@ enum AppTheme {
static var zoomableUpperTrackStackHeight: CGFloat {
regionTrackHeight + markerTrackHeight + tempoTrackHeight + waveformTrackHeight
}
static func notationTrackCurrentHeight(isCollapsed: Bool) -> CGFloat {
isCollapsed ? notationTrackCollapsedHeight : notationTrackHeight
}
static func upperTrackStackHeight(isNotationTrackCollapsed: Bool) -> CGFloat {
zoomableUpperTrackStackHeight + notationTrackCurrentHeight(isCollapsed: isNotationTrackCollapsed)
}
static var upperTrackStackHeight: CGFloat {
zoomableUpperTrackStackHeight + notationTrackHeight
upperTrackStackHeight(isNotationTrackCollapsed: false)
}
static func stemTracksHeight(rowCount: Int) -> CGFloat {
let visibleRows = max(defaultVisibleStemRows, rowCount)
Expand All @@ -299,8 +306,10 @@ enum AppTheme {
static var stemTracksHeight: CGFloat {
stemTracksHeight(rowCount: defaultVisibleStemRows)
}
static func tracksMinimumHeight(stemRowCount: Int) -> CGFloat {
upperTrackStackHeight + trackSpacing + stemTracksHeight(rowCount: stemRowCount)
static func tracksMinimumHeight(stemRowCount: Int, isNotationTrackCollapsed: Bool = false) -> CGFloat {
upperTrackStackHeight(isNotationTrackCollapsed: isNotationTrackCollapsed)
+ trackSpacing
+ stemTracksHeight(rowCount: stemRowCount)
}
static var tracksMinimumHeight: CGFloat {
tracksMinimumHeight(stemRowCount: defaultVisibleStemRows)
Expand All @@ -309,14 +318,26 @@ enum AppTheme {
static var trackControlsMinimumHeight: CGFloat {
tracksMinimumHeight
}
static func timelineBlockMinimumHeight(stemRowCount: Int) -> CGFloat {
tracksMinimumHeight(stemRowCount: stemRowCount) + viewportFooterGap + viewportControlBarHeight
static func timelineBlockMinimumHeight(
stemRowCount: Int,
isNotationTrackCollapsed: Bool = false
) -> CGFloat {
tracksMinimumHeight(
stemRowCount: stemRowCount,
isNotationTrackCollapsed: isNotationTrackCollapsed
) + viewportFooterGap + viewportControlBarHeight
}
static var timelineBlockMinimumHeight: CGFloat {
timelineBlockMinimumHeight(stemRowCount: defaultVisibleStemRows)
}
static func minimumContentHeight(stemRowCount: Int) -> CGFloat {
timelineBlockMinimumHeight(stemRowCount: stemRowCount)
static func minimumContentHeight(
stemRowCount: Int,
isNotationTrackCollapsed: Bool = false
) -> CGFloat {
timelineBlockMinimumHeight(
stemRowCount: stemRowCount,
isNotationTrackCollapsed: isNotationTrackCollapsed
)
}
static var minimumContentHeight: CGFloat {
timelineBlockMinimumHeight
Expand Down
8 changes: 7 additions & 1 deletion JammLab/Models/JammLabProject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ struct JammLabProject: Codable {
var timelineVisibleRange: ProjectTimelineVisibleRange?
var stemState: StemProjectState?
var isVideoWindowOpen: Bool?
var isNotationTrackCollapsed: Bool?

init(
formatVersion: Int = 10,
Expand All @@ -66,7 +67,8 @@ struct JammLabProject: Codable {
playbackMarkerTime: TimeInterval? = nil,
timelineVisibleRange: ProjectTimelineVisibleRange? = nil,
stemState: StemProjectState? = nil,
isVideoWindowOpen: Bool? = nil
isVideoWindowOpen: Bool? = nil,
isNotationTrackCollapsed: Bool? = nil
) {
self.formatVersion = formatVersion
self.audioBookmarkData = audioBookmarkData
Expand All @@ -93,6 +95,7 @@ struct JammLabProject: Codable {
self.timelineVisibleRange = timelineVisibleRange
self.stemState = stemState
self.isVideoWindowOpen = isVideoWindowOpen
self.isNotationTrackCollapsed = isNotationTrackCollapsed
}

private enum CodingKeys: String, CodingKey {
Expand Down Expand Up @@ -121,6 +124,7 @@ struct JammLabProject: Codable {
case timelineVisibleRange
case stemState
case isVideoWindowOpen
case isNotationTrackCollapsed
}

init(from decoder: Decoder) throws {
Expand Down Expand Up @@ -150,6 +154,7 @@ struct JammLabProject: Codable {
timelineVisibleRange = try container.decodeIfPresent(ProjectTimelineVisibleRange.self, forKey: .timelineVisibleRange)
stemState = try container.decodeIfPresent(StemProjectState.self, forKey: .stemState)
isVideoWindowOpen = try container.decodeIfPresent(Bool.self, forKey: .isVideoWindowOpen)
isNotationTrackCollapsed = try container.decodeIfPresent(Bool.self, forKey: .isNotationTrackCollapsed)
}

func encode(to encoder: Encoder) throws {
Expand Down Expand Up @@ -179,6 +184,7 @@ struct JammLabProject: Codable {
try container.encodeIfPresent(timelineVisibleRange, forKey: .timelineVisibleRange)
try container.encodeIfPresent(stemState, forKey: .stemState)
try container.encodeIfPresent(isVideoWindowOpen, forKey: .isVideoWindowOpen)
try container.encodeIfPresent(isNotationTrackCollapsed, forKey: .isNotationTrackCollapsed)
}

func resolvedAudioURL() throws -> URL {
Expand Down
1 change: 1 addition & 0 deletions JammLab/Models/ProjectEditableState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,5 @@ struct ProjectPersistedEditableState: Equatable {
var playbackMarkerTime: TimeInterval
var timelineVisibleRange: ClosedRange<TimeInterval>
var isVideoWindowOpen: Bool
var isNotationTrackCollapsed: Bool
}
4 changes: 3 additions & 1 deletion JammLab/Services/ProjectPersistenceCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ struct ProjectDocumentSnapshot {
let timelineVisibleRange: ClosedRange<TimeInterval>
let stemState: StemProjectState?
let isVideoWindowOpen: Bool
let isNotationTrackCollapsed: Bool
}

struct ProjectPersistenceCoordinator {
Expand Down Expand Up @@ -227,7 +228,8 @@ struct ProjectPersistenceCoordinator {
ProjectStateNormalizer.normalizedTimelineVisibleRange(snapshot.timelineVisibleRange, duration: snapshot.duration)
),
stemState: snapshot.stemState,
isVideoWindowOpen: snapshot.importedFile.mediaKind == .video ? snapshot.isVideoWindowOpen : nil
isVideoWindowOpen: snapshot.importedFile.mediaKind == .video ? snapshot.isVideoWindowOpen : nil,
isNotationTrackCollapsed: snapshot.isNotationTrackCollapsed
)
}

Expand Down
6 changes: 5 additions & 1 deletion JammLab/ViewModels/AudioPlayerViewModel+Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ extension AudioPlayerViewModel {
isLooping = false
isClickEnabled = false
isSnapEnabled = false
isNotationTrackCollapsed = true
mainTrackVolume = AppSliderDefaults.mainTrackVolume
clickVolume = AppSliderDefaults.clickVolume
errorMessage = nil
Expand Down Expand Up @@ -184,6 +185,7 @@ extension AudioPlayerViewModel {
timelineVisibleRange = 0...file.duration
userTimelineVisibleRange = timelineVisibleRange
playbackState = .stopped
isNotationTrackCollapsed = true
resetStemState()
isImporting = false
}
Expand Down Expand Up @@ -274,6 +276,7 @@ extension AudioPlayerViewModel {
restorePlaybackMode(restoredPlaybackMode, preservedTime: currentTime)
setPlaybackMarkerExactly(to: restoredPlaybackMarkerTime)
restoreVideoWindowOpenState(file.mediaKind == .video && project.isVideoWindowOpen == true)
isNotationTrackCollapsed = project.isNotationTrackCollapsed ?? true
isImporting = false
clearUndoHistory()
markProjectClean()
Expand Down Expand Up @@ -393,7 +396,8 @@ extension AudioPlayerViewModel {
playbackMarkerTime: playbackMarkerTime,
timelineVisibleRange: userTimelineVisibleRange,
stemState: makeStemProjectState(),
isVideoWindowOpen: isVideoWindowOpen
isVideoWindowOpen: isVideoWindowOpen,
isNotationTrackCollapsed: isNotationTrackCollapsed
)
}

Expand Down
9 changes: 8 additions & 1 deletion JammLab/ViewModels/AudioPlayerViewModel+UndoDirty.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ extension AudioPlayerViewModel {
isSnapEnabled: isSnapEnabled,
playbackMarkerTime: ProjectStateNormalizer.normalizedTimelineTime(playbackMarkerTime, duration: duration),
timelineVisibleRange: ProjectStateNormalizer.normalizedTimelineVisibleRange(userTimelineVisibleRange, duration: duration),
isVideoWindowOpen: importedFile?.mediaKind == .video && isVideoWindowOpen
isVideoWindowOpen: importedFile?.mediaKind == .video && isVideoWindowOpen,
isNotationTrackCollapsed: isNotationTrackCollapsed
)
}

Expand Down Expand Up @@ -121,6 +122,12 @@ extension AudioPlayerViewModel {
}
}

func setNotationTrackCollapsed(_ isCollapsed: Bool) {
guard isNotationTrackCollapsed != isCollapsed else { return }
isNotationTrackCollapsed = isCollapsed
refreshProjectModifiedState()
}

func registerUndoState(_ state: ProjectEditableState, actionName: String) {
guard !isRestoringUndoState, let undoManager else { return }

Expand Down
1 change: 1 addition & 0 deletions JammLab/ViewModels/AudioPlayerViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ final class AudioPlayerViewModel: ObservableObject {
@Published var isClickEnabled = false
@Published var isSnapEnabled = false
@Published var isVideoWindowOpen = false
@Published var isNotationTrackCollapsed = true
@Published var mainTrackVolume: Float = AppSliderDefaults.mainTrackVolume
@Published var clickVolume: Float = AudioPlayerViewModel.restoredClickVolume()
@Published var undoStateRevision = 0
Expand Down
2 changes: 2 additions & 0 deletions JammLab/Views/Components/ControlHelpText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ enum ControlHelpText {
static let timelinePanRight = "Move timeline right"
static let timelineZoomIn = "Zoom in"
static let timelineZoomOut = "Zoom out"
static let expandNotationTrack = "Expand Notation track"
static let collapseNotationTrack = "Collapse Notation track"

static let resetThemeColors = "Reset theme colors to defaults"
static let resetClickDefaults = "Restore the current built-in click sound: 1760/1120 Hz and 36/26 ms."
Expand Down
43 changes: 36 additions & 7 deletions JammLab/Views/Components/TimelineViewportControlBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ struct TimelineViewportControlBar: View {
let onPanRight: () -> Void
let onZoomIn: () -> Void
let onZoomOut: () -> Void
@Environment(\.appColors) private var appColors

var body: some View {
HStack(spacing: AppTheme.Spacing.md) {
Expand All @@ -69,22 +68,51 @@ struct TimelineViewportControlBar: View {
.frame(height: AppTheme.Timeline.viewportControlBarHeight)

HStack(spacing: AppTheme.Spacing.xs) {
viewportButton(systemName: "chevron.left", helpText: ControlHelpText.timelinePanLeft, action: onPanLeft)
viewportButton(systemName: "chevron.right", helpText: ControlHelpText.timelinePanRight, action: onPanRight)
viewportButton(systemName: "plus", helpText: ControlHelpText.timelineZoomIn, action: onZoomIn)
viewportButton(systemName: "minus", helpText: ControlHelpText.timelineZoomOut, action: onZoomOut)
TimelineIconButton(systemName: "chevron.left", helpText: ControlHelpText.timelinePanLeft, action: onPanLeft)
TimelineIconButton(systemName: "chevron.right", helpText: ControlHelpText.timelinePanRight, action: onPanRight)
TimelineIconButton(systemName: "plus", helpText: ControlHelpText.timelineZoomIn, action: onZoomIn)
TimelineIconButton(systemName: "minus", helpText: ControlHelpText.timelineZoomOut, action: onZoomOut)
}
}
.frame(height: AppTheme.Timeline.viewportControlBarHeight)
.disabled(duration <= 0)
.opacity(duration > 0 ? 1 : 0.45)
}
}

private func viewportButton(
struct TimelineIconButton: View {
let systemName: String
let helpText: String
let accessibilityLabel: String?
let accessibilityValue: String?
let action: () -> Void
@Environment(\.appColors) private var appColors

init(
systemName: String,
helpText: String,
accessibilityLabel: String? = nil,
accessibilityValue: String? = nil,
action: @escaping () -> Void
) -> some View {
) {
self.systemName = systemName
self.helpText = helpText
self.accessibilityLabel = accessibilityLabel
self.accessibilityValue = accessibilityValue
self.action = action
}

var body: some View {
if let accessibilityLabel {
baseButton
.accessibilityLabel(accessibilityLabel)
.accessibilityValue(accessibilityValue ?? "")
} else {
baseButton
}
}

private var baseButton: some View {
Button(action: action) {
Image(systemName: systemName)
.font(.system(size: 10, weight: .semibold))
Expand All @@ -99,6 +127,7 @@ struct TimelineViewportControlBar: View {
RoundedRectangle(cornerRadius: AppTheme.Timeline.viewportControlButtonRadius, style: .continuous)
.stroke(appColors.border, lineWidth: AppTheme.Stroke.thin)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.help(helpText)
Expand Down
8 changes: 6 additions & 2 deletions JammLab/Views/MainWorkspacePanels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,10 @@ extension ContentView {
}

var timelineTracksHeight: CGFloat {
AppTheme.Timeline.tracksMinimumHeight(stemRowCount: timelineStemRowCount)
AppTheme.Timeline.tracksMinimumHeight(
stemRowCount: timelineStemRowCount,
isNotationTrackCollapsed: viewModel.isNotationTrackCollapsed
)
}

var timelineMinimumContentHeight: CGFloat {
Expand Down Expand Up @@ -137,6 +140,7 @@ extension ContentView {
selectedRegionID: viewModel.selectedRegionID,
beatGrid: beatGrid,
notationViewport: notationViewportState(availableWidth: notationTrackContentWidth),
isNotationTrackCollapsed: viewModel.isNotationTrackCollapsed,
isLoadingPeakform: viewModel.isBuildingWaveform,
mainTrackVolume: viewModel.mainTrackVolume,
playbackMode: viewModel.playbackMode,
Expand Down Expand Up @@ -184,7 +188,6 @@ extension ContentView {
TimelineViewActions(
locatePlaybackMarker: { viewModel.locatePlaybackMarker(to: $0) },
addNote: { viewModel.addNote(at: $0) },
harmonyInputResolutionChanged: { viewModel.setHarmonyInputResolutionDenominator($0) },
selectHarmony: { viewModel.selectHarmonySymbol(id: $0) },
selectNotationMeasure: { viewModel.selectNotationMeasure($0, extendingSelection: $1) },
selectNotationBeat: { viewModel.selectNotationBeat($0) },
Expand All @@ -207,6 +210,7 @@ extension ContentView {
loopRegionChanged: { viewModel.updateLoopRegion(start: $0, end: $1) },
timelineScroll: { viewModel.handleTimelineScroll(deltaX: $0, deltaY: $1, anchorTime: $2) },
mainTrackVolumeChanged: { viewModel.setMainTrackVolume($0) },
notationTrackCollapsedChanged: { viewModel.setNotationTrackCollapsed($0) },
showNotationWindow: { openWindow(id: AppWindowID.notation) }
)
}
Expand Down
Loading