├── 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 | 9 | 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 |
9 |

10 |
11 |
12 | 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 |
9 |

10 |
11 |
12 | 13 | 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 | 15 | 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 | 15 | 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 | 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 | 13 | 14 | 15 | 16 | Loading... 17 | 18 | 19 | 23 | 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 |
9 |
12 | music_note 13 |
14 |
15 | 16 | {{ currentTrack.title }} 17 | 18 | 19 | {{ currentTrack.artist }} • {{ currentTrack.album }} 20 | 21 |
22 |
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 | 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 |
9 | 10 | shuffle 11 | Play all randomly ({{ albums.length }}) 12 | 13 |
14 | 15 | 16 | 17 | search 18 | Search 19 | 20 | 23 | 24 |
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 | 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 |
9 | 10 | shuffle 11 | Play all randomly ({{ artists.length }}) 12 | 13 |
14 | 15 | 16 | 17 | search 18 | Search 19 | 20 | 23 | 24 |
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 |
    9 |
  • 10 |
    14 | music_note 15 | play_circle_outline 16 |
    17 | {{ primaryFunc(item) }} 18 | {{ secondaryFunc(item) }} 19 |
  • 20 |
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 |
8 | 11 |
12 | 13 | 14 | 15 | search 16 | {{ searchPlaceholder }} 17 | 18 | 21 | 22 |
23 | 24 |
25 |
26 | 31 | 37 |
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 | --------------------------------------------------------------------------------