Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions BGMApp/BGMApp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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"; }; };
Expand All @@ -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 */; };
Expand Down Expand Up @@ -398,6 +401,8 @@
273F10DC1CC3CF9C00C1C6DA /* VOX.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = VOX.h; path = "Music Players/VOX.h"; sourceTree = "<group>"; };
273F10DD1CC3D0B900C1C6DA /* BGMVOX.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = BGMVOX.h; path = "Music Players/BGMVOX.h"; sourceTree = "<group>"; };
273F10DE1CC3D0B900C1C6DA /* BGMVOX.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = BGMVOX.m; path = "Music Players/BGMVOX.m"; sourceTree = "<group>"; };
D7A1B2C30001000000000001 /* BGMTidal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = BGMTidal.h; path = "Music Players/BGMTidal.h"; sourceTree = "<group>"; };
D7A1B2C30001000000000002 /* BGMTidal.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = BGMTidal.m; path = "Music Players/BGMTidal.m"; sourceTree = "<group>"; };
2743C9E71D852B350089613B /* BGMMusicPlayers.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = BGMMusicPlayers.h; path = "Music Players/BGMMusicPlayers.h"; sourceTree = "<group>"; };
2743C9E81D852B350089613B /* BGMMusicPlayers.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = BGMMusicPlayers.mm; path = "Music Players/BGMMusicPlayers.mm"; sourceTree = "<group>"; };
2743C9E91D852B350089613B /* BGMScriptingBridge.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = BGMScriptingBridge.h; path = "Music Players/BGMScriptingBridge.h"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
4 changes: 3 additions & 1 deletion BGMApp/BGMApp/Music Players/BGMMusicPlayers.mm
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
#import "BGMSwinsian.h"
#import "BGMMusic.h"
#import "BGMGooglePlayMusicDesktopPlayer.h"
#import "BGMTidal.h"


#pragma clang assume_nonnull begin
Expand All @@ -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, *)) {
Expand Down
39 changes: 39 additions & 0 deletions BGMApp/BGMApp/Music Players/BGMTidal.h
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

//
// 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<BGMMusicPlayer>

@end
273 changes: 273 additions & 0 deletions BGMApp/BGMApp/Music Players/BGMTidal.m
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

//
// BGMTidal.m
// BGMApp
//
// Copyright © 2026 Background Music contributors
//

// Self Include
#import "BGMTidal.h"

// PublicUtility Includes
#import "CADebugMacros.h"

// System Includes
#import <ApplicationServices/ApplicationServices.h>


#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<NSString*>* 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<NSString*>* 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<NSString*>* 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<NSRunningApplication*>* 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
Loading