├── src ├── assets │ ├── .gitkeep │ └── 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 ├── app │ ├── app.component.scss │ ├── app.component.html │ ├── module │ │ ├── index │ │ │ ├── component │ │ │ │ ├── sidenav │ │ │ │ │ ├── sidenav.component.scss │ │ │ │ │ ├── sidenav.component.html │ │ │ │ │ ├── sidenav.component.spec.ts │ │ │ │ │ └── sidenav.component.ts │ │ │ │ ├── control │ │ │ │ │ ├── control.component.scss │ │ │ │ │ ├── control.component.spec.ts │ │ │ │ │ ├── control.component.html │ │ │ │ │ └── control.component.ts │ │ │ │ └── index │ │ │ │ │ ├── index.component.spec.ts │ │ │ │ │ ├── index.component.scss │ │ │ │ │ ├── index.component.html │ │ │ │ │ └── index.component.ts │ │ │ ├── index-routing.module.ts │ │ │ └── index.module.ts │ │ └── shared │ │ │ └── shared.module.ts │ ├── entity │ │ ├── RestModel.ts │ │ ├── page │ │ │ ├── Sort.ts │ │ │ ├── Pageable.ts │ │ │ └── Page.ts │ │ └── Music.ts │ ├── pipe │ │ ├── seconds-to-minutes.pipe.spec.ts │ │ └── seconds-to-minutes.pipe.ts │ ├── http │ │ ├── index.ts │ │ └── ResponseDataUnboxingInterceptor.ts │ ├── app.component.ts │ ├── service │ │ ├── pwa.service.spec.ts │ │ ├── file.service.spec.ts │ │ ├── lyric.service.spec.ts │ │ ├── config.service.spec.ts │ │ ├── music-list.service.spec.ts │ │ ├── music-play.service.spec.ts │ │ ├── file.service.ts │ │ ├── pwa.service.ts │ │ ├── config.service.ts │ │ ├── music-list.service.ts │ │ ├── music-play.service.ts │ │ └── lyric.service.ts │ ├── app-routing.module.ts │ ├── app.module.ts │ └── app.component.spec.ts ├── favicon.ico ├── environments │ ├── environment.prod.ts │ └── environment.ts ├── main.ts ├── index.html ├── test.ts ├── manifest.webmanifest ├── styles.scss └── polyfills.ts ├── .wakatime-project ├── pic └── index.png ├── .editorconfig ├── e2e ├── src │ ├── app.po.ts │ └── app.e2e-spec.ts ├── tsconfig.json └── protractor.conf.js ├── tsconfig.app.json ├── tsconfig.spec.json ├── tsconfig.json ├── .gitignore ├── .browserslistrc ├── karma.conf.js ├── ngsw-config.json ├── package.json ├── .github └── workflows │ └── main.yml ├── README.md ├── tslint.json └── angular.json /src/assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.wakatime-project: -------------------------------------------------------------------------------- 1 | Wispy Glitter 50 -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pic/index.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itning/YunShuMusicClient/master/pic/index.png -------------------------------------------------------------------------------- /src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itning/YunShuMusicClient/master/src/favicon.ico -------------------------------------------------------------------------------- /src/app/module/index/component/sidenav/sidenav.component.scss: -------------------------------------------------------------------------------- 1 | mat-slider { 2 | width: 98%; 3 | } 4 | -------------------------------------------------------------------------------- /src/app/entity/RestModel.ts: -------------------------------------------------------------------------------- 1 | export class RestModel { 2 | code: number; 3 | msg: string; 4 | data: T; 5 | } 6 | -------------------------------------------------------------------------------- /src/app/entity/page/Sort.ts: -------------------------------------------------------------------------------- 1 | export class Sort { 2 | unsorted: boolean; 3 | sorted: boolean; 4 | empty: boolean; 5 | } 6 | -------------------------------------------------------------------------------- /src/assets/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itning/YunShuMusicClient/master/src/assets/icons/icon-128x128.png -------------------------------------------------------------------------------- /src/assets/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itning/YunShuMusicClient/master/src/assets/icons/icon-144x144.png -------------------------------------------------------------------------------- /src/assets/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itning/YunShuMusicClient/master/src/assets/icons/icon-152x152.png -------------------------------------------------------------------------------- /src/assets/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itning/YunShuMusicClient/master/src/assets/icons/icon-192x192.png -------------------------------------------------------------------------------- /src/assets/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itning/YunShuMusicClient/master/src/assets/icons/icon-384x384.png -------------------------------------------------------------------------------- /src/assets/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itning/YunShuMusicClient/master/src/assets/icons/icon-512x512.png -------------------------------------------------------------------------------- /src/assets/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itning/YunShuMusicClient/master/src/assets/icons/icon-72x72.png -------------------------------------------------------------------------------- /src/assets/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/itning/YunShuMusicClient/master/src/assets/icons/icon-96x96.png -------------------------------------------------------------------------------- /src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | apiHost: 'http://localhost:8888/' 4 | }; 5 | -------------------------------------------------------------------------------- /src/app/entity/page/Pageable.ts: -------------------------------------------------------------------------------- 1 | import {Sort} from './Sort'; 2 | 3 | export class Pageable { 4 | offset: number; 5 | pageNumber: number; 6 | pageSize: number; 7 | paged: boolean; 8 | unpaged: boolean; 9 | sort: Sort; 10 | } 11 | -------------------------------------------------------------------------------- /src/app/module/index/component/sidenav/sidenav.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
{{lyric}}
4 | -------------------------------------------------------------------------------- /src/app/pipe/seconds-to-minutes.pipe.spec.ts: -------------------------------------------------------------------------------- 1 | import { SecondsToMinutesPipe } from './seconds-to-minutes.pipe'; 2 | 3 | describe('SecondsToMinutesPipe', () => { 4 | it('create an instance', () => { 5 | const pipe = new SecondsToMinutesPipe(); 6 | expect(pipe).toBeTruthy(); 7 | }); 8 | }); 9 | -------------------------------------------------------------------------------- /src/app/entity/Music.ts: -------------------------------------------------------------------------------- 1 | export class Music { 2 | /** 3 | * 音乐ID 4 | */ 5 | musicId: string; 6 | /** 7 | * 音乐名 8 | */ 9 | name: string; 10 | /** 11 | * 歌手 12 | */ 13 | singer: string; 14 | /** 15 | * 歌词ID 16 | */ 17 | lyricId: string; 18 | /** 19 | * 音乐类型 20 | */ 21 | type: number; 22 | } 23 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/app/http/index.ts: -------------------------------------------------------------------------------- 1 | import {HTTP_INTERCEPTORS} from '@angular/common/http'; 2 | import {Provider} from '@angular/core'; 3 | import {ResponseDataUnboxingInterceptor} from './ResponseDataUnboxingInterceptor'; 4 | 5 | export const httpInterceptorProviders: Provider[] = [ 6 | {provide: HTTP_INTERCEPTORS, useClass: ResponseDataUnboxingInterceptor, multi: true} 7 | ]; 8 | -------------------------------------------------------------------------------- /e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo(): Promise { 5 | return browser.get(browser.baseUrl) as Promise; 6 | } 7 | 8 | getTitleText(): Promise { 9 | return element(by.css('app-root .content span')).getText() as Promise; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /e2e/tsconfig.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/e2e", 6 | "module": "commonjs", 7 | "target": "es2018", 8 | "types": [ 9 | "jasmine", 10 | "jasminewd2", 11 | "node" 12 | ] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.app.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/app", 6 | "types": [] 7 | }, 8 | "files": [ 9 | "src/main.ts", 10 | "src/polyfills.ts" 11 | ], 12 | "include": [ 13 | "src/**/*.d.ts" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import {Component} from '@angular/core'; 2 | import {PwaService} from './service/pwa.service'; 3 | 4 | @Component({ 5 | selector: 'app-root', 6 | templateUrl: './app.component.html', 7 | styleUrls: ['./app.component.scss'] 8 | }) 9 | export class AppComponent { 10 | 11 | constructor(private pwaService: PwaService) { 12 | pwaService.start(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/entity/page/Page.ts: -------------------------------------------------------------------------------- 1 | import {Pageable} from './Pageable'; 2 | import {Sort} from './Sort'; 3 | 4 | export class Page { 5 | last: boolean; 6 | totalPages: number; 7 | totalElements: number; 8 | number: number; 9 | size: number; 10 | numberOfElements: number; 11 | first: boolean; 12 | empty: boolean; 13 | pageable: Pageable; 14 | sort: Sort; 15 | content: DATA[]; 16 | } 17 | -------------------------------------------------------------------------------- /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 | ] 9 | }, 10 | "files": [ 11 | "src/test.ts", 12 | "src/polyfills.ts" 13 | ], 14 | "include": [ 15 | "src/**/*.spec.ts", 16 | "src/**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | platformBrowserDynamic().bootstrapModule(AppModule) 12 | .catch(err => console.error(err)); 13 | -------------------------------------------------------------------------------- /src/app/service/pwa.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { PwaService } from './pwa.service'; 4 | 5 | describe('PwaService', () => { 6 | let service: PwaService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(PwaService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/service/file.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { FileService } from './file.service'; 4 | 5 | describe('FileService', () => { 6 | let service: FileService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(FileService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/service/lyric.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { LyricService } from './lyric.service'; 4 | 5 | describe('LyricService', () => { 6 | let service: LyricService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(LyricService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/service/config.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { ConfigService } from './config.service'; 4 | 5 | describe('ConfigService', () => { 6 | let service: ConfigService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(ConfigService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/service/music-list.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { MusicListService } from './music-list.service'; 4 | 5 | describe('MusicListService', () => { 6 | let service: MusicListService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(MusicListService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/service/music-play.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | 3 | import { MusicPlayService } from './music-play.service'; 4 | 5 | describe('MusicPlayService', () => { 6 | let service: MusicPlayService; 7 | 8 | beforeEach(() => { 9 | TestBed.configureTestingModule({}); 10 | service = TestBed.inject(MusicPlayService); 11 | }); 12 | 13 | it('should be created', () => { 14 | expect(service).toBeTruthy(); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/app/app-routing.module.ts: -------------------------------------------------------------------------------- 1 | import {NgModule} from '@angular/core'; 2 | import {RouterModule, Routes} from '@angular/router'; 3 | import {IndexComponent} from './module/index/component/index/index.component'; 4 | 5 | const routes: Routes = [ 6 | {path: '', component: IndexComponent} 7 | ]; 8 | 9 | @NgModule({ 10 | imports: [RouterModule.forRoot(routes, { relativeLinkResolution: 'legacy' })], 11 | exports: [RouterModule] 12 | }) 13 | export class AppRoutingModule { 14 | } 15 | -------------------------------------------------------------------------------- /src/app/pipe/seconds-to-minutes.pipe.ts: -------------------------------------------------------------------------------- 1 | import {Pipe, PipeTransform} from '@angular/core'; 2 | 3 | @Pipe({ 4 | name: 'secondsToMinutes' 5 | }) 6 | export class SecondsToMinutesPipe implements PipeTransform { 7 | 8 | transform(seconds: number): string { 9 | const minutes = Math.floor(seconds / 60).toString().padStart(2, '0'); 10 | const secondsRemaining = Math.floor(seconds % 60).toString().padStart(2, '0'); 11 | return `${minutes}:${secondsRemaining}`; 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/app/module/index/index-routing.module.ts: -------------------------------------------------------------------------------- 1 | import {NgModule} from '@angular/core'; 2 | import {RouterModule, Routes} from '@angular/router'; 3 | import {IndexComponent} from './component/index/index.component'; 4 | 5 | const routes: Routes = [ 6 | { 7 | path: '', 8 | component: IndexComponent, 9 | children: [] 10 | } 11 | ]; 12 | 13 | @NgModule({ 14 | imports: [RouterModule.forChild(routes)], 15 | exports: [RouterModule] 16 | }) 17 | export class IndexRoutingModule { 18 | } 19 | -------------------------------------------------------------------------------- /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 | "sourceMap": true, 8 | "declaration": false, 9 | "downlevelIteration": true, 10 | "experimentalDecorators": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "target": "es2015", 14 | "module": "es2020", 15 | "lib": [ 16 | "es2018", 17 | "dom" 18 | ] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/app/module/index/index.module.ts: -------------------------------------------------------------------------------- 1 | import {NgModule} from '@angular/core'; 2 | import {CommonModule} from '@angular/common'; 3 | import {IndexComponent} from './component/index/index.component'; 4 | import {SharedModule} from '../shared/shared.module'; 5 | import {ControlComponent} from './component/control/control.component'; 6 | import {SidenavComponent} from './component/sidenav/sidenav.component'; 7 | 8 | @NgModule({ 9 | declarations: [IndexComponent, ControlComponent, SidenavComponent], 10 | imports: [ 11 | CommonModule, 12 | SharedModule 13 | ] 14 | }) 15 | export class IndexModule { 16 | } 17 | -------------------------------------------------------------------------------- /e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | import { browser, logging } from 'protractor'; 3 | 4 | describe('workspace-project App', () => { 5 | let page: AppPage; 6 | 7 | beforeEach(() => { 8 | page = new AppPage(); 9 | }); 10 | 11 | it('should display welcome message', () => { 12 | page.navigateTo(); 13 | expect(page.getTitleText()).toEqual('YunShuMusicClient app is running!'); 14 | }); 15 | 16 | afterEach(async () => { 17 | // Assert that there are no errors emitted from the browser 18 | const logs = await browser.manage().logs().get(logging.Type.BROWSER); 19 | expect(logs).not.toContain(jasmine.objectContaining({ 20 | level: logging.Level.SEVERE, 21 | } as logging.Entry)); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/app/module/index/component/control/control.component.scss: -------------------------------------------------------------------------------- 1 | mat-slider { 2 | width: 100%; 3 | bottom: 8px; 4 | } 5 | 6 | .control-box { 7 | height: 60px; 8 | align-items: center; 9 | display: grid; 10 | grid-template-columns: 125px 125px 1fr; 11 | grid-template-rows: 1fr 1fr; 12 | } 13 | 14 | .control-left { 15 | line-height: 60px; 16 | height: 60px; 17 | grid-row-start: 1; 18 | grid-row-end: 3; 19 | } 20 | 21 | .control-slider { 22 | grid-column-start: 2; 23 | grid-column-end: 5; 24 | height: 30px; 25 | } 26 | 27 | .control-time { 28 | height: 30px; 29 | } 30 | 31 | .control-music-info { 32 | line-height: 40px; 33 | height: 30px; 34 | white-space: nowrap; 35 | overflow: hidden; 36 | /*text-overflow: ellipsis;*/ 37 | } 38 | -------------------------------------------------------------------------------- /src/app/module/index/component/index/index.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { IndexComponent } from './index.component'; 4 | 5 | describe('IndexComponent', () => { 6 | let component: IndexComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ IndexComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(IndexComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/module/index/component/control/control.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ControlComponent } from './control.component'; 4 | 5 | describe('ControlComponent', () => { 6 | let component: ControlComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ ControlComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(ControlComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/app/module/index/component/sidenav/sidenav.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { SidenavComponent } from './sidenav.component'; 4 | 5 | describe('SidenavComponent', () => { 6 | let component: SidenavComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | declarations: [ SidenavComponent ] 12 | }) 13 | .compileComponents(); 14 | }); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(SidenavComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /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 | apiHost: 'http://localhost:8888/' 8 | }; 9 | 10 | /* 11 | * For easier debugging in development mode, you can import the following file 12 | * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`. 13 | * 14 | * This import should be commented out in production mode because it will have a negative impact 15 | * on performance if an error is thrown. 16 | */ 17 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 18 | -------------------------------------------------------------------------------- /.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 | /.sass-cache 36 | /connect.lock 37 | /coverage 38 | /libpeerconnection.log 39 | npm-debug.log 40 | yarn-error.log 41 | testem.log 42 | /typings 43 | 44 | # System Files 45 | .DS_Store 46 | Thumbs.db 47 | -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 云舒音乐 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/app/module/index/component/index/index.component.scss: -------------------------------------------------------------------------------- 1 | .index-container { 2 | position: absolute; 3 | top: 60px; 4 | bottom: 60px; 5 | left: 0; 6 | right: 0; 7 | } 8 | 9 | .index-sidenav { 10 | display: flex; 11 | align-items: center; 12 | justify-content: center; 13 | width: 200px; 14 | } 15 | 16 | .index-header { 17 | position: fixed; 18 | top: 0; 19 | left: 0; 20 | right: 0; 21 | } 22 | 23 | .index-footer { 24 | position: fixed; 25 | bottom: 0; 26 | left: 0; 27 | right: 0; 28 | } 29 | 30 | .search-form { 31 | min-width: 150px; 32 | width: 100%; 33 | } 34 | 35 | .search-full-width { 36 | width: 100%; 37 | } 38 | 39 | .music-list-item-index { 40 | text-align: center; 41 | line-height: 40px; 42 | } 43 | 44 | .fab-btn-location { 45 | bottom: 76px; 46 | position: fixed; 47 | right: 24px; 48 | } 49 | -------------------------------------------------------------------------------- /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(path: string, deep?: boolean, filter?: RegExp): { 12 | keys(): string[]; 13 | (id: string): T; 14 | }; 15 | }; 16 | 17 | // First, initialize the Angular testing environment. 18 | getTestBed().initTestEnvironment( 19 | BrowserDynamicTestingModule, 20 | platformBrowserDynamicTesting() 21 | ); 22 | // Then we find all the tests. 23 | const context = require.context('./', true, /\.spec\.ts$/); 24 | // And load the modules. 25 | context.keys().map(context); 26 | -------------------------------------------------------------------------------- /.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 | last 1 Chrome version 12 | last 1 Firefox version 13 | last 2 Edge major versions 14 | last 2 Safari major versions 15 | last 2 iOS major versions 16 | Firefox ESR 17 | not IE 9-10 # Angular support for IE 9-10 has been deprecated and will be removed as of Angular v11. To opt-in, remove the 'not' prefix on this line. 18 | not IE 11 # Angular supports IE 11 only as an opt-in. To opt-in, remove the 'not' prefix on this line. 19 | -------------------------------------------------------------------------------- /e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Protractor configuration file, see link for more information 3 | // https://github.com/angular/protractor/blob/master/lib/config.ts 4 | 5 | const { SpecReporter, StacktraceOption } = require('jasmine-spec-reporter'); 6 | 7 | /** 8 | * @type { import("protractor").Config } 9 | */ 10 | exports.config = { 11 | allScriptsTimeout: 11000, 12 | specs: [ 13 | './src/**/*.e2e-spec.ts' 14 | ], 15 | capabilities: { 16 | browserName: 'chrome' 17 | }, 18 | directConnect: true, 19 | baseUrl: 'http://localhost:4200/', 20 | framework: 'jasmine', 21 | jasmineNodeOpts: { 22 | showColors: true, 23 | defaultTimeoutInterval: 30000, 24 | print: function() {} 25 | }, 26 | onPrepare() { 27 | require('ts-node').register({ 28 | project: require('path').join(__dirname, './tsconfig.json') 29 | }); 30 | jasmine.getEnv().addReporter(new SpecReporter({ 31 | spec: { 32 | displayStacktrace: StacktraceOption.PRETTY 33 | } 34 | })); 35 | } 36 | }; -------------------------------------------------------------------------------- /src/app/service/file.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {Observable} from 'rxjs'; 3 | import {HttpClient} from '@angular/common/http'; 4 | import {map} from 'rxjs/operators'; 5 | import {environment} from '../../environments/environment'; 6 | 7 | @Injectable({ 8 | providedIn: 'root' 9 | }) 10 | export class FileService { 11 | 12 | constructor(private http: HttpClient) { 13 | } 14 | 15 | getMusicFile(musicId: string): Observable { 16 | return this.http.get(`${environment.apiHost}file?id=${musicId}`, {responseType: 'blob'}); 17 | } 18 | 19 | getMusicFileToObjectUrl(musicId: string): Observable { 20 | return this.getMusicFile(musicId).pipe(map(blob => window.URL.createObjectURL(blob))); 21 | } 22 | 23 | getMusicFileUrl(musicId: string): string { 24 | return `${environment.apiHost}file?id=${musicId}`; 25 | } 26 | 27 | getLyricFile(lyricId: string): Observable { 28 | return this.http.get(`${environment.apiHost}file/lyric?id=${lyricId}`, {responseType: 'text'}); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import {BrowserModule} from '@angular/platform-browser'; 2 | import {NgModule} from '@angular/core'; 3 | import {AppRoutingModule} from './app-routing.module'; 4 | import {AppComponent} from './app.component'; 5 | import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; 6 | import {httpInterceptorProviders} from './http'; 7 | import {HttpClientModule} from '@angular/common/http'; 8 | import {SharedModule} from './module/shared/shared.module'; 9 | import {IndexModule} from './module/index/index.module'; 10 | import {ServiceWorkerModule} from '@angular/service-worker'; 11 | import {environment} from '../environments/environment'; 12 | 13 | @NgModule({ 14 | declarations: [ 15 | AppComponent 16 | ], 17 | imports: [ 18 | BrowserModule, 19 | AppRoutingModule, 20 | BrowserAnimationsModule, 21 | HttpClientModule, 22 | SharedModule, 23 | IndexModule, 24 | ServiceWorkerModule.register('ngsw-worker.js', {enabled: environment.production}) 25 | ], 26 | providers: [httpInterceptorProviders], 27 | bootstrap: [AppComponent] 28 | }) 29 | export class AppModule { 30 | } 31 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage-istanbul-reporter'), 13 | require('@angular-devkit/build-angular/plugins/karma') 14 | ], 15 | client: { 16 | clearContext: false // leave Jasmine Spec Runner output visible in browser 17 | }, 18 | coverageIstanbulReporter: { 19 | dir: require('path').join(__dirname, './coverage/YunShuMusicClient'), 20 | reports: ['html', 'lcovonly', 'text-summary'], 21 | fixWebpackSourcePaths: true 22 | }, 23 | reporters: ['progress', 'kjhtml'], 24 | port: 9876, 25 | colors: true, 26 | logLevel: config.LOG_INFO, 27 | autoWatch: true, 28 | browsers: ['Chrome'], 29 | singleRun: false, 30 | restartOnFileChange: true 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /src/app/http/ResponseDataUnboxingInterceptor.ts: -------------------------------------------------------------------------------- 1 | import {HttpEvent, HttpHandler, HttpInterceptor, HttpRequest, HttpResponse} from '@angular/common/http'; 2 | import {Observable} from 'rxjs'; 3 | import {Injectable} from '@angular/core'; 4 | import {map} from 'rxjs/operators'; 5 | import {RestModel} from '../entity/RestModel'; 6 | 7 | /** 8 | *

响应拆箱拦截转换 9 | *

从RestModel转换为data:any 10 | */ 11 | @Injectable() 12 | export class ResponseDataUnboxingInterceptor implements HttpInterceptor { 13 | intercept(req: HttpRequest, next: HttpHandler): Observable> { 14 | return next.handle(req).pipe( 15 | map((event: HttpEvent) => { 16 | if (event instanceof HttpResponse && event.status !== 204) { 17 | // 获取响应 18 | // noinspection TypeScriptValidateTypes 19 | const httpResponse: HttpResponse> = (event as HttpResponse>); 20 | // 获取RestModel中data 21 | const data: any = httpResponse.body.data; 22 | // clone and return 23 | return httpResponse.clone({ 24 | body: data 25 | }); 26 | } 27 | return event; 28 | }) 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { TestBed } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | import { AppComponent } from './app.component'; 4 | 5 | describe('AppComponent', () => { 6 | beforeEach(async () => { 7 | await TestBed.configureTestingModule({ 8 | imports: [ 9 | RouterTestingModule 10 | ], 11 | declarations: [ 12 | AppComponent 13 | ], 14 | }).compileComponents(); 15 | }); 16 | 17 | it('should create the app', () => { 18 | const fixture = TestBed.createComponent(AppComponent); 19 | const app = fixture.componentInstance; 20 | expect(app).toBeTruthy(); 21 | }); 22 | 23 | it(`should have as title 'YunShuMusicClient'`, () => { 24 | const fixture = TestBed.createComponent(AppComponent); 25 | const app = fixture.componentInstance; 26 | expect(app.title).toEqual('YunShuMusicClient'); 27 | }); 28 | 29 | it('should render title', () => { 30 | const fixture = TestBed.createComponent(AppComponent); 31 | fixture.detectChanges(); 32 | const compiled = fixture.nativeElement; 33 | expect(compiled.querySelector('.content span').textContent).toContain('YunShuMusicClient app is running!'); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/app/module/index/component/control/control.component.html: -------------------------------------------------------------------------------- 1 |

2 |
3 | 6 | 9 | 12 |
13 |
14 | {{nowTime | secondsToMinutes}}/{{totalTime | secondsToMinutes}} 15 | 20 |
21 |
{{info}}
23 |
24 | 26 |
27 |
28 | -------------------------------------------------------------------------------- /src/app/service/pwa.service.ts: -------------------------------------------------------------------------------- 1 | import {ApplicationRef, Injectable} from '@angular/core'; 2 | import {SwUpdate} from '@angular/service-worker'; 3 | import {first} from 'rxjs/operators'; 4 | import {concat, interval} from 'rxjs'; 5 | import {environment} from '../../environments/environment'; 6 | 7 | @Injectable({ 8 | providedIn: 'root' 9 | }) 10 | export class PwaService { 11 | 12 | constructor(private appRef: ApplicationRef, private updates: SwUpdate) { 13 | } 14 | 15 | start(): void { 16 | if (!environment.production) { 17 | console.log('non production environment'); 18 | return; 19 | } 20 | console.log('pwa service running...'); 21 | this.updates.available.subscribe(event => { 22 | console.log('current version is', event.current); 23 | console.log('available version is', event.available); 24 | }); 25 | this.updates.activated.subscribe(event => { 26 | console.log('old version was', event.previous); 27 | console.log('new version is', event.current); 28 | }); 29 | 30 | const appIsStable$ = this.appRef.isStable.pipe(first(isStable => isStable === true)); 31 | const everySixHours$ = interval(6 * 60 * 60 * 1000); 32 | const everySixHoursOnceAppIsStable$ = concat(appIsStable$, everySixHours$); 33 | 34 | everySixHoursOnceAppIsStable$.subscribe(() => this.updates.checkForUpdate()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /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 | "dataGroups": [ 31 | { 32 | "name": "musicList", 33 | "version": 0, 34 | "urls": [ 35 | "/music" 36 | ], 37 | "cacheConfig": { 38 | "maxSize": 100, 39 | "maxAge": "15d", 40 | "timeout": "5s", 41 | "strategy": "freshness" 42 | } 43 | }, 44 | { 45 | "name": "musicFile", 46 | "version": 0, 47 | "urls": [ 48 | "/file" 49 | ], 50 | "cacheConfig": { 51 | "maxSize": 50, 52 | "maxAge": "15d", 53 | "timeout": "0u", 54 | "strategy": "freshness" 55 | } 56 | } 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yun-shu-music-client", 3 | "version": "1.0.1", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve --host 0.0.0.0", 7 | "build": "ng build --prod", 8 | "test": "ng test", 9 | "lint": "ng lint", 10 | "e2e": "ng e2e" 11 | }, 12 | "private": true, 13 | "dependencies": { 14 | "@angular/animations": "~12.0.0", 15 | "@angular/cdk": "^12.0.0", 16 | "@angular/common": "~12.0.0", 17 | "@angular/compiler": "~12.0.0", 18 | "@angular/core": "~12.0.0", 19 | "@angular/forms": "~12.0.0", 20 | "@angular/material": "^12.0.0", 21 | "@angular/platform-browser": "~12.0.0", 22 | "@angular/platform-browser-dynamic": "~12.0.0", 23 | "@angular/router": "~12.0.0", 24 | "@angular/service-worker": "~12.0.0", 25 | "rxjs": "~6.6.7", 26 | "tslib": "^2.0.0", 27 | "zone.js": "~0.11.4" 28 | }, 29 | "devDependencies": { 30 | "@angular-devkit/build-angular": "~12.0.0", 31 | "@angular/cli": "~12.0.0", 32 | "@angular/compiler-cli": "~12.0.0", 33 | "@types/jasmine": "~3.6.0", 34 | "@types/jasminewd2": "~2.0.3", 35 | "@types/node": "^12.11.1", 36 | "codelyzer": "^6.0.0", 37 | "jasmine-core": "~3.6.0", 38 | "jasmine-spec-reporter": "~5.0.0", 39 | "karma": "~6.3.2", 40 | "karma-chrome-launcher": "~3.1.0", 41 | "karma-coverage-istanbul-reporter": "~3.0.2", 42 | "karma-jasmine": "~4.0.0", 43 | "karma-jasmine-html-reporter": "^1.5.0", 44 | "protractor": "~7.0.0", 45 | "ts-node": "~8.3.0", 46 | "tslint": "~6.1.0", 47 | "typescript": "~4.2.4" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "云舒音乐", 3 | "short_name": "云舒音乐", 4 | "theme_color": "#1976d2", 5 | "background_color": "#fafafa", 6 | "display": "standalone", 7 | "scope": "./", 8 | "start_url": "./", 9 | "icons": [ 10 | { 11 | "src": "assets/icons/icon-72x72.png", 12 | "sizes": "72x72", 13 | "type": "image/png", 14 | "purpose": "maskable any" 15 | }, 16 | { 17 | "src": "assets/icons/icon-96x96.png", 18 | "sizes": "96x96", 19 | "type": "image/png", 20 | "purpose": "maskable any" 21 | }, 22 | { 23 | "src": "assets/icons/icon-128x128.png", 24 | "sizes": "128x128", 25 | "type": "image/png", 26 | "purpose": "maskable any" 27 | }, 28 | { 29 | "src": "assets/icons/icon-144x144.png", 30 | "sizes": "144x144", 31 | "type": "image/png", 32 | "purpose": "maskable any" 33 | }, 34 | { 35 | "src": "assets/icons/icon-152x152.png", 36 | "sizes": "152x152", 37 | "type": "image/png", 38 | "purpose": "maskable any" 39 | }, 40 | { 41 | "src": "assets/icons/icon-192x192.png", 42 | "sizes": "192x192", 43 | "type": "image/png", 44 | "purpose": "maskable any" 45 | }, 46 | { 47 | "src": "assets/icons/icon-384x384.png", 48 | "sizes": "384x384", 49 | "type": "image/png", 50 | "purpose": "maskable any" 51 | }, 52 | { 53 | "src": "assets/icons/icon-512x512.png", 54 | "sizes": "512x512", 55 | "type": "image/png", 56 | "purpose": "maskable any" 57 | } 58 | ] 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Npm Web project with Node.JS and create release 2 | # author: https://github.com/itning 3 | 4 | name: Auto Build 5 | 6 | on: 7 | push: 8 | # Sequence of patterns matched against refs/tags 9 | tags: 10 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Npm Install And Build 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: '14' 23 | - run: npm install 24 | - run: npm run build 25 | - name: Archive Release 26 | uses: papeloto/action-zip@v1 27 | with: 28 | files: dist/ 29 | dest: release.zip 30 | - name: Create Release 31 | id: create_release 32 | uses: actions/create-release@v1 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token 35 | with: 36 | tag_name: ${{ github.ref }} 37 | release_name: Release ${{ github.ref }} 38 | body: | 39 | - This Release Build By Github Action. 40 | - [Click Me To See Change Log File.](https://github.com/${{ github.repository }}/blob/master/CHANGELOG.md) 41 | draft: false 42 | prerelease: false 43 | - name: Upload Release Asset 44 | id: upload-release-asset 45 | uses: actions/upload-release-asset@v1 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | with: 49 | upload_url: ${{ steps.create_release.outputs.upload_url }} 50 | asset_path: ./release.zip 51 | asset_name: release.zip 52 | asset_content_type: application/zip 53 | -------------------------------------------------------------------------------- /src/app/service/config.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {PlayMode} from './music-list.service'; 3 | 4 | @Injectable({ 5 | providedIn: 'root' 6 | }) 7 | export class ConfigService { 8 | private readonly VOLUME = 'volume'; 9 | private readonly PLAY_MODE = 'play_mode'; 10 | 11 | constructor() { 12 | } 13 | 14 | getDefaultVolume(): number { 15 | const defaultVolumeString: string | null = window.localStorage.getItem(this.VOLUME); 16 | if (!defaultVolumeString) { 17 | this.setDefaultVolume(1); 18 | return 1; 19 | } 20 | const defaultVolume = Number(defaultVolumeString); 21 | if (isNaN(defaultVolume)) { 22 | this.setDefaultVolume(1); 23 | return 1; 24 | } else { 25 | if (defaultVolume > 1) { 26 | return 1; 27 | } else if (defaultVolume < 0) { 28 | return 0; 29 | } else { 30 | return defaultVolume; 31 | } 32 | } 33 | } 34 | 35 | setDefaultVolume(defaultVolume: number): void { 36 | let volume: number; 37 | if (defaultVolume > 1) { 38 | volume = 1; 39 | } else if (defaultVolume < 0) { 40 | volume = 0; 41 | } else { 42 | volume = defaultVolume; 43 | } 44 | window.localStorage.setItem(this.VOLUME, volume.toString()); 45 | } 46 | 47 | setDefaultMusicPlayMode(mode: PlayMode): void { 48 | window.localStorage.setItem(this.PLAY_MODE, mode); 49 | } 50 | 51 | getDefaultMusicPlayMode(): PlayMode { 52 | const mode = window.localStorage.getItem(this.PLAY_MODE); 53 | switch (mode) { 54 | case PlayMode.LOOP: 55 | case PlayMode.REPEAT: 56 | case PlayMode.RANDOM: 57 | return mode; 58 | default: 59 | window.localStorage.removeItem(this.PLAY_MODE); 60 | return PlayMode.LOOP; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/styles.scss: -------------------------------------------------------------------------------- 1 | 2 | // Custom Theming for Angular Material 3 | // For more information: https://material.angular.io/guide/theming 4 | @use '~@angular/material' as mat; 5 | @import '~@angular/material/theming'; 6 | // Plus imports for other components in your app. 7 | 8 | // Include the common styles for Angular Material. We include this here so that you only 9 | // have to load a single css file for Angular Material in your app. 10 | // Be sure that you only ever include this mixin once! 11 | @include mat.core(); 12 | 13 | // Define the palettes for your theme using the Material Design palettes available in palette.scss 14 | // (imported above). For each palette, you can optionally specify a default, lighter, and darker 15 | // hue. Available color palettes: https://material.io/design/color/ 16 | $YunShuMusicClient-primary: mat.define-palette(mat.$indigo-palette); 17 | $YunShuMusicClient-accent: mat.define-palette(mat.$pink-palette, A200, A100, A400); 18 | 19 | // The warn palette is optional (defaults to red). 20 | $YunShuMusicClient-warn: mat.define-palette(mat.$red-palette); 21 | 22 | // Create the theme object. A theme consists of configurations for individual 23 | // theming systems such as "color" or "typography". 24 | $YunShuMusicClient-theme: mat.define-light-theme(( 25 | color: ( 26 | primary: $YunShuMusicClient-primary, 27 | accent: $YunShuMusicClient-accent, 28 | warn: $YunShuMusicClient-warn, 29 | ) 30 | )); 31 | 32 | // Include theme styles for core and each component used in your app. 33 | // Alternatively, you can import and @include the theme mixins for each component 34 | // that you are using. 35 | @include mat.all-component-themes($YunShuMusicClient-theme); 36 | 37 | /* You can add global styles to this file, and also import other style files */ 38 | 39 | html, body { height: 100%; } 40 | body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } 41 | -------------------------------------------------------------------------------- /src/app/module/shared/shared.module.ts: -------------------------------------------------------------------------------- 1 | import {NgModule} from '@angular/core'; 2 | import {CommonModule} from '@angular/common'; 3 | import {MatButtonModule} from '@angular/material/button'; 4 | import {MatListModule} from '@angular/material/list'; 5 | import {MatPaginatorModule} from '@angular/material/paginator'; 6 | import {MatInputModule} from '@angular/material/input'; 7 | import {FormsModule, ReactiveFormsModule} from '@angular/forms'; 8 | import {MatIconModule} from '@angular/material/icon'; 9 | import {MatToolbarModule} from '@angular/material/toolbar'; 10 | import {MatSidenavModule} from '@angular/material/sidenav'; 11 | import {MatSliderModule} from '@angular/material/slider'; 12 | import {SecondsToMinutesPipe} from '../../pipe/seconds-to-minutes.pipe'; 13 | import {MatTooltipModule} from '@angular/material/tooltip'; 14 | import {MatSnackBarModule} from '@angular/material/snack-bar'; 15 | import {MatProgressBarModule} from '@angular/material/progress-bar'; 16 | 17 | @NgModule({ 18 | declarations: [ 19 | SecondsToMinutesPipe 20 | ], 21 | imports: [ 22 | FormsModule, 23 | ReactiveFormsModule, 24 | CommonModule, 25 | MatButtonModule, 26 | MatListModule, 27 | MatPaginatorModule, 28 | MatInputModule, 29 | MatIconModule, 30 | MatToolbarModule, 31 | MatSidenavModule, 32 | MatSliderModule, 33 | MatTooltipModule, 34 | MatSnackBarModule, 35 | MatProgressBarModule 36 | ], 37 | exports: [ 38 | FormsModule, 39 | ReactiveFormsModule, 40 | CommonModule, 41 | MatButtonModule, 42 | MatListModule, 43 | MatPaginatorModule, 44 | MatInputModule, 45 | MatIconModule, 46 | MatToolbarModule, 47 | MatSidenavModule, 48 | MatSliderModule, 49 | SecondsToMinutesPipe, 50 | MatTooltipModule, 51 | MatSnackBarModule, 52 | MatProgressBarModule 53 | ] 54 | }) 55 | export class SharedModule { 56 | } 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 云舒音乐-云舒NAS音乐客户端 2 | 3 | [![GitHub stars](https://img.shields.io/github/stars/itning/YunShuMusicClient.svg?style=social&label=Stars)](https://github.com/itning/YunShuMusicClient/stargazers) 4 | [![GitHub forks](https://img.shields.io/github/forks/itning/YunShuMusicClient.svg?style=social&label=Fork)](https://github.com/itning/YunShuMusicClient/network/members) 5 | [![GitHub watchers](https://img.shields.io/github/watchers/itning/YunShuMusicClient.svg?style=social&label=Watch)](https://github.com/itning/YunShuMusicClient/watchers) 6 | [![GitHub followers](https://img.shields.io/github/followers/itning.svg?style=social&label=Follow)](https://github.com/itning?tab=followers) 7 | 8 | [![Auto Build](https://github.com/itning/YunShuMusicClient/actions/workflows/main.yml/badge.svg)](https://github.com/itning/YunShuMusicClient/actions/workflows/main.yml) 9 | [![GitHub issues](https://img.shields.io/github/issues/itning/YunShuMusicClient.svg)](https://github.com/itning/YunShuMusicClient/issues) 10 | [![GitHub license](https://img.shields.io/github/license/itning/YunShuMusicClient.svg)](https://github.com/itning/YunShuMusicClient/blob/master/LICENSE) 11 | [![GitHub last commit](https://img.shields.io/github/last-commit/itning/YunShuMusicClient.svg)](https://github.com/itning/YunShuMusicClient/commits) 12 | [![GitHub release](https://img.shields.io/github/release/itning/YunShuMusicClient.svg)](https://github.com/itning/YunShuMusicClient/releases) 13 | [![GitHub repo size in bytes](https://img.shields.io/github/repo-size/itning/YunShuMusicClient.svg)](https://github.com/itning/YunShuMusicClient) 14 | [![HitCount](http://hits.dwyl.com/itning/YunShuMusicClient.svg)](http://hits.dwyl.com/itning/YunShuMusicClient) 15 | [![language](https://img.shields.io/badge/language-TypeScript-green.svg)](https://github.com/itning/YunShuMusicClient) 16 | 17 | 服务端:[https://github.com/itning/yunshu-nas](https://github.com/itning/yunshu-nas) 18 | 19 | 使用Angular编写,支持PWA 20 | 21 | ## 预览 22 | ![index](pic/index.png) 23 | -------------------------------------------------------------------------------- /src/app/module/index/component/index/index.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 |
7 | 8 | 9 | 11 | 12 |
13 | 16 |
17 | 18 | 19 | 23 | 24 | 25 | 26 | 28 | 30 |
{{i + 1}}
31 |

{{item.name}}

32 |
{{item.singer}}
33 |
34 |
35 | 40 | 41 | 44 |
45 |
46 | 56 |
57 | -------------------------------------------------------------------------------- /src/app/module/index/component/sidenav/sidenav.component.ts: -------------------------------------------------------------------------------- 1 | import {ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild} from '@angular/core'; 2 | import {Subject} from 'rxjs'; 3 | import {MusicPlaybackDurationChangeEvent} from '../../../../service/music-play.service'; 4 | import {MatSliderChange} from '@angular/material/slider'; 5 | 6 | @Component({ 7 | selector: 'app-sidenav', 8 | templateUrl: './sidenav.component.html', 9 | styleUrls: ['./sidenav.component.scss'], 10 | changeDetection: ChangeDetectionStrategy.OnPush 11 | }) 12 | export class SidenavComponent implements OnInit { 13 | @ViewChild('musicCanvas', {static: true}) 14 | musicCanvas: ElementRef; 15 | @Input() 16 | onTimeChangeEvent: Subject; 17 | @Input() 18 | volumeValue = 100; 19 | @Input() 20 | lyric: string; 21 | @Output() 22 | volumeChange = new EventEmitter(); 23 | 24 | private canvasContext: CanvasRenderingContext2D; 25 | 26 | constructor() { 27 | } 28 | 29 | ngOnInit(): void { 30 | this.canvasContext = this.getCanvasContext(); 31 | this.onTimeChangeEvent.subscribe((time) => { 32 | this.drawTimeRanges(time.totalTime, time.timeRanges); 33 | this.drawCurrentTime(time.nowTime, time.totalTime); 34 | }); 35 | } 36 | 37 | private getCanvasContext(): CanvasRenderingContext2D { 38 | const context: CanvasRenderingContext2D = this.musicCanvas.nativeElement.getContext('2d'); 39 | context.fillStyle = 'lightgray'; 40 | context.fillRect(0, 0, this.musicCanvas.nativeElement.width, this.musicCanvas.nativeElement.height); 41 | return context; 42 | } 43 | 44 | private drawCurrentTime(currentTime: number, duration: number): void { 45 | const width = this.musicCanvas.nativeElement.width; 46 | const height = this.musicCanvas.nativeElement.height; 47 | const inc = width / duration; 48 | this.canvasContext.globalAlpha = 0.5; 49 | this.canvasContext.fillStyle = '#FFC75F'; 50 | this.canvasContext.fillRect(0, 0, currentTime * inc, height); 51 | } 52 | 53 | private drawTimeRanges(duration: number, timeRanges: TimeRanges): void { 54 | const width = this.musicCanvas.nativeElement.width; 55 | const height = this.musicCanvas.nativeElement.height; 56 | const inc = width / duration; 57 | 58 | this.canvasContext.globalAlpha = 1; 59 | this.canvasContext.fillStyle = 'lightgray'; 60 | this.canvasContext.fillRect(0, 0, this.musicCanvas.nativeElement.width, this.musicCanvas.nativeElement.height); 61 | this.canvasContext.fillStyle = '#ff4081'; 62 | for (let i = 0; i < timeRanges.length; i++) { 63 | const startX = timeRanges.start(i) * inc; 64 | const endX = timeRanges.end(i) * inc; 65 | const widthX = endX - startX; 66 | this.canvasContext.fillRect(startX, 0, widthX, height); 67 | } 68 | } 69 | 70 | onVolumeChange(change: MatSliderChange): void { 71 | this.volumeChange.emit(change.value); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/guide/browser-support 15 | */ 16 | 17 | /*************************************************************************************************** 18 | * BROWSER POLYFILLS 19 | */ 20 | 21 | /** IE10 and IE11 requires the following for NgClass support on SVG elements */ 22 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 23 | 24 | /** 25 | * Web Animations `@angular/platform-browser/animations` 26 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 27 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 28 | */ 29 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 30 | 31 | /** 32 | * By default, zone.js will patch all possible macroTask and DomEvents 33 | * user can disable parts of macroTask/DomEvents patch by setting following flags 34 | * because those flags need to be set before `zone.js` being loaded, and webpack 35 | * will put import in the top of bundle, so user need to create a separate file 36 | * in this directory (for example: zone-flags.ts), and put the following flags 37 | * into that file, and then add the following code before importing zone.js. 38 | * import './zone-flags'; 39 | * 40 | * The flags allowed in zone-flags.ts are listed here. 41 | * 42 | * The following flags will work for all browsers. 43 | * 44 | * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 45 | * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 46 | * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 47 | * 48 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 49 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 50 | * 51 | * (window as any).__Zone_enable_cross_context_check = true; 52 | * 53 | */ 54 | 55 | /*************************************************************************************************** 56 | * Zone JS is required by default for Angular itself. 57 | */ 58 | import 'zone.js'; // Included with Angular CLI. 59 | 60 | 61 | /*************************************************************************************************** 62 | * APPLICATION IMPORTS 63 | */ 64 | -------------------------------------------------------------------------------- /src/app/module/index/component/control/control.component.ts: -------------------------------------------------------------------------------- 1 | import {ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, OnInit, Output, ViewChild} from '@angular/core'; 2 | import {MatSliderChange} from '@angular/material/slider'; 3 | import {PlayMode} from '../../../../service/music-list.service'; 4 | 5 | @Component({ 6 | selector: 'app-control', 7 | templateUrl: './control.component.html', 8 | styleUrls: ['./control.component.scss'], 9 | changeDetection: ChangeDetectionStrategy.OnPush 10 | }) 11 | export class ControlComponent implements OnInit { 12 | @ViewChild('musicControlInfo', {static: true}) 13 | musicControlInfo: ElementRef; 14 | musicControlInfoDiv: HTMLDivElement; 15 | @Input() 16 | nowPlaying: boolean; 17 | @Input() 18 | nowTime: number; 19 | @Input() 20 | totalTime: number; 21 | @Input() 22 | info: string; 23 | @Input() 24 | nowPlayMode = PlayMode.LOOP; 25 | @Output() 26 | timeChange: EventEmitter = new EventEmitter(); 27 | @Output() 28 | playStatusChange: EventEmitter = new EventEmitter(); 29 | @Output() 30 | playModeChange: EventEmitter = new EventEmitter(); 31 | 32 | sliderStep = 1; 33 | 34 | private intervalNumber = 0; 35 | private speed = 100; 36 | private lastScrollLeft = -1; 37 | private isRightDirection = true; 38 | 39 | constructor() { 40 | } 41 | 42 | ngOnInit(): void { 43 | this.musicControlInfoDiv = this.musicControlInfo.nativeElement; 44 | this.intervalNumber = setInterval( 45 | this.scrollHorizontally(this.musicControlInfoDiv, this.isRightDirection, this.lastScrollLeft), this.speed); 46 | } 47 | 48 | private scrollHorizontally(musicControlInfoDiv: HTMLDivElement, isRightDirection: boolean, lastScrollLeft: number): () => void { 49 | return () => { 50 | if (lastScrollLeft === musicControlInfoDiv.scrollLeft || !isRightDirection) { 51 | isRightDirection = false; 52 | lastScrollLeft = -1; 53 | musicControlInfoDiv.scrollLeft--; 54 | if (musicControlInfoDiv.scrollLeft <= 0) { 55 | isRightDirection = true; 56 | } 57 | } else { 58 | lastScrollLeft = musicControlInfoDiv.scrollLeft; 59 | isRightDirection = true; 60 | musicControlInfoDiv.scrollLeft++; 61 | } 62 | }; 63 | } 64 | 65 | stopInterval(): void { 66 | clearInterval(this.intervalNumber); 67 | } 68 | 69 | startInterval(): void { 70 | this.intervalNumber = setInterval( 71 | this.scrollHorizontally(this.musicControlInfoDiv, this.isRightDirection, this.lastScrollLeft), this.speed); 72 | } 73 | 74 | onTimeChange(change: MatSliderChange): void { 75 | this.timeChange.emit(change.value); 76 | } 77 | 78 | onPlayChange(event: PlayEvent = null): void { 79 | if (event === null) { 80 | if (this.nowPlaying) { 81 | event = PlayEvent.PAUSE; 82 | } else { 83 | event = PlayEvent.PLAY; 84 | } 85 | } 86 | this.playStatusChange.emit(event); 87 | } 88 | 89 | changePlayMode(): void { 90 | switch (this.nowPlayMode) { 91 | case PlayMode.LOOP: 92 | this.nowPlayMode = PlayMode.REPEAT; 93 | break; 94 | case PlayMode.REPEAT: 95 | this.nowPlayMode = PlayMode.RANDOM; 96 | break; 97 | case PlayMode.RANDOM: 98 | this.nowPlayMode = PlayMode.LOOP; 99 | break; 100 | } 101 | this.playModeChange.emit(this.nowPlayMode); 102 | } 103 | 104 | getNowPlayModeDesc(): string { 105 | switch (this.nowPlayMode) { 106 | case PlayMode.LOOP: 107 | return '列表循环'; 108 | case PlayMode.REPEAT: 109 | return '单曲循环'; 110 | case PlayMode.RANDOM: 111 | return '随机'; 112 | } 113 | } 114 | 115 | } 116 | 117 | export enum PlayEvent { 118 | PLAY, 119 | PAUSE, 120 | NEXT, 121 | PREVIOUS 122 | } 123 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint:recommended", 3 | "rulesDirectory": [ 4 | "codelyzer" 5 | ], 6 | "rules": { 7 | "align": { 8 | "options": [ 9 | "parameters", 10 | "statements" 11 | ] 12 | }, 13 | "array-type": false, 14 | "arrow-return-shorthand": true, 15 | "curly": true, 16 | "deprecation": { 17 | "severity": "warning" 18 | }, 19 | "eofline": true, 20 | "import-blacklist": [ 21 | true, 22 | "rxjs/Rx" 23 | ], 24 | "import-spacing": true, 25 | "indent": { 26 | "options": [ 27 | "spaces" 28 | ] 29 | }, 30 | "max-classes-per-file": false, 31 | "max-line-length": [ 32 | true, 33 | 140 34 | ], 35 | "member-ordering": [ 36 | true, 37 | { 38 | "order": [ 39 | "static-field", 40 | "instance-field", 41 | "static-method", 42 | "instance-method" 43 | ] 44 | } 45 | ], 46 | "no-console": [ 47 | true, 48 | "debug", 49 | "info", 50 | "time", 51 | "timeEnd", 52 | "trace" 53 | ], 54 | "no-empty": false, 55 | "no-inferrable-types": [ 56 | true, 57 | "ignore-params" 58 | ], 59 | "no-non-null-assertion": true, 60 | "no-redundant-jsdoc": true, 61 | "no-switch-case-fall-through": true, 62 | "no-var-requires": false, 63 | "object-literal-key-quotes": [ 64 | true, 65 | "as-needed" 66 | ], 67 | "quotemark": [ 68 | true, 69 | "single" 70 | ], 71 | "semicolon": { 72 | "options": [ 73 | "always" 74 | ] 75 | }, 76 | "space-before-function-paren": { 77 | "options": { 78 | "anonymous": "never", 79 | "asyncArrow": "always", 80 | "constructor": "never", 81 | "method": "never", 82 | "named": "never" 83 | } 84 | }, 85 | "typedef": [ 86 | true, 87 | "call-signature" 88 | ], 89 | "typedef-whitespace": { 90 | "options": [ 91 | { 92 | "call-signature": "nospace", 93 | "index-signature": "nospace", 94 | "parameter": "nospace", 95 | "property-declaration": "nospace", 96 | "variable-declaration": "nospace" 97 | }, 98 | { 99 | "call-signature": "onespace", 100 | "index-signature": "onespace", 101 | "parameter": "onespace", 102 | "property-declaration": "onespace", 103 | "variable-declaration": "onespace" 104 | } 105 | ] 106 | }, 107 | "variable-name": { 108 | "options": [ 109 | "ban-keywords", 110 | "check-format", 111 | "allow-pascal-case" 112 | ] 113 | }, 114 | "whitespace": { 115 | "options": [ 116 | "check-branch", 117 | "check-decl", 118 | "check-operator", 119 | "check-separator", 120 | "check-type", 121 | "check-typecast" 122 | ] 123 | }, 124 | "component-class-suffix": true, 125 | "contextual-lifecycle": true, 126 | "directive-class-suffix": true, 127 | "no-conflicting-lifecycle": true, 128 | "no-host-metadata-property": true, 129 | "no-input-rename": true, 130 | "no-inputs-metadata-property": true, 131 | "no-output-native": true, 132 | "no-output-on-prefix": true, 133 | "no-output-rename": true, 134 | "no-outputs-metadata-property": true, 135 | "template-banana-in-box": true, 136 | "template-no-negated-async": true, 137 | "use-lifecycle-interface": true, 138 | "use-pipe-transform-interface": true, 139 | "directive-selector": [ 140 | true, 141 | "attribute", 142 | "app", 143 | "camelCase" 144 | ], 145 | "component-selector": [ 146 | true, 147 | "element", 148 | "app", 149 | "kebab-case" 150 | ] 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "YunShuMusicClient": { 7 | "projectType": "application", 8 | "schematics": { 9 | "@schematics/angular:component": { 10 | "style": "scss" 11 | } 12 | }, 13 | "root": "", 14 | "sourceRoot": "src", 15 | "prefix": "app", 16 | "architect": { 17 | "build": { 18 | "builder": "@angular-devkit/build-angular:browser", 19 | "options": { 20 | "outputPath": "dist/YunShuMusicClient", 21 | "index": "src/index.html", 22 | "main": "src/main.ts", 23 | "polyfills": "src/polyfills.ts", 24 | "tsConfig": "tsconfig.app.json", 25 | "assets": [ 26 | "src/favicon.ico", 27 | "src/assets", 28 | "src/manifest.webmanifest" 29 | ], 30 | "styles": [ 31 | "src/styles.scss" 32 | ], 33 | "scripts": [], 34 | "vendorChunk": true, 35 | "extractLicenses": false, 36 | "buildOptimizer": false, 37 | "sourceMap": true, 38 | "optimization": false, 39 | "namedChunks": true 40 | }, 41 | "configurations": { 42 | "production": { 43 | "fileReplacements": [ 44 | { 45 | "replace": "src/environments/environment.ts", 46 | "with": "src/environments/environment.prod.ts" 47 | } 48 | ], 49 | "optimization": true, 50 | "outputHashing": "all", 51 | "sourceMap": false, 52 | "namedChunks": false, 53 | "extractLicenses": true, 54 | "vendorChunk": false, 55 | "buildOptimizer": true, 56 | "budgets": [ 57 | { 58 | "type": "initial", 59 | "maximumWarning": "2mb", 60 | "maximumError": "5mb" 61 | }, 62 | { 63 | "type": "anyComponentStyle", 64 | "maximumWarning": "6kb", 65 | "maximumError": "10kb" 66 | } 67 | ], 68 | "serviceWorker": true, 69 | "ngswConfigPath": "ngsw-config.json" 70 | } 71 | } 72 | }, 73 | "serve": { 74 | "builder": "@angular-devkit/build-angular:dev-server", 75 | "options": { 76 | "browserTarget": "YunShuMusicClient:build" 77 | }, 78 | "configurations": { 79 | "production": { 80 | "browserTarget": "YunShuMusicClient:build:production" 81 | } 82 | } 83 | }, 84 | "extract-i18n": { 85 | "builder": "@angular-devkit/build-angular:extract-i18n", 86 | "options": { 87 | "browserTarget": "YunShuMusicClient:build" 88 | } 89 | }, 90 | "test": { 91 | "builder": "@angular-devkit/build-angular:karma", 92 | "options": { 93 | "main": "src/test.ts", 94 | "polyfills": "src/polyfills.ts", 95 | "tsConfig": "tsconfig.spec.json", 96 | "karmaConfig": "karma.conf.js", 97 | "assets": [ 98 | "src/favicon.ico", 99 | "src/assets", 100 | "src/manifest.webmanifest" 101 | ], 102 | "styles": [ 103 | "src/styles.scss" 104 | ], 105 | "scripts": [] 106 | } 107 | }, 108 | "lint": { 109 | "builder": "@angular-devkit/build-angular:tslint", 110 | "options": { 111 | "tsConfig": [ 112 | "tsconfig.app.json", 113 | "tsconfig.spec.json", 114 | "e2e/tsconfig.json" 115 | ], 116 | "exclude": [ 117 | "**/node_modules/**" 118 | ] 119 | } 120 | }, 121 | "e2e": { 122 | "builder": "@angular-devkit/build-angular:protractor", 123 | "options": { 124 | "protractorConfig": "e2e/protractor.conf.js", 125 | "devServerTarget": "YunShuMusicClient:serve" 126 | }, 127 | "configurations": { 128 | "production": { 129 | "devServerTarget": "YunShuMusicClient:serve:production" 130 | } 131 | } 132 | } 133 | } 134 | }}, 135 | "defaultProject": "YunShuMusicClient" 136 | } 137 | -------------------------------------------------------------------------------- /src/app/service/music-list.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {Music} from '../entity/Music'; 3 | import {Observable} from 'rxjs'; 4 | import {HttpClient} from '@angular/common/http'; 5 | import {Page} from '../entity/page/Page'; 6 | import {environment} from '../../environments/environment'; 7 | 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class MusicListService { 12 | private static randomPlayedList = new Set(); 13 | private static randomPlayedStack: string[] = []; 14 | 15 | constructor(private http: HttpClient) { 16 | } 17 | 18 | private static listLoopToGetTheNextSong(nowPlayingMusicId: string, originalResponse: Page): Music { 19 | let index = 0; 20 | if (nowPlayingMusicId !== undefined) { 21 | const findIndex = originalResponse.content.findIndex(music => music.musicId === nowPlayingMusicId); 22 | if (findIndex !== -1) { 23 | if (findIndex + 1 < originalResponse.content.length) { 24 | index = findIndex + 1; 25 | } 26 | } 27 | } 28 | return originalResponse.content[index]; 29 | } 30 | 31 | private static listLoopToGetThePreviousSong(nowPlayingMusicId: string, originalResponse: Page): Music { 32 | let index = originalResponse.content.length - 1; 33 | if (nowPlayingMusicId !== undefined) { 34 | const findIndex = originalResponse.content.findIndex(music => music.musicId === nowPlayingMusicId); 35 | if (findIndex !== -1) { 36 | if (findIndex - 1 >= 0) { 37 | index = findIndex - 1; 38 | } 39 | } 40 | } 41 | return originalResponse.content[index]; 42 | } 43 | 44 | private static getPreviousSongRandomly(originalResponse: Page): Music { 45 | const musicId = MusicListService.randomPlayedStack.pop(); 46 | if (musicId === undefined) { 47 | return MusicListService.getSongsRandomly(undefined, originalResponse); 48 | } 49 | return originalResponse.content.find(music => music.musicId === musicId); 50 | } 51 | 52 | private static getSongsRandomly(nowPlayingMusicId: string, originalResponse: Page): Music { 53 | if (nowPlayingMusicId === undefined) { 54 | return originalResponse.content[Math.floor(Math.random() * originalResponse.content.length)]; 55 | } 56 | // 播放列表只有一个或零个 57 | if (originalResponse.content.length < 2) { 58 | return originalResponse.content.find(music => music.musicId === nowPlayingMusicId); 59 | } 60 | // 播放列表只有两首歌 61 | if (originalResponse.content.length === 2) { 62 | return originalResponse.content.find(music => music.musicId !== nowPlayingMusicId); 63 | } 64 | MusicListService.randomPlayedStack.push(nowPlayingMusicId); 65 | MusicListService.randomPlayedList.add(nowPlayingMusicId); 66 | let canPlayList = originalResponse.content.filter(music => !MusicListService.randomPlayedList.has(music.musicId)); 67 | if (canPlayList.length === 0) { 68 | MusicListService.randomPlayedList.clear(); 69 | MusicListService.randomPlayedList.add(nowPlayingMusicId); 70 | canPlayList = originalResponse.content.filter(music => !MusicListService.randomPlayedList.has(music.musicId)); 71 | } 72 | const index = Math.floor(Math.random() * canPlayList.length); 73 | return canPlayList[index]; 74 | } 75 | 76 | getMusicList(page = 0, size = 2000): Observable> { 77 | return this.http.get>(`${environment.apiHost}music?page=${page}&size=${size}`); 78 | } 79 | 80 | search(keywords: string, page = 0, size = 2000): Observable> { 81 | return this.http.get>(`${environment.apiHost}music/search?keyword=${keywords}&page=${page}&size=${size}`); 82 | } 83 | 84 | getNextMusic(mode: PlayMode, nowPlayingMusicId: string, originalResponse: Page): Music { 85 | switch (mode) { 86 | case PlayMode.LOOP: 87 | case PlayMode.REPEAT: 88 | return MusicListService.listLoopToGetTheNextSong(nowPlayingMusicId, originalResponse); 89 | case PlayMode.RANDOM: 90 | return MusicListService.getSongsRandomly(nowPlayingMusicId, originalResponse); 91 | } 92 | } 93 | 94 | getPreviousMusic(mode: PlayMode, nowPlayingMusicId: string, originalResponse: Page): Music { 95 | switch (mode) { 96 | case PlayMode.LOOP: 97 | case PlayMode.REPEAT: 98 | return MusicListService.listLoopToGetThePreviousSong(nowPlayingMusicId, originalResponse); 99 | case PlayMode.RANDOM: 100 | return MusicListService.getPreviousSongRandomly(originalResponse); 101 | } 102 | } 103 | } 104 | 105 | /** 106 | * 列表循环 loop 107 | * 单曲循环 repeat 108 | * 随机 shuffle 109 | */ 110 | export enum PlayMode { 111 | LOOP = 'loop', 112 | REPEAT = 'repeat_one', 113 | RANDOM = 'shuffle' 114 | } 115 | -------------------------------------------------------------------------------- /src/app/service/music-play.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {Observable, Subscriber} from 'rxjs'; 3 | 4 | /** 5 | * 音乐服务 6 | * 我只关心播放状态和播放什么 7 | */ 8 | @Injectable({ 9 | providedIn: 'root' 10 | }) 11 | export class MusicPlayService { 12 | private readonly audio = new Audio(); 13 | /** 14 | * 播放状态改变事件发射器 15 | */ 16 | private playObserver: Subscriber; 17 | /** 18 | * 时间状态改变事件发射器 19 | */ 20 | private timeObserver: Subscriber; 21 | /** 22 | * 播放结束事件发射器 23 | */ 24 | private endObserver: Subscriber; 25 | 26 | private loadObserver: Subscriber; 27 | /** 28 | * 播放状态改变事件 29 | */ 30 | onPlayChangeEvent: Observable; 31 | /** 32 | * 时间状态改变事件 33 | */ 34 | onTimeChangeEvent: Observable; 35 | /** 36 | * 播放结束事件 37 | */ 38 | onPlayEndEvent: Observable; 39 | 40 | onLoadEvent: Observable; 41 | 42 | constructor() { 43 | this.onPlayChangeEvent = new Observable((observer) => { 44 | this.playObserver = observer; 45 | }); 46 | this.onTimeChangeEvent = new Observable((observer) => { 47 | this.timeObserver = observer; 48 | }); 49 | this.onPlayEndEvent = new Observable((observer) => { 50 | this.endObserver = observer; 51 | }); 52 | this.onLoadEvent = new Observable((observer) => { 53 | this.loadObserver = observer; 54 | }); 55 | 56 | this.audio.ondurationchange = this.musicChangeEventHandlers; 57 | this.audio.ontimeupdate = this.musicChangeEventHandlers; 58 | 59 | this.audio.onended = () => { 60 | this.changePlayStatus(false); 61 | this.endObserver.next(); 62 | }; 63 | 64 | this.audio.onprogress = () => this.loadObserver.next(MusicLoadEvent.LOADING); 65 | this.audio.oncanplay = () => this.loadObserver.next(MusicLoadEvent.STARTED); 66 | this.audio.oncanplaythrough = () => this.loadObserver.next(MusicLoadEvent.STARTED); 67 | 68 | this.audio.onplay = () => this.changePlayStatus(true); 69 | this.audio.onpause = () => this.changePlayStatus(false); 70 | } 71 | 72 | private musicChangeEventHandlers = () => { 73 | if (this.audio.currentTime && this.audio.duration) { 74 | this.timeObserver.next(new MusicPlaybackDurationChangeEvent(this.audio.currentTime, this.audio.duration, this.audio.buffered)); 75 | } 76 | // tslint:disable-next-line 77 | }; 78 | 79 | start(src: string): Observable { 80 | this.loadObserver.next(MusicLoadEvent.START); 81 | this.audio.src = src; 82 | this.audio.load(); 83 | this.audio.pause(); 84 | return this.play(); 85 | } 86 | 87 | seek(position: number): Observable { 88 | return new Observable((observer) => { 89 | if (this.isPlayingNow()) { 90 | if (position < 0) { 91 | position = 0; 92 | } 93 | if (position > this.audio.duration) { 94 | position = this.audio.duration; 95 | } 96 | this.audio.currentTime = position; 97 | observer.next(true); 98 | } else { 99 | observer.next(false); 100 | } 101 | observer.complete(); 102 | }); 103 | } 104 | 105 | volume(value: number): void { 106 | if (value < 0) { 107 | value = 0; 108 | } 109 | if (value > 1) { 110 | value = 1; 111 | } 112 | this.audio.volume = value; 113 | } 114 | 115 | play(): Observable { 116 | return new Observable((observer) => { 117 | if (!this.isPlayingNow()) { 118 | this.audio.play() 119 | .then(() => { 120 | this.changePlayStatus(true); 121 | observer.next(true); 122 | }) 123 | .catch(error => { 124 | console.error(error); 125 | observer.next(false); 126 | }) 127 | .finally(() => { 128 | observer.complete(); 129 | }); 130 | } else { 131 | observer.next(false); 132 | observer.complete(); 133 | } 134 | }); 135 | } 136 | 137 | pause(): Observable { 138 | return new Observable((observer) => { 139 | if (this.isPlayingNow()) { 140 | this.audio.pause(); 141 | this.changePlayStatus(false); 142 | observer.next(true); 143 | } else { 144 | observer.next(false); 145 | } 146 | observer.complete(); 147 | }); 148 | } 149 | 150 | private changePlayStatus(status: boolean): void { 151 | this.playObserver.next(status); 152 | } 153 | 154 | isPlayingNow(): boolean { 155 | return !this.audio.paused; 156 | } 157 | } 158 | 159 | /** 160 | * 音乐时长改变事件 161 | */ 162 | export class MusicPlaybackDurationChangeEvent { 163 | readonly nowTime: number; 164 | readonly totalTime: number; 165 | readonly timeRanges: TimeRanges; 166 | 167 | constructor(nowTime: number, totalTime: number, timeRanges: TimeRanges) { 168 | this.nowTime = nowTime; 169 | this.totalTime = totalTime; 170 | this.timeRanges = timeRanges; 171 | } 172 | } 173 | 174 | export enum MusicLoadEvent { 175 | /** 176 | * 开始加载,发送网络请求,但是还没收到响应 177 | */ 178 | START, 179 | /** 180 | * 接收数据中,还不可以播放 181 | */ 182 | LOADING, 183 | /** 184 | * 可以播放了 185 | */ 186 | STARTED 187 | } 188 | -------------------------------------------------------------------------------- /src/app/service/lyric.service.ts: -------------------------------------------------------------------------------- 1 | import {Injectable} from '@angular/core'; 2 | import {FileService} from './file.service'; 3 | import {Observable, Subscriber} from 'rxjs'; 4 | 5 | @Injectable({ 6 | providedIn: 'root' 7 | }) 8 | export class LyricService { 9 | /** 10 | * 歌词数组,每一项都是一行歌词 11 | * @private 12 | */ 13 | private lyricArray: LrcResult[] = []; 14 | /** 15 | * 歌词元数据信息 16 | * @private 17 | */ 18 | private metaInfoArray: LrcResult[] = []; 19 | /** 20 | * 歌词的偏移量( +/- 以毫秒为单位加快或延後歌詞的播放) 21 | * @private 22 | */ 23 | private offset = 0; 24 | /** 25 | * 播放状态改变事件发射器 26 | */ 27 | private lyricObserver: Subscriber; 28 | /** 29 | * 播放状态改变事件 30 | */ 31 | onLyricChangeEvent: Observable; 32 | 33 | constructor(private fileService: FileService) { 34 | this.onLyricChangeEvent = new Observable((observer) => { 35 | this.lyricObserver = observer; 36 | }); 37 | } 38 | 39 | /** 40 | * 按行解析歌词文件 41 | * @param line 每一行 42 | * @private 43 | */ 44 | private lyricType(line: string): LrcResult | null { 45 | const hourSplitIndex: number = line.indexOf(':'); 46 | const type: string = line.substring(1, hourSplitIndex).trim(); 47 | switch (type) { 48 | case 'ti': { 49 | // 歌词(歌曲)的标题 50 | return new LrcResult(LrcType.META_INFO, 0, line.substring(hourSplitIndex + 1, line.indexOf(']'))); 51 | } 52 | case 'ar': { 53 | // 演出者-歌手 54 | return new LrcResult(LrcType.META_INFO, 0, line.substring(hourSplitIndex + 1, line.indexOf(']'))); 55 | } 56 | case 'al': { 57 | // 本歌所在的唱片集 58 | return new LrcResult(LrcType.META_INFO, 0, line.substring(hourSplitIndex + 1, line.indexOf(']'))); 59 | } 60 | case 'au': { 61 | // 歌詞作者-作曲家 62 | return new LrcResult(LrcType.META_INFO, 0, line.substring(hourSplitIndex + 1, line.indexOf(']'))); 63 | } 64 | case 're': { 65 | // 创建此LRC文件的播放器或编辑器 66 | return new LrcResult(LrcType.META_INFO, 0, line.substring(hourSplitIndex + 1, line.indexOf(']'))); 67 | } 68 | case 've': { 69 | // 程序的版本 70 | return new LrcResult(LrcType.META_INFO, 0, line.substring(hourSplitIndex + 1, line.indexOf(']'))); 71 | } 72 | case 'by': { 73 | // 此LRC文件的创建者 74 | return new LrcResult(LrcType.META_INFO, 0, line.substring(hourSplitIndex + 1, line.indexOf(']'))); 75 | } 76 | case 'offset': { 77 | // +/- 以毫秒为单位加快或延後歌詞的播放 78 | this.offset = Number(line.substring(hourSplitIndex + 1, line.indexOf(']'))); 79 | return null; 80 | } 81 | default: { 82 | // 可能是时间 83 | const metaInfoEndIndex: number = line.indexOf(']'); 84 | const minuteSplitIndex: number = line.indexOf('.'); 85 | const text: string = line.substring(metaInfoEndIndex + 1); 86 | const time: string = line.substring(1, metaInfoEndIndex); 87 | 88 | const minute: number = Number(time.substring(0, hourSplitIndex - 1)); 89 | const second: number = Number(time.substring(hourSplitIndex, minuteSplitIndex - 1)); 90 | const hundredthsOfASecond: number = Number(time.substring(minuteSplitIndex)); 91 | 92 | const totalSeconds: number = (minute * 60 + second + hundredthsOfASecond / 100) + this.offset; 93 | 94 | const trueTotalSeconds: number = totalSeconds < 0.2 ? 0 : totalSeconds - 0.2; 95 | 96 | return new LrcResult(LrcType.LYRIC, trueTotalSeconds, text); 97 | } 98 | } 99 | } 100 | 101 | /** 102 | * 更新播放时间 103 | * @param now 104 | */ 105 | update(now: number): void { 106 | for (let index = 0; index < this.lyricArray.length; index++) { 107 | if (this.lyricArray[index].seconds > now) { 108 | continue; 109 | } 110 | if (this.lyricArray.length === index + 1 111 | || this.lyricArray[index + 1].seconds >= now) { 112 | this.lyricObserver.next(this.lyricArray[index].text); 113 | } 114 | } 115 | } 116 | 117 | /** 118 | * 歌词元数据信息 119 | */ 120 | metaInfo(): string[] { 121 | return this.metaInfoArray.map(item => item.text); 122 | } 123 | 124 | /** 125 | * 初始化解析歌词 126 | * @param lyricId 歌词ID 127 | */ 128 | load(lyricId: string): void { 129 | this.offset = 0; 130 | this.lyricArray = []; 131 | this.metaInfoArray = []; 132 | this.fileService.getLyricFile(lyricId) 133 | .subscribe(file => { 134 | if (file.length > 0) { 135 | const line = file.split('\n'); 136 | line.forEach(item => { 137 | const lrcResult = this.lyricType(item); 138 | if (lrcResult) { 139 | if (lrcResult.type === LrcType.LYRIC) { 140 | this.lyricArray.push(lrcResult); 141 | } 142 | if (lrcResult.type === LrcType.META_INFO) { 143 | this.metaInfoArray.push(lrcResult); 144 | } 145 | } 146 | }); 147 | } 148 | this.lyricArray.sort((a, b) => a.seconds > b.seconds ? 1 : a.seconds === b.seconds ? 0 : -1); 149 | if (this.lyricArray.length > 0 && this.metaInfoArray.length > 0) { 150 | const firstLyric: LrcResult = this.lyricArray[0]; 151 | if (firstLyric.seconds > 0) { 152 | let index = 0; 153 | const itemMetaInfoSecond = firstLyric.seconds / this.metaInfoArray.length; 154 | this.metaInfoArray.forEach(item => item.seconds = itemMetaInfoSecond * index++); 155 | this.lyricArray.concat(this.metaInfoArray); 156 | } 157 | } 158 | if (this.lyricArray.length === 0) { 159 | this.lyricObserver.next('暂无歌词'); 160 | } 161 | }); 162 | } 163 | } 164 | 165 | /** 166 | * 歌词信息 167 | */ 168 | class LrcResult { 169 | /** 170 | * 歌词类型 171 | */ 172 | type: LrcType; 173 | /** 174 | * 对应时间 175 | */ 176 | seconds: number; 177 | /** 178 | * 歌词 179 | */ 180 | text: string; 181 | 182 | constructor(type: LrcType, seconds: number, text: string) { 183 | this.type = type; 184 | this.seconds = seconds; 185 | this.text = text; 186 | } 187 | } 188 | 189 | /** 190 | * 歌词类型 191 | */ 192 | enum LrcType { 193 | /** 194 | * 歌词 195 | */ 196 | LYRIC, 197 | /** 198 | * 元数据信息 199 | */ 200 | META_INFO 201 | } 202 | -------------------------------------------------------------------------------- /src/app/module/index/component/index/index.component.ts: -------------------------------------------------------------------------------- 1 | import {Component, HostListener, OnInit} from '@angular/core'; 2 | import {Page} from '../../../../entity/page/Page'; 3 | import {Music} from '../../../../entity/Music'; 4 | import {PageEvent} from '@angular/material/paginator'; 5 | import {HttpClient} from '@angular/common/http'; 6 | import {MusicLoadEvent, MusicPlaybackDurationChangeEvent, MusicPlayService} from '../../../../service/music-play.service'; 7 | import {FileService} from '../../../../service/file.service'; 8 | import {MusicListService, PlayMode} from '../../../../service/music-list.service'; 9 | import {PlayEvent} from '../control/control.component'; 10 | import {MatSnackBar} from '@angular/material/snack-bar'; 11 | import {Subject, timer} from 'rxjs'; 12 | import {ConfigService} from '../../../../service/config.service'; 13 | import {ProgressBarMode} from '@angular/material/progress-bar/progress-bar'; 14 | import {filter, mapTo, switchMap} from 'rxjs/operators'; 15 | import {Title} from '@angular/platform-browser'; 16 | import {LyricService} from '../../../../service/lyric.service'; 17 | 18 | @Component({ 19 | selector: 'app-index', 20 | templateUrl: './index.component.html', 21 | styleUrls: ['./index.component.scss'] 22 | }) 23 | export class IndexComponent implements OnInit { 24 | private isSearch = false; 25 | 26 | playMode = PlayMode.LOOP; 27 | searchKeyword: string; 28 | list: Music[]; 29 | originalResponse: Page = new Page(); 30 | nowPlayingMusicId: string; 31 | nowPlayMusicInfo: string; 32 | isPlay = false; 33 | musicTimeChangeEvent: MusicPlaybackDurationChangeEvent = new MusicPlaybackDurationChangeEvent(0, 0.1, null); 34 | onTimeChangeEventSubject = new Subject(); 35 | volumeValue = 1; 36 | progressMode: ProgressBarMode; 37 | lyricString = ''; 38 | 39 | constructor(private http: HttpClient, 40 | private snackBar: MatSnackBar, 41 | private musicPlayService: MusicPlayService, 42 | private musicListService: MusicListService, 43 | private fileService: FileService, 44 | private configService: ConfigService, 45 | private titleService: Title, 46 | private lyricsService: LyricService) { 47 | } 48 | 49 | ngOnInit(): void { 50 | this.initDefaultConfig(); 51 | this.handlerMusicPlayServiceEvent(); 52 | this.musicListService.getMusicList().subscribe(music => this.refreshMusicList(music)); 53 | } 54 | 55 | private handlerMusicPlayServiceEvent(): void { 56 | this.musicPlayService.onPlayChangeEvent.subscribe((status) => { 57 | this.isPlay = status; 58 | }); 59 | this.musicPlayService.onTimeChangeEvent.subscribe(this.onTimeChangeEventSubject); 60 | this.onTimeChangeEventSubject.subscribe((time) => { 61 | this.lyricsService.update(time.nowTime); 62 | this.musicTimeChangeEvent = time; 63 | }); 64 | this.musicPlayService.onPlayEndEvent.subscribe(() => { 65 | // 单曲循环 66 | if (this.playMode === PlayMode.REPEAT) { 67 | this.musicPlayService.start(this.fileService.getMusicFileUrl(this.nowPlayingMusicId)).subscribe((status) => { 68 | if (!status) { 69 | this.snackBar.open('播放失败', '我知道了'); 70 | } 71 | } 72 | ); 73 | } else { 74 | this.onPlayStatusChange(PlayEvent.NEXT); 75 | } 76 | }); 77 | 78 | const musicLoadEventSubject = new Subject(); 79 | this.musicPlayService.onLoadEvent.subscribe(musicLoadEventSubject); 80 | 81 | musicLoadEventSubject 82 | .pipe( 83 | filter(value => value === MusicLoadEvent.LOADING), 84 | switchMap(() => timer(2000).pipe(mapTo(MusicLoadEvent.STARTED))) 85 | ) 86 | .subscribe(() => { 87 | this.progressMode = null; 88 | }); 89 | musicLoadEventSubject.subscribe((event) => { 90 | switch (event) { 91 | case MusicLoadEvent.START: 92 | this.progressMode = 'indeterminate'; 93 | break; 94 | case MusicLoadEvent.LOADING: 95 | this.progressMode = 'buffer'; 96 | break; 97 | case MusicLoadEvent.STARTED: 98 | this.progressMode = null; 99 | break; 100 | } 101 | }); 102 | 103 | this.lyricsService.onLyricChangeEvent.subscribe(lyric => { 104 | if (lyric) { 105 | this.lyricString = lyric; 106 | this.nowPlayMusicInfo = lyric; 107 | } 108 | }); 109 | } 110 | 111 | private initDefaultConfig(): void { 112 | const defaultVolume = this.configService.getDefaultVolume(); 113 | this.volumeValue = defaultVolume; 114 | this.musicPlayService.volume(defaultVolume); 115 | this.playMode = this.configService.getDefaultMusicPlayMode(); 116 | } 117 | 118 | private refreshMusicList(originalResponse: Page): void { 119 | this.list = originalResponse.content; 120 | this.originalResponse = originalResponse; 121 | } 122 | 123 | private refreshMusicInfo(nowPlayingMusicId: string): void { 124 | this.nowPlayingMusicId = nowPlayingMusicId; 125 | const nowPlayMusic = this.list.find(item => item.musicId === nowPlayingMusicId); 126 | if (nowPlayMusic) { 127 | this.nowPlayMusicInfo = `${nowPlayMusic.name}-${nowPlayMusic.singer}`; 128 | this.titleService.setTitle(`${this.nowPlayMusicInfo}-云舒音乐`); 129 | } else { 130 | this.nowPlayMusicInfo = ''; 131 | this.titleService.setTitle('云舒音乐'); 132 | } 133 | } 134 | 135 | @HostListener('window:keydown.space', ['$event']) 136 | onSpaceKeyDown(): void { 137 | if (!this.musicPlayService.isPlayingNow()) { 138 | if (this.nowPlayingMusicId) { 139 | this.musicPlayService.play().subscribe(); 140 | } else { 141 | this.onPlayStatusChange(PlayEvent.NEXT); 142 | } 143 | } else { 144 | this.onPlayStatusChange(PlayEvent.PAUSE); 145 | } 146 | } 147 | 148 | @HostListener('window:keydown.control.ArrowLeft', ['$event']) 149 | @HostListener('window:keydown.control.ArrowRight', ['$event']) 150 | onArrowLeftOrArrowRightKeyDown($event: KeyboardEvent): void { 151 | if ($event.ctrlKey && $event.key !== undefined) { 152 | if ($event.key === 'ArrowLeft') { 153 | this.onPlayStatusChange(PlayEvent.PREVIOUS); 154 | return; 155 | } 156 | if ($event.key === 'ArrowRight') { 157 | this.onPlayStatusChange(PlayEvent.NEXT); 158 | return; 159 | } 160 | } 161 | } 162 | 163 | doOnClick(music: Music): void { 164 | if (this.nowPlayingMusicId !== music.musicId) { 165 | this.lyricsService.load(music.lyricId); 166 | this.musicPlayService.start(this.fileService.getMusicFileUrl(music.musicId)) 167 | .subscribe((status) => { 168 | if (status) { 169 | this.refreshMusicInfo(music.musicId); 170 | } else { 171 | this.snackBar.open('播放失败', '我知道了'); 172 | } 173 | }); 174 | } else { 175 | if (this.musicPlayService.isPlayingNow()) { 176 | this.musicPlayService.pause().subscribe(); 177 | } else { 178 | this.musicPlayService.play().subscribe(); 179 | } 180 | } 181 | } 182 | 183 | onPageChange(pageEvent: PageEvent): void { 184 | if (this.isSearch) { 185 | this.musicListService.search(this.searchKeyword, pageEvent.pageIndex, pageEvent.pageSize) 186 | .subscribe(music => this.refreshMusicList(music)); 187 | } else { 188 | this.musicListService.getMusicList(pageEvent.pageIndex, pageEvent.pageSize) 189 | .subscribe(music => this.refreshMusicList(music)); 190 | } 191 | } 192 | 193 | onSearch(): void { 194 | if (this.searchKeyword) { 195 | this.isSearch = true; 196 | this.musicListService.search(this.searchKeyword.trim()).subscribe(music => this.refreshMusicList(music)); 197 | } else { 198 | this.isSearch = false; 199 | this.musicListService.getMusicList().subscribe(music => this.refreshMusicList(music)); 200 | } 201 | } 202 | 203 | onTimeChange(time: number): void { 204 | this.musicPlayService.seek(time).subscribe(); 205 | } 206 | 207 | onPlayStatusChange(event: PlayEvent): void { 208 | switch (event) { 209 | case PlayEvent.PLAY: 210 | if (!this.musicPlayService.isPlayingNow()) { 211 | if (this.nowPlayingMusicId) { 212 | this.musicPlayService.play().subscribe(); 213 | } else { 214 | this.onPlayStatusChange(PlayEvent.NEXT); 215 | } 216 | } 217 | break; 218 | case PlayEvent.PAUSE: 219 | if (this.musicPlayService.isPlayingNow()) { 220 | this.musicPlayService.pause().subscribe(); 221 | } 222 | break; 223 | case PlayEvent.NEXT: 224 | const nextMusic = this.musicListService.getNextMusic(this.playMode, this.nowPlayingMusicId, this.originalResponse); 225 | this.lyricsService.load(nextMusic.lyricId); 226 | this.musicPlayService.start(this.fileService.getMusicFileUrl(nextMusic.musicId)) 227 | .subscribe((status) => { 228 | if (status) { 229 | this.refreshMusicInfo(nextMusic.musicId); 230 | } else { 231 | this.snackBar.open('播放失败', '我知道了'); 232 | } 233 | }); 234 | break; 235 | case PlayEvent.PREVIOUS: 236 | const previousMusic = this.musicListService.getPreviousMusic(this.playMode, this.nowPlayingMusicId, this.originalResponse); 237 | this.lyricsService.load(previousMusic.lyricId); 238 | this.musicPlayService.start(this.fileService.getMusicFileUrl(previousMusic.musicId)) 239 | .subscribe((status) => { 240 | if (status) { 241 | this.refreshMusicInfo(previousMusic.musicId); 242 | } else { 243 | this.snackBar.open('播放失败', '我知道了'); 244 | } 245 | }); 246 | break; 247 | default: 248 | } 249 | } 250 | 251 | onPlayModeChange(mode: PlayMode): void { 252 | this.playMode = mode; 253 | this.configService.setDefaultMusicPlayMode(mode); 254 | } 255 | 256 | onVolumeChange(volume: number): void { 257 | this.musicPlayService.volume(volume); 258 | this.configService.setDefaultVolume(volume); 259 | } 260 | 261 | onLocationClick(): void { 262 | let index = this.list.findIndex(item => item.musicId === this.nowPlayingMusicId); 263 | if (index !== -1) { 264 | const clientHeight = document.getElementsByTagName('mat-list-option')[0].clientHeight; 265 | index -= 2; 266 | index = index < 0 ? 0 : index; 267 | document.getElementsByTagName('mat-sidenav-content')[0].scrollTop = clientHeight * index; 268 | } 269 | } 270 | } 271 | --------------------------------------------------------------------------------