diff --git a/.circleci/config.yml b/.circleci/config.yml index 5bf39ab..e74614b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,8 +1,8 @@ version: 2.1 orbs: - browser-tools: circleci/browser-tools@1.4.2 - codecov: codecov/codecov@3.2.5 + browser-tools: circleci/browser-tools@1.4.8 + codecov: codecov/codecov@4.1.0 jobs: build: @@ -33,6 +33,8 @@ jobs: - run: name: "Run unit tests and code coverage" command: npm run test -- --no-watch --no-progress --browsers=ChromeHeadless --code-coverage + - store_test_results: + path: ./test-results - store_artifacts: path: coverage - codecov/upload: diff --git a/.gitignore b/.gitignore index 90bb2d2..e6d5ebd 100644 --- a/.gitignore +++ b/.gitignore @@ -44,7 +44,11 @@ npm-debug.log yarn-error.log testem.log /typings +/test-results # System Files .DS_Store Thumbs.db + +# Build generated files +src/environments/*.json diff --git a/README.md b/README.md index 1de04f2..c8ef3ad 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,9 @@ Explanation of configurations (**^** denotes required config): - `"name"` - (*string*) the name of the environment - **^**`"domain"` - (*string*) the domain this app is running on - **^**`"spotifyApiUrl"` - (*string*) Spotify's API url + - **^**`"spotifyAccountsUrl"` - (*string*) Spotify's Accounts url + - `"playbackPolling"` - (*number*) polling interval (in ms) to Spotify when a track is currently playing (default: 1000) + - `"idlePolling"` -(*number*) polling interval (in ms) to Spotify when no track is currently playing (default: 5000) - `"auth"` - **^**`"clientId"` (*string*) the client ID for accessing Spotify's API - `"clientSecret"` - (*string*) the client secret for accessing Spotify's API (if using non-PKCE method) @@ -81,8 +84,20 @@ Explanation of configurations (**^** denotes required config): - `"tokenUrl"` - (*string*) the 3rd party backend URL for authentication if not using direct authorization with Spotify - `"forcePkce"` - (*boolean*) used to force the application to use PKCE for authentication disregarding what other configs are set - `"showDialog"` - (*boolean*) determines if Spotify's OAuth page is opened in a new window or not + - `"expiryThreshold"` - (*number*) the threshold (in ms) between the current time and an auth token's expiry time to attempt to refresh the token before it expires (default: 0) These configurations can also be set as environment variables instead of a json file. The names for each config will be `SHOWTUNES_{configName}` where `configName` will be in upper camel case. For example: `spotifyApiUrl` as an environment variable will be `SHOWTUNES_SPOTIFY_API_URL`. +See `gulpfile.js` for exact usage. Environment variables will always overwrite anything in the config file. + +## Deployment + +When deploying the application, run the command `npm run build -- -c {environment}`. + +Current possible environments: +- `production` +- `staging` + +Omitting the environment configuration will default to a development release. diff --git a/angular.json b/angular.json index bbc997e..c6922f2 100644 --- a/angular.json +++ b/angular.json @@ -68,6 +68,33 @@ "maximumError": "15kb" } ] + }, + "staging": { + "fileReplacements": [ + { + "replace": "src/environments/environment.ts", + "with": "src/environments/environment.staging.ts" + } + ], + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "namedChunks": false, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "10kb", + "maximumError": "15kb" + } + ] } }, "defaultConfiguration": "" @@ -80,6 +107,9 @@ "configurations": { "production": { "browserTarget": "showtunes:build:production" + }, + "staging": { + "browserTarget": "showtunes:build:staging" } } }, diff --git a/gulpfile.js b/gulpfile.js index 32517d1..24fe0bb 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -33,6 +33,27 @@ gulp.task('generate-config', () => { if ('SHOWTUNES_SPOTIFY_API_URL' in process.env) { configJson.env.spotifyApiUrl = process.env.SHOWTUNES_SPOTIFY_API_URL; } + if ('SHOWTUNES_SPOTIFY_ACCOUNTS_URL' in process.env) { + configJson.env.spotifyAccountsUrl = process.env.SHOWTUNES_SPOTIFY_ACCOUNTS_URL; + } + if ('SHOWTUNES_PLAYBACK_POLLING' in process.env) { + const playbackPolling = parseInt(process.env.SHOWTUNES_PLAYBACK_POLLING); + if (!isNaN(playbackPolling)) { + configJson.env.playbackPolling = playbackPolling; + } + } + if ('SHOWTUNES_IDLE_POLLING' in process.env) { + const idlePolling = parseInt(process.env.SHOWTUNES_IDLE_POLLING); + if (!isNaN(idlePolling)) { + configJson.env.idlePolling = idlePolling; + } + } + if ('SHOWTUNES_THROTTLE_DELAY' in process.env) { + const throttleDelay = parseInt(process.env.SHOWTUNES_THROTTLE_DELAY); + if (!isNaN(throttleDelay)) { + configJson.env.throttleDelay = throttleDelay; + } + } if ('SHOWTUNES_CLIENT_ID' in process.env) { configJson.auth.clientId = process.env.SHOWTUNES_CLIENT_ID; } @@ -51,6 +72,12 @@ gulp.task('generate-config', () => { if ('SHOWTUNES_AUTH_SHOW_DIALOG' in process.env) { configJson.auth.showDialog = process.env.SHOWTUNES_AUTH_SHOW_DIALOG; } + if ('SHOWTUNES_AUTH_EXPIRY_THRESHOLD' in process.env) { + const expiryThreshold = parseInt(process.env.SHOWTUNES_AUTH_EXPIRY_THRESHOLD); + if (!isNaN(expiryThreshold)) { + configJson.auth.expiryThreshold = expiryThreshold; + } + } console.log('In gulp :: After OS Env: ' + JSON.stringify(configJson)); if (!configJson.env.name) { diff --git a/karma.conf.js b/karma.conf.js index 773c151..998aeb2 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -11,7 +11,8 @@ module.exports = function (config) { require('karma-jasmine-html-reporter'), require('karma-coverage'), require('@angular-devkit/build-angular/plugins/karma'), - 'karma-spec-reporter' + 'karma-spec-reporter', + 'karma-junit-reporter' ], client: { clearContext: false // leave Jasmine Spec Runner output visible in browser @@ -26,7 +27,12 @@ module.exports = function (config) { return browser.toLowerCase().split(/[ /-]/)[0]; } }, - reporters: ['spec', 'coverage'], + junitReporter: { + outputDir: './test-results', + outputFile: 'results.xml', + useBrowserName: false + }, + reporters: ['spec', 'coverage', 'junit'], port: 9876, colors: true, logLevel: config.LOG_INFO, diff --git a/package-lock.json b/package-lock.json index 66025c9..9059a8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "showtunes", - "version": "0.5.2", + "version": "0.6.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "showtunes", - "version": "0.5.2", + "version": "0.6.1", "dependencies": { "@angular/animations": "~13.1.0", "@angular/cdk": "^13.1.0", @@ -51,6 +51,7 @@ "karma-coverage": "^2.1.0", "karma-jasmine": "~4.0.0", "karma-jasmine-html-reporter": "^1.7.0", + "karma-junit-reporter": "^2.0.1", "karma-spec-reporter": "^0.0.36", "ng-mocks": "^13.0.0-alpha.4", "protractor": "~7.0.0", @@ -10628,6 +10629,31 @@ "karma-jasmine": ">=1.1" } }, + "node_modules/karma-junit-reporter": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/karma-junit-reporter/-/karma-junit-reporter-2.0.1.tgz", + "integrity": "sha512-VtcGfE0JE4OE1wn0LK8xxDKaTP7slN8DO3I+4xg6gAi1IoAHAXOJ1V9G/y45Xg6sxdxPOR3THCFtDlAfBo9Afw==", + "dev": true, + "dependencies": { + "path-is-absolute": "^1.0.0", + "xmlbuilder": "12.0.0" + }, + "engines": { + "node": ">= 8" + }, + "peerDependencies": { + "karma": ">=0.9" + } + }, + "node_modules/karma-junit-reporter/node_modules/xmlbuilder": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-12.0.0.tgz", + "integrity": "sha512-lMo8DJ8u6JRWp0/Y4XLa/atVDr75H9litKlb2E5j3V3MesoL50EBgZDWoLT3F/LztVnG67GjPXLZpqcky/UMnQ==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, "node_modules/karma-source-map-support": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", @@ -27540,6 +27566,24 @@ "dev": true, "requires": {} }, + "karma-junit-reporter": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/karma-junit-reporter/-/karma-junit-reporter-2.0.1.tgz", + "integrity": "sha512-VtcGfE0JE4OE1wn0LK8xxDKaTP7slN8DO3I+4xg6gAi1IoAHAXOJ1V9G/y45Xg6sxdxPOR3THCFtDlAfBo9Afw==", + "dev": true, + "requires": { + "path-is-absolute": "^1.0.0", + "xmlbuilder": "12.0.0" + }, + "dependencies": { + "xmlbuilder": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-12.0.0.tgz", + "integrity": "sha512-lMo8DJ8u6JRWp0/Y4XLa/atVDr75H9litKlb2E5j3V3MesoL50EBgZDWoLT3F/LztVnG67GjPXLZpqcky/UMnQ==", + "dev": true + } + } + }, "karma-source-map-support": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz", diff --git a/package.json b/package.json index b5cc107..ccc0c37 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,12 @@ { "name": "showtunes", - "version": "0.6.0", + "version": "0.6.1", "scripts": { "ng": "ng", "start": "ng serve", + "prebuild": "npx ts-node src/environments/create-build-date.ts", "build": "ng build", + "postbuild": "gulp generate-config", "test": "ng test", "lint": "ng lint", "e2e": "ng e2e" @@ -54,6 +56,7 @@ "karma-coverage": "^2.1.0", "karma-jasmine": "~4.0.0", "karma-jasmine-html-reporter": "^1.7.0", + "karma-junit-reporter": "^2.0.1", "karma-spec-reporter": "^0.0.36", "ng-mocks": "^13.0.0-alpha.4", "protractor": "~7.0.0", diff --git a/src/app/app.config.spec.ts b/src/app/app.config.spec.ts new file mode 100644 index 0000000..5254dc3 --- /dev/null +++ b/src/app/app.config.spec.ts @@ -0,0 +1,487 @@ +import { HttpClient } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { expect } from '@angular/flex-layout/_private-utils/testing'; +import { MockProvider } from 'ng-mocks'; +import { of } from 'rxjs'; +import { AppConfig } from './app.config'; +import { getTestAppConfig } from './core/testing/test-responses'; + +const IDLE_POLLING_DEFAULT = 3000; +const PLAYBACK_POLLING_DEFAULT = 1000; +const THROTTLE_DELAY_DEFAULT = 1000; +const EXPIRY_THRESHOLD_DEFAULT = 0; + +describe('AppConfig', () => { + let appConfig: AppConfig; + let http: HttpClient; + + beforeEach(() => { + AppConfig.settings = null; + TestBed.configureTestingModule({ + providers: [ + AppConfig, + MockProvider(HttpClient) + ] + }); + appConfig = TestBed.inject(AppConfig); + http = TestBed.inject(HttpClient); + + http.get = jasmine.createSpy().and.returnValue(of(null)); + }); + + it('should be truthy', () => { + expect(appConfig).toBeTruthy(); + }); + + it('should return true for env initialized when env is set', () => { + AppConfig.settings = getTestAppConfig(); + expect(AppConfig.isEnvInitialized()).toBeTrue(); + }); + + it('should return false for env initialized when env is null', () => { + AppConfig.settings = getTestAppConfig(); + AppConfig.settings.env = null; + expect(AppConfig.isEnvInitialized()).toBeFalse(); + }); + + it('should return false for env initialized when settings is null', () => { + AppConfig.settings = null; + expect(AppConfig.isEnvInitialized()).toBeFalse(); + }); + + it('should return true for auth initialized when auth is set', () => { + AppConfig.settings = getTestAppConfig(); + expect(AppConfig.isAuthInitialized()).toBeTrue(); + }); + + it('should return false for auth initialized when auth is null', () => { + AppConfig.settings = getTestAppConfig(); + AppConfig.settings.auth = null; + expect(AppConfig.isAuthInitialized()).toBeFalse(); + }); + + it('should return false for auth initialized when settings is null', () => { + AppConfig.settings = null; + expect(AppConfig.isAuthInitialized()).toBeFalse(); + }); + + it('should resolve when a valid config is loaded', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: {}, + auth: {} + })); + appConfig.load().then(() => expect(AppConfig.settings).toBeTruthy()); + }); + + it('should reject the promise when an error occurs loading the config', () => { + http.get = jasmine.createSpy().and.throwError('test-error'); + appConfig.load().catch((err) => { + expect(err).toBeTruthy(); + expect(AppConfig.settings).toBeFalsy(); + }); + }); + + it('should set all values of the AppConfig', () => { + const expectedName = 'test-name'; + const expectedDomain = 'test-domain'; + const expectedSpotifyApiUrl = 'test-spotify-api'; + const expectedSpotifyAccountsUrl = 'test-spotify-accounts'; + const expectedPlaybackPolling = 5; + const expectedIdlePolling = 10; + const expectedClientId = 'test-client-id'; + const expectedClientSecret = 'test-client-secret'; + const expectedScopes = 'test-scopes'; + const expectedTokenUrl = 'test-token-url'; + const expectedForcePkce = true; + const expectedShowDialog = true; + const expectedExpiryThreshold = 20; + const expectedLoggingLevel = 'test-log-level'; + + http.get = jasmine.createSpy().and.returnValue(of({ + env: { + name: expectedName, + domain: expectedDomain, + spotifyApiUrl: expectedSpotifyApiUrl, + spotifyAccountsUrl: expectedSpotifyAccountsUrl, + playbackPolling: expectedPlaybackPolling, + idlePolling: expectedIdlePolling + }, + auth: { + clientId: expectedClientId, + clientSecret: expectedClientSecret, + scopes: expectedScopes, + tokenUrl: expectedTokenUrl, + forcePkce: expectedForcePkce, + showDialog: expectedShowDialog, + expiryThreshold: expectedExpiryThreshold + }, + logging: { + level: expectedLoggingLevel + } + })); + + appConfig.load().then(() => { + expect(AppConfig.settings.env.name).toEqual(expectedName); + expect(AppConfig.settings.env.domain).toEqual(expectedDomain); + expect(AppConfig.settings.env.spotifyApiUrl).toEqual(expectedSpotifyApiUrl); + expect(AppConfig.settings.env.spotifyAccountsUrl).toEqual(expectedSpotifyAccountsUrl); + expect(AppConfig.settings.env.playbackPolling).toEqual(expectedPlaybackPolling); + expect(AppConfig.settings.env.idlePolling).toEqual(expectedIdlePolling); + expect(AppConfig.settings.auth.clientId).toEqual(expectedClientId); + expect(AppConfig.settings.auth.clientSecret).toEqual(expectedClientSecret); + expect(AppConfig.settings.auth.scopes).toEqual(expectedScopes); + expect(AppConfig.settings.auth.tokenUrl).toEqual(expectedTokenUrl); + expect(AppConfig.settings.auth.forcePkce).toEqual(expectedForcePkce); + expect(AppConfig.settings.auth.showDialog).toEqual(expectedShowDialog); + expect(AppConfig.settings.auth.expiryThreshold).toEqual(expectedExpiryThreshold); + expect(AppConfig.settings.logging.level).toEqual(expectedLoggingLevel); + }); + }); + + it('should handle idlePolling as a number', () => { + const expectedValue = 10; + http.get = jasmine.createSpy().and.returnValue(of({ + env: { + idlePolling: expectedValue + }, + auth: {} + })); + appConfig.load().then(() => { + expect(AppConfig.settings.env.idlePolling).toEqual(expectedValue); + }); + }); + + it('should parse idlePolling to an int', () => { + const expectedValue = 10; + const strValue = '10'; + http.get = jasmine.createSpy().and.returnValue(of({ + env: { + idlePolling: strValue + }, + auth: {} + })); + appConfig.load().then(() => { + expect(AppConfig.settings.env.idlePolling).toEqual(expectedValue); + }); + }); + + it('should handle idlePolling as an invalid string and set to default value', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: { + idlePolling: 'test' + }, + auth: {} + })); + appConfig.load().then(() => { + expect(AppConfig.settings.env.idlePolling).toEqual(IDLE_POLLING_DEFAULT); + }); + }); + + it('should handle idlePolling as an invalid type and set to default value', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: { + idlePolling: {} + }, + auth: {} + })); + appConfig.load().then(() => { + expect(AppConfig.settings.env.idlePolling).toEqual(IDLE_POLLING_DEFAULT); + }); + }); + + it('should handle an absent idlePolling and set to default value', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: {}, + auth: {} + })); + appConfig.load().then(() => { + expect(AppConfig.settings.env.idlePolling).toEqual(IDLE_POLLING_DEFAULT); + }); + }); + + it('should handle playbackPolling as a number', () => { + const expectedValue = 10; + http.get = jasmine.createSpy().and.returnValue(of({ + env: { + playbackPolling: expectedValue + }, + auth: {} + })); + appConfig.load().then(() => { + expect(AppConfig.settings.env.playbackPolling).toEqual(expectedValue); + }); + }); + + it('should parse playbackPolling to an int', () => { + const expectedValue = 10; + const strValue = '10'; + http.get = jasmine.createSpy().and.returnValue(of({ + env: { + playbackPolling: strValue + }, + auth: {} + })); + appConfig.load().then(() => { + expect(AppConfig.settings.env.playbackPolling).toEqual(expectedValue); + }); + }); + + it('should handle playbackPolling as an invalid string and set to default value', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: { + playbackPolling: 'test' + }, + auth: {} + })); + appConfig.load().then(() => { + expect(AppConfig.settings.env.playbackPolling).toEqual(PLAYBACK_POLLING_DEFAULT); + }); + }); + + it('should handle playbackPolling as an invalid type and set to default value', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: { + playbackPolling: {} + }, + auth: {} + })); + appConfig.load().then(() => { + expect(AppConfig.settings.env.playbackPolling).toEqual(PLAYBACK_POLLING_DEFAULT); + }); + }); + + it('should handle an absent playbackPolling and set to default value', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: {}, + auth: {} + })); + appConfig.load().then(() => { + expect(AppConfig.settings.env.playbackPolling).toEqual(PLAYBACK_POLLING_DEFAULT); + }); + }); + + it('should parse throttleDelay to an int', () => { + const expectedValue = 10; + const strValue = '10'; + http.get = jasmine.createSpy().and.returnValue(of({ + env: { + throttleDelay: strValue + }, + auth: {} + })); + appConfig.load().then(() => { + expect(AppConfig.settings.env.throttleDelay).toEqual(expectedValue); + }); + }); + + it('should handle throttleDelay as an invalid string and set to default value', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: { + throttleDelay: 'test' + }, + auth: {} + })); + appConfig.load().then(() => { + expect(AppConfig.settings.env.throttleDelay).toEqual(THROTTLE_DELAY_DEFAULT); + }); + }); + + it('should handle throttleDelay as an invalid type and set to default value', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: { + throttleDelay: {} + }, + auth: {} + })); + appConfig.load().then(() => { + expect(AppConfig.settings.env.throttleDelay).toEqual(THROTTLE_DELAY_DEFAULT); + }); + }); + + it('should handle an absent throttleDelay and set to default value', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: {}, + auth: {} + })); + appConfig.load().then(() => { + expect(AppConfig.settings.env.throttleDelay).toEqual(THROTTLE_DELAY_DEFAULT); + }); + }); + + it('should handle forcePkce as a boolean', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: {}, + auth: { + forcePkce: true + } + })); + appConfig.load().then(() => { + expect(AppConfig.settings.auth.forcePkce).toBeTrue(); + }); + }); + + it('should parse forcePkce to a boolean', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: {}, + auth: { + forcePkce: 'True' + } + })); + appConfig.load().then(() => { + expect(AppConfig.settings.auth.forcePkce).toBeTrue(); + }); + }); + + it('should handle forcePkce as an invalid string to be false', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: {}, + auth: { + forcePkce: 'test' + } + })); + appConfig.load().then(() => { + expect(AppConfig.settings.auth.forcePkce).toBeFalse(); + }); + }); + + it('should handle forcePkce as an invalid type to be false', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: {}, + auth: { + forcePkce: {} + } + })); + appConfig.load().then(() => { + expect(AppConfig.settings.auth.forcePkce).toBeFalse(); + }); + }); + + it('should handle an absent forcePkce to be false', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: {}, + auth: {} + })); + appConfig.load().then(() => { + expect(AppConfig.settings.auth.forcePkce).toBeFalse(); + }); + }); + + it('should handle showDialog as a boolean', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: {}, + auth: { + showDialog: true + } + })); + appConfig.load().then(() => { + expect(AppConfig.settings.auth.showDialog).toBeTrue(); + }); + }); + + it('should parse showDialog to a boolean', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: {}, + auth: { + showDialog: 'True' + } + })); + appConfig.load().then(() => { + expect(AppConfig.settings.auth.showDialog).toBeTrue(); + }); + }); + + it('should handle showDialog as an invalid string to be false', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: {}, + auth: { + showDialog: 'test' + } + })); + appConfig.load().then(() => { + expect(AppConfig.settings.auth.showDialog).toBeFalse(); + }); + }); + + it('should handle showDialog as an invalid type to be false', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: {}, + auth: { + showDialog: {} + } + })); + appConfig.load().then(() => { + expect(AppConfig.settings.auth.showDialog).toBeFalse(); + }); + }); + + it('should handle an absent showDialog to be false', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: {}, + auth: {} + })); + appConfig.load().then(() => { + expect(AppConfig.settings.auth.showDialog).toBeFalse(); + }); + }); + + it('should handle expiryThreshold as a number', () => { + const expectedValue = 10; + http.get = jasmine.createSpy().and.returnValue(of({ + env: {}, + auth: { + expiryThreshold: expectedValue + } + })); + appConfig.load().then(() => { + expect(AppConfig.settings.auth.expiryThreshold).toEqual(expectedValue); + }); + }); + + it('should parse expiryThreshold to an int', () => { + const expectedValue = 10; + const strValue = '10'; + http.get = jasmine.createSpy().and.returnValue(of({ + env: {}, + auth: { + expiryThreshold: strValue + } + })); + appConfig.load().then(() => { + expect(AppConfig.settings.auth.expiryThreshold).toEqual(expectedValue); + }); + }); + + it('should handle expiryThreshold as an invalid string and set to default value', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: {}, + auth: { + expiryThreshold: 'test' + } + })); + appConfig.load().then(() => { + expect(AppConfig.settings.auth.expiryThreshold).toEqual(EXPIRY_THRESHOLD_DEFAULT); + }); + }); + + it('should handle expiryThreshold as an invalid type and set to default value', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: {}, + auth: { + expiryThreshold: {} + } + })); + appConfig.load().then(() => { + expect(AppConfig.settings.auth.expiryThreshold).toEqual(EXPIRY_THRESHOLD_DEFAULT); + }); + }); + + it('should handle an absent expiryThreshold and set to default value', () => { + http.get = jasmine.createSpy().and.returnValue(of({ + env: {}, + auth: {} + })); + appConfig.load().then(() => { + expect(AppConfig.settings.auth.expiryThreshold).toEqual(EXPIRY_THRESHOLD_DEFAULT); + }); + }); +}); diff --git a/src/app/app.config.ts b/src/app/app.config.ts index b21ec7f..5d78368 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -7,6 +7,19 @@ import { IAppConfig } from './models/app-config.model'; export class AppConfig { static settings: IAppConfig; + private static readonly idlePollingDefault = 3000; + private static readonly playbackPollingDefault = 1000; + private static readonly throttleDelayDefault = 1000; + private static readonly expiryThresholdDefault = 0; + + static isEnvInitialized(): boolean { + return !!AppConfig.settings && !!AppConfig.settings.env; + } + + static isAuthInitialized(): boolean { + return !!AppConfig.settings && !!AppConfig.settings.auth; + } + constructor(private http: HttpClient) {} load(): Promise { @@ -14,10 +27,61 @@ export class AppConfig { return new Promise((resolve, reject) => { this.http.get(jsonFile).toPromise().then((response: IAppConfig) => { AppConfig.settings = response as IAppConfig; + this.checkTypes(); + this.loadDefaults(); resolve(); }).catch((response: any) => { reject(`Could not load file '${jsonFile}': ${JSON.stringify(response)}`); }); }); } + + private checkTypes(): void { + AppConfig.settings.env.idlePolling = this.parseInt(AppConfig.settings.env.idlePolling); + AppConfig.settings.env.playbackPolling = this.parseInt(AppConfig.settings.env.playbackPolling); + AppConfig.settings.env.throttleDelay = this.parseInt(AppConfig.settings.env.throttleDelay); + AppConfig.settings.auth.forcePkce = this.parseBoolean(AppConfig.settings.auth.forcePkce); + AppConfig.settings.auth.showDialog = this.parseBoolean(AppConfig.settings.auth.showDialog); + AppConfig.settings.auth.expiryThreshold = this.parseInt(AppConfig.settings.auth.expiryThreshold); + } + + private loadDefaults(): void { + if (AppConfig.settings.env.idlePolling == null) { + AppConfig.settings.env.idlePolling = AppConfig.idlePollingDefault; + } + if (AppConfig.settings.env.playbackPolling == null) { + AppConfig.settings.env.playbackPolling = AppConfig.playbackPollingDefault; + } + if (AppConfig.settings.env.throttleDelay == null) { + AppConfig.settings.env.throttleDelay = AppConfig.throttleDelayDefault; + } + if (AppConfig.settings.auth.expiryThreshold == null) { + AppConfig.settings.auth.expiryThreshold = AppConfig.expiryThresholdDefault; + } + } + + private parseInt(valueRaw: any): number { + if (valueRaw != null) { + if (typeof valueRaw === 'number') { + return valueRaw; + } else if (typeof valueRaw === 'string') { + const value = parseInt(valueRaw, 10); + if (!isNaN(value)) { + return value; + } + } + } + return null; + } + + private parseBoolean(valueRaw: any): boolean { + if (valueRaw != null) { + if (typeof valueRaw === 'boolean') { + return valueRaw; + } else if (typeof valueRaw === 'string') { + return valueRaw.toLowerCase() === 'true'; + } + } + return false; + } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 3e6ef33..ae21a1a 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -31,11 +31,14 @@ import { AuthState } from './core/auth/auth.state'; import { PlaybackState } from './core/playback/playback.state'; import { SETTINGS_STATE_NAME } from './core/settings/settings.model'; import { SettingsState } from './core/settings/settings.state'; +import { InteractionThrottleDirective } from './directives/component-throttle/interaction-throttle.directive'; import { MaterialModule } from './modules/material.module'; import { InactivityService } from './services/inactivity/inactivity.service'; import { PlaybackService } from './services/playback/playback.service'; -import { SpotifyInterceptor } from './services/spotify/spotify.interceptor'; -import { SpotifyService } from './services/spotify/spotify.service'; +import { SpotifyAuthService } from './services/spotify/auth/spotify-auth.service'; +import { SpotifyControlsService } from './services/spotify/controls/spotify-controls.service'; +import { SpotifyPollingService } from './services/spotify/polling/spotify-polling.service'; +import { SpotifyInterceptor } from './services/spotify/interceptor/spotify.interceptor'; import { StorageService } from './services/storage/storage.service'; export function initializeApp(appConfig: AppConfig): () => Promise { @@ -46,6 +49,7 @@ export function initializeApp(appConfig: AppConfig): () => Promise { declarations: [ AppComponent, AlbumDisplayComponent, + InteractionThrottleDirective, CallbackComponent, ColorPickerComponent, DashboardComponent, @@ -101,7 +105,9 @@ export function initializeApp(appConfig: AppConfig): () => Promise { }, InactivityService, StorageService, - SpotifyService, + SpotifyAuthService, + SpotifyControlsService, + SpotifyPollingService, { provide: HTTP_INTERCEPTORS, useClass: SpotifyInterceptor, diff --git a/src/app/components/album-display/album-display.component.html b/src/app/components/album-display/album-display.component.html index bb45d5e..2b5684a 100644 --- a/src/app/components/album-display/album-display.component.html +++ b/src/app/components/album-display/album-display.component.html @@ -14,7 +14,7 @@
- + Start Spotify to display music! @@ -31,7 +31,7 @@ fxLayoutAlign="center center" fxLayoutGap="12px"> - +
diff --git a/src/app/components/album-display/album-display.component.spec.ts b/src/app/components/album-display/album-display.component.spec.ts index d84b69f..ea78b29 100644 --- a/src/app/components/album-display/album-display.component.spec.ts +++ b/src/app/components/album-display/album-display.component.spec.ts @@ -15,64 +15,25 @@ import { MockProvider } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; import { AppConfig } from '../../app.config'; import { DominantColor, DominantColorFinder } from '../../core/dominant-color/dominant-color-finder'; -import { AlbumModel, TrackModel } from '../../core/playback/playback.model'; +import { AlbumModel, PlayerState, TrackModel } from '../../core/playback/playback.model'; import { ChangeDynamicColor } from '../../core/settings/settings.actions'; import { NgxsSelectorMock } from '../../core/testing/ngxs-selector-mock'; -import { FontColor } from '../../core/util'; +import { getTestAlbumModel, getTestDominantColor, getTestTrackModel } from '../../core/testing/test-models'; +import { getTestImageResponse } from '../../core/testing/test-responses'; import { ImageResponse } from '../../models/image.model'; -import { SpotifyService } from '../../services/spotify/spotify.service'; import { AlbumDisplayComponent } from './album-display.component'; -const TEST_IMAGE_RESPONSE: ImageResponse = { - url: 'test-url', - width: 100, - height: 100 -}; - -const TEST_ALBUM_MODEL: AlbumModel = { - id: 'id', - name: 'test', - href: 'album-href', - artists: ['test-artist-1', 'test-artist-2'], - coverArt: null, - type: 'type', - uri: 'album-uri', - releaseDate: 'release-date', - totalTracks: 10 -}; - -const TEST_TRACK_MODEL: TrackModel = { - id: 'id', - title: 'title', - duration: 100, - href: 'track-href', - artists: null, - uri: 'track-uri' -}; - -const TEST_DOMINANT_COLOR: DominantColor = { - hex: 'ABC123', - rgb: { - r: 100, - g: 100, - b: 100, - a: 255 - }, - foregroundFontColor: FontColor.White -}; - describe('AlbumDisplayComponent', () => { const mockSelectors = new NgxsSelectorMock(); let component: AlbumDisplayComponent; let fixture: ComponentFixture; let loader: HarnessLoader; - let spotify: SpotifyService; let store: Store; let coverArtProducer: BehaviorSubject; let trackProducer: BehaviorSubject; let albumProducer: BehaviorSubject; - let isIdleProducer: BehaviorSubject; + let playerStateProducer: BehaviorSubject; let useDynamicCodeColorProducer: BehaviorSubject; let dynamicColorProducer: BehaviorSubject; let showSpotifyCodeProducer: BehaviorSubject; @@ -86,11 +47,11 @@ describe('AlbumDisplayComponent', () => { AppConfig.settings = { env: { spotifyApiUrl: null, + spotifyAccountsUrl: null, name: null, domain: null }, - auth: null, - logging: null + auth: null }; }); @@ -109,11 +70,9 @@ describe('AlbumDisplayComponent', () => { provide: AppConfig, deps: [ MockProvider(HttpClient) ] }, - MockProvider(SpotifyService), MockProvider(Store) ] }).compileComponents(); - spotify = TestBed.inject(SpotifyService); store = TestBed.inject(Store); fixture = TestBed.createComponent(AlbumDisplayComponent); @@ -123,7 +82,7 @@ describe('AlbumDisplayComponent', () => { coverArtProducer = mockSelectors.defineNgxsSelector(component, 'coverArt$'); trackProducer = mockSelectors.defineNgxsSelector(component, 'track$'); albumProducer = mockSelectors.defineNgxsSelector(component, 'album$'); - isIdleProducer = mockSelectors.defineNgxsSelector(component, 'isIdle$'); + playerStateProducer = mockSelectors.defineNgxsSelector(component, 'playerState$'); useDynamicCodeColorProducer = mockSelectors.defineNgxsSelector(component, 'useDynamicCodeColor$'); dynamicColorProducer = mockSelectors.defineNgxsSelector(component, 'dynamicColor$'); showSpotifyCodeProducer = mockSelectors.defineNgxsSelector(component, 'showSpotifyCode$'); @@ -142,38 +101,38 @@ describe('AlbumDisplayComponent', () => { }); it('should display the cover art', () => { - coverArtProducer.next(TEST_IMAGE_RESPONSE); - albumProducer.next(TEST_ALBUM_MODEL); + coverArtProducer.next(getTestImageResponse()); + albumProducer.next(getTestAlbumModel()); fixture.detectChanges(); const link = fixture.debugElement.query(By.css('a')); const img = fixture.debugElement.query(By.css('img')); expect(link).toBeTruthy(); expect(link.properties.href).toBeTruthy(); - expect(link.properties.href).toEqual(TEST_ALBUM_MODEL.href); + expect(link.properties.href).toEqual(getTestAlbumModel().href); expect(img).toBeTruthy(); expect(img.properties.src).toBeTruthy(); - expect(img.properties.src).toEqual(TEST_IMAGE_RESPONSE.url); + expect(img.properties.src).toEqual(getTestImageResponse().url); }); it('should display a loading spinner when no coverArt and is not idle', () => { - isIdleProducer.next(false); + playerStateProducer.next(PlayerState.Playing); fixture.detectChanges(); const spinner = fixture.debugElement.query(By.directive(MatSpinner)); expect(spinner).toBeTruthy(); }); it('should display a loading spinner when coverArt.url is null and is not idle', () => { - const nullCoverArt = {...TEST_IMAGE_RESPONSE}; + const nullCoverArt = getTestImageResponse(); nullCoverArt.url = null; coverArtProducer.next(nullCoverArt); - isIdleProducer.next(false); + playerStateProducer.next(PlayerState.Playing); fixture.detectChanges(); const spinner = fixture.debugElement.query(By.directive(MatSpinner)); expect(spinner).toBeTruthy(); }); it('should display start Spotify message when no coverArt and is idle', () => { - isIdleProducer.next(true); + playerStateProducer.next(PlayerState.Idling); fixture.detectChanges(); const msg = fixture.debugElement.query(By.css('span')); expect(msg.nativeElement.textContent).toBeTruthy(); @@ -181,10 +140,10 @@ describe('AlbumDisplayComponent', () => { }); it('should display start Spotify message when coverArt.url is null and is idle', () => { - const nullCoverArt = {...TEST_IMAGE_RESPONSE}; + const nullCoverArt = getTestImageResponse(); nullCoverArt.url = null; coverArtProducer.next(nullCoverArt); - isIdleProducer.next(true); + playerStateProducer.next(PlayerState.Idling); fixture.detectChanges(); const msg = fixture.debugElement.query(By.css('span')); expect(msg.nativeElement.textContent).toBeTruthy(); @@ -203,7 +162,7 @@ describe('AlbumDisplayComponent', () => { it('should display Spotify code loading when showing code and no URL and not idle', () => { showSpotifyCodeProducer.next(true); - isIdleProducer.next(false); + playerStateProducer.next(PlayerState.Playing); fixture.detectChanges(); const icon = fixture.debugElement.query(By.css('fa-icon')); const loading = fixture.debugElement.query(By.directive(MatProgressBar)); @@ -213,7 +172,7 @@ describe('AlbumDisplayComponent', () => { it('should only display Spotify code icon when showing code and no URL and is idle', () => { showSpotifyCodeProducer.next(true); - isIdleProducer.next(true); + playerStateProducer.next(PlayerState.Idling); fixture.detectChanges(); const icon = fixture.debugElement.query(By.css('fa-icon')); const loading = fixture.debugElement.query(By.directive(MatProgressBar)); @@ -235,31 +194,31 @@ describe('AlbumDisplayComponent', () => { it('should set Spotify code URL when the track$ is updated', () => { component['setSpotifyCodeUrl'] = jasmine.createSpy(); - trackProducer.next(TEST_TRACK_MODEL); + trackProducer.next(getTestTrackModel()); fixture.detectChanges(); expect(component['setSpotifyCodeUrl']).toHaveBeenCalled(); }); it('should update dynamic color when coverArt$ is updated and dominantColorFinder returns a result', fakeAsync(() => { - mockDominantColorFinder.expects(Promise.resolve(TEST_DOMINANT_COLOR)); - coverArtProducer.next(TEST_IMAGE_RESPONSE); + mockDominantColorFinder.expects(Promise.resolve(getTestDominantColor())); + coverArtProducer.next(getTestImageResponse()); flushMicrotasks(); - expect(store.dispatch).toHaveBeenCalledWith(new ChangeDynamicColor(TEST_DOMINANT_COLOR)); + expect(store.dispatch).toHaveBeenCalledWith(new ChangeDynamicColor(getTestDominantColor())); })); it('should set dynamic color to null when coverArt$ is updated and dominantColorFinder returns null', fakeAsync(() => { mockDominantColorFinder.expects(Promise.resolve(null)); - coverArtProducer.next(TEST_IMAGE_RESPONSE); + coverArtProducer.next(getTestImageResponse()); flushMicrotasks(); expect(store.dispatch).toHaveBeenCalledWith(new ChangeDynamicColor(null)); })); it('should set dynamic color to null when coverArt$ is updated and dominantColorFinder returns an invalid hex', fakeAsync(() => { mockDominantColorFinder.expects(Promise.resolve({ - ...TEST_DOMINANT_COLOR, + ...getTestDominantColor(), hex: 'bad-hex' })); - coverArtProducer.next(TEST_IMAGE_RESPONSE); + coverArtProducer.next(getTestImageResponse()); flushMicrotasks(); expect(store.dispatch).toHaveBeenCalledWith(new ChangeDynamicColor(null)); })); @@ -267,7 +226,7 @@ describe('AlbumDisplayComponent', () => { it('should set dynamic color to null when coverArt$ is updated and dominantColorFinder rejects its promise', fakeAsync(() => { mockDominantColorFinder.expects(Promise.reject('test-error')); spyOn(console, 'error'); - coverArtProducer.next(TEST_IMAGE_RESPONSE); + coverArtProducer.next(getTestImageResponse()); flushMicrotasks(); expect(console.error).toHaveBeenCalled(); expect(store.dispatch).toHaveBeenCalledWith(new ChangeDynamicColor(null)); @@ -296,38 +255,38 @@ describe('AlbumDisplayComponent', () => { it('should set Spotify code URL when dynamicColor$ is updated', () => { component['setSpotifyCodeUrl'] = jasmine.createSpy(); - dynamicColorProducer.next(TEST_DOMINANT_COLOR); + dynamicColorProducer.next(getTestDominantColor()); expect(component['setSpotifyCodeUrl']).toHaveBeenCalled(); }); it('should create a Spotify code URL', () => { backgroundColorProducer.next('bg-color'); barColorProducer.next('bar-color'); - trackProducer.next(TEST_TRACK_MODEL); + trackProducer.next(getTestTrackModel()); fixture.detectChanges(); - expect(component.spotifyCodeUrl).toEqual( - `https://www.spotifycodes.com/downloadCode.php?uri=jpeg%2Fbg-color%2Fbar-color%2F512%2F${TEST_TRACK_MODEL.uri}`); + expect(component.spotifyCodeUrl).toEqual('https://www.spotifycodes.com/downloadCode.php?uri=jpeg%2F' + + `bg-color%2Fbar-color%2F512%2F${encodeURIComponent(getTestTrackModel().uri)}`); }); it('should create a Spotify code URL with expanded background color of length 3', () => { backgroundColorProducer.next('ABC'); barColorProducer.next('bar-color'); - trackProducer.next(TEST_TRACK_MODEL); + trackProducer.next(getTestTrackModel()); fixture.detectChanges(); - expect(component.spotifyCodeUrl).toEqual( - `https://www.spotifycodes.com/downloadCode.php?uri=jpeg%2FAABBCC%2Fbar-color%2F512%2F${TEST_TRACK_MODEL.uri}`); + expect(component.spotifyCodeUrl).toEqual('https://www.spotifycodes.com/downloadCode.php?uri=jpeg%2' + + `FAABBCC%2Fbar-color%2F512%2F${encodeURIComponent(getTestTrackModel().uri)}`); }); it('should not create Spotify code URL with no background color', () => { barColorProducer.next('bar-color'); - trackProducer.next(TEST_TRACK_MODEL); + trackProducer.next(getTestTrackModel()); fixture.detectChanges(); expect(component.spotifyCodeUrl).toBeNull(); }); it('should not create Spotify code URL with no bar color', () => { backgroundColorProducer.next('bg-color'); - trackProducer.next(TEST_TRACK_MODEL); + trackProducer.next(getTestTrackModel()); fixture.detectChanges(); expect(component.spotifyCodeUrl).toBeNull(); }); @@ -342,7 +301,7 @@ describe('AlbumDisplayComponent', () => { it('should not create Spotify code URL with no track uri', () => { backgroundColorProducer.next('bg-color'); barColorProducer.next('bar-color'); - const nullTrackUri = {...TEST_TRACK_MODEL}; + const nullTrackUri = getTestTrackModel(); nullTrackUri.uri = null; trackProducer.next(nullTrackUri); fixture.detectChanges(); @@ -352,23 +311,24 @@ describe('AlbumDisplayComponent', () => { it('should create Spotify code URL with dynamic colors when using dynamic color code', () => { backgroundColorProducer.next('bg-color'); barColorProducer.next('bar-color'); - dynamicColorProducer.next(TEST_DOMINANT_COLOR); - trackProducer.next(TEST_TRACK_MODEL); + dynamicColorProducer.next(getTestDominantColor()); + trackProducer.next(getTestTrackModel()); useDynamicCodeColorProducer.next(true); fixture.detectChanges(); - expect(component.spotifyCodeUrl).toEqual( - `https://www.spotifycodes.com/downloadCode.php?uri=jpeg%2F${TEST_DOMINANT_COLOR.hex}%2F${TEST_DOMINANT_COLOR.foregroundFontColor}%2F512%2F${TEST_TRACK_MODEL.uri}`); + expect(component.spotifyCodeUrl).toEqual('https://www.spotifycodes.com/downloadCode.php?uri=jpeg%2F' + + `${getTestDominantColor().hex}%2F${getTestDominantColor().foregroundFontColor}%2F512%2F` + + encodeURIComponent(getTestTrackModel().uri)); }); it('should create Spotify code URL without dynamic colors when not using dynamic color code', () => { backgroundColorProducer.next('bg-color'); barColorProducer.next('bar-color'); - dynamicColorProducer.next(TEST_DOMINANT_COLOR); - trackProducer.next(TEST_TRACK_MODEL); + dynamicColorProducer.next(getTestDominantColor()); + trackProducer.next(getTestTrackModel()); useDynamicCodeColorProducer.next(false); fixture.detectChanges(); - expect(component.spotifyCodeUrl).toEqual( - `https://www.spotifycodes.com/downloadCode.php?uri=jpeg%2Fbg-color%2Fbar-color%2F512%2F${TEST_TRACK_MODEL.uri}`); + expect(component.spotifyCodeUrl).toEqual('https://www.spotifycodes.com/downloadCode.php?uri=jpeg%2F' + + `bg-color%2Fbar-color%2F512%2F${encodeURIComponent(getTestTrackModel().uri)}`); }); }); diff --git a/src/app/components/album-display/album-display.component.ts b/src/app/components/album-display/album-display.component.ts index 34cd31b..f52f723 100644 --- a/src/app/components/album-display/album-display.component.ts +++ b/src/app/components/album-display/album-display.component.ts @@ -4,13 +4,12 @@ import { Select, Store } from '@ngxs/store'; import { Observable, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { DominantColor, DominantColorFinder } from '../../core/dominant-color/dominant-color-finder'; -import { AlbumModel, TrackModel } from '../../core/playback/playback.model'; +import { AlbumModel, PlayerState, TrackModel } from '../../core/playback/playback.model'; import { PlaybackState } from '../../core/playback/playback.state'; import { ChangeDynamicColor } from '../../core/settings/settings.actions'; import { SettingsState } from '../../core/settings/settings.state'; import { expandHexColor, isHexColor } from '../../core/util'; import { ImageResponse } from '../../models/image.model'; -import { SpotifyService } from '../../services/spotify/spotify.service'; @Component({ selector: 'app-album-display', @@ -30,7 +29,7 @@ export class AlbumDisplayComponent implements OnInit, OnDestroy { @Select(PlaybackState.album) album$: Observable; - @Select(PlaybackState.isIdle) isIdle$: Observable; + @Select(PlaybackState.playerState) playerState$: Observable; @Select(SettingsState.useDynamicCodeColor) useDynamicCodeColor$: Observable; private useDynamicCodeColor: boolean; @@ -56,8 +55,9 @@ export class AlbumDisplayComponent implements OnInit, OnDestroy { // Template constants readonly spotifyIcon = faSpotify; + readonly playingState = PlayerState.Playing; - constructor(private spotifyService: SpotifyService, private store: Store) {} + constructor(private store: Store) {} ngOnInit(): void { if (!this.dominantColorFinder) { diff --git a/src/app/components/app/app.component.spec.ts b/src/app/components/app/app.component.spec.ts index 1caaaa2..211ff37 100644 --- a/src/app/components/app/app.component.spec.ts +++ b/src/app/components/app/app.component.spec.ts @@ -11,7 +11,9 @@ import { PlayerControlsOptions, Theme } from '../../core/settings/settings.model import { NgxsSelectorMock } from '../../core/testing/ngxs-selector-mock'; import { InactivityService } from '../../services/inactivity/inactivity.service'; import { PlaybackService } from '../../services/playback/playback.service'; -import { SpotifyService } from '../../services/spotify/spotify.service'; +import { SpotifyAuthService } from '../../services/spotify/auth/spotify-auth.service'; +import { SpotifyControlsService } from '../../services/spotify/controls/spotify-controls.service'; +import { SpotifyPollingService } from '../../services/spotify/polling/spotify-polling.service'; import { ErrorComponent } from '../error/error.component'; import { AppComponent } from './app.component'; import Spy = jasmine.Spy; @@ -20,7 +22,9 @@ describe('AppComponent', () => { const mockSelectors = new NgxsSelectorMock(); let app: AppComponent; let fixture: ComponentFixture; - let spotify: SpotifyService; + let auth: SpotifyAuthService; + let controls: SpotifyControlsService; + let polling: SpotifyPollingService; let playback: PlaybackService; let themeProducer: BehaviorSubject; @@ -49,10 +53,14 @@ describe('AppComponent', () => { inactive$: inactiveProducer }), MockProvider(PlaybackService), - MockProvider(SpotifyService) + MockProvider(SpotifyAuthService), + MockProvider(SpotifyControlsService), + MockProvider(SpotifyPollingService) ] }).compileComponents(); - spotify = TestBed.inject(SpotifyService); + auth = TestBed.inject(SpotifyAuthService); + controls = TestBed.inject(SpotifyControlsService); + polling = TestBed.inject(SpotifyPollingService); playback = TestBed.inject(PlaybackService); fixture = TestBed.createComponent(AppComponent); @@ -64,8 +72,8 @@ describe('AppComponent', () => { useDynamicThemeAccentProducer = mockSelectors.defineNgxsSelector(app, 'useDynamicThemeAccent$'); dynamicAccentColorProducer = mockSelectors.defineNgxsSelector(app, 'dynamicAccentColor$'); - SpotifyService.initialized = false; - spotifyInitSpy = spyOn(SpotifyService, 'initialize').and.returnValue(true); + SpotifyAuthService.initialized = false; + spotifyInitSpy = spyOn(SpotifyAuthService, 'initialize').and.returnValue(true); fixture.detectChanges(); })); @@ -74,24 +82,24 @@ describe('AppComponent', () => { expect(app).toBeTruthy(); }); - it('should initialize the Spotify service if not initialized', () => { - expect(SpotifyService.initialize).toHaveBeenCalled(); + it('should initialize the Spotify auth service if not initialized', () => { + expect(SpotifyAuthService.initialize).toHaveBeenCalled(); }); - it('should not initialize the Spotify service if already initialized', () => { - SpotifyService.initialized = true; + it('should not initialize the Spotify auth service if already initialized', () => { + SpotifyAuthService.initialized = true; spyOn(console, 'error'); spotifyInitSpy.calls.reset(); app.ngOnInit(); - expect(SpotifyService.initialize).not.toHaveBeenCalled(); + expect(SpotifyAuthService.initialize).not.toHaveBeenCalled(); expect(console.error).not.toHaveBeenCalled(); }); - it('should print error if Spotify service not initialized', () => { + it('should print error if Spotify auth service not initialized', () => { spotifyInitSpy.and.returnValue(false); spyOn(console, 'error'); app.ngOnInit(); - expect(SpotifyService.initialize).toHaveBeenCalled(); + expect(SpotifyAuthService.initialize).toHaveBeenCalled(); expect(console.error).toHaveBeenCalled(); }); @@ -112,8 +120,16 @@ describe('AppComponent', () => { expect(failedInit.componentInstance.message).toEqual('ShowTunes failed to initialize!'); }); - it('should initialize the Spotify service subscriptions', () => { - expect(spotify.initSubscriptions).toHaveBeenCalled(); + it('should initialize the Spotify auth service subscriptions', () => { + expect(auth.initSubscriptions).toHaveBeenCalled(); + }); + + it('should initialize the Spotify controls service subscriptions', () => { + expect(controls.initSubscriptions).toHaveBeenCalled(); + }); + + it('should initialize the Spotify polling service subscriptions', () => { + expect(polling.initSubscriptions).toHaveBeenCalled(); }); it('should initialize the playbackService', () => { diff --git a/src/app/components/app/app.component.ts b/src/app/components/app/app.component.ts index b838c42..de08448 100644 --- a/src/app/components/app/app.component.ts +++ b/src/app/components/app/app.component.ts @@ -6,7 +6,9 @@ import { PlayerControlsOptions } from '../../core/settings/settings.model'; import { SettingsState } from '../../core/settings/settings.state'; import { InactivityService } from '../../services/inactivity/inactivity.service'; import { PlaybackService } from '../../services/playback/playback.service'; -import { SpotifyService } from '../../services/spotify/spotify.service'; +import { SpotifyAuthService } from '../../services/spotify/auth/spotify-auth.service'; +import { SpotifyControlsService } from '../../services/spotify/controls/spotify-controls.service'; +import { SpotifyPollingService } from '../../services/spotify/polling/spotify-polling.service'; @Component({ selector: 'app-root', @@ -26,16 +28,23 @@ export class AppComponent implements OnInit, OnDestroy { fadeCursor = false; appInitialized = false; - constructor(private spotify: SpotifyService, private playback: PlaybackService, private inactivity: InactivityService) {} + constructor( + private auth: SpotifyAuthService, + private controls: SpotifyControlsService, + private polling: SpotifyPollingService, + private playback: PlaybackService, + private inactivity: InactivityService + ) {} ngOnInit(): void { - if (!SpotifyService.initialized && !SpotifyService.initialize()) { - console.error('Failed to initialize Spotify service'); + if (!SpotifyAuthService.initialized && !SpotifyAuthService.initialize()) { + console.error('Failed to initialize the Spotify authentication service'); } else { this.appInitialized = true; - this.spotify.initSubscriptions(); - + this.auth.initSubscriptions(); + this.controls.initSubscriptions(); + this.polling.initSubscriptions(); this.playback.initialize(); this.inactivity.inactive$ diff --git a/src/app/components/callback/callback.component.spec.ts b/src/app/components/callback/callback.component.spec.ts index 84bb08a..b068a5d 100644 --- a/src/app/components/callback/callback.component.spec.ts +++ b/src/app/components/callback/callback.component.spec.ts @@ -7,7 +7,7 @@ import { MockComponent, MockProvider } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; import { AuthToken } from '../../core/auth/auth.model'; import { NgxsSelectorMock } from '../../core/testing/ngxs-selector-mock'; -import { SpotifyService } from '../../services/spotify/spotify.service'; +import { SpotifyAuthService } from '../../services/spotify/auth/spotify-auth.service'; import { LoadingComponent } from '../loading/loading.component'; import { CallbackComponent } from './callback.component'; @@ -18,7 +18,7 @@ describe('CallbackComponent', () => { let fixture: ComponentFixture; let store: Store; let router: Router; - let spotify: SpotifyService; + let auth: SpotifyAuthService; let tokenProducer: BehaviorSubject; let paramMapProducer: BehaviorSubject; @@ -33,21 +33,21 @@ describe('CallbackComponent', () => { providers: [ { provide: ActivatedRoute, useValue: { queryParamMap: paramMapProducer } }, MockProvider(Router), - MockProvider(SpotifyService), + MockProvider(SpotifyAuthService), MockProvider(Store) ] }).compileComponents(); store = TestBed.inject(Store); router = TestBed.inject(Router); - spotify = TestBed.inject(SpotifyService); + auth = TestBed.inject(SpotifyAuthService); fixture = TestBed.createComponent(CallbackComponent); component = fixture.componentInstance; tokenProducer = mockSelectors.defineNgxsSelector(component, 'token$'); - spotify.compareState = jasmine.createSpy().and.returnValue(true); - spotify.requestAuthToken = jasmine.createSpy().and.returnValue(Promise.resolve(null)); + auth.compareState = jasmine.createSpy().and.returnValue(true); + auth.requestAuthToken = jasmine.createSpy().and.returnValue(Promise.resolve(null)); fixture.detectChanges(); })); @@ -79,7 +79,7 @@ describe('CallbackComponent', () => { it('should compare callback state value with current value', () => { paramMapProducer.next(convertToParamMap({ code: 'test_code', state: 'test_state' })); - expect(spotify.compareState).toHaveBeenCalled(); + expect(auth.compareState).toHaveBeenCalled(); }); it('should fail auth token request when callback contains an error', () => { @@ -95,24 +95,24 @@ describe('CallbackComponent', () => { }); it('should fail auth token request when callback doesn\'t contain a state value', () => { - spotify.compareState = jasmine.createSpy().and.returnValue(false); + auth.compareState = jasmine.createSpy().and.returnValue(false); spyOn(console, 'error'); paramMapProducer.next(convertToParamMap({ code: 'test_code' })); expect(console.error).toHaveBeenCalledTimes(2); }); it('should fail auth token request when callback contains an invalid state value', () => { - spotify.compareState = jasmine.createSpy().and.returnValue(false); + auth.compareState = jasmine.createSpy().and.returnValue(false); spyOn(console, 'error'); paramMapProducer.next(convertToParamMap({ code: 'test_code', state: 'bad_state' })); expect(console.error).toHaveBeenCalledTimes(2); }); it('should give an error for a failed auth token request', fakeAsync(() => { - spotify.requestAuthToken = jasmine.createSpy().and.returnValue(Promise.reject('test_error')); + auth.requestAuthToken = jasmine.createSpy().and.returnValue(Promise.reject('test_error')); spyOn(console, 'error'); paramMapProducer.next(convertToParamMap({ code: 'bad_code', state: 'test_state' })); - expect(spotify.requestAuthToken).toHaveBeenCalled(); + expect(auth.requestAuthToken).toHaveBeenCalled(); tick(); expect(console.error).toHaveBeenCalledTimes(1); })); diff --git a/src/app/components/callback/callback.component.ts b/src/app/components/callback/callback.component.ts index d42a252..ea23c20 100644 --- a/src/app/components/callback/callback.component.ts +++ b/src/app/components/callback/callback.component.ts @@ -4,7 +4,7 @@ import { Select } from '@ngxs/store'; import { Observable } from 'rxjs'; import { AuthToken } from '../../core/auth/auth.model'; import { AuthState } from '../../core/auth/auth.state'; -import { SpotifyService } from '../../services/spotify/spotify.service'; +import { SpotifyAuthService } from '../../services/spotify/auth/spotify-auth.service'; const codeKey = 'code'; const errorKey = 'error'; @@ -22,7 +22,7 @@ export class CallbackComponent implements OnInit { constructor( private route: ActivatedRoute, private router: Router, - private spotify: SpotifyService) { } + private auth: SpotifyAuthService) { } ngOnInit(): void { // redirect to /dashboard if already authenticated @@ -38,9 +38,9 @@ export class CallbackComponent implements OnInit { const error = params.get(errorKey); const state = params.get(stateKey); - if (!error && code && this.spotify.compareState(state)) { + if (!error && code && this.auth.compareState(state)) { // use code to get auth tokens - this.spotify.requestAuthToken(code, false) + this.auth.requestAuthToken(code, false) .catch((reason) => { console.error(`Spotify request failed: ${reason}`); this.router.navigateByUrl('/error'); @@ -51,7 +51,7 @@ export class CallbackComponent implements OnInit { if (!code) { console.error('No code value given for callback'); } - else if (!this.spotify.compareState(state)) { + else if (!this.auth.compareState(state)) { console.error(`State value is not correct: ${state}`); } } diff --git a/src/app/components/color-picker/color-picker.component.scss b/src/app/components/color-picker/color-picker.component.scss index 46d7766..5bdf134 100644 --- a/src/app/components/color-picker/color-picker.component.scss +++ b/src/app/components/color-picker/color-picker.component.scss @@ -1,5 +1,3 @@ -@use '../../../assets/styles/colors'; - ::ng-deep .color-picker-menu { max-width: 100% !important; width: calc(48px * 6) !important; @@ -18,11 +16,11 @@ } .white-unselected-light { - border: 2px solid colors.$primary-light !important; + border: 2px solid var(--color-primary-light) !important; } .white-unselected-dark { - border: 2px solid colors.$primary-dark !important; + border: 2px solid var(--color-primary-dark) !important; } .selected { @@ -30,9 +28,9 @@ } .white-selected-light { - box-shadow: colors.$primary-light 0px 0px 0px 6px inset !important; + box-shadow: var(--color-primary-light) 0px 0px 0px 6px inset !important; } .white-selected-dark { - box-shadow: colors.$primary-dark 0px 0px 0px 6px inset !important; + box-shadow: var(--color-primary-dark) 0px 0px 0px 6px inset !important; } diff --git a/src/app/components/devices/devices.component.html b/src/app/components/devices/devices.component.html index 49776c4..b942c05 100644 --- a/src/app/components/devices/devices.component.html +++ b/src/app/components/devices/devices.component.html @@ -1,12 +1,17 @@ - - - - - @@ -22,15 +43,24 @@ fxLayout="row" fxLayoutAlign="center center" fxLayoutGap="6px"> -
- +
- + diff --git a/src/app/components/track-player/track-player-controls/track-player-controls.component.spec.ts b/src/app/components/track-player/track-player-controls/track-player-controls.component.spec.ts index 943e51b..ad25846 100644 --- a/src/app/components/track-player/track-player-controls/track-player-controls.component.spec.ts +++ b/src/app/components/track-player/track-player-controls/track-player-controls.component.spec.ts @@ -13,11 +13,13 @@ import { NgxsModule } from '@ngxs/store'; import { MockComponent, MockProvider } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; import { PlayerControlsOptions } from '../../../core/settings/settings.model'; +import { MockInteractionThrottleDirective } from '../../../core/testing/mock-interaction-throttle.directive'; import { NgxsSelectorMock } from '../../../core/testing/ngxs-selector-mock'; -import { callComponentChange } from '../../../core/testing/test-util'; +import { getTestDisallowsModel } from '../../../core/testing/test-models'; +import { callComponentChange, callComponentChanges } from '../../../core/testing/test-util'; import { InactivityService } from '../../../services/inactivity/inactivity.service'; -import { SpotifyService } from '../../../services/spotify/spotify.service'; -import { StorageService } from '../../../services/storage/storage.service'; +import { SpotifyControlsService } from '../../../services/spotify/controls/spotify-controls.service'; +import { PREVIOUS_VOLUME, StorageService } from '../../../services/storage/storage.service'; import { DevicesComponent } from '../../devices/devices.component'; import { TrackPlayerControlsComponent } from './track-player-controls.component'; @@ -35,7 +37,7 @@ describe('TrackPlayerControlsComponent', () => { let component: TrackPlayerControlsComponent; let fixture: ComponentFixture; let loader: HarnessLoader; - let spotify: SpotifyService; + let controls: SpotifyControlsService; let storage: StorageService; let showPlayerControlsProducer: BehaviorSubject; @@ -46,6 +48,7 @@ describe('TrackPlayerControlsComponent', () => { TestBed.configureTestingModule({ declarations: [ TrackPlayerControlsComponent, + MockInteractionThrottleDirective, MockComponent(DevicesComponent) ], imports: [ @@ -55,7 +58,7 @@ describe('TrackPlayerControlsComponent', () => { NgxsModule.forRoot([], {developmentMode: true}) ], providers: [ - MockProvider(SpotifyService), + MockProvider(SpotifyControlsService), MockProvider(StorageService), MockProvider(InactivityService, { inactive$: inactivityProducer @@ -63,7 +66,7 @@ describe('TrackPlayerControlsComponent', () => { MockProvider(ElementRef) ] }).compileComponents(); - spotify = TestBed.inject(SpotifyService); + controls = TestBed.inject(SpotifyControlsService); storage = TestBed.inject(StorageService); fixture = TestBed.createComponent(TrackPlayerControlsComponent); @@ -126,6 +129,7 @@ describe('TrackPlayerControlsComponent', () => { expect(shuffle).toBeTruthy(); expect(shuffle.classes['track-player-icon']).toBeTruthy(); expect(shuffle.classes['track-player-icon-accent']).toBeFalsy(); + expect(shuffle.classes['default-cursor']).toBeFalsy(); }); it('should display shuffle button with accent if shuffle is on', () => { @@ -137,9 +141,76 @@ describe('TrackPlayerControlsComponent', () => { expect(shuffle).toBeTruthy(); expect(shuffle.classes['track-player-icon']).toBeFalsy(); expect(shuffle.classes['track-player-icon-accent']).toBeTruthy(); + expect(shuffle.classes['default-cursor']).toBeFalsy(); }); - it('should call onToggleShuffle when shuffle button is clicked', async () => { + it('should display shuffle button with accent if shuffle is off and isSmartShuffle', () => { + component.isShuffle = false; + component.isSmartShuffle = true; + callComponentChanges(fixture, ['isShuffle', 'isSmartShuffle'], [component.isShuffle, component.isSmartShuffle]); + const buttons = fixture.debugElement.queryAll(By.directive(MatButton)); + expect(buttons.length).toEqual(BUTTON_COUNT); + const shuffle = buttons[SHUFFLE_BUTTON_INDEX]; + expect(shuffle).toBeTruthy(); + expect(shuffle.classes['track-player-icon']).toBeFalsy(); + expect(shuffle.classes['track-player-icon-accent']).toBeTruthy(); + expect(shuffle.classes['default-cursor']).toBeTruthy(); + }); + + it('should display the shuffle icon when not isSmartShuffle', async () => { + component.isSmartShuffle = false; + callComponentChange(fixture, 'isSmartShuffle', component.isSmartShuffle); + const buttons = await loader.getAllHarnesses(MatButtonHarness); + expect(buttons.length).toEqual(BUTTON_COUNT); + const shuffleButton = buttons[SHUFFLE_BUTTON_INDEX]; + const icon = await shuffleButton.getHarness(MatIconHarness); + expect(icon).toBeTruthy(); + expect(await icon.getName()).toEqual('shuffle'); + }); + + it('should display the smart shuffle icon when isSmartShuffle', async () => { + component.isSmartShuffle = true; + callComponentChange(fixture, 'isSmartShuffle', component.isSmartShuffle); + const buttons = await loader.getAllHarnesses(MatButtonHarness); + expect(buttons.length).toEqual(BUTTON_COUNT); + const shuffleButton = buttons[SHUFFLE_BUTTON_INDEX]; + const icon = await shuffleButton.getHarness(MatIconHarness); + expect(icon).toBeTruthy(); + expect(await icon.getName()).toEqual('model_training'); + }); + + it('should disable the smart shuffle button ripple when isSmartShuffle', async () => { + component.isSmartShuffle = true; + callComponentChange(fixture, 'isSmartShuffle', component.isSmartShuffle); + const buttons = fixture.debugElement.queryAll(By.directive(MatButton)); + expect(buttons.length).toEqual(BUTTON_COUNT); + const shuffle = buttons[SHUFFLE_BUTTON_INDEX]; + expect(shuffle.attributes['ng-reflect-disable-ripple']).toEqual('true'); + }); + + it('should disable the shuffle button when shuffle disallowed', async () => { + component.disallows = { + ...getTestDisallowsModel(), + shuffle: true + }; + fixture.detectChanges(); + const buttons = await loader.getAllHarnesses(MatButtonHarness); + const shuffleButton = buttons[SHUFFLE_BUTTON_INDEX]; + expect(await shuffleButton.isDisabled()).toBeTrue(); + }); + + it('should not disable the shuffle button when shuffle is not disallowed', async () => { + component.disallows = getTestDisallowsModel(); + fixture.detectChanges(); + const buttons = await loader.getAllHarnesses(MatButtonHarness); + const shuffleButton = buttons[SHUFFLE_BUTTON_INDEX]; + expect(await shuffleButton.isDisabled()).toBeFalse(); + }); + + it('should call onToggleShuffle when shuffle button is clicked and not isSmartShuffle', async () => { + component.isSmartShuffle = false; + callComponentChange(fixture, 'isSmartShuffle', component.isSmartShuffle); + spyOn(component, 'onToggleShuffle'); const buttons = await loader.getAllHarnesses(MatButtonHarness); expect(buttons.length).toEqual(BUTTON_COUNT); @@ -158,6 +229,50 @@ describe('TrackPlayerControlsComponent', () => { expect(component.onSkipPrevious).toHaveBeenCalled(); }); + it('should disable the skip prev button when skip prev and seek disallowed', async () => { + component.disallows = { + ...getTestDisallowsModel(), + skipPrev: true, + seek: true + }; + fixture.detectChanges(); + const buttons = await loader.getAllHarnesses(MatButtonHarness); + const prevButton = buttons[PREVIOUS_BUTTON_INDEX]; + expect(await prevButton.isDisabled()).toBeTrue(); + }); + + it('should not disable the skip prev button when skip prev disallowed and seek is not disallowed', async () => { + component.disallows = { + ...getTestDisallowsModel(), + skipPrev: true, + seek: false + }; + fixture.detectChanges(); + const buttons = await loader.getAllHarnesses(MatButtonHarness); + const prevButton = buttons[PREVIOUS_BUTTON_INDEX]; + expect(await prevButton.isDisabled()).toBeFalse(); + }); + + it('should not disable the skip prev button when skip prev is not disallowed and seek disallowed', async () => { + component.disallows = { + ...getTestDisallowsModel(), + skipPrev: false, + seek: true + }; + fixture.detectChanges(); + const buttons = await loader.getAllHarnesses(MatButtonHarness); + const prevButton = buttons[PREVIOUS_BUTTON_INDEX]; + expect(await prevButton.isDisabled()).toBeFalse(); + }); + + it('should not disable the skip prev button when skip prev and seek are not disallowed', async () => { + component.disallows = getTestDisallowsModel(); + fixture.detectChanges(); + const buttons = await loader.getAllHarnesses(MatButtonHarness); + const prevButton = buttons[PREVIOUS_BUTTON_INDEX]; + expect(await prevButton.isDisabled()).toBeFalse(); + }); + it('should call onPause when play/pause button is clicked', async () => { spyOn(component, 'onPause'); const buttons = await loader.getAllHarnesses(MatButtonHarness); @@ -189,6 +304,72 @@ describe('TrackPlayerControlsComponent', () => { expect(await icon.getName()).toEqual('pause'); }); + it('should disable the pause button when is playing and pause disallowed', async () => { + component.isPlaying = true; + component.disallows = { + ...getTestDisallowsModel(), + pause: true + }; + fixture.detectChanges(); + const buttons = await loader.getAllHarnesses(MatButtonHarness); + const pauseButton = buttons[PAUSE_BUTTON_INDEX]; + expect(await pauseButton.isDisabled()).toBeTrue(); + }); + + it('should not disable the pause button when is playing and resume disallowed', async () => { + component.isPlaying = true; + component.disallows = { + ...getTestDisallowsModel(), + resume: true + }; + fixture.detectChanges(); + const buttons = await loader.getAllHarnesses(MatButtonHarness); + const pauseButton = buttons[PAUSE_BUTTON_INDEX]; + expect(await pauseButton.isDisabled()).toBeFalse(); + }); + + it('should not disable the pause button when is playing and pause is not disallowed', async () => { + component.isPlaying = true; + component.disallows = getTestDisallowsModel(); + fixture.detectChanges(); + const buttons = await loader.getAllHarnesses(MatButtonHarness); + const pauseButton = buttons[PAUSE_BUTTON_INDEX]; + expect(await pauseButton.isDisabled()).toBeFalse(); + }); + + it('should disable the resume button when is not playing and resume disallowed', async () => { + component.isPlaying = false; + component.disallows = { + ...getTestDisallowsModel(), + resume: true + }; + fixture.detectChanges(); + const buttons = await loader.getAllHarnesses(MatButtonHarness); + const resumeButton = buttons[PAUSE_BUTTON_INDEX]; + expect(await resumeButton.isDisabled()).toBeTrue(); + }); + + it('should not disable the resume button when is not playing and pause disallowed', async () => { + component.isPlaying = false; + component.disallows = { + ...getTestDisallowsModel(), + pause: true + }; + fixture.detectChanges(); + const buttons = await loader.getAllHarnesses(MatButtonHarness); + const resumeButton = buttons[PAUSE_BUTTON_INDEX]; + expect(await resumeButton.isDisabled()).toBeFalse(); + }); + + it('should not disable the resume button when is not playing and resume is not disallowed', async () => { + component.isPlaying = false; + component.disallows = getTestDisallowsModel(); + fixture.detectChanges(); + const buttons = await loader.getAllHarnesses(MatButtonHarness); + const resumeButton = buttons[PAUSE_BUTTON_INDEX]; + expect(await resumeButton.isDisabled()).toBeFalse(); + }); + it('should call onSkipNext when next button is clicked', async () => { spyOn(component, 'onSkipNext'); const buttons = await loader.getAllHarnesses(MatButtonHarness); @@ -198,6 +379,25 @@ describe('TrackPlayerControlsComponent', () => { expect(component.onSkipNext).toHaveBeenCalled(); }); + it('should disable the skip next button when skip next disallowed', async () => { + component.disallows = { + ...getTestDisallowsModel(), + skipNext: true + }; + fixture.detectChanges(); + const buttons = await loader.getAllHarnesses(MatButtonHarness); + const nextButton = buttons[NEXT_BUTTON_INDEX]; + expect(await nextButton.isDisabled()).toBeTrue(); + }); + + it('should not disable the skip next button when skip next is not disallowed', async () => { + component.disallows = getTestDisallowsModel(); + fixture.detectChanges(); + const buttons = await loader.getAllHarnesses(MatButtonHarness); + const nextButton = buttons[NEXT_BUTTON_INDEX]; + expect(await nextButton.isDisabled()).toBeFalse(); + }); + it('should display repeat button with accent if repeat is not off', () => { component.repeatState = 'track'; callComponentChange(fixture, 'repeatState', component.repeatState); @@ -262,6 +462,25 @@ describe('TrackPlayerControlsComponent', () => { expect(await icon.getName()).toEqual('repeat_one'); }); + it('should disable the repeat button when repeat context disallowed', async () => { + component.disallows = { + ...getTestDisallowsModel(), + repeatContext: true + }; + fixture.detectChanges(); + const buttons = await loader.getAllHarnesses(MatButtonHarness); + const repeatButton = buttons[REPEAT_BUTTON_INDEX]; + expect(await repeatButton.isDisabled()).toBeTrue(); + }); + + it('should not disable the repeat button when repeat context is not disallowed', async () => { + component.disallows = getTestDisallowsModel(); + fixture.detectChanges(); + const buttons = await loader.getAllHarnesses(MatButtonHarness); + const repeatButton = buttons[REPEAT_BUTTON_INDEX]; + expect(await repeatButton.isDisabled()).toBeFalse(); + }); + it('should call onVolumeMute when volume button is clicked', async () => { spyOn(component, 'onVolumeMute'); const buttons = await loader.getAllHarnesses(MatButtonHarness); @@ -395,101 +614,170 @@ describe('TrackPlayerControlsComponent', () => { it('should toggle Spotify playing on pause', () => { component.onPause(); - expect(spotify.togglePlaying).toHaveBeenCalled(); + expect(controls.togglePlaying).toHaveBeenCalled(); }); it('should call Spotify skip on skip previous', () => { component.onSkipPrevious(); - expect(spotify.skipPrevious).toHaveBeenCalledWith(false); + expect(controls.skipPrevious).toHaveBeenCalledWith(false, false); + }); + + it('should call Spotify skip on skip previous with skip prev disallowed', () => { + component.disallows = { + ...getTestDisallowsModel(), + skipPrev: true + }; + fixture.detectChanges(); + component.onSkipPrevious(); + expect(controls.skipPrevious).toHaveBeenCalledWith(true, false); + }); + + it('should call Spotify skip on skip previous with seek disallowed', () => { + component.disallows = { + ...getTestDisallowsModel(), + seek: true + }; + fixture.detectChanges(); + component.onSkipPrevious(); + expect(controls.skipPrevious).toHaveBeenCalledWith(false, true); + }); + + it('should call Spotify skip on skip previous with skip prev and seek disallowed', () => { + component.disallows = { + ...getTestDisallowsModel(), + skipPrev: true, + seek: true + }; + fixture.detectChanges(); + component.onSkipPrevious(); + expect(controls.skipPrevious).toHaveBeenCalledWith(true, true); }); it('should call Spotify skip on skip next', () => { component.onSkipNext(); - expect(spotify.skipNext).toHaveBeenCalled(); + expect(controls.skipNext).toHaveBeenCalled(); }); it('should change Spotify device volume on volume change', () => { const change = new MatSliderChange(); change.value = 10; component.onVolumeChange(change); - expect(spotify.setVolume).toHaveBeenCalledWith(10); + expect(controls.setVolume).toHaveBeenCalledWith(10); }); it('should change Spotify volume to 0 on volume mute when volume > 0', () => { component.volume = 1; component.onVolumeMute(); - expect(storage.set).toHaveBeenCalledWith(SpotifyService.PREVIOUS_VOLUME, '1'); - expect(spotify.setVolume).toHaveBeenCalledWith(0); + expect(storage.set).toHaveBeenCalledWith(PREVIOUS_VOLUME, '1'); + expect(controls.setVolume).toHaveBeenCalledWith(0); }); it('should change Spotify volume to previous volume on volume mute when volume = 0', () => { storage.get = jasmine.createSpy().and.returnValue('10'); component.volume = 0; component.onVolumeMute(); - expect(storage.get).toHaveBeenCalledWith(SpotifyService.PREVIOUS_VOLUME); - expect(spotify.setVolume).toHaveBeenCalledWith(10); + expect(storage.get).toHaveBeenCalledWith(PREVIOUS_VOLUME); + expect(controls.setVolume).toHaveBeenCalledWith(10); }); it('should change Spotify volume to default volume on volume mute when volume = 0 and previous NaN', () => { storage.get = jasmine.createSpy().and.returnValue('abc'); component.volume = 0; component.onVolumeMute(); - expect(storage.get).toHaveBeenCalledWith(SpotifyService.PREVIOUS_VOLUME); - expect(spotify.setVolume).toHaveBeenCalledWith(50); + expect(storage.get).toHaveBeenCalledWith(PREVIOUS_VOLUME); + expect(controls.setVolume).toHaveBeenCalledWith(50); }); it('should change Spotify volume to default volume on volume mute when volume = 0 and previous = 0', () => { storage.get = jasmine.createSpy().and.returnValue('0'); component.volume = 0; component.onVolumeMute(); - expect(storage.get).toHaveBeenCalledWith(SpotifyService.PREVIOUS_VOLUME); - expect(spotify.setVolume).toHaveBeenCalledWith(50); + expect(storage.get).toHaveBeenCalledWith(PREVIOUS_VOLUME); + expect(controls.setVolume).toHaveBeenCalledWith(50); }); it('should call Spotify shuffle on toggle shuffle', () => { component.onToggleShuffle(); - expect(spotify.toggleShuffle).toHaveBeenCalled(); + expect(controls.toggleShuffle).toHaveBeenCalled(); + }); + + it('should not call toggleShuffle when onToggleShuffle is called', async () => { + component.isSmartShuffle = true; + callComponentChange(fixture, 'isSmartShuffle', component.isSmartShuffle); + + component.onToggleShuffle(); + expect(controls.toggleShuffle).not.toHaveBeenCalled(); }); it('should change Spotify repeat state to \'context\' on repeat change when state is off', () => { component.repeatState = 'off'; component.onRepeatChange(); - expect(spotify.setRepeatState).toHaveBeenCalledWith('context'); + expect(controls.setRepeatState).toHaveBeenCalledWith('context'); }); it('should change Spotify repeat state to \'track\' on repeat change when state is context', () => { component.repeatState = 'context'; component.onRepeatChange(); - expect(spotify.setRepeatState).toHaveBeenCalledWith('track'); + expect(controls.setRepeatState).toHaveBeenCalledWith('track'); }); it('should change Spotify repeat state to \'off\' on repeat change when state is track', () => { component.repeatState = 'track'; component.onRepeatChange(); - expect(spotify.setRepeatState).toHaveBeenCalledWith('off'); + expect(controls.setRepeatState).toHaveBeenCalledWith('off'); }); it('should change Spotify repeat state to \'off\' on repeat change when state is null', () => { component.repeatState = null; component.onRepeatChange(); - expect(spotify.setRepeatState).toHaveBeenCalledWith('off'); + expect(controls.setRepeatState).toHaveBeenCalledWith('off'); }); it('should toggle Spotify liked on like change', () => { component.onLikeChange(); - expect(spotify.toggleLiked).toHaveBeenCalled(); + expect(controls.toggleLiked).toHaveBeenCalled(); }); it('should set the shuffle class when isShuffle', fakeAsync(() => { component.isShuffle = true; callComponentChange(fixture, 'isShuffle', component.isShuffle); - expect(component.shuffleClass).toEqual('track-player-icon-accent'); + expect(component.shuffleClasses).toEqual(['track-player-icon-accent', '']); })); it('should set the shuffle class when not isShuffle', () => { component.isShuffle = false; callComponentChange(fixture, 'isShuffle', component.isShuffle); - expect(component.shuffleClass).toEqual('track-player-icon'); + expect(component.shuffleClasses).toEqual(['track-player-icon', '']); + }); + + it('should set the shuffle classes when isShuffle and isSmartShuffle', () => { + component.isShuffle = true; + component.isSmartShuffle = true; + callComponentChanges(fixture, ['isShuffle', 'isSmartShuffle'], [component.isShuffle, component.isSmartShuffle]); + + expect(component.shuffleClasses).toEqual(['track-player-icon-accent', 'default-cursor']); + }); + + it('should set the shuffle classes when not isShuffle and isSmartShuffle', () => { + component.isShuffle = false; + component.isSmartShuffle = true; + callComponentChanges(fixture, ['isShuffle', 'isSmartShuffle'], [component.isShuffle, component.isSmartShuffle]); + + expect(component.shuffleClasses).toEqual(['track-player-icon-accent', 'default-cursor']); + }); + + it('should set the shuffle icon when isShuffle and not isSmartShuffle', () => { + component.isShuffle = true; + component.isSmartShuffle = false; + callComponentChanges(fixture, ['isShuffle', 'isSmartShuffle'], [component.isShuffle, component.isSmartShuffle]); + expect(component.shuffleIcon).toEqual('shuffle'); + }); + + it('should set the shuffle icon when isShuffle and isSmartShuffle', () => { + component.isShuffle = true; + component.isSmartShuffle = true; + callComponentChanges(fixture, ['isShuffle', 'isSmartShuffle'], [component.isShuffle, component.isSmartShuffle]); + expect(component.shuffleIcon).toEqual('model_training'); }); it('should set the pause icon name when playing', () => { diff --git a/src/app/components/track-player/track-player-controls/track-player-controls.component.ts b/src/app/components/track-player/track-player-controls/track-player-controls.component.ts index d597b40..493a84a 100644 --- a/src/app/components/track-player/track-player-controls/track-player-controls.component.ts +++ b/src/app/components/track-player/track-player-controls/track-player-controls.component.ts @@ -3,17 +3,22 @@ import { MatSliderChange } from '@angular/material/slider'; import { Select } from '@ngxs/store'; import { Observable, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; +import { DisallowsModel, getDefaultDisallows } from '../../../core/playback/playback.model'; +import { PlaybackState } from '../../../core/playback/playback.state'; import { PlayerControlsOptions } from '../../../core/settings/settings.model'; import { SettingsState } from '../../../core/settings/settings.state'; import { InactivityService } from '../../../services/inactivity/inactivity.service'; -import { SpotifyService } from '../../../services/spotify/spotify.service'; -import { StorageService } from '../../../services/storage/storage.service'; +import { SpotifyControlsService } from '../../../services/spotify/controls/spotify-controls.service'; +import { PREVIOUS_VOLUME, StorageService } from '../../../services/storage/storage.service'; // Default values const DEFAULT_VOLUME = 50; const FADE_DURATION = 500; // ms // Icons +const SHUFFLE_ICON = 'shuffle'; +const SMART_SHUFFLE_ICON = 'model_training'; + const PLAY_ICON = 'play_arrow'; const PAUSE_ICON = 'pause'; @@ -27,6 +32,8 @@ const VOLUME_MUTE_ICON = 'volume_off'; const ICON_CLASS_PRIMARY = 'track-player-icon'; const ICON_CLASS_ACCENT = 'track-player-icon-accent'; +const DEFAULT_POINTER = 'default-cursor'; + // Keys const REPEAT_OFF = 'off'; const REPEAT_CONTEXT = 'context'; @@ -41,12 +48,15 @@ export class TrackPlayerControlsComponent implements OnInit, OnChanges, OnDestro private ngUnsubscribe = new Subject(); @Input() isShuffle = false; + @Input() isSmartShuffle = false; @Input() isPlaying = false; @Input() repeatState: string; @Input() volume = DEFAULT_VOLUME; @Input() isLiked = false; + @Input() disallows: DisallowsModel = getDefaultDisallows(); - shuffleClass = this.getShuffleClass(); + shuffleClasses = this.getShuffleClasses(); + shuffleIcon = this.getShuffleIcon(); playIcon = this.getPlayIcon(); repeatIcon = this.getRepeatIcon(); repeatClass = this.getRepeatClass(); @@ -57,10 +67,12 @@ export class TrackPlayerControlsComponent implements OnInit, OnChanges, OnDestro fadePlayerControls: boolean; - constructor(private spotify: SpotifyService, - private storage: StorageService, - private inactivity: InactivityService, - private element: ElementRef) {} + constructor( + private controls: SpotifyControlsService, + private storage: StorageService, + private inactivity: InactivityService, + private element: ElementRef + ) {} ngOnInit(): void { this.showPlayerControls$ @@ -85,7 +97,11 @@ export class TrackPlayerControlsComponent implements OnInit, OnChanges, OnDestro ngOnChanges(changes: SimpleChanges): void { if (changes.isShuffle) { - this.shuffleClass = this.getShuffleClass(); + this.shuffleClasses = this.getShuffleClasses(); + } + if (changes.isSmartShuffle) { + this.shuffleIcon = this.getShuffleIcon(); + this.shuffleClasses = this.getShuffleClasses(); } if (changes.isPlaying) { this.playIcon = this.getPlayIcon(); @@ -108,37 +124,39 @@ export class TrackPlayerControlsComponent implements OnInit, OnChanges, OnDestro } onPause(): void { - this.spotify.togglePlaying(); + this.controls.togglePlaying(); } onSkipPrevious(): void { - this.spotify.skipPrevious(false); + this.controls.skipPrevious(this.disallows.skipPrev, this.disallows.seek); } onSkipNext(): void { - this.spotify.skipNext(); + this.controls.skipNext(); } onVolumeChange(change: MatSliderChange): void { - this.spotify.setVolume(change.value); + this.controls.setVolume(change.value); } onVolumeMute(): void { let volumeChange = DEFAULT_VOLUME; if (this.volume > 0) { - this.storage.set(SpotifyService.PREVIOUS_VOLUME, this.volume.toString()); + this.storage.set(PREVIOUS_VOLUME, this.volume.toString()); volumeChange = 0; } else { - const previousVolume = parseInt(this.storage.get(SpotifyService.PREVIOUS_VOLUME), 10); + const previousVolume = parseInt(this.storage.get(PREVIOUS_VOLUME), 10); if (previousVolume && !isNaN(previousVolume) && previousVolume > 0) { volumeChange = previousVolume; } } - this.spotify.setVolume(volumeChange); + this.controls.setVolume(volumeChange); } onToggleShuffle(): void { - this.spotify.toggleShuffle(); + if (!this.isSmartShuffle) { + this.controls.toggleShuffle(); + } } onRepeatChange(): void { @@ -151,15 +169,22 @@ export class TrackPlayerControlsComponent implements OnInit, OnChanges, OnDestro repeatState = REPEAT_TRACK; break; } - this.spotify.setRepeatState(repeatState); + this.controls.setRepeatState(repeatState); } onLikeChange(): void { - this.spotify.toggleLiked(); + this.controls.toggleLiked(); + } + + private getShuffleClasses(): string[] { + return [ + this.isShuffle || this.isSmartShuffle ? ICON_CLASS_ACCENT : ICON_CLASS_PRIMARY, + this.isSmartShuffle ? DEFAULT_POINTER : '' + ]; } - private getShuffleClass(): string { - return this.isShuffle ? ICON_CLASS_ACCENT : ICON_CLASS_PRIMARY; + private getShuffleIcon(): string { + return this.isSmartShuffle ? SMART_SHUFFLE_ICON : SHUFFLE_ICON; } private getPlayIcon(): string { diff --git a/src/app/components/track-player/track-player-progress/track-player-progress.component.html b/src/app/components/track-player/track-player-progress/track-player-progress.component.html index 42ccca4..b26f9d3 100644 --- a/src/app/components/track-player/track-player-progress/track-player-progress.component.html +++ b/src/app/components/track-player/track-player-progress/track-player-progress.component.html @@ -2,7 +2,12 @@ fxLayout="column" fxLayoutAlign="center center">
- +
{ let component: TrackPlayerProgressComponent; let fixture: ComponentFixture; let loader: HarnessLoader; - let spotify: SpotifyService; + let controls: SpotifyControlsService; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ - declarations: [ TrackPlayerProgressComponent ], + declarations: [ + TrackPlayerProgressComponent, + MockInteractionThrottleDirective + ], imports: [ MatSliderModule, NgxsModule.forRoot([], { developmentMode: true }) ], - providers: [ MockProvider(SpotifyService) ] + providers: [ MockProvider(SpotifyControlsService) ] }).compileComponents(); - spotify = TestBed.inject(SpotifyService); + controls = TestBed.inject(SpotifyControlsService); fixture = TestBed.createComponent(TrackPlayerProgressComponent); component = fixture.componentInstance; @@ -85,7 +91,26 @@ describe('TrackPlayerProgressComponent', () => { const change = new MatSliderChange(); change.value = 10; component.onProgressChange(change); - expect(spotify.setTrackPosition).toHaveBeenCalledWith(10); + expect(controls.setTrackPosition).toHaveBeenCalledWith(10); + }); + + it('should not disable the progress bar when seeking allowed', async () => { + component.disallows = { + ...getTestDisallowsModel() + }; + fixture.detectChanges(); + const slider = await loader.getHarness(MatSliderHarness); + expect(await slider.isDisabled()).toBeFalse(); + }); + + it('should disable the progress bar when seeking disallowed', async () => { + component.disallows = { + ...getTestDisallowsModel(), + seek: true + }; + fixture.detectChanges(); + const slider = await loader.getHarness(MatSliderHarness); + expect(await slider.isDisabled()).toBeTrue(); }); it('should display single seconds digit progress correctly', () => { diff --git a/src/app/components/track-player/track-player-progress/track-player-progress.component.ts b/src/app/components/track-player/track-player-progress/track-player-progress.component.ts index 41fee8e..fb4ecee 100644 --- a/src/app/components/track-player/track-player-progress/track-player-progress.component.ts +++ b/src/app/components/track-player/track-player-progress/track-player-progress.component.ts @@ -1,6 +1,7 @@ import { Component, Input, OnChanges, SimpleChanges } from '@angular/core'; import { MatSliderChange } from '@angular/material/slider'; -import { SpotifyService } from '../../../services/spotify/spotify.service'; +import { DisallowsModel, getDefaultDisallows } from '../../../core/playback/playback.model'; +import { SpotifyControlsService } from '../../../services/spotify/controls/spotify-controls.service'; @Component({ selector: 'app-track-player-progress', @@ -11,11 +12,12 @@ export class TrackPlayerProgressComponent implements OnChanges { @Input() progress = 0; @Input() duration = 100; + @Input() disallows: DisallowsModel = getDefaultDisallows(); progressFormatted = this.getFormattedProgress(this.progress); durationFormatted = this.getFormattedProgress(this.duration); - constructor(private spotify: SpotifyService) {} + constructor(private controls: SpotifyControlsService) {} ngOnChanges(changes: SimpleChanges): void { if (changes.progress) { @@ -27,7 +29,7 @@ export class TrackPlayerProgressComponent implements OnChanges { } onProgressChange(change: MatSliderChange): void { - this.spotify.setTrackPosition(change.value); + this.controls.setTrackPosition(change.value); } private getFormattedProgress(milliseconds: number): string { diff --git a/src/app/components/track-player/track-player.component.html b/src/app/components/track-player/track-player.component.html index 82d8b0d..afd0047 100644 --- a/src/app/components/track-player/track-player.component.html +++ b/src/app/components/track-player/track-player.component.html @@ -27,14 +27,20 @@ fxLayoutAlign="center start">
No track currently playing
- +
{ const mockSelectors = new NgxsSelectorMock(); let component: TrackPlayerComponent; @@ -59,10 +24,12 @@ describe('TrackPlayerComponent', () => { let durationProducer: BehaviorSubject; let isPlayingProducer: BehaviorSubject; let isShuffleProducer: BehaviorSubject; + let isSmartShuffleProducer: BehaviorSubject; let repeatProducer: BehaviorSubject; let isLikedProducer: BehaviorSubject; let showPlayerControlsProducer: BehaviorSubject; let showPlaylistNameProducer: BehaviorSubject; + let disallowsProducer: BehaviorSubject; beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ @@ -84,10 +51,12 @@ describe('TrackPlayerComponent', () => { durationProducer = mockSelectors.defineNgxsSelector(component, 'duration$'); isPlayingProducer = mockSelectors.defineNgxsSelector(component, 'isPlaying$'); isShuffleProducer = mockSelectors.defineNgxsSelector(component, 'isShuffle$'); + isSmartShuffleProducer = mockSelectors.defineNgxsSelector(component, 'isSmartShuffle$'); repeatProducer = mockSelectors.defineNgxsSelector(component, 'repeat$'); isLikedProducer = mockSelectors.defineNgxsSelector(component, 'isLiked$'); showPlayerControlsProducer = mockSelectors.defineNgxsSelector(component, 'showPlayerControls$'); showPlaylistNameProducer = mockSelectors.defineNgxsSelector(component, 'showPlaylistName$'); + disallowsProducer = mockSelectors.defineNgxsSelector(component, 'disallows$'); fixture.detectChanges(); })); @@ -112,7 +81,7 @@ describe('TrackPlayerComponent', () => { }); it('should show track div when track is not null', () => { - trackProducer.next(TEST_TRACK); + trackProducer.next(getTestTrackModel()); fixture.detectChanges(); const trackInfo = fixture.debugElement.query(By.css('.track-info')); expect(trackInfo).toBeTruthy(); @@ -128,15 +97,15 @@ describe('TrackPlayerComponent', () => { }); it('should display track title and link', () => { - trackProducer.next(TEST_TRACK); + trackProducer.next(getTestTrackModel()); fixture.detectChanges(); const trackTitle = fixture.debugElement.query(By.css('.track-title a')); - expect(trackTitle.properties.href).toEqual(TEST_TRACK.href); - expect(trackTitle.nativeElement.textContent.trim()).toEqual(TEST_TRACK.title); + expect(trackTitle.properties.href).toEqual(getTestTrackModel().href); + expect(trackTitle.nativeElement.textContent.trim()).toEqual(getTestTrackModel().title); }); it('should display single artist name and link', () => { - const trackSingleArtist = {...TEST_TRACK}; + const trackSingleArtist = getTestTrackModel(); trackSingleArtist.artists = [{ name: 'test-artist', href: 'artist-href' @@ -152,28 +121,28 @@ describe('TrackPlayerComponent', () => { }); it('should display multiple artist names and links', () => { - trackProducer.next(TEST_TRACK); + trackProducer.next(getTestTrackModel()); fixture.detectChanges(); const trackArtists = fixture.debugElement.queryAll(By.css('.track-artist a')); - expect(trackArtists.length).toEqual(TEST_TRACK.artists.length); + expect(trackArtists.length).toEqual(getTestTrackModel().artists.length); trackArtists.forEach((artist, i) => { - expect(artist.properties.href).toEqual(TEST_TRACK.artists[i].href); - expect(artist.nativeElement.textContent.trim()).toEqual((TEST_TRACK.artists[i].name)); + expect(artist.properties.href).toEqual(getTestTrackModel().artists[i].href); + expect(artist.nativeElement.textContent.trim()).toEqual((getTestTrackModel().artists[i].name)); }); }); it('should comma-separate multiple artists', () => { - trackProducer.next(TEST_TRACK); + trackProducer.next(getTestTrackModel()); fixture.detectChanges(); const commaDelims = fixture.debugElement.queryAll(By.css('.track-artist span')); - expect(commaDelims.length).toEqual(TEST_TRACK.artists.length - 1); + expect(commaDelims.length).toEqual(getTestTrackModel().artists.length - 1); commaDelims.forEach((commaDelim) => { expect(commaDelim.nativeElement.textContent.trim()).toEqual(','); }); }); it('should not display artists if none exist', () => { - const noArtists = {...TEST_TRACK}; + const noArtists = {...getTestTrackModel()}; noArtists.artists = []; trackProducer.next(noArtists); fixture.detectChanges(); @@ -182,16 +151,16 @@ describe('TrackPlayerComponent', () => { }); it('should display album name and link if album exists', () => { - trackProducer.next(TEST_TRACK); - albumProducer.next(TEST_ALBUM); + trackProducer.next(getTestTrackModel()); + albumProducer.next(getTestAlbumModel()); fixture.detectChanges(); const album = fixture.debugElement.query(By.css('.track-album a')); - expect(album.properties.href).toEqual(TEST_ALBUM.href); - expect(album.nativeElement.textContent.trim()).toEqual(TEST_ALBUM.name); + expect(album.properties.href).toEqual(getTestAlbumModel().href); + expect(album.nativeElement.textContent.trim()).toEqual(getTestAlbumModel().name); }); it('should not display album when null', () => { - trackProducer.next(TEST_TRACK); + trackProducer.next(getTestTrackModel()); albumProducer.next(null); fixture.detectChanges(); const album = fixture.debugElement.query(By.css('.track-album')); @@ -228,34 +197,44 @@ describe('TrackPlayerComponent', () => { }); it('should correctly set the track player controls values', () => { + const updatedDisallows = { + ...getTestDisallowsModel(), + resume: true, + shuffle: true + }; + component.showPlayerControls = true; isShuffleProducer.next(true); + isSmartShuffleProducer.next(true); isPlayingProducer.next(true); repeatProducer.next('context'); deviceVolumeProducer.next(50); isLikedProducer.next(true); + disallowsProducer.next(updatedDisallows); fixture.detectChanges(); const playerControls = fixture.debugElement.query(By.directive(TrackPlayerControlsComponent)) .componentInstance as TrackPlayerControlsComponent; expect(playerControls.isShuffle).toBeTrue(); + expect(playerControls.isSmartShuffle).toBeTrue(); expect(playerControls.isPlaying).toBeTrue(); expect(playerControls.repeatState).toEqual('context'); expect(playerControls.volume).toEqual(50); expect(playerControls.isLiked).toBeTrue(); + expect(playerControls.disallows).toEqual(updatedDisallows); }); it('should show playlist name when showing playlist and playlist exists', () => { showPlaylistNameProducer.next(true); - playlistProducer.next(TEST_PLAYLIST); + playlistProducer.next(getTestPlaylistModel()); fixture.detectChanges(); const playlist = fixture.debugElement.query(By.css('.playlist-name a')); - expect(playlist.properties.href).toEqual(TEST_PLAYLIST.href); - expect(playlist.nativeElement.textContent.trim()).toEqual(TEST_PLAYLIST.name); + expect(playlist.properties.href).toEqual(getTestPlaylistModel().href); + expect(playlist.nativeElement.textContent.trim()).toEqual(getTestPlaylistModel().name); }); it('should not show the playlist name when not showing playlist', () => { showPlaylistNameProducer.next(false); - playlistProducer.next(TEST_PLAYLIST); + playlistProducer.next(getTestPlaylistModel()); fixture.detectChanges(); const playlist = fixture.debugElement.query(By.css('.playlist-name')); expect(playlist).toBeFalsy(); diff --git a/src/app/components/track-player/track-player.component.ts b/src/app/components/track-player/track-player.component.ts index 55aaa3d..7d371bd 100644 --- a/src/app/components/track-player/track-player.component.ts +++ b/src/app/components/track-player/track-player.component.ts @@ -2,7 +2,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { Select } from '@ngxs/store'; import { Observable, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; -import { AlbumModel, PlaylistModel, TrackModel } from '../../core/playback/playback.model'; +import { AlbumModel, DisallowsModel, PlaylistModel, TrackModel } from '../../core/playback/playback.model'; import { PlaybackState } from '../../core/playback/playback.state'; import { PlayerControlsOptions } from '../../core/settings/settings.model'; import { SettingsState } from '../../core/settings/settings.state'; @@ -23,10 +23,12 @@ export class TrackPlayerComponent implements OnInit, OnDestroy { @Select(PlaybackState.duration) duration$: Observable; @Select(PlaybackState.isPlaying) isPlaying$: Observable; @Select(PlaybackState.isShuffle) isShuffle$: Observable; + @Select(PlaybackState.isSmartShuffle) isSmartShuffle$: Observable; @Select(PlaybackState.repeat) repeat$: Observable; @Select(PlaybackState.isLiked) isLiked$: Observable; @Select(SettingsState.showPlayerControls) showPlayerControls$: Observable; @Select(SettingsState.showPlaylistName) showPlaylistName$: Observable; + @Select(PlaybackState.disallows) disallows$: Observable; showPlayerControls = true; diff --git a/src/app/core/auth/auth.state.spec.ts b/src/app/core/auth/auth.state.spec.ts index 3736ddf..bcb3444 100644 --- a/src/app/core/auth/auth.state.spec.ts +++ b/src/app/core/auth/auth.state.spec.ts @@ -5,19 +5,12 @@ import { NgxsModule, Store } from '@ngxs/store'; import { MockProvider } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; import { NgxsSelectorMock } from '../testing/ngxs-selector-mock'; +import { getTestAuthToken } from '../testing/test-models'; import { SetAuthToken } from './auth.actions'; import { AuthGuard } from './auth.guard'; import { AUTH_STATE_NAME, AuthToken } from './auth.model'; import { AuthState } from './auth.state'; -const TEST_AUTH_TOKEN: AuthToken = { - accessToken: 'test-token', - tokenType: 'test-type', - expiry: new Date(Date.UTC(9999, 1, 1)), - scope: 'test-scope', - refreshToken: 'test-refresh' -}; - describe('Authentication', () => { describe('AuthGuard', () => { const mockSelectors = new NgxsSelectorMock(); @@ -46,7 +39,7 @@ describe('Authentication', () => { }); it('should activate if access token exists', () => { - tokenProducer.next(TEST_AUTH_TOKEN); + tokenProducer.next(getTestAuthToken()); expect(guard.canActivate(null, null)).toBeTrue(); }); @@ -70,7 +63,7 @@ describe('Authentication', () => { store.reset({ ...store.snapshot(), SHOWTUNES_AUTH: { - token: TEST_AUTH_TOKEN, + token: getTestAuthToken(), isAuthenticated: true } }); @@ -78,7 +71,7 @@ describe('Authentication', () => { it('should select token', () => { const token = selectToken(store); - expect(token).toEqual(TEST_AUTH_TOKEN); + expect(token).toEqual(getTestAuthToken()); }); it('should select isAuthenticated', () => { @@ -88,7 +81,7 @@ describe('Authentication', () => { it('should set AuthToken', () => { const newToken: AuthToken = { - ...TEST_AUTH_TOKEN, + ...getTestAuthToken(), accessToken: 'new-token' }; store.dispatch(new SetAuthToken(newToken)); diff --git a/src/app/core/playback/playback.actions.ts b/src/app/core/playback/playback.actions.ts index e209387..896b743 100644 --- a/src/app/core/playback/playback.actions.ts +++ b/src/app/core/playback/playback.actions.ts @@ -1,4 +1,4 @@ -import { AlbumModel, DeviceModel, PlaylistModel, TrackModel } from './playback.model'; +import { AlbumModel, DeviceModel, DisallowsModel, PlayerState, PlaylistModel, TrackModel } from './playback.model'; const PLAYBACK_ACTION_NAME = '[Playback]'; @@ -52,6 +52,11 @@ export class SetShuffle { constructor(public isShuffle: boolean) { } } +export class SetSmartShuffle { + static readonly type = `${PLAYBACK_ACTION_NAME} Set Smart Shuffle`; + constructor(public isSmartShuffle: boolean) { } +} + export class ChangeRepeatState { static readonly type = `${PLAYBACK_ACTION_NAME} Change Repeat State`; constructor(public repeatState: string) { } @@ -62,7 +67,12 @@ export class SetLiked { constructor(public isLiked: boolean) { } } -export class SetIdle { - static readonly type = `${PLAYBACK_ACTION_NAME} Set Idle`; - constructor(public isIdle: boolean) { } +export class SetPlayerState { + static readonly type = `${PLAYBACK_ACTION_NAME} Set Player State`; + constructor(public playerState: PlayerState) { } +} + +export class SetDisallows { + static readonly type = `${PLAYBACK_ACTION_NAME} Set Disallows`; + constructor(public disallows: DisallowsModel) {} } diff --git a/src/app/core/playback/playback.model.ts b/src/app/core/playback/playback.model.ts index e2ef42b..fcef36c 100644 --- a/src/app/core/playback/playback.model.ts +++ b/src/app/core/playback/playback.model.ts @@ -1,5 +1,11 @@ export const PLAYBACK_STATE_NAME = 'SHOWTUNES_PLAYBACK'; +export enum PlayerState { + Idling, + Playing, + Refreshing +} + export interface PlaybackModel { track: TrackModel; album: AlbumModel; @@ -9,9 +15,11 @@ export interface PlaybackModel { progress: number; isPlaying: boolean; isShuffle: boolean; + isSmartShuffle: boolean; repeatState: string; isLiked: boolean; - isIdle: boolean; + playerState: PlayerState; + disallows: DisallowsModel; } export interface TrackModel { @@ -61,6 +69,20 @@ export interface DeviceModel { icon: string; } +export interface DisallowsModel { + interruptPlayback: boolean; + pause: boolean; + resume: boolean; + seek: boolean; + skipNext: boolean; + skipPrev: boolean; + repeatContext: boolean; + shuffle: boolean; + repeatTrack: boolean; + transferPlayback: boolean; + +} + export const DEFAULT_PLAYBACK: PlaybackModel = { track: { id: '', @@ -101,6 +123,23 @@ export const DEFAULT_PLAYBACK: PlaybackModel = { isLiked: false, isPlaying: false, isShuffle: false, + isSmartShuffle: false, repeatState: '', - isIdle: true + playerState: PlayerState.Idling, + disallows: getDefaultDisallows() }; + +export function getDefaultDisallows(): DisallowsModel { + return { + interruptPlayback: false, + pause: false, + resume: false, + seek: false, + skipNext: false, + skipPrev: false, + repeatContext: false, + shuffle: false, + repeatTrack: false, + transferPlayback: false + }; +} diff --git a/src/app/core/playback/playback.state.spec.ts b/src/app/core/playback/playback.state.spec.ts index 03677cb..06de156 100644 --- a/src/app/core/playback/playback.state.spec.ts +++ b/src/app/core/playback/playback.state.spec.ts @@ -2,6 +2,13 @@ import { TestBed } from '@angular/core/testing'; import { expect } from '@angular/flex-layout/_private-utils/testing'; import { NgxsModule, Store } from '@ngxs/store'; import { ImageResponse } from '../../models/image.model'; +import { + getTestAlbumModel, + getTestDeviceModel, + getTestDisallowsModel, + getTestPlaylistModel, + getTestTrackModel +} from '../testing/test-models'; import { ChangeAlbum, ChangeDevice, @@ -10,82 +17,24 @@ import { ChangePlaylist, ChangeRepeatState, ChangeTrack, - SetAvailableDevices, - SetIdle, + SetAvailableDevices, SetDisallows, SetLiked, + SetPlayerState, SetPlaying, SetProgress, - SetShuffle + SetShuffle, SetSmartShuffle } from './playback.actions'; -import { AlbumModel, ArtistModel, DeviceModel, PLAYBACK_STATE_NAME, PlaylistModel, TrackModel } from './playback.model'; +import { + AlbumModel, + DeviceModel, + DisallowsModel, + PLAYBACK_STATE_NAME, + PlayerState, + PlaylistModel, + TrackModel +} from './playback.model'; import { PlaybackState } from './playback.state'; -const TEST_ARTIST_1: ArtistModel = { - name: 'artist-1', - href: 'artist-href-1' -}; - -const TEST_ARTIST_2: ArtistModel = { - name: 'artist-2', - href: 'artist-href-2' -}; - -const TEST_TRACK: TrackModel = { - id: 'track-id', - title: 'test-track', - duration: 100, - artists: [ - TEST_ARTIST_1, - TEST_ARTIST_2 - ], - uri: 'test:track:uri', - href: 'track-href' -}; - -const TEST_COVER_ART = { - width: 500, - height: 500, - url: 'album-art-url' -}; - -const TEST_ALBUM: AlbumModel = { - id: 'album-id', - name: 'test-album', - releaseDate: 'release', - totalTracks: 10, - type: 'album', - artists: [ - 'test-artist-1', - 'test-artist-2' - ], - coverArt: TEST_COVER_ART, - uri: 'test:album:uri', - href: 'album-href' -}; - -const TEST_PLAYLIST: PlaylistModel = { - id: 'playlist-id', - name: 'test-playlist', - href: 'playlist-href' -}; - -const TEST_DEVICE_1: DeviceModel = { - id: 'device-id-1', - name: 'test-device-1', - type: 'device-type', - volume: 50, - isActive: true, - isPrivateSession: true, - isRestricted: true, - icon: 'device-icon' -}; - -const TEST_DEVICE_2: DeviceModel = { - ...TEST_DEVICE_1, - id: 'device-id-2', - name: 'test-device-2' -}; - describe('PlaybackState', () => { let store: Store; @@ -97,57 +46,59 @@ describe('PlaybackState', () => { store.reset({ ...store.snapshot(), SHOWTUNES_PLAYBACK: { - track: TEST_TRACK, - album: TEST_ALBUM, - playlist: TEST_PLAYLIST, - device: TEST_DEVICE_1, + track: getTestTrackModel(), + album: getTestAlbumModel(), + playlist: getTestPlaylistModel(), + device: getTestDeviceModel(1), availableDevices: [ - TEST_DEVICE_1, - TEST_DEVICE_2 + getTestDeviceModel(1), + getTestDeviceModel(2) ], progress: 0, isPlaying: true, isShuffle: true, + isSmartShuffle: true, repeatState: 'context', isLiked: true, - isIdle: true + playerState: PlayerState.Idling, + disallows: getTestDisallowsModel() } }); }); it('should select track', () => { const track = selectTrack(store); - expect(track).toEqual(TEST_TRACK); + expect(track).toEqual(getTestTrackModel()); }); it('should select album', () => { const album = selectAlbum(store); - expect(album).toEqual(TEST_ALBUM); + expect(album).toEqual(getTestAlbumModel()); }); it('should select playlist', () => { const playlist = selectPlaylist(store); - expect(playlist).toEqual(TEST_PLAYLIST); + expect(playlist).toEqual(getTestPlaylistModel()); }); it('should select coverArt', () => { const coverArt = selectCoverArt(store); - expect(coverArt).toEqual(TEST_COVER_ART); + expect(coverArt).toEqual(getTestAlbumModel().coverArt); }); it('should select device', () => { const device = selectDevice(store); - expect(device).toEqual(TEST_DEVICE_1); + expect(device).toEqual(getTestDeviceModel(1)); }); it('should select deviceVolume', () => { const deviceVolume = selectDeviceVolume(store); - expect(deviceVolume).toEqual(TEST_DEVICE_1.volume); + expect(deviceVolume).toEqual(getTestDeviceModel(1).volume); }); it('should select availableDevices', () => { const availableDevices = selectAvailableDevices(store); - expect(availableDevices).toEqual([TEST_DEVICE_1, TEST_DEVICE_2]); + expect(availableDevices).toEqual([getTestDeviceModel(1), getTestDeviceModel(2)]); }); it('should select progress', () => { @@ -170,6 +121,11 @@ describe('PlaybackState', () => { expect(isShuffle).toBeTrue(); }); + it('should select isSmartShuffle', () => { + const isSmartShuffle = selectIsSmartShuffle(store); + expect(isSmartShuffle).toBeTrue(); + }); + it('should select repeat', () => { const repeat = selectRepeat(store); expect(repeat).toEqual('context'); @@ -180,14 +136,19 @@ describe('PlaybackState', () => { expect(isLiked).toBeTrue(); }); - it('should select isIdle', () => { - const isIdle = selectIsIdle(store); - expect(isIdle).toBeTrue(); + it('should select playerState', () => { + const state = selectPlayerState(store); + expect(state).toEqual(PlayerState.Idling); + }); + + it('should select disallows', () => { + const disallows = selectDisallows(store); + expect(disallows).toEqual(getTestDisallowsModel()); }); it('should change track', () => { const updatedTrack: TrackModel = { - ...TEST_TRACK, + ...getTestTrackModel(), id: 'new-track' }; store.dispatch(new ChangeTrack(updatedTrack)); @@ -197,7 +158,7 @@ describe('PlaybackState', () => { it('should change album', () => { const updatedAlbum: AlbumModel = { - ...TEST_ALBUM, + ...getTestAlbumModel(), id: 'new-album' }; store.dispatch(new ChangeAlbum(updatedAlbum)); @@ -207,7 +168,7 @@ describe('PlaybackState', () => { it('should change playlist', () => { const updatedPlaylist: PlaylistModel = { - ...TEST_PLAYLIST, + ...getTestPlaylistModel(), id: 'new-playlist' }; store.dispatch(new ChangePlaylist(updatedPlaylist)); @@ -216,9 +177,9 @@ describe('PlaybackState', () => { }); it('should change device', () => { - store.dispatch(new ChangeDevice(TEST_DEVICE_2)); + store.dispatch(new ChangeDevice(getTestDeviceModel(2))); const device = selectDevice(store); - expect(device).toEqual(TEST_DEVICE_2); + expect(device).toEqual(getTestDeviceModel(2)); }); it('should change deviceVolume', () => { @@ -238,7 +199,7 @@ describe('PlaybackState', () => { ...store.snapshot(), SHOWTUNES_PLAYBACK: { device: { - ...TEST_DEVICE_1, + ...getTestDeviceModel(1), isActive: false } } @@ -250,8 +211,8 @@ describe('PlaybackState', () => { it('should set availableDevices', () => { const updatedDevices = [ - {...TEST_DEVICE_1, id: 'updated-device-1'}, - {...TEST_DEVICE_2, id: 'updated-device-2'} + {...getTestDeviceModel(1), id: 'updated-device-1'}, + {...getTestDeviceModel(2), id: 'updated-device-2'} ]; store.dispatch(new SetAvailableDevices(updatedDevices)); const availableDevices = selectAvailableDevices(store); @@ -276,6 +237,12 @@ describe('PlaybackState', () => { expect(isShuffle).toBeFalse(); }); + it('should set isSmartShuffle', () => { + store.dispatch(new SetSmartShuffle(false)); + const isSmartShuffle = selectIsSmartShuffle(store); + expect(isSmartShuffle).toBeFalse(); + }); + it('should change repeat', () => { store.dispatch(new ChangeRepeatState('none')); const repeat = selectRepeat(store); @@ -288,10 +255,20 @@ describe('PlaybackState', () => { expect(isLiked).toBeFalse(); }); - it('should set isIdle', () => { - store.dispatch(new SetIdle(false)); - const isIdle = selectIsIdle(store); - expect(isIdle).toBeFalse(); + it('should set playerState', () => { + store.dispatch(new SetPlayerState(PlayerState.Refreshing)); + const state = selectPlayerState(store); + expect(state).toEqual(PlayerState.Refreshing); + }); + + it('should set disallows', () => { + const updatedDisallows = { + ...getTestDisallowsModel(), + shuffle: true + }; + store.dispatch(new SetDisallows(updatedDisallows)); + const disallows = selectDisallows(store); + expect(disallows.shuffle).toBeTrue(); }); }); @@ -339,6 +316,10 @@ function selectIsShuffle(store: Store): boolean { return store.selectSnapshot(state => state[PLAYBACK_STATE_NAME].isShuffle); } +function selectIsSmartShuffle(store: Store): boolean { + return store.selectSnapshot(state => state[PLAYBACK_STATE_NAME].isSmartShuffle); +} + function selectRepeat(store: Store): string { return store.selectSnapshot(state => state[PLAYBACK_STATE_NAME].repeatState); } @@ -347,6 +328,10 @@ function selectIsLiked(store: Store): boolean { return store.selectSnapshot(state => state[PLAYBACK_STATE_NAME].isLiked); } -function selectIsIdle(store: Store): boolean { - return store.selectSnapshot(state => state[PLAYBACK_STATE_NAME].isIdle); +function selectPlayerState(store: Store): PlayerState { + return store.selectSnapshot(state => state[PLAYBACK_STATE_NAME].playerState); +} + +function selectDisallows(store: Store): DisallowsModel { + return store.selectSnapshot(state => state[PLAYBACK_STATE_NAME].disallows); } diff --git a/src/app/core/playback/playback.state.ts b/src/app/core/playback/playback.state.ts index 2f87970..8371c79 100644 --- a/src/app/core/playback/playback.state.ts +++ b/src/app/core/playback/playback.state.ts @@ -10,13 +10,22 @@ import { ChangeRepeatState, ChangeTrack, SetAvailableDevices, - SetIdle, + SetPlayerState, SetLiked, SetPlaying, SetProgress, - SetShuffle + SetShuffle, SetSmartShuffle, SetDisallows } from './playback.actions'; -import { AlbumModel, DEFAULT_PLAYBACK, DeviceModel, PLAYBACK_STATE_NAME, PlaybackModel, PlaylistModel, TrackModel } from './playback.model'; +import { + AlbumModel, + DEFAULT_PLAYBACK, + DeviceModel, DisallowsModel, + PLAYBACK_STATE_NAME, + PlaybackModel, + PlayerState, + PlaylistModel, + TrackModel +} from './playback.model'; @State({ name: PLAYBACK_STATE_NAME, @@ -81,6 +90,11 @@ export class PlaybackState { return state.isShuffle; } + @Selector() + static isSmartShuffle(state: PlaybackModel): boolean { + return state.isSmartShuffle; + } + @Selector() static repeat(state: PlaybackModel): string { return state.repeatState; @@ -92,8 +106,13 @@ export class PlaybackState { } @Selector() - static isIdle(state: PlaybackModel): boolean { - return state.isIdle; + static playerState(state: PlaybackModel): PlayerState { + return state.playerState; + } + + @Selector() + static disallows(state: PlaybackModel): DisallowsModel { + return state.disallows; } @Action(ChangeTrack) @@ -148,6 +167,11 @@ export class PlaybackState { ctx.patchState({isShuffle: action.isShuffle}); } + @Action(SetSmartShuffle) + setSmartShuffle(ctx: StateContext, action: SetSmartShuffle): void { + ctx.patchState({isSmartShuffle: action.isSmartShuffle}); + } + @Action(ChangeRepeatState) changeRepeat(ctx: StateContext, action: ChangeRepeatState): void { ctx.patchState({repeatState: action.repeatState}); @@ -158,8 +182,13 @@ export class PlaybackState { ctx.patchState({isLiked: action.isLiked}); } - @Action(SetIdle) - setIdle(ctx: StateContext, action: SetIdle): void { - ctx.patchState({isIdle: action.isIdle}); + @Action(SetPlayerState) + setIdle(ctx: StateContext, action: SetPlayerState): void { + ctx.patchState({playerState: action.playerState}); + } + + @Action(SetDisallows) + setDisallows(ctx: StateContext, action: SetDisallows): void { + ctx.patchState({disallows: action.disallows}); } } diff --git a/src/app/core/settings/settings.state.spec.ts b/src/app/core/settings/settings.state.spec.ts index 5cfd120..567093d 100644 --- a/src/app/core/settings/settings.state.spec.ts +++ b/src/app/core/settings/settings.state.spec.ts @@ -4,6 +4,7 @@ import { expect } from '@angular/flex-layout/_private-utils/testing'; import { NgxsModule, Store } from '@ngxs/store'; import { MockProvider } from 'ng-mocks'; import { DominantColor } from '../dominant-color/dominant-color-finder'; +import { getTestDominantColor } from '../testing/test-models'; import { FontColor } from '../util'; import { ChangeCustomAccentColor, @@ -21,17 +22,6 @@ import { import { PlayerControlsOptions, SETTINGS_STATE_NAME, Theme } from './settings.model'; import { SettingsState } from './settings.state'; -const TEST_DOMINANT_COLOR: DominantColor = { - hex: 'DEF789', - rgb: { - r: 222, - g: 247, - b: 137, - a: 255 - }, - foregroundFontColor: FontColor.White -}; - describe('SettingsState', () => { let store: Store; let overlay: OverlayContainer; @@ -166,13 +156,25 @@ describe('SettingsState', () => { it('should use the dynamic accent theme when customAccentColor is changed', () => { setState(store, { theme: Theme.Light, - dynamicAccentColor: 'blue' + dynamicAccentColor: 'blue', + useDynamicThemeAccent: true }); store.dispatch(new ChangeCustomAccentColor('cyan')); expect(element.classList.length).toEqual(1); expect(element.classList.contains('blue-light-theme')).toBeTrue(); }); + it('should not use the dynamic accent theme when useDynamicThemeAccent is false', () => { + setState(store, { + theme: Theme.Light, + dynamicAccentColor: 'blue', + useDynamicThemeAccent: false + }); + store.dispatch(new ChangeCustomAccentColor('cyan')); + expect(element.classList.length).toEqual(1); + expect(element.classList.contains('cyan-light-theme')).toBeTrue(); + }); + it('should change showPlayerControls', () => { store.dispatch(new ChangePlayerControls(PlayerControlsOptions.Off)); const showPlayerControls = selectShowPlayerControls(store); @@ -225,13 +227,13 @@ describe('SettingsState', () => { }); it('should update the dynamicColor', () => { - store.dispatch(new ChangeDynamicColor(TEST_DOMINANT_COLOR)); + store.dispatch(new ChangeDynamicColor(getTestDominantColor())); const dynamicColor = selectDynamicColor(store); - expect(dynamicColor).toEqual(TEST_DOMINANT_COLOR); + expect(dynamicColor).toEqual(getTestDominantColor()); }); it('should update dynamicAccentColor if when dynamicColor updated', () => { - store.dispatch(new ChangeDynamicColor(TEST_DOMINANT_COLOR)); + store.dispatch(new ChangeDynamicColor(getTestDominantColor())); const accentColor = selectDynamicAccentColor(store); expect(accentColor).toEqual('gray'); }); @@ -242,7 +244,7 @@ describe('SettingsState', () => { dynamicAccentColor: 'test-color' }); const dominantColor: DominantColor = { - ...TEST_DOMINANT_COLOR, + ...getTestDominantColor(), hex: 'badhex' }; store.dispatch(new ChangeDynamicColor(dominantColor)); @@ -270,7 +272,7 @@ describe('SettingsState', () => { customAccentColor: 'cyan', useDynamicThemeAccent: true }); - store.dispatch(new ChangeDynamicColor(TEST_DOMINANT_COLOR)); + store.dispatch(new ChangeDynamicColor(getTestDominantColor())); expect(element.classList.length).toEqual(1); console.log(element.classList); expect(element.classList.contains('gray-dark-theme')).toBeTrue(); @@ -305,7 +307,7 @@ describe('SettingsState', () => { it('should toggle useDynamicThemeAccent on and set dynamicAccentColor', () => { setState(store, { - dynamicColor: TEST_DOMINANT_COLOR, + dynamicColor: getTestDominantColor(), useDynamicThemeAccent: false, dynamicAccentColor: null }); @@ -320,7 +322,7 @@ describe('SettingsState', () => { setState(store, { theme: Theme.Light, customAccentColor: 'cyan', - dynamicColor: TEST_DOMINANT_COLOR, + dynamicColor: getTestDominantColor(), useDynamicThemeAccent: false }); store.dispatch(new ToggleDynamicThemeAccent()); @@ -337,7 +339,8 @@ describe('SettingsState', () => { it('should update overlayContainer on dynamicAccentColor change', () => { setState(store, { theme: Theme.Light, - customAccentColor: 'blue' + customAccentColor: 'blue', + useDynamicThemeAccent: true }); store.dispatch(new ChangeDynamicAccentColor('cyan')); expect(element.classList.length).toEqual(1); diff --git a/src/app/core/settings/settings.state.ts b/src/app/core/settings/settings.state.ts index 3754336..2b846f3 100644 --- a/src/app/core/settings/settings.state.ts +++ b/src/app/core/settings/settings.state.ts @@ -82,22 +82,19 @@ export class SettingsState implements NgxsOnInit { } ngxsOnInit(ctx: StateContext): void { - const state = ctx.getState(); - this.updateOverlayContainer(state.theme, state.customAccentColor, state.dynamicAccentColor); + this.updateOverlayContainer(ctx.getState()); } @Action(ChangeTheme) changeTheme(ctx: StateContext, action: ChangeTheme): void { - const state = ctx.getState(); - this.updateOverlayContainer(action.theme, state.customAccentColor, state.dynamicAccentColor); ctx.patchState({theme: action.theme}); + this.updateOverlayContainer(ctx.getState()); } @Action(ChangeCustomAccentColor) changeCustomAccentColor(ctx: StateContext, action: ChangeCustomAccentColor): void { - const state = ctx.getState(); - this.updateOverlayContainer(state.theme, action.customAccentColor, state.dynamicAccentColor); ctx.patchState({customAccentColor: action.customAccentColor}); + this.updateOverlayContainer(ctx.getState()); } @Action(ChangePlayerControls) @@ -126,7 +123,6 @@ export class SettingsState implements NgxsOnInit { @Action(ChangeDynamicColor) changeDynamicColor(ctx: StateContext, action: ChangeDynamicColor): void { ctx.patchState({dynamicColor: action.dynamicColor}); - const state = ctx.getState(); if ((action.dynamicColor && isHexColor(action.dynamicColor.hex))) { ctx.patchState({ dynamicColor: action.dynamicColor, @@ -138,7 +134,7 @@ export class SettingsState implements NgxsOnInit { dynamicAccentColor: null }); } - this.updateOverlayContainer(state.theme, state.customAccentColor, ctx.getState().dynamicAccentColor); + this.updateOverlayContainer(ctx.getState()); } @Action(ChangeSpotifyCodeBackgroundColor) @@ -161,24 +157,21 @@ export class SettingsState implements NgxsOnInit { this.calculateDynamicAccentColor(state.dynamicColor.rgb) : null, useDynamicThemeAccent: !state.useDynamicThemeAccent, }); - this.updateOverlayContainer(state.theme, state.customAccentColor, ctx.getState().dynamicAccentColor); + this.updateOverlayContainer(ctx.getState()); } @Action(ChangeDynamicAccentColor) changeDynamicAccentColor(ctx: StateContext, action: ChangeDynamicAccentColor): void { - const state = ctx.getState(); - this.updateOverlayContainer(state.theme, state.customAccentColor, action.dynamicAccentColor); ctx.patchState({dynamicAccentColor: action.dynamicAccentColor}); + this.updateOverlayContainer(ctx.getState()); } /** * Updates the theme class on the overlayContainer - * @param theme the standard theme (light/dark) - * @param customTheme a custom accent color for the theme - * @param dynamicTheme a dynamic accent color for the theme (overwrites the custom color) + * @param state the current state * @private */ - private updateOverlayContainer(theme: string, customTheme: string, dynamicTheme: string): void { + private updateOverlayContainer(state: SettingsModel): void { const classList = this.overlayContainer.getContainerElement().classList; const toRemove = Array.from(classList).filter((item: string) => item.includes('-theme') @@ -186,11 +179,16 @@ export class SettingsState implements NgxsOnInit { if (toRemove.length > 0) { classList.remove(...toRemove); } - let additionalTheme = dynamicTheme; - if (!additionalTheme) { - additionalTheme = customTheme; + if (state.theme !== null) { + let theme = state.theme; + if (state.useDynamicThemeAccent && state.dynamicAccentColor !== null) { + theme = `${state.dynamicAccentColor}-${state.theme}`; + } + else if (state.customAccentColor !== null) { + theme = `${state.customAccentColor}-${state.theme}`; + } + classList.add(theme); } - classList.add(additionalTheme ? `${additionalTheme}-${theme}` : theme); } /** diff --git a/src/app/core/spotify/spotify-endpoints.spec.ts b/src/app/core/spotify/spotify-endpoints.spec.ts new file mode 100644 index 0000000..c79402a --- /dev/null +++ b/src/app/core/spotify/spotify-endpoints.spec.ts @@ -0,0 +1,61 @@ +import { expect } from '@angular/flex-layout/_private-utils/testing'; +import { AppConfig } from '../../app.config'; +import { getTestAppConfig } from '../testing/test-responses'; +import { SpotifyEndpoints } from './spotify-endpoints'; + +describe('SpotifyEndpoints', () => { + beforeEach(() => { + AppConfig.settings = getTestAppConfig(); + spyOn(console, 'warn'); + }); + + it('should be initialized if AppConfig.settings is set', () => { + expect(SpotifyEndpoints.isInitialized()).toBeTrue(); + }); + + it('should not be initialized if AppConfig.settings is not set', () => { + AppConfig.settings = null; + expect(SpotifyEndpoints.isInitialized()).toBeFalse(); + + AppConfig.settings = undefined; + expect(SpotifyEndpoints.isInitialized()).toBeFalse(); + }); + + it('should return the spotifyApiUrl when set', () => { + expect(SpotifyEndpoints.getSpotifyApiUrl()).toEqual(AppConfig.settings.env.spotifyApiUrl); + }); + + it('should log a warning when the spotifyApiUrl is not set', () => { + AppConfig.settings = null; + expect(SpotifyEndpoints.getSpotifyApiUrl()).toBeNull(); + + AppConfig.settings = getTestAppConfig(); + AppConfig.settings.env = null; + expect(SpotifyEndpoints.getSpotifyApiUrl()).toBeNull(); + + AppConfig.settings = getTestAppConfig(); + AppConfig.settings.env.spotifyApiUrl = null; + expect(SpotifyEndpoints.getSpotifyApiUrl()).toBeNull(); + + expect(console.warn).toHaveBeenCalledTimes(3); + }); + + it('should return the spotifyAccountsUrl when set', () => { + expect(SpotifyEndpoints.getSpotifyAccountsUrl()).toEqual(AppConfig.settings.env.spotifyAccountsUrl); + }); + + it('should log a warning when the spotifyAccountsUrl is not set', () => { + AppConfig.settings = null; + expect(SpotifyEndpoints.getSpotifyAccountsUrl()).toBeNull(); + + AppConfig.settings = getTestAppConfig(); + AppConfig.settings.env = null; + expect(SpotifyEndpoints.getSpotifyAccountsUrl()).toBeNull(); + + AppConfig.settings = getTestAppConfig(); + AppConfig.settings.env.spotifyAccountsUrl = null; + expect(SpotifyEndpoints.getSpotifyAccountsUrl()).toBeNull(); + + expect(console.warn).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/app/core/spotify/spotify-endpoints.ts b/src/app/core/spotify/spotify-endpoints.ts new file mode 100644 index 0000000..5a63aba --- /dev/null +++ b/src/app/core/spotify/spotify-endpoints.ts @@ -0,0 +1,88 @@ +import { AppConfig } from '../../app.config'; + +export class SpotifyEndpoints { + + static isInitialized(): boolean { + return !!AppConfig.settings; + } + + static getSpotifyApiUrl(): string { + if (!AppConfig.settings || !AppConfig.settings.env || !AppConfig.settings.env.spotifyApiUrl) { + console.warn('Retrieving Spotify API URL but it has not been initialized'); + return null; + } + return AppConfig.settings.env.spotifyApiUrl; + } + + static getSpotifyAccountsUrl(): string { + if (!AppConfig.settings || !AppConfig.settings.env || !AppConfig.settings.env.spotifyAccountsUrl) { + console.warn('Retrieving Spotify Accounts URL but it has not been initialized'); + return null; + } + return AppConfig.settings.env.spotifyAccountsUrl; + } + + static getUserEndpoint(): string { + return SpotifyEndpoints.getSpotifyApiUrl() + '/me'; + } + + static getPlaybackEndpoint(): string { + return SpotifyEndpoints.getUserEndpoint() + '/player'; + } + + static getPlayEndpoint(): string { + return SpotifyEndpoints.getPlaybackEndpoint() + '/play'; + } + + static getPauseEndpoint(): string { + return SpotifyEndpoints.getPlaybackEndpoint() + '/pause'; + } + + static getNextEndpoint(): string { + return SpotifyEndpoints.getPlaybackEndpoint() + '/next'; + } + + static getPreviousEndpoint(): string { + return SpotifyEndpoints.getPlaybackEndpoint() + '/previous'; + } + + static getVolumeEndpoint(): string { + return SpotifyEndpoints.getPlaybackEndpoint() + '/volume'; + } + + static getShuffleEndpoint(): string { + return SpotifyEndpoints.getPlaybackEndpoint() + '/shuffle'; + } + + static getRepeatEndpoint(): string { + return SpotifyEndpoints.getPlaybackEndpoint() + '/repeat'; + } + + static getSeekEndpoint(): string { + return SpotifyEndpoints.getPlaybackEndpoint() + '/seek'; + } + + static getDevicesEndpoint(): string { + return SpotifyEndpoints.getPlaybackEndpoint() + '/devices'; + } + + static getSavedTracksEndpoint(): string { + return SpotifyEndpoints.getUserEndpoint() + '/tracks'; + } + + static getCheckSavedEndpoint(): string { + return SpotifyEndpoints.getSavedTracksEndpoint() + '/contains'; + } + + static getPlaylistsEndpoint(): string { + return SpotifyEndpoints.getSpotifyApiUrl() + '/playlists'; + } + + static getAuthorizeEndpoint(): string { + return SpotifyEndpoints.getSpotifyAccountsUrl() + '/authorize'; + } + + static getTokenEndpoint(): string { + return SpotifyEndpoints.getSpotifyAccountsUrl() + '/api/token'; + } +} diff --git a/src/app/core/testing/mock-interaction-throttle.directive.ts b/src/app/core/testing/mock-interaction-throttle.directive.ts new file mode 100644 index 0000000..bfb2de6 --- /dev/null +++ b/src/app/core/testing/mock-interaction-throttle.directive.ts @@ -0,0 +1,5 @@ +import { InteractionThrottleDirective } from '../../directives/component-throttle/interaction-throttle.directive'; + +export class MockInteractionThrottleDirective extends InteractionThrottleDirective { + delay = 0; +} diff --git a/src/app/core/testing/ngxs-selector-mock.spec.ts b/src/app/core/testing/ngxs-selector-mock.spec.ts index 296625d..49806ed 100644 --- a/src/app/core/testing/ngxs-selector-mock.spec.ts +++ b/src/app/core/testing/ngxs-selector-mock.spec.ts @@ -1,5 +1,4 @@ import { TestBed } from '@angular/core/testing'; -import { expect } from '@angular/flex-layout/_private-utils/testing'; import { BehaviorSubject } from 'rxjs'; import { NgxsSelectorMock } from './ngxs-selector-mock'; diff --git a/src/app/core/testing/test-models.ts b/src/app/core/testing/test-models.ts new file mode 100644 index 0000000..767809d --- /dev/null +++ b/src/app/core/testing/test-models.ts @@ -0,0 +1,105 @@ +import { AuthToken } from '../auth/auth.model'; +import { DominantColor } from '../dominant-color/dominant-color-finder'; +import { AlbumModel, ArtistModel, DeviceModel, DisallowsModel, PlaylistModel, TrackModel } from '../playback/playback.model'; +import { FontColor } from '../util'; + +export function getTestAuthToken(): AuthToken { + return { + accessToken: 'test-token', + tokenType: 'test-type', + expiry: new Date(Date.UTC(9999, 1, 1)), + scope: 'test-scope', + refreshToken: 'test-refresh' + }; +} + +export function getTestAlbumModel(): AlbumModel { + return { + id: 'album-id', + name: 'test-album', + releaseDate: 'release', + totalTracks: 10, + type: 'album', + artists: [ + 'test-artist-1', + 'test-artist-2' + ], + coverArt: { + width: 500, + height: 500, + url: 'album-art-url' + }, + uri: 'test:album:uri', + href: 'album-href' + }; +} + +export function getTestArtistModel(id: number = 0): ArtistModel { + return { + name: `artist-${id}`, + href: `artist-href-${id}` + }; +} + +export function getTestTrackModel(): TrackModel { + return { + id: 'track-id', + title: 'test-track', + duration: 100, + artists: [ + getTestArtistModel(1), + getTestArtistModel(2) + ], + uri: 'test:track:uri', + href: 'track-href' + }; +} + +export function getTestPlaylistModel(): PlaylistModel { + return { + id: 'playlist-id', + name: 'test-playlist', + href: 'playlist-href' + }; +} + +export function getTestDeviceModel(id: number = 0): DeviceModel { + return { + id: `device-id-${id}`, + name: `test-device-${id}`, + type: 'device-type', + volume: 50, + isActive: true, + isPrivateSession: true, + isRestricted: true, + icon: 'device-icon' + }; +} + +export function getTestDisallowsModel(): DisallowsModel { + return { + pause: false, + resume: false, + skipPrev: false, + skipNext: false, + shuffle: false, + repeatContext: false, + repeatTrack: false, + seek: false, + transferPlayback: false, + interruptPlayback: false + }; +} + +export function getTestDominantColor(): DominantColor { + return { + hex: 'DEF789', + rgb: { + r: 222, + g: 247, + b: 137, + a: 255 + }, + foregroundFontColor: FontColor.White + }; +} diff --git a/src/app/core/testing/test-responses.ts b/src/app/core/testing/test-responses.ts new file mode 100644 index 0000000..c94cdfd --- /dev/null +++ b/src/app/core/testing/test-responses.ts @@ -0,0 +1,157 @@ +import { ActionsResponse } from '../../models/actions.model'; +import { AlbumResponse } from '../../models/album.model'; +import { IAppConfig } from '../../models/app-config.model'; +import { ArtistResponse } from '../../models/artist.model'; +import { CurrentPlaybackResponse } from '../../models/current-playback.model'; +import { DeviceResponse } from '../../models/device.model'; +import { ImageResponse } from '../../models/image.model'; +import { PlaylistResponse } from '../../models/playlist.model'; +import { TrackResponse } from '../../models/track.model'; + +export function getTestAppConfig(): IAppConfig { + return { + env: { + name: 'test-name', + domain: 'test-domain', + spotifyApiUrl: 'spotify-url', + spotifyAccountsUrl: 'spotify-accounts', + idlePolling: 3000, + playbackPolling: 1000, + throttleDelay: 1000 + }, + auth: { + clientId: 'test-client-id', + clientSecret: null, + scopes: 'test-scope', + tokenUrl: null, + forcePkce: false, + showDialog: true, + expiryThreshold: 5000 + }, + logging: { + level: 'info' + } + }; +} + +export function getTestArtistResponse(index = 1): ArtistResponse { + return { + id: `artist-id-${index}`, + name: `artist-${index}`, + type: `artist-type-v`, + uri: `artist-uri-${index}`, + external_urls: { + spotify: `artist-url-${index}` + } + }; +} + +export function getTestAlbumResponse(): AlbumResponse { + return { + id: 'album-id', + name: 'test-album', + type: 'album-type', + total_tracks: 10, + release_date: 'album-date', + uri: 'album-uri', + external_urls: { + spotify: 'album-url' + }, + album_type: 'album-type', + images: [ + {url: 'album-img', height: 500, width: 500} + ], + artists: [ + getTestArtistResponse(1), + getTestArtistResponse(2) + ] + }; +} + +export function getTestTrackResponse(): TrackResponse { + return { + name: 'test-track', + album: getTestAlbumResponse(), + track_number: 1, + duration_ms: 1000, + uri: 'test-uri', + id: 'track-id', + popularity: 100, + type: 'type-test', + explicit: true, + external_urls: { + spotify: 'spotify-url' + }, + artists: [ + getTestArtistResponse(1), + getTestArtistResponse(2) + ] + }; +} + +export function getTestPlaylistResponse(): PlaylistResponse { + return { + id: 'playlist-id', + name: 'playlist-test', + external_urls: { + spotify: 'playlist-url' + } + }; +} + +export function getTestDeviceResponse(): DeviceResponse { + return { + id: 'device-id', + volume_percent: 50, + name: 'device-test', + type: 'speaker', + is_active: true, + is_private_session: false, + is_restricted: false + }; +} + +export function getTestActionsResponse(): ActionsResponse { + return { + pausing: false, + resuming: false, + transferring_playback: false, + skipping_prev: false, + skipping_next: false, + toggling_shuffle: false, + seeking: false, + toggling_repeat_context: false, + toggling_repeat_track: false, + interrupting_playback: false + }; +} + +export function getTestPlaybackResponse(): CurrentPlaybackResponse { + return { + item: getTestTrackResponse(), + context: { + type: 'playlist', + href: 'context-url', + uri: 'test:uri:playlist-id' + }, + device: getTestDeviceResponse(), + is_playing: false, + currently_playing_type: 'test-type', + progress_ms: 100, + repeat_state: 'test-state', + shuffle_state: true, + smart_shuffle: false, + timestamp: 10, + actions: { + disallows: getTestActionsResponse() + } + }; +} + +export function getTestImageResponse(): ImageResponse { + return { + url: 'test-url', + width: 100, + height: 100 + }; +} diff --git a/src/app/core/testing/test-util.ts b/src/app/core/testing/test-util.ts index d3752e0..a76c47c 100644 --- a/src/app/core/testing/test-util.ts +++ b/src/app/core/testing/test-util.ts @@ -1,3 +1,4 @@ +import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; import { SimpleChange } from '@angular/core'; import { ComponentFixture } from '@angular/core/testing'; @@ -7,3 +8,33 @@ export function callComponentChange(fixture: ComponentFixture, variable: st fixture.componentInstance.ngOnChanges(simpleChange); fixture.detectChanges(); } + +export function callComponentChanges(fixture: ComponentFixture, variables: string[], values: any[]): void { + if (variables.length !== values.length) { + fail('Incompatible amount of variables and values for component change call'); + } else { + const simpleChanges = {}; + for (let i = 0; i < variables.length; i++) { + simpleChanges[variables[i]] = new SimpleChange(null, values[i], false); + } + fixture.componentInstance.ngOnChanges(simpleChanges); + fixture.detectChanges(); + } +} + +export function generateResponse(body: T, status: number): HttpResponse { + return new HttpResponse({ + body, + headers: null, + status, + statusText: 'test-status', + url: 'test-url' + }); +} + +export function generateErrorResponse(status: number, error = null): HttpErrorResponse { + return new HttpErrorResponse({ + error, + status + }); +} diff --git a/src/app/core/types.ts b/src/app/core/types.ts index 1a5a440..12fc9a5 100644 --- a/src/app/core/types.ts +++ b/src/app/core/types.ts @@ -1,3 +1,17 @@ import { MockImageElement } from './testing/mock-image-element'; export type ImageElement = HTMLImageElement | MockImageElement; + +export enum AuthType { + PKCE, + Secret, + ThirdParty +} + +export enum SpotifyAPIResponse { + Success, + NoPlayback, + ReAuthenticated, + Restricted, + Error +} diff --git a/src/app/core/util.spec.ts b/src/app/core/util.spec.ts index 1efdade..6937069 100644 --- a/src/app/core/util.spec.ts +++ b/src/app/core/util.spec.ts @@ -1,5 +1,6 @@ import { fakeAsync, flushMicrotasks } from '@angular/core/testing'; import { expect } from '@angular/flex-layout/_private-utils/testing'; +import { ActionsResponse } from '../models/actions.model'; import { AlbumResponse } from '../models/album.model'; import { ArtistResponse } from '../models/artist.model'; import { DeviceResponse } from '../models/device.model'; @@ -18,7 +19,7 @@ import { isHexColor, isRgbColor, parseAlbum, - parseDevice, + parseDevice, parseDisallows, parsePlaylist, parseTrack, rgbToHex @@ -458,6 +459,70 @@ describe('util package', () => { }); }); + describe('parseDisallows', () => { + it('should correctly parse an ActionsResponse to a DisallowsModel', () => { + const response: ActionsResponse = { + interrupting_playback: true, + toggling_repeat_track: true, + toggling_repeat_context: true, + seeking: true, + toggling_shuffle: true, + skipping_next: true, + skipping_prev: true, + transferring_playback: true, + resuming: true, + pausing: true + }; + const model = parseDisallows(response); + + expect(model.interruptPlayback).toBeTrue(); + expect(model.repeatTrack).toBeTrue(); + expect(model.repeatContext).toBeTrue(); + expect(model.seek).toBeTrue(); + expect(model.shuffle).toBeTrue(); + expect(model.skipNext).toBeTrue(); + expect(model.skipPrev).toBeTrue(); + expect(model.transferPlayback).toBeTrue(); + expect(model.resume).toBeTrue(); + expect(model.pause).toBeTrue(); + }); + + it('should correctly parse an null or undefined ActionsResponse parameters as false in the DisallowsModel', () => { + const response: ActionsResponse = { + interrupting_playback: null, + toggling_repeat_track: false, + toggling_repeat_context: undefined + }; + const model = parseDisallows(response); + + expect(model.interruptPlayback).toBeFalse(); + expect(model.repeatTrack).toBeFalse(); + expect(model.repeatContext).toBeFalse(); + expect(model.seek).toBeFalse(); + expect(model.shuffle).toBeFalse(); + expect(model.skipNext).toBeFalse(); + expect(model.skipPrev).toBeFalse(); + expect(model.transferPlayback).toBeFalse(); + expect(model.resume).toBeFalse(); + expect(model.pause).toBeFalse(); + }); + + it('should correctly parse a null ActionsResponse as all false values in the DisallowsModel', () => { + const model = parseDisallows(null); + + expect(model.interruptPlayback).toBeFalse(); + expect(model.repeatTrack).toBeFalse(); + expect(model.repeatContext).toBeFalse(); + expect(model.seek).toBeFalse(); + expect(model.shuffle).toBeFalse(); + expect(model.skipNext).toBeFalse(); + expect(model.skipPrev).toBeFalse(); + expect(model.transferPlayback).toBeFalse(); + expect(model.resume).toBeFalse(); + expect(model.pause).toBeFalse(); + }); + }); + describe('getIdFromSpotifyUri', () => { it('should return the ID from a valid Spotify Uri', () => { expect(getIdFromSpotifyUri('abc:123:my_id')).toEqual('my_id'); diff --git a/src/app/core/util.ts b/src/app/core/util.ts index 4a8d2f5..44bf8bd 100644 --- a/src/app/core/util.ts +++ b/src/app/core/util.ts @@ -1,8 +1,11 @@ +import { HttpResponse, HttpStatusCode } from '@angular/common/http'; +import { ActionsResponse } from '../models/actions.model'; import { AlbumResponse } from '../models/album.model'; import { DeviceResponse } from '../models/device.model'; import { PlaylistResponse } from '../models/playlist.model'; import { TrackResponse } from '../models/track.model'; -import { AlbumModel, DeviceModel, PlaylistModel, TrackModel } from './playback/playback.model'; +import { AlbumModel, DeviceModel, DisallowsModel, PlaylistModel, TrackModel } from './playback/playback.model'; +import { SpotifyAPIResponse } from './types'; export const VALID_HEX_COLOR = '^#?[A-Fa-f0-9]{3}$|^#?[A-Fa-f0-9]{6}$'; const validHexRegex = new RegExp(VALID_HEX_COLOR); @@ -256,6 +259,24 @@ export function parseDevice(device: DeviceResponse): DeviceModel { return null; } +export function parseDisallows(disallows: ActionsResponse): DisallowsModel { + if (!disallows) { + disallows = {}; + } + return { + interruptPlayback: !!disallows.interrupting_playback, + pause: !!disallows.pausing, + resume: !!disallows.resuming, + seek: !!disallows.seeking, + skipNext: !!disallows.skipping_next, + skipPrev: !!disallows.skipping_prev, + repeatContext: !!disallows.toggling_repeat_context, + shuffle: !!disallows.toggling_shuffle, + repeatTrack: !!disallows.toggling_repeat_track, + transferPlayback: !!disallows.transferring_playback + }; +} + export function getIdFromSpotifyUri(uri: string): string { if (uri) { const uriParts = uri.split(':'); @@ -307,3 +328,19 @@ export function capitalizeWords(words: string, separator: string): string { } return null; } + +/** + * Checks a Spotify API response against common response codes + * @param res + * @param hasResponse + * @param isPlayback (optional) + * @private + */ +export function checkResponse(res: HttpResponse, hasResponse: boolean, isPlayback = false): SpotifyAPIResponse { + if (res.status === HttpStatusCode.Ok && (hasResponse || isPlayback)) { + return SpotifyAPIResponse.Success; + } + else if (res.status === HttpStatusCode.NoContent && (!hasResponse || isPlayback)) { + return isPlayback ? SpotifyAPIResponse.NoPlayback : SpotifyAPIResponse.Success; + } +} diff --git a/src/app/directives/component-throttle/interaction-throttle.directive.spec.ts b/src/app/directives/component-throttle/interaction-throttle.directive.spec.ts new file mode 100644 index 0000000..910bd32 --- /dev/null +++ b/src/app/directives/component-throttle/interaction-throttle.directive.spec.ts @@ -0,0 +1,62 @@ +import { Component } from '@angular/core'; +import { + ComponentFixture, + discardPeriodicTasks, + fakeAsync, + TestBed, + tick, + waitForAsync +} from '@angular/core/testing'; +import { expect } from '@angular/flex-layout/_private-utils/testing'; +import { InteractionThrottleDirective } from './interaction-throttle.directive'; + +@Component({ + template: ` + + ` +}) +class TestComponent { + onClick(): void {} +} + +describe('ButtonThrottleDirective', () => { + let fixture: ComponentFixture; + let component: TestComponent; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [ + InteractionThrottleDirective, + TestComponent + ] + }).compileComponents(); + + fixture = TestBed.createComponent(TestComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + })); + + it('should call the throttledClick method when clicked', fakeAsync(() => { + spyOn(component, 'onClick'); + const button = fixture.debugElement.nativeElement.querySelector('button'); + button.click(); + tick(500); + expect(component.onClick).toHaveBeenCalledTimes(1); + discardPeriodicTasks(); + })); + + it('should call the throttledClick only once when within throttle delay', fakeAsync(() => { + spyOn(component, 'onClick'); + const button = fixture.debugElement.nativeElement.querySelector('button'); + button.click(); // allowed + tick(500); + button.click(); // throttled + tick(250); + button.click(); // throttled + tick(250); + button.click(); // allowed + expect(component.onClick).toHaveBeenCalledTimes(2); + discardPeriodicTasks(); + })); +}); diff --git a/src/app/directives/component-throttle/interaction-throttle.directive.ts b/src/app/directives/component-throttle/interaction-throttle.directive.ts new file mode 100644 index 0000000..b235449 --- /dev/null +++ b/src/app/directives/component-throttle/interaction-throttle.directive.ts @@ -0,0 +1,69 @@ +import { Directive, EventEmitter, HostListener, Input, OnDestroy, OnInit, Output } from '@angular/core'; +import { MatSliderChange } from '@angular/material/slider'; +import { Subject } from 'rxjs'; +import { takeUntil, throttleTime } from 'rxjs/operators'; +import { AppConfig } from '../../app.config'; + +export const DELAY_DEFAULT = 1000; // ms + +@Directive({ + selector: '[appInteractionThrottle]' +}) +export class InteractionThrottleDirective implements OnInit, OnDestroy { + private ngUnsubscribe = new Subject(); + + @Input() + delay = DELAY_DEFAULT; + + @Output() + throttledClick = new EventEmitter(); + + @Output() + throttledChange = new EventEmitter(); + + private throttledClicks = new Subject(); + private throttledChanges = new Subject(); + + constructor() { + if (AppConfig.isEnvInitialized() && AppConfig.settings.env.throttleDelay) { + this.delay = AppConfig.settings.env.throttleDelay; + } + } + + ngOnInit(): void { + this.throttledClicks.pipe( + takeUntil(this.ngUnsubscribe), + throttleTime(this.delay) + ).subscribe(e => this.emitClick(e)); + + this.throttledChanges.pipe( + takeUntil(this.ngUnsubscribe), + throttleTime(this.delay) + ).subscribe(e => this.emitChange(e)); + } + + ngOnDestroy(): void { + this.ngUnsubscribe.next(); + this.ngUnsubscribe.complete(); + } + + private emitClick(e: any): void { + this.throttledClick.emit(e); + } + + private emitChange(e: any): void { + this.throttledChange.emit(e); + } + + @HostListener('click', ['$event']) + clickEvent(event): void { + event.preventDefault(); + event.stopPropagation(); + this.throttledClicks.next(event); + } + + @HostListener('change', ['$event']) + changeEvent(event: MatSliderChange): void { + this.throttledChanges.next(event); + } +} diff --git a/src/app/models/actions.model.ts b/src/app/models/actions.model.ts new file mode 100644 index 0000000..5c7adf5 --- /dev/null +++ b/src/app/models/actions.model.ts @@ -0,0 +1,12 @@ +export interface ActionsResponse { + interrupting_playback?: boolean; + pausing?: boolean; + resuming?: boolean; + seeking?: boolean; + skipping_next?: boolean; + skipping_prev?: boolean; + toggling_repeat_context?: boolean; + toggling_shuffle?: boolean; + toggling_repeat_track?: boolean; + transferring_playback?: boolean; +} diff --git a/src/app/models/app-config.model.ts b/src/app/models/app-config.model.ts index 2bbf90a..1ad50d4 100644 --- a/src/app/models/app-config.model.ts +++ b/src/app/models/app-config.model.ts @@ -1,18 +1,23 @@ export interface IAppConfig { env: { - name: string; + name?: string; domain: string; spotifyApiUrl: string; + spotifyAccountsUrl: string; + playbackPolling?: number; + idlePolling?: number; + throttleDelay?: number; }; auth: { clientId: string; - clientSecret: string; + clientSecret?: string; scopes: string; - tokenUrl: string; - forcePkce: boolean; - showDialog: boolean; + tokenUrl?: string; + forcePkce?: boolean; + showDialog?: boolean; + expiryThreshold?: number; }; - logging: { + logging?: { level: string; }; } diff --git a/src/app/models/current-playback.model.ts b/src/app/models/current-playback.model.ts index 49d63e9..893e33f 100644 --- a/src/app/models/current-playback.model.ts +++ b/src/app/models/current-playback.model.ts @@ -1,15 +1,20 @@ +import { ActionsResponse } from './actions.model'; import { DeviceResponse } from './device.model'; import { TrackResponse } from './track.model'; -import {ContextResponse} from './context.model'; +import { ContextResponse } from './context.model'; export interface CurrentPlaybackResponse { item: TrackResponse; progress_ms: number; is_playing: boolean; shuffle_state: boolean; + smart_shuffle: boolean; repeat_state: string; context: ContextResponse; device: DeviceResponse; currently_playing_type: string; timestamp: number; + actions: { + disallows: ActionsResponse; + }; } diff --git a/src/app/services/playback/playback.service.spec.ts b/src/app/services/playback/playback.service.spec.ts index dd09f3c..d54c06e 100644 --- a/src/app/services/playback/playback.service.spec.ts +++ b/src/app/services/playback/playback.service.spec.ts @@ -3,30 +3,43 @@ import { expect } from '@angular/flex-layout/_private-utils/testing'; import { NgxsModule } from '@ngxs/store'; import { MockProvider } from 'ng-mocks'; import { BehaviorSubject } from 'rxjs'; +import { AppConfig } from '../../app.config'; +import { PlayerState } from '../../core/playback/playback.model'; import { NgxsSelectorMock } from '../../core/testing/ngxs-selector-mock'; -import { SpotifyService } from '../spotify/spotify.service'; -import { IDLE_POLLING, PLAYBACK_POLLING, PlaybackService } from './playback.service'; +import { SpotifyPollingService } from '../spotify/polling/spotify-polling.service'; +import { PlaybackService } from './playback.service'; describe('PlaybackService', () => { const mockSelectors = new NgxsSelectorMock(); let service: PlaybackService; - let spotify: SpotifyService; + let polling: SpotifyPollingService; let intervalProducer: BehaviorSubject; - let isIdleProducer: BehaviorSubject; + let playerStateProducer: BehaviorSubject; let isAuthenticatedProducer: BehaviorSubject; beforeEach(() => { + AppConfig.settings = { + env: { + name: 'test-name', + domain: 'test-domain', + spotifyApiUrl: 'spotify-url', + spotifyAccountsUrl: 'spotify-accounts', + idlePolling: 3000, + playbackPolling: 1000 + }, + auth: null + }; TestBed.configureTestingModule({ imports: [ NgxsModule.forRoot([], { developmentMode: true }) ], - providers: [ MockProvider(SpotifyService) ] + providers: [ MockProvider(SpotifyPollingService) ] }); service = TestBed.inject(PlaybackService); - spotify = TestBed.inject(SpotifyService); + polling = TestBed.inject(SpotifyPollingService); intervalProducer = mockSelectors.defineNgxsSelector(service, 'interval$'); - isIdleProducer = mockSelectors.defineNgxsSelector(service, 'isIdle$'); + playerStateProducer = mockSelectors.defineNgxsSelector(service, 'playerState$'); isAuthenticatedProducer = mockSelectors.defineNgxsSelector(service, 'isAuthenticated$'); jasmine.clock().install(); @@ -40,51 +53,67 @@ describe('PlaybackService', () => { expect(service).toBeTruthy(); }); - it('should poll Spotify playback with idle polling interval when isAuthenticated and isIdle', () => { + it('should poll Spotify playback with idle polling interval when isAuthenticated and is idling state', () => { service.initialize(); isAuthenticatedProducer.next(true); - isIdleProducer.next(true); - jasmine.clock().tick(IDLE_POLLING); - expect(spotify.pollCurrentPlayback).toHaveBeenCalled(); + playerStateProducer.next(PlayerState.Idling); + jasmine.clock().tick(AppConfig.settings.env.idlePolling); + expect(polling.pollCurrentPlayback).toHaveBeenCalled(); }); - it('should poll Spotify playback with playback polling interval when isAuthenticated and not isIdle', () => { + it('should poll Spotify playback with playback polling interval when isAuthenticated and is playing state', () => { service.initialize(); isAuthenticatedProducer.next(true); - isIdleProducer.next(false); - jasmine.clock().tick(PLAYBACK_POLLING); - expect(spotify.pollCurrentPlayback).toHaveBeenCalled(); + playerStateProducer.next(PlayerState.Playing); + jasmine.clock().tick(AppConfig.settings.env.playbackPolling); + expect(polling.pollCurrentPlayback).toHaveBeenCalled(); }); - it('should not poll Spotify playback after playback polling when not isAuthenticated and isIdle', () => { + it('should not poll Spotify playback after playback polling when not isAuthenticated and is idling state', () => { service.initialize(); isAuthenticatedProducer.next(false); - isIdleProducer.next(true); - jasmine.clock().tick(PLAYBACK_POLLING); - expect(spotify.pollCurrentPlayback).not.toHaveBeenCalled(); + playerStateProducer.next(PlayerState.Idling); + jasmine.clock().tick(AppConfig.settings.env.playbackPolling); + expect(polling.pollCurrentPlayback).not.toHaveBeenCalled(); }); - it('should not poll Spotify playback after idle polling when not isAuthenticated and isIdle', () => { + it('should not poll Spotify playback after idle polling when not isAuthenticated and is idling state', () => { service.initialize(); isAuthenticatedProducer.next(false); - isIdleProducer.next(true); - jasmine.clock().tick(IDLE_POLLING); - expect(spotify.pollCurrentPlayback).not.toHaveBeenCalled(); + playerStateProducer.next(PlayerState.Idling); + jasmine.clock().tick(AppConfig.settings.env.idlePolling); + expect(polling.pollCurrentPlayback).not.toHaveBeenCalled(); }); - it('should not poll Spotify playback after playback polling when not isAuthenticated and not isIdle', () => { + it('should not poll Spotify playback after playback polling when not isAuthenticated and is playing state', () => { service.initialize(); isAuthenticatedProducer.next(false); - isIdleProducer.next(false); - jasmine.clock().tick(PLAYBACK_POLLING); - expect(spotify.pollCurrentPlayback).not.toHaveBeenCalled(); + playerStateProducer.next(PlayerState.Playing); + jasmine.clock().tick(AppConfig.settings.env.playbackPolling); + expect(polling.pollCurrentPlayback).not.toHaveBeenCalled(); }); - it('should not poll Spotify playback after idle polling when not isAuthenticated and not isIdle', () => { + it('should not poll Spotify playback after idle polling when not isAuthenticated and is playing state', () => { service.initialize(); isAuthenticatedProducer.next(false); - isIdleProducer.next(false); - jasmine.clock().tick(IDLE_POLLING); - expect(spotify.pollCurrentPlayback).not.toHaveBeenCalled(); + playerStateProducer.next(PlayerState.Playing); + jasmine.clock().tick(AppConfig.settings.env.idlePolling); + expect(polling.pollCurrentPlayback).not.toHaveBeenCalled(); + }); + + it('should not poll Spotify playback when isAuthenticated and is refreshing state', () => { + service.initialize(); + isAuthenticatedProducer.next(true); + playerStateProducer.next(PlayerState.Refreshing); + jasmine.clock().tick(AppConfig.settings.env.idlePolling); + expect(polling.pollCurrentPlayback).not.toHaveBeenCalled(); + }); + + it('should not poll Spotify playback when not isAuthenticated and is refreshing state', () => { + service.initialize(); + isAuthenticatedProducer.next(false); + playerStateProducer.next(PlayerState.Refreshing); + jasmine.clock().tick(AppConfig.settings.env.idlePolling); + expect(polling.pollCurrentPlayback).not.toHaveBeenCalled(); }); }); diff --git a/src/app/services/playback/playback.service.ts b/src/app/services/playback/playback.service.ts index 49865aa..ee1b6d7 100644 --- a/src/app/services/playback/playback.service.ts +++ b/src/app/services/playback/playback.service.ts @@ -2,44 +2,47 @@ import { Injectable, OnDestroy } from '@angular/core'; import { Select } from '@ngxs/store'; import { BehaviorSubject, interval, NEVER, Observable, Subject } from 'rxjs'; import { switchMap, takeUntil } from 'rxjs/operators'; +import { AppConfig } from '../../app.config'; import { AuthState } from '../../core/auth/auth.state'; +import { PlayerState } from '../../core/playback/playback.model'; import { PlaybackState } from '../../core/playback/playback.state'; -import { SpotifyService } from '../spotify/spotify.service'; - -export const IDLE_POLLING = 3000; // ms -export const PLAYBACK_POLLING = 1000; // ms +import { SpotifyPollingService } from '../spotify/polling/spotify-polling.service'; @Injectable({providedIn: 'root'}) export class PlaybackService implements OnDestroy { private ngUnsubscribe = new Subject(); - private interval$ = new BehaviorSubject(PLAYBACK_POLLING); - @Select(PlaybackState.isIdle) isIdle$: Observable; - private isIdle = true; + private interval$ = new BehaviorSubject(AppConfig.settings.env.playbackPolling); + @Select(PlaybackState.playerState) playerState$: Observable; + private playerState = PlayerState.Idling; @Select(AuthState.isAuthenticated) isAuthenticated$: Observable; private isAuthenticated = false; - constructor(private spotify: SpotifyService) { } + constructor(private polling: SpotifyPollingService) { } initialize(): void { if (this.interval$) { this.interval$ .pipe( switchMap(value => { - return this.isAuthenticated ? interval(value) : NEVER; + // Don't poll playback if not authenticated or currently refreshing the auth token + if (this.isAuthenticated && this.playerState !== PlayerState.Refreshing) { + return interval(value); + } + return NEVER; }), takeUntil(this.ngUnsubscribe)) - .subscribe((pollingInterval) => { - this.spotify.pollCurrentPlayback(pollingInterval); + .subscribe((_) => { + this.polling.pollCurrentPlayback(); }); } - if (this.isIdle$) { - this.isIdle$ + if (this.playerState$) { + this.playerState$ .pipe(takeUntil(this.ngUnsubscribe)) - .subscribe(isIdle => { - this.isIdle = isIdle; - this.interval$.next(isIdle ? IDLE_POLLING : PLAYBACK_POLLING); + .subscribe(playerState => { + this.playerState = playerState; + this.interval$.next(this.calculatePollingRate(playerState)); }); } @@ -49,11 +52,16 @@ export class PlaybackService implements OnDestroy { .subscribe(isAuthenticated => { this.isAuthenticated = isAuthenticated; // Send a new polling value to either start or stop playback - this.interval$.next(this.isIdle ? IDLE_POLLING : PLAYBACK_POLLING); + this.interval$.next(this.calculatePollingRate(this.playerState)); }); } } + private calculatePollingRate(playerState: PlayerState): number { + return playerState === PlayerState.Playing ? + AppConfig.settings.env.playbackPolling : AppConfig.settings.env.idlePolling; + } + ngOnDestroy(): void { this.ngUnsubscribe.next(); this.ngUnsubscribe.complete(); diff --git a/src/app/services/spotify/auth/spotify-auth.service.spec.ts b/src/app/services/spotify/auth/spotify-auth.service.spec.ts new file mode 100644 index 0000000..d96bb48 --- /dev/null +++ b/src/app/services/spotify/auth/spotify-auth.service.spec.ts @@ -0,0 +1,763 @@ +/* tslint:disable:no-string-literal */ +import { HttpClient, HttpHeaders, HttpResponse, HttpStatusCode } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { fakeAsync, flushMicrotasks, TestBed } from '@angular/core/testing'; +import { expect } from '@angular/flex-layout/_private-utils/testing'; +import { Router } from '@angular/router'; +import { NgxsModule, Store } from '@ngxs/store'; +import { MockProvider } from 'ng-mocks'; +import { BehaviorSubject, of, throwError } from 'rxjs'; +import { AppConfig } from '../../../app.config'; +import { SetAuthToken } from '../../../core/auth/auth.actions'; +import { AuthToken } from '../../../core/auth/auth.model'; +import { SetPlayerState } from '../../../core/playback/playback.actions'; +import { PlayerState } from '../../../core/playback/playback.model'; +import { SpotifyEndpoints } from '../../../core/spotify/spotify-endpoints'; +import { NgxsSelectorMock } from '../../../core/testing/ngxs-selector-mock'; +import { getTestAuthToken } from '../../../core/testing/test-models'; +import { getTestAppConfig } from '../../../core/testing/test-responses'; +import { generateErrorResponse, generateResponse } from '../../../core/testing/test-util'; +import { AuthType, SpotifyAPIResponse } from '../../../core/types'; +import { StorageService } from '../../storage/storage.service'; +import { SpotifyAuthService } from './spotify-auth.service'; +import anything = jasmine.anything; + +describe('SpotifyAuthService', () => { + const mockSelectors = new NgxsSelectorMock(); + let service: SpotifyAuthService; + let http: HttpClient; + let router: Router; + let store: Store; + let storage: StorageService; + + let tokenProducer: BehaviorSubject; + + beforeEach(() => { + AppConfig.settings = getTestAppConfig(); + SpotifyAuthService.initialize(); + + TestBed.configureTestingModule({ + imports: [ + NgxsModule.forRoot([], {developmentMode: true}), + HttpClientTestingModule + ], + providers: [ + SpotifyAuthService, + MockProvider(HttpClient), + MockProvider(Router), + MockProvider(Store), + MockProvider(StorageService) + ] + }); + service = TestBed.inject(SpotifyAuthService); + http = TestBed.inject(HttpClient); + router = TestBed.inject(Router); + store = TestBed.inject(Store); + storage = TestBed.inject(StorageService); + + tokenProducer = mockSelectors.defineNgxsSelector(service, 'authToken$', getTestAuthToken()); + + service.initSubscriptions(); + spyOn(console, 'error'); + spyOn(console, 'warn'); + store.dispatch = jasmine.createSpy().and.returnValue(of(null)); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should fail to initialize if no configured clientId', () => { + AppConfig.settings.auth.clientId = null; + expect(SpotifyAuthService.initialize()).toBeFalse(); + expect(console.error).toHaveBeenCalled(); + }); + + it('should set tokenUrl on initialization when configured', () => { + AppConfig.settings.auth.tokenUrl = 'test-token-url'; + expect(SpotifyAuthService.initialize()).toBeTrue(); + expect(SpotifyAuthService['tokenUrl']).toBeTruthy(); + }); + + it('should set clientSecret on initialization when configured', () => { + AppConfig.settings.auth.clientSecret = 'test-client-secret'; + expect(SpotifyAuthService.initialize()).toBeTrue(); + expect(SpotifyAuthService['clientSecret']).toBeTruthy(); + }); + + it('should set scopes on initialization when configured', () => { + expect(SpotifyAuthService.initialize()).toBeTrue(); + expect(SpotifyAuthService['scopes']).toBeTruthy(); + }); + + it('should set showAuthDialog on initialization when configured', () => { + expect(SpotifyAuthService.initialize()).toBeTrue(); + expect(SpotifyAuthService['showAuthDialog']).toBeTrue(); + }); + + it('should set auth type to PKCE if no configured tokenUrl and clientSecret', () => { + expect(SpotifyAuthService.initialize()).toBeTrue(); + expect(SpotifyAuthService['authType']).toEqual(AuthType.PKCE); + }); + + it('should set auth type to PKCE if forcePkce is true', () => { + AppConfig.settings.auth.forcePkce = true; + expect(SpotifyAuthService.initialize()).toBeTrue(); + expect(SpotifyAuthService['authType']).toEqual(AuthType.PKCE); + }); + + it('should set auth type to ThirdParty if tokenUrl is configured and clientSecret not configured', () => { + AppConfig.settings.auth.tokenUrl = 'test-token-url'; + expect(SpotifyAuthService.initialize()).toBeTrue(); + expect(SpotifyAuthService['authType']).toEqual(AuthType.ThirdParty); + }); + + it('should set auth type to Secret if tokenUrl not configured and clientSecret is configured', () => { + AppConfig.settings.auth.clientSecret = 'test-client-secret'; + expect(SpotifyAuthService.initialize()).toBeTrue(); + expect(SpotifyAuthService['authType']).toEqual(AuthType.Secret); + }); + + it('should fail to initialize if no configured spotifyApiUrl', () => { + AppConfig.settings.env.spotifyApiUrl = null; + expect(SpotifyAuthService.initialize()).toBeFalse(); + expect(console.error).toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalled(); + }); + + it('should fail to initialize if no configured spotifyAccountsUrl', () => { + AppConfig.settings.env.spotifyAccountsUrl = null; + expect(SpotifyAuthService.initialize()).toBeFalse(); + expect(console.error).toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalled(); + }); + + it('should fail to initialize if no configured domain', () => { + AppConfig.settings.env.domain = null; + expect(SpotifyAuthService.initialize()).toBeFalse(); + expect(console.error).toHaveBeenCalled(); + }); + + it('should fail to initialize if issue retrieving AppConfig', () => { + AppConfig.settings.env = null; + expect(SpotifyAuthService.initialize()).toBeFalse(); + expect(console.error).toHaveBeenCalled(); + expect(console.warn).toHaveBeenCalled(); + + AppConfig.settings.auth = null; + expect(SpotifyAuthService.initialize()).toBeFalse(); + expect(console.error).toHaveBeenCalled(); + + AppConfig.settings = null; + expect(SpotifyAuthService.initialize()).toBeFalse(); + expect(console.error).toHaveBeenCalled(); + }); + + it('should add Authorization header when requesting auth token and auth type is secret', fakeAsync(() => { + SpotifyAuthService['authType'] = AuthType.Secret; + http.post = jasmine.createSpy().and.returnValue(of(new HttpResponse({body: {}, status: HttpStatusCode.Ok, statusText: 'OK'}))); + + service.requestAuthToken('test-code', false); + flushMicrotasks(); + expect(http.post).toHaveBeenCalledOnceWith( + jasmine.any(String), + jasmine.any(URLSearchParams), + { + headers: new HttpHeaders().set( + 'Content-Type', 'application/x-www-form-urlencoded' + ).set( + 'Authorization', `Basic ${new Buffer(`${SpotifyAuthService['clientId']}:${SpotifyAuthService['clientSecret']}`).toString('base64')}` + ), + observe: 'response' + }); + })); + + it('should NOT add Authorization header when requesting auth token and auth type is PKCE', fakeAsync(() => { + SpotifyAuthService['authType'] = AuthType.PKCE; + http.post = jasmine.createSpy().and.returnValue(of(new HttpResponse({body: {}, status: HttpStatusCode.Ok, statusText: 'OK'}))); + + service.requestAuthToken('test-code', false); + flushMicrotasks(); + expect(http.post).toHaveBeenCalledOnceWith( + jasmine.any(String), + jasmine.any(URLSearchParams), + { + headers: new HttpHeaders().set( + 'Content-Type', 'application/x-www-form-urlencoded' + ), + observe: 'response' + }); + })); + + it('should NOT add Authorization header when requesting auth token and auth type is ThirdParty', fakeAsync(() => { + SpotifyAuthService['authType'] = AuthType.ThirdParty; + SpotifyAuthService['tokenUrl'] = 'test-token-url'; + http.post = jasmine.createSpy().and.returnValue(of(new HttpResponse({body: {}, status: HttpStatusCode.Ok, statusText: 'OK'}))); + + service.requestAuthToken('test-code', false); + flushMicrotasks(); + expect(http.post).toHaveBeenCalledOnceWith( + jasmine.any(String), + jasmine.any(URLSearchParams), + { + headers: new HttpHeaders().set( + 'Content-Type', 'application/x-www-form-urlencoded' + ), + observe: 'response' + }); + })); + + it(`should use Spotify's token endpoint if auth type is PKCE when requesting an auth token`, fakeAsync(() => { + SpotifyAuthService['authType'] = AuthType.PKCE; + http.post = jasmine.createSpy().and.returnValue(of(new HttpResponse({body: {}, status: HttpStatusCode.Ok, statusText: 'OK'}))); + + service.requestAuthToken('test-code', false); + flushMicrotasks(); + expect(http.post).toHaveBeenCalledOnceWith( + SpotifyEndpoints.getTokenEndpoint(), + jasmine.any(URLSearchParams), + jasmine.any(Object) + ); + })); + + it(`should use Spotify's token endpoint if auth type is Secret when requesting an auth token`, fakeAsync(() => { + SpotifyAuthService['authType'] = AuthType.Secret; + http.post = jasmine.createSpy().and.returnValue(of(new HttpResponse({body: {}, status: HttpStatusCode.Ok, statusText: 'OK'}))); + + service.requestAuthToken('test-code', false); + flushMicrotasks(); + expect(http.post).toHaveBeenCalledOnceWith( + SpotifyEndpoints.getTokenEndpoint(), + jasmine.any(URLSearchParams), + jasmine.any(Object) + ); + })); + + it(`should use the configured token URL endpoint is auth type is ThirdParty when requesting an auth token`, fakeAsync(() => { + SpotifyAuthService['authType'] = AuthType.ThirdParty; + http.post = jasmine.createSpy().and.returnValue(of(new HttpResponse({body: {}, status: HttpStatusCode.Ok, statusText: 'OK'}))); + + service.requestAuthToken('test-code', false); + flushMicrotasks(); + expect(http.post).toHaveBeenCalledOnceWith( + SpotifyAuthService['tokenUrl'], + jasmine.any(URLSearchParams), + jasmine.any(Object) + ); + })); + + it('should send correct request parameters for requesting a new auth token', fakeAsync(() => { + service['codeVerifier'] = 'test-code-verifier'; + http.post = jasmine.createSpy().and.returnValue(of(new HttpResponse({body: {}, status: HttpStatusCode.Ok, statusText: 'OK'}))); + const expectedBody = new URLSearchParams({ + grant_type: 'authorization_code', + client_id: SpotifyAuthService['clientId'], + code: 'test-code', + redirect_uri: SpotifyAuthService['redirectUri'], + code_verifier: 'test-code-verifier' + }); + + service.requestAuthToken('test-code', false); + flushMicrotasks(); + expect(http.post).toHaveBeenCalledOnceWith( + jasmine.any(String), + expectedBody, + jasmine.any(Object) + ); + })); + + it('should send correct request parameters for refreshing an existing auth token', fakeAsync(() => { + http.post = jasmine.createSpy().and.returnValue(of(new HttpResponse({body: {}, status: HttpStatusCode.Ok, statusText: 'OK'}))); + const expectedBody = new URLSearchParams({ + grant_type: 'refresh_token', + client_id: SpotifyAuthService['clientId'], + refresh_token: 'test-code' + }); + + service.requestAuthToken('test-code', false); + flushMicrotasks(); + expect(http.post).toHaveBeenCalledOnceWith( + jasmine.any(String), + expectedBody, + jasmine.any(Object) + ); + })); + + it('should set the auth token with response from requesting a new auth token and handle expires_in response', fakeAsync(() => { + const response = new HttpResponse({ + body: { + access_token: 'test-access-token', + token_type: 'test-type', + expires_in: Date.now(), + scope: 'test-scope', + refresh_token: 'test-refresh-token' + }, + status: HttpStatusCode.Ok, + statusText: 'OK' + }); + http.post = jasmine.createSpy().and.returnValue(of(response)); + + service.requestAuthToken('test-code', false); + flushMicrotasks(); + expect(http.post).toHaveBeenCalledOnceWith( + jasmine.any(String), + jasmine.any(URLSearchParams), + jasmine.any(Object) + ); + const expiryResponse = new Date(); + expiryResponse.setSeconds(expiryResponse.getSeconds() + response.body.expires_in); + expect(store.dispatch).toHaveBeenCalledWith(new SetAuthToken({ + accessToken: response.body.access_token, + tokenType: response.body.token_type, + scope: response.body.scope, + expiry: expiryResponse, + refreshToken: response.body.refresh_token + })); + })); + + it('should set the auth token with response from requesting a new auth token using third party and handle expiry response', + fakeAsync(() => { + SpotifyAuthService['authType'] = AuthType.ThirdParty; + SpotifyAuthService['tokenUrl'] = 'test-token-url'; + const response = new HttpResponse({ + body: { + access_token: 'test-access-token', + token_type: 'test-type', + expiry: new Date().toString(), + scope: 'test-scope', + refresh_token: 'test-refresh-token' + }, + status: HttpStatusCode.Ok, + statusText: 'OK' + }); + http.post = jasmine.createSpy().and.returnValue(of(response)); + + service.requestAuthToken('test-code', false); + flushMicrotasks(); + expect(http.post).toHaveBeenCalledOnceWith( + jasmine.any(String), + jasmine.any(URLSearchParams), + jasmine.any(Object) + ); + expect(store.dispatch).toHaveBeenCalledWith(new SetAuthToken({ + accessToken: response.body.access_token, + tokenType: response.body.token_type, + scope: response.body.scope, + expiry: new Date(response.body.expiry), + refreshToken: response.body.refresh_token + })); + })); + + it('should set the auth token with response from requesting a new auth token using third party and handle expires_in response', + fakeAsync(() => { + SpotifyAuthService['authType'] = AuthType.ThirdParty; + SpotifyAuthService['tokenUrl'] = 'test-token-url'; + const response = new HttpResponse({ + body: { + access_token: 'test-access-token', + token_type: 'test-type', + expires_in: Date.now(), + scope: 'test-scope', + refresh_token: 'test-refresh-token' + }, + status: HttpStatusCode.Ok, + statusText: 'OK' + }); + http.post = jasmine.createSpy().and.returnValue(of(response)); + + service.requestAuthToken('test-code', false); + flushMicrotasks(); + expect(http.post).toHaveBeenCalledOnceWith( + jasmine.any(String), + jasmine.any(URLSearchParams), + jasmine.any(Object) + ); + const expiryResponse = new Date(); + expiryResponse.setSeconds(expiryResponse.getSeconds() + response.body.expires_in); + expect(store.dispatch).toHaveBeenCalledWith(new SetAuthToken({ + accessToken: response.body.access_token, + tokenType: response.body.token_type, + scope: response.body.scope, + expiry: expiryResponse, + refreshToken: response.body.refresh_token + })); + })); + + it('should output error when requestAuthToken fails', fakeAsync(() => { + http.post = jasmine.createSpy().and.returnValue(throwError({status: HttpStatusCode.MethodNotAllowed})); + let error; + service.requestAuthToken('test-code', false) + .catch((err) => error = err); + flushMicrotasks(); + expect(error).toBeTruthy(); + expect(http.post).toHaveBeenCalled(); + expect(store.dispatch).not.toHaveBeenCalled(); + expect(console.error).toHaveBeenCalled(); + })); + + it('should create the authorize request url with correct params when auth type is Secret', fakeAsync(() => { + SpotifyAuthService['authType'] = AuthType.Secret; + service['state'] = 'test-state'; + const expectedParams = new URLSearchParams({ + response_type: 'code', + client_id: SpotifyAuthService['clientId'], + scope: 'test-scope', + redirect_uri: `${AppConfig.settings.env.domain}/callback`, + state: 'test-state', + show_dialog: 'true' + }); + const expectedUrl = `${SpotifyEndpoints.getAuthorizeEndpoint()}?${expectedParams.toString()}`; + let actualUrl; + service.getAuthorizeRequestUrl().then((url) => actualUrl = url); + + flushMicrotasks(); + expect(actualUrl).toEqual(expectedUrl); + })); + + it('should create the authorize request url with code challenge params when auth type is PKCE', fakeAsync(() => { + spyOn(window.crypto.subtle, 'digest').and.returnValue(Promise.resolve(new ArrayBuffer(8))); + service['state'] = 'test-state'; + service['codeVerifier'] = 'test-code-verifier'; + const expectedParams = new URLSearchParams({ + response_type: 'code', + client_id: SpotifyAuthService['clientId'], + scope: 'test-scope', + redirect_uri: `${AppConfig.settings.env.domain}/callback`, + state: 'test-state', + show_dialog: 'true', + code_challenge_method: 'S256', + code_challenge: 'AAAAAAAAAAA' + }); + const expectedUrl = `${SpotifyEndpoints.getAuthorizeEndpoint()}?${expectedParams.toString()}`; + let actualUrl; + service.getAuthorizeRequestUrl().then((url) => actualUrl = url); + + flushMicrotasks(); + expect(actualUrl).toEqual(expectedUrl); + })); + + it('should compare states to true when current state not null and equal', () => { + service['state'] = 'test-state'; + expect(service.compareState('test-state')).toBeTrue(); + }); + + it('should compare states to false when current state not null and not equal', () => { + expect(service.compareState('blah')).toBeFalse(); + }); + + it('should compare states to false when current state is null and passed state not null', () => { + service['state'] = null; + expect(service.compareState('blah')).toBeFalse(); + }); + + it('should compare states to false when current state is null and passed state is null', () => { + service['state'] = null; + expect(service.compareState(null)).toBeFalse(); + }); + + it('should compare states to false when current state is undefined and passed state not null', () => { + service['state'] = undefined; + expect(service.compareState('blah')).toBeFalse(); + }); + + it('should compare states to false when current state is undefined and passed state is undefined', () => { + service['state'] = undefined; + expect(service.compareState(undefined)).toBeFalse(); + }); + + it('should remove all state and auth token values on logout', () => { + service['state'] = 'test-state'; + service.logout(); + expect(store.dispatch).toHaveBeenCalledWith(new SetAuthToken(null)); + expect(service['state']).toBeNull(); + expect(service['codeVerifier']).toBeNull(); + expect(service['authToken']).toBeNull(); + expect(storage.remove).toHaveBeenCalledOnceWith(SpotifyAuthService['STATE_KEY']); + expect(storage.removeAuthToken).toHaveBeenCalledTimes(1); + expect(router.navigateByUrl).toHaveBeenCalledWith('/login'); + }); + + it('should get current state if not null', () => { + service['state'] = 'test-state'; + storage.get = jasmine.createSpy(); + expect(service['getState']()).toEqual('test-state'); + expect(storage.get).not.toHaveBeenCalled(); + }); + + it('should set state from storage if exists', () => { + service['state'] = null; + storage.get = jasmine.createSpy().withArgs(SpotifyAuthService['STATE_KEY']).and.returnValue('test-state'); + service['setState'](); + expect(service['state']).toEqual('test-state'); + }); + + it('should generate new state and save to storage if it does not exist in storage', () => { + service['state'] = null; + storage.get = jasmine.createSpy().withArgs('STATE').and.returnValue(null); + service['setState'](); + expect(service['state']).toMatch(`^[A-Za-z0-9]{${SpotifyAuthService['STATE_LENGTH']}}$`); + expect(storage.set).toHaveBeenCalledWith(SpotifyAuthService['STATE_KEY'], service['state']); + }); + + it('should get current codeVerifier if not null', () => { + service['codeVerifier'] = 'test-code-verifier'; + storage.get = jasmine.createSpy(); + expect(service['getCodeVerifier']()).toEqual('test-code-verifier'); + expect(storage.get).not.toHaveBeenCalled(); + }); + + it('should set codeVerifier from storage if exists', () => { + service['codeVerifier'] = null; + storage.get = jasmine.createSpy().withArgs(SpotifyAuthService['CODE_VERIFIER_KEY']).and.returnValue('test-code-verifier'); + service['setCodeVerifier'](); + expect(service['codeVerifier']).toEqual('test-code-verifier'); + }); + + it('should generate new codeVerifier and save to storage if it does not exist in storage', () => { + service['codeVerifier'] = null; + storage.get = jasmine.createSpy().withArgs(SpotifyAuthService['CODE_VERIFIER_KEY']).and.returnValue(null); + service['setCodeVerifier'](); + expect(service['codeVerifier']).toBeTruthy(); + expect(storage.set).toHaveBeenCalledWith(SpotifyAuthService['CODE_VERIFIER_KEY'], service['codeVerifier']); + }); + + it('should reauthenticate when error response is an expired token', fakeAsync(() => { + spyOn(service, 'requestAuthToken').and.returnValue(Promise.resolve(null)); + const expiredToken = { + ...getTestAuthToken(), + expiry: new Date(Date.UTC(1999, 1, 1)) + }; + tokenProducer.next(expiredToken); + let apiResponse; + service.checkErrorResponse(generateErrorResponse(HttpStatusCode.Unauthorized)).then((response) => apiResponse = response); + + flushMicrotasks(); + expect(service.requestAuthToken).toHaveBeenCalledWith(expiredToken.refreshToken, true); + expect(store.dispatch).toHaveBeenCalledWith(new SetPlayerState(PlayerState.Refreshing)); + expect(apiResponse).toEqual(SpotifyAPIResponse.ReAuthenticated); + })); + + it('should logout when an error occurs requesting a new auth token after auth token has expired', fakeAsync(() => { + spyOn(service, 'requestAuthToken').and.returnValue(Promise.reject('test-error')); + spyOn(service, 'logout'); + const expiredToken = { + ...getTestAuthToken(), + expiry: new Date(Date.UTC(1999, 1, 1)) + }; + tokenProducer.next(expiredToken); + let apiResponse; + service.checkErrorResponse(generateErrorResponse(HttpStatusCode.Unauthorized)).then((response) => apiResponse = response); + + flushMicrotasks(); + expect(service.requestAuthToken).toHaveBeenCalledWith(expiredToken.refreshToken, true); + expect(console.error).toHaveBeenCalledOnceWith(jasmine.any(String)); + expect(service.logout).toHaveBeenCalled(); + expect(apiResponse).toEqual(SpotifyAPIResponse.Error); + })); + + it('should reject the promise when no refresh token is present after auth token has expired', fakeAsync(() => { + spyOn(service, 'requestAuthToken'); + spyOn(service, 'logout'); + const expiredToken = { + ...getTestAuthToken(), + refreshToken: null + }; + tokenProducer.next(expiredToken); + let apiError; + service.checkErrorResponse(generateErrorResponse(HttpStatusCode.Unauthorized)).catch((err) => apiError = err); + + flushMicrotasks(); + expect(service.requestAuthToken).not.toHaveBeenCalled(); + expect(service.logout).not.toHaveBeenCalled(); + expect(apiError).not.toBeNull(); + })); + + it('should reject the promise when no auth token is present after auth token has expired', fakeAsync(() => { + spyOn(service, 'requestAuthToken'); + spyOn(service, 'logout'); + tokenProducer.next(null); + let apiError; + service.checkErrorResponse(generateErrorResponse(HttpStatusCode.Unauthorized)).catch((err) => apiError = err); + + flushMicrotasks(); + expect(service.requestAuthToken).not.toHaveBeenCalled(); + expect(service.logout).not.toHaveBeenCalled(); + expect(apiError).not.toBeNull(); + })); + + it('should logout when error response is a bad OAuth request and cannot re-authenticate', fakeAsync(() => { + http.get = jasmine.createSpy().and.returnValue(of(generateResponse({}, HttpStatusCode.Forbidden))); + spyOn(service, 'logout'); + let apiResponse; + service.checkErrorResponse(generateErrorResponse(HttpStatusCode.Forbidden)).then((response) => apiResponse = response); + + flushMicrotasks(); + expect(service.logout).toHaveBeenCalled(); + expect(http.get).toHaveBeenCalledWith(SpotifyEndpoints.getUserEndpoint(), anything()); + expect(console.error).toHaveBeenCalled(); + expect(apiResponse).toEqual(SpotifyAPIResponse.Error); + })); + + it('should not logout when error response is a bad OAuth request and can still authenticate', fakeAsync(() => { + http.get = jasmine.createSpy().and.returnValue(of(generateResponse({}, HttpStatusCode.Ok))); + spyOn(service, 'logout'); + let apiResponse; + service.checkErrorResponse(generateErrorResponse(HttpStatusCode.Forbidden)).then((response) => apiResponse = response); + + flushMicrotasks(); + expect(service.logout).not.toHaveBeenCalled(); + expect(http.get).toHaveBeenCalledWith(SpotifyEndpoints.getUserEndpoint(), anything()); + expect(apiResponse).toEqual(SpotifyAPIResponse.Error); + })); + + it('should not logout when error response is a bad OAuth request for a violated restriction', fakeAsync(() => { + spyOn(service, 'logout'); + let apiResponse; + service.checkErrorResponse(generateErrorResponse(HttpStatusCode.Forbidden, 'Restriction Violated')) + .then((response) => apiResponse = response); + + flushMicrotasks(); + expect(service.logout).not.toHaveBeenCalled(); + expect(http.get).not.toHaveBeenCalled(); + expect(apiResponse).toEqual(SpotifyAPIResponse.Restricted); + })); + + it('should not logout when error response is Spotify rate limits exceeded and cannot re-authenticate', fakeAsync(() => { + http.get = jasmine.createSpy().and.returnValue(of(generateResponse({}, HttpStatusCode.Forbidden))); + spyOn(service, 'logout'); + let apiResponse; + service.checkErrorResponse(generateErrorResponse(HttpStatusCode.TooManyRequests)).then((response) => apiResponse = response); + + flushMicrotasks(); + expect(service.logout).toHaveBeenCalled(); + expect(http.get).toHaveBeenCalledWith(SpotifyEndpoints.getUserEndpoint(), anything()); + expect(console.error).toHaveBeenCalledTimes(2); + expect(apiResponse).toEqual(SpotifyAPIResponse.Error); + })); + + it('should logout when error response is Spotify rate limits exceeded and can still authenticate', fakeAsync(() => { + http.get = jasmine.createSpy().and.returnValue(of(generateResponse({}, HttpStatusCode.Ok))); + spyOn(service, 'logout'); + let apiResponse; + service.checkErrorResponse(generateErrorResponse(HttpStatusCode.TooManyRequests)).then((response) => apiResponse = response); + + flushMicrotasks(); + expect(service.logout).not.toHaveBeenCalled(); + expect(http.get).toHaveBeenCalledWith(SpotifyEndpoints.getUserEndpoint(), anything()); + expect(console.error).toHaveBeenCalledTimes(1); + expect(apiResponse).toEqual(SpotifyAPIResponse.Error); + })); + + it('should logout when error response is unknown and cannot re-authenticate', fakeAsync(() => { + http.get = jasmine.createSpy().and.returnValue(of(generateResponse({}, HttpStatusCode.Forbidden))); + spyOn(service, 'logout'); + let apiResponse; + service.checkErrorResponse(generateErrorResponse(HttpStatusCode.NotFound)).then((response) => apiResponse = response); + + flushMicrotasks(); + expect(service.logout).toHaveBeenCalled(); + expect(http.get).toHaveBeenCalledWith(SpotifyEndpoints.getUserEndpoint(), anything()); + expect(console.error).toHaveBeenCalledTimes(2); + expect(apiResponse).toEqual(SpotifyAPIResponse.Error); + })); + + it('should not logout when error response is unknown and can still authenticate', fakeAsync(() => { + http.get = jasmine.createSpy().and.returnValue(of(generateResponse({}, HttpStatusCode.Ok))); + spyOn(service, 'logout'); + let apiResponse; + service.checkErrorResponse(generateErrorResponse(HttpStatusCode.NotFound)).then((response) => apiResponse = response); + + flushMicrotasks(); + expect(service.logout).not.toHaveBeenCalled(); + expect(http.get).toHaveBeenCalledWith(SpotifyEndpoints.getUserEndpoint(), anything()); + expect(console.error).toHaveBeenCalledTimes(1); + expect(apiResponse).toEqual(SpotifyAPIResponse.Error); + })); + + it('should refresh auth token when expiry is within the threshold', fakeAsync(() => { + spyOn(service, 'requestAuthToken').and.returnValue(Promise.resolve()); + spyOn(service, 'logout'); + const date = new Date(); + date.setMilliseconds(date.getMilliseconds() + AppConfig.settings.auth.expiryThreshold); + const authToken = { + ...getTestAuthToken(), + expiry: date + }; + tokenProducer.next(authToken); + let apiResponse; + service.checkAuthTokenWithinExpiryThreshold().then((response) => apiResponse = response); + + flushMicrotasks(); + expect(service.requestAuthToken).toHaveBeenCalled(); + expect(service.logout).not.toHaveBeenCalled(); + expect(apiResponse).toEqual(SpotifyAPIResponse.ReAuthenticated); + })); + + it('should refresh auth token when token is already expired', fakeAsync(() => { + spyOn(service, 'requestAuthToken').and.returnValue(Promise.resolve()); + spyOn(service, 'logout'); + const date = new Date(); + date.setMilliseconds(date.getMilliseconds() - AppConfig.settings.auth.expiryThreshold); + const authToken = { + ...getTestAuthToken(), + expiry: date + }; + tokenProducer.next(authToken); + let apiResponse; + service.checkAuthTokenWithinExpiryThreshold().then((response) => apiResponse = response); + + flushMicrotasks(); + expect(service.requestAuthToken).toHaveBeenCalled(); + expect(service.logout).not.toHaveBeenCalled(); + expect(apiResponse).toEqual(SpotifyAPIResponse.ReAuthenticated); + })); + + it('should reject the promise when no expiry date is present on the token', fakeAsync(() => { + spyOn(service, 'requestAuthToken'); + spyOn(service, 'logout'); + const authToken = { + ...getTestAuthToken(), + expiry: null + }; + tokenProducer.next(authToken); + let apiError; + service.checkAuthTokenWithinExpiryThreshold().catch((err) => apiError = err); + + flushMicrotasks(); + expect(service.requestAuthToken).not.toHaveBeenCalled(); + expect(service.logout).not.toHaveBeenCalled(); + expect(apiError).not.toBeNull(); + })); + + it('should not refresh the auth token when it is not within the expiry', fakeAsync(() => { + spyOn(service, 'requestAuthToken'); + spyOn(service, 'logout'); + const date = new Date(); + date.setMilliseconds(date.getMilliseconds() + (2 * AppConfig.settings.auth.expiryThreshold)); + const authToken = { + ...getTestAuthToken(), + expiry: date + }; + tokenProducer.next(authToken); + let apiResponse; + service.checkAuthTokenWithinExpiryThreshold().then((response) => apiResponse = response); + + flushMicrotasks(); + expect(service.requestAuthToken).not.toHaveBeenCalled(); + expect(service.logout).not.toHaveBeenCalled(); + expect(apiResponse).toEqual(SpotifyAPIResponse.Success); + })); + + it('should not refresh the auth token when no auth token present', fakeAsync(() => { + spyOn(service, 'requestAuthToken'); + spyOn(service, 'logout'); + tokenProducer.next(null); + let apiResponse; + service.checkAuthTokenWithinExpiryThreshold().then((response) => apiResponse = response); + + flushMicrotasks(); + expect(service.requestAuthToken).not.toHaveBeenCalled(); + expect(service.logout).not.toHaveBeenCalled(); + expect(apiResponse).toEqual(SpotifyAPIResponse.Success); + })); +}); diff --git a/src/app/services/spotify/auth/spotify-auth.service.ts b/src/app/services/spotify/auth/spotify-auth.service.ts new file mode 100644 index 0000000..2f8dfc1 --- /dev/null +++ b/src/app/services/spotify/auth/spotify-auth.service.ts @@ -0,0 +1,302 @@ +import { HttpClient, HttpErrorResponse, HttpHeaders, HttpStatusCode } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Router } from '@angular/router'; +import { Select, Store } from '@ngxs/store'; +import { BehaviorSubject } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { AppConfig } from '../../../app.config'; +import { SetAuthToken } from '../../../core/auth/auth.actions'; +import { AuthToken } from '../../../core/auth/auth.model'; +import { AuthState } from '../../../core/auth/auth.state'; +import { SetPlayerState } from '../../../core/playback/playback.actions'; +import { PlayerState } from '../../../core/playback/playback.model'; +import { SpotifyEndpoints } from '../../../core/spotify/spotify-endpoints'; +import { AuthType, SpotifyAPIResponse } from '../../../core/types'; +import { generateCodeChallenge, generateCodeVerifier, generateRandomString } from '../../../core/util'; +import { TokenResponse } from '../../../models/token.model'; +import { StorageService } from '../../storage/storage.service'; + +@Injectable({providedIn: 'root'}) +export class SpotifyAuthService { + private static readonly STATE_KEY = 'STATE'; + private static readonly CODE_VERIFIER_KEY = 'CODE_VERIFIER'; + private static readonly STATE_LENGTH = 40; + private static readonly RESTRICTION_VIOLATED = 'restriction violated'; + + public static initialized = false; + private static clientId: string; + private static clientSecret: string = null; + private static scopes: string = null; + private static tokenUrl: string = null; + private static authType: AuthType; + private static redirectUri: string; + private static showAuthDialog = true; + + @Select(AuthState.token) private authToken$: BehaviorSubject; + private authToken: AuthToken = null; + private state: string = null; + private codeVerifier = null; + + static initialize(): boolean { + this.initialized = true; + try { + this.clientId = AppConfig.settings.auth.clientId; + if (!this.clientId) { + console.error('No Spotify API Client ID provided'); + this.initialized = false; + } + + this.tokenUrl = AppConfig.settings.auth.tokenUrl; + this.clientSecret = AppConfig.settings.auth.clientSecret; + this.scopes = AppConfig.settings.auth.scopes; + this.showAuthDialog = AppConfig.settings.auth.showDialog; + + if (AppConfig.settings.auth.forcePkce || (!this.tokenUrl && !this.clientSecret)) { + this.authType = AuthType.PKCE; + } else if (this.tokenUrl) { + this.authType = AuthType.ThirdParty; + } else { + this.authType = AuthType.Secret; + } + + if (!SpotifyEndpoints.getSpotifyApiUrl()) { + console.error('No Spotify API URL configured'); + this.initialized = false; + } else if (!SpotifyEndpoints.getSpotifyAccountsUrl()) { + console.error('No Spotify Accounts URL configured'); + this.initialized = false; + } + + if (AppConfig.settings.env.domain) { + this.redirectUri = encodeURI(AppConfig.settings.env.domain + '/callback'); + } else { + console.error('No domain set for Spotify OAuth callback URL'); + this.initialized = false; + } + } catch (error) { + console.error(`Failed to initialize spotify service: ${error}`); + this.initialized = false; + } + return this.initialized; + } + + constructor(private http: HttpClient, private store: Store, private router: Router, private storage: StorageService) { + this.setState(); + this.setCodeVerifier(); + } + + initSubscriptions(): void { + this.authToken$.subscribe((authToken) => this.authToken = authToken); + } + + requestAuthToken(code: string, isRefresh: boolean): Promise { + let headers = new HttpHeaders().set( + 'Content-Type', 'application/x-www-form-urlencoded' + ); + // Set Authorization header if needed + if (SpotifyAuthService.authType === AuthType.Secret) { + headers = headers.set( + 'Authorization', `Basic ${new Buffer(`${SpotifyAuthService.clientId}:${SpotifyAuthService.clientSecret}`).toString('base64')}` + ); + } + + const body = new URLSearchParams({ + grant_type: (!isRefresh ? 'authorization_code' : 'refresh_token'), + client_id: SpotifyAuthService.clientId, + ...(!isRefresh) && { + code, + redirect_uri: SpotifyAuthService.redirectUri, + code_verifier: this.codeVerifier + }, + ...(isRefresh) && { + refresh_token: code + } + }); + + return new Promise((resolve, reject) => { + const endpoint = SpotifyAuthService.authType === AuthType.ThirdParty ? + SpotifyAuthService.tokenUrl : SpotifyEndpoints.getTokenEndpoint(); + this.http.post(endpoint, body, {headers, observe: 'response'}) + .subscribe((response) => { + const token = response.body; + let expiry: Date; + if (SpotifyAuthService.authType === AuthType.ThirdParty && token.expiry) { + expiry = new Date(token.expiry); + } else { + expiry = new Date(); + expiry.setSeconds(expiry.getSeconds() + token.expires_in); + } + + const authToken: AuthToken = { + accessToken: token.access_token, + tokenType: token.token_type, + expiry, + scope: token.scope, + refreshToken: token.refresh_token + }; + this.store.dispatch(new SetAuthToken(authToken)) + .subscribe(() => resolve()); + }, + (error) => { + const errMsg = `Error requesting token: ${JSON.stringify(error)}`; + console.error(errMsg); + reject(errMsg); + }); + }); + } + + /** + * Checks a Spotify API error response against common error response codes + * @param res the http response + */ + checkErrorResponse(res: HttpErrorResponse): Promise { + switch (res.status) { + case HttpStatusCode.Unauthorized: + // Expired token + this.store.dispatch(new SetPlayerState(PlayerState.Refreshing)); + return this.refreshAuthToken(); + case HttpStatusCode.Forbidden: + // Bad OAuth request or restriction violated + if (this.isRestrictionViolated(res.error)) { + return Promise.resolve(SpotifyAPIResponse.Restricted); + } + break; + case HttpStatusCode.TooManyRequests: + console.error('Spotify rate limits exceeded'); + break; + default: + console.error(`Unexpected response ${res.status}: ${res.statusText}`); + break; + } + this.checkIfLogoutRequired(res.status); + return Promise.resolve(SpotifyAPIResponse.Error); + } + + /** + * Checks if the auth token is present and if its expiry value is within the threshold. If so, it refreshes the token + */ + checkAuthTokenWithinExpiryThreshold(): Promise { + const now = new Date(); + if (this.authToken) { + if (!this.authToken.expiry) { + return Promise.reject('No expiry value present on token'); + } + else if (this.authToken.expiry && this.authToken.expiry.getTime() - now.getTime() <= AppConfig.settings.auth.expiryThreshold) { + return this.refreshAuthToken(); + } + } + // Token is not within expiry threshold + return Promise.resolve(SpotifyAPIResponse.Success); + } + + getAuthorizeRequestUrl(): Promise { + const args = new URLSearchParams({ + response_type: 'code', + client_id: SpotifyAuthService.clientId, + scope: SpotifyAuthService.scopes, + redirect_uri: SpotifyAuthService.redirectUri, + state: this.getState(), + show_dialog: `${SpotifyAuthService.showAuthDialog}` + }); + if (SpotifyAuthService.authType === AuthType.PKCE) { + return generateCodeChallenge(this.getCodeVerifier()).then((codeChallenge) => { + args.set('code_challenge_method', 'S256'); + args.set('code_challenge', codeChallenge); + return `${SpotifyEndpoints.getAuthorizeEndpoint()}?${args}`; + }); + } else { + return Promise.resolve(`${SpotifyEndpoints.getAuthorizeEndpoint()}?${args}`); + } + } + + getAuthHeaders(): HttpHeaders { + if (this.authToken) { + return new HttpHeaders({ + Authorization: `${this.authToken.tokenType} ${this.authToken.accessToken}` + }); + } + console.error('No auth token present'); + return null; + } + + compareState(state: string): boolean { + return !!this.state && this.state === state; + } + + logout(): void { + this.store.dispatch(new SetAuthToken(null)); + this.state = null; + this.codeVerifier = null; + this.authToken = null; + this.storage.remove(SpotifyAuthService.STATE_KEY); + this.storage.removeAuthToken(); + this.router.navigateByUrl('/login'); + } + + private refreshAuthToken(): Promise { + if (this.authToken && this.authToken.refreshToken) { + return this.requestAuthToken(this.authToken.refreshToken, true) + .then(() => { + return SpotifyAPIResponse.ReAuthenticated; + }) + .catch((reason) => { + console.error(`Spotify request failed to reauthenticate after token expiry: ${reason}`); + this.logout(); + return SpotifyAPIResponse.Error; + }); + } + return Promise.reject('Refresh token not present'); + } + + private checkIfLogoutRequired(responseStatus: number): void { + this.isAuthTokenValid().then(isValid => { + if (!isValid) { + console.error(`Unable to authenticate following ${responseStatus} response`); + this.logout(); + } + }); + } + + private isAuthTokenValid(): Promise { + if (this.authToken) { + return this.http.get(SpotifyEndpoints.getUserEndpoint(), {headers: this.getAuthHeaders(), observe: 'response'}) + .pipe(map(res => res.status === HttpStatusCode.Ok)) + .toPromise(); + } + return Promise.resolve(false); + } + + private isRestrictionViolated(message: string): boolean { + return message && message.toLowerCase().includes(SpotifyAuthService.RESTRICTION_VIOLATED); + } + + private getState(): string { + if (!this.state) { + this.setState(); + } + return this.state; + } + + private setState(): void { + this.state = this.storage.get(SpotifyAuthService.STATE_KEY); + if (this.state === null) { + this.state = generateRandomString(SpotifyAuthService.STATE_LENGTH); + this.storage.set(SpotifyAuthService.STATE_KEY, this.state); + } + } + + private getCodeVerifier(): string { + if (!this.codeVerifier) { + this.setCodeVerifier(); + } + return this.codeVerifier; + } + + private setCodeVerifier(): void { + this.codeVerifier = this.storage.get(SpotifyAuthService.CODE_VERIFIER_KEY); + if (!this.codeVerifier) { + this.codeVerifier = generateCodeVerifier(43, 128); + this.storage.set(SpotifyAuthService.CODE_VERIFIER_KEY, this.codeVerifier); + } + } +} diff --git a/src/app/services/spotify/controls/spotify-controls.service.spec.ts b/src/app/services/spotify/controls/spotify-controls.service.spec.ts new file mode 100644 index 0000000..2ca790a --- /dev/null +++ b/src/app/services/spotify/controls/spotify-controls.service.spec.ts @@ -0,0 +1,483 @@ +import { HttpClient, HttpHeaders, HttpParams, HttpStatusCode } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { fakeAsync, TestBed } from '@angular/core/testing'; +import { expect } from '@angular/flex-layout/_private-utils/testing'; +import { NgxsModule, Store } from '@ngxs/store'; +import { MockProvider } from 'ng-mocks'; +import { BehaviorSubject, of } from 'rxjs'; +import { AppConfig } from '../../../app.config'; +import { + ChangeDevice, + ChangeDeviceVolume, ChangePlaylist, + ChangeRepeatState, SetAvailableDevices, SetLiked, + SetPlaying, + SetProgress, + SetShuffle +} from '../../../core/playback/playback.actions'; +import { DeviceModel, TrackModel } from '../../../core/playback/playback.model'; +import { SpotifyEndpoints } from '../../../core/spotify/spotify-endpoints'; +import { NgxsSelectorMock } from '../../../core/testing/ngxs-selector-mock'; +import { getTestAppConfig, getTestDeviceResponse, getTestPlaylistResponse, getTestTrackResponse } from '../../../core/testing/test-responses'; +import { generateResponse } from '../../../core/testing/test-util'; +import { parseDevice, parsePlaylist, parseTrack } from '../../../core/util'; +import { DeviceResponse, MultipleDevicesResponse } from '../../../models/device.model'; +import { PlaylistResponse } from '../../../models/playlist.model'; +import { SpotifyAuthService } from '../auth/spotify-auth.service'; +import { SpotifyControlsService } from './spotify-controls.service'; + +describe('SpotifyControlsService', () => { + const mockSelectors = new NgxsSelectorMock(); + let service: SpotifyControlsService; + let auth: SpotifyAuthService; + let http: HttpClient; + let store: Store; + + let trackProducer: BehaviorSubject; + let isPlayingProducer: BehaviorSubject; + let isShuffleProducer: BehaviorSubject; + let progressProducer: BehaviorSubject; + let durationProducer: BehaviorSubject; + let isLikedProducer: BehaviorSubject; + + beforeEach(() => { + AppConfig.settings = getTestAppConfig(); + + TestBed.configureTestingModule({ + imports: [ + NgxsModule.forRoot([], {developmentMode: true}), + HttpClientTestingModule + ], + providers: [ + SpotifyControlsService, + MockProvider(SpotifyAuthService), + MockProvider(HttpClient), + MockProvider(Store) + ] + }); + service = TestBed.inject(SpotifyControlsService); + auth = TestBed.inject(SpotifyAuthService); + http = TestBed.inject(HttpClient); + store = TestBed.inject(Store); + + trackProducer = mockSelectors.defineNgxsSelector(service, 'track$', parseTrack(getTestTrackResponse())); + isPlayingProducer = mockSelectors.defineNgxsSelector(service, 'isPlaying$', true); + isShuffleProducer = mockSelectors.defineNgxsSelector(service, 'isShuffle$', true); + progressProducer = mockSelectors.defineNgxsSelector(service, 'progress$', 10); + durationProducer = mockSelectors.defineNgxsSelector(service, 'duration$', 100); + isLikedProducer = mockSelectors.defineNgxsSelector(service, 'isLiked$', true); + + service.initSubscriptions(); + store.dispatch = jasmine.createSpy().and.returnValue(of(null)); + auth.getAuthHeaders = jasmine.createSpy().and.returnValue(new HttpHeaders({ + Authorization: 'test-token' + })); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should set track position when valid', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.NoContent); + http.put = jasmine.createSpy().and.returnValue(of(response)); + service.setTrackPosition(50); + expect(http.put).toHaveBeenCalledOnceWith( + SpotifyEndpoints.getSeekEndpoint(), + {}, + { + headers: jasmine.any(HttpHeaders), + params: jasmine.any(HttpParams), + observe: 'response', + responseType: 'text' + }); + const spyParams = (http.put as jasmine.Spy).calls.mostRecent().args[2].params as HttpParams; + expect(spyParams.keys().length).toEqual(1); + expect(spyParams.get('position_ms')).toEqual('50'); + expect(store.dispatch).toHaveBeenCalledWith(new SetProgress(50)); + })); + + it('should set track position to duration when greater than', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.NoContent); + http.put = jasmine.createSpy().and.returnValue(of(response)); + durationProducer.next(100); + service.setTrackPosition(101); + expect(store.dispatch).toHaveBeenCalledWith(new SetProgress(100)); + })); + + it('should set track position to 0 when negative', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.NoContent); + http.put = jasmine.createSpy().and.returnValue(of(response)); + service.setTrackPosition(-1); + expect(store.dispatch).toHaveBeenCalledWith(new SetProgress(0)); + })); + + it('should send play request when isPlaying', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.NoContent); + http.put = jasmine.createSpy().and.returnValue(of(response)); + service.setPlaying(true); + expect(http.put).toHaveBeenCalledOnceWith( + SpotifyEndpoints.getPlayEndpoint(), + {}, + { headers: jasmine.any(HttpHeaders), observe: 'response', responseType: 'text' } + ); + expect(store.dispatch).toHaveBeenCalledWith(new SetPlaying(true)); + })); + + it('should send pause request when not isPlaying', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.NoContent); + http.put = jasmine.createSpy().and.returnValue(of(response)); + service.setPlaying(false); + expect(http.put).toHaveBeenCalledOnceWith( + SpotifyEndpoints.getPauseEndpoint(), + {}, + { headers: jasmine.any(HttpHeaders), observe: 'response', responseType: 'text' } + ); + expect(store.dispatch).toHaveBeenCalledWith(new SetPlaying(false)); + })); + + it('should toggle playing off', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.NoContent); + http.put = jasmine.createSpy().and.returnValue(of(response)); + isPlayingProducer.next(true); + service.togglePlaying(); + expect(store.dispatch).toHaveBeenCalledWith(new SetPlaying(false)); + })); + + it('should toggle playing on', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.NoContent); + http.put = jasmine.createSpy().and.returnValue(of(response)); + isPlayingProducer.next(false); + service.togglePlaying(); + expect(store.dispatch).toHaveBeenCalledWith(new SetPlaying(true)); + })); + + it('should send skip previous request when within threshold and no disallows', fakeAsync(() => { + spyOn(service, 'setTrackPosition'); + const response = generateResponse(null, HttpStatusCode.NoContent); + http.post = jasmine.createSpy().and.returnValue(of(response)); + progressProducer.next(2999); + durationProducer.next(6001); + service.skipPrevious(false, false); + expect(http.post).toHaveBeenCalledOnceWith( + SpotifyEndpoints.getPreviousEndpoint(), + {}, + { headers: jasmine.any(HttpHeaders), observe: 'response', responseType: 'text' } + ); + expect(service.setTrackPosition).not.toHaveBeenCalled(); + })); + + it('should set track position to 0 when not within threshold and no disallows', fakeAsync(() => { + spyOn(service, 'setTrackPosition'); + progressProducer.next(3001); + durationProducer.next(6001); + service.skipPrevious(false, false); + expect(service.setTrackPosition).toHaveBeenCalledOnceWith(0); + expect(http.post).not.toHaveBeenCalled(); + })); + + it('should set track position to 0 when within threshold and skip previous disallowed', fakeAsync(() => { + spyOn(service, 'setTrackPosition'); + progressProducer.next(2999); + durationProducer.next(6001); + service.skipPrevious(true, false); + expect(service.setTrackPosition).toHaveBeenCalledOnceWith(0); + expect(http.post).not.toHaveBeenCalled(); + })); + + it('should send skip previous request when not within threshold and seek disallowed', fakeAsync(() => { + spyOn(service, 'setTrackPosition'); + const response = generateResponse(null, HttpStatusCode.NoContent); + http.post = jasmine.createSpy().and.returnValue(of(response)); + progressProducer.next(3001); + durationProducer.next(6001); + service.skipPrevious(false, true); + expect(http.post).toHaveBeenCalledOnceWith( + SpotifyEndpoints.getPreviousEndpoint(), + {}, + { headers: jasmine.any(HttpHeaders), observe: 'response', responseType: 'text' } + ); + expect(service.setTrackPosition).not.toHaveBeenCalled(); + })); + + it('should send skip previous request when duration is less than double the threshold and no disallows', fakeAsync(() => { + spyOn(service, 'setTrackPosition'); + const response = generateResponse(null, HttpStatusCode.NoContent); + http.post = jasmine.createSpy().and.returnValue(of(response)); + progressProducer.next(3001); + durationProducer.next(5999); + service.skipPrevious(false, false); + expect(http.post).toHaveBeenCalledOnceWith( + SpotifyEndpoints.getPreviousEndpoint(), + {}, + { headers: jasmine.any(HttpHeaders), observe: 'response', responseType: 'text' } + ); + expect(service.setTrackPosition).not.toHaveBeenCalled(); + })); + + it('should not skip previous or set track position when both disallowed', fakeAsync(() => { + spyOn(service, 'setTrackPosition'); + service.skipPrevious(true, true); + expect(http.post).not.toHaveBeenCalled(); + expect(service.setTrackPosition).not.toHaveBeenCalled(); + })); + + it('should send skip next request', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.NoContent); + http.post = jasmine.createSpy().and.returnValue(of(response)); + service.skipNext(); + expect(http.post).toHaveBeenCalledOnceWith( + SpotifyEndpoints.getNextEndpoint(), + {}, + { headers: jasmine.any(HttpHeaders), observe: 'response', responseType: 'text' } + ); + })); + + it('should send shuffle on request when isShuffle', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.NoContent); + http.put = jasmine.createSpy().and.returnValue(of(response)); + service.setShuffle(true); + expect(http.put).toHaveBeenCalledOnceWith( + SpotifyEndpoints.getShuffleEndpoint(), + {}, + { + headers: jasmine.any(HttpHeaders), + params: jasmine.any(HttpParams), + observe: 'response', + responseType: 'text' + }); + const spyParams = (http.put as jasmine.Spy).calls.mostRecent().args[2].params as HttpParams; + expect(spyParams.keys().length).toEqual(1); + expect(spyParams.get('state')).toEqual('true'); + expect(store.dispatch).toHaveBeenCalledWith(new SetShuffle(true)); + })); + + it('should send shuffle off request when not isShuffle', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.NoContent); + http.put = jasmine.createSpy().and.returnValue(of(response)); + service.setShuffle(false); + expect(http.put).toHaveBeenCalledOnceWith( + SpotifyEndpoints.getShuffleEndpoint(), + {}, + { + headers: jasmine.any(HttpHeaders), + params: jasmine.any(HttpParams), + observe: 'response', + responseType: 'text' + }); + const spyParams = (http.put as jasmine.Spy).calls.mostRecent().args[2].params as HttpParams; + expect(spyParams.keys().length).toEqual(1); + expect(spyParams.get('state')).toEqual('false'); + expect(store.dispatch).toHaveBeenCalledWith(new SetShuffle(false)); + })); + + it('should toggle shuffle off', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.NoContent); + http.put = jasmine.createSpy().and.returnValue(of(response)); + isShuffleProducer.next(true); + service.toggleShuffle(); + expect(store.dispatch).toHaveBeenCalledWith(new SetShuffle(false)); + })); + + it('should toggle shuffle on', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.NoContent); + http.put = jasmine.createSpy().and.returnValue(of(response)); + isShuffleProducer.next(false); + service.toggleShuffle(); + expect(store.dispatch).toHaveBeenCalledWith(new SetShuffle(true)); + })); + + it('should send volume request', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.NoContent); + http.put = jasmine.createSpy().and.returnValue(of(response)); + service.setVolume(50); + expect(http.put).toHaveBeenCalledOnceWith( + SpotifyEndpoints.getVolumeEndpoint(), + {}, + { + headers: jasmine.any(HttpHeaders), + params: jasmine.any(HttpParams), + observe: 'response' + }); + const spyParams = (http.put as jasmine.Spy).calls.mostRecent().args[2].params as HttpParams; + expect(spyParams.keys().length).toEqual(1); + expect(spyParams.get('volume_percent')).toEqual('50'); + expect(store.dispatch).toHaveBeenCalledWith(new ChangeDeviceVolume(50)); + })); + + it('should set volume to 100 when greater', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.NoContent); + http.put = jasmine.createSpy().and.returnValue(of(response)); + service.setVolume(101); + expect(store.dispatch).toHaveBeenCalledWith(new ChangeDeviceVolume(100)); + })); + + it('should set volume to 0 when negative', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.NoContent); + http.put = jasmine.createSpy().and.returnValue(of(response)); + service.setVolume(-1); + expect(store.dispatch).toHaveBeenCalledWith(new ChangeDeviceVolume(0)); + })); + + it('should send repeat state request', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.NoContent); + http.put = jasmine.createSpy().and.returnValue(of(response)); + service.setRepeatState('context'); + expect(http.put).toHaveBeenCalledOnceWith( + SpotifyEndpoints.getRepeatEndpoint(), + {}, + { + headers: jasmine.any(HttpHeaders), + params: jasmine.any(HttpParams), + observe: 'response', + responseType: 'text' + }); + const spyParams = (http.put as jasmine.Spy).calls.mostRecent().args[2].params as HttpParams; + expect(spyParams.keys().length).toEqual(1); + expect(spyParams.get('state')).toEqual('context'); + expect(store.dispatch).toHaveBeenCalledWith(new ChangeRepeatState('context')); + })); + + it('should send isTrackSaved request', fakeAsync(() => { + const response = generateResponse([true], HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + service.isTrackSaved('test-id'); + expect(http.get).toHaveBeenCalledOnceWith( + SpotifyEndpoints.getCheckSavedEndpoint(), + { + headers: jasmine.any(HttpHeaders), + params: jasmine.any(HttpParams), + observe: 'response' + }); + const spyParams = (http.get as jasmine.Spy).calls.mostRecent().args[1].params as HttpParams; + expect(spyParams.keys().length).toEqual(1); + expect(spyParams.get('ids')).toEqual('test-id'); + expect(store.dispatch).toHaveBeenCalledWith(new SetLiked(true)); + })); + + it('should send add save track request', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.Ok); + http.put = jasmine.createSpy().and.returnValue(of(response)); + service.setSavedTrack('test-id', true); + expect(http.put).toHaveBeenCalledOnceWith( + SpotifyEndpoints.getSavedTracksEndpoint(), + {}, + { + headers: jasmine.any(HttpHeaders), + params: jasmine.any(HttpParams), + observe: 'response' + }); + const spyParams = (http.put as jasmine.Spy).calls.mostRecent().args[2].params as HttpParams; + expect(spyParams.keys().length).toEqual(1); + expect(spyParams.get('ids')).toEqual('test-id'); + expect(store.dispatch).toHaveBeenCalledWith(new SetLiked(true)); + })); + + it('should send remove save track request', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.Ok); + http.delete = jasmine.createSpy().and.returnValue(of(response)); + service.setSavedTrack('test-id', false); + expect(http.delete).toHaveBeenCalledOnceWith( + SpotifyEndpoints.getSavedTracksEndpoint(), + { + headers: jasmine.any(HttpHeaders), + params: jasmine.any(HttpParams), + observe: 'response' + }); + const spyParams = (http.delete as jasmine.Spy).calls.mostRecent().args[1].params as HttpParams; + expect(spyParams.keys().length).toEqual(1); + expect(spyParams.get('ids')).toEqual('test-id'); + expect(store.dispatch).toHaveBeenCalledWith(new SetLiked(false)); + })); + + it('should toggle liked off for current track', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.Ok); + http.delete = jasmine.createSpy().and.returnValue(of(response)); + isLikedProducer.next(true); + service.toggleLiked(); + expect(http.delete).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new SetLiked(false)); + })); + + it('should toggle liked on for current track', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.Ok); + http.put = jasmine.createSpy().and.returnValue(of(response)); + isLikedProducer.next(false); + service.toggleLiked(); + expect(http.put).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith(new SetLiked(true)); + })); + + it('should send get playlist request', () => { + const playlistResponse = { + ...getTestPlaylistResponse(), + id: 'playlist-new-id' + }; + const response = generateResponse(playlistResponse, HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + service.setPlaylist('playlist-new-id'); + expect(http.get).toHaveBeenCalledOnceWith( + `${SpotifyEndpoints.getPlaylistsEndpoint()}/playlist-new-id`, + { headers: jasmine.any(HttpHeaders), observe: 'response' } + ); + expect(store.dispatch).toHaveBeenCalledWith(new ChangePlaylist(parsePlaylist(playlistResponse))); + }); + + it('should set available devices', fakeAsync(() => { + const device2: DeviceResponse = { + ...getTestDeviceResponse(), + id: 'test-device-2' + }; + const devicesResponse: MultipleDevicesResponse = { + devices: [ + getTestDeviceResponse(), + device2 + ] + }; + const response = generateResponse(devicesResponse, HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + service.fetchAvailableDevices(); + expect(http.get).toHaveBeenCalledOnceWith( + SpotifyEndpoints.getDevicesEndpoint(), + { headers: jasmine.any(HttpHeaders), observe: 'response' } + ); + expect(store.dispatch).toHaveBeenCalledWith(new SetAvailableDevices([parseDevice(getTestDeviceResponse()), parseDevice(device2)])); + })); + + it('should send set device playing request', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.NoContent); + http.put = jasmine.createSpy().and.returnValue(of(response)); + const device: DeviceModel = { + ...parseDevice(getTestDeviceResponse()), + id: 'new-device' + }; + service.setDevice(device, true); + expect(http.put).toHaveBeenCalledOnceWith( + SpotifyEndpoints.getPlaybackEndpoint(), + { + device_ids: ['new-device'], + play: true + }, + { headers: jasmine.any(HttpHeaders), observe: 'response' } + ); + expect(store.dispatch).toHaveBeenCalledWith(new ChangeDevice(device)); + })); + + it('should send set device not playing request', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.NoContent); + http.put = jasmine.createSpy().and.returnValue(of(response)); + const device: DeviceModel = { + ...parseDevice(getTestDeviceResponse()), + id: 'new-device' + }; + service.setDevice(device, false); + expect(http.put).toHaveBeenCalledOnceWith( + SpotifyEndpoints.getPlaybackEndpoint(), + { + device_ids: ['new-device'], + play: false + }, + { headers: jasmine.any(HttpHeaders), observe: 'response' } + ); + expect(store.dispatch).toHaveBeenCalledWith(new ChangeDevice(device)); + })); +}); diff --git a/src/app/services/spotify/controls/spotify-controls.service.ts b/src/app/services/spotify/controls/spotify-controls.service.ts new file mode 100644 index 0000000..e102ff4 --- /dev/null +++ b/src/app/services/spotify/controls/spotify-controls.service.ts @@ -0,0 +1,267 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Select, Store } from '@ngxs/store'; +import { BehaviorSubject } from 'rxjs'; +import { + ChangeDevice, + ChangeDeviceVolume, ChangePlaylist, + ChangeRepeatState, SetAvailableDevices, + SetLiked, + SetPlaying, + SetProgress, + SetShuffle +} from '../../../core/playback/playback.actions'; +import { DeviceModel, TrackModel } from '../../../core/playback/playback.model'; +import { PlaybackState } from '../../../core/playback/playback.state'; +import { SpotifyEndpoints } from '../../../core/spotify/spotify-endpoints'; +import { SpotifyAPIResponse } from '../../../core/types'; +import { checkResponse, parseDevice, parsePlaylist } from '../../../core/util'; +import { MultipleDevicesResponse } from '../../../models/device.model'; +import { PlaylistResponse } from '../../../models/playlist.model'; +import { SpotifyAuthService } from '../auth/spotify-auth.service'; + +@Injectable({providedIn: 'root'}) +export class SpotifyControlsService { + private static readonly SKIP_PREVIOUS_THRESHOLD = 3000; // ms + + @Select(PlaybackState.track) private track$: BehaviorSubject; + private track: TrackModel = null; + + @Select(PlaybackState.isPlaying) private isPlaying$: BehaviorSubject; + private isPlaying = false; + + @Select(PlaybackState.isShuffle) private isShuffle$: BehaviorSubject; + private isShuffle = false; + + @Select(PlaybackState.progress) private progress$: BehaviorSubject; + private progress = 0; + + @Select(PlaybackState.duration) private duration$: BehaviorSubject; + private duration = 0; + + @Select(PlaybackState.isLiked) private isLiked$: BehaviorSubject; + private isLiked = false; + + constructor(private http: HttpClient, private store: Store, private auth: SpotifyAuthService) {} + + initSubscriptions(): void { + this.track$.subscribe((track) => this.track = track); + this.isPlaying$.subscribe((isPlaying) => this.isPlaying = isPlaying); + this.isShuffle$.subscribe((isShuffle) => this.isShuffle = isShuffle); + this.progress$.subscribe((progress) => this.progress = progress); + this.duration$.subscribe((duration) => this.duration = duration); + this.isLiked$.subscribe((isLiked) => this.isLiked = isLiked); + } + + setTrackPosition(position: number): void { + if (position > this.duration) { + position = this.duration; + } + else if (position < 0) { + position = 0; + } + + let requestParams = new HttpParams(); + requestParams = requestParams.append('position_ms', position.toString()); + + this.http.put(SpotifyEndpoints.getSeekEndpoint(), {}, { + headers: this.auth.getAuthHeaders(), + params: requestParams, + observe: 'response', + responseType: 'text' + }).subscribe((res) => { + const apiResponse = checkResponse(res, false); + if (apiResponse === SpotifyAPIResponse.Success) { + this.store.dispatch(new SetProgress(position)); + } + }); + } + + setPlaying(isPlaying: boolean): void { + const endpoint = isPlaying ? SpotifyEndpoints.getPlayEndpoint() : SpotifyEndpoints.getPauseEndpoint(); + // TODO: this has optional parameters for JSON body + this.http.put(endpoint, {}, { + headers: this.auth.getAuthHeaders(), + observe: 'response', + responseType: 'text' + }).subscribe((res) => { + const apiResponse = checkResponse(res, false); + if (apiResponse === SpotifyAPIResponse.Success) { + this.store.dispatch(new SetPlaying(isPlaying)); + } + }); + } + + togglePlaying(): void { + this.setPlaying(!this.isPlaying); + } + + skipPrevious(skipPrevDisallowed: boolean, seekDisallowed: boolean): void { + // Check if we should skip to previous track or start of current + if (!seekDisallowed && ( + (this.progress > SpotifyControlsService.SKIP_PREVIOUS_THRESHOLD + && !((SpotifyControlsService.SKIP_PREVIOUS_THRESHOLD * 2) >= this.duration)) || skipPrevDisallowed)) { + this.setTrackPosition(0); + } else if (!skipPrevDisallowed) { + this.http.post(SpotifyEndpoints.getPreviousEndpoint(), {}, { + headers: this.auth.getAuthHeaders(), + observe: 'response', + responseType: 'text' + }).subscribe(); + } + } + + skipNext(): void { + this.http.post(SpotifyEndpoints.getNextEndpoint(), {}, { + headers: this.auth.getAuthHeaders(), + observe: 'response', + responseType: 'text' + }).subscribe(); + } + + setShuffle(isShuffle: boolean): void { + let requestParams = new HttpParams(); + requestParams = requestParams.append('state', (isShuffle ? 'true' : 'false')); + + this.http.put(SpotifyEndpoints.getShuffleEndpoint(), {}, { + headers: this.auth.getAuthHeaders(), + params: requestParams, + observe: 'response', + responseType: 'text' + }).subscribe((res) => { + const apiResponse = checkResponse(res, false); + if (apiResponse === SpotifyAPIResponse.Success) { + this.store.dispatch(new SetShuffle(isShuffle)); + } + }); + } + + toggleShuffle(): void { + this.setShuffle(!this.isShuffle); + } + + setVolume(volume: number): void { + if (volume > 100) { + volume = 100; + } + else if (volume < 0) { + volume = 0; + } + + let requestParams = new HttpParams(); + requestParams = requestParams.append('volume_percent', volume.toString()); + + this.http.put(SpotifyEndpoints.getVolumeEndpoint(), {}, { + headers: this.auth.getAuthHeaders(), + params: requestParams, + observe: 'response' + }).subscribe((res) => { + const apiResponse = checkResponse(res, false); + if (apiResponse === SpotifyAPIResponse.Success) { + this.store.dispatch(new ChangeDeviceVolume(volume)); + } + }); + } + + setRepeatState(repeatState: string): void { + let requestParams = new HttpParams(); + requestParams = requestParams.append('state', repeatState); + + this.http.put(SpotifyEndpoints.getRepeatEndpoint(), {}, { + headers: this.auth.getAuthHeaders(), + params: requestParams, + observe: 'response', + responseType: 'text' + }).subscribe((res) => { + const apiResponse = checkResponse(res, false); + if (apiResponse === SpotifyAPIResponse.Success) { + this.store.dispatch(new ChangeRepeatState(repeatState)); + } + }); + } + + isTrackSaved(id: string): void { + let requestParams = new HttpParams(); + requestParams = requestParams.append('ids', id); + + this.http.get(SpotifyEndpoints.getCheckSavedEndpoint(), { + headers: this.auth.getAuthHeaders(), + params: requestParams, + observe: 'response' + }).subscribe((res) => { + const apiResponse = checkResponse(res, true); + if (apiResponse === SpotifyAPIResponse.Success) { + if (res.body && res.body.length > 0) { + this.store.dispatch(new SetLiked(res.body[0])); + } + } + }); + } + + setSavedTrack(id: string, isSaved: boolean): void { + let requestParams = new HttpParams(); + requestParams = requestParams.append('ids', id); + + const savedEndpoint = isSaved ? + this.http.put(SpotifyEndpoints.getSavedTracksEndpoint(), {}, { + headers: this.auth.getAuthHeaders(), + params: requestParams, + observe: 'response' + }) : + this.http.delete(SpotifyEndpoints.getSavedTracksEndpoint(), { + headers: this.auth.getAuthHeaders(), + params: requestParams, + observe: 'response' + }); + + savedEndpoint.subscribe((res) => { + const apiResponse = checkResponse(res, true); + if (apiResponse === SpotifyAPIResponse.Success) { + this.store.dispatch(new SetLiked(isSaved)); + } + }); + } + + toggleLiked(): void { + this.setSavedTrack(this.track.id, !this.isLiked); + } + + setPlaylist(id: string): void { + if (id === null) { + this.store.dispatch(new ChangePlaylist(null)); + } else { + this.http.get(`${SpotifyEndpoints.getPlaylistsEndpoint()}/${id}`, + {headers: this.auth.getAuthHeaders(), observe: 'response'}) + .subscribe((res) => { + const apiResponse = checkResponse(res, true); + if (apiResponse === SpotifyAPIResponse.Success) { + this.store.dispatch(new ChangePlaylist(parsePlaylist(res.body))); + } + }); + } + } + + fetchAvailableDevices(): void { + this.http.get(SpotifyEndpoints.getDevicesEndpoint(), {headers: this.auth.getAuthHeaders(), observe: 'response'}) + .subscribe((res) => { + const apiResponse = checkResponse(res, true); + if (apiResponse === SpotifyAPIResponse.Success) { + const devices = res.body.devices.map(device => parseDevice(device)); + this.store.dispatch(new SetAvailableDevices(devices)); + } + }); + } + + setDevice(device: DeviceModel, isPlaying: boolean): void { + this.http.put(SpotifyEndpoints.getPlaybackEndpoint(), { + device_ids: [device.id], + play: isPlaying + }, {headers: this.auth.getAuthHeaders(), observe: 'response'}) + .subscribe((res) => { + const apiResponse = checkResponse(res, false); + if (apiResponse === SpotifyAPIResponse.Success) { + this.store.dispatch(new ChangeDevice(device)); + } + }); + } +} diff --git a/src/app/services/spotify/interceptor/spotify.interceptor.spec.ts b/src/app/services/spotify/interceptor/spotify.interceptor.spec.ts new file mode 100644 index 0000000..9c432c5 --- /dev/null +++ b/src/app/services/spotify/interceptor/spotify.interceptor.spec.ts @@ -0,0 +1,270 @@ +import { + HttpClient, + HttpErrorResponse, + HttpEvent, + HttpHandler, HttpHeaders, + HttpRequest, + HttpStatusCode +} from '@angular/common/http'; +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { expect } from '@angular/flex-layout/_private-utils/testing'; +import { MockProvider } from 'ng-mocks'; +import { Observable, throwError } from 'rxjs'; +import { isEmpty } from 'rxjs/operators'; +import { AppConfig } from '../../../app.config'; +import { SpotifyEndpoints } from '../../../core/spotify/spotify-endpoints'; +import { getTestAppConfig } from '../../../core/testing/test-responses'; +import { SpotifyAPIResponse } from '../../../core/types'; +import { SpotifyAuthService } from '../auth/spotify-auth.service'; +import { SpotifyInterceptor } from './spotify.interceptor'; +import Spy = jasmine.Spy; + +const TEST_API_URL = 'spotify-url'; +const AUTH_REQ = new HttpRequest('GET', TEST_API_URL + '/test'); + +describe('SpotifyInterceptor', () => { + let interceptor: SpotifyInterceptor; + let auth: SpotifyAuthService; + let httpMock: HttpTestingController; + let http: HttpClient; + let consoleErrorSpy: Spy; + + beforeEach(() => { + AppConfig.settings = getTestAppConfig(); + AppConfig.settings.env.spotifyApiUrl = TEST_API_URL; + SpotifyAuthService.initialize(); + + TestBed.configureTestingModule({ + imports: [ + HttpClientTestingModule + ], + providers: [ + SpotifyInterceptor, + MockProvider(SpotifyAuthService) + ] + }); + interceptor = TestBed.inject(SpotifyInterceptor); + auth = TestBed.inject(SpotifyAuthService); + httpMock = TestBed.inject(HttpTestingController); + http = TestBed.inject(HttpClient); + + auth.getAuthHeaders = jasmine.createSpy().and.returnValue(new HttpHeaders({ + Authorization: 'test-token' + })); + auth.checkAuthTokenWithinExpiryThreshold = jasmine.createSpy().and.returnValue(Promise.resolve(SpotifyAPIResponse.Success)); + consoleErrorSpy = spyOn(console, 'error'); + }); + + it('should call through with the HttpHandler if the SpotifyEndpoints are not initialized', done => { + AppConfig.settings = null; + const next = createNextHandler(); + const handleSpy = jasmine.createSpy().and.returnValues( + new Observable>(subscriber => subscriber.complete()) + ); + next.handle = handleSpy; + interceptor.intercept(AUTH_REQ, next).subscribe({ + complete: () => { + expect(next.handle).toHaveBeenCalledTimes(1); + expect(auth.checkAuthTokenWithinExpiryThreshold).not.toHaveBeenCalled(); + const request: HttpRequest = handleSpy.calls.all()[0].args[0]; + expect(request.url).toEqual(AUTH_REQ.url); + done(); + } + }); + }); + + it('should throw an error if no auth token is present and auth is required', done => { + auth.getAuthHeaders = jasmine.createSpy().and.returnValue(null); + + interceptor.intercept(AUTH_REQ, createNextHandler()).subscribe({ + error: err => { + expect(err).not.toBeNull(); + done(); + } + }); + }); + + it('should add auth headers to request if auth is required', done => { + const next = createNextHandler(); + const handleSpy = spyOn(next, 'handle').and.callThrough(); + + interceptor.intercept(AUTH_REQ, next).subscribe({ + complete: () => { + const actualReq = handleSpy.calls.first().args[0]; + expect(actualReq.headers.has('Authorization')).toBeTrue(); + done(); + } + }); + }); + + it('should throw an error if handled request returns unknown error', done => { + interceptor.intercept(AUTH_REQ, createNextHandlerReturnsError(new Error('test-error'))).subscribe({ + error: err => { + expect(console.error).toHaveBeenCalled(); + const actualErrMsg = consoleErrorSpy.calls.first().args[0]; + expect(actualErrMsg.includes('error type')).toBeTrue(); + expect(err).not.toBeNull(); + done(); + } + }); + }); + + it('should throw an error if handled request returns HttpErrorResponse but is not reaunthenticated or restricted', done => { + auth.checkErrorResponse = jasmine.createSpy().and.returnValue(Promise.resolve(SpotifyAPIResponse.Error)); + const next = createNextHandlerReturnsError(new HttpErrorResponse({status: HttpStatusCode.Forbidden})); + + interceptor.intercept(AUTH_REQ, next).subscribe({ + error: err => { + expect(console.error).toHaveBeenCalled(); + const errMessage = consoleErrorSpy.calls.first().args[0]; + expect(errMessage.includes('error response')).toBeTrue(); + expect(err).not.toBeNull(); + done(); + } + }); + }); + + it('should return EMPTY if handled request is restricted', done => { + auth.checkErrorResponse = jasmine.createSpy().and.returnValue(Promise.resolve(SpotifyAPIResponse.Restricted)); + const next = createNextHandlerReturnsError(new HttpErrorResponse({status: HttpStatusCode.Forbidden})); + + interceptor.intercept(AUTH_REQ, next).pipe(isEmpty()).subscribe(isEmptyResponse => { + expect(isEmptyResponse).toBeTrue(); + done(); + }); + }); + + it('should handle request after reauthentication and update headers if first handled request returns HttpErrorResponse and is reaunthenticated', done => { + const next = createNextHandler(); + const handleSpy = jasmine.createSpy().and.returnValues( + throwError(new HttpErrorResponse({status: HttpStatusCode.Unauthorized})), + new Observable>(subscriber => subscriber.complete()) + ); + next.handle = handleSpy; + auth.checkErrorResponse = jasmine.createSpy().and.returnValue(Promise.resolve(SpotifyAPIResponse.ReAuthenticated)); + auth.getAuthHeaders = jasmine.createSpy().and.returnValues(new HttpHeaders({ + Authorization: 'test-token' + }), new HttpHeaders({ + Authorization: 'refresh-token' + })); + + interceptor.intercept(AUTH_REQ, next).subscribe({ + complete: () => { + expect(next.handle).toHaveBeenCalledTimes(2); + expect(auth.getAuthHeaders).toHaveBeenCalledTimes(2); + const firstRequest: HttpRequest = handleSpy.calls.all()[0].args[0]; + const secondRequest: HttpRequest = handleSpy.calls.all()[1].args[0]; + expect(firstRequest.headers.get('Authorization')).toEqual('test-token'); + expect(secondRequest.headers.get('Authorization')).toEqual('refresh-token'); + done(); + }}); + }); + + it('should handle request and update headers after new token is refreshed when within expiry threshold', done => { + const next = createNextHandler(); + const handleSpy = jasmine.createSpy().and.returnValues( + new Observable>(subscriber => subscriber.complete()) + ); + next.handle = handleSpy; + auth.checkAuthTokenWithinExpiryThreshold = jasmine.createSpy().and.returnValues(Promise.resolve(SpotifyAPIResponse.ReAuthenticated)); + auth.getAuthHeaders = jasmine.createSpy().and.returnValues(new HttpHeaders({ + Authorization: 'test-token' + }), new HttpHeaders({ + Authorization: 'refresh-token' + })); + + interceptor.intercept(AUTH_REQ, next).subscribe({ + complete: () => { + expect(next.handle).toHaveBeenCalledTimes(1); + expect(auth.getAuthHeaders).toHaveBeenCalledTimes(2); + const request: HttpRequest = handleSpy.calls.all()[0].args[0]; + expect(request.headers.get('Authorization')).toEqual('refresh-token'); + done(); + }}); + }); + + it('should not add auth headers to the request if a token endpoint', done => { + const next = createNextHandler(); + const handleSpy = jasmine.createSpy().and.returnValues( + new Observable>(subscriber => subscriber.complete()) + ); + next.handle = handleSpy; + const tokenReq = new HttpRequest('GET', TEST_API_URL + '/test' + SpotifyEndpoints.getTokenEndpoint()); + + interceptor.intercept(tokenReq, next).subscribe({ + complete: () => { + expect(next.handle).toHaveBeenCalledTimes(1); + expect(auth.getAuthHeaders).not.toHaveBeenCalled(); + const request: HttpRequest = handleSpy.calls.all()[0].args[0]; + expect(request.headers.has('Authorization')).toBeFalse(); + done(); + } + }); + }); + + it('should not add auth headers to the request if not an auth endpoint', done => { + const next = createNextHandler(); + const handleSpy = jasmine.createSpy().and.returnValues( + new Observable>(subscriber => subscriber.complete()) + ); + next.handle = handleSpy; + const nonAuthReq = new HttpRequest('GET', '/test'); + + interceptor.intercept(nonAuthReq, next).subscribe({ + complete: () => { + expect(next.handle).toHaveBeenCalledTimes(1); + expect(auth.getAuthHeaders).not.toHaveBeenCalled(); + const request: HttpRequest = handleSpy.calls.all()[0].args[0]; + expect(request.headers.has('Authorization')).toBeFalse(); + done(); + } + }); + }); + + it('should throw an error if spotify checkAuthTokenWithinExpiryThreshold returns unexpected response', done => { + const next = createNextHandler(); + next.handle = jasmine.createSpy(); + auth.checkAuthTokenWithinExpiryThreshold = jasmine.createSpy().and.returnValue(Promise.resolve(SpotifyAPIResponse.Error)); + + interceptor.intercept(AUTH_REQ, next).subscribe({ + error: (err) => { + expect(err).not.toBeNull(); + expect(next.handle).not.toHaveBeenCalled(); + done(); + } + }); + }); + + it('should throw an error if spotify checkAuthTokenWithinExpiryThreshold throws an error', done => { + const next = createNextHandler(); + next.handle = jasmine.createSpy(); + auth.checkAuthTokenWithinExpiryThreshold = jasmine.createSpy().and.returnValue(Promise.reject('test-error')); + + interceptor.intercept(AUTH_REQ, next).subscribe({ + error: (err) => { + expect(err).not.toBeNull(); + expect(next.handle).not.toHaveBeenCalled(); + done(); + } + }); + }); +}); + +function createNextHandler(): HttpHandler { + return { + handle: (_: HttpRequest) => { + return new Observable((subscriber) => { + subscriber.complete(); + }); + } + }; +} + +function createNextHandlerReturnsError(err: any): HttpHandler { + return { + handle: (_: HttpRequest) => { + return throwError(err); + } + }; +} diff --git a/src/app/services/spotify/interceptor/spotify.interceptor.ts b/src/app/services/spotify/interceptor/spotify.interceptor.ts new file mode 100644 index 0000000..10a1003 --- /dev/null +++ b/src/app/services/spotify/interceptor/spotify.interceptor.ts @@ -0,0 +1,109 @@ +import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { EMPTY, from, Observable, throwError } from 'rxjs'; +import { catchError, switchMap } from 'rxjs/operators'; +import { SpotifyEndpoints } from '../../../core/spotify/spotify-endpoints'; +import { SpotifyAPIResponse } from '../../../core/types'; +import { SpotifyAuthService } from '../auth/spotify-auth.service'; + +@Injectable() +export class SpotifyInterceptor implements HttpInterceptor { + private static readonly urlRequiresAuth = new Map(); + + constructor(private auth: SpotifyAuthService) {} + + intercept(req: HttpRequest, next: HttpHandler): Observable> { + if (!SpotifyEndpoints.isInitialized()) { + return next.handle(req); + } + + const authReq = this.generateAuthHeaders(req); + if (authReq === null) { + return throwError('No auth token present'); + } + + return from(this.checkTokenExpiryThreshold(authReq)).pipe( + switchMap((expiryReq) => { + return this.handleRequest(expiryReq, next); + }), + catchError(err => throwError(err)) + ); + } + + private handleRequest(req: HttpRequest, next: HttpHandler): Observable> { + return next.handle(req).pipe( + catchError(err => { + if (err instanceof HttpErrorResponse) { + return from(this.auth.checkErrorResponse(err)) + .pipe(switchMap((apiResponse) => { + if (apiResponse === SpotifyAPIResponse.ReAuthenticated) { + req = this.generateAuthHeaders(req); + return next.handle(req); + } + else if (apiResponse === SpotifyAPIResponse.Restricted) { + // If the response was restricted, cancel the request + return EMPTY; + } + console.error(`Unexpected error response when handling request: ${req.url}`); + return throwError(err); + })); + } + console.error(`Unexpected error type when handling request: ${req.url}`); + return throwError(err); + }) + ); + } + + private checkTokenExpiryThreshold(req: HttpRequest): Promise> { + return new Promise((resolve, reject) => { + if (this.requestContainsAuthHeaders(req)) { + this.auth.checkAuthTokenWithinExpiryThreshold() + .then((apiResponse) => { + switch (apiResponse) { + case SpotifyAPIResponse.ReAuthenticated: + resolve(this.generateAuthHeaders(req)); + break; + case SpotifyAPIResponse.Success: + resolve(req); + break; + default: + reject('Unexpected response when attempting to check auth token expiry threshold: ' + apiResponse); + } + }) + .catch((err) => { + reject(err); + }); + } else { + resolve(req); + } + }); + } + + private generateAuthHeaders(req: HttpRequest): HttpRequest { + let authReq = req; + if (this.urlRequiresAuth(req.url)) { + const authHeader = this.auth.getAuthHeaders(); + if (!authHeader) { + return null; + } + authReq = req.clone({ + headers: req.headers.set('Authorization', authHeader.get('Authorization')) + }); + } + return authReq; + } + + private urlRequiresAuth(url: string): boolean { + if (SpotifyInterceptor.urlRequiresAuth.has(url)) { + return SpotifyInterceptor.urlRequiresAuth.get(url); + } + + const requiresAuth = url.startsWith(SpotifyEndpoints.getSpotifyApiUrl()) && !url.endsWith(SpotifyEndpoints.getTokenEndpoint()); + SpotifyInterceptor.urlRequiresAuth.set(url, requiresAuth); + return requiresAuth; + } + + private requestContainsAuthHeaders(req: HttpRequest): boolean { + return req.headers.has('Authorization'); + } +} diff --git a/src/app/services/spotify/polling/spotify-polling.service.spec.ts b/src/app/services/spotify/polling/spotify-polling.service.spec.ts new file mode 100644 index 0000000..de6b579 --- /dev/null +++ b/src/app/services/spotify/polling/spotify-polling.service.spec.ts @@ -0,0 +1,353 @@ +import { HttpClient, HttpStatusCode } from '@angular/common/http'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { fakeAsync, TestBed } from '@angular/core/testing'; +import { expect } from '@angular/flex-layout/_private-utils/testing'; +import { NgxsModule, Store } from '@ngxs/store'; +import { MockProvider } from 'ng-mocks'; +import { BehaviorSubject, of } from 'rxjs'; +import { + getTestActionsResponse, + getTestAlbumResponse, getTestAppConfig, getTestDeviceResponse, getTestPlaybackResponse, + getTestPlaylistResponse, + getTestTrackResponse, +} from 'src/app/core/testing/test-responses'; +import { AppConfig } from '../../../app.config'; +import { + ChangeAlbum, + ChangeDevice, + ChangeDeviceIsActive, + ChangeDeviceVolume, + ChangePlaylist, + ChangeTrack, SetDisallows, SetPlayerState, SetPlaying, SetProgress, SetShuffle, SetSmartShuffle +} from '../../../core/playback/playback.actions'; +import { AlbumModel, DeviceModel, PlayerState, PlaylistModel, TrackModel } from '../../../core/playback/playback.model'; +import { SpotifyEndpoints } from '../../../core/spotify/spotify-endpoints'; +import { NgxsSelectorMock } from '../../../core/testing/ngxs-selector-mock'; +import { generateResponse } from '../../../core/testing/test-util'; +import { parseAlbum, parseDevice, parseDisallows, parsePlaylist, parseTrack } from '../../../core/util'; +import { CurrentPlaybackResponse } from '../../../models/current-playback.model'; +import { PREVIOUS_VOLUME, StorageService } from '../../storage/storage.service'; +import { SpotifyControlsService } from '../controls/spotify-controls.service'; +import { SpotifyPollingService } from './spotify-polling.service'; + +describe('SpotifyPollingService', () => { + const mockSelectors = new NgxsSelectorMock(); + let service: SpotifyPollingService; + let controls: SpotifyControlsService; + let http: HttpClient; + let store: Store; + let storage: StorageService; + + let trackProducer: BehaviorSubject; + let albumProducer: BehaviorSubject; + let playlistProducer: BehaviorSubject; + let deviceProducer: BehaviorSubject; + + beforeEach(() => { + AppConfig.settings = getTestAppConfig(); + + TestBed.configureTestingModule({ + imports: [ + NgxsModule.forRoot([], {developmentMode: true}), + HttpClientTestingModule + ], + providers: [ + SpotifyPollingService, + MockProvider(SpotifyControlsService), + MockProvider(HttpClient), + MockProvider(Store), + MockProvider(StorageService) + ] + }); + service = TestBed.inject(SpotifyPollingService); + controls = TestBed.inject(SpotifyControlsService); + store = TestBed.inject(Store); + http = TestBed.inject(HttpClient); + storage = TestBed.inject(StorageService); + + trackProducer = mockSelectors.defineNgxsSelector(service, 'track$', parseTrack(getTestTrackResponse())); + albumProducer = mockSelectors.defineNgxsSelector(service, 'album$', parseAlbum(getTestAlbumResponse())); + playlistProducer = mockSelectors.defineNgxsSelector(service, 'playlist$', parsePlaylist(getTestPlaylistResponse())); + deviceProducer = mockSelectors.defineNgxsSelector(service, 'device$', parseDevice(getTestDeviceResponse())); + + service.initSubscriptions(); + store.dispatch = jasmine.createSpy().and.returnValue(of(null)); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + it('should get current playback on pollCurrentPlayback', fakeAsync(() => { + const response = generateResponse(getTestPlaybackResponse(), HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + + service.pollCurrentPlayback(); + + expect(http.get).toHaveBeenCalledOnceWith( + SpotifyEndpoints.getPlaybackEndpoint(), + { + observe: 'response' + }); + })); + + it('should change track if a new track', fakeAsync(() => { + const response = generateResponse(getTestPlaybackResponse(), HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + const currentTrack = parseTrack({ + ...getTestTrackResponse(), + id: 'old-id' + }); + trackProducer.next(currentTrack); + + service.pollCurrentPlayback(); + expect(store.dispatch).toHaveBeenCalledWith(new ChangeTrack(parseTrack(getTestTrackResponse()))); + expect(controls.isTrackSaved).toHaveBeenCalledOnceWith(getTestTrackResponse().id); + })); + + it('should not change track if same track playing', fakeAsync(() => { + const response = generateResponse(getTestPlaybackResponse(), HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + + service.pollCurrentPlayback(); + expect(store.dispatch).not.toHaveBeenCalledWith(jasmine.any(ChangeTrack)); + expect(controls.isTrackSaved).not.toHaveBeenCalled(); + })); + + it('should change album if new album', fakeAsync(() => { + const response = generateResponse(getTestPlaybackResponse(), HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + const currentAlbum = parseAlbum({ + ...getTestAlbumResponse(), + id: 'old-id' + }); + albumProducer.next(currentAlbum); + + service.pollCurrentPlayback(); + expect(store.dispatch).toHaveBeenCalledWith(new ChangeAlbum(parseAlbum(getTestAlbumResponse()))); + })); + + it('should not change album if same album', fakeAsync(() => { + const response = generateResponse(getTestPlaybackResponse(), HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + + service.pollCurrentPlayback(); + expect(store.dispatch).not.toHaveBeenCalledWith(jasmine.any(ChangeAlbum)); + })); + + it('should change playlist if new playlist', fakeAsync(() => { + const currentPlaylist = { + ...parsePlaylist(getTestPlaylistResponse()), + id: 'old-id' + }; + const response = generateResponse(getTestPlaybackResponse(), HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + playlistProducer.next(currentPlaylist); + + service.pollCurrentPlayback(); + expect(controls.setPlaylist).toHaveBeenCalledWith(getTestPlaylistResponse().id); + })); + + it('should change to playback playlist if no current playlist', fakeAsync(() => { + const response = generateResponse(getTestPlaybackResponse(), HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + playlistProducer.next(null); + + service.pollCurrentPlayback(); + expect(controls.setPlaylist).toHaveBeenCalledWith(getTestPlaylistResponse().id); + })); + + it('should not change playlist if playback playlist is current playlist', fakeAsync(() => { + const response = generateResponse(getTestPlaybackResponse(), HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + + service.pollCurrentPlayback(); + expect(controls.setPlaylist).not.toHaveBeenCalled(); + })); + + it('should remove playlist if context is null and previously had playlist', fakeAsync(() => { + const playbackResponse = { + ...getTestPlaybackResponse(), + context: null + }; + const response = generateResponse(playbackResponse, HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + + service.pollCurrentPlayback(); + expect(store.dispatch).toHaveBeenCalledWith(new ChangePlaylist(null)); + })); + + it('should remove playlist if context type is null and previously had playlist', fakeAsync(() => { + const playbackResponse = { + ...getTestPlaybackResponse(), + context: { + ...getTestPlaybackResponse().context, + type: null + } + }; + const response = generateResponse(playbackResponse, HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + + service.pollCurrentPlayback(); + expect(store.dispatch).toHaveBeenCalledWith(new ChangePlaylist(null)); + })); + + it('should remove playlist if context type is not playlist and previously had playlist', fakeAsync(() => { + const playbackResponse = { + ...getTestPlaybackResponse(), + context: { + ...getTestPlaybackResponse().context, + type: 'test' + } + }; + const response = generateResponse(playbackResponse, HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + + service.pollCurrentPlayback(); + expect(store.dispatch).toHaveBeenCalledWith(new ChangePlaylist(null)); + })); + + it('should not change playlist if no playback playlist and no previous playlist', fakeAsync(() => { + const playbackResponse = { + ...getTestPlaybackResponse(), + context: { + ...getTestPlaybackResponse().context, + type: 'test' + } + }; + const response = generateResponse(playbackResponse, HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + playlistProducer.next(null); + + service.pollCurrentPlayback(); + expect(store.dispatch).not.toHaveBeenCalledWith(jasmine.any(ChangePlaylist)); + })); + + it('should save previous volume value if playback muted and not previously muted', fakeAsync(() => { + const playbackResponse = { + ...getTestPlaybackResponse(), + device: { + ...getTestDeviceResponse(), + volume_percent: 0 + } + }; + const response = generateResponse(playbackResponse, HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + deviceProducer.next(parseDevice({...getTestDeviceResponse(), volume_percent: 25})); + + service.pollCurrentPlayback(); + expect(storage.set).toHaveBeenCalledWith(PREVIOUS_VOLUME, '25'); + })); + + it('should not save previous volume value if playback muted and currently muted', fakeAsync(() => { + const playbackResponse = { + ...getTestPlaybackResponse(), + device: { + ...getTestDeviceResponse(), + volume_percent: 0 + } + }; + const response = generateResponse(playbackResponse, HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + deviceProducer.next(parseDevice({...getTestDeviceResponse(), volume_percent: 0})); + + service.pollCurrentPlayback(); + expect(storage.set).not.toHaveBeenCalledWith(PREVIOUS_VOLUME, jasmine.anything()); + })); + + it('should not save previous volume value if playback not muted', fakeAsync(() => { + const response = generateResponse(getTestPlaybackResponse(), HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + deviceProducer.next(parseDevice({...getTestDeviceResponse(), volume_percent: 0})); + + service.pollCurrentPlayback(); + expect(storage.set).not.toHaveBeenCalledWith(PREVIOUS_VOLUME, jasmine.anything()); + })); + + it('should not save previous volume value if playback device is null', fakeAsync(() => { + const playbackResponse = { + ...getTestPlaybackResponse(), + device: null + }; + const response = generateResponse(playbackResponse, HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + + service.pollCurrentPlayback(); + expect(storage.set).not.toHaveBeenCalledWith(PREVIOUS_VOLUME, jasmine.anything()); + })); + + it('should change current device if playback device differs from current device', fakeAsync(() => { + const response = generateResponse(getTestPlaybackResponse(), HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + deviceProducer.next({...parseDevice(getTestDeviceResponse()), id: 'old-id'}); + + service.pollCurrentPlayback(); + expect(store.dispatch).toHaveBeenCalledWith(new ChangeDevice(parseDevice(getTestDeviceResponse()))); + })); + + it('should not change current device if playback device is the current device', fakeAsync(() => { + const response = generateResponse(getTestPlaybackResponse(), HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + + service.pollCurrentPlayback(); + expect(store.dispatch).not.toHaveBeenCalledWith(jasmine.any(ChangeDevice)); + })); + + it('should not change current device playback device is null', fakeAsync(() => { + const playbackResponse = { + ...getTestPlaybackResponse(), + device: null + }; + const response = generateResponse(playbackResponse, HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + + service.pollCurrentPlayback(); + expect(store.dispatch).not.toHaveBeenCalledWith(jasmine.any(ChangeDevice)); + expect(store.dispatch).not.toHaveBeenCalledWith(jasmine.any(ChangeDeviceIsActive)); + expect(store.dispatch).not.toHaveBeenCalledWith(jasmine.any(ChangeDeviceVolume)); + })); + + it('should set update rest of track playback states', fakeAsync(() => { + const response = generateResponse(getTestPlaybackResponse(), HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + trackProducer.next(parseTrack(getTestTrackResponse())); + + service.pollCurrentPlayback(); + expect(store.dispatch).toHaveBeenCalledWith(new ChangeDeviceIsActive(getTestDeviceResponse().is_active)); + expect(store.dispatch).toHaveBeenCalledWith(new ChangeDeviceVolume(getTestDeviceResponse().volume_percent)); + expect(store.dispatch).toHaveBeenCalledWith(new SetProgress(getTestPlaybackResponse().progress_ms)); + expect(store.dispatch).toHaveBeenCalledWith(new SetPlaying(getTestPlaybackResponse().is_playing)); + expect(store.dispatch).toHaveBeenCalledWith(new SetShuffle(getTestPlaybackResponse().shuffle_state)); + expect(store.dispatch).toHaveBeenCalledWith(new SetSmartShuffle(getTestPlaybackResponse().smart_shuffle)); + expect(store.dispatch).toHaveBeenCalledWith(new SetPlayerState(PlayerState.Playing)); + expect(store.dispatch).toHaveBeenCalledWith(new SetDisallows(parseDisallows(getTestActionsResponse()))); + })); + + it('should set playback to idle when playback not available', fakeAsync(() => { + const response = generateResponse(getTestPlaybackResponse(), HttpStatusCode.NoContent); + http.get = jasmine.createSpy().and.returnValue(of(response)); + + service.pollCurrentPlayback(); + expect(store.dispatch).toHaveBeenCalledWith(new SetPlayerState(PlayerState.Idling)); + })); + + it('should set playback to idle when playback is null', fakeAsync(() => { + const response = generateResponse(null, HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + + service.pollCurrentPlayback(); + expect(store.dispatch).toHaveBeenCalledWith(new SetPlayerState(PlayerState.Idling)); + })); + + it('should set playback to idle when playback track is null', fakeAsync(() => { + const playbackResponse = { + ...getTestPlaybackResponse(), + item: null + }; + const response = generateResponse(playbackResponse, HttpStatusCode.Ok); + http.get = jasmine.createSpy().and.returnValue(of(response)); + + service.pollCurrentPlayback(); + expect(store.dispatch).toHaveBeenCalledWith(new SetPlayerState(PlayerState.Idling)); + })); +}); diff --git a/src/app/services/spotify/polling/spotify-polling.service.ts b/src/app/services/spotify/polling/spotify-polling.service.ts new file mode 100644 index 0000000..5292557 --- /dev/null +++ b/src/app/services/spotify/polling/spotify-polling.service.ts @@ -0,0 +1,120 @@ +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { Select, Store } from '@ngxs/store'; +import { BehaviorSubject } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { + ChangeAlbum, + ChangeDevice, + ChangeDeviceIsActive, + ChangeDeviceVolume, + ChangePlaylist, ChangeRepeatState, + ChangeTrack, SetDisallows, SetPlayerState, SetPlaying, SetProgress, SetShuffle, SetSmartShuffle +} from '../../../core/playback/playback.actions'; +import { AlbumModel, DeviceModel, PlayerState, PlaylistModel, TrackModel } from '../../../core/playback/playback.model'; +import { PlaybackState } from '../../../core/playback/playback.state'; +import { SpotifyEndpoints } from '../../../core/spotify/spotify-endpoints'; +import { SpotifyAPIResponse } from '../../../core/types'; +import { checkResponse, getIdFromSpotifyUri, parseAlbum, parseDevice, parseDisallows, parseTrack } from '../../../core/util'; +import { CurrentPlaybackResponse } from '../../../models/current-playback.model'; +import { PREVIOUS_VOLUME, StorageService } from '../../storage/storage.service'; +import { SpotifyControlsService } from '../controls/spotify-controls.service'; + +@Injectable({providedIn: 'root'}) +export class SpotifyPollingService { + @Select(PlaybackState.track) private track$: BehaviorSubject; + private track: TrackModel = null; + + @Select(PlaybackState.album) private album$: BehaviorSubject; + private album: AlbumModel = null; + + @Select(PlaybackState.playlist) private playlist$: BehaviorSubject; + private playlist: PlaylistModel = null; + + @Select(PlaybackState.device) private device$: BehaviorSubject; + private device: DeviceModel = null; + + constructor(private http: HttpClient, private storage: StorageService, private store: Store, private controls: SpotifyControlsService) {} + + initSubscriptions(): void { + this.track$.subscribe((track) => this.track = track); + this.album$.subscribe((album) => this.album = album); + this.playlist$.subscribe((playlist) => this.playlist = playlist); + this.device$.subscribe((device) => this.device = device); + } + + pollCurrentPlayback(): void { + this.http.get(SpotifyEndpoints.getPlaybackEndpoint(), {observe: 'response'}) + .pipe( + map((res: HttpResponse) => { + const apiResponse = checkResponse(res, true, true); + if (apiResponse === SpotifyAPIResponse.Success) { + return res.body; + } + else if (apiResponse === SpotifyAPIResponse.NoPlayback) { + // Playback not available or active + return null; + } + return null; + })).subscribe((playback) => { + // if not locked? + if (playback && playback.item) { + const track = playback.item; + + // Check for new track + if (track && track.id !== this.track.id) { + this.store.dispatch(new ChangeTrack(parseTrack(track))); + // Check if new track is saved + this.controls.isTrackSaved(track.id); + } + + // Check for new album + if (track && track.album && track.album.id !== this.album.id) { + this.store.dispatch(new ChangeAlbum(parseAlbum(track.album))); + } + + // Check for new playlist + if (playback.context && playback.context.type && playback.context.type === 'playlist') { + const playlistId = getIdFromSpotifyUri(playback.context.uri); + if (!this.playlist || this.playlist.id !== playlistId) { + // Retrieve new playlist + this.controls.setPlaylist(playlistId); + } + } else if (this.playlist) { + // No longer playing a playlist, update if we previously were + this.store.dispatch(new ChangePlaylist(null)); + } + + // Check if volume was muted externally to save previous value + if (playback.device && playback.device.volume_percent === 0 && this.device.volume > 0) { + this.storage.set(PREVIOUS_VOLUME, this.device.volume.toString()); + } + + // Get device changes + if (playback.device) { + // Check for new device + if (playback.device && playback.device.id !== this.device.id) { + this.store.dispatch(new ChangeDevice(parseDevice(playback.device))); + } + + // Update which device is active + this.store.dispatch(new ChangeDeviceIsActive(playback.device.is_active)); + this.store.dispatch(new ChangeDeviceVolume(playback.device.volume_percent)); + } + + // Update remaining playback values + this.store.dispatch(new SetProgress(playback.progress_ms)); + this.store.dispatch(new SetPlaying(playback.is_playing)); + this.store.dispatch(new SetShuffle(playback.shuffle_state)); + this.store.dispatch(new SetSmartShuffle(playback.smart_shuffle)); + this.store.dispatch(new ChangeRepeatState(playback.repeat_state)); + this.store.dispatch(new SetPlayerState(PlayerState.Playing)); + this.store.dispatch(new SetDisallows(parseDisallows(playback.actions.disallows))); + } else { + this.store.dispatch(new SetPlayerState(PlayerState.Idling)); + } + // else locked + // update progress to current + pollingInterval + }); + } +} diff --git a/src/app/services/spotify/spotify.interceptor.spec.ts b/src/app/services/spotify/spotify.interceptor.spec.ts deleted file mode 100644 index 25003c5..0000000 --- a/src/app/services/spotify/spotify.interceptor.spec.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { HTTP_INTERCEPTORS, HttpClient } from '@angular/common/http'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; -import { fakeAsync, TestBed, tick } from '@angular/core/testing'; -import { expect } from '@angular/flex-layout/_private-utils/testing'; -import { MockProvider } from 'ng-mocks'; -import { AppConfig } from '../../app.config'; -import { SpotifyInterceptor } from './spotify.interceptor'; -import { SpotifyAPIResponse, SpotifyService } from './spotify.service'; - -describe('SpotifyInterceptor', () => { - let spotify: SpotifyService; - let httpMock: HttpTestingController; - let http: HttpClient; - - beforeEach(() => { - AppConfig.settings = { - env: { - name: 'test-name', - domain: 'test-domain', - spotifyApiUrl: 'spotify-url' - }, - auth: { - clientId: 'test-client-id', - clientSecret: 'test-client-secret', - scopes: 'test-scope', - tokenUrl: 'token-url', - forcePkce: true, - showDialog: true - }, - logging: null - }; - SpotifyService.initialize(); - - TestBed.configureTestingModule({ - imports: [ - HttpClientTestingModule - ], - providers: [ - { - provide: HTTP_INTERCEPTORS, - useClass: SpotifyInterceptor, - multi: true - }, - MockProvider(SpotifyService), - ] - }); - spotify = TestBed.inject(SpotifyService); - httpMock = TestBed.inject(HttpTestingController); - http = TestBed.inject(HttpClient); - }); - - it('should catch HTTP response error and retry request on reauthentication', fakeAsync(() => { - spotify.checkErrorResponse = jasmine.createSpy().and.returnValue(Promise.resolve(SpotifyAPIResponse.ReAuthenticated)); - http.get('/test').subscribe( - (data) => expect(data).toBeTruthy(), - (err) => { - console.log(err); - fail('Should not have thrown error'); - }); - - let request = httpMock.expectOne('/test'); - request.error(new ProgressEvent('401 Invalid token'), { - status: 401, - statusText: 'Invalid token' - - }); - tick(); - request = httpMock.expectOne('/test'); - request.flush({ - status: 200, - statusText: 'Ok' - }); - httpMock.verify(); - })); - - it('should catch HTTP response error and fail when cannot reauthenticate', () => { - spotify.checkErrorResponse = jasmine.createSpy().and.returnValue(Promise.resolve(SpotifyAPIResponse.Error)); - http.get('/test').subscribe( - (data) => { - console.log(data); - fail('Should not of had a successful request'); - }, - (err) => expect(err).toBeTruthy()); - - const request = httpMock.expectOne('/test'); - request.error(new ProgressEvent('429 Exceeded rate limit'), { - status: 429, - statusText: 'Exceeded rate limit' - }); - httpMock.verify(); - }); - - it('should add the Authorization header when making a request for the Spotify API URL', () => { - spotify.getAuthorizationHeader = jasmine.createSpy().and.returnValue('test-token'); - http.get('spotify-url').subscribe( - (data) => expect(data).toBeTruthy(), - (err) => { - console.log(err); - fail('Should not have thrown error'); - }); - - const request = httpMock.expectOne('spotify-url'); - request.flush({ - status: 200, - statusText: 'Ok' - }); - httpMock.verify(); - - expect(request.request.headers.get('Authorization')).toEqual('test-token'); - }); - - it('should not add the Authorization header when making a request for the Spotify API token endpoint', () => { - const tokenEndpoint = `spotify-url/${SpotifyService.TOKEN_ENDPOINT}`; - http.get(tokenEndpoint).subscribe( - (data) => expect(data).toBeTruthy(), - (err) => { - console.log(err); - fail('Should not have thrown error'); - }); - - const request = httpMock.expectOne(tokenEndpoint); - request.flush({ - status: 200, - statusText: 'Ok' - }); - httpMock.verify(); - - expect(request.request.headers.get('Authorization')).toBeNull(); - }); - - it('should throw an error when making a request to the Spotify API URL but no authToken', () => { - spotify.getAuthorizationHeader = jasmine.createSpy().and.returnValue(null); - http.get('spotify-url').subscribe( - (data) => { - console.log(data); - fail('Should not have returned successfully'); - }, - (err) => { - expect(err).toBeTruthy(); - }); - - httpMock.expectNone('spotify-url'); - httpMock.verify(); - }); - - it('should not add the Authorization header when request is not to the Spotify API URL', () => { - spotify.getAuthorizationHeader = jasmine.createSpy().and.returnValue('test-token'); - http.get('album-url').subscribe( - (data) => expect(data).toBeTruthy(), - (err) => { - console.log(err); - fail('Should not have thrown error'); - }); - - const request = httpMock.expectOne('album-url'); - request.flush({ - status: 200, - statusText: 'Ok' - }); - httpMock.verify(); - - expect(request.request.headers.get('Authorization')).toBeFalsy(); - }); -}); diff --git a/src/app/services/spotify/spotify.interceptor.ts b/src/app/services/spotify/spotify.interceptor.ts deleted file mode 100644 index 1343ed2..0000000 --- a/src/app/services/spotify/spotify.interceptor.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { HttpErrorResponse, HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { from, Observable, throwError } from 'rxjs'; -import { catchError, switchMap } from 'rxjs/operators'; -import { SpotifyAPIResponse, SpotifyService } from './spotify.service'; - -@Injectable() -export class SpotifyInterceptor implements HttpInterceptor { - constructor(private spotify: SpotifyService) {} - - intercept(req: HttpRequest, next: HttpHandler): Observable> { - let authReq = req; - if (req.url.startsWith(SpotifyService.spotifyApiUrl) && !req.url.endsWith(SpotifyService.TOKEN_ENDPOINT)) { - const authHeader = this.spotify.getAuthorizationHeader(); - if (!authHeader) { - return throwError('No auth token present'); - } - authReq = req.clone({ - headers: req.headers.set('Authorization', authHeader) - }); - } - - return next.handle(authReq).pipe( - catchError(err => { - if (err instanceof HttpErrorResponse) { - return from(this.spotify.checkErrorResponse(err)) - .pipe(switchMap((apiResponse) => { - if (apiResponse === SpotifyAPIResponse.ReAuthenticated) { - return next.handle(authReq); - } - })); - } - return throwError(err); - }) - ); - } -} diff --git a/src/app/services/spotify/spotify.service.spec.ts b/src/app/services/spotify/spotify.service.spec.ts deleted file mode 100644 index 474f1b3..0000000 --- a/src/app/services/spotify/spotify.service.spec.ts +++ /dev/null @@ -1,1395 +0,0 @@ -/* tslint:disable:no-string-literal */ - -import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams, HttpResponse } from '@angular/common/http'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { fakeAsync, flushMicrotasks, TestBed } from '@angular/core/testing'; -import { expect } from '@angular/flex-layout/_private-utils/testing'; -import { Router } from '@angular/router'; -import { NgxsModule, Store } from '@ngxs/store'; -import { MockProvider } from 'ng-mocks'; -import { BehaviorSubject, of, throwError } from 'rxjs'; -import { AppConfig } from '../../app.config'; -import { SetAuthToken } from '../../core/auth/auth.actions'; -import { AuthToken } from '../../core/auth/auth.model'; -import { - ChangeAlbum, - ChangeDevice, - ChangeDeviceIsActive, - ChangeDeviceVolume, - ChangePlaylist, - ChangeRepeatState, - ChangeTrack, - SetAvailableDevices, - SetIdle, - SetLiked, - SetPlaying, - SetProgress, - SetShuffle -} from '../../core/playback/playback.actions'; -import { AlbumModel, DeviceModel, PlaylistModel, TrackModel } from '../../core/playback/playback.model'; -import { NgxsSelectorMock } from '../../core/testing/ngxs-selector-mock'; -import { parseAlbum, parseDevice, parsePlaylist, parseTrack } from '../../core/util'; -import { AlbumResponse } from '../../models/album.model'; -import { ArtistResponse } from '../../models/artist.model'; -import { CurrentPlaybackResponse } from '../../models/current-playback.model'; -import { DeviceResponse, MultipleDevicesResponse } from '../../models/device.model'; -import { PlaylistResponse } from '../../models/playlist.model'; -import { TrackResponse } from '../../models/track.model'; -import { StorageService } from '../storage/storage.service'; -import { AuthType, SpotifyAPIResponse, SpotifyService } from './spotify.service'; - -const TEST_AUTH_TOKEN: AuthToken = { - accessToken: 'test-access', - tokenType: 'test-type', - expiry: new Date(Date.UTC(9999, 1, 1, )), - scope: 'test-scope', - refreshToken: 'test-refresh' -}; - -const TEST_ARTIST_RESPONSE_1: ArtistResponse = { - id: 'artist-id-1', - name: 'artist-1', - type: 'artist-type-1', - uri: 'artist-uri-1', - external_urls: { - spotify: 'artist-url-1' - } -}; - -const TEST_ARTIST_RESPONSE_2: ArtistResponse = { - id: 'artist-id-2', - name: 'artist-2', - type: 'artist-type-2', - uri: 'artist-uri-2', - external_urls: { - spotify: 'artist-url-2' - } -}; - -const TEST_ALBUM_RESPONSE: AlbumResponse = { - id: 'album-id', - name: 'test-album', - type: 'album-type', - total_tracks: 10, - release_date: 'album-date', - uri: 'album-uri', - external_urls: { - spotify: 'album-url' - }, - album_type: 'album-type', - images: [ - {url: 'album-img', height: 500, width: 500} - ], - artists: [ - TEST_ARTIST_RESPONSE_1, - TEST_ARTIST_RESPONSE_2 - ] -}; - -const TEST_TRACK_RESPONSE: TrackResponse = { - name: 'test-track', - album: TEST_ALBUM_RESPONSE, - track_number: 1, - duration_ms: 1000, - uri: 'test-uri', - id: 'track-id', - popularity: 100, - type: 'type-test', - explicit: true, - external_urls: { - spotify: 'spotify-url' - }, - artists: [ - TEST_ARTIST_RESPONSE_1, - TEST_ARTIST_RESPONSE_2 - ] -}; - -const TEST_PLAYLIST_RESPONSE: PlaylistResponse = { - id: 'playlist-id', - name: 'playlist-test', - external_urls: { - spotify: 'playlist-url' - } -}; - -const TEST_DEVICE_RESPONSE: DeviceResponse = { - id: 'device-id', - volume_percent: 50, - name: 'device-test', - type: 'speaker', - is_active: true, - is_private_session: false, - is_restricted: false -}; - -const TEST_PLAYBACK_RESPONSE: CurrentPlaybackResponse = { - item: TEST_TRACK_RESPONSE, - context: { - type: 'playlist', - href: 'context-url', - uri: 'test:uri:playlist-id' - }, - device: TEST_DEVICE_RESPONSE, - is_playing: false, - currently_playing_type: 'test-type', - progress_ms: 100, - repeat_state: 'test-state', - shuffle_state: true, - timestamp: 10 -}; - -function generateResponse(body: T, status: number): HttpResponse { - return new HttpResponse({ - body, - headers: null, - status, - statusText: 'test-status', - url: 'test-url' - }); -} - -function generateErrorResponse(status: number): HttpErrorResponse { - return new HttpErrorResponse({ - status - }); -} - -describe('SpotifyService', () => { - const mockSelectors = new NgxsSelectorMock(); - let service: SpotifyService; - let http: HttpClient; - let storage: StorageService; - let router: Router; - let store: Store; - - let tokenProducer: BehaviorSubject; - let trackProducer: BehaviorSubject; - let albumProducer: BehaviorSubject; - let playlistProducer: BehaviorSubject; - let deviceProducer: BehaviorSubject; - let isPlayingProducer: BehaviorSubject; - let isShuffleProducer: BehaviorSubject; - let progressProducer: BehaviorSubject; - let durationProducer: BehaviorSubject; - let isLikedProducer: BehaviorSubject; - - beforeEach(() => { - AppConfig.settings = { - env: { - name: 'test-name', - domain: 'test-domain', - spotifyApiUrl: 'spotify-url' - }, - auth: { - clientId: 'test-client-id', - clientSecret: null, - scopes: 'test-scope', - tokenUrl: null, - forcePkce: false, - showDialog: true - }, - logging: null - }; - SpotifyService.initialize(); - - TestBed.configureTestingModule({ - imports: [ - NgxsModule.forRoot([], {developmentMode: true}), - HttpClientTestingModule - ], - providers: [ - SpotifyService, - MockProvider(HttpClient), - MockProvider(StorageService), - MockProvider(Router), - MockProvider(Store) - ] - }); - service = TestBed.inject(SpotifyService); - http = TestBed.inject(HttpClient); - storage = TestBed.inject(StorageService); - router = TestBed.inject(Router); - store = TestBed.inject(Store); - // Set test auth token as initial default for tests - tokenProducer = mockSelectors.defineNgxsSelector(service, 'authToken$', TEST_AUTH_TOKEN); - trackProducer = mockSelectors.defineNgxsSelector(service, 'track$', parseTrack(TEST_TRACK_RESPONSE)); - albumProducer = mockSelectors.defineNgxsSelector(service, 'album$', parseAlbum(TEST_ALBUM_RESPONSE)); - playlistProducer = mockSelectors.defineNgxsSelector(service, 'playlist$', parsePlaylist(TEST_PLAYLIST_RESPONSE)); - deviceProducer = mockSelectors.defineNgxsSelector(service, 'device$', parseDevice(TEST_DEVICE_RESPONSE)); - isPlayingProducer = mockSelectors.defineNgxsSelector(service, 'isPlaying$', true); - isShuffleProducer = mockSelectors.defineNgxsSelector(service, 'isShuffle$', true); - progressProducer = mockSelectors.defineNgxsSelector(service, 'progress$', 10); - durationProducer = mockSelectors.defineNgxsSelector(service, 'duration$', 100); - isLikedProducer = mockSelectors.defineNgxsSelector(service, 'isLiked$', true); - - service.initSubscriptions(); - spyOn(console, 'error'); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - it('should fail to initialize if no configured clientId', () => { - AppConfig.settings.auth.clientId = null; - expect(SpotifyService.initialize()).toBeFalse(); - expect(console.error).toHaveBeenCalled(); - }); - - it('should set tokenUrl on initialization when configured', () => { - AppConfig.settings.auth.tokenUrl = 'test-token-url'; - expect(SpotifyService.initialize()).toBeTrue(); - expect(SpotifyService['tokenUrl']).toBeTruthy(); - }); - - it('should set clientSecret on initialization when configured', () => { - AppConfig.settings.auth.clientSecret = 'test-client-secret'; - expect(SpotifyService.initialize()).toBeTrue(); - expect(SpotifyService['clientSecret']).toBeTruthy(); - }); - - it('should set scopes on initialization when configured', () => { - expect(SpotifyService.initialize()).toBeTrue(); - expect(SpotifyService['scopes']).toBeTruthy(); - }); - - it('should set showAuthDialog on initialization when configured', () => { - expect(SpotifyService.initialize()).toBeTrue(); - expect(SpotifyService['showAuthDialog']).toBeTrue(); - }); - - it('should set auth type to PKCE if no configured tokenUrl and clientSecret', () => { - expect(SpotifyService.initialize()).toBeTrue(); - expect(SpotifyService['authType']).toEqual(AuthType.PKCE); - }); - - it('should set auth type to PKCE if forcePkce is true', () => { - AppConfig.settings.auth.forcePkce = true; - expect(SpotifyService.initialize()).toBeTrue(); - expect(SpotifyService['authType']).toEqual(AuthType.PKCE); - }); - - it('should set auth type to ThirdParty if tokenUrl is configured and clientSecret not configured', () => { - AppConfig.settings.auth.tokenUrl = 'test-token-url'; - expect(SpotifyService.initialize()).toBeTrue(); - expect(SpotifyService['authType']).toEqual(AuthType.ThirdParty); - }); - - it('should set auth type to Secret if tokenUrl not configured and clientSecret is configured', () => { - AppConfig.settings.auth.clientSecret = 'test-client-secret'; - expect(SpotifyService.initialize()).toBeTrue(); - expect(SpotifyService['authType']).toEqual(AuthType.Secret); - }); - - it('should fail to initialize if no configured spotifyApiUrl', () => { - AppConfig.settings.env.spotifyApiUrl = null; - expect(SpotifyService.initialize()).toBeFalse(); - expect(console.error).toHaveBeenCalled(); - }); - - it('should fail to initialize if no configured domain', () => { - AppConfig.settings.env.domain = null; - expect(SpotifyService.initialize()).toBeFalse(); - expect(console.error).toHaveBeenCalled(); - }); - - it('should fail to initialize if issue retrieving AppConfig', () => { - AppConfig.settings.env = null; - expect(SpotifyService.initialize()).toBeFalse(); - expect(console.error).toHaveBeenCalled(); - - AppConfig.settings.auth = null; - expect(SpotifyService.initialize()).toBeFalse(); - expect(console.error).toHaveBeenCalled(); - - AppConfig.settings = null; - expect(SpotifyService.initialize()).toBeFalse(); - expect(console.error).toHaveBeenCalled(); - }); - - it('should add Authorization header when requesting auth token and auth type is secret', fakeAsync(() => { - SpotifyService['authType'] = AuthType.Secret; - http.post = jasmine.createSpy().and.returnValue(of(new HttpResponse({body: {}, status: 200, statusText: 'OK'}))); - - service.requestAuthToken('test-code', false); - flushMicrotasks(); - expect(http.post).toHaveBeenCalledOnceWith( - jasmine.any(String), - jasmine.any(URLSearchParams), - { - headers: new HttpHeaders().set( - 'Content-Type', 'application/x-www-form-urlencoded' - ).set( - 'Authorization', `Basic ${new Buffer(`${SpotifyService['clientId']}:${SpotifyService['clientSecret']}`).toString('base64')}` - ), - observe: 'response' - }); - })); - - it('should NOT add Authorization header when requesting auth token and auth type is PKCE', fakeAsync(() => { - SpotifyService['authType'] = AuthType.PKCE; - http.post = jasmine.createSpy().and.returnValue(of(new HttpResponse({body: {}, status: 200, statusText: 'OK'}))); - - service.requestAuthToken('test-code', false); - flushMicrotasks(); - expect(http.post).toHaveBeenCalledOnceWith( - jasmine.any(String), - jasmine.any(URLSearchParams), - { - headers: new HttpHeaders().set( - 'Content-Type', 'application/x-www-form-urlencoded' - ), - observe: 'response' - }); - })); - - it('should NOT add Authorization header when requesting auth token and auth type is ThirdParty', fakeAsync(() => { - SpotifyService['authType'] = AuthType.ThirdParty; - SpotifyService['tokenUrl'] = 'test-token-url'; - http.post = jasmine.createSpy().and.returnValue(of(new HttpResponse({body: {}, status: 200, statusText: 'OK'}))); - - service.requestAuthToken('test-code', false); - flushMicrotasks(); - expect(http.post).toHaveBeenCalledOnceWith( - jasmine.any(String), - jasmine.any(URLSearchParams), - { - headers: new HttpHeaders().set( - 'Content-Type', 'application/x-www-form-urlencoded' - ), - observe: 'response' - }); - })); - - it(`should use Spotify's token endpoint if auth type is PKCE when requesting an auth token`, fakeAsync(() => { - SpotifyService['authType'] = AuthType.PKCE; - http.post = jasmine.createSpy().and.returnValue(of(new HttpResponse({body: {}, status: 200, statusText: 'OK'}))); - - service.requestAuthToken('test-code', false); - flushMicrotasks(); - expect(http.post).toHaveBeenCalledOnceWith( - SpotifyService.TOKEN_ENDPOINT, - jasmine.any(URLSearchParams), - jasmine.any(Object) - ); - })); - - it(`should use Spotify's token endpoint if auth type is Secret when requesting an auth token`, fakeAsync(() => { - SpotifyService['authType'] = AuthType.Secret; - http.post = jasmine.createSpy().and.returnValue(of(new HttpResponse({body: {}, status: 200, statusText: 'OK'}))); - - service.requestAuthToken('test-code', false); - flushMicrotasks(); - expect(http.post).toHaveBeenCalledOnceWith( - SpotifyService.TOKEN_ENDPOINT, - jasmine.any(URLSearchParams), - jasmine.any(Object) - ); - })); - - it(`should use the configured token URL endpoint is auth type is ThirdParty when requesting an auth token`, fakeAsync(() => { - SpotifyService['authType'] = AuthType.ThirdParty; - http.post = jasmine.createSpy().and.returnValue(of(new HttpResponse({body: {}, status: 200, statusText: 'OK'}))); - - service.requestAuthToken('test-code', false); - flushMicrotasks(); - expect(http.post).toHaveBeenCalledOnceWith( - SpotifyService['tokenUrl'], - jasmine.any(URLSearchParams), - jasmine.any(Object) - ); - })); - - it('should send correct request parameters for requesting a new auth token', fakeAsync(() => { - service['codeVerifier'] = 'test-code-verifier'; - http.post = jasmine.createSpy().and.returnValue(of(new HttpResponse({body: {}, status: 200, statusText: 'OK'}))); - const expectedBody = new URLSearchParams({ - grant_type: 'authorization_code', - client_id: SpotifyService['clientId'], - code: 'test-code', - redirect_uri: SpotifyService['redirectUri'], - code_verifier: 'test-code-verifier' - }); - - service.requestAuthToken('test-code', false); - flushMicrotasks(); - expect(http.post).toHaveBeenCalledOnceWith( - jasmine.any(String), - expectedBody, - jasmine.any(Object) - ); - })); - - it('should send correct request parameters for refreshing an existing auth token', fakeAsync(() => { - http.post = jasmine.createSpy().and.returnValue(of(new HttpResponse({body: {}, status: 200, statusText: 'OK'}))); - const expectedBody = new URLSearchParams({ - grant_type: 'refresh_token', - client_id: SpotifyService['clientId'], - refresh_token: 'test-code' - }); - - service.requestAuthToken('test-code', false); - flushMicrotasks(); - expect(http.post).toHaveBeenCalledOnceWith( - jasmine.any(String), - expectedBody, - jasmine.any(Object) - ); - })); - - it('should set the auth token with response from requesting a new auth token and handle expires_in response', fakeAsync(() => { - const response = new HttpResponse({ - body: { - access_token: 'test-access-token', - token_type: 'test-type', - expires_in: Date.now(), - scope: 'test-scope', - refresh_token: 'test-refresh-token' - }, - status: 200, - statusText: 'OK' - }); - http.post = jasmine.createSpy().and.returnValue(of(response)); - - service.requestAuthToken('test-code', false); - flushMicrotasks(); - expect(http.post).toHaveBeenCalledOnceWith( - jasmine.any(String), - jasmine.any(URLSearchParams), - jasmine.any(Object) - ); - const expiryResponse = new Date(); - expiryResponse.setSeconds(expiryResponse.getSeconds() + response.body.expires_in); - expect(store.dispatch).toHaveBeenCalledWith(new SetAuthToken({ - accessToken: response.body.access_token, - tokenType: response.body.token_type, - scope: response.body.scope, - expiry: expiryResponse, - refreshToken: response.body.refresh_token - })); - })); - - it('should set the auth token with response from requesting a new auth token using third party and handle expiry response', - fakeAsync(() => { - SpotifyService['authType'] = AuthType.ThirdParty; - SpotifyService['tokenUrl'] = 'test-token-url'; - const response = new HttpResponse({ - body: { - access_token: 'test-access-token', - token_type: 'test-type', - expiry: new Date().toString(), - scope: 'test-scope', - refresh_token: 'test-refresh-token' - }, - status: 200, - statusText: 'OK' - }); - http.post = jasmine.createSpy().and.returnValue(of(response)); - - service.requestAuthToken('test-code', false); - flushMicrotasks(); - expect(http.post).toHaveBeenCalledOnceWith( - jasmine.any(String), - jasmine.any(URLSearchParams), - jasmine.any(Object) - ); - expect(store.dispatch).toHaveBeenCalledWith(new SetAuthToken({ - accessToken: response.body.access_token, - tokenType: response.body.token_type, - scope: response.body.scope, - expiry: new Date(response.body.expiry), - refreshToken: response.body.refresh_token - })); - })); - - it('should set the auth token with response from requesting a new auth token using third party and handle expires_in response', - fakeAsync(() => { - SpotifyService['authType'] = AuthType.ThirdParty; - SpotifyService['tokenUrl'] = 'test-token-url'; - const response = new HttpResponse({ - body: { - access_token: 'test-access-token', - token_type: 'test-type', - expires_in: Date.now(), - scope: 'test-scope', - refresh_token: 'test-refresh-token' - }, - status: 200, - statusText: 'OK' - }); - http.post = jasmine.createSpy().and.returnValue(of(response)); - - service.requestAuthToken('test-code', false); - flushMicrotasks(); - expect(http.post).toHaveBeenCalledOnceWith( - jasmine.any(String), - jasmine.any(URLSearchParams), - jasmine.any(Object) - ); - const expiryResponse = new Date(); - expiryResponse.setSeconds(expiryResponse.getSeconds() + response.body.expires_in); - expect(store.dispatch).toHaveBeenCalledWith(new SetAuthToken({ - accessToken: response.body.access_token, - tokenType: response.body.token_type, - scope: response.body.scope, - expiry: expiryResponse, - refreshToken: response.body.refresh_token - })); - })); - - it('should output error when requestAuthToken fails', fakeAsync(() => { - http.post = jasmine.createSpy().and.returnValue(throwError({status: 405})); - let error; - service.requestAuthToken('test-code', false) - .catch((err) => error = err); - flushMicrotasks(); - expect(error).toBeTruthy(); - expect(http.post).toHaveBeenCalled(); - expect(store.dispatch).not.toHaveBeenCalled(); - expect(console.error).toHaveBeenCalled(); - })); - - it('should create the authorize request url with correct params when auth type is Secret', fakeAsync(() => { - SpotifyService['authType'] = AuthType.Secret; - service['state'] = 'test-state'; - const expectedParams = new URLSearchParams({ - response_type: 'code', - client_id: SpotifyService['clientId'], - scope: 'test-scope', - redirect_uri: `${AppConfig.settings.env.domain}/callback`, - state: 'test-state', - show_dialog: 'true' - }); - const expectedUrl = `${SpotifyService['AUTH_ENDPOINT']}?${expectedParams.toString()}`; - let actualUrl; - service.getAuthorizeRequestUrl().then((url) => actualUrl = url); - - flushMicrotasks(); - expect(actualUrl).toEqual(expectedUrl); - })); - - it('should create the authorize request url with code challenge params when auth type is PKCE', fakeAsync(() => { - spyOn(window.crypto.subtle, 'digest').and.returnValue(Promise.resolve(new ArrayBuffer(8))); - service['state'] = 'test-state'; - service['codeVerifier'] = 'test-code-verifier'; - const expectedParams = new URLSearchParams({ - response_type: 'code', - client_id: SpotifyService['clientId'], - scope: 'test-scope', - redirect_uri: `${AppConfig.settings.env.domain}/callback`, - state: 'test-state', - show_dialog: 'true', - code_challenge_method: 'S256', - code_challenge: 'AAAAAAAAAAA' - }); - const expectedUrl = `${SpotifyService['AUTH_ENDPOINT']}?${expectedParams.toString()}`; - let actualUrl; - service.getAuthorizeRequestUrl().then((url) => actualUrl = url); - - flushMicrotasks(); - expect(actualUrl).toEqual(expectedUrl); - })); - - it('should get current playback on pollCurrentPlayback', fakeAsync(() => { - const response = generateResponse(TEST_PLAYBACK_RESPONSE, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - - service.pollCurrentPlayback(1000); - - expect(http.get).toHaveBeenCalledOnceWith( - SpotifyService.spotifyEndpoints.getPlaybackEndpoint(), - { - observe: 'response' - }); - })); - - it('should change track if a new track', fakeAsync(() => { - spyOn(service, 'isTrackSaved'); - const response = generateResponse(TEST_PLAYBACK_RESPONSE, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - const currentTrack = parseTrack({ - ...TEST_TRACK_RESPONSE, - id: 'old-id' - }); - trackProducer.next(currentTrack); - - service.pollCurrentPlayback(1000); - expect(store.dispatch).toHaveBeenCalledWith(new ChangeTrack(parseTrack(TEST_TRACK_RESPONSE))); - expect(service.isTrackSaved).toHaveBeenCalledOnceWith(TEST_TRACK_RESPONSE.id); - })); - - it('should not change track if same track playing', fakeAsync(() => { - spyOn(service, 'isTrackSaved'); - const response = generateResponse(TEST_PLAYBACK_RESPONSE, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - - service.pollCurrentPlayback(1000); - expect(store.dispatch).not.toHaveBeenCalledWith(jasmine.any(ChangeTrack)); - expect(service.isTrackSaved).not.toHaveBeenCalled(); - })); - - it('should change album if new album', fakeAsync(() => { - const response = generateResponse(TEST_PLAYBACK_RESPONSE, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - const currentAlbum = parseAlbum({ - ...TEST_ALBUM_RESPONSE, - id: 'old-id' - }); - albumProducer.next(currentAlbum); - - service.pollCurrentPlayback(1000); - expect(store.dispatch).toHaveBeenCalledWith(new ChangeAlbum(parseAlbum(TEST_ALBUM_RESPONSE))); - })); - - it('should not change album if same album', fakeAsync(() => { - const response = generateResponse(TEST_PLAYBACK_RESPONSE, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - - service.pollCurrentPlayback(1000); - expect(store.dispatch).not.toHaveBeenCalledWith(jasmine.any(ChangeAlbum)); - })); - - it('should change playlist if new playlist', fakeAsync(() => { - spyOn(service, 'setPlaylist'); - const currentPlaylist = { - ...parsePlaylist(TEST_PLAYLIST_RESPONSE), - id: 'old-id' - }; - const response = generateResponse(TEST_PLAYBACK_RESPONSE, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - playlistProducer.next(currentPlaylist); - - service.pollCurrentPlayback(1000); - expect(service.setPlaylist).toHaveBeenCalledWith(TEST_PLAYLIST_RESPONSE.id); - })); - - it('should change to playback playlist if no current playlist', fakeAsync(() => { - spyOn(service, 'setPlaylist'); - const response = generateResponse(TEST_PLAYBACK_RESPONSE, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - playlistProducer.next(null); - - service.pollCurrentPlayback(1000); - expect(service.setPlaylist).toHaveBeenCalledWith(TEST_PLAYLIST_RESPONSE.id); - })); - - it('should not change playlist if playback playlist is current playlist', fakeAsync(() => { - spyOn(service, 'setPlaylist'); - const response = generateResponse(TEST_PLAYBACK_RESPONSE, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - - service.pollCurrentPlayback(1000); - expect(service.setPlaylist).not.toHaveBeenCalled(); - })); - - it('should remove playlist if context is null and previously had playlist', fakeAsync(() => { - spyOn(service, 'setPlaylist'); - const playbackResponse = { - ...TEST_PLAYBACK_RESPONSE, - context: null - }; - const response = generateResponse(playbackResponse, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - - service.pollCurrentPlayback(1000); - expect(store.dispatch).toHaveBeenCalledWith(new ChangePlaylist(null)); - })); - - it('should remove playlist if context type is null and previously had playlist', fakeAsync(() => { - const playbackResponse = { - ...TEST_PLAYBACK_RESPONSE, - context: { - ...TEST_PLAYBACK_RESPONSE.context, - type: null - } - }; - const response = generateResponse(playbackResponse, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - - service.pollCurrentPlayback(1000); - expect(store.dispatch).toHaveBeenCalledWith(new ChangePlaylist(null)); - })); - - it('should remove playlist if context type is not playlist and previously had playlist', fakeAsync(() => { - const playbackResponse = { - ...TEST_PLAYBACK_RESPONSE, - context: { - ...TEST_PLAYBACK_RESPONSE.context, - type: 'test' - } - }; - const response = generateResponse(playbackResponse, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - - service.pollCurrentPlayback(1000); - expect(store.dispatch).toHaveBeenCalledWith(new ChangePlaylist(null)); - })); - - it('should not change playlist if no playback playlist and no previous playlist', fakeAsync(() => { - const playbackResponse = { - ...TEST_PLAYBACK_RESPONSE, - context: { - ...TEST_PLAYBACK_RESPONSE.context, - type: 'test' - } - }; - const response = generateResponse(playbackResponse, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - playlistProducer.next(null); - - service.pollCurrentPlayback(1000); - expect(store.dispatch).not.toHaveBeenCalledWith(jasmine.any(ChangePlaylist)); - })); - - it('should save previous volume value if playback muted and not previously muted', fakeAsync(() => { - const playbackResponse = { - ...TEST_PLAYBACK_RESPONSE, - device: { - ...TEST_DEVICE_RESPONSE, - volume_percent: 0 - } - }; - const response = generateResponse(playbackResponse, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - deviceProducer.next(parseDevice({...TEST_DEVICE_RESPONSE, volume_percent: 25})); - - service.pollCurrentPlayback(1000); - expect(storage.set).toHaveBeenCalledWith(SpotifyService.PREVIOUS_VOLUME, '25'); - })); - - it('should not save previous volume value if playback muted and currently muted', fakeAsync(() => { - const playbackResponse = { - ...TEST_PLAYBACK_RESPONSE, - device: { - ...TEST_DEVICE_RESPONSE, - volume_percent: 0 - } - }; - const response = generateResponse(playbackResponse, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - deviceProducer.next(parseDevice({...TEST_DEVICE_RESPONSE, volume_percent: 0})); - - service.pollCurrentPlayback(1000); - expect(storage.set).not.toHaveBeenCalledWith(SpotifyService.PREVIOUS_VOLUME, jasmine.anything()); - })); - - it('should not save previous volume value if playback not muted', fakeAsync(() => { - const response = generateResponse(TEST_PLAYBACK_RESPONSE, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - deviceProducer.next(parseDevice({...TEST_DEVICE_RESPONSE, volume_percent: 0})); - - service.pollCurrentPlayback(1000); - expect(storage.set).not.toHaveBeenCalledWith(SpotifyService.PREVIOUS_VOLUME, jasmine.anything()); - })); - - it('should not save previous volume value if playback device is null', fakeAsync(() => { - const playbackResponse = { - ...TEST_PLAYBACK_RESPONSE, - device: null - }; - const response = generateResponse(playbackResponse, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - - service.pollCurrentPlayback(1000); - expect(storage.set).not.toHaveBeenCalledWith(SpotifyService.PREVIOUS_VOLUME, jasmine.anything()); - })); - - it('should change current device if playback device differs from current device', fakeAsync(() => { - const response = generateResponse(TEST_PLAYBACK_RESPONSE, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - deviceProducer.next({...parseDevice(TEST_DEVICE_RESPONSE), id: 'old-id'}); - - service.pollCurrentPlayback(1000); - expect(store.dispatch).toHaveBeenCalledWith(new ChangeDevice(parseDevice(TEST_DEVICE_RESPONSE))); - })); - - it('should not change current device if playback device is the current device', fakeAsync(() => { - const response = generateResponse(TEST_PLAYBACK_RESPONSE, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - - service.pollCurrentPlayback(1000); - expect(store.dispatch).not.toHaveBeenCalledWith(jasmine.any(ChangeDevice)); - })); - - it('should not change current device playback device is null', fakeAsync(() => { - const playbackResponse = { - ...TEST_PLAYBACK_RESPONSE, - device: null - }; - const response = generateResponse(playbackResponse, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - - service.pollCurrentPlayback(1000); - expect(store.dispatch).not.toHaveBeenCalledWith(jasmine.any(ChangeDevice)); - expect(store.dispatch).not.toHaveBeenCalledWith(jasmine.any(ChangeDeviceIsActive)); - expect(store.dispatch).not.toHaveBeenCalledWith(jasmine.any(ChangeDeviceVolume)); - })); - - it('should set update rest of track playback states', fakeAsync(() => { - const response = generateResponse(TEST_PLAYBACK_RESPONSE, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - trackProducer.next(parseTrack(TEST_TRACK_RESPONSE)); - - service.pollCurrentPlayback(1000); - expect(store.dispatch).toHaveBeenCalledWith(new ChangeDeviceIsActive(TEST_DEVICE_RESPONSE.is_active)); - expect(store.dispatch).toHaveBeenCalledWith(new ChangeDeviceVolume(TEST_DEVICE_RESPONSE.volume_percent)); - expect(store.dispatch).toHaveBeenCalledWith(new SetProgress(TEST_PLAYBACK_RESPONSE.progress_ms)); - expect(store.dispatch).toHaveBeenCalledWith(new SetPlaying(TEST_PLAYBACK_RESPONSE.is_playing)); - expect(store.dispatch).toHaveBeenCalledWith(new SetShuffle(TEST_PLAYBACK_RESPONSE.shuffle_state)); - expect(store.dispatch).toHaveBeenCalledWith(new SetIdle(false)); - })); - - it('should set playback to idle when playback not available', fakeAsync(() => { - const response = generateResponse(TEST_PLAYBACK_RESPONSE, 204); - http.get = jasmine.createSpy().and.returnValue(of(response)); - - service.pollCurrentPlayback(1000); - expect(store.dispatch).toHaveBeenCalledWith(new SetIdle(true)); - })); - - it('should set playback to idle when playback is null', fakeAsync(() => { - const response = generateResponse(null, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - - service.pollCurrentPlayback(1000); - expect(store.dispatch).toHaveBeenCalledWith(new SetIdle(true)); - })); - - it('should set playback to idle when playback track is null', fakeAsync(() => { - const playbackResponse = { - ...TEST_PLAYBACK_RESPONSE, - item: null - }; - const response = generateResponse(playbackResponse, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - - service.pollCurrentPlayback(1000); - expect(store.dispatch).toHaveBeenCalledWith(new SetIdle(true)); - })); - - it('should set track position when valid', fakeAsync(() => { - const response = generateResponse(null, 204); - http.put = jasmine.createSpy().and.returnValue(of(response)); - service.setTrackPosition(50); - expect(http.put).toHaveBeenCalledOnceWith( - SpotifyService.spotifyEndpoints.getSeekEndpoint(), - {}, - { - headers: jasmine.any(HttpHeaders), - params: jasmine.any(HttpParams), - observe: 'response' - }); - const spyParams = (http.put as jasmine.Spy).calls.mostRecent().args[2].params as HttpParams; - expect(spyParams.keys().length).toEqual(1); - expect(spyParams.get('position_ms')).toEqual('50'); - expect(store.dispatch).toHaveBeenCalledWith(new SetProgress(50)); - })); - - it('should set track position to duration when greater than', fakeAsync(() => { - const response = generateResponse(null, 204); - http.put = jasmine.createSpy().and.returnValue(of(response)); - durationProducer.next(100); - service.setTrackPosition(101); - expect(store.dispatch).toHaveBeenCalledWith(new SetProgress(100)); - })); - - it('should set track position to 0 when negative', fakeAsync(() => { - const response = generateResponse(null, 204); - http.put = jasmine.createSpy().and.returnValue(of(response)); - service.setTrackPosition(-1); - expect(store.dispatch).toHaveBeenCalledWith(new SetProgress(0)); - })); - - it('should send play request when isPlaying', fakeAsync(() => { - const response = generateResponse(null, 204); - http.put = jasmine.createSpy().and.returnValue(of(response)); - service.setPlaying(true); - expect(http.put).toHaveBeenCalledOnceWith( - SpotifyService.spotifyEndpoints.getPlayEndpoint(), - {}, - { headers: jasmine.any(HttpHeaders), observe: 'response' } - ); - expect(store.dispatch).toHaveBeenCalledWith(new SetPlaying(true)); - })); - - it('should send pause request when not isPlaying', fakeAsync(() => { - const response = generateResponse(null, 204); - http.put = jasmine.createSpy().and.returnValue(of(response)); - service.setPlaying(false); - expect(http.put).toHaveBeenCalledOnceWith( - SpotifyService.spotifyEndpoints.getPauseEndpoint(), - {}, - { headers: jasmine.any(HttpHeaders), observe: 'response' } - ); - expect(store.dispatch).toHaveBeenCalledWith(new SetPlaying(false)); - })); - - it('should toggle playing off', fakeAsync(() => { - const response = generateResponse(null, 204); - http.put = jasmine.createSpy().and.returnValue(of(response)); - isPlayingProducer.next(true); - service.togglePlaying(); - expect(store.dispatch).toHaveBeenCalledWith(new SetPlaying(false)); - })); - - it('should toggle playing on', fakeAsync(() => { - const response = generateResponse(null, 204); - http.put = jasmine.createSpy().and.returnValue(of(response)); - isPlayingProducer.next(false); - service.togglePlaying(); - expect(store.dispatch).toHaveBeenCalledWith(new SetPlaying(true)); - })); - - it('should send skip previous request when within threshold', fakeAsync(() => { - const response = generateResponse(null, 204); - http.post = jasmine.createSpy().and.returnValue(of(response)); - progressProducer.next(2999); - durationProducer.next(6001); - service.skipPrevious(false); - expect(http.post).toHaveBeenCalledOnceWith( - SpotifyService.spotifyEndpoints.getPreviousEndpoint(), - {}, - { headers: jasmine.any(HttpHeaders), observe: 'response' } - ); - })); - - it('should set track position to 0 when not within threshold', fakeAsync(() => { - http.post = jasmine.createSpy().and.returnValue(of(null)); - spyOn(service, 'setTrackPosition'); - progressProducer.next(3001); - durationProducer.next(6001); - service.skipPrevious(false); - expect(service.setTrackPosition).toHaveBeenCalledOnceWith(0); - })); - - it('should send skip previous request when duration is less than double the threshold', fakeAsync(() => { - const response = generateResponse(null, 204); - http.post = jasmine.createSpy().and.returnValue(of(response)); - progressProducer.next(3001); - durationProducer.next(5999); - service.skipPrevious(false); - expect(http.post).toHaveBeenCalledOnceWith( - SpotifyService.spotifyEndpoints.getPreviousEndpoint(), - {}, - { headers: jasmine.any(HttpHeaders), observe: 'response' } - ); - })); - - it('should send skip previous request when forced', fakeAsync(() => { - const response = generateResponse(null, 204); - http.post = jasmine.createSpy().and.returnValue(of(response)); - progressProducer.next(2999); - durationProducer.next(6001); - service.skipPrevious(true); - expect(http.post).toHaveBeenCalledOnceWith( - SpotifyService.spotifyEndpoints.getPreviousEndpoint(), - {}, - { headers: jasmine.any(HttpHeaders), observe: 'response' } - ); - })); - - it('should send skip next request', fakeAsync(() => { - const response = generateResponse(null, 204); - http.post = jasmine.createSpy().and.returnValue(of(response)); - service.skipNext(); - expect(http.post).toHaveBeenCalledOnceWith( - SpotifyService.spotifyEndpoints.getNextEndpoint(), - {}, - { headers: jasmine.any(HttpHeaders), observe: 'response' } - ); - })); - - it('should send shuffle on request when isShuffle', fakeAsync(() => { - const response = generateResponse(null, 204); - http.put = jasmine.createSpy().and.returnValue(of(response)); - service.setShuffle(true); - expect(http.put).toHaveBeenCalledOnceWith( - SpotifyService.spotifyEndpoints.getShuffleEndpoint(), - {}, - { - headers: jasmine.any(HttpHeaders), - params: jasmine.any(HttpParams), - observe: 'response' - }); - const spyParams = (http.put as jasmine.Spy).calls.mostRecent().args[2].params as HttpParams; - expect(spyParams.keys().length).toEqual(1); - expect(spyParams.get('state')).toEqual('true'); - expect(store.dispatch).toHaveBeenCalledWith(new SetShuffle(true)); - })); - - it('should send shuffle off request when not isShuffle', fakeAsync(() => { - const response = generateResponse(null, 204); - http.put = jasmine.createSpy().and.returnValue(of(response)); - service.setShuffle(false); - expect(http.put).toHaveBeenCalledOnceWith( - SpotifyService.spotifyEndpoints.getShuffleEndpoint(), - {}, - { - headers: jasmine.any(HttpHeaders), - params: jasmine.any(HttpParams), - observe: 'response' - }); - const spyParams = (http.put as jasmine.Spy).calls.mostRecent().args[2].params as HttpParams; - expect(spyParams.keys().length).toEqual(1); - expect(spyParams.get('state')).toEqual('false'); - expect(store.dispatch).toHaveBeenCalledWith(new SetShuffle(false)); - })); - - it('should toggle shuffle off', fakeAsync(() => { - const response = generateResponse(null, 204); - http.put = jasmine.createSpy().and.returnValue(of(response)); - isShuffleProducer.next(true); - service.toggleShuffle(); - expect(store.dispatch).toHaveBeenCalledWith(new SetShuffle(false)); - })); - - it('should toggle shuffle on', fakeAsync(() => { - const response = generateResponse(null, 204); - http.put = jasmine.createSpy().and.returnValue(of(response)); - isShuffleProducer.next(false); - service.toggleShuffle(); - expect(store.dispatch).toHaveBeenCalledWith(new SetShuffle(true)); - })); - - it('should send volume request', fakeAsync(() => { - const response = generateResponse(null, 204); - http.put = jasmine.createSpy().and.returnValue(of(response)); - service.setVolume(50); - expect(http.put).toHaveBeenCalledOnceWith( - SpotifyService.spotifyEndpoints.getVolumeEndpoint(), - {}, - { - headers: jasmine.any(HttpHeaders), - params: jasmine.any(HttpParams), - observe: 'response' - }); - const spyParams = (http.put as jasmine.Spy).calls.mostRecent().args[2].params as HttpParams; - expect(spyParams.keys().length).toEqual(1); - expect(spyParams.get('volume_percent')).toEqual('50'); - expect(store.dispatch).toHaveBeenCalledWith(new ChangeDeviceVolume(50)); - })); - - it('should set volume to 100 when greater', fakeAsync(() => { - const response = generateResponse(null, 204); - http.put = jasmine.createSpy().and.returnValue(of(response)); - service.setVolume(101); - expect(store.dispatch).toHaveBeenCalledWith(new ChangeDeviceVolume(100)); - })); - - it('should set volume to 0 when negative', fakeAsync(() => { - const response = generateResponse(null, 204); - http.put = jasmine.createSpy().and.returnValue(of(response)); - service.setVolume(-1); - expect(store.dispatch).toHaveBeenCalledWith(new ChangeDeviceVolume(0)); - })); - - it('should send repeat state request', fakeAsync(() => { - const response = generateResponse(null, 204); - http.put = jasmine.createSpy().and.returnValue(of(response)); - service.setRepeatState('context'); - expect(http.put).toHaveBeenCalledOnceWith( - SpotifyService.spotifyEndpoints.getRepeatEndpoint(), - {}, - { - headers: jasmine.any(HttpHeaders), - params: jasmine.any(HttpParams), - observe: 'response' - }); - const spyParams = (http.put as jasmine.Spy).calls.mostRecent().args[2].params as HttpParams; - expect(spyParams.keys().length).toEqual(1); - expect(spyParams.get('state')).toEqual('context'); - expect(store.dispatch).toHaveBeenCalledWith(new ChangeRepeatState('context')); - })); - - it('should send isTrackSaved request', fakeAsync(() => { - const response = generateResponse([true], 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - service.isTrackSaved('test-id'); - expect(http.get).toHaveBeenCalledOnceWith( - SpotifyService.spotifyEndpoints.getCheckSavedEndpoint(), - { - headers: jasmine.any(HttpHeaders), - params: jasmine.any(HttpParams), - observe: 'response' - }); - const spyParams = (http.get as jasmine.Spy).calls.mostRecent().args[1].params as HttpParams; - expect(spyParams.keys().length).toEqual(1); - expect(spyParams.get('ids')).toEqual('test-id'); - expect(store.dispatch).toHaveBeenCalledWith(new SetLiked(true)); - })); - - it('should send add save track request', fakeAsync(() => { - const response = generateResponse(null, 200); - http.put = jasmine.createSpy().and.returnValue(of(response)); - service.setSavedTrack('test-id', true); - expect(http.put).toHaveBeenCalledOnceWith( - SpotifyService.spotifyEndpoints.getSavedTracksEndpoint(), - {}, - { - headers: jasmine.any(HttpHeaders), - params: jasmine.any(HttpParams), - observe: 'response' - }); - const spyParams = (http.put as jasmine.Spy).calls.mostRecent().args[2].params as HttpParams; - expect(spyParams.keys().length).toEqual(1); - expect(spyParams.get('ids')).toEqual('test-id'); - expect(store.dispatch).toHaveBeenCalledWith(new SetLiked(true)); - })); - - it('should send remove save track request', fakeAsync(() => { - const response = generateResponse(null, 200); - http.delete = jasmine.createSpy().and.returnValue(of(response)); - service.setSavedTrack('test-id', false); - expect(http.delete).toHaveBeenCalledOnceWith( - SpotifyService.spotifyEndpoints.getSavedTracksEndpoint(), - { - headers: jasmine.any(HttpHeaders), - params: jasmine.any(HttpParams), - observe: 'response' - }); - const spyParams = (http.delete as jasmine.Spy).calls.mostRecent().args[1].params as HttpParams; - expect(spyParams.keys().length).toEqual(1); - expect(spyParams.get('ids')).toEqual('test-id'); - expect(store.dispatch).toHaveBeenCalledWith(new SetLiked(false)); - })); - - it('should toggle liked off for current track', fakeAsync(() => { - const response = generateResponse(null, 200); - http.delete = jasmine.createSpy().and.returnValue(of(response)); - isLikedProducer.next(true); - service.toggleLiked(); - expect(http.delete).toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith(new SetLiked(false)); - })); - - it('should toggle liked on for current track', fakeAsync(() => { - const response = generateResponse(null, 200); - http.put = jasmine.createSpy().and.returnValue(of(response)); - isLikedProducer.next(false); - service.toggleLiked(); - expect(http.put).toHaveBeenCalled(); - expect(store.dispatch).toHaveBeenCalledWith(new SetLiked(true)); - })); - - it('should send get playlist request', () => { - const playlistResponse = { - ...TEST_PLAYLIST_RESPONSE, - id: 'playlist-new-id' - }; - const response = generateResponse(playlistResponse, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - service.setPlaylist('playlist-new-id'); - expect(http.get).toHaveBeenCalledOnceWith( - `${SpotifyService.spotifyEndpoints.getPlaylistsEndpoint()}/playlist-new-id`, - { headers: jasmine.any(HttpHeaders), observe: 'response' } - ); - expect(store.dispatch).toHaveBeenCalledWith(new ChangePlaylist(parsePlaylist(playlistResponse))); - }); - - it('should set available devices', fakeAsync(() => { - const device2: DeviceResponse = { - ...TEST_DEVICE_RESPONSE, - id: 'test-device-2' - }; - const devicesResponse: MultipleDevicesResponse = { - devices: [ - TEST_DEVICE_RESPONSE, - device2 - ] - }; - const response = generateResponse(devicesResponse, 200); - http.get = jasmine.createSpy().and.returnValue(of(response)); - service.fetchAvailableDevices(); - expect(http.get).toHaveBeenCalledOnceWith( - SpotifyService.spotifyEndpoints.getDevicesEndpoint(), - { headers: jasmine.any(HttpHeaders), observe: 'response' } - ); - expect(store.dispatch).toHaveBeenCalledWith(new SetAvailableDevices([parseDevice(TEST_DEVICE_RESPONSE), parseDevice(device2)])); - })); - - it('should send set device playing request', fakeAsync(() => { - const response = generateResponse(null, 204); - http.put = jasmine.createSpy().and.returnValue(of(response)); - const device: DeviceModel = { - ...parseDevice(TEST_DEVICE_RESPONSE), - id: 'new-device' - }; - service.setDevice(device, true); - expect(http.put).toHaveBeenCalledOnceWith( - SpotifyService.spotifyEndpoints.getPlaybackEndpoint(), - { - device_ids: ['new-device'], - play: true - }, - { headers: jasmine.any(HttpHeaders), observe: 'response' } - ); - expect(store.dispatch).toHaveBeenCalledWith(new ChangeDevice(device)); - })); - - it('should send set device not playing request', fakeAsync(() => { - const response = generateResponse(null, 204); - http.put = jasmine.createSpy().and.returnValue(of(response)); - const device: DeviceModel = { - ...parseDevice(TEST_DEVICE_RESPONSE), - id: 'new-device' - }; - service.setDevice(device, false); - expect(http.put).toHaveBeenCalledOnceWith( - SpotifyService.spotifyEndpoints.getPlaybackEndpoint(), - { - device_ids: ['new-device'], - play: false - }, - { headers: jasmine.any(HttpHeaders), observe: 'response' } - ); - expect(store.dispatch).toHaveBeenCalledWith(new ChangeDevice(device)); - })); - - it('should compare states to true when current state not null and equal', () => { - service['state'] = 'test-state'; - expect(service.compareState('test-state')).toBeTrue(); - }); - - it('should compare states to false when current state not null and not equal', () => { - expect(service.compareState('blah')).toBeFalse(); - }); - - it('should compare states to false when current state is null and passed state not null', () => { - service['state'] = null; - expect(service.compareState('blah')).toBeFalse(); - }); - - it('should compare states to false when current state is null and passed state is null', () => { - service['state'] = null; - expect(service.compareState(null)).toBeFalse(); - }); - - it('should compare states to false when current state is undefined and passed state not null', () => { - service['state'] = undefined; - expect(service.compareState('blah')).toBeFalse(); - }); - - it('should compare states to false when current state is undefined and passed state is undefined', () => { - service['state'] = undefined; - expect(service.compareState(undefined)).toBeFalse(); - }); - - it('should remove all state and auth token values on logout', () => { - service['state'] = 'test-state'; - service.logout(); - expect(store.dispatch).toHaveBeenCalledWith(new SetAuthToken(null)); - expect(service['state']).toBeNull(); - expect(service['codeVerifier']).toBeNull(); - expect(service['authToken']).toBeNull(); - expect(storage.remove).toHaveBeenCalledOnceWith(SpotifyService['STATE_KEY']); - expect(storage.removeAuthToken).toHaveBeenCalledTimes(1); - }); - - it('should get current state if not null', () => { - service['state'] = 'test-state'; - storage.get = jasmine.createSpy(); - expect(service['getState']()).toEqual('test-state'); - expect(storage.get).not.toHaveBeenCalled(); - }); - - it('should set state from storage if exists', () => { - service['state'] = null; - storage.get = jasmine.createSpy().withArgs(SpotifyService['STATE_KEY']).and.returnValue('test-state'); - service['setState'](); - expect(service['state']).toEqual('test-state'); - }); - - it('should generate new state and save to storage if it does not exist in storage', () => { - service['state'] = null; - storage.get = jasmine.createSpy().withArgs('STATE').and.returnValue(null); - service['setState'](); - expect(service['state']).toMatch(`^[A-Za-z0-9]{${SpotifyService['STATE_LENGTH']}}$`); - expect(storage.set).toHaveBeenCalledWith(SpotifyService['STATE_KEY'], service['state']); - }); - - it('should get current codeVerifier if not null', () => { - service['codeVerifier'] = 'test-code-verifier'; - storage.get = jasmine.createSpy(); - expect(service['getCodeVerifier']()).toEqual('test-code-verifier'); - expect(storage.get).not.toHaveBeenCalled(); - }); - - it('should set codeVerifier from storage if exists', () => { - service['codeVerifier'] = null; - storage.get = jasmine.createSpy().withArgs(SpotifyService['CODE_VERIFIER_KEY']).and.returnValue('test-code-verifier'); - service['setCodeVerifier'](); - expect(service['codeVerifier']).toEqual('test-code-verifier'); - }); - - it('should generate new codeVerifier and save to storage if it does not exist in storage', () => { - service['codeVerifier'] = null; - storage.get = jasmine.createSpy().withArgs(SpotifyService['CODE_VERIFIER_KEY']).and.returnValue(null); - service['setCodeVerifier'](); - expect(service['codeVerifier']).toBeTruthy(); - expect(storage.set).toHaveBeenCalledWith(SpotifyService['CODE_VERIFIER_KEY'], service['codeVerifier']); - }); - - it('should reauthenticate when error response is an expired token', fakeAsync(() => { - spyOn(service, 'requestAuthToken').and.returnValue(Promise.resolve(null)); - const expiredToken = { - ...TEST_AUTH_TOKEN, - expiry: new Date(Date.UTC(1999, 1, 1)) - }; - tokenProducer.next(expiredToken); - let apiResponse; - service.checkErrorResponse(generateErrorResponse(401)).then((response) => apiResponse = response); - - flushMicrotasks(); - expect(service.requestAuthToken).toHaveBeenCalledWith(expiredToken.refreshToken, true); - expect(apiResponse).toEqual(SpotifyAPIResponse.ReAuthenticated); - })); - - it('should logout when an error occurs requesting a new auth token after auth token has expired', fakeAsync(() => { - spyOn(service, 'requestAuthToken').and.returnValue(Promise.reject('test-error')); - spyOn(service, 'logout'); - const expiredToken = { - ...TEST_AUTH_TOKEN, - expiry: new Date(Date.UTC(1999, 1, 1)) - }; - tokenProducer.next(expiredToken); - let apiResponse; - service.checkErrorResponse(generateErrorResponse(401)).then((response) => apiResponse = response); - - flushMicrotasks(); - expect(service.requestAuthToken).toHaveBeenCalledWith(expiredToken.refreshToken, true); - expect(console.error).toHaveBeenCalledOnceWith(jasmine.any(String)); - expect(service.logout).toHaveBeenCalled(); - expect(apiResponse).toEqual(SpotifyAPIResponse.Error); - })); - - it('should logout when error response is a bad OAuth request', fakeAsync(() => { - spyOn(service, 'logout'); - let apiResponse; - service.checkErrorResponse(generateErrorResponse(403)).then((response) => apiResponse = response); - - flushMicrotasks(); - expect(service.logout).toHaveBeenCalled(); - expect(apiResponse).toEqual(SpotifyAPIResponse.Error); - })); - - it('should logout and log an error when error response is Spotify rate limits exceeded', fakeAsync(() => { - spyOn(service, 'logout'); - let apiResponse; - service.checkErrorResponse(generateErrorResponse(429)).then((response) => apiResponse = response); - - flushMicrotasks(); - expect(service.logout).toHaveBeenCalled(); - expect(console.error).toHaveBeenCalled(); - expect(apiResponse).toEqual(SpotifyAPIResponse.Error); - })); - - it('should log an error when error response is unknown', fakeAsync(() => { - let apiResponse; - service.checkErrorResponse(generateErrorResponse(404)).then((response) => apiResponse = response); - - flushMicrotasks(); - expect(console.error).toHaveBeenCalled(); - expect(apiResponse).toEqual(SpotifyAPIResponse.Error); - })); -}); diff --git a/src/app/services/spotify/spotify.service.ts b/src/app/services/spotify/spotify.service.ts deleted file mode 100644 index 2cb1919..0000000 --- a/src/app/services/spotify/spotify.service.ts +++ /dev/null @@ -1,688 +0,0 @@ -import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams, HttpResponse } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { Select, Store } from '@ngxs/store'; -import { BehaviorSubject, Observable, of } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { AppConfig } from '../../app.config'; -import { SetAuthToken } from '../../core/auth/auth.actions'; -import { AuthToken } from '../../core/auth/auth.model'; -import { AuthState } from '../../core/auth/auth.state'; -import { - ChangeAlbum, - ChangeDevice, - ChangeDeviceIsActive, - ChangeDeviceVolume, - ChangePlaylist, - ChangeRepeatState, - ChangeTrack, - SetAvailableDevices, - SetIdle, - SetLiked, - SetPlaying, - SetProgress, - SetShuffle -} from '../../core/playback/playback.actions'; -import { AlbumModel, DeviceModel, PlaylistModel, TrackModel } from '../../core/playback/playback.model'; -import { PlaybackState } from '../../core/playback/playback.state'; -import { - generateCodeChallenge, - generateCodeVerifier, - generateRandomString, - getIdFromSpotifyUri, - parseAlbum, - parseDevice, - parsePlaylist, - parseTrack, -} from '../../core/util'; -import { CurrentPlaybackResponse } from '../../models/current-playback.model'; -import { MultipleDevicesResponse } from '../../models/device.model'; -import { PlaylistResponse } from '../../models/playlist.model'; -import { TokenResponse } from '../../models/token.model'; -import { StorageService } from '../storage/storage.service'; - -export enum SpotifyAPIResponse { - Success, - NoPlayback, - ReAuthenticated, - Error -} - -export enum AuthType { - PKCE, - Secret, - ThirdParty -} - -@Injectable({providedIn: 'root'}) -export class SpotifyService { - public static initialized = false; - private static clientId: string; - private static clientSecret: string = null; - private static scopes: string = null; - private static tokenUrl: string = null; - private static authType: AuthType; - public static spotifyApiUrl: string; - public static spotifyEndpoints: SpotifyEndpoints; - private static redirectUri: string; - private static showAuthDialog = true; - - public static readonly PREVIOUS_VOLUME = 'PREVIOUS_VOLUME'; - private static readonly ACCOUNTS_ENDPOINT = 'https://accounts.spotify.com'; - private static readonly AUTH_ENDPOINT = `${SpotifyService.ACCOUNTS_ENDPOINT}/authorize`; - public static readonly TOKEN_ENDPOINT = `${SpotifyService.ACCOUNTS_ENDPOINT}/api/token`; - private static readonly STATE_KEY = 'STATE'; - private static readonly CODE_VERIFIER_KEY = 'CODE_VERIFIER'; - private static readonly STATE_LENGTH = 40; - private static readonly SKIP_PREVIOUS_THRESHOLD = 3000; // ms - - @Select(AuthState.token) private authToken$: BehaviorSubject; - private authToken: AuthToken = null; - private state: string = null; - private codeVerifier = null; - - @Select(PlaybackState.track) private track$: BehaviorSubject; - private track: TrackModel = null; - - @Select(PlaybackState.album) private album$: BehaviorSubject; - private album: AlbumModel = null; - - @Select(PlaybackState.playlist) private playlist$: BehaviorSubject; - private playlist: PlaylistModel = null; - - @Select(PlaybackState.device) private device$: BehaviorSubject; - private device: DeviceModel = null; - - @Select(PlaybackState.isPlaying) private isPlaying$: BehaviorSubject; - private isPlaying = false; - - @Select(PlaybackState.isShuffle) private isShuffle$: BehaviorSubject; - private isShuffle = false; - - @Select(PlaybackState.progress) private progress$: BehaviorSubject; - private progress = 0; - - @Select(PlaybackState.duration) private duration$: BehaviorSubject; - private duration = 0; - - @Select(PlaybackState.isLiked) private isLiked$: BehaviorSubject; - private isLiked = false; - - static initialize(): boolean { - this.initialized = true; - try { - this.clientId = AppConfig.settings.auth.clientId; - if (!this.clientId) { - console.error('No Spotify API Client ID provided'); - this.initialized = false; - } - - this.tokenUrl = AppConfig.settings.auth.tokenUrl; - this.clientSecret = AppConfig.settings.auth.clientSecret; - this.scopes = AppConfig.settings.auth.scopes; - this.showAuthDialog = AppConfig.settings.auth.showDialog; - - if (AppConfig.settings.auth.forcePkce || (!this.tokenUrl && !this.clientSecret)) { - this.authType = AuthType.PKCE; - } else if (this.tokenUrl) { - this.authType = AuthType.ThirdParty; - } else { - this.authType = AuthType.Secret; - } - - this.spotifyApiUrl = AppConfig.settings.env.spotifyApiUrl; - if (!this.spotifyApiUrl) { - console.error('No Spotify API URL configured'); - this.initialized = false; - } else { - this.spotifyEndpoints = new SpotifyEndpoints(this.spotifyApiUrl); - } - - if (AppConfig.settings.env.domain) { - this.redirectUri = encodeURI(AppConfig.settings.env.domain + '/callback'); - } else { - console.error('No domain set for Spotify OAuth callback URL'); - this.initialized = false; - } - } catch (error) { - console.error(`Failed to initialize spotify service: ${error}`); - this.initialized = false; - } - return this.initialized; - } - - constructor(private http: HttpClient, private storage: StorageService, private store: Store) { - this.setState(); - this.setCodeVerifier(); - } - - initSubscriptions(): void { - this.authToken$.subscribe((authToken) => this.authToken = authToken); - this.track$.subscribe((track) => this.track = track); - this.album$.subscribe((album) => this.album = album); - this.playlist$.subscribe((playlist) => this.playlist = playlist); - this.device$.subscribe((device) => this.device = device); - this.isPlaying$.subscribe((isPlaying) => this.isPlaying = isPlaying); - this.isShuffle$.subscribe((isShuffle) => this.isShuffle = isShuffle); - this.progress$.subscribe((progress) => this.progress = progress); - this.duration$.subscribe((duration) => this.duration = duration); - this.isLiked$.subscribe((isLiked) => this.isLiked = isLiked); - } - - requestAuthToken(code: string, isRefresh: boolean): Promise { - let headers = new HttpHeaders().set( - 'Content-Type', 'application/x-www-form-urlencoded' - ); - // Set Authorization header if needed - if (SpotifyService.authType === AuthType.Secret) { - headers = headers.set( - 'Authorization', `Basic ${new Buffer(`${SpotifyService.clientId}:${SpotifyService.clientSecret}`).toString('base64')}` - ); - } - - const body = new URLSearchParams({ - grant_type: (!isRefresh ? 'authorization_code' : 'refresh_token'), - client_id: SpotifyService.clientId, - ...(!isRefresh) && { - code, - redirect_uri: SpotifyService.redirectUri, - code_verifier: this.codeVerifier - }, - ...(isRefresh) && { - refresh_token: code - } - }); - - return new Promise((resolve, reject) => { - const endpoint = SpotifyService.authType === AuthType.ThirdParty ? SpotifyService.tokenUrl : SpotifyService.TOKEN_ENDPOINT; - this.http.post(endpoint, body, {headers, observe: 'response'}) - .subscribe((response) => { - const token = response.body; - let expiry: Date; - if (SpotifyService.authType === AuthType.ThirdParty && token.expiry) { - expiry = new Date(token.expiry); - } else { - expiry = new Date(); - expiry.setSeconds(expiry.getSeconds() + token.expires_in); - } - - const authToken: AuthToken = { - accessToken: token.access_token, - tokenType: token.token_type, - expiry, - scope: token.scope, - refreshToken: token.refresh_token - }; - this.store.dispatch(new SetAuthToken(authToken)); - resolve(); - }, - (error) => { - const errMsg = `Error requesting token: ${JSON.stringify(error)}`; - console.error(errMsg); - reject(errMsg); - }); - }); - } - - getAuthorizeRequestUrl(): Promise { - const args = new URLSearchParams({ - response_type: 'code', - client_id: SpotifyService.clientId, - scope: SpotifyService.scopes, - redirect_uri: SpotifyService.redirectUri, - state: this.getState(), - show_dialog: `${SpotifyService.showAuthDialog}` - }); - if (SpotifyService.authType === AuthType.PKCE) { - return generateCodeChallenge(this.getCodeVerifier()).then((codeChallenge) => { - args.set('code_challenge_method', 'S256'); - args.set('code_challenge', codeChallenge); - return `${SpotifyService.AUTH_ENDPOINT}?${args}`; - }); - } else { - return Promise.resolve(`${SpotifyService.AUTH_ENDPOINT}?${args}`); - } - } - - pollCurrentPlayback(pollingInterval: number): void { - this.http.get(SpotifyService.spotifyEndpoints.getPlaybackEndpoint(), {observe: 'response'}) - .pipe( - map((res: HttpResponse) => { - const apiResponse = this.checkResponseWithPlayback(res, true, true); - if (apiResponse === SpotifyAPIResponse.Success) { - return res.body; - } - else if (apiResponse === SpotifyAPIResponse.NoPlayback) { - // Playback not available or active - return null; - } - return null; - })).subscribe((playback) => { - // if not locked? - if (playback && playback.item) { - const track = playback.item; - - // Check for new track - if (track && track.id !== this.track.id) { - this.store.dispatch(new ChangeTrack(parseTrack(track))); - // Check if new track is saved - this.isTrackSaved(track.id); - } - - // Check for new album - if (track && track.album && track.album.id !== this.album.id) { - this.store.dispatch(new ChangeAlbum(parseAlbum(track.album))); - } - - // Check for new playlist - if (playback.context && playback.context.type && playback.context.type === 'playlist') { - const playlistId = getIdFromSpotifyUri(playback.context.uri); - if (!this.playlist || this.playlist.id !== playlistId) { - // Retrieve new playlist - this.setPlaylist(playlistId); - } - } else if (this.playlist) { - // No longer playing a playlist, update if we previously were - this.store.dispatch(new ChangePlaylist(null)); - } - - // Check if volume was muted externally to save previous value - if (playback.device && playback.device.volume_percent === 0 && this.device.volume > 0) { - this.storage.set(SpotifyService.PREVIOUS_VOLUME, this.device.volume.toString()); - } - - // Get device changes - if (playback.device) { - // Check for new device - if (playback.device && playback.device.id !== this.device.id) { - this.store.dispatch(new ChangeDevice(parseDevice(playback.device))); - } - - // Update which device is active - this.store.dispatch(new ChangeDeviceIsActive(playback.device.is_active)); - this.store.dispatch(new ChangeDeviceVolume(playback.device.volume_percent)); - } - - // Update remaining playback values - this.store.dispatch(new SetProgress(playback.progress_ms)); - this.store.dispatch(new SetPlaying(playback.is_playing)); - this.store.dispatch(new SetShuffle(playback.shuffle_state)); - this.store.dispatch(new ChangeRepeatState(playback.repeat_state)); - this.store.dispatch(new SetIdle(false)); - } else { - this.store.dispatch(new SetIdle(true)); - } - // else locked - // update progress to current + pollingInterval - }); - } - - setTrackPosition(position: number): void { - if (position > this.duration) { - position = this.duration; - } - else if (position < 0) { - position = 0; - } - - let requestParams = new HttpParams(); - requestParams = requestParams.append('position_ms', position.toString()); - - this.http.put(SpotifyService.spotifyEndpoints.getSeekEndpoint(), {}, { - headers: this.getHeaders(), - params: requestParams, - observe: 'response' - }).subscribe((res) => { - const apiResponse = this.checkResponse(res, false); - if (apiResponse === SpotifyAPIResponse.Success) { - this.store.dispatch(new SetProgress(position)); - } - }); - } - - setPlaying(isPlaying: boolean): void { - const endpoint = isPlaying ? SpotifyService.spotifyEndpoints.getPlayEndpoint() : SpotifyService.spotifyEndpoints.getPauseEndpoint(); - // TODO: this has optional parameters for JSON body - this.http.put(endpoint, {}, {headers: this.getHeaders(), observe: 'response'}) - .subscribe((res) => { - const apiResponse = this.checkResponse(res, false); - if (apiResponse === SpotifyAPIResponse.Success) { - this.store.dispatch(new SetPlaying(isPlaying)); - } - }); - } - - togglePlaying(): void { - this.setPlaying(!this.isPlaying); - } - - skipPrevious(forcePrevious: boolean): void { - // Check if we should skip to previous track or start of current - if (!forcePrevious && this.progress > SpotifyService.SKIP_PREVIOUS_THRESHOLD - && !((SpotifyService.SKIP_PREVIOUS_THRESHOLD * 2) >= this.duration)) { - this.setTrackPosition(0); - } else { - this.http.post(SpotifyService.spotifyEndpoints.getPreviousEndpoint(), {}, - {headers: this.getHeaders(), observe: 'response'}).subscribe(); - } - } - - skipNext(): void { - this.http.post(SpotifyService.spotifyEndpoints.getNextEndpoint(), {}, {headers: this.getHeaders(), observe: 'response'}).subscribe(); - } - - setShuffle(isShuffle: boolean): void { - let requestParams = new HttpParams(); - requestParams = requestParams.append('state', (isShuffle ? 'true' : 'false')); - - this.http.put(SpotifyService.spotifyEndpoints.getShuffleEndpoint(), {}, { - headers: this.getHeaders(), - params: requestParams, - observe: 'response' - }).subscribe((res) => { - const apiResponse = this.checkResponse(res, false); - if (apiResponse === SpotifyAPIResponse.Success) { - this.store.dispatch(new SetShuffle(isShuffle)); - } - }); - } - - toggleShuffle(): void { - this.setShuffle(!this.isShuffle); - } - - setVolume(volume: number): void { - if (volume > 100) { - volume = 100; - } - else if (volume < 0) { - volume = 0; - } - - let requestParams = new HttpParams(); - requestParams = requestParams.append('volume_percent', volume.toString()); - - this.http.put(SpotifyService.spotifyEndpoints.getVolumeEndpoint(), {}, { - headers: this.getHeaders(), - params: requestParams, - observe: 'response' - }).subscribe((res) => { - const apiResponse = this.checkResponse(res, false); - if (apiResponse === SpotifyAPIResponse.Success) { - this.store.dispatch(new ChangeDeviceVolume(volume)); - } - }); - } - - setRepeatState(repeatState: string): void { - let requestParams = new HttpParams(); - requestParams = requestParams.append('state', repeatState); - - this.http.put(SpotifyService.spotifyEndpoints.getRepeatEndpoint(), {}, { - headers: this.getHeaders(), - params: requestParams, - observe: 'response' - }).subscribe((res) => { - const apiResponse = this.checkResponse(res, false); - if (apiResponse === SpotifyAPIResponse.Success) { - this.store.dispatch(new ChangeRepeatState(repeatState)); - } - }); - } - - isTrackSaved(id: string): void { - let requestParams = new HttpParams(); - requestParams = requestParams.append('ids', id); - - this.http.get(SpotifyService.spotifyEndpoints.getCheckSavedEndpoint(), { - headers: this.getHeaders(), - params: requestParams, - observe: 'response' - }).subscribe((res) => { - const apiResponse = this.checkResponse(res, true); - if (apiResponse === SpotifyAPIResponse.Success) { - if (res.body && res.body.length > 0) { - this.store.dispatch(new SetLiked(res.body[0])); - } - } - }); - } - - setSavedTrack(id: string, isSaved: boolean): void { - let requestParams = new HttpParams(); - requestParams = requestParams.append('ids', id); - - const savedEndpoint = isSaved ? - this.http.put(SpotifyService.spotifyEndpoints.getSavedTracksEndpoint(), {}, { - headers: this.getHeaders(), - params: requestParams, - observe: 'response' - }) : - this.http.delete(SpotifyService.spotifyEndpoints.getSavedTracksEndpoint(), { - headers: this.getHeaders(), - params: requestParams, - observe: 'response' - }); - - savedEndpoint.subscribe((res) => { - const apiResponse = this.checkResponse(res, true); - if (apiResponse === SpotifyAPIResponse.Success) { - this.store.dispatch(new SetLiked(isSaved)); - } - }); - } - - toggleLiked(): void { - this.setSavedTrack(this.track.id, !this.isLiked); - } - - setPlaylist(id: string): void { - if (id === null) { - this.store.dispatch(new ChangePlaylist(null)); - } else { - this.http.get(`${SpotifyService.spotifyEndpoints.getPlaylistsEndpoint()}/${id}`, - {headers: this.getHeaders(), observe: 'response'}) - .subscribe((res) => { - const apiResponse = this.checkResponse(res, true); - if (apiResponse === SpotifyAPIResponse.Success) { - this.store.dispatch(new ChangePlaylist(parsePlaylist(res.body))); - } - }); - } - } - - fetchAvailableDevices(): void { - this.http.get(SpotifyService.spotifyEndpoints.getDevicesEndpoint(), {headers: this.getHeaders(), observe: 'response'}) - .subscribe((res) => { - const apiResponse = this.checkResponse(res, true); - if (apiResponse === SpotifyAPIResponse.Success) { - const devices = res.body.devices.map(device => parseDevice(device)); - this.store.dispatch(new SetAvailableDevices(devices)); - } - }); - } - - setDevice(device: DeviceModel, isPlaying: boolean): void { - this.http.put(SpotifyService.spotifyEndpoints.getPlaybackEndpoint(), { - device_ids: [device.id], - play: isPlaying - }, {headers: this.getHeaders(), observe: 'response'}) - .subscribe((res) => { - const apiResponse = this.checkResponse(res, false); - if (apiResponse === SpotifyAPIResponse.Success) { - this.store.dispatch(new ChangeDevice(device)); - } - }); - } - - compareState(state: string): boolean { - return !!this.state && this.state === state; - } - - logout(): void { - this.store.dispatch(new SetAuthToken(null)); - this.state = null; - this.codeVerifier = null; - this.authToken = null; - this.storage.remove(SpotifyService.STATE_KEY); - this.storage.removeAuthToken(); - } - - /** - * Checks a Spotify API error response against common error response codes - * @param res - */ - checkErrorResponse(res: HttpErrorResponse): Promise { - if (res.status === 401) { - // Expired token - return this.requestAuthToken(this.authToken.refreshToken, true) - .then(() => { - return SpotifyAPIResponse.ReAuthenticated; - }) - .catch((reason) => { - console.error(`Spotify request failed to reauthenticate after token expiry: ${reason}`); - this.logout(); - return SpotifyAPIResponse.Error; - }); - } - else if (res.status === 403) { - // Bad OAuth request - this.logout(); - return Promise.resolve(SpotifyAPIResponse.Error); - } - else if (res.status === 429) { - console.error('Spotify rate limits exceeded'); - this.logout(); - return Promise.resolve(SpotifyAPIResponse.Error); - } else { - console.error(`Unexpected response ${res.status}: ${res.statusText}`); - return Promise.resolve(SpotifyAPIResponse.Error); - } - } - - /** - * Checks a Spotify API response against common response codes - * @param res - * @param hasResponse - * @private - */ - private checkResponse(res: HttpResponse, hasResponse: boolean): SpotifyAPIResponse { - return this.checkResponseWithPlayback(res, hasResponse, false); - } - - private checkResponseWithPlayback(res: HttpResponse, hasResponse: boolean, isPlayback: boolean): SpotifyAPIResponse { - if (res.status === 200 && (hasResponse || isPlayback)) { - return SpotifyAPIResponse.Success; - } - else if (res.status === 204 && (!hasResponse || isPlayback)) { - return isPlayback ? SpotifyAPIResponse.NoPlayback : SpotifyAPIResponse.Success; - } - } - - private getState(): string { - if (!this.state) { - this.setState(); - } - return this.state; - } - - private setState(): void { - this.state = this.storage.get(SpotifyService.STATE_KEY); - if (this.state === null) { - this.state = generateRandomString(SpotifyService.STATE_LENGTH); - this.storage.set(SpotifyService.STATE_KEY, this.state); - } - } - - private getCodeVerifier(): string { - if (!this.codeVerifier) { - this.setCodeVerifier(); - } - return this.codeVerifier; - } - - private setCodeVerifier(): void { - this.codeVerifier = this.storage.get(SpotifyService.CODE_VERIFIER_KEY); - if (!this.codeVerifier) { - this.codeVerifier = generateCodeVerifier(43, 128); - this.storage.set(SpotifyService.CODE_VERIFIER_KEY, this.codeVerifier); - } - } - - private getHeaders(): HttpHeaders { - if (this.authToken) { - return new HttpHeaders({ - Authorization: `${this.authToken.tokenType} ${this.authToken.accessToken}` - }); - } - console.error('No auth token present'); - return null; - } - - getAuthorizationHeader(): string { - if (this.authToken) { - return `${this.authToken.tokenType} ${this.authToken.accessToken}`; - } - console.error('No auth token present'); - return null; - } -} - -class SpotifyEndpoints { - private readonly apiUrl: string; - - constructor(apiUrl: string) { - this.apiUrl = apiUrl; - } - - getPlaybackEndpoint(): string { - return this.apiUrl + '/me/player'; - } - - getPlayEndpoint(): string { - return this.getPlaybackEndpoint() + '/play'; - } - - getPauseEndpoint(): string { - return this.getPlaybackEndpoint() + '/pause'; - } - - getNextEndpoint(): string { - return this.getPlaybackEndpoint() + '/next'; - } - - getPreviousEndpoint(): string { - return this.getPlaybackEndpoint() + '/previous'; - } - - getVolumeEndpoint(): string { - return this.getPlaybackEndpoint() + '/volume'; - } - - getShuffleEndpoint(): string { - return this.getPlaybackEndpoint() + '/shuffle'; - } - - getRepeatEndpoint(): string { - return this.getPlaybackEndpoint() + '/repeat'; - } - - getSeekEndpoint(): string { - return this.getPlaybackEndpoint() + '/seek'; - } - - getDevicesEndpoint(): string { - return this.getPlaybackEndpoint() + '/devices'; - } - - getSavedTracksEndpoint(): string { - return this.apiUrl + '/me/tracks'; - } - - getCheckSavedEndpoint(): string { - return this.getSavedTracksEndpoint() + '/contains'; - } - - getPlaylistsEndpoint(): string { - return this.apiUrl + '/playlists'; - } -} diff --git a/src/app/services/storage/storage.service.ts b/src/app/services/storage/storage.service.ts index 4efaae5..a9272f6 100644 --- a/src/app/services/storage/storage.service.ts +++ b/src/app/services/storage/storage.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@angular/core'; import { AUTH_STATE_NAME } from '../../core/auth/auth.model'; +export const PREVIOUS_VOLUME = 'PREVIOUS_VOLUME'; + @Injectable({ providedIn: 'root' }) diff --git a/src/assets/styles/_colors.scss b/src/assets/styles/_colors.scss index 8f3f7d1..9576aaf 100644 --- a/src/assets/styles/_colors.scss +++ b/src/assets/styles/_colors.scss @@ -1,11 +1,13 @@ @use '@angular/material' as mat; @use 'themes'; -$primary-light: mat.get-color-from-palette(themes.$light-primary); -$primary-dark: mat.get-color-from-palette(themes.$dark-primary); +:root { + --color-primary-light: #{mat.get-color-from-palette(themes.$light-primary)}; + --color-primary-dark: #{mat.get-color-from-palette(themes.$dark-primary)}; -$accent-light: mat.get-color-from-palette(themes.$light-accent); -$accent-dark: mat.get-color-from-palette(themes.$dark-accent); + --color-accent-light: #{mat.get-color-from-palette(themes.$light-accent)}; + --color-accent-dark: #{mat.get-color-from-palette(themes.$dark-accent)}; -$warn-light: mat.get-color-from-palette(themes.$light-warn); -$warn-dark: mat.get-color-from-palette(themes.$dark-warn); + --color-warn-light: #{mat.get-color-from-palette(themes.$light-warn)}; + --color-warn-dark: #{mat.get-color-from-palette(themes.$dark-warn)}; +} diff --git a/src/environments/create-build-date.ts b/src/environments/create-build-date.ts new file mode 100644 index 0000000..e6e2c81 --- /dev/null +++ b/src/environments/create-build-date.ts @@ -0,0 +1,8 @@ +const fs = require('fs'); +const path = require('path'); + +const buildDatePath = path.join(__dirname, 'build-date.json'); + +fs.writeFileSync(buildDatePath, JSON.stringify({ + timestamp: Date.now() +}, null, 2)); diff --git a/src/environments/environment.staging.ts b/src/environments/environment.staging.ts new file mode 100644 index 0000000..9f54f28 --- /dev/null +++ b/src/environments/environment.staging.ts @@ -0,0 +1,19 @@ +const buildDateJson = require('./build-date.json'); +let buildDate = null; +if (buildDateJson) { + buildDate = new Date(buildDateJson.timestamp); +} + +let dateString = ''; +if (buildDate && !isNaN(Number(buildDate))) { + const year = buildDate.getFullYear(); + const month = (buildDate.getMonth() + 1).toString(10).padStart(2, '0'); + const day = buildDate.getDate().toString(10).padStart(2, '0'); + dateString = `${year}${month}${day}`; +} + +export const environment = { + production: true, + name: 'staging', + version: `${require('../../package.json').version}-rc${dateString}` +}; diff --git a/src/styles.scss b/src/styles.scss index c1dec0c..c83f7e7 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -2,6 +2,7 @@ @use 'sass:map'; @use 'assets/styles/themes'; @use 'assets/styles/themes_dynamic'; +@use 'assets/styles/colors'; @use 'src/app/components';