Compare commits

..

2 commits

Author SHA1 Message Date
Andrew Kelley
6ccede1e88 Release 0.2.1 2012-10-23 00:09:14 -04:00
Andrew Kelley
19207dc1cb upload plugin: don't check for 'localhost' bind_to_address in mpd.conf 2012-10-23 00:08:39 -04:00
86 changed files with 9712 additions and 9519 deletions

11
.gitignore vendored
View file

@ -1,7 +1,10 @@
/node_modules /node_modules/
/groovebasin.db /.state.json
/config.js /public/library
# not shared with .npmignore # generated code below here
/public/app.js /public/app.js
/public/views.js
/public/app.css /public/app.css
/server.js
/lib/

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "mpd.js"]
path = mpd.js
url = git://github.com/superjoe30/mpd.js.git

View file

@ -1,74 +0,0 @@
{
// Settings
"passfail" : false, // Stop on first error.
"maxerr" : 100, // Maximum errors before stopping.
// Predefined globals whom JSHint will ignore.
"browser" : true, // Standard browser globals e.g. `window`, `document`.
"node" : true,
"predef" : [
"setImmediate",
"clearImmediate"
],
"rhino" : false,
"couch" : false,
"wsh" : false, // Windows Scripting Host.
"jquery" : false,
"prototypejs" : false,
"mootools" : false,
"dojo" : false,
// Development.
"debug" : true, // Allow debugger statements e.g. browser breakpoints.
"devel" : true, // Allow development statements e.g. `console.log();`.
// EcmaScript 5.
"es5" : true, // Allow EcmaScript 5 syntax.
"strict" : false, // Require `use strict` pragma in every file.
"globalstrict" : true, // Allow global "use strict" (also enables 'strict').
// The Good Parts.
"asi" : false, // Tolerate Automatic Semicolon Insertion (no semicolons).
"laxbreak" : false, // Tolerate unsafe line breaks e.g. `return [\n] x` without semicolons.
"laxcomma" : true,
"bitwise" : false, // Prohibit bitwise operators (&, |, ^, etc.).
"boss" : true, // Tolerate assignments inside if, for & while. Usually conditions & loops are for comparison, not assignments.
"curly" : false, // Require {} for every new block or scope.
"eqeqeq" : true, // Require triple equals i.e. `===`.
"eqnull" : true, // Tolerate use of `== null`.
"evil" : false, // Tolerate use of `eval`.
"expr" : false, // Tolerate `ExpressionStatement` as Programs.
"forin" : false, // Prohibt `for in` loops without `hasOwnProperty`.
"immed" : true, // Require immediate invocations to be wrapped in parens e.g. `( function(){}() );`
"latedef" : false, // Prohibit variable use before definition.
"loopfunc" : false, // Allow functions to be defined within loops.
"noarg" : true, // Prohibit use of `arguments.caller` and `arguments.callee`.
"regexp" : false, // Prohibit `.` and `[^...]` in regular expressions.
"regexdash" : false, // Tolerate unescaped last dash i.e. `[-...]`.
"scripturl" : false, // Tolerate script-targeted URLs.
"shadow" : false, // Allows re-define variables later in code e.g. `var x=1; x=2;`.
"supernew" : false, // Tolerate `new function () { ... };` and `new Object;`.
"undef" : true, // Require all non-global variables be declared before they are used.
// Persone styling prefrences.
"newcap" : true, // Require capitalization of all constructor functions e.g. `new F()`.
"noempty" : true, // Prohibit use of empty blocks.
"nonew" : true, // Prohibit use of constructors for side-effects.
"nomen" : false, // Prohibit use of initial or trailing underbars in names.
"onevar" : false, // Allow only one `var` statement per function.
"plusplus" : false, // Prohibit use of `++` & `--`.
"sub" : false, // Tolerate all forms of subscript notation besides dot notation e.g. `dict['key']` instead of `dict.key`.
"trailing" : true, // Prohibit trailing whitespaces.
"white" : false // Check against strict whitespace and indentation rules.
}

View file

@ -1,5 +1,9 @@
/node_modules /node_modules/
/groovebasin.db /.state.json
/config.js /public/library
# not shared with .gitignore # not shared with .gitignore
/Cakefile
/src/client
/src/server
/TODO

61
Cokefile Normal file
View file

@ -0,0 +1,61 @@
const
fs = require("fs")
path = require("path")
watcher = require("watch")
util = require("util")
mkdirp = require("mkdirp")
{spawn} = require("child_process")
!function exec (cmd, args=[], options={}, cb=->)
opts = stdio: 'inherit'
opts[k] = v for k, v in options
bin = spawn(cmd, args, opts)
bin.on 'exit', cb
!function handlebars
exec 'handlebars', ['-f', 'public/views.js', 'src/client/views/']
!function build (watch)
npm_args = if watch then ['run', 'dev'] else ['run', 'build']
exec 'npm', npm_args, cwd: path.resolve(__dirname, 'mpd.js')
mkdirp 'public', !->
args = if watch then ['-w'] else []
exec 'coco', args.concat(['-cbo', 'lib/', 'src/server/'])
exec 'jspackage', args.concat([
'-l', 'src/public/vendor',
'-l', 'public',
'-l', 'mpd.js/lib',
'src/client/app', 'public/app.js'
])
exec 'stylus', args.concat([
'-o', 'public/',
'-c',
'--include-css',
'src/client/styles'
])
# fuck you handlebars
if watch
watcher.watchTree 'src/client/views', ignoreDotFiles: true, !->
handlebars()
util.log "generated public/views.js"
else
handlebars()
!function watch
build('w')
task "watch", !-> watch()
task "build", !-> build()
task "clean", !->
exec "rm", ['-rf', './public', './lib']
task "dev", !->
# avoid a race condition when running from clean
mkdirp "lib", !->
exec 'touch', ["lib/server.js", "mpd.js/lib/mpd.js"], null, !->
watch()
exec "node-dev", ["lib/server.js"],
stdio: [process.stdin, process.stdout, process.stderr, 'ipc']

317
README.md
View file

