diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index d161d7305..978231c91 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -15,6 +15,7 @@ import { BaseSearchService } from './services/search/base-search.service'; import { BaseTranslatorService } from './services/translator/base-translator.service'; import { BaseTrayService } from './services/tray/base-tray.service'; import { IntegrationTestRunner } from './testing/integration-test-runner'; +import { BaseEventListenerService } from './services/event-listener/base-event-listener.service'; describe('AppComponent', () => { let navigationServiceMock: IMock; @@ -26,6 +27,7 @@ describe('AppComponent', () => { let trayServiceMock: IMock; let searchServiceMock: IMock; let mediaSessionServiceMock: IMock; + let eventListenerServiceMock: IMock; let addToPlaylistMenuMock: IMock; let desktopMock: IMock; @@ -48,6 +50,7 @@ describe('AppComponent', () => { trayServiceMock.object, searchServiceMock.object, mediaSessionServiceMock.object, + eventListenerServiceMock.object, addToPlaylistMenuMock.object, desktopMock.object, loggerMock.object, @@ -65,6 +68,7 @@ describe('AppComponent', () => { trayServiceMock = Mock.ofType(); searchServiceMock = Mock.ofType(); mediaSessionServiceMock = Mock.ofType(); + eventListenerServiceMock = Mock.ofType(); addToPlaylistMenuMock = Mock.ofType(); desktopMock = Mock.ofType(); loggerMock = Mock.ofType(); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index e459b9ad8..ee54cb7fd 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -4,7 +4,6 @@ import log from 'electron-log'; import * as path from 'path'; import { Subscription } from 'rxjs'; import { ProductInformation } from './common/application/product-information'; -import { BaseDesktop } from './common/io/base-desktop'; import { Logger } from './common/logger'; import { PromiseUtils } from './common/utils/promise-utils'; import { AddToPlaylistMenu } from './components/add-to-playlist-menu'; @@ -19,6 +18,9 @@ import { BaseTranslatorService } from './services/translator/base-translator.ser import { BaseTrayService } from './services/tray/base-tray.service'; import { IntegrationTestRunner } from './testing/integration-test-runner'; import { AppConfig } from '../environments/environment'; +import { BaseEventListenerService } from './services/event-listener/base-event-listener.service'; +import { BaseDesktop } from './common/io/base-desktop'; + @Component({ selector: 'app-root', templateUrl: './app.component.html', @@ -37,6 +39,7 @@ export class AppComponent implements OnInit { private trayService: BaseTrayService, private searchService: BaseSearchService, private mediaSessionService: BaseMediaSessionService, + private eventListenerService: BaseEventListenerService, private addToPlaylistMenu: AddToPlaylistMenu, private desktop: BaseDesktop, private logger: Logger, @@ -83,7 +86,7 @@ export class AppComponent implements OnInit { this.trayService.updateTrayContextMenu(); this.mediaSessionService.initialize(); this.scrobblingService.initialize(); - + this.eventListenerService.listenToEvents(); await this.navigationService.navigateToLoadingAsync(); } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 4cf327225..4f4626b59 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -56,13 +56,11 @@ import { Hacks } from './common/hacks'; import { ImageProcessor } from './common/image-processor'; import { Application } from './common/io/application'; import { BaseApplication } from './common/io/base-application'; -import { BaseDesktop } from './common/io/base-desktop'; import { BaseFileAccess } from './common/io/base-file-access'; import { BaseIpcProxy } from './common/io/base-ipc-proxy'; import { BaseMediaSessionProxy } from './common/io/base-media-session-proxy'; import { BaseTranslateServiceProxy } from './common/io/base-translate-service-proxy'; import { DateProxy } from './common/io/date-proxy'; -import { Desktop } from './common/io/desktop'; import { DocumentProxy } from './common/io/document-proxy'; import { FileAccess } from './common/io/file-access'; import { IpcProxy } from './common/io/ipc-proxy'; @@ -304,6 +302,10 @@ import { IntegrationTestRunner } from './testing/integration-test-runner'; import { AZLyricsApi } from './common/api/lyrics/a-z-lyrics-api'; import { WebSearchLyricsApi } from './common/api/lyrics/web-search-lyrics/web-search-lyrics-api'; import { WebSearchApi } from './common/api/lyrics/web-search-lyrics/web-search-api'; +import { EventListenerService } from './services/event-listener/event-listener.service'; +import { BaseEventListenerService } from './services/event-listener/base-event-listener.service'; +import { BaseDesktop } from './common/io/base-desktop'; +import { Desktop } from './common/io/desktop'; export function HttpLoaderFactory(http: HttpClient): TranslateHttpLoader { return new TranslateHttpLoader(http, './assets/i18n/', '.json'); @@ -615,6 +617,7 @@ export function appInitializerFactory(translate: TranslateService, injector: Inj { provide: BaseNowPlayingNavigationService, useClass: NowPlayingNavigationService }, { provide: BaseArtistInformationService, useClass: ArtistInformationService }, { provide: BaseLyricsService, useClass: LyricsService }, + { provide: BaseEventListenerService, useClass: EventListenerService }, { provide: ErrorHandler, useClass: GlobalErrorHandler, diff --git a/src/app/common/io/application.ts b/src/app/common/io/application.ts index 34cd39c2c..bba4118ec 100644 --- a/src/app/common/io/application.ts +++ b/src/app/common/io/application.ts @@ -1,22 +1,10 @@ import { Injectable } from '@angular/core'; import * as remote from '@electron/remote'; -import { ipcRenderer } from 'electron'; -import { Observable, Subject } from 'rxjs'; import { BaseApplication } from './base-application'; import { WindowSize } from './window-size'; @Injectable() export class Application implements BaseApplication { - private argumentsReceived: Subject = new Subject(); - - public constructor() { - ipcRenderer.on('arguments-received', (event, argv: string[] | undefined) => { - this.argumentsReceived.next(argv); - }); - } - - public argumentsReceived$: Observable = this.argumentsReceived.asObservable(); - public getGlobal(name: string): unknown { return remote.getGlobal(name); } diff --git a/src/app/common/io/base-application.ts b/src/app/common/io/base-application.ts index dd7e040a5..6afb6e4f5 100644 --- a/src/app/common/io/base-application.ts +++ b/src/app/common/io/base-application.ts @@ -2,7 +2,6 @@ import { BrowserWindow } from 'electron'; import { Observable } from 'rxjs'; import { WindowSize } from './window-size'; export abstract class BaseApplication { - public abstract argumentsReceived$: Observable; public abstract getGlobal(name: string): unknown; public abstract getCurrentWindow(): BrowserWindow; public abstract getWindowSize(): WindowSize; diff --git a/src/app/services/event-listener/base-event-listener.service.ts b/src/app/services/event-listener/base-event-listener.service.ts new file mode 100644 index 000000000..003183551 --- /dev/null +++ b/src/app/services/event-listener/base-event-listener.service.ts @@ -0,0 +1,7 @@ +import { Observable } from 'rxjs'; + +export abstract class BaseEventListenerService { + public abstract argumentsReceived$: Observable; + public abstract filesDropped$: Observable; + public abstract listenToEvents(): void; +} diff --git a/src/app/services/event-listener/event-listener.service.spec.ts b/src/app/services/event-listener/event-listener.service.spec.ts new file mode 100644 index 000000000..e247ef091 --- /dev/null +++ b/src/app/services/event-listener/event-listener.service.spec.ts @@ -0,0 +1,20 @@ +import { BaseEventListenerService } from './base-event-listener.service'; +import { EventListenerService } from './event-listener.service'; + +describe('EventListenerService', () => { + function createSut(): BaseEventListenerService { + return new EventListenerService(); + } + + describe('constructor', () => { + it('should create', () => { + // Arrange + + // Act + const sut: BaseEventListenerService = createSut(); + + // Assert + expect(sut).toBeDefined(); + }); + }); +}); diff --git a/src/app/services/event-listener/event-listener.service.ts b/src/app/services/event-listener/event-listener.service.ts new file mode 100644 index 000000000..6ea612739 --- /dev/null +++ b/src/app/services/event-listener/event-listener.service.ts @@ -0,0 +1,39 @@ +import { ipcRenderer } from 'electron'; +import { Observable, Subject } from 'rxjs'; +import { BaseEventListenerService } from './base-event-listener.service'; + +export class EventListenerService implements BaseEventListenerService { + private argumentsReceived: Subject = new Subject(); + private filesDropped: Subject = new Subject(); + + public argumentsReceived$: Observable = this.argumentsReceived.asObservable(); + public filesDropped$: Observable = this.filesDropped.asObservable(); + + public listenToEvents(): void { + ipcRenderer.on('arguments-received', (event, argv: string[] | undefined) => { + this.argumentsReceived.next(argv); + }); + + document.addEventListener('dragover', (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + + document.addEventListener('drop', (event) => { + event.preventDefault(); + event.stopPropagation(); + + if (event.dataTransfer == undefined) { + return; + } + + const filePaths: string[] = []; + + for (const f of event.dataTransfer.files) { + filePaths.push(f.path); + } + + this.filesDropped.next(filePaths); + }); + } +} diff --git a/src/app/services/file/file.service.spec.ts b/src/app/services/file/file.service.spec.ts index 2e52e1161..d36cf6434 100644 --- a/src/app/services/file/file.service.spec.ts +++ b/src/app/services/file/file.service.spec.ts @@ -11,9 +11,11 @@ import { TrackModelFactory } from '../track/track-model-factory'; import { BaseTranslatorService } from '../translator/base-translator.service'; import { BaseFileService } from './base-file.service'; import { FileService } from './file.service'; +import { BaseEventListenerService } from '../event-listener/base-event-listener.service'; describe('FileService', () => { let playbackServiceMock: IMock; + let eventListenerServiceMock: IMock; let trackModelFactoryMock: IMock; let fileValidatorMock: IMock; let applicationMock: IMock; @@ -25,20 +27,25 @@ describe('FileService', () => { let argumentsReceivedMock: Subject; let argumentsReceivedMock$: Observable; + let filesDroppedMock: Subject; + let filesDroppedMock$: Observable; + const flushPromises = () => new Promise(process.nextTick); function createService(): BaseFileService { return new FileService( playbackServiceMock.object, + eventListenerServiceMock.object, trackModelFactoryMock.object, applicationMock.object, fileValidatorMock.object, - loggerMock.object + loggerMock.object, ); } beforeEach(() => { playbackServiceMock = Mock.ofType(); + eventListenerServiceMock = Mock.ofType(); trackModelFactoryMock = Mock.ofType(); fileValidatorMock = Mock.ofType(); applicationMock = Mock.ofType(); @@ -64,7 +71,11 @@ describe('FileService', () => { argumentsReceivedMock = new Subject(); argumentsReceivedMock$ = argumentsReceivedMock.asObservable(); - applicationMock.setup((x) => x.argumentsReceived$).returns(() => argumentsReceivedMock$); + filesDroppedMock = new Subject(); + filesDroppedMock$ = filesDroppedMock.asObservable(); + + eventListenerServiceMock.setup((x) => x.argumentsReceived$).returns(() => argumentsReceivedMock$); + eventListenerServiceMock.setup((x) => x.filesDropped$).returns(() => filesDroppedMock$); }); describe('constructor', () => { @@ -92,10 +103,10 @@ describe('FileService', () => { x.enqueueAndPlayTracks( It.is( (trackModels: TrackModel[]) => - trackModels.length === 2 && trackModels[0].path === 'file 1.mp3' && trackModels[1].path === 'file 2.ogg' - ) + trackModels.length === 2 && trackModels[0].path === 'file 1.mp3' && trackModels[1].path === 'file 2.ogg', + ), ), - Times.once() + Times.once(), ); }); @@ -134,6 +145,27 @@ describe('FileService', () => { // Assert playbackServiceMock.verify((x) => x.enqueueAndPlayTracks(It.isAny()), Times.never()); }); + + it('should enqueue all playable tracks that are dropped', async () => { + // Arrange + createService(); + + // Act + filesDroppedMock.next(['file 1.mp3', 'file 2.ogg', 'file 3.bmp']); + await flushPromises(); + + // Assert + playbackServiceMock.verify( + (x) => + x.enqueueAndPlayTracks( + It.is( + (trackModels: TrackModel[]) => + trackModels.length === 2 && trackModels[0].path === 'file 1.mp3' && trackModels[1].path === 'file 2.ogg', + ), + ), + Times.once(), + ); + }); }); describe('hasPlayableFilesAsParameters', () => { @@ -178,10 +210,10 @@ describe('FileService', () => { x.enqueueAndPlayTracks( It.is( (trackModels: TrackModel[]) => - trackModels.length === 2 && trackModels[0].path === 'file 1.mp3' && trackModels[1].path === 'file 2.ogg' - ) + trackModels.length === 2 && trackModels[0].path === 'file 1.mp3' && trackModels[1].path === 'file 2.ogg', + ), ), - Times.once() + Times.once(), ); }); diff --git a/src/app/services/file/file.service.ts b/src/app/services/file/file.service.ts index ec0acaf7f..a715127c2 100644 --- a/src/app/services/file/file.service.ts +++ b/src/app/services/file/file.service.ts @@ -8,6 +8,7 @@ import { BasePlaybackService } from '../playback/base-playback.service'; import { TrackModel } from '../track/track-model'; import { TrackModelFactory } from '../track/track-model-factory'; import { BaseFileService } from './base-file.service'; +import { BaseEventListenerService } from '../event-listener/base-event-listener.service'; @Injectable() export class FileService implements BaseFileService { @@ -15,17 +16,24 @@ export class FileService implements BaseFileService { public constructor( private playbackService: BasePlaybackService, + private eventListenerService: BaseEventListenerService, private trackModelFactory: TrackModelFactory, private application: BaseApplication, private fileValidator: FileValidator, - private logger: Logger + private logger: Logger, ) { this.subscription.add( - this.application.argumentsReceived$.subscribe((argv: string[]) => { + this.eventListenerService.argumentsReceived$.subscribe((argv: string[]) => { if (this.hasPlayableFilesAsGivenParameters(argv)) { PromiseUtils.noAwait(this.enqueueGivenParameterFilesAsync(argv)); } - }) + }), + ); + + this.subscription.add( + this.eventListenerService.filesDropped$.subscribe((filePaths: string[]) => { + PromiseUtils.noAwait(this.enqueueGivenParameterFilesAsync(filePaths)); + }), ); } diff --git a/tsconfig.json b/tsconfig.json index d0e9d773d..012ac1a00 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,7 @@ "strictNullChecks": true, "target": "ES2022", "typeRoots": ["node_modules/@types"], - "lib": ["es2017", "es2016", "es2015", "dom"], + "lib": ["es2017", "es2016", "es2015", "dom", "DOM.Iterable"], "useDefineForClassFields": false, "skipLibCheck": true },