Compare commits

..

No commits in common. "master" and "0.0.2" have entirely different histories.

87 changed files with 8689 additions and 9654 deletions

15
.gitignore vendored
View file

@ -1,7 +1,8 @@
/node_modules
/groovebasin.db
/config.js
# not shared with .npmignore
/public/app.js
/public/app.css
server.js
lib/
public/app.js
public/app.css
public/library
node_modules/
*.tmp
.build.timestamp

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 +0,0 @@
/node_modules
/groovebasin.db
/config.js
# not shared with .gitignore

59
Makefile Normal file
View file

@ -0,0 +1,59 @@
appjs=public/app.js
appcss=public/app.css
serverjs=server.js
views=views/*.handlebars
client_src=src/mpd.coffee src/socketmpd.coffee src/app.coffee
server_src=src/daemon.coffee
styles=src/app.styl
lib=lib
mpd_lib=$(lib)/mpd.js
mpd_lib_src=src/mpd.coffee
coffee=node_modules/coffee-script/bin/coffee
handlebars=node_modules/handlebars/bin/handlebars
stylus=node_modules/stylus/bin/stylus
.PHONY: build clean watch
SHELL=bash
build: .build.timestamp
@: # suppress 'nothing to be done' message
.build.timestamp: $(serverjs) $(appjs) $(appcss)
@touch $@
@echo done building
@echo
$(serverjs): $(server_src) $(mpd_lib) $(lib)/mpdconf.js
$(coffee) -p -c $(server_src) >$@.tmp
chmod +x $@.tmp
mv $@{.tmp,}
$(lib):
mkdir -p $(lib)
$(mpd_lib): $(mpd_lib_src) | $(lib)
$(coffee) -p -c $(mpd_lib_src) >$@.tmp
mv $@{.tmp,}
$(lib)/mpdconf.js: src/mpdconf.coffee | $(lib)
$(coffee) -p -c src/mpdconf.coffee >$@.tmp
mv $@{.tmp,}
$(appjs): $(views) $(client_src)
$(coffee) -p -c $(client_src) >$@.tmp
$(handlebars) $(views) -k if -k each -k hash >>$@.tmp
mv $@{.tmp,}
$(appcss): $(styles)
$(stylus) <$(styles) >$@.tmp
mv $@{.tmp,}
clean:
rm -f ./$(appjs){,.tmp}
rm -f ./$(appcss){,.tmp}
rm -f ./$(serverjs){,.tmp}
rm -rf ./$(lib)
rm -f ./public/library
watch:
bash -c 'while [ 1 ]; do make --no-print-directory; sleep 0.5; done'

353
README.md
View file

@ -1,317 +1,94 @@
# Groove Basin
Music player server with a web-based user interface inspired by Amarok 1.4.
No-nonsense music client and server for your home or office.
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.
Run it on a server connected to your main speakers. Guests can connect with
their laptops, tablets, and phones, and play and share music.
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/).
Depends on [mpd](http://musicpd.org) for the backend. Some might call this
project an mpd client.
## Features
* Fast, responsive UI. It feels like a desktop app, not a web app.
* 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 queued recently.
songs that have not been played 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).
## Get Started
* [Last.fm](http://www.last.fm/) scrobbling.
Make sure you have [Node](http://nodejs.org) and [npm](http://npmjs.org)
installed, then:
* File system monitoring. Add songs anywhere inside your music directory and
they instantly appear in your library in real time.
```
$ npm install groovebasin
$ npm start groovebasin
```
* 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`
At this point, Groove Basin will issue warnings telling you what to do next.
## 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)
![Searching library](http://www.superjoesoftware.com/temp/groove-basin-5.png)
![Multi-select and context menu](http://www.superjoesoftware.com/temp/groove-basin-2.png)
![Drag/drop support](http://www.superjoesoftware.com/temp/groove-basin-3.png)
![Keyboard shortcuts](http://www.superjoesoftware.com/temp/groove-basin-4.png)
![Drag and drop / multiselect upload](http://www.superjoesoftware.com/temp/groove-basin-1.png)
## Configuration
## Mpd
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.
Groove Basin depends on [mpd](http://musicpd.org).
Some new features are only available when you compile from source:
```
$ git clone git://git.musicpd.org/master/mpd.git
```
### Configuration
* `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
See http://npmjs.org/doc/config.html#Per-Package-Config-Settings
See the "config" section of `package.json` for configuration options and
defaults.
Example:
```
$ npm config set groovebasin:mpd_conf ~/.mpd/mpd.conf
$ npm config set groovebasin:http_port 80
```
## Developing
```
$ npm run dev
$ sudo npm link
$ make watch
$ npm -g start groovebasin
```
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

67
TODO Normal file
View file

@ -0,0 +1,67 @@
Version 0.0.1 - I want to demo it at Hacker News meetup Tue March 6th 2012.
* ability to select artists, albums, tracks in library
* ability to drag artists, albums, tracks to playlist
* option to not auto-queue uploaded songs
* ability to password protect - make it so that not everybody can kill mpd,
enable/disable audio outputs, edit tags, upload tracks, change playback
state, etc.
* ability to delete songs from library
* ability to edit tags
* ability to move a file to a better location based on its tags
* put song files in an intelligent place when uploading
* if you move a song that was chosen randomly, it and everything from
it to current song are marked not random (do we really want this? [yes])
* as part of lower casing for artist/album keys, change ñ to n, etc.
- like http://lucene.apache.org/core/old_versioned_docs/versions/3_5_0/api/core/org/apache/lucene/analysis/ASCIIFoldingFilter.html
* shift+down select another item, shift+up unselect it
* dynamic mode populates twice when user clicks Clear
- (due to mpd 'player' and 'playlist' events both being handled with empty playlist)
* refactor - server_status stuff should not be in Mpd class, it should be in
GrooveBasinClient class or something like that.
* display an icon when connection to server is lost.
* attempt to reconnect when lost connection to server.
* display any mpd status error message
* ability to filter playlist
* playlist management
- save
- display
- grab individual tracks
- switch to
* ability to download songs in library
* ability to download multiple songs at once
- more intelligent behavior when you click download for multiple selection
* library management
- duplicate detection and elimination
> if a song is byte for byte the same (check md5's) then ignore the
new song
> use heuristics to guess if songs are probably the same (using tags). if
we are reasonably confident that the songs are the same, delete the one
with the lower quality.
> if we're not confident enough, there will be an
api that lists possible duplicates and actions to resolve them.
- when a song is added to the library, automatically replaygain scan it
and the album from whence it came. Do this for update as well.
- ability to add song to library by URL
* plugin API
- plugin to submit songs to last.fm
- plugin to import songs to library from youtube URL
- wolfebin plugin?
- move dynamic playlist mode functionality to a plugin
* file folder inbox to import stuff
* take mpd's status into account. Make them editable?
- consume
- random
* good shuffle heuristics:
- songs that get skipped more often have less probability of being
randomly chosen.
- ability to ban from random
> keyboard shortcut 'B'
- make dynamic playlist mode options configurable
* prepend '!' to a search word to NOT match the word. '\!' to literally match
'!'. '\\' to literally match '\'
* ability to upload zip files
* smarter anticipation of commands to avoid the glitchy behavior when you do
a repetitive action quickly, such as turning the volume down incrementally
or moving a track down one space at a time
* make the list of uploaded files go away after they're all done.

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');
});
};

View file

@ -1,54 +1,34 @@
{
"name": "groovebasin",
"description": "Music player server with a web-based interface inspired by Amarok 1.4",
"author": "Andrew Kelley <superjoe30@gmail.com>",
"version": "1.0.1",
"licenses": [
{
"type": "MIT",
"url": "https://raw.github.com/andrewrk/groovebasin/master/LICENSE"
"name": "groovebasin",
"description": "No-nonsense music client and daemon based on mpd",
"author": "Andrew Kelley <superjoe30@gmail.com>",
"version": "0.0.2",
"licenses": [{
"type": "MIT",
"url": "https://raw.github.com/superjoe30/groovebasin/master/LICENSE"
}],
"engines": {
"node": ">=0.6.8"
},
"repository": {
"type": "git",
"url": "git://github.com/superjoe30/groovebasin.git"
},
"dependencies": {
"socket.io": ">=0.8.7",
"node-static": ">=0.5.9",
"coffee-script": ">=1.2.0i",
"handlebars": ">=1.0.4beta",
"formidable": ">=1.0.8",
"stylus": ">=0.24.0"
},
"scripts": {
"install": "make"
},
"config": {
"user_id": "mpd",
"log_level": 2,
"port": 16242,
"mpd_conf": "/etc/mpd.conf"
}
],
"engines": {
"node": ">=0.10.20"
},
"repository": {
"type": "git",
"url": "git://github.com/andrewrk/groovebasin.git"
},
"dependencies": {
"lastfm": "~0.9.0",
"express": "^4.0.0",
"superagent": "^0.17.0",
"mkdirp": "~0.3.5",
"mv": "~2.0.0",
"pend": "~1.1.1",
"zfill": "0.0.1",
"requireindex": "^1.1.0",
"mess": "~0.1.1",
"groove": "~1.4.1",
"osenv": "0.0.3",
"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": {
"stylus": "^0.42.3",
"browserify": "^3.41.0"
},
"scripts": {
"start": "node lib/server.js",
"build": "npm install && ./build",
"dev": "npm run build && npm start"
}
}

View file

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

103
public/index.html Normal file
View file

@ -0,0 +1,103 @@
<!doctype html>
<html>
<head>
<title>Groove Basin</title>
<link rel="stylesheet" type="text/css" href="vendor/reset.min.css">
<link rel="stylesheet" type="text/css" href="vendor/css/dot-luv/jquery-ui-1.8.17.custom.css">
<link rel="stylesheet" type="text/css" href="vendor/fileuploader/fileuploader.css">
<link rel="stylesheet" type="text/css" href="app.css">
<link rel="shortcut icon" href="/favicon.png" type="image/png">
</head>
<body>
<div id="nowplaying" class="ui-widget-content ui-corner-all">
<ul class="playback-btns ui-widget ui-helper-clearfix">
<li class="ui-state-default ui-corner-all hoverable prev">
<span class="ui-icon ui-icon-seek-prev"></span>
</li>
<li class="ui-state-default ui-corner-all hoverable toggle">
<span class="ui-icon ui-icon-pause"></span>
</li>
<li class="ui-state-default ui-corner-all hoverable stop">
<span class="ui-icon ui-icon-stop"></span>
</li>
<li class="ui-state-default ui-corner-all hoverable next">
<span class="ui-icon ui-icon-seek-next"></span>
</li>
</ul>
<div id="vol">
<span class="ui-icon ui-icon-volume-off"></span>
<div id="vol-slider"></div>
<span class="ui-icon ui-icon-volume-on"></span>
</div>
<div id="more-playback-btns">
<input type="checkbox" id="stream-btn"><label for="stream-btn">Stream</label>
</div>
<h1 id="track-display"></h1>
<div id="track-slider"></div>
<span class="time elapsed"></span>
<span class="time left"></span>
<div style="clear: both;"></div>
</div>
<div id="left-window">
<div id="lib-tabs" class="ui-widget 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 upload-tab"><span>Upload</span></li>
<li class="ui-state-default ui-corner-top chat-tab"><span>Chat</span></li>
</ul>
</div>
<div id="library-tab" class="ui-widget-content ui-corner-all">
<div class="window-header">
<input type="text" id="lib-filter" placeholder="filter">
<select id="organize">
<option selected="selected">Artist / Album / Song</option>
</select>
</div>
<div id="library">
</div>
</div>
<div id="upload-tab" class="ui-widget-content ui-corner-all" style="display: none">
<div id="upload">
<div id="upload-widget"></div>
</div>
</div>
<div id="chat-tab" class="ui-widget-content ui-corner-all" style="display: none">
<div id="chat">
</div>
</div>
</div>
<div id="playlist-window" class="ui-widget-content ui-corner-all">
<div class="window-header">
<button class="jquery-button clear">Clear</button>
<button class="jquery-button shuffle">Shuffle</button>
<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 for="pl-btn-repeat">Repeat: Off</label>
</div>
<div id="playlist">
<div class="header">
<span class="track">&nbsp;</span>
<span class="title">Title</span>
<span class="artist">Artist</span>
<span class="album">Album</span>
<span class="time">Time</span>
</div>
<div id="playlist-items">
</div>
</div>
</div>
<div style="clear: both"></div>
<div id="mpd-error" style="display: none" class="ui-state-error ui-corner-all">
<p>
<span class="ui-icon ui-icon-alert"></span>
<strong>Alert:</strong>
You have no connection to mpd.
</p>
</div>
<script type="text/javascript" src="vendor/jquery-1.7.1.min.js"></script>
<script type="text/javascript" src="vendor/jquery-ui-1.8.17.custom.min.js"></script>
<script type="text/javascript" src="vendor/socket.io/socket.io.min.js"></script>
<script type="text/javascript" src="vendor/handlebars.runtime.js"></script>
<script type="text/javascript" src="vendor/fileuploader/fileuploader.js"></script>
<script type="text/javascript" src="app.js"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View file

@ -0,0 +1,565 @@
/*
* jQuery UI CSS Framework 1.8.17
*
* Copyright 2011, 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.17
*
* Copyright 2011, 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 Resizable 1.8.17
*
* Copyright 2011, 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/Resizable#theming
*/
.ui-resizable { position: relative;}
.ui-resizable-handle { position: absolute;font-size: 0.1px;z-index: 99999; display: block; }
.ui-resizable-disabled .ui-resizable-handle, .ui-resizable-autohide .ui-resizable-handle { display: none; }
.ui-resizable-n { cursor: n-resize; height: 7px; width: 100%; top: -5px; left: 0; }
.ui-resizable-s { cursor: s-resize; height: 7px; width: 100%; bottom: -5px; left: 0; }
.ui-resizable-e { cursor: e-resize; width: 7px; right: -5px; top: 0; height: 100%; }
.ui-resizable-w { cursor: w-resize; width: 7px; left: -5px; top: 0; height: 100%; }
.ui-resizable-se { cursor: se-resize; width: 12px; height: 12px; right: 1px; bottom: 1px; }
.ui-resizable-sw { cursor: sw-resize; width: 9px; height: 9px; left: -5px; bottom: -5px; }
.ui-resizable-nw { cursor: nw-resize; width: 9px; height: 9px; left: -5px; top: -5px; }
.ui-resizable-ne { cursor: ne-resize; width: 9px; height: 9px; right: -5px; top: -5px;}/*
* jQuery UI Selectable 1.8.17
*
* Copyright 2011, 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/Selectable#theming
*/
.ui-selectable-helper { position: absolute; z-index: 100; border:1px dotted black; }
/*
* jQuery UI Accordion 1.8.17
*
* Copyright 2011, 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.17
*
* Copyright 2011, 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.17
*
* 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.17
*
* Copyright 2011, 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.17
*
* Copyright 2011, 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.17
*
* Copyright 2011, 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.17
*
* Copyright 2011, 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.17
*
* Copyright 2011, 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 {
display: none; /*sorry for IE5*/
display/**/: block; /*sorry for IE5*/
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.17
*
* Copyright 2011, 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%; }

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;}

