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

@ -516,7 +516,7 @@ struct ContentView: View {
Rectangle()
.fill(.black)
.frame(width: vm.closedNotchSize.width - 20)
MinimalFaceFeatures()
IdleAnimationView()
}
}.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
}

View file

@ -84,6 +84,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
let webcamManager = WebcamManager.shared
let dndManager = DoNotDisturbManager.shared // NEW: DND detection
let bluetoothAudioManager = BluetoothAudioManager.shared // NEW: Bluetooth audio detection
let idleAnimationManager = IdleAnimationManager.shared // NEW: Custom idle animations
var closeNotchWorkItem: DispatchWorkItem?
private var previousScreens: [NSScreen]?
private var onboardingWindowController: NSWindowController?
@ -402,6 +403,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
// Migrate legacy progress bar settings
Defaults.Keys.migrateProgressBarStyle()
// Initialize idle animations (load bundled + built-in face)
idleAnimationManager.initializeDefaultAnimations()
// Setup SystemHUD Manager
SystemHUDManager.shared.setup(coordinator: coordinator)

View file

@ -810,6 +810,10 @@
},
"%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" : {
@ -917,6 +921,9 @@
}
}
}
},
"%lldx" : {
},
"•" : {
"comment" : "A period used to separate two related items.",
@ -1233,6 +1240,9 @@
},
"+%lld more" : {
},
"1x" : {
},
"3 items" : {
@ -1254,9 +1264,18 @@
},
"15 colors" : {
},
"20" : {
},
"20 colors" : {
},
"30" : {
},
"100%" : {
},
"About" : {
"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 from URL..." : {
"comment" : "A button label that prompts the user to add a Lottie animation from a URL.",
"isCommentAutoGenerated" : true
},
"Add manually" : {
"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" : {
"localizations" : {
"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" : {
},
@ -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?" : {
},
@ -3410,6 +3452,9 @@
}
}
}
},
"Blue dashed box shows actual size in Dynamic Island" : {
},
"Bluetooth Audio Devices" : {
"comment" : "A section header that lists Bluetooth audio devices.",
@ -3527,6 +3572,10 @@
},
"Brightness HUD" : {
},
"Built-in" : {
"comment" : "A label indicating that an animation is a built-in one.",
"isCommentAutoGenerated" : true
},
"Built-in System Monitoring" : {
@ -3836,6 +3885,9 @@
}
}
}
},
"Center" : {
},
"Change" : {
@ -4245,6 +4297,10 @@
},
"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" : {
@ -5188,6 +5244,9 @@
},
"Custom: %@" : {
},
"Customize Animation" : {
},
"Customize in Settings" : {
"localizations" : {
@ -5394,6 +5453,10 @@
},
"Delete" : {
},
"Delete Animation" : {
"comment" : "The title of an alert that appears when trying to delete a custom animation.",
"isCommentAutoGenerated" : true
},
"Delete Color" : {
@ -6253,6 +6316,10 @@
},
"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" : {
@ -7208,6 +7275,9 @@
}
}
}
},
"Fill Notch" : {
},
"Finish" : {
"localizations" : {
@ -7308,6 +7378,9 @@
}
}
}
},
"Fit to Notch" : {
},
"Gemini API Key" : {
@ -8020,6 +8093,9 @@
},
"Graph Visibility" : {
},
"Height:" : {
},
"Hide" : {
@ -8329,6 +8405,13 @@
},
"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" : {
"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" : {
"localizations" : {
"ar" : {
@ -11290,6 +11388,10 @@
}
}
},
"My Animation" : {
"comment" : "A default name for a new animation added via a URL.",
"isCommentAutoGenerated" : true
},
"Name" : {
"localizations" : {
"ar" : {
@ -12760,6 +12862,9 @@
}
}
}
},
"Opacity:" : {
},
"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." : {
},
"Position" : {
},
"Preview" : {
},
"Preview Zoom:" : {
},
"Privacy & Security" : {
"comment" : "The title of the privacy and security settings screen.",
@ -13070,6 +13184,9 @@
},
"Quick Actions" : {
},
"Quick Presets" : {
},
"Quit" : {
"localizations" : {
@ -13507,6 +13624,12 @@
},
"Replaces system HUD notifications with Dynamic Island displays." : {
},
"Reset" : {
},
"Reset All" : {
},
"Reset to Default" : {
@ -13616,6 +13739,9 @@
},
"Resume" : {
},
"Rotation:" : {
},
"Running" : {
"localizations" : {
@ -13722,6 +13848,15 @@
},
"Save Configuration" : {
},
"Scale Down" : {
},
"Scale Up 2x" : {
},
"Scale:" : {
},
"Screen Assistant" : {
@ -13978,6 +14113,10 @@
}
}
},
"Selected" : {
"comment" : "A label indicating that an animation is currently selected.",
"isCommentAutoGenerated" : true
},
"Selected animation" : {
"localizations" : {
"ar" : {
@ -14919,6 +15058,9 @@
},
"Shows the model's reasoning process before the final answer" : {
},
"Size & Scale" : {
},
"Slider color" : {
"localizations" : {
@ -15427,6 +15569,9 @@
}
}
}
},
"Speed:" : {
},
"Square" : {
"localizations" : {
@ -16222,6 +16367,9 @@
"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.",
"isCommentAutoGenerated" : true
},
"Transform" : {
},
"Transform your notch truly yours" : {
@ -17039,6 +17187,9 @@
}
}
}
},
"Vertical:" : {
},
"View on GitHub: th-ch/youtube-music" : {
"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." : {
},
"Width:" : {
},
"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")
}
}
// MARK: - Custom Idle Animations Section
IdleAnimationsSettingsSection()
Section {
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
}
// 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 {
case all
case selected(Set<String>)
@ -234,6 +279,8 @@ extension Defaults.Keys {
static let cornerRadiusScaling = Key<Bool>("cornerRadiusScaling", default: true)
static let useModernCloseAnimation = Key<Bool>("useModernCloseAnimation", default: true)
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 showCalendar = Key<Bool>("showCalendar", default: false)
static let hideCompletedReminders = Key<Bool>("hideCompletedReminders", default: true)