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