2013年10月30日

SoundCloudでQueueを使えるようにするUserScript書いたらコード解析が楽しかった


SoundCloud でいろんな人のトラックを聴いて回ってると、いい曲が見つかって、「次この曲を再生予約したいなあ」などと思うことがよくありますので user.js 書きました

機能概要

  • Queue追加ボタン: 再生ボタンの隣に (+) みたいなボタンがつく
  • Queue Playlist: ページ右上に [Queue] というボタンがついて、押すとキューのプレイリストが表示される
  • 並び替え: キューのプレイリストのトラック名をドラッグドロップで並び替え可
  • リストから削除: キューのプレイリストのトラック名をリストの外にドロップすると削除される
  • localStorage に保存してるのでページ移動しても保持できる

Install



スクリプトを書く前に、キュー機能はないのかなと調べてたら公式ブログがそのうちつけるみたいなこと言ってて、実装例は発見できず、あれこれ検索してるうちに SoundCloud のソースコードを見てて、
minify された JavaScript なのですが、通常 minify では Object のプロパティ/メソッド名まで1文字とかにしないので、時間はかかったけど解析っぽいことはできました
関数名がわかりやすい。いちいち調べなくても連想できるくらい的確な単語で、追ってるうちに Backbone.js + jQuery + Underscore.js + RequireJS ぽいことがわかってきた
例えばページ遷移は
// soundcloud.com/stream に移動
require('config').get('router').navigate('/stream', true);
で移動できる。ヘッダーの黒いバーはそのままで content だけ書き換わる
Backbone.js なんて触ったことないし、AMD も頻繁に使うわけではないので慣れてない。ソースを見てるうちに魅力的だと思った
非同期処理はほとんどが jQuery.Deferred で行われている。カンマ演算子だらけなのは minify の影響なのかな。
sound model は、polling-model, audible などの複数から継承されている、playlist model は Backbone の Collection から継承されている。 playlist に複数の sound が入り、基本的に複数の soundsCollection なるものを持っていて、トラックの再生が続く感じ。
Sound オブジェクトは、とりあえず以下のようにして生成できる
var sound = new require('models/sound')({
  id: 115078163,
  resource_id: null
});
resource_id は、オブジェクトを定義するごとにインクリメントされる counter のような値っぽいですが、その値までわからなかったので null にしたら一応作れた
id は track id のことで、トラックの URL から track id に変換する必要があります。 最初は sdk を使って id を取得してたのですが、今は API を使っています
http://api.soundcloud.com/resolve.json?url=http://soundcloud.com/matas/hobnotropic&client_id=YOUR_CLIENT_ID
ここで使う client_id は、
var client_id = require('config').get('client_id');
こんな感じで取得できます
トラックの再生や停止などの制御は sound model から play() するのか、audioManager というのを使うのか、それとも play-manager か探ってましたが 今は play-manager を使っています。どれがベストなのかわかりませんが、
require('lib/play-manager').toggleCurrent({
  userInitiated: true
});
これでヘッダーの再生ボタンを押したのと同じ効果が得られます
任意のトラックを再生するには、
var trackId = 115078163;
var ms = require('models/sound');
var pm = require('lib/play-manager');
var sound;

// 初回はこの方法じゃないとダメっぽい
sound = ms.instances.get(ms.hashFn({
  id: trackId,
  resource_type: 'sound'
}));
if (!sound) {
  sound = new ms({
    id: trackId,
    resource_id: null
  });
}
// まだ何も再生してない場合
if (pm.sourceCursor === -1) {
  pm.setInitialSource(sound);
}
pm.playSource(sound);
適切なやり方なのか不明ですが、とりあえずこれで再生できると思います。
トラックの再生は、たぶん本来 playlist に入れて soundsCollection とかで扱うっぽいですが、解析力不足です。
あと、難関だったのが、曲の再生が終わったことを取得する方法です。 最初は <title> の文字列を見て判断してたのですが、なんだか悲しくなったのでやめました。 SoundCloud は、'finish', 'audio:flash_block', 'change:source', 'change:currentSound' などいろいろな Events が定義されていて、 on('finish', ...) とかでいけそうな気がしたんですが、あれこれやって動かなくて play-manager で定義されている 'change:currentSound' を使うことにしました。 でもこれ、本来の次の曲が一瞬 かかってしまうことがあって、現状の問題点となっています。
var pm = require('lib/play-manager');
pm.on('change:currentSound', function(sounds) {
  var prevSound = sounds.prev;
  var currentSound = sounds.current;
  // ...
});
こんな感じにすると、曲が変わった時に拾ってくれます。ただ、自分で再生ボタン押して違う曲にしたときも反応するので、今回の user.js ではめんどくさい感じになってしまいました。
なんだかんだで SoundCloud のソースコードは読み応えがあって、コード解析を通して Backbone.js の勉強になった気もして minify コードを無理に読むのもいいかも
そんなこんなの事情もあって、まだ動作が不安定かもしれないです。 SoundCloud が今の UI のうちは user.js のメンテ続けたいところ。

