// // MachineDocument.swift // Clock Signal // // Created by Thomas Harte on 04/01/2016. // Copyright 2016 Thomas Harte. All rights reserved. // import AudioToolbox import Cocoa import QuartzCore class MachineDocument: NSDocument, NSWindowDelegate, CSMachineDelegate, CSScanTargetViewResponderDelegate, CSROMReciverViewDelegate { // MARK: - Mutual Exclusion. /// Ensures exclusive access between calls to self.machine.run and close(). private let actionLock = NSLock() /// Ensures exclusive access between calls to machine.updateView and machine.drawView, and close(). private let drawLock = NSLock() // MARK: - Machine details. /// A description of the machine this document should represent once fully set up. private var machineDescription: CSStaticAnalyser? /// The active machine, following its successful creation. private var machine: CSMachine! /// @returns the appropriate window content aspect ratio for this @c self.machine. private func aspectRatio() -> NSSize { return NSSize(width: 4.0, height: 3.0) } /// The output audio queue, if any. private var audioQueue: CSAudioQueue! // MARK: - Main NIB connections. /// The OpenGL view to receive this machine's display. @IBOutlet weak var scanTargetView: CSScanTargetView! /// The options view, if any. @IBOutlet var optionsView: NSView! @IBOutlet var optionsController: MachineController! /// The activity panel, if one is deemed appropriate. @IBOutlet var activityView: NSView! /// The volume view. @IBOutlet var volumeView: NSView! @IBOutlet var volumeSlider: NSSlider! // MARK: - NSDocument Overrides and NSWindowDelegate methods. /// Links this class to the MachineDocument NIB. override var windowNibName: NSNib.Name? { return "MachineDocument" } convenience init(type typeName: String) throws { self.init() self.fileType = typeName } override func read(from url: URL, ofType typeName: String) throws { if let analyser = CSStaticAnalyser(fileAt: url) { self.displayName = analyser.displayName self.configureAs(analyser) } else { throw NSError(domain: "MachineDocument", code: -1, userInfo: nil) } } override func close() { // Close any dangling sheets. // // Be warned: in 11.0 at least, if there are any panels then posting the endSheet request // will defer the close(), and close() will be called again at the end of that animation. // // So: MAKE SURE IT'S SAFE TO ENTER THIS FUNCTION TWICE. Hence the non-assumption here about // any windows still existing. if let window = self.windowControllers.first?.window { for sheet in window.sheets { window.endSheet(sheet) } } // Stop the machine, if any. machine?.stop() // End the update cycle. actionLock.lock() drawLock.lock() machine = nil scanTargetView.invalidate() actionLock.unlock() drawLock.unlock() // Let the document controller do its thing. super.close() } override func data(ofType typeName: String) throws -> Data { throw NSError(domain: NSOSStatusErrorDomain, code: unimpErr, userInfo: nil) } override func windowControllerDidLoadNib(_ aController: NSWindowController) { super.windowControllerDidLoadNib(aController) aController.window?.contentAspectRatio = self.aspectRatio() volumeSlider.floatValue = pow(2.0, userDefaultsVolume()) volumeView.layer!.cornerRadius = 5.0 } private var missingROMs: String = "" func configureAs(_ analysis: CSStaticAnalyser) { self.machineDescription = analysis actionLock.lock() drawLock.lock() let missingROMs = NSMutableString() if let machine = CSMachine(analyser: analysis, missingROMs: missingROMs) { setRomRequesterIsVisible(false) self.machine = machine machine.setVolume(userDefaultsVolume()) setupMachineOutput() } else { self.missingROMs = missingROMs as String requestRoms() } actionLock.unlock() drawLock.unlock() } enum InteractionMode { case notStarted, showingMachinePicker, showingROMRequester, showingMachine } private var interactionMode: InteractionMode = .notStarted // Attempting to show a sheet before the window is visible (such as when the NIB is loaded) results in // a sheet mysteriously floating on its own. For now, use windowDidUpdate as a proxy to check whether // the window is visible. func windowDidUpdate(_ notification: Notification) { if self.windowControllers.count > 0, let window = self.windowControllers[0].window, window.isVisible { // Grab the regular window title, if it's not already stored. if self.unadornedWindowTitle == "" { self.unadornedWindowTitle = window.title } updateWindowTitle() // If an interaction mode is not yet in effect, pick the proper one and display the relevant thing. if self.interactionMode == .notStarted { // If a full machine exists, just continue showing it. if self.machine != nil { self.interactionMode = .showingMachine setupMachineOutput() return } // If a machine has been picked but is not showing, there must be ROMs missing. if self.machineDescription != nil { self.interactionMode = .showingROMRequester requestRoms() return } // If a machine hasn't even been picked yet, show the machine picker. self.interactionMode = .showingMachinePicker Bundle.main.loadNibNamed("MachinePicker", owner: self, topLevelObjects: nil) self.machinePicker?.establishStoredOptions() window.beginSheet(self.machinePickerPanel!, completionHandler: nil) } } } func windowDidEnterFullScreen(_ notification: Notification) { updateActivityViewVisibility() } // MARK: - Connections Between Machine and the Outside World. private func setupMachineOutput() { if let machine = self.machine, let scanTargetView = self.scanTargetView, machine.view != scanTargetView { // Establish the output aspect ratio and audio. let aspectRatio = self.aspectRatio() machine.setView(scanTargetView, aspectRatio: Float(aspectRatio.width / aspectRatio.height)) // Attach an options panel if one is available. if let optionsNibName = self.machineDescription?.optionsNibName { Bundle.main.loadNibNamed(optionsNibName, owner: self, topLevelObjects: nil) if let optionsController = self.optionsController { optionsController.machine = machine optionsController.establishStoredOptions() } if let optionsView = self.optionsView, let superview = self.volumeView.superview { // Apply rounded edges. optionsView.layer!.cornerRadius = 5.0 // Add to the superview. superview.addSubview(optionsView) // Apply constraints to appear centred and above the volume view. let constraints = [ optionsView.centerXAnchor.constraint(equalTo: volumeView.centerXAnchor), optionsView.bottomAnchor.constraint(equalTo: volumeView.topAnchor, constant: -8.0), ] superview.addConstraints(constraints) } } // Set up a fader for the volume and options. var fadingViews: [NSView] = [] if let optionsView = self.optionsView { fadingViews.append(optionsView) } if let volumeView = self.volumeView { fadingViews.append(volumeView) } optionsFader = ViewFader(views: fadingViews) // Create and populate an activity display if required. setupActivityDisplay() machine.delegate = self scanTargetView.responderDelegate = self // If this machine has a mouse, enable mouse capture; also indicate whether usurption // of the command key is desired. scanTargetView.shouldCaptureMouse = machine.hasMouse scanTargetView.shouldUsurpCommand = machine.shouldUsurpCommand setupAudioQueueClockRate() // Bring OpenGL view-holding window on top of the options panel and show the content. scanTargetView.isHidden = false scanTargetView.window!.makeKeyAndOrderFront(self) scanTargetView.window!.makeFirstResponder(scanTargetView) // Start forwarding best-effort updates. machine.start() } } func machineSpeakerDidChangeInputClock(_ machine: CSMachine) { // setupAudioQueueClockRate not only needs blocking access to the machine, // but may be triggered on an arbitrary thread by a running machine, and that // running machine may not be able to stop running until it has been called // (e.g. if it is currently trying to run_until an audio event). Break the // deadlock with an async dispatch. DispatchQueue.main.async { self.setupAudioQueueClockRate() } } private func setupAudioQueueClockRate() { // Establish and provide the audio queue, taking advice as to an appropriate sampling rate. // // TODO: this needs to be threadsafe. FIX! let maximumSamplingRate = CSAudioQueue.preferredSamplingRate() let selectedSamplingRate = Float64(self.machine.idealSamplingRate(from: NSRange(location: 0, length: NSInteger(maximumSamplingRate)))) let isStereo = self.machine.isStereo if selectedSamplingRate > 0 { // [Re]create the audio queue only if necessary. if self.audioQueue == nil || self.audioQueue.samplingRate != selectedSamplingRate || self.audioQueue != self.machine.audioQueue { self.machine.audioQueue = nil self.audioQueue = CSAudioQueue(samplingRate: Float64(selectedSamplingRate), isStereo:isStereo) self.machine.audioQueue = self.audioQueue self.machine.setAudioSamplingRate(Float(selectedSamplingRate), bufferSize:audioQueue.preferredBufferSize, stereo:isStereo) } } } // MARK: - Pasteboard Forwarding. /// Forwards any text currently on the pasteboard into the active machine. func paste(_ sender: Any) { let pasteboard = NSPasteboard.general if let string = pasteboard.string(forType: .string), let machine = self.machine { machine.paste(string) } } // MARK: - Runtime Media Insertion. /// Delegate message to receive drag and drop files. final func scanTargetView(_ view: CSScanTargetView, didReceiveFileAt URL: URL) { insertFile(URL) } /// Action for the insert menu command; displays an NSOpenPanel and then segues into the same process /// as if a file had been received via drag and drop. @IBAction final func insertMedia(_ sender: AnyObject!) { let openPanel = NSOpenPanel() openPanel.message = "Hint: you can also insert media by dragging and dropping it onto the machine's window." openPanel.beginSheetModal(for: self.windowControllers[0].window!) { (response) in if response == .OK { for url in openPanel.urls { self.insertFile(url) } } } } private func insertFile(_ URL: URL) { // Try to insert media. let mediaSet = CSMediaSet(fileAt: URL) if !mediaSet.empty { mediaSet.apply(to: self.machine) return } // Failing that see whether a new machine is required. // TODO. if let newMachine = CSStaticAnalyser(fileAt: URL) { machine?.stop() self.interactionMode = .notStarted self.scanTargetView.willChangeScanTargetOwner() configureAs(newMachine) } } // MARK: - Input Management. /// Upon a resign key, immediately releases all ongoing input mechanisms — any currently pressed keys, /// and joystick and mouse inputs. func windowDidResignKey(_ notification: Notification) { if let machine = self.machine { machine.clearAllKeys() machine.joystickManager = nil } self.scanTargetView.releaseMouse() } /// Upon becoming key, attaches joystick input to the machine. func windowDidBecomeKey(_ notification: Notification) { if let machine = self.machine { machine.joystickManager = (DocumentController.shared as! DocumentController).joystickManager } } /// Forwards key down events directly to the machine. func keyDown(_ event: NSEvent) { if let machine = self.machine { machine.setKey(event.keyCode, characters: event.characters, isPressed: true) } } /// Forwards key up events directly to the machine. func keyUp(_ event: NSEvent) { if let machine = self.machine { machine.setKey(event.keyCode, characters: event.characters, isPressed: false) } } /// Synthesies appropriate key up and key down events upon any change in modifiers. func flagsChanged(_ newModifiers: NSEvent) { if let machine = self.machine { machine.setKey(VK_Shift, characters: nil, isPressed: newModifiers.modifierFlags.contains(.shift)) machine.setKey(VK_Control, characters: nil, isPressed: newModifiers.modifierFlags.contains(.control)) machine.setKey(VK_Command, characters: nil, isPressed: newModifiers.modifierFlags.contains(.command)) machine.setKey(VK_Option, characters: nil, isPressed: newModifiers.modifierFlags.contains(.option)) } } /// Forwards mouse movement events to the mouse. func mouseMoved(_ event: NSEvent) { if let machine = self.machine { machine.addMouseMotionX(event.deltaX, y: event.deltaY) } } /// Forwards mouse button down events to the mouse. func mouseUp(_ event: NSEvent) { if let machine = self.machine { machine.setMouseButton(Int32(event.buttonNumber), isPressed: false) } } /// Forwards mouse button up events to the mouse. func mouseDown(_ event: NSEvent) { if let machine = self.machine { machine.setMouseButton(Int32(event.buttonNumber), isPressed: true) } } // MARK: - MachinePicker Outlets and Actions @IBOutlet var machinePicker: MachinePicker? @IBOutlet var machinePickerPanel: NSWindow? @IBAction func createMachine(_ sender: NSButton?) { let selectedMachine = machinePicker!.selectedMachine() self.windowControllers[0].window?.endSheet(self.machinePickerPanel!) self.machinePicker = nil self.configureAs(selectedMachine) } @IBAction func tableViewDoubleClick(_ sender: NSTableView?) { createMachine(nil) } @IBAction func cancelCreateMachine(_ sender: NSButton?) { close() } // MARK: - ROMRequester Outlets and Actions @IBOutlet var romRequesterPanel: NSWindow? @IBOutlet var romRequesterText: NSTextField? @IBOutlet var romReceiverErrorField: NSTextField? @IBOutlet var romReceiverView: CSROMReceiverView? private var romRequestBaseText = "" private func setRomRequesterIsVisible(_ visible : Bool) { if !visible && self.romRequesterPanel == nil { return; } if self.romRequesterPanel!.isVisible == visible { return } if visible { self.windowControllers[0].window?.beginSheet(self.romRequesterPanel!, completionHandler: nil) } else { self.windowControllers[0].window?.endSheet(self.romRequesterPanel!) } } func requestRoms() { // Don't act yet if there's no window controller yet. if self.windowControllers.count == 0 { return } // Load the ROM requester dialogue if it's not already loaded. if self.romRequesterPanel == nil { Bundle.main.loadNibNamed("ROMRequester", owner: self, topLevelObjects: nil) self.romReceiverView!.delegate = self self.romRequestBaseText = romRequesterText!.stringValue romReceiverErrorField?.alphaValue = 0.0 } // Populate the current absentee list. populateMissingRomList() // Show the thing. setRomRequesterIsVisible(true) } @IBAction func cancelRequestROMs(_ sender: NSButton?) { close() } func populateMissingRomList() { romRequesterText!.stringValue = self.romRequestBaseText + self.missingROMs } func romReceiverView(_ view: CSROMReceiverView, didReceiveFileAt URL: URL) { // Test whether the file identified matches any of the currently missing ROMs. // If so then remove that ROM from the missing list and update the request screen. // If no ROMs are still missing, start the machine. if CSMachine.attemptInstallROM(URL) { configureAs(self.machineDescription!) } else { showRomReceiverError(error: "Didn't recognise contents of \(URL.lastPathComponent)") } } // Yucky ugliness follows; my experience as an iOS developer intersects poorly with // NSAnimationContext hence the various stateful diplications below. isShowingError // should be essentially a duplicate of the current alphaValue, and animationCount // is to resolve my inability to figure out how to cancel scheduled animations. private var errorText = "" private var isShowingError = false private var animationCount = 0 private func showRomReceiverError(error: String) { // Set or append the new error. if self.errorText.count > 0 { self.errorText = self.errorText + "\n" + error } else { self.errorText = error } // Apply the new complete text. romReceiverErrorField!.stringValue = self.errorText if !isShowingError { // Schedule the box's appearance. NSAnimationContext.beginGrouping() NSAnimationContext.current.duration = 0.1 romReceiverErrorField?.animator().alphaValue = 1.0 NSAnimationContext.endGrouping() isShowingError = true } // Schedule the box to disappear. self.animationCount = self.animationCount + 1 let capturedAnimationCount = animationCount DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + .seconds(2)) { if self.animationCount == capturedAnimationCount { NSAnimationContext.beginGrouping() NSAnimationContext.current.duration = 1.0 self.romReceiverErrorField?.animator().alphaValue = 0.0 NSAnimationContext.endGrouping() self.isShowingError = false self.errorText = "" } } } // MARK: - Joystick-via-the-keyboard selection. @IBAction func useKeyboardAsPhysicalKeyboard(_ sender: NSMenuItem?) { machine.inputMode = .keyboardPhysical } @IBAction func useKeyboardAsLogicalKeyboard(_ sender: NSMenuItem?) { machine.inputMode = .keyboardLogical } @IBAction func useKeyboardAsJoystick(_ sender: NSMenuItem?) { machine.inputMode = .joystick } /// Determines which of the menu items to enable and disable based on the ability of the /// current machine to handle keyboard and joystick input, accept new media and whether /// it has an associted activity window. override func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { if let menuItem = item as? NSMenuItem { switch item.action { case #selector(self.useKeyboardAsPhysicalKeyboard): if machine == nil || !machine.hasExclusiveKeyboard { menuItem.state = .off return false } menuItem.state = machine.inputMode == .keyboardPhysical ? .on : .off return true case #selector(self.useKeyboardAsLogicalKeyboard): if machine == nil || !machine.hasExclusiveKeyboard { menuItem.state = .off return false } menuItem.state = machine.inputMode == .keyboardLogical ? .on : .off return true case #selector(self.useKeyboardAsJoystick): if machine == nil || !machine.hasJoystick { menuItem.state = .off return false } menuItem.state = machine.inputMode == .joystick ? .on : .off return true case #selector(self.insertMedia(_:)): return self.machine != nil && self.machine.canInsertMedia default: break } } return super.validateUserInterfaceItem(item) } // MARK: - Screenshots. /// Saves a screenshot of the machine's current display. @IBAction func saveScreenshot(_ sender: AnyObject!) { // Grab a date formatter and form a file name. let dateFormatter = DateFormatter() dateFormatter.dateStyle = .short dateFormatter.timeStyle = .long let filename = ("Clock Signal Screen Shot " + dateFormatter.string(from: Date()) + ".png").replacingOccurrences(of: "/", with: "-") .replacingOccurrences(of: ":", with: ".") let pictursURL = FileManager.default.urls(for: .picturesDirectory, in: .userDomainMask)[0] let url = pictursURL.appendingPathComponent(filename) // Obtain the machine's current display. let imageRepresentation = self.machine.imageRepresentation // Encode as a PNG and save. let pngData = imageRepresentation.representation(using: .png, properties: [:]) try! pngData?.write(to: url) } // MARK: - Window Title Updates. private var unadornedWindowTitle = "" private var mouseIsCaptured = false private var windowTitleSuffix = "" private func updateWindowTitle() { var title = self.unadornedWindowTitle if windowTitleSuffix != "" { title += windowTitleSuffix } if mouseIsCaptured { title += " (press ⌘+control to release mouse)" } self.windowControllers[0].window?.title = title } internal func scanTargetViewDidCaptureMouse(_ view: CSScanTargetView) { mouseIsCaptured = true updateWindowTitle() } internal func scanTargetViewDidReleaseMouse(_ view: CSScanTargetView) { mouseIsCaptured = false updateWindowTitle() } // MARK: - Activity Display. private class LED { let levelIndicator: NSLevelIndicator init(levelIndicator: NSLevelIndicator, isPersistent: Bool) { self.levelIndicator = levelIndicator self.isPersistent = isPersistent } var isLit = false var isBlinking = false var isPersistent = false } private var leds: [String: LED] = [:] private var activityFader: ViewFader! = nil func setupActivityDisplay() { var leds = machine.leds if leds.count > 0 { Bundle.main.loadNibNamed("Activity", owner: self, topLevelObjects: nil) // Inspect the activity panel for indicators. var activityIndicators: [NSLevelIndicator] = [] var textFields: [NSTextField] = [] if let activityView = self.activityView { for view in activityView.subviews { if let levelIndicator = view as? NSLevelIndicator { activityIndicators.append(levelIndicator) } if let textField = view as? NSTextField { textFields.append(textField) } } } // If there are fewer level indicators than LEDs, trim that list. if activityIndicators.count < leds.count { leds.removeSubrange(activityIndicators.count ..< leds.count) } // Remove unused views. for c in leds.count ..< activityIndicators.count { textFields[c].removeFromSuperview() activityIndicators[c].removeFromSuperview() } // Apply labels and create leds entries. for c in 0 ..< leds.count { textFields[c].stringValue = leds[c].name self.leds[leds[c].name] = LED(levelIndicator: activityIndicators[c], isPersistent: leds[c].isPersisent) } // Create a fader. activityFader = ViewFader(views: [self.activityView!]) // Add view to window, and constrain. if let superview = activityIndicators[leds.count-1].superview { superview.addConstraint( activityIndicators[leds.count-1].bottomAnchor.constraint(equalTo: activityIndicators[leds.count-1].superview!.bottomAnchor, constant: -8.0) ) } if let windowView = self.volumeView.superview { windowView.addSubview(self.activityView) let constraints = [ self.activityView.rightAnchor.constraint(equalTo: windowView.rightAnchor), self.activityView.topAnchor.constraint(equalTo: windowView.topAnchor), ] windowView.addConstraints(constraints) activityView.layer!.cornerRadius = 5.0 activityView.layer!.maskedCorners = [.layerMinXMinYCorner] } // Show or hide activity view as per current state. updateActivityViewVisibility(true) } } func machine(_ machine: CSMachine, ledShouldBlink ledName: String) { // If there is such an LED, switch it off for 0.03 of a second; if it's meant // to be off at the end of that, leave it off. Don't allow the blinks to // pile up — allow there to be only one in flight at a time. if let led = leds[ledName] { DispatchQueue.main.async { if !led.isBlinking && led.isLit { led.levelIndicator.floatValue = 0.0 led.isBlinking = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.03) { led.levelIndicator.floatValue = led.isLit ? 1.0 : 0.0 led.isBlinking = false } } } } } func machine(_ machine: CSMachine, led ledName: String, didChangeToLit isLit: Bool) { // If there is such an LED, switch it appropriately. if let led = leds[ledName] { DispatchQueue.main.async { [self] in // Do nothing for no change of state. if led.isLit == isLit { return } led.levelIndicator.floatValue = isLit ? 1.0 : 0.0 led.isLit = isLit // Possibly show or hide the activity subview. self.updateActivityViewVisibility(false, changed: ledName) } } } private func updateActivityViewVisibility(_ isAppLaunch : Bool = false, changed: String? = nil) { if let window = self.windowControllers.first?.window, let activityFader = self.activityFader { // Rules applied below: // // Fullscreen: // (i) always show activity view if any persistent LEDs are present; // (ii) otherwise, show activity view only while at least one LED is lit. // // Windowed: // (i) show while any non-persistent LED is lit; // (ii) show transiently to indicate a change of state in any persistent LED. // let hasLitLEDs = !self.leds.filter { $0.value.isLit && (!$0.value.isPersistent || window.styleMask.contains(.fullScreen)) || ($0.value.isPersistent && window.styleMask.contains(.fullScreen)) }.isEmpty let shouldShowTransient = !window.styleMask.contains(.fullScreen) && changed != nil && self.leds[changed!]!.isPersistent if hasLitLEDs { activityFader.animateIn() } else if shouldShowTransient { activityFader.showTransiently(for: 1.0) } else { activityFader.animateOut(delay: 0.2) } } } // MARK: - In-window panels (i.e. options, volume). private var optionsFader: ViewFader! = nil internal func scanTargetViewDidShowOSMouseCursor(_ view: CSScanTargetView) { // The OS mouse cursor became visible, so show the volume controls. optionsFader.animateIn() } internal func scanTargetViewWouldHideOSMouseCursor(_ view: CSScanTargetView) { // The OS mouse cursor will be hidden, so hide the volume controls. optionsFader.animateOut(delay: 0.0) } // MARK: - Helpers for fading things in and out. /// Maintains a list of views and offers in-and-out animations on those, /// testing current state as necessary and otherwise coordinating with /// CoreAnimation. private class ViewFader: NSObject, CAAnimationDelegate { private var views: [NSView] init(views: [NSView]) { self.views = views for view in views { view.isHidden = true } } func animationDidStop(_ animation: CAAnimation, finished: Bool) { if finished { for view in views { view.isHidden = true } } } func animateIn() { for view in views { view.layer?.removeAllAnimations() view.isHidden = false } } func animateOut(delay : TimeInterval) { // Do nothing if already animating out or invisible. if views[0].isHidden || views[0].layer?.animation(forKey: "opacity") != nil { return } for view in views { let fadeAnimation = CABasicAnimation(keyPath: "opacity") fadeAnimation.beginTime = CACurrentMediaTime() + delay fadeAnimation.fromValue = 1.0 fadeAnimation.toValue = 0.0 fadeAnimation.duration = 0.2 fadeAnimation.delegate = self fadeAnimation.fillMode = .forwards fadeAnimation.isRemovedOnCompletion = false view.layer?.removeAllAnimations() view.layer!.add(fadeAnimation, forKey: "opacity") } } func showTransiently(for period: TimeInterval) { animateIn() animateOut(delay: period) } } // MARK: - Volume Control. @IBAction func setVolume(_ sender: NSSlider!) { if let machine = self.machine { let linearValue = log2(sender.floatValue) machine.setVolume(linearValue) setUserDefaultsVolume(linearValue) } } // The user's selected volume is stored as 1 - volume in the user defaults in order // to take advantage of the default value being 0. private func userDefaultsVolume() -> Float { return 1.0 - UserDefaults.standard.float(forKey: "defaultVolume") } private func setUserDefaultsVolume(_ volume: Float) { UserDefaults.standard.set(1.0 - volume, forKey: "defaultVolume") } }