diff --git a/.DS_Store b/.DS_Store index de5a495..aae1f70 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 90b5436..0c1810f 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -16,9 +16,16 @@ 7E61181A2E24071E0050006C /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = B1628B912CC260C0003D8DF3 /* SwiftUIIntrospect */; }; 7E61181B2E24071E0050006C /* LottieUI in Frameworks */ = {isa = PBXBuildFile; productRef = B194240A2CD10017003E5DC2 /* LottieUI */; }; 7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 146BC4362D366E9B0047C66A /* MacroVisionKit */; }; + 7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */; }; + 7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */; }; + BA90505C953246D0AB206451 /* MediaRemoteAdapter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; }; + C61F69420FB64DA98516CFBC /* MediaRemoteAdapter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */ = {isa = PBXFileReference; lastKnownFileType = text.script.perl; path = "mediaremote-adapter.pl"; sourceTree = ""; }; + 7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = NowPlayingTestClient; sourceTree = ""; }; + F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = "Frameworks/MediaRemoteAdapter.framework"; sourceTree = SOURCE_ROOT; }; F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DynamicIsland.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -35,6 +42,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + BA90505C953246D0AB206451 /* MediaRemoteAdapter.framework in Frameworks */, 7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */, 7E61181B2E24071E0050006C /* LottieUI in Frameworks */, 7E61181A2E24071E0050006C /* SwiftUIIntrospect in Frameworks */, @@ -49,10 +57,43 @@ }; /* End PBXFrameworksBuildPhase section */ +/* Begin PBXCopyFilesBuildPhase section */ + B933CDB48C2F42C2887A9836 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 12; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + C61F69420FB64DA98516CFBC /* MediaRemoteAdapter.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXGroup section */ + 7E81303F2E3FB40500E08F3F /* mediaremote-adapter */ = { + isa = PBXGroup; + children = ( + 7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */, + 7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */, + F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */, + ); + path = "mediaremote-adapter"; + sourceTree = ""; + }; + 7E81304F2E3FB42700E08F3F /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; F54406DE2CD4987A00C7AFD9 = { isa = PBXGroup; children = ( + 7E81304F2E3FB42700E08F3F /* Frameworks */, + 7E81303F2E3FB40500E08F3F /* mediaremote-adapter */, F54406E92CD4987A00C7AFD9 /* DynamicIsland */, F54406E82CD4987A00C7AFD9 /* Products */, ); @@ -76,6 +117,7 @@ F54406E32CD4987A00C7AFD9 /* Sources */, F54406E42CD4987A00C7AFD9 /* Frameworks */, F54406E52CD4987A00C7AFD9 /* Resources */, + B933CDB48C2F42C2887A9836 /* Embed Frameworks */, ); buildRules = ( ); @@ -151,6 +193,8 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */, + 7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -296,6 +340,12 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; ENABLE_PREVIEWS = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)", + "$(PROJECT_DIR)/Frameworks", + "$(PROJECT_DIR)/mediaremote-adapter", + ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSAppleEventsUsageDescription = "Need for Spotify and Apple Music"; INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode"; @@ -323,6 +373,12 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\""; ENABLE_PREVIEWS = YES; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)", + "$(PROJECT_DIR)/Frameworks", + "$(PROJECT_DIR)/mediaremote-adapter", + ); GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSAppleEventsUsageDescription = "Need for Spotify and Apple Music"; INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode"; diff --git a/DynamicIsland.xcodeproj/project.pbxproj.backup b/DynamicIsland.xcodeproj/project.pbxproj.backup new file mode 100644 index 0000000..afefed5 --- /dev/null +++ b/DynamicIsland.xcodeproj/project.pbxproj.backup @@ -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 = ""; }; + 7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = NowPlayingTestClient; sourceTree = ""; }; + 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 = ""; + }; +/* 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 = ""; + }; + 7E81304F2E3FB42700E08F3F /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + F54406DE2CD4987A00C7AFD9 = { + isa = PBXGroup; + children = ( + 7E81304F2E3FB42700E08F3F /* Frameworks */, + 7E81303F2E3FB40500E08F3F /* mediaremote-adapter */, + F54406E92CD4987A00C7AFD9 /* DynamicIsland */, + F54406E82CD4987A00C7AFD9 /* Products */, + ); + sourceTree = ""; + }; + F54406E82CD4987A00C7AFD9 /* Products */ = { + isa = PBXGroup; + children = ( + F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */, + ); + name = Products; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 102ff7a..72897f9 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -55,10 +55,10 @@ struct DynamicNotchApp: App { workspace.openApplication(at: appURL, configuration: configuration) } - NSApplication.shared.terminate(nil) + NSApplication.shared.terminate(self) } Button("Quit", role: .destructive) { - NSApp.terminate(nil) + NSApplication.shared.terminate(self) } .keyboardShortcut(KeyEquivalent("Q"), modifiers: .command) } @@ -80,7 +80,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared var whatsNewWindow: NSWindow? var timer: Timer? - let calendarManager = CalendarManager() + let calendarManager = CalendarManager.shared let webcamManager = WebcamManager.shared var closeNotchWorkItem: DispatchWorkItem? private var previousScreens: [NSScreen]? diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index af545a7..23c4553 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -52,8 +52,6 @@ class DynamicIslandViewCoordinator: ObservableObject { var notifier: TheBoringWorkerNotifier = .init() @Published var currentView: NotchViews = .home - private var sneakPeekDispatch: DispatchWorkItem? - private var expandingViewDispatch: DispatchWorkItem? @AppStorage("firstLaunch") var firstLaunch: Bool = true @@ -109,21 +107,35 @@ class DynamicIslandViewCoordinator: ObservableObject { } @objc func sneakPeekEvent(_ notification: Notification) { - let decoder = JSONDecoder() - if let decodedData = try? decoder.decode(SharedSneakPeek.self, from: notification.userInfo?.first?.value as! Data) { - let contentType = decodedData.type == "brightness" ? SneakContentType.brightness : decodedData.type == "volume" ? SneakContentType.volume : decodedData.type == "backlight" ? SneakContentType.backlight : decodedData.type == "mic" ? SneakContentType.mic : decodedData.type == "timer" ? SneakContentType.timer : SneakContentType.brightness + let decoder = JSONDecoder() + if let decodedData = try? decoder.decode( + SharedSneakPeek.self, from: notification.userInfo?.first?.value as! Data) + { + let contentType = + decodedData.type == "brightness" + ? SneakContentType.brightness + : decodedData.type == "volume" + ? SneakContentType.volume + : decodedData.type == "backlight" + ? SneakContentType.backlight + : decodedData.type == "mic" + ? SneakContentType.mic + : decodedData.type == "timer" + ? SneakContentType.timer : SneakContentType.brightness + + let formatter = NumberFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.numberStyle = .decimal + let value = CGFloat((formatter.number(from: decodedData.value) ?? 0.0).floatValue) + let icon = decodedData.icon - let value = CGFloat((NumberFormatter().number(from: decodedData.value) ?? 0.0).floatValue) - let icon = decodedData.icon - - print(decodedData) - - toggleSneakPeek(status: decodedData.show, type: contentType, value: value, icon: icon) - - } else { - print("Failed to decode JSON data") - } + print("Decoded: \(decodedData), Parsed value: \(value)") + + toggleSneakPeek(status: decodedData.show, type: contentType, value: value, icon: icon) + } else { + print("Failed to decode JSON data") } + } func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") { sneakPeekDuration = duration @@ -148,35 +160,41 @@ class DynamicIslandViewCoordinator: ObservableObject { } private var sneakPeekDuration: TimeInterval = 1.5 - - @Published var sneakPeek: sneakPeek = .init() { - didSet { - if sneakPeek.show { - sneakPeekDispatch?.cancel() + private var sneakPeekTask: Task? - sneakPeekDispatch = DispatchWorkItem { [weak self] in - guard let self = self else { return } - withAnimation { - self.toggleSneakPeek(status: false, type: SneakContentType.music) - self.sneakPeekDuration = 1.5 - } + // Helper function to manage sneakPeek timer using Swift Concurrency + private func scheduleSneakPeekHide(after duration: TimeInterval) { + sneakPeekTask?.cancel() + + sneakPeekTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(duration)) + guard let self = self, !Task.isCancelled else { return } + await MainActor.run { + withAnimation { + self.toggleSneakPeek(status: false, type: .music) + self.sneakPeekDuration = 1.5 } - DispatchQueue.main - .asyncAfter( - deadline: .now() + self.sneakPeekDuration, - execute: sneakPeekDispatch! - ) } } } - func toggleExpandingView(status: Bool, type: SneakContentType, value: CGFloat = 0, browser: BrowserType = .chromium) { - if expandingView.show { - withAnimation(.smooth) { - self.expandingView.show = false + @Published var sneakPeek: sneakPeek = .init() { + didSet { + if sneakPeek.show { + scheduleSneakPeekHide(after: sneakPeekDuration) + } else { + sneakPeekTask?.cancel() } } - DispatchQueue.main.async { + } + + func toggleExpandingView( + status: Bool, + type: SneakContentType, + value: CGFloat = 0, + browser: BrowserType = .chromium + ) { + Task { @MainActor in withAnimation(.smooth) { self.expandingView.show = status self.expandingView.type = type @@ -185,18 +203,21 @@ class DynamicIslandViewCoordinator: ObservableObject { } } } + + private var expandingViewTask: Task? @Published var expandingView: ExpandedItem = .init() { didSet { if expandingView.show { - expandingViewDispatch?.cancel() - - expandingViewDispatch = DispatchWorkItem { [weak self] in - guard let self = self else { return } - self.toggleExpandingView(status: false, type: SneakContentType.battery) + expandingViewTask?.cancel() + let duration: TimeInterval = (expandingView.type == .download ? 2 : 3) + expandingViewTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(duration)) + guard let self = self, !Task.isCancelled else { return } + self.toggleExpandingView(status: false, type: .battery) } - - DispatchQueue.main.asyncAfter(deadline: .now() + (expandingView.type == .download ? 2 : 3), execute: expandingViewDispatch!) + } else { + expandingViewTask?.cancel() } } } diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index a142c91..aa367ab 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -34,6 +34,9 @@ }, "%lld" : { + }, + "%lld items" : { + }, "%lld%%" : { @@ -49,18 +52,6 @@ }, "β€’ New feature 1" : { - }, - "3 items" : { - - }, - "5 items" : { - - }, - "7 items" : { - - }, - "10 items" : { - }, "About" : { "localizations" : { @@ -85,9 +76,6 @@ }, "Access Denied" : { - }, - "Actions" : { - }, "Activate" : { @@ -253,14 +241,11 @@ }, "Check for Updates…" : { - }, - "Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF." : { - }, "Choose a Music Source" : { }, - "Choose File" : { + "Choose which performance graphs to display. 1-3 graphs use horizontal layout, 4+ graphs use compressed grid layout." : { }, "Circle" : { @@ -268,18 +253,15 @@ }, "Clear" : { - }, - "Clear Clipboard History" : { - - }, - "Clear Data" : { - }, "Clipboard" : { }, "Clipboard History" : { + }, + "Clipboard history will be tracked and accessible via the header button." : { + }, "Clipboard Manager" : { @@ -296,27 +278,15 @@ } } } + }, + "Configure how many recent clipboard items to remember (minimum 3)." : { + }, "Continue" : { - }, - "Controls" : { - }, "Copy something to get started" : { - }, - "CPU Usage" : { - - }, - "Current custom timer: %@" : { - - }, - "Current History" : { - - }, - "Current Items" : { - }, "Custom" : { @@ -340,27 +310,15 @@ }, "Custom notch size - %.0f" : { - }, - "Custom Timer" : { - - }, - "Custom Timer Duration" : { - }, "Custom vizualizers (Lottie)" : { - }, - "Custom: %@" : { - }, "Customize in Settings" : { }, "Default" : { - }, - "Default: dynamic.m4a" : { - }, "Description" : { @@ -370,12 +328,6 @@ }, "Disabled" : { - }, - "Disk Read" : { - - }, - "Disk Write" : { - }, "Download" : { @@ -421,9 +373,6 @@ }, "dynamic.island" : { - }, - "Each graph can be individually enabled or disabled. Network activity shows download/upload speeds, and disk I/O shows read/write speeds." : { - }, "Easily copy, store, and manage your most-used content. Upgrade now for advanced features like multi-item storage and quick access!" : { @@ -464,9 +413,6 @@ }, "Enable music live activity" : { - }, - "Enable or disable the timer functionality in the Dynamic Island. The live activity toggle controls whether timer progress is shown in the expanded view." : { - }, "Enable sneak peek" : { @@ -477,7 +423,13 @@ "Enable timer feature in Settings to use this tab" : { }, - "Enable timer live activity" : { + "Enable to access timer functionality in the Dynamic Island." : { + + }, + "Enable to monitor CPU, Memory, GPU, Network and Disk usage." : { + + }, + "Enable to track clipboard history and access recent items." : { }, "Enhance your experience with HUDs" : { @@ -539,9 +491,6 @@ }, "Got it!" : { - }, - "GPU Usage" : { - }, "Gradient" : { @@ -549,7 +498,7 @@ "Graph Visibility" : { }, - "Hide DynamicIsland Options" : { + "Hide BoringNotch Options" : { }, "Hide only when NowPlaying app is in fullscreen" : { @@ -561,7 +510,7 @@ "High" : { }, - "History Size" : { + "History size" : { }, "https://github.com/th-ch/youtube-music" : { @@ -592,9 +541,6 @@ }, "Installed extensions" : { - }, - "Last Updated" : { - }, "Later" : { @@ -604,9 +550,6 @@ }, "Live" : { - }, - "Live Performance Data" : { - }, "Lottie JSON URL" : { @@ -648,6 +591,9 @@ }, "Media change with horizontal gestures" : { + }, + "Media controls" : { + }, "Media inactivity timeout" : { "localizations" : { @@ -674,9 +620,6 @@ }, "Medium" : { - }, - "Memory Usage" : { - }, "Menubar icon" : { "extractionState" : "stale", @@ -697,21 +640,12 @@ }, "Minimum hover duration" : { - }, - "Minutes" : { - }, "Mirror" : { }, "Mirror shape" : { - }, - "Monitor clipboard changes and keep a history of recent copies. Use Cmd+Shift+V to quickly access clipboard history." : { - - }, - "Monitoring Status" : { - }, "More" : { @@ -727,12 +661,6 @@ }, "Need Restart" : { - }, - "Network Download" : { - - }, - "Network Upload" : { - }, "Never hide" : { @@ -884,9 +812,6 @@ }, "Remember last tab" : { - }, - "Reset to Default" : { - }, "Restart" : { @@ -910,9 +835,6 @@ } } } - }, - "Seconds" : { - }, "Select Calendars" : { @@ -925,9 +847,6 @@ }, "Selected animation" : { - }, - "Set a custom duration for the timer. This will be used when you press the 'Custom' button in the timer interface." : { - }, "Settings" : { "localizations" : { @@ -955,9 +874,6 @@ } } } - }, - "Show calendar" : { - }, "Show charging indicator" : { "extractionState" : "stale", @@ -997,6 +913,9 @@ }, "Show on all displays" : { + }, + "Show shuffle and repeat buttons" : { + }, "Slider color" : { @@ -1025,15 +944,6 @@ }, "Start" : { - }, - "Start Demo Timer:" : { - - }, - "Start Monitoring" : { - - }, - "Starts a 5-minute demo timer to test the timer live activity feature." : { - }, "Stats" : { @@ -1041,10 +951,10 @@ "Stats Disabled" : { }, - "Stop" : { + "Stats tab will appear in the notch when enabled." : { }, - "Stop Monitoring" : { + "Stop" : { }, "Stopped" : { @@ -1063,14 +973,11 @@ "System features" : { }, - "The clipboard icon appears in the header next to the settings gear when enabled. Use the global shortcut Cmd+Shift+V or click the icon to access history." : { + "System Monitoring" : { }, "TheDynamicIsland" : { - }, - "This will permanently delete all stored clipboard history. The clipboard button is located to the left of the settings gear in the header." : { - }, "Time to Full Charge: %lld min" : { @@ -1081,10 +988,7 @@ "Timer Disabled" : { }, - "Timer Feature" : { - - }, - "Timer Sound" : { + "Timer tab will appear in the notch when enabled." : { }, "Toggle Notch Open:" : { @@ -1107,9 +1011,6 @@ }, "unmuted" : { - }, - "Update" : { - }, "Upgrade to Pro" : { @@ -1145,12 +1046,6 @@ }, "What's New" : { - }, - "When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : { - - }, - "With %lld graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row." : { - }, "You can now enjoy the app. If you want to tweak things further, you can always visit the settings." : { diff --git a/DynamicIsland/MediaControllers/AppleMusicController.swift b/DynamicIsland/MediaControllers/AppleMusicController.swift index cfdf94d..5105d41 100644 --- a/DynamicIsland/MediaControllers/AppleMusicController.swift +++ b/DynamicIsland/MediaControllers/AppleMusicController.swift @@ -2,7 +2,7 @@ // AppleMusicController.swift // DynamicIsland // -// Created by Alexander Greco on 2025-03-29. +// Created by Alexander on 2025-03-29. // import Foundation @@ -15,110 +15,125 @@ class AppleMusicController: MediaControllerProtocol { bundleIdentifier: "com.apple.Music" ) - var playbackStatePublisher: Published.Publisher { $playbackState } + var playbackStatePublisher: AnyPublisher { + $playbackState.eraseToAnyPublisher() + } + + var isWorking: Bool { + return true // AppleMusic controller always works + } + + private var notificationTask: Task? // MARK: - Initialization init() { setupPlaybackStateChangeObserver() - DispatchQueue.main.async { [weak self] in - if self?.isActive() == true { - self?.updatePlaybackInfo() + Task { + if isActive() { + await updatePlaybackInfo() } } } private func setupPlaybackStateChangeObserver() { - DistributedNotificationCenter.default().addObserver( - self, - selector: #selector(updatePlaybackInfo), - name: NSNotification.Name("com.apple.Music.playerInfo"), - object: nil - ) + notificationTask = Task { @Sendable [weak self] in + let notifications = DistributedNotificationCenter.default().notifications( + named: NSNotification.Name("com.apple.Music.playerInfo") + ) + + for await _ in notifications { + await self?.updatePlaybackInfo() + } + } } deinit { - DistributedNotificationCenter.default().removeObserver( - self, - name: NSNotification.Name("com.apple.Music.playerInfo"), - object: nil - ) - + notificationTask?.cancel() } // MARK: - Protocol Implementation - func play() { - executeCommand("play") + func play() async { + await executeCommand("play") } - func pause() { - executeCommand("pause") + func pause() async { + await executeCommand("pause") } - func togglePlay() { - executeCommand("playpause") + func togglePlay() async { + await executeCommand("playpause") } - func nextTrack() { - executeCommand("next track") + func nextTrack() async { + await executeCommand("next track") } - func previousTrack() { - executeCommand("previous track") + func previousTrack() async { + await executeCommand("previous track") } - func seek(to time: Double) { - executeCommand("set player position to \(time)") - updatePlaybackInfo() + func seek(to time: Double) async { + await executeCommand("set player position to \(time)") + await updatePlaybackInfo() + } + + func toggleShuffle() async { + await executeCommand("set shuffle enabled to not shuffle enabled") + playbackState.isShuffled.toggle() + } + + func toggleRepeat() async { + await executeCommand(""" + if song repeat is off then + set song repeat to all + else if song repeat is all then + set song repeat to one + else + set song repeat to off + end if + """) + + if playbackState.repeatMode == .off { + playbackState.repeatMode = .all + } else if playbackState.repeatMode == .all { + playbackState.repeatMode = .one + } else { + playbackState.repeatMode = .off + } } func isActive() -> Bool { let runningApps = NSWorkspace.shared.runningApplications - return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier } + return runningApps.contains { $0.bundleIdentifier == "com.apple.Music" } + } + + func updatePlaybackInfo() async { + guard let descriptor = try? await fetchPlaybackInfoAsync() else { return } + guard descriptor.numberOfItems >= 8 else { return } + var updatedState = self.playbackState + + updatedState.isPlaying = descriptor.atIndex(1)?.booleanValue ?? false + updatedState.title = descriptor.atIndex(2)?.stringValue ?? "Unknown" + updatedState.artist = descriptor.atIndex(3)?.stringValue ?? "Unknown" + updatedState.album = descriptor.atIndex(4)?.stringValue ?? "Unknown" + updatedState.currentTime = descriptor.atIndex(5)?.doubleValue ?? 0 + updatedState.duration = descriptor.atIndex(6)?.doubleValue ?? 0 + updatedState.isShuffled = descriptor.atIndex(7)?.booleanValue ?? false + let repeatModeValue = descriptor.atIndex(8)?.int32Value ?? 0 + updatedState.repeatMode = RepeatMode(rawValue: Int(repeatModeValue)) ?? .off + updatedState.artwork = descriptor.atIndex(9)?.data as Data? + + self.playbackState = updatedState } // MARK: - Private Methods - @objc func updatePlaybackInfo() { - guard let descriptor = fetchPlaybackInfo() else { return } - guard descriptor.numberOfItems >= 8 else { return } - - let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false - let currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown" - let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown" - let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown" - let currentTime = descriptor.atIndex(5)?.doubleValue ?? 0 - let duration = descriptor.atIndex(6)?.doubleValue ?? 0 - let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false - let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false - let artworkData = descriptor.atIndex(9)?.data as Data? - - let updatedState = PlaybackState( - bundleIdentifier: "com.apple.Music", - isPlaying: isPlaying, - title: currentTrack, - artist: currentTrackArtist, - album: currentTrackAlbum, - currentTime: currentTime, - duration: duration, - playbackRate: 1, - isShuffled: isShuffled, - isRepeating: isRepeating, - lastUpdated: Date(), - artwork: (artworkData?.isEmpty ?? true) ? nil : artworkData - ) - - DispatchQueue.main.async { [weak self] in - self?.playbackState = updatedState - } - } - private func executeCommand(_ command: String) { + private func executeCommand(_ command: String) async { let script = "tell application \"Music\" to \(command)" - Task { - try? await AppleScriptHelper.executeVoid(script) - } + try? await AppleScriptHelper.executeVoid(script) } - private func fetchPlaybackInfo() -> NSAppleEventDescriptor? { + private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? { let script = """ tell application "Music" set isRunning to true @@ -129,26 +144,28 @@ class AppleMusicController: MediaControllerProtocol { set currentTrackAlbum to album of current track set trackPosition to player position set trackDuration to duration of current track - set shuffleState to false - set repeatState to false + set shuffleState to shuffle enabled + set repeatState to song repeat + if repeatState is off then + set repeatValue to 0 + else if repeatState is all then + set repeatValue to 1 + else if repeatState is one then + set repeatValue to 2 + end if + try set artData to data of artwork 1 of current track on error set artData to "" end try - return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artData} + return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatValue, artData} on error - return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, false, ""} + return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, 0, ""} end try end tell """ - var descriptor: NSAppleEventDescriptor? = nil - let semaphore = DispatchSemaphore(value: 0) - Task { - descriptor = try? await AppleScriptHelper.execute(script) - semaphore.signal() - } - semaphore.wait() - return descriptor + + return try await AppleScriptHelper.execute(script) } } diff --git a/DynamicIsland/MediaControllers/MediaControllerProtocol.swift b/DynamicIsland/MediaControllers/MediaControllerProtocol.swift index 5d03c00..20f9a5e 100644 --- a/DynamicIsland/MediaControllers/MediaControllerProtocol.swift +++ b/DynamicIsland/MediaControllers/MediaControllerProtocol.swift @@ -6,16 +6,19 @@ // import Foundation -import AppKit +import Combine protocol MediaControllerProtocol: ObservableObject { - var playbackStatePublisher: Published.Publisher { get } - func play() - func pause() - func seek(to time: Double) - func nextTrack() - func previousTrack() - func togglePlay() + var playbackStatePublisher: AnyPublisher { get } + var isWorking: Bool { get } + func play() async + func pause() async + func seek(to time: Double) async + func nextTrack() async + func previousTrack() async + func togglePlay() async + func toggleShuffle() async + func toggleRepeat() async func isActive() -> Bool - func updatePlaybackInfo() + func updatePlaybackInfo() async } diff --git a/DynamicIsland/MediaControllers/NowPlayingController.swift b/DynamicIsland/MediaControllers/NowPlayingController.swift index a4c3b0a..bea429a 100644 --- a/DynamicIsland/MediaControllers/NowPlayingController.swift +++ b/DynamicIsland/MediaControllers/NowPlayingController.swift @@ -5,193 +5,222 @@ // Created by Alexander on 2025-03-29. // -import Combine import AppKit +import Combine import Foundation -class NowPlayingController: ObservableObject, MediaControllerProtocol { +final class NowPlayingController: ObservableObject, MediaControllerProtocol { + // Stub for now to conform with ControllerProtocol + func updatePlaybackInfo() {} + // MARK: - Properties - @Published var playbackState: PlaybackState = .init( + @Published private(set) var playbackState: PlaybackState = .init( bundleIdentifier: "com.apple.Music" ) + private var isResourcesAvailable = false + + var playbackStatePublisher: AnyPublisher { + $playbackState.eraseToAnyPublisher() + } - var playbackStatePublisher: Published.Publisher { $playbackState } - private var cancellables = Set() - private var lastMusicItem: (title: String, artist: String, album: String, duration: TimeInterval, artworkData: Data?)? - + var isWorking: Bool { + return isResourcesAvailable + } + private var lastMusicItem: + (title: String, artist: String, album: String, duration: TimeInterval, artworkData: Data?)? + // MARK: - Media Remote Functions private let mediaRemoteBundle: CFBundle - private let MRMediaRemoteGetNowPlayingInfo: @convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void - private let MRMediaRemoteRegisterForNowPlayingNotifications: @convention(c) (DispatchQueue) -> Void - private let MRMediaRemoteGetNowPlayingApplicationIsPlaying: @convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void - private let MRMediaRemoteGetNowPlayingClient: @convention(c) (DispatchQueue, @escaping (AnyObject?) -> Void) -> Void - private let MRNowPlayingClientGetBundleIdentifier: @convention(c) (AnyObject?) -> String? - private let MRNowPlayingClientGetParentAppBundleIdentifier: @convention(c) (AnyObject?) -> String? - private let MRMediaRemoteSendCommandFunction:@convention(c) (Int, AnyObject?) -> Void + private let MRMediaRemoteSendCommandFunction: @convention(c) (Int, AnyObject?) -> Void private let MRMediaRemoteSetElapsedTimeFunction: @convention(c) (Double) -> Void + private let MRMediaRemoteSetShuffleModeFunction: @convention(c) (Int) -> Void + private let MRMediaRemoteSetRepeatModeFunction: @convention(c) (Int) -> Void + + private var process: Process? + private var pipe: Pipe? + private var buffer = "" // MARK: - Initialization init?() { - guard let bundle = CFBundleCreate(kCFAllocatorDefault, NSURL(fileURLWithPath: "/System/Library/PrivateFrameworks/MediaRemote.framework")), - let MRMediaRemoteGetNowPlayingInfoPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingInfo" as CFString), - let MRMediaRemoteRegisterForNowPlayingNotificationsPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteRegisterForNowPlayingNotifications" as CFString), - let MRMediaRemoteGetNowPlayingApplicationIsPlayingPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingApplicationIsPlaying" as CFString), - let MRMediaRemoteGetNowPlayingClientPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteGetNowPlayingClient" as CFString), - let MRNowPlayingClientGetBundleIdentifierPointer = CFBundleGetFunctionPointerForName(bundle, "MRNowPlayingClientGetBundleIdentifier" as CFString), - let MRNowPlayingClientGetParentAppBundleIdentifierPointer = CFBundleGetFunctionPointerForName(bundle, "MRNowPlayingClientGetParentAppBundleIdentifier" as CFString), - let MRMediaRemoteSendCommandPointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteSendCommand" as CFString), - let MRMediaRemoteSetElapsedTimePointer = CFBundleGetFunctionPointerForName(bundle, "MRMediaRemoteSetElapsedTime" as CFString) + guard + let bundle = CFBundleCreate( + kCFAllocatorDefault, + NSURL(fileURLWithPath: "/System/Library/PrivateFrameworks/MediaRemote.framework")), + let MRMediaRemoteSendCommandPointer = CFBundleGetFunctionPointerForName( + bundle, "MRMediaRemoteSendCommand" as CFString), + let MRMediaRemoteSetElapsedTimePointer = CFBundleGetFunctionPointerForName( + bundle, "MRMediaRemoteSetElapsedTime" as CFString), + let MRMediaRemoteSetShuffleModePointer = CFBundleGetFunctionPointerForName( + bundle, "MRMediaRemoteSetShuffleMode" as CFString), + let MRMediaRemoteSetRepeatModePointer = CFBundleGetFunctionPointerForName( + bundle, "MRMediaRemoteSetRepeatMode" as CFString) + else { return nil } mediaRemoteBundle = bundle - MRMediaRemoteGetNowPlayingInfo = unsafeBitCast(MRMediaRemoteGetNowPlayingInfoPointer, to: (@convention(c) (DispatchQueue, @escaping ([String: Any]) -> Void) -> Void).self) - MRMediaRemoteRegisterForNowPlayingNotifications = unsafeBitCast(MRMediaRemoteRegisterForNowPlayingNotificationsPointer, to: (@convention(c) (DispatchQueue) -> Void).self) - MRMediaRemoteGetNowPlayingApplicationIsPlaying = unsafeBitCast(MRMediaRemoteGetNowPlayingApplicationIsPlayingPointer, to: (@convention(c) (DispatchQueue, @escaping (Bool) -> Void) -> Void).self) - MRMediaRemoteGetNowPlayingClient = unsafeBitCast(MRMediaRemoteGetNowPlayingClientPointer, to: (@convention(c) (DispatchQueue, @escaping (AnyObject?) -> Void) -> Void).self) - MRNowPlayingClientGetBundleIdentifier = unsafeBitCast(MRNowPlayingClientGetBundleIdentifierPointer, to: (@convention(c) (AnyObject?) -> String?).self) - MRNowPlayingClientGetParentAppBundleIdentifier = unsafeBitCast(MRNowPlayingClientGetParentAppBundleIdentifierPointer, to: (@convention(c) (AnyObject?) -> String?).self) - MRMediaRemoteSendCommandFunction = unsafeBitCast(MRMediaRemoteSendCommandPointer, to: (@convention(c) (Int, AnyObject?) -> Void).self) - MRMediaRemoteSetElapsedTimeFunction = unsafeBitCast(MRMediaRemoteSetElapsedTimePointer, to: (@convention(c) (Double) -> Void).self) - + MRMediaRemoteSendCommandFunction = unsafeBitCast( + MRMediaRemoteSendCommandPointer, to: (@convention(c) (Int, AnyObject?) -> Void).self) + MRMediaRemoteSetElapsedTimeFunction = unsafeBitCast( + MRMediaRemoteSetElapsedTimePointer, to: (@convention(c) (Double) -> Void).self) + MRMediaRemoteSetShuffleModeFunction = unsafeBitCast( + MRMediaRemoteSetShuffleModePointer, to: (@convention(c) (Int) -> Void).self) + MRMediaRemoteSetRepeatModeFunction = unsafeBitCast( + MRMediaRemoteSetRepeatModePointer, to: (@convention(c) (Int) -> Void).self) + setupNowPlayingObserver() updatePlaybackInfo() } - + deinit { - // Clean up all Combine subscribers - cancellables.removeAll() - - // Remove distributed notification observers - DistributedNotificationCenter.default().removeObserver( - self, - name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"), - object: nil - ) - - DistributedNotificationCenter.default().removeObserver( - self, - name: NSNotification.Name("com.apple.Music.playerInfo"), - object: nil - ) + if let process = self.process { + if process.isRunning { + process.terminate() + process.waitUntilExit() + } + } + + if let pipe = self.pipe { + pipe.fileHandleForReading.closeFile() + pipe.fileHandleForWriting.closeFile() + } + + self.process = nil + self.pipe = nil } - + // MARK: - Protocol Implementation - func play() { + func play() async { MRMediaRemoteSendCommandFunction(0, nil) } - - func pause() { + + func pause() async { MRMediaRemoteSendCommandFunction(1, nil) } - - func togglePlay() { + + func togglePlay() async { MRMediaRemoteSendCommandFunction(2, nil) } - - func nextTrack() { + + func nextTrack() async { MRMediaRemoteSendCommandFunction(4, nil) } - - func previousTrack() { + + func previousTrack() async { MRMediaRemoteSendCommandFunction(5, nil) } - - func seek(to time: Double) { + + func seek(to time: Double) async { MRMediaRemoteSetElapsedTimeFunction(time) } - + func isActive() -> Bool { return true } - // MARK: - Setup Methods - private func setupNowPlayingObserver() { - MRMediaRemoteRegisterForNowPlayingNotifications(DispatchQueue.main) - - NotificationCenter.default.publisher(for: NSNotification.Name("kMRMediaRemoteNowPlayingInfoDidChangeNotification")) - .sink { [weak self] _ in self?.updatePlaybackInfo() } - .store(in: &cancellables) - - NotificationCenter.default.publisher(for: NSNotification.Name("kMRMediaRemoteNowPlayingApplicationDidChangeNotification")) - .sink { [weak self] _ in self?.updateApp() } - .store(in: &cancellables) - - DistributedNotificationCenter.default().addObserver( - self, - selector: #selector(updatePlaybackInfo), - name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"), - object: nil - ) - - DistributedNotificationCenter.default().addObserver( - self, - selector: #selector(updatePlaybackInfo), - name: NSNotification.Name("com.apple.Music.playerInfo"), - object: nil - ) + func toggleShuffle() async { + // MRMediaRemoteSendCommandFunction(6, nil) + MRMediaRemoteSetShuffleModeFunction(playbackState.isShuffled ? 3 : 1) + playbackState.isShuffled.toggle() } - // MARK: - Update Methods - @objc func updateApp() { - // Get the now playing client - MRMediaRemoteGetNowPlayingClient(DispatchQueue.main) { [weak self] clientObj in - guard let clientObj = clientObj else { - DispatchQueue.main.async { - self?.playbackState.bundleIdentifier = "com.apple.Music" // Default fallback - } - return - } - - // Try to get parent bundle ID first, then fall back to direct bundle ID - var appBundleID = self?.MRNowPlayingClientGetParentAppBundleIdentifier(clientObj) - if appBundleID == nil { - appBundleID = self?.MRNowPlayingClientGetBundleIdentifier(clientObj) - } - - // Special case for WebKit.GPU which is often Safari - if appBundleID == "com.apple.WebKit.GPU" { - appBundleID = "com.apple.Safari" - } - - DispatchQueue.main.async { - self?.playbackState.bundleIdentifier = appBundleID ?? "com.apple.Music" - } - } + func toggleRepeat() async { + // MRMediaRemoteSendCommandFunction(7, nil) + let newRepeatMode = (playbackState.repeatMode == .off) ? 3 : (playbackState.repeatMode.rawValue - 1) + playbackState.repeatMode = RepeatMode(rawValue: newRepeatMode) ?? .off + MRMediaRemoteSetRepeatModeFunction(newRepeatMode) } + + // MARK: - Setup Methods + private func setupNowPlayingObserver() { + let process = Process() + guard + let scriptURL = Bundle.main.url(forResource: "mediaremote-adapter", withExtension: "pl"), + let frameworkPath = Bundle.main.privateFrameworksPath?.appending("/MediaRemoteAdapter.framework") + else { + print("⚠️ Could not find mediaremote-adapter.pl script or framework path - NowPlaying controller disabled") + isResourcesAvailable = false + return + } + + isResourcesAvailable = true + process.executableURL = URL(fileURLWithPath: "/usr/bin/perl") + process.arguments = [scriptURL.path, frameworkPath, "stream"] + let pipe = Pipe() + process.standardOutput = pipe + self.process = process + self.pipe = pipe - @objc func updatePlaybackInfo() { - updateApp() - - MRMediaRemoteGetNowPlayingInfo(DispatchQueue.main) { [weak self] information in - guard let self = self else { return } - - let title = information["kMRMediaRemoteNowPlayingInfoTitle"] as? String ?? "" - let artist = information["kMRMediaRemoteNowPlayingInfoArtist"] as? String ?? "" - let album = information["kMRMediaRemoteNowPlayingInfoAlbum"] as? String ?? "" - let duration = information["kMRMediaRemoteNowPlayingInfoDuration"] as? TimeInterval ?? 0 - let currentTime = information["kMRMediaRemoteNowPlayingInfoElapsedTime"] as? TimeInterval ?? 0 - let artworkData = information["kMRMediaRemoteNowPlayingInfoArtworkData"] as? Data - let timestamp = information["kMRMediaRemoteNowPlayingInfoTimestamp"] as? Date ?? Date() - let playbackRate = information["kMRMediaRemoteNowPlayingInfoPlaybackRate"] as? Double ?? 1 - - // Update playback state - DispatchQueue.main.async { - self.playbackState.title = title - self.playbackState.artist = artist - self.playbackState.album = album - self.playbackState.duration = duration - self.playbackState.currentTime = currentTime - self.playbackState.artwork = artworkData - self.playbackState.lastUpdated = timestamp - self.playbackState.playbackRate = playbackRate - - // Check playback state - self.MRMediaRemoteGetNowPlayingApplicationIsPlaying(DispatchQueue.main) { [weak self] isPlaying in - DispatchQueue.main.async { - self?.playbackState.isPlaying = isPlaying + pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + if let chunk = String(data: data, encoding: .utf8) { + self?.buffer.append(chunk) + while let range = self?.buffer.range(of: "\n") { + let line = String(self?.buffer[...Publisher { $playbackState } + var playbackStatePublisher: AnyPublisher { + $playbackState.eraseToAnyPublisher() + } + + var isWorking: Bool { + return true // Spotify controller always works + } + + private var notificationTask: Task? + + //Constant for time between command and update + let commandUpdateDelay: Duration = .milliseconds(25) init() { setupPlaybackStateChangeObserver() - DispatchQueue.main.async { [weak self] in - if self?.isActive() == true { - self?.updatePlaybackInfo() + Task { + if isActive() { + await updatePlaybackInfo() } } } private func setupPlaybackStateChangeObserver() { - DistributedNotificationCenter.default().addObserver( - self, - selector: #selector(updatePlaybackInfo), - name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"), - object: nil - ) + notificationTask = Task { @Sendable [weak self] in + let notifications = DistributedNotificationCenter.default().notifications( + named: NSNotification.Name("com.spotify.client.PlaybackStateChanged") + ) + + for await _ in notifications { + await self?.updatePlaybackInfo() + } + } } deinit { - // Remove notification observer when controller is deallocated - DistributedNotificationCenter.default().removeObserver( - self, - name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"), - object: nil - ) + notificationTask?.cancel() } // MARK: - Protocol Implementation - func play() { - executeCommand("play") + func play() async { + await executeCommand("play") } - func pause() { - executeCommand("pause") + func pause() async { + await executeCommand("pause") } - func togglePlay() { - executeCommand("playpause") + func togglePlay() async { + await executeCommand("playpause") } - func nextTrack() { - executeCommand("next track") + func nextTrack() async { + await executeCommand("next track") } - func previousTrack() { - executeCommand("previous track") + func previousTrack() async { + await executeCommand("previous track") } - func seek(to time: Double) { - executeCommand("set player position to \(time)") - updatePlaybackInfo() + func seek(to time: Double) async { + await executeCommand("set player position to \(time)") + try? await Task.sleep(for: commandUpdateDelay) + await updatePlaybackInfo() + } + + func toggleShuffle() async { + await executeCommand("set shuffling to not shuffling") + try? await Task.sleep(for: commandUpdateDelay) + await updatePlaybackInfo() + } + + func toggleRepeat() async { + await executeCommand("set repeating to not repeating") + try? await Task.sleep(for: commandUpdateDelay) + await updatePlaybackInfo() } func isActive() -> Bool { @@ -75,9 +97,8 @@ class SpotifyController: MediaControllerProtocol { return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier } } - // MARK: - Private Methods - @objc func updatePlaybackInfo() { - guard let descriptor = fetchPlaybackInfo() else { return } + func updatePlaybackInfo() async { + guard let descriptor = try? await fetchPlaybackInfoAsync() else { return } guard descriptor.numberOfItems >= 9 else { return } let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false @@ -100,42 +121,35 @@ class SpotifyController: MediaControllerProtocol { duration: duration, playbackRate: 1, isShuffled: isShuffled, - isRepeating: isRepeating, + repeatMode: isRepeating ? .all : .off, lastUpdated: Date() ) - DispatchQueue.main.async { [weak self] in - self?.playbackState = state - } + self.playbackState = state // Load artwork asynchronously and update the state when complete if !artworkURL.isEmpty, let url = URL(string: artworkURL) { - DispatchQueue.global(qos: .background).async { [weak self] in - do { - let artworkData = try Data(contentsOf: url) - DispatchQueue.main.async { - // Create a new state with the artwork data and update - var updatedState = state - updatedState.artwork = artworkData - self?.playbackState = updatedState - } - } catch { - print("Failed to load artwork: \(error)") - } + let currentState = state + do { + let (data, _) = try await URLSession.shared.data(from: url) + // Create a new state with the artwork data and update + var updatedState = currentState + updatedState.artwork = data + playbackState = updatedState + } catch { + print("Failed to load artwork: \(error)") } - } else { - self.playbackState = state } } - private func executeCommand(_ command: String) { +// MARK: - Private Methods + + private func executeCommand(_ command: String) async { let script = "tell application \"Spotify\" to \(command)" - Task { - try? await AppleScriptHelper.executeVoid(script) - } + try? await AppleScriptHelper.executeVoid(script) } - private func fetchPlaybackInfo() -> NSAppleEventDescriptor? { + private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? { let script = """ tell application "Spotify" set isRunning to true @@ -156,13 +170,6 @@ class SpotifyController: MediaControllerProtocol { end tell """ - var descriptor: NSAppleEventDescriptor? = nil - let semaphore = DispatchSemaphore(value: 0) - Task { - descriptor = try? await AppleScriptHelper.execute(script) - semaphore.signal() - } - semaphore.wait() - return descriptor + return try await AppleScriptHelper.execute(script) } } diff --git a/DynamicIsland/MediaControllers/YouTubeMusicController.swift b/DynamicIsland/MediaControllers/YouTubeMusicController.swift index d5cb82d..5a2d3cb 100644 --- a/DynamicIsland/MediaControllers/YouTubeMusicController.swift +++ b/DynamicIsland/MediaControllers/YouTubeMusicController.swift @@ -3,6 +3,7 @@ // DynamicIsland // // Created By Alexander on 2025-03-30. +// Modified by Pranav on 2025-06-16. // import Foundation @@ -12,143 +13,129 @@ import SwiftUI class YouTubeMusicController: MediaControllerProtocol { // MARK: - Properties @Published var playbackState: PlaybackState = .init( - bundleIdentifier: "com.github.th-ch.youtube-music", - isPlaying: true + bundleIdentifier: "com.github.th-ch.youtube-music" ) - var playbackStatePublisher: Published.Publisher { $playbackState } + var playbackStatePublisher: AnyPublisher { + $playbackState.eraseToAnyPublisher() + } + + var isWorking: Bool { + return true // YouTube Music controller always works + } private let baseURL = "http://localhost:26538" private var accessToken: String? - private var refreshTimer: Timer? + private var periodicUpdateTask: Task? + private var notificationTask: Task? private var cancellables = Set() - private var playbackInfoCancellable: AnyCancellable? private var isAuthenticating = false - private let authQueue = DispatchQueue(label: "com.dynamicisland.youtubemusicauth", qos: .background) init() { setupAppStateObservers() - authQueue.async { [weak self] in - self?.authenticateAndSetup() - } + Task {await authenticateAndSetup()} } deinit { stopPeriodicUpdates() + notificationTask?.cancel() cancellables.forEach { $0.cancel() } - NSWorkspace.shared.notificationCenter.removeObserver(self) } // MARK: - Authentication - private func authenticateAndSetup() { + private func authenticateAndSetup() async { // Prevent multiple concurrent authentication attempts guard !isAuthenticating else { return } isAuthenticating = true - getAccessToken { [weak self] success in - guard let self = self else { return } - - DispatchQueue.main.async { - self.isAuthenticating = false - if success { - self.startPeriodicUpdates() - self.updatePlaybackInfo() - } else { - // Retry authentication after a delay if it fails - DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 5) { [weak self] in - self?.authenticateAndSetup() - } - } - } + let success = await getAccessToken() + + self.isAuthenticating = false + if success { + await self.startPeriodicUpdates() + await updatePlaybackInfo() + } else { + try? await Task.sleep(for: .seconds(5)) + await authenticateAndSetup() } } - private func getAccessToken(completion: @escaping (Bool) -> Void) { - guard let url = URL(string: "\(baseURL)/auth/DynamicIsland") else { - completion(false) - return + private func getAccessToken() async -> Bool { + guard let url = URL(string: "\(baseURL)/auth/boringNotch") else { + return false } var request = URLRequest(url: url) request.httpMethod = "POST" - URLSession.shared.dataTaskPublisher(for: request) - .map { data, response -> Data in - return data - } - .decode(type: AuthResponse.self, decoder: JSONDecoder()) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { completionStatus in - switch completionStatus { - case .failure(_): - completion(false) - case .finished: - break - } - }, receiveValue: { [weak self] response in - self?.accessToken = response.accessToken - completion(true) - }) - .store(in: &cancellables) + do { + let (data, _) = try await URLSession.shared.data(for: request) + let response = try JSONDecoder().decode(AuthResponse.self, from: data) + accessToken = response.accessToken + return true + } catch { + return false + } } // MARK: - Protocol Implementation - func play() { - sendCommand(endpoint: "/play", method: "POST") - updatePlaybackInfo() + func play() async { + await sendCommand(endpoint: "/play", method: "POST") + await updatePlaybackInfo() } - func pause() { - sendCommand(endpoint: "/pause", method: "POST") - updatePlaybackInfo() + func pause() async { + await sendCommand(endpoint: "/pause", method: "POST") + await updatePlaybackInfo() } - func togglePlay() { + func togglePlay() async { if !isActive() { if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: playbackState.bundleIdentifier) { NSWorkspace.shared.open(url) } } - sendCommand(endpoint: "/toggle-play", method: "POST") - updatePlaybackInfo() + await sendCommand(endpoint: "/toggle-play", method: "POST") + await updatePlaybackInfo() } - func nextTrack() { - sendCommand(endpoint: "/next", method: "POST") - updatePlaybackInfo() + func nextTrack() async { + await sendCommand(endpoint: "/next", method: "POST") + await updatePlaybackInfo() } - func previousTrack() { - sendCommand(endpoint: "/previous", method: "POST") - updatePlaybackInfo() + func previousTrack() async { + await sendCommand(endpoint: "/previous", method: "POST") + await updatePlaybackInfo() } - func seek(to time: Double) { - guard let url = URL(string: "\(baseURL)/seek-to") else { return } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - - if let token = accessToken { - request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } else { - authenticateAndSetup() - return - } - - // Create the request body with the seconds value + func seek(to time: Double) async { + // Format the seek data payload according to the API schema let seekData = ["seconds": time] do { - request.httpBody = try JSONEncoder().encode(seekData) - } catch { - return - } - - URLSession.shared.dataTaskPublisher(for: request) - .tryMap { data, response -> Data in + // Convert seek data to JSON + let jsonData = try JSONEncoder().encode(seekData) + + guard let url = URL(string: "\(baseURL)/api/v1/seek-to") else { return } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if let token = accessToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } else { + await authenticateAndSetup() + return + } + + request.httpBody = jsonData + + do { + let (_, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { throw URLError(.badServerResponse) } @@ -159,22 +146,44 @@ class YouTubeMusicController: MediaControllerProtocol { throw URLError(.badServerResponse) } - return data - } - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { [weak self] completion in - if case .failure(let error) = completion { - if let urlError = error as? URLError, urlError.code == .userAuthenticationRequired { - self?.accessToken = nil - self?.authenticateAndSetup() - } + // Update playback info after seeking + try? await Task.sleep(for: .milliseconds(300)) + await updatePlaybackInfo() + + } catch { + print("Seek error: \(error)") + if let urlError = error as? URLError, urlError.code == .userAuthenticationRequired { + self.accessToken = nil + await self.authenticateAndSetup() } - }, receiveValue: { [weak self] _ in - self?.updatePlaybackInfo() - }) - .store(in: &cancellables) + } + + } catch { + print("Error encoding seek data: \(error)") + return + } } + func fetchShuffleState() async { + await sendCommand(endpoint: "/shuffle", method: "GET") + } + + func toggleShuffle() async { + await sendCommand(endpoint: "/shuffle", method: "POST") + try? await Task.sleep(for: .milliseconds(200)) + await fetchShuffleState() + } + + func toggleRepeat() async { + await sendCommand(endpoint: "/switch-repeat", method: "POST") + try? await Task.sleep(for: .milliseconds(200)) + await fetchRepeatMode() + } + + func fetchRepeatMode() async { + await sendCommand(endpoint: "/repeat-mode", method: "GET") + } + func isActive() -> Bool { let runningApps = NSWorkspace.shared.runningApplications return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier } @@ -183,131 +192,170 @@ class YouTubeMusicController: MediaControllerProtocol { // MARK: - Private Methods private func setupAppStateObservers() { - // Register for app launch notifications - NSWorkspace.shared.notificationCenter.addObserver( - self, - selector: #selector(handleAppStateChange), - name: NSWorkspace.didLaunchApplicationNotification, - object: nil - ) - - // Register for app termination notifications - NSWorkspace.shared.notificationCenter.addObserver( - self, - selector: #selector(handleAppStateChange), - name: NSWorkspace.didTerminateApplicationNotification, - object: nil - ) + notificationTask = Task { @Sendable [weak self] in + await withTaskGroup(of: Void.self) { group in + // Handle app launch notifications + group.addTask { + let launchNotifications = NSWorkspace.shared.notificationCenter.notifications( + named: NSWorkspace.didLaunchApplicationNotification + ) + + for await notification in launchNotifications { + await self?.handleAppStateChange(notification: notification) + } + } + + // Handle app termination notifications + group.addTask { + let terminateNotifications = NSWorkspace.shared.notificationCenter.notifications( + named: NSWorkspace.didTerminateApplicationNotification + ) + + for await notification in terminateNotifications { + await self?.handleAppStateChange(notification: notification) + } + } + } + } } - @objc private func handleAppStateChange(notification: Notification) { + private func handleAppStateChange(notification: Notification) async { guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication, app.bundleIdentifier == playbackState.bundleIdentifier else { return } if notification.name == NSWorkspace.didLaunchApplicationNotification { - startPeriodicUpdates() + await startPeriodicUpdates() } else if notification.name == NSWorkspace.didTerminateApplicationNotification { // App has terminated - update state and stop timer stopPeriodicUpdates() - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - self.playbackState = PlaybackState( - bundleIdentifier: self.playbackState.bundleIdentifier, - isPlaying: false - ) - } + self.playbackState = PlaybackState( + bundleIdentifier: self.playbackState.bundleIdentifier, + isPlaying: false + ) } } - private func startPeriodicUpdates() { + private func startPeriodicUpdates() async { stopPeriodicUpdates() // Ensure we don't create multiple timers // Only start the timer if the app is actually running guard isActive() else { return } - refreshTimer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in - self?.updatePlaybackInfo() + periodicUpdateTask = Task { [weak self] in + while !Task.isCancelled { + await self?.updatePlaybackInfo() + try? await Task.sleep(for: .seconds(2)) + } } - refreshTimer?.tolerance = 0.5 // Initial update when timer starts - updatePlaybackInfo() + await updatePlaybackInfo() + await fetchRepeatMode() + await fetchShuffleState() } private func stopPeriodicUpdates() { - refreshTimer?.invalidate() - refreshTimer = nil + periodicUpdateTask?.cancel() + periodicUpdateTask = nil + } + + private func updateRepeatMode(_ modeString: String?) { + guard let modeString = modeString else { return } + + let repeatMode: RepeatMode + switch modeString { + case "NONE": + repeatMode = .off + case "ALL": + repeatMode = .all + case "ONE": + repeatMode = .one + default: + repeatMode = .off + } + + if repeatMode != playbackState.repeatMode { + playbackState.repeatMode = repeatMode + } } - func updatePlaybackInfo() { + func updatePlaybackInfo() async { if !isActive() { playbackState = PlaybackState(bundleIdentifier: playbackState.bundleIdentifier, isPlaying: false) stopPeriodicUpdates() return } - guard let request = createAuthenticatedRequest(for: "/api/v1/song") else { return } + + guard let request = await createAuthenticatedRequest(for: "/api/v1/song") else { return } - let backgroundQueue = DispatchQueue.global(qos: .utility) - - backgroundQueue.async { [weak self] in - guard let self = self else { return } + do { + let (data, response) = try await URLSession.shared.data(for: request) - playbackInfoCancellable?.cancel() + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } - playbackInfoCancellable = URLSession.shared.dataTaskPublisher(for: request) - .tryMap { data, response -> Data in - guard let httpResponse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - // Check for authentication errors - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { - // Re-authenticate and retry later - DispatchQueue.main.async { [weak self] in - self?.accessToken = nil - self?.authenticateAndSetup() - } - throw URLError(.userAuthenticationRequired) - } else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 { - throw URLError(.badServerResponse) - } - - return data - } - .decode(type: PlaybackResponse.self, decoder: JSONDecoder()) - .subscribe(on: backgroundQueue) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { _ in }, receiveValue: { [weak self] response in - self?.updatePlaybackState(with: response) - }) + // Check for authentication errors + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + // Re-authenticate and retry later + self.accessToken = nil + await authenticateAndSetup() + throw URLError(.userAuthenticationRequired) + } else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 { + throw URLError(.badServerResponse) + } + + let playbackResponse = try JSONDecoder().decode(PlaybackResponse.self, from: data) + await updatePlaybackState(with: playbackResponse) + + } catch { + // Handle error silently for now, as this is called periodically } } - private func updatePlaybackState(with response: PlaybackResponse) { - playbackState.isPlaying = !response.isPaused - playbackState.title = response.title - playbackState.artist = response.artist - playbackState.album = response.album ?? "" - playbackState.currentTime = response.elapsedSeconds - playbackState.duration = response.songDuration - playbackState.lastUpdated = Date() + private func updatePlaybackState(with response: PlaybackResponse) async { + var updatedState = self.playbackState + updatedState.isPlaying = !response.isPaused + updatedState.title = response.title + updatedState.artist = response.artist + updatedState.album = response.album ?? "" + updatedState.currentTime = response.elapsedSeconds + updatedState.duration = response.songDuration + updatedState.lastUpdated = Date() + + if let isShuffled = response.isShuffled { + updatedState.isShuffled = isShuffled + } // Load artwork if available if let artworkURL = response.imageSrc, let url = URL(string: artworkURL) { - DispatchQueue.global(qos: .background).async { [weak self] in - do { - let artworkData = try Data(contentsOf: url) - DispatchQueue.main.async { - self?.playbackState.artwork = artworkData - } - } catch { return } + do { + let artworkData = try Data(contentsOf: url) + playbackState.artwork = artworkData + + } catch { + NSLog("Failed to load artwork: \(error)") + updatedState.artwork = nil + self.playbackState = updatedState + return } } + self.playbackState = updatedState } - private func sendCommand(endpoint: String, method: String = "GET") { + func pollPlaybackState() async { + if !isActive() { + return + } + + await fetchRepeatMode() + await fetchShuffleState() + await updatePlaybackInfo() + } + + private func sendCommand(endpoint: String, method: String = "GET") async { guard let url = URL(string: "\(baseURL)/api/v1\(endpoint)") else { return } var request = URLRequest(url: url) @@ -316,49 +364,65 @@ class YouTubeMusicController: MediaControllerProtocol { if let token = accessToken { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } else { - authenticateAndSetup() + await authenticateAndSetup() return } - URLSession.shared.dataTaskPublisher(for: request) - .tryMap { data, response -> Data in - guard let httpResponse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { - throw URLError(.userAuthenticationRequired) - } else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 { - throw URLError(.badServerResponse) - } - - return data + do { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) } - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { [weak self] completion in - if case .failure(let error) = completion { - if let urlError = error as? URLError, urlError.code == .userAuthenticationRequired { - // Authentication error - token might be expired - self?.accessToken = nil - self?.authenticateAndSetup() - - // Try the command again after authentication - DispatchQueue.global(qos: .background).asyncAfter(deadline: .now() + 2) { [weak self] in - self?.sendCommand(endpoint: endpoint, method: method) - } + + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw URLError(.userAuthenticationRequired) + } else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 { + throw URLError(.badServerResponse) + } + + // Handle data based on endpoint + if endpoint == "/shuffle" || endpoint == "/shuffle-status" { + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let shuffleState = json["state"] as? Bool { + playbackState.isShuffled = shuffleState } + } catch { + print("Error parsing shuffle state: \(error)") } - }, receiveValue: { [weak self] _ in - self?.updatePlaybackInfo() - }) - .store(in: &cancellables) + } else if endpoint == "/repeat-mode" { + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let mode = json["mode"] as? String { + updateRepeatMode(mode) + } + } catch { + print("Error parsing repeat mode: \(error)") + } + } + + // Update playback info for relevant commands + await updatePlaybackInfo() + + } catch { + if let urlError = error as? URLError, urlError.code == .userAuthenticationRequired { + // Authentication error - token might be expired + accessToken = nil + await authenticateAndSetup() + + // Try the command again after authentication + try? await Task.sleep(for: .seconds(2)) + await sendCommand(endpoint: endpoint, method: method) + } + } } - private func createAuthenticatedRequest(for endpoint: String) -> URLRequest? { + private func createAuthenticatedRequest(for endpoint: String) async -> URLRequest? { guard let token = accessToken, let url = URL(string: "\(baseURL)\(endpoint)") else { if accessToken == nil { // Token is missing, try to authenticate again - authenticateAndSetup() + await authenticateAndSetup() } return nil } @@ -383,4 +447,6 @@ struct PlaybackResponse: Codable { let elapsedSeconds: Double let songDuration: Double let imageSrc: String? + let repeatMode: Int? + let isShuffled: Bool? } diff --git a/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift b/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift index 4dfd91f..9f3f750 100644 --- a/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift +++ b/DynamicIsland/components/Calendar/DynamicIslandCalendar.swift @@ -150,7 +150,7 @@ struct WheelPicker: View { struct CalendarView: View { @EnvironmentObject var vm: DynamicIslandViewModel - @StateObject private var calendarManager = CalendarManager() + @ObservedObject private var calendarManager = CalendarManager.shared @State private var selectedDate = Date() var body: some View { diff --git a/DynamicIsland/components/Music/MusicVisualizer.swift b/DynamicIsland/components/Music/MusicVisualizer.swift index ffb769f..971fb1c 100644 --- a/DynamicIsland/components/Music/MusicVisualizer.swift +++ b/DynamicIsland/components/Music/MusicVisualizer.swift @@ -72,7 +72,9 @@ class AudioSpectrum: NSView { animation.autoreverses = true animation.fillMode = .forwards animation.isRemovedOnCompletion = false - + if #available(macOS 13.0, *) { + animation.preferredFrameRateRange = CAFrameRateRange(minimum: 24, maximum: 24, preferred: 24) + } barLayer.add(animation, forKey: "scaleY") } } diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index 05ffffc..cbdd9cb 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -46,11 +46,7 @@ struct DynamicIslandHeader: View { if vm.notchState == .open { if Defaults[.showMirror] { Button(action: { - if webcamManager.isSessionRunning { - webcamManager.stopSession() - } else { - webcamManager.startSession() - } + vm.toggleCameraPreview() }) { Capsule() .fill(.black) diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index aeeaf64..31a88e4 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -161,18 +161,58 @@ struct MusicControlsView: View { private var playbackControls: some View { HStack(spacing: 8) { + if Defaults[.showShuffleAndRepeat] { + HoverButton(icon: "shuffle", iconColor: musicManager.isShuffled ? .red : .white, scale: .medium) { + Task { + await MusicManager.shared.toggleShuffle() + } + } + } HoverButton(icon: "backward.fill", scale: .medium) { - MusicManager.shared.previousTrack() + Task { + await MusicManager.shared.previousTrack() + } } HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) { - MusicManager.shared.togglePlay() + Task { + await MusicManager.shared.togglePlay() + } } HoverButton(icon: "forward.fill", scale: .medium) { - MusicManager.shared.nextTrack() + Task { + await MusicManager.shared.nextTrack() + } + } + if Defaults[.showShuffleAndRepeat] { + HoverButton(icon: repeatIcon, iconColor: repeatIconColor, scale: .medium) { + Task { + await MusicManager.shared.toggleRepeat() + } + } } } .frame(maxWidth: .infinity, alignment: .center) } + + private var repeatIcon: String { + switch musicManager.repeatMode { + case .off: + return "repeat" + case .all: + return "repeat" + case .one: + return "repeat.1" + } + } + + private var repeatIconColor: Color { + switch musicManager.repeatMode { + case .off: + return .white + case .all, .one: + return .red + } + } } // MARK: - Main View diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index f2e2aed..a3a60a9 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -3,7 +3,7 @@ // DynamicIsland // // Created by Richard Kunkli on 07/08/2024. -// Modified by Hariharan Mudaliar +// import AVFoundation import Defaults @@ -14,20 +14,17 @@ import LottieUI import Sparkle import SwiftUI import SwiftUIIntrospect -import UniformTypeIdentifiers struct SettingsView: View { @StateObject var extensionManager = DynamicIslandExtensionManager() - @StateObject private var calendarManager = CalendarManager() - @State private var selectedTab = "General" - + let updaterController: SPUStandardUpdaterController? - + init(updaterController: SPUStandardUpdaterController? = nil) { self.updaterController = updaterController } - + var body: some View { NavigationSplitView { List(selection: $selectedTab) { @@ -40,9 +37,6 @@ struct SettingsView: View { NavigationLink(value: "Media") { Label("Media", systemImage: "play.laptopcomputer") } - NavigationLink(value: "Timer") { - Label("Timer", systemImage: "timer") - } NavigationLink(value: "Calendar") { Label("Calendar", systemImage: "calendar") } @@ -55,6 +49,15 @@ struct SettingsView: View { NavigationLink(value: "Battery") { Label("Battery", systemImage: "battery.100.bolt") } + NavigationLink(value: "Timer") { + Label("Timer", systemImage: "timer") + } + NavigationLink(value: "Stats") { + Label("Stats", systemImage: "chart.xyaxis.line") + } + NavigationLink(value: "Clipboard") { + Label("Clipboard", systemImage: "clipboard") + } if extensionManager.installedExtensions .contains(where: { $0.bundleIdentifier == downloadManagerExtension }) { NavigationLink(value: "Downloads") { @@ -64,12 +67,6 @@ struct SettingsView: View { NavigationLink(value: "Shelf") { Label("Shelf", systemImage: "books.vertical") } - NavigationLink(value: "Stats") { - Label("Stats", systemImage: "chart.line.uptrend.xyaxis") - } - NavigationLink(value: "Clipboard") { - Label("Clipboard", systemImage: "doc.on.clipboard") - } NavigationLink(value: "Shortcuts") { Label("Shortcuts", systemImage: "keyboard") } @@ -92,22 +89,22 @@ struct SettingsView: View { Appearance() case "Media": Media() - case "Timer": - TimerSettings() case "Calendar": CalendarSettings() case "HUD": HUD() case "Battery": Charge() + case "Timer": + TimerSettings() + case "Stats": + StatsSettings() + case "Clipboard": + ClipboardSettings() case "Downloads": Downloads() case "Shelf": Shelf() - case "Stats": - Stats() - case "Clipboard": - ClipboardSettings() case "Shortcuts": Shortcuts() case "Extensions": @@ -134,7 +131,6 @@ struct SettingsView: View { .disabled(true) } .environmentObject(extensionManager) - .environmentObject(calendarManager) .formStyle(.grouped) .frame(width: 700) .background(Color(NSColor.windowBackgroundColor)) @@ -158,8 +154,7 @@ struct GeneralSettings: View { @Default(.automaticallySwitchDisplay) var automaticallySwitchDisplay @Default(.enableGestures) var enableGestures @Default(.openNotchOnHover) var openNotchOnHover - @Default(.alwaysHideInFullscreen) var alwaysHideInFullscreen - + var body: some View { Form { Section { @@ -188,7 +183,7 @@ struct GeneralSettings: View { } header: { Text("System features") } - + Section { Picker(selection: $notchHeightMode, label: Text("Notch display height")) { @@ -248,9 +243,9 @@ struct GeneralSettings: View { } header: { Text("Notch Height") } - + NotchBehaviour() - + gestureControls() } .toolbar { @@ -266,7 +261,7 @@ struct GeneralSettings: View { } } } - + @ViewBuilder func gestureControls() -> some View { Section { @@ -297,7 +292,7 @@ struct GeneralSettings: View { .font(.caption) } } - + @ViewBuilder func NotchBehaviour() -> some View { Section { @@ -369,7 +364,7 @@ struct Downloads: View { Text("Both") .tag(DownloadIconStyle.iconAndAppIcon) } - + } header: { HStack { Text("Download indicators") @@ -397,7 +392,7 @@ struct Downloads: View { .contentShape(Rectangle()) .foregroundStyle(.secondary) } - + Divider() Button {} label: { Image(systemName: "minus") @@ -470,7 +465,7 @@ struct Media: View { @Default(.hideNotchOption) var hideNotchOption @Default(.enableSneakPeek) private var enableSneakPeek @Default(.sneakPeekStyles) var sneakPeekStyles - + var body: some View { Form { Section { @@ -503,13 +498,21 @@ struct Media: View { .font(.caption) } } + Section { + Defaults.Toggle(key: .showShuffleAndRepeat) { + HStack { + Text("Show shuffle and repeat buttons") + customBadge(text: "Beta") + } + } + } header: { + Text("Media controls") + } Section { Toggle( "Enable music live activity", - isOn: $coordinator.musicLiveActivityEnabled + isOn: $coordinator.musicLiveActivityEnabled.animation() ) - .animation(.easeInOut, value: coordinator.musicLiveActivityEnabled) - Toggle("Enable sneak peek", isOn: $enableSneakPeek) Picker("Sneak Peek Style", selection: $sneakPeekStyles){ ForEach(SneakPeekStyle.allCases) { style in @@ -532,21 +535,20 @@ struct Media: View { Picker(selection: $hideNotchOption, label: HStack { - Text("Hide DynamicIsland Options") + Text("Hide BoringNotch Options") customBadge(text: "Beta") }) { Text("Always hide in fullscreen").tag(HideNotchOption.always) Text("Hide only when NowPlaying app is in fullscreen").tag(HideNotchOption.nowPlayingOnly) Text("Never hide").tag(HideNotchOption.never) } - .onChange(of: hideNotchOption) { _, newValue in - Defaults[.alwaysHideInFullscreen] = newValue == .always - Defaults[.enableFullscreenMediaDetection] = newValue != .never + .onChange(of: hideNotchOption) { + Defaults[.enableFullscreenMediaDetection] = hideNotchOption != .never } } .navigationTitle("Media") } - + // Only show controller options that are available on this macOS version private var availableMediaControllers: [MediaControllerType] { if MusicManager.shared.isNowPlayingDeprecated { @@ -558,7 +560,7 @@ struct Media: View { } struct CalendarSettings: View { - @ObservedObject private var calendarManager = CalendarManager() + @ObservedObject private var calendarManager = CalendarManager.shared @Default(.showCalendar) var showCalendar: Bool var body: some View { @@ -574,7 +576,7 @@ struct CalendarSettings: View { } } } else { - Toggle("Show calendar", isOn: $showCalendar) + Defaults.Toggle("Show calendar", key: .showCalendar) Section(header: Text("Select Calendars")) { List { ForEach(calendarManager.allCalendars, id: \.id) { calendar in @@ -588,6 +590,7 @@ struct CalendarSettings: View { )) { Text(calendar.title) } + .disabled(!showCalendar) } } } @@ -603,157 +606,6 @@ struct CalendarSettings: View { } } -struct TimerSettings: View { - @ObservedObject private var coordinator = DynamicIslandViewCoordinator.shared - @Default(.enableTimerFeature) var enableTimerFeature - @AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 // 10 minutes default - @State private var customMinutes: Int = 10 - @State private var customSeconds: Int = 0 - - var body: some View { - Form { - Section { - Defaults.Toggle("Enable timer feature", key: .enableTimerFeature) - - if enableTimerFeature { - Toggle( - "Enable timer live activity", - isOn: $coordinator.timerLiveActivityEnabled - ) - .animation(.easeInOut, value: coordinator.timerLiveActivityEnabled) - } - } header: { - Text("Timer Feature") - } footer: { - Text("Enable or disable the timer functionality in the Dynamic Island. The live activity toggle controls whether timer progress is shown in the expanded view.") - } - - if enableTimerFeature { - Section { - VStack(alignment: .leading, spacing: 16) { - Text("Custom Timer Duration") - .font(.headline) - - HStack(spacing: 16) { - VStack { - Text("Minutes") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(.secondary) - - Picker(selection: $customMinutes, label: Text("Minutes")) { - ForEach(0...59, id: \.self) { minute in - Text("\(minute)").tag(minute) - } - } - .pickerStyle(.menu) - .frame(width: 80, height: 40) - } - - VStack { - Text("Seconds") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(.secondary) - - Picker(selection: $customSeconds, label: Text("Seconds")) { - ForEach(0...59, id: \.self) { second in - Text("\(second)").tag(second) - } - } - .pickerStyle(.menu) - .frame(width: 80, height: 40) - } - } - - HStack { - Text("Current custom timer: \(customTimerDisplayText)") - .foregroundStyle(.secondary) - Spacer() - Button("Update") { - customTimerDuration = Double(customMinutes * 60 + customSeconds) - } - .disabled(customMinutes == 0 && customSeconds == 0) - } - } - } header: { - Text("Custom Timer") - } footer: { - Text("Set a custom duration for the timer. This will be used when you press the 'Custom' button in the timer interface.") - } - - Section { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Timer Sound") - .font(.system(size: 16, weight: .medium)) - Spacer() - Button("Choose File") { - selectCustomTimerSound() - } - .buttonStyle(.bordered) - } - - if let customTimerSoundPath = UserDefaults.standard.string(forKey: "customTimerSoundPath") { - Text("Custom: \(URL(fileURLWithPath: customTimerSoundPath).lastPathComponent)") - .font(.caption) - .foregroundColor(.secondary) - } else { - Text("Default: dynamic.m4a") - .font(.caption) - .foregroundColor(.secondary) - } - - Button("Reset to Default") { - UserDefaults.standard.removeObject(forKey: "customTimerSoundPath") - } - .buttonStyle(.bordered) - .disabled(UserDefaults.standard.string(forKey: "customTimerSoundPath") == nil) - } - } header: { - Text("Timer Sound") - } footer: { - Text("Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF.") - } - } - } - .navigationTitle("Timer") - .onAppear { - let totalMinutes = Int(customTimerDuration) / 60 - customMinutes = totalMinutes - customSeconds = Int(customTimerDuration) % 60 - } - } - - private var customTimerDisplayText: String { - let totalMinutes = Int(customTimerDuration) / 60 - let seconds = Int(customTimerDuration) % 60 - let hours = totalMinutes / 60 - let minutes = totalMinutes % 60 - - if hours > 0 { - return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))" - } else if minutes > 0 { - return "\(minutes):\(String(format: "%02d", seconds))" - } else { - return "\(seconds)s" - } - } - - private func selectCustomTimerSound() { - let panel = NSOpenPanel() - panel.title = "Select Timer Sound" - panel.allowedContentTypes = [.audio] - panel.allowsMultipleSelection = false - panel.canChooseDirectories = false - panel.canChooseFiles = true - - if panel.runModal() == .OK { - if let url = panel.url { - UserDefaults.standard.set(url.path, forKey: "customTimerSoundPath") - } - } - } -} - struct About: View { @State private var showBuildNumber: Bool = false let updaterController: SPUStandardUpdaterController @@ -786,9 +638,9 @@ struct About: View { } header: { Text("Version info") } - + UpdaterSettingsView(updater: updaterController.updater) - + HStack(spacing: 30) { Spacer(minLength: 0) Button { @@ -891,15 +743,15 @@ struct Extensions: View { .font(.footnote) } .frame(width: 60, alignment: .leading) - + Menu(content: { Button("Restart") { let ws = NSWorkspace.shared - + if let ext = ws.runningApplications.first(where: { $0.bundleIdentifier == item.bundleIdentifier }) { ext.terminate() } - + if let appURL = ws.urlForApplication(withBundleIdentifier: item.bundleIdentifier) { ws.openApplication(at: appURL, configuration: .init(), completionHandler: nil) } @@ -987,7 +839,7 @@ struct Appearance: View { let icons: [String] = ["logo2"] @State private var selectedIcon: String = "logo2" @State private var selectedListVisualizer: CustomVisualizer? = nil - + @State private var isPresented: Bool = false @State private var name: String = "" @State private var url: String = "" @@ -1003,7 +855,7 @@ struct Appearance: View { } header: { Text("General") } - + Section { Defaults.Toggle("Enable colored spectrograms", key: .coloredSpectrogram) Defaults @@ -1017,7 +869,7 @@ struct Appearance: View { } header: { Text("Media") } - + Section { Toggle( "Use music visualizer spectrogram", @@ -1053,7 +905,7 @@ struct Appearance: View { customBadge(text: "Coming soon") } } - + Section { List { ForEach(customVisualizers, id: \.self) { visualizer in @@ -1151,7 +1003,7 @@ struct Appearance: View { Text("Cancel") .frame(maxWidth: .infinity, alignment: .center) } - + Button { let visualizer: CustomVisualizer = .init( UUID: UUID(), @@ -1159,11 +1011,11 @@ struct Appearance: View { url: URL(string: url)!, speed: speed ) - + if !customVisualizers.contains(visualizer) { customVisualizers.append(visualizer) } - + isPresented.toggle() } label: { Text("Add") @@ -1185,7 +1037,7 @@ struct Appearance: View { } } } - + Section { Defaults.Toggle("Enable boring mirror", key: .showMirror) .disabled(!checkVideoInput()) @@ -1201,7 +1053,7 @@ struct Appearance: View { Text("Additional features") } } - + Section { HStack { ForEach(icons, id: \.self) { icon in @@ -1217,7 +1069,7 @@ struct Appearance: View { lineWidth: 2.5 ) ) - + Text("Default") .fontWeight(.medium) .font(.caption) @@ -1248,12 +1100,12 @@ struct Appearance: View { } .navigationTitle("Appearance") } - + func checkVideoInput() -> Bool { if let _ = AVCaptureDevice.default(for: .video) { return true } - + return false } } @@ -1274,190 +1126,11 @@ struct Shortcuts: View { Section { KeyboardShortcuts.Recorder("Toggle Notch Open:", name: .toggleNotchOpen) } - Section { - KeyboardShortcuts.Recorder("Start Demo Timer:", name: .startDemoTimer) - } header: { - Text("Timer") - } footer: { - Text("Starts a 5-minute demo timer to test the timer live activity feature.") - .multilineTextAlignment(.trailing) - .foregroundStyle(.secondary) - .font(.caption) - } } .navigationTitle("Shortcuts") } } -struct Stats: View { - @ObservedObject var statsManager = StatsManager.shared - @Default(.enableStatsFeature) var enableStatsFeature - @Default(.showCpuGraph) var showCpuGraph - @Default(.showMemoryGraph) var showMemoryGraph - @Default(.showGpuGraph) var showGpuGraph - @Default(.showNetworkGraph) var showNetworkGraph - @Default(.showDiskGraph) var showDiskGraph - - var enabledGraphsCount: Int { - [showCpuGraph, showMemoryGraph, showGpuGraph, showNetworkGraph, showDiskGraph].filter { $0 }.count - } - - var body: some View { - Form { - Section { - Defaults.Toggle("Enable system stats monitoring", key: .enableStatsFeature) - .onChange(of: enableStatsFeature) { _, newValue in - if newValue { - statsManager.startMonitoring() - } else { - statsManager.stopMonitoring() - } - } - } header: { - Text("General") - } footer: { - Text("When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery.") - .multilineTextAlignment(.trailing) - .foregroundStyle(.secondary) - .font(.caption) - } - - if enableStatsFeature { - Section { - Defaults.Toggle("CPU Usage", key: .showCpuGraph) - Defaults.Toggle("Memory Usage", key: .showMemoryGraph) - Defaults.Toggle("GPU Usage", key: .showGpuGraph) - Defaults.Toggle("Network Activity", key: .showNetworkGraph) - Defaults.Toggle("Disk I/O", key: .showDiskGraph) - } header: { - Text("Graph Visibility") - } footer: { - if enabledGraphsCount >= 4 { - Text("With \(enabledGraphsCount) graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row.") - .multilineTextAlignment(.trailing) - .foregroundStyle(.secondary) - .font(.caption) - } else { - Text("Each graph can be individually enabled or disabled. Network activity shows download/upload speeds, and disk I/O shows read/write speeds.") - .multilineTextAlignment(.trailing) - .foregroundStyle(.secondary) - .font(.caption) - } - } - - Section { - HStack { - Text("Monitoring Status") - Spacer() - HStack(spacing: 4) { - Circle() - .fill(statsManager.isMonitoring ? .green : .red) - .frame(width: 8, height: 8) - Text(statsManager.isMonitoring ? "Active" : "Stopped") - .foregroundStyle(.secondary) - } - } - - if statsManager.isMonitoring { - if showCpuGraph { - HStack { - Text("CPU Usage") - Spacer() - Text(statsManager.cpuUsageString) - .foregroundStyle(.secondary) - } - } - - if showMemoryGraph { - HStack { - Text("Memory Usage") - Spacer() - Text(statsManager.memoryUsageString) - .foregroundStyle(.secondary) - } - } - - if showGpuGraph { - HStack { - Text("GPU Usage") - Spacer() - Text(statsManager.gpuUsageString) - .foregroundStyle(.secondary) - } - } - - if showNetworkGraph { - HStack { - Text("Network Download") - Spacer() - Text(String(format: "%.1f MB/s", statsManager.networkDownload)) - .foregroundStyle(.secondary) - } - - HStack { - Text("Network Upload") - Spacer() - Text(String(format: "%.1f MB/s", statsManager.networkUpload)) - .foregroundStyle(.secondary) - } - } - - if showDiskGraph { - HStack { - Text("Disk Read") - Spacer() - Text(String(format: "%.1f MB/s", statsManager.diskRead)) - .foregroundStyle(.secondary) - } - - HStack { - Text("Disk Write") - Spacer() - Text(String(format: "%.1f MB/s", statsManager.diskWrite)) - .foregroundStyle(.secondary) - } - } - - HStack { - Text("Last Updated") - Spacer() - Text(statsManager.lastUpdated, style: .relative) - .foregroundStyle(.secondary) - } - } - } header: { - Text("Live Performance Data") - } - - Section { - HStack { - Button(statsManager.isMonitoring ? "Stop Monitoring" : "Start Monitoring") { - if statsManager.isMonitoring { - statsManager.stopMonitoring() - } else { - statsManager.startMonitoring() - } - } - .buttonStyle(.borderedProminent) - .foregroundColor(statsManager.isMonitoring ? .red : .blue) - - Spacer() - - Button("Clear Data") { - statsManager.clearHistory() - } - .buttonStyle(.bordered) - .disabled(statsManager.isMonitoring) - } - } header: { - Text("Controls") - } - } - } - .navigationTitle("Stats") - } -} - func proFeatureBadge() -> some View { Text("Upgrade to Pro") .foregroundStyle(Color(red: 0.545, green: 0.196, blue: 0.98)) @@ -1504,129 +1177,120 @@ func warningBadge(_ text: String, _ description: String) -> some View { } } -struct ClipboardSettings: View { - @ObservedObject var clipboardManager = ClipboardManager.shared - @Default(.enableClipboardManager) var enableClipboardManager - @Default(.clipboardHistorySize) var clipboardHistorySize - @Default(.showClipboardIcon) var showClipboardIcon - +struct TimerSettings: View { var body: some View { Form { Section { - Defaults.Toggle("Enable Clipboard Manager", key: .enableClipboardManager) - .onChange(of: enableClipboardManager) { enabled in - if enabled { - clipboardManager.startMonitoring() + Defaults.Toggle("Enable timer feature", key: .enableTimerFeature) + .onChange(of: Defaults[.enableTimerFeature]) { _, newValue in + if !newValue { + // Stop any running timers when feature is disabled + TimerManager.shared.stopTimer() + } + } + } header: { + Text("Timer") + } footer: { + if Defaults[.enableTimerFeature] { + Text("Timer tab will appear in the notch when enabled.") + } else { + Text("Enable to access timer functionality in the Dynamic Island.") + } + } + } + .navigationTitle("Timer") + } +} + +struct StatsSettings: View { + var body: some View { + Form { + Section { + Defaults.Toggle("Enable stats feature", key: .enableStatsFeature) + .onChange(of: Defaults[.enableStatsFeature]) { _, newValue in + if !newValue { + // Stop monitoring when feature is disabled + StatsManager.shared.stopMonitoring() + } + } + } header: { + Text("System Monitoring") + } footer: { + if Defaults[.enableStatsFeature] { + Text("Stats tab will appear in the notch when enabled.") + } else { + Text("Enable to monitor CPU, Memory, GPU, Network and Disk usage.") + } + } + + if Defaults[.enableStatsFeature] { + Section { + Defaults.Toggle("Show CPU graph", key: .showCpuGraph) + Defaults.Toggle("Show Memory graph", key: .showMemoryGraph) + Defaults.Toggle("Show GPU graph", key: .showGpuGraph) + Defaults.Toggle("Show Network graph", key: .showNetworkGraph) + Defaults.Toggle("Show Disk graph", key: .showDiskGraph) + } header: { + Text("Graph Visibility") + } footer: { + Text("Choose which performance graphs to display. 1-3 graphs use horizontal layout, 4+ graphs use compressed grid layout.") + } + } + } + .navigationTitle("Stats") + } +} + +struct ClipboardSettings: View { + var body: some View { + Form { + Section { + Defaults.Toggle("Enable clipboard manager", key: .enableClipboardManager) + .onChange(of: Defaults[.enableClipboardManager]) { _, newValue in + if newValue { + ClipboardManager.shared.startMonitoring() } else { - clipboardManager.stopMonitoring() + ClipboardManager.shared.stopMonitoring() } } } header: { Text("Clipboard Manager") } footer: { - Text("Monitor clipboard changes and keep a history of recent copies. Use Cmd+Shift+V to quickly access clipboard history.") + if Defaults[.enableClipboardManager] { + Text("Clipboard history will be tracked and accessible via the header button.") + } else { + Text("Enable to track clipboard history and access recent items.") + } } - if enableClipboardManager { + if Defaults[.enableClipboardManager] { Section { - Defaults.Toggle("Show Clipboard Icon", key: .showClipboardIcon) + Defaults.Toggle("Show clipboard icon", key: .showClipboardIcon) - HStack { - Text("History Size") - Spacer() - Picker("History Size", selection: $clipboardHistorySize) { - Text("3 items").tag(3) - Text("5 items").tag(5) - Text("7 items").tag(7) - Text("10 items").tag(10) + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("History size") + Spacer() + Text("\(Defaults[.clipboardHistorySize]) items") + .foregroundStyle(.secondary) } - .pickerStyle(.menu) - .frame(width: 100) - } - - HStack { - Text("Current Items") - Spacer() - Text("\(clipboardManager.clipboardHistory.count)") - .foregroundColor(.secondary) - } - - HStack { - Text("Monitoring Status") - Spacer() - Text(clipboardManager.isMonitoring ? "Active" : "Stopped") - .foregroundColor(clipboardManager.isMonitoring ? .green : .secondary) + Slider( + value: Binding( + get: { Double(Defaults[.clipboardHistorySize]) }, + set: { Defaults[.clipboardHistorySize] = Int($0) } + ), + in: 3...10, + step: 1 + ) } } header: { Text("Settings") } footer: { - Text("The clipboard icon appears in the header next to the settings gear when enabled. Use the global shortcut Cmd+Shift+V or click the icon to access history.") - } - - Section { - Button("Clear Clipboard History") { - clipboardManager.clearHistory() - } - .foregroundColor(.red) - .disabled(clipboardManager.clipboardHistory.isEmpty) - } header: { - Text("Actions") - } footer: { - Text("This will permanently delete all stored clipboard history. The clipboard button is located to the left of the settings gear in the header.") - } - - if !clipboardManager.clipboardHistory.isEmpty { - Section { - ForEach(clipboardManager.clipboardHistory) { item in - VStack(alignment: .leading, spacing: 4) { - HStack { - Image(systemName: item.type.icon) - .foregroundColor(.blue) - .frame(width: 16) - Text(item.type.displayName) - .font(.caption) - .foregroundColor(.secondary) - Spacer() - Text(timeAgoString(from: item.timestamp)) - .font(.caption2) - .foregroundColor(.secondary) - } - Text(item.preview) - .font(.system(.body, design: .monospaced)) - .lineLimit(2) - } - .padding(.vertical, 2) - } - } header: { - Text("Current History") - } + Text("Configure how many recent clipboard items to remember (minimum 3).") } } } - .formStyle(.grouped) .navigationTitle("Clipboard") - .onAppear { - if enableClipboardManager && !clipboardManager.isMonitoring { - clipboardManager.startMonitoring() - } - } - } - - private func timeAgoString(from date: Date) -> String { - let interval = Date().timeIntervalSince(date) - - if interval < 60 { - return "Just now" - } else if interval < 3600 { - let minutes = Int(interval / 60) - return "\(minutes)m ago" - } else if interval < 86400 { - let hours = Int(interval / 3600) - return "\(hours)h ago" - } else { - let days = Int(interval / 86400) - return "\(days)d ago" - } } } diff --git a/DynamicIsland/helpers/AppIcons.swift b/DynamicIsland/helpers/AppIcons.swift index 1123b01..5941fc0 100644 --- a/DynamicIsland/helpers/AppIcons.swift +++ b/DynamicIsland/helpers/AppIcons.swift @@ -26,13 +26,6 @@ struct AppIcons { return getIcon(file: path) } - func getIcon(application: String) -> NSImage? { - guard let path = NSWorkspace.shared.fullPath(forApplication: application) - else { return nil } - - return getIcon(file: path) - } - /// Easily read Info.plist as a Dictionary from any bundle by accessing .infoDictionary on Bundle func bundle(forBundleID: String) -> Bundle? { guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: forBundleID) diff --git a/DynamicIsland/helpers/MediaChecker.swift b/DynamicIsland/helpers/MediaChecker.swift new file mode 100644 index 0000000..04d0c20 --- /dev/null +++ b/DynamicIsland/helpers/MediaChecker.swift @@ -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 + } +} diff --git a/DynamicIsland/managers/CalendarManager.swift b/DynamicIsland/managers/CalendarManager.swift index 7afc23e..a83906c 100644 --- a/DynamicIsland/managers/CalendarManager.swift +++ b/DynamicIsland/managers/CalendarManager.swift @@ -13,6 +13,8 @@ import Defaults @MainActor class CalendarManager: ObservableObject { + static let shared = CalendarManager() + @Published var currentWeekStartDate: Date @Published var events: [EventModel] = [] @Published var allCalendars: [CalendarModel] = [] @@ -20,7 +22,7 @@ class CalendarManager: ObservableObject { private var selectedCalendars: [CalendarModel] = [] private let calendarService = CalendarService() - init() { + private init() { self.currentWeekStartDate = CalendarManager.startOfDay(Date()) } @@ -33,7 +35,13 @@ class CalendarManager: ObservableObject { switch status { case .notDetermined: - self.authorizationStatus = await calendarService.requestAccess() ? .fullAccess : .denied + let granted = await calendarService.requestAccess() + self.authorizationStatus = granted ? .fullAccess : .denied + if granted { + allCalendars = await calendarService.calendars() + updateSelectedCalendars() + events = await calendarService.events(from: currentWeekStartDate, to: Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate)!, calendars: selectedCalendars.map{$0.id}) + } case .restricted, .denied: // Handle the case where the user has denied or restricted access NSLog("Calendar access denied or restricted") @@ -48,11 +56,11 @@ class CalendarManager: ObservableObject { print("Unknown authorization status") } } - + func updateSelectedCalendars() { selectedCalendars = allCalendars.filter { getCalendarSelected($0) } } - + func getCalendarSelected(_ calendar: CalendarModel) -> Bool { switch Defaults[.calendarSelectionState] { case .all: @@ -78,7 +86,7 @@ class CalendarManager: ObservableObject { } else { identifiers.remove(calendar.id) } - + selectionState = identifiers.isEmpty ? .all : identifiers.count == allCalendars.count ? .all : .selected(identifiers) // if empty, select all } @@ -86,11 +94,11 @@ class CalendarManager: ObservableObject { updateSelectedCalendars() events = await calendarService.events(from: currentWeekStartDate, to: Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate)!, calendars: selectedCalendars.map{$0.id}) } - + static func startOfDay(_ date: Date) -> Date { return Calendar.current.startOfDay(for: date) } - + func updateCurrentDate(_ date: Date) async { currentWeekStartDate = Calendar.current.startOfDay(for: date) events = await calendarService.events(from: currentWeekStartDate, to: Calendar.current.date(byAdding: .day, value: 1, to: currentWeekStartDate)!, calendars: selectedCalendars.map{$0.id}) diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index 9e7fc27..b7e9314 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -19,15 +19,11 @@ class MusicManager: ObservableObject { static let shared = MusicManager() private var cancellables = Set() private var controllerCancellables = Set() - private var debounceToggle: DispatchWorkItem? + private var debounceIdleTask: Task? - // Helper to check if macOS is too new for NowPlayingController - public var isNowPlayingDeprecated: Bool { - if #available(macOS 15.4, *) { - return true - } - return false - } + // Helper to check if macOS has removed support for NowPlayingController + public private(set) var isNowPlayingDeprecated: Bool = false + private let mediaChecker = MediaChecker() // Active controller private var activeController: (any MediaControllerProtocol)? @@ -48,6 +44,8 @@ class MusicManager: ObservableObject { @Published var elapsedTime: TimeInterval = 0 @Published var timestampDate: Date = .init() @Published var playbackRate: Double = 1 + @Published var isShuffled: Bool = false + @Published var repeatMode: RepeatMode = .off @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @Published var usingAppIconForArtwork: Bool = false @@ -67,13 +65,24 @@ class MusicManager: ObservableObject { self?.setActiveControllerBasedOnPreference() } .store(in: &cancellables) + + // Initialize deprecation check asynchronously + Task { @MainActor in + do { + self.isNowPlayingDeprecated = try await self.mediaChecker.checkDeprecationStatus() + print("Deprecation check completed: \(self.isNowPlayingDeprecated)") + } catch { + print("Failed to check deprecation status: \(error). Defaulting to false.") + self.isNowPlayingDeprecated = false + } - // Initialize the active controller - setActiveControllerBasedOnPreference() + // Initialize the active controller after deprecation check + self.setActiveControllerBasedOnPreference() + } } deinit { - debounceToggle?.cancel() + debounceIdleTask?.cancel() cancellables.removeAll() controllerCancellables.removeAll() flipWorkItem?.cancel() @@ -98,7 +107,17 @@ class MusicManager: ObservableObject { // Only create NowPlayingController if not deprecated on this macOS version if !self.isNowPlayingDeprecated { ignoreLastUpdated = false - newController = NowPlayingController() + if let controller = NowPlayingController() { + // Check if the controller can actually work + if controller.isWorking { + newController = controller + } else { + print("⚠️ NowPlayingController created but resources unavailable, falling back to default") + return nil + } + } else { + return nil + } } else { return nil } @@ -285,19 +304,20 @@ class MusicManager: ObservableObject { private func updateIdleState(state: Bool) { if state { isPlayerIdle = false - debounceToggle?.cancel() + debounceIdleTask?.cancel() } else { - debounceToggle = DispatchWorkItem { [weak self] in - guard let self = self else { return } - if self.lastUpdated.timeIntervalSinceNow < -Defaults[.waitInterval] { - withAnimation { - self.isPlayerIdle = !self.isPlaying + debounceIdleTask?.cancel() + debounceIdleTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(Defaults[.waitInterval])) + guard let self = self, !Task.isCancelled else { return } + await MainActor.run { + if self.lastUpdated.timeIntervalSinceNow < -Defaults[.waitInterval] { + withAnimation { + self.isPlayerIdle = !self.isPlaying + } } } } - - DispatchQueue.main.asyncAfter( - deadline: .now() + Defaults[.waitInterval], execute: debounceToggle!) } } @@ -338,31 +358,57 @@ class MusicManager: ObservableObject { // MARK: - Public Methods for controlling playback func playPause() { - activeController?.togglePlay() + Task { + await activeController?.togglePlay() + } } func play() { - activeController?.play() + Task { + await activeController?.play() + } } func pause() { - activeController?.pause() + Task { + await activeController?.pause() + } + } + + func toggleShuffle() { + Task { + await activeController?.toggleShuffle() + } + } + + func toggleRepeat() { + Task { + await activeController?.toggleRepeat() + } } func togglePlay() { - activeController?.togglePlay() + Task { + await activeController?.togglePlay() + } } func nextTrack() { - activeController?.nextTrack() + Task { + await activeController?.nextTrack() + } } func previousTrack() { - activeController?.previousTrack() + Task { + await activeController?.previousTrack() + } } func seek(to position: TimeInterval) { - activeController?.seek(to: position) + Task { + await activeController?.seek(to: position) + } } func openMusicApp() { @@ -388,9 +434,14 @@ class MusicManager: ObservableObject { func forceUpdate() { // Request immediate update from the active controller - DispatchQueue.main.async { [weak self] in + Task { [weak self] in if self?.activeController?.isActive() == true { - self?.activeController?.updatePlaybackInfo() + if type(of: self?.activeController) == YouTubeMusicController.self, + let youtubeController = self?.activeController as? YouTubeMusicController { + await youtubeController.pollPlaybackState() + } else { + await self?.activeController?.updatePlaybackInfo() + } } } } diff --git a/DynamicIsland/managers/NotchSpaceManager.swift b/DynamicIsland/managers/NotchSpaceManager.swift index c781179..4954a79 100644 --- a/DynamicIsland/managers/NotchSpaceManager.swift +++ b/DynamicIsland/managers/NotchSpaceManager.swift @@ -2,7 +2,7 @@ // NotchSpaceManager.swift // DynamicIsland // -// Created by Alexander Greco on 2024-10-27. +// Created by Alexander on 2024-10-27. // import Foundation diff --git a/DynamicIsland/models/BatteryStatusViewModel.swift b/DynamicIsland/models/BatteryStatusViewModel.swift index f439248..f8e9872 100644 --- a/DynamicIsland/models/BatteryStatusViewModel.swift +++ b/DynamicIsland/models/BatteryStatusViewModel.swift @@ -1,12 +1,12 @@ -import Foundation import Cocoa -import SwiftUI -import IOKit.ps import Defaults +import Foundation +import IOKit.ps +import SwiftUI /// A view model that manages and monitors the battery status of the device class BatteryStatusViewModel: ObservableObject { - + private var wasCharging: Bool = false private var powerSourceChangedCallback: IOPowerSourceCallbackType? private var runLoopSource: Unmanaged? @@ -22,14 +22,14 @@ class BatteryStatusViewModel: ObservableObject { @Published private(set) var isInitial: Bool = false @Published private(set) var timeToFullCharge: Int = 0 @Published private(set) var statusText: String = "" - + private let managerBattery = BatteryActivityManager.shared private var managerBatteryId: Int? - + static let shared = BatteryStatusViewModel() - /// Initializes the view model with a given DynamicIslandViewModel instance - /// - Parameter vm: The DynamicIslandViewModel instance + /// Initializes the view model with a given BoringViewModel instance + /// - Parameter vm: The BoringViewModel instance private init() { setupPowerStatus() setupMonitor() @@ -48,58 +48,61 @@ class BatteryStatusViewModel: ObservableObject { self.handleBatteryEvent(event) } } - + /// Handles battery events and updates the corresponding properties /// - Parameter event: The battery event to handle - private func handleBatteryEvent(_ event: BatteryActivityManager.BatteryEvent) { + private func handleBatteryEvent(_ event: BatteryActivityManager.BatteryEvent) { switch event { - case .powerSourceChanged(let isPluggedIn): - print("πŸ”Œ Power source: \(isPluggedIn ? "Connected" : "Disconnected")") - withAnimation { - self.isPluggedIn = isPluggedIn - self.statusText = isPluggedIn ? "Plugged In" : "Unplugged" - self.notifyImportanChangeStatus() - } - - case .batteryLevelChanged(let level): - print("πŸ”‹ Battery level: \(Int(level))%") - withAnimation { - self.levelBattery = level - } - - case .lowPowerModeChanged(let isEnabled): - print("⚑ Low power mode: \(isEnabled ? "Enabled" : "Disabled")") - self.notifyImportanChangeStatus() - withAnimation { - self.isInLowPowerMode = isEnabled - self.statusText = "Low Power: \(self.isInLowPowerMode ? "On" : "Off")" - } - - case .isChargingChanged(let isCharging): - print("πŸ”Œ Charging: \(isCharging ? "Yes" : "No")") - print("maxCapacity: \(self.maxCapacity)") - print("levelBattery: \(self.levelBattery)") - self.notifyImportanChangeStatus() - withAnimation { - self.isCharging = isCharging - self.statusText = isCharging ? "Charging battery" : (self.levelBattery < self.maxCapacity ? "Not charging" : "Full charge") - } - - case .timeToFullChargeChanged(let time): - print("πŸ•’ Time to full charge: \(time) minutes") - withAnimation { - self.timeToFullCharge = time - } - - case .maxCapacityChanged(let capacity): - print("πŸ”‹ Max capacity: \(capacity)") - withAnimation { - self.maxCapacity = capacity - } - - case .error(let description): - print("⚠️ Error: \(description)") + case .powerSourceChanged(let isPluggedIn): + print("πŸ”Œ Power source: \(isPluggedIn ? "Connected" : "Disconnected")") + withAnimation { + self.isPluggedIn = isPluggedIn + self.statusText = isPluggedIn ? "Plugged In" : "Unplugged" + self.notifyImportanChangeStatus() } + + case .batteryLevelChanged(let level): + print("πŸ”‹ Battery level: \(Int(level))%") + withAnimation { + self.levelBattery = level + } + + case .lowPowerModeChanged(let isEnabled): + print("⚑ Low power mode: \(isEnabled ? "Enabled" : "Disabled")") + self.notifyImportanChangeStatus() + withAnimation { + self.isInLowPowerMode = isEnabled + self.statusText = "Low Power: \(self.isInLowPowerMode ? "On" : "Off")" + } + + case .isChargingChanged(let isCharging): + print("πŸ”Œ Charging: \(isCharging ? "Yes" : "No")") + print("maxCapacity: \(self.maxCapacity)") + print("levelBattery: \(self.levelBattery)") + self.notifyImportanChangeStatus() + withAnimation { + self.isCharging = isCharging + self.statusText = + isCharging + ? "Charging battery" + : (self.levelBattery < self.maxCapacity ? "Not charging" : "Full charge") + } + + case .timeToFullChargeChanged(let time): + print("πŸ•’ Time to full charge: \(time) minutes") + withAnimation { + self.timeToFullCharge = time + } + + case .maxCapacityChanged(let capacity): + print("πŸ”‹ Max capacity: \(capacity)") + withAnimation { + self.maxCapacity = capacity + } + + case .error(let description): + print("⚠️ Error: \(description)") + } } /// Updates the battery information with the given BatteryInfo instance @@ -114,25 +117,13 @@ class BatteryStatusViewModel: ObservableObject { self.maxCapacity = batteryInfo.maxCapacity self.statusText = batteryInfo.isPluggedIn ? "Plugged In" : "Unplugged" } - notifyImportanChangeStatus(delay: coordinator.firstLaunch ? 6 : 0.0) - withAnimation { - if self.isCharging { - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - self.statusText = "Charging: Yes" - } - } - if self.isInLowPowerMode { - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - self.statusText = "Low Power: On" - } - } - } } - + /// Notifies important changes in the battery status with an optional delay /// - Parameter delay: The delay before notifying the change, default is 0.0 private func notifyImportanChangeStatus(delay: Double = 0.0) { - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { + Task { + try? await Task.sleep(for: .seconds(delay)) self.coordinator.toggleExpandingView(status: true, type: .battery) } } @@ -143,5 +134,5 @@ class BatteryStatusViewModel: ObservableObject { managerBattery.removeObserver(byId: managerBatteryId) } } - + } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 7a67952..0cb1b52 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -117,6 +117,7 @@ extension Defaults.Keys { static let sneakPeekStyles = Key("sneakPeekStyles", default: .standard) static let enableFullscreenMediaDetection = Key("enableFullscreenMediaDetection", default: true) static let waitInterval = Key("waitInterval", default: 3) + static let showShuffleAndRepeat = Key("showShuffleAndRepeat", default: false) // MARK: Battery static let showPowerStatusNotifications = Key("showPowerStatusNotifications", default: true) diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index a55cf2c..54c64f4 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -30,6 +30,10 @@ class DynamicIslandViewModel: NSObject, ObservableObject { @Published var isHoveringCalendar: Bool = false @Published var isBatteryPopoverActive: Bool = false @Published var isClipboardPopoverActive: Bool = false + + let webcamManager = WebcamManager.shared + @Published var isCameraExpanded: Bool = false + @Published var isRequestingAuthorization: Bool = false @Published var screen: String? @@ -172,4 +176,46 @@ class DynamicIslandViewModel: NSObject, ObservableObject { } } } + + func toggleCameraPreview() { + if isRequestingAuthorization { + return + } + + switch webcamManager.authorizationStatus { + case .authorized: + if webcamManager.isSessionRunning { + webcamManager.stopSession() + isCameraExpanded = false + } else if webcamManager.cameraAvailable { + webcamManager.startSession() + isCameraExpanded = true + } + + case .denied, .restricted: + DispatchQueue.main.async { + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + + let alert = NSAlert() + alert.messageText = "Camera Access Required" + alert.informativeText = "Please allow camera access in System Settings." + alert.addButton(withTitle: "OK") + alert.runModal() + + NSApp.setActivationPolicy(.accessory) + NSApp.deactivate() + } + + case .notDetermined: + isRequestingAuthorization = true + webcamManager.checkAndRequestVideoAuthorization() + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.isRequestingAuthorization = false + } + + default: + break + } + } } diff --git a/DynamicIsland/models/PlaybackState.swift b/DynamicIsland/models/PlaybackState.swift index d758cf7..baa3501 100644 --- a/DynamicIsland/models/PlaybackState.swift +++ b/DynamicIsland/models/PlaybackState.swift @@ -7,6 +7,12 @@ import Foundation +enum RepeatMode: Int, Codable { + case off = 1 + case one = 2 + case all = 3 +} + struct PlaybackState { var bundleIdentifier: String var isPlaying: Bool = false @@ -16,8 +22,8 @@ struct PlaybackState { var currentTime: Double = 0 var duration: Double = 0 var playbackRate: Double = 1 - var isShuffled: Bool? = nil - var isRepeating: Bool? = nil + var isShuffled: Bool = false + var repeatMode: RepeatMode = .off var lastUpdated: Date = Date.distantPast var artwork: Data? } diff --git a/DynamicIsland/observers/FullscreenMediaDetection.swift b/DynamicIsland/observers/FullscreenMediaDetection.swift index 3541758..abbc5a8 100644 --- a/DynamicIsland/observers/FullscreenMediaDetection.swift +++ b/DynamicIsland/observers/FullscreenMediaDetection.swift @@ -5,9 +5,6 @@ // Created by Richard Kunkli on 06/09/2024. // -import Accessibility -import Cocoa -import CoreAudio import Defaults import MacroVisionKit import SwiftUI @@ -16,15 +13,15 @@ import SwiftUI class FullscreenMediaDetector: ObservableObject { static let shared = FullscreenMediaDetector() private let detector: MacroVisionKit + @ObservedObject private var musicManager = MusicManager.shared - // changed from [NSScreen:Bool] to [screenName:Bool] @Published private(set) var fullscreenStatus: [String: Bool] = [:] private init() { self.detector = MacroVisionKit.shared detector.configuration.includeSystemApps = true setupNotificationObservers() - updateFullScreenStatus() // now on MainActor + updateFullScreenStatus() } private func setupNotificationObservers() { @@ -42,25 +39,20 @@ class FullscreenMediaDetector: ObservableObject { } private func updateFullScreenStatus() { - NSLog("πŸ”„ Fullscreen status update triggered") guard Defaults[.enableFullscreenMediaDetection] else { - NSLog("❌ Fullscreen media detection disabled") - let reset = Dictionary(uniqueKeysWithValues: NSScreen.screens.map { ($0.localizedName, false) } - ) + let reset = Dictionary(uniqueKeysWithValues: NSScreen.screens.map { ($0.localizedName, false) }) if reset != fullscreenStatus { fullscreenStatus = reset - NSLog("⏸ Fullscreen disabled β†’ reset all screens") } return } - NSLog("πŸ”„ Fullscreen media detection enabled") let apps = detector.detectFullscreenApps(debug: false) let names = NSScreen.screens.map { $0.localizedName } var newStatus: [String: Bool] = [:] for name in names { - newStatus[name] = apps.contains { $0.screen.localizedName == name && $0.bundleIdentifier != "com.apple.finder" } + newStatus[name] = apps.contains { $0.screen.localizedName == name && $0.bundleIdentifier != "com.apple.finder" && ($0.bundleIdentifier == musicManager.bundleIdentifier || Defaults[.hideNotchOption] == .always) } } if newStatus != fullscreenStatus { @@ -69,15 +61,6 @@ class FullscreenMediaDetector: ObservableObject { } } - func isScreenInFullScreen(_ screen: NSScreen) -> Bool { - let screenName = screen.localizedName - return fullscreenStatus[screenName] ?? false - } - - func isAnyScreenInFullScreen() -> Bool { - return fullscreenStatus.values.contains(true) - } - private func cleanupNotificationObservers() { NSWorkspace.shared.notificationCenter.removeObserver(self) } diff --git a/DynamicIsland/sizing/matters.swift b/DynamicIsland/sizing/matters.swift index f943f85..9d169b2 100644 --- a/DynamicIsland/sizing/matters.swift +++ b/DynamicIsland/sizing/matters.swift @@ -1,7 +1,9 @@ // -// sizeMatters.swift +// +// matters.swift // DynamicIsland // +// // Created by Harsh Vardhan Goswami on 05/08/24. // @@ -12,7 +14,7 @@ import SwiftUI let downloadSneakSize: CGSize = .init(width: 65, height: 1) let batterySneakSize: CGSize = .init(width: 160, height: 1) -let openNotchSize: CGSize = .init(width: 610, height: 200) +let openNotchSize: CGSize = .init(width: 640, height: 190) let cornerRadiusInsets: (opened: (top: CGFloat, bottom: CGFloat), closed: (top: CGFloat, bottom: CGFloat)) = (opened: (top: 19, bottom: 24), closed: (top: 6, bottom: 14)) enum MusicPlayerImageSizes { diff --git a/Frameworks/MediaRemoteAdapter.framework/MediaRemoteAdapter b/Frameworks/MediaRemoteAdapter.framework/MediaRemoteAdapter new file mode 120000 index 0000000..65b3513 --- /dev/null +++ b/Frameworks/MediaRemoteAdapter.framework/MediaRemoteAdapter @@ -0,0 +1 @@ +Versions/Current/MediaRemoteAdapter \ No newline at end of file diff --git a/Frameworks/MediaRemoteAdapter.framework/Resources b/Frameworks/MediaRemoteAdapter.framework/Resources new file mode 120000 index 0000000..953ee36 --- /dev/null +++ b/Frameworks/MediaRemoteAdapter.framework/Resources @@ -0,0 +1 @@ +Versions/Current/Resources \ No newline at end of file diff --git a/Frameworks/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter b/Frameworks/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter new file mode 100755 index 0000000..05db77d Binary files /dev/null and b/Frameworks/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter differ diff --git a/Frameworks/MediaRemoteAdapter.framework/Versions/A/Resources/Info.plist b/Frameworks/MediaRemoteAdapter.framework/Versions/A/Resources/Info.plist new file mode 100644 index 0000000..bf2b7b1 --- /dev/null +++ b/Frameworks/MediaRemoteAdapter.framework/Versions/A/Resources/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + MediaRemoteAdapter + CFBundleIconFile + + CFBundleIdentifier + com.vandenbe.MediaRemoteAdapter + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + MediaRemoteAdapter + CFBundlePackageType + FMWK + CFBundleSignature + ???? + CFBundleVersion + 0.1.0 + CFBundleShortVersionString + 0.1 + CSResourcesFileMapped + + + diff --git a/Frameworks/MediaRemoteAdapter.framework/Versions/A/_CodeSignature/CodeResources b/Frameworks/MediaRemoteAdapter.framework/Versions/A/_CodeSignature/CodeResources new file mode 100644 index 0000000..16ea63b --- /dev/null +++ b/Frameworks/MediaRemoteAdapter.framework/Versions/A/_CodeSignature/CodeResources @@ -0,0 +1,128 @@ + + + + + files + + Resources/Info.plist + + M6AF1VWVJ1A/DSliCSjg170FqsY= + + + files2 + + Resources/Info.plist + + hash2 + + z3yWmTAqjdrPJEZUQ+t6AVPhw0e/I8PAiVr0HIU2ivg= + + + + rules + + ^Resources/ + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^version.plist$ + + + rules2 + + .*\.dSYM($|/) + + weight + 11 + + ^(.*/)?\.DS_Store$ + + omit + + weight + 2000 + + ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ + + nested + + weight + 10 + + ^.* + + ^Info\.plist$ + + omit + + weight + 20 + + ^PkgInfo$ + + omit + + weight + 20 + + ^Resources/ + + weight + 20 + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^[^/]+$ + + nested + + weight + 10 + + ^embedded\.provisionprofile$ + + weight + 20 + + ^version\.plist$ + + weight + 20 + + + + diff --git a/Frameworks/MediaRemoteAdapter.framework/Versions/Current b/Frameworks/MediaRemoteAdapter.framework/Versions/Current new file mode 120000 index 0000000..8c7e5a6 --- /dev/null +++ b/Frameworks/MediaRemoteAdapter.framework/Versions/Current @@ -0,0 +1 @@ +A \ No newline at end of file diff --git a/mediaremote-adapter/MediaRemoteAdapter.framework/MediaRemoteAdapter b/mediaremote-adapter/MediaRemoteAdapter.framework/MediaRemoteAdapter new file mode 120000 index 0000000..65b3513 --- /dev/null +++ b/mediaremote-adapter/MediaRemoteAdapter.framework/MediaRemoteAdapter @@ -0,0 +1 @@ +Versions/Current/MediaRemoteAdapter \ No newline at end of file diff --git a/mediaremote-adapter/MediaRemoteAdapter.framework/Resources b/mediaremote-adapter/MediaRemoteAdapter.framework/Resources new file mode 120000 index 0000000..953ee36 --- /dev/null +++ b/mediaremote-adapter/MediaRemoteAdapter.framework/Resources @@ -0,0 +1 @@ +Versions/Current/Resources \ No newline at end of file diff --git a/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter b/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter new file mode 100755 index 0000000..05db77d Binary files /dev/null and b/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter differ diff --git a/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/Resources/Info.plist b/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/Resources/Info.plist new file mode 100644 index 0000000..bf2b7b1 --- /dev/null +++ b/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/Resources/Info.plist @@ -0,0 +1,28 @@ + + + + + CFBundleDevelopmentRegion + English + CFBundleExecutable + MediaRemoteAdapter + CFBundleIconFile + + CFBundleIdentifier + com.vandenbe.MediaRemoteAdapter + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + MediaRemoteAdapter + CFBundlePackageType + FMWK + CFBundleSignature + ???? + CFBundleVersion + 0.1.0 + CFBundleShortVersionString + 0.1 + CSResourcesFileMapped + + + diff --git a/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/_CodeSignature/CodeResources b/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/_CodeSignature/CodeResources new file mode 100644 index 0000000..16ea63b --- /dev/null +++ b/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/_CodeSignature/CodeResources @@ -0,0 +1,128 @@ + + + + + files + + Resources/Info.plist + + M6AF1VWVJ1A/DSliCSjg170FqsY= + + + files2 + + Resources/Info.plist + + hash2 + + z3yWmTAqjdrPJEZUQ+t6AVPhw0e/I8PAiVr0HIU2ivg= + + + + rules + + ^Resources/ + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^version.plist$ + + + rules2 + + .*\.dSYM($|/) + + weight + 11 + + ^(.*/)?\.DS_Store$ + + omit + + weight + 2000 + + ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ + + nested + + weight + 10 + + ^.* + + ^Info\.plist$ + + omit + + weight + 20 + + ^PkgInfo$ + + omit + + weight + 20 + + ^Resources/ + + weight + 20 + + ^Resources/.*\.lproj/ + + optional + + weight + 1000 + + ^Resources/.*\.lproj/locversion.plist$ + + omit + + weight + 1100 + + ^Resources/Base\.lproj/ + + weight + 1010 + + ^[^/]+$ + + nested + + weight + 10 + + ^embedded\.provisionprofile$ + + weight + 20 + + ^version\.plist$ + + weight + 20 + + + + diff --git a/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/Current b/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/Current new file mode 120000 index 0000000..8c7e5a6 --- /dev/null +++ b/mediaremote-adapter/MediaRemoteAdapter.framework/Versions/Current @@ -0,0 +1 @@ +A \ No newline at end of file diff --git a/mediaremote-adapter/NowPlayingTestClient b/mediaremote-adapter/NowPlayingTestClient new file mode 100755 index 0000000..750e43b Binary files /dev/null and b/mediaremote-adapter/NowPlayingTestClient differ diff --git a/mediaremote-adapter/mediaremote-adapter.pl b/mediaremote-adapter/mediaremote-adapter.pl new file mode 100755 index 0000000..29566e6 --- /dev/null +++ b/mediaremote-adapter/mediaremote-adapter.pl @@ -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: $@"; +}