From: Mike Frysinger Date: Tue, 20 Aug 2013 04:30:26 +0000 (-0400) Subject: initial extension X-Git-Tag: v1.0 X-Git-Url: https://git.wh0rd.org/?p=chrome-ext%2Fmusic-player-client.git;a=commitdiff_plain;h=refs%2Ftags%2Fv1.0 initial extension --- 50dafc4c60cfd4183bdedcd86fcd9b6b932c6a0b diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c4c4ffc --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.zip diff --git a/TODO b/TODO new file mode 100644 index 0000000..7d7c32d --- /dev/null +++ b/TODO @@ -0,0 +1,5 @@ +make UI not so crappy + +support keyboard shortcuts + +playlist support diff --git a/images/icon-128x128.png b/images/icon-128x128.png new file mode 100644 index 0000000..4238d24 Binary files /dev/null and b/images/icon-128x128.png differ diff --git a/images/screenshot.png b/images/screenshot.png new file mode 100644 index 0000000..dc35ed0 Binary files /dev/null and b/images/screenshot.png differ diff --git a/images/small-tile.png b/images/small-tile.png new file mode 100644 index 0000000..ca586d9 Binary files /dev/null and b/images/small-tile.png differ diff --git a/js/mpc.js b/js/mpc.js new file mode 100644 index 0000000..bf2d969 --- /dev/null +++ b/js/mpc.js @@ -0,0 +1,187 @@ +// Written by Mike Frysinger . Released into the public domain. Suck it. + +function Mpc(socket, cb_update_state) { + this._socket = socket; + this._cb_update_state = cb_update_state; + this._queue = ['init']; + this.state = {}; +} + +Mpc.log = function(msg, obj) { + console.log('mpc: ' + msg, obj); +} + +Mpc.prototype.send = function(msg) { + this._queue.push(msg); + this._socket.send(msg, function(x) { + Mpc.log('send: ' + msg + ':', x); + }); +} + +Mpc.prototype.recv_msg = function(lines) { + curr = this._queue.shift(); + Mpc.log('recv: [' + curr + ']:', lines.join('\n')); + curr = curr.split(' '); + + switch (curr[0]) { + // Needs to return a list of dicts (see above for dicts). + //case 'playlistinfo': + case 'currentsong': + case 'stats': + case 'status': + state = {}; + lines.forEach(function(line) { + i = line.indexOf(':'); + if (i == -1) + return; // Ignores the OK line + key = line.substr(0, i); + val = line.substr(i + 2); + state[key] = val; + }); + this.state = state; + this._cb_update_state(state); + break; + default: + this._cb_update_state(lines, curr); + break; + } +} + +Mpc.prototype.recv = function(msg) { + /* We can get back a bunch of responses in a row, so parse them out */ + /* XXX: Do we have to handle partial reads ? like long playlists ... */ + lines = msg.split('\n'); + var i = 0; + while (i < lines.length) { + if (lines[i] == 'OK' || lines[i].substr(0, 3) == 'OK ') { + this.recv_msg(lines.splice(0, i + 1)); + i = 0; + } else + ++i; + } +} + +/* + * Command generator helpers. + */ + +Mpc.__make_send_void = function(cmd) { + return function() { this.send(cmd); } +} + +Mpc.__make_send_arg1 = function(cmd) { + return function(a1) { + if (a1 === undefined) + Mpc.log(cmd + ': function requires one argument'); + else + this.send(cmd + ' ' + a1); + } +} + +Mpc.__make_send_arg2 = function(cmd) { + return function(a1, a2) { + if (a1 === undefined || a2 === undefined) + Mpc.log(cmd + ': function requires two arguments'); + else + this.send(cmd + ' ' + a1 + ' ' + a2); + } +} + +Mpc.__make_send_opt = function(cmd) { + return function(arg) { + if (arg === undefined) + arg = ''; + this.send(cmd + ' ' + arg); + }; +} + +Mpc.__make_send_range = function(cmd, min, max, def) { + return function(arg) { + if (arg === undefined) + arg = def; + if (arg >= min && arg <= max) + this.send(cmd + ' ' + arg); + else + Mpc.log(cmd + ': arg must be [' + min + ',' + max + '] but got "' + arg + '"'); + }; +} + +/* + * Querying MPD's status + * http://www.musicpd.org/doc/protocol/ch03.html#idp118752 + */ + +// clearerror +Mpc.prototype.clearerror = Mpc.__make_send_void('clearerror'); +// currentsong +Mpc.prototype.currentsong = Mpc.__make_send_void('currentsong'); +// idle [SUBSYSTEMS...] +// TODO +// status +Mpc.prototype.status = Mpc.__make_send_void('status'); +// stats +Mpc.prototype.stats = Mpc.__make_send_void('stats'); + +/* + * Playback options + * http://www.musicpd.org/doc/protocol/ch03s02.html + */ + +// consume {STATE} +Mpc.prototype.consume = Mpc.__make_send_range('consume', 0, 1, 1); +// crossfade {SECONDS} +Mpc.prototype.consume = Mpc.__make_send_arg1('crossfade'); +// mixrampdb {deciBels} +Mpc.prototype.mixrampdb = Mpc.__make_send_arg1('mixrampdb'); +// mixrampdelay {SECONDS|nan} +// Note: Probably should handle javascript NaN here. +Mpc.prototype.mixrampdelay = Mpc.__make_send_arg1('mixrampdelay'); +// random {STATE} +Mpc.prototype.random = Mpc.__make_send_range('random', 0, 1, 1); +// repeat {STATE} +Mpc.prototype.repeat = Mpc.__make_send_range('repeat', 0, 1, 1); +// setvol {VOL} +Mpc.prototype.setvol = Mpc.__make_send_range('setvol', 0, 100); +// single {STATE} +Mpc.prototype.single = Mpc.__make_send_range('single', 0, 1, 1); +// replay_gain_mode {MODE} +Mpc.prototype.replay_gain_mode = Mpc.__make_send_arg1('replay_gain_mode'); +// replay_gain_status + +/* + * Controlling playback + * http://www.musicpd.org/doc/protocol/ch03s03.html + */ + +// next +Mpc.prototype.next = Mpc.__make_send_void('next'); +// pause {PAUSE} +Mpc.prototype.pause = Mpc.__make_send_range('pause', 0, 1, 1); +// play [SONGPOS] +Mpc.prototype.play = Mpc.__make_send_opt('play'); +// playid [SONGID] +Mpc.prototype.playid = Mpc.__make_send_opt('playid'); +// previous +Mpc.prototype.previous = Mpc.__make_send_void('previous'); +// seek {SONGPOS} {TIME} +Mpc.prototype.seek = Mpc.__make_send_arg2('seek'); +// seekid {SONGID} {TIME} +Mpc.prototype.seekid = Mpc.__make_send_arg2('seekid'); +// seekcur {TIME} +Mpc.prototype.seekcur = Mpc.__make_send_arg1('seek'); +// stop +Mpc.prototype.stop = Mpc.__make_send_void('stop'); + +/* + * Connection settings + * http://www.musicpd.org/doc/protocol/ch03s08.html + */ + +// close +Mpc.prototype.close = Mpc.__make_send_void('close'); +// kill +Mpc.prototype.kill = Mpc.__make_send_void('kill'); +// password {PASSWORD} +Mpc.prototype.password = Mpc.__make_send_arg1('password'); +// ping +Mpc.prototype.ping = Mpc.__make_send_void('ping'); diff --git a/js/tcp-client.js b/js/tcp-client.js new file mode 100644 index 0000000..8d1db93 --- /dev/null +++ b/js/tcp-client.js @@ -0,0 +1,229 @@ +/* +Copyright 2012 Google Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +Author: Boris Smus (smus@chromium.org) +*/ + +(function(exports) { + + // Define some local variables here. + var socket = chrome.socket; + + /** + * Creates an instance of the client + * + * @param {String} host The remote host to connect to + * @param {Number} port The port to connect to at the remote host + */ + function TcpClient(host, port) { + this.host = host; + this.port = port; + + // Callback functions. + this.callbacks = { + connect: null, // Called when socket is connected. + disconnect: null, // Called when socket is disconnected. + recv: null, // Called when client receives data from server. + sent: null // Called when client sends data to server. + }; + + // Socket. + this.socketId = null; + this.isConnected = false; + + log('initialized tcp client'); + } + + /** + * Connects to the TCP socket, and creates an open socket. + * + * @see http://developer.chrome.com/trunk/apps/socket.html#method-create + * @param {Function} callback The function to call on connection + */ + TcpClient.prototype.connect = function(callback) { + socket.create('tcp', {}, this._onCreate.bind(this)); + + // Register connect callback. + this.callbacks.connect = callback; + }; + + /** + * Sends a message down the wire to the remote side + * + * @see http://developer.chrome.com/trunk/apps/socket.html#method-write + * @param {String} msg The message to send + * @param {Function} callback The function to call when the message has sent + */ + TcpClient.prototype.sendMessage = function(msg, callback) { + this._stringToArrayBuffer(msg + '\n', function(arrayBuffer) { + socket.write(this.socketId, arrayBuffer, this._onWriteComplete.bind(this)); + }.bind(this)); + + // Register sent callback. + this.callbacks.sent = callback; + }; + + /** + * Sets the callback for when a message is received + * + * @param {Function} callback The function to call when a message has arrived + */ + TcpClient.prototype.addResponseListener = function(callback) { + // Register received callback. + this.callbacks.recv = callback; + }; + + /** + * Disconnects from the remote side + * + * @see http://developer.chrome.com/trunk/apps/socket.html#method-disconnect + */ + TcpClient.prototype.disconnect = function() { + socket.disconnect(this.socketId); + this.isConnected = false; + }; + + /** + * The callback function used for when we attempt to have Chrome + * create a socket. If the socket is successfully created + * we go ahead and connect to the remote side. + * + * @private + * @see http://developer.chrome.com/trunk/apps/socket.html#method-connect + * @param {Object} createInfo The socket details + */ + TcpClient.prototype._onCreate = function(createInfo) { + this.socketId = createInfo.socketId; + if (this.socketId > 0) { + socket.connect(this.socketId, this.host, this.port, this._onConnectComplete.bind(this)); + this.isConnected = true; + } else { + error('Unable to create socket'); + } + }; + + /** + * The callback function used for when we attempt to have Chrome + * connect to the remote side. If a successful connection is + * made then polling starts to check for data to read + * + * @private + * @param {Number} resultCode Indicates whether the connection was successful + */ + TcpClient.prototype._onConnectComplete = function(resultCode) { + // Start polling for reads. + setInterval(this._periodicallyRead.bind(this), 500); + + if (this.callbacks.connect) { + log('connect complete'); + this.callbacks.connect(); + } + log('onConnectComplete'); + }; + + /** + * Checks for new data to read from the socket + * + * @see http://developer.chrome.com/trunk/apps/socket.html#method-read + */ + TcpClient.prototype._periodicallyRead = function() { + socket.read(this.socketId, null, this._onDataRead.bind(this)); + }; + + /** + * Callback function for when data has been read from the socket. + * Converts the array buffer that is read in to a string + * and sends it on for further processing by passing it to + * the previously assigned callback function. + * + * @private + * @see TcpClient.prototype.addResponseListener + * @param {Object} readInfo The incoming message + */ + TcpClient.prototype._onDataRead = function(readInfo) { + // Call received callback if there's data in the response. + if (readInfo.resultCode > 0 && this.callbacks.recv) { + log('onDataRead'); + // Convert ArrayBuffer to string. + this._arrayBufferToString(readInfo.data, function(str) { + this.callbacks.recv(str); + }.bind(this)); + } + }; + + /** + * Callback for when data has been successfully + * written to the socket. + * + * @private + * @param {Object} writeInfo The outgoing message + */ + TcpClient.prototype._onWriteComplete = function(writeInfo) { + log('onWriteComplete'); + // Call sent callback. + if (this.callbacks.sent) { + this.callbacks.sent(writeInfo); + } + }; + + /** + * Converts an array buffer to a string + * + * @private + * @param {ArrayBuffer} buf The buffer to convert + * @param {Function} callback The function to call when conversion is complete + */ + TcpClient.prototype._arrayBufferToString = function(buf, callback) { + var bb = new Blob([new Uint8Array(buf)]); + var f = new FileReader(); + f.onload = function(e) { + callback(e.target.result); + }; + f.readAsText(bb); + }; + + /** + * Converts a string to an array buffer + * + * @private + * @param {String} str The string to convert + * @param {Function} callback The function to call when conversion is complete + */ + TcpClient.prototype._stringToArrayBuffer = function(str, callback) { + var bb = new Blob([str]); + var f = new FileReader(); + f.onload = function(e) { + callback(e.target.result); + }; + f.readAsArrayBuffer(bb); + }; + + /** + * Wrapper function for logging + */ + function log(msg) { + //console.log('tcp-client: ', msg); + } + + /** + * Wrapper function for error logging + */ + function error(msg) { + console.error('tcp-client: ', msg); + } + + exports.TcpClient = TcpClient; + +})(window); diff --git a/launcher.js b/launcher.js new file mode 100644 index 0000000..a4b91cc --- /dev/null +++ b/launcher.js @@ -0,0 +1,10 @@ +// Written by Mike Frysinger . Released into the public domain. Suck it. + +chrome.app.runtime.onLaunched.addListener(function() { + chrome.app.window.create('main.html', { + bounds: { + width: 300, + height: 120 + } + }); +}); diff --git a/main.html b/main.html new file mode 100644 index 0000000..8f5e3c4 --- /dev/null +++ b/main.html @@ -0,0 +1,125 @@ + + + + + +Media Player Client + + + + + + + + +
+ +
+ + + + + + + + + + + + +
+ + + +
+ + + +
+ Loading... +
+
+ + + + + +
+ + + + + diff --git a/main.js b/main.js new file mode 100644 index 0000000..08efb72 --- /dev/null +++ b/main.js @@ -0,0 +1,236 @@ +// Written by Mike Frysinger . Released into the public domain. Suck it. + +/* Globals to allow easy manipulation via javascript console */ +var mpc; +var tcpclient; + +function TcpClientSender(tcpclient) { + this.tcpclient = tcpclient; +} +TcpClientSender.prototype.send = function(data, cb) { + this.tcpclient.sendMessage(data, cb); +} + +function tramp_mpc_recv(data) { + mpc.recv(data); +} + +function sync_storage(sync) { + return sync ? chrome.storage.sync : chrome.storage.local; +} + +window.onload = function() { + var local_keys = [ + 'sync', + ]; + var sync_keys = [ + 'host', 'port', + ]; + var options = { + 'host': '192.168.0.2', + 'port': 6600, + 'sync': true, + }; + + chrome.storage.local.get(local_keys, function(settings) { + local_keys.forEach(function(key) { + if (key in settings) + options[key] = settings[key] + }); + + var storage = sync_storage(options['sync']); + storage.get(sync_keys, function(settings) { + sync_keys.forEach(function(key) { + if (key in settings) + options[key] = settings[key]; + }); + + init_ui(local_keys, sync_keys, options); + mpc_connect(); + }); + }); +}; + +function mpc_refresh() { + mpc.status(); + mpc.currentsong(); +} + +function mpc_connect(host, port) { + if (typeof(host) != 'string') { + host = window['opts_host'].value; + port = parseInt(window['opts_port'].value); + } + + if (mpc != undefined) { + console.log('disconnecting'); + update_ui('disconnect'); + delete mpc; + tcpclient.disconnect(); + delete tcpclient; + } + + update_ui('init'); + tcpclient = new TcpClient(host, port); + tcpclient.connect(function() { + var mpc_sender = new TcpClientSender(tcpclient); + tcpclient.addResponseListener(tramp_mpc_recv); + mpc = new Mpc(mpc_sender, update_ui); + console.log('connected to ' + host + ':' + port); + mpc_refresh(); + }); +} + +function tramp_mpc_consume() { + var val = zo(!getToggleButton(this)); + mpc.consume(val); + setToggleButton(this, val); +} +function tramp_mpc_next() { mpc.next(); } +function tramp_mpc_pause() { mpc.pause(); } +function tramp_mpc_play() { mpc.play(); } +function tramp_mpc_previous() { mpc.previous(); } +function tramp_mpc_random() { + var val = zo(!getToggleButton(this)); + mpc.random(val); + setToggleButton(this, val); +} +function tramp_mpc_repeat() { + var val = zo(!getToggleButton(this)); + mpc.repeat(val); + setToggleButton(this, val); +} +function tramp_mpc_seekcur() { mpc.seekcur(this.value); } +function tramp_mpc_setvol() { mpc.setvol(this.value); } +function tramp_mpc_single() { + var val = zo(!getToggleButton(this)); + mpc.single(val); + setToggleButton(this, val); +} +function tramp_mpc_stop() { mpc.stop(); } + +function zo(val) { + return val ? 1 : 0; +} +function szo(val) { + return val == '0' ? 0 : 1; +} +function getToggleButton(btn) { + return btn.style.borderStyle == 'inset'; +} +function setToggleButton(btn, val) { + if (val === undefined) + val = !getToggleButton(btn); + btn.style.borderStyle = val ? 'inset' : ''; +} + +function show_page(page) { + if (typeof(page) != 'string') + page = this.id.split('.')[1]; + + var eles = document.getElementsByClassName('main'); + for (var i = 0; i < eles.length; ++i) { + var ele = eles[i]; + var dis = 'none'; + var cls = ''; + if (ele.id == 'main.' + page) { + dis = ''; + cls = 'selected'; + } + ele.style.display = dis; + document.getElementById('tab.' + ele.id.split('.')[1]).className = cls; + } +} + +function update_local_settings() { + var setting = {}; + setting[this.id] = this.checked; + chrome.storage.local.set(setting); +} + +function update_sync_settings() { + var setting = {}; + setting[this.id] = this.value; + var storage = sync_storage(window['opts_sync'].checked); + storage.set(setting); +} + +function init_ui(local_keys, sync_keys, options) { + /* Setup footer */ + [ + 'controls', 'metadata', 'options', + ].forEach(function(id) { + document.getElementById('tab.' + id).onclick = show_page; + }); + + /* Setup control tab */ + ui_mpc_status = document.getElementById('status'); + ui_mpc_metadata = document.getElementById('metadata'); + [ + 'consume', 'next', 'pause', 'play', 'previous', 'random', 'repeat', + 'seekcur', 'setvol', 'single', 'stop', + ].forEach(function(id) { + var ele = window['ui_mpc_' + id] = document.getElementById(id); + ele.onclick = window['tramp_mpc_' + id]; + ele.title = id; + }); + + /* Setup options tab */ + document.getElementById('connect').onclick = mpc_connect; + local_keys.forEach(function(id) { + var ele = window['opts_' + id] = document.getElementById(id); + ele.checked = options[id]; + ele.onchange = update_local_settings; + }); + sync_keys.forEach(function(id) { + var ele = window['opts_' + id] = document.getElementById(id); + ele.value = options[id]; + ele.oninput = update_sync_settings; + }); +} + +function update_ui(state, cmd) { + if (typeof(state) == 'string') { + ui_mpc_status.innerText = ({ + 'disconnect': 'Disconnecting...', + 'init': 'Connecting...', + })[state]; + return; + } + + if (Array.isArray(state)) { + /* + switch (cmd[0]) { + case 'setvol': + case 'seekcur': + break; + default: + mpc_refresh(); + } + */ + return; + } + + if ('file' in state) { + // Hack: should be a real object. + ui_mpc_metadata.innerText = state['file']; + return; + } + + var time = state.time.split(':'); + window['ui_mpc_seekcur'].max = time[1]; + window['ui_mpc_seekcur'].value = time[0]; + + window['ui_mpc_setvol'].value = state.volume; + [ + 'consume', 'random', 'repeat', 'single', + ].forEach(function(id) { + setToggleButton(window['ui_mpc_' + id], szo(state[id])); + }); + + ui_mpc_status.innerText = ({ + 'play': 'Playing', + 'pause': 'Paused', + 'stop': 'Stopped', + })[state.state]; +} diff --git a/makedist.sh b/makedist.sh new file mode 100755 index 0000000..76be818 --- /dev/null +++ b/makedist.sh @@ -0,0 +1,42 @@ +#!/bin/bash -e +# Copyright (c) 2013 The Chromium OS Authors. All rights reserved. +# Use of this source code is governed by a BSD-style license that can be +# found in the LICENSE file. + +case $1 in +-h|--help) + echo "Usage: $0 [rev]" + exit 0 + ;; +esac + +json_value() { + local key=$1 + sed -n -r \ + -e '/^[[:space:]]*"'"${key}"'"/s|.*:[[:space:]]*"([^"]*)",?$|\1|p' \ + manifest.json +} + +PN=$(json_value name | sed 's:[[:space:]]:_:g' | tr '[:upper:]' '[:lower:]') +PV=$(json_value version) +rev=${1:-0} +PVR="${PV}.${rev}" +P="${PN}-${PVR}" + +rm -rf "${P}" +mkdir "${P}" + +while read line ; do + [[ ${line} == */* ]] && mkdir -p "${P}/${line%/*}" + ln "${line}" "${P}/${line}" +done < <(sed 's:#.*::' manifest.files) +cp manifest.json "${P}/" + +sed -i \ + -e '/"version"/s:"[^"]*",:"'${PVR}'",:' \ + "${P}/manifest.json" + +zip="${P}.zip" +zip -r "${zip}" "${P}" +rm -rf "${P}" +du -b "${zip}" diff --git a/manifest.files b/manifest.files new file mode 100644 index 0000000..e391a0b --- /dev/null +++ b/manifest.files @@ -0,0 +1,6 @@ +images/icon-128x128.png +js/mpc.js +js/tcp-client.js +launcher.js +main.js +main.html diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..14453b1 --- /dev/null +++ b/manifest.json @@ -0,0 +1,20 @@ +{ + "manifest_version": 2, + "minimum_chrome_version": "24", + "name": "Music Player Client", + "version": "1.0.1", + "description": "Control a Music Player Daemon (MPD)", + "icons": { + "128": "images/icon-128x128.png" + }, + "app": { + "background": { + "scripts": ["launcher.js"] + } + }, + "offline_enabled": true, + "permissions": [ + "storage", + {"socket": ["tcp-connect"]} + ] +}