diff --git a/.gitignore b/.gitignore index c25e1e2..776c621 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,8 @@ DerivedData/ Output/ Output.nosync/ *.xcuserstate + +.build/ +Output/ +.claude/worktrees/ +.DS_Store diff --git a/.impeccable/critique/2026-07-12T01-52-59Z__sources-lively-ui-screencardview-swift.md b/.impeccable/critique/2026-07-12T01-52-59Z__sources-lively-ui-screencardview-swift.md new file mode 100644 index 0000000..53b1f0b --- /dev/null +++ b/.impeccable/critique/2026-07-12T01-52-59Z__sources-lively-ui-screencardview-swift.md @@ -0,0 +1,113 @@ +--- +target: Sources/Lively/UI/ScreenCardView.swift +total_score: 31 +p0_count: 0 +p1_count: 3 +timestamp: 2026-07-12T01-52-59Z +slug: sources-lively-ui-screencardview-swift +--- +# Design Critique: Sources/Lively/UI/ScreenCardView.swift + +## Design Health Score + +| # | Heuristic | Score | Key Issue | +|---|-----------|-------|-----------| +| 1 | Visibility of System Status | 3.5 | Success/error toasts use hardcoded timeouts (3s and 5s) with no manual dismissal, making them difficult for slow readers. | +| 2 | Match System / Real World | 4.0 | Terminology matches macOS conventions; vertical hierarchy flows logically from identification to selection. | +| 3 | User Control and Freedom | 2.0 | Accidental clicks on the "Remove wallpaper" trash button immediately delete config without confirmation or undo capability. | +| 4 | Consistency and Standards | 2.5 | Hardcoded literals (`.black.opacity(0.55)`, `.red.opacity(0.85)`, specific paddings) bypass unified `LivelyBrand` token system. | +| 5 | Error Prevention | 3.0 | Programmatic checks (HEVC/H.264 video format, security bookmarks) prevent broken playback, but destructive buttons lack safety alerts. | +| 6 | Recognition Rather Than Recall | 4.0 | Persistent thumbnails and clear filenames show active wallpapers without forcing memorization. | +| 7 | Flexibility and Efficiency | 2.5 | Drag-and-drop works well, but active preview cards rely on `.onTapGesture` instead of a keyboard-focusable `Button`. | +| 8 | Aesthetic and Minimalist Design | 3.0 | Elegant "Mist Reef" styling, but cards crowd up to 7 distinct action items simultaneously when active, exceeding optimal memory limits. | +| 9 | Error Recovery | 3.5 | Toast alerts are descriptive, but the 5-second automatic dismissal window makes them easy to miss. | +| 10 | Help and Documentation | 3.0 | Static placeholder provides onboarding guidance, but browse hints are hidden in appearance mode. | +| **Total** | | **31.0/40** | **Good** | + +--- + +## Anti-Patterns Verdict + +* **LLM Assessment**: The implementation shows traces of AI-generated "magic-number" styling and redundant paths: + - **Token Neglect (Magic Numbers)**: Hardcoded padding parameters (`12`, `6`, `8`, `3`) and specific inline font sizes (`9pt`, `20pt`) bypass established layout constraints in `LivelyBrand`. + - **Redundant Actions**: Implementing two distinct delete paths (header trash and thumbnail 'X') suggests composite code generation that was not simplified during final refactoring. + - **Layout Density**: Stacking two drop zones beside scale, mute, and volume controls makes the 480×560pt popover card feel crowded. +* **Deterministic Scan**: Zero violations. The automated detector returned `[]` because it is built for web-based formats (HTML/CSS/Tailwind) and Swift/SwiftUI abstractions naturally bypass these signatures. + +--- + +## Cognitive Load Assessment + +* **Checklist Status**: + - [x] **Single focus**: Pass + - [x] **Chunking**: Pass + - [x] **Grouping**: Pass + - [x] **Visual hierarchy**: Pass + - [ ] **One thing at a time**: Fail. Active states display scale, mute, volume, deletion, and tabs concurrently. + - [ ] **Minimal choices**: Fail. Stacks 7 choices when a wallpaper is assigned (violates Cowan's ≤4 limit). + - [x] **Working memory**: Pass + - [x] **Progressive disclosure**: Pass. Settings collapse when configuration is empty. +* **Verdict**: **Moderate Cognitive Load (2/8 failures)**. + +--- + +## Overall Impression +A highly defensive, clean-rendering macOS settings card with beautiful adaptive transitions. However, the UX is visually crowded, lacks safety confirmation overlays for destructive commands, and relies on non-focusable gesture views that break accessibility. + +--- + +## What's Working +1. **Asynchronous Validation**: Checking H.264/HEVC support and security-scoped access prevents crashing. +2. **Screen Reader announcements**: Correct use of `AccessibilityNotification.Announcement` alerts VoiceOver users of state changes. +3. **Adaptive layout transitions**: Responsive mode changes are gated on `reduceMotion`. + +--- + +## Priority Issues + +### [P1] Inaccessible Video Thumbnail Affordance +* **Why**: The thumbnail uses `.onTapGesture` instead of a SwiftUI `Button`, making it impossible for keyboard-only/VoiceOver users to select or change a video. +* **Fix**: Wrap `VideoThumbnailView` in a standard button style, or apply `.accessibilityElement()` with appropriate traits and double-tap actions. +* **Suggested Command**: `$impeccable audit` + +### [P1] Missing Deletion Safety Confirmation +* **Why**: The header trash button executes `configStore.remove` instantly. One misclick on a mouse or trackpad wipes a user's setup immediately. +* **Fix**: Bind the trash button to show a `.confirmationDialog` or `.alert` modal before deletion. +* **Suggested Command**: `$impeccable harden` + +### [P1] Hidden Action Instructions in Appearance Mode +* **Why**: In Light/Dark mode, the browse hint "Drop a video, or click to browse" is hidden. First-time users won't know the mode card is interactive. +* **Fix**: Show a shortened secondary prompt (e.g. "Click to add video") under the mode title. +* **Suggested Command**: `$impeccable onboard` + +### [P2] Hardcoded Values & Token Violations +* **Why**: Inline constants like `.system(size: 9)`, `.red.opacity(0.85)`, and padding integers bypass the `LivelyBrand` design system. +* **Fix**: Map inline values to `LivelyBrand.Typography`, `LivelyBrand.destructive`, and `LivelyBrand.Spacing` tokens. +* **Suggested Command**: `$impeccable typeset` + +--- + +## Persona Red Flags + +* **Sam (Accessibility)**: + - Cannot tab to or focus the interactive video preview card using a keyboard. + - Clear button lacks a VoiceOver accessibility label (defaults to reading system icon names). + - "Click to change" visual overlay text is only `9pt`, violating low-vision visibility guidelines. +* **Jordan (First-Timer)**: + - Does not know how to interact with appearance-mode boxes since text instructions are hidden. + - Confronted with unpolished technical jargon when validations fail: "Only H.264 and HEVC are supported. Re-encode this file." +* **Riley (Stress Tester)**: + - Accidentally deletes configuration with a single off-target click in the header. + - Network-stored video files will freeze the main thread inside `onAppear` because `resolveBookmark` runs synchronously. + +--- + +## Minor Observations +- Success toast text instructions ("Switch Spaces...") could be enhanced with a macOS-specific helper (e.g., Mission Control tip). +- Volume slider could be completely hidden unless the user toggles mute off to keep choices under Cowan's limit. + +--- + +## Questions to Consider +1. *Why do we have two separate paths to clear wallpapers (header trash vs. thumbnail cross)? If we remove the global header trash, does the layout breathe more and protect users from accidental wipes?* +2. *Should volume be muted by default, and should the slider stay hidden until explicitly unmuted?* diff --git a/assets/lively-app-icon.png b/assets/lively-app-icon.png new file mode 100644 index 0000000..ce12570 Binary files /dev/null and b/assets/lively-app-icon.png differ diff --git a/assets/lively-setting-design.png b/assets/lively-setting-design.png new file mode 100644 index 0000000..8d798ef Binary files /dev/null and b/assets/lively-setting-design.png differ diff --git a/assets/lively-wallpaper.png b/assets/lively-wallpaper.png new file mode 100644 index 0000000..f9a3f08 Binary files /dev/null and b/assets/lively-wallpaper.png differ diff --git a/docs/Lively-task-list-plan.md b/docs/Lively-task-list-plan.md new file mode 100644 index 0000000..f6781f1 --- /dev/null +++ b/docs/Lively-task-list-plan.md @@ -0,0 +1,188 @@ +# Lively — Agent task list & implementation plan + +> **Give this entire file to one agent (or one session). Work only on this product until ship criteria are met.** + +| Field | Value | +|-------|--------| +| **Product** | Lively | +| **Priority** | P2 — ship + craft proof | +| **GitHub** | https://github.com/harshabala/Lively | +| **Local clone (preferred)** | `~/Documents/Projects/Lively` | +| **If missing, clone to** | `~/Documents/Projects/Lively` via `gh repo clone harshabala/Lively` | +| **Default branch** | `master` | +| **Work branch** | `feat/flow-metrics-2026-07` (create from latest default if needed) | +| **Stack** | Swift Package macOS menu-bar video wallpaper app | +| **Task IDs** | LV-1, LV-2, LV-3 | +| **Fable plan status** | Fable plan FAILED — reconstructed below from task list | +| **Work already done** | None for this initiative | + +## Agent instructions (start here) + +1. **Read this whole file** before editing. +2. **cd** to the local clone path above. If the path does not exist, clone the GitHub repo there. +3. `git fetch origin && git checkout -B feat/flow-metrics-2026-07 origin/master` (or continue existing feature branch if present). +4. Implement tasks in the **recommended commit sequence** at the bottom of the plan section. +5. After each commit: run the **verify commands** listed below. +6. Create/update **`docs/TASKS.md`** with every task ID and status. +7. Complete the **README dual-section rewrite**. +8. Run design + code + security self-review on the diff; fix critical/high issues. +9. Push branch and open a PR against `master`. Do **not** force-push. Do **not** merge unless the user asked for auto-merge. +10. Do **not** touch other portfolio repos in this session. + +## What “done” means for this product + +All task IDs listed above are implemented **or** explicitly documented as infeasible with a narrow alternative. Metrics are local-first, activation is a named constant, scoreboard is user-visible with method + privacy copy, README is dual-section, `docs/TASKS.md` is accurate, tests/build green, PR open. + + +## Global constraints (apply to ALL work) + +Source: `~/Desktop/portfolio-implementation-plan-2026-07-10.md` and `~/Desktop/product-flow-metrics-task-list.md`. + +1. **Local-first metrics** — counters/estimates on device or extension storage only. Nothing leaves unless user explicitly exports or opts in. **No cloud analytics in v1.** +2. **One activation event** — named constant in code (e.g. `ACTIVATION_EVENT = '...'`). +3. **User-visible scoreboard** on the natural success screen (ParseKit token-savings pattern), with **method + caveat microcopy**. Honest estimates; never fake precision. +4. **Privacy line on every stats surface:** e.g. *"Stored only on this device. Never uploaded."* (wording may adapt slightly per product). +5. **Zero-AI default** where relevant — core loop works without API keys; AI is optional upgrade. +6. **README rewrite required** in every repo (see below). +7. **`docs/TASKS.md`** committed checklist; update checkboxes as you finish task IDs. +8. **Ship path:** branch `feat/flow-metrics-2026-07` → push → open PR → (if user requested auto-merge and reviews pass) squash-merge to default branch and delete feature branch. + +### README overhaul (mandatory for every product) + +Rewrite `README.md` with **two top sections**: + +#### Start here (novice) +Plain everyday words, zero jargon: +- What it is +- What problems it solves +- Step-by-step install and first use +- Edge cases and how to still use it when you hit them + +#### For technical users +- Mermaid architecture diagram (GitHub-native render) +- How it works under the hood +- Metrics / activation definition +- Dev install, test commands, storage/privacy notes + +### Ship checklist per repo + +- [ ] All task IDs for this product implemented or honestly deferred with reason in PR +- [ ] `docs/TASKS.md` updated (checkboxes match reality) +- [ ] README novice + technical sections done +- [ ] Build/tests pass (commands in this file) +- [ ] Design pass (hierarchy, motion, empty states, reduced-motion) +- [ ] Code pass (edge cases, no regressions) +- [ ] Security pass (secrets, XSS, storage, permissions, no accidental egress) +- [ ] Branch pushed; PR opened with body mapping commits → task IDs → metrics +- [ ] Merge only if user wants auto-merge **or** explicitly asks + +### Related docs (read if needed) + +| File | Why | +|------|-----| +| `~/Desktop/product-flow-metrics-task-list.md` | Original product task specs + metrics definitions | +| `~/Desktop/pm-portfolio-analysis-lenny.md` | Why these metrics matter (senior PM framing) | +| `~/Desktop/portfolio-implementation-plan-2026-07-10.md` | Multi-repo orchestration notes | +| `~/Desktop/fable-portfolio-handoff/STATUS.md` | What Fable completed (mostly plans only) | + + + +## Work already completed + +None for flow-metrics. + +## Original product task spec + +# P2 — Lively + +## Product in one line + +macOS menu-bar video wallpapers per Space / display, offline. + +## Current gap + +Activation is “set a wallpaper once.” Retention is “still set weeks later.” Less business narrative — keep metrics light and local. + +--- + +### Task LV-1 — First-run: pick video → apply → confirm Space + +| Field | Detail | +|-------|--------| +| **Flow change** | On first launch: pick sample or file → Apply to current Space → toast *“Playing on this Space. Switch Spaces to set another.”* | +| **How it helps** | Activation in one minute. | +| **Metric: Activation** | `activated = wallpaper successfully applied ≥1` | +| **Privacy** | Paths local only; never upload video. | +| **Acceptance** | Sample video works without Finder hunt. | + +### Task LV-2 — Menu bar: “Now playing” + days active + +| Field | Detail | +|-------|--------| +| **Flow change** | Menu: current display/Space mapping; *“Active X days”* (days app applied a wallpaper for ≥1 hour — local). | +| **How it helps** | Lightweight retention signal for user and portfolio. | +| **Metric** | `days_with_wallpaper_active` | +| **User understanding** | *“Counts days Lively was actively rendering on at least one display.”* | +| **Acceptance** | Zero network dependency. | + +### Task LV-3 — Performance honesty + +| Field | Detail | +|-------|--------| +| **Flow change** | If thermal/CPU throttle: gentle notice *“Reduced quality to save power”* — builds trust. | +| **How it helps** | Nuance; support burden. | +| **Metric** | Optional local `throttle_events` count for your debugging only (not user-facing required). | + +**Lively north star:** still active at day 7 among activated. +**User-facing:** now playing + days active. + +--- + +## Implementation plan for agent + + +## Implementation plan (Fable never produced a plan — reconstructed from task list) + +### Repo orientation +- Swift Package macOS app: `Package.swift`, `Sources/`, `Tests/`. +- Menu-bar video wallpaper; per Space / display; offline. + +### LV-1 — First-run: pick video → apply → confirm Space +- First launch flow with sample video if needed. +- Activation: `ACTIVATION_EVENT = 'wallpaper_applied_once'` when apply succeeds. +- Local flag / date of first apply. + +### LV-2 — Menu bar “Now playing” + days active +- Show current Space/display mapping. +- `days_with_wallpaper_active` local counter (days app rendered ≥1 hour optional; if hard, “days app applied wallpaper” is OK with honest copy). +- Privacy: paths local; never upload video. + +### LV-3 — Performance honesty +- If thermal/CPU throttle: gentle notice reduced quality. + +### Metrics +- Activation: wallpaper applied ≥1 +- User-facing: now playing + days active +- Privacy line on stats surfaces + +### Verify +```bash +swift build +swift test # if tests exist +``` + +### Commit sequence +1. feat: activation + first-run apply flow (LV-1) +2. feat: menu bar now-playing + days active (LV-2) +3. feat: throttle/performance honesty notice (LV-3) +4. docs: README dual sections + docs/TASKS.md + + +## Agent completion checklist + +- [ ] LV-1 … LV-3 +- [ ] README dual sections +- [ ] docs/TASKS.md +- [ ] swift build (+ tests if present) +- [ ] Security: no network for wallpapers; local paths only +- [ ] PR against master diff --git a/docs/lively_codebase.md b/docs/lively_codebase.md new file mode 100644 index 0000000..016e62e --- /dev/null +++ b/docs/lively_codebase.md @@ -0,0 +1,1031 @@ +# Lively — Rewritten Codebase (macOS Tahoe 26, Multi-Screen) + +## Mission +Bring life to every macOS desktop space with seamless per-space video wallpapers. +Multi-screen aware. Non-sandboxed. Liquid Glass UI. Zero resource leaks. + +## What Changed vs Original + +| Area | Original Bug | Fix Applied | +|---|---|---| +| Window ordering | `makeKeyAndOrderFront` fought Finder, window buried | `orderFront(nil)` + level re-assertion on space change | +| Security scope | `startAccessingSecurityScopedResource` called, never stopped | Removed entirely — non-sandboxed apps don't need it | +| AVPlayer fade | `playerLayer.opacity` animated via NSAnimationContext (no-op on CALayer) | `CABasicAnimation` + KVO `readyToPlay` gate | +| AVPlayerLooper | Looper orphaned on concurrent `crossFade` calls | `isFading` guard + explicit cleanup before reload | +| Space identity | Only `NSScreen.main`, single `WallpaperWindow` | One `WallpaperSession` per `CGDirectDisplayID` | +| Space key | `desktopImageURL` alone (fragile on multi-screen) | `screenID:desktopImageURL` composite key | +| Settings UI | Notion-style static design | macOS 26 Liquid Glass: `GlassEffectContainer`, `.buttonStyle(.glass)`, per-screen sections | + +--- + +## Architecture + +``` +AppDelegate (main.swift) +├── SpaceMonitor — publishes [ScreenSpace] for all connected displays +├── ConfigStore — persists video assignments keyed by screenID:wallpaperURL +└── WallpaperController — owns one WallpaperSession per screen + └── WallpaperSession + ├── WallpaperWindow — borderless NSWindow at kCGDesktopWindowLevel + └── VideoPlayerView — AVQueuePlayer + AVPlayerLooper, fixed crossFade +``` + +--- + +## Source Files + +### `Sources/LivelyApp/main.swift` + +```swift +import AppKit +import SwiftUI + +@MainActor +class AppDelegate: NSObject, NSApplicationDelegate { + + static func main() { + let app = NSApplication.shared + let delegate = AppDelegate() + app.delegate = delegate + app.run() + } + + private var statusItem: NSStatusItem! + private var settingsWindow: NSWindow? + + // Core services + private let spaceMonitor = SpaceMonitor() + private let configStore = ConfigStore() + private lazy var wallpaperController = WallpaperController( + spaceMonitor: spaceMonitor, + configStore: configStore + ) + + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.accessory) + + // Warm up the controller (triggers initial wallpaper load) + _ = wallpaperController + + setupMenuBar() + } + + // MARK: - Menu Bar + + private func setupMenuBar() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength) + if let button = statusItem.button { + button.image = NSImage( + systemSymbolName: "play.tv.fill", + accessibilityDescription: "Lively" + ) + } + + let menu = NSMenu() + menu.addItem( + withTitle: "Settings…", + action: #selector(openSettings), + keyEquivalent: "," + ) + menu.addItem(.separator()) + + let pauseItem = NSMenuItem( + title: "Pause Wallpapers", + action: #selector(togglePause), + keyEquivalent: "p" + ) + menu.addItem(pauseItem) + menu.addItem(.separator()) + + menu.addItem( + withTitle: "Quit Lively", + action: #selector(NSApplication.terminate(_:)), + keyEquivalent: "q" + ) + + statusItem.menu = menu + } + + @objc private func togglePause() { + wallpaperController.togglePause() + if let item = statusItem.menu?.item(withTag: 100) { + item.title = wallpaperController.isPaused ? "Resume Wallpapers" : "Pause Wallpapers" + } + } + + // MARK: - Settings Window + + @objc func openSettings() { + if settingsWindow == nil { + let view = SettingsView(spaceMonitor: spaceMonitor, configStore: configStore) + let hosting = NSHostingController(rootView: view) + + let window = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 560), + styleMask: [.titled, .closable, .fullSizeContentView, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + window.titlebarAppearsTransparent = true + window.titleVisibility = .hidden + window.isMovableByWindowBackground = true + window.center() + window.contentViewController = hosting + window.isReleasedWhenClosed = false + window.identifier = NSUserInterfaceItemIdentifier("lively.settings") + + self.settingsWindow = window + } + + settingsWindow?.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } +} +``` + +--- + +### `Sources/Lively/Logic/SpaceMonitor.swift` + +Detects the active wallpaper URL per physical display using `CGDirectDisplayID` as the +stable screen key. + +```swift +import AppKit +import Combine + +/// A snapshot of one physical screen's current wallpaper/space state. +public struct ScreenSpace: Equatable, Identifiable { + /// Stable identifier based on CGDirectDisplayID. + public let id: String + public let screen: NSScreen + public let desktopImageURL: URL? + public let displayName: String + + /// The config-store key: stable even across app restarts. + public var spaceKey: String { + guard let url = desktopImageURL else { return id } + return "\(id):\(url.absoluteString)" + } + + public static func == (lhs: ScreenSpace, rhs: ScreenSpace) -> Bool { + lhs.id == rhs.id && lhs.desktopImageURL == rhs.desktopImageURL + } +} + +@MainActor +public class SpaceMonitor: ObservableObject { + @Published public private(set) var screenSpaces: [ScreenSpace] = [] + + private var cancellables = Set() + + public init() { + refresh() + + // Space changes (swipe between desktops) + NSWorkspace.shared.notificationCenter + .publisher(for: NSWorkspace.activeSpaceDidChangeNotification) + .sink { [weak self] _ in self?.handleSpaceChange() } + .store(in: &cancellables) + + // Screen connect / disconnect + NotificationCenter.default + .publisher(for: NSApplication.didChangeScreenParametersNotification) + .sink { [weak self] _ in self?.refresh() } + .store(in: &cancellables) + } + + // MARK: - Private + + private func handleSpaceChange() { + // macOS needs ~100ms to update desktopImageURL after a space switch + Task { + try? await Task.sleep(for: .milliseconds(100)) + refresh() + } + } + + public func refresh() { + screenSpaces = NSScreen.screens.map { screen in + ScreenSpace( + id: Self.displayID(for: screen), + screen: screen, + desktopImageURL: NSWorkspace.shared.desktopImageURL(for: screen), + displayName: screen.localizedName + ) + } + } + + // MARK: - Helpers + + public static func displayID(for screen: NSScreen) -> String { + let key = NSDeviceDescriptionKey("NSScreenNumber") + if let num = screen.deviceDescription[key] as? CGDirectDisplayID { + return String(num) + } + return screen.localizedName + } +} +``` + +--- + +### `Sources/Lively/Logic/ConfigStore.swift` + +Non-sandboxed — no security-scoped bookmarks needed. Uses standard bookmarks for +resilience to file moves, but resolved without the `withSecurityScope` flag. + +```swift +import Foundation +import AppKit + +public struct SpaceConfig: Codable { + /// Composite key: "displayID:desktopImageURL" + public let spaceKey: String + public let videoURL: URL + /// Bookmark for resilience when files move. + public let bookmarkData: Data + public let addedAt: Date +} + +@MainActor +public class ConfigStore: ObservableObject { + @Published public private(set) var configs: [String: SpaceConfig] = [:] + + private let configFileURL: URL + + public init(configFileURL: URL? = nil) { + let url: URL + if let provided = configFileURL { + url = provided + } else { + let appSupport = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + let dir = appSupport.appendingPathComponent("Lively") + try? FileManager.default.createDirectory( + at: dir, + withIntermediateDirectories: true + ) + url = dir.appendingPathComponent("config.json") + } + self.configFileURL = url + load() + } + + // MARK: - Public API + + public func assign(videoURL: URL, toSpaceKey key: String) { + do { + // Non-sandboxed: plain bookmark (no security scope required) + let bookmark = try videoURL.bookmarkData( + options: [], + includingResourceValuesForKeys: nil, + relativeTo: nil + ) + let config = SpaceConfig( + spaceKey: key, + videoURL: videoURL, + bookmarkData: bookmark, + addedAt: Date() + ) + configs[key] = config + persist() + print("ConfigStore: Assigned \(videoURL.lastPathComponent) → \(key)") + } catch { + print("ConfigStore: Bookmark creation failed: \(error)") + } + } + + /// Resolves the stored URL for a space key. + /// Returns nil if nothing is assigned or the file no longer exists. + public func videoURL(for spaceKey: String) -> URL? { + guard let config = configs[spaceKey] else { return nil } + + // Try bookmark resolution first (survives file moves) + var isStale = false + if let url = try? URL( + resolvingBookmarkData: config.bookmarkData, + options: [], // No security scope — non-sandboxed + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) { + if isStale { + // Refresh the bookmark silently + assign(videoURL: url, toSpaceKey: spaceKey) + } + return FileManager.default.fileExists(atPath: url.path) ? url : nil + } + + // Fallback: use stored URL directly + return FileManager.default.fileExists(atPath: config.videoURL.path) + ? config.videoURL + : nil + } + + public func remove(spaceKey: String) { + configs.removeValue(forKey: spaceKey) + persist() + } + + // MARK: - Persistence + + private func persist() { + do { + let data = try JSONEncoder().encode(configs) + try data.write(to: configFileURL, options: .atomic) + } catch { + print("ConfigStore: Save failed: \(error)") + } + } + + private func load() { + guard let data = try? Data(contentsOf: configFileURL), + let decoded = try? JSONDecoder().decode([String: SpaceConfig].self, from: data) + else { + print("ConfigStore: Starting fresh (no config found)") + return + } + configs = decoded + print("ConfigStore: Loaded \(configs.count) assignment(s)") + } +} +``` + +--- + +### `Sources/Lively/Core/VideoPlayerView.swift` + +Key fixes: +1. `CABasicAnimation` for layer opacity (NSAnimationContext doesn't animate CALayer properties directly). +2. `isFading` guard prevents concurrent crossFades from orphaning `AVPlayerLooper`. +3. KVO on `currentItem.status` delays fade-in until the first frame is decoded. + +```swift +import AppKit +import AVFoundation + +@MainActor +class VideoPlayerView: NSView { + + private let playerLayer = AVPlayerLayer() + private var queuePlayer: AVQueuePlayer? + private var playerLooper: AVPlayerLooper? + private var readyObservation: NSKeyValueObservation? + private var isFading = false + + // MARK: - Setup + + override init(frame: NSRect) { + super.init(frame: frame) + setupLayer() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupLayer() + } + + private func setupLayer() { + wantsLayer = true + layer = CALayer() + playerLayer.videoGravity = .resizeAspectFill + playerLayer.frame = bounds + playerLayer.autoresizingMask = [.layerWidthSizable, .layerHeightSizable] + layer?.addSublayer(playerLayer) + } + + override func layout() { + super.layout() + playerLayer.frame = bounds + } + + // MARK: - Playback + + func load(url: URL) { + stopCurrentPlayer() + + let item = AVPlayerItem(url: url) + let player = AVQueuePlayer() + player.volume = 0 + player.isMuted = true + player.preventsDisplaySleepDuringVideoPlayback = false // Don't block display sleep + + playerLooper = AVPlayerLooper(player: player, templateItem: item) + playerLayer.player = player + queuePlayer = player + player.play() + } + + func stop() { + stopCurrentPlayer() + } + + /// Crossfades to a new video. Safe to call repeatedly. + func crossFade(to url: URL, duration: TimeInterval = 0.4) { + guard !isFading else { + // Queue is busy — skip the animation, just swap + load(url: url) + setLayerOpacity(1, animated: false) + return + } + + isFading = true + + // Fade out current layer + setLayerOpacity(0, animated: true, duration: duration) { [weak self] in + guard let self else { return } + + self.load(url: url) + + // Wait for the player to have a decodable frame before fading in + self.waitForReadyThenFadeIn(duration: duration) + } + } + + // MARK: - Private Helpers + + private func stopCurrentPlayer() { + readyObservation?.invalidate() + readyObservation = nil + playerLooper?.disableLooping() + playerLooper = nil + queuePlayer?.pause() + playerLayer.player = nil + queuePlayer = nil + } + + private func waitForReadyThenFadeIn(duration: TimeInterval) { + guard let player = queuePlayer, + let item = player.currentItem else { + finishFadeIn(duration: duration) + return + } + + if item.status == .readyToPlay { + finishFadeIn(duration: duration) + return + } + + readyObservation = item.observe(\.status, options: [.new]) { [weak self] item, _ in + guard let self else { return } + if item.status == .readyToPlay { + Task { @MainActor in + self.readyObservation?.invalidate() + self.readyObservation = nil + self.finishFadeIn(duration: duration) + } + } + } + + // Timeout fallback — fade in after 1s regardless + Task { + try? await Task.sleep(for: .seconds(1)) + await MainActor.run { + guard self.isFading else { return } + self.readyObservation?.invalidate() + self.readyObservation = nil + self.finishFadeIn(duration: duration) + } + } + } + + private func finishFadeIn(duration: TimeInterval) { + setLayerOpacity(1, animated: true, duration: duration) { [weak self] in + self?.isFading = false + } + } + + /// CABasicAnimation is the correct way to animate CALayer properties — + /// NSAnimationContext only works with NSView/NSCell animator() proxies. + private func setLayerOpacity( + _ opacity: Float, + animated: Bool, + duration: TimeInterval = 0.4, + completion: (() -> Void)? = nil + ) { + CATransaction.begin() + CATransaction.setCompletionBlock(completion) + if animated { + let anim = CABasicAnimation(keyPath: "opacity") + anim.fromValue = playerLayer.presentation()?.opacity ?? playerLayer.opacity + anim.toValue = opacity + anim.duration = duration + anim.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + CATransaction.setAnimationDuration(duration) + playerLayer.add(anim, forKey: "opacityFade") + } else { + CATransaction.setDisableActions(true) + } + playerLayer.opacity = opacity + CATransaction.commit() + } +} +``` + +--- + +### `Sources/Lively/Core/WallpaperWindow.swift` + +Key fixes: +1. `orderFront(nil)` — never `makeKeyAndOrderFront` on a wallpaper window. +2. `levelReassert()` — called after space change to fight any OS-level reordering. +3. `participatesInCycle: false` prevents Command-Tab from seeing it. + +```swift +import AppKit + +/// A borderless window sitting at the macOS desktop icon level. +/// One instance is created per physical display. +class WallpaperWindow: NSWindow { + + init(screen: NSScreen) { + // kCGDesktopWindowLevel sits below the Finder desktop icons layer. + // Using the raw key constant is more stable than hardcoding the integer. + let desktopLevel = NSWindow.Level( + rawValue: Int(CGWindowLevelForKey(.desktopWindow)) + ) + + super.init( + contentRect: screen.frame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + + self.level = desktopLevel + self.collectionBehavior = [ + .canJoinAllSpaces, // Present on every Space + .stationary, // Doesn't move with Exposé/Mission Control + .ignoresCycle, // Excluded from Command-Tab + .fullScreenNone // Can't accidentally enter full-screen + ] + self.isOpaque = false + self.backgroundColor = .clear + self.ignoresMouseEvents = true + self.hasShadow = false + self.isReleasedWhenClosed = false + self.canHide = false + + // Cover the full screen frame (including any notch area) + setFrame(screen.frame, display: false) + } + + /// Re-asserts the window level and ordering. + /// macOS can silently re-order windows after space transitions — + /// calling this after `activeSpaceDidChangeNotification` keeps the video visible. + func levelReassert() { + let desktopLevel = NSWindow.Level( + rawValue: Int(CGWindowLevelForKey(.desktopWindow)) + ) + self.level = desktopLevel + orderFront(nil) + } + + override func orderFront(_ sender: Any?) { + // Explicitly never steal key-window status + super.orderFront(sender) + } +} +``` + +--- + +### `Sources/Lively/Core/WallpaperController.swift` + +Manages one `WallpaperSession` per physical display. +Responds to space changes, screen connect/disconnect, and config changes. + +```swift +import AppKit +import Combine + +// MARK: - Session + +/// Encapsulates the window + player for a single physical display. +@MainActor +private class WallpaperSession { + let window: WallpaperWindow + let playerView: VideoPlayerView + var currentURL: URL? + + init(screen: NSScreen) { + window = WallpaperWindow(screen: screen) + playerView = VideoPlayerView(frame: window.contentView!.bounds) + window.contentView = playerView + } + + func play(url: URL) { + if url == currentURL { + // Already playing this video — re-assert visibility only + window.levelReassert() + return + } + currentURL = url + window.levelReassert() + playerView.crossFade(to: url) + } + + func hide() { + currentURL = nil + playerView.stop() + window.orderOut(nil) + } + + func pause() { playerView.stop() } + func resume() { + if let url = currentURL { playerView.crossFade(to: url, duration: 0) } + } +} + +// MARK: - Controller + +@MainActor +public class WallpaperController { + public private(set) var isPaused = false + + private var sessions: [String: WallpaperSession] = [:] // displayID → session + private let spaceMonitor: SpaceMonitor + private let configStore: ConfigStore + private var cancellables = Set() + + public init(spaceMonitor: SpaceMonitor, configStore: ConfigStore) { + self.spaceMonitor = spaceMonitor + self.configStore = configStore + bind() + } + + // MARK: - Controls + + public func togglePause() { + isPaused.toggle() + sessions.values.forEach { isPaused ? $0.pause() : $0.resume() } + } + + // MARK: - Private + + private func bind() { + // React to space changes or new screen connections + spaceMonitor.$screenSpaces + .sink { [weak self] spaces in + self?.synchronize(to: spaces) + } + .store(in: &cancellables) + + // React to the user assigning/removing a video in settings + configStore.$configs + .sink { [weak self] _ in + self?.spaceMonitor.refresh() + } + .store(in: &cancellables) + } + + private func synchronize(to spaces: [ScreenSpace]) { + guard !isPaused else { return } + + let liveIDs = Set(spaces.map(\.id)) + + // Remove sessions for disconnected screens + for id in sessions.keys where !liveIDs.contains(id) { + sessions[id]?.hide() + sessions.removeValue(forKey: id) + } + + // Update / create sessions for each active screen + for space in spaces { + let session = sessions[space.id] ?? makeSession(for: space.screen, id: space.id) + + if let url = configStore.videoURL(for: space.spaceKey) { + session.play(url: url) + } else { + session.hide() + } + } + } + + private func makeSession(for screen: NSScreen, id: String) -> WallpaperSession { + let s = WallpaperSession(screen: screen) + sessions[id] = s + return s + } +} +``` + +--- + +### `Sources/Lively/UI/SettingsView.swift` + +Full macOS 26 Liquid Glass design. +Per-screen sections with drag-and-drop, browse picker, and live status chips. + +```swift +import SwiftUI +import AppKit +import ServiceManagement + +public struct SettingsView: View { + @ObservedObject public var spaceMonitor: SpaceMonitor + @ObservedObject public var configStore: ConfigStore + + public init(spaceMonitor: SpaceMonitor, configStore: ConfigStore) { + self.spaceMonitor = spaceMonitor + self.configStore = configStore + } + + public var body: some View { + ZStack { + // Glass window background — adapts to the video wallpaper behind it + Rectangle() + .fill(.ultraThinMaterial) + .ignoresSafeArea() + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 28) { + header + screensSection + footerSection + } + .padding(24) + } + } + .frame(width: 480, height: 560) + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: 12) { + Image(systemName: "play.tv.fill") + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(.primary) + .frame(width: 44, height: 44) + .glassEffect(.regular.tint(.blue), in: RoundedRectangle(cornerRadius: 12)) + + VStack(alignment: .leading, spacing: 2) { + Text("Lively") + .font(.system(size: 22, weight: .bold, design: .rounded)) + Text("Video wallpapers for every Space") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + Spacer() + } + } + + // MARK: - Screens + + private var screensSection: some View { + VStack(alignment: .leading, spacing: 14) { + Label("DISPLAYS", systemImage: "display.2") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + + if spaceMonitor.screenSpaces.isEmpty { + HStack { + ProgressView() + .scaleEffect(0.7) + Text("Detecting displays…") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + .padding(16) + .frame(maxWidth: .infinity) + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 14)) + } else { + VStack(spacing: 10) { + ForEach(spaceMonitor.screenSpaces) { space in + ScreenCard(space: space, configStore: configStore) + } + } + } + } + } + + // MARK: - Footer + + private var footerSection: some View { + HStack(spacing: 12) { + GlassEffectContainer { + Toggle(isOn: launchAtLoginBinding) { + Label("Launch at Login", systemImage: "power") + .font(.system(size: 13)) + } + .toggleStyle(.switch) + .padding(.horizontal, 14) + .padding(.vertical, 10) + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 12)) + } + + Spacer() + + Button(role: .destructive) { + NSApplication.shared.terminate(nil) + } label: { + Label("Quit", systemImage: "xmark.circle.fill") + .font(.system(size: 13, weight: .medium)) + } + .buttonStyle(.glass) + .tint(.red) + } + } + + // MARK: - Helpers + + private var launchAtLoginBinding: Binding { + Binding( + get: { SMAppService.mainApp.status == .enabled }, + set: { enabled in + try? enabled + ? SMAppService.mainApp.register() + : SMAppService.mainApp.unregister() + } + ) + } +} + +// MARK: - Screen Card + +private struct ScreenCard: View { + let space: ScreenSpace + @ObservedObject var configStore: ConfigStore + @State private var isDropTargeted = false + + private var assignedVideoURL: URL? { + configStore.videoURL(for: space.spaceKey) + } + + var body: some View { + VStack(spacing: 0) { + // Display header row + HStack(spacing: 10) { + Image(systemName: "display") + .font(.system(size: 15)) + .foregroundStyle(.secondary) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(space.displayName) + .font(.system(size: 14, weight: .semibold)) + if let url = space.desktopImageURL { + Text(url.lastPathComponent) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + } + } + + Spacer() + + // Status chip + statusChip + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + + Divider().opacity(0.3) + + // Drop zone + dropZone + .padding(12) + } + .glassEffect( + isDropTargeted ? .regular.tint(.blue) : .regular, + in: RoundedRectangle(cornerRadius: 14) + ) + .animation(.easeInOut(duration: 0.2), value: isDropTargeted) + .onDrop(of: [.fileURL], isTargeted: $isDropTargeted) { providers in + handleDrop(providers: providers) + } + } + + // MARK: Status Chip + + @ViewBuilder + private var statusChip: some View { + if let url = assignedVideoURL { + HStack(spacing: 5) { + Circle() + .fill(.green) + .frame(width: 6, height: 6) + Text(url.lastPathComponent) + .font(.system(size: 11)) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 120) + + Button { + configStore.remove(spaceKey: space.spaceKey) + } label: { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .bold)) + } + .buttonStyle(.glass) + .frame(width: 18, height: 18) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .glassEffect(.regular.tint(.green), in: Capsule()) + } else { + Text("No Video") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .glassEffect(.clear, in: Capsule()) + } + } + + // MARK: Drop Zone + + private var dropZone: some View { + HStack(spacing: 10) { + GlassEffectContainer { + HStack(spacing: 6) { + Image(systemName: "arrow.down.doc") + .font(.system(size: 12)) + Text(isDropTargeted ? "Drop to assign" : "Drop video here") + .font(.system(size: 12)) + } + .foregroundStyle(isDropTargeted ? .primary : .secondary) + .frame(maxWidth: .infinity) + .frame(height: 38) + .glassEffect( + isDropTargeted ? .regular.tint(.blue) : .clear, + in: RoundedRectangle(cornerRadius: 10) + ) + } + + Button { + openFilePicker() + } label: { + Label("Browse", systemImage: "folder") + .font(.system(size: 12, weight: .medium)) + .frame(height: 38) + .padding(.horizontal, 14) + } + .buttonStyle(.glass) + } + } + + // MARK: Interactions + + private func openFilePicker() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.movie] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.begin { response in + guard response == .OK, let url = panel.url else { return } + configStore.assign(videoURL: url, toSpaceKey: space.spaceKey) + } + } + + private func handleDrop(providers: [NSItemProvider]) -> Bool { + guard let provider = providers.first else { return false } + provider.loadItem(forTypeIdentifier: "public.file-url") { urlData, _ in + guard let data = urlData as? Data, + let url = URL(dataRepresentation: data, relativeTo: nil) + else { return } + Task { @MainActor in + configStore.assign(videoURL: url, toSpaceKey: space.spaceKey) + } + } + return true + } +} +``` + +--- + +## Key Gotchas & Notes + +### Why video didn't appear before + +The original `makeKeyAndOrderFront(nil)` tells the window server to bring the window +to the front of its level AND make it the first responder. For a window at +`kCGDesktopWindowLevel`, macOS interprets this as "something wants focus at desktop level" +and Finder immediately reasserts the actual desktop layer on top. The fix is `orderFront(nil)` +(just z-orders at the current level) combined with `levelReassert()` after space changes. + +### Why `.canJoinAllSpaces` + `orderOut` is correct + +Setting `.canJoinAllSpaces` makes the window persistent across all Spaces. We control +**visibility** via `orderOut` / `orderFront`. When no video is assigned to the current +Space, `orderOut` hides the window everywhere; when a video is assigned, `orderFront` +brings it back. This is cleaner than creating/destroying windows per Space. + +### Space identity limitation + +`desktopImageURL` is the only stable, public macOS API for identifying which Space is +active. If two Spaces share the same wallpaper image, Lively can't distinguish them. +The composite key `displayID:desktopImageURL` at least prevents cross-screen confusion. +If you want true Space-UUID tracking, it requires private frameworks (`CGSPrivate`). + +### macOS 26 Transparent Menu Bar + +Because Tahoe's menu bar is now fully transparent, your video wallpaper bleeds through +the menu bar region. This is a feature, not a bug — it makes the effect dramatically +more immersive on Tahoe vs earlier macOS versions. diff --git a/docs/lively_implementation_plan.md b/docs/lively_implementation_plan.md new file mode 100644 index 0000000..b7585e2 --- /dev/null +++ b/docs/lively_implementation_plan.md @@ -0,0 +1,343 @@ +# Lively — Implementation Plan (v2) + +> macOS live wallpaper app with per-Space video support + +--- + +## 1. Product Overview + +**What it does:** Lively renders looping videos as desktop wallpapers on macOS, supporting independent wallpapers per virtual Space (Mission Control desktop). + +**Core constraint:** macOS does not expose a stable public Space ID API for third-party apps. Lively works around this by using the static desktop image URL assigned to each Space as a stable proxy identifier. + +**Target user:** macOS power users with multiple Spaces who want dynamic, personalized desktops without the resource cost of screensavers. + +--- + +## 2. User Journey + +### 2.1 First Launch + +1. User downloads and opens Lively.app. +2. A **Welcome sheet** appears explaining the one-time setup requirement: each Space must have a unique static wallpaper assigned via System Settings → Wallpaper. +3. A guided "Space Setup" flow opens System Settings to Wallpaper automatically (`NSWorkspace.open`) with instructions overlaid. +4. User returns to Lively. The app detects all configured Spaces via screen enumeration and shows them in a grid. + +### 2.2 Assigning a Video to a Space + +1. User sees a grid of Space cards — each card shows a thumbnail of the static wallpaper (the Space's identity). +2. User clicks a Space card or drags a video file onto it. +3. A file picker (`NSOpenPanel`) filters for supported formats: `.mp4`, `.mov`, `.m4v`, `.hevc`. +4. On selection, a **preview loop** plays in the card thumbnail. +5. User clicks "Apply". The video is immediately rendered behind the desktop icons for that Space. +6. A success toast confirms assignment. + +### 2.3 Switching Spaces + +1. User switches Space (swipe, keyboard shortcut, Mission Control). +2. Lively detects the Space change via `NSWorkspace.activeSpaceDidChangeNotification`. +3. The overlay window cross-fades (configurable duration, default 300ms) to the video assigned to the new Space, or hides if no video is assigned. + +### 2.4 Ongoing Use (Menu Bar) + +- Lively lives in the **menu bar** (no Dock icon) with a minimal icon. +- Menu bar dropdown gives quick access to: Pause All / Resume, Open Settings, Quit. +- Plays/pauses automatically on battery (opt-in toggle). + +--- + +## 3. Architecture + +### 3.1 The Per-Space Problem & Solution + +macOS exposes no stable public Space ID. **Solution:** Use the desktop picture URL (`NSWorkspace.shared.desktopImageURL(for: screen)`) as a proxy for Space identity. + +**Setup contract:** Each Space the user wants to customize must have a unique static wallpaper image. Lively maps `DesktopImageURL → VideoURL`. + +**Edge cases handled:** +- Same static wallpaper on two Spaces → treated as the same context; last-assigned video wins. UI warns on duplicate detection. +- Wallpaper changed mid-session → space config becomes orphaned; surfaced as a warning in Settings. +- No static wallpaper assigned to a Space → Lively shows the default video (if configured) or no video. + +### 3.2 Component Map + +``` +Lively.app +├── AppDelegate (NSApplicationDelegate, menu bar lifecycle) +├── SpaceMonitor (detects Space changes, publishes context URL) +├── WallpaperController (manages WallpaperWindow per NSScreen) +│ └── WallpaperWindow (NSWindow at desktop level) +│ └── VideoPlayerView (AVPlayerLayer, looping) +├── ConfigStore (persists URL→Video mappings) +├── SettingsView (SwiftUI: Space grid, video assignment, preferences) +└── WelcomeView (SwiftUI: onboarding flow) +``` + +--- + +## 4. Technical Specifications + +### 4.1 `WallpaperWindow` (NSWindow) + +```swift +// Window level: below desktop icons, above the static wallpaper +window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) - 1) + +// Behavior +window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle] +window.styleMask = .borderless +window.isOpaque = false +window.backgroundColor = .clear +window.ignoresMouseEvents = true +window.hasShadow = false + +// IMPORTANT: Do NOT use .managed collectionBehavior — it breaks Space isolation +``` + +**Sonoma/Sequoia z-order note:** macOS Sonoma introduced desktop widgets at `kCGDesktopIconWindowLevel`. Target `kCGDesktopWindowLevel` (not `kCGDesktopIconWindowLevel - 1`) to sit behind both widgets and icons. Validate on each major macOS release. + +**Multi-monitor:** Create one `WallpaperWindow` per `NSScreen`. Each window independently tracks its screen's current desktop image URL. + +### 4.2 `VideoPlayerView` (NSView / CALayer) + +```swift +class VideoPlayerView: NSView { + private var playerLayer: AVPlayerLayer! + private var playerLooper: AVPlayerLooper! + private var queuePlayer: AVQueuePlayer! + + func load(url: URL) { + let item = AVPlayerItem(url: url) + queuePlayer = AVQueuePlayer() + playerLooper = AVPlayerLooper(player: queuePlayer, templateItem: item) + playerLayer.player = queuePlayer + playerLayer.videoGravity = .resizeAspectFill + queuePlayer.play() + } + + func crossFade(to newURL: URL, duration: TimeInterval = 0.3) { + // Fade out current layer, swap video, fade in + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = duration + playerLayer.opacity = 0 + } completionHandler: { + self.load(url: newURL) + NSAnimationContext.runAnimationGroup { ctx in + ctx.duration = duration + self.playerLayer.opacity = 1 + } + } + } +} +``` + +**Supported codecs:** H.264, H.265/HEVC, ProRes (via AVFoundation). HEVC hardware decode preferred for efficiency. + +**Audio:** Always muted (`player.volume = 0`). No audio controls exposed (wallpapers should be silent by default; future option). + +### 4.3 `SpaceMonitor` (ObservableObject) + +```swift +@MainActor +class SpaceMonitor: ObservableObject { + @Published var currentContextURL: URL? = nil + + init() { + NotificationCenter.default.addObserver( + self, + selector: #selector(spaceDidChange), + name: NSWorkspace.activeSpaceDidChangeNotification, + object: NSWorkspace.shared + ) + } + + @objc private func spaceDidChange() { + // Small delay to ensure desktopImageURL reflects the new Space + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + self.currentContextURL = NSWorkspace.shared + .desktopImageURL(for: NSScreen.main ?? NSScreen.screens[0]) + } + } +} +``` + +**Why the 0.05s delay:** `activeSpaceDidChangeNotification` fires slightly before `desktopImageURL` reflects the new Space. Empirically tested; adjust if needed on each macOS version. + +### 4.4 `ConfigStore` + +```swift +// Stored in: ~/Library/Application Support/Lively/config.json +struct SpaceConfig: Codable { + let spaceKey: String // desktopImageURL.absoluteString + let videoURL: String // absolute path to video file + let addedAt: Date +} + +class ConfigStore: ObservableObject { + @Published private(set) var configs: [String: SpaceConfig] = [:] + + func assign(video: URL, toSpaceKey key: String) { ... } + func videoURL(for spaceKey: String) -> URL? { ... } + func remove(spaceKey: String) { ... } + + private func persist() { + // Encode to JSON, write atomically to avoid corruption + } +} +``` + +**Security:** Video file access is bookmarked using `URL.bookmarkData(options: .withSecurityScope)` so the app retains access after relaunch without re-prompting. + +### 4.5 `WallpaperController` + +```swift +class WallpaperController { + private var windows: [NSScreen: WallpaperWindow] = [:] + + func update(for contextURL: URL?) { + guard let contextURL else { hideAll(); return } + let key = contextURL.absoluteString + + if let videoURL = configStore.videoURL(for: key) { + show(videoAt: videoURL) + } else if let defaultVideo = configStore.defaultVideoURL { + show(videoAt: defaultVideo) + } else { + hideAll() + } + } +} +``` + +--- + +## 5. Feature Set + +### 5.1 MVP (v1.0) + +| Feature | Description | +|---|---| +| Per-Space video assignment | Map unique static wallpaper → looping video | +| Space auto-detection | Detect Space changes, swap video with cross-fade | +| Multi-monitor support | Independent video per screen per Space | +| Menu bar app | No Dock icon; quick pause/resume/quit | +| Battery pause | Automatically pause video when on battery power | +| Supported formats | `.mp4`, `.mov`, `.m4v` (H.264, H.265) | +| Security-scoped bookmarks | Retain file access across relaunches | +| Settings UI | SwiftUI settings panel with Space grid | + +### 5.2 v1.1 — Quality of Life + +| Feature | Description | +|---|---| +| Default video | Fallback video shown for unmapped Spaces | +| Duplicate Space warning | Alert when two Spaces share the same static wallpaper | +| Orphaned config detection | Warn when a configured wallpaper image no longer exists | +| Video trim/offset | Start video at a specific timestamp | +| Playback speed control | 0.25x – 2x speed multiplier | +| Login item toggle | Auto-start on login (using `SMAppService`) | + +### 5.3 v1.2 — Power Features + +| Feature | Description | +|---|---| +| Playlist mode | Cycle through multiple videos for a Space on a timer | +| Screensaver link | Use same video as screensaver (via `ScreenSaverView`) | +| Built-in video library | Curated free wallpaper videos (HEVC, royalty-free) | +| iCloud sync | Sync Space configs across Macs | +| CLI / URL scheme | `lively://assign?space=&video=` for automation | + +--- + +## 6. Settings UI (SwiftUI) + +``` +SettingsView +├── Tab: Wallpapers +│ ├── Space grid (2-col) +│ │ └── SpaceCard: thumbnail, assigned video name, drag-drop target +│ ├── Default video picker +│ └── Add/Remove Space configs +├── Tab: Playback +│ ├── Cross-fade duration (slider: 0–1s) +│ ├── Pause on battery (toggle) +│ ├── Pause when screen locked (toggle) +│ └── Playback speed (slider) +└── Tab: About + ├── Version, credits + └── "Check for updates" button +``` + +**Space detection in Settings:** The currently active Space is highlighted with a blue border on the Space card, updating live as the user switches Spaces while Settings is open. + +--- + +## 7. Performance Targets + +| Metric | Target | How | +|---|---|---| +| CPU idle (video playing) | < 5% on Apple Silicon | HEVC via VideoToolbox hardware decode | +| CPU idle (H.264) | < 8% | AVFoundation decode path | +| Memory footprint | < 80 MB | Single AVQueuePlayer per screen; lazy init | +| Launch time | < 1s to menu bar | Async video load; show menu bar first | +| Space switch latency | < 150ms perceptible delay | Pre-cache next video on Space pre-switch signals | + +**Frame rate:** Lock playback to display refresh rate (`AVPlayer.rate` + `CADisplayLink`) — no need to render at 60fps for wallpaper content; 24–30fps is sufficient and reduces GPU load. + +--- + +## 8. Permissions & Entitlements + +| Permission | Why needed | +|---|---| +| `com.apple.security.files.user-selected.read-only` | Access user-selected video files | +| `com.apple.security.files.bookmarks.app-scope` | Persist file access across relaunches | +| `NSScreenCaptureUsageDescription` | Not needed — AVPlayerLayer renders directly; no screen capture | +| Hardened runtime | Required for notarization | + +**Sandboxing note:** Lively can be sandboxed. All file access goes through security-scoped bookmarks. No need for `com.apple.security.temporary-exception.files.absolute-path`. + +--- + +## 9. Verification & Test Plan + +### 9.1 Functional Tests + +| Test | Steps | Pass Criteria | +|---|---|---| +| Basic playback | Launch app, assign video to current Space | Video renders behind icons | +| Space switching | 2 Spaces, different static wallpapers, different videos assigned | Video changes within 150ms of Space switch | +| Cross-fade | Switch Spaces with fade enabled | Smooth opacity transition, no black flash | +| Multi-monitor | Two displays, assign different videos | Each display shows its own video independently | +| No assignment | Switch to a Space with no video assigned | Desktop shows through cleanly (no black overlay) | +| Relaunch persistence | Assign video, quit, relaunch | Same video plays for configured Space | +| Battery pause | Unplug power with toggle enabled | Playback pauses; resumes on plug-in | + +### 9.2 Edge Case Tests + +| Scenario | Expected Behavior | +|---|---| +| Video file deleted after assignment | Graceful fallback to default or blank; surface warning in Settings | +| Space removed from Mission Control | Orphaned config remains; no crash | +| External display disconnected | Window for that screen is cleaned up | +| macOS upgrade (major version) | z-order and Space detection re-validated on first launch | +| Ultra-wide / non-standard resolution | `resizeAspectFill` handles; letter-box not visible | + +### 9.3 Performance Benchmarks + +Run via Instruments (Time Profiler + Metal System Trace): +- Steady-state CPU with 4K HEVC wallpaper on M-series: target < 3% +- Steady-state CPU on Intel Mac (H.264, 1080p): target < 8% +- Memory: `vmmap` snapshot should show no growing regions over 30-min run + +--- + +## 10. Open Questions / Risks + +| Item | Risk Level | Notes | +|---|---|---| +| macOS API changes to `desktopImageURL` | Medium | The private Space ID approach could break; monitor release notes | +| Sonoma/Sequoia widget z-order shifts | Medium | Re-test window level on every major release | +| App Store distribution | High | Window level manipulation may trigger App Review rejection; consider direct distribution via Gumroad/website + notarization | +| HEVC licensing on older Macs | Low | VideoToolbox handles this; fallback to H.264 | +| iCloud sync complexity (v1.2) | Medium | Security-scoped bookmarks don't sync well; may require re-linking videos on each machine |