Skip to content

Commit

Permalink
Feature/file drag and drop (#475)
Browse files Browse the repository at this point in the history
* Adds working drag and drop service

* Fixes Typescript error about iterables

* Adds basic drop files support

* Some refactoring

* Adds tests
  • Loading branch information
digimezzo authored Nov 4, 2023
1 parent 9338047 commit 67d8fc3
Show file tree
Hide file tree
Showing 11 changed files with 132 additions and 29 deletions.
4 changes: 4 additions & 0 deletions src/app/app.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BaseNavigationService>;
Expand All @@ -26,6 +27,7 @@ describe('AppComponent', () => {
let trayServiceMock: IMock<BaseTrayService>;
let searchServiceMock: IMock<BaseSearchService>;
let mediaSessionServiceMock: IMock<BaseMediaSessionService>;
let eventListenerServiceMock: IMock<BaseEventListenerService>;

let addToPlaylistMenuMock: IMock<AddToPlaylistMenu>;
let desktopMock: IMock<BaseDesktop>;
Expand All @@ -48,6 +50,7 @@ describe('AppComponent', () => {
trayServiceMock.object,
searchServiceMock.object,
mediaSessionServiceMock.object,
eventListenerServiceMock.object,
addToPlaylistMenuMock.object,
desktopMock.object,
loggerMock.object,
Expand All @@ -65,6 +68,7 @@ describe('AppComponent', () => {
trayServiceMock = Mock.ofType<BaseTrayService>();
searchServiceMock = Mock.ofType<BaseSearchService>();
mediaSessionServiceMock = Mock.ofType<BaseMediaSessionService>();
eventListenerServiceMock = Mock.ofType<BaseEventListenerService>();
addToPlaylistMenuMock = Mock.ofType<AddToPlaylistMenu>();
desktopMock = Mock.ofType<BaseDesktop>();
loggerMock = Mock.ofType<Logger>();
Expand Down
7 changes: 5 additions & 2 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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',
Expand All @@ -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,
Expand Down Expand Up @@ -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();
}
}
7 changes: 5 additions & 2 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 0 additions & 12 deletions src/app/common/io/application.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> = new Subject();

public constructor() {
ipcRenderer.on('arguments-received', (event, argv: string[] | undefined) => {
this.argumentsReceived.next(argv);
});
}

public argumentsReceived$: Observable<string[]> = this.argumentsReceived.asObservable();

public getGlobal(name: string): unknown {
return remote.getGlobal(name);
}
Expand Down
1 change: 0 additions & 1 deletion src/app/common/io/base-application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>;
public abstract getGlobal(name: string): unknown;
public abstract getCurrentWindow(): BrowserWindow;
public abstract getWindowSize(): WindowSize;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Observable } from 'rxjs';

export abstract class BaseEventListenerService {
public abstract argumentsReceived$: Observable<string[]>;
public abstract filesDropped$: Observable<string[]>;
public abstract listenToEvents(): void;
}
20 changes: 20 additions & 0 deletions src/app/services/event-listener/event-listener.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
39 changes: 39 additions & 0 deletions src/app/services/event-listener/event-listener.service.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> = new Subject();
private filesDropped: Subject<string[]> = new Subject();

public argumentsReceived$: Observable<string[]> = this.argumentsReceived.asObservable();
public filesDropped$: Observable<string[]> = 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);
});
}
}
48 changes: 40 additions & 8 deletions src/app/services/file/file.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<BasePlaybackService>;
let eventListenerServiceMock: IMock<BaseEventListenerService>;
let trackModelFactoryMock: IMock<TrackModelFactory>;
let fileValidatorMock: IMock<FileValidator>;
let applicationMock: IMock<BaseApplication>;
Expand All @@ -25,20 +27,25 @@ describe('FileService', () => {
let argumentsReceivedMock: Subject<string[]>;
let argumentsReceivedMock$: Observable<string[]>;

let filesDroppedMock: Subject<string[]>;
let filesDroppedMock$: Observable<string[]>;

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<BasePlaybackService>();
eventListenerServiceMock = Mock.ofType<BaseEventListenerService>();
trackModelFactoryMock = Mock.ofType<TrackModelFactory>();
fileValidatorMock = Mock.ofType<FileValidator>();
applicationMock = Mock.ofType<BaseApplication>();
Expand All @@ -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', () => {
Expand Down Expand Up @@ -92,10 +103,10 @@ describe('FileService', () => {
x.enqueueAndPlayTracks(
It.is<TrackModel[]>(
(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(),
);
});

Expand Down Expand Up @@ -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<TrackModel[]>(
(trackModels: TrackModel[]) =>
trackModels.length === 2 && trackModels[0].path === 'file 1.mp3' && trackModels[1].path === 'file 2.ogg',
),
),
Times.once(),
);
});
});

describe('hasPlayableFilesAsParameters', () => {
Expand Down Expand Up @@ -178,10 +210,10 @@ describe('FileService', () => {
x.enqueueAndPlayTracks(
It.is<TrackModel[]>(
(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(),
);
});

Expand Down
14 changes: 11 additions & 3 deletions src/app/services/file/file.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,32 @@ 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 {
private subscription: Subscription = new Subscription();

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));
}),
);
}

Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down

0 comments on commit 67d8fc3

Please sign in to comment.