├── .gitignore ├── src ├── css │ ├── favicon.png │ ├── aurial_200.png │ └── default.css ├── .editorconfig ├── index.html ├── js │ ├── index.js │ ├── events.js │ ├── util.js │ ├── audioplayer.js │ ├── jsx │ │ ├── queue.js │ │ ├── selection.js │ │ ├── tracklist.js │ │ ├── browser.js │ │ ├── app.js │ │ ├── settings.js │ │ ├── common.js │ │ ├── playlist.js │ │ └── player.js │ ├── playerextra.js │ └── subsonic.js ├── webpack.dev.config.js └── webpack.dist.config.js ├── .drone.yml ├── package.json ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | dist 4 | .idea 5 | *.iml -------------------------------------------------------------------------------- /src/css/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shrimpza/aurial/HEAD/src/css/favicon.png -------------------------------------------------------------------------------- /src/css/aurial_200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shrimpza/aurial/HEAD/src/css/aurial_200.png -------------------------------------------------------------------------------- /src/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | indent_style = tab 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | <%= htmlWebpackPlugin.options.title %> 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/js/index.js: -------------------------------------------------------------------------------- 1 | import {h, render} from 'preact' 2 | import Subsonic from './subsonic' 3 | import App from './jsx/app' 4 | 5 | /** 6 | * Application bootstrap 7 | */ 8 | const subsonic = new Subsonic( 9 | localStorage.getItem('url') || 'http://localhost:4040', 10 | localStorage.getItem('username') || '', 11 | localStorage.getItem('token') || '', 12 | localStorage.getItem('salt') || '', 13 | "1.13.0", "Aurial" 14 | ); 15 | 16 | const container = document.createElement('app'); 17 | document.body.appendChild(container); 18 | render( 19 | , 23 | container); 24 | -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | name: default 3 | type: docker 4 | 5 | steps: 6 | - name: build 7 | image: node:12-alpine 8 | volumes: 9 | - name: pages-cache 10 | path: /out 11 | commands: 12 | - yarn install 13 | - yarn run dist 14 | - mv ./dist /out/aurial 15 | - tar -czf /out/aurial.tgz /out/aurial && mv /out/aurial.tgz /out/aurial/aurial.tgz 16 | when: 17 | branch: 18 | - master 19 | - name: publish 20 | image: plugins/gh-pages 21 | volumes: 22 | - name: pages-cache 23 | path: /out 24 | settings: 25 | username: 26 | from_secret: GH_USER 27 | password: 28 | from_secret: GH_TOKEN 29 | pages_directory: /out/aurial 30 | when: 31 | branch: 32 | - master 33 | 34 | volumes: 35 | - name: pages-cache 36 | temp: {} 37 | -------------------------------------------------------------------------------- /src/webpack.dev.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | var CopyWebpackPlugin = require('copy-webpack-plugin'); 4 | 5 | module.exports = { 6 | target: "web", 7 | entry: path.resolve(__dirname, 'js'), 8 | output: { 9 | path: path.resolve(__dirname, '..', 'dist'), 10 | filename: 'index.js' 11 | }, 12 | devtool: 'cheap-module-eval-source-map', 13 | plugins: [ 14 | new HtmlWebpackPlugin({ 15 | title: 'Aurial', 16 | template: 'src/index.html' 17 | }), 18 | new CopyWebpackPlugin([{ 19 | from: 'src/css', 20 | to: 'css' 21 | }]) 22 | ], 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.js$/, 27 | loader: 'babel-loader', 28 | exclude: /node_modules/, 29 | options: { 30 | "plugins": [ 31 | ["transform-react-jsx", { "pragma": "h" }] 32 | ], 33 | "presets": ["es2015", "stage-0"] 34 | } 35 | } 36 | ] 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aurial", 3 | "version": "1.2.0", 4 | "description": "Aurial", 5 | "repository": "shrimpza/aurial", 6 | "engines": { 7 | "node": ">=4.1.1" 8 | }, 9 | "main": "index.js", 10 | "scripts": { 11 | "dist": "webpack --display-error-details --config src/webpack.dist.config.js", 12 | "watch": "webpack --progress --watch --config src/webpack.dev.config.js", 13 | "start": "webpack-dev-server --progress --config src/webpack.dev.config.js" 14 | }, 15 | "author": "shrimpza", 16 | "dependencies": { 17 | "babel-core": "^6.26.3", 18 | "babel-loader": "^7.1.5", 19 | "babel-plugin-transform-react-jsx": "^6.24.1", 20 | "babel-preset-es2015": "^6.24.1", 21 | "babel-preset-stage-0": "^6.24.1", 22 | "babel-runtime": "^6.24.1", 23 | "blueimp-md5": "^2.15.0", 24 | "copy-webpack-plugin": "^4.6.0", 25 | "exports-loader": "^0.6.4", 26 | "html-webpack-plugin": "^2.30.1", 27 | "imports-loader": "^0.6.5", 28 | "moment": "^2.25.3", 29 | "preact": "^10.4.1", 30 | "webpack": "^2.7.0", 31 | "webpack-dev-server": "^2.11.5" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/js/events.js: -------------------------------------------------------------------------------- 1 | import {ArrayDeleteElement} from './util' 2 | 3 | /** 4 | * A stupidly simple publish-subscribe event bus implementation. 5 | */ 6 | export default class Events { 7 | subscribers = {}; 8 | 9 | subscribe(sub) { 10 | if (!(sub.event instanceof Array)) sub.event = [event]; 11 | 12 | for (var i = 0; i < sub.event.length; i++) { 13 | if (!this.subscribers[sub.event[i]]) { 14 | this.subscribers[sub.event[i]] = []; 15 | } 16 | 17 | if (this.subscribers[sub.event[i]].indexOf(sub.subscriber) <= -1) { 18 | this.subscribers[sub.event[i]].push(sub.subscriber); 19 | } 20 | } 21 | } 22 | 23 | unsubscribe(sub) { 24 | if (!(sub.event instanceof Array)) sub.event = [event]; 25 | 26 | for (var i = 0; i < sub.event.length; i++) { 27 | if (this.subscribers[sub.event[i]]) { 28 | ArrayDeleteElement(this.subscribers[sub.event[i]], sub.subscriber); 29 | } 30 | } 31 | } 32 | 33 | publish(event) { 34 | if (this.subscribers[event.event]) { 35 | for (var i = 0; i < this.subscribers[event.event].length; i++) { 36 | this.subscribers[event.event][i].receive(event); 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Kenneth Watson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/webpack.dist.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var webpack = require('webpack'); 3 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | var CopyWebpackPlugin = require('copy-webpack-plugin'); 5 | 6 | module.exports = { 7 | target: "web", 8 | entry: path.resolve(__dirname, 'js'), 9 | output: { 10 | path: path.resolve(__dirname, '..', 'dist'), 11 | filename: 'index.js' 12 | }, 13 | plugins: [ 14 | new webpack.DefinePlugin({ 15 | 'process.env': { 16 | NODE_ENV: JSON.stringify('production') 17 | } 18 | }), 19 | new webpack.optimize.UglifyJsPlugin({ 20 | compress: { 21 | warnings: false, 22 | dead_code: true 23 | } 24 | }), 25 | new HtmlWebpackPlugin({ 26 | title: 'Aurial', 27 | template: 'src/index.html' 28 | }), 29 | new CopyWebpackPlugin([ 30 | {from: 'src/css', to: 'css'}, 31 | {from: 'README.md'} 32 | ]) 33 | ], 34 | module: { 35 | rules: [ 36 | { 37 | test: /\.js$/, 38 | loader: 'babel-loader', 39 | exclude: /node_modules/, 40 | options: { 41 | "plugins": [ 42 | ["transform-react-jsx", { "pragma": "h" }] 43 | ], 44 | "presets": ["es2015", "stage-0"] 45 | } 46 | } 47 | ] 48 | } 49 | }; 50 | -------------------------------------------------------------------------------- /src/js/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Format a number of seconds in a more user-friendly "mm:ss" string format. 3 | */ 4 | export function SecondsToTime(seconds) { 5 | var mins = Math.floor(seconds / 60); 6 | var hours = Math.floor(mins / 60); 7 | var secs = Math.floor(seconds % 60); 8 | 9 | var hourString = ""; 10 | if (hours > 0) { 11 | mins -= hours * 60; 12 | hourString = hours.toString() + (mins < 10 ? ":0" : ":"); 13 | } 14 | 15 | return hourString + mins.toString() + ":" + (secs < 10 ? "0" + secs.toString() : secs.toString()); 16 | } 17 | 18 | /** 19 | * Return a string as hex. 20 | */ 21 | export function HexEncode(string) { 22 | var result = ""; 23 | for (var i = 0; i < string.length; i++) { 24 | result += string.charCodeAt(i).toString(16); 25 | } 26 | return result; 27 | } 28 | 29 | /** 30 | * Remove the provided element from an array. 31 | */ 32 | export function ArrayDeleteElement(array, element) { 33 | var i = array.indexOf(element); 34 | if (i > -1) array.splice(i, 1); 35 | return array; 36 | } 37 | 38 | /** 39 | * Shuffle and return the array. 40 | */ 41 | export function ArrayShuffle(array) { 42 | var counter = array.length, temp, index; 43 | 44 | while (counter > 0) { 45 | index = (Math.random() * counter--) | 0; 46 | 47 | temp = array[counter]; 48 | array[counter] = array[index]; 49 | array[index] = temp; 50 | } 51 | 52 | return array; 53 | } 54 | 55 | /** 56 | * Generate a random whole number. 57 | */ 58 | export function UniqueID() { 59 | return Math.random().toString().replace(/[^A-Za-z0-9]/, ""); 60 | } 61 | -------------------------------------------------------------------------------- /src/js/audioplayer.js: -------------------------------------------------------------------------------- 1 | export default class AudioPlayer { 2 | 3 | constructor(params) { 4 | this.audio = new Audio(); 5 | if (params.volume) this.audio.volume = params.volume; 6 | 7 | // set up events 8 | this.addEvent('play', params.onPlay); 9 | this.addEvent('playing', params.onPlaying); 10 | this.addEvent('pause', params.onPause); 11 | this.addEvent('ended', params.onComplete); 12 | this.addLoadingEvent('progress', params.onLoading); 13 | this.addProgressEvent('timeupdate', params.onProgress); 14 | 15 | // events and everything else configured, set the src to begin loading 16 | this.audio.src = params.url; 17 | 18 | this.stopEvent = params.onStop; 19 | } 20 | 21 | addEvent(event, callback) { 22 | if (callback) { 23 | this.audio.addEventListener(event, callback); 24 | } 25 | } 26 | 27 | addProgressEvent(event, callback) { 28 | if (callback) { 29 | this.audio.addEventListener(event, function() { 30 | callback(this.audio.currentTime * 1000, this.audio.duration * 1000); 31 | }.bind(this)); 32 | } 33 | } 34 | 35 | addLoadingEvent(event, callback) { 36 | if (callback) { 37 | this.audio.addEventListener(event, function() { 38 | callback((this.audio.buffered.length > 0 ? this.audio.buffered.end(0) : 0) * 1000, this.audio.duration * 1000); 39 | }.bind(this)); 40 | } 41 | } 42 | 43 | play() { 44 | this.audio.play(); 45 | return this; 46 | } 47 | 48 | stop() { 49 | this.audio.pause(); 50 | this.audio.currentTime = 0; 51 | // also, generate a synthetic "stop" event 52 | if (this.stopEvent) this.stopEvent(); 53 | return this; 54 | } 55 | 56 | togglePause() { 57 | if (this.audio.paused) this.audio.play(); 58 | else this.audio.pause(); 59 | return this; 60 | } 61 | 62 | volume(volume) { 63 | this.audio.volume = volume; 64 | return this; 65 | } 66 | 67 | unload() { 68 | this.stop(); 69 | this.audio.src = ''; 70 | this.audio.load(); 71 | return this; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/js/jsx/queue.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import moment from 'moment' 3 | import {IconMessage} from './common' 4 | import TrackList from './tracklist' 5 | import {SecondsToTime} from '../util' 6 | 7 | export default class PlayerQueue extends Component { 8 | state = { 9 | queue: null 10 | } 11 | 12 | constructor(props, context) { 13 | super(props, context); 14 | props.events.subscribe({ 15 | subscriber: this, 16 | event: ["playerEnqueued"] 17 | }); 18 | 19 | this.clear = this.clear.bind(this); 20 | this.playlist = this.playlist.bind(this); 21 | } 22 | 23 | receive(event) { 24 | switch (event.event) { 25 | case "playerEnqueued": this.setState({queue: event.data}); break; 26 | } 27 | } 28 | 29 | clear() { 30 | this.props.events.publish({event: "playerEnqueue", data: {action: "ADD", tracks: this.state.queue}}); 31 | } 32 | 33 | playlist() { 34 | this.props.events.publish({event: "playlistManage", data: {action: "ADD", tracks: this.state.queue}}); 35 | } 36 | 37 | render() { 38 | if (this.state.queue == null) { 39 | return ( 40 | 41 | ); 42 | 43 | } else { 44 | var length = this.state.queue.reduce(function(total, track) { 45 | return total + track.duration; 46 | }, 0); 47 | 48 | return ( 49 |
50 |
51 |
52 |
53 |

