├── .browserslistrc ├── .editorconfig ├── .eslintrc.json ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── build-test.yml ├── .gitignore ├── .prettierignore ├── LICENSE ├── PRIVACY_POLICY.md ├── README.md ├── angular.json ├── cypress.json ├── cypress ├── integration │ └── app │ │ └── app.spec.ts ├── plugins │ └── index.js ├── support │ ├── commands.ts │ ├── index.d.ts │ └── index.js └── tsconfig.json ├── docs └── social.png ├── extra-webpack.config.ts ├── karma.conf.js ├── netlify.toml ├── ngsw-config.json ├── package.json ├── src ├── app │ ├── album │ │ ├── album.module.ts │ │ ├── page-album-resolver.service.spec.ts │ │ ├── page-album-resolver.service.ts │ │ ├── page-album.component.spec.ts │ │ └── page-album.component.ts │ ├── artist │ │ ├── artist.module.ts │ │ ├── page-artist-resolver.service.spec.ts │ │ ├── page-artist-resolver.service.ts │ │ ├── page-artist.component.spec.ts │ │ └── page-artist.component.ts │ ├── core │ │ ├── components │ │ │ ├── album.component.spec.ts │ │ │ ├── album.component.ts │ │ │ ├── artist-list-item.component.spec.ts │ │ │ ├── artist-list-item.component.ts │ │ │ ├── artist.component.spec.ts │ │ │ ├── artist.component.ts │ │ │ ├── container-home.component.spec.ts │ │ │ ├── container-home.component.ts │ │ │ ├── container-page.component.spec.ts │ │ │ ├── container-page.component.ts │ │ │ ├── container.component.spec.ts │ │ │ ├── container.component.ts │ │ │ ├── cover.component.spec.ts │ │ │ ├── cover.component.ts │ │ │ ├── filters.component.spec.ts │ │ │ ├── filters.component.ts │ │ │ ├── genre.component.spec.ts │ │ │ ├── genre.component.ts │ │ │ ├── h-list.component.spec.ts │ │ │ ├── h-list.component.ts │ │ │ ├── icon-likes.component.spec.ts │ │ │ ├── icon-likes.component.ts │ │ │ ├── icon-likes2.component.ts │ │ │ ├── icon.component.spec.ts │ │ │ ├── icon.component.ts │ │ │ ├── label.component.spec.ts │ │ │ ├── label.component.ts │ │ │ ├── link.component.spec.ts │ │ │ ├── link.component.ts │ │ │ ├── list.component.spec.ts │ │ │ ├── list.component.ts │ │ │ ├── menu.component.spec.ts │ │ │ ├── menu.component.ts │ │ │ ├── mix.component.ts │ │ │ ├── player-button.component.spec.ts │ │ │ ├── player-button.component.ts │ │ │ ├── playlist-likes.component.spec.ts │ │ │ ├── playlist-likes.component.ts │ │ │ ├── playlist.component.spec.ts │ │ │ ├── playlist.component.ts │ │ │ ├── recent-activity.component.spec.ts │ │ │ ├── recent-activity.component.ts │ │ │ ├── router.component.ts │ │ │ ├── select.component.spec.ts │ │ │ ├── select.component.ts │ │ │ ├── song-list-item.component.spec.ts │ │ │ ├── song-list-item.component.ts │ │ │ ├── song-list.component.spec.ts │ │ │ ├── song-list.component.ts │ │ │ ├── title.component.spec.ts │ │ │ ├── title.component.ts │ │ │ ├── top-bar.component.spec.ts │ │ │ ├── top-bar.component.ts │ │ │ ├── track-list-item.component.spec.ts │ │ │ └── track-list-item.component.ts │ │ ├── core.module.ts │ │ ├── dialogs │ │ │ ├── confirm.component.ts │ │ │ ├── playlist-add.component.spec.ts │ │ │ ├── playlist-add.component.ts │ │ │ ├── playlist-new.component.spec.ts │ │ │ └── playlist-new.component.ts │ │ ├── directives │ │ │ └── routed-dialog.directive.ts │ │ ├── pipes │ │ │ └── duration.pipe.ts │ │ ├── services │ │ │ ├── history.service.spec.ts │ │ │ ├── history.service.ts │ │ │ └── spotify.service.ts │ │ ├── store │ │ │ ├── core.actions.ts │ │ │ ├── core.effects.ts │ │ │ ├── core.reducer.ts │ │ │ ├── core.selectors.ts │ │ │ ├── core.state.ts │ │ │ └── index.ts │ │ ├── styles │ │ │ ├── container-home.component.scss │ │ │ ├── container-page.component.scss │ │ │ ├── container.component.scss │ │ │ └── page-header.component.scss │ │ └── utils │ │ │ ├── array-buffer-to-base64.util.ts │ │ │ ├── array-equals.util.ts │ │ │ ├── concat-tap.util.ts │ │ │ ├── either.util.ts │ │ │ ├── hash.util.ts │ │ │ ├── icons.util.ts │ │ │ ├── index.ts │ │ │ ├── longest-common-prefix.util.ts │ │ │ ├── read-as-data-url.util.ts │ │ │ ├── reduce-array.util.ts │ │ │ ├── remove-from-array.util.ts │ │ │ ├── scan-array.util.ts │ │ │ ├── shuffle-array.util.ts │ │ │ ├── tap-error.util.ts │ │ │ ├── types.util.ts │ │ │ └── uniq.util.ts │ ├── database │ │ ├── albums │ │ │ ├── album.actions.spec.ts │ │ │ ├── album.actions.ts │ │ │ ├── album.effects.spec.ts │ │ │ ├── album.effects.ts │ │ │ ├── album.facade.spec.ts │ │ │ ├── album.facade.ts │ │ │ ├── album.model.ts │ │ │ ├── album.reducer.spec.ts │ │ │ ├── album.reducer.ts │ │ │ ├── album.selectors.spec.ts │ │ │ └── album.selectors.ts │ │ ├── artists │ │ │ ├── artist.actions.spec.ts │ │ │ ├── artist.actions.ts │ │ │ ├── artist.effects.spec.ts │ │ │ ├── artist.effects.ts │ │ │ ├── artist.facade.spec.ts │ │ │ ├── artist.facade.ts │ │ │ ├── artist.model.ts │ │ │ ├── artist.reducer.spec.ts │ │ │ ├── artist.reducer.ts │ │ │ ├── artist.selectors.spec.ts │ │ │ └── artist.selectors.ts │ │ ├── database.module.ts │ │ ├── database.service.ts │ │ ├── entries │ │ │ ├── entry.actions.spec.ts │ │ │ ├── entry.actions.ts │ │ │ ├── entry.effects.spec.ts │ │ │ ├── entry.effects.ts │ │ │ ├── entry.facade.spec.ts │ │ │ ├── entry.facade.ts │ │ │ ├── entry.model.ts │ │ │ ├── entry.reducer.spec.ts │ │ │ ├── entry.reducer.ts │ │ │ ├── entry.selectors.spec.ts │ │ │ └── entry.selectors.ts │ │ ├── pictures │ │ │ ├── picture.actions.spec.ts │ │ │ ├── picture.actions.ts │ │ │ ├── picture.effects.spec.ts │ │ │ ├── picture.effects.ts │ │ │ ├── picture.facade.spec.ts │ │ │ ├── picture.facade.ts │ │ │ ├── picture.model.ts │ │ │ ├── picture.reducer.spec.ts │ │ │ ├── picture.reducer.ts │ │ │ ├── picture.selectors.spec.ts │ │ │ └── picture.selectors.ts │ │ ├── playlists │ │ │ ├── playlist.actions.spec.ts │ │ │ ├── playlist.actions.ts │ │ │ ├── playlist.effects.spec.ts │ │ │ ├── playlist.effects.ts │ │ │ ├── playlist.facade.spec.ts │ │ │ ├── playlist.facade.ts │ │ │ ├── playlist.model.ts │ │ │ ├── playlist.reducer.spec.ts │ │ │ ├── playlist.reducer.ts │ │ │ ├── playlist.selectors.spec.ts │ │ │ └── playlist.selectors.ts │ │ ├── settings │ │ │ ├── settings.actions.ts │ │ │ ├── settings.effects.ts │ │ │ ├── settings.facade.ts │ │ │ └── settings.model.ts │ │ └── songs │ │ │ ├── song.actions.spec.ts │ │ │ ├── song.actions.ts │ │ │ ├── song.effects.spec.ts │ │ │ ├── song.effects.ts │ │ │ ├── song.facade.spec.ts │ │ │ ├── song.facade.ts │ │ │ ├── song.model.ts │ │ │ ├── song.reducer.spec.ts │ │ │ ├── song.reducer.ts │ │ │ ├── song.selectors.spec.ts │ │ │ └── song.selectors.ts │ ├── explorer │ │ └── explorer.component.ts │ ├── helper │ │ ├── helper.actions.ts │ │ ├── helper.effects.ts │ │ ├── helper.facade.ts │ │ └── helper.module.ts │ ├── home │ │ └── home.component.ts │ ├── library │ │ ├── library-albums.component.spec.ts │ │ ├── library-albums.component.ts │ │ ├── library-artists.component.spec.ts │ │ ├── library-artists.component.ts │ │ ├── library-content.component.spec.ts │ │ ├── library-content.component.ts │ │ ├── library-likes.component.spec.ts │ │ ├── library-likes.component.ts │ │ ├── library-playlists.component.spec.ts │ │ ├── library-playlists.component.ts │ │ ├── library-songs.component.spec.ts │ │ ├── library-songs.component.ts │ │ ├── library.component.spec.ts │ │ ├── library.component.ts │ │ ├── library.module.ts │ │ └── store │ │ │ ├── index.ts │ │ │ ├── library.actions.ts │ │ │ ├── library.effects.ts │ │ │ ├── library.facade.ts │ │ │ ├── library.reducer.ts │ │ │ ├── library.selectors.ts │ │ │ └── library.state.ts │ ├── main │ │ ├── main.component.spec.ts │ │ ├── main.component.ts │ │ ├── main.guard.spec.ts │ │ ├── main.guard.ts │ │ ├── main.module.ts │ │ ├── navigation.service.ts │ │ ├── scroller.service.spec.ts │ │ └── scroller.service.ts │ ├── player │ │ ├── analyzer.service.ts │ │ ├── audio.service.ts │ │ ├── media-session.service.spec.ts │ │ ├── media-session.service.ts │ │ ├── play.component.spec.ts │ │ ├── play.component.ts │ │ ├── player.component.spec.ts │ │ ├── player.component.ts │ │ ├── player.module.ts │ │ ├── queue-item.component.spec.ts │ │ ├── queue-item.component.ts │ │ ├── queue-list.component.spec.ts │ │ ├── queue-list.component.ts │ │ └── store │ │ │ ├── player.actions.ts │ │ │ ├── player.effects.ts │ │ │ ├── player.facade.ts │ │ │ ├── player.reducer.ts │ │ │ ├── player.selectors.ts │ │ │ └── player.state.ts │ ├── playlist │ │ ├── page-playlist-likes.component.spec.ts │ │ ├── page-playlist-likes.component.ts │ │ ├── page-playlist-resolver.service.spec.ts │ │ ├── page-playlist-resolver.service.ts │ │ ├── page-playlist.component.spec.ts │ │ ├── page-playlist.component.ts │ │ └── playlist.module.ts │ ├── root.component.spec.ts │ ├── root.component.ts │ ├── root.module.ts │ ├── scanner │ │ ├── extractor.service.ts │ │ ├── extractor.worker.ts │ │ ├── file.service.ts │ │ ├── resizer.service.ts │ │ ├── resizer.worker.ts │ │ ├── scan.component.ts │ │ ├── scanner.module.ts │ │ └── store │ │ │ ├── index.ts │ │ │ ├── scanner.actions.ts │ │ │ ├── scanner.effects.ts │ │ │ ├── scanner.facade.ts │ │ │ ├── scanner.reducer.ts │ │ │ ├── scanner.selectors.ts │ │ │ ├── scanner.state.ts │ │ │ └── scanner2.effects.ts │ ├── settings │ │ ├── library-settings.component.spec.ts │ │ ├── library-settings.component.ts │ │ └── settings.component.ts │ ├── update.service.ts │ └── welcome │ │ ├── support.service.ts │ │ ├── welcome.component.spec.ts │ │ ├── welcome.component.ts │ │ └── welcome.module.ts ├── apple-touch-icon.png ├── assets │ ├── docs_logo.png │ ├── favicon-16x16.png │ ├── favicon-32x32.png │ ├── fonts │ │ ├── roboto-v19-latin-300.woff │ │ ├── roboto-v19-latin-300.woff2 │ │ ├── roboto-v19-latin-500.woff │ │ ├── roboto-v19-latin-500.woff2 │ │ ├── roboto-v19-latin-700.woff │ │ ├── roboto-v19-latin-700.woff2 │ │ ├── roboto-v19-latin-regular.woff │ │ ├── roboto-v19-latin-regular.woff2 │ │ └── youtube-sans-bold.ttf │ ├── icons │ │ ├── icon-128x128.png │ │ ├── icon-144x144.png │ │ ├── icon-152x152.png │ │ ├── icon-192x192.png │ │ ├── icon-384x384.png │ │ ├── icon-512x512.png │ │ ├── icon-72x72.png │ │ ├── icon-96x96.png │ │ └── logo.svg │ ├── mstile-150x150.png │ ├── safari-pinned-tab.svg │ └── welcome │ │ ├── w1.webp │ │ ├── w2.webp │ │ ├── w3.webp │ │ ├── w4.webp │ │ ├── w5.webp │ │ └── w6.webp ├── browserconfig.xml ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── favicon.ico ├── index.html ├── main.ts ├── manifest.webmanifest ├── polyfills.ts ├── robots.txt ├── styles.scss └── test.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.spec.json ├── tsconfig.worker.json └── yarn.lock /.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # For the full list of supported browsers by the Angular framework, please see: 6 | # https://angular.io/guide/browser-support 7 | 8 | # You can see what browsers were selected by your queries by running: 9 | # npx browserslist 10 | 11 | chrome >= 86 12 | edge >= 86 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://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 | [*.ts] 12 | quote_type = single 13 | 14 | [*.md] 15 | max_line_length = off 16 | trim_trailing_whitespace = false 17 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": [ 4 | "projects/**/*", 5 | "cypress/**/*" 6 | ], 7 | "overrides": [ 8 | { 9 | "files": [ 10 | "*.ts" 11 | ], 12 | "parserOptions": { 13 | "project": [ 14 | "tsconfig.json" 15 | ], 16 | "createDefaultProgram": true 17 | }, 18 | "extends": [ 19 | "plugin:@angular-eslint/ng-cli-compat", 20 | "plugin:@angular-eslint/ng-cli-compat--formatting-add-on", 21 | "plugin:@angular-eslint/template/process-inline-templates", 22 | "plugin:@typescript-eslint/recommended" 23 | ], 24 | "rules": { 25 | "@angular-eslint/component-selector": [ 26 | "error", 27 | { 28 | "type": "element", 29 | "prefix": "app", 30 | "style": "kebab-case" 31 | } 32 | ], 33 | "@angular-eslint/directive-selector": [ 34 | "error", 35 | { 36 | "type": "attribute", 37 | "prefix": "app", 38 | "style": "camelCase" 39 | } 40 | ], 41 | "@typescript-eslint/no-explicit-any": "off" 42 | } 43 | }, 44 | { 45 | "files": [ 46 | "*.html" 47 | ], 48 | "extends": [ 49 | "plugin:@angular-eslint/template/recommended" 50 | ], 51 | "rules": {} 52 | } 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Example Contributing Guidelines 2 | 3 | This is an example of GitHub's contributing guidelines file. Check out GitHub's [CONTRIBUTING.md help center article](https://help.github.com/articles/setting-guidelines-for-repository-contributors/) for more information. 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | - **I'm submitting a ...** 2 | [ ] bug report 3 | [ ] feature request 4 | [ ] question about the decisions made in the repository 5 | [ ] question about how to use this project 6 | 7 | - **Summary** 8 | 9 | - **Other information** (e.g. detailed explanation, stack traces, related issues, suggestions how to fix, links for us to have context, e.g. StackOverflow, personal fork, etc.) 10 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | - **What kind of change does this PR introduce?** (Bug fix, feature, docs update, ...) 2 | 3 | - **What is the current behavior?** (You can also link to an open issue here) 4 | 5 | - **What is the new behavior (if this is a feature change)?** 6 | 7 | - **Other information**: 8 | -------------------------------------------------------------------------------- /.github/workflows/build-test.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | name: Build 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: bahmutov/npm-install@v1 15 | - run: yarn build --configuration production 16 | 17 | test-eslint: 18 | name: Lint 19 | needs: 20 | - build 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v2 24 | - uses: bahmutov/npm-install@v1 25 | - run: yarn lint 26 | 27 | test-prettier: 28 | name: Prettier 29 | needs: 30 | - build 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v2 34 | - uses: bahmutov/npm-install@v1 35 | - run: yarn prettier 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | # Only exists if Bazel was run 8 | /bazel-out 9 | 10 | # dependencies 11 | /node_modules 12 | 13 | # profiling files 14 | chrome-profiler-events*.json 15 | speed-measure-plugin*.json 16 | 17 | # IDEs and editors 18 | /.idea 19 | .project 20 | .classpath 21 | .c9/ 22 | *.launch 23 | .settings/ 24 | *.sublime-workspace 25 | 26 | # IDE - VSCode 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | .history/* 33 | 34 | # misc 35 | /.angular/cache 36 | /.sass-cache 37 | /connect.lock 38 | /coverage 39 | /libpeerconnection.log 40 | npm-debug.log 41 | yarn-error.log 42 | testem.log 43 | debug.log 44 | /typings 45 | 46 | # System Files 47 | .DS_Store 48 | Thumbs.db 49 | 50 | ###### 51 | 52 | /documentation 53 | /cypress/screenshots 54 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | src/index.html 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 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 | -------------------------------------------------------------------------------- /PRIVACY_POLICY.md: -------------------------------------------------------------------------------- 1 | Effective date: **December 28, 2022** 2 | 3 | ### MusicSource Privacy Statement 4 | 5 | MusicSource does not collect any personal information. 6 | 7 | ### Contacting us 8 | 9 | If you have any question regarding our privacy policy you can email to contact@creasource.net 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MusicSource 2 | 3 | A serverless clone of YouTube Music, for the desktop. 4 | 5 | [https://music.creasource.app](https://music.creasource.app) 6 | 7 | **Note:** MusicSource uses the [File System Access API](https://wicg.github.io/file-system-access/) to access your library files in read-only mode. Therefore, you will need a browser compatible with this API which is, as of May 2022, only implemented in some Chromium-based browsers like Chrome or Edge. You can check [caniuse](https://caniuse.com/native-filesystem-api) to know if your browser is compatible or head over to [MusicSource](https://music.creasource.app) and give it a try! 8 | 9 | ![](src/assets/welcome/w2.webp) 10 | 11 | Notable libraries that MusicSource relies on: 12 | * [music-metadata-browser](https://github.com/Borewit/music-metadata-browser) 13 | * [audioMotion-analyzer](https://github.com/hvianna/audioMotion-analyzer) 14 | 15 | ## License 16 | 17 | [MIT License](LICENSE) 18 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://on.cypress.io/cypress.schema.json", 3 | "baseUrl": "http://localhost:4200", 4 | "video": false, 5 | "integrationFolder": "cypress/integration" 6 | } 7 | -------------------------------------------------------------------------------- /cypress/integration/app/app.spec.ts: -------------------------------------------------------------------------------- 1 | describe('MusicSource', () => { 2 | before(() => cy.deleteDatabase('musicsource')); 3 | 4 | it('should redirect to /welcome', () => { 5 | cy.visit('/').url().should('include', '/welcome').contains('MusicSource'); 6 | }); 7 | 8 | // it('should display library when scanned=1', () => { 9 | // cy.visit('/'); 10 | // cy.url().should('include', '/library'); 11 | // cy.contains('MusicSource'); 12 | // cy.clearLocalStorage().should( 13 | // (ls) => expect(ls.getItem('scanned')).to.be.null 14 | // ); 15 | // }); 16 | // 17 | // it('should clear database and redirect to /welcome', () => { 18 | // localStorage.setItem('scanned', '1'); 19 | // cy.visit('/'); 20 | // cy.url().should('include', '/library'); 21 | // cy.get('app-menu').click(); 22 | // cy.contains('Clear database').click(); 23 | // cy.url().should('include', '/welcome'); 24 | // }); 25 | }); 26 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | module.exports = (on, config) => { 19 | // `on` is used to hook into various events Cypress emits 20 | // `config` is the resolved Cypress config 21 | } 22 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | 27 | Cypress.Commands.add( 28 | 'deleteDatabase', 29 | (name) => 30 | new Cypress.Promise((resolve, reject) => { 31 | const r = window.indexedDB.deleteDatabase(name); 32 | r.onsuccess = () => resolve(); 33 | r.onblocked = () => resolve(); 34 | r.onerror = () => reject(); 35 | }) 36 | ); 37 | -------------------------------------------------------------------------------- /cypress/support/index.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Cypress { 2 | interface Chainable { 3 | deleteDatabase(name: string): Chainable; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["es2015", "dom"], 5 | "types": ["cypress"] 6 | }, 7 | "include": [ 8 | "**/*.ts" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /docs/social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/docs/social.png -------------------------------------------------------------------------------- /extra-webpack.config.ts: -------------------------------------------------------------------------------- 1 | import { Configuration } from 'webpack'; 2 | 3 | export default { 4 | node: { global: true }, 5 | } as Configuration; 6 | -------------------------------------------------------------------------------- /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'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | jasmineHtmlReporter: { 19 | suppressAll: true // removes the duplicated traces 20 | }, 21 | coverageReporter: { 22 | dir: require('path').join(__dirname, './coverage/musicsource'), 23 | subdir: '.', 24 | reporters: [ 25 | { type: 'html' }, 26 | { type: 'text-summary' } 27 | ] 28 | }, 29 | reporters: ['progress', 'kjhtml'], 30 | port: 9876, 31 | colors: true, 32 | logLevel: config.LOG_INFO, 33 | autoWatch: true, 34 | browsers: ['Chrome'], 35 | singleRun: false, 36 | restartOnFileChange: true 37 | }); 38 | }; 39 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "yarn build --configuration production" 3 | publish = "dist/musicsource" 4 | 5 | [[redirects]] 6 | from = "/*" 7 | to = "/index.html" 8 | status = 200 9 | -------------------------------------------------------------------------------- /ngsw-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/@angular/service-worker/config/schema.json", 3 | "index": "/index.html", 4 | "assetGroups": [ 5 | { 6 | "name": "app", 7 | "installMode": "prefetch", 8 | "resources": { 9 | "files": [ 10 | "/favicon.ico", 11 | "/index.html", 12 | "/manifest.webmanifest", 13 | "/*.css", 14 | "/*.js" 15 | ] 16 | } 17 | }, 18 | { 19 | "name": "assets", 20 | "installMode": "lazy", 21 | "updateMode": "prefetch", 22 | "resources": { 23 | "files": [ 24 | "/assets/**", 25 | "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)" 26 | ] 27 | } 28 | } 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /src/app/album/album.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { PageAlbumComponent } from '@app/album/page-album.component'; 3 | import { CoreModule } from '@app/core/core.module'; 4 | import { RouterModule, Routes } from '@angular/router'; 5 | import { PageAlbumResolverService } from '@app/album/page-album-resolver.service'; 6 | 7 | const routes: Routes = [ 8 | { 9 | path: ':id', 10 | component: PageAlbumComponent, 11 | data: { animation: 'default' }, 12 | resolve: { info: PageAlbumResolverService }, 13 | }, 14 | ]; 15 | 16 | @NgModule({ 17 | declarations: [PageAlbumComponent], 18 | imports: [CoreModule, RouterModule.forChild(routes)], 19 | providers: [PageAlbumResolverService], 20 | }) 21 | export class AlbumModule {} 22 | -------------------------------------------------------------------------------- /src/app/album/page-album-resolver.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { PageAlbumResolverService } from './page-album-resolver.service'; 4 | 5 | describe('AlbumPageResolverService', () => { 6 | let service: PageAlbumResolverService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(PageAlbumResolverService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/album/page-album-resolver.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { ActivatedRouteSnapshot, Resolve, Router } from '@angular/router'; 3 | import { EMPTY, Observable, of, throwError } from 'rxjs'; 4 | import { catchError, concatMap, first } from 'rxjs/operators'; 5 | import { PictureFacade } from '@app/database/pictures/picture.facade'; 6 | import { AlbumFacade } from '@app/database/albums/album.facade'; 7 | import { Album, AlbumId } from '@app/database/albums/album.model'; 8 | import { DatabaseService } from '@app/database/database.service'; 9 | 10 | @Injectable() 11 | export class PageAlbumResolverService implements Resolve { 12 | constructor( 13 | private pictures: PictureFacade, 14 | private storage: DatabaseService, 15 | private albums: AlbumFacade, 16 | private router: Router 17 | ) {} 18 | 19 | resolve( 20 | route: ActivatedRouteSnapshot 21 | // state: RouterStateSnapshot 22 | ): Observable | Observable { 23 | const id = route.paramMap.get('id'); 24 | 25 | if (!id) { 26 | this.router.navigate(['/']); 27 | return EMPTY; 28 | } 29 | 30 | return this.albums.getByKey(id as AlbumId).pipe( 31 | first(), 32 | concatMap((stored) => 33 | stored ? of(stored) : this.storage.get$('albums', id) 34 | ), 35 | concatMap((model) => 36 | model ? of(model.id) : throwError(() => 'not found') 37 | ), 38 | catchError(() => { 39 | this.router.navigate(['/library/albums']); 40 | return EMPTY; 41 | }) 42 | ); 43 | 44 | // return this.albums.getByKey(id).pipe( 45 | // first(), 46 | // concatMap((album) => 47 | // !album ? throwError(() => 'not found') : of(album) 48 | // ), 49 | // catchError(() => { 50 | // this.router.navigate(['/library']); 51 | // return EMPTY; 52 | // }), 53 | // concatMap((album) => { 54 | // const songs$ = this.library.getAlbumTracks(album); 55 | // // const cover$ = this.pictures.getCover(album.pictureKey).pipe(first()); 56 | // return combineLatest([songs$]).pipe( 57 | // map(([songs]) => ({ 58 | // album, 59 | // songs, 60 | // //cover, 61 | // })) 62 | // ); 63 | // }) 64 | // ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/app/album/page-album.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PageAlbumComponent } from './page-album.component'; 4 | 5 | describe('AlbumPageComponent', () => { 6 | let component: PageAlbumComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [PageAlbumComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(PageAlbumComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/artist/artist.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { PageArtistComponent } from '@app/artist/page-artist.component'; 3 | import { CoreModule } from '@app/core/core.module'; 4 | import { RouterModule, Routes } from '@angular/router'; 5 | import { PageArtistResolverService } from '@app/artist/page-artist-resolver.service'; 6 | 7 | const routes: Routes = [ 8 | { 9 | path: ':id', 10 | component: PageArtistComponent, 11 | data: { animation: 'default' }, 12 | resolve: { info: PageArtistResolverService }, 13 | }, 14 | ]; 15 | 16 | @NgModule({ 17 | declarations: [PageArtistComponent], 18 | imports: [CoreModule, RouterModule.forChild(routes)], 19 | providers: [PageArtistResolverService], 20 | }) 21 | export class ArtistModule {} 22 | -------------------------------------------------------------------------------- /src/app/artist/page-artist-resolver.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { PageArtistResolverService } from './page-artist-resolver.service'; 4 | 5 | describe('ArtistPageResolverService', () => { 6 | let service: PageArtistResolverService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(PageArtistResolverService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/artist/page-artist.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PageArtistComponent } from './page-artist.component'; 4 | 5 | describe('ArtistPageComponent', () => { 6 | let component: PageArtistComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [PageArtistComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(PageArtistComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/core/components/album.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | import { MatMenuModule } from '@angular/material/menu'; 4 | import { AlbumComponent } from './album.component'; 5 | import { LabelComponent } from './label.component'; 6 | import { IconComponent } from './icon.component'; 7 | import { MenuComponent } from './menu.component'; 8 | import { PlayerButtonComponent } from './player-button.component'; 9 | import { CoverComponent } from './cover.component'; 10 | 11 | describe('AlbumComponent', () => { 12 | let component: AlbumComponent; 13 | let fixture: ComponentFixture; 14 | 15 | beforeEach(async () => { 16 | await TestBed.configureTestingModule({ 17 | imports: [RouterTestingModule, MatMenuModule], 18 | declarations: [ 19 | AlbumComponent, 20 | LabelComponent, 21 | IconComponent, 22 | MenuComponent, 23 | PlayerButtonComponent, 24 | CoverComponent, 25 | ], 26 | }).compileComponents(); 27 | }); 28 | 29 | beforeEach(() => { 30 | fixture = TestBed.createComponent(AlbumComponent); 31 | component = fixture.componentInstance; 32 | fixture.detectChanges(); 33 | }); 34 | 35 | it('should create', () => { 36 | expect(component).toBeTruthy(); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/app/core/components/artist-list-item.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ArtistListItemComponent } from './artist-list-item.component'; 4 | 5 | describe('ArtistListItemComponent', () => { 6 | let component: ArtistListItemComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ArtistListItemComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(ArtistListItemComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/core/components/artist.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | import { ArtistComponent } from './artist.component'; 4 | import { LabelComponent } from './label.component'; 5 | 6 | describe('ArtistComponent', () => { 7 | let component: ArtistComponent; 8 | let fixture: ComponentFixture; 9 | // let elem: any; 10 | 11 | beforeEach(async () => { 12 | await TestBed.configureTestingModule({ 13 | imports: [RouterTestingModule], 14 | declarations: [ArtistComponent, LabelComponent], 15 | }).compileComponents(); 16 | }); 17 | 18 | beforeEach(() => { 19 | fixture = TestBed.createComponent(ArtistComponent); 20 | component = fixture.componentInstance; 21 | // elem = fixture.nativeElement; 22 | }); 23 | 24 | it('should create', () => { 25 | expect(component).toBeTruthy(); 26 | }); 27 | 28 | // it('should display the artist cover', () => { 29 | // component.cover = 'cover.jpg'; 30 | // fixture.detectChanges(); 31 | // expect(elem.querySelector('img').src).toContain('cover.jpg'); 32 | // }); 33 | // 34 | // it('should display the artist name', () => { 35 | // component.name = 'Artist'; 36 | // component.artistRouterLink = './'; 37 | // fixture.detectChanges(); 38 | // expect(elem.querySelector('app-label').textContent).toContain('Artist'); 39 | // }); 40 | // 41 | // it('should display the legend', () => { 42 | // component.legend = '10 songs'; 43 | // fixture.detectChanges(); 44 | // expect(elem.querySelector('app-label').textContent).toContain('10 songs'); 45 | // }); 46 | // 47 | // it('should contain two links', () => { 48 | // component.name = 'Artist'; 49 | // component.artistRouterLink = 'link'; 50 | // fixture.detectChanges(); 51 | // expect(elem.querySelectorAll('a')).toHaveSize(2); 52 | // elem 53 | // .querySelectorAll('a') 54 | // .forEach((a: HTMLAnchorElement) => expect(a.href).toContain('link')); 55 | // }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/app/core/components/artist.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ChangeDetectionStrategy, Input } from '@angular/core'; 2 | import { Icons } from '@app/core/utils/icons.util'; 3 | import { Artist } from '@app/database/artists/artist.model'; 4 | import { EMPTY, Observable } from 'rxjs'; 5 | import { PictureFacade } from '@app/database/pictures/picture.facade'; 6 | 7 | @Component({ 8 | selector: 'app-artist', 9 | template: ` 10 | 26 | 34 | `, 35 | styles: [ 36 | ` 37 | :host { 38 | display: block; 39 | max-width: 226px; 40 | } 41 | a, 42 | img { 43 | display: block; 44 | } 45 | .image { 46 | margin-bottom: 16px; 47 | overflow: hidden; 48 | border-radius: 50%; 49 | background-color: rgba(255, 255, 255, 0.1); 50 | width: 100%; 51 | } 52 | .image a { 53 | height: 100%; 54 | text-align: center; 55 | } 56 | app-icon { 57 | color: rgba(255, 255, 255, 0.33); 58 | } 59 | `, 60 | ], 61 | changeDetection: ChangeDetectionStrategy.OnPush, 62 | }) 63 | export class ArtistComponent { 64 | @Input() artist!: Artist; 65 | 66 | cover$!: Observable; 67 | 68 | icons = Icons; 69 | 70 | constructor(private pictures: PictureFacade) { 71 | this.cover$ = EMPTY; // TODO this.pictures.getArtistCover(this.artist); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/app/core/components/container-home.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ContainerHomeComponent } from './container-home.component'; 4 | 5 | describe('ContainerHomeComponent', () => { 6 | let component: ContainerHomeComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ContainerHomeComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(ContainerHomeComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/core/components/container-home.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-container-home', 5 | template: ` `, 6 | styleUrls: ['../styles/container-home.component.scss'], 7 | changeDetection: ChangeDetectionStrategy.OnPush, 8 | }) 9 | export class ContainerHomeComponent {} 10 | -------------------------------------------------------------------------------- /src/app/core/components/container-page.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ContainerPageComponent } from './container-page.component'; 4 | 5 | describe('ContainerPageComponent', () => { 6 | let component: ContainerPageComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ContainerPageComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(ContainerPageComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/core/components/container-page.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-container-page', 5 | template: ` `, 6 | styleUrls: ['../styles/container-page.component.scss'], 7 | changeDetection: ChangeDetectionStrategy.OnPush, 8 | }) 9 | export class ContainerPageComponent {} 10 | -------------------------------------------------------------------------------- /src/app/core/components/container.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ContainerComponent } from './container.component'; 4 | 5 | describe('ContainerComponent', () => { 6 | let component: ContainerComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ContainerComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(ContainerComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/core/components/container.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ChangeDetectionStrategy } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-container', 5 | template: ` `, 6 | styleUrls: ['../styles/container.component.scss'], 7 | changeDetection: ChangeDetectionStrategy.OnPush, 8 | }) 9 | export class ContainerComponent {} 10 | -------------------------------------------------------------------------------- /src/app/core/components/cover.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { CoverComponent } from './cover.component'; 4 | import { MenuComponent } from './menu.component'; 5 | import { PlayerButtonComponent } from './player-button.component'; 6 | import { IconComponent } from './icon.component'; 7 | import { MatMenuModule } from '@angular/material/menu'; 8 | import { RouterTestingModule } from '@angular/router/testing'; 9 | 10 | describe('CoverComponent', () => { 11 | let component: CoverComponent; 12 | let fixture: ComponentFixture; 13 | 14 | beforeEach(async () => { 15 | await TestBed.configureTestingModule({ 16 | imports: [MatMenuModule, RouterTestingModule], 17 | declarations: [ 18 | CoverComponent, 19 | MenuComponent, 20 | PlayerButtonComponent, 21 | IconComponent, 22 | ], 23 | }).compileComponents(); 24 | }); 25 | 26 | beforeEach(() => { 27 | fixture = TestBed.createComponent(CoverComponent); 28 | component = fixture.componentInstance; 29 | // component.playerState = 'stopped'; 30 | fixture.detectChanges(); 31 | }); 32 | 33 | it('should create', () => { 34 | expect(component).toBeTruthy(); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/app/core/components/filters.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { FiltersComponent } from './filters.component'; 4 | 5 | describe('FiltersComponent', () => { 6 | let component: FiltersComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [FiltersComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(FiltersComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/core/components/filters.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ChangeDetectionStrategy, Input } from '@angular/core'; 2 | import { Icons } from '@app/core/utils'; 3 | 4 | export interface Filter { 5 | label: string; 6 | path: string; 7 | } 8 | 9 | @Component({ 10 | selector: 'app-filters', 11 | template: ` 12 | 13 | 21 | {{ filter.label }} 22 | 23 | 24 | 34 | 35 | 36 | `, 37 | styles: [ 38 | ` 39 | :host { 40 | display: flex; 41 | } 42 | mat-chip { 43 | min-height: 36px; 44 | border-radius: 18px; 45 | border: 1px solid rgba(255, 255, 255, 0.1); 46 | font-weight: 400; 47 | font-size: 16px; 48 | cursor: pointer; 49 | } 50 | .clear { 51 | padding-left: 5px; 52 | padding-right: 5px; 53 | margin-left: 0.5rem; 54 | } 55 | `, 56 | ], 57 | changeDetection: ChangeDetectionStrategy.OnPush, 58 | }) 59 | export class FiltersComponent { 60 | @Input() filters!: Filter[]; 61 | 62 | @Input() 63 | selectedIndex: number | null = null; 64 | 65 | icons = Icons; 66 | 67 | test($event: any) { 68 | console.log($event); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/app/core/components/genre.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { GenreComponent } from './genre.component'; 4 | import { RouterTestingModule } from '@angular/router/testing'; 5 | 6 | describe('GenreComponent', () => { 7 | let component: GenreComponent; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async () => { 11 | await TestBed.configureTestingModule({ 12 | imports: [RouterTestingModule], 13 | declarations: [GenreComponent], 14 | }).compileComponents(); 15 | }); 16 | 17 | beforeEach(() => { 18 | fixture = TestBed.createComponent(GenreComponent); 19 | component = fixture.componentInstance; 20 | fixture.detectChanges(); 21 | }); 22 | 23 | it('should create', () => { 24 | expect(component).toBeTruthy(); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/app/core/components/genre.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-genre', 5 | template: ` 6 | 13 | `, 14 | styles: [ 15 | ` 16 | :host { 17 | display: block; 18 | } 19 | button { 20 | height: 48px; 21 | width: 226px; 22 | text-align: left; 23 | padding: 0 12px; 24 | background-color: rgba(255, 255, 255, 0.15); 25 | border-left-width: 6px; 26 | border-left-style: solid; 27 | } 28 | `, 29 | ], 30 | changeDetection: ChangeDetectionStrategy.OnPush, 31 | }) 32 | export class GenreComponent { 33 | @Input() name!: string; 34 | @Input() color!: string; 35 | @Input() genreRouterLink!: any[] | string; 36 | } 37 | -------------------------------------------------------------------------------- /src/app/core/components/h-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { HListComponent } from './h-list.component'; 4 | import { IconComponent } from './icon.component'; 5 | import { MatButtonModule } from '@angular/material/button'; 6 | 7 | describe('HListComponent', () => { 8 | let component: HListComponent; 9 | let fixture: ComponentFixture; 10 | 11 | beforeEach(async () => { 12 | await TestBed.configureTestingModule({ 13 | declarations: [HListComponent, IconComponent], 14 | imports: [MatButtonModule], 15 | }).compileComponents(); 16 | }); 17 | 18 | beforeEach(() => { 19 | fixture = TestBed.createComponent(HListComponent); 20 | component = fixture.componentInstance; 21 | fixture.detectChanges(); 22 | }); 23 | 24 | it('should create', () => { 25 | expect(component).toBeTruthy(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/app/core/components/icon-likes.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { IconLikesComponent } from './icon-likes.component'; 4 | 5 | describe('IconLikesComponent', () => { 6 | let component: IconLikesComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [IconLikesComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(IconLikesComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/core/components/icon-likes.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ChangeDetectionStrategy, Input } from '@angular/core'; 2 | import { Icons } from '@app/core/utils/icons.util'; 3 | 4 | @Component({ 5 | selector: 'app-icon-likes', 6 | template: ` 7 | 11 | 16 | 21 | 22 | 23 | `, 24 | styles: [ 25 | ` 26 | :host { 27 | display: inline-flex; 28 | align-items: center; 29 | justify-content: center; 30 | vertical-align: middle; 31 | } 32 | svg { 33 | display: inline-block; 34 | } 35 | path { 36 | transform-origin: right bottom; 37 | mix-blend-mode: multiply; 38 | } 39 | .p1 { 40 | fill: #ee025c; 41 | } 42 | .p2 { 43 | fill: #c70056; 44 | } 45 | .p3 { 46 | fill: #8e004c; 47 | } 48 | `, 49 | ], 50 | changeDetection: ChangeDetectionStrategy.OnPush, 51 | }) 52 | export class IconLikesComponent { 53 | @Input() size = 160; 54 | @Input() fullWidth?: boolean; 55 | 56 | icons = Icons; 57 | } 58 | -------------------------------------------------------------------------------- /src/app/core/components/icon-likes2.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ChangeDetectionStrategy, Input } from '@angular/core'; 2 | import { Icons } from '@app/core/utils/icons.util'; 3 | 4 | @Component({ 5 | selector: 'app-icon-likes2', 6 | template: ` 7 | 11 | 16 | 21 | 22 | 23 | `, 24 | styles: [ 25 | ` 26 | :host { 27 | display: inline-flex; 28 | align-items: center; 29 | justify-content: center; 30 | vertical-align: middle; 31 | background-color: rgb(12, 69, 216); 32 | } 33 | 34 | svg { 35 | display: inline-block; 36 | } 37 | 38 | path { 39 | transform-origin: right center; 40 | mix-blend-mode: difference; 41 | } 42 | 43 | .p1 { 44 | fill: rgb(66, 65, 86); 45 | } 46 | 47 | .p2 { 48 | fill: rgb(59, 255, 41); 49 | } 50 | 51 | .p3 { 52 | fill: rgb(255, 169, 0); 53 | } 54 | `, 55 | ], 56 | changeDetection: ChangeDetectionStrategy.OnPush, 57 | }) 58 | export class IconLikes2Component { 59 | @Input() size = 160; 60 | @Input() fullWidth?: boolean; 61 | 62 | icons = Icons; 63 | } 64 | -------------------------------------------------------------------------------- /src/app/core/components/icon.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { IconComponent } from './icon.component'; 4 | 5 | describe('IconComponent', () => { 6 | let component: IconComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [IconComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(IconComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/core/components/icon.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-icon', 5 | template: ` 6 | 10 | 11 | 12 | `, 13 | styles: [ 14 | ` 15 | :host { 16 | display: inline-flex; 17 | align-items: center; 18 | justify-content: center; 19 | vertical-align: middle; 20 | } 21 | svg { 22 | display: inline-block; 23 | } 24 | path { 25 | fill: currentColor; 26 | } 27 | `, 28 | ], 29 | changeDetection: ChangeDetectionStrategy.OnPush, 30 | }) 31 | export class IconComponent { 32 | @Input() path!: string; 33 | @Input() size = 24; 34 | @Input() fullWidth?: boolean; 35 | } 36 | -------------------------------------------------------------------------------- /src/app/core/components/link.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { LinkComponent } from './link.component'; 4 | 5 | describe('LinkComponent', () => { 6 | let component: LinkComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [LinkComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(LinkComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/core/components/link.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, ChangeDetectionStrategy, Input } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-link', 5 | template: ` 6 | 9 | `, 10 | styles: [ 11 | ` 12 | :host { 13 | display: block; 14 | } 15 | button { 16 | font-size: 14px; 17 | font-weight: 500; 18 | text-transform: uppercase; 19 | color: rgba(255, 255, 255, 0.7); 20 | padding-left: 8px; 21 | padding-right: 8px; 22 | } 23 | `, 24 | ], 25 | changeDetection: ChangeDetectionStrategy.OnPush, 26 | }) 27 | export class LinkComponent { 28 | @Input() link: any[] | string | undefined | null; 29 | @Input() fragment?: string; 30 | } 31 | -------------------------------------------------------------------------------- /src/app/core/components/list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ListComponent } from './list.component'; 4 | 5 | describe('ListComponent', () => { 6 | let component: ListComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ListComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(ListComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/core/components/menu.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { MenuComponent } from './menu.component'; 4 | import { MatMenuModule } from '@angular/material/menu'; 5 | import { IconComponent } from './icon.component'; 6 | 7 | describe('MenuComponent', () => { 8 | let component: MenuComponent; 9 | let fixture: ComponentFixture; 10 | 11 | beforeEach(async () => { 12 | await TestBed.configureTestingModule({ 13 | imports: [MatMenuModule], 14 | declarations: [MenuComponent, IconComponent], 15 | }).compileComponents(); 16 | }); 17 | 18 | beforeEach(() => { 19 | fixture = TestBed.createComponent(MenuComponent); 20 | component = fixture.componentInstance; 21 | fixture.detectChanges(); 22 | }); 23 | 24 | it('should create', () => { 25 | expect(component).toBeTruthy(); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/app/core/components/player-button.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PlayerButtonComponent } from './player-button.component'; 4 | import { IconComponent } from './icon.component'; 5 | 6 | describe('PlayerButtonComponent', () => { 7 | let component: PlayerButtonComponent; 8 | let fixture: ComponentFixture; 9 | 10 | beforeEach(async () => { 11 | await TestBed.configureTestingModule({ 12 | declarations: [PlayerButtonComponent, IconComponent], 13 | }).compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(PlayerButtonComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/core/components/playlist-likes.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PlaylistLikesComponent } from './playlist-likes.component'; 4 | 5 | describe('PlaylistLikesComponent', () => { 6 | let component: PlaylistLikesComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [PlaylistLikesComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(PlaylistLikesComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/core/components/playlist.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PlaylistComponent } from './playlist.component'; 4 | import { CoverComponent } from './cover.component'; 5 | import { LabelComponent } from './label.component'; 6 | import { MenuComponent } from './menu.component'; 7 | import { MatMenuModule } from '@angular/material/menu'; 8 | import { IconComponent } from './icon.component'; 9 | import { PlayerButtonComponent } from './player-button.component'; 10 | import { RouterTestingModule } from '@angular/router/testing'; 11 | 12 | describe('PlaylistComponent', () => { 13 | let component: PlaylistComponent; 14 | let fixture: ComponentFixture; 15 | 16 | beforeEach(async () => { 17 | await TestBed.configureTestingModule({ 18 | imports: [MatMenuModule, RouterTestingModule], 19 | declarations: [ 20 | IconComponent, 21 | PlaylistComponent, 22 | CoverComponent, 23 | LabelComponent, 24 | MenuComponent, 25 | PlayerButtonComponent, 26 | ], 27 | }).compileComponents(); 28 | }); 29 | 30 | beforeEach(() => { 31 | fixture = TestBed.createComponent(PlaylistComponent); 32 | component = fixture.componentInstance; 33 | fixture.detectChanges(); 34 | }); 35 | 36 | it('should create', () => { 37 | expect(component).toBeTruthy(); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/app/core/components/recent-activity.component.spec.ts: -------------------------------------------------------------------------------- 1 | // import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | // 3 | // import { RecentActivityComponent } from './recent-activity.component'; 4 | // 5 | // describe('RecentActivityComponent', () => { 6 | // let component: RecentActivityComponent; 7 | // let fixture: ComponentFixture; 8 | // 9 | // beforeEach(async () => { 10 | // await TestBed.configureTestingModule({ 11 | // declarations: [RecentActivityComponent], 12 | // }).compileComponents(); 13 | // }); 14 | // 15 | // beforeEach(() => { 16 | // fixture = TestBed.createComponent(RecentActivityComponent); 17 | // component = fixture.componentInstance; 18 | // fixture.detectChanges(); 19 | // }); 20 | // 21 | // it('should create', () => { 22 | // expect(component).toBeTruthy(); 23 | // }); 24 | // }); 25 | -------------------------------------------------------------------------------- /src/app/core/components/router.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | 3 | @Component({ 4 | template: ` `, 5 | styles: [], 6 | changeDetection: ChangeDetectionStrategy.OnPush, 7 | }) 8 | export class RouterComponent {} 9 | -------------------------------------------------------------------------------- /src/app/core/components/select.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { SelectComponent } from './select.component'; 4 | 5 | describe('SelectComponent', () => { 6 | let component: SelectComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [SelectComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(SelectComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/core/components/song-list-item.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { SongListItemComponent } from './song-list-item.component'; 4 | 5 | describe('SongListItemComponent', () => { 6 | let component: SongListItemComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [SongListItemComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(SongListItemComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/core/components/song-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { SongListComponent } from './song-list.component'; 4 | 5 | describe('SongListComponent', () => { 6 | let component: SongListComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [SongListComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(SongListComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/core/components/song-list.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; 2 | import { Song, SongId } from '@app/database/songs/song.model'; 3 | import { Icons } from '@app/core/utils/icons.util'; 4 | import { PlayerFacade } from '@app/player/store/player.facade'; 5 | 6 | @Component({ 7 | selector: 'app-song-list', 8 | template: ` 9 | 16 | `, 17 | styles: [ 18 | ` 19 | :host { 20 | display: flex; 21 | flex-direction: column; 22 | } 23 | app-song-list-item { 24 | flex: 0 0 48px; 25 | } 26 | app-song-list-item:last-of-type { 27 | border: none; 28 | } 29 | app-song-list-item.selected { 30 | background-color: rgba(255, 255, 255, 0.1); 31 | } 32 | `, 33 | ], 34 | changeDetection: ChangeDetectionStrategy.OnPush, 35 | }) 36 | export class SongListComponent { 37 | @Input() songs!: Song[]; 38 | 39 | icons = Icons; 40 | 41 | currentSongPath$ = this.player.getCurrentSong$(); 42 | 43 | constructor(private player: PlayerFacade) {} 44 | 45 | trackBy(index: number, song: Song): string { 46 | return song.id; 47 | } 48 | 49 | getIds(songs: Song[]): SongId[] { 50 | return songs.map((s) => s.id); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/app/core/components/top-bar.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { TopBarComponent } from './top-bar.component'; 4 | import { IconComponent } from './icon.component'; 5 | import { MenuComponent } from './menu.component'; 6 | import { MatMenuModule } from '@angular/material/menu'; 7 | import { RouterTestingModule } from '@angular/router/testing'; 8 | 9 | describe('TopBarComponent', () => { 10 | let component: TopBarComponent; 11 | let fixture: ComponentFixture; 12 | 13 | beforeEach(async () => { 14 | await TestBed.configureTestingModule({ 15 | imports: [MatMenuModule, RouterTestingModule], 16 | declarations: [TopBarComponent, IconComponent, MenuComponent], 17 | }).compileComponents(); 18 | }); 19 | 20 | beforeEach(() => { 21 | fixture = TestBed.createComponent(TopBarComponent); 22 | component = fixture.componentInstance; 23 | fixture.detectChanges(); 24 | }); 25 | 26 | it('should create', () => { 27 | expect(component).toBeTruthy(); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/app/core/components/track-list-item.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { TrackListItemComponent } from '@app/core/components/track-list-item.component'; 4 | 5 | describe('TrackListComponent', () => { 6 | let component: TrackListItemComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [TrackListItemComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(TrackListItemComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/core/dialogs/confirm.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; 2 | import { Icons } from '@app/core/utils/icons.util'; 3 | import { MAT_DIALOG_DATA } from '@angular/material/dialog'; 4 | 5 | export interface ConfirmData { 6 | text: string; 7 | action: string; 8 | } 9 | 10 | @Component({ 11 | selector: 'app-confirm', 12 | template: ` 13 |

