-1} playlist={this.props.playlist}
36 | iconSize={this.props.iconSize} />
37 | );
38 | }.bind(this));
39 | }
40 |
41 | return (
42 |
43 |
44 |
45 |
46 | #
47 | Artist
48 | Title
49 | Album
50 | Date
51 | Duration
52 |
53 |
54 |
55 | {tracks}
56 |
57 |
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 |
94 |
95 |
96 | );
97 | } else {
98 | playlistButton = (
99 |
100 |
101 |
102 | );
103 | }
104 |
105 | return (
106 |
107 |
108 |
109 |
110 |
111 |
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 |
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 |
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 |
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 | New Playlist
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 |
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 | Play
347 | Add to Queue
348 | Rename
349 | Delete
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 |
287 |
290 |
291 |
292 |
293 |
294 |
295 |
296 |
297 |
298 |
299 |
300 |
301 |
302 |
303 |
304 |
305 |
306 |
307 |
308 |
309 |
310 |
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 |
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 |
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 |
528 |
529 |
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 |
576 |
577 |
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 |
614 |
615 |
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 |
652 |
653 |
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 |
678 |
679 |
680 | );
681 | }
682 | }
683 |
--------------------------------------------------------------------------------