Fix: Migrations

This commit is contained in:
Hariharan Mudaliar 2025-08-03 21:26:50 +05:30
commit be152c1531
41 changed files with 2373 additions and 1321 deletions

BIN
.DS_Store vendored

Binary file not shown.

View file

@ -16,9 +16,16 @@
7E61181A2E24071E0050006C /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = B1628B912CC260C0003D8DF3 /* SwiftUIIntrospect */; };
7E61181B2E24071E0050006C /* LottieUI in Frameworks */ = {isa = PBXBuildFile; productRef = B194240A2CD10017003E5DC2 /* LottieUI */; };
7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 146BC4362D366E9B0047C66A /* MacroVisionKit */; };
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */; };
7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */; };
BA90505C953246D0AB206451 /* MediaRemoteAdapter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; };
C61F69420FB64DA98516CFBC /* MediaRemoteAdapter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */ = {isa = PBXFileReference; lastKnownFileType = text.script.perl; path = "mediaremote-adapter.pl"; sourceTree = "<group>"; };
7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = NowPlayingTestClient; sourceTree = "<group>"; };
F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = "Frameworks/MediaRemoteAdapter.framework"; sourceTree = SOURCE_ROOT; };
F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DynamicIsland.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
@ -35,6 +42,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
BA90505C953246D0AB206451 /* MediaRemoteAdapter.framework in Frameworks */,
7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */,
7E61181B2E24071E0050006C /* LottieUI in Frameworks */,
7E61181A2E24071E0050006C /* SwiftUIIntrospect in Frameworks */,
@ -49,10 +57,43 @@
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXCopyFilesBuildPhase section */
B933CDB48C2F42C2887A9836 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 12;
dstPath = "";
dstSubfolderSpec = 10;
files = (
C61F69420FB64DA98516CFBC /* MediaRemoteAdapter.framework in Embed Frameworks */,
);
name = "Embed Frameworks";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXGroup section */
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */ = {
isa = PBXGroup;
children = (
7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */,
7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */,
F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */,
);
path = "mediaremote-adapter";
sourceTree = "<group>";
};
7E81304F2E3FB42700E08F3F /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
F54406DE2CD4987A00C7AFD9 = {
isa = PBXGroup;
children = (
7E81304F2E3FB42700E08F3F /* Frameworks */,
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */,
F54406E92CD4987A00C7AFD9 /* DynamicIsland */,
F54406E82CD4987A00C7AFD9 /* Products */,
);
@ -76,6 +117,7 @@
F54406E32CD4987A00C7AFD9 /* Sources */,
F54406E42CD4987A00C7AFD9 /* Frameworks */,
F54406E52CD4987A00C7AFD9 /* Resources */,
B933CDB48C2F42C2887A9836 /* Embed Frameworks */,
);
buildRules = (
);
@ -151,6 +193,8 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */,
7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -296,6 +340,12 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
ENABLE_PREVIEWS = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/Frameworks",
"$(PROJECT_DIR)/mediaremote-adapter",
);
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Need for Spotify and Apple Music";
INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode";
@ -323,6 +373,12 @@
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
ENABLE_PREVIEWS = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/Frameworks",
"$(PROJECT_DIR)/mediaremote-adapter",
);
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Need for Spotify and Apple Music";
INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode";

View file