Contact


0 件のコメント:

コメントを投稿

' ].join(''), ViewSourceDialog: [ '

Source Code

{#META}', '

', '

'].join('') }, Language: null, Labels: { ExpandSource: { Toolbar: { en: '+ expand source', ja: 'コードを展開' } }, ViewSource: { Toolbar: { en: 'view plain', ja: 'ソースを表示' } }, CopyToClipboard: { Toolbar: { en: 'copy to clipboard', ja: 'クリップボードにコピー' }, Alert: { en: 'The code is in your clipboard now.', ja: '\u30af\u30ea\u30c3\u30d7\u30dc\u30fc\u30c9\u306b\u30b3\u30d4\u30fc\u3057\u307e\u3057\u305f' } }, PrintSource: { Toolbar: { en: 'print', ja: '印刷' }, Alert: { en: 'Printing...', ja: '\u5370\u5237\u4e2d\u2026' } }, About:{ Toolbar: { en: '?', ja: '?' } } }, ClipboardSwf:null, Version:'1.5.1 (Based)' } }; dp.sh.Utils.Extend = function(o, c){ o = o || this; if (o && c) for (var p in c) o[p] = c[p]; return o; }; dp.sh.Utils.Each = function(object, callback, args){ var i, len = object.length; if (!!object == !len) for (i in object) if (callback.apply(object[i], args || [i, object[i]]) === false) break; else for (i = 0; i < len; i++) if (callback.apply(object[i], args || [i, object[i]]) === false) break; return object; }; dp.sh.Language = /(?:ja|jp|japanese)/i.test(navigator.language || navigator.userLanguage || navigator.browserLanguage || navigator.systemLanguage) ? 'ja' : 'en'; dp.SyntaxHighlighter=dp.sh; dp.sh.Toolbar.Commands={ ExpandSource:{ label: dp.sh.Labels.ExpandSource.Toolbar[dp.sh.Language], check:function(highlighter){return highlighter.collapse;}, func:function(sender,highlighter){ sender.parentNode.removeChild(sender); highlighter.div.className=highlighter.div.className.replace('collapsed',''); } }, ViewSource:{ label: dp.sh.Labels.ViewSource.Toolbar[dp.sh.Language], func:function(sender,highlighter){ var code = dp.sh.Utils.FixForBlogger(highlighter.originalCode).replace(/').replace(/&/g,'&'); if(window.clipboardData){ window.clipboardData.setData('text',code); }else if(dp.sh.ClipboardSwf!=null){ var flashcopier=highlighter.flashCopier; if(flashcopier==null){ flashcopier=document.createElement('div'); highlighter.flashCopier=flashcopier; highlighter.div.appendChild(flashcopier); } flashcopier.innerHTML=''; } alert(dp.sh.Labels.CopyToClipboard.Alert[dp.sh.Language]); } }, PrintSource:{ label: dp.sh.Labels.PrintSource.Toolbar[dp.sh.Language], func:function(sender,highlighter){ var iframe=document.createElement('IFRAME'); var doc=null; iframe.style.cssText='position:absolute;width:0px;height:0px;left:-500px;top:-500px;'; document.body.appendChild(iframe); doc=iframe.contentWindow.document; dp.sh.Utils.CopyStyles(doc,window.document); doc.write('

'+highlighter.div.innerHTML+'

'); doc.close(); iframe.contentWindow.focus(); iframe.contentWindow.print(); alert(dp.sh.Labels.PrintSource.Alert[dp.sh.Language]); document.body.removeChild(iframe); } }, About:{ label: dp.sh.Labels.About.Toolbar[dp.sh.Language], func:function(highlighter){ var win = dp.sh.Utils.OpenWindow('', '_blank', { dialog: 1, width: 320, height: 240, location: 0, resizable: 1, menubar: 0, scrollbars: 0 }); var doc = win.document; dp.sh.Utils.CopyStyles(doc, window.document); doc.write(dp.sh.Utils.SetTemplateParams(dp.sh.Strings.AboutDialog, { META: dp.sh.Utils.GetFaviconTag(true), VERSION: dp.sh.Version })); doc.close(); win.focus(); } } }; dp.sh.Toolbar.Create=function(highlighter){ var div=document.createElement('DIV');div.className='tools'; for(var name in dp.sh.Toolbar.Commands){ var cmd=dp.sh.Toolbar.Commands[name]; if(cmd.check!=null&&!cmd.check(highlighter)) continue; div.innerHTML+=''+cmd.label+''; } return div; } dp.sh.Toolbar.Command=function(name,sender){ var n=sender;while(n!=null&&n.className.indexOf('dp-highlighter')==-1) n=n.parentNode; if(n!=null) dp.sh.Toolbar.Commands[name].func(sender,n.highlighter); } dp.sh.Utils.CopyStyles=function(destDoc,sourceDoc){ var ret, styles, i, j, css, rule, rules, styleSheet, styleSheets, links; if (dp.sh.isBloggerMode) { css = document.getElementById("dp-highlighter-css"); if (css) { style = css.innerHTML || css.childNodes[0].nodeValue || css.textContent; } if (!style) { styles = []; styleSheets = document.styleSheets; for (i = 0; i < styleSheets.length; i++) { try { styleSheet = styleSheets[i]; rules = styleSheet.rules || styleSheet.cssRules; css = []; for (j = 0; j < rules.length; j++) { rule = rules[j]; if (/[.#]dp-/.test(rule.selectorText)) { css.push([ rule.selectorText + '{', rule.style.cssText, '}' ].join("\n")); } } styles.push(css.join('')); } catch (e) {} } style = styles.join("\n"); } ret = ''; } else { styles = []; links = sourceDoc.getElementsByTagName('link'); for (i = 0; i < links.length; i++) { if (links[i].href && (links[i].rel || '').toLowerCase() == 'stylesheet') { styles.push(''); } } ret = styles.join("\n"); } destDoc.write(ret); return ret; } dp.sh.Utils.FixForBlogger=function(str){ return (dp.sh.isBloggerMode==true)?str.replace(/
|<br\s*\/?>/gi,'\n'):str; } dp.sh.Utils.GetFaviconTag = function(asString){ var i, ret, link, links = document.getElementsByTagName('link'); for (i = links.length - 1; i >= 0; --i) { link = links[i]; if (/(?:shortcut\s*)?icon/i.test(link.rel )) { ret = link; break; } } if (asString) { if (!ret) { ret = ''; } else { ret = [ '' ].join(' '); } } return ret; }; dp.sh.Utils.SetTemplateParams = function(template, params){ dp.sh.Utils.Each(params || {}, function(key, val){ template = template.replace('{#' + key + '}', val); }); return template; }; dp.sh.Utils.OpenWindow = function(url, name, options){ var opts = [], defaults = { width: 500, height: 400, left: 400, top: 300, menubar: 0, toolbar: 0, location: 0, titlebar: 0, status: 1, resizable: 1, scrollbars: 1, channelmode: 0, fullscreen: 0 }; options = dp.sh.Utils.Extend(defaults, options || {}); options.left = Math.ceil((screen.width - options.width) / 2); options.top = Math.ceil((screen.height - options.height) / 2); dp.sh.Utils.Each(options, function(key, val){ opts.push(key + '=' + val); }); return window.open(url, name, opts.join(',')); }; dp.sh.RegexLib={ MultiLineCComments:new RegExp('/\\*[\\s\\S]*?\\*/','gm'), SingleLineCComments:new RegExp('//.*$','gm'), SingleLinePerlComments:new RegExp('#.*$','gm'), DoubleQuotedString:new RegExp('"(?:\\.|(\\\\\\")|[^\\""\\n])*"','g'), SingleQuotedString:new RegExp("'(?:\\.|(\\\\\\')|[^\\''\\n])*'",'g'), MultiLineQuotedString:/("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/g }; dp.sh.Match=function(value,index,css) {this.value=value;this.index=index;this.length=value.length;this.css=css;} dp.sh.Highlighter=function() {this.noGutter=false;this.addControls=true;this.collapse=false;this.tabsToSpaces=true;this.wrapColumn=80;this.showColumns=true;} dp.sh.Highlighter.SortCallback=function(m1,m2) {if(m1.indexm2.index) return 1;else {if(m1.lengthm2.length) return 1;} return 0;} dp.sh.Highlighter.prototype.CreateElement=function(name) {var result=document.createElement(name);result.highlighter=this;return result;} dp.sh.Highlighter.prototype.GetMatches=function(regex,css) {var index=0;var match=null;while((match=regex.exec(this.code))!=null) this.matches[this.matches.length]=new dp.sh.Match(match[0],match.index,css);} dp.sh.Highlighter.prototype.AddBit=function(str,css) {if(str==null||str.length==0) return;var span=this.CreateElement('SPAN');str=str.replace(/\u0020/g,' ');str=str.replace(/');if(css!=null) {if((/br/gi).test(str)) {var lines=str.split(' 
');for(var i=0;ic.index)&&(match.index/gi,'\n');var lines=html.split('\n');if(this.addControls==true) this.bar.appendChild(dp.sh.Toolbar.Create(this));if(this.showColumns) {var div=this.CreateElement('div');var columns=this.CreateElement('div');var showEvery=10;var i=1;while(i<=150) {if(i%showEvery==0) {div.innerHTML+=i;i+=(i+'').length;} else {div.innerHTML+='·';i++;}} columns.className='columns';columns.appendChild(div);this.bar.appendChild(columns);} for(var i=0,lineIndex=this.firstLine;i0;i++){ if(Trim(lines[i]).length==0)continue; var matches=regex.exec(lines[i]); if(matches!=null&&matches.length>0)min=Math.min(matches[0].length,min); } if(min>0) for(var i=0;i