Update formatting

This commit is contained in:
Geomitron
2023-11-23 13:00:30 -06:00
parent 93aae097c6
commit cddec0d9d4
75 changed files with 4900 additions and 3279 deletions

View File

@@ -55,6 +55,10 @@
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^17.0.3", "@angular-devkit/build-angular": "^17.0.3",
"@angular-eslint/builder": "17.1.0", "@angular-eslint/builder": "17.1.0",
"@angular-eslint/eslint-plugin": "^17.1.0",
"@angular-eslint/eslint-plugin-template": "^17.1.0",
"@angular-eslint/schematics": "^17.1.0",
"@angular-eslint/template-parser": "^17.1.0",
"@angular/cli": "^17.0.3", "@angular/cli": "^17.0.3",
"@angular/compiler-cli": "^17.0.4", "@angular/compiler-cli": "^17.0.4",
"@angular/language-service": "^17.0.4", "@angular/language-service": "^17.0.4",
@@ -65,10 +69,22 @@
"@types/randombytes": "^2.0.0", "@types/randombytes": "^2.0.0",
"@typescript-eslint/eslint-plugin": "^6.12.0", "@typescript-eslint/eslint-plugin": "^6.12.0",
"@typescript-eslint/parser": "^6.12.0", "@typescript-eslint/parser": "^6.12.0",
"concurrently": "^8.2.2",
"electron": "^27.1.2", "electron": "^27.1.2",
"electron-builder": "^23.6.0", "electron-builder": "^23.6.0",
"eslint": "^8.54.0", "eslint": "^8.54.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-jsdoc": "^46.9.0",
"eslint-plugin-prefer-arrow": "^1.2.3",
"eslint-plugin-prettier": "^5.0.1",
"nodemon": "^3.0.1", "nodemon": "^3.0.1",
"postcss": "^8.4.31",
"prettier": "^3.1.0",
"prettier-eslint": "^16.1.2",
"source-map-support": "^0.5.21",
"tailwindcss": "^3.3.5",
"tsx": "^4.4.0",
"typescript": "^5.2.2" "typescript": "^5.2.2"
} }
} }

1576
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,23 +1,24 @@
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { Routes, RouterModule, RouteReuseStrategy } from '@angular/router' import { RouteReuseStrategy, RouterModule, Routes } from '@angular/router'
import { BrowseComponent } from './components/browse/browse.component' import { BrowseComponent } from './components/browse/browse.component'
import { SettingsComponent } from './components/settings/settings.component' import { SettingsComponent } from './components/settings/settings.component'
import { TabPersistStrategy } from './core/tab-persist.strategy' import { TabPersistStrategy } from './core/tab-persist.strategy'
// TODO: replace these with the correct components // TODO: replace these with the correct components
const routes: Routes = [ const routes: Routes = [
{ path: 'browse', component: BrowseComponent, data: { shouldReuse: true } }, { path: 'browse', component: BrowseComponent, data: { shouldReuse: true } },
{ path: 'library', redirectTo: '/browse' }, { path: 'library', redirectTo: '/browse' },
{ path: 'settings', component: SettingsComponent, data: { shouldReuse: true } }, { path: 'settings', component: SettingsComponent, data: { shouldReuse: true } },
{ path: 'about', redirectTo: '/browse' }, { path: 'about', redirectTo: '/browse' },
{ path: '**', redirectTo: '/browse' } { path: '**', redirectTo: '/browse' },
] ]
@NgModule({ @NgModule({
imports: [RouterModule.forRoot(routes)], imports: [RouterModule.forRoot(routes)],
exports: [RouterModule], exports: [RouterModule],
providers: [ providers: [
{ provide: RouteReuseStrategy, useClass: TabPersistStrategy }, { provide: RouteReuseStrategy, useClass: TabPersistStrategy },
] ],
}) })
export class AppRoutingModule { } export class AppRoutingModule { }

View File

@@ -1,4 +1,4 @@
<div *ngIf="settingsLoaded" style="display: flex; flex-direction: column; height: 100%;"> <div *ngIf="settingsLoaded" style="display: flex; flex-direction: column; height: 100%">
<app-toolbar></app-toolbar> <app-toolbar></app-toolbar>
<router-outlet></router-outlet> <router-outlet></router-outlet>
</div> </div>

View File

@@ -1,17 +1,18 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { SettingsService } from './core/services/settings.service' import { SettingsService } from './core/services/settings.service'
@Component({ @Component({
selector: 'app-root', selector: 'app-root',
templateUrl: './app.component.html', templateUrl: './app.component.html',
styles: [] styles: [],
}) })
export class AppComponent { export class AppComponent {
settingsLoaded = false settingsLoaded = false
constructor(private settingsService: SettingsService) { constructor(private settingsService: SettingsService) {
// Ensure settings are loaded before rendering the application // Ensure settings are loaded before rendering the application
settingsService.loadSettings().then(() => this.settingsLoaded = true) settingsService.loadSettings().then(() => this.settingsLoaded = true)
} }
} }

View File

@@ -1,41 +1,41 @@
import { BrowserModule } from '@angular/platform-browser'
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { BrowserModule } from '@angular/platform-browser'
import { AppRoutingModule } from './app-routing.module' import { AppRoutingModule } from './app-routing.module'
import { AppComponent } from './app.component' import { AppComponent } from './app.component'
import { ToolbarComponent } from './components/toolbar/toolbar.component'
import { BrowseComponent } from './components/browse/browse.component' import { BrowseComponent } from './components/browse/browse.component'
import { SearchBarComponent } from './components/browse/search-bar/search-bar.component'
import { StatusBarComponent } from './components/browse/status-bar/status-bar.component'
import { ResultTableComponent } from './components/browse/result-table/result-table.component'
import { ChartSidebarComponent } from './components/browse/chart-sidebar/chart-sidebar.component' import { ChartSidebarComponent } from './components/browse/chart-sidebar/chart-sidebar.component'
import { ResultTableRowComponent } from './components/browse/result-table/result-table-row/result-table-row.component' import { ResultTableRowComponent } from './components/browse/result-table/result-table-row/result-table-row.component'
import { ResultTableComponent } from './components/browse/result-table/result-table.component'
import { SearchBarComponent } from './components/browse/search-bar/search-bar.component'
import { DownloadsModalComponent } from './components/browse/status-bar/downloads-modal/downloads-modal.component' import { DownloadsModalComponent } from './components/browse/status-bar/downloads-modal/downloads-modal.component'
import { ProgressBarDirective } from './core/directives/progress-bar.directive' import { StatusBarComponent } from './components/browse/status-bar/status-bar.component'
import { CheckboxDirective } from './core/directives/checkbox.directive'
import { SettingsComponent } from './components/settings/settings.component' import { SettingsComponent } from './components/settings/settings.component'
import { FormsModule } from '@angular/forms' import { ToolbarComponent } from './components/toolbar/toolbar.component'
import { CheckboxDirective } from './core/directives/checkbox.directive'
import { ProgressBarDirective } from './core/directives/progress-bar.directive'
@NgModule({ @NgModule({
declarations: [ declarations: [
AppComponent, AppComponent,
ToolbarComponent, ToolbarComponent,
BrowseComponent, BrowseComponent,
SearchBarComponent, SearchBarComponent,
StatusBarComponent, StatusBarComponent,
ResultTableComponent, ResultTableComponent,
ChartSidebarComponent, ChartSidebarComponent,
ResultTableRowComponent, ResultTableRowComponent,
DownloadsModalComponent, DownloadsModalComponent,
ProgressBarDirective, ProgressBarDirective,
CheckboxDirective, CheckboxDirective,
SettingsComponent SettingsComponent,
], ],
imports: [ imports: [
BrowserModule, BrowserModule,
AppRoutingModule, AppRoutingModule,
FormsModule FormsModule,
], ],
bootstrap: [AppComponent] bootstrap: [AppComponent],
}) })
export class AppModule { } export class AppModule { }

View File

@@ -1,15 +1,12 @@
<app-search-bar></app-search-bar> <app-search-bar></app-search-bar>
<div class="ui celled two column grid"> <div class="ui celled two column grid">
<div id="table-row" class="row"> <div id="table-row" class="row">
<div id="table-column" class="column twelve wide"> <div id="table-column" class="column twelve wide">
<app-result-table <app-result-table #resultTable (rowClicked)="chartSidebar.onRowClicked($event)"></app-result-table>
#resultTable </div>
(rowClicked)="chartSidebar.onRowClicked($event)" <div id="sidebar-column" class="column four wide">
></app-result-table> <app-chart-sidebar #chartSidebar></app-chart-sidebar>
</div> </div>
<div id="sidebar-column" class="column four wide"> </div>
<app-chart-sidebar #chartSidebar></app-chart-sidebar>
</div>
</div>
</div> </div>
<app-status-bar #statusBar></app-status-bar> <app-status-bar #statusBar></app-status-bar>

View File

@@ -24,4 +24,4 @@
min-width: 175px; min-width: 175px;
overflow: hidden; overflow: hidden;
padding: 0; padding: 0;
} }

View File

@@ -1,34 +1,35 @@
import { Component, ViewChild, AfterViewInit } from '@angular/core' import { AfterViewInit, Component, ViewChild } from '@angular/core'
import { ChartSidebarComponent } from './chart-sidebar/chart-sidebar.component'
import { StatusBarComponent } from './status-bar/status-bar.component'
import { ResultTableComponent } from './result-table/result-table.component'
import { SearchService } from 'src/app/core/services/search.service' import { SearchService } from 'src/app/core/services/search.service'
import { ChartSidebarComponent } from './chart-sidebar/chart-sidebar.component'
import { ResultTableComponent } from './result-table/result-table.component'
import { StatusBarComponent } from './status-bar/status-bar.component'
@Component({ @Component({
selector: 'app-browse', selector: 'app-browse',
templateUrl: './browse.component.html', templateUrl: './browse.component.html',
styleUrls: ['./browse.component.scss'] styleUrls: ['./browse.component.scss'],
}) })
export class BrowseComponent implements AfterViewInit { export class BrowseComponent implements AfterViewInit {
@ViewChild('resultTable', { static: true }) resultTable: ResultTableComponent @ViewChild('resultTable', { static: true }) resultTable: ResultTableComponent
@ViewChild('chartSidebar', { static: true }) chartSidebar: ChartSidebarComponent @ViewChild('chartSidebar', { static: true }) chartSidebar: ChartSidebarComponent
@ViewChild('statusBar', { static: true }) statusBar: StatusBarComponent @ViewChild('statusBar', { static: true }) statusBar: StatusBarComponent
constructor(private searchService: SearchService) { } constructor(private searchService: SearchService) { }
ngAfterViewInit() { ngAfterViewInit() {
const $tableColumn = $('#table-column') const $tableColumn = $('#table-column')
$tableColumn.on('scroll', () => { $tableColumn.on('scroll', () => {
const pos = $tableColumn[0].scrollTop + $tableColumn[0].offsetHeight const pos = $tableColumn[0].scrollTop + $tableColumn[0].offsetHeight
const max = $tableColumn[0].scrollHeight const max = $tableColumn[0].scrollHeight
if (pos >= max - 5) { if (pos >= max - 5) {
this.searchService.updateScroll() this.searchService.updateScroll()
} }
}) })
this.searchService.onNewSearch(() => { this.searchService.onNewSearch(() => {
$tableColumn.scrollTop(0) $tableColumn.scrollTop(0)
}) })
} }
} }

View File

@@ -1,46 +1,46 @@
<div id="sidebarCard" *ngIf="selectedVersion" class="ui fluid card"> <div id="sidebarCard" *ngIf="selectedVersion" class="ui fluid card">
<div class="ui placeholder" [ngClass]="{'placeholder': albumArtSrc == '', 'inverted': settingsService.theme == 'Dark'}"> <div class="ui placeholder" [ngClass]="{ placeholder: albumArtSrc === '', inverted: settingsService.theme === 'Dark' }">
<img *ngIf="albumArtSrc != null" class="ui square image" [src]="albumArtSrc"> <img *ngIf="albumArtSrc !== null" class="ui square image" [src]="albumArtSrc" />
</div> </div>
<div *ngIf="charts.length > 1" id="chartDropdown" class="ui fluid right labeled scrolling icon dropdown button"> <div *ngIf="charts.length > 1" id="chartDropdown" class="ui fluid right labeled scrolling icon dropdown button">
<input type="hidden" name="Chart"> <input type="hidden" name="Chart" />
<i id="chartDropdownIcon" class="dropdown icon"></i> <i id="chartDropdownIcon" class="dropdown icon"></i>
<div class="default text"></div> <div class="default text"></div>
<div id="chartDropdownMenu" class="menu"> <div id="chartDropdownMenu" class="menu"></div>
</div> </div>
</div> <div id="textPanel" class="content">
<div id="textPanel" class="content"> <span class="header">{{ selectedVersion.chartName }}</span>
<span class="header">{{selectedVersion.chartName}}</span> <div class="description">
<div class="description"> <div *ngIf="songResult.album === null"><b>Album:</b> {{ selectedVersion.album }} ({{ selectedVersion.year }})</div>
<div *ngIf="songResult.album == null"><b>Album:</b> {{selectedVersion.album}} ({{selectedVersion.year}})</div> <div *ngIf="songResult.album !== null"><b>Year:</b> {{ selectedVersion.year }}</div>
<div *ngIf="songResult.album != null"><b>Year:</b> {{selectedVersion.year}}</div> <div *ngIf="songResult.genre === null"><b>Genre:</b> {{ selectedVersion.genre }}</div>
<div *ngIf="songResult.genre == null"><b>Genre:</b> {{selectedVersion.genre}}</div> <div>
<div><b>{{charterPlural}}</b> {{selectedVersion.charters}}</div> <b>{{ charterPlural }}</b> {{ selectedVersion.charters }}
<div *ngIf="selectedVersion.tags"><b>Tags:</b> {{selectedVersion.tags}}</div> </div>
<div><b>Audio Length:</b> {{songLength}}</div> <div *ngIf="selectedVersion.tags"><b>Tags:</b> {{ selectedVersion.tags }}</div>
<div class="ui divider"></div> <div><b>Audio Length:</b> {{ songLength }}</div>
<div class="ui horizontal list"> <div class="ui divider"></div>
<div *ngFor="let difficulty of difficultiesList" class="item"> <div class="ui horizontal list">
<img class="ui avatar image" src="assets/images/instruments/{{difficulty.instrument}}"> <div *ngFor="let difficulty of difficultiesList" class="item">
<div class="content"> <img class="ui avatar image" src="assets/images/instruments/{{ difficulty.instrument }}" />
<div class="header">Diff: {{difficulty.diffNumber}}</div> <div class="content">
{{difficulty.chartedDifficulties}} <div class="header">Diff: {{ difficulty.diffNumber }}</div>
</div> {{ difficulty.chartedDifficulties }}
</div> </div>
</div> </div>
<div id="sourceLinks"> </div>
<a id="sourceLink" (click)="onSourceLinkClicked()">{{selectedVersion.driveData.source.sourceName}}</a> <div id="sourceLinks">
<button *ngIf="shownFolderButton()" id="folderButton" class="mini ui icon button" (click)="onFolderButtonClicked()"> <a id="sourceLink" (click)="onSourceLinkClicked()">{{ selectedVersion.driveData.source.sourceName }}</a>
<i class="folder open outline icon"></i> <button *ngIf="shownFolderButton()" id="folderButton" class="mini ui icon button" (click)="onFolderButtonClicked()">
</button> <i class="folder open outline icon"></i>
</div> </button>
</div> </div>
</div>
</div> </div>
<div id="downloadButtons" class="ui positive buttons"> <div id="downloadButtons" class="ui positive buttons">
<div id="downloadButton" class="ui button" (click)="onDownloadClicked()">{{downloadButtonText}}</div> <div id="downloadButton" class="ui button" (click)="onDownloadClicked()">{{ downloadButtonText }}</div>
<div *ngIf="getSelectedChartVersions().length > 1" id="versionDropdown" class="ui floating dropdown icon button"> <div *ngIf="getSelectedChartVersions().length > 1" id="versionDropdown" class="ui floating dropdown icon button">
<i class="dropdown icon"></i> <i class="dropdown icon"></i>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -61,4 +61,4 @@
#versionDropdown { #versionDropdown {
margin: 0px 1px 1px -3px; margin: 0px 1px 1px -3px;
} }

View File

@@ -1,286 +1,288 @@
import { Component, OnInit } from '@angular/core' import { Component, OnInit } from '@angular/core'
import { DomSanitizer, SafeUrl } from '@angular/platform-browser'
import { SearchService } from 'src/app/core/services/search.service'
import { SettingsService } from 'src/app/core/services/settings.service'
import { groupBy } from 'src/electron/shared/UtilFunctions'
import { SongResult } from '../../../../electron/shared/interfaces/search.interface' import { SongResult } from '../../../../electron/shared/interfaces/search.interface'
import { ElectronService } from '../../../core/services/electron.service' import { ChartedDifficulty, getInstrumentIcon, Instrument, VersionResult } from '../../../../electron/shared/interfaces/songDetails.interface'
import { VersionResult, getInstrumentIcon, Instrument, ChartedDifficulty } from '../../../../electron/shared/interfaces/songDetails.interface'
import { AlbumArtService } from '../../../core/services/album-art.service' import { AlbumArtService } from '../../../core/services/album-art.service'
import { DownloadService } from '../../../core/services/download.service' import { DownloadService } from '../../../core/services/download.service'
import { groupBy } from 'src/electron/shared/UtilFunctions' import { ElectronService } from '../../../core/services/electron.service'
import { SearchService } from 'src/app/core/services/search.service'
import { DomSanitizer, SafeUrl } from '@angular/platform-browser'
import { SettingsService } from 'src/app/core/services/settings.service'
interface Difficulty { interface Difficulty {
instrument: string instrument: string
diffNumber: string diffNumber: string
chartedDifficulties: string chartedDifficulties: string
} }
@Component({ @Component({
selector: 'app-chart-sidebar', selector: 'app-chart-sidebar',
templateUrl: './chart-sidebar.component.html', templateUrl: './chart-sidebar.component.html',
styleUrls: ['./chart-sidebar.component.scss'] styleUrls: ['./chart-sidebar.component.scss'],
}) })
export class ChartSidebarComponent implements OnInit { export class ChartSidebarComponent implements OnInit {
songResult: SongResult songResult: SongResult
selectedVersion: VersionResult selectedVersion: VersionResult
charts: VersionResult[][] charts: VersionResult[][]
constructor( albumArtSrc: SafeUrl = ''
private electronService: ElectronService, charterPlural: string
private albumArtService: AlbumArtService, songLength: string
private downloadService: DownloadService, difficultiesList: Difficulty[]
private searchService: SearchService, downloadButtonText: string
private sanitizer: DomSanitizer,
public settingsService: SettingsService
) { }
ngOnInit() { constructor(
this.searchService.onNewSearch(() => { private electronService: ElectronService,
this.selectVersion(undefined) private albumArtService: AlbumArtService,
this.songResult = undefined private downloadService: DownloadService,
}) private searchService: SearchService,
} private sanitizer: DomSanitizer,
public settingsService: SettingsService
) { }
/** ngOnInit() {
* Displays the information for the selected song. this.searchService.onNewSearch(() => {
*/ this.selectVersion(undefined)
async onRowClicked(result: SongResult) { this.songResult = undefined
if (this.songResult == undefined || result.id != this.songResult.id) { // Clicking the same row again will not reload })
this.songResult = result }
const albumArt = this.albumArtService.getImage(result.id)
const results = await this.electronService.invoke('song-details', result.id)
this.charts = groupBy(results, 'chartID').sort((v1, v2) => v1[0].chartName.length - v2[0].chartName.length)
this.sortCharts()
await this.selectChart(this.charts[0][0].chartID)
this.initChartDropdown()
this.updateAlbumArtSrc(await albumArt) /**
} * Displays the information for the selected song.
} */
async onRowClicked(result: SongResult) {
if (this.songResult == undefined || result.id != this.songResult.id) { // Clicking the same row again will not reload
this.songResult = result
const albumArt = this.albumArtService.getImage(result.id)
const results = await this.electronService.invoke('song-details', result.id)
this.charts = groupBy(results, 'chartID').sort((v1, v2) => v1[0].chartName.length - v2[0].chartName.length)
this.sortCharts()
await this.selectChart(this.charts[0][0].chartID)
this.initChartDropdown()
/** this.updateAlbumArtSrc(await albumArt)
* Sorts `this.charts` and its subarrays in the correct order. }
* The chart dropdown should display in a random order, but verified charters are prioritized. }
* The version dropdown should be ordered by lastModified date.
* (but prefer the non-pack version if it's only a few days older)
*/
private sortCharts() {
for (const chart of this.charts) {
// TODO: sort by verified charter
this.searchService.sortChart(chart)
}
}
albumArtSrc: SafeUrl = '' /**
/** * Sorts `this.charts` and its subarrays in the correct order.
* Updates the sidebar to display the album art. * The chart dropdown should display in a random order, but verified charters are prioritized.
*/ * The version dropdown should be ordered by lastModified date.
updateAlbumArtSrc(albumArtBase64String?: string) { * (but prefer the non-pack version if it's only a few days older)
if (albumArtBase64String) { */
this.albumArtSrc = this.sanitizer.bypassSecurityTrustUrl('data:image/jpg;base64,' + albumArtBase64String) private sortCharts() {
} else { for (const chart of this.charts) {
this.albumArtSrc = null // TODO: sort by verified charter
} this.searchService.sortChart(chart)
} }
}
/** /**
* Initializes the chart dropdown from `this.charts` (or removes it if there's only one chart). * Updates the sidebar to display the album art.
*/ */
private initChartDropdown() { updateAlbumArtSrc(albumArtBase64String?: string) {
const values = this.charts.map(chart => { if (albumArtBase64String) {
const version = chart[0] this.albumArtSrc = this.sanitizer.bypassSecurityTrustUrl('data:image/jpg;base64,' + albumArtBase64String)
return { } else {
value: version.chartID, this.albumArtSrc = null
text: version.chartName, }
name: `${version.chartName} <b>[${version.charters}]</b>` }
}
})
const $chartDropdown = $('#chartDropdown')
$chartDropdown.dropdown('setup menu', { values })
$chartDropdown.dropdown('setting', 'onChange', (chartID: number) => this.selectChart(chartID))
$chartDropdown.dropdown('set selected', values[0].value)
}
private async selectChart(chartID: number) { /**
const chart = this.charts.find(chart => chart[0].chartID == chartID) * Initializes the chart dropdown from `this.charts` (or removes it if there's only one chart).
await this.selectVersion(chart[0]) */
this.initVersionDropdown() private initChartDropdown() {
} const values = this.charts.map(chart => {
const version = chart[0]
return {
value: version.chartID,
text: version.chartName,
name: `${version.chartName} <b>[${version.charters}]</b>`,
}
})
const $chartDropdown = $('#chartDropdown')
$chartDropdown.dropdown('setup menu', { values })
$chartDropdown.dropdown('setting', 'onChange', (chartID: number) => this.selectChart(chartID))
$chartDropdown.dropdown('set selected', values[0].value)
}
/** private async selectChart(chartID: number) {
* Updates the sidebar to display the metadata for `selectedVersion`. const chart = this.charts.find(chart => chart[0].chartID == chartID)
*/ await this.selectVersion(chart[0])
async selectVersion(selectedVersion: VersionResult) { this.initVersionDropdown()
this.selectedVersion = selectedVersion }
await new Promise<void>(resolve => setTimeout(() => resolve(), 0)) // Wait for *ngIf to update DOM
if (this.selectedVersion != undefined) { /**
this.updateCharterPlural() * Updates the sidebar to display the metadata for `selectedVersion`.
this.updateSongLength() */
this.updateDifficultiesList() async selectVersion(selectedVersion: VersionResult) {
this.updateDownloadButtonText() this.selectedVersion = selectedVersion
} await new Promise<void>(resolve => setTimeout(() => resolve(), 0)) // Wait for *ngIf to update DOM
}
charterPlural: string if (this.selectedVersion != undefined) {
/** this.updateCharterPlural()
* Chooses to display 'Charter:' or 'Charters:'. this.updateSongLength()
*/ this.updateDifficultiesList()
private updateCharterPlural() { this.updateDownloadButtonText()
this.charterPlural = this.selectedVersion.charterIDs.split('&').length == 1 ? 'Charter:' : 'Charters:' }
} }
songLength: string /**
/** * Chooses to display 'Charter:' or 'Charters:'.
* Converts `this.selectedVersion.chartMetadata.length` into a readable duration. */
*/ private updateCharterPlural() {
private updateSongLength() { this.charterPlural = this.selectedVersion.charterIDs.split('&').length == 1 ? 'Charter:' : 'Charters:'
let seconds = this.selectedVersion.songLength }
if (seconds < 60) { this.songLength = `${seconds} second${seconds == 1 ? '' : 's'}`; return }
let minutes = Math.floor(seconds / 60)
let hours = 0
while (minutes > 59) {
hours++
minutes -= 60
}
seconds = Math.floor(seconds % 60)
this.songLength = `${hours == 0 ? '' : hours + ':'}${minutes == 0 ? '' : minutes + ':'}${seconds < 10 ? '0' + seconds : seconds}`
}
difficultiesList: Difficulty[] /**
/** * Converts `this.selectedVersion.chartMetadata.length` into a readable duration.
* Updates `dfficultiesList` with the difficulty information for the selected version. */
*/ private updateSongLength() {
private updateDifficultiesList() { let seconds = this.selectedVersion.songLength
const instruments = Object.keys(this.selectedVersion.chartData.noteCounts) as Instrument[] if (seconds < 60) { this.songLength = `${seconds} second${seconds == 1 ? '' : 's'}`; return }
this.difficultiesList = [] let minutes = Math.floor(seconds / 60)
for (const instrument of instruments) { let hours = 0
if (instrument != 'undefined') { while (minutes > 59) {
this.difficultiesList.push({ hours++
instrument: getInstrumentIcon(instrument), minutes -= 60
diffNumber: this.getDiffNumber(instrument), }
chartedDifficulties: this.getChartedDifficultiesText(instrument) seconds = Math.floor(seconds % 60)
}) this.songLength = `${hours == 0 ? '' : hours + ':'}${minutes == 0 ? '' : minutes + ':'}${seconds < 10 ? '0' + seconds : seconds}`
} }
}
}
/** /**
* @returns a string describing the difficulty number in the selected version. * Updates `dfficultiesList` with the difficulty information for the selected version.
*/ */
private getDiffNumber(instrument: Instrument) { private updateDifficultiesList() {
const diffNumber: number = this.selectedVersion[`diff_${instrument}`] const instruments = Object.keys(this.selectedVersion.chartData.noteCounts) as Instrument[]
return diffNumber == -1 || diffNumber == undefined ? 'Unknown' : String(diffNumber) this.difficultiesList = []
} for (const instrument of instruments) {
if (instrument != 'undefined') {
this.difficultiesList.push({
instrument: getInstrumentIcon(instrument),
diffNumber: this.getDiffNumber(instrument),
chartedDifficulties: this.getChartedDifficultiesText(instrument),
})
}
}
}
/** /**
* @returns a string describing the list of charted difficulties in the selected version. * @returns a string describing the difficulty number in the selected version.
*/ */
private getChartedDifficultiesText(instrument: Instrument) { private getDiffNumber(instrument: Instrument) {
const difficulties = Object.keys(this.selectedVersion.chartData.noteCounts[instrument]) as ChartedDifficulty[] const diffNumber: number = this.selectedVersion[`diff_${instrument}`]
if (difficulties.length == 4) { return 'Full Difficulty' } return diffNumber == -1 || diffNumber == undefined ? 'Unknown' : String(diffNumber)
const difficultyNames = [] }
if (difficulties.includes('x')) { difficultyNames.push('Expert') }
if (difficulties.includes('h')) { difficultyNames.push('Hard') }
if (difficulties.includes('m')) { difficultyNames.push('Medium') }
if (difficulties.includes('e')) { difficultyNames.push('Easy') }
return difficultyNames.join(', ') /**
} * @returns a string describing the list of charted difficulties in the selected version.
*/
private getChartedDifficultiesText(instrument: Instrument) {
const difficulties = Object.keys(this.selectedVersion.chartData.noteCounts[instrument]) as ChartedDifficulty[]
if (difficulties.length == 4) { return 'Full Difficulty' }
const difficultyNames = []
if (difficulties.includes('x')) { difficultyNames.push('Expert') }
if (difficulties.includes('h')) { difficultyNames.push('Hard') }
if (difficulties.includes('m')) { difficultyNames.push('Medium') }
if (difficulties.includes('e')) { difficultyNames.push('Easy') }
downloadButtonText: string return difficultyNames.join(', ')
/** }
* Chooses the text to display on the download button.
*/
private updateDownloadButtonText() {
this.downloadButtonText = 'Download'
if (this.selectedVersion.driveData.inChartPack) {
this.downloadButtonText += ' Chart Pack'
} else {
this.downloadButtonText += (this.selectedVersion.driveData.isArchive ? ' Archive' : ' Files')
}
if (this.getSelectedChartVersions().length > 1) { /**
if (this.selectedVersion.versionID == this.selectedVersion.latestVersionID) { * Chooses the text to display on the download button.
this.downloadButtonText += ' (Latest)' */
} else { private updateDownloadButtonText() {
this.downloadButtonText += ` (${this.getLastModifiedText(this.selectedVersion.lastModified)})` this.downloadButtonText = 'Download'
} if (this.selectedVersion.driveData.inChartPack) {
} this.downloadButtonText += ' Chart Pack'
} } else {
this.downloadButtonText += (this.selectedVersion.driveData.isArchive ? ' Archive' : ' Files')
}
/** if (this.getSelectedChartVersions().length > 1) {
* Initializes the version dropdown from `this.selectedVersion` (or removes it if there's only one version). if (this.selectedVersion.versionID == this.selectedVersion.latestVersionID) {
*/ this.downloadButtonText += ' (Latest)'
private initVersionDropdown() { } else {
const $versionDropdown = $('#versionDropdown') this.downloadButtonText += ` (${this.getLastModifiedText(this.selectedVersion.lastModified)})`
const versions = this.getSelectedChartVersions() }
const values = versions.map(version => ({ }
value: version.versionID, }
text: 'Uploaded ' + this.getLastModifiedText(version.lastModified),
name: 'Uploaded ' + this.getLastModifiedText(version.lastModified)
}))
$versionDropdown.dropdown('setup menu', { values }) /**
$versionDropdown.dropdown('setting', 'onChange', (versionID: number) => { * Initializes the version dropdown from `this.selectedVersion` (or removes it if there's only one version).
this.selectVersion(versions.find(version => version.versionID == versionID)) */
}) private initVersionDropdown() {
$versionDropdown.dropdown('set selected', values[0].value) const $versionDropdown = $('#versionDropdown')
} const versions = this.getSelectedChartVersions()
const values = versions.map(version => ({
value: version.versionID,
text: 'Uploaded ' + this.getLastModifiedText(version.lastModified),
name: 'Uploaded ' + this.getLastModifiedText(version.lastModified),
}))
/** $versionDropdown.dropdown('setup menu', { values })
* Returns the list of versions for the selected chart, sorted by `lastModified`. $versionDropdown.dropdown('setting', 'onChange', (versionID: number) => {
*/ this.selectVersion(versions.find(version => version.versionID == versionID))
getSelectedChartVersions() { })
return this.charts.find(chart => chart[0].chartID == this.selectedVersion.chartID) $versionDropdown.dropdown('set selected', values[0].value)
} }
/** /**
* Converts the <lastModified> value to a user-readable format. * Returns the list of versions for the selected chart, sorted by `lastModified`.
* @param lastModified The UNIX timestamp for the lastModified date. */
*/ getSelectedChartVersions() {
private getLastModifiedText(lastModified: string) { return this.charts.find(chart => chart[0].chartID == this.selectedVersion.chartID)
const date = new Date(lastModified) }
const day = date.getDate().toString().padStart(2, '0')
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const year = date.getFullYear().toString().substr(-2)
return `${month}/${day}/${year}`
}
/** /**
* Opens the proxy link or source folder in the default browser. * Converts the <lastModified> value to a user-readable format.
*/ * @param lastModified The UNIX timestamp for the lastModified date.
onSourceLinkClicked() { */
const source = this.selectedVersion.driveData.source private getLastModifiedText(lastModified: string) {
this.electronService.sendIPC('open-url', source.proxyLink ?? `https://drive.google.com/drive/folders/${source.sourceDriveID}`) const date = new Date(lastModified)
} const day = date.getDate().toString().padStart(2, '0')
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const year = date.getFullYear().toString().substr(-2)
return `${month}/${day}/${year}`
}
/** /**
* @returns `true` if the source folder button should be shown. * Opens the proxy link or source folder in the default browser.
*/ */
shownFolderButton() { onSourceLinkClicked() {
const driveData = this.selectedVersion.driveData const source = this.selectedVersion.driveData.source
return driveData.source.proxyLink || driveData.source.sourceDriveID != driveData.folderID this.electronService.sendIPC('open-url', source.proxyLink ?? `https://drive.google.com/drive/folders/${source.sourceDriveID}`)
} }
/** /**
* Opens the chart folder in the default browser. * @returns `true` if the source folder button should be shown.
*/ */
onFolderButtonClicked() { shownFolderButton() {
this.electronService.sendIPC('open-url', `https://drive.google.com/drive/folders/${this.selectedVersion.driveData.folderID}`) const driveData = this.selectedVersion.driveData
} return driveData.source.proxyLink || driveData.source.sourceDriveID != driveData.folderID
}
/** /**
* Adds the selected version to the download queue. * Opens the chart folder in the default browser.
*/ */
onDownloadClicked() { onFolderButtonClicked() {
this.downloadService.addDownload( this.electronService.sendIPC('open-url', `https://drive.google.com/drive/folders/${this.selectedVersion.driveData.folderID}`)
this.selectedVersion.versionID, { }
chartName: this.selectedVersion.chartName,
artist: this.songResult.artist, /**
charter: this.selectedVersion.charters, * Adds the selected version to the download queue.
driveData: this.selectedVersion.driveData */
}) onDownloadClicked() {
} this.downloadService.addDownload(
} this.selectedVersion.versionID, {
chartName: this.selectedVersion.chartName,
artist: this.songResult.artist,
charter: this.selectedVersion.charters,
driveData: this.selectedVersion.driveData,
})
}
}

