TheFrenchNotch : Animer sa notch

· 8 minutes de lecture
TheFrenchNotch : Animer sa notch

On entend souvent parler de french touch, de la french tech, mais jamais de TheFrenchNotch ?!

Vu que le nom était disponible, je me suis dit pourquoi pas...

Aujourd'hui je vous propose, dans les grandes lignes, de vous expliquer comment j'ai réussi à créer une application macOS en SwiftUI avec les connaissances mises en avant dans mes tutos précédents.

Mon inspiration : TheBoringNotch ✨

Avant de rentrer dans le vif du sujet, laissez-moi vous présenter mon inspiration :

TheBoringNotch ✨
Boring.Notch transforms your MacBook’s notch into a dynamic music control center, complete with a visualizer and music controls, making it the star of your screen & Yeah its Open Source

TheBoringNotch transforme la notch du MacBook en un centre de contrôle musical dynamique, avec un visualiseur et des contrôles musicaux. Et c'est... Open Source !

TheBoringNotch, c'est une application que j'apprécie fortement car elle propose de rendre utile la notch de mon Mac, chose qu'Apple n'a pas su faire.

On peut voir d'un coup de souris vers notre notch des infos sur la musique en cours, notre calendrier, même pouvoir drag and drop des documents via AirDrop, plutôt pratique et bien d'autres choses...

Le drame de la MàJ 15.4

Sauf que là, le drame total ! Alors qu'Apple nous présente son LiquidGlass, une mise à jour 15.4 vient d'arriver sur macOS et là, un bug est ouvert sur la page officielle du GitHub. S'ensuivent plusieurs personnes qui constatent, à leur tour, le problème :

La musique ne s'affiche plus sur TheBoringNotch !

[Bug] Now Playing no longer working after updating to MacOS 15.4 · Issue #417 · TheBoredTeam/boring.notch
What happened? What are the steps to reproduce the issue? Now playing and Dynamic island not working for music (Version 2.6) Music which is currently playing in spotify not shown above. Whereas the…

La cause ?

Il s'agit de MediaController qui n'est plus disponible, le symlink serait cassé, causant ce souci. Mais suite à la mise à jour 15.5, notre peur s'est confirmée : ce n'était pas cassé par hasard. C'est Apple qui, pour des raisons inconnues, a décidé de ne plus rendre disponible cette fonctionnalité.

De ce fait, TheBoringNotch devient un peu inutile pour ce dont je voulais en faire (voir la musique jouée).

Mais, comme bon développeur, je me suis dit que ça pourrait être intéressant de développer mon fork du projet avec mon style à moi. Voici donc TheFrenchNotch.

L'objectif simplifié 🎯

Sauf que là, on va simplifier drastiquement la difficulté de l'application.

Ce qu'on veut : Une app qui a une animation sympa au niveau de la notch pour voir quelle musique on écoute. Rien de plus.

1. Récupérer le son joué 🎵

Vu que le MediaController d'Apple n'est plus disponible, nous devons forcément passer par une solution de contournement.

Étant donné que je suis un fidèle utilisateur de YouTube Music, j'utilise uniquement mon navigateur Arc, n'étant rien d'autre qu'un fork de Google Chrome Dev. Et devinez quoi ? Les extensions Chrome sont sûrement la chose la plus simple à mettre en place.

Création de l'extension Chrome

On va donc créer une extension qui va dynamiquement récupérer les informations sur la musique.

Pour ça, j'ai créé un objet en JavaScript qui me permet de récupérer dynamiquement, avec le nom des classes de YouTube Music, les informations qui m'intéressent.

Je voulais récupérer :

  • Nom de la musique
  • Artiste
  • Durée (temps écouté / temps total)
  • Artwork
let currentTrack = null;
let sendTimer = null;
const SERVER_URL = 'http://localhost:8080/trackInfo';

// Écoute les messages venant du content script
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
    if (message.type === 'TRACK_INFO_UPDATE') {
        currentTrack = message.data;
        restartTrackSending();
        sendResponse({ success: true });
        return true;
    }

    if (message.type === 'CLEAR_TRACK_INFO') {
        currentTrack = null;
        stopTrackSending();
        sendResponse({ success: true });
        return true;
    }

    return false;
});

// Démarre ou redémarre l'envoi toutes les 2 secondes
function restartTrackSending() {
    stopTrackSending();

    if (!currentTrack) return;

    // Envoie immédiatement puis chaque 2 secondes
    sendTrack();
    //  sendTimer = setInterval(sendTrack, 2000);
    chrome.alarms.create('trackSender', { periodInMinutes: 0.033 }); // toutes les 2 sec

}