Please confirm

14 | 15 |

{{ data.text }}

16 |
17 |
18 | 19 | 22 |
23 | `, 24 | styles: [ 25 | ` 26 | :host { 27 | display: block; 28 | } 29 | .mat-dialog-container { 30 | padding-bottom: 0 !important; 31 | } 32 | .dialog-actions { 33 | text-align: right; 34 | margin: 0 -24px -24px; 35 | padding: 1em; 36 | } 37 | .mat-dialog-content { 38 | border-bottom: solid rgba(255, 255, 255, 0.1); 39 | border-width: 1px 0; 40 | padding: 0 24px 24px; 41 | } 42 | button { 43 | margin-left: 1em; 44 | } 45 | `, 46 | ], 47 | changeDetection: ChangeDetectionStrategy.OnPush, 48 | }) 49 | export class ConfirmComponent { 50 | icons = Icons; 51 | 52 | constructor(@Inject(MAT_DIALOG_DATA) public data: ConfirmData) {} 53 | } 54 | -------------------------------------------------------------------------------- /src/app/core/dialogs/playlist-add.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PlaylistAddComponent } from './playlist-add.component'; 4 | 5 | describe('AddToPlaylistComponent', () => { 6 | let component: PlaylistAddComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [PlaylistAddComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(PlaylistAddComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/core/dialogs/playlist-add.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { Icons } from '@app/core/utils/icons.util'; 3 | import { Observable } from 'rxjs'; 4 | import { Playlist } from '@app/database/playlists/playlist.model'; 5 | import { PlaylistFacade } from '@app/database/playlists/playlist.facade'; 6 | 7 | @Component({ 8 | selector: 'app-playlist-add', 9 | template: ` 10 | My playlists 11 | 12 | 13 | 17 | 18 | 19 |
20 | 23 |
24 | `, 25 | styles: [ 26 | ` 27 | :host { 28 | display: block; 29 | } 30 | app-icon { 31 | margin-right: 8px; 32 | } 33 | .mat-dialog-container { 34 | padding-bottom: 0 !important; 35 | } 36 | .dialog-actions { 37 | text-align: right; 38 | margin: 0 -24px 0; 39 | } 40 | .new-playlist { 41 | width: 100%; 42 | height: 52px; 43 | text-transform: uppercase; 44 | } 45 | .mat-dialog-content { 46 | padding: 0 !important; 47 | border: solid rgba(255, 255, 255, 0.1); 48 | border-width: 1px 0; 49 | } 50 | `, 51 | ], 52 | changeDetection: ChangeDetectionStrategy.OnPush, 53 | }) 54 | export class PlaylistAddComponent { 55 | icons = Icons; 56 | 57 | playlists$: Observable = this.playlists.getAll('title'); 58 | 59 | constructor(private playlists: PlaylistFacade) {} 60 | } 61 | -------------------------------------------------------------------------------- /src/app/core/dialogs/playlist-new.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PlaylistNewComponent } from './playlist-new.component'; 4 | 5 | describe('PlaylistDialogComponent', () => { 6 | let component: PlaylistNewComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [PlaylistNewComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(PlaylistNewComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/core/pipes/duration.pipe.ts: -------------------------------------------------------------------------------- 1 | import { Pipe, PipeTransform } from '@angular/core'; 2 | 3 | export const toDuration = (value: number | null | undefined): string => { 4 | if (!value) { 5 | return '0:00'; 6 | } 7 | 8 | const hours = Math.floor(value / 3600); 9 | const minutes = Math.floor(value / 60) % 60; 10 | const seconds = Math.floor(value) % 60; 11 | 12 | const format = (num: number) => num.toString(10).padStart(2, '0'); 13 | 14 | let result = `${minutes}:${format(seconds)}`; 15 | if (hours > 0) { 16 | result = format(hours) + ':' + result; 17 | } 18 | 19 | return result; 20 | }; 21 | 22 | @Pipe({ 23 | name: 'duration', 24 | }) 25 | export class DurationPipe implements PipeTransform { 26 | transform(value: number | null | undefined): string { 27 | return toDuration(value); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/core/services/history.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { HistoryService } from './history.service'; 4 | 5 | describe('HistoryService', () => { 6 | let service: HistoryService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(HistoryService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/core/services/history.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Album } from '@app/database/albums/album.model'; 3 | import { Artist } from '@app/database/artists/artist.model'; 4 | import { EMPTY, Observable } from 'rxjs'; 5 | 6 | @Injectable() 7 | export class HistoryService { 8 | latestPlayedAlbums$(): Observable { 9 | return EMPTY; 10 | // return this.library.getAlbums('listenedOn', undefined, 'prev'); 11 | } 12 | 13 | latestPlayedArtists$(): Observable { 14 | return EMPTY; 15 | // return this.library.getArtists('listenedOn', undefined, 'prev'); 16 | } 17 | 18 | // albumPlayed(album: Album): void { 19 | // this.storage 20 | // .update$('albums', { listenedOn: new Date() }, album.hash) 21 | // .subscribe(); 22 | // } 23 | // 24 | // artistPlayed(artist: Artist): void { 25 | // this.storage 26 | // .update$('artists', { listenedOn: new Date() }, artist.hash) 27 | // .subscribe(); 28 | // } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/core/services/spotify.service.ts: -------------------------------------------------------------------------------- 1 | // import { Injectable } from '@angular/core'; 2 | // import { HttpClient } from '@angular/common/http'; 3 | // import { Observable } from 'rxjs'; 4 | // import { Store } from '@ngrx/store'; 5 | // import { selectSpotifyToken } from '@app/store/core.selectors'; 6 | // import { concatMap, filter, take } from 'rxjs/operators'; 7 | // 8 | // @Injectable({ 9 | // providedIn: 'root', 10 | // }) 11 | // export class SpotifyService { 12 | // constructor(private http: HttpClient, private store: Store) {} 13 | // 14 | // search(query: string, type: string): Observable { 15 | // return this.store.select(selectSpotifyToken).pipe( 16 | // take(1), 17 | // filter((token) => token !== null), 18 | // concatMap((token) => 19 | // this.http.get('https://api.spotify.com/v1/search', { 20 | // headers: { 21 | // Authorization: `Bearer ${token}`, 22 | // }, 23 | // params: { 24 | // q: query, 25 | // type, 26 | // }, 27 | // observe: 'body', 28 | // responseType: 'json', 29 | // }) 30 | // ) 31 | // ); 32 | // } 33 | // } 34 | -------------------------------------------------------------------------------- /src/app/core/store/core.actions.ts: -------------------------------------------------------------------------------- 1 | // import { Song } from '@app/models/song.model'; 2 | 3 | // export const saveToDB = createAction( 4 | // '[Core] Save to db', 5 | // props<{ songs: Song[]; albums: Album[]; artists: Artist[] }>() 6 | // ); 7 | 8 | // export const getArtists = createAction('[Core] Get Artists'); 9 | // 10 | // export const getArtistsSucceeded = createAction('[Core] Get Artists Success'); 11 | // 12 | // export const getArtistsFailed = createAction('[Core] Get Artists Failed'); 13 | 14 | // export const setSpotifyToken = createAction( 15 | // '[Core] Spotify token', 16 | // props<{ token: string; expiresAt: number }>() 17 | // ); 18 | // 19 | // export const loadSpotifyToken = createAction('[Core] Load Spotify token'); 20 | // 21 | // export const loadSpotifyTokenSuccess = createAction( 22 | // '[Core] Load Spotify token success', 23 | // props<{ token: string; expiresAt: number }>() 24 | // ); 25 | // 26 | // export const loadSpotifyTokenFailure = createAction( 27 | // '[Core] Load Spotify token failure', 28 | // props<{ error: any }>() 29 | // ); 30 | -------------------------------------------------------------------------------- /src/app/core/store/core.reducer.ts: -------------------------------------------------------------------------------- 1 | import { ActionReducer, createReducer } from '@ngrx/store'; 2 | import { CoreState, initialState } from '@app/core/store/core.state'; 3 | 4 | export const coreReducer: ActionReducer = createReducer( 5 | initialState 6 | // on(setSpotifyToken, (state, { token, expiresAt }) => ({ 7 | // ...state, 8 | // spotify: { token, expiresAt }, 9 | // })), 10 | // on(loadSpotifyTokenSuccess, (state, { token, expiresAt }) => ({ 11 | // ...state, 12 | // spotify: { token, expiresAt }, 13 | // })) 14 | ); 15 | -------------------------------------------------------------------------------- /src/app/core/store/core.selectors.ts: -------------------------------------------------------------------------------- 1 | import { createFeatureSelector } from '@ngrx/store'; 2 | import { CoreState } from './core.state'; 3 | 4 | export const selectCoreState = createFeatureSelector('core'); 5 | 6 | // export const selectSpotifyToken = createSelector( 7 | // selectCoreState, 8 | // (state) => state.spotify.token 9 | // ); 10 | -------------------------------------------------------------------------------- /src/app/core/store/core.state.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 2 | export interface CoreState { 3 | // spotify: { 4 | // token: string; 5 | // expiresAt: number; 6 | // }; 7 | } 8 | 9 | export const initialState: CoreState = { 10 | // spotify: { 11 | // token: null, 12 | // expiresAt: null, 13 | // }, 14 | }; 15 | -------------------------------------------------------------------------------- /src/app/core/store/index.ts: -------------------------------------------------------------------------------- 1 | import { ActionReducerMap } from '@ngrx/store'; 2 | 3 | import { scannerReducer, ScannerState } from '@app/scanner/store'; 4 | import { libraryReducer, LibraryState } from '@app/library/store'; 5 | import { CoreState } from './core.state'; 6 | import { coreReducer } from './core.reducer'; 7 | import { PlayerState } from '@app/player/store/player.state'; 8 | import { playerReducer } from '@app/player/store/player.reducer'; 9 | 10 | export interface State { 11 | core: CoreState; 12 | scanner: ScannerState; 13 | library: LibraryState; 14 | player: PlayerState; 15 | } 16 | 17 | export const reducers: ActionReducerMap = { 18 | core: coreReducer, 19 | scanner: scannerReducer, 20 | library: libraryReducer, 21 | player: playerReducer, 22 | }; 23 | -------------------------------------------------------------------------------- /src/app/core/styles/container-home.component.scss: -------------------------------------------------------------------------------- 1 | $base-item-width: 250px; 2 | $container-margin: 24px; 3 | 4 | :host { 5 | display: block; 6 | width: 100%; 7 | margin-left: auto; 8 | margin-right: auto; 9 | } 10 | 11 | @for $n from 3 through 6 { 12 | $width: $n * $base-item-width - $container-margin; 13 | @media (min-width: $width + 2 * $container-margin + 12px) { 14 | :host { 15 | width: $width; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app/core/styles/container-page.component.scss: -------------------------------------------------------------------------------- 1 | $base-item-width: 246px; 2 | $container-margin: 24px; 3 | 4 | :host { 5 | display: block; 6 | width: calc(100% - 32px); 7 | margin-left: 16px; 8 | margin-right: 16px; 9 | transition: width 200ms ease; 10 | } 11 | 12 | @media (min-width: 615px) { 13 | :host { 14 | padding-left: 32px; 15 | padding-right: 32px; 16 | box-sizing: border-box; 17 | } 18 | } 19 | 20 | @for $n from 4 through 6 { 21 | $width: $n * $base-item-width; 22 | @media (min-width: $width + 2 * $container-margin + 12px) { 23 | :host { 24 | width: $width; 25 | margin-left: auto; 26 | margin-right: auto; 27 | padding-left: 0; 28 | padding-right: 0; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/core/styles/container.component.scss: -------------------------------------------------------------------------------- 1 | $base-item-width: 184px; 2 | $container-margin: 24px; 3 | 4 | :host { 5 | display: block; 6 | width: calc(100% - 32px); 7 | margin-left: 16px; 8 | margin-right: 16px; 9 | //transition: width 200ms ease; 10 | } 11 | 12 | @media (min-width: 615px) { 13 | :host { 14 | padding-left: 0; 15 | padding-right: 0; 16 | box-sizing: border-box; 17 | } 18 | } 19 | 20 | @for $n from 3 through 9 { 21 | $width: $n * $base-item-width - $container-margin; 22 | @media (min-width: $width + 2 * $container-margin + 12px) { 23 | :host { 24 | width: $width; 25 | margin-left: auto; 26 | margin-right: auto; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/core/utils/array-buffer-to-base64.util.ts: -------------------------------------------------------------------------------- 1 | export const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { 2 | let binary = ''; 3 | const bytes = new Uint8Array(buffer); 4 | const len = bytes.byteLength; 5 | for (let i = 0; i < len; i++) { 6 | binary += String.fromCharCode(bytes[i]); 7 | } 8 | return window.btoa(binary); 9 | }; 10 | -------------------------------------------------------------------------------- /src/app/core/utils/array-equals.util.ts: -------------------------------------------------------------------------------- 1 | export const arrayEquals = (a: T[], b: T[]): boolean => 2 | a.length === b.length && a.every((val, index) => val === b[index]); 3 | 4 | export const arrayEqualsUnordered = (a: T[], b: T[]): boolean => 5 | a.length === b.length && a.every((val) => b.includes(val)); 6 | -------------------------------------------------------------------------------- /src/app/core/utils/concat-tap.util.ts: -------------------------------------------------------------------------------- 1 | import { MonoTypeOperatorFunction, Observable } from 'rxjs'; 2 | import { concatMap, mapTo } from 'rxjs/operators'; 3 | 4 | export const concatTap: ( 5 | project: (_: T) => Observable 6 | ) => MonoTypeOperatorFunction = (project) => 7 | concatMap((t) => project(t).pipe(mapTo(t))); 8 | -------------------------------------------------------------------------------- /src/app/core/utils/either.util.ts: -------------------------------------------------------------------------------- 1 | import { connect, EMPTY, merge, of, OperatorFunction } from 'rxjs'; 2 | import { catchError, concatMap, map } from 'rxjs/operators'; 3 | 4 | export interface Left { 5 | tag: 'left'; 6 | error: any; 7 | } 8 | 9 | export interface Right { 10 | tag: 'right'; 11 | result: T; 12 | } 13 | 14 | export type Either = Left | Right; 15 | 16 | export const collectRight = (): OperatorFunction, T> => 17 | concatMap((t) => (t.tag === 'right' ? of(t.result) : EMPTY)); 18 | 19 | export const collectLeft = (): OperatorFunction, any> => 20 | concatMap((t) => (t.tag === 'left' ? of(t.error) : EMPTY)); 21 | 22 | export const right = (result: T): Right => ({ tag: 'right', result }); 23 | 24 | export const left = (error: unknown): Left => ({ tag: 'left', error }); 25 | 26 | export const toEither = 27 | (): OperatorFunction> => 28 | (obs) => 29 | obs.pipe( 30 | map((result) => right(result)), 31 | catchError((error) => of(left(error))) 32 | ); 33 | 34 | export const foldEither = ( 35 | rightMap: OperatorFunction, 36 | leftMap: OperatorFunction 37 | ): OperatorFunction, R> => 38 | connect((m$) => 39 | merge(m$.pipe(collectRight(), rightMap), m$.pipe(collectLeft(), leftMap)) 40 | ); 41 | -------------------------------------------------------------------------------- /src/app/core/utils/hash.util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A string hashing function based on Daniel J. Bernstein's popular 'times 33' hash algorithm. 3 | */ 4 | export const hash = (text: string): string => { 5 | let h = 5381; 6 | let index = text.length; 7 | 8 | while (index) { 9 | // eslint-disable-next-line no-bitwise 10 | h = (h * 33) ^ text.charCodeAt(--index); 11 | } 12 | // eslint-disable-next-line no-bitwise 13 | return (h >>> 0).toString(16); 14 | }; 15 | -------------------------------------------------------------------------------- /src/app/core/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from './concat-tap.util'; 2 | export * from './either.util'; 3 | export * from './hash.util'; 4 | export * from './icons.util'; 5 | export * from './scan-array.util'; 6 | export * from './shuffle-array.util'; 7 | export * from './tap-error.util'; 8 | export * from './types.util'; 9 | -------------------------------------------------------------------------------- /src/app/core/utils/longest-common-prefix.util.ts: -------------------------------------------------------------------------------- 1 | export const longestCommonPrefix = (arrayOfStrings: string[]): string => { 2 | let k = 0; 3 | const firstPos = 0; 4 | let longestPrefix = ''; 5 | 6 | while (true) { 7 | if (arrayOfStrings.length === 0 || !arrayOfStrings[firstPos][k]) { 8 | return longestPrefix; 9 | } 10 | 11 | const nextCharacter = arrayOfStrings[firstPos][k]; 12 | 13 | for (const item of arrayOfStrings) { 14 | if (item[k] !== nextCharacter) { 15 | return longestPrefix; 16 | } 17 | } 18 | k++; 19 | longestPrefix = `${longestPrefix}${nextCharacter}`; 20 | } 21 | }; 22 | 23 | export const longestCommonPath = (paths: string[]): string => { 24 | const split = paths.map((path) => path.split('/').slice(0, -1)); 25 | return split[0] 26 | .filter((path) => split.every((ps) => ps.includes(path))) 27 | .join('/'); 28 | }; 29 | -------------------------------------------------------------------------------- /src/app/core/utils/read-as-data-url.util.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | 3 | export const readAsDataURL = (blob: Blob): Observable => 4 | new Observable((observer) => { 5 | const reader = new FileReader(); 6 | 7 | reader.onload = (e) => { 8 | observer.next(e.target?.result as string); 9 | observer.complete(); 10 | }; 11 | 12 | reader.onerror = (e) => { 13 | observer.error(e.target?.error); 14 | }; 15 | 16 | reader.readAsDataURL(blob); 17 | 18 | return () => { 19 | reader.abort(); 20 | }; 21 | }); 22 | -------------------------------------------------------------------------------- /src/app/core/utils/reduce-array.util.ts: -------------------------------------------------------------------------------- 1 | export const reduceArray = 2 | () => 3 | (prev: T[], curr: T[]): T[] => 4 | [...prev, ...curr]; 5 | -------------------------------------------------------------------------------- /src/app/core/utils/remove-from-array.util.ts: -------------------------------------------------------------------------------- 1 | export const removeFromArray = (array: T[], element: T): T[] => { 2 | const indexToRemove = array.indexOf(element); 3 | return [ 4 | ...array.slice(0, indexToRemove), 5 | ...array.slice(indexToRemove + 1, array.length), 6 | ]; 7 | }; 8 | -------------------------------------------------------------------------------- /src/app/core/utils/scan-array.util.ts: -------------------------------------------------------------------------------- 1 | import { OperatorFunction } from 'rxjs'; 2 | import { scan } from 'rxjs/operators'; 3 | 4 | export const scanArray = (): OperatorFunction => 5 | scan((acc, cur) => [...acc, cur], [] as T[]); 6 | -------------------------------------------------------------------------------- /src/app/core/utils/shuffle-array.util.ts: -------------------------------------------------------------------------------- 1 | export const shuffleArray = (arr: T[]): T[] => { 2 | const array = [...arr]; 3 | for (let i = array.length - 1; i > 0; i--) { 4 | const j = Math.floor(Math.random() * (i + 1)); 5 | const temp = array[i]; 6 | array[i] = array[j]; 7 | array[j] = temp; 8 | } 9 | return array; 10 | }; 11 | -------------------------------------------------------------------------------- /src/app/core/utils/tap-error.util.ts: -------------------------------------------------------------------------------- 1 | import { tap } from 'rxjs/operators'; 2 | import { MonoTypeOperatorFunction } from 'rxjs'; 3 | 4 | export const tapError: ( 5 | onError: (_: any) => void 6 | ) => MonoTypeOperatorFunction = (project) => 7 | tap({ error: (err) => project(err) }); 8 | -------------------------------------------------------------------------------- /src/app/core/utils/types.util.ts: -------------------------------------------------------------------------------- 1 | export type IdUpdate = { 2 | key: K; 3 | changes: Partial; 4 | }; 5 | -------------------------------------------------------------------------------- /src/app/core/utils/uniq.util.ts: -------------------------------------------------------------------------------- 1 | import { identity } from 'rxjs'; 2 | 3 | export const uniq = 4 | (mapFn: (t: T) => any = identity) => 5 | (value: T, i: number, arr: T[]) => 6 | arr.map(mapFn).indexOf(mapFn(value)) === i; 7 | -------------------------------------------------------------------------------- /src/app/database/albums/album.actions.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fromAlbum from './album.actions'; 2 | 3 | describe('loadAlbums', () => { 4 | it('should return an action', () => { 5 | expect(fromAlbum.loadAlbums().type).toBe('[Album] Load Albums'); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/app/database/albums/album.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | import { Album } from '@app/database/albums/album.model'; 3 | import { IdUpdate } from '@app/core/utils'; 4 | 5 | export const removeAllAlbums = createAction('albums/remove-all'); 6 | 7 | export const loadAlbums = createAction('[Album] Load Albums'); 8 | 9 | export const loadAlbumsSuccess = createAction( 10 | '[Album] Load Album Success', 11 | props<{ data: Album[] }>() 12 | ); 13 | 14 | export const loadAlbumsFailure = createAction( 15 | '[Album] Load Albums Failure', 16 | props<{ error: any }>() 17 | ); 18 | 19 | export const addAlbum = createAction( 20 | '[Album] Add Album', 21 | props<{ album: Album }>() 22 | ); 23 | 24 | export const updateAlbum = createAction( 25 | '[Album] Update Album', 26 | props<{ update: IdUpdate }>() 27 | ); 28 | 29 | export const upsertAlbum = createAction( 30 | '[Album] Upsert Album', 31 | props<{ album: Album }>() 32 | ); 33 | -------------------------------------------------------------------------------- /src/app/database/albums/album.effects.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { provideMockActions } from '@ngrx/effects/testing'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { AlbumEffects } from './album.effects'; 6 | 7 | describe('AlbumEffects', () => { 8 | let actions$: Observable; 9 | let effects: AlbumEffects; 10 | 11 | beforeEach(() => { 12 | TestBed.configureTestingModule({ 13 | providers: [AlbumEffects, provideMockActions(() => actions$)], 14 | }); 15 | 16 | effects = TestBed.inject(AlbumEffects); 17 | }); 18 | 19 | it('should be created', () => { 20 | expect(effects).toBeTruthy(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/app/database/albums/album.effects.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Actions, createEffect, ofType } from '@ngrx/effects'; 3 | import { catchError, concatMap, map } from 'rxjs/operators'; 4 | import { EMPTY, of } from 'rxjs'; 5 | import { 6 | loadAlbums, 7 | loadAlbumsFailure, 8 | loadAlbumsSuccess, 9 | updateAlbum, 10 | } from './album.actions'; 11 | import { Album } from '@app/database/albums/album.model'; 12 | import { DatabaseService } from '@app/database/database.service'; 13 | 14 | // noinspection JSUnusedGlobalSymbols 15 | @Injectable() 16 | export class AlbumEffects { 17 | loadAlbums$ = createEffect(() => 18 | this.actions$.pipe( 19 | ofType(loadAlbums), 20 | concatMap(() => 21 | this.database.getAll$('albums').pipe( 22 | map((data) => loadAlbumsSuccess({ data })), 23 | catchError((error) => of(loadAlbumsFailure({ error }))) 24 | ) 25 | ) 26 | ) 27 | ); 28 | 29 | // upsertAlbum$ = createEffect( 30 | // () => 31 | // this.actions$.pipe( 32 | // ofType(upsertAlbum), 33 | // concatMap(({ album }) => 34 | // this.database.put$('albums', album).pipe( 35 | // catchError(() => EMPTY) // TODO 36 | // ) 37 | // ) 38 | // ), 39 | // { dispatch: false } 40 | // ); 41 | 42 | updateAlbum$ = createEffect( 43 | () => 44 | this.actions$.pipe( 45 | ofType(updateAlbum), 46 | concatMap(({ update: { changes, key } }) => 47 | this.database 48 | .update$('albums', changes, key) 49 | .pipe(catchError(() => EMPTY)) 50 | ) 51 | ), 52 | { dispatch: false } 53 | ); 54 | 55 | constructor(private actions$: Actions, private database: DatabaseService) {} 56 | } 57 | -------------------------------------------------------------------------------- /src/app/database/albums/album.facade.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { AlbumFacade } from './album.facade'; 4 | 5 | describe('AlbumFacadeService', () => { 6 | let service: AlbumFacade; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(AlbumFacade); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/database/albums/album.model.ts: -------------------------------------------------------------------------------- 1 | import { hash } from '@app/core/utils'; 2 | import { Opaque } from 'type-fest'; 3 | import { PictureId } from '@app/database/pictures/picture.model'; 4 | import { ArtistId } from '@app/database/artists/artist.model'; 5 | import { EntryId } from '@app/database/entries/entry.model'; 6 | 7 | export type AlbumId = Opaque; 8 | 9 | export const getAlbumId = (artist?: string, title?: string): AlbumId => 10 | hash(`${artist}|${title}}`) as AlbumId; 11 | 12 | export type Album = { 13 | id: AlbumId; 14 | entries: EntryId[]; 15 | albumArtist: { name: string; id: ArtistId }; 16 | artists: ArtistId[]; 17 | likedOn?: number; 18 | pictureId?: PictureId; 19 | title: string; 20 | updatedOn: number; 21 | year?: number; 22 | folder: string; 23 | }; 24 | 25 | // eslint-disable-next-line @typescript-eslint/naming-convention 26 | // export const Album = { 27 | // getHash: (albumArtist?: string, albumName?: string): string => 28 | // hash(`${albumArtist}|${albumName}}`), 29 | // }; 30 | -------------------------------------------------------------------------------- /src/app/database/albums/album.reducer.spec.ts: -------------------------------------------------------------------------------- 1 | import { albumReducer, initialState } from './album.reducer'; 2 | 3 | describe('Album Reducer', () => { 4 | describe('an unknown action', () => { 5 | it('should return the previous state', () => { 6 | const action = {} as any; 7 | 8 | const result = albumReducer(initialState, action); 9 | 10 | expect(result).toBe(initialState); 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/app/database/albums/album.reducer.ts: -------------------------------------------------------------------------------- 1 | import { createReducer, on } from '@ngrx/store'; 2 | import { Album } from '@app/database/albums/album.model'; 3 | import { createIDBEntityAdapter, IDBEntityState } from '@creasource/ngrx-idb'; 4 | import { 5 | addAlbum, 6 | loadAlbums, 7 | loadAlbumsFailure, 8 | loadAlbumsSuccess, 9 | removeAllAlbums, 10 | updateAlbum, 11 | upsertAlbum, 12 | } from '@app/database/albums/album.actions'; 13 | 14 | export const albumFeatureKey = 'albums'; 15 | 16 | const indexes = [ 17 | { name: 'title' }, 18 | { name: 'year' }, 19 | { name: 'albumArtist', keySelector: (album: Album) => album.albumArtist.id }, 20 | { name: 'artists', multiEntry: true }, 21 | { name: 'likedOn' }, 22 | { name: 'updatedOn' }, 23 | ] as const; 24 | 25 | const indexNames = indexes.map((i) => i.name); 26 | 27 | export type AlbumIndex = typeof indexNames[number]; 28 | 29 | export const albumAdapter = createIDBEntityAdapter({ 30 | keySelector: (album: Album) => album.id, 31 | indexes, 32 | }); 33 | 34 | export type AlbumState = IDBEntityState; 35 | 36 | export const initialState = albumAdapter.getInitialState(); 37 | 38 | export const albumReducer = createReducer( 39 | initialState, 40 | 41 | on(removeAllAlbums, (state) => albumAdapter.removeAll(state)), 42 | on(loadAlbums, (state) => state), 43 | on(loadAlbumsSuccess, (state, action) => 44 | albumAdapter.setAll(action.data, state) 45 | ), 46 | on(loadAlbumsFailure, (state) => state), 47 | on(addAlbum, (state, action) => albumAdapter.addOne(action.album, state)), 48 | on(updateAlbum, (state, action) => 49 | albumAdapter.updateOne(action.update, state) 50 | ), 51 | on(upsertAlbum, (state, action) => 52 | albumAdapter.upsertOne(action.album, state) 53 | ) 54 | ); 55 | -------------------------------------------------------------------------------- /src/app/database/albums/album.selectors.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fromAlbum from './album.reducer'; 2 | import { selectAlbumState } from './album.selectors'; 3 | 4 | describe('Album Selectors', () => { 5 | it('should select the feature state', () => { 6 | const result: any = selectAlbumState({ 7 | [fromAlbum.albumFeatureKey]: {}, 8 | }); 9 | 10 | expect(result).toEqual({}); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/app/database/albums/album.selectors.ts: -------------------------------------------------------------------------------- 1 | import { createFeatureSelector, createSelector } from '@ngrx/store'; 2 | import { 3 | albumAdapter, 4 | albumFeatureKey, 5 | AlbumState, 6 | } from '@app/database/albums/album.reducer'; 7 | import { Album, AlbumId } from '@app/database/albums/album.model'; 8 | import { ArtistId } from '@app/database/artists/artist.model'; 9 | 10 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 11 | 12 | export const selectAlbumState = 13 | createFeatureSelector(albumFeatureKey); 14 | 15 | export const { 16 | selectIndexKeys: selectAlbumIndexKeys, 17 | selectIndexEntities: selectAlbumIndexEntities, 18 | selectIndexAll: selectAlbumIndexAll, 19 | selectKeys: selectAlbumKeys, 20 | selectEntities: selectAlbumEntities, 21 | selectAll: selectAlbumAll, 22 | selectTotal: selectAlbumTotal, 23 | } = albumAdapter.getSelectors(selectAlbumState); 24 | 25 | export const selectAlbumByKey = (key: AlbumId) => 26 | createSelector(selectAlbumEntities, (entities) => entities[key]); 27 | 28 | export const selectAlbumByAlbumArtistKey = (key: ArtistId) => 29 | createSelector( 30 | selectAlbumEntities, 31 | selectAlbumIndexEntities('albumArtist'), 32 | (entities, index) => index[key]?.map((k) => entities[k as any] as Album) 33 | ); 34 | 35 | export const selectAlbumByArtistKey = (key: ArtistId) => 36 | createSelector( 37 | selectAlbumEntities, 38 | selectAlbumIndexEntities('artists'), 39 | (entities, index) => index[key]?.map((k) => entities[k as any] as Album) 40 | ); 41 | -------------------------------------------------------------------------------- /src/app/database/artists/artist.actions.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fromArtist from './artist.actions'; 2 | 3 | describe('loadArtists', () => { 4 | it('should return an action', () => { 5 | expect(fromArtist.loadArtists().type).toBe('[Artist] Load Artists'); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/app/database/artists/artist.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | import { Artist } from '@app/database/artists/artist.model'; 3 | import { IdUpdate } from '@app/core/utils'; 4 | 5 | export const removeAllArtists = createAction('artists/remove-all'); 6 | 7 | export const loadArtists = createAction('[Artist] Load Artists'); 8 | 9 | export const loadArtistsSuccess = createAction( 10 | '[Artist] Load Artists Success', 11 | props<{ data: Artist[] }>() 12 | ); 13 | 14 | export const loadArtistsFailure = createAction( 15 | '[Artist] Load Artists Failure', 16 | props<{ error: any }>() 17 | ); 18 | 19 | export const addArtist = createAction( 20 | '[Artist] Add Artist', 21 | props<{ artist: Artist }>() 22 | ); 23 | 24 | export const updateArtist = createAction( 25 | '[Artist] Update Artist', 26 | props<{ update: IdUpdate }>() 27 | ); 28 | 29 | export const upsertArtist = createAction( 30 | '[Artist] Upsert Artist', 31 | props<{ artist: Artist }>() 32 | ); 33 | -------------------------------------------------------------------------------- /src/app/database/artists/artist.effects.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { provideMockActions } from '@ngrx/effects/testing'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { ArtistEffects } from './artist.effects'; 6 | 7 | describe('ArtistEffects', () => { 8 | let actions$: Observable; 9 | let effects: ArtistEffects; 10 | 11 | beforeEach(() => { 12 | TestBed.configureTestingModule({ 13 | providers: [ArtistEffects, provideMockActions(() => actions$)], 14 | }); 15 | 16 | effects = TestBed.inject(ArtistEffects); 17 | }); 18 | 19 | it('should be created', () => { 20 | expect(effects).toBeTruthy(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/app/database/artists/artist.effects.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Actions, createEffect, ofType } from '@ngrx/effects'; 3 | import { catchError, concatMap, map } from 'rxjs/operators'; 4 | import { EMPTY, of } from 'rxjs'; 5 | import { 6 | loadArtists, 7 | loadArtistsFailure, 8 | loadArtistsSuccess, 9 | updateArtist, 10 | } from './artist.actions'; 11 | import { DatabaseService } from '@app/database/database.service'; 12 | import { Artist } from '@app/database/artists/artist.model'; 13 | 14 | // noinspection JSUnusedGlobalSymbols 15 | @Injectable() 16 | export class ArtistEffects { 17 | loadArtists$ = createEffect(() => 18 | this.actions$.pipe( 19 | ofType(loadArtists), 20 | concatMap(() => 21 | /** An EMPTY observable only emits completion. Replace with your own observable API request */ 22 | this.database.getAll$('artists').pipe( 23 | //map(({ value }) => value), 24 | // bufferTime(100), 25 | // filter((arr) => arr.length > 0), 26 | //toArray(), 27 | map((data) => loadArtistsSuccess({ data })), 28 | catchError((error) => of(loadArtistsFailure({ error }))) 29 | ) 30 | ) 31 | ) 32 | ); 33 | 34 | // upsertArtist$ = createEffect( 35 | // () => 36 | // this.actions$.pipe( 37 | // ofType(upsertArtist), 38 | // concatMap(({ artist }) => 39 | // this.database.put$('artists', artist) 40 | // ), 41 | // catchError(() => EMPTY) // TODO 42 | // ), 43 | // { dispatch: false } 44 | // ); 45 | 46 | updateArtist$ = createEffect( 47 | () => 48 | this.actions$.pipe( 49 | ofType(updateArtist), 50 | concatMap(({ update: { changes, key } }) => 51 | this.database.update$('artists', changes, key).pipe( 52 | catchError(() => EMPTY) // TODO 53 | ) 54 | ) 55 | ), 56 | { dispatch: false } 57 | ); 58 | 59 | constructor(private actions$: Actions, private database: DatabaseService) {} 60 | } 61 | -------------------------------------------------------------------------------- /src/app/database/artists/artist.facade.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { ArtistFacade } from './artist.facade'; 4 | 5 | describe('ArtistService', () => { 6 | let service: ArtistFacade; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(ArtistFacade); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/database/artists/artist.model.ts: -------------------------------------------------------------------------------- 1 | import { hash } from '@app/core/utils'; 2 | import { Opaque } from 'type-fest'; 3 | import { EntryId } from '@app/database/entries/entry.model'; 4 | 5 | export type ArtistId = Opaque; 6 | 7 | export const getArtistId = (name: string): ArtistId => hash(name) as ArtistId; 8 | 9 | export type Artist = { 10 | id: ArtistId; 11 | entries: EntryId[]; 12 | name: string; 13 | updatedOn: number; 14 | likedOn?: number; 15 | }; 16 | -------------------------------------------------------------------------------- /src/app/database/artists/artist.reducer.spec.ts: -------------------------------------------------------------------------------- 1 | import { artistReducer, initialState } from './artist.reducer'; 2 | 3 | describe('Artist Reducer', () => { 4 | describe('an unknown action', () => { 5 | it('should return the previous state', () => { 6 | const action = {} as any; 7 | 8 | const result = artistReducer(initialState, action); 9 | 10 | expect(result).toBe(initialState); 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/app/database/artists/artist.reducer.ts: -------------------------------------------------------------------------------- 1 | import { createReducer, on } from '@ngrx/store'; 2 | import { Artist } from '@app/database/artists/artist.model'; 3 | import { createIDBEntityAdapter, IDBEntityState } from '@creasource/ngrx-idb'; 4 | import { 5 | addArtist, 6 | loadArtists, 7 | loadArtistsFailure, 8 | loadArtistsSuccess, 9 | removeAllArtists, 10 | updateArtist, 11 | upsertArtist, 12 | } from './artist.actions'; 13 | 14 | export const artistFeatureKey = 'artists'; 15 | 16 | export const artistIndexes = [ 17 | { name: 'name' }, 18 | { name: 'likedOn' }, 19 | { name: 'updatedOn' }, 20 | ] as const; 21 | 22 | const indexNames = artistIndexes.map((i) => i.name); 23 | 24 | export type ArtistIndex = typeof indexNames[number]; 25 | 26 | export const artistAdapter = createIDBEntityAdapter({ 27 | keySelector: (artist: Artist) => artist.id, 28 | indexes: artistIndexes, 29 | }); 30 | 31 | export type ArtistState = IDBEntityState; 32 | 33 | export const initialState = artistAdapter.getInitialState(); 34 | 35 | export const artistReducer = createReducer( 36 | initialState, 37 | 38 | on(removeAllArtists, (state) => artistAdapter.removeAll(state)), 39 | on(loadArtists, (state) => state), 40 | on(loadArtistsSuccess, (state, action) => 41 | artistAdapter.setAll(action.data, state) 42 | ), 43 | on(loadArtistsFailure, (state) => state), 44 | on(addArtist, (state, action) => artistAdapter.addOne(action.artist, state)), 45 | on(updateArtist, (state, action) => 46 | artistAdapter.updateOne(action.update, state) 47 | ), 48 | on(upsertArtist, (state, action) => 49 | artistAdapter.upsertOne(action.artist, state) 50 | ) 51 | ); 52 | -------------------------------------------------------------------------------- /src/app/database/artists/artist.selectors.spec.ts: -------------------------------------------------------------------------------- 1 | import { selectArtistState } from './artist.selectors'; 2 | import { artistFeatureKey } from '@app/database/artists/artist.reducer'; 3 | 4 | describe('Artist Selectors', () => { 5 | it('should select the feature state', () => { 6 | const result: any = selectArtistState({ 7 | [artistFeatureKey]: {}, 8 | }); 9 | 10 | expect(result).toEqual({}); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/app/database/artists/artist.selectors.ts: -------------------------------------------------------------------------------- 1 | import { createFeatureSelector, createSelector } from '@ngrx/store'; 2 | import { artistAdapter, artistFeatureKey, ArtistState } from './artist.reducer'; 3 | import { ArtistId } from '@app/database/artists/artist.model'; 4 | 5 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 6 | 7 | export const selectArtistState = 8 | createFeatureSelector(artistFeatureKey); 9 | 10 | export const { 11 | selectIndexKeys: selectArtistIndexKeys, 12 | selectIndexEntities: selectArtistIndexEntities, 13 | selectIndexAll: selectArtistIndexAll, 14 | selectKeys: selectArtistKeys, 15 | selectEntities: selectArtistEntities, 16 | selectAll: selectArtistAll, 17 | selectTotal: selectArtistTotal, 18 | } = artistAdapter.getSelectors(selectArtistState); 19 | 20 | export const selectArtistByKey = (key: ArtistId) => 21 | createSelector(selectArtistEntities, (entities) => entities[key]); 22 | -------------------------------------------------------------------------------- /src/app/database/entries/entry.actions.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fromEntry from './entry.actions'; 2 | 3 | describe('loadEntries', () => { 4 | it('should return an action', () => { 5 | expect(fromEntry.loadEntries().type).toBe('[Entry] Load Entries'); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/app/database/entries/entry.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | import { Entry } from '@app/database/entries/entry.model'; 3 | 4 | export const removeAllEntries = createAction('entries/remove-all'); 5 | 6 | export const loadEntries = createAction('[Entry] Load Entries'); 7 | 8 | export const loadEntriesSuccess = createAction( 9 | '[Entry] Load Entries Success', 10 | props<{ data: Entry[] }>() 11 | ); 12 | 13 | export const loadEntriesFailure = createAction( 14 | '[Entry] Load Entries Failure', 15 | props<{ error: any }>() 16 | ); 17 | 18 | export const addEntry = createAction( 19 | '[Entry] Add Entry', 20 | props<{ entry: Entry }>() 21 | ); 22 | -------------------------------------------------------------------------------- /src/app/database/entries/entry.effects.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { provideMockActions } from '@ngrx/effects/testing'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { EntryEffects } from './entry.effects'; 6 | 7 | describe('EntryEffects', () => { 8 | let actions$: Observable; 9 | let effects: EntryEffects; 10 | 11 | beforeEach(() => { 12 | TestBed.configureTestingModule({ 13 | providers: [EntryEffects, provideMockActions(() => actions$)], 14 | }); 15 | 16 | effects = TestBed.inject(EntryEffects); 17 | }); 18 | 19 | it('should be created', () => { 20 | expect(effects).toBeTruthy(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/app/database/entries/entry.effects.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Actions, createEffect, ofType } from '@ngrx/effects'; 3 | import { catchError, concatMap, map } from 'rxjs/operators'; 4 | import { of } from 'rxjs'; 5 | import { 6 | loadEntries, 7 | loadEntriesFailure, 8 | loadEntriesSuccess, 9 | } from '@app/database/entries/entry.actions'; 10 | import { DatabaseService } from '@app/database/database.service'; 11 | import { Entry } from '@app/database/entries/entry.model'; 12 | 13 | // noinspection JSUnusedGlobalSymbols 14 | @Injectable() 15 | export class EntryEffects { 16 | loadEntries$ = createEffect(() => 17 | this.actions$.pipe( 18 | ofType(loadEntries), 19 | concatMap(() => 20 | this.database.getAll$('entries').pipe( 21 | map((data) => loadEntriesSuccess({ data })), 22 | catchError((error) => of(loadEntriesFailure({ error }))) 23 | ) 24 | ) 25 | ) 26 | ); 27 | 28 | constructor(private actions$: Actions, private database: DatabaseService) {} 29 | } 30 | -------------------------------------------------------------------------------- /src/app/database/entries/entry.facade.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { EntryFacade } from './entry.facade'; 4 | 5 | describe('EntryService', () => { 6 | let service: EntryFacade; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(EntryFacade); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/database/entries/entry.facade.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Store } from '@ngrx/store'; 3 | import { Observable } from 'rxjs'; 4 | import { EntryIndex } from '@app/database/entries/entry.reducer'; 5 | import { Entry, EntryId } from '@app/database/entries/entry.model'; 6 | import { 7 | selectEntryAll, 8 | selectEntryByKey, 9 | selectEntryIndexAll, 10 | selectEntryTotal, 11 | } from '@app/database/entries/entry.selectors'; 12 | import { addEntry } from '@app/database/entries/entry.actions'; 13 | import { DatabaseService } from '@app/database/database.service'; 14 | import { map, tap } from 'rxjs/operators'; 15 | 16 | @Injectable() 17 | export class EntryFacade { 18 | constructor(private store: Store, private database: DatabaseService) {} 19 | 20 | put(entry: Entry): Observable { 21 | return this.database 22 | .put$('entries', entry) 23 | .pipe(tap(() => this.store.dispatch(addEntry({ entry })))); 24 | } 25 | 26 | getByKey(key: EntryId): Observable { 27 | return this.store.select(selectEntryByKey(key)); 28 | } 29 | 30 | getAll(index?: EntryIndex): Observable { 31 | return index 32 | ? this.store.select(selectEntryIndexAll(index)) 33 | : this.store.select(selectEntryAll); 34 | } 35 | 36 | getTotal(): Observable { 37 | return this.store.select(selectEntryTotal); 38 | } 39 | 40 | exists(id: EntryId): Observable { 41 | return this.database.getKey$('entries', id).pipe(map((key) => !!key)); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/app/database/entries/entry.reducer.spec.ts: -------------------------------------------------------------------------------- 1 | import { entryReducer, initialState } from './entry.reducer'; 2 | 3 | describe('Entry Reducer', () => { 4 | describe('an unknown action', () => { 5 | it('should return the previous state', () => { 6 | const action = {} as any; 7 | 8 | const result = entryReducer(initialState, action); 9 | 10 | expect(result).toBe(initialState); 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/app/database/entries/entry.reducer.ts: -------------------------------------------------------------------------------- 1 | import { createReducer, on } from '@ngrx/store'; 2 | import { Entry } from '@app/database/entries/entry.model'; 3 | import { createIDBEntityAdapter, IDBEntityState } from '@creasource/ngrx-idb'; 4 | import { 5 | addEntry, 6 | loadEntries, 7 | loadEntriesFailure, 8 | loadEntriesSuccess, 9 | removeAllEntries, 10 | } from '@app/database/entries/entry.actions'; 11 | 12 | export const entryFeatureKey = 'entries'; 13 | 14 | export const indexes = ['parent'] as const; 15 | 16 | export type EntryIndex = typeof indexes[number]; 17 | 18 | export type EntryState = IDBEntityState; 19 | 20 | export const entryAdapter = createIDBEntityAdapter({ 21 | keySelector: (model) => model.id, 22 | indexes, 23 | }); 24 | 25 | export const initialState: EntryState = entryAdapter.getInitialState(); 26 | 27 | export const entryReducer = createReducer( 28 | initialState, 29 | 30 | on(removeAllEntries, (state) => entryAdapter.removeAll(state)), 31 | on(loadEntries, (state) => state), 32 | on(loadEntriesSuccess, (state, action) => 33 | entryAdapter.setAll(action.data, state) 34 | ), 35 | on(loadEntriesFailure, (state) => state), 36 | on(addEntry, (state, { entry }) => entryAdapter.addOne(entry, state)) 37 | ); 38 | -------------------------------------------------------------------------------- /src/app/database/entries/entry.selectors.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fromEntry from './entry.reducer'; 2 | import { selectEntryState } from './entry.selectors'; 3 | 4 | describe('Entry Selectors', () => { 5 | it('should select the feature state', () => { 6 | const result: any = selectEntryState({ 7 | [fromEntry.entryFeatureKey]: {}, 8 | }); 9 | 10 | expect(result).toEqual({}); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/app/database/entries/entry.selectors.ts: -------------------------------------------------------------------------------- 1 | import { createFeatureSelector, createSelector } from '@ngrx/store'; 2 | import { entryAdapter, entryFeatureKey, EntryState } from './entry.reducer'; 3 | 4 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 5 | 6 | export const selectEntryState = 7 | createFeatureSelector(entryFeatureKey); 8 | 9 | export const { 10 | selectIndexKeys: selectEntryIndexKeys, 11 | selectIndexEntities: selectEntryIndexEntities, 12 | selectIndexAll: selectEntryIndexAll, 13 | selectKeys: selectEntryKeys, 14 | selectEntities: selectEntryEntities, 15 | selectAll: selectEntryAll, 16 | selectTotal: selectEntryTotal, 17 | } = entryAdapter.getSelectors(selectEntryState); 18 | 19 | export const selectEntryByKey = (key: string) => 20 | createSelector(selectEntryEntities, (entities) => entities[key]); 21 | -------------------------------------------------------------------------------- /src/app/database/pictures/picture.actions.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fromPicture from './picture.actions'; 2 | 3 | describe('loadPictures', () => { 4 | it('should return an action', () => { 5 | expect(fromPicture.loadPictures().type).toBe('[Picture] Load Pictures'); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/app/database/pictures/picture.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | import { Picture } from '@app/database/pictures/picture.model'; 3 | import { IdUpdate } from '@app/core/utils'; 4 | 5 | export const removeAllPictures = createAction('pictures/remove-all'); 6 | 7 | export const loadPictures = createAction('[Picture] Load Pictures'); 8 | 9 | export const loadPicturesSuccess = createAction( 10 | '[Picture] Load Pictures Success', 11 | props<{ data: Picture[] }>() 12 | ); 13 | 14 | export const loadPicturesFailure = createAction( 15 | '[Picture] Load Pictures Failure', 16 | props<{ error: any }>() 17 | ); 18 | 19 | export const addPicture = createAction( 20 | 'pictures/add', 21 | props<{ picture: Picture }>() 22 | ); 23 | 24 | export const updatePicture = createAction( 25 | 'pictures/update', 26 | props<{ update: IdUpdate }>() 27 | ); 28 | 29 | export const upsertPicture = createAction( 30 | 'pictures/upsert', 31 | props<{ picture: Picture }>() 32 | ); 33 | -------------------------------------------------------------------------------- /src/app/database/pictures/picture.effects.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { provideMockActions } from '@ngrx/effects/testing'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { PictureEffects } from './picture.effects'; 6 | 7 | describe('PictureEffects', () => { 8 | let actions$: Observable; 9 | let effects: PictureEffects; 10 | 11 | beforeEach(() => { 12 | TestBed.configureTestingModule({ 13 | providers: [PictureEffects, provideMockActions(() => actions$)], 14 | }); 15 | 16 | effects = TestBed.inject(PictureEffects); 17 | }); 18 | 19 | it('should be created', () => { 20 | expect(effects).toBeTruthy(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/app/database/pictures/picture.facade.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { PictureFacade } from './picture.facade'; 4 | 5 | describe('PictureService', () => { 6 | let service: PictureFacade; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(PictureFacade); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/database/pictures/picture.model.ts: -------------------------------------------------------------------------------- 1 | import { Opaque } from 'type-fest'; 2 | import { hash } from '@app/core/utils'; 3 | import { SongId } from '@app/database/songs/song.model'; 4 | import { AlbumId } from '@app/database/albums/album.model'; 5 | import { ArtistId } from '@app/database/artists/artist.model'; 6 | import { EntryId } from '@app/database/entries/entry.model'; 7 | 8 | export type PictureId = Opaque; 9 | 10 | export const getPictureId = (data: string): PictureId => 11 | hash(data) as PictureId; 12 | 13 | export type Picture = { 14 | id: PictureId; 15 | name?: string; 16 | data: Buffer; 17 | format: string; 18 | sources: { src: string; height: number; width?: number }[]; 19 | entries: EntryId[]; 20 | songs: SongId[]; 21 | albums: AlbumId[]; 22 | artists: ArtistId[]; 23 | }; 24 | -------------------------------------------------------------------------------- /src/app/database/pictures/picture.reducer.spec.ts: -------------------------------------------------------------------------------- 1 | import { pictureReducer, initialState } from './picture.reducer'; 2 | 3 | describe('Picture Reducer', () => { 4 | describe('an unknown action', () => { 5 | it('should return the previous state', () => { 6 | const action = {} as any; 7 | 8 | const result = pictureReducer(initialState, action); 9 | 10 | expect(result).toBe(initialState); 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/app/database/pictures/picture.reducer.ts: -------------------------------------------------------------------------------- 1 | import { createReducer, on } from '@ngrx/store'; 2 | import { 3 | addPicture, 4 | loadPictures, 5 | loadPicturesFailure, 6 | loadPicturesSuccess, 7 | removeAllPictures, 8 | updatePicture, 9 | upsertPicture, 10 | } from './picture.actions'; 11 | import { Picture } from '@app/database/pictures/picture.model'; 12 | import { createIDBEntityAdapter, IDBEntityState } from '@creasource/ngrx-idb'; 13 | 14 | export const pictureFeatureKey = 'pictures'; 15 | 16 | export interface PictureState 17 | extends IDBEntityState { 18 | loaded: boolean; 19 | error?: any; 20 | } 21 | 22 | export const pictureAdapter = createIDBEntityAdapter< 23 | Picture, 24 | 'artists' | 'albums' | 'songs' 25 | >({ 26 | keySelector: (model) => model.id, 27 | indexes: [ 28 | { name: 'artists', multiEntry: true }, 29 | { name: 'albums', multiEntry: true }, 30 | { name: 'songs', multiEntry: true }, 31 | ], 32 | }); 33 | 34 | export const initialState = pictureAdapter.getInitialState({ 35 | loaded: false, 36 | }); 37 | 38 | export const pictureReducer = createReducer( 39 | initialState, 40 | 41 | on(removeAllPictures, (state) => pictureAdapter.removeAll(state)), 42 | on(loadPictures, (state) => state), 43 | on(loadPicturesSuccess, (state, { data }) => 44 | pictureAdapter.setAll(data, { ...state, loaded: true }) 45 | ), 46 | on(loadPicturesFailure, (state, { error }) => ({ ...state, error })), 47 | on(addPicture, (state, action) => 48 | pictureAdapter.addOne(action.picture, state) 49 | ), 50 | on(upsertPicture, (state, action) => 51 | pictureAdapter.upsertOne(action.picture, state) 52 | ), 53 | on(updatePicture, (state, action) => 54 | pictureAdapter.updateOne(action.update, state) 55 | ) 56 | ); 57 | -------------------------------------------------------------------------------- /src/app/database/pictures/picture.selectors.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fromPicture from './picture.reducer'; 2 | import { selectPictureState } from './picture.selectors'; 3 | 4 | describe('Picture Selectors', () => { 5 | it('should select the feature state', () => { 6 | const result: any = selectPictureState({ 7 | [fromPicture.pictureFeatureKey]: {}, 8 | }); 9 | 10 | expect(result).toEqual({}); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/app/database/playlists/playlist.actions.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fromPlaylist from './playlist.actions'; 2 | 3 | describe('loadPlaylists', () => { 4 | it('should return an action', () => { 5 | expect(fromPlaylist.loadPlaylists().type).toBe('[Playlist] Load Playlists'); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/app/database/playlists/playlist.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | import { Playlist, PlaylistId } from '@app/database/playlists/playlist.model'; 3 | import { IdUpdate } from '@app/core/utils'; 4 | 5 | export const removeAllPlaylists = createAction('playlists/remove-all'); 6 | 7 | export const loadPlaylists = createAction('[Playlist] Load Playlists'); 8 | 9 | export const loadPlaylistsSuccess = createAction( 10 | '[Playlist] Load Playlists Success', 11 | props<{ data: Playlist[] }>() 12 | ); 13 | 14 | export const loadPlaylistsFailure = createAction( 15 | '[Playlist] Load Playlists Failure', 16 | props<{ error: any }>() 17 | ); 18 | 19 | export const updatePlaylist = createAction( 20 | '[Playlist] Update Playlist', 21 | props<{ update: IdUpdate }>() 22 | ); 23 | 24 | export const addPlaylist = createAction( 25 | '[Playlist] Create Playlist', 26 | props<{ playlist: Playlist }>() 27 | ); 28 | 29 | export const deletePlaylist = createAction( 30 | '[Playlist] Delete Playlist', 31 | props<{ id: PlaylistId }>() 32 | ); 33 | -------------------------------------------------------------------------------- /src/app/database/playlists/playlist.effects.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { provideMockActions } from '@ngrx/effects/testing'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { PlaylistEffects } from './playlist.effects'; 6 | 7 | describe('PlaylistEffects', () => { 8 | let actions$: Observable; 9 | let effects: PlaylistEffects; 10 | 11 | beforeEach(() => { 12 | TestBed.configureTestingModule({ 13 | providers: [PlaylistEffects, provideMockActions(() => actions$)], 14 | }); 15 | 16 | effects = TestBed.inject(PlaylistEffects); 17 | }); 18 | 19 | it('should be created', () => { 20 | expect(effects).toBeTruthy(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/app/database/playlists/playlist.effects.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Actions, createEffect, ofType } from '@ngrx/effects'; 3 | import { catchError, concatMap, map } from 'rxjs/operators'; 4 | import { EMPTY, of } from 'rxjs'; 5 | import { 6 | addPlaylist, 7 | deletePlaylist, 8 | loadPlaylists, 9 | loadPlaylistsFailure, 10 | loadPlaylistsSuccess, 11 | updatePlaylist, 12 | } from './playlist.actions'; 13 | import { Playlist } from '@app/database/playlists/playlist.model'; 14 | import { DatabaseService } from '@app/database/database.service'; 15 | 16 | // noinspection JSUnusedGlobalSymbols 17 | @Injectable() 18 | export class PlaylistEffects { 19 | loadPlaylists$ = createEffect(() => 20 | this.actions$.pipe( 21 | ofType(loadPlaylists), 22 | concatMap(() => 23 | this.database.getAll$('playlists').pipe( 24 | map((data) => loadPlaylistsSuccess({ data })), 25 | catchError((error) => of(loadPlaylistsFailure({ error }))) 26 | ) 27 | ) 28 | ) 29 | ); 30 | 31 | addPlaylist$ = createEffect( 32 | () => 33 | this.actions$.pipe( 34 | ofType(addPlaylist), 35 | concatMap(({ playlist }) => 36 | this.database 37 | .add$('playlists', playlist) 38 | .pipe(catchError(() => EMPTY)) 39 | ) 40 | ), 41 | { dispatch: false } 42 | ); 43 | 44 | updatePlaylist$ = createEffect( 45 | () => 46 | this.actions$.pipe( 47 | ofType(updatePlaylist), 48 | concatMap(({ update }) => 49 | this.database 50 | .update$('playlists', update.changes, update.key) 51 | .pipe(catchError(() => EMPTY)) 52 | ) 53 | ), 54 | { dispatch: false } 55 | ); 56 | 57 | deletePlaylist$ = createEffect( 58 | () => 59 | this.actions$.pipe( 60 | ofType(deletePlaylist), 61 | concatMap(({ id }) => 62 | this.database.delete$('playlists', id).pipe(catchError(() => EMPTY)) 63 | ) 64 | ), 65 | { dispatch: false } 66 | ); 67 | 68 | constructor(private actions$: Actions, private database: DatabaseService) {} 69 | } 70 | -------------------------------------------------------------------------------- /src/app/database/playlists/playlist.facade.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { PlaylistFacade } from './playlist.facade'; 4 | 5 | describe('PlaylistService', () => { 6 | let service: PlaylistFacade; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(PlaylistFacade); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/database/playlists/playlist.model.ts: -------------------------------------------------------------------------------- 1 | import { Opaque } from 'type-fest'; 2 | import { hash } from '@app/core/utils'; 3 | import { SongId } from '@app/database/songs/song.model'; 4 | import { AlbumId } from '@app/database/albums/album.model'; 5 | import { ArtistId } from '@app/database/artists/artist.model'; 6 | 7 | export type PlaylistId = Opaque; 8 | 9 | export const getPlaylistId = (data: string): PlaylistId => 10 | hash(data) as PlaylistId; 11 | 12 | export interface Playlist { 13 | id: PlaylistId; 14 | title: string; 15 | description?: string; 16 | songs: SongId[]; 17 | albums: AlbumId[]; 18 | artists: ArtistId[]; 19 | createdOn: number; // TODO updatedOn 20 | likedOn?: number; 21 | } 22 | -------------------------------------------------------------------------------- /src/app/database/playlists/playlist.reducer.spec.ts: -------------------------------------------------------------------------------- 1 | import { playlistReducer, initialState } from './playlist.reducer'; 2 | 3 | describe('Playlist Reducer', () => { 4 | describe('an unknown action', () => { 5 | it('should return the previous state', () => { 6 | const action = {} as any; 7 | 8 | const result = playlistReducer(initialState, action); 9 | 10 | expect(result).toBe(initialState); 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/app/database/playlists/playlist.reducer.ts: -------------------------------------------------------------------------------- 1 | import { createReducer, on } from '@ngrx/store'; 2 | import { 3 | addPlaylist, 4 | deletePlaylist, 5 | loadPlaylists, 6 | loadPlaylistsFailure, 7 | loadPlaylistsSuccess, 8 | removeAllPlaylists, 9 | updatePlaylist, 10 | } from './playlist.actions'; 11 | import { Playlist } from '@app/database/playlists/playlist.model'; 12 | import { createIDBEntityAdapter, IDBEntityState } from '@creasource/ngrx-idb'; 13 | 14 | export const playlistFeatureKey = 'playlists'; 15 | 16 | const indexes = ['title', 'createdOn', 'likedOn'] as const; 17 | 18 | export type PlaylistIndex = typeof indexes[number]; 19 | 20 | export type PlaylistState = IDBEntityState; 21 | 22 | export const playlistAdapter = createIDBEntityAdapter({ 23 | keySelector: (model: Playlist) => model.id, 24 | indexes, 25 | }); 26 | 27 | export const initialState: PlaylistState = playlistAdapter.getInitialState(); 28 | 29 | export const playlistReducer = createReducer( 30 | initialState, 31 | 32 | on(removeAllPlaylists, (state) => playlistAdapter.removeAll(state)), 33 | on(loadPlaylists, (state) => state), 34 | on(loadPlaylistsSuccess, (state, action) => 35 | playlistAdapter.setAll(action.data, state) 36 | ), 37 | on(loadPlaylistsFailure, (state) => state), 38 | on(updatePlaylist, (state, action) => 39 | playlistAdapter.updateOne(action.update, state) 40 | ), 41 | on(addPlaylist, (state, action) => 42 | playlistAdapter.addOne(action.playlist, state) 43 | ), 44 | on(deletePlaylist, (state, action) => 45 | playlistAdapter.removeOne(action.id, state) 46 | ) 47 | ); 48 | -------------------------------------------------------------------------------- /src/app/database/playlists/playlist.selectors.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fromPlaylist from './playlist.reducer'; 2 | import { selectPlaylistState } from './playlist.selectors'; 3 | 4 | describe('Playlist Selectors', () => { 5 | it('should select the feature state', () => { 6 | const result: any = selectPlaylistState({ 7 | [fromPlaylist.playlistFeatureKey]: {}, 8 | }); 9 | 10 | expect(result).toEqual({}); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/app/database/playlists/playlist.selectors.ts: -------------------------------------------------------------------------------- 1 | import { createFeatureSelector, createSelector } from '@ngrx/store'; 2 | import { 3 | playlistAdapter, 4 | playlistFeatureKey, 5 | PlaylistIndex, 6 | PlaylistState, 7 | } from './playlist.reducer'; 8 | import { Playlist, PlaylistId } from '@app/database/playlists/playlist.model'; 9 | 10 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 11 | 12 | export const selectPlaylistState = 13 | createFeatureSelector(playlistFeatureKey); 14 | 15 | export const { 16 | selectIndexKeys: selectPlaylistIndexKeys, 17 | selectIndexEntities: selectPlaylistIndexEntities, 18 | selectIndexAll: selectPlaylistIndexAll, 19 | selectKeys: selectPlaylistKeys, 20 | selectEntities: selectPlaylistEntities, 21 | selectAll: selectPlaylistAll, 22 | selectTotal: selectPlaylistTotal, 23 | } = playlistAdapter.getSelectors(selectPlaylistState); 24 | 25 | export const selectPlaylistByKey = (key: PlaylistId) => 26 | createSelector(selectPlaylistEntities, (entities) => entities[key]); 27 | 28 | export const selectPlaylistsByIndexKey = ( 29 | key: PlaylistId, 30 | index: PlaylistIndex 31 | ) => 32 | createSelector( 33 | selectPlaylistEntities, 34 | selectPlaylistIndexEntities(index), 35 | (entities, indexKeys) => 36 | (indexKeys[key] || []).map((k) => entities[k as any] as Playlist) 37 | ); 38 | -------------------------------------------------------------------------------- /src/app/database/settings/settings.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction } from '@ngrx/store'; 2 | 3 | export const synchronizeLibrary = createAction('settings/synchronize'); 4 | export const clearDatabase = createAction('settings/clear-database'); 5 | -------------------------------------------------------------------------------- /src/app/database/settings/settings.facade.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { DatabaseService } from '@app/database/database.service'; 3 | import { Observable } from 'rxjs'; 4 | import { Settings } from '@app/database/settings/settings.model'; 5 | import { Store } from '@ngrx/store'; 6 | import { clearDatabase } from '@app/database/settings/settings.actions'; 7 | 8 | @Injectable() 9 | export class SettingsFacade { 10 | constructor(private store: Store, private database: DatabaseService) {} 11 | 12 | clearDatabase(): void { 13 | this.store.dispatch(clearDatabase()); 14 | } 15 | 16 | getRootDirectory(): Observable { 17 | return this.database.get$( 18 | 'settings', 19 | 'rootDirectory' 20 | ); 21 | } 22 | 23 | setRootDirectory( 24 | directory: Settings['rootDirectory'] 25 | ): Observable { 26 | return this.database.put$('settings', directory, 'rootDirectory'); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/app/database/settings/settings.model.ts: -------------------------------------------------------------------------------- 1 | import { DirectoryEntry } from '@app/database/entries/entry.model'; 2 | 3 | export type Settings = { 4 | rootDirectory: DirectoryEntry; 5 | }; 6 | -------------------------------------------------------------------------------- /src/app/database/songs/song.actions.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fromSong from './song.actions'; 2 | 3 | describe('loadSongs', () => { 4 | it('should return an action', () => { 5 | expect(fromSong.loadSongs().type).toBe('[Song] Load Songs'); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/app/database/songs/song.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | import { Song } from '@app/database/songs/song.model'; 3 | import { Update } from '@creasource/ngrx-idb'; 4 | 5 | export const removeAllSongs = createAction('songs/remove-all'); 6 | 7 | export const loadSongs = createAction('[Song] Load Songs'); 8 | 9 | export const loadSongsSuccess = createAction( 10 | '[Song] Load Songs Success', 11 | props<{ data: Song[] }>() 12 | ); 13 | 14 | export const loadSongsFailure = createAction( 15 | '[Song] Load Songs Failure', 16 | props<{ error: any }>() 17 | ); 18 | 19 | export const updateSong = createAction( 20 | '[Song] Update Song', 21 | props<{ update: Update }>() 22 | ); 23 | 24 | export const addSong = createAction('[Song] Add Song', props<{ song: Song }>()); 25 | 26 | export const upsertSong = createAction( 27 | '[Song] Upsert Song', 28 | props<{ song: Song }>() 29 | ); 30 | -------------------------------------------------------------------------------- /src/app/database/songs/song.effects.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { provideMockActions } from '@ngrx/effects/testing'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { SongEffects } from './song.effects'; 6 | 7 | describe('SongEffects', () => { 8 | let actions$: Observable; 9 | let effects: SongEffects; 10 | 11 | beforeEach(() => { 12 | TestBed.configureTestingModule({ 13 | providers: [SongEffects, provideMockActions(() => actions$)], 14 | }); 15 | 16 | effects = TestBed.inject(SongEffects); 17 | }); 18 | 19 | it('should be created', () => { 20 | expect(effects).toBeTruthy(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/app/database/songs/song.effects.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Actions, createEffect, ofType } from '@ngrx/effects'; 3 | import { catchError, concatMap, map } from 'rxjs/operators'; 4 | import { EMPTY, of } from 'rxjs'; 5 | import { 6 | loadSongs, 7 | loadSongsFailure, 8 | loadSongsSuccess, 9 | updateSong, 10 | } from './song.actions'; 11 | import { DatabaseService } from '@app/database/database.service'; 12 | import { Song } from '@app/database/songs/song.model'; 13 | 14 | // noinspection JSUnusedGlobalSymbols 15 | @Injectable() 16 | export class SongEffects { 17 | loadSongs$ = createEffect(() => 18 | this.actions$.pipe( 19 | ofType(loadSongs), 20 | concatMap(() => 21 | this.database.getAll$('songs').pipe( 22 | map((data) => loadSongsSuccess({ data })), 23 | catchError((error) => of(loadSongsFailure({ error }))) 24 | ) 25 | ) 26 | ) 27 | ); 28 | 29 | // addSong$ = createEffect( 30 | // () => 31 | // this.actions$.pipe( 32 | // ofType(addSong), 33 | // concatMap(({ song }) => 34 | // this.database.add$('songs', song).pipe( 35 | // catchError(() => EMPTY) // TODO 36 | // ) 37 | // ) 38 | // ), 39 | // { dispatch: false } 40 | // ); 41 | 42 | updateSong$ = createEffect( 43 | () => 44 | this.actions$.pipe( 45 | ofType(updateSong), 46 | concatMap(({ update: { changes, key } }) => 47 | this.database 48 | .update$('songs', changes, key) 49 | .pipe(catchError(() => EMPTY)) 50 | ) 51 | ), 52 | { dispatch: false } 53 | ); 54 | 55 | constructor(private actions$: Actions, private database: DatabaseService) {} 56 | } 57 | -------------------------------------------------------------------------------- /src/app/database/songs/song.facade.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { SongFacade } from './song.facade'; 4 | 5 | describe('SongService', () => { 6 | let service: SongFacade; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(SongFacade); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/database/songs/song.model.ts: -------------------------------------------------------------------------------- 1 | import { ICommonTagsResult, IFormat } from 'music-metadata/lib/type'; 2 | import { AlbumId } from '@app/database/albums/album.model'; 3 | import { PictureId } from '@app/database/pictures/picture.model'; 4 | import { ArtistId } from '@app/database/artists/artist.model'; 5 | import { Opaque } from 'type-fest'; 6 | import { EntryId } from '@app/database/entries/entry.model'; 7 | import { hash } from '@app/core/utils'; 8 | 9 | export type SongId = Opaque; 10 | 11 | export const getSongId = (path: string): SongId => hash(path) as SongId; 12 | 13 | export type Song = { 14 | id: SongId; 15 | entries: EntryId[]; 16 | title?: string; 17 | album: { title: string; id: AlbumId }; 18 | artists: { name: string; id: ArtistId }[]; 19 | duration?: number; 20 | lastModified?: number; 21 | likedOn?: number; 22 | pictureId?: PictureId; 23 | tags: Omit; 24 | updatedOn: number; 25 | format: Omit; 26 | }; 27 | -------------------------------------------------------------------------------- /src/app/database/songs/song.reducer.spec.ts: -------------------------------------------------------------------------------- 1 | import { songReducer, initialState } from './song.reducer'; 2 | 3 | describe('Song Reducer', () => { 4 | describe('an unknown action', () => { 5 | it('should return the previous state', () => { 6 | const action = {} as any; 7 | 8 | const result = songReducer(initialState, action); 9 | 10 | expect(result).toBe(initialState); 11 | }); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/app/database/songs/song.reducer.ts: -------------------------------------------------------------------------------- 1 | import { createReducer, on } from '@ngrx/store'; 2 | import { Song } from '@app/database/songs/song.model'; 3 | import { createIDBEntityAdapter, IDBEntityState } from '@creasource/ngrx-idb'; 4 | import { 5 | addSong, 6 | loadSongs, 7 | loadSongsFailure, 8 | loadSongsSuccess, 9 | removeAllSongs, 10 | updateSong, 11 | upsertSong, 12 | } from './song.actions'; 13 | 14 | export const songFeatureKey = 'songs'; 15 | 16 | const indexes = [ 17 | // { name: 'artists', multiEntry: true }, 18 | // { name: 'genre', multiEntry: true }, 19 | { name: 'title' }, 20 | { name: 'albumId', keySelector: (song: Song) => song.album.id }, 21 | { 22 | name: 'artists', 23 | keySelector: (song: Song) => song.artists.map((a) => a.id), 24 | multiEntry: true, 25 | }, 26 | { name: 'likedOn' }, 27 | // { name: 'lastModified' }, 28 | { name: 'updatedOn' }, 29 | ] as const; 30 | 31 | const indexNames = indexes.map((i) => i.name); 32 | 33 | export type SongIndex = typeof indexNames[number]; 34 | 35 | export const songAdapter = createIDBEntityAdapter({ 36 | keySelector: (model) => model.id, 37 | indexes, 38 | }); 39 | 40 | export type SongState = IDBEntityState; 41 | 42 | export const initialState: SongState = songAdapter.getInitialState(); 43 | 44 | export const songReducer = createReducer( 45 | initialState, 46 | 47 | on(removeAllSongs, (state) => songAdapter.removeAll(state)), 48 | on(loadSongs, (state) => state), 49 | on(loadSongsSuccess, (state, action) => 50 | songAdapter.setAll(action.data, state) 51 | ), 52 | on(loadSongsFailure, (state) => state), 53 | on(updateSong, (state, action) => 54 | songAdapter.updateOne(action.update, state) 55 | ), 56 | on(addSong, (state, action) => songAdapter.addOne(action.song, state)), 57 | on(upsertSong, (state, action) => songAdapter.upsertOne(action.song, state)) 58 | ); 59 | -------------------------------------------------------------------------------- /src/app/database/songs/song.selectors.spec.ts: -------------------------------------------------------------------------------- 1 | import * as fromSong from './song.reducer'; 2 | import { selectSongState } from './song.selectors'; 3 | 4 | describe('Song Selectors', () => { 5 | it('should select the feature state', () => { 6 | const result: any = selectSongState({ 7 | [fromSong.songFeatureKey]: {}, 8 | }); 9 | 10 | expect(result).toEqual({}); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /src/app/database/songs/song.selectors.ts: -------------------------------------------------------------------------------- 1 | import { createFeatureSelector, createSelector } from '@ngrx/store'; 2 | import { songAdapter, songFeatureKey, SongState } from './song.reducer'; 3 | import { Song } from '@app/database/songs/song.model'; 4 | import { AlbumId } from '@app/database/albums/album.model'; 5 | import { ArtistId } from '@app/database/artists/artist.model'; 6 | 7 | /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ 8 | 9 | export const selectSongState = createFeatureSelector(songFeatureKey); 10 | 11 | export const { 12 | selectIndexKeys: selectSongIndexKeys, 13 | selectIndexEntities: selectSongIndexEntities, 14 | selectIndexAll: selectSongIndexAll, 15 | selectKeys: selectSongKeys, 16 | selectEntities: selectSongEntities, 17 | selectAll: selectSongAll, 18 | selectTotal: selectSongTotal, 19 | } = songAdapter.getSelectors(selectSongState); 20 | 21 | export const selectSongByKey = (key: string) => 22 | createSelector(selectSongEntities, (entities) => entities[key]); 23 | 24 | export const selectSongByKeys = (keys: string[]) => 25 | createSelector(selectSongEntities, (entities) => 26 | keys.map((k) => entities[k]).filter((s): s is Song => !!s) 27 | ); 28 | 29 | export const selectSongByAlbumKey = (key: AlbumId) => 30 | createSelector( 31 | selectSongEntities, 32 | selectSongIndexEntities('albumId'), 33 | (entities, index) => index[key]?.map((k) => entities[k as any] as Song) 34 | ); 35 | 36 | export const selectSongByArtistKey = (key: ArtistId) => 37 | createSelector( 38 | selectSongEntities, 39 | selectSongIndexEntities('artists'), 40 | (entities, index) => index[key]?.map((k) => entities[k as any] as Song) 41 | ); 42 | -------------------------------------------------------------------------------- /src/app/helper/helper.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CoreModule } from '@app/core/core.module'; 3 | import { EffectsModule } from '@ngrx/effects'; 4 | import { HelperFacade } from '@app/helper/helper.facade'; 5 | import { HelperEffects } from '@app/helper/helper.effects'; 6 | 7 | @NgModule({ 8 | declarations: [], 9 | imports: [CoreModule, EffectsModule.forFeature([HelperEffects])], 10 | providers: [HelperFacade], 11 | exports: [], 12 | }) 13 | export class HelperModule {} 14 | -------------------------------------------------------------------------------- /src/app/home/home.component.ts: -------------------------------------------------------------------------------- 1 | // import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | // import { EMPTY, Observable } from 'rxjs'; 3 | // import { Album } from '@app/database/album.model'; 4 | // import { hash } from '@app/core/utils/hash.util'; 5 | // 6 | // @Component({ 7 | // selector: 'app-home', 8 | // template: ` 9 | // 10 | // Albums 11 | // 12 | //
13 | // 14 | //
15 | //
16 | // Artists 17 | // 18 | //
23 | // 28 | //
29 | //
30 | //
31 | // `, 32 | // styles: [ 33 | // ` 34 | // :host { 35 | // display: block; 36 | // padding: 32px 0; 37 | // } 38 | // .album, 39 | // .artist { 40 | // margin: 0 24px 0 0; 41 | // width: 226px; 42 | // } 43 | // .album:last-child, 44 | // .artist:last-child { 45 | // margin-right: 0; 46 | // } 47 | // app-title { 48 | // margin-bottom: 40px; 49 | // } 50 | // app-h-list { 51 | // margin: 0 0; 52 | // min-height: 320px; 53 | // } 54 | // `, 55 | // ], 56 | // changeDetection: ChangeDetectionStrategy.OnPush, 57 | // }) 58 | // export class HomeComponent { 59 | // albums$: Observable = EMPTY; 60 | // 61 | // artists$ = EMPTY; 62 | // 63 | // getHash(albumArtist: string): string { 64 | // return hash(albumArtist); 65 | // } 66 | // } 67 | -------------------------------------------------------------------------------- /src/app/library/library-albums.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { LibraryAlbumsComponent } from './library-albums.component'; 4 | 5 | describe('LibraryAlbumsComponent', () => { 6 | let component: LibraryAlbumsComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [LibraryAlbumsComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(LibraryAlbumsComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/library/library-artists.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { LibraryArtistsComponent } from './library-artists.component'; 4 | 5 | describe('LibraryArtistsComponent', () => { 6 | let component: LibraryArtistsComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [LibraryArtistsComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(LibraryArtistsComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/library/library-content.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { LibraryContentComponent } from './library-content.component'; 4 | 5 | describe('LibraryContentComponent', () => { 6 | let component: LibraryContentComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [LibraryContentComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(LibraryContentComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/library/library-likes.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { LibraryLikesComponent } from './library-likes.component'; 4 | 5 | describe('LibraryLikesComponent', () => { 6 | let component: LibraryLikesComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [LibraryLikesComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(LibraryLikesComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/library/library-playlists.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { LibraryPlaylistsComponent } from './library-playlists.component'; 4 | 5 | describe('LibraryPlaylistsComponent', () => { 6 | let component: LibraryPlaylistsComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [LibraryPlaylistsComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(LibraryPlaylistsComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/library/library-songs.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { LibrarySongsComponent } from './library-songs.component'; 4 | 5 | describe('LibrarySongsComponent', () => { 6 | let component: LibrarySongsComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [LibrarySongsComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(LibrarySongsComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/library/library.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { LibraryComponent } from './library.component'; 4 | 5 | describe('LibraryComponent', () => { 6 | let component: LibraryComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [LibraryComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(LibraryComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/library/library.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { LibraryComponent } from '@app/library/library.component'; 3 | import { LibraryAlbumsComponent } from '@app/library/library-albums.component'; 4 | import { LibraryArtistsComponent } from '@app/library/library-artists.component'; 5 | import { LibraryContentComponent } from '@app/library/library-content.component'; 6 | import { LibraryPlaylistsComponent } from '@app/library/library-playlists.component'; 7 | import { LibrarySongsComponent } from '@app/library/library-songs.component'; 8 | import { CoreModule } from '@app/core/core.module'; 9 | import { RouterModule, Routes } from '@angular/router'; 10 | import { LibraryLikesComponent } from '@app/library/library-likes.component'; 11 | import { RouterComponent } from '@app/core/components/router.component'; 12 | 13 | const routes: Routes = [ 14 | { 15 | path: '', 16 | component: LibraryComponent, 17 | data: { animation: 'default' }, 18 | children: [ 19 | { path: '', redirectTo: 'albums', pathMatch: 'full' }, 20 | { path: 'playlists', component: LibraryPlaylistsComponent }, 21 | { path: 'albums', component: LibraryAlbumsComponent }, 22 | { path: 'artists', component: LibraryArtistsComponent }, 23 | { path: 'songs', component: LibrarySongsComponent }, 24 | { 25 | path: 'likes', 26 | component: RouterComponent, 27 | children: [ 28 | { path: '', redirectTo: 'all', pathMatch: 'full' }, 29 | { path: ':type', component: LibraryLikesComponent }, 30 | ], 31 | }, 32 | ], 33 | }, 34 | ]; 35 | 36 | @NgModule({ 37 | declarations: [ 38 | LibraryComponent, 39 | LibraryAlbumsComponent, 40 | LibraryArtistsComponent, 41 | LibraryContentComponent, 42 | LibraryPlaylistsComponent, 43 | LibrarySongsComponent, 44 | LibraryLikesComponent, 45 | ], 46 | imports: [CoreModule, RouterModule.forChild(routes)], 47 | }) 48 | export class LibraryModule {} 49 | -------------------------------------------------------------------------------- /src/app/library/store/index.ts: -------------------------------------------------------------------------------- 1 | // export * from './library.actions'; 2 | export * from './library.reducer'; 3 | export * from './library.selectors'; 4 | export * from './library.state'; 5 | export * from './library.effects'; 6 | -------------------------------------------------------------------------------- /src/app/library/store/library.actions.ts: -------------------------------------------------------------------------------- 1 | // export const loadEntries = createAction('[Library] Load entries'); 2 | // export const loadSongs = createAction('[Library] Load songs'); 3 | // export const loadPictures = createAction('[Library] Load pictures'); 4 | // 5 | // export const setEntries = createAction( 6 | // '[Library] Set entries', 7 | // props<{ entries: Entry[] }>() 8 | // ); 9 | // 10 | // export const setSongs = createAction( 11 | // '[Library] Set songs', 12 | // props<{ songs: Song[] }>() 13 | // ); 14 | // 15 | // export const setPictures = createAction( 16 | // '[Library] Set pictures', 17 | // props<{ pictures: Picture[] }>() 18 | // ); 19 | // 20 | // export const addEntry = createAction( 21 | // '[Library] Add entry', 22 | // props<{ entry: Entry }>() 23 | // ); 24 | // 25 | // export const addSong = createAction( 26 | // '[Library] Add song', 27 | // props<{ song: Song }>() 28 | // ); 29 | // 30 | // export const addPicture = createAction( 31 | // '[Library] Add picture', 32 | // props<{ picture: Picture }>() 33 | // ); 34 | 35 | // 36 | // export const addAlbum = createAction( 37 | // '[Library] Add album', 38 | // props<{ album: Album }>() 39 | // ); 40 | // 41 | // export const addArtist = createAction( 42 | // '[Library] Add artist', 43 | // props<{ artist: Artist }>() 44 | // ); 45 | // 46 | // export const addCover = createAction( 47 | // '[Library] Add cover', 48 | // props<{ cover: Cover }>() 49 | // ); 50 | 51 | /*export const newLibraryEntry = createAction( 52 | '[Library] New song', 53 | props<{song: Song, album: Album, artist: Artist}>() 54 | ); 55 | 56 | export const newLibraryEntries = createAction( 57 | '[Library] New entries', 58 | props<{songs: Song[], albums: Album[], artists: Artist[]}>() 59 | );*/ 60 | 61 | // export const loadLibrary = createAction('[Library] Load'); 62 | // 63 | // export const loadLibrarySuccess = createAction( 64 | // '[Library] Load success', 65 | // props<{ 66 | // folders: DirectoryEntry[]; 67 | // songs: Song[]; 68 | // albums: Album[]; 69 | // artists: Artist[]; 70 | // covers: Cover[]; 71 | // }>() 72 | // ); 73 | 74 | // export const updateArtist = createAction( 75 | // '[Library] Update artist', 76 | // props<{ update: Update }>() 77 | // ); 78 | -------------------------------------------------------------------------------- /src/app/library/store/library.state.ts: -------------------------------------------------------------------------------- 1 | // export type ArtistState = EntityState; 2 | // export type AlbumState = EntityState; 3 | // export type SongState = EntityState; 4 | // export type CoverState = EntityState; 5 | // export type EntryState = EntityState; 6 | // export type SongState = EntityState; 7 | // export type PictureState = EntityState; 8 | 9 | // export const artistAdapter: EntityAdapter = createEntityAdapter( 10 | // { 11 | // selectId: (model) => model.id, 12 | // } 13 | // ); 14 | // export const albumAdapter: EntityAdapter = createEntityAdapter({ 15 | // selectId: (model) => model.id, 16 | // }); 17 | // export const songAdapter: EntityAdapter = createEntityAdapter({ 18 | // selectId: (model) => model.id, 19 | // }); 20 | // export const coverAdapter: EntityAdapter = createEntityAdapter({ 21 | // selectId: (model) => model.id, 22 | // }); 23 | // export const entryAdapter: EntityAdapter = createEntityAdapter({ 24 | // selectId: (model) => model.path, 25 | // }); 26 | // 27 | // export const songAdapter: EntityAdapter = createEntityAdapter({ 28 | // selectId: (model) => model.id, 29 | // }); 30 | // 31 | // export const pictureAdapter: EntityAdapter = createEntityAdapter< 32 | // Picture 33 | // >({ 34 | // selectId: (model) => model.key || 0, 35 | // }); 36 | 37 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 38 | export interface LibraryState { 39 | // entries: EntryState; 40 | // songs: SongState; 41 | // pictures: PictureState; 42 | // artists: ArtistState; 43 | // albums: AlbumState; 44 | // songs: SongState; 45 | // covers: CoverState; 46 | } 47 | 48 | export const initialState: LibraryState = { 49 | // entries: entryAdapter.getInitialState(), 50 | // songs: songAdapter.getInitialState(), 51 | // pictures: pictureAdapter.getInitialState(), 52 | // artists: artistAdapter.getInitialState(), 53 | // albums: albumAdapter.getInitialState(), 54 | // songs: songAdapter.getInitialState(), 55 | // covers: coverAdapter.getInitialState(), 56 | }; 57 | -------------------------------------------------------------------------------- /src/app/main/main.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | import { MainComponent } from './main.component'; 4 | 5 | describe('AppComponent', () => { 6 | beforeEach(async () => { 7 | await TestBed.configureTestingModule({ 8 | imports: [RouterTestingModule], 9 | declarations: [MainComponent], 10 | }).compileComponents(); 11 | }); 12 | 13 | it('should create the app', () => { 14 | const fixture = TestBed.createComponent(MainComponent); 15 | const app = fixture.componentInstance; 16 | expect(app).toBeTruthy(); 17 | }); 18 | 19 | // it(`should have as title 'musicsource'`, () => { 20 | // const fixture = TestBed.createComponent(AppComponent); 21 | // const app = fixture.componentInstance; 22 | // expect(app.title).toEqual('musicsource'); 23 | // }); 24 | // 25 | // it('should render title', () => { 26 | // const fixture = TestBed.createComponent(AppComponent); 27 | // fixture.detectChanges(); 28 | // const compiled = fixture.nativeElement; 29 | // expect(compiled.querySelector('.content span').textContent).toContain('musicsource app is running!'); 30 | // }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/app/main/main.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { MainGuard } from './main.guard'; 4 | 5 | describe('MainGuard', () => { 6 | let guard: MainGuard; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | guard = TestBed.inject(MainGuard); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(guard).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/main/main.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { 3 | CanActivate, 4 | CanActivateChild, 5 | Router, 6 | UrlTree, 7 | } from '@angular/router'; 8 | 9 | @Injectable() 10 | export class MainGuard implements CanActivate, CanActivateChild { 11 | constructor(private router: Router) {} 12 | 13 | canActivate(): boolean | UrlTree { 14 | if (localStorage.getItem('scanned') === '1') { 15 | return true; 16 | } 17 | return this.router.createUrlTree(['/welcome']); 18 | } 19 | 20 | canActivateChild(): boolean | UrlTree { 21 | return this.canActivate(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/app/main/navigation.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { NavigationEnd, NavigationStart, Router } from '@angular/router'; 3 | import { catchError, filter, map, tap } from 'rxjs/operators'; 4 | import { concatTap, tapError } from '@app/core/utils'; 5 | import { from } from 'rxjs'; 6 | 7 | @Injectable() 8 | export class NavigationService { 9 | page?: string; 10 | 11 | regexp = /\/library\/(?.+)/; 12 | 13 | constructor(private router: Router) {} 14 | 15 | register() { 16 | this.router.events 17 | .pipe( 18 | filter( 19 | (event): event is NavigationStart => event instanceof NavigationStart 20 | ), 21 | filter((event) => event.url === '/library'), 22 | concatTap(() => 23 | from( 24 | this.router.navigateByUrl(`/library/${this.page || 'playlists'}`) 25 | ) 26 | ), 27 | tapError((err) => console.error(err)), 28 | catchError(() => this.router.navigate(['/library', 'playlists'])) 29 | ) 30 | .subscribe(); 31 | 32 | this.router.events 33 | .pipe( 34 | filter( 35 | (event): event is NavigationEnd => event instanceof NavigationEnd 36 | ), 37 | filter((event) => this.regexp.test(event.url)), 38 | map((event) => this.regexp.exec(event.url)?.groups?.page), 39 | tap((page) => (this.page = page?.replace(/#.+$/, ''))) 40 | ) 41 | .subscribe(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/app/main/scroller.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { ScrollerService } from './scroller.service'; 4 | 5 | describe('ScrollerService', () => { 6 | let service: ScrollerService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(ScrollerService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/main/scroller.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { 3 | asapScheduler, 4 | distinctUntilChanged, 5 | fromEvent, 6 | Observable, 7 | ReplaySubject, 8 | share, 9 | startWith, 10 | } from 'rxjs'; 11 | import { map, throttleTime } from 'rxjs/operators'; 12 | 13 | @Injectable() 14 | export class ScrollerService { 15 | scroll$: Observable; 16 | 17 | constructor() { 18 | this.scroll$ = fromEvent(window, 'scroll').pipe( 19 | throttleTime(10, asapScheduler, { 20 | leading: true, 21 | trailing: true, 22 | }), 23 | // debounceTime(25, animationFrameScheduler), 24 | map((event: any) => event.target.scrollingElement.scrollTop), 25 | distinctUntilChanged(), 26 | startWith(0), 27 | share({ 28 | connector: () => new ReplaySubject(1), 29 | resetOnRefCountZero: true, 30 | }) 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/app/player/analyzer.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { AudioService } from '@app/player/audio.service'; 3 | import AudioMotionAnalyzer from 'audiomotion-analyzer'; 4 | 5 | @Injectable() 6 | export class AnalyzerService { 7 | private container?: HTMLElement; 8 | private audioMotion: AudioMotionAnalyzer = this.audio.audioMotion; 9 | 10 | constructor(private audio: AudioService) { 11 | this.audioMotion.canvas.style.objectFit = 'contain'; 12 | this.audioMotion.canvas.style.height = '100%'; 13 | this.audioMotion.canvas.style.width = '100%'; 14 | this.audioMotion.setOptions({ useCanvas: true }); 15 | window.addEventListener('resize', () => { 16 | this.audioMotion.setCanvasSize( 17 | this.container?.offsetWidth || 100, 18 | this.container?.offsetHeight || 100 19 | ); 20 | }); 21 | } 22 | 23 | setContainer(container?: HTMLElement): void { 24 | if (!container) { 25 | return; 26 | } 27 | this.container = container; 28 | container.appendChild(this.audioMotion.canvas); 29 | setTimeout(() => 30 | this.audioMotion.setCanvasSize( 31 | container.offsetWidth, 32 | container.offsetHeight 33 | ) 34 | ); 35 | this.audioMotion.toggleAnalyzer(true); 36 | } 37 | 38 | setCoverColors(cover?: string): void { 39 | if (!cover) { 40 | this.audioMotion.registerGradient('gradient', { 41 | bgColor: 'black', 42 | colorStops: ['white', 'white'], 43 | }); 44 | this.audioMotion.setOptions({ gradient: 'gradient' }); 45 | return; 46 | } 47 | 48 | import('node-vibrant') 49 | .then((vibrant) => vibrant.default.from(cover).getPalette()) 50 | .then((palette) => [ 51 | palette.DarkMuted?.hex || 'white', 52 | palette.DarkVibrant?.hex || 'white', 53 | palette.Vibrant?.hex || 'white', 54 | palette.LightVibrant?.hex || 'white', 55 | ]) 56 | .then(([dm, dv, v, lv]) => { 57 | this.audioMotion.registerGradient('gradient', { 58 | bgColor: 'black', 59 | colorStops: [lv, v, dv, dm], 60 | }); 61 | this.audioMotion.setOptions({ gradient: 'gradient' }); 62 | }); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/app/player/media-session.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { MediaSessionService } from './media-session.service'; 4 | 5 | describe('MediaSessionService', () => { 6 | let service: MediaSessionService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(MediaSessionService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/player/media-session.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Song } from '@app/database/songs/song.model'; 3 | import { PlayerFacade } from '@app/player/store/player.facade'; 4 | import { first, map, tap } from 'rxjs/operators'; 5 | import { PictureFacade } from '@app/database/pictures/picture.facade'; 6 | import { combineLatest, Observable, of } from 'rxjs'; 7 | 8 | @Injectable() 9 | export class MediaSessionService { 10 | constructor(private player: PlayerFacade, private pictures: PictureFacade) {} 11 | 12 | setMetadata(song: Song): Observable { 13 | if (!('mediaSession' in navigator)) { 14 | return of(void 0); 15 | } 16 | const mediaSession = navigator.mediaSession; 17 | 18 | return combineLatest([ 19 | this.pictures.getSongCover(song, 264), 20 | this.player.hasPrevSong$(), 21 | this.player.hasNextSong$(), 22 | ]).pipe( 23 | first(), 24 | tap(([cover, hasPrev, hasNext]) => { 25 | mediaSession.metadata = new MediaMetadata({ 26 | title: song.title, 27 | artist: song.artists.map((a) => a.name).join(', '), 28 | album: song.album.title, 29 | artwork: cover ? [{ src: cover }] : [], 30 | }); 31 | if (hasPrev) { 32 | mediaSession.setActionHandler('previoustrack', () => { 33 | this.player.setPlaying(); 34 | this.player.setPrevIndex(); 35 | }); 36 | } else { 37 | mediaSession.setActionHandler('previoustrack', null); 38 | } 39 | if (hasNext) { 40 | mediaSession.setActionHandler('nexttrack', () => { 41 | this.player.setPlaying(); 42 | this.player.setNextIndex(); 43 | }); 44 | } else { 45 | mediaSession.setActionHandler('nexttrack', null); 46 | } 47 | }), 48 | map(() => void 0) 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/app/player/play.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PlayComponent } from './play.component'; 4 | 5 | describe('PlayComponent', () => { 6 | let component: PlayComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [PlayComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(PlayComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/player/player.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PlayerComponent } from './player.component'; 4 | 5 | describe('PlayerComponent', () => { 6 | let component: PlayerComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [PlayerComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(PlayerComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/player/player.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CoreModule } from '@app/core/core.module'; 3 | import { PlayComponent } from '@app/player/play.component'; 4 | import { PlayerComponent } from '@app/player/player.component'; 5 | import { QueueListComponent } from '@app/player/queue-list.component'; 6 | import { QueueItemComponent } from '@app/player/queue-item.component'; 7 | import { RouterModule, Routes } from '@angular/router'; 8 | import { EffectsModule } from '@ngrx/effects'; 9 | import { PlayerEffects } from '@app/player/store/player.effects'; 10 | import { StoreModule } from '@ngrx/store'; 11 | import { playerReducer } from '@app/player/store/player.reducer'; 12 | import { PlayerFacade } from '@app/player/store/player.facade'; 13 | import { MediaSessionService } from '@app/player/media-session.service'; 14 | import { AnalyzerService } from '@app/player/analyzer.service'; 15 | 16 | const routes: Routes = [ 17 | { 18 | path: '', 19 | component: PlayComponent, 20 | data: { animation: 'PlayPage' }, 21 | }, 22 | ]; 23 | 24 | @NgModule({ 25 | declarations: [ 26 | PlayComponent, 27 | PlayerComponent, 28 | QueueListComponent, 29 | QueueItemComponent, 30 | ], 31 | imports: [ 32 | CoreModule, 33 | RouterModule.forChild(routes), 34 | StoreModule.forFeature('player', playerReducer), 35 | EffectsModule.forFeature([PlayerEffects]), 36 | ], 37 | providers: [PlayerFacade, MediaSessionService, AnalyzerService], 38 | exports: [PlayerComponent], 39 | }) 40 | export class PlayerModule {} 41 | -------------------------------------------------------------------------------- /src/app/player/queue-item.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { QueueItemComponent } from './queue-item.component'; 4 | 5 | describe('PlaylistListItemComponent', () => { 6 | let component: QueueItemComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [QueueItemComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(QueueItemComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/player/queue-list.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { QueueListComponent } from './queue-list.component'; 4 | 5 | describe('PlaylistListComponent', () => { 6 | let component: QueueListComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [QueueListComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(QueueListComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/player/queue-list.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; 2 | import { Song, SongId } from '@app/database/songs/song.model'; 3 | 4 | @Component({ 5 | selector: 'app-queue-list', 6 | template: ` 7 | 17 | `, 18 | styles: [ 19 | ` 20 | :host { 21 | display: flex; 22 | flex-direction: column; 23 | } 24 | .item { 25 | flex: 0 0 56px; 26 | background-color: black; 27 | } 28 | .item.selected { 29 | background-color: #1a1a1a; 30 | } 31 | .item:last-of-type { 32 | border: none; 33 | } 34 | :host::-webkit-scrollbar-track { 35 | background-color: #000; 36 | border-left: none; 37 | } 38 | :host::-webkit-scrollbar-thumb { 39 | background-color: black; 40 | } 41 | :host:hover::-webkit-scrollbar-thumb { 42 | background-color: rgba(255, 255, 255, 0.24); 43 | } 44 | .cdk-drag-preview { 45 | background-color: #1a1a1a; 46 | border-bottom: 1px solid rgba(255, 255, 255, 0.1) !important; 47 | } 48 | .cdk-drag-placeholder { 49 | opacity: 0; 50 | } 51 | .cdk-drag-animating { 52 | transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); 53 | } 54 | :host.cdk-drop-list-dragging .item:not(.cdk-drag-placeholder) { 55 | transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); 56 | } 57 | `, 58 | ], 59 | changeDetection: ChangeDetectionStrategy.OnPush, 60 | }) 61 | export class QueueListComponent { 62 | @Input() songs!: Song[]; 63 | @Input() currentSong!: Song | null; 64 | @Input() currentIndex!: number | null; 65 | 66 | trackBy(index: number, song: Song): string { 67 | return song.id; 68 | } 69 | 70 | getIds(songs: Song[]): SongId[] { 71 | return songs.map((s) => s.id); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/app/player/store/player.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | import { SongId } from '@app/database/songs/song.model'; 3 | 4 | export const show = createAction('player/show'); 5 | export const hide = createAction('player/hide'); 6 | 7 | export const setQueue = createAction( 8 | 'player/set-queue', 9 | props<{ queue: SongId[]; currentIndex: number }>() 10 | ); 11 | 12 | export const addToQueue = createAction( 13 | 'player/add-to-queue', 14 | props<{ queue: SongId[]; next: boolean }>() 15 | ); 16 | 17 | export const setCurrentIndex = createAction( 18 | 'player/set-current-index', 19 | props<{ index: number }>() 20 | ); 21 | 22 | export const setNextIndex = createAction('player/set-next-index'); 23 | export const setPrevIndex = createAction('player/set-prev-index'); 24 | 25 | export const resume = createAction('player/resume'); 26 | export const pause = createAction('player/pause'); 27 | export const reset = createAction('player/reset'); 28 | 29 | export const setPlaying = createAction( 30 | 'player/set-playing', 31 | props<{ playing: boolean }>() 32 | ); 33 | 34 | export const setLoading = createAction( 35 | 'player/set-loading', 36 | props<{ loading: boolean }>() 37 | ); 38 | 39 | export const setDuration = createAction( 40 | 'player/set-duration', 41 | props<{ duration: number }>() 42 | ); 43 | 44 | export const shuffle = createAction('player/shuffle'); 45 | 46 | export const toggleMute = createAction('player/toggleMute'); 47 | 48 | export const setVolume = createAction( 49 | 'player/volume', 50 | props<{ volume: number }>() 51 | ); 52 | 53 | export const setRepeat = createAction( 54 | 'player/repeat', 55 | props<{ value: 'all' | 'once' | 'none' }>() 56 | ); 57 | 58 | export const toggleAnalyzer = createAction('player/analyzer'); 59 | -------------------------------------------------------------------------------- /src/app/player/store/player.reducer.ts: -------------------------------------------------------------------------------- 1 | import { ActionReducer, createReducer, on } from '@ngrx/store'; 2 | import { initialState, PlayerState } from '@app/player/store/player.state'; 3 | import { 4 | addToQueue, 5 | hide, 6 | setCurrentIndex, 7 | setDuration, 8 | setLoading, 9 | setNextIndex, 10 | setPlaying, 11 | setQueue, 12 | setPrevIndex, 13 | show, 14 | toggleMute, 15 | setVolume, 16 | setRepeat, 17 | toggleAnalyzer, 18 | } from '@app/player/store/player.actions'; 19 | 20 | export const playerReducer: ActionReducer = createReducer( 21 | initialState, 22 | on(show, (state) => ({ 23 | ...state, 24 | show: true, 25 | })), 26 | on(hide, (state) => ({ 27 | ...state, 28 | show: false, 29 | })), 30 | on(setQueue, (state, { queue, currentIndex }) => ({ 31 | ...state, 32 | queue, 33 | currentIndex, 34 | })), 35 | on(addToQueue, (state, { queue, next }) => { 36 | const index = state.currentIndex; 37 | const newQueue = [...state.queue]; 38 | if (next) { 39 | newQueue.splice(index + 1, 0, ...queue); // Mutation 40 | } else { 41 | newQueue.push(...queue); 42 | } 43 | return { 44 | ...state, 45 | queue: newQueue, 46 | }; 47 | }), 48 | on(setCurrentIndex, (state, { index }) => ({ 49 | ...state, 50 | currentIndex: Math.min(state.queue.length - 1, index), 51 | })), 52 | on(setNextIndex, (state) => ({ 53 | ...state, 54 | currentIndex: Math.min(state.queue.length - 1, state.currentIndex + 1), 55 | })), 56 | on(setPrevIndex, (state) => ({ 57 | ...state, 58 | currentIndex: Math.max(0, state.currentIndex - 1), 59 | })), 60 | on(setPlaying, (state, { playing }) => ({ ...state, playing })), 61 | on(setLoading, (state, { loading }) => ({ ...state, loading })), 62 | on(setDuration, (state, { duration }) => ({ ...state, duration })), 63 | on(toggleMute, (state) => ({ ...state, muted: !state.muted })), 64 | on(setVolume, (state, { volume }) => ({ ...state, volume })), 65 | on(setRepeat, (state, { value }) => ({ ...state, repeat: value })), 66 | on(toggleAnalyzer, (state) => ({ ...state, analyzer: !state.analyzer })) 67 | ); 68 | -------------------------------------------------------------------------------- /src/app/player/store/player.selectors.ts: -------------------------------------------------------------------------------- 1 | import { createFeatureSelector, createSelector } from '@ngrx/store'; 2 | import { PlayerState } from '@app/player/store/player.state'; 3 | 4 | export const selectPlayerState = createFeatureSelector('player'); 5 | 6 | export const selectShow = createSelector( 7 | selectPlayerState, 8 | (state) => state.show 9 | ); 10 | 11 | export const selectQueue = createSelector( 12 | selectPlayerState, 13 | (state) => state.queue 14 | ); 15 | 16 | export const selectCurrentIndex = createSelector( 17 | selectPlayerState, 18 | (state) => state.currentIndex 19 | ); 20 | 21 | export const selectCurrentSong = createSelector( 22 | selectQueue, 23 | selectCurrentIndex, 24 | (playlist, index) => playlist[index] 25 | ); 26 | 27 | export const selectHasNextSong = createSelector( 28 | selectQueue, 29 | selectCurrentIndex, 30 | (playlist, index) => playlist[index + 1] !== undefined 31 | ); 32 | 33 | export const selectHasPrevSong = createSelector( 34 | selectQueue, 35 | selectCurrentIndex, 36 | (playlist, index) => playlist[index - 1] !== undefined 37 | ); 38 | 39 | export const selectPlaying = createSelector( 40 | selectPlayerState, 41 | (state) => state.playing 42 | ); 43 | 44 | export const selectLoading = createSelector( 45 | selectPlayerState, 46 | (state) => state.loading 47 | ); 48 | 49 | export const selectDuration = createSelector( 50 | selectPlayerState, 51 | (state) => state.duration || 0 52 | ); 53 | 54 | export const selectMuted = createSelector( 55 | selectPlayerState, 56 | (state) => state.muted 57 | ); 58 | 59 | export const selectVolume = createSelector( 60 | selectPlayerState, 61 | (state) => state.volume 62 | ); 63 | 64 | export const selectRepeat = createSelector( 65 | selectPlayerState, 66 | (state) => state.repeat 67 | ); 68 | 69 | export const selectAnalyzer = createSelector( 70 | selectPlayerState, 71 | (state) => state.analyzer 72 | ); 73 | -------------------------------------------------------------------------------- /src/app/player/store/player.state.ts: -------------------------------------------------------------------------------- 1 | import { SongId } from '@app/database/songs/song.model'; 2 | 3 | export interface PlayerState { 4 | show: boolean; 5 | queue: SongId[]; 6 | currentIndex: number; 7 | playing: boolean; 8 | loading: boolean; 9 | duration?: number; 10 | muted: boolean; 11 | volume: number; 12 | repeat: 'all' | 'once' | 'none'; 13 | analyzer: boolean; 14 | } 15 | 16 | export const initialState: PlayerState = { 17 | show: false, 18 | queue: [], 19 | currentIndex: 0, 20 | playing: false, 21 | loading: false, 22 | duration: undefined, 23 | muted: false, 24 | volume: 1, 25 | repeat: 'none', 26 | analyzer: false, 27 | }; 28 | -------------------------------------------------------------------------------- /src/app/playlist/page-playlist-likes.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PagePlaylistLikesComponent } from './page-playlist-likes.component'; 4 | 5 | describe('PagePlaylistLikesComponent', () => { 6 | let component: PagePlaylistLikesComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [PagePlaylistLikesComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(PagePlaylistLikesComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/playlist/page-playlist-resolver.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { PagePlaylistResolver } from './page-playlist-resolver.service'; 4 | 5 | describe('PlaylistPageResolverService', () => { 6 | let service: PagePlaylistResolver; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(PagePlaylistResolver); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/playlist/page-playlist.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { PagePlaylistComponent } from './page-playlist.component'; 4 | 5 | describe('PlaylistPageComponent', () => { 6 | let component: PagePlaylistComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [PagePlaylistComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(PagePlaylistComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/playlist/playlist.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { PagePlaylistComponent } from '@app/playlist/page-playlist.component'; 3 | import { PagePlaylistLikesComponent } from '@app/playlist/page-playlist-likes.component'; 4 | import { CoreModule } from '@app/core/core.module'; 5 | import { RouterModule, Routes } from '@angular/router'; 6 | import { PagePlaylistResolver } from '@app/playlist/page-playlist-resolver.service'; 7 | 8 | const routes: Routes = [ 9 | { 10 | path: 'likes', 11 | component: PagePlaylistLikesComponent, 12 | data: { animation: 'default' }, 13 | }, 14 | { 15 | path: ':id', 16 | component: PagePlaylistComponent, 17 | data: { animation: 'default' }, 18 | resolve: { info: PagePlaylistResolver }, 19 | }, 20 | ]; 21 | 22 | @NgModule({ 23 | declarations: [PagePlaylistComponent, PagePlaylistLikesComponent], 24 | imports: [CoreModule, RouterModule.forChild(routes)], 25 | providers: [PagePlaylistResolver], 26 | }) 27 | export class PlaylistModule {} 28 | -------------------------------------------------------------------------------- /src/app/root.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { RootComponent } from './root.component'; 4 | 5 | describe('RootComponent', () => { 6 | let component: RootComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [RootComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(RootComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/scanner/file.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { defer, from, Observable } from 'rxjs'; 3 | import { map } from 'rxjs/operators'; 4 | import { tapError } from '@app/core/utils/tap-error.util'; 5 | import { 6 | DirectoryEntry, 7 | Entry, 8 | entryFromHandle, 9 | } from '@app/database/entries/entry.model'; 10 | 11 | @Injectable() 12 | export class FileService { 13 | openDirectory(): Observable { 14 | return defer(() => showDirectoryPicker()).pipe( 15 | tapError((err) => console.log(err)), 16 | map((handle) => entryFromHandle(handle) as DirectoryEntry) 17 | // If user has aborted then complete 18 | // catchError(e => e.code === 20 ? EMPTY : throwError(e)) 19 | ); 20 | } 21 | 22 | iterate(dir: DirectoryEntry): Observable { 23 | const generator: ( 24 | directory: DirectoryEntry 25 | ) => AsyncGenerator = async function* ( 26 | directory: DirectoryEntry 27 | ) { 28 | for await (const handle of directory.handle.values()) { 29 | const entry = entryFromHandle(handle, directory.path); 30 | yield entry; 31 | if (entry.kind === 'directory') { 32 | yield* generator(entry); 33 | } 34 | } 35 | }; 36 | 37 | // return scheduled(generator(dir.handle), animationFrameScheduler); 38 | return from(generator(dir)); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/app/scanner/resizer.worker.ts: -------------------------------------------------------------------------------- 1 | import { Picture } from '@app/database/pictures/picture.model'; 2 | import { readAsDataURL } from '@app/core/utils/read-as-data-url.util'; 3 | import { firstValueFrom } from 'rxjs'; 4 | 5 | addEventListener('message', async ({ data }) => { 6 | const { 7 | id, 8 | picture, 9 | width, 10 | height, 11 | }: { 12 | id: number; 13 | picture: Picture; 14 | width: number; 15 | height: number; 16 | } = data; 17 | 18 | if (height === 0) { 19 | const src = await firstValueFrom( 20 | readAsDataURL(new Blob([picture.data], { type: picture.format })) 21 | ); 22 | postMessage({ id, result: { src, width, height } }); 23 | return; 24 | } 25 | 26 | const canvas = new OffscreenCanvas(width, height); 27 | const ctx: OffscreenCanvasRenderingContext2D | null = canvas.getContext('2d'); 28 | 29 | if (!ctx) { 30 | postMessage({ id, error: new Error('canvas not supported') }); 31 | return; 32 | } 33 | 34 | ctx.imageSmoothingEnabled = true; 35 | ctx.imageSmoothingQuality = 'high'; 36 | 37 | let bitmap; 38 | try { 39 | // const file = await picture.entries[0].handle.getFile(); 40 | bitmap = await createImageBitmap( 41 | new Blob([picture.data], { type: picture.format }), 42 | { 43 | // resizeHeight: height * 2, 44 | // resizeWidth: width * 2, 45 | resizeQuality: 'high', 46 | } 47 | ); 48 | ctx.drawImage(bitmap, 0, 0, width, height); 49 | } catch (e) { 50 | postMessage({ id, error: e }); 51 | return; 52 | } 53 | 54 | const reader = new FileReader(); 55 | reader.onload = (e) => { 56 | postMessage({ 57 | id, 58 | result: { 59 | src: e.target?.result, 60 | width, 61 | height, 62 | }, 63 | }); 64 | }; 65 | reader.onerror = (e) => postMessage({ id, error: e.target?.error }); 66 | 67 | await canvas 68 | .convertToBlob({ 69 | type: 'image/webp', 70 | quality: 80, 71 | }) 72 | .then((blob) => { 73 | reader.readAsDataURL(blob); 74 | }) 75 | .catch((error) => postMessage({ id, error })); 76 | }); 77 | -------------------------------------------------------------------------------- /src/app/scanner/scanner.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CoreModule } from '@app/core/core.module'; 3 | import { ScanComponent } from '@app/scanner/scan.component'; 4 | import { EffectsModule } from '@ngrx/effects'; 5 | import { scannerReducer } from '@app/scanner/store'; 6 | import { ScannerFacade } from '@app/scanner/store/scanner.facade'; 7 | import { StoreModule } from '@ngrx/store'; 8 | import { ExtractorService } from '@app/scanner/extractor.service'; 9 | import { FileService } from '@app/scanner/file.service'; 10 | import { ScannerEffects2 } from '@app/scanner/store/scanner2.effects'; 11 | import { ResizerService } from '@app/scanner/resizer.service'; 12 | 13 | @NgModule({ 14 | declarations: [ScanComponent], 15 | imports: [ 16 | CoreModule, 17 | StoreModule.forFeature('scanner', scannerReducer), 18 | EffectsModule.forFeature([ScannerEffects2]), 19 | ], 20 | providers: [ScannerFacade, ExtractorService, FileService, ResizerService], 21 | }) 22 | export class ScannerModule {} 23 | -------------------------------------------------------------------------------- /src/app/scanner/store/index.ts: -------------------------------------------------------------------------------- 1 | export * from './scanner.reducer'; 2 | export * from './scanner.selectors'; 3 | export * from './scanner.state'; 4 | // export * from './scanner.effects'; 5 | -------------------------------------------------------------------------------- /src/app/scanner/store/scanner.actions.ts: -------------------------------------------------------------------------------- 1 | import { createAction, props } from '@ngrx/store'; 2 | import { DirectoryEntry } from '@app/database/entries/entry.model'; 3 | 4 | export const quickSync = createAction('scanner/sync'); 5 | 6 | export const openDirectory = createAction( 7 | 'scanner/open', 8 | props<{ directory?: DirectoryEntry }>() 9 | ); 10 | export const scanStart = createAction('scanner/start'); 11 | export const scanEnd = createAction('scanner/end'); 12 | export const setLabel = createAction( 13 | 'scanner/label', 14 | props<{ label: string }>() 15 | ); 16 | export const scanSuccess = createAction('scanner/success'); 17 | export const scanFailure = createAction( 18 | 'scanner/failure', 19 | props<{ error: any }>() 20 | ); 21 | -------------------------------------------------------------------------------- /src/app/scanner/store/scanner.facade.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Store } from '@ngrx/store'; 3 | import { 4 | selectError, 5 | selectLabel, 6 | selectState, 7 | } from '@app/scanner/store/scanner.selectors'; 8 | import { 9 | quickSync, 10 | scanEnd, 11 | scanStart, 12 | setLabel, 13 | } from '@app/scanner/store/scanner.actions'; 14 | 15 | @Injectable() 16 | export class ScannerFacade { 17 | error$ = this.store.select(selectError); 18 | state$ = this.store.select(selectState); 19 | label$ = this.store.select(selectLabel); 20 | 21 | constructor(private store: Store) {} 22 | 23 | start(): void { 24 | this.store.dispatch(scanStart()); 25 | } 26 | 27 | abort(): void { 28 | this.store.dispatch(scanEnd()); 29 | } 30 | 31 | setLabel(label: string): void { 32 | this.store.dispatch(setLabel({ label })); 33 | } 34 | 35 | quickSync() { 36 | this.store.dispatch(quickSync()); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/scanner/store/scanner.reducer.ts: -------------------------------------------------------------------------------- 1 | import { ActionReducer, createReducer, on } from '@ngrx/store'; 2 | import * as Actions from './scanner.actions'; 3 | import { initialState, ScannerState } from './scanner.state'; 4 | 5 | export const scannerReducer: ActionReducer = createReducer( 6 | initialState, 7 | 8 | on(Actions.scanStart, (state) => ({ ...state, state: 'scanning' })), 9 | on(Actions.scanEnd, (state) => ({ ...state, state: 'idle' })), 10 | on(Actions.setLabel, (state, { label }) => ({ ...state, label })), 11 | on(Actions.scanSuccess, (state) => ({ ...state, state: 'success' })), 12 | on(Actions.scanFailure, (state, { error }) => ({ 13 | ...state, 14 | error, 15 | state: 'error', 16 | })) 17 | ); 18 | -------------------------------------------------------------------------------- /src/app/scanner/store/scanner.selectors.ts: -------------------------------------------------------------------------------- 1 | import { createFeatureSelector, createSelector } from '@ngrx/store'; 2 | import { ScannerState } from './scanner.state'; 3 | 4 | export const selectCoreState = createFeatureSelector('scanner'); 5 | 6 | export const selectState = createSelector( 7 | selectCoreState, 8 | (state) => state.state 9 | ); 10 | 11 | export const selectError = createSelector( 12 | selectCoreState, 13 | (state) => state.error 14 | ); 15 | 16 | export const selectLabel = createSelector( 17 | selectCoreState, 18 | (state) => state.label 19 | ); 20 | -------------------------------------------------------------------------------- /src/app/scanner/store/scanner.state.ts: -------------------------------------------------------------------------------- 1 | export interface ScannerState { 2 | state: 'idle' | 'scanning' | 'success' | 'error'; 3 | error?: any; 4 | label: string; 5 | } 6 | 7 | export const initialState: ScannerState = { 8 | state: 'idle', 9 | label: '', 10 | }; 11 | -------------------------------------------------------------------------------- /src/app/settings/library-settings.component.spec.ts: -------------------------------------------------------------------------------- 1 | // import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | // 3 | // import { LibrarySettingsComponent } from './library-settings.component'; 4 | // 5 | // describe('LibrarySettingsComponent', () => { 6 | // let component: LibrarySettingsComponent; 7 | // let fixture: ComponentFixture; 8 | // 9 | // beforeEach(async () => { 10 | // await TestBed.configureTestingModule({ 11 | // declarations: [ LibrarySettingsComponent ] 12 | // }) 13 | // .compileComponents(); 14 | // }); 15 | // 16 | // beforeEach(() => { 17 | // fixture = TestBed.createComponent(LibrarySettingsComponent); 18 | // component = fixture.componentInstance; 19 | // fixture.detectChanges(); 20 | // }); 21 | // 22 | // it('should create', () => { 23 | // expect(component).toBeTruthy(); 24 | // }); 25 | // }); 26 | -------------------------------------------------------------------------------- /src/app/update.service.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationRef, Inject, Injectable } from '@angular/core'; 2 | import { SwUpdate, VersionReadyEvent } from '@angular/service-worker'; 3 | import { MatSnackBar } from '@angular/material/snack-bar'; 4 | import { concatMap, filter, first, tap } from 'rxjs/operators'; 5 | import { interval } from 'rxjs'; 6 | import { DOCUMENT } from '@angular/common'; 7 | 8 | @Injectable() 9 | export class UpdateService { 10 | constructor( 11 | private appRef: ApplicationRef, 12 | private updates: SwUpdate, 13 | private snackBar: MatSnackBar, 14 | @Inject(DOCUMENT) private document: Document 15 | ) {} 16 | 17 | register(): void { 18 | this.updates.versionUpdates 19 | .pipe( 20 | filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'), 21 | concatMap(() => 22 | this.snackBar 23 | .open('A new version is available.', 'Reload', { 24 | duration: Infinity, 25 | }) 26 | .afterDismissed() 27 | ), 28 | tap(() => 29 | this.updates.activateUpdate().then(() => document.location.reload()) 30 | ) 31 | ) 32 | .subscribe(); 33 | 34 | this.appRef.isStable 35 | .pipe( 36 | first((isStable) => isStable), 37 | concatMap(() => interval(60 * 60 * 1000)), // 1 hour 38 | tap(() => this.updates.checkForUpdate()) 39 | ) 40 | .subscribe(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/app/welcome/support.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable } from '@angular/core'; 2 | import { Platform } from '@angular/cdk/platform'; 3 | import { DOCUMENT } from '@angular/common'; 4 | 5 | @Injectable() 6 | export class SupportService { 7 | constructor( 8 | private platform: Platform, 9 | @Inject(DOCUMENT) private document: Document 10 | ) {} 11 | 12 | checkFileSystemSupport(): boolean { 13 | return ( 14 | (this.document.defaultView && 15 | 'showDirectoryPicker' in this.document.defaultView) ?? 16 | false 17 | ); 18 | } 19 | 20 | isAppAvailable(): boolean { 21 | return localStorage.getItem('scanned') === '1'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/app/welcome/welcome.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { WelcomeComponent } from './welcome.component'; 4 | 5 | describe('WelcomeComponent', () => { 6 | let component: WelcomeComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [WelcomeComponent], 12 | }).compileComponents(); 13 | }); 14 | 15 | beforeEach(() => { 16 | fixture = TestBed.createComponent(WelcomeComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | }); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/app/welcome/welcome.module.ts: -------------------------------------------------------------------------------- 1 | import { NgModule } from '@angular/core'; 2 | import { CoreModule } from '@app/core/core.module'; 3 | import { WelcomeComponent } from '@app/welcome/welcome.component'; 4 | import { RouterModule, Routes } from '@angular/router'; 5 | import { SupportService } from '@app/welcome/support.service'; 6 | 7 | const routes: Routes = [ 8 | { 9 | path: '', 10 | component: WelcomeComponent, 11 | data: { animation: 'default' }, 12 | }, 13 | ]; 14 | 15 | @NgModule({ 16 | declarations: [WelcomeComponent], 17 | imports: [ 18 | CoreModule, 19 | RouterModule.forChild(routes), 20 | // DatabaseModule, 21 | // ScannerModule, 22 | ], 23 | providers: [SupportService], 24 | }) 25 | export class WelcomeModule {} 26 | -------------------------------------------------------------------------------- /src/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/apple-touch-icon.png -------------------------------------------------------------------------------- /src/assets/docs_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/docs_logo.png -------------------------------------------------------------------------------- /src/assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/favicon-16x16.png -------------------------------------------------------------------------------- /src/assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/favicon-32x32.png -------------------------------------------------------------------------------- /src/assets/fonts/roboto-v19-latin-300.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/fonts/roboto-v19-latin-300.woff -------------------------------------------------------------------------------- /src/assets/fonts/roboto-v19-latin-300.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/fonts/roboto-v19-latin-300.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/roboto-v19-latin-500.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/fonts/roboto-v19-latin-500.woff -------------------------------------------------------------------------------- /src/assets/fonts/roboto-v19-latin-500.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/fonts/roboto-v19-latin-500.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/roboto-v19-latin-700.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/fonts/roboto-v19-latin-700.woff -------------------------------------------------------------------------------- /src/assets/fonts/roboto-v19-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/fonts/roboto-v19-latin-700.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/roboto-v19-latin-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/fonts/roboto-v19-latin-regular.woff -------------------------------------------------------------------------------- /src/assets/fonts/roboto-v19-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/fonts/roboto-v19-latin-regular.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/youtube-sans-bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/fonts/youtube-sans-bold.ttf -------------------------------------------------------------------------------- /src/assets/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/icons/icon-128x128.png -------------------------------------------------------------------------------- /src/assets/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/icons/icon-144x144.png -------------------------------------------------------------------------------- /src/assets/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/icons/icon-152x152.png -------------------------------------------------------------------------------- /src/assets/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/icons/icon-192x192.png -------------------------------------------------------------------------------- /src/assets/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/icons/icon-384x384.png -------------------------------------------------------------------------------- /src/assets/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/icons/icon-512x512.png -------------------------------------------------------------------------------- /src/assets/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/icons/icon-72x72.png -------------------------------------------------------------------------------- /src/assets/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/icons/icon-96x96.png -------------------------------------------------------------------------------- /src/assets/icons/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/mstile-150x150.png -------------------------------------------------------------------------------- /src/assets/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 22 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /src/assets/welcome/w1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/welcome/w1.webp -------------------------------------------------------------------------------- /src/assets/welcome/w2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/welcome/w2.webp -------------------------------------------------------------------------------- /src/assets/welcome/w3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/welcome/w3.webp -------------------------------------------------------------------------------- /src/assets/welcome/w4.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/welcome/w4.webp -------------------------------------------------------------------------------- /src/assets/welcome/w5.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/welcome/w5.webp -------------------------------------------------------------------------------- /src/assets/welcome/w6.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/assets/welcome/w6.webp -------------------------------------------------------------------------------- /src/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #333333 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | }; 4 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build --prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false, 7 | }; 8 | 9 | /* 10 | * For easier debugging in development mode, you can import the following file 11 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 12 | * 13 | * This import should be commented out in production mode because it will have a negative impact 14 | * on performance if an error is thrown. 15 | */ 16 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 17 | -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tgambet/musicsource/57363bb810870498a5dbcde184dfa45338747cbc/src/favicon.ico -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | MusicSource 6 | 7 | 8 | 9 | 10 | 16 | 22 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { environment } from './environments/environment'; 5 | import { RootModule } from '@app/root.module'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic() 12 | .bootstrapModule(RootModule) 13 | .catch((err) => console.error(err)); 14 | -------------------------------------------------------------------------------- /src/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "MusicSource", 3 | "short_name": "MusicSource", 4 | "description": "A modern desktop player for your personal music library.", 5 | "theme_color": "#000000", 6 | "background_color": "#131313", 7 | "display": "minimal-ui", 8 | "scope": "./", 9 | "start_url": "/index.html", 10 | "orientation": "any", 11 | "screenshots": [ 12 | { 13 | "src": "assets/welcome/w2.webp", 14 | "sizes": "1025x804", 15 | "type": "image/webp", 16 | "label": "MusicSource - Artist view" 17 | }, 18 | { 19 | "src": "assets/welcome/w4.webp", 20 | "sizes": "1025x804", 21 | "type": "image/webp", 22 | "label": "MusicSource - Library view" 23 | } 24 | ], 25 | "icons": [ 26 | { 27 | "src": "assets/icons/icon-72x72.png", 28 | "sizes": "72x72", 29 | "type": "image/png", 30 | "purpose": "maskable any" 31 | }, 32 | { 33 | "src": "assets/icons/icon-96x96.png", 34 | "sizes": "96x96", 35 | "type": "image/png", 36 | "purpose": "maskable any" 37 | }, 38 | { 39 | "src": "assets/icons/icon-128x128.png", 40 | "sizes": "128x128", 41 | "type": "image/png", 42 | "purpose": "maskable any" 43 | }, 44 | { 45 | "src": "assets/icons/icon-144x144.png", 46 | "sizes": "144x144", 47 | "type": "image/png", 48 | "purpose": "maskable any" 49 | }, 50 | { 51 | "src": "assets/icons/icon-152x152.png", 52 | "sizes": "152x152", 53 | "type": "image/png", 54 | "purpose": "maskable any" 55 | }, 56 | { 57 | "src": "assets/icons/icon-192x192.png", 58 | "sizes": "192x192", 59 | "type": "image/png", 60 | "purpose": "maskable any" 61 | }, 62 | { 63 | "src": "assets/icons/icon-384x384.png", 64 | "sizes": "384x384", 65 | "type": "image/png", 66 | "purpose": "maskable any" 67 | }, 68 | { 69 | "src": "assets/icons/icon-512x512.png", 70 | "sizes": "512x512", 71 | "type": "image/png", 72 | "purpose": "maskable any" 73 | }, 74 | { 75 | "src": "assets/icons/icon-512x512.png", 76 | "sizes": "512x512", 77 | "type": "image/png", 78 | "purpose": "any" 79 | } 80 | ], 81 | "shortcuts": [] 82 | } 83 | -------------------------------------------------------------------------------- /src/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /library/* 3 | -------------------------------------------------------------------------------- /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/testing'; 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, 7 | platformBrowserDynamicTesting, 8 | } from '@angular/platform-browser-dynamic/testing'; 9 | 10 | declare const require: { 11 | context( 12 | path: string, 13 | deep?: boolean, 14 | filter?: RegExp 15 | ): { 16 | (id: string): T; 17 | keys(): string[]; 18 | }; 19 | }; 20 | 21 | // First, initialize the Angular testing environment. 22 | getTestBed().initTestEnvironment( 23 | BrowserDynamicTestingModule, 24 | platformBrowserDynamicTesting(), 25 | { 26 | teardown: { destroyAfterEach: false }, 27 | } 28 | ); 29 | // Then we find all the tests. 30 | const context = require.context('./', true, /\.spec\.ts$/); 31 | // And load the modules. 32 | context.keys().map(context); 33 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./out-tsc/app", 5 | "types": ["wicg-file-system-access", "wicg-mediasession"], 6 | "lib": ["es2018", "dom"] 7 | }, 8 | "files": [ 9 | "src/main.ts", 10 | "src/polyfills.ts" 11 | ], 12 | "include": [ 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "baseUrl": "./", 6 | "outDir": "./dist/out-tsc", 7 | "forceConsistentCasingInFileNames": true, 8 | "strict": true, 9 | "noImplicitReturns": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "sourceMap": true, 12 | "declaration": false, 13 | "downlevelIteration": true, 14 | "experimentalDecorators": true, 15 | "moduleResolution": "node", 16 | "allowSyntheticDefaultImports": true, 17 | "importHelpers": true, 18 | "target": "es2020", 19 | "module": "es2020", 20 | "lib": ["es2018"], 21 | "typeRoots": ["node_modules/@types"], 22 | "paths": { 23 | "@app/*": ["src/app/*"], 24 | "@env/*": ["src/environments/*"] 25 | } 26 | }, 27 | "angularCompilerOptions": { 28 | "strictInjectionParameters": true, 29 | "strictInputAccessModifiers": true, 30 | "strictTemplates": true 31 | }, 32 | "exclude": ["cypress/**/*"] 33 | } 34 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/spec", 6 | "types": [ 7 | "jasmine", 8 | "wicg-file-system-access" 9 | ] 10 | }, 11 | "files": [ 12 | "src/test.ts", 13 | "src/polyfills.ts" 14 | ], 15 | "include": [ 16 | "src/**/*.spec.ts", 17 | "src/**/*.d.ts" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.worker.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/worker", 6 | "lib": [ 7 | "es2018", 8 | ], 9 | "types": ["web", "offscreencanvas"] 10 | }, 11 | "include": [ 12 | "src/**/*.worker.ts", 13 | ], 14 | "files": [ 15 | "src/app/scanner/extractor.worker.ts", 16 | "src/app/scanner/resizer.worker.ts", 17 | ] 18 | } 19 | --------------------------------------------------------------------------------