1276
public/vendor/fileuploader/fileuploader.js vendored Normal file

File diff suppressed because it is too large Load diff

BIN
public/vendor/fileuploader/loading.gif vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 B

223
public/vendor/handlebars.runtime.js vendored Normal file
View file

@ -0,0 +1,223 @@
// lib/handlebars/base.js
var Handlebars = {};
Handlebars.VERSION = "1.0.beta.6";
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.registerHelper('each', function(context, options) {
var fn = options.fn, inverse = options.inverse;
var ret = "";
if(context && context.length > 0) {
for(var i=0, j=context.length; i<j; i++) {
ret = ret + fn(context[i]);
}
} 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) {
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;
;

4
public/vendor/jquery-1.7.1.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

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

Binary file not shown.

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

Binary file not shown.

3750
public/vendor/socket.io/socket.io.js generated vendored Normal file

File diff suppressed because one or more lines are too long

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

File diff suppressed because one or more lines are too long

874
src/app.coffee Normal file
View file

@ -0,0 +1,874 @@
# convenience
schedule = (delay, func) -> window.setInterval(func, delay)
wait = (delay, func) -> setTimeout func, delay
selection =
type: null # 'library' or 'playlist'
playlist_ids: {} # key is id, value is some dummy value
artist_ids: {}
album_ids: {}
track_ids: {}
cursor: null # the last touched id
socket = null
mpd = null
mpd_alive = false
base_title = document.title
user_is_seeking = false
user_is_volume_sliding = false
started_drag = false
abortDrag = null
clickTab = null
stream = null
want_to_queue = []
MARGIN = 10
# cache jQuery objects
$document = $(document)
$playlist_items = $("#playlist-items")
$dynamic_mode = $("#dynamic-mode")
$pl_btn_repeat = $("#pl-btn-repeat")
$stream_btn = $("#stream-btn")
$lib_tabs = $("#lib-tabs")
$upload_tab = $("#lib-tabs .upload-tab")
$chat_tab = $("#lib-tabs .chat-tab")
$library = $("#library")
$track_slider = $("#track-slider")
$nowplaying = $("#nowplaying")
$nowplaying_elapsed = $nowplaying.find(".elapsed")
$nowplaying_left = $nowplaying.find(".left")
$vol_slider = $("#vol-slider")
$chat = $("#chat")
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
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
renderPlaylistButtons = ->
# set the state of dynamic mode button
$dynamic_mode
.prop("checked", if mpd.server_status?.dynamic_mode then true else false)
.button("option", "disabled", not mpd.server_status?.dynamic_mode?)
.button("refresh")
repeat_state = getRepeatStateName()
$pl_btn_repeat
.button("option", "label", "Repeat: #{repeat_state}")
.prop("checked", repeat_state isnt 'Off')
.button("refresh")
# disable stream button if we don't have it set up
$stream_btn
.button("option", "disabled", not mpd.server_status?.stream_httpd_port?)
.button("refresh")
# show/hide upload
$upload_tab.removeClass("ui-state-disabled")
$upload_tab.addClass("ui-state-disabled") if not mpd.server_status?.upload_enabled
chat_status_text = ""
if (users = mpd.server_status?.users)?
chat_status_text = " (#{users.length - 1})" if users.length > 1
$chat.html Handlebars.templates.chat({users: users})
$chat_tab.find("span").text("Chat#{chat_status_text}")
labelPlaylistItems()
renderPlaylist = ->
context =
playlist: mpd.playlist.item_list
server_status: mpd.server_status
scroll_top = $playlist_items.scrollTop()
$playlist_items.html Handlebars.templates.playlist(context)
refreshSelection()
labelPlaylistItems()
$playlist_items.scrollTop(scroll_top)
labelPlaylistItems = ->
cur_item = mpd.status?.current_item
# label the old ones
$playlist_items.find(".pl-item").removeClass('current').removeClass('old')
if cur_item? and mpd.server_status?.dynamic_mode
for pos in [0...cur_item.pos]
id = mpd.playlist.item_list[pos].id
$("#playlist-track-#{id}").addClass('old')
# label the random ones
if mpd.server_status?.random_ids?
for item in mpd.playlist.item_list
if mpd.server_status.random_ids[item.id]
$("#playlist-track-#{item.id}").addClass('random')
# label the current one
$("#playlist-track-#{cur_item.id}").addClass('current') if cur_item?
refreshSelection = ->
return unless mpd?.playlist?.item_table?
# clear all selection
$playlist_items.find(".pl-item").removeClass('selected').removeClass('cursor')
if selection.type is 'playlist'
# if any selected ids are not in mpd.playlist, unselect them
badIds = []
for id of selection.playlist_ids
badIds.push id unless mpd.playlist.item_table[id]?
for id in badIds
delete selection.playlist_ids[id]
# highlight selected rows
for id of selection.playlist_ids
$playlist_track = $("#playlist-track-#{id}")
$playlist_track.addClass 'selected'
$("#playlist-track-#{selection.cursor}").addClass('cursor') if selection.cursor?
renderLibrary = ->
context =
artists: mpd.search_results.artists
empty_library_message: if mpd.haveFileListCache then "No Results" else "loading..."
scroll_top = $library.scrollTop()
$library.html Handlebars.templates.library(context)
# auto expand small datasets
$artists = $library.children("ul").children("li")
node_count = $artists.length
node_count_limit = 20
expand_stuff = ($li_set) ->
for li in $li_set
$li = $(li)
return if node_count >= node_count_limit
$ul = $li.children("ul")
$sub_li_set = $ul.children("li")
proposed_node_count = node_count + $sub_li_set.length
if proposed_node_count <= node_count_limit
toggleExpansion $li
# get these vars again because they might have been dynamically added
# by toggleExpansion
$ul = $li.children("ul")
$sub_li_set = $ul.children("li")
node_count = proposed_node_count
expand_stuff $sub_li_set
expand_stuff $artists
$library.scrollTop(scroll_top)
# returns how many seconds we are into the track
getCurrentTrackPosition = ->
if mpd.status.track_start_date? and mpd.status.state == "play"
(new Date() - mpd.status.track_start_date) / 1000
else
mpd.status.elapsed
updateSliderPos = ->
return if user_is_seeking
if (time = mpd.status?.time)? and mpd.status?.current_item? and (mpd.status?.state ? "stop") isnt "stop"
disabled = false
elapsed = getCurrentTrackPosition()
slider_pos = elapsed / time
else
disabled = true
elapsed = time = slider_pos = 0
$track_slider
.slider("option", "disabled", disabled)
.slider("option", "value", slider_pos)
$nowplaying_elapsed.html formatTime(elapsed)
$nowplaying_left.html formatTime(time)
renderNowPlaying = ->
# set window title
if (track = mpd.status.current_item?.track)?
track_display = "#{track.name} - #{track.artist_name}"
if track.album_name.length
track_display += " - " + track.album_name
document.title = "#{track_display} - #{base_title}"
else
track_display = "&nbsp;"
document.title = base_title
# set song title
$("#track-display").html(track_display)
state = mpd.status.state ? "stop"
# set correct pause/play icon
toggle_icon =
play: ['ui-icon-play', 'ui-icon-pause']
stop: ['ui-icon-pause', 'ui-icon-play']
pause: ['ui-icon-pause', 'ui-icon-play']
[old_class, new_class] = toggle_icon[state]
$nowplaying.find(".toggle span").removeClass(old_class).addClass(new_class)
# hide seeker bar if stopped
$track_slider.slider "option", "disabled", state == "stop"
updateSliderPos()
# update volume pos
if (vol = mpd.status?.volume)? and not user_is_volume_sliding
$vol_slider.slider 'option', 'value', vol
render = ->
$("#playlist-window").toggle(mpd_alive)
$("#left-window").toggle(mpd_alive)
$("#nowplaying").toggle(mpd_alive)
$("#mpd-error").toggle(not mpd_alive)
return unless mpd_alive
renderPlaylist()
renderPlaylistButtons()
renderLibrary()
renderNowPlaying()
handleResize()
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 != 0
return "#{hours}:#{zfill minutes}:#{zfill seconds}"
else
return "#{minutes}:#{zfill seconds}"
toggleExpansion = ($li) ->
$div = $li.find("> div")
$ul = $li.find("> ul")
if $div.hasClass('artist')
if not $li.data('cached')
$li.data 'cached', true
$ul.html Handlebars.templates.albums
albums: mpd.getArtistAlbums($div.find("span").text())
$ul.toggle()
$ul.toggle()
old_class = 'ui-icon-triangle-1-se'
new_class = 'ui-icon-triangle-1-e'
[new_class, old_class] = [old_class, new_class] if $ul.is(":visible")
$div.find("div").removeClass(old_class).addClass(new_class)
return false
handleDeletePressed = ->
if selection.type is 'playlist'
# remove items and select the item next in the list
pos = mpd.playlist.item_table[selection.cursor].pos
mpd.removeIds (id for id of selection.playlist_ids)
pos = mpd.playlist.item_list.length - 1 if pos >= mpd.playlist.item_list.length
if pos > -1
selection.cursor = mpd.playlist.item_list[pos].id
(selection.playlist_ids = {})[selection.cursor] = true if pos > -1
refreshSelection()
changeStreamStatus = (value) ->
return unless (port = mpd.server_status?.stream_httpd_port)?
$stream_btn
.prop("checked", value)
.button("refresh")
if value
stream = document.createElement("audio")
stream.setAttribute('src', "#{location.protocol}//#{location.hostname}:#{port}/mpd.ogg")
stream.setAttribute('autoplay', 'autoplay')
document.body.appendChild(stream)
stream.play()
else
stream.parentNode.removeChild(stream)
stream = null
togglePlayback = ->
if mpd.status.state == 'play'
mpd.pause()
else
mpd.play()
setDynamicMode = (value) ->
socket.emit 'DynamicMode', JSON.stringify(value)
toggleDynamicMode = -> setDynamicMode not mpd.server_status.dynamic_mode
getRepeatStateName = ->
if not mpd.status.repeat
"Off"
else if mpd.status.repeat and not mpd.status.single
"All"
else
"One"
nextRepeatState = ->
if not mpd.status.repeat
mpd.changeStatus
repeat: true
single: true
else if mpd.status.repeat and not mpd.status.single
mpd.changeStatus
repeat: false
single: false
else
mpd.changeStatus
repeat: true
single: false
keyboard_handlers = do ->
upDownHandler = (event) ->
return unless mpd.playlist.item_list.length
if event.keyCode == 38 # up
default_index = mpd.playlist.item_list.length - 1
dir = -1
else
default_index = 0
dir = 1
if event.ctrlKey
if selection.type is 'playlist'
# re-order playlist items
mpd.shiftIds (id for id of selection.playlist_ids), dir
else
# change selection
if selection.type is 'playlist'
next_pos = mpd.playlist.item_table[selection.cursor].pos + dir
return if next_pos < 0 or next_pos >= mpd.playlist.item_list.length
selection.cursor = mpd.playlist.item_list[next_pos].id
selection.playlist_ids = {} unless event.shiftKey
selection.playlist_ids[selection.cursor] = true
else
selection.type = 'playlist'
selection.cursor = mpd.playlist.item_list[default_index].id
(selection.playlist_ids = {})[selection.cursor] = true
refreshSelection()
if selection.type is 'playlist'
# scroll playlist into view of selection
top_pos = null
top_id = null
bottom_pos = null
bottom_id = null
for id of selection.playlist_ids
item_pos = mpd.playlist.item_table[id].pos
if not top_pos? or item_pos < top_pos
top_pos = item_pos
top_id = id
if not bottom_pos? or item_pos > bottom_pos
bottom_pos = item_pos
bottom_id = id
if top_pos?
selection_top = $("#playlist-track-#{top_id}").offset().top - $playlist_items.offset().top
selection_bottom = ($bottom_item = $("#playlist-track-#{bottom_id}")).offset().top + $bottom_item.height() - $playlist_items.offset().top - $playlist_items.height()
pl_items_scroll = $playlist_items.scrollTop()
if selection_top < 0
$playlist_items.scrollTop pl_items_scroll + selection_top
else if selection_bottom > 0
$playlist_items.scrollTop pl_items_scroll + selection_bottom
leftRightHandler = (event) ->
if event.keyCode == 37 # left
dir = -1
else
dir = 1
if event.ctrlKey
if dir > 0
mpd.next()
else
mpd.prev()
else if event.shiftKey
mpd.seek getCurrentTrackPosition() + dir * mpd.status.time * 0.10
else
mpd.seek getCurrentTrackPosition() + dir * 10
handlers =
27: # escape
ctrl: no
alt: no
shift: no
handler: ->
# if the user is dragging, abort the drag
if started_drag
abortDrag()
return
# if there's a menu, only remove that
if $("#menu").get().length > 0
removeContextMenu()
return
# clear selection
selection.type = null
refreshSelection()
32: # space
ctrl: no
alt: no
shift: no
handler: togglePlayback
37: # left
ctrl: null
alt: no
shift: null
handler: leftRightHandler
38: # up
ctrl: null
alt: no
shift: null
handler: upDownHandler
39: # right
ctrl: null
alt: no
shift: null
handler: leftRightHandler
40: # down
ctrl: null
alt: no
shift: null
handler: upDownHandler
46: # delete
ctrl: no
alt: no
shift: no
handler: handleDeletePressed
67: # 'c'
ctrl: no
alt: no
shift: yes
handler: -> mpd.clear()
68: # 'd'
ctrl: no
alt: no
shift: no
handler: toggleDynamicMode
72: # 'h'
ctrl: no
alt: no
shift: yes
handler: -> mpd.shuffle()
76: # 'l'
ctrl: no
alt: no
shift: no
handler: -> clickTab 'library'
82: # 'r'
ctrl: no
alt: no
shift: no
handler: nextRepeatState
83: # 's'
ctrl: no
alt: no
shift: no
handler: -> changeStreamStatus not stream?
85: # 'u'
ctrl: no
alt: no
shift: no
handler: -> clickTab 'upload'
187: # '=' or '+'
ctrl: no
alt: no
shift: null
handler: -> mpd.setVolume mpd.status.volume + 0.10
188: # ',' or '<'
ctrl: no
alt: no
shift: null
handler: -> mpd.prev()
189: # '-' or '_'
ctrl: no
alt: no
shift: null
handler: -> mpd.setVolume mpd.status.volume - 0.10
190: # '.' or '>'
ctrl: no
alt: no
shift: null
handler: -> mpd.next()
191: # '/' or '?'
ctrl: no
alt: no
shift: null
handler: (event) ->
if event.shiftKey
$(Handlebars.templates.shortcuts()).appendTo(document.body)
$("#shortcuts").dialog
modal: true
title: "Keyboard Shortcuts"
minWidth: 600
height: $document.height() - 40
close: -> $("#shortcuts").remove()
else
clickTab 'library'
$("#lib-filter").focus().select()
removeContextMenu = -> $("#menu").remove()
setUpUi = ->
$document.on 'mouseover', '.hoverable', (event) ->
$(this).addClass "ui-state-hover"
$document.on 'mouseout', '.hoverable', (event) ->
$(this).removeClass "ui-state-hover"
$(".jquery-button").button()
$pl_window = $("#playlist-window")
$pl_window.on 'click', 'button.clear', ->
mpd.clear()
$pl_window.on 'click', 'button.shuffle', ->
mpd.shuffle()
$pl_btn_repeat.on 'click', ->
nextRepeatState()
$dynamic_mode.on 'click', ->
value = $(this).prop("checked")
setDynamicMode(value)
return false
$playlist_items.on 'dblclick', '.pl-item', (event) ->
track_id = $(this).data('id')
mpd.playId track_id
$playlist_items.on 'contextmenu', (event) -> return event.altKey
$playlist_items.on 'mousedown', '.pl-item', (event) ->
$("#lib-filter").blur()
if event.button == 0
event.preventDefault()
# selecting / unselecting
removeContextMenu()
track_id = $(this).data('id')
skip_drag = false
if selection.type isnt 'playlist'
selection.type = 'playlist'
(selection.playlist_ids = {})[track_id] = true
selection.cursor = track_id
else if event.ctrlKey or event.shiftKey
skip_drag = true
if event.shiftKey and not event.ctrlKey
selection.playlist_ids = {}
if event.shiftKey
old_pos = if selection.cursor? then mpd.playlist.item_table[selection.cursor].pos else 0
new_pos = mpd.playlist.item_table[track_id].pos
for i in [old_pos..new_pos]
selection.playlist_ids[mpd.playlist.item_list[i].id] = true
else if event.ctrlKey
if selection.playlist_ids[track_id]?
delete selection.playlist_ids[track_id]
else
selection.playlist_ids[track_id] = true
selection.cursor = track_id
else if not selection.playlist_ids[track_id]?
(selection.playlist_ids = {})[track_id] = true
selection.cursor = track_id
refreshSelection()
# dragging
if not skip_drag
start_drag_x = event.pageX
start_drag_y = event.pageY
getDragPosition = (x, y) ->
# loop over the playlist items and find where it fits
best =
track_id: null
distance: null
direction: null
for item in $playlist_items.find(".pl-item").get()
$item = $(item)
pos = $item.offset()
height = $item.height()
track_id = parseInt($item.data('id'))
# try the top of this element
distance = Math.abs(pos.top - y)
if not best.distance? or distance < best.distance
best.distance = distance
best.direction = "top"
best.track_id = track_id
# try the bottom
distance = Math.abs(pos.top + height - y)
if distance < best.distance
best.distance = distance
best.direction = "bottom"
best.track_id = track_id
return best
abortDrag = ->
$document
.off('mousemove', onDragMove)
.off('mouseup', onDragEnd)
if started_drag
$playlist_items.find(".pl-item").removeClass('border-top').removeClass('border-bottom')
started_drag = false
onDragMove = (event) ->
if not started_drag
dist = Math.pow(event.pageX - start_drag_x, 2) + Math.pow(event.pageY - start_drag_y, 2)
started_drag = true if dist > 64
return unless started_drag
result = getDragPosition(event.pageX, event.pageY)
$playlist_items.find(".pl-item").removeClass('border-top').removeClass('border-bottom')
$("#playlist-track-#{result.track_id}").addClass "border-#{result.direction}"
onDragEnd = (event) ->
if started_drag
result = getDragPosition(event.pageX, event.pageY)
delta =
top: 0
bottom: 1
new_pos = mpd.playlist.item_table[result.track_id].pos + delta[result.direction]
mpd.moveIds (id for id of selection.playlist_ids), new_pos
else
# we didn't end up dragging, select the item
(selection.playlist_ids = {})[track_id] = true
selection.cursor = track_id
refreshSelection()
abortDrag()
$document
.on('mousemove', onDragMove)
.on('mouseup', onDragEnd)
onDragMove event
else if event.button == 2
return if event.altKey
event.preventDefault()
# context menu
removeContextMenu()
track_id = parseInt($(this).data('id'))
if selection.type isnt 'playlist' or not selection.playlist_ids[track_id]?
selection.type = 'playlist'
(selection.playlist_ids = {})[track_id] = true
selection.cursor = track_id
refreshSelection()
# adds a new context menu to the document
context =
item: mpd.playlist.item_table[track_id]
status: mpd.server_status
$(Handlebars.templates.playlist_menu(context))
.appendTo(document.body)
$menu = $("#menu") # get the newly created one
$menu.offset
left: event.pageX+1
top: event.pageY+1
# don't close menu when you click on the area next to a button
$menu.on 'mousedown', -> false
$menu.on 'click', '.remove', ->
handleDeletePressed()
removeContextMenu()
return false
$menu.on 'click', '.download', ->
removeContextMenu()
return true
# don't remove selection in playlist click
$playlist_items.on 'mousedown', -> false
# delete context menu
$document.on 'mousedown', ->
removeContextMenu()
selection.type = null
refreshSelection()
$document.on 'keydown', (event) ->
if (handler = keyboard_handlers[event.keyCode])? and
(not handler.ctrl? or handler.ctrl == event.ctrlKey) and
(not handler.alt? or handler.alt == event.altKey) and
(not handler.shift? or handler.shift == event.shiftKey)
handler.handler event
return false
return true
$library.on 'dblclick', 'div.track', (event) ->
mpd.queueFile $(this).data('file')
$library.on 'click', 'div.expandable > div.ui-icon', (event) ->
toggleExpansion $(this).closest("li")
$lib_filter = $("#lib-filter")
$lib_filter.on 'keydown', (event) ->
event.stopPropagation()
if event.keyCode == 27
# if the box is blank, remove focus
if $(event.target).val().length == 0
$(event.target).blur()
else
# defer the setting of the text box until after the event loop to
# work around a firefox bug
wait 0, ->
$(event.target).val("")
mpd.search ""
return false
else if event.keyCode == 13
# queue all the search results
files = []
for artist in mpd.search_results.artists
for album in artist.albums
for track in album.tracks
files.push track.file
if event.ctrlKey
shuffle(files)
if files.length > 2000
return false unless confirm("You are about to queue #{files.length} songs.")
func = if event.shiftKey then mpd.queueFilesNext else mpd.queueFiles
func files
return false
$lib_filter.on 'keyup', (event) ->
mpd.search $(event.target).val()
actions =
'toggle': togglePlayback
'prev': mpd.prev
'next': mpd.next
'stop': mpd.stop
$nowplaying = $("#nowplaying")
for cls, action of actions
do (cls, action) ->
$nowplaying.on 'mousedown', "li.#{cls}", (event) ->
action()
return false
$track_slider.slider
step: 0.0001
min: 0
max: 1
change: (event, ui) ->
return if not event.originalEvent?
mpd.seek ui.value * mpd.status.time
slide: (event, ui) ->
$nowplaying_elapsed.html formatTime(ui.value * mpd.status.time)
start: (event, ui) -> user_is_seeking = true
stop: (event, ui) -> user_is_seeking = false
setVol = (event, ui) ->
return if not event.originalEvent?
mpd.setVolume ui.value
$vol_slider.slider
step: 0.01
min: 0
max: 1
change: setVol
start: (event, ui) -> user_is_volume_sliding = true
stop: (event, ui) -> user_is_volume_sliding = false
# move the slider along the path
schedule 100, updateSliderPos
$stream_btn.button
icons:
primary: "ui-icon-signal-diag"
$stream_btn.on 'click', (event) ->
value = $(this).prop("checked")
changeStreamStatus value
$lib_tabs.on 'mouseover', 'li', (event) ->
$(this).addClass 'ui-state-hover'
$lib_tabs.on 'mouseout', 'li', (event) ->
$(this).removeClass 'ui-state-hover'
tabs = [
'library'
'upload'
'chat'
]
unselectTabs = ->
$lib_tabs.find('li').removeClass 'ui-state-active'
for tab in tabs
$("##{tab}-tab").hide()
clickTab = (name) ->
return if name is 'upload' and not mpd.server_status?.upload_enabled
unselectTabs()
$lib_tabs.find("li.#{name}-tab").addClass 'ui-state-active'
$("##{name}-tab").show()
for tab in tabs
do (tab) ->
$lib_tabs.on 'click', "li.#{tab}-tab", (event) ->
clickTab tab
uploader = new qq.FileUploader
element: document.getElementById("upload-widget")
action: '/upload'
encoding: 'multipart'
onComplete: (id, file_name, response_json) ->
want_to_queue.push file_name
initHandlebars = ->
Handlebars.registerHelper 'time', formatTime
handleResize = ->
$nowplaying = $("#nowplaying")
$left_window = $("#left-window")
$pl_window = $("#playlist-window")
# go really small to make the window as small as possible
$nowplaying.width MARGIN
$pl_window.height MARGIN
$left_window.height MARGIN
# then fit back up to the window
$nowplaying.width $document.width() - MARGIN * 2
second_layer_top = $nowplaying.offset().top + $nowplaying.height() + MARGIN
$left_window.offset
left: MARGIN
top: second_layer_top
$pl_window.offset
left: $left_window.offset().left + $left_window.width() + MARGIN
top: second_layer_top
$pl_window.width $(window).width() - $pl_window.offset().left - MARGIN
$left_window.height $(window).height() - $left_window.offset().top
$pl_window.height $left_window.height() - MARGIN
# make the inside containers fit
$lib_header = $("#library-tab .window-header")
$library.height $left_window.height() - $lib_header.position().top - $lib_header.height() - MARGIN
$("#upload").height $left_window.height() - $lib_tabs.height() - MARGIN
$pl_header = $pl_window.find("#playlist .header")
$playlist_items.height $pl_window.height() - $pl_header.position().top - $pl_header.height()
$document.ready ->
socket = io.connect()
mpd = new window.SocketMpd socket
mpd.on 'error', (msg) -> alert msg
mpd.on 'libraryupdate', ->
flushWantToQueue()
renderLibrary()
mpd.on 'playlistupdate', renderPlaylist
mpd.on 'statusupdate', ->
renderNowPlaying()
renderPlaylistButtons()
mpd.on 'serverstatus', ->
renderPlaylistButtons()
mpd.on 'connect', ->
mpd_alive = true
render()
setUpUi()
initHandlebars()
$(window).resize handleResize
render()
window._debug_mpd = mpd

View file

@ -1,6 +1,3 @@
@import "vendor/reset.min.css"
@import "vendor/jquery-ui-1.10.4.custom.min.css"
user-select()
-moz-user-select arguments
-khtml-user-select arguments
@ -12,16 +9,8 @@ border-radius()
-khtml-border-radius arguments
border-radius arguments
selected-div()
background #00498F url(images/ui-bg_dots-small_40_00498f_2x2.png) 50% 50% repeat
color #fff
html
background url(images/ui-bg_diagonals-thick_15_0b3e6f_40x40.png)
html.groovebasin
background url(img/groovebasin.jpg)
html.nggyu
background url(img/nggyu.jpg)
background url(vendor/css/dot-luv/images/ui-bg_diagonals-thick_15_0b3e6f_40x40.png)
body
font-family Arial, Helvetica, sans-serif
@ -29,9 +18,6 @@ body
#track-slider
margin 10px 10px 5px
background url("img/bright-10.png") left no-repeat
background-size 0% 100%
#nowplaying
margin 6px auto
text-align center
@ -45,7 +31,7 @@ body
list-style none outside none
margin 2px
padding 4px
h1
letter-spacing 0.1em
margin-top 8px
@ -87,11 +73,11 @@ body
width 400px
position absolute
#library-pane
#library-tab
.window-header
height 30px
#tabs
#lib-tabs
user-select none
ul
@ -101,13 +87,13 @@ body
display inline
span
font-size .625em
font-size .6em
padding 2px 4px
font-weight normal
cursor pointer
#library, #stored-playlists
#library
overflow-y auto
user-select none
@ -132,20 +118,11 @@ body
margin-left 9px
ul
margin-left 16px
div.selected
selected-div()
div.cursor
text-decoration underline
#lib-filter
margin 4px
width 175px
#user-id
cursor pointer
#organize
width 200px
@ -213,8 +190,11 @@ body
color #555555
div.selected
selected-div()
border 1px solid #222
background #00498F url(vendor/css/dot-luv/images/ui-bg_dots-small_40_00498f_2x2.png) 50% 50% repeat
font-weight bold
color #fff
div.current
border 1px solid #096AC8
background-color #292929
@ -222,8 +202,7 @@ body
color #75abff
div.cursor
span
text-decoration underline
text-decoration underline
div.border-bottom
border-bottom 2px dashed #ffffff
@ -243,22 +222,14 @@ body
#upload-widget
padding 10px
#upload-by-url
margin: 4px
width: 90%
.ui-menu
width: 240px
font-size: 1em
#menu-library .ui-state-disabled.ui-state-focus,
#menu-playlist .ui-state-disabled.ui-state-focus
margin: .3em -1px .2em
#menu-library .menu-item-last.ui-state-disabled.ui-state-focus,
#menu-playlist .menu-item-last.ui-state-disabled.ui-state-focus
margin: 5px -1px .2em
height: 23px
#menu
position absolute
padding 2px
li
a
display block
text-decoration none
padding 6px
#shortcuts
h1
@ -287,7 +258,7 @@ body
dd
display inline
#main-err-msg
#mpd-error
margin 200px auto
width 300px
padding 4px
@ -296,25 +267,3 @@ body
margin-right .3em
strong
font-weight bold
#settings
padding 6px
.section
margin-bottom 14px
h1
font-weight bold
font-size 1.4em
h2
font-size 1.1em
font-weight bold
button, label
font-size .8em
em
font-style italic
ul
font-size .9em
li:before
content "\2713"
.accesskey
text-decoration: underline

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();
}

File diff suppressed because one or more lines are too long

271
src/daemon.coffee Executable file
View file

@ -0,0 +1,271 @@
fs = require 'fs'
http = require 'http'
net = require 'net'
socketio = require 'socket.io'
static = require 'node-static'
formidable = require 'formidable'
url = require 'url'
util = require 'util'
mpd = require './lib/mpd'
public_dir = "./public"
status =
dynamic_mode: null # null -> disabled
random_ids: {}
stream_httpd_port: null
upload_enabled: false
download_enabled: false
users: []
next_user_id = 0
stickers_enabled = false
mpd_conf = null
# static server
fileServer = new (static.Server) public_dir
app = http.createServer((request, response) ->
parsed_url = url.parse(request.url)
if parsed_url.pathname == '/upload' and request.method == 'POST'
if status.upload_enabled
form = new formidable.IncomingForm()
form.parse request, (err, fields, file) ->
moveFile file.qqfile.path, "#{mpd_conf.music_directory}/#{file.qqfile.name}"
response.writeHead 200, {'content-type': 'text/html'}
response.end JSON.stringify {success: true}
else
response.writeHead 500, {'content-type': 'text/plain'}
response.end JSON.stringify {success: false, reason: "Uploads not enabled"}
else
request.addListener 'end', ->
fileServer.serve request, response
).listen(process.env.npm_package_config_port)
io = socketio.listen(app)
io.set 'log level', process.env.npm_package_config_log_level
log = io.log
log.info "Serving at http://localhost:#{process.env.npm_package_config_port}/"
# read mpd conf
do ->
try
data = fs.readFileSync(process.env.npm_package_config_mpd_conf)
catch error
log.warn "Unable to read mpd conf file: #{error}"
return
mpd_conf = require('./lib/mpdconf').parse(data.toString())
if mpd_conf.music_directory?
status.upload_enabled = true
status.download_enabled = true
else
log.warn "music directory not found in mpd conf"
if not (status.stream_httpd_port = mpd_conf.audio_output?.httpd?.port)?
log.warn "httpd streaming not enabled in mpd conf"
if mpd_conf.sticker_file?
# changing from null to false, enables but does not turn on dynamic mode
status.dynamic_mode = false
stickers_enabled = true
else
log.warn "sticker_file not set in mpd conf"
if mpd_conf.auto_update isnt "yes"
log.warn "recommended to turn auto_update on in mpd conf"
if mpd_conf.gapless_mp3_playback isnt "yes"
log.warn "recommended to turn gapless_mp3_playback on in mpd conf"
if mpd_conf.volume_normalization isnt "yes"
log.warn "recommended to turn volume_normalization on in mpd conf"
if isNaN(n = parseInt(mpd_conf.max_command_list_size)) or n < 16384
log.warn "recommended to set max_command_list_size to >= 16384 in mpd conf"
# set up library link
if mpd_conf?.music_directory?
library_link = "#{public_dir}/library"
try fs.unlinkSync library_link
ok = true
do ->
try
fs.symlinkSync mpd_conf.music_directory, library_link
catch error
log.warn "Unable to link public/library to #{mpd_conf.music_directory}: #{error}"
status.upload_enabled = false
status.download_enabled = false
return
try
fs.readdirSync library_link
catch error
status.upload_enabled = false
status.download_enabled = false
err? and log.warn "Unable to access music directory: #{error}"
moveFile = (source, dest) ->
in_stream = fs.createReadStream(source)
out_stream = fs.createWriteStream(dest)
util.pump in_stream, out_stream, -> fs.unlink source
createMpdConnection = (cb) ->
net.connect mpd_conf?.port ? 6600, mpd_conf?.bind_to_address ? "localhost", cb
sendStatus = ->
my_mpd.sendCommand "sendmessage Status #{JSON.stringify JSON.stringify status}"
setDynamicMode = (value) ->
# return if dynamic mode is disabled
return unless status.dynamic_mode?
return if status.dynamic_mode == value
status.dynamic_mode = value
checkDynamicMode()
sendStatus()
previous_ids = {}
checkDynamicMode = ->
return unless stickers_enabled
item_list = my_mpd.playlist.item_list
current_id = my_mpd.status?.current_item?.id
current_index = -1
all_ids = {}
new_files = []
for item, i in item_list
if item.id == current_id
current_index = i
all_ids[item.id] = true
if not previous_ids[item.id]?
new_files.push item.track.file
# tag any newly queued tracks
my_mpd.sendCommands ("sticker set song \"#{file}\" \"#{sticker_name}\" #{JSON.stringify new Date()}" for file in new_files)
# anticipate the changes
my_mpd.library.track_table[file].last_queued = new Date() for file in new_files
# if no track is playing, assume the first track is about to be
if current_index == -1
current_index = 0
else
# any tracks <= current track don't count as random anymore
for i in [0..current_index]
delete status.random_ids[item_list[i].id]
if status.dynamic_mode
commands = []
delete_count = Math.max(current_index - 10, 0)
for i in [0...delete_count]
commands.push "deleteid #{item_list[i].id}"
add_count = Math.max(11 - (item_list.length - current_index), 0)
commands = commands.concat ("addid #{JSON.stringify file}" for file in getRandomSongFiles add_count)
my_mpd.sendCommands commands, (msg) ->
# track which ones are the automatic ones
changed = false
for line in msg.split("\n")
[name, value] = line.split(": ")
continue if name != "Id"
status.random_ids[value] = 1
changed = true
sendStatus() if changed
# scrub the random_ids
new_random_ids = {}
for id of status.random_ids
if all_ids[id]
new_random_ids[id] = 1
status.random_ids = new_random_ids
previous_ids = all_ids
sendStatus()
sticker_name = "groovebasin.last-queued"
updateStickers = ->
my_mpd.sendCommand "sticker find song \"/\" \"#{sticker_name}\"", (msg) ->
current_file = null
for line in msg.split("\n")
[name, value] = mpd.split_once line, ": "
if name == "file"
current_file = value
else if name == "sticker"
[_, value] = mpd.split_once value, "="
my_mpd.library.track_table[current_file].last_queued = new Date(value)
getRandomSongFiles = (count) ->
return [] if count == 0
never_queued = []
sometimes_queued = []
for _, track of my_mpd.library.track_table
if track.last_queued?
sometimes_queued.push track
else
never_queued.push track
# backwards by time
sometimes_queued.sort (a, b) ->
b.last_queued.getTime() - a.last_queued.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)
rectangle_area = max_weight * never_queued.length
total_size = triangle_area + rectangle_area
# decode indexes through the distribution shape
files = []
for i in [0...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
io.sockets.on 'connection', (socket) ->
user_id = "user_" + next_user_id
next_user_id += 1
status.users.push user_id
mpd_socket = createMpdConnection ->
log.debug "browser to mpd connect"
mpd_socket.on 'data', (data) ->
socket.emit 'FromMpd', data.toString()
mpd_socket.on 'end', ->
log.debug "browser mpd disconnect"
try socket.emit 'disconnect'
mpd_socket.on 'error', ->
log.debug "browser no mpd daemon found."
socket.on 'ToMpd', (data) ->
log.debug "[in] " + data
try mpd_socket.write data
socket.on 'DynamicMode', (data) ->
log.debug "DynamicMode is being turned #{data.toString()}"
value = JSON.parse data.toString()
setDynamicMode value
socket.on 'disconnect', ->
mpd_socket.end()
status.users = (id for id in status.users when id != user_id)
sendStatus()
# our own mpd connection
class DirectMpd extends mpd.Mpd
constructor: (@mpd_socket) ->
super()
@mpd_socket.on 'data', (data) =>
@receive data.toString()
@mpd_socket.on 'end', ->
log.warn "server mpd disconnect"
@mpd_socket.on 'error', ->
log.warn "server no mpd daemon found."
# whenever anyone joins, send status to everyone
@updateFuncs.subscription = ->
sendStatus()
@updateFuncs.sticker = ->
updateStickers()
rawSend: (data) =>
try @mpd_socket.write data
my_mpd = null
my_mpd_socket = createMpdConnection ->
log.debug "server to mpd connect"
my_mpd.handleConnectionStart()
my_mpd = new DirectMpd(my_mpd_socket)
my_mpd.on 'error', (msg) ->
log.error msg
my_mpd.on 'statusupdate', checkDynamicMode
my_mpd.on 'playlistupdate', checkDynamicMode
my_mpd.on 'libraryupdate', updateStickers
# downgrade user permissions
try process.setuid uid if (uid = process.env.npm_package_config_user_id)?

741
src/mpd.coffee Normal file
View file

@ -0,0 +1,741 @@
# library structure: {
# artists: [sorted list of {artist structure}],
# track_table: {"track file" => {track structure}},
# artist_table: {"artist name" => {artist structure}},
# }
# artist structure: {
# name: "Artist Name",
# albums: [sorted list of {album structure}],
# }
# album structure: {
# name: "Album Name",
# year: 1999,
# tracks: [sorted list of {track structure}],
# }
# track structure: {
# name: "Track Name",
# track: 9,
# artist: {artist structure},
# album: {album structure},
# file: "Obtuse/Cloudy Sky/06. Temple of Trance.mp3",
# time: 263, # length in seconds
# }
# playlist structure: {
# item_list: [sorted list of {playlist item structure}],
# item_table: {song id => {playlist item structure}}
# }
# playlist item structure: {
# id: 7, # playlist song id
# track: {track structure},
# pos: 2, # 0-based position in the playlist
# }
# status structure: {
# volume: .89, # float 0-1
# repeat: true, # whether we're in repeat mode. see also `single`
# random: false, # random mode makes the next song random within the playlist
# single: true, # true -> repeat the current song, false -> repeat the playlist
# consume: true, # true -> remove tracks from playlist after done playing
# state: "play", # or "stop" or "pause"
# time: 234, # length of song in seconds
# track_start_date: new Date(), # absolute datetime of now - position of current time
# bitrate: 192, # number of kbps
# current_item: {playlist item structure},
# }
# search_results structure mimics library structure
# To inherit from this class:
# Define these methods:
# rawSend: (data) => # send data untouched to mpd
#
# Call these methods:
# receive: (data) => # when you get data from mpd
######################### global #####################
exports ?= window
######################### static #####################
DEFAULT_ARTIST = "[Unknown Artist]"
VARIOUS_ARTISTS = "Various Artists"
MPD_SENTINEL = /^(OK|ACK|list_OK)(.*)$/m
__trimLeft = /^\s+/
__trimRight = /\s+$/
__trim = String.prototype.trim
trim = if __trim?
(text) ->
if not text? then "" else __trim.call text
else
(text) ->
if not text? then "" else text.toString().replace(__trimLeft, "").replace(__trimRight, "")
extend = (obj, args...) ->
for arg in args
for prop, val of arg
obj[prop] = val
return obj
elapsedToDate = (elapsed) -> new Date((new Date()) - elapsed * 1000)
dateToElapsed = (date) -> ((new Date()) - date) / 1000
bound = (min, val, max) ->
if val < min
min
else if val > max
max
else
val
fromMpdVol = (vol) -> vol / 100
toMpdVol = (vol) -> bound(0, Math.round(parseFloat(vol) * 100), 100)
startsWith = (string, str) -> string.substring(0, str.length) == str
stripPrefixes = ['the ', 'a ', 'an ']
sortableTitle = (title) ->
t = title.toLowerCase()
for prefix in stripPrefixes
if startsWith(t, prefix)
t = t.substring(prefix.length)
break
t
titleCompare = (a,b) ->
_a = sortableTitle(a)
_b = sortableTitle(b)
if _a < _b
-1
else if _a > _b
1
else
# At this point we compare the original strings. Our cache update code
# depends on this behavior.
if a < b
-1
else if a > b
1
else
0
noop = ->
escape = (str) ->
# replace all " with \"
str.toString().replace /"/g, '\\"'
pickNRandomProps = (obj, n) ->
return [] if n == 0
results = []
count = 0
for prop of obj
count += 1
for i in [0...n]
if Math.random() < 1 / count
results[i] = prop
return results
sign = (n) -> if n > 0 then 1 else if n < 0 then -1 else 0
boolToInt = (b) -> if b then 1 else 0
exports.split_once = split_once = (line, separator) ->
# should be line.split(separator, 1), but javascript is stupid
index = line.indexOf(separator)
return [line.substr(0, index), line.substr(index + separator.length)]
exports.Mpd = class Mpd
######################### private #####################
on: (event_name, handler) =>
(@event_handlers[event_name] ?= []).push handler
raiseEvent: (event_name, args...) =>
# create copy so handlers can remove themselves
handlers_list = extend [], @event_handlers[event_name] || []
handler(args...) for handler in handlers_list
handleMessage: (msg) =>
handler = @msgHandlerQueue.shift()
@debugMsgConsole?.log "get-: #{handler.debug_id}: " + JSON.stringify(msg)
handler.cb(msg) if msg?
send: (msg) =>
@debugMsgConsole?.log "send: #{@msgHandlerQueue[@msgHandlerQueue.length - 1]?.debug_id ? -1}: " + JSON.stringify(msg)
@rawSend msg
receive: (data) =>
@buffer += data
loop
m = @buffer.match(MPD_SENTINEL)
return if not m?
msg = @buffer.substring(0, m.index)
[line, code, str] = m
if code == "ACK"
@raiseEvent 'error', str
# flush the handler
@handleMessage null
else if line.indexOf("OK MPD") == 0
# new connection
@raiseEvent 'connect'
else
@handleMessage msg
@buffer = @buffer.substring(msg.length+line.length+1)
handleIdleResults: (msg) =>
(@updateFuncs[system.substring(9)] ? noop)() for system in trim(msg).split("\n") when system.length > 0
# cache of playlist data from mpd
clearPlaylist: =>
@playlist = {}
@playlist.item_list = []
@playlist.item_table = {}
anticipatePlayId: (track_id) =>
item = @playlist.item_table[track_id]
@status.current_item = item
@status.state = "play"
@status.time = item.track.time
@status.track_start_date = new Date()
@raiseEvent 'statusupdate'
anticipateSkip: (direction) =>
next_item = @playlist.item_list[@status.current_item.pos + direction]
if next_item?
@anticipatePlayId next_item.id
parseMpdTracks: (msg) =>
if msg == ""
return []
# build list of tracks from msg
mpd_tracks = []
current_track = null
flush_current_track = ->
if current_track != null
mpd_tracks.push(current_track)
current_track = {}
for line in msg.split("\n")
[key, value] = split_once line, ": "
continue if key == 'directory'
flush_current_track() if key == 'file'
current_track[key] = value
flush_current_track()
mpd_tracks
parseMaybeUndefNumber = (n) ->
n = parseInt(n)
n = "" if isNaN(n)
return n
mpdTracksToTrackObjects: (mpd_tracks) =>
tracks = []
for mpd_track in mpd_tracks
artist_name = trim(mpd_track.Artist)
track =
file: mpd_track.file
name: mpd_track.Title || mpd_track.file.substr mpd_track.file.lastIndexOf('/') + 1
artist_name: artist_name
artist_disambiguation: ""
album_artist_name: mpd_track.AlbumArtist or artist_name
album_name: trim(mpd_track.Album)
track: parseMaybeUndefNumber(mpd_track.Track)
time: parseInt(mpd_track.Time)
year: parseMaybeUndefNumber(mpd_track.Date)
track.search_tags = [track.artist_name, track.album_artist_name, track.album_name, track.name, track.file].join("\n").toLowerCase()
tracks.push track
tracks
getOrCreate = (key, table, initObjFunc) ->
result = table[key]
if not result?
result = initObjFunc()
# insert into table
table[key] = result
return result
makeComparator = (order_keys) ->
(a, b) ->
for order_key in order_keys
a = a[order_key]
b = b[order_key]
if a < b
return -1
if a > b
return 1
return 0
trackComparator = makeComparator ["track", "name"]
albumComparator = makeComparator ["year", "name"]
artistComparator = (a, b) ->
titleCompare a.name, b.name
buildArtistAlbumTree: (tracks, library) =>
# determine set of albums
library.track_table = {}
album_table = {}
for track in tracks
library.track_table[track.file] = track
if track.album_name == ""
album_key = track.album_artist_name + "\n"
else
album_key = track.album_name + "\n"
album_key = album_key.toLowerCase()
album = getOrCreate album_key, album_table, -> {name: track.album_name, year: track.year, tracks: []}
album.tracks.push track
album.year = album_year if not album.year?
# find compilation albums and create artist objects
artist_table = {}
for k, album of album_table
# count up all the artists and album artists mentioned in this album
album_artists = {}
album.tracks.sort trackComparator
for track in album.tracks
album_artist_name = track.album_artist_name
album_artists[album_artist_name.toLowerCase()] = 1
album_artists[track.artist_name.toLowerCase()] = 1
artist_count = 0
for k of album_artists
artist_count += 1
if artist_count > 1
# multiple artists. we're sure it's a compilation album.
album_artist_name = VARIOUS_ARTISTS
if album_artist_name == VARIOUS_ARTISTS
# make sure to disambiguate the artist names
for track in album.tracks
track.artist_disambiguation = track.artist_name
artist = getOrCreate album_artist_name.toLowerCase(), artist_table, -> {name: album_artist_name, albums: []}
artist.albums.push album
# collect list of artists and sort albums
library.artists = []
various_artist = null
for k, artist of artist_table
artist.albums.sort albumComparator
if artist.name == VARIOUS_ARTISTS
various_artist = artist
else
library.artists.push artist
# sort artists
library.artists.sort artistComparator
# various artists goes first
library.artists.splice 0, 0, various_artist if various_artist?
library.artist_table = artist_table
sendWithCallback: (cmd, cb=noop) =>
@msgHandlerQueue.push
debug_id: @msgCounter++
cb: cb
@send cmd + "\n"
handleIdleResultsLoop: (msg) =>
@handleIdleResults(msg)
# if we have nothing else to do, idle.
if @msgHandlerQueue.length == 0
@sendWithCallback "idle", @handleIdleResultsLoop
fixPlaylistPosCache: => item.pos = i for item, i in @playlist.item_list
######################### public #####################
constructor: ->
@buffer = ""
@msgHandlerQueue = []
# assign to console to enable message passing debugging
@debugMsgConsole = null #console
@msgCounter = 0
# whether we've sent the idle command to mpd
@idling = false
@event_handlers = {}
@haveFileListCache = false
# maps mpd subsystems to our function to call which will update ourself
@updateFuncs =
database: => # the song database has been modified after update.
@haveFileListCache = false
@updateLibrary()
update: noop # a database update has started or finished. If the database was modified during the update, the database event is also emitted.
stored_playlist: noop # a stored playlist has been modified, renamed, created or deleted
playlist: @updatePlaylist # the current playlist has been modified
player: @updateStatus # the player has been started, stopped or seeked
mixer: @updateStatus # the volume has been changed
output: noop # an audio output has been enabled or disabled
options: @updateStatus # options like repeat, random, crossfade, replay gain
sticker: noop # the sticker database has been modified.
subscription: noop # a client has subscribed or unsubscribed to a channel
message: @readChannelMessages # a message was received on a channel this client is subscribed to; this event is only emitted when the queue is empty
@channel_handlers =
Status: @handleServerStatus
# cache of library data from mpd. See comment at top of this file
@library =
artists: []
track_table: {}
@search_results = @library
@last_query = ""
@clearPlaylist()
@status =
current_item: null
removeEventListeners: (event_name) =>
(@event_handlers[event_name] || []).length = 0
removeListener: (event_name, handler) =>
handlers = @event_handlers[event_name] || []
for h, i in handlers
if h is handler
handlers.splice i, 1
return
getArtistAlbums: (artist_name) =>
key = if artist_name is DEFAULT_ARTIST then "" else artist_name.toLowerCase()
return @search_results.artist_table[key].albums
handleConnectionStart: =>
@sendCommand 'subscribe Status'
@updateLibrary()
@updateStatus()
@updatePlaylist()
sendCommand: (command, callback=noop) =>
@send "noidle\n" if @idling
@sendWithCallback command, callback
@sendWithCallback "idle", @handleIdleResultsLoop
@idling = true # we're always idling after the first command.
sendCommands: (command_list, callback=noop) =>
return if command_list.length == 0
@sendCommand "command_list_begin\n#{command_list.join("\n")}\ncommand_list_end", callback
updateLibrary: =>
@sendCommand 'listallinfo', (msg) =>
tracks = @mpdTracksToTrackObjects @parseMpdTracks msg
@buildArtistAlbumTree tracks, @library
@haveFileListCache = true
# in case the user has a search open, we'll apply their search again.
last_query = @last_query
# reset last query so that search is forced to run again
@last_query = ""
@search last_query
updatePlaylist: (callback=noop) =>
@sendCommand "playlistinfo", (msg) =>
mpd_tracks = @parseMpdTracks msg
@clearPlaylist()
for mpd_track in mpd_tracks
id = parseInt(mpd_track.Id)
obj =
id: id
track: @library.track_table[mpd_track.file]
pos: @playlist.item_list.length
@playlist.item_list.push obj
@playlist.item_table[id] = obj
# make sure current track data is correct
if @status.current_item?
@status.current_item = @playlist.item_table[@status.current_item.id]
if @status.current_item?
# looks good, notify listeners
@raiseEvent 'playlistupdate'
callback()
else
# we need a status update before raising a playlist update event
@updateStatus =>
callback()
@raiseEvent 'playlistupdate'
updateStatus: (callback=noop) =>
@sendCommand "status", (msg) =>
# no dict comprehensions :(
# https://github.com/jashkenas/coffee-script/issues/77
o = {}
for [key, val] in (split_once(line, ": ") for line in msg.split("\n"))
o[key] = val
extend @status,
volume: parseInt(o.volume) / 100
repeat: parseInt(o.repeat) != 0
random: parseInt(o.random) != 0
single: parseInt(o.single) != 0
consume: parseInt(o.consume) != 0
state: o.state
time: null
bitrate: null
track_start_date: null
@status.bitrate = parseInt(o.bitrate) if o.bitrate?
if o.time? and o.elapsed?
@status.time = parseInt(o.time.split(":")[1])
# we still add elapsed for when its paused
@status.elapsed = parseFloat(o.elapsed)
# add a field for the start date of the current track
@status.track_start_date = elapsedToDate(@status.elapsed)
@sendCommand "currentsong", (msg) =>
mpd_tracks = @parseMpdTracks msg
if mpd_tracks.length == 0
# no current song
@status.current_item = null
callback()
@raiseEvent 'statusupdate'
return
# there's either 0 or 1
for mpd_track in mpd_tracks
id = parseInt(mpd_track.Id)
pos = parseInt(mpd_track.Pos)
@status.current_item = @playlist.item_table[id]
if @status.current_item? and @status.current_item.pos == pos
@status.current_item.track = @library.track_table[mpd_track.file]
# looks good, notify listeners
@raiseEvent 'statusupdate'
callback()
else
# missing or inconsistent playlist data, need to get playlist update
@status.current_item =
id: id
pos: pos
track: @library.track_table[mpd_track.file]
@updatePlaylist =>
callback()
@raiseEvent 'statusupdate'
readChannelMessages: =>
@sendCommand 'readmessages', (msg) =>
lines = msg.split("\n")
channel_to_messages = {}
current_channel = null
for line in lines
continue if line == ""
[name, value] = split_once line, ": "
if name == "channel"
current_channel = value
else if name == "message"
(channel_to_messages[current_channel] ?= []).push value
else
throw null
for channel, messages of channel_to_messages
@channel_handlers[channel] message for message in messages
handleServerStatus: (msg) =>
@server_status = JSON.parse(msg)
@raiseEvent 'serverstatus'
# puts the search results in search_results
search: (query) =>
query = trim(query)
if query.length == 0
@search_results = @library
@raiseEvent 'libraryupdate'
return
words = query.toLowerCase().split(/\s+/)
query = words.join(" ")
return if query == @last_query
@last_query = query
result = []
for k, track of @library.track_table
is_match = (->
for word in words
if track.search_tags.indexOf(word) == -1
return false
return true
)()
result.push track if is_match
# zip results into album
@buildArtistAlbumTree result, @search_results = {}
@raiseEvent 'libraryupdate'
queueRandomTracksCommands: (n) =>
if not @haveFileListCache
return []
("addid \"#{escape(file)}\"" for file in pickNRandomProps(@library.track_table, n))
queueRandomTracks: (n) =>
@sendCommands @queueRandomTracksCommands n
queueFiles: (files) =>
return unless files.length
# queue tracks just before any random ones
pos = @playlist.item_list.length
if @server_status?
for item, i in @playlist.item_list
if @server_status.random_ids[item.id]?
pos = i
break
@queueFilesAtPos files, pos
queueFilesAtPos: (files, pos) =>
cmds = []
for i in [files.length-1..0]
file = files[i]
cmds.push "addid \"#{escape(file)}\" #{pos}"
items = ({id: null, pos: null, track: @library.track_table[file]} for file in files)
@playlist.item_list.splice pos, 0, items...
@fixPlaylistPosCache()
@sendCommands cmds, (msg) =>
for line, i in msg.split("\n")
index = files.length - 1 - i
item_id = parseInt(line.substring(4))
items[index] = item_id
@raiseEvent 'playlistupdate'
queueFile: (file) => @queueFiles [file]
queueFilesNext: (files) =>
new_pos = (@status.current_item?.pos ? -1) + 1
@queueFilesAtPos files, new_pos
queueFileNext: (file) => @queueFilesNext [file]
clear: =>
@sendCommand "clear"
@clearPlaylist()
@raiseEvent 'playlistupdate'
shuffle: => @sendCommand "shuffle"
stop: =>
@sendCommand "stop"
@status.state = "stop"
@raiseEvent 'statusupdate'
play: =>
@sendCommand "play"
if @status.state is "pause"
@status.track_start_date = elapsedToDate(@status.elapsed)
@status.state = "play"
@raiseEvent 'statusupdate'
pause: =>
@sendCommand "pause 1"
if @status.state is "play"
@status.elapsed = dateToElapsed(@status.track_start_date)
@status.state = "pause"
@raiseEvent 'statusupdate'
next: =>
@sendCommand "next"
@anticipateSkip 1
prev: =>
@sendCommand "previous"
@anticipateSkip -1
playId: (track_id) =>
track_id = parseInt(track_id)
@sendCommand "playid #{escape(track_id)}"
@anticipatePlayId track_id
moveIds: (track_ids, pos) =>
pos = parseInt(pos)
# get the playlist items for the ids
items = (item for id in track_ids when (item = @playlist.item_table[id])?)
# sort the list by the reverse order in the playlist
items.sort (a, b) -> b.pos - a.pos
cmds = []
while items.length > 0
if pos <= items[0].pos
real_pos = pos
item = items.shift()
else
real_pos = pos - 1
item = items.pop()
cmds.push "moveid #{item.id} #{real_pos}"
@playlist.item_list.splice item.pos, 1
@playlist.item_list.splice real_pos, 0, item
@fixPlaylistPosCache()
@sendCommands cmds
@raiseEvent 'playlistupdate'
# shifts the list of ids by offset, winamp style
shiftIds: (track_ids, offset) =>
offset = parseInt(offset)
return if offset == 0 or track_ids.length == 0
items = (item for id in track_ids when (item = @playlist.item_table[id])?)
items.sort (a,b) ->
sign(offset) * (b.pos - a.pos)
# abort if any are out of bounds
for item in [items[0], items[items.length-1]]
new_pos = item.pos + offset
return if new_pos < 0 or new_pos >= @playlist.item_list.length
@sendCommands ("moveid #{item.id} #{item.pos + offset}" for item in items)
# anticipate the result
for item in items
@playlist.item_list.splice item.pos, 1
@playlist.item_list.splice item.pos+offset, 0, item
@fixPlaylistPosCache()
@raiseEvent 'playlistupdate'
removeIds: (track_ids) =>
cmds = []
for track_id in track_ids
track_id = parseInt(track_id)
if @status.current_item?.id == track_id
@anticipateSkip 1
if @status.state isnt "play"
@status.state = "stop"
cmds.push "deleteid #{escape(track_id)}"
item = @playlist.item_table[track_id]
delete @playlist.item_table[item.id]
@playlist.item_list.splice(item.pos, 1)
@fixPlaylistPosCache()
@sendCommands cmds
@raiseEvent 'playlistupdate'
removeId: (track_id) =>
@removeIds [track_id]
close: => @send "close\n" # bypass message queue
# in seconds
seek: (pos) =>
pos = parseFloat(pos)
pos = 0 if pos < 0
pos = @status.time if pos > @status.time
@sendCommand "seekid #{@status.current_item.id} #{Math.round(pos)}"
@status.track_start_date = elapsedToDate(pos)
@raiseEvent 'statusupdate'
# between 0 and 1
setVolume: (vol) =>
vol = toMpdVol(vol)
@sendCommand "setvol #{vol}"
@status.volume = fromMpdVol(vol)
@raiseEvent 'statusupdate'
changeStatus: (status) =>
cmds = []
if status.consume?
@status.consume = status.consume
cmds.push "consume #{boolToInt(status.consume)}"
if status.random?
@status.random = status.random
cmds.push "random #{boolToInt(status.random)}"
if status.repeat?
@status.repeat = status.repeat
cmds.push "repeat #{boolToInt(status.repeat)}"
if status.single?
@status.single = status.single
cmds.push "single #{boolToInt(status.single)}"
@sendCommands cmds
@raiseEvent 'statusupdate'

32
src/mpdconf.coffee Normal file
View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -1,365 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Groove Basin</title>
<link rel="stylesheet" href="app.css">
<link rel="shortcut icon" href="/favicon.png">
</head>
<body>
<div id="nowplaying" class="ui-widget-content ui-corner-all" style="display: none">
<ul class="playback-btns ui-widget ui-helper-clearfix">
<li class="ui-state-default ui-corner-all hoverable prev">
<span class="ui-icon ui-icon-seek-prev"></span>
</li>
<li class="ui-state-default ui-corner-all hoverable toggle">
<span class="ui-icon ui-icon-pause"></span>
</li>
<li class="ui-state-default ui-corner-all hoverable stop">
<span class="ui-icon ui-icon-stop"></span>
</li>
<li class="ui-state-default ui-corner-all hoverable next">
<span class="ui-icon ui-icon-seek-next"></span>
</li>
</ul>
<div id="vol">
<span class="ui-icon ui-icon-volume-off"></span>
<div id="vol-slider"></div>
<span class="ui-icon ui-icon-volume-on"></span>
</div>
<div id="more-playback-btns">
<input class="jquery-button" type="checkbox" id="stream-btn"><label id="stream-btn-label" for="stream-btn">Stream</label>
</div>
<h1 id="track-display"></h1>
<div id="track-slider"></div>
<span class="time elapsed"></span>
<span class="time left"></span>
<div style="clear: both;"></div>
</div>
<div id="left-window" style="display: none">
<div id="tabs" class="ui-widget 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 upload-tab"><span>Upload</span></li>
<li class="ui-state-default ui-corner-top settings-tab"><span>Settings</span></li>
</ul>
</div>
<div id="library-pane" class="ui-widget-content ui-corner-all">
<div class="window-header">
<input type="text" id="lib-filter" placeholder="filter">
<select id="organize">
<option selected="selected">Artist / Album / Song</option>
</select>
</div>
<div id="library">
<ul id="library-artists">
</ul>
<p id="library-no-items" class="ui-state-highlight ui-corner-all">
<span class="ui-icon ui-icon-info"></span>
<strong id="empty-library-message">loading...</strong>
</p>
</div>
</div>
<div id="upload-pane" class="ui-widget-content ui-corner-all" style="display: none">
<div id="upload">
<input id="upload-by-url" type="text" placeholder="Paste URL here">
<div id="upload-widget">
<input type="file" id="upload-input" multiple="multiple" placeholder="Drag and drop or click to browse">
</div>
<div>
Automatically queue uploads: <input class="jquery-button" type="checkbox" id="auto-queue-uploads"><label for="auto-queue-uploads">On</label>
</div>
</div>
</div>
<div id="settings-pane" class="ui-widget-content ui-corner-all" style="display: none">
<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 id="playlist-window" class="ui-widget-content ui-corner-all" style="display: none">
<div class="window-header">
<button class="jquery-button clear">Clear</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="pl-btn-repeat"><label id="pl-btn-repeat-label" for="pl-btn-repeat">Repeat: Off</label>
</div>
<div id="playlist">
<div class="header">
<span class="track">&nbsp;</span>
<span class="title">Title</span>
<span class="artist">Artist</span>
<span class="album">Album</span>
<span class="time">Time</span>
</div>
<div id="playlist-items">
</div>
</div>
</div>
<div style="clear: both"></div>
<div id="main-err-msg" class="ui-state-error ui-corner-all">
<p>
<span class="ui-icon ui-icon-alert"></span>
<div id="main-err-msg-text">Loading...</div>
</p>
</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>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

11
src/socketmpd.coffee Normal file
View file

@ -0,0 +1,11 @@
window.WEB_SOCKET_SWF_LOCATION = "/public/vendor/socket.io/WebSocketMain.swf"
window.SocketMpd = class SocketMpd extends window.Mpd
constructor: (socket) ->
super()
@socket = socket
@socket.on 'FromMpd', (data) =>
@receive data
@socket.on 'connect', @handleConnectionStart
rawSend: (msg) =>
@socket.emit 'ToMpd', msg

17
views/albums.handlebars Normal file
View file

@ -0,0 +1,17 @@
{{#albums}}
<li>
<div class="album expandable">
<div class="ui-icon ui-icon-triangle-1-e"></div>
<span>{{#if name}}{{name}}{{else}}[Unknown Album]{{/if}}</span>
</div>
<ul style="display: none;">
{{#tracks}}
<li>
<div class="track" data-file="{{file}}">
<span>{{#if track}}{{track}}. {{/if}}{{#if artist_disambiguation}}{{artist_disambiguation}} - {{/if}}{{name}}</span>
</div>
</li>
{{/tracks}}
</ul>
</li>
{{/albums}}

7
views/chat.handlebars Normal file
View file

@ -0,0 +1,7 @@
<ul>
{{#each users}}
<li>
{{this}}
</li>
{{/each}}
</ul>

18
views/library.handlebars Normal file
View file

@ -0,0 +1,18 @@
{{#if artists}}
<ul>
{{#artists}}
<li>
<div class="artist expandable">
<div class="ui-icon ui-icon-triangle-1-e"></div>
<span>{{#if name}}{{name}}{{else}}[Unknown Artist]{{/if}}</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}}

12
views/playlist.handlebars Normal file
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,10 @@
<ul id="menu" class="ui-widget-content ui-corner-all">
<li><a href="#" class="remove hoverable">Remove</a></li>
<li>
{{#if status.download_enabled}}
<a href="library/{{item.track.file}}" class="download hoverable" target="_blank">Download</a>
{{else}}
<span title="Download is disabled due to invalid server configuration.">Download</span>
{{/if}}
</li>
</ul>

134
views/shortcuts.handlebars Normal file
View file

@ -0,0 +1,134 @@
<div id="shortcuts" style="display: none">
<h1>Playback</h1>
<dl>
<dt>space</dt>
<dd>Toggle playback</dd>
</dl>
<dl>
<dt>left</dt>
<dd>Skip a few seconds backward</dd>
</dl>
<dl>
<dt>right</dt>
<dd>Skip a few seconds forward</dd>
</dl>
<dl>
<dt>Shift + left</dt>
<dd>Skip backward 10%</dd>
</dl>
<dl>
<dt>Shift + right</dt>
<dd>Skip forward 10%</dd>
</dl>
<dl>
<dt>&lt; <em>or</em> Ctrl + left</dt>
<dd>Skip to previous track</dd>
</dl>
<dl>
<dt>&gt; <em>or</em> Ctrl + right</dt>
<dd>Skip to next track</dd>
</dl>
<dl>
<dt>-</dt>
<dd>Volume down</dd>
</dl>
<dl>
<dt>+</dt>
<dd>Volume up</dd>
</dl>
<dl>
<dt>s</dt>
<dd>Toggle streaming</dd>
</dl>
<h1>Playlist</h1>
<dl>
<dt>up</dt>
<dd>Select the next song up</dd>
</dl>
<dl>
<dt>down</dt>
<dd>Select the next song down</dd>
</dl>
<dl>
<dt>Shift + up</dt>
<dd>Add next song up to selection</dd>
</dl>
<dl>
<dt>Shift + down</dt>
<dd>Add next song down to selection</dd>
</dl>
<dl>
<dt>Ctrl + up</dt>
<dd>Move selection up one</dd>
</dl>
<dl>
<dt>Ctrl + down</dt>
<dd>Move selection down one</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>
<h1>Navigation</h1>
<dl>
<dt>l</dt>
<dd>Switch to Library tab</dd>
</dl>
<dl>
<dt>u</dt>
<dd>Switch to Upload tab</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>Shift + Enter</dt>
<dd>Queue next all search results</dd>
</dl>
<dl>
<dt>Ctrl + Enter</dt>
<dd>Queue all search results in random order</dd>
</dl>
<dl>
<dt>Shift + Ctrl + Enter</dt>
<dd>Queue next all search results in random order</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>
</div>