// Arrête l'envoi périodique
function stopTrackSending() {
    if (sendTimer) {
        clearInterval(sendTimer);
        sendTimer = null;
    }
}

// Envoie les données de la piste au serveur
function sendTrack() {
    if (!currentTrack) return;

    const payload = {
        title: currentTrack.title,
        artist: currentTrack.artist,
        album: currentTrack.album,
        artwork: currentTrack.artwork || null,
        duration: currentTrack.duration,
        source: currentTrack.source,
        url: currentTrack.url,
        timestamp: Date.now()
    };

    fetch(SERVER_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload)
    }).catch(() => {
        // TODO
    });
}


// Écoute de l’alarme pour envoyer
chrome.alarms.onAlarm.addListener((alarm) => {
    if (alarm.name === 'trackSender') {
        sendTrack();
    }
});

Transmission des données via HTTP

OK, maintenant on a notre musique, c'est cool, mais... comment on transmet ça à notre application ?!

J'ai donc créé en SwiftUI un petit serveur HTTP avec Embassy pour recevoir les infos :

func startServer() {
    let loop = try! SelectorEventLoop(selector: try! KqueueSelector())
    self.loop = loop

    let server = DefaultHTTPServer(eventLoop: loop, port: 8080) {
        (environ, startResponse, sendBody) in
        let method = environ["REQUEST_METHOD"] as? String ?? "GET"
        let pathInfo = environ["PATH_INFO"] as? String ?? "/"

        if pathInfo == "/trackInfo" && method == "POST" {
            // Gestion de la réception des données JSON
            guard let input = environ["swsgi.input"] as? SWSGIInput else {
                startResponse("400 Bad Request", [])
                sendBody(Data("Missing body".utf8))
                sendBody(Data())
                return
            }

            var receivedData = Data()

            input { data in
                if data.isEmpty {
                    // EOF atteint - traitement du JSON reçu
                    if let jsonString = String(data: receivedData, encoding: .utf8) {
                        print("Reçu JSON:\n\(jsonString)")
                        
                        let decoder = JSONDecoder()
                        do {
                            let trackInfo = try decoder.decode(MusicTrack.self, from: receivedData)
                            SharedAppData.shared.currentTrack = trackInfo
                            SharedAppData.shared.lastUpdate = Date().description
                            SharedAppData.shared.connectionCount += 1
                            print("Track info updated: \(trackInfo)")
                        } catch {
                            print("Erreur de décodage JSON: \(error)")
                            
                            startResponse("400 Bad Request", [])
                            sendBody(Data("Invalid JSON format".utf8))
                            sendBody(Data())
                            return
                        }
                    }

                    startResponse("200 OK", [("Content-Type", "application/json")])
                    let response = """
                    {"status": "ok", "message": "Track info received"}
                    """
                    sendBody(Data(response.utf8))
                    sendBody(Data())
                } else {
                    receivedData.append(data)
                }
            }
        } else {
            startResponse("404 Not Found", [])
            sendBody(Data("Not Found".utf8))
            sendBody(Data())
        }
    }

    self.server = server
    try! server.start()

    DispatchQueue.global(qos: .background).async {
        loop.runForever()
    }
}

Explication du code serveur HTTP

  1. Écoute sur le port 8080 : Notre extension chrome pourra envoyer des données à localhost:8080
  2. Attend les requêtes POST avec l'endpoint /trackInfo
  3. Decode le JSON reçu en objet MusicTrack
  4. Met à jour les données partagées de l'application via SharedAppData.shared
  5. Répond à l'extension avec un statut de confirmation

2. Designer l'animation de notre notch 🎨

Maintenant que nous avons le nécessaire en données (nom de la musique, artiste, durée, artwork), nous pouvons mettre en place notre interface !

Création de la forme de la notch

La première étape consiste à dessiner notre notch :

Explication du code NotchShape

  • Hérite de Shape : Permet de créer des formes personnalisées en SwiftUI
  • Définit les rayons : topCornerRadius et bottomCornerRadius pour personnaliser l'apparence
  • Dessine le chemin : Crée un rectangle avec les coins supérieurs et inférieurs arrondis selon nos paramètres
  • Sert de conteneur : C'est notre conteneur principal pour l'application

3. Gestion de la batterie 🔋

