KDE Connect多媒体控制技术揭秘

278 阅读6分钟

前言

KDE桌面环境下有一个KDE Connect用来和Android设备交互,包括文件传输等,其中有一个媒体控制,这个场景是在电脑上网页播放视频时,Android可以进行暂停、播放、上/下一首,觉得这个很有意思,就研究了一下。

这个问题主要是:外部程序是怎么控制以及知道网页上播放的视频信息的

这其中涉及到的技术有两个。

  1. D-Bus

    D-Bus 是一个消息总线系统,用于在 Linux 系统中的不同进程之间进行通信。它提供了一个机制,通过消息的方式让不同的应用程序或系统组件能够进行跨进程通信。

  2. MPRIS

    MPRIS 是一个在 D-Bus 上构建的规范,它定义了一个标准接口,使媒体播放器能够与其他应用程序或桌面环境交互

其中MPRIS是个规范,那就要有实现,而几个有名的客户端就是Chrome、Firefox、VLC,他们实现了MPRIS规范,在他们播放视频时候,会通过D-Bus在会话总线类别中广播一条信息,称为信号,这里的会话总线是D-Bus中的概念,D-Bus提供了两种类型的通信总线:系统总线(System Bus)会话总线(Session Bus) 。这两种总线具有不同的作用域和使用场景,它们在 D-Bus 中扮演着不同的角色,这里广播只在会话总线中广播,而下面说到的蓝牙设备,是在系统总线中广播。

那么其他应用程序只要监听D-Bus会话总线中这个事件就行,而KDE Connect的系统守护程序,就是监听这个事件后,通过TCP/UDP发送到Android设备,而Android在13后,也增加了媒体统一控制,当系统在播放音乐时,一方面是推送一个他自身的控制界面,另一方面也会推送给Android系统,像我使用的小米,下拉控制栏左面是音乐播放器自身的,右面是系统自身的,都可以达到控制音乐暂停、播放。

那么手机就是收到暂停指令后,在通过TCP/UDP发送到KDE Connect服务器,服务器在通过D-Bus进行方法调用。

这里又提到方法调用,这里类似gRPC,先是服务端定义方法,比如Stop方法、Play方法,然后客户端调用,而D-Bus就是这样做的,他是进程间通信的核心机制,通过 D-Bus,应用程序可以调用其他应用程序暴露的方法,并传递数据。

那么就伴随着方法的ID,比如在Java中,类名+方法名才能定位到具体的方法。

再说一下D-Bus中的几个概念。

  1. 服务名(Service Name)

    服务名是 D-Bus 上唯一标识一个服务的名称。服务名通常采用类似 DNS 域名的格式,例如org.freedesktop.DBusorg.gnome.SettingsDaemon

  2. 对象路径(Object Path)

    对象路径指向 D-Bus 服务中提供的某个具体对象

  3. 接口(Interface)

    接口定义了一个对象支持的方法。它类似于编程语言中的接口(Interface),描述了对象可以提供哪些操作。

  4. 方法(Method)

    方法是接口中定义的可调用函数。

而D-Bus是把这些信息注册到系统里面的, 任何程序都可以查看。

播放/暂停

比如下面命令查看所有已经注册的服务。

dbus-send --session --print-reply --dest=org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus.ListNames

在chrome上播放视频时候,会有这两条记录

string "org.mpris.MediaPlayer2.chromium.instance1354"  
string "org.mpris.MediaPlayer2.plasma-browser-integration"

然后可以通过下面命令查看他们定义的方法。

gdbus introspect --session --dest org.mpris.MediaPlayer2.plasma-browser-integration  --object-path /org/mpris/MediaPlayer2

output
....
interface org.mpris.MediaPlayer2.Player {  
   methods:  
     Next();  
     Previous();  
     Pause();  
     PlayPause();  
     Stop();  
     Play();  
     Seek(in  x Offset);  
     SetPosition(in  o TrackId,  
                 in  x Position);  
     OpenUri(in  s Uri);  
   signals:  
     Seeked(x Position)

这里object-path如果不知道的话,可以传入/,一层层的看。从interface里面可以看到有定义Next、Previous、Pause、Play等控制播放的方法。

知道服务名、对象路径、接口后在通过下面命令调用具体方法,gdbus是其中一个,还可以通过dbus-send,下面两条命令就是用来控制chrom播放暂停

gdbus call --session --dest org.mpris.MediaPlayer2.chromium.instance1354 --object-path /org/mpris/MediaPlayer2 --method org.mpris.MediaPlayer2.Player.Play
dbus-send --session --dest=org.mpris.MediaPlayer2.chromium.instance1354  --print-reply /org/mpris/MediaPlayer2  org.mpris.MediaPlayer2.Player.Pause

事件监听

当chrome播放/暂停等事件发生时,会通过D-Bus广播,我们只要监听就行,下面用c语言写一个例子。

这个例子不光能监听播放暂停、还能监听出视频标题。

#include <dbus/dbus.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void parse_dbus_message(DBusMessage *message) {
    DBusMessageIter iter, array_iter, dict_iter, variant_iter, entry_iter;
    const char *key;
    dbus_bool_t can_pause, can_play;
    const char *playback_status;
    dbus_int64_t position;

