mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #9 from Ebullioscopic/borrow/boringnotch
Borrow/boringnotch
This commit is contained in:
commit
9661d41d18
42 changed files with 4133 additions and 960 deletions
BIN
.DS_Store
vendored
BIN
.DS_Store
vendored
Binary file not shown.
|
|
@ -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 = "<group>"; };
|
||||
7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = NowPlayingTestClient; sourceTree = "<group>"; };
|
||||
F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = Frameworks/MediaRemoteAdapter.framework; sourceTree = SOURCE_ROOT; };
|
||||
F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DynamicIsland.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
|
|
@ -35,6 +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 = "<group>";
|
||||
};
|
||||
7E81304F2E3FB42700E08F3F /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F54406DE2CD4987A00C7AFD9 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7E81304F2E3FB42700E08F3F /* Frameworks */,
|
||||
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */,
|
||||
F54406E92CD4987A00C7AFD9 /* DynamicIsland */,
|
||||
F54406E82CD4987A00C7AFD9 /* Products */,
|
||||
);
|
||||
|
|
@ -76,6 +117,7 @@
|
|||
F54406E32CD4987A00C7AFD9 /* Sources */,
|
||||
F54406E42CD4987A00C7AFD9 /* Frameworks */,
|
||||
F54406E52CD4987A00C7AFD9 /* Resources */,
|
||||
B933CDB48C2F42C2887A9836 /* Embed Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
|
|
@ -151,6 +193,8 @@
|
|||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */,
|
||||
7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -296,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 = (
|
||||
|
|
|
|||
533
DynamicIsland.xcodeproj/project.pbxproj.backup
Normal file
533
DynamicIsland.xcodeproj/project.pbxproj.backup
Normal file
|
|
@ -0,0 +1,533 @@
|
|||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
7E6118142E24071E0050006C /* LaunchAtLogin in Frameworks */ = {isa = PBXBuildFile; productRef = 14D032192C68F32E0096E6A1 /* LaunchAtLogin */; };
|
||||
7E6118152E24071E0050006C /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 14D0321C2C68F3350096E6A1 /* Sparkle */; };
|
||||
7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */ = {isa = PBXBuildFile; productRef = B18654382C6F4990000B926A /* KeyboardShortcuts */; };
|
||||
7E6118172E24071E0050006C /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 9A987A0F2C73CA8D005CA465 /* Collections */; };
|
||||
7E6118182E24071E0050006C /* TheBoringWorkerNotifier in Frameworks */ = {isa = PBXBuildFile; productRef = 1485442E2C8F049C00943381 /* TheBoringWorkerNotifier */; };
|
||||
7E6118192E24071E0050006C /* Defaults in Frameworks */ = {isa = PBXBuildFile; productRef = B19016212CC15B3D00E3F12E /* Defaults */; };
|
||||
7E61181A2E24071E0050006C /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = B1628B912CC260C0003D8DF3 /* SwiftUIIntrospect */; };
|
||||
7E61181B2E24071E0050006C /* LottieUI in Frameworks */ = {isa = PBXBuildFile; productRef = B194240A2CD10017003E5DC2 /* LottieUI */; };
|
||||
7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */ = {isa = PBXBuildFile; productRef = 146BC4362D366E9B0047C66A /* MacroVisionKit */; };
|
||||
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */; };
|
||||
7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */ = {isa = PBXFileReference; lastKnownFileType = text.script.perl; path = "mediaremote-adapter.pl"; sourceTree = "<group>"; };
|
||||
7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = NowPlayingTestClient; sourceTree = "<group>"; };
|
||||
F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DynamicIsland.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
F54406E92CD4987A00C7AFD9 /* DynamicIsland */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = DynamicIsland;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
F54406E42CD4987A00C7AFD9 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
7E61181C2E24071E0050006C /* MacroVisionKit in Frameworks */,
|
||||
7E61181B2E24071E0050006C /* LottieUI in Frameworks */,
|
||||
7E61181A2E24071E0050006C /* SwiftUIIntrospect in Frameworks */,
|
||||
7E6118192E24071E0050006C /* Defaults in Frameworks */,
|
||||
7E6118182E24071E0050006C /* TheBoringWorkerNotifier in Frameworks */,
|
||||
7E6118172E24071E0050006C /* Collections in Frameworks */,
|
||||
7E6118162E24071E0050006C /* KeyboardShortcuts in Frameworks */,
|
||||
7E6118152E24071E0050006C /* Sparkle in Frameworks */,
|
||||
7E6118142E24071E0050006C /* LaunchAtLogin in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */,
|
||||
7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */,
|
||||
);
|
||||
path = "mediaremote-adapter";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7E81304F2E3FB42700E08F3F /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F54406DE2CD4987A00C7AFD9 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
7E81304F2E3FB42700E08F3F /* Frameworks */,
|
||||
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */,
|
||||
F54406E92CD4987A00C7AFD9 /* DynamicIsland */,
|
||||
F54406E82CD4987A00C7AFD9 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
F54406E82CD4987A00C7AFD9 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
F54406E62CD4987A00C7AFD9 /* DynamicIsland */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = F54406F62CD4987B00C7AFD9 /* Build configuration list for PBXNativeTarget "DynamicIsland" */;
|
||||
buildPhases = (
|
||||
F54406E32CD4987A00C7AFD9 /* Sources */,
|
||||
F54406E42CD4987A00C7AFD9 /* Frameworks */,
|
||||
F54406E52CD4987A00C7AFD9 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
F54406E92CD4987A00C7AFD9 /* DynamicIsland */,
|
||||
);
|
||||
name = DynamicIsland;
|
||||
packageProductDependencies = (
|
||||
14D032192C68F32E0096E6A1 /* LaunchAtLogin */,
|
||||
14D0321C2C68F3350096E6A1 /* Sparkle */,
|
||||
B18654382C6F4990000B926A /* KeyboardShortcuts */,
|
||||
9A987A0F2C73CA8D005CA465 /* Collections */,
|
||||
1485442E2C8F049C00943381 /* TheBoringWorkerNotifier */,
|
||||
B19016212CC15B3D00E3F12E /* Defaults */,
|
||||
B1628B912CC260C0003D8DF3 /* SwiftUIIntrospect */,
|
||||
B194240A2CD10017003E5DC2 /* LottieUI */,
|
||||
146BC4362D366E9B0047C66A /* MacroVisionKit */,
|
||||
);
|
||||
productName = DynamicIsland;
|
||||
productReference = F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
F54406DF2CD4987A00C7AFD9 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1600;
|
||||
LastUpgradeCheck = 1600;
|
||||
TargetAttributes = {
|
||||
F54406E62CD4987A00C7AFD9 = {
|
||||
CreatedOnToolsVersion = 16.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = F54406E22CD4987A00C7AFD9 /* Build configuration list for PBXProject "DynamicIsland" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = F54406DE2CD4987A00C7AFD9;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
F54407082CD4A23F00C7AFD9 /* XCRemoteSwiftPackageReference "swift-collections" */,
|
||||
14D032182C68F32E0096E6A1 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */,
|
||||
14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */,
|
||||
B18654372C6F4990000B926A /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */,
|
||||
9A987A112C73CAA1005CA465 /* XCRemoteSwiftPackageReference "Pow" */,
|
||||
1485442D2C8F049C00943381 /* XCRemoteSwiftPackageReference "TheBoringWorkerNotifier" */,
|
||||
B19016202CC15B3D00E3F12E /* XCRemoteSwiftPackageReference "Defaults" */,
|
||||
B1628B902CC260C0003D8DF3 /* XCRemoteSwiftPackageReference "swiftui-introspect" */,
|
||||
B19424072CD0DB52003E5DC2 /* XCRemoteSwiftPackageReference "LottieUI" */,
|
||||
146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = F54406E82CD4987A00C7AFD9 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
F54406E62CD4987A00C7AFD9 /* DynamicIsland */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
F54406E52CD4987A00C7AFD9 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */,
|
||||
7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
F54406E32CD4987A00C7AFD9 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
F54406F42CD4987B00C7AFD9 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
F54406F52CD4987B00C7AFD9 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
F54406F72CD4987B00C7AFD9 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)",
|
||||
"$(SDKROOT)/../../../../../../../../../Users/hariharan/HobbyProjects/DynamicIsland",
|
||||
"$(PROJECT_DIR)/mediaremote-adapter\\ 2",
|
||||
);
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Need for Spotify and Apple Music";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
F54406F82CD4987B00C7AFD9 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = DynamicIsland/DynamicIsland.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"DynamicIsland/Preview Content\"";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
FRAMEWORK_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"$(PROJECT_DIR)",
|
||||
"$(SDKROOT)/../../../../../../../../../Users/hariharan/HobbyProjects/DynamicIsland",
|
||||
"$(PROJECT_DIR)/mediaremote-adapter\\ 2",
|
||||
);
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Need for Spotify and Apple Music";
|
||||
INFOPLIST_KEY_NSCameraUsageDescription = "We need your camera for Mirror mode";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.karthikinformationtechnology.DynamicIsland;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
F54406E22CD4987A00C7AFD9 /* Build configuration list for PBXProject "DynamicIsland" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
F54406F42CD4987B00C7AFD9 /* Debug */,
|
||||
F54406F52CD4987B00C7AFD9 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
F54406F62CD4987B00C7AFD9 /* Build configuration list for PBXNativeTarget "DynamicIsland" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
F54406F72CD4987B00C7AFD9 /* Debug */,
|
||||
F54406F82CD4987B00C7AFD9 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/TheBoredTeam/MacroVisionKit";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 0.1.0;
|
||||
};
|
||||
};
|
||||
1485442D2C8F049C00943381 /* XCRemoteSwiftPackageReference "TheBoringWorkerNotifier" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/TheBoredTeam/TheBoringWorkerNotifier.git";
|
||||
requirement = {
|
||||
branch = main;
|
||||
kind = branch;
|
||||
};
|
||||
};
|
||||
14D032182C68F32E0096E6A1 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/sindresorhus/LaunchAtLogin-Modern";
|
||||
requirement = {
|
||||
branch = main;
|
||||
kind = branch;
|
||||
};
|
||||
};
|
||||
14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/sparkle-project/Sparkle";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.6.4;
|
||||
};
|
||||
};
|
||||
9A987A112C73CAA1005CA465 /* XCRemoteSwiftPackageReference "Pow" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/EmergeTools/Pow";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.0.4;
|
||||
};
|
||||
};
|
||||
B1628B902CC260C0003D8DF3 /* XCRemoteSwiftPackageReference "swiftui-introspect" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/siteline/swiftui-introspect";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.3.0;
|
||||
};
|
||||
};
|
||||
B18654372C6F4990000B926A /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/sindresorhus/KeyboardShortcuts";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.2.4;
|
||||
};
|
||||
};
|
||||
B19016202CC15B3D00E3F12E /* XCRemoteSwiftPackageReference "Defaults" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/sindresorhus/Defaults";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 9.0.2;
|
||||
};
|
||||
};
|
||||
B19424072CD0DB52003E5DC2 /* XCRemoteSwiftPackageReference "LottieUI" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/jasudev/LottieUI.git";
|
||||
requirement = {
|
||||
branch = main;
|
||||
kind = branch;
|
||||
};
|
||||
};
|
||||
F54407082CD4A23F00C7AFD9 /* XCRemoteSwiftPackageReference "swift-collections" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/apple/swift-collections.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 1.1.4;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
146BC4362D366E9B0047C66A /* MacroVisionKit */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */;
|
||||
productName = MacroVisionKit;
|
||||
};
|
||||
1485442E2C8F049C00943381 /* TheBoringWorkerNotifier */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 1485442D2C8F049C00943381 /* XCRemoteSwiftPackageReference "TheBoringWorkerNotifier" */;
|
||||
productName = TheBoringWorkerNotifier;
|
||||
};
|
||||
14D032192C68F32E0096E6A1 /* LaunchAtLogin */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 14D032182C68F32E0096E6A1 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */;
|
||||
productName = LaunchAtLogin;
|
||||
};
|
||||
14D0321C2C68F3350096E6A1 /* Sparkle */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */;
|
||||
productName = Sparkle;
|
||||
};
|
||||
9A987A0F2C73CA8D005CA465 /* Collections */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = F54407082CD4A23F00C7AFD9 /* XCRemoteSwiftPackageReference "swift-collections" */;
|
||||
productName = Collections;
|
||||
};
|
||||
B1628B912CC260C0003D8DF3 /* SwiftUIIntrospect */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = B1628B902CC260C0003D8DF3 /* XCRemoteSwiftPackageReference "swiftui-introspect" */;
|
||||
productName = SwiftUIIntrospect;
|
||||
};
|
||||
B18654382C6F4990000B926A /* KeyboardShortcuts */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = B18654372C6F4990000B926A /* XCRemoteSwiftPackageReference "KeyboardShortcuts" */;
|
||||
productName = KeyboardShortcuts;
|
||||
};
|
||||
B19016212CC15B3D00E3F12E /* Defaults */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = B19016202CC15B3D00E3F12E /* XCRemoteSwiftPackageReference "Defaults" */;
|
||||
productName = Defaults;
|
||||
};
|
||||
B194240A2CD10017003E5DC2 /* LottieUI */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = B19424072CD0DB52003E5DC2 /* XCRemoteSwiftPackageReference "LottieUI" */;
|
||||
productName = LottieUI;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = F54406DF2CD4987A00C7AFD9 /* Project object */;
|
||||
}
|
||||
|
|
@ -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]?
|
||||
|
|
|
|||
|
|
@ -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 value = CGFloat((NumberFormatter().number(from: decodedData.value) ?? 0.0).floatValue)
|
||||
let icon = decodedData.icon
|
||||
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
|
||||
|
||||
print(decodedData)
|
||||
print("Decoded: \(decodedData), Parsed value: \(value)")
|
||||
|
||||
toggleSneakPeek(status: decodedData.show, type: contentType, value: value, icon: icon)
|
||||
|
||||
} else {
|
||||
print("Failed to decode JSON data")
|
||||
}
|
||||
toggleSneakPeek(status: decodedData.show, type: contentType, value: value, icon: icon)
|
||||
} else {
|
||||
print("Failed to decode JSON data")
|
||||
}
|
||||
}
|
||||
|
||||
func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") {
|
||||
sneakPeekDuration = duration
|
||||
|
|
@ -148,35 +160,41 @@ class DynamicIslandViewCoordinator: ObservableObject {
|
|||
}
|
||||
|
||||
private var sneakPeekDuration: TimeInterval = 1.5
|
||||
private var sneakPeekTask: Task<Void, Never>?
|
||||
|
||||
@Published var sneakPeek: sneakPeek = .init() {
|
||||
didSet {
|
||||
if sneakPeek.show {
|
||||
sneakPeekDispatch?.cancel()
|
||||
// Helper function to manage sneakPeek timer using Swift Concurrency
|
||||
private func scheduleSneakPeekHide(after duration: TimeInterval) {
|
||||
sneakPeekTask?.cancel()
|
||||
|
||||
sneakPeekDispatch = DispatchWorkItem { [weak self] in
|
||||
guard let self = self else { return }
|
||||
withAnimation {
|
||||
self.toggleSneakPeek(status: false, type: SneakContentType.music)
|
||||
self.sneakPeekDuration = 1.5
|
||||
}
|
||||
sneakPeekTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(duration))
|
||||
guard let self = self, !Task.isCancelled else { return }
|
||||
await MainActor.run {
|
||||
withAnimation {
|
||||
self.toggleSneakPeek(status: false, type: .music)
|
||||
self.sneakPeekDuration = 1.5
|
||||
}
|
||||
DispatchQueue.main
|
||||
.asyncAfter(
|
||||
deadline: .now() + self.sneakPeekDuration,
|
||||
execute: sneakPeekDispatch!
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toggleExpandingView(status: Bool, type: SneakContentType, value: CGFloat = 0, browser: BrowserType = .chromium) {
|
||||
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
|
||||
|
|
@ -186,17 +204,20 @@ class DynamicIslandViewCoordinator: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
private var expandingViewTask: Task<Void, Never>?
|
||||
|
||||
@Published var expandingView: ExpandedItem = .init() {
|
||||
didSet {
|
||||
if expandingView.show {
|
||||
expandingViewDispatch?.cancel()
|
||||
|
||||
expandingViewDispatch = DispatchWorkItem { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.toggleExpandingView(status: false, type: SneakContentType.battery)
|
||||
expandingViewTask?.cancel()
|
||||
let duration: TimeInterval = (expandingView.type == .download ? 2 : 3)
|
||||
expandingViewTask = Task { [weak self] in
|
||||
try? await Task.sleep(for: .seconds(duration))
|
||||
guard let self = self, !Task.isCancelled else { return }
|
||||
self.toggleExpandingView(status: false, type: .battery)
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + (expandingView.type == .download ? 2 : 3), execute: expandingViewDispatch!)
|
||||
} else {
|
||||
expandingViewTask?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" : {
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// AppleMusicController.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by Alexander Greco on 2025-03-29.
|
||||
// Created by Alexander on 2025-03-29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
|
@ -15,110 +15,125 @@ class AppleMusicController: MediaControllerProtocol {
|
|||
bundleIdentifier: "com.apple.Music"
|
||||
)
|
||||
|
||||
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState }
|
||||
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
|
||||
$playbackState.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
var isWorking: Bool {
|
||||
return true // AppleMusic controller always works
|
||||
}
|
||||
|
||||
private var notificationTask: Task<Void, Never>?
|
||||
|
||||
// MARK: - Initialization
|
||||
init() {
|
||||
setupPlaybackStateChangeObserver()
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if self?.isActive() == true {
|
||||
self?.updatePlaybackInfo()
|
||||
Task {
|
||||
if isActive() {
|
||||
await updatePlaybackInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupPlaybackStateChangeObserver() {
|
||||
DistributedNotificationCenter.default().addObserver(
|
||||
self,
|
||||
selector: #selector(updatePlaybackInfo),
|
||||
name: NSNotification.Name("com.apple.Music.playerInfo"),
|
||||
object: nil
|
||||
)
|
||||
notificationTask = Task { @Sendable [weak self] in
|
||||
let notifications = DistributedNotificationCenter.default().notifications(
|
||||
named: NSNotification.Name("com.apple.Music.playerInfo")
|
||||
)
|
||||
|
||||
for await _ in notifications {
|
||||
await self?.updatePlaybackInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
DistributedNotificationCenter.default().removeObserver(
|
||||
self,
|
||||
name: NSNotification.Name("com.apple.Music.playerInfo"),
|
||||
object: nil
|
||||
)
|
||||
|
||||
notificationTask?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Protocol Implementation
|
||||
func play() {
|
||||
executeCommand("play")
|
||||
func play() async {
|
||||
await executeCommand("play")
|
||||
}
|
||||
|
||||
func pause() {
|
||||
executeCommand("pause")
|
||||
func pause() async {
|
||||
await executeCommand("pause")
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
executeCommand("playpause")
|
||||
func togglePlay() async {
|
||||
await executeCommand("playpause")
|
||||
}
|
||||
|
||||
func nextTrack() {
|
||||
executeCommand("next track")
|
||||
func nextTrack() async {
|
||||
await executeCommand("next track")
|
||||
}
|
||||
|
||||
func previousTrack() {
|
||||
executeCommand("previous track")
|
||||
func previousTrack() async {
|
||||
await executeCommand("previous track")
|
||||
}
|
||||
|
||||
func seek(to time: Double) {
|
||||
executeCommand("set player position to \(time)")
|
||||
updatePlaybackInfo()
|
||||
func seek(to time: Double) async {
|
||||
await executeCommand("set player position to \(time)")
|
||||
await updatePlaybackInfo()
|
||||
}
|
||||
|
||||
func toggleShuffle() async {
|
||||
await executeCommand("set shuffle enabled to not shuffle enabled")
|
||||
playbackState.isShuffled.toggle()
|
||||
}
|
||||
|
||||
func toggleRepeat() async {
|
||||
await executeCommand("""
|
||||
if song repeat is off then
|
||||
set song repeat to all
|
||||
else if song repeat is all then
|
||||
set song repeat to one
|
||||
else
|
||||
set song repeat to off
|
||||
end if
|
||||
""")
|
||||
|
||||
if playbackState.repeatMode == .off {
|
||||
playbackState.repeatMode = .all
|
||||
} else if playbackState.repeatMode == .all {
|
||||
playbackState.repeatMode = .one
|
||||
} else {
|
||||
playbackState.repeatMode = .off
|
||||
}
|
||||
}
|
||||
|
||||
func isActive() -> Bool {
|
||||
let runningApps = NSWorkspace.shared.runningApplications
|
||||
return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier }
|
||||
return runningApps.contains { $0.bundleIdentifier == "com.apple.Music" }
|
||||
}
|
||||
|
||||
func updatePlaybackInfo() async {
|
||||
guard let descriptor = try? await fetchPlaybackInfoAsync() else { return }
|
||||
guard descriptor.numberOfItems >= 8 else { return }
|
||||
var updatedState = self.playbackState
|
||||
|
||||
updatedState.isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
|
||||
updatedState.title = descriptor.atIndex(2)?.stringValue ?? "Unknown"
|
||||
updatedState.artist = descriptor.atIndex(3)?.stringValue ?? "Unknown"
|
||||
updatedState.album = descriptor.atIndex(4)?.stringValue ?? "Unknown"
|
||||
updatedState.currentTime = descriptor.atIndex(5)?.doubleValue ?? 0
|
||||
updatedState.duration = descriptor.atIndex(6)?.doubleValue ?? 0
|
||||
updatedState.isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
|
||||
let repeatModeValue = descriptor.atIndex(8)?.int32Value ?? 0
|
||||
updatedState.repeatMode = RepeatMode(rawValue: Int(repeatModeValue)) ?? .off
|
||||
updatedState.artwork = descriptor.atIndex(9)?.data as Data?
|
||||
|
||||
self.playbackState = updatedState
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
@objc func updatePlaybackInfo() {
|
||||
guard let descriptor = fetchPlaybackInfo() else { return }
|
||||
guard descriptor.numberOfItems >= 8 else { return }
|
||||
|
||||
let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
|
||||
let currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown"
|
||||
let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown"
|
||||
let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown"
|
||||
let currentTime = descriptor.atIndex(5)?.doubleValue ?? 0
|
||||
let duration = descriptor.atIndex(6)?.doubleValue ?? 0
|
||||
let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
|
||||
let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false
|
||||
let artworkData = descriptor.atIndex(9)?.data as Data?
|
||||
|
||||
let updatedState = PlaybackState(
|
||||
bundleIdentifier: "com.apple.Music",
|
||||
isPlaying: isPlaying,
|
||||
title: currentTrack,
|
||||
artist: currentTrackArtist,
|
||||
album: currentTrackAlbum,
|
||||
currentTime: currentTime,
|
||||
duration: duration,
|
||||
playbackRate: 1,
|
||||
isShuffled: isShuffled,
|
||||
isRepeating: isRepeating,
|
||||
lastUpdated: Date(),
|
||||
artwork: (artworkData?.isEmpty ?? true) ? nil : artworkData
|
||||
)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.playbackState = updatedState
|
||||
}
|
||||
}
|
||||
|
||||
private func executeCommand(_ command: String) {
|
||||
private func executeCommand(_ command: String) async {
|
||||
let script = "tell application \"Music\" to \(command)"
|
||||
Task {
|
||||
try? await AppleScriptHelper.executeVoid(script)
|
||||
}
|
||||
try? await AppleScriptHelper.executeVoid(script)
|
||||
}
|
||||
|
||||
private func fetchPlaybackInfo() -> NSAppleEventDescriptor? {
|
||||
private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? {
|
||||
let script = """
|
||||
tell application "Music"
|
||||
set isRunning to true
|
||||
|
|
@ -129,26 +144,28 @@ class AppleMusicController: MediaControllerProtocol {
|
|||
set currentTrackAlbum to album of current track
|
||||
set trackPosition to player position
|
||||
set trackDuration to duration of current track
|
||||
set shuffleState to false
|
||||
set repeatState to false
|
||||
set shuffleState to shuffle enabled
|
||||
set repeatState to song repeat
|
||||
if repeatState is off then
|
||||
set repeatValue to 0
|
||||
else if repeatState is all then
|
||||
set repeatValue to 1
|
||||
else if repeatState is one then
|
||||
set repeatValue to 2
|
||||
end if
|
||||
|
||||
try
|
||||
set artData to data of artwork 1 of current track
|
||||
on error
|
||||
set artData to ""
|
||||
end try
|
||||
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artData}
|
||||
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatValue, artData}
|
||||
on error
|
||||
return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, false, ""}
|
||||
return {false, "Not Playing", "Unknown", "Unknown", 0, 0, false, 0, ""}
|
||||
end try
|
||||
end tell
|
||||
"""
|
||||
var descriptor: NSAppleEventDescriptor? = nil
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
Task {
|
||||
descriptor = try? await AppleScriptHelper.execute(script)
|
||||
semaphore.signal()
|
||||
}
|
||||
semaphore.wait()
|
||||
return descriptor
|
||||
|
||||
return try await AppleScriptHelper.execute(script)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,16 +6,19 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import AppKit
|
||||
import Combine
|
||||
|
||||
protocol MediaControllerProtocol: ObservableObject {
|
||||
var playbackStatePublisher: Published<PlaybackState>.Publisher { get }
|
||||
func play()
|
||||
func pause()
|
||||
func seek(to time: Double)
|
||||
func nextTrack()
|
||||
func previousTrack()
|
||||
func togglePlay()
|
||||
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> { get }
|
||||
var isWorking: Bool { get }
|
||||
func play() async
|
||||
func pause() async
|
||||
func seek(to time: Double) async
|
||||
func nextTrack() async
|
||||
func previousTrack() async
|
||||
func togglePlay() async
|
||||
func toggleShuffle() async
|
||||
func toggleRepeat() async
|
||||
func isActive() -> Bool
|
||||
func updatePlaybackInfo()
|
||||
func updatePlaybackInfo() async
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,98 +5,111 @@
|
|||
// 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: Published<PlaybackState>.Publisher { $playbackState }
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var lastMusicItem: (title: String, artist: String, album: String, duration: TimeInterval, artworkData: Data?)?
|
||||
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
|
||||
$playbackState.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
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()
|
||||
if let process = self.process {
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
process.waitUntilExit()
|
||||
}
|
||||
}
|
||||
|
||||
// Remove distributed notification observers
|
||||
DistributedNotificationCenter.default().removeObserver(
|
||||
self,
|
||||
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
|
||||
object: nil
|
||||
)
|
||||
if let pipe = self.pipe {
|
||||
pipe.fileHandleForReading.closeFile()
|
||||
pipe.fileHandleForWriting.closeFile()
|
||||
}
|
||||
|
||||
DistributedNotificationCenter.default().removeObserver(
|
||||
self,
|
||||
name: NSNotification.Name("com.apple.Music.playerInfo"),
|
||||
object: nil
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -104,94 +117,112 @@ class NowPlayingController: ObservableObject, MediaControllerProtocol {
|
|||
return true
|
||||
}
|
||||
|
||||
func toggleShuffle() async {
|
||||
// MRMediaRemoteSendCommandFunction(6, nil)
|
||||
MRMediaRemoteSetShuffleModeFunction(playbackState.isShuffled ? 3 : 1)
|
||||
playbackState.isShuffled.toggle()
|
||||
}
|
||||
|
||||
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() {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@objc func updatePlaybackInfo() {
|
||||
updateApp()
|
||||
let bundlePath = Bundle.main.bundlePath
|
||||
let frameworkPath = bundlePath + "/Contents/Frameworks/MediaRemoteAdapter.framework"
|
||||
|
||||
MRMediaRemoteGetNowPlayingInfo(DispatchQueue.main) { [weak self] information in
|
||||
guard let self = self else { return }
|
||||
isResourcesAvailable = true
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
|
||||
process.arguments = [scriptURL.path, frameworkPath, "stream"]
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
self.process = process
|
||||
self.pipe = pipe
|
||||
|
||||
let title = information["kMRMediaRemoteNowPlayingInfoTitle"] as? String ?? ""
|
||||
let artist = information["kMRMediaRemoteNowPlayingInfoArtist"] as? String ?? ""
|
||||
let album = information["kMRMediaRemoteNowPlayingInfoAlbum"] as? String ?? ""
|
||||
let duration = information["kMRMediaRemoteNowPlayingInfoDuration"] as? TimeInterval ?? 0
|
||||
let currentTime = information["kMRMediaRemoteNowPlayingInfoElapsedTime"] as? TimeInterval ?? 0
|
||||
let artworkData = information["kMRMediaRemoteNowPlayingInfoArtworkData"] as? Data
|
||||
let timestamp = information["kMRMediaRemoteNowPlayingInfoTimestamp"] as? Date ?? Date()
|
||||
let playbackRate = information["kMRMediaRemoteNowPlayingInfoPlaybackRate"] as? Double ?? 1
|
||||
|
||||
// Update playback state
|
||||
DispatchQueue.main.async {
|
||||
self.playbackState.title = title
|
||||
self.playbackState.artist = artist
|
||||
self.playbackState.album = album
|
||||
self.playbackState.duration = duration
|
||||
self.playbackState.currentTime = currentTime
|
||||
self.playbackState.artwork = artworkData
|
||||
self.playbackState.lastUpdated = timestamp
|
||||
self.playbackState.playbackRate = playbackRate
|
||||
|
||||
// Check playback state
|
||||
self.MRMediaRemoteGetNowPlayingApplicationIsPlaying(DispatchQueue.main) { [weak self] isPlaying in
|
||||
DispatchQueue.main.async {
|
||||
self?.playbackState.isPlaying = isPlaying
|
||||
pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
||||
let data = handle.availableData
|
||||
guard !data.isEmpty else { return }
|
||||
if let chunk = String(data: data, encoding: .utf8) {
|
||||
self?.buffer.append(chunk)
|
||||
while let range = self?.buffer.range(of: "\n") {
|
||||
let line = String(self?.buffer[..<range.lowerBound] ?? "")
|
||||
self?.buffer = String(self?.buffer[range.upperBound...] ?? "")
|
||||
if !line.isEmpty {
|
||||
self?.handleAdapterUpdate(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
print("⚠️ Failed to launch mediaremote-adapter.pl: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Update Methods
|
||||
private func handleAdapterUpdate(_ jsonLine: String) {
|
||||
guard let data = jsonLine.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let payload = object["payload"] as? [String: Any]
|
||||
else { return }
|
||||
|
||||
let diff = object["diff"] as? Bool ?? false
|
||||
|
||||
var newPlaybackState = PlaybackState(bundleIdentifier: playbackState.bundleIdentifier)
|
||||
|
||||
newPlaybackState.title = payload["title"] as? String ?? (diff ? self.playbackState.title : "")
|
||||
newPlaybackState.artist = payload["artist"] as? String ?? (diff ? self.playbackState.artist : "")
|
||||
newPlaybackState.album = payload["album"] as? String ?? (diff ? self.playbackState.album : "")
|
||||
newPlaybackState.duration = payload["duration"] as? Double ?? (diff ? self.playbackState.duration : 0)
|
||||
newPlaybackState.currentTime = payload["elapsedTime"] as? Double ?? (diff ? self.playbackState.currentTime : 0)
|
||||
|
||||
if let shuffleMode = payload["shuffleMode"] as? Int {
|
||||
newPlaybackState.isShuffled = shuffleMode != 1
|
||||
} else if !diff {
|
||||
newPlaybackState.isShuffled = false
|
||||
}
|
||||
if let repeatModeValue = payload["repeatMode"] as? Int {
|
||||
newPlaybackState.repeatMode = RepeatMode(rawValue: repeatModeValue) ?? .off
|
||||
} else if !diff {
|
||||
newPlaybackState.repeatMode = .off
|
||||
}
|
||||
|
||||
if let artworkDataString = payload["artworkData"] as? String {
|
||||
newPlaybackState.artwork = Data(
|
||||
base64Encoded: artworkDataString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
)
|
||||
} else if !diff {
|
||||
newPlaybackState.artwork = nil
|
||||
}
|
||||
|
||||
if let dateString = payload["timestamp"] as? String,
|
||||
let date = ISO8601DateFormatter().date(from: dateString) {
|
||||
newPlaybackState.lastUpdated = date
|
||||
}
|
||||
|
||||
newPlaybackState.playbackRate = payload["playbackRate"] as? Double ?? (diff ? self.playbackState.playbackRate : 1.0)
|
||||
newPlaybackState.isPlaying = payload["playing"] as? Bool ?? (diff ? self.playbackState.isPlaying : false)
|
||||
newPlaybackState.bundleIdentifier = (
|
||||
payload["parentApplicationBundleIdentifier"] as? String ??
|
||||
payload["bundleIdentifier"] as? String ??
|
||||
(diff ? self.playbackState.bundleIdentifier : "")
|
||||
)
|
||||
|
||||
self.playbackState = newPlaybackState
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// SpotifyController.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by Alexander Greco on 2025-03-29.
|
||||
// Created by Alexander on 2025-03-29.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
|
@ -15,59 +15,81 @@ class SpotifyController: MediaControllerProtocol {
|
|||
bundleIdentifier: "com.spotify.client"
|
||||
)
|
||||
|
||||
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState }
|
||||
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
|
||||
$playbackState.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
var isWorking: Bool {
|
||||
return true // Spotify controller always works
|
||||
}
|
||||
|
||||
private var notificationTask: Task<Void, Never>?
|
||||
|
||||
//Constant for time between command and update
|
||||
let commandUpdateDelay: Duration = .milliseconds(25)
|
||||
|
||||
init() {
|
||||
setupPlaybackStateChangeObserver()
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
if self?.isActive() == true {
|
||||
self?.updatePlaybackInfo()
|
||||
Task {
|
||||
if isActive() {
|
||||
await updatePlaybackInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupPlaybackStateChangeObserver() {
|
||||
DistributedNotificationCenter.default().addObserver(
|
||||
self,
|
||||
selector: #selector(updatePlaybackInfo),
|
||||
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
|
||||
object: nil
|
||||
)
|
||||
notificationTask = Task { @Sendable [weak self] in
|
||||
let notifications = DistributedNotificationCenter.default().notifications(
|
||||
named: NSNotification.Name("com.spotify.client.PlaybackStateChanged")
|
||||
)
|
||||
|
||||
for await _ in notifications {
|
||||
await self?.updatePlaybackInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Remove notification observer when controller is deallocated
|
||||
DistributedNotificationCenter.default().removeObserver(
|
||||
self,
|
||||
name: NSNotification.Name("com.spotify.client.PlaybackStateChanged"),
|
||||
object: nil
|
||||
)
|
||||
notificationTask?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Protocol Implementation
|
||||
func play() {
|
||||
executeCommand("play")
|
||||
func play() async {
|
||||
await executeCommand("play")
|
||||
}
|
||||
|
||||
func pause() {
|
||||
executeCommand("pause")
|
||||
func pause() async {
|
||||
await executeCommand("pause")
|
||||
}
|
||||
|
||||
func togglePlay() {
|
||||
executeCommand("playpause")
|
||||
func togglePlay() async {
|
||||
await executeCommand("playpause")
|
||||
}
|
||||
|
||||
func nextTrack() {
|
||||
executeCommand("next track")
|
||||
func nextTrack() async {
|
||||
await executeCommand("next track")
|
||||
}
|
||||
|
||||
func previousTrack() {
|
||||
executeCommand("previous track")
|
||||
func previousTrack() async {
|
||||
await executeCommand("previous track")
|
||||
}
|
||||
|
||||
func seek(to time: Double) {
|
||||
executeCommand("set player position to \(time)")
|
||||
updatePlaybackInfo()
|
||||
func seek(to time: Double) async {
|
||||
await executeCommand("set player position to \(time)")
|
||||
try? await Task.sleep(for: commandUpdateDelay)
|
||||
await updatePlaybackInfo()
|
||||
}
|
||||
|
||||
func toggleShuffle() async {
|
||||
await executeCommand("set shuffling to not shuffling")
|
||||
try? await Task.sleep(for: commandUpdateDelay)
|
||||
await updatePlaybackInfo()
|
||||
}
|
||||
|
||||
func toggleRepeat() async {
|
||||
await executeCommand("set repeating to not repeating")
|
||||
try? await Task.sleep(for: commandUpdateDelay)
|
||||
await updatePlaybackInfo()
|
||||
}
|
||||
|
||||
func isActive() -> Bool {
|
||||
|
|
@ -75,9 +97,8 @@ class SpotifyController: MediaControllerProtocol {
|
|||
return runningApps.contains { $0.bundleIdentifier == playbackState.bundleIdentifier }
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
@objc func updatePlaybackInfo() {
|
||||
guard let descriptor = fetchPlaybackInfo() else { return }
|
||||
func updatePlaybackInfo() async {
|
||||
guard let descriptor = try? await fetchPlaybackInfoAsync() else { return }
|
||||
guard descriptor.numberOfItems >= 9 else { return }
|
||||
|
||||
let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
|
||||
|
|
@ -100,42 +121,35 @@ class SpotifyController: MediaControllerProtocol {
|
|||
duration: duration,
|
||||
playbackRate: 1,
|
||||
isShuffled: isShuffled,
|
||||
isRepeating: isRepeating,
|
||||
repeatMode: isRepeating ? .all : .off,
|
||||
lastUpdated: Date()
|
||||
)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.playbackState = state
|
||||
}
|
||||
self.playbackState = state
|
||||
|
||||
// Load artwork asynchronously and update the state when complete
|
||||
if !artworkURL.isEmpty, let url = URL(string: artworkURL) {
|
||||
DispatchQueue.global(qos: .background).async { [weak self] in
|
||||
do {
|
||||
let artworkData = try Data(contentsOf: url)
|
||||
DispatchQueue.main.async {
|
||||
// Create a new state with the artwork data and update
|
||||
var updatedState = state
|
||||
updatedState.artwork = artworkData
|
||||
self?.playbackState = updatedState
|
||||
}
|
||||
} catch {
|
||||
print("Failed to load artwork: \(error)")
|
||||
}
|
||||
let currentState = state
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(from: url)
|
||||
// Create a new state with the artwork data and update
|
||||
var updatedState = currentState
|
||||
updatedState.artwork = data
|
||||
playbackState = updatedState
|
||||
} catch {
|
||||
print("Failed to load artwork: \(error)")
|
||||
}
|
||||
} else {
|
||||
self.playbackState = state
|
||||
}
|
||||
}
|
||||
|
||||
private func executeCommand(_ command: String) {
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func executeCommand(_ command: String) async {
|
||||
let script = "tell application \"Spotify\" to \(command)"
|
||||
Task {
|
||||
try? await AppleScriptHelper.executeVoid(script)
|
||||
}
|
||||
try? await AppleScriptHelper.executeVoid(script)
|
||||
}
|
||||
|
||||
private func fetchPlaybackInfo() -> NSAppleEventDescriptor? {
|
||||
private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? {
|
||||
let script = """
|
||||
tell application "Spotify"
|
||||
set isRunning to true
|
||||
|
|
@ -156,13 +170,6 @@ class SpotifyController: MediaControllerProtocol {
|
|||
end tell
|
||||
"""
|
||||
|
||||
var descriptor: NSAppleEventDescriptor? = nil
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
Task {
|
||||
descriptor = try? await AppleScriptHelper.execute(script)
|
||||
semaphore.signal()
|
||||
}
|
||||
semaphore.wait()
|
||||
return descriptor
|
||||
return try await AppleScriptHelper.execute(script)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
// DynamicIsland
|
||||
//
|
||||
// Created By Alexander on 2025-03-30.
|
||||
// Modified by Pranav on 2025-06-16.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
|
@ -12,143 +13,129 @@ import SwiftUI
|
|||
class YouTubeMusicController: MediaControllerProtocol {
|
||||
// MARK: - Properties
|
||||
@Published var playbackState: PlaybackState = .init(
|
||||
bundleIdentifier: "com.github.th-ch.youtube-music",
|
||||
isPlaying: true
|
||||
bundleIdentifier: "com.github.th-ch.youtube-music"
|
||||
)
|
||||
|
||||
var playbackStatePublisher: Published<PlaybackState>.Publisher { $playbackState }
|
||||
var playbackStatePublisher: AnyPublisher<PlaybackState, Never> {
|
||||
$playbackState.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
var isWorking: Bool {
|
||||
return true // YouTube Music controller always works
|
||||
}
|
||||
private let baseURL = "http://localhost:26538"
|
||||
private var accessToken: String?
|
||||
private var refreshTimer: Timer?
|
||||
private var periodicUpdateTask: Task<Void, Never>?
|
||||
private var notificationTask: Task<Void, Never>?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var playbackInfoCancellable: AnyCancellable?
|
||||
private var isAuthenticating = false
|
||||
private let authQueue = DispatchQueue(label: "com.dynamicisland.youtubemusicauth", qos: .background)
|
||||
|
||||
init() {
|
||||
setupAppStateObservers()
|
||||
authQueue.async { [weak self] in
|
||||
self?.authenticateAndSetup()
|
||||
}
|
||||
Task {await authenticateAndSetup()}
|
||||
}
|
||||
|
||||
deinit {
|
||||
stopPeriodicUpdates()
|
||||
notificationTask?.cancel()
|
||||
cancellables.forEach { $0.cancel() }
|
||||
NSWorkspace.shared.notificationCenter.removeObserver(self)
|
||||
}
|
||||
|
||||
// MARK: - Authentication
|
||||
|
||||
private func authenticateAndSetup() {
|
||||
private func authenticateAndSetup() async {
|
||||
// Prevent multiple concurrent authentication attempts
|
||||
guard !isAuthenticating else { return }
|
||||
|
||||
isAuthenticating = true
|
||||
getAccessToken { [weak self] success in
|
||||
guard let self = self else { return }
|
||||
let success = await getAccessToken()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
// 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)
|
||||
|
||||
URLSession.shared.dataTaskPublisher(for: request)
|
||||
.tryMap { data, response -> Data in
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
|
@ -159,20 +146,42 @@ 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 {
|
||||
|
|
@ -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
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
// Register for app termination notifications
|
||||
NSWorkspace.shared.notificationCenter.addObserver(
|
||||
self,
|
||||
selector: #selector(handleAppStateChange),
|
||||
name: NSWorkspace.didTerminateApplicationNotification,
|
||||
object: nil
|
||||
)
|
||||
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
|
||||
}
|
||||
|
||||
func updatePlaybackInfo() {
|
||||
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() async {
|
||||
if !isActive() {
|
||||
playbackState = PlaybackState(bundleIdentifier: playbackState.bundleIdentifier, isPlaying: false)
|
||||
stopPeriodicUpdates()
|
||||
return
|
||||
}
|
||||
guard let request = createAuthenticatedRequest(for: "/api/v1/song") else { return }
|
||||
|
||||
let backgroundQueue = DispatchQueue.global(qos: .utility)
|
||||
guard let request = await createAuthenticatedRequest(for: "/api/v1/song") else { return }
|
||||
|
||||
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
|
||||
self.accessToken = nil
|
||||
await authenticateAndSetup()
|
||||
throw URLError(.userAuthenticationRequired)
|
||||
} else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 {
|
||||
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)
|
||||
}
|
||||
let playbackResponse = try JSONDecoder().decode(PlaybackResponse.self, from: data)
|
||||
await updatePlaybackState(with: playbackResponse)
|
||||
|
||||
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)
|
||||
})
|
||||
} 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)
|
||||
}
|
||||
do {
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 {
|
||||
throw URLError(.userAuthenticationRequired)
|
||||
} else if httpResponse.statusCode < 200 || httpResponse.statusCode >= 300 {
|
||||
throw URLError(.badServerResponse)
|
||||
}
|
||||
|
||||
return data
|
||||
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?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
// DynamicIsland
|
||||
//
|
||||
// Created by Richard Kunkli on 07/08/2024.
|
||||
// Modified by Hariharan Mudaliar
|
||||
//
|
||||
|
||||
import AVFoundation
|
||||
import Defaults
|
||||
|
|
@ -18,8 +18,6 @@ import UniformTypeIdentifiers
|
|||
|
||||
struct SettingsView: View {
|
||||
@StateObject var extensionManager = DynamicIslandExtensionManager()
|
||||
@StateObject private var calendarManager = CalendarManager()
|
||||
|
||||
@State private var selectedTab = "General"
|
||||
|
||||
let updaterController: SPUStandardUpdaterController?
|
||||
|
|
@ -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,7 +155,6 @@ struct GeneralSettings: View {
|
|||
@Default(.automaticallySwitchDisplay) var automaticallySwitchDisplay
|
||||
@Default(.enableGestures) var enableGestures
|
||||
@Default(.openNotchOnHover) var openNotchOnHover
|
||||
@Default(.alwaysHideInFullscreen) var alwaysHideInFullscreen
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
|
|
@ -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,16 +536,15 @@ 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")
|
||||
|
|
@ -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,157 +607,6 @@ struct CalendarSettings: View {
|
|||
}
|
||||
}
|
||||
|
||||
struct TimerSettings: View {
|
||||
@ObservedObject private var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@Default(.enableTimerFeature) var enableTimerFeature
|
||||
@AppStorage("customTimerDuration") private var customTimerDuration: Double = 600 // 10 minutes default
|
||||
@State private var customMinutes: Int = 10
|
||||
@State private var customSeconds: Int = 0
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Defaults.Toggle("Enable timer feature", key: .enableTimerFeature)
|
||||
|
||||
if enableTimerFeature {
|
||||
Toggle(
|
||||
"Enable timer live activity",
|
||||
isOn: $coordinator.timerLiveActivityEnabled
|
||||
)
|
||||
.animation(.easeInOut, value: coordinator.timerLiveActivityEnabled)
|
||||
}
|
||||
} header: {
|
||||
Text("Timer Feature")
|
||||
} footer: {
|
||||
Text("Enable or disable the timer functionality in the Dynamic Island. The live activity toggle controls whether timer progress is shown in the expanded view.")
|
||||
}
|
||||
|
||||
if enableTimerFeature {
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Custom Timer Duration")
|
||||
.font(.headline)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
VStack {
|
||||
Text("Minutes")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Picker(selection: $customMinutes, label: Text("Minutes")) {
|
||||
ForEach(0...59, id: \.self) { minute in
|
||||
Text("\(minute)").tag(minute)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 80, height: 40)
|
||||
}
|
||||
|
||||
VStack {
|
||||
Text("Seconds")
|
||||
.font(.system(size: 14, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Picker(selection: $customSeconds, label: Text("Seconds")) {
|
||||
ForEach(0...59, id: \.self) { second in
|
||||
Text("\(second)").tag(second)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 80, height: 40)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Current custom timer: \(customTimerDisplayText)")
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Update") {
|
||||
customTimerDuration = Double(customMinutes * 60 + customSeconds)
|
||||
}
|
||||
.disabled(customMinutes == 0 && customSeconds == 0)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Custom Timer")
|
||||
} footer: {
|
||||
Text("Set a custom duration for the timer. This will be used when you press the 'Custom' button in the timer interface.")
|
||||
}
|
||||
|
||||
Section {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Timer Sound")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
Spacer()
|
||||
Button("Choose File") {
|
||||
selectCustomTimerSound()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
}
|
||||
|
||||
if let customTimerSoundPath = UserDefaults.standard.string(forKey: "customTimerSoundPath") {
|
||||
Text("Custom: \(URL(fileURLWithPath: customTimerSoundPath).lastPathComponent)")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Default: dynamic.m4a")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Button("Reset to Default") {
|
||||
UserDefaults.standard.removeObject(forKey: "customTimerSoundPath")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(UserDefaults.standard.string(forKey: "customTimerSoundPath") == nil)
|
||||
}
|
||||
} header: {
|
||||
Text("Timer Sound")
|
||||
} footer: {
|
||||
Text("Choose a custom sound file that will play when the timer completes. Supported formats: MP3, M4A, WAV, AIFF.")
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Timer")
|
||||
.onAppear {
|
||||
let totalMinutes = Int(customTimerDuration) / 60
|
||||
customMinutes = totalMinutes
|
||||
customSeconds = Int(customTimerDuration) % 60
|
||||
}
|
||||
}
|
||||
|
||||
private var customTimerDisplayText: String {
|
||||
let totalMinutes = Int(customTimerDuration) / 60
|
||||
let seconds = Int(customTimerDuration) % 60
|
||||
let hours = totalMinutes / 60
|
||||
let minutes = totalMinutes % 60
|
||||
|
||||
if hours > 0 {
|
||||
return "\(hours):\(String(format: "%02d", minutes)):\(String(format: "%02d", seconds))"
|
||||
} else if minutes > 0 {
|
||||
return "\(minutes):\(String(format: "%02d", seconds))"
|
||||
} else {
|
||||
return "\(seconds)s"
|
||||
}
|
||||
}
|
||||
|
||||
private func selectCustomTimerSound() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "Select Timer Sound"
|
||||
panel.allowedContentTypes = [.audio]
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.canChooseDirectories = false
|
||||
panel.canChooseFiles = true
|
||||
|
||||
if panel.runModal() == .OK {
|
||||
if let url = panel.url {
|
||||
UserDefaults.standard.set(url.path, forKey: "customTimerSoundPath")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct About: View {
|
||||
@State private var showBuildNumber: Bool = false
|
||||
let updaterController: SPUStandardUpdaterController
|
||||
|
|
@ -1289,7 +1142,204 @@ struct Shortcuts: View {
|
|||
}
|
||||
}
|
||||
|
||||
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 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 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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
67
DynamicIsland/helpers/MediaChecker.swift
Normal file
67
DynamicIsland/helpers/MediaChecker.swift
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
//
|
||||
// MediaChecker.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by Alexander on 2025-07-26.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class MediaChecker: Sendable {
|
||||
private(set) var isNowPlayingDeprecated: Bool = false
|
||||
|
||||
enum MediaCheckerError: Error {
|
||||
case missingResources
|
||||
case processExecutionFailed
|
||||
case timeout
|
||||
}
|
||||
|
||||
func checkDeprecationStatus() async throws -> Bool {
|
||||
guard let scriptURL = Bundle.main.url(forResource: "mediaremote-adapter", withExtension: "pl"),
|
||||
let nowPlayingTestClientPath = Bundle.main.url(forResource: "NowPlayingTestClient", withExtension: nil)?.path,
|
||||
let frameworkPath = Bundle.main.privateFrameworksPath?.appending("/MediaRemoteAdapter.framework")
|
||||
else {
|
||||
throw MediaCheckerError.missingResources
|
||||
}
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
|
||||
process.arguments = [scriptURL.path, frameworkPath, nowPlayingTestClientPath, "test"]
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
} catch {
|
||||
throw MediaCheckerError.processExecutionFailed
|
||||
}
|
||||
|
||||
// Timeout after 10 seconds
|
||||
let result: String = try await withThrowingTaskGroup(of: String?.self) { group in
|
||||
group.addTask {
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return String(data: data, encoding: .utf8)
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(for: .seconds(10))
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
return "0" // Default value if process takes too long
|
||||
}
|
||||
for try await output in group {
|
||||
if let output = output {
|
||||
group.cancelAll()
|
||||
return output
|
||||
}
|
||||
}
|
||||
throw MediaCheckerError.timeout
|
||||
}
|
||||
|
||||
let isDeprecated = result.trimmingCharacters(in: .whitespacesAndNewlines).last == "1"
|
||||
self.isNowPlayingDeprecated = isDeprecated
|
||||
return isDeprecated
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -19,15 +19,11 @@ class MusicManager: ObservableObject {
|
|||
static let shared = MusicManager()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var controllerCancellables = Set<AnyCancellable>()
|
||||
private var debounceToggle: DispatchWorkItem?
|
||||
private var debounceIdleTask: Task<Void, Never>?
|
||||
|
||||
// Helper to check if macOS is too new for NowPlayingController
|
||||
public var isNowPlayingDeprecated: Bool {
|
||||
if #available(macOS 15.4, *) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Helper to check if macOS has removed support for NowPlayingController
|
||||
public private(set) var isNowPlayingDeprecated: Bool = false
|
||||
private let mediaChecker = MediaChecker()
|
||||
|
||||
// Active controller
|
||||
private var activeController: (any MediaControllerProtocol)?
|
||||
|
|
@ -48,6 +44,8 @@ class MusicManager: ObservableObject {
|
|||
@Published var elapsedTime: TimeInterval = 0
|
||||
@Published var timestampDate: Date = .init()
|
||||
@Published var playbackRate: Double = 1
|
||||
@Published var isShuffled: Bool = false
|
||||
@Published var repeatMode: RepeatMode = .off
|
||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||
@Published var usingAppIconForArtwork: Bool = false
|
||||
|
||||
|
|
@ -68,12 +66,23 @@ class MusicManager: ObservableObject {
|
|||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Initialize the active controller
|
||||
setActiveControllerBasedOnPreference()
|
||||
// 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 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
// NotchSpaceManager.swift
|
||||
// DynamicIsland
|
||||
//
|
||||
// Created by Alexander Greco on 2024-10-27.
|
||||
// Created by Alexander on 2024-10-27.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
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 {
|
||||
|
|
@ -28,8 +28,8 @@ class BatteryStatusViewModel: ObservableObject {
|
|||
|
||||
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()
|
||||
|
|
@ -51,55 +51,58 @@ class BatteryStatusViewModel: ObservableObject {
|
|||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ extension Defaults.Keys {
|
|||
static let sneakPeekStyles = Key<SneakPeekStyle>("sneakPeekStyles", default: .standard)
|
||||
static let enableFullscreenMediaDetection = Key<Bool>("enableFullscreenMediaDetection", default: true)
|
||||
static let waitInterval = Key<Double>("waitInterval", default: 3)
|
||||
static let showShuffleAndRepeat = Key<Bool>("showShuffleAndRepeat", default: false)
|
||||
|
||||
// MARK: Battery
|
||||
static let showPowerStatusNotifications = Key<Bool>("showPowerStatusNotifications", default: true)
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ class DynamicIslandViewModel: NSObject, ObservableObject {
|
|||
@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?
|
||||
|
||||
@Published var notchSize: CGSize = getClosedNotchSize()
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
1
Frameworks/MediaRemoteAdapter.framework/MediaRemoteAdapter
Symbolic link
1
Frameworks/MediaRemoteAdapter.framework/MediaRemoteAdapter
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
Versions/Current/MediaRemoteAdapter
|
||||
1
Frameworks/MediaRemoteAdapter.framework/Resources
Symbolic link
1
Frameworks/MediaRemoteAdapter.framework/Resources
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
Versions/Current/Resources
|
||||
BIN
Frameworks/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter
Executable file
BIN
Frameworks/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter
Executable file
Binary file not shown.
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>English</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>MediaRemoteAdapter</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string></string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.vandenbe.MediaRemoteAdapter</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>MediaRemoteAdapter</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1</string>
|
||||
<key>CSResourcesFileMapped</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>files</key>
|
||||
<dict>
|
||||
<key>Resources/Info.plist</key>
|
||||
<data>
|
||||
M6AF1VWVJ1A/DSliCSjg170FqsY=
|
||||
</data>
|
||||
</dict>
|
||||
<key>files2</key>
|
||||
<dict>
|
||||
<key>Resources/Info.plist</key>
|
||||
<dict>
|
||||
<key>hash2</key>
|
||||
<data>
|
||||
z3yWmTAqjdrPJEZUQ+t6AVPhw0e/I8PAiVr0HIU2ivg=
|
||||
</data>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>rules</key>
|
||||
<dict>
|
||||
<key>^Resources/</key>
|
||||
<true/>
|
||||
<key>^Resources/.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Resources/Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^version.plist$</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>rules2</key>
|
||||
<dict>
|
||||
<key>.*\.dSYM($|/)</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>11</real>
|
||||
</dict>
|
||||
<key>^(.*/)?\.DS_Store$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>2000</real>
|
||||
</dict>
|
||||
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
|
||||
<dict>
|
||||
<key>nested</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>10</real>
|
||||
</dict>
|
||||
<key>^.*</key>
|
||||
<true/>
|
||||
<key>^Info\.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^PkgInfo$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^Resources/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Resources/Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^[^/]+$</key>
|
||||
<dict>
|
||||
<key>nested</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>10</real>
|
||||
</dict>
|
||||
<key>^embedded\.provisionprofile$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^version\.plist$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
1
Frameworks/MediaRemoteAdapter.framework/Versions/Current
Symbolic link
1
Frameworks/MediaRemoteAdapter.framework/Versions/Current
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
A
|
||||
1635
SettingsView.swift.md
Normal file
1635
SettingsView.swift.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1 @@
|
|||
Versions/Current/MediaRemoteAdapter
|
||||
1
mediaremote-adapter/MediaRemoteAdapter.framework/Resources
Symbolic link
1
mediaremote-adapter/MediaRemoteAdapter.framework/Resources
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
Versions/Current/Resources
|
||||
BIN
mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter
Executable file
BIN
mediaremote-adapter/MediaRemoteAdapter.framework/Versions/A/MediaRemoteAdapter
Executable file
Binary file not shown.
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>English</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>MediaRemoteAdapter</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string></string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.vandenbe.MediaRemoteAdapter</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>MediaRemoteAdapter</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.1.0</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.1</string>
|
||||
<key>CSResourcesFileMapped</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>files</key>
|
||||
<dict>
|
||||
<key>Resources/Info.plist</key>
|
||||
<data>
|
||||
M6AF1VWVJ1A/DSliCSjg170FqsY=
|
||||
</data>
|
||||
</dict>
|
||||
<key>files2</key>
|
||||
<dict>
|
||||
<key>Resources/Info.plist</key>
|
||||
<dict>
|
||||
<key>hash2</key>
|
||||
<data>
|
||||
z3yWmTAqjdrPJEZUQ+t6AVPhw0e/I8PAiVr0HIU2ivg=
|
||||
</data>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>rules</key>
|
||||
<dict>
|
||||
<key>^Resources/</key>
|
||||
<true/>
|
||||
<key>^Resources/.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Resources/Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^version.plist$</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>rules2</key>
|
||||
<dict>
|
||||
<key>.*\.dSYM($|/)</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>11</real>
|
||||
</dict>
|
||||
<key>^(.*/)?\.DS_Store$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>2000</real>
|
||||
</dict>
|
||||
<key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
|
||||
<dict>
|
||||
<key>nested</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>10</real>
|
||||
</dict>
|
||||
<key>^.*</key>
|
||||
<true/>
|
||||
<key>^Info\.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^PkgInfo$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^Resources/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/</key>
|
||||
<dict>
|
||||
<key>optional</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1000</real>
|
||||
</dict>
|
||||
<key>^Resources/.*\.lproj/locversion.plist$</key>
|
||||
<dict>
|
||||
<key>omit</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>1100</real>
|
||||
</dict>
|
||||
<key>^Resources/Base\.lproj/</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>1010</real>
|
||||
</dict>
|
||||
<key>^[^/]+$</key>
|
||||
<dict>
|
||||
<key>nested</key>
|
||||
<true/>
|
||||
<key>weight</key>
|
||||
<real>10</real>
|
||||
</dict>
|
||||
<key>^embedded\.provisionprofile$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
<key>^version\.plist$</key>
|
||||
<dict>
|
||||
<key>weight</key>
|
||||
<real>20</real>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -0,0 +1 @@
|
|||
A
|
||||
BIN
mediaremote-adapter/NowPlayingTestClient
Executable file
BIN
mediaremote-adapter/NowPlayingTestClient
Executable file
Binary file not shown.
257
mediaremote-adapter/mediaremote-adapter.pl
Executable file
257
mediaremote-adapter/mediaremote-adapter.pl
Executable file
|
|
@ -0,0 +1,257 @@
|
|||
#!/usr/bin/perl
|
||||
# Copyright (c) 2025 Jonas van den Berg
|
||||
# This file is licensed under the BSD 3-Clause License.
|
||||
|
||||
# For usage information read below or run the script without arguments.
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
use DynaLoader;
|
||||
use File::Spec;
|
||||
use File::Basename;
|
||||
|
||||
sub print_help() {
|
||||
print <<'HELP';
|
||||
Usage:
|
||||
mediaremote-adapter.pl FRAMEWORK_PATH [NOWPLAYING_CLIENT_PATH] [FUNCTION [PARAMS|OPTIONS...]]
|
||||
|
||||
FRAMEWORK_PATH:
|
||||
Absolute path to MediaRemoteAdapter.framework
|
||||
|
||||
NOWPLAYING_CLIENT_PATH (optional):
|
||||
Path to the NowPlayingTestClient executable (used for test mode)
|
||||
|
||||
FUNCTION:
|
||||
stream Streams now playing information (as diff by default)
|
||||
get Prints now playing information once with all available metadata
|
||||
send Sends a command to the now playing application
|
||||
seek Seeks to a specific timeline position
|
||||
shuffle Sets the shuffle mode
|
||||
repeat Sets the repeat mode
|
||||
speed Sets the playback speed
|
||||
|
||||
PARAMS:
|
||||
send(command)
|
||||
command: The MRCommand ID as a number (e.g. kMRPlay = 0)
|
||||
seek(position)
|
||||
position: The timeline position in microseconds
|
||||
shuffle(mode)
|
||||
mode: The shuffle mode
|
||||
repeat(mode)
|
||||
mode: The repeat mode
|
||||
speed(speed)
|
||||
speed: The playback speed
|
||||
|
||||
OPTIONS:
|
||||
get
|
||||
--now: Sets "elapsedTime" to the current elapsed time. By default,
|
||||
this value is the elapsed time at the time of the given "timestamp"
|
||||
stream
|
||||
--no-diff: Disable diffing and always dump all metadata
|
||||
--debounce=N: Delay in milliseconds to prevent spam (0 by default)
|
||||
get, stream
|
||||
--micros: Replaces the following time keys with microsecond equivalents
|
||||
"duration" -> "durationMicros"
|
||||
"elapsedTime" -> "elapsedTimeMicros"
|
||||
"timestamp" -> "timestampEpochMicros" (converted to epoch time)
|
||||
--human-readable, -h: Makes values human-readable. Use only for debugging.
|
||||
The JSON output is pretty-printed and the following keys are adapted:
|
||||
"artworkData" -> Binary data is truncated to a shorter representation
|
||||
|
||||
Examples (script name and framework path omitted):
|
||||
stream --no-diff --debounce=100
|
||||
send 2 # Toggles play/pause in the media player (kMRATogglePlayPause)
|
||||
repeat 3 # Sets the repeat mode to "playlist" (kMRARepeatModePlaylist)
|
||||
|
||||
HELP
|
||||
exit 0;
|
||||
}
|
||||
|
||||
if (!defined $ARGV[1]) {
|
||||
print_help();
|
||||
}
|
||||
|
||||
sub fail {
|
||||
my ($error) = @_;
|
||||
print STDERR "$error\n";
|
||||
exit 1;
|
||||
}
|
||||
|
||||
fail "Framework path not provided" unless @ARGV >= 1;
|
||||
|
||||
my $framework_path = shift @ARGV;
|
||||
|
||||
# Optionally accept NOWPLAYING_CLIENT path as second argument
|
||||
my $maybe_helper_path = $ARGV[0] // '';
|
||||
if ($maybe_helper_path =~ m{NowPlayingTestClient} || $maybe_helper_path =~ m{/}) {
|
||||
my $helper_path = shift @ARGV;
|
||||
$ENV{NOWPLAYING_CLIENT} = $helper_path;
|
||||
}
|
||||
|
||||
my $framework_basename = File::Basename::basename($framework_path);
|
||||
fail "Provided path is not a framework: $framework_path"
|
||||
unless $framework_basename =~ s/\.framework$//;
|
||||
|
||||
my $framework = File::Spec->catfile($framework_path, $framework_basename);
|
||||
fail "Framework not found at $framework" unless -e $framework;
|
||||
|
||||
my $handle = DynaLoader::dl_load_file($framework, 0)
|
||||
or fail "Failed to load framework: $framework";
|
||||
my $function_name = shift @ARGV or fail "Missing function name";
|
||||
fail "Invalid function name: '$function_name'"
|
||||
unless $function_name eq "stream"
|
||||
|| $function_name eq "get"
|
||||
|| $function_name eq "send"
|
||||
|| $function_name eq "seek"
|
||||
|| $function_name eq "shuffle"
|
||||
|| $function_name eq "repeat"
|
||||
|| $function_name eq "speed"
|
||||
|| $function_name eq "test";
|
||||
|
||||
sub parse_options {
|
||||
my ($start_index) = @_;
|
||||
my %arg_map;
|
||||
my $i = $start_index;
|
||||
while ($i <= $#ARGV) {
|
||||
my $arg = $ARGV[$i];
|
||||
if ($arg =~ /^--([a-z\\-]+)(?:=(.*))?$/) {
|
||||
my $key = $1;
|
||||
my $value = defined $2 ? $2 : undef;
|
||||
$arg_map{$key} = $value;
|
||||
splice @ARGV, $i, 1;
|
||||
}
|
||||
elsif ($arg =~ /^-([a-zA-Z]+)$/) {
|
||||
my @flags = split //, $1;
|
||||
$arg_map{$_} = undef for @flags;
|
||||
splice @ARGV, $i, 1;
|
||||
}
|
||||
else {
|
||||
$i++;
|
||||
}
|
||||
}
|
||||
return \%arg_map;
|
||||
}
|
||||
|
||||
sub env_func {
|
||||
my $symbol_name = shift;
|
||||
return "${symbol_name}_env";
|
||||
}
|
||||
|
||||
sub set_env_param {
|
||||
my ($func, $index, $name, $value) = @_;
|
||||
$ENV{"MEDIAREMOTEADAPTER_PARAM_${func}_${index}_${name}"} = "$value";
|
||||
}
|
||||
|
||||
sub set_env_option_unsafe {
|
||||
my ($name, $value) = @_;
|
||||
$name =~ s/-/_/g;
|
||||
$ENV{"MEDIAREMOTEADAPTER_OPTION_${name}"} = defined $value ? "$value" : "";
|
||||
}
|
||||
|
||||
sub set_env_option {
|
||||
my ($options, $key) = @_;
|
||||
my $value = $options->{$key};
|
||||
if (defined $value) {
|
||||
fail "Unexpected value for option '$key'";
|
||||
}
|
||||
set_env_option_unsafe($key, $value);
|
||||
}
|
||||
|
||||
sub set_env_option_value {
|
||||
my ($options, $key) = @_;
|
||||
my $value = $options->{$key};
|
||||
if (!defined $value) {
|
||||
fail "Missing value for option '$key'";
|
||||
}
|
||||
set_env_option_unsafe($key, $value);
|
||||
}
|
||||
|
||||
my $symbol_name = "adapter_$function_name";
|
||||
if ($function_name eq "send") {
|
||||
my $id = shift @ARGV;
|
||||
fail "Missing ID for '$function_name' command" unless defined $id;
|
||||
set_env_param($symbol_name, 0, "command", "$id");
|
||||
$symbol_name = env_func($symbol_name);
|
||||
}
|
||||
elsif ($function_name eq "stream") {
|
||||
my $options = parse_options(0);
|
||||
foreach my $key (keys %{$options}) {
|
||||
if ($key eq "no-diff") {
|
||||
set_env_option($options, $key);
|
||||
}
|
||||
elsif ($key eq "debounce") {
|
||||
set_env_option_value($options, $key);
|
||||
}
|
||||
elsif ($key eq "micros") {
|
||||
set_env_option($options, $key);
|
||||
}
|
||||
elsif ($key eq "human-readable" || $key eq "h") {
|
||||
set_env_option($options, "human-readable");
|
||||
}
|
||||
else {
|
||||
fail "Unrecognized option '$key'";
|
||||
}
|
||||
}
|
||||
$symbol_name = env_func($symbol_name);
|
||||
}
|
||||
elsif ($function_name eq "get") {
|
||||
my $options = parse_options(0);
|
||||
foreach my $key (keys %{$options}) {
|
||||
if ($key eq "micros") {
|
||||
set_env_option($options, $key);
|
||||
}
|
||||
elsif ($key eq "human-readable" || $key eq "h") {
|
||||
set_env_option($options, "human-readable");
|
||||
}
|
||||
elsif ($key eq "now") {
|
||||
set_env_option($options, $key);
|
||||
}
|
||||
else {
|
||||
fail "Unrecognized option '$key'";
|
||||
}
|
||||
}
|
||||
$symbol_name = env_func($symbol_name);
|
||||
}
|
||||
elsif ($function_name eq "seek") {
|
||||
my $position = shift @ARGV;
|
||||
fail "Missing position for '$function_name' command" unless defined $position;
|
||||
set_env_param($symbol_name, 0, "position", "$position");
|
||||
$symbol_name = env_func($symbol_name);
|
||||
}
|
||||
elsif ($function_name eq "shuffle") {
|
||||
my $mode = shift @ARGV;
|
||||
fail "Missing mode for '$function_name' command" unless defined $mode;
|
||||
set_env_param($symbol_name, 0, "mode", "$mode");
|
||||
$symbol_name = env_func($symbol_name);
|
||||
}
|
||||
elsif ($function_name eq "repeat") {
|
||||
my $mode = shift @ARGV;
|
||||
fail "Missing mode for '$function_name' command" unless defined $mode;
|
||||
set_env_param($symbol_name, 0, "mode", "$mode");
|
||||
$symbol_name = env_func($symbol_name);
|
||||
}
|
||||
elsif ($function_name eq "speed") {
|
||||
my $speed = shift @ARGV;
|
||||
fail "Missing speed for '$function_name' command" unless defined $speed;
|
||||
set_env_param($symbol_name, 0, "speed", "$speed");
|
||||
$symbol_name = env_func($symbol_name);
|
||||
}
|
||||
elsif ($function_name eq "test") {
|
||||
$symbol_name = "_adapter_is_it_broken_yet";
|
||||
}
|
||||
|
||||
if (defined shift @ARGV) {
|
||||
fail "Too many arguments";
|
||||
}
|
||||
|
||||
my $symbol = DynaLoader::dl_find_symbol($handle, "$symbol_name")
|
||||
or fail "Symbol '$symbol_name' not found in $framework";
|
||||
DynaLoader::dl_install_xsub("main::$function_name", $symbol);
|
||||
|
||||
eval {
|
||||
no strict "refs";
|
||||
&{"main::$function_name"}();
|
||||
};
|
||||
if ($@) {
|
||||
fail "Error executing $function_name: $@";
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue