├── .editorconfig ├── .ember-cli ├── .eslintignore ├── .eslintrc.js ├── .github └── workflows │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .template-lintrc.js ├── .watchmanconfig ├── README.md ├── app ├── app.js ├── components │ ├── album-image.hbs │ ├── album-image.js │ ├── configure-client.hbs │ ├── configure-client.js │ ├── nav.hbs │ ├── nav.js │ ├── nav │ │ ├── controls.hbs │ │ ├── controls.js │ │ ├── now-playing.hbs │ │ ├── now-playing.js │ │ ├── volume.hbs │ │ └── volume.js │ ├── queue │ │ ├── add-stream.hbs │ │ ├── add-stream.js │ │ ├── entry-detail.hbs │ │ ├── entry-detail.js │ │ ├── list-entries.hbs │ │ ├── list-entries.js │ │ ├── modes.hbs │ │ ├── modes.js │ │ ├── remove-tracks.hbs │ │ └── remove-tracks.js │ ├── search.hbs │ ├── search.js │ ├── show-entry.hbs │ └── show-entry.js ├── controllers │ └── search.js ├── helpers │ ├── artist-names.js │ ├── eq.js │ └── track-lenght.js ├── index.html ├── models │ └── .gitkeep ├── router.js ├── routes │ ├── configuration.js │ ├── configured.js │ ├── entries.js │ ├── index.js │ ├── queue.js │ └── search.js ├── services │ ├── mopidy-client.js │ └── player.js ├── styles │ └── app.css └── templates │ ├── application.hbs │ ├── configuration.hbs │ ├── entries.hbs │ ├── queue.hbs │ └── search.hbs ├── config ├── ember-cli-update.json ├── environment.js ├── optional-features.json └── targets.js ├── ember-cli-build.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico └── robots.txt ├── testem.js ├── tests ├── helpers │ └── .gitkeep ├── index.html ├── integration │ └── .gitkeep ├── test-helper.js └── unit │ ├── .gitkeep │ ├── routes │ └── queue-test.js │ └── services │ └── mopidy-client-test.js └── vendor └── .gitkeep /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 2 14 | 15 | [*.hbs] 16 | insert_final_newline = false 17 | 18 | [*.{diff,md}] 19 | trim_trailing_whitespace = false 20 | -------------------------------------------------------------------------------- /.ember-cli: -------------------------------------------------------------------------------- 1 | { 2 | /** 3 | Ember CLI sends analytics information by default. The data is completely 4 | anonymous, but there are times when you might want to disable this behavior. 5 | 6 | Setting `disableAnalytics` to true will prevent any data from being sent. 7 | */ 8 | "disableAnalytics": false 9 | } 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | /vendor/ 4 | 5 | # compiled output 6 | /dist/ 7 | /tmp/ 8 | 9 | # dependencies 10 | /bower_components/ 11 | /node_modules/ 12 | 13 | # misc 14 | /coverage/ 15 | !.* 16 | .eslintcache 17 | 18 | # ember-try 19 | /.node_modules.ember-try/ 20 | /bower.json.ember-try 21 | /package.json.ember-try 22 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | root: true, 5 | parser: "babel-eslint", 6 | parserOptions: { 7 | ecmaVersion: 2018, 8 | sourceType: "module", 9 | ecmaFeatures: { 10 | legacyDecorators: true, 11 | }, 12 | }, 13 | plugins: ["ember"], 14 | extends: [ 15 | "eslint:recommended", 16 | "plugin:ember/recommended", 17 | "plugin:prettier/recommended", 18 | ], 19 | env: { 20 | browser: true, 21 | }, 22 | rules: {}, 23 | overrides: [ 24 | // node files 25 | { 26 | files: [ 27 | ".eslintrc.js", 28 | ".prettierrc.js", 29 | ".template-lintrc.js", 30 | "ember-cli-build.js", 31 | "testem.js", 32 | "blueprints/*/index.js", 33 | "config/**/*.js", 34 | "lib/*/index.js", 35 | "server/**/*.js", 36 | ], 37 | parserOptions: { 38 | sourceType: "script", 39 | }, 40 | env: { 41 | browser: false, 42 | node: true, 43 | }, 44 | plugins: ["node"], 45 | extends: ["plugin:node/recommended"], 46 | rules: { 47 | // this can be removed once the following is fixed 48 | // https://github.com/mysticatea/eslint-plugin-node/issues/77 49 | "node/no-unpublished-require": "off", 50 | }, 51 | }, 52 | ], 53 | }; 54 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | timeout-minutes: 10 9 | 10 | steps: 11 | - uses: actions/checkout@v2 12 | 13 | - name: Setup Node.js 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: 14.x 17 | 18 | - uses: actions/cache@v2 19 | with: 20 | path: ~/.npm 21 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 22 | restore-keys: | 23 | ${{ runner.os }}-node- 24 | 25 | - name: Install packages 26 | run: npm install 27 | 28 | - name: Run tests 29 | run: npm test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist/ 5 | /tmp/ 6 | 7 | # dependencies 8 | /bower_components/ 9 | /node_modules/ 10 | 11 | # misc 12 | /.env* 13 | /.pnp* 14 | /.sass-cache 15 | /.eslintcache 16 | /connect.lock 17 | /coverage/ 18 | /libpeerconnection.log 19 | /npm-debug.log* 20 | /testem.log 21 | /yarn-error.log 22 | 23 | # ember-try 24 | /.node_modules.ember-try/ 25 | /bower.json.ember-try 26 | /package.json.ember-try 27 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # unconventional js 2 | /blueprints/*/files/ 3 | /vendor/ 4 | 5 | # compiled output 6 | /dist/ 7 | /tmp/ 8 | 9 | # dependencies 10 | /bower_components/ 11 | /node_modules/ 12 | 13 | # misc 14 | /coverage/ 15 | !.* 16 | .eslintcache 17 | 18 | # ember-try 19 | /.node_modules.ember-try/ 20 | /bower.json.ember-try 21 | /package.json.ember-try 22 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | singleQuote: false, 5 | }; 6 | -------------------------------------------------------------------------------- /.template-lintrc.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | extends: "octane", 5 | }; 6 | -------------------------------------------------------------------------------- /.watchmanconfig: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": ["tmp", "dist"] 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mopster 2 | 3 | [Mopidy](https://mopidy.com) client written in [Ember.js](https://emberjs.com) 4 | 5 | Go to [Online version](http://mopster.urizen.pl) and set it up with your local configuration. 6 | 7 | ## Prerequisites 8 | 9 | You will need the following things properly installed on your computer. 10 | 11 | * [Git](https://git-scm.com/) 12 | * [Node.js](https://nodejs.org/) (with npm) 13 | * [Ember CLI](https://ember-cli.com/) 14 | * [Google Chrome](https://google.com/chrome/) 15 | 16 | ## Running / Development 17 | 18 | * `ember serve` 19 | * Visit your app at [http://localhost:4200](http://localhost:4200). 20 | * Visit your tests at [http://localhost:4200/tests](http://localhost:4200/tests). 21 | 22 | ### Running Tests 23 | 24 | * `ember test` 25 | * `ember test --server` 26 | 27 | ### Linting 28 | 29 | * `npm run lint:hbs` 30 | * `npm run lint:js` 31 | * `npm run lint:js -- --fix` 32 | 33 | ### Building 34 | 35 | * `ember build` (development) 36 | * `ember build --environment production` (production) 37 | 38 | ### Deploying 39 | 40 | Specify what it takes to deploy your app. 41 | -------------------------------------------------------------------------------- /app/app.js: -------------------------------------------------------------------------------- 1 | import Application from "@ember/application"; 2 | import Resolver from "ember-resolver"; 3 | import loadInitializers from "ember-load-initializers"; 4 | import config from "mopster/config/environment"; 5 | 6 | export default class App extends Application { 7 | modulePrefix = config.modulePrefix; 8 | podModulePrefix = config.podModulePrefix; 9 | Resolver = Resolver; 10 | } 11 | 12 | loadInitializers(App, config.modulePrefix); 13 | -------------------------------------------------------------------------------- /app/components/album-image.hbs: -------------------------------------------------------------------------------- 1 | {{#if this.src}} 2 | 3 | Album 4 | 5 | {{/if}} 6 | -------------------------------------------------------------------------------- /app/components/album-image.js: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { inject as service } from "@ember/service"; 3 | import { tracked } from "@glimmer/tracking"; 4 | 5 | export default class AlbumImageComponent extends Component { 6 | @service mopidyClient; 7 | @service player; 8 | @tracked src; 9 | @tracked title; 10 | @tracked url; 11 | 12 | constructor() { 13 | super(...arguments); 14 | 15 | const lastFmApiKey = this.player.lastFmApiKey; 16 | if (!lastFmApiKey) { 17 | return; 18 | } 19 | 20 | const album = this.args.album; 21 | 22 | fetch( 23 | `http://ws.audioscrobbler.com/2.0/?method=album.search&album=${album.name}&limit=1&api_key=${lastFmApiKey}&format=json` 24 | ).then((response) => { 25 | if (response.status === 200) { 26 | response.json().then((data) => { 27 | const album = data.results.albummatches.album[0]; 28 | 29 | this.src = album.image[2]["#text"]; 30 | this.title = `${album.artist} - ${album.name}`; 31 | this.url = album.url; 32 | }); 33 | } 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/components/configure-client.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 5 | 6 |
7 |
8 | 9 | 10 |
11 |
12 |
13 | LastFM 14 |
15 | 16 | 17 |
18 | Used for fetching album images. Instructions how to create your own API key can be found on LastFM docs. 19 |
20 |
21 |
22 | 23 |
Values will be stored in browser local storage for your convenience
24 |
25 | -------------------------------------------------------------------------------- /app/components/configure-client.js: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { inject as service } from "@ember/service"; 3 | import { tracked } from "@glimmer/tracking"; 4 | import { action } from "@ember/object"; 5 | 6 | export default class ConfigureClientComponent extends Component { 7 | @service router; 8 | @service mopidyClient; 9 | @tracked host = localStorage.getItem("mopidyHost"); 10 | @tracked port = localStorage.getItem("mopidyPort"); 11 | @tracked lastFmApiKey = localStorage.getItem("lastFmApiKey"); 12 | 13 | @action save(event) { 14 | event.preventDefault(); 15 | 16 | localStorage.setItem("mopidyHost", this.host); 17 | localStorage.setItem("mopidyPort", this.port); 18 | if (this.lastFmApiKey && this.lastFmApiKey !== "") { 19 | localStorage.setItem("lastFmApiKey", this.lastFmApiKey); 20 | } 21 | this.router.transitionTo("queue"); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/components/nav.hbs: -------------------------------------------------------------------------------- 1 | {{#if this.isOnline }} 2 | 17 | {{/if}} 18 | -------------------------------------------------------------------------------- /app/components/nav.js: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { inject as service } from "@ember/service"; 3 | 4 | export default class NavComponent extends Component { 5 | @service player; 6 | 7 | get isOnline() { 8 | return this.player.isOnline; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/components/nav/controls.hbs: -------------------------------------------------------------------------------- 1 | 7 | {{#if this.isPlaying}} 8 | 14 | {{else}} 15 | 21 | {{/if}} 22 | 28 | 34 | -------------------------------------------------------------------------------- /app/components/nav/controls.js: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { inject as service } from "@ember/service"; 3 | import { tracked } from "@glimmer/tracking"; 4 | import { action } from "@ember/object"; 5 | 6 | export default class ControlsComponent extends Component { 7 | @service mopidyClient; 8 | @service player; 9 | @tracked state; 10 | 11 | constructor() { 12 | super(...arguments); 13 | 14 | this.mopidyClient.getState().then((state) => { 15 | this.state = state; 16 | }); 17 | 18 | this.mopidyClient.client.on("event:playbackStateChanged", (data) => { 19 | this.state = data.new_state; 20 | }); 21 | } 22 | 23 | get isPlaying() { 24 | return this.state === "playing"; 25 | } 26 | 27 | @action previous() { 28 | this.mopidyClient.previous(); 29 | } 30 | 31 | @action play() { 32 | const selectedTrackId = this.player.selectedTrackIds[0]; 33 | this.mopidyClient.play(selectedTrackId); 34 | } 35 | 36 | @action pause() { 37 | this.mopidyClient.pause(); 38 | } 39 | 40 | @action stop() { 41 | this.mopidyClient.stop(); 42 | } 43 | 44 | @action next() { 45 | this.mopidyClient.next(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/components/nav/now-playing.hbs: -------------------------------------------------------------------------------- 1 | {{#if this.title}} 2 | {{page-title this.title}} 3 | 4 |
5 | 6 | 7 | 8 | 9 | 10 | {{this.title}} 11 |
12 | {{/if}} 13 | -------------------------------------------------------------------------------- /app/components/nav/now-playing.js: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { inject as service } from "@ember/service"; 3 | import { tracked } from "@glimmer/tracking"; 4 | 5 | export default class ControlsComponent extends Component { 6 | @service mopidyClient; 7 | @service player; 8 | @tracked streamTitle; 9 | 10 | constructor() { 11 | super(...arguments); 12 | 13 | this.mopidyClient.client.on("event:streamTitleChanged", (data) => { 14 | this.streamTitle = data.title; 15 | }); 16 | 17 | this.mopidyClient.getStreamTitle().then((title) => { 18 | this.streamTitle = title; 19 | }); 20 | } 21 | 22 | get title() { 23 | return this.streamTitle || this.trackTitle; 24 | } 25 | 26 | get trackTitle() { 27 | const currentTrack = this.player.currentTrack; 28 | 29 | if (!currentTrack) { 30 | return null; 31 | } 32 | 33 | if (currentTrack.track.artists) { 34 | return `${currentTrack.track.artists[0].name} - ${currentTrack.track.name}`; 35 | } 36 | 37 | return currentTrack.track.name; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /app/components/nav/volume.hbs: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /app/components/nav/volume.js: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { inject as service } from "@ember/service"; 3 | import { tracked } from "@glimmer/tracking"; 4 | import { action } from "@ember/object"; 5 | 6 | export default class VolumeComponent extends Component { 7 | @service mopidyClient; 8 | @service player; 9 | @tracked volume; 10 | @tracked isMuted = false; 11 | 12 | constructor() { 13 | super(...arguments); 14 | 15 | this.mopidyClient.getMute().then((value) => { 16 | this.isMuted = value; 17 | }); 18 | 19 | this.mopidyClient.getVolume().then((value) => { 20 | this.volume = value; 21 | }); 22 | 23 | this.mopidyClient.client.on("event:volumeChanged", (data) => { 24 | this.volume = data.volume; 25 | }); 26 | } 27 | 28 | @action toggleMute() { 29 | this.mopidyClient.setMute(!this.isMuted).then(() => { 30 | this.isMuted = !this.isMuted; 31 | }); 32 | } 33 | 34 | @action setVolume() { 35 | const digitVolume = parseInt(this.volume, 10); 36 | this.mopidyClient.setVolume(digitVolume); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/components/queue/add-stream.hbs: -------------------------------------------------------------------------------- 1 | 4 | 5 | 41 | -------------------------------------------------------------------------------- /app/components/queue/add-stream.js: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { action } from "@ember/object"; 3 | import { tracked } from "@glimmer/tracking"; 4 | import { A } from "@ember/array"; 5 | import { inject as service } from "@ember/service"; 6 | 7 | export default class AddStreamComponent extends Component { 8 | @service mopidyClient; 9 | @tracked uri = ""; 10 | @tracked uris = A(this.storedUris || []); 11 | 12 | get storedUris() { 13 | const localUris = localStorage.getItem("uris"); 14 | if (!localUris) { 15 | return null; 16 | } 17 | 18 | return JSON.parse(localUris); 19 | } 20 | 21 | @action add(event) { 22 | event.preventDefault(); 23 | 24 | this.mopidyClient.add([this.uri]).then(() => { 25 | this.uris.pushObject(this.uri); 26 | localStorage.setItem("uris", JSON.stringify(this.uris.uniq().toArray())); 27 | }); 28 | } 29 | 30 | @action addToQueue(uri) { 31 | this.mopidyClient.add([uri]); 32 | } 33 | 34 | @action remove(uri) { 35 | this.uris = this.uris.without(uri); 36 | localStorage.setItem("uris", JSON.stringify(this.uris.toArray())); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /app/components/queue/entry-detail.hbs: -------------------------------------------------------------------------------- 1 | 2 | {{@entry.track.track_no}} 3 | {{@entry.track.name}} 4 | {{@entry.track.artists.0.name}} 5 | {{@entry.track.album.name}} 6 | {{track-lenght @entry.track.length}} 7 | 8 | -------------------------------------------------------------------------------- /app/components/queue/entry-detail.js: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { inject as service } from "@ember/service"; 3 | import { action } from "@ember/object"; 4 | 5 | export default class EntryDetailComponent extends Component { 6 | @service mopidyClient; 7 | @service player; 8 | 9 | get isCurrent() { 10 | const currentTrack = this.player.currentTrack; 11 | return this.args.entry.tlid === (currentTrack && currentTrack.tlid); 12 | } 13 | 14 | get isSelected() { 15 | return this.player.selectedTrackIds.includes(this.args.entry.tlid); 16 | } 17 | 18 | @action select(event) { 19 | let modifier; 20 | 21 | if (event.ctrlKey) { 22 | modifier = "add"; 23 | } else if (event.shiftKey) { 24 | modifier = "addFromPrevious"; 25 | } else { 26 | modifier = "replace"; 27 | } 28 | 29 | this.args.onSelect(this.args.entry.tlid, modifier); 30 | } 31 | 32 | @action play() { 33 | this.mopidyClient.play(this.args.entry.tlid); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/components/queue/list-entries.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {{#each this.playlist as |entry|}} 13 | 14 | {{/each}} 15 | 16 |
#TitleArtistAlbumLength
17 | -------------------------------------------------------------------------------- /app/components/queue/list-entries.js: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { inject as service } from "@ember/service"; 3 | import { tracked } from "@glimmer/tracking"; 4 | import { A } from "@ember/array"; 5 | import { action } from "@ember/object"; 6 | 7 | export default class ListEntriesComponent extends Component { 8 | @service mopidyClient; 9 | @service player; 10 | @tracked playlist; 11 | 12 | constructor() { 13 | super(...arguments); 14 | 15 | this.mopidyClient.trackList().then((result) => { 16 | this.playlist = A(result); 17 | }); 18 | 19 | this.mopidyClient.currentTrack().then((result) => { 20 | if (result) { 21 | this.player.currentTrack = result; 22 | } 23 | }); 24 | 25 | this.mopidyClient.client.on("event:tracklistChanged", () => { 26 | this.mopidyClient.trackList().then((result) => { 27 | this.playlist = result; 28 | }); 29 | }); 30 | } 31 | 32 | @action select(id, modifier) { 33 | switch (modifier) { 34 | case "add": 35 | this.player.selectedTrackIds.pushObject(id); 36 | break; 37 | case "addFromPrevious": { 38 | const lastSelectedId = 39 | this.player.selectedTrackIds[this.player.selectedTrackIds.length - 1]; 40 | const trackIds = this.playlist.mapBy("tlid"); 41 | const indexes = [ 42 | trackIds.indexOf(lastSelectedId), 43 | trackIds.indexOf(id), 44 | ].sort((a, b) => a - b); 45 | 46 | this.player.selectedTrackIds = A( 47 | trackIds.slice(indexes[0], indexes[1] + 1) 48 | ); 49 | break; 50 | } 51 | case "replace": 52 | this.player.selectedTrackIds = A([id]); 53 | break; 54 | default: 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/components/queue/modes.hbs: -------------------------------------------------------------------------------- 1 | 7 | 8 | 14 | 15 | 20 | 21 | 26 | -------------------------------------------------------------------------------- /app/components/queue/modes.js: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { inject as service } from "@ember/service"; 3 | import { tracked } from "@glimmer/tracking"; 4 | import { action } from "@ember/object"; 5 | 6 | export default class ModesComponent extends Component { 7 | @service mopidyClient; 8 | @tracked repeatEnabled; 9 | @tracked singleEnabled; 10 | @tracked randomEnabled; 11 | @tracked consumeEnabled; 12 | 13 | constructor() { 14 | super(...arguments); 15 | 16 | this.setState(); 17 | 18 | this.mopidyClient.client.on("event:optionsChanged", () => { 19 | this.setState(); 20 | }); 21 | } 22 | 23 | setState() { 24 | this.mopidyClient.getRepeat().then((value) => { 25 | this.repeatEnabled = value; 26 | }); 27 | 28 | this.mopidyClient.getSingle().then((value) => { 29 | this.singleEnabled = value; 30 | }); 31 | 32 | this.mopidyClient.getRandom().then((value) => { 33 | this.randomEnabled = value; 34 | }); 35 | 36 | this.mopidyClient.getConsume().then((value) => { 37 | this.consumeEnabled = value; 38 | }); 39 | } 40 | 41 | @action setRepeat() { 42 | this.repeatEnabled = !this.repeatEnabled; 43 | this.mopidyClient.setRepeat(this.repeatEnabled); 44 | } 45 | 46 | @action setSingle() { 47 | this.singleEnabled = !this.singleEnabled; 48 | this.mopidyClient.setSingle(this.singleEnabled); 49 | } 50 | 51 | @action setRandom() { 52 | this.randomEnabled = !this.randomEnabled; 53 | this.mopidyClient.setRandom(this.randomEnabled); 54 | } 55 | 56 | @action setConsume() { 57 | this.consumeEnabled = !this.consumeEnabled; 58 | this.mopidyClient.setConsume(this.consumeEnabled); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /app/components/queue/remove-tracks.hbs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/components/queue/remove-tracks.js: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { action } from "@ember/object"; 3 | import { inject as service } from "@ember/service"; 4 | 5 | export default class RemoveTracksComponent extends Component { 6 | @service mopidyClient; 7 | @service player; 8 | 9 | get isDisabled() { 10 | return !this.player.selectedTrackIds.length; 11 | } 12 | 13 | @action removeTracks() { 14 | this.mopidyClient.remove(this.player.selectedTrackIds); 15 | this.player.selectedTrackIds = []; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/components/search.hbs: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 |
6 |
7 | 15 |
16 |
17 | 18 | 25 | 26 |
27 |
28 |
29 |
Artists
30 |
    31 | {{#each this.artists as |artist|}} 32 |
  • {{artist.name}}
  • 33 | {{/each}} 34 |
35 |
36 |
37 |
38 |
39 |
Albums
40 |
    41 | {{#each this.albums as |album|}} 42 |
  • {{album.artists.0.name}} - {{album.name}}
  • 43 | {{/each}} 44 |
45 |
46 |
47 |
48 |
49 |
Tracks
50 |
    51 | {{#each this.tracks as |track|}} 52 |
  • {{track.artists.0.name}} - {{track.album.name}} - {{track.name}}
  • 53 | {{/each}} 54 |
55 |
56 |
57 |
58 | -------------------------------------------------------------------------------- /app/components/search.js: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { inject as service } from "@ember/service"; 3 | import { tracked } from "@glimmer/tracking"; 4 | import { action } from "@ember/object"; 5 | import { A } from "@ember/array"; 6 | 7 | export default class SearchComponent extends Component { 8 | @service mopidyClient; 9 | @service player; 10 | @tracked query; 11 | @tracked isSearching = false; 12 | @tracked results; 13 | @tracked selectedProvider; 14 | @tracked providers = A([]); 15 | @tracked artists = A([]); 16 | @tracked albums = A([]); 17 | @tracked tracks = A([]); 18 | 19 | constructor() { 20 | super(...arguments); 21 | 22 | this.query = this.args.query; 23 | if (this.query) { 24 | this.search(); 25 | } 26 | } 27 | 28 | @action search(event) { 29 | if (event) { 30 | event.preventDefault(); 31 | } 32 | 33 | this.isSearching = true; 34 | this.mopidyClient.search(this.query).then((data) => { 35 | const rawResults = Object.values(data); 36 | 37 | this.providers = this.providerNames( 38 | rawResults.map((result) => result.uri) 39 | ); 40 | 41 | this.results = this.providers.reduce((all, provider, index) => { 42 | all[provider] = rawResults[index]; 43 | return all; 44 | }, {}); 45 | 46 | const presentedProvider = this.providers.sort( 47 | (a, b) => 48 | (this.results[b].tracks || []).length - 49 | (this.results[a].tracks || []).length 50 | )[0]; 51 | this.assignProvider(presentedProvider); 52 | this.isSearching = false; 53 | this.args.onQueryUpdate(this.query); 54 | }); 55 | } 56 | 57 | @action switchProvider(provider, event) { 58 | event.preventDefault(); 59 | 60 | this.assignProvider(provider); 61 | } 62 | 63 | assignProvider(provider) { 64 | this.selectedProvider = provider; 65 | const result = this.results[this.selectedProvider]; 66 | 67 | this.artists = A(result.artists); 68 | this.albums = A(result.albums); 69 | this.tracks = A(result.tracks); 70 | } 71 | 72 | providerNames(providers) { 73 | return providers.map((provider) => provider.split(":")[0]); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /app/components/show-entry.hbs: -------------------------------------------------------------------------------- 1 | {{#each this.albums as |tracks|}} 2 |
3 |
4 |
5 | 6 |
7 |
8 |
9 |
10 | {{artist-names tracks.0.artists}} 11 |
12 |
13 | {{tracks.0.album.date}} - {{tracks.0.album.name}} 14 |
15 |
16 |
17 |
18 | 19 |
20 |
21 | 22 | 23 | 24 | {{#each tracks as |track|}} 25 | 26 | 27 | 28 | 29 | 30 | {{/each}} 31 | 32 |
{{track.track_no}}{{track.name}}
33 |
34 | {{/each}} 35 | -------------------------------------------------------------------------------- /app/components/show-entry.js: -------------------------------------------------------------------------------- 1 | import Component from "@glimmer/component"; 2 | import { inject as service } from "@ember/service"; 3 | import { tracked } from "@glimmer/tracking"; 4 | import { action } from "@ember/object"; 5 | 6 | export default class ShowEntryComponent extends Component { 7 | @service mopidyClient; 8 | @service router; 9 | @tracked currentTrackUri; 10 | 11 | get groupedTracks() { 12 | const tracks = Object.values(this.args.entry)[0]; 13 | 14 | return tracks.reduce((group, track) => { 15 | if (group[track.album.uri]) { 16 | group[track.album.uri].push(track); 17 | } else { 18 | group[track.album.uri] = [track]; 19 | } 20 | return group; 21 | }, {}); 22 | } 23 | 24 | get albums() { 25 | return Object.values(this.groupedTracks); 26 | } 27 | 28 | @action addToQueue(track) { 29 | this.mopidyClient.add([track.uri]); 30 | this.router.transitionTo("queue"); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/controllers/search.js: -------------------------------------------------------------------------------- 1 | import Controller from "@ember/controller"; 2 | import { action } from "@ember/object"; 3 | import { tracked } from "@glimmer/tracking"; 4 | 5 | export default class SearchController extends Controller { 6 | queryParams = ["query"]; 7 | 8 | @tracked query = null; 9 | 10 | @action setQuery(query) { 11 | this.query = query; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /app/helpers/artist-names.js: -------------------------------------------------------------------------------- 1 | import { helper } from "@ember/component/helper"; 2 | 3 | function artistNames([artists]) { 4 | return artists.map((artist) => artist.name).join(", "); 5 | // Sometimes there are strange artists in list 6 | // return artists[0].name; 7 | } 8 | 9 | export default helper(artistNames); 10 | -------------------------------------------------------------------------------- /app/helpers/eq.js: -------------------------------------------------------------------------------- 1 | import { helper } from "@ember/component/helper"; 2 | 3 | function eq(args) { 4 | const [one, two] = args; 5 | 6 | return one === two; 7 | } 8 | 9 | export default helper(eq); 10 | -------------------------------------------------------------------------------- /app/helpers/track-lenght.js: -------------------------------------------------------------------------------- 1 | import { helper } from "@ember/component/helper"; 2 | 3 | function trackLength(miliseconds) { 4 | const seconds = Math.floor((miliseconds / 1000) % 60); 5 | const minutes = Math.floor((miliseconds / (60 * 1000)) % 60); 6 | 7 | if (minutes === 0 && seconds === 0) { 8 | return null; 9 | } else { 10 | const fullSeconds = seconds < 10 ? `0${seconds}` : seconds; 11 | return `${minutes}:${fullSeconds}`; 12 | } 13 | } 14 | 15 | export default helper(trackLength); 16 | -------------------------------------------------------------------------------- /app/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Mopster 7 | 8 | 9 | 10 | {{content-for "head"}} 11 | 12 | 13 | 14 | 15 | {{content-for "head-footer"}} 16 | 17 | 18 | {{content-for "body"}} 19 | 20 |
21 | 22 | 23 | 24 | 25 | {{content-for "body-footer"}} 26 | 27 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /app/models/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/morgoth/mopster/5e4bbee73db3cef7b5a522d4c84a20066729f451/app/models/.gitkeep -------------------------------------------------------------------------------- /app/router.js: -------------------------------------------------------------------------------- 1 | import EmberRouter from "@ember/routing/router"; 2 | import config from "mopster/config/environment"; 3 | 4 | export default class Router extends EmberRouter { 5 | location = config.locationType; 6 | rootURL = config.rootURL; 7 | } 8 | 9 | Router.map(function () { 10 | this.route("configuration"); 11 | this.route("queue"); 12 | this.route("search"); 13 | this.route("entries", { path: "/entries/:uri/" }); 14 | }); 15 | -------------------------------------------------------------------------------- /app/routes/configuration.js: -------------------------------------------------------------------------------- 1 | import Route from "@ember/routing/route"; 2 | 3 | export default class ConfigurationRoute extends Route {} 4 | -------------------------------------------------------------------------------- /app/routes/configured.js: -------------------------------------------------------------------------------- 1 | import Route from "@ember/routing/route"; 2 | import { inject as service } from "@ember/service"; 3 | 4 | export default class ConfiguredRoute extends Route { 5 | @service router; 6 | @service mopidyClient; 7 | @service player; 8 | 9 | beforeModel() { 10 | const serverHost = localStorage.getItem("mopidyHost"); 11 | const serverPort = localStorage.getItem("mopidyPort"); 12 | const lastFmApiKey = localStorage.getItem("lastFmApiKey"); 13 | 14 | if (!this.player.lastFmApiKey && lastFmApiKey) { 15 | this.player.lastFmApiKey = lastFmApiKey; 16 | } 17 | 18 | if (this.mopidyClient.client) { 19 | // go to the route 20 | } else if (serverHost && serverPort) { 21 | this.mopidyClient.configure(serverHost, serverPort); 22 | } else { 23 | this.router.transitionTo("configuration"); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/routes/entries.js: -------------------------------------------------------------------------------- 1 | import { inject as service } from "@ember/service"; 2 | import ConfiguredRoute from "./configured"; 3 | 4 | export default class EntriesRoute extends ConfiguredRoute { 5 | @service mopidyClient; 6 | 7 | model(params) { 8 | return this.mopidyClient.lookup(params.uri); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/routes/index.js: -------------------------------------------------------------------------------- 1 | import Route from "@ember/routing/route"; 2 | import { inject as service } from "@ember/service"; 3 | 4 | export default class IndexRoute extends Route { 5 | @service router; 6 | 7 | beforeModel() { 8 | this.router.transitionTo("queue"); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/routes/queue.js: -------------------------------------------------------------------------------- 1 | import ConfiguredRoute from "./configured"; 2 | 3 | export default class QueueRoute extends ConfiguredRoute {} 4 | -------------------------------------------------------------------------------- /app/routes/search.js: -------------------------------------------------------------------------------- 1 | import { inject as service } from "@ember/service"; 2 | import ConfiguredRoute from "./configured"; 3 | 4 | export default class SearchRoute extends ConfiguredRoute { 5 | @service mopidyClient; 6 | 7 | model() { 8 | return this.mopidyClient.trackList(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/services/mopidy-client.js: -------------------------------------------------------------------------------- 1 | import Service from "@ember/service"; 2 | import Mopidy from "mopidy"; 3 | import { inject as service } from "@ember/service"; 4 | 5 | export default class MopidyClientService extends Service { 6 | @service player; 7 | 8 | configure(host, port) { 9 | const mopidy = new Mopidy({ 10 | webSocketUrl: `ws://${host}:${port}/mopidy/ws/`, 11 | }); 12 | 13 | const connectedClient = new Promise((resolve) => { 14 | mopidy.on("state:online", () => { 15 | this.player.isOnline = true; 16 | return resolve(mopidy); 17 | }); 18 | }); 19 | 20 | mopidy.on("event:playbackStateChanged", () => { 21 | this.currentTrack().then((result) => { 22 | this.player.currentTrack = result; 23 | }); 24 | }); 25 | 26 | mopidy.on("event:trackPlaybackStarted", () => { 27 | this.currentTrack().then((result) => { 28 | this.player.currentTrack = result; 29 | }); 30 | }); 31 | 32 | // For debugging 33 | // mopidy.on("event", (event, payload) => { 34 | // console.log(event); 35 | // console.log(payload); 36 | // }); 37 | // mopidy.on("state", (event, payload) => { 38 | // console.log(event); 39 | // console.log(payload); 40 | // }); 41 | 42 | this.client = mopidy; 43 | this.connectedClient = connectedClient; 44 | } 45 | 46 | // playback 47 | currentTrack() { 48 | return this.connectedClient.then((mopidy) => { 49 | return mopidy.playback.getCurrentTlTrack(); 50 | }); 51 | } 52 | 53 | play(tlid) { 54 | return this.connectedClient.then((mopidy) => { 55 | return mopidy.playback.play({ tlid }); 56 | }); 57 | } 58 | 59 | previous() { 60 | return this.connectedClient.then((mopidy) => { 61 | return mopidy.playback.previous(); 62 | }); 63 | } 64 | 65 | next() { 66 | return this.connectedClient.then((mopidy) => { 67 | return mopidy.playback.next(); 68 | }); 69 | } 70 | 71 | pause() { 72 | return this.connectedClient.then((mopidy) => { 73 | return mopidy.playback.pause(); 74 | }); 75 | } 76 | 77 | stop() { 78 | return this.connectedClient.then((mopidy) => { 79 | return mopidy.playback.stop(); 80 | }); 81 | } 82 | 83 | getState() { 84 | return this.connectedClient.then((mopidy) => { 85 | return mopidy.playback.getState(); 86 | }); 87 | } 88 | 89 | getTimePosition() { 90 | return this.connectedClient.then((mopidy) => { 91 | return mopidy.playback.getTimePosition(); 92 | }); 93 | } 94 | 95 | seek(timePosition) { 96 | return this.connectedClient.then((mopidy) => { 97 | return mopidy.playback.seek({ time_position: timePosition }); 98 | }); 99 | } 100 | 101 | getStreamTitle() { 102 | return this.connectedClient.then((mopidy) => { 103 | return mopidy.playback.getStreamTitle(); 104 | }); 105 | } 106 | 107 | // tracklist 108 | trackList() { 109 | return this.connectedClient.then((mopidy) => { 110 | return mopidy.tracklist.getTlTracks(); 111 | }); 112 | } 113 | 114 | add(uris) { 115 | return this.connectedClient.then((mopidy) => { 116 | return mopidy.tracklist.add({ uris }); 117 | }); 118 | } 119 | 120 | remove(ids) { 121 | return this.connectedClient.then((mopidy) => { 122 | return mopidy.tracklist.remove({ criteria: { tlid: ids } }); 123 | }); 124 | } 125 | 126 | getRepeat() { 127 | return this.connectedClient.then((mopidy) => { 128 | return mopidy.tracklist.getRepeat(); 129 | }); 130 | } 131 | 132 | setRepeat(value) { 133 | return this.connectedClient.then((mopidy) => { 134 | return mopidy.tracklist.setRepeat({ value }); 135 | }); 136 | } 137 | 138 | getRandom() { 139 | return this.connectedClient.then((mopidy) => { 140 | return mopidy.tracklist.getRandom(); 141 | }); 142 | } 143 | 144 | setRandom(value) { 145 | return this.connectedClient.then((mopidy) => { 146 | return mopidy.tracklist.setRandom({ value }); 147 | }); 148 | } 149 | 150 | getSingle() { 151 | return this.connectedClient.then((mopidy) => { 152 | return mopidy.tracklist.getSingle(); 153 | }); 154 | } 155 | 156 | setSingle(value) { 157 | return this.connectedClient.then((mopidy) => { 158 | return mopidy.tracklist.setSingle({ value }); 159 | }); 160 | } 161 | 162 | getConsume() { 163 | return this.connectedClient.then((mopidy) => { 164 | return mopidy.tracklist.getConsume(); 165 | }); 166 | } 167 | 168 | setConsume(value) { 169 | return this.connectedClient.then((mopidy) => { 170 | return mopidy.tracklist.setConsume({ value }); 171 | }); 172 | } 173 | 174 | // library 175 | browse(uri) { 176 | return this.connectedClient.then((mopidy) => { 177 | return mopidy.library.browse({ uri }); 178 | }); 179 | } 180 | 181 | lookup(uri) { 182 | return this.connectedClient.then((mopidy) => { 183 | return mopidy.library.lookup({ uris: [uri] }); 184 | }); 185 | } 186 | 187 | search(query) { 188 | return this.connectedClient.then((mopidy) => { 189 | return mopidy.library.search({ query: { any: [query] } }); 190 | }); 191 | } 192 | 193 | getImages(uris) { 194 | return this.connectedClient.then((mopidy) => { 195 | return mopidy.library.getImages({ uris }); 196 | }); 197 | } 198 | 199 | // mixer 200 | getVolume() { 201 | return this.connectedClient.then((mopidy) => { 202 | return mopidy.mixer.getVolume(); 203 | }); 204 | } 205 | 206 | setVolume(volume) { 207 | return this.connectedClient.then((mopidy) => { 208 | return mopidy.mixer.setVolume({ volume }); 209 | }); 210 | } 211 | 212 | getMute() { 213 | return this.connectedClient.then((mopidy) => { 214 | return mopidy.mixer.getMute(); 215 | }); 216 | } 217 | 218 | setMute(value) { 219 | return this.connectedClient.then((mopidy) => { 220 | return mopidy.mixer.setMute({ mute: value }); 221 | }); 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /app/services/player.js: -------------------------------------------------------------------------------- 1 | import Service from "@ember/service"; 2 | import { tracked } from "@glimmer/tracking"; 3 | import { A } from "@ember/array"; 4 | 5 | export default class PlayerService extends Service { 6 | @tracked lastFmApiKey; 7 | @tracked currentTrack; 8 | @tracked selectedTrackIds = A([]); 9 | @tracked isOnline = false; 10 | } 11 | -------------------------------------------------------------------------------- /app/styles/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | touch-action: manipulation; 3 | } 4 | 5 | a { 6 | text-decoration: none; 7 | } 8 | -------------------------------------------------------------------------------- /app/templates/application.hbs: -------------------------------------------------------------------------------- 1 | {{page-title "Mopster"}} 2 | 3 |