    dbus_message_iter_init(message, &iter);
    const char *str;
    if (dbus_message_iter_get_arg_type(&iter) == DBUS_TYPE_STRING) {
        dbus_message_iter_get_basic(&iter, &str);
        if (strcmp(str, "org.mpris.MediaPlayer2.Player") != 0) {
            return;
        }
    }

    if (!dbus_message_iter_next(&iter))return;
    if (dbus_message_iter_get_arg_type(&iter) == DBUS_TYPE_ARRAY) {
        dbus_message_iter_recurse(&iter, &array_iter);
        while (dbus_message_iter_get_arg_type(&array_iter) == DBUS_TYPE_DICT_ENTRY) {
            dbus_message_iter_recurse(&array_iter, &dict_iter);
            dbus_message_iter_get_basic(&dict_iter, &key);
            dbus_message_iter_next(&dict_iter);
            dbus_message_iter_recurse(&dict_iter, &variant_iter);
            if (strcmp(key, "CanPause") == 0) {
                dbus_message_iter_get_basic(&variant_iter, &can_pause);
            } else if (strcmp(key, "CanPlay") == 0) {
                dbus_message_iter_get_basic(&variant_iter, &can_play);
            } else if (strcmp(key, "PlaybackStatus") == 0) {
                dbus_message_iter_get_basic(&variant_iter, &playback_status);
            } else if (strcmp(key, "Position") == 0) {
                dbus_message_iter_get_basic(&variant_iter, &position);
            } else if (strcmp(key, "Metadata") == 0) {
                dbus_message_iter_recurse(&variant_iter, &array_iter);
                while (dbus_message_iter_get_arg_type(&array_iter) == DBUS_TYPE_DICT_ENTRY) {
                    dbus_message_iter_recurse(&array_iter, &entry_iter);
                    dbus_message_iter_get_basic(&entry_iter, &key);
                    dbus_message_iter_next(&entry_iter);
                    dbus_message_iter_recurse(&entry_iter, &variant_iter);
                    if (strcmp(key, "kde:pid") == 0) {
                        dbus_int32_t pid;
                        dbus_message_iter_get_basic(&variant_iter, &pid);
                    } else if (strcmp(key, "mpris:length") == 0) {
                        dbus_int64_t length;
                        dbus_message_iter_get_basic(&variant_iter, &length);
                    } else if (strcmp(key, "xesam:title") == 0) {
                        const char *title;
                        dbus_message_iter_get_basic(&variant_iter, &title);
                        printf("%s\n", title);
                    }

                    dbus_message_iter_next(&array_iter);
                }
            }
            dbus_message_iter_next(&array_iter);
        }
    }

    if (playback_status == NULL )return;
    printf("state:%s,\n", playback_status);
}


int main() {
    DBusError error;
    DBusConnection *connection;
    connection = dbus_bus_get(DBUS_BUS_SESSION, &error);

    dbus_bus_add_match(
        connection, "path='/org/mpris/MediaPlayer2',type='signal',interface='org.freedesktop.DBus.Properties'", &error);
    if (dbus_error_is_set(&error)) {
        fprintf(stderr, "Match Error (%s)\n", error.message);
        exit(1);
    }
    for (;;) {
        dbus_connection_read_write(connection, 0);
        DBusMessage *msg = dbus_connection_pop_message(connection);
        if (msg == NULL) {
            continue;
        }
        if (dbus_message_is_signal(msg, "org.freedesktop.DBus.Properties", "PropertiesChanged")) {
            DBusError err;
            dbus_error_init(&err);
            parse_dbus_message(msg);
        }
        dbus_message_unref(msg);

    }
 }

有一个命令行工具,可以监听出系统所有总线消息,可以根据这个输出来编写我们的代码。

其中--session是监听会话总线的消息,还有--system监听系统总线。

dbus-monitor --session

蓝牙设备

Linux下的蓝牙服务也提供了D-Bus接口,可以通过其控制蓝牙,发送AVRCP命令使其播放/暂停。

首先第一步就是查找已经连接的蓝牙信息,通过下面命令。

dbus-send --system --dest=org.bluez --print-reply / org.freedesktop.DBus.ObjectManager.GetManagedObjects

找到其中Connected属性为true的,则表示是连接的蓝牙,他上面会有一个object-path,根据这个才能操作指定的蓝牙。

dict entry(  
              string "org.bluez.MediaControl1"  
              array [  
                 dict entry(  
                    string "Connected"  
                    variant                         boolean true  
                 )  
                 dict entry(  
                    string "Player"  
                    variant                         object path "/org/bluez/hci0/dev_A4_55_90_AD_3C_CE/player0"  
                 )  
              ]  
           )

比如下面两条命令就是控制暂停/播放,其中/org/bluez/hci0/dev_A4_55_90_AD_3C_CE就是从上面输出的object-path的来。

dbus-send --system --type=method_call --dest=org.bluez /org/bluez/hci0/dev_A4_55_90_AD_3C_CE  org.bluez.MediaControl1.Play

dbus-send --system --type=method_call --dest=org.bluez /org/bluez/hci0/dev_A4_55_90_AD_3C_CE  org.bluez.MediaControl1.Pause