View File

@@ -1,9 +1,12 @@
<td> <td>
<div #checkbox class="ui checkbox" (click)="$event.stopPropagation()"> <div #checkbox class="ui checkbox" (click)="$event.stopPropagation()">
<input type="checkbox"> <input type="checkbox" />
</div> </div>
</td> </td>
<td><span id="chartCount" *ngIf="result.chartCount > 1">{{result.chartCount}}</span>{{result.name}}</td> <td>
<td>{{result.artist}}</td> <span id="chartCount" *ngIf="result.chartCount > 1">{{ result.chartCount }}</span
<td>{{result.album || 'Various'}}</td> >{{ result.name }}
<td>{{result.genre || 'Various'}}</td> </td>
<td>{{ result.artist }}</td>
<td>{{ result.album || 'Various' }}</td>
<td>{{ result.genre || 'Various' }}</td>

View File

@@ -7,4 +7,4 @@
border-radius: 3px; border-radius: 3px;
padding: 1px 4px 2px 4px; padding: 1px 4px 2px 4px;
margin-right: 3px; margin-right: 3px;
} }

View File

@@ -1,39 +1,40 @@
import { Component, AfterViewInit, Input, ViewChild, ElementRef } from '@angular/core' import { AfterViewInit, Component, ElementRef, Input, ViewChild } from '@angular/core'
import { SongResult } from '../../../../../electron/shared/interfaces/search.interface' import { SongResult } from '../../../../../electron/shared/interfaces/search.interface'
import { SelectionService } from '../../../../core/services/selection.service' import { SelectionService } from '../../../../core/services/selection.service'
@Component({ @Component({
selector: 'tr[app-result-table-row]', selector: 'tr[app-result-table-row]',
templateUrl: './result-table-row.component.html', templateUrl: './result-table-row.component.html',
styleUrls: ['./result-table-row.component.scss'] styleUrls: ['./result-table-row.component.scss'],
}) })
export class ResultTableRowComponent implements AfterViewInit { export class ResultTableRowComponent implements AfterViewInit {
@Input() result: SongResult @Input() result: SongResult
@ViewChild('checkbox', { static: true }) checkbox: ElementRef @ViewChild('checkbox', { static: true }) checkbox: ElementRef
constructor(private selectionService: SelectionService) { } constructor(private selectionService: SelectionService) { }
get songID() { get songID() {
return this.result.id return this.result.id
} }
ngAfterViewInit() { ngAfterViewInit() {
this.selectionService.onSelectionChanged(this.songID, (isChecked) => { this.selectionService.onSelectionChanged(this.songID, isChecked => {
if (isChecked) { if (isChecked) {
$(this.checkbox.nativeElement).checkbox('check') $(this.checkbox.nativeElement).checkbox('check')
} else { } else {
$(this.checkbox.nativeElement).checkbox('uncheck') $(this.checkbox.nativeElement).checkbox('uncheck')
} }
}) })
$(this.checkbox.nativeElement).checkbox({ $(this.checkbox.nativeElement).checkbox({
onChecked: () => { onChecked: () => {
this.selectionService.selectSong(this.songID) this.selectionService.selectSong(this.songID)
}, },
onUnchecked: () => { onUnchecked: () => {
this.selectionService.deselectSong(this.songID) this.selectionService.deselectSong(this.songID)
} },
}) })
} }
} }

View File

@@ -1,27 +1,30 @@
<table id="resultTable" class="ui stackable selectable single sortable fixed line striped compact small table" [class.inverted]="settingsService.theme == 'Dark'"> <table
<!-- TODO: maybe have some of these tags customizable? E.g. small/large/compact/padded --> id="resultTable"
<!-- TODO: learn semantic themes in order to change the $mobileBreakpoint global variable (better search table adjustment) --> class="ui stackable selectable single sortable fixed line striped compact small table"
<thead> [class.inverted]="settingsService.theme === 'Dark'">
<!-- NOTE: it would be nice to make this header sticky, but Fomantic-UI doesn't currently support that --> <!-- TODO: maybe have some of these tags customizable? E.g. small/large/compact/padded -->
<tr> <!-- TODO: learn semantic themes in order to change the $mobileBreakpoint global variable (better search table adjustment) -->
<th class="collapsing" id="checkboxColumn"> <thead>
<div class="ui checkbox" id="checkbox" #checkboxColumn appCheckbox (checked)="checkAll($event)"> <!-- NOTE: it would be nice to make this header sticky, but Fomantic-UI doesn't currently support that -->
<input type="checkbox"> <tr>
</div> <th class="collapsing" id="checkboxColumn">
</th> <div class="ui checkbox" id="checkbox" #checkboxColumn appCheckbox (checked)="checkAll($event)">
<th class="four wide" [class.sorted]="sortColumn == 'name'" [ngClass]="sortDirection" (click)="onColClicked('name')">Name</th> <input type="checkbox" />
<th class="four wide" [class.sorted]="sortColumn == 'artist'" [ngClass]="sortDirection" (click)="onColClicked('artist')">Artist</th> </div>
<th class="four wide" [class.sorted]="sortColumn == 'album'" [ngClass]="sortDirection" (click)="onColClicked('album')">Album</th> </th>
<th class="four wide" [class.sorted]="sortColumn == 'genre'" [ngClass]="sortDirection" (click)="onColClicked('genre')">Genre</th> <th class="four wide" [class.sorted]="sortColumn === 'name'" [ngClass]="sortDirection" (click)="onColClicked('name')">Name</th>
</tr> <th class="four wide" [class.sorted]="sortColumn === 'artist'" [ngClass]="sortDirection" (click)="onColClicked('artist')">Artist</th>
</thead> <th class="four wide" [class.sorted]="sortColumn === 'album'" [ngClass]="sortDirection" (click)="onColClicked('album')">Album</th>
<tbody> <th class="four wide" [class.sorted]="sortColumn === 'genre'" [ngClass]="sortDirection" (click)="onColClicked('genre')">Genre</th>
<tr </tr>
app-result-table-row </thead>
#tableRow <tbody>
*ngFor="let result of results" <tr
(click)="onRowClicked(result)" app-result-table-row
[class.active]="activeRowID == result.id" #tableRow
[result]="result"></tr> *ngFor="let result of results"
</tbody> (click)="onRowClicked(result)"
</table> [class.active]="activeRowID === result.id"
[result]="result"></tr>
</tbody>
</table>

View File

@@ -1,83 +1,85 @@
import { Component, Output, EventEmitter, ViewChildren, QueryList, ViewChild, OnInit } from '@angular/core' import { Component, EventEmitter, OnInit, Output, QueryList, ViewChild, ViewChildren } from '@angular/core'
import Comparators from 'comparators'
import { SettingsService } from 'src/app/core/services/settings.service'
import { SongResult } from '../../../../electron/shared/interfaces/search.interface' import { SongResult } from '../../../../electron/shared/interfaces/search.interface'
import { ResultTableRowComponent } from './result-table-row/result-table-row.component'
import { CheckboxDirective } from '../../../core/directives/checkbox.directive' import { CheckboxDirective } from '../../../core/directives/checkbox.directive'
import { SearchService } from '../../../core/services/search.service' import { SearchService } from '../../../core/services/search.service'
import { SelectionService } from '../../../core/services/selection.service' import { SelectionService } from '../../../core/services/selection.service'
import { SettingsService } from 'src/app/core/services/settings.service' import { ResultTableRowComponent } from './result-table-row/result-table-row.component'
import Comparators from 'comparators'
@Component({ @Component({
selector: 'app-result-table', selector: 'app-result-table',
templateUrl: './result-table.component.html', templateUrl: './result-table.component.html',
styleUrls: ['./result-table.component.scss'] styleUrls: ['./result-table.component.scss'],
}) })
export class ResultTableComponent implements OnInit { export class ResultTableComponent implements OnInit {
@Output() rowClicked = new EventEmitter<SongResult>() @Output() rowClicked = new EventEmitter<SongResult>()
@ViewChild(CheckboxDirective, { static: true }) checkboxColumn: CheckboxDirective @ViewChild(CheckboxDirective, { static: true }) checkboxColumn: CheckboxDirective
@ViewChildren('tableRow') tableRows: QueryList<ResultTableRowComponent> @ViewChildren('tableRow') tableRows: QueryList<ResultTableRowComponent>
results: SongResult[] = [] results: SongResult[] = []
activeRowID: number = null activeRowID: number = null
sortDirection: 'ascending' | 'descending' = 'descending' sortDirection: 'ascending' | 'descending' = 'descending'
sortColumn: 'name' | 'artist' | 'album' | 'genre' | null = null sortColumn: 'name' | 'artist' | 'album' | 'genre' | null = null
constructor( constructor(
private searchService: SearchService, private searchService: SearchService,
private selectionService: SelectionService, private selectionService: SelectionService,
public settingsService: SettingsService public settingsService: SettingsService
) { } ) { }
ngOnInit() { ngOnInit() {
this.selectionService.onSelectAllChanged((selected) => { this.selectionService.onSelectAllChanged(selected => {
this.checkboxColumn.check(selected) this.checkboxColumn.check(selected)
}) })
this.searchService.onSearchChanged(results => { this.searchService.onSearchChanged(results => {
this.activeRowID = null this.activeRowID = null
this.results = results this.results = results
this.updateSort() this.updateSort()
}) })
this.searchService.onNewSearch(() => { this.searchService.onNewSearch(() => {
this.sortColumn = null this.sortColumn = null
}) })
} }
onRowClicked(result: SongResult) { onRowClicked(result: SongResult) {
this.activeRowID = result.id this.activeRowID = result.id
this.rowClicked.emit(result) this.rowClicked.emit(result)
} }
onColClicked(column: 'name' | 'artist' | 'album' | 'genre') { onColClicked(column: 'name' | 'artist' | 'album' | 'genre') {
if (this.results.length == 0) { return } if (this.results.length == 0) { return }
if (this.sortColumn != column) { if (this.sortColumn != column) {
this.sortColumn = column this.sortColumn = column
this.sortDirection = 'descending' this.sortDirection = 'descending'
} else if (this.sortDirection == 'descending') { } else if (this.sortDirection == 'descending') {
this.sortDirection = 'ascending' this.sortDirection = 'ascending'
} else { } else {
this.sortDirection = 'descending' this.sortDirection = 'descending'
} }
this.updateSort() this.updateSort()
} }
private updateSort() { private updateSort() {
if (this.sortColumn != null) { if (this.sortColumn != null) {
this.results.sort(Comparators.comparing(this.sortColumn, { reversed: this.sortDirection == 'ascending' })) this.results.sort(Comparators.comparing(this.sortColumn, { reversed: this.sortDirection == 'ascending' }))
} }
} }
/** /**
* Called when the user checks the `checkboxColumn`. * Called when the user checks the `checkboxColumn`.
*/ */
checkAll(isChecked: boolean) { checkAll(isChecked: boolean) {
if (isChecked) { if (isChecked) {
this.selectionService.selectAll() this.selectionService.selectAll()
} else { } else {
this.selectionService.deselectAll() this.selectionService.deselectAll()
} }
} }
} }

View File

@@ -1,235 +1,240 @@
<div id="searchMenu" class="ui bottom attached borderless menu"> <div id="searchMenu" class="ui bottom attached borderless menu">
<div class="item"> <div class="item">
<div class="ui icon input" [class.loading]="isLoading()"> <div class="ui icon input" [class.loading]="isLoading()">
<input #searchBox type="text" placeholder=" Search..." (keyup.enter)="onSearch(searchBox.value)"> <input #searchBox type="text" placeholder=" Search..." (keyup.enter)="onSearch(searchBox.value)" />
<i #searchIcon id="searchIcon" class="link icon" [ngClass]="isError ? 'red exclamation triangle' : 'search'" <i
data-content="Failed to connect to the Bridge database" data-position="bottom right"></i> #searchIcon
</div> id="searchIcon"
</div> class="link icon"
[ngClass]="isError ? 'red exclamation triangle' : 'search'"
data-content="Failed to connect to the Bridge database"
data-position="bottom right"></i>
</div>
</div>
<div id="quantityDropdownItem" [class.hidden]="!showAdvanced" class="item"> <div id="quantityDropdownItem" [class.hidden]="!showAdvanced" class="item">
<div #quantityDropdown class="ui compact selection dropdown"> <div #quantityDropdown class="ui compact selection dropdown">
<input name="quantityMatch" type="hidden"> <input name="quantityMatch" type="hidden" />
<i class="dropdown icon"></i> <i class="dropdown icon"></i>
<div class="text"><b>all</b> words match</div> <div class="text"><b>all</b> words match</div>
<div class="menu"> <div class="menu">
<div id="dropdownItem" class="active selected item" data-value="all"><b>all</b> words match</div> <div id="dropdownItem" class="active selected item" data-value="all"><b>all</b> words match</div>
<div id="dropdownItem" class="item" data-value="any"><b>any</b> words match</div> <div id="dropdownItem" class="item" data-value="any"><b>any</b> words match</div>
</div> </div>
</div> </div>
</div> </div>
<div id="similarityDropdownItem" [class.hidden]="!showAdvanced" class="item"> <div id="similarityDropdownItem" [class.hidden]="!showAdvanced" class="item">
<div #similarityDropdown class="ui compact selection dropdown"> <div #similarityDropdown class="ui compact selection dropdown">
<input name="similarityMatch" type="hidden" value="similar"> <input name="similarityMatch" type="hidden" value="similar" />
<i class="dropdown icon"></i> <i class="dropdown icon"></i>
<div class="text"><b>similar</b> words match</div> <div class="text"><b>similar</b> words match</div>
<div class="menu"> <div class="menu">
<div id="dropdownItem" class="active selected item" data-value="similar"><b>similar</b> words match</div> <div id="dropdownItem" class="active selected item" data-value="similar"><b>similar</b> words match</div>
<div id="dropdownItem" class="item" data-value="exact"><b>exact</b> words match</div> <div id="dropdownItem" class="item" data-value="exact"><b>exact</b> words match</div>
</div> </div>
</div> </div>
</div> </div>
<div class="item right"> <div class="item right">
<button class="mini ui right labeled icon button" (click)="onAdvancedSearchClick()"> <button class="mini ui right labeled icon button" (click)="onAdvancedSearchClick()">
Advanced Search Advanced Search
<i class="angle double icon" [ngClass]="showAdvanced ? 'up' : 'down'"></i> <i class="angle double icon" [ngClass]="showAdvanced ? 'up' : 'down'"></i>
</button> </button>
</div> </div>
</div> </div>
<div id="advancedSearchForm" [class.collapsed]="!showAdvanced" class="ui horizontal segments"> <div id="advancedSearchForm" [class.collapsed]="!showAdvanced" class="ui horizontal segments">
<div class="ui secondary segment"> <div class="ui secondary segment">
<div class="grouped fields"> <div class="grouped fields">
<h5 class="ui dividing header">Search for</h5> <h5 class="ui dividing header">Search for</h5>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="name" [(ngModel)]="searchSettings.fields.name"> <input type="checkbox" name="name" [(ngModel)]="searchSettings.fields.name" />
<label>Name</label> <label>Name</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="artist" [(ngModel)]="searchSettings.fields.artist"> <input type="checkbox" name="artist" [(ngModel)]="searchSettings.fields.artist" />
<label>Artist</label> <label>Artist</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="album" [(ngModel)]="searchSettings.fields.album"> <input type="checkbox" name="album" [(ngModel)]="searchSettings.fields.album" />
<label>Album</label> <label>Album</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="genre" [(ngModel)]="searchSettings.fields.genre"> <input type="checkbox" name="genre" [(ngModel)]="searchSettings.fields.genre" />
<label>Genre</label> <label>Genre</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="year" [(ngModel)]="searchSettings.fields.year"> <input type="checkbox" name="year" [(ngModel)]="searchSettings.fields.year" />
<label>Year</label> <label>Year</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="charter" [(ngModel)]="searchSettings.fields.charter"> <input type="checkbox" name="charter" [(ngModel)]="searchSettings.fields.charter" />
<label>Charter</label> <label>Charter</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="tag" [(ngModel)]="searchSettings.fields.tag"> <input type="checkbox" name="tag" [(ngModel)]="searchSettings.fields.tag" />
<label>Tag</label> <label>Tag</label>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="ui secondary segment"> <div class="ui secondary segment">
<div class="grouped fields"> <div class="grouped fields">
<h5 class="ui dividing header">Must include</h5> <h5 class="ui dividing header">Must include</h5>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="sections" [(ngModel)]="searchSettings.tags.sections"> <input type="checkbox" name="sections" [(ngModel)]="searchSettings.tags.sections" />
<label>Sections</label> <label>Sections</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="starpower" [(ngModel)]="searchSettings.tags['star power']"> <input type="checkbox" name="starpower" [(ngModel)]="searchSettings.tags['star power']" />
<label>Star Power</label> <label>Star Power</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="forcing" [(ngModel)]="searchSettings.tags.forcing"> <input type="checkbox" name="forcing" [(ngModel)]="searchSettings.tags.forcing" />
<label>Forcing</label> <label>Forcing</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="taps" [(ngModel)]="searchSettings.tags.taps"> <input type="checkbox" name="taps" [(ngModel)]="searchSettings.tags.taps" />
<label>Taps</label> <label>Taps</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="lyrics" [(ngModel)]="searchSettings.tags.lyrics"> <input type="checkbox" name="lyrics" [(ngModel)]="searchSettings.tags.lyrics" />
<label>Lyrics</label> <label>Lyrics</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="videobackground" [(ngModel)]="searchSettings.tags.video"> <input type="checkbox" name="videobackground" [(ngModel)]="searchSettings.tags.video" />
<label>Video Background</label> <label>Video Background</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="stems" [(ngModel)]="searchSettings.tags.stems"> <input type="checkbox" name="stems" [(ngModel)]="searchSettings.tags.stems" />
<label>Audio Stems</label> <label>Audio Stems</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="solosections" [(ngModel)]="searchSettings.tags['solo sections']"> <input type="checkbox" name="solosections" [(ngModel)]="searchSettings.tags['solo sections']" />
<label>Solo Sections</label> <label>Solo Sections</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="opennotes" [(ngModel)]="searchSettings.tags['open notes']"> <input type="checkbox" name="opennotes" [(ngModel)]="searchSettings.tags['open notes']" />
<label>Open Notes</label> <label>Open Notes</label>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="ui secondary segment"> <div class="ui secondary segment">
<div class="grouped fields"> <div class="grouped fields">
<h5 class="ui dividing header">Instruments</h5> <h5 class="ui dividing header">Instruments</h5>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="guitar" [(ngModel)]="searchSettings.instruments.guitar"> <input type="checkbox" name="guitar" [(ngModel)]="searchSettings.instruments.guitar" />
<label>Guitar</label> <label>Guitar</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="bass" [(ngModel)]="searchSettings.instruments.bass"> <input type="checkbox" name="bass" [(ngModel)]="searchSettings.instruments.bass" />
<label>Bass</label> <label>Bass</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="rhythm" [(ngModel)]="searchSettings.instruments.rhythm"> <input type="checkbox" name="rhythm" [(ngModel)]="searchSettings.instruments.rhythm" />
<label>Rhythm</label> <label>Rhythm</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="keys" [(ngModel)]="searchSettings.instruments.keys"> <input type="checkbox" name="keys" [(ngModel)]="searchSettings.instruments.keys" />
<label>Keys</label> <label>Keys</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="drums" [(ngModel)]="searchSettings.instruments.drums"> <input type="checkbox" name="drums" [(ngModel)]="searchSettings.instruments.drums" />
<label>Drums</label> <label>Drums</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="guitarghl" [(ngModel)]="searchSettings.instruments.guitarghl"> <input type="checkbox" name="guitarghl" [(ngModel)]="searchSettings.instruments.guitarghl" />
<label>GHL Guitar</label> <label>GHL Guitar</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="bassghl" [(ngModel)]="searchSettings.instruments.bassghl"> <input type="checkbox" name="bassghl" [(ngModel)]="searchSettings.instruments.bassghl" />
<label>GHL Bass</label> <label>GHL Bass</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="vocals" [(ngModel)]="searchSettings.instruments.vocals"> <input type="checkbox" name="vocals" [(ngModel)]="searchSettings.instruments.vocals" />
<label>Vocals</label> <label>Vocals</label>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="ui secondary segment"> <div class="ui secondary segment">
<h5 class="ui dividing header">Charted difficulties</h5> <h5 class="ui dividing header">Charted difficulties</h5>
<div class="grouped fields"> <div class="grouped fields">
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="expert" [(ngModel)]="searchSettings.difficulties.expert"> <input type="checkbox" name="expert" [(ngModel)]="searchSettings.difficulties.expert" />
<label>Expert</label> <label>Expert</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="hard" [(ngModel)]="searchSettings.difficulties.hard"> <input type="checkbox" name="hard" [(ngModel)]="searchSettings.difficulties.hard" />
<label>Hard</label> <label>Hard</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="medium" [(ngModel)]="searchSettings.difficulties.medium"> <input type="checkbox" name="medium" [(ngModel)]="searchSettings.difficulties.medium" />
<label>Medium</label> <label>Medium</label>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="easy" [(ngModel)]="searchSettings.difficulties.easy"> <input type="checkbox" name="easy" [(ngModel)]="searchSettings.difficulties.easy" />
<label>Easy</label> <label>Easy</label>
</div> </div>
</div> </div>
<h5 class="ui dividing header">Difficulty range</h5> <h5 class="ui dividing header">Difficulty range</h5>
<div class="field"> <div class="field">
<div #diffSlider class="ui labeled ticked inverted range slider" id="diffSlider"></div> <div #diffSlider class="ui labeled ticked inverted range slider" id="diffSlider"></div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -48,4 +48,4 @@
visibility 0s linear 350ms, visibility 0s linear 350ms,
max-width 0s linear 350ms, max-width 0s linear 350ms,
max-height 0s linear 350ms !important; max-height 0s linear 350ms !important;
} }

View File

@@ -1,74 +1,75 @@
import { Component, AfterViewInit, ViewChild, ElementRef } from '@angular/core' import { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core'
import { SearchService } from 'src/app/core/services/search.service' import { SearchService } from 'src/app/core/services/search.service'
import { getDefaultSearch, SongSearch } from 'src/electron/shared/interfaces/search.interface' import { getDefaultSearch } from 'src/electron/shared/interfaces/search.interface'
@Component({ @Component({
selector: 'app-search-bar', selector: 'app-search-bar',
templateUrl: './search-bar.component.html', templateUrl: './search-bar.component.html',
styleUrls: ['./search-bar.component.scss'] styleUrls: ['./search-bar.component.scss'],
}) })
export class SearchBarComponent implements AfterViewInit { export class SearchBarComponent implements AfterViewInit {
@ViewChild('searchIcon', { static: true }) searchIcon: ElementRef @ViewChild('searchIcon', { static: true }) searchIcon: ElementRef
@ViewChild('quantityDropdown', { static: true }) quantityDropdown: ElementRef @ViewChild('quantityDropdown', { static: true }) quantityDropdown: ElementRef
@ViewChild('similarityDropdown', { static: true }) similarityDropdown: ElementRef @ViewChild('similarityDropdown', { static: true }) similarityDropdown: ElementRef
@ViewChild('diffSlider', { static: true }) diffSlider: ElementRef @ViewChild('diffSlider', { static: true }) diffSlider: ElementRef
isError = false isError = false
showAdvanced = false showAdvanced = false
searchSettings = getDefaultSearch() searchSettings = getDefaultSearch()
private sliderInitialized = false private sliderInitialized = false
constructor(public searchService: SearchService) { } constructor(public searchService: SearchService) { }
ngAfterViewInit() { ngAfterViewInit() {
$(this.searchIcon.nativeElement).popup({ $(this.searchIcon.nativeElement).popup({
onShow: () => this.isError // Only show the popup if there is an error onShow: () => this.isError, // Only show the popup if there is an error
}) })
this.searchService.onSearchErrorStateUpdate((isError) => { this.searchService.onSearchErrorStateUpdate(isError => {
this.isError = isError this.isError = isError
}) })
$(this.quantityDropdown.nativeElement).dropdown({ $(this.quantityDropdown.nativeElement).dropdown({
onChange: (value: string) => { onChange: (value: string) => {
this.searchSettings.quantity = value as 'all' | 'any' this.searchSettings.quantity = value as 'all' | 'any'
} },
}) })
$(this.similarityDropdown.nativeElement).dropdown({ $(this.similarityDropdown.nativeElement).dropdown({
onChange: (value: string) => { onChange: (value: string) => {
this.searchSettings.similarity = value as 'similar' | 'exact' this.searchSettings.similarity = value as 'similar' | 'exact'
} },
}) })
} }
onSearch(query: string) { onSearch(query: string) {
this.searchSettings.query = query this.searchSettings.query = query
this.searchSettings.limit = 50 + 1 this.searchSettings.limit = 50 + 1
this.searchSettings.offset = 0 this.searchSettings.offset = 0
this.searchService.newSearch(this.searchSettings) this.searchService.newSearch(this.searchSettings)
} }
onAdvancedSearchClick() { onAdvancedSearchClick() {
this.showAdvanced = !this.showAdvanced this.showAdvanced = !this.showAdvanced
if (!this.sliderInitialized) { if (!this.sliderInitialized) {
setTimeout(() => { // Initialization requires this element to not be collapsed setTimeout(() => { // Initialization requires this element to not be collapsed
$(this.diffSlider.nativeElement).slider({ $(this.diffSlider.nativeElement).slider({
min: 0, min: 0,
max: 6, max: 6,
start: 0, start: 0,
end: 6, end: 6,
step: 1, step: 1,
onChange: (_length: number, min: number, max: number) => { onChange: (_length: number, min: number, max: number) => {
this.searchSettings.minDiff = min this.searchSettings.minDiff = min
this.searchSettings.maxDiff = max this.searchSettings.maxDiff = max
} },
}) })
}, 50) }, 50)
this.sliderInitialized = true this.sliderInitialized = true
} }
} }
isLoading() { isLoading() {
return this.searchService.isLoading() return this.searchService.isLoading()
} }
} }

View File

@@ -1,30 +1,28 @@
<div *ngIf="downloads.length > 0" class="ui segments"> <div *ngIf="downloads.length > 0" class="ui segments">
<div *ngFor="let download of downloads; trackBy:trackByVersionID" class="ui segment" [style.background-color]="getBackgroundColor(download)"> <div *ngFor="let download of downloads; trackBy: trackByVersionID" class="ui segment" [style.background-color]="getBackgroundColor(download)">
<h3 id="downloadTitle">
<span style="flex-grow: 1">{{ download.title }}</span>
<i class="inside right close icon" (click)="cancelDownload(download.versionID)"></i>
</h3>
<h3 id="downloadTitle"> <div id="downloadText">
<span style="flex-grow: 1;">{{download.title}}</span> <span style="flex-grow: 1">{{ download.header }}</span>
<i class="inside right close icon" (click)="cancelDownload(download.versionID)"></i> <span *ngIf="!download.isLink" class="description">{{ download.description }}</span>
</h3> <span *ngIf="download.isLink" class="description">
<a (click)="openFolder(download.description)">{{ download.description }}</a>
</span>
</div>
<div id="downloadText"> <div id="downloadProgressDiv">
<span style="flex-grow: 1;">{{download.header}}</span> <div id="downloadProgressBar" appProgressBar [percent]="download.percent" class="ui small progress">
<span *ngIf="!download.isLink" class="description">{{download.description}}</span> <div class="bar" [style.height]="download.type === 'error' ? '-webkit-fill-available' : undefined">
<span *ngIf="download.isLink" class="description"> <div class="progress"></div>
<a (click)="openFolder(download.description)">{{download.description}}</a> </div>
</span> </div>
</div> <button *ngIf="download.type === 'error'" class="ui right attached labeled compact icon button" (click)="retryDownload(download.versionID)">
<i class="redo icon"></i>
<div id="downloadProgressDiv"> Retry
<div id="downloadProgressBar" appProgressBar [percent]="download.percent" class="ui small progress"> </button>
<div class="bar" [style.height]="download.type == 'error' ? '-webkit-fill-available' : undefined"> </div>
<div class="progress"></div> </div>
</div> </div>
</div>
<button *ngIf="download.type == 'error'" class="ui right attached labeled compact icon button"
(click)="retryDownload(download.versionID)">
<i class="redo icon"></i>
Retry
</button>
</div>
</div>
</div>

View File

@@ -23,4 +23,4 @@
i.close.icon, a { i.close.icon, a {
cursor: pointer; cursor: pointer;
} }

View File

@@ -1,55 +1,56 @@
import { Component, ChangeDetectorRef } from '@angular/core' import { ChangeDetectorRef, Component } from '@angular/core'
import { DownloadProgress } from '../../../../../electron/shared/interfaces/download.interface' import { DownloadProgress } from '../../../../../electron/shared/interfaces/download.interface'
import { DownloadService } from '../../../../core/services/download.service' import { DownloadService } from '../../../../core/services/download.service'
import { ElectronService } from '../../../../core/services/electron.service' import { ElectronService } from '../../../../core/services/electron.service'
@Component({ @Component({
selector: 'app-downloads-modal', selector: 'app-downloads-modal',
templateUrl: './downloads-modal.component.html', templateUrl: './downloads-modal.component.html',
styleUrls: ['./downloads-modal.component.scss'] styleUrls: ['./downloads-modal.component.scss'],
}) })
export class DownloadsModalComponent { export class DownloadsModalComponent {
downloads: DownloadProgress[] = [] downloads: DownloadProgress[] = []
constructor(private electronService: ElectronService, private downloadService: DownloadService, ref: ChangeDetectorRef) { constructor(private electronService: ElectronService, private downloadService: DownloadService, ref: ChangeDetectorRef) {
electronService.receiveIPC('queue-updated', (order) => { electronService.receiveIPC('queue-updated', order => {
this.downloads.sort((a, b) => order.indexOf(a.versionID) - order.indexOf(b.versionID)) this.downloads.sort((a, b) => order.indexOf(a.versionID) - order.indexOf(b.versionID))
}) })
downloadService.onDownloadUpdated(download => { downloadService.onDownloadUpdated(download => {
const index = this.downloads.findIndex(thisDownload => thisDownload.versionID == download.versionID) const index = this.downloads.findIndex(thisDownload => thisDownload.versionID == download.versionID)
if (download.type == 'cancel') { if (download.type == 'cancel') {
this.downloads = this.downloads.filter(thisDownload => thisDownload.versionID != download.versionID) this.downloads = this.downloads.filter(thisDownload => thisDownload.versionID != download.versionID)
} else if (index == -1) { } else if (index == -1) {
this.downloads.push(download) this.downloads.push(download)
} else { } else {
this.downloads[index] = download this.downloads[index] = download
} }
ref.detectChanges() ref.detectChanges()
}) })
} }
trackByVersionID(_index: number, item: DownloadProgress) { trackByVersionID(_index: number, item: DownloadProgress) {
return item.versionID return item.versionID
} }
cancelDownload(versionID: number) { cancelDownload(versionID: number) {
this.downloadService.cancelDownload(versionID) this.downloadService.cancelDownload(versionID)
} }
retryDownload(versionID: number) { retryDownload(versionID: number) {
this.downloadService.retryDownload(versionID) this.downloadService.retryDownload(versionID)
} }
getBackgroundColor(download: DownloadProgress) { getBackgroundColor(download: DownloadProgress) {
switch (download.type) { switch (download.type) {
case 'error': return '#a63a3a' case 'error': return '#a63a3a'
default: return undefined default: return undefined
} }
} }
openFolder(filepath: string) { openFolder(filepath: string) {
this.electronService.showFolder(filepath) this.electronService.showFolder(filepath)
} }
} }

View File

@@ -1,42 +1,44 @@
<div id="bottomMenu" class="ui bottom borderless menu"> <div id="bottomMenu" class="ui bottom borderless menu">
<div *ngIf="resultCount > 0" class="item">{{resultCount}}{{allResultsVisible ? '' : '+'}} Result{{resultCount == 1 ? '' : 's'}}</div> <div *ngIf="resultCount > 0" class="item">{{ resultCount }}{{ allResultsVisible ? '' : '+' }} Result{{ resultCount === 1 ? '' : 's' }}</div>
<div class="item"> <div class="item">
<button *ngIf="selectedResults.length > 1" (click)="downloadSelected()" class="ui positive button"> <button *ngIf="selectedResults.length > 1" (click)="downloadSelected()" class="ui positive button">
Download {{selectedResults.length}} Results Download {{ selectedResults.length }} Results
</button> </button>
</div> </div>
<a *ngIf="downloading" class="item right" (click)="showDownloads()"> <a *ngIf="downloading" class="item right" (click)="showDownloads()">
<div #progressBar appProgressBar [percent]="percent" class="ui progress" [style.background-color]="error ? 'indianred' : undefined"> <div #progressBar appProgressBar [percent]="percent" class="ui progress" [style.background-color]="error ? 'indianred' : undefined">
<div class="bar"> <div class="bar">
<div class="progress"></div> <div class="progress"></div>
</div> </div>
</div> </div>
</a> </a>
<div id="selectedModal" class="ui modal"> <div id="selectedModal" class="ui modal">
<div class="header">Some selected songs have more than one chart!</div> <div class="header">Some selected songs have more than one chart!</div>
<div class="scrolling content"> <div class="scrolling content">
<div class="ui segments"> <div class="ui segments">
<div class="ui segment" *ngFor="let chartGroup of chartGroups"> <div class="ui segment" *ngFor="let chartGroup of chartGroups">
<p *ngFor="let chart of chartGroup">{{chart.chartName}} <b>[{{chart.charters}}]</b></p> <p *ngFor="let chart of chartGroup">
</div> {{ chart.chartName }} <b>[{{ chart.charters }}]</b>
</div> </p>
</div> </div>
<div class="actions"> </div>
<div class="ui approve button" (click)="downloadAllCharts()">Download all charts for each song</div> </div>
<div class="ui cancel button" (click)="deselectSongsWithMultipleCharts()">Deselect these songs</div> <div class="actions">
<div class="ui cancel button">Cancel</div> <div class="ui approve button" (click)="downloadAllCharts()">Download all charts for each song</div>
</div> <div class="ui cancel button" (click)="deselectSongsWithMultipleCharts()">Deselect these songs</div>
</div> <div class="ui cancel button">Cancel</div>
</div>
</div>
<div id="downloadsModal" class="ui modal"> <div id="downloadsModal" class="ui modal">
<i class="inside close icon"></i> <i class="inside close icon"></i>
<div class="header"> <div class="header">
<span>Downloads</span> <span>Downloads</span>
<div *ngIf="multipleCompleted" class="ui positive compact button" (click)="clearCompleted()">Clear completed</div> <div *ngIf="multipleCompleted" class="ui positive compact button" (click)="clearCompleted()">Clear completed</div>
</div> </div>
<div class="scrolling content"> <div class="scrolling content">
<app-downloads-modal></app-downloads-modal> <app-downloads-modal></app-downloads-modal>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -20,4 +20,4 @@
margin-right: 30px; margin-right: 30px;
} }
} }
} }

View File

@@ -1,114 +1,115 @@
import { Component, ChangeDetectorRef } from '@angular/core' import { ChangeDetectorRef, Component } from '@angular/core'
import { VersionResult } from '../../../../electron/shared/interfaces/songDetails.interface'
import { groupBy } from '../../../../electron/shared/UtilFunctions'
import { DownloadService } from '../../../core/services/download.service' import { DownloadService } from '../../../core/services/download.service'
import { ElectronService } from '../../../core/services/electron.service' import { ElectronService } from '../../../core/services/electron.service'
import { groupBy } from '../../../../electron/shared/UtilFunctions'
import { VersionResult } from '../../../../electron/shared/interfaces/songDetails.interface'
import { SearchService } from '../../../core/services/search.service' import { SearchService } from '../../../core/services/search.service'
import { SelectionService } from '../../../core/services/selection.service' import { SelectionService } from '../../../core/services/selection.service'
@Component({ @Component({
selector: 'app-status-bar', selector: 'app-status-bar',
templateUrl: './status-bar.component.html', templateUrl: './status-bar.component.html',
styleUrls: ['./status-bar.component.scss'] styleUrls: ['./status-bar.component.scss'],
}) })
export class StatusBarComponent { export class StatusBarComponent {
resultCount = 0 resultCount = 0
multipleCompleted = false multipleCompleted = false
downloading = false downloading = false
error = false error = false
percent = 0 percent = 0
batchResults: VersionResult[] batchResults: VersionResult[]
chartGroups: VersionResult[][] chartGroups: VersionResult[][]
constructor( constructor(
private electronService: ElectronService, private electronService: ElectronService,
private downloadService: DownloadService, private downloadService: DownloadService,
private searchService: SearchService, private searchService: SearchService,
private selectionService: SelectionService, private selectionService: SelectionService,
ref: ChangeDetectorRef ref: ChangeDetectorRef
) { ) {
downloadService.onDownloadUpdated(() => { downloadService.onDownloadUpdated(() => {
setTimeout(() => { // Make sure this is the last callback executed to get the accurate downloadCount setTimeout(() => { // Make sure this is the last callback executed to get the accurate downloadCount
this.downloading = downloadService.downloadCount > 0 this.downloading = downloadService.downloadCount > 0
this.multipleCompleted = downloadService.completedCount > 1 this.multipleCompleted = downloadService.completedCount > 1
this.percent = downloadService.totalDownloadingPercent this.percent = downloadService.totalDownloadingPercent
this.error = downloadService.anyErrorsExist this.error = downloadService.anyErrorsExist
ref.detectChanges() ref.detectChanges()
}, 0) }, 0)
}) })
searchService.onSearchChanged(() => { searchService.onSearchChanged(() => {
this.resultCount = searchService.resultCount this.resultCount = searchService.resultCount
}) })
} }
get allResultsVisible() { get allResultsVisible() {
return this.searchService.allResultsVisible return this.searchService.allResultsVisible
} }
get selectedResults() { get selectedResults() {
return this.selectionService.getSelectedResults() return this.selectionService.getSelectedResults()
} }
showDownloads() { showDownloads() {
$('#downloadsModal').modal('show') $('#downloadsModal').modal('show')
} }
async downloadSelected() { async downloadSelected() {
this.chartGroups = [] this.chartGroups = []
this.batchResults = await this.electronService.invoke('batch-song-details', this.selectedResults.map(result => result.id)) this.batchResults = await this.electronService.invoke('batch-song-details', this.selectedResults.map(result => result.id))
const versionGroups = groupBy(this.batchResults, 'songID') const versionGroups = groupBy(this.batchResults, 'songID')
for (const versionGroup of versionGroups) { for (const versionGroup of versionGroups) {
if (versionGroup.findIndex(version => version.chartID != versionGroup[0].chartID) != -1) { if (versionGroup.findIndex(version => version.chartID != versionGroup[0].chartID) != -1) {
// Must have multiple charts of this song // Must have multiple charts of this song
this.chartGroups.push(versionGroup.filter(version => version.versionID == version.latestVersionID)) this.chartGroups.push(versionGroup.filter(version => version.versionID == version.latestVersionID))
} }
} }
if (this.chartGroups.length == 0) { if (this.chartGroups.length == 0) {
for (const versions of versionGroups) { for (const versions of versionGroups) {
this.searchService.sortChart(versions) this.searchService.sortChart(versions)
const downloadVersion = versions[0] const downloadVersion = versions[0]
const downloadSong = this.selectedResults.find(song => song.id == downloadVersion.songID) const downloadSong = this.selectedResults.find(song => song.id == downloadVersion.songID)
this.downloadService.addDownload( this.downloadService.addDownload(
downloadVersion.versionID, { downloadVersion.versionID, {
chartName: downloadVersion.chartName, chartName: downloadVersion.chartName,
artist: downloadSong.artist, artist: downloadSong.artist,
charter: downloadVersion.charters, charter: downloadVersion.charters,
driveData: downloadVersion.driveData driveData: downloadVersion.driveData,
}) })
} }
} else { } else {
$('#selectedModal').modal('show') $('#selectedModal').modal('show')
// [download all charts for each song] [deselect these songs] [X] // [download all charts for each song] [deselect these songs] [X]
} }
} }
downloadAllCharts() { downloadAllCharts() {
const songChartGroups = groupBy(this.batchResults, 'songID', 'chartID') const songChartGroups = groupBy(this.batchResults, 'songID', 'chartID')
for (const chart of songChartGroups) { for (const chart of songChartGroups) {
this.searchService.sortChart(chart) this.searchService.sortChart(chart)
const downloadVersion = chart[0] const downloadVersion = chart[0]
const downloadSong = this.selectedResults.find(song => song.id == downloadVersion.songID) const downloadSong = this.selectedResults.find(song => song.id == downloadVersion.songID)
this.downloadService.addDownload( this.downloadService.addDownload(
downloadVersion.versionID, { downloadVersion.versionID, {
chartName: downloadVersion.chartName, chartName: downloadVersion.chartName,
artist: downloadSong.artist, artist: downloadSong.artist,
charter: downloadVersion.charters, charter: downloadVersion.charters,
driveData: downloadVersion.driveData driveData: downloadVersion.driveData,
} }
) )
} }
} }
deselectSongsWithMultipleCharts() { deselectSongsWithMultipleCharts() {
for (const chartGroup of this.chartGroups) { for (const chartGroup of this.chartGroups) {
this.selectionService.deselectSong(chartGroup[0].songID) this.selectionService.deselectSong(chartGroup[0].songID)
} }
} }
clearCompleted() { clearCompleted() {
this.downloadService.cancelCompleted() this.downloadService.cancelCompleted()
} }
} }

View File

@@ -1,67 +1,71 @@
<h3 class="ui header">Paths</h3> <h3 class="ui header">Paths</h3>
<div class="ui form"> <div class="ui form">
<div class="field"> <div class="field">
<label>Chart library directory</label> <label>Chart library directory</label>
<div class="ui action input"> <div class="ui action input">
<input [value]="settingsService.libraryDirectory || 'No folder selected'" class="default-cursor" readonly <input
type="text" placeholder="No directory selected!"> [value]="settingsService.libraryDirectory || 'No folder selected'"
<button *ngIf="settingsService.libraryDirectory != undefined" (click)="openLibraryDirectory()" class="default-cursor"
class="ui button">Open Folder</button> readonly
<button (click)="getLibraryDirectory()" class="ui button positive">Choose</button> type="text"
</div> placeholder="No directory selected!" />
</div> <button *ngIf="settingsService.libraryDirectory !== undefined" (click)="openLibraryDirectory()" class="ui button">Open Folder</button>
<button (click)="getLibraryDirectory()" class="ui button positive">Choose</button>
</div>
</div>
</div> </div>
<h3 class="ui header">Cache</h3> <h3 class="ui header">Cache</h3>
<div>Current Cache Size: <div class="ui label" style="margin-left: 1em;">{{cacheSize}}</div> <div>
Current Cache Size:
<div class="ui label" style="margin-left: 1em">{{ cacheSize }}</div>
</div> </div>
<button style="margin-top: 0.5em;" (click)="clearCache()" class="ui button">Clear Cache</button> <button style="margin-top: 0.5em" (click)="clearCache()" class="ui button">Clear Cache</button>
<h3 class="ui header">Downloads</h3> <h3 class="ui header">Downloads</h3>
<div class="ui form"> <div class="ui form">
<div class="field"> <div class="field">
<div appCheckbox #videoCheckbox class="ui checkbox" (checked)="downloadVideos($event)"> <div appCheckbox #videoCheckbox class="ui checkbox" (checked)="downloadVideos($event)">
<input type="checkbox"> <input type="checkbox" />
<label>Download video backgrounds</label> <label>Download video backgrounds</label>
</div> </div>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label>Google rate limit delay</label> <label>Google rate limit delay</label>
<div id="rateLimitInput" class="ui right labeled input"> <div id="rateLimitInput" class="ui right labeled input">
<input type="number" [value]="settingsService.rateLimitDelay" (input)="changeRateLimit($event)"> <input type="number" [value]="settingsService.rateLimitDelay" (input)="changeRateLimit($event)" />
<div class="ui basic label"> <div class="ui basic label">sec</div>
sec </div>
</div>
</div>
</div> </div>
<div *ngIf="settingsService.rateLimitDelay < 30" class="ui warning message"> <div *ngIf="settingsService.rateLimitDelay < 30" class="ui warning message">
<i class="exclamation circle icon"></i> <i class="exclamation circle icon"></i>
<b>Warning:</b> downloading files from Google with a delay less than about 30 seconds will eventually cause Google to <b>Warning:</b> downloading files from Google with a delay less than about 30 seconds will eventually cause Google to refuse download requests from
refuse download requests from this program for a few hours. This limitation will be removed in a future update. this program for a few hours. This limitation will be removed in a future update.
</div> </div>
<h3 class="ui header">Theme</h3> <h3 class="ui header">Theme</h3>
<div #themeDropdown class="ui selection dropdown mr"> <div #themeDropdown class="ui selection dropdown mr">
<input type="hidden" name="sort" [value]="settingsService.theme"> <input type="hidden" name="sort" [value]="settingsService.theme" />
<i class="dropdown icon"></i> <i class="dropdown icon"></i>
<div class="default text">{{settingsService.theme}}</div> <div class="default text">{{ settingsService.theme }}</div>
<div class="menu"> <div class="menu">
<div class="item" [attr.data-value]="i" *ngFor="let theme of settingsService.builtinThemes; let i = index">{{theme}}</div> <div class="item" [attr.data-value]="i" *ngFor="let theme of settingsService.builtinThemes; let i = index">{{ theme }}</div>
</div> </div>
</div> </div>
<div class="bottom"> <div class="bottom">
<div class="ui buttons"> <div class="ui buttons">
<button *ngIf="updateAvailable" class="ui labeled icon positive button" (click)="downloadUpdate()"> <button *ngIf="updateAvailable" class="ui labeled icon positive button" (click)="downloadUpdate()">
<i class="left alternate icon" [ngClass]="(updateDownloaded ? 'sync' : 'cloud download')"></i>{{downloadUpdateText}}</button> <i class="left alternate icon" [ngClass]="updateDownloaded ? 'sync' : 'cloud download'"></i>{{ downloadUpdateText }}
<button *ngIf="updateAvailable === null" class="ui labeled yellow icon button" [class.disabled]="updateRetrying" (click)="retryUpdate()"> </button>
<i class="left alternate sync alternate icon" [class.loading]="updateRetrying"></i>{{retryUpdateText}}</button> <button *ngIf="updateAvailable === null" class="ui labeled yellow icon button" [class.disabled]="updateRetrying" (click)="retryUpdate()">
<button id="versionNumberButton" class="ui basic disabled button">{{currentVersion}}</button> <i class="left alternate sync alternate icon" [class.loading]="updateRetrying"></i>{{ retryUpdateText }}
</div> </button>
<button id="versionNumberButton" class="ui basic disabled button">{{ currentVersion }}</button>
</div>
<button class="ui basic icon button" data-tooltip="Toggle developer tools" data-position="top right" <button class="ui basic icon button" data-tooltip="Toggle developer tools" data-position="top right" (click)="toggleDevTools()">
(click)="toggleDevTools()"> <i class="cog icon"></i>
<i class="cog icon"></i> </button>
</button> </div>
</div>

View File

@@ -21,4 +21,4 @@
#versionNumberButton { #versionNumberButton {
margin-right: 1em; margin-right: 1em;
} }

View File

@@ -1,139 +1,140 @@
import { Component, OnInit, AfterViewInit, ChangeDetectorRef, ViewChild, ElementRef } from '@angular/core' import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
import { CheckboxDirective } from 'src/app/core/directives/checkbox.directive' import { CheckboxDirective } from 'src/app/core/directives/checkbox.directive'
import { ElectronService } from 'src/app/core/services/electron.service' import { ElectronService } from 'src/app/core/services/electron.service'
import { SettingsService } from 'src/app/core/services/settings.service' import { SettingsService } from 'src/app/core/services/settings.service'
@Component({ @Component({
selector: 'app-settings', selector: 'app-settings',
templateUrl: './settings.component.html', templateUrl: './settings.component.html',
styleUrls: ['./settings.component.scss'] styleUrls: ['./settings.component.scss'],
}) })
export class SettingsComponent implements OnInit, AfterViewInit { export class SettingsComponent implements OnInit, AfterViewInit {
@ViewChild('themeDropdown', { static: true }) themeDropdown: ElementRef @ViewChild('themeDropdown', { static: true }) themeDropdown: ElementRef
@ViewChild(CheckboxDirective, { static: true }) videoCheckbox: CheckboxDirective @ViewChild(CheckboxDirective, { static: true }) videoCheckbox: CheckboxDirective
cacheSize = 'Calculating...' cacheSize = 'Calculating...'
updateAvailable = false updateAvailable = false
loginClicked = false loginClicked = false
downloadUpdateText = 'Update available' downloadUpdateText = 'Update available'
retryUpdateText = 'Failed to check for update' retryUpdateText = 'Failed to check for update'
updateDownloading = false updateDownloading = false
updateDownloaded = false updateDownloaded = false
updateRetrying = false updateRetrying = false
currentVersion = '' currentVersion = ''
constructor( constructor(
public settingsService: SettingsService, public settingsService: SettingsService,
private electronService: ElectronService, private electronService: ElectronService,
private ref: ChangeDetectorRef private ref: ChangeDetectorRef
) { } ) { }
async ngOnInit() { async ngOnInit() {
this.electronService.receiveIPC('update-available', (result) => { this.electronService.receiveIPC('update-available', result => {
this.updateAvailable = result != null this.updateAvailable = result != null
this.updateRetrying = false this.updateRetrying = false
if (this.updateAvailable) { if (this.updateAvailable) {
this.downloadUpdateText = `Update available (${result.version})` this.downloadUpdateText = `Update available (${result.version})`
} }
this.ref.detectChanges() this.ref.detectChanges()
}) })
this.electronService.receiveIPC('update-error', (err: Error) => { this.electronService.receiveIPC('update-error', (err: Error) => {
console.log(err) console.log(err)
this.updateAvailable = null this.updateAvailable = null
this.updateRetrying = false this.updateRetrying = false
this.retryUpdateText = `Failed to check for update: ${err.message}` this.retryUpdateText = `Failed to check for update: ${err.message}`
this.ref.detectChanges() this.ref.detectChanges()
}) })
this.electronService.invoke('get-current-version', undefined).then(version => { this.electronService.invoke('get-current-version', undefined).then(version => {
this.currentVersion = `v${version}` this.currentVersion = `v${version}`
this.ref.detectChanges() this.ref.detectChanges()
}) })
this.electronService.invoke('get-update-available', undefined).then(isAvailable => { this.electronService.invoke('get-update-available', undefined).then(isAvailable => {
this.updateAvailable = isAvailable this.updateAvailable = isAvailable
this.ref.detectChanges() this.ref.detectChanges()
}) })
const cacheSize = await this.settingsService.getCacheSize() const cacheSize = await this.settingsService.getCacheSize()
this.cacheSize = Math.round(cacheSize / 1000000) + ' MB' this.cacheSize = Math.round(cacheSize / 1000000) + ' MB'
} }
ngAfterViewInit() { ngAfterViewInit() {
$(this.themeDropdown.nativeElement).dropdown({ $(this.themeDropdown.nativeElement).dropdown({
onChange: (_value: string, text: string) => { onChange: (_value: string, text: string) => {
this.settingsService.theme = text this.settingsService.theme = text
} },
}) })
this.videoCheckbox.check(this.settingsService.downloadVideos) this.videoCheckbox.check(this.settingsService.downloadVideos)
} }
async clearCache() { async clearCache() {
this.cacheSize = 'Please wait...' this.cacheSize = 'Please wait...'
await this.settingsService.clearCache() await this.settingsService.clearCache()
this.cacheSize = 'Cleared!' this.cacheSize = 'Cleared!'
} }
async downloadVideos(isChecked: boolean) { async downloadVideos(isChecked: boolean) {
this.settingsService.downloadVideos = isChecked this.settingsService.downloadVideos = isChecked
} }
async getLibraryDirectory() { async getLibraryDirectory() {
const result = await this.electronService.showOpenDialog({ const result = await this.electronService.showOpenDialog({
title: 'Choose library folder', title: 'Choose library folder',
buttonLabel: 'This is where my charts are!', buttonLabel: 'This is where my charts are!',
defaultPath: this.settingsService.libraryDirectory || '', defaultPath: this.settingsService.libraryDirectory || '',
properties: ['openDirectory'] properties: ['openDirectory'],
}) })
if (result.canceled == false) { if (result.canceled == false) {
this.settingsService.libraryDirectory = result.filePaths[0] this.settingsService.libraryDirectory = result.filePaths[0]
} }
} }
openLibraryDirectory() { openLibraryDirectory() {
this.electronService.openFolder(this.settingsService.libraryDirectory) this.electronService.openFolder(this.settingsService.libraryDirectory)
} }
changeRateLimit(event: Event) { changeRateLimit(event: Event) {
const inputElement = event.srcElement as HTMLInputElement const inputElement = event.srcElement as HTMLInputElement
this.settingsService.rateLimitDelay = Number(inputElement.value) this.settingsService.rateLimitDelay = Number(inputElement.value)
} }
downloadUpdate() { downloadUpdate() {
if (this.updateDownloaded) { if (this.updateDownloaded) {
this.electronService.sendIPC('quit-and-install', undefined) this.electronService.sendIPC('quit-and-install', undefined)
} else if (!this.updateDownloading) { } else if (!this.updateDownloading) {
this.updateDownloading = true this.updateDownloading = true
this.electronService.sendIPC('download-update', undefined) this.electronService.sendIPC('download-update', undefined)
this.downloadUpdateText = 'Downloading... (0%)' this.downloadUpdateText = 'Downloading... (0%)'
this.electronService.receiveIPC('update-progress', (result) => { this.electronService.receiveIPC('update-progress', result => {
this.downloadUpdateText = `Downloading... (${result.percent.toFixed(0)}%)` this.downloadUpdateText = `Downloading... (${result.percent.toFixed(0)}%)`
this.ref.detectChanges() this.ref.detectChanges()
}) })
this.electronService.receiveIPC('update-downloaded', () => { this.electronService.receiveIPC('update-downloaded', () => {
this.downloadUpdateText = 'Quit and install update' this.downloadUpdateText = 'Quit and install update'
this.updateDownloaded = true this.updateDownloaded = true
this.ref.detectChanges() this.ref.detectChanges()
}) })
} }
} }
retryUpdate() { retryUpdate() {
if (this.updateRetrying == false) { if (this.updateRetrying == false) {
this.updateRetrying = true this.updateRetrying = true
this.retryUpdateText = 'Retrying...' this.retryUpdateText = 'Retrying...'
this.ref.detectChanges() this.ref.detectChanges()
this.electronService.sendIPC('retry-update', undefined) this.electronService.sendIPC('retry-update', undefined)
} }
} }
toggleDevTools() { toggleDevTools() {
const toolsOpened = this.electronService.currentWindow.webContents.isDevToolsOpened() const toolsOpened = this.electronService.currentWindow.webContents.isDevToolsOpened()
if (toolsOpened) { if (toolsOpened) {
this.electronService.currentWindow.webContents.closeDevTools() this.electronService.currentWindow.webContents.closeDevTools()
} else { } else {
this.electronService.currentWindow.webContents.openDevTools() this.electronService.currentWindow.webContents.openDevTools()
} }
} }
} }

View File

@@ -1,15 +1,15 @@
<div class="ui top menu"> <div class="ui top menu">
<a class="item" routerLinkActive="active" routerLink="/browse">Browse</a> <a class="item" routerLinkActive="active" routerLink="/browse">Browse</a>
<!-- TODO <a class="item" routerLinkActive="active" routerLink="/library">Library</a> --> <!-- TODO <a class="item" routerLinkActive="active" routerLink="/library">Library</a> -->
<a class="item" routerLinkActive="active" routerLink="/settings"> <a class="item" routerLinkActive="active" routerLink="/settings">
<i *ngIf="updateAvailable" class="teal small circle icon"></i> <i *ngIf="updateAvailable" class="teal small circle icon"></i>
<i *ngIf="updateAvailable === null" class="small yellow exclamation triangle icon"></i> <i *ngIf="updateAvailable === null" class="small yellow exclamation triangle icon"></i>
Settings Settings
</a> </a>
<div class="right menu"> <div class="right menu">
<a class="item traffic-light" (click)="minimize()"><i class="minus icon"></i></a> <a class="item traffic-light" (click)="minimize()"><i class="minus icon"></i></a>
<a class="item traffic-light" (click)="toggleMaximized()"><i class="icon window" [ngClass]="isMaximized ? 'restore' : 'maximize'"></i></a> <a class="item traffic-light" (click)="toggleMaximized()"><i class="icon window" [ngClass]="isMaximized ? 'restore' : 'maximize'"></i></a>
<a class="item traffic-light close" (click)="close()"><i class="x icon"></i></a> <a class="item traffic-light close" (click)="close()"><i class="x icon"></i></a>
</div> </div>
</div> </div>

View File

@@ -24,4 +24,4 @@
.close:hover { .close:hover {
background: rgba(255, 0, 0, .15) !important; background: rgba(255, 0, 0, .15) !important;
} }

View File

