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 */; }; 7E61181A2E24071E0050006C /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = B1628B912CC260C0003D8DF3 /* SwiftUIIntrospect */; };
7E61181B2E24071E0050006C /* LottieUI in Frameworks */ = {isa = PBXBuildFile; productRef = B194240A2CD10017003E5DC2 /* LottieUI */; }; 7E61181B2E24071E0050006C /* LottieUI in Frameworks */ = {isa = PBXBuildFile; productRef = B194240A2CD10017003E5DC2 /* LottieUI */; };
7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 146BC4362D366E9B0047C66A /* MacroVisionKit */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXFileReference 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; }; F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DynamicIsland.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -35,6 +42,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
BA90505C953246D0AB206451 /* MediaRemoteAdapter.framework in Frameworks */,
7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */, 7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */,
7E61181B2E24071E0050006C /* LottieUI in Frameworks */, 7E61181B2E24071E0050006C /* LottieUI in Frameworks */,
7E61181A2E24071E0050006C /* SwiftUIIntrospect in Frameworks */, 7E61181A2E24071E0050006C /* SwiftUIIntrospect in Frameworks */,
@ -49,10 +57,43 @@
}; };
/* End PBXFrameworksBuildPhase section */ /* 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 */ /* 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 = { F54406DE2CD4987A00C7AFD9 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
7E81304F2E3FB42700E08F3F /* Frameworks */,
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */,
F54406E92CD4987A00C7AFD9 /* DynamicIsland */, F54406E92CD4987A00C7AFD9 /* DynamicIsland */,
F54406E82CD4987A00C7AFD9 /* Products */, F54406E82CD4987A00C7AFD9 /* Products */,
); );
@ -76,6 +117,7 @@
F54406E32CD4987A00C7AFD9 /* Sources */, F54406E32CD4987A00C7AFD9 /* Sources */,
F54406E42CD4987A00C7AFD9 /* Frameworks */, F54406E42CD4987A00C7AFD9 /* Frameworks */,
F54406E52CD4987A00C7AFD9 /* Resources */, F54406E52CD4987A00C7AFD9 /* Resources */,
B933CDB48C2F42C2887A9836 /* Embed Frameworks */,
); );
buildRules = ( buildRules = (
); );
@ -151,6 +193,8 @@
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */,
7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -296,6 +340,12 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/Frameworks",
"$(PROJECT_DIR)/mediaremote-adapter",
);
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Need for Spotify and Apple Music"; INFOPLIST_KEY_NSAppleEventsUsageDescription = "Need for Spotify and Apple Music";
INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode"; INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode";
@ -323,6 +373,12 @@
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
FRAMEWORK_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)",
"$(PROJECT_DIR)/Frameworks",
"$(PROJECT_DIR)/mediaremote-adapter",
);
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Need for Spotify and Apple Music"; INFOPLIST_KEY_NSAppleEventsUsageDescription = "Need for Spotify and Apple Music";
INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode"; 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) workspace.openApplication(at: appURL, configuration: configuration)
} }
NSApplication.shared.terminate(nil) NSApplication.shared.terminate(self)
} }
Button("Quit", role: .destructive) { Button("Quit", role: .destructive) {
NSApp.terminate(nil) NSApplication.shared.terminate(self)
} }
.keyboardShortcut(KeyEquivalent("Q"), modifiers: .command) .keyboardShortcut(KeyEquivalent("Q"), modifiers: .command)
} }
@ -80,7 +80,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
var whatsNewWindow: NSWindow? var whatsNewWindow: NSWindow?
var timer: Timer? var timer: Timer?
let calendarManager = CalendarManager() let calendarManager = CalendarManager.shared
let webcamManager = WebcamManager.shared let webcamManager = WebcamManager.shared
var closeNotchWorkItem: DispatchWorkItem? var closeNotchWorkItem: DispatchWorkItem?
private var previousScreens: [NSScreen]? private var previousScreens: [NSScreen]?

View file

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

View file

