1 patch for repository /home/josip/bin/tahoe-tmp: Sat Jul 10 20:57:04 CEST 2010 josip.lisec@gmail.com * add-music-player New patches: [add-music-player josip.lisec@gmail.com**20100710185704 Ignore-this: c29dc0709640abd2e33cfd119b2681f ] { adddir ./contrib/musicplayer addfile ./contrib/musicplayer/INSTALL hunk ./contrib/musicplayer/INSTALL 1 +=== Installing Music Player for Tahoe (codename 'Daaw') === + +== Maths and Systems Theory quiz == +If you already have a 'build' directory, feel free to skip this step. + +To build player's code you'll have to do a not-so-simple +operation of computing file dependencies, compressing variable names in JavaScript +code and stitching them all into one file. + +I strongly hope that you took advanced Maths, Systems Theory +and computing related courses. + +Just in case you haven't, you can type in next line into your shell: + $ python manage.py build + running build + Calculating dependencies... + Compressing ... + ... + Done! + +Bravo, you're done! (just make sure you have a 'build' directory) + +(And if you're one of those who prefer to do it by-hand (and keyboard), +this file isn't a place for you.) + +== Battle for Configuration File == +Player's configuration file is a real beast on its own, +and in order to edit it we must prepare ourselves really good, +otherwise, we're doomed (actually, only you are )! + +Read next few steps carefully, the beast is just around the corner! + +1. Create two dirnodes on your Tahoe-LAFS server, one will be used for storing + all your music files and the other one for syncing settings between multiple + computers. + + Just in case you've forgotten how to create Tahoe dirnodes, run this from your + shell: + $ tahoe mkdir music + + $ tahoe mkdir settings + + + (make sure Tahoe-LAFS is running on your computer before issuing those commands) + +2. Take a big breath, as we're about to open example configuration file! + +3. Yep, now open the 'config.example.json' file in your favourite text editor. + Now quickly, we have to replace her evil genes with a good ones, + find following line in her DNA sequence: + + "music_cap": "", + "settings_cap": "" + + and quickly replace with as well as + with . + + If you're still here, congrats! + + (The truth about s is that your Tahoe-LAFS installation actually + knows how to re-sequence DNA of living beings, and we don't want others to + find out about that and use it in evil purposes, don't we?) + + Now save the new genes under name of 'config.json'. + +== The Critical Step == +After we've conquered the beast of configuration file we're ready to +upload the player to the Tahoe! + +To do that, just copy the 'build' directory to 'public_html' directory of your +Tahoe storage node (usually ~/.tahoe). +Note that 'public_html' directory is probably missing, +so you'll have to create it on your own. + + $ mkdir -p ~/.tahoe/public_html/musicplayer + $ cp -r build/ ~/.tahoe/public_html/musicplayer + +WARNING: If you don't perform next step exactly as +you're instructed, the whole process could fail and you'll +have to start all over! + +Now, stand up, and with evident excitement on your face, +say the following phrase: + "Yay! It's working!" + +== Fin == +You can now upload your music to the dirnode and +launch music player by typing this URL into your web browser: + http://localhost:3456/static/musicplayer + +If it appears that something isn't working, it probably means +that you haven't read 'The Critical Step' carefully enough. + +We hope you're going to enjoy your music even more with Music Player for Tahoe-LAFS! addfile ./contrib/musicplayer/manage.py hunk ./contrib/musicplayer/manage.py 1 +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import os, shutil, sys, subprocess, re +from time import sleep +from setuptools import setup +from setuptools import Command + +CLOSURE_COMPILER_PATH = 'tools/closure-compiler-20100514/compiler.jar' + +class JSDepsBuilder: + """ + Looks for + //#require "file.js" + and + //#require + lines in JavaScript files and creates a file with all the required files. + """ + requires_re = re.compile('^//#require ["|\<](.+)["|\>]$', re.M) + + def __init__(self, root_directory): + self.files = {} + self.included = [] + self.root = root_directory + + self.scan() + + def scan(self): + for (dirname, dirs, files) in os.walk(self.root): + for filename in files: + if filename.endswith('.js'): + self.detect_requires(os.path.join(dirname, filename)) + + def detect_requires(self, path): + reqs = [] + script_file = open(path, 'r') + script = script_file.read() + script_file.close() + + reqs = re.findall(JSDepsBuilder.requires_re, script) + for i in range(len(reqs)): + req_path = os.path.join(self.root, reqs[i]) + reqs[i] = req_path + if not os.path.isdir(req_path) and not req_path.endswith('.js'): + reqs[i] += '.js' + + #if len(reqs): + # print '%s depends on:' % os.path.basename(path) + # print '\t', '\n\t'.join(reqs) + + self.files[path] = reqs + + def parse(self, path): + if path in self.included: + return '' + if not path.endswith('.js'): + # TODO: If path points to a directory, require all the files within that directory. + return '' + + def insert_code(match): + req_path = os.path.join(self.root, match.group(1)) + if not req_path in self.included: + if not os.path.isfile(req_path): + raise Exception('%s requires non existing file: %s' % (path, req_path)) + + return self.parse(req_path) + + script_file = open(path, 'r') + script = script_file.read() + script_file.close() + script = re.sub(JSDepsBuilder.requires_re, insert_code, script) + self.included.append(path) + + return script + + def write_to_file(self, filename, root_file = 'Application.js'): + output = open(filename, 'w+') + self.included = [] + output.write(self.parse(os.path.join(self.root, root_file))) + output.close() + + def print_script_tags(self, root_file = 'Application.js'): + self.included = [] + self.parse(os.path.join(self.root, root_file)) + + for filename in self.included: + print '' % filename + +class Build(Command): + description = 'builds whole application into build directory' + user_options = [ + ('compilation-level=', 'c', 'compilation level for Google\'s Closure compiler.'), + ] + + def initialize_options(self): + self.compilation_level = 'SIMPLE_OPTIMIZATIONS' + + def finalize_options(self): + compilation_levels = [ + 'SIMPLE_OPTIMIZATIONS', 'WHITESPACE_ONLY', 'ADVANCED_OPTIMIZATIONS', 'NONE' + ] + + self.compilation_level = self.compilation_level.upper() + if not self.compilation_level in compilation_levels: + self.compilation_level = compilation_levels[0] + + def run(self): + if os.path.isdir('build'): + shutil.rmtree('build') + + shutil.copytree('src/resources', 'build/resources') + shutil.copytree('src/plugins', 'build/plugins') + shutil.copy('src/config.example.json', 'build/') + shutil.copy('src/index.html', 'build/') + + shutil.copytree('src/libs/vendor/soundmanager/swf', 'build/resources/flash') + shutil.copy('src/libs/vendor/persist-js/persist.swf', 'build/resources/flash') + + os.makedirs('build/js/libs') + os.makedirs('build/js/workers') + shutil.copy('src/libs/vendor/browser-couch/js/worker-map-reducer.js', 'build/js/workers/map-reducer.js') + + print 'Calculating dependencies...' + appjs = JSDepsBuilder('src/') + appjs.write_to_file('build/app.js') + self._compress('build/js/app.js', ['build/app.js']) + os.remove('build/app.js') + + # Libraries used by web workers + self._compile_js('src/libs', 'build/js/workers/env.js', files = [ + 'vendor/mootools-1.2.4-core-server.js', + 'vendor/mootools-1.2.4-request.js', + + 'util/util.js', + 'util/BinaryFile.js', + 'util/ID3.js', + 'util/ID3v2.js', + 'util/ID3v1.js', + 'TahoeObject.js' + ]) + + # Compressing the workers themselves + self._compile_js('src/workers', 'build/js/workers/', join = False) + + print 'Done!' + + def _compile_js(self, source, output_file, files = None, join = True): + js_files = files + if not js_files: + js_files = [] + for filename in os.listdir(source): + if filename.endswith('.js'): + js_files.append(os.path.join(source, filename)) + + js_files.sort() + else: + js_files = [os.path.join(source, path) for path in files] + + if join: + self._compress(output_file, js_files) + else: + for js_file in js_files: + self._compress(output_file + os.path.basename(js_file), [js_file]) + + def _compress(self, output_file, files): + print 'Compressing %s...' % output_file + + if self.compilation_level == 'NONE': + output_file = open(output_file, 'a') + for filename in files: + f = open(filename) + output_file.write(f.read()) + output_file.write('\n') + f.close() + + output_file.close() + else: + args = [ + 'java', + '-jar', CLOSURE_COMPILER_PATH, + '--warning_level', 'QUIET', + '--compilation_level', self.compilation_level, + '--js_output_file', output_file] + + for filename in files: + args.append('--js') + args.append(filename) + + subprocess.call(args) + +class Watch(Build): + description = 'watches src directory for changes and runs build command when they occur' + + def run(self): + self.dirs = {} + + while True: + if self._watch_dir(): + print 'Watching for changes...' + sleep(5) + + def _watch_dir(self): + should_build = False + for (root, dirs, files) in os.walk('src'): + for file in files: + path = root + '/' + file + mtime = os.stat(path).st_mtime + + if not path in self.dirs: + self.dirs[path] = 0 + + if self.dirs[path] != mtime: + should_build = True + self.dirs[path] = mtime + print '\t* ' + path + + if should_build: + Build.run(self) + return True + else: + return False + + +class Package(Build): + description = 'builds application and creates a .tar.gz archive' + user_options = [] + + def initalize_options(self): + pass + def finalize_options(self): + pass + + def run(self): + Build.run(self) + + +class Install(Command): + description = 'copies application to storage node\'s public_html and writes configuration files' + user_options = [] + + def initalize_options(self): + pass + def finalize_options(self): + pass + def run(self): + pass + +class Docs(Command): + description = 'generate documentation' + user_options = [] + + def initialize_options(self): + pass + def finalize_options(self): + pass + + def run(self): + if os.path.isdir('docs'): + shutil.rmtree('docs') + + args = ['pdoc', '-o', 'docs'] + + root_dirs = [ + 'src/', 'src/libs', 'src/libs/ui', 'src/libs/db', + 'src/libs/util', 'src/controllers', 'src/doctemplates' + ] + for root_dir in root_dirs: + for filename in os.listdir(root_dir): + if filename.endswith('.js'): + args.append(os.path.join(root_dir, filename)) + + subprocess.call(args) + +setup( + name = 'tahoe-music-player', + cmdclass = { + 'build': Build, + 'install': Install, + 'watch': Watch, + 'docs': Docs + } +) adddir ./contrib/musicplayer/src addfile ./contrib/musicplayer/src/Application.js hunk ./contrib/musicplayer/src/Application.js 1 +//#require "libs/vendor/mootools-1.2.4-core-ui.js" +//#require "libs/vendor/mootools-1.2.4.4-more.js" + +/** + * da + * + * The root namespace. Shorthand for '[Daaw](http://en.wikipedia.org/wiki/Lake_Tahoe#Native_people)'. + * + **/ +if(typeof da === "undefined") + this.da = {}; + +//#require "libs/db/PersistStorage.js" +//#require "libs/db/DocumentTemplate.js" +//#require "libs/util/Goal.js" + +(function () { +var BrowserCouch = da.db.BrowserCouch, + PersistStorage = da.db.PersistStorage, + Goal = da.util.Goal; + +/** section: Controllers + * class da.app + * + * The main controller. All methods are public. + **/ +da.app = { + /** + * da.app.caps -> Object + * Object with `music` and `settings` properties, ie. the contents of `config.json` file. + **/ + caps: {}, + + initialize: function () { + this.startup = new Goal({ + checkpoints: ["domready", "settings_db", "caps", "data_db", "soundmanager"], + onFinish: this.ready.bind(this) + }); + + BrowserCouch.get("settings", function (db) { + da.db.SETTINGS = db; + if(!db.getLength()) + this.loadInitialSettings(); + else { + this.startup.checkpoint("settings_db"); + this.getCaps(); + } + }.bind(this), new PersistStorage("tahoemp_settings")); + + BrowserCouch.get("data", function (db) { + da.db.DEFAULT = db; + this.startup.checkpoint("data_db"); + }.bind(this), new PersistStorage("tahoemp_data")); + }, + + loadInitialSettings: function () { + new Request.JSON({ + url: "config.json", + noCache: true, + + onSuccess: function (data) { + da.db.SETTINGS.put([ + {id: "music_cap", type: "Setting", group_id: "caps", value: data.music_cap}, + {id: "settings_cap", type: "Setting", group_id: "caps", value: data.settings_cap} + ], function () { + this.startup.checkpoint("settings_db"); + + this.caps.music = data.music_cap, + this.caps.settings = data.settings_cap; + + this.startup.checkpoint("caps"); + }.bind(this)); + }.bind(this), + + onFailure: function () { + alert("You're missing a config.json file! See docs on how to set it up."); + var showSettings = function () { + da.controller.Settings.showGroup("caps"); + }; + + if(da.controller.Settings) + showSettings(); + else + da.app.addEvent("ready.controller.Settings", showSettings); + } + }).get() + }, + + getCaps: function () { + // We can't use DocumentTemplate.Setting here as the class + // is usually instantiated after the call to this function. + da.db.SETTINGS.view({ + id: "caps", + + map: function (doc, emit) { + if(doc && doc.type === "Setting" && doc.group_id === "caps") + emit(doc.id, doc.value); + }, + + finished: function (result) { + this.caps.music = result.getRow("music_cap"); + this.caps.settings = result.getRow("settings_cap"); + if(!this.caps.music.length || !this.caps.music.length) + this.loadInitialSettings(); + else + this.startup.checkpoint("caps"); + }.bind(this), + + updated: function (result) { + if(result.findRow("music_cap") !== -1) { + this.caps.music = result.getRow("music_cap"); + da.controller.CollectionScanner.scan(this.caps.music); + } + if(result.findRow("settings_cap") !== -1) + this.caps.settings = result.getRow("settings_cap") + }.bind(this) + }); + }, + + /** + * da.app.ready() -> undefined + * fires ready + * + * Called when all necessary components are initialized. + **/ + ready: function () { + $("loader").destroy(); + $("panes").setStyle("display", "block"); + + if(da.db.DEFAULT.getLength() === 0) + da.controller.CollectionScanner.scan(); + + this.fireEvent("ready"); + } +}; +$extend(da.app, new Events()); + +da.app.initialize(); + +window.addEvent("domready", function () { + da.app.startup.checkpoint("domready"); +}); + +})(); + +//#require +//#require addfile ./contrib/musicplayer/src/config.example.json hunk ./contrib/musicplayer/src/config.example.json 1 +{ + "music_cap": "URI:DIR2:yz6mfqhmnog7jti65rblzwrdxe:7pyqgnikbn4iklmcst6n7hwgmkoim24dfxfm3y4374oi755yhyta", + "settings_cap": "URI:DIR2:i564xjoawurnbrkaevyzdufqzi:mqnvdbqzia3euvorf2dwte6jdb6hnmwlxa4i7syw63kly4ubndda" +} adddir ./contrib/musicplayer/src/controllers addfile ./contrib/musicplayer/src/controllers/CollectionScanner.js hunk ./contrib/musicplayer/src/controllers/CollectionScanner.js 1 +//#require "libs/util/Goal.js" +//#require "doctemplates/Song.js" +//#require "doctemplates/Artist.js" +//#require "doctemplates/Album.js" + +(function () { +var DocumentTemplate = da.db.DocumentTemplate, + Song = DocumentTemplate.Song, + Artist = DocumentTemplate.Artist, + Album = DocumentTemplate.Album, + Goal = da.util.Goal; + +/** section: Controllers + * class CollectionScanner + * + * Controller which operates with [[Scanner]] and [[Indexer]] WebWorkers. + * + * #### Notes + * This is private class. + * Public interface is provided via [[da.controller.CollectionScanner]]. + **/ +var CollectionScanner = new Class({ + /** + * new CollectionScanner() + * + * Starts a new scan using [[Application.caps.music]] as root directory. + **/ + initialize: function (root) { + this.indexer = new Worker("js/workers/indexer.js"); + this.indexer.onmessage = this.onIndexerMessage.bind(this); + + this.scanner = new Worker("js/workers/scanner.js"); + this.scanner.onmessage = this.onScannerMessage.bind(this); + + this.scanner.postMessage(root || da.app.caps.music); + + this.finished = false; + + this._goal = new Goal({ + checkpoints: ["scanner", "indexer"], + onFinish: function () { + this.finished = true; + this.terminate() + }.bind(this) + }) + }, + + /** + * CollectionScanner#finished -> true | false + **/ + finished: false, + + /** + * CollectionScanner#terminate() -> undefined + * + * Instantly kills both workers. + **/ + terminate: function () { + this.indexer.terminate(); + this.scanner.terminate(); + }, + + onScannerMessage: function (event) { + var cap = event.data; + if(cap === "**FINISHED**") { + this._goal.checkpoint("scanner"); + return; // this.scanner.terminate(); + } + + if(da.db.DEFAULT.views.Song.view.findRow(cap) === -1) + this.indexer.postMessage(cap); + }, + + onIndexerMessage: function (event) { + if(event.data === "**FINISHED**") { + this._goal.checkpoint("indexer"); + return; //this.indexer.terminate(); + } + + // Lots of async stuff is going on, a short summary would look something like: + // 1. find or create artist with given name and save its id + // to artist_id. + // 2. look for an album with given artist_id (afterCheckpoint.artist) + // 3. save the album data. + // 4. look for song with given id and save the new data. + + var tags = event.data, + album_id, artist_id, + links = new Goal({ + checkpoints: ["artist", "album"], + onFinish: function () { + Song.findOrCreate({ + properties: {id: tags.id}, + onSuccess: function (song) { + song.update({ + title: tags.title, + track: tags.track, + year: tags.year, + lyrics: tags.lyrics, + genre: tags.genere, + artist_id: artist_id, + album_id: album_id + }); + } + }); + }, + + afterCheckpoint: { + artist: function () { + Album.findOrCreate({ + properties: {artist_id: artist_id, title: tags.album}, + onSuccess: function (album, wasCreated) { + album_id = album.id; + if(wasCreated) + album.save(function () { links.checkpoint("album"); }) + else + links.checkpoint("album"); + } + }); + } + } + }); + + Artist.findOrCreate({ + properties: {title: tags.artist}, + onSuccess: function (artist, was_created) { + artist_id = artist.id; + if(was_created) + artist.save(function () { links.checkpoint("artist"); }); + else + links.checkpoint("artist"); + } + }); + } +}); + +var SCANNER; +/** + * da.controller.CollectionScanner + **/ +da.controller.CollectionScanner = { + /** + * da.controller.CollectionScanner.scan() -> undefined + * Starts scanning music directory + * + * Part of public API. + **/ + scan: function (cap) { + if(!SCANNER || (SCANNER && SCANNER.finished)) + SCANNER = new CollectionScanner(cap); + }, + + /** + * da.controller.CollectionScanner.isScanning() -> true | false + * + * Part of public API. + **/ + isScanning: function () { + return SCANNER && !SCANNER.finished; + } +}; + +da.app.fireEvent("ready.controller.CollectionScanner", [], 1); +})(); addfile ./contrib/musicplayer/src/controllers/Navigation.js hunk ./contrib/musicplayer/src/controllers/Navigation.js 1 +//#require "libs/ui/Menu.js" +(function () { +var Menu = da.ui.Menu; + +/** section: Controllers + * class NavigationColumnContainer + * + * Class for managing column views. + * + * #### Notes + * This class is private. + * Public interface is accessible via [[da.controller.Navigation]]. + **/ + +var NavigationColumnContainer = new Class({ + /** + * new NavigationColumnContainer(options) + * - options.columnName (String): name of the column. + * - options.container (Element): container element. + * - options.header (Element): header element. + * - options.menu (UI.Menu): [[UI.Menu]] instance. + * + * Renders column and adds self to the [[da.controller.Navigation.activeColumns]]. + **/ + + /** + * NavigationColumnContainer#column_name -> String + * Name of the column. + **/ + + /** + * NavigationColumnContainer#column -> NavigationColumn + * `column` here represents the list itself. + **/ + + /** + * NavigationColumnContainer#parent_column -> NavigationColumnContainer + * Usually column which created _this_ one. Visually, its the one to the left of _this_ one. + **/ + + /** + * NavigationColumnContainer#header -> Element + * Header element. It's an `a` tag with an `span` element. + * `a` tag has `column_header`, while `span` tag has `column_title` CSS class. + **/ + + /** + * NavigationColumnContainer#menu -> UI.Menu + * Container's [[UI.Menu]]. It can be also accesed with: + * + * this.header.retrieve("menu") + **/ + + /** + * NavigationColumnContainer#_el -> Element + * [[Element]] of the actual container. Has `column_container` CSS class. + **/ + initialize: function (options) { + this.column_name = options.columnName; + this.parent_column = Navigation.activeColumns[Navigation.activeColumns.push(this) - 2]; + + if(!(this._el = options.container)) + this.createContainer(); + + if(!(this.header = options.header)) + this.createHeader(); + + this.column = new Navigation.columns[this.column_name]({ + filter: options.filter, + parentElement: this._el + }); + Navigation.adjustColumnSize(this.column); + + if(!(this.menu = options.menu)) + this.createMenu(); + + if(this.column.constructor.filters && this.column.constructor.filters.length) + this.column.addEvent("click", this.listItemClick.bind(this)); + + var first_item = this.column._el.getElement(".column_item"); + if(first_item) + first_item.focus(); + }, + + /** + * NavigationColumnContainer#createContainer() -> this + * + * Creates container element in `navigation_pane` [[Element]]. + **/ + createContainer: function () { + $("navigation_pane").grab(this._el = new Element("div", { + id: this.column_name + "_column_container", + "class": "column_container no_selection" + })); + + return this; + }, + + /** + * NavigationColumnContainer#createHeader() -> this + * + * Creates header element and attaches click event. Element is added to [[NavigationColumnContainer#toElement]]. + **/ + createHeader: function () { + this.header = new Element("a", { + "class": "column_header", + href: "#" + }); + + this.header.addEvent("click", function (e) { + var menu = this.retrieve("menu"); + if(menu) + menu.show(e); + }); + + this._el.grab(this.header.grab(new Element("span", { + html: this.column_name, + "class": "column_title" + }))); + + return this; + }, + + /** + * NavigationColumnContainer#createMenu() -> this | false + * + * Creates menu for current column if it has filters. + * [[da.ui.Menu]] instance is stored to `header` element with `menu` key. + **/ + createMenu: function () { + var filters = this.column.constructor.filters, + items = {}; + + if(!filters || !filters.length) + return false; + + items[this.column_name] = {html: this.column.constructor.title, "class": "checked", href: "#"}; + for(var n = 0, m = filters.length; n < m; n++) + items[filters[n]] = {html: filters[n], href: "#"}; + + this.menu = new Menu({ + items: items + }); + + this.menu._el.addClass("navigation_menu"); + this.header.store("menu", this.menu); + + this.menu.addEvent("show", function () { + var header = this.header; + header.addClass("active"); + header.retrieve("menu")._el.style.width = header.getWidth() + "px"; + }.bind(this)); + + this.menu.addEvent("hide", function () { + this.header.removeClass("active"); + }.bind(this)); + + if(filters && filters.length) + this.menu.addEvent("click", this.menuItemClick.bind(this.parent_column || this)); + + return this; + }, + + /** + * NavigationColumnContainer#menuItemClick(filterName, event, element) -> undefined + * - filterName (String): id of the menu item. + * - event (Event): DOM event. + * - element (Element): clicked `Element`. + * + * Function called on menu click. If `filterName` is name of an actual filter then + * list in current column is replaced with a new one (provided by that filter). + **/ + menuItemClick: function (filter_name, event, element) { + if(!Navigation.columns[filter_name]) + return; + + var parent = this.filter_column._el, + header = this.filter_column.header, + menu = this.filter_column.menu; + + // we need to keep the menu and header, since + // all we need to do is to replace the list + this.filter_column.menu = null; + this.filter_column._el = null; + this.filter_column.destroy(); + + this.filter_column = new NavigationColumnContainer({ + columnName: filter_name, + filter: this.filter_column.column.options.filter, + container: parent, + header: header, + menu: menu + }); + + if(menu.last_clicked) + menu.last_clicked.removeClass("checked"); + element.addClass("checked"); + + header.getElement(".column_title").empty().appendText(filter_name); + }, + + /** + * NavigationColumnContainer#listItemClick(item) -> undefined + * - item (Object): clicked item. + * + * Creates a new column after this one with applied filter. + **/ + listItemClick: function (item) { + if(this.filter_column) + this.filter_column.destroy(); + + this.filter_column = new NavigationColumnContainer({ + columnName: this.column.constructor.filters[0], + filter: this.column.createFilter(item) + }); + }, + + /** + * NavigationColumnContainer#destroy() -> this + * + * Destroys this column (including menu). + * Removes itself from [[da.controller.Navigation.activeColumns]]. + **/ + destroy: function () { + if(this.filter_column) { + this.filter_column.destroy(); + delete this.filter_column; + } + if(this.menu) { + this.menu.destroy(); + delete this.menu; + } + this.column.destroy(); + delete this.column; + if(this._el) { + this._el.destroy(); + delete this._el; + } + + var index = Navigation.activeColumns.indexOf(this); + delete Navigation.activeColumns[index]; + Navigation.activeColumns = Navigation.activeColumns.clean(); + + return this; + }, + + /** + * NavigationColumnContainer#toElement() -> Element + **/ + toElement: function () { + return this._el; + } +}); + +/** section: Controllers + * da.controller.Navigation + **/ +var Navigation = { + // This is not really a class, but PDoc refuses to generate docs otherwise. + /** + * da.controller.Navigation.columns + * + * Contains all known columns. + * + * #### Notes + * Use [[da.controller.Navigation.registerColumn]] to add new ones, + * *do not* add them manually. + **/ + columns: {}, + + /** + * da.controller.Navigation.activeColumns -> [NavigationColumnContainer, ...] + * + * Array of currently active columns. + * The first column is always [[da.controller.Navigation.columns.Root]]. + **/ + activeColumns: [], + + initialize: function () { + var root_column = new NavigationColumnContainer({columnName: "Root"}); + root_column.menu.removeItem("Root"); + root_column.menu.addItems({ + separator_1: Menu.separator, + search: {html: "Search", href:"#"}, + settings: {html: "Settings", href:"#", events: {click: da.controller.Settings.show}}, + help: {html: "Help", href:"#"} + }); + + var artists_column = new NavigationColumnContainer({ + columnName: "Artists", + menu: root_column.menu + }); + artists_column.header.store("menu", root_column.menu); + root_column.filter_column = artists_column; + root_column.header = artists_column.header; + + this._header_height = root_column.header.getHeight(); + window.addEvent("resize", function () { + var columns = Navigation.activeColumns, + n = columns.length, + windowHeight = window.getHeight(); + + while(n--) + columns[n].column._el.setStyle("height", windowHeight - this._header_height); + }.bind(this)); + + window.fireEvent("resize"); + }, + + /** + * da.controller.Navigation.adjustColumnSize(column) -> undefined + * - column (da.ui.NavigationColumn): column which needs size adjustment. + * + * Adjusts column's height to window. + **/ + adjustColumnSize: function (column) { + column._el.setStyle("height", window.getHeight() - this._header_height); + }, + + /** + * da.controller.Navigation.registerColumn(name, filters, column) -> undefined + * - name (String): name of the column. + * - filters (Array): names of the columns which can accept filter created (with [[da.ui.NavigationColumn#createFilter]]) by this one. + * - column (da.ui.NavigationColumn): column class. + * + * `name` (renamed to `title`) and `filters` will be added to `column` as static methods. + **/ + registerColumn: function (name, filters, col) { + col.extend({ + title: name, + filters: filters || [] + }); + + this.columns[name] = col; + if(name !== "Root") + this.columns.Root.filters.push(name); + } +}; + +da.controller.Navigation = Navigation; +da.app.addEvent("ready", function () { + Navigation.initialize(); +}); + +//#require "controllers/default_columns.js" + +da.app.fireEvent("ready.controller.Navigation", [], 1); +})(); addfile ./contrib/musicplayer/src/controllers/Player.js hunk ./contrib/musicplayer/src/controllers/Player.js 1 +//#require "libs/vendor/soundmanager/script/soundmanager2.js" + +(function () { +/** section: Controllers + * class Player + * + * Player interface and playlist managment class. + * + * #### Notes + * This class is private. + * Public interface is provided through [[da.controller.Player]]. + **/ +var Player = { + /** + * new Player() + * Sets up soundManager2 and initializes player's interface. + **/ + initialize: function () { + var path = location.protocol + "//" + location.host + location.pathname; + $extend(soundManager, { + useHTML5Audio: false, + url: path + 'resources/flash/', + debugMode: false, + debugFlash: false + }); + + soundManager.onready(function () { + da.app.startup.checkpoint("soundmanager"); + }); + } +}; + +Player.initialize(); + +/** + * da.controller.Player + **/ +da.controller.Player = { + /** + * da.controller.Player.play([uri]) -> Boolean + * - uri (String): location of the audio. + * + * If `uri` is omitted and there is paused playback, then the paused + * file will resume playing. + **/ + play: function (uri) { return false }, + + /** + * da.controller.Player.pause() -> Boolean + * + * Pauses the playback (if any). + **/ + pause: function () { return false }, + + /** + * da.controller.Player.queue(uri) -> Boolean + * - uri (String): location of the audio file. + * + * Adds file to the play queue and plays it as soon as currently playing + * file finishes playing (if any). + **/ + queue: function (uri) { return false } +}; + +da.app.fireEvent("ready.controller.Player", [], 1); + +})(); addfile ./contrib/musicplayer/src/controllers/Settings.js hunk ./contrib/musicplayer/src/controllers/Settings.js 1 +//#require "doctemplates/Setting.js" +//#require "libs/ui/NavigationColumn.js" +//#require "libs/ui/Dialog.js" + +(function () { +/** section: Controllers + * class Settings + * + * #### Notes + * This is private class. + * Public interface is accessible via [[da.controller.Settings]]. + **/ + +var Dialog = da.ui.Dialog, + Setting = da.db.DocumentTemplate.Setting; + +var GROUPS = [{ + id: "caps", + title: "Caps", + description: "Tahoe caps for your music and configuration files." + }, { + id: "lastfm", + title: "Last.fm", + description: 'Share the music your are listening to with the world via Last.fm.' + } +]; + +// Renderers are used render interface elements for each setting (input boxes, checkboxes etc.) +// Settings and renderers are bound together via "representAs" property which +// defaults to "text" for each setting. +// All renderer has to do is to renturn a DIV element with "setting_box" CSS class +// which contains an element with "setting_" element. +// That same element will be passed to the matching serializer. + +var RENDERERS = { + _label: function (setting, details) { + var container = new Element("div", { + "class": "setting_box" + }); + return container.grab(new Element("label", { + text: details.title + ":", + "for": "setting_" + setting.id + })); + }, + + text: function (setting, details) { + return this._label(setting, details).grab(new Element("input", { + type: "text", + id: "setting_" + setting.id, + value: setting.get("value") + })); + }, + + password: function (setting, details) { + var text = this.text(setting, details); + text.getElement("input").type = "password"; + return text; + }, + + checkbox: function (setting, details) { + var control = this._label(setting, details); + control.getElement("label").empty().grab(new Element("input", { + id: "setting_" + setting.id, + type: "checkbox" + })); + control.getElement("input").checked = setting.get("value"); + control.grab(new Element("label", { + text: details.title, + "class": "no_indent", + "for": "setting_" + setting.id + })); + return control; + } +}; +RENDERERS.numeric = RENDERERS.text; + +// Serializers do the opposite job of the one that renderers do, +// they take an element and return its value. +var SERIALIZERS = { + text: function (input) { + return input.value; + }, + + password: function (input) { + return input.value; + }, + + numeric: function (input) { + return +input.value; + }, + + checkbox: function (input) { + return input.checked; + } +}; + +var Settings = { + initialize: function () { + this.dialog = new Dialog({ + title: "Settings", + html: new Element("div", {id: "settings"}), + hideOnOutsideClick: false + }); + this._el = $("settings"); + this.column = new GroupsColumn({ + parentElement: this._el + }); + + var select_message = new Element("div", { + html: "Click on a group on the left.", + "class": "message" + }); + this._controls = new Element("div", {id: "settings_controls"}); + this._controls.grab(select_message); + this._el.grab(this._controls); + + this.initialized = true; + }, + + /** + * Settings.show() -> this + * Shows the settings panel. + **/ + show: function () { + this.dialog.show(); + if(!this._adjusted_height) { + this._title_height = this._el.getElement(".dialog_title").getHeight(); + this.column.toElement().setStyle("height", 300 - this._title_height); + this._controls.style.height = (300 - this._title_height) + "px"; + this._adjusted_height = true; + } + + return this; + }, + + /** + * Settings.hide() -> this + * Hides the settings panel. + **/ + hide: function () { + this.dialog.hide(); + return this; + }, + + /** + * Settings.renderGroup(groupName) -> this + * - groupName (String) name of the settings group whose panel + * is about to be rendered. + **/ + renderGroup: function (group) { + Setting.find({ + properties: {group_id: group.id}, + onSuccess: function (settings) { + Settings.renderSettings(group.value, settings); + } + }); + }, + + /** + * Settings.renderSettings(settings) -> false | this + * - settings ([Settin]): settings for which controls need to be rendered. + * + * Calls the rendering functions for each setting. + * + **/ + renderSettings: function (group, settings) { + if(!settings.length) + return false; + if(this._controls) + this._controls.empty(); + + settings.sort(positionSort); + var container = new Element("div"), + header = new Element("p", { + html: group.description, + "class": "settings_header" + }), + footer = new Element("div", {"class": "settings_footer no_selection"}), + apply_button = new Element("input", { + type: "button", + value: "Apply", + id: "save_settings", + events: {click: function () { Settings.save() }} + }), + revert_button = new Element("input", { + type: "button", + value: "Revert", + id: "revert_settings", + events: {click: function () { Settings.renderSettings(group, settings) }} + }), + settings_el = new Element("form"); + + container.grab(header); + + var n = settings.length, setting, details; + while(n--) { + setting = settings[n]; + details = Setting.getDetails(setting.id); + RENDERERS[details.representAs](setting, details).inject(settings_el, "top"); + } + + footer.adopt(revert_button, apply_button); + container.adopt(settings_el, footer); + this._controls.grab(container); + return this; + }, + + save: function () { + var settings = this.serialize(); + for(var id in settings) + Setting.findFirst({ + properties: {id: id}, + onSuccess: function (setting) { + setting.update({value: settings[id]}); + } + }); + }, + + serialize: function () { + var values = this._controls.getElement("form").getElements("input[id^=setting_]"), + serialized = {}, + // in combo with el.id.slice is approx. x10 faster + // than el.id.split("setting_")[1] + setting_l = "setting_".length, + n = values.length; + + while(n--) { + var el = values[n], + setting_name = el.id.slice(setting_l), + details = Setting.getDetails(setting_name); + serialized[setting_name] = SERIALIZERS[details.representAs](el); + } + + return serialized; + } +}; +$extend(Settings, new Events()); + +function positionSort(a, b) { + a = Setting.getDetails(a.id).position; + b = Setting.getDetails(b.id).position; + + return (a < b) ? -1 : ((a > b) ? 1 : 0); +} + +var GroupsColumn = new Class({ + Extends: da.ui.NavigationColumn, + + view: null, + + initialize: function (options) { + options.totalCount = GROUPS.length; + this.parent(options); + + this.addEvent("click", function (item) { + Settings.renderGroup(item); + }); + }, + + getItem: function (n) { + var group = GROUPS[n]; + return {id: group.id, value: group}; + } +}); + +/** + * da.controller.Settings + * + * Public interface of the settings controller. + **/ +da.controller.Settings = { + /** + * da.controller.Settings.registerGroup(config) -> this + * - config.id (String): name of group. + * - config.title (String): human-friendly name of the group. + * - config.description (String): brief explanation of what this group is for. + * The description will be displayed at the top of settings dialog. + **/ + registerGroup: function (config) { + GROUPS[name] = config; + return this; + }, + + /** + * da.controller.Settings.addRenderer(name, renderer) -> this + * - name (String): name of the renderer. [[da.db.DocumentTemplate.Setting]] uses this in `representAs` property. + * - renderer (Function): function which renderes specific setting. + * + * As first argument `renderer` function takes [[Setting]] object, + * while the second one is the result of [[da.db.DocumentTemplate.Setting.getDetails]]. + * + * The function *must* return an [[Element]] with `setting_box` CSS class name. + * The very same element *must* contain another element with `setting_` id property. + * That element will be passed to the serializer function. + * + * #### Default renderers + * * `text` + * * `numeric` (same as `text`, the only difference is in serializer) + * * `password` + * * `checkbox` + **/ + addRenderer: function (name, fn) { + if(!(name in RENDERERS)) + RENDERERS[name] = fn; + + return this; + }, + + /** + * da.controller.Settings.addSerializer(name, serializer) -> this + * - name (String): name of the serializer. Usually the same name used by matching renderer. + * - serializer (Function): function which returns value stored by rendered UI controls. + * Function takes exactly one argument, the `setting_` element. + **/ + addSerializer: function (name, serializer) { + if(!(name in SERIALIZERS)) + SERIALIZERS[name] = serializer; + + return this; + }, + + /** + * da.controller.Settings.show() -> undefined + * + * Shows the settings dialog. + **/ + show: function () { + if(!Settings.initialized) + Settings.initialize(); + + Settings.show(); + }, + + /** + * da.controller.Settings.hide() -> undefined + * + * Hides the settings dialog. + * Changes to the settings are not automatically saved when dialog + * is dismissed. + **/ + hide: function () { + Settings.hide(); + }, + + /** + * da.controller.Settings.showGroup(group) -> undefined + * - group (String): group's id. + **/ + showGroup: function (group) { + this.show(); + var n = GROUPS.length; + while(n--) + if(GROUPS[n].id === group) + break; + + Settings.renderGroup({id: group, value: GROUPS[n+1]}); + } +}; + +da.app.fireEvent("ready.controller.Settings", [], 1); +})(); + addfile ./contrib/musicplayer/src/controllers/controllers.js hunk ./contrib/musicplayer/src/controllers/controllers.js 1 +/** + * == Controllers == + * + * Controller classes control "background" jobs and user interface. + **/ + +/** section: Controllers + * da.controller + **/ +if(typeof da.controller === "undefined") + da.controller = {}; + +//#require "controllers/Navigation.js" +//#require "controllers/Player.js" +//#require "controllers/Settings.js" +//#require "controllers/CollectionScanner.js" addfile ./contrib/musicplayer/src/controllers/default_columns.js hunk ./contrib/musicplayer/src/controllers/default_columns.js 1 +//#require "libs/ui/NavigationColumn.js" hunk ./contrib/musicplayer/src/controllers/default_columns.js 3 +(function () { +var Navigation = da.controller.Navigation, + NavigationColumn = da.ui.NavigationColumn; + +/** section: Controller + * class da.controller.Navigation.columns.Root < da.ui.NavigationColumn + * filters: All filters provided by other columns. + * + * The root column which provides root menu. + * To access the root menu use: + * + * da.controller.Navigation.columns.Root.menu + **/ +Navigation.registerColumn("Root", [], new Class({ + Extends: NavigationColumn, + + title: "Root", + view: null, + + initialize: function (options) { + this._data = Navigation.columns.Root.filters; + this.parent($extend(options, { + totalCount: 0 // this._data.length + })); + this.render(); + this.options.parentElement.style.display = "none"; + }, + + getItem: function (index) { + return {id: index, key: index, value: {title: this._data[index]}}; + } +})); + +/** + * class da.controller.Navigation.columns.Artists < da.ui.NavigationColumn + * filters: [[da.controller.Navigation.columns.Albums]], [[da.controller.Navigation.columns.Songs]] + * + * Displays artists. + **/ +var the_regex = /^the\s*/i; +Navigation.registerColumn("Artists", ["Albums", "Songs"], new Class({ + Extends: NavigationColumn, + + view: { + id: "artists_column", + map: function (doc, emit) { + // If there are no documents in the DB this function + // will be called with "undefined" as first argument + if(!doc) return; + + if(doc.type === "Artist") + emit(doc.id, { + title: doc.title + }); + } + }, + + createFilter: function (item) { + return {artist_id: item.id}; + }, + + compareFunction: function (a, b) { + a = a && a.value.title ? a.value.title.split(the_regex).slice(-1) : a; + b = b && b.value.title ? b.value.title.split(the_regex).slice(-1) : b; + + if(a < b) return -1; + if(a > b) return 1; + return 0; + } +})); + +/** + * class da.controller.Navigation.columns.Albums < da.ui.NavigationColumn + * filters: [[da.controller.Navigation.columns.Songs]] + * + * Displays albums. + **/ +Navigation.registerColumn("Albums", ["Songs"], new Class({ + Extends: NavigationColumn, + + // We can't reuse "Album" view because of #_passesFilter(). + view: { + id: "albums_column", + + map: function (doc, emit) { + if(!doc || !this._passesFilter(doc)) return; + + if(doc.type === "Album") + emit(doc.id, { + title: doc.title + }); + } + }, + + createFilter: function (item) { + return {album_id: item.id}; + } +})); + +/** + * class da.controller.Navigation.columns.Songs < da.ui.NavigationColumn + * filters: none + * + * Displays songs. + **/ +Navigation.registerColumn("Songs", [], new Class({ + Extends: NavigationColumn, + + initialize: function (options) { + this.parent(options); + + this._el.style.width = "300px"; + + this.addEvent("click", function (item) { + if(this.sound) + soundManager.stop(item.id); + + this.sound = soundManager.createSound({ + id: item.id, + url: "/uri/" + encodeURIComponent(item.id), + autoLoad: true, + onload: function () { + this.play(); + } + }); + }.bind(this)); + }, + + view: { + id: "songs_column", + map: function (doc, emit) { + if(!doc || !this._passesFilter(doc)) return; + + if(doc.type === "Song" && doc.title) + emit(doc.title, { + title: doc.title, + subtitle: doc.track, + track: doc.track + }); + } + }, + + renderItem: function (index) { + var item = this.getItem(index).value, + el = new Element("a", {href: "#", title: item.title}); + + el.grab(new Element("span", {html: item.title, "class": "title"})); + + return el; + }, + + compareFunction: function (a, b) { + a = a && a.value ? a.value.track : a; + b = b && b.value ? b.value.track : b; + + if(a < b) return -1; + if(a > b) return 1; + return 0; + } +})); + +})(); adddir ./contrib/musicplayer/src/doctemplates addfile ./contrib/musicplayer/src/doctemplates/Album.js hunk ./contrib/musicplayer/src/doctemplates/Album.js 1 +//#require "libs/db/DocumentTemplate.js" +/** + * class da.db.DocumentTemplate.Album < da.db.DocumentTemplate + * hasMany: [[da.db.DocumentTemplate.Song]] + * belongsTo: [[da.db.DocumentTemplate.Artist]] + * + * #### Standard properties + * * `title` - name of the album + **/ + +(function () { +var DocumentTemplate = da.db.DocumentTemplate; + +DocumentTemplate.registerType("Album", new Class({ + Extends: DocumentTemplate, + + hasMany: { + songs: "Song" + }, + + belongsTo: { + artist: "Artist" + } +})); + +})(); addfile ./contrib/musicplayer/src/doctemplates/Artist.js hunk ./contrib/musicplayer/src/doctemplates/Artist.js 1 +//#require "libs/db/DocumentTemplate.js" +/** + * class da.db.DocumentTemplate.Artist < da.db.DocumentTemplate + * hasMany: [[da.db.DocumentTemplate.Song]] + * belongsTo: [[da.db.DocumentTemplate.Artist]] + * + * #### Standard properties + * * `title` - name of the artist + * + **/ +(function () { +var DocumentTemplate = da.db.DocumentTemplate; + +DocumentTemplate.registerType("Artist", new Class({ + Extends: DocumentTemplate, + + hasMany: { + songs: "Song" + }, + + belongsTo: { + artist: "Artist" + } +})); + +})(); addfile ./contrib/musicplayer/src/doctemplates/Setting.js hunk ./contrib/musicplayer/src/doctemplates/Setting.js 1 +(function () { +var DocumentTemplate = da.db.DocumentTemplate, + // We are separating the actual setting values from + // information needed to display the UI controls. + SETTINGS = {}; + +/** + * class da.db.DocumentTemplate.Setting < da.db.DocumentTemplate + * + * Class for represeting settings. + * + * #### Example + * da.db.DocumentTemplate.Setting.register({ + * id: "volume", + * group_id: "general", + * representAs: "Number", + * + * title: "Volume", + * help: "Configure the volume", + * value: 64 + * }); + **/ + +var Setting = new Class({ + Extends: DocumentTemplate +}); +DocumentTemplate.registerType("Setting", da.db.SETTINGS, Setting); + +Setting.extend({ + /** + * da.db.DocumentTemplate.Setting.register(template) -> undefined + * - template.id (String): ID of the setting. + * - template.group_id (String | Number): ID of the group to which setting belongs to. + * - template.representAs (String): type of the data this setting represents. ex. `text`, `password`. + * - template.title (String): human-friendly name of the setting. + * - template.help (String): a semi-long description of what this setting is used for. + * - template.value (String | Number | Object): default value. + * - template.hidden (Boolean): if `true`, the setting will not be displayed in settings dialog. + * Defaults to `false`. + * - template.position (Number): position in the list. + * + * For list of possible `template.representAs` values see [[Settings.addRenderer]] for details. + **/ + register: function (template) { + SETTINGS[template.id] = { + title: template.title, + help: template.help, + representAs: template.representAs || "text", + position: typeof template.position === "number" ? template.position : -1 + }; + + this.findOrCreate({ + properties: {id: template.id}, + onSuccess: function (doc, was_created) { + if(was_created) + doc.update({ + group_id: template.group_id, + value: template.value + }); + } + }); + }, + + /** + * da.db.DocumentTemplate.Setting.findInGroup(group, callback) -> undefined + * - group (String | Number): ID of the group. + * - callback (Function): function called with all found settings. + **/ + findInGroup: function (group, callback) { + this.find({ + properties: {group_id: group}, + onSuccess: callback, + onFailure: callback + }); + }, + + /** + * da.db.DocumentTemplate.Setting.getDetails(id) -> Object + * - id (String | Number): id of the setting. + * + * Returns presentation-related details about the given setting. + * These details include `title`, `help` and `data` properties given to [[da.db.DocumentTemplate.Setting.register]]. + **/ + getDetails: function (id) { + return SETTINGS[id]; + } +}); + +Setting.register({ + id: "music_cap", + group_id: "caps", + representAs: "text", + title: "Music cap", + help: "Tahoe cap for the root dirnode in which all your music files are.", + value: "" +}); + +Setting.register({ + id: "settings_cap", + group_id: "caps", + representAs: "text", + title: "Settings cap", + help: "Tahoe read-write cap to the dirnode in which settings will be kept.", + value: "" +}); + +Setting.register({ + id: "lastfm_enabled", + group_id: "lastfm", + representAs: "checkbox", + title: "Enable Last.fm scrobbler", + help: "Enable this if you whish to share music your are listening to with others.", + value: false, + position: 0 +}); + +Setting.register({ + id: "lastfm_username", + group_id: "lastfm", + representAs: "text", + title: "Username", + help: "Type in your Last.fm username.", + value: "", + position: 1 +}); + +Setting.register({ + id: "lastfm_password", + group_id: "lastfm", + representAs: "password", + title: "Password", + help: "Write down your Last.fm password.", + value: "", + position: 2 +}); + +})(); addfile ./contrib/musicplayer/src/doctemplates/Song.js hunk ./contrib/musicplayer/src/doctemplates/Song.js 1 +//#require "libs/db/DocumentTemplate.js" + +(function () { +var DocumentTemplate = da.db.DocumentTemplate; + +/** + * class da.db.DocumentTemplate.Song < da.db.DocumentTemplate + * belongsTo: [[da.db.DocumentTemplate.Artist]], [[da.db.DocumentTemplate.Album]] + * + * #### Standard properties + * * `id` - Read-only cap of the file + * * `title` - name of the song + * * `track` - track number + * * `year` - year in which track was published + * * `lyrics` - lyrics of the song + * * `artist_id` - id of an [[da.db.DocumentTemplate.Artist]] + * * `album_id` - id of an [[da.db.DocumentTemplate.Album]] + * + **/ + +// Defined by ID3 specs: +// http://www.id3.org/id3v2.3.0#head-129376727ebe5309c1de1888987d070288d7c7e7 +var GENRES = [ + "Blues","Classic Rock","Country","Dance","Disco","Funk","Grunge","Hip-Hop","Jazz", + "Metal","New Age","Oldies","Other","Pop","R&B","Rap","Reggae","Rock","Techno", + "Industrial","Alternative","Ska","Death Metal","Pranks","Soundtrack","Euro-Techno", + "Ambient","Trip-Hop","Vocal","Jazz+Funk","Fusion","Trance","Classical","Instrumental", + "Acid","House","Game","Sound Clip","Gospel","Noise","AlternRock","Bass","Soul","Punk", + "Space","Meditative","Instrumental Pop","Instrumental Rock","Ethnic","Gothic", + "Darkwave","Techno-Industrial","Electronic","Pop-Folk","Eurodance","Dream", + "Southern Rock","Comedy","Cult","Gangsta","Top 40","Christian Rap","Pop/Funk", + "Jungle","Native American","Cabaret","New Wave","Psychadelic","Rave","Showtunes", + "Trailer","Lo-Fi","Tribal","Acid Punk","Acid Jazz","Polka","Retro","Musical", + "Rock & Roll","Hard Rock","Folk","Folk-Rock","National Folk","Swing","Fast Fusion", + "Bebob","Latin","Revival","Celtic","Bluegrass","Avantgarde","Gothic Rock", + "Progressive Rock","Psychedelic Rock","Symphonic Rock","Slow Rock","Big Band", + "Chorus","Easy Listening","Acoustic","Humour","Speech","Chanson","Opera","Chamber Music", + "Sonata","Symphony","Booty Bass","Primus","Porn Groove","Satire","Slow Jam","Club","Tango", + "Samba","Folklore","Ballad","Power Ballad","Rhythmic Soul","Freestyle","Duet","Punk Rock", + "Drum Solo","A capella","Euro-House","Dance Hall" +]; + +DocumentTemplate.registerType("Song", new Class({ + Extends: DocumentTemplate, + + belongsTo: { + artist: "Artist", + album: "Album" + }, + + /** + * da.db.DocumentTemplate.Song#getGenre() -> String + * Returns human-friendly name of the genre. + **/ + getGenre: function () { + return GENRES[this.get("genere")]; + } +})); + +})(); addfile ./contrib/musicplayer/src/doctemplates/doctemplates.js hunk ./contrib/musicplayer/src/doctemplates/doctemplates.js 1 +/** + * == DocumentTemplates == + * + * Database document templates. + * + **/ + +//#require "doctemplates/Setting.js" +//#require "doctemplates/Artist.js" +//#require "doctemplates/Album.js" +//#require "doctemplates/Song.js" addfile ./contrib/musicplayer/src/index.html hunk ./contrib/musicplayer/src/index.html 1 + + + + Music Player for Tahoe-LAFS + + + + + + + + +
Loading...
+ + + addfile ./contrib/musicplayer/src/index_devel.html hunk ./contrib/musicplayer/src/index_devel.html 1 + + + + Music Player for Tahoe-LAFS + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Loading...
+ + + adddir ./contrib/musicplayer/src/libs addfile ./contrib/musicplayer/src/libs/TahoeObject.js hunk ./contrib/musicplayer/src/libs/TahoeObject.js 1 +(function () { +/** + * == Tahoe == + * + * Classes and utility methods for working with Tahoe's [web API](http://tahoe-lafs.org/source/tahoe/trunk/docs/frontends/webapi.txt). + **/ +var CACHE = {}; + +/** section: Tahoe + * class TahoeObject + * + * Abstract class representing any Tahoe object - either file or directory. + **/ +var TahoeObject = new Class({ + /** + * new TahoeObject(cap[, meta]) + * - cap (String): cap of the object. + * - meta (Object): metadata about the object. + **/ + initialize: function (uri, meta) { + this.uri = uri; + CACHE[uri] = this; + this._fetched = false; + + if(meta) + this.applyMeta(meta); + }, + + /** + * TahoeObject#applyMeta(meta) -> this + * - meta (Object): metadata about the object. + * + * Applies the metadata to current object. If `meta` contains information + * of child items, new [[TahoeObject]] instances will be created for those + * as well. + **/ + applyMeta: function (meta) { + this.type = meta[0]; + var old_children = meta[1].children || {}, + children = []; + + for(var child_name in old_children) { + var child = old_children[child_name]; + child[1].objectName = child_name; + //child[1].type = child[0]; + + if(CACHE[child[1].ro_uri]) + children.push(CACHE[child[1].ro_uri]) + else + children.push(new TahoeObject(child[1].ro_uri, child)); + } + + meta[1].children = children; + $extend(this, meta[1]); + + return this; + }, + + /** + * TahoeObject#get([onSuccess][, onFailure]) -> this + * - onSuccess (Funcion): called if request succeeds. First argument is `this`. + * - onFailure (Function): called if request fails. + * + * Requests metadata about `this` object. + **/ + get: function (success, failure) { + if(this._fetched) { + (success||$empty)(this); + return this; + } + this._fetched = true; + + new Request.JSON({ + url: "/uri/" + encodeURIComponent(this.uri), + + onSuccess: function (data) { + this.applyMeta(data); + (success||$empty)(this); + }.bind(this), + + onFailure: failure || $empty + }).get({t: "json"}); + + return this; + }, + + /** + * TahoeObject#directories() -> [TahoeObject...] + * Returns an [[Array]] of all child directories. + **/ + directories: function () { + var children = this.children, + n = children.length, + result = []; + + while(n--) + if(children[n].type === "dirnode") + result.push(children[n]); + + return result; + }, + + /** + * TahoeObject#files() -> [TahoeObject...] + * Returns an [[Array]] of all child files. + **/ + files: function () { + var children = this.children, + n = children.length, + result = []; + + while(n--) + if(children[n].type === "filenode") + result.push(children[n]); + + return result; + } +}); +window.TahoeObject = TahoeObject; + +})(); adddir ./contrib/musicplayer/src/libs/db addfile ./contrib/musicplayer/src/libs/db/BrowserCouch.js hunk ./contrib/musicplayer/src/libs/db/BrowserCouch.js 1 +/* ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1/GPL 2.0/LGPL 2.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (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.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is Ubiquity. + * + * The Initial Developer of the Original Code is Mozilla. + * Portions created by the Initial Developer are Copyright (C) 2007 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): + * Atul Varma + * + * Alternatively, the contents of this file may be used under the terms of + * either the GNU General Public License Version 2 or later (the "GPL"), or + * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"), + * in which case the provisions of the GPL or the LGPL are applicable instead + * of those above. If you wish to allow use of your version of this file only + * under the terms of either the GPL or the LGPL, and not to allow others to + * use your version of this file under the terms of the MPL, indicate your + * decision by deleting the provisions above and replace them with the notice + * and other provisions required by the GPL or the LGPL. If you do not delete + * the provisions above, a recipient may use your version of this file under + * the terms of any one of the MPL, the GPL or the LGPL. + * + * ***** END LICENSE BLOCK ***** */ + +/* + * TODO: Update license block - we've done some changes: + * - optimised loops - faster map proccess + * - removed LocalStorage and FakeStorage - using PersistStorage instead + * - removed ModuleLoader - it's safe to assume JSON is available - speed optimisation + * - fixed mapping proccess - if no documents were emitted finish function wouldn't be called + * - added live views - map/reduce is only performed on updated documents + */ + +//#require + +(function () { + +/** section: Database + * class da.db.BrowserCouch + * + * Map/Reduce framework for browser. + * + * #### MapReducer Implementations + * MapReducer is a generic interface for any map-reduce + * implementation. Any object implementing this interface will need + * to be able to work asynchronously, passing back control to the + * client at a given interval, so that the client has the ability to + * pause/cancel or report progress on the calculation if needed. + **/ +var BrowserCouch = { + /** + * da.db.BrowserCouch.get(name, callback[, storage]) -> DB + * - name (String): name of the database. + * - callback (Function): called when database is initialized. + * - storage (Function): instance of storage class. + * Defaults to [[da.db.PersistStorage]]. + **/ + get: function BC_get(name, cb, storage) { + if (!storage) + storage = new da.db.PersistStorage(name); + + new DB({ + name: name, + storage: storage, + dict: new Dictionary(), + onReady: cb + }); + } +}; + +/** + * class da.db.BrowserCouch.Dictionary + * + * Internal representation of the database. + **/ +/** + * new da.db.BrowserCouch.Dictionary([object]) + * - object (Object): initial values + **/ +function Dictionary (object) { + /** + * da.db.BrowserCouch.Dictionary#dict -> Object + * The dictionary itself. + **/ + this.dict = {}; + /** + * da.db.BrowserCouch.Dictionary#keys -> Array + * + * Use this property to determine the number of items + * in the dictionary. + * + * (new Dictionary({h: 1, e: 1, l: 2, o: 1})).keys.length + * // => 4 + * + **/ + this.keys = []; + + if(object) + this.unpickle(object); + + return this; +} + +Dictionary.prototype = { + /** + * da.db.BrowserCouch.Dictionary#has(key) -> true | false + **/ + has: function (key) { + return (key in this.dict); + }, + + /* + getKeys: function () { + return this.keys; + }, + + get: function (key) { + return this.dict[key]; + }, + */ + + /** + * da.db.BrowserCouch.Dictionary#set(key, value) -> undefined + **/ + set: function (key, value) { + if(!(key in this.dict)) + this.keys.push(key); + + this.dict[key] = value; + }, + + /** + * da.db.BrowserCouch.Dictionary#setDocs(docs) -> undefined + * - docs ([Object, ...]): array of objects whose `id` property + * will be used as key. + * + * Use this method whenever you have to add more then one + * item to the dictionary as it provides better perofrmance over + * calling [[da.db.BrowserCouch.Dictionary#set]] over and over. + **/ + setDocs: function (docs) { + var n = docs.length, + newKeys = []; + + while(n--) { + var doc = docs[n], id = doc.id; + if(!(id in this.dict) && newKeys.indexOf(id) === -1) + newKeys.push(id); + + this.dict[id] = doc; + } + + this.keys = this.keys.concat(newKeys); + }, + + /** + * da.db.BrowserCouch.Dictionary#remove(key) -> undefined + **/ + remove: function (key) { + delete this.dict[key]; + + var keys = this.keys, + index = keys.indexOf(key), + keysLength = keys.length; + + if(index === 0) + return this.keys.shift(); + if(index === length - 1) + return this.keys = keys.slice(0, -1); + + this.keys = keys.slice(0, index).concat(keys.slice(index + 1, keysLength)); + }, + + /** + * da.db.BrowserCouch.Dictionary#clear() -> undefined + **/ + clear: function () { + this.dict = {}; + this.keys = []; + }, + + /** + * da.db.BrowserCouch.Dictionary#unpickle(object) -> undefined + * - object (Object): `object`'s properties will be replaced with current ones. + **/ + unpickle: function (obj) { + if(!obj) + return; + + this.dict = obj; + this._regenerateKeys(); + }, + + _regenerateKeys: function () { + var keys = [], + dict = this.dict; + + for(var key in dict) + keys.push(key); + + this.keys = keys; + } +}; + +/** section: Database + * class DB + * + * da.db.BrowserCouch database instance. + **/ +var DB = new Class({ + Implements: [Events, Options], + + options: {}, + /** + * new DB(options) + * - options.name (String) + * - options.storage (Object) + * - options.dict (Dictionary) + * - options.onReady (Function) + * fires ready + **/ + initialize: function (options) { + this.setOptions(options); + + this.name = "BrowserCouch_DB_" + this.options.name; + this.dict = this.options.dict; + this.storage = this.options.storage; + this.views = {}; + + this.storage.get(this.name, function (obj) { + this.dict.unpickle(obj); + this.fireEvent("ready", [this]); + }.bind(this)); + + this.addEvent("store", function (docs) { + var views = this.views, + dict = new Dictionary(); + + if($type(docs) === "array") + dict.setDocs(docs); + else + dict.set(docs.id, docs); + + for(var view_name in views) + this.view(views[view_name].options, dict); + }.bind(this), true); + }, + + /** + * DB#commitToStorage(callback) -> undefined + **/ + commitToStorage: function (callback) { + if(!callback) + callback = $empty; + + this.storage.put(this.name, this.dict.dict, callback); + }, + + /** + * DB#wipe(callback) -> undefined + **/ + wipe: function wipe(cb) { + this.dict.clear(); + this.commitToStorage(cb); + this.views = {}; + }, + + /** + * DB#get(id, callback) -> undefined + * - id (String): id of the document. + **/ + get: function get(id, cb) { + if(this.dict.has(id)) + cb(this.dict.dict[id]); + else + cb(null); + }, + + /** + * DB#getLength() -> Number + * Size of the database - number of documents. + **/ + getLength: function () { + return this.dict.keys.length; + }, + + /** + * DB#put(document, callback) -> undefined + * DB#put(documents, callback) -> undefined + * - document.id (String | Number): remember to set this property + * - documents (Array): array of documents. + * fires store + **/ + put: function (doc, cb) { + if ($type(doc) === "array") { + this.dict.setDocs(doc); + //var n = doc.length, _doc; + //while(n--) { + // _doc = doc[n]; + // this.dict.set(_doc.id, _doc); + //} + } else + this.dict.set(doc.id, doc); + + this.commitToStorage(cb); + this.fireEvent("store", [doc]); + }, + + /** + * DB#view(options[, _dict]) -> this + * - options.id (String): name of the view. Optional for temporary views. + * - options.map (Function): mapping function. First argument is the document, + * while second argument is `emit` function. + * - options.reduce (Function): reduce function. + * - options.finished (Function): called once map/reduce process finishes. + * - options.updated (Function): called on each update to the view. + * First argument is a view with only new/changed documents. + * - options.progress (Function): called between pauses. + * - options.chunkSize (Number): number of documents to be processed at once. + * Defaults to 50. + * - options.mapReducer (Object): MapReducer to be used. + * Defaults to [[da.db.SingleThreadedMapReducer]]. + * - options.temporary (Boolean): if enabled, new updates won't be reported. + * (`options.updated` won't be called at all) + * - _dict (Dictionary): objects on which proccess will be performed. + * Defaults to current database. + **/ + view: function DB_view(options, dict) { + if(!options.id && !options.temporary) + return false; + if(!options.map) + return false; + if(!options.finished) + return false; + + if(typeof options.temporary === "undefined") + options.temporary = false; + if(options.updated && !this.views[options.id]) + this.addEvent("updated." + options.id, options.updated); + if(!options.mapReducer) + options.mapReducer = SingleThreadedMapReducer; + if(!options.progress) + options.progress = defaultProgress; + if(!options.chunkSize) + options.chunkSize = DEFAULT_CHUNK_SIZE; + + var onReduce = function onReduce (rows) { + this._updateView(options, new ReduceView(rows), rows); + }.bind(this); + + var onMap = function (mapResult) { + if(!options.reduce) + this._updateView(options, new MapView(mapResult), mapResult); + else + options.mapReducer.reduce( + options.reduce, mapResult, options.progress, options.chunkSize, onReduce + ); + }.bind(this); + + options.mapReducer.map( + options.map, + dict || this.dict, + options.progress, + options.chunkSize, + onMap + ); + + return this; + }, + + _updateView: function (options, view, rows) { + if(options.temporary) + return options.finished(view); + + var id = options.id; + if(!this.views[id]) { + this.views[id] = { + options: options, + view: view + }; + options.finished(view); + } else { + if(!view.rows.length) + return this; + + if(options.reduce) { + var full_view = this.views[id].view.rows.concat(view.rows), + rereduce = {}, + reduce = options.reduce, + n = full_view.length; + + while(n--) { + var row = full_view[n], + key = row.key; + if(!rereduce[key]) + rereduce[key] = [row.value]; + else + rereduce[key].push(row.value); + } + + rows = []; + for(var key in rereduce) + rows.push({ + key: key, + value: reduce(null, rereduce[key], true) + }); + } + + this.views[id].view._include(rows); + this.fireEvent("updated." + id, [view]); + } + + return this; + }, + + /** + * DB#killView(id) -> this + * - id (String): name of the view. + **/ + killView: function (id) { + delete this.views[id]; + return this; + } +}); + +// Maximum number of items to process before giving the UI a chance +// to breathe. +var DEFAULT_CHUNK_SIZE = 1000; + +// If no progress callback is given, we'll automatically give the +// UI a chance to breathe for this many milliseconds before continuing +// processing. +var DEFAULT_UI_BREATHE_TIME = 50; + +function defaultProgress(phase, percent, resume) { + window.setTimeout(resume, DEFAULT_UI_BREATHE_TIME); +} + +/** + * class ReduceView + * Represents the result of map/reduce process. + **/ +/** + * new ReduceView(rows) -> this + * - rows (Array): value returned by reducer. + **/ +function ReduceView(rows) { + /** + * ReduceView#rows -> Array + * Result of the reduce process. + **/ + this.rows = []; + var keys = []; + + this._include = function (newRows) { + var n = newRows.length; + + while(n--) { + var row = newRows[n]; + if(keys.indexOf(row.key) === -1) { + this.rows.push(row); + keys.push(row.key); + } else { + this.rows[this.findRow(row.key)] = newRows[n]; + } + } + + this.rows.sort(keySort); + }; + + /** + * ReduceView#findRow(key) -> Number + * - key (String): key of the row. + * + * Returns position of the row in [[ReduceView#rows]]. + **/ + this.findRow = function (key) { + return findRowInReducedView(key, rows); + }; + + /** + * ReduceView#getRow(key) -> row + * - key (String): key of the row. + **/ + this.getRow = function (key) { + var row = this.rows[findRowInReducedView(key, rows)]; + return row ? row.value : undefined; + }; + + this._include(rows); + return this; +} + +function keySort (a, b) { + a = a.key; + b = b.key + if(a < b) return -1; + if(a > b) return 1; + return 0; +} + +function findRowInReducedView (key, rows) { + if(rows.length > 1) { + var midpoint = Math.floor(rows.length / 2); + var row = rows[midpoint]; + if(key < row.key) + return findRowInReducedView(key, rows.slice(0, midpoint)); + if(key > row.key) + return midpoint + findRowInReducedView(key, rows.slice(midpoint)); + return row.key === key ? midpoint : -1; + } + + return rows[0].key === key ? 0 : -1; +} + +/** + * class MapView + * Represents the result of map/reduce process. + **/ +/** + * new MapView(rows) -> this + * - rows (Array): value returned by mapper. + **/ +function MapView (mapResult) { + /** + * MapView#rows -> Object + * Result of the mapping process. + **/ + this.rows = []; + var keyRows = []; + + this._include = function (mapResult) { + var mapKeys = mapResult.keys, + mapDict = mapResult.dict; + + for(var i = 0, ii = mapKeys.length; i < ii; i++) { + var key = mapKeys[i], + ki = this.findRow(key), + has_key = ki !== -1, + item = mapDict[key], + j = item.keys.length, + newRows = new Array(j); + + if(has_key && this.rows[ki]) { + this.rows[ki].value = item.values.shift(); + item.keys.shift(); + j--; + } //else + //keyRows.push({key: key, pos: this.rows.length}); + + while(j--) + newRows[j] = { + id: item.keys[j], + key: key, + value: item.values[j] + }; + + if(has_key) + newRows.shift(); + this.rows = this.rows.concat(newRows); + } + + this.rows.sort(idSort); + + var keys = []; + keyRows = []; + for(var n = 0, m = this.rows.length; n < m; n++) { + var key = this.rows[n].key; + if(keys.indexOf(key) === -1) + keyRows.push({ + key: key, + pos: keys.push(key) - 1 + }); + } + + //delete keys; + }; + + /** + * MapView#findRow(key) -> Number + * - key (String): key of the row. + * + * Returns position of the row in [[MapView#rows]]. + **/ + this.findRow = function MV_findRow (key) { + return findRowInMappedView(key, keyRows); + }; + + /** + * MapView#getRow(key) -> row + * - key (String): key of the row. + * + * Returns row's value, ie. it's a shortcut for: + * this.rows[this.findRow(key)].value + **/ + this.getRow = function MV_findRow (key) { + var row = this.rows[findRowInMappedView(key, keyRows)]; + return row ? row.value : undefined; + }; + + this._include(mapResult); + + return this; +} + +function idSort (a, b) { + a = a.id; + b = b.id; + + if(a < b) return -1; + if(a > b) return 1; + return 0; +} + +function findRowInMappedView (key, keyRows) { + if (keyRows.length > 1) { + var midpoint = Math.floor(keyRows.length / 2); + var keyRow = keyRows[midpoint]; + if (key < keyRow.key) + return findRowInMappedView(key, keyRows.slice(0, midpoint)); + if (key > keyRow.key) + return findRowInMappedView(key, keyRows.slice(midpoint)); + return keyRow ? keyRow.pos : -1; + } else + return (keyRows[0] && keyRows[0].key === key) ? keyRows[0].pos : -1; +} + +/** section: Database + * class WebWorkerMapReducer + * + * A MapReducer that uses [Web Workers](https://developer.mozilla.org/En/Using_DOM_workers) + * for its implementation, allowing the client to take advantage of + * multiple processor cores and potentially decouple the map-reduce + * calculation from the user interface. + * + * The script run by spawned Web Workers is [[MapReduceWorker]]. + **/ + +/** + * new WebWorkerMapReducer(numWorkers[, worker]) + * - numWorkers (Number): number of workers. + * - worker (Object): reference to Web worker implementation. Defaults to `window.Worker`. + **/ +function WebWorkerMapReducer(numWorkers, Worker) { + if (!Worker) + Worker = window.Worker; + + var pool = []; + + function MapWorker(id) { + var worker = new Worker('js/workers/map-reducer.js'); + var onDone; + + worker.onmessage = function(event) { + onDone(event.data); + }; + + this.id = id; + this.map = function MW_map(map, dict, cb) { + onDone = cb; + worker.postMessage({map: map.toString(), dict: dict}); + }; + } + + for (var i = 0; i < numWorkers; i++) + pool.push(new MapWorker(i)); + + this.map = function WWMR_map(map, dict, progress, chunkSize, finished) { + var keys = dict.keys, + size = keys.length, + workersDone = 0, + mapDict = {}; + + function getNextChunk() { + if (keys.length) { + var chunkKeys = keys.slice(0, chunkSize), + chunk = {}, + n = chunkKeys.length; + + keys = keys.slice(chunkSize); + var key; + while(n--) { + key = chunkKeys[n]; + chunk[key] = dict.dict[key]; + } + return chunk; +// for (var i = 0, ii = chunkKeys.length; i < ii; i++) +// chunk[chunkKeys[i]] = dict.dict[chunkKeys[i]]; + + } else + return null; + } + + function nextJob(mapWorker) { + var chunk = getNextChunk(); + if (chunk) { + mapWorker.map( + map, + chunk, + function jobDone(aMapDict) { + for (var name in aMapDict) + if (name in mapDict) { + var item = mapDict[name]; + item.keys = item.keys.concat(aMapDict[name].keys); + item.values = item.values.concat(aMapDict[name].values); + } else + mapDict[name] = aMapDict[name]; + + if (keys.length) + progress("map", + (size - keys.length) / size, + function() { nextJob(mapWorker); }); + else + workerDone(); + }); + } else + workerDone(); + } + + function workerDone() { + workersDone += 1; + if (workersDone == numWorkers) + allWorkersDone(); + } + + function allWorkersDone() { + var mapKeys = []; + for (var name in mapDict) + mapKeys.push(name); + mapKeys.sort(); + finished({dict: mapDict, keys: mapKeys}); + } + + for (var i = 0; i < numWorkers; i++) + nextJob(pool[i]); + }; + + // TODO: Actually implement our own reduce() method here instead + // of delegating to the single-threaded version. + this.reduce = SingleThreadedMapReducer.reduce; +}; + +/** section: Database + * da.db.SingleThreadedMapReducer + * + * A MapReducer that works on the current thread. + **/ +var SingleThreadedMapReducer = { + /** + * da.db.SingleThreadedMapReducer.map(map, dict, progress, chunkSize, finished) -> undefined + * - map (Function): mapping function. + * - dict (Object): database documents. + * - progress (Function): progress reporting function. Called with `"map"` as first argument. + * - chunkSize (Number): number of documents to map at once. + * - finished (Function): called once map proccess finishes. + **/ + map: function STMR_map(map, dict, progress, + chunkSize, finished) { + var mapDict = {}, + keys = dict.keys, + currDoc; + + function emit(key, value) { + // TODO: This assumes that the key will always be + // an indexable value. We may have to hash the value, + // though, if it's e.g. an Object. + var item = mapDict[key]; + if (!item) + item = mapDict[key] = {keys: [], values: []}; + item.keys.push(currDoc.id); + item.values.push(value); + } + + var i = 0; + + function continueMap() { + var iAtStart = i, keysLength = keys.length; + + if(keysLength > 0) + do { + currDoc = dict.dict[keys[i]]; + map(currDoc, emit); + i++; + } while (i - iAtStart < chunkSize && i < keysLength); + + if (i == keys.length) { + var mapKeys = []; + for (var name in mapDict) + mapKeys.push(name); + mapKeys.sort(); + finished({dict: mapDict, keys: mapKeys}); + } else + progress("map", i / keysLength, continueMap); + } + + continueMap(); + }, + + /** + * da.db.SingleThreadedMapReducer.reduce(reduce, mapResult, progress, chunkSize, finished) -> undefined + * - reduce (Function): reduce function. + * - mapResult (Object): Object returned by [[da.db.SingleThreadedMapReducer.map]]. + * - progress (Function): progress reportiong function. Called with `"reduce"` as first argument. + * - chunkSize (Number): number of documents to process at once. + * - finished (Function): called when reduce process finishes. + * - rereduce (Boolean | Object): object which will be passed to `reduce` during the rereduce process. + * + * Please refer to [CouchDB's docs on map and reduce functions](http://wiki.apache.org/couchdb/Introduction_to_CouchDB_views#Basics) + * for more detailed usage details. + **/ + reduce: function STMR_reduce(reduce, mapResult, progress, + chunkSize, finished, rereduce) { + var rows = [], + mapDict = mapResult.dict, + mapKeys = mapResult.keys, + i = 0; + rereduce = rereduce || {}; + + function continueReduce() { + var iAtStart = i; + + do { + var key = mapKeys[i], + item = mapDict[key] + + rows.push({ + key: key, + value: reduce(key, item.values, false) + }); + + i++; + } while (i - iAtStart < chunkSize && + i < mapKeys.length) + + if (i == mapKeys.length) { + finished(rows); + } else + progress("reduce", i / mapKeys.length, continueReduce); + } + + continueReduce(); + } +}; + +da.db.BrowserCouch = BrowserCouch; +da.db.BrowserCouch.Dictionary = Dictionary; +da.db.SingleThreadedMapReducer = SingleThreadedMapReducer; +da.db.WebWorkerMapReducer = WebWorkerMapReducer; + +})(); addfile ./contrib/musicplayer/src/libs/db/DocumentTemplate.js hunk ./contrib/musicplayer/src/libs/db/DocumentTemplate.js 1 +//#require "libs/db/db.js" +//#require "libs/db/BrowserCouch.js" +//#require "libs/vendor/Math.uuid.js" +//#require "libs/util/util.js" + +(function () { +/** section: Database + * class da.db.DocumentTemplate + * implements Events + * + * Abstract class for manufacturing document templates. (ie. Model from MVC) + **/ +var DocumentTemplate = new Class({ + Implements: Events, + + /** + * da.db.DocumentTemplate#belongsTo -> Object + * + * Provides belongs-to-many relationsip found in may ORM libraries. + * + * #### Example + * da.db.DocumentTemplate.registerType("Artist", new Class({ + * Extends: da.db.DocumentTemplate + * })); + * + * var queen = new da.db.DocumentTemplate.Artist({ + * id: 0, + * title: "Queen" + * }); + * + * da.db.DocumentTemplate.registerType("Song", new Class({ + * Extends: da.db.DocumentTemplate, + * belongsTo: { + * artist: "Artist" // -> artist_id property will be used to create a new Artist + * } + * })); + * + * var yeah = new da.db.DocumentTemplate.Song({ + * artist_id: queen.id, + * album_id: 5, + * title: "Yeah" + * }); + * + * yeah.get("artist", function (artist) { + * console.log("Yeah by " + artist.get("title")); + * }); + * + **/ + belongsTo: {}, + + /** + * da.db.DocumentTemplate#hasMany -> Object + * + * Provides has-many relationship between database documents. + * + * #### Example + * If we defined `da.db.DocumentTemplate.Artist` in [[da.db.DocumentTemplate#belongsTo]] like: + * + * da.db.DocumentTemplate.registerType("Artist", new Class({ + * Extends: da.db.DocumentTemplate, + * hasMany: { + * songs: ["Song", "artist_id"] + * } + * })); + * + * And assumed that `"artist_id"` is the name of the property which holds id of an `Artist`, + * while `"Song"` represents the type of the document. + * + * Then we can obtain all the songs by given a artist with: + * + * queen.get("songs", function (songs) { + * console.log("Queen songs:") + * for(var n = 0, m = songs.length; n < m; n++) + * console.log(songs[n].get("title")); + * }); + **/ + hasMany: {}, + + /** + * new da.db.DocumentTemplate(properties[, events]) + * - properties (Object): document's properties. + * - events (Object): default events. + **/ + initialize: function (properties, events) { + this.doc = properties; + if(!this.doc.id) + this.doc.id = Math.uuid(); + + this.id = this.doc.id; + this.doc.type = this.constructor.type; + if(!this.constructor.db) + this.constructor.db = function () { + return Application.db + }; + + // Time delay is set so class can finish initialization + this.addEvents(events); + this.fireEvent("create", [this], 1); + }, + + /** + * da.db.DocumentTemplate#id -> "id of the document" + * + * Shortcut for [[da.db.DocumentTemplate#get]]`("id")`. + **/ + id: null, + + /** + * da.db.DocumentTemplate#get(key[, callback]) -> Object | false | this + * - key (String): name of the property. + * - callback (Function): needed only if `key` points to an property defined by an relationship. + **/ + get: function (key, callback) { + if(key in this.doc) + return this.doc[key]; + + if(!callback) + return false; + + if(key in this.belongsTo) { + var cache_key = "_belongs_to_" + key, + cached = this[cache_key]; + + if(cached && cached.id === this.doc[key + "_id"]) + return callback(cached); + + if(!this.doc[key + "_id"]) + return callback(null); + + DocumentTemplate.find({ + properties: { + id: this.doc[key + "_id"], + type: this.belongsTo[key] + }, + + onSuccess: function (doc) { + this[cache_key] = doc; + callback(doc); + }.bind(this), + + onFailure: callback + }, this.constructor.db()); + } else if(key in this.hasMany) { + var relation = this.hasMany[key], + props = {type: relation[0]}; + + props[relation[1]] = this.id; + + DocumentTemplate.find({ + properties: props, + onSuccess: callback, + onFailure: callback + }, DocumentTemplate[relation[0]].db()); + } + + return this; + }, + + /** + * da.db.DocumentTemplate#set(properties) -> this + * da.db.DocumentTemplate#set(key, value) -> this + * - properties (Object): updated properties. + * fires propertyChange + **/ + set: function (properties) { + if(arguments.length == 2) { + var key = properties; + properties = {}; + properties[key] = arguments[1]; + } + + $extend(this.doc, properties); + this.fireEvent("propertyChange", [properties, this]); + + return this; + }, + + /** + * da.db.DocumentTemplate#remove(property) -> this + * - property (String): property to be removed. + * fires propertyRemove + **/ + remove: function (property) { + if(property !== "_id") + delete this.doc[property]; + + this.fireEvent("propertyRemove", [property, this]); + return this; + }, + + /** + * da.db.DocumentTemplate#save([callback]) -> this + * - callback (Function): function called after `save` event. + * fires save + **/ + save: function (callback) { + this.constructor.db().put(this.doc, function () { + this.fireEvent("save", [this]); + if(callback) + callback(this); + }.bind(this)); + + return this; + }, + + /** + * da.db.DocumentTemplate#update(properties[, cb]) -> this + * - properties (Object): new properties. + * - callback (Function): called after `save`. + * + * Calls [[da.db.DocumentTemplate#set]] and [[da.db.DocumentTemplate#save]]. + **/ + update: function (properties, cb) { + this.set(properties); + this.save(cb); + return this; + }, + + /** + * da.db.DocumentTemplate#destroy([callback]) -> this + * - callback (Function): function called after `destroy` event. + * + * Removes all document's properties except for `id` and adds `_deleted` property. + **/ + destroy: function (callback) { + this.doc = {id: this.id, _deleted: true}; + this.constructor.db().put(this.doc, function () { + this.fireEvent("destroy", [this]); + if(callback) + callback(this); + }); + + return this; + } +}); + +DocumentTemplate.extend({ + /** + * da.db.DocumentTemplate.find(options[, db]) -> undefined + * - options.properties (String | Object | Function): properties document must have or an function which checks document's properties. + * If `String` is provided, it's assumed that it represents document's `id`. + * - options.onSuccess (Function): function called once document is found. + * - options.onFailure (Function): function called if no documents are found. + * - options.onlyFirst (Bool): gives back only first result. + * - db (BrowserCouch): if not provided, `Application.db` is used. + **/ + find: function (options, db) { + if(!options.onSuccess) + return false; + if(!options.onFailure) + options.onFailure = $empty; + if(typeof options.properties === "string") + options.properties = {id: options.properties} + + var map_fn, props = options.properties; + if(typeof properties === "function") + map_fn = function (doc, emit) { + if(doc && !doc._deleted && props(doc)) + emit(doc.id, doc); + }; + else + map_fn = function (doc, emit) { + if(doc && !doc._deleted && Hash.containsAll(doc, props)) + emit(doc.id, doc); + }; + + (db || da.db.DEFAULT).view({ + temporary: true, + map: map_fn, + finished: function (result) { + if(!result.rows.length) + return options.onFailure(); + + var n = result.rows.length; + while(n--) { + var row = result.rows[n].value, + type = DocumentTemplate[row.type]; + + result.rows[n] = type ? new type(row) : row; + } + + options.onSuccess(options.onlyFirst ? result.rows[0] : result.rows); + } + }); + }, + + /** + * da.db.DocumentTemplate.findFirst(options[, db]) -> undefined + * - options (Object): same options as in [[da.db.DocumentTemplate.find]] apply here. + **/ + findFirst: function (options, db) { + options.onlyFirst = true; + this.find(options, db); + }, + + /** + * da.db.DocumentTemplate.findOrCreate(options[, db]) -> undefined + * - options (Object): same options as in [[da.db.DocumentTemplate.find]] apply here. + * - options.properties.type (String): must be set to the desired [[da.db.DocumentTemplate]] type. + **/ + findOrCreate: function (options, db) { + options.onSuccess = options.onSuccess || $empty; + options.onFailure = function () { + options.onSuccess(new DocumentTemplate[options.properties.type](options.properties), true); + }; + this.findFirst(options, db); + }, + + /** + * da.db.DocumentTemplate.registerType(typeName[, db = Application.db], template) -> da.db.DocumentTemplate + * - typeName (String): name of the type. ex.: `Car`, `Chocolate` etc. + * - db (BrowserCouch): database to be used. + * - template (da.db.DocumentTemplate): the actual [[da.db.DocumentTemplate]] [[Class]]. + * + * New classes are accessible from `da.db.DocumentTemplate.`. + **/ + registerType: function (type, db, template) { + if(arguments.length === 2) { + template = db; + db = null; + } + + template.type = type; + // This is a function so we can + // return a reference to the original instance + // of DB, otherwise, due to MooTools' inheritance + // we would get a new copy. + if(db) + template.db = function () { return db }; + else + template.db = function () { return da.db.DEFAULT }; + + template.find = function (options) { + options.properties.type = type; + DocumentTemplate.find(options, db); + }; + + template.findFirst = function (options) { + options.properties.type = type; + DocumentTemplate.findFirst(options, db); + }; + + template.create = function (properties, callback) { + return (new template(properties)).save(callback); + }; + + template.findOrCreate = function (options) { + options.properties.type = type; + DocumentTemplate.findOrCreate(options, db); + }; + + template.db().view({ + id: type, + map: function (doc, emit) { + if(doc && doc.type === type) + emit(doc.id, doc); + }, + finished: $empty + }); + + DocumentTemplate[type] = template; + return template; + } +}); + +da.db.DocumentTemplate = DocumentTemplate; + +})(); addfile ./contrib/musicplayer/src/libs/db/PersistStorage.js hunk ./contrib/musicplayer/src/libs/db/PersistStorage.js 1 +//#require "libs/vendor/persist-js/src/persist.js" +//#require "libs/db/db.js" + +(function () { +/** section: Database + * class da.db.PersistStorage + * + * Interface between PersistJS and BrowserCouch. + **/ +/* + * new da.db.PersistStorage(database_name) + * - database_name (String): name of the database. + **/ +function PersistStorage (db_name) { + var storage = new Persist.Store(db_name || "tahoemp"); + + /** + * da.db.PersistStorage#get(key, callback) -> undefined + * - key (String): name of the property + * - callback (Function): will be called once data is fetched, + * which will be passed as first argument. + **/ + this.get = function (key, cb) { + storage.get(key, function (ok, value) { + cb(value ? JSON.parse(value) : null, ok); + }); + }; + + /** + * da.db.PersistStorage#put(key, value[, callback]) -> undefined + * - key (String): name of the property. + * - value (Object): value of the property. + * - callback (Function): will be called once data is saved. + **/ + this.put = function (key, value, cb) { + storage.set(key, JSON.stringify(value)); + if(cb) cb(); + }; + + return this; +} + +da.db.PersistStorage = PersistStorage; +})(); addfile ./contrib/musicplayer/src/libs/db/db.js hunk ./contrib/musicplayer/src/libs/db/db.js 1 +/** + * == Database == + * + * Map/Reduce, storage and model APIs. + **/ + +/** section: Database + * da.db + **/ +if(typeof da.db === "undefined") + da.db = {}; adddir ./contrib/musicplayer/src/libs/ui addfile ./contrib/musicplayer/src/libs/ui/Column.js hunk ./contrib/musicplayer/src/libs/ui/Column.js 1 +//#require "libs/ui/ui.js" + +/** section: UserInterface + * class da.ui.Column + * implements Events, Options + * + * Widget which can efficiently display large amounts of items in a list. + **/ +da.ui.Column = new Class({ + Implements: [Events, Options], + + options: { + id: undefined, + rowHeight: 30, + totalCount: 0, + renderTimeout: 120, + itemClassNames: "column_item" + }, + /** + * new da.ui.Column(options) + * - options.id (String): desired ID of the column's DIV element, `_column` will be appended. + * if ommited, random one will be generated. + * - options.rowHeight (Number): height of an row. Defaults to 30. + * - options.totalCount (Number): number of items this column has to show in total. + * - options.itemClassNames (String): CSS class names added to each item. Defaults to `column_item`. + * - options.renderTimeout (Number): milliseconds to wait during the scroll before rendering + * items. Defaults to 120. + * + * Creates a new Column. + * + * ##### Notes + * When resizing (height) of the column use [[Element#set]] function provided by MooTools + * which properly fires `resize` event. + * + * column._el.set("height", window.getHeight()); + * + **/ + initialize: function (options) { + this.setOptions(options); + if(!this.options.id) + this.options.id = "column_" + Math.uuid(5); + + this._populated = false; + // #_rendered will contain keys of items which have been rendered. + // What is a key is up to particular implementation. + this._rendered = []; + + this._el = new Element("div", { + id: options.id, + 'class': 'column', + styles: { + overflowX: "hidden", + overflowY: "auto", + position: "relative" + } + }); + + // weight is used to force the browser + // to show scrollbar with right proportions. + this._weight = new Element("div", { + styles: { + position: "absolute", + top: 0, + left: 0, + width: 1, + height: 1 + } + }); + this._weight.injectBottom(this._el); + + // scroll event is fired for even smallest changes + // of scrollbars positions, since rendering items can be + // expensive a small timeout will be set in order to save + // some bandwidth - the negative side is that flicker is seen + // while scrolling. + var scroll_timer = null, + timeout = this.options.renderTimeout, + timeout_fn = this.render.bind(this); + + this._el.addEvent("scroll", function () { + clearTimeout(scroll_timer); + scroll_timer = setTimeout(scroll_timer, timeout); + }); + + // We're caching lists' height so we won't have to + // ask for it in every #render() - which can be quite expensiv. + this._el.addEvent("resize", function () { + this._el_height = this._el.getHeight(); + }.bind(this)); + }, + + /** + * da.ui.Column#render() -> this | false + * + * Renders all of items which are in current viewport in a batch. + * + * Returns `false` if all of items have already been rendered. + * + * Items are rendered in groups of (`div` tags with `column_items_box` CSS class). + * The number of items is determined by number of items which can fit in viewport + five + * items before and 10 items after current viewport. + * Each item has CSS classes defined in `options.itemClassNames` and have a `column_index` + * property stored. + **/ + render: function () { + if(!this._populated) + this.populate(); + if(this._rendered.length === this.options.totalCount + 1) + return false; + + // We're pre-fetching previous 5 and next 10 items + // which are outside of current viewport + var total_count = this.options.totalCount, + ids = this.getVisibleIndexes(), + n = Math.max(0, ids[0] - 6), + m = Math.max(Math.min(ids[1] + 10, total_count), total_count), + box = new Element("div", {"class": "column_items_box"}), + item_class = this.options.itemClassNames, + first_rendered = null; + + for( ; n < m; n++) { + if(!this._rendered.contains(n)) { + // First item in viewport could be already rendered + // this helps minimizing amount of DOM nodes that will be inserted + if(first_rendered === null) + first_rendered = n; + + this.renderItem(n).addClass(item_class).store("column_index", n).injectBottom(box); + this._rendered.push(n); + } + } + + if(first_rendered !== null) { + var coords = this.getBoxCoords(first_rendered); + box.setStyles({ + position: "absolute", + top: coords[0], + left: coords[1] + }).injectBottom(this._el); + } + + return this; + }, + + /** + * da.ui.Column#populate() -> this + * fires resize + * + * Positiones weight element and fires `resize` event. This method should ignore `_populated` property. + **/ + populate: function () { + var o = this.options; + this._populated = true; + this._weight.setStyle("top", o.rowHeight * o.totalCount /*+ o.rowHeight*/); + this._el.fireEvent("resize"); + + return this; + }, + + /** + * da.ui.Column#rerender() -> this + **/ + rerender: function () { + var weight = this._weight; + this._el.empty(); + this._el.grab(weight); + + this._rendered = []; + this._populated = false; + return this.render(); + }, + /** + * da.ui.Column#updateTotalCount(totalCount) -> this | false + * - totalCount (Number): total number of items this column is going to display + * + * Provides means to update `totalCount` option after column has already been rendered/initialized. + **/ + updateTotalCount: function (total_count) { + this.options.totalCount = total_count; + return this.populate(); + }, + + /** + * da.ui.Column#renderItem(index) -> Element + * - index (Object): could be a String or Number, internal representation of data. + * + * Constructs and returns new Element without adding it to the `document`. + **/ + renderItem: function(index) { + console.warn("Column.renderItem(index) should be overwritten", this); + return new Element("div", {html: index}); + }, + + /** + * da.ui.Column#getBoxCoords(index) -> [Number, Number] + * - index (Number): index of the first item in a box. + * + * Returns X and Y coordinates at which item with given `index` should be rendered at. + **/ + getBoxCoords: function(index) { + return [this.options.rowHeight * index, 0]; + }, + + /** + * da.ui.Column#getVisibleIndexes() -> Array + * + * Returns an array with indexes of first and last item in visible portion of list. + **/ + getVisibleIndexes: function () { + // Math.round() and Math.ceil() are used in such combination + // to include items which could be only partially in viewport + var rh = this.options.rowHeight, + per_viewport = Math.round(this._el_height / rh), + first = Math.ceil(this._el.getScroll().y / rh); + if(first > 0) first--; + + return [first, first + per_viewport]; + }, + + /** + * da.ui.Column#injectBottom(element) -> this + * - element (Element): element to which column should be appended. + * + * Injects column at the bottom of provided element. + **/ + injectBottom: function(el) { + this._el.injectBottom(el); + return this; + }, + + /** + * da.ui.Column#destory() -> this + * + * Removes column from DOM. + **/ + destroy: function (dispose) { + this._el.destroy(); + delete this._el; + return this; + }, + + /** + * da.ui.Column#toElement() -> Element + **/ + toElement: function () { + return this._el; + } +}); addfile ./contrib/musicplayer/src/libs/ui/Dialog.js hunk ./contrib/musicplayer/src/libs/ui/Dialog.js 1 +//#require "libs/ui/ui.js" + +/** section: UserInterface + * class da.ui.Dialog + * + * Class for working with interface dialogs. + **/ +da.ui.Dialog = new Class({ + Implements: [Events, Options], + + options: { + title: null, + hideOnOutsideClick: true, + show: false + }, + + /** + * new da.ui.Dialog(options) + * - options.title (String): title of the dialog. optional. + * - options.hideOnOutsideClick (Boolean): if `true`, the dialog will be hidden when + * click outside the dialog element (ie. on the dimmed portion of screen) occurs. + * - options.show (Boolean): if `true` the dialog will be shown immediately as it's created. + * Defaults to `false`. + * - options.html (Element): contents of the. + * + * To the `options.html` element `dialog` CSS class name will be added and + * the element will be wrapped into a `div` with `dialog_wrapper` CSS class name. + * + * If `options.title` is provided, the title element will be injected at the top of + * `options.html` and will be given `dialog_title` CSS class name. + * + * #### Notes + * All dialogs are hidden by default, use [[Dialog.show]] to show them immediately + * after they are created method. + * + * #### Example + * new da.ui.Dialog({ + * title: "What's your name?" + * html: new Element("div", { + * html: "Hello!" + * }), + * show: true + * }); + * + **/ + initialize: function (options) { + this.setOptions(options); + if(!this.options.html) + throw "options.html must be provided when creating an Dialog"; + + this._el = new Element("div", { + "class": "dialog_wrapper" + }); + if(!this.options.show) + this._el.style.display = "none"; + + if(this.options.title) + if(typeof this.options.title === "string") + (new Element("h2", { + html: this.options.title, + "class": "dialog_title no_selection" + })).inject(this.options.html, "top"); + else if($type(this.options.title) === "element") + this.options.title.inject(this.options.html, "top"); + + if(this.options.hideOnOutsideClick) + this._el.addEvent("click", this.hide.bind(this)); + + this._el.grab(options.html.addClass("dialog")); + document.body.grab(this._el); + }, + + /** + * da.ui.Dialog#show() -> this + * fires show + **/ + show: function () { + this._el.show(); + this.fireEvent("show", [this]); + return this; + }, + + /** + * da.ui.Dialog#hide(event) -> this + * fires hide + **/ + hide: function (event) { + if(event && event.target !== this._el) + return this; + + this._el.hide(); + this.fireEvent("hide", [this]); + return this; + }, + + /** + * da.ui.Dialog#destroy() -> this + **/ + destory: function () { + this.options.html.destroy(); + this._el.destroy(); + return this; + } +}); + addfile ./contrib/musicplayer/src/libs/ui/Menu.js hunk ./contrib/musicplayer/src/libs/ui/Menu.js 1 +//#require "libs/ui/ui.js" + +(function () { +var VISIBLE_MENU; + +/** section: UserInterface + * class da.ui.Menu + * implements Events, Options + * + * Lightweight menu class. + * + * #### Example + * + * var file_menu = new da.ui.Menu({ + * items: { + * neu: {html: "New", href: "#"}, + * neu_tpl: {html: "New from template", href: "#"}, + * open: {html: "Open", href: "#"}, + * + * _sep1: da.ui.Menu.separator, + * + * close: {html: "Close", href: "#"}, + * save: {html: "Save", href: "#"}, + * save_all: {html: "Save all", href: "#", "class": "disabled"}, + * + * _sep2: da.ui.Menu.separator, + * + * quit: {html: "Quit", href: "#", onClick: function () { + * confirm("Are you sure?") + * }} + * }, + * + * position: { + * position: "topLeft" + * }, + * + * onClick: function (key, event, element) { + * console.log("knock knock", key); + * } + * }); + * + * file_menu.show(); + * + * Values of properties in `items` are actually second arguments for MooTools' + * `new Element()` and therefore provide great customization ability. + * + * `position` property will be passed to MooTools' `Element.position()` method, + * and defaults to `bottomRight`. + * + * #### Events + * - `click` - arguments: key of the clicked item, clicked element + * - `show` + * - `hide` + * + * #### Notes + * `href` attribute is added to all items in order to enable + * keyboard navigation with tab key. + * + * #### See also + * * [MooTools Element class](http://mootools.net/docs/core/Element/Element#Element:constructor) + **/ + +da.ui.Menu = new Class({ + Implements: [Events, Options], + + options: { + items: {}, + position: { + position: "bottomLeft" + } + }, + + /** + * da.ui.Menu#last_clicked -> Element + * + * Last clicked menu item. + **/ + last_clicked: null, + + /** + * new da.ui.Menu([options = {}]) + * - options.items (Object): menu items. + * - options.position (Object): menu positioning parameters. + **/ + initialize: function (options) { + this.setOptions(options); + + this._el = (new Element("ul")).addClass("menu").addClass("no_selection"); + this._el.style.display = "none"; + this._el.addEvent("click:relay(.menu_item a)", this.click.bind(this)); + //this._el.injectBottom(document.body); + + this.render(); + }, + + /** + * da.ui.Menu#render() -> this + * + * Renders the menu items and adds them to the document. + * Menu element is an `ul` tag appeded to the bottom of `document.body` and has `menu` CSS class. + **/ + render: function () { + var items = this.options.items; + this._el.dispose().empty(); + + for(var id in items) + this._el.grab(this.renderItem(id)); + + document.body.grab(this._el); + return this; + }, + + /** + * da.ui.Menu#renderItem(id) -> Element + * - id (String): id of the menu item. + * + * Renders item without attaching it to DOM. + * Item is a `li` tag with `menu_item` CSS class. `li` tag contains an `a` tag with the item's text. + * Each `li` tag also has a `menu_key` property set, which can be retrived with: + * + * menu.toElement().getItems('.menu_item').retrieve("menu_key") + * + * If the item was defined with function than those tag names might not be used, + * but CSS class names are guaranteed to be there in both cases. + **/ + renderItem: function (id) { + var options = this.options.items[id], el; + + if(typeof options === "function") + el = options(this).addClass("menu_item"); + else + el = new Element("li").grab(new Element("a", options)); + + return el.addClass("menu_item").store("menu_key", id); + }, + + /** + * da.ui.Menu#addItems(items) -> this + * - items (Object): key-value pairs of items to be added to the menu. + * + * Adds items to the bottom of menu and renders them. + **/ + addItems: function (items) { + $extend(this.options.items, items); + return this.render(); + }, + + /** + * da.ui.Menu#addItem(id, value) -> this + * - id (String): id of the item. + * - value (Object | Function): options for [[Element]] class or function which will render the item. + * + * If `value` is an [[Object]] then it will be passed as second argument to MooTools's [[Element]] class. + * If `value` is an [[Function]] then it has return an [[Element]], + * first argument of the function is id of the item that needs to be rendered. + **/ + addItem: function (id, value) { + this.options.items[id] = value; + this._el.grab(this.renderItem(id)); + return this; + }, + + /** + * da.ui.Menu#removeItem(id) -> this + * - id (String): id of the item. + * + * Removes an item from the menu. + **/ + removeItem: function (id) { + delete this.options.items[id]; + return this.render(); + }, + + /** + * da.ui.Menu#addSeparator() -> this + * + * Adds separator to the menu. + **/ + addSeparator: function () { + return this.addItem("separator_" + Math.uuid(3), da.ui.Menu.separator); + }, + + /** + * da.ui.Menu#click(event, element) -> this + * - event (Event): DOM event or `null`. + * - element (Element): list item which was clicked. + * fires: click + **/ + click: function (event, element) { + this.hide(); + + if(!element.className.contains("menu_item")) + element = element.getParent(".menu_item"); + if(!element) + return this; + + this.fireEvent("click", [element.retrieve("menu_key"), event, element]); + this.last_clicked = element; + + return this; + }, + + /** + * da.ui.Menu#show([event]) -> this + * - event (Event): click or some other DOM event with coordinates. + * fires show + * + * Shows the menu. If event is present than menus location will be adjusted according to + * event's coordinates and position option. + * In case the menu is already visible, it will be hidden. + **/ + show: function (event) { + if(VISIBLE_MENU) { + if(VISIBLE_MENU == this) + return this.hide(); + else + VISIBLE_MENU.hide(); + } + + VISIBLE_MENU = this; + + if(event) + event.stop(); + + if(event && event.target) + this._el.position($extend({ + relativeTo: event.target + }, this.options.position)); + + this._el.style.zIndex = 5; + this._el.style.display = "block"; + this._el.focus(); + + this.fireEvent("show"); + + return this; + }, + + /** + * da.ui.Menu#hide() -> this + * fires hide + * + * Hides the menu. + **/ + hide: function () { + if(this._el.style.display === "none") + return this; + + VISIBLE_MENU = null; + this._el.style.display = "none"; + this.fireEvent("hide"); + + return this; + }, + + /** + * da.ui.Menu#destroy() -> this + * + * Destroys the menu. + **/ + destroy: function () { + this._el.destroy(); + delete this._el; + return this; + }, + + /** + * da.ui.Menu#toElement() -> Element + * + * Returns menu element. + **/ + toElement: function () { + return this._el; + } +}); + +/** + * da.ui.Menu.separator -> Object + * + * Use this object as a separator. + **/ +da.ui.Menu.separator = { + "class": "menu_separator", + html: "
", + onClick: function (event) { + if(event) + event.stop(); + } +}; + +// Hides the menu if click happened somewhere outside of the menu. +window.addEvent("click", function (e) { + var target = e.target; + if(VISIBLE_MENU && (!target || !$(target).getParents().contains(VISIBLE_MENU._el))) + VISIBLE_MENU.hide(); +}); + +})(); addfile ./contrib/musicplayer/src/libs/ui/NavigationColumn.js hunk ./contrib/musicplayer/src/libs/ui/NavigationColumn.js 1 +//#require "libs/ui/Column.js" +//#require "libs/util/util.js" + +/** section: UserInterface + * class da.ui.NavigationColumn < da.ui.Column + * + * Extends Column class to provide common implementation of a navigation column. + **/ +da.ui.NavigationColumn = new Class({ + Extends: da.ui.Column, + + /** + * da.ui.NavigationColumn#view -> {map: $empty, finished: $empty} + * + * Use this object to pass arguments to `Application.db.view()`. + * + * If `view.finished` is left empty, it will be replaced with function which will + * render the list as soon as map/reduce proccess finishes. + **/ + view: { + map: function (doc, emit) { + if(!this._passesFilter(doc)) + return false; + + emit(doc.id, { + title: doc.title || doc.id + }); + }, + + finished: $empty + }, + + options: { + filter: null, + killView: true + }, + + /** + * new da.ui.NavigationColumn([options]) + * - options.filter (Object | Function): filtering object or function. + * - options.db (BrowserCouch): [[BrowserCouch]] database to use for views. + * Defaults to `Application.db`. + * + * If `filter` is provided than it will be applied during the map/reduce proccess. + * If it's an [[Object]] than only documents with same properties as those + * in `filter` will be considered, and if it's an [[Function]], + * than it *must* return `true` if document should be passed to + * any aditional filters, or `false` if the document should be discarded. + * First argument of the `filter` function will be the document itself. + * + * If the column lacks map/reduce view but `total_count` is present, [[da.ui.NavigationColumn#render]] will be called. + * + * All other options are the same as for [[da.ui.Column]]. + **/ + initialize: function (options) { + this.parent(options); + this._el.addClass("navigation_column"); + + // Small speed-hack + if(!this.options.filter) + this._passesFilter = $lambda(true); + + this._el.addEvent("click:relay(.column_item)", this.click.bind(this)); + + if(this.view) { + this.view.map = this.view.map.bind(this); + if(!this.view.finished || this.view.finished === $empty) + this.view.finished = this.mapReduceFinished.bind(this); + else + this.view.finished = this.view.finished.bind(this); + + if(this.view.reduce) + this.view.reduce = this.view.reduced.bind(this); + if(!this.view.updated && !this.view.temporary) + this.view.updated = this.mapReduceUpdated; + if(this.view.updated) + this.view.updated = this.view.updated.bind(this); + + (options.db || da.db.DEFAULT).view(this.view); + } else if(this.options.totalCount) { + this.injectBottom(this.options.parentElement || document.body); + this.render(); + } + }, + + /** + * da.ui.NavigationColumn#mapReduceFinished(values) -> this + * - values (Object): an object with result rows and `findRow` function. + * + * Function called when map/reduce proccess finishes, if not specified otherwise in view. + * This function will provide [[da.ui.NavigationColumn#getItem]], update `total_count` option and render the column. + **/ + mapReduceFinished: function (values) { + // BrowserCouch's findRow() needs rows to be sorted by id. + this._rows = $A(values.rows); + this._rows.sort(this.compareFunction); + + this.updateTotalCount(values.rows.length); + this.injectBottom(this.options.parentElement || document.body); + return this.render(); + }, + + /** + * da.ui.NavigationColumn#mapReduceUpdated(values) -> this + * - values (Object): rows returned by map/reduce process. + * + * Note that this will have to re-render the whole column, as it's possible + * that one of the new documents should be rendered in the middle of already + * rendered ones (due to sorting). + **/ + mapReduceUpdated: function (values) { + this._rows = $A(da.db.DEFAULT.views[this.view.id].view.rows); + this._rows.sort(this.compareFunction); + this.options.totalCount = this._rows.length; + return this.rerender(); + }, + + /** + * da.ui.NavigationColumn#getItem(index) -> Object + * - index (Number): index number of the item in the list. + **/ + getItem: function (index) { + return this._rows[index]; + }, + + /** + * da.ui.NavigationColumn#renderItem(index) -> Element + * - index (Number): position of the item that needs to be rendered. + * + * This function relies on `title`, `subtitle` and `icon` properties from emitted documents. + **/ + renderItem: function (index) { + var item = this.getItem(index).value, + el = new Element("a", {href: "#", title: item.title}); + + if(item.icon) + el.grab(new Element("img", {src: item.icon})); + if(item.title) + el.grab(new Element("span", {html: item.title, "class": "title"})); + if(item.subtitle) + el.grab(new Element("span", {html: item.subtitle, "class": "subtitle"})); + + return el; + }, + + /** + * da.ui.NavigationColumn#createFilter(item) -> Object | Function + * - item (Object): one of the rendered objects, usually clicked one. + * + * Returns an object with properties which will be required from + * on columns "below" this one. + * + * If function is returned, than returned function will be called + * by Map/Reduce proccess on column "below" and should return `true`/`false` + * depending if the document meets criteria. + * + * #### Examples + * + * function createFilter (item) { + * return {artist_id: item.id}; + * } + * + **/ + createFilter: function (item) { + return {}; + }, + + click: function (event, el) { + var item = this.getItem(el.retrieve("column_index")); + if(this._active_el) + this._active_el.removeClass("active_column_item"); + + this._active_el = el.addClass("active_column_item"); + this.fireEvent("click", [item, event, el]); + + return item; + }, + + /** + * da.ui.NavigationColumn#compareFunction(a, b) -> Number + * - a (Object): first document. + * - b (Object): second document. + * + * Function used for sorting items returned by map/reduce proccess. Compares documents by their `title` property. + * + * [See meanings of return values](https://developer.mozilla.org/en/Core_JavaScript_1.5_Reference/Objects/Array/sort#Description). + **/ + compareFunction: function (a, b) { + a = a.value.title; + b = b.value.title; + + if(a < b) return -1; + if(a > b) return 1; + return 0; + }, + + destroy: function () { + this.parent(); + if(this.view) + if(this.options.killView) + (this.options.db || da.db.DEFAULT).killView(this.view.id); + else + (this.options.db || da.db.DEFAULT).removeEvent("update." + this.view.id, this.view.updated); + }, + + _passesFilter: function (doc) { + var filter = this.options.filter; + if(!filter) + return false; + + return (typeof(filter) === "object") ? Hash.containsAll(doc, filter) : filter(doc); + } +}); addfile ./contrib/musicplayer/src/libs/ui/ui.js hunk ./contrib/musicplayer/src/libs/ui/ui.js 1 +/** + * == UserInterface == + * + * Common UI classes like [[Column]] and [[Menu]]. + **/ + +/** section: UserInterface + * da.ui + **/ +da.ui = {}; adddir ./contrib/musicplayer/src/libs/util addfile ./contrib/musicplayer/src/libs/util/BinaryFile.js hunk ./contrib/musicplayer/src/libs/util/BinaryFile.js 1 +/* + * Binary Ajax 0.2 + * + * Copyright (c) 2008 Jacob Seidelin, cupboy@gmail.com, http://blog.nihilogic.dk/ + * Copyright (c) 2010 Josip Lisec + * MIT License [http://www.opensource.org/licenses/mit-license.php] + * + * Adoption for MooTools, da.util.BinaryFile#unpack(), da.util.BinaryFile#getBitsAt() and Request.Binary + * were added by Josip Lisec. + */ + +(function () { +/** section: Utilities + * class da.util.BinaryFile + * + * Class containing methods for working with files as binary data. + **/ +var BinaryFile = new Class({ + /** + * new da.util.BinaryFile(data[, options]) + * - data (String): the binary data. + * - options.offset (Number): initial offset. + * - options.length (Number): length of the data. + * - options.bigEndian (Boolean): defaults to `false`. + **/ + initialize: function (data, options) { + options = options || {}; + this.data = data; + this.offset = options.offset || 0; + this.length = options.length || 0; + this.bigEndian = options.bigEndian || false; + + if(typeof data === "string") { + this.length = this.length || data.length; + } else { + // In this case we're probably dealing with IE, + // and in order for this to work, VisualBasic-script magic is needed, + // for which we don't have enough of mana. + throw Exception("This browser is not supported"); + } + }, + + /** + * da.util.BinaryFile#getByteAt(offset) -> Number + **/ + getByteAt: function (offset) { + return this.data.charCodeAt(offset + this.offset) & 0xFF; + }, + + /** + * da.util.BinaryFile#getSByteAt(offset) -> Number + **/ + getSByteAt: function(iOffset) { + var iByte = this.getByteAt(iOffset); + return iByte > 127 ? iByte - 256 : iByte; + }, + + /** + * da.util.BinaryFile#getShortAt(offset) -> Number + **/ + getShortAt: function(iOffset) { + var iShort = this.bigEndian ? + (this.getByteAt(iOffset) << 8) + this.getByteAt(iOffset + 1) + : (this.getByteAt(iOffset + 1) << 8) + this.getByteAt(iOffset) + + return iShort < 0 ? iShort + 65536 : iShort; + }, + + /** + * da.util.BinaryFile#getSShortAt(offset) -> Number + **/ + getSShortAt: function(iOffset) { + var iUShort = this.getShortAt(iOffset); + return iUShort > 32767 ? iUShort - 65536 : iUShort; + }, + + /** + * da.util.BinaryFile#getLongAt(offset) -> Number + **/ + getLongAt: function(iOffset) { + var iByte1 = this.getByteAt(iOffset), + iByte2 = this.getByteAt(iOffset + 1), + iByte3 = this.getByteAt(iOffset + 2), + iByte4 = this.getByteAt(iOffset + 3); + + var iLong = this.bigEndian ? + (((((iByte1 << 8) + iByte2) << 8) + iByte3) << 8) + iByte4 + : (((((iByte4 << 8) + iByte3) << 8) + iByte2) << 8) + iByte1; + if (iLong < 0) iLong += 4294967296; + return iLong; + }, + + /** + * da.util.BinaryFile#getSLongAt(offset) -> Number + **/ + getSLongAt: function(iOffset) { + var iULong = this.getLongAt(iOffset); + return iULong > 2147483647 ? iULong - 4294967296 : iULong; + }, + + /** + * da.util.BinaryFile#getStringAt(offset, length) -> String + **/ + getStringAt: function(offset, length) { + var str = new Array(length); + length += offset; + + for(var i = 0; offset < length; offset++, i++) + str[i] = String.fromCharCode(this.getByteAt(offset)); + + return str.join(""); + }, + + /** + * da.util.BinaryFile#getCharAt(offset) -> String + * - offset (Number): position of the character. + **/ + getCharAt: function(iOffset) { + return String.fromCharCode(this.getByteAt(iOffset)); + }, + + /** + * da.util.BinaryFile#getBitsAt(offset[, length]) -> Array + * - offset (Number): position of character. + * - length (Number): number of bits, if result has less, zeors will be appended at the begging. + * + * Returns an array with bit values. + * + * #### Example + * (new da.util.BinaryFile("2")).getBitsAt(0, 8) + * // -> [0, 0, 1, 1, 0, 0, 1, 0] + * + **/ + getBitsAt: function (offset, padding) { + var bits = this.getByteAt(offset).toString(2); + padding = padding || 8; + if(padding && bits.length < padding) { + var delta = padding - bits.length; + padding = []; + while(delta--) padding.push(0); + bits = padding.concat(bits).join(""); + } + + var n = bits.length, + result = new Array(n); + + while(n--) + result[n] = +bits[n]; + + return result; + }, + + /** + * da.util.BinaryFile#getBitsFromStringAt(offset, length) -> Array + * - offset (Number): position of the first character. + * - length (Number): length of the string. + * + * Returns an array with return values of [[da.util.BinaryFile#getBitsAt]]. + **/ + getBitsFromStringAt: function (offset, length) { + var bits = new Array(length); + length += offset; + + for(var i = 0; offset < length; offset++, i++) + bits[i] = this.getBitsAt(offset); + + return bits; + }, + + /** + * da.util.BinaryFile#toEncodedString() -> String + * Returns URI encoded value of data. + * + * We're not using from/toBase64 because `btoa()`/`atob()` functions can't convert everything to/from Base64 encoding, + * `encodeUriComponent()` method seems to be more reliable. + **/ + toEncodedString: function() { + return encodeURIComponent(this.data); + }, + + /** + * da.util.BinaryFile#unpack(format) -> Array + * - format (String): String according to which data will be unpacked. + * + * This method is using format similar to the one used in Python, and does exactly the same job, + * mapping C types to JavaScript ones. + * + * + * #### Code mapping + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
CodeC typeReturnsFunction
b_Bool[[Boolean]]
cchar[[String]]String with one character
hshort[[Number]]
iint[[Number]]
llong[[Number]]
schar[][[String]]
Schar[][[String]]String with removed whitespace (including \0 chars)
tint[[Array]]Returns an array with bit values
Tchar[[Array]]Returns an array of arrays with bit values.
x/[[String]]Padding byte
+ * + * + * #### External resources + * * [Python implementation of `unpack`](http://docs.python.org/library/struct.html#format-strings) + **/ + _unpack_format: /(\d+\w|\w)/g, + _whitespace: /\s./g, + unpack: function (format) { + format = format.replace(this._whitespace, ""); + var pairs = format.match(this._unpack_format), + n = pairs.length, + result = []; + + if(!pairs.length) + return pairs; + + var offset = 0; + for(var n = 0, m = pairs.length; n < m; n++) { + var pair = pairs[n], + code = pair.slice(-1), + repeat = +pair.slice(0, pair.length - 1) || 1; + + switch(code) { + case 'b': + while(repeat--) + result.push(this.getByteAt(offset++) === 1); + break; + case 'c': + while(repeat--) + result.push(this.getCharAt(offset++)); + break; + case 'h': + while(repeat--) { + result.push(this.getShortAt(offset)); + offset += 2; + } + break; + case 'i': + while(repeat--) + result.push(this.getByteAt(offset++)); + break; + case 'l': + while(repeat--) { + result.push(this.getLongAt(offset)); + offset += 4; + } + break; + case 's': + result.push(this.getStringAt(offset, repeat)); + offset += repeat; + break; + case 'S': + result.push(this.getStringAt(offset, repeat).strip()); + offset += repeat; + break; + case 't': + while(repeat--) + result.push(this.getBitsAt(offset++, 2)); + break; + case 'T': + result.push(this.getBitsFromStringAt(offset, repeat)); + offset += repeat; + break; + case 'x': + offset += repeat; + break; + default: + throw new Exception("Unknow code is being used (" + code + ")."); + } + } + + return result; + } +}); + +BinaryFile.extend({ + /** + * da.util.BinaryFile.fromEncodedString(data) -> da.util.BinaryFile + * - data (String): URI encoded string. + **/ + fromEncodedString: function(encoded_str) { + return new BinaryFile(decodeURIComponent(encoded_str)); + } +}); + +da.util.BinaryFile = BinaryFile; + +/** section: Utilities + * class Request + * + * MooTools Request class + **/ + +/** section: Utilities + * class Request.Binary < Request + * + * Class for receiving binary data over XMLHTTPRequest. + * If server supports setting Range header, then only minimal data will be downloaded. + * + * This works in two phases, if a range option is set then a HEAD request is performed to get the + * total length of the file and to see if server supports `Range` HTTP header. + * If server supports `Range` header than only requested range is asked from server in another HTTP GET request, + * otherwise the whole file is downloaded and sliced to desired range. + * + **/ +Request.Binary = new Class({ + Extends: Request, + + /** + * Request.Binary#acceptsRange -> String + * Indicates if server supports HTTP requests with `Range` header. + **/ + acceptsRange: false, + options: { + range: null + }, + + /** + * new Request.Binary(options) + * - options (Object): all of the [Request](http://mootools.net/docs/core/Request/Request) options can be used. + * - options.range (Object): array with starting position and length. `[0, 100]`. + * If first element is negative, starting position will be calculated from end of the file. + * - options.bigEndian (Boolean) + * fires request, complete, success, failure, cancel + * + * Functions attached to `success` event will receive response in form of [[da.util.BinaryFile]] as their first argument. + **/ + initialize: function (options) { + this.parent($extend(options, { + method: "GET" + })); + + this.headRequest = new Request({ + url: options.url, + method: "HEAD", + emulation: false, + evalResponse: false, + onSuccess: this.onHeadSuccess.bind(this) + }); + }, + + onHeadSuccess: function () { + this.acceptsRange = this.headRequest.getHeader("Accept-Ranges") === "bytes"; + + var range = this.options.range; + if(range[0] < 0) + range[0] += +this.headRequest.getHeader("Content-Length"); + range[1] = range[0] + range[1] - 1; + this.options.range = range; + + if(this.headRequest.isSuccess()) + this.send(this._send_options || {}); + }, + + success: function (text) { + var range = this.options.range; + this.response.binary = new BinaryFile(text, { + offset: range && !this.acceptsRange ? range[0] : 0, + length: range ? range[1] - range[0] + 1 : 0, + bigEndian: this.options.bigEndian + }); + this.onSuccess(this.response.binary); + }, + + send: function (options) { + if(this.headRequest.running || this.running) + return this; + + if(!this.headRequest.isSuccess()) { + this._send_options = options; + this.headRequest.send(); + return this; + } + + if(typeof this.xhr.overrideMimeType === "function") + this.xhr.overrideMimeType("text/plain; charset=x-user-defined"); + + this.setHeader("If-Modified-Since", "Sat, 1 Jan 1970 00:00:00 GMT"); + var range = this.options.range; + if(range && this.acceptsRange) + this.setHeader("Range", "bytes=" + range[0] + "-" + range[1]); + + return this.parent(options); + } +}); + +})(); addfile ./contrib/musicplayer/src/libs/util/Goal.js hunk ./contrib/musicplayer/src/libs/util/Goal.js 1 +/** section: Utilities + * class da.util.Goal + * implements Events, Options + * + * A helper class which makes it easier to manage async nature of JS. + * An Goal consists of several checkpoints, which, in order to complete the goal have to be reached. + * + * #### Examples + * + * var travel_the_world = new da.util.Goal({ + * checkpoints: ["Nicosia", "Vienna", "Berlin", "Paris", "London", "Reykjavik"], + * + * onCheckpoint: function (city) { + * console.log("Hello from " + name + "!"); + * }, + * + * onFinish: function () { + * console.log("Yay!"); + * }, + * + * afterCheckpoint: { + * Paris: function () { + * consle.log("Aww..."); + * } + * } + * }); + * + * travel_the_world.checkpoint("Nicosia"); + * // -> "Hello from Nicosia!" + * travel_the_world.checkpoint("Berlin"); + * // -> "Hello from Berlin!" + * travel_the_world.checkpoint("Paris"); + * // -> "Hello from Paris!" + * // -> "Aww..." + * travel_the_world.checkpoint("London"); + * // -> "Hello from London!" + * travel_the_world.checkpoint("Reykyavik"); + * // -> "Hello from Paris!" + * travel_the_world.checkpoint("Vienna"); + * // -> "Hello from Vienna!" + * // -> "Yay!" + * + **/ +da.util.Goal = new Class({ + Implements: [Events, Options], + + options: { + checkpoints: [], + afterCheckpoint: {} + }, + /** + * da.util.Goal#finished -> Boolean + * + * Indicates if all checkpoints have been reached. + **/ + finished: false, + + /** + * new da.util.Goal([options]) + * - options.checkpoints (Array): list of checkpoints needed for goal to finish. + * - options.onFinish (Function): called once all checkpoints are reached. + * - options.onCheckpoint (Function): called after each checkpoint. + * - options.afterCheckpoint (Object): object keys represent checkpoints whose functions will be called after respective checkpoint. + **/ + initialize: function (options) { + this.setOptions(options); + this.completedCheckpoints = []; + }, + + /** + * da.util.Goal#checkpoint(name) -> undefined | false + * - name (String): name of the checkpoint. + * fires checkpoint, finish + * + * Registers that checkpoint has been reached; + **/ + checkpoint: function (name) { + if(!this.options.checkpoints.contains(name)) + return false; + if(this.completedCheckpoints.contains(name)) + return false; + + this.completedCheckpoints.push(name); + this.fireEvent("checkpoint", [name, this.completedCheckpoints]); + + if(this.options.afterCheckpoint[name]) + this.options.afterCheckpoint[name](this.completedCheckpoints); + + if(this.completedCheckpoints.containsAll(this.options.checkpoints)) + this.finish(); + }, + + finish: function () { + this.finished = true; + this.fireEvent("finish"); + } +}); addfile ./contrib/musicplayer/src/libs/util/ID3.js hunk ./contrib/musicplayer/src/libs/util/ID3.js 1 +/** + * == ID3 == + * + * ID3 parsers and common interface. + **/ + +/** section: ID3 + * class da.util.ID3 + * + * Class for extracting ID3 metadata from music files. Provides an interface to ID3v1 and ID3v2 parsers. + * The reason why ID3 v1 and v2 parsers are implemented separately is due to idea that parsers for other + * formats (OGG Comments, especially) can be later implemented with ease. +**/ +da.util.ID3 = new Class({ + Implements: Options, + + options: { + url: null, + onSuccess: $empty, + onFailure: $empty + }, + + /** + * da.util.ID3#parsers -> Array + * List of parsers with which the file will be tested. Defaults to ID3v2 and ID3v1 parsers. + **/ + parsers: [], + + /** + * da.util.ID3#parser -> Object + * + * Instance of the parser in use. + **/ + parser: null, + + /** + * new da.util.ID3(options) + * - options.url (String): URL of the MP3 file. + * - options.onSuccess (Function): called with found tags once they are parsed. + * - options.onFailure (Function): called if none of available parsers know how to extract tags. + * + **/ + initialize: function (options) { + this.setOptions(options); + this.parsers = $A(da.util.ID3.parsers); + this._getFile(this.parsers[0]); + }, + + _getFile: function (parser) { + if(!parser) + return this.options.onFailure(); + + this.request = new Request.Binary({ + url: this.options.url, + range: parser.range, + onSuccess: this._onFileFetched.bind(this) + }); + + this.request.send(); + }, + + _onFileFetched: function (data) { + if(this.parsers[0] && this.parsers[0].test(data)) + this.parser = (new this.parsers[0](data, this.options, this.request)); + else + this._getFile(this.parsers.shift()); + } +}); + +/** + * da.util.ID3.parsers -> Array + * Array with all known parsers. + **/ + +da.util.ID3.parsers = []; + +//#require "libs/util/ID3v2.js" +//#require "libs/util/ID3v1.js" addfile ./contrib/musicplayer/src/libs/util/ID3v1.js hunk ./contrib/musicplayer/src/libs/util/ID3v1.js 1 +//#require "libs/util/BinaryFile.js" + +/** section: ID3 + * class da.util.ID3v1Parser + * + * ID3 v1 parser based on [ID3 v1 specification](http://mpgedit.org/mpgedit/mpeg_format/mpeghdr.htm#MPEGTAG). + * + * #### Notes + * All of these methods are private. + **/ + +(function () { +var CACHE = {}; + +var ID3v1Parser = new Class({ + /** + * new da.util.ID3v1Parser(data, options) + * - data (da.util.BinaryFile): ID3 tag. + * - options.url (String): URL of the file. + * - options.onSuccess (Function): function called once tags are parsed. + **/ + initialize: function (data, options) { + this.data = data; + this.options = options; + if(!this.options.url) + this.options.url = Math.uuid(); + + if(CACHE[options.url]) + options.onSuccess(CACHE[options.url]); + else + this.parse(); + }, + + /** + * da.util.ID3v1Parser#parse() -> undefined + * Extracts the tags from file. + **/ + parse: function () { + // 29x - comment + this.tags = this.data.unpack("xxx30S30S30S4S29x2i").associate([ + "title", "artist", "album", "year", "track", "genre" + ]); + this.tags.year = +this.tags.year; + if(isNaN(this.tags.year)) + this.tags.year = 0; + + this.options.onSuccess(CACHE[this.options.url] = this.tags); + } +}); + +ID3v1Parser.extend({ + /** + * da.util.ID3v1Parser.range -> [-128, 128] + * Range in which ID3 tag is positioned. -128 indicates that it's last 128 bytes. + **/ + range: [-128, 128], + + /** + * da.util.ID3v1Parser.test(data) -> Boolean + * - data (da.util.BinaryFile): data that needs to be tested. + * + * Checks if first three characters equal to `TAG`, as per ID3 v1 specification. + **/ + test: function (data) { + return data.getStringAt(0, 3) === "TAG"; + } +}); + +da.util.ID3v1Parser = ID3v1Parser; +da.util.ID3.parsers.push(ID3v1Parser); +})(); addfile ./contrib/musicplayer/src/libs/util/ID3v2.js hunk ./contrib/musicplayer/src/libs/util/ID3v2.js 1 +//#require "libs/util/BinaryFile.js" +/** section: ID3 + * class da.util.ID3v2Parser + * + * ID3 v2 parser implementation based on [Mutagen](http://code.google.com/p/mutagen) and + * [ruby-mp3info](http://ruby-mp3info.rubyforge.org) libraries. + * + * #### Known frames + * This is the list of frames that this implementation by default can parse - only those that are needed to get + * the basic information about song. Others can be added via da.util.ID3v2Parser.addFrameParser. + * + * * TRCK + * * TIT1 + * * TIT2 + * * TIT3 + * * TPE1 + * * TPE2 + * * TALB + * * TYER + * * TIME + * * TCON + * * USLT + * * WOAR + * * WXXX + * + * As well as their equivalents in ID3 v2.2 specification. + * + * #### Notes + * All methods except for `addFrameParser` are private. + * + * #### External resources + * * [ID3v2.4 specification](http://www.id3.org/id3v2.4.0-structure) + * * [ID3v2.4 native frames](http://www.id3.org/id3v2.4.0-frames) + * * [ID3v2.3 specification](http://www.id3.org/id3v2.3.0) + * * [ID3v2.2 specification](http://www.id3.org/id3v2-00) -- obsolete + **/ + +(function () { +/** section: ID3 + * da.util.ID3v2Parser.frameTypes + * + * Contains know ID3v2 frame types. + **/ +var BinaryFile = da.util.BinaryFile, + CACHE = [], +FrameType = { + /** + * da.util.ID3v2Parser.frameTypes.text(offset, size) -> String + **/ + text: function (offset, size) { + var d = this.data; + if(d.getByteAt(offset) === 1) { + // Unicode is being used, and we're trying to detect Unicode BOM. + // (we don't actually care if it's little or big endian) + if(d.getByteAt(offset + 1) + d.getByteAt(offset + 2) === 255 + 254) { + offset += 2; + size -= 2; + } + } + + return d.getStringAt(offset + 1, size - 1).strip(); + }, + + /** + * da.util.ID3v2Parser.frameTypes.textNumeric(offset, size) -> String + **/ + textNumeric: function(offset, size) { + return +FrameType.text.call(this, offset, size); + }, + + /** + * da.util.ID3v2Parser.frameTypes.link(offset, size) -> String + **/ + link: function (offset, size) { + return this.data.getStringAt(offset, size).strip(); + }, + + /** + * da.util.ID3v2Parser.frameTypes.userLink(offset, size) -> String + **/ + userLink: function (offset, size) { + var str = this.data.getStringAt(offset, size); + return str.slice(str.lastIndexOf("\0") + 1); + }, + + /** + * da.util.ID3v2Parser.frameTypes.unsyncedLyrics(offset, size) -> String + **/ + unsyncedLyrics: function (offset, size) { + var is_utf8 = this.data.getByteAt(offset) === 1, + lang = this.data.getStringAt(offset += 1, 3); + + return this.data.getStringAt(offset += 3, size - 4).strip(); + }, + + ignore: $empty +}, +FRAMES = { + // ID3v2.4 tags + SEEK: $empty, + + // ID3v2.3 tags + TRCK: function (offset, size) { + var data = FrameType.text.call(this, offset, size); + return +data.split("/")[0] + }, + TIT1: FrameType.text, + TIT2: FrameType.text, + TIT3: FrameType.text, + TPE1: FrameType.text, + TPE2: FrameType.text, + TALB: FrameType.text, + TYER: FrameType.textNumeric, + TIME: $empty, + TCON: function (offset, size) { + // Genre, can be either "(123)Genre", "(123)" or "Genre". + var data = FrameType.text.call(this, offset, size); + return +((data.match(/^\(\d+\)/) || " ")[0].slice(1, -1)); + }, + USLT: FrameType.unsyncedLyrics, + WOAR: FrameType.link, + WXXX: FrameType.userLink +}; + +// ID3v2.2 tags (the structure is the same as in later versions, but they use different names) +$extend(FRAMES, { + UFI: FRAMES.UFID, + TT1: FRAMES.TIT1, + TT2: FRAMES.TIT2, + TT3: FRAMES.TIT3, + TP1: FRAMES.TPE1, + TP2: FRAMES.TPE2, + TP3: FRAMES.TPE3, + TP4: FRAMES.TPE4, + TAL: FRAMES.TALB, + TRK: FRAMES.TRCK, + TYE: FRAMES.TYER, + TPB: FRAMES.TPUB, + ULT: FRAMES.USLT, + WAR: FRAMES.WOAR, + WXX: FRAMES.WXXX +}); + +var ID3v2Parser = new Class({ + /** + * new da.util.ID3v2Parser(data, options, request) + * - data (BinaryFile): tag. + * - options.onSuccess (Function): function which will be called once tag is parsed. + * - request (Request.Binary): original HTTP request object. + **/ + initialize: function (data, options, request) { + this.options = options; + + this.data = data; + this.data.bigEndian = true; + + this.header = {}; + this.frames = {}; + + this._request = request; + + if(CACHE[options.url]) + options.onSuccess(CACHE[options.url]); + else + this.parse(); + }, + + /** + * da.util.ID3v2Parser#parse() -> undefined + * Parses the tag. If size of tag exceeds current data (and it usually does) + * another HTTP GET request is issued to get the rest of the file. + **/ + /** + * da.util.ID3v2Parser#header -> {majorVersion: 0, minorVersion: 0, flags: 0, size: 0} + * Parsed ID3 header. + **/ + /** + * da.util.ID3v2Parser#version -> 2.2 | 2.3 | 2.4 + **/ + parse: function () { + this.header = this.data.unpack("xxx2ii4s").associate([ + 'majorVersion', 'minorVersion', "flags", "size" + ]); + this.version = 2 + (this.header.majorVersion/10) + this.header.minorVersion; + this.header.size = this.unsync(this.header.size) + 10; + + this.parseFlags(); + + if(this.data.length >= this.header.size) + return this.parseFrames(); + + this._request.options.range = [0, this.header.size]; + // Removing event listeners which were added by ID3 + this._request.removeEvents('success'); + this._request.addEvent('success', function (data) { + this.data = data; + this.parseFrames(); + }.bind(this)); + this._request.send(); + }, + + /** + * da.util.ID3v2Parser#parseFlags() -> undefined + * Parses header flags. + **/ + /** + * da.util.ID3v2Parser#flags -> {unsync_all: false, extended: false, experimental: false, footer: false} + * Header flags. + **/ + parseFlags: function () { + var flags = this.header.flags; + this.flags = { + unsync_all: flags & 0x80, + extended: flags & 0x40, + experimental: flags & 0x20, + footer: flags & 0x10 + }; + }, + + /** + * da.util.ID3v2Parser#parseFrames() -> undefined + * Calls proper function for parsing frames depending on tag's version. + **/ + parseFrames: function () { + if(this.version >= 2.3) + this.parseFrames_23(); + else + this.parseFrames_22(); + + CACHE[this.options.url] = this.frames; + this.options.onSuccess(this.simplify(), this.frames); + }, + + /** + * da.util.ID3v2Parser#parseFrames_23() -> undefined + * Parses ID3 frames from ID3 v2.3 and newer. + **/ + parseFrames_23: function () { + if(this.version >= 2.4 && this.flags.unsync_all) + this.data.data = this.unsync(0, this.header.size); + + var offset = 10, + ext_header_size = this.data.getStringAt(offset, 4), + tag_size = this.header.size; + + // Some tagging software is apparently know for setting + // "extended header present" flag but then ommiting it from the file, + // which means that ext_header_size will be equal to name of a frame. + if(this.flags.extended && !FRAMES[ext_header_size]) { + if(this.version >= 2.4) + ext_header_size = this.unsync(ext_header_size) - 4; + else + ext_header_size = this.data.getLongAt(10); + + offset += ext_header_size; + } + + while(offset < tag_size) { + var foffset = offset, + frame_name = this.data.getStringAt(foffset, 4), + frame_size = this.unsync(foffset += 4, 4), + frame_flags = [this.data.getByteAt(foffset += 4), this.data.getByteAt(foffset += 1)]; + foffset++; // frame_flags + + if(!frame_size) + break; + + if(FRAMES[frame_name] && frame_size) + this.frames[frame_name] = FRAMES[frame_name].call(this, foffset, frame_size); + + //console.log(frame_name, this.frames[frame_name], [foffset, frame_size]); + offset += frame_size + 10; + } + }, + + /** + * da.util.ID3v2Parser#parseFrames_22() -> undefined + * Parses ID3 frames from ID3 v2.2 tags. + **/ + parseFrames_22: function () { + var offset = 10, + tag_size = this.header.size; + + while(offset < tag_size) { + var foffset = offset, + frame_name = this.data.getStringAt(foffset, 3), + frame_size = (new BinaryFile( + "\0" + this.data.getStringAt(foffset += 3, 3), + {bigEndian:true} + )).getLongAt(0); + foffset += 3; + + if(!frame_size) + break; + + if(FRAMES[frame_name] && frame_size) + this.frames[frame_name] = FRAMES[frame_name].call(this, foffset, frame_size); + + //console.log(frame_name, this.frames[frame_name], [foffset, frame_size]); + offset += frame_size + 6; + } + }, + + /** + * da.util.ID3v2Parser#unsync(offset, length[, bits = 7]) -> Number + * da.util.ID3v2Parser#unsync(string) -> Number + * - offset (Number): offset from which so start unsyncing. + * - length (Number): length string to unsync. + * - bits (Number): number of bits used. + * - string (String): String to unsync. + * + * Performs unsyncing process defined in ID3 specification. + **/ + unsync: function (offset, length, bits) { + bits = bits || 7; + var mask = (1 << bits) - 1, + bytes = [], + numeric_value = 0, + data = this.data; + + if(typeof offset === "string") { + data = new BinaryFile(offset, {bigEndian: true}); + length = offset.length; + offset = 0; + } + + if(length) { + for(var n = offset, m = offset + length; n < m; n++) + bytes.push(data.getByteAt(n) & mask); + + bytes.reverse(); + } else { + var value = data.getByteAt(offset); + while(value) { + bytes.push(value & mask); + value >>= 8; + } + } + + for(var n = 0, i = 0, m = bytes.length * bits; n < m; n+=bits, i++) + numeric_value += bytes[i] << n; + + return numeric_value; + }, + + /** + * da.util.ID3v2Parser#simplify() -> Object + * + * Returns humanised version of data parsed from frames. + * Returned object contains these values (in brackets are used frames or default values): + * + * * title (`TIT2`, `TT2`, `"Unknown"`) + * * album (`TALB`, `TAL`, `"Unknown"`) + * * artist (`TPE2`, `TPE1`, `TP2`, `TP1`, `"Unknown"`) + * * track (`TRCK`, `TRK`, `0`) + * * year (`TYER`, `TYE`, `0`) + * * genre (`TCON`, `TCO`, `0`) + * * lyrics (`USLT`, `ULT`, _empty string_) + * * links: official (`WOAR`, `WXXX`, `WAR`, `WXXX`, _empty string_) + **/ + simplify: function () { + var f = this.frames; + return !f || !$H(f).getKeys().length ? {} : { + title: f.TIT2 || f.TT2 || "Unknown", + album: f.TALB || f.TAL || "Unknown", + artist: f.TPE2 || f.TPE1 || f.TP2 || f.TP1 || "Unknown", + track: f.TRCK || f.TRK || 0, + year: f.TYER || f.TYE || 0, + genre: f.TCON || f.TCO || 0, + lyrics: f.USLT || f.ULT || "", + links: { + official: f.WOAR || f.WXXX || f.WAR || f.WXX || "" + } + }; + } +}); + +ID3v2Parser.extend({ + /** + * da.util.ID3v2Parser.range -> [0, 14] + * + * Default position of ID3v2 header, including extended header. + **/ + range: [0, 10 + 4], + + /** + * da.util.ID3v2Parser.test(data) -> Boolean + * - data (BinaryFile): the tag. + * + * Checks if data begins with `ID3` and major version is less than 5. + **/ + test: function (data) { + return data.getStringAt(0, 3) === "ID3" && data.getByteAt(3) <= 4; + }, + + /** + * da.util.ID3v2Parser.addFrameParser(frameName, fn) -> da.util.ID3v2Parser + * - frameName (String): name of the frame. + * - fn (Function): function which will parse the data. + * + * Use this method to add your own ID3v2 frame parsers. You can access this as `da.util.ID3v2Parser.addFrameParser`. + * + * `fn` will be called with following arguments: + * * offset - position at frame appears in data + * * size - size of the frame, including header + * + * + * `this` keyword inside `fn` will refer to instance of ID3v2. + **/ + addFrameParser: function (name, fn) { + FRAMES[name] = fn; + return this; + } +}); + +ID3v2Parser.frameTypes = FrameType; +da.util.ID3v2Parser = ID3v2Parser; +da.util.ID3.parsers.push(ID3v2Parser); + +})(); addfile ./contrib/musicplayer/src/libs/util/util.js hunk ./contrib/musicplayer/src/libs/util/util.js 1 +/** + * == Utilities == + * Utility classes and extensions to Native objects. + **/ + +/** + * da.util + **/ +if(typeof da.util === "undefined") + da.util = {}; + +(function () { + +/** section: Utilities + * class String + * + * #### External resources + * * [MooTools String docs](http://mootools.net/docs/core/Native/String) + **/ +var NULL_BYTE = /\0/g, + INTERPOL_VAR = /\{(\w+)\}/g; + +String.implement({ + /** + * String.strip(@string) -> String + * + * Removes \0's from string. + **/ + strip: function () { + return this.replace(NULL_BYTE, ""); + }, + + /** + * String.interpolate(@string, data) -> String + * - data (Object | Array): object or an array with data. + * + * Interpolates string with data. + * + * #### Example + * + * "{0}/{1}%".interpolate([10, 100]) + * // -> "10/100%" + * + * "Hi {name}! You've got {new_mail} new messages.".interpolate({name: "John", new_mail: 10}) + * // -> "Hi John! You've got 10 new messages." + * + **/ + interpolate: function (data) { + if(!data) + return this.toString(); // otherwise typeof result === "object". + + return this.replace(INTERPOL_VAR, function (match, property) { + var value = data[property]; + return typeof value === "undefined" ? "{" + property + "}" : value; + }); + } +}); + +/** section: Utilities + * class Array + * + * #### External resources + * * [MooTools Array docs](http://mootools.net/docs/core/Native/Array) + * * [MDC Array specification](https://developer.mozilla.org/en/Core_JavaScript_1.5_Reference/Global_Objects/Array) + **/ +Array.implement({ + /** + * Array.zip(@array...) -> Array + * + * Returns an array whose n-th element contains n-th element from each argument. + * + * #### Example + * Array.zip([1,2,3], [1,2,3]) + * // -> [[1, 1], [2, 2], [3, 3]] + * + * #### See also + * * [Python's `zip` function](http://docs.python.org/library/functions.html?highlight=zip#zip) + **/ + zip: function () { + var n = this.length, + args = [this].concat($A(arguments)); + args_length = args.length, + zipped = new Array(n); + + while(n--) { + zipped[n] = new Array(args_length); + var m = args_length; + while(m--) + zipped[n][m] = args[m][n]; + } + + return zipped; + }, + + /** + * Array.containsAll(@array, otherArray) -> Boolean + * - otherArray (Array): array which has to contain all of the defined items. + * + * Checks if this array contains all of those provided in otherArray. + **/ + containsAll: function (other) { + var n = other.length; + + while(n--) + if(!this.contains(other[n])) + return false; + + return true; + } +}); + +/** section: Utilities + * class Hash + * + * #### External resources + * * [MooTools Hash docs](http://mootools.net/docs/core/Native/Hash) + **/ + +Hash.implement({ + /** + * Hash.containsAll(@hash, otherHash) -> Boolean + * - otherHash (Hash | Object): hash which has to contain all of the defined properties. + * + * Checks if all properties from this hash are present in otherHash. + **/ + containsAll: function (otherHash) { + for(var key in otherHash) + if(otherHash.hasOwnProperty(key) && otherHash[key] !== this[key]) + return false; + + return true; + } +}) + +})(); adddir ./contrib/musicplayer/src/resources adddir ./contrib/musicplayer/src/resources/css addfile ./contrib/musicplayer/src/resources/css/app.css hunk ./contrib/musicplayer/src/resources/css/app.css 1 +/*** Global styles ***/ +@font-face { + font-family: Junction; + font-style: normal; + font-weight: normal; + src: local('Junction'), url('resources/fonts/Junction.ttf') format('truetype'); +} + +body { + font-family: 'Liberation Sans', 'Helvetica Neue', Helvetica, sans-serif; + overflow: hidden; +} + +a { + text-decoration: none; + color: inherit; +} + +input[type="text"], input[type="password"] { + border: 1px solid #ddd; + border-top: 1px solid #c0c0c0; + background: #fff; + padding: 2px; +} + +input:focus, input:active { + border-color: #33519d; + -webkit-box-shadow: #33519d 0 0 5px; + -moz-box-shadow: #33519d 0 0 5px; + -o-box-shadow: #33519d 0 0 5px; + box-shadow: #33519d 0 0 5px; +} + +input[type="button"], input[type="submit"], button { + background: #ddd; + border: 1px transparent; + border-bottom: 1px solid #c0c0c0; + padding: 2px 7px; + color: #000; + text-shadow: #fff 0 1px 0; + + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + -o-border-radius: 4px; + border-radius: 4px; +} + +input[type="button"]:active, input[type="submit"]:active, button:active { + border-top: 1px solid #1e2128; + border-bottom: 0; + background: #33519d !important; + color: #fff; + text-shadow: #000 0 1px 1px; +} + +.no_selection { + -webkit-user-select: none; + -moz-user-select: none; + -o-user-select: none; + user-select: none; + cursor: default; +} + +/*** Dialogs ***/ +.dialog_wrapper { + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.2); + overflow: hidden; + position: fixed; + top: 0; + left: 0; + z-index: 2; +} + +.dialog { + margin: 50px auto 0 auto; + background: #fff; + border: 1px solid #ddd; + + -webkit-box-shadow: rgba(0, 0, 0, 0.4) 0 10px 40px; + -moz-box-shadow: rgba(0, 0, 0, 0.4) 0 10px 40px; + -o-box-shadow: rgba(0, 0, 0, 0.4) 0 10px 40px; + box-shadow: rgba(0, 0, 0, 0.4) 0 10px 40px; +} + +.dialog_title { + margin: 0; + padding: 5px; + text-indent: 10px; + font-size: 1.3em; + color: #fff; + background: #2f343e; + border-bottom: 1px solid #1e2128; + text-shadow: #1e2128 0 1px 0; +} + +#loader { + font-size: 2em; + width: 100%; + height: 100%; + text-align: center; + padding: 50px 0 0 0; +} + +/*** Navigation columns ***/ +.column_container { + float: left; + min-width: 200px; + margin-right: 1px; +} + +.column_container .column_header { + display: block; + width: inherit; + text-align: center; + font-size: 1.2em; + cursor: default; + padding: 2px 0; + background: #2f343e; + color: #fff; + text-shadow: #1e2128 0 1px 0; + border-right: 1px solid #1e2128; + border-bottom: 1px solid #1e2128; +} + +.column_container .column_header span { + display: block; + vertical-align: middle; + text-overflow: ellipsis; + width: 100%; +} + +.column_container .column_header:active, .column_container .column_header:focus, .column_header.active { + background-color: #1e2128; + padding: 3px 0 1px 0; + outline: 0; +} + +.column_header.active { + +} + +.column_container .navigation_column { + border-right: 1px solid #ddd; +} + +.column_container .navigation_column:last { + border-right: 5px solid #ddd; +} + +.navigation_column { + width: 100%; + background: #fff url(../images/column_background.png) 0 0 repeat; +/* background-attachment: fixed; */ + z-index: 1; +} + +.navigation_column .column_items_box { + width: inherit; +} + +.navigation_column .column_item { + display: block; + height: 20px; + padding: 5px 0; + width: inherit; + overflow: hidden; + text-overflow: ellipsis; + text-indent: 5px; + white-space: nowrap; +} + +.navigation_column a.column_item { + display: block; + cursor: default; +} + +.navigation_column .column_item img { + display: none; +} + +.navigation_column .column_item span { + /*display: block;*/ + vertical-align: middle; +} + +.navigation_column .column_item span.subtitle { + opacity: 0.5; + font-size: 0.9em; + margin-left: 5px; + vertical-align: bottom; +} + +.navigation_column .column_item_with_icon span { + margin-left: 20px; +} + +.navigation_column .active_column_item, .menu_item:hover, .navigation_column .column_item:focus, .menu_item a:focus { + background: #33519d !important; + text-shadow: #000 0 1px 0; + color: #fff !important; + outline: 0 !important; +} + +/*** Menus ***/ +.menu { + display: block; + text-indent: 0; + margin: 0 0 0 -1px; + padding: 3px 0; + position: fixed; + background: #fff; + color: #000; + min-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + list-style: none; + cursor: default; + z-index: 5; + border: 1px solid #ddd; + + -webkit-box-shadow: rgba(0, 0, 0, 0.3) 0 10px 30px; + -moz-box-shadow: rgba(0, 0, 0, 0.3) 0 10px 30px; + -o-box-shadow: rgba(0, 0, 0, 0.3) 0 10px 30px; + box-shadow: rgba(0, 0, 0, 0.3) 0 10px 30px; + + -webkit-border-radius: 3px; + -moz-border-radius: 3px; + -o-border-radius: 3px; + border-radius: 3px; +} + +.menu_item { + margin: 0; +} + +.menu_item a { + display: block; + padding: 2px 0; + text-indent: 15px; + color: inherit; + text-decoration: none; + cursor: default; +} + +.menu_item .menu_separator { + margin: 2px auto; + background: #fff !important; + padding: 0; + height: 1px; +} + +.menu_item hr { + margin: auto; + padding: 0; + height: 1px; + color: #ddd; + width: 95%; +} + +.menu_item.checked a:before { + content: " ✔ "; +} + +.navigation_menu { + border-top: 0; + -webkit-box-shadow: rgba(0, 0, 0, 0.3) 0 10px 30px; + -moz-box-shadow: rgba(0, 0, 0, 0.3) 0 10px 30px; + -o-box-shadow: rgba(0, 0, 0, 0.3) 0 10px 30px; + box-shadow: rgba(0, 0, 0, 0.3) 0 10px 30px; +} + +/*** Settings ***/ +#settings { + width: 600px; + height: 300px; +} + +#settings .navigation_column { + border-right: 1px solid #c0c0c0; + width: 150px; + float: left; +} + +#settings_controls { + width: 449px; + height: inherit; + float: right; + background: #f3f3f3; +} + +#settings_controls .message { + text-align: center; + font-size: 2em; + color: #ddd; + margin-top: 70px; +} + +#settings_controls .settings_header { + padding: 10px; + border-bottom: 1px solid #c0c0c0; + text-shadow: #fff 0 1px 0; + margin: 0; +} + +#settings_controls .settings_header a { + color: #00f; + text-decoration: underline; +} + +#settings_controls form { + background: #fff; + padding: 20px 0; +} + +#settings_controls .setting_box { + padding: 2px 10px; + width: inherit; +} + +#settings_controls .setting_box label { + width: 150px; + text-align: right; + display: inline-block; +} + +#settings_controls .setting_box label.no_indent { + width: auto; + text-align: left; +} + +#settings_controls .settings_footer { + border-top: 1px solid #c0c0c0; + text-align: right; + padding: 5px; +} + + +#save_settings { + font-weight: bold; + padding-top: 4px; + padding-bottom: 4px; +} + +#revert_settings { + float: left; + background: transparent; + border-bottom: 1px transparent; +} adddir ./contrib/musicplayer/src/resources/images addfile ./contrib/musicplayer/src/resources/images/column_background.png binary ./contrib/musicplayer/src/resources/images/column_background.png oldhex * newhex *89504e470d0a1a0a0000000d49484452000000010000003c0802000000289347ad0000033b6943 *43504943432050726f66696c650000780185944b68d4501486ff8c2982b482a8b51694e0428bb4 *253ed08a50db69b5d6917118fbd022c83473671a4d333199191f884841dcf95a8a1b1f888b2ae2 *42ba5070a50b9142eb6b510471a52288423752c6ff26ed4c2a562f24f972ce7fcfeb8600550f53 *8e63453460d8cebbc9aea876e8f080b6780255a84135b85286e7b42712fb7da6563ee7afe9b750 *a465b249c68af51d98f8b46df5fd4b8f62efeb9ef6fa9ef9fa796f352e13028a46eb8a6cc05b25 *0f06bc57f2c9bc93a7e68864632895263be446b727d941be415e9a0df16088d3c23380aa366a72 *86e3324e6405b9a5686465cc51b26ea74d9b3c25ed69cf18a686fd467ec859d0c6958f01ad6b80 *452f2bb6010f18bd0bac5a5fb135d4012bfb81b12d15dbcfa43f1fa576dccb6cd9ec8753aaa3ac *e943a9f4733db0f81a3073b554fa75ab549ab9cd1cace3996514dca2af6561ca2b20a837d8cdf8 *c9395a88839e7c550b709373ec5f02c42e00d73f021b1e00cb1f03891aa0670722e7d96e70e5c5 *29ce05e8c839a75d333b94d736ebfa76ad9d472bb46edb686ed45296a5f92e4f738527dca24837 *63d82a70cefe5ac67bb5b07b0ff2c9fe22e784b77b96959174aa93b34433dbfa92169dbbc98de4 *7b19734f37b981d754c6ddd31bb0b2d1cc77f7041ce9b3ad38cf456a2275f660fc0099f115d5c9 *47e53c248f78c58332a66f3f96da9720d7d39e3c9e8b494d2df7b69d19eae1194956ac33431df1 *597eed169232ef3a6aee3896ffcdb3b6c8731c8605011336ef363424d185289ae0c0450e197a4c *2a4c5aa55fd06ac2c3f1bf2a2d24cabb2c2abaf0997b3efb7b4ea0c0dd327e1fa2718c34962368 *fa3bfd9bfe46bfa9dfd1bf5ea92f34543c23ee51d318bffc9d716566598d8c1bd428e3cb9a82f8 *06ab6da7d74296d6615e414f5e59df14ae2e635fa92f7b3499435c8c4f87ba14a14c4d18643cd9 *b5ecbe48b6f826fc7c73d9169a1eb52fce3ea9abe47aa38e1d99ac7e71365c0d6bffb3ab60d2b2 *abf0e48d902e3c6ba1ae5537a9dd6a8bba039aba4b6d535bd54ebeed54f79777f47256264eb26e *97d5a7d8838dd3f4564eba325b04ff167e31fc2f75095bb8a6a1c97f68c2cd654c4bf88ee0f61f *7748f92ffc0d0185150d7c4b3b3b000000097048597300000b1300000b1301009a9c180000001a *49444154081d63f8ffff3f13030303ddf1dbafffe86e27d09f0052e10654d7b720ec0000000049 *454e44ae426082 adddir ./contrib/musicplayer/src/workers addfile ./contrib/musicplayer/src/workers/indexer.js hunk ./contrib/musicplayer/src/workers/indexer.js 1 +/** + * == Workers == + * + * Web Workers used to dispach computation-heavy work into background. + **/ + +/** section: Workers, related to: CollectionScanner + * Indexer + * + * This Worker is responsible for fetching MP3 files and then + * extracting ID3 metadata, which could grately slowup the interface. + * + * Messages sent to this worker have to contain only a read-cap to + * an MP3 file stored in Tahoe (without /uri/ prefix). + * + * Messages sent from this worker are objects returned by ID3 parser. + * + **/ + +var window = this, + document = {}, + queue = 0; + +this.da = {}; +importScripts("env.js"); + +/** + * Indexer.onMessage(event) -> undefined + * - event (Event): DOM event. + * - event.data (String): Tahoe URI cap for an file. + * + * When tags are parsed, `postMessage` is called. + **/ +onmessage = function (event) { + var cap = event.data, + uri = "/uri/" + encodeURIComponent(cap); + + queue++; + new da.util.ID3({ + url: uri, + onSuccess: function (tags) { + // To avoid duplication, we're using id property (which is mandatary) to store + // read-cap, which is probably already "more unique" than Math.uuid() + if(tags && typeof tags.title !== "undefined" && typeof tags.artist !== "undefined") { + tags.id = cap; + postMessage(tags); + } + + // Not all files are reporeted instantly so it might + // take some time for scanner.js/CollectionScanner.js to + // report the files, maximum delay we're allowing here + // for new files to come in is one minute. + if(!--queue) + setTimeout(checkQueue, 1000*60*1); + }, + onFailure: function () { + if(!--queue) + setTimeout(checkQueue, 1000*60*1); + } + }); +}; + +function checkQueue() { + if(!queue) + postMessage("**FINISHED**"); +} + addfile ./contrib/musicplayer/src/workers/scanner.js hunk ./contrib/musicplayer/src/workers/scanner.js 1 +/** section: Workers + * Scanner + * + * Scanner worker recursively scans the given root direcory for any type of files. + * Messages sent to this worker should contain a directory cap (without `/uri/` part). + * Messages sent from this worker are strings with read-only caps for each found file. + **/ + +var window = this, + document = {}, + queue = 0; + +this.da = {}; +importScripts("env.js"); + +/** + * Scanner.scan(object) -> undefined + * - object (TahoeObject): an Tahoe object. + * + * Traverses the `object` until it finds a file, whose cap is then reported to main thread via `postMessage`. + **/ +function scan (obj) { + queue++; + obj.get(function () { + queue--; + + if(obj.type === "filenode") + return postMessage(obj.uri); + + var n = obj.children.length; + while(n--) { + var child = obj.children[n]; + + if(child.type === "filenode") + postMessage(child.ro_uri); + else + scan(child); + } + + if(!queue) + postMessage("**FINISHED**"); + }); +} + +/** + * Scanner.onmessage(event) -> undefined + * - event.data (String): Tahoe cap pointing to root directory from which scanning should begin. + **/ +onmessage = function (event) { + scan(new TahoeObject(event.data)); +}; adddir ./contrib/musicplayer/tests adddir ./contrib/musicplayer/tests/data addfile ./contrib/musicplayer/tests/data/songs.js hunk ./contrib/musicplayer/tests/data/songs.js 1 +SHARED.songs = { + // ID3 v2.2 tag with UTF data + v22: { + data: "ID3%02%00%00%00%01I6TT2%00%00%11%01%EF%9F%BF%EF%9F%BEL%00j%00%EF%9F%B3%00s%00i%00%EF%9F%B0%00%00%00TP1%00%00!%01%EF%9F%BF%EF%9F%BE%EF%9F%93%00l%00a%00f%00u%00r%00%20%00A%00r%00n%00a%00l%00d%00s%00%00%00TP2%00%00!%01%EF%9F%BF%EF%9F%BE%EF%9F%93%00l%00a%00f%00u%00r%00%20%00A%00r%00n%00a%00l%00d%00s%00%00%00TCM%00%00!%01%EF%9F%BF%EF%9F%BE%EF%9F%93%00l%00a%00f%00u%00r%00%20%00A%00r%00n%00a%00l%00d%00s%00%00%00TAL%00%00%0D%00Found%20Songs%00TRK%00%00%05%007%2F7%00TYE%00%00%06%002009%00COM%00%00%10%00engiTunPGAP%000%00%00TEN%00%00%0E%00iTunes%208.0.2%00COM%00%00h%00engiTunNORM%00%20000007AA%2000000B2E%2000006443%200000967A%200000BF53%2000016300%200000821A%200000816B%2000010C29%20000166FA%00COM%00%00%EF%9E%82%00engiTunSMPB%00%2000000000%2000000210%200000079B%2000000000008BDDD5%2000000000%20004C0FD7%2000000000%2000000000%2000000000%2000000000%2000000000%2000000000%00TPA%00%00%05%001%2F1%00TCO%00%00%0F%00Neo-Classical%00COM%00%00%22%00eng%00available%20on%20ErasedTapes.com>>>PADDING<<<%EF%9F%BF", + simplified: { + title: "Lj\u00f3si\u00f0", + artist: "\u00d3lafur Arnalds", + album: "Found Songs", + track: 7, + year: 2009, + genre: 0, + lyrics: "", + links: { + official: "" + } + }, + frames: { + TT2: "Lj\u00f3si\u00f0", + TP1: "\u00d3lafur Arnalds", + TP2: "\u00d3lafur Arnalds", + TAL: "Found Songs", + TRK: 7, + TYE: 2009 + } + }, + + // ID3 v2.3 tag + v23: { + data: "ID3%03%00%00%00%00Q%01TPOS%00%00%00%04%00%00%001%2F1TENC%00%00%00%0E%40%00%00iTunes%20v7.6.2TIT2%00%00%005%00%00%01%EF%9F%BF%EF%9F%BED%00e%00a%00t%00h%00%20%00W%00i%00l%00l%00%20%00N%00e%00v%00e%00r%00%20%00C%00o%00n%00q%00u%00e%00r%00%00%00TPE1%00%00%00%15%00%00%01%EF%9F%BF%EF%9F%BEC%00o%00l%00d%00p%00l%00a%00y%00%00%00TCON%00%00%00%0D%00%00%01%EF%9F%BF%EF%9F%BER%00o%00c%00k%00%00%00COMM%00%00%00h%00%00%00engiTunNORM%00%20000002F6%200000036E%2000001471%200000163D%2000000017%2000000017%20000069F3%2000006AA9%2000000017%2000000017%00RVAD%00%00%00%0A%00%00%03%105555>>>PADDING<<<%EF%9F%BF", + simplified: { + title: "Death Will Never Conquer", + artist: "Coldplay", + album: "Unknown", + track: 0, + year: 0, + genre: 0, + lyrics: "", + links: { + official: "" + } + }, + frames: { + TIT2: "Death Will Never Conquer", + TPE1: "Coldplay", + TCON: 0 + } + }, + + // ID3 v2.4 tag + v24: { + data: "ID3%04%00%00%00%00%02%00TRCK%00%00%00%05%00%00%006%2F10TIT2%00%00%00%08%00%00%00HalcyonTPE1%00%00%00%08%00%00%00DelphicTALB%00%00%00%08%00%00%00AcolyteTYER%00%00%00%05%00%00%002010TCON%00%00%00%0F%00%00%00(52)ElectronicWXXX%00%00%00%13%00%00%00%00http%3A%2F%2Fdelphic.ccTPUB%00%00%00%13%00%00%00Chimeric%20%2F%20PolydorTPOS%00%00%00%04%00%00%001%2F1%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%EF%9F%BF", + simplified: { + title: "Halcyon", + artist: "Delphic", + album: "Acolyte", + track: 6, + year: 2010, + genre: 52, // Electornic, + lyrics: "", + links: { + official: "http://delphic.cc" + } + }, + frames: { + TIT2: "Halcyon", + TPE1: "Delphic", + TALB: "Acolyte", + TYER: 2010, + TCON: 52, + TRCK: 6, + WXXX: "http://delphic.cc" + } + }, + + // ID3 v1 tag + v1: { + data: "TAGYeah%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00Queen%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00Made%20In%20Heaven%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%001995%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%00%0C%0C", + simplified: { + title: "Yeah", + artist: "Queen", + album: "Made In Heaven", + track: 12, + year: 1995, + genre: 12 + } + }, + + // 1x1 transparent PNG file + image: { + data: "%EF%9E%89PNG%0D%0A%1A%0A%00%00%00%0DIHDR%00%00%00%01%00%00%00%01%01%03%00%00%00%25%EF%9F%9BV%EF%9F%8A%00%00%00%03PLTE%00%00%00%EF%9E%A7z%3D%EF%9F%9A%00%00%00%01tRNS%00%40%EF%9F%A6%EF%9F%98f%00%00%00%0AIDAT%08%EF%9F%97c%60%00%00%00%02%00%01%EF%9F%A2!%EF%9E%BC3%00%00%00%00IEND%EF%9E%AEB%60%EF%9E%82" + } +}; + +(function (args) { + // SONGS.v22 and SONGS.v23 have vast amount of padding bits, + // so we're adding them programatically + + function addPaddingTo(key, n) { + var p = []; + while(n--) + p.push("%00"); + + SHARED.songs[key].data = SHARED.songs[key].data.replace(">>>PADDING<<<", p.join("")); + } + + addPaddingTo("v22", 25241); + addPaddingTo("v23", 10084); +})(); + addfile ./contrib/musicplayer/tests/initialize.js hunk ./contrib/musicplayer/tests/initialize.js 1 +windmill.jsTest.require("shared.js"); + +windmill.jsTest.register([ +// 'test_utils', + 'test_Goal', + 'test_BinaryFile', + 'test_ID3', + 'test_ID3v1', + 'test_ID3v2', + 'test_BrowserCouch', + 'test_DocumentTemplate', + + 'test_NavigationController' +]); addfile ./contrib/musicplayer/tests/shared.js hunk ./contrib/musicplayer/tests/shared.js 1 +var SHARED = {}; +var util = { + wait_for_data: function (key) { + return { + method: 'waits.forJS', + params: { + js: function () { return !!SHARED[key]; } + } + } + }, + + create_id3v2_test: function (version, size) { + var ID3v2Parser = da.util.ID3v2Parser, + vkey = "v" + (version * 10); + + return new function () { + var self = this; + + this.setup = function () { + self.simplified = null; + self.frames = null; hunk ./contrib/musicplayer/tests/shared.js 23 + var data = da.util.BinaryFile.fromEncodedString(SHARED.songs[vkey].data); + self.parser = new ID3v2Parser(data, { + url: "/fake/" + Math.uuid(), + onSuccess: function (simplified, frames) { + self.simplified = simplified; + self.frames = frames; + } + }, {}); + }; + + this.test_waitForData = { + method: 'waits.forJS', + params: { + js: function () { return !!self.simplified && !!self.frames; } + } + }; + + this.test_header = function () { + jum.assertEquals("version should be " + version, self.parser.version, version); + jum.assertEquals("no flags should be set", self.parser.header.flags, 0); + jum.assertEquals("tag size shoudl be " + size, self.parser.header.size, size); + }; + + this.test_verifySimplifiedResult = function () { + jum.assertSameObjects(SHARED.songs[vkey].simplified, self.simplified); + }; + + this.test_verifyDetectedFrames = function () { + jum.assertSameObjects(SHARED.songs[vkey].frames,self.frames); + }; + + return this; + }; + } +}; + +jum.assertSameObjects = function (a, b) { + if(a === b) + return true; + // catches cases when one of args is null + if(!a || !b) + jum.assertEquals(a, b); + + for(var prop in a) + if(a.hasOwnProperty(prop)) + if(prop in a && prop in b) + if(typeof a[prop] === "object") + jum.assertSameObjects(a[prop], b[prop]); + else + jum.assertEquals(a[prop], b[prop]); + else + jum.assertTrue("missing '" + prop +"' property", false); + + return true; +}; addfile ./contrib/musicplayer/tests/test_BinaryFile.js hunk ./contrib/musicplayer/tests/test_BinaryFile.js 1 +windmill.jsTest.require("data/"); + +var test_BinaryFile = new function () { + var BinaryFile = da.util.BinaryFile, + self = this; + + this.setup = function () { + this.file_le = new BinaryFile("\0\0\1\0"); + this.file_be = new BinaryFile("\0\1\0\0", {bigEndian: true}); + this.bond = new BinaryFile("A\0\0\7James Bond\0"); + }; + + this.test_options = function () { + jum.assertEquals(4, this.file_le.length); + jum.assertFalse(this.file_le.bigEndian); + + jum.assertEquals(4, this.file_be.length); + jum.assertTrue(this.file_be.bigEndian); + }; + + this.test_getByte = function () { + jum.assertEquals(0, this.file_le.getByteAt(0)); + jum.assertEquals(1, this.file_le.getByteAt(2)); + + jum.assertEquals(0, this.file_be.getByteAt(0)); + jum.assertEquals(1, this.file_be.getByteAt(1)); + }; + + this.test_getShort = function () { + jum.assertEquals(0, this.file_le.getShortAt(0)); // 00 + jum.assertEquals(256, this.file_le.getShortAt(1)); // 01 + jum.assertEquals(1, this.file_le.getShortAt(2)); // 10 + + jum.assertEquals(1, this.file_be.getShortAt(0)); // 01 + jum.assertEquals(256, this.file_be.getShortAt(1)); // 10 + jum.assertEquals(0, this.file_be.getShortAt(2)); // 00 + }; + + this.test_getLong = function () { + jum.assertEquals(65536, this.file_le.getLongAt(0)); + jum.assertEquals(65536, this.file_be.getLongAt(0)); + }; + + this.test_getBits = function () { + jum.assertSameObjects([0, 1], this.file_le.getBitsAt(2, 2)); + jum.assertSameObjects([0, 0, 0, 1], this.file_be.getBitsAt(1, 4)); + }; + + this.test_unpack = function () { + jum.assertSameObjects(["A", 0, 0, 7], this.bond.unpack("c3i")); + jum.assertSameObjects(["James Bond"], this.bond.unpack("4x10S")); + }; + + this.test_toEncodedString = function () { + jum.assertEquals("%00%00%01%00", this.file_le.toEncodedString()); + jum.assertEquals("%00%01%00%00", this.file_be.toEncodedString()); + }; + + return this; +}; addfile ./contrib/musicplayer/tests/test_BrowserCouch.js hunk ./contrib/musicplayer/tests/test_BrowserCouch.js 1 +windmill.jsTest.require("shared.js"); + +var test_BrowserCouchDict = new function () { + var BrowserCouch = da.db.BrowserCouch, + self = this; + + this.setup = function () { + self.dict = new BrowserCouch.Dictionary(); + + this.dict.set("a", 1); + this.dict.set("b", 2); + + this.dict.setDocs([ + {id: "c", value: 3}, + {id: "d", value: 4}, + {id: "a", value: 5} + ]); + }; + + this.test_set = function () { + jum.assertTrue(this.dict.has("a")); + jum.assertTrue(this.dict.has("b")); + jum.assertFalse(this.dict.has("x")); + + jum.assertSameObjects({id:"a", value: 5}, this.dict.dict.a); + jum.assertEquals(2, this.dict.dict.b); + }; + + this.test_setDocs = function () { + jum.assertTrue(this.dict.has("c")); + jum.assertTrue(this.dict.has("d")); + + jum.assertEquals(3, this.dict.dict.c.value); + jum.assertEquals(4, this.dict.dict.d.value); + }; + + this.test_remove = function () { + this.dict.remove("a"); + jum.assertEquals(3, this.dict.keys.length); + jum.assertFalse(this.dict.has("a")); + }; + + this.test_unpickle = function () { + this.dict.unpickle({ + x: 2.2, + y: 2.3 + }); + + jum.assertEquals(2, this.dict.keys.length); + jum.assertTrue(this.dict.has("x")); + jum.assertTrue(this.dict.has("y")); + jum.assertFalse(this.dict.has("a")); + }; + + this.test_clear = function () { + this.dict.clear(); + + jum.assertEquals(0, this.dict.keys.length); + jum.assertFalse(this.dict.has("x")); + jum.assertFalse(this.dict.has("b")); + }; +}; + +var test_BrowserCouch = new function () { + var BrowserCouch = da.db.BrowserCouch, + self = this; + + this.setup = function () { + this.db = false; + this.stored = {}; + + BrowserCouch.get("test1", function (db) { + self.db = db; + db.addEvent("store", function (doc) { + self.stored[doc.id] = new Date(); + }); + }); + }; + + this.test_waitForDb = { + method: 'waits.forJS', + params: { + js: function () { return !!self.db; } + } + }; + + this.test_verifyDb = function () { + jum.assertEquals(0, this.db.getLength()); + }; + + this.test_put = function () { + var cb = {doc1: 0, doc2: 0, doc3: 0}; + this.db.put({id: "doc1", test: 1}, function () { cb.doc1++ }); + this.db.put({id: "doc2", test: 2}, function () { cb.doc2++ }); + this.db.put({id: "doc3", test: 3}, function () { cb.doc3++ }); + this.db.put({id: "doc1", test: 4}, function () { cb.doc1++ }); + + jum.assertEquals(2, cb.doc1); + jum.assertEquals(1, cb.doc2); + jum.assertEquals(1, cb.doc3); + }; + + this.test_storeEvent = function () { + jum.assertTrue(self.stored.doc1 >= self.stored.doc3); + jum.assertTrue(self.stored.doc3 >= self.stored.doc2); + }; + + this.test_wipe = function () { + jum.assertEquals(3, this.db.getLength()); + this.db.wipe(); + + BrowserCouch.get("test1", function (db) { + jum.assertEquals(0, db.getLength()); + }); + }; + + this.teardown = function () { + self.db.wipe(); + }; + + return this; +}; + +var test_BrowserCouch_tempView = new function () { + var BrowserCouch = da.db.BrowserCouch, + self = this; + + this.setup = function () { + BrowserCouch.get("test2", function (db) { + self.db = db; + self.map_called = 0; + self.map_updated_called = false; + self.reduce_updated_called = false; + + db.put([ + {id: "doc1", nr: 1}, + {id: "doc2", nr: 2}, + {id: "doc3", nr: 3} + ], function () { + self.docs_saved = true; + }); + }); + }; + + this.test_waitForDb = { + method: 'waits.forJS', + params: { + js: function () { return !!self.db && self.docs_saved; } + } + }; + + this.test_map = function () { + this.db.view({ + temporary: true, + + map: function (doc, emit) { + self.map_called++; + if(doc.nr !== 2) + emit(doc.id, doc.nr); + }, + + finished: function (result) { + self.map_result = result; + + self.db.put({id: "doc4", nr: 4}); + }, + + updated: function () { + self.map_updated_called = true; + } + }) + }; + + this.test_waitForMapResult = { + method: 'waits.forJS', + params: { + js: function () { return !!self.map_result } + } + }; + + this.test_verifyMapResult = function () { + var mr = self.map_result; + + jum.assertEquals(3, self.map_called); + jum.assertTrue("rows" in mr); + jum.assertEquals(2, mr.rows.length); + jum.assertEquals("function", typeof mr.findRow); + jum.assertEquals("function", typeof mr.getRow); + jum.assertFalse(self.map_updated_called); + }; + + this.test_mapFindRow = function () { + var mr = self.map_result; + jum.assertEquals(-1, mr.findRow("doc2")); + jum.assertEquals(-1, mr.findRow("doc4")); + jum.assertEquals(-1, mr.findRow("doc7")); + jum.assertEquals(0, mr.findRow("doc1")); + }; + + this.test_reduce = function () { + self.reduce_called = 0; + self.db.view({ + temporary: true, + + map: function (doc, emit) { + emit(doc.nr%2 ? "odd" : "even", doc.nr); + }, + + reduce: function (keys, values) { + var sum = 0, n = values.length; + self.reduce_called++; + + while(n--) + sum += values[n]; + + return sum; + }, + + finished: function (result) { + self.reduce_result = result; + self.db.put({id: "doc5", nr: 5}); + }, + + updated: function () { + self.reduce_updated_called = true; + } + }); + }; + + this.test_waitForReduceResult = { + method: 'waits.forJS', + params: { + js: function () { return !!self.reduce_result } + } + }; + + this.test_verifyReduceResult = function () { + var rr = this.reduce_result; + jum.assertFalse(this.reduce_updated_called); + + jum.assertEquals("function", typeof rr.findRow); + jum.assertEquals("function", typeof rr.getRow); + + jum.assertEquals(2, self.reduce_called); + jum.assertEquals(2, rr.rows.length); + }; + + this.test_verifyReduceFindRow = function () { + var rr = this.reduce_result; + + jum.assertTrue(rr.findRow("even") !== -1); + jum.assertTrue(rr.findRow("odd") !== -1); + jum.assertEquals(-1, rr.findRow("even/odd")); + + jum.assertEquals(6, rr.getRow("even")); // 2 + 4 + jum.assertEquals(4, rr.getRow("odd")); // 1 + 3 + }; + + this.teardown = function () { + this.db.wipe(); + }; + + return this; +}; + +var test_BrowserCouch_liveView = new function () { + var BrowserCouch = da.db.BrowserCouch, + self = this; + + this.setup = function () { + this.docs_saved = false; + + this.map_result = null; + this.map_updated = null; + this.map_finished_called = 0; + this.map_updated_called = 0; + + this.reduce_result = null; + this.reduce_updated = null; + this.reduce_finished_called = 0; + this.reduce_updated_called = 0; + + BrowserCouch.get("test3", function (db) { + self.db = db; + + db.put([ + {id: "Keane", albums: 3, formed: 1997}, + {id: "Delphic", albums: 1, formed: 2010}, + {id: "The Blue Nile", albums: 4, formed: 1981} + ], function () { + self.docs_saved = true; + + db.view({ + id: "test1", + + map: function (doc, emit) { + if(doc.id.toLowerCase().indexOf("the") === -1) + emit(doc.id, doc.formed); + }, + + finished: function (view) { + self.map_finished_called++; + self.map_result = view; + }, + + updated: function (view) { + self.map_updated_called++; + self.map_updates = view; + } + }); + }); + }); + }; + + this.test_waitForDb = { + method: 'waits.forJS', + params: { + js: function () { return !!self.db && self.docs_saved && !!self.map_result } + } + }; + + this.test_verifyMap = function () { + var mr = self.map_result; + + jum.assertEquals(1, self.map_finished_called); + jum.assertEquals(2, mr.rows.length); + jum.assertEquals("function", typeof mr.findRow); + + jum.assertEquals(-1, mr.findRow("The Drums")); + jum.assertEquals(-1, mr.findRow("The Blue Nile")); + jum.assertEquals(0, mr.findRow("Delphic")); + + self.db.put([ + {id: "Marina and The Diamonds", albums: 1, formed: 2007}, + {id: "Coldplay", albums: 4, formed: 1997}, + {id: "Delphic", albums: 1, formed: 2009} + ], function () { + self.map_updates_saved = true; + }); + }; + + this.test_waitForUpdate = { + method: 'waits.forJS', + params: { + js: function () { return self.map_updates_saved && !!self.map_updates } + } + }; + + this.test_verifyMapUpdates = function () { + var mr = self.map_result, + mu = self.map_updates; + + jum.assertEquals(1, self.map_updated_called); + jum.assertEquals(1, self.map_finished_called); + jum.assertEquals(2, mu.rows.length); + jum.assertEquals(3, mr.rows.length); + + jum.assertEquals(-1, mu.findRow("Marina and The Diamonds")); + jum.assertEquals(-1, mu.findRow("Keane")); + jum.assertEquals(0, mu.findRow("Coldplay")); + + jum.assertEquals(-1, mr.findRow("Marina and The Diamonds")); + jum.assertEquals(0, mr.findRow("Coldplay")); + + jum.assertEquals(2009, mr.getRow("Delphic")); + }; + + this.test_killView = function () { + self.db.killView("test1"); + self.db.put({id: "Noisettes", formed: 2003, albums: 2}, $empty); + }; + + this.test_waitForViewToDie = { + method: 'waits.forJS', + params: { + js: function () { return !!!self.db.views.test1 } + } + }; + + this.test_viewIsDead = function () { + jum.assertEquals(1, self.map_updated_called); + }; + + this.test_reduce = function () { + self.rereduce_args = null; + self.rereduce_called = 0; + self.reduce_called = 0; + + self.db.view({ + id: "test2", + + map: function (doc, emit) { + if(doc.albums) + emit("albums", doc.albums); + }, + + reduce: function (keys, values, rereduce) { + if(rereduce) { + self.rereduce_args = arguments; + self.rereduce_called++; + } else { + self.reduce_called++; + } + + var n = values.length, sum = 0; + while(n--) sum += values[n]; + return sum; + }, + + finished: function (view) { + self.reduce_finished_called++; + self.reduce_result = view; + }, + + updated: function (view) { + self.reduce_updated_called++; + self.reduce_updates = view; + } + }) + }; + + this.test_waitForReduce = { + method: 'waits.forJS', + params: { + js: function () { return !!self.reduce_result } + } + }; + + this.test_verifyReduceResult = function () { + var rr = self.reduce_result; + + jum.assertEquals(1, self.reduce_finished_called); + jum.assertEquals(0, self.reduce_updated_called); + + jum.assertEquals("function", typeof rr.findRow); + + jum.assertEquals(1, rr.rows.length); + jum.assertEquals(0, rr.findRow("albums")); + jum.assertEquals(15, rr.getRow("albums")); + + self.db.put([ + {id: "Imaginary", albums: 0, formed: 2020}, + {id: "Grizzly Bear", albums: 2, formed: 2000} + ], function() { + self.reduce_updates_saved = true; + }); + }; + + this.test_waitForUpdates = { + method: 'waits.forJS', + params: { + js: function () { return self.reduce_updates_saved } + } + }; + + this.test_reduceUpdates = function () { + var rr = self.reduce_result, + ru = self.reduce_updates; + + jum.assertEquals(1, self.reduce_updated_called); + jum.assertEquals(1, self.reduce_finished_called); + + jum.assertEquals(1, ru.rows.length); + jum.assertEquals(-1, ru.findRow("Grizzly Bear")); + jum.assertEquals(0, ru.findRow("albums")); + + jum.assertEquals(2, ru.getRow("albums")); + jum.assertEquals(17, rr.getRow("albums")); + }; + + this.test_rereduce = function () { + jum.assertEquals(1, self.rereduce_called); + jum.assertSameObjects([null, [2, 15], true], self.rereduce_args); + } + + this.teardown = function () { + self.db.killView("test2"); + }; + + return this; +}; addfile ./contrib/musicplayer/tests/test_DocumentTemplate.js hunk ./contrib/musicplayer/tests/test_DocumentTemplate.js 1 +windmill.jsTest.require("shared.js"); + +var test_DocumentTemplate = new function () { + var BrowserCouch = da.db.BrowserCouch, + DocumentTemplate = da.db.DocumentTemplate, + self = this; + + this.setup = function () { + self.db = null; + BrowserCouch.get("dt_test1", function (db) { + self.db = db; + }); + }; + + this.waitForDb = { + method: "waits.forJS", + params: { + js: function () { return !!self.db } + } + }; + + this.test_registerType = function () { + DocumentTemplate.registerType("test_Person", self.db, new Class({ + Extends: DocumentTemplate, + + hasMany: { + cars: ["test_Car", "owner_id"] + }, + + sayHi: function () { + return "Hello! My name is %0 %1.".interpolate([ + this.get("name"), + this.get("surname") + ]) + } + })); + self.Person = DocumentTemplate.test_Person; + + DocumentTemplate.registerType("test_Car", self.db, new Class({ + Extends: DocumentTemplate, + + belongsTo: { + owner: "test_Person" + }, + + start: function () { + this.update({state: "inMotion"}) + }, + + stop: function () { + this.update({state: "stopped"}); + }, + + isRunning: function () { + return this.get("state") === "inMotion" + } + })); + this.Car = DocumentTemplate.test_Car; + + jum.assertTrue("test_Person" in DocumentTemplate); + jum.assertTrue("test_Person" in self.db.views); + jum.assertEquals(self.db.name, self.Person.db().name); + }; + + this.test_instanceFindNoResult = function () { + this.instanceFind_success_called = 0; + this.instanceFind_failure_called = 0; + + this.Car.find({ + properties: {manufacturer: "Volkswagen"}, + onSuccess: function () { + self.instanceFind_success_called++; + }, + onFailure: function () { + self.instanceFind_failure_called++; + } + }) + }; + + this.test_waitForInstanceFind = { + method: "waits.forJS", + params: { + js: function () { return self.instanceFind_failure_called } + } + }; + + this.test_verifyInstaceFind = function () { + jum.assertEquals(1, self.instanceFind_failure_called); + jum.assertEquals(0, self.instanceFind_success_called); + }; + + this.test_createDoc = function () { + self.herbie_saved = 0; + self.Person.create({ + id: "jim", + first: "Jim", + last: "Douglas" + }, function (jim) { + self.jim = jim; + + self.herbie = new self.Car({ + id: "herbie", + owner_id: "jim", + state: "sleeping", + diamods: 0 + }); + + self.herbie.save(function () { + self.herbie_saved++; + }); + }); + }; + + this.test_waitForDocs = { + method: "waits.forJS", + params: { + js: function () { return !!self.jim && !!self.herbie && self.herbie_saved } + } + }; + + this.test_verifyCreate = function () { + jum.assertEquals("jim", self.jim.id); + jum.assertEquals("herbie", self.herbie.id); + jum.assertEquals(1, self.db.views.test_Person.view.rows.length); + jum.assertEquals(1, self.db.views.test_Car.view.rows.length); + }; + + this.test_get = function () { + jum.assertEquals("Jim", self.jim.get("first")); + jum.assertEquals("jim", self.herbie.get("owner_id")); + }; + + this.test_belongsTo = function () { + self.herbie.get("owner", function (owners) { + jum.assertEquals(1, owners.length); + jum.assertEquals(self.jim.id, owners[0].id); + self.got_jim = true; + }); + }; + + this.test_waitForJim = { + method: "waits.forJS", + params: { + js: function () { return self.got_jim } + } + }; + + this.test_hasMany = function () { + self.jim.get("cars", function (cars) { + jum.assertEquals(1, cars.length); + jum.assertEquals(self.herbie.id, cars[0].id); + self.got_herbie = true; + }); + }; + + this.test_waitForHerbie = { + method: "waits.forJS", + params: { + js: function () { return self.got_herbie } + } + }; + + this.test_propertyChangeEvent = function () { + self.herbie.addEvent("propertyChange", function (changes, herbie) { + jum.assertEquals(self.herbie, herbie); + jum.assertTrue("state" in changes); + jum.assertFalse("id" in changes); + jum.assertEquals("inMotion", herbie.get("state")); + }); + + self.herbie.start(); + }; + + this.test_findOrCreate = function () { + self.foc_finished = 0; + self.Person.findOrCreate({ + properties: {id: "jim"}, + onSuccess: function (jim, created) { + self.foc_finished++; + self.foc_jim = {jim: jim, created: created}; + } + }); + + self.john_props = {id: "john", first: "John", last: "Doe"}; + self.Person.findOrCreate({ + properties: self.john_props, + onSuccess: function (john, created) { + self.foc_finished++; + self.foc_john = {john: john, created: created}; + } + }); + }; + + this.test_waitForFindOrCreate = { + method: "waits.forJS", + params: { + js: function () { return self.foc_finished === 2 } + } + }; + + this.test_verifyFindOrCreate = function () { + jum.assertEquals("jim", self.foc_jim.jim.id); + jum.assertTrue(self.foc_jim.created !== true); + + jum.assertEquals("john", self.foc_john.john.id); + jum.assertSameObjects(self.john_props, self.foc_john.john.doc); + jum.assertTrue(self.foc_john.created); + }; + + this.test_destroy = function () { + self.success_on_destroy = self.failure_on_destroy = false; + self.jim.destroy(function () { + self.Person.findFirst({ + properties: {id: "jim"}, + onSuccess: function() { + self.success_on_destory = true; + }, + onFailure: function () { + self.failure_on_destory = true; + } + }); + }); + }; + + this.wait_forDestroy = { + method: "waits.forJS", + params: { + js: function () { return self.failure_on_destroy || self.success_on_destroy } + } + }; + + this.test_verifyDestroy = function () { + jum.assertTrue(self.failure_on_destroy); + jum.assertFalse(self.success_on_destroy); + }; + + this.teardown = function () { + self.db.wipe(); + }; + + return this; +}; addfile ./contrib/musicplayer/tests/test_Goal.js hunk ./contrib/musicplayer/tests/test_Goal.js 1 +var test_Goal = new function () { + var Goal = da.util.Goal, + self = this; + this.test_setup = function () { + this._timestamps = {}; + this._calls = {a: 0, b: 0, c: 0, afterC: 0, success: 0, setup: 0}; + this._calls.setup++; + this._goal = new Goal({ + checkpoints: ["a", "b", "c"], + + onCheckpoint: function (name) { + self._timestamps[name] = new Date(); + self._calls[name]++; + }, + + onFinish: function (name) { + self._timestamps.success = new Date(); + self._calls.success++; + }, + + afterCheckpoint: { + c: function () { + self._timestamps.afterC = new Date(); + self._calls.afterC++; + } + } + }); + + this._goal.checkpoint("b"); + this._goal.checkpoint("c"); + this._goal.checkpoint("a"); + + this._goal.checkpoint("c"); + this._goal.checkpoint("b"); + }; + + this.test_allEventsCalledOnce = function () { + jum.assertTrue(this._calls.a === 1); + jum.assertTrue(this._calls.b === 1); + jum.assertTrue(this._calls.c === 1); + jum.assertTrue(this._calls.afterC === 1); + jum.assertTrue(this._calls.success === 1); + jum.assertTrue(this._goal.finished); + }; + + this.test_timestamps = function () { + jum.assertTrue(this._timestamps.b <= this._timestamps.c); + jum.assertTrue(this._timestamps.c <= this._timestamps.a); + jum.assertTrue(this._timestamps.c <= this._timestamps.afterC); + }; + + this.test_successCalls = function () { + jum.assertTrue(this._timestamps.success >= this._timestamps.a); + jum.assertTrue(this._timestamps.success >= this._timestamps.b); + jum.assertTrue(this._timestamps.success >= this._timestamps.c); + jum.assertTrue(this._timestamps.success >= this._timestamps.afterC); + }; +}; addfile ./contrib/musicplayer/tests/test_ID3.js hunk ./contrib/musicplayer/tests/test_ID3.js 1 +windmill.jsTest.require("shared.js"); +windmill.jsTest.require("data/songs.js"); + +var test_ID3 = new function () { + var BinaryFile = da.util.BinaryFile, + ID3 = da.util.ID3; + + var ID3_patched = new Class({ + Extends: ID3, + + _data: BinaryFile.fromEncodedString(SHARED.songs.image.data), + _getFile: function (parser) { + if(!parser) + this.options.onFailure(); + else + this._onFileFetched(this._data); + } + }); + ID3_patched.parsers = $A(ID3.parsers); + + var self = this; + this.setup = function () { + this.called_onSuccess = false; + this.called_onFailure = false; + + new ID3_patched({ + url: "/fake/" + Math.uuid(), + onSuccess: function () { + self.called_onSuccess = true; + }, + onFailure: function () { + self.called_onFailure = true; + } + }); + }; + + this.test_callbacks = function () { + jum.assertTrue(self.called_onFailure); + jum.assertFalse(self.called_onSuccess); + }; + + this.teardown = function () { + delete self.called_onSuccess; + delete self.called_onFailure; + }; +}; addfile ./contrib/musicplayer/tests/test_ID3v1.js hunk ./contrib/musicplayer/tests/test_ID3v1.js 1 +windmill.jsTest.require("shared.js"); +windmill.jsTest.require("data/songs.js"); + +var test_ID3v1 = new function () { + var BinaryFile = da.util.BinaryFile, + ID3v1Parser = da.util.ID3v1Parser, + self = this; + + this.setup = function () { + this.tags = {}; + + SHARED.parser = new ID3v1Parser(BinaryFile.fromEncodedString(SHARED.songs.v1.data), { + url: "/fake/" + Math.uuid(), + onSuccess: function (tags) { + self.tags = tags; + } + }, {}); + }; + + this.test_waitForData = { + method: 'waits.forJS', + params: { + js: function () { return !!self.tags; } + } + }; + + this.test_verifyResult = function () { + jum.assertSameObjects(SHARED.songs.v1.simplified, self.tags); + }; + + this.test_withID3v2 = function () { + jum.assertFalse("ID3v1 parser should not parse ID3v2 tags", + ID3v1Parser.test(BinaryFile.fromEncodedString(SHARED.songs.v24.data)) + ); + }; + + this.test_withPNGFile = function () { + jum.assertFalse("ID3v1 parser should not parse PNG file", + ID3v1Parser.test(BinaryFile.fromEncodedString(SHARED.songs.image.data)) + ); + }; +}; addfile ./contrib/musicplayer/tests/test_ID3v2.js hunk ./contrib/musicplayer/tests/test_ID3v2.js 1 +windmill.jsTest.require("shared.js"); +windmill.jsTest.require("data/songs.js"); + +var test_ID3v2 = new function () { + var BinaryFile = da.util.BinaryFile, + ID3v2Parser = da.util.ID3v2Parser; + + // Sometimes the code gets exectued before data/songs.js + this.test_waitForData = { + method: "waits.forJS", + params: { + js: function () { return !!SHARED && !!SHARED.songs } + } + }; + + this.test_withPNGFile = function () { + jum.assertFalse("should not parse PNG file", + ID3v2Parser.test(BinaryFile.fromEncodedString(SHARED.songs.image.data)) + ); + }; + + this.test_withID3v1File = function () { + jum.assertFalse("should not parse ID3v1 file", + ID3v2Parser.test(BinaryFile.fromEncodedString(SHARED.songs.v1.data)) + ); + }; + + this.test_withID3v2Files = function () { + jum.assertTrue("should detect v2.2", + ID3v2Parser.test(BinaryFile.fromEncodedString(SHARED.songs.v22.data)) + ); + jum.assertTrue("should detect v2.3", + ID3v2Parser.test(BinaryFile.fromEncodedString(SHARED.songs.v23.data)) + ); + jum.assertTrue(ID3v2Parser.test(BinaryFile.fromEncodedString(SHARED.songs.v24.data))); + }; + + return this; +}; + +var test_ID3v22 = util.create_id3v2_test(2.2, 25792); +var test_ID3v23 = util.create_id3v2_test(2.3, 10379); +var test_ID3v24 = util.create_id3v2_test(2.4, 266); addfile ./contrib/musicplayer/tests/test_Menu.js hunk ./contrib/musicplayer/tests/test_Menu.js 1 - +var test_Menu = new function () { + var Menu = da.ui.Menu, + self = this; + + this.setup = function () { + self.menu = new Menu({ + items: { + a: {html: "a", id: "_test_first_menu_item"}, + b: {html: "b", id: "_test_second_menu_item"}, + _sep: Menu.separator, + c: {html: "c", id: "_test_third_menu_item"} + } + }); + }; + + this.test_domNode = function () { + var el = self.menu.toElement(); + + jum.assertEquals("menu's element should be inserted into body of the page", + el.getParent(), document.body + ); + //jum.assertEquals("should have four list items", ) + }; + + this.test_events = function () { + var el = self.menu.toElement(); + + self.menu.addEvent("click", function (key, element) { + jum.assertEquals("clicked items' key should be 'b'", "b", key); + }); + // events are synchronous + self.menu.click(null, el.getElement("li:nth-child(2)")); + + var showed = 0, + hidden = 0; + self.menu.addEvent("show", function () { + showed++; + }); + self.menu.addEvent("hide", function () { + hidden++; + }); + + self.menu.show(); + jum.assertEquals("shown menu should be visible to the user", + "block", el.style.display + ); + + self.menu.show(); + jum.assertEquals("showing visible menu should not fire 'show' event ", + 1, showed + ); + jum.assertEquals("calling `show` on visible menu should hide it", + "none", el.style.display + ); + + self.menu.hide(); + jum.assertEquals("hiding hidden menu should not fire 'hide' event", + 1, hidden + ); + }; + + this.teardown = function () { + self.menu.destroy(); + }; + + return this; +}; addfile ./contrib/musicplayer/tests/test_NavigationController.js hunk ./contrib/musicplayer/tests/test_NavigationController.js 1 +var test_NavigationController = new function () { + var Navigation = da.controller.Navigation, + self = this; + + // We can't use da.controller.CollectionScanner.isFinished() + // here because scanner worker has one minute timeout + this.test_waitForCollectionScanner = { + method: "waits.forJS", + params: { + js: function () { + return da.db.DEFAULT.views.Song.view.rows.length === 3 + } + } + }; + + // Generated by Windmill + // It clicks on a item in Artists column and than on a item in Albums column + this.test_navigationBehaviour = [ + {"params": {"xpath": "//div[@id='Artists_column_container']/div/div[2]/a[2]/span"}, + "method": "click"}, + {"params": {"xpath": "//div[@id='Albums_column_container']/div/div[2]/a/span"}, + "method": "click"}, + {"params": {"xpath": "//div[@id='Albums_column_container']/a/span", "validator": "Albums"}, + "method": "asserts.assertText"} + ]; + + this.test_activeColumns = function () { + var ac = Navigation.activeColumns; + jum.assertEquals("first column should be Root", + "Root", ac[0].column_name + ); + jum.assertEquals("second column should be Artists", + "Artists", ac[1].column_name + ); + jum.assertEquals("third colum should be Albums", + "Albums", ac[2].column_name + ); + jum.assertEquals("fourth column should be Songs", + "Songs", ac[3].column_name + ); + }; + + this.test_items = function () { + var ac = Navigation.activeColumns, + artists = ac[1].column, + albums = ac[2].column, + songs = ac[3].column; + + jum.assertEquals("there should be two artists", + 2, artists.options.totalCount + ); + jum.assertEquals("first artist should be Keane", + "Keane", artists.getItem(0).value.title + ); + jum.assertEquals("second artist should be Superhumanoids", + "Superhumanoids", artists.getItem(1).value.title + ); + + jum.assertEquals("there should be only one album by Superhumanoids", + 1, albums.options.totalCount + ); + jum.assertEquals("first album should be Urgency", + "Urgency", albums.getItem(0).value.title + ); + + jum.assertEquals("there should be two songs on Urgency album", + 2, songs.options.totalCount + ); + // indirectly tests sorting, since 'Hey Big Bang' is third track + // while 'Persona' is first on the album + jum.assertEquals("first song should be 'Persona'", + "Persona", songs.getItem(0).value.title + ); + jum.assertEquals("second song should be 'Hey Big Bang'", + "Hey Big Bang", songs.getItem(1).value.title + ); + }; + + return this; +}; addfile ./contrib/musicplayer/tests/test_utils.js hunk ./contrib/musicplayer/tests/test_utils.js 1 +var test_StringStrip = function () { + jum.assertEquals("123ab", "123\0\0a\0\0b".strip()); + jum.assertEquals("abc", "\0\0\0ab\0c\0\0\0".strip()); + jum.assertEquals("d", "\0d".strip()); + jum.assertEquals("e ", "e\0 ".strip()); +}; + +var test_StringInterpolate = new function () { + this.test_withNoArgs = function () { + jum.assertEquals("test", "test".interpolate()); + }; + + this.test_withArray = function () { + jum.assertEquals("10/100%", "{0}/{1}%".interpolate([10, 100])); + jum.assertEquals("100/100%", "{1}/{1}%".interpolate([10, 100])); + jum.assertEquals("001011", "{0}{0}{1}{0}{1}{1}".interpolate([0, 1])); + }; + + this.test_withObject = function () { + jum.assertEquals("Hi John! How are you?", "Hi {name}! How are {who}?".interpolate({ + name: "John", + who: "you" + })); + }; + + this.test_missingProperties = function () { + jum.assertEquals("Hi mum! {feeling} to see you!", "Hi {who}! {feeling} to see you!".interpolate({ + who: "mum" + })); + }; +}; + +var test_ArrayZip = new function () { + this.test_oneArg = function () { + jum.assertSameObjects([[1]], Array.zip([1])); + }; + + this.test_twoArgs = function () { + jum.assertSameObjects([[1, 1], [2, 2], [3, 3]], Array.zip([1, 2, 3], [1, 2, 3])); + }; + + this.test_moreSimpleArgs = function () { + jum.assertSameObjects([[1, 2, 3, 4, 5]], Array.zip([1], [2], [3], [4], [5])); + }; + + this.test_notSameLength = function () { + jum.assertSameObjects([[1, 2, 4]], Array.zip([1], [2, 3], [4, 5, 6])); + jum.assertSameObjects([ + [1, 4, 6], + [2, 5, undefined], + [3, undefined, undefined] + ], Array.zip([1, 2, 3], [4, 5], [6])); + }; +}; + +var test_ArrayContainsAll = function () { + jum.assertTrue( [] .containsAll([] )); + jum.assertTrue( [1, 2, 3] .containsAll([1, 2, 3])); + jum.assertTrue( [1, 2, 1] .containsAll([2, 1] )); + jum.assertTrue( [1, 2] .containsAll([1, 2, 1])); + jum.assertFalse([1, 2] .containsAll([3, 1, 2])); +}; + +var test_HashContainsAll = function () { + jum.assertTrue( $H({}) .containsAll({} )); + jum.assertTrue( $H({a: 1, b: 2, c: 3}).containsAll({a: 1, b: 2})); + jum.assertFalse($H({a: 1}) .containsAll({a: 1, b: 2})); + jum.assertFalse($H({a: 1, b: 2}) .containsAll({a: 2, b: 3})); +}; addfile ./src/allmydata/test/test_musicplayer.py hunk ./src/allmydata/test/test_musicplayer.py 1 +import os, shutil +from allmydata.test import tilting +from allmydata.immutable import upload +from base64 import b64decode + +timeout = 1200 + +DATA = {} +DATA['persona'] = """ +SUQzAwAAAAAGEFRJVDIAAAAJAAAAUGVyc29uYQBUUEUxAAAAEAAAAFN1cGVyaHVtYW5 +vaWRzAFRBTEIAAAAJAAAAVXJnZW5jeQBUUkNLAAAABQAAADEvNgBUWUVSAAAABgAAAD +IwMTAAVENPTgAAAAYAAAAoMTMpAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=""".replace('\n', '') + +DATA['bigbang'] = """ +SUQzAwAAAAACblRJVDIAAAAOAAAASGV5IEJpZyBCYW5nAFRQRTEAAAAQAAAAU3VwZXJo +dW1hbm9pZHMAVEFMQgAAAAkAAABVcmdlbmN5AFRSQ0sAAAAFAAAAMy82AFRZRVIAAAAG +AAAAMjAxMABUQ09OAAAABgAAACgxMykAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAA==""".replace('\n', '') + +DATA['maps'] = """ +SUQzBAAAAAAKClRJVDIAAAAFAAADTWFwc1RQRTEAAAAGAAADS2VhbmVURFJDAAAABQAA +AzIwMTBUQUxCAAAAHwAAA1N1bnNoaW5lIFJldHJvc3BlY3RpdmUgQ29sbGVjdFRSQ0sA +AAACAAADMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA +AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==""".replace('\n', '') + +class MusicPlayerJSTest: + def _set_up_tree(self): + self.settings['JAVASCRIPT_TEST_DIR'] = '../contrib/musicplayer/tests' + self.settings['SCRIPT_APPEND_ONLY'] = True + + self.test_url = 'static/musicplayer/index_devel.html' + shutil.copytree('../contrib/musicplayer/src', self.public_html_path + '/musicplayer') + #os.makedirs(self.public_html_path + '/musicplayer/js/workers') + shutil.copytree('../contrib/musicplayer/build/js/workers', self.public_html_path + '/musicplayer/js/workers') + + d = self.client.create_dirnode() + def _created_music_dirnode(node): + self.music_node = node + self.music_cap = node.get_uri() + + return self.client.create_dirnode() + d.addCallback(_created_music_dirnode) + + def _created_settings_dirnode(node): + self.settings_cap = node.get_uri() + d.addCallback(_created_settings_dirnode) + + def _write_config_file(ign): + config = open(os.path.join(self.public_html_path, 'musicplayer', 'config.json'), 'w+') + config.write("""{ + "music_cap": "%s", + "settings_cap": "%s" + }\n""" % (self.music_cap, self.settings_cap)) + config.close() + d.addCallback(_write_config_file) + + persona = upload.Data(b64decode(DATA['persona']), None) + d.addCallback(lambda ign: self.music_node.add_file(u'persona', persona)) + + bigbang = upload.Data(b64decode(DATA['bigbang']), None) + d.addCallback(lambda ign: self.music_node.add_file(u'bigbang', bigbang)) + + maps = upload.Data(b64decode(DATA['maps']), None) + d.addCallback(lambda ign: self.music_node.add_file(u'maps', maps)) + + return d + +class ChromeTest(MusicPlayerJSTest, tilting.JSTestsMixin, tilting.Chrome): + pass + +# . +#class FirefoxTest(MusicPlayerJSTest, tilting.JSTestsMixin, tilting.Firefox): + #pass addfile ./src/allmydata/test/tilting.py hunk ./src/allmydata/test/tilting.py 1 +# Note: may be Apache 2.0 license-contaminated. +# (I haven't checked whether there is a significant license compatibility issue here.) + +import os, logging, tempfile, windmill +from windmill.bin import admin_lib +from twisted.internet import defer +from twisted.trial import unittest +from foolscap.api import eventually + +from allmydata.util import log, fileutil +from allmydata.scripts.create_node import create_node, create_introducer +from allmydata.scripts.startstop_node import do_start, do_stop +from allmydata.immutable import upload +from allmydata.test.no_network import GridTestMixin + +from time import sleep + +class TiltingMixin(GridTestMixin): + # adapted from + # http://github.com/windmill/windmill/blob/master/windmill/authoring/unit.py + # http://github.com/windmill/windmill/blob/master/windmill/bin/shell_objects.py + + def _set_up(self, basedir, num_clients=1, num_servers=10): + self.basedir = 'tilting/' + basedir + self.set_up_grid(num_clients=num_clients, num_servers=num_servers) + self.client = self.g.clients[0] + + self._set_up_windmill() + d = defer.maybeDeferred(self._set_up_tree) + d.addCallback(lambda ign: self._start_windmill()) + return d + + def _set_up_windmill(self): + self.browser_debugging = True + self.browser_name = 'firefox' + self.test_url = '/' + self._js_test_details = [] + self.settings = { + 'EXIT_ON_DONE': False, + 'CONSOLE_LOG_LEVEL': logging.CRITICAL, + 'controllers': []} + + self.public_html_path = "public_html" + fileutil.make_dirs(self.public_html_path) + + log.msg("setting up Windmill for browser '%s'" % (self.browser_name)) + windmill.block_exit = True + # Windmill loves to output all sorts of stuff + windmill.stdout = tempfile.TemporaryFile() + admin_lib.configure_global_settings(logging_on=False) + self.configure() + + def _start_windmill(self): + if self.browser_name == 'firefox': + self.settings['INSTALL_FIREBUG'] = True + for (setting, value) in self.settings.iteritems(): + windmill.settings[setting] = value + windmill.settings['TEST_URL'] = self.client_baseurls[0] + self.test_url + + self.shell_objects = admin_lib.setup() + self.jsonrpc = self.shell_objects['httpd'].jsonrpc_methods_instance + self.jsonrpc_app = self.shell_objects['httpd'].namespaces['windmill-jsonrpc'] + + d = defer.Deferred() + # Windmill prints success/failure statistics on its own + # and this unfortunately seems to be the only way to stop it from doing that. + # This is just a stripped down version of teardown method from windmill.server.convergence.JSONRPCMethods + def _windmill_teardown(**kwargs): + if windmill.settings['EXIT_ON_DONE']: + admin_lib.teardown(admin_lib.shell_objects_dict) + windmill.runserver_running = False + sleep(.25) + + eventually(d.callback, None) + + self.jsonrpc_app.__dict__[u'teardown'] = _windmill_teardown + + log.msg("starting browser") + self.shell_objects['start_' + self.browser_name]() + + if self.browser_debugging: + ready_d = defer.Deferred() + admin_lib.on_ide_awake.append(lambda: eventually(ready_d.callback, None)) + + self.xmlrpc = windmill.tools.make_xmlrpc_client() + ready_d.addCallback(lambda ign: + self.xmlrpc.add_command({'method':'commands.setOptions', + 'params':{'runTests':False, 'priority':'normal'}})) + + if self.settings['JAVASCRIPT_TEST_DIR']: + self._log_js_test_results() + + return d + + def tearDown(self): + if self.browser_debugging: + self.xmlrpc.add_command({'method':'commands.setOptions', + 'params':{'runTests':True, 'priority':'normal'}}) + else: + log.msg("shutting down browser '%s'" % (self.browser_name)) + admin_lib.teardown(self.shell_objects) + log.msg("browser shutdown done") + + return GridTestMixin.tearDown(self) + + def _log_js_test_results(self): + # When running JS tests in windmill, only a "X tests of Y failed" string is printed + # when all tests finish. This replaces Windmill's reporting method so that + # all test results (success/failure) are collected in self._js_test_details and later reported + # to Trial via self.failUnless(). This way Trial can easily pickup failing tests + # and display the error messages (if any). + + def _report_without_resolve(**kwargs): + self.jsonrpc._test_resolution_suite.report_without_resolve(*kwargs) + self._js_test_details.append(kwargs) + + return 200 + + del self.jsonrpc_app.__dict__[u'report_without_resolve'] + self.jsonrpc_app.register_method(_report_without_resolve, u'report_without_resolve') + +class JSTestsMixin: + """ + Mixin for running tests written in JavaScript. + Remember to set self.settings['JS_TESTS_DIR'] (path is relative to _trial_tmp) as well as self.test_url. + """ + + def test_js(self): + d = self._set_up('test_js') + d.addCallback(lambda ign: self._report_results()) + return d + + def _report_results(self): + for test in self._js_test_details: + self.failUnless(test['result'], test['debug']) + +class Chrome(TiltingMixin, unittest.TestCase): + """Starts tests in Chrome.""" + def configure(self): + self.browser_name = "chrome" + +class Firefox(TiltingMixin, unittest.TestCase): + """Starts tests in Firefox.""" + def configure(self): + self.browser_name = "firefox" + +class InternetExplorer(TiltingMixin, unittest.TestCase): + """Starts tests in Internet Explorer.""" + def configure(self): + self.browser_name = "ie" + +class Safari(TiltingMixin, unittest.TestCase): + """Starts tests in Safari.""" + def configure(self): + self.browser_name = "safari" } Context: [quickstart.html: python 2.5 -> 2.6 as recommended version david-sarah@jacaranda.org**20100705175858 Ignore-this: bc3a14645ea1d5435002966ae903199f ] [SFTP: don't call .stopProducing on the producer registered with OverwriteableFileConsumer (which breaks with warner's new downloader). david-sarah@jacaranda.org**20100628231926 Ignore-this: 131b7a5787bc85a9a356b5740d9d996f ] [docs/how_to_make_a_tahoe-lafs_release.txt: trivial correction, install.html should now be quickstart.html. david-sarah@jacaranda.org**20100625223929 Ignore-this: 99a5459cac51bd867cc11ad06927ff30 ] [setup: in the Makefile, refuse to upload tarballs unless someone has passed the environment variable "BB_BRANCH" with value "trunk" zooko@zooko.com**20100619034928 Ignore-this: 276ddf9b6ad7ec79e27474862e0f7d6 ] [trivial: tiny update to in-line comment zooko@zooko.com**20100614045715 Ignore-this: 10851b0ed2abfed542c97749e5d280bc (I'm actually committing this patch as a test of the new eager-annotation-computation of trac-darcs.) ] [docs: about.html link to home page early on, and be decentralized storage instead of cloud storage this time around zooko@zooko.com**20100619065318 Ignore-this: dc6db03f696e5b6d2848699e754d8053 ] [docs: update about.html, especially to have a non-broken link to quickstart.html, and also to comment out the broken links to "for Paranoids" and "for Corporates" zooko@zooko.com**20100619065124 Ignore-this: e292c7f51c337a84ebfeb366fbd24d6c ] [TAG allmydata-tahoe-1.7.0 zooko@zooko.com**20100619052631 Ignore-this: d21e27afe6d85e2e3ba6a3292ba2be1 ] Patch bundle hash: bdbbdc1c68b20ad0e265eb9b0afd16e4ac7c678d