Compare commits

...

212 Commits

Author SHA1 Message Date
Kyle Neideck 0388b26aa1 Add Xcode 11 to Travis CI builds. 2019-08-06 17:24:25 +10:00
Kyle Neideck 002afc0cd1 Update copyright years. #227 2019-08-05 22:42:17 +10:00
Kyle Neideck a45335b65d Add GPMDP to the list of supported music players in the README. 2019-08-05 21:31:58 +10:00
Kyle Neideck 0a7be7d32c Enable Hardened Runtime in BGMApp, BGMXPCHelper and BGMDriver.
This is required for Notarization, which will be mandatory in macOS
Catalina.

Also, suppress some STL deprecation warnings in PublicUtility code. (The
warnings were recently added to Clang.)
2019-08-05 21:25:57 +10:00
Dave Nicolson d26e9ee3d1 Update README.md 2019-08-05 21:05:19 +10:00
Kyle Neideck 94fc1259e3 Add BGMMusic files to the Xcode project.
Also, add BGMMusic to BGMMusicPlayers and add the Scripting Bridge
header for Music.app.

Resolves #216.
2019-07-03 02:34:15 +10:00
Kyle Neideck a97529e812 Merge branch 'theLMGN-patch-1' 2019-07-03 02:29:52 +10:00
Kyle Neideck 0231e131df Merge branch 'patch-1' of https://github.com/theLMGN/BackgroundMusic into theLMGN-patch-1 2019-07-03 01:50:47 +10:00
Vikas Shukla 9951a82879 Minor error update to README
An accidental "the" I think.
2019-07-03 01:45:27 +10:00
Leo Nesfield 3ac7221cb1 Create BGMMusic.h 2019-06-30 17:16:16 +01:00
Leo Nesfield 2be4bab54f Create BGMMusic.m 2019-06-30 17:14:35 +01:00
Kyle Neideck 04f17301a1 Fix BGMApp test compilation.
Updates the lists of files to be compiled when the tests are built,
which were missing the new source files added in the previous commit.
2019-06-09 19:43:17 +10:00
Kyle Neideck e616718eab Add music player: Google Play Music Desktop Player.
The code for GPMDP is a lot more complicated than the code for other
music players. See BGMGooglePlayMusicDesktopPlayer.h for details.

Adds a class, BGMAppWatcher, to hold the code that notifies listeners
when a given app is launched or terminated.

Resolves #161.
2019-06-09 18:56:12 +10:00
Kyle Neideck 503d1a92ec Fix another crash when BGMDevice's volume is changed.
Fixes the same issue as df9815a4be, but in
BGMOutputVolumeMenuItem instead of BGMStatusBarItem.

I think the problem was that it captured a weak reference in a C++
lambda, but it would capture by (C++) reference and when the reference
was used it would be referencing invalid memory. The fix to have the
lambda capture by value instead.

See #202.
2019-03-28 18:29:54 +11:00
Kyle Neideck 2939dbe28c Update Travis CI builds to use Xcode 10.1. 2019-03-27 13:43:10 +11:00
Kyle Neideck a40dfde439 Fix potential minor memory leaks in BGMPreferredOutputDevices. 2019-03-27 13:36:19 +11:00
Kyle Neideck df9815a4be Fix a crash when BGMDevice's volume is changed.
I think that, because BGMStatusBarItem::initWithMenu was capturing a
weak reference in a C++ lambda as a C++ reference, the C++ reference
would be invalid when it was used. The fix to have the lambda capture by
value instead.

I'm not completely sure why builds from Xcode never crashed or why ASan
didn't catch the bug. Maybe the stack memory was just never
reused/invalidated with Xcode builds. (And I probably just don't
understand how ASan works well enough.)

Fixes #202.
2019-03-27 12:55:12 +11:00
Kyle Neideck 26dd2ee1ab Minor README change. 2019-03-23 15:25:11 +11:00
Kyle Neideck d89d1e7813 Add a warning about clipping to Known Issues in the README.
[skip travis]
2019-03-11 11:39:04 +11:00
Kyle Neideck e093e7d3b2 Add an option to use a volume icon instead of the Background Music logo.
This is so the icon can show the current volume. Then you can hide the
built-in volume status bar item in System Preferences.

Closes #183.
2019-03-05 00:01:42 +11:00
Kyle Neideck 14df80da24 README.md: Fix the Homebrew command for installing snapshot releases. 2019-02-27 21:57:23 +11:00
Kyle Neideck a4c93c050b Fix build_and_install.sh failing when run from a path containing spaces.
When build_and_install.sh tried to install BGMXPCHelper, xcodebuild
would fail to run post_install.sh.

Fixes #187.
2019-01-22 21:58:47 +11:00
Erwann Mest 5257b4c94d docs(README): add snapshot version
I created a formula for cask about the snapshot version. So I'd like to add it into the README. :)
2018-12-20 10:34:23 +11:00
Kyle Neideck 30185633ac Add Homebrew install command to the README. 2018-11-29 23:06:12 +11:00
Kyle Neideck 624369f297 Add "unsigned" to the filenames of packages from Travis CI.
My plan is to also build the packages locally (at least full releases),
code sign manually and then check that the two packages match apart from
the code signature. That way I don't have to give the Travis script
access to my signing private key, which I figure is slightly more
secure.
2018-11-24 15:03:09 +11:00
Kyle Neideck d1bf34e741 Add folds to the Travis CI build logs. 2018-11-17 14:49:25 +11:00
Kyle Neideck 475d141ae4 Make builds more deterministic.
Packages built with package.sh should now be byte-identical except for
timestamps in Assets.car (in BGMApp resources) and modification dates in
the package's Bom file, if built with the same version of Xcode.
Hopefully this will be enough to allow builds to be reproduced, with a
bit of effort.
2018-11-12 19:15:26 +11:00
Kyle Neideck d1f5492a47 Fix compilation errors with Clang 8 in BGMAppUITests.
You can't run the UI tests in Xcode 8 anyway, so now we just skip
compiling them.
2018-11-04 18:15:52 +11:00
Kyle Neideck 64b7ca9fd9 Fix compilation error with Clang 8 in BGMOutputDeviceMenuSection. 2018-11-04 17:57:42 +11:00
Kyle Neideck 7b8d1a0e0d Rename BGMOutputDevicePrefs to BGMOutputDeviceMenuSection. 2018-11-04 15:28:01 +11:00
Kyle Neideck 5e12f9fc01 Move the output device menu items to the main menu.
I don't know why I put them in the Preferences menu initially. This is
more convenient.

Closes #170.

Also:
 - Update the output device menu items as needed instead of when the
   user opens the menu. This saves a bit of CPU time and means if the
   user has the menu open, changes are made when they're needed instead
   of the next time the user opens the menu.
 - Fix BGMAppUITests::testCycleOutputDevices for the latest Xcode/macOS.
2018-11-04 12:30:43 +11:00
Kyle Neideck 94f13e747c Clarify some comments in BGMPreferredOutputDevices. 2018-10-28 18:11:40 +11:00
Kyle Neideck 4c0c656538 Store the preferred devices list in User Defaults.
BGMApp has to set BGMDevice, and often also the Null Device for a short
time, as the systemwide default audio device, which makes CoreAudio put
them in the preferred devices list in its Plist file. And since the list
is limited to three devices, it only gives us one or two usable ones.
Ideally, CoreAudio just wouldn't add our devices to its list, but I
don't think we can prevent that.

As a partial workaround, we now store our own copy of the preferred
devices list without our devices, which BGMApp can use to figure out
which devices were pushed out of CoreAudio's list by our devices.

This doesn't fix the problem entirely because our devices still take up
room in CoreAudio's list when BGMApp is closed, but I think that would
be harder to solve.

See #167.

Also:
 - Handle setting the initial output device in BGMPreferredOutputDevices
   instead of BGMAudioDeviceManager.
 - Fix a crash in BGMOutputVolumeMenuItem::dealloc caused by using
   dispatch_sync to dispatch to the main queue while running on the main
   queue.
 - Fix a crash in BGMPreferredOutputDevices if
   /Library/Preferences/Audio/com.apple.audio.SystemSettings.plist
   doesn't exist.
 - Add Swinsian to the list of music players in the README. (I must have
   forgotten to do that when I added support for it.)
2018-10-28 17:08:47 +11:00
Kyle Neideck 871bb97a52 Increment minor version number. 2018-10-25 00:00:52 +11:00
Kyle Neideck ffe7406025 Fix errors logged when the current output device is disconnected. 2018-10-24 23:46:28 +11:00
Kyle Neideck 29642da1cf Update the preferred devices list when the user changes output device.
When the user chooses a different output device in BGMApp, the new
device is now added to the front of the list of preferred devices. This
stops BGMPreferredOutputDevices changing the output device back shortly
afterward when it gets a device connection/disconnection notification,
which is sent because BGMDriver's Null Device is enabled and then
disabled as part of changing the output device.

It also means BGMApp will now account for the times the output device
has been changed since BGMApp started when deciding whether to change to
a newly connected device and deciding which device to change to when the
current output device is removed.
2018-10-24 22:29:20 +11:00
Kyle Neideck 1bb3873a53 Change output device in some cases when devices are added/removed.
Tries to copy the way CoreAudio normally handles devices being added or
removed, which it can't do while Background Music is running (because
BGMDevice needs to be the default output device for the system).

This may break when Background Music is run on later versions of macOS
as the only way BGMApp can tell what CoreAudio (probably) would have
done is by reading one of its Plist files directly.

See #167.
2018-10-23 14:07:13 +11:00
Kyle Neideck a4c849dcb2 Update the README to link to v0.2.0. Also update the screenshot. 2018-10-20 14:40:24 +11:00
Kyle Neideck b0282706df Fix adding "SNAPSHOT"/"DEBUG" to the version string with multiple tags.
The most recent tag is now used to decide whether to add "-SNAPSHOT-..."
to the version string. So now we can make a new release by tagging a
commit even if we've already made a snapshot/debug release from the same
commit.
2018-10-16 12:38:52 +11:00
Kyle Neideck 94a5f37c2b Fix BGMOutputVolumeMenuItem tooltip not always being updated.
When you changed to an output device with no data sources, the tooltip
was left set to the name of the previous output device.
2018-10-06 21:42:33 +10:00
Kyle Neideck 797d2f14f5 Update the output device name in the UI if its data source changes.
For the label above the output device volume slider, we use the name of
the output device's current data source, if it has one. But it was only
being updated when the user changed to a different output device.
BGMOutputVolumeMenuItem now updates the label if the output device
changes to a different data source, e.g. from Internal Speakers to
Headphones.
2018-10-06 21:17:41 +10:00
Kyle Neideck b3b4482bda Automatically set the release name for Travis CI builds.
Also mark them as prereleases, since most will be snapshot/debug builds.
We can just change that back manually for full releases.
2018-10-06 14:35:57 +10:00
Kyle Neideck 9b33fffd23 Fix the Show More Controls buttons displaying as "...".
The character we use for them (looks like '^') seems to be 1 pixel wider
on macOS 10.14, which meant it didn't fit in the text label anymore.
2018-10-06 12:46:07 +10:00
Kyle Neideck ac33909b51 Fix pkg installer not opening BGMApp if relocated.
If the user moved BGMApp and then installed a new version from a .pkg,
BGMApp would be installed to the same place the old version had been
moved to. When pkg/postinstall tried to open BGMApp, it would fail
because it assumed BGMApp would be installed to /Applications.

Also, the installer now fails with an error message if it can't open
BGMApp after finishing the install.

Fixes #164.
2018-10-04 21:24:29 +10:00
Kyle Neideck 1a49802675 Hide call to requestAccessForMediaType when compiling on macOS < 10.14.
This should fix the compilation error in BGMAppDelegate when compiling
against a macOS SDK earlier than 10.14.
2018-10-03 18:40:22 +10:00
Kyle Neideck 75e8d5ceac Request user permission to use input devices and Apple Events.
This is required to build against the macOS 10.14 SDK because 10.14
requires users to grant apps permission before they can use audio input
devices or send Apple Events to other apps.

I think builds built against the 10.13 SDK were supposed to continue
working, but I haven't tested it.

Note that without NSMicrophoneUsageDescription and
NSAppleEventsUsageDescription, 10.14 builds will fail more or less
silently when they try to use those features. (tccd does log a message
about it, though.)

See #163.
2018-10-03 13:28:18 +10:00
Kyle Neideck 08fdef6084 Workaround some Xcode 10 bugs in build_and_install.sh.
Also, update the Xcode versions in .travis.yml.
2018-09-23 18:19:58 +10:00
Kyle Neideck 1e5d625d64 Add app volume workaround for Skype. Fixes #112. 2018-07-03 09:49:16 +10:00
Kyle Neideck ffa86bbcd9 Add workaround for Discord voice chat volume. 2018-06-19 22:39:40 +10:00
Kyle Neideck 5f31f54a85 Use a larger disk image in Travis CI builds.
Builds for "DEBUG" tags are running out of space.
2018-06-05 12:01:02 +10:00
Kyle Neideck 2a41204fc0 Update the years in some copyright notices. 2018-06-05 01:13:14 +10:00
Kyle Neideck cb9cdb00b6 Support creating .pkg installers using the debug build configuration.
Users reporting bugs will be able to use these packages to install debug
builds of Background Music without having to install from source. This
is mainly useful because debug builds have more detailed logging.
Hopefully we'll get around to adding an option to enable debug logging
at runtime, but this should work well enough for now.

Also:
 - Use newer macOS images in Travis CI builds.
 - Fix an xcrun command in build_and_install.sh that was accidentally
   being started in the background.
 - Fix build_and_install.sh building libPublicUtility.a twice for no
   reason.
2018-06-05 00:40:21 +10:00
Kyle Neideck ed06a257a8 Add auto-pause support for Swinsian. See #141. 2018-02-25 12:01:43 +11:00
Kyle Neideck 7171cfcb78 Add a test for BGM_Device::DoIOOperation. 2018-02-24 21:44:09 +11:00
Kyle Neideck 3ba53a50ac Support building snapshot releases by creating tags.
If HEAD is tagged, check for "SNAPSHOT" or "DEBUG" in the tag name when
generating the version string for a build. If found, add
"-SNAPSHOT-abcdef0" or "-DEBUG-abcdef0" at the end of the version
string (where "abcdef0" is the short commit ID for HEAD).
2018-02-24 17:55:29 +11:00
Kyle Neideck 944fc11212 Add workaround for FaceTime volume.
FaceTime plays call audio using a daemon called avconferenced, so
BGMDriver can't tell where the audio is actually coming from. As a
hopefully temporary fix, BGMApp now just sets avconferenced's volume to
match FaceTime's. See #139.

Also,
 - set a tooltip and accessibility label for BGMApp's status bar item
   (the thing you click to show the main menu), and
 - some minor refactoring.
2018-02-24 15:23:18 +11:00
Kyle Neideck 287bae0923 Fix mocks in BGMMusicPlayersUnitTests. 2018-01-21 13:43:30 +11:00
Kyle Neideck 6117bc285c Fix BGMApp crashing at launch if BGMDriver isn't installed. 2018-01-20 22:28:32 +11:00
Kyle Neideck 18aa97f055 Fix some minor bugs, mostly found by Coverity.
BGMDeviceControlsList: Set some members to null before they've been
lazily initialised.

BGM_TaskQueue: Fix the destructor possibly throwing.

BGM_Device and BGM_NullDevice: Fix integer division when calculating the
host clock frequency.

BGM_Utils: Fix the C++ utility function used to explicitly cast
__nullable values to __nonnull. (Was previously unused.)
2017-12-28 18:46:52 +11:00
Kyle Neideck ac2130c3c3 build_and_install.sh: Fix printing the last command in error_handler. 2017-12-27 18:23:25 +11:00
Kyle Neideck e83d07f00b .travis.yml: Update Xcode versions. Print logs if package.sh fails.
Travis CI no longer supports building with Xcode 8, 8.1 or 8.2.
2017-12-27 17:53:28 +11:00
Kyle Neideck b693a8af1e Add .editorconfig file to fix tab width on GitHub. 2017-12-27 15:22:17 +11:00
Kyle Neideck b3b60559f2 Link BGMDriver tests with Accelerate framework. 2017-12-27 13:46:58 +11:00
Kyle Neideck f64cf41f8a Add a volume slider for system sounds.
System sounds are UI-related sounds like mail notifications or terminal
bells.

Xcode 9.2 doesn't support saving .xib files in Xcode 7 format any more,
so building Background Music now requires Xcode 8 or above.

Also, fix some of the tooltips that would only work if BGMApp was the
foreground app, which it shouldn't be.
2017-12-26 23:10:57 +11:00
Kyle Neideck bd90399ce3 Update the link to eqMac in the README. 2017-12-11 08:49:33 +11:00
Kyle Neideck 425cb4af9d When the output device is changed, update its volume slider.
The label above the slider is set to the name of the new output device
and the slider's value is set to its volume.

Also,
 - clean up some code in BGMAudioDeviceManager and
   BGMOutputVolumeMenuItem, and
 - return from BGMAppDelegate::applicationDidFinishLaunching early if
   the launch is being aborted.
2017-11-26 16:12:56 +11:00
Kyle Neideck 4c6de2f77f Add new QuickLook bundle ID to fix App Volumes for Finder.
QuickLook's bundle ID has changed in High Sierra, which broke the
workaround that BGMApp uses to change QuickLook's app volume when
Finder's is changed.

Reported in #134.
2017-11-25 13:28:43 +11:00
Kyle Neideck ec81520379 Fix BGMApp unit tests not compiling. 2017-10-29 01:00:37 +11:00
Kyle Neideck 1171bee102 Refactor non-UI code out of BGMAppVolumes. 2017-10-28 18:13:08 +11:00
Kyle Neideck fb0740c4c1 Enable new compiler warnings suggested by Xcode. 2017-10-23 21:40:30 +11:00
Kyle Neideck db63ae0cf1 Add BGMTermination files forgotten in previous commit. 2017-10-23 21:35:49 +11:00
Kyle Neideck 59e70fb9d1 Set the OS default audio device back if BGMApp exits abnormally.
This is mostly so BGMApp won't leave BGMDevice as the default if BGMApp
crashes, which would stop audio from playing until the user changed the
default device themselves. Also handles SIGINT, SIGTERM and SIGQUIT.

For crashes where the BGMApp process may be in an unknown state, e.g.
segfaults, BGMXPCHelper handles changing the default device.

Should fix the Travis Xcode 9 build, which is currently failing because
the AppleScript we use to quit BGMApp in .travis.yml gets "user
cancelled" for some reason.

Also makes some minor improvements to the reports generated by
CrashReporter. The way CrashReporter works with Background Music should
otherwise be unchanged.
2017-10-23 20:19:42 +11:00
Kyle Neideck 47ff99303a Fix compiler warnings in Xcode 9.
Mostly -Wpartial-availability. Fixes #129.
2017-10-12 22:20:28 +11:00
Kyle Neideck 173914e343 Add Xcode 9.0 and 9.1 to .travis.yml. 2017-10-11 09:01:05 +11:00
Kyle Neideck cce8111251 Add an App Volumes submenu for apps that aren't shown in the dock.
Specifically, apps with NSApplicationActivationPolicyAccessory. That
includes status bar apps like Background Music, but also includes some
that aren't intended to be shown to users as applications like
SystemUIServer.

I'm not sure how much we can do about that. It would probably help if we
hid apps that BGMDriver isn't able to match to a CoreAudio client.

Resolves #122.
2017-09-25 13:05:00 +10:00
Kyle Neideck df67e4fa2b Accessibility improvements for the volumes menu items. 2017-09-18 15:21:32 +10:00
Kyle Neideck e4b98e4099 Add a UI test for the output volume slider. 2017-09-17 16:43:51 +10:00
Kyle Neideck 9fd5c89b27 Move the code for the output volume slider into a new class. 2017-09-16 22:09:03 +10:00
Kyle Neideck 3c001066c4 Set the output volume slider to 0 when the output device is muted. 2017-09-14 00:06:24 +10:00
Kyle Neideck 289f6b3d27 Fix a crash in BGMBackgroundMusicDevice::ResponsibleBundleIDsOf.
If the bundle ID passed to the function (a CACFString) was wrapping a
null CFStringRef, one of the comparison operator functions of CACFString
would pass null to CFStringCompare.
2017-09-12 20:09:04 +10:00
Kyle Neideck 07a419fb34 Add an output volume slider above the app volume sliders.
Similar to the one in macOS's Volume menu extra.

I'm mainly adding it so we can increase the output volume when the user
sets an app volume above 50%. Currently, setting an app volume above 50%
(the default) risks clipping, so it doesn't make sense to do so unless
your main output volume is at its max.

The volume slider added in this commit will make it clear to the user
that their main output volume is also increasing.

The other app volumes won't change, so in the ideal case the user
wouldn't need to be aware that their output device's volume is being
changed. But they might play audio to the device directly and would
expect it to play at the same volume as before they changed the app
volume.
2017-09-12 19:48:42 +10:00
Kyle Neideck 283db29fb4 App Volumes: Fix the hardcoded HAL client process bundle IDs.
Fix the workaround for apps whose bundle IDs don't match their CoreAudio
clients. BGMBackgroundMusicDevice::ResponsibleBundleIDsOf was always
returning an empty list.

Also fix over-releasing the app's bundle ID CFString in
BGMAVM_VolumeSlider::appVolumeChanged and
BGMAVM_PanSlider::appPanPositionChanged.

Both bugs were introduced two commits ago in
e05acde351.
2017-08-06 22:47:57 +10:00
Kyle Neideck 02558cd275 BGMApp: Move some more code into BGMBackgroundMusicDevice.
Mostly code for getting and setting BGMDevice's custom properties. Also
adds some stricter checking for property data received from BGMDevice.
2017-08-06 22:16:17 +10:00
Kyle Neideck e05acde351 BGMApp: Move functions for talking to BGMDevice into their own class.
There's still some code left that should be moved into the new class,
BGMBackgroundMusicDevice, but I think this is most of it.

This also helps reduce/contain the code that has to be aware of the
second instance of BGMDevice, which handles UI-related audio.
2017-08-05 21:15:10 +10:00
Kyle Neideck 09aacabefa Support Finder's Quicklook in App Volumes
Resolves #124.
2017-08-01 21:51:29 +10:00
Kyle Neideck b715212cab Ignore UI sounds when auto-pausing.
On macOS, apps are supposed to play UI-related sounds using the "system
default" device. This commit creates a new instance of BGM_Driver, which
BGMApp sets as the system default device. BGMApp ignores audio played to
that device when deciding whether to pause/unpause the user's music
player.

Since UI sounds are short, this helps avoid pausing the music player and
unpausing it shortly after.
2017-07-30 18:16:25 +10:00
Kyle Neideck a6e9179f2d Travis: Skip packaging with Xcode 7.
It seems to be failing because of the "-enableAddressSanitizer NO"
option in build_and_install.sh, but I haven't looked into it.
2017-06-25 14:04:01 +10:00
Kyle Neideck ccac7d7001 Rename BGMDevice from "Background Music Device" to "Background Music".
Resolves #116.
2017-06-25 12:39:34 +10:00
Kyle Neideck 243c798ccd Disable AddressSanitizer in release builds. 2017-06-24 22:28:11 +10:00
Kyle Neideck a4a9530513 Change build dir back in build_and_install.sh to fix package.sh. 2017-06-24 21:40:17 +10:00
Kyle Neideck 9aec1aed34 Enable AddressSanitizer by default in debug builds and tests.
Except when running BGMDriver because you have to change coreaudiod's
sandbox profile for that. And you have to disable SIP (rootless) to do
that.
2017-06-24 20:20:16 +10:00
Kyle Neideck 7f784b5d94 Add client bundle ID for Parallels to App Volumes.
Fixes #86.
2017-06-23 00:49:27 +10:00
Kyle Neideck c907f13554 Partial fix for apps' bundle IDs not matching their HAL clients.
Some apps have different bundle IDs to their CoreAudio clients, so
the bundle ID BGMApp sends with the app's volume doesn't match any
client in BGMDriver.

This change hardcodes the bundle IDs used by some popular apps and the
bundle IDs their clients use. We should be able to fix this for all
(almost all?) apps at some point.
2017-06-22 19:36:17 +10:00
Kyle Neideck 5a657a01a6 Fix dropped frames when starting IO.
It turns out that the HAL will sometimes call BGM_Driver::StartIO before
sending kAudioDevicePropertyDeviceIsRunning to BGMPlayThrough. In that
case, BGMApp would start playthrough and tell BGMDriver to return from
StartIO immediately, which meant we would drop the initial frames while
the output device started up.

So now BGMApp waits for the output device in that case as well.

Fixes #7.
2017-06-22 19:36:17 +10:00
Kyle Neideck aa8d9ae518 Increment minor version number. 2017-06-22 19:36:17 +10:00
Kyle Neideck 36b4b7e0c4 README.md: Move build info out of the Download section. 2017-06-18 22:16:09 +10:00
Kyle Neideck 462bdee9cd Fix formatting in README.md. 2017-06-18 22:03:39 +10:00
Kyle Neideck bf9faaef55 Link to the 0.1.1 pkg installer in README.md. 2017-06-18 21:07:42 +10:00
Kyle Neideck 6d2fd39296 Split uninstall.sh into an interactive and non-interactive version.
The non-interactive version can be called by a Homebrew Cask formula.

Also, change some AppleScript to reference applications by their IDs
rather than their names, which should make them slightly more robust.
2017-06-17 21:56:42 +10:00
Kyle Neideck 3732eceed8 Increase the version number. (But just the patch level.) 2017-06-17 18:01:09 +10:00
Kyle Neideck a7750f7d1e Travis: Only publish releases for tags. 2017-06-14 13:54:45 +10:00
Scott Humphries 76c660292e Temporarily remove travis branch restriction 2017-06-14 13:28:29 +10:00
Kyle Neideck d322c3ac9b Never show warning dialog box in command-line pkg installs.
Also,
 - add Background Music Device.driver to the array of bundles in
   pkgbuild.plist, and
 - don't warn about the permissions of the install dir for BGMXPCHelper
   in build_and_install.sh if it's only building.
2017-06-13 23:18:10 +10:00
Kyle Neideck 45519a4d52 Fix BGMDriver's version number. 2017-06-12 17:22:34 +10:00
Kyle Neideck ed2f356570 Try to fix Travis not finding the files to release.
Also, make sure commands in uninstall.sh use the binaries that ship with
OS X and add the release files to .gitignore.
2017-06-12 14:38:26 +10:00
Scott Humphries 4d80e1f38f Fix deploy script 2017-06-12 14:04:50 +10:00
Kyle Neideck ca56d8d0b2 travis setup releases 2017-06-12 13:52:59 +10:00
Kyle Neideck 5e4556b49d Add packaging script and (possibly) support for OS X 10.9. 2017-06-11 19:19:31 +10:00
Kyle Neideck 8d1adf25bb BGM_Device: Fix over-releasing custom property data.
BGM_Device::Device_SetPropertyData was releasing the CFArray it gets
from the host (i.e. coreaudiod) when BGMApp sets
kAudioDeviceCustomPropertyEnabledOutputControls, which would deallocate
it, but coreaudiod also releases that CFArray.

Found with AddressSanitizer.
2017-06-04 23:31:56 +10:00
Kyle Neideck c33941a22b Disable -Wpartial-availability in BGMDriver tests. 2017-06-04 01:31:31 +10:00
Kyle Neideck ec7128495f Skip setting NSMenuItem.accessibilityTitle on OS X < 10.12.
This should also fix compilation with the 10.11 SDK.

Also enabled -Wpartial-availability and raised the deployment target to
OS X 10.9.
2017-06-03 23:17:34 +10:00
Kyle Neideck 6fc46c7943 uninstall.sh: Increase the filesize limit for deletion.
As a safety check, uninstall.sh refuses to delete a file if it's over a
certain size. With debug symbols, Background Music.app was just over the
previous 5MB limit.
2017-06-01 23:54:36 +10:00
Kyle Neideck b986b687ea Fix exception in Mock_CAHALAudioObject during BGMApp unit tests.
Also, avoid initialising BGMDeviceControlsList in
BGMMockAudioDeviceManager::init.
2017-06-01 20:10:35 +10:00
Kyle Neideck c617d98f9d BGMDevice: Only enable volume/mute if the output device also has them.
BGMApp now disables BGMDevice's volume and/or mute controls if the
output device selected in BGMApp doesn't have matching controls. This
prevents the controls from being presented to the user when they don't
do anything.

In BGMPlayThrough, wait much longer for our IOProcs to stop themselves
before assuming something's gone wrong. In testing, rapidly changing
between output devices with and without controls while playing audio
would occasionally cause one of the IOProcs to take too long to stop
itself.

Also adds some basic scriptability, mainly so UI tests can use
AppleScript to check BGMApp's state that would be complicated to check
otherwise. (In this case, to check which output device is selected.)

Fixes #101.
2017-05-30 23:22:48 +10:00
Kyle Neideck 4839ea8a4b Remove Xcode 6 from the Travis build matrix. 2017-05-30 23:22:48 +10:00
Kyle Neideck 612e249e1b Fix BGMApp crash when BGMDriver isn't installed. 2017-05-07 16:02:50 +10:00
Kyle Neideck d4df6107bd Add "Sound Control" to Related Projects in README.md 2017-05-07 15:17:48 +10:00
Kyle Neideck f5628e78a9 Fix some bugs in GitHub's rendering of README.md.
Not sure why these parts stopped rendering correctly on GitHub. Might be on their end.
2017-04-09 18:25:27 +10:00
Kyle Neideck 728a3a7331 build_and_install.sh: Offer fix when xcodebuild can't find Xcode.app.
If the Xcode command line tools were set to use a "command line tools
instance", which can be installed without having Xcode installed,
build_and_install.sh would fail. It prints an error message with a
command that can fix it if you do have Xcode installed, but the message
was kind of confusing and the command would fail if you didn't run it as
root.

build_and_install.sh now offers to run the command for you and then
continues the installation. I've also tried to make the message a bit
clearer and cleaned up some of the code.

Also fixes another bug that occurred with this configuration problem,
where the error from xcodebuild would be printed at an unintended (and
confusing) point in the script.

Fixes #108.
2017-04-09 17:35:27 +10:00
Kyle Neideck f254e8c58d uninstall.sh: Open System Preferences pane by ID instead of by name.
The Applescript that opened System Preferences at the end of the process
was failing to find the "Sound" pane. It might have been because I don't
have OS X set to English, but it's always worked for me in the past.
Either way, it's less fragile to use the ID and it fixes the problem (on
my machine, at least).

Also, added a short pause before restarting coreaudiod because the step
before that makes Finder to play a short sound. It probably wouldn't
cause any problems, but why risk it?
2017-04-09 15:44:46 +10:00
Kyle Neideck 5f9487deb0 Add PublicUtility to manual build instructions and...
...make them less likely to fail because of permissions errors.
2017-04-09 15:40:25 +10:00
Kyle Neideck 6a26afe47a Fix nullability warning in BGMXPCHelper. Also add Xcode 8.3 to .travis.yml.
Fixes #107.
2017-04-05 22:15:54 +10:00
Kyle Neideck 78e2813af1 Fix encoding issue on Travis caused by "©" char in Python script. 2017-02-19 20:50:11 +11:00
Kyle Neideck 87af15d290 Skip the UI tests on Travis by directly editing BGMApp's Xcode scheme.
Skipping them by overriding runTest didn't work and this is the only other way
I can think of. xcodebuild's -skip-testing option would work, but only with
recent versions of Xcode.
2017-02-19 20:25:54 +11:00
Kyle Neideck 7b32b6ef66 Skip the UI tests on Travis because it doesn't support UI testing. 2017-02-19 17:43:45 +11:00
Kyle Neideck 32723ff04b Append the git HEAD short ID to the build version for snapshot builds. 2017-02-19 15:14:34 +11:00
Kyle Neideck 60e1b3564b Add a UI tests target for BGMApp. Only has one test so far.
The UI tests run with clean user defaults, but BGMDevice and Scripting Bridge
still need to be mocked/stubbed out. That also means that the UI tests can only
run if BGMDriver is installed and that changes to BGMDriver's state made during
the tests will persist.
2017-02-19 13:39:34 +11:00
Kyle Neideck d49ff20820 Add builds with Xcode 8.1 and 6.4 to .travis.yml.
6.4 should already have been retired according to their documentation, so it
will probably need to be removed. But it would be nice to have because it's the
only OSX 10.10 image.
2017-02-19 12:55:20 +11:00
Kyle Neideck 9b5d5bf921 Remove BGMAppTests, which was essentially empty. 2017-02-18 18:59:32 +11:00
Kyle Neideck 07c1c2320b Reorganise the BGMApp test dirs slightly. 2017-02-18 18:12:40 +11:00
Kyle Neideck 523ad02761 Add -w option to build_and_install.sh, which passes -Wno-error to the compiler.
-Wno-error tells the compiler not to treat warnings as errors.

Using the option in the one-liner install command in README.md, since it's
mostly used by users rather than developers.

Also, log the options passed to build_and_install.sh in build_and_install.log.
2017-02-16 22:54:38 +11:00
Kyle Neideck 3ee563e4c5 Fix Travis again.
It seemed to think commands starting with ! were tags.
2017-02-16 00:26:04 +11:00
Kyle Neideck e5c406da16 Fix ls command in .travis.yml failing the macOS 10.12 build.
Also, add some simple tests for build_and_install.sh and uninstall.sh to
.travis.yml.
2017-02-15 23:53:14 +11:00
Kyle Neideck b5cf6de2ac Update OSX image versions in .travis.yml. Add some files to the Xcode project. 2017-02-15 22:53:02 +11:00
Kyle Neideck 8257f49b46 Merge pull request #98 from rakslice/pan 2017-02-14 23:32:21 +11:00
Kyle Neideck cdea147010 Move the pan sliders into an "extra controls" section of the menu items.
Also add centre tick marks and "L"/"R" (left/right) labels to them.

The idea is to eventually include extra controls like an equalizer, recording
apps, hiding/ignoring apps, routing apps, etc.

Also, remove the left margin from the App Volumes menu items. Even macOS isn't
consistent about including that margin, as far as I can tell.
2017-02-11 16:47:52 +11:00
Kyle Neideck a91615fc5e Fix a deadlock when changing output device while IO is running.
BGM_Device::StartIO blocks on
BGMAudioDeviceManager::waitForOutputDeviceToStart, which could be blocked by
HAL requests that the HAL wouldn't return until BGM_Device::StartIO returned.

Also:
 - Replace BGMPlayThrough's move constructor with a SetDevices function for
   simplicity.
 - Pause/abort debug builds if an error is logged.
2017-02-01 09:09:00 +11:00
Kyle Neideck 467b072a9d Don't throw in BGMPlayThrough::DestroyIOProcIDs if the device has been removed.
Also, default to only aborting debug builds when they log and swallow an
exception if the exception was unexpected. That is, the developer didn't
realise the code could throw.
2017-01-27 00:33:34 +11:00
Kyle Neideck a62fae6fd1 Merge branch 'pan' of https://github.com/rakslice/BackgroundMusic into rakslice-pan 2017-01-18 21:49:20 +11:00
Kyle Neideck 129c21a180 Add BGM_STOP_DEBUGGER_ON_LOGGED_EXCEPTIONS preprocessor flag. Also, add...
an option to build_and_install.sh for passing extra options to xcodebuild.
2017-01-16 23:58:19 +11:00
Kyle Neideck 2d135838fa Add more error handling and logging to BGMPlayThrough. 2017-01-16 23:54:09 +11:00
Andrew Tonner acf3976b9b remove suprious setCellClass call; cleanup 2017-01-09 14:58:05 -08:00
rakslice 61ce9ef165 better custom slider hack 2017-01-02 04:21:56 -08:00
rakslice f6ac51a334 fixed typo 2017-01-02 02:43:40 -08:00
rakslice 94c594b342 added per-app pan sliders 2017-01-02 02:30:00 -08:00
Kyle Neideck d7e3980af8 Warn if uninstall.sh is run as root.
Also, add some fallback commands so it will work correctly, more or less, when
run as root in case someone ever wants to do that.

Fixes #95.
2016-12-31 21:56:00 +11:00
Kyle Neideck 2cb572ecf4 Revert "Fix default icon shown for BGMApp bundle."
This reverts commit a4160d370d.

I've tested the icon bug on a couple of other machines now and haven't been
able to reproduce it. My "fix" did cause the bug on those machines, though,
despite fixing it on mine. Still not sure exactly what's going on, but it seems
to be a problem with my development environment.
2016-12-31 21:05:04 +11:00
Kyle Neideck 8d5ea9c67c Auto-pause: make the unpause delay proportional to the pause duration.
We only unpause the music player, after auto-pausing it, if it's been
paused for longer than some minimum length of time. This commit reduces
that time if the music player hasn't been paused for long.
2016-12-29 02:25:44 +11:00
Kyle Neideck 651b91aeee Fix a link in the Install section of README.md. 2016-12-28 05:04:54 +11:00
Kyle Neideck 12840cbf31 Superficial fixes in the Install section of README.md. 2016-12-28 05:00:52 +11:00
Kyle Neideck f15708461d Add some info about logging to CONTRIBUTING.md. (Also, some clean up.) 2016-12-28 04:43:43 +11:00
Kyle Neideck a4160d370d Fix default icon shown for BGMApp bundle.
I'm not sure why this is necessary or why it works. Probably an Xcode
bug or something. This is with Xcode 8.2.1 on macOS 10.12.3 Beta
(16D17a).
2016-12-26 21:46:30 +11:00
Kyle Neideck 847313a174 Log debug messages to syslog and include debug symbols in BGMApp bundle.
In BGMApp, messages logged with the DebugMsg macro now go to syslog
instead of stdout.

People running standalone BGMApp debug builds (i.e. not in Xcode) should
be able the find the debug logs more easily. Xcode still shows the debug
logs normally when running BGMApp in Xcode.

Also, debug symbols (the .dSYM directory) are now included in the
Background Music.app bundle. (In both debug and release builds.)
CrashReporter is able to find these and use them to symbolicate BGMApp
crash logs.
2016-12-26 21:27:41 +11:00
Kyle Neideck 7992a5708c Use data source names instead of device names in the output device menu.
The names of the data source(s) for a device are generally the names
intended to be shown to the user, since the OS X volume menu, System
Preferences, etc. use them.

A menu item is now added for each data source of each output device,
rather than one per device.

Also adds some macros/functions for casting values to __nonnull.

Resolves #59.
2016-12-23 01:46:27 +11:00
Kyle Neideck 31b501e832 Make BGMPlayThrough::WaitForOutputDeviceToStart noexcept. 2016-12-16 21:54:08 +11:00
Kyle Neideck ab9d4cdc2b Add more exception handling to BGMApp...
And other reliability improvements. Mostly in BGMPlayThrough and the
classes that use it. Trying to catch C++ exceptions as early as possible
in the Objective-C++ code and, if necessary, convert them to NSErrors.

More errors are logged in release builds now, which will hopefully help
with debugging issues the developers can't reproduce themselves.
2016-12-15 03:20:07 +11:00
Kyle Neideck e0acb34f29 Assume non-null in BGMDeviceControlSync.cpp. 2016-11-14 22:13:46 +11:00
Kyle Neideck ec87adb6e9 Allow MainMenu.xib to open in Xcode 7. Mostly to fix the tests in 7. 2016-11-14 21:58:23 +11:00
Kyle Neideck 810b2ed462 Fix BGMApp unit tests. 2016-11-13 01:32:26 +11:00
Kyle Neideck 035faa615f Fix link in README. 2016-11-13 00:28:26 +11:00
Kyle Neideck c8f6790274 Add a link to the about panel and refactor its code.
Link to the project website (GitHub) in the About Background Music
window, and move its code into its own class.

Also, update the copyright notices in the UI and README.
2016-11-13 00:06:58 +11:00
Kyle Neideck b0bfebedc4 Add Hermes music player. (A Pandora client.)
Closes #83.
2016-11-12 20:54:04 +11:00
Kyle Neideck da74e5ea1d Check that the user's accepted the Xcode license before installing.
If they haven't, xcodebuild would fail.
2016-10-08 19:33:38 +11:00
Kyle Neideck a5fb68ad2b Fix permissions error in Travis build. 2016-10-07 07:41:08 +11:00
Kyle Neideck 61a9d89ef9 Do Travis builds on a case-sensitive .dmg to catch failures.
Compiling on case-sensitive filesystems has been broken a couple of
times, so hopefully this will let us catch some of those bugs early.
2016-10-06 19:20:17 +11:00
Kyle Neideck b2a12b3e37 Change "Vox" to "VOX" in filenames.
Fixes #77: build fails on case-sensitive filesystems.
2016-10-05 22:07:58 +11:00
Kyle Neideck d38ea256cd Don't quit BGMApp if BGMXPCHelper is missing. See #76. 2016-09-26 11:08:19 +10:00
Kyle Neideck e31f2b1c29 Refactor and clean up BGMApp's music player code and add tests. Also...
- Destroy the Scripting Bridge application object for a music player
  when that music player isn't running.
- Move the UI code for the auto-pause menu item into its own class.
- Add a User Defaults class to BGMApp.
- Enable some more warnings for the BGMApp project.
2016-09-17 18:24:19 +10:00
Kyle Neideck 59aa04c9bc Use dot notation for properties in BGMAppVolumes.mm 2016-09-11 15:33:23 +10:00
Kyle Neideck cbbd48dcee Show Finder in the app volumes menu. Fixes #45. 2016-09-08 00:25:03 +10:00
Kyle Neideck c91af08d54 Enable more warnings in BGMDriver.
The PublicUtility classes are now built as a separate target so we can
disable some of those warnings for it.
2016-09-08 00:24:57 +10:00
Kyle Neideck 484ffa16f3 BGMDriver: Clean up constructor initializer lists.
See https://isocpp.org/wiki/faq/ctors#ctor-initializer-order.
2016-09-06 22:18:11 +10:00
Kyle Neideck 4e091c7398 Merge pull request #73 from Qix-/patch-1
Short circuit audible loop
2016-09-04 00:56:51 +10:00
Josh Junon 808fe1b6b3 Short circuit audible loop
Slight micro-opt that will short circuit the `BufferIsAudible` loop if we've already found something audible.

Was looking through your IO layout and noticed this.
2016-09-02 12:14:29 -07:00
Kyle Neideck 758fe02c7a Add OS X 10.10 and Xcode 8 builds to .travis.yml. 2016-08-23 19:42:07 +10:00
Kyle Neideck 23fd57713d Fix nullability warnings in builds using the macOS 10.12 SDK.
Fixes #70.
2016-08-22 00:42:55 +10:00
Kyle Neideck dad87b57b6 Add a summary to DEVELOPING.md and update a few sections.
The summary is originally from #66.
2016-08-17 23:22:18 +10:00
Kyle Neideck 679d624860 Add BGM_Driver tests: get/set the music player bundle ID property. 2016-07-04 16:32:59 +10:00
Kyle Neideck 55e9f60774 Merge branch 'hoke-t-Decibel'. Resolves #17. 2016-06-30 09:32:12 +10:00
Kyle Neideck e3fcbdb37e Add BGMDecibel files to the Xcode project. 2016-06-30 09:30:05 +10:00
Kyle Neideck 1ee9fa348e Fix build failure on case-sensitive file systems. Fixes #64.
It seems that BGMDriver was failing to compile on case-sensitive file
systems because BGM_Types.h included "AudioServerPlugin.h" instead of
"AudioServerPlugIn.h". (Lowercase "i".)

I tried building with the project and Xcode on a case-sensitive disk
image and it would fail without this patch. So I figure it should at
least build now. I haven't had time to test Background Music on a system
running on a case-insensitive file system yet, so I added a TODO about
it in TODO.md.

Also, some unrelated tidying up.
2016-06-16 18:38:29 +10:00
Kyle Neideck 3684483543 Merge branch 'Decibel' of https://github.com/hoke-t/BackgroundMusic into hoke-t-Decibel 2016-05-14 10:42:50 +10:00
Tanner Hoke 30a1735346 git rm extraneous Decibel files and use HTTPS links in README.md 2016-05-12 17:24:55 -05:00
Kyle Neideck 4dba9412fb Install NullAudio driver before tests run on Travis. 2016-05-11 10:02:31 +10:00
Kyle Neideck 90bceb9887 On Travis, quit BGMApp before running the tests.
Also, log the installed audio devices and their audio IDs.

I think the tests are probably failing because the Travis VMs don't have
any audio devices. If so, this won't actually fix the tests, but it
should help us narrow it down.
2016-05-11 02:34:54 +10:00
Kyle Neideck 6c9ca6e85c Merge pull request #53 from Piccirello/master
Add shortcut for changing output device from menu bar
2016-05-11 01:27:29 +10:00
Kyle Neideck 82ea2e4803 Merge pull request #27 from hoke-t/master
Disable the Auto-pause Music menu item if the selected music player isn't
running
2016-05-11 00:19:49 +10:00
Kyle Neideck bbe65a2431 Only pretend to disable the auto-pause menu item.
Instead of disabling the menu item when the music player isn't running,
just make it appear disabled. That way you can always disable auto-pause
without having to open your music player, but the UI still indicates
when it thinks the music player isn't running.
2016-05-11 00:08:46 +10:00
Kyle Neideck a229791ade Merge branch 'master' of https://github.com/hoke-t/BackgroundMusic into hoke-t-master 2016-05-10 07:06:57 +10:00
Piccirello be4135523e Add shortcut for changing output device from menu bar 2016-05-03 16:36:53 -04:00
Kyle Neideck b58ad2a1f8 Fix possible deadlock when starting IO.
BGM_Device::StartIO was holding the state mutex longer than it needed
to, which meant HasProperty, GetProperty, etc. couldn't return. If
BGMPlayThrough was notified about IO starting after StartIO locked the
mutex, BGMPlayThrough would get stuck trying to get one of BGMDevice's
properties.

Fixes #46.
2016-04-30 21:28:16 +10:00
Kyle Neideck 960fe0d28d Fix rare race condition in BGM_TaskQueue (hopefully).
Also enable a few more warnings in the BGMDriver project.
2016-04-30 20:50:29 +10:00
Kyle Neideck d827e7e0d8 Fix build script printing a git error when run outside of a git repo. 2016-04-29 13:29:19 +10:00
Kyle Neideck 34071e633f Add an install "one-liner" to the README.
Also fix a bug when running build_and_install.sh from a directory other
than the root of the project.
2016-04-29 13:18:52 +10:00
Kyle Neideck d021dff7a6 Merge pull request #50 from ZV95/patch-1
Addition of Wiki Install Instructions
2016-04-29 12:20:27 +10:00
Kyle Neideck e95f371305 Add debug build option to build script.
Also add info about logging to CONTRIBUTING.md.
2016-04-29 12:09:37 +10:00
Kyle Neideck 6fb281c3ce Merge pull request #30 from IgorMarques/improve/readme
Improve install instructions
2016-04-29 10:57:07 +10:00
Kyle Neideck 3f62b012c3 Fix build script failure if Xcode check finishes before install starts. 2016-04-29 07:41:06 +10:00
ZV95 c70a22dd24 Addition of Wiki Install Instructions
Tried to make a simple install instructional Wiki for those who are confused about installation. Simply adding a link to that wiki in this ReadMe.
2016-04-28 13:22:38 -04:00
Kyle Neideck ccb709fc02 Fix "sudo -v" in uninstall.sh causing Travis builds to fail. 2016-04-28 10:11:19 +10:00
Kyle Neideck 480d769c26 Fix race condition in build script. Also avoid 'sudo -v' on Travis CI. 2016-04-28 09:33:23 +10:00
Kyle Neideck 3eac1f5dab Allow sudo on Travis CI. 2016-04-28 08:49:35 +10:00
Kyle Neideck eda3505f1a Add initial .travis.yml to see if it works. 2016-04-27 02:49:35 +10:00
Kyle Neideck 44082ac920 Add manual installation instructions (mostly for troubleshooting) 2016-04-27 02:31:14 +10:00
Kyle Neideck f2a0898590 Build script: Check Xcode version in the background to launch quicker. 2016-04-27 02:28:47 +10:00
Kyle Neideck b707513e49 Lots of small improvements to the build script.
- Clean before installing. (Mostly to get full logs every time.)
- Clearer error messages.
- Better checking for Xcode/xcodebuild.
- Log extra system info.
- A number of minor bug fixes.
2016-04-26 21:04:13 +10:00
Kyle Neideck 8acc5d4c9e Change the min and max sample rates in BGM_Driver. 2016-04-25 08:38:45 +10:00
Tanner Hoke ad5fe4ecbb Update README.md 2016-04-19 10:53:10 -05:00
Tanner Hoke 7cc0d19182 Put music files in the correct group. 2016-04-19 10:43:24 -05:00
Igor Marques 9a5ed7c2b5 Improve install instructions 2016-04-19 10:06:27 -03:00
Tanner Hoke 5981e05bb1 Add support for Decibel 2016-04-18 21:37:29 -05:00
Tanner Hoke e87c43dc52 Disable the Auto-pause Music menu item if the selected music player isn't running 2016-04-18 20:15:28 -05:00
200 changed files with 27814 additions and 4481 deletions
+15
View File
@@ -0,0 +1,15 @@
# This file was added to get GitHub to display our source code correctly when
# it has mixed tabs and spaces. (I didn't realise the sample code BGMDriver is
# based on used tabs and it's too late to fix it now.)
#
# See http://editorconfig.org.
# This is the top-most .editorconfig file.
root = true
# Set tabs to the width of 4 spaces in C, C++, Objective-C and Objective-C++
# source files.
[*.{h,c,cpp,m,mm}]
tab_width = 4
+7
View File
@@ -2,6 +2,13 @@
.*.swp
/BGMDriver/BGMDriver/quick_install.conf
/build_and_install.log
.idea/
tags
cmake-build-debug/
/Background-Music-*/
BGM.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
Images/*.aux
Images/*.log
# Everything below is from https://github.com/github/gitignore/blob/master/Objective-C.gitignore
+124
View File
@@ -0,0 +1,124 @@
language: objective-c
matrix:
include:
- os: osx
osx_image: xcode11
xcode_sdk: macosx10.14
sudo: required
env: DEPLOY=true
- os: osx
osx_image: xcode10.1
xcode_sdk: macosx10.14
sudo: required
- os: osx
osx_image: xcode10
xcode_sdk: macosx10.14
sudo: required
- os: osx
osx_image: xcode9.4
xcode_sdk: macosx10.13
sudo: required
- os: osx
osx_image: xcode9.3
xcode_sdk: macosx10.13
sudo: required
- os: osx
osx_image: xcode9.2
xcode_sdk: macosx10.13
sudo: required
# Fails to compile in 8.3, but it isn't clear from the logs why it fails.
# - os: osx
# osx_image: xcode8.3
# xcode_sdk: macosx10.12
# sudo: required
- os: osx
osx_image: xcode7.3
xcode_sdk: macosx10.11
sudo: required
env: PACKAGE=false
# branches:
# only:
# - master
install:
# Install Apple's NullAudio device. Travis' VMs don't have any audio devices installed.
- sudo xcodebuild -project BGMApp/BGMAppTests/NullAudio/AudioDriverExamples.xcodeproj -target NullAudio DSTROOT="/" install
- sudo launchctl kickstart -kp system/com.apple.audio.coreaudiod || sudo killall coreaudiod
script:
# Build in a case-sensitive disk image to catch failures that only happen on case-sensitive filesystems.
- hdiutil create -type SPARSEBUNDLE -fs 'Case-sensitive Journaled HFS+' -volname bgmbuild -nospotlight -verbose -attach -size 100m bgmbuild.dmg
- sudo cp -r . /Volumes/bgmbuild
- cd /Volumes/bgmbuild
# Install Background Music.
- yes | ./build_and_install.sh
# Print the log file, but put it in a fold because it's so long.
- echo -en 'build_and_install.log\ntravis_fold:start:build.log\\r'
- cat build_and_install.log
- echo -en 'travis_fold:end:build.log\\r'
- find */build/Release/*/ -type f -exec md5 {} \;
# Log the installed audio devices...
- system_profiler SPAudioDataType
# ...and their IDs.
- say -a '?'
# Check the BGM dirs and files were installed. (These fail if the dir/file isn't found.)
- ls -la "/Applications/Background Music.app"
- ls -la "/Library/Audio/Plug-Ins/HAL/Background Music Device.driver"
- ls -la "/usr/local/libexec/BGMXPCHelper.xpc" || ls -la "/Library/Application Support/Background Music/BGMXPCHelper.xpc"
- ls -la "/Library/LaunchDaemons/com.bearisdriving.BGM.XPCHelper.plist"
# Close BGMApp (which the install script opened).
#
# The killall fallback command is necessary because the AppleScript gets "user canceled" on Travis'
# Xcode 9 images for some reason.
- osascript -e 'tell application "Background Music" to quit' || killall "Background Music"
# Skip the UI tests until Travis has support for them.
- BGMApp/BGMAppTests/UITests/travis-skip.py
# Run the tests.
# The echo commands put the output into a fold in the Travis logs.
- echo -en 'Unit Tests\ntravis_fold:start:tests\\r'
- xcodebuild -workspace BGM.xcworkspace -scheme 'Background Music Device' test
- xcodebuild -workspace BGM.xcworkspace -scheme 'Background Music' test
- xcodebuild -workspace BGM.xcworkspace -scheme 'BGMXPCHelper' test
- echo -en 'travis_fold:end:tests\\r'
# Uninstall Background Music.
- yes | ./uninstall.sh
# Check the BGM dirs and files were removed.
- if ls -la "/Applications/Background Music.app"; then false; fi
- if ls -la "/Library/Audio/Plug-Ins/HAL/Background Music Device.driver"; then false; fi
- if ls -la "/usr/local/libexec/BGMXPCHelper.xpc"; then false; fi
- if ls -la "/Library/Application Support/Background Music/BGMXPCHelper.xpc"; then false; fi
- if ls -la "/Library/LaunchDaemons/com.bearisdriving.BGM.XPCHelper.plist"; then false; fi
# Return early if we're not testing packaging on this OS X version.
- if [[ "$PACKAGE" == "false" ]]; then exit 0; fi
# Build the .pkg installer. Print the build logs if it fails. If this build is for a tag with
# "DEBUG" in its name, build a debug package. (More detailed logging, no optimization, etc.)
- if [[ "$TRAVIS_TAG" =~ .*DEBUG.* ]]; then
./package.sh -d || (cat build_and_install.log && travis_terminate 1);
else
./package.sh || (cat build_and_install.log && travis_terminate 1);
fi
# Install the .pkg.
- sudo installer -pkg Background-Music-*/BackgroundMusic-*.pkg -target / -verbose -dumplog
# Check the BGM dirs and files were installed again.
- ls -la "/Applications/Background Music.app"
- ls -la "/Library/Audio/Plug-Ins/HAL/Background Music Device.driver"
- ls -la "/usr/local/libexec/BGMXPCHelper.xpc" || ls -la "/Library/Application Support/Background Music/BGMXPCHelper.xpc"
- ls -la "/Library/LaunchDaemons/com.bearisdriving.BGM.XPCHelper.plist"
# Post on IRC when Travis builds finish.
notifications:
irc: "irc.freenode.org#backgroundmusic"
# Upload the .pkg and dSYM zip to GitHub.
deploy:
provider: releases
api_key:
secure: j5GdMTkJI/9lfGMcAW4dnBnfNSW0EUGSuaKSXw49FfjfcshLL2RFxIbQkyA7QqjoJm6ohstU3tOCo7c9FrqIWjE/+5itGJpq7NXDRxFtd2qzcli1u+1IRvQUZJ4VYC9982pSS0IUynK9/f0rhbdkWsCuXWIjoClYPBRscc8soDBJvkDbfilPFfFgkc8TuSmtGDCdu9coGVi6b9HuTLNQU0g5DZkjmv71Vj3SwJ2CmvOk3GFfV1SjvG2SRgBDwyP1g9MRGRiNYkmK9lJRgsq2KLluzb04lt22x8RIcZ+kZYOQVmgDlCeWlOcXi0iz1wU/QzdoYFEAnJdG4q0hqKeqIi+p8Tc31nHPuc1ZlYpifzMQ6KuOoOP19eceJwriAT133t2RSB3Rl3nxh9bymNPNyQ2dJwGNFtO68f3aZsuE5L92lVgW/ipZ6e5Sw1ovXldR04mxNtyY4WvFXFlkn/776tKV0vgAubsHfceGM/aRoBj+E2gDvqkFqIR8wrZAZEeSM2reMHPMx5ICFppIZ8dCIVjF5bsxZQsbojY+LXV8BUU5kLAou0yD7Q+lHi9r3HYdN90+cC02HKGFYzsIiMAyf4IAngnLhwmmrLOwr3wWdACjYTJhznAZGNJh4lCeB4dx85iyj3EexJ6J/DL1k2+ZNKyMN3+i/215t+AvSsXuw5U=
file_glob: true
file: Background-Music-*/*
skip_cleanup: true
name: $TRAVIS_TAG
prerelease: true
on:
repo: kyleneideck/BackgroundMusic
tags: true
# TODO: Use "condition" to build master and tags?
condition: $DEPLOY = true
+24
View File
@@ -10,15 +10,39 @@
<FileRef
location = "group:README.md">
</FileRef>
<FileRef
location = "group:MANUAL-INSTALL.md">
</FileRef>
<FileRef
location = "group:MANUAL-UNINSTALL.md">
</FileRef>
<FileRef
location = "group:TODO.md">
</FileRef>
<FileRef
location = "group:DEVELOPING.md">
</FileRef>
<FileRef
location = "group:CONTRIBUTING.md">
</FileRef>
<FileRef
location = "group:LICENSE">
</FileRef>
<FileRef
location = "group:LICENSE-Apple-Sample-Code">
</FileRef>
<FileRef
location = "group:build_and_install.sh">
</FileRef>
<FileRef
location = "group:uninstall.sh">
</FileRef>
<FileRef
location = "group:package.sh">
</FileRef>
<FileRef
location = "group:pkg">
</FileRef>
<FileRef
location = "group:Images">
</FileRef>
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "0720"
LastUpgradeVersion = "0900"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
@@ -26,6 +26,8 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
enableAddressSanitizer = "YES"
language = ""
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
@@ -55,6 +57,8 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
enableAddressSanitizer = "YES"
language = ""
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
@@ -70,6 +74,13 @@
ReferencedContainer = "container:BGMApp.xcodeproj">
</BuildableReference>
</MacroExpansion>
<EnvironmentVariables>
<EnvironmentVariable
key = "ASAN_OPTIONS"
value = "detect_odr_violation=1:use_odr_indicator=1:detect_stack_use_after_return=1:detect_invalid_pointer_pairs=2:check_initialization_order=1"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
<AdditionalOptions>
</AdditionalOptions>
</LaunchAction>
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "0720"
LastUpgradeVersion = "0900"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
@@ -26,15 +26,26 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
enableAddressSanitizer = "YES"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1CB8B3481BBA75F0000E2DD1"
BuildableName = "BGMAppTests.xctest"
BlueprintName = "BGMAppTests"
BlueprintIdentifier = "2743C9F51D86CFF90089613B"
BuildableName = "BGMAppUnitTests.xctest"
BlueprintName = "BGMAppUnitTests"
ReferencedContainer = "container:BGMApp.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "1CCC4F531E584081008053E4"
BuildableName = "BGMAppUITests.xctest"
BlueprintName = "BGMAppUITests"
ReferencedContainer = "container:BGMApp.xcodeproj">
</BuildableReference>
</TestableReference>
@@ -55,6 +66,7 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
enableAddressSanitizer = "YES"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
@@ -71,10 +83,20 @@
ReferencedContainer = "container:BGMApp.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "--show-dock-icon"
isEnabled = "NO">
</CommandLineArgument>
<CommandLineArgument
argument = "--no-persistent-data"
isEnabled = "NO">
</CommandLineArgument>
</CommandLineArguments>
<EnvironmentVariables>
<EnvironmentVariable
key = "ASAN_OPTIONS"
value = "detect_odr_violation=0"
value = "detect_odr_violation=1:use_odr_indicator=1:detect_stack_use_after_return=1:detect_invalid_pointer_pairs=2:check_initialization_order=1"
isEnabled = "YES">
</EnvironmentVariable>
</EnvironmentVariables>
-178
View File
@@ -1,178 +0,0 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// AppDelegate.mm
// BGMApp
//
// Copyright © 2016 Kyle Neideck
//
// Self Includes
#import "AppDelegate.h"
// Local Includes
#include "BGM_Types.h"
#import "BGMAudioDeviceManager.h"
#import "BGMAutoPauseMusic.h"
#import "BGMAppVolumes.h"
#import "BGMPreferencesMenu.h"
#import "BGMXPCListener.h"
static float const kStatusBarIconPadding = 0.25;
@implementation AppDelegate {
// The button in the system status bar (the bar with volume, battery, clock, etc.) to show the main menu
// for the app. These are called "menu bar extras" in the Human Interface Guidelines.
NSStatusItem* statusBarItem;
BGMAutoPauseMusic* autoPauseMusic;
BGMAppVolumes* appVolumes;
BGMAudioDeviceManager* audioDevices;
BGMPreferencesMenu* prefsMenu;
BGMXPCListener* xpcListener;
}
- (void) awakeFromNib {
// Set up the status bar item
statusBarItem = [[NSStatusBar systemStatusBar] statusItemWithLength:NSSquareStatusItemLength];
// Set the icon
NSImage* icon = [NSImage imageNamed:@"FermataIcon"];
if (icon != nil) {
CGFloat lengthMinusPadding = [[statusBarItem button] frame].size.height * (1 - kStatusBarIconPadding);
[icon setSize:NSMakeSize(lengthMinusPadding, lengthMinusPadding)];
// Make the icon a "template image" so it gets drawn colour-inverted when it's highlighted or the status
// bar's in dark mode
[icon setTemplate:YES];
statusBarItem.button.image = icon;
} else {
// If our icon is missing for some reason, fallback to a fermata character (1D110)
statusBarItem.button.title = @"𝄐";
}
// Set the main menu
statusBarItem.menu = self.bgmMenu;
}
- (void) applicationDidFinishLaunching:(NSNotification*)aNotification {
#pragma unused (aNotification)
// Set up the GUI and other external interfaces.
// Coordinates the audio devices (BGMDevice and the output device): manages playthrough, volume/mute controls, etc.
NSError* err;
audioDevices = [[BGMAudioDeviceManager alloc] initWithError:&err];
if (audioDevices == nil) {
[self showDeviceNotFoundErrorMessageAndExit:err.code];
}
[audioDevices setBGMDeviceAsOSDefault];
autoPauseMusic = [[BGMAutoPauseMusic alloc] initWithAudioDevices:audioDevices];
xpcListener = [[BGMXPCListener alloc] initWithAudioDevices:audioDevices
helperConnectionErrorHandler:^(NSError* error) {
[self showXPCHelperErrorMessageAndExit:error];
}];
appVolumes = [[BGMAppVolumes alloc] initWithMenu:[self bgmMenu]
appVolumeView:[self appVolumeView]
audioDevices:audioDevices];
prefsMenu = [[BGMPreferencesMenu alloc] initWithbgmMenu:[self bgmMenu]
audioDevices:audioDevices
aboutPanel:[self aboutPanel]
aboutPanelLicenseView:[self aboutPanelLicenseView]];
[self loadUserDefaults];
}
- (void) loadUserDefaults {
// Register the preference defaults. These are the preferences/state that only apply to BGMApp. The others are
// persisted on BGMDriver.
NSDictionary* appDefaults = [NSDictionary dictionaryWithObject:[NSNumber numberWithBool:YES]
forKey:@"AutoPauseMusicEnabled"];
[[NSUserDefaults standardUserDefaults] registerDefaults:appDefaults];
// Enable auto-pausing music if it's enabled in the user's preferences (which it is by default).
if ([[NSUserDefaults standardUserDefaults] boolForKey:@"AutoPauseMusicEnabled"]) {
[self toggleAutoPauseMusic:self];
}
}
- (void) showDeviceNotFoundErrorMessageAndExit:(NSInteger)code {
// Show an error dialog and exit if either BGMDevice wasn't found on the system or we couldn't find any output devices
// NSAlert should only be used on the main thread.
dispatch_async(dispatch_get_main_queue(), ^{
NSAlert* alert = [NSAlert new];
if (code == kBGMErrorCode_BGMDeviceNotFound) {
// TODO: Check whether the driver files are in /Library/Audio/Plug-Ins/HAL and offer to install them if not. Also,
// it would be nice if we could restart coreaudiod automatically (using launchd).
[alert setMessageText:@"Could not find the Background Music virtual audio device."];
[alert setInformativeText:@"Make sure you've installed Background Music.driver to /Library/Audio/Plug-Ins/HAL and restarted coreaudiod (e.g. \"sudo killall coreaudiod\")."];
} else if (code == kBGMErrorCode_OutputDeviceNotFound) {
[alert setMessageText:@"Could not find an audio output device."];
[alert setInformativeText:@"If you do have one installed, this is probably a bug. Sorry about that. Feel free to file an issue on GitHub."];
}
[alert runModal];
[NSApp terminate:self];
});
}
- (void) showXPCHelperErrorMessageAndExit:(NSError*)error {
// NSAlert should only be used on the main thread.
dispatch_async(dispatch_get_main_queue(), ^{
NSAlert* alert = [NSAlert new];
// TODO: Offer to install BGMXPCHelper if it's missing.
[alert setMessageText:@"Error connecting to BGMXPCHelper."];
[alert setInformativeText:[NSString stringWithFormat:@"%s%s%@ (%lu)",
"Make sure you have BGMXPCHelper installed. There are instructions in the README.md file.",
"\n\nDetails:\n",
[error localizedDescription],
[error code]]];
[alert runModal];
[NSApp terminate:self];
});
}
- (void) applicationWillTerminate:(NSNotification*)aNotification {
#pragma unused (aNotification)
[audioDevices unsetBGMDeviceAsOSDefault];
}
- (IBAction) toggleAutoPauseMusic:(id)sender {
#pragma unused (sender)
if (self.autoPauseMenuItem.state == NSOnState) {
self.autoPauseMenuItem.state = NSOffState;
[autoPauseMusic disable];
} else {
self.autoPauseMenuItem.state = NSOnState;
[autoPauseMusic enable];
}
// Persist the change in the user's preferences
[[NSUserDefaults standardUserDefaults] setBool:(self.autoPauseMenuItem.state == NSOnState)
forKey:@"AutoPauseMusicEnabled"];
}
@end
+57
View File
@@ -0,0 +1,57 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMAppDelegate.h
// BGMApp
//
// Copyright © 2016, 2017 Kyle Neideck
//
// Sets up and tears down the app.
//
// Local Includes
#import "BGMAudioDeviceManager.h"
// System Includes
#import <Cocoa/Cocoa.h>
// Tags for UI elements in MainMenu.xib
static NSInteger const kVolumesHeadingMenuItemTag = 3;
static NSInteger const kSeparatorBelowVolumesMenuItemTag = 4;
@interface BGMAppDelegate : NSObject <NSApplicationDelegate, NSMenuDelegate>
@property (weak) IBOutlet NSMenu* bgmMenu;
@property (weak) IBOutlet NSView* outputVolumeView;
@property (weak) IBOutlet NSTextField* outputVolumeLabel;
@property (weak) IBOutlet NSSlider* outputVolumeSlider;
@property (weak) IBOutlet NSView* systemSoundsView;
@property (weak) IBOutlet NSSlider* systemSoundsSlider;
@property (weak) IBOutlet NSView* appVolumeView;
@property (weak) IBOutlet NSPanel* aboutPanel;
@property (unsafe_unretained) IBOutlet NSTextView* aboutPanelLicenseView;
@property (weak) IBOutlet NSMenuItem* autoPauseMenuItemUnwrapped;
@property (readonly) BGMAudioDeviceManager* audioDevices;
@end
+469
View File
@@ -0,0 +1,469 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMAppDelegate.mm
// BGMApp
//
// Copyright © 2016-2019 Kyle Neideck
//
// Self Include
#import "BGMAppDelegate.h"
// Local Includes
#import "BGM_Utils.h"
#import "BGMAppVolumesController.h"
#import "BGMAutoPauseMusic.h"
#import "BGMAutoPauseMenuItem.h"
#import "BGMMusicPlayers.h"
#import "BGMOutputDeviceMenuSection.h"
#import "BGMOutputVolumeMenuItem.h"
#import "BGMPreferencesMenu.h"
#import "BGMPreferredOutputDevices.h"
#import "BGMStatusBarItem.h"
#import "BGMSystemSoundsVolume.h"
#import "BGMTermination.h"
#import "BGMUserDefaults.h"
#import "BGMXPCListener.h"
#import "SystemPreferences.h"
// System Includes
#import <AVFoundation/AVCaptureDevice.h>
#pragma clang assume_nonnull begin
static NSString* const kOptNoPersistentData = @"--no-persistent-data";
static NSString* const kOptShowDockIcon = @"--show-dock-icon";
@implementation BGMAppDelegate {
// The button in the system status bar that shows the main menu.
BGMStatusBarItem* statusBarItem;
// Only show the 'BGMXPCHelper is missing' error dialog once.
BOOL haveShownXPCHelperErrorMessage;
// Persistently stores user settings and data.
BGMUserDefaults* userDefaults;
BGMAutoPauseMusic* autoPauseMusic;
BGMAutoPauseMenuItem* autoPauseMenuItem;
BGMMusicPlayers* musicPlayers;
BGMSystemSoundsVolume* systemSoundsVolume;
BGMAppVolumesController* appVolumes;
BGMOutputDeviceMenuSection* outputDeviceMenuSection;
BGMPreferencesMenu* prefsMenu;
BGMXPCListener* xpcListener;
BGMPreferredOutputDevices* preferredOutputDevices;
}
@synthesize audioDevices = audioDevices;
- (void) awakeFromNib {
[super awakeFromNib];
// Show BGMApp in the dock, if the command-line option for that was passed. This is used by the
// UI tests.
if ([NSProcessInfo.processInfo.arguments indexOfObject:kOptShowDockIcon] != NSNotFound) {
[NSApp setActivationPolicy:NSApplicationActivationPolicyRegular];
}
haveShownXPCHelperErrorMessage = NO;
// Set up audioDevices, which coordinates BGMDevice and the output device. It manages
// playthrough, volume/mute controls, etc.
if (![self initAudioDeviceManager]) {
return;
}
// Stored user settings
userDefaults = [self createUserDefaults];
// Add the status bar item. (The thing you click to show BGMApp's main menu.)
statusBarItem = [[BGMStatusBarItem alloc] initWithMenu:self.bgmMenu
audioDevices:audioDevices
userDefaults:userDefaults];
}
- (void) applicationDidFinishLaunching:(NSNotification*)aNotification {
#pragma unused (aNotification)
// Log the version/build number.
//
// TODO: NSLog should only be used for logging errors.
// TODO: Automatically add the commit ID to the end of the build number for unreleased builds. (In the
// Info.plist or something -- not here.)
NSLog(@"BGMApp version: %@, BGMApp build number: %@",
NSBundle.mainBundle.infoDictionary[@"CFBundleShortVersionString"],
NSBundle.mainBundle.infoDictionary[@"CFBundleVersion"]);
// Handles changing (or not changing) the output device when devices are added or removed. Must
// be initialised before calling setBGMDeviceAsDefault.
preferredOutputDevices =
[[BGMPreferredOutputDevices alloc] initWithDevices:audioDevices userDefaults:userDefaults];
// Choose an output device for BGMApp to use to play audio.
if (![self setInitialOutputDevice]) {
return;
}
// Make BGMDevice the default device.
[self setBGMDeviceAsDefault];
// Handle some of the unusual reasons BGMApp might have to exit, mostly crashes.
BGMTermination::SetUpTerminationCleanUp(audioDevices);
// Set up the rest of the UI and other external interfaces.
musicPlayers = [[BGMMusicPlayers alloc] initWithAudioDevices:audioDevices
userDefaults:userDefaults];
autoPauseMusic = [[BGMAutoPauseMusic alloc] initWithAudioDevices:audioDevices
musicPlayers:musicPlayers];
[self setUpMainMenu];
xpcListener = [[BGMXPCListener alloc] initWithAudioDevices:audioDevices
helperConnectionErrorHandler:^(NSError* error) {
NSLog(@"BGMAppDelegate::applicationDidFinishLaunching: (helperConnectionErrorHandler) "
"BGMXPCHelper connection error: %@",
error);
[self showXPCHelperErrorMessage:error];
}];
}
// Returns NO if (and only if) BGMApp is about to terminate because of a fatal error.
- (BOOL) initAudioDeviceManager {
audioDevices = [BGMAudioDeviceManager new];
if (!audioDevices) {
[self showBGMDeviceNotFoundErrorMessageAndExit];
return NO;
}
return YES;
}
// Returns NO if (and only if) BGMApp is about to terminate because of a fatal error.
- (BOOL) setInitialOutputDevice {
AudioObjectID preferredDevice = [preferredOutputDevices findPreferredDevice];
if (preferredDevice != kAudioObjectUnknown) {
NSError* __nullable error = [audioDevices setOutputDeviceWithID:preferredDevice
revertOnFailure:NO];
if (error) {
// Show the error message.
[self showFailedToSetOutputDeviceErrorMessage:BGMNN(error)
preferredDevice:preferredDevice];
}
} else {
// We couldn't find a device to use, so show an error message and quit.
[self showOutputDeviceNotFoundErrorMessageAndExit];
return NO;
}
return YES;
}
// Sets the "Background Music" virtual audio device (BGMDevice) as the user's default audio device.
- (void) setBGMDeviceAsDefault {
void (^setDefaultDevice)() = ^{
NSError* error = [audioDevices setBGMDeviceAsOSDefault];
if (error) {
[self showSetDeviceAsDefaultError:error
message:@"Could not set the Background Music device as your"
"default audio device."
informativeText:@"You might be able to change it yourself."];
}
};
// Skip this if we're compiling on a version of macOS before 10.14 as won't compile and it
// isn't needed.
#if MAC_OS_X_VERSION_MAX_ALLOWED >= 101400 // MAC_OS_X_VERSION_10_14
if (@available(macOS 10.14, *)) {
// On macOS 10.14+ we need to get the user's permission to use input devices before we can
// use BGMDevice for playthrough (see BGMPlayThrough), so we wait until they've given it
// before making BGMDevice the default device. This way, if the user is playing audio when
// they open Background Music, we won't interrupt it while we're waiting for them to click
// OK.
//
// TODO: This isn't a perfect solution because, if the user takes too long to accept,
// BGMPlayThrough will try to use BGMDevice again and log some errors.
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeAudio
completionHandler:^(BOOL granted) {
if (granted) {
DebugMsg("BGMAppDelegate::setBGMDeviceAsDefault: "
"Permission granted");
setDefaultDevice();
} else {
NSLog(@"BGMAppDelegate::setBGMDeviceAsDefault: "
"Permission denied");
// TODO: If they don't accept, Background Music won't work
// at all and the only way to fix it is in System
// Preferences, so we should show an error dialog
// with instructions.
}
}];
}
else
#endif
{
// We can change the device immediately on older versions of macOS because they don't
// require user permission for input devices.
setDefaultDevice();
}
}
- (void) setUpMainMenu {
autoPauseMenuItem =
[[BGMAutoPauseMenuItem alloc] initWithMenuItem:self.autoPauseMenuItemUnwrapped
autoPauseMusic:autoPauseMusic
musicPlayers:musicPlayers
userDefaults:userDefaults];
[self initVolumesMenuSection];
// Output device selection.
outputDeviceMenuSection =
[[BGMOutputDeviceMenuSection alloc] initWithBGMMenu:self.bgmMenu
audioDevices:audioDevices
preferredDevices:preferredOutputDevices];
[audioDevices setOutputDeviceMenuSection:outputDeviceMenuSection];
// Preferences submenu.
prefsMenu = [[BGMPreferencesMenu alloc] initWithBGMMenu:self.bgmMenu
audioDevices:audioDevices
musicPlayers:musicPlayers
statusBarItem:statusBarItem
aboutPanel:self.aboutPanel
aboutPanelLicenseView:self.aboutPanelLicenseView];
// Handle events about the main menu. (See the NSMenuDelegate methods below.)
self.bgmMenu.delegate = self;
}
- (BGMUserDefaults*) createUserDefaults {
BOOL persistentDefaults =
[NSProcessInfo.processInfo.arguments indexOfObject:kOptNoPersistentData] == NSNotFound;
NSUserDefaults* wrappedDefaults = persistentDefaults ? [NSUserDefaults standardUserDefaults] : nil;
return [[BGMUserDefaults alloc] initWithDefaults:wrappedDefaults];
}
- (void) initVolumesMenuSection {
// Create the menu item with the (main) output volume slider.
BGMOutputVolumeMenuItem* outputVolume =
[[BGMOutputVolumeMenuItem alloc] initWithAudioDevices:audioDevices
view:self.outputVolumeView
slider:self.outputVolumeSlider
deviceLabel:self.outputVolumeLabel];
[audioDevices setOutputVolumeMenuItem:outputVolume];
NSInteger headingIdx = [self.bgmMenu indexOfItemWithTag:kVolumesHeadingMenuItemTag];
// Add it to the main menu below the "Volumes" heading.
[self.bgmMenu insertItem:outputVolume atIndex:(headingIdx + 1)];
// Add the volume control for system (UI) sounds to the menu.
BGMAudioDevice uiSoundsDevice = [audioDevices bgmDevice].GetUISoundsBGMDeviceInstance();
systemSoundsVolume =
[[BGMSystemSoundsVolume alloc] initWithUISoundsDevice:uiSoundsDevice
view:self.systemSoundsView
slider:self.systemSoundsSlider];
[self.bgmMenu insertItem:systemSoundsVolume.menuItem atIndex:(headingIdx + 2)];
// Add the app volumes to the menu.
appVolumes = [[BGMAppVolumesController alloc] initWithMenu:self.bgmMenu
appVolumeView:self.appVolumeView
audioDevices:audioDevices];
}
- (void) applicationWillTerminate:(NSNotification*)aNotification {
#pragma unused (aNotification)
DebugMsg("BGMAppDelegate::applicationWillTerminate");
// Change the user's default output device back.
NSError* error = [audioDevices unsetBGMDeviceAsOSDefault];
if (error) {
[self showSetDeviceAsDefaultError:error
message:@"Failed to reset your system's audio output device."
informativeText:@"You'll have to change it yourself to get audio working again."];
}
}
#pragma mark Error messages
- (void) showBGMDeviceNotFoundErrorMessageAndExit {
// BGMDevice wasn't found on the system. Most likely, BGMDriver isn't installed. Show an error
// dialog and exit.
//
// TODO: Check whether the driver files are in /Library/Audio/Plug-Ins/HAL? Might even want to
// offer to install them if not.
[self showErrorMessage:@"Could not find the Background Music virtual audio device."
informativeText:@"Make sure you've installed Background Music Device.driver to "
"/Library/Audio/Plug-Ins/HAL and restarted coreaudiod (e.g. \"sudo "
"killall coreaudiod\")."
exitAfterMessageDismissed:YES];
}
- (void) showFailedToSetOutputDeviceErrorMessage:(NSError*)error
preferredDevice:(BGMAudioDevice)device {
NSLog(@"Failed to set initial output device. Error: %@", error);
dispatch_async(dispatch_get_main_queue(), ^{
NSAlert* alert = [NSAlert alertWithError:BGMNN(error)];
alert.messageText = @"Failed to set the output device.";
NSString* __nullable name = nil;
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
name = (__bridge NSString* __nullable)device.CopyName();
});
alert.informativeText =
[NSString stringWithFormat:@"Could not start the device '%@'. (Error: %ld)",
name, error.code];
[alert runModal];
});
}
- (void) showOutputDeviceNotFoundErrorMessageAndExit {
// We couldn't find any output devices. Show an error dialog and exit.
[self showErrorMessage:@"Could not find an audio output device."
informativeText:@"If you do have one installed, this is probably a bug. Sorry about "
"that. Feel free to file an issue on GitHub."
exitAfterMessageDismissed:YES];
}
- (void) showXPCHelperErrorMessage:(NSError*)error {
if (!haveShownXPCHelperErrorMessage) {
haveShownXPCHelperErrorMessage = YES;
// NSAlert should only be used on the main thread.
dispatch_async(dispatch_get_main_queue(), ^{
NSAlert* alert = [NSAlert new];
// TODO: Offer to install BGMXPCHelper if it's missing.
// TODO: Show suppression button?
[alert setMessageText:@"Error connecting to BGMXPCHelper."];
[alert setInformativeText:[NSString stringWithFormat:@"%s%s%@ (%lu)",
"Make sure you have BGMXPCHelper installed. There are instructions in the "
"README.md file.\n\n"
"Background Music might still work, but it won't work as well as it could.",
"\n\nDetails:\n",
[error localizedDescription],
[error code]]];
[alert runModal];
});
}
}
- (void) showErrorMessage:(NSString*)message
informativeText:(NSString*)informativeText
exitAfterMessageDismissed:(BOOL)fatal {
// NSAlert should only be used on the main thread.
dispatch_async(dispatch_get_main_queue(), ^{
NSAlert* alert = [NSAlert new];
[alert setMessageText:message];
[alert setInformativeText:informativeText];
// This crashes if built with Xcode 9.0.1, but works with versions of Xcode before 9 and
// with 9.1.
[alert runModal];
if (fatal) {
[NSApp terminate:self];
}
});
}
- (void) showSetDeviceAsDefaultError:(NSError*)error
message:(NSString*)msg
informativeText:(NSString*)info {
dispatch_async(dispatch_get_main_queue(), ^{
NSLog(@"%@ %@ Error: %@", msg, info, error);
NSAlert* alert = [NSAlert alertWithError:error];
alert.messageText = msg;
alert.informativeText = info;
[alert addButtonWithTitle:@"OK"];
[alert addButtonWithTitle:@"Open Sound in System Preferences"];
NSModalResponse buttonClicked = [alert runModal];
if (buttonClicked != NSAlertFirstButtonReturn) { // 'OK' is the first button.
[self openSysPrefsSoundOutput];
}
});
}
- (void) openSysPrefsSoundOutput {
SystemPreferencesApplication* __nullable sysPrefs =
[SBApplication applicationWithBundleIdentifier:@"com.apple.systempreferences"];
if (!sysPrefs) {
NSLog(@"Could not open System Preferences");
return;
}
// In System Preferences, go to the "Output" tab on the "Sound" pane.
for (SystemPreferencesPane* pane : [sysPrefs panes]) {
DebugMsg("BGMAppDelegate::openSysPrefsSoundOutput: pane = %s", [pane.name UTF8String]);
if ([pane.id isEqualToString:@"com.apple.preference.sound"]) {
sysPrefs.currentPane = pane;
for (SystemPreferencesAnchor* anchor : [pane anchors]) {
DebugMsg("BGMAppDelegate::openSysPrefsSoundOutput: anchor = %s", [anchor.name UTF8String]);
if ([[anchor.name lowercaseString] isEqualToString:@"output"]) {
DebugMsg("BGMAppDelegate::openSysPrefsSoundOutput: Showing Output in Sound pane.");
[anchor reveal];
}
}
}
}
// Bring System Preferences to the foreground.
[sysPrefs activate];
}
#pragma mark NSMenuDelegate
- (void) menuNeedsUpdate:(NSMenu*)menu {
if ([menu isEqual:self.bgmMenu]) {
[autoPauseMenuItem parentMenuNeedsUpdate];
} else {
DebugMsg("BGMAppDelegate::menuNeedsUpdate: Warning: unexpected menu. menu=%s", menu.description.UTF8String);
}
}
- (void) menu:(NSMenu*)menu willHighlightItem:(NSMenuItem* __nullable)item {
if ([menu isEqual:self.bgmMenu]) {
[autoPauseMenuItem parentMenuItemWillHighlight:item];
} else {
DebugMsg("BGMAppDelegate::menu: Warning: unexpected menu. menu=%s", menu.description.UTF8String);
}
}
@end
#pragma clang assume_nonnull end
+37 -9
View File
@@ -17,41 +17,69 @@
// BGMAppVolumes.h
// BGMApp
//
// Copyright © 2016 Kyle Neideck
// Copyright © 2016, 2017 Kyle Neideck
//
// Local Includes
#import "BGMAudioDeviceManager.h"
#import "BGMAppVolumesController.h"
// System Includes
#import <Cocoa/Cocoa.h>
#pragma clang assume_nonnull begin
@interface BGMAppVolumes : NSObject
- (id) initWithMenu:(NSMenu*)menu appVolumeView:(NSView*)view audioDevices:(BGMAudioDeviceManager*)audioDevices;
- (id) initWithController:(BGMAppVolumesController*)inController
bgmMenu:(NSMenu*)inMenu
appVolumeView:(NSView*)inView;
// Pass -1 for initialVolume or initialPan to leave the volume/pan at its default level.
- (void) insertMenuItemForApp:(NSRunningApplication*)app
initialVolume:(int)volume
initialPan:(int)pan;
- (void) removeMenuItemForApp:(NSRunningApplication*)app;
- (void) removeAllAppVolumeMenuItems;
@end
// Protocol for the UI custom classes
@protocol BGMAppVolumeSubview <NSObject>
@protocol BGMAppVolumeMenuItemSubview <NSObject>
- (void) setUpWithApp:(NSRunningApplication*)app context:(BGMAppVolumes*)ctx;
- (void) setUpWithApp:(NSRunningApplication*)app
context:(BGMAppVolumes*)ctx
controller:(BGMAppVolumesController*)ctrl
menuItem:(NSMenuItem*)item;
@end
// Custom classes for the UI elements in the app volume menu items
@interface BGMAVM_AppIcon : NSImageView <BGMAppVolumeSubview>
@interface BGMAVM_AppIcon : NSImageView <BGMAppVolumeMenuItemSubview>
@end
@interface BGMAVM_AppNameLabel : NSTextField <BGMAppVolumeSubview>
@interface BGMAVM_AppNameLabel : NSTextField <BGMAppVolumeMenuItemSubview>
@end
@interface BGMAVM_VolumeSlider : NSSlider <BGMAppVolumeSubview>
@interface BGMAVM_ShowMoreControlsButton : NSButton <BGMAppVolumeMenuItemSubview>
@end
- (void) setRelativeVolume:(NSNumber*)relativeVolume;
@interface BGMAVM_VolumeSlider : NSSlider <BGMAppVolumeMenuItemSubview>
- (void) setRelativeVolume:(int)relativeVolume;
@end
@interface BGMAVM_PanSlider : NSSlider <BGMAppVolumeMenuItemSubview>
- (void) setPanPosition:(int)panPosition;
@end
#pragma clang assume_nonnull end
+449
View File
@@ -0,0 +1,449 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMAppVolumes.m
// BGMApp
//
// Copyright © 2016-2018 Kyle Neideck
// Copyright © 2017 Andrew Tonner
//
// Self Include
#import "BGMAppVolumes.h"
// Local Includes
#import "BGM_Types.h"
#import "BGM_Utils.h"
#import "BGMAppDelegate.h"
// PublicUtility Includes
#import "CADebugMacros.h"
static float const kSlidersSnapWithin = 5;
static CGFloat const kAppVolumeViewInitialHeight = 20;
static NSString* const kMoreAppsMenuTitle = @"More Apps";
@implementation BGMAppVolumes {
BGMAppVolumesController* controller;
NSMenu* bgmMenu;
NSMenu* moreAppsMenu;
NSView* appVolumeView;
CGFloat appVolumeViewFullHeight;
// The number of menu items this class has added to bgmMenu. Doesn't include the More Apps menu.
NSInteger numMenuItems;
}
- (id) initWithController:(BGMAppVolumesController*)inController
bgmMenu:(NSMenu*)inMenu
appVolumeView:(NSView*)inView {
if ((self = [super init])) {
controller = inController;
bgmMenu = inMenu;
moreAppsMenu = [[NSMenu alloc] initWithTitle:kMoreAppsMenuTitle];
appVolumeView = inView;
appVolumeViewFullHeight = appVolumeView.frame.size.height;
numMenuItems = 0;
// Add the More Apps menu to the main menu.
NSMenuItem* moreAppsMenuItem =
[[NSMenuItem alloc] initWithTitle:kMoreAppsMenuTitle action:nil keyEquivalent:@""];
moreAppsMenuItem.submenu = moreAppsMenu;
[bgmMenu insertItem:moreAppsMenuItem atIndex:([self lastMenuItemIndex] + 1)];
numMenuItems++;
// Put an empty menu item above the More Apps menu item to fix its top margin.
NSMenuItem* spacer = [[NSMenuItem alloc] initWithTitle:@"" action:nil keyEquivalent:@""];
spacer.view = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 0, 4)];
spacer.hidden = YES; // Tells accessibility clients to ignore this menu item.
[bgmMenu insertItem:spacer atIndex:[self lastMenuItemIndex]];
numMenuItems++;
}
return self;
}
#pragma mark UI Modifications
- (void) insertMenuItemForApp:(NSRunningApplication*)app
initialVolume:(int)volume
initialPan:(int)pan {
NSMenuItem* appVolItem = [self createBlankAppVolumeMenuItem];
// Look through the menu item's subviews for the ones we want to set up
for (NSView* subview in appVolItem.view.subviews) {
if ([subview conformsToProtocol:@protocol(BGMAppVolumeMenuItemSubview)]) {
[(NSView<BGMAppVolumeMenuItemSubview>*)subview setUpWithApp:app
context:self
controller:controller
menuItem:appVolItem];
}
}
// Store the NSRunningApplication object with the menu item so when the app closes we can find the item to remove it
appVolItem.representedObject = app;
// Set the slider to the volume for this app if we got one from the driver
[self setVolumeOfMenuItem:appVolItem relativeVolume:volume panPosition:pan];
// NSMenuItem didn't implement NSAccessibility before OS X SDK 10.12.
#if MAC_OS_X_VERSION_MAX_ALLOWED >= 101200 // MAC_OS_X_VERSION_10_12
if ([appVolItem respondsToSelector:@selector(setAccessibilityTitle:)]) {
// TODO: This doesn't show up in Accessibility Inspector for me. Not sure why.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
appVolItem.accessibilityTitle = [NSString stringWithFormat:@"%@", [app localizedName]];
#pragma clang diagnostic pop
}
#endif
// Add the menu item to its menu.
if (app.activationPolicy == NSApplicationActivationPolicyRegular) {
[bgmMenu insertItem:appVolItem atIndex:[self firstMenuItemIndex]];
numMenuItems++;
} else if (app.activationPolicy == NSApplicationActivationPolicyAccessory) {
[moreAppsMenu insertItem:appVolItem atIndex:0];
}
}
// Create a blank menu item to copy as a template.
- (NSMenuItem*) createBlankAppVolumeMenuItem {
NSMenuItem* menuItem = [[NSMenuItem alloc] initWithTitle:@"" action:nil keyEquivalent:@""];
menuItem.view = appVolumeView;
menuItem = [menuItem copy]; // So we can modify a copy of the view, rather than the template itself.
return menuItem;
}
- (void) setVolumeOfMenuItem:(NSMenuItem*)menuItem relativeVolume:(int)volume panPosition:(int)pan {
// Update the sliders.
for (NSView* subview in menuItem.view.subviews) {
// Set the volume.
if (volume != -1 && [subview isKindOfClass:[BGMAVM_VolumeSlider class]]) {
[(BGMAVM_VolumeSlider*)subview setRelativeVolume:volume];
}
// Set the pan position.
if (pan != -1 && [subview isKindOfClass:[BGMAVM_PanSlider class]]) {
[(BGMAVM_PanSlider*)subview setPanPosition:pan];
}
}
}
- (NSInteger) firstMenuItemIndex {
return [self lastMenuItemIndex] - numMenuItems + 1;
}
- (NSInteger) lastMenuItemIndex {
return [bgmMenu indexOfItemWithTag:kSeparatorBelowVolumesMenuItemTag] - 1;
}
- (void) removeMenuItemForApp:(NSRunningApplication*)app {
// Subtract two extra positions to skip the More Apps menu and the spacer menu item above it.
NSInteger lastAppVolumeMenuItemIndex = [self lastMenuItemIndex] - 2;
// Check each app volume menu item and remove the item that controls the given app.
// Look through the main menu.
for (NSInteger i = [self firstMenuItemIndex]; i <= lastAppVolumeMenuItemIndex; i++) {
NSMenuItem* item = [bgmMenu itemAtIndex:i];
NSRunningApplication* itemApp = item.representedObject;
BGMAssert(itemApp, "!itemApp for %s", item.title.UTF8String);
if ([itemApp isEqual:app]) {
[bgmMenu removeItem:item];
numMenuItems--;
return;
}
}
// Look through the More Apps menu.
for (NSInteger i = 0; i < [moreAppsMenu numberOfItems]; i++) {
NSMenuItem* item = [moreAppsMenu itemAtIndex:i];
NSRunningApplication* itemApp = item.representedObject;
BGMAssert(itemApp, "!itemApp for %s", item.title.UTF8String);
if ([itemApp isEqual:app]) {
[moreAppsMenu removeItem:item];
return;
}
}
}
- (void) showHideExtraControls:(BGMAVM_ShowMoreControlsButton*)button {
// Show or hide an app's extra controls, currently only pan, in its App Volumes menu item.
NSMenuItem* menuItem = button.cell.representedObject;
BGMAssert(button, "!button");
BGMAssert(menuItem, "!menuItem");
CGFloat width = menuItem.view.frame.size.width;
CGFloat height = menuItem.view.frame.size.height;
#if DEBUG
const char* appName = [((NSRunningApplication*)menuItem.representedObject).localizedName UTF8String];
#endif
// Using this function (instead of just ==) shouldn't be necessary, but just in case.
BOOL(^nearEnough)(CGFloat x, CGFloat y) = ^BOOL(CGFloat x, CGFloat y) {
return fabs(x - y) < 0.01; // We don't need much precision.
};
if (nearEnough(button.frameCenterRotation, 0.0)) {
// Hide extra controls
DebugMsg("BGMAppVolumes::showHideExtraControls: Hiding extra controls (%s)", appName);
BGMAssert(nearEnough(height, appVolumeViewFullHeight), "Extra controls were already hidden");
// Make the menu item shorter to hide the extra controls. Keep the width unchanged.
menuItem.view.frameSize = NSMakeSize(width, kAppVolumeViewInitialHeight);
// Turn the button upside down so the arrowhead points down.
button.frameCenterRotation = 180.0;
// Move the button up slightly so it aligns with the volume slider.
[button setFrameOrigin:NSMakePoint(button.frame.origin.x, button.frame.origin.y - 1)];
// Set the extra controls, and anything else below the fold, to hidden so accessibility
// clients can skip over them.
for (NSView* subview in menuItem.view.subviews) {
CGFloat top = subview.frame.origin.y + subview.frame.size.height;
if (top <= 0.0) {
subview.hidden = YES;
}
}
} else {
// Show extra controls
DebugMsg("BGMAppVolumes::showHideExtraControls: Showing extra controls (%s)", appName);
BGMAssert(nearEnough(button.frameCenterRotation, 180.0), "Unexpected button rotation");
BGMAssert(nearEnough(height, kAppVolumeViewInitialHeight), "Extra controls were already shown");
// Make the menu item taller to show the extra controls. Keep the width unchanged.
menuItem.view.frameSize = NSMakeSize(width, appVolumeViewFullHeight);
// Turn the button rightside up so the arrowhead points up.
button.frameCenterRotation = 0.0;
// Move the button down slightly, back to it's original position.
[button setFrameOrigin:NSMakePoint(button.frame.origin.x, button.frame.origin.y + 1)];
// Set all of the UI elements in the menu item to "not hidden" for accessibility clients.
for (NSView* subview in menuItem.view.subviews) {
subview.hidden = NO;
}
}
}
- (void) removeAllAppVolumeMenuItems {
// Remove all of the menu items this class adds to the menu except for the last two, which are
// the More Apps menu item and the invisible spacer above it.
while (numMenuItems > 2) {
[bgmMenu removeItemAtIndex:[self firstMenuItemIndex]];
numMenuItems--;
}
// The More Apps menu only contains app volume menu items, so we can just remove everything.
[moreAppsMenu removeAllItems];
}
@end
#pragma mark Custom Classes (IB)
// Custom classes for the UI elements in the app volume menu items
@implementation BGMAVM_AppIcon
- (void) setUpWithApp:(NSRunningApplication*)app
context:(BGMAppVolumes*)ctx
controller:(BGMAppVolumesController*)ctrl
menuItem:(NSMenuItem*)menuItem {
#pragma unused (ctx, ctrl, menuItem)
self.image = app.icon;
// Remove the icon from the accessibility hierarchy.
#if MAC_OS_X_VERSION_MAX_ALLOWED >= 101000 // MAC_OS_X_VERSION_10_10
if ([self.cell respondsToSelector:@selector(setAccessibilityElement:)]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
self.cell.accessibilityElement = NO;
#pragma clang diagnostic pop
}
#endif
}
@end
@implementation BGMAVM_AppNameLabel
- (void) setUpWithApp:(NSRunningApplication*)app
context:(BGMAppVolumes*)ctx
controller:(BGMAppVolumesController*)ctrl
menuItem:(NSMenuItem*)menuItem {
#pragma unused (ctx, ctrl, menuItem)
NSString* name = app.localizedName ? (NSString*)app.localizedName : @"";
self.stringValue = name;
}
@end
@implementation BGMAVM_ShowMoreControlsButton
- (void) setUpWithApp:(NSRunningApplication*)app
context:(BGMAppVolumes*)ctx
controller:(BGMAppVolumesController*)ctrl
menuItem:(NSMenuItem*)menuItem {
#pragma unused (app, ctrl)
// Set up the button that show/hide the extra controls (currently only a pan slider) for the app.
self.cell.representedObject = menuItem;
self.target = ctx;
self.action = @selector(showHideExtraControls:);
// The menu item starts out with the extra controls visible, so we hide them here.
//
// TODO: Leave them visible if any of the controls are set to non-default values. The user has no way to
// tell otherwise. Maybe we should also make this button look different if the controls are hidden
// when they have non-default values.
[ctx showHideExtraControls:self];
if ([self respondsToSelector:@selector(setAccessibilityTitle:)]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
self.accessibilityTitle = @"More options";
#pragma clang diagnostic pop
}
}
@end
@implementation BGMAVM_VolumeSlider {
// Will be set to -1 for apps without a pid
pid_t appProcessID;
NSString* __nullable appBundleID;
BGMAppVolumesController* controller;
}
- (void) setUpWithApp:(NSRunningApplication*)app
context:(BGMAppVolumes*)ctx
controller:(BGMAppVolumesController*)ctrl
menuItem:(NSMenuItem*)menuItem {
#pragma unused (ctx, menuItem)
controller = ctrl;
self.target = self;
self.action = @selector(appVolumeChanged);
appProcessID = app.processIdentifier;
appBundleID = app.bundleIdentifier;
self.maxValue = kAppRelativeVolumeMaxRawValue;
self.minValue = kAppRelativeVolumeMinRawValue;
if ([self respondsToSelector:@selector(setAccessibilityTitle:)]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
self.accessibilityTitle = [NSString stringWithFormat:@"Volume for %@", [app localizedName]];
#pragma clang diagnostic pop
}
}
// We have to handle snapping for volume sliders ourselves because adding a tick mark (snap point) in Interface Builder
// changes how the slider looks.
- (void) snap {
// Snap to the 50% point.
float midPoint = (float)((self.maxValue + self.minValue) / 2);
if (self.floatValue > (midPoint - kSlidersSnapWithin) && self.floatValue < (midPoint + kSlidersSnapWithin)) {
self.floatValue = midPoint;
}
}
- (void) setRelativeVolume:(int)relativeVolume {
self.intValue = relativeVolume;
[self snap];
}
- (void) appVolumeChanged {
// TODO: This (sending updates to the driver) should probably be rate-limited. It uses a fair bit of CPU for me.
DebugMsg("BGMAppVolumes::appVolumeChanged: App volume for %s (%d) changed to %d",
appBundleID.UTF8String,
appProcessID,
self.intValue);
[self snap];
// The values from our sliders are in
// [kAppRelativeVolumeMinRawValue, kAppRelativeVolumeMaxRawValue] already.
[controller setVolume:self.intValue forAppWithProcessID:appProcessID bundleID:appBundleID];
}
@end
@implementation BGMAVM_PanSlider {
// Will be set to -1 for apps without a pid
pid_t appProcessID;
NSString* __nullable appBundleID;
BGMAppVolumesController* controller;
}
- (void) setUpWithApp:(NSRunningApplication*)app
context:(BGMAppVolumes*)ctx
controller:(BGMAppVolumesController*)ctrl
menuItem:(NSMenuItem*)menuItem {
#pragma unused (ctx, menuItem)
controller = ctrl;
self.target = self;
self.action = @selector(appPanPositionChanged);
appProcessID = app.processIdentifier;
appBundleID = app.bundleIdentifier;
self.minValue = kAppPanLeftRawValue;
self.maxValue = kAppPanRightRawValue;
if ([self respondsToSelector:@selector(setAccessibilityTitle:)]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
self.accessibilityTitle = [NSString stringWithFormat:@"Pan for %@", [app localizedName]];
#pragma clang diagnostic pop
}
}
- (void) setPanPosition:(int)panPosition {
self.intValue = panPosition;
}
- (void) appPanPositionChanged {
// TODO: This (sending updates to the driver) should probably be rate-limited. It uses a fair bit of CPU for me.
DebugMsg("BGMAppVolumes::appPanPositionChanged: App pan position for %s changed to %d", appBundleID.UTF8String, self.intValue);
// The values from our sliders are in [kAppPanLeftRawValue, kAppPanRightRawValue] already.
[controller setPanPosition:self.intValue forAppWithProcessID:appProcessID bundleID:appBundleID];
}
@end
-298
View File
@@ -1,298 +0,0 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMAppVolumes.m
// BGMApp
//
// Copyright © 2016 Kyle Neideck
//
// Self Include
#import "BGMAppVolumes.h"
// BGM Includes
#include "BGM_Types.h"
// PublicUtility Includes
#include "CACFDictionary.h"
#include "CACFArray.h"
#include "CACFString.h"
static NSInteger const kAppVolumesMenuItemTag = 3;
static NSInteger const kSeparatorBelowAppVolumesMenuItemTag = 4;
static float const kSlidersSnapWithin = 5;
@implementation BGMAppVolumes {
NSMenu* bgmMenu;
NSView* appVolumeView;
BGMAudioDeviceManager* audioDevices;
}
- (id) initWithMenu:(NSMenu*)menu appVolumeView:(NSView*)view audioDevices:(BGMAudioDeviceManager*)devices {
if ((self = [super init])) {
bgmMenu = menu;
appVolumeView = view;
audioDevices = devices;
// Create the menu items for controlling app volumes
[self insertMenuItemsForApps:[[NSWorkspace sharedWorkspace] runningApplications]];
// Register for notifications when the user opens or closes apps, so we can update the menu
[[NSWorkspace sharedWorkspace] addObserver:self
forKeyPath:@"runningApplications"
options:NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld
context:nil];
}
return self;
}
- (void) dealloc {
[[NSWorkspace sharedWorkspace] removeObserver:self forKeyPath:@"runningApplications" context:nil];
}
- (void) insertMenuItemsForApps:(NSArray<NSRunningApplication*>*)apps {
NSAssert([NSThread isMainThread], @"insertMenuItemsForApps is not thread safe");
#ifndef NS_BLOCK_ASSERTIONS // If assertions are enabled
NSInteger numMenuItemsBeforeInsert =
[bgmMenu indexOfItemWithTag:kSeparatorBelowAppVolumesMenuItemTag] - [bgmMenu indexOfItemWithTag:kAppVolumesMenuItemTag] - 1;
NSUInteger numApps = 0;
#endif
// Create a blank menu item to copy as a template
NSMenuItem* blankItem = [[NSMenuItem alloc] initWithTitle:@"" action:nil keyEquivalent:@""];
[blankItem setView:appVolumeView];
// Get the app volumes currently set on the device
CACFArray appVolumesOnDevice((CFArrayRef)[audioDevices bgmDevice].GetPropertyData_CFType(kBGMAppVolumesAddress), false);
NSInteger index = [bgmMenu indexOfItemWithTag:kAppVolumesMenuItemTag] + 1;
// Add a volume-control menu item for each app
for (NSRunningApplication* app in apps) {
// Only show apps that appear in the dock (at first)
// TODO: Would it be better to only show apps that are registered as HAL clients?
if ([app activationPolicy] != NSApplicationActivationPolicyRegular) continue;
// Don't show Finder
if ([[app bundleIdentifier] isEqualTo:@"com.apple.finder"]) continue;
#ifndef NS_BLOCK_ASSERTIONS // If assertions are enabled
// Count how many apps we should add menu items for so we can check it at the end of the method
numApps++;
#endif
NSMenuItem* appVolItem = [blankItem copy];
// Look through the menu item's subviews for the ones we want to set up
for (NSView* subview in [[appVolItem view] subviews]) {
if ([subview conformsToProtocol:@protocol(BGMAppVolumeSubview)]) {
[subview performSelector:@selector(setUpWithApp:context:) withObject:app withObject:self];
}
}
// Store the NSRunningApplication object with the menu item so when the app closes we can find the item to remove it
[appVolItem setRepresentedObject:app];
// Set the slider to the volume for this app if we got one from the driver
[self setVolumeOfMenuItem:appVolItem fromAppVolumes:appVolumesOnDevice];
[bgmMenu insertItem:appVolItem atIndex:index];
}
#ifndef NS_BLOCK_ASSERTIONS // If assertions are enabled
NSInteger numMenuItemsAfterInsert =
[bgmMenu indexOfItemWithTag:kSeparatorBelowAppVolumesMenuItemTag] - [bgmMenu indexOfItemWithTag:kAppVolumesMenuItemTag] - 1;
NSAssert3(numMenuItemsAfterInsert == (numMenuItemsBeforeInsert + numApps),
@"Did not add the expected number of menu items. numMenuItemsBeforeInsert=%ld numMenuItemsAfterInsert=%ld numAppsToAdd=%lu",
(long)numMenuItemsBeforeInsert,
(long)numMenuItemsAfterInsert,
(unsigned long)numApps);
#endif
}
- (void) removeMenuItemsForApps:(NSArray<NSRunningApplication*>*)apps {
NSAssert([NSThread isMainThread], @"removeMenuItemsForApps is not thread safe");
NSInteger firstItemIndex = [bgmMenu indexOfItemWithTag:kAppVolumesMenuItemTag] + 1;
NSInteger lastItemIndex = [bgmMenu indexOfItemWithTag:kSeparatorBelowAppVolumesMenuItemTag] - 1;
// Check each app volume menu item, removing the items that control one of the given apps
for (NSInteger i = firstItemIndex; i <= lastItemIndex; i++) {
NSMenuItem* item = [bgmMenu itemAtIndex:i];
for (NSRunningApplication* appToBeRemoved in apps) {
NSRunningApplication* itemApp = [item representedObject];
if ([itemApp isEqual:appToBeRemoved]) {
[bgmMenu removeItem:item];
// Correct i to account for the item we removed, since we're editing the menu in place
i--;
continue;
}
}
}
}
- (void) setVolumeOfMenuItem:(NSMenuItem*)menuItem fromAppVolumes:(CACFArray&)appVolumes {
// Set menuItem's volume slider to the volume of the app in appVolumes that menuItem represents
// Leaves menuItem unchanged if it doesn't match any of the apps in appVolumes
NSRunningApplication* representedApp = [menuItem representedObject];
for (UInt32 i = 0; i < appVolumes.GetNumberItems(); i++) {
CACFDictionary appVolume(false);
appVolumes.GetCACFDictionary(i, appVolume);
// Match the app to the menu item by pid or bundle id
CACFString bundleID;
bundleID.DontAllowRelease();
appVolume.GetCACFString(CFSTR(kBGMAppVolumesKey_BundleID), bundleID);
pid_t pid;
appVolume.GetSInt32(CFSTR(kBGMAppVolumesKey_ProcessID), pid);
if ([representedApp processIdentifier] == pid ||
[[representedApp bundleIdentifier] isEqualToString:(__bridge NSString*)bundleID.GetCFString()]) {
CFTypeRef relativeVolume;
appVolume.GetCFType(CFSTR(kBGMAppVolumesKey_RelativeVolume), relativeVolume);
// Update the slider
for (NSView* subview in [[menuItem view] subviews]) {
if ([subview respondsToSelector:@selector(setRelativeVolume:)]) {
[subview performSelector:@selector(setRelativeVolume:) withObject:(__bridge NSNumber*)relativeVolume];
}
}
}
}
}
- (void) observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
#pragma unused (object, context)
// KVO callback for the apps currently running on the system. Adds/removes the associated menu items.
if ([keyPath isEqualToString:@"runningApplications"]) {
NSArray<NSRunningApplication*>* newApps = [change objectForKey:NSKeyValueChangeNewKey];
NSArray<NSRunningApplication*>* oldApps = [change objectForKey:NSKeyValueChangeOldKey];
int changeKind = [[change valueForKey:NSKeyValueChangeKindKey] intValue];
switch (changeKind) {
case NSKeyValueChangeInsertion:
[self insertMenuItemsForApps:newApps];
break;
case NSKeyValueChangeRemoval:
[self removeMenuItemsForApps:oldApps];
break;
case NSKeyValueChangeReplacement:
[self removeMenuItemsForApps:oldApps];
[self insertMenuItemsForApps:newApps];
break;
case NSKeyValueChangeSetting:
[bgmMenu removeAllItems];
[self insertMenuItemsForApps:newApps];
break;
}
}
}
- (void) sendVolumeChangeToBGMDevice:(SInt32)newVolume appProcessID:(pid_t)appProcessID appBundleID:(NSString*)appBundleID {
CACFDictionary appVolumeChange(true);
appVolumeChange.AddSInt32(CFSTR(kBGMAppVolumesKey_ProcessID), appProcessID);
appVolumeChange.AddString(CFSTR(kBGMAppVolumesKey_BundleID), (__bridge CFStringRef)appBundleID);
// The values from our sliders are in [kAppRelativeVolumeMinRawValue, kAppRelativeVolumeMaxRawValue] already
appVolumeChange.AddSInt32(CFSTR(kBGMAppVolumesKey_RelativeVolume), newVolume);
CACFArray appVolumeChanges(true);
appVolumeChanges.AppendDictionary(appVolumeChange.GetDict());
[audioDevices bgmDevice].SetPropertyData_CFType(kBGMAppVolumesAddress, appVolumeChanges.AsPropertyList());
}
@end
// Custom classes for the UI elements in the app volume menu items
@implementation BGMAVM_AppIcon
- (void) setUpWithApp:(NSRunningApplication*)app context:(BGMAppVolumes*)ctx {
#pragma unused (ctx)
[self setImage:[app icon]];
}
@end
@implementation BGMAVM_AppNameLabel
- (void) setUpWithApp:(NSRunningApplication*)app context:(BGMAppVolumes*)ctx {
#pragma unused (ctx)
[self setStringValue:[app localizedName]];
}
@end
@implementation BGMAVM_VolumeSlider {
// Will be set to -1 for apps without a pid
pid_t appProcessID;
NSString* appBundleID;
BGMAppVolumes* context;
}
- (void) setUpWithApp:(NSRunningApplication*)app context:(BGMAppVolumes*)ctx {
context = ctx;
[self setTarget:self];
[self setAction:@selector(appVolumeChanged)];
appProcessID = [app processIdentifier];
appBundleID = [app bundleIdentifier];
[self setMaxValue:kAppRelativeVolumeMaxRawValue];
[self setMinValue:kAppRelativeVolumeMinRawValue];
}
- (void) snap {
// Snap to the 50% point
float midPoint = static_cast<float>(([self maxValue] - [self minValue]) / 2);
if ([self floatValue] > (midPoint - kSlidersSnapWithin) && [self floatValue] < (midPoint + kSlidersSnapWithin)) {
[self setFloatValue:midPoint];
}
}
- (void) setRelativeVolume:(NSNumber*)relativeVolume {
[self setIntValue:[relativeVolume intValue]];
[self snap];
}
- (void) appVolumeChanged {
// TODO: This (sending updates to the driver) should probably be rate-limited. It uses a fair bit of CPU for me.
DebugMsg("BGMAppVolumes::appVolumeChanged: App volume for %s changed to %d", [appBundleID UTF8String], [self intValue]);
[self snap];
[context sendVolumeChangeToBGMDevice:[self intValue] appProcessID:appProcessID appBundleID:appBundleID];
}
@end
+51
View File
@@ -0,0 +1,51 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMAppVolumesController.h
// BGMApp
//
// Copyright © 2017 Kyle Neideck
//
// Local Includes
#import "BGMAudioDeviceManager.h"
// System Includes
#import <Cocoa/Cocoa.h>
#pragma clang assume_nonnull begin
@interface BGMAppVolumesController : NSObject
- (id) initWithMenu:(NSMenu*)menu
appVolumeView:(NSView*)view
audioDevices:(BGMAudioDeviceManager*)audioDevices;
// See BGMBackgroundMusicDevice::SetAppVolume.
- (void) setVolume:(SInt32)volume
forAppWithProcessID:(pid_t)processID
bundleID:(NSString* __nullable)bundleID;
// See BGMBackgroundMusicDevice::SetPanVolume.
- (void) setPanPosition:(SInt32)pan
forAppWithProcessID:(pid_t)processID
bundleID:(NSString* __nullable)bundleID;
@end
#pragma clang assume_nonnull end
+249
View File
@@ -0,0 +1,249 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMAppVolumesController.mm
// BGMApp
//
// Copyright © 2017, 2018 Kyle Neideck
// Copyright © 2017 Andrew Tonner
//
// Self Include
#import "BGMAppVolumesController.h"
// Local Includes
#import "BGM_Types.h"
#import "BGM_Utils.h"
#import "BGMAppVolumes.h"
// PublicUtility Includes
#import "CACFArray.h"
#import "CACFDictionary.h"
#import "CACFString.h"
// System Includes
#include <libproc.h>
#pragma clang assume_nonnull begin
typedef struct BGMAppVolumeAndPan {
int volume;
int pan;
} BGMAppVolumeAndPan;
@implementation BGMAppVolumesController {
// The App Volumes UI.
BGMAppVolumes* appVolumes;
BGMAudioDeviceManager* audioDevices;
}
#pragma mark Initialisation
- (id) initWithMenu:(NSMenu*)menu
appVolumeView:(NSView*)view
audioDevices:(BGMAudioDeviceManager*)devices {
if ((self = [super init])) {
audioDevices = devices;
appVolumes = [[BGMAppVolumes alloc] initWithController:self
bgmMenu:menu
appVolumeView:view];
// Create the menu items for controlling app volumes.
NSArray<NSRunningApplication*>* apps = [[NSWorkspace sharedWorkspace] runningApplications];
[self insertMenuItemsForApps:apps];
// Register for notifications when the user opens or closes apps, so we can update the menu.
auto opts = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
[[NSWorkspace sharedWorkspace] addObserver:self
forKeyPath:@"runningApplications"
options:opts
context:nil];
}
return self;
}
- (void) dealloc {
[[NSWorkspace sharedWorkspace] removeObserver:self
forKeyPath:@"runningApplications"
context:nil];
}
// Adds a volume control menu item for each given app.
- (void) insertMenuItemsForApps:(NSArray<NSRunningApplication*>*)apps {
NSAssert([NSThread isMainThread], @"insertMenuItemsForApps is not thread safe");
// TODO: Handle the C++ exceptions this method can throw. They can cause crashes because this
// method is called in a KVO handler.
// Get the app volumes currently set on the device
CACFArray volumesFromBGMDevice([audioDevices bgmDevice].GetAppVolumes(), false);
for (NSRunningApplication* app in apps) {
if ([self shouldBeIncludedInMenu:app]) {
BGMAppVolumeAndPan initial = [self getVolumeAndPanForApp:app
fromVolumes:volumesFromBGMDevice];
[appVolumes insertMenuItemForApp:app
initialVolume:initial.volume
initialPan:initial.pan];
}
}
}
- (BGMAppVolumeAndPan) getVolumeAndPanForApp:(NSRunningApplication*)app
fromVolumes:(const CACFArray&)volumes {
BGMAppVolumeAndPan volumeAndPan = {
.volume = -1,
.pan = -1
};
for (UInt32 i = 0; i < volumes.GetNumberItems(); i++) {
CACFDictionary appVolume(false);
volumes.GetCACFDictionary(i, appVolume);
// Match the app to the volume/pan by pid or bundle ID.
CACFString bundleID;
bundleID.DontAllowRelease();
appVolume.GetCACFString(CFSTR(kBGMAppVolumesKey_BundleID), bundleID);
pid_t pid;
appVolume.GetSInt32(CFSTR(kBGMAppVolumesKey_ProcessID), pid);
if ((app.processIdentifier == pid) ||
[app.bundleIdentifier isEqualToString:(__bridge NSString*)bundleID.GetCFString()]) {
// Found a match, so read the volume and pan.
appVolume.GetSInt32(CFSTR(kBGMAppVolumesKey_RelativeVolume), volumeAndPan.volume);
appVolume.GetSInt32(CFSTR(kBGMAppVolumesKey_PanPosition), volumeAndPan.pan);
break;
}
}
return volumeAndPan;
}
- (BOOL) shouldBeIncludedInMenu:(NSRunningApplication*)app {
// Ignore hidden apps and Background Music itself.
// TODO: Would it be better to only show apps that are registered as HAL clients?
BOOL isHidden = app.activationPolicy != NSApplicationActivationPolicyRegular &&
app.activationPolicy != NSApplicationActivationPolicyAccessory;
NSString* bundleID = app.bundleIdentifier;
BOOL isBGMApp = bundleID && [@kBGMAppBundleID isEqualToString:BGMNN(bundleID)];
return !isHidden && !isBGMApp;
}
- (void) removeMenuItemsForApps:(NSArray<NSRunningApplication*>*)apps {
NSAssert([NSThread isMainThread], @"removeMenuItemsForApps is not thread safe");
for (NSRunningApplication* app in apps) {
[appVolumes removeMenuItemForApp:app];
}
}
#pragma mark Accessors
- (void) setVolume:(SInt32)volume
forAppWithProcessID:(pid_t)processID
bundleID:(NSString* __nullable)bundleID {
// Update the app's volume.
audioDevices.bgmDevice.SetAppVolume(volume, processID, (__bridge_retained CFStringRef)bundleID);
// If this volume is for FaceTime, set the volume for the avconferenced process as well. This
// works around FaceTime not playing its own audio. It plays UI sounds through
// systemsoundserverd and call audio through avconferenced.
//
// This isn't ideal because other apps might play audio through avconferenced, but I don't see a
// good way we could find out which app is actually playing the audio. We could probably figure
// it out from reading avconferenced's logs, at least, if it turns out to be important. See
// https://github.com/kyleneideck/BackgroundMusic/issues/139.
if ([bundleID isEqual:@"com.apple.FaceTime"]) {
[self setAvconferencedVolume:volume];
}
}
- (void) setAvconferencedVolume:(SInt32)volume {
// TODO: This volume will be lost if avconferenced is restarted.
pid_t pids[1024];
size_t procCount = proc_listallpids(pids, 1024);
char path[PROC_PIDPATHINFO_MAXSIZE];
for (int i = 0; i < procCount; i++) {
pid_t pid = pids[i];
if (proc_pidpath(pid, path, sizeof(path)) > 0 &&
strncmp(path, "/usr/libexec/avconferenced", sizeof(path)) == 0) {
DebugMsg("Setting avconferenced volume: %d", volume);
audioDevices.bgmDevice.SetAppVolume(volume, pid, nullptr);
return;
}
}
LogWarning("Failed to set avconferenced volume.");
}
- (void) setPanPosition:(SInt32)pan
forAppWithProcessID:(pid_t)processID
bundleID:(NSString* __nullable)bundleID {
audioDevices.bgmDevice.SetAppPanPosition(pan,
processID,
(__bridge_retained CFStringRef)bundleID);
}
#pragma mark KVO
- (void) observeValueForKeyPath:(NSString* __nullable)keyPath
ofObject:(id __nullable)object
change:(NSDictionary* __nullable)change
context:(void* __nullable)context
{
#pragma unused (object, context)
// KVO callback for the apps currently running on the system. Adds/removes the associated menu
// items.
if (keyPath && change && [keyPath isEqualToString:@"runningApplications"]) {
NSArray<NSRunningApplication*>* newApps = change[NSKeyValueChangeNewKey];
NSArray<NSRunningApplication*>* oldApps = change[NSKeyValueChangeOldKey];
int changeKind = [change[NSKeyValueChangeKindKey] intValue];
switch (changeKind) {
case NSKeyValueChangeInsertion:
[self insertMenuItemsForApps:newApps];
break;
case NSKeyValueChangeRemoval:
[self removeMenuItemsForApps:oldApps];
break;
case NSKeyValueChangeReplacement:
[self removeMenuItemsForApps:oldApps];
[self insertMenuItemsForApps:newApps];
break;
case NSKeyValueChangeSetting:
[appVolumes removeAllAppVolumeMenuItems];
[self insertMenuItemsForApps:newApps];
break;
}
}
}
@end
#pragma clang assume_nonnull end
+49
View File
@@ -0,0 +1,49 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMAppWatcher.h
// BGMApp
//
// Copyright © 2019 Kyle Neideck
//
// Calls callback functions when a given application is launched or terminated. Starts watching
// after being initialised, stops after being destroyed.
//
// System Includes
#import <Foundation/Foundation.h>
#pragma clang assume_nonnull begin
@interface BGMAppWatcher : NSObject
// appLaunched will be called when the application is launched and appTerminated will be called when
// it's terminated. Background apps, status bar apps, etc. are ignored.
- (instancetype) initWithBundleID:(NSString*)bundleID
appLaunched:(void(^)(void))appLaunched
appTerminated:(void(^)(void))appTerminated;
// With this constructor, when an application is launched or terminated, isMatchingBundleID will be
// called first to decide whether or not the callback should be called.
- (instancetype) initWithAppLaunched:(void(^)(void))appLaunched
appTerminated:(void(^)(void))appTerminated
isMatchingBundleID:(BOOL(^)(NSString* appBundleID))isMatchingBundleID;
@end
#pragma clang assume_nonnull end
+109
View File
@@ -0,0 +1,109 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMAppWatcher.m
// BGMApp
//
// Copyright © 2019 Kyle Neideck
//
// Self Include
#import "BGMAppWatcher.h"
// System Includes
#import <Cocoa/Cocoa.h>
#pragma clang assume_nonnull begin
@implementation BGMAppWatcher {
// Tokens for the notification observers so we can remove them in dealloc.
id<NSObject> didLaunchToken;
id<NSObject> didTerminateToken;
}
- (instancetype) initWithBundleID:(NSString*)bundleID
appLaunched:(void(^)(void))appLaunched
appTerminated:(void(^)(void))appTerminated {
return [self initWithAppLaunched:appLaunched
appTerminated:appTerminated
isMatchingBundleID:^BOOL(NSString* appBundleID) {
return [bundleID isEqualToString:appBundleID];
}];
}
- (instancetype) initWithAppLaunched:(void(^)(void))appLaunched
appTerminated:(void(^)(void))appTerminated
isMatchingBundleID:(BOOL(^)(NSString*))isMatchingBundleID
{
if ((self = [super init])) {
NSNotificationCenter* center = [NSWorkspace sharedWorkspace].notificationCenter;
didLaunchToken =
[center addObserverForName:NSWorkspaceDidLaunchApplicationNotification
object:nil
queue:nil
usingBlock:^(NSNotification* notification) {
if ([BGMAppWatcher shouldBeHandled:notification
isMatchingBundleID:isMatchingBundleID]) {
appLaunched();
}
}];
didTerminateToken =
[center addObserverForName:NSWorkspaceDidTerminateApplicationNotification
object:nil
queue:nil
usingBlock:^(NSNotification* notification) {
if ([BGMAppWatcher shouldBeHandled:notification
isMatchingBundleID:isMatchingBundleID]) {
appTerminated();
}
}];
}
return self;
}
// Returns YES if we should call the app launch/termination callback for this NSNotification.
+ (BOOL) shouldBeHandled:(NSNotification*)notification
isMatchingBundleID:(BOOL(^)(NSString*))isMatchingBundleID {
NSString* __nullable notifiedBundleID =
[notification.userInfo[NSWorkspaceApplicationKey] bundleIdentifier];
// Ignore the notification if the app doesn't have a bundle ID or isMatchingBundleID returns NO.
return notifiedBundleID && isMatchingBundleID((NSString*)notifiedBundleID);
}
- (void) dealloc {
// Remove the application launch/termination observers.
NSNotificationCenter* center = [NSWorkspace sharedWorkspace].notificationCenter;
if (didLaunchToken) {
[center removeObserver:didLaunchToken];
didLaunchToken = nil;
}
if (didTerminateToken) {
[center removeObserver:didTerminateToken];
didTerminateToken = nil;
}
}
@end
#pragma clang assume_nonnull end
+386
View File
@@ -0,0 +1,386 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMAudioDevice.cpp
// BGMApp
//
// Copyright © 2017 Kyle Neideck
//
// Self Include
#include "BGMAudioDevice.h"
// Local Includes
#include "BGM_Types.h"
// System Includes
#include <AudioToolbox/AudioServices.h>
#pragma mark Construction/Destruction
BGMAudioDevice::BGMAudioDevice(AudioObjectID inAudioDevice)
:
CAHALAudioDevice(inAudioDevice)
{
}
BGMAudioDevice::BGMAudioDevice(CFStringRef inUID)
:
CAHALAudioDevice(inUID)
{
}
BGMAudioDevice::BGMAudioDevice(const CAHALAudioDevice& inDevice)
:
BGMAudioDevice(inDevice.GetObjectID())
{
};
BGMAudioDevice::~BGMAudioDevice()
{
}
bool BGMAudioDevice::CanBeOutputDeviceInBGMApp() const
{
CFStringRef uid = CopyDeviceUID();
bool isNullDevice = CFEqual(uid, CFSTR(kBGMNullDeviceUID));
CFRelease(uid);
bool hasOutputChannels = GetTotalNumberChannels(/* inIsInput = */ false) > 0;
bool canBeDefault = CanBeDefaultDevice(/* inIsInput = */ false, /* inIsSystem = */ false);
return !IsBGMDeviceInstance() &&
!isNullDevice &&
!IsHidden() &&
hasOutputChannels &&
canBeDefault;
}
#pragma mark Available Controls
bool BGMAudioDevice::HasSettableMasterVolume(AudioObjectPropertyScope inScope) const
{
return HasVolumeControl(inScope, kMasterChannel) &&
VolumeControlIsSettable(inScope, kMasterChannel);
}
bool BGMAudioDevice::HasSettableVirtualMasterVolume(AudioObjectPropertyScope inScope) const
{
AudioObjectPropertyAddress virtualMasterVolumeAddress = {
kAudioHardwareServiceDeviceProperty_VirtualMasterVolume,
inScope,
kAudioObjectPropertyElementMaster
};
// TODO: Replace these calls deprecated AudioToolbox functions. There are more below.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
Boolean virtualMasterVolumeIsSettable;
OSStatus err = AudioHardwareServiceIsPropertySettable(GetObjectID(),
&virtualMasterVolumeAddress,
&virtualMasterVolumeIsSettable);
virtualMasterVolumeIsSettable &= (err == kAudioServicesNoError);
bool hasVirtualMasterVolume =
AudioHardwareServiceHasProperty(GetObjectID(), &virtualMasterVolumeAddress);
#pragma clang diagnostic pop
return hasVirtualMasterVolume && virtualMasterVolumeIsSettable;
}
bool BGMAudioDevice::HasSettableMasterMute(AudioObjectPropertyScope inScope) const
{
return HasMuteControl(inScope, kMasterChannel) &&
MuteControlIsSettable(inScope, kMasterChannel);
}
#pragma mark Control Values Accessors
void BGMAudioDevice::CopyMuteFrom(const BGMAudioDevice inDevice,
AudioObjectPropertyScope inScope)
{
// TODO: Support for devices that have per-channel mute controls but no master mute control
if(HasSettableMasterMute(inScope) && inDevice.HasMuteControl(inScope, kMasterChannel))
{
SetMuteControlValue(inScope,
kMasterChannel,
inDevice.GetMuteControlValue(inScope, kMasterChannel));
}
}
void BGMAudioDevice::CopyVolumeFrom(const BGMAudioDevice inDevice,
AudioObjectPropertyScope inScope)
{
// Get the volume of the other device.
bool didGetVolume = false;
Float32 volume = FLT_MIN;
if(inDevice.HasVolumeControl(inScope, kMasterChannel))
{
volume = inDevice.GetVolumeControlScalarValue(inScope, kMasterChannel);
didGetVolume = true;
}
// Use the average channel volume of the other device if it has no master volume.
if(!didGetVolume)
{
UInt32 numChannels =
inDevice.GetTotalNumberChannels(inScope == kAudioObjectPropertyScopeInput);
volume = 0;
for(UInt32 channel = 1; channel <= numChannels; channel++)
{
if(inDevice.HasVolumeControl(inScope, channel))
{
volume += inDevice.GetVolumeControlScalarValue(inScope, channel);
didGetVolume = true;
}
}
if(numChannels > 0) // Avoid divide by zero.
{
volume /= numChannels;
}
}
// Set the volume of this device.
if(didGetVolume && volume != FLT_MIN)
{
bool didSetVolume = false;
try
{
didSetVolume = SetMasterVolumeScalar(inScope, volume);
}
catch(CAException e)
{
OSStatus err = e.GetError();
char err4CC[5] = CA4CCToCString(err);
CFStringRef uid = CopyDeviceUID();
LogWarning("BGMAudioDevice::CopyVolumeFrom: CAException '%s' trying to set master "
"volume of %s",
err4CC,
CFStringGetCStringPtr(uid, kCFStringEncodingUTF8));
CFRelease(uid);
}
if(!didSetVolume)
{
// Couldn't find a master volume control to set, so try to find a virtual one
Float32 virtualMasterVolume;
bool success = inDevice.GetVirtualMasterVolumeScalar(inScope, virtualMasterVolume);
if(success)
{
didSetVolume = SetVirtualMasterVolumeScalar(inScope, virtualMasterVolume);
}
}
if(!didSetVolume)
{
// Couldn't set a master or virtual master volume, so as a fallback try to set each
// channel individually.
UInt32 numChannels = GetTotalNumberChannels(inScope == kAudioObjectPropertyScopeInput);
for(UInt32 channel = 1; channel <= numChannels; channel++)
{
if(HasVolumeControl(inScope, channel) && VolumeControlIsSettable(inScope, channel))
{
SetVolumeControlScalarValue(inScope, channel, volume);
}
}
}
}
}
bool BGMAudioDevice::SetMasterVolumeScalar(AudioObjectPropertyScope inScope, Float32 inVolume)
{
if(HasSettableMasterVolume(inScope))
{
SetVolumeControlScalarValue(inScope, kMasterChannel, inVolume);
return true;
}
return false;
}
bool BGMAudioDevice::GetVirtualMasterVolumeScalar(AudioObjectPropertyScope inScope,
Float32& outVirtualMasterVolume) const
{
AudioObjectPropertyAddress virtualMasterVolumeAddress = {
kAudioHardwareServiceDeviceProperty_VirtualMasterVolume,
inScope,
kAudioObjectPropertyElementMaster
};
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
if(!AudioHardwareServiceHasProperty(GetObjectID(), &virtualMasterVolumeAddress))
{
return false;
}
#pragma clang diagnostic pop
UInt32 virtualMasterVolumePropertySize = sizeof(Float32);
return kAudioServicesNoError == AHSGetPropertyData(GetObjectID(),
&virtualMasterVolumeAddress,
&virtualMasterVolumePropertySize,
&outVirtualMasterVolume);
}
bool BGMAudioDevice::SetVirtualMasterVolumeScalar(AudioObjectPropertyScope inScope,
Float32 inVolume)
{
// TODO: For me, setting the virtual master volume sets all the device's channels to the same volume, meaning you can't
// keep any channels quieter than the others. The expected behaviour is to scale the channel volumes
// proportionally. So to do this properly I think we'd have to store BGMDevice's previous volume and calculate
// each channel's new volume from its current volume and the distance between BGMDevice's old and new volumes.
//
// The docs kAudioHardwareServiceDeviceProperty_VirtualMasterVolume for say
// "If the device has individual channel volume controls, this property will apply to those identified by the
// device's preferred multi-channel layout (or preferred stereo pair if the device is stereo only). Note that
// this control maintains the relative balance between all the channels it affects.
// so I'm not sure why that's not working here. As a workaround we take the to device's (virtual master) balance
// before changing the volume and set it back after, but of course that'll only work for stereo devices.
bool didSetVolume = false;
if(HasSettableVirtualMasterVolume(inScope))
{
// Not sure why, but setting the virtual master volume sets all channels to the same volume. As a workaround, we store
// the current balance here so we can reset it after setting the volume.
Float32 virtualMasterBalance;
bool didGetVirtualMasterBalance = GetVirtualMasterBalance(inScope, virtualMasterBalance);
AudioObjectPropertyAddress virtualMasterVolumeAddress = {
kAudioHardwareServiceDeviceProperty_VirtualMasterVolume,
inScope,
kAudioObjectPropertyElementMaster
};
didSetVolume = (kAudioServicesNoError == AHSSetPropertyData(GetObjectID(),
&virtualMasterVolumeAddress,
sizeof(Float32),
&inVolume));
// Reset the balance
AudioObjectPropertyAddress virtualMasterBalanceAddress = {
kAudioHardwareServiceDeviceProperty_VirtualMasterBalance,
inScope,
kAudioObjectPropertyElementMaster
};
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
if(didSetVolume &&
didGetVirtualMasterBalance &&
AudioHardwareServiceHasProperty(GetObjectID(), &virtualMasterBalanceAddress))
{
Boolean balanceIsSettable;
OSStatus err = AudioHardwareServiceIsPropertySettable(GetObjectID(),
&virtualMasterBalanceAddress,
&balanceIsSettable);
if(err == kAudioServicesNoError && balanceIsSettable)
{
AHSSetPropertyData(GetObjectID(),
&virtualMasterBalanceAddress,
sizeof(Float32),
&virtualMasterBalance);
}
}
#pragma clang diagnostic pop
}
return didSetVolume;
}
bool BGMAudioDevice::GetVirtualMasterBalance(AudioObjectPropertyScope inScope,
Float32& outVirtualMasterBalance) const
{
AudioObjectPropertyAddress virtualMasterBalanceAddress = {
kAudioHardwareServiceDeviceProperty_VirtualMasterBalance,
inScope,
kAudioObjectPropertyElementMaster
};
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
if(!AudioHardwareServiceHasProperty(GetObjectID(), &virtualMasterBalanceAddress))
{
return false;
}
#pragma clang diagnostic pop
UInt32 virtualMasterVolumePropertySize = sizeof(Float32);
return kAudioServicesNoError == AHSGetPropertyData(GetObjectID(),
&virtualMasterBalanceAddress,
&virtualMasterVolumePropertySize,
&outVirtualMasterBalance);
}
#pragma mark Implementation
bool BGMAudioDevice::IsBGMDevice(bool inIncludeUISoundsInstance) const
{
bool isBGMDevice = false;
if(GetObjectID() != kAudioObjectUnknown)
{
// Check the device's UID to see whether it's BGMDevice.
CFStringRef uid = CopyDeviceUID();
isBGMDevice =
CFEqual(uid, CFSTR(kBGMDeviceUID)) ||
(inIncludeUISoundsInstance && CFEqual(uid, CFSTR(kBGMDeviceUID_UISounds)));
CFRelease(uid);
}
return isBGMDevice;
}
// static
OSStatus BGMAudioDevice::AHSGetPropertyData(AudioObjectID inObjectID,
const AudioObjectPropertyAddress* inAddress,
UInt32* ioDataSize,
void* outData)
{
// The docs for AudioHardwareServiceGetPropertyData specifically allow passing NULL for
// inQualifierData as we do here, but it's declared in an assume_nonnull section so we have to
// disable the warning here. I'm not sure why inQualifierData isn't __nullable. I'm assuming
// it's either a backwards compatibility thing or just a bug.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wnonnull"
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
// The non-depreciated version of this (and the setter below) doesn't seem to support devices
// other than the default
return AudioHardwareServiceGetPropertyData(inObjectID, inAddress, 0, NULL, ioDataSize, outData);
#pragma clang diagnostic pop
}
// static
OSStatus BGMAudioDevice::AHSSetPropertyData(AudioObjectID inObjectID,
const AudioObjectPropertyAddress* inAddress,
UInt32 inDataSize,
const void* inData)
{
// See the explanation about these pragmas in AHSGetPropertyData
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wnonnull"
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
return AudioHardwareServiceSetPropertyData(inObjectID, inAddress, 0, NULL, inDataSize, inData);
#pragma clang diagnostic pop
}
+120
View File
@@ -0,0 +1,120 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
// BGMAudioDevice.h
// BGMApp
//
// Copyright © 2017 Kyle Neideck
//
// A HAL audio device. Note that this class's only state is the AudioObjectID of the device.
//
#ifndef BGMApp__BGMAudioDevice
#define BGMApp__BGMAudioDevice
// PublicUtility Includes
#include "CAHALAudioDevice.h"
class BGMAudioDevice
:
public CAHALAudioDevice
{
#pragma mark Construction/Destruction
public:
BGMAudioDevice(AudioObjectID inAudioDevice);
/*!
Creates a BGMAudioDevice with the Audio Object ID of the device whose UID is inUID or, if no
such device is found, kAudioObjectUnknown.
@throws CAException If the HAL returns an error when queried for the device's ID.
@see kAudioPlugInPropertyTranslateUIDToDevice in AudioHardwareBase.h.
*/
BGMAudioDevice(CFStringRef inUID);
BGMAudioDevice(const CAHALAudioDevice& inDevice);
virtual ~BGMAudioDevice();
#if defined(__OBJC__)
// Hack/workaround for Objective-C classes so we don't have to use pointers for instance
// variables.
BGMAudioDevice() : BGMAudioDevice(kAudioObjectUnknown) { }
#endif /* defined(__OBJC__) */
operator AudioObjectID() const { return GetObjectID(); }
/*!
@return True if this device is BGMDevice. (Specifically, the main instance of BGMDevice.)
@throws CAException If the HAL returns an error when queried.
*/
bool IsBGMDevice() const { return IsBGMDevice(false); };
/*!
@return True if this device is either the main instance of BGMDevice (the device named
"Background Music") or the instance used for UI sounds (the device named "Background
Music (UI Sounds)").
@throws CAException If the HAL returns an error when queried.
*/
bool IsBGMDeviceInstance() const { return IsBGMDevice(true); };
/*!
@return True if this device can be set as the output device in BGMApp.
@throws CAException If the HAL returns an error when queried.
*/
bool CanBeOutputDeviceInBGMApp() const;
#pragma mark Available Controls
bool HasSettableMasterVolume(AudioObjectPropertyScope inScope) const;
bool HasSettableVirtualMasterVolume(AudioObjectPropertyScope inScope) const;
bool HasSettableMasterMute(AudioObjectPropertyScope inScope) const;
#pragma mark Control Values Accessors
void CopyMuteFrom(const BGMAudioDevice inDevice,
AudioObjectPropertyScope inScope);
void CopyVolumeFrom(const BGMAudioDevice inDevice,
AudioObjectPropertyScope inScope);
bool SetMasterVolumeScalar(AudioObjectPropertyScope inScope, Float32 inVolume);
bool GetVirtualMasterVolumeScalar(AudioObjectPropertyScope inScope,
Float32& outVirtualMasterVolume) const;
bool SetVirtualMasterVolumeScalar(AudioObjectPropertyScope inScope,
Float32 inVolume);
bool GetVirtualMasterBalance(AudioObjectPropertyScope inScope,
Float32& outVirtualMasterBalance) const;
#pragma mark Implementation
private:
bool IsBGMDevice(bool inIncludingUISoundsInstance) const;
static OSStatus AHSGetPropertyData(AudioObjectID inObjectID,
const AudioObjectPropertyAddress* inAddress,
UInt32* ioDataSize,
void* outData);
static OSStatus AHSSetPropertyData(AudioObjectID inObjectID,
const AudioObjectPropertyAddress* inAddress,
UInt32 inDataSize,
const void* inData);
};
#endif /* BGMApp__BGMAudioDevice */
+70 -15
View File
@@ -17,40 +17,95 @@
// BGMAudioDeviceManager.h
// BGMApp
//
// Copyright © 2016 Kyle Neideck
// Copyright © 2016-2018 Kyle Neideck
//
// Manages the BGMDevice and the output device. Sets the system's current default device as the output device on init, then
// starts playthrough and mirroring the devices' controls. The output device can be changed but the BGMDevice is fixed.
// Manages BGMDevice and the output device. Sets the system's current default device as the output
// device on init, then starts playthrough and mirroring the devices' controls.
//
#if defined(__cplusplus)
// Local Includes
#import "BGMBackgroundMusicDevice.h"
// PublicUtility Includes
#include "CAHALAudioDevice.h"
#import "CAHALAudioDevice.h"
#endif /* defined(__cplusplus) */
// System Includes
#import <Foundation/Foundation.h>
#include <CoreAudio/AudioHardwareBase.h>
#import <CoreAudio/AudioHardwareBase.h>
// Forward Declarations
@class BGMOutputVolumeMenuItem;
@class BGMOutputDeviceMenuSection;
extern int const kBGMErrorCode_BGMDeviceNotFound;
extern int const kBGMErrorCode_OutputDeviceNotFound;
#pragma clang assume_nonnull begin
static const int kBGMErrorCode_OutputDeviceNotFound = 1;
static const int kBGMErrorCode_ReturningEarly = 2;
@interface BGMAudioDeviceManager : NSObject
- (id) initWithError:(NSError**)error;
// Returns nil if BGMDevice isn't installed.
- (instancetype) init;
// Set the BGMOutputVolumeMenuItem to be notified when the output device is changed.
- (void) setOutputVolumeMenuItem:(BGMOutputVolumeMenuItem*)item;
// Set the BGMOutputDeviceMenuSection to be notified when the output device is changed.
- (void) setOutputDeviceMenuSection:(BGMOutputDeviceMenuSection*)menuSection;
// Set BGMDevice as the default audio device for all processes
- (void) setBGMDeviceAsOSDefault;
- (NSError* __nullable) setBGMDeviceAsOSDefault;
// Replace BGMDevice as the default device with the output device
- (void) unsetBGMDeviceAsOSDefault;
- (NSError* __nullable) unsetBGMDeviceAsOSDefault;
- (CAHALAudioDevice) bgmDevice;
#ifdef __cplusplus
// The virtual device published by BGMDriver.
- (BGMBackgroundMusicDevice) bgmDevice;
// The device BGMApp will play audio through, making it, from the user's perspective, the system's
// default output device.
- (CAHALAudioDevice) outputDevice;
#endif
- (BOOL) isOutputDevice:(AudioObjectID)deviceID;
// Returns NO if the output device couldn't be changed and has been reverted
- (BOOL) setOutputDeviceWithID:(AudioObjectID)deviceID revertOnFailure:(BOOL)revertOnFailure;
- (BOOL) isOutputDataSource:(UInt32)dataSourceID;
// Returns when IO has started running on the output device (for playthrough).
- (OSStatus) waitForOutputDeviceToStart;
// Set the audio output device that BGMApp uses.
//
// Returns an error if the output device couldn't be changed. If revertOnFailure is true in that case,
// this method will attempt to set the output device back to the original device. If it fails to
// revert, an additional error will be included in the error's userInfo with the key "revertError".
//
// Both errors' codes will be the code of the exception that caused the failure, if any, generally one
// of the error constants from AudioHardwareBase.h.
//
// Blocks while the old device stops IO (if there was one).
- (NSError* __nullable) setOutputDeviceWithID:(AudioObjectID)deviceID
revertOnFailure:(BOOL)revertOnFailure;
// As above, but also sets the new output device's data source. See kAudioDevicePropertyDataSource in
// AudioHardware.h.
- (NSError* __nullable) setOutputDeviceWithID:(AudioObjectID)deviceID
dataSourceID:(UInt32)dataSourceID
revertOnFailure:(BOOL)revertOnFailure;
// Start playthrough synchronously. Blocks until IO has started on the output device and playthrough
// is running. See BGMPlayThrough.
//
// Returns one of the error codes defined by this class or BGMPlayThrough, or an AudioHardware error
// code received from the HAL.
- (OSStatus) startPlayThroughSync:(BOOL)forUISoundsDevice;
// When the output device is changed, BGMAudioDeviceManager will send the ID of the new output
// device to BGMXPCHelper through this connection.
- (void) setBGMXPCHelperConnection:(NSXPCConnection* __nullable)connection;
@end
#pragma clang assume_nonnull end
+378 -148
View File
@@ -17,62 +17,69 @@
// BGMAudioDeviceManager.mm
// BGMApp
//
// Copyright © 2016 Kyle Neideck
// Copyright © 2016-2018 Kyle Neideck
//
// Self Include
#import "BGMAudioDeviceManager.h"
// Local Includes
#include "BGM_Types.h"
#include "BGMDeviceControlSync.h"
#include "BGMPlayThrough.h"
#import "BGM_Types.h"
#import "BGM_Utils.h"
#import "BGMAudioDevice.h"
#import "BGMDeviceControlSync.h"
#import "BGMOutputDeviceMenuSection.h"
#import "BGMOutputVolumeMenuItem.h"
#import "BGMPlayThrough.h"
#import "BGMXPCProtocols.h"
// PublicUtility Includes
#include "CAHALAudioSystemObject.h"
#include "CAAutoDisposer.h"
#import "CAAtomic.h"
#import "CAAutoDisposer.h"
#import "CAHALAudioSystemObject.h"
int const kBGMErrorCode_BGMDeviceNotFound = 0;
int const kBGMErrorCode_OutputDeviceNotFound = 1;
// Hack/workaround that adds a default constructor to CAHALAudioDevice so we don't have to use pointers for the instance variables
class BGMAudioDevice : public CAHALAudioDevice {
using CAHALAudioDevice::CAHALAudioDevice;
public:
BGMAudioDevice() : CAHALAudioDevice(kAudioDeviceUnknown) { }
};
#pragma clang assume_nonnull begin
@implementation BGMAudioDeviceManager {
BGMAudioDevice bgmDevice;
// This ivar is a pointer so that BGMBackgroundMusicDevice's constructor doesn't get called
// during [BGMAudioDeviceManager alloc] when the ivars are initialised. It queries the HAL for
// BGMDevice's AudioObject ID, which might throw a CAException, most likely because BGMDevice
// isn't installed.
//
// That would be the only way for [BGMAudioDeviceManager alloc] to throw a CAException, so we
// could wrap that call in a try/catch block instead, but it would make the code a bit
// confusing.
BGMBackgroundMusicDevice* bgmDevice;
BGMAudioDevice outputDevice;
BGMDeviceControlSync deviceControlSync;
BGMPlayThrough playThrough;
BGMPlayThrough playThrough_UISounds;
// A connection to BGMXPCHelper so we can send it the ID of the output device.
NSXPCConnection* __nullable bgmXPCHelperConnection;
BGMOutputVolumeMenuItem* __nullable outputVolumeMenuItem;
BGMOutputDeviceMenuSection* __nullable outputDeviceMenuSection;
NSRecursiveLock* stateLock;
}
#pragma mark Construction/Destruction
- (id) initWithError:(NSError**)error {
- (instancetype) init {
if ((self = [super init])) {
bgmDevice = BGMAudioDevice(CFSTR(kBGMDeviceUID));
if (bgmDevice.GetObjectID() == kAudioObjectUnknown) {
DebugMsg("BGMAudioDeviceManager::initWithError: BGMDevice not found");
if (error) {
*error = [NSError errorWithDomain:@kBGMAppBundleID code:kBGMErrorCode_BGMDeviceNotFound userInfo:nil];
}
self = nil;
return self;
}
[self initOutputDevice];
if (outputDevice.GetObjectID() == kAudioDeviceUnknown) {
DebugMsg("BGMAudioDeviceManager::initWithError: output device not found");
if (error) {
*error = [NSError errorWithDomain:@kBGMAppBundleID code:kBGMErrorCode_OutputDeviceNotFound userInfo:nil];
}
stateLock = [NSRecursiveLock new];
bgmXPCHelperConnection = nil;
outputVolumeMenuItem = nil;
outputDeviceMenuSection = nil;
outputDevice = kAudioObjectUnknown;
try {
bgmDevice = new BGMBackgroundMusicDevice;
} catch (const CAException& e) {
LogError("BGMAudioDeviceManager::init: BGMDevice not found. (%d)", e.GetError());
self = nil;
return self;
}
@@ -81,151 +88,374 @@ public:
return self;
}
- (void) initOutputDevice {
CAHALAudioSystemObject audioSystem;
// outputDevice = BGMAudioDevice(CFSTR("AppleHDAEngineOutput:1B,0,1,1:0"));
AudioObjectID defaultDeviceID = audioSystem.GetDefaultAudioDevice(false, false);
if (defaultDeviceID == bgmDevice.GetObjectID()) {
// TODO: If BGMDevice is already the default (because BGMApp didn't shutdown properly or it was set manually)
// we should temporarily disable BGMDevice so we can find out what the previous default was.
// For now, just pick the device with the lowest latency
UInt32 numDevices = audioSystem.GetNumberAudioDevices();
if (numDevices > 0) {
SInt32 minLatencyDeviceIdx = -1;
UInt32 minLatency = UINT32_MAX;
CAAutoArrayDelete<AudioObjectID> devices(numDevices);
audioSystem.GetAudioDevices(numDevices, devices);
for (UInt32 i = 0; i < numDevices; i++) {
BGMAudioDevice device(devices[i]);
BOOL isBGMDevice = device.GetObjectID() == bgmDevice.GetObjectID();
BOOL hasOutputChannels = device.GetTotalNumberChannels(/* inIsInput = */ false) > 0;
if (!isBGMDevice && hasOutputChannels) {
if (minLatencyDeviceIdx == -1) {
// First, look for any device other than BGMDevice
minLatencyDeviceIdx = i;
} else if (device.GetLatency(false) < minLatency) {
// Then compare the devices by their latencies
minLatencyDeviceIdx = i;
minLatency = device.GetLatency(false);
}
}
}
[self setOutputDeviceWithID:devices[minLatencyDeviceIdx] revertOnFailure:NO];
- (void) dealloc {
@try {
[stateLock lock];
if (bgmDevice) {
delete bgmDevice;
bgmDevice = nullptr;
}
} else {
[self setOutputDeviceWithID:defaultDeviceID revertOnFailure:NO];
}
assert(outputDevice.GetObjectID() != bgmDevice.GetObjectID());
// Log message
if (outputDevice.GetObjectID() == kAudioDeviceUnknown) {
CFStringRef outputDeviceUID = outputDevice.CopyDeviceUID();
DebugMsg("BGMAudioDeviceManager::initDevices: Set output device to %s",
CFStringGetCStringPtr(outputDeviceUID, kCFStringEncodingUTF8));
CFRelease(outputDeviceUID);
} @finally {
[stateLock unlock];
}
}
- (void) setOutputVolumeMenuItem:(BGMOutputVolumeMenuItem*)item {
outputVolumeMenuItem = item;
}
- (void) setOutputDeviceMenuSection:(BGMOutputDeviceMenuSection*)menuSection {
outputDeviceMenuSection = menuSection;
}
#pragma mark Systemwide Default Device
- (void) setBGMDeviceAsOSDefault {
CAHALAudioSystemObject audioSystem;
@synchronized (self) {
if (audioSystem.GetDefaultAudioDevice(false, true) == outputDevice.GetObjectID()) {
// The default system device was the same as the default device, so change that as well
audioSystem.SetDefaultAudioDevice(false, true, bgmDevice.GetObjectID());
}
audioSystem.SetDefaultAudioDevice(false, false, bgmDevice.GetObjectID());
// Note that there are two different "default" output devices on OS X: "output" and "system output". See
// kAudioHardwarePropertyDefaultSystemOutputDevice in AudioHardware.h.
- (NSError* __nullable) setBGMDeviceAsOSDefault {
try {
// Intentionally avoid taking stateLock before making calls to the HAL. See
// startPlayThroughSync.
CAMemoryBarrier();
bgmDevice->SetAsOSDefault();
} catch (const CAException& e) {
BGMLogExceptionIn("BGMAudioDeviceManager::setBGMDeviceAsOSDefault", e);
return [NSError errorWithDomain:@kBGMAppBundleID code:e.GetError() userInfo:nil];
}
return nil;
}
- (void) unsetBGMDeviceAsOSDefault {
CAHALAudioSystemObject audioSystem;
@synchronized (self) {
if (audioSystem.GetDefaultAudioDevice(false, true) == bgmDevice.GetObjectID()) {
// We changed the system output device to BGMDevice, which we only do if it initially matches the
// default output device, so change it back
audioSystem.SetDefaultAudioDevice(false, true, outputDevice.GetObjectID());
}
if (audioSystem.GetDefaultAudioDevice(false, false) == bgmDevice.GetObjectID()) {
audioSystem.SetDefaultAudioDevice(false, false, outputDevice.GetObjectID());
}
- (NSError* __nullable) unsetBGMDeviceAsOSDefault {
// Copy the devices so we can call the HAL without holding stateLock. See startPlayThroughSync.
BGMBackgroundMusicDevice* bgmDeviceCopy;
AudioDeviceID outputDeviceID;
@try {
[stateLock lock];
bgmDeviceCopy = bgmDevice;
outputDeviceID = outputDevice.GetObjectID();
} @finally {
[stateLock unlock];
}
if (outputDeviceID == kAudioObjectUnknown) {
return [NSError errorWithDomain:@kBGMAppBundleID
code:kBGMErrorCode_OutputDeviceNotFound
userInfo:nil];
}
try {
bgmDeviceCopy->UnsetAsOSDefault(outputDeviceID);
} catch (const CAException& e) {
BGMLogExceptionIn("BGMAudioDeviceManager::unsetBGMDeviceAsOSDefault", e);
return [NSError errorWithDomain:@kBGMAppBundleID code:e.GetError() userInfo:nil];
}
return nil;
}
#pragma mark Accessors
- (CAHALAudioDevice) bgmDevice {
return bgmDevice;
- (BGMBackgroundMusicDevice) bgmDevice {
return *bgmDevice;
}
- (CAHALAudioDevice) outputDevice {
return outputDevice;
}
- (BOOL) isOutputDevice:(AudioObjectID)deviceID {
@synchronized (self) {
@try {
[stateLock lock];
return deviceID == outputDevice.GetObjectID();
} @finally {
[stateLock unlock];
}
}
- (BOOL) setOutputDeviceWithID:(AudioObjectID)deviceID revertOnFailure:(BOOL)revertOnFailure {
DebugMsg("BGMAudioDeviceManager::setOutputDeviceWithID: Setting output device. deviceID=%u", deviceID);
// Set up playthrough and control sync
BGMAudioDevice newOutputDevice(deviceID);
try {
@synchronized (self) {
// Mirror changes in BGMDevice's controls to the new output device's.
deviceControlSync = BGMDeviceControlSync(bgmDevice, newOutputDevice);
- (BOOL) isOutputDataSource:(UInt32)dataSourceID {
BOOL isOutputDataSource = NO;
@try {
[stateLock lock];
try {
AudioObjectPropertyScope scope = kAudioDevicePropertyScopeOutput;
UInt32 channel = 0;
// Stream audio from BGMDevice to the output device.
//
// TODO: Should this be done async? Some output devices take a long time to start IO (e.g. AirPlay) and I
// assume this blocks the main thread. Haven't tried it to check, though.
playThrough = BGMPlayThrough(bgmDevice, newOutputDevice);
outputDevice = BGMAudioDevice(deviceID);
isOutputDataSource =
outputDevice.HasDataSourceControl(scope, channel) &&
(dataSourceID == outputDevice.GetCurrentDataSourceID(scope, channel));
} catch (const CAException& e) {
BGMLogException(e);
}
// Start playthrough because audio might be playing.
//
// TODO: If audio isn't playing, this makes playthrough run until the user plays audio and then stops it again,
// which wastes CPU. I think we could just have Start() call StopIfIdle(), but I haven't tried it yet.
} @finally {
[stateLock unlock];
}
return isOutputDataSource;
}
#pragma mark Output Device
- (NSError* __nullable) setOutputDeviceWithID:(AudioObjectID)deviceID
revertOnFailure:(BOOL)revertOnFailure {
return [self setOutputDeviceWithIDImpl:deviceID
dataSourceID:nil
revertOnFailure:revertOnFailure];
}
- (NSError* __nullable) setOutputDeviceWithID:(AudioObjectID)deviceID
dataSourceID:(UInt32)dataSourceID
revertOnFailure:(BOOL)revertOnFailure {
return [self setOutputDeviceWithIDImpl:deviceID
dataSourceID:&dataSourceID
revertOnFailure:revertOnFailure];
}
- (NSError* __nullable) setOutputDeviceWithIDImpl:(AudioObjectID)newDeviceID
dataSourceID:(UInt32* __nullable)dataSourceID
revertOnFailure:(BOOL)revertOnFailure {
DebugMsg("BGMAudioDeviceManager::setOutputDeviceWithIDImpl: Setting output device. newDeviceID=%u",
newDeviceID);
@try {
[stateLock lock];
AudioDeviceID currentDeviceID = outputDevice.GetObjectID(); // (Doesn't throw.)
try {
[self setOutputDeviceWithIDImpl:newDeviceID
dataSourceID:dataSourceID
currentDeviceID:currentDeviceID];
} catch (const CAException& e) {
BGMAssert(e.GetError() != kAudioHardwareNoError,
"CAException with kAudioHardwareNoError");
return [self failedToSetOutputDevice:newDeviceID
errorCode:e.GetError()
revertTo:(revertOnFailure ? &currentDeviceID : nullptr)];
} catch (...) {
return [self failedToSetOutputDevice:newDeviceID
errorCode:kAudioHardwareUnspecifiedError
revertTo:(revertOnFailure ? &currentDeviceID : nullptr)];
}
// Tell other classes and BGMXPCHelper that we changed the output device.
[self propagateOutputDeviceChange];
} @finally {
[stateLock unlock];
}
return nil;
}
// Throws CAException.
- (void) setOutputDeviceWithIDImpl:(AudioObjectID)newDeviceID
dataSourceID:(UInt32* __nullable)dataSourceID
currentDeviceID:(AudioObjectID)currentDeviceID {
if (newDeviceID != currentDeviceID) {
BGMAudioDevice newOutputDevice(newDeviceID);
[self setOutputDeviceForPlaythroughAndControlSync:newOutputDevice];
outputDevice = newOutputDevice;
}
// Set the output device to use the new data source.
if (dataSourceID) {
// TODO: If this fails, ideally we'd still start playthrough and return an error, but not
// revert the device. It would probably be a bit awkward, though.
[self setDataSource:*dataSourceID device:outputDevice];
}
if (newDeviceID != currentDeviceID) {
// We successfully changed to the new device. Start playthrough on it, since audio might be
// playing. (If we only changed the data source, playthrough will already be running if it
// needs to be.)
playThrough.Start();
} catch (CAException e) {
// Using LogWarning from PublicUtility instead of NSLog here crashes from a bad access. Not sure why.
NSLog(@"BGMAudioDeviceManager::setOutputDeviceWithID: Couldn't set device with ID %u as output device. %s %s%d.",
newOutputDevice.GetObjectID(),
(revertOnFailure ? "Will attempt to revert to the previous device." : ""),
"Error: ", e.GetError());
playThrough_UISounds.Start();
// But stop playthrough if audio isn't playing, since it uses CPU.
playThrough.StopIfIdle();
playThrough_UISounds.StopIfIdle();
}
CFStringRef outputDeviceUID = outputDevice.CopyDeviceUID();
DebugMsg("BGMAudioDeviceManager::setOutputDeviceWithIDImpl: Set output device to %s (%d)",
CFStringGetCStringPtr(outputDeviceUID, kCFStringEncodingUTF8),
outputDevice.GetObjectID());
CFRelease(outputDeviceUID);
}
// Changes the output device that playthrough plays audio to and that BGMDevice's controls are
// kept in sync with. Throws CAException.
- (void) setOutputDeviceForPlaythroughAndControlSync:(const BGMAudioDevice&)newOutputDevice {
// Deactivate playthrough rather than stopping it so it can't be started by HAL notifications
// while we're updating deviceControlSync.
playThrough.Deactivate();
playThrough_UISounds.Deactivate();
deviceControlSync.SetDevices(*bgmDevice, newOutputDevice);
deviceControlSync.Activate();
// Stream audio from BGMDevice to the new output device. This blocks while the old device stops
// IO.
playThrough.SetDevices(bgmDevice, &newOutputDevice);
playThrough.Activate();
// TODO: Support setting different devices as the default output device and the default system
// output device the way OS X does?
BGMAudioDevice uiSoundsDevice = bgmDevice->GetUISoundsBGMDeviceInstance();
playThrough_UISounds.SetDevices(&uiSoundsDevice, &newOutputDevice);
playThrough_UISounds.Activate();
}
- (void) setDataSource:(UInt32)dataSourceID device:(BGMAudioDevice&)device {
BGMLogAndSwallowExceptions("BGMAudioDeviceManager::setDataSource", ([&] {
AudioObjectPropertyScope scope = kAudioObjectPropertyScopeOutput;
UInt32 channel = 0;
if (device.DataSourceControlIsSettable(scope, channel)) {
DebugMsg("BGMAudioDeviceManager::setOutputDeviceWithID: Setting dataSourceID=%u",
dataSourceID);
device.SetCurrentDataSourceByID(scope, channel, dataSourceID);
}
}));
}
- (void) propagateOutputDeviceChange {
// Tell BGMXPCHelper that the output device has changed.
[self sendOutputDeviceToBGMXPCHelper];
// Update the menu item for the volume of the output device.
[outputVolumeMenuItem outputDeviceDidChange];
[outputDeviceMenuSection outputDeviceDidChange];
}
- (NSError*) failedToSetOutputDevice:(AudioDeviceID)deviceID
errorCode:(OSStatus)errorCode
revertTo:(AudioDeviceID*)revertTo {
// Using LogWarning from PublicUtility instead of NSLog here crashes from a bad access. Not sure why.
// TODO: Possibly caused by a bug in CADebugMacros.cpp. See commit ab9d4cd.
NSLog(@"BGMAudioDeviceManager::failedToSetOutputDevice: Couldn't set device with ID %u as output device. "
"%s%d. %@",
deviceID,
"Error: ", errorCode,
(revertTo ? [NSString stringWithFormat:@"Will attempt to revert to the previous device. "
"Previous device ID: %u.", *revertTo] : @""));
NSDictionary* __nullable info = nil;
if (revertTo) {
// Try to reactivate the original device listener and playthrough. (Sorry about the mutual recursion.)
NSError* __nullable revertError = [self setOutputDeviceWithID:*revertTo revertOnFailure:NO];
if (revertOnFailure) {
// Try to reactivate the original device listener and playthrough
[self setOutputDeviceWithID:outputDevice.GetObjectID() revertOnFailure:NO];
return NO;
if (revertError) {
info = @{ @"revertError": (NSError*)revertError };
}
} else {
// TODO: Handle this error better in callers. Maybe show an error dialog and try to set the original
// default device as the output device.
NSLog(@"BGMAudioDeviceManager::failedToSetOutputDevice: Failed to revert to the previous device.");
}
return [NSError errorWithDomain:@kBGMAppBundleID code:errorCode userInfo:info];
}
- (OSStatus) startPlayThroughSync:(BOOL)forUISoundsDevice {
// We can only try for stateLock because setOutputDeviceWithID might have already taken it, then made a
// HAL request to BGMDevice and be waiting for the response. Some of the requests setOutputDeviceWithID
// makes to BGMDevice block in the HAL if another thread is in BGM_Device::StartIO.
//
// Since BGM_Device::StartIO calls this method (via XPC), waiting for setOutputDeviceWithID to release
// stateLock could cause deadlocks. Instead we return early with an error code that BGMDriver knows to
// ignore, since the output device is (almost certainly) being changed and we can't avoid dropping frames
// while the output device starts up.
OSStatus err;
BOOL gotLock;
@try {
gotLock = [stateLock tryLock];
if (gotLock) {
BGMPlayThrough& pt = (forUISoundsDevice ? playThrough_UISounds : playThrough);
// Playthrough might not have been notified that BGMDevice is starting yet, so make sure
// playthrough is starting. This way we won't drop any frames while waiting for the HAL to send
// that notification. We can't be completely sure this is safe from deadlocking, though, since
// CoreAudio is closed-source.
//
// TODO: Test this on older OS X versions. Differences in the CoreAudio implementations could
// cause deadlocks.
BGMLogAndSwallowExceptionsMsg("BGMAudioDeviceManager::startPlayThroughSync",
"Starting playthrough", [&] {
pt.Start();
});
err = pt.WaitForOutputDeviceToStart();
BGMAssert(err != BGMPlayThrough::kDeviceNotStarting, "Playthrough didn't start");
} else {
// TODO: Handle in callers. (Maybe show an error dialog and try to set the original default device as the
// output device.)
Throw(e);
LogWarning("BGMAudioDeviceManager::startPlayThroughSync: Didn't get state lock. Returning "
"early with kBGMErrorCode_ReturningEarly.");
err = kBGMErrorCode_ReturningEarly;
dispatch_async(BGMGetDispatchQueue_PriorityUserInteractive(), ^{
@try {
[stateLock lock];
BGMPlayThrough& pt = (forUISoundsDevice ? playThrough_UISounds : playThrough);
BGMLogAndSwallowExceptionsMsg("BGMAudioDeviceManager::startPlayThroughSync",
"Starting playthrough (dispatched)", [&] {
pt.Start();
});
BGMLogAndSwallowExceptions("BGMAudioDeviceManager::startPlayThroughSync", [&] {
pt.StopIfIdle();
});
} @finally {
[stateLock unlock];
}
});
}
} @finally {
if (gotLock) {
[stateLock unlock];
}
}
return YES;
return err;
}
- (OSStatus) waitForOutputDeviceToStart {
@synchronized (self) {
return playThrough.WaitForOutputDeviceToStart();
#pragma mark BGMXPCHelper Communication
- (void) setBGMXPCHelperConnection:(NSXPCConnection* __nullable)connection {
bgmXPCHelperConnection = connection;
// Tell BGMXPCHelper which device is the output device, since it might not be up-to-date.
[self sendOutputDeviceToBGMXPCHelper];
}
- (void) sendOutputDeviceToBGMXPCHelper {
NSXPCConnection* __nullable connection = bgmXPCHelperConnection;
if (connection)
{
id<BGMXPCHelperXPCProtocol> helperProxy =
[connection remoteObjectProxyWithErrorHandler:^(NSError* error) {
// We could wait a bit and try again, but it isn't that important.
NSLog(@"BGMAudioDeviceManager::sendOutputDeviceToBGMXPCHelper: Connection"
"error: %@", error);
}];
[helperProxy setOutputDeviceToMakeDefaultOnAbnormalTermination:outputDevice.GetObjectID()];
}
}
@end
#pragma clang assume_nonnull end
@@ -14,45 +14,35 @@
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMAppTests.m
// BGMAppTests
// BGMAutoPauseMenuItem.h
// BGMApp
//
// Copyright © 2016 Kyle Neideck
//
// Local Includes
#import "BGMAutoPauseMusic.h"
#import "BGMMusicPlayers.h"
#import "BGMUserDefaults.h"
// System Includes
#import <Cocoa/Cocoa.h>
#import <XCTest/XCTest.h>
@interface BGMAppTests : XCTestCase
#pragma clang assume_nonnull begin
@interface BGMAutoPauseMenuItem : NSObject
- (instancetype) initWithMenuItem:(NSMenuItem*)item
autoPauseMusic:(BGMAutoPauseMusic*)autoPause
musicPlayers:(BGMMusicPlayers*)players
userDefaults:(BGMUserDefaults*)defaults;
// Handle events passed along by the delegate (NSMenuDelegate) of the menu containing this menu item.
- (void) parentMenuNeedsUpdate;
- (void) parentMenuItemWillHighlight:(NSMenuItem* __nullable)item;
@end
@implementation BGMAppTests
// TODO: More than no tests
- (void)setUp {
[super setUp];
// Put setup code here. This method is called before the invocation of each test method in the class.
}
- (void)tearDown {
// Put teardown code here. This method is called after the invocation of each test method in the class.
[super tearDown];
}
- (void)testExample {
// This is an example of a functional test case.
XCTAssert(YES, @"Pass");
}
- (void)testPerformanceExample {
// This is an example of a performance test case.
[self measureBlock:^{
// Put the code you want to measure the time of here.
}];
}
@end
#pragma clang assume_nonnull end
+187
View File
@@ -0,0 +1,187 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMAutoPauseMenuItem.m
// BGMApp
//
// Copyright © 2016, 2019 Kyle Neideck
// Copyright © 2016 Tanner Hoke
//
// Self Include
#import "BGMAutoPauseMenuItem.h"
// Local Includes
#import "BGMAppWatcher.h"
#pragma clang assume_nonnull begin
static NSString* const kMenuItemTitleFormat = @"Auto-pause %@";
static NSString* const kMenuItemDisabledToolTipFormat = @"%@ doesn't appear to be running.";
// Wait time to disable/enable the auto-pause menu item, in seconds.
static SInt64 const kMenuItemUpdateWaitTime = 1;
@implementation BGMAutoPauseMenuItem {
BGMUserDefaults* userDefaults;
NSMenuItem* menuItem;
BGMAutoPauseMusic* autoPauseMusic;
BGMMusicPlayers* musicPlayers;
BGMAppWatcher* appWatcher;
}
- (instancetype) initWithMenuItem:(NSMenuItem*)item
autoPauseMusic:(BGMAutoPauseMusic*)autoPause
musicPlayers:(BGMMusicPlayers*)players
userDefaults:(BGMUserDefaults*)defaults {
if ((self = [super init])) {
menuItem = item;
autoPauseMusic = autoPause;
musicPlayers = players;
userDefaults = defaults;
// Enable/disable auto-pause to match the user's preferences setting.
if (userDefaults.autoPauseMusicEnabled) {
menuItem.state = NSOnState;
[autoPauseMusic enable];
} else {
menuItem.state = NSOffState;
[autoPauseMusic disable];
}
// Toggle auto-pause when the menu item is clicked.
menuItem.target = self;
menuItem.action = @selector(toggleAutoPauseMusic);
[self initMenuItemTitle];
}
return self;
}
- (void) initMenuItemTitle {
// Set the initial text, tool-tip, state, etc.
[self updateMenuItemTitle];
// Avoid retain cycles in case we ever want to destroy instances of this class.
BGMAutoPauseMenuItem* __weak weakSelf = self;
// Add a callback that enables/disables the Auto-pause Music menu item when the music player
// is launched/terminated.
void (^callback)(void) = ^{
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, kMenuItemUpdateWaitTime * NSEC_PER_SEC),
dispatch_get_main_queue(),
^{
BGMAutoPauseMenuItem* strongSelf = weakSelf;
[strongSelf updateMenuItemTitle];
});
};
appWatcher = [[BGMAppWatcher alloc] initWithAppLaunched:callback
appTerminated:callback
isMatchingBundleID:^BOOL(NSString* appBundleID) {
BGMAutoPauseMenuItem* strongSelf = weakSelf;
NSString* __nullable playerBundleID =
strongSelf->musicPlayers.selectedMusicPlayer.bundleID;
return playerBundleID && [appBundleID isEqualToString:(NSString*)playerBundleID];
}];
}
- (void) toggleAutoPauseMusic {
// The menu item was clicked.
if (menuItem.state == NSOnState) {
menuItem.state = NSOffState;
[autoPauseMusic disable];
} else {
menuItem.state = NSOnState;
[autoPauseMusic enable];
}
// Persist the change in the user's preferences.
userDefaults.autoPauseMusicEnabled = (menuItem.state == NSOnState);
}
- (void) updateMenuItemTitle {
[self updateMenuItemTitleWithHighlight:menuItem.isHighlighted];
}
- (void) updateMenuItemTitleWithHighlight:(BOOL)highlight {
// Set the title of the Auto-pause Music menu item, including the name of the selected music player.
NSString* musicPlayerName = musicPlayers.selectedMusicPlayer.name;
menuItem.title = [NSString stringWithFormat:kMenuItemTitleFormat, musicPlayerName];
// Make the Auto-pause Music menu item appear disabled if the application is not running.
//
// We don't actually disable it just in case the user decides to disable auto-pause and their music player isn't
// running. E.g. someone who only recently installed Background Music and doesn't want to use auto-pause at all.
if (musicPlayers.selectedMusicPlayer.running) {
menuItem.attributedTitle = nil;
menuItem.toolTip = nil;
} else {
// Hardcode the text colour grey to match disabled menu items (unless the menu item is highlighted, in which
// case use white).
//
// I couldn't figure out a way to do this without hardcoding the colours. There's no colour constant for this,
// except possibly disabledControlTextColor, which just leaves the text black for me. I also couldn't get the
// colours from the built-in NSColorLists.
//
// TODO: Can we make the tick mark grey as well?
NSString* __nullable appleInterfaceStyle =
[[NSUserDefaults standardUserDefaults] stringForKey:@"AppleInterfaceStyle"];
BOOL darkMode = [appleInterfaceStyle isEqualToString:@"Dark"];
NSColor* textColor = [NSColor colorWithHue:0
saturation:0
brightness:(highlight ? 1 : (darkMode ? 0.25 : 0.75))
alpha:1];
NSDictionary* attributes = @{ NSFontAttributeName: [NSFont menuBarFontOfSize:0], // Default font size
NSForegroundColorAttributeName: textColor };
NSAttributedString* pseudoDisabledTitle = [[NSAttributedString alloc] initWithString:menuItem.title
attributes:attributes];
menuItem.attributedTitle = pseudoDisabledTitle;
menuItem.toolTip = [NSString stringWithFormat:kMenuItemDisabledToolTipFormat, musicPlayerName];
}
}
#pragma mark Parent menu events
- (void) parentMenuNeedsUpdate {
[self updateMenuItemTitle];
}
- (void) parentMenuItemWillHighlight:(NSMenuItem* __nullable)item {
// Used to make the auto-pause menu item's text white when it's highlighted and change it back after.
//
// TODO: If you click the auto-pause menu item while it's disabled, it will initially appear highlighted next time
// you open the main menu.
// If item is nil or any other menu item, the auto-pause menu item will be unhighlighted.
BOOL willHighlightMenuItem = [item isEqual:menuItem];
// Only update the menu item if it's changing (from highlighted to unhighlighted or vice versa) to save a little
// CPU.
if (willHighlightMenuItem != menuItem.highlighted) {
[self updateMenuItemTitleWithHighlight:willHighlightMenuItem];
}
}
@end
#pragma clang assume_nonnull end
+7 -1
View File
@@ -25,16 +25,22 @@
// Local Includes
#import "BGMAudioDeviceManager.h"
#import "BGMMusicPlayers.h"
// System Includes
#import <Foundation/Foundation.h>
#pragma clang assume_nonnull begin
@interface BGMAutoPauseMusic : NSObject
- (id) initWithAudioDevices:(BGMAudioDeviceManager*)inAudioDevices;
- (id) initWithAudioDevices:(BGMAudioDeviceManager*)inAudioDevices musicPlayers:(BGMMusicPlayers*)inMusicPlayers;
- (void) enable;
- (void) disable;
@end
#pragma clang assume_nonnull end
+78 -28
View File
@@ -17,7 +17,7 @@
// BGMAutoPauseMusic.m
// BGMApp
//
// Copyright © 2016 Kyle Neideck
// Copyright © 2016, 2017 Kyle Neideck
//
// Self Include
@@ -27,6 +27,9 @@
#include "BGM_Types.h"
#import "BGMMusicPlayer.h"
// STL Includes
#import <algorithm> // std::max, std::min
// System Includes
#include <CoreAudio/AudioHardware.h>
#include <mach/mach_time.h>
@@ -36,34 +39,63 @@
// and other audio can have short periods of silence without causing music to play and quickly pause again. Of course, it's a
// trade-off against how long the music will overlap the other audio before it gets paused and how long the music will stay paused
// after a sound that was only slightly longer than the pause delay.
static UInt64 const kPauseDelayNSec = 1500 * NSEC_PER_MSEC;
// The delay before unpausing the music player is proportional to how long we paused it for, bounded by these limits. This makes it
// a bit less annoying when a sound is just long enough to cause an auto-pause.
//
// TODO: Make these settable in advanced settings?
static int const kPauseDelayMSecs = 1500;
static int const kUnpauseDelayMSecs = 3000;
// I haven't spent much time experimenting with different values for these constants, so they could probably be improved a fair
// bit.
//
// TODO: Would it be worth listening for kAudioDeviceCustomPropertyDeviceIsRunningSomewhereOtherThanBGMApp so we can unpause
// immediately if we haven't been paused for long and the non-music-player client stops IO? That would usually indicate that
// it doesn't intend to start playing audio again soon. We'd also have to deal with music players that don't stop IO when
// they're paused.
static UInt64 const kMaxUnpauseDelayNSec = 3500 * NSEC_PER_MSEC;
static UInt64 const kMinUnpauseDelayNSec = kMaxUnpauseDelayNSec / 10;
// We multiply the time spent paused by this factor to calculate the delay before we consider unpausing.
static Float32 const kUnpauseDelayWeightingFactor = 0.1f;
@implementation BGMAutoPauseMusic {
BOOL enabled;
BGMAudioDeviceManager* audioDevices;
BGMMusicPlayers* musicPlayers;
dispatch_queue_t listenerQueue;
// Have to keep track of the listener block we add so we can remove it later
// Have to keep track of the listener block we add so we can remove it later.
AudioObjectPropertyListenerBlock listenerBlock;
// True if BGMApp has paused musicPlayer and hasn't unpaused it yet. (Will be out of sync with the music player app if the user
// has unpaused it themselves.)
dispatch_queue_t pauseUnpauseMusicQueue;
// True if BGMApp has paused musicPlayer and hasn't unpaused it yet. (Will be out of sync with the music player app if the
// user has unpaused it themselves.)
BOOL wePaused;
// The times, in absolute time, that the BGMDevice last changed its audible state to silent...
UInt64 wentSilent;
// ...and to audible
// ...and to audible.
UInt64 wentAudible;
dispatch_queue_t pauseUnpauseMusicQueue;
}
- (id) initWithAudioDevices:(BGMAudioDeviceManager*)inAudioDevices {
- (id) initWithAudioDevices:(BGMAudioDeviceManager*)inAudioDevices musicPlayers:(BGMMusicPlayers*)inMusicPlayers {
if ((self = [super init])) {
enabled = NO;
audioDevices = inAudioDevices;
musicPlayers = inMusicPlayers;
enabled = NO;
wePaused = NO;
dispatch_queue_attr_t attr = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_DEFAULT, 0);
dispatch_queue_attr_t attr;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
if (&dispatch_queue_attr_make_with_qos_class) {
attr = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_DEFAULT, 0);
} else {
// OS X 10.9 fallback
attr = DISPATCH_QUEUE_SERIAL;
}
#pragma clang diagnostic pop
listenerQueue = dispatch_queue_create("com.bearisdriving.BGM.AutoPauseMusic.Listener", attr);
pauseUnpauseMusicQueue = dispatch_queue_create("com.bearisdriving.BGM.AutoPauseMusic.PauseUnpauseMusic", attr);
@@ -86,7 +118,7 @@ static int const kUnpauseDelayMSecs = 3000;
// so we have to check them all
for (int i = 0; i < inNumberAddresses; i++) {
if (inAddresses[i].mSelector == kAudioDeviceCustomPropertyDeviceAudibleState) {
SInt32 audibleState = [weakSelf deviceAudibleState];
BGMDeviceAudibleState audibleState = [weakSelf deviceAudibleState];
#if DEBUG
const char audibleStateStr[5] = CA4CCToCString(audibleState);
@@ -94,6 +126,8 @@ static int const kUnpauseDelayMSecs = 3000;
audibleStateStr);
#endif
// TODO: We shouldn't assume this block will only get called when BGMDevice's audible state changes. (Even if
// the Core Audio docs did specify that, there's no reason not to be fault tolerant.)
if (audibleState == kBGMDeviceIsAudible) {
[weakSelf queuePauseBlock];
} else if (audibleState == kBGMDeviceIsSilent) {
@@ -110,12 +144,8 @@ static int const kUnpauseDelayMSecs = 3000;
};
}
- (SInt32) deviceAudibleState {
SInt32 audibleState;
CFNumberRef audibleStateRef = static_cast<CFNumberRef>([audioDevices bgmDevice].GetPropertyData_CFType(kBGMAudibleStateAddress));
CFNumberGetValue(audibleStateRef, kCFNumberSInt32Type, &audibleState);
CFRelease(audibleStateRef);
return audibleState;
- (BGMDeviceAudibleState) deviceAudibleState {
return [audioDevices bgmDevice].GetAudibleState();
}
- (void) queuePauseBlock {
@@ -124,10 +154,10 @@ static int const kUnpauseDelayMSecs = 3000;
UInt64 startedPauseDelay = now;
DebugMsg("BGMAutoPauseMusic::queuePauseBlock: Dispatching pause block at %llu", now);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, kPauseDelayMSecs * NSEC_PER_MSEC),
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, kPauseDelayNSec),
pauseUnpauseMusicQueue,
^{
BOOL stillAudible = [self deviceAudibleState] == kBGMDeviceIsAudible;
BOOL stillAudible = ([self deviceAudibleState] == kBGMDeviceIsAudible);
DebugMsg("BGMAutoPauseMusic::queuePauseBlock: Running pause block dispatched at %llu.%s wentAudible=%llu",
startedPauseDelay,
@@ -137,8 +167,8 @@ static int const kUnpauseDelayMSecs = 3000;
// Pause if this is the most recent pause block and the device is still audible, which means the audible
// state hasn't changed since this block was queued. Also set wePaused to true if the player wasn't
// already paused.
if (!wePaused && startedPauseDelay == wentAudible && stillAudible) {
wePaused = [[BGMMusicPlayer selectedMusicPlayer] pause] || wePaused;
if (!wePaused && (startedPauseDelay == wentAudible) && stillAudible) {
wePaused = ([musicPlayers.selectedMusicPlayer pause] || wePaused);
}
});
}
@@ -148,11 +178,31 @@ static int const kUnpauseDelayMSecs = 3000;
wentSilent = now;
UInt64 startedUnpauseDelay = now;
DebugMsg("BGMAutoPauseMusic::queueUnpauseBlock: Dispatched unpause block at %llu", now);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, kUnpauseDelayMSecs * NSEC_PER_MSEC),
// Unpause sooner if we've only been paused for a short time. This is so a notification sound causing an auto-pause is
// less of an interruption.
//
// TODO: Fading in and out would make short pauses a lot less jarring because, if they were short enough, we wouldn't
// actually pause the music player. So you'd hear a dip in the music's volume rather than a gap.
UInt64 unpauseDelayNsec =
static_cast<UInt64>((wentSilent - wentAudible) * kUnpauseDelayWeightingFactor);
// Convert from absolute time to nanos.
mach_timebase_info_data_t info;
mach_timebase_info(&info);
unpauseDelayNsec = unpauseDelayNsec * info.numer / info.denom;
// Clamp.
unpauseDelayNsec = std::min(kMaxUnpauseDelayNSec, unpauseDelayNsec);
unpauseDelayNsec = std::max(kMinUnpauseDelayNSec, unpauseDelayNsec);
DebugMsg("BGMAutoPauseMusic::queueUnpauseBlock: Dispatched unpause block at %llu. unpauseDelayNsec=%llu",
now,
unpauseDelayNsec);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, unpauseDelayNsec),
pauseUnpauseMusicQueue,
^{
BOOL stillSilent = [self deviceAudibleState] == kBGMDeviceIsSilent;
BOOL stillSilent = ([self deviceAudibleState] == kBGMDeviceIsSilent);
DebugMsg("BGMAutoPauseMusic::queueUnpauseBlock: Running unpause block dispatched at %llu.%s%s wentSilent=%llu",
startedUnpauseDelay,
@@ -162,9 +212,9 @@ static int const kUnpauseDelayMSecs = 3000;
// Unpause if we were the one who paused. Also check that this is the most recent unpause block and the
// device is still silent, which means the audible state hasn't changed since this block was queued.
if (wePaused && startedUnpauseDelay == wentSilent && stillSilent) {
if (wePaused && (startedUnpauseDelay == wentSilent) && stillSilent) {
wePaused = NO;
[[BGMMusicPlayer selectedMusicPlayer] unpause];
[musicPlayers.selectedMusicPlayer unpause];
}
});
}
+321
View File
@@ -0,0 +1,321 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMBackgroundMusicDevice.cpp
// BGMApp
//
// Copyright © 2016, 2017 Kyle Neideck
// Copyright © 2017 Andrew Tonner
//
// Self Include
#include "BGMBackgroundMusicDevice.h"
// Local Includes
#include "BGM_Types.h"
#include "BGM_Utils.h"
// PublicUtility Includes
#include "CADebugMacros.h"
#include "CAHALAudioSystemObject.h"
#include "CACFArray.h"
#include "CACFDictionary.h"
// STL Includes
#include <map>
#pragma clang assume_nonnull begin
#pragma mark Construction/Destruction
BGMBackgroundMusicDevice::BGMBackgroundMusicDevice()
:
BGMAudioDevice(CFSTR(kBGMDeviceUID)),
mUISoundsBGMDevice(CFSTR(kBGMDeviceUID_UISounds))
{
if((GetObjectID() == kAudioObjectUnknown) || (mUISoundsBGMDevice == kAudioObjectUnknown))
{
LogError("BGMBackgroundMusicDevice::BGMBackgroundMusicDevice: Error getting BGMDevice ID");
Throw(CAException(kAudioHardwareIllegalOperationError));
}
};
BGMBackgroundMusicDevice::~BGMBackgroundMusicDevice()
{
}
#pragma mark Systemwide Default Device
void BGMBackgroundMusicDevice::SetAsOSDefault()
{
DebugMsg("BGMBackgroundMusicDevice::SetAsOSDefault: Setting the system's default audio device "
"to BGMDevice");
CAHALAudioSystemObject audioSystem;
AudioDeviceID defaultDevice = audioSystem.GetDefaultAudioDevice(false, false);
AudioDeviceID systemDefaultDevice = audioSystem.GetDefaultAudioDevice(false, true);
if(systemDefaultDevice == defaultDevice)
{
// The default system device is the same as the default device, so change both of them.
//
// Use the UI sounds instance of BGMDevice because the default system output device is the
// device "to use for system related sound". The allows BGMDriver to tell when the audio it
// receives is UI-related.
audioSystem.SetDefaultAudioDevice(false, true, mUISoundsBGMDevice);
}
audioSystem.SetDefaultAudioDevice(false, false, GetObjectID());
}
void BGMBackgroundMusicDevice::UnsetAsOSDefault(AudioDeviceID inOutputDeviceID)
{
CAHALAudioSystemObject audioSystem;
// Set BGMApp's output device as OS X's default output device.
bool bgmDeviceIsDefault =
(audioSystem.GetDefaultAudioDevice(false, false) == GetObjectID());
if(bgmDeviceIsDefault)
{
DebugMsg("BGMBackgroundMusicDevice::UnsetAsOSDefault: Setting the system's default output "
"device back to device %d", inOutputDeviceID);
audioSystem.SetDefaultAudioDevice(false, false, inOutputDeviceID);
}
// Set BGMApp's output device as OS X's default system output device.
bool bgmDeviceIsSystemDefault =
(audioSystem.GetDefaultAudioDevice(false, true) == mUISoundsBGMDevice);
// If we changed the default system output device to BGMDevice, which we only do if it's set to
// the same device as the default output device, change it back to the previous device.
if(bgmDeviceIsSystemDefault)
{
DebugMsg("BGMBackgroundMusicDevice::UnsetAsOSDefault: Setting the system's default system "
"output device back to device %d", inOutputDeviceID);
audioSystem.SetDefaultAudioDevice(false, true, inOutputDeviceID);
}
}
#pragma mark App Volumes
CFArrayRef BGMBackgroundMusicDevice::GetAppVolumes() const
{
CFTypeRef appVolumes = GetPropertyData_CFType(kBGMAppVolumesAddress);
ThrowIfNULL(appVolumes,
CAException(kAudioHardwareIllegalOperationError),
"BGMBackgroundMusicDevice::GetAppVolumes: !appVolumes");
ThrowIf(CFGetTypeID(appVolumes) != CFArrayGetTypeID(),
CAException(kAudioHardwareIllegalOperationError),
"BGMBackgroundMusicDevice::GetAppVolumes: Expected CFArray value");
return static_cast<CFArrayRef>(appVolumes);
}
void BGMBackgroundMusicDevice::SetAppVolume(SInt32 inVolume,
pid_t inAppProcessID,
CFStringRef __nullable inAppBundleID)
{
BGMAssert((kAppRelativeVolumeMinRawValue <= inVolume) &&
(inVolume <= kAppRelativeVolumeMaxRawValue),
"BGMBackgroundMusicDevice::SetAppVolume: Volume out of bounds");
// Clamp the volume to [kAppRelativeVolumeMinRawValue, kAppPanRightRawValue].
inVolume = std::max(kAppRelativeVolumeMinRawValue, inVolume);
inVolume = std::min(kAppRelativeVolumeMaxRawValue, inVolume);
SendAppVolumeOrPanToBGMDevice(inVolume,
CFSTR(kBGMAppVolumesKey_RelativeVolume),
inAppProcessID,
inAppBundleID);
}
void BGMBackgroundMusicDevice::SetAppPanPosition(SInt32 inPanPosition,
pid_t inAppProcessID,
CFStringRef __nullable inAppBundleID)
{
BGMAssert((kAppPanLeftRawValue <= inPanPosition) && (inPanPosition <= kAppPanRightRawValue),
"BGMBackgroundMusicDevice::SetAppPanPosition: Pan position out of bounds");
// Clamp the pan position to [kAppPanLeftRawValue, kAppPanRightRawValue].
inPanPosition = std::max(kAppPanLeftRawValue, inPanPosition);
inPanPosition = std::min(kAppPanRightRawValue, inPanPosition);
SendAppVolumeOrPanToBGMDevice(inPanPosition,
CFSTR(kBGMAppVolumesKey_PanPosition),
inAppProcessID,
inAppBundleID);
}
void BGMBackgroundMusicDevice::SendAppVolumeOrPanToBGMDevice(SInt32 inNewValue,
CFStringRef inVolumeTypeKey,
pid_t inAppProcessID,
CFStringRef __nullable inAppBundleID)
{
CACFArray appVolumeChanges(true);
auto addVolumeChange = [&] (pid_t pid, CFStringRef bundleID)
{
CACFDictionary appVolumeChange(true);
appVolumeChange.AddSInt32(CFSTR(kBGMAppVolumesKey_ProcessID), pid);
appVolumeChange.AddString(CFSTR(kBGMAppVolumesKey_BundleID), bundleID);
appVolumeChange.AddSInt32(inVolumeTypeKey, inNewValue);
appVolumeChanges.AppendDictionary(appVolumeChange.GetDict());
};
addVolumeChange(inAppProcessID, inAppBundleID);
// Add the same change for each process the app is responsible for.
for(CACFString responsibleBundleID : ResponsibleBundleIDsOf(CACFString(inAppBundleID)))
{
// Send -1 as the PID so this volume will only ever be matched by bundle ID.
addVolumeChange(-1, responsibleBundleID.GetCFString());
}
CFPropertyListRef changesPList = appVolumeChanges.AsPropertyList();
// Send the change to BGMDevice.
SetPropertyData_CFType(kBGMAppVolumesAddress, changesPList);
// Also send it to the instance of BGMDevice that handles UI sounds.
mUISoundsBGMDevice.SetPropertyData_CFType(kBGMAppVolumesAddress, changesPList);
}
// This is a temporary solution that lets us control the volumes of some multiprocess apps, i.e.
// apps that play their audio from a process with a different bundle ID.
//
// We can't just check the child processes of the apps' main processes because they're usually
// created with launchd rather than being actual child processes. There's a private API to get the
// processes that an app is "responsible for", so we'll try to use it in the proper fix and only use
// this list if the API doesn't work.
//
// static
std::vector<CACFString>
BGMBackgroundMusicDevice::ResponsibleBundleIDsOf(CACFString inParentBundleID)
{
if(!inParentBundleID.IsValid())
{
return {};
}
std::map<CACFString, std::vector<CACFString>> bundleIDMap = {
// Finder
{ "com.apple.finder",
{ "com.apple.quicklook.ui.helper",
"com.apple.quicklook.QuickLookUIService" } },
// Safari
{ "com.apple.Safari", { "com.apple.WebKit.WebContent" } },
// Firefox
{ "org.mozilla.firefox", { "org.mozilla.plugincontainer" } },
// Firefox Nightly
{ "org.mozilla.nightly", { "org.mozilla.plugincontainer" } },
// VMWare Fusion
{ "com.vmware.fusion", { "com.vmware.vmware-vmx" } },
// Parallels
{ "com.parallels.desktop.console", { "com.parallels.vm" } },
// MPlayer OSX Extended
{ "hu.mplayerhq.mplayerosx.extended",
{ "ch.sttz.mplayerosx.extended.binaries.officialsvn" } },
// Discord
{ "com.hnc.Discord", { "com.hnc.Discord.helper" } },
// Skype
{ "com.skype.skype", { "com.skype.skype.Helper" } }
};
// Parallels' VM "dock helper" apps have bundle IDs like
// com.parallels.winapp.87f6bfc236d64d70a81c47f6243add4c.f5a25fdede514f7aa0a475a1873d3287.fs
if(inParentBundleID.StartsWith(CFSTR("com.parallels.winapp.")))
{
return { "com.parallels.vm" };
}
return bundleIDMap[inParentBundleID];
}
#pragma mark Audible State
BGMDeviceAudibleState BGMBackgroundMusicDevice::GetAudibleState() const
{
CFTypeRef propertyDataRef = GetPropertyData_CFType(kBGMAudibleStateAddress);
ThrowIfNULL(propertyDataRef,
CAException(kAudioHardwareIllegalOperationError),
"BGMBackgroundMusicDevice::GetAudibleState: !propertyDataRef");
ThrowIf(CFGetTypeID(propertyDataRef) != CFNumberGetTypeID(),
CAException(kAudioHardwareIllegalOperationError),
"BGMBackgroundMusicDevice::GetAudibleState: Property was not a CFNumber");
CFNumberRef audibleStateRef = static_cast<CFNumberRef>(propertyDataRef);
BGMDeviceAudibleState audibleState;
Boolean success = CFNumberGetValue(audibleStateRef, kCFNumberSInt32Type, &audibleState);
CFRelease(audibleStateRef);
ThrowIf(!success,
CAException(kAudioHardwareIllegalOperationError),
"BGMBackgroundMusicDevice::GetMusicPlayerProcessID: CFNumberGetValue failed");
return audibleState;
}
#pragma mark Music Player
pid_t BGMBackgroundMusicDevice::GetMusicPlayerProcessID() const
{
CFTypeRef propertyDataRef = GetPropertyData_CFType(kBGMMusicPlayerProcessIDAddress);
ThrowIfNULL(propertyDataRef,
CAException(kAudioHardwareIllegalOperationError),
"BGMBackgroundMusicDevice::GetMusicPlayerProcessID: !propertyDataRef");
ThrowIf(CFGetTypeID(propertyDataRef) != CFNumberGetTypeID(),
CAException(kAudioHardwareIllegalOperationError),
"BGMBackgroundMusicDevice::GetMusicPlayerProcessID: Property was not a CFNumber");
CFNumberRef pidRef = static_cast<CFNumberRef>(propertyDataRef);
pid_t pid;
Boolean success = CFNumberGetValue(pidRef, kCFNumberIntType, &pid);
CFRelease(pidRef);
ThrowIf(!success,
CAException(kAudioHardwareIllegalOperationError),
"BGMBackgroundMusicDevice::GetMusicPlayerProcessID: CFNumberGetValue failed");
return pid;
}
CFStringRef BGMBackgroundMusicDevice::GetMusicPlayerBundleID() const
{
CFStringRef bundleID = GetPropertyData_CFString(kBGMMusicPlayerBundleIDAddress);
ThrowIfNULL(bundleID,
CAException(kAudioHardwareIllegalOperationError),
"BGMBackgroundMusicDevice::GetMusicPlayerBundleID: !bundleID");
return bundleID;
}
#pragma clang assume_nonnull end
+196
View File
@@ -0,0 +1,196 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMBackgroundMusicDevice.h
// BGMApp
//
// Copyright © 2017 Kyle Neideck
//
// The interface to BGMDevice, the main virtual device published by BGMDriver, and the second
// instance of that device, which handles UI-related audio. In most cases, users of this class
// should be able to think of it as representing a single device.
//
// BGMDevice is the device that appears as "Background Music" in programs that list the output
// devices, e.g. System Preferences. It receives the system's audio, processes it and sends it to
// BGMApp by publishing an input stream. BGMApp then plays the audio on the user's real output
// device.
//
// See BGMDriver/BGMDriver/BGM_Device.h.
//
#ifndef BGMApp__BGMBackgroundMusicDevice
#define BGMApp__BGMBackgroundMusicDevice
// Superclass Includes
#include "BGMAudioDevice.h"
// Local Includes
#include "BGM_Types.h"
// PublicUtility Includes
#include "CACFString.h"
// STL Includes
#include <vector>
#pragma clang assume_nonnull begin
class BGMBackgroundMusicDevice
:
public BGMAudioDevice
{
#pragma mark Construction/Destruction
public:
/*!
@throws CAException If BGMDevice is not found or the HAL returns an error when queried for
BGMDevice's current Audio Object ID.
*/
BGMBackgroundMusicDevice();
virtual ~BGMBackgroundMusicDevice();
#pragma mark Systemwide Default Device
public:
/*!
Set BGMDevice as the default audio device for all processes.
@throws CAException If the HAL responds with an error.
*/
void SetAsOSDefault();
/*!
Replace BGMDevice as the default device with the output device.
@throws CAException If the HAL responds with an error.
*/
void UnsetAsOSDefault(AudioDeviceID inOutputDeviceID);
#pragma mark App Volumes
public:
/*!
@return The current value of BGMDevice's kAudioDeviceCustomPropertyAppVolumes property. See
BGM_Types.h.
@throws CAException If the HAL returns an error or a non-array type. Callers are responsible
for validating and type-checking the values contained in the array.
*/
CFArrayRef GetAppVolumes() const;
/*!
@param inVolume A value between kAppRelativeVolumeMinRawValue and kAppRelativeVolumeMaxRawValue
from BGM_Types.h. See kBGMAppVolumesKey_RelativeVolume in BGM_Types.h.
@param inAppProcessID The ID of app's main process (or the process it uses to play audio, if
you've managed to figure that out). If an app has multiple audio
processes, you can just set the volume for each of them. Pass -1 to omit
this param.
@param inAppBundleID The app's bundle ID. Pass null to omit this param.
@throws CAException If the HAL returns an error when this function sends the volume change to
BGMDevice.
*/
void SetAppVolume(SInt32 inVolume,
pid_t inAppProcessID,
CFStringRef __nullable inAppBundleID);
/*!
@param inPanPosition A value between kAppPanLeftRawValue and kAppPanRightRawValue from
BGM_Types.h. A negative value has a higher proportion of left channel, and
a positive value has a higher proportion of right channel.
@param inAppProcessID The ID of app's main process (or the process it uses to play audio, if
you've managed to figure that out). If an app has multiple audio
processes, you can just set the pan position for each of them. Pass -1 to
omit this param.
@param inAppBundleID The app's bundle ID. Pass null to omit this param.
@throws CAException If the HAL returns an error when this function sends the pan position
change to BGMDevice.
*/
void SetAppPanPosition(SInt32 inPanPosition,
pid_t inAppProcessID,
CFStringRef __nullable inAppBundleID);
private:
void SendAppVolumeOrPanToBGMDevice(SInt32 inNewValue,
CFStringRef inVolumeTypeKey,
pid_t inAppProcessID,
CFStringRef __nullable inAppBundleID);
static std::vector<CACFString>
ResponsibleBundleIDsOf(CACFString inParentBundleID);
#pragma mark Audible State
public:
/*!
@return BGMDevice's current "audible state", which can be either silent, silent except for the
user's music player or audible, meaning a program other than the music player is
playing audio.
@throws CAException If the HAL returns an error or invalid data when queried.
@see kAudioDeviceCustomPropertyDeviceAudibleState in BGM_Types.h.
*/
BGMDeviceAudibleState GetAudibleState() const;
#pragma mark Music Player
public:
/*!
@return The value of BGMDevice's property for the selected music player's process ID. Zero if
the property is unset. (We assume kernel_task will never be the user's music player.)
@throws CAException If the HAL returns an error or an invalid PID when queried.
@see kAudioDeviceCustomPropertyMusicPlayerProcessID in BGM_Types.h.
*/
virtual pid_t GetMusicPlayerProcessID() const;
/*!
Set the value of BGMDevice's property for the selected music player's process ID. Pass zero to
unset the property. Setting this property will unset the bundle ID version of the property.
@throws CAException If the HAL returns an error.
@see kAudioDeviceCustomPropertyMusicPlayerProcessID in BGM_Types.h.
*/
virtual void SetMusicPlayerProcessID(CFNumberRef inProcessID) {
SetPropertyData_CFType(kBGMMusicPlayerProcessIDAddress, inProcessID); }
/*!
@return The value of BGMDevice's property for the selected music player's bundle ID. The empty
string if the property is unset.
@throws CAException If the HAL returns an error or an invalid bundle ID when queried.
@see kAudioDeviceCustomPropertyMusicPlayerBundleID in BGM_Types.h.
*/
virtual CFStringRef GetMusicPlayerBundleID() const;
/*!
Set the value of BGMDevice's property for the selected music player's bundle ID. Pass the empty
string to unset the property. Setting this property will unset the process ID version of the
property.
@throws CAException If the HAL returns an error.
@see kAudioDeviceCustomPropertyMusicPlayerBundleID in BGM_Types.h.
*/
virtual void SetMusicPlayerBundleID(CFStringRef inBundleID) {
SetPropertyData_CFString(kBGMMusicPlayerBundleIDAddress, inBundleID); }
#pragma mark UI Sounds Instance
public:
/*! @return The instance of BGMDevice that handles UI sounds. */
BGMAudioDevice GetUISoundsBGMDeviceInstance() { return mUISoundsBGMDevice; }
private:
/*! The instance of BGMDevice that handles UI sounds. */
BGMAudioDevice mUISoundsBGMDevice;
};
#pragma clang assume_nonnull end
#endif /* BGMApp__BGMBackgroundMusicDevice */
+139 -259
View File
@@ -17,7 +17,7 @@
// BGMDeviceControlSync.cpp
// BGMApp
//
// Copyright © 2016 Kyle Neideck
// Copyright © 2016, 2017 Kyle Neideck
//
// Self Include
@@ -25,13 +25,13 @@
// Local Includes
#include "BGM_Types.h"
#include "BGM_Utils.h"
// System Includes
#include <AudioToolbox/AudioServices.h>
// PublicUtility Includes
#include "CAPropertyAddress.h"
// AudioObjectPropertyElement docs: "Elements are numbered sequentially where 0 represents the master element."
static const AudioObjectPropertyElement kMasterChannel = 0;
#pragma clang assume_nonnull begin
static const AudioObjectPropertyAddress kMutePropertyAddress =
{ kAudioDevicePropertyMute, kAudioObjectPropertyScopeOutput, kAudioObjectPropertyElementMaster };
@@ -41,31 +41,55 @@ static const AudioObjectPropertyAddress kVolumePropertyAddress =
#pragma mark Construction/Destruction
BGMDeviceControlSync::BGMDeviceControlSync(CAHALAudioDevice inBGMDevice, CAHALAudioDevice inOutputDevice)
BGMDeviceControlSync::BGMDeviceControlSync(AudioObjectID inBGMDevice,
AudioObjectID inOutputDevice,
CAHALAudioSystemObject inAudioSystem)
:
mBGMDevice(inBGMDevice),
mOutputDevice(inOutputDevice)
mOutputDevice(inOutputDevice),
mAudioSystem(inAudioSystem),
mBGMDeviceControlsList(inBGMDevice)
{
Activate();
}
BGMDeviceControlSync::~BGMDeviceControlSync()
{
Deactivate();
BGMLogAndSwallowExceptions("BGMDeviceControlSync::~BGMDeviceControlSync", [&] {
CAMutex::Locker locker(mMutex);
Deactivate();
});
}
void BGMDeviceControlSync::Activate()
{
ThrowIf((mBGMDevice.GetObjectID() == kAudioDeviceUnknown || mOutputDevice.GetObjectID() == kAudioDeviceUnknown),
CAMutex::Locker locker(mMutex);
ThrowIf((mBGMDevice.GetObjectID() == kAudioObjectUnknown || mOutputDevice.GetObjectID() == kAudioObjectUnknown),
BGM_DeviceNotSetException(),
"BGMDeviceControlSync::Activate: Both the output device and BGMDevice must be set to start synchronizing their controls");
// Init BGMDevice controls to match output device
CopyVolume(mOutputDevice, mBGMDevice, kAudioObjectPropertyScopeOutput);
CopyMute(mOutputDevice, mBGMDevice, kAudioObjectPropertyScopeOutput);
if(!mActive)
{
DebugMsg("BGMDeviceControlSync::Activate: Activating control sync");
// Disable BGMDevice controls that the output device doesn't have and reenable any that were
// disabled for the previous output device.
//
// Continue anyway if this fails because it's better to have extra/missing controls than to
// be unable to use the device.
BGMLogAndSwallowExceptionsMsg("BGMDeviceControlSync::Activate", "Controls list", [&] {
bool wasUpdated = mBGMDeviceControlsList.MatchControlsListOf(mOutputDevice);
if(wasUpdated)
{
mBGMDeviceControlsList.PropagateControlListChange();
}
});
// Init BGMDevice controls to match output device
mBGMDevice.CopyVolumeFrom(mOutputDevice, kAudioObjectPropertyScopeOutput);
mBGMDevice.CopyMuteFrom(mOutputDevice, kAudioObjectPropertyScopeOutput);
// Register listeners for volume and mute values
mBGMDevice.AddPropertyListener(kVolumePropertyAddress, &BGMDeviceControlSync::BGMDeviceListenerProc, this);
@@ -75,284 +99,140 @@ void BGMDeviceControlSync::Activate()
}
catch(CAException)
{
CATry
mBGMDevice.RemovePropertyListener(kVolumePropertyAddress, &BGMDeviceControlSync::BGMDeviceListenerProc, this);
CACatch
throw;
}
mActive = true;
}
else
{
DebugMsg("BGMDeviceControlSync::Activate: Already active");
}
}
void BGMDeviceControlSync::Deactivate()
{
if(mActive && mBGMDevice.GetObjectID() != kAudioDeviceUnknown)
CAMutex::Locker locker(mMutex);
if(mActive)
{
// Unregister listeners
mBGMDevice.RemovePropertyListener(kVolumePropertyAddress, &BGMDeviceControlSync::BGMDeviceListenerProc, this);
mBGMDevice.RemovePropertyListener(kMutePropertyAddress, &BGMDeviceControlSync::BGMDeviceListenerProc, this);
}
}
DebugMsg("BGMDeviceControlSync::Deactivate: Deactivating control sync");
void BGMDeviceControlSync::Swap(BGMDeviceControlSync& inDeviceControlSync)
{
mBGMDevice = inDeviceControlSync.mBGMDevice;
mOutputDevice = inDeviceControlSync.mOutputDevice;
inDeviceControlSync.Deactivate();
Activate();
}
#pragma mark Get/Set Control Values
// static
void BGMDeviceControlSync::CopyMute(CAHALAudioDevice inFromDevice, CAHALAudioDevice inToDevice, AudioObjectPropertyScope inScope)
{
// TODO: Support for devices that have per-channel mute controls but no master mute control
bool toHasSettableMasterMute = inToDevice.HasMuteControl(inScope, kMasterChannel) && inToDevice.MuteControlIsSettable(inScope, kMasterChannel);
if(toHasSettableMasterMute && inFromDevice.HasMuteControl(inScope, kMasterChannel))
{
inToDevice.SetMuteControlValue(inScope,
kMasterChannel,
inFromDevice.GetMuteControlValue(inScope, kMasterChannel));
}
}
// static
void BGMDeviceControlSync::CopyVolume(CAHALAudioDevice inFromDevice, CAHALAudioDevice inToDevice, AudioObjectPropertyScope inScope)
{
// Get the volume of the from device
bool didGetFromVolume = false;
Float32 fromVolume = FLT_MIN;
if(inFromDevice.HasVolumeControl(inScope, kMasterChannel))
{
fromVolume = inFromDevice.GetVolumeControlScalarValue(inScope, kMasterChannel);
didGetFromVolume = true;
}
// Use the average channel volume of the from device if it has no master volume
if(!didGetFromVolume)
{
UInt32 fromNumChannels = inFromDevice.GetTotalNumberChannels(inScope == kAudioObjectPropertyScopeInput);
fromVolume = 0;
for(UInt32 channel = 1; channel <= fromNumChannels; channel++)
// Deregister listeners
if(mBGMDevice.GetObjectID() != kAudioDeviceUnknown)
{
if(inFromDevice.HasVolumeControl(inScope, channel))
{
fromVolume += inFromDevice.GetVolumeControlScalarValue(inScope, channel);
didGetFromVolume = true;
}
}
fromVolume /= fromNumChannels;
}
BGMLogAndSwallowExceptions("BGMDeviceControlSync::Deactivate", [&] {
mBGMDevice.RemovePropertyListener(kVolumePropertyAddress,
&BGMDeviceControlSync::BGMDeviceListenerProc,
this);
});
// Set the volume of the to device
if(didGetFromVolume && fromVolume != FLT_MIN)
{
bool didSetVolume = false;
try
{
didSetVolume = SetMasterVolumeScalar(inToDevice, inScope, fromVolume);
}
catch(CAException e)
{
OSStatus err = e.GetError();
char err4CC[5] = CA4CCToCString(err);
CFStringRef uid = inToDevice.CopyDeviceUID();
LogWarning("BGMDeviceControlSync::CopyVolume: CAException '%s' trying to set master volume of %s", err4CC, uid);
CFRelease(uid);
BGMLogAndSwallowExceptions("BGMDeviceControlSync::Deactivate", [&] {
mBGMDevice.RemovePropertyListener(kMutePropertyAddress,
&BGMDeviceControlSync::BGMDeviceListenerProc,
this);
});
}
if(!didSetVolume)
{
// Couldn't find a master volume control to set, so try to find a virtual one
Float32 fromVirtualMasterVolume;
bool success = GetVirtualMasterVolume(inFromDevice, inScope, fromVirtualMasterVolume);
if(success)
{
didSetVolume = SetVirtualMasterVolume(inToDevice, inScope, fromVirtualMasterVolume);
}
}
if(!didSetVolume)
{
// Couldn't set a master or virtual master volume, so as a fallback try to set each channel individually
UInt32 numChannels = inToDevice.GetTotalNumberChannels(inScope == kAudioObjectPropertyScopeInput);
for(UInt32 channel = 1; channel <= numChannels; channel++)
{
if(inToDevice.HasVolumeControl(inScope, channel) && inToDevice.VolumeControlIsSettable(inScope, channel))
{
inToDevice.SetVolumeControlScalarValue(inScope, channel, fromVolume);
}
}
}
mActive = false;
}
}
// static
bool BGMDeviceControlSync::SetMasterVolumeScalar(CAHALAudioDevice inDevice, AudioObjectPropertyScope inScope, Float32 inVolume)
{
bool hasSettableMasterVolume =
inDevice.HasVolumeControl(inScope, kMasterChannel) && inDevice.VolumeControlIsSettable(inScope, kMasterChannel);
if(hasSettableMasterVolume)
else
{
inDevice.SetVolumeControlScalarValue(inScope, kMasterChannel, inVolume);
return true;
DebugMsg("BGMDeviceControlSync::Deactivate: Not active");
}
return false;
}
// static
bool BGMDeviceControlSync::GetVirtualMasterVolume(CAHALAudioDevice inDevice, AudioObjectPropertyScope inScope, Float32& outVirtualMasterVolume)
#pragma mark Accessors
void BGMDeviceControlSync::SetDevices(AudioObjectID inBGMDevice, AudioObjectID inOutputDevice)
{
AudioObjectPropertyAddress virtualMasterVolumeAddress =
{ kAudioHardwareServiceDeviceProperty_VirtualMasterVolume, inScope, kAudioObjectPropertyElementMaster };
CAMutex::Locker locker(mMutex);
bool wasActive = mActive;
Deactivate();
mBGMDevice = inBGMDevice;
mBGMDeviceControlsList.SetBGMDevice(inBGMDevice);
mOutputDevice = inOutputDevice;
if(!AudioHardwareServiceHasProperty(inDevice.GetObjectID(), &virtualMasterVolumeAddress))
if(wasActive)
{
return false;
Activate();
}
UInt32 virtualMasterVolumePropertySize = sizeof(Float32);
return kAudioServicesNoError == AHSGetPropertyData(inDevice.GetObjectID(),
&virtualMasterVolumeAddress,
&virtualMasterVolumePropertySize,
&outVirtualMasterVolume);
}
// static
bool BGMDeviceControlSync::SetVirtualMasterVolume(CAHALAudioDevice inDevice, AudioObjectPropertyScope inScope, Float32 inVolume)
{
// TODO: For me, setting the virtual master volume sets all the device's channels to the same volume, meaning you can't
// keep any channels quieter than the others. The expected behaviour is to scale the channel volumes
// proportionally. So to do this properly I think we'd have to store BGMDevice's previous volume and calculate
// each channel's new volume from its current volume and the distance between BGMDevice's old and new volumes.
//
// The docs kAudioHardwareServiceDeviceProperty_VirtualMasterVolume for say
// "If the device has individual channel volume controls, this property will apply to those identified by the
// device's preferred multi-channel layout (or preferred stereo pair if the device is stereo only). Note that
// this control maintains the relative balance between all the channels it affects.
// so I'm not sure why that's not working here. As a workaround we take the to device's (virtual master) balance
// before changing the volume and set it back after, but of course that'll only work for stereo devices.
bool didSetVolume = false;
AudioObjectPropertyAddress virtualMasterVolumeAddress =
{ kAudioHardwareServiceDeviceProperty_VirtualMasterVolume, inScope, kAudioObjectPropertyElementMaster };
bool hasVirtualMasterVolume = AudioHardwareServiceHasProperty(inDevice.GetObjectID(), &virtualMasterVolumeAddress);
Boolean virtualMasterVolumeIsSettable;
OSStatus err = AudioHardwareServiceIsPropertySettable(inDevice.GetObjectID(), &virtualMasterVolumeAddress, &virtualMasterVolumeIsSettable);
virtualMasterVolumeIsSettable &= (err == kAudioServicesNoError);
if(hasVirtualMasterVolume && virtualMasterVolumeIsSettable)
{
// Not sure why, but setting the virtual master volume sets all channels to the same volume. As a workaround, we store
// the current balance here so we can reset it after setting the volume.
Float32 virtualMasterBalance;
bool didGetVirtualMasterBalance = GetVirtualMasterBalance(inDevice, inScope, virtualMasterBalance);
didSetVolume = kAudioServicesNoError == AHSSetPropertyData(inDevice.GetObjectID(), &virtualMasterVolumeAddress, sizeof(Float32), &inVolume);
// Reset the balance
AudioObjectPropertyAddress virtualMasterBalanceAddress =
{ kAudioHardwareServiceDeviceProperty_VirtualMasterBalance, inScope, kAudioObjectPropertyElementMaster };
if(didSetVolume && didGetVirtualMasterBalance && AudioHardwareServiceHasProperty(inDevice.GetObjectID(), &virtualMasterBalanceAddress))
{
Boolean balanceIsSettable;
err = AudioHardwareServiceIsPropertySettable(inDevice.GetObjectID(), &virtualMasterBalanceAddress, &balanceIsSettable);
if(err == kAudioServicesNoError && balanceIsSettable)
{
AHSSetPropertyData(inDevice.GetObjectID(), &virtualMasterBalanceAddress, sizeof(Float32), &virtualMasterBalance);
}
}
}
return didSetVolume;
}
#pragma mark Listener Procs
// static
bool BGMDeviceControlSync::GetVirtualMasterBalance(CAHALAudioDevice inDevice, AudioObjectPropertyScope inScope, Float32& outVirtualMasterBalance)
OSStatus BGMDeviceControlSync::BGMDeviceListenerProc(AudioObjectID inObjectID, UInt32 inNumberAddresses, const AudioObjectPropertyAddress* inAddresses, void* __nullable inClientData)
{
AudioObjectPropertyAddress virtualMasterBalanceAddress =
{ kAudioHardwareServiceDeviceProperty_VirtualMasterBalance, inScope, kAudioObjectPropertyElementMaster };
if(!AudioHardwareServiceHasProperty(inDevice.GetObjectID(), &virtualMasterBalanceAddress))
{
return false;
}
UInt32 virtualMasterVolumePropertySize = sizeof(Float32);
return kAudioServicesNoError == AHSGetPropertyData(inDevice.GetObjectID(),
&virtualMasterBalanceAddress,
&virtualMasterVolumePropertySize,
&outVirtualMasterBalance);
}
// static
OSStatus BGMDeviceControlSync::AHSGetPropertyData(AudioObjectID inObjectID, const AudioObjectPropertyAddress* inAddress, UInt32* ioDataSize, void* outData)
{
// The docs for AudioHardwareServiceGetPropertyData specifically allow passing NULL for inQualifierData as we do here,
// but it's declared in an assume_nonnull section so we have to disable the warning here. I'm not sure why inQualifierData
// isn't __nullable. I'm assuming it's either a backwards compatibility thing or just a bug.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wnonnull"
// The non-depreciated version of this (and the setter below) doesn't seem to support devices other than the default
return AudioHardwareServiceGetPropertyData(inObjectID, inAddress, 0, NULL, ioDataSize, outData);
#pragma clang diagnostic pop
}
// static
OSStatus BGMDeviceControlSync::AHSSetPropertyData(AudioObjectID inObjectID, const AudioObjectPropertyAddress* inAddress, UInt32 inDataSize, const void* inData)
{
// See the explanation about these pragmas in AHSGetPropertyData
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wnonnull"
return AudioHardwareServiceSetPropertyData(inObjectID, inAddress, 0, NULL, inDataSize, inData);
#pragma clang diagnostic pop
}
#pragma mark Listener
// static
OSStatus BGMDeviceControlSync::BGMDeviceListenerProc(AudioObjectID inObjectID, UInt32 inNumberAddresses, const AudioObjectPropertyAddress* __nonnull inAddresses, void* __nullable inClientData)
{
// refCon (reference context) is the instance that registered this listener proc
// refCon (reference context) is the instance that registered this listener proc.
BGMDeviceControlSync* refCon = static_cast<BGMDeviceControlSync*>(inClientData);
if(refCon->mActive)
{
ThrowIf(inObjectID != refCon->mBGMDevice.GetObjectID(),
CAException(kAudioHardwareBadObjectError),
"BGMDeviceControlSync::BGMDeviceListenerProc: notified about audio object other than BGMDevice");
for(int i = 0; i < inNumberAddresses; i++)
auto checkState = [&] {
if(!refCon)
{
AudioObjectPropertyScope scope = inAddresses[i].mScope;
switch(inAddresses[i].mSelector)
{
case kAudioDevicePropertyVolumeScalar:
// Update the output device
CopyVolume(refCon->mBGMDevice, refCon->mOutputDevice, scope);
break;
case kAudioDevicePropertyMute:
// Update the output device. Note that this also runs when you change the volume (on BGMDevice)
CopyMute(refCon->mBGMDevice, refCon->mOutputDevice, scope);
break;
default:
break;
}
LogError("BGMDeviceControlSync::BGMDeviceListenerProc: !refCon");
return false;
}
if(!refCon->mActive ||
(refCon->mBGMDevice.GetObjectID() == kAudioObjectUnknown) ||
(refCon->mOutputDevice.GetObjectID() == kAudioObjectUnknown))
{
return false;
}
if(inObjectID != refCon->mBGMDevice.GetObjectID())
{
LogError("BGMDeviceControlSync::BGMDeviceListenerProc: notified about audio object other than BGMDevice");
return false;
}
return true;
};
for(int i = 0; i < inNumberAddresses; i++)
{
AudioObjectPropertyScope scope = inAddresses[i].mScope;
switch(inAddresses[i].mSelector)
{
case kAudioDevicePropertyVolumeScalar:
{
CAMutex::Locker locker(refCon->mMutex);
// Update the output device's volume.
if(checkState())
{
refCon->mOutputDevice.CopyVolumeFrom(refCon->mBGMDevice, scope);
}
}
break;
case kAudioDevicePropertyMute:
{
CAMutex::Locker locker(refCon->mMutex);
// Update the output device's mute control. Note that this also runs when you
// change the volume (on BGMDevice).
if(checkState())
{
refCon->mOutputDevice.CopyMuteFrom(refCon->mBGMDevice, scope);
}
}
break;
}
}
// "The return value [of an AudioObjectPropertyListenerProc] is currently unused and should always be 0."
return 0;
}
#pragma clang assume_nonnull end
+73 -33
View File
@@ -17,65 +17,105 @@
// BGMDeviceControlSync.h
// BGMApp
//
// Copyright © 2016 Kyle Neideck
// Copyright © 2016, 2017 Kyle Neideck
//
// Listens for notifications that BGMDevice's controls (just volume and mute currently) have changed value, and
// copies the new values to the output device.
// Synchronises BGMDevice's controls (just volume and mute currently) with the output device's
// controls. This allows the user to control the output device normally while BGMDevice is set as
// the default device.
//
// BGMDeviceControlSync disables any BGMDevice controls that the output device doesn't also have.
// When the value of one of BGMDevice's controls is changed, BGMDeviceControlSync copies the new
// value to the output device.
//
// Thread safe.
//
#ifndef __BGMApp__BGMDeviceControlSync__
#define __BGMApp__BGMDeviceControlSync__
#ifndef BGMApp__BGMDeviceControlSync
#define BGMApp__BGMDeviceControlSync
// Local Includes
#include "BGMAudioDevice.h"
#include "BGMDeviceControlsList.h"
// PublicUtility Includes
#include "CAHALAudioDevice.h"
#include "CAHALAudioSystemObject.h"
#include "CAMutex.h"
// System Includes
#include <AudioToolbox/AudioServices.h>
#pragma clang assume_nonnull begin
class BGMDeviceControlSync
{
#pragma mark Construction/Destruction
public:
BGMDeviceControlSync(CAHALAudioDevice inBGMDevice, CAHALAudioDevice inOutputDevice);
BGMDeviceControlSync(AudioObjectID inBGMDevice,
AudioObjectID inOutputDevice,
CAHALAudioSystemObject inAudioSystem
= CAHALAudioSystemObject());
~BGMDeviceControlSync();
// Disallow copying
BGMDeviceControlSync(const BGMDeviceControlSync&) = delete;
BGMDeviceControlSync& operator=(const BGMDeviceControlSync&) = delete;
// Move constructor/assignment
BGMDeviceControlSync(BGMDeviceControlSync&& inDeviceControlSync) { Swap(inDeviceControlSync); }
BGMDeviceControlSync& operator=(BGMDeviceControlSync&& inDeviceControlSync) { Swap(inDeviceControlSync); return *this; }
#ifdef __OBJC__
// Only intended as a convenience for Objective-C instance vars
BGMDeviceControlSync() { };
BGMDeviceControlSync()
: BGMDeviceControlSync(kAudioObjectUnknown, kAudioObjectUnknown) { };
#endif
private:
/*!
Begin synchronising BGMDevice's controls with the output device's.
@throws BGM_DeviceNotSetException if BGMDevice isn't set.
@throws CAException if the HAL or one of the devices returns an error when this function
registers for device property notifications or when it copies the current
values of the output device's controls to BGMDevice. This
BGMDeviceControlSync will remain inactive if this function throws.
*/
void Activate();
/*! Stop synchronising BGMDevice's controls with the output device's. */
void Deactivate();
void Swap(BGMDeviceControlSync& inDeviceControlSync);
static void CopyMute(CAHALAudioDevice inFromDevice, CAHALAudioDevice inToDevice, AudioObjectPropertyScope inScope);
static void CopyVolume(CAHALAudioDevice inFromDevice, CAHALAudioDevice inToDevice, AudioObjectPropertyScope inScope);
static bool SetMasterVolumeScalar(CAHALAudioDevice inDevice, AudioObjectPropertyScope inScope, Float32 inVolume);
static bool GetVirtualMasterVolume(CAHALAudioDevice inDevice, AudioObjectPropertyScope inScope, Float32& outVirtualMasterVolume);
static bool SetVirtualMasterVolume(CAHALAudioDevice inDevice, AudioObjectPropertyScope inScope, Float32 inVolume);
static bool GetVirtualMasterBalance(CAHALAudioDevice inDevice, AudioObjectPropertyScope inScope, Float32& outVirtualMasterBalance);
static OSStatus AHSGetPropertyData(AudioObjectID inObjectID, const AudioObjectPropertyAddress* inAddress, UInt32* ioDataSize, void* outData);
static OSStatus AHSSetPropertyData(AudioObjectID inObjectID, const AudioObjectPropertyAddress* inAddress, UInt32 inDataSize, const void* inData);
static OSStatus BGMDeviceListenerProc(AudioObjectID inObjectID, UInt32 inNumberAddresses, const AudioObjectPropertyAddress* inAddresses, void* __nullable inClientData);
#pragma mark Accessors
/*!
Set the IDs of BGMDevice and the output device to synchronise with.
@throws BGM_DeviceNotSetException if BGMDevice isn't set.
@throws CAException if the HAL or one of the new devices returns an error while restarting
synchronisation. This BGMDeviceControlSync will be deactivated if this
function throws, but its devices will still be set.
*/
void SetDevices(AudioObjectID inBGMDevice, AudioObjectID inOutputDevice);
#pragma mark Listener Procs
private:
bool mActive = false;
CAHALAudioDevice mBGMDevice { kAudioDeviceUnknown };
CAHALAudioDevice mOutputDevice { kAudioDeviceUnknown };
/*! Receives HAL notifications about the BGMDevice properties this class listens to. */
static OSStatus BGMDeviceListenerProc(AudioObjectID inObjectID,
UInt32 inNumberAddresses,
const AudioObjectPropertyAddress* inAddresses,
void* __nullable inClientData);
private:
CAMutex mMutex { "Device Control Sync" };
bool mActive = false;
CAHALAudioSystemObject mAudioSystem;
BGMAudioDevice mBGMDevice { (AudioObjectID)kAudioObjectUnknown };
BGMAudioDevice mOutputDevice { (AudioObjectID)kAudioObjectUnknown };
BGMDeviceControlsList mBGMDeviceControlsList;
};
#pragma clang assume_nonnull end
#endif /* __BGMApp__BGMDeviceControlSync__ */
#endif /* BGMApp__BGMDeviceControlSync */
+569
View File
@@ -0,0 +1,569 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMDeviceControlsList.cpp
// BGMApp
//
// Copyright © 2017 Kyle Neideck
//
// Self Include
#include "BGMDeviceControlsList.h"
// Local Includes
#include "BGM_Types.h"
#include "BGM_Utils.h"
// PublicUtility Includes
#include "CAPropertyAddress.h"
#include "CACFArray.h"
#pragma clang assume_nonnull begin
static const SInt64 kToggleDeviceInitialDelay = 50 * NSEC_PER_MSEC;
static const SInt64 kToggleDeviceBackDelay = 500 * NSEC_PER_MSEC;
static const SInt64 kDisableNullDeviceDelay = 500 * NSEC_PER_MSEC;
static const SInt64 kDisableNullDeviceTimeout = 5000 * NSEC_PER_MSEC;
#pragma mark Construction/Destruction
BGMDeviceControlsList::BGMDeviceControlsList(AudioObjectID inBGMDevice,
CAHALAudioSystemObject inAudioSystem)
:
mBGMDevice(inBGMDevice),
mAudioSystem(inAudioSystem)
{
BGMAssert((mBGMDevice.IsBGMDevice() || mBGMDevice.GetObjectID() == kAudioObjectUnknown),
"BGMDeviceControlsList::BGMDeviceControlsList: Given device is not BGMDevice");
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
mCanToggleDeviceOnSystem = (&dispatch_block_wait &&
&dispatch_block_cancel &&
&dispatch_block_testcancel &&
&dispatch_queue_attr_make_with_qos_class);
#pragma clang diagnostic pop
}
BGMDeviceControlsList::~BGMDeviceControlsList()
{
CAMutex::Locker locker(mMutex);
if(!mDeviceTogglingInitialised)
{
return;
}
if(mListenerQueue && mListenerBlock)
{
BGMLogAndSwallowExceptions("BGMDeviceControlsList::~BGMDeviceControlsList", ([&] {
mAudioSystem.RemovePropertyListenerBlock(
CAPropertyAddress(kAudioHardwarePropertyDevices),
mListenerQueue,
mListenerBlock);
}));
}
// If we're in the middle of toggling the default device, block until we've finished.
if(mDisableNullDeviceBlock && mDeviceToggleState != ToggleState::NotToggling)
{
DebugMsg("BGMDeviceControlsList::~BGMDeviceControlsList: Waiting for device toggle");
// Copy the reference so we can unlock the mutex and allow any remaining blocks to run.
dispatch_block_t disableNullDeviceBlock = mDisableNullDeviceBlock;
CAMutex::Unlocker unlocker(mMutex);
// Note that if mDisableNullDeviceBlock is currently running this will return after it
// finishes and if it's already run this will return immediately. So we don't have to
// worry about ending up waiting for mDisableNullDeviceBlock when it isn't queued.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
long timedOut = dispatch_block_wait(disableNullDeviceBlock, kDisableNullDeviceTimeout);
#pragma clang diagnostic pop
if(timedOut)
{
LogWarning("BGMDeviceControlsList::~BGMDeviceControlsList: Device toggle timed out");
}
}
mDeviceToggleState = ToggleState::NotToggling;
DestroyBlock(mDeviceToggleBlock);
DestroyBlock(mDeviceToggleBackBlock);
DestroyBlock(mDisableNullDeviceBlock);
if(mListenerBlock)
{
Block_release(mListenerBlock);
}
if(mListenerQueue)
{
dispatch_release(BGM_Utils::NN(mListenerQueue));
}
}
#pragma mark Accessors
void BGMDeviceControlsList::SetBGMDevice(AudioObjectID inBGMDeviceID)
{
CAMutex::Locker locker(mMutex);
mBGMDevice = inBGMDeviceID;
BGMAssert(mBGMDevice.IsBGMDevice(),
"BGMDeviceControlsList::SetBGMDevice: Given device is not BGMDevice");
}
#pragma mark Update Controls List
bool BGMDeviceControlsList::MatchControlsListOf(AudioObjectID inDeviceID)
{
CAMutex::Locker locker(mMutex);
if(!mBGMDevice.IsBGMDevice())
{
LogWarning("BGMDeviceControlsList::MatchControlsListOf: BGMDevice ID not set");
return false;
}
// If the output device doesn't have a control that BGMDevice does, disable it on BGMDevice so
// the system's audio UI isn't confusing.
// No need to change input controls.
AudioObjectPropertyScope inScope = kAudioObjectPropertyScopeOutput;
// Check which of BGMDevice's controls are currently enabled. We need to know whether we're
// actually enabling/disabling any controls so we know whether we need to call
// PropagateControlListChange afterward.
CFTypeRef __nullable enabledControlsRef =
mBGMDevice.GetPropertyData_CFType(kBGMEnabledOutputControlsAddress);
ThrowIf(!enabledControlsRef || (CFGetTypeID(enabledControlsRef) != CFArrayGetTypeID()),
CAException(kAudioHardwareIllegalOperationError),
"BGMDeviceControlsList::MatchControlsListOf: Expected a CFArray for "
"kAudioDeviceCustomPropertyEnabledOutputControls");
CACFArray enabledControls(static_cast<CFArrayRef>(enabledControlsRef), true);
BGMAssert(enabledControls.GetNumberItems() == 2,
"BGMDeviceControlsList::MatchControlsListOf: Expected 2 array elements for "
"kAudioDeviceCustomPropertyEnabledOutputControls");
bool volumeEnabled;
bool didGetBool = enabledControls.GetBool(kBGMEnabledOutputControlsIndex_Volume, volumeEnabled);
ThrowIf(!didGetBool,
CAException(kAudioHardwareIllegalOperationError),
"BGMDeviceControlsList::MatchControlsListOf: Expected volume element of "
"kAudioDeviceCustomPropertyEnabledOutputControls to be a CFBoolean");
bool muteEnabled;
didGetBool = enabledControls.GetBool(kBGMEnabledOutputControlsIndex_Mute, muteEnabled);
ThrowIf(!didGetBool,
CAException(kAudioHardwareIllegalOperationError),
"BGMDeviceControlsList::MatchControlsListOf: Expected mute element of "
"kAudioDeviceCustomPropertyEnabledOutputControls to be a CFBoolean");
DebugMsg("BGMDeviceControlsList::MatchControlsListOf: BGMDevice has volume %s, mute %s",
(volumeEnabled ? "enabled" : "disabled"),
(muteEnabled ? "enabled" : "disabled"));
// Check which controls the other device has.
BGMAudioDevice device(inDeviceID);
bool hasMute = device.HasSettableMasterMute(inScope);
bool hasVolume =
device.HasSettableMasterVolume(inScope) || device.HasSettableVirtualMasterVolume(inScope);
if(!hasVolume)
{
// Check for per-channel volume controls.
UInt32 numChannels =
device.GetTotalNumberChannels(inScope == kAudioObjectPropertyScopeInput);
for(UInt32 channel = 1; channel <= numChannels; channel++)
{
BGMLogAndSwallowExceptionsMsg("BGMDeviceControlsList::MatchControlsListOf",
"Checking for channel volume controls",
([&] {
hasVolume =
(device.HasVolumeControl(inScope, channel)
&& device.VolumeControlIsSettable(inScope, channel));
}));
if(hasVolume)
{
break;
}
}
}
// Tell BGMDevice to enable/disable its controls to match the output device.
bool deviceUpdated = false;
CACFArray newEnabledControls;
newEnabledControls.SetCFMutableArrayFromCopy(enabledControls.GetCFArray());
// Update volume.
if(volumeEnabled != hasVolume)
{
DebugMsg("BGMDeviceControlsList::MatchControlsListOf: %s BGMDevice volume control.",
hasVolume ? "Enabling" : "Disabling");
newEnabledControls.SetBool(kBGMEnabledOutputControlsIndex_Volume, hasVolume);
deviceUpdated = true;
}
// Update mute.
if(muteEnabled != hasMute)
{
DebugMsg("BGMDeviceControlsList::MatchControlsListOf: %s BGMDevice mute control.",
hasMute ? "Enabling" : "Disabling");
newEnabledControls.SetBool(kBGMEnabledOutputControlsIndex_Mute, hasMute);
deviceUpdated = true;
}
if(deviceUpdated)
{
mBGMDevice.SetPropertyData_CFType(kBGMEnabledOutputControlsAddress,
newEnabledControls.GetCFMutableArray());
}
return deviceUpdated;
}
void BGMDeviceControlsList::PropagateControlListChange()
{
CAMutex::Locker locker(mMutex);
if((mBGMDevice == kAudioObjectUnknown) || !mCanToggleDeviceOnSystem)
{
return;
}
InitDeviceToggling();
// Leave the default device alone if the user has changed it since launching BGMApp.
bool bgmDeviceIsDefault = true;
BGMLogAndSwallowExceptions("BGMDeviceControlsList::PropagateControlListChange", ([&] {
bgmDeviceIsDefault =
(mBGMDevice.GetObjectID() == mAudioSystem.GetDefaultAudioDevice(false, false));
}));
if(bgmDeviceIsDefault)
{
mDeviceToggleState = ToggleState::SettingNullDeviceAsDefault;
// We'll get a notification from the HAL after the Null Device is enabled. Then we can
// temporarily make it the default device, which gets other programs to notice that
// BGMDevice's controls have changed.
try
{
CAMutex::Unlocker unlocker(mMutex);
SetNullDeviceEnabled(true);
}
catch (...)
{
mDeviceToggleState = ToggleState::NotToggling;
LogError("BGMDeviceControlsList::PropagateControlListChange: Could not enable the Null "
"Device");
throw;
}
}
}
#pragma mark Implementation
void BGMDeviceControlsList::InitDeviceToggling()
{
CAMutex::Locker locker(mMutex);
if(mDeviceTogglingInitialised || !mCanToggleDeviceOnSystem)
{
return;
}
BGMAssert(mBGMDevice.IsBGMDevice(),
"BGMDeviceControlsList::InitDeviceToggling: mBGMDevice device is not set to "
"BGMDevice's ID");
// Register a listener to find out when the Null Device becomes available/unavailable. See
// ToggleDefaultDevice.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
dispatch_queue_attr_t attr =
dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_DEFAULT, 0);
#pragma clang diagnostic pop
mListenerQueue = dispatch_queue_create("com.bearisdriving.BGM.BGMDeviceControlsList", attr);
auto listenerBlock = ^(UInt32 inNumberAddresses, const AudioObjectPropertyAddress* inAddresses) {
// Ignore the notification if we're not toggling the default device, which would just mean
// the default device has been changed for an unrelated reason.
if(mDeviceToggleState == ToggleState::NotToggling)
{
return;
}
for(int i = 0; i < inNumberAddresses; i++)
{
switch(inAddresses[i].mSelector)
{
case kAudioHardwarePropertyDevices:
{
CAMutex::Locker innerLocker(mMutex);
DebugMsg("BGMDeviceControlsList::InitDeviceToggling: Got "
"kAudioHardwarePropertyDevices");
// Cancel the previous block in case it hasn't run yet.
DestroyBlock(mDeviceToggleBlock);
mDeviceToggleBlock = CreateDeviceToggleBlock();
// Changing the default device too quickly after enabling the Null Device
// seems to cause problems with some programs. Not sure why.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
if(mDeviceToggleBlock)
{
dispatch_after(dispatch_time(DISPATCH_TIME_NOW,
kToggleDeviceInitialDelay),
dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0),
BGM_Utils::NN(mDeviceToggleBlock));
}
#pragma clang diagnostic pop
}
break;
default:
break;
}
}
};
mListenerBlock = Block_copy(listenerBlock);
BGMLogAndSwallowExceptions("BGMDeviceControlsList::InitDeviceToggling", [&] {
mAudioSystem.AddPropertyListenerBlock(CAPropertyAddress(kAudioHardwarePropertyDevices),
mListenerQueue,
mListenerBlock);
});
mDeviceTogglingInitialised = true;
}
void BGMDeviceControlsList::ToggleDefaultDevice()
{
// Set the Null Device as the OS X default device.
AudioObjectID nullDeviceID = mAudioSystem.GetAudioDeviceForUID(CFSTR(kBGMNullDeviceUID));
if(nullDeviceID == kAudioObjectUnknown)
{
// It's unlikely, but we might have been notified about an unrelated device so just log a
// warning.
LogWarning("BGMDeviceControlsList::ToggleDefaultDevice: Null Device not found");
return;
}
DebugMsg("BGMDeviceControlsList::ToggleDefaultDevice: Setting Null Device as default. "
"nullDeviceID = %u", nullDeviceID);
mAudioSystem.SetDefaultAudioDevice(false, false, nullDeviceID);
mDeviceToggleState = ToggleState::SettingBGMDeviceAsDefault;
// A small number of apps (e.g. Firefox) seem to have trouble with the default device being
// changed back immediately, so for now we insert a short delay here and before disabling the
// Null Device.
// Cancel the previous block in case it hasn't run yet.
DestroyBlock(mDeviceToggleBackBlock);
mDeviceToggleBackBlock = CreateDeviceToggleBackBlock();
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
if(mDeviceToggleBackBlock)
{
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, kToggleDeviceBackDelay),
dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0),
BGM_Utils::NN(mDeviceToggleBackBlock));
}
#pragma clang diagnostic pop
}
void BGMDeviceControlsList::SetNullDeviceEnabled(bool inEnabled)
{
DebugMsg("BGMDeviceControlsList::SetNullDeviceEnabled: %s the null device",
inEnabled ? "Enabling" : "Disabling");
// Get the audio object for BGMDriver, which is the object the Null Device belongs to.
AudioObjectID bgmDriverID = mAudioSystem.GetAudioPlugInForBundleID(CFSTR(kBGMDriverBundleID));
if(bgmDriverID == kAudioObjectUnknown)
{
LogError("BGMDeviceControlsList::SetNullDeviceEnabled: BGMDriver plug-in audio object not "
"found");
throw CAException(kAudioHardwareUnspecifiedError);
}
CAHALAudioObject bgmDriver(bgmDriverID);
bgmDriver.SetPropertyData_CFType(CAPropertyAddress(kAudioPlugInCustomPropertyNullDeviceActive),
(inEnabled ? kCFBooleanTrue : kCFBooleanFalse));
}
dispatch_block_t __nullable BGMDeviceControlsList::CreateDeviceToggleBlock()
{
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
dispatch_block_t __nullable toggleBlock = dispatch_block_create((dispatch_block_flags_t)0, ^{
#pragma clang diagnostic pop
CAMutex::Locker locker(mMutex);
if(mDeviceToggleState == ToggleState::SettingNullDeviceAsDefault)
{
BGMLogAndSwallowExceptions("BGMDeviceControlsList::CreateDeviceToggleBlock",
([&] {
ToggleDefaultDevice();
}));
}
});
if(!toggleBlock)
{
// Pretty sure this should never happen, but the docs aren't completely clear.
LogError("BGMDeviceControlsList::CreateDeviceToggleBlock: !toggleBlock");
}
return toggleBlock;
}
dispatch_block_t __nullable BGMDeviceControlsList::CreateDeviceToggleBackBlock()
{
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
dispatch_block_t __nullable toggleBackBlock =
dispatch_block_create((dispatch_block_flags_t)0, ^{
#pragma clang diagnostic pop
CAMutex::Locker locker(mMutex);
if(mDeviceToggleState != ToggleState::SettingBGMDeviceAsDefault)
{
return;
}
// Set BGMDevice back as the default device.
DebugMsg("BGMDeviceControlsList::ToggleDefaultDevice: Setting BGMDevice as default");
BGMLogAndSwallowExceptions("BGMDeviceControlsList::CreateDeviceToggleBackBlock", ([&] {
mAudioSystem.SetDefaultAudioDevice(false, false, mBGMDevice.GetObjectID());
}));
mDeviceToggleState = ToggleState::DisablingNullDevice;
// Cancel the previous block in case it hasn't run yet.
DestroyBlock(mDisableNullDeviceBlock);
mDisableNullDeviceBlock = CreateDisableNullDeviceBlock();
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
if(mDisableNullDeviceBlock)
{
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, kDisableNullDeviceDelay),
dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0),
BGM_Utils::NN(mDisableNullDeviceBlock));
}
#pragma clang diagnostic pop
});
if(!toggleBackBlock)
{
// Pretty sure this should never happen, but the docs aren't completely clear.
LogError("BGMDeviceControlsList::CreateDeviceToggleBackBlock: !toggleBackBlock");
}
return toggleBackBlock;
}
dispatch_block_t __nullable BGMDeviceControlsList::CreateDisableNullDeviceBlock()
{
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
dispatch_block_t __nullable disableNullDeviceBlock =
dispatch_block_create((dispatch_block_flags_t)0, ^{
#pragma clang diagnostic pop
CAMutex::Locker locker(mMutex);
if(mDeviceToggleState != ToggleState::DisablingNullDevice)
{
return;
}
mDeviceToggleState = ToggleState::NotToggling;
BGMLogAndSwallowExceptions("BGMDeviceControlsList::CreateDisableNullDeviceBlock",
([&] {
CAMutex::Unlocker unlocker(mMutex);
// Hide the null device from the user again.
SetNullDeviceEnabled(false);
}));
BGMAssert(mBGMDevice.IsBGMDevice(), "BGMDevice's AudioObjectID changed");
});
if(!disableNullDeviceBlock)
{
// Pretty sure this should never happen, but the docs aren't completely clear.
LogError("BGMDeviceControlsList::CreateDisableNullDeviceBlock: !disableNullDeviceBlock");
}
return disableNullDeviceBlock;
}
void BGMDeviceControlsList::DestroyBlock(dispatch_block_t __nullable & block)
{
if(!block)
{
return;
}
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
dispatch_block_t& blockNN = (dispatch_block_t&)block;
if(!dispatch_block_testcancel(blockNN))
{
// Stop the block from running if it's currently queued.
dispatch_block_cancel(blockNN);
// Make sure the block isn't currently running. That should almost never be the case.
while(!dispatch_block_testcancel(blockNN))
{
CAMutex::Unlocker unlocker(mMutex);
usleep(10);
}
Block_release(block);
block = nullptr;
}
#pragma clang diagnostic pop
}
#pragma clang assume_nonnull end
+138
View File
@@ -0,0 +1,138 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMDeviceControlsList.h
// BGMApp
//
// Copyright © 2017 Kyle Neideck
//
#ifndef BGMApp__BGMDeviceControlsList
#define BGMApp__BGMDeviceControlsList
// Local Includes
#include "BGMAudioDevice.h"
// PublicUtility Includes
#include "CAHALAudioDevice.h"
#include "CAHALAudioSystemObject.h"
#include "CAMutex.h"
// System Includes
#include <dispatch/dispatch.h>
#include <AudioToolbox/AudioServices.h>
#pragma clang assume_nonnull begin
class BGMDeviceControlsList
{
#pragma mark Construction/Destruction
public:
BGMDeviceControlsList(AudioObjectID inBGMDevice,
CAHALAudioSystemObject inAudioSystem
= CAHALAudioSystemObject());
~BGMDeviceControlsList();
// Disallow copying
BGMDeviceControlsList(const BGMDeviceControlsList&) = delete;
BGMDeviceControlsList& operator=(const BGMDeviceControlsList&) = delete;
#pragma mark Accessors
/*! @param inBGMDeviceID The ID of BGMDevice. */
void SetBGMDevice(AudioObjectID inBGMDeviceID);
#pragma mark Update Controls List
/*!
Enable the BGMDevice controls (volume and mute currently) that can be matched to controls of
the given device, and disable the ones that can't.
@param inDeviceID The ID of the device.
@return True if BGMDevice's list of controls was updated.
@throws CAException if an error is received from either device.
*/
bool MatchControlsListOf(AudioObjectID inDeviceID);
/*!
After updating BGMDevice's controls list, we need to change the default device so programs
(including OS X's audio UI) will update themselves. We could just change to the real output
device and change back, but that could have side effects the user wouldn't expect. For example,
an app the user has muted might be unmuted for a short period.
Instead we tell BGMDriver to enable the Null Device -- a device that does nothing -- so we can
use it to toggle the default device. The Null Device is normally disabled so it can be hidden
from the user. OS X won't let us make a hidden device temporarily visible or set a hidden
device as the default, so we have to completely remove the Null Device from the system while
we're not using it.
@throws CAException if it fails to enable the Null Device.
*/
void PropagateControlListChange();
#pragma mark Implementation
private:
/*! Lazily initialises the fields used to toggle the default device. */
void InitDeviceToggling();
/*! Changes the OS X default audio device to the Null Device and then back to BGMDevice. */
void ToggleDefaultDevice();
/*!
Enable or disable the Null Device. See PropagateControlListChange and BGM_NullDevice in
BGMDriver.
@throws CAException if we can't get the BGMDriver plug-in audio object from the HAL or the HAL
returns an error when setting kAudioPlugInCustomPropertyNullDeviceActive.
*/
void SetNullDeviceEnabled(bool inEnabled);
dispatch_block_t __nullable CreateDeviceToggleBlock();
dispatch_block_t __nullable CreateDeviceToggleBackBlock();
dispatch_block_t __nullable CreateDisableNullDeviceBlock();
void DestroyBlock(dispatch_block_t __nullable & block);
private:
CAMutex mMutex { "Device Controls List" };
bool mDeviceTogglingInitialised = false;
// OS X 10.9 doesn't have the functions we use for PropagateControlListChange.
bool mCanToggleDeviceOnSystem;
BGMAudioDevice mBGMDevice;
CAHALAudioSystemObject mAudioSystem; // Not guarded by the mutex.
enum ToggleState
{
NotToggling, SettingNullDeviceAsDefault, SettingBGMDeviceAsDefault, DisablingNullDevice
};
BGMDeviceControlsList::ToggleState mDeviceToggleState = ToggleState::NotToggling;
dispatch_block_t __nullable mDeviceToggleBlock = nullptr;
dispatch_block_t __nullable mDeviceToggleBackBlock = nullptr;
dispatch_block_t __nullable mDisableNullDeviceBlock = nullptr;
// These will only ever be null after construction on 10.9, since toggling will be disabled.
dispatch_queue_t __nullable mListenerQueue = nullptr;
AudioObjectPropertyListenerBlock __nullable mListenerBlock = nullptr;
};
#pragma clang assume_nonnull end
#endif /* BGMApp__BGMDeviceControlsList */
@@ -0,0 +1,46 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMOutputDeviceMenuSection.h
// BGMApp
//
// Copyright © 2016, 2018 Kyle Neideck
//
// Local Includes
#import "BGMAudioDeviceManager.h"
#import "BGMPreferredOutputDevices.h"
// System Includes
#import <AppKit/AppKit.h>
#pragma clang assume_nonnull begin
@interface BGMOutputDeviceMenuSection : NSObject
- (instancetype) initWithBGMMenu:(NSMenu*)inBGMMenu
audioDevices:(BGMAudioDeviceManager*)inAudioDevices
preferredDevices:(BGMPreferredOutputDevices*)inPreferredDevices;
// To be called when BGMApp has been set to use a different output device. For example, when a new
// device is connected and BGMPreferredOutputDevices decides BGMApp should switch to it.
- (void) outputDeviceDidChange;
@end
#pragma clang assume_nonnull end
+393
View File
@@ -0,0 +1,393 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMOutputDeviceMenuSection.mm
// BGMApp
//
// Copyright © 2016-2018 Kyle Neideck
//
// Self Include
#import "BGMOutputDeviceMenuSection.h"
// Local Includes
#import "BGM_Utils.h"
#import "BGM_Types.h"
#import "BGMAudioDevice.h"
// PublicUtility Includes
#import "CAAutoDisposer.h"
#import "CAHALAudioDevice.h"
#import "CAHALAudioSystemObject.h"
#import "CAPropertyAddress.h"
// STL Includes
#import <set>
#pragma clang assume_nonnull begin
static NSInteger const kOutputDeviceMenuItemTag = 5;
@implementation BGMOutputDeviceMenuSection {
NSMenu* bgmMenu;
BGMAudioDeviceManager* audioDevices;
BGMPreferredOutputDevices* preferredDevices;
NSMutableArray<NSMenuItem*>* outputDeviceMenuItems;
// Called when a CoreAudio property has changed and we might need to update the menu. For
// example, when a device is connected or disconnected.
AudioObjectPropertyListenerBlock refreshNeededListener;
// The devices we've added refreshNeededListener to. Used to avoid adding it to a device twice
// for the same property and to remove it from all devices in dealloc.
std::set<BGMAudioDevice> listenedDevices_kAudioDevicePropertyDataSources;
std::set<BGMAudioDevice> listenedDevices_kAudioDevicePropertyDataSource;
}
- (instancetype) initWithBGMMenu:(NSMenu*)inBGMMenu
audioDevices:(BGMAudioDeviceManager*)inAudioDevices
preferredDevices:(BGMPreferredOutputDevices*)inPreferredDevices {
if ((self = [super init])) {
bgmMenu = inBGMMenu;
audioDevices = inAudioDevices;
preferredDevices = inPreferredDevices;
outputDeviceMenuItems = [NSMutableArray new];
[self listenForDevicesAddedOrRemoved];
[self populateBGMMenu];
}
return self;
}
- (void) dealloc {
// Tell CoreAudio not to call the listener block anymore. This probably isn't necessary.
//
// I think it's safe to do this without dispatching to the main queue because dealloc and
// refreshNeededListener should be essentially mutually exclusive. If refreshNeededListener is
// invoked and gets a value for weakSelf, it holds the strong ref until it returns, so dealloc
// won't be called. If refreshNeededListener is invoked and deallocation has started, it will
// get nil for weakSelf and just return.
auto removeListener = [&] (CAHALAudioObject audioObject, AudioObjectPropertySelector prop) {
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
// Check the object still exists first to reduce unnecessary error logs.
if (CAHALAudioObject::ObjectExists(audioObject.GetObjectID())) {
audioObject.RemovePropertyListenerBlock(CAPropertyAddress(prop),
dispatch_get_main_queue(),
refreshNeededListener);
}
});
};
// Remove the listener from each audio object we added it to.
removeListener(CAHALAudioSystemObject(), kAudioHardwarePropertyDevices);
for (auto device : listenedDevices_kAudioDevicePropertyDataSources) {
removeListener(device, kAudioDevicePropertyDataSources);
}
for (auto device : listenedDevices_kAudioDevicePropertyDataSource) {
removeListener(device, kAudioDevicePropertyDataSource);
}
}
- (void) listenForDevicesAddedOrRemoved {
// Create the block that will run when a device is added or removed.
BGMOutputDeviceMenuSection* __weak weakSelf = self;
refreshNeededListener = ^(UInt32 inNumberAddresses,
const AudioObjectPropertyAddress* inAddresses) {
#pragma unused (inNumberAddresses, inAddresses)
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
[weakSelf populateBGMMenu];
});
};
// Register the listener block to be called when devices are connected or disconnected.
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
CAHALAudioSystemObject().AddPropertyListenerBlock(
CAPropertyAddress(kAudioHardwarePropertyDevices),
dispatch_get_main_queue(),
refreshNeededListener);
});
}
- (void) populateBGMMenu {
// TODO: Technically, we should assert we're on the main queue rather than just the main thread.
BGMAssert([NSThread isMainThread],
"BGMOutputDeviceMenuSection::populateBGMMenu called on non-main thread");
// Remove existing menu items
for (NSMenuItem* item in outputDeviceMenuItems) {
DebugMsg("BGMOutputDeviceMenuSection::populateBGMMenu: Removing %s",
item.description.UTF8String);
[bgmMenu removeItem:item];
}
[outputDeviceMenuItems removeAllObjects];
// Add a menu item for each output device
CAHALAudioSystemObject audioSystem;
UInt32 numDevices = audioSystem.GetNumberAudioDevices();
if (numDevices > 0) {
CAAutoArrayDelete<AudioObjectID> devices(numDevices);
audioSystem.GetAudioDevices(numDevices, devices);
for (UInt32 i = 0; i < numDevices; i++) {
[self insertMenuItemsForDevice:devices[i]];
}
}
}
- (void) insertMenuItemsForDevice:(BGMAudioDevice)device {
// Insert menu items after the item for the "Output Device" heading.
const NSInteger menuItemsIdx = [bgmMenu indexOfItemWithTag:kOutputDeviceMenuItemTag] + 1;
BOOL canBeOutputDevice = YES;
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
canBeOutputDevice = device.CanBeOutputDeviceInBGMApp();
});
if (canBeOutputDevice) {
for (NSMenuItem* item : [self createMenuItemsForDevice:device]) {
DebugMsg("BGMOutputDeviceMenuSection::insertMenuItemsForDevice: Inserting %s",
item.description.UTF8String);
[bgmMenu insertItem:item atIndex:menuItemsIdx];
[outputDeviceMenuItems addObject:item];
}
// Add listeners to update the menu when the device's data source changes or it changes its
// list of data sources. We do this so that, for example, when you plug headphones into the
// built-in jack, the menu item for the built-in device will change from "Internal Speakers"
// to "Headphones".
if (listenedDevices_kAudioDevicePropertyDataSources.count(device) == 0) {
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
device.AddPropertyListenerBlock(CAPropertyAddress(kAudioDevicePropertyDataSources,
kAudioDevicePropertyScopeOutput),
dispatch_get_main_queue(),
refreshNeededListener);
listenedDevices_kAudioDevicePropertyDataSources.insert(device);
});
};
if (listenedDevices_kAudioDevicePropertyDataSource.count(device) == 0) {
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
device.AddPropertyListenerBlock(CAPropertyAddress(kAudioDevicePropertyDataSource,
kAudioDevicePropertyScopeOutput),
dispatch_get_main_queue(),
refreshNeededListener);
listenedDevices_kAudioDevicePropertyDataSource.insert(device);
});
};
}
}
- (NSArray<NSMenuItem*>*) createMenuItemsForDevice:(CAHALAudioDevice)device {
// We fill this array with a menu item for each output device (or each data source for each device) on
// the system.
NSMutableArray<NSMenuItem*>* items = [NSMutableArray new];
AudioObjectPropertyScope scope = kAudioObjectPropertyScopeOutput;
UInt32 channel = kAudioObjectPropertyElementMaster;
// If the device has data sources, create a menu item for each. Otherwise, create a single menu item
// for the device. This way the menu items' titles will be, for example, "Internal Speakers" rather
// than "Built-in Output".
//
// TODO: Handle data destinations as well? I don't have (or know of) any hardware with them.
// TODO: Use the current data source's name when the control isn't settable, but only add one menu item.
UInt32 numDataSources = 0;
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
if (device.HasDataSourceControl(scope, channel) &&
device.DataSourceControlIsSettable(scope, channel)) {
numDataSources = device.GetNumberAvailableDataSources(scope, channel);
}
});
if (numDataSources > 0) {
UInt32 dataSourceIDs[numDataSources];
// This call updates numDataSources to the real number of IDs it added to our array.
device.GetAvailableDataSources(scope, channel, numDataSources, dataSourceIDs);
for (UInt32 i = 0; i < numDataSources; i++) {
DebugMsg("BGMOutputDeviceMenuSection::createMenuItemsForDevice: "
"Creating item. %s%u %s%u",
"Device ID:", device.GetObjectID(),
", Data source ID:", dataSourceIDs[i]);
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, "(DS)", [&] {
NSString* dataSourceName =
CFBridgingRelease(device.CopyDataSourceNameForID(scope, channel, dataSourceIDs[i]));
NSString* deviceName = CFBridgingRelease(device.CopyName());
[items addObject:[self createMenuItemForDevice:device
dataSourceID:@(dataSourceIDs[i])
title:dataSourceName
toolTip:deviceName]];
});
}
} else {
DebugMsg("BGMOutputDeviceMenuSection::createMenuItemsForDevice: Creating item. %s%u",
"Device ID:", device.GetObjectID());
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
[items addObject:[self createMenuItemForDevice:device
dataSourceID:nil
title:CFBridgingRelease(device.CopyName())
toolTip:nil]];
});
}
return items;
}
- (NSMenuItem*) createMenuItemForDevice:(CAHALAudioDevice)device
dataSourceID:(NSNumber* __nullable)dataSourceID
title:(NSString* __nullable)title
toolTip:(NSString* __nullable)toolTip {
// If we don't have a title, use the tool-tip text instead.
if (!title) {
title = (toolTip ? toolTip : @"");
}
NSMenuItem* item = [[NSMenuItem alloc] initWithTitle:BGMNN(title)
action:@selector(outputDeviceMenuItemSelected:)
keyEquivalent:@""];
// Add the AirPlay icon to the labels of AirPlay devices.
//
// TODO: Test this with real hardware that supports AirPlay. (I don't have any.)
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
if (device.GetTransportType() == kAudioDeviceTransportTypeAirPlay) {
item.image = [NSImage imageNamed:@"AirPlayIcon"];
// Make the icon a "template image" so it gets drawn colour-inverted when it's highlighted or
// OS X is in dark mode.
[item.image setTemplate:YES];
}
});
// The menu item should be selected if it's the menu item for the current output device. If the device
// has data sources, only the menu item for the current data source should be selected.
BOOL isSelected =
[audioDevices isOutputDevice:device.GetObjectID()] &&
(!dataSourceID || [audioDevices isOutputDataSource:[dataSourceID unsignedIntValue]]);
item.state = (isSelected ? NSOnState : NSOffState);
item.toolTip = toolTip;
item.target = self;
item.indentationLevel = 1;
item.representedObject = @{ @"deviceID": @(device.GetObjectID()),
@"dataSourceID": dataSourceID ? BGMNN(dataSourceID) : [NSNull null] };
#if __clang_major__ >= 9
if (@available(macOS 10.10, *)) {
// Used for UI tests.
item.accessibilityIdentifier = @"output-device";
}
#endif
return item;
}
// Called by BGMAudioDeviceManager to tell us a different device has been set as the output device.
- (void) outputDeviceDidChange {
BGMOutputDeviceMenuSection* __weak weakSelf = self;
dispatch_async(dispatch_get_main_queue(), ^{
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
[weakSelf populateBGMMenu];
});
});
}
- (void) outputDeviceMenuItemSelected:(NSMenuItem*)menuItem {
DebugMsg("BGMOutputDeviceMenuSection::outputDeviceMenuItemSelected: '%s' menu item selected",
[menuItem.title UTF8String]);
// Make sure the menu item is actually for an output device.
if (![outputDeviceMenuItems containsObject:menuItem]) {
return;
}
// Change to the new output device.
AudioDeviceID newDeviceID = [[menuItem representedObject][@"deviceID"] unsignedIntValue];
id newDataSourceID = [menuItem representedObject][@"dataSourceID"];
BOOL changingDevice = ![audioDevices isOutputDevice:newDeviceID];
BOOL changingDataSource =
(newDataSourceID != [NSNull null]) &&
![audioDevices isOutputDataSource:[newDataSourceID unsignedIntValue]];
if (changingDevice || changingDataSource) {
NSString* deviceName =
menuItem.toolTip ?
[NSString stringWithFormat:@"%@ (%@)", menuItem.title, menuItem.toolTip] :
menuItem.title;
if (changingDevice) {
// Add the new output device to the list of preferred devices.
[preferredDevices userChangedOutputDeviceTo:newDeviceID];
}
// Dispatched because it usually blocks. (Note that we're using
// DISPATCH_QUEUE_PRIORITY_HIGH, which is the second highest priority.)
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{
[self changeToOutputDevice:newDeviceID
newDataSource:newDataSourceID
deviceName:deviceName];
});
}
}
- (void) changeToOutputDevice:(AudioDeviceID)deviceID
newDataSource:(id)dataSourceID
deviceName:(NSString*)deviceName {
NSError* __nullable error;
if (dataSourceID == [NSNull null]) {
error = [audioDevices setOutputDeviceWithID:deviceID revertOnFailure:YES];
} else {
error = [audioDevices setOutputDeviceWithID:deviceID
dataSourceID:[dataSourceID unsignedIntValue]
revertOnFailure:YES];
}
if (error) {
// Couldn't change the output device, so show a warning. (No need to change the menu
// selection back because it gets repopulated every time it's opened.)
// NSAlerts should only be shown on the main thread.
dispatch_async(dispatch_get_main_queue(), ^{
NSLog(@"Failed to set output device: %@", deviceName);
NSAlert* alert = [NSAlert new];
alert.messageText =
[NSString stringWithFormat:@"Failed to set %@ as the output device.", deviceName];
alert.informativeText = @"This is probably a bug. Feel free to report it.";
[alert runModal];
});
}
}
@end
#pragma clang assume_nonnull end
+48
View File
@@ -0,0 +1,48 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMOutputVolumeMenuItem.h
// BGMApp
//
// Copyright © 2017 Kyle Neideck
//
// Local Includes
#import "BGMAudioDeviceManager.h"
// System Includes
#import <Cocoa/Cocoa.h>
#pragma clang assume_nonnull begin
@interface BGMOutputVolumeMenuItem : NSMenuItem
// A menu item with a slider for controlling the volume of the output device. Similar to the one in
// macOS's Volume menu extra.
//
// view, slider and deviceLabel are the UI elements from MainMenu.xib.
- (instancetype) initWithAudioDevices:(BGMAudioDeviceManager*)devices
view:(NSView*)view
slider:(NSSlider*)slider
deviceLabel:(NSTextField*)label;
- (void) outputDeviceDidChange;
@end
#pragma clang assume_nonnull end
+290
View File
@@ -0,0 +1,290 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMOutputVolumeMenuItem.mm
// BGMApp
//
// Copyright © 2017-2019 Kyle Neideck
//
// Self Include
#import "BGMOutputVolumeMenuItem.h"
// Local Includes
#import "BGM_Utils.h"
#import "BGMAudioDevice.h"
#import "BGMVolumeChangeListener.h"
// PublicUtility Includes
#import "CAException.h"
#import "CAPropertyAddress.h"
// System Includes
#import <CoreAudio/AudioHardware.h>
#pragma clang assume_nonnull begin
const float kSliderEpsilon = 1e-10f;
const AudioObjectPropertyScope kScope = kAudioDevicePropertyScopeOutput;
NSString* const __nonnull kGenericOutputDeviceName = @"Output Device";
@implementation BGMOutputVolumeMenuItem {
BGMAudioDeviceManager* audioDevices;
NSTextField* deviceLabel;
NSSlider* volumeSlider;
BGMAudioDevice outputDevice;
BGMVolumeChangeListener* volumeChangeListener;
AudioObjectPropertyListenerBlock updateLabelListenerBlock;
}
// TODO: Show the output device's icon next to its name.
// TODO: Should the menu (bgmMenu) hide after you change the output volume slider, like the normal
// menu bar volume slider does?
// TODO: Move the output devices from Preferences to the main menu so they're slightly easier to
// access?
// TODO: Update the screenshot in the README at some point.
- (instancetype) initWithAudioDevices:(BGMAudioDeviceManager*)devices
view:(NSView*)view
slider:(NSSlider*)slider
deviceLabel:(NSTextField*)label {
if ((self = [super initWithTitle:@"" action:nil keyEquivalent:@""])) {
audioDevices = devices;
deviceLabel = label;
volumeSlider = slider;
outputDevice = audioDevices.outputDevice;
// volumeChangeListener and updateLabelListenerBlock are initialised in the methods called
// below.
// Apply our custom view from MainMenu.xib.
self.view = view;
// Set up the UI components in the view.
[self initSlider];
[self updateLabelAndToolTip];
// Register a listener so we can update if the output device's data source changes.
[self addOutputDeviceDataSourceListener];
}
return self;
}
- (void) dealloc {
// Remove the audio property listeners.
// TODO: This call isn't thread safe. (But currently this dealloc method is only called if
// there's an error.)
[self removeOutputDeviceDataSourceListener];
}
- (void) initSlider {
BGMAssert([NSThread isMainThread],
"initSlider must be called from the main thread because it calls UI functions.");
volumeSlider.target = self;
volumeSlider.action = @selector(sliderChanged:);
// Initialise the slider.
[self updateVolumeSlider];
// Register a listener that will update the slider when the user changes the volume or
// mutes/unmutes their audio.
BGMOutputVolumeMenuItem* __weak weakSelf = self;
volumeChangeListener = new BGMVolumeChangeListener(audioDevices.bgmDevice, [=] {
[weakSelf updateVolumeSlider];
});
}
// Updates the value of the output volume slider. Should only be called on the main thread because
// it calls UI functions.
- (void) updateVolumeSlider {
BGMAssert([[NSThread currentThread] isMainThread], "updateVolumeSlider on non-main thread.");
BGMAudioDevice bgmDevice = [audioDevices bgmDevice];
// BGMDevice should never return an error for these calls, so we just swallow any exceptions and
// give up. (That said, we do check mute last so that, if it did throw, it wouldn't affect the
// more important calls.)
BGMLogAndSwallowExceptions("BGMOutputVolumeMenuItem::updateVolumeSlider", ([&] {
BOOL hasVolume = bgmDevice.HasSettableMasterVolume(kScope);
// If the device doesn't have a master volume control, we disable the slider and set it to
// full (or to zero, if muted).
volumeSlider.enabled = hasVolume;
if (hasVolume) {
// Set the slider to the current output volume. The slider values and volume values are
// both from 0 to 1, so we can use the volume as is.
volumeSlider.doubleValue =
bgmDevice.GetVolumeControlScalarValue(kScope, kMasterChannel);
} else {
volumeSlider.doubleValue = 1.0;
}
// Set the slider to zero if the device is muted.
if (bgmDevice.HasSettableMasterMute(kScope) &&
bgmDevice.GetMuteControlValue(kScope, kMasterChannel)) {
volumeSlider.doubleValue = 0.0;
}
}));
}
- (void) addOutputDeviceDataSourceListener {
// Create the block that updates deviceLabel when the output device's data source changes, e.g.
// from Internal Speakers to Headphones.
if (!updateLabelListenerBlock) {
BGMOutputVolumeMenuItem* __weak weakSelf = self;
updateLabelListenerBlock =
^(UInt32 inNumberAddresses, const AudioObjectPropertyAddress* inAddresses) {
// The docs for AudioObjectPropertyListenerBlock say inAddresses will always contain
// at least one property the block is listening to, so there's no need to check it.
#pragma unused (inNumberAddresses, inAddresses)
[weakSelf updateLabelAndToolTip];
};
}
// Register the listener.
//
// Instead of swallowing exceptions, we could try again later, but I doubt it would be worth the
// effort. And the documentation doesn't actually explain what could cause this to fail.
BGMLogAndSwallowExceptions("BGMOutputVolumeMenuItem::addOutputDeviceDataSourceListener", ([&] {
outputDevice.AddPropertyListenerBlock(
CAPropertyAddress(kAudioDevicePropertyDataSource, kScope),
dispatch_get_main_queue(),
updateLabelListenerBlock);
}));
}
- (void) removeOutputDeviceDataSourceListener {
BGMLogAndSwallowExceptions("BGMOutputVolumeMenuItem::removeOutputDeviceDataSourceListener",
([&] {
// Technically, there's a race here in that the device could be removed after we check it
// exists, but before we try to remove the listener. We could check the error code of the
// exception and not log an error message if the code is kAudioHardwareBadObjectError or
// kAudioHardwareBadDeviceError, but it probably wouldn't be worth the effort.
//
// So for now the main reason for checking the device exists here is that it makes debug
// builds much less likely to crash here. (They crash/break when an error is logged so it
// will be noticed.)
if (CAHALAudioObject::ObjectExists(outputDevice)) {
outputDevice.RemovePropertyListenerBlock(
CAPropertyAddress(kAudioDevicePropertyDataSource, kScope),
dispatch_get_main_queue(),
updateLabelListenerBlock);
}
}));
}
- (void) outputDeviceDidChange {
dispatch_async(dispatch_get_main_queue(), ^{
// Remove the data source listener from the previous output device.
[self removeOutputDeviceDataSourceListener];
// Add it to the new output device.
outputDevice = audioDevices.outputDevice;
[self addOutputDeviceDataSourceListener];
// Update the label to use the name of the new output device.
[self updateLabelAndToolTip];
// Set the slider to the volume of the new device.
[self updateVolumeSlider];
});
}
// Sets the label to the output device's name or, if it has one, its current datasource. If it has a
// datasource, the device's name is set as this menu item's tooltip. Falls back to a generic name if
// the device returns an error when queried.
- (void) updateLabelAndToolTip {
BOOL didSetLabel = NO;
try {
if (outputDevice.HasDataSourceControl(kScope, kMasterChannel)) {
// The device has datasources, so use the current datasource's name like macOS does.
UInt32 dataSourceID = outputDevice.GetCurrentDataSourceID(kScope, kMasterChannel);
deviceLabel.stringValue =
(__bridge_transfer NSString*)outputDevice.CopyDataSourceNameForID(kScope,
kMasterChannel,
dataSourceID);
didSetLabel = YES; // So we know not to change the text if setting the tooltip fails.
// Set the tooltip of the menu item (the container) rather than the label because menu
// items' tooltips will still appear when a different app is focused and, as far as I
// know, BGMApp should never be the foreground app.
self.toolTip = (__bridge_transfer NSString*)outputDevice.CopyName();
} else {
deviceLabel.stringValue = (__bridge_transfer NSString*)outputDevice.CopyName();
self.toolTip = nil;
}
} catch (const CAException& e) {
BGMLogException(e);
// The device returned an error, so set the label to a generic device name, since we don't
// want to leave it set to the previous device's name.
self.toolTip = nil;
if (!didSetLabel) {
deviceLabel.stringValue = kGenericOutputDeviceName;
}
}
DebugMsg("BGMOutputVolumeMenuItem::updateLabelAndToolTip: Label: '%s' Tooltip: '%s'",
deviceLabel.stringValue.UTF8String,
self.toolTip.UTF8String);
// Take the label out of the accessibility hierarchy, which also moves the slider up a level.
#if MAC_OS_X_VERSION_MAX_ALLOWED >= 101000 // MAC_OS_X_VERSION_10_10
if ([deviceLabel.cell respondsToSelector:@selector(setAccessibilityElement:)]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
deviceLabel.cell.accessibilityElement = NO;
#pragma clang diagnostic pop
}
#endif
}
// Called when the user slides the slider.
- (IBAction) sliderChanged:(NSSlider*)sender {
float newValue = sender.floatValue;
DebugMsg("BGMOutputVolumeMenuItem::sliderChanged: New value: %f", newValue);
// Update BGMDevice's volume to the new value selected by the user.
try {
// The slider values and volume values are both from 0.0f to 1.0f, so we can use the slider
// value as is.
audioDevices.bgmDevice.SetVolumeControlScalarValue(kScope, kMasterChannel, newValue);
// Mute BGMDevice if they set the slider to zero, and unmute it for non-zero. Muting makes
// sure the audio doesn't play very quietly instead being completely silent. This matches
// the behaviour of the Volume menu built-in to macOS.
if (audioDevices.bgmDevice.HasMuteControl(kScope, kMasterChannel)) {
audioDevices.bgmDevice.SetMuteControlValue(kScope,
kMasterChannel,
(newValue < kSliderEpsilon));
}
} catch (const CAException& e) {
NSLog(@"BGMOutputVolumeMenuItem::sliderChanged: Failed to set volume (%d)", e.GetError());
}
}
@end
#pragma clang assume_nonnull end
File diff suppressed because it is too large Load Diff
+91 -38
View File
@@ -17,7 +17,7 @@
// BGMPlayThrough.h
// BGMApp
//
// Copyright © 2016 Kyle Neideck
// Copyright © 2016, 2017 Kyle Neideck
//
// Reads audio from an input device and immediately writes it to an output device. We currently use this class with the input
// device always set to BGMDevice and the output device set to the one selected in the preferences menu.
@@ -28,7 +28,7 @@
// sample code from 2004. This class's main addition is pausing playthrough when idle to save CPU.
//
// Playing audio with this class uses more CPU, mostly in the coreaudiod process, than playing audio normally because we need
// an input IO proc as well as an output one, and BGMDriver is running in addition to the output device's driver. For me, it
// an input IOProc as well as an output one, and BGMDriver is running in addition to the output device's driver. For me, it
// usually adds around 1-2% (as a percentage of total usage -- it doesn't seem to be relative to the CPU used when playing
// audio normally).
//
@@ -36,14 +36,20 @@
// a future release.
//
#ifndef __BGMApp__BGMPlayThrough__
#define __BGMApp__BGMPlayThrough__
#ifndef BGMApp__BGMPlayThrough
#define BGMApp__BGMPlayThrough
// Local Includes
#include "BGMAudioDevice.h"
// PublicUtility Includes
#include "CARingBuffer.h"
#include "CAHALAudioDevice.h"
#include "CAMutex.h"
// STL Includes
#include <atomic>
#include <algorithm>
// System Includes
#include <mach/semaphore.h>
@@ -52,48 +58,79 @@
class BGMPlayThrough
{
public:
// Error codes
static const OSStatus kDeviceNotStarting = 100;
public:
BGMPlayThrough(CAHALAudioDevice inInputDevice, CAHALAudioDevice inOutputDevice);
BGMPlayThrough(BGMAudioDevice inInputDevice, BGMAudioDevice inOutputDevice);
~BGMPlayThrough();
// Disallow copying
BGMPlayThrough(const BGMPlayThrough&) = delete;
BGMPlayThrough& operator=(const BGMPlayThrough&) = delete;
// Move constructor/assignment
BGMPlayThrough(BGMPlayThrough&& inPlayThrough) { Swap(inPlayThrough); }
BGMPlayThrough& operator=(BGMPlayThrough&& inPlayThrough) { Swap(inPlayThrough); return *this; }
#ifdef __OBJC__
// Only intended as a convenience for Objective-C instance vars
// Only intended as a convenience (hack) for Objective-C instance vars. Call
// SetDevices to initialise the instance before using it.
BGMPlayThrough() { }
#endif
private:
void Swap(BGMPlayThrough& inPlayThrough);
void Activate();
void Deactivate();
void AllocateBuffer();
static bool IsBGMDevice(CAHALAudioDevice inDevice);
/*! @throws CAException */
void Init(BGMAudioDevice inInputDevice, BGMAudioDevice inOutputDevice);
void CreateIOProcs();
void DestroyIOProcs();
public:
/*! @throws CAException */
void Activate();
/*! @throws CAException */
void Deactivate();
private:
void AllocateBuffer();
/*! @throws CAException */
void CreateIOProcIDs();
/*! @throws CAException */
void DestroyIOProcIDs();
/*!
@return True if both IOProcs are stopped.
@nonthreadsafe
*/
bool CheckIOProcsAreStopped() const noexcept; // TODO: REQUIRES(mStateMutex);
public:
OSStatus Start();
OSStatus WaitForOutputDeviceToStart();
/*!
Pass null for either param to only change one of the devices.
@throws CAException
*/
void SetDevices(const BGMAudioDevice* __nullable inInputDevice,
const BGMAudioDevice* __nullable inOutputDevice);
/*! @throws CAException */
void Start();
// Blocks until the output device has started our IOProc. Returns one of the error constants
// from AudioHardwareBase.h (e.g. kAudioHardwareNoError).
OSStatus WaitForOutputDeviceToStart() noexcept;
private:
void ReleaseThreadsWaitingForOutputToStart() const;
public:
OSStatus Stop();
void StopIfIdle();
private:
static OSStatus BGMDeviceListenerProc(AudioObjectID inObjectID,
UInt32 inNumberAddresses,
const AudioObjectPropertyAddress* inAddresses,
void* __nullable inClientData);
static bool RunningSomewhereOtherThanBGMApp(const CAHALAudioDevice inBGMDevice);
static void HandleBGMDeviceIsRunning(BGMPlayThrough* refCon);
static void HandleBGMDeviceIsRunningSomewhereOtherThanBGMApp(BGMPlayThrough* refCon);
static bool IsRunningSomewhereOtherThanBGMApp(const BGMAudioDevice& inBGMDevice);
static OSStatus InputDeviceIOProc(AudioObjectID inDevice,
const AudioTimeStamp* inNow,
@@ -109,6 +146,22 @@ private:
AudioBufferList* outOutputData,
const AudioTimeStamp* inOutputTime,
void* __nullable inClientData);
// The state of an IOProc. Used by the IOProc to tell other threads when it's finished starting. Used by other
// threads to tell the IOProc to stop itself. (Probably used for other things as well.)
enum class IOState
{
Stopped, Starting, Running, Stopping
};
// The IOProcs call this to update their IOState member. Also stops the IOProc if its state has been set to Stopping.
// Returns true if it changes the state.
static bool UpdateIOProcState(const char* __nullable callerName,
std::atomic<IOState>& inState,
AudioDeviceIOProcID __nullable inIOProcID,
BGMAudioDevice& inDevice,
IOState& outNewState);
static void HandleRingBufferError(CARingBufferError err,
const char* methodName,
const char* callReturningErr);
@@ -116,41 +169,41 @@ private:
private:
CARingBuffer mBuffer;
AudioDeviceIOProcID mInputDeviceIOProcID;
AudioDeviceIOProcID mOutputDeviceIOProcID;
AudioDeviceIOProcID __nullable mInputDeviceIOProcID { nullptr };
AudioDeviceIOProcID __nullable mOutputDeviceIOProcID { nullptr };
CAHALAudioDevice mInputDevice { kAudioDeviceUnknown };
CAHALAudioDevice mOutputDevice { kAudioDeviceUnknown };
BGMAudioDevice mInputDevice { kAudioObjectUnknown };
BGMAudioDevice mOutputDevice { kAudioObjectUnknown };
CAMutex mStateMutex { "Playthrough state" };
// Signalled when the output IO proc runs. We use it to tell BGMDriver when the output device is ready to receive audio data.
// Signalled when the output IOProc runs. We use it to tell BGMDriver when the output device is ready to receive audio data.
semaphore_t mOutputDeviceIOProcSemaphore { SEMAPHORE_NULL };
bool mActive = false;
bool mPlayingThrough = false;
UInt64 mLastNotifiedIOStoppedOnBGMDevice;
bool mInputDeviceIOProcShouldStop = false;
bool mOutputDeviceIOProcShouldStop = false;
UInt64 mLastNotifiedIOStoppedOnBGMDevice { 0 };
std::atomic<IOState> mInputDeviceIOProcState { IOState::Stopped };
std::atomic<IOState> mOutputDeviceIOProcState { IOState::Stopped };
// For debug logging.
UInt64 mToldOutputDeviceToStartAt;
UInt64 mToldOutputDeviceToStartAt { 0 };
// IO proc vars. (Should only be used inside IO procs.)
// IOProc vars. (Should only be used inside IOProcs.)
// The earliest/latest sample times seen by the IO procs since starting playthrough. -1 for unset.
// The earliest/latest sample times seen by the IOProcs since starting playthrough. -1 for unset.
Float64 mFirstInputSampleTime = -1;
Float64 mLastInputSampleTime = -1;
Float64 mLastOutputSampleTime = -1;
// Subtract this from the output time to get the input time.
Float64 mInToOutSampleOffset;
Float64 mInToOutSampleOffset { 0.0 };
};
#pragma clang assume_nonnull end
#endif /* __BGMApp__BGMPlayThrough__ */
#endif /* BGMApp__BGMPlayThrough */
+64
View File
@@ -0,0 +1,64 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMPreferredOutputDevices.h
// BGMApp
//
// Copyright © 2018 Kyle Neideck
//
// Tries to change BGMApp's output device when the user plugs in or unplugs an audio device, in the
// same way macOS would change its default device if Background Music wasn't running.
//
// For example, if you plug in some USB headphones, make them your default device and then unplug
// them, macOS will change its default device to the previous default device. Then, if you plug
// them back in, macOS will make them the default device again.
//
// This class isn't always able to figure out what macOS would do, in which case it leaves BGMApp's
// output device as it is.
//
// Local Includes
#import "BGMAudioDeviceManager.h"
#import "BGMUserDefaults.h"
// System Includes
#import <CoreAudio/AudioHardwareBase.h>
#import <Foundation/Foundation.h>
#pragma clang assume_nonnull begin
@interface BGMPreferredOutputDevices : NSObject
// Starts responding to device connections/disconnections immediately. Stops if/when the instance is
// deallocated.
- (instancetype) initWithDevices:(BGMAudioDeviceManager*)devices
userDefaults:(BGMUserDefaults*)userDefaults;
// Returns the most-preferred device that's currently connected. If no preferred devices are
// connected, returns the current output device. If the current output device has been disconnected,
// returns an arbitrary device.
//
// If none of the connected devices can be used as the output device, or if it can't find a device
// to use because the HAL returned errors when queried, returns kAudioObjectUnknown.
- (AudioObjectID) findPreferredDevice;
- (void) userChangedOutputDeviceTo:(AudioObjectID)device;
@end
#pragma clang assume_nonnull end
+495
View File
@@ -0,0 +1,495 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMPreferredOutputDevices.m
// BGMApp
//
// Copyright © 2018 Kyle Neideck
//
// Self Include
#import "BGMPreferredOutputDevices.h"
// Local Includes
#import "BGM_Types.h"
#import "BGM_Utils.h"
#import "BGMAudioDevice.h"
// PublicUtility Includes
#import "CAAutoDisposer.h"
#import "CAHALAudioSystemObject.h"
#import "CAPropertyAddress.h"
#pragma clang assume_nonnull begin
// The Plist file CoreAudio stores the preferred devices list in. It isn't part of the public API,
// but if this class fails to read/parse it, BGMApp will continue without it.
NSString* const kAudioSystemSettingsPlist =
@"/Library/Preferences/Audio/com.apple.audio.SystemSettings.plist";
@implementation BGMPreferredOutputDevices {
NSRecursiveLock* _stateLock;
// Used to change BGMApp's output device.
BGMAudioDeviceManager* _devices;
// User settings and data.
BGMUserDefaults* _userDefaults;
// The UIDs of the preferred devices, in order of preference. The most-preferred device is at
// index 0. This list is derived from several sources.
NSArray<NSString*>* _preferredDeviceUIDs;
// Called when a device is connected or disconnected.
AudioObjectPropertyListenerBlock _deviceListListener;
}
- (instancetype) initWithDevices:(BGMAudioDeviceManager*)devices
userDefaults:(BGMUserDefaults*)userDefaults {
if ((self = [super init])) {
_stateLock = [NSRecursiveLock new];
_devices = devices;
_userDefaults = userDefaults;
_preferredDeviceUIDs = [self readPreferredDevices];
DebugMsg("BGMPreferredOutputDevices::initWithDevices: Preferred devices: %s",
_preferredDeviceUIDs.debugDescription.UTF8String);
[self listenForDevicesAddedOrRemoved];
}
return self;
}
- (void) dealloc {
@try {
[_stateLock lock];
// Tell CoreAudio not to call the listener block anymore.
CAHALAudioSystemObject().RemovePropertyListenerBlock(
CAPropertyAddress(kAudioHardwarePropertyDevices),
dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0),
_deviceListListener);
} @finally {
[_stateLock unlock];
}
}
// Reads the preferred devices list from CoreAudio's Plist file. Uses BGMApp's stored list to fill
// in the blanks, if necessary.
- (NSArray<NSString*>*) readPreferredDevices {
// Read the Plist file into a dictionary.
//
// TODO: If this file doesn't exist, try paths used by older versions of macOS. (If there are
// any, that is. I haven't checked.)
NSURL* url = [NSURL fileURLWithPath:kAudioSystemSettingsPlist];
NSError* error = nil;
NSData* data = [NSData dataWithContentsOfURL:url options:0 error:&error];
NSDictionary* settings = nil;
if (data && !error) {
settings = [NSPropertyListSerialization propertyListWithData:data
options:NSPropertyListImmutable
format:nil
error:&error];
}
// Default to a list with just the systemwide default device (or an empty list if that fails) if
// we can't read the preferred devices from the Plist because preferredDeviceUIDsFrom will use
// BGMApp's stored preferred devices to fill in the rest optimistically. This doesn't help us
// tell when to switch to a newly connected device, but it should improve our chances of
// switching to the best device if the current output device is disconnected.
NSArray<NSDictionary*>* preferredOutputDeviceInfos = @[];
// If we can't read the Plist, we only know that the current systemwide default device is the
// most-preferred device that's currently connected.
//
// TODO: If we are able to read the Plist, check that the systemwide default device is the
// most-preferred device in the list from the Plist that's also connected. If it isn't,
// the format of the Plist has probably changed, so we should ignore its data and log a
// warning.
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
BGMAudioDevice defaultDevice = CAHALAudioSystemObject().GetDefaultAudioDevice(false, false);
NSString* __nullable defaultDeviceUID =
(__bridge_transfer NSString* __nullable)defaultDevice.CopyDeviceUID();
if (defaultDeviceUID) {
preferredOutputDeviceInfos = @[ @{ @"uid": BGMNN(defaultDeviceUID) } ];
}
});
if (error || !data || !settings) {
// The Plist file either doesn't exist or we weren't able to parse it.
LogWarning("BGMPreferredOutputDevices::readPreferredDevices: Couldn't read %s. "
"(data = %s, settings = %s) Error: %s",
kAudioSystemSettingsPlist.UTF8String,
data.debugDescription.UTF8String,
settings.debugDescription.UTF8String,
error.debugDescription.UTF8String);
} else if (!settings[@"preferred devices"]) {
// The Plist doesn't include the lists of preferred devices (for input, output and system
// output).
LogWarning("BGMPreferredOutputDevices::readPreferredDevices: No preferred devices in %s",
settings.debugDescription.UTF8String);
} else if (!settings[@"preferred devices"][@"output"]) {
// The Plist doesn't include the list of preferred output devices.
LogWarning("BGMPreferredOutputDevices::readPreferredDevices: "
"No preferred output devices in %s",
settings.debugDescription.UTF8String);
} else {
// Copy the preferred devices out of the Plist.
preferredOutputDeviceInfos = BGMNN(settings[@"preferred devices"][@"output"]);
}
return [self preferredDeviceUIDsFrom:preferredOutputDeviceInfos
storedPreferredDeviceUIDs:_userDefaults.preferredDeviceUIDs];
}
- (NSArray<NSString*>*) preferredDeviceUIDsFrom:(NSArray<NSDictionary*>*)deviceInfos
storedPreferredDeviceUIDs:(NSArray<NSString*>*)storedUIDs {
NSArray<NSString*>* deviceUIDs = @[];
int storedPreferredDeviceIdx = 0;
for (NSDictionary* deviceInfo in deviceInfos) {
// Check the Plist actually has a UID for this device.
if (![deviceInfo[@"uid"] isKindOfClass:NSString.class]) {
LogWarning("BGMPreferredOutputDevices::preferredDeviceUIDsFrom: No UID in %s",
deviceInfo.debugDescription.UTF8String);
continue;
}
NSString* uid = deviceInfo[@"uid"];
if ([uid isEqualToString:@kBGMDeviceUID] ||
[uid isEqualToString:@kBGMDeviceUID_UISounds] ||
[uid isEqualToString:@kBGMNullDeviceUID]) {
// This is one of the Background Music devices, so look for a preferred device saved
// from a previous run of BGMApp and add it instead.
//
// BGMApp has to set BGMDevice, and often also the Null Device for a short time, as the
// systemwide default audio device, which makes CoreAudio put them in its Plist. And
// since the Plist is limited to three devices, it only gives us one or two usable ones.
// Ideally, CoreAudio just wouldn't add our devices to its list, but I don't think we
// can prevent that. And we can't be sure that editing its Plist file ourselves would be
// safe.
//
// TODO: This doesn't work if the user has made BGMDevice the systemwide default device
// themselves since the last time they closed BGMApp. We might be able to fix that
// by having a background process watch the Plist for changes while BGMApp is
// closed or something like that, but I doubt there's a nice or simple solution.
deviceUIDs = [self addNextStoredPreferredDevice:&storedPreferredDeviceIdx
preferredDeviceUIDs:deviceUIDs
storedPreferredDeviceUIDs:storedUIDs];
} else if (![deviceUIDs containsObject:uid]) {
// Add this preferred device's UID to the list.
deviceUIDs = [deviceUIDs arrayByAddingObject:uid];
}
}
// Fill in any remaining places in the list with stored devices. Limit the list to three devices
// just to match CoreAudio's behaviour.
while ((storedPreferredDeviceIdx < storedUIDs.count) && (deviceUIDs.count < 3)) {
deviceUIDs = [self addNextStoredPreferredDevice:&storedPreferredDeviceIdx
preferredDeviceUIDs:deviceUIDs
storedPreferredDeviceUIDs:storedUIDs];
}
return deviceUIDs;
}
- (NSArray<NSString*>*) addNextStoredPreferredDevice:(int*)storedPreferredDeviceIdx
preferredDeviceUIDs:(NSArray<NSString*>*)deviceUIDs
storedPreferredDeviceUIDs:(NSArray<NSString*>*)storedUIDs {
NSString* __nullable storedPreferredDevice;
// Try to find a stored UID that isn't already in the list.
do {
storedPreferredDevice = (*storedPreferredDeviceIdx < storedUIDs.count) ?
storedUIDs[*storedPreferredDeviceIdx] : nil;
(*storedPreferredDeviceIdx)++;
} while (storedPreferredDevice && [deviceUIDs containsObject:BGMNN(storedPreferredDevice)]);
// If we found a stored UID, add it to the list.
if (storedPreferredDevice) {
DebugMsg("BGMPreferredOutputDevices::addNextStoredPreferredDevice: "
"Adding stored preferred device: %s",
storedPreferredDevice.UTF8String);
deviceUIDs = [deviceUIDs arrayByAddingObject:BGMNN(storedPreferredDevice)];
}
return deviceUIDs;
}
- (void) listenForDevicesAddedOrRemoved {
// Create the block that will run when a device is added or removed.
BGMPreferredOutputDevices* __weak weakSelf = self;
_deviceListListener = ^(UInt32 inNumberAddresses,
const AudioObjectPropertyAddress* inAddresses) {
#pragma unused (inNumberAddresses, inAddresses)
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
[weakSelf connectedDeviceListChanged];
});
};
// Register the listener block with CoreAudio.
CAHALAudioSystemObject().AddPropertyListenerBlock(
CAPropertyAddress(kAudioHardwarePropertyDevices),
dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0),
_deviceListListener);
}
- (void) connectedDeviceListChanged {
@try {
[_stateLock lock];
// Decide which device should be the output device now. If a device has been connected and
// it's preferred over the current output device, we'll change to that device. If the
// current output device has been removed, we'll change to the next most-preferred device.
AudioObjectID preferredDevice = [self findPreferredDevice];
if (preferredDevice == kAudioObjectUnknown) {
LogWarning("BGMPreferredOutputDevices::connectedDeviceListChanged: "
"No preferred device found.");
} else if (_devices.outputDevice.GetObjectID() == preferredDevice) {
DebugMsg("BGMPreferredOutputDevices::connectedDeviceListChanged: "
"The preferred device is already set as the output device.");
} else {
// Change to the preferred device.
DebugMsg("BGMPreferredOutputDevices::connectedDeviceListChanged: "
"Changing output device to %d.",
preferredDevice);
NSError* __nullable error = [_devices setOutputDeviceWithID:preferredDevice
revertOnFailure:YES];
if (error) {
// There's not much we can do if this happens.
LogError("BGMPreferredOutputDevices::connectedDeviceListChanged: "
"Failed to change to preferred device. Error: %s",
error.debugDescription.UTF8String);
}
}
} @finally {
[_stateLock unlock];
}
}
- (AudioObjectID) findPreferredDevice {
AudioObjectID preferredDevice = kAudioObjectUnknown;
CAHALAudioSystemObject audioSystem;
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
BGMAudioDevice defaultDevice = audioSystem.GetDefaultAudioDevice(false, false);
if (!defaultDevice.IsBGMDeviceInstance()) {
// BGMDevice isn't the systemwide default device, so we know the default device is the
// most-preferred device that's currently connected.
preferredDevice = defaultDevice;
}
});
if (preferredDevice == kAudioObjectUnknown) {
// BGMDevice is the systemwide default device, so this method is probably being called after
// launch, since we set BGMDevice as the default device then. It could also be that the user
// set it manually or that BGMApp failed to change it back the last time it closed. Either
// way, we'll try to find a device to use in the Plist or stored list instead.
DebugMsg("BGMPreferredOutputDevices::findPreferredDevice: Checking Plist and stored list.");
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
preferredDevice = [self findPreferredDeviceInDerivedList];
});
}
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
if (preferredDevice == kAudioObjectUnknown && [self isCurrentOutputDeviceConnected]) {
// The output device (for BGMApp) has been set and is still connected. We haven't found
// a better device to use, so prefer leaving the output device as it is.
DebugMsg("BGMPreferredOutputDevices::findPreferredDevice: "
"Choosing the current output device as the preferred device.");
preferredDevice = _devices.outputDevice.GetObjectID();
}
});
if (preferredDevice == kAudioObjectUnknown) {
// The current output device has been disconnected or hasn't been set yet and there are no
// preferred devices connected, so pick one arbitrarily.
DebugMsg("BGMPreferredOutputDevices::findPreferredDevice: Choosing an arbitrary device.");
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
preferredDevice = [self findPreferredDeviceByLatency];
});
}
return preferredDevice;
}
// Looks for a suitable device in _preferredDeviceUIDs, the list of preferred devices derived from
// CoreAudio's Plist and BGMApp's stored list.
- (AudioObjectID) findPreferredDeviceInDerivedList {
CAHALAudioSystemObject audioSystem;
UInt32 numDevices = audioSystem.GetNumberAudioDevices();
// Get the list of currently connected audio devices.
CAAutoArrayDelete<AudioObjectID> devices(numDevices);
audioSystem.GetAudioDevices(numDevices, devices);
// Look through the preferred devices list to see if one's connected. Return the first one we
// find because they're stored in order of preference.
for (int position = 0; position < _preferredDeviceUIDs.count; position++) {
// Compare the current preferred device to each connected device by UID.
for (UInt32 i = 0; i < numDevices; i++) {
NSString* __nullable connectedDeviceUID = nil;
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
// Skip devices we can't use, e.g. BGMDevice.
if (BGMAudioDevice(devices[i]).CanBeOutputDeviceInBGMApp()) {
// Get the connected device's UID.
connectedDeviceUID =
(__bridge NSString* __nullable)CAHALAudioDevice(devices[i]).CopyDeviceUID();
}
});
// If the UIDs match, the current preferred device is connected.
//
// If you plug a USB device in to different USB port, macOS might assign it a different
// UID and make it fail to match its old UID here. CoreAudio/macOS doesn't seem to
// handle that case either, though, so it's probably not worth worrying about.
if ([connectedDeviceUID isEqualToString:_preferredDeviceUIDs[position]]) {
// We're iterating through the preferred devices from most to least-preferred, so
// we've found the device to use.
DebugMsg("BGMPreferredOutputDevices::findPreferredDeviceInDerivedList: "
"Found preferred device '%s' at position %d",
_preferredDeviceUIDs[position].UTF8String,
position);
return devices[i];
}
}
DebugMsg("BGMPreferredOutputDevices::findPreferredDeviceInDerivedList: "
"Preferred device not connected: %s",
_preferredDeviceUIDs[position].UTF8String);
}
return kAudioObjectUnknown;
}
- (bool) isCurrentOutputDeviceConnected {
if (_devices.outputDevice.GetObjectID() == kAudioObjectUnknown) {
DebugMsg("BGMPreferredOutputDevices::isCurrentOutputDeviceConnected: "
"The output device hasn't been set yet.");
return false;
}
CAHALAudioSystemObject audioSystem;
UInt32 numDevices = audioSystem.GetNumberAudioDevices();
// Get the list of currently connected audio devices.
CAAutoArrayDelete<AudioObjectID> devices(numDevices);
audioSystem.GetAudioDevices(numDevices, devices);
// Look for the current output device in the list of connected devices.
for (UInt32 i = 0; i < numDevices; i++) {
// TODO: Are AudioObjectIDs reused? If they are, could that cause a collision here?
if (_devices.outputDevice.GetObjectID() == devices[i]) {
DebugMsg("BGMPreferredOutputDevices::isCurrentOutputDeviceConnected: "
"The output device is connected.");
return true;
}
}
DebugMsg("BGMPreferredOutputDevices::isCurrentOutputDeviceConnected: "
"The output device is not connected.");
return false;
}
// Returns the audio output device with the lowest latency. Used when we have no better way to
// choose the output device for BGMApp to use.
- (AudioObjectID) findPreferredDeviceByLatency {
CAHALAudioSystemObject audioSystem;
UInt32 numDevices = audioSystem.GetNumberAudioDevices();
AudioObjectID minLatencyDevice = kAudioObjectUnknown;
UInt32 minLatency = UINT32_MAX;
CAAutoArrayDelete<AudioObjectID> devices(numDevices);
audioSystem.GetAudioDevices(numDevices, devices);
for (UInt32 i = 0; i < numDevices; i++) {
BGMAudioDevice device(devices[i]);
if (!device.IsBGMDeviceInstance()) {
BOOL hasOutputChannels = NO;
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, "GetTotalNumberChannels", [&] {
hasOutputChannels = device.GetTotalNumberChannels(/* inIsInput = */ false) > 0;
});
if (hasOutputChannels) {
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, "GetLatency", [&] {
UInt32 latency = device.GetLatency(false);
if (latency < minLatency) {
minLatencyDevice = devices[i];
minLatency = latency;
}
});
}
}
}
return minLatencyDevice;
}
- (void) userChangedOutputDeviceTo:(AudioObjectID)device {
@try {
[_stateLock lock];
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
// Add the new output device to the list.
NSString* __nullable outputDeviceUID =
(__bridge_transfer NSString* __nullable)CAHALAudioDevice(device).CopyDeviceUID();
if (outputDeviceUID) {
// Limit the list to three devices because that's what macOS does.
if (_preferredDeviceUIDs.count >= 2) {
_preferredDeviceUIDs = @[BGMNN(outputDeviceUID),
_preferredDeviceUIDs[0],
_preferredDeviceUIDs[1]];
} else if (_preferredDeviceUIDs.count >= 1) {
_preferredDeviceUIDs = @[BGMNN(outputDeviceUID), _preferredDeviceUIDs[0]];
} else {
_preferredDeviceUIDs = @[BGMNN(outputDeviceUID)];
}
DebugMsg("BGMPreferredOutputDevices::userChangedOutputDeviceTo: "
"Preferred devices: %s",
_preferredDeviceUIDs.debugDescription.UTF8String);
// Save the list.
_userDefaults.preferredDeviceUIDs = _preferredDeviceUIDs;
} else {
LogWarning("BGMPreferredOutputDevices::userChangedOutputDeviceTo: "
"Output device has no UID");
}
});
} @finally {
[_stateLock unlock];
}
}
@end
#pragma clang assume_nonnull end
+63
View File
@@ -0,0 +1,63 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMStatusBarItem.h
// BGMApp
//
// Copyright © 2019 Kyle Neideck
//
// The button in the system status bar (the bar with volume, battery, clock, etc.) to show the main
// menu for the app. These are called "menu bar extras" in the Human Interface Guidelines.
//
// Local Includes
#import "BGMAudioDeviceManager.h"
// System Includes
#import <Cocoa/Cocoa.h>
// Forward Declarations
@class BGMUserDefaults;
#pragma clang assume_nonnull begin
typedef NS_ENUM(NSInteger, BGMStatusBarIcon) {
BGMFermataStatusBarIcon = 0,
BGMVolumeStatusBarIcon
};
static BGMStatusBarIcon const kBGMStatusBarIconMinValue = BGMFermataStatusBarIcon;
static BGMStatusBarIcon const kBGMStatusBarIconMaxValue = BGMVolumeStatusBarIcon;
static BGMStatusBarIcon const kBGMStatusBarIconDefaultValue = BGMFermataStatusBarIcon;
@interface BGMStatusBarItem : NSObject
- (instancetype) initWithMenu:(NSMenu*)bgmMenu
audioDevices:(BGMAudioDeviceManager*)devices
userDefaults:(BGMUserDefaults*)defaults;
// Set this to BGMFermataStatusBarIcon to change the icon to the Background Music logo.
//
// Set this to BGMFermataStatusBarIcon to change the icon to a volume icon. This icon has the
// advantage of indicating the volume level, but we can't make it the default because it looks the
// same as the icon for the macOS volume status bar item.
@property BGMStatusBarIcon icon;
@end
#pragma clang assume_nonnull end
+257
View File
@@ -0,0 +1,257 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMStatusBarItem.m
// BGMApp
//
// Copyright © 2019 Kyle Neideck
//
// Self Include
#import "BGMStatusBarItem.h"
// Local Includes
#import "BGM_Utils.h"
#import "BGMUserDefaults.h"
#import "BGMVolumeChangeListener.h"
#pragma clang assume_nonnull begin
static CGFloat const kStatusBarIconPadding = 0.25;
static CGFloat const kVolumeIconAdditionalVerticalPadding = 0.075;
@implementation BGMStatusBarItem
{
BGMAudioDeviceManager* audioDevices;
// User settings and data.
BGMUserDefaults* userDefaults;
NSImage* fermataIcon;
NSImage* volumeIcon0SoundWaves;
NSImage* volumeIcon1SoundWave;
NSImage* volumeIcon2SoundWaves;
NSImage* volumeIcon3SoundWaves;
NSStatusItem* statusBarItem;
BGMVolumeChangeListener* volumeChangeListener;
BGMStatusBarIcon _icon;
}
- (instancetype) initWithMenu:(NSMenu*)bgmMenu
audioDevices:(BGMAudioDeviceManager*)devices
userDefaults:(BGMUserDefaults*)defaults {
if ((self = [super init])) {
statusBarItem =
[[NSStatusBar systemStatusBar] statusItemWithLength:NSVariableStatusItemLength];
audioDevices = devices;
userDefaults = defaults;
// Initialise the icons.
[self initIcons];
// Set the initial icon.
self.icon = userDefaults.statusBarIcon;
// Set the menu item to open the main menu.
statusBarItem.menu = bgmMenu;
// Set the accessibility label to "Background Music". (We intentionally don't set a title or
// a tooltip.)
if ([BGMStatusBarItem buttonAvailable]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
statusBarItem.button.accessibilityLabel =
[NSRunningApplication currentApplication].localizedName;
#pragma clang diagnostic pop
}
// Update the icon when BGMDevice's volume changes.
BGMStatusBarItem* __weak weakSelf = self;
volumeChangeListener = new BGMVolumeChangeListener(audioDevices.bgmDevice, [=] {
[weakSelf bgmDeviceVolumeDidChange];
});
}
return self;
}
- (void) dealloc {
delete volumeChangeListener;
}
- (void) initIcons {
// Load the icons.
fermataIcon = [NSImage imageNamed:@"FermataIcon"];
volumeIcon0SoundWaves = [NSImage imageNamed:@"Volume0"];
volumeIcon1SoundWave = [NSImage imageNamed:@"Volume1"];
volumeIcon2SoundWaves = [NSImage imageNamed:@"Volume2"];
volumeIcon3SoundWaves = [NSImage imageNamed:@"Volume3"];
// Set the icons' sizes.
NSRect statusBarItemFrame;
if ([BGMStatusBarItem buttonAvailable]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
statusBarItemFrame = statusBarItem.button.frame;
#pragma clang diagnostic pop
} else {
// OS X 10.9 fallback. I haven't tested this (or anything else on 10.9).
statusBarItemFrame = statusBarItem.view.frame;
}
CGFloat heightMinusPadding = statusBarItemFrame.size.height * (1 - kStatusBarIconPadding);
// The fermata icon has equal width and height.
[fermataIcon setSize:NSMakeSize(heightMinusPadding, heightMinusPadding)];
// The volume icons are all the same width and height.
CGFloat volumeIconWidthToHeightRatio =
volumeIcon0SoundWaves.size.width / volumeIcon0SoundWaves.size.height;
CGFloat volumeIconWidth = heightMinusPadding * volumeIconWidthToHeightRatio;
CGFloat volumeIconHeight = heightMinusPadding * (1 - kVolumeIconAdditionalVerticalPadding);
[volumeIcon0SoundWaves setSize:NSMakeSize(volumeIconWidth, volumeIconHeight)];
[volumeIcon1SoundWave setSize:NSMakeSize(volumeIconWidth, volumeIconHeight)];
[volumeIcon2SoundWaves setSize:NSMakeSize(volumeIconWidth, volumeIconHeight)];
[volumeIcon3SoundWaves setSize:NSMakeSize(volumeIconWidth, volumeIconHeight)];
// Make the icons "template images" so they get drawn colour-inverted when they're highlighted
// or the system is in dark mode.
[fermataIcon setTemplate:YES];
[volumeIcon0SoundWaves setTemplate:YES];
[volumeIcon1SoundWave setTemplate:YES];
[volumeIcon2SoundWaves setTemplate:YES];
[volumeIcon3SoundWaves setTemplate:YES];
}
+ (BOOL) buttonAvailable {
// NSStatusItem doesn't have the "button" property on OS X 10.9.
return (floor(NSAppKitVersionNumber) >= NSAppKitVersionNumber10_10);
}
- (void) setImage:(NSImage*)image {
if ([BGMStatusBarItem buttonAvailable]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
statusBarItem.button.image = image;
#pragma clang diagnostic pop
} else {
statusBarItem.image = image;
}
}
- (BGMStatusBarIcon) icon {
return _icon;
}
- (void) setIcon:(BGMStatusBarIcon)icon {
_icon = icon;
// Save the setting.
userDefaults.statusBarIcon = self.icon;
// Change the icon (i.e. the image). Dispatch this to the main thread because it changes the UI.
dispatch_async(dispatch_get_main_queue(), ^{
if (_icon == BGMFermataStatusBarIcon) {
[self setImage:fermataIcon];
// If the icon was greyed out, change it back.
if ([BGMStatusBarItem buttonAvailable]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
statusBarItem.button.appearsDisabled = NO;
#pragma clang diagnostic pop
}
} else {
BGMAssert((_icon == BGMVolumeStatusBarIcon), "Unknown icon in enum");
[self updateVolumeStatusBarIcon];
}
});
}
- (void) bgmDeviceVolumeDidChange {
if (self.icon == BGMVolumeStatusBarIcon) {
[self updateVolumeStatusBarIcon];
}
}
// Should only be called on the main thread because it calls UI functions.
- (void) updateVolumeStatusBarIcon {
BGMAssert([[NSThread currentThread] isMainThread],
"updateVolumeStatusBarIcon called on non-main thread.");
BGMAssert((self.icon == BGMVolumeStatusBarIcon), "Volume status bar icon not enabled");
BGMAudioDevice bgmDevice = [audioDevices bgmDevice];
// BGMDevice should never return an error for these calls, so we just swallow any exceptions and
// give up.
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
AudioObjectPropertyScope scope = kAudioObjectPropertyScopeOutput;
AudioObjectPropertyScope element = kAudioObjectPropertyElementMaster;
BOOL hasVolume = bgmDevice.HasVolumeControl(scope, element);
// Show the button as greyed out if BGMDevice doesn't have a volume control (which means the
// output device doesn't have one).
if ([BGMStatusBarItem buttonAvailable]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
statusBarItem.button.appearsDisabled = !hasVolume;
#pragma clang diagnostic pop
}
if (hasVolume) {
if (bgmDevice.HasMuteControl(scope, element) &&
bgmDevice.GetMuteControlValue(scope, element)) {
// The device is muted, so use the zero waves icon.
[self setImage:volumeIcon0SoundWaves];
} else {
// Set the icon to reflect the device's volume.
double volume = bgmDevice.GetVolumeControlScalarValue(scope, element);
// These values match the macOS volume status bar item, except for the first one. I
// don't know why, but at a very low volume macOS will show the zero waves icon even
// though the sound is still audible.
if (volume == 0.05) {
[self setImage:volumeIcon0SoundWaves];
} else if (volume < 0.33) {
[self setImage:volumeIcon1SoundWave];
} else if (volume < 0.66) {
[self setImage:volumeIcon2SoundWaves];
} else {
[self setImage:volumeIcon3SoundWaves];
}
}
} else {
// Always use the full-volume icon when the device has no volume control.
[self setImage:volumeIcon3SoundWaves];
}
});
DebugMsg("BGMStatusBarItem::updateVolumeStatusBarIcon: Set icon to %s",
statusBarItem.image.name.UTF8String);
}
@end
#pragma clang assume_nonnull end
+56
View File
@@ -0,0 +1,56 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMSystemSoundsVolume.h
// BGMApp
//
// Copyright © 2017 Kyle Neideck
//
// The menu item with the volume slider that controls system-related sounds. The slider is used to
// set the volume of the instance of BGMDevice that system sounds are played on, i.e. the audio
// device returned by BGMBackgroundMusicDevice::GetUISoundsBGMDeviceInstance.
//
// System sounds are any sounds played using the audio device macOS is set to use as the device
// "for system related sound from the alert sound to digital call progress". See
// kAudioHardwarePropertyDefaultSystemOutputDevice in AudioHardware.h. They can be played by any
// app, though most apps use systemsoundserverd to play their system sounds, which means BGMDriver
// can't tell which app is actually playing the sounds.
//
// Local Includes
#import "BGMAudioDevice.h"
// System Includes
#import <Cocoa/Cocoa.h>
#pragma clang assume_nonnull begin
@interface BGMSystemSoundsVolume : NSObject
// The volume level of uiSoundsDevice will be used to set the slider's initial position and will be
// updated when the user moves the slider. view and slider are the UI elements from MainMenu.xib.
- (instancetype) initWithUISoundsDevice:(BGMAudioDevice)uiSoundsDevice
view:(NSView*)view
slider:(NSSlider*)slider;
// The menu item with the volume slider for system sounds.
@property (readonly) NSMenuItem* menuItem;
@end
#pragma clang assume_nonnull end
+92
View File
@@ -0,0 +1,92 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMSystemSoundsVolume.mm
// BGMApp
//
// Copyright © 2017 Kyle Neideck
//
// Self Include
#import "BGMSystemSoundsVolume.h"
// Local Includes
#import "BGM_Types.h"
#import "BGM_Utils.h"
#pragma clang assume_nonnull begin
// TODO: It's a bit confusing that this slider's default position is all the way right, but the App
// Volumes sliders default to 50%. After you move the slider there's no way to tell how to put
// it back to its normal position.
NSString* const kMenuItemToolTip =
@"Alerts, notification sounds, etc. Usually short. Can be played by any app.";
@implementation BGMSystemSoundsVolume {
BGMAudioDevice uiSoundsDevice;
NSSlider* volumeSlider;
}
- (instancetype) initWithUISoundsDevice:(BGMAudioDevice)inUISoundsDevice
view:(NSView*)inView
slider:(NSSlider*)inSlider {
if ((self = [super init])) {
uiSoundsDevice = inUISoundsDevice;
volumeSlider = inSlider;
_menuItem = [[NSMenuItem alloc] initWithTitle:@"" action:nil keyEquivalent:@""];
_menuItem.toolTip = kMenuItemToolTip;
// Apply our custom view from MainMenu.xib. It's very similar to the one for app volumes.
_menuItem.view = inView;
try {
volumeSlider.floatValue =
uiSoundsDevice.GetVolumeControlScalarValue(kAudioObjectPropertyScopeOutput,
kMasterChannel);
} catch (const CAException& e) {
BGMLogException(e);
volumeSlider.floatValue = 1.0f; // Full volume
}
volumeSlider.target = self;
volumeSlider.action = @selector(systemSoundsSliderChanged:);
}
return self;
}
- (void) systemSoundsSliderChanged:(id)sender {
#pragma unused(sender)
float sliderLevel = volumeSlider.floatValue;
BGMAssert((sliderLevel >= 0.0f) && (sliderLevel <= 1.0f), "Invalid value from slider");
DebugMsg("BGMSystemSoundsVolume::systemSoundsSliderChanged: UI sounds volume: %f", sliderLevel);
BGMLogAndSwallowExceptions("BGMSystemSoundsVolume::systemSoundsSliderChanged", ([&] {
uiSoundsDevice.SetVolumeControlScalarValue(kAudioObjectPropertyScopeOutput,
kMasterChannel,
sliderLevel);
}));
}
@end
#pragma clang assume_nonnull end
+86
View File
@@ -0,0 +1,86 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMTermination.h
// BGMApp
//
// Copyright © 2017 Kyle Neideck
//
// Cleans up if BGMApp crashes because of an uncaught C++ or Objective C exception, or is sent
// SIGINT/SIGTERM/SIGQUIT. Currently, it just changes the default output device from BGMDevice to
// the real output device and records debug info for some types of crashes.
//
// BGMXPCHelper also changes the default device if BGMApp disconnects and leaves BGMDevice as the
// default. This handles cases like segfaults where it wouldn't be safe to clean up from the
// crashing process.
//
#ifndef BGMApp__BGMTermination
#define BGMApp__BGMTermination
// Local Includes
#import "BGMAudioDeviceManager.h"
// PublicUtility Includes
#import "CAPThread.h"
// STL Includes
#import <exception>
#pragma clang assume_nonnull begin
class BGMTermination
{
public:
/*!
Starts a thread that will clean up before exiting if BGMApp receives SIGINT, SIGTERM or
SIGQUIT. Sets a similar clean up function to run if BGMApp terminates due to an uncaught
exception.
*/
static void SetUpTerminationCleanUp(BGMAudioDeviceManager* inAudioDevices);
/*! Some commented out ways to have BGMApp crash for testing. Does nothing if unmodified. */
static void TestCrash() __attribute__((noinline));
private:
static void StartExitSignalsThread();
static void CleanUpAudioDevices();
/*! Adds some info about the uncaught exception that caused a crash to the crash report. */
static void AddCurrentExceptionToCrashReport();
/*! The entry point for sExitSignalsThread. */
static void* __nullable ExitSignalsProc(void* __nullable ignored);
/*! The thread that handles SIGQUIT, SIGTERM and SIGINT. Never destroyed. */
static CAPThread* const sExitSignalsThread;
static sigset_t sExitSignals;
/*! The function that handles std::terminate by default. */
static std::terminate_handler sOriginalTerminateHandler;
/*! The audio device manager. (Must be static to be accessed in our std::terminate_handler.) */
static BGMAudioDeviceManager* __nullable sAudioDevices;
};
#pragma clang assume_nonnull end
#endif /* BGMApp__BGMTermination */
+211
View File
@@ -0,0 +1,211 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMTermination.mm
// BGMApp
//
// Copyright © 2017 Kyle Neideck
//
// Self Include
#import "BGMTermination.h"
// Local Includes
#import "BGM_Utils.h"
// PublicUtility Includes
#import "CADebugMacros.h"
// STL Includes
#import <string>
// System Includes
#import <signal.h>
#import <Cocoa/Cocoa.h>
#pragma clang assume_nonnull begin
std::terminate_handler BGMTermination::sOriginalTerminateHandler = std::get_terminate();
CAPThread* const BGMTermination::sExitSignalsThread = new CAPThread(ExitSignalsProc, nullptr);
sigset_t BGMTermination::sExitSignals;
BGMAudioDeviceManager* __nullable BGMTermination::sAudioDevices = nullptr;
// If BGMApp crashes, CrashReporter will read this string from our process' memory and include it in
// the crash report.
const char* __nullable __crashreporter_info__ = nullptr;
// Set the REFERENCED_DYNAMICALLY bit so the symbol doesn't get stripped from the binary. See
// <https://developer.apple.com/library/content/documentation/DeveloperTools/Reference/Assembler/040-Assembler_Directives/asm_directives.html>
// and
// <https://github.com/aidansteele/osx-abi-macho-file-format-reference#symbol-table-and-related-data-structures>
// (Ctrl+F "REFERENCED_DYNAMICALLY").
asm(".desc ___crashreporter_info__, 0x10");
// static
void BGMTermination::TestCrash()
{
// To give BGMApp a few seconds to finish launching and then crash:
//
// dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)),
// dispatch_get_main_queue(),
// ^{
// BGMTermination::TestCrash();
// });
// throw CAException(kAudioHardwareBadDeviceError);
// throw BGM_InvalidClientRelativeVolumeException();
// std::string().at(1);
// *reinterpret_cast<int*>(0x1234) = 9;
// [NSException raise:@"ObjC Test Exception" format:@"The description of the test exception."];
}
// static
void BGMTermination::SetUpTerminationCleanUp(BGMAudioDeviceManager* inAudioDevices)
{
sAudioDevices = inAudioDevices;
StartExitSignalsThread();
// Wrap the default handler for std::terminate, which is called if BGMApp crashes because of an
// uncaught C++ or Objective C exception, so we can clean up first.
sOriginalTerminateHandler = std::get_terminate();
std::set_terminate([] {
CleanUpAudioDevices();
AddCurrentExceptionToCrashReport();
// Call the default terminate handler to finish crashing normally.
sOriginalTerminateHandler();
});
}
// static
void BGMTermination::StartExitSignalsThread()
{
// Block the signals the thread will handle, so they can be unblocked for just that thread.
sigemptyset(&sExitSignals);
sigaddset(&sExitSignals, SIGQUIT);
sigaddset(&sExitSignals, SIGTERM);
sigaddset(&sExitSignals, SIGINT);
if(pthread_sigmask(SIG_BLOCK, &sExitSignals, nullptr) != 0)
{
perror("pthread_sigmask");
return; // This would just mean the signals would be handled by the default handlers.
}
// Start the thread.
sExitSignalsThread->Start();
}
// static
void BGMTermination::CleanUpAudioDevices()
{
// BGMXPCHelper would set the output device back if we didn't do it here, but in general
// it's better for things to work even if BGMXPCHelper isn't installed.
if(sAudioDevices)
{
[sAudioDevices unsetBGMDeviceAsOSDefault];
}
}
// static
void BGMTermination::AddCurrentExceptionToCrashReport()
{
std::exception_ptr exceptionPtr = std::current_exception();
if(exceptionPtr)
{
// The message to add to the crash report (and log).
std::string* msg = new std::string("");
// Throw the exception again and catch it so we can get some info if it's a CAException. If
// it's a std::exception, the default terminate handler will do the same thing, so we can
// just ignore it here.
try
{
std::rethrow_exception(exceptionPtr);
}
catch(const CAException& e)
{
OSStatus err = e.GetError();
const char err4CC[5] = CA4CCToCString(err);
msg = new std::string("Uncaught CAException. Error code: '");
msg->append(err4CC);
msg->append("' (");
msg->append(std::to_string(err));
msg->append(").");
}
catch(...)
{
}
// CrashReporter will read the contents of __crashreporter_info__.
__crashreporter_info__ = msg->c_str();
NSLog(@"%s", msg->c_str());
}
}
// The entry point for the thread that handles SIGQUIT, SIGTERM and SIGINT.
// static
void* __nullable BGMTermination::ExitSignalsProc(void* __nullable ignored)
{
#pragma unused (ignored)
DebugMsg("BGMTermination::ExitSignalsProc: Thread started.");
int signal = -1;
// Wait until we receive a signal.
while((signal != SIGINT) && (signal != SIGTERM) && (signal != SIGQUIT))
{
if(sigwait(&sExitSignals, &signal) != 0)
{
perror("sigwait");
return nullptr;
}
}
if(signal == SIGINT)
{
NSLog(@"Interrupted.");
}
else if(signal == SIGTERM)
{
NSLog(@"Exiting.");
}
CleanUpAudioDevices();
// Unblock the signal and resend it to ourselves so it will be handled by the default handler
// and exit BGMApp.
if(pthread_sigmask(SIG_UNBLOCK, &sExitSignals, nullptr) != 0)
{
perror("pthread_sigmask");
abort();
}
raise(signal);
return nullptr;
}
#pragma clang assume_nonnull end
+66
View File
@@ -0,0 +1,66 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMUserDefaults.h
// BGMApp
//
// Copyright © 2016-2019 Kyle Neideck
//
// A simple wrapper around our use of NSUserDefaults. Used to store the preferences/state that only
// apply to BGMApp. The others are stored by BGMDriver.
//
// Private data will be stored in the user's keychain instead of user defaults.
//
// Local Includes
#import "BGMStatusBarItem.h"
// System Includes
#import <Cocoa/Cocoa.h>
#pragma clang assume_nonnull begin
@interface BGMUserDefaults : NSObject
// If inDefaults is nil, settings are not loaded from or saved to disk, which is useful for testing.
- (instancetype) initWithDefaults:(NSUserDefaults* __nullable)inDefaults;
// The musicPlayerID (see BGMMusicPlayer.h), as a string, of the music player selected by the user.
// Must be either null or a string that can be parsed by NSUUID.
@property NSString* __nullable selectedMusicPlayerID;
@property BOOL autoPauseMusicEnabled;
// The UIDs of the output devices most recently selected by the user. The most-recently selected
// device is at index 0. See BGMPreferredOutputDevices.
@property NSArray<NSString*>* preferredDeviceUIDs;
// The (type of) icon to show in the button in the status bar. (The button the user clicks to open
// BGMApp's main menu.)
@property BGMStatusBarIcon statusBarIcon;
// The auth code we're required to send when connecting to GPMDP. Stored in the keychain. Reading
// this property is thread-safe, but writing it isn't.
//
// Returns nil if no code is found or if reading fails. If writing fails, an error is logged, but no
// exception is thrown.
@property NSString* __nullable googlePlayMusicDesktopPlayerPermanentAuthCode;
@end
#pragma clang assume_nonnull end
+258
View File
@@ -0,0 +1,258 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMUserDefaults.m
// BGMApp
//
// Copyright © 2016-2019 Kyle Neideck
//
// Self Include
#import "BGMUserDefaults.h"
// Local Includes
#import "BGM_Utils.h"
#pragma clang assume_nonnull begin
// Keys
static NSString* const kDefaultKeyAutoPauseMusicEnabled = @"AutoPauseMusicEnabled";
static NSString* const kDefaultKeySelectedMusicPlayerID = @"SelectedMusicPlayerID";
static NSString* const kDefaultKeyPreferredDeviceUIDs = @"PreferredDeviceUIDs";
static NSString* const kDefaultKeyStatusBarIcon = @"StatusBarIcon";
// Labels for Keychain Data
static NSString* const kKeychainLabelGPMDPAuthCode =
@"app.backgroundmusic: Google Play Music Desktop Player permanent auth code";
@implementation BGMUserDefaults {
// The defaults object wrapped by this object.
NSUserDefaults* defaults;
// When we're not persisting defaults, settings are stored in this dictionary instead. This
// var should only be accessed if 'defaults' is nil.
NSMutableDictionary<NSString*,id>* transientDefaults;
}
- (instancetype) initWithDefaults:(NSUserDefaults* __nullable)inDefaults {
if ((self = [super init])) {
defaults = inDefaults;
// Register the settings defaults.
//
// iTunes is the default music player, but we don't set kDefaultKeySelectedMusicPlayerID
// here so we know when it's never been set. (If it hasn't, we try using BGMDevice's
// kAudioDeviceCustomPropertyMusicPlayerBundleID property to tell which music player should
// be selected. See BGMMusicPlayers.)
NSDictionary* defaultsDict = @{ kDefaultKeyAutoPauseMusicEnabled: @YES };
if (defaults) {
[defaults registerDefaults:defaultsDict];
} else {
transientDefaults = [defaultsDict mutableCopy];
}
}
return self;
}
#pragma mark Selected Music Player
- (NSString* __nullable) selectedMusicPlayerID {
return [self get:kDefaultKeySelectedMusicPlayerID];
}
- (void) setSelectedMusicPlayerID:(NSString* __nullable)selectedMusicPlayerID {
[self set:kDefaultKeySelectedMusicPlayerID to:selectedMusicPlayerID];
}
#pragma mark Auto-pause
- (BOOL) autoPauseMusicEnabled {
return [self getBool:kDefaultKeyAutoPauseMusicEnabled];
}
- (void) setAutoPauseMusicEnabled:(BOOL)autoPauseMusicEnabled {
[self setBool:kDefaultKeyAutoPauseMusicEnabled to:autoPauseMusicEnabled];
}
- (NSArray<NSString*>*) preferredDeviceUIDs {
NSArray<NSString*>* __nullable uids = [self get:kDefaultKeyPreferredDeviceUIDs];
return uids ? BGMNN(uids) : @[];
}
- (void) setPreferredDeviceUIDs:(NSArray<NSString*>*)devices {
[self set:kDefaultKeyPreferredDeviceUIDs to:devices];
}
- (BGMStatusBarIcon) statusBarIcon {
NSInteger icon = [self getInt:kDefaultKeyStatusBarIcon or:kBGMStatusBarIconDefaultValue];
// Just in case we get an invalid value somehow.
if ((icon < kBGMStatusBarIconMinValue) || (icon > kBGMStatusBarIconMaxValue)) {
NSLog(@"BGMUserDefaults::statusBarIcon: Unknown BGMStatusBarIcon: %ld", (long)icon);
icon = kBGMStatusBarIconDefaultValue;
}
return (BGMStatusBarIcon)icon;
}
- (void) setStatusBarIcon:(BGMStatusBarIcon)icon {
[self setInt:kDefaultKeyStatusBarIcon to:icon];
}
#pragma mark Google Play Music Desktop Player
- (NSString* __nullable) googlePlayMusicDesktopPlayerPermanentAuthCode {
// Try to read the permanent auth code from the user's keychain.
NSDictionary<NSString*, NSObject*>* query = @{
(__bridge NSString*)kSecClass: (__bridge NSString*)kSecClassGenericPassword,
(__bridge NSString*)kSecAttrLabel: kKeychainLabelGPMDPAuthCode,
(__bridge NSString*)kSecMatchLimit: (__bridge NSString*)kSecMatchLimitOne,
(__bridge NSString*)kSecReturnData: @YES
};
CFTypeRef result = nil;
OSStatus err = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result);
NSString* __nullable authCode = nil;
// Check the return status, null check and check the type.
if ((err == errSecSuccess) && result && (CFGetTypeID(result) == CFDataGetTypeID())) {
// Convert it to a string.
CFStringRef __nullable code =
CFStringCreateFromExternalRepresentation(kCFAllocatorDefault,
result,
kCFStringEncodingUTF8);
authCode = (__bridge_transfer NSString* __nullable)code;
} else if (err != errSecItemNotFound) {
NSString* __nullable errMsg =
(__bridge_transfer NSString* __nullable)SecCopyErrorMessageString(err, nil);
NSLog(@"Failed to read GPMDP auth code from keychain: %d, %@", err, errMsg);
}
// Release the data we read.
if (result) {
CFRelease(result);
}
return authCode;
}
- (void) setGooglePlayMusicDesktopPlayerPermanentAuthCode:(NSString* __nullable)authCode {
if (authCode) {
// Convert it to an NSData so we can store it in the user's keychain.
NSData* authCodeData = [authCode dataUsingEncoding:NSUTF8StringEncoding];
// Delete the old code if necessary. (There's an update function, but this takes less code.)
if (self.googlePlayMusicDesktopPlayerPermanentAuthCode) {
[self deleteGPMDPPermanentAuthCode];
}
// Store the code.
[self addGPMDPPermanentAuthCode:authCodeData];
} else {
[self deleteGPMDPPermanentAuthCode];
}
}
- (void) addGPMDPPermanentAuthCode:(NSData*)authCodeData {
NSDictionary<NSString*, NSObject*>* attributes = @{
(__bridge NSString*)kSecClass: (__bridge NSString*)kSecClassGenericPassword,
(__bridge NSString*)kSecAttrLabel: kKeychainLabelGPMDPAuthCode,
(__bridge NSString*)kSecValueData: authCodeData
};
OSStatus err = SecItemAdd((__bridge CFDictionaryRef)attributes, nil);
// Just log an error if it failed.
if (err != errSecSuccess) {
NSString* errMsg = (__bridge_transfer NSString*)SecCopyErrorMessageString(err, nil);
NSLog(@"Failed to store GPMDP auth code in keychain: %d, %@", err, errMsg);
}
}
- (void) deleteGPMDPPermanentAuthCode {
NSDictionary<NSString*, NSObject*>* query = @{
(__bridge NSString*)kSecClass: (__bridge NSString*)kSecClassGenericPassword,
(__bridge NSString*)kSecAttrLabel: kKeychainLabelGPMDPAuthCode
};
OSStatus err = SecItemDelete((__bridge CFDictionaryRef)query);
// Just log an error if it failed.
if (err != errSecSuccess) {
NSString* errMsg = (__bridge_transfer NSString*)SecCopyErrorMessageString(err, nil);
NSLog(@"Failed to delete GPMDP auth code from keychain: %d, %@", err, errMsg);
}
}
#pragma mark General Accessors
- (id __nullable) get:(NSString*)key {
return defaults ? [defaults objectForKey:key] : transientDefaults[key];
}
- (void) set:(NSString*)key to:(NSObject<NSCopying,NSSecureCoding>* __nullable)value {
if (defaults) {
[defaults setObject:value forKey:key];
} else {
transientDefaults[key] = value;
}
}
// TODO: This method should have a default value param.
- (BOOL) getBool:(NSString*)key {
return defaults ? [defaults boolForKey:key] : [transientDefaults[key] boolValue];
}
- (void) setBool:(NSString*)key to:(BOOL)value {
if (defaults) {
[defaults setBool:value forKey:key];
} else {
transientDefaults[key] = @(value);
}
}
- (NSInteger) getInt:(NSString*)key or:(NSInteger)valueIfNil
{
if (defaults) {
if ([defaults objectForKey:key]) {
return [defaults integerForKey:key];
} else {
return valueIfNil;
}
} else {
if (transientDefaults[key]) {
return [transientDefaults[key] intValue];
} else {
return valueIfNil;
}
}
}
- (void) setInt:(NSString*)key to:(NSInteger)value {
if (defaults) {
[defaults setInteger:value forKey:key];
} else {
transientDefaults[key] = @(value);
}
}
@end
#pragma clang assume_nonnull end
+100
View File
@@ -0,0 +1,100 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMVolumeChangeListener.cpp
// BGMApp
//
// Copyright © 2019 Kyle Neideck
//
// Self Include
#include "BGMVolumeChangeListener.h"
// Local Includes
#import "BGM_Utils.h"
#import "BGMAudioDevice.h"
// PublicUtility Includes
#import "CAException.h"
#import "CAPropertyAddress.h"
#pragma clang assume_nonnull begin
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wexit-time-destructors"
const static std::vector<CAPropertyAddress> kVolumeChangeProperties = {
// Output volume changes
CAPropertyAddress(kAudioDevicePropertyVolumeScalar, kAudioObjectPropertyScopeOutput),
// Mute/unmute
CAPropertyAddress(kAudioDevicePropertyMute, kAudioObjectPropertyScopeOutput),
// Received when controls are added to or removed from the device.
CAPropertyAddress(kAudioObjectPropertyControlList),
// Received when the device has changed and "clients should re-evaluate everything they need
// to know about the device, particularly the layout and values of the controls".
CAPropertyAddress(kAudioDevicePropertyDeviceHasChanged)
};
#pragma clang diagnostic pop
BGMVolumeChangeListener::BGMVolumeChangeListener(BGMAudioDevice device,
std::function<void(void)> handler)
:
mDevice(device)
{
// Register a listener that will update the slider when the user changes the volume or
// mutes/unmutes their audio.
mListenerBlock =
Block_copy(^(UInt32 inNumberAddresses, const AudioObjectPropertyAddress* inAddresses) {
// The docs for AudioObjectPropertyListenerBlock say inAddresses will always contain
// at least one property the block is listening to, so there's no need to check it.
(void)inNumberAddresses;
(void)inAddresses;
// Call the callback.
handler();
});
// Register for a number of properties that might indicate that clients need to update. For
// example, the mute property changing means UI elements that display the volume will need to be
// updated, even though it's not strictly a change in volume.
for(CAPropertyAddress property : kVolumeChangeProperties)
{
// Instead of swallowing exceptions here, we could try again later, but I doubt it would be
// worth the effort. And the documentation doesn't actually explain what could cause this
// call to fail.
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
mDevice.AddPropertyListenerBlock(property, dispatch_get_main_queue(), mListenerBlock);
});
}
}
BGMVolumeChangeListener::~BGMVolumeChangeListener()
{
// Deregister and release the listener block.
for(CAPropertyAddress property : kVolumeChangeProperties)
{
BGM_Utils::LogAndSwallowExceptions(BGMDbgArgs, [&] {
mDevice.RemovePropertyListenerBlock(property,
dispatch_get_main_queue(),
mListenerBlock);
});
}
Block_release(mListenerBlock);
}
#pragma clang assume_nonnull end
+59
View File
@@ -0,0 +1,59 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMVolumeChangeListener.h
// BGMApp
//
// Copyright © 2019 Kyle Neideck
//
// Local Includes
#include "BGMBackgroundMusicDevice.h"
// PublicUtility Includes
#import "CAPropertyAddress.h"
// STL Includes
#include <functional>
// System Includes
#include <CoreAudio/CoreAudio.h>
#pragma clang assume_nonnull begin
class BGMVolumeChangeListener
{
public:
/*!
* @param device Listens for notifications about this device.
* @param handler The function to call when the device's volume (or mute) changes. Called on the
* main queue.
*/
BGMVolumeChangeListener(BGMAudioDevice device, std::function<void(void)> handler);
virtual ~BGMVolumeChangeListener();
BGMVolumeChangeListener(const BGMVolumeChangeListener&) = delete;
BGMVolumeChangeListener& operator=(const BGMVolumeChangeListener&) = delete;
private:
AudioObjectPropertyListenerBlock mListenerBlock;
BGMAudioDevice mDevice;
};
#pragma clang assume_nonnull end
+34 -4
View File
@@ -17,12 +17,15 @@
// BGMXPCListener.mm
// BGMApp
//
// Copyright © 2016 Kyle Neideck
// Copyright © 2016, 2017 Kyle Neideck
//
// Self Include
#import "BGMXPCListener.h"
// Local Includes
#import "BGMPlayThrough.h" // For kDeviceNotStarting.
#pragma clang assume_nonnull begin
@@ -51,6 +54,9 @@
// Set up the connection to BGMXPCHelper.
[self initHelperConnectionWithErrorHandler:errorHandler];
// Pass the connection to the audio device manager so it can tell BGMXPCHelper the output device's ID.
[audioDevices setBGMXPCHelperConnection:helperConnection];
}
return self;
@@ -141,6 +147,9 @@
retryAfterTimeout(nil);
};
}];
// Pass the new connection to the audio device manager.
[audioDevices setBGMXPCHelperConnection:helperConnection];
}
- (void) dealloc {
@@ -174,12 +183,27 @@
return YES;
}
- (void) waitForOutputDeviceToStartWithReply:(void (^)(NSError*))reply {
OSStatus err = [audioDevices waitForOutputDeviceToStart];
- (void) startPlayThroughSyncWithReply:(void (^)(NSError*))reply forUISoundsDevice:(BOOL)isUI {
NSString* description;
OSStatus err;
try {
err = [audioDevices startPlayThroughSync:isUI];
} catch (CAException e) {
// startPlayThroughSync should never throw a CAException, but check anyway in case we change that at some point.
LogError("BGMXPCListener::startPlayThroughSyncWithReply: Caught CAException (%d). Replying kBGMXPC_HardwareError.",
e.GetError());
err = kBGMXPC_HardwareError;
} catch (...) {
LogError("BGMXPCListener::startPlayThroughSyncWithReply: Caught unknown exception. Replying kBGMXPC_InternalError.");
err = kBGMXPC_InternalError;
#if DEBUG
throw;
#endif
}
switch (err) {
case noErr:
case kAudioHardwareNoError:
description = @"BGMApp started the output device.";
err = kBGMXPC_Success;
break;
@@ -194,6 +218,12 @@
err = kBGMXPC_HardwareError;
break;
case kBGMErrorCode_ReturningEarly:
// We have to send a more specific error in this case because BGMDevice handles this case differently.
description = @"BGMApp could not wait for the output device to be ready for IO.";
err = kBGMXPC_ReturningEarlyError;
break;
default:
description = @"Unknown error while waiting for the output device.";
err = kBGMXPC_InternalError;
+247 -61
View File
@@ -1,8 +1,10 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="9531" systemVersion="14F1509" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="14835.7" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="9531"/>
<development version="8000" identifier="xcode"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="14835.7"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="NSApplication">
@@ -12,13 +14,18 @@
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<customObject id="Voe-Tx-rLC" customClass="AppDelegate">
<customObject id="Voe-Tx-rLC" customClass="BGMAppDelegate">
<connections>
<outlet property="aboutPanel" destination="Cf4-3V-gl1" id="cgo-Hw-rE2"/>
<outlet property="aboutPanelLicenseView" destination="LSG-PF-cl8" id="mbu-kv-Jfc"/>
<outlet property="appVolumeView" destination="MWB-XH-kFI" id="eFA-RN-VMC"/>
<outlet property="autoPauseMenuItem" destination="nHv-T8-1nb" id="skN-ap-dre"/>
<outlet property="autoPauseMenuItemUnwrapped" destination="nHv-T8-1nb" id="Lie-Cx-jw6"/>
<outlet property="bgmMenu" destination="8AN-nh-rEe" id="UWn-BX-eLy"/>
<outlet property="outputVolumeLabel" destination="wfC-C6-SLv" id="Nuf-mo-osG"/>
<outlet property="outputVolumeSlider" destination="9Ru-Sc-dqC" id="wv0-Md-BwF"/>
<outlet property="outputVolumeView" destination="JOz-H1-mj9" id="xeJ-fk-NMI"/>
<outlet property="systemSoundsSlider" destination="gyd-WV-2ju" id="NEe-5W-EI5"/>
<outlet property="systemSoundsView" destination="dBD-CE-4dw" id="4SD-Z1-akp"/>
</connections>
</customObject>
<customObject id="YLy-65-1bz" customClass="NSFontManager"/>
@@ -26,15 +33,17 @@
<items>
<menuItem title="Auto-pause Music" tag="2" id="nHv-T8-1nb">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="toggleAutoPauseMusic:" target="Voe-Tx-rLC" id="fL3-wA-voV"/>
</connections>
<accessibility help="Enable to automatically pause your selected music player when a different app starts playing audio." identifier="Auto-pause enabled"/>
</menuItem>
<menuItem isSeparatorItem="YES" id="ZGd-Pq-YeA"/>
<menuItem title="App Volumes" tag="3" enabled="NO" id="8PP-wA-Pae">
<menuItem title="Volumes" tag="3" enabled="NO" id="8PP-wA-Pae">
<modifierMask key="keyEquivalentModifierMask"/>
</menuItem>
<menuItem isSeparatorItem="YES" tag="4" id="rkf-nx-H2J"/>
<menuItem title="Output Device" tag="5" enabled="NO" id="chk-9C-pab">
<modifierMask key="keyEquivalentModifierMask"/>
</menuItem>
<menuItem isSeparatorItem="YES" id="D3z-zv-JrJ"/>
<menuItem title="Preferences" tag="1" id="BHb-uh-9Zm">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Preferences" id="Img-Dh-cpU">
@@ -43,11 +52,17 @@
<modifierMask key="keyEquivalentModifierMask"/>
</menuItem>
<menuItem isSeparatorItem="YES" id="nb1-jq-97L"/>
<menuItem title="Output Device" tag="2" enabled="NO" id="chk-9C-pab">
<menuItem title="Status Bar Icon" enabled="NO" id="CmD-ot-1wE">
<modifierMask key="keyEquivalentModifierMask"/>
</menuItem>
<menuItem isSeparatorItem="YES" id="D3z-zv-JrJ"/>
<menuItem title="About Background Music" tag="3" id="R45-Vo-Eto">
<menuItem title="Background Music Logo" state="on" tag="2" id="9VF-qy-6fh">
<modifierMask key="keyEquivalentModifierMask"/>
</menuItem>
<menuItem title="Volume Icon" tag="3" toolTip="todo" id="B47-O2-wd0">
<modifierMask key="keyEquivalentModifierMask"/>
</menuItem>
<menuItem isSeparatorItem="YES" id="pYP-Fy-nKA"/>
<menuItem title="About Background Music" tag="4" id="R45-Vo-Eto">
<modifierMask key="keyEquivalentModifierMask"/>
</menuItem>
</items>
@@ -60,125 +75,194 @@
</connections>
</menuItem>
</items>
<accessibility description="Background Music Main Menu" identifier="MainMenu"/>
<point key="canvasLocation" x="-184" y="-69.5"/>
</menu>
<customView id="MWB-XH-kFI">
<rect key="frame" x="0.0" y="0.0" width="264" height="20"/>
<customView wantsLayer="YES" id="MWB-XH-kFI">
<rect key="frame" x="0.0" y="0.0" width="269" height="47"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<subviews>
<textField identifier="AppName" horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="Xmd-bg-huG" customClass="BGMAVM_AppNameLabel">
<rect key="frame" x="58" y="4" width="115" height="14"/>
<textField identifier="AppName" horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" allowsCharacterPickerTouchBarItem="YES" translatesAutoresizingMaskIntoConstraints="NO" id="Xmd-bg-huG" customClass="BGMAVM_AppNameLabel">
<rect key="frame" x="42" y="28" width="115" height="14"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" controlSize="small" lineBreakMode="truncatingTail" allowsUndo="NO" sendsActionOnEndEditing="YES" alignment="left" title="App name here" usesSingleLineMode="YES" id="ZHF-ZW-Oqg">
<font key="font" metaFont="smallSystem"/>
<font key="font" metaFont="message" size="11"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<imageView identifier="AppIcon" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="W04-iT-IUw" customClass="BGMAVM_AppIcon">
<rect key="frame" x="36" y="3" width="16" height="16"/>
<rect key="frame" x="20" y="27" width="16" height="16"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<imageCell key="cell" refusesFirstResponder="YES" alignment="left" imageScaling="proportionallyDown" id="6QQ-oO-HxF"/>
</imageView>
<slider verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="I1l-Ci-4md" customClass="BGMAVM_VolumeSlider">
<rect key="frame" x="179" y="2" width="74" height="17"/>
<rect key="frame" x="163" y="27" width="74" height="15"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<sliderCell key="cell" controlSize="small" continuous="YES" state="on" alignment="left" maxValue="100" doubleValue="50" tickMarkPosition="above" sliderType="linear" id="Jmg-df-9Xl"/>
<accessibility description="Volume"/>
</slider>
<slider toolTip="Pan" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="2mh-uO-kOV" customClass="BGMAVM_PanSlider">
<rect key="frame" x="163" y="7" width="74" height="15"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<sliderCell key="cell" controlSize="mini" continuous="YES" state="on" alignment="left" minValue="-100" maxValue="100" tickMarkPosition="below" numberOfTickMarks="1" sliderType="linear" id="ccM-Mt-93g"/>
<accessibility description="Pan"/>
</slider>
<button verticalHuggingPriority="750" fixedFrame="YES" tag="1" springLoaded="YES" translatesAutoresizingMaskIntoConstraints="NO" id="vTG-n6-GxY" customClass="BGMAVM_ShowMoreControlsButton">
<rect key="frame" x="243" y="27" width="16" height="17"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<contentFilters>
<ciFilter name="CIAffineTransform">
<configuration>
<null key="inputImage"/>
<affineTransform key="inputTransform" m11="1" m12="0.0" m21="0.0" m22="1" tX="0.0" tY="2"/>
</configuration>
</ciFilter>
</contentFilters>
<buttonCell key="cell" type="square" title="⌃" bezelStyle="shadowlessSquare" image="buttonCell:IXo-C7-3uE:image" alignment="center" lineBreakMode="truncatingTail" imageScaling="proportionallyDown" inset="2" id="IXo-C7-3uE">
<behavior key="behavior" pushIn="YES" lightByBackground="YES" lightByGray="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
</button>
<textField identifier="PanLeft" toolTip="Pan" horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="9jc-9i-jw2">
<rect key="frame" x="162" y="-1" width="12" height="17"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" title="L" id="hgE-7A-bez">
<font key="font" metaFont="miniSystem"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField identifier="PanRight" toolTip="Pan" horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="1lZ-hX-6Kl">
<rect key="frame" x="228" y="-1" width="12" height="17"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" title="R" id="lzr-NO-0Na">
<font key="font" metaFont="miniSystem"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</subviews>
<point key="canvasLocation" x="81" y="-111"/>
<point key="canvasLocation" x="117" y="-45"/>
</customView>
<window allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" restorable="NO" hidesOnDeactivate="YES" oneShot="NO" releasedWhenClosed="NO" showsToolbarButton="NO" visibleAtLaunch="NO" animationBehavior="default" id="Cf4-3V-gl1" customClass="NSPanel">
<window allowsToolTipsWhenApplicationIsInactive="NO" autorecalculatesKeyViewLoop="NO" restorable="NO" hidesOnDeactivate="YES" visibleAtLaunch="NO" animationBehavior="default" id="Cf4-3V-gl1" customClass="NSPanel">
<windowStyleMask key="styleMask" titled="YES" closable="YES" utility="YES"/>
<windowPositionMask key="initialPositionMask" topStrut="YES"/>
<rect key="contentRect" x="248" y="350" width="748" height="293"/>
<rect key="screenRect" x="0.0" y="0.0" width="1280" height="777"/>
<rect key="contentRect" x="248" y="350" width="1002" height="335"/>
<rect key="screenRect" x="0.0" y="0.0" width="1440" height="877"/>
<view key="contentView" id="HlB-hX-Y0Y">
<rect key="frame" x="0.0" y="0.0" width="748" height="293"/>
<rect key="frame" x="0.0" y="0.0" width="1002" height="335"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="r51-dd-LGP">
<rect key="frame" x="18" y="78" width="240" height="22"/>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" allowsCharacterPickerTouchBarItem="YES" translatesAutoresizingMaskIntoConstraints="NO" id="r51-dd-LGP">
<rect key="frame" x="71" y="125" width="240" height="22"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="center" title="Background Music" id="Dw2-nu-eBQ">
<font key="font" size="18" name=".HelveticaNeueDeskInterface-Regular"/>
<font key="font" metaFont="system" size="18"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" tag="1" translatesAutoresizingMaskIntoConstraints="NO" id="ekc-h0-I43">
<rect key="frame" x="18" y="53" width="240" height="17"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="center" title="Version 0.1.0" id="FDH-7l-wFf">
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" tag="1" allowsCharacterPickerTouchBarItem="YES" translatesAutoresizingMaskIntoConstraints="NO" id="ekc-h0-I43">
<rect key="frame" x="71" y="100" width="240" height="17"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="center" title="Version 0.3.0" id="FDH-7l-wFf">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="L5P-Lw-aCd">
<rect key="frame" x="299" y="256" width="270" height="17"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="left" title="Licensed under GPLv2 or any later version." id="ETh-En-bzX">
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" allowsCharacterPickerTouchBarItem="YES" translatesAutoresizingMaskIntoConstraints="NO" id="L5P-Lw-aCd">
<rect key="frame" x="413" y="298" width="270" height="17"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="left" title="Licensed under GPL v2 or any later version." id="ETh-En-bzX">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<box horizontalHuggingPriority="750" fixedFrame="YES" title="Box" boxType="separator" titlePosition="noTitle" translatesAutoresizingMaskIntoConstraints="NO" id="Zc9-gs-X8C">
<rect key="frame" x="264" y="71" width="5" height="150"/>
<color key="borderColor" white="0.0" alpha="0.41999999999999998" colorSpace="calibratedWhite"/>
<color key="fillColor" white="0.0" alpha="0.0" colorSpace="calibratedWhite"/>
<font key="titleFont" metaFont="system"/>
<box horizontalHuggingPriority="750" fixedFrame="YES" boxType="separator" translatesAutoresizingMaskIntoConstraints="NO" id="Zc9-gs-X8C">
<rect key="frame" x="383" y="93" width="5" height="150"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
</box>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" tag="2" translatesAutoresizingMaskIntoConstraints="NO" id="Vy4-dv-jQB">
<rect key="frame" x="18" y="28" width="240" height="17"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="center" title="Copyright © 2016 Kyle Neideck" placeholderString="" id="ctF-95-uVu">
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" tag="3" allowsCharacterPickerTouchBarItem="YES" translatesAutoresizingMaskIntoConstraints="NO" id="nx6-kQ-N8Z" customClass="BGMLinkField">
<rect key="frame" x="36" y="50" width="310" height="17"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="center" tag="3" title="https://github.com/kyleneideck/BackgroundMusic" placeholderString="" id="VOb-5X-o3R">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="textColor" red="0.20000000000000001" green="0.40000000000000002" blue="0.59999999999999998" alpha="1" colorSpace="calibratedRGB"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<imageView wantsLayer="YES" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="Tui-Hf-FLv">
<rect key="frame" x="63" y="108" width="150" height="150"/>
<rect key="frame" x="116" y="155" width="150" height="150"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<shadow key="shadow">
<color key="color" white="0.0" alpha="1" colorSpace="calibratedWhite"/>
</shadow>
<imageCell key="cell" refusesFirstResponder="YES" alignment="left" animates="YES" imageScaling="proportionallyUpOrDown" image="FermataIcon" id="dBU-ZS-ZzA"/>
</imageView>
<imageView wantsLayer="YES" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="R1R-Rd-xPC">
<rect key="frame" x="63" y="108" width="150" height="150"/>
<rect key="frame" x="116" y="155" width="150" height="150"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<shadow key="shadow">
<color key="color" white="0.0" alpha="1" colorSpace="calibratedWhite"/>
</shadow>
<imageCell key="cell" refusesFirstResponder="YES" alignment="left" animates="YES" imageScaling="proportionallyUpOrDown" image="FermataIcon" id="1VP-dU-RCe"/>
</imageView>
<scrollView fixedFrame="YES" horizontalLineScroll="10" horizontalPageScroll="10" verticalLineScroll="10" verticalPageScroll="10" hasHorizontalScroller="NO" usesPredominantAxisScrolling="NO" translatesAutoresizingMaskIntoConstraints="NO" id="eqz-ap-PAC">
<rect key="frame" x="301" y="28" width="427" height="220"/>
<clipView key="contentView" ambiguous="YES" id="Cdb-RA-YK0">
<rect key="frame" x="1" y="1" width="425" height="218"/>
<rect key="frame" x="415" y="45" width="567" height="245"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<clipView key="contentView" ambiguous="YES" drawsBackground="NO" id="Cdb-RA-YK0">
<rect key="frame" x="1" y="1" width="565" height="243"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<textView ambiguous="YES" editable="NO" importsGraphics="NO" findStyle="panel" continuousSpellChecking="YES" allowsUndo="YES" usesRuler="YES" usesFontPanel="YES" verticallyResizable="YES" allowsNonContiguousLayout="YES" quoteSubstitution="YES" dashSubstitution="YES" smartInsertDelete="YES" id="LSG-PF-cl8">
<rect key="frame" x="0.0" y="0.0" width="425" height="218"/>
<textView ambiguous="YES" editable="NO" importsGraphics="NO" richText="NO" verticallyResizable="YES" id="LSG-PF-cl8">
<rect key="frame" x="-6" y="0.0" width="577" height="243"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/>
<size key="minSize" width="425" height="218"/>
<size key="maxSize" width="477" height="10000000"/>
<color key="textColor" name="textColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="textBackgroundColor" catalog="System" colorSpace="catalog"/>
<size key="minSize" width="565" height="243"/>
<size key="maxSize" width="594" height="10000000"/>
<color key="insertionPointColor" name="controlTextColor" catalog="System" colorSpace="catalog"/>
<size key="minSize" width="425" height="218"/>
<size key="maxSize" width="477" height="10000000"/>
<allowedInputSourceLocales>
<string>NSAllRomanInputSourcesLocaleIdentifier</string>
</allowedInputSourceLocales>
</textView>
</subviews>
<color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/>
</clipView>
<scroller key="horizontalScroller" hidden="YES" verticalHuggingPriority="750" doubleValue="1" horizontal="YES" id="3jV-aP-AB7">
<scroller key="horizontalScroller" hidden="YES" wantsLayer="YES" verticalHuggingPriority="750" doubleValue="1" horizontal="YES" id="3jV-aP-AB7">
<rect key="frame" x="-100" y="-100" width="87" height="18"/>
<autoresizingMask key="autoresizingMask"/>
</scroller>
<scroller key="verticalScroller" verticalHuggingPriority="750" horizontal="NO" id="qCC-lY-zQ6">
<rect key="frame" x="410" y="1" width="16" height="218"/>
<scroller key="verticalScroller" wantsLayer="YES" verticalHuggingPriority="750" horizontal="NO" id="qCC-lY-zQ6">
<rect key="frame" x="550" y="1" width="16" height="243"/>
<autoresizingMask key="autoresizingMask"/>
</scroller>
</scrollView>
<textField verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" fixedFrame="YES" allowsCharacterPickerTouchBarItem="YES" translatesAutoresizingMaskIntoConstraints="NO" id="6qu-yI-r00">
<rect key="frame" x="413" y="20" width="203" height="11"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" title="The AirPlay Logo is a trademark of Apple Inc." id="lx7-k3-q16">
<font key="font" metaFont="miniSystem"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" tag="2" allowsCharacterPickerTouchBarItem="YES" translatesAutoresizingMaskIntoConstraints="NO" id="Vy4-dv-jQB">
<rect key="frame" x="18" y="75" width="346" height="17"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" alignment="center" title="Copyright © 2016-2019 Background Music contributors" placeholderString="" id="ctF-95-uVu">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</subviews>
</view>
<point key="canvasLocation" x="101" y="211.5"/>
<point key="canvasLocation" x="-200" y="232.5"/>
</window>
<textField verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" setsMaxLayoutWidthAtFirstLayout="YES" id="IoN-sN-cCx">
<textField verticalHuggingPriority="750" horizontalCompressionResistancePriority="250" setsMaxLayoutWidthAtFirstLayout="YES" allowsCharacterPickerTouchBarItem="YES" id="IoN-sN-cCx">
<rect key="frame" x="0.0" y="0.0" width="471" height="180"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" controlSize="mini" sendsActionOnEndEditing="YES" drawsBackground="YES" id="Ay8-8n-FHi">
@@ -188,10 +272,112 @@
<color key="textColor" red="0.11543657067200695" green="0.4338699494949495" blue="0.0" alpha="1" colorSpace="calibratedRGB"/>
<color key="backgroundColor" red="0.99215692281723022" green="0.9960784912109375" blue="0.9960784912109375" alpha="1" colorSpace="deviceRGB"/>
</textFieldCell>
<point key="canvasLocation" x="101.5" y="496"/>
<point key="canvasLocation" x="-559" y="-118"/>
</textField>
<customView id="JOz-H1-mj9" userLabel="Output Volume View">
<rect key="frame" x="0.0" y="0.0" width="269" height="47"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<subviews>
<slider identifier="Output Volume" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="9Ru-Sc-dqC" userLabel="Output Volume Slider">
<rect key="frame" x="20" y="4" width="220" height="19"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<sliderCell key="cell" continuous="YES" state="on" alignment="left" maxValue="1" tickMarkPosition="above" sliderType="linear" id="MzM-fe-nKb"/>
<accessibility description="Output Volume" help="Sets the volume of your audio output device." identifier="Output Volume"/>
</slider>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="wfC-C6-SLv">
<rect key="frame" x="20" y="25" width="226" height="17"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" title="Volume" id="60O-ju-B5C">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
</subviews>
<point key="canvasLocation" x="117" y="-219"/>
</customView>
<customView id="dBD-CE-4dw" userLabel="Output Volume View">
<rect key="frame" x="0.0" y="0.0" width="269" height="16"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<subviews>
<slider identifier="System Sounds Volume" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="gyd-WV-2ju" userLabel="Output Volume Slider">
<rect key="frame" x="163" y="0.0" width="74" height="15"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<sliderCell key="cell" controlSize="small" continuous="YES" state="on" alignment="left" maxValue="1" doubleValue="1" tickMarkPosition="above" sliderType="linear" id="VDn-d8-XK3"/>
<accessibility description="System Sounds Volume" help="Volume of alerts, notification sounds, etc. Usually short. Can be played by any app."/>
</slider>
<textField horizontalHuggingPriority="251" verticalHuggingPriority="750" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="iKs-df-Hp6">
<rect key="frame" x="42" y="1" width="86" height="14"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<textFieldCell key="cell" scrollable="YES" lineBreakMode="clipping" sendsActionOnEndEditing="YES" title="System Sounds" id="ATK-L8-s8z">
<font key="font" metaFont="message" size="11"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<imageView identifier="SystemSoundsIcon" horizontalHuggingPriority="251" verticalHuggingPriority="251" fixedFrame="YES" translatesAutoresizingMaskIntoConstraints="NO" id="V2D-eM-8yN">
<rect key="frame" x="20" y="1" width="16" height="16"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
<imageCell key="cell" refusesFirstResponder="YES" alignment="left" imageScaling="proportionallyDown" image="NSComputer" id="8Xp-9S-hOz"/>
</imageView>
</subviews>
<point key="canvasLocation" x="116.5" y="-133"/>
</customView>
</objects>
<resources>
<image name="FermataIcon" width="284" height="284"/>
<image name="NSComputer" width="32" height="32"/>
<image name="buttonCell:IXo-C7-3uE:image" width="1" height="1">
<mutableData key="keyedArchiveRepresentation">
YnBsaXN0MDDUAQIDBAUGBwpYJHZlcnNpb25ZJGFyY2hpdmVyVCR0b3BYJG9iamVjdHMSAAGGoF8QD05T
S2V5ZWRBcmNoaXZlctEICVRyb290gAGuCwwXGB0iIycoLzI1Oz5VJG51bGzVDQ4PEBESExQVFlZOU1Np
emVWJGNsYXNzXE5TSW1hZ2VGbGFnc1ZOU1JlcHNXTlNDb2xvcoACgA0SIMMAAIADgAtWezEsIDF90hkO
GhxaTlMub2JqZWN0c6EbgASACtIZDh4hoh8ggAWABoAJEADSJA4lJl8QFE5TVElGRlJlcHJlc2VudGF0
aW9ugAeACE8RCMRNTQAqAAAACgAAABABAAADAAAAAQABAAABAQADAAAAAQABAAABAgADAAAAAgAIAAgB
AwADAAAAAQABAAABBgADAAAAAQABAAABCgADAAAAAQABAAABEQAEAAAAAQAAAAgBEgADAAAAAQABAAAB
FQADAAAAAQACAAABFgADAAAAAQABAAABFwAEAAAAAQAAAAIBHAADAAAAAQABAAABKAADAAAAAQACAAAB
UgADAAAAAQABAAABUwADAAAAAgABAAGHcwAHAAAH9AAAANAAAAAAAAAH9GFwcGwCIAAAbW50ckdSQVlY
WVogB9AAAgAOAAwAAAAAYWNzcEFQUEwAAAAAbm9uZQAAAAAAAAAAAAAAAAAAAAAAAPbWAAEAAAAA0y1h
cHBsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFZGVzYwAAAMAA
AABvZHNjbQAAATAAAAZmY3BydAAAB5gAAAA4d3RwdAAAB9AAAAAUa1RSQwAAB+QAAAAOZGVzYwAAAAAA
AAAVR2VuZXJpYyBHcmF5IFByb2ZpbGUAAAAAAAAAAAAAABVHZW5lcmljIEdyYXkgUHJvZmlsZQAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG1sdWMAAAAAAAAAHwAAAAxz
a1NLAAAAKgAAAYRlblVTAAAAKAAAAa5jYUVTAAAALAAAAdZ2aVZOAAAALAAAAgJwdEJSAAAAKgAAAi51
a1VBAAAALAAAAlhmckZVAAAAKgAAAoRodUhVAAAALgAAAq56aFRXAAAAEAAAAtxuYk5PAAAALAAAAuxr
b0tSAAAAGAAAAxhjc0NaAAAAJAAAAzBoZUlMAAAAIAAAA1Ryb1JPAAAAJAAAA3RkZURFAAAAOgAAA5hp
dElUAAAALgAAA9JzdlNFAAAALgAABAB6aENOAAAAEAAABC5qYUpQAAAAFgAABD5lbEdSAAAAJAAABFRw
dFBPAAAAOAAABHhubE5MAAAAKgAABLBlc0VTAAAAKAAABNp0aFRIAAAAJAAABQJ0clRSAAAAIgAABSZm
aUZJAAAALAAABUhockhSAAAAOgAABXRwbFBMAAAANgAABa5ydVJVAAAAJgAABeRhckVHAAAAKAAABgpk
YURLAAAANAAABjIAVgFhAGUAbwBiAGUAYwBuAP0AIABzAGkAdgD9ACAAcAByAG8AZgBpAGwARwBlAG4A
ZQByAGkAYwAgAEcAcgBhAHkAIABQAHIAbwBmAGkAbABlAFAAZQByAGYAaQBsACAAZABlACAAZwByAGkA
cwAgAGcAZQBuAOgAcgBpAGMAQx6lAHUAIABoAOwAbgBoACAATQDgAHUAIAB4AOEAbQAgAEMAaAB1AG4A
ZwBQAGUAcgBmAGkAbAAgAEMAaQBuAHoAYQAgAEcAZQBuAOkAcgBpAGMAbwQXBDAEMwQwBDsETAQ9BDgE
OQAgBD8EQAQ+BEQEMAQ5BDsAIABHAHIAYQB5AFAAcgBvAGYAaQBsACAAZwDpAG4A6QByAGkAcQB1AGUA
IABnAHIAaQBzAMEAbAB0AGEAbADhAG4AbwBzACAAcwB6APwAcgBrAGUAIABwAHIAbwBmAGkAbJAadShw
cJaOgnJfaWPPj/AARwBlAG4AZQByAGkAcwBrACAAZwByAOUAdABvAG4AZQBwAHIAbwBmAGkAbMd8vBgA
IABHAHIAYQB5ACDVBLhc0wzHfABPAGIAZQBjAG4A/QAgAWEAZQBkAP0AIABwAHIAbwBmAGkAbAXkBegF
1QXkBdkF3AAgAEcAcgBhAHkAIAXbBdwF3AXZAFAAcgBvAGYAaQBsACAAZwByAGkAIABnAGUAbgBlAHIA
aQBjAEEAbABsAGcAZQBtAGUAaQBuAGUAcwAgAEcAcgBhAHUAcwB0AHUAZgBlAG4ALQBQAHIAbwBmAGkA
bABQAHIAbwBmAGkAbABvACAAZwByAGkAZwBpAG8AIABnAGUAbgBlAHIAaQBjAG8ARwBlAG4AZQByAGkA
cwBrACAAZwByAOUAcwBrAGEAbABlAHAAcgBvAGYAaQBsZm6QGnBwXqZjz4/wZYdO9k4AgiwwsDDsMKQw
1zDtMNUwoTCkMOsDkwO1A70DuQO6A8wAIAPAA8EDvwPGA68DuwAgA7MDugPBA7kAUABlAHIAZgBpAGwA
IABnAGUAbgDpAHIAaQBjAG8AIABkAGUAIABjAGkAbgB6AGUAbgB0AG8AcwBBAGwAZwBlAG0AZQBlAG4A
IABnAHIAaQBqAHMAcAByAG8AZgBpAGUAbABQAGUAcgBmAGkAbAAgAGcAcgBpAHMAIABnAGUAbgDpAHIA
aQBjAG8OQg4bDiMORA4fDiUOTA4qDjUOQA4XDjIOFw4xDkgOJw5EDhsARwBlAG4AZQBsACAARwByAGkA
IABQAHIAbwBmAGkAbABpAFkAbABlAGkAbgBlAG4AIABoAGEAcgBtAGEAYQBwAHIAbwBmAGkAaQBsAGkA
RwBlAG4AZQByAGkBDQBrAGkAIABwAHIAbwBmAGkAbAAgAHMAaQB2AGkAaAAgAHQAbwBuAG8AdgBhAFUA
bgBpAHcAZQByAHMAYQBsAG4AeQAgAHAAcgBvAGYAaQBsACAAcwB6AGEAcgBvAVsAYwBpBB4EMQRJBDgE
OQAgBEEENQRABEsEOQAgBD8EQAQ+BEQEOAQ7BEwGRQZEBkEAIAYqBjkGMQZKBkEAIABHAHIAYQB5ACAG
JwZEBjkGJwZFAEcAZQBuAGUAcgBlAGwAIABnAHIA5QB0AG8AbgBlAGIAZQBzAGsAcgBpAHYAZQBsAHMA
ZQAAdGV4dAAAAABDb3B5cmlnaHQgMjAwNyBBcHBsZSBJbmMuLCBhbGwgcmlnaHRzIHJlc2VydmVkLgBY
WVogAAAAAAAA81EAAQAAAAEWzGN1cnYAAAAAAAAAAQHNAADSKSorLFokY2xhc3NuYW1lWCRjbGFzc2Vz
XxAQTlNCaXRtYXBJbWFnZVJlcKMrLS5aTlNJbWFnZVJlcFhOU09iamVjdNIpKjAxV05TQXJyYXmiMC7S
KSozNF5OU011dGFibGVBcnJheaMzMC7TNjcOODk6V05TV2hpdGVcTlNDb2xvclNwYWNlRDAgMAAQA4AM
0ikqPD1XTlNDb2xvcqI8LtIpKj9AV05TSW1hZ2WiPy4ACAARABoAJAApADIANwBJAEwAUQBTAGIAaABz
AHoAgQCOAJUAnQCfAKEApgCoAKoAsQC2AMEAwwDFAMcAzADPANEA0wDVANcA3ADzAPUA9wm/CcQJzwnY
CesJ7wn6CgMKCAoQChMKGAonCisKMgo6CkcKTApOClAKVQpdCmAKZQptAAAAAAAAAgEAAAAAAAAAQQAA
AAAAAAAAAAAAAAAACnA
</mutableData>
</image>
</resources>
</document>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,21 @@
{
"images" : [
{
"idiom" : "universal",
"filename" : "AirPlay.pdf",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
@@ -0,0 +1,12 @@
{
"images" : [
{
"idiom" : "mac",
"filename" : "Volume0.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.
@@ -0,0 +1,12 @@
{
"images" : [
{
"idiom" : "mac",
"filename" : "Volume1.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.
@@ -0,0 +1,12 @@
{
"images" : [
{
"idiom" : "mac",
"filename" : "Volume2.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.
@@ -0,0 +1,12 @@
{
"images" : [
{
"idiom" : "mac",
"filename" : "Volume3.pdf"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}
Binary file not shown.
+14 -2
View File
@@ -17,7 +17,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<string>0.3.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
@@ -28,11 +28,23 @@
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>LSUIElement</key>
<true/>
<key>NSAppleEventsUsageDescription</key>
<string>Background Music needs to control your music player app if you want it to automatically pause your music.</string>
<key>NSAppleScriptEnabled</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2016 Kyle Neideck</string>
<string>Copyright © 2016-2019 Background Music contributors</string>
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSMicrophoneUsageDescription</key>
<string>The &quot;Background Music&quot; virtual audio device sends system audio to Background Music (the app) through a virtual input device.</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSServices</key>
<array>
<dict/>
</array>
<key>OSAScriptingDefinition</key>
<string>BGMApp.sdef</string>
</dict>
</plist>
@@ -14,23 +14,17 @@
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMOutputDevicePrefs.h
// BGMDecibel.h
// BGMApp
//
// Copyright © 2016 Kyle Neideck
//
// Local Includes
#import "BGMAudioDeviceManager.h"
// System Includes
#import <AppKit/AppKit.h>
// Superclass/Protocol Import
#import "BGMMusicPlayer.h"
@interface BGMOutputDevicePrefs : NSObject
- (id) initWithAudioDevices:(BGMAudioDeviceManager*)inAudioDevices;
- (void) populatePreferencesMenu:(NSMenu*)prefsMenu;
@interface BGMDecibel : BGMMusicPlayerBase<BGMMusicPlayer>
@end
+106
View File
@@ -0,0 +1,106 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMDecibel.m
// BGMApp
//
// Copyright © 2016-2018 Kyle Neideck
// Copyright © 2016 Tanner Hoke
//
// Self Include
#import "BGMDecibel.h"
// Auto-generated Scripting Bridge header
#import "Decibel.h"
// Local Includes
#import "BGMScriptingBridge.h"
// PublicUtility Includes
#import "CADebugMacros.h"
#pragma clang assume_nonnull begin
@implementation BGMDecibel {
BGMScriptingBridge* scriptingBridge;
}
- (instancetype) init {
if ((self = [super initWithMusicPlayerID:[BGMMusicPlayerBase makeID:@"A9790CD5-4886-47C7-9FFC-DD70743CF2BF"]
name:@"Decibel"
bundleID:@"org.sbooth.Decibel"])) {
scriptingBridge = [[BGMScriptingBridge alloc] initWithMusicPlayer:self];
}
return self;
}
- (DecibelApplication* __nullable) decibel {
return (DecibelApplication* __nullable)scriptingBridge.application;
}
- (void) wasSelected {
[super wasSelected];
[scriptingBridge ensurePermission];
}
- (BOOL) isRunning {
return self.decibel.running;
}
- (BOOL) isPlaying {
return self.running && self.decibel.playing;
}
- (BOOL) isPaused {
// We don't want to return true when Decibel is stopped, rather than paused. At least for me, Decibel
// returns -1 for playbackTime and playbackPosition when it's neither playing nor paused.
BOOL probablyNotStopped =
self.decibel.playbackTime >= 0 || self.decibel.playbackPosition >= 0;
return self.running && !self.decibel.playing && probablyNotStopped;
}
- (BOOL) pause {
// isPlaying checks isRunning, so we don't need to check it here and waste an Apple event
BOOL wasPlaying = self.playing;
if (wasPlaying) {
DebugMsg("BGMDecibel::pause: Pausing Decibel");
[self.decibel pause];
}
return wasPlaying;
}
- (BOOL) unpause {
// isPaused checks isRunning, so we don't need to check it here and waste an Apple event
BOOL wasPaused = self.paused;
if (wasPaused) {
DebugMsg("BGMDecibel::unpause: Unpausing Decibel");
[self.decibel play];
}
return wasPaused;
}
@end
#pragma clang assume_nonnull end
@@ -0,0 +1,46 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMGooglePlayMusicDesktopPlayer.h
// BGMApp
//
// Copyright © 2019 Kyle Neideck
//
// We have a lot more code for GPMDP than most music players largely because GPMDP has a WebSockets
// API and because the user has to enter a code from GPMDP to allow BGMApp to control it.
// Currently, the other music players all have AppleScript APIs, so for them the OS asks the user
// for permission on our behalf automatically and handles the whole process for us.
//
// This class implements the usual BGMMusicPlayer methods and handles the UI for authenticating
// with GPMDP. BGMGooglePlayMusicDesktopPlayerConnection manages the connection to GPMDP and hides
// the details of its API.
//
// Superclass/Protocol Import
#import "BGMMusicPlayer.h"
#pragma clang assume_nonnull begin
API_AVAILABLE(macos(10.10))
@interface BGMGooglePlayMusicDesktopPlayer : BGMMusicPlayerBase<BGMMusicPlayer>
+ (NSArray<id<BGMMusicPlayer>>*) createInstancesWithDefaults:(BGMUserDefaults*)userDefaults;
@end
#pragma clang assume_nonnull end
@@ -0,0 +1,342 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMGooglePlayMusicDesktopPlayer.m
// BGMApp
//
// Copyright © 2019 Kyle Neideck
//
// Self Include
#import "BGMGooglePlayMusicDesktopPlayer.h"
// Local Includes
#import "BGM_Types.h"
#import "BGM_Utils.h"
#import "BGMAppWatcher.h"
#import "BGMGooglePlayMusicDesktopPlayerConnection.h"
// PublicUtility Includes
#import "CADebugMacros.h"
#pragma clang assume_nonnull begin
@implementation BGMGooglePlayMusicDesktopPlayer {
BGMUserDefaults* userDefaults;
BGMGooglePlayMusicDesktopPlayerConnection* connection;
BGMAppWatcher* appWatcher;
// True while the auth code dialog is open. The user types in the four-digit auth code from
// GPMDP when we connect to it for the first time.
BOOL showingAuthCodeDialog;
// True if the user has cancelled the auth code dialog. We only show the auth code dialog again
// after the user has changed the music player and then changed it back to GPMDP (or restarted
// BGMApp).
BOOL authCancelled;
}
+ (NSArray<id<BGMMusicPlayer>>*) createInstancesWithDefaults:(BGMUserDefaults*)userDefaults {
return @[[[self alloc] initWithUserDefaults:userDefaults]];
}
- (instancetype) initWithUserDefaults:(BGMUserDefaults*)defaults {
// If you're copying this class, replace the ID string with a new one generated by uuidgen (the
// command line tool).
NSUUID* playerID = [BGMMusicPlayerBase makeID:@"FCDCC01F-4BF1-4AD2-BE3E-6B7659A90A3F"];
if ((self = [super initWithMusicPlayerID:playerID
name:@"GPMDP"
toolTip:@"Google Play Music Desktop Player"
bundleID:@"google-play-music-desktop-player"])) {
userDefaults = defaults;
showingAuthCodeDialog = NO;
authCancelled = NO;
// We don't strictly need to use a weak ref (at least not yet), but it doesn't hurt.
BGMGooglePlayMusicDesktopPlayer* __weak weakSelf = self;
connection = [[BGMGooglePlayMusicDesktopPlayerConnection alloc]
initWithUserDefaults:userDefaults
authRequiredHandler:^{
BGMGooglePlayMusicDesktopPlayer* strongSelf = weakSelf;
return [strongSelf requestAuthCodeFromUser];
}
connectionErrorHandler:^{
BGMGooglePlayMusicDesktopPlayer* strongSelf = weakSelf;
[strongSelf showConnectionErrorDialog];
}
apiVersionMismatchHandler:^(NSString* reportedAPIVersion) {
BGMGooglePlayMusicDesktopPlayer* strongSelf = weakSelf;
[strongSelf showAPIVersionMismatchDialog:reportedAPIVersion];
}];
// Set up callbacks that run when GPMDP is opened or closed.
appWatcher = [[BGMAppWatcher alloc]
initWithBundleID:BGMNN(self.bundleID)
appLaunched:^{
BGMGooglePlayMusicDesktopPlayer* strongSelf = weakSelf;
[strongSelf gpmdpWasLaunched];
}
appTerminated:^{
BGMGooglePlayMusicDesktopPlayer* strongSelf = weakSelf;
[strongSelf gpmdpWasTerminated];
}];
}
return self;
}
- (void) gpmdpWasLaunched {
if (self.selected) {
// Reconnect so we can control GPMDP.
DebugMsg("BGMGooglePlayMusicDesktopPlayer::gpmdpWasLaunched: GPMDP launched. Connecting");
// Try up to 10 times because GPMDP won't start accepting connections until it's finished
// starting up.
//
// TODO: If GPMDP shows an alert before it finishes launching, it doesn't start accepting
// connections until the alert is dismissed, which can make this can timeout.
// TODO: Is the error dialog still shown if the user closes GPMDP again while we're
// retrying? It shouldn't be.
[connection connectWithRetries:10];
}
}
- (void) gpmdpWasTerminated {
if (self.selected) {
// Allow the connection to clean up and reset itself.
DebugMsg("BGMGooglePlayMusicDesktopPlayer::gpmdpWasTerminated: GPMDP has been closed.");
[connection disconnect];
}
}
- (void) wasSelected {
[super wasSelected];
// Allow the auth code dialog to be shown again if we were hiding it because the user cancelled
// it last time.
authCancelled = NO;
if (self.running) {
// Only retry once so the error message is shown fairly quickly if we fail to connect.
[connection connectWithRetries:1];
}
}
- (void) wasDeselected {
[super wasDeselected];
[connection disconnect];
}
- (NSString* __nullable) requestAuthCodeFromUser {
if (showingAuthCodeDialog) {
DebugMsg("BGMGooglePlayMusicDesktopPlayer::requestAuthCodeFromUser: "
"Already showing the auth code dialog");
return nil;
}
if (authCancelled) {
DebugMsg("BGMGooglePlayMusicDesktopPlayer::requestAuthCodeFromUser: "
"Previously cancelled. Doing nothing.");
return nil;
}
showingAuthCodeDialog = YES;
// Ask the user to read the auth code from GPMDP and type it in to BGMApp.
NSString* __nullable authCode = [self showAuthCodeDialog];
showingAuthCodeDialog = NO;
return authCode;
}
- (NSString* __nullable) showAuthCodeDialog {
// When this isn't being called because the user just changed something in BGMApp (e.g. GPMDP
// was closed, they selected it in BGMApp for the first time, then opened GPMDP later), we could
// use notifications instead of an NSAlert. But it probably wouldn't happen often enough to be
// worth the effort.
NSAlert* alert = [NSAlert new];
alert.messageText = @"Background Music needs permission to control GPMDP.";
alert.informativeText = @"It should be displaying a four-digit code for you to enter.";
[alert addButtonWithTitle:@"OK"];
[alert addButtonWithTitle:@"Cancel"];
// The text field to type the auth code in.
// TODO: Can we derive these dimensions from something instead of hardcoding them?
NSTextField* input = [[NSTextField alloc] initWithFrame:NSMakeRect(0, 0, 50, 24)];
[alert setAccessoryView:input];
// Focus the text field (so the user doesn't have to do it themselves).
[alert.window setInitialFirstResponder:input];
// Bring GMPDP to the front, underneath our NSAlert, so the user can see the auth code.
[self showGPMDPBehindAuthCodeDialog];
NSModalResponse buttonPressed = [alert runModal];
if (buttonPressed == NSAlertFirstButtonReturn) {
// Set input's value to the text entered by the user so we can access it.
[input validateEditing];
DebugMsg("BGMGooglePlayMusicDesktopPlayer::showAuthCodeDialog: Got auth code: <private>");
return input.stringValue;
} else {
DebugMsg("BGMGooglePlayMusicDesktopPlayer::showAuthCodeDialog: "
"The user cancelled the auth code dialog");
authCancelled = YES;
return nil;
}
}
- (void) showGPMDPBehindAuthCodeDialog {
// Dispatched because if we do this just before showing the auth code dialog, the user's current
// active window will be deactivated, the auth code dialog will become the active window and
// macOS will act as if the user activated it themselves. To avoid stealing key focus, it won't
// activate GPMDP.
//
// We could pass NSApplicationActivateIgnoringOtherApps to activateWithOptions instead, but then
// GPMDP would be activated even if the user really did activate a different application, which
// would steal focus from it.
//
// 250 ms is a reasonable value on my system, but won't always be long enough. When it isn't,
// GPMDP won't be activated, but that just means the user will have to do it themselves.
const int64_t delay = 250 * NSEC_PER_MSEC;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delay),
dispatch_get_main_queue(),
^{
// Make GMPDP the frontmost app.
NSArray<NSRunningApplication*>* gpmdpApps =
[NSRunningApplication
runningApplicationsWithBundleIdentifier:BGMNN(self.bundleID)];
if (gpmdpApps.count > 0) {
[gpmdpApps[0] activateWithOptions:0];
}
// Focus the auth code dialog. It will already be in front of GPMDP because
// it's modal. Dispatched for the same reason as above.
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delay),
dispatch_get_main_queue(),
^{
[NSApp activateIgnoringOtherApps:YES];
});
});
}
- (void) showConnectionErrorDialog {
NSString* errorMsg = @"Could not connect to Google Play Music Desktop Player";
NSString* troubleshootingMsg =
[NSString stringWithFormat:
@"Make sure \"Enable JSON API\" and \"Enable Playback API\" are both checked in GPMDP's "
"settings, then restart GPMDP.\n\n"
"GPMDP should be listening on its default port, 5672.\n\n"
"Consider filing a bug report at %s",
kBGMIssueTrackerURL];
[self showErrorDialog:errorMsg troubleshootingMsg:troubleshootingMsg];
}
- (void) showAPIVersionMismatchDialog:(NSString*)reportedAPIVersion {
NSString* errorMsg = @"Google Play Music Desktop Player Version Not Supported";
NSString* troubleshootingMsg =
[NSString stringWithFormat:
@"GPMDP reported its API version as \"%@\", which Background Music doesn't support "
"yet. Background Music might not be able to control GPMDP properly.\n\n"
"Feel free to open an issue about this at %s",
reportedAPIVersion,
kBGMIssueTrackerURL];
[self showErrorDialog:errorMsg troubleshootingMsg:troubleshootingMsg];
}
- (void) showErrorDialog:(NSString*)errorMsg troubleshootingMsg:(NSString*)troubleshootingMsg {
if (!self.running) {
// GPMDP isn't running, so there's no need to inform the user. (The "Auto-pause GPMDP" menu
// item will be greyed out, but that's handled elsewhere.)
DebugMsg("BGMGooglePlayMusicDesktopPlayer::showErrorDialog: Not running");
return;
}
NSLog(@"%@", errorMsg);
// Show the error in a UI dialog.
NSAlert* alert = [NSAlert new];
alert.messageText = errorMsg;
alert.informativeText = troubleshootingMsg;
// TODO: Show the suppression checkbox and save its value in user defaults.
alert.showsSuppressionButton = NO;
[alert addButtonWithTitle:@"OK"];
[alert runModal];
}
- (BOOL) isRunning {
// We have to check with NSRunningApplication instead of just setting a flag in appWatcher's
// callbacks because BGMAutoPauseMenuItem calls this method when it's notified by its own
// instance of BGMAppWatcher. If BGMAutoPauseMenuItem got notified first, the flag wouldn't be
// updated in time.
//
// At some point we might want to try to avoid this by making the BGMMusicPlayers' running
// properties observable.
NSArray<NSRunningApplication*>* instances =
[NSRunningApplication runningApplicationsWithBundleIdentifier:BGMNN(self.bundleID)];
return instances.count > 0;
}
- (BOOL) isPlaying {
return self.running && connection.playing;
}
- (BOOL) isPaused {
return self.running && connection.paused;
}
- (BOOL) pause {
// isPlaying checks isRunning, so we don't need to check it here.
BOOL wasPlaying = self.playing;
if (wasPlaying) {
DebugMsg("BGMGooglePlayMusicDesktopPlayer::pause: Pausing Google Play Music Desktop "
"Player");
// There's a race condition here and in unpause because, if the user paused GPMDP just
// before we called playPause, GPMDP would play instead of pausing. I'm not sure there's
// much we can/should do about it.
[connection playPause];
}
return wasPlaying;
}
- (BOOL) unpause {
// isPaused checks isRunning, so we don't need to check it here.
BOOL wasPaused = self.paused;
if (wasPaused) {
DebugMsg("BGMGooglePlayMusicDesktopPlayer::unpause: Unpausing Google Play Music Desktop "
"Player");
[connection playPause];
}
return wasPaused;
}
@end
#pragma clang assume_nonnull end
@@ -0,0 +1,64 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMGooglePlayMusicDesktopPlayerConnection.h
// BGMApp
//
// Copyright © 2019 Kyle Neideck
//
// Local Includes
#import "BGMUserDefaults.h"
// System Includes
#import <Cocoa/Cocoa.h>
#import <WebKit/WebKit.h>
#pragma clang assume_nonnull begin
API_AVAILABLE(macos(10.10))
@interface BGMGooglePlayMusicDesktopPlayerConnection : NSObject<WKScriptMessageHandler>
// authRequiredHandler: A UI callback that asks the user for the auth code GPMDP will display.
// Returns the auth code they entered, or nil.
// connectionErrorHandler: A UI callback that shows a connection error message.
// apiVersionMismatchHandler: A UI callback that shows a warning dialog explaining that GPMDP
// reported an API version that we don't support yet.
- (instancetype) initWithUserDefaults:(BGMUserDefaults*)defaults
authRequiredHandler:(NSString* __nullable (^)(void))authHandler
connectionErrorHandler:(void (^)(void))errorHandler
apiVersionMismatchHandler:(void (^)(NSString* reportedAPIVersion))apiVersionHandler;
// Returns before the connection has been fully established. The playing and paused properties will
// remain false until the connection is complete, but playPause can be called at any time after
// calling this method.
//
// If the connection fails, it will be retried after a one second delay, up to the number of times
// given.
- (void) connectWithRetries:(int)retries;
- (void) disconnect;
// Tell GPMDP to play if it's paused or pause if it's playing.
- (void) playPause;
@property (readonly) BOOL playing;
@property (readonly) BOOL paused;
@end
#pragma clang assume_nonnull end
@@ -0,0 +1,444 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMGooglePlayMusicDesktopPlayerConnection.m
// BGMApp
//
// Copyright © 2019 Kyle Neideck
//
// Self Include
#import "BGMGooglePlayMusicDesktopPlayerConnection.h"
// Local Includes
#import "BGM_Utils.h"
// PublicUtility Includes
#import "CADebugMacros.h"
#pragma clang assume_nonnull begin
// When GooglePlayMusicDesktopPlayer.js sends a message to this class, it sets the message handler
// name to one of these, which tells us what type of message it is. (This is a macro because you
// can't make a static const NSArray.)
#define kScriptMessageHandlerNames (@[@"gpmdp", @"log", @"error"])
@implementation BGMGooglePlayMusicDesktopPlayerConnection {
// GPMDP has a WebSocket API, so we use a WKWebView to access it using Javascript. Using a
// proper library would make the code a bit cleaner and save a little memory, but I'm not sure
// it would be worth adding an external dependency for that.
WKWebView* webView;
NSString* __nullable permanentAuthCode;
BGMUserDefaults* userDefaults;
// The number of times to retry if we fail to connect. For example, if GPMDP is still starting
// up. Set to 0 when we aren't trying to connect.
int connectionRetries;
// A UI callback that asks the user for the auth code GPMDP will display.
NSString* __nullable (^authRequiredHandler)(void);
// A UI callback that shows a connection error message.
void (^connectionErrorHandler)(void);
// A UI callback that shows a warning dialog explaining that GPMDP reported an API version that
// we don't support yet.
void (^apiVersionMismatchHandler)(NSString* reportedAPIVersion);
}
- (instancetype) initWithUserDefaults:(BGMUserDefaults*)defaults
authRequiredHandler:(NSString* __nullable (^)(void))authHandler
connectionErrorHandler:(void (^)(void))errorHandler
apiVersionMismatchHandler:(void (^)(NSString* reportedAPIVersion))apiVersionHandler {
if((self = [super init])) {
userDefaults = defaults;
authRequiredHandler = authHandler;
connectionErrorHandler = errorHandler;
apiVersionMismatchHandler = apiVersionHandler;
connectionRetries = 0;
// Lazily initialised.
permanentAuthCode = nil;
// Report that GPMDP is stopped until we know otherwise.
_playing = NO;
_paused = NO;
}
return self;
}
// Creates and initialises webView, a WKWebView we use to communicate with GPMDP over WebSockets.
- (void) createWebView {
// Read the Javascript we'll need for this.
NSString* __nullable jsPath =
[[NSBundle mainBundle] pathForResource:@"GooglePlayMusicDesktopPlayer.js"
ofType:nil];
NSError* err;
NSString* __nullable jsStr =
(!jsPath ? nil : [NSString stringWithContentsOfFile:BGMNN(jsPath)
encoding:NSUTF8StringEncoding
error:&err]);
if (err || !jsStr || [jsStr isEqualToString:@""]) {
// TODO: Return an error so the caller can show an error dialog or something.
NSLog(@"Error loading GPMDP Javascript file: %@", err);
} else {
webView = [WKWebView new];
// Register to receive messages from our Javascript. The messages are handled in
// userContentController. We register several times using different names as a convenient
// way to separate messages from GPMDP, messages to log and errors.
for (NSString* name in kScriptMessageHandlerNames) {
[webView.configuration.userContentController addScriptMessageHandler:self name:name];
}
// Load our Javascript functions into webView so we can call them later.
[self evaluateJavaScript:BGMNN(jsStr)];
}
}
- (void) connectWithRetries:(int)retries {
if (retries < 0) {
BGMAssert(false, "retries < 0");
return;
}
if (!permanentAuthCode) {
// Read the API auth code from user defaults (actually the keychain), if there is one. If
// the user hasn't authenticated before, it will be nil.
//
// We do this lazily because it can show a password dialog in debug/unsigned builds.
permanentAuthCode = userDefaults.googlePlayMusicDesktopPlayerPermanentAuthCode;
}
connectionRetries = retries;
// Create the WKWebView we'll use to connect to GPMDP with WebSockets. Using a WKWebView means
// Background Music uses a bit more memory while connected to GPMDP, around 15 MB for me, but
// saves us having to complicate the build process to add a dependency on a proper library.
[self createWebView];
if (permanentAuthCode) {
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::connectWithRetries: "
"Connecting with auth code");
NSString* __nullable percentEncodedCode =
[BGMGooglePlayMusicDesktopPlayerConnection
toPercentEncoded:BGMNN(permanentAuthCode)];
[self evaluateJavaScript:[NSString stringWithFormat:@"connect('%@');", percentEncodedCode]];
} else {
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::connectWithRetries: "
"Connecting without auth code");
[self evaluateJavaScript:@"connect();"];
}
// Check whether GPMDP is playing, paused or stopped.
[self requestPlaybackState];
}
- (void) disconnect {
// Stop retrying if we're in the process of connecting.
connectionRetries = 0;
// evaluateJavaScript is only safe to call on the main thread.
dispatch_async(dispatch_get_main_queue(), ^{
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::disconnect: Disconnecting");
[webView evaluateJavaScript:@"disconnect();"
completionHandler:^(id __nullable result, NSError* __nullable error) {
#pragma unused (result)
if (error) {
NSLog(@"Error closing connection to GPMDP: %@", error);
}
// Allow the WKWebView to be garbage collected.
for (NSString* name in kScriptMessageHandlerNames) {
[webView.configuration.userContentController
removeScriptMessageHandlerForName:name];
}
webView = nil;
}];
});
}
- (void) evaluateJavaScript:(NSString*)js {
// evaluateJavaScript is only safe to call on the main thread.
dispatch_async(dispatch_get_main_queue(), ^{
[webView evaluateJavaScript:js
completionHandler:^(id __nullable result, NSError* __nullable error) {
#pragma unused (result)
if (error) {
// TODO: We should probably show an error dialog in some cases.
NSLog(@"JS error: %@", error);
}
}];
});
}
- (void) playPause {
[self evaluateJavaScript:@"playPause();"];
}
- (void) sendAuthCode:(NSString*)authCode {
// Don't log the code itself just in case it could be a security problem.
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::sendAuthCode: Sending GPMDP auth code");
// Percent-encode the user input just in case they entered something that could execute as
// Javascript. We could limit the input to four digits instead, but this should be fine.
NSString* __nullable percentEncodedCode =
[BGMGooglePlayMusicDesktopPlayerConnection toPercentEncoded:authCode];
// We send the message to GPMDP even if percentEncodedCode is nil so it will reply with an error
// and BGMApp will ask the user for the auth code again.
NSString* js = [NSString stringWithFormat:@"window.sendAuthCode('%@');", percentEncodedCode];
[self evaluateJavaScript:js];
}
- (void) sendPermanentAuthCode {
NSString* __nullable code = permanentAuthCode;
if (code) {
// Don't log the code itself just in case it could be a security problem.
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::sendPermanentAuthCode: "
"Sending GPMDP permanent auth code");
// Percent-encode it just in case something it includes could be executed as Javascript.
NSString* __nullable percentEncodedCode =
[BGMGooglePlayMusicDesktopPlayerConnection toPercentEncoded:BGMNN(code)];
// Pass the code to our WKWebView so it can send it to GPMDP.
NSString* js =
[NSString stringWithFormat:@"sendPermanentAuthCode('%@');", percentEncodedCode];
[self evaluateJavaScript:js];
} else {
NSLog(@"BGMGooglePlayMusicDesktopPlayerConnection::sendPermanentAuthCode: No code to send");
}
}
+ (NSString* __nullable)toPercentEncoded:(NSString*)rawString {
// Just percent-encode every character (by passing an empty NSCharacterSet as the allowed
// characters).
NSString* __nullable percentEncoded = [rawString
stringByAddingPercentEncodingWithAllowedCharacters:
[NSCharacterSet characterSetWithCharactersInString:@""]];
if (percentEncoded) {
return percentEncoded;
} else {
// The docs say that stringByAddingPercentEncodingWithAllowedCharacters returns nil "if the
// transformation is not possible", but don't explain when that could happen. According to
// https://stackoverflow.com/a/33558934/1091063 it can be caused by the string containing
// invalid unicode.
NSLog(@"Could not encode");
return nil;
}
}
// Ask GPMDP whether it's playing, paused or stopped. The response is handled asynchronously in
// handleResultMessage.
- (void) requestPlaybackState {
[self evaluateJavaScript:@"requestPlaybackState();"];
}
#pragma mark WKScriptMessageHandler Methods
- (void) userContentController:(WKUserContentController*)userContentController
didReceiveScriptMessage:(WKScriptMessage*)message {
#pragma unused (userContentController)
if ([@"log" isEqual:message.name]) {
// The message body is always a string in this case.
[self handleLogMessage:message.body];
} else if ([@"error" isEqual:message.name]) {
[self handleConnectionError];
} else {
BGMAssert([@"gpmdp" isEqual:message.name], "Unexpected message handler name");
[self handleGPMDPMessage:message];
}
}
- (void) handleLogMessage:(NSString*)message {
(void)message;
#if DEBUG
if (permanentAuthCode) {
// Avoid logging the auth code, which would be a minor security issue.
message = [message stringByReplacingOccurrencesOfString:BGMNN(permanentAuthCode)
withString:@"<private>"];
}
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::userContentController: %s",
message.UTF8String);
#endif
}
- (void) handleConnectionError {
if (connectionRetries > 0) {
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::handleConnectionError: "
"Retrying in 1 second");
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)),
dispatch_get_main_queue(),
^{
// Check connectionRetries again because disconnect may have been called.
if (connectionRetries > 0) {
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::"
"handleConnectionError: Retrying");
[self connectWithRetries:(connectionRetries - 1)];
}
});
} else {
NSLog(@"BGMGooglePlayMusicDesktopPlayerConnection::handleConnectionError: "
"No retries left. Giving up.");
connectionErrorHandler();
}
}
- (void) handleGPMDPMessage:(WKScriptMessage*)message {
// See https://github.com/MarshallOfSound/Google-Play-Music-Desktop-Player-UNOFFICIAL-/blob/master/docs/PlaybackAPI_WebSocket.md
// Type check.
if (![message.body isKindOfClass:[NSDictionary class]]) {
NSLog(@"Unexpected message body type");
return;
}
NSDictionary* body = message.body;
NSString* messageType;
// The key for the message type is "channel", except when the message is a response, in which
// case the key can be "namespace".
if ([body[@"channel"] isKindOfClass:[NSString class]]) {
messageType = body[@"channel"];
} else if ([body[@"namespace"] isKindOfClass:[NSString class]]) {
messageType = body[@"namespace"];
} else {
NSLog(@"No channel/namespace");
return;
}
// Handle the message depending on its type (or ignore it).
if ([@"API_VERSION" isEqual:messageType]) {
[self handleAPIVersionMessage:body];
} else if ([@"connect" isEqual:messageType]) {
[self handleConnectMessage:body];
} else if ([@"playState" isEqual:messageType]) {
[self handlePlayStateMessage:body];
} else if ([@"result" isEqual:messageType]) {
[self handleResultMessage:body];
}
}
- (void) handleAPIVersionMessage:(NSDictionary*)body {
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::handleAPIVersionMessage: Response: %s",
[NSString stringWithFormat:@"%@", body].UTF8String);
// Type check.
if (![body[@"payload"] isKindOfClass:[NSString class]]) {
NSLog(@"Unexpected payload type");
[self handleConnectionError];
return;
}
NSString* apiVersion = body[@"payload"];
// "1.0.0" -> ["1", "0", "0"]
NSArray<NSString*>* versionParts = [apiVersion componentsSeparatedByString:@"."];
// Check the major version number is 1, which is the only major version we support.
if (versionParts.count > 0) {
NSInteger majorVersion = versionParts[0].integerValue;
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::handleAPIVersionMessage: "
"Major version: %lu", majorVersion);
if (majorVersion == 1) {
// GPMDP uses SemVer, so as long as the major version number matches what we can handle,
// it should work.
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::handleAPIVersionMessage: "
"This API version is supported");
return;
}
}
// Show a warning dialog box to the user, but try to continue anyway. There's probably a
// reasonable chance it'll still work.
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::handleAPIVersionMessage: "
"Unsupported GPMDP API version");
apiVersionMismatchHandler(apiVersion);
}
- (void) handleConnectMessage:(NSDictionary*)body {
// Don't log the response as it may contain the auth code.
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::handleConnectMessage: Received response");
// Type check.
if (![body[@"payload"] isKindOfClass:[NSString class]]) {
NSLog(@"Unexpected payload type");
[self handleConnectionError];
return;
}
NSString* payload = body[@"payload"];
if ([@"CODE_REQUIRED" isEqual:payload]) {
// Ask the user for the auth code GPMDP is displaying and send it to GPMDP to finish
// connecting.
NSString* __nullable authCode = authRequiredHandler();
if (authCode) {
[self sendAuthCode:BGMNN(authCode)];
}
} else {
// The payload should be the permanent auth code.
permanentAuthCode = payload;
[self sendPermanentAuthCode];
// Save the code to the keychain so we can use it when connecting to GPMDP in future.
userDefaults.googlePlayMusicDesktopPlayerPermanentAuthCode = permanentAuthCode;
}
}
- (void) handlePlayStateMessage:(NSDictionary*)body {
(void)body;
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::handlePlayStateMessage: Response: %s",
[NSString stringWithFormat:@"%@", body].UTF8String);
// This message tells us the playstate has changed, but doesn't differentiate between stopped
// and paused. The response to this API request will. See handleResultMessage.
// TODO: Can it transition from stopped to paused? Would that be a problem?
[self requestPlaybackState];
}
- (void) handleResultMessage:(NSDictionary*)body {
DebugMsg("BGMGooglePlayMusicDesktopPlayerConnection::handleResultMessage: Response: %s",
[NSString stringWithFormat:@"%@", body].UTF8String);
// Type check.
if (![body[@"value"] isKindOfClass:[NSNumber class]]) {
NSLog(@"No value");
return;
}
// 0 - Playback is stopped
// 1 - Track is paused
// 2 - Track is playing
int state = ((NSNumber*)body[@"value"]).intValue;
_playing = (state == 2);
_paused = (state == 1);
}
@end
#pragma clang assume_nonnull end
+30
View File
@@ -0,0 +1,30 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMHermes.h
// BGMApp
//
// Copyright © 2016 Kyle Neideck
//
// Superclass/Protocol Import
#import "BGMMusicPlayer.h"
@interface BGMHermes : BGMMusicPlayerBase<BGMMusicPlayer>
@end
+105
View File
@@ -0,0 +1,105 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMHermes.m
// BGMApp
//
// Copyright © 2016-2018 Kyle Neideck
//
// Self Include
#import "BGMHermes.h"
// Auto-generated Scripting Bridge header
#import "Hermes.h"
// Local Includes
#import "BGMScriptingBridge.h"
// PublicUtility Includes
#import "CADebugMacros.h"
#pragma clang assume_nonnull begin
@implementation BGMHermes {
BGMScriptingBridge* scriptingBridge;
}
- (instancetype) init {
// If you're copying this class, replace the ID string with a new one generated by uuidgen. (Command line tool.)
if ((self = [super initWithMusicPlayerID:[BGMMusicPlayerBase makeID:@"0CDC67B0-56D3-4D94-BC06-6E380D8F5E34"]
name:@"Hermes"
bundleID:@"com.alexcrichton.Hermes"])) {
scriptingBridge = [[BGMScriptingBridge alloc] initWithMusicPlayer:self];
}
return self;
}
- (HermesApplication* __nullable) hermes {
return (HermesApplication* __nullable)scriptingBridge.application;
}
- (void) wasSelected {
[super wasSelected];
[scriptingBridge ensurePermission];
}
- (BOOL) isRunning {
// Note that this will return NO if is self.hermes is nil (i.e. Hermes isn't running).
return self.hermes.running;
}
// isPlaying and isPaused check self.running first just in case Hermes is closed but self.hermes hasn't become
// nil yet. In that case, reading self.hermes.playerState could make Scripting Bridge open Hermes.
- (BOOL) isPlaying {
return self.running && (self.hermes.playbackState == HermesPlayerStatesPlaying);
}
- (BOOL) isPaused {
return self.running && (self.hermes.playbackState == HermesPlayerStatesPaused);
}
- (BOOL) pause {
// isPlaying checks isRunning, so we don't need to check it here and waste an Apple event
BOOL wasPlaying = self.playing;
if (wasPlaying) {
DebugMsg("BGMHermes::pause: Pausing Hermes");
[self.hermes pause];
}
return wasPlaying;
}
- (BOOL) unpause {
// isPaused checks isRunning, so we don't need to check it here and waste an Apple event
BOOL wasPaused = self.paused;
if (wasPaused) {
DebugMsg("BGMHermes::unpause: Unpausing Hermes");
[self.hermes play];
}
return wasPaused;
}
@end
#pragma clang assume_nonnull end
+35
View File
@@ -0,0 +1,35 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMMusic.h
// BGMApp
//
// Copyright © 2016, 2019 Kyle Neideck
// Copyright © 2019 theLMGN
//
// Superclass/Protocol Import
#import "BGMMusicPlayer.h"
#pragma clang assume_nonnull begin
@interface BGMMusic : BGMMusicPlayerBase<BGMMusicPlayer>
@end
#pragma clang assume_nonnull end
+111
View File
@@ -0,0 +1,111 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMMusic.m
// BGMApp
//
// Copyright © 2016-2019 Kyle Neideck, theLMGN
//
// Self Include
#import "BGMMusic.h"
// Auto-generated Scripting Bridge header
#import "Music.h"
// Local Includes
#import "BGMScriptingBridge.h"
// PublicUtility Includes
#import "CADebugMacros.h"
#pragma clang assume_nonnull begin
@implementation BGMMusic {
BGMScriptingBridge* scriptingBridge;
}
+ (NSUUID*) sharedMusicPlayerID {
NSUUID* __nullable musicPlayerID =
[[NSUUID alloc] initWithUUIDString:@"829B8069-8BD2-481D-BD40-54AB8CDAE228"];
NSAssert(musicPlayerID, @"BGMMusic::sharedMusicPlayerID: !musicPlayerID");
return (NSUUID*)musicPlayerID;
}
- (instancetype) init {
if ((self = [super initWithMusicPlayerID:[BGMMusic sharedMusicPlayerID]
name:@"Music"
bundleID:@"com.apple.Music"])) {
scriptingBridge = [[BGMScriptingBridge alloc] initWithMusicPlayer:self];
}
return self;
}
- (MusicApplication* __nullable) music {
return (MusicApplication*)scriptingBridge.application;
}
- (void) wasSelected {
[super wasSelected];
[scriptingBridge ensurePermission];
}
- (BOOL) isRunning {
return self.music.running;
}
// isPlaying and isPaused check self.running first just in case Music is closed but self.music
// hasn't become nil yet. In that case, reading self.music.playerState could make Scripting Bridge
// open Music.
- (BOOL) isPlaying {
return self.running && (self.music.playerState == MusicEPlSPlaying);
}
- (BOOL) isPaused {
return self.running && (self.music.playerState == MusicEPlSPaused);
}
- (BOOL) pause {
// isPlaying checks isRunning, so we don't need to check it here and waste an Apple event
BOOL wasPlaying = self.playing;
if (wasPlaying) {
DebugMsg("BGMMusic::pause: Pausing Music");
[self.music pause];
}
return wasPlaying;
}
- (BOOL) unpause {
// isPaused checks isRunning, so we don't need to check it here and waste an Apple event
BOOL wasPaused = self.paused;
if (wasPaused) {
DebugMsg("BGMMusic::unpause: Unpausing Music");
[self.music playpause];
}
return wasPaused;
}
@end
#pragma clang assume_nonnull end
+99 -55
View File
@@ -17,94 +17,138 @@
// BGMMusicPlayer.h
// BGMApp
//
// Copyright © 2016 Kyle Neideck
// Copyright © 2016, 2018, 2019 Kyle Neideck
//
// The base class and protocol for music player apps. Also holds the state of the currently
// selected music player.
// The base classes and protocol for objects that represent a music player app.
//
// To add support for a music player, create a subclass of BGMMusicPlayerBase that implements
// BGMMusicPlayerProtocol. BGMSpotify will probably be the most useful example.
// To add support for a music player, create a class that implements the BGMMusicPlayer protocol
// and add it to initWithAudioDevices in BGMMusicPlayers.mm.
//
// Include the BGM_MUSIC_PLAYER_DEFAULT_LOAD_METHOD macro somewhere in the @implementation block.
// You might also want to override the icon method if the default implementation from
// BGMMusicPlayerBase doesn't work.
// You'll probably want to subclass BGMMusicPlayerBase and, if the music player supports
// AppleScript, use BGMScriptingBridge. Your class might need to override the icon method if the
// default implementation from BGMMusicPlayerBase doesn't work.
//
// The music player classes written so far use Scripting Bridge to communicate with the music
// player apps (see iTunes.h/Spotify.h) but any other way is fine too.
// BGMSpotify will probably be the most useful example to follow, but they're all pretty
// similar. The music player classes written so far all use Scripting Bridge to communicate with
// the music player apps (see iTunes.h/Spotify.h) but any other way is fine too.
//
// BGMDriver will use either the music player's bundle ID or PID to match it to the audio it
// plays. (Though using PIDs hasn't been tested yet.)
//
// If you're not sure what bundle ID the music player uses, install a debug build of BGMDriver
// and play something in the music player. The easiest way is to do
// build_and_install.sh -d
// BGMDriver will log the bundle ID to system.log when it becomes aware of the music player.
//
// Local Includes
#import "BGMUserDefaults.h"
// System Includes
#import <Foundation/Foundation.h>
#import <ScriptingBridge/ScriptingBridge.h>
#import <Cocoa/Cocoa.h>
#pragma clang assume_nonnull begin
#define BGM_MUSIC_PLAYER_ADD_SELF_TO_CLASSES_LIST \
[BGMMusicPlayerBase addToMusicPlayerClasses:[self class]];
@protocol BGMMusicPlayer <NSObject>
#define BGM_MUSIC_PLAYER_DEFAULT_LOAD_METHOD \
+ (void) load { \
BGM_MUSIC_PLAYER_ADD_SELF_TO_CLASSES_LIST \
}
// Classes return an instance of themselves for each music player app they make available in
// BGMApp. So far that's always been a single instance, but that will probably change eventually.
// Most classes don't need to override the default implementation from BGMMusicPlayerBase.
//
// But, for example, a class for custom music players would probably return an instance for each
// custom player the user has created. (Also note that it could return an empty array.)
//
// TODO: I think the return type should actually be NSArray<instancetype>*, but that doesn't seem
// to work. There's a Clang bug about this: https://llvm.org/bugs/show_bug.cgi?id=27323
// (though it hasn't been confirmed yet).
+ (NSArray<id<BGMMusicPlayer>>*) createInstancesWithDefaults:(BGMUserDefaults*)userDefaults;
// Forward declarations (just for the typedef)
@class BGMMusicPlayerBase;
@protocol BGMMusicPlayerProtocol;
// We need a unique ID for each music player to store in user defaults. In the most common case,
// classes that provide a static (or at least bounded) number of music players, you can generate
// IDs with uuidgen (the command line tool) and include them in your class as constants. Otherwise,
// you'll probably want to store them in user defaults and load them in createInstancesWithDefaults.
@property (readonly) NSUUID* musicPlayerID;
typedef BGMMusicPlayerBase<BGMMusicPlayerProtocol> BGMMusicPlayer;
// The name, tool-tip and icon of the music player, to be used in the UI.
@property (readonly) NSString* name;
@property (readonly) NSString* __nullable toolTip;
@property (readonly) NSImage* __nullable icon;
@protocol BGMMusicPlayerProtocol
@property (readonly) NSString* __nullable bundleID;
@optional
// Subclasses usually won't need to implement these unless the music player has no bundle ID.
+ (id) initWithPID:(pid_t)pid;
+ (id) initWithPIDFromNSNumber:(NSNumber*)pid;
+ (id) initWithPIDFromCFNumber:(CFNumberRef)pid;
// The pid of each instance of the music player app currently running
+ (NSArray<NSNumber*>*) pidsOfRunningInstances;
// Classes will usually ignore this property and leave it nil unless the music player has no
// bundle ID.
//
// TODO: If we ever add a music player class that uses this property, it'll need a way to inform
// BGMDevice of changes. It might be easiest to have BGMMusicPlayers to observe this property,
// on the selected music player, with KVO and update BGMDevice when it changes. Or
// BGMMusicPlayers could pass a pointer to itself to createInstancesWithDefaults.
@property NSNumber* __nullable pid;
@required
// The name of the music player, to be used in the UI
+ (NSString*) name;
// True if this is currently the selected music player.
@property (readonly) BOOL selected;
// The refs returned by the bundleID and pid methods don't need to be released by users, but may be
// released by the class/instance at some point (get rule applies).
+ (CFStringRef __nullable) bundleID;
// Subclasses will usually always return NULL unless they implement the optional methods above.
- (CFNumberRef __nullable) pid;
// The state of the music player.
//
// True if the music player app is open.
@property (readonly, getter=isRunning) BOOL running;
// True if the music player is playing a song or some other user-selected audio file. Note that
// the music player playing audio for UI, notifications, etc. won't make this true (which is why we
// need this property and can't just ask BGMDriver if the music player is playing audio).
@property (readonly, getter=isPlaying) BOOL playing;
// True if the music player has a current/open song (or whatever) and will continue playing it if
// BGMMusicPlayer::unpause is called. Normally because the user was playing a song and they or
// BGMApp paused it.
@property (readonly, getter=isPaused) BOOL paused;
- (BOOL) isRunning;
// Called when the user selects this music player.
- (void) wasSelected;
// Called when this was the selected music player and the user just selected a different one.
- (void) wasDeselected;
// Pause the music player. Does nothing if the music player is already paused or isn't running.
// Returns YES if the music player is paused now but wasn't before, returns NO otherwise.
- (BOOL) pause;
// Unpause the music player. Does nothing if the music player is already playing or isn't running.
// Returns YES if the music player is playing now but wasn't before, returns NO otherwise.
- (BOOL) unpause;
- (BOOL) isPlaying;
- (BOOL) isPaused;
@end
@interface BGMMusicPlayerBase : NSObject <SBApplicationDelegate>
+ (NSArray*) musicPlayerClasses;
+ (void) addToMusicPlayerClasses:(Class)musicPlayerClass;
@interface BGMMusicPlayerBase : NSObject
// The music player currently selected in the preferences menu. (There's no real reason for this to be
// global or in this class. I was just trying it out of curiosity.)
+ (BGMMusicPlayer*) selectedMusicPlayer;
+ (void) setSelectedMusicPlayer:(BGMMusicPlayer*)musicPlayer;
- (instancetype) initWithMusicPlayerID:(NSUUID*)musicPlayerID
name:(NSString*)name
bundleID:(NSString* __nullable)bundleID;
+ (NSImage* __nullable) icon;
- (instancetype) initWithMusicPlayerID:(NSUUID*)musicPlayerID
name:(NSString*)name
toolTip:(NSString*)toolTip
bundleID:(NSString* __nullable)bundleID;
// If the music player application is running, the scripting bridge object representing it. Otherwise
// nil.
@property (readonly) __kindof SBApplication* __nullable sbApplication;
- (instancetype) initWithMusicPlayerID:(NSUUID*)musicPlayerID
name:(NSString*)name
toolTip:(NSString* __nullable)toolTip
bundleID:(NSString* __nullable)bundleID
pid:(NSNumber* __nullable)pid;
// Convenience wrapper around NSUUID's initWithUUIDString. musicPlayerIDString must be a string
// generated by uuidgen (command line tool), e.g. "60BA9739-B6DD-4E6A-8134-51410A45BB84".
+ (NSUUID*) makeID:(NSString*)musicPlayerIDString;
// BGMMusicPlayer default implementations
+ (NSArray<id<BGMMusicPlayer>>*) createInstancesWithDefaults:(BGMUserDefaults*)userDefaults;
@property (readonly) NSImage* __nullable icon;
@property (readonly) NSUUID* musicPlayerID;
@property (readonly) NSString* name;
@property (readonly) NSString* __nullable toolTip;
@property (readonly) NSString* __nullable bundleID;
@property NSNumber* __nullable pid;
@property (readonly) BOOL selected;
- (void) wasSelected;
- (void) wasDeselected;
@end
+65 -113
View File
@@ -17,146 +17,98 @@
// BGMMusicPlayer.m
// BGMApp
//
// Copyright © 2016 Kyle Neideck
// Copyright © 2016-2019 Kyle Neideck
//
// Self Include
#import "BGMMusicPlayer.h"
// PublicUtility Includes
#undef CoreAudio_ThreadStampMessages
#define CoreAudio_ThreadStampMessages 0 // Requires C++
#include "CADebugMacros.h"
// System Includes
#import <Cocoa/Cocoa.h>
#import "CADebugMacros.h"
#pragma clang assume_nonnull begin
@implementation BGMMusicPlayerBase {
// Tokens for the notification observers. We need these to remove the observers in dealloc.
id didLaunchToken;
id didTerminateToken;
@implementation BGMMusicPlayerBase
@synthesize musicPlayerID = _musicPlayerID;
@synthesize name = _name;
@synthesize toolTip = _toolTip;
@synthesize bundleID = _bundleID;
@synthesize pid = _pid;
@synthesize selected = _selected;
- (instancetype) initWithMusicPlayerID:(NSUUID*)musicPlayerID
name:(NSString*)name
bundleID:(NSString* __nullable)bundleID {
return [self initWithMusicPlayerID:musicPlayerID
name:name
toolTip:nil
bundleID:bundleID
pid:nil];
}
@synthesize sbApplication = sbApplication;
// A array of the subclasses of BGMMusicPlayer
static NSArray* sMusicPlayerClasses;
// The user-selected music player. One of BGMMusicPlayer's subclasses declares itself the default music player by
// setting this to an instance of itself in its load method.
static BGMMusicPlayer* sSelectedMusicPlayer;
// Load-time static initializer
+ (void) load {
sMusicPlayerClasses = @[];
- (instancetype) initWithMusicPlayerID:(NSUUID*)musicPlayerID
name:(NSString*)name
toolTip:(NSString*)toolTip
bundleID:(NSString* __nullable)bundleID {
return [self initWithMusicPlayerID:musicPlayerID
name:name
toolTip:toolTip
bundleID:bundleID
pid:nil];
}
- (id) init {
- (instancetype) initWithMusicPlayerID:(NSUUID*)musicPlayerID
name:(NSString*)name
toolTip:(NSString* __nullable)toolTip
bundleID:(NSString* __nullable)bundleID
pid:(NSNumber* __nullable)pid {
if ((self = [super init])) {
NSString* bundleID = (__bridge NSString*)[[self class] bundleID];
NSAssert(musicPlayerID, @"BGMMusicPlayerBase::initWithMusicPlayerID: !musicPlayerID");
void (^createSBApplication)(void) = ^{
sbApplication = [SBApplication applicationWithBundleIdentifier:bundleID];
sbApplication.delegate = self;
};
NSAssert([self conformsToProtocol:@protocol(BGMMusicPlayer)],
@"BGMMusicPlayerBase::initWithMusicPlayerID: !conformsToProtocol");
BOOL (^isAboutThisMusicPlayer)(NSNotification*) = ^(NSNotification* note){
return [[note.userInfo[NSWorkspaceApplicationKey] bundleIdentifier] isEqualToString:bundleID];
};
// Add observers that create/destroy the SBApplication when the music player is launched/terminated. We
// only create the SBApplication when the music player is open because, if it isn't, creating the
// SBApplication, or sending it events, could launch the music player. Whether it does or not depends on
// the music player, and possibly the version of the music player, so to be safe we assume they all do.
//
// From the docs for SBApplication's applicationWithBundleIdentifier method:
// "For applications that declare themselves to have a dynamic scripting interface, this method will
// launch the application if it is not already running."
NSNotificationCenter* center = [[NSWorkspace sharedWorkspace] notificationCenter];
#if DEBUG
const char* mpName = [[[self class] name] UTF8String];
#endif
didLaunchToken = [center addObserverForName:NSWorkspaceDidLaunchApplicationNotification
object:nil
queue:nil
usingBlock:^(NSNotification* note) {
if (isAboutThisMusicPlayer(note)) {
DebugMsg("BGMMusicPlayer::init: %s launched", mpName);
createSBApplication();
}
}];
didTerminateToken = [center addObserverForName:NSWorkspaceDidTerminateApplicationNotification
object:nil
queue:nil
usingBlock:^(NSNotification* note) {
if (isAboutThisMusicPlayer(note)) {
DebugMsg("BGMMusicPlayer::init: %s terminated", mpName);
sbApplication = nil;
}
}];
// Create the SBApplication if the music player is already running.
if ([[NSRunningApplication runningApplicationsWithBundleIdentifier:bundleID] count] > 0) {
createSBApplication();
}
_musicPlayerID = musicPlayerID;
_name = name;
_toolTip = toolTip;
_bundleID = bundleID;
_pid = pid;
_selected = NO;
}
return self;
}
- (id) eventDidFail:(const AppleEvent*)event withError:(NSError*)error {
// SBApplicationDelegate method. So far, this just logs the error.
+ (NSUUID*) makeID:(NSString*)musicPlayerIDString {
NSUUID* __nullable musicPlayerID = [[NSUUID alloc] initWithUUIDString:musicPlayerIDString];
NSAssert(musicPlayerID, @"BGMMusicPlayerBase::makeID: !musicPlayerID");
#if DEBUG
NSString* vars = [NSString stringWithFormat:@"event=%@ error=%@ sbApplication=%@", event, error, sbApplication];
DebugMsg("BGMMusicPlayer::eventDidFail: Apple event sent to %s failed. %s",
[[[self class] name] UTF8String],
[vars UTF8String]);
#else
#pragma unused (event, error)
#endif
return (NSUUID*)musicPlayerID;
}
#pragma mark BGMMusicPlayer default implementations
+ (NSArray<id<BGMMusicPlayer>>*) createInstancesWithDefaults:(BGMUserDefaults*)userDefaults {
#pragma unused (userDefaults)
return @[ [self new] ];
}
- (NSImage* __nullable) icon {
NSString* __nullable bundleID = self.bundleID;
NSString* __nullable bundlePath =
(!bundleID ? nil : [[NSWorkspace sharedWorkspace] absolutePathForAppBundleWithIdentifier:(NSString*)bundleID]);
return nil;
return (!bundlePath ? nil : [[NSWorkspace sharedWorkspace] iconForFile:(NSString*)bundlePath]);
}
- (void) dealloc {
// Remove the application launch/termination observers.
NSNotificationCenter* center = [[NSWorkspace sharedWorkspace] notificationCenter];
if (didLaunchToken) {
[center removeObserver:didLaunchToken];
}
if (didTerminateToken) {
[center removeObserver:didTerminateToken];
}
- (void) wasSelected {
_selected = YES;
}
+ (void) addToMusicPlayerClasses:(Class)musicPlayerClass {
sMusicPlayerClasses = [sMusicPlayerClasses arrayByAddingObject:musicPlayerClass];
}
+ (NSArray*) musicPlayerClasses {
return sMusicPlayerClasses;
}
+ (BGMMusicPlayer*) selectedMusicPlayer {
NSAssert(sSelectedMusicPlayer != nil, @"One of BGMMusicPlayer's subclasses should set itself as the default "
"music player (i.e. set sSelectedMusicPlayer) in its initialize method");
return sSelectedMusicPlayer;
}
+ (void) setSelectedMusicPlayer:(BGMMusicPlayer*)musicPlayer {
sSelectedMusicPlayer = musicPlayer;
}
+ (NSImage* __nullable) icon {
NSString* bundleID = (__bridge NSString*)[(id<BGMMusicPlayerProtocol>)self bundleID];
NSString* bundlePath = [[NSWorkspace sharedWorkspace] absolutePathForAppBundleWithIdentifier:bundleID];
return bundlePath == nil ? nil : [[NSWorkspace sharedWorkspace] iconForFile:bundlePath];
- (void) wasDeselected {
_selected = NO;
}
@end
@@ -0,0 +1,62 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMMusicPlayers.h
// BGMApp
//
// Copyright © 2016, 2019 Kyle Neideck
//
// Holds the music players (i.e. BGMMusicPlayer objects) available in BGMApp. Also keeps track of
// which music player is currently selected by the user.
//
// Local Includes
#import "BGMAudioDeviceManager.h"
#import "BGMMusicPlayer.h"
#import "BGMUserDefaults.h"
// System Includes
#import <Foundation/Foundation.h>
#pragma clang assume_nonnull begin
@interface BGMMusicPlayers : NSObject
// Calls initWithAudioDevices:musicPlayers: with sensible defaults.
- (instancetype) initWithAudioDevices:(BGMAudioDeviceManager*)devices
userDefaults:(BGMUserDefaults*)defaults;
// defaultMusicPlayerID is the musicPlayerID (see BGMMusicPlayer.h) of the music player that should be
// selected by default.
//
// The createInstancesWithDefaults method of each class in musicPlayerClasses will be called and
// the results will be stored in the musicPlayers property.
- (instancetype) initWithAudioDevices:(BGMAudioDeviceManager*)devices
defaultMusicPlayerID:(NSUUID*)defaultMusicPlayerID
musicPlayerClasses:(NSArray<Class<BGMMusicPlayer>>*)musicPlayerClasses
userDefaults:(BGMUserDefaults*)defaults;
@property (readonly) NSArray<id<BGMMusicPlayer>>* musicPlayers;
// The music player currently selected in the preferences menu. BGMDevice is informed when this property
// is changed.
@property id<BGMMusicPlayer> selectedMusicPlayer;
@end
#pragma clang assume_nonnull end
@@ -0,0 +1,267 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMMusicPlayers.mm
// BGMApp
//
// Copyright © 2016-2019 Kyle Neideck
//
// Self include
#import "BGMMusicPlayers.h"
// Local includes
#import "BGM_Types.h"
// Music player includes
#import "BGMiTunes.h"
#import "BGMSpotify.h"
#import "BGMVLC.h"
#import "BGMVOX.h"
#import "BGMDecibel.h"
#import "BGMHermes.h"
#import "BGMSwinsian.h"
#import "BGMMusic.h"
#import "BGMGooglePlayMusicDesktopPlayer.h"
#pragma clang assume_nonnull begin
@implementation BGMMusicPlayers {
BGMAudioDeviceManager* audioDevices;
BGMUserDefaults* userDefaults;
}
@synthesize selectedMusicPlayer = _selectedMusicPlayer;
- (instancetype) initWithAudioDevices:(BGMAudioDeviceManager*)devices
userDefaults:(BGMUserDefaults*)defaults {
// The classes handling each music player we support. If you write a new music player class, add
// it to this array.
NSArray<Class<BGMMusicPlayer>>* mpClasses = @[ [BGMVOX class],
[BGMVLC class],
[BGMSpotify class],
[BGMiTunes class],
[BGMDecibel class],
[BGMHermes class],
[BGMSwinsian class],
[BGMMusic class] ];
// We only support Google Play Music Desktop Player on macOS 10.10 and higher.
if (@available(macOS 10.10, *)) {
mpClasses = [mpClasses arrayByAddingObject:[BGMGooglePlayMusicDesktopPlayer class]];
}
return [self initWithAudioDevices:devices
defaultMusicPlayerID:[BGMiTunes sharedMusicPlayerID]
musicPlayerClasses:mpClasses
userDefaults:defaults];
}
- (instancetype) initWithAudioDevices:(BGMAudioDeviceManager*)devices
defaultMusicPlayerID:(NSUUID*)defaultMusicPlayerID
musicPlayerClasses:(NSArray<Class<BGMMusicPlayer>>*)musicPlayerClasses
userDefaults:(BGMUserDefaults*)defaults {
if ((self = [super init])) {
audioDevices = devices;
userDefaults = defaults;
// Init _musicPlayers, an array containing one object for each music player in BGMApp.
//
// Each music player class has a factory method, createInstancesWithDefaults, that returns
// all the instances of that class BGMApp will use. (Though so far it's always just one
// instance.)
NSMutableArray* musicPlayers = [NSMutableArray new];
for (Class<BGMMusicPlayer> musicPlayerClass in musicPlayerClasses) {
NSArray<id<BGMMusicPlayer>>* instances =
[musicPlayerClass createInstancesWithDefaults:userDefaults];
[musicPlayers addObjectsFromArray:instances];
}
_musicPlayers = [NSArray arrayWithArray:musicPlayers];
// Set _selectedMusicPlayer to its setting from last time BGMApp ran. (Unless this is the first run or
// that music player isn't available this time.)
[self initSelectedMusicPlayerFromUserDefaults];
if (!_selectedMusicPlayer) {
// Couldn't set _selectedMusicPlayer from user defaults, so try BGMDevice's music player property.
[self initSelectedMusicPlayerFromBGMDevice];
}
if (!_selectedMusicPlayer) {
// The user hasn't changed the music player yet, so we set the default music player as selected.
[self setSelectedMusicPlayerByID:defaultMusicPlayerID];
}
NSAssert(_selectedMusicPlayer, @"BGMMusicPlayers::initWithAudioDevices: !_selectedMusicPlayer");
}
return self;
}
- (void) initSelectedMusicPlayerFromUserDefaults {
// Load the selected music player setting from user defaults.
NSString* __nullable selectedMusicPlayerIDStr = userDefaults.selectedMusicPlayerID;
NSUUID* __nullable selectedMusicPlayerID = nil;
if (selectedMusicPlayerIDStr) {
NSString* idStrNN = selectedMusicPlayerIDStr;
selectedMusicPlayerID = [[NSUUID alloc] initWithUUIDString:idStrNN];
NSAssert(selectedMusicPlayerID,
@"BGMMusicPlayers::initSelectedMusicPlayerFromUserDefaults: !selectedMusicPlayerID");
}
if (selectedMusicPlayerID) {
NSUUID* idNN = selectedMusicPlayerID;
BOOL didChangeMusicPlayer = [self setSelectedMusicPlayerByID:idNN];
#if DEBUG
DebugMsg("BGMMusicPlayers::initSelectedMusicPlayerFromUserDefaults: %s selectedMusicPlayerIDStr=%s",
(didChangeMusicPlayer ?
"Selected music player restored from user defaults." :
"The selected music player setting found in user defaults didn't match an available music player."),
selectedMusicPlayerIDStr.UTF8String);
#else
#pragma unused (didChangeMusicPlayer)
#endif
}
}
- (void) initSelectedMusicPlayerFromBGMDevice {
// When the selected music player setting hasn't been stored in user defaults yet, we get the music player
// bundle ID from the driver and look for the music player with that bundle ID. This is mainly done for
// backwards compatability.
NSString* __nullable bundleID =
(__bridge_transfer NSString* __nullable)[audioDevices bgmDevice].GetMusicPlayerBundleID();
DebugMsg("BGMMusicPlayers::initSelectedMusicPlayerFromBGMDevice: "
"Trying to set selected music player by bundle ID (from BGMDriver). bundleID=%s",
(bundleID ? bundleID.UTF8String : "(null)"));
if (bundleID && ![bundleID isEqualToString:@""]) {
// Find any music players with a bundle ID matching the one from BGMDriver.
NSArray<id<BGMMusicPlayer>>* matchingMusicPlayers = @[ ];
for (id<BGMMusicPlayer> musicPlayer in _musicPlayers) {
NSString* bundleIDNN = bundleID;
if ([musicPlayer.bundleID isEqualToString:bundleIDNN]) {
DebugMsg("BGMMusicPlayers::initSelectedMusicPlayerFromBGMDevice: Bundle ID on BGMDevice matches %s",
musicPlayer.name.UTF8String);
matchingMusicPlayers = [matchingMusicPlayers arrayByAddingObject:musicPlayer];
}
}
// Currently, the music players all have different bundle IDs, but that might change at some point. We
// might want to consider some websites as music players, for example. So we don't change the setting
// unless the bundle ID only matches one music player.
if (matchingMusicPlayers.count == 1) {
// (Use setSelectedMusicPlayerImpl to avoid setSelectedMusicPlayer being called in init.)
[self setSelectedMusicPlayerImpl:matchingMusicPlayers[0]];
}
}
}
- (id<BGMMusicPlayer>) selectedMusicPlayer {
return _selectedMusicPlayer;
}
- (void) setSelectedMusicPlayer:(id<BGMMusicPlayer>)newSelectedMusicPlayer {
// Apparently you shouldn't call properties' setter methods in init (KVO notifications might trigger, etc.)
// so the actual work is done in setSelectedMusicPlayerImpl.
[self setSelectedMusicPlayerImpl:newSelectedMusicPlayer];
NSAssert(self.selectedMusicPlayer == newSelectedMusicPlayer,
@"BGMMusicPlayers::setSelectedMusicPlayer: selectedMusicPlayer wasn't set to the object expected");
}
- (BOOL) setSelectedMusicPlayerByID:(NSUUID*)newSelectedMusicPlayerID {
id<BGMMusicPlayer> __nullable newSelectedMusicPlayer = nil;
// Find the music player with the given ID, if there is one.
for (id<BGMMusicPlayer> musicPlayer in _musicPlayers) {
if ([musicPlayer.musicPlayerID isEqual:newSelectedMusicPlayerID]) {
NSAssert(!newSelectedMusicPlayer, @"BGMMusicPlayers::setSelectedMusicPlayerByID: Non-unique musicPlayerID");
newSelectedMusicPlayer = musicPlayer;
}
}
if (newSelectedMusicPlayer) {
// (Use setSelectedMusicPlayerImpl to avoid setSelectedMusicPlayer being called in init.)
id<BGMMusicPlayer> newPlayerNN = newSelectedMusicPlayer;
[self setSelectedMusicPlayerImpl:newPlayerNN];
return YES;
} else {
return NO;
}
}
- (void) setSelectedMusicPlayerImpl:(id<BGMMusicPlayer>)newSelectedMusicPlayer {
NSAssert([_musicPlayers containsObject:newSelectedMusicPlayer],
@"BGMMusicPlayers::setSelectedMusicPlayerImpl: Only the music players in the musicPlayers array can be selected. "
"newSelectedMusicPlayer=%@",
newSelectedMusicPlayer.name);
if (_selectedMusicPlayer == newSelectedMusicPlayer) {
DebugMsg("BGMMusicPlayers::setSelectedMusicPlayerImpl: %s is already the selected music "
"player.",
_selectedMusicPlayer.name.UTF8String);
return;
}
// Tell the current music player (object) a different player has been selected.
[_selectedMusicPlayer wasDeselected];
_selectedMusicPlayer = newSelectedMusicPlayer;
DebugMsg("BGMMusicPlayers::setSelectedMusicPlayerImpl: Set selected music player to %s",
_selectedMusicPlayer.name.UTF8String);
// Update the selected music player on the driver.
[self updateBGMDeviceMusicPlayerProperties];
// Save the new setting in user defaults.
userDefaults.selectedMusicPlayerID = _selectedMusicPlayer.musicPlayerID.UUIDString;
// Tell the music player (object) it's been selected.
[_selectedMusicPlayer wasSelected];
}
- (void) updateBGMDeviceMusicPlayerProperties {
// Send the music player's PID and/or bundle ID to the driver.
NSAssert(self.selectedMusicPlayer.pid || self.selectedMusicPlayer.bundleID,
@"BGMMusicPlayers::updateBGMDeviceMusicPlayerProperties: Music player has neither bundle ID nor PID");
if (self.selectedMusicPlayer.pid) {
[audioDevices bgmDevice].SetMusicPlayerProcessID((__bridge CFNumberRef)self.selectedMusicPlayer.pid);
}
if (self.selectedMusicPlayer.bundleID) {
[audioDevices bgmDevice].SetMusicPlayerBundleID((__bridge CFStringRef)self.selectedMusicPlayer.bundleID);
}
}
@end
#pragma clang assume_nonnull end
@@ -0,0 +1,69 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMScriptingBridge.h
// BGMApp
//
// Copyright © 2016, 2018 Kyle Neideck
//
// A wrapper around Scripting Bridge's SBApplication that tries to avoid ever launching the application.
//
// We use Scripting Bridge to communicate with music player apps, which we never want to launch
// ourselves. But creating an SBApplication for an app, or sending messages/events to an existing one,
// can launch the app.
//
// As a workaround, this class has an SBApplication property, application (see below), which is nil
// unless the music player app is running. That way messages sent while the app is closed are ignored.
//
// Local Includes
#import "BGMMusicPlayer.h"
// System Includes
#import <Cocoa/Cocoa.h>
#import <ScriptingBridge/ScriptingBridge.h>
#pragma clang assume_nonnull begin
@interface BGMScriptingBridge : NSObject <SBApplicationDelegate>
// Only keeps a weak ref to musicPlayer.
- (instancetype) initWithMusicPlayer:(id<BGMMusicPlayer>)musicPlayer;
// If the music player application is running, this property is the Scripting Bridge object representing
// it. If not, it's set to nil. Used to send Apple events to the music player app.
@property (readonly) __kindof SBApplication* __nullable application;
// macOS 10.14 requires the user's permission to send Apple Events. If the music player that owns
// this object (i.e. the one passed to initWithMusicPlayer) is currently the selected music player
// and the user hasn't already given us permission to send it Apple Events, this method asks the
// user for permission.
- (void) ensurePermission;
// SBApplicationDelegate
// On 10.11, SBApplicationDelegate.h declares eventDidFail with a non-null return type, but the docs
// specifically say that returning nil is allowed.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wnullability"
- (id __nullable) eventDidFail:(const AppleEvent*)event withError:(NSError*)error;
#pragma clang diagnostic pop
@end
#pragma clang assume_nonnull end
@@ -0,0 +1,175 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMScriptingBridge.m
// BGMApp
//
// Copyright © 2016-2019 Kyle Neideck
//
// Self Include
#import "BGMScriptingBridge.h"
// Local Includes
#import "BGM_Utils.h"
#import "BGMAppWatcher.h"
// PublicUtility Includes
#import "CADebugMacros.h"
#pragma clang assume_nonnull begin
@implementation BGMScriptingBridge {
id<BGMMusicPlayer> __weak _musicPlayer;
BGMAppWatcher* appWatcher;
}
@synthesize application = _application;
- (instancetype) initWithMusicPlayer:(id<BGMMusicPlayer>)musicPlayer {
if ((self = [super init])) {
_musicPlayer = musicPlayer;
[self initApplication];
}
return self;
}
- (void) initApplication {
NSString* bundleID = _musicPlayer.bundleID;
BGMAssert(bundleID, "Music players need a bundle ID to use ScriptingBridge");
BGMScriptingBridge* __weak weakSelf = self;
void (^createSBApplication)(void) = ^{
BGMScriptingBridge* strongSelf = weakSelf;
strongSelf->_application = [SBApplication applicationWithBundleIdentifier:bundleID];
// TODO: The SBApplication will still keep a strong ref to this object, so we would have to
// make a separate delegate object to avoid the retain cycle. Not currently a problem
// because we only ever create instances that live forever.
strongSelf->_application.delegate = strongSelf;
};
// Add observers that create/destroy the SBApplication when the music player is launched/terminated. We
// only create the SBApplication when the music player is open. If it isn't open, creating the
// SBApplication or sending it events could launch the music player. Whether or not it does depends on
// the music player, and possibly the version of the music player, so to be safe we assume they all do.
//
// From the docs for SBApplication's applicationWithBundleIdentifier method:
// "For applications that declare themselves to have a dynamic scripting interface, this method will
// launch the application if it is not already running."
appWatcher =
[[BGMAppWatcher alloc] initWithBundleID:bundleID
appLaunched:^{
DebugMsg("BGMScriptingBridge::initApplication: %s launched",
bundleID.UTF8String);
createSBApplication();
[weakSelf ensurePermission];
}
appTerminated:^{
BGMScriptingBridge* strongSelf = weakSelf;
DebugMsg("BGMScriptingBridge::initApplication: %s terminated",
bundleID.UTF8String);
strongSelf->_application = nil;
}];
// Create the SBApplication if the music player is already running.
if ([NSRunningApplication runningApplicationsWithBundleIdentifier:bundleID].count > 0) {
createSBApplication();
}
}
- (void) ensurePermission {
// Skip this check if running on a version of macOS before 10.14. In that case, we don't require
// user permission to send Apple Events. Also skip it if compiling on an earlier version.
#if MAC_OS_X_VERSION_MAX_ALLOWED >= 101400 // MAC_OS_X_VERSION_10_14
if (@available(macOS 10.14, *)) {
id<BGMMusicPlayer> musicPlayer = _musicPlayer;
if (!musicPlayer.selected) {
DebugMsg("BGMScriptingBridge::ensurePermission: %s not selected. Nothing to do.",
musicPlayer.name.UTF8String);
return;
}
if (!musicPlayer.running) {
DebugMsg("BGMScriptingBridge::ensurePermission: %s not running. Nothing to do.",
musicPlayer.name.UTF8String);
return;
}
// AEDeterminePermissionToAutomateTarget will block if it has to show a dialog to the user
// to ask for permission, so dispatch this to make sure it doesn't run on the main thread.
dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{
NSAppleEventDescriptor* musicPlayerEventDescriptor =
[NSAppleEventDescriptor
descriptorWithBundleIdentifier:(NSString*)musicPlayer.bundleID];
OSStatus status =
AEDeterminePermissionToAutomateTarget(musicPlayerEventDescriptor.aeDesc,
typeWildCard,
typeWildCard,
true);
DebugMsg("BGMScriptingBridge::ensurePermission: "
"Apple Events permission status for %s: %d",
musicPlayer.name.UTF8String,
status);
if (status != noErr) {
// TODO: If they deny permission, we should grey-out the auto-pause menu item and
// add something to the UI that indicates the problem. Maybe a warning icon
// that shows an explanation when you hover your mouse over it. (We can't just
// ask them again later because the API doesn't support it. They can only fix
// it in System Preferences.)
NSLog(@"BGMScriptingBridge::ensurePermission: Permission denied for %@. status=%d",
musicPlayer.name,
status);
}
});
} else {
DebugMsg("BGMScriptingBridge::ensurePermission: Not macOS 10.14+. Nothing to do.");
}
#endif /* MAC_OS_X_VERSION_MAX_ALLOWED >= 101400 */
}
#pragma mark SBApplicationDelegate
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wnullability" // See explanation in the header file.
- (id __nullable) eventDidFail:(const AppleEvent*)event withError:(NSError*)error {
#pragma clang diagnostic pop
// So far, this just logs the error.
#if DEBUG
NSString* vars = [NSString stringWithFormat:@"event='%4.4s' error=%@ application=%@",
(char*)&(event->descriptorType), error, self.application];
DebugMsg("BGMScriptingBridge::eventDidFail: Apple event sent to %s failed. %s",
_musicPlayer.bundleID.UTF8String,
vars.UTF8String);
#else
#pragma unused (event, error)
#endif
return nil;
}
@end
#pragma clang assume_nonnull end
+1 -1
View File
@@ -24,7 +24,7 @@
#import "BGMMusicPlayer.h"
@interface BGMSpotify : BGMMusicPlayer
@interface BGMSpotify : BGMMusicPlayerBase<BGMMusicPlayer>
@end
+40 -27
View File
@@ -17,7 +17,7 @@
// BGMSpotify.m
// BGMApp
//
// Copyright © 2016 Kyle Neideck
// Copyright © 2016-2018 Kyle Neideck
//
// Spotify's AppleScript API looks to have been designed to match iTunes', so this file is basically
// just s/iTunes/Spotify/ on BGMiTunes.m
@@ -29,39 +29,58 @@
// Auto-generated Scripting Bridge header
#import "Spotify.h"
// Local Includes
#import "BGMScriptingBridge.h"
// PublicUtility Includes
#undef CoreAudio_ThreadStampMessages
#define CoreAudio_ThreadStampMessages 0 // Requires C++
#include "CADebugMacros.h"
#import "CADebugMacros.h"
@implementation BGMSpotify
#pragma clang assume_nonnull begin
BGM_MUSIC_PLAYER_DEFAULT_LOAD_METHOD
+ (NSString*) name {
return @"Spotify";
@implementation BGMSpotify {
BGMScriptingBridge* scriptingBridge;
}
- (CFNumberRef) pid {
return NULL;
}
+ (CFStringRef) bundleID {
return CFSTR("com.spotify.client");
- (instancetype) init {
// If you're copying this class, replace the ID string with a new one generated by uuidgen. (Command line tool.)
if ((self = [super initWithMusicPlayerID:[BGMMusicPlayerBase makeID:@"EC2A907F-8515-4687-9570-1BF63176E6D8"]
name:@"Spotify"
bundleID:@"com.spotify.client"])) {
scriptingBridge = [[BGMScriptingBridge alloc] initWithMusicPlayer:self];
}
return self;
}
- (SpotifyApplication* __nullable) spotify {
return (SpotifyApplication*) self.sbApplication;
return (SpotifyApplication* __nullable)scriptingBridge.application;
}
- (void) wasSelected {
[super wasSelected];
[scriptingBridge ensurePermission];
}
- (BOOL) isRunning {
return self.spotify && [self.spotify isRunning];
// Note that this will return NO if is self.spotify is nil (i.e. Spotify isn't running).
return self.spotify.running;
}
// isPlaying and isPaused check self.running first just in case Spotify is closed but self.spotify hasn't become
// nil yet. In that case, reading self.spotify.playerState could make Scripting Bridge open Spotify.
- (BOOL) isPlaying {
return self.running && (self.spotify.playerState == SpotifyEPlSPlaying);
}
- (BOOL) isPaused {
return self.running && (self.spotify.playerState == SpotifyEPlSPaused);
}
- (BOOL) pause {
// isPlaying checks isRunning, so we don't need to check it here and waste an Apple event
BOOL wasPlaying = [self isPlaying];
BOOL wasPlaying = self.playing;
if (wasPlaying) {
DebugMsg("BGMSpotify::pause: Pausing Spotify");
@@ -73,7 +92,7 @@ BGM_MUSIC_PLAYER_DEFAULT_LOAD_METHOD
- (BOOL) unpause {
// isPaused checks isRunning, so we don't need to check it here and waste an Apple event
BOOL wasPaused = [self isPaused];
BOOL wasPaused = self.paused;
if (wasPaused) {
DebugMsg("BGMSpotify::unpause: Unpausing Spotify");
@@ -83,13 +102,7 @@ BGM_MUSIC_PLAYER_DEFAULT_LOAD_METHOD
return wasPaused;
}
- (BOOL) isPlaying {
return [self isRunning] && [self.spotify playerState] == SpotifyEPlSPlaying;
}
- (BOOL) isPaused {
return [self isRunning] && [self.spotify playerState] == SpotifyEPlSPaused;
}
@end
#pragma clang assume_nonnull end
+34
View File
@@ -0,0 +1,34 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMSwinsian.h
// BGMApp
//
// Copyright © 2018 Kyle Neideck
//
// Superclass/Protocol Import
#import "BGMMusicPlayer.h"
#pragma clang assume_nonnull begin
@interface BGMSwinsian : BGMMusicPlayerBase<BGMMusicPlayer>
@end
#pragma clang assume_nonnull end
+109
View File
@@ -0,0 +1,109 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMSwinsian.m
// BGMApp
//
// Copyright © 2018 Kyle Neideck
//
// Self Include
#import "BGMSwinsian.h"
// Auto-generated Scripting Bridge header
#import "Swinsian.h"
// Local Includes
#import "BGMScriptingBridge.h"
// PublicUtility Includes
#import "CADebugMacros.h"
#pragma clang assume_nonnull begin
@implementation BGMSwinsian {
BGMScriptingBridge* scriptingBridge;
}
- (instancetype) init {
// If you're copying this class, replace the ID string with a new one generated by uuidgen (the
// command line tool).
NSUUID* musicPlayerID = [BGMMusicPlayerBase makeID:@"B74D18F6-DFF7-4D88-B719-429CFF98CFFA"];
if ((self = [super initWithMusicPlayerID:musicPlayerID
name:@"Swinsian"
bundleID:@"com.swinsian.Swinsian"])) {
scriptingBridge = [[BGMScriptingBridge alloc] initWithMusicPlayer:self];
}
return self;
}
- (SwinsianApplication* __nullable) swinsian {
return (SwinsianApplication* __nullable)scriptingBridge.application;
}
- (void) wasSelected {
[super wasSelected];
[scriptingBridge ensurePermission];
}
- (BOOL) isRunning {
// Note that this will return NO if is self.swinsian is nil (i.e. Swinsian isn't running).
return self.swinsian.running;
}
// isPlaying and isPaused check self.running first just in case Swinsian is closed but self.swinsian
// hasn't become nil yet. In that case, reading self.swinsian.playerState could make Scripting
// Bridge open Swinsian.
- (BOOL) isPlaying {
return self.running && (self.swinsian.playerState == SwinsianPlayerStatePlaying);
}
- (BOOL) isPaused {
return self.running && (self.swinsian.playerState == SwinsianPlayerStatePaused);
}
- (BOOL) pause {
// isPlaying checks isRunning, so we don't need to check it here and waste an Apple event.
BOOL wasPlaying = self.playing;
if (wasPlaying) {
DebugMsg("BGMSwinsian::pause: Pausing Swinsian");
[self.swinsian pause];
}
return wasPlaying;
}
- (BOOL) unpause {
// isPaused checks isRunning, so we don't need to check it here and waste an Apple event.
BOOL wasPaused = self.paused;
if (wasPaused) {
DebugMsg("BGMSwinsian::unpause: Unpausing Swinsian");
[self.swinsian play];
}
return wasPaused;
}
@end
#pragma clang assume_nonnull end
+1 -1
View File
@@ -24,7 +24,7 @@
#import "BGMMusicPlayer.h"
@interface BGMVLC : BGMMusicPlayer
@interface BGMVLC : BGMMusicPlayerBase<BGMMusicPlayer>
@end
+43 -32
View File
@@ -17,7 +17,7 @@
// BGMVLC.m
// BGMApp
//
// Copyright © 2016 Kyle Neideck
// Copyright © 2016-2018 Kyle Neideck
// Portions copyright (C) 2012 Peter Ljunglöf. All rights reserved.
//
@@ -27,43 +27,61 @@
// Auto-generated Scripting Bridge header
#import "VLC.h"
// Local Includes
#import "BGMScriptingBridge.h"
// PublicUtility Includes
#undef CoreAudio_ThreadStampMessages
#define CoreAudio_ThreadStampMessages 0 // Requires C++
#include "CADebugMacros.h"
#import "CADebugMacros.h"
@implementation BGMVLC
#pragma clang assume_nonnull begin
BGM_MUSIC_PLAYER_DEFAULT_LOAD_METHOD
+ (NSString*) name {
return @"VLC";
@implementation BGMVLC {
BGMScriptingBridge* scriptingBridge;
}
- (CFNumberRef) pid {
return NULL;
}
+ (CFStringRef) bundleID {
return CFSTR("org.videolan.vlc");
- (instancetype) init {
if ((self = [super initWithMusicPlayerID:[BGMMusicPlayerBase makeID:@"5226F4B9-C740-4045-A273-4B8EABC0E8FC"]
name:@"VLC"
bundleID:@"org.videolan.vlc"])) {
scriptingBridge = [[BGMScriptingBridge alloc] initWithMusicPlayer:self];
}
return self;
}
- (VLCApplication* __nullable) vlc {
return (VLCApplication*) self.sbApplication;
return (VLCApplication*)scriptingBridge.application;
}
- (void) wasSelected {
[super wasSelected];
[scriptingBridge ensurePermission];
}
- (BOOL) isRunning {
return self.vlc && [self.vlc isRunning];
return self.vlc.running;
}
// isPlaying and isPaused check self.running first just in case VLC is closed but self.vlc hasn't become
// nil yet. In that case, reading other properties of self.vlc could make Scripting Bridge open VLC.
- (BOOL) isPlaying {
return self.running && self.vlc.playing;
}
- (BOOL) isPaused {
// VLC is paused if it has a file open but isn't playing it
return self.running && (self.vlc.nameOfCurrentItem != nil) && !self.vlc.playing;
}
- (BOOL) pause {
// isPlaying checks isRunning, so we don't need to check it here and waste an Apple event
BOOL wasPlaying = [self isPlaying];
BOOL wasPlaying = self.playing;
if (wasPlaying) {
DebugMsg("BGMVLC::pause: Pausing VLC");
[self togglePlay];
[BGMVLC togglePlay];
}
return wasPlaying;
@@ -71,34 +89,27 @@ BGM_MUSIC_PLAYER_DEFAULT_LOAD_METHOD
- (BOOL) unpause {
// isPaused checks isRunning, so we don't need to check it here and waste an Apple event
BOOL wasPaused = [self isPaused];
BOOL wasPaused = self.paused;
if (wasPaused) {
DebugMsg("BGMVLC::unpause: Unpausing VLC");
[self togglePlay];
[BGMVLC togglePlay];
}
return wasPaused;
}
- (BOOL) isPlaying {
return [self isRunning] && [self.vlc playing];
}
- (BOOL) isPaused {
// VLC is paused if it has a file open but isn't playing it
return [self isRunning] && [self.vlc nameOfCurrentItem] != nil && ![self.vlc playing];
}
// This is from SubTTS's STVLCPlayer class:
// https://github.com/heatherleaf/subtts-mac/blob/master/SubTTS/STVLCPlayer.m
//
// VLC's Scripting Bridge interface doesn't seem to have a cleaner way to do this.
- (void) togglePlay {
NSString* src = @"tell application \"VLC\" to play";
+ (void) togglePlay {
NSString* src = @"tell application id \"org.videolan.vlc\" to play";
NSAppleScript* script = [[NSAppleScript alloc] initWithSource:src];
[script executeAndReturnError:nil];
}
@end
#pragma clang assume_nonnull end
@@ -14,7 +14,7 @@
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMVox.h
// BGMVOX.h
// BGMApp
//
// Copyright © 2016 Kyle Neideck
@@ -24,7 +24,7 @@
#import "BGMMusicPlayer.h"
@interface BGMVox : BGMMusicPlayer
@interface BGMVOX : BGMMusicPlayerBase<BGMMusicPlayer>
@end
+105
View File
@@ -0,0 +1,105 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMVOX.m
// BGMApp
//
// Copyright © 2016-2018 Kyle Neideck
//
// Self Include
#import "BGMVOX.h"
// Auto-generated Scripting Bridge header
#import "VOX.h"
// Local Includes
#import "BGMScriptingBridge.h"
// PublicUtility Includes
#import "CADebugMacros.h"
#pragma clang assume_nonnull begin
@implementation BGMVOX {
BGMScriptingBridge* scriptingBridge;
}
- (instancetype) init {
if ((self = [super initWithMusicPlayerID:[BGMMusicPlayerBase makeID:@"26498C5D-C18B-4689-8B41-9DA91A78FFAD"]
name:@"VOX"
bundleID:@"com.coppertino.Vox"])) {
scriptingBridge = [[BGMScriptingBridge alloc] initWithMusicPlayer:self];
}
return self;
}
- (VoxApplication* __nullable) vox {
return (VoxApplication*)scriptingBridge.application;
}
- (void) wasSelected {
[super wasSelected];
[scriptingBridge ensurePermission];
}
- (BOOL) isRunning {
return self.vox.running;
}
// isPlaying and isPaused check self.running first just in case VOX is closed but self.vox hasn't become
// nil yet. In that case, reading self.vox.playerState could make Scripting Bridge open VOX.
//
// VOX's comment for its playerState property says "playing = 1, paused = 0".
- (BOOL) isPlaying {
return self.running && (self.vox.playerState == 1);
}
- (BOOL) isPaused {
return self.running && (self.vox.playerState == 0);
}
- (BOOL) pause {
// isPlaying checks isRunning, so we don't need to check it here and waste an Apple event
BOOL wasPlaying = self.playing;
if (wasPlaying) {
DebugMsg("BGMVOX::pause: Pausing VOX");
[self.vox pause];
}
return wasPlaying;
}
- (BOOL) unpause {
// isPaused checks isRunning, so we don't need to check it here and waste an Apple event
BOOL wasPaused = self.paused;
if (wasPaused) {
DebugMsg("BGMVOX::unpause: Unpausing VOX");
[self.vox playpause];
}
return wasPaused;
}
@end
#pragma clang assume_nonnull end
-94
View File
@@ -1,94 +0,0 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMVox.m
// BGMApp
//
// Copyright © 2016 Kyle Neideck
//
// Self Include
#import "BGMVox.h"
// Auto-generated Scripting Bridge header
#import "Vox.h"
// PublicUtility Includes
#undef CoreAudio_ThreadStampMessages
#define CoreAudio_ThreadStampMessages 0 // Requires C++
#include "CADebugMacros.h"
@implementation BGMVox
BGM_MUSIC_PLAYER_DEFAULT_LOAD_METHOD
+ (NSString*) name {
return @"VOX";
}
- (CFNumberRef) pid {
return NULL;
}
+ (CFStringRef) bundleID {
return CFSTR("com.coppertino.Vox");
}
- (VoxApplication* __nullable) vox {
return (VoxApplication*) self.sbApplication;
}
- (BOOL) isRunning {
return self.vox && [self.vox isRunning];
}
- (BOOL) pause {
// isPlaying checks isRunning, so we don't need to check it here and waste an Apple event
BOOL wasPlaying = [self isPlaying];
if (wasPlaying) {
DebugMsg("BGMVox::pause: Pausing VOX");
[self.vox pause];
}
return wasPlaying;
}
- (BOOL) unpause {
// isPaused checks isRunning, so we don't need to check it here and waste an Apple event
BOOL wasPaused = [self isPaused];
if (wasPaused) {
DebugMsg("BGMVox::unpause: Unpausing VOX");
[self.vox playpause];
}
return wasPaused;
}
// Vox's comment for playerState says "playing = 1, paused = 0"
- (BOOL) isPlaying {
return [self isRunning] && [self.vox playerState] == 1;
}
- (BOOL) isPaused {
return [self isRunning] && [self.vox playerState] == 0;
}
@end
+5 -2
View File
@@ -24,8 +24,11 @@
#import "BGMMusicPlayer.h"
@interface BGMiTunes : BGMMusicPlayer
@interface BGMiTunes : BGMMusicPlayerBase<BGMMusicPlayer>
// The music player ID (see BGMMusicPlayer.h) used by BGMiTunes instances. (Though BGMApp only ever creates one instance of
// BGMiTunes, sharedMusicPlayerID is exposed so iTunes can be set as the default music player.)
+ (NSUUID*) sharedMusicPlayerID;
@end
+45 -33
View File
@@ -17,7 +17,7 @@
// BGMiTunes.m
// BGMApp
//
// Copyright © 2016 Kyle Neideck
// Copyright © 2016-2018 Kyle Neideck
//
// Self Include
@@ -26,44 +26,62 @@
// Auto-generated Scripting Bridge header
#import "iTunes.h"
// Local Includes
#import "BGMScriptingBridge.h"
// PublicUtility Includes
#undef CoreAudio_ThreadStampMessages
#define CoreAudio_ThreadStampMessages 0 // Requires C++
#include "CADebugMacros.h"
#import "CADebugMacros.h"
@implementation BGMiTunes
#pragma clang assume_nonnull begin
+ (void) load {
BGM_MUSIC_PLAYER_ADD_SELF_TO_CLASSES_LIST
@implementation BGMiTunes {
BGMScriptingBridge* scriptingBridge;
}
+ (NSUUID*) sharedMusicPlayerID {
NSUUID* __nullable musicPlayerID = [[NSUUID alloc] initWithUUIDString:@"7B62B5BF-CF90-4938-84E3-F16DEDC3F608"];
NSAssert(musicPlayerID, @"BGMiTunes::sharedMusicPlayerID: !musicPlayerID");
return (NSUUID*)musicPlayerID;
}
- (instancetype) init {
if ((self = [super initWithMusicPlayerID:[BGMiTunes sharedMusicPlayerID]
name:@"iTunes"
bundleID:@"com.apple.iTunes"])) {
scriptingBridge = [[BGMScriptingBridge alloc] initWithMusicPlayer:self];
}
// iTunes is selected as the music player when the user hasn't changed the setting yet
[self setSelectedMusicPlayer:[BGMiTunes new]];
}
+ (NSString*) name {
return @"iTunes";
}
- (CFNumberRef) pid {
return NULL;
}
+ (CFStringRef) bundleID {
return CFSTR("com.apple.iTunes");
return self;
}
- (iTunesApplication* __nullable) iTunes {
return (iTunesApplication*) self.sbApplication;
return (iTunesApplication*)scriptingBridge.application;
}
- (void) wasSelected {
[super wasSelected];
[scriptingBridge ensurePermission];
}
- (BOOL) isRunning {
return self.iTunes && [self.iTunes isRunning];
return self.iTunes.running;
}
// isPlaying and isPaused check self.running first just in case iTunes is closed but self.iTunes hasn't become
// nil yet. In that case, reading self.iTunes.playerState could make Scripting Bridge open iTunes.
- (BOOL) isPlaying {
return self.running && (self.iTunes.playerState == iTunesEPlSPlaying);
}
- (BOOL) isPaused {
return self.running && (self.iTunes.playerState == iTunesEPlSPaused);
}
- (BOOL) pause {
// isPlaying checks isRunning, so we don't need to check it here and waste an Apple event
BOOL wasPlaying = [self isPlaying];
BOOL wasPlaying = self.playing;
if (wasPlaying) {
DebugMsg("BGMiTunes::pause: Pausing iTunes");
@@ -75,7 +93,7 @@
- (BOOL) unpause {
// isPaused checks isRunning, so we don't need to check it here and waste an Apple event
BOOL wasPaused = [self isPaused];
BOOL wasPaused = self.paused;
if (wasPaused) {
DebugMsg("BGMiTunes::unpause: Unpausing iTunes");
@@ -85,13 +103,7 @@
return wasPaused;
}
- (BOOL) isPlaying {
return [self isRunning] && [self.iTunes playerState] == iTunesEPlSPlaying;
}
- (BOOL) isPaused {
return [self isRunning] && [self.iTunes playerState] == iTunesEPlSPaused;
}
@end
#pragma clang assume_nonnull end
+181
View File
@@ -0,0 +1,181 @@
/*
* Decibel.h
*
* Generated with
* sdef /Applications/Decibel.app | sdp -fh --basename Decibel
*/
#import <AppKit/AppKit.h>
#import <ScriptingBridge/ScriptingBridge.h>
@class DecibelApplication, DecibelDocument, DecibelWindow, DecibelApplication, DecibelTrack;
enum DecibelSaveOptions {
DecibelSaveOptionsYes = 'yes ' /* Save the file. */,
DecibelSaveOptionsNo = 'no ' /* Do not save the file. */,
DecibelSaveOptionsAsk = 'ask ' /* Ask the user whether or not to save the file. */
};
typedef enum DecibelSaveOptions DecibelSaveOptions;
enum DecibelPrintingErrorHandling {
DecibelPrintingErrorHandlingStandard = 'lwst' /* Standard PostScript error handling */,
DecibelPrintingErrorHandlingDetailed = 'lwdt' /* print a detailed report of PostScript errors */
};
typedef enum DecibelPrintingErrorHandling DecibelPrintingErrorHandling;
enum DecibelShuffleMode {
DecibelShuffleModeOff = 'off ' /* Off */,
DecibelShuffleModeTrack = 'trck' /* Track */,
DecibelShuffleModeAlbum = 'albm' /* Album */,
DecibelShuffleModeArtist = 'arts' /* Artist */
};
typedef enum DecibelShuffleMode DecibelShuffleMode;
enum DecibelRepeatMode {
DecibelRepeatModeOff = 'off ' /* Off */,
DecibelRepeatModeTrack = 'trck' /* Track */,
DecibelRepeatModeAlbum = 'albm' /* Album */,
DecibelRepeatModeArtist = 'arts' /* Artist */,
DecibelRepeatModeAll = 'all ' /* All */
};
typedef enum DecibelRepeatMode DecibelRepeatMode;
@protocol DecibelGenericMethods
- (void) closeSaving:(DecibelSaveOptions)saving savingIn:(NSURL *)savingIn; // Close a document.
- (void) saveIn:(NSURL *)in_ as:(id)as; // Save a document.
- (void) printWithProperties:(NSDictionary *)withProperties printDialog:(BOOL)printDialog; // Print a document.
- (void) delete; // Delete an object.
- (void) duplicateTo:(SBObject *)to withProperties:(NSDictionary *)withProperties; // Copy an object.
- (void) moveTo:(SBObject *)to; // Move an object to a new location.
@end
/*
* Standard Suite
*/
// The application's top-level scripting object.
@interface DecibelApplication : SBApplication
- (SBElementArray<DecibelDocument *> *) documents;
- (SBElementArray<DecibelWindow *> *) windows;
@property (copy, readonly) NSString *name; // The name of the application.
@property (readonly) BOOL frontmost; // Is this the active application?
@property (copy, readonly) NSString *version; // The version number of the application.
- (id) open:(id)x; // Open a document.
- (void) print:(id)x withProperties:(NSDictionary *)withProperties printDialog:(BOOL)printDialog; // Print a document.
- (void) quitSaving:(DecibelSaveOptions)saving; // Quit the application.
- (BOOL) exists:(id)x; // Verify that an object exists.
- (void) play; // Begin audio playback
- (void) pause; // Suspend audio playback
- (void) stop; // Stop audio playback
- (void) playPause; // Begin or suspend audio playback
- (void) seekForward; // Seek forward three seconds
- (void) seekBackward; // Seek backward three seconds
- (void) playSelection; // Play the selected track, or the first track if more than one are selected
- (void) playPreviousTrack; // Play the previous logical track in the playlist
- (void) playNextTrack; // Play the next logical track in the playlist
- (void) addFile:(NSURL *)x; // Add a file to the playlist
- (void) playFile:(NSURL *)x; // Add a file to the playlist and play it
- (void) playTrackAtIndex:(NSInteger)x; // Play a track in the playlist
- (void) increaseDeviceVolume; // Increase the device volume
- (void) decreaseDeviceVolume; // Decrease the device volume
- (void) increaseDigitalVolume; // Increase the digital volume
- (void) decreaseDigitalVolume; // Decrease the digital volume
- (void) clearPlaylist; // Clear the playlist
- (void) scramblePlaylist; // Scramble the playlist
@end
// A document.
@interface DecibelDocument : SBObject <DecibelGenericMethods>
@property (copy, readonly) NSString *name; // Its name.
@property (readonly) BOOL modified; // Has it been modified since the last save?
@property (copy, readonly) NSURL *file; // Its location on disk, if it has one.
@end
// A window.
@interface DecibelWindow : SBObject <DecibelGenericMethods>
@property (copy, readonly) NSString *name; // The title of the window.
- (NSInteger) id; // The unique identifier of the window.
@property NSInteger index; // The index of the window, ordered front to back.
@property NSRect bounds; // The bounding rectangle of the window.
@property (readonly) BOOL closeable; // Does the window have a close button?
@property (readonly) BOOL miniaturizable; // Does the window have a minimize button?
@property BOOL miniaturized; // Is the window minimized right now?
@property (readonly) BOOL resizable; // Can the window be resized?
@property BOOL visible; // Is the window visible right now?
@property (readonly) BOOL zoomable; // Does the window have a zoom button?
@property BOOL zoomed; // Is the window zoomed right now?
@property (copy, readonly) DecibelDocument *document; // The document whose contents are displayed in the window.
@end
/*
* Decibel Scripting Suite
*/
// The Decibel application class.
@interface DecibelApplication (DecibelScriptingSuite)
- (SBElementArray<DecibelTrack *> *) tracks;
@property (readonly) BOOL playing; // Is the player currently playing?
@property (readonly) BOOL shuffling; // Is the player currently shuffling?
@property (readonly) BOOL repeating; // Is the player currently repeating?
@property (copy, readonly) DecibelTrack *nowPlaying; // The track that is currently playing?
@property double deviceVolume; // The current device volume
@property double digitalVolume; // The current digital volume
@property double playbackPosition; // The current playback position [0, 1]
@property double playbackTime; // The current playback time in seconds
@property (readonly) BOOL canPlay; // Is the player currently playing?
@property (readonly) BOOL canPlayPreviousTrack; // Is the player currently playing?
@property (readonly) BOOL canPlayNextTrack; // Is the player currently playing?
@property (readonly) BOOL canAdjustDeviceVolume; // Can the device volume be adjusted?
@property DecibelShuffleMode shuffleMode; // Player shuffle mode
@property DecibelRepeatMode repeatMode; // Player repeat mode
@property (copy, readonly) SBObject *currentPlaylist; // The current playlist
@end
// A track in the playlist
@interface DecibelTrack : SBObject <DecibelGenericMethods>
- (NSString *) id; // The track's ID
@property (copy, readonly) NSURL *file; // The track's location
@property (readonly) double duration; // The track's duration in seconds
@property (readonly) double sampleRate; // The track's sample rate in Hz
@property (readonly) NSInteger bitDepth; // The bit depth
@property (readonly) NSInteger channels; // The track's channels
@property (copy) NSString *title; // The track's title
@property (copy) NSString *artist; // The track's artist
@property (copy) NSString *albumTitle; // The track's album title
@property (copy) NSString *albumArtist; // The track's album artist
@property NSInteger trackNumber; // The track's track number
@property NSInteger trackTotal; // The total number of tracks on the album
@property NSInteger discNumber; // The disc number containing the track
@property NSInteger discTotal; // The total number of discs (for multidisc albums)
@property BOOL partOfACompilation; // Is the track part of a compilation?
@property (copy) NSString *genre; // The track's genre
@property (copy) NSString *composer; // The track's composer
@property (copy) NSString *releaseDate; // The track's release date
@property (copy) NSString *ISRC; // The track's ISRC
@property (copy) id MCN; // The track's MCN
- (void) playTrack; // Play a track in the playlist
@end
@@ -0,0 +1,190 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// GooglePlayMusicDesktopPlayer.js
// BGMApp
//
// Copyright © 2019 Kyle Neideck
//
// The specification for GPMDP's API:
// https://github.com/MarshallOfSound/Google-Play-Music-Desktop-Player-UNOFFICIAL-/blob/master/docs/PlaybackAPI_WebSocket.md
try {
window._log = msg => {
window.webkit.messageHandlers.log.postMessage(msg);
};
// Global JS error handler.
window.onerror = (msg, url, line, col, error) => {
let extra = !col ? '' : '\nColumn: ' + col;
extra += !error ? '' : '\nError: ' + error;
// TODO: I'm not sure this log message is ever actually useful.
window._log('Error: ' + msg + '\nURL: ' + url + '\nLine: ' + line + extra);
window.webkit.messageHandlers.error.postMessage(error);
};
// Send a JSON message to GPMDP.
//
// If we're connecting, this function will return immediately and the message will be sent after we
// finish connecting. Logs an error and returns if window.connect() hasn't been called yet.
window._sendJSON = json => {
if (window._wsPromise) {
window._wsPromise.then(() => {
window._sendJSONImmediate(json);
}).catch(error => {
// TODO: Is there anything else we can do? Retries?
window._log('Error sending JSON: ' + JSON.stringify(error));
});
} else {
window._log('Error: No WebSocket promise. Discarding JSON message: ' +
JSON.stringify(json));
}
};
// Send a JSON message to GPMDP, but don't wait if we're in the process of connecting.
//
// Logs an error and returns if window.connect() hasn't been called yet. The authCode param is
// optional and only used to hide the code in log messages.
window._sendJSONImmediate = (json, authCode) => {
let jsonStr = JSON.stringify(json);
let jsonStrSanitized = authCode ? jsonStr.replace(authCode, "<private>") : jsonStr;
if (window._ws) {
window._log('Sending JSON: ' + jsonStrSanitized);
window._ws.send(jsonStr);
} else {
window._log('Error: No WebSocket. Discarding JSON message: ' + jsonStrSanitized);
}
};
// permanentAuthCode is optional. If this is the first time they've selected GPMDP, we won't have a
// permanent code yet.
window.connect = permanentAuthCode => {
// Reset the connection state.
window._requestID = 1;
// Close the existing connection if we're already connected.
window.disconnect();
// Create the new connection.
window._ws = new WebSocket('ws://localhost:5672');
window._ws.onmessage = event => {
// Pass the message along to BGMGooglePlayMusicDesktopPlayerConnection.
let reply = JSON.parse(event.data);
window.webkit.messageHandlers.gpmdp.postMessage(reply);
};
window._wsPromise = new Promise((resolve, reject) => {
window._ws.onopen = () => {
// Send GPMDP the initial connection message.
if (permanentAuthCode) {
window._log('Connecting with auth code');
window.sendPermanentAuthCode(permanentAuthCode);
} else {
// Since we don't have an auth code, it will display a four-digit code and reply
// telling us to ask the user to type it into Background Music.
window._log('Connecting without auth code');
window._sendJSONImmediate({
'namespace': 'connect',
'method': 'connect',
'arguments': ['Background Music']
});
}
};
window._ws.onerror = error => {
// Report the error to BGMGooglePlayMusicDesktopPlayerConnection.
window.webkit.messageHandlers.error.postMessage(error);
// Reject the connection promise.
reject(error);
};
// Store the function that resolves this promise. We resolve it after we finish
// authenticating.
window._resolveConnectionPromise = resolve;
});
};
// Close the connection to GPMDP. Does nothing if we aren't connected.
window.disconnect = () => {
if (window._ws) {
window._log('Closing WebSocket');
window._ws.close();
window._ws = null;
}
};
// Send an authentication code to GPMDP. To send a four-digit code (i.e. one entered by the user),
// call this directly. To send a permanent code received from GPMDP, use
// window.sendPermanentAuthCode().
//
// authCode should be percent-encoded.
window.sendAuthCode = authCode => {
// Percent-decode the auth code string. We pass it percent-encoded just to make sure nothing in
// it accidentally gets executed as Javascript.
authCode = window.decodeURIComponent(authCode);
window._sendJSONImmediate({
'namespace': 'connect',
'method': 'connect',
'arguments': ['Background Music', authCode]
}, authCode);
};
// Send a permanent authentication code, received from GPMDP previously, to GPMDP.
window.sendPermanentAuthCode = permanentAuthCode => {
window._log('Sending permanent auth code');
window.sendAuthCode(permanentAuthCode);
// TODO: If the code is rejected, GPMDP will send us a connect message and we'll show the auth
// code dialog, but accepting the promise here means some messages we send might get
// ignored.
window._resolveConnectionPromise();
};
// Ask GPMDP to send us its current playback state (playing, paused or stopped).
window.requestPlaybackState = () => {
window._sendJSON({
'namespace': 'playback',
'method': 'getPlaybackState',
// We don't send any other types of request, so the ID we send only needs to be unique.
'requestID': window._requestID++
});
};
// Tell GPMDP to toggle between playing and paused.
window.playPause = () => {
window._sendJSON({
'namespace': 'playback',
'method': 'playPause'
});
};
} catch (error) {
window.webkit.messageHandlers.log.postMessage('Error: ' + JSON.stringify(error));
window.webkit.messageHandlers.log.postMessage(JSON.stringify(error.stack));
window.webkit.messageHandlers.error.postMessage(error);
}
// Return an empty string as returning some types can cause an error when this Javascript is loaded
// into the WKWebView.
""
+78
View File
@@ -0,0 +1,78 @@
/*
* Hermes.h
*
* Generated with
* sdef /Applications/Hermes.app | sdp -fh --basename Hermes
*/
#import <AppKit/AppKit.h>
#import <ScriptingBridge/ScriptingBridge.h>
@class HermesApplication, HermesSong, HermesStation;
// Legal player states
enum HermesPlayerStates {
HermesPlayerStatesStopped = 'stop' /* Player is stopped */,
HermesPlayerStatesPlaying = 'play' /* Player is playing */,
HermesPlayerStatesPaused = 'paus' /* Player is paused */
};
typedef enum HermesPlayerStates HermesPlayerStates;
/*
* Hermes Suite
*/
// The Pandora player.
@interface HermesApplication : SBApplication
- (SBElementArray<HermesStation *> *) stations;
@property NSInteger playbackVolume; // The current playback volume (0100).
@property HermesPlayerStates playbackState; // The current playback state.
@property (readonly) double playbackPosition; // The current songs playback position, in seconds.
@property (readonly) double currentSongDuration; // The duration (length) of the current song, in seconds.
@property (copy) HermesStation *currentStation; // The currently selected Pandora station.
@property (copy, readonly) HermesSong *currentSong; // The currently playing (or paused) Pandora song (WARNING: This is an invalid reference in current versions of Hermes; you must access the current songs properties individually or as a group directly instead.)
- (void) playpause; // Play the current song if it is paused; pause the current song if it is playing.
- (void) pause; // Pause the currently playing song.
- (void) play; // Resume playing the current song.
- (void) nextSong; // Skip to the next song on the current station.
- (void) thumbsUp; // Tell Pandora you like the current song.
- (void) thumbsDown; // Tell Pandora you dont like the current song.
- (void) tiredOfSong; // Tell Pandora youre tired of the current song.
- (void) increaseVolume; // Increase the playback volume.
- (void) decreaseVolume; // Decrease the playback volume.
- (void) maximizeVolume; // Set the playback volume to its maximum level.
- (void) mute; // Mutes playback, saving the current volume level.
- (void) unmute; // Restores the volume to the level prior to muting.
@end
// A Pandora song (track).
@interface HermesSong : SBObject
@property (copy, readonly) NSString *title; // The songs title.
@property (copy, readonly) NSString *artist; // The songs artist.
@property (copy, readonly) NSString *album; // The songs album.
@property (copy, readonly) NSString *artworkURL; // An image URL for the albums cover artwork.
@property (readonly) NSInteger rating; // The songs numeric rating.
@property (copy, readonly) NSString *albumURL; // A Pandora URL for more information on the album.
@property (copy, readonly) NSString *artistURL; // A Pandora URL for more information on the artist.
@property (copy, readonly) NSString *trackURL; // A Pandora URL for more information on the track.
@end
// A Pandora station.
@interface HermesStation : SBObject
@property (copy, readonly) NSString *name; // The stations name.
@property (copy, readonly) NSString *stationID; // The stations ID.
@end
+545
View File
@@ -0,0 +1,545 @@
/*
* Music.h
*
* Generated with
* sdef /System/Applications/Music.app | sdp -fh --basename Music
*/
#import <AppKit/AppKit.h>
#import <ScriptingBridge/ScriptingBridge.h>
@class MusicApplication, MusicItem, MusicAirPlayDevice, MusicArtwork, MusicEncoder, MusicEQPreset, MusicPlaylist, MusicAudioCDPlaylist, MusicLibraryPlaylist, MusicRadioTunerPlaylist, MusicSource, MusicSubscriptionPlaylist, MusicTrack, MusicAudioCDTrack, MusicFileTrack, MusicSharedTrack, MusicURLTrack, MusicUserPlaylist, MusicFolderPlaylist, MusicVisual, MusicWindow, MusicBrowserWindow, MusicEQWindow, MusicMiniplayerWindow, MusicPlaylistWindow, MusicVideoWindow;
enum MusicEKnd {
MusicEKndTrackListing = 'kTrk' /* a basic listing of tracks within a playlist */,
MusicEKndAlbumListing = 'kAlb' /* a listing of a playlist grouped by album */,
MusicEKndCdInsert = 'kCDi' /* a printout of the playlist for jewel case inserts */
};
typedef enum MusicEKnd MusicEKnd;
enum MusicEnum {
MusicEnumStandard = 'lwst' /* Standard PostScript error handling */,
MusicEnumDetailed = 'lwdt' /* print a detailed report of PostScript errors */
};
typedef enum MusicEnum MusicEnum;
enum MusicEPlS {
MusicEPlSStopped = 'kPSS',
MusicEPlSPlaying = 'kPSP',
MusicEPlSPaused = 'kPSp',
MusicEPlSFastForwarding = 'kPSF',
MusicEPlSRewinding = 'kPSR'
};
typedef enum MusicEPlS MusicEPlS;
enum MusicERpt {
MusicERptOff = 'kRpO',
MusicERptOne = 'kRp1',
MusicERptAll = 'kAll'
};
typedef enum MusicERpt MusicERpt;
enum MusicEShM {
MusicEShMSongs = 'kShS',
MusicEShMAlbums = 'kShA',
MusicEShMGroupings = 'kShG'
};
typedef enum MusicEShM MusicEShM;
enum MusicESrc {
MusicESrcLibrary = 'kLib',
MusicESrcIPod = 'kPod',
MusicESrcAudioCD = 'kACD',
MusicESrcMP3CD = 'kMCD',
MusicESrcRadioTuner = 'kTun',
MusicESrcSharedLibrary = 'kShd',
MusicESrcITunesStore = 'kITS',
MusicESrcUnknown = 'kUnk'
};
typedef enum MusicESrc MusicESrc;
enum MusicESrA {
MusicESrAAlbums = 'kSrL' /* albums only */,
MusicESrAAll = 'kAll' /* all text fields */,
MusicESrAArtists = 'kSrR' /* artists only */,
MusicESrAComposers = 'kSrC' /* composers only */,
MusicESrADisplayed = 'kSrV' /* visible text fields */,
MusicESrASongs = 'kSrS' /* song names only */
};
typedef enum MusicESrA MusicESrA;
enum MusicESpK {
MusicESpKNone = 'kNon',
MusicESpKFolder = 'kSpF',
MusicESpKGenius = 'kSpG',
MusicESpKLibrary = 'kSpL',
MusicESpKMusic = 'kSpZ',
MusicESpKPurchasedMusic = 'kSpM'
};
typedef enum MusicESpK MusicESpK;
enum MusicEMdK {
MusicEMdKSong = 'kMdS' /* music track */,
MusicEMdKMusicVideo = 'kVdV' /* music video track */,
MusicEMdKUnknown = 'kUnk'
};
typedef enum MusicEMdK MusicEMdK;
enum MusicERtK {
MusicERtKUser = 'kRtU' /* user-specified rating */,
MusicERtKComputed = 'kRtC' /* iTunes-computed rating */
};
typedef enum MusicERtK MusicERtK;
enum MusicEAPD {
MusicEAPDComputer = 'kAPC',
MusicEAPDAirPortExpress = 'kAPX',
MusicEAPDAppleTV = 'kAPT',
MusicEAPDAirPlayDevice = 'kAPO',
MusicEAPDBluetoothDevice = 'kAPB',
MusicEAPDHomePod = 'kAPH',
MusicEAPDUnknown = 'kAPU'
};
typedef enum MusicEAPD MusicEAPD;
enum MusicEClS {
MusicEClSUnknown = 'kUnk',
MusicEClSPurchased = 'kPur',
MusicEClSMatched = 'kMat',
MusicEClSUploaded = 'kUpl',
MusicEClSIneligible = 'kRej',
MusicEClSRemoved = 'kRem',
MusicEClSError = 'kErr',
MusicEClSDuplicate = 'kDup',
MusicEClSSubscription = 'kSub',
MusicEClSNoLongerAvailable = 'kRev',
MusicEClSNotUploaded = 'kUpP'
};
typedef enum MusicEClS MusicEClS;
@protocol MusicGenericMethods
- (void) printPrintDialog:(BOOL)printDialog withProperties:(NSDictionary *)withProperties kind:(MusicEKnd)kind theme:(NSString *)theme; // Print the specified object(s)
- (void) close; // Close an object
- (void) delete; // Delete an element from an object
- (SBObject *) duplicateTo:(SBObject *)to; // Duplicate one or more object(s)
- (BOOL) exists; // Verify if an object exists
- (void) open; // Open the specified object(s)
- (void) save; // Save the specified object(s)
- (void) playOnce:(BOOL)once; // play the current track or the specified track or file.
- (void) select; // select the specified object(s)
@end
/*
* iTunes Suite
*/
// The application program
@interface MusicApplication : SBApplication
- (SBElementArray<MusicAirPlayDevice *> *) AirPlayDevices;
- (SBElementArray<MusicBrowserWindow *> *) browserWindows;
- (SBElementArray<MusicEncoder *> *) encoders;
- (SBElementArray<MusicEQPreset *> *) EQPresets;
- (SBElementArray<MusicEQWindow *> *) EQWindows;
- (SBElementArray<MusicMiniplayerWindow *> *) miniplayerWindows;
- (SBElementArray<MusicPlaylist *> *) playlists;
- (SBElementArray<MusicPlaylistWindow *> *) playlistWindows;
- (SBElementArray<MusicSource *> *) sources;
- (SBElementArray<MusicTrack *> *) tracks;
- (SBElementArray<MusicVideoWindow *> *) videoWindows;
- (SBElementArray<MusicVisual *> *) visuals;
- (SBElementArray<MusicWindow *> *) windows;
@property (readonly) BOOL AirPlayEnabled; // is AirPlay currently enabled?
@property (readonly) BOOL converting; // is a track currently being converted?
@property (copy) NSArray<MusicAirPlayDevice *> *currentAirPlayDevices; // the currently selected AirPlay device(s)
@property (copy) MusicEncoder *currentEncoder; // the currently selected encoder (MP3, AIFF, WAV, etc.)
@property (copy) MusicEQPreset *currentEQPreset; // the currently selected equalizer preset
@property (copy, readonly) MusicPlaylist *currentPlaylist; // the playlist containing the currently targeted track
@property (copy, readonly) NSString *currentStreamTitle; // the name of the current song in the playing stream (provided by streaming server)
@property (copy, readonly) NSString *currentStreamURL; // the URL of the playing stream or streaming web site (provided by streaming server)
@property (copy, readonly) MusicTrack *currentTrack; // the current targeted track
@property (copy) MusicVisual *currentVisual; // the currently selected visual plug-in
@property BOOL EQEnabled; // is the equalizer enabled?
@property BOOL fixedIndexing; // true if all AppleScript track indices should be independent of the play order of the owning playlist.
@property BOOL frontmost; // is iTunes the frontmost application?
@property BOOL fullScreen; // are visuals displayed using the entire screen?
@property (copy, readonly) NSString *name; // the name of the application
@property BOOL mute; // has the sound output been muted?
@property double playerPosition; // the players position within the currently playing track in seconds.
@property (readonly) MusicEPlS playerState; // is iTunes stopped, paused, or playing?
@property (copy, readonly) SBObject *selection; // the selection visible to the user
@property BOOL shuffleEnabled; // are songs played in random order?
@property MusicEShM shuffleMode; // the playback shuffle mode
@property MusicERpt songRepeat; // the playback repeat mode
@property NSInteger soundVolume; // the sound output volume (0 = minimum, 100 = maximum)
@property (copy, readonly) NSString *version; // the version of iTunes
@property BOOL visualsEnabled; // are visuals currently being displayed?
- (void) printPrintDialog:(BOOL)printDialog withProperties:(NSDictionary *)withProperties kind:(MusicEKnd)kind theme:(NSString *)theme; // Print the specified object(s)
- (void) run; // Run iTunes
- (void) quit; // Quit iTunes
- (MusicTrack *) add:(NSArray<NSURL *> *)x to:(SBObject *)to; // add one or more files to a playlist
- (void) backTrack; // reposition to beginning of current track or go to previous track if already at start of current track
- (MusicTrack *) convert:(NSArray<SBObject *> *)x; // convert one or more files or tracks
- (void) fastForward; // skip forward in a playing track
- (void) nextTrack; // advance to the next track in the current playlist
- (void) pause; // pause playback
- (void) playOnce:(BOOL)once; // play the current track or the specified track or file.
- (void) playpause; // toggle the playing/paused state of the current track
- (void) previousTrack; // return to the previous track in the current playlist
- (void) resume; // disable fast forward/rewind and resume playback, if playing.
- (void) rewind; // skip backwards in a playing track
- (void) stop; // stop playback
- (void) openLocation:(NSString *)x; // Opens a Music Store or audio stream URL
@end
// an item
@interface MusicItem : SBObject <MusicGenericMethods>
@property (copy, readonly) SBObject *container; // the container of the item
- (NSInteger) id; // the id of the item
@property (readonly) NSInteger index; // The index of the item in internal application order.
@property (copy) NSString *name; // the name of the item
@property (copy, readonly) NSString *persistentID; // the id of the item as a hexadecimal string. This id does not change over time.
@property (copy) NSDictionary *properties; // every property of the item
- (void) download; // download a cloud track or playlist
- (void) reveal; // reveal and select a track or playlist
@end
// an AirPlay device
@interface MusicAirPlayDevice : MusicItem
@property (readonly) BOOL active; // is the device currently being played to?
@property (readonly) BOOL available; // is the device currently available?
@property (readonly) MusicEAPD kind; // the kind of the device
@property (copy, readonly) NSString *networkAddress; // the network (MAC) address of the device
- (BOOL) protected; // is the device password- or passcode-protected?
@property BOOL selected; // is the device currently selected?
@property (readonly) BOOL supportsAudio; // does the device support audio playback?
@property (readonly) BOOL supportsVideo; // does the device support video playback?
@property NSInteger soundVolume; // the output volume for the device (0 = minimum, 100 = maximum)
@end
// a piece of art within a track or playlist
@interface MusicArtwork : MusicItem
@property (copy) NSImage *data; // data for this artwork, in the form of a picture
@property (copy) NSString *objectDescription; // description of artwork as a string
@property (readonly) BOOL downloaded; // was this artwork downloaded by iTunes?
@property (copy, readonly) NSNumber *format; // the data format for this piece of artwork
@property NSInteger kind; // kind or purpose of this piece of artwork
@property (copy) NSData *rawData; // data for this artwork, in original format
@end
// converts a track to a specific file format
@interface MusicEncoder : MusicItem
@property (copy, readonly) NSString *format; // the data format created by the encoder
@end
// equalizer preset configuration
@interface MusicEQPreset : MusicItem
@property double band1; // the equalizer 32 Hz band level (-12.0 dB to +12.0 dB)
@property double band2; // the equalizer 64 Hz band level (-12.0 dB to +12.0 dB)
@property double band3; // the equalizer 125 Hz band level (-12.0 dB to +12.0 dB)
@property double band4; // the equalizer 250 Hz band level (-12.0 dB to +12.0 dB)
@property double band5; // the equalizer 500 Hz band level (-12.0 dB to +12.0 dB)
@property double band6; // the equalizer 1 kHz band level (-12.0 dB to +12.0 dB)
@property double band7; // the equalizer 2 kHz band level (-12.0 dB to +12.0 dB)
@property double band8; // the equalizer 4 kHz band level (-12.0 dB to +12.0 dB)
@property double band9; // the equalizer 8 kHz band level (-12.0 dB to +12.0 dB)
@property double band10; // the equalizer 16 kHz band level (-12.0 dB to +12.0 dB)
@property (readonly) BOOL modifiable; // can this preset be modified?
@property double preamp; // the equalizer preamp level (-12.0 dB to +12.0 dB)
@property BOOL updateTracks; // should tracks which refer to this preset be updated when the preset is renamed or deleted?
@end
// a list of songs/streams
@interface MusicPlaylist : MusicItem
- (SBElementArray<MusicTrack *> *) tracks;
- (SBElementArray<MusicArtwork *> *) artworks;
@property (copy) NSString *objectDescription; // the description of the playlist
@property BOOL disliked; // is this playlist disliked?
@property (readonly) NSInteger duration; // the total length of all songs (in seconds)
@property (copy) NSString *name; // the name of the playlist
@property BOOL loved; // is this playlist loved?
@property (copy, readonly) MusicPlaylist *parent; // folder which contains this playlist (if any)
@property (readonly) NSInteger size; // the total size of all songs (in bytes)
@property (readonly) MusicESpK specialKind; // special playlist kind
@property (copy, readonly) NSString *time; // the length of all songs in MM:SS format
@property (readonly) BOOL visible; // is this playlist visible in the Source list?
- (void) moveTo:(SBObject *)to; // Move playlist(s) to a new location
- (MusicTrack *) searchFor:(NSString *)for_ only:(MusicESrA)only; // search a playlist for tracks matching the search string. Identical to entering search text in the Search field in iTunes.
@end
// a playlist representing an audio CD
@interface MusicAudioCDPlaylist : MusicPlaylist
- (SBElementArray<MusicAudioCDTrack *> *) audioCDTracks;
@property (copy) NSString *artist; // the artist of the CD
@property BOOL compilation; // is this CD a compilation album?
@property (copy) NSString *composer; // the composer of the CD
@property NSInteger discCount; // the total number of discs in this CDs album
@property NSInteger discNumber; // the index of this CD disc in the source album
@property (copy) NSString *genre; // the genre of the CD
@property NSInteger year; // the year the album was recorded/released
@end
// the master music library playlist
@interface MusicLibraryPlaylist : MusicPlaylist
- (SBElementArray<MusicFileTrack *> *) fileTracks;
- (SBElementArray<MusicURLTrack *> *) URLTracks;
- (SBElementArray<MusicSharedTrack *> *) sharedTracks;
@end
// the radio tuner playlist
@interface MusicRadioTunerPlaylist : MusicPlaylist
- (SBElementArray<MusicURLTrack *> *) URLTracks;
@end
// a music source (music library, CD, device, etc.)
@interface MusicSource : MusicItem
- (SBElementArray<MusicAudioCDPlaylist *> *) audioCDPlaylists;
- (SBElementArray<MusicLibraryPlaylist *> *) libraryPlaylists;
- (SBElementArray<MusicPlaylist *> *) playlists;
- (SBElementArray<MusicRadioTunerPlaylist *> *) radioTunerPlaylists;
- (SBElementArray<MusicSubscriptionPlaylist *> *) subscriptionPlaylists;
- (SBElementArray<MusicUserPlaylist *> *) userPlaylists;
@property (readonly) long long capacity; // the total size of the source if it has a fixed size
@property (readonly) long long freeSpace; // the free space on the source if it has a fixed size
@property (readonly) MusicESrc kind;
@end
// a subscription playlist from Apple Music
@interface MusicSubscriptionPlaylist : MusicPlaylist
- (SBElementArray<MusicFileTrack *> *) fileTracks;
- (SBElementArray<MusicURLTrack *> *) URLTracks;
@end
// playable audio source
@interface MusicTrack : MusicItem
- (SBElementArray<MusicArtwork *> *) artworks;
@property (copy) NSString *album; // the album name of the track
@property (copy) NSString *albumArtist; // the album artist of the track
@property BOOL albumDisliked; // is the album for this track disliked?
@property BOOL albumLoved; // is the album for this track loved?
@property NSInteger albumRating; // the rating of the album for this track (0 to 100)
@property (readonly) MusicERtK albumRatingKind; // the rating kind of the album rating for this track
@property (copy) NSString *artist; // the artist/source of the track
@property (readonly) NSInteger bitRate; // the bit rate of the track (in kbps)
@property double bookmark; // the bookmark time of the track in seconds
@property BOOL bookmarkable; // is the playback position for this track remembered?
@property NSInteger bpm; // the tempo of this track in beats per minute
@property (copy) NSString *category; // the category of the track
@property (readonly) MusicEClS cloudStatus; // the iCloud status of the track
@property (copy) NSString *comment; // freeform notes about the track
@property BOOL compilation; // is this track from a compilation album?
@property (copy) NSString *composer; // the composer of the track
@property (readonly) NSInteger databaseID; // the common, unique ID for this track. If two tracks in different playlists have the same database ID, they are sharing the same data.
@property (copy, readonly) NSDate *dateAdded; // the date the track was added to the playlist
@property (copy) NSString *objectDescription; // the description of the track
@property NSInteger discCount; // the total number of discs in the source album
@property NSInteger discNumber; // the index of the disc containing this track on the source album
@property BOOL disliked; // is this track disliked?
@property (copy, readonly) NSString *downloaderAppleID; // the Apple ID of the person who downloaded this track
@property (copy, readonly) NSString *downloaderName; // the name of the person who downloaded this track
@property (readonly) double duration; // the length of the track in seconds
@property BOOL enabled; // is this track checked for playback?
@property (copy) NSString *episodeID; // the episode ID of the track
@property NSInteger episodeNumber; // the episode number of the track
@property (copy) NSString *EQ; // the name of the EQ preset of the track
@property double finish; // the stop time of the track in seconds
@property BOOL gapless; // is this track from a gapless album?
@property (copy) NSString *genre; // the music/audio genre (category) of the track
@property (copy) NSString *grouping; // the grouping (piece) of the track. Generally used to denote movements within a classical work.
@property (copy, readonly) NSString *kind; // a text description of the track
@property (copy) NSString *longDescription;
@property BOOL loved; // is this track loved?
@property (copy) NSString *lyrics; // the lyrics of the track
@property MusicEMdK mediaKind; // the media kind of the track
@property (copy, readonly) NSDate *modificationDate; // the modification date of the content of this track
@property (copy) NSString *movement; // the movement name of the track
@property NSInteger movementCount; // the total number of movements in the work
@property NSInteger movementNumber; // the index of the movement in the work
@property NSInteger playedCount; // number of times this track has been played
@property (copy) NSDate *playedDate; // the date and time this track was last played
@property (copy, readonly) NSString *purchaserAppleID; // the Apple ID of the person who purchased this track
@property (copy, readonly) NSString *purchaserName; // the name of the person who purchased this track
@property NSInteger rating; // the rating of this track (0 to 100)
@property (readonly) MusicERtK ratingKind; // the rating kind of this track
@property (copy, readonly) NSDate *releaseDate; // the release date of this track
@property (readonly) NSInteger sampleRate; // the sample rate of the track (in Hz)
@property NSInteger seasonNumber; // the season number of the track
@property BOOL shufflable; // is this track included when shuffling?
@property NSInteger skippedCount; // number of times this track has been skipped
@property (copy) NSDate *skippedDate; // the date and time this track was last skipped
@property (copy) NSString *show; // the show name of the track
@property (copy) NSString *sortAlbum; // override string to use for the track when sorting by album
@property (copy) NSString *sortArtist; // override string to use for the track when sorting by artist
@property (copy) NSString *sortAlbumArtist; // override string to use for the track when sorting by album artist
@property (copy) NSString *sortName; // override string to use for the track when sorting by name
@property (copy) NSString *sortComposer; // override string to use for the track when sorting by composer
@property (copy) NSString *sortShow; // override string to use for the track when sorting by show name
@property (readonly) long long size; // the size of the track (in bytes)
@property double start; // the start time of the track in seconds
@property (copy, readonly) NSString *time; // the length of the track in MM:SS format
@property NSInteger trackCount; // the total number of tracks on the source album
@property NSInteger trackNumber; // the index of the track on the source album
@property BOOL unplayed; // is this track unplayed?
@property NSInteger volumeAdjustment; // relative volume adjustment of the track (-100% to 100%)
@property (copy) NSString *work; // the work name of the track
@property NSInteger year; // the year the track was recorded/released
@end
// a track on an audio CD
@interface MusicAudioCDTrack : MusicTrack
@property (copy, readonly) NSURL *location; // the location of the file represented by this track
@end
// a track representing an audio file (MP3, AIFF, etc.)
@interface MusicFileTrack : MusicTrack
@property (copy) NSURL *location; // the location of the file represented by this track
- (void) refresh; // update file track information from the current information in the tracks file
@end
// a track residing in a shared library
@interface MusicSharedTrack : MusicTrack
@end
// a track representing a network stream
@interface MusicURLTrack : MusicTrack
@property (copy) NSString *address; // the URL for this track
@end
// custom playlists created by the user
@interface MusicUserPlaylist : MusicPlaylist
- (SBElementArray<MusicFileTrack *> *) fileTracks;
- (SBElementArray<MusicURLTrack *> *) URLTracks;
- (SBElementArray<MusicSharedTrack *> *) sharedTracks;
@property BOOL shared; // is this playlist shared?
@property (readonly) BOOL smart; // is this a Smart Playlist?
@property (readonly) BOOL genius; // is this a Genius Playlist?
@end
// a folder that contains other playlists
@interface MusicFolderPlaylist : MusicUserPlaylist
@end
// a visual plug-in
@interface MusicVisual : MusicItem
@end
// any window
@interface MusicWindow : MusicItem
@property NSRect bounds; // the boundary rectangle for the window
@property (readonly) BOOL closeable; // does the window have a close button?
@property (readonly) BOOL collapseable; // does the window have a collapse button?
@property BOOL collapsed; // is the window collapsed?
@property BOOL fullScreen; // is the window full screen?
@property NSPoint position; // the upper left position of the window
@property (readonly) BOOL resizable; // is the window resizable?
@property BOOL visible; // is the window visible?
@property (readonly) BOOL zoomable; // is the window zoomable?
@property BOOL zoomed; // is the window zoomed?
@end
// the main iTunes window
@interface MusicBrowserWindow : MusicWindow
@property (copy, readonly) SBObject *selection; // the selected songs
@property (copy) MusicPlaylist *view; // the playlist currently displayed in the window
@end
// the iTunes equalizer window
@interface MusicEQWindow : MusicWindow
@end
// the miniplayer window
@interface MusicMiniplayerWindow : MusicWindow
@end
// a sub-window showing a single playlist
@interface MusicPlaylistWindow : MusicWindow
@property (copy, readonly) SBObject *selection; // the selected songs
@property (copy, readonly) MusicPlaylist *view; // the playlist displayed in the window
@end
// the video window
@interface MusicVideoWindow : MusicWindow
@end
+252
View File
@@ -0,0 +1,252 @@
/*
* Swinsian.h
*
* Generated with
* sdef /Applications/Swinsian.app | sdp -fh --basename Swinsian
*/
#import <AppKit/AppKit.h>
#import <ScriptingBridge/ScriptingBridge.h>
@class SwinsianItem, SwinsianColor, SwinsianWindow, SwinsianApplication, SwinsianPlaylist, SwinsianLibrary, SwinsianTrack, SwinsianLibraryTrack, SwinsianIPodTrack, SwinsianQueue, SwinsianSmartPlaylist, SwinsianNormalPlaylist, SwinsianPlaylistFolder, SwinsianAudioDevice;
enum SwinsianSaveOptions {
SwinsianSaveOptionsYes = 'yes ' /* Save the file. */,
SwinsianSaveOptionsNo = 'no ' /* Do not save the file. */,
SwinsianSaveOptionsAsk = 'ask ' /* Ask the user whether or not to save the file. */
};
typedef enum SwinsianSaveOptions SwinsianSaveOptions;
enum SwinsianPlayerState {
SwinsianPlayerStateStopped = 'kPSS',
SwinsianPlayerStatePlaying = 'kPSP',
SwinsianPlayerStatePaused = 'kPSp'
};
typedef enum SwinsianPlayerState SwinsianPlayerState;
@protocol SwinsianGenericMethods
- (void) closeSaving:(SwinsianSaveOptions)saving savingIn:(NSURL *)savingIn; // Close an object.
- (void) delete; // Delete an object.
- (void) duplicateTo:(SBObject *)to withProperties:(NSDictionary *)withProperties; // Copy object(s) and put the copies at a new location.
- (BOOL) exists; // Verify if an object exists.
- (void) moveTo:(SBObject *)to; // Move object(s) to a new location.
- (void) saveIn:(NSURL *)in_ as:(NSString *)as; // Save an object.
@end
/*
* Standard Suite
*/
// A scriptable object.
@interface SwinsianItem : SBObject <SwinsianGenericMethods>
@property (copy) NSDictionary *properties; // All of the object's properties.
@end
// A color.
@interface SwinsianColor : SBObject <SwinsianGenericMethods>
@end
// A window.
@interface SwinsianWindow : SBObject <SwinsianGenericMethods>
@property (copy) NSString *name; // The full title of the window.
- (NSNumber *) id; // The unique identifier of the window.
@property NSRect bounds; // The bounding rectangle of the window.
@property (readonly) BOOL closeable; // Whether the window has a close box.
@property (readonly) BOOL titled; // Whether the window has a title bar.
@property (copy) NSNumber *index; // The index of the window in the back-to-front window ordering.
@property (readonly) BOOL floating; // Whether the window floats.
@property (readonly) BOOL miniaturizable; // Whether the window can be miniaturized.
@property BOOL miniaturized; // Whether the window is currently miniaturized.
@property (readonly) BOOL modal; // Whether the window is the application's current modal window.
@property (readonly) BOOL resizable; // Whether the window can be resized.
@property BOOL visible; // Whether the window is currently visible.
@property (readonly) BOOL zoomable; // Whether the window can be zoomed.
@property BOOL zoomed; // Whether the window is currently zoomed.
@property (copy, readonly) NSArray<SwinsianTrack *> *selection; // Currently seleted tracks
@end
/*
* Swinsian Suite
*/
// The application
@interface SwinsianApplication : SBApplication
- (SBElementArray<SwinsianWindow *> *) windows;
- (SBElementArray<SwinsianPlaylist *> *) playlists;
- (SBElementArray<SwinsianSmartPlaylist *> *) smartPlaylists;
- (SBElementArray<SwinsianNormalPlaylist *> *) normalPlaylists;
- (SBElementArray<SwinsianLibrary *> *) libraries;
- (SBElementArray<SwinsianTrack *> *) tracks;
- (SBElementArray<SwinsianAudioDevice *> *) audioDevices;
@property (copy, readonly) NSString *name; // The name of the application.
@property (readonly) BOOL frontmost; // Is this the frontmost (active) application?
@property (copy, readonly) NSString *version; // The version of the application.
@property NSInteger playerPosition; // the players position within the currently playing track in seconds.
@property (copy, readonly) SwinsianTrack *currentTrack; // the currently playing track
@property (copy) NSNumber *soundVolume; // the volume. (0 minimum, 100 maximum)
@property (readonly) SwinsianPlayerState playerState; // are we stopped, paused or still playing?
@property (copy, readonly) SwinsianQueue *playbackQueue; // the currently queued tracks
@property (copy) SwinsianAudioDevice *outputDevice; // current audio output device
- (void) open:(NSURL *)x; // Open an object.
- (void) print:(NSURL *)x; // Print an object.
- (void) quitSaving:(SwinsianSaveOptions)saving; // Quit an application.
- (void) play; // begin playing the current playlist
- (void) pause; // pause playback
- (void) nextTrack; // skip to the next track in the current playlist
- (void) stop; // stop playback
- (NSArray<SwinsianTrack *> *) searchPlaylist:(SwinsianPlaylist *)playlist for:(NSString *)for_; // search a playlist for tracks matching a string
- (void) previousTrack; // skip back to the previous track
- (void) playpause; // toggle play/pause
- (void) addTracks:(NSArray<SwinsianTrack *> *)tracks to:(SwinsianNormalPlaylist *)to; // add a track to a playlist
- (void) notify; // show currently playing track notification
- (void) rescanTags:(NSArray<SwinsianTrack *> *)x; // rescan tags on tracks
- (NSArray<SwinsianTrack *> *) findTrack:(NSString *)x; // Finds tracks for the given path
- (void) removeTracks:(NSArray<SwinsianTrack *> *)tracks from:(SwinsianNormalPlaylist *)from; // remove tracks from a playlist
@end
// generic playlist type, subcasses include smart playlist and normal playlist
@interface SwinsianPlaylist : SwinsianItem
- (SBElementArray<SwinsianTrack *> *) tracks;
@property (copy) NSString *name; // the name of the playlist
@property (readonly) BOOL smart; // is this a smart playlist
@end
@interface SwinsianLibrary : SwinsianItem
- (SBElementArray<SwinsianTrack *> *) tracks;
@end
// a music track
@interface SwinsianTrack : SwinsianItem
@property (copy) NSString *album; // the album of the track
@property (copy) NSString *artist; // the artist
@property (copy) NSString *composer; // the composer
@property (copy) NSString *genre; // the genre
@property (copy, readonly) NSString *time; // the length of the track in text format as MM:SS
@property NSInteger year; // the year the track was recorded
@property (copy, readonly) NSDate *dateAdded; // the date the track was added to the library
@property (readonly) double duration; // the length of the track in seconds
@property (copy, readonly) NSString *location; // location on disk
@property (readonly) BOOL iPodTrack; // TRUE if the track is on an iPod
@property (copy) NSString *name; // the title of the track (same as title)
@property (readonly) NSInteger bitRate; // the bitrate of the track
@property (copy, readonly) NSString *kind; // a text description of the type of file the track is
@property (copy) NSNumber *rating; // Track rating. 0-5
@property NSInteger trackNumber; // the Track number
@property (readonly) NSInteger fileSize; // file size in bytes
@property (copy, readonly) NSImage *albumArt; // the album artwork
@property (copy, readonly) NSString *artFormat; // the data format for this piece of artwork. text that will be "PNG" or "JPEG". getting the album art property first will mean this information has been retrieved already, otherwise the tags for the file will have to be re-read
@property (copy) NSNumber *discNumber; // the disc number
@property (copy) NSNumber *discCount; // the total number of discs in the album
- (NSString *) id; // uuid
@property (copy) NSString *albumArtist; // the album artist
@property (copy, readonly) NSString *albumArtistOrArtist; // the album artist of the track, or is none is set, the artist
@property BOOL compilation; // compilation flag
@property (copy) NSString *title; // track title (the same as name)
@property (copy) NSString *comment; // the comment
@property (copy, readonly) NSDate *dateCreated; // the date created
@property (readonly) NSInteger channels; // audio channel count
@property (readonly) NSInteger sampleRate; // audio sample rate
@property (readonly) NSInteger bitDepth; // the audio bit depth
@property (copy) NSDate *lastPlayed; // date track was last played
@property (copy) NSString *lyrics; // track lyrics
@property (copy, readonly) NSString *path; // POSIX style path
@property (copy) NSString *grouping; // grouping
@property (copy) NSString *publisher; // the publisher
@property (copy) NSString *conductor; // the conductor
@property (copy) NSString *objectDescription; // the description
@property (copy, readonly) NSString *encoder; // the encoder
@property (copy, readonly) NSString *copyright; // the copyright
@property (copy) NSString *catalogNumber; // the catalog number
@property (copy, readonly) NSDate *dateModified; // the date modified
@property NSInteger playCount; // the play count
@property (copy) NSNumber *trackCount; // the total number of tracks in the album
@end
@interface SwinsianLibraryTrack : SwinsianTrack
@end
@interface SwinsianIPodTrack : SwinsianTrack
@property (copy, readonly) NSString *iPodName; // the name of the iPod this track is on
@end
// The playback queue
@interface SwinsianQueue : SwinsianItem
- (SBElementArray<SwinsianTrack *> *) tracks;
@end
// a smart playlist
@interface SwinsianSmartPlaylist : SwinsianPlaylist
@end
// a normal, non-smart, playlist
@interface SwinsianNormalPlaylist : SwinsianPlaylist
- (SBElementArray<SwinsianTrack *> *) tracks;
- (NSString *) id; // uuid
@end
// folder of playlists
@interface SwinsianPlaylistFolder : SwinsianPlaylist
- (SBElementArray<SwinsianPlaylist *> *) playlists;
- (NSString *) id; // uuid
@end
// an audio output device
@interface SwinsianAudioDevice : SBObject <SwinsianGenericMethods>
@property (copy, readonly) NSString *name; // device name
- (NSString *) id; // uuid
- (void) setId: (NSString *) id;
@end
@@ -1,8 +1,8 @@
/*
* Vox.h
* VOX.h
*
* Generated with
* sdef /Applications/Vox.app | sdp -fh --basename Vox
* sdef /Applications/VOX.app | sdp -fh --basename VOX
*/
#import <AppKit/AppKit.h>
@@ -14,24 +14,30 @@
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// AppDelegate.h
// BGMAboutPanel.h
// BGMApp
//
// Copyright © 2016 Kyle Neideck
//
// Sets up and tears down the app.
// This class manages the "About Background Music" window.
//
// System Includes
#import <Cocoa/Cocoa.h>
@interface AppDelegate : NSObject <NSApplicationDelegate>
NS_ASSUME_NONNULL_BEGIN
@property (weak) IBOutlet NSMenu* bgmMenu;
@property (weak) IBOutlet NSMenuItem* autoPauseMenuItem;
@property (weak) IBOutlet NSView* appVolumeView;
@property (weak) IBOutlet NSPanel* aboutPanel;
@property (unsafe_unretained) IBOutlet NSTextView *aboutPanelLicenseView;
@interface BGMAboutPanel : NSObject
- (instancetype)initWithPanel:(NSPanel*)inAboutPanel licenseView:(NSTextView*)inLicenseView;
- (void) show;
@end
@interface BGMLinkField : NSTextField
@end
NS_ASSUME_NONNULL_END
+143
View File
@@ -0,0 +1,143 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMAboutPanel.m
// BGMApp
//
// Copyright © 2016 Kyle Neideck
//
// Self Include
#import "BGMAboutPanel.h"
// Local Includes
#import "BGM_Types.h"
// PublicUtility Includes
#include "CADebugMacros.h"
NS_ASSUME_NONNULL_BEGIN
static NSInteger const kVersionLabelTag = 1;
static NSInteger const kCopyrightLabelTag = 2;
static NSInteger const kProjectWebsiteLabelTag = 3;
@implementation BGMAboutPanel {
NSPanel* aboutPanel;
NSTextField* versionLabel;
NSTextField* copyrightLabel;
NSTextField* websiteLabel;
NSTextView* licenseView;
}
- (instancetype)initWithPanel:(NSPanel*)inAboutPanel licenseView:(NSTextView*)inLicenseView {
if ((self = [super init])) {
aboutPanel = inAboutPanel;
versionLabel = [[aboutPanel contentView] viewWithTag:kVersionLabelTag];
copyrightLabel = [[aboutPanel contentView] viewWithTag:kCopyrightLabelTag];
websiteLabel = [[aboutPanel contentView] viewWithTag:kProjectWebsiteLabelTag];
licenseView = inLicenseView;
[self initAboutPanel];
}
return self;
}
- (void) initAboutPanel {
// Set up the About Background Music window
NSBundle* bundle = [NSBundle mainBundle];
if (bundle == nil) {
NSLog(@"Background Music: BGMAboutPanel::initAboutPanel: Could not find main bundle");
} else {
// Version number label
NSString* __nullable version =
[[bundle infoDictionary] objectForKey:@"CFBundleShortVersionString"];
if (version) {
versionLabel.stringValue = [NSString stringWithFormat:@"Version %@", version];
}
// Copyright notice label
NSString* __nullable copyrightNotice =
[[bundle infoDictionary] objectForKey:@"NSHumanReadableCopyright"];
if (copyrightNotice) {
copyrightLabel.stringValue = (NSString*)copyrightNotice;
}
// Project website link label
websiteLabel.selectable = YES;
websiteLabel.allowsEditingTextAttributes = YES;
NSString* projectURL = [NSString stringWithUTF8String:kBGMProjectURL];
NSFont* linkFont = websiteLabel.font ? websiteLabel.font : [NSFont labelFontOfSize:0.0];
websiteLabel.attributedStringValue =
[[NSAttributedString alloc] initWithString:projectURL
attributes:@{ NSLinkAttributeName: projectURL,
NSFontAttributeName: linkFont }];
// Load the text of the license into the text view
NSString* __nullable licensePath = [bundle pathForResource:@"LICENSE" ofType:nil];
NSError* err;
NSString* __nullable licenseStr = (!licensePath ? nil :
[NSString stringWithContentsOfFile:(NSString*)licensePath
encoding:NSASCIIStringEncoding
error:&err]);
if (err || !licenseStr || [licenseStr isEqualToString:@""]) {
NSLog(@"Error loading license file: %@", err);
licenseStr = @"Error: could not open license file.";
}
licenseView.string = (NSString*)licenseStr;
NSFont* __nullable font = [NSFont fontWithName:@"Andale Mono" size:0.0];
if (font) {
licenseView.textStorage.font = font;
}
}
}
- (void) show {
DebugMsg("BGMAboutPanel::showAboutPanel: Opening \"About Background Music\" panel");
[NSApp activateIgnoringOtherApps:YES];
[aboutPanel setIsVisible:YES];
[aboutPanel makeKeyAndOrderFront:self];
}
@end
@implementation BGMLinkField
- (void) resetCursorRects {
// Change the mouse cursor when hovering over the link. (It does change by default, but only after
// you've clicked it once.)
[self addCursorRect:self.bounds cursor:[NSCursor pointingHandCursor]];
}
@end
NS_ASSUME_NONNULL_END
@@ -20,21 +20,23 @@
// Copyright © 2016 Kyle Neideck
//
// PublicUtility Includes
#include "BGMAudioDeviceManager.h"
// Local Includes
#import "BGMAudioDeviceManager.h"
#import "BGMMusicPlayers.h"
// System Includes
#import <AppKit/AppKit.h>
#import <Cocoa/Cocoa.h>
#pragma clang assume_nonnull begin
@interface BGMAutoPauseMusicPrefs : NSObject
// Note that toggleAutoPauseMusicMenuItem is the item in the main menu that enables/disables auto-pausing, rather than the
// disabled "Auto-pause" menu item in the preferences menu that acts as a section heading. This class updates the text of
// toggleAutoPauseMusicMenuItem when the user changes the music player.
- (id) initWithPreferencesMenu:(NSMenu*)inPrefsMenu
toggleAutoPauseMusicMenuItem:(NSMenuItem*)inToggleAutoPauseMusicMenuItem
audioDevices:(BGMAudioDeviceManager*)inAudioDevices;
audioDevices:(BGMAudioDeviceManager*)inAudioDevices
musicPlayers:(BGMMusicPlayers*)inMusicPlayers;
@end
#pragma clang assume_nonnull end
@@ -17,189 +17,102 @@
// BGMAutoPauseMusicPrefs.mm
// BGMApp
//
// Copyright © 2016 Kyle Neideck
// Copyright © 2016, 2019 Kyle Neideck
//
// Self Includes
#import "BGMAutoPauseMusicPrefs.h"
// Local Includes
#include "BGM_Types.h"
#import "BGM_Types.h"
#import "BGMMusicPlayer.h"
static NSString* const kToggleAutoPauseMusicMenuItemTitleFormat = @"Auto-pause %@";
#pragma clang assume_nonnull begin
static float const kMenuItemIconScalingFactor = 1.15f;
static NSInteger const kPrefsMenuAutoPauseHeaderTag = 1;
@implementation BGMAutoPauseMusicPrefs {
BGMAudioDeviceManager* audioDevices;
NSMenuItem* toggleAutoPauseMusicMenuItem;
BGMMusicPlayers* musicPlayers;
NSMenu* prefsMenu;
NSArray<NSMenuItem*>* musicPlayerMenuItems;
}
- (id) initWithPreferencesMenu:(NSMenu*)inPrefsMenu
toggleAutoPauseMusicMenuItem:(NSMenuItem*)inToggleAutoPauseMusicMenuItem
audioDevices:(BGMAudioDeviceManager*)inAudioDevices {
audioDevices:(BGMAudioDeviceManager*)inAudioDevices
musicPlayers:(BGMMusicPlayers*)inMusicPlayers {
if ((self = [super init])) {
prefsMenu = inPrefsMenu;
toggleAutoPauseMusicMenuItem = inToggleAutoPauseMusicMenuItem;
audioDevices = inAudioDevices;
musicPlayers = inMusicPlayers;
musicPlayerMenuItems = @[];
[self initSelectedMusicPlayer];
[self initMenuSection];
[self updateMenuItemTitle];
[self initPreferencesMenuSection];
}
return self;
}
- (void) initSelectedMusicPlayer {
// TODO: It would make more sense to either just save the music player setting in the User Defaults (the same way AppDelegate saves
// whether auto-pause is enabled) or to send a "musicPlayerID" to the driver, which would only be used by BGMApp. If the latter,
// we might as well save the auto-pause setting on the driver as well just so all the settings are saved in the same place.
// Get the currently selected music player from the driver and update the global in BGMMusicPlayerBase
// The bundle ID and PID set on the driver
CFNumberRef selectedPID = static_cast<CFNumberRef>([audioDevices bgmDevice].GetPropertyData_CFType(kBGMMusicPlayerProcessIDAddress));
CFStringRef selectedBundleID = [audioDevices bgmDevice].GetPropertyData_CFString(kBGMMusicPlayerBundleIDAddress);
DebugMsg("BGMAutoPauseMusicPrefs::initSelectedMusicPlayer: Music player on BGMDriver: bundleID=%s PID=%s",
selectedBundleID == NULL ? "null" : CFStringGetCStringPtr(selectedBundleID, kCFStringEncodingUTF8),
selectedPID == NULL ? "null" : [[(__bridge NSNumber*)selectedPID stringValue] UTF8String]);
// If no music player is set on the driver, set it to the one set in the app and return
if ((selectedBundleID == NULL || CFEqual(selectedBundleID, CFSTR(""))) &&
(selectedPID == NULL || [(__bridge NSNumber*)selectedPID intValue] < 1)) {
[self updateBGMDevice];
return;
}
// The IDs set in the app, which will be updated if they don't match the values from the driver
CFNumberRef selectedPIDInBGMApp = [[BGMMusicPlayerBase selectedMusicPlayer] pid];
CFStringRef selectedBundleIDInBGMApp = [[[BGMMusicPlayerBase selectedMusicPlayer] class] bundleID];
// Return early if the music player selected in the app already matches the driver
if ((selectedPID != NULL && selectedPIDInBGMApp != NULL && CFEqual(selectedPID, selectedPIDInBGMApp)) ||
(selectedBundleID != NULL && selectedBundleIDInBGMApp != NULL && CFEqual(selectedBundleID, selectedBundleIDInBGMApp))) {
return;
}
// Check each selectable music player
for (Class mpClass in [BGMMusicPlayerBase musicPlayerClasses]) {
// Look for a running instance of the music player by PID
if (selectedPID != NULL &&
[mpClass respondsToSelector:@selector(pidsOfRunningInstances)] &&
[mpClass respondsToSelector:@selector(initWithPIDFromNSNumber:)]) {
NSArray<NSNumber*>* mpPIDs = [mpClass pidsOfRunningInstances];
for (NSNumber* mpPID in mpPIDs) {
if (CFEqual((__bridge CFNumberRef)mpPID, selectedPID)) {
DebugMsg("BGMAutoPauseMusicPrefs::initSelectedMusicPlayer: Selected music player on driver was %s (found by pid)",
[[mpClass name] UTF8String]);
[BGMMusicPlayerBase setSelectedMusicPlayer:[[mpClass alloc] initWithPIDFromNSNumber:mpPID]];
return;
}
}
}
// Check by bundle ID
CFStringRef mpBundleID = [mpClass bundleID];
if (selectedBundleID != NULL &&
mpBundleID != NULL &&
CFEqual(mpBundleID, selectedBundleID)) {
// Found the selected music player. Update the app to match the driver and return.
DebugMsg("BGMAutoPauseMusicPrefs::initSelectedMusicPlayer: Selected music player on driver was %s",
[[mpClass name] UTF8String]);
[BGMMusicPlayerBase setSelectedMusicPlayer:[mpClass new]];
return;
}
}
}
- (void) initMenuSection {
// Add the menu items related to auto-pausing music to the settings submenu
- (void) initPreferencesMenuSection {
// Add the menu items related to auto-pausing music to the Preferences submenu
// The index to start inserting music player menu items at
NSInteger musicPlayerItemsIndex = [prefsMenu indexOfItemWithTag:kPrefsMenuAutoPauseHeaderTag] + 1;
// Insert the options to change the music player app
for (Class musicPlayerClass in [BGMMusicPlayerBase musicPlayerClasses]) {
NSMenuItem* menuItem = [prefsMenu insertItemWithTitle:[musicPlayerClass name]
// Insert the menu items used to change the music player app.
for (id<BGMMusicPlayer> musicPlayer in musicPlayers.musicPlayers) {
// Create an menu item for this music player.
NSMenuItem* menuItem = [prefsMenu insertItemWithTitle:musicPlayer.name
action:@selector(handleMusicPlayerChange:)
keyEquivalent:@""
atIndex:musicPlayerItemsIndex];
atIndex:musicPlayerItemsIndex];
menuItem.toolTip = musicPlayer.toolTip;
musicPlayerMenuItems = [musicPlayerMenuItems arrayByAddingObject:menuItem];
// Create an instance for this music player and associate it with the menu item
[menuItem setRepresentedObject:[musicPlayerClass new]];
// Associate the music player with the menu item
menuItem.representedObject = musicPlayer;
// Show the default music player as selected
if (musicPlayerClass == [[BGMMusicPlayerBase selectedMusicPlayer] class]) {
[menuItem setState:NSOnState];
// Show the menu item for the selected music player as selected
if (musicPlayers.selectedMusicPlayer == musicPlayer) {
menuItem.state = NSOnState;
}
// Set the item's icon
NSImage* icon = [musicPlayerClass icon];
// Set the menu item's icon
NSImage* __nullable icon = musicPlayer.icon;
if (icon == nil) {
// Set a blank icon so the text lines up
icon = [NSImage new];
}
// Size the icon relative to the size of the item's text
CGFloat length = [[NSFont menuBarFontOfSize:0] pointSize] * kMenuItemIconScalingFactor;
[icon setSize:NSMakeSize(length, length)];
[menuItem setImage:icon];
[menuItem setTarget:self];
[menuItem setIndentationLevel:1];
// Size the icon relative to the size of the item's text
CGFloat length = [NSFont menuBarFontOfSize:0].pointSize * kMenuItemIconScalingFactor;
icon.size = NSMakeSize(length, length);
menuItem.image = icon;
menuItem.target = self;
menuItem.indentationLevel = 1;
}
}
- (void) handleMusicPlayerChange:(NSMenuItem*)sender {
// Set the new music player as the selected music player
BGMMusicPlayer* musicPlayer = [sender representedObject];
assert(musicPlayer != nil);
[BGMMusicPlayerBase setSelectedMusicPlayer:musicPlayer];
id<BGMMusicPlayer> musicPlayer = sender.representedObject;
NSAssert(musicPlayer, @"BGMAutoPauseMusicPrefs::handleMusicPlayerChange: !musicPlayer");
// Select/Deselect the menu items
musicPlayers.selectedMusicPlayer = musicPlayer;
// Select/deselect the menu items
for (NSMenuItem* item in musicPlayerMenuItems) {
BOOL isNewlySelectedMusicPlayer = item == sender;
[item setState:(isNewlySelectedMusicPlayer ? NSOnState : NSOffState)];
BOOL isNewlySelectedMusicPlayer = (item == sender);
item.state = (isNewlySelectedMusicPlayer ? NSOnState : NSOffState);
}
[self updateMenuItemTitle];
[self updateBGMDevice];
}
- (void) updateBGMDevice {
// Send the music player's PID or bundle ID to the driver
DebugMsg("BGMAutoPauseMusicPrefs::updateBGMDevice: Setting the music player to %s on the driver",
[[[[BGMMusicPlayer selectedMusicPlayer] class] name] UTF8String]);
CFNumberRef __nullable pid = [[BGMMusicPlayer selectedMusicPlayer] pid];
if (pid != NULL) {
[audioDevices bgmDevice].SetPropertyData_CFType(kBGMMusicPlayerProcessIDAddress, pid);
} else {
CFStringRef __nullable bundleID = [[[BGMMusicPlayer selectedMusicPlayer] class] bundleID];
if (bundleID != NULL) {
[audioDevices bgmDevice].SetPropertyData_CFString(kBGMMusicPlayerBundleIDAddress, bundleID);
}
}
}
- (void) updateMenuItemTitle {
// Set the title of the Auto-pause Music menu item, including the name of the selected music player
NSString* musicPlayerName = [[[BGMMusicPlayer selectedMusicPlayer] class] name];
NSString* title = [NSString stringWithFormat:kToggleAutoPauseMusicMenuItemTitleFormat, musicPlayerName];
[toggleAutoPauseMusicMenuItem setTitle:title];
}
@end
#pragma clang assume_nonnull end
@@ -1,107 +0,0 @@
// This file is part of Background Music.
//
// Background Music is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 2 of the
// License, or (at your option) any later version.
//
// Background Music is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Background Music. If not, see <http://www.gnu.org/licenses/>.
//
// BGMOutputDevicePrefs.mm
// BGMApp
//
// Copyright © 2016 Kyle Neideck
//
// Self Include
#import "BGMOutputDevicePrefs.h"
// PublicUtility Includes
#include "CAHALAudioSystemObject.h"
#include "CAHALAudioDevice.h"
#include "CAAutoDisposer.h"
static NSInteger const kOutputDeviceMenuItemTag = 2;
@implementation BGMOutputDevicePrefs {
BGMAudioDeviceManager* audioDevices;
NSMutableArray<NSMenuItem*>* outputDeviceMenuItems;
}
- (id) initWithAudioDevices:(BGMAudioDeviceManager*)inAudioDevices {
if ((self = [super init])) {
audioDevices = inAudioDevices;
outputDeviceMenuItems = [NSMutableArray new];
}
return self;
}
- (void) populatePreferencesMenu:(NSMenu*)prefsMenu {
// Remove existing menu items
for (NSMenuItem* item in outputDeviceMenuItems) {
[prefsMenu removeItem:item];
}
[outputDeviceMenuItems removeAllObjects];
// Insert menu items after the item for the "Output Device" heading
const NSInteger menuItemsIdx = [prefsMenu indexOfItemWithTag:kOutputDeviceMenuItemTag] + 1;
// Add a menu item for each output device
CAHALAudioSystemObject audioSystem;
UInt32 numDevices = audioSystem.GetNumberAudioDevices();
if (numDevices > 0) {
CAAutoArrayDelete<AudioObjectID> devices(numDevices);
audioSystem.GetAudioDevices(numDevices, devices);
for (UInt32 i = 0; i < numDevices; i++) {
CAHALAudioDevice device(devices[i]);
BOOL hasOutputChannels = device.GetTotalNumberChannels(/* inIsInput = */ false) > 0;
if (device.GetObjectID() != [audioDevices bgmDevice].GetObjectID() && hasOutputChannels) {
NSString* deviceName = CFBridgingRelease(device.CopyName());
NSMenuItem* item = [[NSMenuItem alloc] initWithTitle:deviceName
action:@selector(outputDeviceWasChanged:)
keyEquivalent:@""];
BOOL isSelected = [audioDevices isOutputDevice:device.GetObjectID()];
[item setState:(isSelected ? NSOnState : NSOffState)];
[item setTarget:self];
[item setIndentationLevel:1];
[item setRepresentedObject:[NSNumber numberWithUnsignedInt:device.GetObjectID()]];
[prefsMenu insertItem:item atIndex:menuItemsIdx];
[outputDeviceMenuItems addObject:item];
}
}
}
}
- (void) outputDeviceWasChanged:(NSMenuItem*)menuItem {
BOOL success = [audioDevices setOutputDeviceWithID:[[menuItem representedObject] unsignedIntValue] revertOnFailure:YES];
if (!success) {
// Couldn't change the output device, so show a warning and change the menu selection back
NSAlert* alert = [NSAlert new];
NSString* deviceName = [menuItem title];
[alert setMessageText:[NSString stringWithFormat:@"Failed to set %@ as the output device", deviceName]];
[alert setInformativeText:@"This is probably a bug. Feel free to report it."];
[alert runModal];
[menuItem setState:NSOffState];
// TODO: Reselect previous menu item
}
}
@end

Some files were not shown because too many files have changed in this diff Show More