@ -34,6 +34,9 @@
}, },
"%lld" : { "%lld" : {
},
"%lld items" : {
}, },
"%lld%%" : { "%lld%%" : {
@ -49,18 +52,6 @@
}, },
"• New feature 1" : { "• New feature 1" : {
},
"3 items" : {
},
"5 items" : {
},
"7 items" : {
},
"10 items" : {
}, },
"About" : { "About" : {
"localizations" : { "localizations" : {
@ -85,9 +76,6 @@
}, },
"Access Denied" : { "Access Denied" : {
},
"Actions" : {
}, },
"Activate" : { "Activate" : {
@ -253,14 +241,11 @@
}, },
"Check for Updates…" : { "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 a Music Source" : {
}, },
"Choose File" : { "Choose which performance graphs to display. 1-3 graphs use horizontal layout, 4+ graphs use compressed grid layout." : {
}, },
"Circle" : { "Circle" : {
@ -268,18 +253,15 @@
}, },
"Clear" : { "Clear" : {
},
"Clear Clipboard History" : {
},
"Clear Data" : {
}, },
"Clipboard" : { "Clipboard" : {
}, },
"Clipboard History" : { "Clipboard History" : {
},
"Clipboard history will be tracked and accessible via the header button." : {
}, },
"Clipboard Manager" : { "Clipboard Manager" : {
@ -296,27 +278,15 @@
} }
} }
} }
},
"Configure how many recent clipboard items to remember (minimum 3)." : {
}, },
"Continue" : { "Continue" : {
},
"Controls" : {
}, },
"Copy something to get started" : { "Copy something to get started" : {
},
"CPU Usage" : {
},
"Current custom timer: %@" : {
},
"Current History" : {
},
"Current Items" : {
}, },
"Custom" : { "Custom" : {
@ -340,27 +310,15 @@
}, },
"Custom notch size - %.0f" : { "Custom notch size - %.0f" : {
},
"Custom Timer" : {
},
"Custom Timer Duration" : {
}, },
"Custom vizualizers (Lottie)" : { "Custom vizualizers (Lottie)" : {
},
"Custom: %@" : {
}, },
"Customize in Settings" : { "Customize in Settings" : {
}, },
"Default" : { "Default" : {
},
"Default: dynamic.m4a" : {
}, },
"Description" : { "Description" : {
@ -370,12 +328,6 @@
}, },
"Disabled" : { "Disabled" : {
},
"Disk Read" : {
},
"Disk Write" : {
}, },
"Download" : { "Download" : {
@ -421,9 +373,6 @@
}, },
"dynamic.island" : { "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!" : { "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 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" : { "Enable sneak peek" : {
@ -477,7 +423,13 @@
"Enable timer feature in Settings to use this tab" : { "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" : { "Enhance your experience with HUDs" : {
@ -539,9 +491,6 @@
}, },
"Got it!" : { "Got it!" : {
},
"GPU Usage" : {
}, },
"Gradient" : { "Gradient" : {
@ -549,7 +498,7 @@
"Graph Visibility" : { "Graph Visibility" : {
}, },
"Hide DynamicIsland Options" : { "Hide BoringNotch Options" : {
}, },
"Hide only when NowPlaying app is in fullscreen" : { "Hide only when NowPlaying app is in fullscreen" : {
@ -561,7 +510,7 @@
"High" : { "High" : {
}, },
"History Size" : { "History size" : {
}, },
"https://github.com/th-ch/youtube-music" : { "https://github.com/th-ch/youtube-music" : {
@ -592,9 +541,6 @@
}, },
"Installed extensions" : { "Installed extensions" : {
},
"Last Updated" : {
}, },
"Later" : { "Later" : {
@ -604,9 +550,6 @@
}, },
"Live" : { "Live" : {
},
"Live Performance Data" : {
}, },
"Lottie JSON URL" : { "Lottie JSON URL" : {
@ -648,6 +591,9 @@
}, },
"Media change with horizontal gestures" : { "Media change with horizontal gestures" : {
},
"Media controls" : {
}, },
"Media inactivity timeout" : { "Media inactivity timeout" : {
"localizations" : { "localizations" : {
@ -674,9 +620,6 @@
}, },
"Medium" : { "Medium" : {
},
"Memory Usage" : {
}, },
"Menubar icon" : { "Menubar icon" : {
"extractionState" : "stale", "extractionState" : "stale",
@ -697,21 +640,12 @@
}, },
"Minimum hover duration" : { "Minimum hover duration" : {
},
"Minutes" : {
}, },
"Mirror" : { "Mirror" : {
}, },
"Mirror shape" : { "Mirror shape" : {
},
"Monitor clipboard changes and keep a history of recent copies. Use Cmd+Shift+V to quickly access clipboard history." : {
},
"Monitoring Status" : {
}, },
"More" : { "More" : {
@ -727,12 +661,6 @@
}, },
"Need Restart" : { "Need Restart" : {
},
"Network Download" : {
},
"Network Upload" : {
}, },
"Never hide" : { "Never hide" : {
@ -884,9 +812,6 @@
}, },
"Remember last tab" : { "Remember last tab" : {
},
"Reset to Default" : {
}, },
"Restart" : { "Restart" : {
@ -910,9 +835,6 @@
} }
} }
} }
},
"Seconds" : {
}, },
"Select Calendars" : { "Select Calendars" : {
@ -925,9 +847,6 @@
}, },
"Selected animation" : { "Selected animation" : {
},
"Set a custom duration for the timer. This will be used when you press the 'Custom' button in the timer interface." : {
}, },
"Settings" : { "Settings" : {
"localizations" : { "localizations" : {
@ -955,9 +874,6 @@
} }
} }
} }
},
"Show calendar" : {
}, },
"Show charging indicator" : { "Show charging indicator" : {
"extractionState" : "stale", "extractionState" : "stale",
@ -997,6 +913,9 @@
}, },
"Show on all displays" : { "Show on all displays" : {
},
"Show shuffle and repeat buttons" : {
}, },
"Slider color" : { "Slider color" : {
@ -1025,15 +944,6 @@
}, },
"Start" : { "Start" : {
},
"Start Demo Timer:" : {
},
"Start Monitoring" : {
},
"Starts a 5-minute demo timer to test the timer live activity feature." : {
}, },
"Stats" : { "Stats" : {
@ -1041,10 +951,10 @@
"Stats Disabled" : { "Stats Disabled" : {
}, },
"Stop" : { "Stats tab will appear in the notch when enabled." : {
}, },
"Stop Monitoring" : { "Stop" : {
}, },
"Stopped" : { "Stopped" : {
@ -1063,14 +973,11 @@
"System features" : { "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" : { "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" : { "Time to Full Charge: %lld min" : {
@ -1081,10 +988,7 @@
"Timer Disabled" : { "Timer Disabled" : {
}, },
"Timer Feature" : { "Timer tab will appear in the notch when enabled." : {
},
"Timer Sound" : {
}, },
"Toggle Notch Open:" : { "Toggle Notch Open:" : {
@ -1107,9 +1011,6 @@
}, },
"unmuted" : { "unmuted" : {
},
"Update" : {
}, },
"Upgrade to Pro" : { "Upgrade to Pro" : {
@ -1145,12 +1046,6 @@
}, },
"What's New" : { "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." : { "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 // AppleMusicController.swift
// DynamicIsland // DynamicIsland
// //
// Created by Alexander Greco on 2025-03-29. // Created by Alexander on 2025-03-29.
// //
import Foundation import Foundation
@ -15,110 +15,125 @@ class AppleMusicController: MediaControllerProtocol {
bundleIdentifier: "com.apple.Music" 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 // MARK: - Initialization
init() { init() {
setupPlaybackStateChangeObserver() setupPlaybackStateChangeObserver()
DispatchQueue.main.async { [weak self] in Task {
if self?.isActive() == true { if isActive() {
self?.updatePlaybackInfo() await updatePlaybackInfo()
} }
} }
} }
private func setupPlaybackStateChangeObserver() { private func setupPlaybackStateChangeObserver() {
DistributedNotificationCenter.default().addObserver( notificationTask = Task { @Sendable [weak self] in
self, let notifications = DistributedNotificationCenter.default().notifications(
selector: #selector(updatePlaybackInfo), named: NSNotification.Name("com.apple.Music.playerInfo")
name: NSNotification.Name("com.apple.Music.playerInfo"), )
object: nil
) for await _ in notifications {
await self?.updatePlaybackInfo()
}
}
} }
deinit { deinit {
DistributedNotificationCenter.default().removeObserver( notificationTask?.cancel()
self,
name: NSNotification.Name("com.apple.Music.playerInfo"),
object: nil
)
} }
// MARK: - Protocol Implementation // MARK: - Protocol Implementation
func play() { func play() async {
executeCommand("play") await executeCommand("play")
} }
func pause() { func pause() async {
executeCommand("pause") await executeCommand("pause")
} }
func togglePlay() { func togglePlay() async {
executeCommand("playpause") await executeCommand("playpause")
} }
func nextTrack() { func nextTrack() async {
executeCommand("next track") await executeCommand("next track")
} }
func previousTrack() { func previousTrack() async {
executeCommand("previous track") await executeCommand("previous track")
} }
func seek(to time: Double) { func seek(to time: Double) async {
executeCommand("set player position to \(time)") await executeCommand("set player position to \(time)")
updatePlaybackInfo() 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 { func isActive() -> Bool {
let runningApps = NSWorkspace.shared.runningApplications 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 // 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)" 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 = """ let script = """
tell application "Music" tell application "Music"
set isRunning to true set isRunning to true
@ -129,26 +144,28 @@ class AppleMusicController: MediaControllerProtocol {
set currentTrackAlbum to album of current track set currentTrackAlbum to album of current track
set trackPosition to player position set trackPosition to player position
set trackDuration to duration of current track set trackDuration to duration of current track
set shuffleState to false set shuffleState to shuffle enabled
set repeatState to false 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 try
set artData to data of artwork 1 of current track set artData to data of artwork 1 of current track
on error on error
set artData to "" set artData to ""
end try end try
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artData} return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatValue, artData}
on error 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 try
end tell end tell
""" """
var descriptor: NSAppleEventDescriptor? = nil
let semaphore = DispatchSemaphore(value: 0) return try await AppleScriptHelper.execute(script)
Task {
descriptor = try? await AppleScriptHelper.execute(script)
semaphore.signal()
}
semaphore.wait()
return descriptor
} }
} }

View file

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

View file

@ -5,193 +5,222 @@
// Created by Alexander on 2025-03-29. // Created by Alexander on 2025-03-29.
// //
import Combine
import AppKit import AppKit
import Combine
import Foundation import Foundation
class NowPlayingController: ObservableObject, MediaControllerProtocol { final class NowPlayingController: ObservableObject, MediaControllerProtocol {
// Stub for now to conform with ControllerProtocol
func updatePlaybackInfo() {}
// MARK: - Properties // MARK: - Properties
@Published var playbackState: PlaybackState = .init( @Published private(set) var playbackState: PlaybackState = .init(
bundleIdentifier: "com.apple.Music" bundleIdentifier: "com.apple.Music"
) )
private var isResourcesAvailable = false
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
$playbackState.eraseToAnyPublisher()
}
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState } var isWorking: Bool {
private var cancellables = Set<AnyCancellable>() return isResourcesAvailable
private var lastMusicItem: (title: String, artist: String, album: String, duration: TimeInterval, artworkData: Data?)? }
private var lastMusicItem:
(title: String, artist: String, album: String, duration: TimeInterval, artworkData: Data?)?
// MARK: - Media Remote Functions // MARK: - Media Remote Functions
private let mediaRemoteBundle: CFBundle private let mediaRemoteBundle: CFBundle
private let MRMediaRemoteGetNowPlayingInfo: @convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void private let MRMediaRemoteSendCommandFunction: @convention(c) (Int, AnyObject?) -> 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 MRMediaRemoteSetElapsedTimeFunction: @convention(c) (Double) -> 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 // MARK: - Initialization
init?() { init?() {
guard let bundle = CFBundleCreate(kCFAllocatorDefault, NSURL(fileURLWithPath: "/System/Library/PrivateFrameworks/MediaRemote.framework")), guard
let MRMediaRemoteGetNowPlayingInfoPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingInfo" as CFString), let bundle = CFBundleCreate(
let MRMediaRemoteRegisterForNowPlayingNotificationsPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteRegisterForNowPlayingNotifications" as CFString), kCFAllocatorDefault,
let MRMediaRemoteGetNowPlayingApplicationIsPlayingPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingApplicationIsPlaying" as CFString), NSURL(fileURLWithPath: "/System/Library/PrivateFrameworks/MediaRemote.framework")),
let MRMediaRemoteGetNowPlayingClientPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingClient" as CFString), let MRMediaRemoteSendCommandPointer = CFBundleGetFunctionPointerForName(
let MRNowPlayingClientGetBundleIdentifierPointer = CFBundleGetFunctionPointerForName(bundle, "MRNowPlayingClientGetBundleIdentifier" as CFString), bundle, "MRMediaRemoteSendCommand" as CFString),
let MRNowPlayingClientGetParentAppBundleIdentifierPointer = CFBundleGetFunctionPointerForName(bundle, "MRNowPlayingClientGetParentAppBundleIdentifier" as CFString), let MRMediaRemoteSetElapsedTimePointer = CFBundleGetFunctionPointerForName(
let MRMediaRemoteSendCommandPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteSendCommand" as CFString), bundle, "MRMediaRemoteSetElapsedTime" 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 } else { return nil }
mediaRemoteBundle = bundle mediaRemoteBundle = bundle
MRMediaRemoteGetNowPlayingInfo = unsafeBitCast(MRMediaRemoteGetNowPlayingInfoPointer, to: (@convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void).self) MRMediaRemoteSendCommandFunction = unsafeBitCast(
MRMediaRemoteRegisterForNowPlayingNotifications = unsafeBitCast(MRMediaRemoteRegisterForNowPlayingNotificationsPointer, to: (@convention(c) (DispatchQueue) -> Void).self) MRMediaRemoteSendCommandPointer, to: (@convention(c) (Int, AnyObject?) -> Void).self)
MRMediaRemoteGetNowPlayingApplicationIsPlaying = unsafeBitCast(MRMediaRemoteGetNowPlayingApplicationIsPlayingPointer, to: (@convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void).self) MRMediaRemoteSetElapsedTimeFunction = unsafeBitCast(
MRMediaRemoteGetNowPlayingClient = unsafeBitCast(MRMediaRemoteGetNowPlayingClientPointer, to: (@convention(c) (DispatchQueue, @escaping (AnyObject?) -> Void) -> Void).self) MRMediaRemoteSetElapsedTimePointer, to: (@convention(c) (Double) -> Void).self)
MRNowPlayingClientGetBundleIdentifier = unsafeBitCast(MRNowPlayingClientGetBundleIdentifierPointer, to: (@convention(c) (AnyObject?) -> String?).self) MRMediaRemoteSetShuffleModeFunction = unsafeBitCast(
MRNowPlayingClientGetParentAppBundleIdentifier = unsafeBitCast(MRNowPlayingClientGetParentAppBundleIdentifierPointer, to: (@convention(c) (AnyObject?) -> String?).self) MRMediaRemoteSetShuffleModePointer, to: (@convention(c) (Int) -> Void).self)
MRMediaRemoteSendCommandFunction = unsafeBitCast(MRMediaRemoteSendCommandPointer, to: (@convention(c) (Int, AnyObject?) -> Void).self) MRMediaRemoteSetRepeatModeFunction = unsafeBitCast(
MRMediaRemoteSetElapsedTimeFunction = unsafeBitCast(MRMediaRemoteSetElapsedTimePointer, to: (@convention(c) (Double) -> Void).self) MRMediaRemoteSetRepeatModePointer, to: (@convention(c) (Int) -> Void).self)
setupNowPlayingObserver() setupNowPlayingObserver()
updatePlaybackInfo() updatePlaybackInfo()
} }
deinit { deinit {
// Clean up all Combine subscribers if let process = self.process {
cancellables.removeAll() if process.isRunning {
process.terminate()
// Remove distributed notification observers process.waitUntilExit()
DistributedNotificationCenter.default().removeObserver( }
self, }
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
object: nil if let pipe = self.pipe {
) pipe.fileHandleForReading.closeFile()
pipe.fileHandleForWriting.closeFile()
DistributedNotificationCenter.default().removeObserver( }
self,
name: NSNotification.Name("com.apple.Music.playerInfo"), self.process = nil
object: nil self.pipe = nil
)
} }
// MARK: - Protocol Implementation // MARK: - Protocol Implementation
func play() { func play() async {
MRMediaRemoteSendCommandFunction(0, nil) MRMediaRemoteSendCommandFunction(0, nil)
} }
func pause() { func pause() async {
MRMediaRemoteSendCommandFunction(1, nil) MRMediaRemoteSendCommandFunction(1, nil)
} }
func togglePlay() { func togglePlay() async {
MRMediaRemoteSendCommandFunction(2, nil) MRMediaRemoteSendCommandFunction(2, nil)
} }
func nextTrack() { func nextTrack() async {
MRMediaRemoteSendCommandFunction(4, nil) MRMediaRemoteSendCommandFunction(4, nil)
} }
func previousTrack() { func previousTrack() async {
MRMediaRemoteSendCommandFunction(5, nil) MRMediaRemoteSendCommandFunction(5, nil)
} }
func seek(to time: Double) { func seek(to time: Double) async {
MRMediaRemoteSetElapsedTimeFunction(time) MRMediaRemoteSetElapsedTimeFunction(time)
} }
func isActive() -> Bool { func isActive() -> Bool {
return true return true
} }
// MARK: - Setup Methods func toggleShuffle() async {
private func setupNowPlayingObserver() { // MRMediaRemoteSendCommandFunction(6, nil)
MRMediaRemoteRegisterForNowPlayingNotifications(DispatchQueue.main) MRMediaRemoteSetShuffleModeFunction(playbackState.isShuffled ? 3 : 1)
playbackState.isShuffled.toggle()
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
)
} }
// MARK: - Update Methods func toggleRepeat() async {
@objc func updateApp() { // MRMediaRemoteSendCommandFunction(7, nil)
// Get the now playing client let newRepeatMode = (playbackState.repeatMode == .off) ? 3 : (playbackState.repeatMode.rawValue - 1)
MRMediaRemoteGetNowPlayingClient(DispatchQueue.main) { [weak self] clientObj in playbackState.repeatMode = RepeatMode(rawValue: newRepeatMode) ?? .off
guard let clientObj = clientObj else { MRMediaRemoteSetRepeatModeFunction(newRepeatMode)
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"
}
}
} }
// 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() { pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
updateApp() let data = handle.availableData
guard !data.isEmpty else { return }
MRMediaRemoteGetNowPlayingInfo(DispatchQueue.main) { [weak self] information in if let chunk = String(data: data, encoding: .utf8) {
guard let self = self else { return } self?.buffer.append(chunk)
while let range = self?.buffer.range(of: "\n") {
let title = information["kMRMediaRemoteNowPlayingInfoTitle"] as? String ?? "" let line = String(self?.buffer[..<range.lowerBound] ?? "")
let artist = information["kMRMediaRemoteNowPlayingInfoArtist"] as? String ?? "" self?.buffer = String(self?.buffer[range.upperBound...] ?? "")
let album = information["kMRMediaRemoteNowPlayingInfoAlbum"] as? String ?? "" if !line.isEmpty {
let duration = information["kMRMediaRemoteNowPlayingInfoDuration"] as? TimeInterval ?? 0 self?.handleAdapterUpdate(line)
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
} }
} }
} }
} }
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 // SpotifyController.swift
// DynamicIsland // DynamicIsland
// //
// Created by Alexander Greco on 2025-03-29. // Created by Alexander on 2025-03-29.
// //
import Foundation import Foundation
@ -15,59 +15,81 @@ class SpotifyController: MediaControllerProtocol {
bundleIdentifier: "com.spotify.client" 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() { init() {
setupPlaybackStateChangeObserver() setupPlaybackStateChangeObserver()
DispatchQueue.main.async { [weak self] in Task {
if self?.isActive() == true { if isActive() {
self?.updatePlaybackInfo() await updatePlaybackInfo()
} }
} }
} }
private func setupPlaybackStateChangeObserver() { private func setupPlaybackStateChangeObserver() {
DistributedNotificationCenter.default().addObserver( notificationTask = Task { @Sendable [weak self] in
self, let notifications = DistributedNotificationCenter.default().notifications(
selector: #selector(updatePlaybackInfo), named: NSNotification.Name("com.spotify.client.PlaybackStateChanged")
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"), )
object: nil
) for await _ in notifications {
await self?.updatePlaybackInfo()
}
}
} }
deinit { deinit {
// Remove notification observer when controller is deallocated notificationTask?.cancel()
DistributedNotificationCenter.default().removeObserver(
self,
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
object: nil
)
} }
// MARK: - Protocol Implementation // MARK: - Protocol Implementation
func play() { func play() async {
executeCommand("play") await executeCommand("play")
} }
func pause() { func pause() async {
executeCommand("pause") await executeCommand("pause")
} }
func togglePlay() { func togglePlay() async {
executeCommand("playpause") await executeCommand("playpause")
} }
func nextTrack() { func nextTrack() async {
executeCommand("next track") await executeCommand("next track")
} }
func previousTrack() { func previousTrack() async {
executeCommand("previous track") await executeCommand("previous track")
} }
func seek(to time: Double) { func seek(to time: Double) async {
executeCommand("set player position to \(time)") await executeCommand("set player position to \(time)")
updatePlaybackInfo() 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 { func isActive() -> Bool {
@ -75,9 +97,8 @@ class SpotifyController: MediaControllerProtocol {
return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier } return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier }
} }
// MARK: - Private Methods func updatePlaybackInfo() async {
@objc func updatePlaybackInfo() { guard let descriptor = try? await fetchPlaybackInfoAsync() else { return }
guard let descriptor = fetchPlaybackInfo() else { return }
guard descriptor.numberOfItems >= 9 else { return } guard descriptor.numberOfItems >= 9 else { return }
let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
@ -100,42 +121,35 @@ class SpotifyController: MediaControllerProtocol {
duration: duration, duration: duration,
playbackRate: 1, playbackRate: 1,
isShuffled: isShuffled, isShuffled: isShuffled,
isRepeating: isRepeating, repeatMode: isRepeating ? .all : .off,
lastUpdated: Date() lastUpdated: Date()
) )
DispatchQueue.main.async { [weak self] in self.playbackState = state
self?.playbackState = state
}
// Load artwork asynchronously and update the state when complete // Load artwork asynchronously and update the state when complete
if !artworkURL.isEmpty, let url = URL(string: artworkURL) { if !artworkURL.isEmpty, let url = URL(string: artworkURL) {
DispatchQueue.global(qos: .background).async { [weak self] in let currentState = state
do { do {
let artworkData = try Data(contentsOf: url) let (data, _) = try await URLSession.shared.data(from: url)
DispatchQueue.main.async { // Create a new state with the artwork data and update
// Create a new state with the artwork data and update var updatedState = currentState
var updatedState = state updatedState.artwork = data
updatedState.artwork = artworkData playbackState = updatedState
self?.playbackState = updatedState } catch {
} print("Failed to load artwork: \(error)")
} 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)" 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 = """ let script = """
tell application "Spotify" tell application "Spotify"
set isRunning to true set isRunning to true
@ -156,13 +170,6 @@ class SpotifyController: MediaControllerProtocol {
end tell end tell
""" """
var descriptor: NSAppleEventDescriptor? = nil return try await AppleScriptHelper.execute(script)
let semaphore = DispatchSemaphore(value: 0)
Task {
descriptor = try? await AppleScriptHelper.execute(script)
semaphore.signal()
}
semaphore.wait()
return descriptor
} }
} }

View file

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

View file

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

View file

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

View file

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

View file

@ -161,18 +161,58 @@ struct MusicControlsView: View {
private var playbackControls: some View { private var playbackControls: some View {
HStack(spacing: 8) { 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) { HoverButton(icon: "backward.fill", scale: .medium) {
MusicManager.shared.previousTrack() Task {
await MusicManager.shared.previousTrack()
}
} }
HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) { HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) {
MusicManager.shared.togglePlay() Task {
await MusicManager.shared.togglePlay()
}
} }
HoverButton(icon: "forward.fill", scale: .medium) { 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) .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 // MARK: - Main View

View file

@ -3,7 +3,7 @@
// DynamicIsland // DynamicIsland
// //
// Created by Richard Kunkli on 07/08/2024. // Created by Richard Kunkli on 07/08/2024.
// Modified by Hariharan Mudaliar //
import AVFoundation import AVFoundation
import Defaults import Defaults
@ -14,20 +14,17 @@ import LottieUI
import Sparkle import Sparkle
import SwiftUI import SwiftUI
import SwiftUIIntrospect import SwiftUIIntrospect
import UniformTypeIdentifiers
struct SettingsView: View { struct SettingsView: View {
@StateObject var extensionManager = DynamicIslandExtensionManager() @StateObject var extensionManager = DynamicIslandExtensionManager()
@StateObject private var calendarManager = CalendarManager()
@State private var selectedTab = "General" @State private var selectedTab = "General"
let updaterController: SPUStandardUpdaterController? let updaterController: SPUStandardUpdaterController?
init(updaterController: SPUStandardUpdaterController? = nil) { init(updaterController: SPUStandardUpdaterController? = nil) {
self.updaterController = updaterController self.updaterController = updaterController
} }
var body: some View { var body: some View {
NavigationSplitView { NavigationSplitView {
List(selection: $selectedTab) { List(selection: $selectedTab) {
@ -40,9 +37,6 @@ struct SettingsView: View {
NavigationLink(value: "Media") { NavigationLink(value: "Media") {
Label("Media", systemImage: "play.laptopcomputer") Label("Media", systemImage: "play.laptopcomputer")
} }
NavigationLink(value: "Timer") {
Label("Timer", systemImage: "timer")
}
NavigationLink(value: "Calendar") { NavigationLink(value: "Calendar") {
Label("Calendar", systemImage: "calendar") Label("Calendar", systemImage: "calendar")
} }
@ -55,6 +49,15 @@ struct SettingsView: View {
NavigationLink(value: "Battery") { NavigationLink(value: "Battery") {
Label("Battery", systemImage: "battery.100.bolt") 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 if extensionManager.installedExtensions
.contains(where: { $0.bundleIdentifier == downloadManagerExtension }) { .contains(where: { $0.bundleIdentifier == downloadManagerExtension }) {
NavigationLink(value: "Downloads") { NavigationLink(value: "Downloads") {
@ -64,12 +67,6 @@ struct SettingsView: View {
NavigationLink(value: "Shelf") { NavigationLink(value: "Shelf") {
Label("Shelf", systemImage: "books.vertical") 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") { NavigationLink(value: "Shortcuts") {
Label("Shortcuts", systemImage: "keyboard") Label("Shortcuts", systemImage: "keyboard")
} }
@ -92,22 +89,22 @@ struct SettingsView: View {
Appearance() Appearance()
case "Media": case "Media":
Media() Media()
case "Timer":
TimerSettings()
case "Calendar": case "Calendar":
CalendarSettings() CalendarSettings()
case "HUD": case "HUD":
HUD() HUD()
case "Battery": case "Battery":
Charge() Charge()
case "Timer":
TimerSettings()
case "Stats":
StatsSettings()
case "Clipboard":
ClipboardSettings()
case "Downloads": case "Downloads":
Downloads() Downloads()
case "Shelf": case "Shelf":
Shelf() Shelf()
case "Stats":
Stats()
case "Clipboard":
ClipboardSettings()
case "Shortcuts": case "Shortcuts":
Shortcuts() Shortcuts()
case "Extensions": case "Extensions":
@ -134,7 +131,6 @@ struct SettingsView: View {
.disabled(true) .disabled(true)
} }
.environmentObject(extensionManager) .environmentObject(extensionManager)
.environmentObject(calendarManager)
.formStyle(.grouped) .formStyle(.grouped)
.frame(width: 700) .frame(width: 700)
.background(Color(NSColor.windowBackgroundColor)) .background(Color(NSColor.windowBackgroundColor))
@ -158,8 +154,7 @@ struct GeneralSettings: View {
@Default(.automaticallySwitchDisplay) var automaticallySwitchDisplay @Default(.automaticallySwitchDisplay) var automaticallySwitchDisplay
@Default(.enableGestures) var enableGestures @Default(.enableGestures) var enableGestures
@Default(.openNotchOnHover) var openNotchOnHover @Default(.openNotchOnHover) var openNotchOnHover
@Default(.alwaysHideInFullscreen) var alwaysHideInFullscreen
var body: some View { var body: some View {
Form { Form {
Section { Section {
@ -188,7 +183,7 @@ struct GeneralSettings: View {
} header: { } header: {
Text("System features") Text("System features")
} }
Section { Section {
Picker(selection: $notchHeightMode, label: Picker(selection: $notchHeightMode, label:
Text("Notch display height")) { Text("Notch display height")) {
@ -248,9 +243,9 @@ struct GeneralSettings: View {
} header: { } header: {
Text("Notch Height") Text("Notch Height")
} }
NotchBehaviour() NotchBehaviour()
gestureControls() gestureControls()
} }
.toolbar { .toolbar {
@ -266,7 +261,7 @@ struct GeneralSettings: View {
} }
} }
} }
@ViewBuilder @ViewBuilder
func gestureControls() -> some View { func gestureControls() -> some View {
Section { Section {
@ -297,7 +292,7 @@ struct GeneralSettings: View {
.font(.caption) .font(.caption)
} }
} }
@ViewBuilder @ViewBuilder
func NotchBehaviour() -> some View { func NotchBehaviour() -> some View {
Section { Section {
@ -369,7 +364,7 @@ struct Downloads: View {
Text("Both") Text("Both")
.tag(DownloadIconStyle.iconAndAppIcon) .tag(DownloadIconStyle.iconAndAppIcon)
} }
} header: { } header: {
HStack { HStack {
Text("Download indicators") Text("Download indicators")
@ -397,7 +392,7 @@ struct Downloads: View {
.contentShape(Rectangle()) .contentShape(Rectangle())
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Divider() Divider()
Button {} label: { Button {} label: {
Image(systemName: "minus") Image(systemName: "minus")
@ -470,7 +465,7 @@ struct Media: View {
@Default(.hideNotchOption) var hideNotchOption @Default(.hideNotchOption) var hideNotchOption
@Default(.enableSneakPeek) private var enableSneakPeek @Default(.enableSneakPeek) private var enableSneakPeek
@Default(.sneakPeekStyles) var sneakPeekStyles @Default(.sneakPeekStyles) var sneakPeekStyles
var body: some View { var body: some View {
Form { Form {
Section { Section {
@ -503,13 +498,21 @@ struct Media: View {
.font(.caption) .font(.caption)
} }
} }
Section {
Defaults.Toggle(key: .showShuffleAndRepeat) {
HStack {
Text("Show shuffle and repeat buttons")
customBadge(text: "Beta")
}
}
} header: {
Text("Media controls")
}
Section { Section {
Toggle( Toggle(
"Enable music live activity", "Enable music live activity",
isOn: $coordinator.musicLiveActivityEnabled isOn: $coordinator.musicLiveActivityEnabled.animation()
) )
.animation(.easeInOut, value: coordinator.musicLiveActivityEnabled)
Toggle("Enable sneak peek", isOn: $enableSneakPeek) Toggle("Enable sneak peek", isOn: $enableSneakPeek)
Picker("Sneak Peek Style", selection: $sneakPeekStyles){ Picker("Sneak Peek Style", selection: $sneakPeekStyles){
ForEach(SneakPeekStyle.allCases) { style in ForEach(SneakPeekStyle.allCases) { style in
@ -532,21 +535,20 @@ struct Media: View {
Picker(selection: $hideNotchOption, label: Picker(selection: $hideNotchOption, label:
HStack { HStack {
Text("Hide DynamicIsland Options") Text("Hide BoringNotch Options")
customBadge(text: "Beta") customBadge(text: "Beta")
}) { }) {
Text("Always hide in fullscreen").tag(HideNotchOption.always) Text("Always hide in fullscreen").tag(HideNotchOption.always)
Text("Hide only when NowPlaying app is in fullscreen").tag(HideNotchOption.nowPlayingOnly) Text("Hide only when NowPlaying app is in fullscreen").tag(HideNotchOption.nowPlayingOnly)
Text("Never hide").tag(HideNotchOption.never) Text("Never hide").tag(HideNotchOption.never)
} }
.onChange(of: hideNotchOption) { _, newValue in .onChange(of: hideNotchOption) {
Defaults[.alwaysHideInFullscreen] = newValue == .always Defaults[.enableFullscreenMediaDetection] = hideNotchOption != .never
Defaults[.enableFullscreenMediaDetection] = newValue != .never
} }
} }
.navigationTitle("Media") .navigationTitle("Media")
} }
// Only show controller options that are available on this macOS version // Only show controller options that are available on this macOS version
private var availableMediaControllers: [MediaControllerType] { private var availableMediaControllers: [MediaControllerType] {
if MusicManager.shared.isNowPlayingDeprecated { if MusicManager.shared.isNowPlayingDeprecated {
@ -558,7 +560,7 @@ struct Media: View {
} }
struct CalendarSettings: View { struct CalendarSettings: View {
@ObservedObject private var calendarManager = CalendarManager() @ObservedObject private var calendarManager = CalendarManager.shared
@Default(.showCalendar) var showCalendar: Bool @Default(.showCalendar) var showCalendar: Bool
var body: some View { var body: some View {
@ -574,7 +576,7 @@ struct CalendarSettings: View {
} }
} }
} else { } else {
Toggle("Show calendar", isOn: $showCalendar) Defaults.Toggle("Show calendar", key: .showCalendar)
Section(header: Text("Select Calendars")) { Section(header: Text("Select Calendars")) {
List { List {
ForEach(calendarManager.allCalendars, id: \.id) { calendar in ForEach(calendarManager.allCalendars, id: \.id) { calendar in
@ -588,6 +590,7 @@ struct CalendarSettings: View {
)) { )) {
Text(calendar.title) 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 { struct About: View {
@State private var showBuildNumber: Bool = false @State private var showBuildNumber: Bool = false
let updaterController: SPUStandardUpdaterController let updaterController: SPUStandardUpdaterController
@ -786,9 +638,9 @@ struct About: View {
} header: { } header: {
Text("Version info") Text("Version info")
} }
UpdaterSettingsView(updater: updaterController.updater) UpdaterSettingsView(updater: updaterController.updater)
HStack(spacing: 30) { HStack(spacing: 30) {
Spacer(minLength: 0) Spacer(minLength: 0)
Button { Button {
@ -891,15 +743,15 @@ struct Extensions: View {
.font(.footnote) .font(.footnote)
} }
.frame(width: 60, alignment: .leading) .frame(width: 60, alignment: .leading)
Menu(content: { Menu(content: {
Button("Restart") { Button("Restart") {
let ws = NSWorkspace.shared let ws = NSWorkspace.shared
if let ext = ws.runningApplications.first(where: { $0.bundleIdentifier == item.bundleIdentifier }) { if let ext = ws.runningApplications.first(where: { $0.bundleIdentifier == item.bundleIdentifier }) {
ext.terminate() ext.terminate()
} }
if let appURL = ws.urlForApplication(withBundleIdentifier: item.bundleIdentifier) { if let appURL = ws.urlForApplication(withBundleIdentifier: item.bundleIdentifier) {
ws.openApplication(at: appURL, configuration: .init(), completionHandler: nil) ws.openApplication(at: appURL, configuration: .init(), completionHandler: nil)
} }
@ -987,7 +839,7 @@ struct Appearance: View {
let icons: [String] = ["logo2"] let icons: [String] = ["logo2"]
@State private var selectedIcon: String = "logo2" @State private var selectedIcon: String = "logo2"
@State private var selectedListVisualizer: CustomVisualizer? = nil @State private var selectedListVisualizer: CustomVisualizer? = nil
@State private var isPresented: Bool = false @State private var isPresented: Bool = false
@State private var name: String = "" @State private var name: String = ""
@State private var url: String = "" @State private var url: String = ""
@ -1003,7 +855,7 @@ struct Appearance: View {
} header: { } header: {
Text("General") Text("General")
} }
Section { Section {
Defaults.Toggle("Enable colored spectrograms", key: .coloredSpectrogram) Defaults.Toggle("Enable colored spectrograms", key: .coloredSpectrogram)
Defaults Defaults
@ -1017,7 +869,7 @@ struct Appearance: View {
} header: { } header: {
Text("Media") Text("Media")
} }
Section { Section {
Toggle( Toggle(
"Use music visualizer spectrogram", "Use music visualizer spectrogram",
@ -1053,7 +905,7 @@ struct Appearance: View {
customBadge(text: "Coming soon") customBadge(text: "Coming soon")
} }
} }
Section { Section {
List { List {
ForEach(customVisualizers, id: \.self) { visualizer in ForEach(customVisualizers, id: \.self) { visualizer in
@ -1151,7 +1003,7 @@ struct Appearance: View {
Text("Cancel") Text("Cancel")
.frame(maxWidth: .infinity, alignment: .center) .frame(maxWidth: .infinity, alignment: .center)
} }
Button { Button {
let visualizer: CustomVisualizer = .init( let visualizer: CustomVisualizer = .init(
UUID: UUID(), UUID: UUID(),
@ -1159,11 +1011,11 @@ struct Appearance: View {
url: URL(string: url)!, url: URL(string: url)!,
speed: speed speed: speed
) )
if !customVisualizers.contains(visualizer) { if !customVisualizers.contains(visualizer) {
customVisualizers.append(visualizer) customVisualizers.append(visualizer)
} }
isPresented.toggle() isPresented.toggle()
} label: { } label: {
Text("Add") Text("Add")
@ -1185,7 +1037,7 @@ struct Appearance: View {
} }
} }
} }
Section { Section {
Defaults.Toggle("Enable boring mirror", key: .showMirror) Defaults.Toggle("Enable boring mirror", key: .showMirror)
.disabled(!checkVideoInput()) .disabled(!checkVideoInput())
@ -1201,7 +1053,7 @@ struct Appearance: View {
Text("Additional features") Text("Additional features")
} }
} }
Section { Section {
HStack { HStack {
ForEach(icons, id: \.self) { icon in ForEach(icons, id: \.self) { icon in
@ -1217,7 +1069,7 @@ struct Appearance: View {
lineWidth: 2.5 lineWidth: 2.5
) )
) )
Text("Default") Text("Default")
.fontWeight(.medium) .fontWeight(.medium)
.font(.caption) .font(.caption)
@ -1248,12 +1100,12 @@ struct Appearance: View {
} }
.navigationTitle("Appearance") .navigationTitle("Appearance")
} }
func checkVideoInput() -> Bool { func checkVideoInput() -> Bool {
if let _ = AVCaptureDevice.default(for: .video) { if let _ = AVCaptureDevice.default(for: .video) {
return true return true
} }
return false return false
} }
} }
@ -1274,190 +1126,11 @@ struct Shortcuts: View {
Section { Section {
KeyboardShortcuts.Recorder("Toggle Notch Open:", name: .toggleNotchOpen) 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") .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 { func proFeatureBadge() -> some View {
Text("Upgrade to Pro") Text("Upgrade to Pro")
.foregroundStyle(Color(red: 0.545, green: 0.196, blue: 0.98)) .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 { struct TimerSettings: View {
@ObservedObject var clipboardManager = ClipboardManager.shared
@Default(.enableClipboardManager) var enableClipboardManager
@Default(.clipboardHistorySize) var clipboardHistorySize
@Default(.showClipboardIcon) var showClipboardIcon
var body: some View { var body: some View {
Form { Form {
Section { Section {
Defaults.Toggle("Enable Clipboard Manager", key: .enableClipboardManager) Defaults.Toggle("Enable timer feature", key: .enableTimerFeature)
.onChange(of: enableClipboardManager) { enabled in .onChange(of: Defaults[.enableTimerFeature]) { _, newValue in
if enabled { if !newValue {
clipboardManager.startMonitoring() // 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 { } else {
clipboardManager.stopMonitoring() ClipboardManager.shared.stopMonitoring()
} }
} }
} header: { } header: {
Text("Clipboard Manager") Text("Clipboard Manager")
} footer: { } 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 { Section {
Defaults.Toggle("Show Clipboard Icon", key: .showClipboardIcon) Defaults.Toggle("Show clipboard icon", key: .showClipboardIcon)
HStack { VStack(alignment: .leading, spacing: 8) {
Text("History Size") HStack {
Spacer() Text("History size")
Picker("History Size", selection: $clipboardHistorySize) { Spacer()
Text("3 items").tag(3) Text("\(Defaults[.clipboardHistorySize]) items")
Text("5 items").tag(5) .foregroundStyle(.secondary)
Text("7 items").tag(7)
Text("10 items").tag(10)
} }
.pickerStyle(.menu) Slider(
.frame(width: 100) value: Binding(
} get: { Double(Defaults[.clipboardHistorySize]) },
set: { Defaults[.clipboardHistorySize] = Int($0) }
HStack { ),
Text("Current Items") in: 3...10,
Spacer() step: 1
Text("\(clipboardManager.clipboardHistory.count)") )
.foregroundColor(.secondary)
}
HStack {
Text("Monitoring Status")
Spacer()
Text(clipboardManager.isMonitoring ? "Active" : "Stopped")
.foregroundColor(clipboardManager.isMonitoring ? .green : .secondary)
} }
} header: { } header: {
Text("Settings") Text("Settings")
} footer: { } 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.") Text("Configure how many recent clipboard items to remember (minimum 3).")
}
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")
}
} }
} }
} }
.formStyle(.grouped)
.navigationTitle("Clipboard") .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) 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 /// Easily read Info.plist as a Dictionary from any bundle by accessing .infoDictionary on Bundle
func bundle(forBundleID: String) -> Bundle? { func bundle(forBundleID: String) -> Bundle? {
guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: forBundleID) 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 @MainActor
class CalendarManager: ObservableObject { class CalendarManager: ObservableObject {
static let shared = CalendarManager()
@Published var currentWeekStartDate: Date @Published var currentWeekStartDate: Date
@Published var events: [EventModel] = [] @Published var events: [EventModel] = []
@Published var allCalendars: [CalendarModel] = [] @Published var allCalendars: [CalendarModel] = []
@ -20,7 +22,7 @@ class CalendarManager: ObservableObject {
private var selectedCalendars: [CalendarModel] = [] private var selectedCalendars: [CalendarModel] = []
private let calendarService = CalendarService() private let calendarService = CalendarService()
init() { private init() {
self.currentWeekStartDate = CalendarManager.startOfDay(Date()) self.currentWeekStartDate = CalendarManager.startOfDay(Date())
} }
@ -33,7 +35,13 @@ class CalendarManager: ObservableObject {
switch status { switch status {
case .notDetermined: 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: case .restricted, .denied:
// Handle the case where the user has denied or restricted access // Handle the case where the user has denied or restricted access
NSLog("Calendar access denied or restricted") NSLog("Calendar access denied or restricted")
@ -48,11 +56,11 @@ class CalendarManager: ObservableObject {
print("Unknown authorization status") print("Unknown authorization status")
} }
} }
func updateSelectedCalendars() { func updateSelectedCalendars() {
selectedCalendars = allCalendars.filter { getCalendarSelected($0) } selectedCalendars = allCalendars.filter { getCalendarSelected($0) }
} }
func getCalendarSelected(_ calendar: CalendarModel) -> Bool { func getCalendarSelected(_ calendar: CalendarModel) -> Bool {
switch Defaults[.calendarSelectionState] { switch Defaults[.calendarSelectionState] {
case .all: case .all:
@ -78,7 +86,7 @@ class CalendarManager: ObservableObject {
} else { } else {
identifiers.remove(calendar.id) identifiers.remove(calendar.id)
} }
selectionState = identifiers.isEmpty ? .all : identifiers.count == allCalendars.count ? .all : .selected(identifiers) // if empty, select all selectionState = identifiers.isEmpty ? .all : identifiers.count == allCalendars.count ? .all : .selected(identifiers) // if empty, select all
} }
@ -86,11 +94,11 @@ class CalendarManager: ObservableObject {
updateSelectedCalendars() updateSelectedCalendars()
events = await calendarService.events(from: currentWeekStartDate, to: Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate)!, calendars: selectedCalendars.map{$0.id}) 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 { static func startOfDay(_ date: Date) -> Date {
return Calendar.current.startOfDay(for: date) return Calendar.current.startOfDay(for: date)
} }
func updateCurrentDate(_ date: Date) async { func updateCurrentDate(_ date: Date) async {
currentWeekStartDate = Calendar.current.startOfDay(for: date) 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}) 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() static let shared = MusicManager()
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var controllerCancellables = 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 // Helper to check if macOS has removed support for NowPlayingController
public var isNowPlayingDeprecated: Bool { public private(set) var isNowPlayingDeprecated: Bool = false
if #available(macOS 15.4, *) { private let mediaChecker = MediaChecker()
return true
}
return false
}
// Active controller // Active controller
private var activeController: (any MediaControllerProtocol)? private var activeController: (any MediaControllerProtocol)?
@ -48,6 +44,8 @@ class MusicManager: ObservableObject {
@Published var elapsedTime: TimeInterval = 0 @Published var elapsedTime: TimeInterval = 0
@Published var timestampDate: Date = .init() @Published var timestampDate: Date = .init()
@Published var playbackRate: Double = 1 @Published var playbackRate: Double = 1
@Published var isShuffled: Bool = false
@Published var repeatMode: RepeatMode = .off
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@Published var usingAppIconForArtwork: Bool = false @Published var usingAppIconForArtwork: Bool = false
@ -67,13 +65,24 @@ class MusicManager: ObservableObject {
self?.setActiveControllerBasedOnPreference() self?.setActiveControllerBasedOnPreference()
} }
.store(in: &cancellables) .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 // Initialize the active controller after deprecation check
setActiveControllerBasedOnPreference() self.setActiveControllerBasedOnPreference()
}
} }
deinit { deinit {
debounceToggle?.cancel() debounceIdleTask?.cancel()
cancellables.removeAll() cancellables.removeAll()
controllerCancellables.removeAll() controllerCancellables.removeAll()
flipWorkItem?.cancel() flipWorkItem?.cancel()
@ -98,7 +107,17 @@ class MusicManager: ObservableObject {
// Only create NowPlayingController if not deprecated on this macOS version // Only create NowPlayingController if not deprecated on this macOS version
if !self.isNowPlayingDeprecated { if !self.isNowPlayingDeprecated {
ignoreLastUpdated = false 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 { } else {
return nil return nil
} }
@ -285,19 +304,20 @@ class MusicManager: ObservableObject {
private func updateIdleState(state: Bool) { private func updateIdleState(state: Bool) {
if state { if state {
isPlayerIdle = false isPlayerIdle = false
debounceToggle?.cancel() debounceIdleTask?.cancel()
} else { } else {
debounceToggle = DispatchWorkItem { [weak self] in debounceIdleTask?.cancel()
guard let self = self else { return } debounceIdleTask = Task { [weak self] in
if self.lastUpdated.timeIntervalSinceNow < -Defaults[.waitInterval] { try? await Task.sleep(for: .seconds(Defaults[.waitInterval]))
withAnimation { guard let self = self, !Task.isCancelled else { return }
self.isPlayerIdle = !self.isPlaying 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 // MARK: - Public Methods for controlling playback
func playPause() { func playPause() {
activeController?.togglePlay() Task {
await activeController?.togglePlay()
}
} }
func play() { func play() {
activeController?.play() Task {
await activeController?.play()
}
} }
func pause() { func pause() {
activeController?.pause() Task {
await activeController?.pause()
}
}
func toggleShuffle() {
Task {
await activeController?.toggleShuffle()
}
}
func toggleRepeat() {
Task {
await activeController?.toggleRepeat()
}
} }
func togglePlay() { func togglePlay() {
activeController?.togglePlay() Task {
await activeController?.togglePlay()
}
} }
func nextTrack() { func nextTrack() {
activeController?.nextTrack() Task {
await activeController?.nextTrack()
}
} }
func previousTrack() { func previousTrack() {
activeController?.previousTrack() Task {
await activeController?.previousTrack()
}
} }
func seek(to position: TimeInterval) { func seek(to position: TimeInterval) {
activeController?.seek(to: position) Task {
await activeController?.seek(to: position)
}
} }
func openMusicApp() { func openMusicApp() {
@ -388,9 +434,14 @@ class MusicManager: ObservableObject {
func forceUpdate() { func forceUpdate() {
// Request immediate update from the active controller // Request immediate update from the active controller
DispatchQueue.main.async { [weak self] in Task { [weak self] in
if self?.activeController?.isActive() == true { 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 // NotchSpaceManager.swift
// DynamicIsland // DynamicIsland
// //
// Created by Alexander Greco on 2024-10-27. // Created by Alexander on 2024-10-27.
// //
import Foundation import Foundation

View file

@ -1,12 +1,12 @@
import Foundation
import Cocoa import Cocoa
import SwiftUI
import IOKit.ps
import Defaults import Defaults
import Foundation
import IOKit.ps
import SwiftUI
/// A view model that manages and monitors the battery status of the device /// A view model that manages and monitors the battery status of the device
class BatteryStatusViewModel: ObservableObject { class BatteryStatusViewModel: ObservableObject {
private var wasCharging: Bool = false private var wasCharging: Bool = false
private var powerSourceChangedCallback: IOPowerSourceCallbackType? private var powerSourceChangedCallback: IOPowerSourceCallbackType?
private var runLoopSource: Unmanaged<CFRunLoopSource>? private var runLoopSource: Unmanaged<CFRunLoopSource>?
@ -22,14 +22,14 @@ class BatteryStatusViewModel: ObservableObject {
@Published private(set) var isInitial: Bool = false @Published private(set) var isInitial: Bool = false
@Published private(set) var timeToFullCharge: Int = 0 @Published private(set) var timeToFullCharge: Int = 0
@Published private(set) var statusText: String = "" @Published private(set) var statusText: String = ""
private let managerBattery = BatteryActivityManager.shared private let managerBattery = BatteryActivityManager.shared
private var managerBatteryId: Int? private var managerBatteryId: Int?
static let shared = BatteryStatusViewModel() static let shared = BatteryStatusViewModel()
/// Initializes the view model with a given DynamicIslandViewModel instance /// Initializes the view model with a given BoringViewModel instance
/// - Parameter vm: The DynamicIslandViewModel instance /// - Parameter vm: The BoringViewModel instance
private init() { private init() {
setupPowerStatus() setupPowerStatus()
setupMonitor() setupMonitor()
@ -48,58 +48,61 @@ class BatteryStatusViewModel: ObservableObject {
self.handleBatteryEvent(event) self.handleBatteryEvent(event)
} }
} }
/// Handles battery events and updates the corresponding properties /// Handles battery events and updates the corresponding properties
/// - Parameter event: The battery event to handle /// - Parameter event: The battery event to handle
private func handleBatteryEvent(_ event: BatteryActivityManager.BatteryEvent) { private func handleBatteryEvent(_ event: BatteryActivityManager.BatteryEvent) {
switch event { switch event {
case .powerSourceChanged(let isPluggedIn): case .powerSourceChanged(let isPluggedIn):
print("🔌 Power source: \(isPluggedIn ? "Connected" : "Disconnected")") print("🔌 Power source: \(isPluggedIn ? "Connected" : "Disconnected")")
withAnimation { withAnimation {
self.isPluggedIn = isPluggedIn self.isPluggedIn = isPluggedIn
self.statusText = isPluggedIn ? "Plugged In" : "Unplugged" self.statusText = isPluggedIn ? "Plugged In" : "Unplugged"
self.notifyImportanChangeStatus() 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 .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 /// Updates the battery information with the given BatteryInfo instance
@ -114,25 +117,13 @@ class BatteryStatusViewModel: ObservableObject {
self.maxCapacity = batteryInfo.maxCapacity self.maxCapacity = batteryInfo.maxCapacity
self.statusText = batteryInfo.isPluggedIn ? "Plugged In" : "Unplugged" 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 /// Notifies important changes in the battery status with an optional delay
/// - Parameter delay: The delay before notifying the change, default is 0.0 /// - Parameter delay: The delay before notifying the change, default is 0.0
private func notifyImportanChangeStatus(delay: Double = 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) self.coordinator.toggleExpandingView(status: true, type: .battery)
} }
} }
@ -143,5 +134,5 @@ class BatteryStatusViewModel: ObservableObject {
managerBattery.removeObserver(byId: managerBatteryId) managerBattery.removeObserver(byId: managerBatteryId)
} }
} }
} }

View file

@ -117,6 +117,7 @@ extension Defaults.Keys {
static let sneakPeekStyles = Key<SneakPeekStyle>("sneakPeekStyles", default: .standard) static let sneakPeekStyles = Key<SneakPeekStyle>("sneakPeekStyles", default: .standard)
static let enableFullscreenMediaDetection = Key<Bool>("enableFullscreenMediaDetection", default: true) static let enableFullscreenMediaDetection = Key<Bool>("enableFullscreenMediaDetection", default: true)
static let waitInterval = Key<Double>("waitInterval", default: 3) static let waitInterval = Key<Double>("waitInterval", default: 3)
static let showShuffleAndRepeat = Key<Bool>("showShuffleAndRepeat", default: false)
// MARK: Battery // MARK: Battery
static let showPowerStatusNotifications = Key<Bool>("showPowerStatusNotifications", default: true) 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 isHoveringCalendar: Bool = false
@Published var isBatteryPopoverActive: Bool = false @Published var isBatteryPopoverActive: Bool = false
@Published var isClipboardPopoverActive: 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? @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 import Foundation
enum RepeatMode: Int, Codable {
case off = 1
case one = 2
case all = 3
}
struct PlaybackState { struct PlaybackState {
var bundleIdentifier: String var bundleIdentifier: String
var isPlaying: Bool = false var isPlaying: Bool = false
@ -16,8 +22,8 @@ struct PlaybackState {
var currentTime: Double = 0 var currentTime: Double = 0
var duration: Double = 0 var duration: Double = 0
var playbackRate: Double = 1 var playbackRate: Double = 1
var isShuffled: Bool? = nil var isShuffled: Bool = false
var isRepeating: Bool? = nil var repeatMode: RepeatMode = .off
var lastUpdated: Date = Date.distantPast var lastUpdated: Date = Date.distantPast
var artwork: Data? var artwork: Data?
} }

View file

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

View file

@ -1,7 +1,9 @@
// //
// sizeMatters.swift //
// matters.swift
// DynamicIsland // DynamicIsland
// //
//
// Created by Harsh Vardhan Goswami on 05/08/24. // Created by Harsh Vardhan Goswami on 05/08/24.
// //
@ -12,7 +14,7 @@ import SwiftUI
let downloadSneakSize: CGSize = .init(width: 65, height: 1) let downloadSneakSize: CGSize = .init(width: 65, height: 1)
let batterySneakSize: CGSize = .init(width: 160, 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)) let cornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 19, bottom: 24), closed: (top: 6, bottom: 14))
enum MusicPlayerImageSizes { 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: $@";
}