@@ -1,55 +1,56 @@
import { Component, OnInit, ChangeDetectorRef } from '@angular/core' import { ChangeDetectorRef, Component, OnInit } from '@angular/core'
import { ElectronService } from '../../core/services/electron.service' import { ElectronService } from '../../core/services/electron.service'
@Component({ @Component({
selector: 'app-toolbar', selector: 'app-toolbar',
templateUrl: './toolbar.component.html', templateUrl: './toolbar.component.html',
styleUrls: ['./toolbar.component.scss'] styleUrls: ['./toolbar.component.scss'],
}) })
export class ToolbarComponent implements OnInit { export class ToolbarComponent implements OnInit {
isMaximized: boolean isMaximized: boolean
updateAvailable = false updateAvailable = false
constructor(private electronService: ElectronService, private ref: ChangeDetectorRef) { } constructor(private electronService: ElectronService, private ref: ChangeDetectorRef) { }
async ngOnInit() { async ngOnInit() {
this.isMaximized = this.electronService.currentWindow.isMaximized() this.isMaximized = this.electronService.currentWindow.isMaximized()
this.electronService.currentWindow.on('unmaximize', () => { this.electronService.currentWindow.on('unmaximize', () => {
this.isMaximized = false this.isMaximized = false
this.ref.detectChanges() this.ref.detectChanges()
}) })
this.electronService.currentWindow.on('maximize', () => { this.electronService.currentWindow.on('maximize', () => {
this.isMaximized = true this.isMaximized = true
this.ref.detectChanges() this.ref.detectChanges()
}) })
this.electronService.receiveIPC('update-available', (result) => { this.electronService.receiveIPC('update-available', result => {
this.updateAvailable = result != null this.updateAvailable = result != null
this.ref.detectChanges() this.ref.detectChanges()
}) })
this.electronService.receiveIPC('update-error', () => { this.electronService.receiveIPC('update-error', () => {
this.updateAvailable = null this.updateAvailable = null
this.ref.detectChanges() this.ref.detectChanges()
}) })
this.updateAvailable = await this.electronService.invoke('get-update-available', undefined) this.updateAvailable = await this.electronService.invoke('get-update-available', undefined)
this.ref.detectChanges() this.ref.detectChanges()
} }
minimize() { minimize() {
this.electronService.currentWindow.minimize() this.electronService.currentWindow.minimize()
} }
toggleMaximized() { toggleMaximized() {
if (this.isMaximized) { if (this.isMaximized) {
this.electronService.currentWindow.restore() this.electronService.currentWindow.restore()
} else { } else {
this.electronService.currentWindow.maximize() this.electronService.currentWindow.maximize()
} }
this.isMaximized = !this.isMaximized this.isMaximized = !this.isMaximized
} }
close() { close() {
this.electronService.quit() this.electronService.quit()
} }
} }

View File

@@ -1,38 +1,38 @@
import { Directive, ElementRef, Output, EventEmitter, AfterViewInit } from '@angular/core' import { AfterViewInit, Directive, ElementRef, EventEmitter, Output } from '@angular/core'
@Directive({ @Directive({
selector: '[appCheckbox]' selector: '[appCheckbox]',
}) })
export class CheckboxDirective implements AfterViewInit { export class CheckboxDirective implements AfterViewInit {
@Output() checked = new EventEmitter<boolean>() @Output() checked = new EventEmitter<boolean>()
_isChecked = false _isChecked = false
constructor(private checkbox: ElementRef) { } constructor(private checkbox: ElementRef) { }
ngAfterViewInit() { ngAfterViewInit() {
$(this.checkbox.nativeElement).checkbox({ $(this.checkbox.nativeElement).checkbox({
onChecked: () => { onChecked: () => {
this.checked.emit(true) this.checked.emit(true)
this._isChecked = true this._isChecked = true
}, },
onUnchecked: () => { onUnchecked: () => {
this.checked.emit(false) this.checked.emit(false)
this._isChecked = false this._isChecked = false
} },
}) })
} }
check(isChecked: boolean) { check(isChecked: boolean) {
this._isChecked = isChecked this._isChecked = isChecked
if (isChecked) { if (isChecked) {
$(this.checkbox.nativeElement).checkbox('check') $(this.checkbox.nativeElement).checkbox('check')
} else { } else {
$(this.checkbox.nativeElement).checkbox('uncheck') $(this.checkbox.nativeElement).checkbox('uncheck')
} }
} }
get isChecked() { get isChecked() {
return this._isChecked return this._isChecked
} }
} }

View File

@@ -1,26 +1,27 @@
import { Directive, ElementRef, Input } from '@angular/core' import { Directive, ElementRef, Input } from '@angular/core'
import * as _ from 'underscore' import * as _ from 'underscore'
@Directive({ @Directive({
selector: '[appProgressBar]' selector: '[appProgressBar]',
}) })
export class ProgressBarDirective { export class ProgressBarDirective {
progress: (percent: number) => void progress: (percent: number) => void
@Input() set percent(percent: number) { @Input() set percent(percent: number) {
this.progress(percent) this.progress(percent)
} }
constructor(private element: ElementRef) { constructor(private element: ElementRef) {
this.progress = _.throttle((percent: number) => this.$progressBar.progress('set').percent(percent), 100) this.progress = _.throttle((percent: number) => this.$progressBar.progress('set').percent(percent), 100)
} }
private get $progressBar() { private get $progressBar() {
if (!this._$progressBar) { if (!this._$progressBar) {
this._$progressBar = $(this.element.nativeElement) this._$progressBar = $(this.element.nativeElement)
} }
return this._$progressBar return this._$progressBar
} }
private _$progressBar: any private _$progressBar: any
} }

View File

@@ -1,25 +1,26 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { ElectronService } from './electron.service' import { ElectronService } from './electron.service'
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class AlbumArtService { export class AlbumArtService {
constructor(private electronService: ElectronService) { } constructor(private electronService: ElectronService) { }
private imageCache: { [songID: number]: string } = {} private imageCache: { [songID: number]: string } = {}
async getImage(songID: number): Promise<string | null> { async getImage(songID: number): Promise<string | null> {
if (this.imageCache[songID] == undefined) { if (this.imageCache[songID] == undefined) {
const albumArtResult = await this.electronService.invoke('album-art', songID) const albumArtResult = await this.electronService.invoke('album-art', songID)
if (albumArtResult) { if (albumArtResult) {
this.imageCache[songID] = albumArtResult.base64Art this.imageCache[songID] = albumArtResult.base64Art
} else { } else {
this.imageCache[songID] = null this.imageCache[songID] = null
} }
} }
return this.imageCache[songID] return this.imageCache[songID]
} }
} }

View File

@@ -1,88 +1,89 @@
import { Injectable, EventEmitter } from '@angular/core' import { EventEmitter, Injectable } from '@angular/core'
import { DownloadProgress, NewDownload } from '../../../electron/shared/interfaces/download.interface'
import { ElectronService } from './electron.service' import { ElectronService } from './electron.service'
import { NewDownload, DownloadProgress } from '../../../electron/shared/interfaces/download.interface'
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class DownloadService { export class DownloadService {
private downloadUpdatedEmitter = new EventEmitter<DownloadProgress>() private downloadUpdatedEmitter = new EventEmitter<DownloadProgress>()
private downloads: DownloadProgress[] = [] private downloads: DownloadProgress[] = []
constructor(private electronService: ElectronService) { constructor(private electronService: ElectronService) {
this.electronService.receiveIPC('download-updated', result => { this.electronService.receiveIPC('download-updated', result => {
// Update <this.downloads> with result // Update <this.downloads> with result
const thisDownloadIndex = this.downloads.findIndex(download => download.versionID == result.versionID) const thisDownloadIndex = this.downloads.findIndex(download => download.versionID == result.versionID)
if (result.type == 'cancel') { if (result.type == 'cancel') {
this.downloads = this.downloads.filter(download => download.versionID != result.versionID) this.downloads = this.downloads.filter(download => download.versionID != result.versionID)
} else if (thisDownloadIndex == -1) { } else if (thisDownloadIndex == -1) {
this.downloads.push(result) this.downloads.push(result)
} else { } else {
this.downloads[thisDownloadIndex] = result this.downloads[thisDownloadIndex] = result
} }
this.downloadUpdatedEmitter.emit(result) this.downloadUpdatedEmitter.emit(result)
}) })
} }
get downloadCount() { get downloadCount() {
return this.downloads.length return this.downloads.length
} }
get completedCount() { get completedCount() {
return this.downloads.filter(download => download.type == 'done').length return this.downloads.filter(download => download.type == 'done').length
} }
get totalDownloadingPercent() { get totalDownloadingPercent() {
let total = 0 let total = 0
let count = 0 let count = 0
for (const download of this.downloads) { for (const download of this.downloads) {
if (!download.stale) { if (!download.stale) {
total += download.percent total += download.percent
count++ count++
} }
} }
return total / count return total / count
} }
get anyErrorsExist() { get anyErrorsExist() {
return this.downloads.find(download => download.type == 'error') ? true : false return this.downloads.find(download => download.type == 'error') ? true : false
} }
addDownload(versionID: number, newDownload: NewDownload) { addDownload(versionID: number, newDownload: NewDownload) {
if (!this.downloads.find(download => download.versionID == versionID)) { // Don't download something twice if (!this.downloads.find(download => download.versionID == versionID)) { // Don't download something twice
if (this.downloads.every(download => download.type == 'done')) { // Reset overall progress bar if it finished if (this.downloads.every(download => download.type == 'done')) { // Reset overall progress bar if it finished
this.downloads.forEach(download => download.stale = true) this.downloads.forEach(download => download.stale = true)
} }
this.electronService.sendIPC('download', { action: 'add', versionID, data: newDownload }) this.electronService.sendIPC('download', { action: 'add', versionID, data: newDownload })
} }
} }
onDownloadUpdated(callback: (download: DownloadProgress) => void) { onDownloadUpdated(callback: (download: DownloadProgress) => void) {
this.downloadUpdatedEmitter.subscribe(callback) this.downloadUpdatedEmitter.subscribe(callback)
} }
cancelDownload(versionID: number) { cancelDownload(versionID: number) {
const removedDownload = this.downloads.find(download => download.versionID == versionID) const removedDownload = this.downloads.find(download => download.versionID == versionID)
if (['error', 'done'].includes(removedDownload.type)) { if (['error', 'done'].includes(removedDownload.type)) {
this.downloads = this.downloads.filter(download => download.versionID != versionID) this.downloads = this.downloads.filter(download => download.versionID != versionID)
removedDownload.type = 'cancel' removedDownload.type = 'cancel'
this.downloadUpdatedEmitter.emit(removedDownload) this.downloadUpdatedEmitter.emit(removedDownload)
} else { } else {
this.electronService.sendIPC('download', { action: 'cancel', versionID }) this.electronService.sendIPC('download', { action: 'cancel', versionID })
} }
} }
cancelCompleted() { cancelCompleted() {
for (const download of this.downloads) { for (const download of this.downloads) {
if (download.type == 'done') { if (download.type == 'done') {
this.cancelDownload(download.versionID) this.cancelDownload(download.versionID)
} }
} }
} }
retryDownload(versionID: number) { retryDownload(versionID: number) {
this.electronService.sendIPC('download', { action: 'retry', versionID }) this.electronService.sendIPC('download', { action: 'retry', versionID })
} }
} }

View File

@@ -3,78 +3,79 @@ import { Injectable } from '@angular/core'
// If you import a module but never use any of the imported values other than as TypeScript types, // If you import a module but never use any of the imported values other than as TypeScript types,
// the resulting javascript file will look as if you never imported the module at all. // the resulting javascript file will look as if you never imported the module at all.
import * as electron from 'electron' import * as electron from 'electron'
import { IPCInvokeEvents, IPCEmitEvents } from '../../../electron/shared/IPCHandler'
import { IPCEmitEvents, IPCInvokeEvents } from '../../../electron/shared/IPCHandler'
const { app, getCurrentWindow, dialog, session } = window.require('@electron/remote') const { app, getCurrentWindow, dialog, session } = window.require('@electron/remote')
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class ElectronService { export class ElectronService {
electron: typeof electron electron: typeof electron
get isElectron() { get isElectron() {
return !!(window && window.process && window.process.type) return !!(window && window.process && window.process.type)
} }
constructor() { constructor() {
if (this.isElectron) { if (this.isElectron) {
this.electron = window.require('electron') this.electron = window.require('electron')
this.receiveIPC('log', results => results.forEach(result => console.log(result))) this.receiveIPC('log', results => results.forEach(result => console.log(result)))
} }
} }
get currentWindow() { get currentWindow() {
return getCurrentWindow() return getCurrentWindow()
} }
/** /**
* Calls an async function in the main process. * Calls an async function in the main process.
* @param event The name of the IPC event to invoke. * @param event The name of the IPC event to invoke.
* @param data The data object to send across IPC. * @param data The data object to send across IPC.
* @returns A promise that resolves to the output data. * @returns A promise that resolves to the output data.
*/ */
async invoke<E extends keyof IPCInvokeEvents>(event: E, data: IPCInvokeEvents[E]['input']) { async invoke<E extends keyof IPCInvokeEvents>(event: E, data: IPCInvokeEvents[E]['input']) {
return this.electron.ipcRenderer.invoke(event, data) as Promise<IPCInvokeEvents[E]['output']> return this.electron.ipcRenderer.invoke(event, data) as Promise<IPCInvokeEvents[E]['output']>
} }
/** /**
* Sends an IPC message to the main process. * Sends an IPC message to the main process.
* @param event The name of the IPC event to send. * @param event The name of the IPC event to send.
* @param data The data object to send across IPC. * @param data The data object to send across IPC.
*/ */
sendIPC<E extends keyof IPCEmitEvents>(event: E, data: IPCEmitEvents[E]) { sendIPC<E extends keyof IPCEmitEvents>(event: E, data: IPCEmitEvents[E]) {
this.electron.ipcRenderer.send(event, data) this.electron.ipcRenderer.send(event, data)
} }
/** /**
* Receives an IPC message from the main process. * Receives an IPC message from the main process.
* @param event The name of the IPC event to receive. * @param event The name of the IPC event to receive.
* @param callback The data object to receive across IPC. * @param callback The data object to receive across IPC.
*/ */
receiveIPC<E extends keyof IPCEmitEvents>(event: E, callback: (result: IPCEmitEvents[E]) => void) { receiveIPC<E extends keyof IPCEmitEvents>(event: E, callback: (result: IPCEmitEvents[E]) => void) {
this.electron.ipcRenderer.on(event, (_event, ...results) => { this.electron.ipcRenderer.on(event, (_event, ...results) => {
callback(results[0]) callback(results[0])
}) })
} }
quit() { quit() {
app.exit() app.exit()
} }
openFolder(filepath: string) { openFolder(filepath: string) {
this.electron.shell.openPath(filepath) this.electron.shell.openPath(filepath)
} }
showFolder(filepath: string) { showFolder(filepath: string) {
this.electron.shell.showItemInFolder(filepath) this.electron.shell.showItemInFolder(filepath)
} }
showOpenDialog(options: Electron.OpenDialogOptions) { showOpenDialog(options: Electron.OpenDialogOptions) {
return dialog.showOpenDialog(this.currentWindow, options) return dialog.showOpenDialog(this.currentWindow, options)
} }
get defaultSession() { get defaultSession() {
return session.defaultSession return session.defaultSession
} }
} }

View File

@@ -1,118 +1,119 @@
import { Injectable, EventEmitter } from '@angular/core' import { EventEmitter, Injectable } from '@angular/core'
import { ElectronService } from './electron.service'
import { SongResult, SongSearch } from 'src/electron/shared/interfaces/search.interface' import { SongResult, SongSearch } from 'src/electron/shared/interfaces/search.interface'
import { VersionResult } from 'src/electron/shared/interfaces/songDetails.interface' import { VersionResult } from 'src/electron/shared/interfaces/songDetails.interface'
import { ElectronService } from './electron.service'
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class SearchService { export class SearchService {
private resultsChangedEmitter = new EventEmitter<SongResult[]>() // For when any results change private resultsChangedEmitter = new EventEmitter<SongResult[]>() // For when any results change
private newResultsEmitter = new EventEmitter<SongResult[]>() // For when a new search happens private newResultsEmitter = new EventEmitter<SongResult[]>() // For when a new search happens
private errorStateEmitter = new EventEmitter<boolean>() // To indicate the search's error state private errorStateEmitter = new EventEmitter<boolean>() // To indicate the search's error state
private results: SongResult[] = [] private results: SongResult[] = []
private awaitingResults = false private awaitingResults = false
private currentQuery: SongSearch private currentQuery: SongSearch
private _allResultsVisible = true private _allResultsVisible = true
constructor(private electronService: ElectronService) { } constructor(private electronService: ElectronService) { }
async newSearch(query: SongSearch) { async newSearch(query: SongSearch) {
if (this.awaitingResults) { return } if (this.awaitingResults) { return }
this.awaitingResults = true this.awaitingResults = true
this.currentQuery = query this.currentQuery = query
try { try {
this.results = this.trimLastChart(await this.electronService.invoke('song-search', this.currentQuery)) this.results = this.trimLastChart(await this.electronService.invoke('song-search', this.currentQuery))
this.errorStateEmitter.emit(false) this.errorStateEmitter.emit(false)
} catch (err) { } catch (err) {
this.results = [] this.results = []
console.log(err.message) console.log(err.message)
this.errorStateEmitter.emit(true) this.errorStateEmitter.emit(true)
} }
this.awaitingResults = false this.awaitingResults = false
this.newResultsEmitter.emit(this.results) this.newResultsEmitter.emit(this.results)
this.resultsChangedEmitter.emit(this.results) this.resultsChangedEmitter.emit(this.results)
} }
isLoading() { isLoading() {
return this.awaitingResults return this.awaitingResults
} }
/** /**
* Event emitted when new search results are returned * Event emitted when new search results are returned
* or when more results are added to an existing search. * or when more results are added to an existing search.
* (emitted after `onNewSearch`) * (emitted after `onNewSearch`)
*/ */
onSearchChanged(callback: (results: SongResult[]) => void) { onSearchChanged(callback: (results: SongResult[]) => void) {
this.resultsChangedEmitter.subscribe(callback) this.resultsChangedEmitter.subscribe(callback)
} }
/** /**
* Event emitted when a new search query is typed in. * Event emitted when a new search query is typed in.
* (emitted before `onSearchChanged`) * (emitted before `onSearchChanged`)
*/ */
onNewSearch(callback: (results: SongResult[]) => void) { onNewSearch(callback: (results: SongResult[]) => void) {
this.newResultsEmitter.subscribe(callback) this.newResultsEmitter.subscribe(callback)
} }
/** /**
* Event emitted when the error state of the search changes. * Event emitted when the error state of the search changes.
* (emitted before `onSearchChanged`) * (emitted before `onSearchChanged`)
*/ */
onSearchErrorStateUpdate(callback: (isError: boolean) => void) { onSearchErrorStateUpdate(callback: (isError: boolean) => void) {
this.errorStateEmitter.subscribe(callback) this.errorStateEmitter.subscribe(callback)
} }
get resultCount() { get resultCount() {
return this.results.length return this.results.length
} }
async updateScroll() { async updateScroll() {
if (!this.awaitingResults && !this._allResultsVisible) { if (!this.awaitingResults && !this._allResultsVisible) {
this.awaitingResults = true this.awaitingResults = true
this.currentQuery.offset += 50 this.currentQuery.offset += 50
this.results.push(...this.trimLastChart(await this.electronService.invoke('song-search', this.currentQuery))) this.results.push(...this.trimLastChart(await this.electronService.invoke('song-search', this.currentQuery)))
this.awaitingResults = false this.awaitingResults = false
this.resultsChangedEmitter.emit(this.results) this.resultsChangedEmitter.emit(this.results)
} }
} }
trimLastChart(results: SongResult[]) { trimLastChart(results: SongResult[]) {
if (results.length > 50) { if (results.length > 50) {
results.splice(50, 1) results.splice(50, 1)
this._allResultsVisible = false this._allResultsVisible = false
} else { } else {
this._allResultsVisible = true this._allResultsVisible = true
} }
return results return results
} }
get allResultsVisible() { get allResultsVisible() {
return this._allResultsVisible return this._allResultsVisible
} }
/** /**
* Orders `versionResults` by lastModified date, but prefer the * Orders `versionResults` by lastModified date, but prefer the
* non-pack version if it's only a few days older. * non-pack version if it's only a few days older.
*/ */
sortChart(versionResults: VersionResult[]) { sortChart(versionResults: VersionResult[]) {
const dates: { [versionID: number]: number } = {} const dates: { [versionID: number]: number } = {}
versionResults.forEach(version => dates[version.versionID] = new Date(version.lastModified).getTime()) versionResults.forEach(version => dates[version.versionID] = new Date(version.lastModified).getTime())
versionResults.sort((v1, v2) => { versionResults.sort((v1, v2) => {
const diff = dates[v2.versionID] - dates[v1.versionID] const diff = dates[v2.versionID] - dates[v1.versionID]
if (Math.abs(diff) < 6.048e+8 && v1.driveData.inChartPack != v2.driveData.inChartPack) { if (Math.abs(diff) < 6.048e+8 && v1.driveData.inChartPack != v2.driveData.inChartPack) {
if (v1.driveData.inChartPack) { if (v1.driveData.inChartPack) {
return 1 // prioritize v2 return 1 // prioritize v2
} else { } else {
return -1 // prioritize v1 return -1 // prioritize v1
} }
} else { } else {
return diff return diff
} }
}) })
} }
} }

View File

@@ -1,89 +1,90 @@
import { Injectable, EventEmitter } from '@angular/core' import { EventEmitter, Injectable } from '@angular/core'
import { SongResult } from '../../../electron/shared/interfaces/search.interface' import { SongResult } from '../../../electron/shared/interfaces/search.interface'
import { SearchService } from './search.service' import { SearchService } from './search.service'
// Note: this class prevents event cycles by only emitting events if the checkbox changes // Note: this class prevents event cycles by only emitting events if the checkbox changes
interface SelectionEvent { interface SelectionEvent {
songID: number songID: number
selected: boolean selected: boolean
} }
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class SelectionService { export class SelectionService {
private searchResults: SongResult[] = [] private searchResults: SongResult[] = []
private selectAllChangedEmitter = new EventEmitter<boolean>() private selectAllChangedEmitter = new EventEmitter<boolean>()
private selectionChangedCallbacks: { [songID: number]: (selection: boolean) => void } = {} private selectionChangedCallbacks: { [songID: number]: (selection: boolean) => void } = {}
private allSelected = false private allSelected = false
private selections: { [songID: number]: boolean | undefined } = {} private selections: { [songID: number]: boolean | undefined } = {}
constructor(searchService: SearchService) { constructor(searchService: SearchService) {
searchService.onSearchChanged((results) => { searchService.onSearchChanged(results => {
this.searchResults = results this.searchResults = results
if (this.allSelected) { if (this.allSelected) {
this.selectAll() // Select newly added rows if allSelected this.selectAll() // Select newly added rows if allSelected
} }
}) })
searchService.onNewSearch((results) => { searchService.onNewSearch(results => {
this.searchResults = results this.searchResults = results
this.selectionChangedCallbacks = {} this.selectionChangedCallbacks = {}
this.selections = {} this.selections = {}
this.selectAllChangedEmitter.emit(false) this.selectAllChangedEmitter.emit(false)
}) })
} }
getSelectedResults() { getSelectedResults() {
return this.searchResults.filter(result => this.selections[result.id] == true) return this.searchResults.filter(result => this.selections[result.id] == true)
} }
onSelectAllChanged(callback: (selected: boolean) => void) { onSelectAllChanged(callback: (selected: boolean) => void) {
this.selectAllChangedEmitter.subscribe(callback) this.selectAllChangedEmitter.subscribe(callback)
} }
/** /**
* Emits an event when the selection for `songID` needs to change. * Emits an event when the selection for `songID` needs to change.
* (note: only one emitter can be registered per `songID`) * (note: only one emitter can be registered per `songID`)
*/ */
onSelectionChanged(songID: number, callback: (selection: boolean) => void) { onSelectionChanged(songID: number, callback: (selection: boolean) => void) {
this.selectionChangedCallbacks[songID] = callback this.selectionChangedCallbacks[songID] = callback
} }
deselectAll() { deselectAll() {
if (this.allSelected) { if (this.allSelected) {
this.allSelected = false this.allSelected = false
this.selectAllChangedEmitter.emit(false) this.selectAllChangedEmitter.emit(false)
} }
setTimeout(() => this.searchResults.forEach(result => this.deselectSong(result.id)), 0) setTimeout(() => this.searchResults.forEach(result => this.deselectSong(result.id)), 0)
} }
selectAll() { selectAll() {
if (!this.allSelected) { if (!this.allSelected) {
this.allSelected = true this.allSelected = true
this.selectAllChangedEmitter.emit(true) this.selectAllChangedEmitter.emit(true)
} }
setTimeout(() => this.searchResults.forEach(result => this.selectSong(result.id)), 0) setTimeout(() => this.searchResults.forEach(result => this.selectSong(result.id)), 0)
} }
deselectSong(songID: number) { deselectSong(songID: number) {
if (this.selections[songID]) { if (this.selections[songID]) {
this.selections[songID] = false this.selections[songID] = false
this.selectionChangedCallbacks[songID](false) this.selectionChangedCallbacks[songID](false)
} }
} }
selectSong(songID: number) { selectSong(songID: number) {
if (!this.selections[songID]) { if (!this.selections[songID]) {
this.selections[songID] = true this.selections[songID] = true
this.selectionChangedCallbacks[songID](true) this.selectionChangedCallbacks[songID](true)
} }
} }
} }

View File

@@ -1,81 +1,82 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { ElectronService } from './electron.service'
import { Settings } from 'src/electron/shared/Settings' import { Settings } from 'src/electron/shared/Settings'
import { ElectronService } from './electron.service'
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root',
}) })
export class SettingsService { export class SettingsService {
readonly builtinThemes = ['Default', 'Dark'] readonly builtinThemes = ['Default', 'Dark']
private settings: Settings private settings: Settings
private currentThemeLink: HTMLLinkElement private currentThemeLink: HTMLLinkElement
constructor(private electronService: ElectronService) { } constructor(private electronService: ElectronService) { }
async loadSettings() { async loadSettings() {
this.settings = await this.electronService.invoke('get-settings', undefined) this.settings = await this.electronService.invoke('get-settings', undefined)
if (this.settings.theme != this.builtinThemes[0]) { if (this.settings.theme != this.builtinThemes[0]) {
this.changeTheme(this.settings.theme) this.changeTheme(this.settings.theme)
} }
} }
saveSettings() { saveSettings() {
this.electronService.sendIPC('set-settings', this.settings) this.electronService.sendIPC('set-settings', this.settings)
} }
changeTheme(theme: string) { changeTheme(theme: string) {
if (this.currentThemeLink != undefined) this.currentThemeLink.remove() if (this.currentThemeLink != undefined) this.currentThemeLink.remove()
if (theme == 'Default') { return } if (theme == 'Default') { return }
const link = document.createElement('link') const link = document.createElement('link')
link.type = 'text/css' link.type = 'text/css'
link.rel = 'stylesheet' link.rel = 'stylesheet'
link.href = `./assets/themes/${theme.toLowerCase()}.css` link.href = `./assets/themes/${theme.toLowerCase()}.css`
this.currentThemeLink = document.head.appendChild(link) this.currentThemeLink = document.head.appendChild(link)
} }
async getCacheSize() { async getCacheSize() {
return this.electronService.defaultSession.getCacheSize() return this.electronService.defaultSession.getCacheSize()
} }
async clearCache() { async clearCache() {
this.saveSettings() this.saveSettings()
await this.electronService.defaultSession.clearCache() await this.electronService.defaultSession.clearCache()
await this.electronService.invoke('clear-cache', undefined) await this.electronService.invoke('clear-cache', undefined)
} }
// Individual getters/setters // Individual getters/setters
get libraryDirectory() { get libraryDirectory() {
return this.settings.libraryPath return this.settings.libraryPath
} }
set libraryDirectory(newValue: string) { set libraryDirectory(newValue: string) {
this.settings.libraryPath = newValue this.settings.libraryPath = newValue
this.saveSettings() this.saveSettings()
} }
get downloadVideos() { get downloadVideos() {
return this.settings.downloadVideos return this.settings.downloadVideos
} }
set downloadVideos(isChecked) { set downloadVideos(isChecked) {
this.settings.downloadVideos = isChecked this.settings.downloadVideos = isChecked
this.saveSettings() this.saveSettings()
} }
get theme() { get theme() {
return this.settings.theme return this.settings.theme
} }
set theme(newValue: string) { set theme(newValue: string) {
this.settings.theme = newValue this.settings.theme = newValue
this.changeTheme(newValue) this.changeTheme(newValue)
this.saveSettings() this.saveSettings()
} }
get rateLimitDelay() { get rateLimitDelay() {
return this.settings.rateLimitDelay return this.settings.rateLimitDelay
} }
set rateLimitDelay(delay: number) { set rateLimitDelay(delay: number) {
this.settings.rateLimitDelay = delay this.settings.rateLimitDelay = delay
this.saveSettings() this.saveSettings()
} }
} }

View File

@@ -1,29 +1,29 @@
import { RouteReuseStrategy, ActivatedRouteSnapshot, DetachedRouteHandle } from '@angular/router'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { ActivatedRouteSnapshot, DetachedRouteHandle, RouteReuseStrategy } from '@angular/router'
/** /**
* This makes each route with the 'reuse' data flag persist when not in focus. * This makes each route with the 'reuse' data flag persist when not in focus.
*/ */
@Injectable() @Injectable()
export class TabPersistStrategy extends RouteReuseStrategy { export class TabPersistStrategy extends RouteReuseStrategy {
private handles: { [path: string]: DetachedRouteHandle } = {} private handles: { [path: string]: DetachedRouteHandle } = {}
shouldDetach(route: ActivatedRouteSnapshot) { shouldDetach(route: ActivatedRouteSnapshot) {
return route.data.shouldReuse || false return route.data.shouldReuse || false
} }
store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle) { store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle) {
if (route.data.shouldReuse) { if (route.data.shouldReuse) {
this.handles[route.routeConfig.path] = handle this.handles[route.routeConfig.path] = handle
} }
} }
shouldAttach(route: ActivatedRouteSnapshot) { shouldAttach(route: ActivatedRouteSnapshot) {
return !!route.routeConfig && !!this.handles[route.routeConfig.path] return !!route.routeConfig && !!this.handles[route.routeConfig.path]
} }
retrieve(route: ActivatedRouteSnapshot) { retrieve(route: ActivatedRouteSnapshot) {
if (!route.routeConfig) return null if (!route.routeConfig) return null
return this.handles[route.routeConfig.path] return this.handles[route.routeConfig.path]
} }
shouldReuseRoute(future: ActivatedRouteSnapshot) { shouldReuseRoute(future: ActivatedRouteSnapshot) {
return future.data.shouldReuse || false return future.data.shouldReuse || false
} }
} }

View File

