blog

我是如何实现「正在听」的

2025-01-09
MacOS 技术

软件中的小细节往往能带来意想不到的情感共鸣,那些细微的真实触动会产生更深层次的连接 🎵✨

之前在访问一个很棒的开发者的「独立博客」的时候,看到博客上显示正在听的音乐,觉得非常有意思,作者也把方案写在文章里。但是原方案只能实现监听Apple Music,而我平时用的比较多的还是网易云音乐,不得已只能另辟蹊径。

在尝试了抓包分析网易云音乐的接口和Github上搜索一番后,无果。

不得已还是只能从系统层面入手,作为一个前苹果开发者,这应该是我熟悉的领域啊。

于是很快想到是否可以通过监听系统的控制中心的媒体播放信息来获取当前播放的音乐信息。

果不其然,真的可以。

系统有个私有库 MediaRemote.framework,里面有很多关于媒体播放的接口,其中有一个 MRMediaRemoteGetNowPlayingInfo 可以获取当前播放的音乐信息。

通过注册两个系统通知 kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification, kMRMediaRemoteNowPlayingInfoDidChangeNotification 就可以监听到控制中心的媒体播放变化的通知了。

写 Swift 代码对我来说轻车熟路。很快,我就写好了 MacOS 版本的代码,并打包成应用程序。

example:

    func printNowPlayingInfo() {
        guard let MRMediaRemoteGetNowPlayingInfoPointer = CFBundleGetFunctionPointerForName(
                bundle, "MRMediaRemoteGetNowPlayingInfo" as CFString)
            else { return }

            typealias MRMediaRemoteGetNowPlayingInfoFunction = @convention(c) (
                DispatchQueue, @escaping ([String: Any]) -> Void
            ) -> Void
            let MRMediaRemoteGetNowPlayingInfo = unsafeBitCast(
                MRMediaRemoteGetNowPlayingInfoPointer,
                to: MRMediaRemoteGetNowPlayingInfoFunction.self)

            MRMediaRemoteGetNowPlayingInfo(DispatchQueue.main) { [weak self] (information) in
                guard let self = self else { return }

                var name: String?
                var artist: String?
                var album: String?
                var playbackRate = 0.0
                var artworkPath: String?

                // 获取基本信息
                if let info = information["kMRMediaRemoteNowPlayingInfoTitle"] as? String {
                    name = info
                }
                if let info = information["kMRMediaRemoteNowPlayingInfoArtist"] as? String {
                    artist = info
                }
                if let info = information["kMRMediaRemoteNowPlayingInfoAlbum"] as? String {
                    album = info
                }
                if let info = information["kMRMediaRemoteNowPlayingInfoPlaybackRate"] as? Double {
                    playbackRate = info
                }

                if let artworkData = information["kMRMediaRemoteNowPlayingInfoArtworkData"] as? Data {
                    // 使用歌曲信息生成唯一ID
                    let songId = "xxx"
                    artworkPath = self.saveArtwork(artworkData, for: songId)
                }
            }
    }


    func registerNotificationObservers() {
        guard let registerForNotificationsPointer = CFBundleGetFunctionPointerForName(
            bundle, "MRMediaRemoteRegisterForNowPlayingNotifications" as CFString)
        else { return }

        typealias RegisterForNotificationsFunction = @convention(c) (DispatchQueue) -> Void
        let registerForNowPlayingNotifications = unsafeBitCast(
            registerForNotificationsPointer,
            to: RegisterForNotificationsFunction.self)

        registerForNowPlayingNotifications(DispatchQueue.main)

        DispatchQueue.main.async {
            NotificationCenter.default.addObserver(
                self,
                selector: #selector(self.printNowPlayingInfo),
                name: NSNotification.Name("kMRMediaRemoteNowPlayingApplicationIsPlayingDidChangeNotification"),
                object: nil)

            NotificationCenter.default.addObserver(
                self,
                selector: #selector(self.printNowPlayingInfo),
                name: NSNotification.Name("kMRMediaRemoteNowPlayingInfoDidChangeNotification"),
                object: nil)
        }
    }

接下来,我又基于 Python 写了一个小脚本,用来获取 Mac 应用的输出,并且将封面上传到 Cloudflare R2,同时把音乐信息保存到 Supabase 数据库。

最后在博客里加入 supabase realtime 的监听,这样就能实时更新音乐信息了。

我还想让音乐组件的背景色根据歌曲封面的主色调动态变化。 一开始尝试在前端实现,但效果不理想。

最终,我在Python代码里实现了这个功能,并将主色调信息也保存到数据库里,效果还是不错的。

  def extract_dominant_color(self, image_path: str) -> Optional[Dict[str, int]]:
      """提取图片的主色调"""
      try:
          with Image.open(image_path) as img:
              img = img.resize((150, 150))
              img = img.convert('RGB')
              np_img = np.array(img)
              pixels = np_img.reshape(-1, 3)
              avg_color = np.mean(pixels, axis=0)

              return {
                  'r': int(avg_color[0]),
                  'g': int(avg_color[1]),
                  'b': int(avg_color[2])
              }
      except Exception as e:
          self.logger.error(f"Error extracting color: {str(e)}")
          return None

最后给组件加入一个 hover 的效果,当鼠标移动到音乐组件上时,会放大组件。

大功告成!

理论上来讲,这个方案可以支持所有的音乐播放器,甚至是看视频也会反应到博客上。

如果有同样兴趣的朋友,回头可以找个时间整理下代码开源。