@ -1,317 +0,0 @@
# Groove Basin
Music player server with a web-based user interface inspired by Amarok 1.4.
Run it on a server (such as a 512MB
[Raspberry Pi](http://www.raspberrypi.org/)) connected to some speakers
in your home or office. Guests can control the music player by connecting
with a laptop, tablet, or smart phone. Further, you can stream your music
library remotely.
Groove Basin works with your personal music library; not an external music
service. Groove Basin will never support DRM content.
Try out the [live demo](http://demo.groovebasin.com/).
## Features
* Fast, responsive UI. It feels like a desktop app, not a web app.
* Dynamic playlist mode which automatically queues random songs, favoring
songs that have not been queued recently.
* Drag and drop upload. Drag and drop playlist editing. Rich keyboard
shortcuts.
* Lazy multi-core
[EBU R128 loudness scanning](http://tech.ebu.ch/loudness) (tags compatible
with [ReplayGain](http://wiki.hydrogenaudio.org/index.php?title=ReplayGain_1.0_specification))
and automatic switching between track and album mode.
["Loudness Zen"](http://www.youtube.com/watch?v=iuEtQqC-Sqo)
* Streaming support. You can listen to your music library - or share it with
your friends - even when you are not physically near your home speakers.
* MPD protocol support. This means you already have a selection of
[clients](http://mpd.wikia.com/wiki/Clients) which integrate with Groove Basin.
For example [MPDroid](https://github.com/abarisain/dmix).
* [Last.fm](http://www.last.fm/) scrobbling.
* File system monitoring. Add songs anywhere inside your music directory and
they instantly appear in your library in real time.
* Supports GrooveBasin Protocol on the same port as MPD Protocol - use the
`protocolupgrade` command to upgrade.
## Install
1. Install [Node.js](http://nodejs.org) v0.10.x. Note that on Debian and
Ubuntu, sadly the official node package is not sufficient. You will either
have to use [Chris Lea's PPA](https://launchpad.net/~chris-lea/+archive/node.js/)
or compile from source.
2. Install [libgroove](https://github.com/andrewrk/libgroove).
3. Clone the source.
4. `npm run build`
5. `npm start`
## Screenshots
![Search + drag/drop support](http://superjoesoftware.com/temp/groove-basin-0.0.4.png)
![Multi-select and context menu](http://superjoesoftware.com/temp/groove-basin-0.0.4-lib-menu.png)
![Keyboard shortcuts](http://superjoesoftware.com/temp/groove-basin-0.0.4-shortcuts.png)
![Last.fm Scrobbling](http://superjoesoftware.com/temp/groove-basin-0.0.4-lastfm.png)
## Configuration
When Groove Basin starts it will look for `config.js` in the current directory.
If not found it creates one for you with default values.
## Developing
```
$ npm run dev
```
This will install dependencies, build generated files, and then start the
sever. It is up to you to restart it when you modify assets or server files.
### Community
Pull requests, feature requests, and bug reports are welcome! Live discussion
in #libgroove on Freenode.
### Roadmap
1. Tag Editing
2. Music library organization
3. Accoustid Integration
4. Playlists
5. User accounts / permissions rehaul
6. Event history / chat
7. Finalize GrooveBasin protocol spec
## Release Notes
### 1.0.1 (Mar 18 2014)
* Andrew Kelley:
* Fix race condition when removing tracks from playlist. Closes #160
* Default import path includes artist directory.
* Also recognize "TCMP" ID3 tag as compilation album flag
* Fix Last.fm authentication
### 1.0.0 (Mar 15 2014)
* Andrew Kelley:
* Remove dependency on MPD. Groove Basin now works independently of MPD.
It uses [libgroove](https://github.com/andrewrk/libgroove) for audio
playback and streaming support.
* Support MPD protocol on (default) port 6600. Groove Basin now functions as
an MPD server.
* Fix regression for handling unknown artist/album
* Fix playlist to display artist name
* Plug upload security hole
* Groove Basin is no longer written in coco. Hopefully this will enable more
code contributions.
* Simpler config file that can survive new version releases.
* Simpler and more efficient protocol between client and server.
* Pressing prev on first track with repeat all on goes to end
* Automatic loudness detection (ReplayGain) using EBU R128.
- Lazy playlist scanning.
- Automatic switching between album and track mode.
- Takes advantage of multi-core systems.
* Faster rebuilding of album table index
* HTTP audio stream buffers much more quickly and flushes the buffer on seek.
* Fix volume ui going higher than 1.0.
* Fix changing volume not showing up on other clients.
* Native html5 audio streaming instead of soundmanager 2
* Streaming shows when it is buffering
* add meta charset=utf8 to index.html.
* fix volume keyboard shortcuts in firefox.
* Watches music library for updates and quickly updates library.
* Route dynamicmode through permissions framework
* Better default password generation
* web ui: fix current track not displayed sometimes
* upgrade jquery and jquery ui to latest stable. Fixes some UI glitches.
* static assets are gzipped and held permanently in memory. Makes the
web interface load faster.
* player: set "don't cache this" headers on stream
* Remove chat. It's not quite ready yet. Chat will be reimplemented better
in a future release.
* Remove stored playlist stub from UI. Stored playlists will be reimplemented
better in a future release.
* Josh Wolfe:
* Converting the code to not use MPD
* fix multiselect shiftIds
* deleting library items removes them from the queue as well.
* fix shift click going up in the queue
* after deleting tracks, select the next one, not some random one.
### 0.2.0 (Oct 16 2012)
* Andrew Kelley:
* ability to import songs by pasting a URL
* improve build and development setup
* update style to not resize on selection. closes #23
* better connection error messages. closes #21
* separate [mpd.js](https://github.com/andrewrk/mpd.js) into an open source module. closes #25
* fix dynamicmode; use higher level sticker api. closes #22
* search uses ascii folding so that 'jonsi' matches 'Jónsi'. closes #29
* server restarts if it crashes
* server runs as daemon
* server logs to rotating log files
* remove setuid feature. use authbind if you want to run as port 80
* ability to download albums and artists as zip. see #9
* ability to download arbitrary selection as zip. closes #9
* fix track 08 and 09 displaying as 0. closes #65
* fix right click for IE
* better error reporting when state json file is corrupted
* log chats
* fix edge case with unicode characters. closes #67
* fix next and previous while stopped behavior. closes #19
* handle uploading errors. fixes #59
* put link to stream URL in settings. closes #69
* loads faster and renders faster
* send a 404 when downloading can't find artist or album. closes #70
* read-only stored playlist support
* fix playlist display when empty
* add uploaded songs to "Incoming" playlist. closes #80
* fix resize weirdness when you click library tab. closes #75
* don't bold menu option text
* add color to the first part of the track slider. closes #15
* Josh Wolfe:
* fix dynamic mode glitch
* fix dynamic mode with no library or no tags file
* uploading with mpd <0.17 falls back to upload name
### 0.1.2 (Jul 12 2012)
* Andrew Kelley:
* lock in the major versions of dependencies
* more warnings about mpd conf settings
* remove "alert" text on no connection
* better build system
* move dynamic mode configuration to server
* server handles permissions in mpd.conf correctly
* clients can set a password
* ability to delete from library
* use soundmanager2 instead of jplayer for streaming
* buffering status on stream button
* stream button has a paused state
* use .npmignore to only deploy generated files
* update to work with node 0.8.2
* Josh Wolfe:
* pointing at mpd's own repository in readme. #12
* fixing null pointer error for when streaming is disabled
* fixing blank search on library update
* fixing username on reconnect
* backend support for configurable dynamic history and future sizes
* ui for configuring dynamic mode history and future sizes
* coloring yourself different in chat
* scrubbing stale user ids in my_user_ids
* better chat name setting ui
* scrolling chat window properly
* moar chat history
* formatting the state file
* fixing chat window resize on join/left
* validation on dynamic mode settings
* clearer wording in Get Started section and louder mpd version dependency
documentation
### 0.0.6 (Apr 27 2012)
* Josh Wolfe:
* fixing not queuing before random when pressing enter in the search box
* fixing streaming hotkey not updating button ui
* stopping and starting streaming in sync with mpd.status.state.
* fixing weird bug with Stream button checked state
* warning when bind_to_address is not also configured for localhost
* fixing derpy log reference
* fixing negative trackNumber scrobbling
* directory urls download .zip files. #9
* document dependency on mpd version 0.17
* Andrew Kelley:
* fix regression: not queuing before random songs client side
* uploaded songs are queued in the correct place
* support restarting mpd without restarting daemon
* ability to reconnect without refreshing
* log.info instead of console.info for track uploaded msg
* avoid the use of 'static' keyword
* David Banham:
* Make jPlayer aware of which stream format is set
* Removed extra constructor. Changed tabs to 2spaces
### 0.0.5 (Mar 11 2012)
* Note: Requires you to pull from latest mpd git code and recompile.
* Andrew Kelley:
* disable volume slider when mpd reports volume as -1. fixes #8
* on last.fm callback, do minimal work then refresh. fixes #7
* warnings output the actual mpd.conf path instead of "mpd conf". see #5
* resize things *after* rendering things. fixes #6
* put uploaded files in an intelligent place, and fix #2
* ability to retain server state file even when structure changes
* downgrade user permissions ASAP
* label playlist items upon status update
* use blank user_id to avoid error message
* use jplayer for streaming
* Josh Wolfe:
* do not show ugly "user_n" text after usernames in chat.
### 0.0.4 (Mar 6 2012)
* Andrew Kelley:
* update keyboard shortcuts dialog
* fix enter not queuing library songs in firefox
* ability to authenticate with last.fm, last.fm scrobbling
* last.fm scrobbling works
* fix issues with empty playlist. fixes #4
* fix bug with dynamic mode when playlist is clear
* Josh Wolfe:
* easter eggs
* daemon uses a state file
### 0.0.3 (Mar 4 2012)
* Andrew Kelley:
* ability to select artists, albums, tracks in library
* prevents sticker race conditions from crashing the server (#3)
* escape clears the selection cursor too
* ability to shift+click select in library
* right-click queuing in library works
* do not show download menu option since it is not supported yet
* show selection on expanded elements
* download button works for single tracks in right click library menu
* library up/down to change selection
* nextLibPos/prevLibPos respects whether tree items are expanded or collapse
* library window scrolls down when you press up/down to move selection
* double click artists and albums in library to queue
* left/right expands/collapses library tree when lib has selection
* handle enter in playlist and library
* ability to drag artists, albums, tracks to playlist
* Josh Wolfe:
* implement chat room
* users can set their name in the chat room
* users can change their name multiple times
* storing username persistently. disambiguating conflicting usernames.
* loading recent chat history on connect
* normalizing usernames and sanitizing username display
* canot send blank chats
* supporting /nick renames in chat box
* hotkey to focus chat box
### 0.0.2 (Mar 1 2012)
* Andrew Kelley:
* learn mpd host and port in mpd conf
* render unknown albums and unknown artists the same in the playlist (blank)
* auto-scroll playlist window and library window appropriately
* fix server crash when no top-level files exist
* fix some songs error message when uploading
* edit file uploader spinny gif to fit the theme
* move chat stuff to another tab
* Josh Wolfe:
* tracking who is online

4
build
View file

@ -1,4 +0,0 @@
#!/bin/bash
mkdir -p public
./node_modules/.bin/stylus -o public/ -c --include-css src/client/styles
./node_modules/.bin/browserify src/client/app.js --outfile public/app.js

3
docs/.gitignore vendored
View file

@ -1,3 +0,0 @@
_build
_static
_templates

View file

@ -1,287 +0,0 @@
Changelog
=========
1.0.1 (March 18, 2014)
----------------------
.. What does this mean?
* Default import path includes artist directory.
* Groove Basin now recognizes the `TCMP`_ ID3 tag as a compilation album flag.
.. _TCMP: http://id3.org/iTunes%20Compilation%20Flag
Fixes:
* Fixed Last.fm authentication.
* Fixed a race condition when removing tracks from playlist.
1.0.0 (March 15, 2014)
----------------------
In the 1.0.0 release, Groove Basin has removed its dependency on MPD, using
`libgroove`_ for audio playback and streaming support. Groove Basin is also not
written in `coco`_ anymore. Hopefully this will encourage more contributors to
join the project!
Major features include `ReplayGain`_ style automatic loudness detection using the
`EBU R128`_ recommendation. Scanning takes place on the fly, taking advantage of
multi-core systems. Groove Basin automatically switches between album and track
mode depending on the next item in the play queue.
Chat and playlist functionality have been removed as they are not quite ready
yet. These features will be reimplemented better in a future release.
.. _libgroove: https://github.com/andrewrk/libgroove
.. _coco: https://github.com/satyr/coco
.. _ReplayGain: https://en.wikipedia.org/wiki/ReplayGain
.. _EBU R128: https://tech.ebu.ch/loudness
Other features:
* Groove Basin now functions as an MPD server. MPD clients can connect to port
6600 by default.
* The config file is simpler and should survive new version releases.
* Client and server communications now use a simpler and more efficient protocol.
* Rebuilding the album index is faster.
* The HTTP audio stream buffers much more quickly and flushes the buffer on seek.
* Streaming shows when it is buffering.
* The web UI now specifies a `UTF-8`_ character set.
* Groove Basin's music library now updates automatically by watching the music
folder for changes.
* HTTP streaming now uses native HTML5 audio, instead of `SoundManager 2`_
* `jQuery`_ and `jQuery UI`_ have been updated to the latest stable version, fixing
some UI glitches.
* Static assets are gzipped and held permanently in memory, making the web
interface load faster.
* Now routing Dynamic mode through the permissions framework.
* Better default password generation.
.. _UTF-8: https://en.wikipedia.org/wiki/UTF-8
.. _SoundManager 2: http://www.schillmania.com/projects/soundmanager2/
.. _jQuery: https://jquery.com/
.. _jQuery UI: https://jqueryui.com/
Fixes:
* Fixed a regression for handling unknown artists or albums.
* Fixed play queue to display the artist name of tracks.
* Plugged an upload security hole.
* Pressing the previous track button on the first track in the play queue when
"repeat all" is turned on now plays the last track in the play queue.
* The volume widget no longer goes higher than 100%.
* Changing the volume now shows up on other clients.
* The volume keyboard shortcuts now work in Firefox.
* Ensured that no-cache headers are set for the stream.
* Fixed an issue in the Web UI where the current track was sometimes not
displayed.
Thanks to Josh Wolfe, who worked to fix some issues around deleting library
items, ensuring that deleting library items removes them from the play queue,
and that the play queue correctly reacts to deleted library entries.
In addition, he worked to:
* Convert Groove Basin to not use MPD.
* fix multiselect shiftIds.
* fix shift click going up in the queue.
.. What does this mean?
0.2.0 (October 16, 2012)
-------------------------
* Andrew Kelley:
* ability to import songs by pasting a URL
* improve build and development setup
* update style to not resize on selection. closes #23
* better connection error messages. closes #21
* separate [mpd.js](https://github.com/andrewrk/mpd.js) into an open source module. closes #25
* fix dynamicmode; use higher level sticker api. closes #22
* search uses ascii folding so that 'jonsi' matches 'Jónsi'. closes #29
* server restarts if it crashes
* server runs as daemon
* server logs to rotating log files
* remove setuid feature. use authbind if you want to run as port 80
* ability to download albums and artists as zip. see #9
* ability to download arbitrary selection as zip. closes #9
* fix track 08 and 09 displaying as 0. closes #65
* fix right click for IE
* better error reporting when state json file is corrupted
* log chats
* fix edge case with unicode characters. closes #67
* fix next and previous while stopped behavior. closes #19
* handle uploading errors. fixes #59
* put link to stream URL in settings. closes #69
* loads faster and renders faster
* send a 404 when downloading can't find artist or album. closes #70
* read-only stored playlist support
* fix playlist display when empty
* add uploaded songs to "Incoming" playlist. closes #80
* fix resize weirdness when you click library tab. closes #75
* don't bold menu option text
* add color to the first part of the track slider. closes #15
* Josh Wolfe:
* fix dynamic mode glitch
* fix dynamic mode with no library or no tags file
* uploading with mpd <0.17 falls back to upload name
0.1.2 (July 12, 2012)
---------------------
* Andrew Kelley:
* lock in the major versions of dependencies
* more warnings about mpd conf settings
* remove "alert" text on no connection
* better build system
* move dynamic mode configuration to server
* server handles permissions in mpd.conf correctly
* clients can set a password
* ability to delete from library
* use soundmanager2 instead of jplayer for streaming
* buffering status on stream button
* stream button has a paused state
* use .npmignore to only deploy generated files
* update to work with node 0.8.2
* Josh Wolfe:
* pointing at mpd's own repository in readme. #12
* fixing null pointer error for when streaming is disabled
* fixing blank search on library update
* fixing username on reconnect
* backend support for configurable dynamic history and future sizes
* ui for configuring dynamic mode history and future sizes
* coloring yourself different in chat
* scrubbing stale user ids in my_user_ids
* better chat name setting ui
* scrolling chat window properly
* moar chat history
* formatting the state file
* fixing chat window resize on join/left
* validation on dynamic mode settings
* clearer wording in Get Started section and louder mpd version dependency
documentation
0.0.6 (April 27, 2012)
----------------------
* Josh Wolfe:
* fixing not queuing before random when pressing enter in the search box
* fixing streaming hotkey not updating button ui
* stopping and starting streaming in sync with mpd.status.state.
* fixing weird bug with Stream button checked state
* warning when bind_to_address is not also configured for localhost
* fixing derpy log reference
* fixing negative trackNumber scrobbling
* directory urls download .zip files. #9
* document dependency on mpd version 0.17
* Andrew Kelley:
* fix regression: not queuing before random songs client side
* uploaded songs are queued in the correct place
* support restarting mpd without restarting daemon
* ability to reconnect without refreshing
* log.info instead of console.info for track uploaded msg
* avoid the use of 'static' keyword
* David Banham:
* Make jPlayer aware of which stream format is set
* Removed extra constructor. Changed tabs to 2spaces
0.0.5 (March 11, 2012)
----------------------
* Note: Requires you to pull from latest mpd git code and recompile.
* Andrew Kelley:
* disable volume slider when mpd reports volume as -1. fixes #8
* on last.fm callback, do minimal work then refresh. fixes #7
* warnings output the actual mpd.conf path instead of "mpd conf". see #5
* resize things *after* rendering things. fixes #6
* put uploaded files in an intelligent place, and fix #2
* ability to retain server state file even when structure changes
* downgrade user permissions ASAP
* label playlist items upon status update
* use blank user_id to avoid error message
* use jplayer for streaming
* Josh Wolfe:
* do not show ugly "user_n" text after usernames in chat.
0.0.4 (March 6, 2012)
---------------------
* Andrew Kelley:
* update keyboard shortcuts dialog
* fix enter not queuing library songs in firefox
* ability to authenticate with last.fm, last.fm scrobbling
* last.fm scrobbling works
* fix issues with empty playlist. fixes #4
* fix bug with dynamic mode when playlist is clear
* Josh Wolfe:
* easter eggs
* daemon uses a state file
0.0.3 (March 4, 2012)
---------------------
* Andrew Kelley:
* ability to select artists, albums, tracks in library
* prevents sticker race conditions from crashing the server (#3)
* escape clears the selection cursor too
* ability to shift+click select in library
* right-click queuing in library works
* do not show download menu option since it is not supported yet
* show selection on expanded elements
* download button works for single tracks in right click library menu
* library up/down to change selection
* nextLibPos/prevLibPos respects whether tree items are expanded or collapse
* library window scrolls down when you press up/down to move selection
* double click artists and albums in library to queue
* left/right expands/collapses library tree when lib has selection
* handle enter in playlist and library
* ability to drag artists, albums, tracks to playlist
* Josh Wolfe:
* implement chat room
* users can set their name in the chat room
* users can change their name multiple times
* storing username persistently. disambiguating conflicting usernames.
* loading recent chat history on connect
* normalizing usernames and sanitizing username display
* canot send blank chats
* supporting /nick renames in chat box
* hotkey to focus chat box
0.0.2 (March 1, 2012)
-------------------------
* Andrew Kelley:
* learn mpd host and port in mpd conf
* render unknown albums and unknown artists the same in the playlist (blank)
* auto-scroll playlist window and library window appropriately
* fix server crash when no top-level files exist
* fix some songs error message when uploading
* edit file uploader spinny gif to fit the theme
* move chat stuff to another tab
* Josh Wolfe:
* tracking who is online

View file

@ -1,242 +0,0 @@
# -*- coding: utf-8 -*-
#
# Groove Basin documentation build configuration file, created by
# sphinx-quickstart on Thu Apr 24 14:07:20 2014.
#
# This file is execfile()d with the current directory set to its containing dir.
#
# Note that not all possible configuration values are present in this
# autogenerated file.
#
# All configuration values have a default; values that are commented out
# serve to show the default.
import sys, os
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#sys.path.insert(0, os.path.abspath('.'))
# -- General configuration -----------------------------------------------------
# If your documentation needs a minimal Sphinx version, state it here.
#needs_sphinx = '1.0'
# Add any Sphinx extension module names here, as strings. They can be extensions
# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
extensions = []
# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']
# The suffix of source filenames.
source_suffix = '.rst'
# The encoding of source files.
#source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'index'
# General information about the project.
project = u'Groove Basin'
copyright = u'2014, Andrew Kelley'
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = '1.0.1'
# The full version, including alpha/beta/rc tags.
release = '1.0.1'
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
#today = ''
# Else, today_fmt is used as the format for a strftime call.
#today_fmt = '%B %d, %Y'
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
exclude_patterns = ['_build']
# The reST default role (used for this markup: `text`) to use for all documents.
#default_role = None
# If true, '()' will be appended to :func: etc. cross-reference text.
#add_function_parentheses = True
# If true, the current module name will be prepended to all description
# unit titles (such as .. function::).
#add_module_names = True
# If true, sectionauthor and moduleauthor directives will be shown in the
# output. They are ignored by default.
#show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
pygments_style = 'sphinx'
# A list of ignored prefixes for module index sorting.
#modindex_common_prefix = []
# -- Options for HTML output ---------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
html_theme = 'default'
# Theme options are theme-specific and customize the look and feel of a theme
# further. For a list of options available for each theme, see the
# documentation.
#html_theme_options = {}
# Add any paths that contain custom themes here, relative to this directory.
#html_theme_path = []
# The name for this set of Sphinx documents. If None, it defaults to
# "<project> v<release> documentation".
#html_title = None
# A shorter title for the navigation bar. Default is the same as html_title.
#html_short_title = None
# The name of an image file (relative to this directory) to place at the top
# of the sidebar.
#html_logo = None
# The name of an image file (within the static path) to use as favicon of the
# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
# pixels large.
#html_favicon = None
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']
# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
# using the given strftime format.
#html_last_updated_fmt = '%b %d, %Y'
# If true, SmartyPants will be used to convert quotes and dashes to
# typographically correct entities.
#html_use_smartypants = True
# Custom sidebar templates, maps document names to template names.
#html_sidebars = {}
# Additional templates that should be rendered to pages, maps page names to
# template names.
#html_additional_pages = {}
# If false, no module index is generated.
#html_domain_indices = True
# If false, no index is generated.
#html_use_index = True
# If true, the index is split into individual pages for each letter.
#html_split_index = False
# If true, links to the reST sources are added to the pages.
#html_show_sourcelink = True
# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
#html_show_sphinx = True
# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
#html_show_copyright = True
# If true, an OpenSearch description file will be output, and all pages will
# contain a <link> tag referring to it. The value of this option must be the
# base URL from which the finished HTML is served.
#html_use_opensearch = ''
# This is the file name suffix for HTML files (e.g. ".xhtml").
#html_file_suffix = None
# Output file base name for HTML help builder.
htmlhelp_basename = 'GrooveBasindoc'
# -- Options for LaTeX output --------------------------------------------------
latex_elements = {
# The paper size ('letterpaper' or 'a4paper').
#'papersize': 'letterpaper',
# The font size ('10pt', '11pt' or '12pt').
#'pointsize': '10pt',
# Additional stuff for the LaTeX preamble.
#'preamble': '',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title, author, documentclass [howto/manual]).
latex_documents = [
('index', 'GrooveBasin.tex', u'Groove Basin Documentation',
u'Andrew Kelley', 'manual'),
]
# The name of an image file (relative to this directory) to place at the top of
# the title page.
#latex_logo = None
# For "manual" documents, if this is true, then toplevel headings are parts,
# not chapters.
#latex_use_parts = False
# If true, show page references after internal links.
#latex_show_pagerefs = False
# If true, show URL addresses after external links.
#latex_show_urls = False
# Documents to append as an appendix to all manuals.
#latex_appendices = []
# If false, no module index is generated.
#latex_domain_indices = True
# -- Options for manual page output --------------------------------------------
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
('index', 'groovebasin', u'Groove Basin Documentation',
[u'Andrew Kelley'], 1)
]
# If true, show URL addresses after external links.
#man_show_urls = False
# -- Options for Texinfo output ------------------------------------------------
# Grouping the document tree into Texinfo files. List of tuples
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
('index', 'GrooveBasin', u'Groove Basin Documentation',
u'Andrew Kelley', 'GrooveBasin', 'One line description of project.',
'Miscellaneous'),
]
# Documents to append as an appendix to all manuals.
#texinfo_appendices = []
# If false, no module index is generated.
#texinfo_domain_indices = True
# How to display URL addresses: 'footnote', 'no', or 'inline'.
#texinfo_show_urls = 'footnote'

View file

@ -1,69 +0,0 @@
Getting Started
===============
Welcome to Groove Basin! This guide will help you begin using it to listen to music.
Installing
----------
Installing on Ubuntu
^^^^^^^^^^^^^^^^^^^^
Groove Basin is still in development and has not yet been packaged by Ubuntu, so you will have to build it from source.
Install `Node.js`_ v0.10.x or greater. We recommend using `Chris Lea's PPA`_ for Node. If you want to use the PPA, run:
``add-apt-repository ppa:chris-lea/node.js``
``apt-get update && apt-get install nodejs``
.. _Node.js: http://nodejs.org
.. _Chris Lea's PPA: https://launchpad.net/~chris-lea/+archive/node.js/
Install `libgroove`_ from the `libgroove PPA`_:
.. _libgroove: https://github.com/andrewrk/libgroove
.. _libgroove PPA: https://launchpad.net/~andrewrk/+archive/libgroove
``apt-add-repository ppa:andrewrk/libgroove``
``apt-get update && apt-get install libgroove-dev libgrooveplayer-dev libgrooveloudness-dev libgroovefingerprinter-dev``
Install `Git`_ if it is not already installed:
``apt-get install git``
.. _Git: http://git-scm.com/
Clone the Groove Basin git repository somewhere:
``git clone https://github.com/andrewrk/groovebasin.git``
Build Groove Basin:
``cd groovebasin``
``npm run build``
Running Groove Basin
--------------------
To start Groove Basin:
``npm start``
Importing Your Library
----------------------
Groove Basin currently supports a single music library folder. Open the ``config.js`` file that Groove Basin creates on first run and edit the ``musicDirectory`` key to point to your music directory.
Playing Your Music
------------------
Now that you have Groove Basin set up and indexing your music, you can start playing your music!
Open your favorite web browser and point it to:
http://localhost:16242
You should now see Groove Basin and can add songs to the play queue for playback. Double click on a song to play it.

View file

@ -1,29 +0,0 @@
.. Groove Basin documentation master file, created by
sphinx-quickstart on Thu Apr 24 14:07:20 2014.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Groove Basin: the ultimate music player
========================================
Welcome to the documentation for Groove Basin, a music player with a web-based user interface inspired by Amarok 1.4..
If you're new to Groove Basin, begin with the `:docs:guides/main`_ guide. That guide walks you through installing Groove Basin, setting it up how you like it, and starting to build your music library.
If you still need help, your can drop by the #libgroove IRC channel on Freenode or file a bug in the issue tracker. Please let us know where you think this documentation can be improved.
.. _:docs:guides/main: guides/main.rst
Contents:
.. toctree::
:maxdepth: 2
Index
==================
* :ref:`genindex`
* :ref:`search`

View file

@ -1,96 +0,0 @@
var cpuCount = require('os').cpus().length;
var EventEmitter = require('events').EventEmitter;
var util = require('util');
module.exports = DedupedQueue;
util.inherits(DedupedQueue, EventEmitter);
function DedupedQueue(options) {
EventEmitter.call(this);
this.maxAsync = options.maxAsync || cpuCount;
this.processOne = options.processOne;
this.pendingQueue = [];
this.pendingSet = {};
this.processingCount = 0;
this.processingSet = {};
}
DedupedQueue.prototype.idInQueue = function(id) {
return !!(this.pendingSet[id] || this.processingSet[id]);
};
DedupedQueue.prototype.add = function(id, item, cb) {
var queueItem = this.pendingSet[id];
if (queueItem) {
if (cb) queueItem.cbs.push(cb);
return;
}
queueItem = new QueueItem(id, item);
if (cb) queueItem.cbs.push(cb);
this.pendingSet[id] = queueItem;
this.pendingQueue.push(queueItem);
this.flush();
};
DedupedQueue.prototype.waitForId = function(id, cb) {
var queueItem = this.pendingSet[id] || this.processingSet[id];
if (!queueItem) return cb();
queueItem.cbs.push(cb);
};
DedupedQueue.prototype.flush = function() {
// if an item cannot go into the processing queue because an item with the
// same ID is already there, it goes into deferred
var deferred = [];
while (this.processingCount < this.maxAsync && this.pendingQueue.length > 0) {
var queueItem = this.pendingQueue.shift();
if (this.processingSet[queueItem.id]) {
deferred.push(queueItem);
} else {
delete this.pendingSet[queueItem.id];
this.processingSet[queueItem.id] = queueItem;
this.processingCount += 1;
this.startOne(queueItem);
}
}
for (var i = 0; i < deferred.length; i += 1) {
this.pendingQueue.push(deferred[i]);
}
};
DedupedQueue.prototype.startOne = function(queueItem) {
var self = this;
var callbackCalled = false;
self.processOne(queueItem.item, function(err) {
if (callbackCalled) {
self.emit('error', new Error("callback called more than once"));
return;
}
callbackCalled = true;
delete self.processingSet[queueItem.id];
self.processingCount -= 1;
if (queueItem.cbs.length === 0) {
defaultCb(err);
} else {
for (var i = 0; i < queueItem.cbs.length; i += 1) {
queueItem.cbs[i](err);
}
}
self.flush();
function defaultCb(err) {
if (err) self.emit('error', err);
self.emit('oneEnd');
}
});
};
function QueueItem(id, item) {
this.id = id;
this.item = item;
this.cbs = [];
}

View file

@ -1,379 +0,0 @@
var EventEmitter = require('events').EventEmitter;
var http = require('http');
var assert = require('assert');
var WebSocketServer = require('ws').Server;
var fs = require('fs');
var archiver = require('archiver');
var util = require('util');
var path = require('path');
var Pend = require('pend');
var express = require('express');
var osenv = require('osenv');
var spawn = require('child_process').spawn;
var requireIndex = require('requireindex');
var plugins = requireIndex(path.join(__dirname, 'plugins'));
var Player = require('./player');
var PlayerServer = require('./player_server');
var MpdProtocol = require('./mpd_protocol');
var MpdApiServer = require('./mpd_api_server');
var WebSocketApiClient = require('./web_socket_api_client');
var levelup = require('level');
var crypto = require('crypto');
var net = require('net');
var safePath = require('./safe_path');
var MultipartForm = require('multiparty').Form;
var createGzipStatic = require('connect-static');
var serveStatic = require('serve-static');
var bodyParser = require('body-parser');
module.exports = GrooveBasin;
var defaultConfig = {
host: '0.0.0.0',
port: 16242,
dbPath: "groovebasin.db",
musicDirectory: path.join(osenv.home(), "music"),
permissions: {},
defaultPermissions: {
read: true,
add: true,
control: true,
},
lastFmApiKey: "7d831eff492e6de5be8abb736882c44d",
lastFmApiSecret: "8713e8e893c5264608e584a232dd10a0",
mpdHost: '0.0.0.0',
mpdPort: 6600,
acoustidAppKey: 'bgFvC4vW',
instantBufferBytes: 220 * 1024,
};
defaultConfig.permissions[genPassword()] = {
admin: true,
read: true,
add: true,
control: true,
};
util.inherits(GrooveBasin, EventEmitter);
function GrooveBasin() {
EventEmitter.call(this);
this.app = express();
}
GrooveBasin.prototype.initConfigVar = function(name, defaultValue) {
this.configVars.push(name);
this[name] = defaultValue;
};
GrooveBasin.prototype.loadConfig = function(cb) {
var self = this;
var pathToConfig = "config.js";
fs.readFile(pathToConfig, {encoding: 'utf8'}, function(err, contents) {
var anythingAdded = false;
var config;
if (err) {
if (err.code === 'ENOENT') {
anythingAdded = true;
self.config = defaultConfig;
console.warn("No config.js found; writing default.");
} else {
return cb(err);
}
} else {
try {
self.config = JSON.parse(contents);
} catch (err) {
cb(err);
}
}
// this ensures that even old files get new config values when we add them
for (var key in defaultConfig) {
if (self.config[key] === undefined) {
anythingAdded = true;
self.config[key] = defaultConfig[key];
}
}
if (anythingAdded) {
fs.writeFile(pathToConfig, JSON.stringify(self.config, null, 4), cb);
} else {
cb();
}
});
};
GrooveBasin.prototype.start = function() {
var self = this;
var pend = new Pend();
pend.go(function(cb) {
self.loadConfig(cb);
});
pend.go(function(cb) {
var options = {
dir: path.join(__dirname, "../public"),
aliases: [],
};
createGzipStatic(options, function(err, middleware) {
if (err) return cb(err);
self.app.use(middleware);
cb();
});
});
pend.go(function(cb) {
createGzipStatic({dir: path.join(__dirname, "../src/public")}, function(err, middleware) {
if (err) return cb(err);
self.app.use(middleware);
cb();
});
});
pend.wait(function(err) {
if (err) {
console.error(err.stack);
return;
}
self.httpHost = self.config.host;
self.httpPort = self.config.port;
self.db = levelup(self.config.dbPath);
self.initializeDownload();
self.initializeUpload();
self.player = new Player(self.db, self.config.musicDirectory, self.config.instantBufferBytes);
self.player.initialize(function(err) {
if (err) {
console.error("unable to initialize player:", err.stack);
return;
}
console.info("Player initialization complete.");
self.app.use(self.player.streamMiddleware.bind(self.player));
var pend = new Pend();
for (var pluginName in plugins) {
var PluginClass = plugins[pluginName];
var plugin = new PluginClass(self);
if (plugin.initialize) pend.go(plugin.initialize.bind(plugin));
}
pend.wait(function(err) {
if (err) {
console.error("Error initializing plugin:", err.stack);
return;
}
self.startServer();
});
});
});
};
GrooveBasin.prototype.initializeDownload = function() {
var self = this;
var musicDir = self.config.musicDirectory;
self.app.use('/library', serveStatic(musicDir));
self.app.get('/library/', function(req, resp) {
downloadPath("", "library.zip", req, resp);
});
self.app.get(/^\/library\/(.*)\/$/, function(req, resp){
var reqDir = req.params[0];
var zipName = safePath(reqDir.replace(/\//g, " - ")) + ".zip";
downloadPath(reqDir, zipName, req, resp);
});
self.app.post('/download/custom', bodyParser(), function(req, resp) {
var reqKeys = req.body.key;
if (!Array.isArray(reqKeys)) {
reqKeys = [reqKeys];
}
var files = [];
for (var i = 0; i < reqKeys.length; i += 1) {
var key = reqKeys[i];
var dbFile = self.player.libraryIndex.trackTable[key];
if (dbFile) files.push(path.join(musicDir, dbFile.file));
}
var reqZipName = (req.body.zipName || "music").toString();
var zipName = safePath(reqZipName) + ".zip";
sendZipOfFiles(zipName, files, req, resp);
});
function downloadPath(dirName, zipName, req, resp) {
var files = [];
var dirEntry = self.player.dirs[dirName];
if (!dirEntry) {
resp.statusCode = 404;
resp.end("Not found");
return;
}
sendZipOfFiles(zipName, files, req, resp);
function addOneDir(dirEntry) {
var baseName, relPath;
for (baseName in dirEntry.entries) {
relPath = path.join(dirEntry.dirName, baseName);
var dbTrack = self.player.dbFilesByPath[relPath];
if (dbTrack) files.push(dbTrack.file);
}
for (baseName in dirEntry.dirEntries) {
relPath = path.join(dirEntry.dirName, baseName);
var childEntry = self.player.dirs[relPath];
if (childEntry) addOneDir(childEntry);
}
}
}
function sendZipOfFiles(zipName, files, req, resp) {
var cleanup = [];
req.on('close', cleanupEverything);
resp.setHeader("Content-Type", "application/zip");
resp.setHeader("Content-Disposition", "attachment; filename=" + zipName);
var archive = archiver('zip');
archive.on('error', function(err) {
console.log("Error while sending zip of files:", err.stack);
cleanupEverything();
});
cleanup.push(function(){
archive.destroy();
});
archive.pipe(resp);
files.forEach(function(file) {
var options = {
name: path.relative(self.config.musicDirectory, file),
};
var readStream = fs.createReadStream(file);
readStream.on('error', function(err) {
console.error("zip read stream error:", err.stack);
});
cleanup.push(function() {
readStream.destroy();
});
archive.append(readStream, options);
});
archive.finalize(function(err) {
if (err) {
console.error("Error finalizing zip:", err.stack);
cleanupEverything();
}
});
function cleanupEverything() {
cleanup.forEach(function(fn) {
try {
fn();
} catch(err) {}
});
resp.end();
}
}
};
GrooveBasin.prototype.initializeUpload = function() {
var self = this;
self.app.post('/upload', function(request, response, next) {
var form = new MultipartForm();
form.parse(request, function(err, fields, files) {
if (err) return next(err);
var keys = [];
var pend = new Pend();
for (var key in files) {
var arr = files[key];
for (var i = 0; i < arr.length; i += 1) {
var file = arr[i];
pend.go(makeImportFn(file));
}
}
pend.wait(function() {
response.json(keys);
});
function makeImportFn(file) {
return function(cb) {
self.player.importFile(file.path, file.originalFilename, function(err, dbFile) {
if (err) {
console.error("Unable to import file:", file.path, "error:", err.stack);
} else if (!dbFile) {
console.error("Unable to locate new file due to race condition");
} else {
keys.push(dbFile.key);
}
cb();
});
};
}
});
});
};
GrooveBasin.prototype.startServer = function() {
var self = this;
assert.ok(self.httpServer == null);
self.playerServer = new PlayerServer({
player: self.player,
authenticate: authenticate,
defaultPermissions: self.config.defaultPermissions,
});
self.mpdApiServer = new MpdApiServer(self.player);
self.httpServer = http.createServer(self.app);
self.wss = new WebSocketServer({
server: self.httpServer,
clientTracking: false,
});
self.wss.on('connection', function(ws) {
self.playerServer.handleNewClient(new WebSocketApiClient(ws));
});
self.httpServer.listen(self.httpPort, self.httpHost, function() {
self.emit('listening');
console.info("Listening at http://" + self.httpHost + ":" + self.httpPort + "/");
});
self.httpServer.on('close', function() {
console.info("server closed");
});
var mpdPort = self.config.mpdPort;
var mpdHost = self.config.mpdHost;
if (mpdPort == null || mpdHost == null) {
console.info("MPD Protocol disabled");
} else {
self.protocolServer = net.createServer(function(socket) {
socket.setEncoding('utf8');
var protocol = new MpdProtocol({
player: self.player,
playerServer: self.playerServer,
apiServer: self.mpdApiServer,
authenticate: authenticate,
permissions: self.config.defaultPermissions,
});
protocol.on('error', handleError);
socket.on('error', handleError);
socket.pipe(protocol).pipe(socket);
socket.on('close', cleanup);
self.mpdApiServer.handleNewClient(protocol);
function handleError(err) {
console.error("socket error:", err.stack);
socket.destroy();
cleanup();
}
function cleanup() {
self.mpdApiServer.handleClientEnd(protocol);
}
});
self.protocolServer.listen(mpdPort, mpdHost, function() {
console.info("MPD/GrooveBasin Protocol listening at " +
mpdHost + ":" + mpdPort);
});
}
function authenticate(password) {
return self.config.permissions[password];
}
};
function genPassword() {
return crypto.pseudoRandomBytes(9).toString('base64');
}

View file

@ -1,73 +0,0 @@
var EventEmitter = require('events').EventEmitter;
var util = require('util');
module.exports = MpdApiServer;
// stuff that is global to all connected mpd clients
util.inherits(MpdApiServer, EventEmitter);
function MpdApiServer(player) {
var self = this;
EventEmitter.call(self);
self.gbIdToMpdId = {};
self.mpdIdToGbId = {};
self.nextMpdId = 0;
self.singleMode = false;
self.clients = [];
player.on('volumeUpdate', onVolumeUpdate);
player.on('repeatUpdate', updateOptionsSubsystem);
player.on('dynamicModeOn', updateOptionsSubsystem);
player.on('playlistUpdate', onPlaylistUpdate);
player.on('deleteDbTrack', updateDatabaseSubsystem);
player.on('addDbTrack', updateDatabaseSubsystem);
player.on('updateDbTrack', updateDatabaseSubsystem);
function onVolumeUpdate() {
self.subsystemUpdate('mixer');
}
function onPlaylistUpdate() {
// TODO make these updates more fine grained
self.subsystemUpdate('playlist');
self.subsystemUpdate('player');
}
function updateOptionsSubsystem() {
self.subsystemUpdate('options');
}
function updateDatabaseSubsystem() {
self.subsystemUpdate('database');
}
}
MpdApiServer.prototype.handleClientEnd = function(client) {
var index = this.clients.indexOf(client);
if (index !== -1) this.clients.splice(index, 1);
};
MpdApiServer.prototype.handleNewClient = function(client) {
this.clients.push(client);
};
MpdApiServer.prototype.subsystemUpdate = function(subsystem) {
this.clients.forEach(function(client) {
client.updatedSubsystems[subsystem] = true;
if (client.isIdle) client.handleIdle();
});
};
MpdApiServer.prototype.toMpdId = function(grooveBasinId) {
var mpdId = this.gbIdToMpdId[grooveBasinId];
if (!mpdId) {
mpdId = this.nextMpdId++;
this.gbIdToMpdId[grooveBasinId] = mpdId;
this.mpdIdToGbId[mpdId] = grooveBasinId;
}
return mpdId;
};
MpdApiServer.prototype.fromMpdId = function(mpdId) {
return this.mpdIdToGbId[mpdId];
};
MpdApiServer.prototype.setSingleMode = function(mode) {
this.singleMode = mode;
this.subsystemUpdate('options');
};

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,347 +0,0 @@
var uuid = require('uuid');
var jsondiffpatch = require('jsondiffpatch');
var Player = require('./player');
module.exports = PlayerServer;
PlayerServer.plugins = [];
PlayerServer.actions = {
'addid': {
permission: 'add',
args: 'object',
fn: function(self, client, items) {
self.player.addItems(items);
},
},
'clear': {
permission: 'control',
fn: function(self) {
self.player.clearPlaylist();
},
},
'deleteTracks': {
permission: 'admin',
args: 'array',
fn: function(self, client, keys) {
for (var i = 0; i < keys.length; i += 1) {
var key = keys[i];
self.player.deleteFile(key);
}
},
},
'deleteid': {
permission: 'control',
args: 'array',
fn: function(self, client, ids) {
self.player.removePlaylistItems(ids);
},
},
'dynamicModeOn': {
permission: 'control',
args: 'boolean',
fn: function(self, client, on) {
self.player.setDynamicModeOn(on);
},
},
'dynamicModeHistorySize': {
permission: 'control',
args: 'number',
fn: function(self, client, size) {
self.player.setDynamicModeHistorySize(size);
},
},
'dynamicModeFutureSize': {
permission: 'control',
args: 'number',
fn: function(self, client, size) {
self.player.setDynamicModeFutureSize(size);
},
},
'importUrl': {
permission: 'control',
args: 'object',
fn: function(self, client, args) {
var urlString = String(args.url);
var id = args.id;
self.player.importUrl(urlString, function(err, dbFile) {
var key = null;
if (err) {
console.error("Unable to import url:", urlString, "error:", err.stack);
} else if (!dbFile) {
console.error("Unable to import file due to race condition.");
} else {
key = dbFile.key;
}
client.sendMessage('importUrl', {id: id, key: key});
});
},
},
'subscribe': {
permission: 'read',
args: 'object',
fn: function(self, client, args) {
var name = args.name;
var subscription = self.subscriptions[name];
if (!subscription) {
console.warn("Invalid subscription item:", name);
return;
}
if (args.delta) {
client.subscriptions[name] = 'delta';
if (args.version !== subscription.version) {
client.sendMessage(name, {
version: subscription.version,
reset: true,
delta: jsondiffpatch.diff(undefined, subscription.value),
});
}
} else {
client.subscriptions[name] = 'simple';
client.sendMessage(name, subscription.value);
}
},
},
'updateTags': {
permission: 'admin',
args: 'object',
fn: function(self, client, obj) {
self.player.updateTags(obj);
},
},
'unsubscribe': {
permission: 'read',
args: 'string',
fn: function(self, client, name) {
delete client.subscriptions[name];
},
},
'move': {
permission: 'control',
args: 'object',
fn: function(self, client, items) {
self.player.movePlaylistItems(items);
},
},
'password': {
permission: null,
args: 'string',
fn: function(self, client, password) {
var perms = self.authenticate(password);
var success = perms != null;
if (success) client.permissions = perms;
client.sendMessage('permissions', client.permissions);
},
},
'pause': {
permission: 'control',
fn: function(self, client) {
self.player.pause();
},
},
'play': {
permission: 'control',
fn: function(self, client) {
self.player.play();
},
},
'seek': {
permission: 'control',
args: 'object',
fn: function(self, client, args) {
self.player.seek(args.id, args.pos);
},
},
'repeat': {
permission: 'control',
args: 'number',
fn: function(self, client, mode) {
self.player.setRepeat(mode);
},
},
'setvol': {
permission: 'control',
args: 'number',
fn: function(self, client, vol) {
self.player.setVolume(vol);
},
},
'shuffle': {
permission: 'control',
fn: function(self, client) {
self.player.shufflePlaylist();
},
},
'stop': {
permission: 'control',
fn: function(self, client) {
self.player.stop();
},
},
};
function PlayerServer(options) {
this.player = options.player;
this.authenticate = options.authenticate;
this.defaultPermissions = options.defaultPermissions;
this.subscriptions = {};
this.clients = [];
this.playlistId = uuid();
this.libraryId = uuid();
this.initialize();
}
PlayerServer.prototype.initialize = function() {
var self = this;
self.player.on('currentTrack', addSubscription('currentTrack', getCurrentTrack));
self.player.on('dynamicModeOn', addSubscription('dynamicModeOn', getDynamicModeOn));
self.player.on('dynamicModeHistorySize', addSubscription('dynamicModeHistorySize', getDynamicModeHistorySize));
self.player.on('dynamicModeFutureSize', addSubscription('dynamicModeFutureSize', getDynamicModeFutureSize));
self.player.on('repeatUpdate', addSubscription('repeat', getRepeat));
self.player.on('volumeUpdate', addSubscription('volume', getVolume));
self.player.on('playlistUpdate', addSubscription('playlist', serializePlaylist));
var onLibraryUpdate = addSubscription('library', serializeLibrary);
self.player.on('addDbTrack', onLibraryUpdate);
self.player.on('updateDbTrack', onLibraryUpdate);
self.player.on('deleteDbTrack', onLibraryUpdate);
self.player.on('scanComplete', onLibraryUpdate);
self.player.on('seek', function() {
self.clients.forEach(function(client) {
client.sendMessage('seek');
});
});
setInterval(function() {
self.clients.forEach(function(client) {
client.sendMessage('time', new Date());
});
}, 30000);
function addSubscription(name, serializeFn) {
var subscription = self.subscriptions[name] = {
version: uuid(),
value: serializeFn(),
};
return function() {
var newValue = serializeFn();
var delta = jsondiffpatch.diff(subscription.value, newValue);
if (!delta) return; // no delta, nothing to send!
subscription.value = newValue;
subscription.version = uuid();
self.clients.forEach(function(client) {
var clientSubscription = client.subscriptions[name];
if (clientSubscription === 'simple') {
client.sendMessage(name, newValue);
} else if (clientSubscription === 'delta') {
client.sendMessage(name, {
version: subscription.version,
delta: delta,
});
}
});
};
}
function getVolume(client) {
return self.player.volume;
}
function getTime(client) {
return new Date();
}
function getRepeat(client) {
return self.player.repeat;
}
function getCurrentTrack() {
return {
currentItemId: self.player.currentTrack && self.player.currentTrack.id,
isPlaying: self.player.isPlaying,
trackStartDate: self.player.trackStartDate,
pausedTime: self.player.pausedTime,
};
}
function getDynamicModeOn() {
return self.player.dynamicModeOn;
}
function getDynamicModeFutureSize() {
return self.player.dynamicModeFutureSize;
}
function getDynamicModeHistorySize() {
return self.player.dynamicModeHistorySize;
}
function serializePlaylist() {
var playlist = self.player.playlist;
var o = {};
for (var id in playlist) {
var item = playlist[id];
o[id] = {
key: item.key,
sortKey: item.sortKey,
isRandom: item.isRandom,
};
}
return o;
}
function serializeLibrary() {
var table = {};
for (var key in self.player.libraryIndex.trackTable) {
var track = self.player.libraryIndex.trackTable[key];
table[key] = Player.trackWithoutIndex('read', track);
}
return table;
}
};
PlayerServer.prototype.handleNewClient = function(client) {
var self = this;
client.subscriptions = {};
client.permissions = self.defaultPermissions;
client.on('message', onMessage);
client.sendMessage('permissions', client.permissions);
client.sendMessage('time', new Date());
client.on('close', onClose);
self.clients.push(client);
PlayerServer.plugins.forEach(function(plugin) {
plugin.handleNewClient(client);
});
function onClose() {
var index = self.clients.indexOf(client);
if (index >= 0) self.clients.splice(index, 1);
}
function onMessage(name, args) {
var action = PlayerServer.actions[name];
if (!action) {
console.warn("Invalid command:", name);
client.sendMessage("error", "invalid command: " + JSON.stringify(name));
return;
}
var perm = action.permission;
if (perm != null && !client.permissions[perm]) {
var errText = "command " + JSON.stringify(name) +
" requires permission " + JSON.stringify(perm);
console.warn("permissions error:", errText);
client.sendMessage("error", errText);
return;
}
var argsType = Array.isArray(args) ? 'array' : typeof args;
if (action.args && argsType !== action.args) {
console.warn("expected arg type", action.args, args);
client.sendMessage("error", "expected " + action.args + ": " + JSON.stringify(args));
return;
}
console.info("ok command", name, args);
action.fn(self, client, args);
}
};

View file

@ -1,237 +0,0 @@
var LastFmNode = require('lastfm').LastFmNode;
var PlayerServer = require('../player_server');
module.exports = LastFm;
var DB_KEY = 'Plugin.lastfm';
function LastFm(gb) {
this.gb = gb;
this.previousNowPlaying = null;
this.lastPlayingItem = null;
this.playingStart = new Date();
this.playingTime = 0;
this.previousIsPlaying = false;
this.scrobblers = {};
this.scrobbles = [];
this.lastFm = new LastFmNode({
api_key: this.gb.config.lastFmApiKey,
secret: this.gb.config.lastFmApiSecret,
});
this.gb.player.on('playlistUpdate', checkScrobble.bind(this));
this.gb.player.on('playlistUpdate', updateNowPlaying.bind(this));
this.initActions();
}
LastFm.prototype.initialize = function(cb) {
var self = this;
self.gb.db.get(DB_KEY, function(err, value) {
if (err) {
if (err.type !== 'NotFoundError') return cb(err);
} else {
var state = JSON.parse(value);
self.scrobblers = state.scrobblers;
self.scrobbles = state.scrobbles;
}
// in case scrobbling fails and then the user presses stop, this will still
// flush the queue.
setInterval(self.flushScrobbleQueue.bind(self), 120000);
cb();
});
};
LastFm.prototype.persist = function() {
var self = this;
var state = {
scrobblers: self.scrobblers,
scrobbles: self.scrobbles,
};
self.gb.db.put(DB_KEY, JSON.stringify(state), function(err) {
if (err) {
console.error("Unable to persist lastfm state to db:", err.stack);
}
});
}
LastFm.prototype.initActions = function() {
var self = this;
PlayerServer.plugins.push({
handleNewClient: function(client) {
client.sendMessage('LastFmApiKey', self.gb.config.lastFmApiKey);
},
});
PlayerServer.actions.LastFmGetSession = {
permission: 'read',
args: 'string',
fn: function(playerServer, client, token){
self.lastFm.request("auth.getSession", {
token: token,
handlers: {
success: function(data){
delete self.scrobblers[data.session.name];
client.sendMessage('LastFmGetSessionSuccess', data);
},
error: function(error){
console.error("error from last.fm auth.getSession:", error.message);
client.sendMessage('LastFmGetSessionError', error.message);
}
}
});
}
};
PlayerServer.actions.LastFmScrobblersAdd = {
permission: 'read',
args: 'object',
fn: function(playerServer, client, params) {
var existingUser = self.scrobblers[params.username];
if (existingUser) {
console.warn("Trying to overwrite a scrobbler:", params.username);
return;
}
self.scrobblers[params.username] = params.session_key;
self.persist();
},
};
PlayerServer.actions.LastFmScrobblersRemove = {
permission: 'read',
args: 'object',
fn: function(playerServer, client, params) {
var sessionKey = self.scrobblers[params.username];
if (sessionKey !== params.session_key) {
console.warn("Invalid session key from user trying to remove scrobbler:", params.username);
return;
}
delete self.scrobblers[params.username];
self.persist();
},
};
}
LastFm.prototype.flushScrobbleQueue = function() {
var self = this;
var params;
var maxSimultaneous = 10;
var count = 0;
while ((params = self.scrobbles.shift()) != null && count++ < maxSimultaneous) {
console.info("scrobbling " + params.track + " for session " + params.sk);
params.handlers = {
error: onError,
};
self.lastFm.request('track.scrobble', params);
}
self.persist();
function onError(error){
console.error("error from last.fm track.scrobble:", error.stack);
if (!error.code || error.code === 11 || error.code === 16) {
// try again
self.scrobbles.push(params);
self.persist();
}
}
}
LastFm.prototype.queueScrobble = function(params){
console.info("queueScrobble", params);
this.scrobbles.push(params);
this.persist();
};
function checkScrobble() {
var self = this;
if (self.gb.player.isPlaying && !self.previousIsPlaying) {
self.playingStart = new Date(new Date() - self.playingTime);
self.previousIsPlaying = true;
}
self.playingTime = new Date() - self.playingStart;
var thisItem = self.gb.player.currentTrack;
if (thisItem === self.lastPlayingItem) return;
if (self.lastPlayingItem) {
var dbFile = self.gb.player.libraryIndex.trackTable[self.lastPlayingItem.key];
var minAmt = 15 * 1000;
var maxAmt = 4 * 60 * 1000;
var halfAmt = dbFile.duration / 2 * 1000;
if (self.playingTime >= minAmt && (self.playingTime >= maxAmt || self.playingTime >= halfAmt)) {
if (dbFile.artistName) {
for (var username in self.scrobblers) {
var sessionKey = self.scrobblers[username];
self.queueScrobble({
sk: sessionKey,
chosenByUser: +!self.lastPlayingItem.isRandom,
timestamp: Math.round(self.playingStart.getTime() / 1000),
album: dbFile.albumName,
track: dbFile.name,
artist: dbFile.artistName,
albumArtist: dbFile.albumArtistName,
duration: Math.round(dbFile.duration),
trackNumber: dbFile.track,
});
}
self.flushScrobbleQueue();
} else {
console.warn("Not scrobbling " + dbFile.name + " - missing artist.");
}
} else {
console.info("not scrobbling", dbFile.name, " - only listened for", self.playingTime);
}
}
self.lastPlayingItem = thisItem;
self.previousIsPlaying = self.gb.player.isPlaying;
self.playingStart = new Date();
self.playingTime = 0;
}
function updateNowPlaying() {
var self = this;
if (!self.gb.player.isPlaying) return;
var track = self.gb.player.currentTrack;
if (!track) return;
if (self.previousNowPlaying === track) return;
self.previousNowPlaying = track;
var dbFile = self.gb.player.libraryIndex.trackTable[track.key];
if (!dbFile.artistName) {
console.warn("Not updating last.fm now playing for " + dbFile.name + ": missing artist");
return;
}
for (var username in self.scrobblers) {
var sessionKey = self.scrobblers[username];
var props = {
sk: sessionKey,
track: dbFile.name,
artist: dbFile.artistName,
album: dbFile.albumName,
albumArtist: dbFile.albumArtistName,
trackNumber: dbFile.track,
duration: Math.round(dbFile.duration),
handlers: {
error: onError
}
}
console.info("updateNowPlaying", props);
self.lastFm.request("track.updateNowPlaying", props);
}
function onError(error){
console.error("unable to update last.fm now playing:", error.message);
}
}

View file

@ -1,62 +0,0 @@
var ytdl = require('ytdl');
var url = require('url');
module.exports = YtDlPlugin;
// sorted from worst to best
var YTDL_AUDIO_ENCODINGS = [
'mp3',
'aac',
'wma',
'vorbis',
'wav',
'flac',
];
function YtDlPlugin(gb) {
gb.player.importUrlFilters.push(this);
}
YtDlPlugin.prototype.importUrl = function(urlString, cb) {
var parsedUrl = url.parse(urlString);
var isYouTube = (parsedUrl.pathname === '/watch' &&
(parsedUrl.hostname === 'youtube.com' ||
parsedUrl.hostname === 'www.youtube.com' ||
parsedUrl.hostname === 'm.youtube.com')) ||
parsedUrl.hostname === 'youtu.be' ||
parsedUrl.hostname === 'www.youtu.be';
if (!isYouTube) {
cb();
return;
}
var bestFormat = null;
ytdl.getInfo(urlString, gotYouTubeInfo);
function gotYouTubeInfo(err, info) {
if (err) return cb(err);
for (var i = 0; i < info.formats.length; i += 1) {
var format = info.formats[i];
if (bestFormat == null || format.audioBitrate > bestFormat.audioBitrate ||
(format.audioBitrate === bestFormat.audioBitrate &&
YTDL_AUDIO_ENCODINGS.indexOf(format.audioEncoding) >
YTDL_AUDIO_ENCODINGS.indexOf(bestFormat.audioEncoding)))
{
bestFormat = format;
}
}
if (YTDL_AUDIO_ENCODINGS.indexOf(bestFormat.audioEncoding) === -1) {
console.warn("YouTube Import: unrecognized audio format:", bestFormat.audioEncoding);
}
var req = ytdl(urlString, {filter: filter});
var filename = info.title + '.' + bestFormat.container;
cb(null, req, filename);
function filter(format) {
return format.audioBitrate === bestFormat.audioBitrate &&
format.audioEncoding === bestFormat.audioEncoding;
}
}
};

View file

@ -1,65 +0,0 @@
var Duplex = require('stream').Duplex;
var util = require('util');
module.exports = ProtocolParser;
util.inherits(ProtocolParser, Duplex);
function ProtocolParser(options) {
var streamOptions = extend(extend({}, options.streamOptions || {}), {decodeStrings: false});
Duplex.call(this, streamOptions);
this.player = options.player;
this.buffer = "";
this.alreadyClosed = false;
}
ProtocolParser.prototype._read = function(size) {};
ProtocolParser.prototype._write = function(chunk, encoding, callback) {
var self = this;
var lines = chunk.split("\n");
self.buffer += lines[0];
if (lines.length === 1) return callback();
handleLine(self.buffer);
var lastIndex = lines.length - 1;
for (var i = 1; i < lastIndex; i += 1) {
handleLine(lines[i]);
}
self.buffer = lines[lastIndex];
callback();
function handleLine(line) {
var jsonObject;
try {
jsonObject = JSON.parse(line);
} catch (err) {
console.warn("received invalid json:", err.message);
self.sendMessage("error", "invalid json: " + err.message);
return;
}
if (typeof jsonObject !== 'object') {
console.warn("received json not an object:", jsonObject);
self.sendMessage("error", "expected json object");
return;
}
self.emit('message', jsonObject.name, jsonObject.args);
}
};
ProtocolParser.prototype.sendMessage = function(name, args) {
if (this.alreadyClosed) return;
var jsonObject = {name: name, args: args};
this.push(JSON.stringify(jsonObject));
};
ProtocolParser.prototype.close = function() {
if (this.alreadyClosed) return;
this.push(null);
this.alreadyClosed = true;
};
function extend(o, src) {
for (var key in src) o[key] = src[key];
return o;
}

View file

@ -1,11 +0,0 @@
module.exports = safePath;
var MAX_LEN = 100;
function safePath(string) {
string = string.replace(/[<>:"\/\\|?*%]/g, "_");
string = string.substring(0, MAX_LEN);
string = string.replace(/\.$/, "_");
string = string.replace(/^\./, "_");
return string;
}

View file

@ -1,11 +0,0 @@
if (!process.env.NODE_ENV) process.env.NODE_ENV = "dev";
var GrooveBasin = require('./groovebasin');
var gb = new GrooveBasin();
gb.on('listening', function() {
if (process.send) process.send('online');
});
process.on('message', function(message){
if (message === 'shutdown') process.exit(0);
});
gb.start();

View file

@ -1,51 +0,0 @@
var EventEmitter = require('events').EventEmitter;
var util = require('util');
module.exports = WebSocketApiClient;
util.inherits(WebSocketApiClient, EventEmitter);
function WebSocketApiClient(ws) {
EventEmitter.call(this);
this.ws = ws;
this.initialize();
}
WebSocketApiClient.prototype.sendMessage = function(name, args) {
try {
this.ws.send(JSON.stringify({
name: name,
args: args,
}));
} catch (err) {
// nothing to do
// client might have disconnected by now
}
};
WebSocketApiClient.prototype.close = function() {
this.ws.close();
};
WebSocketApiClient.prototype.initialize = function() {
var self = this;
self.ws.on('message', function(data, flags) {
if (flags.binary) {
console.warn("ignoring binary web socket message");
return;
}
var msg;
try {
msg = JSON.parse(data);
} catch (err) {
console.warn("received invalid JSON from web socket:", err.message);
return;
}
self.emit('message', msg.name, msg.args);
});
self.ws.on('error', function(err) {
console.error("web socket error:", err.stack);
});
self.ws.on('close', function() {
self.emit('close');
});
};

1
mpd.js Submodule

@ -0,0 +1 @@
Subproject commit 00c016f4d801caefbf5db4a6d8c908ff3faae4d7

View file

@ -1,54 +1,47 @@
{ {
"name": "groovebasin", "name": "groovebasin",
"description": "Music player server with a web-based interface inspired by Amarok 1.4", "description": "No-nonsense music client and daemon based on mpd",
"author": "Andrew Kelley <superjoe30@gmail.com>", "author": "Andrew Kelley <superjoe30@gmail.com>",
"version": "1.0.1", "version": "0.2.1",
"licenses": [ "licenses": [
{ {
"type": "MIT", "type": "MIT",
"url": "https://raw.github.com/andrewrk/groovebasin/master/LICENSE" "url": "https://raw.github.com/superjoe30/groovebasin/master/LICENSE"
} }
], ],
"engines": { "engines": {
"node": ">=0.10.20" "node": "~0.8.2"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "git://github.com/andrewrk/groovebasin.git" "url": "git://github.com/superjoe30/groovebasin.git"
}, },
"dependencies": { "dependencies": {
"lastfm": "~0.9.0", "socket.io": "~0.9.10",
"express": "^4.0.0", "lastfm": "~0.8.1",
"superagent": "^0.17.0", "zipstream": "~0.2.1",
"mkdirp": "~0.3.5", "express": "~3.0.0rc5",
"mv": "~2.0.0", "temp": "~0.4.0",
"pend": "~1.1.1", "naught": "~0.2.2",
"zfill": "0.0.1", "async": "~0.1.22",
"requireindex": "^1.1.0", "superagent": "~0.9.5",
"mess": "~0.1.1", "mkdirp": "~0.3.4",
"groove": "~1.4.1", "mv": "~0.0.3",
"osenv": "0.0.3", "findit": "~0.1.2"
"level": "^0.18.0",
"findit": "~1.1.1",
"archiver": "^0.6.1",
"uuid": "~1.4.1",
"music-library-index": "^1.1.1",
"keese": "~1.0.0",
"ws": "^0.4.31",
"jsondiffpatch": "~0.1.4",
"connect-static": "^1.1.0",
"multiparty": "^3.2.4",
"ytdl": "^0.2.4",
"serve-static": "^1.0.3",
"body-parser": "^1.0.1"
}, },
"devDependencies": { "devDependencies": {
"stylus": "^0.42.3", "handlebars": "~1.0.7",
"browserify": "^3.41.0" "stylus": "~0.29.0",
"node-dev": "~0.2.8",
"jspackage": "~0.4.9",
"watch": "~0.5.1",
"coco": "~0.8.2"
}, },
"scripts": { "scripts": {
"start": "node lib/server.js", "start": "naught start lib/server.js",
"build": "npm install && ./build", "stop": "naught stop",
"dev": "npm run build && npm start" "reload": "naught deploy",
"build": "npm install && coke build",
"dev": "npm install && coke dev"
} }
} }

250
readme.md Normal file
View file

@ -0,0 +1,250 @@
# Groove Basin
No-nonsense music client and server for your home or office.
Run it on a server connected to your main speakers. Guests can connect with
their laptops, tablets, and phones, and play and share music.
Depends on [mpd](http://musicpd.org) version 0.17+ for the backend. Some might
call this project an mpd client. (Note, version 0.17 is only available from
source as of writing this; see below instructions regarding mpd installation.)
[Live demo](http://superjoe.zapto.org:16242/)
## Features
* Lightning-fast, responsive UI. You can hardly tell that the music server is
on another computer.
* Dynamic playlist mode which automatically queues random songs, favoring
songs that have not been played recently.
* Drag and drop upload. Drag and drop playlist editing. Rich keyboard
shortcuts.
* Streaming support. You can listen to your music library - or share it with
your friends - even when you are not physically near your home speakers.
* Last.fm scrobbling.
## Get Started
Make sure you have [Node](http://nodejs.org) >=0.8.0 installed and
[mpd](http://musicpd.org) version >=0.17.0 (see below) running, then:
```
$ npm install --production groovebasin
$ npm start groovebasin
```
At this point, Groove Basin will issue warnings telling you what to do next.
## Screenshots
![Search + drag/drop support](http://www.superjoesoftware.com/temp/groove-basin-0.0.4.png)
![Multi-select and context menu](http://www.superjoesoftware.com/temp/groove-basin-0.0.4-lib-menu.png)
![Keyboard shortcuts](http://www.superjoesoftware.com/temp/groove-basin-0.0.4-shortcuts.png)
![Last.fm Scrobbling](http://www.superjoesoftware.com/temp/groove-basin-0.0.4-lastfm.png)
## Mpd
Groove Basin depends on [mpd](http://musicpd.org) version 0.17+.
To compile from source, start here
```
$ git clone git://git.musicpd.org/master/mpd.git
```
and follow mpd's instructions from there.
### Configuration
* `default_permissions` - Recommended to remove `admin` so that anonymous
users can't do nefarious things.
* `password` - Recommended to add a password for yourself to give yourself `admin` permissions.
* `read` - allows reading the library, current playlist, and playback status.
* `add` - allows adding songs, loading playlists, and uploading songs.
* `control` - allows controlling playback state and manipulating playlists.
* `admin` - allows updating the db, killing mpd, deleting songs from the
library, and updating song tags.
* `audio_output` - Uncomment the "httpd" one and configure the port to enable
streaming. Recommended "vorbis" encoder for better browser support.
* `sticker_file` - Groove Basin will not run without one set.
* `gapless_mp3_playback` - "yes" recommended. <3 gapless playback.
* `volume_normalization` - "yes" recommended. Replaygain scanners are not
implemented for all the formats that can be played back. Volume normalization
works on all formats.
* `max_command_list_size` - "16384" recommended. You do not want mpd crashing
when you try to remove a ton of songs from the playlist at once.
* `auto_update` - "yes" recommended. Required for uploaded songs to show up
in your library.
## Configuring Groove Basin
Groove Basin is configured using environment variables. Available options
and their defaults:
HOST="0.0.0.0"
PORT="16242"
MPD_CONF="/etc/mpd.conf"
STATE_FILE=".state.json"
NODE_ENV="dev"
LASTFM_API_KEY=<not shown>
LASTFM_SECRET=<not shown>
## Developing
Install dependencies and run mpd as described in the Get Started section.
Clone the repository using `git clone --recursive` or if you have
already cloned, do `git submodule update --init --recursive`.
```
$ npm run dev
```
## Release Notes
### 0.1.2
* Andrew Kelley
* lock in the major versions of dependencies
* more warnings about mpd conf settings
* remove "alert" text on no connection
* better build system
* move dynamic mode configuration to server
* server handles permissions in mpd.conf correctly
* clients can set a password
* ability to delete from library
* use soundmanager2 instead of jplayer for streaming
* buffering status on stream button
* stream button has a paused state
* use .npmignore to only deploy generated files
* update to work with node 0.8.2
* Josh Wolfe:
* pointing at mpd's own repository in readme. #12
* fixing null pointer error for when streaming is disabled
* fixing blank search on library update
* fixing username on reconnect
* backend support for configurable dynamic history and future sizes
* ui for configuring dynamic mode history and future sizes
* coloring yourself different in chat
* scrubbing stale user ids in my_user_ids
* better chat name setting ui
* scrolling chat window properly
* moar chat history
* formatting the state file
* fixing chat window resize on join/left
* validation on dynamic mode settings
* clearer wording in Get Started section and louder mpd version dependency
documentation
### 0.0.6
* Josh Wolfe:
* fixing not queuing before random when pressing enter in the search box
* fixing streaming hotkey not updating button ui
* stopping and starting streaming in sync with mpd.status.state.
* fixing weird bug with Stream button checked state
* warning when bind_to_address is not also configured for localhost
* fixing derpy log reference
* fixing negative trackNumber scrobbling
* directory urls download .zip files. #9
* document dependency on mpd version 0.17
* Andrew Kelley:
* fix regression: not queuing before random songs client side
* uploaded songs are queued in the correct place
* support restarting mpd without restarting daemon
* ability to reconnect without refreshing
* log.info instead of console.info for track uploaded msg
* avoid the use of 'static' keyword
* David Banham:
* Make jPlayer aware of which stream format is set
* Removed extra constructor. Changed tabs to 2spaces
### 0.0.5
* Note: Requires you to pull from latest mpd git code and recompile.
* Andrew Kelley:
* disable volume slider when mpd reports volume as -1. fixes #8
* on last.fm callback, do minimal work then refresh. fixes #7
* warnings output the actual mpd.conf path instead of "mpd conf". see #5
* resize things *after* rendering things. fixes #6
* put uploaded files in an intelligent place, and fix #2
* ability to retain server state file even when structure changes
* downgrade user permissions ASAP
* label playlist items upon status update
* use blank user_id to avoid error message
* use jplayer for streaming
* Josh Wolfe:
* do not show ugly "user_n" text after usernames in chat.
### 0.0.4
* Andrew Kelley:
* update keyboard shortcuts dialog
* fix enter not queuing library songs in firefox
* ability to authenticate with last.fm, last.fm scrobbling
* last.fm scrobbling works
* fix issues with empty playlist. fixes #4
* fix bug with dynamic mode when playlist is clear
* Josh Wolfe:
* easter eggs
* daemon uses a state file
### 0.0.3
* Andrew Kelley:
* ability to select artists, albums, tracks in library
* prevents sticker race conditions from crashing the server (#3)
* escape clears the selection cursor too
* ability to shift+click select in library
* right-click queuing in library works
* do not show download menu option since it is not supported yet
* show selection on expanded elements
* download button works for single tracks in right click library menu
* library up/down to change selection
* nextLibPos/prevLibPos respects whether tree items are expanded or collapse
* library window scrolls down when you press up/down to move selection
* double click artists and albums in library to queue
* left/right expands/collapses library tree when lib has selection
* handle enter in playlist and library
* ability to drag artists, albums, tracks to playlist
* Josh Wolfe:
* implement chat room
* users can set their name in the chat room
* users can change their name multiple times
* storing username persistently. disambiguating conflicting usernames.
* loading recent chat history on connect
* normalizing usernames and sanitizing username display
* canot send blank chats
* supporting /nick renames in chat box
* hotkey to focus chat box
### 0.0.2
* Andrew Kelley:
* learn mpd host and port in mpd conf
* render unknown albums and unknown artists the same in the playlist (blank)
* auto-scroll playlist window and library window appropriately
* fix server crash when no top-level files exist
* fix some songs error message when uploading
* edit file uploader spinny gif to fit the theme
* move chat stuff to another tab
* Josh Wolfe:
* tracking who is online

1805
src/client/app.co Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,542 +0,0 @@
var EventEmitter = require('events').EventEmitter;
var util = require('util');
var uuid = require('uuid');
var MusicLibraryIndex = require('music-library-index');
var keese = require('keese');
var jsondiffpatch = require('jsondiffpatch');
module.exports = PlayerClient;
var compareSortKeyAndId = makeCompareProps(['sortKey', 'id']);
PlayerClient.REPEAT_OFF = 0;
PlayerClient.REPEAT_ONE = 1;
PlayerClient.REPEAT_ALL = 2;
util.inherits(PlayerClient, EventEmitter);
function PlayerClient(socket) {
EventEmitter.call(this);
window.__debug_PlayerClient = this;
var self = this;
self.socket = socket;
self.serverTimeOffset = 0;
self.serverTrackStartDate = null;
self.playlistFromServer = undefined;
self.playlistFromServerVersion = null;
self.libraryFromServer = undefined;
self.libraryFromServerVersion = null;
self.resetServerState();
self.socket.on('disconnect', function() {
self.resetServerState();
});
if (self.socket.isConnected) {
self.handleConnectionStart();
} else {
self.socket.on('connect', self.handleConnectionStart.bind(self));
}
self.socket.on('time', function(o) {
self.serverTimeOffset = new Date(o) - new Date();
self.updateTrackStartDate();
self.emit('statusupdate');
});
self.socket.on('volume', function(volume) {
self.volume = volume;
self.emit('statusupdate');
});
self.socket.on('repeat', function(repeat) {
self.repeat = repeat;
self.emit('statusupdate');
});
self.socket.on('currentTrack', function(o) {
self.isPlaying = o.isPlaying;
self.serverTrackStartDate = o.trackStartDate && new Date(o.trackStartDate);
self.pausedTime = o.pausedTime;
self.currentItemId = o.currentItemId;
self.updateTrackStartDate();
self.updateCurrentItem();
self.emit('statusupdate');
self.emit('currentTrack');
});
self.socket.on('playlist', function(o) {
if (o.reset) self.playlistFromServer = undefined;
self.playlistFromServer = jsondiffpatch.patch(self.playlistFromServer, o.delta);
deleteUndefineds(self.playlistFromServer);
self.playlistFromServerVersion = o.version;
self.updatePlaylistIndex();
self.emit('statusupdate');
self.emit('playlistupdate');
});
self.socket.on('library', function(o) {
if (o.reset) self.libraryFromServer = undefined;
self.libraryFromServer = jsondiffpatch.patch(self.libraryFromServer, o.delta);
deleteUndefineds(self.libraryFromServer);
self.libraryFromServerVersion = o.version;
self.library.clear();
for (var key in self.libraryFromServer) {
var track = self.libraryFromServer[key];
self.library.addTrack(track);
}
self.library.rebuild();
self.updatePlaylistIndex();
self.haveFileListCache = true;
var lastQuery = self.lastQuery;
self.lastQuery = null;
self.search(lastQuery);
});
function deleteUndefineds(o) {
for (var key in o) {
if (o[key] === undefined) delete o[key];
}
}
}
PlayerClient.prototype.handleConnectionStart = function(){
this.sendCommand('subscribe', { name: 'library', delta: true, });
this.sendCommand('subscribe', {name: 'volume'});
this.sendCommand('subscribe', {name: 'repeat'});
this.sendCommand('subscribe', {name: 'currentTrack'});
this.sendCommand('subscribe', {
name: 'playlist',
delta: true,
version: this.playlistFromServerVersion,
});
};
PlayerClient.prototype.updateTrackStartDate = function() {
this.trackStartDate = (this.serverTrackStartDate != null) ?
new Date(new Date(this.serverTrackStartDate) - this.serverTimeOffset) : null;
};
PlayerClient.prototype.updateCurrentItem = function() {
this.currentItem = (this.currentItemId != null) ?
this.playlist.itemTable[this.currentItemId] : null;
};
PlayerClient.prototype.updatePlaylistIndex = function() {
this.clearPlaylist();
if (!this.playlistFromServer) return;
for (var id in this.playlistFromServer) {
var item = this.playlistFromServer[id];
var track = this.library.trackTable[item.key];
this.playlist.itemTable[id] = {
id: id,
sortKey: item.sortKey,
isRandom: item.isRandom,
track: track,
playlist: this.playlist,
};
}
this.refreshPlaylistList();
this.updateCurrentItem();
};
PlayerClient.prototype.search = function(query) {
query = query.trim();
var words = query.split(/\s+/);
query = words.join(" ");
if (query === this.lastQuery) return;
this.lastQuery = query;
this.searchResults = this.library.search(query);
this.emit('libraryupdate');
this.emit('playlistupdate');
this.emit('statusupdate');
};
PlayerClient.prototype.getDefaultQueuePosition = function() {
var previousKey = this.currentItem && this.currentItem.sortKey;
var nextKey = null;
var startPos = this.currentItem ? this.currentItem.index + 1 : 0;
for (var i = startPos; i < this.playlist.itemList.length; i += 1) {
var track = this.playlist.itemList[i];
var sortKey = track.sortKey;
if (track.isRandom) {
nextKey = sortKey;
break;
}
previousKey = sortKey;
}
return {
previousKey: previousKey,
nextKey: nextKey
};
};
PlayerClient.prototype.queueTracks = function(keys, previousKey, nextKey) {
if (!keys.length) return;
if (previousKey == null && nextKey == null) {
var defaultPos = this.getDefaultQueuePosition();
previousKey = defaultPos.previousKey;
nextKey = defaultPos.nextKey;
}
var items = {};
for (var i = 0; i < keys.length; i += 1) {
var key = keys[i];
var sortKey = keese(previousKey, nextKey);
var id = uuid();
items[id] = {
key: key,
sortKey: sortKey,
};
this.playlist.itemTable[id] = {
id: id,
key: key,
sortKey: sortKey,
isRandom: false,
track: this.library.trackTable[key],
};
previousKey = sortKey;
}
this.refreshPlaylistList();
this.sendCommand('addid', items);
this.emit('playlistupdate');
};
PlayerClient.prototype.queueTracksNext = function(keys) {
var prevKey = this.currentItem && this.currentItem.sortKey;
var nextKey = null;
var itemList = this.playlist.itemList;
for (var i = 0; i < itemList.length; ++i) {
var track = itemList[i];
if (prevKey == null || track.sortKey > prevKey) {
if (nextKey == null || track.sortKey < nextKey) {
nextKey = track.sortKey;
}
}
}
this.queueTracks(keys, prevKey, nextKey);
};
PlayerClient.prototype.clear = function(){
this.sendCommand('clear');
this.clearPlaylist();
this.emit('playlistupdate');
};
PlayerClient.prototype.shuffle = function(){
this.sendCommand('shuffle');
};
PlayerClient.prototype.play = function(){
this.sendCommand('play');
if (this.isPlaying === false) {
this.trackStartDate = elapsedToDate(this.pausedTime);
this.isPlaying = true;
this.emit('statusupdate');
}
};
PlayerClient.prototype.stop = function(){
this.sendCommand('stop');
if (this.isPlaying === true) {
this.pausedTime = 0;
this.isPlaying = false;
this.emit('statusupdate');
}
};
PlayerClient.prototype.pause = function(){
this.sendCommand('pause');
if (this.isPlaying === true) {
this.pausedTime = dateToElapsed(this.trackStartDate);
this.isPlaying = false;
this.emit('statusupdate');
}
};
PlayerClient.prototype.next = function(){
var index = this.currentItem ? this.currentItem.index + 1 : 0;
// handle the case of Repeat All
if (index >= this.playlist.itemList.length &&
this.repeat === PlayerClient.REPEAT_ALL)
{
index = 0;
}
var item = this.playlist.itemList[index];
var id = item && item.id;
this.seek(id, 0);
};
PlayerClient.prototype.prev = function(){
var index = this.currentItem ? this.currentItem.index - 1 : this.playlist.itemList.length - 1;
// handle case of Repeat All
if (index < 0 && this.repeat === PlayerClient.REPEAT_ALL) {
index = this.playlist.itemList.length - 1;
}
var item = this.playlist.itemList[index];
var id = item && item.id;
this.seek(id, 0);
};
PlayerClient.prototype.moveIds = function(trackIds, previousKey, nextKey){
var track, i;
var tracks = [];
for (i = 0; i < trackIds.length; i += 1) {
var id = trackIds[i];
track = this.playlist.itemTable[id];
if (track) tracks.push(track);
}
tracks.sort(compareSortKeyAndId);
var items = {};
for (i = 0; i < tracks.length; i += 1) {
track = tracks[i];
var sortKey = keese(previousKey, nextKey);
items[track.id] = {
sortKey: sortKey,
};
track.sortKey = sortKey;
previousKey = sortKey;
}
this.refreshPlaylistList();
this.sendCommand('move', items);
this.emit('playlistupdate');
};
PlayerClient.prototype.shiftIds = function(trackIdSet, offset) {
// an example of shifting 5 items (a,c,f,g,i) "down":
// offset: +1, reverse: false, this -> way
// selection: * * * * *
// before: a, b, c, d, e, f, g, h, i
// \ \ \ \ |
// \ \ \ \ |
// after: b, a, d, c, e, h, f, g, i
// selection: * * * * *
// (note that "i" does not move because it has no futher to go.)
//
// an alternate way to think about it: some items "leapfrog" backwards over the selected items.
// this ends up being much simpler to compute, and even more compact to communicate.
// selection: * * * * *
// before: a, b, c, d, e, f, g, h, i
// / / ___/
// / / /
// after: b, a, d, c, e, h, f, g, i
// selection: * * * * *
// (note that the moved items are not the selected items)
var itemList = this.playlist.itemList;
var movedItems = {};
var reverse = offset === -1;
function getKeeseBetween(itemA, itemB) {
if (reverse) {
var tmp = itemA;
itemA = itemB;
itemB = tmp;
}
var keyA = itemA == null ? null : itemA.sortKey;
var keyB = itemB == null ? null : itemB.sortKey;
return keese(keyA, keyB);
}
if (reverse) {
// to make this easier, just reverse the item list in place so we can write one iteration routine.
// note that we are editing our data model live! so don't forget to refresh it later.
itemList.reverse();
}
for (var i = itemList.length - 1; i >= 1; i--) {
var track = itemList[i];
if (!(track.id in trackIdSet) && (itemList[i - 1].id in trackIdSet)) {
// this one needs to move backwards (e.g. found "h" is not selected, and "g" is selected)
i--; // e.g. g
i--; // e.g. f
while (true) {
if (i < 0) {
// fell off the end (or beginning) of the list
track.sortKey = getKeeseBetween(null, itemList[0]);
break;
}
if (!(itemList[i].id in trackIdSet)) {
// this is where it goes (e.g. found "d" is not selected)
track.sortKey = getKeeseBetween(itemList[i], itemList[i + 1]);
break;
}
i--;
}
movedItems[track.id] = {sortKey: track.sortKey};
i++;
}
}
// we may have reversed the table and adjusted all the sort keys, so we need to refresh this.
this.refreshPlaylistList();
this.sendCommand('move', movedItems);
this.emit('playlistupdate');
};
PlayerClient.prototype.removeIds = function(trackIds){
if (trackIds.length === 0) return;
var ids = [];
for (var i = 0; i < trackIds.length; i += 1) {
var trackId = trackIds[i];
var currentId = this.currentItem && this.currentItem.id;
if (currentId === trackId) {
this.currentItemId = null;
this.currentItem = null;
}
ids.push(trackId);
var item = this.playlist.itemTable[trackId];
delete this.playlist.itemTable[item.id];
this.refreshPlaylistList();
}
this.sendCommand('deleteid', ids);
this.emit('playlistupdate');
};
PlayerClient.prototype.seek = function(id, pos) {
pos = parseFloat(pos || 0, 10);
var item = id ? this.playlist.itemTable[id] : this.currentItem;
if (pos < 0) pos = 0;
if (pos > item.duration) pos = item.duration;
this.sendCommand('seek', {
id: item.id,
pos: pos,
});
this.currentItem = item;
this.currentItemId = item.id;
this.isPlaying = true;
this.duration = item.track.duration;
this.trackStartDate = elapsedToDate(pos);
this.emit('statusupdate');
};
PlayerClient.prototype.setVolume = function(vol){
if (vol > 1.0) vol = 1.0;
if (vol < 0.0) vol = 0.0;
this.volume = vol;
this.sendCommand('setvol', this.volume);
this.emit('statusupdate');
};
PlayerClient.prototype.setRepeatMode = function(mode) {
this.repeat = mode;
this.sendCommand('repeat', mode);
this.emit('statusupdate');
};
PlayerClient.prototype.sendCommand = function(name, args) {
this.socket.send(name, args);
};
PlayerClient.prototype.clearPlaylist = function(){
this.playlist = {
itemList: [],
itemTable: {},
index: null,
name: null
};
};
PlayerClient.prototype.anticipatePlayId = function(trackId){
var item = this.playlist.itemTable[trackId];
this.currentItem = item;
this.currentItemId = item.id;
this.isPlaying = true;
this.duration = item.track.duration;
this.trackStartDate = new Date();
this.emit('statusupdate');
};
PlayerClient.prototype.anticipateSkip = function(direction) {
if (this.currentItem) {
var nextItem = this.playlist.itemList[this.currentItem.index + direction];
if (nextItem) this.anticipatePlayId(nextItem.id);
}
};
PlayerClient.prototype.refreshPlaylistList = function(){
this.playlist.itemList = [];
var item;
for (var id in this.playlist.itemTable) {
item = this.playlist.itemTable[id];
item.playlist = this.playlist;
this.playlist.itemList.push(item);
}
this.playlist.itemList.sort(compareSortKeyAndId);
for (var i = 0; i < this.playlist.itemList.length; i += 1) {
item = this.playlist.itemList[i];
item.index = i;
}
};
// sort keys according to how they appear in the library
PlayerClient.prototype.sortKeys = function(keys) {
var realLib = this.library;
var lib = new MusicLibraryIndex();
keys.forEach(function(key) {
var track = realLib.trackTable[key];
if (track) lib.addTrack(track);
});
lib.rebuild();
var results = [];
lib.artistList.forEach(function(artist) {
artist.albumList.forEach(function(album) {
album.trackList.forEach(function(track) {
results.push(track.key);
});
});
});
return results;
};
PlayerClient.prototype.resetServerState = function(){
this.haveFileListCache = false;
this.library = new MusicLibraryIndex({
searchFields: MusicLibraryIndex.defaultSearchFields.concat('file'),
});
this.searchResults = this.library;
this.lastQuery = "";
this.clearPlaylist();
this.repeat = 0;
this.currentItem = null;
this.currentItemId = null;
this.stored_playlist_table = {};
this.stored_playlist_item_table = {};
this.stored_playlists = [];
};
function elapsedToDate(elapsed){
return new Date(new Date() - elapsed * 1000);
}
function dateToElapsed(date){
return (new Date() - date) / 1000;
}
function noop(err){
if (err) throw err;
}
function operatorCompare(a, b){
if (a === b) {
return 0;
}
if (a < b) {
return -1;
} else {
return 1;
}
}
function makeCompareProps(props){
return function(a, b) {
for (var i = 0; i < props.length; i += 1) {
var prop = props[i];
var result = operatorCompare(a[prop], b[prop]);
if (result) return result;
}
return 0;
};
}

View file

@ -1,55 +0,0 @@
var EventEmitter = require('events').EventEmitter;
var util = require('util');
module.exports = Socket;
util.inherits(Socket, EventEmitter);
function Socket() {
var self = this;
EventEmitter.call(self);
self.isConnected = false;
createWs();
function createWs() {
var host = window.document.location.host;
var pathname = window.document.location.pathname;
var match = host.match(/^(.+):(\d+)$/);
var port = match ? parseInt(match[2], 10) : 80;
var hostName = match ? match[1] : host;
var wsUrl = 'ws://' + hostName + ':' + port + pathname;
self.ws = new WebSocket(wsUrl);
self.ws.addEventListener('message', onMessage, false);
self.ws.addEventListener('error', timeoutThenCreateNew, false);
self.ws.addEventListener('close', timeoutThenCreateNew, false);
self.ws.addEventListener('open', onOpen, false);
function onOpen() {
self.isConnected = true;
self.emit('connect');
}
function onMessage(event) {
var msg = JSON.parse(event.data);
self.emit(msg.name, msg.args);
}
function timeoutThenCreateNew() {
self.ws.removeEventListener('error', timeoutThenCreateNew, false);
self.ws.removeEventListener('close', timeoutThenCreateNew, false);
self.ws.removeEventListener('open', onOpen, false);
if (self.isConnected) {
self.isConnected = false;
self.emit('disconnect');
}
setTimeout(createWs, 1000);
}
}
}
Socket.prototype.send = function(name, args) {
this.ws.send(JSON.stringify({
name: name,
args: args,
}));
}

View file

@ -1,101 +0,0 @@
exports.getUrl = getUrl;
exports.toggleStatus = toggleStatus;
exports.init = init;
var tryingToStream = false;
var actuallyStreaming = false;
var stillBuffering = false;
var player = null;
var audio = new Audio();
audio.addEventListener('playing', onPlaying, false);
var $ = window.$;
var $streamBtn = $('#stream-btn');
document.getElementById('stream-btn-label').addEventListener('mousedown', onLabelDown, false);
function onLabelDown(event) {
event.stopPropagation();
}
function getButtonLabel() {
if (tryingToStream) {
if (actuallyStreaming) {
if (stillBuffering) {
return "Stream: Buffering";
} else {
return "Stream: On";
}
} else {
return "Stream: Paused";
}
} else {
return "Stream: Off";
}
}
function renderStreamButton(){
var label = getButtonLabel();
$streamBtn
.button("option", "label", label)
.prop("checked", tryingToStream)
.button("refresh");
}
function toggleStatus() {
tryingToStream = !tryingToStream;
renderStreamButton();
updatePlayer();
return false;
}
function getUrl(){
return "/stream.mp3";
}
function onPlaying() {
stillBuffering = false;
renderStreamButton();
}
function clearBuffer() {
if (tryingToStream) {
tryingToStream = !tryingToStream;
updatePlayer();
tryingToStream = !tryingToStream;
updatePlayer();
}
}
function updatePlayer() {
var shouldStream = tryingToStream && player.isPlaying === true;
if (actuallyStreaming === shouldStream) return;
if (shouldStream) {
audio.src = getUrl();
audio.load();
audio.play();
stillBuffering = true;
} else {
audio.pause();
stillBuffering = false;
}
actuallyStreaming = shouldStream;
renderStreamButton();
}
function setUpUi() {
$streamBtn.button({
icons: {
primary: "ui-icon-signal-diag"
}
});
$streamBtn.on('click', toggleStatus);
}
function init(playerInstance, socket) {
player = playerInstance;
player.on('currentTrack', updatePlayer);
socket.on('seek', clearBuffer);
setUpUi();
}

View file

@ -1,5 +1,6 @@
@import "vendor/reset.min.css" @import "vendor/reset.min.css"
@import "vendor/jquery-ui-1.10.4.custom.min.css" @import "vendor/jquery-ui-1.8.24.custom.css"
@import "vendor/fileuploader.css"
user-select() user-select()
-moz-user-select arguments -moz-user-select arguments
@ -45,7 +46,7 @@ body
list-style none outside none list-style none outside none
margin 2px margin 2px
padding 4px padding 4px
h1 h1
letter-spacing 0.1em letter-spacing 0.1em
margin-top 8px margin-top 8px
@ -138,11 +139,16 @@ body
div.cursor div.cursor
text-decoration underline text-decoration underline
#lib-filter #lib-filter
margin 4px margin 4px
width 175px width 175px
span.chat-user
color #8888ff
span.chat-user-self
color #888888
#user-id #user-id
cursor pointer cursor pointer
@ -214,7 +220,7 @@ body
div.selected div.selected
selected-div() selected-div()
div.current div.current
border 1px solid #096AC8 border 1px solid #096AC8
background-color #292929 background-color #292929
@ -222,8 +228,7 @@ body
color #75abff color #75abff
div.cursor div.cursor
span text-decoration underline
text-decoration underline
div.border-bottom div.border-bottom
border-bottom 2px dashed #ffffff border-bottom 2px dashed #ffffff
@ -244,21 +249,17 @@ body
padding 10px padding 10px
#upload-by-url #upload-by-url
margin: 4px
width: 90% width: 90%
.ui-menu #menu
width: 240px position absolute
font-size: 1em padding 2px
li
#menu-library .ui-state-disabled.ui-state-focus, a
#menu-playlist .ui-state-disabled.ui-state-focus display block
margin: .3em -1px .2em text-decoration none
padding 6px
#menu-library .menu-item-last.ui-state-disabled.ui-state-focus, font-weight normal
#menu-playlist .menu-item-last.ui-state-disabled.ui-state-focus
margin: 5px -1px .2em
height: 23px
#shortcuts #shortcuts
h1 h1
@ -315,6 +316,3 @@ body
font-size .9em font-size .9em
li:before li:before
content "\2713" content "\2713"
.accesskey
text-decoration: underline

View file

@ -0,0 +1,31 @@
.qq-uploader { position:relative; width: 100%;}
.qq-upload-button {
display:block; /* or inline-block */
width: 105px; padding: 7px 0; text-align:center;
background:#880000; border-bottom:1px solid #ddd;color:#fff;
}
.qq-upload-button-hover {background:#cc0000;}
.qq-upload-button-focus {outline:1px dotted black;}
.qq-upload-drop-area {
position:absolute; top:0; left:0; width:100%; height:100%; min-height: 70px; z-index:2;
background:#FF9797; text-align:center;
}
.qq-upload-drop-area span {
display:block; position:absolute; top: 50%; width:100%; margin-top:-8px; font-size:16px;
}
.qq-upload-drop-area-active {background:#FF7171;}
.qq-upload-list {margin:15px 35px; padding:0; list-style:disc;}
.qq-upload-list li { margin:0; padding:0; line-height:15px; font-size:12px;}
.qq-upload-file, .qq-upload-spinner, .qq-upload-size, .qq-upload-cancel, .qq-upload-failed-text {
margin-right: 7px;
}
.qq-upload-file {}
.qq-upload-spinner {display:inline-block; background: url("loading.gif"); width:15px; height:15px; vertical-align:text-bottom;}
.qq-upload-size,.qq-upload-cancel {font-size:11px;}
.qq-upload-failed-text {display:none;}
.qq-upload-fail .qq-upload-failed-text {display:inline;}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,534 @@
/*!
* jQuery UI CSS Framework 1.8.24
*
* Copyright 2012, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Theming/API
*/
/* Layout helpers
----------------------------------*/
.ui-helper-hidden { display: none; }
.ui-helper-hidden-accessible { position: absolute !important; clip: rect(1px 1px 1px 1px); clip: rect(1px,1px,1px,1px); }
.ui-helper-reset { margin: 0; padding: 0; border: 0; outline: 0; line-height: 1.3; text-decoration: none; font-size: 100%; list-style: none; }
.ui-helper-clearfix:before, .ui-helper-clearfix:after { content: ""; display: table; }
.ui-helper-clearfix:after { clear: both; }
.ui-helper-clearfix { zoom: 1; }
.ui-helper-zfix { width: 100%; height: 100%; top: 0; left: 0; position: absolute; opacity: 0; filter:Alpha(Opacity=0); }
/* Interaction Cues
----------------------------------*/
.ui-state-disabled { cursor: default !important; }
/* Icons
----------------------------------*/
/* states and images */
.ui-icon { display: block; text-indent: -99999px; overflow: hidden; background-repeat: no-repeat; }
/* Misc visuals
----------------------------------*/
/* Overlays */
.ui-widget-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
/*!
* jQuery UI CSS Framework 1.8.24
*
* Copyright 2012, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Theming/API
*
* To view and modify this theme, visit http://jqueryui.com/themeroller/?ffDefault=Arial,%20sans-serif&fwDefault=bold&fsDefault=1.3em&cornerRadius=4px&bgColorHeader=0b3e6f&bgTextureHeader=08_diagonals_thick.png&bgImgOpacityHeader=15&borderColorHeader=0b3e6f&fcHeader=f6f6f6&iconColorHeader=98d2fb&bgColorContent=111111&bgTextureContent=12_gloss_wave.png&bgImgOpacityContent=20&borderColorContent=000000&fcContent=d9d9d9&iconColorContent=9ccdfc&bgColorDefault=333333&bgTextureDefault=09_dots_small.png&bgImgOpacityDefault=20&borderColorDefault=333333&fcDefault=ffffff&iconColorDefault=9ccdfc&bgColorHover=00498f&bgTextureHover=09_dots_small.png&bgImgOpacityHover=40&borderColorHover=222222&fcHover=ffffff&iconColorHover=ffffff&bgColorActive=292929&bgTextureActive=01_flat.png&bgImgOpacityActive=40&borderColorActive=096ac8&fcActive=75abff&iconColorActive=00498f&bgColorHighlight=0b58a2&bgTextureHighlight=10_dots_medium.png&bgImgOpacityHighlight=30&borderColorHighlight=052f57&fcHighlight=ffffff&iconColorHighlight=ffffff&bgColorError=a32d00&bgTextureError=09_dots_small.png&bgImgOpacityError=30&borderColorError=cd0a0a&fcError=ffffff&iconColorError=ffffff&bgColorOverlay=aaaaaa&bgTextureOverlay=01_flat.png&bgImgOpacityOverlay=0&opacityOverlay=30&bgColorShadow=aaaaaa&bgTextureShadow=01_flat.png&bgImgOpacityShadow=0&opacityShadow=30&thicknessShadow=8px&offsetTopShadow=-8px&offsetLeftShadow=-8px&cornerRadiusShadow=8px
*/
/* Component containers
----------------------------------*/
.ui-widget { font-family: Arial, sans-serif; font-size: 1.3em; }
.ui-widget .ui-widget { font-size: 1em; }
.ui-widget input, .ui-widget select, .ui-widget textarea, .ui-widget button { font-family: Arial, sans-serif; font-size: 1em; }
.ui-widget-content { border: 1px solid #000000; background: #111111 url(images/ui-bg_gloss-wave_20_111111_500x100.png) 50% top repeat-x; color: #d9d9d9; }
.ui-widget-content a { color: #d9d9d9; }
.ui-widget-header { border: 1px solid #0b3e6f; background: #0b3e6f url(images/ui-bg_diagonals-thick_15_0b3e6f_40x40.png) 50% 50% repeat; color: #f6f6f6; font-weight: bold; }
.ui-widget-header a { color: #f6f6f6; }
/* Interaction states
----------------------------------*/
.ui-state-default, .ui-widget-content .ui-state-default, .ui-widget-header .ui-state-default { border: 1px solid #333333; background: #333333 url(images/ui-bg_dots-small_20_333333_2x2.png) 50% 50% repeat; font-weight: bold; color: #ffffff; }
.ui-state-default a, .ui-state-default a:link, .ui-state-default a:visited { color: #ffffff; text-decoration: none; }
.ui-state-hover, .ui-widget-content .ui-state-hover, .ui-widget-header .ui-state-hover, .ui-state-focus, .ui-widget-content .ui-state-focus, .ui-widget-header .ui-state-focus { border: 1px solid #222222; background: #00498f url(images/ui-bg_dots-small_40_00498f_2x2.png) 50% 50% repeat; font-weight: bold; color: #ffffff; }
.ui-state-hover a, .ui-state-hover a:hover { color: #ffffff; text-decoration: none; }
.ui-state-active, .ui-widget-content .ui-state-active, .ui-widget-header .ui-state-active { border: 1px solid #096ac8; background: #292929 url(images/ui-bg_flat_40_292929_40x100.png) 50% 50% repeat-x; font-weight: bold; color: #75abff; }
.ui-state-active a, .ui-state-active a:link, .ui-state-active a:visited { color: #75abff; text-decoration: none; }
.ui-widget :active { outline: none; }
/* Interaction Cues
----------------------------------*/
.ui-state-highlight, .ui-widget-content .ui-state-highlight, .ui-widget-header .ui-state-highlight {border: 1px solid #052f57; background: #0b58a2 url(images/ui-bg_dots-medium_30_0b58a2_4x4.png) 50% 50% repeat; color: #ffffff; }
.ui-state-highlight a, .ui-widget-content .ui-state-highlight a,.ui-widget-header .ui-state-highlight a { color: #ffffff; }
.ui-state-error, .ui-widget-content .ui-state-error, .ui-widget-header .ui-state-error {border: 1px solid #cd0a0a; background: #a32d00 url(images/ui-bg_dots-small_30_a32d00_2x2.png) 50% 50% repeat; color: #ffffff; }
.ui-state-error a, .ui-widget-content .ui-state-error a, .ui-widget-header .ui-state-error a { color: #ffffff; }
.ui-state-error-text, .ui-widget-content .ui-state-error-text, .ui-widget-header .ui-state-error-text { color: #ffffff; }
.ui-priority-primary, .ui-widget-content .ui-priority-primary, .ui-widget-header .ui-priority-primary { font-weight: bold; }
.ui-priority-secondary, .ui-widget-content .ui-priority-secondary, .ui-widget-header .ui-priority-secondary { opacity: .7; filter:Alpha(Opacity=70); font-weight: normal; }
.ui-state-disabled, .ui-widget-content .ui-state-disabled, .ui-widget-header .ui-state-disabled { opacity: .35; filter:Alpha(Opacity=35); background-image: none; }
/* Icons
----------------------------------*/
/* states and images */
.ui-icon { width: 16px; height: 16px; background-image: url(images/ui-icons_9ccdfc_256x240.png); }
.ui-widget-content .ui-icon {background-image: url(images/ui-icons_9ccdfc_256x240.png); }
.ui-widget-header .ui-icon {background-image: url(images/ui-icons_98d2fb_256x240.png); }
.ui-state-default .ui-icon { background-image: url(images/ui-icons_9ccdfc_256x240.png); }
.ui-state-hover .ui-icon, .ui-state-focus .ui-icon {background-image: url(images/ui-icons_ffffff_256x240.png); }
.ui-state-active .ui-icon {background-image: url(images/ui-icons_00498f_256x240.png); }
.ui-state-highlight .ui-icon {background-image: url(images/ui-icons_ffffff_256x240.png); }
.ui-state-error .ui-icon, .ui-state-error-text .ui-icon {background-image: url(images/ui-icons_ffffff_256x240.png); }
/* positioning */
.ui-icon-carat-1-n { background-position: 0 0; }
.ui-icon-carat-1-ne { background-position: -16px 0; }
.ui-icon-carat-1-e { background-position: -32px 0; }
.ui-icon-carat-1-se { background-position: -48px 0; }
.ui-icon-carat-1-s { background-position: -64px 0; }
.ui-icon-carat-1-sw { background-position: -80px 0; }
.ui-icon-carat-1-w { background-position: -96px 0; }
.ui-icon-carat-1-nw { background-position: -112px 0; }
.ui-icon-carat-2-n-s { background-position: -128px 0; }
.ui-icon-carat-2-e-w { background-position: -144px 0; }
.ui-icon-triangle-1-n { background-position: 0 -16px; }
.ui-icon-triangle-1-ne { background-position: -16px -16px; }
.ui-icon-triangle-1-e { background-position: -32px -16px; }
.ui-icon-triangle-1-se { background-position: -48px -16px; }
.ui-icon-triangle-1-s { background-position: -64px -16px; }
.ui-icon-triangle-1-sw { background-position: -80px -16px; }
.ui-icon-triangle-1-w { background-position: -96px -16px; }
.ui-icon-triangle-1-nw { background-position: -112px -16px; }
.ui-icon-triangle-2-n-s { background-position: -128px -16px; }
.ui-icon-triangle-2-e-w { background-position: -144px -16px; }
.ui-icon-arrow-1-n { background-position: 0 -32px; }
.ui-icon-arrow-1-ne { background-position: -16px -32px; }
.ui-icon-arrow-1-e { background-position: -32px -32px; }
.ui-icon-arrow-1-se { background-position: -48px -32px; }
.ui-icon-arrow-1-s { background-position: -64px -32px; }
.ui-icon-arrow-1-sw { background-position: -80px -32px; }
.ui-icon-arrow-1-w { background-position: -96px -32px; }
.ui-icon-arrow-1-nw { background-position: -112px -32px; }
.ui-icon-arrow-2-n-s { background-position: -128px -32px; }
.ui-icon-arrow-2-ne-sw { background-position: -144px -32px; }
.ui-icon-arrow-2-e-w { background-position: -160px -32px; }
.ui-icon-arrow-2-se-nw { background-position: -176px -32px; }
.ui-icon-arrowstop-1-n { background-position: -192px -32px; }
.ui-icon-arrowstop-1-e { background-position: -208px -32px; }
.ui-icon-arrowstop-1-s { background-position: -224px -32px; }
.ui-icon-arrowstop-1-w { background-position: -240px -32px; }
.ui-icon-arrowthick-1-n { background-position: 0 -48px; }
.ui-icon-arrowthick-1-ne { background-position: -16px -48px; }
.ui-icon-arrowthick-1-e { background-position: -32px -48px; }
.ui-icon-arrowthick-1-se { background-position: -48px -48px; }
.ui-icon-arrowthick-1-s { background-position: -64px -48px; }
.ui-icon-arrowthick-1-sw { background-position: -80px -48px; }
.ui-icon-arrowthick-1-w { background-position: -96px -48px; }
.ui-icon-arrowthick-1-nw { background-position: -112px -48px; }
.ui-icon-arrowthick-2-n-s { background-position: -128px -48px; }
.ui-icon-arrowthick-2-ne-sw { background-position: -144px -48px; }
.ui-icon-arrowthick-2-e-w { background-position: -160px -48px; }
.ui-icon-arrowthick-2-se-nw { background-position: -176px -48px; }
.ui-icon-arrowthickstop-1-n { background-position: -192px -48px; }
.ui-icon-arrowthickstop-1-e { background-position: -208px -48px; }
.ui-icon-arrowthickstop-1-s { background-position: -224px -48px; }
.ui-icon-arrowthickstop-1-w { background-position: -240px -48px; }
.ui-icon-arrowreturnthick-1-w { background-position: 0 -64px; }
.ui-icon-arrowreturnthick-1-n { background-position: -16px -64px; }
.ui-icon-arrowreturnthick-1-e { background-position: -32px -64px; }
.ui-icon-arrowreturnthick-1-s { background-position: -48px -64px; }
.ui-icon-arrowreturn-1-w { background-position: -64px -64px; }
.ui-icon-arrowreturn-1-n { background-position: -80px -64px; }
.ui-icon-arrowreturn-1-e { background-position: -96px -64px; }
.ui-icon-arrowreturn-1-s { background-position: -112px -64px; }
.ui-icon-arrowrefresh-1-w { background-position: -128px -64px; }
.ui-icon-arrowrefresh-1-n { background-position: -144px -64px; }
.ui-icon-arrowrefresh-1-e { background-position: -160px -64px; }
.ui-icon-arrowrefresh-1-s { background-position: -176px -64px; }
.ui-icon-arrow-4 { background-position: 0 -80px; }
.ui-icon-arrow-4-diag { background-position: -16px -80px; }
.ui-icon-extlink { background-position: -32px -80px; }
.ui-icon-newwin { background-position: -48px -80px; }
.ui-icon-refresh { background-position: -64px -80px; }
.ui-icon-shuffle { background-position: -80px -80px; }
.ui-icon-transfer-e-w { background-position: -96px -80px; }
.ui-icon-transferthick-e-w { background-position: -112px -80px; }
.ui-icon-folder-collapsed { background-position: 0 -96px; }
.ui-icon-folder-open { background-position: -16px -96px; }
.ui-icon-document { background-position: -32px -96px; }
.ui-icon-document-b { background-position: -48px -96px; }
.ui-icon-note { background-position: -64px -96px; }
.ui-icon-mail-closed { background-position: -80px -96px; }
.ui-icon-mail-open { background-position: -96px -96px; }
.ui-icon-suitcase { background-position: -112px -96px; }
.ui-icon-comment { background-position: -128px -96px; }
.ui-icon-person { background-position: -144px -96px; }
.ui-icon-print { background-position: -160px -96px; }
.ui-icon-trash { background-position: -176px -96px; }
.ui-icon-locked { background-position: -192px -96px; }
.ui-icon-unlocked { background-position: -208px -96px; }
.ui-icon-bookmark { background-position: -224px -96px; }
.ui-icon-tag { background-position: -240px -96px; }
.ui-icon-home { background-position: 0 -112px; }
.ui-icon-flag { background-position: -16px -112px; }
.ui-icon-calendar { background-position: -32px -112px; }
.ui-icon-cart { background-position: -48px -112px; }
.ui-icon-pencil { background-position: -64px -112px; }
.ui-icon-clock { background-position: -80px -112px; }
.ui-icon-disk { background-position: -96px -112px; }
.ui-icon-calculator { background-position: -112px -112px; }
.ui-icon-zoomin { background-position: -128px -112px; }
.ui-icon-zoomout { background-position: -144px -112px; }
.ui-icon-search { background-position: -160px -112px; }
.ui-icon-wrench { background-position: -176px -112px; }
.ui-icon-gear { background-position: -192px -112px; }
.ui-icon-heart { background-position: -208px -112px; }
.ui-icon-star { background-position: -224px -112px; }
.ui-icon-link { background-position: -240px -112px; }
.ui-icon-cancel { background-position: 0 -128px; }
.ui-icon-plus { background-position: -16px -128px; }
.ui-icon-plusthick { background-position: -32px -128px; }
.ui-icon-minus { background-position: -48px -128px; }
.ui-icon-minusthick { background-position: -64px -128px; }
.ui-icon-close { background-position: -80px -128px; }
.ui-icon-closethick { background-position: -96px -128px; }
.ui-icon-key { background-position: -112px -128px; }
.ui-icon-lightbulb { background-position: -128px -128px; }
.ui-icon-scissors { background-position: -144px -128px; }
.ui-icon-clipboard { background-position: -160px -128px; }
.ui-icon-copy { background-position: -176px -128px; }
.ui-icon-contact { background-position: -192px -128px; }
.ui-icon-image { background-position: -208px -128px; }
.ui-icon-video { background-position: -224px -128px; }
.ui-icon-script { background-position: -240px -128px; }
.ui-icon-alert { background-position: 0 -144px; }
.ui-icon-info { background-position: -16px -144px; }
.ui-icon-notice { background-position: -32px -144px; }
.ui-icon-help { background-position: -48px -144px; }
.ui-icon-check { background-position: -64px -144px; }
.ui-icon-bullet { background-position: -80px -144px; }
.ui-icon-radio-off { background-position: -96px -144px; }
.ui-icon-radio-on { background-position: -112px -144px; }
.ui-icon-pin-w { background-position: -128px -144px; }
.ui-icon-pin-s { background-position: -144px -144px; }
.ui-icon-play { background-position: 0 -160px; }
.ui-icon-pause { background-position: -16px -160px; }
.ui-icon-seek-next { background-position: -32px -160px; }
.ui-icon-seek-prev { background-position: -48px -160px; }
.ui-icon-seek-end { background-position: -64px -160px; }
.ui-icon-seek-start { background-position: -80px -160px; }
/* ui-icon-seek-first is deprecated, use ui-icon-seek-start instead */
.ui-icon-seek-first { background-position: -80px -160px; }
.ui-icon-stop { background-position: -96px -160px; }
.ui-icon-eject { background-position: -112px -160px; }
.ui-icon-volume-off { background-position: -128px -160px; }
.ui-icon-volume-on { background-position: -144px -160px; }
.ui-icon-power { background-position: 0 -176px; }
.ui-icon-signal-diag { background-position: -16px -176px; }
.ui-icon-signal { background-position: -32px -176px; }
.ui-icon-battery-0 { background-position: -48px -176px; }
.ui-icon-battery-1 { background-position: -64px -176px; }
.ui-icon-battery-2 { background-position: -80px -176px; }
.ui-icon-battery-3 { background-position: -96px -176px; }
.ui-icon-circle-plus { background-position: 0 -192px; }
.ui-icon-circle-minus { background-position: -16px -192px; }
.ui-icon-circle-close { background-position: -32px -192px; }
.ui-icon-circle-triangle-e { background-position: -48px -192px; }
.ui-icon-circle-triangle-s { background-position: -64px -192px; }
.ui-icon-circle-triangle-w { background-position: -80px -192px; }
.ui-icon-circle-triangle-n { background-position: -96px -192px; }
.ui-icon-circle-arrow-e { background-position: -112px -192px; }
.ui-icon-circle-arrow-s { background-position: -128px -192px; }
.ui-icon-circle-arrow-w { background-position: -144px -192px; }
.ui-icon-circle-arrow-n { background-position: -160px -192px; }
.ui-icon-circle-zoomin { background-position: -176px -192px; }
.ui-icon-circle-zoomout { background-position: -192px -192px; }
.ui-icon-circle-check { background-position: -208px -192px; }
.ui-icon-circlesmall-plus { background-position: 0 -208px; }
.ui-icon-circlesmall-minus { background-position: -16px -208px; }
.ui-icon-circlesmall-close { background-position: -32px -208px; }
.ui-icon-squaresmall-plus { background-position: -48px -208px; }
.ui-icon-squaresmall-minus { background-position: -64px -208px; }
.ui-icon-squaresmall-close { background-position: -80px -208px; }
.ui-icon-grip-dotted-vertical { background-position: 0 -224px; }
.ui-icon-grip-dotted-horizontal { background-position: -16px -224px; }
.ui-icon-grip-solid-vertical { background-position: -32px -224px; }
.ui-icon-grip-solid-horizontal { background-position: -48px -224px; }
.ui-icon-gripsmall-diagonal-se { background-position: -64px -224px; }
.ui-icon-grip-diagonal-se { background-position: -80px -224px; }
/* Misc visuals
----------------------------------*/
/* Corner radius */
.ui-corner-all, .ui-corner-top, .ui-corner-left, .ui-corner-tl { -moz-border-radius-topleft: 4px; -webkit-border-top-left-radius: 4px; -khtml-border-top-left-radius: 4px; border-top-left-radius: 4px; }
.ui-corner-all, .ui-corner-top, .ui-corner-right, .ui-corner-tr { -moz-border-radius-topright: 4px; -webkit-border-top-right-radius: 4px; -khtml-border-top-right-radius: 4px; border-top-right-radius: 4px; }
.ui-corner-all, .ui-corner-bottom, .ui-corner-left, .ui-corner-bl { -moz-border-radius-bottomleft: 4px; -webkit-border-bottom-left-radius: 4px; -khtml-border-bottom-left-radius: 4px; border-bottom-left-radius: 4px; }
.ui-corner-all, .ui-corner-bottom, .ui-corner-right, .ui-corner-br { -moz-border-radius-bottomright: 4px; -webkit-border-bottom-right-radius: 4px; -khtml-border-bottom-right-radius: 4px; border-bottom-right-radius: 4px; }
/* Overlays */
.ui-widget-overlay { background: #aaaaaa url(images/ui-bg_flat_0_aaaaaa_40x100.png) 50% 50% repeat-x; opacity: .30;filter:Alpha(Opacity=30); }
.ui-widget-shadow { margin: -8px 0 0 -8px; padding: 8px; background: #aaaaaa url(images/ui-bg_flat_0_aaaaaa_40x100.png) 50% 50% repeat-x; opacity: .30;filter:Alpha(Opacity=30); -moz-border-radius: 8px; -khtml-border-radius: 8px; -webkit-border-radius: 8px; border-radius: 8px; }/*!
* jQuery UI Accordion 1.8.24
*
* Copyright 2012, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Accordion#theming
*/
/* IE/Win - Fix animation bug - #4615 */
.ui-accordion { width: 100%; }
.ui-accordion .ui-accordion-header { cursor: pointer; position: relative; margin-top: 1px; zoom: 1; }
.ui-accordion .ui-accordion-li-fix { display: inline; }
.ui-accordion .ui-accordion-header-active { border-bottom: 0 !important; }
.ui-accordion .ui-accordion-header a { display: block; font-size: 1em; padding: .5em .5em .5em .7em; }
.ui-accordion-icons .ui-accordion-header a { padding-left: 2.2em; }
.ui-accordion .ui-accordion-header .ui-icon { position: absolute; left: .5em; top: 50%; margin-top: -8px; }
.ui-accordion .ui-accordion-content { padding: 1em 2.2em; border-top: 0; margin-top: -2px; position: relative; top: 1px; margin-bottom: 2px; overflow: auto; display: none; zoom: 1; }
.ui-accordion .ui-accordion-content-active { display: block; }
/*!
* jQuery UI Autocomplete 1.8.24
*
* Copyright 2012, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Autocomplete#theming
*/
.ui-autocomplete { position: absolute; cursor: default; }
/* workarounds */
* html .ui-autocomplete { width:1px; } /* without this, the menu expands to 100% in IE6 */
/*
* jQuery UI Menu 1.8.24
*
* Copyright 2010, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Menu#theming
*/
.ui-menu {
list-style:none;
padding: 2px;
margin: 0;
display:block;
float: left;
}
.ui-menu .ui-menu {
margin-top: -3px;
}
.ui-menu .ui-menu-item {
margin:0;
padding: 0;
zoom: 1;
float: left;
clear: left;
width: 100%;
}
.ui-menu .ui-menu-item a {
text-decoration:none;
display:block;
padding:.2em .4em;
line-height:1.5;
zoom:1;
}
.ui-menu .ui-menu-item a.ui-state-hover,
.ui-menu .ui-menu-item a.ui-state-active {
font-weight: normal;
margin: -1px;
}
/*!
* jQuery UI Button 1.8.24
*
* Copyright 2012, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Button#theming
*/
.ui-button { display: inline-block; position: relative; padding: 0; margin-right: .1em; text-decoration: none !important; cursor: pointer; text-align: center; zoom: 1; overflow: visible; } /* the overflow property removes extra width in IE */
.ui-button-icon-only { width: 2.2em; } /* to make room for the icon, a width needs to be set here */
button.ui-button-icon-only { width: 2.4em; } /* button elements seem to need a little more width */
.ui-button-icons-only { width: 3.4em; }
button.ui-button-icons-only { width: 3.7em; }
/*button text element */
.ui-button .ui-button-text { display: block; line-height: 1.4; }
.ui-button-text-only .ui-button-text { padding: .4em 1em; }
.ui-button-icon-only .ui-button-text, .ui-button-icons-only .ui-button-text { padding: .4em; text-indent: -9999999px; }
.ui-button-text-icon-primary .ui-button-text, .ui-button-text-icons .ui-button-text { padding: .4em 1em .4em 2.1em; }
.ui-button-text-icon-secondary .ui-button-text, .ui-button-text-icons .ui-button-text { padding: .4em 2.1em .4em 1em; }
.ui-button-text-icons .ui-button-text { padding-left: 2.1em; padding-right: 2.1em; }
/* no icon support for input elements, provide padding by default */
input.ui-button { padding: .4em 1em; }
/*button icon element(s) */
.ui-button-icon-only .ui-icon, .ui-button-text-icon-primary .ui-icon, .ui-button-text-icon-secondary .ui-icon, .ui-button-text-icons .ui-icon, .ui-button-icons-only .ui-icon { position: absolute; top: 50%; margin-top: -8px; }
.ui-button-icon-only .ui-icon { left: 50%; margin-left: -8px; }
.ui-button-text-icon-primary .ui-button-icon-primary, .ui-button-text-icons .ui-button-icon-primary, .ui-button-icons-only .ui-button-icon-primary { left: .5em; }
.ui-button-text-icon-secondary .ui-button-icon-secondary, .ui-button-text-icons .ui-button-icon-secondary, .ui-button-icons-only .ui-button-icon-secondary { right: .5em; }
.ui-button-text-icons .ui-button-icon-secondary, .ui-button-icons-only .ui-button-icon-secondary { right: .5em; }
/*button sets*/
.ui-buttonset { margin-right: 7px; }
.ui-buttonset .ui-button { margin-left: 0; margin-right: -.3em; }
/* workarounds */
button.ui-button::-moz-focus-inner { border: 0; padding: 0; } /* reset extra padding in Firefox */
/*!
* jQuery UI Dialog 1.8.24
*
* Copyright 2012, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Dialog#theming
*/
.ui-dialog { position: absolute; padding: .2em; width: 300px; overflow: hidden; }
.ui-dialog .ui-dialog-titlebar { padding: .4em 1em; position: relative; }
.ui-dialog .ui-dialog-title { float: left; margin: .1em 16px .1em 0; }
.ui-dialog .ui-dialog-titlebar-close { position: absolute; right: .3em; top: 50%; width: 19px; margin: -10px 0 0 0; padding: 1px; height: 18px; }
.ui-dialog .ui-dialog-titlebar-close span { display: block; margin: 1px; }
.ui-dialog .ui-dialog-titlebar-close:hover, .ui-dialog .ui-dialog-titlebar-close:focus { padding: 0; }
.ui-dialog .ui-dialog-content { position: relative; border: 0; padding: .5em 1em; background: none; overflow: auto; zoom: 1; }
.ui-dialog .ui-dialog-buttonpane { text-align: left; border-width: 1px 0 0 0; background-image: none; margin: .5em 0 0 0; padding: .3em 1em .5em .4em; }
.ui-dialog .ui-dialog-buttonpane .ui-dialog-buttonset { float: right; }
.ui-dialog .ui-dialog-buttonpane button { margin: .5em .4em .5em 0; cursor: pointer; }
.ui-dialog .ui-resizable-se { width: 14px; height: 14px; right: 3px; bottom: 3px; }
.ui-draggable .ui-dialog-titlebar { cursor: move; }
/*!
* jQuery UI Slider 1.8.24
*
* Copyright 2012, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Slider#theming
*/
.ui-slider { position: relative; text-align: left; }
.ui-slider .ui-slider-handle { position: absolute; z-index: 2; width: 1.2em; height: 1.2em; cursor: default; }
.ui-slider .ui-slider-range { position: absolute; z-index: 1; font-size: .7em; display: block; border: 0; background-position: 0 0; }
.ui-slider-horizontal { height: .8em; }
.ui-slider-horizontal .ui-slider-handle { top: -.3em; margin-left: -.6em; }
.ui-slider-horizontal .ui-slider-range { top: 0; height: 100%; }
.ui-slider-horizontal .ui-slider-range-min { left: 0; }
.ui-slider-horizontal .ui-slider-range-max { right: 0; }
.ui-slider-vertical { width: .8em; height: 100px; }
.ui-slider-vertical .ui-slider-handle { left: -.3em; margin-left: 0; margin-bottom: -.6em; }
.ui-slider-vertical .ui-slider-range { left: 0; width: 100%; }
.ui-slider-vertical .ui-slider-range-min { bottom: 0; }
.ui-slider-vertical .ui-slider-range-max { top: 0; }/*!
* jQuery UI Tabs 1.8.24
*
* Copyright 2012, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Tabs#theming
*/
.ui-tabs { position: relative; padding: .2em; zoom: 1; } /* position: relative prevents IE scroll bug (element with position: relative inside container with overflow: auto appear as "fixed") */
.ui-tabs .ui-tabs-nav { margin: 0; padding: .2em .2em 0; }
.ui-tabs .ui-tabs-nav li { list-style: none; float: left; position: relative; top: 1px; margin: 0 .2em 1px 0; border-bottom: 0 !important; padding: 0; white-space: nowrap; }
.ui-tabs .ui-tabs-nav li a { float: left; padding: .5em 1em; text-decoration: none; }
.ui-tabs .ui-tabs-nav li.ui-tabs-selected { margin-bottom: 0; padding-bottom: 1px; }
.ui-tabs .ui-tabs-nav li.ui-tabs-selected a, .ui-tabs .ui-tabs-nav li.ui-state-disabled a, .ui-tabs .ui-tabs-nav li.ui-state-processing a { cursor: text; }
.ui-tabs .ui-tabs-nav li a, .ui-tabs.ui-tabs-collapsible .ui-tabs-nav li.ui-tabs-selected a { cursor: pointer; } /* first selector in group seems obsolete, but required to overcome bug in Opera applying cursor: text overall if defined elsewhere... */
.ui-tabs .ui-tabs-panel { display: block; border-width: 0; padding: 1em 1.4em; background: none; }
.ui-tabs .ui-tabs-hide { display: none !important; }
/*!
* jQuery UI Datepicker 1.8.24
*
* Copyright 2012, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Datepicker#theming
*/
.ui-datepicker { width: 17em; padding: .2em .2em 0; display: none; }
.ui-datepicker .ui-datepicker-header { position:relative; padding:.2em 0; }
.ui-datepicker .ui-datepicker-prev, .ui-datepicker .ui-datepicker-next { position:absolute; top: 2px; width: 1.8em; height: 1.8em; }
.ui-datepicker .ui-datepicker-prev-hover, .ui-datepicker .ui-datepicker-next-hover { top: 1px; }
.ui-datepicker .ui-datepicker-prev { left:2px; }
.ui-datepicker .ui-datepicker-next { right:2px; }
.ui-datepicker .ui-datepicker-prev-hover { left:1px; }
.ui-datepicker .ui-datepicker-next-hover { right:1px; }
.ui-datepicker .ui-datepicker-prev span, .ui-datepicker .ui-datepicker-next span { display: block; position: absolute; left: 50%; margin-left: -8px; top: 50%; margin-top: -8px; }
.ui-datepicker .ui-datepicker-title { margin: 0 2.3em; line-height: 1.8em; text-align: center; }
.ui-datepicker .ui-datepicker-title select { font-size:1em; margin:1px 0; }
.ui-datepicker select.ui-datepicker-month-year {width: 100%;}
.ui-datepicker select.ui-datepicker-month,
.ui-datepicker select.ui-datepicker-year { width: 49%;}
.ui-datepicker table {width: 100%; font-size: .9em; border-collapse: collapse; margin:0 0 .4em; }
.ui-datepicker th { padding: .7em .3em; text-align: center; font-weight: bold; border: 0; }
.ui-datepicker td { border: 0; padding: 1px; }
.ui-datepicker td span, .ui-datepicker td a { display: block; padding: .2em; text-align: right; text-decoration: none; }
.ui-datepicker .ui-datepicker-buttonpane { background-image: none; margin: .7em 0 0 0; padding:0 .2em; border-left: 0; border-right: 0; border-bottom: 0; }
.ui-datepicker .ui-datepicker-buttonpane button { float: right; margin: .5em .2em .4em; cursor: pointer; padding: .2em .6em .3em .6em; width:auto; overflow:visible; }
.ui-datepicker .ui-datepicker-buttonpane button.ui-datepicker-current { float:left; }
/* with multiple calendars */
.ui-datepicker.ui-datepicker-multi { width:auto; }
.ui-datepicker-multi .ui-datepicker-group { float:left; }
.ui-datepicker-multi .ui-datepicker-group table { width:95%; margin:0 auto .4em; }
.ui-datepicker-multi-2 .ui-datepicker-group { width:50%; }
.ui-datepicker-multi-3 .ui-datepicker-group { width:33.3%; }
.ui-datepicker-multi-4 .ui-datepicker-group { width:25%; }
.ui-datepicker-multi .ui-datepicker-group-last .ui-datepicker-header { border-left-width:0; }
.ui-datepicker-multi .ui-datepicker-group-middle .ui-datepicker-header { border-left-width:0; }
.ui-datepicker-multi .ui-datepicker-buttonpane { clear:left; }
.ui-datepicker-row-break { clear:both; width:100%; font-size:0em; }
/* RTL support */
.ui-datepicker-rtl { direction: rtl; }
.ui-datepicker-rtl .ui-datepicker-prev { right: 2px; left: auto; }
.ui-datepicker-rtl .ui-datepicker-next { left: 2px; right: auto; }
.ui-datepicker-rtl .ui-datepicker-prev:hover { right: 1px; left: auto; }
.ui-datepicker-rtl .ui-datepicker-next:hover { left: 1px; right: auto; }
.ui-datepicker-rtl .ui-datepicker-buttonpane { clear:right; }
.ui-datepicker-rtl .ui-datepicker-buttonpane button { float: left; }
.ui-datepicker-rtl .ui-datepicker-buttonpane button.ui-datepicker-current { float:right; }
.ui-datepicker-rtl .ui-datepicker-group { float:right; }
.ui-datepicker-rtl .ui-datepicker-group-last .ui-datepicker-header { border-right-width:0; border-left-width:1px; }
.ui-datepicker-rtl .ui-datepicker-group-middle .ui-datepicker-header { border-right-width:0; border-left-width:1px; }
/* IE6 IFRAME FIX (taken from datepicker 1.5.3 */
.ui-datepicker-cover {
position: absolute; /*must have*/
z-index: -1; /*must have*/
filter: mask(); /*must have*/
top: -4px; /*must have*/
left: -4px; /*must have*/
width: 200px; /*must have*/
height: 200px; /*must have*/
}/*!
* jQuery UI Progressbar 1.8.24
*
* Copyright 2012, AUTHORS.txt (http://jqueryui.com/about)
* Dual licensed under the MIT or GPL Version 2 licenses.
* http://jquery.org/license
*
* http://docs.jquery.com/UI/Progressbar#theming
*/
.ui-progressbar { height:2em; text-align: left; overflow: hidden; }
.ui-progressbar .ui-progressbar-value {margin: -1px; height:100%; }

49
src/client/util.co Normal file
View file

@ -0,0 +1,49 @@
_exports = exports ? window.Util = {}
_exports.schedule = (delay, func) -> setInterval(func, delay * 1000)
_exports.wait = (delay, func) -> setTimeout(func, delay * 1000)
_exports.shuffle = !(array) ->
top = array.length
while --top > 0
current = Math.floor(Math.random() * (top + 1))
tmp = array[current]
array[current] = array[top]
array[top] = tmp
_exports.formatTime = (seconds) ->
seconds = Math.floor seconds
minutes = Math.floor seconds / 60
seconds -= minutes * 60
hours = Math.floor minutes / 60
minutes -= hours * 60
zfill = (n) ->
if n < 10 then "0#n" else "#n"
if hours is not 0
return "#{hours}:#{zfill minutes}:#{zfill seconds}"
else
return "#{minutes}:#{zfill seconds}"
# converts any string into an HTML id, guaranteed to be unique
bad_char_re = new RegExp('[^a-zA-Z0-9\-]', 'gm')
_exports.toHtmlId = (string) ->
string.replace bad_char_re, (c) -> "_#{c.charCodeAt(0)}_"
# compares 2 arrays with positive integers, returning > 0, 0, or < 0
_exports.compareArrays = (arr1, arr2) ->
for val1, i1 of arr1
val2 = arr2[i1]
diff = (val1 ? -1) - (val2 ? -1)
return diff if diff is not 0
return 0
_exports.parseQuery = (query) ->
obj = {}
return obj unless query?
for [param, val] of (valset.split('=') for valset of query.split('&'))
obj[unescape(param)] = unescape(val)
return obj

View file

@ -0,0 +1,17 @@
{{#albums}}
<li>
<div class="clickable expandable" id="{{albumid key}}" data-key="{{key}}" data-type="album">
<div class="ui-icon ui-icon-triangle-1-e"></div>
<span>{{name}}</span>
</div>
<ul style="display: none;">
{{#tracks}}
<li>
<div class="clickable" id="{{trackid file}}" data-key="{{file}}" data-type="track">
<span>{{#if track}}{{track}}. {{/if}}{{#if artist_disambiguation}}{{artist_disambiguation}} - {{/if}}{{name}}</span>
</div>
</li>
{{/tracks}}
</ul>
</li>
{{/albums}}

View file

@ -0,0 +1,7 @@
<ul>
{{#each chats}}
<li>
<span class="{{class}}">{{user_name}}</span>: {{message}}
</li>
{{/each}}
</ul>

View file

@ -0,0 +1,11 @@
{{#if users}}
<ul>
{{#each users}}
<li>
<span class="{{class}}">{{user_name}}</span>
</li>
{{/each}}
</ul>
{{else}}
{{/if}}

View file

@ -0,0 +1,18 @@
{{#if artists}}
<ul>
{{#artists}}
<li>
<div class="clickable expandable" id="{{artistid key}}" data-key="{{key}}" data-type="artist">
<div class="ui-icon ui-icon-triangle-1-e"></div>
<span>{{name}}</span>
</div>
<ul></ul>
</li>
{{/artists}}
</ul>
{{else}}
<p class="ui-state-highlight ui-corner-all">
<span class="ui-icon ui-icon-info"></span>
<strong>{{empty_library_message}}</strong>
</p>
{{/if}}

View file

@ -0,0 +1,33 @@
<ul id="menu" class="ui-widget-content ui-corner-all">
<li><a href="#" class="queue hoverable">Queue</a></li>
<li><a href="#" class="queue-next hoverable">Queue Next</a></li>
<li><a href="#" class="queue-random hoverable">Queue in Random Order</a></li>
<li><a href="#" class="queue-next-random hoverable">Queue Next in Random Order</a></li>
<li>
{{#if status.delete_enabled}}
{{#if permissions.admin}}
<a href="#" class="delete hoverable">Delete</a>
{{else}}
<span title="Delete is disabled: insufficient privileges. See Settings.">Delete</span>
{{/if}}
{{else}}
<span title="Delete is disabled due to invalid server configuration.">Delete</span>
{{/if}}
</li>
<li>
{{#if status.download_enabled}}
{{#if track}}
<a href="library/{{track.file}}" class="download hoverable" target="_blank">Download</a>
{{/if}}
{{#if download_type}}
<a href="download/{{download_type}}/{{escaped_key}}" class="download hoverable" target="_blank">Download</a>
{{/if}}
{{#if download_multi}}
<a href="#" class="download-multi hoverable">Download</a>
{{/if}}
{{else}}
<span title="Download is disabled due to invalid server configuration.">Download</span>
{{/if}}
</li>
</ul>

View file

@ -0,0 +1,12 @@
{{#if playlist}}
{{#playlist}}
<div class="pl-item" id="playlist-track-{{id}}" data-id="{{id}}">
<span class="track">{{track.track}}</span>
<span class="title">{{track.name}}</span>
<span class="artist">{{track.artist_name}}</span>
<span class="album">{{track.album_name}}</span>
<span class="time">{{time track.time}}</span>
</div>
{{/playlist}}
{{/if}}

View file

@ -0,0 +1,11 @@
{{#item_list}}
<li>
<div class="clickable" id="{{storedplaylistitemid id}}" data-key="{{id}}" data-type="stored_playlist_item">
<span>
{{#track}}
{{#if artist.name}}{{artist.name}} - {{/if}}{{name}}
{{/track}}
</span>
</div>
</li>
{{/item_list}}

View file

@ -0,0 +1,26 @@
<ul id="menu" class="ui-widget-content ui-corner-all">
<li><a href="#" class="remove hoverable">Remove</a></li>
<li>
{{#if status.delete_enabled}}
{{#if permissions.admin}}
<a href="#" class="delete hoverable">Delete From Library</a>
{{else}}
<span title="Delete is disabled: insufficient privileges. See Settings.">Delete From Library</span>
{{/if}}
{{else}}
<span title="Delete is disabled due to invalid server configuration.">Delete From Library</span>
{{/if}}
</li>
<li>
{{#if status.download_enabled}}
{{#if item}}
<a href="library/{{item.track.file}}" class="download hoverable" target="_blank">Download</a>
{{/if}}
{{#if download_multi}}
<a href="#" class="download-multi hoverable">Download</a>
{{/if}}
{{else}}
<span title="Download is disabled due to invalid server configuration.">Download</span>
{{/if}}
</li>
</ul>

View file

@ -0,0 +1,55 @@
<div class="section">
<h1>Authentication</h1>
<p>
{{#if auth.show_edit}}
<input type="text" id="auth-password" placeholder="password" />
<button class="auth-save">Save</button>
{{#if auth.password }}
<button class="auth-cancel">Cancel</button>
{{/if}}
{{else}}
Using password <em>{{auth.password}}</em>
<button class="auth-edit">Edit</button>
<button class="auth-clear">Clear</button>
{{/if}}
</p>
<h2>Permissions</h2>
<ul>
{{#if auth.permissions.read}}
<li>Reading the library, current playlist, and playback status</li>
{{/if}}
{{#if auth.permissions.add}}
<li>Adding songs, loading playlists, and uploading songs.</li>
{{/if}}
{{#if auth.permissions.control}}
<li>Control playback state, and manipulate playlists.</li>
{{/if}}
{{#if auth.permissions.admin}}
<li>Deleting songs, updating tags, organizing library.</li>
{{/if}}
</ul>
</div>
<div class="section">
<h1>Last.fm</h1>
{{#if lastfm.username}}
<p>
Authenticated as
<a href="http://last.fm/user/{{lastfm.username}}">{{lastfm.username}}</a>.
<button class="signout">Sign out</button>
</p>
<p>
Scrobbling is <input type="checkbox" id="toggle-scrobble"{{#if lastfm.scrobbling_on}} checked="checked"{{/if}}><label for="toggle-scrobble">{{#if lastfm.scrobbling_on}}On{{else}}Off{{/if}}</label>
</p>
{{else}}
<p>
<a href="{{lastfm.auth_url}}">Authenticate with Last.fm</a>
</p>
{{/if}}
</div>
<div class="section">
<h1>About</h1>
<ul>
<li><a href="{{misc.stream_url}}">Stream URL</a></li>
<li><a href="http://github.com/superjoe30/groovebasin">GrooveBasin on GitHub</a></li>
</ul>
</div>

View file

@ -0,0 +1,145 @@
<div id="shortcuts" style="display: none">
<h1>Playback</h1>
<dl>
<dt>Space</dt>
<dd>Toggle playback</dd>
</dl>
<dl>
<dt>Left <em>and</em> Right</dt>
<dd>Skip 10 seconds in the song</dd>
</dl>
<dl>
<dt>Shift</dt>
<dd>Hold to skip by 10% instead of 10 seconds</dd>
</dl>
<dl>
<dt>&lt; <em>or</em> Ctrl + Left <em>and</em> &gt; <em>or</em> Ctrl + Right</dt>
<dd>Skip track</dd>
</dl>
<dl>
<dt>- <em>and</em> +</dt>
<dd>Change volume</dd>
</dl>
<dl>
<dt>s</dt>
<dd>Toggle streaming</dd>
</dl>
<h1>Playlist</h1>
<dl>
<dt>Up <em>and</em> Down</dt>
<dd>Select the next song</dd>
</dl>
<dl>
<dt>Ctrl + Up <em>and</em> Ctrl + Down</dt>
<dd>Move selection up or down one</dd>
</dl>
<dl>
<dt>Enter</dt>
<dd>Play the selected song</dd>
</dl>
<dl>
<dt>C</dt>
<dd>Clear playlist</dd>
</dl>
<dl>
<dt>H</dt>
<dd>Shuffle playlist</dd>
</dl>
<dl>
<dt>d</dt>
<dd>Toggle dynamic playlist mode</dd>
</dl>
<dl>
<dt>r</dt>
<dd>Change repeat state</dd>
</dl>
<dl>
<dt>Del</dt>
<dd>Remove selected songs from playlist</dd>
</dl>
<dl>
<dt>Shift + Del</dt>
<dd>Delete selected songs from library</dd>
</dl>
<h1>Navigation</h1>
<dl>
<dt>l</dt>
<dd>Switch to Library tab</dd>
</dl>
<dl>
<dt>u</dt>
<dd>Switch to Upload tab and focus the upload by URL box</dd>
</dl>
<dl>
<dt>t</dt>
<dd>Focus chat box</dd>
</dl>
<h1>Library Search Box</h1>
<dl>
<dt>/</dt>
<dd>Focus library search</dd>
</dl>
<dl>
<dt>Esc</dt>
<dd>Clear filter. If filter is already clear, remove focus.</dd>
</dl>
<dl>
<dt>Enter</dt>
<dd>Queue all search results</dd>
</dl>
<dl>
<dt>Down</dt>
<dd>Select the first search result</dd>
</dl>
<h1>Library</h1>
<dl>
<dt>Up <em>and</em> Down</dt>
<dd>Select the next item up or down</dd>
</dl>
<dl>
<dt>Left <em>and</em> Right</dt>
<dd>Expand or collapse selected item</dd>
</dl>
<dl>
<dt>Enter</dt>
<dd>Queue selected items<dd>
</dl>
<dl>
<dt>Del</dt>
<dd>Delete selected songs from library</dd>
</dl>
<h1>Miscellaneous</h1>
<dl>
<dt>?</dt>
<dd>Displays keyboard shortcuts</dd>
</dl>
<dl>
<dt>Esc</dt>
<dd>Close menu, cancel drag, clear selection</dd>
</dl>
<dl>
<dt>Alt</dt>
<dd>Hold when right clicking to get the normal browser menu</dd>
</dl>
<dl>
<dt>Shift</dt>
<dd>Hold while queuing to queue next<dd>
</dl>
<dl>
<dt>Alt</dt>
<dd>Hold while queuing to queue in random order<dd>
</dl>
<dl>
<dt>Ctrl</dt>
<dd>Hold to select multiple items<dd>
</dl>
<dl>
<dt>Shift</dt>
<dd>Hold while selecting to select all items in between<dd>
</dl>
</div>

View file

@ -0,0 +1,18 @@
{{#if stored_playlists}}
<ul>
{{#stored_playlists}}
<li>
<div class="clickable expandable" id="{{storedplaylistid name}}" data-key="{{name}}" data-type="stored_playlist">
<div class="ui-icon ui-icon-triangle-1-e"></div>
<span>{{name}}</span>
</div>
<ul></ul>
</li>
{{/stored_playlists}}
</ul>
{{else}}
<p class="ui-state-highlight ui-corner-all">
<span class="ui-icon ui-icon-info"></span>
<strong>No Playlists</strong>
</p>
{{/if}}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 B

After

Width:  |  Height:  |  Size: 260 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 B

After

Width:  |  Height:  |  Size: 98 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 B

After

Width:  |  Height:  |  Size: 83 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 B

After

Width:  |  Height:  |  Size: 84 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 B

After

Width:  |  Height:  |  Size: 84 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 B

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 B

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View file

@ -1,7 +1,6 @@
<!doctype html> <!doctype html>
<html> <html>
<head> <head>
<meta charset="utf-8">
<title>Groove Basin</title> <title>Groove Basin</title>
<link rel="stylesheet" href="app.css"> <link rel="stylesheet" href="app.css">
<link rel="shortcut icon" href="/favicon.png"> <link rel="shortcut icon" href="/favicon.png">
@ -28,7 +27,7 @@
<span class="ui-icon ui-icon-volume-on"></span> <span class="ui-icon ui-icon-volume-on"></span>
</div> </div>
<div id="more-playback-btns"> <div id="more-playback-btns">
<input class="jquery-button" type="checkbox" id="stream-btn"><label id="stream-btn-label" for="stream-btn">Stream</label> <input type="checkbox" id="stream-btn"><label for="stream-btn">Stream</label>
</div> </div>
<h1 id="track-display"></h1> <h1 id="track-display"></h1>
<div id="track-slider"></div> <div id="track-slider"></div>
@ -40,7 +39,9 @@
<div id="tabs" class="ui-widget ui-corner-all"> <div id="tabs" class="ui-widget ui-corner-all">
<ul class="ui-tabs-nav ui-helper-reset ui-helper-clearfix ui-corner-all"> <ul class="ui-tabs-nav ui-helper-reset ui-helper-clearfix ui-corner-all">
<li class="ui-state-default ui-corner-top ui-state-active library-tab"><span>Library</span></li> <li class="ui-state-default ui-corner-top ui-state-active library-tab"><span>Library</span></li>
<li class="ui-state-default ui-corner-top stored-playlists-tab"><span>Playlists</span></li>
<li class="ui-state-default ui-corner-top upload-tab"><span>Upload</span></li> <li class="ui-state-default ui-corner-top upload-tab"><span>Upload</span></li>
<li class="ui-state-default ui-corner-top chat-tab"><span>Chat</span></li>
<li class="ui-state-default ui-corner-top settings-tab"><span>Settings</span></li> <li class="ui-state-default ui-corner-top settings-tab"><span>Settings</span></li>
</ul> </ul>
</div> </div>
@ -52,73 +53,31 @@
</select> </select>
</div> </div>
<div id="library"> <div id="library">
<ul id="library-artists"> </div>
</ul> </div>
<p id="library-no-items" class="ui-state-highlight ui-corner-all"> <div id="stored-playlists-pane" class="ui-widget-content ui-corner-all" style="display: none">
<span class="ui-icon ui-icon-info"></span> <div id="stored-playlists">
<strong id="empty-library-message">loading...</strong>
</p>
</div> </div>
</div> </div>
<div id="upload-pane" class="ui-widget-content ui-corner-all" style="display: none"> <div id="upload-pane" class="ui-widget-content ui-corner-all" style="display: none">
<div id="upload"> <div id="upload">
<input id="upload-by-url" type="text" placeholder="Paste URL here"> <input id="upload-by-url" type="text" placeholder="Paste URL here">
<div id="upload-widget"> <div id="upload-widget"></div>
<input type="file" id="upload-input" multiple="multiple" placeholder="Drag and drop or click to browse"> </div>
</div> </div>
<div> <div id="chat-pane" class="ui-widget-content ui-corner-all" style="display: none">
Automatically queue uploads: <input class="jquery-button" type="checkbox" id="auto-queue-uploads"><label for="auto-queue-uploads">On</label> <div id="chat-user-list">
</div> </div>
<div id="chat-list" style="overflow-y: auto;">
</div>
<div id="chat-input-pane">
<span id="user-id" class="chat-user-self"></span>
<input type="text" id="chat-name-input" placeholder="your name" style="display: none">
<input type="text" id="chat-input" placeholder="chat">
</div> </div>
</div> </div>
<div id="settings-pane" class="ui-widget-content ui-corner-all" style="display: none"> <div id="settings-pane" class="ui-widget-content ui-corner-all" style="display: none">
<div id="settings"> <div id="settings">
<div class="section">
<h1>Authentication</h1>
<div id="settings-edit-password">
<input type="text" id="auth-password" placeholder="password" />
<button id="settings-auth-save">Save</button>
<button id="settings-auth-cancel">Cancel</button>
</div>
<div id="settings-show-password">
Using password <em id="password-display">...</em>
<button id="settings-auth-edit">Edit</button>
<button id="settings-auth-clear">Clear</button>
</div>
<h2>Permissions</h2>
<ul>
<li id="auth-perm-read">Reading the library, current playlist, and playback status</li>
<li id="auth-perm-add">Adding songs, loading playlists, and uploading songs.</li>
<li id="auth-perm-control">Control playback state, and manipulate playlists.</li>
<li id="auth-perm-admin">Deleting songs, updating tags, organizing library.</li>
</ul>
</div>
<div class="section">
<h1>Last.fm</h1>
<div id="settings-lastfm-in">
<p>
Authenticated as
<a id="settings-lastfm-user" href="#">...</a>.
<button id="lastfm-sign-out">Sign out</button>
</p>
<p>
Scrobbling is
<input class="jquery-button" type="checkbox" id="toggle-scrobble"><label for="toggle-scrobble">Off</label>
</p>
</div>
<div id="settings-lastfm-out">
<p>
<a id="lastfm-auth-url" href="#">Authenticate with Last.fm</a>
</p>
</div>
</div>
<div class="section">
<h1>About</h1>
<ul>
<li><a id="settings-stream-url" href="#">Stream URL</a></li>
<li><a href="http://github.com/andrewrk/groovebasin">GrooveBasin on GitHub</a></li>
</ul>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -126,8 +85,8 @@
<div class="window-header"> <div class="window-header">
<button class="jquery-button clear">Clear</button> <button class="jquery-button clear">Clear</button>
<button class="jquery-button shuffle">Shuffle</button> <button class="jquery-button shuffle">Shuffle</button>
<input class="jquery-button" type="checkbox" id="dynamic-mode"><label id="dynamic-mode-label" for="dynamic-mode">Dynamic Mode</label> <input class="jquery-button" type="checkbox" id="dynamic-mode"><label for="dynamic-mode">Dynamic Mode</label>
<input class="jquery-button" type="checkbox" id="pl-btn-repeat"><label id="pl-btn-repeat-label" for="pl-btn-repeat">Repeat: Off</label> <input class="jquery-button" type="checkbox" id="pl-btn-repeat"><label for="pl-btn-repeat">Repeat: Off</label>
</div> </div>
<div id="playlist"> <div id="playlist">
<div class="header"> <div class="header">
@ -148,218 +107,6 @@
<div id="main-err-msg-text">Loading...</div> <div id="main-err-msg-text">Loading...</div>
</p> </p>
</div> </div>
<div id="shortcuts" style="display: none" tabindex="-1">
<h1>Playback</h1>
<dl>
<dt>Space</dt>
<dd>Toggle playback</dd>
</dl>
<dl>
<dt>Left <em>and</em> Right</dt>
<dd>Skip 10 seconds in the song</dd>
</dl>
<dl>
<dt>Shift</dt>
<dd>Hold to skip by 10% instead of 10 seconds</dd>
</dl>
<dl>
<dt>&lt; <em>or</em> Ctrl + Left <em>and</em> &gt; <em>or</em> Ctrl + Right</dt>
<dd>Skip track</dd>
</dl>
<dl>
<dt>- <em>and</em> +</dt>
<dd>Change volume</dd>
</dl>
<dl>
<dt>s</dt>
<dd>Toggle streaming</dd>
</dl>
<h1>Playlist</h1>
<dl>
<dt>Up <em>and</em> Down</dt>
<dd>Select the next song</dd>
</dl>
<dl>
<dt>Alt + Up <em>and</em> Alt + Down</dt>
<dd>Move selected tracks up or down one</dd>
</dl>
<dl>
<dt>Enter</dt>
<dd>Play the selected song</dd>
</dl>
<dl>
<dt>C</dt>
<dd>Clear playlist</dd>
</dl>
<dl>
<dt>H</dt>
<dd>Shuffle playlist</dd>
</dl>
<dl>
<dt>d</dt>
<dd>Toggle dynamic playlist mode</dd>
</dl>
<dl>
<dt>r</dt>
<dd>Change repeat state</dd>
</dl>
<dl>
<dt>Del</dt>
<dd>Remove selected songs from playlist</dd>
</dl>
<dl>
<dt>Shift + Del</dt>
<dd>Delete selected songs from library</dd>
</dl>
<h1>Navigation</h1>
<dl>
<dt>l</dt>
<dd>Switch to Library tab</dd>
</dl>
<dl>
<dt>u</dt>
<dd>Switch to Upload tab and focus the upload by URL box</dd>
</dl>
<h1>Library Search Box</h1>
<dl>
<dt>/</dt>
<dd>Focus library search</dd>
</dl>
<dl>
<dt>Esc</dt>
<dd>Clear filter. If filter is already clear, remove focus.</dd>
</dl>
<dl>
<dt>Enter</dt>
<dd>Queue all search results</dd>
</dl>
<dl>
<dt>Down</dt>
<dd>Select the first search result</dd>
</dl>
<h1>Library</h1>
<dl>
<dt>Up <em>and</em> Down</dt>
<dd>Select the next item up or down</dd>
</dl>
<dl>
<dt>Left <em>and</em> Right</dt>
<dd>Expand or collapse selected item</dd>
</dl>
<dl>
<dt>Enter</dt>
<dd>Queue selected items<dd>
</dl>
<dl>
<dt>Del</dt>
<dd>Delete selected songs from library</dd>
</dl>
<h1>Miscellaneous</h1>
<dl>
<dt>?</dt>
<dd>Displays keyboard shortcuts</dd>
</dl>
<dl>
<dt>Esc</dt>
<dd>Close menu, cancel drag, clear selection</dd>
</dl>
<dl>
<dt>Alt</dt>
<dd>Hold when right clicking to get the normal browser menu</dd>
</dl>
<dl>
<dt>Shift</dt>
<dd>Hold while queuing to queue next<dd>
</dl>
<dl>
<dt>Alt</dt>
<dd>Hold while queuing to queue in random order<dd>
</dl>
<dl>
<dt>Ctrl</dt>
<dd>Hold to select multiple items<dd>
</dl>
<dl>
<dt>Shift</dt>
<dd>Hold while selecting to select all items in between<dd>
</dl>
</div>
<div id="edit-tags" style="display: none">
<input type="checkbox" id="edit-tag-multi-name">
<label accesskey="i">T<span class="accesskey">i</span>tle: <input id="edit-tag-name"></label><br>
<input type="checkbox" id="edit-tag-multi-track">
<label accesskey="k">Trac<span class="accesskey">k</span> Number: <input id="edit-tag-track"></label><br>
<input type="checkbox" id="edit-tag-multi-file">
<label>Filename: <input id="edit-tag-file"></label><br>
<hr>
<input type="checkbox" id="edit-tag-multi-artistName">
<label accesskey="a"><span class="accesskey">A</span>rtist: <input id="edit-tag-artistName"></label><br>
<input type="checkbox" id="edit-tag-multi-composerName">
<label accesskey="c"><span class="accesskey">C</span>omposer: <input id="edit-tag-composerName"></label><br>
<input type="checkbox" id="edit-tag-multi-performerName">
<label>Performer: <input id="edit-tag-performerName"></label><br>
<input type="checkbox" id="edit-tag-multi-genre">
<label accesskey="g"><span class="accesskey">G</span>enre: <input id="edit-tag-genre"></label><br>
<hr>
<input type="checkbox" id="edit-tag-multi-albumName">
<label accesskey="b">Al<span class="accesskey">b</span>um: <input id="edit-tag-albumName"></label><br>
<input type="checkbox" id="edit-tag-multi-albumArtistName">
<label>Album Artist: <input id="edit-tag-albumArtistName"></label><br>
<input type="checkbox" id="edit-tag-multi-trackCount">
<label>Track Count: <input id="edit-tag-trackCount"></label><br>
<input type="checkbox" id="edit-tag-multi-year">
<label accesskey="y"><span class="accesskey">Y</span>ear: <input id="edit-tag-year"></label><br>
<input type="checkbox" id="edit-tag-multi-disc">
<label accesskey="d"><span class="accesskey">D</span>isc Number: <input id="edit-tag-disc"></label><br>
<input type="checkbox" id="edit-tag-multi-discCount">
<label>Disc Count: <input id="edit-tag-discCount"></label><br>
<input type="checkbox" id="edit-tag-multi-compilation">
<label accesskey="m">Co<span class="accesskey">m</span>pilation: <input type="checkbox" id="edit-tag-compilation"></label><br>
<hr>
<div style="float: right">
<button id="edit-tags-ok" accesskey="v">Sa<span class="accesskey">v</span>e &amp; Close</button>
<button id="edit-tags-cancel">Cancel</button>
</div>
<button id="edit-tags-prev" type="button" accesskey="p"><span class="accesskey">P</span>revious</button>
<button id="edit-tags-next" type="button" accesskey="n"><span class="accesskey">N</span>ext</button>
<label accesskey="r" id="edit-tags-per-label"><input id="edit-tags-per" type="checkbox">Pe<span class="accesskey">r</span> Track</label>
</div>
<ul id="menu-playlist" style="display: none">
<li><a href="#" class="remove">Remove</a></li>
<li><a href="#" class="delete">Delete From Library</a></li>
<li><a href="#" class="download" target="_blank">Download</a></li>
<li><a href="#" class="edit-tags">Edit Tags</a></li>
</ul>
<ul id="menu-library" style="display: none">
<li><a href="#" class="queue">Queue</a></li>
<li><a href="#" class="queue-next">Queue Next</a></li>
<li><a href="#" class="queue-random">Queue in Random Order</a></li>
<li><a href="#" class="queue-next-random">Queue Next in Random Order</a></li>
<li><a href="#" class="delete">Delete</a></li>
<li><a href="#" class="download" target="_blank">Download</a></li>
<li><a href="#" class="edit-tags menu-item-last">Edit Tags</a></li>
</ul>
<script src="vendor/jquery-2.1.0.min.js"></script>
<script src="vendor/jquery-ui-1.10.4.custom.min.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 B

243
src/public/vendor/handlebars.runtime.js vendored Normal file
View file

@ -0,0 +1,243 @@
// lib/handlebars/base.js
/*jshint eqnull:true*/
this.Handlebars = {};
(function() {
Handlebars.VERSION = "1.0.rc.1";
Handlebars.helpers = {};
Handlebars.partials = {};
Handlebars.registerHelper = function(name, fn, inverse) {
if(inverse) { fn.not = inverse; }
this.helpers[name] = fn;
};
Handlebars.registerPartial = function(name, str) {
this.partials[name] = str;
};
Handlebars.registerHelper('helperMissing', function(arg) {
if(arguments.length === 2) {
return undefined;
} else {
throw new Error("Could not find property '" + arg + "'");
}
});
var toString = Object.prototype.toString, functionType = "[object Function]";
Handlebars.registerHelper('blockHelperMissing', function(context, options) {
var inverse = options.inverse || function() {}, fn = options.fn;
var ret = "";
var type = toString.call(context);
if(type === functionType) { context = context.call(this); }
if(context === true) {
return fn(this);
} else if(context === false || context == null) {
return inverse(this);
} else if(type === "[object Array]") {
if(context.length > 0) {
for(var i=0, j=context.length; i<j; i++) {
ret = ret + fn(context[i]);
}
} else {
ret = inverse(this);
}
return ret;
} else {
return fn(context);
}
});
Handlebars.K = function() {};
Handlebars.createFrame = Object.create || function(object) {
Handlebars.K.prototype = object;
var obj = new Handlebars.K();
Handlebars.K.prototype = null;
return obj;
};
Handlebars.registerHelper('each', function(context, options) {
var fn = options.fn, inverse = options.inverse;
var ret = "", data;
if (options.data) {
data = Handlebars.createFrame(options.data);
}
if(context && context.length > 0) {
for(var i=0, j=context.length; i<j; i++) {
if (data) { data.index = i; }
ret = ret + fn(context[i], { data: data });
}
} else {
ret = inverse(this);
}
return ret;
});
Handlebars.registerHelper('if', function(context, options) {
var type = toString.call(context);
if(type === functionType) { context = context.call(this); }
if(!context || Handlebars.Utils.isEmpty(context)) {
return options.inverse(this);
} else {
return options.fn(this);
}
});
Handlebars.registerHelper('unless', function(context, options) {
var fn = options.fn, inverse = options.inverse;
options.fn = inverse;
options.inverse = fn;
return Handlebars.helpers['if'].call(this, context, options);
});
Handlebars.registerHelper('with', function(context, options) {
return options.fn(context);
});
Handlebars.registerHelper('log', function(context) {
Handlebars.log(context);
});
}());
;
// lib/handlebars/utils.js
Handlebars.Exception = function(message) {
var tmp = Error.prototype.constructor.apply(this, arguments);
for (var p in tmp) {
if (tmp.hasOwnProperty(p)) { this[p] = tmp[p]; }
}
this.message = tmp.message;
};
Handlebars.Exception.prototype = new Error();
// Build out our basic SafeString type
Handlebars.SafeString = function(string) {
this.string = string;
};
Handlebars.SafeString.prototype.toString = function() {
return this.string.toString();
};
(function() {
var escape = {
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#x27;",
"`": "&#x60;"
};
var badChars = /&(?!\w+;)|[<>"'`]/g;
var possible = /[&<>"'`]/;
var escapeChar = function(chr) {
return escape[chr] || "&amp;";
};
Handlebars.Utils = {
escapeExpression: function(string) {
// don't escape SafeStrings, since they're already safe
if (string instanceof Handlebars.SafeString) {
return string.toString();
} else if (string == null || string === false) {
return "";
}
if(!possible.test(string)) { return string; }
return string.replace(badChars, escapeChar);
},
isEmpty: function(value) {
if (typeof value === "undefined") {
return true;
} else if (value === null) {
return true;
} else if (value === false) {
return true;
} else if(Object.prototype.toString.call(value) === "[object Array]" && value.length === 0) {
return true;
} else {
return false;
}
}
};
})();;
// lib/handlebars/runtime.js
Handlebars.VM = {
template: function(templateSpec) {
// Just add water
var container = {
escapeExpression: Handlebars.Utils.escapeExpression,
invokePartial: Handlebars.VM.invokePartial,
programs: [],
program: function(i, fn, data) {
var programWrapper = this.programs[i];
if(data) {
return Handlebars.VM.program(fn, data);
} else if(programWrapper) {
return programWrapper;
} else {
programWrapper = this.programs[i] = Handlebars.VM.program(fn);
return programWrapper;
}
},
programWithDepth: Handlebars.VM.programWithDepth,
noop: Handlebars.VM.noop
};
return function(context, options) {
options = options || {};
return templateSpec.call(container, Handlebars, context, options.helpers, options.partials, options.data);
};
},
programWithDepth: function(fn, data, $depth) {
var args = Array.prototype.slice.call(arguments, 2);
return function(context, options) {
options = options || {};
return fn.apply(this, [context, options.data || data].concat(args));
};
},
program: function(fn, data) {
return function(context, options) {
options = options || {};
return fn(context, options.data || data);
};
},
noop: function() { return ""; },
invokePartial: function(partial, name, context, helpers, partials, data) {
var options = { helpers: helpers, partials: partials, data: data };
if(partial === undefined) {
throw new Handlebars.Exception("The partial " + name + " could not be found");
} else if(partial instanceof Function) {
return partial(context, options);
} else if (!Handlebars.compile) {
throw new Handlebars.Exception("The partial " + name + " could not be compiled when running in runtime-only mode");
} else {
partials[name] = Handlebars.compile(partial);
return partials[name](context, options);
}
}
};
Handlebars.template = Handlebars.VM.template;
;

2
src/public/vendor/jquery-1.8.2.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

BIN
src/public/vendor/socket.io/WebSocketMain.swf generated vendored Normal file

Binary file not shown.

BIN
src/public/vendor/socket.io/WebSocketMainInsecure.swf generated vendored Normal file

Binary file not shown.

3862
src/public/vendor/socket.io/socket.io.js generated vendored Normal file

File diff suppressed because one or more lines are too long

2
src/public/vendor/socket.io/socket.io.min.js generated vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,80 @@
/** @license
*
* SoundManager 2: JavaScript Sound for the Web
* ----------------------------------------------
* http://schillmania.com/projects/soundmanager2/
*
* Copyright (c) 2007, Scott Schiller. All rights reserved.
* Code provided under the BSD License:
* http://schillmania.com/projects/soundmanager2/license.txt
*
* V2.97a.20120624
*/
(function(ea){function Q(Q,da){function R(a){return c.preferFlash&&t&&!c.ignoreFlash&&"undefined"!==typeof c.flash[a]&&c.flash[a]}function m(a){return function(c){var d=this._t;return!d||!d._a?null:a.call(this,c)}}this.setupOptions={url:Q||null,flashVersion:8,debugMode:!0,debugFlash:!1,useConsole:!0,consoleOnly:!0,waitForWindowLoad:!1,bgColor:"#ffffff",useHighPerformance:!1,flashPollingInterval:null,html5PollingInterval:null,flashLoadTimeout:1E3,wmode:null,allowScriptAccess:"always",useFlashBlock:!1,
useHTML5Audio:!0,html5Test:/^(probably|maybe)$/i,preferFlash:!0,noSWFCache:!1};this.defaultOptions={autoLoad:!1,autoPlay:!1,from:null,loops:1,onid3:null,onload:null,whileloading:null,onplay:null,onpause:null,onresume:null,whileplaying:null,onposition:null,onstop:null,onfailure:null,onfinish:null,multiShot:!0,multiShotEvents:!1,position:null,pan:0,stream:!0,to:null,type:null,usePolicyFile:!1,volume:100};this.flash9Options={isMovieStar:null,usePeakData:!1,useWaveformData:!1,useEQData:!1,onbufferchange:null,
ondataerror:null};this.movieStarOptions={bufferTime:3,serverURL:null,onconnect:null,duration:null};this.audioFormats={mp3:{type:['audio/mpeg; codecs="mp3"',"audio/mpeg","audio/mp3","audio/MPA","audio/mpa-robust"],required:!0},mp4:{related:["aac","m4a"],type:['audio/mp4; codecs="mp4a.40.2"',"audio/aac","audio/x-m4a","audio/MP4A-LATM","audio/mpeg4-generic"],required:!1},ogg:{type:["audio/ogg; codecs=vorbis"],required:!1},wav:{type:['audio/wav; codecs="1"',"audio/wav","audio/wave","audio/x-wav"],required:!1}};
this.movieID="sm2-container";this.id=da||"sm2movie";this.debugID="soundmanager-debug";this.debugURLParam=/([#?&])debug=1/i;this.versionNumber="V2.97a.20120624";this.altURL=this.movieURL=this.version=null;this.enabled=this.swfLoaded=!1;this.oMC=null;this.sounds={};this.soundIDs=[];this.didFlashBlock=this.muted=!1;this.filePattern=null;this.filePatterns={flash8:/\.mp3(\?.*)?$/i,flash9:/\.mp3(\?.*)?$/i};this.features={buffering:!1,peakData:!1,waveformData:!1,eqData:!1,movieStar:!1};this.sandbox={};var fa;
try{fa="undefined"!==typeof Audio&&"undefined"!==typeof(new Audio).canPlayType}catch(Za){fa=!1}this.hasHTML5=fa;this.html5={usingFlash:null};this.flash={};this.ignoreFlash=this.html5Only=!1;var Ca,c=this,i=null,S,q=navigator.userAgent,h=ea,ga=h.location.href.toString(),l=document,ha,Da,ia,j,w=[],J=!1,K=!1,k=!1,s=!1,ja=!1,L,r,ka,T,la,B,C,D,Ea,ma,U,V,E,na,oa,pa,W,F,Fa,qa,Ga,X,Ha,M=null,ra=null,u,sa,G,Y,Z,H,p,N=!1,ta=!1,Ia,Ja,Ka,$=0,O=null,aa,n=null,La,ba,P,x,ua,va,Ma,o,Wa=Array.prototype.slice,z=!1,
t,wa,Na,v,Oa,xa=q.match(/(ipad|iphone|ipod)/i),y=q.match(/msie/i),Xa=q.match(/webkit/i),ya=q.match(/safari/i)&&!q.match(/chrome/i),Pa=q.match(/opera/i),za=q.match(/(mobile|pre\/|xoom)/i)||xa,Qa=!ga.match(/usehtml5audio/i)&&!ga.match(/sm2\-ignorebadua/i)&&ya&&!q.match(/silk/i)&&q.match(/OS X 10_6_([3-7])/i),Aa="undefined"!==typeof l.hasFocus?l.hasFocus():null,ca=ya&&("undefined"===typeof l.hasFocus||!l.hasFocus()),Ra=!ca,Sa=/(mp3|mp4|mpa|m4a)/i,Ba=l.location?l.location.protocol.match(/http/i):null,
Ta=!Ba?"http://":"",Ua=/^\s*audio\/(?:x-)?(?:mpeg4|aac|flv|mov|mp4||m4v|m4a|mp4v|3gp|3g2)\s*(?:$|;)/i,Va="mpeg4,aac,flv,mov,mp4,m4v,f4v,m4a,mp4v,3gp,3g2".split(","),Ya=RegExp("\\.("+Va.join("|")+")(\\?.*)?$","i");this.mimePattern=/^\s*audio\/(?:x-)?(?:mp(?:eg|3))\s*(?:$|;)/i;this.useAltURL=!Ba;this._global_a=null;if(za&&(c.useHTML5Audio=!0,c.preferFlash=!1,xa))z=c.ignoreFlash=!0;this.setup=function(a){"undefined"!==typeof a&&k&&n&&c.ok()&&("undefined"!==typeof a.flashVersion||"undefined"!==typeof a.url)&&
H(u("setupLate"));ka(a);return c};this.supported=this.ok=function(){return n?k&&!s:c.useHTML5Audio&&c.hasHTML5};this.getMovie=function(a){return S(a)||l[a]||h[a]};this.createSound=function(a,e){function d(){b=Y(b);c.sounds[f.id]=new Ca(f);c.soundIDs.push(f.id);return c.sounds[f.id]}var b=null,g=null,f=null;if(!k||!c.ok())return H(void 0),!1;"undefined"!==typeof e&&(a={id:a,url:e});b=r(a);b.url=aa(b.url);f=b;if(p(f.id,!0))return c.sounds[f.id];if(ba(f))g=d(),g._setup_html5(f);else{if(8<j&&null===f.isMovieStar)f.isMovieStar=
!(!f.serverURL&&!(f.type&&f.type.match(Ua)||f.url.match(Ya)));f=Z(f,void 0);g=d();if(8===j)i._createSound(f.id,f.loops||1,f.usePolicyFile);else if(i._createSound(f.id,f.url,f.usePeakData,f.useWaveformData,f.useEQData,f.isMovieStar,f.isMovieStar?f.bufferTime:!1,f.loops||1,f.serverURL,f.duration||null,f.autoPlay,!0,f.autoLoad,f.usePolicyFile),!f.serverURL)g.connected=!0,f.onconnect&&f.onconnect.apply(g);!f.serverURL&&(f.autoLoad||f.autoPlay)&&g.load(f)}!f.serverURL&&f.autoPlay&&g.play();return g};this.destroySound=
function(a,e){if(!p(a))return!1;var d=c.sounds[a],b;d._iO={};d.stop();d.unload();for(b=0;b<c.soundIDs.length;b++)if(c.soundIDs[b]===a){c.soundIDs.splice(b,1);break}e||d.destruct(!0);delete c.sounds[a];return!0};this.load=function(a,e){return!p(a)?!1:c.sounds[a].load(e)};this.unload=function(a){return!p(a)?!1:c.sounds[a].unload()};this.onposition=this.onPosition=function(a,e,d,b){return!p(a)?!1:c.sounds[a].onposition(e,d,b)};this.clearOnPosition=function(a,e,d){return!p(a)?!1:c.sounds[a].clearOnPosition(e,
d)};this.start=this.play=function(a,e){var d=!1;if(!k||!c.ok())return H("soundManager.play(): "+u(!k?"notReady":"notOK")),d;if(!p(a)){e instanceof Object||(e={url:e});if(e&&e.url)e.id=a,d=c.createSound(e).play();return d}return c.sounds[a].play(e)};this.setPosition=function(a,e){return!p(a)?!1:c.sounds[a].setPosition(e)};this.stop=function(a){return!p(a)?!1:c.sounds[a].stop()};this.stopAll=function(){for(var a in c.sounds)c.sounds.hasOwnProperty(a)&&c.sounds[a].stop()};this.pause=function(a){return!p(a)?
!1:c.sounds[a].pause()};this.pauseAll=function(){var a;for(a=c.soundIDs.length-1;0<=a;a--)c.sounds[c.soundIDs[a]].pause()};this.resume=function(a){return!p(a)?!1:c.sounds[a].resume()};this.resumeAll=function(){var a;for(a=c.soundIDs.length-1;0<=a;a--)c.sounds[c.soundIDs[a]].resume()};this.togglePause=function(a){return!p(a)?!1:c.sounds[a].togglePause()};this.setPan=function(a,e){return!p(a)?!1:c.sounds[a].setPan(e)};this.setVolume=function(a,e){return!p(a)?!1:c.sounds[a].setVolume(e)};this.mute=function(a){var e=
0;"string"!==typeof a&&(a=null);if(a)return!p(a)?!1:c.sounds[a].mute();for(e=c.soundIDs.length-1;0<=e;e--)c.sounds[c.soundIDs[e]].mute();return c.muted=!0};this.muteAll=function(){c.mute()};this.unmute=function(a){"string"!==typeof a&&(a=null);if(a)return!p(a)?!1:c.sounds[a].unmute();for(a=c.soundIDs.length-1;0<=a;a--)c.sounds[c.soundIDs[a]].unmute();c.muted=!1;return!0};this.unmuteAll=function(){c.unmute()};this.toggleMute=function(a){return!p(a)?!1:c.sounds[a].toggleMute()};this.getMemoryUse=function(){var a=
0;i&&8!==j&&(a=parseInt(i._getMemoryUse(),10));return a};this.disable=function(a){var e;"undefined"===typeof a&&(a=!1);if(s)return!1;s=!0;for(e=c.soundIDs.length-1;0<=e;e--)Ga(c.sounds[c.soundIDs[e]]);L(a);o.remove(h,"load",C);return!0};this.canPlayMIME=function(a){var e;c.hasHTML5&&(e=P({type:a}));!e&&n&&(e=a&&c.ok()?!!(8<j&&a.match(Ua)||a.match(c.mimePattern)):null);return e};this.canPlayURL=function(a){var e;c.hasHTML5&&(e=P({url:a}));!e&&n&&(e=a&&c.ok()?!!a.match(c.filePattern):null);return e};
this.canPlayLink=function(a){return"undefined"!==typeof a.type&&a.type&&c.canPlayMIME(a.type)?!0:c.canPlayURL(a.href)};this.getSoundById=function(a){if(!a)throw Error("soundManager.getSoundById(): sID is null/undefined");return c.sounds[a]};this.onready=function(a,c){var d=!1;if("function"===typeof a)c||(c=h),la("onready",a,c),B();else throw u("needFunction","onready");return!0};this.ontimeout=function(a,c){var d=!1;if("function"===typeof a)c||(c=h),la("ontimeout",a,c),B({type:"ontimeout"});else throw u("needFunction",
"ontimeout");return!0};this._wD=this._writeDebug=function(){return!0};this._debug=function(){};this.reboot=function(){var a,e;for(a=c.soundIDs.length-1;0<=a;a--)c.sounds[c.soundIDs[a]].destruct();if(i)try{if(y)ra=i.innerHTML;M=i.parentNode.removeChild(i)}catch(d){}ra=M=n=null;c.enabled=oa=k=N=ta=J=K=s=c.swfLoaded=!1;c.soundIDs=[];c.sounds={};i=null;for(a in w)if(w.hasOwnProperty(a))for(e=w[a].length-1;0<=e;e--)w[a][e].fired=!1;h.setTimeout(c.beginDelayedInit,20)};this.getMoviePercent=function(){return i&&
"undefined"!==typeof i.PercentLoaded?i.PercentLoaded():null};this.beginDelayedInit=function(){ja=!0;E();setTimeout(function(){if(ta)return!1;W();V();return ta=!0},20);D()};this.destruct=function(){c.disable(!0)};Ca=function(a){var e,d,b=this,g,f,A,I,h,l,m=!1,k=[],o=0,q,s,n=null;e=null;d=null;this.sID=this.id=a.id;this.url=a.url;this._iO=this.instanceOptions=this.options=r(a);this.pan=this.options.pan;this.volume=this.options.volume;this.isHTML5=!1;this._a=null;this.id3={};this._debug=function(){};
this.load=function(a){var c=null;if("undefined"!==typeof a)b._iO=r(a,b.options),b.instanceOptions=b._iO;else if(a=b.options,b._iO=a,b.instanceOptions=b._iO,n&&n!==b.url)b._iO.url=b.url,b.url=null;if(!b._iO.url)b._iO.url=b.url;b._iO.url=aa(b._iO.url);if(b._iO.url===b.url&&0!==b.readyState&&2!==b.readyState)return 3===b.readyState&&b._iO.onload&&b._iO.onload.apply(b,[!!b.duration]),b;a=b._iO;n=b.url;b.loaded=!1;b.readyState=1;b.playState=0;b.id3={};if(ba(a)){if(c=b._setup_html5(a),!c._called_load){b._html5_canplay=
!1;if(b._a.src!==a.url)b._a.src=a.url,b.setPosition(0);b._a.autobuffer="auto";b._a.preload="auto";c._called_load=!0;a.autoPlay&&b.play()}}else try{b.isHTML5=!1,b._iO=Z(Y(a)),a=b._iO,8===j?i._load(b.id,a.url,a.stream,a.autoPlay,a.whileloading?1:0,a.loops||1,a.usePolicyFile):i._load(b.id,a.url,!!a.stream,!!a.autoPlay,a.loops||1,!!a.autoLoad,a.usePolicyFile)}catch(e){F({type:"SMSOUND_LOAD_JS_EXCEPTION",fatal:!0})}return b};this.unload=function(){if(0!==b.readyState){if(b.isHTML5){if(I(),b._a)b._a.pause(),
ua(b._a,"about:blank"),b.url="about:blank"}else 8===j?i._unload(b.id,"about:blank"):i._unload(b.id);g()}return b};this.destruct=function(a){if(b.isHTML5){if(I(),b._a)b._a.pause(),ua(b._a),z||A(),b._a._t=null,b._a=null}else b._iO.onfailure=null,i._destroySound(b.id);a||c.destroySound(b.id,!0)};this.start=this.play=function(a,c){var e,d;d=!0;d=null;c="undefined"===typeof c?!0:c;a||(a={});b._iO=r(a,b._iO);b._iO=r(b._iO,b.options);b._iO.url=aa(b._iO.url);b.instanceOptions=b._iO;if(b._iO.serverURL&&!b.connected)return b.getAutoPlay()||
b.setAutoPlay(!0),b;ba(b._iO)&&(b._setup_html5(b._iO),h());if(1===b.playState&&!b.paused)(e=b._iO.multiShot)||(d=b);if(null!==d)return d;if(!b.loaded)if(0===b.readyState){if(!b.isHTML5)b._iO.autoPlay=!0;b.load(b._iO)}else 2===b.readyState&&(d=b);if(null!==d)return d;if(!b.isHTML5&&9===j&&0<b.position&&b.position===b.duration)a.position=0;if(b.paused&&b.position&&0<b.position)b.resume();else{b._iO=r(a,b._iO);if(null!==b._iO.from&&null!==b._iO.to&&0===b.instanceCount&&0===b.playState&&!b._iO.serverURL){e=
function(){b._iO=r(a,b._iO);b.play(b._iO)};if(b.isHTML5&&!b._html5_canplay)b.load({_oncanplay:e}),d=!1;else if(!b.isHTML5&&!b.loaded&&(!b.readyState||2!==b.readyState))b.load({onload:e}),d=!1;if(null!==d)return d;b._iO=s()}(!b.instanceCount||b._iO.multiShotEvents||!b.isHTML5&&8<j&&!b.getAutoPlay())&&b.instanceCount++;b._iO.onposition&&0===b.playState&&l(b);b.playState=1;b.paused=!1;b.position="undefined"!==typeof b._iO.position&&!isNaN(b._iO.position)?b._iO.position:0;if(!b.isHTML5)b._iO=Z(Y(b._iO));
b._iO.onplay&&c&&(b._iO.onplay.apply(b),m=!0);b.setVolume(b._iO.volume,!0);b.setPan(b._iO.pan,!0);b.isHTML5?(h(),d=b._setup_html5(),b.setPosition(b._iO.position),d.play()):(d=i._start(b.id,b._iO.loops||1,9===j?b._iO.position:b._iO.position/1E3,b._iO.multiShot),9===j&&!d&&b._iO.onplayerror&&b._iO.onplayerror.apply(b))}return b};this.stop=function(a){var c=b._iO;if(1===b.playState){b._onbufferchange(0);b._resetOnPosition(0);b.paused=!1;if(!b.isHTML5)b.playState=0;q();c.to&&b.clearOnPosition(c.to);if(b.isHTML5){if(b._a)a=
b.position,b.setPosition(0),b.position=a,b._a.pause(),b.playState=0,b._onTimer(),I()}else i._stop(b.id,a),c.serverURL&&b.unload();b.instanceCount=0;b._iO={};c.onstop&&c.onstop.apply(b)}return b};this.setAutoPlay=function(a){b._iO.autoPlay=a;b.isHTML5||(i._setAutoPlay(b.id,a),a&&!b.instanceCount&&1===b.readyState&&b.instanceCount++)};this.getAutoPlay=function(){return b._iO.autoPlay};this.setPosition=function(a){"undefined"===typeof a&&(a=0);var c=b.isHTML5?Math.max(a,0):Math.min(b.duration||b._iO.duration,
Math.max(a,0));b.position=c;a=b.position/1E3;b._resetOnPosition(b.position);b._iO.position=c;if(b.isHTML5){if(b._a&&b._html5_canplay&&b._a.currentTime!==a)try{b._a.currentTime=a,(0===b.playState||b.paused)&&b._a.pause()}catch(e){}}else a=9===j?b.position:a,b.readyState&&2!==b.readyState&&i._setPosition(b.id,a,b.paused||!b.playState,b._iO.multiShot);b.isHTML5&&b.paused&&b._onTimer(!0);return b};this.pause=function(a){if(b.paused||0===b.playState&&1!==b.readyState)return b;b.paused=!0;b.isHTML5?(b._setup_html5().pause(),
I()):(a||"undefined"===typeof a)&&i._pause(b.id,b._iO.multiShot);b._iO.onpause&&b._iO.onpause.apply(b);return b};this.resume=function(){var a=b._iO;if(!b.paused)return b;b.paused=!1;b.playState=1;b.isHTML5?(b._setup_html5().play(),h()):(a.isMovieStar&&!a.serverURL&&b.setPosition(b.position),i._pause(b.id,a.multiShot));!m&&a.onplay?(a.onplay.apply(b),m=!0):a.onresume&&a.onresume.apply(b);return b};this.togglePause=function(){if(0===b.playState)return b.play({position:9===j&&!b.isHTML5?b.position:b.position/
1E3}),b;b.paused?b.resume():b.pause();return b};this.setPan=function(a,c){"undefined"===typeof a&&(a=0);"undefined"===typeof c&&(c=!1);b.isHTML5||i._setPan(b.id,a);b._iO.pan=a;if(!c)b.pan=a,b.options.pan=a;return b};this.setVolume=function(a,e){"undefined"===typeof a&&(a=100);"undefined"===typeof e&&(e=!1);if(b.isHTML5){if(b._a)b._a.volume=Math.max(0,Math.min(1,a/100))}else i._setVolume(b.id,c.muted&&!b.muted||b.muted?0:a);b._iO.volume=a;if(!e)b.volume=a,b.options.volume=a;return b};this.mute=function(){b.muted=
!0;if(b.isHTML5){if(b._a)b._a.muted=!0}else i._setVolume(b.id,0);return b};this.unmute=function(){b.muted=!1;var a="undefined"!==typeof b._iO.volume;if(b.isHTML5){if(b._a)b._a.muted=!1}else i._setVolume(b.id,a?b._iO.volume:b.options.volume);return b};this.toggleMute=function(){return b.muted?b.unmute():b.mute()};this.onposition=this.onPosition=function(a,c,e){k.push({position:parseInt(a,10),method:c,scope:"undefined"!==typeof e?e:b,fired:!1});return b};this.clearOnPosition=function(b,a){var c,b=parseInt(b,
10);if(isNaN(b))return!1;for(c=0;c<k.length;c++)if(b===k[c].position&&(!a||a===k[c].method))k[c].fired&&o--,k.splice(c,1)};this._processOnPosition=function(){var a,c;a=k.length;if(!a||!b.playState||o>=a)return!1;for(a-=1;0<=a;a--)if(c=k[a],!c.fired&&b.position>=c.position)c.fired=!0,o++,c.method.apply(c.scope,[c.position]);return!0};this._resetOnPosition=function(b){var a,c;a=k.length;if(!a)return!1;for(a-=1;0<=a;a--)if(c=k[a],c.fired&&b<=c.position)c.fired=!1,o--;return!0};s=function(){var a=b._iO,
c=a.from,e=a.to,d,f;f=function(){b.clearOnPosition(e,f);b.stop()};d=function(){if(null!==e&&!isNaN(e))b.onPosition(e,f)};if(null!==c&&!isNaN(c))a.position=c,a.multiShot=!1,d();return a};l=function(){var a,c=b._iO.onposition;if(c)for(a in c)if(c.hasOwnProperty(a))b.onPosition(parseInt(a,10),c[a])};q=function(){var a,c=b._iO.onposition;if(c)for(a in c)c.hasOwnProperty(a)&&b.clearOnPosition(parseInt(a,10))};h=function(){b.isHTML5&&Ia(b)};I=function(){b.isHTML5&&Ja(b)};g=function(a){a||(k=[],o=0);m=!1;
b._hasTimer=null;b._a=null;b._html5_canplay=!1;b.bytesLoaded=null;b.bytesTotal=null;b.duration=b._iO&&b._iO.duration?b._iO.duration:null;b.durationEstimate=null;b.buffered=[];b.eqData=[];b.eqData.left=[];b.eqData.right=[];b.failures=0;b.isBuffering=!1;b.instanceOptions={};b.instanceCount=0;b.loaded=!1;b.metadata={};b.readyState=0;b.muted=!1;b.paused=!1;b.peakData={left:0,right:0};b.waveformData={left:[],right:[]};b.playState=0;b.position=null;b.id3={}};g();this._onTimer=function(a){var c,f=!1,g={};
if(b._hasTimer||a){if(b._a&&(a||(0<b.playState||1===b.readyState)&&!b.paused)){c=b._get_html5_duration();if(c!==e)e=c,b.duration=c,f=!0;b.durationEstimate=b.duration;c=1E3*b._a.currentTime||0;c!==d&&(d=c,f=!0);(f||a)&&b._whileplaying(c,g,g,g,g)}return f}};this._get_html5_duration=function(){var a=b._iO,c=b._a?1E3*b._a.duration:a?a.duration:void 0;return c&&!isNaN(c)&&Infinity!==c?c:a?a.duration:null};this._apply_loop=function(b,a){b.loop=1<a?"loop":""};this._setup_html5=function(a){var a=r(b._iO,
a),e=decodeURI,d=z?c._global_a:b._a,i=e(a.url),h=d&&d._t?d._t.instanceOptions:null,A;if(d){if(d._t){if(!z&&i===e(n))A=d;else if(z&&h.url===a.url&&(!n||n===h.url))A=d;if(A)return b._apply_loop(d,a.loops),A}z&&d._t&&d._t.playState&&a.url!==h.url&&d._t.stop();g(h&&h.url?a.url===h.url:n?n===a.url:!1);d.src=a.url;n=b.url=a.url;d._called_load=!1}else if(b._a=a.autoLoad||a.autoPlay?new Audio(a.url):Pa?new Audio(null):new Audio,d=b._a,d._called_load=!1,z)c._global_a=d;b.isHTML5=!0;b._a=d;d._t=b;f();b._apply_loop(d,
a.loops);a.autoLoad||a.autoPlay?b.load():(d.autobuffer=!1,d.preload="auto");return d};f=function(){if(b._a._added_events)return!1;var a;b._a._added_events=!0;for(a in v)v.hasOwnProperty(a)&&b._a&&b._a.addEventListener(a,v[a],!1);return!0};A=function(){var a;b._a._added_events=!1;for(a in v)v.hasOwnProperty(a)&&b._a&&b._a.removeEventListener(a,v[a],!1)};this._onload=function(a){a=!!a||!b.isHTML5&&8===j&&b.duration;b.loaded=a;b.readyState=a?3:2;b._onbufferchange(0);b._iO.onload&&b._iO.onload.apply(b,
[a]);return!0};this._onbufferchange=function(a){if(0===b.playState||a&&b.isBuffering||!a&&!b.isBuffering)return!1;b.isBuffering=1===a;b._iO.onbufferchange&&b._iO.onbufferchange.apply(b);return!0};this._onsuspend=function(){b._iO.onsuspend&&b._iO.onsuspend.apply(b);return!0};this._onfailure=function(a,c,e){b.failures++;if(b._iO.onfailure&&1===b.failures)b._iO.onfailure(b,a,c,e)};this._onfinish=function(){var a=b._iO.onfinish;b._onbufferchange(0);b._resetOnPosition(0);if(b.instanceCount){b.instanceCount--;
if(!b.instanceCount&&(q(),b.playState=0,b.paused=!1,b.instanceCount=0,b.instanceOptions={},b._iO={},I(),b.isHTML5))b.position=0;(!b.instanceCount||b._iO.multiShotEvents)&&a&&a.apply(b)}};this._whileloading=function(a,c,e,d){var f=b._iO;b.bytesLoaded=a;b.bytesTotal=c;b.duration=Math.floor(e);b.bufferLength=d;if(f.isMovieStar)b.durationEstimate=b.duration;else if(b.durationEstimate=f.duration?b.duration>f.duration?b.duration:f.duration:parseInt(b.bytesTotal/b.bytesLoaded*b.duration,10),"undefined"===
typeof b.durationEstimate)b.durationEstimate=b.duration;if(!b.isHTML5)b.buffered=[{start:0,end:b.duration}];(3!==b.readyState||b.isHTML5)&&f.whileloading&&f.whileloading.apply(b)};this._whileplaying=function(a,c,e,d,f){var g=b._iO;if(isNaN(a)||null===a)return!1;b.position=Math.max(0,a);b._processOnPosition();if(!b.isHTML5&&8<j){if(g.usePeakData&&"undefined"!==typeof c&&c)b.peakData={left:c.leftPeak,right:c.rightPeak};if(g.useWaveformData&&"undefined"!==typeof e&&e)b.waveformData={left:e.split(","),
right:d.split(",")};if(g.useEQData&&"undefined"!==typeof f&&f&&f.leftEQ&&(a=f.leftEQ.split(","),b.eqData=a,b.eqData.left=a,"undefined"!==typeof f.rightEQ&&f.rightEQ))b.eqData.right=f.rightEQ.split(",")}1===b.playState&&(!b.isHTML5&&8===j&&!b.position&&b.isBuffering&&b._onbufferchange(0),g.whileplaying&&g.whileplaying.apply(b));return!0};this._oncaptiondata=function(a){b.captiondata=a;b._iO.oncaptiondata&&b._iO.oncaptiondata.apply(b)};this._onmetadata=function(a,c){var e={},d,f;for(d=0,f=a.length;d<
f;d++)e[a[d]]=c[d];b.metadata=e;b._iO.onmetadata&&b._iO.onmetadata.apply(b)};this._onid3=function(a,c){var e=[],d,f;for(d=0,f=a.length;d<f;d++)e[a[d]]=c[d];b.id3=r(b.id3,e);b._iO.onid3&&b._iO.onid3.apply(b)};this._onconnect=function(a){a=1===a;if(b.connected=a)b.failures=0,p(b.id)&&(b.getAutoPlay()?b.play(void 0,b.getAutoPlay()):b._iO.autoLoad&&b.load()),b._iO.onconnect&&b._iO.onconnect.apply(b,[a])};this._ondataerror=function(){0<b.playState&&b._iO.ondataerror&&b._iO.ondataerror.apply(b)}};pa=function(){return l.body||
l._docElement||l.getElementsByTagName("div")[0]};S=function(a){return l.getElementById(a)};r=function(a,e){var d=a||{},b,g;b="undefined"===typeof e?c.defaultOptions:e;for(g in b)b.hasOwnProperty(g)&&"undefined"===typeof d[g]&&(d[g]="object"!==typeof b[g]||null===b[g]?b[g]:r(d[g],b[g]));return d};T={onready:1,ontimeout:1,defaultOptions:1,flash9Options:1,movieStarOptions:1};ka=function(a,e){var d,b=!0,g="undefined"!==typeof e,f=c.setupOptions;for(d in a)if(a.hasOwnProperty(d))if("object"!==typeof a[d]||
null===a[d]||a[d]instanceof Array)g&&"undefined"!==typeof T[e]?c[e][d]=a[d]:"undefined"!==typeof f[d]?(c.setupOptions[d]=a[d],c[d]=a[d]):"undefined"===typeof T[d]?(H(u("undefined"===typeof c[d]?"setupUndef":"setupError",d),2),b=!1):c[d]instanceof Function?c[d].apply(c,a[d]instanceof Array?a[d]:[a[d]]):c[d]=a[d];else if("undefined"===typeof T[d])H(u("undefined"===typeof c[d]?"setupUndef":"setupError",d),2),b=!1;else return ka(a[d],d);return b};o=function(){function a(a){var a=Wa.call(a),b=a.length;
d?(a[1]="on"+a[1],3<b&&a.pop()):3===b&&a.push(!1);return a}function c(a,e){var h=a.shift(),i=[b[e]];if(d)h[i](a[0],a[1]);else h[i].apply(h,a)}var d=h.attachEvent,b={add:d?"attachEvent":"addEventListener",remove:d?"detachEvent":"removeEventListener"};return{add:function(){c(a(arguments),"add")},remove:function(){c(a(arguments),"remove")}}}();v={abort:m(function(){}),canplay:m(function(){var a=this._t,c;if(a._html5_canplay)return!0;a._html5_canplay=!0;a._onbufferchange(0);c="undefined"!==typeof a._iO.position&&
!isNaN(a._iO.position)?a._iO.position/1E3:null;if(a.position&&this.currentTime!==c)try{this.currentTime=c}catch(d){}a._iO._oncanplay&&a._iO._oncanplay()}),canplaythrough:m(function(){var a=this._t;a.loaded||(a._onbufferchange(0),a._whileloading(a.bytesLoaded,a.bytesTotal,a._get_html5_duration()),a._onload(!0))}),ended:m(function(){this._t._onfinish()}),error:m(function(){this._t._onload(!1)}),loadeddata:m(function(){var a=this._t;if(!a._loaded&&!ya)a.duration=a._get_html5_duration()}),loadedmetadata:m(function(){}),
loadstart:m(function(){this._t._onbufferchange(1)}),play:m(function(){this._t._onbufferchange(0)}),playing:m(function(){this._t._onbufferchange(0)}),progress:m(function(a){var c=this._t,d,b,g=0,g=a.target.buffered;d=a.loaded||0;var f=a.total||1;c.buffered=[];if(g&&g.length){for(d=0,b=g.length;d<b;d++)c.buffered.push({start:g.start(d),end:g.end(d)});g=g.end(0)-g.start(0);d=g/a.target.duration}isNaN(d)||(c._onbufferchange(0),c._whileloading(d,f,c._get_html5_duration()),d&&f&&d===f&&v.canplaythrough.call(this,
a))}),ratechange:m(function(){}),suspend:m(function(a){var c=this._t;v.progress.call(this,a);c._onsuspend()}),stalled:m(function(){}),timeupdate:m(function(){this._t._onTimer()}),waiting:m(function(){this._t._onbufferchange(1)})};ba=function(a){return a.serverURL||a.type&&R(a.type)?!1:a.type?P({type:a.type}):P({url:a.url})||c.html5Only};ua=function(a,c){if(a)a.src=c};P=function(a){if(!c.useHTML5Audio||!c.hasHTML5)return!1;var e=a.url||null,a=a.type||null,d=c.audioFormats,b;if(a&&"undefined"!==typeof c.html5[a])return c.html5[a]&&
!R(a);if(!x){x=[];for(b in d)d.hasOwnProperty(b)&&(x.push(b),d[b].related&&(x=x.concat(d[b].related)));x=RegExp("\\.("+x.join("|")+")(\\?.*)?$","i")}b=e?e.toLowerCase().match(x):null;!b||!b.length?a&&(e=a.indexOf(";"),b=(-1!==e?a.substr(0,e):a).substr(6)):b=b[1];b&&"undefined"!==typeof c.html5[b]?e=c.html5[b]&&!R(b):(a="audio/"+b,e=c.html5.canPlayType({type:a}),e=(c.html5[b]=e)&&c.html5[a]&&!R(a));return e};Ma=function(){function a(a){var b,d,f=b=!1;if(!e||"function"!==typeof e.canPlayType)return b;
if(a instanceof Array){for(b=0,d=a.length;b<d&&!f;b++)if(c.html5[a[b]]||e.canPlayType(a[b]).match(c.html5Test))f=!0,c.html5[a[b]]=!0,c.flash[a[b]]=!!a[b].match(Sa);b=f}else a=e&&"function"===typeof e.canPlayType?e.canPlayType(a):!1,b=!(!a||!a.match(c.html5Test));return b}if(!c.useHTML5Audio||"undefined"===typeof Audio)return!1;var e="undefined"!==typeof Audio?Pa?new Audio(null):new Audio:null,d,b,g={},f;f=c.audioFormats;for(d in f)if(f.hasOwnProperty(d)&&(b="audio/"+d,g[d]=a(f[d].type),g[b]=g[d],
d.match(Sa)?(c.flash[d]=!0,c.flash[b]=!0):(c.flash[d]=!1,c.flash[b]=!1),f[d]&&f[d].related))for(b=f[d].related.length-1;0<=b;b--)g["audio/"+f[d].related[b]]=g[d],c.html5[f[d].related[b]]=g[d],c.flash[f[d].related[b]]=g[d];g.canPlayType=e?a:null;c.html5=r(c.html5,g);return!0};u=function(){};Y=function(a){if(8===j&&1<a.loops&&a.stream)a.stream=!1;return a};Z=function(a){if(a&&!a.usePolicyFile&&(a.onid3||a.usePeakData||a.useWaveformData||a.useEQData))a.usePolicyFile=!0;return a};H=function(){};ha=function(){return!1};
Ga=function(a){for(var c in a)a.hasOwnProperty(c)&&"function"===typeof a[c]&&(a[c]=ha)};X=function(a){"undefined"===typeof a&&(a=!1);(s||a)&&c.disable(a)};Ha=function(a){var e=null;if(a)if(a.match(/\.swf(\?.*)?$/i)){if(e=a.substr(a.toLowerCase().lastIndexOf(".swf?")+4))return a}else a.lastIndexOf("/")!==a.length-1&&(a+="/");a=(a&&-1!==a.lastIndexOf("/")?a.substr(0,a.lastIndexOf("/")+1):"./")+c.movieURL;c.noSWFCache&&(a+="?ts="+(new Date).getTime());return a};ma=function(){j=parseInt(c.flashVersion,
10);if(8!==j&&9!==j)c.flashVersion=j=8;var a=c.debugMode||c.debugFlash?"_debug.swf":".swf";if(c.useHTML5Audio&&!c.html5Only&&c.audioFormats.mp4.required&&9>j)c.flashVersion=j=9;c.version=c.versionNumber+(c.html5Only?" (HTML5-only mode)":9===j?" (AS3/Flash 9)":" (AS2/Flash 8)");8<j?(c.defaultOptions=r(c.defaultOptions,c.flash9Options),c.features.buffering=!0,c.defaultOptions=r(c.defaultOptions,c.movieStarOptions),c.filePatterns.flash9=RegExp("\\.(mp3|"+Va.join("|")+")(\\?.*)?$","i"),c.features.movieStar=
!0):c.features.movieStar=!1;c.filePattern=c.filePatterns[8!==j?"flash9":"flash8"];c.movieURL=(8===j?"soundmanager2.swf":"soundmanager2_flash9.swf").replace(".swf",a);c.features.peakData=c.features.waveformData=c.features.eqData=8<j};Fa=function(a,c){if(!i)return!1;i._setPolling(a,c)};qa=function(){if(c.debugURLParam.test(ga))c.debugMode=!0};p=this.getSoundById;G=function(){var a=[];c.debugMode&&a.push("sm2_debug");c.debugFlash&&a.push("flash_debug");c.useHighPerformance&&a.push("high_performance");
return a.join(" ")};sa=function(){u("fbHandler");var a=c.getMoviePercent(),e={type:"FLASHBLOCK"};if(c.html5Only)return!1;if(c.ok()){if(c.oMC)c.oMC.className=[G(),"movieContainer","swf_loaded"+(c.didFlashBlock?" swf_unblocked":"")].join(" ")}else{if(n)c.oMC.className=G()+" movieContainer "+(null===a?"swf_timedout":"swf_error");c.didFlashBlock=!0;B({type:"ontimeout",ignoreInit:!0,error:e});F(e)}};la=function(a,c,d){"undefined"===typeof w[a]&&(w[a]=[]);w[a].push({method:c,scope:d||null,fired:!1})};B=
function(a){a||(a={type:c.ok()?"onready":"ontimeout"});if(!k&&a&&!a.ignoreInit||"ontimeout"===a.type&&(c.ok()||s&&!a.ignoreInit))return!1;var e={success:a&&a.ignoreInit?c.ok():!s},d=a&&a.type?w[a.type]||[]:[],b=[],g,e=[e],f=n&&c.useFlashBlock&&!c.ok();if(a.error)e[0].error=a.error;for(a=0,g=d.length;a<g;a++)!0!==d[a].fired&&b.push(d[a]);if(b.length)for(a=0,g=b.length;a<g;a++)if(b[a].scope?b[a].method.apply(b[a].scope,e):b[a].method.apply(this,e),!f)b[a].fired=!0;return!0};C=function(){h.setTimeout(function(){c.useFlashBlock&&
sa();B();"function"===typeof c.onload&&c.onload.apply(h);c.waitForWindowLoad&&o.add(h,"load",C)},1)};wa=function(){if("undefined"!==typeof t)return t;var a=!1,c=navigator,d=c.plugins,b,g=h.ActiveXObject;if(d&&d.length)(c=c.mimeTypes)&&c["application/x-shockwave-flash"]&&c["application/x-shockwave-flash"].enabledPlugin&&c["application/x-shockwave-flash"].enabledPlugin.description&&(a=!0);else if("undefined"!==typeof g){try{b=new g("ShockwaveFlash.ShockwaveFlash")}catch(f){}a=!!b}return t=a};La=function(){var a,
e,d=c.audioFormats;if(xa&&q.match(/os (1|2|3_0|3_1)/i)){if(c.hasHTML5=!1,c.html5Only=!0,c.oMC)c.oMC.style.display="none"}else if(c.useHTML5Audio)c.hasHTML5=!c.html5||!c.html5.canPlayType?!1:!0;if(c.useHTML5Audio&&c.hasHTML5)for(e in d)if(d.hasOwnProperty(e)&&(d[e].required&&!c.html5.canPlayType(d[e].type)||c.preferFlash&&(c.flash[e]||c.flash[d[e].type])))a=!0;c.ignoreFlash&&(a=!1);c.html5Only=c.hasHTML5&&c.useHTML5Audio&&!a;return!c.html5Only};aa=function(a){var e,d,b=0;if(a instanceof Array){for(e=
0,d=a.length;e<d;e++)if(a[e]instanceof Object){if(c.canPlayMIME(a[e].type)){b=e;break}}else if(c.canPlayURL(a[e])){b=e;break}if(a[b].url)a[b]=a[b].url;a=a[b]}return a};Ia=function(a){if(!a._hasTimer)a._hasTimer=!0,!za&&c.html5PollingInterval&&(null===O&&0===$&&(O=h.setInterval(Ka,c.html5PollingInterval)),$++)};Ja=function(a){if(a._hasTimer)a._hasTimer=!1,!za&&c.html5PollingInterval&&$--};Ka=function(){var a;if(null!==O&&!$)return h.clearInterval(O),O=null,!1;for(a=c.soundIDs.length-1;0<=a;a--)c.sounds[c.soundIDs[a]].isHTML5&&
c.sounds[c.soundIDs[a]]._hasTimer&&c.sounds[c.soundIDs[a]]._onTimer()};F=function(a){a="undefined"!==typeof a?a:{};"function"===typeof c.onerror&&c.onerror.apply(h,[{type:"undefined"!==typeof a.type?a.type:null}]);"undefined"!==typeof a.fatal&&a.fatal&&c.disable()};Na=function(){if(!Qa||!wa())return!1;var a=c.audioFormats,e,d;for(d in a)if(a.hasOwnProperty(d)&&("mp3"===d||"mp4"===d))if(c.html5[d]=!1,a[d]&&a[d].related)for(e=a[d].related.length-1;0<=e;e--)c.html5[a[d].related[e]]=!1};this._setSandboxType=
function(){};this._externalInterfaceOK=function(){if(c.swfLoaded)return!1;(new Date).getTime();c.swfLoaded=!0;ca=!1;Qa&&Na();setTimeout(ia,y?100:1)};W=function(a,e){function d(a,b){return'<param name="'+a+'" value="'+b+'" />'}if(J&&K)return!1;if(c.html5Only)return ma(),c.oMC=S(c.movieID),ia(),K=J=!0,!1;var b=e||c.url,g=c.altURL||b,f;f=pa();var h,i,j=G(),k,m=null,m=(m=l.getElementsByTagName("html")[0])&&m.dir&&m.dir.match(/rtl/i),a="undefined"===typeof a?c.id:a;ma();c.url=Ha(Ba?b:g);e=c.url;c.wmode=
!c.wmode&&c.useHighPerformance?"transparent":c.wmode;if(null!==c.wmode&&(q.match(/msie 8/i)||!y&&!c.useHighPerformance)&&navigator.platform.match(/win32|win64/i))c.wmode=null;f={name:a,id:a,src:e,quality:"high",allowScriptAccess:c.allowScriptAccess,bgcolor:c.bgColor,pluginspage:Ta+"www.macromedia.com/go/getflashplayer",title:"JS/Flash audio component (SoundManager 2)",type:"application/x-shockwave-flash",wmode:c.wmode,hasPriority:"true"};if(c.debugFlash)f.FlashVars="debug=1";c.wmode||delete f.wmode;
if(y)b=l.createElement("div"),i=['<object id="'+a+'" data="'+e+'" type="'+f.type+'" title="'+f.title+'" classid="clsid:D27CDB6E-AE6D-11cf-96B8-444553540000" codebase="'+Ta+'download.macromedia.com/pub/shockwave/cabs/flash/swflash.cab#version=6,0,40,0">',d("movie",e),d("AllowScriptAccess",c.allowScriptAccess),d("quality",f.quality),c.wmode?d("wmode",c.wmode):"",d("bgcolor",c.bgColor),d("hasPriority","true"),c.debugFlash?d("FlashVars",f.FlashVars):"","</object>"].join("");else for(h in b=l.createElement("embed"),
f)f.hasOwnProperty(h)&&b.setAttribute(h,f[h]);qa();j=G();if(f=pa())if(c.oMC=S(c.movieID)||l.createElement("div"),c.oMC.id){k=c.oMC.className;c.oMC.className=(k?k+" ":"movieContainer")+(j?" "+j:"");c.oMC.appendChild(b);if(y)h=c.oMC.appendChild(l.createElement("div")),h.className="sm2-object-box",h.innerHTML=i;K=!0}else{c.oMC.id=c.movieID;c.oMC.className="movieContainer "+j;h=j=null;if(!c.useFlashBlock)if(c.useHighPerformance)j={position:"fixed",width:"8px",height:"8px",bottom:"0px",left:"0px",overflow:"hidden"};
else if(j={position:"absolute",width:"6px",height:"6px",top:"-9999px",left:"-9999px"},m)j.left=Math.abs(parseInt(j.left,10))+"px";if(Xa)c.oMC.style.zIndex=1E4;if(!c.debugFlash)for(k in j)j.hasOwnProperty(k)&&(c.oMC.style[k]=j[k]);try{y||c.oMC.appendChild(b);f.appendChild(c.oMC);if(y)h=c.oMC.appendChild(l.createElement("div")),h.className="sm2-object-box",h.innerHTML=i;K=!0}catch(n){throw Error(u("domError")+" \n"+n.toString());}}return J=!0};V=function(){if(c.html5Only)return W(),!1;if(i)return!1;
i=c.getMovie(c.id);if(!i)M?(y?c.oMC.innerHTML=ra:c.oMC.appendChild(M),M=null,J=!0):W(c.id,c.url),i=c.getMovie(c.id);"function"===typeof c.oninitmovie&&setTimeout(c.oninitmovie,1);return!0};D=function(){setTimeout(Ea,1E3)};Ea=function(){var a,e=!1;if(N)return!1;N=!0;o.remove(h,"load",D);if(ca&&!Aa)return!1;k||(a=c.getMoviePercent(),0<a&&100>a&&(e=!0));setTimeout(function(){a=c.getMoviePercent();if(e)return N=!1,h.setTimeout(D,1),!1;!k&&Ra&&(null===a?c.useFlashBlock||0===c.flashLoadTimeout?c.useFlashBlock&&
sa():X(!0):0!==c.flashLoadTimeout&&X(!0))},c.flashLoadTimeout)};U=function(){if(Aa||!ca)return o.remove(h,"focus",U),!0;Aa=Ra=!0;N=!1;D();o.remove(h,"focus",U);return!0};Oa=function(){var a,e=[];if(c.useHTML5Audio&&c.hasHTML5)for(a in c.audioFormats)c.audioFormats.hasOwnProperty(a)&&e.push(a+": "+c.html5[a]+(!c.html5[a]&&t&&c.flash[a]?" (using flash)":c.preferFlash&&c.flash[a]&&t?" (preferring flash)":!c.html5[a]?" ("+(c.audioFormats[a].required?"required, ":"")+"and no flash support)":""))};L=function(a){if(k)return!1;
if(c.html5Only)return k=!0,C(),!0;var e=!0,d;if(!c.useFlashBlock||!c.flashLoadTimeout||c.getMoviePercent())k=!0,s&&(d={type:!t&&n?"NO_FLASH":"INIT_TIMEOUT"});if(s||a){if(c.useFlashBlock&&c.oMC)c.oMC.className=G()+" "+(null===c.getMoviePercent()?"swf_timedout":"swf_error");B({type:"ontimeout",error:d,ignoreInit:!0});F(d);e=!1}s||(c.waitForWindowLoad&&!ja?o.add(h,"load",C):C());return e};Da=function(){var a,e=c.setupOptions;for(a in e)e.hasOwnProperty(a)&&("undefined"===typeof c[a]?c[a]=e[a]:c[a]!==
e[a]&&(c.setupOptions[a]=c[a]))};ia=function(){if(k)return!1;if(c.html5Only){if(!k)o.remove(h,"load",c.beginDelayedInit),c.enabled=!0,L();return!0}V();try{i._externalInterfaceTest(!1),Fa(!0,c.flashPollingInterval||(c.useHighPerformance?10:50)),c.debugMode||i._disableDebug(),c.enabled=!0,c.html5Only||o.add(h,"unload",ha)}catch(a){return F({type:"JS_TO_FLASH_EXCEPTION",fatal:!0}),X(!0),L(),!1}L();o.remove(h,"load",c.beginDelayedInit);return!0};E=function(){if(oa)return!1;oa=!0;Da();qa();!t&&c.hasHTML5&&
c.setup({useHTML5Audio:!0,preferFlash:!1});Ma();c.html5.usingFlash=La();n=c.html5.usingFlash;Oa();!t&&n&&c.setup({flashLoadTimeout:1});l.removeEventListener&&l.removeEventListener("DOMContentLoaded",E,!1);V();return!0};va=function(){"complete"===l.readyState&&(E(),l.detachEvent("onreadystatechange",va));return!0};na=function(){ja=!0;o.remove(h,"load",na)};wa();o.add(h,"focus",U);o.add(h,"load",D);o.add(h,"load",na);l.addEventListener?l.addEventListener("DOMContentLoaded",E,!1):l.attachEvent?l.attachEvent("onreadystatechange",
va):F({type:"NO_DOM2_EVENTS",fatal:!0});"complete"===l.readyState&&setTimeout(E,100)}var da=null;if("undefined"===typeof SM2_DEFER||!SM2_DEFER)da=new Q;ea.SoundManager=Q;ea.soundManager=da})(window);

Binary file not shown.

25
src/server/futils.co Normal file
View file

@ -0,0 +1,25 @@
const
path = require('path')
Mpd = require '../mpd.js/lib/mpd'
# http://msdn.microsoft.com/en-us/library/windows/desktop/aa365247%28v=vs.85%29.aspx
# this is a good start
export function safePath(string)
string.replace(/[<>:"\/\\|?*%]/g, "_")
export function getSuggestedPath(track, default_name=Mpd.trackNameFromFile(track.file))
p = ""
p = path.join(p, safePath(track.album_artist_name)) if track.album_artist_name
p = path.join(p, safePath(track.album_name)) if track.album_name
t = ""
t += safePath(zfill(track.track)) + " " if track.track?
if track.name is Mpd.trackNameFromFile(track.file)
t += safePath(default_name)
else
t += track.name + path.extname(track.file)
path.join(p, t)
function zfill (n)
(if n < 10 then "0" else "") + n

42
src/server/mpdconf.co Normal file
View file

@ -0,0 +1,42 @@
# parses an mpd conf file, returning an object with all the values
export function parse (file_contents)
obj = {}
stack = []
audio_outputs = []
for line of file_contents.split("\n")
line = line.trim()
continue if line.length is 0
continue if line.substring(0, 1) is "#"
if line.substring(0, 1) is "}"
obj = stack.pop()
else
parts = line.match(/([^\s]*)\s+([^#]*)/)
key = parts[1]
val = parts[2]
if val is "{"
stack.push obj
if key is 'audio_output'
audio_outputs.push new_obj = {}
else
obj[key] = new_obj = {}
obj = new_obj
else
val = JSON.parse(val)
if key is 'bind_to_address'
obj[key] ?= {}
if val[0] is '/'
obj[key].unix_socket = val
else
obj[key].network = val
else if key is 'password'
(obj[key] ||= []).push val
else
obj[key] = val
# arrange audio_outputs by type
obj.audio_output = {}
for audio_output of audio_outputs
obj.audio_output[audio_output.type] = audio_output
return obj

14
src/server/plugin.co Normal file
View file

@ -0,0 +1,14 @@
const {EventEmitter} = require('events')
# events emitted: ['state_changed', 'status_changed']
module.exports = class extends EventEmitter
~>
@mpd = null
@conf = null
@is_enabled = true
@checkEnabledMiddleware = !(req, resp, next) ~>
if @is_enabled
next()
else
resp.writeHead 500, {'content-type': 'text/json'}
resp.end JSON.stringify {success: false, reason: "DisabledEndpoint"}

View file

@ -0,0 +1,67 @@
Plugin = require('../plugin')
const
CHATS_LIMIT = 100
USER_NAME_LIMIT = 20
module.exports = class Chat extends Plugin
(bus) ~>
super(...)
# the online users list is always blank at startup
@users = []
bus.on 'save_state', @~saveState
bus.on 'restore_state', @~restoreState
bus.on 'socket_connect', @~onSocketConnection
bus.on 'mpd', @~setMpd
restoreState: !(state) ->
@next_user_id = state.next_user_id ? 0
@user_names = state.status.user_names ? {}
@chats = state.status.chats ? []
saveState: !(state) ->
state.next_user_id = @next_user_id
state.status.users = @users
state.status.user_names = @user_names
state.status.chats = @chats
setMpd: !(@mpd) ->
@mpd.on 'chat', @~scrubStaleUserNames
onSocketConnection: !(socket) ->
user_id = "user_" + @next_user_id
@next_user_id += 1
@users.push user_id
socket.emit 'Identify', user_id
socket.on 'Chat', !(msg) ~>
chat_object =
user_id: user_id
message: msg
console.info "chat: #{@user_names[user_id]}: #{msg}"
@chats.push(chat_object)
@chats.splice(0, @chats.length - CHATS_LIMIT) if @chats.length > CHATS_LIMIT
@emit('status_changed')
socket.on 'SetUserName', !(data) ~>
user_name = data.trim().replace(/\s+/g, " ")
if user_name is not ""
user_name = user_name.substr(0, USER_NAME_LIMIT)
@user_names[user_id] = user_name
else
delete @user_names[user_id]
@emit('status_changed')
socket.on 'disconnect', !~>
@users = (id if id is not user_id for id of @users)
@scrubStaleUserNames()
@emit('status_changed')
scrubStaleUserNames: !->
keep_user_ids = {}
for user_id of @users
keep_user_ids[user_id] = true
for chat_object of @chats
keep_user_ids[chat_object.user_id] = true
for user_id in @user_names
delete @user_names[user_id] unless keep_user_ids[user_id]
@emit('status_changed')

View file

@ -0,0 +1,42 @@
fs = require('fs')
path = require('path')
Plugin = require('../plugin')
# ability to delete songs from your library
module.exports = class extends Plugin
(bus) ~>
super(...)
bus.on 'save_state', @~saveState
bus.on 'mpd_conf', @~setConf
bus.on 'mpd', @~setMpd
bus.on 'socket_connect', @~onSocketConnection
saveState: !(state) ->
state.status.delete_enabled = @is_enabled
onSocketConnection: !(socket, getPermissions) ->
socket.on 'DeleteFromLibrary', !(data) ~>
if not getPermissions().admin
console.warn "User without admin permission trying to delete songs"
return
files = JSON.parse data
file = null
!~function next (err)
if err
console.error "deleting #{file}: #{err.stack}"
else if file?
console.info "deleted #{file}"
if (file = files.shift())!?
@mpd.scanFiles files
else # tail call recursion, bitch
fs.unlink path.join(@music_lib_path, file), next
next()
setMpd: !(@mpd) ->
setConf: !(conf, conf_path) ->
if conf.music_directory?
@music_lib_path = conf.music_directory
else
@is_enabled = false
console.warn "Delete disabled - music directory not found in #{conf_path}"

View file

@ -0,0 +1,100 @@
const
Plugin = require('../plugin')
fs = require 'fs'
zipstream = require 'zipstream'
path = require 'path'
{safePath} = require '../futils'
express = require 'express'
findit = require 'findit'
module.exports = class Download extends Plugin
(bus) ~>
super(...)
@is_enabled = false
@is_ready = false # not until we set up library link
bus.on 'save_state', (state) ~>
state.status.download_enabled = @is_enabled
bus.on 'mpd_conf', (conf, conf_path) ~>
@is_enabled = true
unless (@music_directory = conf.music_directory)?
@is_enabled = false
console.warn "music_directory not found in #{conf_path}. Download disabled."
return
bus.on 'app', (app) ~>
# so you can download songs
app.use('/library', express.static(@music_directory))
app.get '/library/', @checkEnabledMiddleware, (req, resp) ~>
@downloadPath @music_directory, "library.zip", req, resp
app.get /^\/library\/(.*)\/$/, @checkEnabledMiddleware, (req, resp) ~>
req_dir = req.params[0]
zip_name = safePath(req_dir.replace(/\//g, " - ")) + ".zip"
dl_path = path.join(@music_directory, req_dir)
@downloadPath dl_path, zip_name, req, resp
app.post '/download/custom', [@checkEnabledMiddleware, express.urlencoded()], (req, resp) ~>
files = (path.join(@music_directory, f) for f of req.body.file)
zip_name = "music.zip"
@sendZipOfFiles zip_name, files, req, resp
app.get '/download/album/:album', @checkEnabledMiddleware, (req, resp) ~>
album = @mpd.library.album_table[req.params.album]
if album!?
resp.statusCode = 404
resp.end()
return
files = (path.join(@music_directory, track.file) for track of album.tracks)
zip_name = safePath(album.name) + ".zip"
@sendZipOfFiles zip_name, files, req, resp
app.get '/download/artist/:artist', @checkEnabledMiddleware, (req, resp) ~>
artist = @mpd.library.artist_table[req.params.artist]
if artist!?
resp.statusCode = 404
resp.end()
return
zip_name = safePath(artist.name) + ".zip"
files = []
for album of artist.albums
for track of album.tracks
files.push(path.join(@music_directory, track.file))
@sendZipOfFiles zip_name, files, req, resp
bus.on 'mpd', !(@mpd) ~>
downloadPath: !(dl_path, zip_name, req, resp) ->
finder = findit.find dl_path
files = []
finder.on \file, !(file) ~> files.push(file)
finder.on \error, !(err) ~>
finder.removeAllListeners \end
console.error "Error when downloading zip of", relative_path, err.stack
resp.statusCode = 404
resp.end()
finder.on \end, !~>
@sendZipOfFiles zip_name, files, req, resp
sendZipOfFiles: !(zip_name, files, req, resp) ->
cleanup = []
req.on \close, !~>
for fn of cleanup then try fn()
resp.end()
resp.setHeader "Content-Type", "application/zip"
resp.setHeader "Content-Disposition", "attachment; filename=#{zip_name}"
zip = zipstream.createZip {}
cleanup.push !~> zip.destroy()
zip.pipe resp
!~function nextFile
file_path = files.shift()
if file_path?
options =
name: path.relative(@music_directory, file_path)
store: true
read_stream = fs.createReadStream(file_path)
cleanup.push !~> read_stream.destroy()
zip.addFile read_stream, options, nextFile
else
zip.finalize !~> resp.end()
nextFile()

View file

@ -0,0 +1,161 @@
const
Plugin = require('../plugin')
const
history_size = 10
future_size = 10
LAST_QUEUED_STICKER = "groovebasin.last-queued"
module.exports = class DynamicMode extends Plugin
(bus) ~>
super(...)
@previous_ids = {}
@is_enabled = false
@got_stickers = false
# our cache of the LAST_QUEUED_STICKER
@last_queued = {}
bus.on 'save_state', @~saveState
bus.on 'restore_state', @~restoreState
bus.on 'mpd_conf', @~setConf
bus.on 'mpd', @~setMpd
bus.on 'socket_connect', @~onSocketConnection
restoreState: !(state) ->
@is_on = state.status.dynamic_mode ? false
@random_ids = state.status.random_ids ? {}
saveState: !(state) ->
state.status.dynamic_mode = @is_on
state.status.dynamic_mode_enabled = @is_enabled
state.status.random_ids = @random_ids
setConf: !(conf, conf_path) ->
@is_enabled = true
unless conf.sticker_file?
@is_enabled = false
@is_on = false
console.warn "sticker_file not set in #{conf_path}. Dynamic Mode disabled."
setMpd: !(@mpd) ->
@mpd.on 'statusupdate', @~checkDynamicMode
@mpd.on 'playlistupdate', @~checkDynamicMode
@mpd.on 'libraryupdate', @~updateStickers
onSocketConnection: !(socket) ->
socket.on 'DynamicMode', !(data) ~>
return unless @is_enabled
args = JSON.parse data
did_anything = false
for key, value in args
if key is 'dynamic_mode'
continue if @is_on is value
did_anything = true
@is_on = value
if did_anything
@checkDynamicMode()
@emit('status_changed')
checkDynamicMode: !->
return unless @is_enabled
return unless @mpd.library.artists.length
return unless @got_stickers
item_list = @mpd.playlist.item_list
current_id = @mpd.status.current_item?.id
current_index = -1
all_ids = {}
new_files = []
for item, i of item_list
if item.id is current_id
current_index = i
all_ids[item.id] = true
new_files.push item.track.file unless @previous_ids[item.id]?
# tag any newly queued tracks
now = new Date()
@mpd.setStickers new_files, LAST_QUEUED_STICKER, JSON.stringify(now), !(err) ~>
if err then console.warn "dynamic mode set stickers error:", err
# anticipate the changes
@last_queued[file] = now for file of new_files
# if no track is playing, assume the first track is about to be
if current_index is -1
current_index = 0
else
# any tracks <= current track don't count as random anymore
for i from 0 to current_index
delete @random_ids[item_list[i].id]
if @is_on
delete_count = Math.max(current_index - history_size, 0)
if history_size < 0
delete_count = 0
@mpd.removeIds (item_list[i].id for i from 0 til delete_count)
add_count = Math.max(future_size + 1 - (item_list.length - current_index), 0)
@mpd.queueFiles @getRandomSongFiles(add_count), null, !(err, items) ~>
throw err if err
# track which ones are the automatic ones
changed = false
for item of items
@random_ids[item.id] = true
changed = true
@emit('status_changed') if changed
# scrub the random_ids (only if we're sure we're not still loading
if item_list.length
new_random_ids = {}
for id in @random_ids
if all_ids[id]
new_random_ids[id] = true
@random_ids = new_random_ids
@previous_ids = all_ids
@emit('status_changed')
updateStickers: !->
@mpd.findStickers '/', LAST_QUEUED_STICKER, !(err, stickers) ~>
if err
console.error "dynamicmode findsticker error: #{err}"
return
for sticker in stickers
[file, value] = sticker
track = @mpd.library.track_table[file]
@last_queued[file] = new Date(value)
@got_stickers = true
getRandomSongFiles: (count) ->
return [] if count is 0
never_queued = []
sometimes_queued = []
for file, track in @mpd.library.track_table
if @last_queued[file]?
sometimes_queued.push track
else
never_queued.push track
# backwards by time
sometimes_queued.sort (a, b) ~>
@last_queued[b.file].getTime() - @last_queued[a.file].getTime()
# distribution is a triangle for ever queued, and a rectangle for never queued
# ___
# /| |
# / | |
# /__|_|
max_weight = sometimes_queued.length
triangle_area = Math.floor(max_weight * max_weight / 2)
max_weight = 1 if max_weight is 0
rectangle_area = max_weight * never_queued.length
total_size = triangle_area + rectangle_area
if total_size is 0
return []
# decode indexes through the distribution shape
files = []
for i from 0 til count
index = Math.random() * total_size
if index < triangle_area
# triangle
track = sometimes_queued[Math.floor Math.sqrt index]
else
# rectangle
track = never_queued[Math.floor((index - triangle_area) / max_weight)]
files.push track.file
files

View file

@ -0,0 +1,147 @@
const
Plugin = require('../plugin')
LastFmNode = require('lastfm').LastFmNode
module.exports = class LastFm extends Plugin
(bus) ~>
super(...)
@lastfm = new LastFmNode do
api_key: process.env.LASTFM_API_KEY
secret: process.env.LASTFM_SECRET
@previous_now_playing_id = null
@last_playing_item = null
@playing_start = new Date()
@playing_time = 0
@previous_play_state = null
setTimeout @~flushScrobbleQueue, 120000
bus.on 'save_state', @~saveState
bus.on 'restore_state', @~restoreState
bus.on 'socket_connect', @~onSocketConnection
bus.on 'mpd', @~setMpd
restoreState: !(state) ->
@scrobblers = state.lastfm_scrobblers ? {}
@scrobbles = state.scrobbles ? []
saveState: !(state) ->
state.lastfm_scrobblers = @scrobblers
state.scrobbles = @scrobbles
state.status.lastfm_api_key = process.env.LASTFM_API_KEY
setMpd: !(@mpd) ->
@mpd.on 'statusupdate', !~>
@updateNowPlaying()
@checkScrobble()
onSocketConnection: !(socket) ->
socket.on 'LastfmGetSession', !(data) ~>
@lastfm.request "auth.getSession",
token: data
handlers:
success: !(data) ~>
# clear them from the scrobblers
delete @scrobblers[data?.session?.name]
socket.emit 'LastfmGetSessionSuccess', JSON.stringify(data)
error: !(error) ~>
console.error "error from last.fm auth.getSession: #{error.message}"
socket.emit 'LastfmGetSessionError', JSON.stringify(error)
socket.on 'LastfmScrobblersAdd', !(data) ~>
params = JSON.parse(data)
# ignore if scrobbling user already exists. this is a fake request.
return if @scrobblers[params.username]?
@scrobblers[params.username] = params.session_key
@emit('state_changed')
socket.on 'LastfmScrobblersRemove', !(data) ~>
params = JSON.parse(data)
session_key = @scrobblers[params.username]
if session_key is params.session_key
delete @scrobblers[params.username]
@emit('state_changed')
else
console.warn "Invalid session key from user trying to remove scrobbler: #{params.username}"
flushScrobbleQueue: !->
const max_simultaneous = 10
count = 0
while (params = @scrobbles.shift())? and count++ < max_simultaneous
console.info "scrobbling #{params.track} for session #{params.sk}"
params.handlers =
error: !(error) ~>
console.error "error from last.fm track.scrobble: #{error.message}"
if not error?.code? or error.code is 11 or error.code is 16
# retryable - add to queue
@scrobbles.push params
@emit('state_changed')
@lastfm.request 'track.scrobble', params
@emit('state_changed')
queueScrobble: !(params) ->
@scrobbles.push params
@emit('state_changed')
checkTrackNumber: (trackNumber) ->
if parseInt(trackNumber, 10) >= 0 then trackNumber else ""
checkScrobble: !->
this_item = @mpd.status.current_item
if @mpd.status.state is 'play'
if @previous_play_state is not 'play'
@playing_start = new Date(new Date().getTime() - @playing_time)
@previous_play_state = @mpd.status.state
@playing_time = new Date().getTime() - @playing_start.getTime()
return unless this_item?.id is not @last_playing_item?.id
if (track = @last_playing_item?.track)?
# then scrobble it
min_amt = 15 * 1000
max_amt = 4 * 60 * 1000
half_amt = track.time / 2 * 1000
if @playing_time >= min_amt and (@playing_time >= max_amt or @playing_time >= half_amt)
if track.artist_name
for username, session_key of @scrobblers
@queueScrobble do
sk: session_key
timestamp: Math.round(@playing_start.getTime() / 1000)
album: track.album?.name or ""
track: track.name or ""
artist: track.artist_name or ""
albumArtist: track.album_artist_name or ""
duration: track.time or ""
trackNumber: @checkTrackNumber track.track
@flushScrobbleQueue()
else
console.warn "Not scrobbling #{track.name} - missing artist."
@last_playing_item = this_item
@previous_play_state = @mpd.status.state
@playing_start = new Date()
@playing_time = 0
updateNowPlaying: !->
return unless @mpd.status.state is 'play'
return unless (track = @mpd.status.current_item?.track)?
return unless @previous_now_playing_id is not @mpd.status.current_item.id
@previous_now_playing_id = @mpd.status.current_item.id
if not track.artist_name
console.warn "Not updating last.fm now playing for #{track.name}: missing artist"
return
for username, session_key in @scrobblers
@lastfm.request "track.updateNowPlaying",
sk: session_key
track: track.name or ""
artist: track.artist_name or ""
album: track.album?.name or ""
albumArtist: track.album_artist_name or ""
trackNumber: @checkTrackNumber track.track
duration: track.time or ""
handlers:
error: !(error) ~>
console.error "error from last.fm track.updateNowPlaying: #{error.message}"

View file

@ -0,0 +1,34 @@
Plugin = require('../plugin')
module.exports = class Stream extends Plugin
(bus) ~>
super(...)
@port = null
@format = null
@is_enabled = false
bus.on 'save_state', @~saveState
bus.on 'mpd_conf', @~setConf
saveState: !(state) ->
state.status.stream_httpd_port = @port
state.status.stream_httpd_format = @format
setConf: !(conf, conf_path) ->
@is_enabled = true
if (httpd = conf.audio_output?.httpd)?
@port = httpd.port
if httpd.encoder is 'lame'
@format = 'mp3'
if httpd.quality?
console.warn "Use audio_output.bitrate for setting quality when using mp3 streaming in #{conf_path}"
else if httpd.encoder is 'vorbis'
@format = 'ogg'
if httpd.bitrate?
console.warn "Use audio_output.quality for setting quality when using vorbis streaming in #{conf_path}"
else
@format = 'unknown'
if httpd.format is not "44100:16:2"
console.warn "Recommended 44100:16:2 for audio_output.format in #{conf_path}"
else
@is_enabled = false
console.warn "httpd audio_output not enabled in #{conf_path}. Streaming disabled."

View file

@ -0,0 +1,126 @@
const
Plugin = require('../plugin')
mkdirp = require 'mkdirp'
fs = require 'fs'
path = require 'path'
request = require 'superagent'
url = require 'url'
temp = require 'temp'
mv = require 'mv'
{getSuggestedPath} = require '../futils'
express = require 'express'
multipart = express.multipart(keepExtensions: true)
module.exports = class Upload extends Plugin
(bus) ~>
super(...)
@is_enabled = false
@random_ids = null
bus.on 'app', @~setUpRoutes
bus.on 'mpd_conf', @~setConf
bus.on 'mpd', @~setMpd
bus.on 'save_state', @~saveState
bus.on 'restore_state', @~restoreState
bus.on 'status_sent', @~onSendStatus
bus.on 'socket_connect', @~onSocketConnection
restoreState: !(state) ->
@want_to_queue = state.want_to_queue ? []
saveState: !(state) ->
state.want_to_queue = @want_to_queue
state.status.upload_enabled = @is_enabled
setConf: !(conf, conf_path) ->
@is_enabled = true
unless conf.bind_to_address?.unix_socket?
@is_enabled = false
console.warn "bind_to_address does not have a unix socket enabled in #{conf_path}. Uploading disabled."
if conf.music_directory?
@music_lib_path = conf.music_directory
else
@is_enabled = false
console.warn "music directory not found in #{conf_path}. Uploading disabled."
setMpd: !(@mpd) ->
@mpd.on 'libraryupdate', @~flushWantToQueue
onSocketConnection: !(socket) ->
socket.on 'ImportTrackUrl', !(url_string) ~>
parsed_url = url.parse(url_string)
remote_filename = path.basename(parsed_url.pathname) + path.extname(parsed_url.pathname)
temp_file = temp.path()
cleanUp = !~>
fs.unlink(temp_file)
cleanAndLogIfErr = !(err) ~>
if err
console.error "Unable to import by URL.", err.stack, "URL:", url_string
cleanUp()
req = request.get(url_string)
ws = fs.createWriteStream(temp_file)
req.pipe(ws)
ws.on 'close', !~>
@importFile temp_file, remote_filename, cleanAndLogIfErr
ws.on 'error', cleanAndLogIfErr
req.on 'error', cleanAndLogIfErr
importFile: !(temp_file, remote_filename, cb=->) ->
tmp_with_ext = temp_file + path.extname(remote_filename)
mv temp_file, tmp_with_ext, !(err) ~>
return cb(err) if err
@mpd.getFileInfo "file://#{tmp_with_ext}", !(err, track) ~>
if err
console.warn "Unable to read tags to get a suggested upload path: #{err.stack}"
suggested_path = remote_filename
else
suggested_path = getSuggestedPath(track, remote_filename)
relative_path = path.join('incoming', suggested_path)
dest = path.join(@music_lib_path, relative_path)
mkdirp path.dirname(dest), !(err) ~>
if err
console.error err
return cb(err)
mv tmp_with_ext, dest, !(err) ~>
@want_to_queue.push relative_path
@emit('state_changed')
console.info "Track was uploaded: #{dest}"
cb(err)
setUpRoutes: !(app) ->
app.post '/upload', [@checkEnabledMiddleware, multipart], !(request, response) ~>
!~function logIfErr (err)
if err
console.error "Unable to import by uploading. Error: #{err}"
for name, file in request.files
@importFile file.path, file.name, logIfErr
response.statusCode = 200
response.setHeader 'content-type', 'application/json'
response.end JSON.stringify {success: true}
onSendStatus: !(status) ->
@random_ids = status?.random_ids
queueFilesPos: ->
pos = @mpd.playlist.item_list.length
return pos unless @random_ids?
for item, i of @mpd.playlist.item_list
return i if @random_ids[item.id]?
flushWantToQueue: !->
i = 0
files = []
while i < @want_to_queue.length
file = @want_to_queue[i]
if @mpd.library.track_table[file]?
files.push file
@want_to_queue.splice i, 1
else
i++
@mpd.queueFiles files, @queueFilesPos()
@mpd.queueFilesInStoredPlaylist(files, "Incoming")
@emit('state_changed') if files.length

271
src/server/server.co Normal file
View file

@ -0,0 +1,271 @@
const
fs = require 'fs'
http = require 'http'
net = require 'net'
socketio = require 'socket.io'
express = require 'express'
path = require 'path'
assert = require 'assert'
Mpd = require '../mpd.js/lib/mpd'
async = require 'async'
parseMpdConf = require('./mpdconf').parse
{EventEmitter} = require('events')
process.env.HOST ||= "0.0.0.0"
process.env.PORT ||= "16242"
process.env.MPD_CONF ||= "/etc/mpd.conf"
process.env.STATE_FILE ||= ".state.json"
process.env.NODE_ENV ||= "dev"
process.env.LASTFM_API_KEY ||= "7d831eff492e6de5be8abb736882c44d"
process.env.LASTFM_SECRET ||= "8713e8e893c5264608e584a232dd10a0"
app = express()
app.configure !->
app.use(express.static(path.join(__dirname, '../public')))
app.use(express.static(path.join(__dirname, '../src/public')))
app.disable('x-powered-by')
app_server = http.createServer(app)
io = socketio.listen(app_server)
io.set 'log level', 2
plugins =
objects: {}
bus: new EventEmitter()
initialize: !(cb) ->
const PLUGIN_PATH = path.join(__dirname, "plugins")
fs.readdir PLUGIN_PATH, !(err, files) ~>
return cb(err) if err
for file of files
continue unless /\.js$/.test(file)
name = path.basename(file, ".js")
Plugin = require("./plugins/#name")
plugin = @objects[name] = new Plugin(@bus)
plugin.on \state_changed, saveState
plugin.on \status_changed, saveAndSendStatus
cb()
featuresList: ->
([name, plugin.is_enabled] for name, plugin in @objects)
# state management
const STATE_VERSION = 2 # increment this when layout of state object changes
state =
state_version: STATE_VERSION
status: {} # this structure is visible to clients
mpd_conf = null
root_pass = null
accounts = null
default_account = null
my_mpd = null
my_mpd_socket = null
connect_success = true
async.series [
plugins~initialize
restoreState
readMpdConf
], !(err) ->
assert.ifError(err)
plugins.bus.emit \app, app
plugins.bus.emit \save_state, state
for [name, enabled] of plugins.featuresList()
if enabled
console.info "#{name} is enabled."
else
console.warn "#{name} is disabled."
io.sockets.on 'connection', onSocketIoConnection
connectServerMpd()
process.on 'message', onProcessMessage
host = process.env.HOST
port = parseInt(process.env.PORT, 10)
app_server.listen port, host, !->
process.send 'online'
console.info "Listening at http://#host:#port"
function oncePerEventLoopFunc (fn)
queued = false
cbs = []
!(cb) ->
cbs.push(cb) if cb?
return if queued
queued := true
process.nextTick !->
queued := false
fn !->
cb(...) for cb of cbs
const saveState = oncePerEventLoopFunc !(cb) ->
plugins.bus.emit \save_state, state
fs.writeFile process.env.STATE_FILE, JSON.stringify(state, null, 4), "utf8", !(err) ->
if err then console.error "Error saving state to disk: #{err.stack}"
cb(err)
!function restoreState (cb)
fs.readFile process.env.STATE_FILE, \utf8, !(err, data) ->
if err?code is \ENOENT
console.warn "No state file. Creating a new one."
else if err
return cb(err)
else
try
loaded_state = JSON.parse(data)
catch e
return cb(new Error("state file contains invalid JSON: #e"))
if loaded_state.state_version is not STATE_VERSION
return cb(new Error("State version is #{loaded_state.state_version} but should be #STATE_VERSION"))
state := loaded_state
# have the plugins restore and then save to delete values that should not
# have been restored.
plugins.bus.emit \restore_state, state
plugins.bus.emit \save_state, state
cb()
!function sendStatus
plugins.bus.emit \status_sent, state.status
io.sockets.emit 'Status', JSON.stringify state.status
!function saveAndSendStatus
saveState()
sendStatus()
!function readMpdConf (cb)
mpd_conf_path = process.env.MPD_CONF
fs.readFile mpd_conf_path, "utf8", !(error, contents) ->
if error
console.warn "Unable to read #{mpd_conf_path}: #{error}. Most features disabled."
return cb()
mpd_conf := parseMpdConf(contents)
plugins.bus.emit \mpd_conf, mpd_conf, mpd_conf_path
if mpd_conf.auto_update is not "yes"
console.warn "recommended to turn auto_update on in #{mpd_conf_path}"
if mpd_conf.gapless_mp3_playback is not "yes"
console.warn "recommended to turn gapless_mp3_playback on in #{mpd_conf_path}"
if mpd_conf.volume_normalization is not "yes"
console.warn "recommended to turn volume_normalization on in #{mpd_conf_path}"
if isNaN(n = parseInt(mpd_conf.max_command_list_size, 10)) or n < 16384
console.warn "recommended to set max_command_list_size to >= 16384 in #{mpd_conf_path}"
const all_permissions = "read,add,control,admin"
function accountIsRoot (account)
for perm of all_permissions.split(',')
if not account[perm]
return false
return true
default_account := arrayToSet((mpd_conf.default_permissions ? all_permissions).split(","))
if accountIsRoot(default_account)
root_pass := ""
accounts := {}
for account_str of (mpd_conf.password ? [])
[password, perms] = account_str.split("@")
accounts[password] = account = arrayToSet(perms.split(","))
if not root_pass? and accountIsRoot(account)
root_pass := password
if default_account.admin
console.warn "Anonymous users have admin permissions. Recommended to remove `admin` from `default_permissions` in #{mpd_conf_path}"
if not root_pass?
rand_pass = Math.floor(Math.random() * 99999999999)
console.error """
It is required to have at least one password which is granted all the
permissions. Recommended to add this line in #{mpd_conf_path}:
password "groovebasin-#{rand_pass}@#{all_permissions}"
"""
return cb(new Error("mpd conf is missing root account"))
cb()
function createMpdConnection (unix_socket)
if unix_socket and (path = mpd_conf?.bind_to_address?.unix_socket)?
connection = net.connect {path}
else
port = mpd_conf?.port ? 6600
host = mpd_conf?.bind_to_address?.network ? "localhost"
connection = net.connect {port, host}
connection.setEncoding('utf8')
connection
!function connectBrowserMpd (socket)
mpd_socket = createMpdConnection false
mpd_socket.on \connect, !->
try socket.emit 'MpdConnect'
mpd_socket.on 'data', !(data) ->
socket.emit 'FromMpd', data
mpd_socket.on 'end', !->
try socket.emit 'MpdDisconnect'
mpd_socket.on 'error', !->
console.warn "browser no mpd daemon found."
socket.removeAllListeners 'ToMpd'
socket.on 'ToMpd', !(data) ->
try mpd_socket.write data
socket.removeAllListeners 'disconnect'
socket.on 'disconnect', !->
mpd_socket.end()
!function onSocketIoConnection (socket)
connectBrowserMpd socket
permissions = default_account
plugins.bus.emit \socket_connect, socket, -> permissions
socket.emit 'Permissions', JSON.stringify(permissions)
socket.on 'Password', !(pass) ->
if success = (ref = accounts[pass])?
permissions := ref
socket.emit 'Permissions', JSON.stringify(permissions)
socket.emit 'PasswordResult', JSON.stringify(success)
!function connectServerMpd
my_mpd_socket := createMpdConnection true
my_mpd_socket.on 'end', !->
console.warn "server mpd disconnect"
tryReconnect()
my_mpd_socket.on 'error', !->
if connect_success
connect_success := false
console.warn "server no mpd daemon found."
tryReconnect()
my_mpd := Mpd()
my_mpd.on \data, !(data) -> my_mpd_socket.write(data)
my_mpd.on 'error', !(msg) -> console.error msg
my_mpd_socket.on \connect, !->
console.info "server to mpd connect"
connect_success := true
my_mpd.handleConnectionStart()
if root_pass? and root_pass.length > 0
my_mpd.authenticate root_pass
# connect socket clients to mpd
io.sockets.clients().forEach connectBrowserMpd
my_mpd_socket.on \data, !(data) -> my_mpd.receive(data)
plugins.bus.emit \mpd, my_mpd
!function tryReconnect
setTimeout connectServerMpd, 1000
function arrayToSet (array)
obj = {}
obj[item] = true for item of array
obj
!function onProcessMessage (message)
if message is 'shutdown'
process.exit(0)