@@ -1,10 +1,11 @@
import { Dirent, readdir as _readdir } from 'fs'
import { join } from 'path'
import { rimraf } from 'rimraf'
import { inspect, promisify } from 'util'
import { devLog } from '../shared/ElectronUtilFunctions'
import { IPCInvokeHandler } from '../shared/IPCHandler' import { IPCInvokeHandler } from '../shared/IPCHandler'
import { tempPath } from '../shared/Paths' import { tempPath } from '../shared/Paths'
import { rimraf } from 'rimraf'
import { Dirent, readdir as _readdir } from 'fs'
import { inspect, promisify } from 'util'
import { join } from 'path'
import { devLog } from '../shared/ElectronUtilFunctions'
const readdir = promisify(_readdir) const readdir = promisify(_readdir)
@@ -12,30 +13,30 @@ const readdir = promisify(_readdir)
* Handles the 'clear-cache' event. * Handles the 'clear-cache' event.
*/ */
class ClearCacheHandler implements IPCInvokeHandler<'clear-cache'> { class ClearCacheHandler implements IPCInvokeHandler<'clear-cache'> {
event: 'clear-cache' = 'clear-cache' event = 'clear-cache' as const
/** /**
* Deletes all the files under `tempPath` * Deletes all the files under `tempPath`
*/ */
async handler() { async handler() {
let files: Dirent[] let files: Dirent[]
try { try {
files = await readdir(tempPath, { withFileTypes: true }) files = await readdir(tempPath, { withFileTypes: true })
} catch (err) { } catch (err) {
devLog('Failed to read cache: ', err) devLog('Failed to read cache: ', err)
return return
} }
for (const file of files) { for (const file of files) {
try { try {
devLog(`Deleting ${file.isFile() ? 'file' : 'folder'}: ${join(tempPath, file.name)}`) devLog(`Deleting ${file.isFile() ? 'file' : 'folder'}: ${join(tempPath, file.name)}`)
await rimraf(join(tempPath, file.name)) await rimraf(join(tempPath, file.name))
} catch (err) { } catch (err) {
devLog(`Failed to delete ${file.isFile() ? 'file' : 'folder'}: `, inspect(err)) devLog(`Failed to delete ${file.isFile() ? 'file' : 'folder'}: `, inspect(err))
return return
} }
} }
} }
} }
export const clearCacheHandler = new ClearCacheHandler() export const clearCacheHandler = new ClearCacheHandler()

View File

@@ -1,18 +1,19 @@
import { IPCEmitHandler } from '../shared/IPCHandler'
import { shell } from 'electron' import { shell } from 'electron'
import { IPCEmitHandler } from '../shared/IPCHandler'
/** /**
* Handles the 'open-url' event. * Handles the 'open-url' event.
*/ */
class OpenURLHandler implements IPCEmitHandler<'open-url'> { class OpenURLHandler implements IPCEmitHandler<'open-url'> {
event: 'open-url' = 'open-url' event = 'open-url' as const
/** /**
* Opens `url` in the default browser. * Opens `url` in the default browser.
*/ */
handler(url: string) { handler(url: string) {
shell.openExternal(url) shell.openExternal(url)
} }
} }
export const openURLHandler = new OpenURLHandler() export const openURLHandler = new OpenURLHandler()

View File

@@ -1,7 +1,8 @@
import * as fs from 'fs' import * as fs from 'fs'
import { dataPath, tempPath, themesPath, settingsPath } from '../shared/Paths'
import { promisify } from 'util' import { promisify } from 'util'
import { IPCInvokeHandler, IPCEmitHandler } from '../shared/IPCHandler'
import { IPCEmitHandler, IPCInvokeHandler } from '../shared/IPCHandler'
import { dataPath, settingsPath, tempPath, themesPath } from '../shared/Paths'
import { defaultSettings, Settings } from '../shared/Settings' import { defaultSettings, Settings } from '../shared/Settings'
const exists = promisify(fs.exists) const exists = promisify(fs.exists)
@@ -11,83 +12,83 @@ const writeFile = promisify(fs.writeFile)
let settings: Settings let settings: Settings
/**
* Handles the 'get-settings' event.
*/
class GetSettingsHandler implements IPCInvokeHandler<'get-settings'> {
event: 'get-settings' = 'get-settings'
/**
* @returns the current settings oject, or default settings if they couldn't be loaded.
*/
handler() {
return this.getSettings()
}
/**
* @returns the current settings oject, or default settings if they couldn't be loaded.
*/
getSettings() {
if (settings == undefined) {
return defaultSettings
} else {
return settings
}
}
/**
* If data directories don't exist, creates them and saves the default settings.
* Otherwise, loads user settings from data directories.
* If this process fails, default settings are used.
*/
async initSettings() {
try {
// Create data directories if they don't exists
for (const path of [dataPath, tempPath, themesPath]) {
if (!await exists(path)) {
await mkdir(path)
}
}
// Read/create settings
if (await exists(settingsPath)) {
settings = JSON.parse(await readFile(settingsPath, 'utf8'))
settings = Object.assign(JSON.parse(JSON.stringify(defaultSettings)), settings)
} else {
await SetSettingsHandler.saveSettings(defaultSettings)
settings = defaultSettings
}
} catch (e) {
console.error('Failed to initialize settings! Default settings will be used.')
console.error(e)
settings = defaultSettings
}
}
}
/** /**
* Handles the 'set-settings' event. * Handles the 'set-settings' event.
*/ */
class SetSettingsHandler implements IPCEmitHandler<'set-settings'> { class SetSettingsHandler implements IPCEmitHandler<'set-settings'> {
event: 'set-settings' = 'set-settings' event = 'set-settings' as const
/** /**
* Updates Bridge's settings object to `newSettings` and saves them to Bridge's data directories. * Updates Bridge's settings object to `newSettings` and saves them to Bridge's data directories.
*/ */
handler(newSettings: Settings) { handler(newSettings: Settings) {
settings = newSettings settings = newSettings
SetSettingsHandler.saveSettings(settings) SetSettingsHandler.saveSettings(settings)
} }
/** /**
* Saves `settings` to Bridge's data directories. * Saves `settings` to Bridge's data directories.
*/ */
static async saveSettings(settings: Settings) { static async saveSettings(settings: Settings) {
const settingsJSON = JSON.stringify(settings, undefined, 2) const settingsJSON = JSON.stringify(settings, undefined, 2)
await writeFile(settingsPath, settingsJSON, 'utf8') await writeFile(settingsPath, settingsJSON, 'utf8')
} }
}
/**
* Handles the 'get-settings' event.
*/
class GetSettingsHandler implements IPCInvokeHandler<'get-settings'> {
event = 'get-settings' as const
/**
* @returns the current settings oject, or default settings if they couldn't be loaded.
*/
handler() {
return this.getSettings()
}
/**
* @returns the current settings oject, or default settings if they couldn't be loaded.
*/
getSettings() {
if (settings == undefined) {
return defaultSettings
} else {
return settings
}
}
/**
* If data directories don't exist, creates them and saves the default settings.
* Otherwise, loads user settings from data directories.
* If this process fails, default settings are used.
*/
async initSettings() {
try {
// Create data directories if they don't exists
for (const path of [dataPath, tempPath, themesPath]) {
if (!await exists(path)) {
await mkdir(path)
}
}
// Read/create settings
if (await exists(settingsPath)) {
settings = JSON.parse(await readFile(settingsPath, 'utf8'))
settings = Object.assign(JSON.parse(JSON.stringify(defaultSettings)), settings)
} else {
await SetSettingsHandler.saveSettings(defaultSettings)
settings = defaultSettings
}
} catch (e) {
console.error('Failed to initialize settings! Default settings will be used.')
console.error(e)
settings = defaultSettings
}
}
} }
export const getSettingsHandler = new GetSettingsHandler() export const getSettingsHandler = new GetSettingsHandler()
export const setSettingsHandler = new SetSettingsHandler() export const setSettingsHandler = new SetSettingsHandler()
export function getSettings() { return getSettingsHandler.getSettings() } export function getSettings() { return getSettingsHandler.getSettings() }

View File

@@ -1,12 +1,13 @@
import { IPCEmitHandler, IPCInvokeHandler } from '../shared/IPCHandler'
import { autoUpdater, UpdateInfo } from 'electron-updater' import { autoUpdater, UpdateInfo } from 'electron-updater'
import { emitIPCEvent } from '../main' import { emitIPCEvent } from '../main'
import { IPCEmitHandler, IPCInvokeHandler } from '../shared/IPCHandler'
export interface UpdateProgress { export interface UpdateProgress {
bytesPerSecond: number bytesPerSecond: number
percent: number percent: number
transferred: number transferred: number
total: number total: number
} }
let updateAvailable = false let updateAvailable = false
@@ -15,44 +16,44 @@ let updateAvailable = false
* Checks for updates when the program is launched. * Checks for updates when the program is launched.
*/ */
class UpdateChecker implements IPCEmitHandler<'retry-update'> { class UpdateChecker implements IPCEmitHandler<'retry-update'> {
event: 'retry-update' = 'retry-update' event = 'retry-update' as const
/** constructor() {
* Check for an update. autoUpdater.autoDownload = false
*/ autoUpdater.logger = null
handler() { this.registerUpdaterListeners()
this.checkForUpdates() }
}
constructor() { /**
autoUpdater.autoDownload = false * Check for an update.
autoUpdater.logger = null */
this.registerUpdaterListeners() handler() {
} this.checkForUpdates()
}
checkForUpdates() { checkForUpdates() {
autoUpdater.checkForUpdates().catch(reason => { autoUpdater.checkForUpdates().catch(reason => {
updateAvailable = null updateAvailable = null
emitIPCEvent('update-error', reason) emitIPCEvent('update-error', reason)
}) })
} }
private registerUpdaterListeners() { private registerUpdaterListeners() {
autoUpdater.on('error', (err: Error) => { autoUpdater.on('error', (err: Error) => {
updateAvailable = null updateAvailable = null
emitIPCEvent('update-error', err) emitIPCEvent('update-error', err)
}) })
autoUpdater.on('update-available', (info: UpdateInfo) => { autoUpdater.on('update-available', (info: UpdateInfo) => {
updateAvailable = true updateAvailable = true
emitIPCEvent('update-available', info) emitIPCEvent('update-available', info)
}) })
autoUpdater.on('update-not-available', (info: UpdateInfo) => { autoUpdater.on('update-not-available', (info: UpdateInfo) => {
updateAvailable = false updateAvailable = false
emitIPCEvent('update-available', null) emitIPCEvent('update-available', null)
}) })
} }
} }
export const updateChecker = new UpdateChecker() export const updateChecker = new UpdateChecker()
@@ -61,14 +62,14 @@ export const updateChecker = new UpdateChecker()
* Handles the 'get-update-available' event. * Handles the 'get-update-available' event.
*/ */
class GetUpdateAvailableHandler implements IPCInvokeHandler<'get-update-available'> { class GetUpdateAvailableHandler implements IPCInvokeHandler<'get-update-available'> {
event: 'get-update-available' = 'get-update-available' event = 'get-update-available' as const
/** /**
* @returns `true` if an update is available. * @returns `true` if an update is available.
*/ */
handler() { handler() {
return updateAvailable return updateAvailable
} }
} }
export const getUpdateAvailableHandler = new GetUpdateAvailableHandler() export const getUpdateAvailableHandler = new GetUpdateAvailableHandler()
@@ -77,14 +78,14 @@ export const getUpdateAvailableHandler = new GetUpdateAvailableHandler()
* Handles the 'get-current-version' event. * Handles the 'get-current-version' event.
*/ */
class GetCurrentVersionHandler implements IPCInvokeHandler<'get-current-version'> { class GetCurrentVersionHandler implements IPCInvokeHandler<'get-current-version'> {
event: 'get-current-version' = 'get-current-version' event = 'get-current-version' as const
/** /**
* @returns the current version of Bridge. * @returns the current version of Bridge.
*/ */
handler() { handler() {
return autoUpdater.currentVersion.raw return autoUpdater.currentVersion.raw
} }
} }
export const getCurrentVersionHandler = new GetCurrentVersionHandler() export const getCurrentVersionHandler = new GetCurrentVersionHandler()
@@ -93,26 +94,26 @@ export const getCurrentVersionHandler = new GetCurrentVersionHandler()
* Handles the 'download-update' event. * Handles the 'download-update' event.
*/ */
class DownloadUpdateHandler implements IPCEmitHandler<'download-update'> { class DownloadUpdateHandler implements IPCEmitHandler<'download-update'> {
event: 'download-update' = 'download-update' event = 'download-update' as const
downloading = false downloading = false
/** /**
* Begins the process of downloading the latest update. * Begins the process of downloading the latest update.
*/ */
handler() { handler() {
if (this.downloading) { return } if (this.downloading) { return }
this.downloading = true this.downloading = true
autoUpdater.on('download-progress', (updateProgress: UpdateProgress) => { autoUpdater.on('download-progress', (updateProgress: UpdateProgress) => {
emitIPCEvent('update-progress', updateProgress) emitIPCEvent('update-progress', updateProgress)
}) })
autoUpdater.on('update-downloaded', () => { autoUpdater.on('update-downloaded', () => {
emitIPCEvent('update-downloaded', undefined) emitIPCEvent('update-downloaded', undefined)
}) })
autoUpdater.downloadUpdate() autoUpdater.downloadUpdate()
} }
} }
export const downloadUpdateHandler = new DownloadUpdateHandler() export const downloadUpdateHandler = new DownloadUpdateHandler()
@@ -121,14 +122,14 @@ export const downloadUpdateHandler = new DownloadUpdateHandler()
* Handles the 'quit-and-install' event. * Handles the 'quit-and-install' event.
*/ */
class QuitAndInstallHandler implements IPCEmitHandler<'quit-and-install'> { class QuitAndInstallHandler implements IPCEmitHandler<'quit-and-install'> {
event: 'quit-and-install' = 'quit-and-install' event = 'quit-and-install' as const
/** /**
* Immediately closes the application and installs the update. * Immediately closes the application and installs the update.
*/ */
handler() { handler() {
autoUpdater.quitAndInstall() // autoUpdater installs a downloaded update on the next program restart by default autoUpdater.quitAndInstall() // autoUpdater installs a downloaded update on the next program restart by default
} }
} }
export const quitAndInstallHandler = new QuitAndInstallHandler() export const quitAndInstallHandler = new QuitAndInstallHandler()

View File

@@ -1,20 +1,20 @@
import { IPCInvokeHandler } from '../../shared/IPCHandler'
import { AlbumArtResult } from '../../shared/interfaces/songDetails.interface' import { AlbumArtResult } from '../../shared/interfaces/songDetails.interface'
import { IPCInvokeHandler } from '../../shared/IPCHandler'
import { serverURL } from '../../shared/Paths' import { serverURL } from '../../shared/Paths'
/** /**
* Handles the 'album-art' event. * Handles the 'album-art' event.
*/ */
class AlbumArtHandler implements IPCInvokeHandler<'album-art'> { class AlbumArtHandler implements IPCInvokeHandler<'album-art'> {
event: 'album-art' = 'album-art' event = 'album-art' as const
/** /**
* @returns an `AlbumArtResult` object containing the album art for the song with `songID`. * @returns an `AlbumArtResult` object containing the album art for the song with `songID`.
*/ */
async handler(songID: number): Promise<AlbumArtResult> { async handler(songID: number): Promise<AlbumArtResult> {
const response = await fetch(`https://${serverURL}/api/data/album-art/${songID}`) const response = await fetch(`https://${serverURL}/api/data/album-art/${songID}`)
return await response.json() return await response.json()
} }
} }
export const albumArtHandler = new AlbumArtHandler() export const albumArtHandler = new AlbumArtHandler()

View File

@@ -1,20 +1,20 @@
import { IPCInvokeHandler } from '../../shared/IPCHandler'
import { VersionResult } from '../../shared/interfaces/songDetails.interface' import { VersionResult } from '../../shared/interfaces/songDetails.interface'
import { IPCInvokeHandler } from '../../shared/IPCHandler'
import { serverURL } from '../../shared/Paths' import { serverURL } from '../../shared/Paths'
/** /**
* Handles the 'batch-song-details' event. * Handles the 'batch-song-details' event.
*/ */
class BatchSongDetailsHandler implements IPCInvokeHandler<'batch-song-details'> { class BatchSongDetailsHandler implements IPCInvokeHandler<'batch-song-details'> {
event: 'batch-song-details' = 'batch-song-details' event = 'batch-song-details' as const
/** /**
* @returns an array of all the chart versions with a songID found in `songIDs`. * @returns an array of all the chart versions with a songID found in `songIDs`.
*/ */
async handler(songIDs: number[]): Promise<VersionResult[]> { async handler(songIDs: number[]): Promise<VersionResult[]> {
const response = await fetch(`https://${serverURL}/api/data/song-versions/${songIDs.join(',')}`) const response = await fetch(`https://${serverURL}/api/data/song-versions/${songIDs.join(',')}`)
return await response.json() return await response.json()
} }
} }
export const batchSongDetailsHandler = new BatchSongDetailsHandler() export const batchSongDetailsHandler = new BatchSongDetailsHandler()

View File

@@ -1,27 +1,28 @@
import { IPCInvokeHandler } from '../../shared/IPCHandler'
import { SongResult, SongSearch } from '../../shared/interfaces/search.interface' import { SongResult, SongSearch } from '../../shared/interfaces/search.interface'
import { IPCInvokeHandler } from '../../shared/IPCHandler'
import { serverURL } from '../../shared/Paths' import { serverURL } from '../../shared/Paths'
/** /**
* Handles the 'song-search' event. * Handles the 'song-search' event.
*/ */
class SearchHandler implements IPCInvokeHandler<'song-search'> { class SearchHandler implements IPCInvokeHandler<'song-search'> {
event: 'song-search' = 'song-search' event = 'song-search' as const
/** /**
* @returns the top 50 songs that match `search`. * @returns the top 50 songs that match `search`.
*/ */
async handler(search: SongSearch): Promise<SongResult[]> { async handler(search: SongSearch): Promise<SongResult[]> {
const response = await fetch(`https://${serverURL}/api/search`, { const response = await fetch(`https://${serverURL}/api/search`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' // eslint-disable-next-line @typescript-eslint/naming-convention
}, 'Content-Type': 'application/json',
body: JSON.stringify(search) },
}) body: JSON.stringify(search),
})
return await response.json() return await response.json()
} }
} }
export const searchHandler = new SearchHandler() export const searchHandler = new SearchHandler()

View File

@@ -1,20 +1,20 @@
import { IPCInvokeHandler } from '../../shared/IPCHandler'
import { VersionResult } from '../../shared/interfaces/songDetails.interface' import { VersionResult } from '../../shared/interfaces/songDetails.interface'
import { IPCInvokeHandler } from '../../shared/IPCHandler'
import { serverURL } from '../../shared/Paths' import { serverURL } from '../../shared/Paths'
/** /**
* Handles the 'song-details' event. * Handles the 'song-details' event.
*/ */
class SongDetailsHandler implements IPCInvokeHandler<'song-details'> { class SongDetailsHandler implements IPCInvokeHandler<'song-details'> {
event: 'song-details' = 'song-details' event = 'song-details' as const
/** /**
* @returns the chart versions with `songID`. * @returns the chart versions with `songID`.
*/ */
async handler(songID: number): Promise<VersionResult[]> { async handler(songID: number): Promise<VersionResult[]> {
const response = await fetch(`https://${serverURL}/api/data/song-versions/${songID}`) const response = await fetch(`https://${serverURL}/api/data/song-versions/${songID}`)
return await response.json() return await response.json()
} }
} }
export const songDetailsHandler = new SongDetailsHandler() export const songDetailsHandler = new SongDetailsHandler()

View File

@@ -1,282 +1,284 @@
import { FileDownloader, getDownloader } from './FileDownloader'
import { join, parse } from 'path' import { join, parse } from 'path'
import { FileExtractor } from './FileExtractor'
import { sanitizeFilename, interpolate } from '../../shared/UtilFunctions'
import { emitIPCEvent } from '../../main'
import { ProgressType, NewDownload } from 'src/electron/shared/interfaces/download.interface'
import { DriveFile } from 'src/electron/shared/interfaces/songDetails.interface'
import { FileTransfer } from './FileTransfer'
import { rimraf } from 'rimraf' import { rimraf } from 'rimraf'
import { FilesystemChecker } from './FilesystemChecker'
import { getSettings } from '../SettingsHandler.ipc'
import { hasVideoExtension } from '../../shared/ElectronUtilFunctions'
type EventCallback = { import { NewDownload, ProgressType } from 'src/electron/shared/interfaces/download.interface'
/** Note: this will not be the last event if `retry()` is called. */ import { DriveFile } from 'src/electron/shared/interfaces/songDetails.interface'
'error': () => void import { emitIPCEvent } from '../../main'
'complete': () => void import { hasVideoExtension } from '../../shared/ElectronUtilFunctions'
import { interpolate, sanitizeFilename } from '../../shared/UtilFunctions'
import { getSettings } from '../SettingsHandler.ipc'
import { FileDownloader, getDownloader } from './FileDownloader'
import { FileExtractor } from './FileExtractor'
import { FilesystemChecker } from './FilesystemChecker'
import { FileTransfer } from './FileTransfer'
interface EventCallback {
/** Note: this will not be the last event if `retry()` is called. */
'error': () => void
'complete': () => void
} }
type Callbacks = { [E in keyof EventCallback]: EventCallback[E] } type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
export type DownloadError = { header: string; body: string; isLink?: boolean } export interface DownloadError { header: string; body: string; isLink?: boolean }
export class ChartDownload { export class ChartDownload {
private retryFn: () => void | Promise<void> private retryFn: () => void | Promise<void>
private cancelFn: () => void private cancelFn: () => void
private callbacks = {} as Callbacks private callbacks = {} as Callbacks
private files: DriveFile[] private files: DriveFile[]
private percent = 0 // Needs to be stored here because errors won't know the exact percent private percent = 0 // Needs to be stored here because errors won't know the exact percent
private tempPath: string private tempPath: string
private wasCanceled = false private wasCanceled = false
private readonly individualFileProgressPortion: number private readonly individualFileProgressPortion: number
private readonly destinationFolderName: string private readonly destinationFolderName: string
private _allFilesProgress = 0 private _allFilesProgress = 0
get allFilesProgress() { return this._allFilesProgress } get allFilesProgress() { return this._allFilesProgress }
private _hasFailed = false private _hasFailed = false
/** If this chart download needs to be retried */ /** If this chart download needs to be retried */
get hasFailed() { return this._hasFailed } get hasFailed() { return this._hasFailed }
get isArchive() { return this.data.driveData.isArchive } get isArchive() { return this.data.driveData.isArchive }
get hash() { return this.data.driveData.filesHash } get hash() { return this.data.driveData.filesHash }
constructor(public versionID: number, private data: NewDownload) { constructor(public versionID: number, private data: NewDownload) {
this.updateGUI('', 'Waiting for other downloads to finish...', 'good') this.updateGUI('', 'Waiting for other downloads to finish...', 'good')
this.files = this.filterDownloadFiles(data.driveData.files) this.files = this.filterDownloadFiles(data.driveData.files)
this.individualFileProgressPortion = 80 / this.files.length this.individualFileProgressPortion = 80 / this.files.length
if (data.driveData.inChartPack) { if (data.driveData.inChartPack) {
this.destinationFolderName = sanitizeFilename(parse(data.driveData.files[0].name).name) this.destinationFolderName = sanitizeFilename(parse(data.driveData.files[0].name).name)
} else { } else {
this.destinationFolderName = sanitizeFilename(`${this.data.artist} - ${this.data.chartName} (${this.data.charter})`) this.destinationFolderName = sanitizeFilename(`${this.data.artist} - ${this.data.chartName} (${this.data.charter})`)
} }
} }
/** /**
* Calls `callback` when `event` fires. (no events will be fired after `this.cancel()` is called) * Calls `callback` when `event` fires. (no events will be fired after `this.cancel()` is called)
*/ */
on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) { on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
this.callbacks[event] = callback this.callbacks[event] = callback
} }
filterDownloadFiles(files: DriveFile[]) { filterDownloadFiles(files: DriveFile[]) {
return files.filter(file => { return files.filter(file => {
return (file.name != 'ch.dat') && (getSettings().downloadVideos || !hasVideoExtension(file.name)) return (file.name != 'ch.dat') && (getSettings().downloadVideos || !hasVideoExtension(file.name))
}) })
} }
/** /**
* Retries the last failed step if it is running. * Retries the last failed step if it is running.
*/ */
retry() { // Only allow it to be called once retry() { // Only allow it to be called once
if (this.retryFn != undefined) { if (this.retryFn != undefined) {
this._hasFailed = false this._hasFailed = false
const retryFn = this.retryFn const retryFn = this.retryFn
this.retryFn = undefined this.retryFn = undefined
retryFn() retryFn()
} }
} }
/** /**
* Updates the GUI to indicate that a retry will be attempted. * Updates the GUI to indicate that a retry will be attempted.
*/ */
displayRetrying() { displayRetrying() {
this.updateGUI('', 'Waiting for other downloads to finish to retry...', 'good') this.updateGUI('', 'Waiting for other downloads to finish to retry...', 'good')
} }
/** /**
* Cancels the download if it is running. * Cancels the download if it is running.
*/ */
cancel() { // Only allow it to be called once cancel() { // Only allow it to be called once
if (this.cancelFn != undefined) { if (this.cancelFn != undefined) {
const cancelFn = this.cancelFn const cancelFn = this.cancelFn
this.cancelFn = undefined this.cancelFn = undefined
cancelFn() cancelFn()
rimraf(this.tempPath).catch(() => { /** Do nothing */ }) // Delete temp folder rimraf(this.tempPath).catch(() => { /** Do nothing */ }) // Delete temp folder
} }
this.updateGUI('', '', 'cancel') this.updateGUI('', '', 'cancel')
this.wasCanceled = true this.wasCanceled = true
} }
/** /**
* Updates the GUI with new information about this chart download. * Updates the GUI with new information about this chart download.
*/ */
private updateGUI(header: string, description: string, type: ProgressType, isLink = false) { private updateGUI(header: string, description: string, type: ProgressType, isLink = false) {
if (this.wasCanceled) { return } if (this.wasCanceled) { return }
emitIPCEvent('download-updated', { emitIPCEvent('download-updated', {
versionID: this.versionID, versionID: this.versionID,
title: `${this.data.chartName} - ${this.data.artist}`, title: `${this.data.chartName} - ${this.data.artist}`,
header: header, header: header,
description: description, description: description,
percent: this.percent, percent: this.percent,
type: type, type: type,
isLink isLink,
}) })
} }
/** /**
* Save the retry function, update the GUI, and call the `error` callback. * Save the retry function, update the GUI, and call the `error` callback.
*/ */
private handleError(err: DownloadError, retry: () => void) { private handleError(err: DownloadError, retry: () => void) {
this._hasFailed = true this._hasFailed = true
this.retryFn = retry this.retryFn = retry
this.updateGUI(err.header, err.body, 'error', err.isLink == true) this.updateGUI(err.header, err.body, 'error', err.isLink == true)
this.callbacks.error() this.callbacks.error()
} }
/** /**
* Starts the download process. * Starts the download process.
*/ */
async beginDownload() { async beginDownload() {
// CHECK FILESYSTEM ACCESS // CHECK FILESYSTEM ACCESS
const checker = new FilesystemChecker(this.destinationFolderName) const checker = new FilesystemChecker(this.destinationFolderName)
this.cancelFn = () => checker.cancelCheck() this.cancelFn = () => checker.cancelCheck()
const checkerComplete = this.addFilesystemCheckerEventListeners(checker) const checkerComplete = this.addFilesystemCheckerEventListeners(checker)
checker.beginCheck() checker.beginCheck()
await checkerComplete await checkerComplete
// DOWNLOAD FILES // DOWNLOAD FILES
for (let i = 0; i < this.files.length; i++) { for (let i = 0; i < this.files.length; i++) {
let wasCanceled = false let wasCanceled = false
this.cancelFn = () => { wasCanceled = true } this.cancelFn = () => { wasCanceled = true }
const downloader = getDownloader(this.files[i].webContentLink, join(this.tempPath, this.files[i].name)) const downloader = getDownloader(this.files[i].webContentLink, join(this.tempPath, this.files[i].name))
if (wasCanceled) { return } if (wasCanceled) { return }
this.cancelFn = () => downloader.cancelDownload() this.cancelFn = () => downloader.cancelDownload()
const downloadComplete = this.addDownloadEventListeners(downloader, i) const downloadComplete = this.addDownloadEventListeners(downloader, i)
downloader.beginDownload() downloader.beginDownload()
await downloadComplete await downloadComplete
} }
// EXTRACT FILES // EXTRACT FILES
if (this.isArchive) { if (this.isArchive) {
const extractor = new FileExtractor(this.tempPath) const extractor = new FileExtractor(this.tempPath)
this.cancelFn = () => extractor.cancelExtract() this.cancelFn = () => extractor.cancelExtract()
const extractComplete = this.addExtractorEventListeners(extractor) const extractComplete = this.addExtractorEventListeners(extractor)
extractor.beginExtract() extractor.beginExtract()
await extractComplete await extractComplete
} }
// TRANSFER FILES // TRANSFER FILES
const transfer = new FileTransfer(this.tempPath, this.destinationFolderName) const transfer = new FileTransfer(this.tempPath, this.destinationFolderName)
this.cancelFn = () => transfer.cancelTransfer() this.cancelFn = () => transfer.cancelTransfer()
const transferComplete = this.addTransferEventListeners(transfer) const transferComplete = this.addTransferEventListeners(transfer)
transfer.beginTransfer() transfer.beginTransfer()
await transferComplete await transferComplete
this.callbacks.complete() this.callbacks.complete()
} }
/** /**
* Defines what happens in reponse to `FilesystemChecker` events. * Defines what happens in reponse to `FilesystemChecker` events.
* @returns a `Promise` that resolves when the filesystem has been checked. * @returns a `Promise` that resolves when the filesystem has been checked.
*/ */
private addFilesystemCheckerEventListeners(checker: FilesystemChecker) { private addFilesystemCheckerEventListeners(checker: FilesystemChecker) {
checker.on('start', () => { checker.on('start', () => {
this.updateGUI('Checking filesystem...', '', 'good') this.updateGUI('Checking filesystem...', '', 'good')
}) })
checker.on('error', this.handleError.bind(this)) checker.on('error', this.handleError.bind(this))
return new Promise<void>(resolve => { return new Promise<void>(resolve => {
checker.on('complete', (tempPath) => { checker.on('complete', tempPath => {
this.tempPath = tempPath this.tempPath = tempPath
resolve() resolve()
}) })
}) })
} }
/** /**
* Defines what happens in response to `FileDownloader` events. * Defines what happens in response to `FileDownloader` events.
* @returns a `Promise` that resolves when the download finishes. * @returns a `Promise` that resolves when the download finishes.
*/ */
private addDownloadEventListeners(downloader: FileDownloader, fileIndex: number) { private addDownloadEventListeners(downloader: FileDownloader, fileIndex: number) {
let downloadHeader = `[${this.files[fileIndex].name}] (file ${fileIndex + 1}/${this.files.length})` let downloadHeader = `[${this.files[fileIndex].name}] (file ${fileIndex + 1}/${this.files.length})`
let downloadStartPoint = 0 // How far into the individual file progress portion the download progress starts let downloadStartPoint = 0 // How far into the individual file progress portion the download progress starts
let fileProgress = 0 let fileProgress = 0
downloader.on('waitProgress', (remainingSeconds: number, totalSeconds: number) => { downloader.on('waitProgress', (remainingSeconds: number, totalSeconds: number) => {
downloadStartPoint = this.individualFileProgressPortion / 2 downloadStartPoint = this.individualFileProgressPortion / 2
this.percent = this._allFilesProgress + interpolate(remainingSeconds, totalSeconds, 0, 0, this.individualFileProgressPortion / 2) this.percent =
this.updateGUI(downloadHeader, `Waiting for Google rate limit... (${remainingSeconds}s)`, 'good') this._allFilesProgress + interpolate(remainingSeconds, totalSeconds, 0, 0, this.individualFileProgressPortion / 2)
}) this.updateGUI(downloadHeader, `Waiting for Google rate limit... (${remainingSeconds}s)`, 'good')
})
downloader.on('requestSent', () => { downloader.on('requestSent', () => {
fileProgress = downloadStartPoint fileProgress = downloadStartPoint
this.percent = this._allFilesProgress + fileProgress this.percent = this._allFilesProgress + fileProgress
this.updateGUI(downloadHeader, 'Sending request...', 'good') this.updateGUI(downloadHeader, 'Sending request...', 'good')
}) })
downloader.on('downloadProgress', (bytesDownloaded: number) => { downloader.on('downloadProgress', (bytesDownloaded: number) => {
downloadHeader = `[${this.files[fileIndex].name}] (file ${fileIndex + 1}/${this.files.length})` downloadHeader = `[${this.files[fileIndex].name}] (file ${fileIndex + 1}/${this.files.length})`
const size = Number(this.files[fileIndex].size) const size = Number(this.files[fileIndex].size)
fileProgress = interpolate(bytesDownloaded, 0, size, downloadStartPoint, this.individualFileProgressPortion) fileProgress = interpolate(bytesDownloaded, 0, size, downloadStartPoint, this.individualFileProgressPortion)
this.percent = this._allFilesProgress + fileProgress this.percent = this._allFilesProgress + fileProgress
this.updateGUI(downloadHeader, `Downloading... (${Math.round(1000 * bytesDownloaded / size) / 10}%)`, 'good') this.updateGUI(downloadHeader, `Downloading... (${Math.round(1000 * bytesDownloaded / size) / 10}%)`, 'good')
}) })
downloader.on('error', this.handleError.bind(this)) downloader.on('error', this.handleError.bind(this))
return new Promise<void>(resolve => { return new Promise<void>(resolve => {
downloader.on('complete', () => { downloader.on('complete', () => {
this._allFilesProgress += this.individualFileProgressPortion this._allFilesProgress += this.individualFileProgressPortion
resolve() resolve()
}) })
}) })
} }
/** /**
* Defines what happens in response to `FileExtractor` events. * Defines what happens in response to `FileExtractor` events.
* @returns a `Promise` that resolves when the extraction finishes. * @returns a `Promise` that resolves when the extraction finishes.
*/ */
private addExtractorEventListeners(extractor: FileExtractor) { private addExtractorEventListeners(extractor: FileExtractor) {
let archive = '' let archive = ''
extractor.on('start', (filename) => { extractor.on('start', filename => {
archive = filename archive = filename
this.updateGUI(`[${archive}]`, 'Extracting...', 'good') this.updateGUI(`[${archive}]`, 'Extracting...', 'good')
}) })
extractor.on('extractProgress', (percent, filecount) => { extractor.on('extractProgress', (percent, filecount) => {
this.percent = interpolate(percent, 0, 100, 80, 95) this.percent = interpolate(percent, 0, 100, 80, 95)
this.updateGUI(`[${archive}] (${filecount} file${filecount == 1 ? '' : 's'} extracted)`, `Extracting... (${percent}%)`, 'good') this.updateGUI(`[${archive}] (${filecount} file${filecount == 1 ? '' : 's'} extracted)`, `Extracting... (${percent}%)`, 'good')
}) })
extractor.on('error', this.handleError.bind(this)) extractor.on('error', this.handleError.bind(this))
return new Promise<void>(resolve => { return new Promise<void>(resolve => {
extractor.on('complete', () => { extractor.on('complete', () => {
this.percent = 95 this.percent = 95
resolve() resolve()
}) })
}) })
} }
/** /**
* Defines what happens in response to `FileTransfer` events. * Defines what happens in response to `FileTransfer` events.
* @returns a `Promise` that resolves when the transfer finishes. * @returns a `Promise` that resolves when the transfer finishes.
*/ */
private addTransferEventListeners(transfer: FileTransfer) { private addTransferEventListeners(transfer: FileTransfer) {
let destinationFolder: string let destinationFolder: string
transfer.on('start', (_destinationFolder) => { transfer.on('start', _destinationFolder => {
destinationFolder = _destinationFolder destinationFolder = _destinationFolder
this.updateGUI('Moving files to library folder...', destinationFolder, 'good', true) this.updateGUI('Moving files to library folder...', destinationFolder, 'good', true)
}) })
transfer.on('error', this.handleError.bind(this)) transfer.on('error', this.handleError.bind(this))
return new Promise<void>(resolve => { return new Promise<void>(resolve => {
transfer.on('complete', () => { transfer.on('complete', () => {
this.percent = 100 this.percent = 100
this.updateGUI('Download complete.', destinationFolder, 'done', true) this.updateGUI('Download complete.', destinationFolder, 'done', true)
resolve() resolve()
}) })
}) })
} }
} }

