Working LottieAnimations with Scaling and stuff

This commit is contained in:
Hariharan Mudaliar 2025-10-11 12:41:47 +05:30
commit e500f5a5b2
13 changed files with 1451 additions and 1 deletions

View file

@ -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;
}; };

View file

@ -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)
} }

View file

@ -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)

View file

@ -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." : {

View 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)
}

View 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)
)
}

View file

@ -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)
}

View file

@ -1227,6 +1227,9 @@ struct Appearance: View {
Text("Additional features") Text("Additional features")
} }
} }
// MARK: - Custom Idle Animations Section
IdleAnimationsSettingsSection()
Section { Section {
HStack { HStack {

View 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"
}
}
}

View file

@ -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)

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long