/** * @file * Implementation of class HBPreferencesController. */ #import "HBPreferencesController.h" @import HandBrakeKit.HBUtilities; NSString * const HBShowOpenPanelAtLaunch = @"HBShowOpenPanelAtLaunch"; NSString * const HBShowSummaryPreview = @"HBShowSummaryPreview"; NSString * const HBKeepPresetEdits = @"HBKeepPresetEdits"; NSString * const HBUseSourceFolderDestination = @"HBUseSourceFolderDestination"; NSString * const HBRecursiveScan = @"HBRecursiveScan"; NSString * const HBExcludedFileExtensions = @"HBExcludedFileExtensions"; NSString * const HBLastDestinationDirectoryURL = @"HBLastDestinationDirectoryURL"; NSString * const HBLastDestinationDirectoryBookmark = @"HBLastDestinationDirectoryBookmark"; NSString * const HBLastSourceDirectoryURL = @"HBLastSourceDirectoryURL"; NSString * const HBDefaultAutoNaming = @"DefaultAutoNaming"; NSString * const HBAutoNamingFormat = @"HBAutoNamingFormat"; NSString * const HBAutoNamingRemoveUnderscore = @"HBAutoNamingRemoveUnderscore"; NSString * const HBAutoNamingRemovePunctuation = @"HBAutoNamingRemovePunctuation"; NSString * const HBAutoNamingTitleCase = @"HBAutoNamingTitleCase"; NSString * const HBAutoNamingISODateFormat = @"HBAutoNamingISODateFormat"; NSString * const HBDefaultMpegExtension = @"DefaultMpegExtension"; NSString * const HBCqSliderFractional = @"HBx264CqSliderFractional"; NSString * const HBUseHardwareDecoder = @"HBUseHardwareDecoder"; NSString * const HBAlwaysUseHardwareDecoder = @"HBAlwaysUseHardwareDecoder"; NSString * const HBPreviewsNumber = @"PreviewsNumber"; NSString * const HBMinTitleScan = @"HBMinTitleScan"; NSString * const HBMinTitleScanSeconds = @"HBMinTitleScanSeconds"; NSString * const HBMaxTitleScan = @"HBMaxTitleScan"; NSString * const HBMaxTitleScanSeconds = @"HBMaxTitleScanSeconds"; NSString * const HBKeepDuplicateTitles = @"HBKeepDuplicateTitles"; NSString * const HBUseDvdNav = @"UseDvdNav"; NSString * const HBLoggingLevel = @"LoggingLevel"; NSString * const HBEncodeLogLocation = @"EncodeLogLocation"; NSString * const HBClearOldLogs = @"HBClearOldLogs"; NSString * const HBQueuePauseIfLowSpace = @"HBQueuePauseIfLowSpace"; NSString * const HBQueueMinFreeSpace = @"HBQueueMinFreeSpace"; NSString * const HBQueuePauseOnBatteryPower = @"HBQueuePauseOnBatteryPower"; NSString * const HBQueueAutoClearCompletedItems = @"HBQueueAutoClearCompletedItems"; NSString * const HBQueueAutoClearCompletedItemsAtLaunch = @"HBQueueAutoClearCompletedItemsAtLaunch"; NSString * const HBQueueWorkerCounts = @"HBQueueWorkerCounts"; NSString * const HBResetWhenDoneOnLaunch = @"HBResetWhenDoneOnLaunch"; NSString * const HBQueueDoneAction = @"HBQueueDoneAction"; NSString * const HBQueueNotificationWhenDone = @"HBQueueNotificationWhenDone"; NSString * const HBQueueNotificationWhenJobDone = @"HBQueueNotificationWhenJobDone"; NSString * const HBQueueNotificationPlaySound = @"HBQueueNotificationPlaySound"; NSString * const HBSendToAppEnabled = @"HBSendToAppEnabled"; NSString * const HBSendToApp = @"HBSendToApp"; #define TOOLBAR_GENERAL @"TOOLBAR_GENERAL" #define TOOLBAR_OUTPUT_NAME @"TOOLBAR_OUTPUT_NAME" #define TOOLBAR_QUEUE @"TOOLBAR_QUEUE" #define TOOLBAR_ADVANCED @"TOOLBAR_ADVANCED" // KVO Context static void *HBPreferencesControllerContext = &HBPreferencesControllerContext; @protocol HBFileExtensionDelegate - (void)extensionDidChange; @end @interface HBFileExtension : NSObject @property (nonatomic) NSString *extension; @property (nonatomic, readonly, weak) id delegate; - (instancetype)init NS_UNAVAILABLE; - (instancetype)initWithDelegate:(id)delegate; - (instancetype)initWithExtension:(NSString *)extension delegate:(id)delegate; @end @implementation HBFileExtension - (instancetype)initWithDelegate:(id)delegate { self = [super self]; if (self) { _extension = NSLocalizedString(@"extension", "Default name for a newly added excluded file extension"); _delegate = delegate; } return self; } - (instancetype)initWithExtension:(NSString *)extension delegate:(id)delegate { self = [super self]; if (self) { _extension = extension; _delegate = delegate; } return self; } - (void)setExtension:(NSString *)extension { if ([extension isEqualToString:_extension] == NO) { _extension = extension; [self.delegate extensionDidChange]; } } - (BOOL)validateExtension:(id *)ioValue error:(NSError * __autoreleasing *)outError { // Return an error is the name is empty if (![*ioValue length]) { if (outError) { *outError = [[NSError alloc] initWithDomain:@"HBErrorDomain" code:0 userInfo:@{NSLocalizedDescriptionKey:@"The file extension cannot be empty.", NSLocalizedRecoverySuggestionErrorKey:@"Please enter a file extension."}]; } return NO; } return YES; } @end /** * This class controls the preferences window of HandBrake. Default values for * all preferences and user defaults are specified in class method * @c registerUserDefaults. The preferences window is loaded from * Preferences.nib file when HBPreferencesController is initialized. * * All preferences are bound to user defaults in Interface Builder, therefore * no getter/setter code is needed in this file (unless more complicated * preference settings are added that cannot be handled with Cocoa bindings). */ @interface HBPreferencesController () @property (nonatomic, weak) IBOutlet NSView *generalView; @property (nonatomic, weak) IBOutlet NSView *fileNameView; @property (nonatomic, weak) IBOutlet NSView *queueView; @property (nonatomic, weak) IBOutlet NSView *advancedView; @property (nonatomic, weak) IBOutlet NSTokenField *formatTokenField; @property (nonatomic, weak) IBOutlet NSTokenField *builtInTokenField; @property (nonatomic, readonly, strong) NSArray *buildInFormatTokens; @property (nonatomic, strong) NSArray *matches; @property (nonatomic, weak) IBOutlet NSSegmentedControl *excludedExtensionsControl; @property (nonatomic, weak) IBOutlet NSArrayController *excludedExtensionsController; @property (nonatomic, weak) IBOutlet NSTableView *excludedExtensionsTableView; @property (nonatomic) NSMutableArray *excludedExtensions; @property (nonatomic) BOOL hardwareDecodersCheckboxesEnabled; @property (class, nonatomic, getter=areHardwareDecoderSupported) BOOL hardwareDecoderSupported; @end @implementation HBPreferencesController static BOOL _hardwareDecoderSupported = NO; + (BOOL)areHardwareDecoderSupported { return _hardwareDecoderSupported; } + (void)setHardwareDecoderSupported:(BOOL)hardwareDecoderSupported { _hardwareDecoderSupported = hardwareDecoderSupported; } /// Registers default values to user defaults. This is called immediately /// when HandBrake starts, from [HBAppDelegate init]. + (void)registerUserDefaults { NSUserDefaults *defaults = NSUserDefaults.standardUserDefaults; NSData *moviesData = [NSKeyedArchiver archivedDataWithRootObject:HBUtilities.defaultDestinationFolderURL requiringSecureCoding:YES error:NULL]; if (@available(macOS 13, *)) { HBPreferencesController.hardwareDecoderSupported = YES; } else { HBPreferencesController.hardwareDecoderSupported = NO; } [defaults registerDefaults:@{ HBShowOpenPanelAtLaunch: @YES, HBShowSummaryPreview: @YES, HBKeepPresetEdits: @YES, HBDefaultMpegExtension: @".mp4", HBUseDvdNav: @YES, HBUseHardwareDecoder: @NO, HBAlwaysUseHardwareDecoder: @NO, HBRecursiveScan: @NO, HBExcludedFileExtensions: @[@"jpg", @"png", @"srt", @"ssa", @"ass", @"txt"], HBLastDestinationDirectoryURL: moviesData, HBLastSourceDirectoryURL: moviesData, HBUseSourceFolderDestination: @NO, HBDefaultAutoNaming: @NO, HBAutoNamingFormat: @[@"{Source}", @" ", @"{Title}"], HBAutoNamingISODateFormat: @NO, HBResetWhenDoneOnLaunch: @NO, HBLoggingLevel: @1, HBClearOldLogs: @YES, HBEncodeLogLocation: @NO, HBPreviewsNumber: @10, HBMinTitleScan: @YES, HBMinTitleScanSeconds: @10, HBMaxTitleScan: @NO, HBMaxTitleScanSeconds: @3600, HBCqSliderFractional: @2, HBQueuePauseIfLowSpace: @YES, HBQueueMinFreeSpace: @"2", HBQueuePauseOnBatteryPower: @NO, HBQueueAutoClearCompletedItems: @NO, HBQueueAutoClearCompletedItemsAtLaunch: @YES, HBQueueWorkerCounts: @1, HBQueueDoneAction: @0, HBQueueNotificationWhenDone: @YES, HBQueueNotificationWhenJobDone: @NO, HBQueueNotificationPlaySound: @NO }]; if (HBPreferencesController.areHardwareDecoderSupported == NO && [defaults boolForKey:HBUseHardwareDecoder] == YES) { [defaults setBool:NO forKey:HBUseHardwareDecoder]; } // Overwrite the update check interval because previous versions // could be set to a daily check. NSUInteger week = 60 * 60 * 24 * 7; [defaults setObject:@(week) forKey:@"SUScheduledCheckInterval"]; } - (instancetype)init { self = [super initWithWindowNibName:@"Preferences"]; return self; } - (void)showWindow:(id)sender { if (!self.window.isVisible) { [self.window center]; } [super showWindow:sender]; } - (void)windowDidLoad { if (@available (macOS 11, *)) { self.window.toolbarStyle = NSWindowToolbarStylePreference; } NSToolbar *toolbar = [[NSToolbar alloc] initWithIdentifier:@"Preferences Toolbar"]; toolbar.delegate = self; toolbar.allowsUserCustomization = NO; toolbar.displayMode = NSToolbarDisplayModeIconAndLabel; toolbar.sizeMode = NSToolbarSizeModeRegular; self.window.toolbar = toolbar; self.hardwareDecodersCheckboxesEnabled = HBPreferencesController.areHardwareDecoderSupported; // Format token field initialization [self.formatTokenField setTokenizingCharacterSet:[NSCharacterSet characterSetWithCharactersInString:@"%%"]]; [self.formatTokenField setCompletionDelay:0.2]; _buildInFormatTokens = @[@"{Source}", @"{Title}", @"{Angle}", @"{Chapters}", @"{Preset}", @"{Width}", @"{Height}", @"{Codec}", @"{Encoder}", @"{Bit-Depth}", @"{Quality/Bitrate}", @"{Quality-Type}", @"{Date}", @"{Time}", @"{Creation-Date}", @"{Creation-Time}", @"{Modification-Date}", @"{Modification-Time}"]; [self.builtInTokenField setTokenizingCharacterSet:[NSCharacterSet characterSetWithCharactersInString:@"%%"]]; [self.builtInTokenField setStringValue:[self.buildInFormatTokens componentsJoinedByString:@"%%"]]; // Excluded file extension initialization self.excludedExtensions = [[NSMutableArray alloc] init]; for (NSString *extension in [NSUserDefaults.standardUserDefaults arrayForKey:HBExcludedFileExtensions]) { if ([extension isKindOfClass:[NSString class]]) { [self.excludedExtensions addObject:[[HBFileExtension alloc] initWithExtension:extension delegate:self]]; } } self.excludedExtensionsController.content = self.excludedExtensions; [self.excludedExtensionsController addObserver:self forKeyPath:@"selectedObjects" options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionInitial context:HBPreferencesControllerContext]; [self.excludedExtensionsController addObserver:self forKeyPath:@"arrangedObjects" options:NSKeyValueObservingOptionNew context:HBPreferencesControllerContext]; // Set default tab toolbar.selectedItemIdentifier = TOOLBAR_GENERAL; [self setPrefView:nil]; } - (nullable NSToolbarItem *)toolbar:(NSToolbar *)toolbar itemForItemIdentifier:(NSToolbarItemIdentifier)itemIdentifier willBeInsertedIntoToolbar:(BOOL)flag { NSImage *image = nil; if ([itemIdentifier isEqualToString:TOOLBAR_GENERAL]) { if (@available (macOS 11, *)) { image = [NSImage imageWithSystemSymbolName:@"gearshape" accessibilityDescription:@"General preferences"]; } else { image = [NSImage imageNamed:NSImageNamePreferencesGeneral]; } return [self toolbarItemWithIdentifier:itemIdentifier label:NSLocalizedString(@"General", @"Preferences General Toolbar Item") image:image]; } else if ([itemIdentifier isEqualToString:TOOLBAR_OUTPUT_NAME]) { if (@available (macOS 11, *)) { image = [NSImage imageWithSystemSymbolName:@"character.textbox" accessibilityDescription:@"File output preferences"]; } else { image = [NSImage imageNamed:NSImageNameAdvanced]; } return [self toolbarItemWithIdentifier:itemIdentifier label:NSLocalizedString(@"File name", @"Preferences Output File Name Toolbar Item") image:image]; } else if ([itemIdentifier isEqualToString:TOOLBAR_QUEUE]) { if (@available (macOS 13, *)) { image = [NSImage imageWithSystemSymbolName:@"photo.stack" accessibilityDescription:@"Queue preferences"]; } else if (@available (macOS 11, *)) { image = [NSImage imageNamed:@"photo.stack"]; } else { image = [NSImage imageNamed:@"showqueue"]; } return [self toolbarItemWithIdentifier:itemIdentifier label:NSLocalizedString(@"Queue", @"Preferences Queue Toolbar Item") image:image]; } else if ([itemIdentifier isEqualToString:TOOLBAR_ADVANCED]) { if (@available (macOS 11, *)) { image = [NSImage imageWithSystemSymbolName:@"gearshape.2" accessibilityDescription:@"General preferences"]; } else { image = [NSImage imageNamed:NSImageNameAdvanced]; } return [self toolbarItemWithIdentifier:itemIdentifier label:NSLocalizedString(@"Advanced", @"Preferences Advanced Toolbar Item") image:image]; } return nil; } - (NSArray *)toolbarSelectableItemIdentifiers:(NSToolbar *)toolbar { return [self toolbarDefaultItemIdentifiers:toolbar]; } - (NSArray *)toolbarDefaultItemIdentifiers:(NSToolbar *)toolbar { return [self toolbarAllowedItemIdentifiers:toolbar]; } - (NSArray *)toolbarAllowedItemIdentifiers:(NSToolbar *)toolbar { return @[TOOLBAR_GENERAL, TOOLBAR_OUTPUT_NAME, TOOLBAR_QUEUE, TOOLBAR_ADVANCED]; } - (IBAction)browseSendToApp:(id)sender { NSUserDefaults *ud = NSUserDefaults.standardUserDefaults; NSOpenPanel *panel = [NSOpenPanel openPanel]; [panel setAllowsMultipleSelection:NO]; [panel setCanChooseFiles:YES]; [panel setCanChooseDirectories:NO]; [panel setAllowedFileTypes:@[@"app"]]; [panel setMessage:NSLocalizedString(@"Select the desired external application", @"Preferences -> send to app destination open panel")]; NSURL *sendToAppDirectory; if ([ud stringForKey:@"HBLastSendToAppDirectory"]) { sendToAppDirectory = [ud URLForKey:@"HBLastSendToAppDirectory"]; } else { sendToAppDirectory = [NSURL fileURLWithPath:@"/Applications" isDirectory:YES]; } [panel setDirectoryURL:sendToAppDirectory]; [panel beginSheetModalForWindow:[self window] completionHandler:^(NSInteger result) { if (result == NSModalResponseOK) { NSURL *sendToAppURL = panel.URL; NSURL *sendToAppDirectoryURL = sendToAppURL.URLByDeletingLastPathComponent; [ud setURL:sendToAppDirectoryURL forKey:@"HBLastSendToAppDirectory"]; // We set the name of the app to send to in the display field NSString *sendToAppName = sendToAppURL.lastPathComponent.stringByDeletingPathExtension; [ud setObject:sendToAppName forKey:HBSendToApp]; [sendToAppURL stopAccessingSecurityScopedResource]; } }]; } #pragma mark - Format Token Field Delegate - (NSString *)tokenField:(NSTokenField *)tokenField displayStringForRepresentedObject:(id)representedObject { if ([representedObject rangeOfString: @"{"].location == 0 && [representedObject length] > 1) { return [self localizedStringForToken:representedObject]; } return representedObject; } - (NSString *)localizedStringForToken:(NSString *)tokenString { if ([tokenString isEqualToString:@"{Source}"]) { return NSLocalizedString(@"Source", "Preferences -> Output Name Token"); } else if ([tokenString isEqualToString:@"{Title}"]) { return NSLocalizedString(@"Title", "Preferences -> Output Name Token"); } else if ([tokenString isEqualToString:@"{Angle}"]) { return NSLocalizedString(@"Angle", "Preferences -> Output Name Token"); } else if ([tokenString isEqualToString:@"{Chapters}"]) { return NSLocalizedString(@"Chapters", "Preferences -> Output Name Token"); } else if ([tokenString isEqualToString:@"{Preset}"]) { return NSLocalizedString(@"Preset", "Preferences -> Output Name Token"); } else if ([tokenString isEqualToString:@"{Width}"]) { return NSLocalizedString(@"Width", "Preferences -> Output Name Token"); } else if ([tokenString isEqualToString:@"{Height}"]) { return NSLocalizedString(@"Height", "Preferences -> Output Name Token"); } else if ([tokenString isEqualToString:@"{Codec}"]) { return NSLocalizedString(@"Codec", "Preferences -> Output Name Token"); } else if ([tokenString isEqualToString:@"{Encoder}"]) { return NSLocalizedString(@"Encoder", "Preferences -> Output Name Token"); } else if ([tokenString isEqualToString:@"{Bit-Depth}"]) { return NSLocalizedString(@"Bit Depth", "Preferences -> Output Name Token"); } else if ([tokenString isEqualToString:@"{Quality/Bitrate}"]) { return NSLocalizedString(@"Quality/Bitrate", "Preferences -> Output Name Token"); } else if ([tokenString isEqualToString:@"{Quality-Type}"]) { return NSLocalizedString(@"Quality Type", "Preferences -> Output Name Token"); } else if ([tokenString isEqualToString:@"{Date}"]) { return NSLocalizedString(@"Date", "Preferences -> Output Name Token"); } else if ([tokenString isEqualToString:@"{Time}"]) { return NSLocalizedString(@"Time", "Preferences -> Output Name Token"); } else if ([tokenString isEqualToString:@"{Creation-Date}"]) { return NSLocalizedString(@"Creation-Date", "Preferences -> Output Name Token"); } else if ([tokenString isEqualToString:@"{Creation-Time}"]) { return NSLocalizedString(@"Creation-Time", "Preferences -> Output Name Token"); } else if ([tokenString isEqualToString:@"{Modification-Date}"]) { return NSLocalizedString(@"Modification-Date", "Preferences -> Output Name Token"); } else if ([tokenString isEqualToString:@"{Modification-Time}"]) { return NSLocalizedString(@"Modification-Time", "Preferences -> Output Name Token"); } return tokenString; } - (NSTokenStyle)tokenField:(NSTokenField *)tokenField styleForRepresentedObject:(id)representedObject { if ([representedObject rangeOfString: @"{"].location == 0) { return NSTokenStyleRounded; } else { return NSTokenStyleNone; } } - (id)tokenField:(NSTokenField *)tokenField representedObjectForEditingString:(NSString *)editingString { return editingString; } - (NSArray *)tokenField:(NSTokenField *)tokenField completionsForSubstring:(NSString *)substring indexOfToken:(NSInteger)tokenIndex indexOfSelectedItem:(NSInteger *)selectedIndex { self.matches = [self.buildInFormatTokens filteredArrayUsingPredicate: [NSPredicate predicateWithFormat:@"SELF beginswith[cd] %@", substring]]; return self.matches; } - (NSString *)tokenField:(NSTokenField *)tokenField editingStringForRepresentedObject:(id)representedObject { if ([representedObject rangeOfString: @"{"].location == 0) { return [NSString stringWithFormat:@"%%%@%%", representedObject]; } else { return representedObject; } } - (NSArray *)tokenField:(NSTokenField *)tokenField shouldAddObjects:(NSArray *)tokens atIndex:(NSUInteger)index { return tokens; } - (BOOL)tokenField:(NSTokenField *)tokenField writeRepresentedObjects:(NSArray *)objects toPasteboard:(NSPasteboard *)pboard { NSString *format = [objects componentsJoinedByString:@"%%"]; [pboard setString:format forType:NSPasteboardTypeString]; return YES; } #pragma mark - Excluded file extensions - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { if (context == HBPreferencesControllerContext) { if ([keyPath isEqualToString:@"selectedObjects"]) { BOOL selected = self.excludedExtensionsController.selectedObjects.count > 0; [self.excludedExtensionsControl setEnabled:selected forSegment:1]; } else if ([keyPath isEqualToString:@"arrangedObjects"]) { [self extensionDidChange]; } } else { [super observeValueForKeyPath:keyPath ofObject:object change:change context:context]; } } - (void)extensionDidChange { NSMutableArray *extensions = [NSMutableArray array]; for (HBFileExtension *extension in self.excludedExtensions) { [extensions addObject:extension.extension]; } [NSUserDefaults.standardUserDefaults setObject:extensions forKey:HBExcludedFileExtensions]; } - (IBAction)extensionDoubleClickAction:(NSTableView *)sender { if (sender.clickedRow > -1) { [sender editColumn:0 row:sender.clickedRow withEvent:nil select:YES]; } } - (IBAction)addFileExtension:(id)sender { if ([sender selectedSegment]) { [self.excludedExtensionsController removeObjectsAtArrangedObjectIndexes:self.excludedExtensionsController.selectionIndexes]; } else { HBFileExtension *extension = [[HBFileExtension alloc] initWithDelegate:self]; [self.excludedExtensionsController addObject:extension]; NSInteger row = [self.excludedExtensionsController.arrangedObjects count] - 1; if (row > -1) { [self.excludedExtensionsTableView selectRowIndexes:[NSIndexSet indexSetWithIndex:row] byExtendingSelection:NO]; [self.excludedExtensionsTableView editColumn:0 row:row withEvent:nil select:YES]; } } } #pragma mark - Private methods - (void)setPrefView:(id)sender { NSView *view = self.generalView; if (sender) { NSString *identifier = [sender itemIdentifier]; if ([identifier isEqualToString:TOOLBAR_OUTPUT_NAME]) { view = self.fileNameView; } else if ([identifier isEqualToString:TOOLBAR_QUEUE]) { view = self.queueView; } else if ([identifier isEqualToString:TOOLBAR_ADVANCED]) { view = self.advancedView; } } NSWindow *window = self.window; if (window.contentView == view) { return; } window.contentView = view; if (window.isVisible) { view.hidden = YES; [NSAnimationContext runAnimationGroup:^(NSAnimationContext *context) { context.allowsImplicitAnimation = YES; [window layoutIfNeeded]; } completionHandler:^{ view.hidden = NO; }]; } // set title label if (sender) { window.title = [sender label]; } else { NSToolbar *toolbar = window.toolbar; NSString *itemIdentifier = toolbar.selectedItemIdentifier; for (NSToolbarItem *item in toolbar.items) { if ([item.itemIdentifier isEqualToString:itemIdentifier]) { window.title = item.label; break; } } } } - (NSToolbarItem *)toolbarItemWithIdentifier:(NSString *)identifier label:(NSString *)label image:(NSImage *)image { NSToolbarItem *item = [[NSToolbarItem alloc] initWithItemIdentifier:identifier]; item.label = label; item.image = image; item.action = @selector(setPrefView:); item.autovalidates = NO; return item; } @end