View File

@@ -1,86 +1,86 @@
import { IPCEmitHandler } from '../../shared/IPCHandler'
import { Download } from '../../shared/interfaces/download.interface' import { Download } from '../../shared/interfaces/download.interface'
import { IPCEmitHandler } from '../../shared/IPCHandler'
import { ChartDownload } from './ChartDownload' import { ChartDownload } from './ChartDownload'
import { DownloadQueue } from './DownloadQueue' import { DownloadQueue } from './DownloadQueue'
class DownloadHandler implements IPCEmitHandler<'download'> { class DownloadHandler implements IPCEmitHandler<'download'> {
event: 'download' = 'download' event = 'download' as const
downloadQueue: DownloadQueue = new DownloadQueue() downloadQueue: DownloadQueue = new DownloadQueue()
currentDownload: ChartDownload = undefined currentDownload: ChartDownload = undefined
retryWaiting: ChartDownload[] = [] retryWaiting: ChartDownload[] = []
handler(data: Download) { handler(data: Download) {
switch (data.action) { switch (data.action) {
case 'add': this.addDownload(data); break case 'add': this.addDownload(data); break
case 'retry': this.retryDownload(data); break case 'retry': this.retryDownload(data); break
case 'cancel': this.cancelDownload(data); break case 'cancel': this.cancelDownload(data); break
} }
} }
private addDownload(data: Download) { private addDownload(data: Download) {
const filesHash = data.data.driveData.filesHash // Note: using versionID would cause chart packs to download multiple times const filesHash = data.data.driveData.filesHash // Note: using versionID would cause chart packs to download multiple times
if (this.currentDownload?.hash == filesHash || this.downloadQueue.isDownloadingLink(filesHash)) { if (this.currentDownload?.hash == filesHash || this.downloadQueue.isDownloadingLink(filesHash)) {
return return
} }
const newDownload = new ChartDownload(data.versionID, data.data) const newDownload = new ChartDownload(data.versionID, data.data)
this.addDownloadEventListeners(newDownload) this.addDownloadEventListeners(newDownload)
if (this.currentDownload == undefined) { if (this.currentDownload == undefined) {
this.currentDownload = newDownload this.currentDownload = newDownload
newDownload.beginDownload() newDownload.beginDownload()
} else { } else {
this.downloadQueue.push(newDownload) this.downloadQueue.push(newDownload)
} }
} }
private retryDownload(data: Download) { private retryDownload(data: Download) {
const index = this.retryWaiting.findIndex(download => download.versionID == data.versionID) const index = this.retryWaiting.findIndex(download => download.versionID == data.versionID)
if (index != -1) { if (index != -1) {
const retryDownload = this.retryWaiting.splice(index, 1)[0] const retryDownload = this.retryWaiting.splice(index, 1)[0]
retryDownload.displayRetrying() retryDownload.displayRetrying()
if (this.currentDownload == undefined) { if (this.currentDownload == undefined) {
this.currentDownload = retryDownload this.currentDownload = retryDownload
retryDownload.retry() retryDownload.retry()
} else { } else {
this.downloadQueue.push(retryDownload) this.downloadQueue.push(retryDownload)
} }
} }
} }
private cancelDownload(data: Download) { private cancelDownload(data: Download) {
if (this.currentDownload?.versionID == data.versionID) { if (this.currentDownload?.versionID == data.versionID) {
this.currentDownload.cancel() this.currentDownload.cancel()
this.currentDownload = undefined this.currentDownload = undefined
this.startNextDownload() this.startNextDownload()
} else { } else {
this.downloadQueue.remove(data.versionID) this.downloadQueue.remove(data.versionID)
} }
} }
private addDownloadEventListeners(download: ChartDownload) { private addDownloadEventListeners(download: ChartDownload) {
download.on('complete', () => { download.on('complete', () => {
this.currentDownload = undefined this.currentDownload = undefined
this.startNextDownload() this.startNextDownload()
}) })
download.on('error', () => { download.on('error', () => {
this.retryWaiting.push(this.currentDownload) this.retryWaiting.push(this.currentDownload)
this.currentDownload = undefined this.currentDownload = undefined
this.startNextDownload() this.startNextDownload()
}) })
} }
private startNextDownload() { private startNextDownload() {
if (!this.downloadQueue.isEmpty()) { if (!this.downloadQueue.isEmpty()) {
this.currentDownload = this.downloadQueue.shift() this.currentDownload = this.downloadQueue.shift()
if (this.currentDownload.hasFailed) { if (this.currentDownload.hasFailed) {
this.currentDownload.retry() this.currentDownload.retry()
} else { } else {
this.currentDownload.beginDownload() this.currentDownload.beginDownload()
} }
} }
} }
} }
export const downloadHandler = new DownloadHandler() export const downloadHandler = new DownloadHandler()

View File

@@ -1,50 +1,51 @@
import Comparators from 'comparators' import Comparators from 'comparators'
import { ChartDownload } from './ChartDownload'
import { emitIPCEvent } from '../../main' import { emitIPCEvent } from '../../main'
import { ChartDownload } from './ChartDownload'
export class DownloadQueue { export class DownloadQueue {
private downloadQueue: ChartDownload[] = [] private downloadQueue: ChartDownload[] = []
isDownloadingLink(filesHash: string) { isDownloadingLink(filesHash: string) {
return this.downloadQueue.some(download => download.hash == filesHash) return this.downloadQueue.some(download => download.hash == filesHash)
} }
isEmpty() { isEmpty() {
return this.downloadQueue.length == 0 return this.downloadQueue.length == 0
} }
push(chartDownload: ChartDownload) { push(chartDownload: ChartDownload) {
this.downloadQueue.push(chartDownload) this.downloadQueue.push(chartDownload)
this.sort() this.sort()
} }
shift() { shift() {
return this.downloadQueue.shift() return this.downloadQueue.shift()
} }
get(versionID: number) { get(versionID: number) {
return this.downloadQueue.find(download => download.versionID == versionID) return this.downloadQueue.find(download => download.versionID == versionID)
} }
remove(versionID: number) { remove(versionID: number) {
const index = this.downloadQueue.findIndex(download => download.versionID == versionID) const index = this.downloadQueue.findIndex(download => download.versionID == versionID)
if (index != -1) { if (index != -1) {
this.downloadQueue[index].cancel() this.downloadQueue[index].cancel()
this.downloadQueue.splice(index, 1) this.downloadQueue.splice(index, 1)
emitIPCEvent('queue-updated', this.downloadQueue.map(download => download.versionID)) emitIPCEvent('queue-updated', this.downloadQueue.map(download => download.versionID))
} }
} }
private sort() { private sort() {
let comparator = Comparators.comparing('allFilesProgress', { reversed: true }) let comparator = Comparators.comparing('allFilesProgress', { reversed: true })
const prioritizeArchives = true const prioritizeArchives = true
if (prioritizeArchives) { if (prioritizeArchives) {
comparator = comparator.thenComparing('isArchive', { reversed: true }) comparator = comparator.thenComparing('isArchive', { reversed: true })
} }
this.downloadQueue.sort(comparator) this.downloadQueue.sort(comparator)
emitIPCEvent('queue-updated', this.downloadQueue.map(download => download.versionID)) emitIPCEvent('queue-updated', this.downloadQueue.map(download => download.versionID))
} }
} }

View File

@@ -1,42 +1,44 @@
import { AnyFunction } from '../../shared/UtilFunctions' import Bottleneck from 'bottleneck'
import { devLog } from '../../shared/ElectronUtilFunctions'
import { createWriteStream, writeFile as _writeFile } from 'fs' import { createWriteStream, writeFile as _writeFile } from 'fs'
import { google } from 'googleapis'
import * as needle from 'needle' import * as needle from 'needle'
import { join } from 'path'
import { Readable } from 'stream' import { Readable } from 'stream'
import { inspect, promisify } from 'util'
import { devLog } from '../../shared/ElectronUtilFunctions'
import { tempPath } from '../../shared/Paths'
import { AnyFunction } from '../../shared/UtilFunctions'
import { DownloadError } from './ChartDownload'
// TODO: replace needle with got (for cancel() method) (if before-headers event is possible?) // TODO: replace needle with got (for cancel() method) (if before-headers event is possible?)
import { googleTimer } from './GoogleTimer' import { googleTimer } from './GoogleTimer'
import { DownloadError } from './ChartDownload'
import { google } from 'googleapis'
import Bottleneck from 'bottleneck'
import { inspect, promisify } from 'util'
import { join } from 'path'
import { tempPath } from '../../shared/Paths'
const drive = google.drive('v3') const drive = google.drive('v3')
const limiter = new Bottleneck({ const limiter = new Bottleneck({
minTime: 200 // Wait 200 ms between API requests minTime: 200, // Wait 200 ms between API requests
}) })
const RETRY_MAX = 2 const RETRY_MAX = 2
const writeFile = promisify(_writeFile) const writeFile = promisify(_writeFile)
type EventCallback = { interface EventCallback {
'waitProgress': (remainingSeconds: number, totalSeconds: number) => void 'waitProgress': (remainingSeconds: number, totalSeconds: number) => void
/** Note: this event can be called multiple times if the connection times out or a large file is downloaded */ /** Note: this event can be called multiple times if the connection times out or a large file is downloaded */
'requestSent': () => void 'requestSent': () => void
'downloadProgress': (bytesDownloaded: number) => void 'downloadProgress': (bytesDownloaded: number) => void
/** Note: after calling retry, the event lifecycle restarts */ /** Note: after calling retry, the event lifecycle restarts */
'error': (err: DownloadError, retry: () => void) => void 'error': (err: DownloadError, retry: () => void) => void
'complete': () => void 'complete': () => void
} }
type Callbacks = { [E in keyof EventCallback]: EventCallback[E] } type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
export type FileDownloader = APIFileDownloader | SlowFileDownloader export type FileDownloader = APIFileDownloader | SlowFileDownloader
const downloadErrors = { const downloadErrors = {
timeout: (type: string) => { return { header: 'Timeout', body: `The download server could not be reached. (type=${type})` } }, timeout: (type: string) => { return { header: 'Timeout', body: `The download server could not be reached. (type=${type})` } },
connectionError: (err: Error) => { return { header: 'Connection Error', body: `${err.name}: ${err.message}` } }, connectionError: (err: Error) => { return { header: 'Connection Error', body: `${err.name}: ${err.message}` } },
responseError: (statusCode: string) => { return { header: 'Connection failed', body: `Server returned status code: ${statusCode}` } }, responseError: (statusCode: string) => { return { header: 'Connection failed', body: `Server returned status code: ${statusCode}` } },
htmlError: (path: string) => { return { header: 'Download server returned HTML instead of a file.', body: path, isLink: true } }, htmlError: (path: string) => { return { header: 'Download server returned HTML instead of a file.', body: path, isLink: true } },
linkError: (url: string) => { return { header: 'Invalid link', body: `The download link is not formatted correctly: ${url}` } } linkError: (url: string) => { return { header: 'Invalid link', body: `The download link is not formatted correctly: ${url}` } },
} }
/** /**
@@ -48,7 +50,7 @@ const downloadErrors = {
* @param fullPath The full path to where this file should be stored (including the filename). * @param fullPath The full path to where this file should be stored (including the filename).
*/ */
export function getDownloader(url: string, fullPath: string): FileDownloader { export function getDownloader(url: string, fullPath: string): FileDownloader {
return new SlowFileDownloader(url, fullPath) return new SlowFileDownloader(url, fullPath)
} }
/** /**
@@ -56,141 +58,141 @@ export function getDownloader(url: string, fullPath: string): FileDownloader {
* On error, provides the ability to retry. * On error, provides the ability to retry.
*/ */
class APIFileDownloader { class APIFileDownloader {
private readonly URL_REGEX = /uc\?id=([^&]*)&export=download/u private readonly URL_REGEX = /uc\?id=([^&]*)&export=download/u
private callbacks = {} as Callbacks private callbacks = {} as Callbacks
private retryCount: number private retryCount: number
private wasCanceled = false private wasCanceled = false
private fileID: string private fileID: string
private downloadStream: Readable private downloadStream: Readable
/** /**
* @param url The download link. * @param url The download link.
* @param fullPath The full path to where this file should be stored (including the filename). * @param fullPath The full path to where this file should be stored (including the filename).
*/ */
constructor(private url: string, private fullPath: string) { constructor(private url: string, private fullPath: string) {
// url looks like: "https://drive.google.com/uc?id=1TlxtOZlVgRgX7-1tyW0d5QzXVfL-MC3Q&export=download" // url looks like: "https://drive.google.com/uc?id=1TlxtOZlVgRgX7-1tyW0d5QzXVfL-MC3Q&export=download"
this.fileID = this.URL_REGEX.exec(url)[1] this.fileID = this.URL_REGEX.exec(url)[1]
} }
/** /**
* Calls `callback` when `event` fires. (no events will be fired after `this.cancelDownload()` is called) * Calls `callback` when `event` fires. (no events will be fired after `this.cancelDownload()` is called)
*/ */
on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) { on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
this.callbacks[event] = callback this.callbacks[event] = callback
} }
/** /**
* Download the file after waiting for the google rate limit. * Download the file after waiting for the google rate limit.
*/ */
beginDownload() { beginDownload() {
if (this.fileID == undefined) { if (this.fileID == undefined) {
this.failDownload(downloadErrors.linkError(this.url)) this.failDownload(downloadErrors.linkError(this.url))
} }
this.startDownloadStream() this.startDownloadStream()
} }
/** /**
* Uses the Google Drive API to start a download stream for the file with `this.fileID`. * Uses the Google Drive API to start a download stream for the file with `this.fileID`.
*/ */
private startDownloadStream() { private startDownloadStream() {
limiter.schedule(this.cancelable(async () => { limiter.schedule(this.cancelable(async () => {
this.callbacks.requestSent() this.callbacks.requestSent()
try { try {
this.downloadStream = (await drive.files.get({ this.downloadStream = (await drive.files.get({
fileId: this.fileID, fileId: this.fileID,
alt: 'media' alt: 'media',
}, { }, {
responseType: 'stream' responseType: 'stream',
})).data })).data
if (this.wasCanceled) { return } if (this.wasCanceled) { return }
this.handleDownloadResponse() this.handleDownloadResponse()
} catch (err) { } catch (err) {
this.retryCount++ this.retryCount++
if (this.retryCount <= RETRY_MAX) { if (this.retryCount <= RETRY_MAX) {
devLog(`Failed to get file: Retry attempt ${this.retryCount}...`) devLog(`Failed to get file: Retry attempt ${this.retryCount}...`)
if (this.wasCanceled) { return } if (this.wasCanceled) { return }
this.startDownloadStream() this.startDownloadStream()
} else { } else {
devLog(inspect(err)) devLog(inspect(err))
if (err?.code && err?.response?.statusText) { if (err?.code && err?.response?.statusText) {
this.failDownload(downloadErrors.responseError(`${err.code} (${err.response.statusText})`)) this.failDownload(downloadErrors.responseError(`${err.code} (${err.response.statusText})`))
} else { } else {
this.failDownload(downloadErrors.responseError(err?.code ?? 'unknown')) this.failDownload(downloadErrors.responseError(err?.code ?? 'unknown'))
} }
} }
} }
})) }))
} }
/** /**
* Pipes the data from a download response to `this.fullPath`. * Pipes the data from a download response to `this.fullPath`.
* @param req The download request. * @param req The download request.
*/ */
private handleDownloadResponse() { private handleDownloadResponse() {
this.callbacks.downloadProgress(0) this.callbacks.downloadProgress(0)
let downloadedSize = 0 let downloadedSize = 0
const writeStream = createWriteStream(this.fullPath) const writeStream = createWriteStream(this.fullPath)
try { try {
this.downloadStream.pipe(writeStream) this.downloadStream.pipe(writeStream)
} catch (err) { } catch (err) {
this.failDownload(downloadErrors.connectionError(err)) this.failDownload(downloadErrors.connectionError(err))
} }
this.downloadStream.on('data', this.cancelable((chunk: Buffer) => { this.downloadStream.on('data', this.cancelable((chunk: Buffer) => {
downloadedSize += chunk.length downloadedSize += chunk.length
})) }))
const progressUpdater = setInterval(() => { const progressUpdater = setInterval(() => {
this.callbacks.downloadProgress(downloadedSize) this.callbacks.downloadProgress(downloadedSize)
}, 100) }, 100)
this.downloadStream.on('error', this.cancelable((err: Error) => { this.downloadStream.on('error', this.cancelable((err: Error) => {
clearInterval(progressUpdater) clearInterval(progressUpdater)
this.failDownload(downloadErrors.connectionError(err)) this.failDownload(downloadErrors.connectionError(err))
})) }))
this.downloadStream.on('end', this.cancelable(() => { this.downloadStream.on('end', this.cancelable(() => {
clearInterval(progressUpdater) clearInterval(progressUpdater)
writeStream.end() writeStream.end()
this.downloadStream.destroy() this.downloadStream.destroy()
this.downloadStream = null this.downloadStream = null
this.callbacks.complete() this.callbacks.complete()
})) }))
} }
/** /**
* Display an error message and provide a function to retry the download. * Display an error message and provide a function to retry the download.
*/ */
private failDownload(error: DownloadError) { private failDownload(error: DownloadError) {
this.callbacks.error(error, this.cancelable(() => this.beginDownload())) this.callbacks.error(error, this.cancelable(() => this.beginDownload()))
} }
/** /**
* Stop the process of downloading the file. (no more events will be fired after this is called) * Stop the process of downloading the file. (no more events will be fired after this is called)
*/ */
cancelDownload() { cancelDownload() {
this.wasCanceled = true this.wasCanceled = true
googleTimer.cancelTimer() // Prevents timer from trying to activate a download and resetting googleTimer.cancelTimer() // Prevents timer from trying to activate a download and resetting
if (this.downloadStream) { if (this.downloadStream) {
this.downloadStream.destroy() this.downloadStream.destroy()
} }
} }
/** /**
* Wraps a function that is able to be prevented if `this.cancelDownload()` was called. * Wraps a function that is able to be prevented if `this.cancelDownload()` was called.
*/ */
private cancelable<F extends AnyFunction>(fn: F) { private cancelable<F extends AnyFunction>(fn: F) {
return (...args: Parameters<F>): ReturnType<F> => { return (...args: Parameters<F>): ReturnType<F> => {
if (this.wasCanceled) { return } if (this.wasCanceled) { return }
return fn(...Array.from(args)) return fn(...Array.from(args))
} }
} }
} }
/** /**
@@ -201,166 +203,166 @@ class APIFileDownloader {
*/ */
class SlowFileDownloader { class SlowFileDownloader {
private callbacks = {} as Callbacks private callbacks = {} as Callbacks
private retryCount: number private retryCount: number
private wasCanceled = false private wasCanceled = false
private req: NodeJS.ReadableStream private req: NodeJS.ReadableStream
/** /**
* @param url The download link. * @param url The download link.
* @param fullPath The full path to where this file should be stored (including the filename). * @param fullPath The full path to where this file should be stored (including the filename).
*/ */
constructor(private url: string, private fullPath: string) { } constructor(private url: string, private fullPath: string) { }
/** /**
* Calls `callback` when `event` fires. (no events will be fired after `this.cancelDownload()` is called) * Calls `callback` when `event` fires. (no events will be fired after `this.cancelDownload()` is called)
*/ */
on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) { on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
this.callbacks[event] = callback this.callbacks[event] = callback
} }
/** /**
* Download the file after waiting for the google rate limit. * Download the file after waiting for the google rate limit.
*/ */
beginDownload() { beginDownload() {
googleTimer.on('waitProgress', this.cancelable((remainingSeconds, totalSeconds) => { googleTimer.on('waitProgress', this.cancelable((remainingSeconds, totalSeconds) => {
this.callbacks.waitProgress(remainingSeconds, totalSeconds) this.callbacks.waitProgress(remainingSeconds, totalSeconds)
})) }))
googleTimer.on('complete', this.cancelable(() => { googleTimer.on('complete', this.cancelable(() => {
this.requestDownload() this.requestDownload()
})) }))
} }
/** /**
* Sends a request to download the file at `this.url`. * Sends a request to download the file at `this.url`.
* @param cookieHeader the "cookie=" header to include this request. * @param cookieHeader the "cookie=" header to include this request.
*/ */
private requestDownload(cookieHeader?: string) { private requestDownload(cookieHeader?: string) {
this.callbacks.requestSent() this.callbacks.requestSent()
this.req = needle.get(this.url, { this.req = needle.get(this.url, {
'follow_max': 10, 'follow_max': 10,
'open_timeout': 5000, 'open_timeout': 5000,
'headers': Object.assign({ 'headers': Object.assign({
'Referer': this.url, 'Referer': this.url,
'Accept': '*/*' 'Accept': '*/*',
}, },
(cookieHeader ? { 'Cookie': cookieHeader } : undefined) (cookieHeader ? { 'Cookie': cookieHeader } : undefined)
) ),
}) })
this.req.on('timeout', this.cancelable((type: string) => { this.req.on('timeout', this.cancelable((type: string) => {
this.retryCount++ this.retryCount++
if (this.retryCount <= RETRY_MAX) { if (this.retryCount <= RETRY_MAX) {
devLog(`TIMEOUT: Retry attempt ${this.retryCount}...`) devLog(`TIMEOUT: Retry attempt ${this.retryCount}...`)
this.requestDownload(cookieHeader) this.requestDownload(cookieHeader)
} else { } else {
this.failDownload(downloadErrors.timeout(type)) this.failDownload(downloadErrors.timeout(type))
} }
})) }))
this.req.on('err', this.cancelable((err: Error) => { this.req.on('err', this.cancelable((err: Error) => {
this.failDownload(downloadErrors.connectionError(err)) this.failDownload(downloadErrors.connectionError(err))
})) }))
this.req.on('header', this.cancelable((statusCode, headers: Headers) => { this.req.on('header', this.cancelable((statusCode, headers: Headers) => {
if (statusCode != 200) { if (statusCode != 200) {
this.failDownload(downloadErrors.responseError(statusCode)) this.failDownload(downloadErrors.responseError(statusCode))
return return
} }
if (headers['content-type'].startsWith('text/html')) { if (headers['content-type'].startsWith('text/html')) {
this.handleHTMLResponse(headers['set-cookie']) this.handleHTMLResponse(headers['set-cookie'])
} else { } else {
this.handleDownloadResponse() this.handleDownloadResponse()
} }
})) }))
} }
/** /**
* A Google Drive HTML response to a download request usually means this is the "file too large to scan for viruses" warning. * A Google Drive HTML response to a download request usually means this is the "file too large to scan for viruses" warning.
* This function sends the request that results from clicking "download anyway", or generates an error if it can't be found. * This function sends the request that results from clicking "download anyway", or generates an error if it can't be found.
* @param cookieHeader The "cookie=" header of this request. * @param cookieHeader The "cookie=" header of this request.
*/ */
private handleHTMLResponse(cookieHeader: string) { private handleHTMLResponse(cookieHeader: string) {
let virusScanHTML = '' let virusScanHTML = ''
this.req.on('data', this.cancelable(data => virusScanHTML += data)) this.req.on('data', this.cancelable(data => virusScanHTML += data))
this.req.on('done', this.cancelable((err: Error) => { this.req.on('done', this.cancelable((err: Error) => {
if (err) { if (err) {
this.failDownload(downloadErrors.connectionError(err)) this.failDownload(downloadErrors.connectionError(err))
} else { } else {
try { try {
const confirmTokenRegex = /confirm=([0-9A-Za-z\-_]+)&/g const confirmTokenRegex = /confirm=([0-9A-Za-z\-_]+)&/g
const confirmTokenResults = confirmTokenRegex.exec(virusScanHTML) const confirmTokenResults = confirmTokenRegex.exec(virusScanHTML)
const confirmToken = confirmTokenResults[1] const confirmToken = confirmTokenResults[1]
const downloadID = this.url.substr(this.url.indexOf('id=') + 'id='.length) const downloadID = this.url.substr(this.url.indexOf('id=') + 'id='.length)
this.url = `https://drive.google.com/uc?confirm=${confirmToken}&id=${downloadID}` this.url = `https://drive.google.com/uc?confirm=${confirmToken}&id=${downloadID}`
const warningCode = /download_warning_([^=]*)=/.exec(cookieHeader)[1] const warningCode = /download_warning_([^=]*)=/.exec(cookieHeader)[1]
const NID = /NID=([^;]*);/.exec(cookieHeader)[1].replace('=', '%') const NID = /NID=([^;]*);/.exec(cookieHeader)[1].replace('=', '%')
const newHeader = `download_warning_${warningCode}=${confirmToken}; NID=${NID}` const newHeader = `download_warning_${warningCode}=${confirmToken}; NID=${NID}`
this.requestDownload(newHeader) this.requestDownload(newHeader)
} catch(e) { } catch (e) {
this.saveHTMLError(virusScanHTML).then((path) => { this.saveHTMLError(virusScanHTML).then(path => {
this.failDownload(downloadErrors.htmlError(path)) this.failDownload(downloadErrors.htmlError(path))
}) })
} }
} }
})) }))
} }
/** /**
* Pipes the data from a download response to `this.fullPath`. * Pipes the data from a download response to `this.fullPath`.
* @param req The download request. * @param req The download request.
*/ */
private handleDownloadResponse() { private handleDownloadResponse() {
this.callbacks.downloadProgress(0) this.callbacks.downloadProgress(0)
let downloadedSize = 0 let downloadedSize = 0
this.req.pipe(createWriteStream(this.fullPath)) this.req.pipe(createWriteStream(this.fullPath))
this.req.on('data', this.cancelable((data) => { this.req.on('data', this.cancelable(data => {
downloadedSize += data.length downloadedSize += data.length
this.callbacks.downloadProgress(downloadedSize) this.callbacks.downloadProgress(downloadedSize)
})) }))
this.req.on('err', this.cancelable((err: Error) => { this.req.on('err', this.cancelable((err: Error) => {
this.failDownload(downloadErrors.connectionError(err)) this.failDownload(downloadErrors.connectionError(err))
})) }))
this.req.on('end', this.cancelable(() => { this.req.on('end', this.cancelable(() => {
this.callbacks.complete() this.callbacks.complete()
})) }))
} }
private async saveHTMLError(text: string) { private async saveHTMLError(text: string) {
const errorPath = join(tempPath, 'HTMLError.html') const errorPath = join(tempPath, 'HTMLError.html')
await writeFile(errorPath, text) await writeFile(errorPath, text)
return errorPath return errorPath
} }
/** /**
* Display an error message and provide a function to retry the download. * Display an error message and provide a function to retry the download.
*/ */
private failDownload(error: DownloadError) { private failDownload(error: DownloadError) {
this.callbacks.error(error, this.cancelable(() => this.beginDownload())) this.callbacks.error(error, this.cancelable(() => this.beginDownload()))
} }
/** /**
* Stop the process of downloading the file. (no more events will be fired after this is called) * Stop the process of downloading the file. (no more events will be fired after this is called)
*/ */
cancelDownload() { cancelDownload() {
this.wasCanceled = true this.wasCanceled = true
googleTimer.cancelTimer() // Prevents timer from trying to activate a download and resetting googleTimer.cancelTimer() // Prevents timer from trying to activate a download and resetting
if (this.req) { if (this.req) {
// TODO: destroy request // TODO: destroy request
} }
} }
/** /**
* Wraps a function that is able to be prevented if `this.cancelDownload()` was called. * Wraps a function that is able to be prevented if `this.cancelDownload()` was called.
*/ */
private cancelable<F extends AnyFunction>(fn: F) { private cancelable<F extends AnyFunction>(fn: F) {
return (...args: Parameters<F>): ReturnType<F> => { return (...args: Parameters<F>): ReturnType<F> => {
if (this.wasCanceled) { return } if (this.wasCanceled) { return }
return fn(...Array.from(args)) return fn(...Array.from(args))
} }
} }
} }