54 | 55 | {this.state.queue.length} tracks, {SecondsToTime(length)}

56 |
57 | 58 | 59 |
60 |
61 |
62 |
63 | 64 |
65 | ); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Aurial 2 | 3 | Aurial is a browser-based HTML/JavaScript client interface for streaming music 4 | from [Subsonic](http://subsonic.org/), [Airsonic](https://airsonic.github.io/), 5 | [Navidrome](https://www.navidrome.org/), or other software and services 6 | implementing the Subsonic API, and does does not require the use of a 7 | Flash-based player or plugin. 8 | 9 | Aurial's aim is to provide a simple, intuitive and straight-forward interface 10 | to browse and play your music, and to be as easy to deploy as it is to 11 | configure and use. 12 | 13 | As such, it focusses exclusively on playback of your music library, and by 14 | design does not support other media types, such as video, podcasts or internet 15 | radio. 16 | 17 | 18 | ## Live Demo 19 | 20 | - https://shrimpza.github.io/aurial/ 21 | 22 | The latest build is always deployed at the above URL, feel free to make use of 23 | it for your own purposes, or play around with it prior to hosting your own copy. 24 | 25 | 26 | ## Download and Installation 27 | 28 | For convenience, the latest automated build is available for download, so you 29 | do not need to configure or set up a build environment (if you do want to build 30 | it yourself, see the instructions below). 31 | 32 | - [aurial.tgz](https://shrimpza.github.io/aurial/aurial.tgz) 33 | 34 | To "install", simply extract the archive into a directory exposed via an HTTP 35 | service (there's no need for any server-side scripting or database), and browse 36 | to that location. 37 | 38 | Configuration is done on the "Settings" tab of the main application interface. 39 | 40 | 41 | ## Screenshots 42 | 43 | Note that the current look and functionality may differ from what is shown 44 | here, as the application is still under development. 45 | 46 | ![Browsing the library](https://i.imgur.com/O8AdgCH.png) 47 | 48 | ![Playing some music](https://i.imgur.com/b0oLCp4.png) 49 | 50 | ![Playlist support](https://i.imgur.com/xih3aT7.png) 51 | 52 | 53 | ## Building 54 | 55 | The project is built via NPM and [Webpack](https://webpack.github.io/). 56 | 57 | Install `npm` for your platform, and then execute the following in the project 58 | root directory (alternatively, `yarn` may also be used): 59 | 60 | ``` 61 | $ npm install 62 | $ npm run 63 | ``` 64 | 65 | A `dist` directory will be produced containing the built output, which may be 66 | served via an HTTP server and accessed via a web browser. 67 | 68 | `watch` includes additional debug information, which may not be optimal for 69 | production or general-use deployments, and produces a significantly larger 70 | download; it recompiles code as changes are made. `dist` will produce 71 | uglified and minified output suitable for "production" deployment. `start` will 72 | run Aurial in Webpack's dev server on port 8080 (or next available port above 73 | that), and allows automatic reloading of the page as code changes are made. 74 | -------------------------------------------------------------------------------- /src/js/jsx/selection.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import moment from 'moment' 3 | import {IconMessage,CoverArt} from './common' 4 | import TrackList from './tracklist' 5 | import {SecondsToTime} from '../util' 6 | 7 | export default class Selection extends Component { 8 | state = { 9 | album: null 10 | } 11 | 12 | constructor(props, context) { 13 | super(props, context); 14 | props.events.subscribe({ 15 | subscriber: this, 16 | event: ["browserSelected"] 17 | }); 18 | } 19 | 20 | receive(event) { 21 | switch (event.event) { 22 | case "browserSelected": this.setState({album: event.data.tracks}); break; 23 | } 24 | } 25 | 26 | render() { 27 | if (this.state.album == null) { 28 | return ( 29 | 30 | ); 31 | 32 | } else { 33 | return ( 34 |
35 | 36 | 37 |
38 | ); 39 | } 40 | } 41 | } 42 | 43 | class SelectionAlbum extends Component { 44 | 45 | constructor(props, context) { 46 | super(props, context); 47 | 48 | this.play = this.play.bind(this); 49 | this.enqueue = this.enqueue.bind(this); 50 | this.playlist = this.playlist.bind(this); 51 | } 52 | 53 | play() { 54 | this.props.events.publish({event: "playerEnqueue", data: {action: "REPLACE", tracks: this.props.album.song}}); 55 | this.props.events.publish({event: "playerPlay", data: this.props.album.song[0]}); 56 | } 57 | 58 | enqueue() { 59 | this.props.events.publish({event: "playerEnqueue", data: {action: "ADD", tracks: this.props.album.song}}); 60 | } 61 | 62 | playlist() { 63 | this.props.events.publish({event: "playlistManage", data: {action: "ADD", tracks: this.props.album.song}}); 64 | } 65 | 66 | render() { 67 | return ( 68 |
69 |
70 |
71 | 72 |
73 |
74 |
75 |
{this.props.album.artist}
76 |
{this.props.album.name}
77 |
78 |
79 |
{this.props.album.genre != '(255)' ? this.props.album.genre : ""}
80 |
{this.props.album.year ? "Year: " + this.props.album.year : ""}
81 |
Added: {moment(this.props.album.created).format("ll")}
82 |
{this.props.album.songCount} tracks, {SecondsToTime(this.props.album.duration)}
83 |
84 |
85 | 86 | 87 | 88 |
89 |
90 |
91 |
92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/css/default.css: -------------------------------------------------------------------------------- 1 | /* style for desktop and larger displays */ 2 | @media screen and (min-width: 1000px) { 3 | .browser-frame { 4 | position: absolute; 5 | top: 0; bottom: 0; left: 0; 6 | width: 380px; 7 | overflow: auto; 8 | 9 | background-color: #1B1C1D; 10 | } 11 | 12 | .browser-frame .segment { 13 | padding:0.25em; 14 | } 15 | 16 | .browser-frame .segment .input { 17 | margin-top: 1.0em; 18 | } 19 | 20 | .browser-frame .accordion .segment { 21 | margin: 0 0 1rem 0; 22 | } 23 | 24 | .background-layer { 25 | position: absolute; 26 | top: 0; right: 0; left: 380px; bottom: 0; 27 | background-repeat: no-repeat; 28 | background-size: 100% auto; 29 | opacity: 0.3; 30 | filter: blur(20px); 31 | } 32 | 33 | .player-frame { 34 | position: absolute; 35 | top: 0; right: 0; left: 380px; 36 | height: 100px; 37 | } 38 | 39 | .player-frame .player { 40 | position: absolute; 41 | top: 0; right: 0; left: 0; bottom: 0; 42 | } 43 | 44 | .player-frame .player .bar { 45 | min-width: 0; 46 | } 47 | 48 | .player-frame .player table { 49 | width: 100%; 50 | } 51 | 52 | .player-frame .player table td.controls { 53 | width: 1%; 54 | white-space: nowrap; 55 | } 56 | 57 | .player-frame .player table td.progress .progress, 58 | .player-frame .player table td.volume .progress { 59 | margin: 3px; 60 | } 61 | 62 | .player-frame .player table td.volume { 63 | width: 10%; 64 | white-space: nowrap; 65 | cursor: pointer; 66 | } 67 | 68 | .playlist-frame { 69 | position: absolute; 70 | top: 100px; right: 0; bottom: 0; left: 380px; 71 | } 72 | 73 | .playlist-menu { 74 | position: absolute; 75 | top: 0; right: 0; bottom: 0; left: 0; 76 | } 77 | 78 | .playlist-content { 79 | position: absolute; 80 | top: 45px; right: 0; bottom: 0; left: 0; 81 | } 82 | 83 | .playlist-selection { 84 | position: absolute; 85 | top: 0; right: 0; bottom: 0; left: 0; 86 | } 87 | 88 | .playlist-selection .selectionView { 89 | position: absolute; 90 | top: 0; right: 0; bottom: 0; left: 0; 91 | overflow: auto; 92 | } 93 | 94 | .playlist-selection .selectionView .item .header .artist { 95 | font-weight: normal; 96 | font-size: 0.8em; 97 | } 98 | 99 | .playlist-selection .selectionView .item .meta div { 100 | line-height: 1.2em; 101 | } 102 | 103 | .playlist-playlists { 104 | position: absolute; 105 | top: 0; right: 0; bottom: 0; left: 0; 106 | } 107 | 108 | .playlist-playlists, .playlist-playlists .playlistManager { 109 | position: absolute; 110 | top: 0; right: 0; bottom: 0; left: 0; 111 | } 112 | 113 | .playlist-playlists .playlistManager .playlistSelector { 114 | position: absolute; 115 | top: 0; right: 0; left: 0; 116 | height: 50px; 117 | } 118 | 119 | .playlist-playlists .playlistManager .playlistView { 120 | position: absolute; 121 | top: 51px; right: 0; bottom:0; left: 0; 122 | overflow: auto; 123 | } 124 | 125 | .playlist-playing .queueView { 126 | position: absolute; 127 | top: 0; right: 0; bottom: 0; left: 0; 128 | overflow: auto; 129 | } 130 | 131 | .ui.small.modal .image.content .description { 132 | width: 100%; 133 | } 134 | 135 | .messages { 136 | position: absolute; 137 | right: 10px; bottom: 10px; 138 | width: 400px; 139 | } 140 | 141 | .messages .message { 142 | animation-name: fate-out; 143 | animation-timing-function: ease-out; 144 | animation-iteration-count: 1; 145 | } 146 | 147 | .links { 148 | position: absolute; 149 | top: 0; 150 | right: 0; 151 | padding: 5px; 152 | z-index: 10000; 153 | } 154 | 155 | @keyframes fate-out { 156 | 0% { opacity: 1; } 157 | 90% { opacity: 0; } 158 | 100% { opacity: 0; } 159 | } 160 | } 161 | 162 | .trackList .controls { 163 | width: 5%; 164 | } 165 | 166 | .trackList .number { 167 | width: 5%; 168 | } 169 | 170 | .trackList .artist { 171 | width: 20%; 172 | } 173 | 174 | .trackList .title { 175 | width: 25%; 176 | } 177 | 178 | .trackList .album { 179 | width: 25%; 180 | } 181 | 182 | .trackList .date { 183 | width: 10%; 184 | } 185 | 186 | .trackList .duration { 187 | width: 10%; 188 | } 189 | 190 | .player .progress i { 191 | position: absolute; 192 | left: 5px; 193 | top: 5px; 194 | z-index: 1000; 195 | } 196 | 197 | .player .player-progress .bar.track { 198 | border-radius: 5px 5px 0 0; 199 | } 200 | 201 | .player .player-progress .bar.loading { 202 | background-color: #555; 203 | height: 0.2em; 204 | border-top-left-radius: 0; 205 | border-top-right-radius: 0; 206 | } 207 | -------------------------------------------------------------------------------- /src/js/jsx/tracklist.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import {IconMessage,CoverArt} from './common' 3 | import {SecondsToTime} from '../util' 4 | 5 | export default class TrackList extends Component { 6 | state = { 7 | queue: [], 8 | playing: null 9 | } 10 | 11 | constructor(props, context) { 12 | super(props, context); 13 | props.events.subscribe({ 14 | subscriber: this, 15 | event: ["playerStarted", "playerStopped", "playerFinished", "playerEnqueued"] 16 | }); 17 | } 18 | 19 | receive(event) { 20 | switch (event.event) { 21 | case "playerStarted": this.setState({playing: event.data}); break; 22 | case "playerStopped": 23 | case "playerFinished": this.setState({playing: null}); break; 24 | case "playerEnqueued": this.setState({queue: event.data.map(function(q) {return q.id} )}); break; 25 | } 26 | } 27 | 28 | render() { 29 | var tracks = [] 30 | if (this.props.tracks && this.props.tracks.length > 0) { 31 | tracks = this.props.tracks.map(function (entry) { 32 | return ( 33 | -1} playlist={this.props.playlist} 36 | iconSize={this.props.iconSize} /> 37 | ); 38 | }.bind(this)); 39 | } 40 | 41 | return ( 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | {tracks} 56 | 57 |
 #ArtistTitleAlbumDateDuration
58 | ); 59 | } 60 | } 61 | 62 | class Track extends Component { 63 | 64 | constructor(props, context) { 65 | super(props, context); 66 | 67 | this.play = this.play.bind(this); 68 | this.enqueue = this.enqueue.bind(this); 69 | this.playlistAdd = this.playlistAdd.bind(this); 70 | this.playlistRemove = this.playlistRemove.bind(this); 71 | } 72 | 73 | play() { 74 | this.props.events.publish({event: "playerPlay", data: this.props.track}); 75 | } 76 | 77 | enqueue() { 78 | this.props.events.publish({event: "playerEnqueue", data: {action: "ADD", tracks: [this.props.track]}}); 79 | } 80 | 81 | playlistAdd() { 82 | this.props.events.publish({event: "playlistManage", data: {action: "ADD", tracks: [this.props.track]}}); 83 | } 84 | 85 | playlistRemove() { 86 | this.props.events.publish({event: "playlistManage", data: {action: "REMOVE", tracks: [this.props.track], id: this.props.playlist}}); 87 | } 88 | 89 | render() { 90 | var playlistButton; 91 | if (this.props.playlist) { 92 | playlistButton = ( 93 | 96 | ); 97 | } else { 98 | playlistButton = ( 99 | 102 | ); 103 | } 104 | 105 | return ( 106 | 107 | 108 | 109 | 112 | {playlistButton} 113 | 114 | 115 | {this.props.track.discNumber ? (this.props.track.discNumber + '.' + this.props.track.track) : this.props.track.track} 116 | 117 | 118 | {this.props.track.artist} 119 | 120 | 121 | {this.props.track.title} 122 | 123 | 124 | {/* */} 125 | {this.props.track.album} 126 | 127 | 128 | {this.props.track.year} 129 | 130 | 131 | {this.props.track.duration ? SecondsToTime(this.props.track.duration) : '?:??'} 132 | 133 | 134 | ); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/js/playerextra.js: -------------------------------------------------------------------------------- 1 | import {Messages} from './jsx/app' 2 | 3 | /** 4 | * The Player Extras class initialises a bunch of extra non-critical things. 5 | */ 6 | export default class PlayerExtras { 7 | constructor(subsonic, app, events) { 8 | this.scrobbler = new Scrobbler(subsonic, events); 9 | 10 | if (localStorage.getItem('notifications') === 'true') { 11 | this.notifier = new Notifier(subsonic, events); 12 | } 13 | 14 | if (localStorage.getItem('backgroundArt') === 'true') { 15 | this.albumBackgroundChanger = new AlbumBackgroundChanger(subsonic, events); 16 | } 17 | } 18 | 19 | terminate() { 20 | if (this.scrobler) this.scrobler.terminate(); 21 | if (this.notifier) this.notifier.terminate(); 22 | if (this.albumBackgroundChanger) this.albumBackgroundChanger.terminate(); 23 | } 24 | } 25 | 26 | /** 27 | * Scrobbles the currently playing track when it has played 50% or more. 28 | * 29 | * On submission failure, will retry. 30 | */ 31 | class Scrobbler { 32 | 33 | constructor(subsonic, events) { 34 | this.subsonic = subsonic; 35 | this.events = events; 36 | 37 | this.submitted = null; 38 | 39 | this.events.subscribe({ 40 | subscriber: this, 41 | event: ["playerUpdated"] 42 | }); 43 | } 44 | 45 | terminate() { 46 | this.events.unsubscribe({ 47 | subscriber: this, 48 | event: ["playerUpdated"] 49 | }); 50 | } 51 | 52 | receive(event) { 53 | switch (event.event) { 54 | case "playerUpdated": { 55 | this.update(event.data.track, event.data.duration, event.data.position); 56 | break; 57 | } 58 | } 59 | } 60 | 61 | update(playing, length, position) { 62 | if (this.submitted != playing.id) { 63 | var percent = (position / length) * 100; 64 | if (percent > 50) { 65 | this.submitted = playing.id; 66 | this.subsonic.scrobble({ 67 | id: playing.id, 68 | success: function() { 69 | console.log("Scrobbled track " + playing.title); 70 | }, 71 | error: function(e) { 72 | this.submitted = null; 73 | console.error("Scrobble failed for track " + playing.title, e); 74 | Messages.message(this.events, "Scrobble failed for track " + playing.title, "warning", "warning"); 75 | }.bind(this) 76 | }); 77 | } 78 | } 79 | } 80 | } 81 | 82 | /** 83 | * Sets the page background to the currently playing track's album art. 84 | */ 85 | class AlbumBackgroundChanger { 86 | 87 | constructor(subsonic, events) { 88 | this.subsonic = subsonic; 89 | this.events = events; 90 | 91 | this.currentArt = 0; 92 | 93 | events.subscribe({ 94 | subscriber: this, 95 | event: ["playerStarted"] 96 | }); 97 | } 98 | 99 | terminate() { 100 | this.events.unsubscribe({ 101 | subscriber: this, 102 | event: ["playerStarted"] 103 | }); 104 | } 105 | 106 | receive(event) { 107 | switch (event.event) { 108 | case "playerStarted": { 109 | if (this.currentArt != event.data.coverArt) { 110 | this.currentArt = event.data.coverArt; 111 | $('.background-layer').css('background-image', 'url(' + this.subsonic.getUrl("getCoverArt", {id: event.data.coverArt}) + ')'); 112 | } 113 | break; 114 | } 115 | } 116 | } 117 | } 118 | 119 | /** 120 | * Sets the page background to the currently playing track's album art. 121 | */ 122 | class Notifier { 123 | 124 | ICON_SIZE = 64; // small icon for notifications 125 | 126 | constructor(subsonic, events) { 127 | this.subsonic = subsonic; 128 | this.events = events; 129 | 130 | Notification.requestPermission(function (permission) { 131 | if (permission === "granted") { 132 | events.subscribe({ 133 | subscriber: this, 134 | event: ["playerStarted"] 135 | }); 136 | } 137 | }.bind(this)); 138 | } 139 | 140 | terminate() { 141 | this.events.unsubscribe({ 142 | subscriber: this, 143 | event: ["playerStarted"] 144 | }); 145 | } 146 | 147 | receive(event) { 148 | // TODO only update the image if `event.data.coverArt` has changed 149 | 150 | switch (event.event) { 151 | case "playerStarted": { 152 | /* 153 | to support desktop notification daemons which don't render JPEG images 154 | the following hack has been put in place. it loads an image, draws it 155 | onto a canvas, and gets the canvas a data url in png format. the data 156 | url is then used for the notification icon. 157 | */ 158 | 159 | var canvas = document.createElement('canvas'); 160 | canvas.width = this.ICON_SIZE; 161 | canvas.height = this.ICON_SIZE; 162 | var ctx = canvas.getContext('2d'); 163 | 164 | var img = document.createElement('img'); 165 | 166 | // we can only render to canvas and display the notification once the image has loaded 167 | img.onload = function() { 168 | ctx.drawImage(img, 0, 0); 169 | 170 | var notification = new Notification(event.data.title, { 171 | body: event.data.artist + '\n\n' + event.data.album, 172 | icon: canvas.toDataURL('image/png'), 173 | silent: true 174 | }); 175 | }; 176 | 177 | img.crossOrigin = "anonymous"; // if this isn't the most obscure thing you've ever seen, then i don't know... 178 | 179 | img.src = this.subsonic.getUrl("getCoverArt", { 180 | id: event.data.coverArt, 181 | size: this.ICON_SIZE 182 | }); 183 | 184 | break; 185 | } 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/js/jsx/browser.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import {UniqueID} from '../util' 3 | import {IconMessage,CoverArt} from './common' 4 | import {Messages} from './app' 5 | 6 | export default class ArtistList extends Component { 7 | 8 | state = { 9 | artists: [], 10 | loaded: false, 11 | error: null, 12 | search: "", 13 | uid: UniqueID() 14 | } 15 | 16 | constructor(props, context) { 17 | super(props, context); 18 | 19 | this.search = this.search.bind(this); 20 | 21 | this.loadArtists(); 22 | } 23 | 24 | componentDidMount() { 25 | $('#' + this.state.uid).accordion({exclusive: false}); 26 | } 27 | 28 | componentDidUpdate(prevProps, prevState) { 29 | if (prevProps.subsonic != this.props.subsonic) this.loadArtists(); 30 | } 31 | 32 | loadArtists() { 33 | this.props.subsonic.getArtists({ 34 | success: function(data) { 35 | this.setState({artists: data.artists, loaded: true, error: null}); 36 | }.bind(this), 37 | error: function(err) { 38 | this.setState({error: , loaded: true}); 39 | console.error(this, err); 40 | Messages.message(this.props.events, "Unable to get artists: " + err.message, "error", "warning sign"); 41 | }.bind(this) 42 | }) 43 | } 44 | 45 | search(e) { 46 | this.setState({search: e.target.value}); 47 | } 48 | 49 | render() { 50 | var artists = this.state.artists 51 | .filter(function (artist) { 52 | return this.state.search == '' || artist.name.toLowerCase().indexOf(this.state.search.toLowerCase()) !== -1; 53 | }.bind(this)) 54 | .map(function (artist) { 55 | return ( 56 | 57 | ); 58 | }.bind(this)); 59 | 60 | if (!this.state.loaded && artists.length == 0) { 61 | artists =
62 | } 63 | 64 | return this.state.error || ( 65 |
66 |
67 | 68 | 69 |
70 |
71 |
72 | {artists} 73 |
74 |
75 | ); 76 | } 77 | } 78 | 79 | export class Artist extends Component { 80 | 81 | state = { 82 | albums: [], 83 | loaded: false 84 | } 85 | 86 | constructor(props, context) { 87 | super(props, context); 88 | 89 | this.loadAlbums = this.loadAlbums.bind(this); 90 | this.onClick = this.onClick.bind(this); 91 | } 92 | 93 | loadAlbums() { 94 | this.props.subsonic.getArtist({ 95 | id: this.props.data.id, 96 | success: function(data) { 97 | this.setState({albums: data.albums, loaded: true}); 98 | }.bind(this), 99 | error: function(err) { 100 | console.error(this, err); 101 | Messages.message(this.props.events, "Unable to load artist's albums: " + err.message, "error", "warning sign"); 102 | }.bind(this) 103 | }); 104 | } 105 | 106 | onClick() { 107 | if (!this.state.loaded) { 108 | this.loadAlbums(); 109 | } 110 | } 111 | 112 | render() { 113 | var albums = this.state.albums.map(function (album) { 114 | return ( 115 | 116 | ); 117 | }.bind(this)); 118 | 119 | if (!this.state.loaded && albums.length == 0) { 120 | albums =
121 | } 122 | 123 | return ( 124 |
125 |
126 | 127 | {this.props.data.name} ({this.props.filter ? Object.keys(this.props.filter).length : this.props.data.albumCount}) 128 |
129 |
130 |
131 | {albums} 132 |
133 |
134 |
135 | ); 136 | } 137 | } 138 | 139 | class Album extends Component { 140 | 141 | constructor(props, context) { 142 | super(props, context); 143 | 144 | this.onClick = this.onClick.bind(this); 145 | } 146 | 147 | onClick() { 148 | this.props.subsonic.getAlbum({ 149 | id: this.props.data.id, 150 | success: function(data) { 151 | this.props.events.publish({event: "browserSelected", data: {tracks: data.album}}); 152 | }.bind(this), 153 | error: function(err) { 154 | console.error(this, err); 155 | Messages.message(this.props.events, "Unable to load album: " + err.message, "error", "warning sign"); 156 | }.bind(this) 157 | }); 158 | } 159 | 160 | render() { 161 | var year = this.props.data.year ? '[' + this.props.data.year + ']' : ''; 162 | return ( 163 |
164 | 165 |
166 |
{this.props.data.name}
167 |
{year} {this.props.data.songCount} tracks
168 |
169 |
170 |
171 |
172 | ); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/js/jsx/app.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import Events from '../events' 3 | import PlayerExtras from '../playerextra' 4 | import Player from './player' 5 | import Selection from './selection' 6 | import PlaylistManager from './playlist' 7 | import PlayerQueue from './queue' 8 | import ArtistList from './browser' 9 | import {TabGroup, ImageViewer} from './common' 10 | import Settings from './settings' 11 | import {ArrayDeleteElement} from '../util' 12 | 13 | export default class App extends Component { 14 | 15 | constructor(props, context) { 16 | super(props, context); 17 | 18 | this.state = { 19 | subsonic: props.subsonic, 20 | trackBuffer: props.trackBuffer, 21 | persistQueue: props.persistQueue 22 | } 23 | 24 | this.events = new Events(); 25 | 26 | this.events.subscribe({ 27 | subscriber: this, 28 | event: ["appSettings"] 29 | }); 30 | } 31 | 32 | receive(event) { 33 | if (event.event == "appSettings") { 34 | if (this.playerExtras) this.playerExtras.terminate(); 35 | this.setState({ 36 | subsonic: event.data.subsonic, 37 | trackBuffer: event.data.trackBuffer, 38 | persistQueue: event.data.persistQueue 39 | }); 40 | } 41 | } 42 | 43 | render() { 44 | var player = ; 45 | 46 | var selection = ; 47 | var playlists = ; 48 | var queue = ; 49 | 50 | var artistList = ; 51 | 52 | var settings = ; 53 | 54 | var messages = ; 55 | 56 | var tabs = []; 57 | tabs.push({id:"selection", title: "Selection", active: true, icon: "chevron right"}); 58 | tabs.push({id:"playlists", title: "Playlists", icon: "teal list"}); 59 | tabs.push({id:"playing", title: "Queue", icon: "olive play"}); 60 | tabs.push({id:"settings", title: "Settings", icon: "setting"}); 61 | 62 | var tabGroup = ; 63 | 64 | this.playerExtras = new PlayerExtras(this.state.subsonic, this, this.events); 65 | 66 | return ( 67 |
68 | 69 | 70 |
71 |
{artistList}
72 |
73 |
74 |
{player}
75 |
76 |
{tabGroup}
77 |
78 |
{selection}
79 |
{playlists}
80 |
{queue}
81 |
{settings}
82 |
83 |
84 | {messages} 85 |
86 | ); 87 | } 88 | } 89 | 90 | export class Messages extends Component { 91 | 92 | static defaultProps = { 93 | showTime: 8 // seconds 94 | } 95 | 96 | static message(events, message, type, icon) { 97 | events.publish({ 98 | event: "message", 99 | data: { 100 | text: message.toString(), 101 | type: type, 102 | icon: icon 103 | } 104 | }); 105 | } 106 | 107 | constructor(props, context) { 108 | super(props, context); 109 | 110 | this.state = { 111 | messages: [] 112 | } 113 | 114 | props.events.subscribe({ 115 | subscriber: this, 116 | event: ["message"] 117 | }); 118 | 119 | this.receive = this.receive.bind(this); 120 | this.removeMessage = this.removeMessage.bind(this); 121 | } 122 | 123 | receive(event) { 124 | if (event.event == "message") { 125 | event.data._id = "msg" + Math.random(); 126 | var msgs = this.state.messages.slice(); 127 | msgs.push(event.data); 128 | this.setState({messages: msgs}); 129 | 130 | setTimeout(function() { 131 | this.removeMessage(event.data); 132 | }.bind(this), this.props.showTime * 1000); 133 | } 134 | } 135 | 136 | removeMessage(message) { 137 | var msgs = this.state.messages.slice(); 138 | ArrayDeleteElement(msgs, message); 139 | this.setState({messages: msgs}); 140 | } 141 | 142 | render() { 143 | var anim = { 144 | animationDuration: ((this.props.showTime / 2) + 0.2) + "s", 145 | animationDelay: (this.props.showTime / 2) + "s" 146 | } 147 | 148 | var messages = this.state.messages.map(function(m) { 149 | var icon = m.icon ? : null; 150 | return ( 151 |
152 | {icon} 153 |

{m.text}

154 |
155 | ); 156 | }); 157 | 158 | return ( 159 |
160 | {messages} 161 |
162 | ); 163 | } 164 | } 165 | 166 | class Links extends Component { 167 | render() { 168 | return ( 169 | 175 | ); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/js/subsonic.js: -------------------------------------------------------------------------------- 1 | import md5 from 'blueimp-md5' 2 | 3 | /** 4 | * Subsonic API client. 5 | * 6 | * Exposes methods to make requests to Subsonic API endpoints, given the 7 | * configuration provided at initialisation time. 8 | * 9 | * In addition to whatever input the API methods require, success and failure 10 | * callbacks may be provided to consume output. For example: 11 | * 12 | * subsonic.ping({ 13 | * success: function(response) { 14 | * // use response 15 | * }, 16 | * failure: function(status, message) { 17 | * // ... 18 | * } 19 | * }) 20 | */ 21 | export default class Subsonic { 22 | 23 | constructor(url, user, token, salt, version, appName) { 24 | this.url = url.endsWith('/') ? url.substring(0, url.length - 1) : url.trim(); 25 | this.user = user; 26 | this.token = token; 27 | this.salt = salt; 28 | this.version = version; 29 | this.appName = appName; 30 | } 31 | 32 | static createToken(password, salt) { 33 | return md5(password + salt); 34 | } 35 | 36 | getUrl(func, params) { 37 | var result = this.url + "/rest/" + func + ".view?"; 38 | var _params = { 39 | u: this.user, 40 | t: this.token, 41 | s: this.salt, 42 | v: this.version, 43 | c: this.appName, 44 | f: "json" 45 | }; 46 | 47 | Object.keys(_params).forEach(function(k) { 48 | result += k + "=" + _params[k] + "&"; 49 | }); 50 | 51 | Object.keys(params).forEach(function(k) { 52 | if (Array.isArray(params[k])) { 53 | params[k].forEach(function(v) { 54 | result += k + "=" + v + "&"; 55 | }); 56 | } else { 57 | result += k + "=" + params[k] + "&"; 58 | } 59 | }); 60 | 61 | return result; 62 | } 63 | 64 | ping(params) { 65 | fetch(this.getUrl('ping', {}), { 66 | mode: 'cors', 67 | cache: 'no-cache' 68 | }) 69 | .then(function(result) { 70 | result.json().then(function(data) { 71 | params.success(data['subsonic-response']); 72 | }); 73 | }) 74 | .catch(function(error) { 75 | params.error(error); 76 | }); 77 | } 78 | 79 | getArtists(params) { 80 | fetch(this.getUrl('getArtists', {}), { 81 | mode: 'cors' 82 | }) 83 | .then(function(result) { 84 | result.json().then(function(data) { 85 | var allArtists = []; 86 | 87 | // get artists from their letter-based groups into a flat collection 88 | data['subsonic-response'].artists.index.map(function(letter) { 89 | letter.artist.map(function(artist) { 90 | allArtists.push(artist); 91 | }); 92 | }); 93 | 94 | // sort artists ignoring the 'ignored articles', such as 'The' etc 95 | var ignoredArticles = data['subsonic-response'].artists.ignoredArticles.split(' '); 96 | allArtists.sort(function(a, b) { 97 | var at = a.name; 98 | var bt = b.name; 99 | for (var i = ignoredArticles.length - 1; i >= 0; i--) { 100 | if (at.indexOf(ignoredArticles[i] + ' ') == 0) at = at.replace(ignoredArticles[i] + ' ', ''); 101 | if (bt.indexOf(ignoredArticles[i] + ' ') == 0) bt = bt.replace(ignoredArticles[i] + ' ', ''); 102 | }; 103 | return at.localeCompare(bt); 104 | }); 105 | 106 | params.success({artists: allArtists}); 107 | }); 108 | }) 109 | .catch(function(error) { 110 | params.error(error); 111 | }); 112 | } 113 | 114 | getArtist(params) { 115 | fetch(this.getUrl('getArtist', {id: params.id}), { 116 | mode: 'cors' 117 | }).then(function(result) { 118 | result.json().then(function(data) { 119 | var albums = data['subsonic-response'].artist.album; 120 | 121 | if (albums.length > 1) { 122 | albums.sort(function(a, b) { 123 | return (a.year || 0) - (b.year || 0); 124 | }); 125 | } 126 | 127 | params.success({albums: albums}); 128 | }); 129 | }) 130 | .catch(function(error) { 131 | params.error(error); 132 | }); 133 | } 134 | 135 | getAlbum(params) { 136 | fetch(this.getUrl('getAlbum', {id: params.id}), { 137 | mode: 'cors' 138 | }).then(function(result) { 139 | result.json().then(function(data) { 140 | var album = data['subsonic-response'].album; 141 | album.song.sort(function(a, b) { 142 | return a.discNumber && b.discNumber 143 | ? ((a.discNumber*1000) + a.track) - ((b.discNumber*1000) + b.track) 144 | : a.track - b.track; 145 | }); 146 | params.success({album: album}); 147 | }) 148 | }) 149 | .catch(function(error) { 150 | params.error(error); 151 | }); 152 | } 153 | 154 | getPlaylists(params) { 155 | fetch(this.getUrl('getPlaylists', {}), { 156 | mode: 'cors' 157 | }).then(function(result) { 158 | result.json().then(function(data) { 159 | params.success({playlists: data['subsonic-response'].playlists.playlist}); 160 | }); 161 | }) 162 | .catch(function(error) { 163 | params.error(error); 164 | }); 165 | } 166 | 167 | getPlaylist(params) { 168 | fetch(this.getUrl('getPlaylist', {id: params.id}), { 169 | mode: 'cors' 170 | }).then(function(result) { 171 | result.json().then(function(data) { 172 | params.success({playlist: data['subsonic-response'].playlist}); 173 | }); 174 | }) 175 | .catch(function(error) { 176 | params.error(error); 177 | }); 178 | } 179 | 180 | createPlaylist(params) { 181 | fetch(this.getUrl('createPlaylist', {name: params.name, songId: params.tracks}), { 182 | mode: 'cors' 183 | }).then(function(result) { 184 | result.json().then(function(data) { 185 | if (data['subsonic-response'].status == "ok") { 186 | params.success(); 187 | } else { 188 | params.error(data['subsonic-response'].error.message); 189 | } 190 | }); 191 | }) 192 | .catch(function(error) { 193 | params.error(error); 194 | }); 195 | } 196 | 197 | updatePlaylist(params) { 198 | var options = {playlistId: params.id}; 199 | if (params.name) options.name = params.name; 200 | if (params.comment) options.comment = params.comment; 201 | if (params.add) options.songIdToAdd = params.add; 202 | if (params.remove) options.songIndexToRemove = params.remove; 203 | 204 | fetch(this.getUrl('updatePlaylist', options), { 205 | mode: 'cors' 206 | }).then(function(result) { 207 | result.json().then(function(data) { 208 | if (data['subsonic-response'].status == "ok") { 209 | params.success(); 210 | } else { 211 | params.error(data['subsonic-response'].error.message); 212 | } 213 | }); 214 | }) 215 | .catch(function(error) { 216 | params.error(error); 217 | }); 218 | } 219 | 220 | deletePlaylist(params) { 221 | fetch(this.getUrl('deletePlaylist', {id: params.id}), { 222 | mode: 'cors' 223 | }).then(function(result) { 224 | result.json().then(function(data) { 225 | if (data['subsonic-response'].status == "ok") { 226 | params.success(); 227 | } else { 228 | params.error(data['subsonic-response'].error.message); 229 | } 230 | }); 231 | }) 232 | .catch(function(error) { 233 | params.error(error); 234 | }); 235 | } 236 | 237 | search(params) { 238 | fetch(this.getUrl('search3', {query: params.query, songCount: params.songCount}), { 239 | mode: 'cors' 240 | }).then(function(result) { 241 | result.json().then(function(data) { 242 | params.success(data['subsonic-response'].searchResult3); 243 | }); 244 | }) 245 | .catch(function(error) { 246 | params.error(error); 247 | }); 248 | } 249 | 250 | scrobble(params) { 251 | fetch(this.getUrl('scrobble', {id: params.id}), { 252 | mode: 'cors' 253 | }).then(function(result) { 254 | result.json().then(function(data) { 255 | params.success(); 256 | }); 257 | }) 258 | .catch(function(error) { 259 | params.error(error); 260 | }); 261 | } 262 | 263 | getStreamUrl(params) { 264 | return this.getUrl('stream', { 265 | id: params.id, 266 | format: params.format ? params.format : 'mp3', 267 | maxBitRate: params.bitrate ? params.bitrate : 0 268 | }); 269 | } 270 | 271 | } 272 | -------------------------------------------------------------------------------- /src/js/jsx/settings.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import Subsonic from '../subsonic' 3 | import {UniqueID} from '../util' 4 | import {Messages} from './app' 5 | import {Prompt} from './common' 6 | 7 | const TEST_UNTESTED = 0; 8 | const TEST_BUSY = 1; 9 | const TEST_SUCCESS = 2; 10 | const TEST_FAILED = 3; 11 | 12 | export default class Settings extends Component { 13 | 14 | state = { 15 | url: this.props.subsonic.url, 16 | user: this.props.subsonic.user, 17 | password: '', 18 | notifications: localStorage.getItem('notifications') === 'true', 19 | backgroundArt: localStorage.getItem('backgroundArt') === 'true', 20 | persistQueue: localStorage.getItem('persistQueue') === 'true', 21 | repeatQueue: localStorage.getItem('repeatQueue') === 'true', 22 | trackBuffer: localStorage.getItem('trackBuffer') || '0', 23 | testState: TEST_UNTESTED 24 | }; 25 | 26 | constructor(props, context) { 27 | super(props, context); 28 | 29 | this.save = this.save.bind(this); 30 | this.change = this.change.bind(this); 31 | this.demo = this.demo.bind(this); 32 | this.test = this.test.bind(this); 33 | } 34 | 35 | save(e) { 36 | e.preventDefault(); 37 | 38 | localStorage.setItem('url', this.state.url); 39 | localStorage.setItem('username', this.state.user); 40 | 41 | if (this.state.password !== '') { 42 | var salt = UniqueID(); 43 | localStorage.setItem('token', Subsonic.createToken(this.state.password, salt)); 44 | localStorage.setItem('salt', salt); 45 | } 46 | 47 | localStorage.setItem('notifications', this.state.notifications); 48 | localStorage.setItem('backgroundArt', this.state.backgroundArt); 49 | localStorage.setItem('persistQueue', this.state.persistQueue); 50 | localStorage.setItem('repeatQueue', this.state.repeatQueue); 51 | localStorage.setItem('trackBuffer', this.state.trackBuffer); 52 | 53 | Messages.message(this.props.events, "Settings saved.", "success", "Save"); 54 | 55 | // reload app with new settings 56 | var subsonic = new Subsonic( 57 | localStorage.getItem('url'), 58 | localStorage.getItem('username'), 59 | localStorage.getItem('token'), 60 | localStorage.getItem('salt'), 61 | this.props.subsonic.version, 62 | this.props.subsonic.appName 63 | ); 64 | 65 | // publish new settings to negate need to reload the page - App consumes these 66 | this.props.events.publish({event: "appSettings", 67 | data: { 68 | subsonic: subsonic, 69 | trackBuffer: localStorage.getItem('trackBuffer') 70 | } 71 | }); 72 | } 73 | 74 | demo(e) { 75 | e.preventDefault(); 76 | this.demoPrompt.show(function(approve) { 77 | if (!approve) return; 78 | 79 | this.setState({ 80 | url: "http://demo.subsonic.org", 81 | user: "guest5", 82 | password: "guest" 83 | }); 84 | 85 | }.bind(this)); 86 | } 87 | 88 | test(e) { 89 | e.preventDefault(); 90 | 91 | var salt = UniqueID(); 92 | 93 | var subsonic = new Subsonic( 94 | this.state.url, 95 | this.state.user, 96 | Subsonic.createToken(this.state.password, salt), 97 | salt, 98 | this.props.subsonic.version, 99 | this.props.subsonic.appName 100 | ); 101 | 102 | this.setState({testState: TEST_BUSY}); 103 | 104 | subsonic.ping({ 105 | success: function(data) { 106 | if (data.status === "ok") { 107 | this.setState({testState: TEST_SUCCESS}); 108 | Messages.message(this.props.events, "Connection test successful!", "success", "plug"); 109 | } else { 110 | console.log(data.error); 111 | this.setState({testState: TEST_FAILED}); 112 | Messages.message(this.props.events, data.error.message, "error", "plug"); 113 | } 114 | }.bind(this), 115 | error: function(err) { 116 | this.setState({testState: TEST_FAILED}); 117 | Messages.message(this.props.events, "Failed to connect to server: " + err.message, "error", "plug"); 118 | }.bind(this) 119 | }); 120 | } 121 | 122 | change(e) { 123 | switch (e.target.name) { 124 | case "url": this.setState({url: e.target.value}); break; 125 | case "user": this.setState({user: e.target.value}); break; 126 | case "password": this.setState({password: e.target.value}); break; 127 | case "notifications": this.setState({notifications: e.target.checked}); break; 128 | case "backgroundArt": this.setState({backgroundArt: e.target.checked}); break; 129 | case "persistQueue": this.setState({persistQueue: e.target.checked}); break; 130 | case "repeatQueue": this.setState({repeatQueue: e.target.checked}); break; 131 | case "trackBuffer": this.setState({trackBuffer: e.target.value}); break; 132 | } 133 | 134 | this.setState({testState: TEST_UNTESTED}); 135 | } 136 | 137 | render() { 138 | var testIcon = "circle thin"; 139 | switch (this.state.testState) { 140 | case TEST_BUSY: testIcon = "loading spinner"; break; 141 | case TEST_SUCCESS: testIcon = "green checkmark"; break; 142 | case TEST_FAILED: testIcon = "red warning sign"; break; 143 | default: testIcon = "circle thin"; 144 | } 145 | 146 | return ( 147 |
148 |
149 |

150 | Subsonic Connection 151 |

152 |
153 | 154 | 155 |
156 |
157 |
158 | 159 | 160 |
161 |
162 | 163 | 164 |
165 |
166 | 167 |

168 | Preferences 169 |

170 |
171 | 172 | 177 |
178 |
179 |
180 | 181 | 182 |
183 |
184 |
185 |
186 | 187 | 188 |
189 |
190 |
191 |
192 | 193 | 194 |
195 |
196 |
197 |
198 | 199 | 200 |
201 |
202 | 203 |
204 | 205 | 206 | 207 | 211 |
212 | 213 | {this.demoPrompt = r;} } title="Use Demo Server" 214 | message="Reconfigure to use the Subsonic demo server? Please see http://www.subsonic.org/pages/demo.jsp for more information." 215 | ok="Yes" cancel="No" icon="red question" /> 216 |
217 | ); 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/js/jsx/common.js: -------------------------------------------------------------------------------- 1 | import {h, Component} from 'preact'; 2 | import {UniqueID} from '../util' 3 | 4 | export class CoverArt extends Component { 5 | static defaultProps = { 6 | id: 0, 7 | size: 20 8 | } 9 | 10 | constructor(props, context) { 11 | super(props, context); 12 | 13 | this.state = { 14 | error: false 15 | }; 16 | 17 | this.popup = this.popup.bind(this); 18 | } 19 | 20 | componentWillReceiveProps(nextProps) { 21 | this.setState({error: false}); 22 | } 23 | 24 | popup() { 25 | if (this.props.events) this.props.events.publish({ 26 | event: "showImage", 27 | data: this.props.subsonic.getUrl("getCoverArt", {id: this.props.id}) 28 | }); 29 | } 30 | 31 | render() { 32 | var style = {maxHeight: this.props.size + "px", maxWidth: this.props.size + "px"}; 33 | 34 | var src = this.state.error 35 | ? "css/aurial_200.png" 36 | : this.props.subsonic.getUrl("getCoverArt", {id: this.props.id, size: this.props.size}); 37 | 38 | return ( 39 | this.setState({error: true})} /> 41 | ); 42 | } 43 | } 44 | 45 | export class TabGroup extends Component { 46 | static defaultProps = { 47 | tabs: [] 48 | } 49 | 50 | componentDidMount() { 51 | $('.menu .item').tab(); 52 | } 53 | 54 | render() { 55 | var tabs = this.props.tabs.map(function (tab) { 56 | return ( 57 | 58 | ); 59 | }); 60 | 61 | return ( 62 |
63 | {tabs} 64 |
65 | ); 66 | } 67 | } 68 | 69 | class Tab extends Component { 70 | static defaultProps = { 71 | icon: null, 72 | active: false 73 | } 74 | 75 | render() { 76 | var icon = this.props.icon != null ? : null; 77 | return ( 78 | 79 | {icon} 80 | {this.props.title} 81 | 82 | ); 83 | } 84 | } 85 | 86 | export class IconMessage extends Component { 87 | static defaultProps = { 88 | icon: "info circle", 89 | type: "info" 90 | } 91 | 92 | render() { 93 | return ( 94 |
95 |
96 | 97 |
98 |
{this.props.header}
99 |

{this.props.message}

100 |
101 |
102 |
103 | ); 104 | } 105 | } 106 | 107 | export class Prompt extends Component { 108 | _id = UniqueID(); 109 | 110 | static defaultProps = { 111 | title: "Question", 112 | message: "Are you sure?", 113 | ok: "OK", 114 | cancel: "Cancel", 115 | icon: "grey help circle" 116 | } 117 | 118 | constructor(props, context) { 119 | super(props, context); 120 | 121 | this.show = this.show.bind(this); 122 | } 123 | 124 | componentDidMount() { 125 | $('#' + this._id).modal({ 126 | onApprove: function() { 127 | this.state.result(true); 128 | }.bind(this), 129 | onDeny: function() { 130 | this.state.result(false); 131 | }.bind(this) 132 | }); 133 | } 134 | 135 | show(result) { 136 | this.setState({result: result}); 137 | 138 | $('#' + this._id).modal('show'); 139 | } 140 | 141 | render() { 142 | return ( 143 |
144 |
145 | {this.props.title} 146 |
147 |
148 |
149 | 150 |
151 |
152 | {this.props.message} 153 |
154 |
155 |
156 |
{this.props.cancel}
157 |
{this.props.ok}
158 |
159 |
160 | ); 161 | } 162 | } 163 | 164 | export class InputPrompt extends Component { 165 | _id = UniqueID(); 166 | 167 | static defaultProps = { 168 | title: "Prompt", 169 | message: "Please provide a value", 170 | ok: "OK", 171 | cancel: "Cancel", 172 | icon: "grey edit" 173 | } 174 | 175 | constructor(props, context) { 176 | super(props, context); 177 | 178 | this.state = { 179 | value: "" 180 | }; 181 | 182 | this.show = this.show.bind(this); 183 | this.change = this.change.bind(this); 184 | } 185 | 186 | componentDidMount() { 187 | $('#' + this._id).modal({ 188 | onApprove: function() { 189 | this.state.result(true, this.state.value); 190 | }.bind(this), 191 | onDeny: function() { 192 | this.state.result(false, this.state.value); 193 | }.bind(this), 194 | }); 195 | } 196 | 197 | show(value, result) { 198 | this.setState({value: value, result: result}); 199 | $('#' + this._id).modal('show'); 200 | } 201 | 202 | change(e) { 203 | switch (e.target.name) { 204 | case "value": this.setState({value: e.target.value}); break; 205 | } 206 | } 207 | 208 | render() { 209 | return ( 210 |
211 |
212 | {this.props.title} 213 |
214 |
215 |
216 | 217 |
218 |
219 |
220 |
221 | 222 | 223 |
224 |
225 |
226 |
227 |
228 |
{this.props.cancel}
229 |
{this.props.ok}
230 |
231 |
232 | ); 233 | } 234 | } 235 | 236 | export class ListPrompt extends Component { 237 | _id = UniqueID(); 238 | 239 | static defaultProps = { 240 | title: "Prompt", 241 | message: "Please select an option", 242 | defaultText: "Select an option...", 243 | ok: "OK", 244 | cancel: "Cancel", 245 | icon: "grey list", 246 | items: [], 247 | value: null, 248 | allowNew: false, 249 | approve: function() { }, 250 | deny: function() { } 251 | } 252 | 253 | constructor(props, context) { 254 | super(props, context); 255 | 256 | this.state = { 257 | value: props.value 258 | } 259 | 260 | this.show = this.show.bind(this); 261 | } 262 | 263 | componentDidMount() { 264 | $('#' + this._id).modal({ 265 | onApprove: function() { 266 | this.state.approve(true, this.state.value); 267 | }.bind(this), 268 | onDeny: function() { 269 | this.state.approve(false, this.state.value); 270 | }.bind(this) 271 | }); 272 | } 273 | 274 | show(approve) { 275 | this.setState({value: this.props.value, approve: approve}); 276 | var dropdown = $('#' + this._id + ' .dropdown'); 277 | 278 | dropdown.dropdown({ 279 | action: 'activate', 280 | allowAdditions: this.props.allowNew, 281 | onChange: function(value, text, selectedItem) { 282 | this.setState({value: value}); 283 | }.bind(this) 284 | }); 285 | 286 | dropdown.dropdown('clear'); 287 | 288 | $('#' + this._id).modal('show'); 289 | } 290 | 291 | render() { 292 | return ( 293 |
294 |
295 | {this.props.title} 296 |
297 |
298 |
299 | 300 |
301 |
302 |
{this.props.message}
303 |
304 |
305 | 306 |
{this.props.defaultText}
307 |
308 | {this.props.items} 309 |
310 |
311 |
312 |
313 |
314 |
315 |
{this.props.cancel}
316 |
{this.props.ok}
317 |
318 |
319 | ); 320 | } 321 | } 322 | 323 | export class ImageViewer extends Component { 324 | _id = UniqueID(); 325 | 326 | static defaultProps = { 327 | title: "View", 328 | ok: "OK" 329 | } 330 | 331 | constructor(props, context) { 332 | super(props, context); 333 | 334 | this.state = { 335 | iamge: "" 336 | }; 337 | 338 | props.events.subscribe({ 339 | subscriber: this, 340 | event: ["showImage"] 341 | }); 342 | 343 | this.show = this.show.bind(this); 344 | } 345 | 346 | receive(event) { 347 | if (event.event === "showImage") { 348 | this.setState({image: event.data}); 349 | this.show(); 350 | } 351 | } 352 | 353 | componentDidMount() { 354 | $('#' + this._id).modal(); 355 | } 356 | 357 | show() { 358 | $('#' + this._id).modal('show'); 359 | } 360 | 361 | render() { 362 | var center = { 363 | textAlign: "center", 364 | maxHeight: "700px" 365 | }; 366 | return ( 367 |
368 |
369 | {this.props.title} 370 |
371 |
372 | 373 |
374 |
375 |
{this.props.ok}
376 |
377 |
378 | ); 379 | } 380 | } 381 | -------------------------------------------------------------------------------- /src/js/jsx/playlist.js: -------------------------------------------------------------------------------- 1 | import {h, Component} from 'preact'; 2 | import moment from 'moment' 3 | import {IconMessage,CoverArt,Prompt,InputPrompt,ListPrompt} from './common' 4 | import TrackList from './tracklist' 5 | import {SecondsToTime,UniqueID} from '../util' 6 | import {Messages} from './app' 7 | 8 | export default class PlaylistManager extends Component { 9 | 10 | state = { 11 | playlists: [], 12 | playlist: null 13 | } 14 | 15 | constructor(props, context) { 16 | super(props, context); 17 | 18 | this.loadPlaylists = this.loadPlaylists.bind(this); 19 | this.loadPlaylist = this.loadPlaylist.bind(this); 20 | this.createPlaylist = this.createPlaylist.bind(this); 21 | this.updatePlaylist = this.updatePlaylist.bind(this); 22 | this.receive = this.receive.bind(this); 23 | 24 | this.loadPlaylists(); 25 | 26 | props.events.subscribe({ 27 | subscriber: this, 28 | event: ["playlistManage"] 29 | }); 30 | } 31 | 32 | 33 | componentDidUpdate(prevProps, prevState) { 34 | if (prevProps.subsonic !== this.props.subsonic) this.loadPlaylists(); 35 | } 36 | 37 | receive(event) { 38 | if (event.event === "playlistManage") { 39 | if (event.data.action === "ADD") { 40 | this.lister.show(function(approved, playlist) { 41 | if (!approved) return; 42 | 43 | var tracks = event.data.tracks.map(t => t.id); 44 | 45 | var currentPlaylist = this.state.playlists.find(p => p.id === playlist) 46 | 47 | if (currentPlaylist === undefined) { 48 | this.createPlaylist(playlist, tracks); 49 | } else { 50 | this.updatePlaylist(playlist, tracks, []); 51 | } 52 | }.bind(this)); 53 | } else if (event.data.action === "CREATE") { 54 | this.creator.show("", function(approved, newName) { 55 | if (!approved) return; 56 | 57 | this.createPlaylist(newName, []); 58 | }.bind(this)); 59 | } else if (event.data.action === "DELETE") { 60 | this.deleter.show(function(approved) { 61 | if (!approved) return; 62 | 63 | this.props.subsonic.deletePlaylist({ 64 | id: event.data.id, 65 | success: function() { 66 | this.loadPlaylists(); 67 | Messages.message(this.props.events, "Playlist deleted", "warning", "trash"); 68 | }.bind(this) 69 | }); 70 | }.bind(this)); 71 | } else if (event.data.action === "RENAME") { 72 | this.renamer.show(event.data.name, function(approved, newName) { 73 | if (!approved) return; 74 | 75 | this.props.subsonic.updatePlaylist({ 76 | id: event.data.id, 77 | name: newName, 78 | success: function() { 79 | this.loadPlaylists(); 80 | Messages.message(this.props.events, "Playlist renamed", "success", "edit"); 81 | }.bind(this) 82 | }); 83 | }.bind(this)); 84 | } else if (event.data.action === "REMOVE") { 85 | // load up the playlist, since we can only remove tracks by their index within a playlist 86 | this.props.subsonic.getPlaylist({ 87 | id: event.data.id, 88 | success: function(data) { 89 | var tracks = event.data.tracks.map(function(t) { 90 | for (var i = 0; i < data.playlist.entry.length; i++) { 91 | if (t.id === data.playlist.entry[i].id) return i; 92 | } 93 | }); 94 | 95 | this.updatePlaylist(event.data.id, [], tracks); 96 | }.bind(this), 97 | error: function(err) { 98 | console.error(this, err); 99 | Messages.message(this.props.events, "Unable to load playlist: " + err.message, "error", "warning sign"); 100 | }.bind(this) 101 | }); 102 | } 103 | } 104 | } 105 | 106 | createPlaylist(name, trackIds) { 107 | this.props.subsonic.createPlaylist({ 108 | name: name, 109 | tracks: trackIds, 110 | success: function() { 111 | Messages.message(this.props.events, "New playlist " + name + " created", "success", "checkmark"); 112 | this.loadPlaylists(); 113 | }.bind(this), 114 | error: function(err) { 115 | console.error(this, err); 116 | Messages.message(this.props.events, "Failed to create playlist: " + err.message, "error", "warning sign"); 117 | }.bind(this) 118 | }); 119 | } 120 | 121 | updatePlaylist(id, add, remove) { 122 | this.props.subsonic.updatePlaylist({ 123 | id: id, 124 | add: add, 125 | remove: remove, 126 | success: function() { 127 | Messages.message(this.props.events, "Playlist updated", "success", "checkmark"); 128 | this.loadPlaylists(); 129 | if (this.state.playlist !== null && id === this.state.playlist.id) this.loadPlaylist(id); 130 | }.bind(this), 131 | error: function(err) { 132 | console.error(this, err); 133 | Messages.message(this.props.events, "Failed to update playlist: " + err.message, "error", "warning sign"); 134 | }.bind(this) 135 | }); 136 | } 137 | 138 | loadPlaylists() { 139 | this.props.subsonic.getPlaylists({ 140 | success: function(data) { 141 | this.setState({playlists: data.playlists}); 142 | if (this.state.playlist != null) { 143 | this.loadPlaylist(this.state.playlist.id); 144 | } 145 | }.bind(this), 146 | error: function(err) { 147 | console.error(this, err); 148 | Messages.message(this.props.events, "Unable to get playlists: " + err.message, "error", "warning sign"); 149 | }.bind(this) 150 | }); 151 | } 152 | 153 | loadPlaylist(id) { 154 | this.props.subsonic.getPlaylist({ 155 | id: id, 156 | success: function(data) { 157 | this.setState({playlist: data.playlist}); 158 | }.bind(this), 159 | error: function(err) { 160 | console.error(this, err); 161 | Messages.message(this.props.events, "Unable to load playlist: " + err.message, "error", "warning sign"); 162 | }.bind(this) 163 | }); 164 | } 165 | 166 | render() { 167 | var playlists = []; 168 | if (this.state.playlists) { 169 | playlists = this.state.playlists.map(function (playlist) { 170 | return ( 171 | 172 | ); 173 | }.bind(this)); 174 | } 175 | 176 | return ( 177 |
178 | {this.creator = r;}} title="Create Playlist" message="Enter a name for the new playlist" /> 179 | {this.renamer = r;}} title="Rename Playlist" message="Enter a new name for this playlist" /> 180 | {this.deleter = r;}} title="Delete Playlist" message="Are you sure you want to delete this playlist?" ok="Yes" icon="red trash" /> 181 | {this.lister = r;}} title="Add to playlist" message="Choose a playlist to add tracks to" ok="Add" icon="teal list" 182 | defaultText="Playlists..." allowNew={true} items={playlists} /> 183 | 184 | 185 | 186 |
187 | ); 188 | } 189 | } 190 | 191 | class PlaylistSelector extends Component { 192 | 193 | defaultProps = { 194 | playlists: [] 195 | } 196 | 197 | constructor(props, context) { 198 | super(props, context); 199 | 200 | this.value = null; 201 | 202 | this.create = this.create.bind(this); 203 | } 204 | 205 | componentDidMount() { 206 | $('.playlistSelector .dropdown').dropdown({ 207 | action: 'activate', 208 | onChange: function(value, text, selectedItem) { 209 | if (this.value !== value) { 210 | if (this.props.selected) this.props.selected(value); 211 | this.value = value; 212 | } 213 | }.bind(this) 214 | }); 215 | } 216 | 217 | componentDidUpdate(prevProps, prevState) { 218 | if (this.value) $('.playlistSelector .dropdown').dropdown('set selected', this.value); 219 | } 220 | 221 | create() { 222 | this.props.events.publish({event: "playlistManage", data: {action: "CREATE"}}); 223 | } 224 | 225 | render() { 226 | var playlists = []; 227 | if (this.props.playlists) { 228 | playlists = this.props.playlists.map(function (playlist) { 229 | return ( 230 | 231 | ); 232 | }.bind(this)); 233 | } 234 | 235 | return ( 236 |
237 |
238 |
239 |
240 | 241 |
Playlists...
242 |
243 | {playlists} 244 |
245 |
246 |
247 |
248 | 249 |
250 |
251 |
252 | ); 253 | } 254 | } 255 | 256 | class PlaylistSelectorItem extends Component { 257 | render() { 258 | var description = !this.props.simple 259 | ? {this.props.data.songCount} tracks, {SecondsToTime(this.props.data.duration)} 260 | : null; 261 | 262 | return ( 263 |
264 | 265 | {description} 266 | {this.props.data.name} 267 |
268 | ); 269 | } 270 | } 271 | 272 | class Playlist extends Component { 273 | 274 | defaultProps = { 275 | playlist: null 276 | } 277 | 278 | constructor(props, context) { 279 | super(props, context); 280 | } 281 | 282 | render() { 283 | if (!this.props.playlist) { 284 | return ( 285 |
286 | 287 |
288 | ); 289 | } else { 290 | return ( 291 |
292 | 293 | 295 |
296 | ); 297 | } 298 | } 299 | } 300 | 301 | class PlaylistInfo extends Component { 302 | 303 | constructor(props, context) { 304 | super(props, context); 305 | 306 | this.play = this.play.bind(this); 307 | this.enqueue = this.enqueue.bind(this); 308 | this.delete = this.delete.bind(this); 309 | this.rename = this.rename.bind(this); 310 | } 311 | 312 | play() { 313 | this.props.events.publish({event: "playerEnqueue", data: {action: "REPLACE", tracks: this.props.playlist.entry}}); 314 | this.props.events.publish({event: "playerPlay", data: this.props.playlist.entry[0]}); 315 | } 316 | 317 | enqueue() { 318 | this.props.events.publish({event: "playerEnqueue", data: {action: "ADD", tracks: this.props.playlist.entry}}); 319 | } 320 | 321 | delete() { 322 | this.props.events.publish({event: "playlistManage", data: {action: "DELETE", id: this.props.playlist.id}}); 323 | } 324 | 325 | rename() { 326 | this.props.events.publish({event: "playlistManage", data: {action: "RENAME", id: this.props.playlist.id, name: this.props.playlist.name}}); 327 | } 328 | 329 | render() { 330 | return ( 331 |
332 |
333 |
334 | 335 |
336 |
337 |
338 |
{this.props.playlist.name}
339 |
340 |
341 |
Added: {moment(this.props.playlist.created).format("ll")}
342 |
Updated: {moment(this.props.playlist.changed).format("ll")}
343 |
{this.props.playlist.songCount} tracks, {SecondsToTime(this.props.playlist.duration)}
344 |
345 |
346 | 347 | 348 | 349 | 350 |
351 |
352 |
353 |
354 | ); 355 | } 356 | } 357 | -------------------------------------------------------------------------------- /src/js/jsx/player.js: -------------------------------------------------------------------------------- 1 | import {h, Component} from 'preact'; 2 | import AudioPlayer from '../audioplayer' 3 | import {SecondsToTime, ArrayShuffle} from '../util' 4 | import {CoverArt} from './common' 5 | import {Messages} from './app' 6 | 7 | export default class Player extends Component { 8 | noImage = 'css/aurial_200.png'; 9 | 10 | static defaultProps = { 11 | trackBuffer: false 12 | } 13 | 14 | playerQueue = []; 15 | buffered = false; 16 | player = null; 17 | queue = []; // the queue we use internally for jumping between tracks, shuffling, etc 18 | 19 | state = { 20 | queue: [], // the input queue 21 | shuffle: false, 22 | playing: null, 23 | volume: 1.0 24 | } 25 | 26 | constructor(props, context) { 27 | super(props, context); 28 | props.events.subscribe({ 29 | subscriber: this, 30 | event: ["playerPlay", "playerToggle", "playerStop", "playerNext", "playerPrevious", "playerEnqueue", "playerShuffle", "playerVolume"] 31 | }); 32 | 33 | if (props.persist === true) { 34 | // this is (badly) delayed to allow it a chance to set up, and stuff. this is a bad idea 35 | setTimeout(function() { 36 | props.events.publish({event: "playerEnqueue", data: {action: "ADD", tracks: JSON.parse(localStorage.getItem('queue'))}}); 37 | }, 500); 38 | } 39 | } 40 | 41 | componentWillUpdate(nextProps, nextState) { 42 | if (this.queueDiff(this.queue, nextState.queue) || this.state.shuffle !== nextState.shuffle) { 43 | this.queue = (this.state.shuffle || nextState.shuffle) ? ArrayShuffle(nextState.queue.slice()) : nextState.queue.slice(); 44 | } 45 | } 46 | 47 | receive(event) { 48 | switch (event.event) { 49 | case "playerPlay": this.play({track: event.data}); break; 50 | case "playerToggle": this.togglePlay(); break; 51 | case "playerStop": this.stop(); break; 52 | case "playerNext": this.next(); break; 53 | case "playerPrevious": this.previous(); break; 54 | case "playerEnqueue": this.enqueue(event.data.action, event.data.tracks); break; 55 | case "playerShuffle": this.setState({shuffle: event.data}); break; 56 | case "playerVolume": this.volume(event.data); break; 57 | } 58 | } 59 | 60 | createPlayer(track) { 61 | var events = this.props.events; 62 | 63 | var streamUrl = this.props.subsonic.getStreamUrl({id: track.id}); 64 | 65 | return new AudioPlayer({ 66 | url: streamUrl, 67 | volume: this.state.volume, 68 | onPlay: function() { 69 | events.publish({event: "playerStarted", data: track}); 70 | }, 71 | onResume: function() { 72 | events.publish({event: "playerStarted", data: track}); 73 | }, 74 | onStop: function() { 75 | events.publish({event: "playerStopped", data: track}); 76 | }, 77 | onPause: function() { 78 | events.publish({event: "playerPaused", data: track}); 79 | }, 80 | onProgress: function(position, duration) { 81 | events.publish({event: "playerUpdated", data: {track: track, duration: duration, position: position}}); 82 | 83 | // at X seconds remaining in the current track, allow the client to begin buffering the next stream 84 | if (!this.buffered && this.props.trackBuffer > 0 && duration - position < (this.props.trackBuffer * 1000)) { 85 | var next = this.nextTrack(); 86 | console.log("Prepare next track", next); 87 | if (next !== null) { 88 | this.buffered = true; 89 | this.playerQueue.push({ 90 | track: next, 91 | player: this.createPlayer(next) 92 | }); 93 | } else { 94 | console.log("There is no next track"); 95 | } 96 | } 97 | 98 | }.bind(this), 99 | onLoading: function(loaded, total) { 100 | events.publish({event: "playerLoading", data: {track: track, loaded: loaded, total: total}}); 101 | }, 102 | onComplete: function() { 103 | events.publish({event: "playerFinished", data: track}); 104 | this.next(); 105 | }.bind(this) 106 | }); 107 | } 108 | 109 | play(playItem) { 110 | this.buffered = false; 111 | this.stop(); 112 | 113 | if (playItem != null) { 114 | this.player = playItem.player ? playItem.player.play() : this.createPlayer(playItem.track).play(); 115 | this.setState({playing: playItem.track}); 116 | } else { 117 | this.setState({playing: null}); 118 | } 119 | } 120 | 121 | next() { 122 | var next = this.playerQueue.shift(); 123 | 124 | if (next == null) { 125 | var track = this.nextTrack(); 126 | if (track != null) { 127 | next = { 128 | track: track 129 | }; 130 | } 131 | } 132 | 133 | this.play(next); 134 | } 135 | 136 | previous() { 137 | var prev = null; 138 | var track = this.previousTrack(); 139 | if (track != null) { 140 | prev = { 141 | track: track 142 | } 143 | } 144 | 145 | if (this.player != null) this.player.unload(); 146 | this.play(prev); 147 | } 148 | 149 | nextTrack() { 150 | var next = null; 151 | if (this.queue.length > 0) { 152 | var idx = this.state.playing == null ? 0 : Math.max(0, this.queue.indexOf(this.state.playing)); 153 | 154 | if (idx < this.queue.length - 1) { 155 | idx++; 156 | } else { 157 | // it's the end of the queue, user may choose to not repeat, in which case return no next track 158 | if (this.state.playing != null && localStorage.getItem('repeatQueue') === 'false') return null 159 | else idx = 0; 160 | } 161 | 162 | next = this.queue[idx]; 163 | } 164 | 165 | return next; 166 | } 167 | 168 | previousTrack() { 169 | var previous = null; 170 | if (this.queue.length > 0) { 171 | var idx = this.state.playing == null ? 0 : Math.max(0, this.queue.indexOf(this.state.playing)); 172 | 173 | if (idx > 0) idx--; 174 | else idx = this.queue.length - 1; 175 | 176 | previous = this.queue[idx]; 177 | } 178 | 179 | return previous; 180 | } 181 | 182 | togglePlay() { 183 | if (this.player != null) { 184 | this.player.togglePause(); 185 | } else if (this.state.playing != null) { 186 | this.play({track: this.state.playing}); 187 | } else if (this.queue.length > 0) { 188 | this.next(); 189 | } 190 | } 191 | 192 | stop() { 193 | if (this.player != null) { 194 | this.player.stop(); 195 | this.player.unload(); 196 | } 197 | this.player = null; 198 | } 199 | 200 | volume(volume) { 201 | if (this.player != null) this.player.volume(volume); 202 | 203 | this.setState({volume: volume}); 204 | } 205 | 206 | enqueue(action, tracks) { 207 | var queue = this.state.queue.slice(); 208 | 209 | if (action === "REPLACE") { 210 | queue = tracks.slice(); 211 | Messages.message(this.props.events, "Added " + tracks.length + " tracks to queue.", "info", "info"); 212 | } else if (action === "ADD") { 213 | var trackIds = queue.map(function(t) { 214 | return t.id; 215 | }); 216 | 217 | var added = 0; 218 | var removed = 0; 219 | for (var i = 0; i < tracks.length; i++) { 220 | var idx = trackIds.indexOf(tracks[i].id); 221 | if (idx === -1) { 222 | queue.push(tracks[i]); 223 | trackIds.push(tracks[i].id); 224 | added ++; 225 | } else { 226 | queue.splice(idx, 1); 227 | trackIds.splice(idx, 1); 228 | removed ++; 229 | } 230 | } 231 | 232 | if (tracks.length === 1) { 233 | var trackTitle = tracks[0].artist + " - " + tracks[0].title; 234 | Messages.message(this.props.events, (added ? "Added " + trackTitle + " to queue. " : "") + (removed ? "Removed " + trackTitle + " from queue." : ""), "info", "info"); 235 | } else if (added || removed) { 236 | Messages.message(this.props.events, (added ? "Added " + added + " tracks to queue. " : "") + (removed ? "Removed " + removed + " tracks from queue." : ""), "info", "info"); 237 | } 238 | } 239 | 240 | this.setState({queue: queue}); 241 | 242 | this.props.events.publish({event: "playerEnqueued", data: queue}); 243 | 244 | if (this.props.persist) { 245 | localStorage.setItem('queue', JSON.stringify(queue)); 246 | } 247 | } 248 | 249 | queueDiff(q1, q2) { 250 | if (q1.length !== q2.length) return true; 251 | 252 | var diff = true; 253 | 254 | q1.forEach(function(t1) { 255 | var found = false; 256 | for (const t2 of q2) { 257 | if (t1.id === t2.id) { 258 | found = true; 259 | break; 260 | } 261 | } 262 | diff = diff && !found; 263 | }); 264 | 265 | return diff; 266 | } 267 | 268 | render() { 269 | var nowPlaying = "Nothing playing"; 270 | var coverArt = ; 271 | 272 | if (this.state.playing != null) { 273 | coverArt = ; 274 | } 275 | 276 | return ( 277 |
278 |
279 |
280 |
281 | {coverArt} 282 |
283 |
284 |
285 | 286 |
287 |
288 | 289 |
290 |
291 | 292 | 293 | 303 | 306 | 309 | 310 |
294 |
295 | 296 | 297 | 298 | 299 | 300 | 301 |
302 |
304 | 305 | 307 | 308 |
311 |
312 |
313 |
314 |
315 |
316 | ); 317 | } 318 | } 319 | 320 | class PlayerPlayingTitle extends Component { 321 | render() { 322 | return ( 323 | 324 | {this.props.playing == null ? "Nothing playing" : this.props.playing.title} 325 | 326 | ); 327 | } 328 | } 329 | 330 | class PlayerPlayingInfo extends Component { 331 | render() { 332 | var album = "Nothing playing"; 333 | if (this.props.playing != null) { 334 | album = this.props.playing.artist + " - " + this.props.playing.album; 335 | if (this.props.playing.date) album += " (" + this.props.playing.date + ")"; 336 | } 337 | 338 | return ( 339 | 340 | {album} 341 | 342 | ); 343 | } 344 | } 345 | 346 | class PlayerPositionDisplay extends Component { 347 | state = { 348 | duration: 0, 349 | position: 0 350 | } 351 | 352 | constructor(props, context) { 353 | super(props, context); 354 | props.events.subscribe({ 355 | subscriber: this, 356 | event: ["playerUpdated"] 357 | }); 358 | } 359 | 360 | componentWillUnmount() { 361 | } 362 | 363 | receive(event) { 364 | switch (event.event) { 365 | case "playerUpdated": this.setState({duration: event.data.duration, position: event.data.position}); break; 366 | } 367 | } 368 | 369 | render() { 370 | return ( 371 |
372 | 373 | {SecondsToTime(this.state.position / 1000)}/{SecondsToTime(this.state.duration / 1000)} 374 |
375 | ); 376 | } 377 | } 378 | 379 | class PlayerProgress extends Component { 380 | state = { 381 | playerProgress: 0, 382 | loadingProgress: 0 383 | } 384 | 385 | constructor(props, context) { 386 | super(props, context); 387 | props.events.subscribe({ 388 | subscriber: this, 389 | event: ["playerUpdated", "playerLoading", "playerStopped"] 390 | }); 391 | } 392 | 393 | componentWillUnmount() { 394 | } 395 | 396 | receive(event) { 397 | switch (event.event) { 398 | case "playerUpdated": this.playerUpdate(event.data.track, event.data.duration, event.data.position); break; 399 | case "playerLoading": this.playerLoading(event.data.track, event.data.loaded, event.data.total); break; 400 | case "playerStopped": this.playerUpdate(event.data.track, 1, 0); break; 401 | } 402 | } 403 | 404 | playerUpdate(playing, length, position) { 405 | var percent = (position / length) * 100; 406 | this.setState({playerProgress: percent}); 407 | } 408 | 409 | playerLoading(playing, loaded, total) { 410 | var percent = (loaded / total) * 100; 411 | this.setState({loadingProgress: percent}); 412 | } 413 | 414 | render() { 415 | var playerProgress = {width: this.state.playerProgress + "%"}; 416 | var loadingProgress = {width: this.state.loadingProgress + "%"}; 417 | return ( 418 |
419 |
420 | 421 |
422 |
423 |
424 |
425 | ); 426 | } 427 | } 428 | 429 | 430 | class PlayerVolume extends Component { 431 | 432 | constructor(props, context) { 433 | super(props, context); 434 | 435 | this.mouseDown = this.mouseDown.bind(this); 436 | this.mouseUp = this.mouseUp.bind(this); 437 | this.mouseMove = this.mouseMove.bind(this); 438 | } 439 | 440 | componentWillUnmount() { 441 | } 442 | 443 | mouseDown(event) { 444 | this.drag = true; 445 | this.mouseMove(event); 446 | } 447 | 448 | mouseUp(event) { 449 | this.drag = false; 450 | } 451 | 452 | mouseMove(event) { 453 | if (this.drag) { 454 | var rect = document.querySelector(".player-volume").getBoundingClientRect(); 455 | var volume = Math.min(1.0, Math.max(0.0, (event.clientX - rect.left) / rect.width)); 456 | 457 | this.props.events.publish({event: "playerVolume", data: volume}); 458 | } 459 | } 460 | 461 | render() { 462 | var playerVolume = {width: (this.props.volume*100) + "%"}; 463 | return ( 464 |
465 |
466 | 467 |
468 |
469 |
470 | ); 471 | } 472 | } 473 | 474 | class PlayerPlayToggleButton extends Component { 475 | state = { 476 | paused: false, 477 | playing: false, 478 | enabled: false 479 | } 480 | 481 | constructor(props, context) { 482 | super(props, context); 483 | 484 | this.onClick = this.onClick.bind(this); 485 | 486 | props.events.subscribe({ 487 | subscriber: this, 488 | event: ["playerStarted", "playerStopped", "playerFinished", "playerPaused", "playerEnqueued"] 489 | }); 490 | } 491 | 492 | componentWillUnmount() { 493 | } 494 | 495 | receive(event) { 496 | switch (event.event) { 497 | case "playerStarted": this.playerStart(event.data); break; 498 | case "playerStopped": 499 | case "playerFinished": this.playerFinish(event.data); break; 500 | case "playerPaused": this.playerPause(event.data); break; 501 | case "playerEnqueued": this.playerEnqueue(event.data); break; 502 | } 503 | } 504 | 505 | playerStart(playing) { 506 | this.setState({paused: false, playing: true, enabled: true}); 507 | } 508 | 509 | playerFinish(playing) { 510 | this.setState({paused: false, playing: false}); 511 | } 512 | 513 | playerPause(playing) { 514 | this.setState({paused: true}); 515 | } 516 | 517 | playerEnqueue(queue) { 518 | this.setState({enabled: queue.length > 0}); 519 | } 520 | 521 | onClick() { 522 | this.props.events.publish({event: "playerToggle"}); 523 | } 524 | 525 | render() { 526 | return ( 527 | 530 | ); 531 | } 532 | } 533 | 534 | class PlayerStopButton extends Component { 535 | state = { 536 | enabled: false 537 | } 538 | 539 | constructor(props, context) { 540 | super(props, context); 541 | 542 | this.onClick = this.onClick.bind(this); 543 | 544 | props.events.subscribe({ 545 | subscriber: this, 546 | event: ["playerStarted", "playerStopped", "playerFinished"] 547 | }); 548 | } 549 | 550 | componentWillUnmount() { 551 | } 552 | 553 | receive(event) { 554 | switch (event.event) { 555 | case "playerStarted": this.playerStart(event.data); break; 556 | case "playerStopped": 557 | case "playerFinished": this.playerFinish(event.data); break; 558 | } 559 | } 560 | 561 | playerStart(playing) { 562 | this.setState({enabled: true}); 563 | } 564 | 565 | playerFinish(playing) { 566 | this.setState({enabled: false}); 567 | } 568 | 569 | onClick() { 570 | this.props.events.publish({event: "playerStop"}); 571 | } 572 | 573 | render() { 574 | return ( 575 | 578 | ); 579 | } 580 | } 581 | 582 | class PlayerNextButton extends Component { 583 | state = { 584 | enabled: false 585 | } 586 | 587 | constructor(props, context) { 588 | super(props, context); 589 | 590 | this.onClick = this.onClick.bind(this); 591 | 592 | props.events.subscribe({ 593 | subscriber: this, 594 | event: ["playerEnqueued"] 595 | }); 596 | } 597 | 598 | componentWillUnmount() { 599 | } 600 | 601 | receive(event) { 602 | switch (event.event) { 603 | case "playerEnqueued": this.setState({enabled: event.data.length > 0}); break; 604 | } 605 | } 606 | 607 | onClick() { 608 | this.props.events.publish({event: "playerNext"}); 609 | } 610 | 611 | render() { 612 | return ( 613 | 616 | ); 617 | } 618 | } 619 | 620 | class PlayerPriorButton extends Component { 621 | state = { 622 | enabled: false 623 | } 624 | 625 | constructor(props, context) { 626 | super(props, context); 627 | 628 | this.onClick = this.onClick.bind(this); 629 | 630 | props.events.subscribe({ 631 | subscriber: this, 632 | event: ["playerEnqueued"] 633 | }); 634 | } 635 | 636 | componentWillUnmount() { 637 | } 638 | 639 | receive(event) { 640 | switch (event.event) { 641 | case "playerEnqueued": this.setState({enabled: event.data.length > 0}); break; 642 | } 643 | } 644 | 645 | onClick() { 646 | this.props.events.publish({event: "playerPrevious"}); 647 | } 648 | 649 | render() { 650 | return ( 651 | 654 | ); 655 | } 656 | } 657 | 658 | class PlayerShuffleButton extends Component { 659 | state = { 660 | shuffle: false 661 | } 662 | 663 | constructor(props, context) { 664 | super(props, context); 665 | 666 | this.onClick = this.onClick.bind(this); 667 | } 668 | 669 | onClick() { 670 | var shuffle = !this.state.shuffle; 671 | this.setState({shuffle: shuffle}); 672 | this.props.events.publish({event: "playerShuffle", data: shuffle}); 673 | } 674 | 675 | render() { 676 | return ( 677 | 680 | ); 681 | } 682 | } 683 | --------------------------------------------------------------------------------