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
92 changes: 86 additions & 6 deletions BetterCapture/Model/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,41 @@ enum ContainerFormat: String, CaseIterable, Identifiable {
var id: String { rawValue }

var fileExtension: String { rawValue }

/// Video codecs supported by this container format
var supportedVideoCodecs: [VideoCodec] {
switch self {
case .mov:
// MOV (QuickTime) supports all codecs including ProRes and HEVC with alpha
return VideoCodec.allCases
case .mp4:
// MP4 (MPEG-4) only supports H.264 and HEVC (without alpha)
return [.h264, .hevc]
}
}

/// Whether this container supports alpha channel video
var supportsAlphaChannel: Bool {
switch self {
case .mov:
return true
case .mp4:
// MP4 does not support alpha channel (HEVC with alpha or ProRes 4444)
return false
}
}

/// Audio codecs supported by this container format
var supportedAudioCodecs: [AudioCodec] {
switch self {
case .mov:
// MOV supports all audio codecs
return AudioCodec.allCases
case .mp4:
// MP4 only supports AAC (not raw PCM)
return [.aac]
}
}
}

/// Audio codec options
Expand Down Expand Up @@ -116,16 +151,25 @@ final class SettingsStore {
VideoCodec(rawValue: videoCodecRaw) ?? .hevc
}
set {
// Ensure the codec is compatible with the current container format
guard containerFormat.supportedVideoCodecs.contains(newValue) else {
// If codec is not compatible, switch to MOV container first
containerFormatRaw = ContainerFormat.mov.rawValue
videoCodecRaw = newValue.rawValue
return
}

videoCodecRaw = newValue.rawValue
// Set alpha channel based on codec capabilities

// Set alpha channel based on codec and container capabilities
if newValue.alwaysHasAlpha {
// ProRes 4444 always has alpha
// ProRes 4444 always has alpha, requires MOV container
captureAlphaChannel = true
} else if !newValue.supportsAlphaChannel {
// H.264 and ProRes 422 never have alpha
} else if !newValue.supportsAlphaChannel || !containerFormat.supportsAlphaChannel {
// H.264, ProRes 422 never have alpha, or container doesn't support it
captureAlphaChannel = false
}
// HEVC can toggle alpha, so leave it as-is
// HEVC can toggle alpha (if container supports it), so leave it as-is

// Disable HDR for codecs that don't support it
if !newValue.supportsHDR {
Expand All @@ -140,17 +184,45 @@ final class SettingsStore {
}
set {
containerFormatRaw = newValue.rawValue

// Ensure current video codec is compatible with new container
if !newValue.supportedVideoCodecs.contains(videoCodec) {
// Switch to a compatible codec (prefer HEVC for quality)
videoCodec = .hevc
}

// Disable alpha channel if container doesn't support it
if !newValue.supportsAlphaChannel {
captureAlphaChannel = false
}

// Ensure current audio codec is compatible with new container
if !newValue.supportedAudioCodecs.contains(audioCodec) {
audioCodec = .aac
}
}
}

var captureAlphaChannel: Bool {
get {
access(keyPath: \.captureAlphaChannel)
// ProRes 4444 always has alpha regardless of stored value
if videoCodec.alwaysHasAlpha {
return true
}
// If codec or container doesn't support alpha, always return false
if !videoCodec.supportsAlphaChannel || !containerFormat.supportsAlphaChannel {
return false
}
return UserDefaults.standard.bool(forKey: "captureAlphaChannel")
}
set {
// Only allow alpha channel if both codec and container support it
let canEnable = videoCodec.supportsAlphaChannel && containerFormat.supportsAlphaChannel
let finalValue = newValue && canEnable

withMutation(keyPath: \.captureAlphaChannel) {
UserDefaults.standard.set(newValue, forKey: "captureAlphaChannel")
UserDefaults.standard.set(finalValue, forKey: "captureAlphaChannel")
}
}
}
Expand Down Expand Up @@ -198,6 +270,14 @@ final class SettingsStore {
AudioCodec(rawValue: audioCodecRaw) ?? .aac
}
set {
// Ensure the audio codec is compatible with the current container format
guard containerFormat.supportedAudioCodecs.contains(newValue) else {
// If codec is not compatible, switch to MOV container first
containerFormatRaw = ContainerFormat.mov.rawValue
audioCodecRaw = newValue.rawValue
return
}

audioCodecRaw = newValue.rawValue
}
}
Expand Down
74 changes: 62 additions & 12 deletions BetterCapture/View/MenuBarSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,32 @@ struct MenuBarToggle: View {
struct MenuBarExpandablePicker<SelectionValue: Hashable & Equatable>: View {
let name: String
@Binding var selection: SelectionValue
let options: [(value: SelectionValue, label: String)]
let options: [(value: SelectionValue, label: String, isDisabled: Bool, disabledMessage: String?)]
@State private var isExpanded = false
@State private var isHovered = false

/// Convenience initializer for simple options without disabled state
init(
name: String,
selection: Binding<SelectionValue>,
options: [(value: SelectionValue, label: String)]
) {
self.name = name
self._selection = selection
self.options = options.map { ($0.value, $0.label, false, nil) }
}

/// Full initializer with disabled state support
init(
name: String,
selection: Binding<SelectionValue>,
optionsWithState: [(value: SelectionValue, label: String, isDisabled: Bool, disabledMessage: String?)]
) {
self.name = name
self._selection = selection
self.options = optionsWithState
}

var body: some View {
VStack(spacing: 0) {
// Header row
Expand Down Expand Up @@ -131,7 +153,9 @@ struct MenuBarExpandablePicker<SelectionValue: Hashable & Equatable>: View {
ForEach(options, id: \.value) { option in
PickerOptionRow(
label: option.label,
isSelected: selection == option.value
isSelected: selection == option.value,
isDisabled: option.isDisabled,
disabledMessage: option.disabledMessage
) {
selection = option.value
withAnimation(.easeInOut(duration: 0.2)) {
Expand All @@ -157,15 +181,24 @@ struct MenuBarExpandablePicker<SelectionValue: Hashable & Equatable>: View {
struct PickerOptionRow: View {
let label: String
let isSelected: Bool
var isDisabled: Bool = false
var disabledMessage: String? = nil
let onSelect: () -> Void
@State private var isHovered = false

var body: some View {
Button(action: onSelect) {
HStack {
Text(label)
.font(.system(size: 13))
.foregroundStyle(.primary)
VStack(alignment: .leading, spacing: 1) {
Text(label)
.font(.system(size: 13))
.foregroundStyle(isDisabled ? .tertiary : .primary)
if isDisabled, let message = disabledMessage {
Text(message)
.font(.system(size: 10))
.foregroundStyle(.tertiary)
}
}
Spacer()
if isSelected {
Image(systemName: "checkmark")
Expand All @@ -178,9 +211,10 @@ struct PickerOptionRow: View {
.contentShape(.rect)
}
.buttonStyle(.plain)
.disabled(isDisabled)
.background(
RoundedRectangle(cornerRadius: 4)
.fill(isHovered ? .gray.opacity(0.1) : .clear)
.fill(isHovered && !isDisabled ? .gray.opacity(0.1) : .clear)
.padding(.horizontal, 4)
)
.onHover { hovering in
Expand Down Expand Up @@ -410,11 +444,19 @@ struct VideoSettingsSection: View {
options: FrameRate.allCases.map { ($0, $0.displayName) }
)

// Video Codec Picker
// Video Codec Picker (shows all codecs, disables incompatible ones)
MenuBarExpandablePicker(
name: "Codec",
selection: $settings.videoCodec,
options: VideoCodec.allCases.map { ($0, $0.rawValue) }
optionsWithState: VideoCodec.allCases.map { codec in
let isSupported = settings.containerFormat.supportedVideoCodecs.contains(codec)
return (
value: codec,
label: codec.rawValue,
isDisabled: !isSupported,
disabledMessage: isSupported ? nil : "Not supported for \(settings.containerFormat.rawValue.uppercased())"
)
}
)

// Container Format Picker
Expand All @@ -424,11 +466,11 @@ struct VideoSettingsSection: View {
options: ContainerFormat.allCases.map { ($0, $0.rawValue.uppercased()) }
)

// Alpha Channel Toggle (always visible, but disabled for non-toggleable codecs)
// Alpha Channel Toggle (disabled if codec doesn't support or container doesn't support)
MenuBarToggle(
name: "Capture Alpha Channel",
isOn: $settings.captureAlphaChannel,
isDisabled: !settings.videoCodec.canToggleAlpha
isDisabled: !settings.videoCodec.canToggleAlpha || !settings.containerFormat.supportsAlphaChannel
)

// HDR Recording Toggle (disabled for codecs that don't support HDR)
Expand Down Expand Up @@ -469,11 +511,19 @@ struct AudioSettingsSection: View {
)
}

// Audio Codec Picker
// Audio Codec Picker (shows all codecs, disables incompatible ones)
MenuBarExpandablePicker(
name: "Audio Codec",
selection: $settings.audioCodec,
options: AudioCodec.allCases.map { ($0, $0.rawValue) }
optionsWithState: AudioCodec.allCases.map { codec in
let isSupported = settings.containerFormat.supportedAudioCodecs.contains(codec)
return (
value: codec,
label: codec.rawValue,
isDisabled: !isSupported,
disabledMessage: isSupported ? nil : "Not supported for \(settings.containerFormat.rawValue.uppercased())"
)
}
)
}
}
Expand Down
22 changes: 18 additions & 4 deletions BetterCapture/View/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,14 @@ struct VideoSettingsView: View {

Picker("Codec", selection: $settings.videoCodec) {
ForEach(VideoCodec.allCases) { codec in
Text(codec.rawValue).tag(codec)
let isSupported = settings.containerFormat.supportedVideoCodecs.contains(codec)
if isSupported {
Text(codec.rawValue).tag(codec)
} else {
Text("\(codec.rawValue) (not supported for \(settings.containerFormat.rawValue.uppercased()))")
.foregroundStyle(.secondary)
.tag(codec)
}
}
}

Expand All @@ -82,7 +89,7 @@ struct VideoSettingsView: View {

Section("Advanced") {
Toggle("Capture Alpha Channel", isOn: $settings.captureAlphaChannel)
.disabled(!settings.videoCodec.canToggleAlpha)
.disabled(!settings.videoCodec.canToggleAlpha || !settings.containerFormat.supportsAlphaChannel)
.help(alphaChannelHelpText)

Toggle("HDR Recording", isOn: $settings.captureHDR)
Expand Down Expand Up @@ -113,10 +120,17 @@ struct AudioSettingsView: View {
Section("Format") {
Picker("Codec", selection: $settings.audioCodec) {
ForEach(AudioCodec.allCases) { codec in
Text(codec.rawValue).tag(codec)
let isSupported = settings.containerFormat.supportedAudioCodecs.contains(codec)
if isSupported {
Text(codec.rawValue).tag(codec)
} else {
Text("\(codec.rawValue) (not supported for \(settings.containerFormat.rawValue.uppercased()))")
.foregroundStyle(.secondary)
.tag(codec)
}
}
}
.help("AAC is compressed, PCM is uncompressed lossless")
.help("AAC is compressed, PCM is uncompressed lossless (MOV only)")
}

Section {
Expand Down
Loading