View File

@@ -1,163 +1,164 @@
import { readdir, unlink, mkdir as _mkdir } from 'fs'
import { promisify } from 'util'
import { join, extname } from 'path'
import { AnyFunction } from '../../shared/UtilFunctions'
import { devLog } from '../../shared/ElectronUtilFunctions'
import * as node7z from 'node-7z'
import * as zipBin from '7zip-bin' import * as zipBin from '7zip-bin'
import { mkdir as _mkdir, readdir, unlink } from 'fs'
import * as node7z from 'node-7z'
import * as unrarjs from 'node-unrar-js' // TODO find better rar library that has async extraction import * as unrarjs from 'node-unrar-js' // TODO find better rar library that has async extraction
import { FailReason } from 'node-unrar-js/dist/js/extractor' import { FailReason } from 'node-unrar-js/dist/js/extractor'
import { extname, join } from 'path'
import { promisify } from 'util'
import { devLog } from '../../shared/ElectronUtilFunctions'
import { AnyFunction } from '../../shared/UtilFunctions'
import { DownloadError } from './ChartDownload' import { DownloadError } from './ChartDownload'
const mkdir = promisify(_mkdir) const mkdir = promisify(_mkdir)
type EventCallback = { interface EventCallback {
'start': (filename: string) => void 'start': (filename: string) => void
'extractProgress': (percent: number, fileCount: number) => void 'extractProgress': (percent: number, fileCount: number) => void
'error': (err: DownloadError, retry: () => void | Promise<void>) => void 'error': (err: DownloadError, retry: () => void | Promise<void>) => void
'complete': () => void 'complete': () => void
} }
type Callbacks = { [E in keyof EventCallback]: EventCallback[E] } type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
const extractErrors = { const extractErrors = {
readError: (err: NodeJS.ErrnoException) => { return { header: `Failed to read file (${err.code})`, body: `${err.name}: ${err.message}` } }, readError: (err: NodeJS.ErrnoException) => { return { header: `Failed to read file (${err.code})`, body: `${err.name}: ${err.message}` } },
emptyError: () => { return { header: 'Failed to extract archive', body: 'File archive was downloaded but could not be found' } }, emptyError: () => { return { header: 'Failed to extract archive', body: 'File archive was downloaded but could not be found' } },
rarmkdirError: (err: NodeJS.ErrnoException, sourceFile: string) => { rarmkdirError: (err: NodeJS.ErrnoException, sourceFile: string) => {
return { header: `Extracting archive failed. (${err.code})`, body: `${err.name}: ${err.message} (${sourceFile})`} return { header: `Extracting archive failed. (${err.code})`, body: `${err.name}: ${err.message} (${sourceFile})` }
}, },
rarextractError: (result: { reason: FailReason; msg: string }, sourceFile: string) => { rarextractError: (result: { reason: FailReason; msg: string }, sourceFile: string) => {
return { header: `Extracting archive failed: ${result.reason}`, body: `${result.msg} (${sourceFile})`} return { header: `Extracting archive failed: ${result.reason}`, body: `${result.msg} (${sourceFile})` }
} },
} }
export class FileExtractor { export class FileExtractor {
private callbacks = {} as Callbacks private callbacks = {} as Callbacks
private wasCanceled = false private wasCanceled = false
constructor(private sourceFolder: string) { } constructor(private sourceFolder: string) { }
/** /**
* Calls `callback` when `event` fires. (no events will be fired after `this.cancelExtract()` is called) * Calls `callback` when `event` fires. (no events will be fired after `this.cancelExtract()` is called)
*/ */
on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) { on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
this.callbacks[event] = callback this.callbacks[event] = callback
} }
/** /**
* Extract the chart from `this.sourceFolder`. (assumes there is exactly one archive file in that folder) * Extract the chart from `this.sourceFolder`. (assumes there is exactly one archive file in that folder)
*/ */
beginExtract() { beginExtract() {
setTimeout(this.cancelable(() => { setTimeout(this.cancelable(() => {
readdir(this.sourceFolder, (err, files) => { readdir(this.sourceFolder, (err, files) => {
if (err) { if (err) {
this.callbacks.error(extractErrors.readError(err), () => this.beginExtract()) this.callbacks.error(extractErrors.readError(err), () => this.beginExtract())
} else if (files.length == 0) { } else if (files.length == 0) {
this.callbacks.error(extractErrors.emptyError(), () => this.beginExtract()) this.callbacks.error(extractErrors.emptyError(), () => this.beginExtract())
} else { } else {
this.callbacks.start(files[0]) this.callbacks.start(files[0])
this.extract(join(this.sourceFolder, files[0]), extname(files[0]) == '.rar') this.extract(join(this.sourceFolder, files[0]), extname(files[0]) == '.rar')
} }
}) })
}), 100) // Wait for filesystem to process downloaded file }), 100) // Wait for filesystem to process downloaded file
} }
/** /**
* Extracts the file at `fullPath` to `this.sourceFolder`. * Extracts the file at `fullPath` to `this.sourceFolder`.
*/ */
private async extract(fullPath: string, useRarExtractor: boolean) { private async extract(fullPath: string, useRarExtractor: boolean) {
if (useRarExtractor) { if (useRarExtractor) {
await this.extractRar(fullPath) // Use node-unrar-js to extract the archive await this.extractRar(fullPath) // Use node-unrar-js to extract the archive
} else { } else {
this.extract7z(fullPath) // Use node-7z to extract the archive this.extract7z(fullPath) // Use node-7z to extract the archive
} }
} }
/** /**
* Extracts a .rar archive found at `fullPath` and puts the extracted results in `this.sourceFolder`. * Extracts a .rar archive found at `fullPath` and puts the extracted results in `this.sourceFolder`.
* @throws an `ExtractError` if this fails. * @throws an `ExtractError` if this fails.
*/ */
private async extractRar(fullPath: string) { private async extractRar(fullPath: string) {
const extractor = unrarjs.createExtractorFromFile(fullPath, this.sourceFolder) const extractor = unrarjs.createExtractorFromFile(fullPath, this.sourceFolder)
const fileList = extractor.getFileList() const fileList = extractor.getFileList()
if (fileList[0].state != 'FAIL') { if (fileList[0].state != 'FAIL') {
// Create directories for nested archives (because unrarjs didn't feel like handling that automatically) // Create directories for nested archives (because unrarjs didn't feel like handling that automatically)
const headers = fileList[1].fileHeaders const headers = fileList[1].fileHeaders
for (const header of headers) { for (const header of headers) {
if (header.flags.directory) { if (header.flags.directory) {
try { try {
await mkdir(join(this.sourceFolder, header.name), { recursive: true }) await mkdir(join(this.sourceFolder, header.name), { recursive: true })
} catch (err) { } catch (err) {
this.callbacks.error(extractErrors.rarmkdirError(err, fullPath), () => this.extract(fullPath, extname(fullPath) == '.rar')) this.callbacks.error(extractErrors.rarmkdirError(err, fullPath), () => this.extract(fullPath, extname(fullPath) == '.rar'))
return return
} }
} }
} }
} }
const extractResult = extractor.extractAll() const extractResult = extractor.extractAll()
if (extractResult[0].state == 'FAIL') { if (extractResult[0].state == 'FAIL') {
this.callbacks.error(extractErrors.rarextractError(extractResult[0], fullPath), () => this.extract(fullPath, extname(fullPath) == '.rar')) this.callbacks.error(extractErrors.rarextractError(extractResult[0], fullPath), () => this.extract(fullPath, extname(fullPath) == '.rar'))
} else { } else {
this.deleteArchive(fullPath) this.deleteArchive(fullPath)
} }
} }
/** /**
* Extracts a .zip or .7z archive found at `fullPath` and puts the extracted results in `this.sourceFolder`. * Extracts a .zip or .7z archive found at `fullPath` and puts the extracted results in `this.sourceFolder`.
*/ */
private extract7z(fullPath: string) { private extract7z(fullPath: string) {
const zipBinPath = zipBin.path7za.replace('app.asar', 'app.asar.unpacked') // I love electron-builder packaging :) const zipBinPath = zipBin.path7za.replace('app.asar', 'app.asar.unpacked') // I love electron-builder packaging :)
const stream = node7z.extractFull(fullPath, this.sourceFolder, { $progress: true, $bin: zipBinPath }) const stream = node7z.extractFull(fullPath, this.sourceFolder, { $progress: true, $bin: zipBinPath })
stream.on('progress', this.cancelable((progress: { percent: number; fileCount: number }) => { stream.on('progress', this.cancelable((progress: { percent: number; fileCount: number }) => {
this.callbacks.extractProgress(progress.percent, isNaN(progress.fileCount) ? 0 : progress.fileCount) this.callbacks.extractProgress(progress.percent, isNaN(progress.fileCount) ? 0 : progress.fileCount)
})) }))
let extractErrorOccured = false let extractErrorOccured = false
stream.on('error', this.cancelable(() => { stream.on('error', this.cancelable(() => {
extractErrorOccured = true extractErrorOccured = true
devLog(`Failed to extract [${fullPath}]; retrying with .rar extractor...`) devLog(`Failed to extract [${fullPath}]; retrying with .rar extractor...`)
this.extract(fullPath, true) this.extract(fullPath, true)
})) }))
stream.on('end', this.cancelable(() => { stream.on('end', this.cancelable(() => {
if (!extractErrorOccured) { if (!extractErrorOccured) {
this.deleteArchive(fullPath) this.deleteArchive(fullPath)
} }
})) }))
} }
/** /**
* Tries to delete the archive at `fullPath`. * Tries to delete the archive at `fullPath`.
*/ */
private deleteArchive(fullPath: string) { private deleteArchive(fullPath: string) {
unlink(fullPath, this.cancelable((err) => { unlink(fullPath, this.cancelable(err => {
if (err && err.code != 'ENOENT') { if (err && err.code != 'ENOENT') {
devLog(`Warning: failed to delete archive at [${fullPath}]`) devLog(`Warning: failed to delete archive at [${fullPath}]`)
} }
this.callbacks.complete() this.callbacks.complete()
})) }))
} }
/** /**
* Stop the process of extracting the file. (no more events will be fired after this is called) * Stop the process of extracting the file. (no more events will be fired after this is called)
*/ */
cancelExtract() { cancelExtract() {
this.wasCanceled = true this.wasCanceled = true
} }
/** /**
* Wraps a function that is able to be prevented if `this.cancelExtract()` was called. * Wraps a function that is able to be prevented if `this.cancelExtract()` was called.
*/ */
private cancelable<F extends AnyFunction>(fn: F) { private cancelable<F extends AnyFunction>(fn: F) {
return (...args: Parameters<F>): ReturnType<F> => { return (...args: Parameters<F>): ReturnType<F> => {
if (this.wasCanceled) { return } if (this.wasCanceled) { return }
return fn(...Array.from(args)) return fn(...Array.from(args))
} }
} }
} }

View File

@@ -1,108 +1,109 @@
import { Dirent, readdir as _readdir } from 'fs' import { Dirent, readdir as _readdir } from 'fs'
import { promisify } from 'util'
import { getSettings } from '../SettingsHandler.ipc'
import * as mv from 'mv' import * as mv from 'mv'
import { join } from 'path' import { join } from 'path'
import { rimraf } from 'rimraf' import { rimraf } from 'rimraf'
import { promisify } from 'util'
import { getSettings } from '../SettingsHandler.ipc'
import { DownloadError } from './ChartDownload' import { DownloadError } from './ChartDownload'
const readdir = promisify(_readdir) const readdir = promisify(_readdir)
type EventCallback = { interface EventCallback {
'start': (destinationFolder: string) => void 'start': (destinationFolder: string) => void
'error': (err: DownloadError, retry: () => void | Promise<void>) => void 'error': (err: DownloadError, retry: () => void | Promise<void>) => void
'complete': () => void 'complete': () => void
} }
type Callbacks = { [E in keyof EventCallback]: EventCallback[E] } type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
const transferErrors = { const transferErrors = {
readError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to read file.'), readError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to read file.'),
deleteError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to delete file.'), deleteError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to delete file.'),
rimrafError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to delete folder.'), rimrafError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to delete folder.'),
mvError: (err: NodeJS.ErrnoException) => fsError(err, `Failed to move folder to library.${err.code == 'EPERM' ? ' (does the chart already exist?)' : ''}`) mvError: (err: NodeJS.ErrnoException) => fsError(err, `Failed to move folder to library.${err.code == 'EPERM' ? ' (does the chart already exist?)' : ''}`),
} }
function fsError(err: NodeJS.ErrnoException, description: string) { function fsError(err: NodeJS.ErrnoException, description: string) {
return { header: description, body: `${err.name}: ${err.message}` } return { header: description, body: `${err.name}: ${err.message}` }
} }
export class FileTransfer { export class FileTransfer {
private callbacks = {} as Callbacks private callbacks = {} as Callbacks
private wasCanceled = false private wasCanceled = false
private destinationFolder: string private destinationFolder: string
private nestedSourceFolder: string // The top-level folder that is copied to the library folder private nestedSourceFolder: string // The top-level folder that is copied to the library folder
constructor(private sourceFolder: string, destinationFolderName: string) { constructor(private sourceFolder: string, destinationFolderName: string) {
this.destinationFolder = join(getSettings().libraryPath, destinationFolderName) this.destinationFolder = join(getSettings().libraryPath, destinationFolderName)
this.nestedSourceFolder = sourceFolder this.nestedSourceFolder = sourceFolder
} }
/** /**
* Calls `callback` when `event` fires. (no events will be fired after `this.cancelTransfer()` is called) * Calls `callback` when `event` fires. (no events will be fired after `this.cancelTransfer()` is called)
*/ */
on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) { on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
this.callbacks[event] = callback this.callbacks[event] = callback
} }
async beginTransfer() { async beginTransfer() {
this.callbacks.start(this.destinationFolder) this.callbacks.start(this.destinationFolder)
await this.cleanFolder() await this.cleanFolder()
if (this.wasCanceled) { return } if (this.wasCanceled) { return }
this.moveFolder() this.moveFolder()
} }
/** /**
* Fixes common problems with the download chart folder. * Fixes common problems with the download chart folder.
*/ */
private async cleanFolder() { private async cleanFolder() {
let files: Dirent[] let files: Dirent[]
try { try {
files = await readdir(this.nestedSourceFolder, { withFileTypes: true }) files = await readdir(this.nestedSourceFolder, { withFileTypes: true })
} catch (err) { } catch (err) {
this.callbacks.error(transferErrors.readError(err), () => this.cleanFolder()) this.callbacks.error(transferErrors.readError(err), () => this.cleanFolder())
return return
} }
// Remove nested folders // Remove nested folders
if (files.length == 1 && !files[0].isFile()) { if (files.length == 1 && !files[0].isFile()) {
this.nestedSourceFolder = join(this.nestedSourceFolder, files[0].name) this.nestedSourceFolder = join(this.nestedSourceFolder, files[0].name)
await this.cleanFolder() await this.cleanFolder()
return return
} }
// Delete '__MACOSX' folder // Delete '__MACOSX' folder
for (const file of files) { for (const file of files) {
if (!file.isFile() && file.name == '__MACOSX') { if (!file.isFile() && file.name == '__MACOSX') {
try { try {
await rimraf(join(this.nestedSourceFolder, file.name)) await rimraf(join(this.nestedSourceFolder, file.name))
} catch (err) { } catch (err) {
this.callbacks.error(transferErrors.rimrafError(err), () => this.cleanFolder()) this.callbacks.error(transferErrors.rimrafError(err), () => this.cleanFolder())
return return
} }
} else { } else {
// TODO: handle other common problems, like chart/audio files not named correctly // TODO: handle other common problems, like chart/audio files not named correctly
} }
} }
} }
/** /**
* Moves the downloaded chart to the library path. * Moves the downloaded chart to the library path.
*/ */
private moveFolder() { private moveFolder() {
mv(this.nestedSourceFolder, this.destinationFolder, { mkdirp: true }, (err) => { mv(this.nestedSourceFolder, this.destinationFolder, { mkdirp: true }, err => {
if (err) { if (err) {
this.callbacks.error(transferErrors.mvError(err), () => this.moveFolder()) this.callbacks.error(transferErrors.mvError(err), () => this.moveFolder())
} else { } else {
rimraf(this.sourceFolder) // Delete temp folder rimraf(this.sourceFolder) // Delete temp folder
this.callbacks.complete() this.callbacks.complete()
} }
}) })
} }
/** /**
* Stop the process of transfering the file. (no more events will be fired after this is called) * Stop the process of transfering the file. (no more events will be fired after this is called)
*/ */
cancelTransfer() { cancelTransfer() {
this.wasCanceled = true this.wasCanceled = true
} }
} }

View File

@@ -1,121 +1,122 @@
import { DownloadError } from './ChartDownload'
import { tempPath } from '../../shared/Paths'
import { AnyFunction } from '../../shared/UtilFunctions'
import { devLog } from '../../shared/ElectronUtilFunctions'
import { randomBytes as _randomBytes } from 'crypto' import { randomBytes as _randomBytes } from 'crypto'
import { mkdir, access, constants } from 'fs' import { access, constants, mkdir } from 'fs'
import { join } from 'path' import { join } from 'path'
import { promisify } from 'util' import { promisify } from 'util'
import { devLog } from '../../shared/ElectronUtilFunctions'
import { tempPath } from '../../shared/Paths'
import { AnyFunction } from '../../shared/UtilFunctions'
import { getSettings } from '../SettingsHandler.ipc' import { getSettings } from '../SettingsHandler.ipc'
import { DownloadError } from './ChartDownload'
const randomBytes = promisify(_randomBytes) const randomBytes = promisify(_randomBytes)
type EventCallback = { interface EventCallback {
'start': () => void 'start': () => void
'error': (err: DownloadError, retry: () => void | Promise<void>) => void 'error': (err: DownloadError, retry: () => void | Promise<void>) => void
'complete': (tempPath: string) => void 'complete': (tempPath: string) => void
} }
type Callbacks = { [E in keyof EventCallback]: EventCallback[E] } type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
const filesystemErrors = { const filesystemErrors = {
libraryFolder: () => { return { header: 'Library folder not specified', body: 'Please go to the settings to set your library folder.' } }, libraryFolder: () => { return { header: 'Library folder not specified', body: 'Please go to the settings to set your library folder.' } },
libraryAccess: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to access library folder.'), libraryAccess: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to access library folder.'),
destinationFolderExists: (destinationPath: string) => { destinationFolderExists: (destinationPath: string) => {
return { header: 'This chart already exists in your library folder.', body: destinationPath, isLink: true } return { header: 'This chart already exists in your library folder.', body: destinationPath, isLink: true }
}, },
mkdirError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to create temporary folder.') mkdirError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to create temporary folder.'),
} }
function fsError(err: NodeJS.ErrnoException, description: string) { function fsError(err: NodeJS.ErrnoException, description: string) {
return { header: description, body: `${err.name}: ${err.message}` } return { header: description, body: `${err.name}: ${err.message}` }
} }
export class FilesystemChecker { export class FilesystemChecker {
private callbacks = {} as Callbacks private callbacks = {} as Callbacks
private wasCanceled = false private wasCanceled = false
constructor(private destinationFolderName: string) { } constructor(private destinationFolderName: string) { }
/** /**
* Calls `callback` when `event` fires. (no events will be fired after `this.cancelDownload()` is called) * Calls `callback` when `event` fires. (no events will be fired after `this.cancelDownload()` is called)
*/ */
on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) { on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
this.callbacks[event] = callback this.callbacks[event] = callback
} }
/** /**
* Check that the filesystem is set up for the download. * Check that the filesystem is set up for the download.
*/ */
beginCheck() { beginCheck() {
this.callbacks.start() this.callbacks.start()
this.checkLibraryFolder() this.checkLibraryFolder()
} }
/** /**
* Verifies that the user has specified a library folder. * Verifies that the user has specified a library folder.
*/ */
private checkLibraryFolder() { private checkLibraryFolder() {
if (getSettings().libraryPath == undefined) { if (getSettings().libraryPath == undefined) {
this.callbacks.error(filesystemErrors.libraryFolder(), () => this.beginCheck()) this.callbacks.error(filesystemErrors.libraryFolder(), () => this.beginCheck())
} else { } else {
access(getSettings().libraryPath, constants.W_OK, this.cancelable((err) => { access(getSettings().libraryPath, constants.W_OK, this.cancelable(err => {
if (err) { if (err) {
this.callbacks.error(filesystemErrors.libraryAccess(err), () => this.beginCheck()) this.callbacks.error(filesystemErrors.libraryAccess(err), () => this.beginCheck())
} else { } else {
this.checkDestinationFolder() this.checkDestinationFolder()
} }
})) }))
} }
} }
/** /**
* Checks that the destination folder doesn't already exist. * Checks that the destination folder doesn't already exist.
*/ */
private checkDestinationFolder() { private checkDestinationFolder() {
const destinationPath = join(getSettings().libraryPath, this.destinationFolderName) const destinationPath = join(getSettings().libraryPath, this.destinationFolderName)
access(destinationPath, constants.F_OK, this.cancelable((err) => { access(destinationPath, constants.F_OK, this.cancelable(err => {
if (err) { // File does not exist if (err) { // File does not exist
this.createDownloadFolder() this.createDownloadFolder()
} else { } else {
this.callbacks.error(filesystemErrors.destinationFolderExists(destinationPath), () => this.beginCheck()) this.callbacks.error(filesystemErrors.destinationFolderExists(destinationPath), () => this.beginCheck())
} }
})) }))
} }
/** /**
* Attempts to create a unique folder in Bridge's data paths. * Attempts to create a unique folder in Bridge's data paths.
*/ */
private async createDownloadFolder(retryCount = 0) { private async createDownloadFolder(retryCount = 0) {
const tempChartPath = join(tempPath, `chart_${(await randomBytes(5)).toString('hex')}`) const tempChartPath = join(tempPath, `chart_${(await randomBytes(5)).toString('hex')}`)
mkdir(tempChartPath, this.cancelable((err) => { mkdir(tempChartPath, this.cancelable(err => {
if (err) { if (err) {
if (retryCount < 5) { if (retryCount < 5) {
devLog(`Error creating folder [${tempChartPath}], retrying with a different folder...`) devLog(`Error creating folder [${tempChartPath}], retrying with a different folder...`)
this.createDownloadFolder(retryCount + 1) this.createDownloadFolder(retryCount + 1)
} else { } else {
this.callbacks.error(filesystemErrors.mkdirError(err), () => this.createDownloadFolder()) this.callbacks.error(filesystemErrors.mkdirError(err), () => this.createDownloadFolder())
} }
} else { } else {
this.callbacks.complete(tempChartPath) this.callbacks.complete(tempChartPath)
} }
})) }))
} }
/** /**
* Stop the process of checking the filesystem permissions. (no more events will be fired after this is called) * Stop the process of checking the filesystem permissions. (no more events will be fired after this is called)
*/ */
cancelCheck() { cancelCheck() {
this.wasCanceled = true this.wasCanceled = true
} }
/** /**
* Wraps a function that is able to be prevented if `this.cancelCheck()` was called. * Wraps a function that is able to be prevented if `this.cancelCheck()` was called.
*/ */
private cancelable<F extends AnyFunction>(fn: F) { private cancelable<F extends AnyFunction>(fn: F) {
return (...args: Parameters<F>): ReturnType<F> => { return (...args: Parameters<F>): ReturnType<F> => {
if (this.wasCanceled) { return } if (this.wasCanceled) { return }
return fn(...Array.from(args)) return fn(...Array.from(args))
} }
} }
} }

View File

@@ -1,72 +1,72 @@
import { getSettings } from '../SettingsHandler.ipc' import { getSettings } from '../SettingsHandler.ipc'
type EventCallback = { interface EventCallback {
'waitProgress': (remainingSeconds: number, totalSeconds: number) => void 'waitProgress': (remainingSeconds: number, totalSeconds: number) => void
'complete': () => void 'complete': () => void
} }
type Callbacks = { [E in keyof EventCallback]?: EventCallback[E] } type Callbacks = { [E in keyof EventCallback]?: EventCallback[E] }
class GoogleTimer { class GoogleTimer {
private rateLimitCounter = Infinity private rateLimitCounter = Infinity
private callbacks: Callbacks = {} private callbacks: Callbacks = {}
/** /**
* Initializes the timer to call the callbacks if they are defined. * Initializes the timer to call the callbacks if they are defined.
*/ */
constructor() { constructor() {
setInterval(() => { setInterval(() => {
this.rateLimitCounter++ this.rateLimitCounter++
this.updateCallbacks() this.updateCallbacks()
}, 1000) }, 1000)
} }
/** /**
* Calls `callback` when `event` fires. (no events will be fired after `this.cancelTimer()` is called) * Calls `callback` when `event` fires. (no events will be fired after `this.cancelTimer()` is called)
*/ */
on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) { on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
this.callbacks[event] = callback this.callbacks[event] = callback
this.updateCallbacks() // Fire events immediately after the listeners have been added this.updateCallbacks() // Fire events immediately after the listeners have been added
} }
/** /**
* Check the state of the callbacks and call them if necessary. * Check the state of the callbacks and call them if necessary.
*/ */
private updateCallbacks() { private updateCallbacks() {
if (this.hasTimerEnded() && this.callbacks.complete != undefined) { if (this.hasTimerEnded() && this.callbacks.complete != undefined) {
this.endTimer() this.endTimer()
} else if (this.callbacks.waitProgress != undefined) { } else if (this.callbacks.waitProgress != undefined) {
const delay = getSettings().rateLimitDelay const delay = getSettings().rateLimitDelay
this.callbacks.waitProgress(delay - this.rateLimitCounter, delay) this.callbacks.waitProgress(delay - this.rateLimitCounter, delay)
} }
} }
/** /**
* Prevents the callbacks from activating when the timer ends. * Prevents the callbacks from activating when the timer ends.
*/ */
cancelTimer() { cancelTimer() {
this.callbacks = {} this.callbacks = {}
} }
/** /**
* Checks if enough time has elapsed since the last timer activation. * Checks if enough time has elapsed since the last timer activation.
*/ */
private hasTimerEnded() { private hasTimerEnded() {
return this.rateLimitCounter > getSettings().rateLimitDelay return this.rateLimitCounter > getSettings().rateLimitDelay
} }
/** /**
* Activates the completion callback and resets the timer. * Activates the completion callback and resets the timer.
*/ */
private endTimer() { private endTimer() {
this.rateLimitCounter = 0 this.rateLimitCounter = 0
const completeCallback = this.callbacks.complete const completeCallback = this.callbacks.complete
this.callbacks = {} this.callbacks = {}
completeCallback() completeCallback()
} }
} }
/** /**
* Important: this instance cannot be used by more than one file download at a time. * Important: this instance cannot be used by more than one file download at a time.
*/ */
export const googleTimer = new GoogleTimer() export const googleTimer = new GoogleTimer()

View File