Pour rendre l'application encore plus utile, j'ai ajouté un indicateur de batterie. Voici le code qui récupère les informations de batterie du système :

import IOKit.ps

// Définition des constantes manquantes
let kIOPSIsBatteryKey = "Is Battery" as CFString
let kIOPSCurrentCapacityKey = "Current Capacity" as CFString  
let kIOPSMaxCapacityKey = "Max Capacity" as CFString

func getBatteryLevel() -> (level: Int?, isCharging: Bool) {
    // Récupération des informations de source d'alimentation
    guard let powerSourceInfo = IOPSCopyPowerSourcesInfo()?.takeRetainedValue() else {
        print("Échec de récupération des infos de source d'alimentation")
        return (nil, false)
    }
    
    // Récupération de la liste des sources d'alimentation
    guard let powerSourcesList = IOPSCopyPowerSourcesList(powerSourceInfo)?.takeRetainedValue() as? [Any] else {
        print("Échec de récupération de la liste des sources d'alimentation")
        return (nil, false)
    }
    
    // Itération à travers les sources d'alimentation
    for powerSource in powerSourcesList {
        if let powerSourceInfo = IOPSGetPowerSourceDescription(powerSourceInfo, powerSource as CFTypeRef)?.takeUnretainedValue() as? [String: Any] {
            
            // Vérification si la source d'alimentation est une batterie
            if let type = powerSourceInfo["Type"] as? String, type == "InternalBatttery" {
                // Récupération de la capacité actuelle et maximale
                if let currentCapacity = powerSourceInfo["Current Capacity"] as? Int,
                   let maxCapacity = powerSourceInfo["Max Capacity"] as? Int {
                    
                    // Calcul du pourcentage de batterie
                    let batteryLevel = Int((Double(currentCapacity) / Double(maxCapacity)) * 100)
                    
                    // Vérification si la batterie est en charge
                    let isCharging = powerSourceInfo["Is Charging"] as? Bool ?? false
                    
                    return (batteryLevel, isCharging)
                } else {
                    print("Échec de récupération de la capacité actuelle ou maximale")
                }
            } else {
                print("La source d'alimentation n'est pas une batterie")
            }
        }
    }
    
    print("Aucune batterie trouvée")
    return (nil, false)
}

Explication du code batterie

Cette fonction utilise les APIs IOKit d'Apple pour :

  1. Accéder aux informations système : IOPSCopyPowerSourcesInfo() récupère toutes les sources d'alimentation
  2. Filtrer les batteries : On cherche spécifiquement les sources de type "InternalBattery"
  3. Calculer le pourcentage : Ratio entre capacité actuelle et maximale
  4. Détecter la charge : Statut "Is Charging" pour savoir si le Mac est branché
  5. Retourner les données : Tuple contenant le niveau et le statut de charge

4. Effet AmbiLight 🌈

Pour rendre l'application visuellement attractive, j'ai implémenté un effet AmbiLight qui extrait les couleurs dominantes de la pochette d'album et les utilise comme arrière-plan animé.

Cet effet analyse l'image de la pochette, extrait les couleurs principales et crée des dégradés radiaux animés qui donnent une ambiance colorée à la notch.

5. Créer notre DMG 📦

Une fois notre application terminée et testée, il faut la packager proprement pour la distribution. Heureusement, create-dmg nous simplifie grandement cette tâche en générant automatiquement un fichier .dmg avec une interface d'installation élégante, le tout en une seule commande !

Installer l'utilitaire :

npm install --global create-dmg

Pour packager l'app :

create-dmg 'TheFrenchNotch.app' Build/Releases

Et voilàààààà

0:00
/0:09

6. Agrandissement pour plus de détails 🔍

0:00
/0:09

Pour améliorer l'expérience utilisateur, j'ai ajouté une animation fluide qui s'active au survol de la notch. Celle-ci permet d'agrandir l'interface pour afficher le titre de la musique et l'artwork dans un format plus grand et plus lisible. L'animation se déclenche automatiquement après quelques millisecondes de survol et se rétracte élégamment quand on éloigne la souris.

Rendu IRL :

Conclusion 🚀

Le projet m'a permis d'approfondir mes connaissances en communication interprocess avec l'HTTP local, l'animation SwiftUI avancées, les APIs système macOS et le développement de l'extension chrome.

Je vais sûrement continuer à faire évoluer le projet avec de nouvelle fonctionnalités : support d'autres services de musique, widget perso...

Et qui sait ? Je publierai peut-être un jour le code source sur github 😏😏