diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index e6cedd7..250bb9a 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -9171,6 +9171,12 @@ }, "Live Performance Data" : { + }, + "Loop Mode" : { + + }, + "Loop Mode:" : { + }, "Lottie JSON URL" : { "localizations" : { diff --git a/DynamicIsland/components/IdleAnimationView.swift b/DynamicIsland/components/IdleAnimationView.swift index d075ae7..100c910 100644 --- a/DynamicIsland/components/IdleAnimationView.swift +++ b/DynamicIsland/components/IdleAnimationView.swift @@ -18,6 +18,7 @@ struct IdleAnimationView: View { Group { if let animation = selectedAnimation { AnimationContentView(animation: animation) + .id("\(animation.id)-\(animation.hashValue)") // Force complete recreation when ANY property changes } else { // Fallback to original face if nothing selected MinimalFaceFeatures(height: 20, width: 30) @@ -38,8 +39,9 @@ private struct AnimationContentView: View { LottieView(state: LUStateData( type: .loadedFrom(url), speed: animation.speed, - loopMode: .loop + loopMode: config.loopMode.lottieLoopMode )) + .id(animation.id) // Force reload when animation changes .frame( width: config.cropWidth * config.scale, height: config.cropHeight * config.scale @@ -55,8 +57,9 @@ private struct AnimationContentView: View { LottieView(state: LUStateData( type: .loadedFrom(url), speed: animation.speed, - loopMode: .loop + loopMode: config.loopMode.lottieLoopMode )) + .id(animation.id) // Force reload when animation changes .frame( width: config.cropWidth * config.scale, height: config.cropHeight * config.scale diff --git a/DynamicIsland/components/Settings/AnimationEditorView.swift b/DynamicIsland/components/Settings/AnimationEditorView.swift index 86063f9..23ff81e 100644 --- a/DynamicIsland/components/Settings/AnimationEditorView.swift +++ b/DynamicIsland/components/Settings/AnimationEditorView.swift @@ -29,6 +29,7 @@ struct AnimationEditorView: View { @State private var opacity: CGFloat = 1.0 @State private var paddingBottom: CGFloat = 0 @State private var expandWithAnimation: Bool = false + @State private var loopMode: AnimationLoopMode = .loop @State private var showingError = false @State private var errorMessage = "" @@ -57,6 +58,7 @@ struct AnimationEditorView: View { _opacity = State(initialValue: config.opacity) _paddingBottom = State(initialValue: config.paddingBottom) _expandWithAnimation = State(initialValue: config.expandWithAnimation) + _loopMode = State(initialValue: config.loopMode) } else { let fileName = sourceURL.deletingPathExtension().lastPathComponent _name = State(initialValue: fileName) @@ -107,7 +109,7 @@ struct AnimationEditorView: View { LottieView(state: LUStateData( type: .loadedFrom(sourceURL), speed: speed, - loopMode: .loop + loopMode: loopMode.lottieLoopMode )) .frame(width: cropWidth * scale * previewScale, height: cropHeight * scale * previewScale) .offset(x: offsetX * previewScale, y: offsetY * previewScale) @@ -313,6 +315,18 @@ struct AnimationEditorView: View { .buttonStyle(.bordered) .controlSize(.small) } + + // Loop Mode + VStack(alignment: .leading, spacing: 8) { + Text("Loop Mode:") + .font(.subheadline) + Picker("Loop Mode", selection: $loopMode) { + ForEach(AnimationLoopMode.allCases, id: \.self) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + } } Divider() @@ -430,7 +444,9 @@ struct AnimationEditorView: View { } .padding() } + .frame(idealWidth: 900, idealHeight: 650) .frame(minWidth: 800, minHeight: 600) + .fixedSize() .alert("Import Error", isPresented: $showingError) { Button("OK") { } } message: { @@ -455,15 +471,21 @@ struct AnimationEditorView: View { rotation: rotation, opacity: opacity, paddingBottom: paddingBottom, - expandWithAnimation: expandWithAnimation + expandWithAnimation: expandWithAnimation, + loopMode: loopMode ) - // If editing existing animation, update it directly + // If editing existing animation, create a NEW animation object with updated values if let existing = existingAnimation { - var updatedAnimation = existing - updatedAnimation.name = name - updatedAnimation.speed = speed - updatedAnimation.transformConfig = config + // Create completely new animation to force view refresh + let updatedAnimation = CustomIdleAnimation( + id: existing.id, // Keep same ID so it replaces the right one + name: name, + source: existing.source, + speed: speed, + isBuiltIn: existing.isBuiltIn, + transformConfig: config + ) animation = updatedAnimation dismiss() return diff --git a/DynamicIsland/components/Settings/IdleAnimationsSettingsSection.swift b/DynamicIsland/components/Settings/IdleAnimationsSettingsSection.swift index 9819f6b..20afc84 100644 --- a/DynamicIsland/components/Settings/IdleAnimationsSettingsSection.swift +++ b/DynamicIsland/components/Settings/IdleAnimationsSettingsSection.swift @@ -123,19 +123,20 @@ struct IdleAnimationsSettingsSection: View { animation: $editedAnimation, existingAnimation: editingExistingAnimation ) - .frame(minWidth: 800, minHeight: 600) + .interactiveDismissDisabled(false) .onChange(of: editedAnimation) { oldValue, newValue in if let animation = newValue { // Animation was successfully imported/edited via editor if let existingAnim = editingExistingAnimation { // Editing existing animation - update it - if var animations = customIdleAnimations as? [CustomIdleAnimation], - let index = animations.firstIndex(where: { $0.id == existingAnim.id }) { + var animations = Defaults[.customIdleAnimations] + if let index = animations.firstIndex(where: { $0.id == existingAnim.id }) { animations[index] = animation - customIdleAnimations = animations + Defaults[.customIdleAnimations] = animations + // Update selection if this was the selected animation if selectedIdleAnimation?.id == existingAnim.id { - selectedIdleAnimation = animation + Defaults[.selectedIdleAnimation] = animation } } } else { @@ -183,6 +184,7 @@ struct IdleAnimationsSettingsSection: View { editorSourceURL = url editorIsRemote = false editedAnimation = nil + editingExistingAnimation = nil // Reset to nil for new imports showingEditor = true case .failure(let error): @@ -202,6 +204,7 @@ struct IdleAnimationsSettingsSection: View { editorSourceURL = url editorIsRemote = true editedAnimation = nil + editingExistingAnimation = nil // Reset to nil for new imports showingURLSheet = false showingEditor = true } diff --git a/DynamicIsland/managers/IdleAnimationManager.swift b/DynamicIsland/managers/IdleAnimationManager.swift index 8c4d979..3db212b 100644 --- a/DynamicIsland/managers/IdleAnimationManager.swift +++ b/DynamicIsland/managers/IdleAnimationManager.swift @@ -47,29 +47,29 @@ class IdleAnimationManager { animations.append(contentsOf: bundledAnimations) } - // Load previously imported user animations from storage - if let userAnimations = loadStoredUserAnimations() { - animations.append(contentsOf: userAnimations) - } + // Get existing animations + var existing = Defaults[.customIdleAnimations] - // Only set if empty (first launch) - if Defaults[.customIdleAnimations].isEmpty { + if existing.isEmpty { + // First launch - set everything Defaults[.customIdleAnimations] = animations - Defaults[.selectedIdleAnimation] = builtInFace // Default to built-in face - print("✅ [IdleAnimationManager] Initialized with \(animations.count) animations") + Defaults[.selectedIdleAnimation] = builtInFace + print("✅ [IdleAnimationManager] First launch: 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 }) + // Subsequent launch - ensure all bundled animations are present + let existingIDs = Set(existing.map { $0.id }) + let existingNames = Set(existing.filter { $0.isBuiltIn }.map { $0.name }) - for newAnim in animations where newAnim.isBuiltIn { - if !existingNames.contains(newAnim.name) { - existing.append(newAnim) + // Add any missing bundled animations + for bundledAnim in animations where bundledAnim.isBuiltIn { + if !existingNames.contains(bundledAnim.name) { + existing.insert(bundledAnim, at: existing.firstIndex(where: { !$0.isBuiltIn }) ?? existing.count) + print("➕ [IdleAnimationManager] Added missing bundled animation: \(bundledAnim.name)") } } Defaults[.customIdleAnimations] = existing - print("✅ [IdleAnimationManager] Updated with \(existing.count) animations") + print("✅ [IdleAnimationManager] Subsequent launch: \(existing.count) total animations (\(animations.count - 1) bundled + built-in face)") } } diff --git a/DynamicIsland/managers/ImageService.swift b/DynamicIsland/managers/ImageService.swift index 9d8a101..878e009 100644 --- a/DynamicIsland/managers/ImageService.swift +++ b/DynamicIsland/managers/ImageService.swift @@ -19,9 +19,14 @@ public final class ImageService: ImageServiceProtocol { private init() { let config = URLSessionConfiguration.default + + // Create proper cache directory path + let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first! + let artworkCachePath = cacheDir.appendingPathComponent("artwork_cache").path + let cache = URLCache(memoryCapacity: 50 * 1024 * 1024, // 50MB diskCapacity: 100 * 1024 * 1024, // 100MB - diskPath: "artwork_cache") + diskPath: artworkCachePath) config.urlCache = cache config.timeoutIntervalForRequest = 15 config.timeoutIntervalForResource = 30 diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 2772a0a..2a27ddd 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -7,6 +7,7 @@ import SwiftUI import Defaults +import Lottie private let availableDirectories = FileManager .default @@ -54,10 +55,25 @@ struct AnimationTransformConfig: Codable, Hashable, Equatable { var opacity: CGFloat = 1.0 var paddingBottom: CGFloat = 0 // Allow adjustment to fill notch from bottom var expandWithAnimation: Bool = false // Whether notch should expand horizontally with animation + var loopMode: AnimationLoopMode = .loop // Loop mode for animation static let `default` = AnimationTransformConfig() } +enum AnimationLoopMode: String, Codable, CaseIterable { + case loop = "Loop" + case playOnce = "Play Once" + case autoReverse = "Auto Reverse" + + var lottieLoopMode: LottieLoopMode { + switch self { + case .loop: return .loop + case .playOnce: return .playOnce + case .autoReverse: return .autoReverse + } + } +} + enum AnimationSource: Codable, Hashable, Equatable { case lottieFile(URL) // Local file (in app support or bundle) case lottieURL(URL) // Remote URL