@@ -1,16 +1,16 @@
import { app, BrowserWindow, ipcMain } from 'electron' import { app, BrowserWindow, ipcMain } from 'electron'
import { updateChecker } from './ipc/UpdateHandler.ipc'
import * as windowStateKeeper from 'electron-window-state' import * as windowStateKeeper from 'electron-window-state'
import * as path from 'path' import * as path from 'path'
import * as url from 'url' import * as url from 'url'
require('electron-unhandled')({ showDialog: true })
// IPC Handlers
import { getIPCInvokeHandlers, getIPCEmitHandlers, IPCEmitEvents } from './shared/IPCHandler'
import { getSettingsHandler } from './ipc/SettingsHandler.ipc' import { getSettingsHandler } from './ipc/SettingsHandler.ipc'
import { updateChecker } from './ipc/UpdateHandler.ipc'
// IPC Handlers
import { getIPCEmitHandlers, getIPCInvokeHandlers, IPCEmitEvents } from './shared/IPCHandler'
import { dataPath } from './shared/Paths' import { dataPath } from './shared/Paths'
require('electron-unhandled')({ showDialog: true })
export let mainWindow: BrowserWindow export let mainWindow: BrowserWindow
const args = process.argv.slice(1) const args = process.argv.slice(1)
const isDevBuild = args.some(val => val == '--dev') const isDevBuild = args.some(val => val == '--dev')
@@ -21,13 +21,13 @@ remote.initialize()
restrictToSingleInstance() restrictToSingleInstance()
handleOSXWindowClosed() handleOSXWindowClosed()
app.on('ready', () => { app.on('ready', () => {
// Load settings from file before the window is created // Load settings from file before the window is created
getSettingsHandler.initSettings().then(() => { getSettingsHandler.initSettings().then(() => {
createBridgeWindow() createBridgeWindow()
if (!isDevBuild) { if (!isDevBuild) {
updateChecker.checkForUpdates() updateChecker.checkForUpdates()
} }
}) })
}) })
/** /**
@@ -35,14 +35,14 @@ app.on('ready', () => {
* If this is attempted, restore the open window instead. * If this is attempted, restore the open window instead.
*/ */
function restrictToSingleInstance() { function restrictToSingleInstance() {
const isFirstBridgeInstance = app.requestSingleInstanceLock() const isFirstBridgeInstance = app.requestSingleInstanceLock()
if (!isFirstBridgeInstance) app.quit() if (!isFirstBridgeInstance) app.quit()
app.on('second-instance', () => { app.on('second-instance', () => {
if (mainWindow != undefined) { if (mainWindow != undefined) {
if (mainWindow.isMinimized()) mainWindow.restore() if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.focus() mainWindow.focus()
} }
}) })
} }
/** /**
@@ -50,17 +50,17 @@ function restrictToSingleInstance() {
* minimize when closed and maximize when opened. * minimize when closed and maximize when opened.
*/ */
function handleOSXWindowClosed() { function handleOSXWindowClosed() {
app.on('window-all-closed', () => { app.on('window-all-closed', () => {
if (process.platform != 'darwin') { if (process.platform != 'darwin') {
app.quit() app.quit()
} }
}) })
app.on('activate', () => { app.on('activate', () => {
if (mainWindow == undefined) { if (mainWindow == undefined) {
createBridgeWindow() createBridgeWindow()
} }
}) })
} }
/** /**
@@ -68,81 +68,81 @@ function handleOSXWindowClosed() {
*/ */
function createBridgeWindow() { function createBridgeWindow() {
// Load window size and maximized/restored state from previous session // Load window size and maximized/restored state from previous session
const windowState = windowStateKeeper({ const windowState = windowStateKeeper({
defaultWidth: 1000, defaultWidth: 1000,
defaultHeight: 800, defaultHeight: 800,
path: dataPath path: dataPath,
}) })
// Create the browser window // Create the browser window
mainWindow = createBrowserWindow(windowState) mainWindow = createBrowserWindow(windowState)
// Store window size and maximized/restored state for next session // Store window size and maximized/restored state for next session
windowState.manage(mainWindow) windowState.manage(mainWindow)
// Don't use a system menu // Don't use a system menu
mainWindow.setMenu(null) mainWindow.setMenu(null)
// IPC handlers // IPC handlers
getIPCInvokeHandlers().map(handler => ipcMain.handle(handler.event, (_event, ...args) => handler.handler(args[0]))) getIPCInvokeHandlers().map(handler => ipcMain.handle(handler.event, (_event, ...args) => handler.handler(args[0])))
getIPCEmitHandlers().map(handler => ipcMain.on(handler.event, (_event, ...args) => handler.handler(args[0]))) getIPCEmitHandlers().map(handler => ipcMain.on(handler.event, (_event, ...args) => handler.handler(args[0])))
// Load angular app // Load angular app
mainWindow.loadURL(getLoadUrl()) mainWindow.loadURL(getLoadUrl())
if (isDevBuild) { if (isDevBuild) {
mainWindow.webContents.openDevTools() mainWindow.webContents.openDevTools()
} }
mainWindow.on('closed', () => { mainWindow.on('closed', () => {
mainWindow = null // Dereference mainWindow when the window is closed mainWindow = null // Dereference mainWindow when the window is closed
}) })
// enable the remote webcontents // enable the remote webcontents
remote.enable(mainWindow.webContents) remote.enable(mainWindow.webContents)
} }
/** /**
* Initialize a BrowserWindow object with initial parameters * Initialize a BrowserWindow object with initial parameters
*/ */
function createBrowserWindow(windowState: windowStateKeeper.State) { function createBrowserWindow(windowState: windowStateKeeper.State) {
let options: Electron.BrowserWindowConstructorOptions = { let options: Electron.BrowserWindowConstructorOptions = {
x: windowState.x, x: windowState.x,
y: windowState.y, y: windowState.y,
width: windowState.width, width: windowState.width,
height: windowState.height, height: windowState.height,
frame: false, frame: false,
title: 'Bridge', title: 'Bridge',
webPreferences: { webPreferences: {
nodeIntegration: true, nodeIntegration: true,
allowRunningInsecureContent: (isDevBuild) ? true : false, allowRunningInsecureContent: (isDevBuild) ? true : false,
textAreasAreResizable: false, textAreasAreResizable: false,
contextIsolation: false contextIsolation: false,
}, },
simpleFullscreen: true, simpleFullscreen: true,
fullscreenable: false, fullscreenable: false,
backgroundColor: '#121212' backgroundColor: '#121212',
} }
if (process.platform == 'linux' && !isDevBuild) { if (process.platform == 'linux' && !isDevBuild) {
options = Object.assign(options, { icon: path.join(__dirname, '..', 'assets', 'images', 'system', 'icons', 'png', '48x48.png' ) }) options = Object.assign(options, { icon: path.join(__dirname, '..', 'assets', 'images', 'system', 'icons', 'png', '48x48.png') })
} }
return new BrowserWindow(options) return new BrowserWindow(options)
} }
/** /**
* Load from localhost during development; load from index.html in production * Load from localhost during development; load from index.html in production
*/ */
function getLoadUrl() { function getLoadUrl() {
return url.format({ return url.format({
protocol: isDevBuild ? 'http:' : 'file:', protocol: isDevBuild ? 'http:' : 'file:',
pathname: isDevBuild ? '//localhost:4200/' : path.join(__dirname, '..', 'index.html'), pathname: isDevBuild ? '//localhost:4200/' : path.join(__dirname, '..', 'index.html'),
slashes: true slashes: true,
}) })
} }
export function emitIPCEvent<E extends keyof IPCEmitEvents>(event: E, data: IPCEmitEvents[E]) { export function emitIPCEvent<E extends keyof IPCEmitEvents>(event: E, data: IPCEmitEvents[E]) {
mainWindow.webContents.send(event, data) mainWindow.webContents.send(event, data)
} }

View File

@@ -1,4 +1,5 @@
import { basename, parse } from 'path' import { basename, parse } from 'path'
import { getSettingsHandler } from '../ipc/SettingsHandler.ipc' import { getSettingsHandler } from '../ipc/SettingsHandler.ipc'
import { emitIPCEvent } from '../main' import { emitIPCEvent } from '../main'
import { lower } from './UtilFunctions' import { lower } from './UtilFunctions'
@@ -7,15 +8,15 @@ import { lower } from './UtilFunctions'
* @returns The relative filepath from the library folder to `absoluteFilepath`. * @returns The relative filepath from the library folder to `absoluteFilepath`.
*/ */
export function getRelativeFilepath(absoluteFilepath: string) { export function getRelativeFilepath(absoluteFilepath: string) {
const settings = getSettingsHandler.getSettings() const settings = getSettingsHandler.getSettings()
return basename(settings.libraryPath) + absoluteFilepath.substring(settings.libraryPath.length) return basename(settings.libraryPath) + absoluteFilepath.substring(settings.libraryPath.length)
} }
/** /**
* @returns `true` if `name` has a valid video file extension. * @returns `true` if `name` has a valid video file extension.
*/ */
export function hasVideoExtension(name: string) { export function hasVideoExtension(name: string) {
return (['.mp4', '.avi', '.webm', '.ogv', '.mpeg'].includes(parse(lower(name)).ext)) return (['.mp4', '.avi', '.webm', '.ogv', '.mpeg'].includes(parse(lower(name)).ext))
} }
/** /**
@@ -23,5 +24,5 @@ export function hasVideoExtension(name: string) {
* Note: Error objects can't be serialized by this; use inspect(err) before passing it here. * Note: Error objects can't be serialized by this; use inspect(err) before passing it here.
*/ */
export function devLog(...messages: any[]) { export function devLog(...messages: any[]) {
emitIPCEvent('log', messages) emitIPCEvent('log', messages)
} }

View File

@@ -1,17 +1,18 @@
import { SongSearch, SongResult } from './interfaces/search.interface' import { UpdateInfo } from 'electron-updater'
import { VersionResult, AlbumArtResult } from './interfaces/songDetails.interface'
import { albumArtHandler } from '../ipc/browse/AlbumArtHandler.ipc'
import { batchSongDetailsHandler } from '../ipc/browse/BatchSongDetailsHandler.ipc'
import { searchHandler } from '../ipc/browse/SearchHandler.ipc' import { searchHandler } from '../ipc/browse/SearchHandler.ipc'
import { songDetailsHandler } from '../ipc/browse/SongDetailsHandler.ipc' import { songDetailsHandler } from '../ipc/browse/SongDetailsHandler.ipc'
import { albumArtHandler } from '../ipc/browse/AlbumArtHandler.ipc'
import { Download, DownloadProgress } from './interfaces/download.interface'
import { downloadHandler } from '../ipc/download/DownloadHandler'
import { Settings } from './Settings'
import { batchSongDetailsHandler } from '../ipc/browse/BatchSongDetailsHandler.ipc'
import { getSettingsHandler, setSettingsHandler } from '../ipc/SettingsHandler.ipc'
import { clearCacheHandler } from '../ipc/CacheHandler.ipc' import { clearCacheHandler } from '../ipc/CacheHandler.ipc'
import { updateChecker, UpdateProgress, getCurrentVersionHandler, downloadUpdateHandler, quitAndInstallHandler, getUpdateAvailableHandler } from '../ipc/UpdateHandler.ipc' import { downloadHandler } from '../ipc/download/DownloadHandler'
import { UpdateInfo } from 'electron-updater'
import { openURLHandler } from '../ipc/OpenURLHandler.ipc' import { openURLHandler } from '../ipc/OpenURLHandler.ipc'
import { getSettingsHandler, setSettingsHandler } from '../ipc/SettingsHandler.ipc'
import { downloadUpdateHandler, getCurrentVersionHandler, getUpdateAvailableHandler, quitAndInstallHandler, updateChecker, UpdateProgress } from '../ipc/UpdateHandler.ipc'
import { Download, DownloadProgress } from './interfaces/download.interface'
import { SongResult, SongSearch } from './interfaces/search.interface'
import { AlbumArtResult, VersionResult } from './interfaces/songDetails.interface'
import { Settings } from './Settings'
/** /**
* To add a new IPC listener: * To add a new IPC listener:
@@ -22,101 +23,101 @@ import { openURLHandler } from '../ipc/OpenURLHandler.ipc'
*/ */
export function getIPCInvokeHandlers(): IPCInvokeHandler<keyof IPCInvokeEvents>[] { export function getIPCInvokeHandlers(): IPCInvokeHandler<keyof IPCInvokeEvents>[] {
return [ return [
getSettingsHandler, getSettingsHandler,
clearCacheHandler, clearCacheHandler,
searchHandler, searchHandler,
songDetailsHandler, songDetailsHandler,
batchSongDetailsHandler, batchSongDetailsHandler,
albumArtHandler, albumArtHandler,
getCurrentVersionHandler, getCurrentVersionHandler,
getUpdateAvailableHandler, getUpdateAvailableHandler,
] ]
} }
/** /**
* The list of possible async IPC events that return values, mapped to their input and output types. * The list of possible async IPC events that return values, mapped to their input and output types.
*/ */
export type IPCInvokeEvents = { export interface IPCInvokeEvents {
'get-settings': { 'get-settings': {
input: undefined input: undefined
output: Settings output: Settings
} }
'clear-cache': { 'clear-cache': {
input: undefined input: undefined
output: void output: void
} }
'song-search': { 'song-search': {
input: SongSearch input: SongSearch
output: SongResult[] output: SongResult[]
} }
'album-art': { 'album-art': {
input: SongResult['id'] input: SongResult['id']
output: AlbumArtResult output: AlbumArtResult
} }
'song-details': { 'song-details': {
input: SongResult['id'] input: SongResult['id']
output: VersionResult[] output: VersionResult[]
} }
'batch-song-details': { 'batch-song-details': {
input: number[] input: number[]
output: VersionResult[] output: VersionResult[]
} }
'get-current-version': { 'get-current-version': {
input: undefined input: undefined
output: string output: string
} }
'get-update-available': { 'get-update-available': {
input: undefined input: undefined
output: boolean output: boolean
} }
} }
/** /**
* Describes an object that handles the `E` async IPC event that will return a value. * Describes an object that handles the `E` async IPC event that will return a value.
*/ */
export interface IPCInvokeHandler<E extends keyof IPCInvokeEvents> { export interface IPCInvokeHandler<E extends keyof IPCInvokeEvents> {
event: E event: E
handler(data: IPCInvokeEvents[E]['input']): Promise<IPCInvokeEvents[E]['output']> | IPCInvokeEvents[E]['output'] handler(data: IPCInvokeEvents[E]['input']): Promise<IPCInvokeEvents[E]['output']> | IPCInvokeEvents[E]['output']
} }
export function getIPCEmitHandlers(): IPCEmitHandler<keyof IPCEmitEvents>[] { export function getIPCEmitHandlers(): IPCEmitHandler<keyof IPCEmitEvents>[] {
return [ return [
downloadHandler, downloadHandler,
setSettingsHandler, setSettingsHandler,
downloadUpdateHandler, downloadUpdateHandler,
updateChecker, updateChecker,
quitAndInstallHandler, quitAndInstallHandler,
openURLHandler openURLHandler,
] ]
} }
/** /**
* The list of possible async IPC events that don't return values, mapped to their input types. * The list of possible async IPC events that don't return values, mapped to their input types.
*/ */
export type IPCEmitEvents = { export interface IPCEmitEvents {
'log': any[] 'log': any[]
'download': Download 'download': Download
'download-updated': DownloadProgress 'download-updated': DownloadProgress
'set-settings': Settings 'set-settings': Settings
'queue-updated': number[] 'queue-updated': number[]
'update-error': Error 'update-error': Error
'update-available': UpdateInfo 'update-available': UpdateInfo
'update-progress': UpdateProgress 'update-progress': UpdateProgress
'update-downloaded': undefined 'update-downloaded': undefined
'download-update': undefined 'download-update': undefined
'retry-update': undefined 'retry-update': undefined
'quit-and-install': undefined 'quit-and-install': undefined
'open-url': string 'open-url': string
} }
/** /**
* Describes an object that handles the `E` async IPC event that will not return a value. * Describes an object that handles the `E` async IPC event that will not return a value.
*/ */
export interface IPCEmitHandler<E extends keyof IPCEmitEvents> { export interface IPCEmitHandler<E extends keyof IPCEmitEvents> {
event: E event: E
handler(data: IPCEmitEvents[E]): void handler(data: IPCEmitEvents[E]): void
} }

View File

@@ -1,5 +1,5 @@
import { join } from 'path'
import { app } from 'electron' import { app } from 'electron'
import { join } from 'path'
// Data paths // Data paths
export const dataPath = join(app.getPath('userData'), 'bridge_data') export const dataPath = join(app.getPath('userData'), 'bridge_data')
@@ -15,4 +15,4 @@ export const serverURL = 'bridge-db.net'
export const SERVER_PORT = 42813 export const SERVER_PORT = 42813
export const REDIRECT_BASE = `http://127.0.0.1:${SERVER_PORT}` export const REDIRECT_BASE = `http://127.0.0.1:${SERVER_PORT}`
export const REDIRECT_PATH = `/oauth2callback` export const REDIRECT_PATH = `/oauth2callback`
export const REDIRECT_URI = `${REDIRECT_BASE}${REDIRECT_PATH}` export const REDIRECT_URI = `${REDIRECT_BASE}${REDIRECT_PATH}`

View File

@@ -2,18 +2,18 @@
* Represents Bridge's user settings. * Represents Bridge's user settings.
*/ */
export interface Settings { export interface Settings {
rateLimitDelay: number // Number of seconds to wait between each file download from Google servers rateLimitDelay: number // Number of seconds to wait between each file download from Google servers
downloadVideos: boolean // If background videos should be downloaded downloadVideos: boolean // If background videos should be downloaded
theme: string // The name of the currently enabled UI theme theme: string // The name of the currently enabled UI theme
libraryPath: string // The path to the user's library libraryPath: string // The path to the user's library
} }
/** /**
* Bridge's default user settings. * Bridge's default user settings.
*/ */
export const defaultSettings: Settings = { export const defaultSettings: Settings = {
rateLimitDelay: 31, rateLimitDelay: 31,
downloadVideos: true, downloadVideos: true,
theme: 'Default', theme: 'Default',
libraryPath: undefined libraryPath: undefined,
} }

View File

@@ -1,4 +1,5 @@
import * as randomBytes from 'randombytes' import * as randomBytes from 'randombytes'
const sanitize = require('sanitize-filename') const sanitize = require('sanitize-filename')
// WARNING: do not import anything related to Electron; the code will not compile correctly. // WARNING: do not import anything related to Electron; the code will not compile correctly.
@@ -10,52 +11,52 @@ export type AnyFunction = (...args: any) => any
* @returns `filename` with all invalid filename characters replaced. * @returns `filename` with all invalid filename characters replaced.
*/ */
export function sanitizeFilename(filename: string): string { export function sanitizeFilename(filename: string): string {
const newFilename = sanitize(filename, { const newFilename = sanitize(filename, {
replacement: ((invalidChar: string) => { replacement: ((invalidChar: string) => {
switch (invalidChar) { switch (invalidChar) {
case '<': return '' case '<': return ''
case '>': return '' case '>': return ''
case ':': return '' case ':': return ''
case '"': return "'" case '"': return "'"
case '/': return '' case '/': return ''
case '\\': return '' case '\\': return ''
case '|': return '⏐' case '|': return '⏐'
case '?': return '' case '?': return ''
case '*': return '' case '*': return ''
default: return '_' default: return '_'
} }
}) }),
}) })
return (newFilename == '' ? randomBytes(5).toString('hex') : newFilename) return (newFilename == '' ? randomBytes(5).toString('hex') : newFilename)
} }
/** /**
* @returns `text` converted to lower case. * @returns `text` converted to lower case.
*/ */
export function lower(text: string) { export function lower(text: string) {
return text.toLowerCase() return text.toLowerCase()
} }
/** /**
* Converts `val` from the range (`fromStart`, `fromEnd`) to the range (`toStart`, `toEnd`). * Converts `val` from the range (`fromStart`, `fromEnd`) to the range (`toStart`, `toEnd`).
*/ */
export function interpolate(val: number, fromStart: number, fromEnd: number, toStart: number, toEnd: number) { export function interpolate(val: number, fromStart: number, fromEnd: number, toStart: number, toEnd: number) {
return ((val - fromStart) / (fromEnd - fromStart)) * (toEnd - toStart) + toStart return ((val - fromStart) / (fromEnd - fromStart)) * (toEnd - toStart) + toStart
} }
/** /**
* @returns `objectList` split into multiple groups, where each group contains objects where every one of its values in `keys` matches. * @returns `objectList` split into multiple groups, where each group contains objects where every one of its values in `keys` matches.
*/ */
export function groupBy<T>(objectList: T[], ...keys: (keyof T)[]) { export function groupBy<T>(objectList: T[], ...keys: (keyof T)[]) {
const results: T[][] = [] const results: T[][] = []
for (const object of objectList) { for (const object of objectList) {
const matchingGroup = results.find(result => keys.every(key => result[0][key] == object[key])) const matchingGroup = results.find(result => keys.every(key => result[0][key] == object[key]))
if (matchingGroup != undefined) { if (matchingGroup != undefined) {
matchingGroup.push(object) matchingGroup.push(object)
} else { } else {
results.push([object]) results.push([object])
} }
} }
return results return results
} }

View File

@@ -4,35 +4,35 @@ import { DriveChart } from './songDetails.interface'
* Represents a user's request to interact with the download system. * Represents a user's request to interact with the download system.
*/ */
export interface Download { export interface Download {
action: 'add' | 'retry' | 'cancel' action: 'add' | 'retry' | 'cancel'
versionID: number versionID: number
data?: NewDownload // Should be defined if action == 'add' data?: NewDownload // Should be defined if action == 'add'
} }
/** /**
* Contains the data required to start downloading a single chart. * Contains the data required to start downloading a single chart.
*/ */
export interface NewDownload { export interface NewDownload {
chartName: string chartName: string
artist: string artist: string
charter: string charter: string
driveData: DriveChart & { inChartPack: boolean } driveData: DriveChart & { inChartPack: boolean }
} }
/** /**
* Represents the download progress of a single chart. * Represents the download progress of a single chart.
*/ */
export interface DownloadProgress { export interface DownloadProgress {
versionID: number versionID: number
title: string title: string
header: string header: string
description: string description: string
percent: number percent: number
type: ProgressType type: ProgressType
/** If `description` contains a filepath that can be clicked */ /** If `description` contains a filepath that can be clicked */
isLink: boolean isLink: boolean
/** If the download should not appear in the total download progress */ /** If the download should not appear in the total download progress */
stale?: boolean stale?: boolean
} }
export type ProgressType = 'good' | 'error' | 'cancel' | 'done' export type ProgressType = 'good' | 'error' | 'cancel' | 'done'

View File

@@ -2,86 +2,90 @@
* Represents a user's song search query. * Represents a user's song search query.
*/ */
export interface SongSearch { // TODO: make limit a setting that's not always 51 export interface SongSearch { // TODO: make limit a setting that's not always 51
query: string query: string
quantity: 'all' | 'any' quantity: 'all' | 'any'
similarity: 'similar' | 'exact' similarity: 'similar' | 'exact'
fields: SearchFields fields: SearchFields
tags: SearchTags tags: SearchTags
instruments: SearchInstruments instruments: SearchInstruments
difficulties: SearchDifficulties difficulties: SearchDifficulties
minDiff: number minDiff: number
maxDiff: number maxDiff: number
limit: number limit: number
offset: number offset: number
} }
export function getDefaultSearch(): SongSearch { export function getDefaultSearch(): SongSearch {
return { return {
query: '', query: '',
quantity: 'all', quantity: 'all',
similarity: 'similar', similarity: 'similar',
fields: { name: true, artist: true, album: true, genre: true, year: true, charter: true, tag: true }, fields: { name: true, artist: true, album: true, genre: true, year: true, charter: true, tag: true },
tags: { 'sections': false, 'star power': false, 'forcing': false, 'taps': false, 'lyrics': false, tags: {
'video': false, 'stems': false, 'solo sections': false, 'open notes': false }, 'sections': false, 'star power': false, 'forcing': false, 'taps': false, 'lyrics': false,
instruments: { guitar: false, bass: false, rhythm: false, keys: false, 'video': false, 'stems': false, 'solo sections': false, 'open notes': false
drums: false, guitarghl: false, bassghl: false, vocals: false }, },
difficulties: { expert: false, hard: false, medium: false, easy: false }, instruments: {
minDiff: 0, guitar: false, bass: false, rhythm: false, keys: false,
maxDiff: 6, drums: false, guitarghl: false, bassghl: false, vocals: false
limit: 50 + 1, },
offset: 0 difficulties: { expert: false, hard: false, medium: false, easy: false },
} minDiff: 0,
maxDiff: 6,
limit: 50 + 1,
offset: 0,
}
} }
export interface SearchFields { export interface SearchFields {
name: boolean name: boolean
artist: boolean artist: boolean
album: boolean album: boolean
genre: boolean genre: boolean
year: boolean year: boolean
charter: boolean charter: boolean
tag: boolean tag: boolean
} }
export interface SearchTags { export interface SearchTags {
'sections': boolean // Tag inverted 'sections': boolean // Tag inverted
'star power': boolean // Tag inverted 'star power': boolean // Tag inverted
'forcing': boolean // Tag inverted 'forcing': boolean // Tag inverted
'taps': boolean 'taps': boolean
'lyrics': boolean 'lyrics': boolean
'video': boolean 'video': boolean
'stems': boolean 'stems': boolean
'solo sections': boolean 'solo sections': boolean
'open notes': boolean 'open notes': boolean
} }
export interface SearchInstruments { export interface SearchInstruments {
guitar: boolean guitar: boolean
bass: boolean bass: boolean
rhythm: boolean rhythm: boolean
keys: boolean keys: boolean
drums: boolean drums: boolean
guitarghl: boolean guitarghl: boolean
bassghl: boolean bassghl: boolean
vocals: boolean vocals: boolean
} }
export interface SearchDifficulties { export interface SearchDifficulties {
expert: boolean expert: boolean
hard: boolean hard: boolean
medium: boolean medium: boolean
easy: boolean easy: boolean
} }
/** /**
* Represents a single song search result. * Represents a single song search result.
*/ */
export interface SongResult { export interface SongResult {
id: number id: number
chartCount: number chartCount: number
name: string name: string
artist: string artist: string
album: string album: string
genre: string genre: string
year: string year: string
} }

View File

@@ -2,100 +2,100 @@
* The image data for a song's album art. * The image data for a song's album art.
*/ */
export interface AlbumArtResult { export interface AlbumArtResult {
base64Art: string base64Art: string
} }
/** /**
* Represents a single chart version. * Represents a single chart version.
*/ */
export interface VersionResult { export interface VersionResult {
versionID: number versionID: number
chartID: number chartID: number
songID: number songID: number
latestVersionID: number latestVersionID: number
latestSetlistVersionID: number latestSetlistVersionID: number
name: string name: string
chartName: string chartName: string
artist: string artist: string
album: string album: string
genre: string genre: string
year: string year: string
songDataIncorrect: boolean songDataIncorrect: boolean
driveData: DriveChart & { inChartPack: boolean } driveData: DriveChart & { inChartPack: boolean }
md5: string md5: string
lastModified: string lastModified: string
icon: string icon: string
charters: string charters: string
charterIDs: string charterIDs: string
tags: string | null tags: string | null
songLength: number songLength: number
diff_band: number diff_band: number
diff_guitar: number diff_guitar: number
diff_bass: number diff_bass: number
diff_rhythm: number diff_rhythm: number
diff_drums: number diff_drums: number
diff_keys: number diff_keys: number
diff_guitarghl: number diff_guitarghl: number
diff_bassghl: number diff_bassghl: number
chartData: ChartData chartData: ChartData
} }
export interface DriveChart { export interface DriveChart {
source: DriveSource source: DriveSource
isArchive: boolean isArchive: boolean
downloadPath: string downloadPath: string
filesHash: string filesHash: string
folderName: string folderName: string
folderID: string folderID: string
files: DriveFile[] files: DriveFile[]
} }
export interface DriveSource { export interface DriveSource {
isSetlistSource: boolean isSetlistSource: boolean
isDriveFileSource?: boolean isDriveFileSource?: boolean
setlistIcon?: string setlistIcon?: string
sourceUserIDs: number[] sourceUserIDs: number[]
sourceName: string sourceName: string
sourceDriveID: string sourceDriveID: string
proxyLink?: string proxyLink?: string
} }
export interface DriveFile { export interface DriveFile {
id: string id: string
name: string name: string
mimeType: string mimeType: string
webContentLink: string webContentLink: string
modifiedTime: string modifiedTime: string
md5Checksum: string md5Checksum: string
size: string size: string
} }
export interface ChartData { export interface ChartData {
hasSections: boolean hasSections: boolean
hasStarPower: boolean hasStarPower: boolean
hasForced: boolean hasForced: boolean
hasTap: boolean hasTap: boolean
hasOpen: { hasOpen: {
[instrument: string]: boolean [instrument: string]: boolean
} }
hasSoloSections: boolean hasSoloSections: boolean
hasLyrics: boolean hasLyrics: boolean
is120: boolean is120: boolean
hasBrokenNotes: boolean hasBrokenNotes: boolean
noteCounts: { noteCounts: {
[instrument in Instrument]: { [instrument in Instrument]: {
[difficulty in ChartedDifficulty]: number [difficulty in ChartedDifficulty]: number
} }
} }
/** number of seconds */ /** number of seconds */
length: number length: number
/** number of seconds */ /** number of seconds */
effectiveLength: number effectiveLength: number
} }
export type Instrument = 'guitar' | 'bass' | 'rhythm' | 'keys' | 'drums' | 'guitarghl' | 'bassghl' | 'vocals' | 'undefined' export type Instrument = 'guitar' | 'bass' | 'rhythm' | 'keys' | 'drums' | 'guitarghl' | 'bassghl' | 'vocals' | 'undefined'
export type ChartedDifficulty = 'x' | 'h' | 'm' | 'e' export type ChartedDifficulty = 'x' | 'h' | 'm' | 'e'
export function getInstrumentIcon(instrument: Instrument) { export function getInstrumentIcon(instrument: Instrument) {
return `${instrument}.png` return `${instrument}.png`
} }

View File

@@ -1 +0,0 @@
declare module 'node-7z'

View File

@@ -1,3 +1,3 @@
export const environment = { export const environment = {
production: true production: true,
} }

View File

@@ -3,7 +3,7 @@
// The list of file replacements can be found in `angular.json`. // The list of file replacements can be found in `angular.json`.
export const environment = { export const environment = {
production: false production: false,
} }
/* /*
@@ -13,4 +13,4 @@ export const environment = {
* This import should be commented out in production mode because it will have a negative impact * This import should be commented out in production mode because it will have a negative impact
* on performance if an error is thrown. * on performance if an error is thrown.
*/ */
// import 'zone.js/plugins/zone-error'; // Included with Angular CLI. // import 'zone.js/plugins/zone-error'; // Included with Angular CLI.

View File

@@ -1,12 +1,12 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<title>Bridge</title> <title>Bridge</title>
<base href="./"> <base href="./" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1" />
</head> </head>
<body> <body>
<app-root></app-root> <app-root></app-root>
</body> </body>
</html> </html>

View File

@@ -5,8 +5,8 @@ import { AppModule } from './app/app.module'
import { environment } from './environments/environment' import { environment } from './environments/environment'
if (environment.production) { if (environment.production) {
enableProdMode() enableProdMode()
} }
platformBrowserDynamic().bootstrapModule(AppModule) platformBrowserDynamic().bootstrapModule(AppModule)
.catch(err => console.error(err)) .catch(err => console.error(err))

View File

@@ -45,9 +45,8 @@
/*************************************************************************************************** /***************************************************************************************************
* Zone JS is required by default for Angular itself. * Zone JS is required by default for Angular itself.
*/ */
import 'zone.js' // Included with Angular CLI. import 'zone.js' // Included with Angular CLI.
/*************************************************************************************************** /***************************************************************************************************
* APPLICATION IMPORTS * APPLICATION IMPORTS
*/ */

View File

@@ -46,4 +46,4 @@
.ui.inverted.teal.buttons .button:active { .ui.inverted.teal.buttons .button:active {
background-color: #089C95; background-color: #089C95;
color: white; color: white;
} }

11
src/typings.d.ts vendored
View File

@@ -2,14 +2,13 @@
/* SystemJS module definition */ /* SystemJS module definition */
declare let nodeModule: NodeModule declare let nodeModule: NodeModule
interface NodeModule { interface NodeModule {
id: string id: string
} }
// @ts-ignore
// declare let window: Window // declare let window: Window
declare let $: any declare let $: any
interface Window { interface Window {
process: any process: any
require: any require: any
jQuery: any jQuery: any
} }

View File

@@ -3,7 +3,6 @@
"compilerOptions": { "compilerOptions": {
"baseUrl": "./", "baseUrl": "./",
"noImplicitAny": true, "noImplicitAny": true,
"suppressImplicitAnyIndexErrors": true,
"sourceMap": true, "sourceMap": true,
"declaration": false, "declaration": false,
"downlevelIteration": true, "downlevelIteration": true,
@@ -19,5 +18,8 @@
], ],
"module": "commonjs", "module": "commonjs",
"useDefineForClassFields": false "useDefineForClassFields": false
},
"angularCompilerOptions": {
"strictTemplates": true
} }
} }