Compare commits
No commits in common. "master" and "0.0.2" have entirely different histories.
15
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
74
.jshintrc
|
|
@ -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.
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
/node_modules
|
||||
/groovebasin.db
|
||||
/config.js
|
||||
|
||||
# not shared with .gitignore
|
||||
59
Makefile
Normal 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
|
|
@ -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
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## 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
|
|
@ -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
|
|
@ -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
|
|
@ -1,3 +0,0 @@
|
|||
_build
|
||||
_static
|
||||
_templates
|
||||
|
|
@ -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
|
||||
242
docs/conf.py
|
|
@ -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'
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`
|
||||
|
||||
|
|
@ -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 = [];
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
|
|
@ -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');
|
||||
};
|
||||
1605
lib/mpd_protocol.js
1897
lib/player.js
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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');
|
||||
});
|
||||
};
|
||||
82
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
103
public/index.html
Normal 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"> </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>
|
||||
BIN
public/vendor/css/dot-luv/images/ui-bg_diagonals-thick_15_0b3e6f_40x40.png
vendored
Normal file
|
After Width: | Height: | Size: 260 B |
BIN
public/vendor/css/dot-luv/images/ui-bg_dots-medium_30_0b58a2_4x4.png
vendored
Normal file
|
After Width: | Height: | Size: 98 B |
BIN
public/vendor/css/dot-luv/images/ui-bg_dots-small_20_333333_2x2.png
vendored
Normal file
|
After Width: | Height: | Size: 83 B |
BIN
public/vendor/css/dot-luv/images/ui-bg_dots-small_30_a32d00_2x2.png
vendored
Normal file
|
After Width: | Height: | Size: 84 B |
BIN
public/vendor/css/dot-luv/images/ui-bg_dots-small_40_00498f_2x2.png
vendored
Normal file
|
After Width: | Height: | Size: 83 B |
BIN
public/vendor/css/dot-luv/images/ui-bg_flat_0_aaaaaa_40x100.png
vendored
Normal file
|
After Width: | Height: | Size: 180 B |
BIN
public/vendor/css/dot-luv/images/ui-bg_flat_40_292929_40x100.png
vendored
Normal file
|
After Width: | Height: | Size: 211 B |
BIN
public/vendor/css/dot-luv/images/ui-bg_gloss-wave_20_111111_500x100.png
vendored
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
public/vendor/css/dot-luv/images/ui-icons_00498f_256x240.png
vendored
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/vendor/css/dot-luv/images/ui-icons_98d2fb_256x240.png
vendored
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/vendor/css/dot-luv/images/ui-icons_9ccdfc_256x240.png
vendored
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/vendor/css/dot-luv/images/ui-icons_ffffff_256x240.png
vendored
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
565
public/vendor/css/dot-luv/jquery-ui-1.8.17.custom.css
vendored
Normal 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%; }
|
||||
31
public/vendor/fileuploader/fileuploader.css
vendored
Normal 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
BIN
public/vendor/fileuploader/loading.gif
vendored
Normal file
|
After Width: | Height: | Size: 455 B |
223
public/vendor/handlebars.runtime.js
vendored
Normal 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 = {
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
"`": "`"
|
||||
};
|
||||
|
||||
var badChars = /&(?!\w+;)|[<>"'`]/g;
|
||||
var possible = /[&<>"'`]/;
|
||||
|
||||
var escapeChar = function(chr) {
|
||||
return escape[chr] || "&";
|
||||
};
|
||||
|
||||
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
356
public/vendor/jquery-ui-1.8.17.custom.min.js
vendored
Normal file
BIN
public/vendor/socket.io/WebSocketMain.swf
generated
vendored
Normal file
BIN
public/vendor/socket.io/WebSocketMainInsecure.swf
generated
vendored
Normal file
3750
public/vendor/socket.io/socket.io.js
generated
vendored
Normal file
2
public/vendor/socket.io/socket.io.min.js
generated
vendored
Normal file
874
src/app.coffee
Normal 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 = " "
|
||||
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
|
||||
|
|
@ -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
|
||||
2607
src/client/app.js
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
}));
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
271
src/daemon.coffee
Executable 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
|
|
@ -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
|
|
@ -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
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 424 B |
|
Before Width: | Height: | Size: 237 B |
|
Before Width: | Height: | Size: 206 B |
|
Before Width: | Height: | Size: 218 B |
|
Before Width: | Height: | Size: 224 B |
|
Before Width: | Height: | Size: 212 B |
|
Before Width: | Height: | Size: 230 B |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 141 B |
|
Before Width: | Height: | Size: 5 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
|
@ -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"> </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>< <em>or</em> Ctrl + Left <em>and</em> > <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 & 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>
|
||||
4
src/public/vendor/jquery-2.1.0.min.js
vendored
11
src/socketmpd.coffee
Normal 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
|
|
@ -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
|
|
@ -0,0 +1,7 @@
|
|||
<ul>
|
||||
{{#each users}}
|
||||
<li>
|
||||
{{this}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
18
views/library.handlebars
Normal 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
|
|
@ -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}}
|
||||
|
||||
10
views/playlist_menu.handlebars
Normal 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
|
|
@ -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>< <em>or</em> Ctrl + left</dt>
|
||||
<dd>Skip to previous track</dd>
|
||||
</dl>
|
||||
<dl>
|
||||
<dt>> <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>
|
||||