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..d754eed 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -16,9 +16,30 @@ 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 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 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 +56,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + BA90505C953246D0AB206451 /* MediaRemoteAdapter.framework in Frameworks */, 7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */, 7E61181B2E24071E0050006C /* LottieUI in Frameworks */, 7E61181A2E24071E0050006C /* SwiftUIIntrospect in Frameworks */, @@ -50,9 +72,28 @@ /* End PBXFrameworksBuildPhase 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,8 +340,15 @@ 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_NSCalendarsUsageDescription = "Need for Calendar Mode"; INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( @@ -323,8 +374,15 @@ 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_NSCalendarsUsageDescription = "Need for Calendar Mode"; INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode"; INFOPLIST_KEY_NSHumanReadableCopyright = ""; LD_RUNPATH_SEARCH_PATHS = ( 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..a7c7d42 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -549,7 +549,7 @@ "Graph Visibility" : { }, - "Hide DynamicIsland Options" : { + "Hide BoringNotch Options" : { }, "Hide only when NowPlaying app is in fullscreen" : { @@ -648,6 +648,9 @@ }, "Media change with horizontal gestures" : { + }, + "Media controls" : { + }, "Media inactivity timeout" : { "localizations" : { @@ -955,9 +958,6 @@ } } } - }, - "Show calendar" : { - }, "Show charging indicator" : { "extractionState" : "stale", @@ -997,6 +997,9 @@ }, "Show on all displays" : { + }, + "Show shuffle and repeat buttons" : { + }, "Slider color" : { 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..69b754e 100644 --- a/DynamicIsland/MediaControllers/NowPlayingController.swift +++ b/DynamicIsland/MediaControllers/NowPlayingController.swift @@ -5,193 +5,224 @@ // 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") + else { + print("⚠️ Could not find mediaremote-adapter.pl script - NowPlaying controller disabled") + isResourcesAvailable = false + return + } + + let bundlePath = Bundle.main.bundlePath + let frameworkPath = bundlePath + "/Contents/Frameworks/MediaRemoteAdapter.framework" + + 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..7d0bde2 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 @@ -18,16 +18,14 @@ 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 +38,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 +50,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 +68,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 +90,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 +132,6 @@ struct SettingsView: View { .disabled(true) } .environmentObject(extensionManager) - .environmentObject(calendarManager) .formStyle(.grouped) .frame(width: 700) .background(Color(NSColor.windowBackgroundColor)) @@ -158,8 +155,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 +184,7 @@ struct GeneralSettings: View { } header: { Text("System features") } - + Section { Picker(selection: $notchHeightMode, label: Text("Notch display height")) { @@ -248,9 +244,9 @@ struct GeneralSettings: View { } header: { Text("Notch Height") } - + NotchBehaviour() - + gestureControls() } .toolbar { @@ -266,7 +262,7 @@ struct GeneralSettings: View { } } } - + @ViewBuilder func gestureControls() -> some View { Section { @@ -297,7 +293,7 @@ struct GeneralSettings: View { .font(.caption) } } - + @ViewBuilder func NotchBehaviour() -> some View { Section { @@ -369,7 +365,7 @@ struct Downloads: View { Text("Both") .tag(DownloadIconStyle.iconAndAppIcon) } - + } header: { HStack { Text("Download indicators") @@ -397,7 +393,7 @@ struct Downloads: View { .contentShape(Rectangle()) .foregroundStyle(.secondary) } - + Divider() Button {} label: { Image(systemName: "minus") @@ -470,7 +466,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 +499,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 +536,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 +561,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 +577,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 +591,7 @@ struct CalendarSettings: View { )) { Text(calendar.title) } + .disabled(!showCalendar) } } } @@ -603,6 +607,587 @@ struct CalendarSettings: View { } } +struct About: View { + @State private var showBuildNumber: Bool = false + let updaterController: SPUStandardUpdaterController + @Environment(\.openWindow) var openWindow + var body: some View { + VStack { + Form { + Section { + HStack { + Text("Release name") + Spacer() + Text(Defaults[.releaseName]) + .foregroundStyle(.secondary) + } + HStack { + Text("Version") + Spacer() + if showBuildNumber { + Text("(\(Bundle.main.buildVersionNumber ?? ""))") + .foregroundStyle(.secondary) + } + Text(Bundle.main.releaseVersionNumber ?? "unkown") + .foregroundStyle(.secondary) + } + .onTapGesture { + withAnimation { + showBuildNumber.toggle() + } + } + } header: { + Text("Version info") + } + + UpdaterSettingsView(updater: updaterController.updater) + + HStack(spacing: 30) { + Spacer(minLength: 0) + Button { + NSWorkspace.shared.open(sponsorPage) + } label: { + VStack(spacing: 5) { + Image(systemName: "cup.and.saucer.fill") + .imageScale(.large) + Text("Support Us") + .foregroundStyle(.white) + } + .contentShape(Rectangle()) + } + Spacer(minLength: 0) + Button { + NSWorkspace.shared.open(productPage) + } label: { + VStack(spacing: 5) { + Image("Github") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 18) + Text("GitHub") + .foregroundStyle(.white) + } + .contentShape(Rectangle()) + } + Spacer(minLength: 0) + } + .buttonStyle(PlainButtonStyle()) + } + VStack(spacing: 0) { + Divider() + Text("Made with 🫶🏻 by not so boring not.people") + .foregroundStyle(.secondary) + .padding(.top, 5) + .padding(.bottom, 7) + .multilineTextAlignment(.center) + .padding(.horizontal, 10) + } + .frame(maxWidth: .infinity, alignment: .center) + } + .toolbar { +// Button("Welcome window") { +// openWindow(id: "onboarding") +// } +// .controlSize(.extraLarge) + CheckForUpdatesView(updater: updaterController.updater) + } + .navigationTitle("About") + } +} + +struct Shelf: View { + var body: some View { + Form { + Section { + Defaults.Toggle("Enable shelf", key: .dynamicShelf) + Defaults.Toggle("Open shelf tab by default if items added", key: .openShelfByDefault) + } header: { + HStack { + Text("General") + } + } + } + .navigationTitle("Shelf") + } +} + +struct Extensions: View { + @EnvironmentObject var extensionManager: DynamicIslandExtensionManager + @State private var effectTrigger: Bool = false + var body: some View { + Form { + //warningBadge("We don't support extensions yet") // Uhhhh You do? <><><> Oori.S + Section { + List { + ForEach(extensionManager.installedExtensions.indices, id: \.self) { index in + let item = extensionManager.installedExtensions[index] + HStack { + AppIcon(for: item.bundleIdentifier) + .resizable() + .frame(width: 24, height: 24) + Text(item.name) + ListItemPopover { + Text("Description") + } + Spacer(minLength: 0) + HStack(spacing: 6) { + Circle() + .frame(width: 6, height: 6) + .foregroundColor(isExtensionRunning(item.bundleIdentifier) ? .green : item.status == .disabled ? .gray : .red) + .conditionalModifier(isExtensionRunning(item.bundleIdentifier)) { view in + view + .shadow(color: .green, radius: 3) + } + Text(isExtensionRunning(item.bundleIdentifier) ? "Running" : item.status == .disabled ? "Disabled" : "Stopped") + .contentTransition(.numericText()) + .foregroundStyle(.secondary) + .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) + } + } + .keyboardShortcut("R", modifiers: .command) + Button("Disable") { + if let ext = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == item.bundleIdentifier }) { + ext.terminate() + } + extensionManager.installedExtensions[index].status = .disabled + } + .keyboardShortcut("D", modifiers: .command) + Divider() + Button("Uninstall", role: .destructive) { + // + } + }, label: { + Image(systemName: "ellipsis.circle") + .foregroundStyle(.secondary) + }) + .controlSize(.regular) + } + .buttonStyle(PlainButtonStyle()) + .padding(.vertical, 5) + } + } + .frame(minHeight: 120) + .actionBar { + Button {} label: { + HStack(spacing: 3) { + Image(systemName: "plus") + Text("Add manually") + } + .foregroundStyle(.secondary) + } + .disabled(true) + Spacer() + Button { + withAnimation(.linear(duration: 1)) { + effectTrigger.toggle() + } completion: { + effectTrigger.toggle() + } + extensionManager.checkIfExtensionsAreInstalled() + } label: { + HStack(spacing: 3) { + Image(systemName: "arrow.triangle.2.circlepath") + .rotationEffect(effectTrigger ? .degrees(360) : .zero) + } + .foregroundStyle(.secondary) + } + } + .controlSize(.small) + .buttonStyle(PlainButtonStyle()) + .overlay { + if extensionManager.installedExtensions.isEmpty { + Text("No extension installed") + .foregroundStyle(Color(.secondaryLabelColor)) + .padding(.bottom, 22) + } + } + } header: { + HStack(spacing: 0) { + Text("Installed extensions") + if !extensionManager.installedExtensions.isEmpty { + Text(" – \(extensionManager.installedExtensions.count)") + .foregroundStyle(.secondary) + } + } + } + } + .navigationTitle("Extensions") + // TipsView() + // .padding(.horizontal, 19) + } +} + +struct Appearance: View { + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @Default(.mirrorShape) var mirrorShape + @Default(.sliderColor) var sliderColor + @Default(.useMusicVisualizer) var useMusicVisualizer + @Default(.customVisualizers) var customVisualizers + @Default(.selectedVisualizer) var selectedVisualizer + 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 = "" + @State private var speed: CGFloat = 1.0 + var body: some View { + Form { + Section { + Toggle("Always show tabs", isOn: $coordinator.alwaysShowTabs) + Defaults.Toggle("Settings icon in notch", key: .settingsIconInNotch) + Defaults.Toggle("Enable window shadow", key: .enableShadow) + Defaults.Toggle("Corner radius scaling", key: .cornerRadiusScaling) + Defaults.Toggle("Use simpler close animation", key: .useModernCloseAnimation) + } header: { + Text("General") + } + + Section { + Defaults.Toggle("Enable colored spectrograms", key: .coloredSpectrogram) + Defaults + .Toggle("Player tinting", key: .playerColorTinting) + Defaults.Toggle("Enable blur effect behind album art", key: .lightingEffect) + Picker("Slider color", selection: $sliderColor) { + ForEach(SliderColorEnum.allCases, id: \.self) { option in + Text(option.rawValue) + } + } + } header: { + Text("Media") + } + + Section { + Toggle( + "Use music visualizer spectrogram", + isOn: $useMusicVisualizer.animation() + ) + .disabled(true) + if !useMusicVisualizer { + if customVisualizers.count > 0 { + Picker( + "Selected animation", + selection: $selectedVisualizer + ) { + ForEach( + customVisualizers, + id: \.self + ) { visualizer in + Text(visualizer.name) + .tag(visualizer) + } + } + } else { + HStack { + Text("Selected animation") + Spacer() + Text("No custom animation available") + .foregroundStyle(.secondary) + } + } + } + } header: { + HStack { + Text("Custom music live activity animation") + customBadge(text: "Coming soon") + } + } + + Section { + List { + ForEach(customVisualizers, id: \.self) { visualizer in + HStack { + LottieView(state: LUStateData(type: .loadedFrom(visualizer.url), speed: visualizer.speed, loopMode: .loop)) + .frame(width: 30, height: 30, alignment: .center) + Text(visualizer.name) + Spacer(minLength: 0) + if selectedVisualizer == visualizer { + Text("selected") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.secondary) + .padding(.trailing, 8) + } + } + .buttonStyle(PlainButtonStyle()) + .padding(.vertical, 2) + .background( + selectedListVisualizer != nil ? selectedListVisualizer == visualizer ? Color.accentColor : Color.clear : Color.clear, + in: RoundedRectangle(cornerRadius: 5) + ) + .contentShape(Rectangle()) + .onTapGesture { + if selectedListVisualizer == visualizer { + selectedListVisualizer = nil + return + } + selectedListVisualizer = visualizer + } + } + } + .safeAreaPadding( + EdgeInsets(top: 5, leading: 0, bottom: 5, trailing: 0) + ) + .frame(minHeight: 120) + .actionBar { + HStack(spacing: 5) { + Button { + name = "" + url = "" + speed = 1.0 + isPresented.toggle() + } label: { + Image(systemName: "plus") + .foregroundStyle(.secondary) + .contentShape(Rectangle()) + } + Divider() + Button { + if selectedListVisualizer != nil { + let visualizer = selectedListVisualizer! + selectedListVisualizer = nil + customVisualizers.remove(at: customVisualizers.firstIndex(of: visualizer)!) + if visualizer == selectedVisualizer && customVisualizers.count > 0 { + selectedVisualizer = customVisualizers[0] + } + } + } label: { + Image(systemName: "minus") + .foregroundStyle(.secondary) + .contentShape(Rectangle()) + } + } + } + .controlSize(.small) + .buttonStyle(PlainButtonStyle()) + .overlay { + if customVisualizers.isEmpty { + Text("No custom visualizer") + .foregroundStyle(Color(.secondaryLabelColor)) + .padding(.bottom, 22) + } + } + .sheet(isPresented: $isPresented) { + VStack(alignment: .leading) { + Text("Add new visualizer") + .font(.largeTitle.bold()) + .padding(.vertical) + TextField("Name", text: $name) + TextField("Lottie JSON URL", text: $url) + HStack { + Text("Speed") + Spacer(minLength: 80) + Text("\(speed, specifier: "%.1f")s") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + Slider(value: $speed, in: 0...2, step: 0.1) + } + .padding(.vertical) + HStack { + Button { + isPresented.toggle() + } label: { + Text("Cancel") + .frame(maxWidth: .infinity, alignment: .center) + } + + Button { + let visualizer: CustomVisualizer = .init( + UUID: UUID(), + name: name, + url: URL(string: url)!, + speed: speed + ) + + if !customVisualizers.contains(visualizer) { + customVisualizers.append(visualizer) + } + + isPresented.toggle() + } label: { + Text("Add") + .frame(maxWidth: .infinity, alignment: .center) + } + .buttonStyle(BorderedProminentButtonStyle()) + } + } + .textFieldStyle(RoundedBorderTextFieldStyle()) + .controlSize(.extraLarge) + .padding() + } + } header: { + HStack(spacing: 0) { + Text("Custom vizualizers (Lottie)") + if !Defaults[.customVisualizers].isEmpty { + Text(" – \(Defaults[.customVisualizers].count)") + .foregroundStyle(.secondary) + } + } + } + + Section { + Defaults.Toggle("Enable boring mirror", key: .showMirror) + .disabled(!checkVideoInput()) + Picker("Mirror shape", selection: $mirrorShape) { + Text("Circle") + .tag(MirrorShapeEnum.circle) + Text("Square") + .tag(MirrorShapeEnum.rectangle) + } + Defaults.Toggle("Show cool face animation while inactivity", key: .showNotHumanFace) + } header: { + HStack { + Text("Additional features") + } + } + + Section { + HStack { + ForEach(icons, id: \.self) { icon in + Spacer() + VStack { + Image(icon) + .resizable() + .frame(width: 80, height: 80) + .background( + RoundedRectangle(cornerRadius: 20, style: .circular) + .strokeBorder( + icon == selectedIcon ? Color.accentColor : .clear, + lineWidth: 2.5 + ) + ) + + Text("Default") + .fontWeight(.medium) + .font(.caption) + .foregroundStyle(icon == selectedIcon ? .white : .secondary) + .padding(.horizontal, 10) + .padding(.vertical, 3) + .background( + Capsule() + .fill(icon == selectedIcon ? Color.accentColor : .clear) + ) + } + .onTapGesture { + withAnimation { + selectedIcon = icon + } + NSApp.applicationIconImage = NSImage(named: icon) + } + Spacer() + } + } + .disabled(true) + } header: { + HStack { + Text("App icon") + customBadge(text: "Coming soon") + } + } + } + .navigationTitle("Appearance") + } + + func checkVideoInput() -> Bool { + if let _ = AVCaptureDevice.default(for: .video) { + return true + } + + return false + } +} + +struct Shortcuts: View { + var body: some View { + Form { + Section { + KeyboardShortcuts.Recorder("Toggle Sneak Peek:", name: .toggleSneakPeek) + } header: { + Text("Media") + } footer: { + Text("Sneak Peek shows the media title and artist under the notch for a few seconds.") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .font(.caption) + } + 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") + } +} + +func proFeatureBadge() -> some View { + Text("Upgrade to Pro") + .foregroundStyle(Color(red: 0.545, green: 0.196, blue: 0.98)) + .font(.footnote.bold()) + .padding(.vertical, 3) + .padding(.horizontal, 6) + .background(RoundedRectangle(cornerRadius: 4).stroke(Color(red: 0.545, green: 0.196, blue: 0.98), lineWidth: 1)) +} + +func comingSoonTag() -> some View { + Text("Coming soon") + .foregroundStyle(.secondary) + .font(.footnote.bold()) + .padding(.vertical, 3) + .padding(.horizontal, 6) + .background(Color(nsColor: .secondarySystemFill)) + .clipShape(.capsule) +} + +func customBadge(text: String) -> some View { + Text(text) + .foregroundStyle(.secondary) + .font(.footnote.bold()) + .padding(.vertical, 3) + .padding(.horizontal, 6) + .background(Color(nsColor: .secondarySystemFill)) + .clipShape(.capsule) +} + +func warningBadge(_ text: String, _ description: String) -> some View { + Section { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 22)) + .foregroundStyle(.yellow) + VStack(alignment: .leading) { + Text(text) + .font(.headline) + Text(description) + .foregroundStyle(.secondary) + } + Spacer() + } + } +} + struct TimerSettings: View { @ObservedObject private var coordinator = DynamicIslandViewCoordinator.shared @Default(.enableTimerFeature) var enableTimerFeature @@ -754,542 +1339,7 @@ struct TimerSettings: View { } } -struct About: View { - @State private var showBuildNumber: Bool = false - let updaterController: SPUStandardUpdaterController - @Environment(\.openWindow) var openWindow - var body: some View { - VStack { - Form { - Section { - HStack { - Text("Release name") - Spacer() - Text(Defaults[.releaseName]) - .foregroundStyle(.secondary) - } - HStack { - Text("Version") - Spacer() - if showBuildNumber { - Text("(\(Bundle.main.buildVersionNumber ?? ""))") - .foregroundStyle(.secondary) - } - Text(Bundle.main.releaseVersionNumber ?? "unkown") - .foregroundStyle(.secondary) - } - .onTapGesture { - withAnimation { - showBuildNumber.toggle() - } - } - } header: { - Text("Version info") - } - - UpdaterSettingsView(updater: updaterController.updater) - - HStack(spacing: 30) { - Spacer(minLength: 0) - Button { - NSWorkspace.shared.open(sponsorPage) - } label: { - VStack(spacing: 5) { - Image(systemName: "cup.and.saucer.fill") - .imageScale(.large) - Text("Support Us") - .foregroundStyle(.white) - } - .contentShape(Rectangle()) - } - Spacer(minLength: 0) - Button { - NSWorkspace.shared.open(productPage) - } label: { - VStack(spacing: 5) { - Image("Github") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 18) - Text("GitHub") - .foregroundStyle(.white) - } - .contentShape(Rectangle()) - } - Spacer(minLength: 0) - } - .buttonStyle(PlainButtonStyle()) - } - VStack(spacing: 0) { - Divider() - Text("Made with 🫶🏻 by not so boring not.people") - .foregroundStyle(.secondary) - .padding(.top, 5) - .padding(.bottom, 7) - .multilineTextAlignment(.center) - .padding(.horizontal, 10) - } - .frame(maxWidth: .infinity, alignment: .center) - } - .toolbar { -// Button("Welcome window") { -// openWindow(id: "onboarding") -// } -// .controlSize(.extraLarge) - CheckForUpdatesView(updater: updaterController.updater) - } - .navigationTitle("About") - } -} - -struct Shelf: View { - var body: some View { - Form { - Section { - Defaults.Toggle("Enable shelf", key: .dynamicShelf) - Defaults.Toggle("Open shelf tab by default if items added", key: .openShelfByDefault) - } header: { - HStack { - Text("General") - } - } - } - .navigationTitle("Shelf") - } -} - -struct Extensions: View { - @EnvironmentObject var extensionManager: DynamicIslandExtensionManager - @State private var effectTrigger: Bool = false - var body: some View { - Form { - //warningBadge("We don't support extensions yet") // Uhhhh You do? <><><> Oori.S - Section { - List { - ForEach(extensionManager.installedExtensions.indices, id: \.self) { index in - let item = extensionManager.installedExtensions[index] - HStack { - AppIcon(for: item.bundleIdentifier) - .resizable() - .frame(width: 24, height: 24) - Text(item.name) - ListItemPopover { - Text("Description") - } - Spacer(minLength: 0) - HStack(spacing: 6) { - Circle() - .frame(width: 6, height: 6) - .foregroundColor(isExtensionRunning(item.bundleIdentifier) ? .green : item.status == .disabled ? .gray : .red) - .conditionalModifier(isExtensionRunning(item.bundleIdentifier)) { view in - view - .shadow(color: .green, radius: 3) - } - Text(isExtensionRunning(item.bundleIdentifier) ? "Running" : item.status == .disabled ? "Disabled" : "Stopped") - .contentTransition(.numericText()) - .foregroundStyle(.secondary) - .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) - } - } - .keyboardShortcut("R", modifiers: .command) - Button("Disable") { - if let ext = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == item.bundleIdentifier }) { - ext.terminate() - } - extensionManager.installedExtensions[index].status = .disabled - } - .keyboardShortcut("D", modifiers: .command) - Divider() - Button("Uninstall", role: .destructive) { - // - } - }, label: { - Image(systemName: "ellipsis.circle") - .foregroundStyle(.secondary) - }) - .controlSize(.regular) - } - .buttonStyle(PlainButtonStyle()) - .padding(.vertical, 5) - } - } - .frame(minHeight: 120) - .actionBar { - Button {} label: { - HStack(spacing: 3) { - Image(systemName: "plus") - Text("Add manually") - } - .foregroundStyle(.secondary) - } - .disabled(true) - Spacer() - Button { - withAnimation(.linear(duration: 1)) { - effectTrigger.toggle() - } completion: { - effectTrigger.toggle() - } - extensionManager.checkIfExtensionsAreInstalled() - } label: { - HStack(spacing: 3) { - Image(systemName: "arrow.triangle.2.circlepath") - .rotationEffect(effectTrigger ? .degrees(360) : .zero) - } - .foregroundStyle(.secondary) - } - } - .controlSize(.small) - .buttonStyle(PlainButtonStyle()) - .overlay { - if extensionManager.installedExtensions.isEmpty { - Text("No extension installed") - .foregroundStyle(Color(.secondaryLabelColor)) - .padding(.bottom, 22) - } - } - } header: { - HStack(spacing: 0) { - Text("Installed extensions") - if !extensionManager.installedExtensions.isEmpty { - Text(" – \(extensionManager.installedExtensions.count)") - .foregroundStyle(.secondary) - } - } - } - } - .navigationTitle("Extensions") - // TipsView() - // .padding(.horizontal, 19) - } -} - -struct Appearance: View { - @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared - @Default(.mirrorShape) var mirrorShape - @Default(.sliderColor) var sliderColor - @Default(.useMusicVisualizer) var useMusicVisualizer - @Default(.customVisualizers) var customVisualizers - @Default(.selectedVisualizer) var selectedVisualizer - 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 = "" - @State private var speed: CGFloat = 1.0 - var body: some View { - Form { - Section { - Toggle("Always show tabs", isOn: $coordinator.alwaysShowTabs) - Defaults.Toggle("Settings icon in notch", key: .settingsIconInNotch) - Defaults.Toggle("Enable window shadow", key: .enableShadow) - Defaults.Toggle("Corner radius scaling", key: .cornerRadiusScaling) - Defaults.Toggle("Use simpler close animation", key: .useModernCloseAnimation) - } header: { - Text("General") - } - - Section { - Defaults.Toggle("Enable colored spectrograms", key: .coloredSpectrogram) - Defaults - .Toggle("Player tinting", key: .playerColorTinting) - Defaults.Toggle("Enable blur effect behind album art", key: .lightingEffect) - Picker("Slider color", selection: $sliderColor) { - ForEach(SliderColorEnum.allCases, id: \.self) { option in - Text(option.rawValue) - } - } - } header: { - Text("Media") - } - - Section { - Toggle( - "Use music visualizer spectrogram", - isOn: $useMusicVisualizer.animation() - ) - .disabled(true) - if !useMusicVisualizer { - if customVisualizers.count > 0 { - Picker( - "Selected animation", - selection: $selectedVisualizer - ) { - ForEach( - customVisualizers, - id: \.self - ) { visualizer in - Text(visualizer.name) - .tag(visualizer) - } - } - } else { - HStack { - Text("Selected animation") - Spacer() - Text("No custom animation available") - .foregroundStyle(.secondary) - } - } - } - } header: { - HStack { - Text("Custom music live activity animation") - customBadge(text: "Coming soon") - } - } - - Section { - List { - ForEach(customVisualizers, id: \.self) { visualizer in - HStack { - LottieView(state: LUStateData(type: .loadedFrom(visualizer.url), speed: visualizer.speed, loopMode: .loop)) - .frame(width: 30, height: 30, alignment: .center) - Text(visualizer.name) - Spacer(minLength: 0) - if selectedVisualizer == visualizer { - Text("selected") - .font(.caption) - .fontWeight(.medium) - .foregroundStyle(.secondary) - .padding(.trailing, 8) - } - } - .buttonStyle(PlainButtonStyle()) - .padding(.vertical, 2) - .background( - selectedListVisualizer != nil ? selectedListVisualizer == visualizer ? Color.accentColor : Color.clear : Color.clear, - in: RoundedRectangle(cornerRadius: 5) - ) - .contentShape(Rectangle()) - .onTapGesture { - if selectedListVisualizer == visualizer { - selectedListVisualizer = nil - return - } - selectedListVisualizer = visualizer - } - } - } - .safeAreaPadding( - EdgeInsets(top: 5, leading: 0, bottom: 5, trailing: 0) - ) - .frame(minHeight: 120) - .actionBar { - HStack(spacing: 5) { - Button { - name = "" - url = "" - speed = 1.0 - isPresented.toggle() - } label: { - Image(systemName: "plus") - .foregroundStyle(.secondary) - .contentShape(Rectangle()) - } - Divider() - Button { - if selectedListVisualizer != nil { - let visualizer = selectedListVisualizer! - selectedListVisualizer = nil - customVisualizers.remove(at: customVisualizers.firstIndex(of: visualizer)!) - if visualizer == selectedVisualizer && customVisualizers.count > 0 { - selectedVisualizer = customVisualizers[0] - } - } - } label: { - Image(systemName: "minus") - .foregroundStyle(.secondary) - .contentShape(Rectangle()) - } - } - } - .controlSize(.small) - .buttonStyle(PlainButtonStyle()) - .overlay { - if customVisualizers.isEmpty { - Text("No custom visualizer") - .foregroundStyle(Color(.secondaryLabelColor)) - .padding(.bottom, 22) - } - } - .sheet(isPresented: $isPresented) { - VStack(alignment: .leading) { - Text("Add new visualizer") - .font(.largeTitle.bold()) - .padding(.vertical) - TextField("Name", text: $name) - TextField("Lottie JSON URL", text: $url) - HStack { - Text("Speed") - Spacer(minLength: 80) - Text("\(speed, specifier: "%.1f")s") - .multilineTextAlignment(.trailing) - .foregroundStyle(.secondary) - Slider(value: $speed, in: 0...2, step: 0.1) - } - .padding(.vertical) - HStack { - Button { - isPresented.toggle() - } label: { - Text("Cancel") - .frame(maxWidth: .infinity, alignment: .center) - } - - Button { - let visualizer: CustomVisualizer = .init( - UUID: UUID(), - name: name, - url: URL(string: url)!, - speed: speed - ) - - if !customVisualizers.contains(visualizer) { - customVisualizers.append(visualizer) - } - - isPresented.toggle() - } label: { - Text("Add") - .frame(maxWidth: .infinity, alignment: .center) - } - .buttonStyle(BorderedProminentButtonStyle()) - } - } - .textFieldStyle(RoundedBorderTextFieldStyle()) - .controlSize(.extraLarge) - .padding() - } - } header: { - HStack(spacing: 0) { - Text("Custom vizualizers (Lottie)") - if !Defaults[.customVisualizers].isEmpty { - Text(" – \(Defaults[.customVisualizers].count)") - .foregroundStyle(.secondary) - } - } - } - - Section { - Defaults.Toggle("Enable boring mirror", key: .showMirror) - .disabled(!checkVideoInput()) - Picker("Mirror shape", selection: $mirrorShape) { - Text("Circle") - .tag(MirrorShapeEnum.circle) - Text("Square") - .tag(MirrorShapeEnum.rectangle) - } - Defaults.Toggle("Show cool face animation while inactivity", key: .showNotHumanFace) - } header: { - HStack { - Text("Additional features") - } - } - - Section { - HStack { - ForEach(icons, id: \.self) { icon in - Spacer() - VStack { - Image(icon) - .resizable() - .frame(width: 80, height: 80) - .background( - RoundedRectangle(cornerRadius: 20, style: .circular) - .strokeBorder( - icon == selectedIcon ? Color.accentColor : .clear, - lineWidth: 2.5 - ) - ) - - Text("Default") - .fontWeight(.medium) - .font(.caption) - .foregroundStyle(icon == selectedIcon ? .white : .secondary) - .padding(.horizontal, 10) - .padding(.vertical, 3) - .background( - Capsule() - .fill(icon == selectedIcon ? Color.accentColor : .clear) - ) - } - .onTapGesture { - withAnimation { - selectedIcon = icon - } - NSApp.applicationIconImage = NSImage(named: icon) - } - Spacer() - } - } - .disabled(true) - } header: { - HStack { - Text("App icon") - customBadge(text: "Coming soon") - } - } - } - .navigationTitle("Appearance") - } - - func checkVideoInput() -> Bool { - if let _ = AVCaptureDevice.default(for: .video) { - return true - } - - return false - } -} - -struct Shortcuts: View { - var body: some View { - Form { - Section { - KeyboardShortcuts.Recorder("Toggle Sneak Peek:", name: .toggleSneakPeek) - } header: { - Text("Media") - } footer: { - Text("Sneak Peek shows the media title and artist under the notch for a few seconds.") - .multilineTextAlignment(.trailing) - .foregroundStyle(.secondary) - .font(.caption) - } - 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 { +struct StatsSettings: View { @ObservedObject var statsManager = StatsManager.shared @Default(.enableStatsFeature) var enableStatsFeature @Default(.showCpuGraph) var showCpuGraph @@ -1458,52 +1508,6 @@ struct Stats: View { } } -func proFeatureBadge() -> some View { - Text("Upgrade to Pro") - .foregroundStyle(Color(red: 0.545, green: 0.196, blue: 0.98)) - .font(.footnote.bold()) - .padding(.vertical, 3) - .padding(.horizontal, 6) - .background(RoundedRectangle(cornerRadius: 4).stroke(Color(red: 0.545, green: 0.196, blue: 0.98), lineWidth: 1)) -} - -func comingSoonTag() -> some View { - Text("Coming soon") - .foregroundStyle(.secondary) - .font(.footnote.bold()) - .padding(.vertical, 3) - .padding(.horizontal, 6) - .background(Color(nsColor: .secondarySystemFill)) - .clipShape(.capsule) -} - -func customBadge(text: String) -> some View { - Text(text) - .foregroundStyle(.secondary) - .font(.footnote.bold()) - .padding(.vertical, 3) - .padding(.horizontal, 6) - .background(Color(nsColor: .secondarySystemFill)) - .clipShape(.capsule) -} - -func warningBadge(_ text: String, _ description: String) -> some View { - Section { - HStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 22)) - .foregroundStyle(.yellow) - VStack(alignment: .leading) { - Text(text) - .font(.headline) - Text(description) - .foregroundStyle(.secondary) - } - Spacer() - } - } -} - struct ClipboardSettings: View { @ObservedObject var clipboardManager = ClipboardManager.shared @Default(.enableClipboardManager) var enableClipboardManager @@ -1514,7 +1518,7 @@ struct ClipboardSettings: View { Form { Section { Defaults.Toggle("Enable Clipboard Manager", key: .enableClipboardManager) - .onChange(of: enableClipboardManager) { enabled in + .onChange(of: enableClipboardManager) { _, enabled in if enabled { clipboardManager.startMonitoring() } else { 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..cf141ca 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 } @@ -246,6 +265,15 @@ class MusicManager: ObservableObject { self.bundleIdentifier = state.bundleIdentifier } + // Update shuffle and repeat state from controller + if state.isShuffled != self.isShuffled { + self.isShuffled = state.isShuffled + } + + if state.repeatMode != self.repeatMode { + self.repeatMode = state.repeatMode + } + self.timestampDate = state.lastUpdated } @@ -285,19 +313,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 +367,82 @@ 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 { + // Provide immediate UI feedback + await MainActor.run { + self.isShuffled.toggle() + } + + // Execute the actual command + await activeController?.toggleShuffle() + + // Note: The actual state will be updated when the controller reports back + // via updateFromPlaybackState if the command provides status feedback + } + } + + func toggleRepeat() { + Task { + // Provide immediate UI feedback + await MainActor.run { + switch self.repeatMode { + case .off: + self.repeatMode = .all + case .all: + self.repeatMode = .one + case .one: + self.repeatMode = .off + } + } + + // Execute the actual command + await activeController?.toggleRepeat() + + // Note: The actual state will be updated when the controller reports back + // via updateFromPlaybackState if the command provides status feedback + } } 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 +468,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/SettingsView.swift.md b/SettingsView.swift.md new file mode 100644 index 0000000..d8817e4 --- /dev/null +++ b/SettingsView.swift.md @@ -0,0 +1,1635 @@ +// +// SettingsView.swift +// DynamicIsland +// +// Created by Richard Kunkli on 07/08/2024. +// Modified by Hariharan Mudaliar + +import AVFoundation +import Defaults +import EventKit +import KeyboardShortcuts +import LaunchAtLogin +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) { + NavigationLink(value: "General") { + Label("General", systemImage: "gear") + } + NavigationLink(value: "Appearance") { + Label("Appearance", systemImage: "eye") + } + NavigationLink(value: "Media") { + Label("Media", systemImage: "play.laptopcomputer") + } + NavigationLink(value: "Timer") { + Label("Timer", systemImage: "timer") + } + NavigationLink(value: "Calendar") { + Label("Calendar", systemImage: "calendar") + } + if extensionManager.installedExtensions + .contains(where: { $0.bundleIdentifier == hudExtension }) { + NavigationLink(value: "HUD") { + Label("HUDs", systemImage: "dial.medium.fill") + } + } + NavigationLink(value: "Battery") { + Label("Battery", systemImage: "battery.100.bolt") + } + if extensionManager.installedExtensions + .contains(where: { $0.bundleIdentifier == downloadManagerExtension }) { + NavigationLink(value: "Downloads") { + Label("Downloads", systemImage: "square.and.arrow.down") + } + } + 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") + } + NavigationLink(value: "Extensions") { + Label("Extensions", systemImage: "puzzlepiece.extension") + } + NavigationLink(value: "About") { + Label("About", systemImage: "info.circle") + } + } + .listStyle(SidebarListStyle()) + .toolbar(removing: .sidebarToggle) + .navigationSplitViewColumnWidth(200) + } detail: { + Group { + switch selectedTab { + case "General": + GeneralSettings() + case "Appearance": + Appearance() + case "Media": + Media() + case "Timer": + TimerSettings() + case "Calendar": + CalendarSettings() + case "HUD": + HUD() + case "Battery": + Charge() + case "Downloads": + Downloads() + case "Shelf": + Shelf() + case "Stats": + Stats() + case "Clipboard": + ClipboardSettings() + case "Shortcuts": + Shortcuts() + case "Extensions": + Extensions() + case "About": + if let controller = updaterController { + About(updaterController: controller) + } else { + // Fallback with a default controller + About(updaterController: SPUStandardUpdaterController(startingUpdater: false, updaterDelegate: nil, userDriverDelegate: nil)) + } + default: + GeneralSettings() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .navigationSplitViewStyle(.balanced) + .toolbar(removing: .sidebarToggle) + .toolbar { + Button("") {} // Empty label, does nothing + .controlSize(.extraLarge) + .opacity(0) // Invisible, but reserves space for a consistent look between tabs + .disabled(true) + } + .environmentObject(extensionManager) + .environmentObject(calendarManager) + .formStyle(.grouped) + .frame(width: 700) + .background(Color(NSColor.windowBackgroundColor)) + } +} + +struct GeneralSettings: View { + @State private var screens: [String] = NSScreen.screens.compactMap { $0.localizedName } + @EnvironmentObject var vm: DynamicIslandViewModel + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + + @Default(.mirrorShape) var mirrorShape + @Default(.showEmojis) var showEmojis + @Default(.gestureSensitivity) var gestureSensitivity + @Default(.minimumHoverDuration) var minimumHoverDuration + @Default(.nonNotchHeight) var nonNotchHeight + @Default(.nonNotchHeightMode) var nonNotchHeightMode + @Default(.notchHeight) var notchHeight + @Default(.notchHeightMode) var notchHeightMode + @Default(.showOnAllDisplays) var showOnAllDisplays + @Default(.automaticallySwitchDisplay) var automaticallySwitchDisplay + @Default(.enableGestures) var enableGestures + @Default(.openNotchOnHover) var openNotchOnHover + @Default(.alwaysHideInFullscreen) var alwaysHideInFullscreen + + var body: some View { + Form { + Section { + Defaults.Toggle("Menubar icon", key: .menubarIcon) + LaunchAtLogin.Toggle("Launch at login") + Defaults.Toggle(key: .showOnAllDisplays) { + Text("Show on all displays") + } + .onChange(of: showOnAllDisplays) { + NotificationCenter.default.post(name: Notification.Name.showOnAllDisplaysChanged, object: nil) + } + Picker("Show on a specific display", selection: $coordinator.preferredScreen) { + ForEach(screens, id: \.self) { screen in + Text(screen) + } + } + .onChange(of: NSScreen.screens) { + screens = NSScreen.screens.compactMap({$0.localizedName}) + } + .disabled(showOnAllDisplays) + Defaults.Toggle("Automatically switch displays", key: .automaticallySwitchDisplay) + .onChange(of: automaticallySwitchDisplay) { + NotificationCenter.default.post(name: Notification.Name.automaticallySwitchDisplayChanged, object: nil) + } + .disabled(showOnAllDisplays) + } header: { + Text("System features") + } + + Section { + Picker(selection: $notchHeightMode, label: + Text("Notch display height")) { + Text("Match real notch size") + .tag(WindowHeightMode.matchRealNotchSize) + Text("Match menubar height") + .tag(WindowHeightMode.matchMenuBar) + Text("Custom height") + .tag(WindowHeightMode.custom) + } + .onChange(of: notchHeightMode) { + switch notchHeightMode { + case .matchRealNotchSize: + notchHeight = 38 + case .matchMenuBar: + notchHeight = 44 + case .custom: + notchHeight = 38 + } + NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil) + } + if notchHeightMode == .custom { + Slider(value: $notchHeight, in: 15...45, step: 1) { + Text("Custom notch size - \(notchHeight, specifier: "%.0f")") + } + .onChange(of: notchHeight) { + NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil) + } + } + Picker("Non-notch display height", selection: $nonNotchHeightMode) { + Text("Match menubar height") + .tag(WindowHeightMode.matchMenuBar) + Text("Match real notch size") + .tag(WindowHeightMode.matchRealNotchSize) + Text("Custom height") + .tag(WindowHeightMode.custom) + } + .onChange(of: nonNotchHeightMode) { + switch nonNotchHeightMode { + case .matchMenuBar: + nonNotchHeight = 24 + case .matchRealNotchSize: + nonNotchHeight = 32 + case .custom: + nonNotchHeight = 32 + } + NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil) + } + if nonNotchHeightMode == .custom { + Slider(value: $nonNotchHeight, in: 0...40, step: 1) { + Text("Custom notch size - \(nonNotchHeight, specifier: "%.0f")") + } + .onChange(of: nonNotchHeight) { + NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil) + } + } + } header: { + Text("Notch Height") + } + + NotchBehaviour() + + gestureControls() + } + .toolbar { + Button("Quit app") { + NSApp.terminate(self) + } + .controlSize(.extraLarge) + } + .navigationTitle("General") + .onChange(of: openNotchOnHover) { + if !openNotchOnHover { + enableGestures = true + } + } + } + + @ViewBuilder + func gestureControls() -> some View { + Section { + Defaults.Toggle("Enable gestures", key: .enableGestures) + .disabled(!openNotchOnHover) + if enableGestures { + Toggle("Media change with horizontal gestures", isOn: .constant(false)) + .disabled(true) + Defaults.Toggle("Close gesture", key: .closeGestureEnabled) + Slider(value: $gestureSensitivity, in: 100...300, step: 100) { + HStack { + Text("Gesture sensitivity") + Spacer() + Text(Defaults[.gestureSensitivity] == 100 ? "High" : Defaults[.gestureSensitivity] == 200 ? "Medium" : "Low") + .foregroundStyle(.secondary) + } + } + } + } header: { + HStack { + Text("Gesture control") + customBadge(text: "Beta") + } + } footer: { + Text("Two-finger swipe up on notch to close, two-finger swipe down on notch to open when **Open notch on hover** option is disabled") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .font(.caption) + } + } + + @ViewBuilder + func NotchBehaviour() -> some View { + Section { + Defaults.Toggle("Extend hover area", key: .extendHoverArea) + Defaults.Toggle("Enable haptics", key: .enableHaptics) + Defaults.Toggle("Open notch on hover", key: .openNotchOnHover) + Toggle("Remember last tab", isOn: $coordinator.openLastTabByDefault) + if openNotchOnHover { + Slider(value: $minimumHoverDuration, in: 0...1, step: 0.1) { + HStack { + Text("Minimum hover duration") + Spacer() + Text("\(minimumHoverDuration, specifier: "%.1f")s") + .foregroundStyle(.secondary) + } + } + .onChange(of: minimumHoverDuration) { + NotificationCenter.default.post(name: Notification.Name.notchHeightChanged, object: nil) + } + } + } header: { + Text("Notch behavior") + } + } +} + +struct Charge: View { + var body: some View { + Form { + Section { + Defaults.Toggle("Show battery indicator", key: .showBatteryIndicator) + Defaults.Toggle("Show power status notifications", key: .showPowerStatusNotifications) + } header: { + Text("General") + } + Section { + Defaults.Toggle("Show battery percentage", key: .showBatteryPercentage) + Defaults.Toggle("Show power status icons", key: .showPowerStatusIcons) + } header: { + Text("Battery Information") + } + } + .navigationTitle("Battery") + } +} + +struct Downloads: View { + @Default(.selectedDownloadIndicatorStyle) var selectedDownloadIndicatorStyle + @Default(.selectedDownloadIconStyle) var selectedDownloadIconStyle + var body: some View { + Form { + warningBadge("We don't support downloads yet", "It will be supported later on.") + Section { + Defaults.Toggle("Show download progress", key: .enableDownloadListener) + .disabled(true) + Defaults.Toggle("Enable Safari Downloads", key: .enableSafariDownloads) + .disabled(!Defaults[.enableDownloadListener]) + Picker("Download indicator style", selection: $selectedDownloadIndicatorStyle) { + Text("Progress bar") + .tag(DownloadIndicatorStyle.progress) + Text("Percentage") + .tag(DownloadIndicatorStyle.percentage) + } + Picker("Download icon style", selection: $selectedDownloadIconStyle) { + Text("Only app icon") + .tag(DownloadIconStyle.onlyAppIcon) + Text("Only download icon") + .tag(DownloadIconStyle.onlyIcon) + Text("Both") + .tag(DownloadIconStyle.iconAndAppIcon) + } + + } header: { + HStack { + Text("Download indicators") + comingSoonTag() + } + } + Section { + List { + ForEach([].indices, id: \.self) { index in + Text("\(index)") + } + } + .frame(minHeight: 96) + .overlay { + if true { + Text("No excluded apps") + .foregroundStyle(Color(.secondaryLabelColor)) + } + } + .actionBar(padding: 0) { + Group { + Button {} label: { + Image(systemName: "plus") + .frame(width: 25, height: 16, alignment: .center) + .contentShape(Rectangle()) + .foregroundStyle(.secondary) + } + + Divider() + Button {} label: { + Image(systemName: "minus") + .frame(width: 20, height: 16, alignment: .center) + .contentShape(Rectangle()) + .foregroundStyle(.secondary) + } + } + } + } header: { + HStack(spacing: 4) { + Text("Exclude apps") + comingSoonTag() + } + } + } + .navigationTitle("Downloads") + } +} + +struct HUD: View { + @EnvironmentObject var vm: DynamicIslandViewModel + @Default(.inlineHUD) var inlineHUD + @Default(.enableGradient) var enableGradient + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + var body: some View { + Form { + Section { + Toggle("Enable HUD replacement", isOn: $coordinator.hudReplacement) + } header: { + Text("General") + } + Section { + Picker("HUD style", selection: $inlineHUD) { + Text("Default") + .tag(false) + Text("Inline") + .tag(true) + } + .onChange(of: Defaults[.inlineHUD]) { + if Defaults[.inlineHUD] { + withAnimation { + Defaults[.systemEventIndicatorShadow] = false + Defaults[.enableGradient] = false + } + } + } + Picker("Progressbar style", selection: $enableGradient) { + Text("Hierarchical") + .tag(false) + Text("Gradient") + .tag(true) + } + Defaults.Toggle("Enable glowing effect", key: .systemEventIndicatorShadow) + Defaults.Toggle("Use accent color", key: .systemEventIndicatorUseAccent) + } header: { + HStack { + Text("Appearance") + } + } + } + .navigationTitle("HUDs") + } +} + +struct Media: View { + @Default(.waitInterval) var waitInterval + @Default(.mediaController) var mediaController + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @Default(.hideNotchOption) var hideNotchOption + @Default(.enableSneakPeek) private var enableSneakPeek + @Default(.sneakPeekStyles) var sneakPeekStyles + + var body: some View { + Form { + Section { + Picker("Music Source", selection: $mediaController) { + ForEach(availableMediaControllers) { controller in + Text(controller.rawValue).tag(controller) + } + } + .onChange(of: mediaController) { _, _ in + NotificationCenter.default.post( + name: Notification.Name.mediaControllerChanged, + object: nil + ) + } + } header: { + Text("Media Source") + } footer: { + if MusicManager.shared.isNowPlayingDeprecated { + HStack { + Text("YouTube Music requires this third-party app to be installed: ") + .foregroundStyle(.secondary) + .font(.caption) + Link("https://github.com/th-ch/youtube-music", destination: URL(string: "https://github.com/th-ch/youtube-music")!) + .font(.caption) + .foregroundColor(.blue) // Ensures it's visibly a link + } + } else { + Text("'Now Playing' was the only option on previous versions and works with all media apps.") + .foregroundStyle(.secondary) + .font(.caption) + } + } + Section { + Toggle( + "Enable music live activity", + isOn: $coordinator.musicLiveActivityEnabled + ) + .animation(.easeInOut, value: coordinator.musicLiveActivityEnabled) + + Toggle("Enable sneak peek", isOn: $enableSneakPeek) + Picker("Sneak Peek Style", selection: $sneakPeekStyles){ + ForEach(SneakPeekStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + }.disabled(!enableSneakPeek) + HStack { + Stepper(value: $waitInterval, in: 0...10, step: 1) { + HStack { + Text("Media inactivity timeout") + Spacer() + Text("\(Defaults[.waitInterval], specifier: "%.0f") seconds") + .foregroundStyle(.secondary) + } + } + } + } header: { + Text("Media playback live activity") + } + + Picker(selection: $hideNotchOption, label: + HStack { + Text("Hide DynamicIsland 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 + } + } + .navigationTitle("Media") + } + + // Only show controller options that are available on this macOS version + private var availableMediaControllers: [MediaControllerType] { + if MusicManager.shared.isNowPlayingDeprecated { + return MediaControllerType.allCases.filter { $0 != .nowPlaying } + } else { + return MediaControllerType.allCases + } + } +} + +struct CalendarSettings: View { + @ObservedObject private var calendarManager = CalendarManager() + @Default(.showCalendar) var showCalendar: Bool + + var body: some View { + Form { + if calendarManager.authorizationStatus != .fullAccess { + Text("Calendar access is denied. Please enable it in System Settings.") + .foregroundColor(.red) + .multilineTextAlignment(.center) + .padding() + Button("Open System Settings") { + if let settingsURL = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars") { + NSWorkspace.shared.open(settingsURL) + } + } + } else { + Toggle("Show calendar", isOn: $showCalendar) + Section(header: Text("Select Calendars")) { + List { + ForEach(calendarManager.allCalendars, id: \.id) { calendar in + Toggle(isOn: Binding( + get: { calendarManager.getCalendarSelected(calendar) }, + set: { isSelected in + Task { + await calendarManager.setCalendarSelected(calendar, isSelected: isSelected) + } + } + )) { + Text(calendar.title) + } + } + } + } + } + } + .onAppear { + Task { + await calendarManager.checkCalendarAuthorization() + } + } + // Add navigation title if it's missing or adjust as needed + .navigationTitle("Calendar") + } +} + +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 + @Environment(\.openWindow) var openWindow + var body: some View { + VStack { + Form { + Section { + HStack { + Text("Release name") + Spacer() + Text(Defaults[.releaseName]) + .foregroundStyle(.secondary) + } + HStack { + Text("Version") + Spacer() + if showBuildNumber { + Text("(\(Bundle.main.buildVersionNumber ?? ""))") + .foregroundStyle(.secondary) + } + Text(Bundle.main.releaseVersionNumber ?? "unkown") + .foregroundStyle(.secondary) + } + .onTapGesture { + withAnimation { + showBuildNumber.toggle() + } + } + } header: { + Text("Version info") + } + + UpdaterSettingsView(updater: updaterController.updater) + + HStack(spacing: 30) { + Spacer(minLength: 0) + Button { + NSWorkspace.shared.open(sponsorPage) + } label: { + VStack(spacing: 5) { + Image(systemName: "cup.and.saucer.fill") + .imageScale(.large) + Text("Support Us") + .foregroundStyle(.white) + } + .contentShape(Rectangle()) + } + Spacer(minLength: 0) + Button { + NSWorkspace.shared.open(productPage) + } label: { + VStack(spacing: 5) { + Image("Github") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 18) + Text("GitHub") + .foregroundStyle(.white) + } + .contentShape(Rectangle()) + } + Spacer(minLength: 0) + } + .buttonStyle(PlainButtonStyle()) + } + VStack(spacing: 0) { + Divider() + Text("Made with 🫶🏻 by not so boring not.people") + .foregroundStyle(.secondary) + .padding(.top, 5) + .padding(.bottom, 7) + .multilineTextAlignment(.center) + .padding(.horizontal, 10) + } + .frame(maxWidth: .infinity, alignment: .center) + } + .toolbar { +// Button("Welcome window") { +// openWindow(id: "onboarding") +// } +// .controlSize(.extraLarge) + CheckForUpdatesView(updater: updaterController.updater) + } + .navigationTitle("About") + } +} + +struct Shelf: View { + var body: some View { + Form { + Section { + Defaults.Toggle("Enable shelf", key: .dynamicShelf) + Defaults.Toggle("Open shelf tab by default if items added", key: .openShelfByDefault) + } header: { + HStack { + Text("General") + } + } + } + .navigationTitle("Shelf") + } +} + +struct Extensions: View { + @EnvironmentObject var extensionManager: DynamicIslandExtensionManager + @State private var effectTrigger: Bool = false + var body: some View { + Form { + //warningBadge("We don't support extensions yet") // Uhhhh You do? <><><> Oori.S + Section { + List { + ForEach(extensionManager.installedExtensions.indices, id: \.self) { index in + let item = extensionManager.installedExtensions[index] + HStack { + AppIcon(for: item.bundleIdentifier) + .resizable() + .frame(width: 24, height: 24) + Text(item.name) + ListItemPopover { + Text("Description") + } + Spacer(minLength: 0) + HStack(spacing: 6) { + Circle() + .frame(width: 6, height: 6) + .foregroundColor(isExtensionRunning(item.bundleIdentifier) ? .green : item.status == .disabled ? .gray : .red) + .conditionalModifier(isExtensionRunning(item.bundleIdentifier)) { view in + view + .shadow(color: .green, radius: 3) + } + Text(isExtensionRunning(item.bundleIdentifier) ? "Running" : item.status == .disabled ? "Disabled" : "Stopped") + .contentTransition(.numericText()) + .foregroundStyle(.secondary) + .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) + } + } + .keyboardShortcut("R", modifiers: .command) + Button("Disable") { + if let ext = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == item.bundleIdentifier }) { + ext.terminate() + } + extensionManager.installedExtensions[index].status = .disabled + } + .keyboardShortcut("D", modifiers: .command) + Divider() + Button("Uninstall", role: .destructive) { + // + } + }, label: { + Image(systemName: "ellipsis.circle") + .foregroundStyle(.secondary) + }) + .controlSize(.regular) + } + .buttonStyle(PlainButtonStyle()) + .padding(.vertical, 5) + } + } + .frame(minHeight: 120) + .actionBar { + Button {} label: { + HStack(spacing: 3) { + Image(systemName: "plus") + Text("Add manually") + } + .foregroundStyle(.secondary) + } + .disabled(true) + Spacer() + Button { + withAnimation(.linear(duration: 1)) { + effectTrigger.toggle() + } completion: { + effectTrigger.toggle() + } + extensionManager.checkIfExtensionsAreInstalled() + } label: { + HStack(spacing: 3) { + Image(systemName: "arrow.triangle.2.circlepath") + .rotationEffect(effectTrigger ? .degrees(360) : .zero) + } + .foregroundStyle(.secondary) + } + } + .controlSize(.small) + .buttonStyle(PlainButtonStyle()) + .overlay { + if extensionManager.installedExtensions.isEmpty { + Text("No extension installed") + .foregroundStyle(Color(.secondaryLabelColor)) + .padding(.bottom, 22) + } + } + } header: { + HStack(spacing: 0) { + Text("Installed extensions") + if !extensionManager.installedExtensions.isEmpty { + Text(" – \(extensionManager.installedExtensions.count)") + .foregroundStyle(.secondary) + } + } + } + } + .navigationTitle("Extensions") + // TipsView() + // .padding(.horizontal, 19) + } +} + +struct Appearance: View { + @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared + @Default(.mirrorShape) var mirrorShape + @Default(.sliderColor) var sliderColor + @Default(.useMusicVisualizer) var useMusicVisualizer + @Default(.customVisualizers) var customVisualizers + @Default(.selectedVisualizer) var selectedVisualizer + 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 = "" + @State private var speed: CGFloat = 1.0 + var body: some View { + Form { + Section { + Toggle("Always show tabs", isOn: $coordinator.alwaysShowTabs) + Defaults.Toggle("Settings icon in notch", key: .settingsIconInNotch) + Defaults.Toggle("Enable window shadow", key: .enableShadow) + Defaults.Toggle("Corner radius scaling", key: .cornerRadiusScaling) + Defaults.Toggle("Use simpler close animation", key: .useModernCloseAnimation) + } header: { + Text("General") + } + + Section { + Defaults.Toggle("Enable colored spectrograms", key: .coloredSpectrogram) + Defaults + .Toggle("Player tinting", key: .playerColorTinting) + Defaults.Toggle("Enable blur effect behind album art", key: .lightingEffect) + Picker("Slider color", selection: $sliderColor) { + ForEach(SliderColorEnum.allCases, id: \.self) { option in + Text(option.rawValue) + } + } + } header: { + Text("Media") + } + + Section { + Toggle( + "Use music visualizer spectrogram", + isOn: $useMusicVisualizer.animation() + ) + .disabled(true) + if !useMusicVisualizer { + if customVisualizers.count > 0 { + Picker( + "Selected animation", + selection: $selectedVisualizer + ) { + ForEach( + customVisualizers, + id: \.self + ) { visualizer in + Text(visualizer.name) + .tag(visualizer) + } + } + } else { + HStack { + Text("Selected animation") + Spacer() + Text("No custom animation available") + .foregroundStyle(.secondary) + } + } + } + } header: { + HStack { + Text("Custom music live activity animation") + customBadge(text: "Coming soon") + } + } + + Section { + List { + ForEach(customVisualizers, id: \.self) { visualizer in + HStack { + LottieView(state: LUStateData(type: .loadedFrom(visualizer.url), speed: visualizer.speed, loopMode: .loop)) + .frame(width: 30, height: 30, alignment: .center) + Text(visualizer.name) + Spacer(minLength: 0) + if selectedVisualizer == visualizer { + Text("selected") + .font(.caption) + .fontWeight(.medium) + .foregroundStyle(.secondary) + .padding(.trailing, 8) + } + } + .buttonStyle(PlainButtonStyle()) + .padding(.vertical, 2) + .background( + selectedListVisualizer != nil ? selectedListVisualizer == visualizer ? Color.accentColor : Color.clear : Color.clear, + in: RoundedRectangle(cornerRadius: 5) + ) + .contentShape(Rectangle()) + .onTapGesture { + if selectedListVisualizer == visualizer { + selectedListVisualizer = nil + return + } + selectedListVisualizer = visualizer + } + } + } + .safeAreaPadding( + EdgeInsets(top: 5, leading: 0, bottom: 5, trailing: 0) + ) + .frame(minHeight: 120) + .actionBar { + HStack(spacing: 5) { + Button { + name = "" + url = "" + speed = 1.0 + isPresented.toggle() + } label: { + Image(systemName: "plus") + .foregroundStyle(.secondary) + .contentShape(Rectangle()) + } + Divider() + Button { + if selectedListVisualizer != nil { + let visualizer = selectedListVisualizer! + selectedListVisualizer = nil + customVisualizers.remove(at: customVisualizers.firstIndex(of: visualizer)!) + if visualizer == selectedVisualizer && customVisualizers.count > 0 { + selectedVisualizer = customVisualizers[0] + } + } + } label: { + Image(systemName: "minus") + .foregroundStyle(.secondary) + .contentShape(Rectangle()) + } + } + } + .controlSize(.small) + .buttonStyle(PlainButtonStyle()) + .overlay { + if customVisualizers.isEmpty { + Text("No custom visualizer") + .foregroundStyle(Color(.secondaryLabelColor)) + .padding(.bottom, 22) + } + } + .sheet(isPresented: $isPresented) { + VStack(alignment: .leading) { + Text("Add new visualizer") + .font(.largeTitle.bold()) + .padding(.vertical) + TextField("Name", text: $name) + TextField("Lottie JSON URL", text: $url) + HStack { + Text("Speed") + Spacer(minLength: 80) + Text("\(speed, specifier: "%.1f")s") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + Slider(value: $speed, in: 0...2, step: 0.1) + } + .padding(.vertical) + HStack { + Button { + isPresented.toggle() + } label: { + Text("Cancel") + .frame(maxWidth: .infinity, alignment: .center) + } + + Button { + let visualizer: CustomVisualizer = .init( + UUID: UUID(), + name: name, + url: URL(string: url)!, + speed: speed + ) + + if !customVisualizers.contains(visualizer) { + customVisualizers.append(visualizer) + } + + isPresented.toggle() + } label: { + Text("Add") + .frame(maxWidth: .infinity, alignment: .center) + } + .buttonStyle(BorderedProminentButtonStyle()) + } + } + .textFieldStyle(RoundedBorderTextFieldStyle()) + .controlSize(.extraLarge) + .padding() + } + } header: { + HStack(spacing: 0) { + Text("Custom vizualizers (Lottie)") + if !Defaults[.customVisualizers].isEmpty { + Text(" – \(Defaults[.customVisualizers].count)") + .foregroundStyle(.secondary) + } + } + } + + Section { + Defaults.Toggle("Enable boring mirror", key: .showMirror) + .disabled(!checkVideoInput()) + Picker("Mirror shape", selection: $mirrorShape) { + Text("Circle") + .tag(MirrorShapeEnum.circle) + Text("Square") + .tag(MirrorShapeEnum.rectangle) + } + Defaults.Toggle("Show cool face animation while inactivity", key: .showNotHumanFace) + } header: { + HStack { + Text("Additional features") + } + } + + Section { + HStack { + ForEach(icons, id: \.self) { icon in + Spacer() + VStack { + Image(icon) + .resizable() + .frame(width: 80, height: 80) + .background( + RoundedRectangle(cornerRadius: 20, style: .circular) + .strokeBorder( + icon == selectedIcon ? Color.accentColor : .clear, + lineWidth: 2.5 + ) + ) + + Text("Default") + .fontWeight(.medium) + .font(.caption) + .foregroundStyle(icon == selectedIcon ? .white : .secondary) + .padding(.horizontal, 10) + .padding(.vertical, 3) + .background( + Capsule() + .fill(icon == selectedIcon ? Color.accentColor : .clear) + ) + } + .onTapGesture { + withAnimation { + selectedIcon = icon + } + NSApp.applicationIconImage = NSImage(named: icon) + } + Spacer() + } + } + .disabled(true) + } header: { + HStack { + Text("App icon") + customBadge(text: "Coming soon") + } + } + } + .navigationTitle("Appearance") + } + + func checkVideoInput() -> Bool { + if let _ = AVCaptureDevice.default(for: .video) { + return true + } + + return false + } +} + +struct Shortcuts: View { + var body: some View { + Form { + Section { + KeyboardShortcuts.Recorder("Toggle Sneak Peek:", name: .toggleSneakPeek) + } header: { + Text("Media") + } footer: { + Text("Sneak Peek shows the media title and artist under the notch for a few seconds.") + .multilineTextAlignment(.trailing) + .foregroundStyle(.secondary) + .font(.caption) + } + 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)) + .font(.footnote.bold()) + .padding(.vertical, 3) + .padding(.horizontal, 6) + .background(RoundedRectangle(cornerRadius: 4).stroke(Color(red: 0.545, green: 0.196, blue: 0.98), lineWidth: 1)) +} + +func comingSoonTag() -> some View { + Text("Coming soon") + .foregroundStyle(.secondary) + .font(.footnote.bold()) + .padding(.vertical, 3) + .padding(.horizontal, 6) + .background(Color(nsColor: .secondarySystemFill)) + .clipShape(.capsule) +} + +func customBadge(text: String) -> some View { + Text(text) + .foregroundStyle(.secondary) + .font(.footnote.bold()) + .padding(.vertical, 3) + .padding(.horizontal, 6) + .background(Color(nsColor: .secondarySystemFill)) + .clipShape(.capsule) +} + +func warningBadge(_ text: String, _ description: String) -> some View { + Section { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 22)) + .foregroundStyle(.yellow) + VStack(alignment: .leading) { + Text(text) + .font(.headline) + Text(description) + .foregroundStyle(.secondary) + } + Spacer() + } + } +} + +struct ClipboardSettings: View { + @ObservedObject var clipboardManager = ClipboardManager.shared + @Default(.enableClipboardManager) var enableClipboardManager + @Default(.clipboardHistorySize) var clipboardHistorySize + @Default(.showClipboardIcon) var showClipboardIcon + + var body: some View { + Form { + Section { + Defaults.Toggle("Enable Clipboard Manager", key: .enableClipboardManager) + .onChange(of: enableClipboardManager) { enabled in + if enabled { + clipboardManager.startMonitoring() + } else { + clipboardManager.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 enableClipboardManager { + Section { + 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) + } + .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) + } + } 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") + } + } + } + } + .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" + } + } +} + +#Preview { + HUD() +} \ 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: $@"; +}