├── .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 |
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 |
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 |
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 |
6 |
7 |
8 |
12 |
25 |
26 |
27 | {{#each this.uris as |uri|}}
28 |
29 | {{uri}} |
30 |
31 |
32 |
33 | |
34 |
35 | {{/each}}
36 |
37 |
38 |
39 |
40 |
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 | Title |
6 | Artist |
7 | Album |
8 | Length |
9 |
10 |
11 |
12 | {{#each this.playlist as |entry|}}
13 |
14 | {{/each}}
15 |
16 |
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 |
17 |
18 |
19 | {{#each this.providers as |provider|}}
20 | -
21 | {{provider}}
22 |
23 | {{/each}}
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | {{#each this.artists as |artist|}}
32 | - {{artist.name}}
33 | {{/each}}
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | {{#each this.albums as |album|}}
42 | - {{album.artists.0.name}} - {{album.name}}
43 | {{/each}}
44 |
45 |
46 |
47 |
48 |
49 |
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 |
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 | {{track.track_no}} |
27 | {{track.name}} |
28 | |
29 |
30 | {{/each}}
31 |
32 |
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 |
4 |
5 |
6 | {{outlet}}
7 |
8 |
--------------------------------------------------------------------------------
/app/templates/configuration.hbs:
--------------------------------------------------------------------------------
1 | {{page-title "Configuration"}}
2 |
3 | Provide configuration for Mopidy
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/templates/entries.hbs:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/app/templates/queue.hbs:
--------------------------------------------------------------------------------
1 | {{page-title "Queue"}}
2 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/app/templates/search.hbs:
--------------------------------------------------------------------------------
1 | {{page-title "Search"}}
2 |
3 |
--------------------------------------------------------------------------------
/config/ember-cli-update.json:
--------------------------------------------------------------------------------
1 | {
2 | "schemaVersion": "1.0.0",
3 | "packages": [
4 | {
5 | "name": "ember-cli",
6 | "version": "3.25.2",
7 | "blueprints": [
8 | {
9 | "name": "app",
10 | "outputRepo": "https://github.com/ember-cli/ember-new-output",
11 | "codemodsSource": "ember-app-codemods-manifest@1",
12 | "isBaseBlueprint": true,
13 | "options": []
14 | }
15 | ]
16 | }
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------
/config/environment.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | module.exports = function (environment) {
4 | let ENV = {
5 | modulePrefix: "mopster",
6 | environment,
7 | rootURL: "/",
8 | locationType: "hash",
9 | EmberENV: {
10 | FEATURES: {
11 | // Here you can enable experimental features on an ember canary build
12 | // e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true
13 | },
14 | EXTEND_PROTOTYPES: {
15 | // Prevent Ember Data from overriding Date.parse.
16 | Date: false,
17 | },
18 | },
19 |
20 | APP: {
21 | // Here you can pass flags/options to your application instance
22 | // when it is created
23 | rootElement: "#app",
24 | },
25 | };
26 |
27 | if (environment === "development") {
28 | // ENV.APP.LOG_RESOLVER = true;
29 | // ENV.APP.LOG_ACTIVE_GENERATION = true;
30 | // ENV.APP.LOG_TRANSITIONS = true;
31 | // ENV.APP.LOG_TRANSITIONS_INTERNAL = true;
32 | // ENV.APP.LOG_VIEW_LOOKUPS = true;
33 | }
34 |
35 | if (environment === "test") {
36 | // Testem prefers this...
37 | ENV.locationType = "none";
38 |
39 | // keep test console output quieter
40 | ENV.APP.LOG_ACTIVE_GENERATION = false;
41 | ENV.APP.LOG_VIEW_LOOKUPS = false;
42 |
43 | ENV.APP.rootElement = "#ember-testing";
44 | ENV.APP.autoboot = false;
45 | }
46 |
47 | if (environment === "production") {
48 | // here you can enable a production-specific feature
49 | }
50 |
51 | return ENV;
52 | };
53 |
--------------------------------------------------------------------------------
/config/optional-features.json:
--------------------------------------------------------------------------------
1 | {
2 | "application-template-wrapper": false,
3 | "default-async-observers": true,
4 | "jquery-integration": false,
5 | "template-only-glimmer-components": true
6 | }
7 |
--------------------------------------------------------------------------------
/config/targets.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const browsers = [
4 | "last 1 Chrome versions",
5 | "last 1 Firefox versions",
6 | "last 1 Safari versions",
7 | ];
8 |
9 | module.exports = {
10 | browsers,
11 | };
12 |
--------------------------------------------------------------------------------
/ember-cli-build.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | const EmberApp = require("ember-cli/lib/broccoli/ember-app");
4 |
5 | module.exports = function (defaults) {
6 | let app = new EmberApp(defaults, {
7 | // Add options here
8 | });
9 |
10 | // Use `app.import` to add additional libraries to the generated
11 | // output files.
12 | //
13 | // If you need to use different assets in different
14 | // environments, specify an object as the first parameter. That
15 | // object's keys should be the environment name and the values
16 | // should be the asset to use in that environment.
17 | //
18 | // If the library that you are including contains AMD or ES6
19 | // modules that you would like to import into your application
20 | // please specify an object with the list of modules as keys
21 | // along with the exports of each module as its value.
22 |
23 | return app.toTree();
24 | };
25 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mopster",
3 | "version": "1.0.0",
4 | "private": true,
5 | "description": "Web client for Mopidy",
6 | "repository": "https://github.com/cowbell/mopster",
7 | "license": "MIT",
8 | "author": "Wojciech Wnętrzak",
9 | "directories": {
10 | "doc": "doc",
11 | "test": "tests"
12 | },
13 | "scripts": {
14 | "build": "ember build --environment=production",
15 | "lint": "npm-run-all --aggregate-output --continue-on-error --parallel 'lint:!(fix)'",
16 | "lint:fix": "npm-run-all --aggregate-output --continue-on-error --parallel lint:*:fix",
17 | "lint:hbs": "ember-template-lint .",
18 | "lint:hbs:fix": "ember-template-lint . --fix",
19 | "lint:js": "eslint . --cache",
20 | "lint:js:fix": "eslint . --fix",
21 | "start": "ember serve",
22 | "test": "npm-run-all lint test:*",
23 | "test:ember": "ember test"
24 | },
25 | "devDependencies": {
26 | "@ember/optional-features": "^2.0.0",
27 | "@ember/test-helpers": "^2.2.0",
28 | "@glimmer/component": "^1.0.3",
29 | "@glimmer/tracking": "^1.0.3",
30 | "babel-eslint": "^10.1.0",
31 | "broccoli-asset-rev": "^3.0.0",
32 | "ember-auto-import": "^2.0.0",
33 | "ember-cli": "~3.28.0",
34 | "ember-cli-babel": "^7.23.1",
35 | "ember-cli-dependency-checker": "^3.2.0",
36 | "ember-cli-htmlbars": "^6.0.0",
37 | "ember-cli-inject-live-reload": "^2.0.2",
38 | "ember-cli-sri": "^2.1.1",
39 | "ember-cli-terser": "^4.0.1",
40 | "ember-export-application-global": "^2.0.1",
41 | "ember-fetch": "^8.0.4",
42 | "ember-load-initializers": "^2.1.2",
43 | "ember-maybe-import-regenerator": "^1.0.0",
44 | "ember-page-title": "^7.0.0",
45 | "ember-qunit": "^5.1.2",
46 | "ember-resolver": "^8.0.2",
47 | "ember-source": "~4.0.0",
48 | "ember-template-lint": "^3.2.0",
49 | "eslint": "^7.24.0",
50 | "eslint-config-prettier": "^8.1.0",
51 | "eslint-plugin-ember": "^10.2.0",
52 | "eslint-plugin-node": "^11.1.0",
53 | "eslint-plugin-prettier": "^4.0.0",
54 | "loader.js": "^4.7.0",
55 | "mopidy": "^1.2.1",
56 | "npm-run-all": "^4.1.5",
57 | "prettier": "^2.2.1",
58 | "qunit": "^2.14.0",
59 | "qunit-dom": "^2.0.0",
60 | "webpack": "^5.64.1"
61 | },
62 | "engines": {
63 | "node": "10.* || >= 12"
64 | },
65 | "ember": {
66 | "edition": "octane"
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/morgoth/mopster/5e4bbee73db3cef7b5a522d4c84a20066729f451/public/favicon.ico
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # http://www.robotstxt.org
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/testem.js:
--------------------------------------------------------------------------------
1 | "use strict";
2 |
3 | module.exports = {
4 | test_page: "tests/index.html?hidepassed",
5 | disable_watching: true,
6 | launch_in_ci: ["Chrome"],
7 | launch_in_dev: ["Chrome"],
8 | browser_start_timeout: 120,
9 | browser_args: {
10 | Chrome: {
11 | ci: [
12 | // --no-sandbox is needed when running Chrome inside a container
13 | process.env.CI ? "--no-sandbox" : null,
14 | "--headless",
15 | "--disable-dev-shm-usage",
16 | "--disable-software-rasterizer",
17 | "--mute-audio",
18 | "--remote-debugging-port=0",
19 | "--window-size=1440,900",
20 | ].filter(Boolean),
21 | },
22 | },
23 | };
24 |
--------------------------------------------------------------------------------
/tests/helpers/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/morgoth/mopster/5e4bbee73db3cef7b5a522d4c84a20066729f451/tests/helpers/.gitkeep
--------------------------------------------------------------------------------
/tests/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Mopster Tests
7 |
8 |
9 |
10 | {{content-for "head"}}
11 | {{content-for "test-head"}}
12 |
13 |
14 |
15 |
16 |
17 | {{content-for "head-footer"}}
18 | {{content-for "test-head-footer"}}
19 |
20 |
21 | {{content-for "body"}}
22 | {{content-for "test-body"}}
23 |
24 |
25 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 | {{content-for "body-footer"}}
38 | {{content-for "test-body-footer"}}
39 |
40 |
41 |
--------------------------------------------------------------------------------
/tests/integration/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/morgoth/mopster/5e4bbee73db3cef7b5a522d4c84a20066729f451/tests/integration/.gitkeep
--------------------------------------------------------------------------------
/tests/test-helper.js:
--------------------------------------------------------------------------------
1 | import Application from "mopster/app";
2 | import config from "mopster/config/environment";
3 | import * as QUnit from "qunit";
4 | import { setApplication } from "@ember/test-helpers";
5 | import { setup } from "qunit-dom";
6 | import { start } from "ember-qunit";
7 |
8 | setApplication(Application.create(config.APP));
9 |
10 | setup(QUnit.assert);
11 |
12 | start();
13 |
--------------------------------------------------------------------------------
/tests/unit/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/morgoth/mopster/5e4bbee73db3cef7b5a522d4c84a20066729f451/tests/unit/.gitkeep
--------------------------------------------------------------------------------
/tests/unit/routes/queue-test.js:
--------------------------------------------------------------------------------
1 | import { module, test } from "qunit";
2 | import { setupTest } from "ember-qunit";
3 |
4 | module("Unit | Route | queue", function (hooks) {
5 | setupTest(hooks);
6 |
7 | test("it exists", function (assert) {
8 | let route = this.owner.lookup("route:queue");
9 | assert.ok(route);
10 | });
11 | });
12 |
--------------------------------------------------------------------------------
/tests/unit/services/mopidy-client-test.js:
--------------------------------------------------------------------------------
1 | import { module, test } from "qunit";
2 | import { setupTest } from "ember-qunit";
3 |
4 | module("Unit | Service | mopidy-client", function (hooks) {
5 | setupTest(hooks);
6 |
7 | // TODO: Replace this with your real tests.
8 | test("it exists", function (assert) {
9 | let service = this.owner.lookup("service:mopidy-client");
10 | assert.ok(service);
11 | });
12 | });
13 |
--------------------------------------------------------------------------------
/vendor/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/morgoth/mopster/5e4bbee73db3cef7b5a522d4c84a20066729f451/vendor/.gitkeep
--------------------------------------------------------------------------------