From 38f7d71ac72a49f94c2dfa502c80e564228daff2 Mon Sep 17 00:00:00 2001 From: Jhoan Gallego Date: Mon, 5 Oct 2026 16:40:25 +0200 Subject: [PATCH 1/2] Mac: Spotify pill and a richer Apple Music card Spotify (GitHub build): a new integration_spotify pill. Its card shows the album cover, title, artist and album, a progress bar you drag or click to seek, play/pause, previous/next, shuffle, repeat and volume. State comes from Spotify's com.spotify.client.PlaybackStateChanged notification and AppleScript; nothing polls and Spotify is never launched by Coucou. The cover comes from Spotify's `artwork url`, or from its public oEmbed before Automation is allowed. Apple Music: the card gets the same layout, with the cover (from Music, or Apple's public iTunes Search API when the track has none), seek, shuffle, repeat all/one, volume and a favorite heart. The pill ID, the hover pill and the Automation-denied card are unchanged. The cover, bar, volume and buttons live in NowPlayingViews.swift and are shared by both cards; each player keeps its own controller. Mochi dances for either. Privacy page, README, SPEC, INTEGRATIONS and CHANGELOG updated. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 + NotchBuddy/Resources/Info.plist | 2 +- NotchBuddy/Sources/App/AppDelegate.swift | 1 + NotchBuddy/Sources/App/BotCanvasView.swift | 10 +- NotchBuddy/Sources/App/DesktopMochi.swift | 6 +- .../Sources/App/IslandViewContent.swift | 157 ++++-- NotchBuddy/Sources/App/MusicController.swift | 393 ++++++++++--- NotchBuddy/Sources/App/NowPlayingViews.swift | 186 +++++++ NotchBuddy/Sources/App/SettingsView.swift | 1 + .../Sources/App/SpotifyController.swift | 514 ++++++++++++++++++ NotchBuddy/Sources/App/SpotifyViews.swift | 255 +++++++++ .../Sources/CoucouKit/PillCatalog.swift | 2 + NotchBuddy/project.yml | 2 +- README.md | 3 +- docs/INTEGRATIONS.md | 1 + docs/SPEC.md | 2 +- docs/privacy.html | 14 +- 17 files changed, 1426 insertions(+), 128 deletions(-) create mode 100644 NotchBuddy/Sources/App/NowPlayingViews.swift create mode 100644 NotchBuddy/Sources/App/SpotifyController.swift create mode 100644 NotchBuddy/Sources/App/SpotifyViews.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 23431cdc77..0ec84c15d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Apple Music (macOS, GitHub build): the card gets the album cover, the album, a progress bar you drag or click to seek, shuffle, repeat (all or one), volume and a heart to favorite the track, in the same layout as Spotify. The cover comes from Music itself, or from Apple's public catalog for a track that has none — thanks @JhoanG956 +- Spotify (macOS, GitHub build): a new pill with the album cover, title, artist and album, a progress bar you drag or click to seek, play/pause, previous/next, shuffle, repeat and volume. It follows Spotify's own notification, so nothing polls, and it never opens Spotify by itself. The cover shows even before you allow Automation, through Spotify's public oEmbed; Mochi dances while it plays — thanks @JhoanG956 + ## 0.1.8 — October 5, 2026 - Coucou on iPhone: turn on Settings → General → iPhone (off by default) and your agent sessions show up live in the Coucou iPhone app and its widgets, through your own private iCloud. Project names, commands and questions are encrypted with your iCloud keys; turning it off deletes them (#209, #211, #212, #213) diff --git a/NotchBuddy/Resources/Info.plist b/NotchBuddy/Resources/Info.plist index 1b70689b93..14a416fb17 100644 --- a/NotchBuddy/Resources/Info.plist +++ b/NotchBuddy/Resources/Info.plist @@ -30,7 +30,7 @@ NSAppleEventsUsageDescription - Pour sauter au terminal, envoyer des mails et piloter Musique. + Pour sauter au terminal, envoyer des mails et piloter Musique et Spotify. NSHighResolutionCapable NSSupportsAutomaticGraphicsSwitching diff --git a/NotchBuddy/Sources/App/AppDelegate.swift b/NotchBuddy/Sources/App/AppDelegate.swift index bca82b954d..9db2c2eb14 100644 --- a/NotchBuddy/Sources/App/AppDelegate.swift +++ b/NotchBuddy/Sources/App/AppDelegate.swift @@ -118,6 +118,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } #if !APPSTORE _ = MusicController.shared + _ = SpotifyController.shared #endif } } diff --git a/NotchBuddy/Sources/App/BotCanvasView.swift b/NotchBuddy/Sources/App/BotCanvasView.swift index 38f5598e83..3c45cf9884 100644 --- a/NotchBuddy/Sources/App/BotCanvasView.swift +++ b/NotchBuddy/Sources/App/BotCanvasView.swift @@ -49,12 +49,16 @@ struct BotCanvasView: View { // Compute shouldDance per-frame (no observer lag) let dancing: Bool = { #if !APPSTORE - guard AppState.shared.musicPlaying else { return false } - guard AppState.shared.activeIntegrations.contains("integration_music") else { return false } + let active = AppState.shared.activeIntegrations + let music = AppState.shared.musicPlaying && active.contains("integration_music") + let spotify = SpotifyController.shared.isPlaying && active.contains(SpotifyController.pillId) + guard music || spotify else { return false } let allowed: Set = [.idle, .working, .thinking, .searching, .finished] guard allowed.contains(state.effectiveState) else { return false } if state.mode == .compact { return true } - return state.mode == .expanded && state.view == .overview && state.focusId == "integration_music" + guard state.mode == .expanded && state.view == .overview else { return false } + return (music && state.focusId == "integration_music") + || (spotify && state.focusId == SpotifyController.pillId) #else return false #endif diff --git a/NotchBuddy/Sources/App/DesktopMochi.swift b/NotchBuddy/Sources/App/DesktopMochi.swift index b810365c94..4e1256b261 100644 --- a/NotchBuddy/Sources/App/DesktopMochi.swift +++ b/NotchBuddy/Sources/App/DesktopMochi.swift @@ -45,8 +45,10 @@ struct DesktopBotView: View { // Dance when music plays (same rules as compact mode) let dancing: Bool = { #if !APPSTORE - guard appState.musicPlaying else { return false } - guard appState.activeIntegrations.contains("integration_music") else { return false } + let music = appState.musicPlaying && appState.activeIntegrations.contains("integration_music") + let spotify = SpotifyController.shared.isPlaying + && appState.activeIntegrations.contains(SpotifyController.pillId) + guard music || spotify else { return false } let allowed: Set = [.idle, .working, .thinking, .searching, .finished] return allowed.contains(appState.effectiveState) #else diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index 3947f0d6a6..05f58567c6 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -241,6 +241,10 @@ struct OverviewView: View { #if !APPSTORE MusicController.shared.openMusic() #endif + case "integration_spotify": + #if !APPSTORE + SpotifyController.shared.openSpotify() + #endif default: // Non-integration real tasks if task.source == .n8n { @@ -1706,6 +1710,15 @@ struct IntegrationCardView: View { #endif } + // Spotify: its own card for every state (playing, idle, not installed, Automation denied) + private var isSpotify: Bool { + #if !APPSTORE + return task.id == "integration_spotify" + #else + return false + #endif + } + private var statusDot: Color { #if !APPSTORE if task.id == "integration_music" { @@ -1822,6 +1835,11 @@ struct IntegrationCardView: View { MusicCardView() .transition(.opacity) #endif + } else if isSpotify { + #if !APPSTORE + SpotifyCardView() + .transition(.opacity) + #endif } else if agentSessionActive { // Active session view — reuse overview layout VStack(alignment: .leading, spacing: 0) { @@ -3670,6 +3688,13 @@ struct AgentPillsView: View { SoundEngine.shared.play("blip") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { swapping = false } } + } else if task.id == "integration_spotify" { + SpotifyPill(task: task, swapping: $swapping) { + swapping = true + state.setFocus(task.id) + SoundEngine.shared.play("blip") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { swapping = false } + } } else { AgentPill(task: task, state: state, swapping: $swapping) { swapping = true @@ -3901,60 +3926,110 @@ struct MusicCardView: View { .buttonStyle(.plain) .padding(.leading, 108) .padding(.top, 2) - } else { - // Line 1: dot + title - HStack(spacing: 6) { - Circle() - .fill(Color(hex: "#FA2D48")) - .frame(width: 7, height: 7) - if let title = controller.trackTitle { + } else if let title = controller.trackTitle { + nowPlaying(title) + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.top, 4) + // Music doesn't announce seeks made in its own window: re-read when the card shows + .onAppear { controller.refresh() } + } + + private let red = Color(hex: "#FA2D48") + + // The card is 98 pt tall: artwork row, progress row, controls row (same layout as Spotify). + private func nowPlaying(_ title: String) -> some View { + let subtitle = [controller.artist, controller.album].compactMap { $0 }.joined(separator: " · ") + return VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 9) { + NowPlayingArtwork(image: controller.artwork, accent: red) + .frame(width: 36, height: 36) + .onTapGesture { controller.openMusic() } + .help(controller.album.map { "\($0) — open Music" } ?? "Open Music") + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 5) { + Circle().fill(red).frame(width: 6, height: 6) Text(title) .font(.system(size: 12, weight: .semibold)) .foregroundColor(Color(hex: "#F5F6F8")) .lineLimit(1).truncationMode(.tail) - .frame(maxWidth: 150, alignment: .leading) + if let fav = controller.favorited { + NowPlayingIconButton(icon: fav ? "heart.fill" : "heart", size: 9, + tint: fav ? red : Color(hex: "#6B7079"), + help: fav ? "Remove from favorites" : "Favorite") { + controller.toggleFavorite() + } + } } - } - .padding(.top, 6) - .padding(.leading, 108) - - // Line 2: artist - if let artist = controller.artist { - Text(artist) - .font(.system(size: 11)) - .foregroundColor(Color(hex: "#8E939C")) - .lineLimit(1).truncationMode(.tail) - .frame(maxWidth: 150, alignment: .leading) - .padding(.leading, 108) - } - - // Line 3: controls - HStack(spacing: 8) { - Button(action: { MusicController.shared.previousTrack() }) { - Image(systemName: "backward.fill") + if !subtitle.isEmpty { + Text(subtitle) .font(.system(size: 11)) .foregroundColor(Color(hex: "#8E939C")) + .lineLimit(1).truncationMode(.tail) } - .buttonStyle(.plain) - Button(action: { MusicController.shared.playPause() }) { + } + Spacer(minLength: 0) + } + .padding(.trailing, 24) // the ↗ button sits top-right + .padding(.top, 6) + + // Position runs on its own clock only while playing and on screen + TimelineView(.animation(minimumInterval: 0.5, paused: !appState.musicPlaying)) { context in + NowPlayingProgress( + position: controller.position(at: context.date), + duration: controller.duration, + accent: red, + canSeek: controller.duration > 0, + onSeek: { controller.seek(to: $0) } + ) + } + .padding(.top, 7) + + controls + .padding(.top, 4) + } + .padding(.leading, 108) + .padding(.trailing, 12) + } + + private var controls: some View { + HStack(spacing: 0) { + HStack(spacing: 10) { + NowPlayingIconButton(icon: "shuffle", size: 10, + tint: controller.shuffling ? red : Color(hex: "#6B7079"), + help: controller.shuffling ? "Shuffle on" : "Shuffle off") { + controller.setShuffling(!controller.shuffling) + } + NowPlayingIconButton(icon: "backward.fill", size: 11, tint: Color(hex: "#C5C8CD"), help: "Previous") { + controller.previousTrack() + } + Button(action: { controller.playPause() }) { + ZStack { + Circle().fill(Color(hex: "#F5F6F8")) Image(systemName: appState.musicPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 11)) - .foregroundColor(Color(hex: "#FA2D48")) - } - .buttonStyle(.plain) - Button(action: { MusicController.shared.nextTrack() }) { - Image(systemName: "forward.fill") - .font(.system(size: 11)) - .foregroundColor(Color(hex: "#8E939C")) + .font(.system(size: 8.5, weight: .bold)) + .foregroundColor(Color(hex: "#0E0F11")) + .offset(x: appState.musicPlaying ? 0 : 1) } - .buttonStyle(.plain) + .frame(width: 20, height: 20) + } + .buttonStyle(NowPlayingPressStyle()) + .help(appState.musicPlaying ? "Pause" : "Play") + NowPlayingIconButton(icon: "forward.fill", size: 11, tint: Color(hex: "#C5C8CD"), help: "Next") { + controller.nextTrack() + } + NowPlayingIconButton(icon: controller.repeatMode == .one ? "repeat.1" : "repeat", size: 10, + tint: controller.repeatMode == .off ? Color(hex: "#6B7079") : red, + help: controller.repeatMode == .off ? "Repeat off" + : controller.repeatMode == .all ? "Repeat all" : "Repeat one") { + controller.cycleRepeat() } - .padding(.leading, 108) - .padding(.top, 6) } + Spacer(minLength: 8) + NowPlayingVolume(volume: controller.volume, accent: red) { controller.setVolume($0) } } - .frame(maxWidth: .infinity, alignment: .topLeading) - .padding(.top, 4) } } #endif diff --git a/NotchBuddy/Sources/App/MusicController.swift b/NotchBuddy/Sources/App/MusicController.swift index 3578e3826d..86d1181195 100644 --- a/NotchBuddy/Sources/App/MusicController.swift +++ b/NotchBuddy/Sources/App/MusicController.swift @@ -7,25 +7,63 @@ import Combine /// Observes Apple Music state via distributed notifications and provides playback controls. /// Singleton, @MainActor, GitHub build only. +/// Nothing runs on a timer: state arrives with Music's notification, the card extrapolates the position. @MainActor final class MusicController: ObservableObject { static let shared = MusicController() + nonisolated static let bundleId = "com.apple.Music" + + enum RepeatMode: String, Sendable { case off, all, one } @Published var trackTitle: String? @Published var artist: String? @Published var album: String? + @Published private(set) var duration: Double = 0 + /// Position (seconds) at `positionDate`; while playing, the real position runs on from there. + @Published private(set) var positionAnchor: Double = 0 + @Published private(set) var positionDate = Date() + @Published private(set) var shuffling = false + @Published private(set) var repeatMode: RepeatMode = .off + @Published private(set) var volume: Int = 50 + /// nil when Music can't favorite this track (radio, some streams). + @Published private(set) var favorited: Bool? + @Published private(set) var artwork: NSImage? + + nonisolated private static let grantedKey = "coucou.musicAutomationGranted" private var notifTokens: [Any] = [] private var cancellables = Set() private let queue = DispatchQueue(label: "fr.louisraille.coucou.music") + private var artworkCache: [String: NSImage] = [:] + private var artworkCacheOrder: [String] = [] + private var artworkLoadingKey: String? + private var pendingVolume: Int? + private var volumeTask: Task? private var isPillActive: Bool { AppState.shared.activeIntegrations.contains("integration_music") } + private var automationGranted: Bool { + UserDefaults.standard.bool(forKey: Self.grantedKey) + } + + /// Same track = same title, artist and album (the notification and AppleScript IDs differ in format). + private var trackKey: String? { + guard let trackTitle else { return nil } + return [trackTitle, artist ?? "", album ?? ""].joined(separator: "\u{1F}") + } + + /// Where playback is now, extrapolated from the last anchor. + func position(at date: Date) -> Double { + let elapsed = AppState.shared.musicPlaying ? date.timeIntervalSince(positionDate) : 0 + let p = positionAnchor + max(0, elapsed) + return duration > 0 ? min(p, duration) : p + } + private init() { // playerInfo fires whenever Music state changes (play/pause/track change). - // Extract Sendable String? values before crossing into @MainActor. + // Extract Sendable values before crossing into @MainActor. let tok1 = DistributedNotificationCenter.default().addObserver( forName: NSNotification.Name("com.apple.Music.playerInfo"), object: nil, @@ -36,8 +74,10 @@ final class MusicController: ObservableObject { let name = info?["Name"] as? String let artist = info?["Artist"] as? String let album = info?["Album"] as? String + let totalTimeMs = (info?["Total Time"] as? NSNumber)?.doubleValue Task { @MainActor [weak self] in - self?.handlePlayerInfo(playerState: playerState, name: name, artist: artist, album: album) + self?.handlePlayerInfo(playerState: playerState, name: name, artist: artist, + album: album, totalTimeMs: totalTimeMs) } } notifTokens.append(tok1) @@ -50,12 +90,10 @@ final class MusicController: ObservableObject { ) { [weak self] notif in let bundleId = (notif.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication)?.bundleIdentifier - guard bundleId == "com.apple.Music" else { return } + guard bundleId == MusicController.bundleId else { return } Task { @MainActor [weak self] in - guard let self else { return } - guard self.isPillActive, - UserDefaults.standard.bool(forKey: "coucou.musicAutomationGranted") else { return } - self.fetchAndApply() + guard let self, self.automationGranted else { return } + self.refresh() } } notifTokens.append(tok2) @@ -68,7 +106,7 @@ final class MusicController: ObservableObject { ) { [weak self] notif in let bundleId = (notif.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication)?.bundleIdentifier - guard bundleId == "com.apple.Music" else { return } + guard bundleId == MusicController.bundleId else { return } Task { @MainActor [weak self] in self?.clearState() } } notifTokens.append(tok3) @@ -78,9 +116,9 @@ final class MusicController: ObservableObject { .sink { [weak self] integrations in guard let self else { return } if integrations.contains("integration_music") { - if self.isMusicRunning(), - UserDefaults.standard.bool(forKey: "coucou.musicAutomationGranted") { - self.fetchAndApply() + if self.automationGranted { + // The publisher fires before the property is set: read once it is + Task { @MainActor [weak self] in self?.refresh() } } } else { self.clearState() @@ -92,7 +130,7 @@ final class MusicController: ObservableObject { // MARK: - Private helpers private func isMusicRunning() -> Bool { - NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == "com.apple.Music" } + NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == Self.bundleId } } // MARK: - Metadata cleaners @@ -132,96 +170,293 @@ final class MusicController: ObservableObject { return raw } - private func handlePlayerInfo(playerState: String?, name: String?, artist inputArtist: String?, album inputAlbum: String?) { + // MARK: - State + + private func handlePlayerInfo(playerState: String?, name: String?, artist inputArtist: String?, + album inputAlbum: String?, totalTimeMs: Double?) { guard isPillActive else { return } + if playerState == "Stopped" { clearState(); return } - let playing = playerState == "Playing" - let wasPlaying = AppState.shared.musicPlaying + if let name { + setTrack(title: name, artist: inputArtist ?? "", album: inputAlbum ?? "", + duration: (totalTimeMs ?? 0) / 1000) + } + setPlaying(playerState == "Playing") - trackTitle = name.map { Self.shortTitle($0) }.flatMap { $0.isEmpty ? nil : $0 } - artist = inputArtist.map { Self.shortArtist($0) }.flatMap { $0.isEmpty ? nil : $0 } - album = inputAlbum + // The notification carries no position, shuffle, repeat, volume or artwork: read them + // when we may, otherwise look the cover up in Apple's public catalog. + if automationGranted { + refresh() + } else if artwork == nil { + loadArtwork(localData: nil) + } + } - AppState.shared.musicPlaying = playing + private func setTrack(title: String, artist inputArtist: String, album inputAlbum: String, duration: Double) { + let oldKey = trackKey + trackTitle = Self.shortTitle(title).nilIfEmpty + artist = Self.shortArtist(inputArtist).nilIfEmpty + album = inputAlbum.nilIfEmpty + if duration > 0 { self.duration = duration } + if trackKey != oldKey { + artwork = trackKey.flatMap { artworkCache[$0] } + favorited = nil + } syncTaskName() + } + private func setPlaying(_ playing: Bool) { + let wasPlaying = AppState.shared.musicPlaying + if playing != wasPlaying { + // Freeze or restart the clock at the current position + anchor(position(at: Date())) + } + AppState.shared.musicPlaying = playing // Reveal only on transition from not-playing → playing if playing && !wasPlaying { NotificationCenter.default.post(name: .musicReveal, object: nil) } } - private func fetchAndApply() { + private func anchor(_ position: Double) { + positionAnchor = max(0, position) + positionDate = Date() + } + + private func clearState() { + trackTitle = nil; artist = nil; album = nil + duration = 0 + artwork = nil + favorited = nil + anchor(0) + AppState.shared.musicPlaying = false + syncTaskName() + } + + private func syncTaskName() { + guard let idx = AppState.shared.tasks.firstIndex(where: { $0.id == "integration_music" }) else { return } + let title = trackTitle ?? "" + AppState.shared.tasks[idx].name = title.isEmpty + ? (PillCatalog.definition(for: "integration_music")?.name ?? "Apple Music") + : title + } + + // MARK: - Reading Music + + /// Reads track, position, shuffle, repeat, volume and favorite. Never launches Music. + /// The first call is what makes macOS ask for Automation. + func refresh() { + guard isPillActive, isMusicRunning() else { return } Task { let result = await runAppleScript(""" tell application id "com.apple.Music" set ps to player state as string - if ps is "stopped" then return {ps, "", "", ""} + if ps is "stopped" then return {ps} try set tr to current track set n to name of tr on error - return {ps, "", "", ""} + return {"stopped"} end try set ar to "" set al to "" + set du to 0 + set pp to 0 + set fv to "unknown" try set ar to artist of tr end try try set al to album of tr end try - return {ps, n, ar, al} + try + set du to duration of tr + end try + try + set pp to player position + end try + try + set fv to favorited of tr as string + end try + return {ps, n, ar, al, du, pp, shuffle enabled, song repeat as string, sound volume, fv} + end tell + """) + guard case .success(let v) = result, let ps = v.first?.text else { return } + guard isPillActive else { return } + if ps == "stopped" || v.count < 10 { clearState(); return } + + let oldKey = trackKey + setTrack(title: v[1].text, artist: v[2].text, album: v[3].text, duration: v[4].number) + anchor(v[5].number) + setPlaying(ps == "playing") + shuffling = v[6].flag + repeatMode = RepeatMode(rawValue: v[7].text) ?? .off + if pendingVolume == nil { volume = Int(v[8].number.rounded()) } + favorited = v[9].text == "true" ? true : v[9].text == "false" ? false : nil + if artwork == nil || trackKey != oldKey { fetchArtwork() } + } + } + + // MARK: - Artwork + + /// The cover Music has for the track; streamed tracks without one fall back to Apple's catalog. + private func fetchArtwork() { + guard let key = trackKey, artworkCache[key] == nil, artworkLoadingKey != key else { + if let key = trackKey, let cached = artworkCache[key] { artwork = cached } + return + } + Task { + let result = await runAppleScript(""" + tell application id "com.apple.Music" + try + return raw data of artwork 1 of current track + on error + return "" + end try end tell """) - guard case .success(let values) = result, values.count >= 4 else { return } - let playing = values[0] == "playing" - let wasPlaying = AppState.shared.musicPlaying - trackTitle = values[1].isEmpty ? nil : Self.shortTitle(values[1]) - artist = values[2].isEmpty ? nil : Self.shortArtist(values[2]) - album = values[3].isEmpty ? nil : values[3] - AppState.shared.musicPlaying = playing - syncTaskName() - if playing && !wasPlaying { - NotificationCenter.default.post(name: .musicReveal, object: nil) + guard trackKey == key else { return } + if case .success(let v) = result, let data = v.first?.data { + loadArtwork(localData: data) + } else { + loadArtwork(localData: nil) } } } - private func clearState() { - trackTitle = nil; artist = nil; album = nil - AppState.shared.musicPlaying = false - syncTaskName() + private func loadArtwork(localData: Data?) { + guard let key = trackKey else { return } + if let cached = artworkCache[key] { artwork = cached; return } + if let localData, let image = NSImage(data: localData) { + cacheArtwork(image, for: key) + artwork = image + return + } + guard artworkLoadingKey != key else { return } + artworkLoadingKey = key + let term = [artist, album ?? trackTitle].compactMap { $0 }.joined(separator: " ") + Task { + defer { if artworkLoadingKey == key { artworkLoadingKey = nil } } + guard let url = await Self.catalogArtworkURL(term: term), + let (data, response) = try? await URLSession.shared.data(from: url), + (response as? HTTPURLResponse)?.statusCode == 200, + let image = NSImage(data: data) else { return } + cacheArtwork(image, for: key) + if trackKey == key { artwork = image } + } } - private func syncTaskName() { - guard let idx = AppState.shared.tasks.firstIndex(where: { $0.id == "integration_music" }) else { return } - let title = trackTitle ?? "" - AppState.shared.tasks[idx].name = title.isEmpty - ? (PillCatalog.definition(for: "integration_music")?.name ?? "Apple Music") - : title + private func cacheArtwork(_ image: NSImage, for key: String) { + artworkCache[key] = image + artworkCacheOrder.removeAll { $0 == key } + artworkCacheOrder.append(key) + while artworkCacheOrder.count > 12 { + artworkCache[artworkCacheOrder.removeFirst()] = nil + } + } + + /// Apple's public iTunes Search API: no account, just "artist album". + nonisolated private static func catalogArtworkURL(term: String) async -> URL? { + guard !term.trimmingCharacters(in: .whitespaces).isEmpty else { return nil } + var comps = URLComponents(string: "https://itunes.apple.com/search")! + comps.queryItems = [ + URLQueryItem(name: "term", value: term), + URLQueryItem(name: "entity", value: "album"), + URLQueryItem(name: "limit", value: "1"), + ] + guard let url = comps.url, + let (data, response) = try? await URLSession.shared.data(from: url), + (response as? HTTPURLResponse)?.statusCode == 200, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let first = (json["results"] as? [[String: Any]])?.first, + let small = first["artworkUrl100"] as? String, + // Only Apple's image CDN: the URL comes from a web response + let art = safeWebURL(small.replacingOccurrences(of: "100x100bb", with: "300x300bb")), + art.scheme == "https", + art.host?.lowercased().hasSuffix(".mzstatic.com") == true else { return nil } + return art } // MARK: - Playback controls func playPause() { guard isMusicRunning() else { return } - Task { await runAppleScript(#"tell application id "com.apple.Music" to playpause"#) } + setPlaying(!AppState.shared.musicPlaying) // optimistic; Music's notification confirms + command("playpause") } func nextTrack() { guard isMusicRunning() else { return } - Task { await runAppleScript(#"tell application id "com.apple.Music" to next track"#) } + command("next track") } func previousTrack() { guard isMusicRunning() else { return } - Task { await runAppleScript(#"tell application id "com.apple.Music" to back track"#) } + command("back track") + } + + func seek(to seconds: Double) { + guard isMusicRunning(), duration > 0 else { return } + let target = min(max(0, seconds), max(0, duration - 1)) + anchor(target) + command("set player position to \(Int(target.rounded()))", refreshAfter: false) + } + + func setShuffling(_ on: Bool) { + guard isMusicRunning() else { return } + shuffling = on + command("set shuffle enabled to \(on)", refreshAfter: false) + } + + /// off → all → one → off, like the Music app. + func cycleRepeat() { + guard isMusicRunning() else { return } + let next: RepeatMode = repeatMode == .off ? .all : repeatMode == .all ? .one : .off + repeatMode = next + command("set song repeat to \(next.rawValue)", refreshAfter: false) + } + + func toggleFavorite() { + guard isMusicRunning(), let current = favorited else { return } + favorited = !current + Task { + let result = await runAppleScript( + #"tell application id "com.apple.Music" to set favorited of current track to \#(!current)"#) + // Some tracks can't be favorited: put the heart back + if case .success = result {} else { favorited = current } + } + } + + /// Coalesced: a slider drag sends at most one Apple Event every 120 ms. + func setVolume(_ value: Int) { + guard isMusicRunning() else { return } + let v = min(100, max(0, value)) + volume = v + pendingVolume = v + guard volumeTask == nil else { return } + volumeTask = Task { + while let v = pendingVolume { + pendingVolume = nil + await runAppleScript(#"tell application id "com.apple.Music" to set sound volume to \#(v)"#) + try? await Task.sleep(for: .milliseconds(120)) + } + volumeTask = nil + } + } + + private func command(_ verb: String, refreshAfter: Bool = true) { + Task { + let result = await runAppleScript(#"tell application id "com.apple.Music" to \#(verb)"#) + guard refreshAfter, case .success = result else { return } + // Let Music settle on the new track before reading it back + try? await Task.sleep(for: .milliseconds(350)) + refresh() + } } func openMusic() { - if let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == "com.apple.Music" }) { - app.activate(options: .activateIgnoringOtherApps) + if let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == Self.bundleId }) { + app.activate() } else { NSWorkspace.shared.open(URL(fileURLWithPath: "/System/Applications/Music.app")) } @@ -235,45 +470,59 @@ final class MusicController: ObservableObject { // MARK: - AppleScript runner - enum ScriptResult { case success([String]), denied, error } + /// One item of an AppleScript result, read the ways callers may expect it. + struct ScriptValue: Sendable { + let text: String + let number: Double + let flag: Bool + let data: Data? + } + + enum ScriptResult: Sendable { case success([ScriptValue]), denied, error } @discardableResult private func runAppleScript(_ source: String) async -> ScriptResult { - await withCheckedContinuation { cont in + let grantedKey = Self.grantedKey + let result: ScriptResult = await withCheckedContinuation { cont in queue.async { - let script = NSAppleScript(source: source)! + guard let script = NSAppleScript(source: source) else { + cont.resume(returning: .error); return + } var errDict: NSDictionary? let desc = script.executeAndReturnError(&errDict) if let errDict { let code = (errDict[NSAppleScript.errorNumber] as? Int) ?? 0 - if code == -1743 { - Task { @MainActor in - AppState.shared.musicAutomationDenied = true - UserDefaults.standard.set(false, forKey: "coucou.musicAutomationGranted") - } - cont.resume(returning: .denied) - } else { - cont.resume(returning: .error) - } + cont.resume(returning: code == -1743 ? .denied : .error) return } - Task { @MainActor in - UserDefaults.standard.set(true, forKey: "coucou.musicAutomationGranted") - AppState.shared.musicAutomationDenied = false + // Extract values on this queue (NSAppleEventDescriptor isn't Sendable) + func value(_ d: NSAppleEventDescriptor?) -> ScriptValue { + let text = d?.stringValue ?? "" + // Only binary results (artwork) carry data; text comes back through `text` + let binary = d.map { $0.stringValue == nil && !$0.data.isEmpty } ?? false + return ScriptValue(text: text, number: d?.doubleValue ?? 0, + flag: d?.booleanValue ?? false, data: binary ? d?.data : nil) } - // Extract values on this queue before resuming (avoids NSAppleEventDescriptor Sendable issues) - var values: [String] = [] let count = desc.numberOfItems - if count > 0 { - for i in 1...count { - values.append(desc.atIndex(i)?.stringValue ?? "") - } - } else { - values = [desc.stringValue ?? ""] - } + let values = count > 0 ? (1...count).map { value(desc.atIndex($0)) } : [value(desc)] cont.resume(returning: .success(values)) } } + switch result { + case .success: + UserDefaults.standard.set(true, forKey: grantedKey) + AppState.shared.musicAutomationDenied = false + case .denied: + UserDefaults.standard.set(false, forKey: grantedKey) + AppState.shared.musicAutomationDenied = true + case .error: + break + } + return result } } + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} #endif diff --git a/NotchBuddy/Sources/App/NowPlayingViews.swift b/NotchBuddy/Sources/App/NowPlayingViews.swift new file mode 100644 index 0000000000..743d4ee7a8 --- /dev/null +++ b/NotchBuddy/Sources/App/NowPlayingViews.swift @@ -0,0 +1,186 @@ +#if !APPSTORE +import SwiftUI +import AppKit + +// MARK: - Now playing pieces (Spotify and Apple Music cards, GitHub build only) + +struct NowPlayingArtwork: View { + let image: NSImage? + let accent: Color + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(Color.white.opacity(0.06)) + if let image { + Image(nsImage: image) + .resizable() + .interpolation(.high) + .aspectRatio(contentMode: .fill) + .transition(.opacity) + } else { + Image(systemName: "music.note") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(accent.opacity(0.7)) + } + } + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.white.opacity(0.08), lineWidth: 1)) + .shadow(color: .black.opacity(0.35), radius: 4, y: 2) + .animation(.easeInOut(duration: 0.25), value: image) + .contentShape(Rectangle()) + } +} + +/// Thin bar with elapsed / remaining time; drag or click anywhere on it to seek. +struct NowPlayingProgress: View { + let position: Double + let duration: Double + let accent: Color + let canSeek: Bool + let onSeek: (Double) -> Void + + @State private var dragFraction: Double? + + private var fraction: Double { + if let dragFraction { return dragFraction } + guard duration > 0 else { return 0 } + return min(1, max(0, position / duration)) + } + + var body: some View { + HStack(spacing: 6) { + Text(Self.format(fraction * duration)) + .frame(minWidth: 26, alignment: .trailing) + NowPlayingBar(fraction: fraction, accent: accent, enabled: canSeek, + onChange: { dragFraction = $0 }, + onCommit: { f in + onSeek(f * duration) + dragFraction = nil + }) + .frame(height: 10) + Text(duration > 0 ? "-" + Self.format(max(0, duration - fraction * duration)) : "") + .frame(minWidth: 28, alignment: .leading) + } + .font(.system(size: 9.5).monospacedDigit()) + .foregroundColor(Color(hex: "#6B7079")) + } + + static func format(_ seconds: Double) -> String { + let s = max(0, Int(seconds.rounded(.down))) + return s >= 3600 + ? String(format: "%d:%02d:%02d", s / 3600, (s % 3600) / 60, s % 60) + : String(format: "%d:%02d", s / 60, s % 60) + } +} + +struct NowPlayingVolume: View { + let volume: Int + let accent: Color + let onChange: (Int) -> Void + + private var icon: String { + switch volume { + case 0: return "speaker.slash.fill" + case 1..<34: return "speaker.wave.1.fill" + case 34..<67: return "speaker.wave.2.fill" + default: return "speaker.wave.3.fill" + } + } + + var body: some View { + HStack(spacing: 5) { + Image(systemName: icon) + .font(.system(size: 9)) + .foregroundColor(Color(hex: "#6B7079")) + .frame(width: 13, alignment: .trailing) + NowPlayingBar(fraction: Double(volume) / 100, accent: accent, enabled: true, + onChange: { onChange(Int(($0 * 100).rounded())) }, + onCommit: { onChange(Int(($0 * 100).rounded())) }) + .frame(width: 44, height: 10) + } + .help("Volume \(volume)%") + } +} + +/// Player slider: grey track, filled part turns green and grows a knob on hover. +struct NowPlayingBar: View { + let fraction: Double + let accent: Color + let enabled: Bool + let onChange: (Double) -> Void + let onCommit: (Double) -> Void + + @State private var hovering = false + @State private var dragging = false + + var body: some View { + GeometryReader { geo in + let w = max(1, geo.size.width) + let f = min(1, max(0, fraction)) + let active = enabled && (hovering || dragging) + ZStack(alignment: .leading) { + Capsule() + .fill(Color.white.opacity(0.12)) + .frame(height: active ? 4 : 3) + Capsule() + .fill(active ? accent : Color(hex: "#C5C8CD")) + .frame(width: w * f, height: active ? 4 : 3) + if active { + Circle() + .fill(Color(hex: "#F5F6F8")) + .frame(width: 9, height: 9) + .shadow(color: .black.opacity(0.4), radius: 2) + .offset(x: min(w - 9, max(0, w * f - 4.5))) + } + } + .frame(width: w, height: geo.size.height) + .contentShape(Rectangle()) + .onHover { h in withAnimation(.easeOut(duration: 0.12)) { hovering = h } } + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { g in + guard enabled else { return } + dragging = true + onChange(min(1, max(0, g.location.x / w))) + } + .onEnded { g in + guard enabled else { return } + dragging = false + onCommit(min(1, max(0, g.location.x / w))) + } + ) + } + } +} + +struct NowPlayingIconButton: View { + let icon: String + let size: CGFloat + let tint: Color + let help: String + let action: () -> Void + @State private var hovered = false + + var body: some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: size, weight: .semibold)) + .foregroundColor(hovered ? tint.opacity(1).lighter(by: 0.2) : tint) + .frame(width: 16, height: 18) + .contentShape(Rectangle()) + } + .buttonStyle(NowPlayingPressStyle()) + .onHover { h in withAnimation(.easeOut(duration: 0.12)) { hovered = h } } + .help(help) + } +} + +struct NowPlayingPressStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.9 : 1) + .animation(.spring(response: 0.2, dampingFraction: 0.7), value: configuration.isPressed) + } +} +#endif diff --git a/NotchBuddy/Sources/App/SettingsView.swift b/NotchBuddy/Sources/App/SettingsView.swift index 9a3d040817..ec425f8ad5 100644 --- a/NotchBuddy/Sources/App/SettingsView.swift +++ b/NotchBuddy/Sources/App/SettingsView.swift @@ -1232,6 +1232,7 @@ struct SettingsView: View { if def.id == "agent_gemini" && !HookServer.geminiHooksInstalled() { return "Hooks not installed" } if def.id == "agent_antigravity" && !HookServer.agyHooksInstalled() { return "Hooks not installed" } if def.id == "agent_codex" && !HookServer.codexHooksInstalled() { return "Hooks not installed" } + if def.id == SpotifyController.pillId && !SpotifyController.shared.isInstalled { return "Not installed" } #endif if def.category == .ai { if let provider = ChatProvider(pillID: def.id), provider.isLocal { diff --git a/NotchBuddy/Sources/App/SpotifyController.swift b/NotchBuddy/Sources/App/SpotifyController.swift new file mode 100644 index 0000000000..89e45ec990 --- /dev/null +++ b/NotchBuddy/Sources/App/SpotifyController.swift @@ -0,0 +1,514 @@ +#if !APPSTORE +import Foundation +import AppKit +import Combine + +// MARK: - Spotify Controller + +/// The track Spotify has loaded, as its notification and AppleScript describe it. +struct SpotifyTrack: Equatable, Sendable { + let id: String // spotify:track:…, spotify:episode:…, spotify:ad:…, spotify:local:… + let title: String + let artist: String + let album: String + let duration: Double // seconds + var artworkURL: String? + + var isAd: Bool { id.hasPrefix("spotify:ad:") } + + /// open.spotify.com page for tracks and episodes (ads and local files have none). + var webURL: URL? { + let parts = id.split(separator: ":") + guard parts.count == 3, parts[0] == "spotify", parts[1] == "track" || parts[1] == "episode" else { return nil } + return URL(string: "https://open.spotify.com/\(parts[1])/\(parts[2])") + } +} + +/// Observes Spotify through its distributed notification and drives it over AppleScript. +/// Singleton, @MainActor, GitHub build only (the App Store sandbox would need an Apple Events exception). +/// Nothing runs on a timer: state arrives with Spotify's notification, the card extrapolates the position. +@MainActor +final class SpotifyController: ObservableObject { + static let shared = SpotifyController() + nonisolated static let pillId = "integration_spotify" + nonisolated static let bundleId = "com.spotify.client" + nonisolated static let green = "#1DB954" + + @Published private(set) var track: SpotifyTrack? + @Published private(set) var isPlaying = false + /// Position (seconds) at `positionDate`; while playing, the real position runs on from there. + @Published private(set) var positionAnchor: Double = 0 + @Published private(set) var positionDate = Date() + @Published private(set) var shuffling = false + @Published private(set) var repeating = false + @Published private(set) var volume: Int = 50 + @Published private(set) var artwork: NSImage? + @Published private(set) var automationDenied = false + + nonisolated private static let grantedKey = "coucou.spotifyAutomationGranted" + + private var notifTokens: [Any] = [] + private var cancellables = Set() + private let queue = DispatchQueue(label: "fr.louisraille.coucou.spotify") + private var artworkCache: [String: NSImage] = [:] + private var artworkCacheOrder: [String] = [] + private var artworkLoadingId: String? + private var pendingVolume: Int? + private var volumeTask: Task? + + private var isPillActive: Bool { + AppState.shared.activeIntegrations.contains(Self.pillId) + } + + private var automationGranted: Bool { + UserDefaults.standard.bool(forKey: Self.grantedKey) + } + + var isInstalled: Bool { + NSWorkspace.shared.urlForApplication(withBundleIdentifier: Self.bundleId) != nil + } + + var isRunning: Bool { + NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == Self.bundleId } + } + + /// Where playback is now, extrapolated from the last anchor. + func position(at date: Date) -> Double { + let elapsed = isPlaying ? date.timeIntervalSince(positionDate) : 0 + let p = positionAnchor + max(0, elapsed) + guard let d = track?.duration, d > 0 else { return p } + return min(p, d) + } + + private init() { + // Spotify posts this on play, pause and track change. Seeks inside Spotify don't post it, + // so the card re-reads the position when it appears. + let tok1 = DistributedNotificationCenter.default().addObserver( + forName: NSNotification.Name("com.spotify.client.PlaybackStateChanged"), + object: nil, + queue: .main + ) { [weak self] notif in + // Extract Sendable values before crossing into @MainActor. + let info = notif.userInfo + let state = info?["Player State"] as? String + let trackId = info?["Track ID"] as? String + let name = info?["Name"] as? String + let artist = info?["Artist"] as? String + let album = info?["Album"] as? String + let duration = (info?["Duration"] as? NSNumber)?.doubleValue + let position = (info?["Playback Position"] as? NSNumber)?.doubleValue + Task { @MainActor [weak self] in + self?.handleNotification(state: state, trackId: trackId, name: name, artist: artist, + album: album, durationMs: duration, position: position) + } + } + notifTokens.append(tok1) + + // Spotify launched: read its state once, only if we already may + let tok2 = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didLaunchApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notif in + let bundleId = (notif.userInfo?[NSWorkspace.applicationUserInfoKey] + as? NSRunningApplication)?.bundleIdentifier + guard bundleId == SpotifyController.bundleId else { return } + Task { @MainActor [weak self] in + guard let self, self.automationGranted else { return } + self.refresh() + } + } + notifTokens.append(tok2) + + // Spotify quit: nothing is playing anymore + let tok3 = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didTerminateApplicationNotification, + object: nil, + queue: .main + ) { [weak self] notif in + let bundleId = (notif.userInfo?[NSWorkspace.applicationUserInfoKey] + as? NSRunningApplication)?.bundleIdentifier + guard bundleId == SpotifyController.bundleId else { return } + Task { @MainActor [weak self] in self?.clearState() } + } + notifTokens.append(tok3) + + // Pill turned on → read the current track (this is when macOS asks for Automation, right + // after the user's own click). At launch, only if already granted. Pill turned off → clear. + var wasActive: Bool? = nil + AppState.shared.$activeIntegrations + .sink { [weak self] integrations in + guard let self else { return } + let active = integrations.contains(Self.pillId) + defer { wasActive = active } + guard active else { self.clearState(); return } + guard wasActive != true else { return } + if wasActive == false || self.automationGranted { + // The publisher fires before the property is set: read once it is + Task { @MainActor [weak self] in self?.refresh() } + } + } + .store(in: &cancellables) + } + + // MARK: - State + + private func handleNotification(state: String?, trackId: String?, name: String?, artist: String?, + album: String?, durationMs: Double?, position: Double?) { + guard isPillActive else { return } + if state == "Stopped" { clearState(); return } + + if let trackId, !trackId.isEmpty { + let same = track?.id == trackId + let newTrack = SpotifyTrack( + id: trackId, + title: Self.shortTitle(name ?? ""), + artist: Self.shortArtist(artist ?? ""), + album: album ?? "", + duration: (durationMs ?? 0) / 1000, + artworkURL: same ? track?.artworkURL : nil + ) + setTrack(newTrack) + } + if let position { anchor(position) } + setPlaying(state == "Playing") + + // The notification carries no artwork, shuffle, repeat or volume: read them when we may, + // otherwise find the artwork through Spotify's public oEmbed. + if automationGranted { + refresh() + } else if let track, artwork == nil { + loadArtwork(for: track) + } + } + + private func setTrack(_ newTrack: SpotifyTrack) { + let changed = track?.id != newTrack.id + track = newTrack + if changed { + artwork = artworkCache[newTrack.id] + } + syncTaskName() + } + + private func setPlaying(_ playing: Bool) { + let wasPlaying = isPlaying + if playing != wasPlaying { + // Freeze or restart the clock at the current position + anchor(position(at: Date())) + } + isPlaying = playing + // Reveal only on transition from not-playing → playing + if playing && !wasPlaying { + NotificationCenter.default.post(name: .musicReveal, object: nil) + } + } + + private func anchor(_ position: Double) { + positionAnchor = max(0, position) + positionDate = Date() + } + + private func clearState() { + track = nil + artwork = nil + isPlaying = false + anchor(0) + syncTaskName() + } + + private func syncTaskName() { + guard let idx = AppState.shared.tasks.firstIndex(where: { $0.id == Self.pillId }) else { return } + let title = track?.isAd == true ? "Advertisement" : (track?.title ?? "") + AppState.shared.tasks[idx].name = title.isEmpty + ? (PillCatalog.definition(for: Self.pillId)?.name ?? "Spotify") + : title + } + + // MARK: - Reading Spotify + + /// Reads track, position, shuffle, repeat, volume and artwork URL. Never launches Spotify. + /// The first call is what makes macOS ask for Automation. + func refresh() { + guard isPillActive, isRunning else { return } + Task { + let result = await runAppleScript(""" + tell application id "com.spotify.client" + set ps to player state as string + if ps is "stopped" then return {ps} + try + set tr to current track + set tid to id of tr + on error + return {"stopped"} + end try + set n to "" + set ar to "" + set al to "" + set du to 0 + set au to "" + set pp to 0 + try + set n to name of tr + end try + try + set ar to artist of tr + end try + try + set al to album of tr + end try + try + set du to duration of tr + end try + try + set au to artwork url of tr + end try + try + set pp to player position + end try + return {ps, tid, n, ar, al, du, pp, shuffling, repeating, sound volume, au} + end tell + """) + guard case .success(let v) = result, let ps = v.first?.text else { return } + guard isPillActive else { return } + if ps == "stopped" || v.count < 11 { clearState(); return } + + let artworkURL = v[10].text.isEmpty ? nil : v[10].text + let newTrack = SpotifyTrack( + id: v[1].text, + title: Self.shortTitle(v[2].text), + artist: Self.shortArtist(v[3].text), + album: v[4].text, + duration: v[5].number / 1000, + artworkURL: artworkURL + ) + setTrack(newTrack) + anchor(v[6].number) + setPlaying(ps == "playing") + shuffling = v[7].flag + repeating = v[8].flag + if pendingVolume == nil { volume = Int(v[9].number.rounded()) } + if artwork == nil { loadArtwork(for: newTrack) } + } + } + + // MARK: - Artwork + + private func loadArtwork(for track: SpotifyTrack) { + if let cached = artworkCache[track.id] { artwork = cached; return } + guard artworkLoadingId != track.id else { return } + artworkLoadingId = track.id + let id = track.id + let direct = track.artworkURL + let page = track.webURL + Task { + defer { if artworkLoadingId == id { artworkLoadingId = nil } } + var imageURL = direct.flatMap(Self.spotifyImageURL) + if imageURL == nil, let page { + imageURL = await Self.oEmbedThumbnail(for: page) + } + guard let imageURL, + let (data, response) = try? await URLSession.shared.data(from: imageURL), + (response as? HTTPURLResponse)?.statusCode == 200, + let image = NSImage(data: data) else { return } + cacheArtwork(image, for: id) + if self.track?.id == id { artwork = image } + } + } + + private func cacheArtwork(_ image: NSImage, for id: String) { + artworkCache[id] = image + artworkCacheOrder.removeAll { $0 == id } + artworkCacheOrder.append(id) + while artworkCacheOrder.count > 12 { + artworkCache[artworkCacheOrder.removeFirst()] = nil + } + } + + /// Only Spotify's own image CDN: the URL comes from another app. + nonisolated private static func spotifyImageURL(_ string: String) -> URL? { + guard let url = safeWebURL(string), url.scheme == "https", + let host = url.host?.lowercased(), + host == "i.scdn.co" || host.hasSuffix(".scdn.co") || host.hasSuffix(".spotifycdn.com") + else { return nil } + return url + } + + /// Spotify's public oEmbed gives the cover without any account or Automation permission. + nonisolated private static func oEmbedThumbnail(for page: URL) async -> URL? { + var comps = URLComponents(string: "https://open.spotify.com/oembed")! + comps.queryItems = [URLQueryItem(name: "url", value: page.absoluteString)] + guard let url = comps.url, + let (data, response) = try? await URLSession.shared.data(from: url), + (response as? HTTPURLResponse)?.statusCode == 200, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let thumb = json["thumbnail_url"] as? String else { return nil } + return spotifyImageURL(thumb) + } + + // MARK: - Playback controls + + func playPause() { + guard isRunning else { return } + setPlaying(!isPlaying) // optimistic; Spotify's notification confirms + command("playpause") + } + + func nextTrack() { + guard isRunning else { return } + command("next track") + } + + func previousTrack() { + guard isRunning else { return } + command("previous track") + } + + func seek(to seconds: Double) { + guard isRunning, let track, !track.isAd else { return } + let target = min(max(0, seconds), max(0, track.duration - 1)) + anchor(target) + command("set player position to \(Int(target.rounded()))", refreshAfter: false) + } + + func setShuffling(_ on: Bool) { + guard isRunning else { return } + shuffling = on + command("set shuffling to \(on)", refreshAfter: false) + } + + func setRepeating(_ on: Bool) { + guard isRunning else { return } + repeating = on + command("set repeating to \(on)", refreshAfter: false) + } + + /// Coalesced: a slider drag sends at most one Apple Event every 120 ms. + func setVolume(_ value: Int) { + guard isRunning else { return } + let v = min(100, max(0, value)) + volume = v + pendingVolume = v + guard volumeTask == nil else { return } + volumeTask = Task { + while let v = pendingVolume { + pendingVolume = nil + await runAppleScript(#"tell application id "com.spotify.client" to set sound volume to \#(v)"#) + try? await Task.sleep(for: .milliseconds(120)) + } + volumeTask = nil + } + } + + private func command(_ verb: String, refreshAfter: Bool = true) { + Task { + let result = await runAppleScript(#"tell application id "com.spotify.client" to \#(verb)"#) + guard refreshAfter, case .success = result else { return } + // Let Spotify settle on the new track before reading it back + try? await Task.sleep(for: .milliseconds(350)) + refresh() + } + } + + func openSpotify() { + if let app = NSWorkspace.shared.runningApplications.first(where: { $0.bundleIdentifier == Self.bundleId }) { + app.activate() + } else if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: Self.bundleId) { + NSWorkspace.shared.openApplication(at: url, configuration: .init(), completionHandler: nil) + } + } + + func openDownloadPage() { + NSWorkspace.shared.open(URL(string: "https://www.spotify.com/download/")!) + } + + func openAutomationSettings() { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") { + NSWorkspace.shared.open(url) + } + } + + // MARK: - Metadata cleaners (same rules as Apple Music) + + private static func shortTitle(_ raw: String) -> String { + guard !raw.isEmpty else { return raw } + var s = raw + // Cut at first " - " ("Song - Remastered 2011") + if let r = s.range(of: " - ") { + s = String(s[.. String { + guard !raw.isEmpty else { return raw } + let lower = raw.lowercased() + for tag in [" feat.", " ft."] { + if let r = lower.range(of: tag) { + let result = String(raw[.. ScriptResult { + let grantedKey = Self.grantedKey + let result: ScriptResult = await withCheckedContinuation { cont in + queue.async { + guard let script = NSAppleScript(source: source) else { + cont.resume(returning: .error); return + } + var errDict: NSDictionary? + let desc = script.executeAndReturnError(&errDict) + if let errDict { + let code = (errDict[NSAppleScript.errorNumber] as? Int) ?? 0 + cont.resume(returning: code == -1743 ? .denied : .error) + return + } + // Extract values on this queue (NSAppleEventDescriptor isn't Sendable) + func value(_ d: NSAppleEventDescriptor?) -> ScriptValue { + ScriptValue(text: d?.stringValue ?? "", number: d?.doubleValue ?? 0, flag: d?.booleanValue ?? false) + } + let count = desc.numberOfItems + let values = count > 0 ? (1...count).map { value(desc.atIndex($0)) } : [value(desc)] + cont.resume(returning: .success(values)) + } + } + switch result { + case .success: + UserDefaults.standard.set(true, forKey: grantedKey) + automationDenied = false + case .denied: + UserDefaults.standard.set(false, forKey: grantedKey) + automationDenied = true + case .error: + break + } + return result + } +} +#endif diff --git a/NotchBuddy/Sources/App/SpotifyViews.swift b/NotchBuddy/Sources/App/SpotifyViews.swift new file mode 100644 index 0000000000..4e09f76318 --- /dev/null +++ b/NotchBuddy/Sources/App/SpotifyViews.swift @@ -0,0 +1,255 @@ +#if !APPSTORE +import SwiftUI +import AppKit + +// MARK: - Spotify Pill (overview right card, GitHub build only) + +struct SpotifyPill: View { + let task: AgentTask + @Binding var swapping: Bool + let onTap: () -> Void + @ObservedObject private var controller = SpotifyController.shared + @State private var isHovered = false + + private var showControls: Bool { isHovered && controller.track != nil } + + var body: some View { + ZStack { + // Selection target — full pill area, receives taps where controls don't + Capsule() + .fill(Color.clear) + .contentShape(Capsule()) + .onTapGesture { onTap() } + + // Visual fills + Capsule() + .fill(isHovered ? Color(hex: task.color).opacity(0.18) : Color(hex: "#0E0F11")) + .allowsHitTesting(false) + Capsule() + .stroke(Color(hex: task.color).opacity(isHovered ? 0.55 : 0.14), lineWidth: 1) + .allowsHitTesting(false) + + // Mini Mochi at leading edge + HStack(spacing: 0) { + MiniBotCanvasView(task: task, isDancing: controller.isPlaying) + .frame(width: 22 / 0.6, height: 22 / 0.6) + .frame(width: 22, height: 22, alignment: .center) + .padding(.leading, 8) + Spacer() + } + .allowsHitTesting(false) + + // Title — trailing padding grows on hover to make room for buttons + Text(task.name) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(isHovered ? Color(hex: task.color).lighter(by: 0.3) : Color(hex: "#6B7079")) + .lineLimit(1) + .truncationMode(.tail) + .padding(.leading, 34) + .padding(.trailing, showControls ? 52 : 10) + .frame(maxWidth: .infinity, alignment: .center) + .animation(.spring(response: 0.2, dampingFraction: 0.7), value: showControls) + .allowsHitTesting(false) + + // Playback controls — appear on hover when a track is loaded + if showControls { + HStack(spacing: 0) { + Spacer() + HStack(spacing: 2) { + MusicControlButton(icon: controller.isPlaying ? "pause.fill" : "play.fill", color: task.color) { + controller.playPause() + } + MusicControlButton(icon: "forward.fill", color: task.color) { + controller.nextTrack() + } + } + .padding(.trailing, 4) + } + .transition(.opacity.combined(with: .scale(scale: 0.85, anchor: .trailing))) + } + } + .frame(maxWidth: .infinity) + .frame(height: 28) + .shadow(color: Color(hex: task.color).opacity(isHovered ? 0.35 : 0), radius: 10, x: 0, y: 2) + .scaleEffect(isHovered ? 1.04 : 1.0) + .brightness(isHovered ? 0.06 : 0) + .onHover { newHover in + guard !swapping else { return } + withAnimation(.spring(response: 0.2, dampingFraction: 0.7)) { isHovered = newHover } + } + } +} + +// MARK: - Spotify Card (overview left card, GitHub build only) + +struct SpotifyCardView: View { + @ObservedObject private var controller = SpotifyController.shared + + private let green = Color(hex: SpotifyController.green) + + var body: some View { + Group { + if controller.automationDenied { + deniedView + } else if let track = controller.track { + nowPlaying(track) + } else { + idleView + } + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.top, 4) + // Spotify doesn't announce seeks made in its own window: re-read when the card shows + .onAppear { controller.refresh() } + } + + // MARK: Now playing + + // The card is 98 pt tall: artwork row, progress row, controls row. + private func nowPlaying(_ track: SpotifyTrack) -> some View { + let subtitle = [track.artist, track.album].filter { !$0.isEmpty }.joined(separator: " · ") + return VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 9) { + NowPlayingArtwork(image: controller.artwork, accent: green) + .frame(width: 36, height: 36) + .onTapGesture { controller.openSpotify() } + .help(track.album.isEmpty ? "Open Spotify" : "\(track.album) — open Spotify") + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 5) { + Circle().fill(green).frame(width: 6, height: 6) + Text(track.isAd ? "Advertisement" : track.title) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + .lineLimit(1).truncationMode(.tail) + } + if !subtitle.isEmpty { + Text(subtitle) + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + .lineLimit(1).truncationMode(.tail) + } + } + Spacer(minLength: 0) + } + .padding(.trailing, 24) // the ↗ button sits top-right + .padding(.top, 6) + + // Position runs on its own clock only while playing and on screen + TimelineView(.animation(minimumInterval: 0.5, paused: !controller.isPlaying)) { context in + NowPlayingProgress( + position: controller.position(at: context.date), + duration: track.duration, + accent: green, + canSeek: !track.isAd && track.duration > 0, + onSeek: { controller.seek(to: $0) } + ) + } + .padding(.top, 7) + + controls + .padding(.top, 4) + } + .padding(.leading, 108) + .padding(.trailing, 12) + } + + private var controls: some View { + HStack(spacing: 0) { + HStack(spacing: 10) { + NowPlayingIconButton(icon: "shuffle", size: 10, + tint: controller.shuffling ? green : Color(hex: "#6B7079"), + help: controller.shuffling ? "Shuffle on" : "Shuffle off") { + controller.setShuffling(!controller.shuffling) + } + NowPlayingIconButton(icon: "backward.fill", size: 11, tint: Color(hex: "#C5C8CD"), help: "Previous") { + controller.previousTrack() + } + Button(action: { controller.playPause() }) { + ZStack { + Circle().fill(Color(hex: "#F5F6F8")) + Image(systemName: controller.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 8.5, weight: .bold)) + .foregroundColor(Color(hex: "#0E0F11")) + .offset(x: controller.isPlaying ? 0 : 1) + } + .frame(width: 20, height: 20) + } + .buttonStyle(NowPlayingPressStyle()) + .help(controller.isPlaying ? "Pause" : "Play") + NowPlayingIconButton(icon: "forward.fill", size: 11, tint: Color(hex: "#C5C8CD"), help: "Next") { + controller.nextTrack() + } + NowPlayingIconButton(icon: "repeat", size: 10, + tint: controller.repeating ? green : Color(hex: "#6B7079"), + help: controller.repeating ? "Repeat on" : "Repeat off") { + controller.setRepeating(!controller.repeating) + } + } + Spacer(minLength: 8) + NowPlayingVolume(volume: controller.volume, accent: green) { controller.setVolume($0) } + } + } + + // MARK: Idle / not installed / denied (same layout as the other idle cards) + + private var idleView: some View { + let installed = controller.isInstalled + return VStack(alignment: .leading, spacing: 6) { + header(dot: green) + HStack(spacing: 5) { + Circle() + .fill(installed ? Color(hex: "#22C55E") : Color(hex: "#F4505E")) + .frame(width: 5, height: 5) + Text(installed ? "Not playing" : "Spotify not installed") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#6B7079")) + } + .padding(.leading, 108) + .padding(.top, 2) + + Button(installed ? "Open Spotify" : "Get Spotify") { + installed ? controller.openSpotify() : controller.openDownloadPage() + } + .font(.system(size: 11, weight: .medium)) + .foregroundColor(green.opacity(0.85)) + .buttonStyle(.plain) + .padding(.leading, 108) + .padding(.top, 2) + } + } + + private var deniedView: some View { + VStack(alignment: .leading, spacing: 4) { + header(dot: Color(hex: "#F4505E")) + Text("Allow Coucou to control Spotify") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + .padding(.leading, 108) + .padding(.trailing, 12) + Button("Open Settings…") { controller.openAutomationSettings() } + .font(.system(size: 11, weight: .medium)) + .foregroundColor(green.opacity(0.85)) + .buttonStyle(.plain) + .padding(.leading, 108) + .padding(.top, 2) + } + } + + private func header(dot: Color) -> some View { + HStack(spacing: 6) { + Circle().fill(dot).frame(width: 7, height: 7) + Text("Spotify") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(Color(hex: "#F5F6F8")) + Text(PillCatalog.definition(for: SpotifyController.pillId)?.subtitle ?? "Integration") + .font(.system(size: 11)) + .foregroundColor(Color(hex: "#8E939C")) + Spacer(minLength: 2) + } + .padding(.top, 6) + .padding(.leading, 108) + .padding(.trailing, 36) + } +} +#endif diff --git a/NotchBuddy/Sources/CoucouKit/PillCatalog.swift b/NotchBuddy/Sources/CoucouKit/PillCatalog.swift index 530b5ecfa7..41c0e78992 100644 --- a/NotchBuddy/Sources/CoucouKit/PillCatalog.swift +++ b/NotchBuddy/Sources/CoucouKit/PillCatalog.swift @@ -87,6 +87,8 @@ enum PillCatalog { category: .service, subtitle: "Integration", source: .n8n), .init(id: "integration_music", name: "Apple Music", color: "#FA2D48", category: .service, subtitle: "Integration", source: .n8n, githubOnly: true), + .init(id: "integration_spotify", name: "Spotify", color: "#1DB954", + category: .service, subtitle: "Integration", source: .n8n, githubOnly: true), ] /// Pills available in the current build target. diff --git a/NotchBuddy/project.yml b/NotchBuddy/project.yml index 9be7314eaa..81aa7a4315 100644 --- a/NotchBuddy/project.yml +++ b/NotchBuddy/project.yml @@ -131,7 +131,7 @@ targets: LSUIElement: YES NSHighResolutionCapable: YES NSSupportsAutomaticGraphicsSwitching: YES - NSAppleEventsUsageDescription: "Pour sauter au terminal, envoyer des mails et piloter Musique." + NSAppleEventsUsageDescription: "Pour sauter au terminal, envoyer des mails et piloter Musique et Spotify." NSAccessibilityUsageDescription: "Pour lire le titre de la fenêtre active et l'attacher comme contexte." NSAppTransportSecurity: NSAllowsLocalNetworking: true diff --git a/README.md b/README.md index abf6f8cd63..7a92489255 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,8 @@ Meet **Mochi**: a soft little squircle with big eyes that pops out of your notch - 🖥️ **Mochi on the desktop** — drag Mochi out of the island to set him loose on your desktop: he floats as a 120 pt companion, follows your cursor, wears his outfit, reacts to alerts by flying home and flying back, and comes back where you left him on next launch. - 🪟 **Drag Mochi onto any window** — attach that window as context for Claude *(macOS)*. - 🔌 **Integrations** — Stripe payments, n8n workflows, GitHub (open PRs, reviews requested, CI status), Vercel deployments, Resend emails, Notion, Cal.com. Each one gets its own little colored Mochi. -- 🎵 **Apple Music pill** *(macOS, GitHub build)* — add the Apple Music pill in Settings → Active pills to see what's playing and control playback from the notch; Mochi dances while it plays. +- 🎵 **Apple Music pill** *(macOS, GitHub build)* — add the Apple Music pill in Settings → Active pills: album cover, title and artist, a progress bar you drag to seek, play/pause, previous/next, shuffle, repeat, volume and favorite, right from the notch; Mochi dances while it plays. +- 🎧 **Spotify pill** *(macOS, GitHub build)* — add the Spotify pill in Settings → Active pills: album cover, title and artist, a progress bar you drag to seek, play/pause, previous/next, shuffle, repeat and volume, right from the notch; Mochi dances while it plays. - 👗 **Dress Mochi up** — right-click him for the wardrobe. He also dresses up for the seasons on his own. - ⌨️ **Keyboard shortcuts** — open the chat, jump to an alert or a terminal, switch pills, mute, send Mochi to the desktop or open the wardrobe from anywhere; all customizable in Settings → Shortcuts. - 🎭 **A real character** — idle breathing, blinks, eyes on a sphere that follow your mouse, emotes, 28 handcrafted sounds, a greeting on launch. diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index 138e018319..5aec7e2dfb 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -328,6 +328,7 @@ Réglages → Chat → Local models → **Disconnect**. Efface l'URL sauvegardé | Automatisation → Mail | envoyer les mails | premier envoi | | Automatisation → Terminal / iTerm / navigateur | sauter au bon onglet, lire l'URL | première utilisation | | Automatisation → Musique *(GitHub only)* | contrôler la lecture Apple Music | première commande depuis le notch | +| Automatisation → Spotify *(GitHub only)* | lire la position, le shuffle, le volume ; piloter la lecture | activation de la pill Spotify, ou première ouverture de sa carte | | Enregistrement de l'écran | capturer la fenêtre attrapée | première attache | | Micro + Reconnaissance vocale (optionnel) | dictée | premier clic sur le micro | diff --git a/docs/SPEC.md b/docs/SPEC.md index e2a511a118..75939ae2a7 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -103,7 +103,7 @@ Toutes les pastilles déclarées sont définies dans `PillCatalog.all` (source d | `workspace` | Where you code | VS Code, Cursor, Antigravity *(GitHub only)*, Codex *(GitHub only)* | Integration | Claude Code / Cursor / Codex / Agent | | `agent` | Agents | Gemini CLI *(GitHub only)* | Agent | Agent | | `ai` | AI for the chat | Anthropic, Google AI, OpenAI, Ollama, LM Studio | Chat | — | -| `service` | Services | Resend, n8n, Vercel, GitHub, Notion, Cal.com, Stripe, Apple Music *(GitHub only)* | Integration | — | +| `service` | Services | Resend, n8n, Vercel, GitHub, Notion, Cal.com, Stripe, Apple Music *(GitHub only)*, Spotify *(GitHub only)* | Integration | — | Couleurs : Cursor `#C0C4CC`, Codex `#2DD4BF`, Gemini CLI `#8AB4F8`, Antigravity `#E879F9`, pastilles IA = `ChatProvider.accentHex` (Ollama `#FACC15`, LM Studio `#A3E635`). diff --git a/docs/privacy.html b/docs/privacy.html index 6c5a4769d8..6a89246a53 100644 --- a/docs/privacy.html +++ b/docs/privacy.html @@ -39,14 +39,15 @@

4. Services you connect yourself

5. Claude Code, Gemini CLI, Antigravity and Codex hooks

If you install the Claude Code hooks, Claude Code sends session events to Coucou through a local connection on your computer. If you install the Gemini CLI, Antigravity or Codex hooks, those agents send their session events the same way. These events never leave your computer through Coucou. If you install the Plan usage status line, Claude Code also passes the percentage used and the reset times of your plan windows to Coucou through the same local connection; nothing leaves your computer. Before changing any settings file (~/.claude/settings.json, ~/.gemini/settings.json, ~/.gemini/config/hooks.json, ~/.codex/hooks.json), Coucou makes a dated backup and shows you the changes. File edits (the old and new text received from Edit, MultiEdit and Write hooks) are kept in memory for the duration of the session and deleted when the session ends or after one hour of inactivity; they never leave your computer.

-

6. Apple Music

-

If you activate the Apple Music pill (macOS, GitHub build), Coucou reads the current track (title, artist, album) from Music on your Mac to display it in the notch, and only sends play, pause or skip commands to Music when you click. Nothing leaves your computer.

+

6. Apple Music and Spotify

+

If you activate the Apple Music pill (macOS, GitHub build), Coucou reads the current track (title, artist, album) from Music on your Mac to display it in the notch, and only sends play, pause, skip, seek, shuffle, repeat, volume or favorite commands to Music when you click. The album cover comes from Music itself; when Music has none for the track, Coucou looks it up in Apple's public iTunes Search API (itunes.apple.com/search) with the artist and album names. No account, key or other data is sent.

+

If you activate the Spotify pill (macOS, GitHub build), Coucou reads the current track (title, artist, album, position, shuffle, repeat and volume) from the Spotify app on your Mac, and only sends play, pause, skip, seek, shuffle, repeat or volume commands when you click. To show the album cover, Coucou downloads it from Spotify's image server and, when the Spotify app doesn't give its address, asks Spotify's public open.spotify.com/oembed for it with the track's link. No account, key or other data is sent. Spotify privacy policy.

7. Email

When you send a file by email, the message is created and sent by your own Mail app. Coucou never sends email by itself.

8. System permissions

-

Depending on the version and the features you use, macOS may ask you to allow Automation (Mail, Terminal, Music), Accessibility (to read the title of the window you attach) or Screen Recording (to capture a window you attach). These are only used for the feature you triggered and can be revoked at any time in System Settings → Privacy & Security. The Windows and Linux versions ask for no special permission. On Linux, installing the .deb or .rpm package needs administrator rights; the AppImage doesn't.

+

Depending on the version and the features you use, macOS may ask you to allow Automation (Mail, Terminal, Music, Spotify), Accessibility (to read the title of the window you attach) or Screen Recording (to capture a window you attach). These are only used for the feature you triggered and can be revoked at any time in System Settings → Privacy & Security. The Windows and Linux versions ask for no special permission. On Linux, installing the .deb or .rpm package needs administrator rights; the AppImage doesn't.

9. iPhone app

If you turn on Settings → General → iPhone on your Mac (off by default), Coucou writes your agent sessions to your own private iCloud database, which only your devices signed in to your Apple Account can read. For each agent session it also keeps its last turn only: the prompt you sent, the commands it ran and what they printed (trimmed), the file changes it made, and its answer. It also writes what your connected services show in the notch (GitHub pull requests and checks, Stripe payments and balance, Vercel deployments, Resend emails, Cal.com bookings, n8n runs, Notion pages), from data your Mac already fetched: no extra request, and no API key ever leaves your Mac. Project names, steps, folders, the last message, pending commands, questions, the last turn and all that service data are encrypted with your iCloud keys. When you allow or deny a command from the iPhone (Face ID is required to allow), the decision goes back to your Mac through the same database, and the iPhone keeps a history of your decisions on the iPhone only. Turning the setting off deletes the sessions from iCloud. We have no access to your iCloud data.

@@ -100,14 +101,15 @@

4. Services que tu connectes toi-même

5. Claude Code, Gemini CLI, Antigravity et Codex

Si tu installes les hooks Claude Code, Claude Code envoie les événements de session à Coucou par une connexion locale sur ton ordinateur. Si tu installes les hooks Gemini CLI, Antigravity ou Codex, ces agents envoient leurs événements de la même façon. Coucou ne les fait jamais sortir de ton ordinateur. Si tu installes le relais de jauge de forfait (Plan usage), Claude Code transmet aussi à Coucou, par la même connexion locale, les pourcentages utilisés et les heures de remise à zéro de tes fenêtres de forfait ; rien ne quitte ton ordinateur. Avant de modifier un fichier de configuration (~/.claude/settings.json, ~/.gemini/settings.json, ~/.gemini/config/hooks.json, ~/.codex/hooks.json), Coucou en fait une sauvegarde datée et te montre les changements. Les modifications de fichiers (l'ancien et le nouveau texte reçus via les hooks Edit, MultiEdit et Write) sont conservées en mémoire pendant la durée de la session et supprimées à la fin de la session ou après une heure d'inactivité ; elles ne quittent jamais ton ordinateur.

-

6. Apple Music

-

Si tu actives la pastille Apple Music (macOS, version GitHub), Coucou lit le morceau en cours (titre, artiste, album) depuis l’app Musique de ton Mac pour l’afficher dans le notch, et n’envoie lecture, pause ou morceau suivant à Musique que quand tu cliques. Rien ne quitte ton ordinateur.

+

6. Apple Music et Spotify

+

Si tu actives la pastille Apple Music (macOS, version GitHub), Coucou lit le morceau en cours (titre, artiste, album) depuis l’app Musique de ton Mac pour l’afficher dans le notch, et n’envoie lecture, pause, morceau suivant, avance, lecture aléatoire, répétition, volume ou favori à Musique que quand tu cliques. La pochette vient de Musique ; quand Musique n’en a pas pour le morceau, Coucou la cherche dans l’API publique iTunes Search d’Apple (itunes.apple.com/search) avec les noms de l’artiste et de l’album. Aucun compte, aucune clé ni autre donnée n’est envoyé.

+

Si tu actives la pastille Spotify (macOS, version GitHub), Coucou lit le morceau en cours (titre, artiste, album, position, lecture aléatoire, répétition et volume) depuis l’app Spotify de ton Mac, et n’envoie lecture, pause, morceau suivant, avance, lecture aléatoire, répétition ou volume que quand tu cliques. Pour afficher la pochette, Coucou la télécharge depuis le serveur d’images de Spotify et, quand l’app Spotify n’en donne pas l’adresse, la demande à l’oEmbed public de Spotify (open.spotify.com/oembed) avec le lien du morceau. Aucun compte, aucune clé ni autre donnée n’est envoyé. Politique de confidentialité de Spotify.

7. E-mail

Quand tu envoies un fichier par e-mail, le message est créé et envoyé par ta propre app Mail. Coucou n’envoie jamais d’e-mail de lui-même.

8. Autorisations système

-

Selon la version et les fonctions utilisées, macOS peut te demander d’autoriser l’Automatisation (Mail, Terminal), l’Accessibilité (lire le titre de la fenêtre que tu attaches) ou l’Enregistrement de l’écran (capturer une fenêtre que tu attaches). Elles ne servent qu’à la fonction que tu as déclenchée et se retirent à tout moment dans Réglages Système → Confidentialité et sécurité. Les versions Windows et Linux ne demandent aucune autorisation particulière. Sous Linux, l’installation du paquet .deb ou .rpm nécessite des droits administrateur ; l’AppImage non.

+

Selon la version et les fonctions utilisées, macOS peut te demander d’autoriser l’Automatisation (Mail, Terminal, Musique, Spotify), l’Accessibilité (lire le titre de la fenêtre que tu attaches) ou l’Enregistrement de l’écran (capturer une fenêtre que tu attaches). Elles ne servent qu’à la fonction que tu as déclenchée et se retirent à tout moment dans Réglages Système → Confidentialité et sécurité. Les versions Windows et Linux ne demandent aucune autorisation particulière. Sous Linux, l’installation du paquet .deb ou .rpm nécessite des droits administrateur ; l’AppImage non.

9. App iPhone

Si tu actives Réglages → Général → iPhone sur ton Mac (désactivé par défaut), Coucou écrit tes sessions d’agents dans ta propre base iCloud privée, que seuls tes appareils connectés à ton compte Apple peuvent lire. Pour chaque session d’agent, il garde aussi uniquement son dernier tour : le prompt que tu as envoyé, les commandes lancées et ce qu’elles ont affiché (raccourci), les modifications de fichiers et sa réponse. Il y écrit aussi ce que tes services connectés affichent dans l’encoche (pull requests et checks GitHub, paiements et solde Stripe, déploiements Vercel, e-mails Resend, rendez-vous Cal.com, exécutions n8n, pages Notion), à partir des données que ton Mac a déjà récupérées : aucune requête en plus, et aucune clé d’API ne quitte ton Mac. Les noms de projet, étapes, dossiers, dernier message, commandes en attente, questions, le dernier tour et toutes ces données de services sont chiffrés avec tes clés iCloud. Quand tu autorises ou refuses une commande depuis l’iPhone (Face ID est demandé pour autoriser), la décision revient à ton Mac par la même base, et l’iPhone garde l’historique de tes décisions sur l’iPhone uniquement. Désactiver le réglage supprime les sessions d’iCloud. Le développeur n’a aucun accès à tes données iCloud.

From b23f04693a5adfdff0c170813cb0016030a7af43 Mon Sep 17 00:00:00 2001 From: Jhoan Gallego Date: Tue, 6 Oct 2026 20:18:02 +0200 Subject: [PATCH 2/2] Spotify and Apple Music cards: translate them like the rest of the app Ternaries and String parameters were shown verbatim, so the help texts, the idle and denied states and the Settings hint now go through String(localized:) or LocalizedStringKey, with their 19 strings in the catalog for the 10 languages. Co-Authored-By: Claude Opus 5.5 --- NotchBuddy/Resources/Localizable.xcstrings | 1216 +++++++++++++++++ .../Sources/App/IslandViewContent.swift | 2 +- NotchBuddy/Sources/App/NowPlayingViews.swift | 4 +- NotchBuddy/Sources/App/SpotifyViews.swift | 10 +- 4 files changed, 1224 insertions(+), 8 deletions(-) diff --git a/NotchBuddy/Resources/Localizable.xcstrings b/NotchBuddy/Resources/Localizable.xcstrings index aab463e431..2d9cbd4981 100644 --- a/NotchBuddy/Resources/Localizable.xcstrings +++ b/NotchBuddy/Resources/Localizable.xcstrings @@ -18873,6 +18873,1222 @@ } } } + }, + "Not installed": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "غير مثبت" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "ইনস্টল নেই" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Not installed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No instalado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Non installé" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "इंस्टॉल नहीं" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Belum terpasang" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não instalado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не установлено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未安装" + } + } + } + }, + "Play": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تشغيل" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "চালান" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Play" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Reproducir" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Lecture" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "चलाएँ" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Putar" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reproduzir" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Воспроизвести" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "播放" + } + } + } + }, + "Pause": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إيقاف مؤقت" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "বিরতি" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Pause" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Pausa" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Pause" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "रोकें" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Jeda" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Pausar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Пауза" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "暂停" + } + } + } + }, + "Previous": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "السابق" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "আগেরটি" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Previous" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Anterior" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Précédent" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "पिछला" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Sebelumnya" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Anterior" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Предыдущий" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "上一首" + } + } + } + }, + "Shuffle on": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الترتيب العشوائي مفعّل" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "শাফল চালু" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Shuffle on" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aleatorio activado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aléatoire activé" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "शफ़ल चालू" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Acak aktif" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aleatório ativado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перемешивание вкл." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "随机播放:开" + } + } + } + }, + "Shuffle off": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "الترتيب العشوائي متوقف" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "শাফল বন্ধ" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Shuffle off" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Aleatorio desactivado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aléatoire désactivé" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "शफ़ल बंद" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Acak nonaktif" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Aleatório desativado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перемешивание выкл." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "随机播放:关" + } + } + } + }, + "Repeat on": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التكرار مفعّل" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "রিপিট চালু" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Repeat on" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Repetir activado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Répétition activée" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "दोहराना चालू" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Ulangi aktif" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Repetir ativado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Повтор вкл." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重复:开" + } + } + } + }, + "Repeat off": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التكرار متوقف" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "রিপিট বন্ধ" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Repeat off" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Repetir desactivado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Répétition désactivée" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "दोहराना बंद" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Ulangi nonaktif" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Repetir desativado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Повтор выкл." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重复:关" + } + } + } + }, + "Repeat all": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تكرار الكل" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "সব রিপিট" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Repeat all" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Repetir todo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tout répéter" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "सभी दोहराएँ" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Ulangi semua" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Repetir tudo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Повторять все" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全部重复" + } + } + } + }, + "Repeat one": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تكرار مقطع واحد" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "একটি রিপিট" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Repeat one" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Repetir una" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Répéter le morceau" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "एक दोहराएँ" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Ulangi satu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Repetir uma" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Повторять один" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "单曲重复" + } + } + } + }, + "Favorite": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إضافة إلى المفضلة" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "প্রিয়তে যোগ করুন" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Favorite" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Añadir a favoritos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter aux favoris" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "पसंदीदा में जोड़ें" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Tambahkan ke favorit" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Favoritar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "В избранное" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "收藏" + } + } + } + }, + "Remove from favorites": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة من المفضلة" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "প্রিয় থেকে সরান" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove from favorites" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Quitar de favoritos" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Retirer des favoris" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "पसंदीदा से हटाएँ" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Hapus dari favorit" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Remover dos favoritos" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Убрать из избранного" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消收藏" + } + } + } + }, + "Volume %lld%%": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مستوى الصوت %lld%%" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "ভলিউম %lld%%" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Volume %lld%%" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Volumen %lld%%" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Volume %lld %%" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "वॉल्यूम %lld%%" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Volume %lld%%" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Volume %lld%%" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Громкость %lld%%" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "音量 %lld%%" + } + } + } + }, + "Open Spotify": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح Spotify" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "Spotify খুলুন" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Spotify" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir Spotify" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir Spotify" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "Spotify खोलें" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Buka Spotify" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abrir o Spotify" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Открыть Spotify" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开 Spotify" + } + } + } + }, + "%@ — open Spotify": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ — فتح Spotify" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "%@ — Spotify খুলুন" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ — open Spotify" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ — abrir Spotify" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ — ouvrir Spotify" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "%@ — Spotify खोलें" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "%@ — buka Spotify" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%@ — abrir o Spotify" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ — открыть Spotify" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ — 打开 Spotify" + } + } + } + }, + "Get Spotify": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "احصل على Spotify" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "Spotify নিন" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Get Spotify" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Descargar Spotify" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Obtenir Spotify" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "Spotify पाएँ" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Dapatkan Spotify" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Baixar o Spotify" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скачать Spotify" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "获取 Spotify" + } + } + } + }, + "Spotify not installed": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Spotify غير مثبت" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "Spotify ইনস্টল নেই" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Spotify not installed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Spotify no está instalado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Spotify n'est pas installé" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "Spotify इंस्टॉल नहीं है" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Spotify belum terpasang" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Spotify não está instalado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Spotify не установлен" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未安装 Spotify" + } + } + } + }, + "Allow Coucou to control Spotify": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "السماح لـ Coucou بالتحكم في Spotify" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "Coucou-কে Spotify নিয়ন্ত্রণ করতে দিন" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Allow Coucou to control Spotify" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Permitir que Coucou controle Spotify" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Autoriser Coucou à contrôler Spotify" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "Coucou को Spotify नियंत्रित करने दें" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Izinkan Coucou mengontrol Spotify" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Permitir que o Coucou controle o Spotify" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Разрешить Coucou управлять Spotify" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "允许 Coucou 控制 Spotify" + } + } + } + }, + "Advertisement": { + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعلان" + } + }, + "bn": { + "stringUnit": { + "state": "translated", + "value": "বিজ্ঞাপন" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Advertisement" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Anuncio" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Publicité" + } + }, + "hi": { + "stringUnit": { + "state": "translated", + "value": "विज्ञापन" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Iklan" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Anúncio" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Реклама" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "广告" + } + } + } } }, "version": "1.0" diff --git a/NotchBuddy/Sources/App/IslandViewContent.swift b/NotchBuddy/Sources/App/IslandViewContent.swift index 0050f8d551..65ad163b9e 100644 --- a/NotchBuddy/Sources/App/IslandViewContent.swift +++ b/NotchBuddy/Sources/App/IslandViewContent.swift @@ -4048,7 +4048,7 @@ struct MusicCardView: View { .frame(width: 20, height: 20) } .buttonStyle(NowPlayingPressStyle()) - .help(appState.musicPlaying ? "Pause" : "Play") + .help(appState.musicPlaying ? String(localized: "Pause") : String(localized: "Play")) NowPlayingIconButton(icon: "forward.fill", size: 11, tint: Color(hex: "#C5C8CD"), help: "Next") { controller.nextTrack() } diff --git a/NotchBuddy/Sources/App/NowPlayingViews.swift b/NotchBuddy/Sources/App/NowPlayingViews.swift index 743d4ee7a8..048d2b219e 100644 --- a/NotchBuddy/Sources/App/NowPlayingViews.swift +++ b/NotchBuddy/Sources/App/NowPlayingViews.swift @@ -99,7 +99,7 @@ struct NowPlayingVolume: View { onCommit: { onChange(Int(($0 * 100).rounded())) }) .frame(width: 44, height: 10) } - .help("Volume \(volume)%") + .help(String(localized: "Volume \(volume)%")) } } @@ -158,7 +158,7 @@ struct NowPlayingIconButton: View { let icon: String let size: CGFloat let tint: Color - let help: String + let help: LocalizedStringKey let action: () -> Void @State private var hovered = false diff --git a/NotchBuddy/Sources/App/SpotifyViews.swift b/NotchBuddy/Sources/App/SpotifyViews.swift index 4e09f76318..5674b959e4 100644 --- a/NotchBuddy/Sources/App/SpotifyViews.swift +++ b/NotchBuddy/Sources/App/SpotifyViews.swift @@ -113,12 +113,12 @@ struct SpotifyCardView: View { NowPlayingArtwork(image: controller.artwork, accent: green) .frame(width: 36, height: 36) .onTapGesture { controller.openSpotify() } - .help(track.album.isEmpty ? "Open Spotify" : "\(track.album) — open Spotify") + .help(track.album.isEmpty ? String(localized: "Open Spotify") : String(localized: "\(track.album) — open Spotify")) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 5) { Circle().fill(green).frame(width: 6, height: 6) - Text(track.isAd ? "Advertisement" : track.title) + Text(track.isAd ? String(localized: "Advertisement") : track.title) .font(.system(size: 12, weight: .semibold)) .foregroundColor(Color(hex: "#F5F6F8")) .lineLimit(1).truncationMode(.tail) @@ -176,7 +176,7 @@ struct SpotifyCardView: View { .frame(width: 20, height: 20) } .buttonStyle(NowPlayingPressStyle()) - .help(controller.isPlaying ? "Pause" : "Play") + .help(controller.isPlaying ? String(localized: "Pause") : String(localized: "Play")) NowPlayingIconButton(icon: "forward.fill", size: 11, tint: Color(hex: "#C5C8CD"), help: "Next") { controller.nextTrack() } @@ -201,14 +201,14 @@ struct SpotifyCardView: View { Circle() .fill(installed ? Color(hex: "#22C55E") : Color(hex: "#F4505E")) .frame(width: 5, height: 5) - Text(installed ? "Not playing" : "Spotify not installed") + Text(installed ? String(localized: "Not playing") : String(localized: "Spotify not installed")) .font(.system(size: 11)) .foregroundColor(Color(hex: "#6B7079")) } .padding(.leading, 108) .padding(.top, 2) - Button(installed ? "Open Spotify" : "Get Spotify") { + Button(installed ? String(localized: "Open Spotify") : String(localized: "Get Spotify")) { installed ? controller.openSpotify() : controller.openDownloadPage() } .font(.system(size: 11, weight: .medium))