mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Working LottieAnimations with Scaling and stuff
This commit is contained in:
parent
c00b6db984
commit
e500f5a5b2
13 changed files with 1451 additions and 1 deletions
|
|
@ -21,6 +21,9 @@
|
||||||
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 */; };
|
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */; };
|
||||||
7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */; };
|
7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */ = {isa = PBXBuildFile; fileRef = 7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */; };
|
||||||
|
7E8840D72E9A346700C5CB8C /* Dog waiting.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840D62E9A346700C5CB8C /* Dog waiting.json */; };
|
||||||
|
7E8840D92E9A347000C5CB8C /* Moody Dog.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840D82E9A347000C5CB8C /* Moody Dog.json */; };
|
||||||
|
7E8840DB2E9A347800C5CB8C /* Orange Cat Peeping.json in Resources */ = {isa = PBXBuildFile; fileRef = 7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */; };
|
||||||
7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; };
|
7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */; };
|
||||||
7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; };
|
7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7EE1D73E2E6B1E1200997219 /* IOKit.framework */; };
|
||||||
BA90505C953246D0AB206451 /* MediaRemoteAdapter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; };
|
BA90505C953246D0AB206451 /* MediaRemoteAdapter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */; };
|
||||||
|
|
@ -47,6 +50,9 @@
|
||||||
7E25ECA42E6C6E7800700E72 /* DisplayServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = DisplayServices.framework; path = /System/Library/PrivateFrameworks/DisplayServices.framework; sourceTree = "<absolute>"; };
|
7E25ECA42E6C6E7800700E72 /* DisplayServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = DisplayServices.framework; path = /System/Library/PrivateFrameworks/DisplayServices.framework; sourceTree = "<absolute>"; };
|
||||||
7E81303D2E3FB40500E08F3F /* mediaremote-adapter.pl */ = {isa = PBXFileReference; lastKnownFileType = text.script.perl; path = "mediaremote-adapter.pl"; sourceTree = "<group>"; };
|
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>"; };
|
7E81303E2E3FB40500E08F3F /* NowPlayingTestClient */ = {isa = PBXFileReference; lastKnownFileType = "compiled.mach-o.executable"; path = NowPlayingTestClient; sourceTree = "<group>"; };
|
||||||
|
7E8840D62E9A346700C5CB8C /* Dog waiting.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Dog waiting.json"; sourceTree = "<group>"; };
|
||||||
|
7E8840D82E9A347000C5CB8C /* Moody Dog.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Moody Dog.json"; sourceTree = "<group>"; };
|
||||||
|
7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "Orange Cat Peeping.json"; sourceTree = "<group>"; };
|
||||||
7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ApplicationServices.framework; path = System/Library/Frameworks/ApplicationServices.framework; sourceTree = SDKROOT; };
|
7EE1D73C2E6B1E0B00997219 /* ApplicationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ApplicationServices.framework; path = System/Library/Frameworks/ApplicationServices.framework; sourceTree = SDKROOT; };
|
||||||
7EE1D73E2E6B1E1200997219 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; };
|
7EE1D73E2E6B1E1200997219 /* IOKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = IOKit.framework; path = System/Library/Frameworks/IOKit.framework; sourceTree = SDKROOT; };
|
||||||
F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = Frameworks/MediaRemoteAdapter.framework; sourceTree = SOURCE_ROOT; };
|
F4C1DD09EC134302938E84F6 /* MediaRemoteAdapter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MediaRemoteAdapter.framework; path = Frameworks/MediaRemoteAdapter.framework; sourceTree = SOURCE_ROOT; };
|
||||||
|
|
@ -109,9 +115,20 @@
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
7E8840D52E9A341F00C5CB8C /* LottieAnimations */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
7E8840D62E9A346700C5CB8C /* Dog waiting.json */,
|
||||||
|
7E8840D82E9A347000C5CB8C /* Moody Dog.json */,
|
||||||
|
7E8840DA2E9A347800C5CB8C /* Orange Cat Peeping.json */,
|
||||||
|
);
|
||||||
|
path = LottieAnimations;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
F54406DE2CD4987A00C7AFD9 = {
|
F54406DE2CD4987A00C7AFD9 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
7E8840D52E9A341F00C5CB8C /* LottieAnimations */,
|
||||||
7E81304F2E3FB42700E08F3F /* Frameworks */,
|
7E81304F2E3FB42700E08F3F /* Frameworks */,
|
||||||
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */,
|
7E81303F2E3FB40500E08F3F /* mediaremote-adapter */,
|
||||||
F54406E92CD4987A00C7AFD9 /* DynamicIsland */,
|
F54406E92CD4987A00C7AFD9 /* DynamicIsland */,
|
||||||
|
|
@ -215,6 +232,9 @@
|
||||||
files = (
|
files = (
|
||||||
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */,
|
7E8130402E3FB40500E08F3F /* mediaremote-adapter.pl in Resources */,
|
||||||
7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */,
|
7E8130412E3FB40500E08F3F /* NowPlayingTestClient in Resources */,
|
||||||
|
7E8840D92E9A347000C5CB8C /* Moody Dog.json in Resources */,
|
||||||
|
7E8840D72E9A346700C5CB8C /* Dog waiting.json in Resources */,
|
||||||
|
7E8840DB2E9A347800C5CB8C /* Orange Cat Peeping.json in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -516,7 +516,7 @@ struct ContentView: View {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(.black)
|
.fill(.black)
|
||||||
.frame(width: vm.closedNotchSize.width - 20)
|
.frame(width: vm.closedNotchSize.width - 20)
|
||||||
MinimalFaceFeatures()
|
IdleAnimationView()
|
||||||
}
|
}
|
||||||
}.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
}.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
let webcamManager = WebcamManager.shared
|
let webcamManager = WebcamManager.shared
|
||||||
let dndManager = DoNotDisturbManager.shared // NEW: DND detection
|
let dndManager = DoNotDisturbManager.shared // NEW: DND detection
|
||||||
let bluetoothAudioManager = BluetoothAudioManager.shared // NEW: Bluetooth audio detection
|
let bluetoothAudioManager = BluetoothAudioManager.shared // NEW: Bluetooth audio detection
|
||||||
|
let idleAnimationManager = IdleAnimationManager.shared // NEW: Custom idle animations
|
||||||
var closeNotchWorkItem: DispatchWorkItem?
|
var closeNotchWorkItem: DispatchWorkItem?
|
||||||
private var previousScreens: [NSScreen]?
|
private var previousScreens: [NSScreen]?
|
||||||
private var onboardingWindowController: NSWindowController?
|
private var onboardingWindowController: NSWindowController?
|
||||||
|
|
@ -402,6 +403,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
// Migrate legacy progress bar settings
|
// Migrate legacy progress bar settings
|
||||||
Defaults.Keys.migrateProgressBarStyle()
|
Defaults.Keys.migrateProgressBarStyle()
|
||||||
|
|
||||||
|
// Initialize idle animations (load bundled + built-in face)
|
||||||
|
idleAnimationManager.initializeDefaultAnimations()
|
||||||
|
|
||||||
// Setup SystemHUD Manager
|
// Setup SystemHUD Manager
|
||||||
SystemHUDManager.shared.setup(coordinator: coordinator)
|
SystemHUDManager.shared.setup(coordinator: coordinator)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -810,6 +810,10 @@
|
||||||
},
|
},
|
||||||
"%lld" : {
|
"%lld" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"%lld animations" : {
|
||||||
|
"comment" : "A label displaying the number of custom animations available. The argument is the count of custom animations.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"%lld colors" : {
|
"%lld colors" : {
|
||||||
|
|
||||||
|
|
@ -917,6 +921,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"%lldx" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"•" : {
|
"•" : {
|
||||||
"comment" : "A period used to separate two related items.",
|
"comment" : "A period used to separate two related items.",
|
||||||
|
|
@ -1233,6 +1240,9 @@
|
||||||
},
|
},
|
||||||
"+%lld more" : {
|
"+%lld more" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"1x" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"3 items" : {
|
"3 items" : {
|
||||||
|
|
||||||
|
|
@ -1254,9 +1264,18 @@
|
||||||
},
|
},
|
||||||
"15 colors" : {
|
"15 colors" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"20" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"20 colors" : {
|
"20 colors" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"30" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"100%" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"About" : {
|
"About" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -1678,8 +1697,16 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Add Animation from URL" : {
|
||||||
|
"comment" : "A title for the sheet that allows users to add animations from a URL.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Add files" : {
|
"Add files" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Add from URL..." : {
|
||||||
|
"comment" : "A button label that prompts the user to add a Lottie animation from a URL.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Add manually" : {
|
"Add manually" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -1781,6 +1808,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Add New" : {
|
||||||
|
"comment" : "A label displayed below the button to add a new idle animation.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Add new visualizer" : {
|
"Add new visualizer" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -2596,6 +2627,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Animation" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Animation Name" : {
|
||||||
|
"comment" : "A label for the name input field in the URL import sheet.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"API Configuration" : {
|
"API Configuration" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
@ -2802,6 +2840,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Are you sure you want to delete \"%@\"?" : {
|
||||||
|
"comment" : "A confirmation prompt asking the user to confirm the deletion of a custom idle animation. The placeholder text inside the quotation marks should be replaced with the name of the animation.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Are you sure you want to delete this color from your history?" : {
|
"Are you sure you want to delete this color from your history?" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
@ -3410,6 +3452,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Blue dashed box shows actual size in Dynamic Island" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Bluetooth Audio Devices" : {
|
"Bluetooth Audio Devices" : {
|
||||||
"comment" : "A section header that lists Bluetooth audio devices.",
|
"comment" : "A section header that lists Bluetooth audio devices.",
|
||||||
|
|
@ -3527,6 +3572,10 @@
|
||||||
},
|
},
|
||||||
"Brightness HUD" : {
|
"Brightness HUD" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Built-in" : {
|
||||||
|
"comment" : "A label indicating that an animation is a built-in one.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Built-in System Monitoring" : {
|
"Built-in System Monitoring" : {
|
||||||
|
|
||||||
|
|
@ -3836,6 +3885,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Center" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Change" : {
|
"Change" : {
|
||||||
|
|
||||||
|
|
@ -4245,6 +4297,10 @@
|
||||||
},
|
},
|
||||||
"Choose AI model" : {
|
"Choose AI model" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Choose animation to display when Dynamic Island is idle. Tap to select, hold to delete custom animations." : {
|
||||||
|
"comment" : "A footer label under the section that lists their idle animations.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Choose File" : {
|
"Choose File" : {
|
||||||
|
|
||||||
|
|
@ -5188,6 +5244,9 @@
|
||||||
},
|
},
|
||||||
"Custom: %@" : {
|
"Custom: %@" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Customize Animation" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Customize in Settings" : {
|
"Customize in Settings" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -5394,6 +5453,10 @@
|
||||||
},
|
},
|
||||||
"Delete" : {
|
"Delete" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Delete Animation" : {
|
||||||
|
"comment" : "The title of an alert that appears when trying to delete a custom animation.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Delete Color" : {
|
"Delete Color" : {
|
||||||
|
|
||||||
|
|
@ -6253,6 +6316,10 @@
|
||||||
},
|
},
|
||||||
"Email address" : {
|
"Email address" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Enable \"Show cool face animation while inactivity\" to customize animations" : {
|
||||||
|
"comment" : "A message displayed below the section header in the IdleAnimationsSettingsSection, explaining that users can customize animations by enabling this feature.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"Enable Built-in System HUD" : {
|
"Enable Built-in System HUD" : {
|
||||||
|
|
||||||
|
|
@ -7208,6 +7275,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Fill Notch" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Finish" : {
|
"Finish" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -7308,6 +7378,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Fit to Notch" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Gemini API Key" : {
|
"Gemini API Key" : {
|
||||||
|
|
||||||
|
|
@ -8020,6 +8093,9 @@
|
||||||
},
|
},
|
||||||
"Graph Visibility" : {
|
"Graph Visibility" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Height:" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Hide" : {
|
"Hide" : {
|
||||||
|
|
||||||
|
|
@ -8329,6 +8405,13 @@
|
||||||
},
|
},
|
||||||
"History Size" : {
|
"History Size" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Horizontal:" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"https://example.com/animation.json" : {
|
||||||
|
"comment" : "A placeholder text for a Lottie JSON URL input field.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
},
|
},
|
||||||
"https://github.com/th-ch/youtube-music" : {
|
"https://github.com/th-ch/youtube-music" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -8630,6 +8713,21 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Idle Animation Style" : {
|
||||||
|
"comment" : "The header label for the section that lets users choose the style of idle animation.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Import" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Import Error" : {
|
||||||
|
"comment" : "The title of an alert that appears when there is an error importing a Lottie animation.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Import File..." : {
|
||||||
|
"comment" : "A menu option to import a Lottie animation from a file.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"In progress" : {
|
"In progress" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -11290,6 +11388,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"My Animation" : {
|
||||||
|
"comment" : "A default name for a new animation added via a URL.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Name" : {
|
"Name" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -12760,6 +12862,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Opacity:" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Open Google AI Studio" : {
|
"Open Google AI Studio" : {
|
||||||
|
|
||||||
|
|
@ -12949,6 +13054,15 @@
|
||||||
},
|
},
|
||||||
"Popover mode shows the assistant as a dropdown attached to the AI button. Panel mode shows the assistant in a floating window near the notch." : {
|
"Popover mode shows the assistant as a dropdown attached to the AI button. Panel mode shows the assistant in a floating window near the notch." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Position" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Preview" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Preview Zoom:" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Privacy & Security" : {
|
"Privacy & Security" : {
|
||||||
"comment" : "The title of the privacy and security settings screen.",
|
"comment" : "The title of the privacy and security settings screen.",
|
||||||
|
|
@ -13070,6 +13184,9 @@
|
||||||
},
|
},
|
||||||
"Quick Actions" : {
|
"Quick Actions" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Quick Presets" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Quit" : {
|
"Quit" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -13507,6 +13624,12 @@
|
||||||
},
|
},
|
||||||
"Replaces system HUD notifications with Dynamic Island displays." : {
|
"Replaces system HUD notifications with Dynamic Island displays." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Reset" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Reset All" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Reset to Default" : {
|
"Reset to Default" : {
|
||||||
|
|
||||||
|
|
@ -13616,6 +13739,9 @@
|
||||||
},
|
},
|
||||||
"Resume" : {
|
"Resume" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Rotation:" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Running" : {
|
"Running" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -13722,6 +13848,15 @@
|
||||||
},
|
},
|
||||||
"Save Configuration" : {
|
"Save Configuration" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Scale Down" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Scale Up 2x" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Scale:" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Screen Assistant" : {
|
"Screen Assistant" : {
|
||||||
|
|
||||||
|
|
@ -13978,6 +14113,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Selected" : {
|
||||||
|
"comment" : "A label indicating that an animation is currently selected.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Selected animation" : {
|
"Selected animation" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"ar" : {
|
"ar" : {
|
||||||
|
|
@ -14919,6 +15058,9 @@
|
||||||
},
|
},
|
||||||
"Shows the model's reasoning process before the final answer" : {
|
"Shows the model's reasoning process before the final answer" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Size & Scale" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Slider color" : {
|
"Slider color" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -15427,6 +15569,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Speed:" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Square" : {
|
"Square" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -16222,6 +16367,9 @@
|
||||||
"Top Processes by %@" : {
|
"Top Processes by %@" : {
|
||||||
"comment" : "A heading that announces the top processes by a specific metric. The text inside the parentheses is replaced with the actual metric name.",
|
"comment" : "A heading that announces the top processes by a specific metric. The text inside the parentheses is replaced with the actual metric name.",
|
||||||
"isCommentAutoGenerated" : true
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
|
"Transform" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Transform your notch truly yours" : {
|
"Transform your notch truly yours" : {
|
||||||
|
|
||||||
|
|
@ -17039,6 +17187,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Vertical:" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"View on GitHub: th-ch/youtube-music" : {
|
"View on GitHub: th-ch/youtube-music" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
|
|
@ -17348,6 +17499,9 @@
|
||||||
},
|
},
|
||||||
"When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : {
|
"When enabled, the Stats tab will display real-time system performance graphs. This feature requires system permissions and may use additional battery." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Width:" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"With %lld graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row." : {
|
"With %lld graphs enabled, the Dynamic Island will expand horizontally to accommodate all graphs in a single row." : {
|
||||||
|
|
||||||
|
|
|
||||||
74
DynamicIsland/components/IdleAnimationView.swift
Normal file
74
DynamicIsland/components/IdleAnimationView.swift
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
//
|
||||||
|
// IdleAnimationView.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Created by AI Assistant on 11/10/2025.
|
||||||
|
// Unified view for displaying idle animations (Lottie or built-in face)
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Lottie
|
||||||
|
import LottieUI
|
||||||
|
import Defaults
|
||||||
|
|
||||||
|
struct IdleAnimationView: View {
|
||||||
|
@Default(.selectedIdleAnimation) var selectedAnimation
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if let animation = selectedAnimation {
|
||||||
|
AnimationContentView(animation: animation)
|
||||||
|
} else {
|
||||||
|
// Fallback to original face if nothing selected
|
||||||
|
MinimalFaceFeatures(height: 20, width: 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal view that renders the actual animation content
|
||||||
|
private struct AnimationContentView: View {
|
||||||
|
let animation: CustomIdleAnimation
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let config = animation.transformConfig
|
||||||
|
|
||||||
|
switch animation.source {
|
||||||
|
case .lottieFile(let url):
|
||||||
|
LottieView(state: LUStateData(
|
||||||
|
type: .loadedFrom(url),
|
||||||
|
speed: animation.speed,
|
||||||
|
loopMode: .loop
|
||||||
|
))
|
||||||
|
.frame(width: config.cropWidth * config.scale,
|
||||||
|
height: config.cropHeight * config.scale)
|
||||||
|
.offset(x: config.offsetX, y: config.offsetY)
|
||||||
|
.rotationEffect(.degrees(config.rotation))
|
||||||
|
.opacity(config.opacity)
|
||||||
|
|
||||||
|
case .lottieURL(let url):
|
||||||
|
LottieView(state: LUStateData(
|
||||||
|
type: .loadedFrom(url),
|
||||||
|
speed: animation.speed,
|
||||||
|
loopMode: .loop
|
||||||
|
))
|
||||||
|
.frame(width: config.cropWidth * config.scale,
|
||||||
|
height: config.cropHeight * config.scale)
|
||||||
|
.offset(x: config.offsetX, y: config.offsetY)
|
||||||
|
.rotationEffect(.degrees(config.rotation))
|
||||||
|
.opacity(config.opacity)
|
||||||
|
|
||||||
|
case .builtInFace:
|
||||||
|
MinimalFaceFeatures(height: 20, width: 30)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
#Preview {
|
||||||
|
ZStack {
|
||||||
|
Color.black
|
||||||
|
IdleAnimationView()
|
||||||
|
}
|
||||||
|
.frame(width: 100, height: 50)
|
||||||
|
}
|
||||||
427
DynamicIsland/components/Settings/AnimationEditorView.swift
Normal file
427
DynamicIsland/components/Settings/AnimationEditorView.swift
Normal file
|
|
@ -0,0 +1,427 @@
|
||||||
|
//
|
||||||
|
// AnimationEditorView.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Created by AI Assistant on 11/10/2025.
|
||||||
|
// Editor for resizing, repositioning, and cropping Lottie animations
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import LottieUI
|
||||||
|
import Defaults
|
||||||
|
|
||||||
|
struct AnimationEditorView: View {
|
||||||
|
@Environment(\.dismiss) var dismiss
|
||||||
|
|
||||||
|
let sourceURL: URL
|
||||||
|
let isRemoteURL: Bool
|
||||||
|
@Binding var animation: CustomIdleAnimation?
|
||||||
|
|
||||||
|
@State private var name: String
|
||||||
|
@State private var speed: CGFloat = 1.0
|
||||||
|
@State private var scale: CGFloat = 1.0
|
||||||
|
@State private var offsetX: CGFloat = 0
|
||||||
|
@State private var offsetY: CGFloat = 0
|
||||||
|
@State private var cropWidth: CGFloat = 30
|
||||||
|
@State private var cropHeight: CGFloat = 20
|
||||||
|
@State private var rotation: CGFloat = 0
|
||||||
|
@State private var opacity: CGFloat = 1.0
|
||||||
|
|
||||||
|
@State private var showingError = false
|
||||||
|
@State private var errorMessage = ""
|
||||||
|
@State private var isImporting = false
|
||||||
|
|
||||||
|
// Preview state
|
||||||
|
@State private var previewScale: CGFloat = 1.0
|
||||||
|
|
||||||
|
init(sourceURL: URL, isRemoteURL: Bool, animation: Binding<CustomIdleAnimation?>) {
|
||||||
|
self.sourceURL = sourceURL
|
||||||
|
self.isRemoteURL = isRemoteURL
|
||||||
|
self._animation = animation
|
||||||
|
|
||||||
|
// Initialize name from URL
|
||||||
|
let fileName = sourceURL.deletingPathExtension().lastPathComponent
|
||||||
|
_name = State(initialValue: fileName)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header
|
||||||
|
HStack {
|
||||||
|
Text("Customize Animation")
|
||||||
|
.font(.title2)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Cancel") {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
HSplitView {
|
||||||
|
// Preview Panel
|
||||||
|
VStack {
|
||||||
|
Text("Preview")
|
||||||
|
.font(.headline)
|
||||||
|
.padding(.top)
|
||||||
|
|
||||||
|
ZStack {
|
||||||
|
// Background to show bounds
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.fill(Color(nsColor: .controlBackgroundColor))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.strokeBorder(Color.gray.opacity(0.3), lineWidth: 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Target size indicator (30x20 - actual size in notch)
|
||||||
|
Rectangle()
|
||||||
|
.strokeBorder(Color.blue.opacity(0.5), style: StrokeStyle(lineWidth: 2, dash: [5, 3]))
|
||||||
|
.frame(width: 30 * previewScale, height: 20 * previewScale)
|
||||||
|
|
||||||
|
// Animation preview with transformations
|
||||||
|
LottieView(state: LUStateData(
|
||||||
|
type: .loadedFrom(sourceURL),
|
||||||
|
speed: speed,
|
||||||
|
loopMode: .loop
|
||||||
|
))
|
||||||
|
.frame(width: cropWidth * scale * previewScale, height: cropHeight * scale * previewScale)
|
||||||
|
.offset(x: offsetX * previewScale, y: offsetY * previewScale)
|
||||||
|
.rotationEffect(.degrees(rotation))
|
||||||
|
.opacity(opacity)
|
||||||
|
.clipped()
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.padding()
|
||||||
|
|
||||||
|
// Preview Scale Control
|
||||||
|
HStack {
|
||||||
|
Text("Preview Zoom:")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Slider(value: $previewScale, in: 1...10)
|
||||||
|
.frame(width: 150)
|
||||||
|
Text("\(Int(previewScale))x")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 30)
|
||||||
|
}
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.bottom)
|
||||||
|
}
|
||||||
|
.frame(minWidth: 300)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Controls Panel
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
// Name
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Animation Name")
|
||||||
|
.font(.headline)
|
||||||
|
TextField("Name", text: $name)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Size Controls
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Size & Scale")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
// Scale
|
||||||
|
HStack {
|
||||||
|
Text("Scale:")
|
||||||
|
.frame(width: 80, alignment: .leading)
|
||||||
|
Slider(value: $scale, in: 0.1...5.0)
|
||||||
|
Text(String(format: "%.2f", scale))
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("Reset") {
|
||||||
|
withAnimation { scale = 1.0 }
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crop Width
|
||||||
|
HStack {
|
||||||
|
Text("Width:")
|
||||||
|
.frame(width: 80, alignment: .leading)
|
||||||
|
Slider(value: $cropWidth, in: 5...100)
|
||||||
|
Text(String(format: "%.0f", cropWidth))
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("30") {
|
||||||
|
withAnimation { cropWidth = 30 }
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crop Height
|
||||||
|
HStack {
|
||||||
|
Text("Height:")
|
||||||
|
.frame(width: 80, alignment: .leading)
|
||||||
|
Slider(value: $cropHeight, in: 5...100)
|
||||||
|
Text(String(format: "%.0f", cropHeight))
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("20") {
|
||||||
|
withAnimation { cropHeight = 20 }
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Position Controls
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Position")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
// Offset X
|
||||||
|
HStack {
|
||||||
|
Text("Horizontal:")
|
||||||
|
.frame(width: 80, alignment: .leading)
|
||||||
|
Slider(value: $offsetX, in: -50...50)
|
||||||
|
Text(String(format: "%.1f", offsetX))
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("Center") {
|
||||||
|
withAnimation { offsetX = 0 }
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offset Y
|
||||||
|
HStack {
|
||||||
|
Text("Vertical:")
|
||||||
|
.frame(width: 80, alignment: .leading)
|
||||||
|
Slider(value: $offsetY, in: -50...50)
|
||||||
|
Text(String(format: "%.1f", offsetY))
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("Center") {
|
||||||
|
withAnimation { offsetY = 0 }
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Rotation & Opacity
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Transform")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
// Rotation
|
||||||
|
HStack {
|
||||||
|
Text("Rotation:")
|
||||||
|
.frame(width: 80, alignment: .leading)
|
||||||
|
Slider(value: $rotation, in: -180...180)
|
||||||
|
Text(String(format: "%.0f°", rotation))
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("Reset") {
|
||||||
|
withAnimation { rotation = 0 }
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opacity
|
||||||
|
HStack {
|
||||||
|
Text("Opacity:")
|
||||||
|
.frame(width: 80, alignment: .leading)
|
||||||
|
Slider(value: $opacity, in: 0...1)
|
||||||
|
Text(String(format: "%.0f%%", opacity * 100))
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("100%") {
|
||||||
|
withAnimation { opacity = 1.0 }
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Animation Speed
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Animation")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Text("Speed:")
|
||||||
|
.frame(width: 80, alignment: .leading)
|
||||||
|
Slider(value: $speed, in: 0.1...3.0)
|
||||||
|
Text(String(format: "%.2fx", speed))
|
||||||
|
.frame(width: 50, alignment: .trailing)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("1x") {
|
||||||
|
withAnimation { speed = 1.0 }
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Presets
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Quick Presets")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
|
||||||
|
Button("Fit to Notch") {
|
||||||
|
withAnimation {
|
||||||
|
scale = 1.0
|
||||||
|
cropWidth = 30
|
||||||
|
cropHeight = 20
|
||||||
|
offsetX = 0
|
||||||
|
offsetY = 0
|
||||||
|
rotation = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
|
Button("Fill Notch") {
|
||||||
|
withAnimation {
|
||||||
|
scale = 1.5
|
||||||
|
cropWidth = 30
|
||||||
|
cropHeight = 20
|
||||||
|
offsetX = 0
|
||||||
|
offsetY = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
|
Button("Scale Up 2x") {
|
||||||
|
withAnimation {
|
||||||
|
scale = 2.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
|
Button("Scale Down") {
|
||||||
|
withAnimation {
|
||||||
|
scale = 0.75
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
|
Button("Reset All") {
|
||||||
|
withAnimation {
|
||||||
|
scale = 1.0
|
||||||
|
cropWidth = 30
|
||||||
|
cropHeight = 20
|
||||||
|
offsetX = 0
|
||||||
|
offsetY = 0
|
||||||
|
rotation = 0
|
||||||
|
opacity = 1.0
|
||||||
|
speed = 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.frame(minWidth: 350, maxWidth: 450)
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
// Footer with actions
|
||||||
|
HStack {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "info.circle")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Blue dashed box shows actual size in Dynamic Island")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Import") {
|
||||||
|
importAnimation()
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.disabled(name.isEmpty || isImporting)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
}
|
||||||
|
.frame(minWidth: 800, minHeight: 600)
|
||||||
|
.alert("Import Error", isPresented: $showingError) {
|
||||||
|
Button("OK") { }
|
||||||
|
} message: {
|
||||||
|
Text(errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Import Logic
|
||||||
|
|
||||||
|
private func importAnimation() {
|
||||||
|
guard !name.isEmpty else { return }
|
||||||
|
|
||||||
|
isImporting = true
|
||||||
|
|
||||||
|
// Create animation config with transform settings
|
||||||
|
let config = AnimationTransformConfig(
|
||||||
|
scale: scale,
|
||||||
|
offsetX: offsetX,
|
||||||
|
offsetY: offsetY,
|
||||||
|
cropWidth: cropWidth,
|
||||||
|
cropHeight: cropHeight,
|
||||||
|
rotation: rotation,
|
||||||
|
opacity: opacity
|
||||||
|
)
|
||||||
|
|
||||||
|
// Import with config
|
||||||
|
let result = IdleAnimationManager.shared.importLottieFile(
|
||||||
|
from: sourceURL,
|
||||||
|
name: name,
|
||||||
|
speed: speed,
|
||||||
|
transformConfig: config
|
||||||
|
)
|
||||||
|
|
||||||
|
switch result {
|
||||||
|
case .success(let importedAnimation):
|
||||||
|
animation = importedAnimation
|
||||||
|
dismiss()
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
showingError = true
|
||||||
|
isImporting = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
#Preview {
|
||||||
|
AnimationEditorView(
|
||||||
|
sourceURL: URL(string: "https://assets9.lottiefiles.com/packages/lf20_mniampqn.json")!,
|
||||||
|
isRemoteURL: true,
|
||||||
|
animation: .constant(nil)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,417 @@
|
||||||
|
//
|
||||||
|
// IdleAnimationsSettingsSection.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Created by AI Assistant on 11/10/2025.
|
||||||
|
// Settings section for custom idle animations
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
import Defaults
|
||||||
|
import LottieUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
struct IdleAnimationsSettingsSection: View {
|
||||||
|
@Default(.customIdleAnimations) var customIdleAnimations
|
||||||
|
@Default(.selectedIdleAnimation) var selectedIdleAnimation
|
||||||
|
@Default(.showNotHumanFace) var showNotHumanFace
|
||||||
|
|
||||||
|
@State private var showingFilePicker = false
|
||||||
|
@State private var showingURLSheet = false
|
||||||
|
@State private var urlInput = ""
|
||||||
|
@State private var nameInput = ""
|
||||||
|
@State private var selectedForDeletion: CustomIdleAnimation?
|
||||||
|
@State private var showingDeleteAlert = false
|
||||||
|
@State private var importError: String?
|
||||||
|
@State private var showingError = false
|
||||||
|
|
||||||
|
// Animation editor state
|
||||||
|
@State private var showingEditor = false
|
||||||
|
@State private var editorSourceURL: URL?
|
||||||
|
@State private var editorIsRemote = false
|
||||||
|
@State private var editedAnimation: CustomIdleAnimation?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Section {
|
||||||
|
if showNotHumanFace {
|
||||||
|
// Horizontal scrollable grid of animations
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
LazyHStack(spacing: 16) {
|
||||||
|
ForEach(customIdleAnimations) { animation in
|
||||||
|
AnimationPreviewCard(
|
||||||
|
animation: animation,
|
||||||
|
isSelected: selectedIdleAnimation?.id == animation.id,
|
||||||
|
onSelect: {
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
selectedIdleAnimation = animation
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDelete: animation.isBuiltIn ? nil : {
|
||||||
|
selectedForDeletion = animation
|
||||||
|
showingDeleteAlert = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add animation button
|
||||||
|
AddAnimationCard {
|
||||||
|
showingFilePicker = true
|
||||||
|
} onAddURL: {
|
||||||
|
urlInput = ""
|
||||||
|
nameInput = ""
|
||||||
|
showingURLSheet = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.padding(.horizontal, 4)
|
||||||
|
}
|
||||||
|
.frame(height: 140)
|
||||||
|
} else {
|
||||||
|
Text("Enable \"Show cool face animation while inactivity\" to customize animations")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
HStack {
|
||||||
|
Text("Idle Animation Style")
|
||||||
|
Spacer()
|
||||||
|
if showNotHumanFace {
|
||||||
|
Text("\(customIdleAnimations.count) animations")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} footer: {
|
||||||
|
if showNotHumanFace {
|
||||||
|
Text("Choose animation to display when Dynamic Island is idle. Tap to select, hold to delete custom animations.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fileImporter(
|
||||||
|
isPresented: $showingFilePicker,
|
||||||
|
allowedContentTypes: [UTType.json],
|
||||||
|
allowsMultipleSelection: false
|
||||||
|
) { result in
|
||||||
|
handleFileImport(result)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingURLSheet) {
|
||||||
|
URLImportSheet(
|
||||||
|
urlInput: $urlInput,
|
||||||
|
nameInput: $nameInput,
|
||||||
|
onImport: { handleURLImport() },
|
||||||
|
onCancel: { showingURLSheet = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showingEditor) {
|
||||||
|
if let url = editorSourceURL {
|
||||||
|
AnimationEditorView(
|
||||||
|
sourceURL: url,
|
||||||
|
isRemoteURL: editorIsRemote,
|
||||||
|
animation: $editedAnimation
|
||||||
|
)
|
||||||
|
.frame(minWidth: 800, minHeight: 600)
|
||||||
|
.onChange(of: editedAnimation) { oldValue, newValue in
|
||||||
|
if let animation = newValue {
|
||||||
|
// Animation was successfully imported via editor
|
||||||
|
withAnimation {
|
||||||
|
selectedIdleAnimation = animation
|
||||||
|
}
|
||||||
|
showingEditor = false
|
||||||
|
// Reset editor state
|
||||||
|
editorSourceURL = nil
|
||||||
|
editedAnimation = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Delete Animation", isPresented: $showingDeleteAlert) {
|
||||||
|
Button("Cancel", role: .cancel) { }
|
||||||
|
Button("Delete", role: .destructive) {
|
||||||
|
if let animation = selectedForDeletion {
|
||||||
|
IdleAnimationManager.shared.deleteAnimation(animation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} message: {
|
||||||
|
if let animation = selectedForDeletion {
|
||||||
|
Text("Are you sure you want to delete \"\(animation.name)\"?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.alert("Import Error", isPresented: $showingError) {
|
||||||
|
Button("OK") { }
|
||||||
|
} message: {
|
||||||
|
Text(importError ?? "Unknown error occurred")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Import Handlers
|
||||||
|
|
||||||
|
private func handleFileImport(_ result: Result<[URL], Error>) {
|
||||||
|
switch result {
|
||||||
|
case .success(let urls):
|
||||||
|
guard let url = urls.first else { return }
|
||||||
|
|
||||||
|
// Show editor instead of directly importing
|
||||||
|
editorSourceURL = url
|
||||||
|
editorIsRemote = false
|
||||||
|
editedAnimation = nil
|
||||||
|
showingEditor = true
|
||||||
|
|
||||||
|
case .failure(let error):
|
||||||
|
importError = error.localizedDescription
|
||||||
|
showingError = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleURLImport() {
|
||||||
|
guard let url = URL(string: urlInput), !nameInput.isEmpty else {
|
||||||
|
importError = "Please provide both a valid URL and name"
|
||||||
|
showingError = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show editor instead of directly importing
|
||||||
|
editorSourceURL = url
|
||||||
|
editorIsRemote = true
|
||||||
|
editedAnimation = nil
|
||||||
|
showingURLSheet = false
|
||||||
|
showingEditor = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Animation Preview Card
|
||||||
|
struct AnimationPreviewCard: View {
|
||||||
|
let animation: CustomIdleAnimation
|
||||||
|
let isSelected: Bool
|
||||||
|
let onSelect: () -> Void
|
||||||
|
let onDelete: (() -> Void)?
|
||||||
|
|
||||||
|
@State private var isHovering = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
// Preview area
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.fill(Color(nsColor: .controlBackgroundColor))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.strokeBorder(
|
||||||
|
isSelected ? Color.accentColor : Color.clear,
|
||||||
|
lineWidth: 2.5
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Animation preview
|
||||||
|
AnimationPreview(animation: animation)
|
||||||
|
.frame(width: 60, height: 40)
|
||||||
|
|
||||||
|
// Delete button (only for custom animations)
|
||||||
|
if let onDelete = onDelete, isHovering {
|
||||||
|
VStack {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
Button {
|
||||||
|
onDelete()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "xmark.circle.fill")
|
||||||
|
.foregroundStyle(.white, .red)
|
||||||
|
.font(.system(size: 20))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(4)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 100, height: 80)
|
||||||
|
.onHover { hovering in
|
||||||
|
withAnimation(.easeInOut(duration: 0.15)) {
|
||||||
|
isHovering = hovering
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name and badge
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text(animation.name)
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.frame(width: 100)
|
||||||
|
|
||||||
|
if isSelected {
|
||||||
|
Text("Selected")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
.background(
|
||||||
|
Capsule()
|
||||||
|
.fill(Color.accentColor)
|
||||||
|
)
|
||||||
|
} else if animation.isBuiltIn {
|
||||||
|
Text("Built-in")
|
||||||
|
.font(.system(size: 10))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture {
|
||||||
|
onSelect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Animation Preview
|
||||||
|
struct AnimationPreview: View {
|
||||||
|
let animation: CustomIdleAnimation
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch animation.source {
|
||||||
|
case .lottieFile(let url):
|
||||||
|
LottieView(state: LUStateData(
|
||||||
|
type: .loadedFrom(url),
|
||||||
|
speed: animation.speed,
|
||||||
|
loopMode: .loop
|
||||||
|
))
|
||||||
|
.frame(width: 60, height: 40)
|
||||||
|
|
||||||
|
case .lottieURL(let url):
|
||||||
|
LottieView(state: LUStateData(
|
||||||
|
type: .loadedFrom(url),
|
||||||
|
speed: animation.speed,
|
||||||
|
loopMode: .loop
|
||||||
|
))
|
||||||
|
.frame(width: 60, height: 40)
|
||||||
|
|
||||||
|
case .builtInFace:
|
||||||
|
MinimalFaceFeatures(height: 40, width: 60)
|
||||||
|
.scaleEffect(2.0) // Scale up the face for preview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Add Animation Card
|
||||||
|
struct AddAnimationCard: View {
|
||||||
|
let onAddFile: () -> Void
|
||||||
|
let onAddURL: () -> Void
|
||||||
|
|
||||||
|
@State private var showingMenu = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Button {
|
||||||
|
showingMenu.toggle()
|
||||||
|
} label: {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 12)
|
||||||
|
.strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [5, 3]))
|
||||||
|
.fill(Color(nsColor: .controlBackgroundColor))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.font(.system(size: 28))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text("Add")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 100, height: 80)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.popover(isPresented: $showingMenu, arrowEdge: .bottom) {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Button {
|
||||||
|
showingMenu = false
|
||||||
|
onAddFile()
|
||||||
|
} label: {
|
||||||
|
Label("Import File...", systemImage: "doc.badge.plus")
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(8)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showingMenu = false
|
||||||
|
onAddURL()
|
||||||
|
} label: {
|
||||||
|
Label("Add from URL...", systemImage: "link.badge.plus")
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
.frame(width: 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Add New")
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - URL Import Sheet
|
||||||
|
struct URLImportSheet: View {
|
||||||
|
@Binding var urlInput: String
|
||||||
|
@Binding var nameInput: String
|
||||||
|
let onImport: () -> Void
|
||||||
|
let onCancel: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Text("Add Animation from URL")
|
||||||
|
.font(.headline)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Lottie JSON URL")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("https://example.com/animation.json", text: $urlInput)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Animation Name")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
TextField("My Animation", text: $nameInput)
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
Button("Cancel") {
|
||||||
|
onCancel()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button("Add") {
|
||||||
|
onImport()
|
||||||
|
}
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
|
.disabled(urlInput.isEmpty || nameInput.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.frame(width: 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
#Preview {
|
||||||
|
Form {
|
||||||
|
IdleAnimationsSettingsSection()
|
||||||
|
}
|
||||||
|
.frame(width: 600, height: 400)
|
||||||
|
}
|
||||||
|
|
@ -1228,6 +1228,9 @@ struct Appearance: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Custom Idle Animations Section
|
||||||
|
IdleAnimationsSettingsSection()
|
||||||
|
|
||||||
Section {
|
Section {
|
||||||
HStack {
|
HStack {
|
||||||
ForEach(icons, id: \.self) { icon in
|
ForEach(icons, id: \.self) { icon in
|
||||||
|
|
|
||||||
301
DynamicIsland/managers/IdleAnimationManager.swift
Normal file
301
DynamicIsland/managers/IdleAnimationManager.swift
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
//
|
||||||
|
// IdleAnimationManager.swift
|
||||||
|
// DynamicIsland
|
||||||
|
//
|
||||||
|
// Created by AI Assistant on 11/10/2025.
|
||||||
|
// Manages custom idle animations (Lottie files) for the Dynamic Island
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Defaults
|
||||||
|
import AppKit
|
||||||
|
|
||||||
|
class IdleAnimationManager {
|
||||||
|
static let shared = IdleAnimationManager()
|
||||||
|
|
||||||
|
// Storage directory for user-imported animations
|
||||||
|
private let storageDirectory: URL
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
// Create storage directory in Application Support
|
||||||
|
let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||||
|
storageDirectory = appSupport.appendingPathComponent("DynamicIsland/IdleAnimations", isDirectory: true)
|
||||||
|
|
||||||
|
// Create directory if it doesn't exist
|
||||||
|
try? FileManager.default.createDirectory(at: storageDirectory, withIntermediateDirectories: true)
|
||||||
|
|
||||||
|
print("📁 [IdleAnimationManager] Storage directory: \(storageDirectory.path)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
/// Load bundled animations from the LottieAnimations folder and built-in face
|
||||||
|
func initializeDefaultAnimations() {
|
||||||
|
var animations: [CustomIdleAnimation] = []
|
||||||
|
|
||||||
|
// Add built-in face as first option
|
||||||
|
let builtInFace = CustomIdleAnimation(
|
||||||
|
name: "Classic Face",
|
||||||
|
source: .builtInFace,
|
||||||
|
speed: 1.0,
|
||||||
|
isBuiltIn: true
|
||||||
|
)
|
||||||
|
animations.append(builtInFace)
|
||||||
|
|
||||||
|
// Load bundled Lottie files
|
||||||
|
if let bundledAnimations = loadBundledAnimations() {
|
||||||
|
animations.append(contentsOf: bundledAnimations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load previously imported user animations from storage
|
||||||
|
if let userAnimations = loadStoredUserAnimations() {
|
||||||
|
animations.append(contentsOf: userAnimations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only set if empty (first launch)
|
||||||
|
if Defaults[.customIdleAnimations].isEmpty {
|
||||||
|
Defaults[.customIdleAnimations] = animations
|
||||||
|
Defaults[.selectedIdleAnimation] = builtInFace // Default to built-in face
|
||||||
|
print("✅ [IdleAnimationManager] Initialized with \(animations.count) animations")
|
||||||
|
} else {
|
||||||
|
// Merge: add any new bundled animations that aren't already in the list
|
||||||
|
var existing = Defaults[.customIdleAnimations]
|
||||||
|
let existingNames = Set(existing.map { $0.name })
|
||||||
|
|
||||||
|
for newAnim in animations where newAnim.isBuiltIn {
|
||||||
|
if !existingNames.contains(newAnim.name) {
|
||||||
|
existing.append(newAnim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Defaults[.customIdleAnimations] = existing
|
||||||
|
print("✅ [IdleAnimationManager] Updated with \(existing.count) animations")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Bundled Animations
|
||||||
|
|
||||||
|
/// Load animations from the LottieAnimations folder in the bundle
|
||||||
|
private func loadBundledAnimations() -> [CustomIdleAnimation]? {
|
||||||
|
guard let bundlePath = Bundle.main.resourcePath else {
|
||||||
|
print("⚠️ [IdleAnimationManager] Could not find bundle resource path")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let lottieFolder = URL(fileURLWithPath: bundlePath).appendingPathComponent("LottieAnimations")
|
||||||
|
|
||||||
|
guard FileManager.default.fileExists(atPath: lottieFolder.path) else {
|
||||||
|
print("⚠️ [IdleAnimationManager] LottieAnimations folder not found at: \(lottieFolder.path)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let files = try FileManager.default.contentsOfDirectory(at: lottieFolder, includingPropertiesForKeys: nil)
|
||||||
|
let jsonFiles = files.filter { $0.pathExtension.lowercased() == "json" }
|
||||||
|
|
||||||
|
let animations = jsonFiles.map { url -> CustomIdleAnimation in
|
||||||
|
let name = url.deletingPathExtension().lastPathComponent
|
||||||
|
return CustomIdleAnimation(
|
||||||
|
name: name,
|
||||||
|
source: .lottieFile(url),
|
||||||
|
speed: 1.0,
|
||||||
|
isBuiltIn: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
print("📦 [IdleAnimationManager] Loaded \(animations.count) bundled animations")
|
||||||
|
return animations
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
print("❌ [IdleAnimationManager] Error loading bundled animations: \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - User Animations
|
||||||
|
|
||||||
|
/// Load user-imported animations from storage directory
|
||||||
|
private func loadStoredUserAnimations() -> [CustomIdleAnimation]? {
|
||||||
|
do {
|
||||||
|
let files = try FileManager.default.contentsOfDirectory(at: storageDirectory, includingPropertiesForKeys: nil)
|
||||||
|
let jsonFiles = files.filter { $0.pathExtension.lowercased() == "json" }
|
||||||
|
|
||||||
|
let animations = jsonFiles.map { url -> CustomIdleAnimation in
|
||||||
|
let name = url.deletingPathExtension().lastPathComponent
|
||||||
|
return CustomIdleAnimation(
|
||||||
|
name: name,
|
||||||
|
source: .lottieFile(url),
|
||||||
|
speed: 1.0,
|
||||||
|
isBuiltIn: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !animations.isEmpty {
|
||||||
|
print("💾 [IdleAnimationManager] Loaded \(animations.count) stored user animations")
|
||||||
|
}
|
||||||
|
return animations.isEmpty ? nil : animations
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
print("❌ [IdleAnimationManager] Error loading stored animations: \(error)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Import & Export
|
||||||
|
|
||||||
|
/// Import a Lottie JSON file from URL (either local file or download from remote)
|
||||||
|
func importLottieFile(from url: URL, name: String? = nil, speed: CGFloat = 1.0, transformConfig: AnimationTransformConfig = .default) -> Result<CustomIdleAnimation, Error> {
|
||||||
|
let fileName = name ?? url.deletingPathExtension().lastPathComponent
|
||||||
|
|
||||||
|
// If it's a remote URL, download it first
|
||||||
|
if url.scheme == "http" || url.scheme == "https" {
|
||||||
|
return importRemoteAnimation(from: url, name: fileName, speed: speed, transformConfig: transformConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local file import
|
||||||
|
return importLocalFile(from: url, name: fileName, speed: speed, transformConfig: transformConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import a local Lottie JSON file
|
||||||
|
private func importLocalFile(from sourceURL: URL, name: String, speed: CGFloat, transformConfig: AnimationTransformConfig) -> Result<CustomIdleAnimation, Error> {
|
||||||
|
do {
|
||||||
|
// Validate it's a JSON file
|
||||||
|
guard sourceURL.pathExtension.lowercased() == "json" else {
|
||||||
|
return .failure(AnimationImportError.invalidFileType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate JSON content (basic check)
|
||||||
|
let data = try Data(contentsOf: sourceURL)
|
||||||
|
guard let _ = try? JSONSerialization.jsonObject(with: data) else {
|
||||||
|
return .failure(AnimationImportError.invalidJSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique filename
|
||||||
|
let uniqueFileName = "\(UUID().uuidString).json"
|
||||||
|
let destinationURL = storageDirectory.appendingPathComponent(uniqueFileName)
|
||||||
|
|
||||||
|
// Copy file to storage
|
||||||
|
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
|
||||||
|
|
||||||
|
// Create animation object
|
||||||
|
let animation = CustomIdleAnimation(
|
||||||
|
name: name,
|
||||||
|
source: .lottieFile(destinationURL),
|
||||||
|
speed: speed,
|
||||||
|
isBuiltIn: false,
|
||||||
|
transformConfig: transformConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add to defaults
|
||||||
|
var animations = Defaults[.customIdleAnimations]
|
||||||
|
animations.append(animation)
|
||||||
|
Defaults[.customIdleAnimations] = animations
|
||||||
|
|
||||||
|
print("✅ [IdleAnimationManager] Imported local file: \(name)")
|
||||||
|
return .success(animation)
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
print("❌ [IdleAnimationManager] Import failed: \(error)")
|
||||||
|
return .failure(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import animation from remote URL
|
||||||
|
private func importRemoteAnimation(from url: URL, name: String, speed: CGFloat, transformConfig: AnimationTransformConfig) -> Result<CustomIdleAnimation, Error> {
|
||||||
|
// For remote URLs, we store the URL directly (no download)
|
||||||
|
// The LottieView will handle downloading when needed
|
||||||
|
|
||||||
|
let animation = CustomIdleAnimation(
|
||||||
|
name: name,
|
||||||
|
source: .lottieURL(url),
|
||||||
|
speed: speed,
|
||||||
|
isBuiltIn: false,
|
||||||
|
transformConfig: transformConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
// Add to defaults
|
||||||
|
var animations = Defaults[.customIdleAnimations]
|
||||||
|
animations.append(animation)
|
||||||
|
Defaults[.customIdleAnimations] = animations
|
||||||
|
|
||||||
|
print("✅ [IdleAnimationManager] Added remote animation: \(name)")
|
||||||
|
return .success(animation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Management
|
||||||
|
|
||||||
|
/// Delete an animation (only user-added ones, not built-in)
|
||||||
|
func deleteAnimation(_ animation: CustomIdleAnimation) -> Bool {
|
||||||
|
guard !animation.isBuiltIn else {
|
||||||
|
print("⚠️ [IdleAnimationManager] Cannot delete built-in animation")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from defaults
|
||||||
|
var animations = Defaults[.customIdleAnimations]
|
||||||
|
guard let index = animations.firstIndex(of: animation) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
animations.remove(at: index)
|
||||||
|
Defaults[.customIdleAnimations] = animations
|
||||||
|
|
||||||
|
// If it's a local file, delete it from storage
|
||||||
|
if case .lottieFile(let url) = animation.source {
|
||||||
|
// Only delete if it's in our storage directory (not bundled)
|
||||||
|
if url.path.contains(storageDirectory.path) {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
print("🗑️ [IdleAnimationManager] Deleted file: \(url.lastPathComponent)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If deleted animation was selected, select the first one
|
||||||
|
if Defaults[.selectedIdleAnimation] == animation {
|
||||||
|
Defaults[.selectedIdleAnimation] = animations.first
|
||||||
|
}
|
||||||
|
|
||||||
|
print("✅ [IdleAnimationManager] Deleted animation: \(animation.name)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update animation properties
|
||||||
|
func updateAnimation(_ animation: CustomIdleAnimation, name: String? = nil, speed: CGFloat? = nil) {
|
||||||
|
var animations = Defaults[.customIdleAnimations]
|
||||||
|
guard let index = animations.firstIndex(where: { $0.id == animation.id }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let name = name {
|
||||||
|
animations[index].name = name
|
||||||
|
}
|
||||||
|
if let speed = speed {
|
||||||
|
animations[index].speed = speed
|
||||||
|
}
|
||||||
|
|
||||||
|
Defaults[.customIdleAnimations] = animations
|
||||||
|
|
||||||
|
// Update selected animation if it's the same one
|
||||||
|
if Defaults[.selectedIdleAnimation]?.id == animation.id {
|
||||||
|
Defaults[.selectedIdleAnimation] = animations[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
print("✅ [IdleAnimationManager] Updated animation: \(animation.name)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Error Types
|
||||||
|
enum AnimationImportError: LocalizedError {
|
||||||
|
case invalidFileType
|
||||||
|
case invalidJSON
|
||||||
|
case downloadFailed
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidFileType:
|
||||||
|
return "Only .json files are supported"
|
||||||
|
case .invalidJSON:
|
||||||
|
return "Invalid Lottie JSON format"
|
||||||
|
case .downloadFailed:
|
||||||
|
return "Failed to download animation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,51 @@ struct CustomVisualizer: Codable, Hashable, Equatable, Defaults.Serializable {
|
||||||
var speed: CGFloat = 1.0
|
var speed: CGFloat = 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Custom Idle Animation Models
|
||||||
|
struct CustomIdleAnimation: Codable, Hashable, Equatable, Defaults.Serializable, Identifiable {
|
||||||
|
let id: UUID
|
||||||
|
var name: String
|
||||||
|
var source: AnimationSource
|
||||||
|
var speed: CGFloat = 1.0
|
||||||
|
var isBuiltIn: Bool = false // Track if it's bundled vs user-added
|
||||||
|
var transformConfig: AnimationTransformConfig = .default // Transform settings
|
||||||
|
|
||||||
|
init(id: UUID = UUID(), name: String, source: AnimationSource, speed: CGFloat = 1.0, isBuiltIn: Bool = false, transformConfig: AnimationTransformConfig = .default) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.source = source
|
||||||
|
self.speed = speed
|
||||||
|
self.isBuiltIn = isBuiltIn
|
||||||
|
self.transformConfig = transformConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AnimationTransformConfig: Codable, Hashable, Equatable {
|
||||||
|
var scale: CGFloat = 1.0
|
||||||
|
var offsetX: CGFloat = 0
|
||||||
|
var offsetY: CGFloat = 0
|
||||||
|
var cropWidth: CGFloat = 30
|
||||||
|
var cropHeight: CGFloat = 20
|
||||||
|
var rotation: CGFloat = 0
|
||||||
|
var opacity: CGFloat = 1.0
|
||||||
|
|
||||||
|
static let `default` = AnimationTransformConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AnimationSource: Codable, Hashable, Equatable {
|
||||||
|
case lottieFile(URL) // Local file (in app support or bundle)
|
||||||
|
case lottieURL(URL) // Remote URL
|
||||||
|
case builtInFace // Original MinimalFaceFeatures
|
||||||
|
|
||||||
|
var displayType: String {
|
||||||
|
switch self {
|
||||||
|
case .lottieFile: return "Local"
|
||||||
|
case .lottieURL: return "Remote"
|
||||||
|
case .builtInFace: return "Built-in"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum CalendarSelectionState: Codable, Defaults.Serializable {
|
enum CalendarSelectionState: Codable, Defaults.Serializable {
|
||||||
case all
|
case all
|
||||||
case selected(Set<String>)
|
case selected(Set<String>)
|
||||||
|
|
@ -234,6 +279,8 @@ extension Defaults.Keys {
|
||||||
static let cornerRadiusScaling = Key<Bool>("cornerRadiusScaling", default: true)
|
static let cornerRadiusScaling = Key<Bool>("cornerRadiusScaling", default: true)
|
||||||
static let useModernCloseAnimation = Key<Bool>("useModernCloseAnimation", default: true)
|
static let useModernCloseAnimation = Key<Bool>("useModernCloseAnimation", default: true)
|
||||||
static let showNotHumanFace = Key<Bool>("showNotHumanFace", default: false)
|
static let showNotHumanFace = Key<Bool>("showNotHumanFace", default: false)
|
||||||
|
static let customIdleAnimations = Key<[CustomIdleAnimation]>("customIdleAnimations", default: [])
|
||||||
|
static let selectedIdleAnimation = Key<CustomIdleAnimation?>("selectedIdleAnimation", default: nil)
|
||||||
static let tileShowLabels = Key<Bool>("tileShowLabels", default: false)
|
static let tileShowLabels = Key<Bool>("tileShowLabels", default: false)
|
||||||
static let showCalendar = Key<Bool>("showCalendar", default: false)
|
static let showCalendar = Key<Bool>("showCalendar", default: false)
|
||||||
static let hideCompletedReminders = Key<Bool>("hideCompletedReminders", default: true)
|
static let hideCompletedReminders = Key<Bool>("hideCompletedReminders", default: true)
|
||||||
|
|
|
||||||
1
LottieAnimations/Dog waiting.json
Normal file
1
LottieAnimations/Dog waiting.json
Normal file
File diff suppressed because one or more lines are too long
1
LottieAnimations/Moody Dog.json
Normal file
1
LottieAnimations/Moody Dog.json
Normal file
File diff suppressed because one or more lines are too long
1
LottieAnimations/Orange Cat Peeping.json
Normal file
1
LottieAnimations/Orange Cat Peeping.json
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue