Compare commits
7 commits
master
...
no-hardwar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
091a24ee9d | ||
|
|
1631fdd10f | ||
|
|
4e1c926cd5 | ||
|
|
a9a4796922 | ||
|
|
54fd53000b | ||
|
|
259526c82b | ||
|
|
c5f31269d2 |
19 changed files with 670 additions and 1646 deletions
14
README.md
14
README.md
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Music player server with a web-based user interface inspired by Amarok 1.4.
|
Music player server with a web-based user interface inspired by Amarok 1.4.
|
||||||
|
|
||||||
Run it on a server (such as a 512MB
|
Run it on a server (such as a
|
||||||
[Raspberry Pi](http://www.raspberrypi.org/)) connected to some speakers
|
[Raspberry Pi](http://www.raspberrypi.org/)) connected to some speakers
|
||||||
in your home or office. Guests can control the music player by connecting
|
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
|
with a laptop, tablet, or smart phone. Further, you can stream your music
|
||||||
|
|
@ -11,7 +11,7 @@ library remotely.
|
||||||
Groove Basin works with your personal music library; not an external music
|
Groove Basin works with your personal music library; not an external music
|
||||||
service. Groove Basin will never support DRM content.
|
service. Groove Basin will never support DRM content.
|
||||||
|
|
||||||
Try out the [live demo](http://demo.groovebasin.com/).
|
Live discussion in #libgroove on Freenode.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
|
@ -46,10 +46,7 @@ Try out the [live demo](http://demo.groovebasin.com/).
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
1. Install [Node.js](http://nodejs.org) v0.10.x. Note that on Debian and
|
1. Install [Node.js](http://nodejs.org) v0.10.x.
|
||||||
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).
|
2. Install [libgroove](https://github.com/andrewrk/libgroove).
|
||||||
3. Clone the source.
|
3. Clone the source.
|
||||||
4. `npm run build`
|
4. `npm run build`
|
||||||
|
|
@ -76,11 +73,6 @@ $ npm run dev
|
||||||
This will install dependencies, build generated files, and then start the
|
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.
|
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
|
### Roadmap
|
||||||
|
|
||||||
1. Tag Editing
|
1. Tag Editing
|
||||||
|
|
|
||||||
3
docs/.gitignore
vendored
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
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`
|
|
||||||
|
|
||||||
|
|
@ -43,8 +43,6 @@ var defaultConfig = {
|
||||||
lastFmApiSecret: "8713e8e893c5264608e584a232dd10a0",
|
lastFmApiSecret: "8713e8e893c5264608e584a232dd10a0",
|
||||||
mpdHost: '0.0.0.0',
|
mpdHost: '0.0.0.0',
|
||||||
mpdPort: 6600,
|
mpdPort: 6600,
|
||||||
acoustidAppKey: 'bgFvC4vW',
|
|
||||||
instantBufferBytes: 220 * 1024,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
defaultConfig.permissions[genPassword()] = {
|
defaultConfig.permissions[genPassword()] = {
|
||||||
|
|
@ -140,7 +138,7 @@ GrooveBasin.prototype.start = function() {
|
||||||
self.initializeDownload();
|
self.initializeDownload();
|
||||||
self.initializeUpload();
|
self.initializeUpload();
|
||||||
|
|
||||||
self.player = new Player(self.db, self.config.musicDirectory, self.config.instantBufferBytes);
|
self.player = new Player(self.db, self.config.musicDirectory);
|
||||||
self.player.initialize(function(err) {
|
self.player.initialize(function(err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error("unable to initialize player:", err.stack);
|
console.error("unable to initialize player:", err.stack);
|
||||||
|
|
|
||||||
|
|
@ -1320,7 +1320,7 @@ function writePlaylistInfo(self, start, end) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function forEachMatchingTrack(self, filters, caseSensitive, fn) {
|
function forEachMatchingTrack(self, filters, caseSensitive, fn) {
|
||||||
// TODO: support 'in' as tag type
|
// TODO: support 'any' and 'in' as tag types
|
||||||
var trackTable = self.player.libraryIndex.trackTable;
|
var trackTable = self.player.libraryIndex.trackTable;
|
||||||
if (!caseSensitive) {
|
if (!caseSensitive) {
|
||||||
filters.forEach(function(filter) {
|
filters.forEach(function(filter) {
|
||||||
|
|
@ -1329,24 +1329,13 @@ function forEachMatchingTrack(self, filters, caseSensitive, fn) {
|
||||||
}
|
}
|
||||||
for (var key in trackTable) {
|
for (var key in trackTable) {
|
||||||
var track = trackTable[key];
|
var track = trackTable[key];
|
||||||
var matches = false;
|
var matches = true;
|
||||||
for (var filterIndex = 0; filterIndex < filters.length; filterIndex += 1) {
|
for (var filterIndex = 0; filterIndex < filters.length; filterIndex += 1) {
|
||||||
var filter = filters[filterIndex];
|
var filter = filters[filterIndex];
|
||||||
var filterField = String(track[filter.field]);
|
var filterField = track[filter.field];
|
||||||
if (!filterField) continue;
|
|
||||||
if (!caseSensitive && filterField) filterField = filterField.toLowerCase();
|
if (!caseSensitive && filterField) filterField = filterField.toLowerCase();
|
||||||
|
if (filterField !== filter.value) {
|
||||||
/* assumes:
|
matches = false;
|
||||||
* caseSensitive implies "find"
|
|
||||||
* !caseSensitive implies "search"
|
|
||||||
*/
|
|
||||||
if (caseSensitive) {
|
|
||||||
if (filterField === filter.value) {
|
|
||||||
matches = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else if (filterField.indexOf(filter.value) > -1) {
|
|
||||||
matches = true;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1394,23 +1383,12 @@ function parseFindArgs(self, args, caseSensitive, onTrack, cb, onFinish) {
|
||||||
}
|
}
|
||||||
var filters = [];
|
var filters = [];
|
||||||
for (var i = 0; i < args.length; i += 2) {
|
for (var i = 0; i < args.length; i += 2) {
|
||||||
var tagsToSearch = [];
|
|
||||||
if (args[i].toLowerCase() === "any") {
|
|
||||||
// Special case the any key. Just search everything.
|
|
||||||
for (var tagType in tagTypes) {
|
|
||||||
tagsToSearch.push(tagTypes[tagType]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
var tagType = tagTypes[args[i].toLowerCase()];
|
var tagType = tagTypes[args[i].toLowerCase()];
|
||||||
if (!tagType) return cb(ERR_CODE_ARG, "\"" + args[i] + "\" is not known");
|
if (!tagType) return cb(ERR_CODE_ARG, "\"" + args[i] + "\" is not known");
|
||||||
tagsToSearch.push(tagType);
|
|
||||||
}
|
|
||||||
for (var j = 0; j < tagsToSearch.length; j++) {
|
|
||||||
filters.push({
|
filters.push({
|
||||||
field: tagsToSearch[j].grooveTag,
|
field: tagType.grooveTag,
|
||||||
value: args[i+1],
|
value: args[i+1],
|
||||||
});
|
});
|
||||||
}
|
|
||||||
forEachMatchingTrack(self, filters, caseSensitive, onTrack);
|
forEachMatchingTrack(self, filters, caseSensitive, onTrack);
|
||||||
}
|
}
|
||||||
onFinish();
|
onFinish();
|
||||||
|
|
|
||||||
747
lib/player.js
747
lib/player.js
|
|
@ -17,6 +17,7 @@ var safePath = require('./safe_path');
|
||||||
var PassThrough = require('stream').PassThrough;
|
var PassThrough = require('stream').PassThrough;
|
||||||
var url = require('url');
|
var url = require('url');
|
||||||
var superagent = require('superagent');
|
var superagent = require('superagent');
|
||||||
|
var ytdl = require('ytdl');
|
||||||
|
|
||||||
module.exports = Player;
|
module.exports = Player;
|
||||||
|
|
||||||
|
|
@ -25,171 +26,40 @@ groove.setLogging(groove.LOG_WARNING);
|
||||||
var cpuCount = require('os').cpus().length;
|
var cpuCount = require('os').cpus().length;
|
||||||
|
|
||||||
|
|
||||||
|
// sorted from worst to best
|
||||||
|
var YTDL_AUDIO_ENCODINGS = [
|
||||||
|
'mp3',
|
||||||
|
'aac',
|
||||||
|
'wma',
|
||||||
|
'vorbis',
|
||||||
|
'wav',
|
||||||
|
'flac',
|
||||||
|
];
|
||||||
|
|
||||||
var PLAYER_KEY_PREFIX = "Player.";
|
var PLAYER_KEY_PREFIX = "Player.";
|
||||||
var LIBRARY_KEY_PREFIX = "Library.";
|
var LIBRARY_KEY_PREFIX = "Library.";
|
||||||
var LIBRARY_DIR_PREFIX = "LibraryDir.";
|
var LIBRARY_DIR_PREFIX = "LibraryDir.";
|
||||||
var PLAYLIST_KEY_PREFIX = "Playlist.";
|
var PLAYLIST_KEY_PREFIX = "Playlist.";
|
||||||
|
|
||||||
// db: store in the DB
|
// these are the ones we store in the DB, not the ones we send to the web
|
||||||
// read: send to clients
|
var DB_FILE_PROPS = [
|
||||||
// write: accept updates from clients
|
'key', 'name', 'artistName', 'albumArtistName',
|
||||||
var DB_PROPS = {
|
'albumName', 'compilation', 'track', 'trackCount',
|
||||||
key: {
|
'disc', 'discCount', 'duration', 'year', 'genre',
|
||||||
db: true,
|
'file', 'mtime', 'replayGainAlbumGain', 'replayGainAlbumPeak',
|
||||||
read: true,
|
'replayGainTrackGain', 'replayGainTrackPeak',
|
||||||
write: false,
|
'composerName', 'performerName', 'lastQueueDate',
|
||||||
type: 'string',
|
];
|
||||||
},
|
|
||||||
name: {
|
|
||||||
db: true,
|
|
||||||
read: true,
|
|
||||||
write: true,
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
artistName: {
|
|
||||||
db: true,
|
|
||||||
read: true,
|
|
||||||
write: true,
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
albumArtistName: {
|
|
||||||
db: true,
|
|
||||||
read: true,
|
|
||||||
write: true,
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
albumName: {
|
|
||||||
db: true,
|
|
||||||
read: true,
|
|
||||||
write: true,
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
compilation: {
|
|
||||||
db: true,
|
|
||||||
read: true,
|
|
||||||
write: true,
|
|
||||||
type: 'boolean',
|
|
||||||
},
|
|
||||||
track: {
|
|
||||||
db: true,
|
|
||||||
read: true,
|
|
||||||
write: true,
|
|
||||||
type: 'integer',
|
|
||||||
},
|
|
||||||
trackCount: {
|
|
||||||
db: true,
|
|
||||||
read: true,
|
|
||||||
write: true,
|
|
||||||
type: 'integer',
|
|
||||||
},
|
|
||||||
disc: {
|
|
||||||
db: true,
|
|
||||||
read: true,
|
|
||||||
write: true,
|
|
||||||
type: 'integer',
|
|
||||||
},
|
|
||||||
discCount: {
|
|
||||||
db: true,
|
|
||||||
read: true,
|
|
||||||
write: true,
|
|
||||||
type: 'integer',
|
|
||||||
},
|
|
||||||
duration: {
|
|
||||||
db: true,
|
|
||||||
read: true,
|
|
||||||
write: false,
|
|
||||||
type: 'float',
|
|
||||||
},
|
|
||||||
year: {
|
|
||||||
db: true,
|
|
||||||
read: true,
|
|
||||||
write: true,
|
|
||||||
type: 'integer',
|
|
||||||
},
|
|
||||||
genre: {
|
|
||||||
db: true,
|
|
||||||
read: true,
|
|
||||||
write: true,
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
file: {
|
|
||||||
db: true,
|
|
||||||
read: true,
|
|
||||||
write: false,
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
mtime: {
|
|
||||||
db: true,
|
|
||||||
read: false,
|
|
||||||
write: false,
|
|
||||||
type: 'integer',
|
|
||||||
},
|
|
||||||
replayGainAlbumGain: {
|
|
||||||
db: true,
|
|
||||||
read: false,
|
|
||||||
write: false,
|
|
||||||
type: 'float',
|
|
||||||
},
|
|
||||||
replayGainAlbumPeak: {
|
|
||||||
db: true,
|
|
||||||
read: false,
|
|
||||||
write: false,
|
|
||||||
type: 'float',
|
|
||||||
},
|
|
||||||
replayGainTrackGain: {
|
|
||||||
db: true,
|
|
||||||
read: false,
|
|
||||||
write: false,
|
|
||||||
type: 'float',
|
|
||||||
},
|
|
||||||
replayGainTrackPeak: {
|
|
||||||
db: true,
|
|
||||||
read: false,
|
|
||||||
write: false,
|
|
||||||
type: 'float',
|
|
||||||
},
|
|
||||||
composerName: {
|
|
||||||
db: true,
|
|
||||||
read: true,
|
|
||||||
write: true,
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
performerName: {
|
|
||||||
db: true,
|
|
||||||
read: true,
|
|
||||||
write: true,
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
lastQueueDate: {
|
|
||||||
db: true,
|
|
||||||
read: false,
|
|
||||||
write: false,
|
|
||||||
type: 'date',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
var PROP_TYPE_PARSERS = {
|
|
||||||
'string': function(value) {
|
|
||||||
return value ? String(value) : "";
|
|
||||||
},
|
|
||||||
'date': function(value) {
|
|
||||||
if (!value) return null;
|
|
||||||
var date = new Date(value);
|
|
||||||
if (isNaN(date.getTime())) return null;
|
|
||||||
return date;
|
|
||||||
},
|
|
||||||
'integer': parseIntOrNull,
|
|
||||||
'float': parseFloatOrNull,
|
|
||||||
'boolean': function(value) {
|
|
||||||
return value == null ? null : !!value;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// how many GrooveFiles to keep open, ready to be decoded
|
// how many GrooveFiles to keep open, ready to be decoded
|
||||||
var OPEN_FILE_COUNT = 8;
|
var OPEN_FILE_COUNT = 8;
|
||||||
var PREV_FILE_COUNT = Math.floor(OPEN_FILE_COUNT / 2);
|
var PREV_FILE_COUNT = Math.floor(OPEN_FILE_COUNT / 2);
|
||||||
var NEXT_FILE_COUNT = OPEN_FILE_COUNT - PREV_FILE_COUNT;
|
var NEXT_FILE_COUNT = OPEN_FILE_COUNT - PREV_FILE_COUNT;
|
||||||
|
|
||||||
|
// when a streaming client connects we send them many buffers quickly
|
||||||
|
// in order to get the stream started, then we slow down.
|
||||||
|
var ENCODE_QUEUE_DURATION = 5;
|
||||||
|
|
||||||
var DB_SCALE = Math.log(10.0) * 0.05;
|
var DB_SCALE = Math.log(10.0) * 0.05;
|
||||||
var REPLAYGAIN_PREAMP = 0.75;
|
var REPLAYGAIN_PREAMP = 0.75;
|
||||||
var REPLAYGAIN_DEFAULT = 0.25;
|
var REPLAYGAIN_DEFAULT = 0.25;
|
||||||
|
|
@ -201,7 +71,7 @@ Player.REPEAT_ALL = 2;
|
||||||
Player.trackWithoutIndex = trackWithoutIndex;
|
Player.trackWithoutIndex = trackWithoutIndex;
|
||||||
|
|
||||||
util.inherits(Player, EventEmitter);
|
util.inherits(Player, EventEmitter);
|
||||||
function Player(db, musicDirectory, instantBufferBytes) {
|
function Player(db, musicDirectory) {
|
||||||
EventEmitter.call(this);
|
EventEmitter.call(this);
|
||||||
this.setMaxListeners(0);
|
this.setMaxListeners(0);
|
||||||
|
|
||||||
|
|
@ -211,10 +81,6 @@ function Player(db, musicDirectory, instantBufferBytes) {
|
||||||
this.libraryIndex = new MusicLibraryIndex();
|
this.libraryIndex = new MusicLibraryIndex();
|
||||||
this.addQueue = new DedupedQueue({processOne: this.addToLibrary.bind(this)});
|
this.addQueue = new DedupedQueue({processOne: this.addToLibrary.bind(this)});
|
||||||
|
|
||||||
// when a streaming client connects we send them many buffers quickly
|
|
||||||
// in order to get the stream started, then we slow down.
|
|
||||||
this.instantBufferBytes = instantBufferBytes;
|
|
||||||
|
|
||||||
this.dirs = {};
|
this.dirs = {};
|
||||||
this.dirScanQueue = new DedupedQueue({
|
this.dirScanQueue = new DedupedQueue({
|
||||||
processOne: this.refreshFilesIndex.bind(this),
|
processOne: this.refreshFilesIndex.bind(this),
|
||||||
|
|
@ -224,9 +90,6 @@ function Player(db, musicDirectory, instantBufferBytes) {
|
||||||
maxAsync: 1,
|
maxAsync: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.groovePlayer = null; // initialized by initialize method
|
|
||||||
this.groovePlaylist = null; // initialized by initialize method
|
|
||||||
|
|
||||||
this.playlist = {};
|
this.playlist = {};
|
||||||
this.currentTrack = null;
|
this.currentTrack = null;
|
||||||
this.tracksInOrder = []; // another way to look at playlist
|
this.tracksInOrder = []; // another way to look at playlist
|
||||||
|
|
@ -235,6 +98,7 @@ function Player(db, musicDirectory, instantBufferBytes) {
|
||||||
this.invalidPaths = {}; // files that could not be opened
|
this.invalidPaths = {}; // files that could not be opened
|
||||||
|
|
||||||
this.repeat = Player.REPEAT_OFF;
|
this.repeat = Player.REPEAT_OFF;
|
||||||
|
this.hardwarePlayback = null;
|
||||||
this.isPlaying = false;
|
this.isPlaying = false;
|
||||||
this.trackStartDate = null;
|
this.trackStartDate = null;
|
||||||
this.pausedTime = 0;
|
this.pausedTime = 0;
|
||||||
|
|
@ -249,25 +113,29 @@ function Player(db, musicDirectory, instantBufferBytes) {
|
||||||
|
|
||||||
this.headerBuffers = [];
|
this.headerBuffers = [];
|
||||||
this.recentBuffers = [];
|
this.recentBuffers = [];
|
||||||
this.recentBuffersByteCount = 0;
|
|
||||||
this.newHeaderBuffers = [];
|
this.newHeaderBuffers = [];
|
||||||
this.openStreamers = [];
|
this.openStreamers = [];
|
||||||
this.lastEncodeItem = null;
|
|
||||||
this.lastEncodePos = null;
|
|
||||||
this.expectHeaders = true;
|
this.expectHeaders = true;
|
||||||
|
|
||||||
this.playlistItemDeleteQueue = [];
|
this.groovePlaylist = groove.createPlaylist();
|
||||||
|
this.groovePlayer = groove.createPlayer();
|
||||||
|
this.grooveEncoder = groove.createEncoder();
|
||||||
|
|
||||||
this.importUrlFilters = [];
|
this.manualTimeInterval = null;
|
||||||
|
this.pendingEncoderAttachDetach = null;
|
||||||
|
this.desiredEncoderAttachState = false;
|
||||||
|
this.flushEncodedInterval = null;
|
||||||
|
this.groovePlaylist.pause();
|
||||||
|
this.volume = this.groovePlaylist.volume;
|
||||||
|
this.grooveEncoder.formatShortName = "mp3";
|
||||||
|
this.grooveEncoder.codecShortName = "mp3";
|
||||||
|
this.grooveEncoder.bitRate = 256 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
Player.prototype.initialize = function(cb) {
|
Player.prototype.initialize = function(cb) {
|
||||||
var self = this;
|
var self = this;
|
||||||
|
|
||||||
var pend = new Pend();
|
initLibrary(function(err) {
|
||||||
pend.go(initPlayer);
|
|
||||||
pend.go(initLibrary);
|
|
||||||
pend.wait(function(err) {
|
|
||||||
if (err) return cb(err);
|
if (err) return cb(err);
|
||||||
self.requestUpdateDb();
|
self.requestUpdateDb();
|
||||||
playlistChanged(self);
|
playlistChanged(self);
|
||||||
|
|
@ -275,109 +143,6 @@ Player.prototype.initialize = function(cb) {
|
||||||
cacheAllOptions(cb);
|
cacheAllOptions(cb);
|
||||||
});
|
});
|
||||||
|
|
||||||
function initPlayer(cb) {
|
|
||||||
var groovePlaylist = groove.createPlaylist();
|
|
||||||
var groovePlayer = groove.createPlayer();
|
|
||||||
var grooveEncoder = groove.createEncoder();
|
|
||||||
grooveEncoder.formatShortName = "mp3";
|
|
||||||
grooveEncoder.codecShortName = "mp3";
|
|
||||||
grooveEncoder.bitRate = 256 * 1000;
|
|
||||||
|
|
||||||
var pend = new Pend();
|
|
||||||
pend.go(function(cb) {
|
|
||||||
groovePlayer.attach(groovePlaylist, cb);
|
|
||||||
});
|
|
||||||
pend.go(function(cb) {
|
|
||||||
grooveEncoder.attach(groovePlaylist, cb);
|
|
||||||
});
|
|
||||||
pend.wait(doneAttaching);
|
|
||||||
|
|
||||||
function doneAttaching(err) {
|
|
||||||
if (err) {
|
|
||||||
cb(err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.groovePlaylist = groovePlaylist;
|
|
||||||
self.groovePlayer = groovePlayer;
|
|
||||||
self.grooveEncoder = grooveEncoder;
|
|
||||||
self.groovePlaylist.pause();
|
|
||||||
self.volume = self.groovePlaylist.volume;
|
|
||||||
self.groovePlayer.on('nowplaying', onNowPlaying);
|
|
||||||
self.flushEncodedInterval = setInterval(flushEncoded, 10);
|
|
||||||
cb();
|
|
||||||
|
|
||||||
function flushEncoded() {
|
|
||||||
// poll the encoder for more buffers until either there are no buffers
|
|
||||||
// available or we get enough buffered
|
|
||||||
while (1) {
|
|
||||||
var bufferedSeconds = self.secondsIntoFuture(self.lastEncodeItem, self.lastEncodePos);
|
|
||||||
if (bufferedSeconds > 0.5 && self.recentBuffersByteCount >= self.instantBufferBytes) return;
|
|
||||||
var buf = self.grooveEncoder.getBuffer();
|
|
||||||
if (!buf) return;
|
|
||||||
if (buf.buffer) {
|
|
||||||
if (buf.item) {
|
|
||||||
if (self.expectHeaders) {
|
|
||||||
console.log("encoder: got first non-header");
|
|
||||||
self.headerBuffers = self.newHeaderBuffers;
|
|
||||||
self.newHeaderBuffers = [];
|
|
||||||
self.expectHeaders = false;
|
|
||||||
}
|
|
||||||
self.recentBuffers.push(buf.buffer);
|
|
||||||
self.recentBuffersByteCount += buf.buffer.length;
|
|
||||||
while (self.recentBuffers.length > 0 &&
|
|
||||||
self.recentBuffersByteCount - self.recentBuffers[0].length >= self.instantBufferBytes)
|
|
||||||
{
|
|
||||||
self.recentBuffersByteCount -= self.recentBuffers.shift().length;
|
|
||||||
}
|
|
||||||
for (var i = 0; i < self.openStreamers.length; i += 1) {
|
|
||||||
self.openStreamers[i].write(buf.buffer);
|
|
||||||
}
|
|
||||||
self.lastEncodeItem = buf.item;
|
|
||||||
self.lastEncodePos = buf.pos;
|
|
||||||
} else if (self.expectHeaders) {
|
|
||||||
// this is a header
|
|
||||||
console.log("encoder: got header");
|
|
||||||
self.newHeaderBuffers.push(buf.buffer);
|
|
||||||
} else {
|
|
||||||
// it's a footer, ignore the fuck out of it
|
|
||||||
console.info("ignoring encoded audio footer");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// end of playlist sentinel
|
|
||||||
console.log("encoder: end of playlist sentinel");
|
|
||||||
self.expectHeaders = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onNowPlaying() {
|
|
||||||
var playHead = self.groovePlayer.position();
|
|
||||||
var decodeHead = self.groovePlaylist.position();
|
|
||||||
|
|
||||||
|
|
||||||
var playHeadDbKey = playHead.item && self.grooveItems[playHead.item.id].key;
|
|
||||||
var playHeadDbFile = playHeadDbKey && self.libraryIndex.trackTable[playHeadDbKey];
|
|
||||||
var playHeadFile = playHeadDbFile && playHeadDbFile.file;
|
|
||||||
console.info("onNowPlaying event. playhead:", playHeadFile);
|
|
||||||
|
|
||||||
if (playHead.item) {
|
|
||||||
var nowMs = (new Date()).getTime();
|
|
||||||
var posMs = playHead.pos * 1000;
|
|
||||||
self.trackStartDate = new Date(nowMs - posMs);
|
|
||||||
self.currentTrack = self.grooveItems[playHead.item.id];
|
|
||||||
playlistChanged(self);
|
|
||||||
self.emit('currentTrack');
|
|
||||||
} else if (!decodeHead.item) {
|
|
||||||
// both play head and decode head are null. end of playlist.
|
|
||||||
console.log("end of playlist");
|
|
||||||
self.currentTrack = null;
|
|
||||||
playlistChanged(self);
|
|
||||||
self.emit('currentTrack');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initLibrary(cb) {
|
function initLibrary(cb) {
|
||||||
var pend = new Pend();
|
var pend = new Pend();
|
||||||
pend.go(cacheAllDb);
|
pend.go(cacheAllDb);
|
||||||
|
|
@ -416,6 +181,7 @@ Player.prototype.initialize = function(cb) {
|
||||||
dynamicModeOn: null,
|
dynamicModeOn: null,
|
||||||
dynamicModeHistorySize: null,
|
dynamicModeHistorySize: null,
|
||||||
dynamicModeFutureSize: null,
|
dynamicModeFutureSize: null,
|
||||||
|
hardwarePlayback: null,
|
||||||
};
|
};
|
||||||
var pend = new Pend();
|
var pend = new Pend();
|
||||||
for (var name in options) {
|
for (var name in options) {
|
||||||
|
|
@ -435,6 +201,12 @@ Player.prototype.initialize = function(cb) {
|
||||||
if (options.dynamicModeFutureSize != null) {
|
if (options.dynamicModeFutureSize != null) {
|
||||||
self.setDynamicModeFutureSize(options.dynamicModeFutureSize);
|
self.setDynamicModeFutureSize(options.dynamicModeFutureSize);
|
||||||
}
|
}
|
||||||
|
var hardwarePlaybackValue = options.hardwarePlayback == null ? true : options.hardwarePlayback;
|
||||||
|
if (hardwarePlaybackValue) {
|
||||||
|
self.setHardwarePlayback(hardwarePlaybackValue);
|
||||||
|
} else {
|
||||||
|
self.refreshManualTimeInterval();
|
||||||
|
}
|
||||||
cb();
|
cb();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -519,6 +291,181 @@ Player.prototype.initialize = function(cb) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function startEncoderAttach(self, cb) {
|
||||||
|
self.desiredEncoderAttachState = true;
|
||||||
|
if (!self.pendingEncoderAttachDetach) {
|
||||||
|
self.pendingEncoderAttachDetach = true;
|
||||||
|
self.grooveEncoder.attach(self.groovePlaylist, function(err) {
|
||||||
|
if (err) return cb(err);
|
||||||
|
self.pendingEncoderAttachDetach = false;
|
||||||
|
if (!self.desiredEncoderAttachState) startEncoderDetach(self, cb);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEncoderDetach(self, cb) {
|
||||||
|
self.desiredEncoderAttachState = false;
|
||||||
|
if (!self.pendingEncoderAttachDetach) {
|
||||||
|
self.pendingEncoderAttachDetach = true;
|
||||||
|
self.grooveEncoder.detach(function(err) {
|
||||||
|
if (err) return cb(err);
|
||||||
|
self.pendingEncoderAttachDetach = false;
|
||||||
|
if (self.desiredEncoderAttachState) startEncoderAttach(self, cb);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Player.prototype.refreshManualTimeInterval = function() {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
var wantManualTime = !self.hardwarePlayback && !self.desiredEncoderAttachState;
|
||||||
|
if (wantManualTime && !self.manualTimeInterval) {
|
||||||
|
self.manualTimeInterval = setInterval(checkNowPlaying, 100);
|
||||||
|
} else if (!wantManualTime && self.manualTimeInterval) {
|
||||||
|
clearInterval(self.manualTimeInterval);
|
||||||
|
self.manualTimeInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkNowPlaying() {
|
||||||
|
if (!self.currentTrack) return;
|
||||||
|
if (!self.isPlaying) return;
|
||||||
|
var now = new Date();
|
||||||
|
var dbFile = self.libraryIndex.trackTable[self.currentTrack.key];
|
||||||
|
var nextTrackBegin = new Date(self.trackStartDate.getTime() + dbFile.duration * 1000);
|
||||||
|
if (now > nextTrackBegin) {
|
||||||
|
self.currentTrack = self.tracksInOrder[self.currentTrack.index + 1];
|
||||||
|
self.trackStartDate = nextTrackBegin;
|
||||||
|
playlistChanged(self);
|
||||||
|
self.emit('currentTrack');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Player.prototype.getBufferedSeconds = function() {
|
||||||
|
if (this.recentBuffers.length < 2) return 0;
|
||||||
|
var curBuf = this.recentBuffers[0];
|
||||||
|
var curSongId = curBuf.item.id;
|
||||||
|
var prevBuf = curBuf;
|
||||||
|
var totalTime = 0;
|
||||||
|
for (var i = 1; i < this.recentBuffers.length - 1; i += 1) {
|
||||||
|
var buf = this.recentBuffers[i];
|
||||||
|
var thisSongId = buf.item.id;
|
||||||
|
if (thisSongId !== curSongId) {
|
||||||
|
curSongId = thisSongId;
|
||||||
|
totalTime += prevBuf.pos - curBuf.pos;
|
||||||
|
curBuf = buf;
|
||||||
|
}
|
||||||
|
prevBuf = buf;
|
||||||
|
}
|
||||||
|
totalTime += prevBuf.pos - curBuf.pos;
|
||||||
|
return totalTime;
|
||||||
|
};
|
||||||
|
|
||||||
|
Player.prototype.attachEncoder = function(cb) {
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
cb = cb || logIfError;
|
||||||
|
|
||||||
|
if (self.flushEncodedInterval) return cb();
|
||||||
|
|
||||||
|
console.info("first streamer connected - attaching encoder");
|
||||||
|
self.flushEncodedInterval = setInterval(flushEncoded, 20);
|
||||||
|
self.grooveEncoder.on('buffer', onBuffer);
|
||||||
|
self.refreshManualTimeInterval();
|
||||||
|
|
||||||
|
startEncoderAttach(self, cb);
|
||||||
|
|
||||||
|
function onBuffer() {
|
||||||
|
if (!self.desiredEncoderAttachState) return;
|
||||||
|
if (self.hardwarePlayback) return;
|
||||||
|
var encodeHead = self.grooveEncoder.position();
|
||||||
|
var decodeHead = self.groovePlaylist.position();
|
||||||
|
var prevCurrentTrack = self.currentTrack;
|
||||||
|
if (encodeHead.item) {
|
||||||
|
var nowMs = (new Date()).getTime();
|
||||||
|
var posMs = encodeHead.pos * 1000;
|
||||||
|
self.trackStartDate = new Date(nowMs - posMs);
|
||||||
|
self.currentTrack = self.grooveItems[encodeHead.item.id];
|
||||||
|
} else if (!decodeHead.item) {
|
||||||
|
// both play head and decode head are null. end of playlist.
|
||||||
|
console.log("encoder: end of playlist");
|
||||||
|
self.currentTrack = null;
|
||||||
|
}
|
||||||
|
if (prevCurrentTrack !== self.currentTrack) {
|
||||||
|
playlistChanged(self);
|
||||||
|
self.emit('currentTrack');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushEncoded() {
|
||||||
|
// get rid of old items
|
||||||
|
var buf;
|
||||||
|
while (buf = self.recentBuffers[0]) {
|
||||||
|
var thisBufTrack = self.grooveItems[buf.item.id];
|
||||||
|
if (!thisBufTrack) return;
|
||||||
|
if (thisBufTrack !== self.currentTrack || self.getCurPos() > buf.pos) {
|
||||||
|
self.recentBuffers.shift();
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// poll the encoder for more buffers until either there are no buffers
|
||||||
|
// available or we get enough buffered
|
||||||
|
while (self.getBufferedSeconds() < ENCODE_QUEUE_DURATION) {
|
||||||
|
buf = self.grooveEncoder.getBuffer();
|
||||||
|
if (!buf) break;
|
||||||
|
if (buf.buffer) {
|
||||||
|
if (buf.item) {
|
||||||
|
if (self.expectHeaders) {
|
||||||
|
console.log("encoder: got first non-header");
|
||||||
|
self.headerBuffers = self.newHeaderBuffers;
|
||||||
|
self.newHeaderBuffers = [];
|
||||||
|
self.expectHeaders = false;
|
||||||
|
}
|
||||||
|
self.recentBuffers.push(buf);
|
||||||
|
for (var i = 0; i < self.openStreamers.length; i += 1) {
|
||||||
|
self.openStreamers[i].write(buf.buffer);
|
||||||
|
}
|
||||||
|
} else if (self.expectHeaders) {
|
||||||
|
// this is a header
|
||||||
|
console.log("encoder: got header");
|
||||||
|
self.newHeaderBuffers.push(buf.buffer);
|
||||||
|
} else {
|
||||||
|
// it's a footer, ignore the fuck out of it
|
||||||
|
console.info("ignoring encoded audio footer");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// end of playlist sentinel
|
||||||
|
console.log("encoder: end of playlist sentinel");
|
||||||
|
self.expectHeaders = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logIfError(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Unable to attach encoder:", err.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Player.prototype.detachEncoder = function(cb) {
|
||||||
|
cb = cb || logIfError;
|
||||||
|
|
||||||
|
this.clearEncodedBuffer();
|
||||||
|
clearInterval(this.flushEncodedInterval);
|
||||||
|
this.flushEncodedInterval = null;
|
||||||
|
startEncoderDetach(this, cb);
|
||||||
|
this.refreshManualTimeInterval();
|
||||||
|
this.grooveEncoder.removeAllListeners();
|
||||||
|
|
||||||
|
function logIfError(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Unable to attach encoder:", err.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
Player.prototype.requestUpdateDb = function(dirName, forceRescan, cb) {
|
Player.prototype.requestUpdateDb = function(dirName, forceRescan, cb) {
|
||||||
var fullPath = path.resolve(this.musicDirectory, dirName || "");
|
var fullPath = path.resolve(this.musicDirectory, dirName || "");
|
||||||
this.dirScanQueue.add(fullPath, {
|
this.dirScanQueue.add(fullPath, {
|
||||||
|
|
@ -681,23 +628,73 @@ Player.prototype.getOrCreateDir = function (dirName, stat) {
|
||||||
return dirEntry;
|
return dirEntry;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
Player.prototype.getCurPos = function() {
|
Player.prototype.getCurPos = function() {
|
||||||
return this.isPlaying ?
|
return this.isPlaying ?
|
||||||
((new Date() - this.trackStartDate) / 1000.0) : this.pausedTime;
|
((new Date() - this.trackStartDate) / 1000.0) : this.pausedTime;
|
||||||
};
|
};
|
||||||
|
|
||||||
Player.prototype.secondsIntoFuture = function(groovePlaylistItem, pos) {
|
Player.prototype.setHardwarePlayback = function(value, cb) {
|
||||||
if (!groovePlaylistItem || !pos) {
|
var self = this;
|
||||||
return 0;
|
|
||||||
|
cb = cb || logIfError;
|
||||||
|
value = !!value;
|
||||||
|
|
||||||
|
if (value === self.hardwarePlayback) return cb();
|
||||||
|
|
||||||
|
self.hardwarePlayback = value;
|
||||||
|
if (self.hardwarePlayback) {
|
||||||
|
self.groovePlayer = groove.createPlayer();
|
||||||
|
self.groovePlayer.attach(self.groovePlaylist, onAttachPlayer);
|
||||||
|
} else {
|
||||||
|
self.groovePlayer.detach(onDetachPlayer);
|
||||||
}
|
}
|
||||||
|
|
||||||
var item = this.grooveItems[groovePlaylistItem.id];
|
function onAttachPlayer(err) {
|
||||||
|
if (err) {
|
||||||
|
self.hardwarePlayback = false;
|
||||||
|
return cb(err);
|
||||||
|
}
|
||||||
|
self.refreshManualTimeInterval();
|
||||||
|
self.groovePlayer.on('nowplaying', onNowPlaying);
|
||||||
|
self.persistOption('hardwarePlayback', self.hardwarePlayback);
|
||||||
|
self.emit('hardwarePlayback', self.hardwarePlayback);
|
||||||
|
cb();
|
||||||
|
}
|
||||||
|
|
||||||
if (item === this.currentTrack) {
|
function onDetachPlayer(err) {
|
||||||
return pos - this.getCurPos();
|
if (err) {
|
||||||
|
self.hardwarePlayback = true;
|
||||||
} else {
|
} else {
|
||||||
return pos;
|
self.refreshManualTimeInterval();
|
||||||
|
self.persistOption('hardwarePlayback', self.hardwarePlayback);
|
||||||
|
self.emit('hardwarePlayback', self.hardwarePlayback);
|
||||||
|
}
|
||||||
|
cb(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logIfError(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error("Unable to set hardware playback mode:", err.stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNowPlaying() {
|
||||||
|
var playHead = self.getPlayHead();
|
||||||
|
var decodeHead = self.groovePlaylist.position();
|
||||||
|
if (playHead.item) {
|
||||||
|
var nowMs = (new Date()).getTime();
|
||||||
|
var posMs = playHead.pos * 1000;
|
||||||
|
self.trackStartDate = new Date(nowMs - posMs);
|
||||||
|
self.currentTrack = self.grooveItems[playHead.item.id];
|
||||||
|
playlistChanged(self);
|
||||||
|
self.emit('currentTrack');
|
||||||
|
} else if (!decodeHead.item) {
|
||||||
|
// both play head and decode head are null. end of playlist.
|
||||||
|
console.log("end of playlist");
|
||||||
|
self.currentTrack = null;
|
||||||
|
playlistChanged(self);
|
||||||
|
self.emit('currentTrack');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -717,15 +714,20 @@ Player.prototype.streamMiddleware = function(req, resp, next) {
|
||||||
resp.write(headerBuffer);
|
resp.write(headerBuffer);
|
||||||
});
|
});
|
||||||
self.recentBuffers.forEach(function(recentBuffer) {
|
self.recentBuffers.forEach(function(recentBuffer) {
|
||||||
resp.write(recentBuffer);
|
resp.write(recentBuffer.buffer);
|
||||||
});
|
});
|
||||||
console.log("sent", count, "bytes of headers and", self.recentBuffersByteCount,
|
self.attachEncoder();
|
||||||
"bytes of unthrottled data");
|
|
||||||
self.openStreamers.push(resp);
|
self.openStreamers.push(resp);
|
||||||
req.on('abort', function() {
|
req.on('close', function() {
|
||||||
for (var i = 0; i < self.openStreamers.length; i += 1) {
|
for (var i = 0; i < self.openStreamers.length; i += 1) {
|
||||||
if (self.openStreamers[i] === resp) {
|
if (self.openStreamers[i] === resp) {
|
||||||
self.openStreamers.splice(i, 1);
|
self.openStreamers.splice(i, 1);
|
||||||
|
if (self.openStreamers.length === 0) {
|
||||||
|
console.info("last streamer disconnected. detaching encoder");
|
||||||
|
self.detachEncoder();
|
||||||
|
} else {
|
||||||
|
console.info("streamer count:", self.openStreamers.length);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -788,34 +790,19 @@ Player.prototype.importUrl = function(urlString, cb) {
|
||||||
cb = cb || logIfError;
|
cb = cb || logIfError;
|
||||||
|
|
||||||
var tmpDir = path.join(self.musicDirectory, '.tmp');
|
var tmpDir = path.join(self.musicDirectory, '.tmp');
|
||||||
var filterIndex = 0;
|
|
||||||
|
|
||||||
mkdirp(tmpDir, function(err) {
|
mkdirp(tmpDir, function(err) {
|
||||||
if (err) return cb(err);
|
if (err) return cb(err);
|
||||||
|
|
||||||
tryImportFilter();
|
|
||||||
});
|
|
||||||
|
|
||||||
function tryImportFilter() {
|
|
||||||
var importPlugin = self.importUrlFilters[filterIndex];
|
|
||||||
if (importPlugin) {
|
|
||||||
importPlugin.importUrl(urlString, callNextFilter);
|
|
||||||
} else {
|
|
||||||
downloadRaw();
|
|
||||||
}
|
|
||||||
function callNextFilter(err, dlStream, filename) {
|
|
||||||
if (err || !dlStream) {
|
|
||||||
if (err) console.warn("import filter error, skipping:", err.stack);
|
|
||||||
filterIndex += 1;
|
|
||||||
tryImportFilter();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handleDownload(dlStream, filename);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadRaw() {
|
|
||||||
var parsedUrl = url.parse(urlString);
|
var parsedUrl = url.parse(urlString);
|
||||||
|
|
||||||
|
// detect youtube downloads
|
||||||
|
if ((parsedUrl.hostname === 'youtube.com' || parsedUrl.hostname === 'www.youtube.com') &&
|
||||||
|
parsedUrl.pathname === '/watch')
|
||||||
|
{
|
||||||
|
var bestFormat = null;
|
||||||
|
ytdl.getInfo(urlString, gotYouTubeInfo);
|
||||||
|
} else {
|
||||||
var remoteFilename = path.basename(parsedUrl.pathname);
|
var remoteFilename = path.basename(parsedUrl.pathname);
|
||||||
var decodedFilename;
|
var decodedFilename;
|
||||||
try {
|
try {
|
||||||
|
|
@ -827,6 +814,30 @@ Player.prototype.importUrl = function(urlString, cb) {
|
||||||
handleDownload(req, decodedFilename);
|
handleDownload(req, decodedFilename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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});
|
||||||
|
handleDownload(req, info.title + '.' + bestFormat.container);
|
||||||
|
|
||||||
|
function filter(format) {
|
||||||
|
return format.audioBitrate === bestFormat.audioBitrate &&
|
||||||
|
format.audioEncoding === bestFormat.audioEncoding;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleDownload(req, remoteFilename) {
|
function handleDownload(req, remoteFilename) {
|
||||||
var ext = path.extname(remoteFilename);
|
var ext = path.extname(remoteFilename);
|
||||||
var destPath = path.join(tmpDir, uuid() + ext);
|
var destPath = path.join(tmpDir, uuid() + ext);
|
||||||
|
|
@ -859,6 +870,7 @@ Player.prototype.importUrl = function(urlString, cb) {
|
||||||
cb(err);
|
cb(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function logIfError(err) {
|
function logIfError(err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|
@ -995,24 +1007,6 @@ Player.prototype.addToLibrary = function(args, cb) {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
Player.prototype.updateTags = function(obj) {
|
|
||||||
for (var key in obj) {
|
|
||||||
var track = this.libraryIndex.trackTable[key];
|
|
||||||
if (!track) continue;
|
|
||||||
var props = obj[key];
|
|
||||||
if (!props || typeof props !== 'object') continue;
|
|
||||||
for (var propName in DB_PROPS) {
|
|
||||||
var prop = DB_PROPS[propName];
|
|
||||||
if (! prop.write) continue;
|
|
||||||
if (! (propName in props)) continue;
|
|
||||||
var parser = PROP_TYPE_PARSERS[prop.type];
|
|
||||||
track[propName] = parser(props[propName]);
|
|
||||||
}
|
|
||||||
this.persist(track);
|
|
||||||
this.emit('updateDbTrack', track);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
Player.prototype.insertTracks = function(index, keys, tagAsRandom) {
|
Player.prototype.insertTracks = function(index, keys, tagAsRandom) {
|
||||||
if (keys.length === 0) return;
|
if (keys.length === 0) return;
|
||||||
if (index < 0) index = 0;
|
if (index < 0) index = 0;
|
||||||
|
|
@ -1078,13 +1072,11 @@ Player.prototype.shufflePlaylist = function() {
|
||||||
shuffle(this.tracksInOrder);
|
shuffle(this.tracksInOrder);
|
||||||
// fix sortKey and index properties
|
// fix sortKey and index properties
|
||||||
var nextSortKey = keese(null, null);
|
var nextSortKey = keese(null, null);
|
||||||
for (var i = 0; i < this.tracksInOrder.length; i += 1) {
|
this.tracksInOrder.forEach(function(track, index) {
|
||||||
var track = this.tracksInOrder[i];
|
track.index = index;
|
||||||
track.index = i;
|
|
||||||
track.sortKey = nextSortKey;
|
track.sortKey = nextSortKey;
|
||||||
this.persistPlaylistItem(track);
|
|
||||||
nextSortKey = keese(nextSortKey, null);
|
nextSortKey = keese(nextSortKey, null);
|
||||||
}
|
});
|
||||||
playlistChanged(this);
|
playlistChanged(this);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1099,7 +1091,10 @@ Player.prototype.removePlaylistItems = function(ids) {
|
||||||
|
|
||||||
delCmds.push({type: 'del', key: PLAYLIST_KEY_PREFIX + id});
|
delCmds.push({type: 'del', key: PLAYLIST_KEY_PREFIX + id});
|
||||||
|
|
||||||
if (item.grooveFile) this.playlistItemDeleteQueue.push(item);
|
if (item.grooveFile) closeFile(item.grooveFile);
|
||||||
|
// we set this so that any callbacks that return which were trying to
|
||||||
|
// set the grooveItem can check if the item got deleted
|
||||||
|
item.deleted = true;
|
||||||
if (item === this.currentTrack) {
|
if (item === this.currentTrack) {
|
||||||
this.currentTrack = null;
|
this.currentTrack = null;
|
||||||
currentTrackChanged = true;
|
currentTrackChanged = true;
|
||||||
|
|
@ -1271,7 +1266,6 @@ Player.prototype.clearEncodedBuffer = function() {
|
||||||
while (this.recentBuffers.length > 0) {
|
while (this.recentBuffers.length > 0) {
|
||||||
this.recentBuffers.shift();
|
this.recentBuffers.shift();
|
||||||
}
|
}
|
||||||
this.recentBuffersByteCount = 0;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Player.prototype.getSuggestedPath = function(track, filenameHint) {
|
Player.prototype.getSuggestedPath = function(track, filenameHint) {
|
||||||
|
|
@ -1500,6 +1494,16 @@ Player.prototype.checkDynamicMode = function() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Player.prototype.getPlayHead = function() {
|
||||||
|
if (this.hardwarePlayback) {
|
||||||
|
return this.groovePlayer.position();
|
||||||
|
} else if (this.desiredEncoderAttachState && !this.pendingEncoderAttachDetach) {
|
||||||
|
return this.grooveEncoder.position();
|
||||||
|
} else {
|
||||||
|
return this.groovePlaylist.position();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
function operatorCompare(a, b) {
|
function operatorCompare(a, b) {
|
||||||
return a < b ? -1 : a > b ? 1 : 0;
|
return a < b ? -1 : a > b ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
@ -1553,12 +1557,29 @@ function lazyReplayGainScanPlaylist(self) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function playlistChanged(self) {
|
function playlistChanged(self) {
|
||||||
|
if (self.desiredEncoderAttachState && !self.pendingEncoderAttachDetach && !self.hardwarePlayback) {
|
||||||
|
var encodeHead = self.grooveEncoder.position();
|
||||||
|
var decodeHead = self.groovePlaylist.position();
|
||||||
|
var prevCurrentTrack = self.currentTrack;
|
||||||
|
if (encodeHead.item) {
|
||||||
|
var nowMs = (new Date()).getTime();
|
||||||
|
var posMs = encodeHead.pos * 1000;
|
||||||
|
self.trackStartDate = new Date(nowMs - posMs);
|
||||||
|
self.currentTrack = self.grooveItems[encodeHead.item.id];
|
||||||
|
} else if (!decodeHead.item) {
|
||||||
|
// both play head and decode head are null. end of playlist.
|
||||||
|
console.log("encoder: end of playlist");
|
||||||
|
self.currentTrack = null;
|
||||||
|
}
|
||||||
|
if (prevCurrentTrack !== self.currentTrack) {
|
||||||
|
playlistChanged(self);
|
||||||
|
self.emit('currentTrack');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cacheTracksArray(self);
|
cacheTracksArray(self);
|
||||||
disambiguateSortKeys(self);
|
disambiguateSortKeys(self);
|
||||||
|
|
||||||
self.lastEncodeItem = null;
|
|
||||||
self.lastEncodePos = null;
|
|
||||||
|
|
||||||
if (self.currentTrack) {
|
if (self.currentTrack) {
|
||||||
self.tracksInOrder.forEach(function(track, index) {
|
self.tracksInOrder.forEach(function(track, index) {
|
||||||
var withinPrev = (self.currentTrack.index - index) <= PREV_FILE_COUNT;
|
var withinPrev = (self.currentTrack.index - index) <= PREV_FILE_COUNT;
|
||||||
|
|
@ -1577,23 +1598,16 @@ function playlistChanged(self) {
|
||||||
self.pausedTime = 0;
|
self.pausedTime = 0;
|
||||||
}
|
}
|
||||||
checkUpdateGroovePlaylist(self);
|
checkUpdateGroovePlaylist(self);
|
||||||
performGrooveFileDeletes(self);
|
console.log("Begin Groove Playlist:");
|
||||||
|
self.groovePlaylist.items().forEach(function(item, index) {
|
||||||
|
console.log(index, item.file.filename);
|
||||||
|
});
|
||||||
|
console.log("End Groove Playlist:");
|
||||||
self.checkDynamicMode();
|
self.checkDynamicMode();
|
||||||
|
|
||||||
self.emit('playlistUpdate');
|
self.emit('playlistUpdate');
|
||||||
}
|
}
|
||||||
|
|
||||||
function performGrooveFileDeletes(self) {
|
|
||||||
while (self.playlistItemDeleteQueue.length) {
|
|
||||||
var item = self.playlistItemDeleteQueue.shift();
|
|
||||||
// we set this so that any callbacks that return which were trying to
|
|
||||||
// set the grooveItem can check if the item got deleted
|
|
||||||
item.deleted = true;
|
|
||||||
closeFile(item.grooveFile);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function preloadFile(self, track) {
|
function preloadFile(self, track) {
|
||||||
var relPath = self.libraryIndex.trackTable[track.key].file;
|
var relPath = self.libraryIndex.trackTable[track.key].file;
|
||||||
var fullPath = path.join(self.musicDirectory, relPath);
|
var fullPath = path.join(self.musicDirectory, relPath);
|
||||||
|
|
@ -1621,7 +1635,7 @@ function checkUpdateGroovePlaylist(self) {
|
||||||
}
|
}
|
||||||
|
|
||||||
var groovePlaylist = self.groovePlaylist.items();
|
var groovePlaylist = self.groovePlaylist.items();
|
||||||
var playHead = self.groovePlayer.position();
|
var playHead = self.getPlayHead();
|
||||||
var playHeadItemId = playHead.item && playHead.item.id;
|
var playHeadItemId = playHead.item && playHead.item.id;
|
||||||
var groovePlIndex = 0;
|
var groovePlIndex = 0;
|
||||||
var grooveItem;
|
var grooveItem;
|
||||||
|
|
@ -1749,14 +1763,7 @@ function isFileIgnored(basename) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function deserializeFileData(dataStr) {
|
function deserializeFileData(dataStr) {
|
||||||
var dbFile = JSON.parse(dataStr);
|
return JSON.parse(dataStr);
|
||||||
for (var propName in DB_PROPS) {
|
|
||||||
var propInfo = DB_PROPS[propName];
|
|
||||||
if (!propInfo) continue;
|
|
||||||
var parser = PROP_TYPE_PARSERS[propInfo.type];
|
|
||||||
dbFile[propName] = parser(dbFile[propName]);
|
|
||||||
}
|
|
||||||
return dbFile;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializePlaylistItem(item) {
|
function serializePlaylistItem(item) {
|
||||||
|
|
@ -1768,21 +1775,18 @@ function serializePlaylistItem(item) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function trackWithoutIndex(category, dbFile) {
|
function trackWithoutIndex(props, dbFile) {
|
||||||
var out = {};
|
var out = {};
|
||||||
for (var propName in DB_PROPS) {
|
props.forEach(function(propName) {
|
||||||
var prop = DB_PROPS[propName];
|
|
||||||
if (!prop[category]) continue;
|
|
||||||
// save space by leaving out null and undefined values
|
|
||||||
var value = dbFile[propName];
|
var value = dbFile[propName];
|
||||||
if (value == null) continue;
|
// save space by leaving out null or undefined values
|
||||||
out[propName] = value;
|
if (value != null) out[propName] = value;
|
||||||
}
|
});
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeFileData(dbFile) {
|
function serializeFileData(dbFile) {
|
||||||
return JSON.stringify(trackWithoutIndex('db', dbFile));
|
return JSON.stringify(trackWithoutIndex(DB_FILE_PROPS, dbFile));
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeDirEntry(dirEntry) {
|
function serializeDirEntry(dirEntry) {
|
||||||
|
|
@ -1837,11 +1841,10 @@ function parseFloatOrNull(n) {
|
||||||
function grooveFileToDbFile(file, filenameHint, object) {
|
function grooveFileToDbFile(file, filenameHint, object) {
|
||||||
object = object || {key: uuid()};
|
object = object || {key: uuid()};
|
||||||
var parsedTrack = parseTrackString(file.getMetadata("track"));
|
var parsedTrack = parseTrackString(file.getMetadata("track"));
|
||||||
var parsedDisc = parseTrackString(file.getMetadata("disc") || file.getMetadata("TPA"));
|
var parsedDisc = parseTrackString(file.getMetadata("disc"));
|
||||||
object.name = (file.getMetadata("title") || trackNameFromFile(filenameHint) || "").trim();
|
object.name = file.getMetadata("title") || trackNameFromFile(filenameHint);
|
||||||
object.artistName = (file.getMetadata("artist") || "").trim();
|
object.artistName = (file.getMetadata("artist") || "").trim();
|
||||||
object.composerName = (file.getMetadata("composer") ||
|
object.composerName = (file.getMetadata("composer") || "").trim();
|
||||||
file.getMetadata("TCM") || "").trim();
|
|
||||||
object.performerName = (file.getMetadata("performer") || "").trim();
|
object.performerName = (file.getMetadata("performer") || "").trim();
|
||||||
object.albumArtistName = (file.getMetadata("album_artist") || "").trim();
|
object.albumArtistName = (file.getMetadata("album_artist") || "").trim();
|
||||||
object.albumName = (file.getMetadata("album") || "").trim();
|
object.albumName = (file.getMetadata("album") || "").trim();
|
||||||
|
|
@ -1852,7 +1855,7 @@ function grooveFileToDbFile(file, filenameHint, object) {
|
||||||
object.disc = parsedDisc.value;
|
object.disc = parsedDisc.value;
|
||||||
object.discCount = parsedDisc.total;
|
object.discCount = parsedDisc.total;
|
||||||
object.duration = file.duration();
|
object.duration = file.duration();
|
||||||
object.year = parseIntOrNull(file.getMetadata("date"));
|
object.year = parseInt(file.getMetadata("date") || "0", 10);
|
||||||
object.genre = file.getMetadata("genre");
|
object.genre = file.getMetadata("genre");
|
||||||
object.replayGainTrackGain = parseFloatOrNull(file.getMetadata("REPLAYGAIN_TRACK_GAIN"));
|
object.replayGainTrackGain = parseFloatOrNull(file.getMetadata("REPLAYGAIN_TRACK_GAIN"));
|
||||||
object.replayGainTrackPeak = parseFloatOrNull(file.getMetadata("REPLAYGAIN_TRACK_PEAK"));
|
object.replayGainTrackPeak = parseFloatOrNull(file.getMetadata("REPLAYGAIN_TRACK_PEAK"));
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,14 @@ var Player = require('./player');
|
||||||
|
|
||||||
module.exports = PlayerServer;
|
module.exports = PlayerServer;
|
||||||
|
|
||||||
|
// these are the ones we send to the web, not the ones we store in the DB
|
||||||
|
var DB_FILE_PROPS = [
|
||||||
|
'key', 'name', 'artistName', 'albumArtistName',
|
||||||
|
'albumName', 'compilation', 'track', 'trackCount',
|
||||||
|
'disc', 'discCount', 'duration', 'year', 'genre',
|
||||||
|
'file'
|
||||||
|
];
|
||||||
|
|
||||||
PlayerServer.plugins = [];
|
PlayerServer.plugins = [];
|
||||||
|
|
||||||
PlayerServer.actions = {
|
PlayerServer.actions = {
|
||||||
|
|
@ -58,6 +66,13 @@ PlayerServer.actions = {
|
||||||
self.player.setDynamicModeFutureSize(size);
|
self.player.setDynamicModeFutureSize(size);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'hardwarePlayback': {
|
||||||
|
permission: 'admin',
|
||||||
|
args: 'boolean',
|
||||||
|
fn: function(self, client, isOn) {
|
||||||
|
self.player.setHardwarePlayback(isOn);
|
||||||
|
},
|
||||||
|
},
|
||||||
'importUrl': {
|
'importUrl': {
|
||||||
permission: 'control',
|
permission: 'control',
|
||||||
args: 'object',
|
args: 'object',
|
||||||
|
|
@ -73,7 +88,10 @@ PlayerServer.actions = {
|
||||||
} else {
|
} else {
|
||||||
key = dbFile.key;
|
key = dbFile.key;
|
||||||
}
|
}
|
||||||
|
// client might have disconnected by now
|
||||||
|
try {
|
||||||
client.sendMessage('importUrl', {id: id, key: key});
|
client.sendMessage('importUrl', {id: id, key: key});
|
||||||
|
} catch (err) {}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -102,13 +120,6 @@ PlayerServer.actions = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'updateTags': {
|
|
||||||
permission: 'admin',
|
|
||||||
args: 'object',
|
|
||||||
fn: function(self, client, obj) {
|
|
||||||
self.player.updateTags(obj);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'unsubscribe': {
|
'unsubscribe': {
|
||||||
permission: 'read',
|
permission: 'read',
|
||||||
args: 'string',
|
args: 'string',
|
||||||
|
|
@ -201,6 +212,7 @@ PlayerServer.prototype.initialize = function() {
|
||||||
self.player.on('repeatUpdate', addSubscription('repeat', getRepeat));
|
self.player.on('repeatUpdate', addSubscription('repeat', getRepeat));
|
||||||
self.player.on('volumeUpdate', addSubscription('volume', getVolume));
|
self.player.on('volumeUpdate', addSubscription('volume', getVolume));
|
||||||
self.player.on('playlistUpdate', addSubscription('playlist', serializePlaylist));
|
self.player.on('playlistUpdate', addSubscription('playlist', serializePlaylist));
|
||||||
|
self.player.on('hardwarePlayback', addSubscription('hardwarePlayback', getHardwarePlayback));
|
||||||
|
|
||||||
var onLibraryUpdate = addSubscription('library', serializeLibrary);
|
var onLibraryUpdate = addSubscription('library', serializeLibrary);
|
||||||
self.player.on('addDbTrack', onLibraryUpdate);
|
self.player.on('addDbTrack', onLibraryUpdate);
|
||||||
|
|
@ -253,6 +265,10 @@ PlayerServer.prototype.initialize = function() {
|
||||||
return new Date();
|
return new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getHardwarePlayback(client) {
|
||||||
|
return self.player.hardwarePlayback;
|
||||||
|
}
|
||||||
|
|
||||||
function getRepeat(client) {
|
function getRepeat(client) {
|
||||||
return self.player.repeat;
|
return self.player.repeat;
|
||||||
}
|
}
|
||||||
|
|
@ -296,7 +312,7 @@ PlayerServer.prototype.initialize = function() {
|
||||||
var table = {};
|
var table = {};
|
||||||
for (var key in self.player.libraryIndex.trackTable) {
|
for (var key in self.player.libraryIndex.trackTable) {
|
||||||
var track = self.player.libraryIndex.trackTable[key];
|
var track = self.player.libraryIndex.trackTable[key];
|
||||||
table[key] = Player.trackWithoutIndex('read', track);
|
table[key] = Player.trackWithoutIndex(DB_FILE_PROPS, track);
|
||||||
}
|
}
|
||||||
return table;
|
return table;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -10,10 +10,9 @@ function ProtocolParser(options) {
|
||||||
this.player = options.player;
|
this.player = options.player;
|
||||||
|
|
||||||
this.buffer = "";
|
this.buffer = "";
|
||||||
this.alreadyClosed = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ProtocolParser.prototype._read = function(size) {};
|
ProtocolParser.prototype._read = function(size) {}
|
||||||
|
|
||||||
ProtocolParser.prototype._write = function(chunk, encoding, callback) {
|
ProtocolParser.prototype._write = function(chunk, encoding, callback) {
|
||||||
var self = this;
|
var self = this;
|
||||||
|
|
@ -45,18 +44,15 @@ ProtocolParser.prototype._write = function(chunk, encoding, callback) {
|
||||||
}
|
}
|
||||||
self.emit('message', jsonObject.name, jsonObject.args);
|
self.emit('message', jsonObject.name, jsonObject.args);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
ProtocolParser.prototype.sendMessage = function(name, args) {
|
ProtocolParser.prototype.sendMessage = function(name, args) {
|
||||||
if (this.alreadyClosed) return;
|
|
||||||
var jsonObject = {name: name, args: args};
|
var jsonObject = {name: name, args: args};
|
||||||
this.push(JSON.stringify(jsonObject));
|
this.push(JSON.stringify(jsonObject));
|
||||||
};
|
};
|
||||||
|
|
||||||
ProtocolParser.prototype.close = function() {
|
ProtocolParser.prototype.close = function() {
|
||||||
if (this.alreadyClosed) return;
|
|
||||||
this.push(null);
|
this.push(null);
|
||||||
this.alreadyClosed = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function extend(o, src) {
|
function extend(o, src) {
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,10 @@ function WebSocketApiClient(ws) {
|
||||||
}
|
}
|
||||||
|
|
||||||
WebSocketApiClient.prototype.sendMessage = function(name, args) {
|
WebSocketApiClient.prototype.sendMessage = function(name, args) {
|
||||||
try {
|
|
||||||
this.ws.send(JSON.stringify({
|
this.ws.send(JSON.stringify({
|
||||||
name: name,
|
name: name,
|
||||||
args: args,
|
args: args,
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
|
||||||
// nothing to do
|
|
||||||
// client might have disconnected by now
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
WebSocketApiClient.prototype.close = function() {
|
WebSocketApiClient.prototype.close = function() {
|
||||||
|
|
|
||||||
16
package.json
16
package.json
|
|
@ -18,7 +18,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lastfm": "~0.9.0",
|
"lastfm": "~0.9.0",
|
||||||
"express": "^4.0.0",
|
"express": "^4.0.0-rc4",
|
||||||
"superagent": "^0.17.0",
|
"superagent": "^0.17.0",
|
||||||
"mkdirp": "~0.3.5",
|
"mkdirp": "~0.3.5",
|
||||||
"mv": "~2.0.0",
|
"mv": "~2.0.0",
|
||||||
|
|
@ -26,25 +26,25 @@
|
||||||
"zfill": "0.0.1",
|
"zfill": "0.0.1",
|
||||||
"requireindex": "^1.1.0",
|
"requireindex": "^1.1.0",
|
||||||
"mess": "~0.1.1",
|
"mess": "~0.1.1",
|
||||||
"groove": "~1.4.1",
|
"groove": "^1.3.2",
|
||||||
"osenv": "0.0.3",
|
"osenv": "0.0.3",
|
||||||
"level": "^0.18.0",
|
"level": "~0.18.0",
|
||||||
"findit": "~1.1.1",
|
"findit": "~1.1.1",
|
||||||
"archiver": "^0.6.1",
|
"archiver": "^0.6.1",
|
||||||
"uuid": "~1.4.1",
|
"uuid": "~1.4.1",
|
||||||
"music-library-index": "^1.1.1",
|
"music-library-index": "^1.1.0",
|
||||||
"keese": "~1.0.0",
|
"keese": "~1.0.0",
|
||||||
"ws": "^0.4.31",
|
"ws": "~0.4.31",
|
||||||
"jsondiffpatch": "~0.1.4",
|
"jsondiffpatch": "~0.1.4",
|
||||||
"connect-static": "^1.1.0",
|
"connect-static": "^1.1.0",
|
||||||
"multiparty": "^3.2.4",
|
"multiparty": "^3.2.3",
|
||||||
"ytdl": "^0.2.4",
|
"ytdl": "^0.2.2",
|
||||||
"serve-static": "^1.0.3",
|
"serve-static": "^1.0.3",
|
||||||
"body-parser": "^1.0.1"
|
"body-parser": "^1.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"stylus": "^0.42.3",
|
"stylus": "^0.42.3",
|
||||||
"browserify": "^3.41.0"
|
"browserify": "~3.32.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node lib/server.js",
|
"start": "node lib/server.js",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ var Socket = require('./socket');
|
||||||
var uuid = require('uuid');
|
var uuid = require('uuid');
|
||||||
|
|
||||||
var dynamicModeOn = false;
|
var dynamicModeOn = false;
|
||||||
|
var hardwarePlaybackOn = false;
|
||||||
|
|
||||||
var selection = {
|
var selection = {
|
||||||
ids: {
|
ids: {
|
||||||
|
|
@ -47,13 +48,13 @@ var selection = {
|
||||||
this.rangeSelectAnchor = null;
|
this.rangeSelectAnchor = null;
|
||||||
this.rangeSelectAnchorType = null;
|
this.rangeSelectAnchorType = null;
|
||||||
},
|
},
|
||||||
selectOnly: function(selName, key){
|
selectOnly: function(sel_name, key){
|
||||||
this.clear();
|
this.clear();
|
||||||
this.type = selName;
|
this.type = sel_name;
|
||||||
this.ids[selName][key] = true;
|
this.ids[sel_name][key] = true;
|
||||||
this.cursor = key;
|
this.cursor = key;
|
||||||
this.rangeSelectAnchor = key;
|
this.rangeSelectAnchor = key;
|
||||||
this.rangeSelectAnchorType = selName;
|
this.rangeSelectAnchorType = sel_name;
|
||||||
},
|
},
|
||||||
isMulti: function(){
|
isMulti: function(){
|
||||||
var result, k;
|
var result, k;
|
||||||
|
|
@ -197,15 +198,10 @@ var selection = {
|
||||||
} else if (pos.album != null) {
|
} else if (pos.album != null) {
|
||||||
if (isAlbumExpanded(pos.album)) {
|
if (isAlbumExpanded(pos.album)) {
|
||||||
pos.track = pos.album.trackList[0];
|
pos.track = pos.album.trackList[0];
|
||||||
} else {
|
|
||||||
var nextAlbum = pos.artist.albumList[pos.album.index + 1];
|
|
||||||
if (nextAlbum) {
|
|
||||||
pos.album = nextAlbum;
|
|
||||||
} else {
|
} else {
|
||||||
pos.artist = player.searchResults.artistList[pos.artist.index + 1];
|
pos.artist = player.searchResults.artistList[pos.artist.index + 1];
|
||||||
pos.album = null;
|
pos.album = null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} else if (pos.artist != null) {
|
} else if (pos.artist != null) {
|
||||||
if (isArtistExpanded(pos.artist)) {
|
if (isArtistExpanded(pos.artist)) {
|
||||||
pos.album = pos.artist.albumList[0];
|
pos.album = pos.artist.albumList[0];
|
||||||
|
|
@ -357,8 +353,8 @@ var ICON_EXPANDED = 'ui-icon-triangle-1-se';
|
||||||
var permissions = {};
|
var permissions = {};
|
||||||
var socket = null;
|
var socket = null;
|
||||||
var player = null;
|
var player = null;
|
||||||
var userIsSeeking = false;
|
var user_is_seeking = false;
|
||||||
var userIsVolumeSliding = false;
|
var user_is_volume_sliding = false;
|
||||||
var started_drag = false;
|
var started_drag = false;
|
||||||
var abortDrag = function(){};
|
var abortDrag = function(){};
|
||||||
var clickTab = null;
|
var clickTab = null;
|
||||||
|
|
@ -399,11 +395,11 @@ var $tabs = $('#tabs');
|
||||||
var $upload_tab = $tabs.find('.upload-tab');
|
var $upload_tab = $tabs.find('.upload-tab');
|
||||||
var $library = $('#library');
|
var $library = $('#library');
|
||||||
var $lib_filter = $('#lib-filter');
|
var $lib_filter = $('#lib-filter');
|
||||||
var $trackSlider = $('#track-slider');
|
var $track_slider = $('#track-slider');
|
||||||
var $nowplaying = $('#nowplaying');
|
var $nowplaying = $('#nowplaying');
|
||||||
var $nowplaying_elapsed = $nowplaying.find('.elapsed');
|
var $nowplaying_elapsed = $nowplaying.find('.elapsed');
|
||||||
var $nowplaying_left = $nowplaying.find('.left');
|
var $nowplaying_left = $nowplaying.find('.left');
|
||||||
var $volSlider = $('#vol-slider');
|
var $vol_slider = $('#vol-slider');
|
||||||
var $settings = $('#settings');
|
var $settings = $('#settings');
|
||||||
var $uploadByUrl = $('#upload-by-url');
|
var $uploadByUrl = $('#upload-by-url');
|
||||||
var $main_err_msg = $('#main-err-msg');
|
var $main_err_msg = $('#main-err-msg');
|
||||||
|
|
@ -436,9 +432,9 @@ var $settingsLastFmOut = $('#settings-lastfm-out');
|
||||||
var settingsLastFmUserDom = document.getElementById('settings-lastfm-user');
|
var settingsLastFmUserDom = document.getElementById('settings-lastfm-user');
|
||||||
var $toggleScrobble = $('#toggle-scrobble');
|
var $toggleScrobble = $('#toggle-scrobble');
|
||||||
var $shortcuts = $('#shortcuts');
|
var $shortcuts = $('#shortcuts');
|
||||||
var $editTagsDialog = $('#edit-tags');
|
|
||||||
var $playlistMenu = $('#menu-playlist');
|
var $playlistMenu = $('#menu-playlist');
|
||||||
var $libraryMenu = $('#menu-library');
|
var $libraryMenu = $('#menu-library');
|
||||||
|
var $toggleHardwarePlayback = $('#toggle-hardware-playback');
|
||||||
|
|
||||||
function saveLocalState(){
|
function saveLocalState(){
|
||||||
localStorage.setItem('state', JSON.stringify(localState));
|
localStorage.setItem('state', JSON.stringify(localState));
|
||||||
|
|
@ -718,7 +714,6 @@ function refreshSelection() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getValidIds(selection_type) {
|
function getValidIds(selection_type) {
|
||||||
switch (selection_type) {
|
switch (selection_type) {
|
||||||
case 'playlist': return player.playlist.itemTable;
|
case 'playlist': return player.playlist.itemTable;
|
||||||
|
|
@ -824,7 +819,7 @@ function getCurrentTrackPosition(){
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSliderPos() {
|
function updateSliderPos() {
|
||||||
if (userIsSeeking) return;
|
if (user_is_seeking) return;
|
||||||
|
|
||||||
var duration, disabled, elapsed, sliderPos;
|
var duration, disabled, elapsed, sliderPos;
|
||||||
if (player.currentItem && player.isPlaying != null && player.currentItem.track) {
|
if (player.currentItem && player.isPlaying != null && player.currentItem.track) {
|
||||||
|
|
@ -836,19 +831,19 @@ function updateSliderPos() {
|
||||||
disabled = true;
|
disabled = true;
|
||||||
elapsed = duration = sliderPos = 0;
|
elapsed = duration = sliderPos = 0;
|
||||||
}
|
}
|
||||||
$trackSlider.slider("option", "disabled", disabled).slider("option", "value", sliderPos);
|
$track_slider.slider("option", "disabled", disabled).slider("option", "value", sliderPos);
|
||||||
$nowplaying_elapsed.html(formatTime(elapsed));
|
$nowplaying_elapsed.html(formatTime(elapsed));
|
||||||
$nowplaying_left.html(formatTime(duration));
|
$nowplaying_left.html(formatTime(duration));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderVolumeSlider() {
|
function renderVolumeSlider() {
|
||||||
if (userIsVolumeSliding) return;
|
if (user_is_volume_sliding) return;
|
||||||
|
|
||||||
var enabled = player.volume != null;
|
var enabled = player.volume != null;
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
$volSlider.slider('option', 'value', player.volume);
|
$vol_slider.slider('option', 'value', player.volume);
|
||||||
}
|
}
|
||||||
$volSlider.slider('option', 'disabled', !enabled);
|
$vol_slider.slider('option', 'disabled', !enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNowPlaying(){
|
function renderNowPlaying(){
|
||||||
|
|
@ -888,7 +883,7 @@ function renderNowPlaying(){
|
||||||
new_class = 'ui-icon-play';
|
new_class = 'ui-icon-play';
|
||||||
}
|
}
|
||||||
$nowplaying.find(".toggle span").removeClass(old_class).addClass(new_class);
|
$nowplaying.find(".toggle span").removeClass(old_class).addClass(new_class);
|
||||||
$trackSlider.slider("option", "disabled", player.isPlaying == null);
|
$track_slider.slider("option", "disabled", player.isPlaying == null);
|
||||||
updateSliderPos();
|
updateSliderPos();
|
||||||
renderVolumeSlider();
|
renderVolumeSlider();
|
||||||
}
|
}
|
||||||
|
|
@ -1053,34 +1048,29 @@ function nextRepeatState(){
|
||||||
player.setRepeatMode((player.repeat + 1) % repeatModeNames.length);
|
player.setRepeatMode((player.repeat + 1) % repeatModeNames.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
var keyboardHandlers = (function(){
|
var keyboard_handlers = (function(){
|
||||||
function upDownHandler(event){
|
function upDownHandler(event){
|
||||||
var defaultIndex, dir, nextPos;
|
var default_index, dir, next_pos;
|
||||||
if (event.which === 38) {
|
if (event.which === 38) {
|
||||||
// up
|
// up
|
||||||
defaultIndex = player.currentItem ? player.currentItem.index - 1 : player.playlist.itemList.length - 1;
|
default_index = player.playlist.itemList.length - 1;
|
||||||
dir = -1;
|
dir = -1;
|
||||||
} else {
|
} else {
|
||||||
// down
|
// down
|
||||||
defaultIndex = player.currentItem ? player.currentItem.index + 1 : 0;
|
default_index = 0;
|
||||||
dir = 1;
|
dir = 1;
|
||||||
}
|
}
|
||||||
if (defaultIndex >= player.playlist.itemList.length) {
|
|
||||||
defaultIndex = player.playlist.itemList.length - 1;
|
|
||||||
} else if (defaultIndex < 0) {
|
|
||||||
defaultIndex = 0;
|
|
||||||
}
|
|
||||||
if (event.altKey) {
|
if (event.altKey) {
|
||||||
if (selection.isPlaylist()) {
|
if (selection.isPlaylist()) {
|
||||||
player.shiftIds(selection.ids.playlist, dir);
|
player.shiftIds(selection.ids.playlist, dir);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (selection.isPlaylist()) {
|
if (selection.isPlaylist()) {
|
||||||
nextPos = player.playlist.itemTable[selection.cursor].index + dir;
|
next_pos = player.playlist.itemTable[selection.cursor].index + dir;
|
||||||
if (nextPos < 0 || nextPos >= player.playlist.itemList.length) {
|
if (next_pos < 0 || next_pos >= player.playlist.itemList.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
selection.cursor = player.playlist.itemList[nextPos].id;
|
selection.cursor = player.playlist.itemList[next_pos].id;
|
||||||
if (!event.ctrlKey && !event.shiftKey) {
|
if (!event.ctrlKey && !event.shiftKey) {
|
||||||
// single select
|
// single select
|
||||||
selection.clear();
|
selection.clear();
|
||||||
|
|
@ -1096,22 +1086,24 @@ var keyboardHandlers = (function(){
|
||||||
selection.rangeSelectAnchorType = selection.type;
|
selection.rangeSelectAnchorType = selection.type;
|
||||||
}
|
}
|
||||||
} else if (selection.isLibrary()) {
|
} else if (selection.isLibrary()) {
|
||||||
nextPos = selection.getPos();
|
next_pos = selection.getPos();
|
||||||
if (dir > 0) {
|
if (dir > 0) {
|
||||||
selection.incrementPos(nextPos);
|
selection.incrementPos(next_pos);
|
||||||
} else {
|
} else {
|
||||||
prevLibPos(nextPos);
|
prevLibPos(next_pos);
|
||||||
}
|
}
|
||||||
if (nextPos.artist == null) return;
|
if (next_pos.artist == null) {
|
||||||
if (nextPos.track != null) {
|
return;
|
||||||
|
}
|
||||||
|
if (next_pos.track != null) {
|
||||||
selection.type = 'track';
|
selection.type = 'track';
|
||||||
selection.cursor = nextPos.track.key;
|
selection.cursor = next_pos.track.key;
|
||||||
} else if (nextPos.album != null) {
|
} else if (next_pos.album != null) {
|
||||||
selection.type = 'album';
|
selection.type = 'album';
|
||||||
selection.cursor = nextPos.album.key;
|
selection.cursor = next_pos.album.key;
|
||||||
} else {
|
} else {
|
||||||
selection.type = 'artist';
|
selection.type = 'artist';
|
||||||
selection.cursor = nextPos.artist.key;
|
selection.cursor = next_pos.artist.key;
|
||||||
}
|
}
|
||||||
if (!event.ctrlKey && !event.shiftKey) {
|
if (!event.ctrlKey && !event.shiftKey) {
|
||||||
// single select
|
// single select
|
||||||
|
|
@ -1126,12 +1118,16 @@ var keyboardHandlers = (function(){
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (player.playlist.itemList.length === 0) return;
|
if (player.playlist.itemList.length === 0) return;
|
||||||
selection.selectOnly('playlist', player.playlist.itemList[defaultIndex].id);
|
selection.selectOnly('playlist', player.playlist.itemList[default_index].id);
|
||||||
}
|
}
|
||||||
refreshSelection();
|
refreshSelection();
|
||||||
}
|
}
|
||||||
if (selection.isPlaylist()) scrollPlaylistToSelection();
|
if (selection.isPlaylist()) {
|
||||||
if (selection.isLibrary()) scrollLibraryToSelection();
|
scrollPlaylistToSelection();
|
||||||
|
}
|
||||||
|
if (selection.isLibrary()) {
|
||||||
|
scrollLibraryToSelection();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
function leftRightHandler(event){
|
function leftRightHandler(event){
|
||||||
var dir = event.which === 37 ? -1 : 1;
|
var dir = event.which === 37 ? -1 : 1;
|
||||||
|
|
@ -1366,8 +1362,10 @@ var keyboardHandlers = (function(){
|
||||||
title: "Keyboard Shortcuts",
|
title: "Keyboard Shortcuts",
|
||||||
minWidth: 600,
|
minWidth: 600,
|
||||||
height: $document.height() - 40,
|
height: $document.height() - 40,
|
||||||
|
close: function(){
|
||||||
|
$('#shortcuts').remove();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
$shortcuts.focus();
|
|
||||||
} else {
|
} else {
|
||||||
clickTab('library');
|
clickTab('library');
|
||||||
$lib_filter.focus().select();
|
$lib_filter.focus().select();
|
||||||
|
|
@ -1398,28 +1396,29 @@ function isArtistExpanded(artist){
|
||||||
return $li.find("> ul").is(":visible");
|
return $li.find("> ul").is(":visible");
|
||||||
}
|
}
|
||||||
function isAlbumExpanded(album){
|
function isAlbumExpanded(album){
|
||||||
var $li = $("#lib-album-" + toHtmlId(album.key)).closest("li");
|
var $li;
|
||||||
|
$li = $("#lib-album-" + toHtmlId(album.key)).closest("li");
|
||||||
return $li.find("> ul").is(":visible");
|
return $li.find("> ul").is(":visible");
|
||||||
}
|
}
|
||||||
function isStoredPlaylistExpanded(stored_playlist){
|
function isStoredPlaylistExpanded(stored_playlist){
|
||||||
var $li = $("#stored-pl-pl-" + toHtmlId(stored_playlist.name)).closest("li");
|
var $li;
|
||||||
|
$li = $("#stored-pl-pl-" + toHtmlId(stored_playlist.name)).closest("li");
|
||||||
return $li.find("> ul").is(":visible");
|
return $li.find("> ul").is(":visible");
|
||||||
}
|
}
|
||||||
|
function prevLibPos(lib_pos){
|
||||||
function prevLibPos(libPos){
|
if (lib_pos.track != null) {
|
||||||
if (libPos.track != null) {
|
lib_pos.track = lib_pos.track.album.trackList[lib_pos.track.index - 1];
|
||||||
libPos.track = libPos.track.album.trackList[libPos.track.index - 1];
|
} else if (lib_pos.album != null) {
|
||||||
} else if (libPos.album != null) {
|
lib_pos.album = lib_pos.artist.albumList[lib_pos.album.index - 1];
|
||||||
libPos.album = libPos.artist.albumList[libPos.album.index - 1];
|
if (lib_pos.album != null && isAlbumExpanded(lib_pos.album)) {
|
||||||
if (libPos.album != null && isAlbumExpanded(libPos.album)) {
|
lib_pos.track = lib_pos.album.trackList[lib_pos.album.trackList.length - 1];
|
||||||
libPos.track = libPos.album.trackList[libPos.album.trackList.length - 1];
|
|
||||||
}
|
}
|
||||||
} else if (libPos.artist != null) {
|
} else if (lib_pos.artist != null) {
|
||||||
libPos.artist = player.searchResults.artistList[libPos.artist.index - 1];
|
lib_pos.artist = player.searchResults.artistList[lib_pos.artist.index - 1];
|
||||||
if (libPos.artist != null && isArtistExpanded(libPos.artist)) {
|
if (lib_pos.artist != null && isArtistExpanded(lib_pos.artist)) {
|
||||||
libPos.album = libPos.artist.albumList[libPos.artist.albumList.length - 1];
|
lib_pos.album = lib_pos.artist.albumList[lib_pos.artist.albumList.length - 1];
|
||||||
if (libPos.album != null && isAlbumExpanded(libPos.album)) {
|
if (lib_pos.album != null && isAlbumExpanded(lib_pos.album)) {
|
||||||
libPos.track = libPos.album.trackList[libPos.album.trackList.length - 1];
|
lib_pos.track = lib_pos.album.trackList[lib_pos.album.trackList.length - 1];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1549,14 +1548,14 @@ function setUpGenericUi(){
|
||||||
$document.on('mouseout', '.hoverable', function(event){
|
$document.on('mouseout', '.hoverable', function(event){
|
||||||
$(this).removeClass("ui-state-hover");
|
$(this).removeClass("ui-state-hover");
|
||||||
});
|
});
|
||||||
$(".jquery-button").button().on('click', blur);
|
$(".jquery-button").button();
|
||||||
$document.on('mousedown', function(){
|
$document.on('mousedown', function(){
|
||||||
removeContextMenu();
|
removeContextMenu();
|
||||||
selection.fullClear();
|
selection.type = null;
|
||||||
refreshSelection();
|
refreshSelection();
|
||||||
});
|
});
|
||||||
$document.on('keydown', function(event){
|
$document.on('keydown', function(event){
|
||||||
var handler = keyboardHandlers[event.which];
|
var handler = keyboard_handlers[event.which];
|
||||||
if (handler == null) return true;
|
if (handler == null) return true;
|
||||||
if (handler.ctrl != null && handler.ctrl !== event.ctrlKey) return true;
|
if (handler.ctrl != null && handler.ctrl !== event.ctrlKey) return true;
|
||||||
if (handler.alt != null && handler.alt !== event.altKey) return true;
|
if (handler.alt != null && handler.alt !== event.altKey) return true;
|
||||||
|
|
@ -1564,41 +1563,23 @@ function setUpGenericUi(){
|
||||||
handler.handler(event);
|
handler.handler(event);
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
$shortcuts.on('keydown', function(event) {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (event.which === 27) {
|
|
||||||
$shortcuts.dialog('close');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function blur() {
|
|
||||||
$(this).blur();
|
|
||||||
}
|
|
||||||
|
|
||||||
var dynamicModeLabel = document.getElementById('dynamic-mode-label');
|
|
||||||
var plBtnRepeatLabel = document.getElementById('pl-btn-repeat-label');
|
|
||||||
function setUpPlaylistUi(){
|
function setUpPlaylistUi(){
|
||||||
$pl_window.on('click', 'button.clear', function(event){
|
$pl_window.on('click', 'button.clear', function(){
|
||||||
player.clear();
|
player.clear();
|
||||||
});
|
});
|
||||||
$pl_window.on('mousedown', 'button.clear', stopPropagation);
|
|
||||||
|
|
||||||
$pl_window.on('click', 'button.shuffle', function(){
|
$pl_window.on('click', 'button.shuffle', function(){
|
||||||
player.shuffle();
|
player.shuffle();
|
||||||
});
|
});
|
||||||
$pl_window.on('mousedown', 'button.shuffle', stopPropagation);
|
$pl_btn_repeat.on('click', function(){
|
||||||
|
nextRepeatState();
|
||||||
$pl_btn_repeat.on('click', nextRepeatState);
|
});
|
||||||
plBtnRepeatLabel.addEventListener('mousedown', stopPropagation, false);
|
|
||||||
|
|
||||||
$dynamicMode.on('click', function(){
|
$dynamicMode.on('click', function(){
|
||||||
var value = $(this).prop("checked");
|
var value = $(this).prop("checked");
|
||||||
setDynamicMode(value);
|
setDynamicMode(value);
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
dynamicModeLabel.addEventListener('mousedown', stopPropagation, false);
|
|
||||||
|
|
||||||
$playlistItems.on('dblclick', '.pl-item', function(event){
|
$playlistItems.on('dblclick', '.pl-item', function(event){
|
||||||
var trackId = $(this).attr('data-id');
|
var trackId = $(this).attr('data-id');
|
||||||
player.seek(trackId, 0);
|
player.seek(trackId, 0);
|
||||||
|
|
@ -1607,18 +1588,20 @@ function setUpPlaylistUi(){
|
||||||
return event.altKey;
|
return event.altKey;
|
||||||
});
|
});
|
||||||
$playlistItems.on('mousedown', '.pl-item', function(event){
|
$playlistItems.on('mousedown', '.pl-item', function(event){
|
||||||
var trackId, skipDrag;
|
var trackId, skip_drag;
|
||||||
if (started_drag) return true;
|
if (started_drag) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
$(document.activeElement).blur();
|
$(document.activeElement).blur();
|
||||||
if (event.which === 1) {
|
if (event.which === 1) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
removeContextMenu();
|
removeContextMenu();
|
||||||
trackId = $(this).attr('data-id');
|
trackId = $(this).attr('data-id');
|
||||||
skipDrag = false;
|
skip_drag = false;
|
||||||
if (!selection.isPlaylist()) {
|
if (!selection.isPlaylist()) {
|
||||||
selection.selectOnly('playlist', trackId);
|
selection.selectOnly('playlist', trackId);
|
||||||
} else if (event.ctrlKey || event.shiftKey) {
|
} else if (event.ctrlKey || event.shiftKey) {
|
||||||
skipDrag = true;
|
skip_drag = true;
|
||||||
if (event.shiftKey && !event.ctrlKey) {
|
if (event.shiftKey && !event.ctrlKey) {
|
||||||
// range select click
|
// range select click
|
||||||
selection.cursor = trackId;
|
selection.cursor = trackId;
|
||||||
|
|
@ -1634,7 +1617,7 @@ function setUpPlaylistUi(){
|
||||||
selection.selectOnly('playlist', trackId);
|
selection.selectOnly('playlist', trackId);
|
||||||
}
|
}
|
||||||
refreshSelection();
|
refreshSelection();
|
||||||
if (!skipDrag) {
|
if (!skip_drag) {
|
||||||
return performDrag(event, {
|
return performDrag(event, {
|
||||||
complete: function(result, event){
|
complete: function(result, event){
|
||||||
var delta, id;
|
var delta, id;
|
||||||
|
|
@ -1657,7 +1640,9 @@ function setUpPlaylistUi(){
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (event.which === 3) {
|
} else if (event.which === 3) {
|
||||||
if (event.altKey) return;
|
if (event.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
removeContextMenu();
|
removeContextMenu();
|
||||||
trackId = $(this).attr('data-id');
|
trackId = $(this).attr('data-id');
|
||||||
|
|
@ -1675,7 +1660,15 @@ function setUpPlaylistUi(){
|
||||||
left: event.pageX + 1,
|
left: event.pageX + 1,
|
||||||
top: event.pageY + 1
|
top: event.pageY + 1
|
||||||
});
|
});
|
||||||
updateAdminActions($playlistMenu);
|
if (!permissions.admin) {
|
||||||
|
$playlistMenu.find('.delete')
|
||||||
|
.addClass('ui-state-disabled')
|
||||||
|
.attr('title', "Insufficient privileges. See Settings.");
|
||||||
|
} else {
|
||||||
|
$playlistMenu.find('.delete')
|
||||||
|
.removeClass('ui-state-disabled')
|
||||||
|
.attr('title', '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$playlistItems.on('mousedown', function(){
|
$playlistItems.on('mousedown', function(){
|
||||||
|
|
@ -1690,16 +1683,7 @@ function setUpPlaylistUi(){
|
||||||
removeContextMenu();
|
removeContextMenu();
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
$playlistMenu.on('click', '.download', onDownloadContextMenu);
|
$playlistMenu.on('click', '.download', function(){
|
||||||
$playlistMenu.on('click', '.delete', onDeleteContextMenu);
|
|
||||||
$playlistMenu.on('click', '.edit-tags', onEditTagsContextMenu);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopPropagation(event) {
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onDownloadContextMenu() {
|
|
||||||
removeContextMenu();
|
removeContextMenu();
|
||||||
|
|
||||||
if (selection.isMulti()) {
|
if (selection.isMulti()) {
|
||||||
|
|
@ -1708,248 +1692,24 @@ function onDownloadContextMenu() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
});
|
||||||
function onDeleteContextMenu() {
|
$playlistMenu.on('click', '.delete', function(){
|
||||||
if (!permissions.admin) return false;
|
if (!permissions.admin) return false;
|
||||||
removeContextMenu();
|
|
||||||
handleDeletePressed(true);
|
handleDeletePressed(true);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
var editTagsTrackKeys = null;
|
|
||||||
var editTagsTrackIndex = null;
|
|
||||||
function onEditTagsContextMenu() {
|
|
||||||
if (!permissions.admin) return false;
|
|
||||||
removeContextMenu();
|
removeContextMenu();
|
||||||
editTagsTrackKeys = selection.toTrackKeys();
|
|
||||||
editTagsTrackIndex = 0;
|
|
||||||
showEditTags();
|
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
var EDITABLE_PROPS = {
|
|
||||||
name: {
|
|
||||||
type: 'string',
|
|
||||||
write: true,
|
|
||||||
},
|
|
||||||
artistName: {
|
|
||||||
type: 'string',
|
|
||||||
write: true,
|
|
||||||
},
|
|
||||||
albumArtistName: {
|
|
||||||
type: 'string',
|
|
||||||
write: true,
|
|
||||||
},
|
|
||||||
albumName: {
|
|
||||||
type: 'string',
|
|
||||||
write: true,
|
|
||||||
},
|
|
||||||
compilation: {
|
|
||||||
type: 'boolean',
|
|
||||||
write: true,
|
|
||||||
},
|
|
||||||
track: {
|
|
||||||
type: 'integer',
|
|
||||||
write: true,
|
|
||||||
},
|
|
||||||
trackCount: {
|
|
||||||
type: 'integer',
|
|
||||||
write: true,
|
|
||||||
},
|
|
||||||
disc: {
|
|
||||||
type: 'integer',
|
|
||||||
write: true,
|
|
||||||
},
|
|
||||||
discCount: {
|
|
||||||
type: 'integer',
|
|
||||||
write: true,
|
|
||||||
},
|
|
||||||
year: {
|
|
||||||
type: 'integer',
|
|
||||||
write: true,
|
|
||||||
},
|
|
||||||
genre: {
|
|
||||||
type: 'string',
|
|
||||||
write: true,
|
|
||||||
},
|
|
||||||
composerName: {
|
|
||||||
type: 'string',
|
|
||||||
write: true,
|
|
||||||
},
|
|
||||||
performerName: {
|
|
||||||
type: 'string',
|
|
||||||
write: true,
|
|
||||||
},
|
|
||||||
file: {
|
|
||||||
type: 'string',
|
|
||||||
write: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
var EDIT_TAG_TYPES = {
|
|
||||||
'string': {
|
|
||||||
get: function(domItem) {
|
|
||||||
return domItem.value;
|
|
||||||
},
|
|
||||||
set: function(domItem, value) {
|
|
||||||
domItem.value = value || "";
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'integer': {
|
|
||||||
get: function(domItem) {
|
|
||||||
var n = parseInt(domItem.value, 10);
|
|
||||||
if (isNaN(n)) return null;
|
|
||||||
return n;
|
|
||||||
},
|
|
||||||
set: function(domItem, value) {
|
|
||||||
domItem.value = value == null ? "" : value;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'boolean': {
|
|
||||||
get: function(domItem) {
|
|
||||||
return domItem.checked;
|
|
||||||
},
|
|
||||||
set: function(domItem, value) {
|
|
||||||
domItem.checked = !!value;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
var perDom = document.getElementById('edit-tags-per');
|
|
||||||
var perLabelDom = document.getElementById('edit-tags-per-label');
|
|
||||||
var prevDom = document.getElementById('edit-tags-prev');
|
|
||||||
var nextDom = document.getElementById('edit-tags-next');
|
|
||||||
var editTagsFocusDom = document.getElementById('edit-tag-name');
|
|
||||||
function updateEditTagsUi() {
|
|
||||||
var multiple = editTagsTrackKeys.length > 1;
|
|
||||||
prevDom.disabled = !perDom.checked || editTagsTrackIndex === 0;
|
|
||||||
nextDom.disabled = !perDom.checked || (editTagsTrackIndex === editTagsTrackKeys.length - 1);
|
|
||||||
prevDom.style.visibility = multiple ? 'visible' : 'hidden';
|
|
||||||
nextDom.style.visibility = multiple ? 'visible' : 'hidden';
|
|
||||||
perLabelDom.style.visibility = multiple ? 'visible' : 'hidden';
|
|
||||||
var multiCheckBoxVisible = multiple && !perDom.checked;
|
|
||||||
var trackKeysToUse = perDom.checked ? [editTagsTrackKeys[editTagsTrackIndex]] : editTagsTrackKeys;
|
|
||||||
|
|
||||||
for (var propName in EDITABLE_PROPS) {
|
|
||||||
var propInfo = EDITABLE_PROPS[propName];
|
|
||||||
var type = propInfo.type;
|
|
||||||
var setter = EDIT_TAG_TYPES[type].set;
|
|
||||||
var domItem = document.getElementById('edit-tag-' + propName);
|
|
||||||
domItem.disabled = !propInfo.write;
|
|
||||||
var multiCheckBoxDom = document.getElementById('edit-tag-multi-' + propName);
|
|
||||||
multiCheckBoxDom.style.visibility = (multiCheckBoxVisible && propInfo.write) ? 'visible' : 'hidden';
|
|
||||||
var commonValue = null;
|
|
||||||
var consistent = true;
|
|
||||||
for (var i = 0; i < trackKeysToUse.length; i += 1) {
|
|
||||||
var key = trackKeysToUse[i];
|
|
||||||
var track = player.library.trackTable[key];
|
|
||||||
var value = track[propName];
|
|
||||||
if (commonValue == null) {
|
|
||||||
commonValue = value;
|
|
||||||
} else if (commonValue !== value) {
|
|
||||||
consistent = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
multiCheckBoxDom.checked = consistent;
|
|
||||||
setter(domItem, consistent ? commonValue : null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function showEditTags() {
|
|
||||||
$editTagsDialog.dialog({
|
|
||||||
modal: true,
|
|
||||||
title: "Edit Tags",
|
|
||||||
minWidth: 800,
|
|
||||||
height: $document.height() - 40,
|
|
||||||
});
|
});
|
||||||
perDom.checked = false;
|
|
||||||
updateEditTagsUi();
|
|
||||||
editTagsFocusDom.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setUpEditTagsUi() {
|
|
||||||
$editTagsDialog.find("input").on("keydown", function(event) {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (event.which === 27) {
|
|
||||||
$editTagsDialog.dialog('close');
|
|
||||||
} else if (event.which === 13) {
|
|
||||||
saveAndClose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
for (var propName in EDITABLE_PROPS) {
|
|
||||||
var domItem = document.getElementById('edit-tag-' + propName);
|
|
||||||
var multiCheckBoxDom = document.getElementById('edit-tag-multi-' + propName);
|
|
||||||
var listener = createChangeListener(multiCheckBoxDom);
|
|
||||||
domItem.addEventListener('change', listener, false);
|
|
||||||
domItem.addEventListener('keypress', listener, false);
|
|
||||||
domItem.addEventListener('focus', onFocus, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onFocus(event) {
|
|
||||||
editTagsFocusDom = event.target;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createChangeListener(multiCheckBoxDom) {
|
|
||||||
return function() {
|
|
||||||
multiCheckBoxDom.checked = true;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
$("#edit-tags-ok").on('click', saveAndClose);
|
|
||||||
$("#edit-tags-cancel").on('click', closeDialog);
|
|
||||||
perDom.addEventListener('click', updateEditTagsUi, false);
|
|
||||||
nextDom.addEventListener('click', saveAndNext, false);
|
|
||||||
prevDom.addEventListener('click', saveAndPrev, false);
|
|
||||||
|
|
||||||
function saveAndMoveOn(dir) {
|
|
||||||
save();
|
|
||||||
editTagsTrackIndex += dir;
|
|
||||||
updateEditTagsUi();
|
|
||||||
editTagsFocusDom.focus();
|
|
||||||
editTagsFocusDom.select();
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveAndNext() {
|
|
||||||
saveAndMoveOn(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveAndPrev() {
|
|
||||||
saveAndMoveOn(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function save() {
|
|
||||||
var trackKeysToUse = perDom.checked ? [editTagsTrackKeys[editTagsTrackIndex]] : editTagsTrackKeys;
|
|
||||||
var cmd = {};
|
|
||||||
for (var i = 0; i < trackKeysToUse.length; i += 1) {
|
|
||||||
var key = trackKeysToUse[i];
|
|
||||||
var track = player.library.trackTable[key];
|
|
||||||
var props = cmd[track.key] = {};
|
|
||||||
for (var propName in EDITABLE_PROPS) {
|
|
||||||
var propInfo = EDITABLE_PROPS[propName];
|
|
||||||
var type = propInfo.type;
|
|
||||||
var getter = EDIT_TAG_TYPES[type].get;
|
|
||||||
var domItem = document.getElementById('edit-tag-' + propName);
|
|
||||||
var multiCheckBoxDom = document.getElementById('edit-tag-multi-' + propName);
|
|
||||||
if (multiCheckBoxDom.checked && propInfo.write) {
|
|
||||||
props[propName] = getter(domItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
player.sendCommand('updateTags', cmd);
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveAndClose() {
|
|
||||||
save();
|
|
||||||
closeDialog();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDialog() {
|
|
||||||
$editTagsDialog.dialog('close');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateSliderUi(value){
|
function updateSliderUi(value){
|
||||||
var percent = value * 100;
|
var percent = value * 100;
|
||||||
$trackSlider.css('background-size', percent + "% 100%");
|
$track_slider.css('background-size', percent + "% 100%");
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUpNowPlayingUi(){
|
function setUpNowPlayingUi(){
|
||||||
var actions = {
|
var actions, cls, action;
|
||||||
|
actions = {
|
||||||
toggle: togglePlayback,
|
toggle: togglePlayback,
|
||||||
prev: function(){
|
prev: function(){
|
||||||
player.prev();
|
player.prev();
|
||||||
|
|
@ -1961,11 +1721,11 @@ function setUpNowPlayingUi(){
|
||||||
player.stop();
|
player.stop();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
for (var cls in actions) {
|
for (cls in actions) {
|
||||||
var action = actions[cls];
|
action = actions[cls];
|
||||||
setUpMouseDownListener(cls, action);
|
(fn$.call(this, cls, action));
|
||||||
}
|
}
|
||||||
$trackSlider.slider({
|
$track_slider.slider({
|
||||||
step: 0.0001,
|
step: 0.0001,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 1,
|
max: 1,
|
||||||
|
|
@ -1983,30 +1743,32 @@ function setUpNowPlayingUi(){
|
||||||
$nowplaying_elapsed.html(formatTime(ui.value * player.currentItem.track.duration));
|
$nowplaying_elapsed.html(formatTime(ui.value * player.currentItem.track.duration));
|
||||||
},
|
},
|
||||||
start: function(event, ui){
|
start: function(event, ui){
|
||||||
userIsSeeking = true;
|
user_is_seeking = true;
|
||||||
},
|
},
|
||||||
stop: function(event, ui){
|
stop: function(event, ui){
|
||||||
userIsSeeking = false;
|
user_is_seeking = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
function setVol(event, ui){
|
function setVol(event, ui){
|
||||||
if (event.originalEvent == null) return;
|
if (event.originalEvent == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
player.setVolume(ui.value);
|
player.setVolume(ui.value);
|
||||||
}
|
}
|
||||||
$volSlider.slider({
|
$vol_slider.slider({
|
||||||
step: 0.01,
|
step: 0.01,
|
||||||
min: 0,
|
min: 0,
|
||||||
max: 1,
|
max: 1,
|
||||||
change: setVol,
|
change: setVol,
|
||||||
start: function(event, ui){
|
start: function(event, ui){
|
||||||
userIsVolumeSliding = true;
|
user_is_volume_sliding = true;
|
||||||
},
|
},
|
||||||
stop: function(event, ui){
|
stop: function(event, ui){
|
||||||
userIsVolumeSliding = false;
|
user_is_volume_sliding = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setInterval(updateSliderPos, 100);
|
setInterval(updateSliderPos, 100);
|
||||||
function setUpMouseDownListener(cls, action){
|
function fn$(cls, action){
|
||||||
$nowplaying.on('mousedown', "li." + cls, function(event){
|
$nowplaying.on('mousedown', "li." + cls, function(event){
|
||||||
action();
|
action();
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -2200,8 +1962,16 @@ function updateSettingsAuthUi() {
|
||||||
streamUrlDom.setAttribute('href', streaming.getUrl());
|
streamUrlDom.setAttribute('href', streaming.getUrl());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateSettingsAdminUi() {
|
||||||
|
$toggleHardwarePlayback
|
||||||
|
.button('option', 'label', hardwarePlaybackOn ? 'On' : 'Off')
|
||||||
|
.prop('checked', hardwarePlaybackOn)
|
||||||
|
.button('refresh');
|
||||||
|
}
|
||||||
|
|
||||||
function setUpSettingsUi(){
|
function setUpSettingsUi(){
|
||||||
$toggleScrobble.button();
|
$toggleScrobble.button();
|
||||||
|
$toggleHardwarePlayback.button();
|
||||||
$lastFmSignOut.button();
|
$lastFmSignOut.button();
|
||||||
$settingsAuthCancel.button();
|
$settingsAuthCancel.button();
|
||||||
$settingsAuthSave.button();
|
$settingsAuthSave.button();
|
||||||
|
|
@ -2234,6 +2004,11 @@ function setUpSettingsUi(){
|
||||||
socket.send(msg, params);
|
socket.send(msg, params);
|
||||||
updateLastFmSettingsUi();
|
updateLastFmSettingsUi();
|
||||||
});
|
});
|
||||||
|
$toggleHardwarePlayback.on('click', function(event) {
|
||||||
|
var value = $(this).prop('checked');
|
||||||
|
socket.send('hardwarePlayback', value);
|
||||||
|
updateSettingsAdminUi();
|
||||||
|
});
|
||||||
$settingsAuthEdit.on('click', function(event) {
|
$settingsAuthEdit.on('click', function(event) {
|
||||||
settings_ui.auth.show_edit = true;
|
settings_ui.auth.show_edit = true;
|
||||||
updateSettingsAuthUi();
|
updateSettingsAuthUi();
|
||||||
|
|
@ -2352,9 +2127,22 @@ function setUpLibraryUi(){
|
||||||
removeContextMenu();
|
removeContextMenu();
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
$libraryMenu.on('click', '.download', onDownloadContextMenu);
|
$libraryMenu.on('click', '.download', function(){
|
||||||
$libraryMenu.on('click', '.delete', onDeleteContextMenu);
|
removeContextMenu();
|
||||||
$libraryMenu.on('click', '.edit-tags', onEditTagsContextMenu);
|
|
||||||
|
if (selection.isMulti()) {
|
||||||
|
downloadKeys(selection.toTrackKeys());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
$libraryMenu.on('click', '.delete', function(){
|
||||||
|
if (!permissions.admin) return false;
|
||||||
|
handleDeletePressed(true);
|
||||||
|
removeContextMenu();
|
||||||
|
return false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function genericTreeUi($elem, options){
|
function genericTreeUi($elem, options){
|
||||||
|
|
@ -2385,11 +2173,11 @@ function genericTreeUi($elem, options){
|
||||||
function leftMouseDown(event){
|
function leftMouseDown(event){
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
removeContextMenu();
|
removeContextMenu();
|
||||||
var skipDrag = false;
|
var skip_drag = false;
|
||||||
if (!options.isSelectionOwner()) {
|
if (!options.isSelectionOwner()) {
|
||||||
selection.selectOnly(type, key);
|
selection.selectOnly(type, key);
|
||||||
} else if (event.ctrlKey || event.shiftKey) {
|
} else if (event.ctrlKey || event.shiftKey) {
|
||||||
skipDrag = true;
|
skip_drag = true;
|
||||||
selection.cursor = key;
|
selection.cursor = key;
|
||||||
selection.type = type;
|
selection.type = type;
|
||||||
if (!event.shiftKey && !event.ctrlKey) {
|
if (!event.shiftKey && !event.ctrlKey) {
|
||||||
|
|
@ -2403,7 +2191,7 @@ function genericTreeUi($elem, options){
|
||||||
selection.selectOnly(type, key);
|
selection.selectOnly(type, key);
|
||||||
}
|
}
|
||||||
refreshSelection();
|
refreshSelection();
|
||||||
if (!skipDrag) {
|
if (!skip_drag) {
|
||||||
performDrag(event, {
|
performDrag(event, {
|
||||||
complete: function(result, event){
|
complete: function(result, event){
|
||||||
var delta = {
|
var delta = {
|
||||||
|
|
@ -2444,24 +2232,21 @@ function genericTreeUi($elem, options){
|
||||||
left: event.pageX + 1,
|
left: event.pageX + 1,
|
||||||
top: event.pageY + 1
|
top: event.pageY + 1
|
||||||
});
|
});
|
||||||
updateAdminActions($libraryMenu);
|
if (!permissions.admin) {
|
||||||
|
$libraryMenu.find('.delete')
|
||||||
|
.addClass('ui-state-disabled')
|
||||||
|
.attr('title', "Insufficient privileges. See Settings.");
|
||||||
|
} else {
|
||||||
|
$libraryMenu.find('.delete')
|
||||||
|
.removeClass('ui-state-disabled')
|
||||||
|
.attr('title', '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$elem.on('mousedown', function(){
|
$elem.on('mousedown', function(){
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function updateAdminActions($menu) {
|
|
||||||
if (!permissions.admin) {
|
|
||||||
$menu.find('.delete,.edit-tags')
|
|
||||||
.addClass('ui-state-disabled')
|
|
||||||
.attr('title', "Insufficient privileges. See Settings.");
|
|
||||||
} else {
|
|
||||||
$menu.find('.delete,.edit-tags')
|
|
||||||
.removeClass('ui-state-disabled')
|
|
||||||
.attr('title', '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function setUpUi(){
|
function setUpUi(){
|
||||||
setUpGenericUi();
|
setUpGenericUi();
|
||||||
setUpPlaylistUi();
|
setUpPlaylistUi();
|
||||||
|
|
@ -2470,7 +2255,6 @@ function setUpUi(){
|
||||||
setUpTabsUi();
|
setUpTabsUi();
|
||||||
setUpUploadUi();
|
setUpUploadUi();
|
||||||
setUpSettingsUi();
|
setUpSettingsUi();
|
||||||
setUpEditTagsUi();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toAlbumId(s) {
|
function toAlbumId(s) {
|
||||||
|
|
@ -2531,6 +2315,10 @@ $document.ready(function(){
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
socket.on('hardwarePlayback', function(isOn) {
|
||||||
|
hardwarePlaybackOn = isOn;
|
||||||
|
updateSettingsAdminUi();
|
||||||
|
});
|
||||||
socket.on('LastFmApiKey', updateLastFmApiKey);
|
socket.on('LastFmApiKey', updateLastFmApiKey);
|
||||||
socket.on('permissions', function(data){
|
socket.on('permissions', function(data){
|
||||||
permissions = data;
|
permissions = data;
|
||||||
|
|
@ -2555,6 +2343,7 @@ $document.ready(function(){
|
||||||
});
|
});
|
||||||
socket.on('connect', function(){
|
socket.on('connect', function(){
|
||||||
socket.send('subscribe', {name: 'dynamicModeOn'});
|
socket.send('subscribe', {name: 'dynamicModeOn'});
|
||||||
|
socket.send('subscribe', {name: 'hardwarePlayback'});
|
||||||
sendAuth();
|
sendAuth();
|
||||||
load_status = LoadStatus.GoodToGo;
|
load_status = LoadStatus.GoodToGo;
|
||||||
render();
|
render();
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,8 @@ module.exports = PlayerClient;
|
||||||
var compareSortKeyAndId = makeCompareProps(['sortKey', 'id']);
|
var compareSortKeyAndId = makeCompareProps(['sortKey', 'id']);
|
||||||
|
|
||||||
PlayerClient.REPEAT_OFF = 0;
|
PlayerClient.REPEAT_OFF = 0;
|
||||||
PlayerClient.REPEAT_ONE = 1;
|
PlayerClient.REPEAT_ALL = 1;
|
||||||
PlayerClient.REPEAT_ALL = 2;
|
PlayerClient.REPEAT_ONE = 2;
|
||||||
|
|
||||||
util.inherits(PlayerClient, EventEmitter);
|
util.inherits(PlayerClient, EventEmitter);
|
||||||
function PlayerClient(socket) {
|
function PlayerClient(socket) {
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,6 @@ audio.addEventListener('playing', onPlaying, false);
|
||||||
var $ = window.$;
|
var $ = window.$;
|
||||||
var $streamBtn = $('#stream-btn');
|
var $streamBtn = $('#stream-btn');
|
||||||
|
|
||||||
document.getElementById('stream-btn-label').addEventListener('mousedown', onLabelDown, false);
|
|
||||||
|
|
||||||
function onLabelDown(event) {
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getButtonLabel() {
|
function getButtonLabel() {
|
||||||
if (tryingToStream) {
|
if (tryingToStream) {
|
||||||
|
|
@ -34,9 +29,14 @@ function getButtonLabel() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getButtonDisabled() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function renderStreamButton(){
|
function renderStreamButton(){
|
||||||
var label = getButtonLabel();
|
var label = getButtonLabel();
|
||||||
$streamBtn
|
$streamBtn
|
||||||
|
.button("option", "disabled", getButtonDisabled())
|
||||||
.button("option", "label", label)
|
.button("option", "label", label)
|
||||||
.prop("checked", tryingToStream)
|
.prop("checked", tryingToStream)
|
||||||
.button("refresh");
|
.button("refresh");
|
||||||
|
|
@ -77,6 +77,8 @@ function updatePlayer() {
|
||||||
stillBuffering = true;
|
stillBuffering = true;
|
||||||
} else {
|
} else {
|
||||||
audio.pause();
|
audio.pause();
|
||||||
|
audio.src = "";
|
||||||
|
audio.load();
|
||||||
stillBuffering = false;
|
stillBuffering = false;
|
||||||
}
|
}
|
||||||
actuallyStreaming = shouldStream;
|
actuallyStreaming = shouldStream;
|
||||||
|
|
|
||||||
|
|
@ -244,22 +244,13 @@ body
|
||||||
padding 10px
|
padding 10px
|
||||||
|
|
||||||
#upload-by-url
|
#upload-by-url
|
||||||
margin: 4px
|
|
||||||
width: 90%
|
width: 90%
|
||||||
|
|
||||||
|
|
||||||
.ui-menu
|
.ui-menu
|
||||||
width: 240px
|
width: 240px
|
||||||
font-size: 1em
|
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
|
|
||||||
|
|
||||||
#shortcuts
|
#shortcuts
|
||||||
h1
|
h1
|
||||||
margin-bottom 10px
|
margin-bottom 10px
|
||||||
|
|
@ -315,6 +306,3 @@ body
|
||||||
font-size .9em
|
font-size .9em
|
||||||
li:before
|
li:before
|
||||||
content "\2713"
|
content "\2713"
|
||||||
|
|
||||||
.accesskey
|
|
||||||
text-decoration: underline
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
<span class="ui-icon ui-icon-volume-on"></span>
|
<span class="ui-icon ui-icon-volume-on"></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="more-playback-btns">
|
<div id="more-playback-btns">
|
||||||
<input class="jquery-button" type="checkbox" id="stream-btn"><label id="stream-btn-label" for="stream-btn">Stream</label>
|
<input type="checkbox" id="stream-btn"><label for="stream-btn">Stream</label>
|
||||||
</div>
|
</div>
|
||||||
<h1 id="track-display"></h1>
|
<h1 id="track-display"></h1>
|
||||||
<div id="track-slider"></div>
|
<div id="track-slider"></div>
|
||||||
|
|
@ -67,7 +67,7 @@
|
||||||
<input type="file" id="upload-input" multiple="multiple" placeholder="Drag and drop or click to browse">
|
<input type="file" id="upload-input" multiple="multiple" placeholder="Drag and drop or click to browse">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
Automatically queue uploads: <input class="jquery-button" type="checkbox" id="auto-queue-uploads"><label for="auto-queue-uploads">On</label>
|
Automatically queue uploads: <input type="checkbox" id="auto-queue-uploads"><label for="auto-queue-uploads">On</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -103,7 +103,7 @@
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Scrobbling is
|
Scrobbling is
|
||||||
<input class="jquery-button" type="checkbox" id="toggle-scrobble"><label for="toggle-scrobble">Off</label>
|
<input type="checkbox" id="toggle-scrobble"><label for="toggle-scrobble">Off</label>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="settings-lastfm-out">
|
<div id="settings-lastfm-out">
|
||||||
|
|
@ -112,6 +112,13 @@
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="section">
|
||||||
|
<h1>Admin</h1>
|
||||||
|
<p>
|
||||||
|
Hardware audio playback is
|
||||||
|
<input type="checkbox" id="toggle-hardware-playback"><label for="toggle-hardware-playback">On</label>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h1>About</h1>
|
<h1>About</h1>
|
||||||
<ul>
|
<ul>
|
||||||
|
|
@ -126,8 +133,8 @@
|
||||||
<div class="window-header">
|
<div class="window-header">
|
||||||
<button class="jquery-button clear">Clear</button>
|
<button class="jquery-button clear">Clear</button>
|
||||||
<button class="jquery-button shuffle">Shuffle</button>
|
<button class="jquery-button shuffle">Shuffle</button>
|
||||||
<input class="jquery-button" type="checkbox" id="dynamic-mode"><label id="dynamic-mode-label" for="dynamic-mode">Dynamic Mode</label>
|
<input class="jquery-button" type="checkbox" id="dynamic-mode"><label for="dynamic-mode">Dynamic Mode</label>
|
||||||
<input class="jquery-button" type="checkbox" id="pl-btn-repeat"><label id="pl-btn-repeat-label" for="pl-btn-repeat">Repeat: Off</label>
|
<input class="jquery-button" type="checkbox" id="pl-btn-repeat"><label for="pl-btn-repeat">Repeat: Off</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="playlist">
|
<div id="playlist">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
|
|
@ -148,7 +155,7 @@
|
||||||
<div id="main-err-msg-text">Loading...</div>
|
<div id="main-err-msg-text">Loading...</div>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div id="shortcuts" style="display: none" tabindex="-1">
|
<div id="shortcuts" style="display: none">
|
||||||
<h1>Playback</h1>
|
<h1>Playback</h1>
|
||||||
<dl>
|
<dl>
|
||||||
<dt>Space</dt>
|
<dt>Space</dt>
|
||||||
|
|
@ -289,74 +296,26 @@
|
||||||
<dd>Hold while selecting to select all items in between<dd>
|
<dd>Hold while selecting to select all items in between<dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</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">
|
<ul id="menu-playlist" style="display: none">
|
||||||
<li><a href="#" class="remove">Remove</a></li>
|
<li><a href="#" class="remove">Remove</a></li>
|
||||||
<li><a href="#" class="delete">Delete From Library</a></li>
|
<li>
|
||||||
<li><a href="#" class="download" target="_blank">Download</a></li>
|
<a href="#" class="delete">Delete From Library</a>
|
||||||
<li><a href="#" class="edit-tags">Edit Tags</a></li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#" class="download" target="_blank">Download</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<ul id="menu-library" style="display: none">
|
<ul id="menu-library" style="display: none">
|
||||||
<li><a href="#" class="queue">Queue</a></li>
|
<li><a href="#" class="queue">Queue</a></li>
|
||||||
<li><a href="#" class="queue-next">Queue Next</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-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="queue-next-random">Queue Next in Random Order</a></li>
|
||||||
<li><a href="#" class="delete">Delete</a></li>
|
<li>
|
||||||
<li><a href="#" class="download" target="_blank">Download</a></li>
|
<a href="#" class="delete">Delete</a>
|
||||||
<li><a href="#" class="edit-tags menu-item-last">Edit Tags</a></li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#" class="download" target="_blank">Download</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<script src="vendor/jquery-2.1.0.min.js"></script>
|
<script src="vendor/jquery-2.1.0.min.js"></script>
|
||||||
<script src="vendor/jquery-ui-1.10.4.custom.min.js"></script>
|
<script src="vendor/jquery-ui-1.10.4.custom.min.js"></script>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue