Skip to content

Commit

Permalink
Add basic MPRIS support on Linux (berrberr#451)
Browse files Browse the repository at this point in the history
* Add basic MPRIS support for Linux

Add an option to register StreamKeys as an MPRIS player in DBus on
Linux. This is optional and currently requires single player mode to
be enabled.

Some advantages of using MPRIS on Linux are:
* Desktop integration, e.g. on Gnome users should be able to see and
  control StreamKeys from the notifications panel.
* Media keys working without having to set them in globals. In fact, it
  is better to not use them when MPRIS is used, as chrome hijacks the
  media keys when they are global, resulting in no other program being
  able to use them.

Signed-off-by: Alex Tsitsimpis <[email protected]>

* bandcamp: Add support to get cover art

Signed-off-by: Alex Tsitsimpis <[email protected]>

* Add MPRIS host installation script

Add an installation script and instructions on how to install the MPRIS
host. The process is (for now at least) manual.

Signed-off-by: Alex Tsitsimpis <[email protected]>

* mpris: Support updating position and track length

Signed-off-by: Alex Tsitsimpis <[email protected]>
  • Loading branch information
alextsits authored and berrberr committed Jan 1, 2019
1 parent 94dee92 commit 0fa4216
Show file tree
Hide file tree
Showing 9 changed files with 896 additions and 2 deletions.
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,34 @@ To run the tests locally, simply
$ npm test
```

## Linux MPRIS support

On Linux you can enable basic MPRIS support in options. Currently this requires
`single player mode` to be enabled. It requires an extra host script to be
installed.

#### Install host script

To install the host script, locate the extension ID from the Chrome extensions page
and run the following commands:

```bash
$ extension_id="....."
$ installer=$(find $HOME/.config -name "mpris_host_setup.py" | grep ${extension_id})
$ python3 ${installer} install ${extension_id}
```

#### Uninstall host script

To uninstall the host script, locate the extension ID from the Chrome extensions page
and run the following commands:

```bash
$ extension_id="....."
$ installer=$(find $HOME/.config -name "mpris_host_setup.py" | grep ${extension_id})
$ python3 ${installer} uninstall
```

## License (MIT)

Copyright (c) 2018 Alex Gabriel under the MIT license.
Expand Down
6 changes: 6 additions & 0 deletions code/html/options.html
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ <h3>General Settings</h3>
<input type="checkbox" id="single-player-mode" data-bind="checked: $data.singlePlayerMode">
<label for="single-player-mode">Send commands to most recent player tab only.</label>
</p>
<p class="setting" data-bind="visible: $data.supportsMPRIS">
<input type="checkbox" id="use-mpris" data-bind="checked: $data.useMPRIS, enable: $data.singlePlayerMode">
<label for="use-mpris">Register StreamKeys as an MPRIS player in DBus (send commands to most recent player tab only is required).
You should also <a href=https://github.com/berrberr/streamkeys#linux-mpris-support>install the host script</a>.
</label>
</p>
<p class="setting">
<input type="checkbox" id="open-on-update" data-bind="checked: $data.openOnUpdate">
<label for="open-on-update">Open information tab on update.</label>
Expand Down
148 changes: 148 additions & 0 deletions code/js/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@
if(request.action === "inject_controller") {
console.log("Inject: " + request.file + " into: " + sender.tab.id);
chrome.tabs.executeScript(sender.tab.id, {file: request.file});
if (mprisPort) mprisPort.postMessage({ command: "add_player" });
}
if(request.action === "check_music_site") {
/**
Expand All @@ -172,6 +173,7 @@
stateData: request.stateData,
fromTab: sender.tab
});
if (mprisPort) handleStateData(updateMPRISState);
}
if(request.action === "get_music_tabs") {
var musicTabs = window.skSites.getMusicTabs();
Expand Down Expand Up @@ -274,4 +276,150 @@
window.skSites = new Sitelist();
window.skSites.loadSettings();
});


/**
* MPRIS support
*/
var connections = 0;
var mprisPort = null;

var hmsToSecondsOnly = function(str) {
var p = str.split(":");
var s = 0;
var m = 1;

while (p.length > 0) {
s += m * parseInt(p.pop(), 10);
m *= 60;
}

return s;
};

var handleNativeMsg = function(msg) {
switch(msg.command) {
case "play":
case "pause":
case "playpause":
sendAction("playPause");
break;
case "stop":
sendAction("stop");
break;
case "next":
sendAction("playNext");
break;
case "previous":
sendAction("playPrev");
break;
default:
console.log("Cannot handle native message command: " + msg.command);
}
};

/**
* Get the state of the player that a command will end up affecting and pass
* it to a function to handle them, along with the tab that corresponds to
* that state data.
* For "single player mode" (which is required for MPRIS support), a command
* will end up affecting the best tab if there are active tabs but no
* playing tabs, or all playing tabs if there are playing tabs (see
* sendActionSinglePlayer). With that in mind:
* - If there is no active tab, then the state and the tab are null.
* - If there are active tabs but no playing tabs, use the best tab.
* - If there are any playing tabs, just use the state of the best playing
* tab. The command will be sent to all playing tabs anyway.
*/
var handleStateData = function(func) {
var activeMusicTabs = window.skSites.getActiveMusicTabs();
activeMusicTabs.then(function(tabs) {
if (_.isEmpty(tabs)) {
func(null, null);
} else {
var bestTab = null;
var playingTabs = getPlayingTabs(tabs);
if (_.isEmpty(playingTabs)){
bestTab = getBestSinglePlayerTab(tabs);
} else {
bestTab = getBestSinglePlayerTab(playingTabs);
}

func(tabStates[bestTab.id].state, bestTab);
}
});
};

/**
* If stateData is null, then state is stopped with NoTrack. Otherwise update
* with the state of the player that a command will end up affecting.
*/
var updateMPRISState = function(stateData, tab) {
if (stateData === null) {
mprisPort.postMessage({ command: "remove_player" });
} else {
var metadata = {
"mpris:trackid": stateData.song ? tab.id : null,
"xesam:title": stateData.song,
"xesam:artist": stateData.artist ? [stateData.artist.trim()] : null,
"xesam:album": stateData.album,
"mpris:artUrl": stateData.art,
"mpris:length": hmsToSecondsOnly((stateData.totalTime || "").trim()) * 1000000
};
var args = [{ "CanGoNext": stateData.canPlayNext,
"CanGoPrevious": stateData.canPlayPrev,
"PlaybackStatus": (stateData.isPlaying ? "Playing" : "Paused"),
"CanPlay": stateData.canPlayPause,
"CanPause": stateData.canPlayPause,
"Metadata": metadata,
"Position": hmsToSecondsOnly((stateData.currentTime || "").trim()) * 1000000}];

mprisPort.postMessage({ command: "update_state", args: args });
}
};

/**
* Connect to the native messaging host for MPRIS support
*/
chrome.storage.sync.get(function(obj) {

if (obj.hasOwnProperty("hotkey-use_mpris") && obj["hotkey-use_mpris"]) {
if (!connections) {
connections += 1;
console.log("Starting native messaging host");
mprisPort = chrome.runtime.connectNative("org.mpris.streamkeys_host");
mprisPort.onMessage.addListener(handleNativeMsg);

chrome.runtime.onSuspend.addListener(function() {
if (!--connections)
mprisPort.postMessage({ command: "quit" });
mprisPort.onMessage.removeListener(handleNativeMsg);
mprisPort.disconnect();
});

/**
* When a music tab is removed, we must remove it from tabStates and
* update the state of the MPRIS player.
*/
chrome.tabs.onRemoved.addListener(function(tabId) {
if (tabStates.hasOwnProperty(tabId)) {
delete tabStates[tabId];
handleStateData(updateMPRISState);
}
});

/**
* When the active tab changes, the best single tab might change too.
* Thus we need to update the state of the MPRIS player.
*/
chrome.tabs.onActivated.addListener(function(activeInfo) {
if (tabStates.hasOwnProperty(activeInfo.tabId)) {
handleStateData(updateMPRISState);
}
});

}
}
});

})();
13 changes: 12 additions & 1 deletion code/js/controllers/BandcampController.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

var BaseController = require("BaseController");

new BaseController({
var controller = new BaseController({
siteName: "Bandcamp",
playPause: ".playbutton",
playNext: ".nextbutton",
Expand All @@ -12,10 +12,21 @@
playState: ".playbutton.playing",
song: "a.title_link > span.title",
artist: "[itemprop=byArtist]",
art: ".popupImage",

hidePlayer: true,

currentTime: ".time_elapsed",
totalTime: ".time_total"
});


controller.getArtData = function(selector) {
var dataEl = this.doc().querySelector(selector);
if(dataEl && dataEl.attributes && dataEl.attributes.href) {
return dataEl.attributes.href.value;
}

return null;
};
})();
3 changes: 3 additions & 0 deletions code/js/modules/Sitelist.js
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,9 @@
if(!obj.hasOwnProperty("hotkey-open_on_update")) {
chrome.storage.sync.set({ "hotkey-open_on_update": true });
}
if(!obj.hasOwnProperty("hotkey-use_mpris")) {
chrome.storage.sync.set({ "hotkey-use_mpris": false });
}
if(!obj.hasOwnProperty("hotkey-youtube_restart")) {
chrome.storage.sync.set({ "hotkey-youtube_restart": false });
}
Expand Down
27 changes: 27 additions & 0 deletions code/js/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,39 @@ var OptionsViewModel = function OptionsViewModel() {
});
};

chrome.runtime.getPlatformInfo(function(platformInfo){
self.supportsMPRIS = (platformInfo.os === chrome.runtime.PlatformOs.LINUX);
});

// Load localstorage settings into observables
chrome.storage.sync.get(function(obj) {
self.openOnUpdate = ko.observable(obj["hotkey-open_on_update"]);
self.openOnUpdate.subscribe(function(value) {
chrome.storage.sync.set({ "hotkey-open_on_update": value });
});

self.useMPRIS = ko.observable(obj["hotkey-use_mpris"]);
self.useMPRIS.subscribe(function(value) {
if (value) {
chrome.permissions.contains({
permissions: ["nativeMessaging"],
}, function (alreadyHaveNativeMessagingPermissions) {
if (alreadyHaveNativeMessagingPermissions) {
chrome.storage.sync.set({ "hotkey-use_mpris": value });
}
else {
chrome.permissions.request({
permissions: ["nativeMessaging"],
}, function (granted) {
chrome.storage.sync.set({ "hotkey-use_mpris": granted });
});
}
});
} else {
chrome.storage.sync.set({ "hotkey-use_mpris": value });
}
});

self.youtubeRestart = ko.observable(obj["hotkey-youtube_restart"]);
self.youtubeRestart.subscribe(function(value) {
chrome.storage.sync.set({ "hotkey-youtube_restart": value });
Expand All @@ -42,6 +68,7 @@ var OptionsViewModel = function OptionsViewModel() {
self.singlePlayerMode = ko.observable(obj["hotkey-single_player_mode"]);
self.singlePlayerMode.subscribe(function(value) {
chrome.storage.sync.set({ "hotkey-single_player_mode": value });
if (!value) self.useMPRIS(false);
});

self.settingsInitialized(true);
Expand Down
3 changes: 2 additions & 1 deletion code/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
"js/controllers/*"
],
"permissions": ["tabs", "storage", "http://*/*", "https://*/*"],
"optional_permissions": [ "notifications", "http://*/*", "https://*/*"],
"optional_permissions": [ "notifications", "http://*/*", "https://*/*",
"nativeMessaging"],
"commands": {
"playPause": {
"suggested_key": {
Expand Down
Loading

0 comments on commit 0fa4216

Please sign in to comment.