diff --git a/BGMApp/BGMApp.xcodeproj/project.pbxproj b/BGMApp/BGMApp.xcodeproj/project.pbxproj index 41406316..b8bdf448 100644 --- a/BGMApp/BGMApp.xcodeproj/project.pbxproj +++ b/BGMApp/BGMApp.xcodeproj/project.pbxproj @@ -143,6 +143,7 @@ 1CD9894C1ECFFCFC0014BBBF /* BGMHermes.m in Sources */ = {isa = PBXBuildFile; fileRef = 279F48761DD6D73900768A85 /* BGMHermes.m */; }; 1CD9894D1ECFFCFC0014BBBF /* BGMVLC.m in Sources */ = {isa = PBXBuildFile; fileRef = 27379B891C7C562D0084A24C /* BGMVLC.m */; }; 1CD9894E1ECFFCFC0014BBBF /* BGMVOX.m in Sources */ = {isa = PBXBuildFile; fileRef = 273F10DE1CC3D0B900C1C6DA /* BGMVOX.m */; }; + D7A1B2C30001000000000004 /* BGMTidal.m in Sources */ = {isa = PBXBuildFile; fileRef = D7A1B2C30001000000000002 /* BGMTidal.m */; }; 1CD9894F1ECFFCFC0014BBBF /* BGMPreferencesMenu.mm in Sources */ = {isa = PBXBuildFile; fileRef = 1C0BD0A71BF1B029004F4CF5 /* BGMPreferencesMenu.mm */; }; 1CD989501ECFFCFC0014BBBF /* BGMAboutPanel.m in Sources */ = {isa = PBXBuildFile; fileRef = 27D1D6BA1DD7226C0049E707 /* BGMAboutPanel.m */; }; 1CD989511ECFFCFC0014BBBF /* BGMAutoPauseMusicPrefs.mm in Sources */ = {isa = PBXBuildFile; fileRef = 1C0BD0A41BF1A8E6004F4CF5 /* BGMAutoPauseMusicPrefs.mm */; }; @@ -183,6 +184,7 @@ 271677BA1C6CBDFA0080B0A2 /* CACFNumber.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 271677B81C6CBDFA0080B0A2 /* CACFNumber.cpp */; settings = {COMPILER_FLAGS = "-frandom-seed=BGMApp-CACFNumber.cpp"; }; }; 27379B8A1C7C562D0084A24C /* BGMVLC.m in Sources */ = {isa = PBXBuildFile; fileRef = 27379B891C7C562D0084A24C /* BGMVLC.m */; settings = {COMPILER_FLAGS = "-frandom-seed=BGMApp-BGMVLC.m"; }; }; 273F10DF1CC3D0B900C1C6DA /* BGMVOX.m in Sources */ = {isa = PBXBuildFile; fileRef = 273F10DE1CC3D0B900C1C6DA /* BGMVOX.m */; settings = {COMPILER_FLAGS = "-frandom-seed=BGMApp-BGMVOX.m"; }; }; + D7A1B2C30001000000000003 /* BGMTidal.m in Sources */ = {isa = PBXBuildFile; fileRef = D7A1B2C30001000000000002 /* BGMTidal.m */; settings = {COMPILER_FLAGS = "-frandom-seed=BGMApp-BGMTidal.m"; }; }; 2743C9EB1D852B360089613B /* BGMMusicPlayers.mm in Sources */ = {isa = PBXBuildFile; fileRef = 2743C9E81D852B350089613B /* BGMMusicPlayers.mm */; settings = {COMPILER_FLAGS = "-frandom-seed=BGMApp-BGMMusicPlayers.mm"; }; }; 2743C9EC1D852B360089613B /* BGMScriptingBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = 2743C9EA1D852B350089613B /* BGMScriptingBridge.m */; settings = {COMPILER_FLAGS = "-frandom-seed=BGMApp-BGMScriptingBridge.m"; }; }; 2743C9F11D853FBB0089613B /* BGMUserDefaults.m in Sources */ = {isa = PBXBuildFile; fileRef = 2743C9F01D853FBB0089613B /* BGMUserDefaults.m */; settings = {COMPILER_FLAGS = "-frandom-seed=BGMApp-BGMUserDefaults.m"; }; }; @@ -194,6 +196,7 @@ 2743CA061D86D41C0089613B /* BGMSpotify.m in Sources */ = {isa = PBXBuildFile; fileRef = 1C2336DE1BEAE10C004C1C4E /* BGMSpotify.m */; }; 2743CA071D86D41C0089613B /* BGMVLC.m in Sources */ = {isa = PBXBuildFile; fileRef = 27379B891C7C562D0084A24C /* BGMVLC.m */; }; 2743CA081D86D41C0089613B /* BGMVOX.m in Sources */ = {isa = PBXBuildFile; fileRef = 273F10DE1CC3D0B900C1C6DA /* BGMVOX.m */; }; + D7A1B2C30001000000000005 /* BGMTidal.m in Sources */ = {isa = PBXBuildFile; fileRef = D7A1B2C30001000000000002 /* BGMTidal.m */; }; 2743CA091D86D41C0089613B /* BGMUserDefaults.m in Sources */ = {isa = PBXBuildFile; fileRef = 2743C9F01D853FBB0089613B /* BGMUserDefaults.m */; }; 2743CA0A1D86D52D0089613B /* BGMAudioDeviceManager.mm in Sources */ = {isa = PBXBuildFile; fileRef = 1CED616B1C316E1A002CAFCF /* BGMAudioDeviceManager.mm */; }; 2743CA0C1D86D7FA0089613B /* CACFArray.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 1CC1DF7D1BE5068A00FB8FE4 /* CACFArray.cpp */; }; @@ -398,6 +401,8 @@ 273F10DC1CC3CF9C00C1C6DA /* VOX.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = VOX.h; path = "Music Players/VOX.h"; sourceTree = ""; }; 273F10DD1CC3D0B900C1C6DA /* BGMVOX.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = BGMVOX.h; path = "Music Players/BGMVOX.h"; sourceTree = ""; }; 273F10DE1CC3D0B900C1C6DA /* BGMVOX.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = BGMVOX.m; path = "Music Players/BGMVOX.m"; sourceTree = ""; }; + D7A1B2C30001000000000001 /* BGMTidal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = BGMTidal.h; path = "Music Players/BGMTidal.h"; sourceTree = ""; }; + D7A1B2C30001000000000002 /* BGMTidal.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = BGMTidal.m; path = "Music Players/BGMTidal.m"; sourceTree = ""; }; 2743C9E71D852B350089613B /* BGMMusicPlayers.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = BGMMusicPlayers.h; path = "Music Players/BGMMusicPlayers.h"; sourceTree = ""; }; 2743C9E81D852B350089613B /* BGMMusicPlayers.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = BGMMusicPlayers.mm; path = "Music Players/BGMMusicPlayers.mm"; sourceTree = ""; }; 2743C9E91D852B350089613B /* BGMScriptingBridge.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = BGMScriptingBridge.h; path = "Music Players/BGMScriptingBridge.h"; sourceTree = ""; }; @@ -614,6 +619,8 @@ 27379B891C7C562D0084A24C /* BGMVLC.m */, 273F10DD1CC3D0B900C1C6DA /* BGMVOX.h */, 273F10DE1CC3D0B900C1C6DA /* BGMVOX.m */, + D7A1B2C30001000000000001 /* BGMTidal.h */, + D7A1B2C30001000000000002 /* BGMTidal.m */, 27379B841C7C53BE0084A24C /* Supporting Files */, ); name = "Music Players"; @@ -1122,6 +1129,7 @@ 1C1962E41BC94E15008A4DF7 /* CARingBuffer.cpp in Sources */, 1C8D830B2042DE9600A838F2 /* BGMGooglePlayMusicDesktopPlayer.m in Sources */, 273F10DF1CC3D0B900C1C6DA /* BGMVOX.m in Sources */, + D7A1B2C30001000000000003 /* BGMTidal.m in Sources */, 1CC1DF811BE5068A00FB8FE4 /* CACFArray.cpp in Sources */, 279F48771DD6D73A00768A85 /* BGMHermes.m in Sources */, 1C0BD0A81BF1B029004F4CF5 /* BGMPreferencesMenu.mm in Sources */, @@ -1210,6 +1218,7 @@ 1CD9894C1ECFFCFC0014BBBF /* BGMHermes.m in Sources */, 1CD9894D1ECFFCFC0014BBBF /* BGMVLC.m in Sources */, 1CD9894E1ECFFCFC0014BBBF /* BGMVOX.m in Sources */, + D7A1B2C30001000000000004 /* BGMTidal.m in Sources */, 1CD9894F1ECFFCFC0014BBBF /* BGMPreferencesMenu.mm in Sources */, 1CD989501ECFFCFC0014BBBF /* BGMAboutPanel.m in Sources */, 1CD989511ECFFCFC0014BBBF /* BGMAutoPauseMusicPrefs.mm in Sources */, @@ -1314,6 +1323,7 @@ 2743CA071D86D41C0089613B /* BGMVLC.m in Sources */, 1CF5423D1EAAEE4300445AD8 /* BGMAudioDevice.cpp in Sources */, 2743CA081D86D41C0089613B /* BGMVOX.m in Sources */, + D7A1B2C30001000000000005 /* BGMTidal.m in Sources */, 1C62FE5223D3EB2E00B9B68E /* Mock_CAHALAudioDevice.cpp in Sources */, 1C62FE5323D3EB2E00B9B68E /* MockAudioDevice.cpp in Sources */, 2743CA091D86D41C0089613B /* BGMUserDefaults.m in Sources */, diff --git a/BGMApp/BGMApp/Music Players/BGMMusicPlayers.mm b/BGMApp/BGMApp/Music Players/BGMMusicPlayers.mm index 39a5c9c6..f184179b 100644 --- a/BGMApp/BGMApp/Music Players/BGMMusicPlayers.mm +++ b/BGMApp/BGMApp/Music Players/BGMMusicPlayers.mm @@ -36,6 +36,7 @@ #import "BGMSwinsian.h" #import "BGMMusic.h" #import "BGMGooglePlayMusicDesktopPlayer.h" +#import "BGMTidal.h" #pragma clang assume_nonnull begin @@ -58,7 +59,8 @@ - (instancetype) initWithAudioDevices:(BGMAudioDeviceManager*)devices [BGMDecibel class], [BGMHermes class], [BGMSwinsian class], - [BGMMusic class] ]; + [BGMMusic class], + [BGMTidal class] ]; // We only support Google Play Music Desktop Player on macOS 10.10 and higher. if (@available(macOS 10.10, *)) { diff --git a/BGMApp/BGMApp/Music Players/BGMTidal.h b/BGMApp/BGMApp/Music Players/BGMTidal.h new file mode 100644 index 00000000..139c8b25 --- /dev/null +++ b/BGMApp/BGMApp/Music Players/BGMTidal.h @@ -0,0 +1,39 @@ +// 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 . + +// +// BGMTidal.h +// BGMApp +// +// Copyright © 2026 Background Music contributors +// +// Auto-pause support for the TIDAL desktop app. +// +// TIDAL is an Electron app that doesn't expose an AppleScript dictionary or a local API, so unlike +// most of the other music player classes we can't use Scripting Bridge. Instead, we control it +// through the macOS Accessibility API: we read and press the first item of TIDAL's "Playback" menu +// (the play/pause toggle) directly, without visibly opening the menu. +// +// This requires the user to grant Background Music the Accessibility permission +// (System Settings > Privacy & Security > Accessibility). See BGMTidal.m for the details. +// + +// Superclass/Protocol Import +#import "BGMMusicPlayer.h" + + +@interface BGMTidal : BGMMusicPlayerBase + +@end diff --git a/BGMApp/BGMApp/Music Players/BGMTidal.m b/BGMApp/BGMApp/Music Players/BGMTidal.m new file mode 100644 index 00000000..3d97bd88 --- /dev/null +++ b/BGMApp/BGMApp/Music Players/BGMTidal.m @@ -0,0 +1,273 @@ +// 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 . + +// +// BGMTidal.m +// BGMApp +// +// Copyright © 2026 Background Music contributors +// + +// Self Include +#import "BGMTidal.h" + +// PublicUtility Includes +#import "CADebugMacros.h" + +// System Includes +#import + + +#pragma clang assume_nonnull begin + +// TIDAL's bundle ID. It's an Electron app with no AppleScript dictionary or local API. +static NSString* const kBGMTidalBundleID = @"com.tidal.desktop"; + +// TIDAL's menus can be localized, so we look for the "Playback" menu by matching its title against a +// list of known titles. Within that menu, the first item is the play/pause toggle regardless of +// locale. +// +// These are functions (rather than file-scope constants) because Objective-C array literals aren't +// compile-time constants. +// +// TODO: These lists only cover a few locales. If you use TIDAL in another language, please add its +// titles here (or file an issue). +static NSArray* BGMTidalPlaybackMenuTitles(void) { + return @[ + @"Playback", // English + @"Reproducción", // Spanish + @"Wiedergabe", // German + @"Lecture" // French + ]; +} + +// The play/pause toggle item's title reads as one of these when TIDAL is currently playing (i.e. the +// item would pause playback if pressed). +static NSArray* BGMTidalPauseTitles(void) { + return @[ + @"Pause", // English / German / French + @"Pausa", // Spanish (Italian) + @"Pausar" // Spanish (alternative) + ]; +} + +// The play/pause toggle item's title reads as one of these when TIDAL is currently paused (i.e. the +// item would start playback if pressed). +static NSArray* BGMTidalPlayTitles(void) { + return @[ + @"Play", // English + @"Reproducir", // Spanish + @"Wiedergabe starten", // German + @"Lecture" // French + ]; +} + +// The state of TIDAL's playback, as read from the play/pause toggle menu item. +typedef NS_ENUM(NSInteger, BGMTidalPlaybackState) { + // TIDAL isn't running, we couldn't read the menu, or the title didn't match any known title. + BGMTidalPlaybackStateUnknown = 0, + BGMTidalPlaybackStatePlaying, + BGMTidalPlaybackStatePaused +}; + +// Read an Accessibility attribute and return its value bridged to an ARC-managed object (or nil if +// the attribute couldn't be read). AXUIElementCopyAttributeValue follows the Create Rule, so we +// hand ownership of the returned value to ARC via CFBridgingRelease. +static id __nullable BGMTidalCopyAXAttribute(AXUIElementRef element, CFStringRef attribute) { + CFTypeRef value = NULL; + AXError error = AXUIElementCopyAttributeValue(element, attribute, &value); + + if (error != kAXErrorSuccess || value == NULL) { + return nil; + } + + return CFBridgingRelease(value); +} + +@implementation BGMTidal + +- (instancetype) init { + // If you're copying this class, replace the ID string with a new one generated by uuidgen (the + // command line tool). + if ((self = [super initWithMusicPlayerID:[BGMMusicPlayerBase makeID:@"CA99EB53-26D3-43D6-9716-55AE8FEE1540"] + name:@"TIDAL" + bundleID:kBGMTidalBundleID])) { + } + + return self; +} + +- (void) wasSelected { + [super wasSelected]; + + // Controlling TIDAL's menus requires Background Music to have the Accessibility permission. Ask + // for it now that the user has actually selected TIDAL (rather than prompting on every launch), + // mirroring the way the Scripting Bridge players call ensurePermission in wasSelected. + // + // AXIsProcessTrustedWithOptions only shows the prompt if the permission hasn't been granted yet. + // If it isn't granted, all our AX queries fail gracefully and we report TIDAL as not + // playing/paused. + NSDictionary* options = @{ (__bridge NSString*)kAXTrustedCheckOptionPrompt: @YES }; + AXIsProcessTrustedWithOptions((__bridge CFDictionaryRef)options); +} + +// Find TIDAL's play/pause toggle menu item (the first item of its "Playback" menu) and return it as +// an ARC-managed AXUIElementRef, or nil if TIDAL isn't running or the menu couldn't be found. +- (id __nullable) copyPlayPauseMenuItem { + NSRunningApplication* __nullable app = + [NSRunningApplication runningApplicationsWithBundleIdentifier:kBGMTidalBundleID].firstObject; + + if (!app) { + return nil; + } + + // AXUIElementCreateApplication follows the Create Rule, so we own the returned element. + AXUIElementRef appElement = AXUIElementCreateApplication(app.processIdentifier); + + id __nullable menuBar = BGMTidalCopyAXAttribute(appElement, kAXMenuBarAttribute); + CFRelease(appElement); + + if (!menuBar) { + // Usually because Background Music doesn't have the Accessibility permission yet. + DebugMsg("BGMTidal::copyPlayPauseMenuItem: Couldn't read TIDAL's menu bar"); + return nil; + } + + NSArray* __nullable menuBarItems = + BGMTidalCopyAXAttribute((__bridge AXUIElementRef)menuBar, kAXChildrenAttribute); + + for (id menuBarItemObj in menuBarItems) { + AXUIElementRef menuBarItem = (__bridge AXUIElementRef)menuBarItemObj; + NSString* __nullable title = BGMTidalCopyAXAttribute(menuBarItem, kAXTitleAttribute); + + if (!title || ![BGMTidalPlaybackMenuTitles() containsObject:(NSString*)title]) { + continue; + } + + // A menu bar item has a single child: the menu it opens. + NSArray* __nullable menus = BGMTidalCopyAXAttribute(menuBarItem, kAXChildrenAttribute); + id __nullable menuObj = menus.firstObject; + + if (!menuObj) { + continue; + } + + NSArray* __nullable menuItems = + BGMTidalCopyAXAttribute((__bridge AXUIElementRef)menuObj, kAXChildrenAttribute); + + // The first item of the "Playback" menu is the play/pause toggle. + return menuItems.firstObject; + } + + DebugMsg("BGMTidal::copyPlayPauseMenuItem: Couldn't find TIDAL's playback menu"); + return nil; +} + +// Read TIDAL's current playback state from the play/pause toggle menu item's title. +- (BGMTidalPlaybackState) playbackState { + // Don't prompt for the Accessibility permission here (that's done in wasSelected). If it hasn't + // been granted, the AX queries below just fail and we report the state as unknown. + if (!AXIsProcessTrusted()) { + return BGMTidalPlaybackStateUnknown; + } + + id __nullable menuItem = [self copyPlayPauseMenuItem]; + + if (!menuItem) { + return BGMTidalPlaybackStateUnknown; + } + + NSString* __nullable title = + BGMTidalCopyAXAttribute((__bridge AXUIElementRef)menuItem, kAXTitleAttribute); + + if (!title) { + return BGMTidalPlaybackStateUnknown; + } + + NSString* titleNN = (NSString*)title; + + if ([BGMTidalPauseTitles() containsObject:titleNN]) { + // The toggle would pause playback, so TIDAL is currently playing. + return BGMTidalPlaybackStatePlaying; + } + + if ([BGMTidalPlayTitles() containsObject:titleNN]) { + // The toggle would start playback, so TIDAL is currently paused. + return BGMTidalPlaybackStatePaused; + } + + DebugMsg("BGMTidal::playbackState: Unrecognized play/pause menu item title"); + return BGMTidalPlaybackStateUnknown; +} + +// Press TIDAL's play/pause toggle menu item. Returns YES if the item was found and pressed. +- (BOOL) pressPlayPauseMenuItem { + id __nullable menuItem = [self copyPlayPauseMenuItem]; + + if (!menuItem) { + return NO; + } + + AXError error = AXUIElementPerformAction((__bridge AXUIElementRef)menuItem, kAXPressAction); + + if (error != kAXErrorSuccess) { + DebugMsg("BGMTidal::pressPlayPauseMenuItem: AXUIElementPerformAction failed (%d)", error); + return NO; + } + + return YES; +} + +- (BOOL) isRunning { + // Like the other music players, we check with NSRunningApplication rather than launching TIDAL. + NSArray* instances = + [NSRunningApplication runningApplicationsWithBundleIdentifier:kBGMTidalBundleID]; + + return instances.count > 0; +} + +- (BOOL) isPlaying { + return self.playbackState == BGMTidalPlaybackStatePlaying; +} + +- (BOOL) isPaused { + return self.playbackState == BGMTidalPlaybackStatePaused; +} + +- (BOOL) pause { + // isPlaying reads the menu state (which is nil/unknown if TIDAL isn't running), so we don't need + // to check isRunning here. + if (!self.playing) { + return NO; + } + + DebugMsg("BGMTidal::pause: Pausing TIDAL"); + return [self pressPlayPauseMenuItem]; +} + +- (BOOL) unpause { + // isPaused reads the menu state (which is nil/unknown if TIDAL isn't running), so we don't need + // to check isRunning here. + if (!self.paused) { + return NO; + } + + DebugMsg("BGMTidal::unpause: Unpausing TIDAL"); + return [self pressPlayPauseMenuItem]; +} + +@end + +#pragma clang assume_nonnull end diff --git a/README.md b/README.md index f8c24627..8a703197 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ The auto-pause feature currently supports following music players: + [Hermes](http://hermesapp.org/) + [Swinsian](https://swinsian.com/) + [GPMDP](https://www.googleplaymusicdesktopplayer.com/) ++ [TIDAL](https://tidal.com/download) Adding support for a new music player is usually straightforward.[1](#f1) If you don't know how to program, or just don't feel like it, feel free to [create an issue](https://github.com/kyleneideck/BackgroundMusic/issues/new). Otherwise, see @@ -157,6 +158,12 @@ and check the box next to it. Background Music doesn't actually listen to your m the permission because it gets your system audio from its virtual input device, which macOS counts as a microphone. (We're working on it in [#177](/../../issues/177).) +If you use auto-pause with **TIDAL**, you also need to allow **Background Music** under +`System Settings > Privacy & Security > Accessibility`. TIDAL doesn't have an AppleScript API, so +Background Music pauses and unpauses it through its "Playback" menu using the macOS Accessibility +API, which requires that permission. Background Music asks for it when you select TIDAL as your +music player. + If the volume slider for an app isn't working, try looking in `More Apps` for entries like `Some App (Helper)`. For some meeting or video chat apps, you may need to do this to change the current meeting volume.