├── project
├── build.properties
└── plugins.sbt
├── web
├── src
│ ├── favicon.ico
│ ├── app
│ │ ├── shared
│ │ │ ├── dialogs
│ │ │ │ ├── folder
│ │ │ │ │ ├── folder.component.scss
│ │ │ │ │ ├── folder.component.html
│ │ │ │ │ ├── folder.component.ts
│ │ │ │ │ └── folder.component.spec.ts
│ │ │ │ ├── info.component.ts
│ │ │ │ ├── confirm.component.ts
│ │ │ │ ├── playlists-dialog.component.ts
│ │ │ │ ├── new-playlist-dialog.component.ts
│ │ │ │ ├── dialogs.module.ts
│ │ │ │ └── details.component.ts
│ │ │ ├── pipes
│ │ │ │ ├── year.pipe.ts
│ │ │ │ ├── search.pipe.ts
│ │ │ │ ├── pipes.module.ts
│ │ │ │ ├── time.pipe.ts
│ │ │ │ └── file-size.pipe.ts
│ │ │ ├── shared.module.ts
│ │ │ └── material
│ │ │ │ └── material.module.ts
│ │ ├── editor
│ │ │ ├── editor.component.ts
│ │ │ └── editor.module.ts
│ │ ├── library
│ │ │ ├── library.module.spec.ts
│ │ │ ├── actions
│ │ │ │ ├── recent.actions.ts
│ │ │ │ ├── favorites.actions.ts
│ │ │ │ ├── lyrics.actions.ts
│ │ │ │ ├── tracks.actions.ts
│ │ │ │ ├── albums.actions.ts
│ │ │ │ ├── artists.actions.ts
│ │ │ │ ├── playlists.actions.ts
│ │ │ │ └── player.actions.ts
│ │ │ ├── components
│ │ │ │ ├── shared
│ │ │ │ │ ├── loader.component.ts
│ │ │ │ │ ├── dictionary.component.ts
│ │ │ │ │ ├── chips.component.ts
│ │ │ │ │ ├── list-item.component.ts
│ │ │ │ │ └── controls.component.ts
│ │ │ │ └── player
│ │ │ │ │ └── progress.component.ts
│ │ │ ├── reducers
│ │ │ │ ├── recent.reducer.ts
│ │ │ │ ├── favorites.reducers.ts
│ │ │ │ ├── lyrics.reducers.ts
│ │ │ │ ├── tracks.reducers.ts
│ │ │ │ ├── playlists.reducers.ts
│ │ │ │ ├── artists.reducers.ts
│ │ │ │ └── albums.reducers.ts
│ │ │ ├── library.module.ts
│ │ │ ├── library.theme.scss
│ │ │ └── library.utils.ts
│ │ ├── settings
│ │ │ ├── settings.module.spec.ts
│ │ │ ├── settings.theme.scss
│ │ │ ├── reducers
│ │ │ │ ├── lyrics.reducers.ts
│ │ │ │ └── libray.reducers.ts
│ │ │ ├── components
│ │ │ │ ├── themes.component.ts
│ │ │ │ ├── library-folders.component.ts
│ │ │ │ └── lyrics-options.component.ts
│ │ │ ├── settings.module.ts
│ │ │ ├── settings.reducers.ts
│ │ │ ├── settings.effects.ts
│ │ │ └── settings.actions.ts
│ │ ├── playlists
│ │ │ ├── playlists.module.ts
│ │ │ └── playlists.theme.scss
│ │ ├── core
│ │ │ ├── services
│ │ │ │ ├── router.service.ts
│ │ │ │ ├── core.service.ts
│ │ │ │ ├── update.service.ts
│ │ │ │ ├── electron.service.ts
│ │ │ │ └── loader.service.ts
│ │ │ ├── actions
│ │ │ │ ├── core.actions.ts
│ │ │ │ └── audio.actions.ts
│ │ │ ├── reducers
│ │ │ │ ├── core.reducers.ts
│ │ │ │ └── audio.reducers.ts
│ │ │ ├── components
│ │ │ │ ├── initializer.component.ts
│ │ │ │ ├── toolbar.component.scss
│ │ │ │ └── sidenav.component.ts
│ │ │ ├── core.reducers.ts
│ │ │ ├── core.module.ts
│ │ │ ├── core.utils.ts
│ │ │ ├── core.effects.ts
│ │ │ └── core.theme.scss
│ │ ├── my-music
│ │ │ ├── my-music.module.ts
│ │ │ ├── my-music.theme.scss
│ │ │ ├── my-music.component.ts
│ │ │ └── components
│ │ │ │ ├── albums.component.ts
│ │ │ │ ├── artists.component.ts
│ │ │ │ └── shared
│ │ │ │ └── box-list.component.ts
│ │ ├── app.serializer.ts
│ │ ├── player
│ │ │ ├── player.module.ts
│ │ │ ├── player.theme.scss
│ │ │ └── components
│ │ │ │ ├── status.component.ts
│ │ │ │ ├── progress.component.ts
│ │ │ │ └── header.component.ts
│ │ ├── routes.ts
│ │ ├── model.ts
│ │ ├── app.reducers.ts
│ │ └── app.module.ts
│ ├── assets
│ │ ├── mstile-70x70.png
│ │ ├── favicon-16x16.png
│ │ ├── favicon-32x32.png
│ │ ├── mstile-144x144.png
│ │ ├── mstile-150x150.png
│ │ ├── mstile-310x150.png
│ │ ├── mstile-310x310.png
│ │ ├── apple-touch-icon.png
│ │ ├── icons
│ │ │ ├── icon-72x72.png
│ │ │ ├── icon-96x96.png
│ │ │ ├── icon-128x128.png
│ │ │ ├── icon-144x144.png
│ │ │ ├── icon-152x152.png
│ │ │ ├── icon-192x192.png
│ │ │ ├── icon-384x384.png
│ │ │ └── icon-512x512.png
│ │ ├── fonts
│ │ │ ├── cookie-v9-latin-regular.woff
│ │ │ ├── cookie-v9-latin-regular.woff2
│ │ │ ├── roboto-v18-latin_latin-ext-300.woff
│ │ │ ├── roboto-v18-latin_latin-ext-500.woff
│ │ │ ├── roboto-v18-latin_latin-ext-300.woff2
│ │ │ ├── roboto-v18-latin_latin-ext-500.woff2
│ │ │ ├── roboto-v18-latin_latin-ext-regular.woff
│ │ │ └── roboto-v18-latin_latin-ext-regular.woff2
│ │ ├── logo_grey.svg
│ │ └── safari-pinned-tab.svg
│ ├── environments
│ │ ├── environment.prod.ts
│ │ ├── environment.electron.ts
│ │ └── environment.ts
│ ├── tsconfig.app.json
│ ├── browserconfig.xml
│ ├── tsconfig.spec.json
│ ├── main.ts
│ ├── karma.conf.js
│ ├── test.ts
│ ├── manifest.json
│ ├── index.html
│ └── polyfills.ts
├── e2e
│ ├── app.po.ts
│ ├── tsconfig.e2e.json
│ └── app.e2e-spec.ts
└── ngsw-config.json
├── Procfile
├── src
├── main
│ ├── scala
│ │ └── net
│ │ │ └── creasource
│ │ │ ├── model
│ │ │ ├── AlbumCover.scala
│ │ │ ├── TrackMetadata.scala
│ │ │ └── Track.scala
│ │ │ ├── io
│ │ │ ├── FileSystemChange.scala
│ │ │ ├── WatchActor.scala
│ │ │ └── WatchService.scala
│ │ │ ├── http
│ │ │ ├── actors
│ │ │ │ ├── SocketSinkSupervisor.scala
│ │ │ │ └── SocketSinkActor.scala
│ │ │ ├── SPAWebServer.scala
│ │ │ ├── WebServer.scala
│ │ │ └── SocketWebServer.scala
│ │ │ ├── core
│ │ │ └── Application.scala
│ │ │ ├── Main.scala
│ │ │ └── web
│ │ │ ├── AudioLibraryRoutes.scala
│ │ │ ├── SettingsActor.scala
│ │ │ └── package.scala
│ └── resources
│ │ ├── reference.conf
│ │ └── logback.xml
└── test
│ └── scala
│ └── net
│ └── creasource
│ ├── SimpleTest.scala
│ └── TagExtractorTest.scala
├── .gitignore
├── electron
└── tsconfig.json
├── .editorconfig
├── bin
├── copy-jre.js
├── electron-windows-store-x64.js
└── electron-windows-store-x86.js
├── tsconfig.json
├── PRIVACY_POLICY.md
├── LICENSE
└── tslint.json
/project/build.properties:
--------------------------------------------------------------------------------
1 | sbt.version = 1.2.7
--------------------------------------------------------------------------------
/project/plugins.sbt:
--------------------------------------------------------------------------------
1 | addSbtPlugin("com.typesafe.sbt" % "sbt-native-packager" % "1.3.5")
--------------------------------------------------------------------------------
/web/src/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/favicon.ico
--------------------------------------------------------------------------------
/web/src/app/shared/dialogs/folder/folder.component.scss:
--------------------------------------------------------------------------------
1 | .mat-form-field {
2 | width: 100%;
3 | }
4 |
--------------------------------------------------------------------------------
/web/src/assets/mstile-70x70.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/mstile-70x70.png
--------------------------------------------------------------------------------
/web/src/assets/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/favicon-16x16.png
--------------------------------------------------------------------------------
/web/src/assets/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/favicon-32x32.png
--------------------------------------------------------------------------------
/web/src/assets/mstile-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/mstile-144x144.png
--------------------------------------------------------------------------------
/web/src/assets/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/mstile-150x150.png
--------------------------------------------------------------------------------
/web/src/assets/mstile-310x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/mstile-310x150.png
--------------------------------------------------------------------------------
/web/src/assets/mstile-310x310.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/mstile-310x310.png
--------------------------------------------------------------------------------
/web/src/assets/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/apple-touch-icon.png
--------------------------------------------------------------------------------
/web/src/assets/icons/icon-72x72.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/icons/icon-72x72.png
--------------------------------------------------------------------------------
/web/src/assets/icons/icon-96x96.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/icons/icon-96x96.png
--------------------------------------------------------------------------------
/web/src/assets/icons/icon-128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/icons/icon-128x128.png
--------------------------------------------------------------------------------
/web/src/assets/icons/icon-144x144.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/icons/icon-144x144.png
--------------------------------------------------------------------------------
/web/src/assets/icons/icon-152x152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/icons/icon-152x152.png
--------------------------------------------------------------------------------
/web/src/assets/icons/icon-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/icons/icon-192x192.png
--------------------------------------------------------------------------------
/web/src/assets/icons/icon-384x384.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/icons/icon-384x384.png
--------------------------------------------------------------------------------
/web/src/assets/icons/icon-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/icons/icon-512x512.png
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: target/universal/stage/bin/musicalypse -Dhttp.port=${PORT} -Dhttp.stop-on-return=false -Dmusic.uploadFolder=${UPLOAD_FOLDER}
--------------------------------------------------------------------------------
/web/src/environments/environment.prod.ts:
--------------------------------------------------------------------------------
1 | export const environment = {
2 | production: true,
3 | electron: false,
4 | httpPort: 443
5 | };
6 |
--------------------------------------------------------------------------------
/web/src/assets/fonts/cookie-v9-latin-regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/fonts/cookie-v9-latin-regular.woff
--------------------------------------------------------------------------------
/web/src/environments/environment.electron.ts:
--------------------------------------------------------------------------------
1 | export const environment = {
2 | production: true,
3 | electron: true,
4 | httpPort: 8080
5 | };
6 |
--------------------------------------------------------------------------------
/src/main/scala/net/creasource/model/AlbumCover.scala:
--------------------------------------------------------------------------------
1 | package net.creasource.model
2 |
3 | case class AlbumCover(bytes: Array[Byte], mimeType: String)
4 |
--------------------------------------------------------------------------------
/web/src/assets/fonts/cookie-v9-latin-regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/fonts/cookie-v9-latin-regular.woff2
--------------------------------------------------------------------------------
/web/src/assets/fonts/roboto-v18-latin_latin-ext-300.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/fonts/roboto-v18-latin_latin-ext-300.woff
--------------------------------------------------------------------------------
/web/src/assets/fonts/roboto-v18-latin_latin-ext-500.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/fonts/roboto-v18-latin_latin-ext-500.woff
--------------------------------------------------------------------------------
/web/src/assets/fonts/roboto-v18-latin_latin-ext-300.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/fonts/roboto-v18-latin_latin-ext-300.woff2
--------------------------------------------------------------------------------
/web/src/assets/fonts/roboto-v18-latin_latin-ext-500.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/fonts/roboto-v18-latin_latin-ext-500.woff2
--------------------------------------------------------------------------------
/web/src/assets/fonts/roboto-v18-latin_latin-ext-regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/fonts/roboto-v18-latin_latin-ext-regular.woff
--------------------------------------------------------------------------------
/web/src/assets/fonts/roboto-v18-latin_latin-ext-regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tgambet/musicalypse/HEAD/web/src/assets/fonts/roboto-v18-latin_latin-ext-regular.woff2
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .idea/
2 | project/project/
3 | target/
4 | logs/
5 | node_modules/
6 | npm_debug.log
7 | config/
8 | uploads/
9 | cache/
10 | data/
11 | dist/
12 | *.iml
13 |
--------------------------------------------------------------------------------
/web/src/app/editor/editor.component.ts:
--------------------------------------------------------------------------------
1 | import {Component} from '@angular/core';
2 |
3 | @Component({
4 | selector: 'app-editor',
5 | template: `
6 | Editor
7 | `,
8 | styles: [``]
9 | })
10 | export class EditorComponent {
11 |
12 | }
13 |
--------------------------------------------------------------------------------
/web/e2e/app.po.ts:
--------------------------------------------------------------------------------
1 | import { browser, by, element } from 'protractor';
2 |
3 | export class AppPage {
4 | navigateTo() {
5 | return browser.get('/');
6 | }
7 |
8 | getParagraphText() {
9 | return element(by.css('app-root h1')).getText();
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/src/test/scala/net/creasource/SimpleTest.scala:
--------------------------------------------------------------------------------
1 | package net.creasource
2 |
3 | import org.scalatest.{BeforeAndAfterAll, Matchers, WordSpecLike}
4 |
5 | abstract class SimpleTest
6 | extends WordSpecLike
7 | with Matchers
8 | with BeforeAndAfterAll {
9 |
10 | }
11 |
--------------------------------------------------------------------------------
/electron/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compileOnSave": false,
3 | "compilerOptions": {
4 | "baseUrl": "./",
5 | "outDir": "../target/electron/out-tsc",
6 | "sourceMap": false,
7 | "moduleResolution": "node",
8 | "typeRoots": [
9 | "../node_modules/@types"
10 | ]
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/web/src/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../../target/out-tsc/app",
5 | "baseUrl": "./",
6 | "module": "es2015",
7 | "types": []
8 | },
9 | "exclude": [
10 | "test.ts",
11 | "**/*.spec.ts"
12 | ]
13 | }
14 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # Editor configuration, see http://editorconfig.org
2 | root = true
3 |
4 | [*]
5 | charset = utf-8
6 | indent_style = space
7 | indent_size = 2
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
11 | [*.md]
12 | max_line_length = off
13 | trim_trailing_whitespace = false
14 |
--------------------------------------------------------------------------------
/web/e2e/tsconfig.e2e.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../out-tsc/e2e",
5 | "baseUrl": "./",
6 | "module": "commonjs",
7 | "target": "es5",
8 | "types": [
9 | "jasmine",
10 | "jasminewd2",
11 | "node"
12 | ]
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/bin/copy-jre.js:
--------------------------------------------------------------------------------
1 | const prompt = require('prompt-sync')();
2 | const fs = require('fs-extra');
3 |
4 | const srcJRE = prompt('Path to JRE: ');
5 | const destJRE = './target/jre';
6 |
7 | fs.ensureDir(destJRE).then(() =>
8 | fs.copy(srcJRE, destJRE).then(() => {
9 | console.log('JRE copied.')
10 | }).catch(err => console.log(err))
11 | );
12 |
--------------------------------------------------------------------------------
/web/e2e/app.e2e-spec.ts:
--------------------------------------------------------------------------------
1 | import { AppPage } from './app.po';
2 |
3 | describe('my-app App', () => {
4 | let page: AppPage;
5 |
6 | beforeEach(() => {
7 | page = new AppPage();
8 | });
9 |
10 | it('should display welcome message', () => {
11 | page.navigateTo();
12 | expect(page.getParagraphText()).toEqual('Welcome to app!');
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/web/src/app/library/library.module.spec.ts:
--------------------------------------------------------------------------------
1 | import { LibraryModule } from './library.module';
2 |
3 | describe('LibraryModule', () => {
4 | let libraryModule: LibraryModule;
5 |
6 | beforeEach(() => {
7 | libraryModule = new LibraryModule();
8 | });
9 |
10 | it('should create an instance', () => {
11 | expect(libraryModule).toBeTruthy();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/web/src/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | #da532c
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/web/src/app/settings/settings.module.spec.ts:
--------------------------------------------------------------------------------
1 | import { SettingsModule } from './settings.module';
2 |
3 | describe('SettingsModule', () => {
4 | let settingsModule: SettingsModule;
5 |
6 | beforeEach(() => {
7 | settingsModule = new SettingsModule();
8 | });
9 |
10 | it('should create an instance', () => {
11 | expect(settingsModule).toBeTruthy();
12 | });
13 | });
14 |
--------------------------------------------------------------------------------
/web/src/app/shared/pipes/year.pipe.ts:
--------------------------------------------------------------------------------
1 | import {Pipe, PipeTransform} from '@angular/core';
2 |
3 | @Pipe({
4 | name: 'sgYear'
5 | })
6 | export class YearPipe implements PipeTransform {
7 |
8 | transform(value: string): string {
9 | if (value && value.length > 4) {
10 | return value.substr(0, 4);
11 | } else {
12 | return value;
13 | }
14 | }
15 |
16 | }
17 |
--------------------------------------------------------------------------------
/web/src/tsconfig.spec.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "../../target/out-tsc/spec",
5 | "module": "commonjs",
6 | "types": [
7 | "jasmine",
8 | "node"
9 | ]
10 | },
11 | "files": [
12 | "test.ts",
13 | "polyfills.ts"
14 | ],
15 | "include": [
16 | "**/*.spec.ts",
17 | "**/*.d.ts"
18 | ]
19 | }
20 |
--------------------------------------------------------------------------------
/src/main/scala/net/creasource/io/FileSystemChange.scala:
--------------------------------------------------------------------------------
1 | package net.creasource.io
2 |
3 | import java.nio.file.Path
4 |
5 | object FileSystemChange {
6 |
7 | sealed trait FileSystemChange {
8 | def path: Path
9 | }
10 |
11 | case class Created(path: Path) extends FileSystemChange
12 |
13 | case class Deleted(path: Path) extends FileSystemChange
14 |
15 | case class WatchDir(path: Path)
16 |
17 | }
18 |
--------------------------------------------------------------------------------
/web/src/app/library/actions/recent.actions.ts:
--------------------------------------------------------------------------------
1 | import {Track} from '@app/model';
2 | import {Action} from '@ngrx/store';
3 |
4 | export enum RecentActionTypes {
5 | AddToRecent = 'library/recent/add'
6 | }
7 |
8 | export class AddToRecent implements Action {
9 | readonly type = RecentActionTypes.AddToRecent;
10 | constructor(public payload: Track[]) {}
11 | }
12 |
13 | export type RecentActionsUnion = AddToRecent;
14 |
--------------------------------------------------------------------------------
/web/src/app/editor/editor.module.ts:
--------------------------------------------------------------------------------
1 | import {NgModule} from '@angular/core';
2 | import {SharedModule} from '@app/shared/shared.module';
3 |
4 | import {EditorComponent} from './editor.component';
5 |
6 | export const COMPONENTS = [
7 | EditorComponent
8 | ];
9 |
10 | @NgModule({
11 | imports: [
12 | SharedModule
13 | ],
14 | declarations: COMPONENTS,
15 | exports: COMPONENTS,
16 | })
17 | export class EditorModule {
18 |
19 | }
20 |
--------------------------------------------------------------------------------
/web/src/app/shared/pipes/search.pipe.ts:
--------------------------------------------------------------------------------
1 | import { Pipe, PipeTransform } from '@angular/core';
2 |
3 | @Pipe({
4 | name: 'sgSearch'
5 | })
6 | export class SearchPipe implements PipeTransform {
7 |
8 | transform(value: string, search?: string): any {
9 | if (!search) {
10 | return value;
11 | }
12 | const reg = RegExp(search, 'gi');
13 | return value.replace(reg, sub => `${sub} `);
14 | }
15 |
16 | }
17 |
--------------------------------------------------------------------------------
/web/src/main.ts:
--------------------------------------------------------------------------------
1 | import { enableProdMode } from '@angular/core';
2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
3 |
4 | import { AppModule } from './app/app.module';
5 | import { environment } from './environments/environment';
6 |
7 | import 'hammerjs';
8 |
9 | if (environment.production) {
10 | enableProdMode();
11 | }
12 |
13 | platformBrowserDynamic().bootstrapModule(AppModule)
14 | .catch(err => console.log(err));
15 |
--------------------------------------------------------------------------------
/web/src/app/playlists/playlists.module.ts:
--------------------------------------------------------------------------------
1 | import {NgModule} from '@angular/core';
2 | import {SharedModule} from '@app/shared/shared.module';
3 |
4 | import {PlaylistsComponent} from './playlists.component';
5 |
6 | export const COMPONENTS = [
7 | PlaylistsComponent
8 | ];
9 |
10 | @NgModule({
11 | imports: [
12 | SharedModule
13 | ],
14 | declarations: COMPONENTS,
15 | exports: COMPONENTS,
16 | })
17 | export class PlaylistsModule {
18 |
19 | }
20 |
--------------------------------------------------------------------------------
/web/src/app/shared/dialogs/folder/folder.component.html:
--------------------------------------------------------------------------------
1 |
Add a library folder
2 |
3 |
4 |
5 |
6 |
7 |
8 | Cancel
9 | Save
10 |
11 |
--------------------------------------------------------------------------------
/web/src/app/shared/pipes/pipes.module.ts:
--------------------------------------------------------------------------------
1 | import {NgModule} from '@angular/core';
2 |
3 | import {FileSizePipe} from './file-size.pipe';
4 | import {SearchPipe} from './search.pipe';
5 | import {TimePipe} from './time.pipe';
6 | import {YearPipe} from './year.pipe';
7 |
8 | export const PIPES = [
9 | FileSizePipe,
10 | SearchPipe,
11 | TimePipe,
12 | YearPipe
13 | ];
14 |
15 | @NgModule({
16 | declarations: PIPES,
17 | exports: PIPES
18 | })
19 | export class PipesModule {}
20 |
--------------------------------------------------------------------------------
/web/src/environments/environment.ts:
--------------------------------------------------------------------------------
1 | // The file contents for the current environment will overwrite these during build.
2 | // The build system defaults to the dev environment which uses `environment.ts`, but if you do
3 | // `ng build --env=prod` then `environment.prod.ts` will be used instead.
4 | // The list of which env maps to which file can be found in `.angular-cli.json`.
5 |
6 | export const environment = {
7 | production: false,
8 | electron: false,
9 | httpPort: 8080
10 | };
11 |
--------------------------------------------------------------------------------
/src/main/scala/net/creasource/model/TrackMetadata.scala:
--------------------------------------------------------------------------------
1 | package net.creasource.model
2 |
3 | import java.nio.file.Path
4 |
5 | case class TrackMetadata(
6 | location: Path,
7 | title: Option[String],
8 | artist: Option[String],
9 | albumArtist: Option[String],
10 | album: Option[String],
11 | year: Option[String],
12 | duration: Int,
13 | cover: Option[AlbumCover])
14 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compileOnSave": true,
3 | "compilerOptions": {
4 | "baseUrl": "web/",
5 | "outDir": "./target/out-tsc",
6 | "sourceMap": true,
7 | "declaration": false,
8 | "moduleResolution": "node",
9 | "emitDecoratorMetadata": true,
10 | "experimentalDecorators": true,
11 | "target": "es5",
12 | "typeRoots": [
13 | "node_modules/@types"
14 | ],
15 | "lib": [
16 | "es2017",
17 | "dom"
18 | ],
19 | "paths": {
20 | "@app/*": ["app/*"],
21 | "@env/*": ["environments/*"]
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/web/src/app/core/services/router.service.ts:
--------------------------------------------------------------------------------
1 | import {Injectable} from '@angular/core';
2 | import {Store} from '@ngrx/store';
3 | import {Observable} from 'rxjs';
4 |
5 | import * as fromRoot from '@app/app.reducers';
6 | import {RouterStateUrl} from '@app/app.serializer';
7 | import {getRouterState} from '@app/app.reducers';
8 |
9 | @Injectable()
10 | export class RouterService {
11 |
12 | constructor(
13 | private store: Store
14 | ) {
15 | }
16 |
17 | getRouterState(): Observable {
18 | return this.store.select(getRouterState);
19 | }
20 |
21 | }
22 |
--------------------------------------------------------------------------------
/web/src/app/shared/dialogs/folder/folder.component.ts:
--------------------------------------------------------------------------------
1 | import {Component, Inject, OnInit} from '@angular/core';
2 | import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material';
3 |
4 | @Component({
5 | selector: 'app-folder',
6 | templateUrl: './folder.component.html',
7 | styleUrls: ['./folder.component.scss']
8 | })
9 | export class FolderComponent implements OnInit {
10 |
11 | folder: string;
12 |
13 | constructor(
14 | public dialogRef: MatDialogRef,
15 | @Inject(MAT_DIALOG_DATA) public data: any
16 | ) { }
17 |
18 | ngOnInit() {
19 | }
20 |
21 | cancel() {
22 | this.dialogRef.close();
23 | }
24 |
25 | }
26 |
--------------------------------------------------------------------------------
/src/main/scala/net/creasource/model/Track.scala:
--------------------------------------------------------------------------------
1 | package net.creasource.model
2 |
3 | import spray.json.DefaultJsonProtocol._
4 | import spray.json._
5 |
6 | case class Track(
7 | url: String,
8 | coverUrl: Option[String],
9 | location: String,
10 | title: Option[String],
11 | artist: Option[String],
12 | albumArtist: Option[String],
13 | album: Option[String],
14 | year: Option[String],
15 | duration: Int)
16 |
17 | object Track {
18 | implicit val formatter: RootJsonFormat[Track] = jsonFormat9(Track.apply)
19 | }
20 |
--------------------------------------------------------------------------------
/web/src/app/shared/pipes/time.pipe.ts:
--------------------------------------------------------------------------------
1 | import { Pipe, PipeTransform } from '@angular/core';
2 |
3 | @Pipe({
4 | name: 'sgTime'
5 | })
6 | export class TimePipe implements PipeTransform {
7 |
8 | transform(value: number): string {
9 |
10 | const hours = Math.floor(value / 3600);
11 | const minutes = Math.floor(value / 60) % 60;
12 | const seconds = Math.floor(value) % 60;
13 |
14 | function format(num: number) {
15 | return num.toString().padStart(2, '0');
16 | }
17 |
18 | let result = `${format(minutes)}:${format(seconds)}`;
19 | if (hours > 0) { result = format(hours) + ':' + result; }
20 |
21 | return result;
22 | }
23 |
24 | }
25 |
--------------------------------------------------------------------------------
/web/src/app/playlists/playlists.theme.scss:
--------------------------------------------------------------------------------
1 | @import '~@angular/material/theming';
2 |
3 | @mixin playlists-component-theme($theme) {
4 |
5 | $primary: map-get($theme, primary);
6 | $accent: map-get($theme, accent);
7 | $warn: map-get($theme, warn);
8 | $foreground: map-get($theme, foreground);
9 | $background: map-get($theme, background);
10 |
11 | .playlists {
12 |
13 | .avatar-icon {
14 | color: mat-color($foreground, disabled-text);
15 | }
16 |
17 | .covers {
18 | border: 1px solid mat-color($foreground, divider);
19 | }
20 |
21 | .more {
22 | background-color: mat-color($background, background);
23 | }
24 |
25 | }
26 |
27 | }
28 |
--------------------------------------------------------------------------------
/web/src/app/library/components/shared/loader.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
2 |
3 | @Component({
4 | selector: 'app-loader',
5 | template: `
6 |
7 |
8 | Loading...
9 |
10 | `,
11 | styles: [`
12 | div {
13 | height: 100%;
14 | display: flex;
15 | flex-direction: column;
16 | align-items: center;
17 | justify-content: center;
18 | overflow-y: scroll;
19 | }
20 | `],
21 | changeDetection: ChangeDetectionStrategy.OnPush
22 | })
23 | export class LoaderComponent {
24 |
25 | @Input() show: boolean;
26 |
27 | }
28 |
--------------------------------------------------------------------------------
/web/src/app/library/actions/favorites.actions.ts:
--------------------------------------------------------------------------------
1 | import {Track} from '@app/model';
2 | import {Action} from '@ngrx/store';
3 |
4 | export enum FavoritesActionTypes {
5 | AddToFavorites = 'library/favorites/add',
6 | RemoveFromFavorites = 'library/favorites/remove'
7 | }
8 |
9 | export class AddToFavorites implements Action {
10 | readonly type = FavoritesActionTypes.AddToFavorites;
11 | constructor(public payload: Track[]) {}
12 | }
13 |
14 | export class RemoveFromFavorites implements Action {
15 | readonly type = FavoritesActionTypes.RemoveFromFavorites;
16 | constructor(public payload: Track) {}
17 | }
18 |
19 | export type FavoritesActionsUnion =
20 | AddToFavorites |
21 | RemoveFromFavorites;
22 |
--------------------------------------------------------------------------------
/web/src/app/core/services/core.service.ts:
--------------------------------------------------------------------------------
1 | import {Injectable} from '@angular/core';
2 | import {Store} from '@ngrx/store';
3 |
4 | import * as fromCore from '@app/core/core.reducers';
5 | import {Observable} from 'rxjs';
6 | import {Theme} from '@app/core/core.utils';
7 | import {ChangeTheme} from '@app/core/actions/core.actions';
8 |
9 | @Injectable()
10 | export class CoreService {
11 |
12 | constructor(
13 | private store: Store
14 | ) {
15 |
16 | }
17 |
18 | getCurrentTheme(): Observable {
19 | return this.store.select(fromCore.getCurrentTheme);
20 | }
21 |
22 | changeTheme(theme: Theme): void {
23 | this.store.dispatch(new ChangeTheme(theme));
24 | }
25 |
26 | // TODO sidenav actions here
27 |
28 | }
29 |
--------------------------------------------------------------------------------
/web/src/app/settings/settings.theme.scss:
--------------------------------------------------------------------------------
1 | @import '~@angular/material/theming';
2 |
3 | @mixin settings-theme($theme) {
4 |
5 | $primary: map-get($theme, primary);
6 | $accent: map-get($theme, accent);
7 | $warn: map-get($theme, warn);
8 | $foreground: map-get($theme, foreground);
9 | $background: map-get($theme, background);
10 |
11 | .settings {
12 | a {
13 | color: mat-color($foreground, text);
14 | }
15 |
16 | .error {
17 | color: mat-color($warn);
18 | }
19 |
20 | .mat-tab-label {
21 | min-width: 100px !important;
22 | }
23 |
24 | .mat-tab-body {
25 | padding: 1rem 0;
26 | }
27 |
28 | .mat-tab-body-content {
29 | overflow: unset !important;
30 | }
31 |
32 | }
33 |
34 | }
35 |
--------------------------------------------------------------------------------
/src/main/resources/reference.conf:
--------------------------------------------------------------------------------
1 | music {
2 | library: "",
3 | cacheFolder: "data"
4 | }
5 |
6 | http {
7 | host: "0.0.0.0"
8 | port: 8080
9 | stop-on-return: true
10 | }
11 |
12 | akka {
13 | log-config-on-start = false
14 | log-dead-letters-during-shutdown = off
15 | log-dead-letters = 0
16 | loggers = ["akka.event.slf4j.Slf4jLogger"]
17 | loglevel = "DEBUG"
18 | logging-filter = "akka.event.slf4j.Slf4jLoggingFilter"
19 | stdout-loglevel = "WARNING"
20 | actor {
21 | debug {
22 | unhandled = on
23 | }
24 | }
25 | http {
26 | server {
27 | idle-timeout = 90s
28 | websocket {
29 | periodic-keep-alive-max-idle = 10s
30 | // periodic-keep-alive-mode = pong
31 | }
32 | }
33 | }
34 | }
35 |
36 |
--------------------------------------------------------------------------------
/web/src/app/shared/dialogs/info.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, Inject, OnInit} from '@angular/core';
2 | import {MAT_DIALOG_DATA} from '@angular/material';
3 |
4 | @Component({
5 | selector: 'app-info',
6 | template: `
7 | {{ data.title }}
8 |
11 |
12 | Ok
13 |
14 | `,
15 | styles: [``],
16 | changeDetection: ChangeDetectionStrategy.OnPush
17 | })
18 | export class InfoComponent implements OnInit {
19 |
20 | constructor(@Inject(MAT_DIALOG_DATA) public data: any) { }
21 |
22 | ngOnInit() {}
23 |
24 | }
25 |
--------------------------------------------------------------------------------
/web/src/app/shared/dialogs/folder/folder.component.spec.ts:
--------------------------------------------------------------------------------
1 | import { async, ComponentFixture, TestBed } from '@angular/core/testing';
2 |
3 | import { FolderComponent } from './folder.component';
4 |
5 | describe('FolderComponent', () => {
6 | let component: FolderComponent;
7 | let fixture: ComponentFixture;
8 |
9 | beforeEach(async(() => {
10 | TestBed.configureTestingModule({
11 | declarations: [ FolderComponent ]
12 | })
13 | .compileComponents();
14 | }));
15 |
16 | beforeEach(() => {
17 | fixture = TestBed.createComponent(FolderComponent);
18 | component = fixture.componentInstance;
19 | fixture.detectChanges();
20 | });
21 |
22 | it('should create', () => {
23 | expect(component).toBeTruthy();
24 | });
25 | });
26 |
--------------------------------------------------------------------------------
/web/src/app/my-music/my-music.module.ts:
--------------------------------------------------------------------------------
1 | import {NgModule} from '@angular/core';
2 | import {SharedModule} from '@app/shared/shared.module';
3 |
4 | import {MyMusicComponent} from './my-music.component';
5 | import {TracksComponent} from './components/tracks.component';
6 | import {ArtistsComponent} from './components/artists.component';
7 | import {AlbumsComponent} from './components/albums.component';
8 | import {BoxListComponent} from '@app/my-music/components/shared/box-list.component';
9 |
10 | export const COMPONENTS = [
11 | MyMusicComponent,
12 | TracksComponent,
13 | ArtistsComponent,
14 | AlbumsComponent,
15 | BoxListComponent
16 | ];
17 |
18 | @NgModule({
19 | imports: [
20 | SharedModule
21 | ],
22 | declarations: COMPONENTS,
23 | exports: COMPONENTS,
24 | })
25 | export class MyMusicModule {
26 |
27 | }
28 |
--------------------------------------------------------------------------------
/web/src/app/app.serializer.ts:
--------------------------------------------------------------------------------
1 | import {Data, ParamMap, RouterStateSnapshot} from '@angular/router';
2 | import {RouterStateSerializer} from '@ngrx/router-store';
3 |
4 | export interface RouterStateUrl {
5 | url: string;
6 | params: ParamMap;
7 | queryParams: ParamMap;
8 | data: Data;
9 | }
10 |
11 | export class CustomSerializer implements RouterStateSerializer {
12 | serialize(routerState: RouterStateSnapshot): RouterStateUrl {
13 | let route = routerState.root;
14 | while (route.firstChild) {
15 | route = route.firstChild;
16 | }
17 | // Only return an object including the URL, params and query params instead of the entire snapshot
18 | return {
19 | url: routerState.url,
20 | params: route.paramMap,
21 | queryParams: route.queryParamMap,
22 | data: route.data
23 | };
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/web/src/app/shared/dialogs/confirm.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, Inject, OnInit} from '@angular/core';
2 | import {MAT_DIALOG_DATA} from '@angular/material';
3 |
4 | @Component({
5 | selector: 'app-confirm',
6 | template: `
7 | {{ data.title }}
8 |
11 |
12 | Cancel
13 | Yes
14 |
15 | `,
16 | styles: [``],
17 | changeDetection: ChangeDetectionStrategy.OnPush
18 | })
19 | export class ConfirmComponent implements OnInit {
20 |
21 | constructor(@Inject(MAT_DIALOG_DATA) public data: any) { }
22 |
23 | ngOnInit() {}
24 |
25 | }
26 |
--------------------------------------------------------------------------------
/web/ngsw-config.json:
--------------------------------------------------------------------------------
1 | {
2 | "appData": {
3 | "version": "1.0.0"
4 | },
5 | "index": "/index.html",
6 | "assetGroups": [{
7 | "name": "app",
8 | "installMode": "prefetch",
9 | "resources": {
10 | "files": [
11 | "/favicon.ico",
12 | "/index.html",
13 | "/*.css",
14 | "/*.js"
15 | ]
16 | }
17 | }, {
18 | "name": "assets",
19 | "installMode": "lazy",
20 | "updateMode": "prefetch",
21 | "resources": {
22 | "files": [
23 | "/**/*.woff2",
24 | "/assets/**"
25 | ]
26 | }
27 | }],
28 | "dataGroups": [{
29 | "name": "covers",
30 | "version": 0,
31 | "urls": [
32 | "/cache/covers/**"
33 | ],
34 | "cacheConfig": {
35 | "maxSize": 1000,
36 | "maxAge": "1h",
37 | "strategy": "performance"
38 | }
39 |
40 | }]
41 | }
42 |
--------------------------------------------------------------------------------
/web/src/app/player/player.module.ts:
--------------------------------------------------------------------------------
1 | import {NgModule} from '@angular/core';
2 | import {SharedModule} from '@app/shared/shared.module';
3 |
4 | import {PlayerComponent} from './player.component';
5 | import {PlayerHeaderComponent} from '@app/player/components/header.component';
6 | import {PlayerProgressComponent} from '@app/player/components/progress.component';
7 | import {PlayerControlsComponent} from '@app/player/components/controls.component';
8 | import {PlayerPlaylistComponent} from '@app/player/components/playlist.component';
9 |
10 | export const COMPONENTS = [
11 | PlayerComponent,
12 | PlayerHeaderComponent,
13 | PlayerProgressComponent,
14 | PlayerControlsComponent,
15 | PlayerPlaylistComponent
16 | ];
17 |
18 | @NgModule({
19 | imports: [
20 | SharedModule
21 | ],
22 | declarations: COMPONENTS,
23 | exports: COMPONENTS,
24 | })
25 | export class PlayerModule {
26 |
27 | }
28 |
--------------------------------------------------------------------------------
/web/src/app/my-music/my-music.theme.scss:
--------------------------------------------------------------------------------
1 | @import '~@angular/material/theming';
2 |
3 | @mixin my-music-component-theme($theme) {
4 |
5 | $primary: map-get($theme, primary);
6 | $accent: map-get($theme, accent);
7 | $warn: map-get($theme, warn);
8 | $foreground: map-get($theme, foreground);
9 | $background: map-get($theme, background);
10 |
11 | .my-music {
12 |
13 | .avatar-icon {
14 | color: mat-color($foreground, disabled-text);
15 | }
16 |
17 | .cover.noArt {
18 | border: 1px solid mat-color($foreground, divider);
19 | }
20 |
21 | mat-tab-header {
22 | position: fixed;
23 | width: 100%;
24 | z-index: 2;
25 | background-color: mat-color($background, background);
26 | }
27 |
28 | mat-tab-body {
29 | padding-top: 50px;
30 | }
31 |
32 | mat-row:hover {
33 | background-color: mat-color($background, hover);
34 | }
35 |
36 | }
37 |
38 | }
39 |
--------------------------------------------------------------------------------
/web/src/app/player/player.theme.scss:
--------------------------------------------------------------------------------
1 | @import '~@angular/material/theming';
2 |
3 | @mixin player-component-theme($theme) {
4 |
5 | $is-dark: map-get($theme, is-dark);
6 | $primary: map-get($theme, primary);
7 | $accent: map-get($theme, accent);
8 | $warn: map-get($theme, warn);
9 | $foreground: map-get($theme, foreground);
10 | $background: map-get($theme, background);
11 |
12 | .player-background {
13 | .b2 {
14 | background-color: rgba(mat-color($background, background), 0.75);
15 | }
16 | }
17 |
18 | .player {
19 |
20 | .no-art {
21 | border: 1px solid mat-color($foreground, disabled);
22 | }
23 |
24 | .cover mat-icon {
25 | color: mat-color($foreground, disabled);
26 | }
27 |
28 | mat-row:hover {
29 | background-color: mat-color($background, hover);
30 | }
31 |
32 | mat-row.current mat-cell {
33 | color: mat-color($primary);
34 | }
35 |
36 | }
37 |
38 | }
39 |
--------------------------------------------------------------------------------
/web/src/app/core/actions/core.actions.ts:
--------------------------------------------------------------------------------
1 | import {Action} from '@ngrx/store';
2 | import {Theme} from '@app/core/core.utils';
3 |
4 | export enum CoreActionTypes {
5 | OpenSidenav = 'core/sidenav/open',
6 | CloseSidenav = 'core/sidenav/close',
7 | ToggleSidenav = 'core/sidenav/toggle',
8 | ChangeTheme = 'core/theme',
9 | }
10 |
11 | export class OpenSidenav implements Action {
12 | readonly type = CoreActionTypes.OpenSidenav;
13 | }
14 |
15 | export class CloseSidenav implements Action {
16 | readonly type = CoreActionTypes.CloseSidenav;
17 | }
18 |
19 | export class ToggleSidenav implements Action {
20 | readonly type = CoreActionTypes.ToggleSidenav;
21 | }
22 |
23 | export class ChangeTheme implements Action {
24 | readonly type = CoreActionTypes.ChangeTheme;
25 | constructor(public payload: Theme) {}
26 | }
27 |
28 | export type CoreActionsUnion =
29 | OpenSidenav |
30 | CloseSidenav |
31 | ToggleSidenav |
32 | ChangeTheme;
33 |
--------------------------------------------------------------------------------
/web/src/app/library/actions/lyrics.actions.ts:
--------------------------------------------------------------------------------
1 | import {Action} from '@ngrx/store';
2 | import {Track} from '@app/model';
3 |
4 | export enum LyricsActionTypes {
5 | LoadLyrics = 'library/lyrics/load',
6 | LoadLyricsSuccess = 'library/lyrics/load/success',
7 | LoadLyricsFailure = 'library/lyrics/load/failure',
8 | }
9 |
10 | export class LoadLyrics implements Action {
11 | readonly type = LyricsActionTypes.LoadLyrics;
12 | constructor(public payload: Track) {}
13 | }
14 |
15 | export class LoadLyricsSuccess implements Action {
16 | readonly type = LyricsActionTypes.LoadLyricsSuccess;
17 | constructor(public lyrics: string, public source: string) {}
18 | }
19 |
20 | export class LoadLyricsFailure implements Action {
21 | readonly type = LyricsActionTypes.LoadLyricsFailure;
22 | constructor(public error: string) {}
23 | }
24 |
25 | export type LyricsActionsUnion =
26 | LoadLyrics |
27 | LoadLyricsSuccess |
28 | LoadLyricsFailure;
29 |
--------------------------------------------------------------------------------
/web/src/app/shared/pipes/file-size.pipe.ts:
--------------------------------------------------------------------------------
1 | import { Pipe, PipeTransform } from '@angular/core';
2 |
3 | // https://gist.github.com/JonCatmull/ecdf9441aaa37336d9ae2c7f9cb7289a
4 |
5 | /*
6 | * Convert bytes into largest possible unit.
7 | * Takes an precision argument that defaults to 2.
8 | * Usage:
9 | * bytes | fileSize:precision
10 | * Example:
11 | * {{ 1024 | fileSize}}
12 | * formats to: 1 KB
13 | */
14 | @Pipe({name: 'sgFileSize'})
15 | export class FileSizePipe implements PipeTransform {
16 |
17 | private units = [
18 | 'bytes',
19 | 'KB',
20 | 'MB',
21 | 'GB',
22 | 'TB',
23 | 'PB'
24 | ];
25 |
26 | transform(bytes: number = 0, precision: number = 2): string {
27 | if (isNaN(parseFloat(String(bytes))) || !isFinite(bytes)) { return '?'; }
28 |
29 | let unit = 0;
30 |
31 | while (bytes >= 1024) {
32 | bytes /= 1024;
33 | unit ++;
34 | }
35 |
36 | return bytes.toFixed(+precision) + ' ' + this.units[unit];
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/PRIVACY_POLICY.md:
--------------------------------------------------------------------------------
1 | Effective date: **July 12, 2018**
2 |
3 | Thank you for using or considering using Musicalypse.
4 |
5 | ### Musicalypse Privacy Statement
6 |
7 | Musicalypse does not collect any personal information.
8 |
9 | **However**, Musicalypse will scan the folder(s) you specify for audio files and will extract metadata from those files. Metadata thus extracted is stored in a local file which is accessible only to the user. This file is used by the application itself and will never be sent to Musicalypse maintainer or any third party in any way.
10 |
11 | **Moreover**, Musicalypse logs some debugging data in a local log file, like the number of songs in your library, the path to your library folders, or the name of the file or files which resulted in an error. This file **may** be sent to Musicalypse maintainer with the user's consent, for debugging purposes only.
12 |
13 | ### Contacting us
14 |
15 | If you have any question regarding our privacy policy you can send an email to contact@creasource.net
16 |
--------------------------------------------------------------------------------
/web/src/app/shared/shared.module.ts:
--------------------------------------------------------------------------------
1 | import {NgModule} from '@angular/core';
2 | import {CommonModule} from '@angular/common';
3 | import {HttpClientModule} from '@angular/common/http';
4 | import {FormsModule} from '@angular/forms';
5 | import {BrowserModule} from '@angular/platform-browser';
6 | import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
7 | import {RouterModule} from '@angular/router';
8 |
9 | import {MaterialModule} from './material/material.module';
10 | import {DialogsModule} from './dialogs/dialogs.module';
11 | import {PipesModule} from './pipes/pipes.module';
12 |
13 |
14 | export const SHARED_MODULES = [
15 | // Angular Modules
16 | CommonModule,
17 | HttpClientModule,
18 | FormsModule,
19 | BrowserModule,
20 | BrowserAnimationsModule,
21 | RouterModule,
22 |
23 | // My Modules
24 | MaterialModule,
25 | DialogsModule,
26 | PipesModule
27 | ];
28 |
29 | @NgModule({
30 | imports: SHARED_MODULES,
31 | exports: SHARED_MODULES
32 | })
33 | export class SharedModule { }
34 |
--------------------------------------------------------------------------------
/web/src/app/settings/reducers/lyrics.reducers.ts:
--------------------------------------------------------------------------------
1 | import {SettingsActionsUnion, SettingsActionTypes} from '../settings.actions';
2 | import {LyricsOptions} from '@app/model';
3 |
4 | /**
5 | * State
6 | */
7 | export interface State extends LyricsOptions {
8 | useService: boolean;
9 | services: {
10 | wikia: boolean;
11 | lyricsOvh: boolean;
12 | };
13 | automaticSave: boolean;
14 | }
15 |
16 | const initialState: State = {
17 | useService: true,
18 | services: {
19 | wikia: true,
20 | lyricsOvh: true
21 | },
22 | automaticSave: true
23 | };
24 |
25 | /**
26 | * Reducer
27 | */
28 | export function reducer(
29 | state: State = initialState,
30 | action: SettingsActionsUnion
31 | ): State {
32 | switch (action.type) {
33 |
34 | case SettingsActionTypes.SetLyricsOptions:
35 | return {
36 | ...state,
37 | ...action.payload
38 | };
39 |
40 | default:
41 | return state;
42 | }
43 | }
44 |
45 | /**
46 | * Selectors
47 | */
48 | export const getLyricsOptions = (state: State) => state;
49 |
--------------------------------------------------------------------------------
/web/src/app/settings/components/themes.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@angular/core';
2 | import {Theme} from '@app/core/core.utils';
3 |
4 | @Component({
5 | selector: 'app-themes',
6 | template: `
7 |
8 |
13 | {{ theme.name }}
14 |
15 |
16 | `,
17 | styles: [`
18 | mat-radio-button {
19 | display: block;
20 | margin-bottom: 1rem;
21 | padding-left: 1rem;
22 | }
23 | `],
24 | changeDetection: ChangeDetectionStrategy.OnPush
25 | })
26 | export class ThemesComponent {
27 |
28 | @Input() themes: Theme[];
29 | @Input() currentTheme: Theme;
30 |
31 | @Output() changeTheme = new EventEmitter();
32 |
33 | }
34 |
--------------------------------------------------------------------------------
/web/src/app/shared/dialogs/playlists-dialog.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, Inject} from '@angular/core';
2 | import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material';
3 |
4 | @Component({
5 | selector: 'app-playlists-dialog',
6 | template: `
7 | Save playlist
8 |
9 |
10 |
11 |
12 |
13 |
14 | Cancel
15 | Save
16 |
17 | `,
18 | styles: [``],
19 | changeDetection: ChangeDetectionStrategy.OnPush
20 | })
21 | export class PlaylistsDialogComponent {
22 |
23 | playlistName: '';
24 |
25 | constructor(
26 | public dialogRef: MatDialogRef,
27 | @Inject(MAT_DIALOG_DATA) public data: any
28 | ) { }
29 |
30 | cancel() {
31 | this.dialogRef.close();
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 Thomas Gambet
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/main/scala/net/creasource/http/actors/SocketSinkSupervisor.scala:
--------------------------------------------------------------------------------
1 | package net.creasource.http.actors
2 |
3 | import akka.actor.{Actor, OneForOneStrategy, Props, SupervisorStrategy}
4 | import akka.event.Logging
5 | import spray.json.JsonParser.ParsingException
6 |
7 | import scala.util.control.NonFatal
8 |
9 | object SocketSinkSupervisor {
10 | def props(): Props = Props(new SocketSinkSupervisor)
11 | }
12 |
13 | class SocketSinkSupervisor extends Actor {
14 |
15 | private val logger = Logging(context.system, this)
16 |
17 | override def receive: Receive = {
18 | case props: Props => sender() ! context.actorOf(props)
19 | }
20 |
21 | override val supervisorStrategy: OneForOneStrategy =
22 | OneForOneStrategy(loggingEnabled = true) {
23 | case p: ParsingException =>
24 | logger.error(p, "Sent message was not a correct json message. Resuming.")
25 | SupervisorStrategy.Resume
26 | case NonFatal(e) =>
27 | logger.error(e, "An Exception occurred in a SocketSinkActor. Terminating actor.")
28 | SupervisorStrategy.Stop
29 | case _ => SupervisorStrategy.Escalate
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/web/src/app/shared/dialogs/new-playlist-dialog.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, Inject} from '@angular/core';
2 | import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material';
3 |
4 | @Component({
5 | selector: 'app-new-playlist-dialog',
6 | template: `
7 | New Playlist
8 |
9 |
10 |
11 |
12 |
13 |
14 | Cancel
15 | Save
16 |
17 | `,
18 | styles: [``],
19 | changeDetection: ChangeDetectionStrategy.OnPush
20 | })
21 | export class NewPlaylistDialogComponent {
22 |
23 | playlistName: '';
24 |
25 | constructor(
26 | public dialogRef: MatDialogRef,
27 | @Inject(MAT_DIALOG_DATA) public data: any
28 | ) { }
29 |
30 | cancel() {
31 | this.dialogRef.close();
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/web/src/app/library/reducers/recent.reducer.ts:
--------------------------------------------------------------------------------
1 | import {Set} from 'immutable';
2 | import {ImmutableTrack, toImmutable, Track} from '@app/model';
3 | import {RecentActionsUnion, RecentActionTypes} from '@app/library/actions/recent.actions';
4 |
5 | export interface State {
6 | recentTracks: Set;
7 | }
8 |
9 | export const initialState: State = {
10 | recentTracks: Set()
11 | };
12 |
13 | export function reducer(
14 | state = initialState,
15 | action: RecentActionsUnion
16 | ): State {
17 | switch (action.type) {
18 |
19 | case RecentActionTypes.AddToRecent: {
20 | let result = state.recentTracks;
21 | toImmutable(action.payload)
22 | .filter(track => !result.contains(track))
23 | .forEach(track => result = result.add(track));
24 | if (result.size > 50) {
25 | result = result.slice(1, result.size);
26 | }
27 | return {
28 | ...state,
29 | recentTracks: result
30 | };
31 | }
32 |
33 | default:
34 | return state;
35 | }
36 | }
37 |
38 | export const getRecentTracks = (state: State) => state.recentTracks.toJS() as Track[];
39 |
40 |
41 |
--------------------------------------------------------------------------------
/web/src/karma.conf.js:
--------------------------------------------------------------------------------
1 | // Karma configuration file, see link for more information
2 | // https://karma-runner.github.io/1.0/config/configuration-file.html
3 |
4 | module.exports = function (config) {
5 | config.set({
6 | basePath: '',
7 | frameworks: ['jasmine', '@angular-devkit/build-angular'],
8 | plugins: [
9 | require('karma-jasmine'),
10 | require('karma-chrome-launcher'),
11 | require('karma-jasmine-html-reporter'),
12 | require('karma-coverage-istanbul-reporter'),
13 | require('@angular-devkit/build-angular/plugins/karma')
14 | ],
15 | client: {
16 | clearContext: false // leave Jasmine Spec Runner output visible in browser
17 | },
18 | coverageIstanbulReporter: {
19 | dir: require('path').join(__dirname, 'coverage'),
20 | reports: ['html', 'lcovonly'],
21 | fixWebpackSourcePaths: true
22 | },
23 | angularCli: {
24 | environment: 'dev'
25 | },
26 | reporters: ['progress', 'kjhtml'],
27 | port: 9876,
28 | colors: true,
29 | logLevel: config.LOG_INFO,
30 | autoWatch: true,
31 | browsers: ['Chrome'],
32 | singleRun: false
33 | });
34 | };
35 |
--------------------------------------------------------------------------------
/web/src/app/shared/dialogs/dialogs.module.ts:
--------------------------------------------------------------------------------
1 | import {NgModule} from '@angular/core';
2 | import {CommonModule} from '@angular/common';
3 | import {RouterModule} from '@angular/router';
4 |
5 | import {MaterialModule} from '@app/shared/material/material.module';
6 | import {FormsModule} from '@angular/forms';
7 | import {ConfirmComponent} from './confirm.component';
8 | import {DetailsComponent} from './details.component';
9 | import {FolderComponent} from './folder/folder.component';
10 | import {PlaylistsDialogComponent} from './playlists-dialog.component';
11 | import {NewPlaylistDialogComponent} from '@app/shared/dialogs/new-playlist-dialog.component';
12 | import {InfoComponent} from '@app/shared/dialogs/info.component';
13 |
14 | export const COMPONENTS = [
15 | ConfirmComponent,
16 | DetailsComponent,
17 | FolderComponent,
18 | InfoComponent,
19 | PlaylistsDialogComponent,
20 | NewPlaylistDialogComponent
21 | ];
22 |
23 | @NgModule({
24 | imports: [
25 | CommonModule,
26 | MaterialModule,
27 | RouterModule,
28 | FormsModule
29 | ],
30 | entryComponents: COMPONENTS,
31 | declarations: COMPONENTS,
32 | exports: COMPONENTS,
33 | })
34 | export class DialogsModule {}
35 |
--------------------------------------------------------------------------------
/web/src/app/routes.ts:
--------------------------------------------------------------------------------
1 | import {Routes} from '@angular/router';
2 | import {LibraryComponent} from './library/library.component';
3 | import {SettingsComponent} from './settings/settings.component';
4 | import {AboutComponent} from './core/components/about.component';
5 | import {PlayerComponent} from '@app/player/player.component';
6 | // import {MyMusicComponent} from '@app/my-music/my-music.component';
7 | import {PlaylistsComponent} from '@app/playlists/playlists.component';
8 | // import {EditorComponent} from '@app/editor/editor.component';
9 |
10 | export const routes: Routes = [
11 | { path: '', redirectTo: '/library', pathMatch: 'full' },
12 | { path: 'playing', component: PlayerComponent },
13 | // { path: 'mymusic', component: MyMusicComponent },
14 | { path: 'playlists', component: PlaylistsComponent },
15 | { path: 'library', component: LibraryComponent },
16 | { path: 'recent', component: LibraryComponent, data: { recent: true } },
17 | { path: 'favorites', component: LibraryComponent, data: { favorites: true } },
18 | // { path: 'editor', component: EditorComponent },
19 | { path: 'settings', component: SettingsComponent },
20 | { path: 'about', component: AboutComponent },
21 | ];
22 |
--------------------------------------------------------------------------------
/web/src/test.ts:
--------------------------------------------------------------------------------
1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files
2 |
3 | import 'zone.js/dist/long-stack-trace-zone';
4 | import 'zone.js/dist/proxy.js';
5 | import 'zone.js/dist/sync-test';
6 | import 'zone.js/dist/jasmine-patch';
7 | import 'zone.js/dist/async-test';
8 | import 'zone.js/dist/fake-async-test';
9 | import { getTestBed } from '@angular/core/testing';
10 | import {
11 | BrowserDynamicTestingModule,
12 | platformBrowserDynamicTesting
13 | } from '@angular/platform-browser-dynamic/testing';
14 |
15 | // Unfortunately there's no typing for the `__karma__` variable. Just declare it as any.
16 | declare const __karma__: any;
17 | declare const require: any;
18 |
19 | // Prevent Karma from running prematurely.
20 | __karma__.loaded = function () {};
21 |
22 | // First, initialize the Angular testing environment.
23 | getTestBed().initTestEnvironment(
24 | BrowserDynamicTestingModule,
25 | platformBrowserDynamicTesting()
26 | );
27 | // Then we find all the tests.
28 | const context = require.context('./', true, /\.spec\.ts$/);
29 | // And load the modules.
30 | context.keys().map(context);
31 | // Finally, start Karma to run the tests.
32 | __karma__.start();
33 |
--------------------------------------------------------------------------------
/src/test/scala/net/creasource/TagExtractorTest.scala:
--------------------------------------------------------------------------------
1 | package net.creasource
2 |
3 | import java.io.File
4 |
5 | import akka.Done
6 | import akka.actor.ActorSystem
7 | import akka.stream.ActorMaterializer
8 | import akka.stream.scaladsl.Sink
9 | import net.creasource.audio._
10 |
11 | import scala.concurrent.{Await, Future}
12 |
13 | class TagExtractorTest extends SimpleTest {
14 |
15 | "A LibraryScanner" should {
16 |
17 | "find audio files in a folder" in {
18 |
19 | //val scanner = new LibraryScanner(new File("D:\\Musique\\Metallica"))
20 |
21 | //val files = scanner.getAudioFiles(new File("C:\\Users\\Thomas\\Workspace\\musicalypse\\web\\src\\assets\\music"))
22 | //files.foreach(println)
23 | //val metas = scanner.scanLibrary(new File("D:\\Musique\\Metallica"))
24 | //metas.foreach(println)
25 |
26 | // implicit val actorSystem: ActorSystem = ActorSystem()
27 | // implicit val materializer: ActorMaterializer = ActorMaterializer()
28 | //
29 | // val sink = Sink.foreach[TrackMetadata](m => println(m))
30 | // val f: Future[Done] = LibraryScanner.scan(new File("D:\\Musique\\Metallica")).runWith(sink)
31 | //
32 | // import scala.concurrent.duration._
33 | // Await.result(f, 10.seconds)
34 |
35 | }
36 |
37 | }
38 |
39 | }
40 |
41 |
--------------------------------------------------------------------------------
/web/src/app/core/services/update.service.ts:
--------------------------------------------------------------------------------
1 | import {Injectable} from '@angular/core';
2 | import {SwUpdate} from '@angular/service-worker';
3 | import {MatDialog} from '@angular/material';
4 | import {ConfirmComponent} from '@app/shared/dialogs/confirm.component';
5 |
6 | @Injectable()
7 | export class UpdateService {
8 |
9 | constructor(private updates: SwUpdate, private dialog: MatDialog) {}
10 |
11 | initialize(): void {
12 | this.updates.available.subscribe(event => {
13 | // console.log('current version is', event.current);
14 | // console.log('available version is', event.available);
15 | const dialogRef = this.dialog.open(ConfirmComponent, {
16 | data: {
17 | title: 'New version available!',
18 | message: `Do you want to activate the new version of Musicalypse (${event.current.appData['version']})?`
19 | }
20 | });
21 | dialogRef.afterClosed().subscribe(confirmed => {
22 | if (confirmed) {
23 | this.updates.activateUpdate().then(() => document.location.reload());
24 | }
25 | });
26 | });
27 | /*this.updates.activated.subscribe(event => {
28 | // console.log('old version was', event.previous);
29 | // console.log('new version is', event.current);
30 | });*/
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/web/src/app/library/reducers/favorites.reducers.ts:
--------------------------------------------------------------------------------
1 | import {Set} from 'immutable';
2 | import {ImmutableTrack, toImmutable, Track} from '@app/model';
3 | import {FavoritesActionsUnion, FavoritesActionTypes} from '../actions/favorites.actions';
4 |
5 | export interface State {
6 | favorites: Set;
7 | }
8 |
9 | export const initialState: State = {
10 | favorites: Set()
11 | };
12 |
13 | export function reducer(
14 | state = initialState,
15 | action: FavoritesActionsUnion
16 | ): State {
17 | switch (action.type) {
18 |
19 | case FavoritesActionTypes.AddToFavorites: {
20 | const tracksToAdd = action.payload.map(toImmutable);
21 | return {
22 | ...state,
23 | favorites: state.favorites.concat(tracksToAdd)
24 | };
25 | }
26 |
27 | case FavoritesActionTypes.RemoveFromFavorites: {
28 | const track = toImmutable(action.payload);
29 | return {
30 | ...state,
31 | favorites: state.favorites.contains(track) ? state.favorites.delete(track) : state.favorites
32 | };
33 | }
34 |
35 | default:
36 | return state;
37 | }
38 | }
39 |
40 | export const getFavorites = (state: State) => state.favorites.toJS() as Track[];
41 | export const isFavorite = (state: State, track: Track) => state.favorites.contains(toImmutable(track));
42 |
43 |
44 |
--------------------------------------------------------------------------------
/web/src/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Musicalypse",
3 | "short_name": "Musicalypse",
4 | "theme_color": "#303030",
5 | "background_color": "#303030",
6 | "display": "standalone",
7 | "orientation": "portrait",
8 | "scope": "/",
9 | "start_url": "/",
10 | "icons": [
11 | {
12 | "src": "assets/icons/icon-72x72.png",
13 | "sizes": "72x72",
14 | "type": "image/png"
15 | },
16 | {
17 | "src": "assets/icons/icon-96x96.png",
18 | "sizes": "96x96",
19 | "type": "image/png"
20 | },
21 | {
22 | "src": "assets/icons/icon-128x128.png",
23 | "sizes": "128x128",
24 | "type": "image/png"
25 | },
26 | {
27 | "src": "assets/icons/icon-144x144.png",
28 | "sizes": "144x144",
29 | "type": "image/png"
30 | },
31 | {
32 | "src": "assets/icons/icon-152x152.png",
33 | "sizes": "152x152",
34 | "type": "image/png"
35 | },
36 | {
37 | "src": "assets/icons/icon-192x192.png",
38 | "sizes": "192x192",
39 | "type": "image/png"
40 | },
41 | {
42 | "src": "assets/icons/icon-384x384.png",
43 | "sizes": "384x384",
44 | "type": "image/png"
45 | },
46 | {
47 | "src": "assets/icons/icon-512x512.png",
48 | "sizes": "512x512",
49 | "type": "image/png"
50 | }
51 | ]
52 | }
53 |
--------------------------------------------------------------------------------
/src/main/scala/net/creasource/core/Application.scala:
--------------------------------------------------------------------------------
1 | package net.creasource.core
2 |
3 | import akka.actor.{ActorRef, ActorSystem}
4 | import akka.stream.ActorMaterializer
5 | import com.typesafe.config.{Config, ConfigFactory}
6 | import net.creasource.web.{LibraryActor, LyricsActor, SettingsActor}
7 |
8 | import scala.concurrent.Await
9 |
10 | object Application {
11 |
12 | def apply() = new Application
13 |
14 | }
15 |
16 | /**
17 | * Represents an application. This is where you'll instantiate your top actors, connect to a database, etc...
18 | */
19 | class Application {
20 |
21 | val config: Config = ConfigFactory.load()
22 |
23 | implicit val system: ActorSystem = ActorSystem("MySystem", config)
24 | implicit val materializer: ActorMaterializer = ActorMaterializer()
25 |
26 | system.log.info("Application starting.")
27 |
28 | val libraryActor: ActorRef = system.actorOf(LibraryActor.props()(this), "library")
29 | val settingsActor: ActorRef = system.actorOf(SettingsActor.props()(this), "settings")
30 | val lyricsActor: ActorRef = system.actorOf(LyricsActor.props()(this), "lyrics")
31 |
32 | def shutdown() {
33 | system.log.info("Shutting down Akka materializer and system.")
34 | import scala.concurrent.duration._
35 | materializer.shutdown()
36 | Await.result(system.terminate(), 30.seconds)
37 | }
38 |
39 | }
40 |
--------------------------------------------------------------------------------
/web/src/app/settings/settings.module.ts:
--------------------------------------------------------------------------------
1 | import {NgModule} from '@angular/core';
2 | import {StoreModule} from '@ngrx/store';
3 | import {EffectsModule} from '@ngrx/effects';
4 |
5 | import {SharedModule} from '@app/shared/shared.module';
6 |
7 | import {LibraryFoldersComponent} from './components/library-folders.component';
8 | import {SettingsComponent} from './settings.component';
9 | import {UploadsComponent} from './components/uploads.component';
10 | import {ThemesComponent} from './components/themes.component';
11 |
12 | import {SettingsService} from './services/settings.service';
13 |
14 | import {SettingsEffects} from './settings.effects';
15 | import {reducers} from './settings.reducers';
16 | import {LyricsOptionsComponent} from './components/lyrics-options.component';
17 | import {CoreModule} from '@app/core/core.module';
18 |
19 | export const COMPONENTS = [
20 | SettingsComponent,
21 | LibraryFoldersComponent,
22 | UploadsComponent,
23 | ThemesComponent,
24 | LyricsOptionsComponent
25 | ];
26 |
27 | @NgModule({
28 | imports: [
29 | SharedModule,
30 | CoreModule,
31 | StoreModule.forFeature('settings', reducers),
32 | EffectsModule.forFeature([SettingsEffects]),
33 | ],
34 | declarations: COMPONENTS,
35 | exports: COMPONENTS,
36 | providers: [
37 | SettingsService
38 | ]
39 | })
40 | export class SettingsModule {}
41 |
--------------------------------------------------------------------------------
/web/src/app/model.ts:
--------------------------------------------------------------------------------
1 | import {fromJS, Map, Set} from 'immutable';
2 |
3 | // https://stackoverflow.com/questions/43607652/typescript-immutable-proper-way-of-extending-immutable-map-type
4 |
5 | interface ImmutableMap extends Map {}
6 |
7 | export function toImmutable (o: T) {
8 | return fromJS(o) as ImmutableMap;
9 | }
10 |
11 | export interface Track {
12 | url: string;
13 | coverUrl?: string;
14 | location: string;
15 | title: string;
16 | album: string;
17 | artist: string;
18 | albumArtist: string;
19 | year: string;
20 | duration: number;
21 | // warn?: boolean;
22 | }
23 |
24 | export type ImmutableTrack = ImmutableMap;
25 |
26 | export interface Artist {
27 | name: string;
28 | songs: number;
29 | avatarUrl?: string;
30 | // warn?: boolean;
31 | }
32 |
33 | export interface Album {
34 | artist: string;
35 | title: string;
36 | songs: number;
37 | avatarUrl?: string;
38 | // warn?: boolean;
39 | }
40 |
41 | export interface Playlist {
42 | name: string;
43 | tracks: Track[];
44 | }
45 |
46 | export type ImmutablePlaylist = ImmutableMap<{
47 | name: string;
48 | tracks: Set;
49 | }>;
50 |
51 | export interface LyricsOptions {
52 | useService: boolean;
53 | services: {
54 | wikia: boolean;
55 | lyricsOvh: boolean;
56 | };
57 | automaticSave: boolean;
58 | }
59 |
--------------------------------------------------------------------------------
/web/src/app/player/components/status.component.ts:
--------------------------------------------------------------------------------
1 | /*
2 | import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
3 | import {Track} from '@app/model';
4 |
5 | @Component({
6 | selector: 'app-player-status',
7 | template: `
8 |
9 | {{ getCurrentTrackPosition() }}/{{ playlist.length }} tracks
10 | Total duration: {{ getTotalDuration() | sgTime }}
11 |
12 | `,
13 | styles: [`
14 | .status-bar {
15 | display: flex;
16 | flex-direction: row;
17 | align-items: center;
18 | padding: .5rem 1rem .5rem 1rem;
19 | font-weight: 300;
20 | }
21 | .status-position {
22 | }
23 | .status-total-time {
24 | margin-left: auto;
25 | }
26 | `],
27 | changeDetection: ChangeDetectionStrategy.OnPush
28 | })
29 | export class PlayerStatusComponent {
30 |
31 | @Input() playlist: Track[];
32 | @Input() currentTrack: Track;
33 |
34 | getTotalDuration(): number {
35 | return this.playlist.reduce((total, track) => total + track.duration, 0);
36 | }
37 |
38 | getCurrentTrackPosition(): number {
39 | if (this.currentTrack) {
40 | return this.playlist.findIndex(track => track.url === this.currentTrack.url) + 1;
41 | } else {
42 | return 0;
43 | }
44 | }
45 |
46 | }
47 | */
48 |
--------------------------------------------------------------------------------
/web/src/app/core/reducers/core.reducers.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * State
3 | */
4 | import {CoreUtils, Theme} from '@app/core/core.utils';
5 | import {CoreActionsUnion, CoreActionTypes} from '@app/core/actions/core.actions';
6 |
7 | export interface State {
8 | showSidenav: boolean;
9 | currentTheme: Theme;
10 | }
11 |
12 | const initialState: State = {
13 | showSidenav: false,
14 | currentTheme: CoreUtils.allThemes[0],
15 | };
16 |
17 | /**
18 | * Reducer
19 | */
20 | export function reducer(
21 | state: State = initialState,
22 | action: CoreActionsUnion
23 | ): State {
24 | switch (action.type) {
25 |
26 | case CoreActionTypes.OpenSidenav:
27 | return {
28 | ...state,
29 | showSidenav: true,
30 | };
31 |
32 | case CoreActionTypes.CloseSidenav:
33 | return {
34 | ...state,
35 | showSidenav: false,
36 | };
37 |
38 | case CoreActionTypes.ToggleSidenav:
39 | return {
40 | ...state,
41 | showSidenav: !state.showSidenav,
42 | };
43 |
44 | case CoreActionTypes.ChangeTheme:
45 | return {
46 | ...state,
47 | currentTheme: action.payload,
48 | };
49 |
50 | default:
51 | return state;
52 | }
53 | }
54 |
55 | /**
56 | * Selectors
57 | */
58 | export const getShowSidenav = (state: State) => state.showSidenav;
59 | export const getCurrentTheme = (state: State) => state.currentTheme;
60 |
--------------------------------------------------------------------------------
/web/src/app/my-music/my-music.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component} from '@angular/core';
2 | import {LibraryService} from '@app/library/services/library.service';
3 | import {Observable} from 'rxjs';
4 | import {Album, Artist, Track} from '@app/model';
5 |
6 | @Component({
7 | selector: 'app-my-music',
8 | template: `
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | `,
26 | styles: [`
27 | .my-music {
28 | }
29 | `],
30 | changeDetection: ChangeDetectionStrategy.OnPush
31 | })
32 | export class MyMusicComponent {
33 |
34 | tracks$: Observable;
35 | artists$: Observable;
36 | albums$: Observable;
37 |
38 | constructor(private library: LibraryService) {
39 | this.tracks$ = library.getAllTracks();
40 | this.artists$ = library.getAllArtists();
41 | this.albums$ = library.getAllAlbums();
42 | }
43 |
44 | }
45 |
--------------------------------------------------------------------------------
/src/main/scala/net/creasource/io/WatchActor.scala:
--------------------------------------------------------------------------------
1 | package net.creasource.io
2 |
3 | import akka.actor.{Actor, Props}
4 | import akka.event.{Logging, LoggingReceive}
5 | import akka.stream.Materializer
6 | import net.creasource.io.FileSystemChange.WatchDir
7 |
8 | /**
9 | * This file is copy/pasted from https://github.com/nurkiewicz/learning-akka
10 | * It is released under the Apache 2 License (https://github.com/nurkiewicz/learning-akka/blob/master/license.txt)
11 | * Some minor modifications have been made, including changing the package name, and adding a companion object.
12 | */
13 |
14 | object WatchActor {
15 | def props()(implicit materializer: Materializer): Props = Props(new WatchActor()(materializer))
16 | }
17 |
18 | class WatchActor()(implicit materializer: Materializer) extends Actor {
19 |
20 | import context.dispatcher
21 |
22 | val log = Logging(context.system, this)
23 | val watchService = new WatchService(self, log)
24 | val watchThread = new Thread(watchService, "WatchService")
25 |
26 | override def preStart() {
27 | watchThread.setDaemon(true)
28 | watchThread.start()
29 | }
30 |
31 | override def postStop() {
32 | watchThread.interrupt()
33 | }
34 |
35 | def receive = LoggingReceive {
36 |
37 | case WatchDir(path) =>
38 | val s = sender()
39 | (watchService watch path).map(s ! _)
40 |
41 | case change: FileSystemChange.FileSystemChange => context.parent ! change
42 |
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/web/src/app/core/services/electron.service.ts:
--------------------------------------------------------------------------------
1 | import {Injectable} from '@angular/core';
2 | import {environment} from '@env/environment';
3 |
4 | @Injectable()
5 | export class ElectronService {
6 |
7 | private ipcRenderer: any;
8 | private remote: any;
9 | private shell: any;
10 |
11 | constructor() {
12 | if (environment.electron) {
13 | const electron = (window).require('electron');
14 | this.ipcRenderer = electron.ipcRenderer;
15 | this.remote = electron.remote;
16 | this.shell = electron.shell;
17 | }
18 | }
19 |
20 | onIpc(channel: string, listener: (event, ...args) => void): void {
21 | if (environment.electron) {
22 | this.ipcRenderer.on(channel, listener);
23 | }
24 | }
25 |
26 | removeIpc(channel?: string): void {
27 | if (environment.electron) {
28 | this.ipcRenderer.removeAllListeners(channel);
29 | }
30 | }
31 |
32 | send(message: string): void {
33 | if (environment.electron) {
34 | this.ipcRenderer.send(message);
35 | }
36 | }
37 |
38 | onWindow(event: string, listener: () => void): void {
39 | if (environment.electron) {
40 | this.remote.getCurrentWindow().addListener(event, listener);
41 | }
42 | }
43 |
44 | getWindow(): any {
45 | if (environment.electron) {
46 | return this.remote.getCurrentWindow();
47 | }
48 | }
49 |
50 | openExternal(url: string): void {
51 | this.shell.openExternal(url);
52 | }
53 |
54 | }
55 |
--------------------------------------------------------------------------------
/src/main/scala/net/creasource/http/SPAWebServer.scala:
--------------------------------------------------------------------------------
1 | package net.creasource.http
2 |
3 | import akka.http.scaladsl.model.StatusCodes
4 | import akka.http.scaladsl.model.headers.RawHeader
5 | import akka.http.scaladsl.server.Directives._
6 | import akka.http.scaladsl.server._
7 |
8 | /**
9 | * A Single Page Application WebServer. Serves files that are found in a resource "web" directory statically and
10 | * fallbacks to serving index.html when a file is not found.
11 | */
12 | trait SPAWebServer extends WebServer { self: WebServer =>
13 |
14 | override def routes: Route =
15 | encodeResponse {
16 | headerValueByName("Accept") { accept =>
17 | val serveIndexIfNotFound: RejectionHandler =
18 | RejectionHandler.newBuilder()
19 | .handleNotFound {
20 | if (accept.contains("text/html")) { // || accept.contains("*/*")) {
21 | respondWithHeader(RawHeader("Cache-Control", "no-cache")) {
22 | getFromResource("web/index.html")
23 | }
24 | } else {
25 | complete(StatusCodes.NotFound, "The requested resource could not be found.")
26 | }
27 | }
28 | .result()
29 | handleRejections(serveIndexIfNotFound) {
30 | respondWithHeader(RawHeader("Cache-Control", "max-age=86400")) {
31 | getFromResourceDirectory("web")
32 | }
33 | }
34 | }
35 | } ~ super.routes
36 |
37 | }
38 |
--------------------------------------------------------------------------------
/src/main/scala/net/creasource/http/WebServer.scala:
--------------------------------------------------------------------------------
1 | package net.creasource.http
2 |
3 | import akka.Done
4 | import akka.actor.ActorSystem
5 | import akka.http.scaladsl.Http
6 | import akka.http.scaladsl.server._
7 | import akka.http.scaladsl.server.Directives._
8 | import akka.http.scaladsl.server.RouteResult.route2HandlerFlow
9 | import akka.stream.ActorMaterializer
10 |
11 | import scala.concurrent.{ExecutionContext, Future}
12 |
13 | /**
14 | * A Simple WebServer trait that uses akka-http API.
15 | */
16 | trait WebServer {
17 |
18 | implicit val system: ActorSystem
19 |
20 | implicit lazy val materializer: ActorMaterializer = ActorMaterializer()
21 |
22 | implicit protected lazy val dispatcher: ExecutionContext = system.dispatcher
23 |
24 | private var bindingFuture: Future[Http.ServerBinding] = _
25 |
26 | def routes: Route = reject
27 |
28 | def start(host: String, port: Int): Future[Unit] = {
29 | bindingFuture = Http().bindAndHandle(route2HandlerFlow(routes), host, port)
30 | bindingFuture.failed.foreach { ex =>
31 | system.log.error(ex, "Failed to bind to {}:{}!", host, port)
32 | }
33 | bindingFuture map { _ =>
34 | system.log.info("Server online at http://{}:{}/", host, port)
35 | }
36 | }
37 |
38 | def stop(): Future[Done] = {
39 | require(bindingFuture != null, "No binding found. Have you called start() before?")
40 | system.log.info("Unbinding.")
41 | bindingFuture.flatMap(_.unbind())
42 | }
43 |
44 | }
45 |
46 |
47 |
48 |
--------------------------------------------------------------------------------
/web/src/app/library/actions/tracks.actions.ts:
--------------------------------------------------------------------------------
1 | import {Action} from '@ngrx/store';
2 | import {Track} from '@app/model';
3 |
4 | export enum TracksActionTypes {
5 | AddTracks = 'library/tracks/add',
6 | RemoveTracks = 'library/tracks/remove',
7 | LoadTracks = 'library/tracks/load',
8 | LoadTracksSuccess = 'library/tracks/load/success',
9 | LoadTracksFailure = 'library/tracks/load/failure',
10 | ScanTracks = 'library/tracks/scan',
11 | }
12 |
13 | export class AddTracks implements Action {
14 | readonly type = TracksActionTypes.AddTracks;
15 | constructor(public payload: Track[]) {}
16 | }
17 |
18 | export class RemoveTracks implements Action {
19 | readonly type = TracksActionTypes.RemoveTracks;
20 | constructor(public payload: Track[]) {}
21 | }
22 |
23 | export class LoadTracks implements Action {
24 | readonly type = TracksActionTypes.LoadTracks;
25 | }
26 |
27 | export class LoadTrackSuccess implements Action {
28 | readonly type = TracksActionTypes.LoadTracksSuccess;
29 | constructor(public payload: Track[]) {}
30 | }
31 |
32 | export class LoadTrackFailure implements Action {
33 | readonly type = TracksActionTypes.LoadTracksFailure;
34 | constructor(public payload: string) {}
35 | }
36 |
37 | export class ScanTracks implements Action {
38 | readonly type = TracksActionTypes.ScanTracks;
39 | }
40 |
41 | export type TracksActionsUnion =
42 | AddTracks |
43 | RemoveTracks |
44 | LoadTracks |
45 | LoadTrackSuccess |
46 | LoadTrackFailure |
47 | ScanTracks;
48 |
--------------------------------------------------------------------------------
/web/src/app/settings/settings.reducers.ts:
--------------------------------------------------------------------------------
1 | import {ActionReducerMap, createFeatureSelector, createSelector} from '@ngrx/store';
2 |
3 | import * as fromLibraryFolders from './reducers/libray.reducers';
4 | import * as fromLyrics from './reducers/lyrics.reducers';
5 | import * as fromRoot from '@app/app.reducers';
6 |
7 | export interface SettingsState {
8 | library: fromLibraryFolders.State;
9 | lyrics: fromLyrics.State;
10 | }
11 |
12 | export interface State extends fromRoot.State {
13 | settings: SettingsState;
14 | }
15 |
16 | export const reducers: ActionReducerMap = {
17 | library: fromLibraryFolders.reducer,
18 | lyrics: fromLyrics.reducer
19 | };
20 |
21 | export const getSettingsState = createFeatureSelector('settings');
22 |
23 | export const getLibraryFoldersState = createSelector(
24 | getSettingsState,
25 | state => state.library
26 | );
27 |
28 | export const getLyricsState = createSelector(
29 | getSettingsState,
30 | state => state.lyrics
31 | );
32 |
33 | export const getLibraryFolders = createSelector(
34 | getLibraryFoldersState,
35 | fromLibraryFolders.getLibraryFolders
36 | );
37 |
38 | export const getSettingsError = createSelector(
39 | getLibraryFoldersState,
40 | fromLibraryFolders.getError
41 | );
42 |
43 | export const getSettingsLoading = createSelector(
44 | getLibraryFoldersState,
45 | fromLibraryFolders.getLoading
46 | );
47 |
48 | export const getLyricsOptions = createSelector(
49 | getLyricsState,
50 | fromLyrics.getLyricsOptions
51 | );
52 |
--------------------------------------------------------------------------------
/web/src/app/library/reducers/lyrics.reducers.ts:
--------------------------------------------------------------------------------
1 | import {LyricsActionsUnion, LyricsActionTypes} from '../actions/lyrics.actions';
2 |
3 | export interface State {
4 | loading: boolean;
5 | lyrics: string;
6 | error: string;
7 | source: string;
8 | }
9 |
10 | export const initialState: State = {
11 | loading: false,
12 | lyrics: null,
13 | error: null,
14 | source: null
15 | };
16 |
17 | export function reducer(
18 | state = initialState,
19 | action: LyricsActionsUnion
20 | ): State {
21 | switch (action.type) {
22 |
23 | case LyricsActionTypes.LoadLyrics: {
24 | return {
25 | ...state,
26 | loading: true,
27 | lyrics: null,
28 | error: null,
29 | source: null
30 | };
31 | }
32 |
33 | case LyricsActionTypes.LoadLyricsSuccess: {
34 | return {
35 | ...state,
36 | loading: false,
37 | lyrics: action.lyrics,
38 | error: null,
39 | source: action.source
40 | };
41 | }
42 |
43 | case LyricsActionTypes.LoadLyricsFailure: {
44 | return {
45 | ...state,
46 | loading: false,
47 | lyrics: null,
48 | error: action.error,
49 | source: null
50 | };
51 | }
52 |
53 | default:
54 | return state;
55 | }
56 | }
57 |
58 | export const getLoading = (state: State) => state.loading;
59 | export const getLyrics = (state: State) => state.lyrics;
60 | export const getError = (state: State) => state.error;
61 | export const getSource = (state: State) => state.source;
62 |
63 |
64 |
--------------------------------------------------------------------------------
/web/src/app/app.reducers.ts:
--------------------------------------------------------------------------------
1 | import {ActionReducerMap, createFeatureSelector, createSelector, MetaReducer} from '@ngrx/store';
2 | import {environment} from '@env/environment';
3 | import {routerReducer, RouterReducerState} from '@ngrx/router-store';
4 | import {RouterStateUrl} from '@app/app.serializer';
5 |
6 | export interface State {
7 | router: RouterReducerState;
8 | }
9 |
10 | /**
11 | * Our state is composed of a map of action reducer functions.
12 | * These reducer functions are called with each dispatched action
13 | * and the current or initial state and return a new immutable state.
14 | */
15 | export const reducers: ActionReducerMap = {
16 | router: routerReducer
17 | };
18 |
19 | // console.log all actions
20 | // export function logger(reducer: ActionReducer): ActionReducer {
21 | // return function(state: State, action: any): State {
22 | // console.log('state', state);
23 | // console.log('action', action);
24 | //
25 | // return reducer(state, action);
26 | // };
27 | // }
28 |
29 | /**
30 | * By default, @ngrx/store uses combineReducers with the reducer map to compose
31 | * the root meta-reducer. To add more meta-reducers, provide an array of meta-reducers
32 | * that will be composed to form the root meta-reducer.
33 | */
34 | export const metaReducers: MetaReducer[] = !environment.production ? [/*logger*/] : [];
35 |
36 | export const getRouterState = createSelector(
37 | createFeatureSelector>('router'),
38 | (state) => state.state
39 | );
40 |
--------------------------------------------------------------------------------
/web/src/app/library/actions/albums.actions.ts:
--------------------------------------------------------------------------------
1 | import {Action} from '@ngrx/store';
2 | import {Album} from '@app/model';
3 |
4 | export enum AlbumsActionTypes {
5 | LoadAlbums = 'library/albums/load',
6 | SelectAlbum = 'library/albums/select-add',
7 | SelectAlbums = 'library/albums/select',
8 | SelectAlbumsByIds = 'library/albums/select-by-id',
9 | DeselectAlbum = 'library/albums/deselect',
10 | DeselectAllAlbums = 'library/albums/deselect-all',
11 | }
12 |
13 | export class LoadAlbums implements Action {
14 | readonly type = AlbumsActionTypes.LoadAlbums;
15 | constructor(public payload: Album[]) {}
16 | }
17 |
18 | export class SelectAlbum implements Action {
19 | readonly type = AlbumsActionTypes.SelectAlbum;
20 | constructor(public payload: Album) {}
21 | }
22 |
23 | export class SelectAlbums implements Action {
24 | readonly type = AlbumsActionTypes.SelectAlbums;
25 | constructor(public payload: Album[]) {}
26 | }
27 |
28 | export class SelectAlbumsByIds implements Action {
29 | readonly type = AlbumsActionTypes.SelectAlbumsByIds;
30 | constructor(public payload: (string | number)[]) {}
31 | }
32 |
33 | export class DeselectAlbum implements Action {
34 | readonly type = AlbumsActionTypes.DeselectAlbum;
35 | constructor(public payload: Album) {}
36 | }
37 |
38 | export class DeselectAllAlbums implements Action {
39 | readonly type = AlbumsActionTypes.DeselectAllAlbums;
40 | }
41 |
42 | export type AlbumsActionsUnion =
43 | LoadAlbums |
44 | SelectAlbum |
45 | SelectAlbums |
46 | SelectAlbumsByIds |
47 | DeselectAlbum |
48 | DeselectAllAlbums;
49 |
--------------------------------------------------------------------------------
/src/main/scala/net/creasource/Main.scala:
--------------------------------------------------------------------------------
1 | package net.creasource
2 |
3 | import akka.actor.{ActorSystem, Props}
4 | import akka.http.scaladsl.server.Directives._
5 | import akka.http.scaladsl.server.Route
6 | import net.creasource.core.Application
7 | import net.creasource.http.{SPAWebServer, SocketWebServer}
8 | import net.creasource.web._
9 |
10 | import scala.io.StdIn
11 |
12 | /**
13 | * The Main class that bootstraps the application.
14 | */
15 | object Main extends App with SPAWebServer with SocketWebServer {
16 |
17 | implicit val app: Application = Application()
18 |
19 | private val host = app.config.getString("http.host")
20 | private val port = app.config.getInt("http.port")
21 | private val stopOnReturn = app.config.getBoolean("http.stop-on-return")
22 |
23 | private val apiRoutes = new APIRoutes(app)
24 | private val libraryRoutes = new AudioLibraryRoutes(app)
25 |
26 | override implicit val system: ActorSystem = app.system
27 | override val socketActorProps: Props = SocketActor.props(apiRoutes.routes)
28 | override val routes: Route = libraryRoutes.routes ~ apiRoutes.routes ~ super.routes
29 |
30 | val startFuture = start(host, port)
31 |
32 | startFuture.failed.foreach(t => {
33 | stop().onComplete(_ => {
34 | system.log.error(t, "An error occurred while starting the server!")
35 | app.shutdown()
36 | System.exit(1)
37 | })
38 | })
39 |
40 | startFuture foreach { _ =>
41 | if (stopOnReturn) {
42 | system.log.info(s"Press RETURN to stop...")
43 | StdIn.readLine()
44 | stop().onComplete(_ => app.shutdown())
45 | }
46 | }
47 |
48 | }
49 |
--------------------------------------------------------------------------------
/src/main/resources/logback.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | ${music.cacheFolder:-./data}/logs/application.log
5 |
6 | ./logs/application.%d{yyyy-MM-dd}.log
7 | 10
8 | 10MB
9 |
10 |
11 |
12 | %date{ISO8601} %-5level [%logger] [%X{akkaSource}] - %msg%n
13 |
14 |
15 |
16 |
17 |
18 | %X{akkaTimestamp} %-5level [%logger] - %msg%n
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/web/src/app/library/actions/artists.actions.ts:
--------------------------------------------------------------------------------
1 | import {Action} from '@ngrx/store';
2 | import {Artist} from '@app/model';
3 |
4 | export enum ArtistsActionTypes {
5 | LoadArtists = 'library/artists/load',
6 | SelectArtist = 'library/artists/select-add',
7 | SelectArtists = 'library/artists/select',
8 | SelectArtistsByIds = 'library/artists/select-by-id',
9 | DeselectArtist = 'library/artists/deselect',
10 | DeselectAllArtists = 'library/artists/deselect-all',
11 | }
12 |
13 | export class LoadArtists implements Action {
14 | readonly type = ArtistsActionTypes.LoadArtists;
15 | constructor(public payload: Artist[]) {}
16 | }
17 |
18 | export class SelectArtist implements Action {
19 | readonly type = ArtistsActionTypes.SelectArtist;
20 | constructor(public payload: Artist) {}
21 | }
22 |
23 | export class SelectArtists implements Action {
24 | readonly type = ArtistsActionTypes.SelectArtists;
25 | constructor(public payload: Artist[]) {}
26 | }
27 |
28 | export class SelectArtistsByIds implements Action {
29 | readonly type = ArtistsActionTypes.SelectArtistsByIds;
30 | constructor(public payload: (string | number)[]) {}
31 | }
32 |
33 | export class DeselectArtist implements Action {
34 | readonly type = ArtistsActionTypes.DeselectArtist;
35 | constructor(public payload: Artist) {}
36 | }
37 |
38 | export class DeselectAllArtists implements Action {
39 | readonly type = ArtistsActionTypes.DeselectAllArtists;
40 | }
41 |
42 | export type ArtistsActionsUnion =
43 | LoadArtists |
44 | DeselectArtist |
45 | SelectArtist |
46 | SelectArtists |
47 | SelectArtistsByIds |
48 | DeselectAllArtists;
49 |
--------------------------------------------------------------------------------
/web/src/app/library/components/player/progress.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@angular/core';
2 |
3 | @Component({
4 | selector: 'app-progress',
5 | template: `
6 |
7 | {{ currentTime ? (currentTime | sgTime) : '00:00' }}
8 |
11 |
19 | {{ duration ? (duration | sgTime) : '00:00' }}
20 |
21 | `,
22 | styles: [`
23 | .progress {
24 | padding: 0 1rem;
25 | display: flex;
26 | flex-direction: row;
27 | align-items: center;
28 | height: 36px;
29 | min-height: 36px;
30 | }
31 | mat-slider {
32 | flex-grow: 1;
33 | margin: 0 8px;
34 | }
35 | mat-progress-bar {
36 | margin: 0 22px;
37 | height: 2px;
38 | flex-grow: 1;
39 | }
40 | .time-elapsed, .time-total {
41 | width: 2.5rem;
42 | }
43 | .time-total {
44 | text-align: right;
45 | }
46 | `],
47 | changeDetection: ChangeDetectionStrategy.OnPush
48 | })
49 | export class ProgressComponent {
50 |
51 | @Input() currentTime: number;
52 | @Input() duration: number;
53 | @Input() loading: boolean;
54 |
55 | @Output() seekTo = new EventEmitter();
56 |
57 | }
58 |
--------------------------------------------------------------------------------
/bin/electron-windows-store-x64.js:
--------------------------------------------------------------------------------
1 | const convertToWindowsStore = require('electron-windows-store');
2 | const prompt = require('prompt-sync')();
3 |
4 | const windowsKit = prompt('Path to Windows Kit: ');
5 |
6 | const fs = require('fs-extra');
7 |
8 | const options = {
9 | containerVirtualization: false,
10 | inputDirectory: __dirname + '/../dist/electron/win-unpacked',
11 | outputDirectory: __dirname + '/../dist',
12 | packageVersion: '1.0.0.0',
13 | packageName: '53695CreaSource.Musicalypse',
14 | packageDisplayName: 'Musicalypse',
15 | packageDescription: 'A modern audio player built with Web technologies.',
16 | packageExecutable: 'app/Musicalypse.exe',
17 | assets: __dirname + '/../build/UWP/assets',
18 | manifest: __dirname + '/../build/UWP/AppXManifest_x64.xml',
19 | deploy: false,
20 | publisher: 'CN=482ACF73-DAC8-4D98-BA01-FA590F32FB7E',
21 | publisherDisplayName: 'CreaSource',
22 | windowsKit: windowsKit,
23 | devCert: __dirname + '/../dist/creasource.pfx',
24 | certPass: '',
25 | makePri: true,
26 | };
27 |
28 | // function createCertIfNeeded() {
29 | // return new Promise((resolve, reject) => {
30 | // try {
31 | // if (!fs.existsSync(options.devCert)) {
32 | // sign.makeCert({
33 | // publisherName: options.publisher,
34 | // certFilePath: __dirname + '/../dist/',
35 | // certFileName: 'creasource',
36 | // program: options2,
37 | // install: false
38 | // }).then(() => resolve());
39 | // } else {
40 | // resolve()
41 | // }
42 | // } catch (e) {
43 | // reject(e);
44 | // }
45 | // });
46 | // }
47 |
48 | convertToWindowsStore(options);
49 |
--------------------------------------------------------------------------------
/bin/electron-windows-store-x86.js:
--------------------------------------------------------------------------------
1 | const convertToWindowsStore = require('electron-windows-store');
2 | const prompt = require('prompt-sync')();
3 |
4 | const windowsKit = prompt('Path to Windows Kit: ');
5 |
6 | const fs = require('fs-extra');
7 |
8 | const options = {
9 | containerVirtualization: false,
10 | inputDirectory: __dirname + '/../dist/electron/win-ia32-unpacked',
11 | outputDirectory: __dirname + '/../dist',
12 | packageVersion: '1.0.0.0',
13 | packageName: '53695CreaSource.Musicalypse',
14 | packageDisplayName: 'Musicalypse',
15 | packageDescription: 'A modern audio player built with Web technologies.',
16 | packageExecutable: 'app/Musicalypse.exe',
17 | assets: __dirname + '/../build/UWP/assets',
18 | manifest: __dirname + '/../build/UWP/AppXManifest_x86.xml',
19 | deploy: false,
20 | publisher: 'CN=482ACF73-DAC8-4D98-BA01-FA590F32FB7E',
21 | publisherDisplayName: 'CreaSource',
22 | windowsKit: windowsKit,
23 | devCert: __dirname + '/../dist/creasource.pfx',
24 | certPass: '',
25 | makePri: true,
26 | };
27 |
28 | // function createCertIfNeeded() {
29 | // return new Promise((resolve, reject) => {
30 | // try {
31 | // if (!fs.existsSync(options.devCert)) {
32 | // sign.makeCert({
33 | // publisherName: options.publisher,
34 | // certFilePath: __dirname + '/../dist/',
35 | // certFileName: 'creasource',
36 | // program: options2,
37 | // install: false
38 | // }).then(() => resolve());
39 | // } else {
40 | // resolve()
41 | // }
42 | // } catch (e) {
43 | // reject(e);
44 | // }
45 | // });
46 | // }
47 |
48 | convertToWindowsStore(options);
49 |
--------------------------------------------------------------------------------
/web/src/app/core/components/initializer.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@angular/core';
2 |
3 | @Component({
4 | selector: 'app-initializer',
5 | template: `
6 |
7 |
8 | error_outline
9 | {{ initializingLog }}
10 | Retry
11 |
12 |
13 | `,
14 | styles: [`
15 | :host-context(.electron) .app-loader {
16 | top: 34px;
17 | }
18 | .app-loader {
19 | position: absolute;
20 | left: 0;
21 | right: 0;
22 | top: 0;
23 | bottom: 0;
24 | display: flex;
25 | flex-direction: column;
26 | align-items: center;
27 | justify-content: center;
28 | z-index: 10;
29 | }
30 | .app-loader mat-spinner {
31 | margin-bottom: 0.5rem;
32 | }
33 | .app-loader .error-icon {
34 | height: 50px;
35 | width: 50px;
36 | line-height: 50px;
37 | font-size: 50px;
38 | margin-bottom: 0.5rem;
39 | }
40 | .app-loader .retry {
41 | text-decoration: underline;
42 | cursor: pointer;
43 | }
44 | `],
45 | changeDetection: ChangeDetectionStrategy.OnPush
46 | })
47 | export class InitializerComponent {
48 |
49 | @Input() initializing = true;
50 | @Input() hasErrors: boolean;
51 | @Input() initializingLog: string;
52 |
53 | @Output() retry = new EventEmitter();
54 |
55 | }
56 |
--------------------------------------------------------------------------------
/web/src/app/player/components/progress.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@angular/core';
2 |
3 | // TODO refactor with app-progress
4 | @Component({
5 | selector: 'app-player-progress',
6 | template: `
7 |
8 | {{ currentTime ? (currentTime | sgTime) : '00:00' }}
9 |
12 |
20 | {{ duration ? (duration | sgTime) : '00:00' }}
21 |
22 | `,
23 | styles: [`
24 | .progress {
25 | padding: 0 1rem;
26 | display: flex;
27 | flex-direction: row;
28 | align-items: center;
29 | height: 36px;
30 | min-height: 36px;
31 | }
32 | mat-slider {
33 | flex-grow: 1;
34 | margin: 0 8px;
35 | }
36 | mat-progress-bar {
37 | margin: 0 22px;
38 | height: 2px;
39 | flex-grow: 1;
40 | }
41 | .time-elapsed, .time-total {
42 | width: 2.5rem;
43 | }
44 | .time-total {
45 | text-align: right;
46 | }
47 | `],
48 | changeDetection: ChangeDetectionStrategy.OnPush
49 | })
50 | export class PlayerProgressComponent {
51 |
52 | @Input() loading: boolean;
53 | @Input() currentTime: number;
54 | @Input() duration: number;
55 |
56 | @Output() seekTo = new EventEmitter();
57 |
58 | }
59 |
--------------------------------------------------------------------------------
/web/src/app/library/actions/playlists.actions.ts:
--------------------------------------------------------------------------------
1 | import {Action} from '@ngrx/store';
2 | import {Playlist, Track} from '@app/model';
3 |
4 | export enum PlaylistsActionTypes {
5 | LoadPlaylists = 'library/playlists/load',
6 | LoadPlaylist = 'library/playlists/load-playlist',
7 | SavePlaylist = 'library/playlists/save',
8 | DeletePlaylist = 'library/playlists/delete',
9 | AddToPlaylist = 'library/playlists/add',
10 | RemoveFromPlaylist = 'library/playlists/remove'
11 | }
12 |
13 | export class LoadPlaylists implements Action {
14 | readonly type = PlaylistsActionTypes.LoadPlaylists;
15 | constructor(public playlists: Playlist[]) {}
16 | }
17 |
18 | export class LoadPlaylist implements Action {
19 | readonly type = PlaylistsActionTypes.LoadPlaylist;
20 | constructor(public playlist: Playlist) {}
21 | }
22 |
23 | export class SavePlaylist implements Action {
24 | readonly type = PlaylistsActionTypes.SavePlaylist;
25 | constructor(public name: string, public tracks: Track[]) {}
26 | }
27 |
28 | export class DeletePlaylist implements Action {
29 | readonly type = PlaylistsActionTypes.DeletePlaylist;
30 | constructor(public name: string) {}
31 | }
32 |
33 | export class AddToPlaylist implements Action {
34 | readonly type = PlaylistsActionTypes.AddToPlaylist;
35 | constructor(public tracks: Track[], public playlistName: string) {}
36 | }
37 |
38 | export class RemoveFromPlaylist implements Action {
39 | readonly type = PlaylistsActionTypes.RemoveFromPlaylist;
40 | constructor(public track: Track, public playlistName: string) {}
41 | }
42 |
43 | export type PlaylistsActionUnion =
44 | LoadPlaylists |
45 | LoadPlaylist |
46 | SavePlaylist |
47 | DeletePlaylist |
48 | AddToPlaylist |
49 | RemoveFromPlaylist;
50 |
--------------------------------------------------------------------------------
/web/src/app/core/actions/audio.actions.ts:
--------------------------------------------------------------------------------
1 | import {Action} from '@ngrx/store';
2 |
3 | export enum AudioActionTypes {
4 | SetAudioSource = 'core/audio/source',
5 | SetAudioVolume = 'core/audio/volume',
6 | SetAudioMuted = 'core/audio/muted',
7 | SetAudioLoading = 'core/audio/loading',
8 | SetAudioPlaying = 'core/audio/playing',
9 | SetAudioDuration = 'core/audio/duration',
10 | SetAudioError = 'core/audio/error',
11 | }
12 |
13 | export class SetAudioSource implements Action {
14 | readonly type = AudioActionTypes.SetAudioSource;
15 | constructor(public payload: string) {}
16 | }
17 |
18 | export class SetAudioVolume implements Action {
19 | readonly type = AudioActionTypes.SetAudioVolume;
20 | constructor(public payload: number) {}
21 | }
22 |
23 | export class SetAudioMuted implements Action {
24 | readonly type = AudioActionTypes.SetAudioMuted;
25 | constructor(public payload: boolean) {}
26 | }
27 |
28 | export class SetAudioLoading implements Action {
29 | readonly type = AudioActionTypes.SetAudioLoading;
30 | constructor(public payload: boolean) {}
31 | }
32 |
33 | export class SetAudioPlaying implements Action {
34 | readonly type = AudioActionTypes.SetAudioPlaying;
35 | constructor(public payload: boolean) {}
36 | }
37 |
38 | export class SetAudioDuration implements Action {
39 | readonly type = AudioActionTypes.SetAudioDuration;
40 | constructor(public payload: number) {}
41 | }
42 |
43 | export class SetAudioError implements Action {
44 | readonly type = AudioActionTypes.SetAudioError;
45 | constructor(public payload: string) {}
46 | }
47 |
48 | export type AudioActionsUnion =
49 | SetAudioSource |
50 | SetAudioVolume |
51 | SetAudioMuted |
52 | SetAudioLoading |
53 | SetAudioPlaying |
54 | SetAudioDuration |
55 | SetAudioError;
56 |
--------------------------------------------------------------------------------
/web/src/app/core/core.reducers.ts:
--------------------------------------------------------------------------------
1 | import {ActionReducerMap, createFeatureSelector, createSelector} from '@ngrx/store';
2 |
3 | import * as fromCore from './reducers/core.reducers';
4 | import * as fromAudio from './reducers/audio.reducers';
5 |
6 | /**
7 | * State
8 | */
9 | export interface State {
10 | core: fromCore.State;
11 | audio: fromAudio.State;
12 | }
13 |
14 | export const reducers: ActionReducerMap = {
15 | core: fromCore.reducer,
16 | audio: fromAudio.reducer,
17 | };
18 |
19 | /**
20 | * Selectors
21 | */
22 | export const getCoreFeatureState = createFeatureSelector('core');
23 |
24 | export const getCoreState = createSelector(
25 | getCoreFeatureState,
26 | (state: State) => state.core
27 | );
28 |
29 | export const getAudioState = createSelector(
30 | getCoreFeatureState,
31 | (state: State) => state.audio
32 | );
33 |
34 | export const getShowSidenav = createSelector(
35 | getCoreState,
36 | fromCore.getShowSidenav
37 | );
38 |
39 | export const getCurrentTheme = createSelector(
40 | getCoreState,
41 | fromCore.getCurrentTheme
42 | );
43 |
44 | export const getAudioInput = createSelector(
45 | getAudioState,
46 | fromAudio.getAudioInput
47 | );
48 |
49 | export const getAudioPlaying = createSelector(
50 | getAudioState,
51 | fromAudio.getAudioPlaying
52 | );
53 |
54 | export const getAudioLoading = createSelector(
55 | getAudioState,
56 | fromAudio.getAudioLoading
57 | );
58 |
59 | export const getAudioDuration = createSelector(
60 | getAudioState,
61 | fromAudio.getAudioDuration
62 | );
63 |
64 | export const getAudioMuted = createSelector(
65 | getAudioState,
66 | fromAudio.getAudioMuted
67 | );
68 |
69 | export const getAudioVolume = createSelector(
70 | getAudioState,
71 | fromAudio.getAudioVolume
72 | );
73 |
74 |
75 |
--------------------------------------------------------------------------------
/web/src/app/library/components/shared/dictionary.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, EventEmitter, Output} from '@angular/core';
2 |
3 | @Component({
4 | selector: 'app-dictionary',
5 | template: `
6 |
7 |
8 | {{ letter }}
9 |
10 |
11 | `,
12 | styles: [`
13 | nav {
14 | user-select: none;
15 | height: calc(100% - 132px);
16 | display: flex;
17 | flex-direction: column;
18 | justify-content: flex-start;
19 | font-size: 10px;
20 | position: absolute;
21 | top: 60px;
22 | right: 21px;
23 | }
24 | ol {
25 | list-style-type: none;
26 | height: 100%;
27 | margin: 0;
28 | padding: 0;
29 | }
30 | li {
31 | text-align: center;
32 | cursor: pointer;
33 | height: calc(100% / 27);
34 | }
35 |
36 | @supports (-webkit-appearance:none) {
37 | nav {
38 | right: 9px;
39 | }
40 | }
41 |
42 | @media screen and (min-width: 1319px){
43 | nav {
44 | height: calc(100% - 60px);
45 | }
46 | }
47 | `],
48 | changeDetection: ChangeDetectionStrategy.OnPush
49 | })
50 | export class DictionaryComponent {
51 |
52 | @Output() letterClicked = new EventEmitter();
53 |
54 | alphabet = [
55 | '#',
56 | 'A',
57 | 'B',
58 | 'C',
59 | 'D',
60 | 'E',
61 | 'F',
62 | 'G',
63 | 'H',
64 | 'I',
65 | 'J',
66 | 'K',
67 | 'L',
68 | 'M',
69 | 'N',
70 | 'O',
71 | 'P',
72 | 'Q',
73 | 'R',
74 | 'S',
75 | 'T',
76 | 'U',
77 | 'V',
78 | 'W',
79 | 'X',
80 | 'Y',
81 | 'Z'
82 | ];
83 |
84 | }
85 |
--------------------------------------------------------------------------------
/src/main/scala/net/creasource/web/AudioLibraryRoutes.scala:
--------------------------------------------------------------------------------
1 | package net.creasource.web
2 |
3 | import java.io.File
4 |
5 | import akka.http.scaladsl.model.headers.RawHeader
6 | import akka.http.scaladsl.server.Directives._
7 | import akka.http.scaladsl.server.Route
8 | import akka.http.scaladsl.settings.RoutingSettings
9 | import akka.pattern.ask
10 | import net.creasource.core.Application
11 | import net.creasource.web.LibraryActor.{GetLibraries, Libraries}
12 |
13 | import scala.concurrent.duration._
14 |
15 | class AudioLibraryRoutes(application: Application) {
16 |
17 | implicit val settings: RoutingSettings = RoutingSettings.apply(application.config)
18 |
19 | implicit val askTimeout: akka.util.Timeout = 2.seconds
20 |
21 | var cacheFolder: String = application.config.getString("music.cacheFolder")
22 |
23 | // val uploadFolder: String = application.config.getString("music.uploadFolder")
24 |
25 | val cacheFolderFile = new File(cacheFolder)
26 |
27 | if(!cacheFolderFile.exists()) {
28 | cacheFolderFile.mkdirs()
29 | }
30 |
31 | if (!cacheFolderFile.isDirectory) {
32 | throw new IllegalArgumentException(s"Config music.cacheFolder ($cacheFolder) is not a folder!")
33 | }
34 |
35 | def routes: Route =
36 | pathPrefix("music") {
37 | onSuccess((application.libraryActor ? GetLibraries).mapTo[Libraries]) { libraries =>
38 | //val libs = libraries.libraries.map(_.toString) +: uploadFolder
39 | val libs = libraries.libraries.map(_.toString)
40 | Route.seal(libs.map(getFromBrowseableDirectory).fold(reject())(_ ~ _))
41 | }
42 | } ~ pathPrefix("cache") {
43 | respondWithHeader(RawHeader("Cache-Control", "max-age=604800")) {
44 | encodeResponse {
45 | getFromBrowseableDirectory(cacheFolder)
46 | }
47 | }
48 | }
49 |
50 | }
51 |
--------------------------------------------------------------------------------
/src/main/scala/net/creasource/web/SettingsActor.scala:
--------------------------------------------------------------------------------
1 | package net.creasource.web
2 |
3 | import java.net.NetworkInterface
4 | import java.nio.file.{Files, Path, Paths}
5 |
6 | import akka.Done
7 | import akka.actor.{Actor, Props}
8 | import akka.event.Logging
9 | import net.creasource.core.Application
10 | import net.creasource.web.SettingsActor.{DeleteCovers, GetHostIps}
11 |
12 | import scala.collection.JavaConverters._
13 |
14 | object SettingsActor {
15 |
16 | case object GetHostIps
17 | case object DeleteCovers
18 |
19 | def props()(implicit application: Application): Props = Props(new SettingsActor())
20 |
21 | }
22 |
23 | class SettingsActor()(implicit application: Application) extends Actor with JsonSupport {
24 |
25 | private val logger = Logging(context.system, this)
26 |
27 | val cacheFolder: Path = Paths.get(application.config.getString("music.cacheFolder"))
28 |
29 | override def receive: Receive = {
30 |
31 | case GetHostIps =>
32 | val interfaces: Seq[NetworkInterface] = java.net.NetworkInterface.getNetworkInterfaces.asScala.toSeq
33 | val ipAddresses: Seq[String] =
34 | interfaces.flatMap { p =>
35 | logger.debug("Found interface: " + p.getDisplayName)
36 | p.getInetAddresses.asScala.toSeq
37 | }.collect { case address if !address.isLoopbackAddress && address.getHostAddress.contains(".") =>
38 | logger.debug("Found address: " + address.getHostAddress)
39 | address.getHostAddress
40 | }
41 | sender() ! ipAddresses
42 |
43 | case DeleteCovers =>
44 | logger.info("Deleting covers")
45 | val covers = cacheFolder.resolve("covers")
46 | Files.walk(covers)
47 | .filter(path => path != covers)
48 | .forEach(path => path.toFile.delete())
49 | sender() ! Done
50 |
51 | }
52 |
53 | }
54 |
--------------------------------------------------------------------------------
/web/src/assets/logo_grey.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/web/src/app/library/components/shared/chips.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@angular/core';
2 |
3 | // TODO add backdrop
4 |
5 | @Component({
6 | selector: 'app-chips',
7 | template: `
8 |
9 | Remove All
10 |
11 | {{ element[displayProperty] }}
12 | cancel
13 |
14 |
15 | `,
16 | styles: [`
17 | .full-list {
18 | padding: 0.5rem;
19 | position: absolute;
20 | z-index: 2;
21 | top: 60px;
22 | width: 100%;
23 | box-sizing: border-box;
24 | max-height: 50%;
25 | overflow-y: auto;
26 | }
27 | mat-chip {
28 | cursor: pointer;
29 | max-width: calc(50% - 27px);
30 | white-space: nowrap;
31 | }
32 | .chip-text {
33 | display: inline-block;
34 | overflow: hidden;
35 | text-overflow: ellipsis;
36 | }
37 | `],
38 | changeDetection: ChangeDetectionStrategy.OnPush
39 | })
40 | export class ChipsComponent {
41 |
42 | @Input() list: any[];
43 | @Input() displayProperty: string;
44 |
45 | @Output() clickedElement = new EventEmitter();
46 | @Output() removedAll = new EventEmitter();
47 | @Output() removedElement = new EventEmitter();
48 |
49 | clickElement(element: any) {
50 | this.clickedElement.emit(element);
51 | }
52 |
53 | removeAll() {
54 | this.removedAll.emit();
55 | }
56 |
57 | removeElement(element: any) {
58 | this.removedElement.emit(element);
59 | }
60 |
61 | }
62 |
--------------------------------------------------------------------------------
/web/src/app/app.module.ts:
--------------------------------------------------------------------------------
1 | import {NgModule} from '@angular/core';
2 | import {RouterModule} from '@angular/router';
3 | import {ServiceWorkerModule} from '@angular/service-worker';
4 | import {StoreModule} from '@ngrx/store';
5 | import {EffectsModule} from '@ngrx/effects';
6 | import {StoreDevtoolsModule} from '@ngrx/store-devtools';
7 | import {StoreRouterConnectingModule} from '@ngrx/router-store';
8 |
9 | import {routes} from './routes';
10 |
11 | import {CoreModule} from './core/core.module';
12 | import {CoreComponent} from './core/core.component';
13 | import {LibraryModule} from './library/library.module';
14 | import {SettingsModule} from './settings/settings.module';
15 | import {metaReducers, reducers} from './app.reducers';
16 | import {environment} from '@env/environment';
17 | // import {EditorModule} from '@app/editor/editor.module';
18 | // import {MyMusicModule} from '@app/my-music/my-music.module';
19 | import {PlayerModule} from '@app/player/player.module';
20 | import {PlaylistsModule} from '@app/playlists/playlists.module';
21 | import {CustomSerializer} from './app.serializer';
22 |
23 | @NgModule({
24 | imports: [
25 | // Angular Modules
26 | RouterModule.forRoot(routes),
27 | ServiceWorkerModule.register('/ngsw-worker.js', { enabled: environment.production && !environment.electron }),
28 |
29 | // Ngrx Modules
30 | StoreModule.forRoot(reducers, { metaReducers }),
31 | StoreRouterConnectingModule.forRoot({
32 | serializer: CustomSerializer
33 | }),
34 | EffectsModule.forRoot([]),
35 | StoreDevtoolsModule.instrument({
36 | maxAge: 50, // Retains last 50 states
37 | logOnly: environment.production // Restrict extension to log-only mode
38 | }),
39 |
40 | // My Modules
41 | CoreModule,
42 | LibraryModule,
43 | SettingsModule,
44 | // EditorModule,
45 | // MyMusicModule,
46 | PlayerModule,
47 | PlaylistsModule
48 | ],
49 | bootstrap: [CoreComponent]
50 | })
51 | export class AppModule { }
52 |
53 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/src/main/scala/net/creasource/http/SocketWebServer.scala:
--------------------------------------------------------------------------------
1 | package net.creasource.http
2 |
3 | import akka.Done
4 | import akka.actor.{ActorRef, Props, Status}
5 | import akka.http.scaladsl.model.ws.{Message, TextMessage}
6 | import akka.http.scaladsl.server.Directives._
7 | import akka.http.scaladsl.server.Route
8 | import akka.pattern.ask
9 | import akka.stream.{KillSwitches, OverflowStrategy, SharedKillSwitch}
10 | import akka.stream.scaladsl.{Flow, Keep, Sink, Source}
11 |
12 | import scala.concurrent.Future
13 | import scala.concurrent.duration._
14 | import net.creasource.http.actors.{SocketSinkActor, SocketSinkSupervisor}
15 |
16 | /**
17 | * A WebServer that supports WebSockets connections on the path /socket.
18 | * Concrete classes must define a socketActorProps that will receive socket messages.
19 | */
20 | trait SocketWebServer extends WebServer { self: WebServer =>
21 |
22 | protected val socketActorProps: Props
23 |
24 | private lazy val sinkActorProps: Props = SocketSinkActor.props(socketActorProps)
25 | private lazy val socketsKillSwitch: SharedKillSwitch = KillSwitches.shared("sockets")
26 | private lazy val supervisor = system.actorOf(SocketSinkSupervisor.props(), "sockets")
27 |
28 | def socketFlow(sinkActor: ActorRef): Flow[Message, Message, Unit] = {
29 | Flow.fromSinkAndSourceMat(
30 | Sink.actorRef(sinkActor, Status.Success(())),
31 | Source.actorRef(1000, OverflowStrategy.fail)
32 | )(Keep.right).mapMaterializedValue(sourceActor => sinkActor ! sourceActor)
33 | }
34 |
35 | override def stop(): Future[Done] = {
36 | system.log.info("Killing open sockets.")
37 | socketsKillSwitch.shutdown()
38 | super.stop()
39 | }
40 |
41 | override def routes: Route =
42 | path("socket") {
43 | extractUpgradeToWebSocket { _ =>
44 | onSuccess((supervisor ? sinkActorProps)(1.second).mapTo[ActorRef]) { sinkActor: ActorRef =>
45 | handleWebSocketMessages(socketFlow(sinkActor).via(socketsKillSwitch.flow))
46 | }
47 | }
48 | } ~ super.routes
49 |
50 | }
51 |
--------------------------------------------------------------------------------
/web/src/app/settings/components/library-folders.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@angular/core';
2 |
3 | @Component({
4 | selector: 'app-library-folders',
5 | template: `
6 |
7 |
8 | folder_open
9 | {{ folder }}
10 |
11 | close
12 |
13 |
14 |
15 |
16 | Loading...
17 |
18 |
19 |
20 | create_new_folder
21 | Add a new folder
22 |
23 |
24 | sync
25 | Scan library
26 |
27 | Error: {{ error }}
28 |
29 | `,
30 | styles: [`
31 | mat-list {
32 | padding-top: 0 !important;
33 | margin-bottom: 12px;
34 | max-width: 500px;
35 | }
36 | button:not(.close) mat-icon {
37 | margin-right: 0.5rem;
38 | }
39 | mat-spinner {
40 | width: 16px !important;
41 | height: 16px !important;;
42 | }
43 | `],
44 | changeDetection: ChangeDetectionStrategy.OnPush
45 | })
46 | export class LibraryFoldersComponent {
47 |
48 | @Input() folders: string[];
49 | @Input() error: string;
50 | @Input() loading: boolean;
51 |
52 | @Output() addFolder = new EventEmitter();
53 | @Output() removeFolder = new EventEmitter();
54 | @Output() scanRequest = new EventEmitter();
55 |
56 | }
57 |
--------------------------------------------------------------------------------
/web/src/app/shared/material/material.module.ts:
--------------------------------------------------------------------------------
1 | import {Injectable, NgModule} from '@angular/core';
2 | import {HAMMER_GESTURE_CONFIG} from '@angular/platform-browser';
3 | import {BreakpointObserver} from '@angular/cdk/layout';
4 | import {
5 | GestureConfig,
6 | HammerManager,
7 | MatButtonModule,
8 | MatCheckboxModule,
9 | MatChipsModule,
10 | MatDialogModule,
11 | MatFormFieldModule,
12 | MatGridListModule,
13 | MatIconModule,
14 | MatInputModule,
15 | MatListModule,
16 | MatMenuModule,
17 | MatPaginatorModule,
18 | MatProgressBarModule,
19 | MatProgressSpinnerModule,
20 | MatRadioModule,
21 | MatSelectModule,
22 | MatSidenavModule,
23 | MatSliderModule,
24 | MatSlideToggleModule,
25 | MatSnackBarModule,
26 | MatSortModule,
27 | MatTableModule,
28 | MatTabsModule,
29 | MatToolbarModule,
30 | MatTooltipModule
31 | } from '@angular/material';
32 |
33 | @Injectable()
34 | export class MyHammerConfig extends GestureConfig {
35 | buildHammer(element: HTMLElement) {
36 | const mc = super.buildHammer(element);
37 | mc.set({ touchAction: 'pan-y' });
38 | return mc;
39 | }
40 | }
41 |
42 | const MATERIAL_MODULES = [
43 | MatButtonModule,
44 | MatCheckboxModule,
45 | MatChipsModule,
46 | MatDialogModule,
47 | MatFormFieldModule,
48 | MatGridListModule,
49 | MatIconModule,
50 | MatInputModule,
51 | MatListModule,
52 | MatMenuModule,
53 | MatPaginatorModule,
54 | MatProgressBarModule,
55 | MatProgressSpinnerModule,
56 | MatRadioModule,
57 | MatSelectModule,
58 | MatSidenavModule,
59 | MatSliderModule,
60 | MatSlideToggleModule,
61 | MatSnackBarModule,
62 | MatSortModule,
63 | MatTableModule,
64 | MatTabsModule,
65 | MatToolbarModule,
66 | MatTooltipModule
67 | ];
68 |
69 | @NgModule({
70 | imports: MATERIAL_MODULES,
71 | exports: MATERIAL_MODULES,
72 | providers: [
73 | {
74 | provide: HAMMER_GESTURE_CONFIG,
75 | useClass: MyHammerConfig
76 | },
77 | BreakpointObserver
78 | ]
79 | })
80 | export class MaterialModule {}
81 |
--------------------------------------------------------------------------------
/web/src/app/library/actions/player.actions.ts:
--------------------------------------------------------------------------------
1 | import {Track} from '@app/model';
2 | import {Action} from '@ngrx/store';
3 |
4 | export enum PlayerActionTypes {
5 | PlayTrackNext = 'library/player/play-track-next',
6 | AddToCurrentPlaylist = 'library/player/add-to-playlist',
7 | SetRepeat = 'library/player/repeat',
8 | SetShuffle = 'library/player/shuffle',
9 | SetCurrentTrack = 'library/player/track',
10 | SetCurrentPlaylist = 'library/player/playlist',
11 | SetNextTrack = 'library/player/next',
12 | SetPreviousTrack = 'library/player/previous',
13 | }
14 |
15 | export class PlayTrackNext implements Action {
16 | readonly type = PlayerActionTypes.PlayTrackNext;
17 | constructor(public payload: Track) {}
18 | }
19 |
20 | export class AddToCurrentPlaylist implements Action {
21 | readonly type = PlayerActionTypes.AddToCurrentPlaylist;
22 | constructor(public payload: Track[]) {}
23 | }
24 |
25 | export class SetNextTrack implements Action {
26 | readonly type = PlayerActionTypes.SetNextTrack;
27 | }
28 |
29 | export class SetPreviousTrack implements Action {
30 | readonly type = PlayerActionTypes.SetPreviousTrack;
31 | }
32 |
33 | export class SetRepeat implements Action {
34 | readonly type = PlayerActionTypes.SetRepeat;
35 | constructor(public payload: boolean) {}
36 | }
37 |
38 | export class SetShuffle implements Action {
39 | readonly type = PlayerActionTypes.SetShuffle;
40 | constructor(public payload: boolean) {}
41 | }
42 |
43 | export class SetCurrentTrack implements Action {
44 | readonly type = PlayerActionTypes.SetCurrentTrack;
45 | constructor(public payload: Track) {}
46 | }
47 |
48 | export class SetCurrentPlaylist implements Action {
49 | readonly type = PlayerActionTypes.SetCurrentPlaylist;
50 | constructor(public payload: Track[]) {}
51 | }
52 |
53 | export type PlayerActionsUnion =
54 | PlayTrackNext |
55 | AddToCurrentPlaylist |
56 | SetNextTrack |
57 | SetPreviousTrack |
58 | SetRepeat |
59 | SetShuffle |
60 | SetCurrentTrack |
61 | SetCurrentPlaylist;
62 |
--------------------------------------------------------------------------------
/web/src/app/library/reducers/tracks.reducers.ts:
--------------------------------------------------------------------------------
1 | import {createEntityAdapter, EntityAdapter, EntityState} from '@ngrx/entity';
2 | import {ImmutableTrack, toImmutable} from '@app/model';
3 | import {TracksActionsUnion, TracksActionTypes} from '@app/library/actions/tracks.actions';
4 |
5 | /**
6 | * State
7 | */
8 | export interface State extends EntityState {
9 | error: string;
10 | loading: boolean;
11 | }
12 |
13 | export const adapter: EntityAdapter = createEntityAdapter({
14 | selectId: (track: ImmutableTrack) => track.get('url'),
15 | sortComparer: (a, b) => a.get('url').localeCompare(b.get('url')),
16 | });
17 |
18 | export const initialState: State = adapter.getInitialState({
19 | error: '',
20 | loading: false
21 | });
22 |
23 | /**
24 | * Reducer
25 | */
26 | export function reducer(
27 | state = initialState,
28 | action: TracksActionsUnion
29 | ): State {
30 | switch (action.type) {
31 |
32 | case TracksActionTypes.AddTracks:
33 | return adapter.addMany(action.payload.map(toImmutable), state);
34 |
35 | case TracksActionTypes.RemoveTracks: {
36 | return adapter.removeMany(action.payload.map(t => t.url), state);
37 | }
38 |
39 | case TracksActionTypes.LoadTracksSuccess: {
40 | return adapter.addMany(action.payload.map(toImmutable), {
41 | ...state,
42 | loading: false
43 | });
44 | }
45 |
46 | case TracksActionTypes.LoadTracksFailure: {
47 | return {
48 | ...state,
49 | error: action.payload,
50 | loading: false
51 | };
52 | }
53 |
54 | case TracksActionTypes.ScanTracks:
55 | return adapter.removeAll(state);
56 |
57 | case TracksActionTypes.LoadTracks: {
58 | return {
59 | ...state,
60 | loading: true,
61 | error: ''
62 | };
63 | }
64 |
65 | default: {
66 | return state;
67 | }
68 | }
69 | }
70 |
71 | /**
72 | * Selectors
73 | */
74 | export const getError = (state: State) => state.error;
75 | export const getLoading = (state: State) => state.loading;
76 |
--------------------------------------------------------------------------------
/web/src/app/core/core.module.ts:
--------------------------------------------------------------------------------
1 | import {NgModule, Optional, SkipSelf} from '@angular/core';
2 | import {EffectsModule} from '@ngrx/effects';
3 |
4 | import {SharedModule} from '@app/shared/shared.module';
5 |
6 | import {CoreComponent} from './core.component';
7 | import {AboutComponent} from './components/about.component';
8 | import {SideMenuComponent} from './components/sidemenu.component';
9 | import {SidenavComponent} from './components/sidenav.component';
10 | import {ToolbarComponent} from './components/toolbar.component';
11 |
12 | import {LoaderService} from './services/loader.service';
13 | import {HttpSocketClientService} from './services/http-socket-client.service';
14 | import {AudioService} from './services/audio.service';
15 |
16 | import {CoreEffects} from './core.effects';
17 | import {InitializerComponent} from '@app/core/components/initializer.component';
18 | import {UpdateService} from '@app/core/services/update.service';
19 | import {RouterService} from '@app/core/services/router.service';
20 | import {ElectronService} from '@app/core/services/electron.service';
21 | import {CoreService} from '@app/core/services/core.service';
22 | import {StoreModule} from '@ngrx/store';
23 | import {reducers} from '@app/core/core.reducers';
24 |
25 | export const COMPONENTS = [
26 | CoreComponent,
27 | AboutComponent,
28 | SideMenuComponent,
29 | SidenavComponent,
30 | ToolbarComponent,
31 | InitializerComponent
32 | ];
33 |
34 | @NgModule({
35 | imports: [
36 | SharedModule,
37 | StoreModule.forFeature('core', reducers),
38 | EffectsModule.forFeature([CoreEffects]),
39 | ],
40 | declarations: COMPONENTS,
41 | exports: COMPONENTS,
42 | providers: [
43 | CoreService,
44 | HttpSocketClientService,
45 | LoaderService,
46 | AudioService,
47 | UpdateService,
48 | RouterService,
49 | ElectronService
50 | ]
51 | })
52 | export class CoreModule {
53 | constructor (@Optional() @SkipSelf() parentModule: CoreModule) {
54 | if (parentModule) {
55 | throw new Error('CoreModule is already loaded. Import only in AppModule');
56 | }
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/web/src/assets/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/web/src/app/library/library.module.ts:
--------------------------------------------------------------------------------
1 | import {NgModule} from '@angular/core';
2 | import {StoreModule} from '@ngrx/store';
3 | import {EffectsModule} from '@ngrx/effects';
4 |
5 | import {SharedModule} from '@app/shared/shared.module';
6 |
7 | import {LibraryService} from './services/library.service';
8 | import {DictionaryComponent} from './components/shared/dictionary.component';
9 | import {MiniPlayerComponent} from './components/mini-player.component';
10 | import {PlayerComponent} from './components/player/player.component';
11 | import {AlbumsComponent} from './components/albums.component';
12 | import {ArtistsComponent} from './components/artists.component';
13 | import {LibraryComponent} from './library.component';
14 | import {TracksComponent} from './components/tracks.component';
15 | import {ControlsComponent} from './components/shared/controls.component';
16 | import {ChipsComponent} from './components/shared/chips.component';
17 | import {ListItemComponent} from './components/shared/list-item.component';
18 | import {LoaderComponent} from './components/shared/loader.component';
19 | import {TrackComponent, TrackControlComponent} from './components/track.component';
20 | import {ProgressComponent} from './components/player/progress.component';
21 |
22 | import {LibraryEffects} from './library.effects';
23 | import {reducers} from './library.reducers';
24 | import {LyricsService} from '@app/library/services/lyrics.service';
25 |
26 | export const COMPONENTS = [
27 | AlbumsComponent,
28 | ArtistsComponent,
29 | LibraryComponent,
30 | MiniPlayerComponent,
31 | PlayerComponent,
32 | TracksComponent,
33 | DictionaryComponent,
34 | ControlsComponent,
35 | ChipsComponent,
36 | ListItemComponent,
37 | LoaderComponent,
38 | TrackComponent,
39 | ProgressComponent,
40 | TrackControlComponent
41 | ];
42 |
43 | @NgModule({
44 | imports: [
45 | SharedModule,
46 | StoreModule.forFeature('library', reducers),
47 | EffectsModule.forFeature([LibraryEffects]),
48 | ],
49 | declarations: COMPONENTS,
50 | exports: COMPONENTS,
51 | providers: [
52 | LibraryService,
53 | LyricsService
54 | ]
55 | })
56 | export class LibraryModule {}
57 |
--------------------------------------------------------------------------------
/web/src/app/settings/reducers/libray.reducers.ts:
--------------------------------------------------------------------------------
1 | import {SettingsActionsUnion, SettingsActionTypes} from '../settings.actions';
2 |
3 | /**
4 | * State
5 | */
6 | export interface State {
7 | folders: string[];
8 | error: string;
9 | loading: boolean;
10 | }
11 |
12 | const initialState: State = {
13 | folders: [],
14 | error: '',
15 | loading: false
16 | };
17 |
18 | /**
19 | * Reducer
20 | */
21 | export function reducer(
22 | state: State = initialState,
23 | action: SettingsActionsUnion
24 | ): State {
25 | switch (action.type) {
26 |
27 | case SettingsActionTypes.LoadLibraryFoldersSuccess:
28 | return {
29 | ...state,
30 | folders: action.payload,
31 | error: '',
32 | loading: false
33 | };
34 |
35 | case SettingsActionTypes.LoadLibraryFoldersFailure:
36 | return {
37 | ...state,
38 | error: action.payload,
39 | loading: false
40 | };
41 |
42 | case SettingsActionTypes.AddLibraryFolderSuccess:
43 | return {
44 | ...state,
45 | folders: [...state.folders, action.payload],
46 | error: '',
47 | loading: false
48 | };
49 |
50 | case SettingsActionTypes.AddLibraryFolderFailure:
51 | return {
52 | ...state,
53 | error: action.payload,
54 | loading: false
55 | };
56 |
57 | case SettingsActionTypes.RemoveLibraryFolderSuccess:
58 | return {
59 | ...state,
60 | folders: state.folders.filter(folder => folder !== action.payload),
61 | error: '',
62 | loading: false
63 | };
64 |
65 | case SettingsActionTypes.RemoveLibraryFolderFailure:
66 | return {
67 | ...state,
68 | error: action.payload,
69 | loading: false
70 | };
71 |
72 | case SettingsActionTypes.LoadLibraryFolders:
73 | case SettingsActionTypes.AddLibraryFolder:
74 | case SettingsActionTypes.RemoveLibraryFolder:
75 | return {
76 | ...state,
77 | loading: true
78 | };
79 |
80 | default:
81 | return state;
82 | }
83 | }
84 |
85 | /**
86 | * Selectors
87 | */
88 | export const getLibraryFolders = (state: State) => state.folders;
89 | export const getError = (state: State) => state.error;
90 | export const getLoading = (state: State) => state.loading;
91 |
--------------------------------------------------------------------------------
/src/main/scala/net/creasource/http/actors/SocketSinkActor.scala:
--------------------------------------------------------------------------------
1 | package net.creasource.http.actors
2 |
3 | import akka.actor.{Actor, ActorRef, OneForOneStrategy, Props, Stash, Status, SupervisorStrategy, Terminated}
4 | import akka.event.Logging
5 | import akka.http.scaladsl.model.ws.{BinaryMessage, TextMessage}
6 | import akka.stream.ActorMaterializer
7 | import akka.stream.scaladsl.Sink
8 |
9 | import spray.json._
10 |
11 | import scala.concurrent.duration._
12 |
13 | object SocketSinkActor {
14 | def props(socketActorProps: Props)(implicit materializer: ActorMaterializer): Props = Props(new SocketSinkActor(socketActorProps))
15 | }
16 |
17 | class SocketSinkActor(socketActorProps: Props)(implicit materializer: ActorMaterializer) extends Actor with Stash {
18 | private val logger = Logging(context.system, this)
19 |
20 | logger.debug("Socket opened. Actor created.")
21 |
22 | override def receive: Receive = {
23 | case sourceActor: ActorRef =>
24 | val user = context.watch(context.actorOf(socketActorProps, "user"))
25 | unstashAll()
26 | context.become {
27 | case TextMessage.Strict(data) => user ! JsonParser(data)
28 | case BinaryMessage.Strict(_) => // ignore
29 | case TextMessage.Streamed(stream) => stream.runWith(Sink.ignore)
30 | case BinaryMessage.Streamed(stream) => stream.runWith(Sink.ignore)
31 | case msg: JsValue if sender() == user => sourceActor ! TextMessage(msg.compactPrint)
32 | case Terminated(`user`) =>
33 | logger.debug("UserActor terminated. Terminating.")
34 | sourceActor ! Status.Success(())
35 | context.stop(self)
36 | case s @ Status.Success(_) =>
37 | logger.debug("Socket closed. Terminating.")
38 | sourceActor ! s
39 | context.stop(self)
40 | case f @ Status.Failure(cause) =>
41 | logger.error(cause, "Socket failed. Terminating.")
42 | sourceActor ! f
43 | context.stop(self)
44 | case m => logger.warning("Unsupported message: {}", m.toString)
45 | }
46 | case _ => stash()
47 | }
48 |
49 | override val supervisorStrategy: OneForOneStrategy =
50 | OneForOneStrategy(maxNrOfRetries = 5, withinTimeRange = 1.minute, loggingEnabled = true) {
51 | case _: Exception => SupervisorStrategy.Stop
52 | }
53 |
54 | override def postStop(): Unit = {
55 | logger.debug("SocketActor killed")
56 | super.postStop()
57 | }
58 |
59 | }
60 |
--------------------------------------------------------------------------------
/web/src/app/settings/settings.effects.ts:
--------------------------------------------------------------------------------
1 | import {Injectable} from '@angular/core';
2 | import {Actions, Effect, ofType} from '@ngrx/effects';
3 | import {Observable, of} from 'rxjs';
4 | import {catchError, map, mergeMap, switchMap} from 'rxjs/operators';
5 | import {Action} from '@ngrx/store';
6 | import {
7 | AddLibraryFolder,
8 | AddLibraryFolderFailure,
9 | AddLibraryFolderSuccess,
10 | LoadLibraryFoldersFailure,
11 | LoadLibraryFoldersSuccess,
12 | RemoveLibraryFolder,
13 | RemoveLibraryFolderFailure,
14 | RemoveLibraryFolderSuccess,
15 | SettingsActionTypes
16 | } from '@app/settings/settings.actions';
17 | import {HttpSocketClientService} from '@app/core/services/http-socket-client.service';
18 | import {HttpErrorResponse} from '@angular/common/http';
19 |
20 | @Injectable()
21 | export class SettingsEffects {
22 |
23 | @Effect()
24 | loadLibraryFolders$: Observable =
25 | this.actions$.pipe(
26 | ofType(SettingsActionTypes.LoadLibraryFolders),
27 | switchMap(() =>
28 | this.httpSocketClient.get('/api/libraries').pipe(
29 | map((folders: string[]) => new LoadLibraryFoldersSuccess(folders)),
30 | catchError((error: HttpErrorResponse) => of(new LoadLibraryFoldersFailure(error.error)))
31 | )
32 | ),
33 | );
34 |
35 | @Effect()
36 | addLibraryFolder$: Observable =
37 | this.actions$.pipe(
38 | ofType(SettingsActionTypes.AddLibraryFolder),
39 | map(action => action.payload),
40 | mergeMap(folder =>
41 | this.httpSocketClient.post('/api/libraries', folder).pipe(
42 | map(() => new AddLibraryFolderSuccess(folder)),
43 | catchError((error: HttpErrorResponse) => of(new AddLibraryFolderFailure(error.error)))
44 | )
45 | ),
46 | );
47 |
48 | @Effect()
49 | removeLibraryFolder$: Observable =
50 | this.actions$.pipe(
51 | ofType(SettingsActionTypes.RemoveLibraryFolder),
52 | map(action => action.payload),
53 | mergeMap(folder =>
54 | this.httpSocketClient.delete('/api/libraries/' + encodeURIComponent(folder)).pipe(
55 | map(() => new RemoveLibraryFolderSuccess(folder)),
56 | catchError((error: HttpErrorResponse) => of(new RemoveLibraryFolderFailure(error.error)))
57 | )
58 | ),
59 | );
60 |
61 | constructor(
62 | private actions$: Actions,
63 | private httpSocketClient: HttpSocketClientService
64 | ) {}
65 |
66 | }
67 |
--------------------------------------------------------------------------------
/web/src/app/player/components/header.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, Input} from '@angular/core';
2 | import {Track} from '@app/model';
3 | import {DomSanitizer} from '@angular/platform-browser';
4 |
5 | @Component({
6 | selector: 'app-player-header',
7 | template: `
8 |
23 | `,
24 | styles: [`
25 | .header {
26 | padding: 1rem;
27 | display: flex;
28 | flex-direction: row;
29 | align-items: center;
30 | }
31 | .cover {
32 | height: 100px;
33 | min-width: 100px;
34 | margin-right: 1rem;
35 | background-size: cover;
36 | box-sizing: border-box;
37 | display: flex;
38 | align-items: center;
39 | justify-content: center;
40 | }
41 | .cover mat-icon {
42 | height: 50px;
43 | width: 50px;
44 | line-height: 50px;
45 | font-size: 50px;
46 | }
47 | .meta {
48 | display: flex;
49 | flex-direction: column;
50 | }
51 | .title {
52 | font-size: 25px;
53 | line-height: 25px;
54 | font-weight: 500;
55 | margin-bottom: 1rem;
56 | }
57 | .artist-album {
58 | font-size: 15px;
59 | line-height: 15px;
60 | font-weight: 300;
61 | }
62 | @media screen and (min-width: 599px) {
63 | .cover {
64 | height: 150px;
65 | min-width: 150px;
66 | }
67 | .title {
68 | font-size: 35px;
69 | line-height: 35px;
70 | }
71 | .artist-album {
72 | font-size: 20px;
73 | line-height: 20px;
74 | }
75 | }
76 | `],
77 | changeDetection: ChangeDetectionStrategy.OnPush
78 | })
79 | export class PlayerHeaderComponent {
80 |
81 | @Input() currentTrack: Track;
82 |
83 | constructor(private sanitizer: DomSanitizer) {}
84 |
85 | getAvatarStyle(track: Track) {
86 | return track && track.coverUrl ? this.sanitizer.bypassSecurityTrustStyle(`background-image: url("${track.coverUrl}")`) : '';
87 | }
88 |
89 | }
90 |
--------------------------------------------------------------------------------
/web/src/app/library/library.theme.scss:
--------------------------------------------------------------------------------
1 | @import '~@angular/material/theming';
2 |
3 | // mixin name will be used in main style.scss
4 | @mixin library-component-theme($theme) {
5 |
6 | // retrieve variables from theme
7 | // (all possible variables, use only what you really need)
8 | $primary: map-get($theme, primary);
9 | $accent: map-get($theme, accent);
10 | $warn: map-get($theme, warn);
11 | $foreground: map-get($theme, foreground);
12 | $background: map-get($theme, background);
13 |
14 | .list {
15 | .mat-list-item {
16 | .avatar {
17 | background-color: mat-color($background, hover);
18 | color: mat-color($foreground, disabled-text);
19 | }
20 | }
21 | }
22 |
23 | .wrapper {
24 | .alphabet {
25 | ol {
26 | color: mat-color($foreground, disabled-text);
27 | li {
28 | &:hover {
29 | color: mat-color($foreground, text);
30 | }
31 | }
32 | }
33 | }
34 | }
35 |
36 | .select-all, .chip-more, .remove-all {
37 | color: mat-color($primary, darker-contrast) !important;
38 | background-color: mat-color($primary, darker) !important;
39 | }
40 |
41 | .mat-chip-list.full-list {
42 | background-color: mat-color($background, dialog);
43 | .mat-chip {
44 | background-color: mat-color($background, background);
45 | }
46 | }
47 |
48 | .mini-player {
49 | background-color: mat-color($background, background);
50 |
51 | .avatar {
52 | background-color: mat-color($background, hover);
53 | color: mat-color($foreground, disabled-text);
54 | }
55 |
56 | .controls {
57 | .playPause {
58 | background-color: mat-color($background, focused-button);
59 | }
60 | .next, .previous {
61 | border: 1px solid mat-color($foreground, divider);
62 | }
63 | }
64 | }
65 |
66 | .player {
67 | .playlist, .playlist-long {
68 | .mat-list-item, li {
69 | color: mat-color($foreground, secondary-text);
70 | &:not(.status-bar):hover {
71 | color: mat-color($foreground, text);
72 | background: mat-color($background, hover);
73 | }
74 | }
75 | .current {
76 | color: mat-color($primary) !important;
77 | }
78 | }
79 | .playPause, .next, .previous {
80 | background-color: mat-color($background, focused-button);
81 | }
82 | .albumArt {
83 | color: mat-color($foreground, disabled-text);
84 | }
85 | }
86 |
87 | //.status-bar {
88 | // color: mat-color($foreground, disabled-text);
89 | //}
90 |
91 | }
92 |
--------------------------------------------------------------------------------
/web/src/app/core/core.utils.ts:
--------------------------------------------------------------------------------
1 | import {environment} from '@env/environment';
2 | import {Observable, Subscription} from 'rxjs';
3 |
4 | export class CoreUtils {
5 |
6 | static allThemes: Theme[] = [
7 | {name: 'Dark/Green', cssClass: 'dark-theme', color: '#212121'},
8 | {name: 'Light/Blue', cssClass: 'light-theme', color: '#F5F5F5'},
9 | {name: 'Blue/Orange', cssClass: 'blue-theme', color: '#263238'},
10 | {name: 'Pink', cssClass: 'pink-theme', color: '#F8BBD0'}
11 | ];
12 |
13 | static featuredThemes: Theme[] = CoreUtils.allThemes.slice(0, 4);
14 |
15 | static save(key: string, value: string) {
16 | window.localStorage.setItem(key, value);
17 | }
18 |
19 | static remove(key: string) {
20 | window.localStorage.removeItem(key);
21 | }
22 |
23 | static load(key: string): string {
24 | return window.localStorage.getItem(key);
25 | }
26 |
27 | static restoreAndSave(id: string, onLoad: (saved: T) => void, saveWhen: Observable, ifNotLoaded?: () => void): Subscription {
28 | const savedItem = CoreUtils.load(id);
29 | if (savedItem) {
30 | try {
31 | onLoad(JSON.parse(savedItem));
32 | } catch (e) {
33 | console.log(e);
34 | }
35 | } else {
36 | if (ifNotLoaded) { ifNotLoaded(); }
37 | }
38 | return saveWhen.subscribe(
39 | elem => CoreUtils.save(id, JSON.stringify(elem))
40 | );
41 | }
42 |
43 | static resolveUrl(sourceUrl: string) {
44 | if (environment.electron) {
45 | return 'http://localhost:' + environment.httpPort + sourceUrl;
46 | } else if (environment.production) {
47 | return sourceUrl;
48 | } else {
49 | return `${window.location.protocol}//${window.location.hostname}:${environment.httpPort}${sourceUrl}`;
50 | }
51 | }
52 |
53 | static isScrolledIntoView(el: Element, refElement: Element = null): boolean {
54 | const rect = el.getBoundingClientRect();
55 | const elemTop = rect.top;
56 | const elemBottom = rect.bottom;
57 | if (refElement) {
58 | const refRect = refElement.getBoundingClientRect();
59 | const refTop = refRect.top;
60 | const refBottom = refRect.bottom;
61 | return (elemTop >= refTop) && (elemBottom <= refBottom);
62 | }
63 | return (elemTop >= 0) && (elemBottom <= window.innerHeight);
64 | }
65 |
66 | static isHorizontallyVisible(el: Element): boolean {
67 | const rect = el.getBoundingClientRect();
68 | const elemLeft = rect.left;
69 | return elemLeft < window.innerWidth;
70 | }
71 |
72 | }
73 |
74 | export class Theme {
75 | name: string;
76 | cssClass: string;
77 | color: string;
78 | }
79 |
--------------------------------------------------------------------------------
/web/src/app/core/core.effects.ts:
--------------------------------------------------------------------------------
1 | import {Injectable} from '@angular/core';
2 | import {OverlayContainer} from '@angular/cdk/overlay';
3 | import {MatDialog} from '@angular/material';
4 | import {Router} from '@angular/router';
5 | import {Store} from '@ngrx/store';
6 | import {Actions, Effect, ofType} from '@ngrx/effects';
7 |
8 | import {Observable} from 'rxjs';
9 | import {filter, map, tap} from 'rxjs/operators';
10 |
11 | import {ChangeTheme, CoreActionTypes} from './actions/core.actions';
12 | import {LoaderService} from './services/loader.service';
13 |
14 | import {ConfirmComponent} from '@app/shared/dialogs/confirm.component';
15 | import * as fromRoot from '@app/app.reducers';
16 | // TODO dependency on library!
17 | import {ScanTracks} from '@app/library/actions/tracks.actions';
18 |
19 | @Injectable()
20 | export class CoreEffects {
21 |
22 | // Change Theme
23 | @Effect({ dispatch: false })
24 | themes$: Observable =
25 | this.actions$.pipe(
26 | ofType(CoreActionTypes.ChangeTheme),
27 | tap((action: ChangeTheme) => {
28 | this.overlayContainer.getContainerElement().className = 'cdk-overlay-container ' + action.payload.cssClass;
29 | }),
30 | map(() => {})
31 | );
32 |
33 | // First Notification
34 | @Effect({ dispatch: false })
35 | notify$ =
36 | this.loader.getSharedSocket().pipe(
37 | filter(next => (next.method === 'Notify') && next.id === 0),
38 | tap(next => {
39 | if (next.entity === 'First_Launch') {
40 | const title = 'Thank you for using Musicalypse!';
41 | const message = 'It looks like this is your first launch of Musicalypse. ' +
42 | 'Before you can listen to your music you need to go to Settings and scan your library. ' +
43 | 'By default Musicalypse will use the "Music" folder in your user home if it exists. ' +
44 | 'Do you want to scan it now?';
45 | this.dialog.open(ConfirmComponent, {data: {title: title, message: message}})
46 | .afterClosed()
47 | .subscribe(
48 | ok => {
49 | if (ok) {
50 | this.store.dispatch(new ScanTracks());
51 | } else {
52 | this.router.navigate(['/settings']);
53 | }
54 | }
55 | );
56 | }
57 | })
58 | );
59 |
60 | constructor(
61 | private actions$: Actions,
62 | private overlayContainer: OverlayContainer,
63 | private loader: LoaderService,
64 | private dialog: MatDialog,
65 | private store: Store,
66 | private router: Router
67 | ) {}
68 |
69 | }
70 |
--------------------------------------------------------------------------------
/web/src/app/library/reducers/playlists.reducers.ts:
--------------------------------------------------------------------------------
1 | import {List} from 'immutable';
2 |
3 | import {ImmutablePlaylist, Playlist, toImmutable} from '@app/model';
4 | import {PlaylistsActionTypes, PlaylistsActionUnion} from '@app/library/actions/playlists.actions';
5 |
6 | export interface State {
7 | playlists: List;
8 | }
9 |
10 | export const initialState: State = {
11 | playlists: List(),
12 | };
13 |
14 | export function reducer(
15 | state = initialState,
16 | action: PlaylistsActionUnion
17 | ): State {
18 | switch (action.type) {
19 |
20 | case PlaylistsActionTypes.AddToPlaylist: {
21 | const playlistIndex = state.playlists.findIndex(p => p.get('name') === action.playlistName);
22 | if (playlistIndex === -1) {
23 | return {
24 | ...state,
25 | playlists: state.playlists.push(toImmutable({name: action.playlistName, tracks: action.tracks}))
26 | };
27 | } else {
28 | const playlist = state.playlists.get(playlistIndex);
29 | return {
30 | ...state,
31 | playlists: state.playlists.set(
32 | playlistIndex,
33 | playlist.set('tracks', playlist.get('tracks').union(action.tracks))
34 | )
35 | };
36 | }
37 | }
38 |
39 | case PlaylistsActionTypes.RemoveFromPlaylist: {
40 | const playlist = state.playlists.find(p => p.get('name') === action.playlistName);
41 | return {
42 | ...state,
43 | playlists: state.playlists.set(
44 | state.playlists.indexOf(playlist),
45 | playlist.set('tracks', playlist.get('tracks').delete(action.track))
46 | )
47 | };
48 | }
49 |
50 | case PlaylistsActionTypes.LoadPlaylists: {
51 | const playlists = action.playlists.map(toImmutable);
52 | return {
53 | ...state,
54 | playlists: List.of(...playlists)
55 | };
56 | }
57 |
58 | case PlaylistsActionTypes.SavePlaylist: {
59 | const playlist = state.playlists.find(p => p.get('name') === action.name);
60 | const playlists = playlist ?
61 | state.playlists.set(state.playlists.indexOf(playlist), toImmutable({name: action.name, tracks: action.tracks})) :
62 | state.playlists.push(toImmutable({name: action.name, tracks: action.tracks}));
63 | return {
64 | ...state,
65 | playlists: playlists
66 | };
67 | }
68 |
69 | case PlaylistsActionTypes.DeletePlaylist: {
70 | const index = state.playlists.findIndex(p => p.get('name') === action.name);
71 | const playlists = index > -1 ? state.playlists.remove(index) : state.playlists;
72 | return {
73 | ...state,
74 | playlists: playlists
75 | };
76 | }
77 |
78 | default: {
79 | return state;
80 | }
81 | }
82 | }
83 |
84 | export const getPlaylists = (state: State) => state.playlists.toJS() as Playlist[];
85 |
--------------------------------------------------------------------------------
/web/src/app/settings/settings.actions.ts:
--------------------------------------------------------------------------------
1 | import {Action} from '@ngrx/store';
2 | import {LyricsOptions} from '@app/model';
3 |
4 | export enum SettingsActionTypes {
5 | AddLibraryFolder = 'settings/library/add',
6 | AddLibraryFolderSuccess = 'settings/library/add/success',
7 | AddLibraryFolderFailure = 'settings/library/add/failure',
8 | RemoveLibraryFolder = 'settings/library/remove',
9 | RemoveLibraryFolderSuccess = 'settings/library/remove/success',
10 | RemoveLibraryFolderFailure = 'settings/library/remove/failure',
11 | LoadLibraryFolders = 'settings/library/load',
12 | LoadLibraryFoldersSuccess = 'settings/library/load/success',
13 | LoadLibraryFoldersFailure = 'settings/library/load/failure',
14 | SetLyricsOptions = 'settings/lyrics'
15 | }
16 |
17 | export class AddLibraryFolder implements Action {
18 | readonly type = SettingsActionTypes.AddLibraryFolder;
19 | constructor(public payload: string) {}
20 | }
21 |
22 | export class AddLibraryFolderSuccess implements Action {
23 | readonly type = SettingsActionTypes.AddLibraryFolderSuccess;
24 | constructor(public payload: string) {}
25 | }
26 |
27 | export class AddLibraryFolderFailure implements Action {
28 | readonly type = SettingsActionTypes.AddLibraryFolderFailure;
29 | constructor(public payload: string) {}
30 | }
31 |
32 | export class RemoveLibraryFolder implements Action {
33 | readonly type = SettingsActionTypes.RemoveLibraryFolder;
34 | constructor(public payload: string) {}
35 | }
36 |
37 | export class RemoveLibraryFolderSuccess implements Action {
38 | readonly type = SettingsActionTypes.RemoveLibraryFolderSuccess;
39 | constructor(public payload: string) {}
40 | }
41 |
42 | export class RemoveLibraryFolderFailure implements Action {
43 | readonly type = SettingsActionTypes.RemoveLibraryFolderFailure;
44 | constructor(public payload: string) {}
45 | }
46 |
47 | export class LoadLibraryFolders implements Action {
48 | readonly type = SettingsActionTypes.LoadLibraryFolders;
49 | }
50 |
51 | export class LoadLibraryFoldersSuccess implements Action {
52 | readonly type = SettingsActionTypes.LoadLibraryFoldersSuccess;
53 | constructor(public payload: string[]) {}
54 | }
55 |
56 | export class LoadLibraryFoldersFailure implements Action {
57 | readonly type = SettingsActionTypes.LoadLibraryFoldersFailure;
58 | constructor(public payload: string) {}
59 | }
60 |
61 | export class SetLyricsOptions implements Action {
62 | readonly type = SettingsActionTypes.SetLyricsOptions;
63 | constructor(public payload: LyricsOptions) {}
64 | }
65 |
66 | export type SettingsActionsUnion =
67 | AddLibraryFolder |
68 | AddLibraryFolderSuccess |
69 | AddLibraryFolderFailure |
70 | RemoveLibraryFolder |
71 | RemoveLibraryFolderSuccess |
72 | RemoveLibraryFolderFailure |
73 | LoadLibraryFolders |
74 | LoadLibraryFoldersSuccess |
75 | LoadLibraryFoldersFailure |
76 | SetLyricsOptions;
77 |
--------------------------------------------------------------------------------
/web/src/app/library/components/shared/list-item.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@angular/core';
2 |
3 | @Component({
4 | selector: `app-list-item`,
5 | template: `
6 |
7 |
8 |
13 |
14 |
15 |
16 | music_note
17 |
18 |
19 |
20 | warning
21 |
22 |
23 |
24 |
25 |
26 |
27 | keyboard_arrow_right
28 |
29 |
30 |
31 |
32 |
33 | `,
34 | styles: [`
35 | .list-item {
36 | cursor: pointer;
37 | text-decoration: none;
38 | }
39 | .list-item:hover mat-checkbox {
40 | display: block;
41 | }
42 | .list-item:hover .avatar {
43 | display: none;
44 | }
45 | mat-checkbox {
46 | display: none;
47 | padding: 0 !important;
48 | line-height: 0;
49 | width: 20px !important;
50 | height: 20px !important;
51 | margin: 0 0.5rem !important;
52 | }
53 | .mat-checkbox-checked {
54 | display: block;
55 | }
56 | .mat-checkbox-checked ~ .avatar {
57 | display: none;
58 | }
59 | .avatar {
60 | display: flex;
61 | flex-direction: row;
62 | justify-content: center;
63 | align-items: center;
64 | background-size: cover;
65 | width: 40px;
66 | height: 40px;
67 | }
68 | .primary-text {
69 | font-size: 14px !important;
70 | }
71 | mat-icon.warn {
72 | height: 14px;
73 | width: 14px;
74 | font-size: 14px;
75 | line-height: 14px;
76 | vertical-align: middle;
77 | margin-right: 0.2rem;
78 | }
79 | .secondary-text {
80 | font-size: 12px;
81 | }
82 | `],
83 | changeDetection: ChangeDetectionStrategy.OnPush
84 | })
85 | export class ListItemComponent {
86 |
87 | @Input() selected: boolean;
88 | @Input() avatarStyle: string;
89 | @Input() warn: boolean;
90 | @Input() primaryHTML: string;
91 | @Input() secondaryHTML: string;
92 | @Input() tooltip: string;
93 |
94 | @Output() checked = new EventEmitter();
95 | @Output() arrowClicked = new EventEmitter();
96 |
97 | }
98 |
--------------------------------------------------------------------------------
/web/src/app/core/components/toolbar.component.scss:
--------------------------------------------------------------------------------
1 | mat-toolbar {
2 | min-height: 50px;
3 | max-height: 50px;
4 | z-index: 4;
5 | position: relative;
6 | h1 {
7 | font-family: 'Cookie', cursive;
8 | font-size: 30px;
9 | }
10 | .toggle {
11 | margin-left: -0.5rem;
12 | margin-right: 0.5rem;
13 | }
14 | }
15 |
16 | :host-context(.electron) mat-toolbar {
17 | user-select: none;
18 | -webkit-app-region: drag;
19 | height: 34px !important;
20 | min-height: 34px;
21 |
22 | a, button, .electron-buttons, .theme-chooser {
23 | -webkit-app-region: no-drag;
24 | }
25 |
26 | h1 {
27 | font-family: 'Cookie', cursive;
28 | font-size: 24px;
29 | }
30 | }
31 |
32 | @media screen and (min-width: 599px){
33 | mat-toolbar {
34 | .toggle {
35 | display: none;
36 | }
37 | }
38 | }
39 |
40 | .filler {
41 | flex-grow: 1;
42 | }
43 |
44 | .micro-player {
45 | margin-right: 0.5rem;
46 | }
47 |
48 | .electron-buttons {
49 | display: flex;
50 | flex-direction: row;
51 | position: absolute;
52 | top: 0;
53 | right: 0;
54 | mat-icon {
55 | cursor: pointer;
56 | padding: 9px 14px;
57 | box-sizing: content-box;
58 | font-size: 16px;
59 | height: 16px;
60 | width: 16px;
61 | line-height: 16px;
62 | &.electron-theme {
63 | display: flex;
64 | justify-content: center;
65 | align-items: center;
66 | }
67 | }
68 | }
69 |
70 | $theme-chooser-height: 80px;
71 | $theme-chooser-width: 80px;
72 |
73 | .theme-chooser {
74 | position: absolute;
75 | height: $theme-chooser-height;
76 | width: $theme-chooser-width;
77 | padding: 0.5rem;
78 | z-index: 101;
79 | border-radius: 3px;
80 | box-sizing: content-box;
81 |
82 | visibility: hidden;
83 | opacity: 0;
84 | top: 10px;
85 | right: 10px;
86 | transform: scale(0.6);
87 | transform-origin: top right;
88 |
89 | transition-property: opacity, visibility, transform;
90 | transition-duration: .3s;
91 | transition-timing-function: ease-in-out;
92 | }
93 |
94 | .theme-chooser.visible {
95 | visibility: visible;
96 | opacity: 1;
97 | transform: scale(1);
98 | }
99 |
100 | .theme-chooser-backdrop {
101 | position: fixed;
102 | top: 0;
103 | right: 0;
104 | bottom: 0;
105 | left: 0;
106 | z-index: 100;
107 | }
108 |
109 | .theme-chooser ol {
110 | list-style-type: none;
111 | display: flex;
112 | flex-direction: row;
113 | flex-wrap: wrap;
114 | justify-content: space-between;
115 | margin: 0;
116 | padding: 0;
117 |
118 | li {
119 | display: inline-block;
120 | width: $theme-chooser-width / 2 - 4px;
121 | height: $theme-chooser-height / 2 - 4px;;
122 | margin-bottom: 0.5rem;
123 | line-height: 0;
124 |
125 | button {
126 | width: 100%;
127 | height: 100%;
128 | background-color: grey;
129 | line-height: 0;
130 | }
131 |
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/web/src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Musicalypse
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
60 |
61 |
62 |
63 |
72 | Loading...
73 |
74 |
75 |
76 |
77 |
--------------------------------------------------------------------------------
/web/src/app/core/reducers/audio.reducers.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * State
3 | */
4 | import {AudioActionsUnion, AudioActionTypes} from '@app/core/actions/audio.actions';
5 |
6 | export interface State {
7 | audioInput: {
8 | source: string;
9 | volume: number;
10 | muted: boolean;
11 | };
12 | audioState: {
13 | loading: boolean;
14 | playing: boolean;
15 | duration: number;
16 | error: string;
17 | };
18 | }
19 |
20 | const initialState: State = {
21 | audioInput: {
22 | source: null,
23 | volume: 1,
24 | muted: false,
25 | },
26 | audioState: {
27 | loading: false,
28 | playing: false,
29 | duration: 0,
30 | error: null
31 | },
32 | };
33 |
34 | /**
35 | * Reducer
36 | */
37 | export function reducer(
38 | state: State = initialState,
39 | action: AudioActionsUnion
40 | ): State {
41 | switch (action.type) {
42 |
43 | case AudioActionTypes.SetAudioSource:
44 | return {
45 | ...state,
46 | audioInput: {
47 | ...state.audioInput,
48 | source: action.payload
49 | }
50 | };
51 |
52 | case AudioActionTypes.SetAudioVolume:
53 | return {
54 | ...state,
55 | audioInput: {
56 | ...state.audioInput,
57 | volume: action.payload
58 | }
59 | };
60 |
61 | case AudioActionTypes.SetAudioMuted:
62 | return {
63 | ...state,
64 | audioInput: {
65 | ...state.audioInput,
66 | muted: action.payload
67 | }
68 | };
69 |
70 | case AudioActionTypes.SetAudioDuration:
71 | return {
72 | ...state,
73 | audioState: {
74 | ...state.audioState,
75 | duration: action.payload
76 | }
77 | };
78 |
79 | case AudioActionTypes.SetAudioError:
80 | return {
81 | ...state,
82 | audioState: {
83 | ...state.audioState,
84 | error: action.payload
85 | }
86 | };
87 |
88 | case AudioActionTypes.SetAudioPlaying:
89 | return {
90 | ...state,
91 | audioState: {
92 | ...state.audioState,
93 | playing: action.payload
94 | }
95 | };
96 |
97 | case AudioActionTypes.SetAudioLoading:
98 | return {
99 | ...state,
100 | audioState: {
101 | ...state.audioState,
102 | loading: action.payload
103 | }
104 | };
105 |
106 | default:
107 | return state;
108 | }
109 | }
110 |
111 | /**
112 | * Selectors
113 | */
114 | export const getAudioInput = (state: State) => state.audioInput;
115 | export const getAudioMuted = (state: State) => state.audioInput.muted;
116 | export const getAudioVolume = (state: State) => state.audioInput.volume;
117 |
118 | export const getAudioPlaying = (state: State) => state.audioState.playing;
119 | export const getAudioLoading = (state: State) => state.audioState.loading;
120 | export const getAudioDuration = (state: State) => state.audioState.duration;
121 |
--------------------------------------------------------------------------------
/web/src/app/my-music/components/albums.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges} from '@angular/core';
2 | import {Album, Artist} from '@app/model';
3 | import {DomSanitizer} from '@angular/platform-browser';
4 |
5 | @Component({
6 | selector: 'app-my-music-albums',
7 | template: `
8 |
25 |
26 |
31 |
32 | `,
33 | styles: [`
34 | .controls {
35 | padding: 0 1rem;
36 | display: flex;
37 | flex-direction: row;
38 | align-items: center;
39 | flex-wrap: wrap;
40 | }
41 | .play-all {
42 | margin: 1rem 0;
43 | }
44 | .play-all mat-icon {
45 | vertical-align: middle;
46 | margin-right: 0.2rem;
47 | }
48 | .filler {
49 | flex-grow: 1;
50 | }
51 | .search {
52 | min-width: 13rem;
53 | }
54 | `],
55 | changeDetection: ChangeDetectionStrategy.OnPush
56 | })
57 | export class AlbumsComponent implements OnChanges {
58 |
59 | @Input() albums: Album[];
60 |
61 | displayedAlbums: Album[];
62 |
63 | _search = '';
64 | set search(value: string) {
65 | this._search = value;
66 | this.displayedAlbums = this.filter(this.albums);
67 | }
68 | get search() {
69 | return this._search;
70 | }
71 |
72 | primaryFunc = (album: Album) => album.title;
73 | secondaryFunc = (album: Album) => album.artist;
74 |
75 | constructor(private sanitizer: DomSanitizer) {}
76 |
77 | ngOnChanges(changes: SimpleChanges): void {
78 | if (changes.albums) {
79 | const albums = changes.albums.currentValue;
80 | this.displayedAlbums = this.filter(albums);
81 | }
82 | }
83 |
84 | getAvatarStyle(album: Album) {
85 | return album.avatarUrl ? this.sanitizer.bypassSecurityTrustStyle(`background-image: url("${album.avatarUrl}")`) : '';
86 | }
87 |
88 | play(album: Artist | Album) {
89 | console.log(album);
90 | }
91 |
92 | filter(albums: Album[]): Album[] {
93 | const toStringAlbum = (album: Album) => `${album.artist} ${album.title}`;
94 | return albums.filter(album => toStringAlbum(album).toLowerCase().includes(this._search.toLowerCase().trim()));
95 | }
96 |
97 | }
98 |
--------------------------------------------------------------------------------
/web/src/app/shared/dialogs/details.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, Inject, OnInit} from '@angular/core';
2 | import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material';
3 | // import {CoreUtils} from '@app/core/core.utils';
4 |
5 | @Component({
6 | selector: 'app-details',
7 | template: `
8 | Details of "{{ data.track.title }}"
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
42 |
43 |
44 |
45 |
46 |
47 | Ok
48 |
50 |
51 | `,
52 | styles: [`
53 | mat-form-field {
54 | width: 100%;
55 | display: block;
56 | }
57 | mat-grid-list {
58 | height: 300px;
59 | width: 452px;
60 | }
61 | /*.location, .url {
62 | font-size: 80%;
63 | }*/
64 | `],
65 | changeDetection: ChangeDetectionStrategy.OnPush
66 | })
67 | export class DetailsComponent implements OnInit {
68 |
69 | constructor(
70 | public dialogRef: MatDialogRef,
71 | @Inject(MAT_DIALOG_DATA) public data: any
72 | ) { }
73 |
74 | ngOnInit() {
75 | }
76 |
77 | cancel() {
78 | this.dialogRef.close();
79 | }
80 |
81 | /* download() {
82 | window.open(CoreUtils.resolveUrl(this.data.track.url), '_blank');
83 | }*/
84 |
85 | }
86 |
87 |
--------------------------------------------------------------------------------
/web/src/app/my-music/components/artists.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges} from '@angular/core';
2 | import {Album, Artist} from '@app/model';
3 | import {DomSanitizer} from '@angular/platform-browser';
4 |
5 | @Component({
6 | selector: 'app-my-music-artists',
7 | template: `
8 |
25 |
26 |
31 |
32 | `,
33 | styles: [`
34 | .controls {
35 | padding: 0 1rem;
36 | display: flex;
37 | flex-direction: row;
38 | align-items: center;
39 | flex-wrap: wrap;
40 | }
41 | .play-all {
42 | margin: 1rem 0
43 | }
44 | .play-all mat-icon {
45 | vertical-align: middle;
46 | margin-right: 0.2rem;
47 | }
48 | .filler {
49 | flex-grow: 1;
50 | }
51 | .search {
52 | min-width: 13rem;
53 | }
54 | `],
55 | changeDetection: ChangeDetectionStrategy.OnPush
56 | })
57 | export class ArtistsComponent implements OnChanges {
58 |
59 | @Input() artists: Artist[];
60 |
61 | displayedArtists: Artist[];
62 |
63 | _search = '';
64 | set search(value: string) {
65 | this._search = value;
66 | this.displayedArtists = this.filter(this.artists);
67 | }
68 | get search() {
69 | return this._search;
70 | }
71 |
72 | primaryFunc = (artist: Artist) => artist.name;
73 | secondaryFunc = (artist: Artist) => artist.songs + ' song' + (artist.songs > 1 ? 's' : '');
74 |
75 | constructor(private sanitizer: DomSanitizer) {}
76 |
77 | ngOnChanges(changes: SimpleChanges): void {
78 | if (changes.artists) {
79 | const artists = changes.artists.currentValue;
80 | this.displayedArtists = this.filter(artists);
81 | }
82 | }
83 |
84 | getAvatarStyle(artist: Artist) {
85 | return artist.avatarUrl ? this.sanitizer.bypassSecurityTrustStyle(`background-image: url("${artist.avatarUrl}")`) : '';
86 | }
87 |
88 | play(album: Artist | Album) {
89 | console.log(album);
90 | }
91 |
92 | filter(artists: Artist[]): Artist[] {
93 | const toStringAlbum = (artist: Artist) => `${artist.name}`;
94 | return artists.filter(artist => toStringAlbum(artist).toLowerCase().includes(this._search.toLowerCase().trim()));
95 | }
96 |
97 | }
98 |
--------------------------------------------------------------------------------
/web/src/app/settings/components/lyrics-options.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@angular/core';
2 | import {LyricsOptions} from '@app/model';
3 |
4 | @Component({
5 | selector: 'app-lyrics-options',
6 | template: `
7 |
8 |
9 | Find lyrics on the Web
10 |
11 |
12 |
13 |
15 | Search on lyrics.wikia.com
16 |
18 | open_in_new
19 |
20 |
21 |
22 |
23 |
25 | Use the lyrics.ovh service
26 |
28 | open_in_new
29 |
30 |
31 |
32 |
33 |
34 | Save found lyrics automatically
35 | info
36 |
37 |
38 | `,
39 | styles: [`
40 | mat-icon.small {
41 | margin-left: 0.25rem;
42 | font-size: 18px;
43 | height: 18px;
44 | width: 18px;
45 | position: relative;
46 | top: 4px
47 | }
48 | .sub {
49 | padding-left: 1.2rem;
50 | }
51 | `],
52 | changeDetection: ChangeDetectionStrategy.OnPush
53 | })
54 | export class LyricsOptionsComponent {
55 |
56 | @Input() lyricsOpts: LyricsOptions;
57 | @Output() linkClicked: EventEmitter = new EventEmitter();
58 | @Output() optionsChanged: EventEmitter = new EventEmitter();
59 |
60 | lyricsSaveTooltip = 'If you enable this option, every time lyrics are found they are saved on disk for future use.' +
61 | ' Otherwise they are saved on disk only if you edit them.';
62 |
63 | toggleService() {
64 | if (!this.lyricsOpts.services.wikia && !this.lyricsOpts.services.lyricsOvh) {
65 | this.lyricsOpts.useService = false;
66 | }
67 | this.save();
68 | }
69 |
70 | toggleUseService() {
71 | if (this.lyricsOpts.useService && !(this.lyricsOpts.services.wikia || this.lyricsOpts.services.lyricsOvh)) {
72 | this.lyricsOpts.services.wikia = true;
73 | this.lyricsOpts.services.lyricsOvh = true;
74 | }
75 | this.save();
76 | }
77 |
78 | save() {
79 | this.optionsChanged.emit(this.lyricsOpts);
80 | }
81 |
82 | }
83 |
--------------------------------------------------------------------------------
/web/src/app/core/core.theme.scss:
--------------------------------------------------------------------------------
1 | @import '~@angular/material/theming';
2 |
3 | @mixin core-theme($theme) {
4 |
5 | $primary: map-get($theme, primary);
6 | $accent: map-get($theme, accent);
7 | $warn: map-get($theme, warn);
8 | $foreground: map-get($theme, foreground);
9 | $background: map-get($theme, background);
10 |
11 | *::-webkit-scrollbar {
12 | width: 6px;
13 | height: 6px;
14 | }
15 | *::-webkit-scrollbar-track {
16 | background-color: mat-color($background, app-bar);
17 | }
18 | *::-webkit-scrollbar-thumb {
19 | background-color: mat-color($foreground, disabled-button);
20 | }
21 | *::-webkit-scrollbar-button {
22 | display: none;
23 | }
24 |
25 | .app-loader {
26 | background-color: mat-color($background, background);
27 | color: mat-color($foreground, text);
28 | }
29 |
30 | .divider {
31 | border-bottom: 1px solid mat-color($foreground, divider);
32 | }
33 |
34 | .fake-scroll-y {
35 | border-right: 5.5px solid mat-color($background, app-bar);
36 | }
37 |
38 | .hover:hover {
39 | background-color: mat-color($background, hover);
40 | }
41 |
42 | .link {
43 | text-decoration: none;
44 | color: mat-color($foreground, text);
45 | }
46 |
47 | .accent {
48 | color: mat-color($accent);
49 | }
50 |
51 | .warn {
52 | color: mat-color($warn);
53 | }
54 |
55 | .secondary-text {
56 | color: mat-color($foreground, secondary-text);
57 | }
58 |
59 | .mat-list-item.accent .mat-line {
60 | color: mat-color($accent);
61 | }
62 |
63 | .mat-list-item.warn .mat-line {
64 | color: mat-color($warn);
65 | }
66 |
67 | .theme-chooser {
68 | background-color: mat-color($background, dialog);
69 | border: 1px solid mat-color($background, status-bar);
70 | button {
71 | border: 1px solid mat-color($foreground, divider);
72 | mat-icon {
73 | color: mat-color($warn);
74 | }
75 | }
76 | }
77 |
78 | .hover-background {
79 | background-color: mat-color($background, hover);
80 | }
81 |
82 | .outline-dashed {
83 | outline: 2px dashed mat-color($foreground, icon);
84 | }
85 |
86 | mat-toolbar {
87 | background-color: mat-color($background, status-bar) !important;
88 | box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.3);
89 | }
90 |
91 | .side-menu {
92 | background-color: mat-color($background, app-bar);
93 | color: mat-color($foreground, text);
94 | box-shadow: 0 0 4px 0 rgba(0, 0, 0, 0.3);
95 | mat-icon {
96 | padding: 5px;
97 | }
98 | }
99 |
100 | mat-nav-list, .side-menu {
101 | .active {
102 | color: mat-color($primary) !important;
103 | }
104 | }
105 |
106 | &.electron .electron-buttons {
107 | :hover {
108 | background-color: mat-color($background, hover);
109 | }
110 | .close:hover {
111 | background-color: mat-color($warn);
112 | }
113 | }
114 |
115 | &.main-wrapper {
116 | border: 1px solid mat-color($background, app-bar);
117 | }
118 |
119 | &.main-wrapper.electron.focused {
120 | border: 1px solid mat-color($primary);
121 | }
122 |
123 | .about {
124 | a {
125 | color: mat-color($foreground, text);
126 | }
127 | }
128 |
129 | }
130 |
--------------------------------------------------------------------------------
/src/main/scala/net/creasource/web/package.scala:
--------------------------------------------------------------------------------
1 | package net.creasource
2 |
3 | import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport
4 | import akka.http.scaladsl.model._
5 | import akka.util.ByteString
6 | import spray.json._
7 |
8 | import scala.util.Try
9 |
10 | package object web {
11 |
12 | case class JsonMessage(method: String, id: Int, entity: JsValue)
13 |
14 | object JsonMessage extends JsonSupport {
15 | def unapply(arg: JsValue): Option[(String, Int, JsValue)] = {
16 | Try(arg.convertTo[JsonMessage]).toOption.map(m => (m.method, m.id, m.entity))
17 | }
18 | }
19 |
20 | trait JsonSupport extends SprayJsonSupport with DefaultJsonProtocol {
21 | implicit val jsonMessageFormat: RootJsonFormat[JsonMessage] = jsonFormat3(JsonMessage.apply)
22 | implicit val httpHeaderWriter: RootJsonWriter[HttpHeader] = (obj: HttpHeader) => {
23 | JsObject(
24 | "name" -> JsString(obj.name()),
25 | "value" -> JsString(obj.value())
26 | )
27 | }
28 | implicit val httpResponseFormat: RootJsonWriter[HttpResponse] = (obj: HttpResponse) => JsObject(
29 | "status" -> JsNumber(obj.status.intValue),
30 | "statusText" -> JsString(obj.status.reason),
31 | "entity" -> (obj.entity match {
32 | case HttpEntity.Strict(ct@ContentTypes.`application/json`, body) => JsonParser(body.decodeString(ct.charset.value))
33 | case HttpEntity.Strict(ct@ContentTypes.`text/plain(UTF-8)`, body) => JsString(body.decodeString(ct.charset.value))
34 | case _ => throw new UnsupportedOperationException("Only strict application/json and text/plain endpoints are supported.")
35 | })
36 | //"headers" -> JsArray(obj.headers.map(_.toJson).toVector)
37 | )
38 | implicit val httpRequestFormat: RootJsonReader[HttpRequest] = (json: JsValue) => {
39 | val (method, uri, headers, entity) = json match {
40 | case js: JsObject =>
41 | val method = js.fields.get("method") match {
42 | case Some(JsString("GET")) => HttpMethods.GET
43 | case Some(JsString("POST")) => HttpMethods.POST
44 | case Some(JsString("PUT")) => HttpMethods.PUT
45 | case Some(JsString("DELETE")) => HttpMethods.DELETE
46 | case Some(JsString("OPTIONS")) => HttpMethods.OPTIONS
47 | case Some(JsString("HEAD")) => HttpMethods.HEAD
48 | case Some(m) => throw new UnsupportedOperationException(s"Method $m is not supported.")
49 | case _ => throw new UnsupportedOperationException(s"No Method header found.")
50 | }
51 | val uri = js.fields.get("url") match {
52 | case Some(JsString(url)) => Uri(url)
53 | case _ => throw new UnsupportedOperationException(s"No string url parameter found.")
54 | }
55 | val entity = js.fields.get("entity") match {
56 | case None => HttpEntity.Empty
57 | case Some(value: JsValue) => HttpEntity.Strict(ContentTypes.`application/json`, ByteString(value.compactPrint))
58 | }
59 | (method, uri, Nil, entity)
60 | case _ => throw new UnsupportedOperationException("The body of an HttpRequest message must be a JsObject.")
61 | }
62 | HttpRequest(method = method, uri = uri, headers = headers, entity = entity)
63 | }
64 | }
65 |
66 | object JsonSupport extends JsonSupport
67 |
68 | }
69 |
--------------------------------------------------------------------------------
/web/src/app/library/library.utils.ts:
--------------------------------------------------------------------------------
1 | import {Album, Artist, Track} from '@app/model';
2 | import {CoreUtils} from '@app/core/core.utils';
3 |
4 | export class LibraryUtils {
5 |
6 | static fixTags(track: Track): Track {
7 | if (track.title === undefined || track.title === '') {
8 | const components = track.url.split('/');
9 | track.title = components[components.length - 1];
10 | // track.warn = true;
11 | }
12 | if (track.albumArtist === undefined || track.albumArtist === '') {
13 | track.albumArtist = track.artist || 'Unknown Album Artist';
14 | // track.warn = true;
15 | }
16 | if (track.artist === undefined || track.artist === '') {
17 | track.artist = 'Unknown Artist';
18 | // track.warn = true;
19 | }
20 | if (track.album === undefined || track.album === '') {
21 | track.album = 'Unknown Album';
22 | // track.warn = true;
23 | }
24 | if (track.coverUrl) {
25 | track.coverUrl = CoreUtils.resolveUrl(encodeURI(track.coverUrl));
26 | }
27 | return track;
28 | }
29 |
30 | static extractArtists(tracks: Track[]): Artist[] {
31 | const artists: Artist[] = [];
32 | tracks.forEach(track => {
33 | const artist = track.albumArtist;
34 | const artistIndex = artists.findIndex(a => a.name === artist);
35 | if (artistIndex === -1) {
36 | const newArtist: Artist = {name: artist, songs: 1};
37 | // if (track.warn) { newArtist.warn = true; }
38 | if (track.coverUrl) { newArtist.avatarUrl = track.coverUrl; }
39 | artists.push(newArtist);
40 | } else {
41 | // if (track.warn) { artists[artistIndex].warn = true; }
42 | if (track.coverUrl) { artists[artistIndex].avatarUrl = track.coverUrl; }
43 | artists[artistIndex].songs += 1;
44 | }
45 | });
46 | return artists;
47 | }
48 |
49 | static extractAlbums(tracks: Track[]): Album[] {
50 | const albums: Album[] = [];
51 | tracks.forEach(track => {
52 | const artist = track.albumArtist;
53 | const album = track.album;
54 | const albumIndex = albums.findIndex(a => a.title === album && a.artist === artist);
55 | if (albumIndex === -1) {
56 | const newAlbum: Album = {title: album, artist: artist, songs: 1};
57 | // if (track.warn) { newAlbum.warn = true; }
58 | if (track.coverUrl) { newAlbum.avatarUrl = track.coverUrl; }
59 | albums.push(newAlbum);
60 | } else {
61 | // if (track.warn) { albums[albumIndex].warn = true; }
62 | if (track.coverUrl) { albums[albumIndex].avatarUrl = track.coverUrl; }
63 | albums[albumIndex].songs += 1;
64 | }
65 | });
66 | return albums;
67 | }
68 |
69 | static shuffleArray(a: T[]): T[] {
70 | for (let i = a.length - 1; i > 0; i--) {
71 | const j = Math.floor(Math.random() * (i + 1));
72 | [a[i], a[j]] = [a[j], a[i]];
73 | }
74 | return a;
75 | }
76 |
77 | static uniq(a: T[]): T[] {
78 | return a.filter((v, i, s) => s.indexOf(v) === i);
79 | }
80 |
81 | static uniqBy(a: T[], p: (t: T) => any): T[] {
82 | const interMap = a.reduce(
83 | (map, item) => {
84 | const key = p(item);
85 | if (!map.has(key)) { map.set(key, item); }
86 | return map;
87 | },
88 | new Map()
89 | );
90 | return Array.from(interMap.values());
91 | }
92 |
93 | }
94 |
--------------------------------------------------------------------------------
/web/src/polyfills.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This file includes polyfills needed by Angular and is loaded before the app.
3 | * You can add your own extra polyfills to this file.
4 | *
5 | * This file is divided into 2 sections:
6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main
8 | * file.
9 | *
10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that
11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
13 | *
14 | * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html
15 | */
16 |
17 | /***************************************************************************************************
18 | * BROWSER POLYFILLS
19 | */
20 |
21 | /** IE9, IE10 and IE11 requires all of the following polyfills. **/
22 | // import 'core-js/es6/symbol';
23 | // import 'core-js/es6/object';
24 | // import 'core-js/es6/function';
25 | // import 'core-js/es6/parse-int';
26 | // import 'core-js/es6/parse-float';
27 | // import 'core-js/es6/number';
28 | // import 'core-js/es6/math';
29 | // import 'core-js/es6/string';
30 | // import 'core-js/es6/date';
31 | // import 'core-js/es6/array';
32 | // import 'core-js/es6/regexp';
33 | // import 'core-js/es6/map';
34 | // import 'core-js/es6/weak-map';
35 | // import 'core-js/es6/set';
36 |
37 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */
38 | // import 'classlist.js'; // Run `npm install --save classlist.js`.
39 |
40 | /** IE10 and IE11 requires the following for the Reflect API. */
41 | // import 'core-js/es6/reflect';
42 |
43 |
44 | /** Evergreen browsers require these. **/
45 | // Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove.
46 | import 'core-js/es7/reflect';
47 |
48 |
49 | /**
50 | * Required to support Web Animations `@angular/platform-browser/animations`.
51 | * Needed for: All but Chrome, Firefox and Opera. http://caniuse.com/#feat=web-animation
52 | **/
53 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`.
54 |
55 | /**
56 | * By default, zone.js will patch all possible macroTask and DomEvents
57 | * user can disable parts of macroTask/DomEvents patch by setting following flags
58 | */
59 |
60 | // (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
61 | // (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
62 | // (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
63 |
64 | /*
65 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
66 | * with the following flag, it will bypass `zone.js` patch for IE/Edge
67 | */
68 | // (window as any).__Zone_enable_cross_context_check = true;
69 |
70 | /***************************************************************************************************
71 | * Zone JS is required by default for Angular itself.
72 | */
73 | import 'zone.js/dist/zone'; // Included with Angular CLI.
74 |
75 |
76 |
77 | /***************************************************************************************************
78 | * APPLICATION IMPORTS
79 | */
80 |
--------------------------------------------------------------------------------
/web/src/app/my-music/components/shared/box-list.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@angular/core';
2 | import {Album, Artist} from '@app/model';
3 | import {DomSanitizer} from '@angular/platform-browser';
4 |
5 | @Component({
6 | selector: 'app-box-list',
7 | template: `
8 |
21 | `,
22 | styles: [`
23 | .list {
24 | display: flex;
25 | flex-direction: row;
26 | flex-wrap: wrap;
27 | list-style: none;
28 | margin: 0;
29 | padding: 0 0.5rem 0.5rem 0.5rem;
30 | justify-content: space-evenly;
31 | }
32 | .item {
33 | width: 150px;
34 | margin: 0.5rem;
35 | display: flex;
36 | flex-direction: column;
37 | }
38 | .item:hover .cover {
39 | box-shadow: 0 5px 10px 2px rgba(0, 0, 0, 0.2);
40 | }
41 | .cover {
42 | box-sizing: border-box;
43 | width: 150px;
44 | height: 150px;
45 | background-size: cover;
46 | margin-bottom: 0.5rem;
47 | display: flex;
48 | align-items: center;
49 | justify-content: center;
50 | cursor: pointer;
51 | }
52 | .cover mat-icon {
53 | width: 60px;
54 | height: 60px;
55 | line-height: 60px;
56 | font-size: 60px;
57 | user-select: none;
58 | }
59 | .play-icon {
60 | color: white;
61 | text-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
62 | display: none;
63 | }
64 | .cover:hover .play-icon {
65 | display: unset;
66 | }
67 | .cover:hover .avatar-icon {
68 | display: none;
69 | }
70 | .primary {
71 | font-weight: 500;
72 | white-space: nowrap;
73 | max-width: 150px;
74 | overflow: hidden;
75 | text-overflow: ellipsis;
76 | }
77 | .secondary {
78 | font-weight: 300;
79 | font-size: 12px;
80 | }
81 | .center .primary, .center .secondary {
82 | text-align: center;
83 | }
84 | @media screen and (min-width: 599px){
85 | .item {
86 | margin: 1rem;
87 | }
88 | }
89 | `],
90 | changeDetection: ChangeDetectionStrategy.OnPush
91 | })
92 | export class BoxListComponent {
93 |
94 | @Input() center: boolean;
95 | @Input() list: (Artist[] | Album[]);
96 | @Input() primaryFunc: (item: Artist | Album) => string;
97 | @Input() secondaryFunc: (item: Artist | Album) => string;
98 |
99 | @Output() itemClicked = new EventEmitter();
100 |
101 | constructor(private sanitizer: DomSanitizer) {}
102 |
103 | getAvatarStyle(item: Artist | Album) {
104 | return item.avatarUrl ? this.sanitizer.bypassSecurityTrustStyle(`background-image: url("${item.avatarUrl}")`) : '';
105 | }
106 |
107 | }
108 |
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "rulesDirectory": [
3 | "node_modules/codelyzer"
4 | ],
5 | "rules": {
6 | "arrow-return-shorthand": true,
7 | "callable-types": true,
8 | "class-name": true,
9 | "comment-format": [
10 | true,
11 | "check-space"
12 | ],
13 | "curly": true,
14 | "deprecation": {
15 | "severity": "warn"
16 | },
17 | "eofline": true,
18 | "forin": true,
19 | "import-blacklist": [
20 | true,
21 | "rxjs/Rx"
22 | ],
23 | "import-spacing": true,
24 | "indent": [
25 | true,
26 | "spaces"
27 | ],
28 | "interface-over-type-literal": true,
29 | "label-position": true,
30 | "max-line-length": [
31 | true,
32 | 140
33 | ],
34 | "member-access": false,
35 | "member-ordering": [
36 | true,
37 | {
38 | "order": [
39 | "static-field",
40 | "instance-field",
41 | "static-method",
42 | "instance-method"
43 | ]
44 | }
45 | ],
46 | "no-arg": true,
47 | "no-bitwise": true,
48 | "no-console": [
49 | true,
50 | "debug",
51 | "info",
52 | "time",
53 | "timeEnd",
54 | "trace"
55 | ],
56 | "no-construct": true,
57 | "no-debugger": true,
58 | "no-duplicate-super": true,
59 | "no-empty": false,
60 | "no-empty-interface": true,
61 | "no-eval": true,
62 | "no-inferrable-types": [
63 | true,
64 | "ignore-params"
65 | ],
66 | "no-misused-new": true,
67 | "no-non-null-assertion": true,
68 | "no-shadowed-variable": true,
69 | "no-string-literal": false,
70 | "no-string-throw": true,
71 | "no-switch-case-fall-through": true,
72 | "no-trailing-whitespace": true,
73 | "no-unnecessary-initializer": true,
74 | "no-unused-expression": true,
75 | "no-use-before-declare": true,
76 | "no-var-keyword": true,
77 | "object-literal-sort-keys": false,
78 | "one-line": [
79 | true,
80 | "check-open-brace",
81 | "check-catch",
82 | "check-else",
83 | "check-whitespace"
84 | ],
85 | "prefer-const": true,
86 | "quotemark": [
87 | true,
88 | "single"
89 | ],
90 | "radix": true,
91 | "semicolon": [
92 | true,
93 | "always"
94 | ],
95 | "triple-equals": [
96 | true,
97 | "allow-null-check"
98 | ],
99 | "typedef-whitespace": [
100 | true,
101 | {
102 | "call-signature": "nospace",
103 | "index-signature": "nospace",
104 | "parameter": "nospace",
105 | "property-declaration": "nospace",
106 | "variable-declaration": "nospace"
107 | }
108 | ],
109 | "unified-signatures": true,
110 | "variable-name": false,
111 | "whitespace": [
112 | true,
113 | "check-branch",
114 | "check-decl",
115 | "check-operator",
116 | "check-separator",
117 | "check-type"
118 | ],
119 | "no-output-on-prefix": true,
120 | "use-input-property-decorator": true,
121 | "use-output-property-decorator": true,
122 | "use-host-property-decorator": true,
123 | "no-input-rename": true,
124 | "no-output-rename": true,
125 | "use-life-cycle-interface": true,
126 | "use-pipe-transform-interface": true,
127 | "component-class-suffix": true,
128 | "directive-class-suffix": true
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/web/src/app/core/components/sidenav.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, EventEmitter, Output} from '@angular/core';
2 |
3 | @Component({
4 | selector: 'app-side-nav',
5 | template: `
6 |
7 | Navigation
8 |
9 |
13 | play_circle_outline
14 | Playing now
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
27 | album
28 | Playlists
29 |
30 |
31 |
35 | queue_music
36 | Library
37 |
38 |
42 | schedule
43 | Recently Played
44 |
45 |
49 | favorite_border
50 | Favorites
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
64 | settings
65 | Settings
66 |
67 |
71 | info
72 | About
73 |
74 |
75 | `,
76 | styles: [`
77 | a {
78 | text-decoration: none;
79 | }
80 | mat-icon {
81 | padding: 0 !important;
82 | }
83 | .mat-list-item {
84 | height: 48px !important;
85 | }
86 | .playing-icon {
87 | height: 18px;
88 | width: 18px;
89 | line-height: 18px;
90 | font-size: 18px;
91 | vertical-align: text-top;
92 | }
93 | @media screen and (min-width: 599px){
94 | mat-icon:not(.playing-icon) {
95 | display: none;
96 | }
97 | .mat-line {
98 | margin-left: -1rem !important;
99 | }
100 | }
101 | `],
102 | changeDetection: ChangeDetectionStrategy.OnPush
103 | })
104 | export class SidenavComponent {
105 |
106 | @Output() closeSidenav = new EventEmitter();
107 |
108 | }
109 |
--------------------------------------------------------------------------------
/web/src/app/library/reducers/artists.reducers.ts:
--------------------------------------------------------------------------------
1 | import {createEntityAdapter, EntityAdapter, EntityState} from '@ngrx/entity';
2 | import {Artist} from '@app/model';
3 | import {ArtistsActionsUnion, ArtistsActionTypes} from '@app/library/actions/artists.actions';
4 | import {TracksActionsUnion, TracksActionTypes} from '@app/library/actions/tracks.actions';
5 | import {LibraryUtils} from '@app/library/library.utils';
6 |
7 | /**
8 | * State
9 | */
10 | export interface State extends EntityState {
11 | selectedIds: (string | number)[];
12 | }
13 |
14 | export const adapter: EntityAdapter = createEntityAdapter({
15 | selectId: (artist: Artist) => artist.name,
16 | sortComparer: (a, b) => a.name.localeCompare(b.name),
17 | });
18 |
19 | export const initialState: State = adapter.getInitialState({
20 | selectedIds: []
21 | });
22 |
23 | /**
24 | * Reducer
25 | */
26 | export function reducer(
27 | state = initialState,
28 | action: ArtistsActionsUnion | TracksActionsUnion
29 | ): State {
30 | switch (action.type) {
31 |
32 | case ArtistsActionTypes.LoadArtists:
33 | return adapter.upsertMany(action.payload, state);
34 |
35 | case ArtistsActionTypes.DeselectAllArtists: {
36 | return {
37 | ...state,
38 | selectedIds: []
39 | };
40 | }
41 |
42 | case ArtistsActionTypes.DeselectArtist: {
43 | return {
44 | ...state,
45 | selectedIds: state.selectedIds.filter(id => id !== action.payload.name)
46 | };
47 | }
48 |
49 | case ArtistsActionTypes.SelectArtist: {
50 | if (state.selectedIds.indexOf(action.payload.name) === -1) {
51 | return {
52 | ...state,
53 | selectedIds: [...state.selectedIds, action.payload.name]
54 | };
55 | } else {
56 | return state;
57 | }
58 | }
59 |
60 | case ArtistsActionTypes.SelectArtists: {
61 | return {
62 | ...state,
63 | selectedIds: action.payload.map(a => a.name)
64 | };
65 | }
66 |
67 | case ArtistsActionTypes.SelectArtistsByIds: {
68 | return {
69 | ...state,
70 | selectedIds: action.payload
71 | };
72 | }
73 |
74 | case TracksActionTypes.ScanTracks:
75 | return adapter.removeAll({
76 | ...state,
77 | selectedIds: []
78 | });
79 |
80 | case TracksActionTypes.LoadTracksSuccess: {
81 | const artists = LibraryUtils.extractArtists(action.payload);
82 | return adapter.upsertMany(artists, state);
83 | }
84 |
85 | case TracksActionTypes.AddTracks: {
86 | const artists = LibraryUtils.extractArtists(action.payload);
87 | artists.map(artist => {
88 | const old = state.entities[artist.name];
89 | if (old) {
90 | artist.songs += old.songs;
91 | }
92 | return artist;
93 | });
94 | return adapter.upsertMany(artists, state);
95 | }
96 |
97 | case TracksActionTypes.RemoveTracks: {
98 | const artists = LibraryUtils.extractArtists(action.payload);
99 | const fn: (s: State, artist: Artist) => State = (s, artist) => {
100 | const old = s.entities[artist.name];
101 | if (old) {
102 | old.songs -= artist.songs;
103 | if (old.songs === 0) {
104 | return adapter.removeOne(old.name, s);
105 | } else {
106 | return adapter.upsertOne(old, s);
107 | }
108 | }
109 | };
110 | return artists.reduce(fn, state);
111 | }
112 |
113 | default: {
114 | return state;
115 | }
116 | }
117 | }
118 |
119 | /**
120 | * Selectors
121 | */
122 | export const getSelectedIds = (state: State) => state.selectedIds;
123 |
--------------------------------------------------------------------------------
/web/src/app/library/components/shared/controls.component.ts:
--------------------------------------------------------------------------------
1 | import {ChangeDetectionStrategy, Component, EventEmitter, Input, Output} from '@angular/core';
2 | import {MatMenu} from '@angular/material';
3 |
4 | @Component({
5 | selector: 'app-controls',
6 | template: `
7 |
38 | `,
39 | styles: [`
40 | .controls {
41 | overflow-y: hidden;
42 | position: absolute;
43 | top: 0;
44 | left: 0;
45 | right: 0;
46 | height: 60px;
47 | padding: 0 0.5rem 0 1rem;
48 | display: flex;
49 | flex-direction: row;
50 | align-items: center;
51 | box-sizing: border-box;
52 | }
53 | .controls-inner {
54 | flex-grow: 1;
55 | align-self: flex-end;
56 | display: flex;
57 | flex-direction: column;
58 | transition-property: transform;
59 | transition-timing-function: ease;
60 | transition-duration: 0.4s;
61 | overflow: hidden;
62 | }
63 | .controls-meta {
64 | height: 59px;
65 | display: flex;
66 | flex-direction: row;
67 | align-items: center;
68 | transition-property: transform;
69 | transition-timing-function: ease;
70 | transition-duration: 0.4s;
71 | }
72 | .controls-meta.showSearch {
73 | transform: translateY(59px);
74 | }
75 | .search {
76 | height: 59px;
77 | transition-property: transform;
78 | transition-timing-function: ease;
79 | transition-duration: 0.4s;
80 | }
81 | .search.showSearch {
82 | transform: translateY(59px);
83 | }
84 | .search-icon {
85 | vertical-align: bottom;
86 | }
87 | .back {
88 | margin-right: 1rem;
89 | position: relative;
90 | z-index: 1;
91 | }
92 | .searchButton {
93 | margin-left: 0.5rem;
94 | }
95 | `],
96 | changeDetection: ChangeDetectionStrategy.OnPush
97 | })
98 | export class ControlsComponent {
99 |
100 | @Input() backButton: boolean;
101 | @Input() search: string;
102 | @Input() searchPlaceholder: string;
103 | @Input() matMenu: MatMenu;
104 |
105 | @Output() searchChange = new EventEmitter();
106 | @Output() backClicked = new EventEmitter();
107 |
108 | showSearch = false;
109 |
110 | }
111 |
--------------------------------------------------------------------------------
/web/src/app/core/services/loader.service.ts:
--------------------------------------------------------------------------------
1 | import {Injectable} from '@angular/core';
2 | import {concat, EMPTY, Observable, Subject, throwError} from 'rxjs';
3 | import {catchError, delay, mergeMap, publishReplay, refCount, retryWhen, share, switchMap, take} from 'rxjs/operators';
4 | import {HttpSocketClientService, SocketMessage} from '@app/core/services/http-socket-client.service';
5 | import {MatSnackBar} from '@angular/material';
6 |
7 | @Injectable()
8 | export class LoaderService {
9 |
10 | private readonly loadings$: Observable;
11 | private loading = new Subject();
12 |
13 | hasErrors$: Observable;
14 | private hasErrors = new Subject();
15 |
16 | log$: Observable;
17 | private log = new Subject();
18 |
19 | initializing$: Observable;
20 | private initializing = new Subject();
21 |
22 | private socketObs$: Observable;
23 |
24 | constructor(
25 | private httpSocketClient: HttpSocketClientService,
26 | private snack: MatSnackBar
27 | ) {
28 | this.loadings$ = this.loading.asObservable().pipe(publishReplay(1), refCount());
29 | this.initializing$ = this.initializing.asObservable().pipe(publishReplay(1), refCount());
30 | // this.initializing$ = this.httpSocketClient.socketOpened$.pipe(map(opened => !opened), publishReplay(1), refCount())
31 | this.log$ = this.log.asObservable().pipe(publishReplay(1), refCount());
32 | this.hasErrors$ = this.hasErrors.asObservable().pipe(publishReplay(1), refCount());
33 | this.loadings$.subscribe();
34 | this.initializing$.subscribe();
35 | this.log$.subscribe();
36 | this.hasErrors$.subscribe();
37 | }
38 |
39 | /* errors => errors.pipe(
40 | switchMap(() => {
41 | if (window.navigator.onLine) {
42 | console.warn(`WebSocket failed. Retrying in 500ms.`);
43 | return timer(500);
44 | } else {
45 | return fromEvent(window, 'online').pipe(take(1));
46 | }
47 | }),
48 | ))*/
49 |
50 | getSharedSocket(): Observable {
51 | if (!this.socketObs$) {
52 | return this.socketObs$ =
53 | this.initializing$.pipe(
54 | switchMap(initializing => {
55 | if (initializing) {
56 | return EMPTY;
57 | } else {
58 | return this.httpSocketClient.getSocket().pipe(
59 | retryWhen(errors =>
60 | concat(errors.pipe(delay(500), take(6)), throwError('Connection to server lost!'))
61 | ),
62 | catchError((error, caught) =>
63 | this.snack.open(error, 'Retry')
64 | .afterDismissed()
65 | .pipe(mergeMap(() => caught))
66 | )
67 | );
68 | }
69 | }),
70 | share()
71 | );
72 | } else {
73 | return this.socketObs$;
74 | }
75 | }
76 |
77 | getLoading(): Observable {
78 | return this.loadings$;
79 | }
80 |
81 | load(value: number) {
82 | this.loading.next(value);
83 | }
84 |
85 | unload() {
86 | this.loading.next(0);
87 | }
88 |
89 | initialize() {
90 | this.initializing.next(true);
91 | this.hasErrors.next(false);
92 | this.log.next('Loading...');
93 | this.httpSocketClient.getSocket().pipe(
94 | retryWhen(
95 | errors => concat(errors.pipe(delay(500), take(20)), throwError('Connection to server failed!'))
96 | ),
97 | take(1)
98 | ).subscribe(
99 | () => {},
100 | error => {
101 | this.hasErrors.next(true);
102 | this.log.next(error);
103 | },
104 | () => this.initializing.next(false)
105 | );
106 | }
107 |
108 | }
109 |
--------------------------------------------------------------------------------
/web/src/app/library/reducers/albums.reducers.ts:
--------------------------------------------------------------------------------
1 | import {createEntityAdapter, EntityAdapter, EntityState} from '@ngrx/entity';
2 | import {Album} from '@app/model';
3 | import {AlbumsActionsUnion, AlbumsActionTypes} from '@app/library/actions/albums.actions';
4 | import {TracksActionsUnion, TracksActionTypes} from '@app/library/actions/tracks.actions';
5 | import {LibraryUtils} from '@app/library/library.utils';
6 |
7 | export const getAlbumId = (album: Album) => album.artist + '-' + album.title;
8 |
9 | /**
10 | * State
11 | */
12 | export interface State extends EntityState {
13 | selectedIds: (string | number)[];
14 | }
15 |
16 | export const adapter: EntityAdapter = createEntityAdapter({
17 | selectId: getAlbumId,
18 | sortComparer: (a, b) => a.title.localeCompare(b.title),
19 | });
20 |
21 | export const initialState: State = adapter.getInitialState({
22 | selectedIds: []
23 | });
24 |
25 | /**
26 | * Reducer
27 | */
28 | export function reducer(
29 | state = initialState,
30 | action: AlbumsActionsUnion | TracksActionsUnion
31 | ): State {
32 | switch (action.type) {
33 |
34 | case AlbumsActionTypes.LoadAlbums:
35 | return adapter.upsertMany(action.payload, state);
36 |
37 | case AlbumsActionTypes.DeselectAllAlbums: {
38 | return {
39 | ...state,
40 | selectedIds: []
41 | };
42 | }
43 |
44 | case AlbumsActionTypes.DeselectAlbum: {
45 | return {
46 | ...state,
47 | selectedIds: state.selectedIds.filter(id => id !== getAlbumId(action.payload))
48 | };
49 | }
50 |
51 | case AlbumsActionTypes.SelectAlbum: {
52 | if (state.selectedIds.indexOf(getAlbumId(action.payload)) === -1) {
53 | return {
54 | ...state,
55 | selectedIds: [...state.selectedIds, getAlbumId(action.payload)]
56 | };
57 | } else {
58 | return state;
59 | }
60 | }
61 |
62 | case AlbumsActionTypes.SelectAlbums: {
63 | return {
64 | ...state,
65 | selectedIds: action.payload.map(getAlbumId)
66 | };
67 | }
68 |
69 | case AlbumsActionTypes.SelectAlbumsByIds: {
70 | return {
71 | ...state,
72 | selectedIds: action.payload
73 | };
74 | }
75 |
76 | case TracksActionTypes.ScanTracks:
77 | return adapter.removeAll({
78 | ...state,
79 | selectedIds: []
80 | });
81 |
82 | case TracksActionTypes.LoadTracksSuccess: {
83 | const albums = LibraryUtils.extractAlbums(action.payload);
84 | return adapter.upsertMany(albums, state);
85 | }
86 |
87 | case TracksActionTypes.AddTracks: {
88 | const albums = LibraryUtils.extractAlbums(action.payload);
89 | albums.map(album => {
90 | const old = state.entities[getAlbumId(album)];
91 | if (old) {
92 | album.songs += old.songs;
93 | }
94 | return album;
95 | });
96 | return adapter.upsertMany(albums, state);
97 | }
98 |
99 | case TracksActionTypes.RemoveTracks: {
100 | const albums = LibraryUtils.extractAlbums(action.payload);
101 | const fn: (s: State, album: Album) => State = (s, album) => {
102 | const old = s.entities[getAlbumId(album)];
103 | if (old) {
104 | old.songs -= album.songs;
105 | if (old.songs === 0) {
106 | return adapter.removeOne(getAlbumId(old), s);
107 | } else {
108 | return adapter.upsertOne(old, s);
109 | }
110 | }
111 | };
112 | return albums.reduce(fn, state);
113 | }
114 |
115 | default: {
116 | return state;
117 | }
118 | }
119 | }
120 |
121 | /**
122 | * Selectors
123 | */
124 | export const getSelectedIds = (state: State) => state.selectedIds;
125 |
--------------------------------------------------------------------------------
/src/main/scala/net/creasource/io/WatchService.scala:
--------------------------------------------------------------------------------
1 | package net.creasource.io
2 |
3 | /**
4 | * This file is copy/pasted from https://github.com/nurkiewicz/learning-akka
5 | * It is released under the Apache 2 License (https://github.com/nurkiewicz/learning-akka/blob/master/license.txt)
6 | * Substantial modifications have been made, including changing the package name, and fixing the code for fast copy.
7 | */
8 |
9 | import java.io.UncheckedIOException
10 | import java.nio.file.StandardWatchEventKinds._
11 | import java.nio.file._
12 |
13 | import akka.Done
14 | import akka.actor.ActorRef
15 | import akka.event.LoggingAdapter
16 | import akka.stream.Materializer
17 | import akka.stream.scaladsl.{Sink, StreamConverters}
18 |
19 | import scala.collection.JavaConverters._
20 | import scala.concurrent.Future
21 |
22 | class WatchService(notifyActor: ActorRef, logger: LoggingAdapter)(implicit materializer: Materializer) extends Runnable {
23 |
24 | private val watchService = FileSystems.getDefault.newWatchService()
25 |
26 | private def register(path: Path): WatchKey = path.register(watchService, ENTRY_CREATE, ENTRY_DELETE)
27 |
28 | def watch(root: Path): Future[Done] = {
29 | logger.debug("Watching folder: " + root)
30 | register(root)
31 | StreamConverters
32 | .fromJavaStream(() => Files.walk(root))
33 | .recover {
34 | case _: UncheckedIOException => root
35 | }
36 | .runWith(Sink.foreach(path =>
37 | if (path.toFile.isDirectory && path != root) {
38 | logger.debug("Registering folder: " + path)
39 | register(path)
40 | }
41 | )
42 | )
43 | }
44 |
45 | def run() {
46 | try {
47 | logger.debug("Waiting for file system events...")
48 | while (!Thread.currentThread().isInterrupted) {
49 | val key = watchService.take()
50 | key.pollEvents().asScala foreach {
51 | event =>
52 | val relativePath = event.context().asInstanceOf[Path]
53 | val path = key.watchable().asInstanceOf[Path].resolve(relativePath)
54 | event.kind() match {
55 | case ENTRY_CREATE =>
56 | if (path.toFile.isDirectory) {
57 | StreamConverters
58 | .fromJavaStream(() => Files.walk(path))
59 | .runWith(Sink.foreach(path =>
60 | if (path.toFile.isDirectory) {
61 | logger.debug("Registering folder: " + path)
62 | register(path)
63 | } else {
64 | // Ugly hack to wait for the file to be unlocked
65 | while (path.toFile.exists() && !path.toFile.renameTo(path.toFile)) {
66 | Thread.sleep(100)
67 | }
68 | notifyActor ! FileSystemChange.Created(path)
69 | logger.debug("Entry created: " + path)
70 | }
71 | ))
72 | } else {
73 | // Ugly hack to wait for the file to be unlocked
74 | while (path.toFile.exists() && !path.toFile.renameTo(path.toFile)) {
75 | Thread.sleep(100)
76 | }
77 | notifyActor ! FileSystemChange.Created(path)
78 | logger.debug("Entry created: " + path)
79 | }
80 |
81 | case ENTRY_DELETE =>
82 | notifyActor ! FileSystemChange.Deleted(path)
83 | logger.debug("Entry deleted: " + path)
84 |
85 | case e =>
86 | logger.warning("Unknown event received: " + e.toString)
87 | }
88 | }
89 | key.reset()
90 | }
91 | } catch {
92 | case _: InterruptedException => logger.debug("Interrupted.")
93 | } finally {
94 | watchService.close()
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------