@ -0,0 +1,533 @@
// !$*UTF8*$!
{
archiveVersion = 1;
classes = {
};
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
7E6118142E24071E0050006C /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = 14D032192C68F32E0096E6A1 /* LaunchAtLogin */; };
7E6118152E24071E0050006C /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 14D0321C2C68F3350096E6A1 /* Sparkle */; };
7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = B18654382C6F4990000B926A /* KeyboardShortcuts */; };
7E6118172E24071E0050006C /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 9A987A0F2C73CA8D005CA465 /* Collections */; };
7E6118182E24071E0050006C /* TheBoringWorkerNotifier in Frameworks */ = {isa = PBXBuildFile; productRef = 1485442E2C8F049C00943381 /* TheBoringWorkerNotifier */; };
7E6118192E24071E0050006C /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = B19016212CC15B3D00E3F12E /* Defaults */; };
7E61181A2E24071E0050006C /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = B1628B912CC260C0003D8DF3 /* SwiftUIIntrospect */; };
7E61181B2E24071E0050006C /* LottieUI in Frameworks */ = {isa = PBXBuildFile; productRef = B194240A2CD10017003E5DC2 /* LottieUI */; };
7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 146BC4362D366E9B0047C66A /* MacroVisionKit */; };
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */; };
7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */ = {isa = PBXFileReference; lastKnownFileType = text.script.perl; path = "mediaremote-adapter.pl"; sourceTree = "<group>"; };
7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = NowPlayingTestClient; sourceTree = "<group>"; };
F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DynamicIsland.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
F54406E92CD4987A00C7AFD9 /* DynamicIsland */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = DynamicIsland;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
F54406E42CD4987A00C7AFD9 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */,
7E61181B2E24071E0050006C /* LottieUI in Frameworks */,
7E61181A2E24071E0050006C /* SwiftUIIntrospect in Frameworks */,
7E6118192E24071E0050006C /* Defaults in Frameworks */,
7E6118182E24071E0050006C /* TheBoringWorkerNotifier in Frameworks */,
7E6118172E24071E0050006C /* Collections in Frameworks */,
7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */,
7E6118152E24071E0050006C /* Sparkle in Frameworks */,
7E6118142E24071E0050006C /* LaunchAtLogin in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */ = {
isa = PBXGroup;
children = (
7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */,
7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */,
);
path = "mediaremote-adapter";
sourceTree = "<group>";
};
7E81304F2E3FB42700E08F3F /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
F54406DE2CD4987A00C7AFD9 = {
isa = PBXGroup;
children = (
7E81304F2E3FB42700E08F3F /* Frameworks */,
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */,
F54406E92CD4987A00C7AFD9 /* DynamicIsland */,
F54406E82CD4987A00C7AFD9 /* Products */,
);
sourceTree = "<group>";
};
F54406E82CD4987A00C7AFD9 /* Products */ = {
isa = PBXGroup;
children = (
F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */,
);
name = Products;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
F54406E62CD4987A00C7AFD9 /* DynamicIsland */ = {
isa = PBXNativeTarget;
buildConfigurationList = F54406F62CD4987B00C7AFD9 /* Build configuration list for PBXNativeTarget "DynamicIsland" */;
buildPhases = (
F54406E32CD4987A00C7AFD9 /* Sources */,
F54406E42CD4987A00C7AFD9 /* Frameworks */,
F54406E52CD4987A00C7AFD9 /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
F54406E92CD4987A00C7AFD9 /* DynamicIsland */,
);
name = DynamicIsland;
packageProductDependencies = (
14D032192C68F32E0096E6A1 /* LaunchAtLogin */,
14D0321C2C68F3350096E6A1 /* Sparkle */,
B18654382C6F4990000B926A /* KeyboardShortcuts */,
9A987A0F2C73CA8D005CA465 /* Collections */,
1485442E2C8F049C00943381 /* TheBoringWorkerNotifier */,
B19016212CC15B3D00E3F12E /* Defaults */,
B1628B912CC260C0003D8DF3 /* SwiftUIIntrospect */,
B194240A2CD10017003E5DC2 /* LottieUI */,
146BC4362D366E9B0047C66A /* MacroVisionKit */,
);
productName = DynamicIsland;
productReference = F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
F54406DF2CD4987A00C7AFD9 /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1600;
LastUpgradeCheck = 1600;
TargetAttributes = {
F54406E62CD4987A00C7AFD9 = {
CreatedOnToolsVersion = 16.0;
};
};
};
buildConfigurationList = F54406E22CD4987A00C7AFD9 /* Build configuration list for PBXProject "DynamicIsland" */;
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
en,
Base,
);
mainGroup = F54406DE2CD4987A00C7AFD9;
minimizedProjectReferenceProxies = 1;
packageReferences = (
F54407082CD4A23F00C7AFD9 /* XCRemoteSwiftPackageReference "swift-collections" */,
14D032182C68F32E0096E6A1 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */,
14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */,
B18654372C6F4990000B926A /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */,
9A987A112C73CAA1005CA465 /* XCRemoteSwiftPackageReference "Pow" */,
1485442D2C8F049C00943381 /* XCRemoteSwiftPackageReference "TheBoringWorkerNotifier" */,
B19016202CC15B3D00E3F12E /* XCRemoteSwiftPackageReference "Defaults" */,
B1628B902CC260C0003D8DF3 /* XCRemoteSwiftPackageReference "swiftui-introspect" */,
B19424072CD0DB52003E5DC2 /* XCRemoteSwiftPackageReference "LottieUI" */,
146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = F54406E82CD4987A00C7AFD9 /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
F54406E62CD4987A00C7AFD9 /* DynamicIsland */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
F54406E52CD4987A00C7AFD9 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */,
7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
F54406E32CD4987A00C7AFD9 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
F54406F42CD4987B00C7AFD9 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = dwarf;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_DYNAMIC_NO_PIC = NO;
GCC_NO_COMMON_BLOCKS = YES;
GCC_OPTIMIZATION_LEVEL = 0;
GCC_PREPROCESSOR_DEFINITIONS = (
"DEBUG=1",
"$(inherited)",
);
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
name = Debug;
};
F54406F52CD4987B00C7AFD9 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_MODULES = YES;
CLANG_ENABLE_OBJC_ARC = YES;
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
CLANG_WARN_BOOL_CONVERSION = YES;
CLANG_WARN_COMMA = YES;
CLANG_WARN_CONSTANT_CONVERSION = YES;
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_EMPTY_BODY = YES;
CLANG_WARN_ENUM_CONVERSION = YES;
CLANG_WARN_INFINITE_RECURSION = YES;
CLANG_WARN_INT_CONVERSION = YES;
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
CLANG_WARN_STRICT_PROTOTYPES = YES;
CLANG_WARN_SUSPICIOUS_MOVE = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
GCC_NO_COMMON_BLOCKS = YES;
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
GCC_WARN_UNDECLARED_SELECTOR = YES;
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MACOSX_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
};
F54406F72CD4987B00C7AFD9 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
ENABLE_PREVIEWS = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)",
"$(SDKROOT)/../../../../../../../../../Users/hariharan/HobbyProjects/DynamicIsland",
"$(PROJECT_DIR)/mediaremote-adapter\\ 2",
);
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Need for Spotify and Apple Music";
INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Debug;
};
F54406F82CD4987B00C7AFD9 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
ENABLE_PREVIEWS = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)",
"$(SDKROOT)/../../../../../../../../../Users/hariharan/HobbyProjects/DynamicIsland",
"$(PROJECT_DIR)/mediaremote-adapter\\ 2",
);
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Need for Spotify and Apple Music";
INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
F54406E22CD4987A00C7AFD9 /* Build configuration list for PBXProject "DynamicIsland" */ = {
isa = XCConfigurationList;
buildConfigurations = (
F54406F42CD4987B00C7AFD9 /* Debug */,
F54406F52CD4987B00C7AFD9 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
F54406F62CD4987B00C7AFD9 /* Build configuration list for PBXNativeTarget "DynamicIsland" */ = {
isa = XCConfigurationList;
buildConfigurations = (
F54406F72CD4987B00C7AFD9 /* Debug */,
F54406F82CD4987B00C7AFD9 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/TheBoredTeam/MacroVisionKit";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 0.1.0;
};
};
1485442D2C8F049C00943381 /* XCRemoteSwiftPackageReference "TheBoringWorkerNotifier" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/TheBoredTeam/TheBoringWorkerNotifier.git";
requirement = {
branch = main;
kind = branch;
};
};
14D032182C68F32E0096E6A1 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sindresorhus/LaunchAtLogin-Modern";
requirement = {
branch = main;
kind = branch;
};
};
14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sparkle-project/Sparkle";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.6.4;
};
};
9A987A112C73CAA1005CA465 /* XCRemoteSwiftPackageReference "Pow" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/EmergeTools/Pow";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.0.4;
};
};
B1628B902CC260C0003D8DF3 /* XCRemoteSwiftPackageReference "swiftui-introspect" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/siteline/swiftui-introspect";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.3.0;
};
};
B18654372C6F4990000B926A /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sindresorhus/KeyboardShortcuts";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.2.4;
};
};
B19016202CC15B3D00E3F12E /* XCRemoteSwiftPackageReference "Defaults" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/sindresorhus/Defaults";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 9.0.2;
};
};
B19424072CD0DB52003E5DC2 /* XCRemoteSwiftPackageReference "LottieUI" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/jasudev/LottieUI.git";
requirement = {
branch = main;
kind = branch;
};
};
F54407082CD4A23F00C7AFD9 /* XCRemoteSwiftPackageReference "swift-collections" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-collections.git";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 1.1.4;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
146BC4362D366E9B0047C66A /* MacroVisionKit */ = {
isa = XCSwiftPackageProductDependency;
package = 146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */;
productName = MacroVisionKit;
};
1485442E2C8F049C00943381 /* TheBoringWorkerNotifier */ = {
isa = XCSwiftPackageProductDependency;
package = 1485442D2C8F049C00943381 /* XCRemoteSwiftPackageReference "TheBoringWorkerNotifier" */;
productName = TheBoringWorkerNotifier;
};
14D032192C68F32E0096E6A1 /* LaunchAtLogin */ = {
isa = XCSwiftPackageProductDependency;
package = 14D032182C68F32E0096E6A1 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */;
productName = LaunchAtLogin;
};
14D0321C2C68F3350096E6A1 /* Sparkle */ = {
isa = XCSwiftPackageProductDependency;
package = 14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
9A987A0F2C73CA8D005CA465 /* Collections */ = {
isa = XCSwiftPackageProductDependency;
package = F54407082CD4A23F00C7AFD9 /* XCRemoteSwiftPackageReference "swift-collections" */;
productName = Collections;
};
B1628B912CC260C0003D8DF3 /* SwiftUIIntrospect */ = {
isa = XCSwiftPackageProductDependency;
package = B1628B902CC260C0003D8DF3 /* XCRemoteSwiftPackageReference "swiftui-introspect" */;
productName = SwiftUIIntrospect;
};
B18654382C6F4990000B926A /* KeyboardShortcuts */ = {
isa = XCSwiftPackageProductDependency;
package = B18654372C6F4990000B926A /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */;
productName = KeyboardShortcuts;
};
B19016212CC15B3D00E3F12E /* Defaults */ = {
isa = XCSwiftPackageProductDependency;
package = B19016202CC15B3D00E3F12E /* XCRemoteSwiftPackageReference "Defaults" */;
productName = Defaults;
};
B194240A2CD10017003E5DC2 /* LottieUI */ = {
isa = XCSwiftPackageProductDependency;
package = B19424072CD0DB52003E5DC2 /* XCRemoteSwiftPackageReference "LottieUI" */;
productName = LottieUI;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = F54406DF2CD4987A00C7AFD9 /* Project object */;
}

View file

@ -55,10 +55,10 @@ struct DynamicNotchApp: App {
workspace.openApplication(at: appURL, configuration: configuration)
}
NSApplication.shared.terminate(nil)
NSApplication.shared.terminate(self)
}
Button("Quit", role: .destructive) {
NSApp.terminate(nil)
NSApplication.shared.terminate(self)
}
.keyboardShortcut(KeyEquivalent("Q"), modifiers: .command)
}
@ -80,7 +80,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
var whatsNewWindow: NSWindow?
var timer: Timer?
let calendarManager = CalendarManager()
let calendarManager = CalendarManager.shared
let webcamManager = WebcamManager.shared
var closeNotchWorkItem: DispatchWorkItem?
private var previousScreens: [NSScreen]?

View file

@ -52,8 +52,6 @@ class DynamicIslandViewCoordinator: ObservableObject {
var notifier: TheBoringWorkerNotifier = .init()
@Published var currentView: NotchViews = .home
private var sneakPeekDispatch: DispatchWorkItem?
private var expandingViewDispatch: DispatchWorkItem?
@AppStorage("firstLaunch") var firstLaunch: Bool = true
@ -109,21 +107,35 @@ class DynamicIslandViewCoordinator: ObservableObject {
}
@objc func sneakPeekEvent(_ notification: Notification) {
let decoder = JSONDecoder()
if let decodedData = try? decoder.decode(SharedSneakPeek.self, from: notification.userInfo?.first?.value as! Data) {
let contentType = decodedData.type == "brightness" ? SneakContentType.brightness : decodedData.type == "volume" ? SneakContentType.volume : decodedData.type == "backlight" ? SneakContentType.backlight : decodedData.type == "mic" ? SneakContentType.mic : decodedData.type == "timer" ? SneakContentType.timer : SneakContentType.brightness
let decoder = JSONDecoder()
if let decodedData = try? decoder.decode(
SharedSneakPeek.self, from: notification.userInfo?.first?.value as! Data)
{
let contentType =
decodedData.type == "brightness"
? SneakContentType.brightness
: decodedData.type == "volume"
? SneakContentType.volume
: decodedData.type == "backlight"
? SneakContentType.backlight
: decodedData.type == "mic"
? SneakContentType.mic
: decodedData.type == "timer"
? SneakContentType.timer : SneakContentType.brightness
let formatter = NumberFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.numberStyle = .decimal
let value = CGFloat((formatter.number(from: decodedData.value) ?? 0.0).floatValue)
let icon = decodedData.icon
let value = CGFloat((NumberFormatter().number(from: decodedData.value) ?? 0.0).floatValue)
let icon = decodedData.icon
print(decodedData)
toggleSneakPeek(status: decodedData.show, type: contentType, value: value, icon: icon)
} else {
print("Failed to decode JSON data")
}
print("Decoded: \(decodedData), Parsed value: \(value)")
toggleSneakPeek(status: decodedData.show, type: contentType, value: value, icon: icon)
} else {
print("Failed to decode JSON data")
}
}
func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") {
sneakPeekDuration = duration
@ -148,35 +160,41 @@ class DynamicIslandViewCoordinator: ObservableObject {
}
private var sneakPeekDuration: TimeInterval = 1.5
@Published var sneakPeek: sneakPeek = .init() {
didSet {
if sneakPeek.show {
sneakPeekDispatch?.cancel()
private var sneakPeekTask: Task<Void, Never>?
sneakPeekDispatch = DispatchWorkItem { [weak self] in
guard let self = self else { return }
withAnimation {
self.toggleSneakPeek(status: false, type: SneakContentType.music)
self.sneakPeekDuration = 1.5
}
// Helper function to manage sneakPeek timer using Swift Concurrency
private func scheduleSneakPeekHide(after duration: TimeInterval) {
sneakPeekTask?.cancel()
sneakPeekTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(duration))
guard let self = self, !Task.isCancelled else { return }
await MainActor.run {
withAnimation {
self.toggleSneakPeek(status: false, type: .music)
self.sneakPeekDuration = 1.5
}
DispatchQueue.main
.asyncAfter(
deadline: .now() + self.sneakPeekDuration,
execute: sneakPeekDispatch!
)
}
}
}
func toggleExpandingView(status: Bool, type: SneakContentType, value: CGFloat = 0, browser: BrowserType = .chromium) {
if expandingView.show {
withAnimation(.smooth) {
self.expandingView.show = false
@Published var sneakPeek: sneakPeek = .init() {
didSet {
if sneakPeek.show {
scheduleSneakPeekHide(after: sneakPeekDuration)
} else {
sneakPeekTask?.cancel()
}
}
DispatchQueue.main.async {
}
func toggleExpandingView(
status: Bool,
type: SneakContentType,
value: CGFloat = 0,
browser: BrowserType = .chromium
) {
Task { @MainActor in
withAnimation(.smooth) {
self.expandingView.show = status
self.expandingView.type = type
@ -185,18 +203,21 @@ class DynamicIslandViewCoordinator: ObservableObject {
}
}
}
private var expandingViewTask: Task<Void, Never>?
@Published var expandingView: ExpandedItem = .init() {
didSet {
if expandingView.show {
expandingViewDispatch?.cancel()
expandingViewDispatch = DispatchWorkItem { [weak self] in
guard let self = self else { return }
self.toggleExpandingView(status: false, type: SneakContentType.battery)
expandingViewTask?.cancel()
let duration: TimeInterval = (expandingView.type == .download ? 2 : 3)
expandingViewTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(duration))
guard let self = self, !Task.isCancelled else { return }
self.toggleExpandingView(status: false, type: .battery)
}
DispatchQueue.main.asyncAfter(deadline: .now() + (expandingView.type == .download ? 2 : 3), execute: expandingViewDispatch!)
} else {
expandingViewTask?.cancel()
}
}
}

View file

@ -34,6 +34,9 @@
},
"%lld" : {
},
"%lld items" : {
},
"%lld%%" : {
@ -49,18 +52,6 @@
},
"• New feature 1" : {
},
"3 items" : {
},
"5 items" : {
},
"7 items" : {
},
"10 items" : {
},
"About" : {
"localizations" : {
@ -85,9 +76,6 @@
},
"Access Denied" : {
},
"Actions" : {
},
"Activate" : {
@ -253,14 +241,11 @@
},
"Check for Updates…" : {
},
"Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF." : {
},
"Choose a Music Source" : {
},
"Choose File" : {
"Choose which performance graphs to display. 1-3 graphs use horizontal layout, 4+ graphs use compressed grid layout." : {
},
"Circle" : {
@ -268,18 +253,15 @@
},
"Clear" : {
},
"Clear Clipboard History" : {
},
"Clear Data" : {
},
"Clipboard" : {
},
"Clipboard History" : {
},
"Clipboard history will be tracked and accessible via the header button." : {
},
"Clipboard Manager" : {
@ -296,27 +278,15 @@
}
}
}
},
"Configure how many recent clipboard items to remember (minimum 3)." : {
},
"Continue" : {
},
"Controls" : {
},
"Copy something to get started" : {
},
"CPU Usage" : {
},
"Current custom timer: %@" : {
},
"Current History" : {
},
"Current Items" : {
},
"Custom" : {
@ -340,27 +310,15 @@
},
"Custom notch size - %.0f" : {
},
"Custom Timer" : {
},
"Custom Timer Duration" : {
},
"Custom vizualizers (Lottie)" : {
},
"Custom: %@" : {
},
"Customize in Settings" : {
},
"Default" : {
},
"Default: dynamic.m4a" : {
},
"Description" : {
@ -370,12 +328,6 @@
},
"Disabled" : {
},
"Disk Read" : {
},
"Disk Write" : {
},
"Download" : {
@ -421,9 +373,6 @@
},
"dynamic.island" : {
},
"Each graph can be individually enabled or disabled. Network activity shows download/upload speeds, and disk I/O shows read/write speeds." : {
},
"Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" : {
@ -464,9 +413,6 @@
},
"Enable music live activity" : {
},
"Enable or disable the timer functionality in the Dynamic Island. The live activity toggle controls whether timer progress is shown in the expanded view." : {
},
"Enable sneak peek" : {
@ -477,7 +423,13 @@
"Enable timer feature in Settings to use this tab" : {
},
"Enable timer live activity" : {
"Enable to access timer functionality in the Dynamic Island." : {
},
"Enable to monitor CPU, Memory, GPU, Network and Disk usage." : {
},
"Enable to track clipboard history and access recent items." : {
},
"Enhance your experience with HUDs" : {
@ -539,9 +491,6 @@
},
"Got it!" : {
},
"GPU Usage" : {
},
"Gradient" : {
@ -549,7 +498,7 @@
"Graph Visibility" : {
},
"Hide DynamicIsland Options" : {
"Hide BoringNotch Options" : {
},
"Hide only when NowPlaying app is in fullscreen" : {
@ -561,7 +510,7 @@
"High" : {
},
"History Size" : {
"History size" : {
},
"https://github.com/th-ch/youtube-music" : {
@ -592,9 +541,6 @@
},
"Installed extensions" : {
},
"Last Updated" : {
},
"Later" : {
@ -604,9 +550,6 @@
},
"Live" : {
},
"Live Performance Data" : {
},
"Lottie JSON URL" : {
@ -648,6 +591,9 @@
},
"Media change with horizontal gestures" : {
},
"Media controls" : {
},
"Media inactivity timeout" : {
"localizations" : {
@ -674,9 +620,6 @@
},
"Medium" : {
},
"Memory Usage" : {
},
"Menubar icon" : {
"extractionState" : "stale",
@ -697,21 +640,12 @@
},
"Minimum hover duration" : {
},
"Minutes" : {
},
"Mirror" : {
},
"Mirror shape" : {
},
"Monitor clipboard changes and keep a history of recent copies. Use Cmd+Shift+V to quickly access clipboard history." : {
},
"Monitoring Status" : {
},
"More" : {
@ -727,12 +661,6 @@
},
"Need Restart" : {
},
"Network Download" : {
},
"Network Upload" : {
},
"Never hide" : {
@ -884,9 +812,6 @@
},
"Remember last tab" : {
},
"Reset to Default" : {
},
"Restart" : {
@ -910,9 +835,6 @@
}
}
}
},
"Seconds" : {
},
"Select Calendars" : {
@ -925,9 +847,6 @@
},
"Selected animation" : {
},
"Set a custom duration for the timer. This will be used when you press the 'Custom' button in the timer interface." : {
},
"Settings" : {
"localizations" : {
@ -955,9 +874,6 @@
}
}
}
},
"Show calendar" : {
},
"Show charging indicator" : {
"extractionState" : "stale",
@ -997,6 +913,9 @@
},
"Show on all displays" : {
},
"Show shuffle and repeat buttons" : {
},
"Slider color" : {
@ -1025,15 +944,6 @@
},
"Start" : {
},
"Start Demo Timer:" : {
},
"Start Monitoring" : {
},
"Starts a 5-minute demo timer to test the timer live activity feature." : {
},
"Stats" : {
@ -1041,10 +951,10 @@
"Stats Disabled" : {
},
"Stop" : {
"Stats tab will appear in the notch when enabled." : {
},
"Stop Monitoring" : {
"Stop" : {
},
"Stopped" : {
@ -1063,14 +973,11 @@
"System features" : {
},
"The clipboard icon appears in the header next to the settings gear when enabled. Use the global shortcut Cmd+Shift+V or click the icon to access history." : {
"System Monitoring" : {
},
"TheDynamicIsland" : {
},
"This will permanently delete all stored clipboard history. The clipboard button is located to the left of the settings gear in the header." : {
},
"Time to Full Charge: %lld min" : {
@ -1081,10 +988,7 @@
"Timer Disabled" : {
},
"Timer Feature" : {
},
"Timer Sound" : {
"Timer tab will appear in the notch when enabled." : {
},
"Toggle Notch Open:" : {
@ -1107,9 +1011,6 @@
},
"unmuted" : {
},
"Update" : {
},
"Upgrade to Pro" : {
@ -1145,12 +1046,6 @@
},
"What's New" : {
},
"When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : {
},
"With %lld graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row." : {
},
"You can now enjoy the app. If you want to tweak things further, you can always visit the settings." : {

View file

@ -2,7 +2,7 @@
// AppleMusicController.swift
// DynamicIsland
//
// Created by Alexander Greco on 2025-03-29.
// Created by Alexander on 2025-03-29.
//
import Foundation
@ -15,110 +15,125 @@ class AppleMusicController: MediaControllerProtocol {
bundleIdentifier: "com.apple.Music"
)
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState }
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
$playbackState.eraseToAnyPublisher()
}
var isWorking: Bool {
return true // AppleMusic controller always works
}
private var notificationTask: Task<Void, Never>?
// MARK: - Initialization
init() {
setupPlaybackStateChangeObserver()
DispatchQueue.main.async { [weak self] in
if self?.isActive() == true {
self?.updatePlaybackInfo()
Task {
if isActive() {
await updatePlaybackInfo()
}
}
}
private func setupPlaybackStateChangeObserver() {
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(updatePlaybackInfo),
name: NSNotification.Name("com.apple.Music.playerInfo"),
object: nil
)
notificationTask = Task { @Sendable [weak self] in
let notifications = DistributedNotificationCenter.default().notifications(
named: NSNotification.Name("com.apple.Music.playerInfo")
)
for await _ in notifications {
await self?.updatePlaybackInfo()
}
}
}
deinit {
DistributedNotificationCenter.default().removeObserver(
self,
name: NSNotification.Name("com.apple.Music.playerInfo"),
object: nil
)
notificationTask?.cancel()
}
// MARK: - Protocol Implementation
func play() {
executeCommand("play")
func play() async {
await executeCommand("play")
}
func pause() {
executeCommand("pause")
func pause() async {
await executeCommand("pause")
}
func togglePlay() {
executeCommand("playpause")
func togglePlay() async {
await executeCommand("playpause")
}
func nextTrack() {
executeCommand("next track")
func nextTrack() async {
await executeCommand("next track")
}
func previousTrack() {
executeCommand("previous track")
func previousTrack() async {
await executeCommand("previous track")
}
func seek(to time: Double) {
executeCommand("set player position to \(time)")
updatePlaybackInfo()
func seek(to time: Double) async {
await executeCommand("set player position to \(time)")
await updatePlaybackInfo()
}
func toggleShuffle() async {
await executeCommand("set shuffle enabled to not shuffle enabled")
playbackState.isShuffled.toggle()
}
func toggleRepeat() async {
await executeCommand("""
if song repeat is off then
set song repeat to all
else if song repeat is all then
set song repeat to one
else
set song repeat to off
end if
""")
if playbackState.repeatMode == .off {
playbackState.repeatMode = .all
} else if playbackState.repeatMode == .all {
playbackState.repeatMode = .one
} else {
playbackState.repeatMode = .off
}
}
func isActive() -> Bool {
let runningApps = NSWorkspace.shared.runningApplications
return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier }
return runningApps.contains { $0.bundleIdentifier == "com.apple.Music" }
}
func updatePlaybackInfo() async {
guard let descriptor = try? await fetchPlaybackInfoAsync() else { return }
guard descriptor.numberOfItems >= 8 else { return }
var updatedState = self.playbackState
updatedState.isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
updatedState.title = descriptor.atIndex(2)?.stringValue ?? "Unknown"
updatedState.artist = descriptor.atIndex(3)?.stringValue ?? "Unknown"
updatedState.album = descriptor.atIndex(4)?.stringValue ?? "Unknown"
updatedState.currentTime = descriptor.atIndex(5)?.doubleValue ?? 0
updatedState.duration = descriptor.atIndex(6)?.doubleValue ?? 0
updatedState.isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
let repeatModeValue = descriptor.atIndex(8)?.int32Value ?? 0
updatedState.repeatMode = RepeatMode(rawValue: Int(repeatModeValue)) ?? .off
updatedState.artwork = descriptor.atIndex(9)?.data as Data?
self.playbackState = updatedState
}
// MARK: - Private Methods
@objc func updatePlaybackInfo() {
guard let descriptor = fetchPlaybackInfo() else { return }
guard descriptor.numberOfItems >= 8 else { return }
let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
let currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown"
let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown"
let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown"
let currentTime = descriptor.atIndex(5)?.doubleValue ?? 0
let duration = descriptor.atIndex(6)?.doubleValue ?? 0
let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false
let artworkData = descriptor.atIndex(9)?.data as Data?
let updatedState = PlaybackState(
bundleIdentifier: "com.apple.Music",
isPlaying: isPlaying,
title: currentTrack,
artist: currentTrackArtist,
album: currentTrackAlbum,
currentTime: currentTime,
duration: duration,
playbackRate: 1,
isShuffled: isShuffled,
isRepeating: isRepeating,
lastUpdated: Date(),
artwork: (artworkData?.isEmpty ?? true) ? nil : artworkData
)
DispatchQueue.main.async { [weak self] in
self?.playbackState = updatedState
}
}
private func executeCommand(_ command: String) {
private func executeCommand(_ command: String) async {
let script = "tell application \"Music\" to \(command)"
Task {
try? await AppleScriptHelper.executeVoid(script)
}
try? await AppleScriptHelper.executeVoid(script)
}
private func fetchPlaybackInfo() -> NSAppleEventDescriptor? {
private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? {
let script = """
tell application "Music"
set isRunning to true
@ -129,26 +144,28 @@ class AppleMusicController: MediaControllerProtocol {
set currentTrackAlbum to album of current track
set trackPosition to player position
set trackDuration to duration of current track
set shuffleState to false
set repeatState to false
set shuffleState to shuffle enabled
set repeatState to song repeat
if repeatState is off then
set repeatValue to 0
else if repeatState is all then
set repeatValue to 1
else if repeatState is one then
set repeatValue to 2
end if
try
set artData to data of artwork 1 of current track
on error
set artData to ""
end try
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artData}
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatValue, artData}
on error
return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, false, ""}
return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, 0, ""}
end try
end tell
"""
var descriptor: NSAppleEventDescriptor? = nil
let semaphore = DispatchSemaphore(value: 0)
Task {
descriptor = try? await AppleScriptHelper.execute(script)
semaphore.signal()
}
semaphore.wait()
return descriptor
return try await AppleScriptHelper.execute(script)
}
}

View file

@ -6,16 +6,19 @@
//
import Foundation
import AppKit
import Combine
protocol MediaControllerProtocol: ObservableObject {
var playbackStatePublisher: Published<PlaybackState>.Publisher { get }
func play()
func pause()
func seek(to time: Double)
func nextTrack()
func previousTrack()
func togglePlay()
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> { get }
var isWorking: Bool { get }
func play() async
func pause() async
func seek(to time: Double) async
func nextTrack() async
func previousTrack() async
func togglePlay() async
func toggleShuffle() async
func toggleRepeat() async
func isActive() -> Bool
func updatePlaybackInfo()
func updatePlaybackInfo() async
}

View file

@ -5,193 +5,222 @@
// Created by Alexander on 2025-03-29.
//
import Combine
import AppKit
import Combine
import Foundation
class NowPlayingController: ObservableObject, MediaControllerProtocol {
final class NowPlayingController: ObservableObject, MediaControllerProtocol {
// Stub for now to conform with ControllerProtocol
func updatePlaybackInfo() {}
// MARK: - Properties
@Published var playbackState: PlaybackState = .init(
@Published private(set) var playbackState: PlaybackState = .init(
bundleIdentifier: "com.apple.Music"
)
private var isResourcesAvailable = false
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
$playbackState.eraseToAnyPublisher()
}
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState }
private var cancellables = Set<AnyCancellable>()
private var lastMusicItem: (title: String, artist: String, album: String, duration: TimeInterval, artworkData: Data?)?
var isWorking: Bool {
return isResourcesAvailable
}
private var lastMusicItem:
(title: String, artist: String, album: String, duration: TimeInterval, artworkData: Data?)?
// MARK: - Media Remote Functions
private let mediaRemoteBundle: CFBundle
private let MRMediaRemoteGetNowPlayingInfo: @convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void
private let MRMediaRemoteRegisterForNowPlayingNotifications: @convention(c) (DispatchQueue) -> Void
private let MRMediaRemoteGetNowPlayingApplicationIsPlaying: @convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void
private let MRMediaRemoteGetNowPlayingClient: @convention(c) (DispatchQueue, @escaping (AnyObject?) -> Void) -> Void
private let MRNowPlayingClientGetBundleIdentifier: @convention(c) (AnyObject?) -> String?
private let MRNowPlayingClientGetParentAppBundleIdentifier: @convention(c) (AnyObject?) -> String?
private let MRMediaRemoteSendCommandFunction:@convention(c) (Int, AnyObject?) -> Void
private let MRMediaRemoteSendCommandFunction: @convention(c) (Int, AnyObject?) -> Void
private let MRMediaRemoteSetElapsedTimeFunction: @convention(c) (Double) -> Void
private let MRMediaRemoteSetShuffleModeFunction: @convention(c) (Int) -> Void
private let MRMediaRemoteSetRepeatModeFunction: @convention(c) (Int) -> Void
private var process: Process?
private var pipe: Pipe?
private var buffer = ""
// MARK: - Initialization
init?() {
guard let bundle = CFBundleCreate(kCFAllocatorDefault, NSURL(fileURLWithPath: "/System/Library/PrivateFrameworks/MediaRemote.framework")),
let MRMediaRemoteGetNowPlayingInfoPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingInfo" as CFString),
let MRMediaRemoteRegisterForNowPlayingNotificationsPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteRegisterForNowPlayingNotifications" as CFString),
let MRMediaRemoteGetNowPlayingApplicationIsPlayingPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingApplicationIsPlaying" as CFString),
let MRMediaRemoteGetNowPlayingClientPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingClient" as CFString),
let MRNowPlayingClientGetBundleIdentifierPointer = CFBundleGetFunctionPointerForName(bundle, "MRNowPlayingClientGetBundleIdentifier" as CFString),
let MRNowPlayingClientGetParentAppBundleIdentifierPointer = CFBundleGetFunctionPointerForName(bundle, "MRNowPlayingClientGetParentAppBundleIdentifier" as CFString),
let MRMediaRemoteSendCommandPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteSendCommand" as CFString),
let MRMediaRemoteSetElapsedTimePointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteSetElapsedTime" as CFString)
guard
let bundle = CFBundleCreate(
kCFAllocatorDefault,
NSURL(fileURLWithPath: "/System/Library/PrivateFrameworks/MediaRemote.framework")),
let MRMediaRemoteSendCommandPointer = CFBundleGetFunctionPointerForName(
bundle, "MRMediaRemoteSendCommand" as CFString),
let MRMediaRemoteSetElapsedTimePointer = CFBundleGetFunctionPointerForName(
bundle, "MRMediaRemoteSetElapsedTime" as CFString),
let MRMediaRemoteSetShuffleModePointer = CFBundleGetFunctionPointerForName(
bundle, "MRMediaRemoteSetShuffleMode" as CFString),
let MRMediaRemoteSetRepeatModePointer = CFBundleGetFunctionPointerForName(
bundle, "MRMediaRemoteSetRepeatMode" as CFString)
else { return nil }
mediaRemoteBundle = bundle
MRMediaRemoteGetNowPlayingInfo = unsafeBitCast(MRMediaRemoteGetNowPlayingInfoPointer, to: (@convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void).self)
MRMediaRemoteRegisterForNowPlayingNotifications = unsafeBitCast(MRMediaRemoteRegisterForNowPlayingNotificationsPointer, to: (@convention(c) (DispatchQueue) -> Void).self)
MRMediaRemoteGetNowPlayingApplicationIsPlaying = unsafeBitCast(MRMediaRemoteGetNowPlayingApplicationIsPlayingPointer, to: (@convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void).self)
MRMediaRemoteGetNowPlayingClient = unsafeBitCast(MRMediaRemoteGetNowPlayingClientPointer, to: (@convention(c) (DispatchQueue, @escaping (AnyObject?) -> Void) -> Void).self)
MRNowPlayingClientGetBundleIdentifier = unsafeBitCast(MRNowPlayingClientGetBundleIdentifierPointer, to: (@convention(c) (AnyObject?) -> String?).self)
MRNowPlayingClientGetParentAppBundleIdentifier = unsafeBitCast(MRNowPlayingClientGetParentAppBundleIdentifierPointer, to: (@convention(c) (AnyObject?) -> String?).self)
MRMediaRemoteSendCommandFunction = unsafeBitCast(MRMediaRemoteSendCommandPointer, to: (@convention(c) (Int, AnyObject?) -> Void).self)
MRMediaRemoteSetElapsedTimeFunction = unsafeBitCast(MRMediaRemoteSetElapsedTimePointer, to: (@convention(c) (Double) -> Void).self)
MRMediaRemoteSendCommandFunction = unsafeBitCast(
MRMediaRemoteSendCommandPointer, to: (@convention(c) (Int, AnyObject?) -> Void).self)
MRMediaRemoteSetElapsedTimeFunction = unsafeBitCast(
MRMediaRemoteSetElapsedTimePointer, to: (@convention(c) (Double) -> Void).self)
MRMediaRemoteSetShuffleModeFunction = unsafeBitCast(
MRMediaRemoteSetShuffleModePointer, to: (@convention(c) (Int) -> Void).self)
MRMediaRemoteSetRepeatModeFunction = unsafeBitCast(
MRMediaRemoteSetRepeatModePointer, to: (@convention(c) (Int) -> Void).self)
setupNowPlayingObserver()
updatePlaybackInfo()
}
deinit {
// Clean up all Combine subscribers
cancellables.removeAll()
// Remove distributed notification observers
DistributedNotificationCenter.default().removeObserver(
self,
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
object: nil
)
DistributedNotificationCenter.default().removeObserver(
self,
name: NSNotification.Name("com.apple.Music.playerInfo"),
object: nil
)
if let process = self.process {
if process.isRunning {
process.terminate()
process.waitUntilExit()
}
}
if let pipe = self.pipe {
pipe.fileHandleForReading.closeFile()
pipe.fileHandleForWriting.closeFile()
}
self.process = nil
self.pipe = nil
}
// MARK: - Protocol Implementation
func play() {
func play() async {
MRMediaRemoteSendCommandFunction(0, nil)
}
func pause() {
func pause() async {
MRMediaRemoteSendCommandFunction(1, nil)
}
func togglePlay() {
func togglePlay() async {
MRMediaRemoteSendCommandFunction(2, nil)
}
func nextTrack() {
func nextTrack() async {
MRMediaRemoteSendCommandFunction(4, nil)
}
func previousTrack() {
func previousTrack() async {
MRMediaRemoteSendCommandFunction(5, nil)
}
func seek(to time: Double) {
func seek(to time: Double) async {
MRMediaRemoteSetElapsedTimeFunction(time)
}
func isActive() -> Bool {
return true
}
// MARK: - Setup Methods
private func setupNowPlayingObserver() {
MRMediaRemoteRegisterForNowPlayingNotifications(DispatchQueue.main)
NotificationCenter.default.publisher(for: NSNotification.Name("kMRMediaRemoteNowPlayingInfoDidChangeNotification"))
.sink { [weak self] _ in self?.updatePlaybackInfo() }
.store(in: &cancellables)
NotificationCenter.default.publisher(for: NSNotification.Name("kMRMediaRemoteNowPlayingApplicationDidChangeNotification"))
.sink { [weak self] _ in self?.updateApp() }
.store(in: &cancellables)
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(updatePlaybackInfo),
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
object: nil
)
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(updatePlaybackInfo),
name: NSNotification.Name("com.apple.Music.playerInfo"),
object: nil
)
func toggleShuffle() async {
// MRMediaRemoteSendCommandFunction(6, nil)
MRMediaRemoteSetShuffleModeFunction(playbackState.isShuffled ? 3 : 1)
playbackState.isShuffled.toggle()
}
// MARK: - Update Methods
@objc func updateApp() {
// Get the now playing client
MRMediaRemoteGetNowPlayingClient(DispatchQueue.main) { [weak self] clientObj in
guard let clientObj = clientObj else {
DispatchQueue.main.async {
self?.playbackState.bundleIdentifier = "com.apple.Music" // Default fallback
}
return
}
// Try to get parent bundle ID first, then fall back to direct bundle ID
var appBundleID = self?.MRNowPlayingClientGetParentAppBundleIdentifier(clientObj)
if appBundleID == nil {
appBundleID = self?.MRNowPlayingClientGetBundleIdentifier(clientObj)
}
// Special case for WebKit.GPU which is often Safari
if appBundleID == "com.apple.WebKit.GPU" {
appBundleID = "com.apple.Safari"
}
DispatchQueue.main.async {
self?.playbackState.bundleIdentifier = appBundleID ?? "com.apple.Music"
}
}
func toggleRepeat() async {
// MRMediaRemoteSendCommandFunction(7, nil)
let newRepeatMode = (playbackState.repeatMode == .off) ? 3 : (playbackState.repeatMode.rawValue - 1)
playbackState.repeatMode = RepeatMode(rawValue: newRepeatMode) ?? .off
MRMediaRemoteSetRepeatModeFunction(newRepeatMode)
}
// MARK: - Setup Methods
private func setupNowPlayingObserver() {
let process = Process()
guard
let scriptURL = Bundle.main.url(forResource: "mediaremote-adapter", withExtension: "pl"),
let frameworkPath = Bundle.main.privateFrameworksPath?.appending("/MediaRemoteAdapter.framework")
else {
print("⚠️ Could not find mediaremote-adapter.pl script or framework path - NowPlaying controller disabled")
isResourcesAvailable = false
return
}
isResourcesAvailable = true
process.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
process.arguments = [scriptURL.path, frameworkPath, "stream"]
let pipe = Pipe()
process.standardOutput = pipe
self.process = process
self.pipe = pipe
@objc func updatePlaybackInfo() {
updateApp()
MRMediaRemoteGetNowPlayingInfo(DispatchQueue.main) { [weak self] information in
guard let self = self else { return }
let title = information["kMRMediaRemoteNowPlayingInfoTitle"] as? String ?? ""
let artist = information["kMRMediaRemoteNowPlayingInfoArtist"] as? String ?? ""
let album = information["kMRMediaRemoteNowPlayingInfoAlbum"] as? String ?? ""
let duration = information["kMRMediaRemoteNowPlayingInfoDuration"] as? TimeInterval ?? 0
let currentTime = information["kMRMediaRemoteNowPlayingInfoElapsedTime"] as? TimeInterval ?? 0
let artworkData = information["kMRMediaRemoteNowPlayingInfoArtworkData"] as? Data
let timestamp = information["kMRMediaRemoteNowPlayingInfoTimestamp"] as? Date ?? Date()
let playbackRate = information["kMRMediaRemoteNowPlayingInfoPlaybackRate"] as? Double ?? 1
// Update playback state
DispatchQueue.main.async {
self.playbackState.title = title
self.playbackState.artist = artist
self.playbackState.album = album
self.playbackState.duration = duration
self.playbackState.currentTime = currentTime
self.playbackState.artwork = artworkData
self.playbackState.lastUpdated = timestamp
self.playbackState.playbackRate = playbackRate
// Check playback state
self.MRMediaRemoteGetNowPlayingApplicationIsPlaying(DispatchQueue.main) { [weak self] isPlaying in
DispatchQueue.main.async {
self?.playbackState.isPlaying = isPlaying
pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty else { return }
if let chunk = String(data: data, encoding: .utf8) {
self?.buffer.append(chunk)
while let range = self?.buffer.range(of: "\n") {
let line = String(self?.buffer[..<range.lowerBound] ?? "")
self?.buffer = String(self?.buffer[range.upperBound...] ?? "")
if !line.isEmpty {
self?.handleAdapterUpdate(line)
}
}
}
}
do {
try process.run()
} catch {
print("⚠️ Failed to launch mediaremote-adapter.pl: \(error)")
}
}
// MARK: - Update Methods
private func handleAdapterUpdate(_ jsonLine: String) {
guard let data = jsonLine.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let payload = object["payload"] as? [String: Any]
else { return }
let diff = object["diff"] as? Bool ?? false
var newPlaybackState = PlaybackState(bundleIdentifier: playbackState.bundleIdentifier)
newPlaybackState.title = payload["title"] as? String ?? (diff ? self.playbackState.title : "")
newPlaybackState.artist = payload["artist"] as? String ?? (diff ? self.playbackState.artist : "")
newPlaybackState.album = payload["album"] as? String ?? (diff ? self.playbackState.album : "")
newPlaybackState.duration = payload["duration"] as? Double ?? (diff ? self.playbackState.duration : 0)
newPlaybackState.currentTime = payload["elapsedTime"] as? Double ?? (diff ? self.playbackState.currentTime : 0)
if let shuffleMode = payload["shuffleMode"] as? Int {
newPlaybackState.isShuffled = shuffleMode != 1
} else if !diff {
newPlaybackState.isShuffled = false
}
if let repeatModeValue = payload["repeatMode"] as? Int {
newPlaybackState.repeatMode = RepeatMode(rawValue: repeatModeValue) ?? .off
} else if !diff {
newPlaybackState.repeatMode = .off
}
if let artworkDataString = payload["artworkData"] as? String {
newPlaybackState.artwork = Data(
base64Encoded: artworkDataString.trimmingCharacters(in: .whitespacesAndNewlines)
)
} else if !diff {
newPlaybackState.artwork = nil
}
if let dateString = payload["timestamp"] as? String,
let date = ISO8601DateFormatter().date(from: dateString) {
newPlaybackState.lastUpdated = date
}
newPlaybackState.playbackRate = payload["playbackRate"] as? Double ?? (diff ? self.playbackState.playbackRate : 1.0)
newPlaybackState.isPlaying = payload["playing"] as? Bool ?? (diff ? self.playbackState.isPlaying : false)
newPlaybackState.bundleIdentifier = (
payload["parentApplicationBundleIdentifier"] as? String ??
payload["bundleIdentifier"] as? String ??
(diff ? self.playbackState.bundleIdentifier : "")
)
self.playbackState = newPlaybackState
}
}

View file

@ -2,7 +2,7 @@
// SpotifyController.swift
// DynamicIsland
//
// Created by Alexander Greco on 2025-03-29.
// Created by Alexander on 2025-03-29.
//
import Foundation
@ -15,59 +15,81 @@ class SpotifyController: MediaControllerProtocol {
bundleIdentifier: "com.spotify.client"
)
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState }
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
$playbackState.eraseToAnyPublisher()
}
var isWorking: Bool {
return true // Spotify controller always works
}
private var notificationTask: Task<Void, Never>?
//Constant for time between command and update
let commandUpdateDelay: Duration = .milliseconds(25)
init() {
setupPlaybackStateChangeObserver()
DispatchQueue.main.async { [weak self] in
if self?.isActive() == true {
self?.updatePlaybackInfo()
Task {
if isActive() {
await updatePlaybackInfo()
}
}
}
private func setupPlaybackStateChangeObserver() {
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(updatePlaybackInfo),
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
object: nil
)
notificationTask = Task { @Sendable [weak self] in
let notifications = DistributedNotificationCenter.default().notifications(
named: NSNotification.Name("com.spotify.client.PlaybackStateChanged")
)
for await _ in notifications {
await self?.updatePlaybackInfo()
}
}
}
deinit {
// Remove notification observer when controller is deallocated
DistributedNotificationCenter.default().removeObserver(
self,
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
object: nil
)
notificationTask?.cancel()
}
// MARK: - Protocol Implementation
func play() {
executeCommand("play")
func play() async {
await executeCommand("play")
}
func pause() {
executeCommand("pause")
func pause() async {
await executeCommand("pause")
}
func togglePlay() {
executeCommand("playpause")
func togglePlay() async {
await executeCommand("playpause")
}
func nextTrack() {
executeCommand("next track")
func nextTrack() async {
await executeCommand("next track")
}
func previousTrack() {
executeCommand("previous track")
func previousTrack() async {
await executeCommand("previous track")
}
func seek(to time: Double) {
executeCommand("set player position to \(time)")
updatePlaybackInfo()
func seek(to time: Double) async {
await executeCommand("set player position to \(time)")
try? await Task.sleep(for: commandUpdateDelay)
await updatePlaybackInfo()
}
func toggleShuffle() async {
await executeCommand("set shuffling to not shuffling")
try? await Task.sleep(for: commandUpdateDelay)
await updatePlaybackInfo()
}
func toggleRepeat() async {
await executeCommand("set repeating to not repeating")
try? await Task.sleep(for: commandUpdateDelay)
await updatePlaybackInfo()
}
func isActive() -> Bool {
@ -75,9 +97,8 @@ class SpotifyController: MediaControllerProtocol {
return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier }
}
// MARK: - Private Methods
@objc func updatePlaybackInfo() {
guard let descriptor = fetchPlaybackInfo() else { return }
func updatePlaybackInfo() async {
guard let descriptor = try? await fetchPlaybackInfoAsync() else { return }
guard descriptor.numberOfItems >= 9 else { return }
let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
@ -100,42 +121,35 @@ class SpotifyController: MediaControllerProtocol {
duration: duration,
playbackRate: 1,
isShuffled: isShuffled,
isRepeating: isRepeating,
repeatMode: isRepeating ? .all : .off,
lastUpdated: Date()
)
DispatchQueue.main.async { [weak self] in
self?.playbackState = state
}
self.playbackState = state
// Load artwork asynchronously and update the state when complete
if !artworkURL.isEmpty, let url = URL(string: artworkURL) {
DispatchQueue.global(qos: .background).async { [weak self] in
do {
let artworkData = try Data(contentsOf: url)
DispatchQueue.main.async {
// Create a new state with the artwork data and update
var updatedState = state
updatedState.artwork = artworkData
self?.playbackState = updatedState
}
} catch {
print("Failed to load artwork: \(error)")
}
let currentState = state
do {
let (data, _) = try await URLSession.shared.data(from: url)
// Create a new state with the artwork data and update
var updatedState = currentState
updatedState.artwork = data
playbackState = updatedState
} catch {
print("Failed to load artwork: \(error)")
}
} else {
self.playbackState = state
}
}
private func executeCommand(_ command: String) {
// MARK: - Private Methods
private func executeCommand(_ command: String) async {
let script = "tell application \"Spotify\" to \(command)"
Task {
try? await AppleScriptHelper.executeVoid(script)
}
try? await AppleScriptHelper.executeVoid(script)
}
private func fetchPlaybackInfo() -> NSAppleEventDescriptor? {
private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? {
let script = """
tell application "Spotify"
set isRunning to true
@ -156,13 +170,6 @@ class SpotifyController: MediaControllerProtocol {
end tell
"""
var descriptor: NSAppleEventDescriptor? = nil
let semaphore = DispatchSemaphore(value: 0)
Task {
descriptor = try? await AppleScriptHelper.execute(script)
semaphore.signal()
}
semaphore.wait()
return descriptor
return try await AppleScriptHelper.execute(script)
}
}

View file

@ -3,6 +3,7 @@
// DynamicIsland
//
// Created By Alexander on 2025-03-30.
// Modified by Pranav on 2025-06-16.
//
import Foundation
@ -12,143 +13,129 @@ import SwiftUI
class YouTubeMusicController: MediaControllerProtocol {
// MARK: - Properties
@Published var playbackState: PlaybackState = .init(
bundleIdentifier: "com.github.th-ch.youtube-music",
isPlaying: true
bundleIdentifier: "com.github.th-ch.youtube-music"
)
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState }
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
$playbackState.eraseToAnyPublisher()
}
var isWorking: Bool {
return true // YouTube Music controller always works
}
private let baseURL = "http://localhost:26538"
private var accessToken: String?
private var refreshTimer: Timer?
private var periodicUpdateTask: Task<Void, Never>?
private var notificationTask: Task<Void, Never>?
private var cancellables = Set<AnyCancellable>()
private var playbackInfoCancellable: AnyCancellable?
private var isAuthenticating = false
private let authQueue = DispatchQueue(label: "com.dynamicisland.youtubemusicauth", qos: .background)
init() {
setupAppStateObservers()
authQueue.async { [weak self] in
self?.authenticateAndSetup()
}
Task {await authenticateAndSetup()}
}
deinit {
stopPeriodicUpdates()
notificationTask?.cancel()
cancellables.forEach { $0.cancel() }
NSWorkspace.shared.notificationCenter.removeObserver(self)
}
// MARK: - Authentication
private func authenticateAndSetup() {
private func authenticateAndSetup() async {
// Prevent multiple concurrent authentication attempts
guard !isAuthenticating else { return }
isAuthenticating = true
getAccessToken { [weak self] success in
guard let self = self else { return }
DispatchQueue.main.async {
self.isAuthenticating = false
if success {
self.startPeriodicUpdates()
self.updatePlaybackInfo()
} else {
// Retry authentication after a delay if it fails
DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 5) { [weak self] in
self?.authenticateAndSetup()
}
}
}
let success = await getAccessToken()
self.isAuthenticating = false
if success {
await self.startPeriodicUpdates()
await updatePlaybackInfo()
} else {
try? await Task.sleep(for: .seconds(5))
await authenticateAndSetup()
}
}
private func getAccessToken(completion: @escaping (Bool) -> Void) {
guard let url = URL(string: "\(baseURL)/auth/DynamicIsland") else {
completion(false)
return
private func getAccessToken() async -> Bool {
guard let url = URL(string: "\(baseURL)/auth/boringNotch") else {
return false
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
URLSession.shared.dataTaskPublisher(for: request)
.map { data, response -> Data in
return data
}
.decode(type: AuthResponse.self, decoder: JSONDecoder())
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { completionStatus in
switch completionStatus {
case .failure(_):
completion(false)
case .finished:
break
}
}, receiveValue: { [weak self] response in
self?.accessToken = response.accessToken
completion(true)
})
.store(in: &cancellables)
do {
let (data, _) = try await URLSession.shared.data(for: request)
let response = try JSONDecoder().decode(AuthResponse.self, from: data)
accessToken = response.accessToken
return true
} catch {
return false
}
}
// MARK: - Protocol Implementation
func play() {
sendCommand(endpoint: "/play", method: "POST")
updatePlaybackInfo()
func play() async {
await sendCommand(endpoint: "/play", method: "POST")
await updatePlaybackInfo()
}
func pause() {
sendCommand(endpoint: "/pause", method: "POST")
updatePlaybackInfo()
func pause() async {
await sendCommand(endpoint: "/pause", method: "POST")
await updatePlaybackInfo()
}
func togglePlay() {
func togglePlay() async {
if !isActive() {
if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: playbackState.bundleIdentifier) {
NSWorkspace.shared.open(url)
}
}
sendCommand(endpoint: "/toggle-play", method: "POST")
updatePlaybackInfo()
await sendCommand(endpoint: "/toggle-play", method: "POST")
await updatePlaybackInfo()
}
func nextTrack() {
sendCommand(endpoint: "/next", method: "POST")
updatePlaybackInfo()
func nextTrack() async {
await sendCommand(endpoint: "/next", method: "POST")
await updatePlaybackInfo()
}
func previousTrack() {
sendCommand(endpoint: "/previous", method: "POST")
updatePlaybackInfo()
func previousTrack() async {
await sendCommand(endpoint: "/previous", method: "POST")
await updatePlaybackInfo()
}
func seek(to time: Double) {
guard let url = URL(string: "\(baseURL)/seek-to") else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token = accessToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
} else {
authenticateAndSetup()
return
}
// Create the request body with the seconds value
func seek(to time: Double) async {
// Format the seek data payload according to the API schema
let seekData = ["seconds": time]
do {
request.httpBody = try JSONEncoder().encode(seekData)
} catch {
return
}
URLSession.shared.dataTaskPublisher(for: request)
.tryMap { data, response -> Data in
// Convert seek data to JSON
let jsonData = try JSONEncoder().encode(seekData)
guard let url = URL(string: "\(baseURL)/api/v1/seek-to") else { return }
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token = accessToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
} else {
await authenticateAndSetup()
return
}
request.httpBody = jsonData
do {
let (_, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
@ -159,22 +146,44 @@ class YouTubeMusicController: MediaControllerProtocol {
throw URLError(.badServerResponse)
}
return data
}
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
if case .failure(let error) = completion {
if let urlError = error as? URLError, urlError.code == .userAuthenticationRequired {
self?.accessToken = nil
self?.authenticateAndSetup()
}
// Update playback info after seeking
try? await Task.sleep(for: .milliseconds(300))
await updatePlaybackInfo()
} catch {
print("Seek error: \(error)")
if let urlError = error as? URLError, urlError.code == .userAuthenticationRequired {
self.accessToken = nil
await self.authenticateAndSetup()
}
}, receiveValue: { [weak self] _ in
self?.updatePlaybackInfo()
})
.store(in: &cancellables)
}
} catch {
print("Error encoding seek data: \(error)")
return
}
}
func fetchShuffleState() async {
await sendCommand(endpoint: "/shuffle", method: "GET")
}
func toggleShuffle() async {
await sendCommand(endpoint: "/shuffle", method: "POST")
try? await Task.sleep(for: .milliseconds(200))
await fetchShuffleState()
}
func toggleRepeat() async {
await sendCommand(endpoint: "/switch-repeat", method: "POST")
try? await Task.sleep(for: .milliseconds(200))
await fetchRepeatMode()
}
func fetchRepeatMode() async {
await sendCommand(endpoint: "/repeat-mode", method: "GET")
}
func isActive() -> Bool {
let runningApps = NSWorkspace.shared.runningApplications
return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier }
@ -183,131 +192,170 @@ class YouTubeMusicController: MediaControllerProtocol {
// MARK: - Private Methods
private func setupAppStateObservers() {
// Register for app launch notifications
NSWorkspace.shared.notificationCenter.addObserver(
self,
selector: #selector(handleAppStateChange),
name: NSWorkspace.didLaunchApplicationNotification,
object: nil
)
// Register for app termination notifications
NSWorkspace.shared.notificationCenter.addObserver(
self,
selector: #selector(handleAppStateChange),
name: NSWorkspace.didTerminateApplicationNotification,
object: nil
)
notificationTask = Task { @Sendable [weak self] in
await withTaskGroup(of: Void.self) { group in
// Handle app launch notifications
group.addTask {
let launchNotifications = NSWorkspace.shared.notificationCenter.notifications(
named: NSWorkspace.didLaunchApplicationNotification
)
for await notification in launchNotifications {
await self?.handleAppStateChange(notification: notification)
}
}
// Handle app termination notifications
group.addTask {
let terminateNotifications = NSWorkspace.shared.notificationCenter.notifications(
named: NSWorkspace.didTerminateApplicationNotification
)
for await notification in terminateNotifications {
await self?.handleAppStateChange(notification: notification)
}
}
}
}
}
@objc private func handleAppStateChange(notification: Notification) {
private func handleAppStateChange(notification: Notification) async {
guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
app.bundleIdentifier == playbackState.bundleIdentifier else {
return
}
if notification.name == NSWorkspace.didLaunchApplicationNotification {
startPeriodicUpdates()
await startPeriodicUpdates()
} else if notification.name == NSWorkspace.didTerminateApplicationNotification {
// App has terminated - update state and stop timer
stopPeriodicUpdates()
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.playbackState = PlaybackState(
bundleIdentifier: self.playbackState.bundleIdentifier,
isPlaying: false
)
}
self.playbackState = PlaybackState(
bundleIdentifier: self.playbackState.bundleIdentifier,
isPlaying: false
)
}
}
private func startPeriodicUpdates() {
private func startPeriodicUpdates() async {
stopPeriodicUpdates() // Ensure we don't create multiple timers
// Only start the timer if the app is actually running
guard isActive() else { return }
refreshTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
self?.updatePlaybackInfo()
periodicUpdateTask = Task { [weak self] in
while !Task.isCancelled {
await self?.updatePlaybackInfo()
try? await Task.sleep(for: .seconds(2))
}
}
refreshTimer?.tolerance = 0.5
// Initial update when timer starts
updatePlaybackInfo()
await updatePlaybackInfo()
await fetchRepeatMode()
await fetchShuffleState()
}
private func stopPeriodicUpdates() {
refreshTimer?.invalidate()
refreshTimer = nil
periodicUpdateTask?.cancel()
periodicUpdateTask = nil
}
private func updateRepeatMode(_ modeString: String?) {
guard let modeString = modeString else { return }
let repeatMode: RepeatMode
switch modeString {
case "NONE":
repeatMode = .off
case "ALL":
repeatMode = .all
case "ONE":
repeatMode = .one
default:
repeatMode = .off
}
if repeatMode != playbackState.repeatMode {
playbackState.repeatMode = repeatMode
}
}
func updatePlaybackInfo() {
func updatePlaybackInfo() async {
if !isActive() {
playbackState = PlaybackState(bundleIdentifier: playbackState.bundleIdentifier, isPlaying: false)
stopPeriodicUpdates()
return
}
guard let request = createAuthenticatedRequest(for: "/api/v1/song") else { return }
guard let request = await createAuthenticatedRequest(for: "/api/v1/song") else { return }
let backgroundQueue = DispatchQueue.global(qos: .utility)
backgroundQueue.async { [weak self] in
guard let self = self else { return }
do {
let (data, response) = try await URLSession.shared.data(for: request)
playbackInfoCancellable?.cancel()
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
playbackInfoCancellable = URLSession.shared.dataTaskPublisher(for: request)
.tryMap { data, response -> Data in
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
// Check for authentication errors
if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
// Re-authenticate and retry later
DispatchQueue.main.async { [weak self] in
self?.accessToken = nil
self?.authenticateAndSetup()
}
throw URLError(.userAuthenticationRequired)
} else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 {
throw URLError(.badServerResponse)
}
return data
}
.decode(type: PlaybackResponse.self, decoder: JSONDecoder())
.subscribe(on: backgroundQueue)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { _ in }, receiveValue: { [weak self] response in
self?.updatePlaybackState(with: response)
})
// Check for authentication errors
if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
// Re-authenticate and retry later
self.accessToken = nil
await authenticateAndSetup()
throw URLError(.userAuthenticationRequired)
} else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 {
throw URLError(.badServerResponse)
}
let playbackResponse = try JSONDecoder().decode(PlaybackResponse.self, from: data)
await updatePlaybackState(with: playbackResponse)
} catch {
// Handle error silently for now, as this is called periodically
}
}
private func updatePlaybackState(with response: PlaybackResponse) {
playbackState.isPlaying = !response.isPaused
playbackState.title = response.title
playbackState.artist = response.artist
playbackState.album = response.album ?? ""
playbackState.currentTime = response.elapsedSeconds
playbackState.duration = response.songDuration
playbackState.lastUpdated = Date()
private func updatePlaybackState(with response: PlaybackResponse) async {
var updatedState = self.playbackState
updatedState.isPlaying = !response.isPaused
updatedState.title = response.title
updatedState.artist = response.artist
updatedState.album = response.album ?? ""
updatedState.currentTime = response.elapsedSeconds
updatedState.duration = response.songDuration
updatedState.lastUpdated = Date()
if let isShuffled = response.isShuffled {
updatedState.isShuffled = isShuffled
}
// Load artwork if available
if let artworkURL = response.imageSrc, let url = URL(string: artworkURL) {
DispatchQueue.global(qos: .background).async { [weak self] in
do {
let artworkData = try Data(contentsOf: url)
DispatchQueue.main.async {
self?.playbackState.artwork = artworkData
}
} catch { return }
do {
let artworkData = try Data(contentsOf: url)
playbackState.artwork = artworkData
} catch {
NSLog("Failed to load artwork: \(error)")
updatedState.artwork = nil
self.playbackState = updatedState
return
}
}
self.playbackState = updatedState
}
private func sendCommand(endpoint: String, method: String = "GET") {
func pollPlaybackState() async {
if !isActive() {
return
}
await fetchRepeatMode()
await fetchShuffleState()
await updatePlaybackInfo()
}
private func sendCommand(endpoint: String, method: String = "GET") async {
guard let url = URL(string: "\(baseURL)/api/v1\(endpoint)") else { return }
var request = URLRequest(url: url)
@ -316,49 +364,65 @@ class YouTubeMusicController: MediaControllerProtocol {
if let token = accessToken {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
} else {
authenticateAndSetup()
await authenticateAndSetup()
return
}
URLSession.shared.dataTaskPublisher(for: request)
.tryMap { data, response -> Data in
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
throw URLError(.userAuthenticationRequired)
} else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 {
throw URLError(.badServerResponse)
}
return data
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { [weak self] completion in
if case .failure(let error) = completion {
if let urlError = error as? URLError, urlError.code == .userAuthenticationRequired {
// Authentication error - token might be expired
self?.accessToken = nil
self?.authenticateAndSetup()
// Try the command again after authentication
DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 2) { [weak self] in
self?.sendCommand(endpoint: endpoint, method: method)
}
if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
throw URLError(.userAuthenticationRequired)
} else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 {
throw URLError(.badServerResponse)
}
// Handle data based on endpoint
if endpoint == "/shuffle" || endpoint == "/shuffle-status" {
do {
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let shuffleState = json["state"] as? Bool {
playbackState.isShuffled = shuffleState
}
} catch {
print("Error parsing shuffle state: \(error)")
}
}, receiveValue: { [weak self] _ in
self?.updatePlaybackInfo()
})
.store(in: &cancellables)
} else if endpoint == "/repeat-mode" {
do {
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let mode = json["mode"] as? String {
updateRepeatMode(mode)
}
} catch {
print("Error parsing repeat mode: \(error)")
}
}
// Update playback info for relevant commands
await updatePlaybackInfo()
} catch {
if let urlError = error as? URLError, urlError.code == .userAuthenticationRequired {
// Authentication error - token might be expired
accessToken = nil
await authenticateAndSetup()
// Try the command again after authentication
try? await Task.sleep(for: .seconds(2))
await sendCommand(endpoint: endpoint, method: method)
}
}
}
private func createAuthenticatedRequest(for endpoint: String) -> URLRequest? {
private func createAuthenticatedRequest(for endpoint: String) async -> URLRequest? {
guard let token = accessToken, let url = URL(string: "\(baseURL)\(endpoint)") else {
if accessToken == nil {
// Token is missing, try to authenticate again
authenticateAndSetup()
await authenticateAndSetup()
}
return nil
}
@ -383,4 +447,6 @@ struct PlaybackResponse: Codable {
let elapsedSeconds: Double
let songDuration: Double
let imageSrc: String?
let repeatMode: Int?
let isShuffled: Bool?
}

View file

@ -150,7 +150,7 @@ struct WheelPicker: View {
struct CalendarView: View {
@EnvironmentObject var vm: DynamicIslandViewModel
@StateObject private var calendarManager = CalendarManager()
@ObservedObject private var calendarManager = CalendarManager.shared
@State private var selectedDate = Date()
var body: some View {

View file

@ -72,7 +72,9 @@ class AudioSpectrum: NSView {
animation.autoreverses = true
animation.fillMode = .forwards
animation.isRemovedOnCompletion = false
if #available(macOS 13.0, *) {
animation.preferredFrameRateRange = CAFrameRateRange(minimum: 24, maximum: 24, preferred: 24)
}
barLayer.add(animation, forKey: "scaleY")
}
}

View file

@ -46,11 +46,7 @@ struct DynamicIslandHeader: View {
if vm.notchState == .open {
if Defaults[.showMirror] {
Button(action: {
if webcamManager.isSessionRunning {
webcamManager.stopSession()
} else {
webcamManager.startSession()
}
vm.toggleCameraPreview()
}) {
Capsule()
.fill(.black)

View file

@ -161,18 +161,58 @@ struct MusicControlsView: View {
private var playbackControls: some View {
HStack(spacing: 8) {
if Defaults[.showShuffleAndRepeat] {
HoverButton(icon: "shuffle", iconColor: musicManager.isShuffled ? .red : .white, scale: .medium) {
Task {
await MusicManager.shared.toggleShuffle()
}
}
}
HoverButton(icon: "backward.fill", scale: .medium) {
MusicManager.shared.previousTrack()
Task {
await MusicManager.shared.previousTrack()
}
}
HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) {
MusicManager.shared.togglePlay()
Task {
await MusicManager.shared.togglePlay()
}
}
HoverButton(icon: "forward.fill", scale: .medium) {
MusicManager.shared.nextTrack()
Task {
await MusicManager.shared.nextTrack()
}
}
if Defaults[.showShuffleAndRepeat] {
HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) {
Task {
await MusicManager.shared.toggleRepeat()
}
}
}
}
.frame(maxWidth: .infinity, alignment: .center)
}
private var repeatIcon: String {
switch musicManager.repeatMode {
case .off:
return "repeat"
case .all:
return "repeat"
case .one:
return "repeat.1"
}
}
private var repeatIconColor: Color {
switch musicManager.repeatMode {
case .off:
return .white
case .all, .one:
return .red
}
}
}
// MARK: - Main View

View file

@ -3,7 +3,7 @@
// DynamicIsland
//
// Created by Richard Kunkli on 07/08/2024.
// Modified by Hariharan Mudaliar
//
import AVFoundation
import Defaults
@ -14,20 +14,17 @@ import LottieUI
import Sparkle
import SwiftUI
import SwiftUIIntrospect
import UniformTypeIdentifiers
struct SettingsView: View {
@StateObject var extensionManager = DynamicIslandExtensionManager()
@StateObject private var calendarManager = CalendarManager()
@State private var selectedTab = "General"
let updaterController: SPUStandardUpdaterController?
init(updaterController: SPUStandardUpdaterController? = nil) {
self.updaterController = updaterController
}
var body: some View {
NavigationSplitView {
List(selection: $selectedTab) {
@ -40,9 +37,6 @@ struct SettingsView: View {
NavigationLink(value: "Media") {
Label("Media", systemImage: "play.laptopcomputer")
}
NavigationLink(value: "Timer") {
Label("Timer", systemImage: "timer")
}
NavigationLink(value: "Calendar") {
Label("Calendar", systemImage: "calendar")
}
@ -55,6 +49,15 @@ struct SettingsView: View {
NavigationLink(value: "Battery") {
Label("Battery", systemImage: "battery.100.bolt")
}
NavigationLink(value: "Timer") {
Label("Timer", systemImage: "timer")
}
NavigationLink(value: "Stats") {
Label("Stats", systemImage: "chart.xyaxis.line")
}
NavigationLink(value: "Clipboard") {
Label("Clipboard", systemImage: "clipboard")
}
if extensionManager.installedExtensions
.contains(where: { $0.bundleIdentifier == downloadManagerExtension }) {
NavigationLink(value: "Downloads") {
@ -64,12 +67,6 @@ struct SettingsView: View {
NavigationLink(value: "Shelf") {
Label("Shelf", systemImage: "books.vertical")
}
NavigationLink(value: "Stats") {
Label("Stats", systemImage: "chart.line.uptrend.xyaxis")
}
NavigationLink(value: "Clipboard") {
Label("Clipboard", systemImage: "doc.on.clipboard")
}
NavigationLink(value: "Shortcuts") {
Label("Shortcuts", systemImage: "keyboard")
}
@ -92,22 +89,22 @@ struct SettingsView: View {
Appearance()
case "Media":
Media()
case "Timer":
TimerSettings()
case "Calendar":
CalendarSettings()
case "HUD":
HUD()
case "Battery":
Charge()
case "Timer":
TimerSettings()
case "Stats":
StatsSettings()
case "Clipboard":
ClipboardSettings()
case "Downloads":
Downloads()
case "Shelf":
Shelf()
case "Stats":
Stats()
case "Clipboard":
ClipboardSettings()
case "Shortcuts":
Shortcuts()
case "Extensions":
@ -134,7 +131,6 @@ struct SettingsView: View {
.disabled(true)
}
.environmentObject(extensionManager)
.environmentObject(calendarManager)
.formStyle(.grouped)
.frame(width: 700)
.background(Color(NSColor.windowBackgroundColor))
@ -158,8 +154,7 @@ struct GeneralSettings: View {
@Default(.automaticallySwitchDisplay) var automaticallySwitchDisplay
@Default(.enableGestures) var enableGestures
@Default(.openNotchOnHover) var openNotchOnHover
@Default(.alwaysHideInFullscreen) var alwaysHideInFullscreen
var body: some View {
Form {
Section {
@ -188,7 +183,7 @@ struct GeneralSettings: View {
} header: {
Text("System features")
}
Section {
Picker(selection: $notchHeightMode, label:
Text("Notch display height")) {
@ -248,9 +243,9 @@ struct GeneralSettings: View {
} header: {
Text("Notch Height")
}
NotchBehaviour()
gestureControls()
}
.toolbar {
@ -266,7 +261,7 @@ struct GeneralSettings: View {
}
}
}
@ViewBuilder
func gestureControls() -> some View {
Section {
@ -297,7 +292,7 @@ struct GeneralSettings: View {
.font(.caption)
}
}
@ViewBuilder
func NotchBehaviour() -> some View {
Section {
@ -369,7 +364,7 @@ struct Downloads: View {
Text("Both")
.tag(DownloadIconStyle.iconAndAppIcon)
}
} header: {
HStack {
Text("Download indicators")
@ -397,7 +392,7 @@ struct Downloads: View {
.contentShape(Rectangle())
.foregroundStyle(.secondary)
}
Divider()
Button {} label: {
Image(systemName: "minus")
@ -470,7 +465,7 @@ struct Media: View {
@Default(.hideNotchOption) var hideNotchOption
@Default(.enableSneakPeek) private var enableSneakPeek
@Default(.sneakPeekStyles) var sneakPeekStyles
var body: some View {
Form {
Section {
@ -503,13 +498,21 @@ struct Media: View {
.font(.caption)
}
}
Section {
Defaults.Toggle(key: .showShuffleAndRepeat) {
HStack {
Text("Show shuffle and repeat buttons")
customBadge(text: "Beta")
}
}
} header: {
Text("Media controls")
}
Section {
Toggle(
"Enable music live activity",
isOn: $coordinator.musicLiveActivityEnabled
isOn: $coordinator.musicLiveActivityEnabled.animation()
)
.animation(.easeInOut, value: coordinator.musicLiveActivityEnabled)
Toggle("Enable sneak peek", isOn: $enableSneakPeek)
Picker("Sneak Peek Style", selection: $sneakPeekStyles){
ForEach(SneakPeekStyle.allCases) { style in
@ -532,21 +535,20 @@ struct Media: View {
Picker(selection: $hideNotchOption, label:
HStack {
Text("Hide DynamicIsland Options")
Text("Hide BoringNotch Options")
customBadge(text: "Beta")
}) {
Text("Always hide in fullscreen").tag(HideNotchOption.always)
Text("Hide only when NowPlaying app is in fullscreen").tag(HideNotchOption.nowPlayingOnly)
Text("Never hide").tag(HideNotchOption.never)
}
.onChange(of: hideNotchOption) { _, newValue in
Defaults[.alwaysHideInFullscreen] = newValue == .always
Defaults[.enableFullscreenMediaDetection] = newValue != .never
.onChange(of: hideNotchOption) {
Defaults[.enableFullscreenMediaDetection] = hideNotchOption != .never
}
}
.navigationTitle("Media")
}
// Only show controller options that are available on this macOS version
private var availableMediaControllers: [MediaControllerType] {
if MusicManager.shared.isNowPlayingDeprecated {
@ -558,7 +560,7 @@ struct Media: View {
}
struct CalendarSettings: View {
@ObservedObject private var calendarManager = CalendarManager()
@ObservedObject private var calendarManager = CalendarManager.shared
@Default(.showCalendar) var showCalendar: Bool
var body: some View {
@ -574,7 +576,7 @@ struct CalendarSettings: View {
}
}
} else {
Toggle("Show calendar", isOn: $showCalendar)
Defaults.Toggle("Show calendar", key: .showCalendar)
Section(header: Text("Select Calendars")) {
List {
ForEach(calendarManager.allCalendars, id: \.id) { calendar in
@ -588,6 +590,7 @@ struct CalendarSettings: View {
)) {
Text(calendar.title)
}
.disabled(!showCalendar)
}
}
}
@ -603,157 +606,6 @@ struct CalendarSettings: View {
}
}
struct TimerSettings: View {
@ObservedObject private var coordinator = DynamicIslandViewCoordinator.shared
@Default(.enableTimerFeature) var enableTimerFeature
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 // 10 minutes default
@State private var customMinutes: Int = 10
@State private var customSeconds: Int = 0
var body: some View {
Form {
Section {
Defaults.Toggle("Enable timer feature", key: .enableTimerFeature)
if enableTimerFeature {
Toggle(
"Enable timer live activity",
isOn: $coordinator.timerLiveActivityEnabled
)
.animation(.easeInOut, value: coordinator.timerLiveActivityEnabled)
}
} header: {
Text("Timer Feature")
} footer: {
Text("Enable or disable the timer functionality in the Dynamic Island. The live activity toggle controls whether timer progress is shown in the expanded view.")
}
if enableTimerFeature {
Section {
VStack(alignment: .leading, spacing: 16) {
Text("Custom Timer Duration")
.font(.headline)
HStack(spacing: 16) {
VStack {
Text("Minutes")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary)
Picker(selection: $customMinutes, label: Text("Minutes")) {
ForEach(0...59, id: \.self) { minute in
Text("\(minute)").tag(minute)
}
}
.pickerStyle(.menu)
.frame(width: 80, height: 40)
}
VStack {
Text("Seconds")
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.secondary)
Picker(selection: $customSeconds, label: Text("Seconds")) {
ForEach(0...59, id: \.self) { second in
Text("\(second)").tag(second)
}
}
.pickerStyle(.menu)
.frame(width: 80, height: 40)
}
}
HStack {
Text("Current custom timer: \(customTimerDisplayText)")
.foregroundStyle(.secondary)
Spacer()
Button("Update") {
customTimerDuration = Double(customMinutes * 60 + customSeconds)
}
.disabled(customMinutes == 0 && customSeconds == 0)
}
}
} header: {
Text("Custom Timer")
} footer: {
Text("Set a custom duration for the timer. This will be used when you press the 'Custom' button in the timer interface.")
}
Section {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Timer Sound")
.font(.system(size: 16, weight: .medium))
Spacer()
Button("Choose File") {
selectCustomTimerSound()
}
.buttonStyle(.bordered)
}
if let customTimerSoundPath = UserDefaults.standard.string(forKey: "customTimerSoundPath") {
Text("Custom: \(URL(fileURLWithPath: customTimerSoundPath).lastPathComponent)")
.font(.caption)
.foregroundColor(.secondary)
} else {
Text("Default: dynamic.m4a")
.font(.caption)
.foregroundColor(.secondary)
}
Button("Reset to Default") {
UserDefaults.standard.removeObject(forKey: "customTimerSoundPath")
}
.buttonStyle(.bordered)
.disabled(UserDefaults.standard.string(forKey: "customTimerSoundPath") == nil)
}
} header: {
Text("Timer Sound")
} footer: {
Text("Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF.")
}
}
}
.navigationTitle("Timer")
.onAppear {
let totalMinutes = Int(customTimerDuration) / 60
customMinutes = totalMinutes
customSeconds = Int(customTimerDuration) % 60
}
}
private var customTimerDisplayText: String {
let totalMinutes = Int(customTimerDuration) / 60
let seconds = Int(customTimerDuration) % 60
let hours = totalMinutes / 60
let minutes = totalMinutes % 60
if hours > 0 {
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))"
} else if minutes > 0 {
return "\(minutes):\(String(format: "%02d", seconds))"
} else {
return "\(seconds)s"
}
}
private func selectCustomTimerSound() {
let panel = NSOpenPanel()
panel.title = "Select Timer Sound"
panel.allowedContentTypes = [.audio]
panel.allowsMultipleSelection = false
panel.canChooseDirectories = false
panel.canChooseFiles = true
if panel.runModal() == .OK {
if let url = panel.url {
UserDefaults.standard.set(url.path, forKey: "customTimerSoundPath")
}
}
}
}
struct About: View {
@State private var showBuildNumber: Bool = false
let updaterController: SPUStandardUpdaterController
@ -786,9 +638,9 @@ struct About: View {
} header: {
Text("Version info")
}
UpdaterSettingsView(updater: updaterController.updater)
HStack(spacing: 30) {
Spacer(minLength: 0)
Button {
@ -891,15 +743,15 @@ struct Extensions: View {
.font(.footnote)
}
.frame(width: 60, alignment: .leading)
Menu(content: {
Button("Restart") {
let ws = NSWorkspace.shared
if let ext = ws.runningApplications.first(where: { $0.bundleIdentifier == item.bundleIdentifier }) {
ext.terminate()
}
if let appURL = ws.urlForApplication(withBundleIdentifier: item.bundleIdentifier) {
ws.openApplication(at: appURL, configuration: .init(), completionHandler: nil)
}
@ -987,7 +839,7 @@ struct Appearance: View {
let icons: [String] = ["logo2"]
@State private var selectedIcon: String = "logo2"
@State private var selectedListVisualizer: CustomVisualizer? = nil
@State private var isPresented: Bool = false
@State private var name: String = ""
@State private var url: String = ""
@ -1003,7 +855,7 @@ struct Appearance: View {
} header: {
Text("General")
}
Section {
Defaults.Toggle("Enable colored spectrograms", key: .coloredSpectrogram)
Defaults
@ -1017,7 +869,7 @@ struct Appearance: View {
} header: {
Text("Media")
}
Section {
Toggle(
"Use music visualizer spectrogram",
@ -1053,7 +905,7 @@ struct Appearance: View {
customBadge(text: "Coming soon")
}
}
Section {
List {
ForEach(customVisualizers, id: \.self) { visualizer in
@ -1151,7 +1003,7 @@ struct Appearance: View {
Text("Cancel")
.frame(maxWidth: .infinity, alignment: .center)
}
Button {
let visualizer: CustomVisualizer = .init(
UUID: UUID(),
@ -1159,11 +1011,11 @@ struct Appearance: View {
url: URL(string: url)!,
speed: speed
)
if !customVisualizers.contains(visualizer) {
customVisualizers.append(visualizer)
}
isPresented.toggle()
} label: {
Text("Add")
@ -1185,7 +1037,7 @@ struct Appearance: View {
}
}
}
Section {
Defaults.Toggle("Enable boring mirror", key: .showMirror)
.disabled(!checkVideoInput())
@ -1201,7 +1053,7 @@ struct Appearance: View {
Text("Additional features")
}
}
Section {
HStack {
ForEach(icons, id: \.self) { icon in
@ -1217,7 +1069,7 @@ struct Appearance: View {
lineWidth: 2.5
)
)
Text("Default")
.fontWeight(.medium)
.font(.caption)
@ -1248,12 +1100,12 @@ struct Appearance: View {
}
.navigationTitle("Appearance")
}
func checkVideoInput() -> Bool {
if let _ = AVCaptureDevice.default(for: .video) {
return true
}
return false
}
}
@ -1274,190 +1126,11 @@ struct Shortcuts: View {
Section {
KeyboardShortcuts.Recorder("Toggle Notch Open:", name: .toggleNotchOpen)
}
Section {
KeyboardShortcuts.Recorder("Start Demo Timer:", name: .startDemoTimer)
} header: {
Text("Timer")
} footer: {
Text("Starts a 5-minute demo timer to test the timer live activity feature.")
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.font(.caption)
}
}
.navigationTitle("Shortcuts")
}
}
struct Stats: View {
@ObservedObject var statsManager = StatsManager.shared
@Default(.enableStatsFeature) var enableStatsFeature
@Default(.showCpuGraph) var showCpuGraph
@Default(.showMemoryGraph) var showMemoryGraph
@Default(.showGpuGraph) var showGpuGraph
@Default(.showNetworkGraph) var showNetworkGraph
@Default(.showDiskGraph) var showDiskGraph
var enabledGraphsCount: Int {
[showCpuGraph, showMemoryGraph, showGpuGraph, showNetworkGraph, showDiskGraph].filter { $0 }.count
}
var body: some View {
Form {
Section {
Defaults.Toggle("Enable system stats monitoring", key: .enableStatsFeature)
.onChange(of: enableStatsFeature) { _, newValue in
if newValue {
statsManager.startMonitoring()
} else {
statsManager.stopMonitoring()
}
}
} header: {
Text("General")
} footer: {
Text("When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery.")
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.font(.caption)
}
if enableStatsFeature {
Section {
Defaults.Toggle("CPU Usage", key: .showCpuGraph)
Defaults.Toggle("Memory Usage", key: .showMemoryGraph)
Defaults.Toggle("GPU Usage", key: .showGpuGraph)
Defaults.Toggle("Network Activity", key: .showNetworkGraph)
Defaults.Toggle("Disk I/O", key: .showDiskGraph)
} header: {
Text("Graph Visibility")
} footer: {
if enabledGraphsCount >= 4 {
Text("With \(enabledGraphsCount) graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row.")
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.font(.caption)
} else {
Text("Each graph can be individually enabled or disabled. Network activity shows download/upload speeds, and disk I/O shows read/write speeds.")
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
.font(.caption)
}
}
Section {
HStack {
Text("Monitoring Status")
Spacer()
HStack(spacing: 4) {
Circle()
.fill(statsManager.isMonitoring ? .green : .red)
.frame(width: 8, height: 8)
Text(statsManager.isMonitoring ? "Active" : "Stopped")
.foregroundStyle(.secondary)
}
}
if statsManager.isMonitoring {
if showCpuGraph {
HStack {
Text("CPU Usage")
Spacer()
Text(statsManager.cpuUsageString)
.foregroundStyle(.secondary)
}
}
if showMemoryGraph {
HStack {
Text("Memory Usage")
Spacer()
Text(statsManager.memoryUsageString)
.foregroundStyle(.secondary)
}
}
if showGpuGraph {
HStack {
Text("GPU Usage")
Spacer()
Text(statsManager.gpuUsageString)
.foregroundStyle(.secondary)
}
}
if showNetworkGraph {
HStack {
Text("Network Download")
Spacer()
Text(String(format: "%.1f MB/s", statsManager.networkDownload))
.foregroundStyle(.secondary)
}
HStack {
Text("Network Upload")
Spacer()
Text(String(format: "%.1f MB/s", statsManager.networkUpload))
.foregroundStyle(.secondary)
}
}
if showDiskGraph {
HStack {
Text("Disk Read")
Spacer()
Text(String(format: "%.1f MB/s", statsManager.diskRead))
.foregroundStyle(.secondary)
}
HStack {
Text("Disk Write")
Spacer()
Text(String(format: "%.1f MB/s", statsManager.diskWrite))
.foregroundStyle(.secondary)
}
}
HStack {
Text("Last Updated")
Spacer()
Text(statsManager.lastUpdated, style: .relative)
.foregroundStyle(.secondary)
}
}
} header: {
Text("Live Performance Data")
}
Section {
HStack {
Button(statsManager.isMonitoring ? "Stop Monitoring" : "Start Monitoring") {
if statsManager.isMonitoring {
statsManager.stopMonitoring()
} else {
statsManager.startMonitoring()
}
}
.buttonStyle(.borderedProminent)
.foregroundColor(statsManager.isMonitoring ? .red : .blue)
Spacer()
Button("Clear Data") {
statsManager.clearHistory()
}
.buttonStyle(.bordered)
.disabled(statsManager.isMonitoring)
}
} header: {
Text("Controls")
}
}
}
.navigationTitle("Stats")
}
}
func proFeatureBadge() -> some View {
Text("Upgrade to Pro")
.foregroundStyle(Color(red: 0.545, green: 0.196, blue: 0.98))
@ -1504,129 +1177,120 @@ func warningBadge(_ text: String, _ description: String) -> some View {
}
}
struct ClipboardSettings: View {
@ObservedObject var clipboardManager = ClipboardManager.shared
@Default(.enableClipboardManager) var enableClipboardManager
@Default(.clipboardHistorySize) var clipboardHistorySize
@Default(.showClipboardIcon) var showClipboardIcon
struct TimerSettings: View {
var body: some View {
Form {
Section {
Defaults.Toggle("Enable Clipboard Manager", key: .enableClipboardManager)
.onChange(of: enableClipboardManager) { enabled in
if enabled {
clipboardManager.startMonitoring()
Defaults.Toggle("Enable timer feature", key: .enableTimerFeature)
.onChange(of: Defaults[.enableTimerFeature]) { _, newValue in
if !newValue {
// Stop any running timers when feature is disabled
TimerManager.shared.stopTimer()
}
}
} header: {
Text("Timer")
} footer: {
if Defaults[.enableTimerFeature] {
Text("Timer tab will appear in the notch when enabled.")
} else {
Text("Enable to access timer functionality in the Dynamic Island.")
}
}
}
.navigationTitle("Timer")
}
}
struct StatsSettings: View {
var body: some View {
Form {
Section {
Defaults.Toggle("Enable stats feature", key: .enableStatsFeature)
.onChange(of: Defaults[.enableStatsFeature]) { _, newValue in
if !newValue {
// Stop monitoring when feature is disabled
StatsManager.shared.stopMonitoring()
}
}
} header: {
Text("System Monitoring")
} footer: {
if Defaults[.enableStatsFeature] {
Text("Stats tab will appear in the notch when enabled.")
} else {
Text("Enable to monitor CPU, Memory, GPU, Network and Disk usage.")
}
}
if Defaults[.enableStatsFeature] {
Section {
Defaults.Toggle("Show CPU graph", key: .showCpuGraph)
Defaults.Toggle("Show Memory graph", key: .showMemoryGraph)
Defaults.Toggle("Show GPU graph", key: .showGpuGraph)
Defaults.Toggle("Show Network graph", key: .showNetworkGraph)
Defaults.Toggle("Show Disk graph", key: .showDiskGraph)
} header: {
Text("Graph Visibility")
} footer: {
Text("Choose which performance graphs to display. 1-3 graphs use horizontal layout, 4+ graphs use compressed grid layout.")
}
}
}
.navigationTitle("Stats")
}
}
struct ClipboardSettings: View {
var body: some View {
Form {
Section {
Defaults.Toggle("Enable clipboard manager", key: .enableClipboardManager)
.onChange(of: Defaults[.enableClipboardManager]) { _, newValue in
if newValue {
ClipboardManager.shared.startMonitoring()
} else {
clipboardManager.stopMonitoring()
ClipboardManager.shared.stopMonitoring()
}
}
} header: {
Text("Clipboard Manager")
} footer: {
Text("Monitor clipboard changes and keep a history of recent copies. Use Cmd+Shift+V to quickly access clipboard history.")
if Defaults[.enableClipboardManager] {
Text("Clipboard history will be tracked and accessible via the header button.")
} else {
Text("Enable to track clipboard history and access recent items.")
}
}
if enableClipboardManager {
if Defaults[.enableClipboardManager] {
Section {
Defaults.Toggle("Show Clipboard Icon", key: .showClipboardIcon)
Defaults.Toggle("Show clipboard icon", key: .showClipboardIcon)
HStack {
Text("History Size")
Spacer()
Picker("History Size", selection: $clipboardHistorySize) {
Text("3 items").tag(3)
Text("5 items").tag(5)
Text("7 items").tag(7)
Text("10 items").tag(10)
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("History size")
Spacer()
Text("\(Defaults[.clipboardHistorySize]) items")
.foregroundStyle(.secondary)
}
.pickerStyle(.menu)
.frame(width: 100)
}
HStack {
Text("Current Items")
Spacer()
Text("\(clipboardManager.clipboardHistory.count)")
.foregroundColor(.secondary)
}
HStack {
Text("Monitoring Status")
Spacer()
Text(clipboardManager.isMonitoring ? "Active" : "Stopped")
.foregroundColor(clipboardManager.isMonitoring ? .green : .secondary)
Slider(
value: Binding(
get: { Double(Defaults[.clipboardHistorySize]) },
set: { Defaults[.clipboardHistorySize] = Int($0) }
),
in: 3...10,
step: 1
)
}
} header: {
Text("Settings")
} footer: {
Text("The clipboard icon appears in the header next to the settings gear when enabled. Use the global shortcut Cmd+Shift+V or click the icon to access history.")
}
Section {
Button("Clear Clipboard History") {
clipboardManager.clearHistory()
}
.foregroundColor(.red)
.disabled(clipboardManager.clipboardHistory.isEmpty)
} header: {
Text("Actions")
} footer: {
Text("This will permanently delete all stored clipboard history. The clipboard button is located to the left of the settings gear in the header.")
}
if !clipboardManager.clipboardHistory.isEmpty {
Section {
ForEach(clipboardManager.clipboardHistory) { item in
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: item.type.icon)
.foregroundColor(.blue)
.frame(width: 16)
Text(item.type.displayName)
.font(.caption)
.foregroundColor(.secondary)
Spacer()
Text(timeAgoString(from: item.timestamp))
.font(.caption2)
.foregroundColor(.secondary)
}
Text(item.preview)
.font(.system(.body, design: .monospaced))
.lineLimit(2)
}
.padding(.vertical, 2)
}
} header: {
Text("Current History")
}
Text("Configure how many recent clipboard items to remember (minimum 3).")
}
}
}
.formStyle(.grouped)
.navigationTitle("Clipboard")
.onAppear {
if enableClipboardManager && !clipboardManager.isMonitoring {
clipboardManager.startMonitoring()
}
}
}
private func timeAgoString(from date: Date) -> String {
let interval = Date().timeIntervalSince(date)
if interval < 60 {
return "Just now"
} else if interval < 3600 {
let minutes = Int(interval / 60)
return "\(minutes)m ago"
} else if interval < 86400 {
let hours = Int(interval / 3600)
return "\(hours)h ago"
} else {
let days = Int(interval / 86400)
return "\(days)d ago"
}
}
}

View file

@ -26,13 +26,6 @@ struct AppIcons {
return getIcon(file: path)
}
func getIcon(application: String) -> NSImage? {
guard let path = NSWorkspace.shared.fullPath(forApplication: application)
else { return nil }
return getIcon(file: path)
}
/// Easily read Info.plist as a Dictionary from any bundle by accessing .infoDictionary on Bundle
func bundle(forBundleID: String) -> Bundle? {
guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: forBundleID)

View file

@ -0,0 +1,67 @@
//
// MediaChecker.swift
// DynamicIsland
//
// Created by Alexander on 2025-07-26.
//
import Foundation
@MainActor
final class MediaChecker: Sendable {
private(set) var isNowPlayingDeprecated: Bool = false
enum MediaCheckerError: Error {
case missingResources
case processExecutionFailed
case timeout
}
func checkDeprecationStatus() async throws -> Bool {
guard let scriptURL = Bundle.main.url(forResource: "mediaremote-adapter", withExtension: "pl"),
let nowPlayingTestClientPath = Bundle.main.url(forResource: "NowPlayingTestClient", withExtension: nil)?.path,
let frameworkPath = Bundle.main.privateFrameworksPath?.appending("/MediaRemoteAdapter.framework")
else {
throw MediaCheckerError.missingResources
}
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
process.arguments = [scriptURL.path, frameworkPath, nowPlayingTestClientPath, "test"]
let pipe = Pipe()
process.standardOutput = pipe
do {
try process.run()
} catch {
throw MediaCheckerError.processExecutionFailed
}
// Timeout after 10 seconds
let result: String = try await withThrowingTaskGroup(of: String?.self) { group in
group.addTask {
process.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return String(data: data, encoding: .utf8)
}
group.addTask {
try await Task.sleep(for: .seconds(10))
if process.isRunning {
process.terminate()
}
return "0" // Default value if process takes too long
}
for try await output in group {
if let output = output {
group.cancelAll()
return output
}
}
throw MediaCheckerError.timeout
}
let isDeprecated = result.trimmingCharacters(in: .whitespacesAndNewlines).last == "1"
self.isNowPlayingDeprecated = isDeprecated
return isDeprecated
}
}

View file

@ -13,6 +13,8 @@ import Defaults
@MainActor
class CalendarManager: ObservableObject {
static let shared = CalendarManager()
@Published var currentWeekStartDate: Date
@Published var events: [EventModel] = []
@Published var allCalendars: [CalendarModel] = []
@ -20,7 +22,7 @@ class CalendarManager: ObservableObject {
private var selectedCalendars: [CalendarModel] = []
private let calendarService = CalendarService()
init() {
private init() {
self.currentWeekStartDate = CalendarManager.startOfDay(Date())
}
@ -33,7 +35,13 @@ class CalendarManager: ObservableObject {
switch status {
case .notDetermined:
self.authorizationStatus = await calendarService.requestAccess() ? .fullAccess : .denied
let granted = await calendarService.requestAccess()
self.authorizationStatus = granted ? .fullAccess : .denied
if granted {
allCalendars = await calendarService.calendars()
updateSelectedCalendars()
events = await calendarService.events(from: currentWeekStartDate, to: Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate)!, calendars: selectedCalendars.map{$0.id})
}
case .restricted, .denied:
// Handle the case where the user has denied or restricted access
NSLog("Calendar access denied or restricted")
@ -48,11 +56,11 @@ class CalendarManager: ObservableObject {
print("Unknown authorization status")
}
}
func updateSelectedCalendars() {
selectedCalendars = allCalendars.filter { getCalendarSelected($0) }
}
func getCalendarSelected(_ calendar: CalendarModel) -> Bool {
switch Defaults[.calendarSelectionState] {
case .all:
@ -78,7 +86,7 @@ class CalendarManager: ObservableObject {
} else {
identifiers.remove(calendar.id)
}
selectionState = identifiers.isEmpty ? .all : identifiers.count == allCalendars.count ? .all : .selected(identifiers) // if empty, select all
}
@ -86,11 +94,11 @@ class CalendarManager: ObservableObject {
updateSelectedCalendars()
events = await calendarService.events(from: currentWeekStartDate, to: Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate)!, calendars: selectedCalendars.map{$0.id})
}
static func startOfDay(_ date: Date) -> Date {
return Calendar.current.startOfDay(for: date)
}
func updateCurrentDate(_ date: Date) async {
currentWeekStartDate = Calendar.current.startOfDay(for: date)
events = await calendarService.events(from: currentWeekStartDate, to: Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate)!, calendars: selectedCalendars.map{$0.id})

View file

@ -19,15 +19,11 @@ class MusicManager: ObservableObject {
static let shared = MusicManager()
private var cancellables = Set<AnyCancellable>()
private var controllerCancellables = Set<AnyCancellable>()
private var debounceToggle: DispatchWorkItem?
private var debounceIdleTask: Task<Void, Never>?
// Helper to check if macOS is too new for NowPlayingController
public var isNowPlayingDeprecated: Bool {
if #available(macOS 15.4, *) {
return true
}
return false
}
// Helper to check if macOS has removed support for NowPlayingController
public private(set) var isNowPlayingDeprecated: Bool = false
private let mediaChecker = MediaChecker()
// Active controller
private var activeController: (any MediaControllerProtocol)?
@ -48,6 +44,8 @@ class MusicManager: ObservableObject {
@Published var elapsedTime: TimeInterval = 0
@Published var timestampDate: Date = .init()
@Published var playbackRate: Double = 1
@Published var isShuffled: Bool = false
@Published var repeatMode: RepeatMode = .off
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@Published var usingAppIconForArtwork: Bool = false
@ -67,13 +65,24 @@ class MusicManager: ObservableObject {
self?.setActiveControllerBasedOnPreference()
}
.store(in: &cancellables)
// Initialize deprecation check asynchronously
Task { @MainActor in
do {
self.isNowPlayingDeprecated = try await self.mediaChecker.checkDeprecationStatus()
print("Deprecation check completed: \(self.isNowPlayingDeprecated)")
} catch {
print("Failed to check deprecation status: \(error). Defaulting to false.")
self.isNowPlayingDeprecated = false
}
// Initialize the active controller
setActiveControllerBasedOnPreference()
// Initialize the active controller after deprecation check
self.setActiveControllerBasedOnPreference()
}
}
deinit {
debounceToggle?.cancel()
debounceIdleTask?.cancel()
cancellables.removeAll()
controllerCancellables.removeAll()
flipWorkItem?.cancel()
@ -98,7 +107,17 @@ class MusicManager: ObservableObject {
// Only create NowPlayingController if not deprecated on this macOS version
if !self.isNowPlayingDeprecated {
ignoreLastUpdated = false
newController = NowPlayingController()
if let controller = NowPlayingController() {
// Check if the controller can actually work
if controller.isWorking {
newController = controller
} else {
print("⚠️ NowPlayingController created but resources unavailable, falling back to default")
return nil
}
} else {
return nil
}
} else {
return nil
}
@ -285,19 +304,20 @@ class MusicManager: ObservableObject {
private func updateIdleState(state: Bool) {
if state {
isPlayerIdle = false
debounceToggle?.cancel()
debounceIdleTask?.cancel()
} else {
debounceToggle = DispatchWorkItem { [weak self] in
guard let self = self else { return }
if self.lastUpdated.timeIntervalSinceNow < -Defaults[.waitInterval] {
withAnimation {
self.isPlayerIdle = !self.isPlaying
debounceIdleTask?.cancel()
debounceIdleTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(Defaults[.waitInterval]))
guard let self = self, !Task.isCancelled else { return }
await MainActor.run {
if self.lastUpdated.timeIntervalSinceNow < -Defaults[.waitInterval] {
withAnimation {
self.isPlayerIdle = !self.isPlaying
}
}
}
}
DispatchQueue.main.asyncAfter(
deadline: .now() + Defaults[.waitInterval], execute: debounceToggle!)
}
}
@ -338,31 +358,57 @@ class MusicManager: ObservableObject {
// MARK: - Public Methods for controlling playback
func playPause() {
activeController?.togglePlay()
Task {
await activeController?.togglePlay()
}
}
func play() {
activeController?.play()
Task {
await activeController?.play()
}
}
func pause() {
activeController?.pause()
Task {
await activeController?.pause()
}
}
func toggleShuffle() {
Task {
await activeController?.toggleShuffle()
}
}
func toggleRepeat() {
Task {
await activeController?.toggleRepeat()
}
}
func togglePlay() {
activeController?.togglePlay()
Task {
await activeController?.togglePlay()
}
}
func nextTrack() {
activeController?.nextTrack()
Task {
await activeController?.nextTrack()
}
}
func previousTrack() {
activeController?.previousTrack()
Task {
await activeController?.previousTrack()
}
}
func seek(to position: TimeInterval) {
activeController?.seek(to: position)
Task {
await activeController?.seek(to: position)
}
}
func openMusicApp() {
@ -388,9 +434,14 @@ class MusicManager: ObservableObject {
func forceUpdate() {
// Request immediate update from the active controller
DispatchQueue.main.async { [weak self] in
Task { [weak self] in
if self?.activeController?.isActive() == true {
self?.activeController?.updatePlaybackInfo()
if type(of: self?.activeController) == YouTubeMusicController.self,
let youtubeController = self?.activeController as? YouTubeMusicController {
await youtubeController.pollPlaybackState()
} else {
await self?.activeController?.updatePlaybackInfo()
}
}
}
}

View file

@ -2,7 +2,7 @@
// NotchSpaceManager.swift
// DynamicIsland
//
// Created by Alexander Greco on 2024-10-27.
// Created by Alexander on 2024-10-27.
//
import Foundation

View file

@ -1,12 +1,12 @@
import Foundation
import Cocoa
import SwiftUI
import IOKit.ps
import Defaults
import Foundation
import IOKit.ps
import SwiftUI
/// A view model that manages and monitors the battery status of the device
class BatteryStatusViewModel: ObservableObject {
private var wasCharging: Bool = false
private var powerSourceChangedCallback: IOPowerSourceCallbackType?
private var runLoopSource: Unmanaged<CFRunLoopSource>?
@ -22,14 +22,14 @@ class BatteryStatusViewModel: ObservableObject {
@Published private(set) var isInitial: Bool = false
@Published private(set) var timeToFullCharge: Int = 0
@Published private(set) var statusText: String = ""
private let managerBattery = BatteryActivityManager.shared
private var managerBatteryId: Int?
static let shared = BatteryStatusViewModel()
/// Initializes the view model with a given DynamicIslandViewModel instance
/// - Parameter vm: The DynamicIslandViewModel instance
/// Initializes the view model with a given BoringViewModel instance
/// - Parameter vm: The BoringViewModel instance
private init() {
setupPowerStatus()
setupMonitor()
@ -48,58 +48,61 @@ class BatteryStatusViewModel: ObservableObject {
self.handleBatteryEvent(event)
}
}
/// Handles battery events and updates the corresponding properties
/// - Parameter event: The battery event to handle
private func handleBatteryEvent(_ event: BatteryActivityManager.BatteryEvent) {
private func handleBatteryEvent(_ event: BatteryActivityManager.BatteryEvent) {
switch event {
case .powerSourceChanged(let isPluggedIn):
print("🔌 Power source: \(isPluggedIn ? "Connected" : "Disconnected")")
withAnimation {
self.isPluggedIn = isPluggedIn
self.statusText = isPluggedIn ? "Plugged In" : "Unplugged"
self.notifyImportanChangeStatus()
}
case .batteryLevelChanged(let level):
print("🔋 Battery level: \(Int(level))%")
withAnimation {
self.levelBattery = level
}
case .lowPowerModeChanged(let isEnabled):
print("⚡ Low power mode: \(isEnabled ? "Enabled" : "Disabled")")
self.notifyImportanChangeStatus()
withAnimation {
self.isInLowPowerMode = isEnabled
self.statusText = "Low Power: \(self.isInLowPowerMode ? "On" : "Off")"
}
case .isChargingChanged(let isCharging):
print("🔌 Charging: \(isCharging ? "Yes" : "No")")
print("maxCapacity: \(self.maxCapacity)")
print("levelBattery: \(self.levelBattery)")
self.notifyImportanChangeStatus()
withAnimation {
self.isCharging = isCharging
self.statusText = isCharging ? "Charging battery" : (self.levelBattery < self.maxCapacity ? "Not charging" : "Full charge")
}
case .timeToFullChargeChanged(let time):
print("🕒 Time to full charge: \(time) minutes")
withAnimation {
self.timeToFullCharge = time
}
case .maxCapacityChanged(let capacity):
print("🔋 Max capacity: \(capacity)")
withAnimation {
self.maxCapacity = capacity
}
case .error(let description):
print("⚠️ Error: \(description)")
case .powerSourceChanged(let isPluggedIn):
print("🔌 Power source: \(isPluggedIn ? "Connected" : "Disconnected")")
withAnimation {
self.isPluggedIn = isPluggedIn
self.statusText = isPluggedIn ? "Plugged In" : "Unplugged"
self.notifyImportanChangeStatus()
}
case .batteryLevelChanged(let level):
print("🔋 Battery level: \(Int(level))%")
withAnimation {
self.levelBattery = level
}
case .lowPowerModeChanged(let isEnabled):
print("⚡ Low power mode: \(isEnabled ? "Enabled" : "Disabled")")
self.notifyImportanChangeStatus()
withAnimation {
self.isInLowPowerMode = isEnabled
self.statusText = "Low Power: \(self.isInLowPowerMode ? "On" : "Off")"
}
case .isChargingChanged(let isCharging):
print("🔌 Charging: \(isCharging ? "Yes" : "No")")
print("maxCapacity: \(self.maxCapacity)")
print("levelBattery: \(self.levelBattery)")
self.notifyImportanChangeStatus()
withAnimation {
self.isCharging = isCharging
self.statusText =
isCharging
? "Charging battery"
: (self.levelBattery < self.maxCapacity ? "Not charging" : "Full charge")
}
case .timeToFullChargeChanged(let time):
print("🕒 Time to full charge: \(time) minutes")
withAnimation {
self.timeToFullCharge = time
}
case .maxCapacityChanged(let capacity):
print("🔋 Max capacity: \(capacity)")
withAnimation {
self.maxCapacity = capacity
}
case .error(let description):
print("⚠️ Error: \(description)")
}
}
/// Updates the battery information with the given BatteryInfo instance
@ -114,25 +117,13 @@ class BatteryStatusViewModel: ObservableObject {
self.maxCapacity = batteryInfo.maxCapacity
self.statusText = batteryInfo.isPluggedIn ? "Plugged In" : "Unplugged"
}
notifyImportanChangeStatus(delay: coordinator.firstLaunch ? 6 : 0.0)
withAnimation {
if self.isCharging {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.statusText = "Charging: Yes"
}
}
if self.isInLowPowerMode {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
self.statusText = "Low Power: On"
}
}
}
}
/// Notifies important changes in the battery status with an optional delay
/// - Parameter delay: The delay before notifying the change, default is 0.0
private func notifyImportanChangeStatus(delay: Double = 0.0) {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
Task {
try? await Task.sleep(for: .seconds(delay))
self.coordinator.toggleExpandingView(status: true, type: .battery)
}
}
@ -143,5 +134,5 @@ class BatteryStatusViewModel: ObservableObject {
managerBattery.removeObserver(byId: managerBatteryId)
}
}
}

View file

@ -117,6 +117,7 @@ extension Defaults.Keys {
static let sneakPeekStyles = Key<SneakPeekStyle>("sneakPeekStyles", default: .standard)
static let enableFullscreenMediaDetection = Key<Bool>("enableFullscreenMediaDetection", default: true)
static let waitInterval = Key<Double>("waitInterval", default: 3)
static let showShuffleAndRepeat = Key<Bool>("showShuffleAndRepeat", default: false)
// MARK: Battery
static let showPowerStatusNotifications = Key<Bool>("showPowerStatusNotifications", default: true)

View file

@ -30,6 +30,10 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
@Published var isHoveringCalendar: Bool = false
@Published var isBatteryPopoverActive: Bool = false
@Published var isClipboardPopoverActive: Bool = false
let webcamManager = WebcamManager.shared
@Published var isCameraExpanded: Bool = false
@Published var isRequestingAuthorization: Bool = false
@Published var screen: String?
@ -172,4 +176,46 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
}
}
}
func toggleCameraPreview() {
if isRequestingAuthorization {
return
}
switch webcamManager.authorizationStatus {
case .authorized:
if webcamManager.isSessionRunning {
webcamManager.stopSession()
isCameraExpanded = false
} else if webcamManager.cameraAvailable {
webcamManager.startSession()
isCameraExpanded = true
}
case .denied, .restricted:
DispatchQueue.main.async {
NSApp.setActivationPolicy(.regular)
NSApp.activate(ignoringOtherApps: true)
let alert = NSAlert()
alert.messageText = "Camera Access Required"
alert.informativeText = "Please allow camera access in System Settings."
alert.addButton(withTitle: "OK")
alert.runModal()
NSApp.setActivationPolicy(.accessory)
NSApp.deactivate()
}
case .notDetermined:
isRequestingAuthorization = true
webcamManager.checkAndRequestVideoAuthorization()
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
self.isRequestingAuthorization = false
}
default:
break
}
}
}

View file

@ -7,6 +7,12 @@
import Foundation
enum RepeatMode: Int, Codable {
case off = 1
case one = 2
case all = 3
}
struct PlaybackState {
var bundleIdentifier: String
var isPlaying: Bool = false
@ -16,8 +22,8 @@ struct PlaybackState {
var currentTime: Double = 0
var duration: Double = 0
var playbackRate: Double = 1
var isShuffled: Bool? = nil
var isRepeating: Bool? = nil
var isShuffled: Bool = false
var repeatMode: RepeatMode = .off
var lastUpdated: Date = Date.distantPast
var artwork: Data?
}

View file

@ -5,9 +5,6 @@
// Created by Richard Kunkli on 06/09/2024.
//
import Accessibility
import Cocoa
import CoreAudio
import Defaults
import MacroVisionKit
import SwiftUI
@ -16,15 +13,15 @@ import SwiftUI
class FullscreenMediaDetector: ObservableObject {
static let shared = FullscreenMediaDetector()
private let detector: MacroVisionKit
@ObservedObject private var musicManager = MusicManager.shared
// changed from [NSScreen:Bool] to [screenName:Bool]
@Published private(set) var fullscreenStatus: [String: Bool] = [:]
private init() {
self.detector = MacroVisionKit.shared
detector.configuration.includeSystemApps = true
setupNotificationObservers()
updateFullScreenStatus() // now on MainActor
updateFullScreenStatus()
}
private func setupNotificationObservers() {
@ -42,25 +39,20 @@ class FullscreenMediaDetector: ObservableObject {
}
private func updateFullScreenStatus() {
NSLog("🔄 Fullscreen status update triggered")
guard Defaults[.enableFullscreenMediaDetection] else {
NSLog("❌ Fullscreen media detection disabled")
let reset = Dictionary(uniqueKeysWithValues: NSScreen.screens.map { ($0.localizedName, false) }
)
let reset = Dictionary(uniqueKeysWithValues: NSScreen.screens.map { ($0.localizedName, false) })
if reset != fullscreenStatus {
fullscreenStatus = reset
NSLog("⏸ Fullscreen disabled → reset all screens")
}
return
}
NSLog("🔄 Fullscreen media detection enabled")
let apps = detector.detectFullscreenApps(debug: false)
let names = NSScreen.screens.map { $0.localizedName }
var newStatus: [String: Bool] = [:]
for name in names {
newStatus[name] = apps.contains { $0.screen.localizedName == name && $0.bundleIdentifier != "com.apple.finder" }
newStatus[name] = apps.contains { $0.screen.localizedName == name && $0.bundleIdentifier != "com.apple.finder" && ($0.bundleIdentifier == musicManager.bundleIdentifier || Defaults[.hideNotchOption] == .always) }
}
if newStatus != fullscreenStatus {
@ -69,15 +61,6 @@ class FullscreenMediaDetector: ObservableObject {
}
}
func isScreenInFullScreen(_ screen: NSScreen) -> Bool {
let screenName = screen.localizedName
return fullscreenStatus[screenName] ?? false
}
func isAnyScreenInFullScreen() -> Bool {
return fullscreenStatus.values.contains(true)
}
private func cleanupNotificationObservers() {
NSWorkspace.shared.notificationCenter.removeObserver(self)
}

View file

@ -1,7 +1,9 @@
//
// sizeMatters.swift
//
// matters.swift
// DynamicIsland
//
//
// Created by Harsh Vardhan Goswami on 05/08/24.
//
@ -12,7 +14,7 @@ import SwiftUI
let downloadSneakSize: CGSize = .init(width: 65, height: 1)
let batterySneakSize: CGSize = .init(width: 160, height: 1)
let openNotchSize: CGSize = .init(width: 610, height: 200)
let openNotchSize: CGSize = .init(width: 640, height: 190)
let cornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 19, bottom: 24), closed: (top: 6, bottom: 14))
enum MusicPlayerImageSizes {

View file

@ -0,0 +1 @@
Versions/Current/MediaRemoteAdapter

View file

@ -0,0 +1 @@
Versions/Current/Resources

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleExecutable</key>
<string>MediaRemoteAdapter</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>com.vandenbe.MediaRemoteAdapter</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>MediaRemoteAdapter</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>CFBundleShortVersionString</key>
<string>0.1</string>
<key>CSResourcesFileMapped</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,128 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>files</key>
<dict>
<key>Resources/Info.plist</key>
<data>
M6AF1VWVJ1A/DSliCSjg170FqsY=
</data>
</dict>
<key>files2</key>
<dict>
<key>Resources/Info.plist</key>
<dict>
<key>hash2</key>
<data>
z3yWmTAqjdrPJEZUQ+t6AVPhw0e/I8PAiVr0HIU2ivg=
</data>
</dict>
</dict>
<key>rules</key>
<dict>
<key>^Resources/</key>
<true/>
<key>^Resources/.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^Resources/.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Resources/Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^version.plist$</key>
<true/>
</dict>
<key>rules2</key>
<dict>
<key>.*\.dSYM($|/)</key>
<dict>
<key>weight</key>
<real>11</real>
</dict>
<key>^(.*/)?\.DS_Store$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>2000</real>
</dict>
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
<dict>
<key>nested</key>
<true/>
<key>weight</key>
<real>10</real>
</dict>
<key>^.*</key>
<true/>
<key>^Info\.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^PkgInfo$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^Resources/</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^Resources/.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^Resources/.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Resources/Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^[^/]+$</key>
<dict>
<key>nested</key>
<true/>
<key>weight</key>
<real>10</real>
</dict>
<key>^embedded\.provisionprofile$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^version\.plist$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
</dict>
</dict>
</plist>

View file

@ -0,0 +1 @@
A

View file

@ -0,0 +1 @@
Versions/Current/MediaRemoteAdapter

View file

@ -0,0 +1 @@
Versions/Current/Resources

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleExecutable</key>
<string>MediaRemoteAdapter</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>com.vandenbe.MediaRemoteAdapter</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>MediaRemoteAdapter</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>CFBundleShortVersionString</key>
<string>0.1</string>
<key>CSResourcesFileMapped</key>
<true/>
</dict>
</plist>

View file

@ -0,0 +1,128 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>files</key>
<dict>
<key>Resources/Info.plist</key>
<data>
M6AF1VWVJ1A/DSliCSjg170FqsY=
</data>
</dict>
<key>files2</key>
<dict>
<key>Resources/Info.plist</key>
<dict>
<key>hash2</key>
<data>
z3yWmTAqjdrPJEZUQ+t6AVPhw0e/I8PAiVr0HIU2ivg=
</data>
</dict>
</dict>
<key>rules</key>
<dict>
<key>^Resources/</key>
<true/>
<key>^Resources/.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^Resources/.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Resources/Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^version.plist$</key>
<true/>
</dict>
<key>rules2</key>
<dict>
<key>.*\.dSYM($|/)</key>
<dict>
<key>weight</key>
<real>11</real>
</dict>
<key>^(.*/)?\.DS_Store$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>2000</real>
</dict>
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
<dict>
<key>nested</key>
<true/>
<key>weight</key>
<real>10</real>
</dict>
<key>^.*</key>
<true/>
<key>^Info\.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^PkgInfo$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>20</real>
</dict>
<key>^Resources/</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^Resources/.*\.lproj/</key>
<dict>
<key>optional</key>
<true/>
<key>weight</key>
<real>1000</real>
</dict>
<key>^Resources/.*\.lproj/locversion.plist$</key>
<dict>
<key>omit</key>
<true/>
<key>weight</key>
<real>1100</real>
</dict>
<key>^Resources/Base\.lproj/</key>
<dict>
<key>weight</key>
<real>1010</real>
</dict>
<key>^[^/]+$</key>
<dict>
<key>nested</key>
<true/>
<key>weight</key>
<real>10</real>
</dict>
<key>^embedded\.provisionprofile$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
<key>^version\.plist$</key>
<dict>
<key>weight</key>
<real>20</real>
</dict>
</dict>
</dict>
</plist>

View file

@ -0,0 +1 @@
A

Binary file not shown.

View file

@ -0,0 +1,257 @@
#!/usr/bin/perl
# Copyright (c) 2025 Jonas van den Berg
# This file is licensed under the BSD 3-Clause License.
# For usage information read below or run the script without arguments.
use strict;
use warnings;
use DynaLoader;
use File::Spec;
use File::Basename;
sub print_help() {
print <<'HELP';
Usage:
mediaremote-adapter.pl FRAMEWORK_PATH [NOWPLAYING_CLIENT_PATH] [FUNCTION [PARAMS|OPTIONS...]]
FRAMEWORK_PATH:
Absolute path to MediaRemoteAdapter.framework
NOWPLAYING_CLIENT_PATH (optional):
Path to the NowPlayingTestClient executable (used for test mode)
FUNCTION:
stream Streams now playing information (as diff by default)
get Prints now playing information once with all available metadata
send Sends a command to the now playing application
seek Seeks to a specific timeline position
shuffle Sets the shuffle mode
repeat Sets the repeat mode
speed Sets the playback speed
PARAMS:
send(command)
command: The MRCommand ID as a number (e.g. kMRPlay = 0)
seek(position)
position: The timeline position in microseconds
shuffle(mode)
mode: The shuffle mode
repeat(mode)
mode: The repeat mode
speed(speed)
speed: The playback speed
OPTIONS:
get
--now: Sets "elapsedTime" to the current elapsed time. By default,
this value is the elapsed time at the time of the given "timestamp"
stream
--no-diff: Disable diffing and always dump all metadata
--debounce=N: Delay in milliseconds to prevent spam (0 by default)
get, stream
--micros: Replaces the following time keys with microsecond equivalents
"duration" -> "durationMicros"
"elapsedTime" -> "elapsedTimeMicros"
"timestamp" -> "timestampEpochMicros" (converted to epoch time)
--human-readable, -h: Makes values human-readable. Use only for debugging.
The JSON output is pretty-printed and the following keys are adapted:
"artworkData" -> Binary data is truncated to a shorter representation
Examples (script name and framework path omitted):
stream --no-diff --debounce=100
send 2 # Toggles play/pause in the media player (kMRATogglePlayPause)
repeat 3 # Sets the repeat mode to "playlist" (kMRARepeatModePlaylist)
HELP
exit 0;
}
if (!defined $ARGV[1]) {
print_help();
}
sub fail {
my ($error) = @_;
print STDERR "$error\n";
exit 1;
}
fail "Framework path not provided" unless @ARGV >= 1;
my $framework_path = shift @ARGV;
# Optionally accept NOWPLAYING_CLIENT path as second argument
my $maybe_helper_path = $ARGV[0] // '';
if ($maybe_helper_path =~ m{NowPlayingTestClient} || $maybe_helper_path =~ m{/}) {
my $helper_path = shift @ARGV;
$ENV{NOWPLAYING_CLIENT} = $helper_path;
}
my $framework_basename = File::Basename::basename($framework_path);
fail "Provided path is not a framework: $framework_path"
unless $framework_basename =~ s/\.framework$//;
my $framework = File::Spec->catfile($framework_path, $framework_basename);
fail "Framework not found at $framework" unless -e $framework;
my $handle = DynaLoader::dl_load_file($framework, 0)
or fail "Failed to load framework: $framework";
my $function_name = shift @ARGV or fail "Missing function name";
fail "Invalid function name: '$function_name'"
unless $function_name eq "stream"
|| $function_name eq "get"
|| $function_name eq "send"
|| $function_name eq "seek"
|| $function_name eq "shuffle"
|| $function_name eq "repeat"
|| $function_name eq "speed"
|| $function_name eq "test";
sub parse_options {
my ($start_index) = @_;
my %arg_map;
my $i = $start_index;
while ($i <= $#ARGV) {
my $arg = $ARGV[$i];
if ($arg =~ /^--([a-z\\-]+)(?:=(.*))?$/) {
my $key = $1;
my $value = defined $2 ? $2 : undef;
$arg_map{$key} = $value;
splice @ARGV, $i, 1;
}
elsif ($arg =~ /^-([a-zA-Z]+)$/) {
my @flags = split //, $1;
$arg_map{$_} = undef for @flags;
splice @ARGV, $i, 1;
}
else {
$i++;
}
}
return \%arg_map;
}
sub env_func {
my $symbol_name = shift;
return "${symbol_name}_env";
}
sub set_env_param {
my ($func, $index, $name, $value) = @_;
$ENV{"MEDIAREMOTEADAPTER_PARAM_${func}_${index}_${name}"} = "$value";
}
sub set_env_option_unsafe {
my ($name, $value) = @_;
$name =~ s/-/_/g;
$ENV{"MEDIAREMOTEADAPTER_OPTION_${name}"} = defined $value ? "$value" : "";
}
sub set_env_option {
my ($options, $key) = @_;
my $value = $options->{$key};
if (defined $value) {
fail "Unexpected value for option '$key'";
}
set_env_option_unsafe($key, $value);
}
sub set_env_option_value {
my ($options, $key) = @_;
my $value = $options->{$key};
if (!defined $value) {
fail "Missing value for option '$key'";
}
set_env_option_unsafe($key, $value);
}
my $symbol_name = "adapter_$function_name";
if ($function_name eq "send") {
my $id = shift @ARGV;
fail "Missing ID for '$function_name' command" unless defined $id;
set_env_param($symbol_name, 0, "command", "$id");
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "stream") {
my $options = parse_options(0);
foreach my $key (keys %{$options}) {
if ($key eq "no-diff") {
set_env_option($options, $key);
}
elsif ($key eq "debounce") {
set_env_option_value($options, $key);
}
elsif ($key eq "micros") {
set_env_option($options, $key);
}
elsif ($key eq "human-readable" || $key eq "h") {
set_env_option($options, "human-readable");
}
else {
fail "Unrecognized option '$key'";
}
}
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "get") {
my $options = parse_options(0);
foreach my $key (keys %{$options}) {
if ($key eq "micros") {
set_env_option($options, $key);
}
elsif ($key eq "human-readable" || $key eq "h") {
set_env_option($options, "human-readable");
}
elsif ($key eq "now") {
set_env_option($options, $key);
}
else {
fail "Unrecognized option '$key'";
}
}
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "seek") {
my $position = shift @ARGV;
fail "Missing position for '$function_name' command" unless defined $position;
set_env_param($symbol_name, 0, "position", "$position");
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "shuffle") {
my $mode = shift @ARGV;
fail "Missing mode for '$function_name' command" unless defined $mode;
set_env_param($symbol_name, 0, "mode", "$mode");
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "repeat") {
my $mode = shift @ARGV;
fail "Missing mode for '$function_name' command" unless defined $mode;
set_env_param($symbol_name, 0, "mode", "$mode");
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "speed") {
my $speed = shift @ARGV;
fail "Missing speed for '$function_name' command" unless defined $speed;
set_env_param($symbol_name, 0, "speed", "$speed");
$symbol_name = env_func($symbol_name);
}
elsif ($function_name eq "test") {
$symbol_name = "_adapter_is_it_broken_yet";
}
if (defined shift @ARGV) {
fail "Too many arguments";
}
my $symbol = DynaLoader::dl_find_symbol($handle, "$symbol_name")
or fail "Symbol '$symbol_name' not found in $framework";
DynaLoader::dl_install_xsub("main::$function_name", $symbol);
eval {
no strict "refs";
&{"main::$function_name"}();
};
if ($@) {
fail "Error executing $function_name: $@";
}