mirror of
https://github.com/Myxelium/Bridge-Multi.git
synced 2026-04-11 14:19:38 +00:00
Update formatting
This commit is contained in:
16
package.json
16
package.json
@@ -55,6 +55,10 @@
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^17.0.3",
|
||||
"@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/compiler-cli": "^17.0.4",
|
||||
"@angular/language-service": "^17.0.4",
|
||||
@@ -65,10 +69,22 @@
|
||||
"@types/randombytes": "^2.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.12.0",
|
||||
"@typescript-eslint/parser": "^6.12.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"electron": "^27.1.2",
|
||||
"electron-builder": "^23.6.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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
1576
pnpm-lock.yaml
generated
1576
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,24 @@
|
||||
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 { SettingsComponent } from './components/settings/settings.component'
|
||||
import { TabPersistStrategy } from './core/tab-persist.strategy'
|
||||
|
||||
// TODO: replace these with the correct components
|
||||
const routes: Routes = [
|
||||
{ path: 'browse', component: BrowseComponent, data: { shouldReuse: true } },
|
||||
{ path: 'library', redirectTo: '/browse' },
|
||||
{ path: 'settings', component: SettingsComponent, data: { shouldReuse: true } },
|
||||
{ path: 'about', redirectTo: '/browse' },
|
||||
{ path: '**', redirectTo: '/browse' }
|
||||
{ path: 'browse', component: BrowseComponent, data: { shouldReuse: true } },
|
||||
{ path: 'library', redirectTo: '/browse' },
|
||||
{ path: 'settings', component: SettingsComponent, data: { shouldReuse: true } },
|
||||
{ path: 'about', redirectTo: '/browse' },
|
||||
{ path: '**', redirectTo: '/browse' },
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forRoot(routes)],
|
||||
exports: [RouterModule],
|
||||
providers: [
|
||||
{ provide: RouteReuseStrategy, useClass: TabPersistStrategy },
|
||||
]
|
||||
imports: [RouterModule.forRoot(routes)],
|
||||
exports: [RouterModule],
|
||||
providers: [
|
||||
{ provide: RouteReuseStrategy, useClass: TabPersistStrategy },
|
||||
],
|
||||
})
|
||||
export class AppRoutingModule { }
|
||||
@@ -1,4 +1,4 @@
|
||||
<div *ngIf="settingsLoaded" style="display: flex; flex-direction: column; height: 100%;">
|
||||
<app-toolbar></app-toolbar>
|
||||
<router-outlet></router-outlet>
|
||||
<div *ngIf="settingsLoaded" style="display: flex; flex-direction: column; height: 100%">
|
||||
<app-toolbar></app-toolbar>
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
@@ -1,17 +1,18 @@
|
||||
import { Component } from '@angular/core'
|
||||
|
||||
import { SettingsService } from './core/services/settings.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styles: []
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
styles: [],
|
||||
})
|
||||
export class AppComponent {
|
||||
|
||||
settingsLoaded = false
|
||||
settingsLoaded = false
|
||||
|
||||
constructor(private settingsService: SettingsService) {
|
||||
// Ensure settings are loaded before rendering the application
|
||||
settingsService.loadSettings().then(() => this.settingsLoaded = true)
|
||||
}
|
||||
constructor(private settingsService: SettingsService) {
|
||||
// Ensure settings are loaded before rendering the application
|
||||
settingsService.loadSettings().then(() => this.settingsLoaded = true)
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,41 @@
|
||||
import { BrowserModule } from '@angular/platform-browser'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { BrowserModule } from '@angular/platform-browser'
|
||||
|
||||
import { AppRoutingModule } from './app-routing.module'
|
||||
import { AppComponent } from './app.component'
|
||||
import { ToolbarComponent } from './components/toolbar/toolbar.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 { 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 { ProgressBarDirective } from './core/directives/progress-bar.directive'
|
||||
import { CheckboxDirective } from './core/directives/checkbox.directive'
|
||||
import { StatusBarComponent } from './components/browse/status-bar/status-bar.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({
|
||||
declarations: [
|
||||
AppComponent,
|
||||
ToolbarComponent,
|
||||
BrowseComponent,
|
||||
SearchBarComponent,
|
||||
StatusBarComponent,
|
||||
ResultTableComponent,
|
||||
ChartSidebarComponent,
|
||||
ResultTableRowComponent,
|
||||
DownloadsModalComponent,
|
||||
ProgressBarDirective,
|
||||
CheckboxDirective,
|
||||
SettingsComponent
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
AppRoutingModule,
|
||||
FormsModule
|
||||
],
|
||||
bootstrap: [AppComponent]
|
||||
declarations: [
|
||||
AppComponent,
|
||||
ToolbarComponent,
|
||||
BrowseComponent,
|
||||
SearchBarComponent,
|
||||
StatusBarComponent,
|
||||
ResultTableComponent,
|
||||
ChartSidebarComponent,
|
||||
ResultTableRowComponent,
|
||||
DownloadsModalComponent,
|
||||
ProgressBarDirective,
|
||||
CheckboxDirective,
|
||||
SettingsComponent,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
AppRoutingModule,
|
||||
FormsModule,
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule { }
|
||||
@@ -1,15 +1,12 @@
|
||||
<app-search-bar></app-search-bar>
|
||||
<div class="ui celled two column grid">
|
||||
<div id="table-row" class="row">
|
||||
<div id="table-column" class="column twelve wide">
|
||||
<app-result-table
|
||||
#resultTable
|
||||
(rowClicked)="chartSidebar.onRowClicked($event)"
|
||||
></app-result-table>
|
||||
</div>
|
||||
<div id="sidebar-column" class="column four wide">
|
||||
<app-chart-sidebar #chartSidebar></app-chart-sidebar>
|
||||
</div>
|
||||
</div>
|
||||
<div id="table-row" class="row">
|
||||
<div id="table-column" class="column twelve wide">
|
||||
<app-result-table #resultTable (rowClicked)="chartSidebar.onRowClicked($event)"></app-result-table>
|
||||
</div>
|
||||
<div id="sidebar-column" class="column four wide">
|
||||
<app-chart-sidebar #chartSidebar></app-chart-sidebar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<app-status-bar #statusBar></app-status-bar>
|
||||
@@ -1,34 +1,35 @@
|
||||
import { Component, ViewChild, AfterViewInit } 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 { AfterViewInit, Component, ViewChild } from '@angular/core'
|
||||
|
||||
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({
|
||||
selector: 'app-browse',
|
||||
templateUrl: './browse.component.html',
|
||||
styleUrls: ['./browse.component.scss']
|
||||
selector: 'app-browse',
|
||||
templateUrl: './browse.component.html',
|
||||
styleUrls: ['./browse.component.scss'],
|
||||
})
|
||||
export class BrowseComponent implements AfterViewInit {
|
||||
|
||||
@ViewChild('resultTable', { static: true }) resultTable: ResultTableComponent
|
||||
@ViewChild('chartSidebar', { static: true }) chartSidebar: ChartSidebarComponent
|
||||
@ViewChild('statusBar', { static: true }) statusBar: StatusBarComponent
|
||||
@ViewChild('resultTable', { static: true }) resultTable: ResultTableComponent
|
||||
@ViewChild('chartSidebar', { static: true }) chartSidebar: ChartSidebarComponent
|
||||
@ViewChild('statusBar', { static: true }) statusBar: StatusBarComponent
|
||||
|
||||
constructor(private searchService: SearchService) { }
|
||||
constructor(private searchService: SearchService) { }
|
||||
|
||||
ngAfterViewInit() {
|
||||
const $tableColumn = $('#table-column')
|
||||
$tableColumn.on('scroll', () => {
|
||||
const pos = $tableColumn[0].scrollTop + $tableColumn[0].offsetHeight
|
||||
const max = $tableColumn[0].scrollHeight
|
||||
if (pos >= max - 5) {
|
||||
this.searchService.updateScroll()
|
||||
}
|
||||
})
|
||||
ngAfterViewInit() {
|
||||
const $tableColumn = $('#table-column')
|
||||
$tableColumn.on('scroll', () => {
|
||||
const pos = $tableColumn[0].scrollTop + $tableColumn[0].offsetHeight
|
||||
const max = $tableColumn[0].scrollHeight
|
||||
if (pos >= max - 5) {
|
||||
this.searchService.updateScroll()
|
||||
}
|
||||
})
|
||||
|
||||
this.searchService.onNewSearch(() => {
|
||||
$tableColumn.scrollTop(0)
|
||||
})
|
||||
}
|
||||
this.searchService.onNewSearch(() => {
|
||||
$tableColumn.scrollTop(0)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,46 +1,46 @@
|
||||
<div id="sidebarCard" *ngIf="selectedVersion" class="ui fluid card">
|
||||
<div class="ui placeholder" [ngClass]="{'placeholder': albumArtSrc == '', 'inverted': settingsService.theme == 'Dark'}">
|
||||
<img *ngIf="albumArtSrc != null" class="ui square image" [src]="albumArtSrc">
|
||||
</div>
|
||||
<div *ngIf="charts.length > 1" id="chartDropdown" class="ui fluid right labeled scrolling icon dropdown button">
|
||||
<input type="hidden" name="Chart">
|
||||
<i id="chartDropdownIcon" class="dropdown icon"></i>
|
||||
<div class="default text"></div>
|
||||
<div id="chartDropdownMenu" class="menu">
|
||||
</div>
|
||||
</div>
|
||||
<div id="textPanel" class="content">
|
||||
<span class="header">{{selectedVersion.chartName}}</span>
|
||||
<div class="description">
|
||||
<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.genre == null"><b>Genre:</b> {{selectedVersion.genre}}</div>
|
||||
<div><b>{{charterPlural}}</b> {{selectedVersion.charters}}</div>
|
||||
<div *ngIf="selectedVersion.tags"><b>Tags:</b> {{selectedVersion.tags}}</div>
|
||||
<div><b>Audio Length:</b> {{songLength}}</div>
|
||||
<div class="ui divider"></div>
|
||||
<div class="ui horizontal list">
|
||||
<div *ngFor="let difficulty of difficultiesList" class="item">
|
||||
<img class="ui avatar image" src="assets/images/instruments/{{difficulty.instrument}}">
|
||||
<div class="content">
|
||||
<div class="header">Diff: {{difficulty.diffNumber}}</div>
|
||||
{{difficulty.chartedDifficulties}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sourceLinks">
|
||||
<a id="sourceLink" (click)="onSourceLinkClicked()">{{selectedVersion.driveData.source.sourceName}}</a>
|
||||
<button *ngIf="shownFolderButton()" id="folderButton" class="mini ui icon button" (click)="onFolderButtonClicked()">
|
||||
<i class="folder open outline icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div id="downloadButtons" class="ui positive buttons">
|
||||
<div id="downloadButton" class="ui button" (click)="onDownloadClicked()">{{downloadButtonText}}</div>
|
||||
<div *ngIf="getSelectedChartVersions().length > 1" id="versionDropdown" class="ui floating dropdown icon button">
|
||||
<i class="dropdown icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui placeholder" [ngClass]="{ placeholder: albumArtSrc === '', inverted: settingsService.theme === 'Dark' }">
|
||||
<img *ngIf="albumArtSrc !== null" class="ui square image" [src]="albumArtSrc" />
|
||||
</div>
|
||||
<div *ngIf="charts.length > 1" id="chartDropdown" class="ui fluid right labeled scrolling icon dropdown button">
|
||||
<input type="hidden" name="Chart" />
|
||||
<i id="chartDropdownIcon" class="dropdown icon"></i>
|
||||
<div class="default text"></div>
|
||||
<div id="chartDropdownMenu" class="menu"></div>
|
||||
</div>
|
||||
<div id="textPanel" class="content">
|
||||
<span class="header">{{ selectedVersion.chartName }}</span>
|
||||
<div class="description">
|
||||
<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.genre === null"><b>Genre:</b> {{ selectedVersion.genre }}</div>
|
||||
<div>
|
||||
<b>{{ charterPlural }}</b> {{ selectedVersion.charters }}
|
||||
</div>
|
||||
<div *ngIf="selectedVersion.tags"><b>Tags:</b> {{ selectedVersion.tags }}</div>
|
||||
<div><b>Audio Length:</b> {{ songLength }}</div>
|
||||
<div class="ui divider"></div>
|
||||
<div class="ui horizontal list">
|
||||
<div *ngFor="let difficulty of difficultiesList" class="item">
|
||||
<img class="ui avatar image" src="assets/images/instruments/{{ difficulty.instrument }}" />
|
||||
<div class="content">
|
||||
<div class="header">Diff: {{ difficulty.diffNumber }}</div>
|
||||
{{ difficulty.chartedDifficulties }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="sourceLinks">
|
||||
<a id="sourceLink" (click)="onSourceLinkClicked()">{{ selectedVersion.driveData.source.sourceName }}</a>
|
||||
<button *ngIf="shownFolderButton()" id="folderButton" class="mini ui icon button" (click)="onFolderButtonClicked()">
|
||||
<i class="folder open outline icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="downloadButtons" class="ui positive buttons">
|
||||
<div id="downloadButton" class="ui button" (click)="onDownloadClicked()">{{ downloadButtonText }}</div>
|
||||
<div *ngIf="getSelectedChartVersions().length > 1" id="versionDropdown" class="ui floating dropdown icon button">
|
||||
<i class="dropdown icon"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,286 +1,288 @@
|
||||
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 { ElectronService } from '../../../core/services/electron.service'
|
||||
import { VersionResult, getInstrumentIcon, Instrument, ChartedDifficulty } from '../../../../electron/shared/interfaces/songDetails.interface'
|
||||
import { ChartedDifficulty, getInstrumentIcon, Instrument, VersionResult } from '../../../../electron/shared/interfaces/songDetails.interface'
|
||||
import { AlbumArtService } from '../../../core/services/album-art.service'
|
||||
import { DownloadService } from '../../../core/services/download.service'
|
||||
import { groupBy } from 'src/electron/shared/UtilFunctions'
|
||||
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'
|
||||
import { ElectronService } from '../../../core/services/electron.service'
|
||||
|
||||
interface Difficulty {
|
||||
instrument: string
|
||||
diffNumber: string
|
||||
chartedDifficulties: string
|
||||
instrument: string
|
||||
diffNumber: string
|
||||
chartedDifficulties: string
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-chart-sidebar',
|
||||
templateUrl: './chart-sidebar.component.html',
|
||||
styleUrls: ['./chart-sidebar.component.scss']
|
||||
selector: 'app-chart-sidebar',
|
||||
templateUrl: './chart-sidebar.component.html',
|
||||
styleUrls: ['./chart-sidebar.component.scss'],
|
||||
})
|
||||
export class ChartSidebarComponent implements OnInit {
|
||||
|
||||
songResult: SongResult
|
||||
selectedVersion: VersionResult
|
||||
charts: VersionResult[][]
|
||||
songResult: SongResult
|
||||
selectedVersion: VersionResult
|
||||
charts: VersionResult[][]
|
||||
|
||||
constructor(
|
||||
private electronService: ElectronService,
|
||||
private albumArtService: AlbumArtService,
|
||||
private downloadService: DownloadService,
|
||||
private searchService: SearchService,
|
||||
private sanitizer: DomSanitizer,
|
||||
public settingsService: SettingsService
|
||||
) { }
|
||||
albumArtSrc: SafeUrl = ''
|
||||
charterPlural: string
|
||||
songLength: string
|
||||
difficultiesList: Difficulty[]
|
||||
downloadButtonText: string
|
||||
|
||||
ngOnInit() {
|
||||
this.searchService.onNewSearch(() => {
|
||||
this.selectVersion(undefined)
|
||||
this.songResult = undefined
|
||||
})
|
||||
}
|
||||
constructor(
|
||||
private electronService: ElectronService,
|
||||
private albumArtService: AlbumArtService,
|
||||
private downloadService: DownloadService,
|
||||
private searchService: SearchService,
|
||||
private sanitizer: DomSanitizer,
|
||||
public settingsService: SettingsService
|
||||
) { }
|
||||
|
||||
/**
|
||||
* 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()
|
||||
ngOnInit() {
|
||||
this.searchService.onNewSearch(() => {
|
||||
this.selectVersion(undefined)
|
||||
this.songResult = undefined
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
this.updateAlbumArtSrc(await albumArt)
|
||||
}
|
||||
}
|
||||
|
||||
albumArtSrc: SafeUrl = ''
|
||||
/**
|
||||
* Updates the sidebar to display the album art.
|
||||
*/
|
||||
updateAlbumArtSrc(albumArtBase64String?: string) {
|
||||
if (albumArtBase64String) {
|
||||
this.albumArtSrc = this.sanitizer.bypassSecurityTrustUrl('data:image/jpg;base64,' + albumArtBase64String)
|
||||
} else {
|
||||
this.albumArtSrc = null
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the chart dropdown from `this.charts` (or removes it if there's only one chart).
|
||||
*/
|
||||
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)
|
||||
}
|
||||
/**
|
||||
* Updates the sidebar to display the album art.
|
||||
*/
|
||||
updateAlbumArtSrc(albumArtBase64String?: string) {
|
||||
if (albumArtBase64String) {
|
||||
this.albumArtSrc = this.sanitizer.bypassSecurityTrustUrl('data:image/jpg;base64,' + albumArtBase64String)
|
||||
} else {
|
||||
this.albumArtSrc = null
|
||||
}
|
||||
}
|
||||
|
||||
private async selectChart(chartID: number) {
|
||||
const chart = this.charts.find(chart => chart[0].chartID == chartID)
|
||||
await this.selectVersion(chart[0])
|
||||
this.initVersionDropdown()
|
||||
}
|
||||
/**
|
||||
* Initializes the chart dropdown from `this.charts` (or removes it if there's only one chart).
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the sidebar to display the metadata for `selectedVersion`.
|
||||
*/
|
||||
async selectVersion(selectedVersion: VersionResult) {
|
||||
this.selectedVersion = selectedVersion
|
||||
await new Promise<void>(resolve => setTimeout(() => resolve(), 0)) // Wait for *ngIf to update DOM
|
||||
private async selectChart(chartID: number) {
|
||||
const chart = this.charts.find(chart => chart[0].chartID == chartID)
|
||||
await this.selectVersion(chart[0])
|
||||
this.initVersionDropdown()
|
||||
}
|
||||
|
||||
if (this.selectedVersion != undefined) {
|
||||
this.updateCharterPlural()
|
||||
this.updateSongLength()
|
||||
this.updateDifficultiesList()
|
||||
this.updateDownloadButtonText()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Updates the sidebar to display the metadata for `selectedVersion`.
|
||||
*/
|
||||
async selectVersion(selectedVersion: VersionResult) {
|
||||
this.selectedVersion = selectedVersion
|
||||
await new Promise<void>(resolve => setTimeout(() => resolve(), 0)) // Wait for *ngIf to update DOM
|
||||
|
||||
charterPlural: string
|
||||
/**
|
||||
* Chooses to display 'Charter:' or 'Charters:'.
|
||||
*/
|
||||
private updateCharterPlural() {
|
||||
this.charterPlural = this.selectedVersion.charterIDs.split('&').length == 1 ? 'Charter:' : 'Charters:'
|
||||
}
|
||||
if (this.selectedVersion != undefined) {
|
||||
this.updateCharterPlural()
|
||||
this.updateSongLength()
|
||||
this.updateDifficultiesList()
|
||||
this.updateDownloadButtonText()
|
||||
}
|
||||
}
|
||||
|
||||
songLength: string
|
||||
/**
|
||||
* Converts `this.selectedVersion.chartMetadata.length` into a readable duration.
|
||||
*/
|
||||
private updateSongLength() {
|
||||
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}`
|
||||
}
|
||||
/**
|
||||
* Chooses to display 'Charter:' or 'Charters:'.
|
||||
*/
|
||||
private updateCharterPlural() {
|
||||
this.charterPlural = this.selectedVersion.charterIDs.split('&').length == 1 ? 'Charter:' : 'Charters:'
|
||||
}
|
||||
|
||||
difficultiesList: Difficulty[]
|
||||
/**
|
||||
* Updates `dfficultiesList` with the difficulty information for the selected version.
|
||||
*/
|
||||
private updateDifficultiesList() {
|
||||
const instruments = Object.keys(this.selectedVersion.chartData.noteCounts) as Instrument[]
|
||||
this.difficultiesList = []
|
||||
for (const instrument of instruments) {
|
||||
if (instrument != 'undefined') {
|
||||
this.difficultiesList.push({
|
||||
instrument: getInstrumentIcon(instrument),
|
||||
diffNumber: this.getDiffNumber(instrument),
|
||||
chartedDifficulties: this.getChartedDifficultiesText(instrument)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Converts `this.selectedVersion.chartMetadata.length` into a readable duration.
|
||||
*/
|
||||
private updateSongLength() {
|
||||
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}`
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns a string describing the difficulty number in the selected version.
|
||||
*/
|
||||
private getDiffNumber(instrument: Instrument) {
|
||||
const diffNumber: number = this.selectedVersion[`diff_${instrument}`]
|
||||
return diffNumber == -1 || diffNumber == undefined ? 'Unknown' : String(diffNumber)
|
||||
}
|
||||
/**
|
||||
* Updates `dfficultiesList` with the difficulty information for the selected version.
|
||||
*/
|
||||
private updateDifficultiesList() {
|
||||
const instruments = Object.keys(this.selectedVersion.chartData.noteCounts) as Instrument[]
|
||||
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.
|
||||
*/
|
||||
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') }
|
||||
/**
|
||||
* @returns a string describing the difficulty number in the selected version.
|
||||
*/
|
||||
private getDiffNumber(instrument: Instrument) {
|
||||
const diffNumber: number = this.selectedVersion[`diff_${instrument}`]
|
||||
return diffNumber == -1 || diffNumber == undefined ? 'Unknown' : String(diffNumber)
|
||||
}
|
||||
|
||||
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
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
return difficultyNames.join(', ')
|
||||
}
|
||||
|
||||
if (this.getSelectedChartVersions().length > 1) {
|
||||
if (this.selectedVersion.versionID == this.selectedVersion.latestVersionID) {
|
||||
this.downloadButtonText += ' (Latest)'
|
||||
} else {
|
||||
this.downloadButtonText += ` (${this.getLastModifiedText(this.selectedVersion.lastModified)})`
|
||||
}
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the version dropdown from `this.selectedVersion` (or removes it if there's only one version).
|
||||
*/
|
||||
private initVersionDropdown() {
|
||||
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)
|
||||
}))
|
||||
if (this.getSelectedChartVersions().length > 1) {
|
||||
if (this.selectedVersion.versionID == this.selectedVersion.latestVersionID) {
|
||||
this.downloadButtonText += ' (Latest)'
|
||||
} else {
|
||||
this.downloadButtonText += ` (${this.getLastModifiedText(this.selectedVersion.lastModified)})`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$versionDropdown.dropdown('setup menu', { values })
|
||||
$versionDropdown.dropdown('setting', 'onChange', (versionID: number) => {
|
||||
this.selectVersion(versions.find(version => version.versionID == versionID))
|
||||
})
|
||||
$versionDropdown.dropdown('set selected', values[0].value)
|
||||
}
|
||||
/**
|
||||
* Initializes the version dropdown from `this.selectedVersion` (or removes it if there's only one version).
|
||||
*/
|
||||
private initVersionDropdown() {
|
||||
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),
|
||||
}))
|
||||
|
||||
/**
|
||||
* Returns the list of versions for the selected chart, sorted by `lastModified`.
|
||||
*/
|
||||
getSelectedChartVersions() {
|
||||
return this.charts.find(chart => chart[0].chartID == this.selectedVersion.chartID)
|
||||
}
|
||||
$versionDropdown.dropdown('setup menu', { values })
|
||||
$versionDropdown.dropdown('setting', 'onChange', (versionID: number) => {
|
||||
this.selectVersion(versions.find(version => version.versionID == versionID))
|
||||
})
|
||||
$versionDropdown.dropdown('set selected', values[0].value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the <lastModified> value to a user-readable format.
|
||||
* @param lastModified The UNIX timestamp for the lastModified date.
|
||||
*/
|
||||
private getLastModifiedText(lastModified: string) {
|
||||
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 the list of versions for the selected chart, sorted by `lastModified`.
|
||||
*/
|
||||
getSelectedChartVersions() {
|
||||
return this.charts.find(chart => chart[0].chartID == this.selectedVersion.chartID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the proxy link or source folder in the default browser.
|
||||
*/
|
||||
onSourceLinkClicked() {
|
||||
const source = this.selectedVersion.driveData.source
|
||||
this.electronService.sendIPC('open-url', source.proxyLink ?? `https://drive.google.com/drive/folders/${source.sourceDriveID}`)
|
||||
}
|
||||
/**
|
||||
* Converts the <lastModified> value to a user-readable format.
|
||||
* @param lastModified The UNIX timestamp for the lastModified date.
|
||||
*/
|
||||
private getLastModifiedText(lastModified: string) {
|
||||
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.
|
||||
*/
|
||||
shownFolderButton() {
|
||||
const driveData = this.selectedVersion.driveData
|
||||
return driveData.source.proxyLink || driveData.source.sourceDriveID != driveData.folderID
|
||||
}
|
||||
/**
|
||||
* Opens the proxy link or source folder in the default browser.
|
||||
*/
|
||||
onSourceLinkClicked() {
|
||||
const source = this.selectedVersion.driveData.source
|
||||
this.electronService.sendIPC('open-url', source.proxyLink ?? `https://drive.google.com/drive/folders/${source.sourceDriveID}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the chart folder in the default browser.
|
||||
*/
|
||||
onFolderButtonClicked() {
|
||||
this.electronService.sendIPC('open-url', `https://drive.google.com/drive/folders/${this.selectedVersion.driveData.folderID}`)
|
||||
}
|
||||
/**
|
||||
* @returns `true` if the source folder button should be shown.
|
||||
*/
|
||||
shownFolderButton() {
|
||||
const driveData = this.selectedVersion.driveData
|
||||
return driveData.source.proxyLink || driveData.source.sourceDriveID != driveData.folderID
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the selected version to the download queue.
|
||||
*/
|
||||
onDownloadClicked() {
|
||||
this.downloadService.addDownload(
|
||||
this.selectedVersion.versionID, {
|
||||
chartName: this.selectedVersion.chartName,
|
||||
artist: this.songResult.artist,
|
||||
charter: this.selectedVersion.charters,
|
||||
driveData: this.selectedVersion.driveData
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Opens the chart folder in the default browser.
|
||||
*/
|
||||
onFolderButtonClicked() {
|
||||
this.electronService.sendIPC('open-url', `https://drive.google.com/drive/folders/${this.selectedVersion.driveData.folderID}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the selected version to the download queue.
|
||||
*/
|
||||
onDownloadClicked() {
|
||||
this.downloadService.addDownload(
|
||||
this.selectedVersion.versionID, {
|
||||
chartName: this.selectedVersion.chartName,
|
||||
artist: this.songResult.artist,
|
||||
charter: this.selectedVersion.charters,
|
||||
driveData: this.selectedVersion.driveData,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
<td>
|
||||
<div #checkbox class="ui checkbox" (click)="$event.stopPropagation()">
|
||||
<input type="checkbox">
|
||||
</div>
|
||||
<div #checkbox class="ui checkbox" (click)="$event.stopPropagation()">
|
||||
<input type="checkbox" />
|
||||
</div>
|
||||
</td>
|
||||
<td><span id="chartCount" *ngIf="result.chartCount > 1">{{result.chartCount}}</span>{{result.name}}</td>
|
||||
<td>{{result.artist}}</td>
|
||||
<td>{{result.album || 'Various'}}</td>
|
||||
<td>{{result.genre || 'Various'}}</td>
|
||||
<td>
|
||||
<span id="chartCount" *ngIf="result.chartCount > 1">{{ result.chartCount }}</span
|
||||
>{{ result.name }}
|
||||
</td>
|
||||
<td>{{ result.artist }}</td>
|
||||
<td>{{ result.album || 'Various' }}</td>
|
||||
<td>{{ result.genre || 'Various' }}</td>
|
||||
|
||||
@@ -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 { SelectionService } from '../../../../core/services/selection.service'
|
||||
|
||||
@Component({
|
||||
selector: 'tr[app-result-table-row]',
|
||||
templateUrl: './result-table-row.component.html',
|
||||
styleUrls: ['./result-table-row.component.scss']
|
||||
selector: 'tr[app-result-table-row]',
|
||||
templateUrl: './result-table-row.component.html',
|
||||
styleUrls: ['./result-table-row.component.scss'],
|
||||
})
|
||||
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() {
|
||||
return this.result.id
|
||||
}
|
||||
get songID() {
|
||||
return this.result.id
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.selectionService.onSelectionChanged(this.songID, (isChecked) => {
|
||||
if (isChecked) {
|
||||
$(this.checkbox.nativeElement).checkbox('check')
|
||||
} else {
|
||||
$(this.checkbox.nativeElement).checkbox('uncheck')
|
||||
}
|
||||
})
|
||||
ngAfterViewInit() {
|
||||
this.selectionService.onSelectionChanged(this.songID, isChecked => {
|
||||
if (isChecked) {
|
||||
$(this.checkbox.nativeElement).checkbox('check')
|
||||
} else {
|
||||
$(this.checkbox.nativeElement).checkbox('uncheck')
|
||||
}
|
||||
})
|
||||
|
||||
$(this.checkbox.nativeElement).checkbox({
|
||||
onChecked: () => {
|
||||
this.selectionService.selectSong(this.songID)
|
||||
},
|
||||
onUnchecked: () => {
|
||||
this.selectionService.deselectSong(this.songID)
|
||||
}
|
||||
})
|
||||
}
|
||||
$(this.checkbox.nativeElement).checkbox({
|
||||
onChecked: () => {
|
||||
this.selectionService.selectSong(this.songID)
|
||||
},
|
||||
onUnchecked: () => {
|
||||
this.selectionService.deselectSong(this.songID)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,30 @@
|
||||
<table id="resultTable" class="ui stackable selectable single sortable fixed line striped compact small table" [class.inverted]="settingsService.theme == 'Dark'">
|
||||
<!-- TODO: maybe have some of these tags customizable? E.g. small/large/compact/padded -->
|
||||
<!-- TODO: learn semantic themes in order to change the $mobileBreakpoint global variable (better search table adjustment) -->
|
||||
<thead>
|
||||
<!-- NOTE: it would be nice to make this header sticky, but Fomantic-UI doesn't currently support that -->
|
||||
<tr>
|
||||
<th class="collapsing" id="checkboxColumn">
|
||||
<div class="ui checkbox" id="checkbox" #checkboxColumn appCheckbox (checked)="checkAll($event)">
|
||||
<input type="checkbox">
|
||||
</div>
|
||||
</th>
|
||||
<th class="four wide" [class.sorted]="sortColumn == 'name'" [ngClass]="sortDirection" (click)="onColClicked('name')">Name</th>
|
||||
<th class="four wide" [class.sorted]="sortColumn == 'artist'" [ngClass]="sortDirection" (click)="onColClicked('artist')">Artist</th>
|
||||
<th class="four wide" [class.sorted]="sortColumn == 'album'" [ngClass]="sortDirection" (click)="onColClicked('album')">Album</th>
|
||||
<th class="four wide" [class.sorted]="sortColumn == 'genre'" [ngClass]="sortDirection" (click)="onColClicked('genre')">Genre</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
app-result-table-row
|
||||
#tableRow
|
||||
*ngFor="let result of results"
|
||||
(click)="onRowClicked(result)"
|
||||
[class.active]="activeRowID == result.id"
|
||||
[result]="result"></tr>
|
||||
</tbody>
|
||||
<table
|
||||
id="resultTable"
|
||||
class="ui stackable selectable single sortable fixed line striped compact small table"
|
||||
[class.inverted]="settingsService.theme === 'Dark'">
|
||||
<!-- TODO: maybe have some of these tags customizable? E.g. small/large/compact/padded -->
|
||||
<!-- TODO: learn semantic themes in order to change the $mobileBreakpoint global variable (better search table adjustment) -->
|
||||
<thead>
|
||||
<!-- NOTE: it would be nice to make this header sticky, but Fomantic-UI doesn't currently support that -->
|
||||
<tr>
|
||||
<th class="collapsing" id="checkboxColumn">
|
||||
<div class="ui checkbox" id="checkbox" #checkboxColumn appCheckbox (checked)="checkAll($event)">
|
||||
<input type="checkbox" />
|
||||
</div>
|
||||
</th>
|
||||
<th class="four wide" [class.sorted]="sortColumn === 'name'" [ngClass]="sortDirection" (click)="onColClicked('name')">Name</th>
|
||||
<th class="four wide" [class.sorted]="sortColumn === 'artist'" [ngClass]="sortDirection" (click)="onColClicked('artist')">Artist</th>
|
||||
<th class="four wide" [class.sorted]="sortColumn === 'album'" [ngClass]="sortDirection" (click)="onColClicked('album')">Album</th>
|
||||
<th class="four wide" [class.sorted]="sortColumn === 'genre'" [ngClass]="sortDirection" (click)="onColClicked('genre')">Genre</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
app-result-table-row
|
||||
#tableRow
|
||||
*ngFor="let result of results"
|
||||
(click)="onRowClicked(result)"
|
||||
[class.active]="activeRowID === result.id"
|
||||
[result]="result"></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -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 { ResultTableRowComponent } from './result-table-row/result-table-row.component'
|
||||
import { CheckboxDirective } from '../../../core/directives/checkbox.directive'
|
||||
import { SearchService } from '../../../core/services/search.service'
|
||||
import { SelectionService } from '../../../core/services/selection.service'
|
||||
import { SettingsService } from 'src/app/core/services/settings.service'
|
||||
import Comparators from 'comparators'
|
||||
import { ResultTableRowComponent } from './result-table-row/result-table-row.component'
|
||||
|
||||
@Component({
|
||||
selector: 'app-result-table',
|
||||
templateUrl: './result-table.component.html',
|
||||
styleUrls: ['./result-table.component.scss']
|
||||
selector: 'app-result-table',
|
||||
templateUrl: './result-table.component.html',
|
||||
styleUrls: ['./result-table.component.scss'],
|
||||
})
|
||||
export class ResultTableComponent implements OnInit {
|
||||
|
||||
@Output() rowClicked = new EventEmitter<SongResult>()
|
||||
@Output() rowClicked = new EventEmitter<SongResult>()
|
||||
|
||||
@ViewChild(CheckboxDirective, { static: true }) checkboxColumn: CheckboxDirective
|
||||
@ViewChildren('tableRow') tableRows: QueryList<ResultTableRowComponent>
|
||||
@ViewChild(CheckboxDirective, { static: true }) checkboxColumn: CheckboxDirective
|
||||
@ViewChildren('tableRow') tableRows: QueryList<ResultTableRowComponent>
|
||||
|
||||
results: SongResult[] = []
|
||||
activeRowID: number = null
|
||||
sortDirection: 'ascending' | 'descending' = 'descending'
|
||||
sortColumn: 'name' | 'artist' | 'album' | 'genre' | null = null
|
||||
results: SongResult[] = []
|
||||
activeRowID: number = null
|
||||
sortDirection: 'ascending' | 'descending' = 'descending'
|
||||
sortColumn: 'name' | 'artist' | 'album' | 'genre' | null = null
|
||||
|
||||
constructor(
|
||||
private searchService: SearchService,
|
||||
private selectionService: SelectionService,
|
||||
public settingsService: SettingsService
|
||||
) { }
|
||||
constructor(
|
||||
private searchService: SearchService,
|
||||
private selectionService: SelectionService,
|
||||
public settingsService: SettingsService
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.selectionService.onSelectAllChanged((selected) => {
|
||||
this.checkboxColumn.check(selected)
|
||||
})
|
||||
ngOnInit() {
|
||||
this.selectionService.onSelectAllChanged(selected => {
|
||||
this.checkboxColumn.check(selected)
|
||||
})
|
||||
|
||||
this.searchService.onSearchChanged(results => {
|
||||
this.activeRowID = null
|
||||
this.results = results
|
||||
this.updateSort()
|
||||
})
|
||||
this.searchService.onSearchChanged(results => {
|
||||
this.activeRowID = null
|
||||
this.results = results
|
||||
this.updateSort()
|
||||
})
|
||||
|
||||
this.searchService.onNewSearch(() => {
|
||||
this.sortColumn = null
|
||||
})
|
||||
}
|
||||
this.searchService.onNewSearch(() => {
|
||||
this.sortColumn = null
|
||||
})
|
||||
}
|
||||
|
||||
onRowClicked(result: SongResult) {
|
||||
this.activeRowID = result.id
|
||||
this.rowClicked.emit(result)
|
||||
}
|
||||
onRowClicked(result: SongResult) {
|
||||
this.activeRowID = result.id
|
||||
this.rowClicked.emit(result)
|
||||
}
|
||||
|
||||
onColClicked(column: 'name' | 'artist' | 'album' | 'genre') {
|
||||
if (this.results.length == 0) { return }
|
||||
if (this.sortColumn != column) {
|
||||
this.sortColumn = column
|
||||
this.sortDirection = 'descending'
|
||||
} else if (this.sortDirection == 'descending') {
|
||||
this.sortDirection = 'ascending'
|
||||
} else {
|
||||
this.sortDirection = 'descending'
|
||||
}
|
||||
this.updateSort()
|
||||
}
|
||||
onColClicked(column: 'name' | 'artist' | 'album' | 'genre') {
|
||||
if (this.results.length == 0) { return }
|
||||
if (this.sortColumn != column) {
|
||||
this.sortColumn = column
|
||||
this.sortDirection = 'descending'
|
||||
} else if (this.sortDirection == 'descending') {
|
||||
this.sortDirection = 'ascending'
|
||||
} else {
|
||||
this.sortDirection = 'descending'
|
||||
}
|
||||
this.updateSort()
|
||||
}
|
||||
|
||||
private updateSort() {
|
||||
if (this.sortColumn != null) {
|
||||
this.results.sort(Comparators.comparing(this.sortColumn, { reversed: this.sortDirection == 'ascending' }))
|
||||
}
|
||||
}
|
||||
private updateSort() {
|
||||
if (this.sortColumn != null) {
|
||||
this.results.sort(Comparators.comparing(this.sortColumn, { reversed: this.sortDirection == 'ascending' }))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the user checks the `checkboxColumn`.
|
||||
*/
|
||||
checkAll(isChecked: boolean) {
|
||||
if (isChecked) {
|
||||
this.selectionService.selectAll()
|
||||
} else {
|
||||
this.selectionService.deselectAll()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Called when the user checks the `checkboxColumn`.
|
||||
*/
|
||||
checkAll(isChecked: boolean) {
|
||||
if (isChecked) {
|
||||
this.selectionService.selectAll()
|
||||
} else {
|
||||
this.selectionService.deselectAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,235 +1,240 @@
|
||||
<div id="searchMenu" class="ui bottom attached borderless menu">
|
||||
<div class="item">
|
||||
<div class="ui icon input" [class.loading]="isLoading()">
|
||||
<input #searchBox type="text" placeholder=" Search..." (keyup.enter)="onSearch(searchBox.value)">
|
||||
<i #searchIcon id="searchIcon" 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 class="item">
|
||||
<div class="ui icon input" [class.loading]="isLoading()">
|
||||
<input #searchBox type="text" placeholder=" Search..." (keyup.enter)="onSearch(searchBox.value)" />
|
||||
<i
|
||||
#searchIcon
|
||||
id="searchIcon"
|
||||
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 #quantityDropdown class="ui compact selection dropdown">
|
||||
<input name="quantityMatch" type="hidden">
|
||||
<i class="dropdown icon"></i>
|
||||
<div class="text"><b>all</b> words match</div>
|
||||
<div class="menu">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div id="quantityDropdownItem" [class.hidden]="!showAdvanced" class="item">
|
||||
<div #quantityDropdown class="ui compact selection dropdown">
|
||||
<input name="quantityMatch" type="hidden" />
|
||||
<i class="dropdown icon"></i>
|
||||
<div class="text"><b>all</b> words match</div>
|
||||
<div class="menu">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="similarityDropdownItem" [class.hidden]="!showAdvanced" class="item">
|
||||
<div #similarityDropdown class="ui compact selection dropdown">
|
||||
<input name="similarityMatch" type="hidden" value="similar">
|
||||
<i class="dropdown icon"></i>
|
||||
<div class="text"><b>similar</b> words match</div>
|
||||
<div class="menu">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div id="similarityDropdownItem" [class.hidden]="!showAdvanced" class="item">
|
||||
<div #similarityDropdown class="ui compact selection dropdown">
|
||||
<input name="similarityMatch" type="hidden" value="similar" />
|
||||
<i class="dropdown icon"></i>
|
||||
<div class="text"><b>similar</b> words match</div>
|
||||
<div class="menu">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item right">
|
||||
<button class="mini ui right labeled icon button" (click)="onAdvancedSearchClick()">
|
||||
Advanced Search
|
||||
<i class="angle double icon" [ngClass]="showAdvanced ? 'up' : 'down'"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="item right">
|
||||
<button class="mini ui right labeled icon button" (click)="onAdvancedSearchClick()">
|
||||
Advanced Search
|
||||
<i class="angle double icon" [ngClass]="showAdvanced ? 'up' : 'down'"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="advancedSearchForm" [class.collapsed]="!showAdvanced" class="ui horizontal segments">
|
||||
<div class="ui secondary segment">
|
||||
<div class="grouped fields">
|
||||
<h5 class="ui dividing header">Search for</h5>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="name" [(ngModel)]="searchSettings.fields.name">
|
||||
<label>Name</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="artist" [(ngModel)]="searchSettings.fields.artist">
|
||||
<label>Artist</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="album" [(ngModel)]="searchSettings.fields.album">
|
||||
<label>Album</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="genre" [(ngModel)]="searchSettings.fields.genre">
|
||||
<label>Genre</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="year" [(ngModel)]="searchSettings.fields.year">
|
||||
<label>Year</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="charter" [(ngModel)]="searchSettings.fields.charter">
|
||||
<label>Charter</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="tag" [(ngModel)]="searchSettings.fields.tag">
|
||||
<label>Tag</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui secondary segment">
|
||||
<div class="grouped fields">
|
||||
<h5 class="ui dividing header">Must include</h5>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="sections" [(ngModel)]="searchSettings.tags.sections">
|
||||
<label>Sections</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="starpower" [(ngModel)]="searchSettings.tags['star power']">
|
||||
<label>Star Power</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="forcing" [(ngModel)]="searchSettings.tags.forcing">
|
||||
<label>Forcing</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="taps" [(ngModel)]="searchSettings.tags.taps">
|
||||
<label>Taps</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="lyrics" [(ngModel)]="searchSettings.tags.lyrics">
|
||||
<label>Lyrics</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="videobackground" [(ngModel)]="searchSettings.tags.video">
|
||||
<label>Video Background</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="stems" [(ngModel)]="searchSettings.tags.stems">
|
||||
<label>Audio Stems</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="solosections" [(ngModel)]="searchSettings.tags['solo sections']">
|
||||
<label>Solo Sections</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="opennotes" [(ngModel)]="searchSettings.tags['open notes']">
|
||||
<label>Open Notes</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui secondary segment">
|
||||
<div class="grouped fields">
|
||||
<h5 class="ui dividing header">Instruments</h5>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="guitar" [(ngModel)]="searchSettings.instruments.guitar">
|
||||
<label>Guitar</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="bass" [(ngModel)]="searchSettings.instruments.bass">
|
||||
<label>Bass</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="rhythm" [(ngModel)]="searchSettings.instruments.rhythm">
|
||||
<label>Rhythm</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="keys" [(ngModel)]="searchSettings.instruments.keys">
|
||||
<label>Keys</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="drums" [(ngModel)]="searchSettings.instruments.drums">
|
||||
<label>Drums</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="guitarghl" [(ngModel)]="searchSettings.instruments.guitarghl">
|
||||
<label>GHL Guitar</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="bassghl" [(ngModel)]="searchSettings.instruments.bassghl">
|
||||
<label>GHL Bass</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="vocals" [(ngModel)]="searchSettings.instruments.vocals">
|
||||
<label>Vocals</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui secondary segment">
|
||||
<h5 class="ui dividing header">Charted difficulties</h5>
|
||||
<div class="grouped fields">
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="expert" [(ngModel)]="searchSettings.difficulties.expert">
|
||||
<label>Expert</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="hard" [(ngModel)]="searchSettings.difficulties.hard">
|
||||
<label>Hard</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="medium" [(ngModel)]="searchSettings.difficulties.medium">
|
||||
<label>Medium</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="easy" [(ngModel)]="searchSettings.difficulties.easy">
|
||||
<label>Easy</label>
|
||||
</div>
|
||||
</div>
|
||||
<h5 class="ui dividing header">Difficulty range</h5>
|
||||
<div class="field">
|
||||
<div #diffSlider class="ui labeled ticked inverted range slider" id="diffSlider"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui secondary segment">
|
||||
<div class="grouped fields">
|
||||
<h5 class="ui dividing header">Search for</h5>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="name" [(ngModel)]="searchSettings.fields.name" />
|
||||
<label>Name</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="artist" [(ngModel)]="searchSettings.fields.artist" />
|
||||
<label>Artist</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="album" [(ngModel)]="searchSettings.fields.album" />
|
||||
<label>Album</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="genre" [(ngModel)]="searchSettings.fields.genre" />
|
||||
<label>Genre</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="year" [(ngModel)]="searchSettings.fields.year" />
|
||||
<label>Year</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="charter" [(ngModel)]="searchSettings.fields.charter" />
|
||||
<label>Charter</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="tag" [(ngModel)]="searchSettings.fields.tag" />
|
||||
<label>Tag</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui secondary segment">
|
||||
<div class="grouped fields">
|
||||
<h5 class="ui dividing header">Must include</h5>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="sections" [(ngModel)]="searchSettings.tags.sections" />
|
||||
<label>Sections</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="starpower" [(ngModel)]="searchSettings.tags['star power']" />
|
||||
<label>Star Power</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="forcing" [(ngModel)]="searchSettings.tags.forcing" />
|
||||
<label>Forcing</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="taps" [(ngModel)]="searchSettings.tags.taps" />
|
||||
<label>Taps</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="lyrics" [(ngModel)]="searchSettings.tags.lyrics" />
|
||||
<label>Lyrics</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="videobackground" [(ngModel)]="searchSettings.tags.video" />
|
||||
<label>Video Background</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="stems" [(ngModel)]="searchSettings.tags.stems" />
|
||||
<label>Audio Stems</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="solosections" [(ngModel)]="searchSettings.tags['solo sections']" />
|
||||
<label>Solo Sections</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="opennotes" [(ngModel)]="searchSettings.tags['open notes']" />
|
||||
<label>Open Notes</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui secondary segment">
|
||||
<div class="grouped fields">
|
||||
<h5 class="ui dividing header">Instruments</h5>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="guitar" [(ngModel)]="searchSettings.instruments.guitar" />
|
||||
<label>Guitar</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="bass" [(ngModel)]="searchSettings.instruments.bass" />
|
||||
<label>Bass</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="rhythm" [(ngModel)]="searchSettings.instruments.rhythm" />
|
||||
<label>Rhythm</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="keys" [(ngModel)]="searchSettings.instruments.keys" />
|
||||
<label>Keys</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="drums" [(ngModel)]="searchSettings.instruments.drums" />
|
||||
<label>Drums</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="guitarghl" [(ngModel)]="searchSettings.instruments.guitarghl" />
|
||||
<label>GHL Guitar</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="bassghl" [(ngModel)]="searchSettings.instruments.bassghl" />
|
||||
<label>GHL Bass</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="vocals" [(ngModel)]="searchSettings.instruments.vocals" />
|
||||
<label>Vocals</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui secondary segment">
|
||||
<h5 class="ui dividing header">Charted difficulties</h5>
|
||||
<div class="grouped fields">
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="expert" [(ngModel)]="searchSettings.difficulties.expert" />
|
||||
<label>Expert</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="hard" [(ngModel)]="searchSettings.difficulties.hard" />
|
||||
<label>Hard</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="medium" [(ngModel)]="searchSettings.difficulties.medium" />
|
||||
<label>Medium</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="ui checkbox">
|
||||
<input type="checkbox" name="easy" [(ngModel)]="searchSettings.difficulties.easy" />
|
||||
<label>Easy</label>
|
||||
</div>
|
||||
</div>
|
||||
<h5 class="ui dividing header">Difficulty range</h5>
|
||||
<div class="field">
|
||||
<div #diffSlider class="ui labeled ticked inverted range slider" id="diffSlider"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 { getDefaultSearch, SongSearch } from 'src/electron/shared/interfaces/search.interface'
|
||||
import { getDefaultSearch } from 'src/electron/shared/interfaces/search.interface'
|
||||
|
||||
@Component({
|
||||
selector: 'app-search-bar',
|
||||
templateUrl: './search-bar.component.html',
|
||||
styleUrls: ['./search-bar.component.scss']
|
||||
selector: 'app-search-bar',
|
||||
templateUrl: './search-bar.component.html',
|
||||
styleUrls: ['./search-bar.component.scss'],
|
||||
})
|
||||
export class SearchBarComponent implements AfterViewInit {
|
||||
|
||||
@ViewChild('searchIcon', { static: true }) searchIcon: ElementRef
|
||||
@ViewChild('quantityDropdown', { static: true }) quantityDropdown: ElementRef
|
||||
@ViewChild('similarityDropdown', { static: true }) similarityDropdown: ElementRef
|
||||
@ViewChild('diffSlider', { static: true }) diffSlider: ElementRef
|
||||
@ViewChild('searchIcon', { static: true }) searchIcon: ElementRef
|
||||
@ViewChild('quantityDropdown', { static: true }) quantityDropdown: ElementRef
|
||||
@ViewChild('similarityDropdown', { static: true }) similarityDropdown: ElementRef
|
||||
@ViewChild('diffSlider', { static: true }) diffSlider: ElementRef
|
||||
|
||||
isError = false
|
||||
showAdvanced = false
|
||||
searchSettings = getDefaultSearch()
|
||||
private sliderInitialized = false
|
||||
isError = false
|
||||
showAdvanced = false
|
||||
searchSettings = getDefaultSearch()
|
||||
private sliderInitialized = false
|
||||
|
||||
constructor(public searchService: SearchService) { }
|
||||
constructor(public searchService: SearchService) { }
|
||||
|
||||
ngAfterViewInit() {
|
||||
$(this.searchIcon.nativeElement).popup({
|
||||
onShow: () => this.isError // Only show the popup if there is an error
|
||||
})
|
||||
this.searchService.onSearchErrorStateUpdate((isError) => {
|
||||
this.isError = isError
|
||||
})
|
||||
$(this.quantityDropdown.nativeElement).dropdown({
|
||||
onChange: (value: string) => {
|
||||
this.searchSettings.quantity = value as 'all' | 'any'
|
||||
}
|
||||
})
|
||||
$(this.similarityDropdown.nativeElement).dropdown({
|
||||
onChange: (value: string) => {
|
||||
this.searchSettings.similarity = value as 'similar' | 'exact'
|
||||
}
|
||||
})
|
||||
}
|
||||
ngAfterViewInit() {
|
||||
$(this.searchIcon.nativeElement).popup({
|
||||
onShow: () => this.isError, // Only show the popup if there is an error
|
||||
})
|
||||
this.searchService.onSearchErrorStateUpdate(isError => {
|
||||
this.isError = isError
|
||||
})
|
||||
$(this.quantityDropdown.nativeElement).dropdown({
|
||||
onChange: (value: string) => {
|
||||
this.searchSettings.quantity = value as 'all' | 'any'
|
||||
},
|
||||
})
|
||||
$(this.similarityDropdown.nativeElement).dropdown({
|
||||
onChange: (value: string) => {
|
||||
this.searchSettings.similarity = value as 'similar' | 'exact'
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
onSearch(query: string) {
|
||||
this.searchSettings.query = query
|
||||
this.searchSettings.limit = 50 + 1
|
||||
this.searchSettings.offset = 0
|
||||
this.searchService.newSearch(this.searchSettings)
|
||||
}
|
||||
onSearch(query: string) {
|
||||
this.searchSettings.query = query
|
||||
this.searchSettings.limit = 50 + 1
|
||||
this.searchSettings.offset = 0
|
||||
this.searchService.newSearch(this.searchSettings)
|
||||
}
|
||||
|
||||
onAdvancedSearchClick() {
|
||||
this.showAdvanced = !this.showAdvanced
|
||||
onAdvancedSearchClick() {
|
||||
this.showAdvanced = !this.showAdvanced
|
||||
|
||||
if (!this.sliderInitialized) {
|
||||
setTimeout(() => { // Initialization requires this element to not be collapsed
|
||||
$(this.diffSlider.nativeElement).slider({
|
||||
min: 0,
|
||||
max: 6,
|
||||
start: 0,
|
||||
end: 6,
|
||||
step: 1,
|
||||
onChange: (_length: number, min: number, max: number) => {
|
||||
this.searchSettings.minDiff = min
|
||||
this.searchSettings.maxDiff = max
|
||||
}
|
||||
})
|
||||
}, 50)
|
||||
this.sliderInitialized = true
|
||||
}
|
||||
}
|
||||
if (!this.sliderInitialized) {
|
||||
setTimeout(() => { // Initialization requires this element to not be collapsed
|
||||
$(this.diffSlider.nativeElement).slider({
|
||||
min: 0,
|
||||
max: 6,
|
||||
start: 0,
|
||||
end: 6,
|
||||
step: 1,
|
||||
onChange: (_length: number, min: number, max: number) => {
|
||||
this.searchSettings.minDiff = min
|
||||
this.searchSettings.maxDiff = max
|
||||
},
|
||||
})
|
||||
}, 50)
|
||||
this.sliderInitialized = true
|
||||
}
|
||||
}
|
||||
|
||||
isLoading() {
|
||||
return this.searchService.isLoading()
|
||||
}
|
||||
isLoading() {
|
||||
return this.searchService.isLoading()
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,28 @@
|
||||
<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">
|
||||
<span style="flex-grow: 1;">{{download.title}}</span>
|
||||
<i class="inside right close icon" (click)="cancelDownload(download.versionID)"></i>
|
||||
</h3>
|
||||
<div id="downloadText">
|
||||
<span style="flex-grow: 1">{{ download.header }}</span>
|
||||
<span *ngIf="!download.isLink" class="description">{{ download.description }}</span>
|
||||
<span *ngIf="download.isLink" class="description">
|
||||
<a (click)="openFolder(download.description)">{{ download.description }}</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div id="downloadText">
|
||||
<span style="flex-grow: 1;">{{download.header}}</span>
|
||||
<span *ngIf="!download.isLink" class="description">{{download.description}}</span>
|
||||
<span *ngIf="download.isLink" class="description">
|
||||
<a (click)="openFolder(download.description)">{{download.description}}</a>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div id="downloadProgressDiv">
|
||||
<div id="downloadProgressBar" appProgressBar [percent]="download.percent" class="ui small progress">
|
||||
<div class="bar" [style.height]="download.type == 'error' ? '-webkit-fill-available' : undefined">
|
||||
<div class="progress"></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 id="downloadProgressDiv">
|
||||
<div id="downloadProgressBar" appProgressBar [percent]="download.percent" class="ui small progress">
|
||||
<div class="bar" [style.height]="download.type === 'error' ? '-webkit-fill-available' : undefined">
|
||||
<div class="progress"></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>
|
||||
@@ -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 { DownloadService } from '../../../../core/services/download.service'
|
||||
import { ElectronService } from '../../../../core/services/electron.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-downloads-modal',
|
||||
templateUrl: './downloads-modal.component.html',
|
||||
styleUrls: ['./downloads-modal.component.scss']
|
||||
selector: 'app-downloads-modal',
|
||||
templateUrl: './downloads-modal.component.html',
|
||||
styleUrls: ['./downloads-modal.component.scss'],
|
||||
})
|
||||
export class DownloadsModalComponent {
|
||||
|
||||
downloads: DownloadProgress[] = []
|
||||
downloads: DownloadProgress[] = []
|
||||
|
||||
constructor(private electronService: ElectronService, private downloadService: DownloadService, ref: ChangeDetectorRef) {
|
||||
electronService.receiveIPC('queue-updated', (order) => {
|
||||
this.downloads.sort((a, b) => order.indexOf(a.versionID) - order.indexOf(b.versionID))
|
||||
})
|
||||
constructor(private electronService: ElectronService, private downloadService: DownloadService, ref: ChangeDetectorRef) {
|
||||
electronService.receiveIPC('queue-updated', order => {
|
||||
this.downloads.sort((a, b) => order.indexOf(a.versionID) - order.indexOf(b.versionID))
|
||||
})
|
||||
|
||||
downloadService.onDownloadUpdated(download => {
|
||||
const index = this.downloads.findIndex(thisDownload => thisDownload.versionID == download.versionID)
|
||||
if (download.type == 'cancel') {
|
||||
this.downloads = this.downloads.filter(thisDownload => thisDownload.versionID != download.versionID)
|
||||
} else if (index == -1) {
|
||||
this.downloads.push(download)
|
||||
} else {
|
||||
this.downloads[index] = download
|
||||
}
|
||||
ref.detectChanges()
|
||||
})
|
||||
}
|
||||
downloadService.onDownloadUpdated(download => {
|
||||
const index = this.downloads.findIndex(thisDownload => thisDownload.versionID == download.versionID)
|
||||
if (download.type == 'cancel') {
|
||||
this.downloads = this.downloads.filter(thisDownload => thisDownload.versionID != download.versionID)
|
||||
} else if (index == -1) {
|
||||
this.downloads.push(download)
|
||||
} else {
|
||||
this.downloads[index] = download
|
||||
}
|
||||
ref.detectChanges()
|
||||
})
|
||||
}
|
||||
|
||||
trackByVersionID(_index: number, item: DownloadProgress) {
|
||||
return item.versionID
|
||||
}
|
||||
trackByVersionID(_index: number, item: DownloadProgress) {
|
||||
return item.versionID
|
||||
}
|
||||
|
||||
cancelDownload(versionID: number) {
|
||||
this.downloadService.cancelDownload(versionID)
|
||||
}
|
||||
cancelDownload(versionID: number) {
|
||||
this.downloadService.cancelDownload(versionID)
|
||||
}
|
||||
|
||||
retryDownload(versionID: number) {
|
||||
this.downloadService.retryDownload(versionID)
|
||||
}
|
||||
retryDownload(versionID: number) {
|
||||
this.downloadService.retryDownload(versionID)
|
||||
}
|
||||
|
||||
getBackgroundColor(download: DownloadProgress) {
|
||||
switch (download.type) {
|
||||
case 'error': return '#a63a3a'
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
getBackgroundColor(download: DownloadProgress) {
|
||||
switch (download.type) {
|
||||
case 'error': return '#a63a3a'
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
|
||||
openFolder(filepath: string) {
|
||||
this.electronService.showFolder(filepath)
|
||||
}
|
||||
openFolder(filepath: string) {
|
||||
this.electronService.showFolder(filepath)
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,44 @@
|
||||
<div id="bottomMenu" class="ui bottom borderless menu">
|
||||
<div *ngIf="resultCount > 0" class="item">{{resultCount}}{{allResultsVisible ? '' : '+'}} Result{{resultCount == 1 ? '' : 's'}}</div>
|
||||
<div class="item">
|
||||
<button *ngIf="selectedResults.length > 1" (click)="downloadSelected()" class="ui positive button">
|
||||
Download {{selectedResults.length}} Results
|
||||
</button>
|
||||
</div>
|
||||
<a *ngIf="downloading" class="item right" (click)="showDownloads()">
|
||||
<div #progressBar appProgressBar [percent]="percent" class="ui progress" [style.background-color]="error ? 'indianred' : undefined">
|
||||
<div class="bar">
|
||||
<div class="progress"></div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div *ngIf="resultCount > 0" class="item">{{ resultCount }}{{ allResultsVisible ? '' : '+' }} Result{{ resultCount === 1 ? '' : 's' }}</div>
|
||||
<div class="item">
|
||||
<button *ngIf="selectedResults.length > 1" (click)="downloadSelected()" class="ui positive button">
|
||||
Download {{ selectedResults.length }} Results
|
||||
</button>
|
||||
</div>
|
||||
<a *ngIf="downloading" class="item right" (click)="showDownloads()">
|
||||
<div #progressBar appProgressBar [percent]="percent" class="ui progress" [style.background-color]="error ? 'indianred' : undefined">
|
||||
<div class="bar">
|
||||
<div class="progress"></div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div id="selectedModal" class="ui modal">
|
||||
<div class="header">Some selected songs have more than one chart!</div>
|
||||
<div class="scrolling content">
|
||||
<div class="ui segments">
|
||||
<div class="ui segment" *ngFor="let chartGroup of chartGroups">
|
||||
<p *ngFor="let chart of chartGroup">{{chart.chartName}} <b>[{{chart.charters}}]</b></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui approve button" (click)="downloadAllCharts()">Download all charts for each song</div>
|
||||
<div class="ui cancel button" (click)="deselectSongsWithMultipleCharts()">Deselect these songs</div>
|
||||
<div class="ui cancel button">Cancel</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="selectedModal" class="ui modal">
|
||||
<div class="header">Some selected songs have more than one chart!</div>
|
||||
<div class="scrolling content">
|
||||
<div class="ui segments">
|
||||
<div class="ui segment" *ngFor="let chartGroup of chartGroups">
|
||||
<p *ngFor="let chart of chartGroup">
|
||||
{{ chart.chartName }} <b>[{{ chart.charters }}]</b>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui approve button" (click)="downloadAllCharts()">Download all charts for each song</div>
|
||||
<div class="ui cancel button" (click)="deselectSongsWithMultipleCharts()">Deselect these songs</div>
|
||||
<div class="ui cancel button">Cancel</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="downloadsModal" class="ui modal">
|
||||
<i class="inside close icon"></i>
|
||||
<div class="header">
|
||||
<span>Downloads</span>
|
||||
<div *ngIf="multipleCompleted" class="ui positive compact button" (click)="clearCompleted()">Clear completed</div>
|
||||
</div>
|
||||
<div class="scrolling content">
|
||||
<app-downloads-modal></app-downloads-modal>
|
||||
</div>
|
||||
</div>
|
||||
<div id="downloadsModal" class="ui modal">
|
||||
<i class="inside close icon"></i>
|
||||
<div class="header">
|
||||
<span>Downloads</span>
|
||||
<div *ngIf="multipleCompleted" class="ui positive compact button" (click)="clearCompleted()">Clear completed</div>
|
||||
</div>
|
||||
<div class="scrolling content">
|
||||
<app-downloads-modal></app-downloads-modal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 { 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 { SelectionService } from '../../../core/services/selection.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-status-bar',
|
||||
templateUrl: './status-bar.component.html',
|
||||
styleUrls: ['./status-bar.component.scss']
|
||||
selector: 'app-status-bar',
|
||||
templateUrl: './status-bar.component.html',
|
||||
styleUrls: ['./status-bar.component.scss'],
|
||||
})
|
||||
export class StatusBarComponent {
|
||||
|
||||
resultCount = 0
|
||||
multipleCompleted = false
|
||||
downloading = false
|
||||
error = false
|
||||
percent = 0
|
||||
batchResults: VersionResult[]
|
||||
chartGroups: VersionResult[][]
|
||||
resultCount = 0
|
||||
multipleCompleted = false
|
||||
downloading = false
|
||||
error = false
|
||||
percent = 0
|
||||
batchResults: VersionResult[]
|
||||
chartGroups: VersionResult[][]
|
||||
|
||||
constructor(
|
||||
private electronService: ElectronService,
|
||||
private downloadService: DownloadService,
|
||||
private searchService: SearchService,
|
||||
private selectionService: SelectionService,
|
||||
ref: ChangeDetectorRef
|
||||
) {
|
||||
downloadService.onDownloadUpdated(() => {
|
||||
setTimeout(() => { // Make sure this is the last callback executed to get the accurate downloadCount
|
||||
this.downloading = downloadService.downloadCount > 0
|
||||
this.multipleCompleted = downloadService.completedCount > 1
|
||||
this.percent = downloadService.totalDownloadingPercent
|
||||
this.error = downloadService.anyErrorsExist
|
||||
ref.detectChanges()
|
||||
}, 0)
|
||||
})
|
||||
constructor(
|
||||
private electronService: ElectronService,
|
||||
private downloadService: DownloadService,
|
||||
private searchService: SearchService,
|
||||
private selectionService: SelectionService,
|
||||
ref: ChangeDetectorRef
|
||||
) {
|
||||
downloadService.onDownloadUpdated(() => {
|
||||
setTimeout(() => { // Make sure this is the last callback executed to get the accurate downloadCount
|
||||
this.downloading = downloadService.downloadCount > 0
|
||||
this.multipleCompleted = downloadService.completedCount > 1
|
||||
this.percent = downloadService.totalDownloadingPercent
|
||||
this.error = downloadService.anyErrorsExist
|
||||
ref.detectChanges()
|
||||
}, 0)
|
||||
})
|
||||
|
||||
searchService.onSearchChanged(() => {
|
||||
this.resultCount = searchService.resultCount
|
||||
})
|
||||
}
|
||||
searchService.onSearchChanged(() => {
|
||||
this.resultCount = searchService.resultCount
|
||||
})
|
||||
}
|
||||
|
||||
get allResultsVisible() {
|
||||
return this.searchService.allResultsVisible
|
||||
}
|
||||
get allResultsVisible() {
|
||||
return this.searchService.allResultsVisible
|
||||
}
|
||||
|
||||
get selectedResults() {
|
||||
return this.selectionService.getSelectedResults()
|
||||
}
|
||||
get selectedResults() {
|
||||
return this.selectionService.getSelectedResults()
|
||||
}
|
||||
|
||||
showDownloads() {
|
||||
$('#downloadsModal').modal('show')
|
||||
}
|
||||
showDownloads() {
|
||||
$('#downloadsModal').modal('show')
|
||||
}
|
||||
|
||||
async downloadSelected() {
|
||||
this.chartGroups = []
|
||||
this.batchResults = await this.electronService.invoke('batch-song-details', this.selectedResults.map(result => result.id))
|
||||
const versionGroups = groupBy(this.batchResults, 'songID')
|
||||
for (const versionGroup of versionGroups) {
|
||||
if (versionGroup.findIndex(version => version.chartID != versionGroup[0].chartID) != -1) {
|
||||
// Must have multiple charts of this song
|
||||
this.chartGroups.push(versionGroup.filter(version => version.versionID == version.latestVersionID))
|
||||
}
|
||||
}
|
||||
async downloadSelected() {
|
||||
this.chartGroups = []
|
||||
this.batchResults = await this.electronService.invoke('batch-song-details', this.selectedResults.map(result => result.id))
|
||||
const versionGroups = groupBy(this.batchResults, 'songID')
|
||||
for (const versionGroup of versionGroups) {
|
||||
if (versionGroup.findIndex(version => version.chartID != versionGroup[0].chartID) != -1) {
|
||||
// Must have multiple charts of this song
|
||||
this.chartGroups.push(versionGroup.filter(version => version.versionID == version.latestVersionID))
|
||||
}
|
||||
}
|
||||
|
||||
if (this.chartGroups.length == 0) {
|
||||
for (const versions of versionGroups) {
|
||||
this.searchService.sortChart(versions)
|
||||
const downloadVersion = versions[0]
|
||||
const downloadSong = this.selectedResults.find(song => song.id == downloadVersion.songID)
|
||||
this.downloadService.addDownload(
|
||||
downloadVersion.versionID, {
|
||||
chartName: downloadVersion.chartName,
|
||||
artist: downloadSong.artist,
|
||||
charter: downloadVersion.charters,
|
||||
driveData: downloadVersion.driveData
|
||||
})
|
||||
}
|
||||
} else {
|
||||
$('#selectedModal').modal('show')
|
||||
// [download all charts for each song] [deselect these songs] [X]
|
||||
}
|
||||
}
|
||||
if (this.chartGroups.length == 0) {
|
||||
for (const versions of versionGroups) {
|
||||
this.searchService.sortChart(versions)
|
||||
const downloadVersion = versions[0]
|
||||
const downloadSong = this.selectedResults.find(song => song.id == downloadVersion.songID)
|
||||
this.downloadService.addDownload(
|
||||
downloadVersion.versionID, {
|
||||
chartName: downloadVersion.chartName,
|
||||
artist: downloadSong.artist,
|
||||
charter: downloadVersion.charters,
|
||||
driveData: downloadVersion.driveData,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
$('#selectedModal').modal('show')
|
||||
// [download all charts for each song] [deselect these songs] [X]
|
||||
}
|
||||
}
|
||||
|
||||
downloadAllCharts() {
|
||||
const songChartGroups = groupBy(this.batchResults, 'songID', 'chartID')
|
||||
for (const chart of songChartGroups) {
|
||||
this.searchService.sortChart(chart)
|
||||
const downloadVersion = chart[0]
|
||||
const downloadSong = this.selectedResults.find(song => song.id == downloadVersion.songID)
|
||||
this.downloadService.addDownload(
|
||||
downloadVersion.versionID, {
|
||||
chartName: downloadVersion.chartName,
|
||||
artist: downloadSong.artist,
|
||||
charter: downloadVersion.charters,
|
||||
driveData: downloadVersion.driveData
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
downloadAllCharts() {
|
||||
const songChartGroups = groupBy(this.batchResults, 'songID', 'chartID')
|
||||
for (const chart of songChartGroups) {
|
||||
this.searchService.sortChart(chart)
|
||||
const downloadVersion = chart[0]
|
||||
const downloadSong = this.selectedResults.find(song => song.id == downloadVersion.songID)
|
||||
this.downloadService.addDownload(
|
||||
downloadVersion.versionID, {
|
||||
chartName: downloadVersion.chartName,
|
||||
artist: downloadSong.artist,
|
||||
charter: downloadVersion.charters,
|
||||
driveData: downloadVersion.driveData,
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
deselectSongsWithMultipleCharts() {
|
||||
for (const chartGroup of this.chartGroups) {
|
||||
this.selectionService.deselectSong(chartGroup[0].songID)
|
||||
}
|
||||
}
|
||||
deselectSongsWithMultipleCharts() {
|
||||
for (const chartGroup of this.chartGroups) {
|
||||
this.selectionService.deselectSong(chartGroup[0].songID)
|
||||
}
|
||||
}
|
||||
|
||||
clearCompleted() {
|
||||
this.downloadService.cancelCompleted()
|
||||
}
|
||||
clearCompleted() {
|
||||
this.downloadService.cancelCompleted()
|
||||
}
|
||||
}
|
||||
@@ -1,67 +1,71 @@
|
||||
<h3 class="ui header">Paths</h3>
|
||||
<div class="ui form">
|
||||
<div class="field">
|
||||
<label>Chart library directory</label>
|
||||
<div class="ui action input">
|
||||
<input [value]="settingsService.libraryDirectory || 'No folder selected'" class="default-cursor" readonly
|
||||
type="text" placeholder="No directory selected!">
|
||||
<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 class="field">
|
||||
<label>Chart library directory</label>
|
||||
<div class="ui action input">
|
||||
<input
|
||||
[value]="settingsService.libraryDirectory || 'No folder selected'"
|
||||
class="default-cursor"
|
||||
readonly
|
||||
type="text"
|
||||
placeholder="No directory selected!" />
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<div class="ui form">
|
||||
<div class="field">
|
||||
<div appCheckbox #videoCheckbox class="ui checkbox" (checked)="downloadVideos($event)">
|
||||
<input type="checkbox">
|
||||
<label>Download video backgrounds</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div appCheckbox #videoCheckbox class="ui checkbox" (checked)="downloadVideos($event)">
|
||||
<input type="checkbox" />
|
||||
<label>Download video backgrounds</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Google rate limit delay</label>
|
||||
<div id="rateLimitInput" class="ui right labeled input">
|
||||
<input type="number" [value]="settingsService.rateLimitDelay" (input)="changeRateLimit($event)">
|
||||
<div class="ui basic label">
|
||||
sec
|
||||
</div>
|
||||
</div>
|
||||
<label>Google rate limit delay</label>
|
||||
<div id="rateLimitInput" class="ui right labeled input">
|
||||
<input type="number" [value]="settingsService.rateLimitDelay" (input)="changeRateLimit($event)" />
|
||||
<div class="ui basic label">sec</div>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="settingsService.rateLimitDelay < 30" class="ui warning message">
|
||||
<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
|
||||
refuse download requests from this program for a few hours. This limitation will be removed in a future update.
|
||||
<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 refuse download requests from
|
||||
this program for a few hours. This limitation will be removed in a future update.
|
||||
</div>
|
||||
|
||||
<h3 class="ui header">Theme</h3>
|
||||
<div #themeDropdown class="ui selection dropdown mr">
|
||||
<input type="hidden" name="sort" [value]="settingsService.theme">
|
||||
<i class="dropdown icon"></i>
|
||||
<div class="default text">{{settingsService.theme}}</div>
|
||||
<div class="menu">
|
||||
<div class="item" [attr.data-value]="i" *ngFor="let theme of settingsService.builtinThemes; let i = index">{{theme}}</div>
|
||||
</div>
|
||||
<input type="hidden" name="sort" [value]="settingsService.theme" />
|
||||
<i class="dropdown icon"></i>
|
||||
<div class="default text">{{ settingsService.theme }}</div>
|
||||
<div class="menu">
|
||||
<div class="item" [attr.data-value]="i" *ngFor="let theme of settingsService.builtinThemes; let i = index">{{ theme }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bottom">
|
||||
<div class="ui buttons">
|
||||
<button *ngIf="updateAvailable" class="ui labeled icon positive button" (click)="downloadUpdate()">
|
||||
<i class="left alternate icon" [ngClass]="(updateDownloaded ? 'sync' : 'cloud download')"></i>{{downloadUpdateText}}</button>
|
||||
<button *ngIf="updateAvailable === null" class="ui labeled yellow icon button" [class.disabled]="updateRetrying" (click)="retryUpdate()">
|
||||
<i class="left alternate sync alternate icon" [class.loading]="updateRetrying"></i>{{retryUpdateText}}</button>
|
||||
<button id="versionNumberButton" class="ui basic disabled button">{{currentVersion}}</button>
|
||||
</div>
|
||||
<div class="ui buttons">
|
||||
<button *ngIf="updateAvailable" class="ui labeled icon positive button" (click)="downloadUpdate()">
|
||||
<i class="left alternate icon" [ngClass]="updateDownloaded ? 'sync' : 'cloud download'"></i>{{ downloadUpdateText }}
|
||||
</button>
|
||||
<button *ngIf="updateAvailable === null" class="ui labeled yellow icon button" [class.disabled]="updateRetrying" (click)="retryUpdate()">
|
||||
<i class="left alternate sync alternate icon" [class.loading]="updateRetrying"></i>{{ retryUpdateText }}
|
||||
</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"
|
||||
(click)="toggleDevTools()">
|
||||
<i class="cog icon"></i>
|
||||
</button>
|
||||
<button class="ui basic icon button" data-tooltip="Toggle developer tools" data-position="top right" (click)="toggleDevTools()">
|
||||
<i class="cog icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -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 { ElectronService } from 'src/app/core/services/electron.service'
|
||||
import { SettingsService } from 'src/app/core/services/settings.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrls: ['./settings.component.scss']
|
||||
selector: 'app-settings',
|
||||
templateUrl: './settings.component.html',
|
||||
styleUrls: ['./settings.component.scss'],
|
||||
})
|
||||
export class SettingsComponent implements OnInit, AfterViewInit {
|
||||
@ViewChild('themeDropdown', { static: true }) themeDropdown: ElementRef
|
||||
@ViewChild(CheckboxDirective, { static: true }) videoCheckbox: CheckboxDirective
|
||||
@ViewChild('themeDropdown', { static: true }) themeDropdown: ElementRef
|
||||
@ViewChild(CheckboxDirective, { static: true }) videoCheckbox: CheckboxDirective
|
||||
|
||||
cacheSize = 'Calculating...'
|
||||
updateAvailable = false
|
||||
loginClicked = false
|
||||
downloadUpdateText = 'Update available'
|
||||
retryUpdateText = 'Failed to check for update'
|
||||
updateDownloading = false
|
||||
updateDownloaded = false
|
||||
updateRetrying = false
|
||||
currentVersion = ''
|
||||
cacheSize = 'Calculating...'
|
||||
updateAvailable = false
|
||||
loginClicked = false
|
||||
downloadUpdateText = 'Update available'
|
||||
retryUpdateText = 'Failed to check for update'
|
||||
updateDownloading = false
|
||||
updateDownloaded = false
|
||||
updateRetrying = false
|
||||
currentVersion = ''
|
||||
|
||||
constructor(
|
||||
public settingsService: SettingsService,
|
||||
private electronService: ElectronService,
|
||||
private ref: ChangeDetectorRef
|
||||
) { }
|
||||
constructor(
|
||||
public settingsService: SettingsService,
|
||||
private electronService: ElectronService,
|
||||
private ref: ChangeDetectorRef
|
||||
) { }
|
||||
|
||||
async ngOnInit() {
|
||||
this.electronService.receiveIPC('update-available', (result) => {
|
||||
this.updateAvailable = result != null
|
||||
this.updateRetrying = false
|
||||
if (this.updateAvailable) {
|
||||
this.downloadUpdateText = `Update available (${result.version})`
|
||||
}
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
this.electronService.receiveIPC('update-error', (err: Error) => {
|
||||
console.log(err)
|
||||
this.updateAvailable = null
|
||||
this.updateRetrying = false
|
||||
this.retryUpdateText = `Failed to check for update: ${err.message}`
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
this.electronService.invoke('get-current-version', undefined).then(version => {
|
||||
this.currentVersion = `v${version}`
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
this.electronService.invoke('get-update-available', undefined).then(isAvailable => {
|
||||
this.updateAvailable = isAvailable
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
async ngOnInit() {
|
||||
this.electronService.receiveIPC('update-available', result => {
|
||||
this.updateAvailable = result != null
|
||||
this.updateRetrying = false
|
||||
if (this.updateAvailable) {
|
||||
this.downloadUpdateText = `Update available (${result.version})`
|
||||
}
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
this.electronService.receiveIPC('update-error', (err: Error) => {
|
||||
console.log(err)
|
||||
this.updateAvailable = null
|
||||
this.updateRetrying = false
|
||||
this.retryUpdateText = `Failed to check for update: ${err.message}`
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
this.electronService.invoke('get-current-version', undefined).then(version => {
|
||||
this.currentVersion = `v${version}`
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
this.electronService.invoke('get-update-available', undefined).then(isAvailable => {
|
||||
this.updateAvailable = isAvailable
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
|
||||
const cacheSize = await this.settingsService.getCacheSize()
|
||||
this.cacheSize = Math.round(cacheSize / 1000000) + ' MB'
|
||||
}
|
||||
const cacheSize = await this.settingsService.getCacheSize()
|
||||
this.cacheSize = Math.round(cacheSize / 1000000) + ' MB'
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
$(this.themeDropdown.nativeElement).dropdown({
|
||||
onChange: (_value: string, text: string) => {
|
||||
this.settingsService.theme = text
|
||||
}
|
||||
})
|
||||
ngAfterViewInit() {
|
||||
$(this.themeDropdown.nativeElement).dropdown({
|
||||
onChange: (_value: string, text: string) => {
|
||||
this.settingsService.theme = text
|
||||
},
|
||||
})
|
||||
|
||||
this.videoCheckbox.check(this.settingsService.downloadVideos)
|
||||
}
|
||||
this.videoCheckbox.check(this.settingsService.downloadVideos)
|
||||
}
|
||||
|
||||
async clearCache() {
|
||||
this.cacheSize = 'Please wait...'
|
||||
await this.settingsService.clearCache()
|
||||
this.cacheSize = 'Cleared!'
|
||||
}
|
||||
async clearCache() {
|
||||
this.cacheSize = 'Please wait...'
|
||||
await this.settingsService.clearCache()
|
||||
this.cacheSize = 'Cleared!'
|
||||
}
|
||||
|
||||
async downloadVideos(isChecked: boolean) {
|
||||
this.settingsService.downloadVideos = isChecked
|
||||
}
|
||||
async downloadVideos(isChecked: boolean) {
|
||||
this.settingsService.downloadVideos = isChecked
|
||||
}
|
||||
|
||||
async getLibraryDirectory() {
|
||||
const result = await this.electronService.showOpenDialog({
|
||||
title: 'Choose library folder',
|
||||
buttonLabel: 'This is where my charts are!',
|
||||
defaultPath: this.settingsService.libraryDirectory || '',
|
||||
properties: ['openDirectory']
|
||||
})
|
||||
async getLibraryDirectory() {
|
||||
const result = await this.electronService.showOpenDialog({
|
||||
title: 'Choose library folder',
|
||||
buttonLabel: 'This is where my charts are!',
|
||||
defaultPath: this.settingsService.libraryDirectory || '',
|
||||
properties: ['openDirectory'],
|
||||
})
|
||||
|
||||
if (result.canceled == false) {
|
||||
this.settingsService.libraryDirectory = result.filePaths[0]
|
||||
}
|
||||
}
|
||||
if (result.canceled == false) {
|
||||
this.settingsService.libraryDirectory = result.filePaths[0]
|
||||
}
|
||||
}
|
||||
|
||||
openLibraryDirectory() {
|
||||
this.electronService.openFolder(this.settingsService.libraryDirectory)
|
||||
}
|
||||
openLibraryDirectory() {
|
||||
this.electronService.openFolder(this.settingsService.libraryDirectory)
|
||||
}
|
||||
|
||||
changeRateLimit(event: Event) {
|
||||
const inputElement = event.srcElement as HTMLInputElement
|
||||
this.settingsService.rateLimitDelay = Number(inputElement.value)
|
||||
}
|
||||
changeRateLimit(event: Event) {
|
||||
const inputElement = event.srcElement as HTMLInputElement
|
||||
this.settingsService.rateLimitDelay = Number(inputElement.value)
|
||||
}
|
||||
|
||||
downloadUpdate() {
|
||||
if (this.updateDownloaded) {
|
||||
this.electronService.sendIPC('quit-and-install', undefined)
|
||||
} else if (!this.updateDownloading) {
|
||||
this.updateDownloading = true
|
||||
this.electronService.sendIPC('download-update', undefined)
|
||||
this.downloadUpdateText = 'Downloading... (0%)'
|
||||
this.electronService.receiveIPC('update-progress', (result) => {
|
||||
this.downloadUpdateText = `Downloading... (${result.percent.toFixed(0)}%)`
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
this.electronService.receiveIPC('update-downloaded', () => {
|
||||
this.downloadUpdateText = 'Quit and install update'
|
||||
this.updateDownloaded = true
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
}
|
||||
}
|
||||
downloadUpdate() {
|
||||
if (this.updateDownloaded) {
|
||||
this.electronService.sendIPC('quit-and-install', undefined)
|
||||
} else if (!this.updateDownloading) {
|
||||
this.updateDownloading = true
|
||||
this.electronService.sendIPC('download-update', undefined)
|
||||
this.downloadUpdateText = 'Downloading... (0%)'
|
||||
this.electronService.receiveIPC('update-progress', result => {
|
||||
this.downloadUpdateText = `Downloading... (${result.percent.toFixed(0)}%)`
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
this.electronService.receiveIPC('update-downloaded', () => {
|
||||
this.downloadUpdateText = 'Quit and install update'
|
||||
this.updateDownloaded = true
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
retryUpdate() {
|
||||
if (this.updateRetrying == false) {
|
||||
this.updateRetrying = true
|
||||
this.retryUpdateText = 'Retrying...'
|
||||
this.ref.detectChanges()
|
||||
this.electronService.sendIPC('retry-update', undefined)
|
||||
}
|
||||
}
|
||||
retryUpdate() {
|
||||
if (this.updateRetrying == false) {
|
||||
this.updateRetrying = true
|
||||
this.retryUpdateText = 'Retrying...'
|
||||
this.ref.detectChanges()
|
||||
this.electronService.sendIPC('retry-update', undefined)
|
||||
}
|
||||
}
|
||||
|
||||
toggleDevTools() {
|
||||
const toolsOpened = this.electronService.currentWindow.webContents.isDevToolsOpened()
|
||||
toggleDevTools() {
|
||||
const toolsOpened = this.electronService.currentWindow.webContents.isDevToolsOpened()
|
||||
|
||||
if (toolsOpened) {
|
||||
this.electronService.currentWindow.webContents.closeDevTools()
|
||||
} else {
|
||||
this.electronService.currentWindow.webContents.openDevTools()
|
||||
}
|
||||
}
|
||||
if (toolsOpened) {
|
||||
this.electronService.currentWindow.webContents.closeDevTools()
|
||||
} else {
|
||||
this.electronService.currentWindow.webContents.openDevTools()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
<div class="ui top menu">
|
||||
<a class="item" routerLinkActive="active" routerLink="/browse">Browse</a>
|
||||
<!-- TODO <a class="item" routerLinkActive="active" routerLink="/library">Library</a> -->
|
||||
<a class="item" routerLinkActive="active" routerLink="/settings">
|
||||
<i *ngIf="updateAvailable" class="teal small circle icon"></i>
|
||||
<i *ngIf="updateAvailable === null" class="small yellow exclamation triangle icon"></i>
|
||||
Settings
|
||||
</a>
|
||||
<a class="item" routerLinkActive="active" routerLink="/browse">Browse</a>
|
||||
<!-- TODO <a class="item" routerLinkActive="active" routerLink="/library">Library</a> -->
|
||||
<a class="item" routerLinkActive="active" routerLink="/settings">
|
||||
<i *ngIf="updateAvailable" class="teal small circle icon"></i>
|
||||
<i *ngIf="updateAvailable === null" class="small yellow exclamation triangle icon"></i>
|
||||
Settings
|
||||
</a>
|
||||
|
||||
<div class="right menu">
|
||||
<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 close" (click)="close()"><i class="x icon"></i></a>
|
||||
</div>
|
||||
<div class="right menu">
|
||||
<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 close" (click)="close()"><i class="x icon"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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'
|
||||
|
||||
@Component({
|
||||
selector: 'app-toolbar',
|
||||
templateUrl: './toolbar.component.html',
|
||||
styleUrls: ['./toolbar.component.scss']
|
||||
selector: 'app-toolbar',
|
||||
templateUrl: './toolbar.component.html',
|
||||
styleUrls: ['./toolbar.component.scss'],
|
||||
})
|
||||
export class ToolbarComponent implements OnInit {
|
||||
|
||||
isMaximized: boolean
|
||||
updateAvailable = false
|
||||
isMaximized: boolean
|
||||
updateAvailable = false
|
||||
|
||||
constructor(private electronService: ElectronService, private ref: ChangeDetectorRef) { }
|
||||
constructor(private electronService: ElectronService, private ref: ChangeDetectorRef) { }
|
||||
|
||||
async ngOnInit() {
|
||||
this.isMaximized = this.electronService.currentWindow.isMaximized()
|
||||
this.electronService.currentWindow.on('unmaximize', () => {
|
||||
this.isMaximized = false
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
this.electronService.currentWindow.on('maximize', () => {
|
||||
this.isMaximized = true
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
async ngOnInit() {
|
||||
this.isMaximized = this.electronService.currentWindow.isMaximized()
|
||||
this.electronService.currentWindow.on('unmaximize', () => {
|
||||
this.isMaximized = false
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
this.electronService.currentWindow.on('maximize', () => {
|
||||
this.isMaximized = true
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
|
||||
this.electronService.receiveIPC('update-available', (result) => {
|
||||
this.updateAvailable = result != null
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
this.electronService.receiveIPC('update-error', () => {
|
||||
this.updateAvailable = null
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
this.updateAvailable = await this.electronService.invoke('get-update-available', undefined)
|
||||
this.ref.detectChanges()
|
||||
}
|
||||
this.electronService.receiveIPC('update-available', result => {
|
||||
this.updateAvailable = result != null
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
this.electronService.receiveIPC('update-error', () => {
|
||||
this.updateAvailable = null
|
||||
this.ref.detectChanges()
|
||||
})
|
||||
this.updateAvailable = await this.electronService.invoke('get-update-available', undefined)
|
||||
this.ref.detectChanges()
|
||||
}
|
||||
|
||||
minimize() {
|
||||
this.electronService.currentWindow.minimize()
|
||||
}
|
||||
minimize() {
|
||||
this.electronService.currentWindow.minimize()
|
||||
}
|
||||
|
||||
toggleMaximized() {
|
||||
if (this.isMaximized) {
|
||||
this.electronService.currentWindow.restore()
|
||||
} else {
|
||||
this.electronService.currentWindow.maximize()
|
||||
}
|
||||
this.isMaximized = !this.isMaximized
|
||||
}
|
||||
toggleMaximized() {
|
||||
if (this.isMaximized) {
|
||||
this.electronService.currentWindow.restore()
|
||||
} else {
|
||||
this.electronService.currentWindow.maximize()
|
||||
}
|
||||
this.isMaximized = !this.isMaximized
|
||||
}
|
||||
|
||||
close() {
|
||||
this.electronService.quit()
|
||||
}
|
||||
close() {
|
||||
this.electronService.quit()
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,38 @@
|
||||
import { Directive, ElementRef, Output, EventEmitter, AfterViewInit } from '@angular/core'
|
||||
import { AfterViewInit, Directive, ElementRef, EventEmitter, Output } from '@angular/core'
|
||||
|
||||
@Directive({
|
||||
selector: '[appCheckbox]'
|
||||
selector: '[appCheckbox]',
|
||||
})
|
||||
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() {
|
||||
$(this.checkbox.nativeElement).checkbox({
|
||||
onChecked: () => {
|
||||
this.checked.emit(true)
|
||||
this._isChecked = true
|
||||
},
|
||||
onUnchecked: () => {
|
||||
this.checked.emit(false)
|
||||
this._isChecked = false
|
||||
}
|
||||
})
|
||||
}
|
||||
ngAfterViewInit() {
|
||||
$(this.checkbox.nativeElement).checkbox({
|
||||
onChecked: () => {
|
||||
this.checked.emit(true)
|
||||
this._isChecked = true
|
||||
},
|
||||
onUnchecked: () => {
|
||||
this.checked.emit(false)
|
||||
this._isChecked = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
check(isChecked: boolean) {
|
||||
this._isChecked = isChecked
|
||||
if (isChecked) {
|
||||
$(this.checkbox.nativeElement).checkbox('check')
|
||||
} else {
|
||||
$(this.checkbox.nativeElement).checkbox('uncheck')
|
||||
}
|
||||
}
|
||||
check(isChecked: boolean) {
|
||||
this._isChecked = isChecked
|
||||
if (isChecked) {
|
||||
$(this.checkbox.nativeElement).checkbox('check')
|
||||
} else {
|
||||
$(this.checkbox.nativeElement).checkbox('uncheck')
|
||||
}
|
||||
}
|
||||
|
||||
get isChecked() {
|
||||
return this._isChecked
|
||||
}
|
||||
get isChecked() {
|
||||
return this._isChecked
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,27 @@
|
||||
import { Directive, ElementRef, Input } from '@angular/core'
|
||||
|
||||
import * as _ from 'underscore'
|
||||
|
||||
@Directive({
|
||||
selector: '[appProgressBar]'
|
||||
selector: '[appProgressBar]',
|
||||
})
|
||||
export class ProgressBarDirective {
|
||||
|
||||
progress: (percent: number) => void
|
||||
progress: (percent: number) => void
|
||||
|
||||
@Input() set percent(percent: number) {
|
||||
this.progress(percent)
|
||||
}
|
||||
@Input() set percent(percent: number) {
|
||||
this.progress(percent)
|
||||
}
|
||||
|
||||
constructor(private element: ElementRef) {
|
||||
this.progress = _.throttle((percent: number) => this.$progressBar.progress('set').percent(percent), 100)
|
||||
}
|
||||
constructor(private element: ElementRef) {
|
||||
this.progress = _.throttle((percent: number) => this.$progressBar.progress('set').percent(percent), 100)
|
||||
}
|
||||
|
||||
private get $progressBar() {
|
||||
if (!this._$progressBar) {
|
||||
this._$progressBar = $(this.element.nativeElement)
|
||||
}
|
||||
return this._$progressBar
|
||||
}
|
||||
private _$progressBar: any
|
||||
private get $progressBar() {
|
||||
if (!this._$progressBar) {
|
||||
this._$progressBar = $(this.element.nativeElement)
|
||||
}
|
||||
return this._$progressBar
|
||||
}
|
||||
private _$progressBar: any
|
||||
}
|
||||
@@ -1,25 +1,26 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
|
||||
import { ElectronService } from './electron.service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
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> {
|
||||
if (this.imageCache[songID] == undefined) {
|
||||
const albumArtResult = await this.electronService.invoke('album-art', songID)
|
||||
if (albumArtResult) {
|
||||
this.imageCache[songID] = albumArtResult.base64Art
|
||||
} else {
|
||||
this.imageCache[songID] = null
|
||||
}
|
||||
}
|
||||
async getImage(songID: number): Promise<string | null> {
|
||||
if (this.imageCache[songID] == undefined) {
|
||||
const albumArtResult = await this.electronService.invoke('album-art', songID)
|
||||
if (albumArtResult) {
|
||||
this.imageCache[songID] = albumArtResult.base64Art
|
||||
} else {
|
||||
this.imageCache[songID] = null
|
||||
}
|
||||
}
|
||||
|
||||
return this.imageCache[songID]
|
||||
}
|
||||
return this.imageCache[songID]
|
||||
}
|
||||
}
|
||||
@@ -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 { NewDownload, DownloadProgress } from '../../../electron/shared/interfaces/download.interface'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DownloadService {
|
||||
|
||||
private downloadUpdatedEmitter = new EventEmitter<DownloadProgress>()
|
||||
private downloads: DownloadProgress[] = []
|
||||
private downloadUpdatedEmitter = new EventEmitter<DownloadProgress>()
|
||||
private downloads: DownloadProgress[] = []
|
||||
|
||||
constructor(private electronService: ElectronService) {
|
||||
this.electronService.receiveIPC('download-updated', result => {
|
||||
// Update <this.downloads> with result
|
||||
const thisDownloadIndex = this.downloads.findIndex(download => download.versionID == result.versionID)
|
||||
if (result.type == 'cancel') {
|
||||
this.downloads = this.downloads.filter(download => download.versionID != result.versionID)
|
||||
} else if (thisDownloadIndex == -1) {
|
||||
this.downloads.push(result)
|
||||
} else {
|
||||
this.downloads[thisDownloadIndex] = result
|
||||
}
|
||||
constructor(private electronService: ElectronService) {
|
||||
this.electronService.receiveIPC('download-updated', result => {
|
||||
// Update <this.downloads> with result
|
||||
const thisDownloadIndex = this.downloads.findIndex(download => download.versionID == result.versionID)
|
||||
if (result.type == 'cancel') {
|
||||
this.downloads = this.downloads.filter(download => download.versionID != result.versionID)
|
||||
} else if (thisDownloadIndex == -1) {
|
||||
this.downloads.push(result)
|
||||
} else {
|
||||
this.downloads[thisDownloadIndex] = result
|
||||
}
|
||||
|
||||
this.downloadUpdatedEmitter.emit(result)
|
||||
})
|
||||
}
|
||||
this.downloadUpdatedEmitter.emit(result)
|
||||
})
|
||||
}
|
||||
|
||||
get downloadCount() {
|
||||
return this.downloads.length
|
||||
}
|
||||
get downloadCount() {
|
||||
return this.downloads.length
|
||||
}
|
||||
|
||||
get completedCount() {
|
||||
return this.downloads.filter(download => download.type == 'done').length
|
||||
}
|
||||
get completedCount() {
|
||||
return this.downloads.filter(download => download.type == 'done').length
|
||||
}
|
||||
|
||||
get totalDownloadingPercent() {
|
||||
let total = 0
|
||||
let count = 0
|
||||
for (const download of this.downloads) {
|
||||
if (!download.stale) {
|
||||
total += download.percent
|
||||
count++
|
||||
}
|
||||
}
|
||||
return total / count
|
||||
}
|
||||
get totalDownloadingPercent() {
|
||||
let total = 0
|
||||
let count = 0
|
||||
for (const download of this.downloads) {
|
||||
if (!download.stale) {
|
||||
total += download.percent
|
||||
count++
|
||||
}
|
||||
}
|
||||
return total / count
|
||||
}
|
||||
|
||||
get anyErrorsExist() {
|
||||
return this.downloads.find(download => download.type == 'error') ? true : false
|
||||
}
|
||||
get anyErrorsExist() {
|
||||
return this.downloads.find(download => download.type == 'error') ? true : false
|
||||
}
|
||||
|
||||
addDownload(versionID: number, newDownload: NewDownload) {
|
||||
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
|
||||
this.downloads.forEach(download => download.stale = true)
|
||||
}
|
||||
this.electronService.sendIPC('download', { action: 'add', versionID, data: newDownload })
|
||||
}
|
||||
}
|
||||
addDownload(versionID: number, newDownload: NewDownload) {
|
||||
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
|
||||
this.downloads.forEach(download => download.stale = true)
|
||||
}
|
||||
this.electronService.sendIPC('download', { action: 'add', versionID, data: newDownload })
|
||||
}
|
||||
}
|
||||
|
||||
onDownloadUpdated(callback: (download: DownloadProgress) => void) {
|
||||
this.downloadUpdatedEmitter.subscribe(callback)
|
||||
}
|
||||
onDownloadUpdated(callback: (download: DownloadProgress) => void) {
|
||||
this.downloadUpdatedEmitter.subscribe(callback)
|
||||
}
|
||||
|
||||
cancelDownload(versionID: number) {
|
||||
const removedDownload = this.downloads.find(download => download.versionID == versionID)
|
||||
if (['error', 'done'].includes(removedDownload.type)) {
|
||||
this.downloads = this.downloads.filter(download => download.versionID != versionID)
|
||||
removedDownload.type = 'cancel'
|
||||
this.downloadUpdatedEmitter.emit(removedDownload)
|
||||
} else {
|
||||
this.electronService.sendIPC('download', { action: 'cancel', versionID })
|
||||
}
|
||||
}
|
||||
cancelDownload(versionID: number) {
|
||||
const removedDownload = this.downloads.find(download => download.versionID == versionID)
|
||||
if (['error', 'done'].includes(removedDownload.type)) {
|
||||
this.downloads = this.downloads.filter(download => download.versionID != versionID)
|
||||
removedDownload.type = 'cancel'
|
||||
this.downloadUpdatedEmitter.emit(removedDownload)
|
||||
} else {
|
||||
this.electronService.sendIPC('download', { action: 'cancel', versionID })
|
||||
}
|
||||
}
|
||||
|
||||
cancelCompleted() {
|
||||
for (const download of this.downloads) {
|
||||
if (download.type == 'done') {
|
||||
this.cancelDownload(download.versionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
cancelCompleted() {
|
||||
for (const download of this.downloads) {
|
||||
if (download.type == 'done') {
|
||||
this.cancelDownload(download.versionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
retryDownload(versionID: number) {
|
||||
this.electronService.sendIPC('download', { action: 'retry', versionID })
|
||||
}
|
||||
retryDownload(versionID: number) {
|
||||
this.electronService.sendIPC('download', { action: 'retry', versionID })
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
// the resulting javascript file will look as if you never imported the module at all.
|
||||
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')
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ElectronService {
|
||||
electron: typeof electron
|
||||
electron: typeof electron
|
||||
|
||||
get isElectron() {
|
||||
return !!(window && window.process && window.process.type)
|
||||
}
|
||||
get isElectron() {
|
||||
return !!(window && window.process && window.process.type)
|
||||
}
|
||||
|
||||
constructor() {
|
||||
if (this.isElectron) {
|
||||
this.electron = window.require('electron')
|
||||
this.receiveIPC('log', results => results.forEach(result => console.log(result)))
|
||||
}
|
||||
}
|
||||
constructor() {
|
||||
if (this.isElectron) {
|
||||
this.electron = window.require('electron')
|
||||
this.receiveIPC('log', results => results.forEach(result => console.log(result)))
|
||||
}
|
||||
}
|
||||
|
||||
get currentWindow() {
|
||||
return getCurrentWindow()
|
||||
}
|
||||
get currentWindow() {
|
||||
return getCurrentWindow()
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls an async function in the main process.
|
||||
* @param event The name of the IPC event to invoke.
|
||||
* @param data The data object to send across IPC.
|
||||
* @returns A promise that resolves to the output data.
|
||||
*/
|
||||
async invoke<E extends keyof IPCInvokeEvents>(event: E, data: IPCInvokeEvents[E]['input']) {
|
||||
return this.electron.ipcRenderer.invoke(event, data) as Promise<IPCInvokeEvents[E]['output']>
|
||||
}
|
||||
/**
|
||||
* Calls an async function in the main process.
|
||||
* @param event The name of the IPC event to invoke.
|
||||
* @param data The data object to send across IPC.
|
||||
* @returns A promise that resolves to the output data.
|
||||
*/
|
||||
async invoke<E extends keyof IPCInvokeEvents>(event: E, data: IPCInvokeEvents[E]['input']) {
|
||||
return this.electron.ipcRenderer.invoke(event, data) as Promise<IPCInvokeEvents[E]['output']>
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an IPC message to the main process.
|
||||
* @param event The name of the IPC event to send.
|
||||
* @param data The data object to send across IPC.
|
||||
*/
|
||||
sendIPC<E extends keyof IPCEmitEvents>(event: E, data: IPCEmitEvents[E]) {
|
||||
this.electron.ipcRenderer.send(event, data)
|
||||
}
|
||||
/**
|
||||
* Sends an IPC message to the main process.
|
||||
* @param event The name of the IPC event to send.
|
||||
* @param data The data object to send across IPC.
|
||||
*/
|
||||
sendIPC<E extends keyof IPCEmitEvents>(event: E, data: IPCEmitEvents[E]) {
|
||||
this.electron.ipcRenderer.send(event, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives an IPC message from the main process.
|
||||
* @param event The name of the IPC event to receive.
|
||||
* @param callback The data object to receive across IPC.
|
||||
*/
|
||||
receiveIPC<E extends keyof IPCEmitEvents>(event: E, callback: (result: IPCEmitEvents[E]) => void) {
|
||||
this.electron.ipcRenderer.on(event, (_event, ...results) => {
|
||||
callback(results[0])
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Receives an IPC message from the main process.
|
||||
* @param event The name of the IPC event to receive.
|
||||
* @param callback The data object to receive across IPC.
|
||||
*/
|
||||
receiveIPC<E extends keyof IPCEmitEvents>(event: E, callback: (result: IPCEmitEvents[E]) => void) {
|
||||
this.electron.ipcRenderer.on(event, (_event, ...results) => {
|
||||
callback(results[0])
|
||||
})
|
||||
}
|
||||
|
||||
quit() {
|
||||
app.exit()
|
||||
}
|
||||
quit() {
|
||||
app.exit()
|
||||
}
|
||||
|
||||
openFolder(filepath: string) {
|
||||
this.electron.shell.openPath(filepath)
|
||||
}
|
||||
openFolder(filepath: string) {
|
||||
this.electron.shell.openPath(filepath)
|
||||
}
|
||||
|
||||
showFolder(filepath: string) {
|
||||
this.electron.shell.showItemInFolder(filepath)
|
||||
}
|
||||
showFolder(filepath: string) {
|
||||
this.electron.shell.showItemInFolder(filepath)
|
||||
}
|
||||
|
||||
showOpenDialog(options: Electron.OpenDialogOptions) {
|
||||
return dialog.showOpenDialog(this.currentWindow, options)
|
||||
}
|
||||
showOpenDialog(options: Electron.OpenDialogOptions) {
|
||||
return dialog.showOpenDialog(this.currentWindow, options)
|
||||
}
|
||||
|
||||
get defaultSession() {
|
||||
return session.defaultSession
|
||||
}
|
||||
get defaultSession() {
|
||||
return session.defaultSession
|
||||
}
|
||||
}
|
||||
@@ -1,118 +1,119 @@
|
||||
import { Injectable, EventEmitter } from '@angular/core'
|
||||
import { ElectronService } from './electron.service'
|
||||
import { EventEmitter, Injectable } from '@angular/core'
|
||||
|
||||
import { SongResult, SongSearch } from 'src/electron/shared/interfaces/search.interface'
|
||||
import { VersionResult } from 'src/electron/shared/interfaces/songDetails.interface'
|
||||
import { ElectronService } from './electron.service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SearchService {
|
||||
|
||||
private resultsChangedEmitter = new EventEmitter<SongResult[]>() // For when any results change
|
||||
private newResultsEmitter = new EventEmitter<SongResult[]>() // For when a new search happens
|
||||
private errorStateEmitter = new EventEmitter<boolean>() // To indicate the search's error state
|
||||
private results: SongResult[] = []
|
||||
private awaitingResults = false
|
||||
private currentQuery: SongSearch
|
||||
private _allResultsVisible = true
|
||||
private resultsChangedEmitter = new EventEmitter<SongResult[]>() // For when any results change
|
||||
private newResultsEmitter = new EventEmitter<SongResult[]>() // For when a new search happens
|
||||
private errorStateEmitter = new EventEmitter<boolean>() // To indicate the search's error state
|
||||
private results: SongResult[] = []
|
||||
private awaitingResults = false
|
||||
private currentQuery: SongSearch
|
||||
private _allResultsVisible = true
|
||||
|
||||
constructor(private electronService: ElectronService) { }
|
||||
constructor(private electronService: ElectronService) { }
|
||||
|
||||
async newSearch(query: SongSearch) {
|
||||
if (this.awaitingResults) { return }
|
||||
this.awaitingResults = true
|
||||
this.currentQuery = query
|
||||
try {
|
||||
this.results = this.trimLastChart(await this.electronService.invoke('song-search', this.currentQuery))
|
||||
this.errorStateEmitter.emit(false)
|
||||
} catch (err) {
|
||||
this.results = []
|
||||
console.log(err.message)
|
||||
this.errorStateEmitter.emit(true)
|
||||
}
|
||||
this.awaitingResults = false
|
||||
async newSearch(query: SongSearch) {
|
||||
if (this.awaitingResults) { return }
|
||||
this.awaitingResults = true
|
||||
this.currentQuery = query
|
||||
try {
|
||||
this.results = this.trimLastChart(await this.electronService.invoke('song-search', this.currentQuery))
|
||||
this.errorStateEmitter.emit(false)
|
||||
} catch (err) {
|
||||
this.results = []
|
||||
console.log(err.message)
|
||||
this.errorStateEmitter.emit(true)
|
||||
}
|
||||
this.awaitingResults = false
|
||||
|
||||
this.newResultsEmitter.emit(this.results)
|
||||
this.resultsChangedEmitter.emit(this.results)
|
||||
}
|
||||
this.newResultsEmitter.emit(this.results)
|
||||
this.resultsChangedEmitter.emit(this.results)
|
||||
}
|
||||
|
||||
isLoading() {
|
||||
return this.awaitingResults
|
||||
}
|
||||
isLoading() {
|
||||
return this.awaitingResults
|
||||
}
|
||||
|
||||
/**
|
||||
* Event emitted when new search results are returned
|
||||
* or when more results are added to an existing search.
|
||||
* (emitted after `onNewSearch`)
|
||||
*/
|
||||
onSearchChanged(callback: (results: SongResult[]) => void) {
|
||||
this.resultsChangedEmitter.subscribe(callback)
|
||||
}
|
||||
/**
|
||||
* Event emitted when new search results are returned
|
||||
* or when more results are added to an existing search.
|
||||
* (emitted after `onNewSearch`)
|
||||
*/
|
||||
onSearchChanged(callback: (results: SongResult[]) => void) {
|
||||
this.resultsChangedEmitter.subscribe(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Event emitted when a new search query is typed in.
|
||||
* (emitted before `onSearchChanged`)
|
||||
*/
|
||||
onNewSearch(callback: (results: SongResult[]) => void) {
|
||||
this.newResultsEmitter.subscribe(callback)
|
||||
}
|
||||
/**
|
||||
* Event emitted when a new search query is typed in.
|
||||
* (emitted before `onSearchChanged`)
|
||||
*/
|
||||
onNewSearch(callback: (results: SongResult[]) => void) {
|
||||
this.newResultsEmitter.subscribe(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Event emitted when the error state of the search changes.
|
||||
* (emitted before `onSearchChanged`)
|
||||
*/
|
||||
onSearchErrorStateUpdate(callback: (isError: boolean) => void) {
|
||||
this.errorStateEmitter.subscribe(callback)
|
||||
}
|
||||
/**
|
||||
* Event emitted when the error state of the search changes.
|
||||
* (emitted before `onSearchChanged`)
|
||||
*/
|
||||
onSearchErrorStateUpdate(callback: (isError: boolean) => void) {
|
||||
this.errorStateEmitter.subscribe(callback)
|
||||
}
|
||||
|
||||
get resultCount() {
|
||||
return this.results.length
|
||||
}
|
||||
get resultCount() {
|
||||
return this.results.length
|
||||
}
|
||||
|
||||
async updateScroll() {
|
||||
if (!this.awaitingResults && !this._allResultsVisible) {
|
||||
this.awaitingResults = true
|
||||
this.currentQuery.offset += 50
|
||||
this.results.push(...this.trimLastChart(await this.electronService.invoke('song-search', this.currentQuery)))
|
||||
this.awaitingResults = false
|
||||
async updateScroll() {
|
||||
if (!this.awaitingResults && !this._allResultsVisible) {
|
||||
this.awaitingResults = true
|
||||
this.currentQuery.offset += 50
|
||||
this.results.push(...this.trimLastChart(await this.electronService.invoke('song-search', this.currentQuery)))
|
||||
this.awaitingResults = false
|
||||
|
||||
this.resultsChangedEmitter.emit(this.results)
|
||||
}
|
||||
}
|
||||
this.resultsChangedEmitter.emit(this.results)
|
||||
}
|
||||
}
|
||||
|
||||
trimLastChart(results: SongResult[]) {
|
||||
if (results.length > 50) {
|
||||
results.splice(50, 1)
|
||||
this._allResultsVisible = false
|
||||
} else {
|
||||
this._allResultsVisible = true
|
||||
}
|
||||
trimLastChart(results: SongResult[]) {
|
||||
if (results.length > 50) {
|
||||
results.splice(50, 1)
|
||||
this._allResultsVisible = false
|
||||
} else {
|
||||
this._allResultsVisible = true
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
get allResultsVisible() {
|
||||
return this._allResultsVisible
|
||||
}
|
||||
get allResultsVisible() {
|
||||
return this._allResultsVisible
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders `versionResults` by lastModified date, but prefer the
|
||||
* non-pack version if it's only a few days older.
|
||||
*/
|
||||
sortChart(versionResults: VersionResult[]) {
|
||||
const dates: { [versionID: number]: number } = {}
|
||||
versionResults.forEach(version => dates[version.versionID] = new Date(version.lastModified).getTime())
|
||||
versionResults.sort((v1, v2) => {
|
||||
const diff = dates[v2.versionID] - dates[v1.versionID]
|
||||
if (Math.abs(diff) < 6.048e+8 && v1.driveData.inChartPack != v2.driveData.inChartPack) {
|
||||
if (v1.driveData.inChartPack) {
|
||||
return 1 // prioritize v2
|
||||
} else {
|
||||
return -1 // prioritize v1
|
||||
}
|
||||
} else {
|
||||
return diff
|
||||
}
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Orders `versionResults` by lastModified date, but prefer the
|
||||
* non-pack version if it's only a few days older.
|
||||
*/
|
||||
sortChart(versionResults: VersionResult[]) {
|
||||
const dates: { [versionID: number]: number } = {}
|
||||
versionResults.forEach(version => dates[version.versionID] = new Date(version.lastModified).getTime())
|
||||
versionResults.sort((v1, v2) => {
|
||||
const diff = dates[v2.versionID] - dates[v1.versionID]
|
||||
if (Math.abs(diff) < 6.048e+8 && v1.driveData.inChartPack != v2.driveData.inChartPack) {
|
||||
if (v1.driveData.inChartPack) {
|
||||
return 1 // prioritize v2
|
||||
} else {
|
||||
return -1 // prioritize v1
|
||||
}
|
||||
} else {
|
||||
return diff
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 { SearchService } from './search.service'
|
||||
|
||||
// Note: this class prevents event cycles by only emitting events if the checkbox changes
|
||||
|
||||
interface SelectionEvent {
|
||||
songID: number
|
||||
selected: boolean
|
||||
songID: number
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SelectionService {
|
||||
|
||||
private searchResults: SongResult[] = []
|
||||
private searchResults: SongResult[] = []
|
||||
|
||||
private selectAllChangedEmitter = new EventEmitter<boolean>()
|
||||
private selectionChangedCallbacks: { [songID: number]: (selection: boolean) => void } = {}
|
||||
private selectAllChangedEmitter = new EventEmitter<boolean>()
|
||||
private selectionChangedCallbacks: { [songID: number]: (selection: boolean) => void } = {}
|
||||
|
||||
private allSelected = false
|
||||
private selections: { [songID: number]: boolean | undefined } = {}
|
||||
private allSelected = false
|
||||
private selections: { [songID: number]: boolean | undefined } = {}
|
||||
|
||||
constructor(searchService: SearchService) {
|
||||
searchService.onSearchChanged((results) => {
|
||||
this.searchResults = results
|
||||
if (this.allSelected) {
|
||||
this.selectAll() // Select newly added rows if allSelected
|
||||
}
|
||||
})
|
||||
constructor(searchService: SearchService) {
|
||||
searchService.onSearchChanged(results => {
|
||||
this.searchResults = results
|
||||
if (this.allSelected) {
|
||||
this.selectAll() // Select newly added rows if allSelected
|
||||
}
|
||||
})
|
||||
|
||||
searchService.onNewSearch((results) => {
|
||||
this.searchResults = results
|
||||
this.selectionChangedCallbacks = {}
|
||||
this.selections = {}
|
||||
this.selectAllChangedEmitter.emit(false)
|
||||
})
|
||||
}
|
||||
searchService.onNewSearch(results => {
|
||||
this.searchResults = results
|
||||
this.selectionChangedCallbacks = {}
|
||||
this.selections = {}
|
||||
this.selectAllChangedEmitter.emit(false)
|
||||
})
|
||||
}
|
||||
|
||||
getSelectedResults() {
|
||||
return this.searchResults.filter(result => this.selections[result.id] == true)
|
||||
}
|
||||
getSelectedResults() {
|
||||
return this.searchResults.filter(result => this.selections[result.id] == true)
|
||||
}
|
||||
|
||||
onSelectAllChanged(callback: (selected: boolean) => void) {
|
||||
this.selectAllChangedEmitter.subscribe(callback)
|
||||
}
|
||||
onSelectAllChanged(callback: (selected: boolean) => void) {
|
||||
this.selectAllChangedEmitter.subscribe(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an event when the selection for `songID` needs to change.
|
||||
* (note: only one emitter can be registered per `songID`)
|
||||
*/
|
||||
onSelectionChanged(songID: number, callback: (selection: boolean) => void) {
|
||||
this.selectionChangedCallbacks[songID] = callback
|
||||
}
|
||||
/**
|
||||
* Emits an event when the selection for `songID` needs to change.
|
||||
* (note: only one emitter can be registered per `songID`)
|
||||
*/
|
||||
onSelectionChanged(songID: number, callback: (selection: boolean) => void) {
|
||||
this.selectionChangedCallbacks[songID] = callback
|
||||
}
|
||||
|
||||
|
||||
deselectAll() {
|
||||
if (this.allSelected) {
|
||||
this.allSelected = false
|
||||
this.selectAllChangedEmitter.emit(false)
|
||||
}
|
||||
deselectAll() {
|
||||
if (this.allSelected) {
|
||||
this.allSelected = 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() {
|
||||
if (!this.allSelected) {
|
||||
this.allSelected = true
|
||||
this.selectAllChangedEmitter.emit(true)
|
||||
}
|
||||
selectAll() {
|
||||
if (!this.allSelected) {
|
||||
this.allSelected = 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) {
|
||||
if (this.selections[songID]) {
|
||||
this.selections[songID] = false
|
||||
this.selectionChangedCallbacks[songID](false)
|
||||
}
|
||||
}
|
||||
deselectSong(songID: number) {
|
||||
if (this.selections[songID]) {
|
||||
this.selections[songID] = false
|
||||
this.selectionChangedCallbacks[songID](false)
|
||||
}
|
||||
}
|
||||
|
||||
selectSong(songID: number) {
|
||||
if (!this.selections[songID]) {
|
||||
this.selections[songID] = true
|
||||
this.selectionChangedCallbacks[songID](true)
|
||||
}
|
||||
}
|
||||
selectSong(songID: number) {
|
||||
if (!this.selections[songID]) {
|
||||
this.selections[songID] = true
|
||||
this.selectionChangedCallbacks[songID](true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,81 +1,82 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ElectronService } from './electron.service'
|
||||
|
||||
import { Settings } from 'src/electron/shared/Settings'
|
||||
import { ElectronService } from './electron.service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SettingsService {
|
||||
readonly builtinThemes = ['Default', 'Dark']
|
||||
readonly builtinThemes = ['Default', 'Dark']
|
||||
|
||||
private settings: Settings
|
||||
private currentThemeLink: HTMLLinkElement
|
||||
private settings: Settings
|
||||
private currentThemeLink: HTMLLinkElement
|
||||
|
||||
constructor(private electronService: ElectronService) { }
|
||||
constructor(private electronService: ElectronService) { }
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = await this.electronService.invoke('get-settings', undefined)
|
||||
if (this.settings.theme != this.builtinThemes[0]) {
|
||||
this.changeTheme(this.settings.theme)
|
||||
}
|
||||
}
|
||||
async loadSettings() {
|
||||
this.settings = await this.electronService.invoke('get-settings', undefined)
|
||||
if (this.settings.theme != this.builtinThemes[0]) {
|
||||
this.changeTheme(this.settings.theme)
|
||||
}
|
||||
}
|
||||
|
||||
saveSettings() {
|
||||
this.electronService.sendIPC('set-settings', this.settings)
|
||||
}
|
||||
saveSettings() {
|
||||
this.electronService.sendIPC('set-settings', this.settings)
|
||||
}
|
||||
|
||||
changeTheme(theme: string) {
|
||||
if (this.currentThemeLink != undefined) this.currentThemeLink.remove()
|
||||
if (theme == 'Default') { return }
|
||||
changeTheme(theme: string) {
|
||||
if (this.currentThemeLink != undefined) this.currentThemeLink.remove()
|
||||
if (theme == 'Default') { return }
|
||||
|
||||
const link = document.createElement('link')
|
||||
link.type = 'text/css'
|
||||
link.rel = 'stylesheet'
|
||||
link.href = `./assets/themes/${theme.toLowerCase()}.css`
|
||||
this.currentThemeLink = document.head.appendChild(link)
|
||||
}
|
||||
const link = document.createElement('link')
|
||||
link.type = 'text/css'
|
||||
link.rel = 'stylesheet'
|
||||
link.href = `./assets/themes/${theme.toLowerCase()}.css`
|
||||
this.currentThemeLink = document.head.appendChild(link)
|
||||
}
|
||||
|
||||
async getCacheSize() {
|
||||
return this.electronService.defaultSession.getCacheSize()
|
||||
}
|
||||
async getCacheSize() {
|
||||
return this.electronService.defaultSession.getCacheSize()
|
||||
}
|
||||
|
||||
async clearCache() {
|
||||
this.saveSettings()
|
||||
await this.electronService.defaultSession.clearCache()
|
||||
await this.electronService.invoke('clear-cache', undefined)
|
||||
}
|
||||
async clearCache() {
|
||||
this.saveSettings()
|
||||
await this.electronService.defaultSession.clearCache()
|
||||
await this.electronService.invoke('clear-cache', undefined)
|
||||
}
|
||||
|
||||
// Individual getters/setters
|
||||
get libraryDirectory() {
|
||||
return this.settings.libraryPath
|
||||
}
|
||||
set libraryDirectory(newValue: string) {
|
||||
this.settings.libraryPath = newValue
|
||||
this.saveSettings()
|
||||
}
|
||||
// Individual getters/setters
|
||||
get libraryDirectory() {
|
||||
return this.settings.libraryPath
|
||||
}
|
||||
set libraryDirectory(newValue: string) {
|
||||
this.settings.libraryPath = newValue
|
||||
this.saveSettings()
|
||||
}
|
||||
|
||||
get downloadVideos() {
|
||||
return this.settings.downloadVideos
|
||||
}
|
||||
set downloadVideos(isChecked) {
|
||||
this.settings.downloadVideos = isChecked
|
||||
this.saveSettings()
|
||||
}
|
||||
get downloadVideos() {
|
||||
return this.settings.downloadVideos
|
||||
}
|
||||
set downloadVideos(isChecked) {
|
||||
this.settings.downloadVideos = isChecked
|
||||
this.saveSettings()
|
||||
}
|
||||
|
||||
get theme() {
|
||||
return this.settings.theme
|
||||
}
|
||||
set theme(newValue: string) {
|
||||
this.settings.theme = newValue
|
||||
this.changeTheme(newValue)
|
||||
this.saveSettings()
|
||||
}
|
||||
get theme() {
|
||||
return this.settings.theme
|
||||
}
|
||||
set theme(newValue: string) {
|
||||
this.settings.theme = newValue
|
||||
this.changeTheme(newValue)
|
||||
this.saveSettings()
|
||||
}
|
||||
|
||||
get rateLimitDelay() {
|
||||
return this.settings.rateLimitDelay
|
||||
}
|
||||
set rateLimitDelay(delay: number) {
|
||||
this.settings.rateLimitDelay = delay
|
||||
this.saveSettings()
|
||||
}
|
||||
get rateLimitDelay() {
|
||||
return this.settings.rateLimitDelay
|
||||
}
|
||||
set rateLimitDelay(delay: number) {
|
||||
this.settings.rateLimitDelay = delay
|
||||
this.saveSettings()
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,29 @@
|
||||
import { RouteReuseStrategy, ActivatedRouteSnapshot, DetachedRouteHandle } from '@angular/router'
|
||||
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.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TabPersistStrategy extends RouteReuseStrategy {
|
||||
private handles: { [path: string]: DetachedRouteHandle } = {}
|
||||
private handles: { [path: string]: DetachedRouteHandle } = {}
|
||||
|
||||
shouldDetach(route: ActivatedRouteSnapshot) {
|
||||
return route.data.shouldReuse || false
|
||||
}
|
||||
store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle) {
|
||||
if (route.data.shouldReuse) {
|
||||
this.handles[route.routeConfig.path] = handle
|
||||
}
|
||||
}
|
||||
shouldAttach(route: ActivatedRouteSnapshot) {
|
||||
return !!route.routeConfig && !!this.handles[route.routeConfig.path]
|
||||
}
|
||||
retrieve(route: ActivatedRouteSnapshot) {
|
||||
if (!route.routeConfig) return null
|
||||
return this.handles[route.routeConfig.path]
|
||||
}
|
||||
shouldReuseRoute(future: ActivatedRouteSnapshot) {
|
||||
return future.data.shouldReuse || false
|
||||
}
|
||||
shouldDetach(route: ActivatedRouteSnapshot) {
|
||||
return route.data.shouldReuse || false
|
||||
}
|
||||
store(route: ActivatedRouteSnapshot, handle: DetachedRouteHandle) {
|
||||
if (route.data.shouldReuse) {
|
||||
this.handles[route.routeConfig.path] = handle
|
||||
}
|
||||
}
|
||||
shouldAttach(route: ActivatedRouteSnapshot) {
|
||||
return !!route.routeConfig && !!this.handles[route.routeConfig.path]
|
||||
}
|
||||
retrieve(route: ActivatedRouteSnapshot) {
|
||||
if (!route.routeConfig) return null
|
||||
return this.handles[route.routeConfig.path]
|
||||
}
|
||||
shouldReuseRoute(future: ActivatedRouteSnapshot) {
|
||||
return future.data.shouldReuse || false
|
||||
}
|
||||
}
|
||||
@@ -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 { 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)
|
||||
|
||||
@@ -12,30 +13,30 @@ const readdir = promisify(_readdir)
|
||||
* Handles the 'clear-cache' event.
|
||||
*/
|
||||
class ClearCacheHandler implements IPCInvokeHandler<'clear-cache'> {
|
||||
event: 'clear-cache' = 'clear-cache'
|
||||
event = 'clear-cache' as const
|
||||
|
||||
/**
|
||||
* Deletes all the files under `tempPath`
|
||||
*/
|
||||
async handler() {
|
||||
let files: Dirent[]
|
||||
try {
|
||||
files = await readdir(tempPath, { withFileTypes: true })
|
||||
} catch (err) {
|
||||
devLog('Failed to read cache: ', err)
|
||||
return
|
||||
}
|
||||
/**
|
||||
* Deletes all the files under `tempPath`
|
||||
*/
|
||||
async handler() {
|
||||
let files: Dirent[]
|
||||
try {
|
||||
files = await readdir(tempPath, { withFileTypes: true })
|
||||
} catch (err) {
|
||||
devLog('Failed to read cache: ', err)
|
||||
return
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
devLog(`Deleting ${file.isFile() ? 'file' : 'folder'}: ${join(tempPath, file.name)}`)
|
||||
await rimraf(join(tempPath, file.name))
|
||||
} catch (err) {
|
||||
devLog(`Failed to delete ${file.isFile() ? 'file' : 'folder'}: `, inspect(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const file of files) {
|
||||
try {
|
||||
devLog(`Deleting ${file.isFile() ? 'file' : 'folder'}: ${join(tempPath, file.name)}`)
|
||||
await rimraf(join(tempPath, file.name))
|
||||
} catch (err) {
|
||||
devLog(`Failed to delete ${file.isFile() ? 'file' : 'folder'}: `, inspect(err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const clearCacheHandler = new ClearCacheHandler()
|
||||
@@ -1,18 +1,19 @@
|
||||
import { IPCEmitHandler } from '../shared/IPCHandler'
|
||||
import { shell } from 'electron'
|
||||
|
||||
import { IPCEmitHandler } from '../shared/IPCHandler'
|
||||
|
||||
/**
|
||||
* Handles the 'open-url' event.
|
||||
*/
|
||||
class OpenURLHandler implements IPCEmitHandler<'open-url'> {
|
||||
event: 'open-url' = 'open-url'
|
||||
event = 'open-url' as const
|
||||
|
||||
/**
|
||||
* Opens `url` in the default browser.
|
||||
*/
|
||||
handler(url: string) {
|
||||
shell.openExternal(url)
|
||||
}
|
||||
/**
|
||||
* Opens `url` in the default browser.
|
||||
*/
|
||||
handler(url: string) {
|
||||
shell.openExternal(url)
|
||||
}
|
||||
}
|
||||
|
||||
export const openURLHandler = new OpenURLHandler()
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as fs from 'fs'
|
||||
import { dataPath, tempPath, themesPath, settingsPath } from '../shared/Paths'
|
||||
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'
|
||||
|
||||
const exists = promisify(fs.exists)
|
||||
@@ -11,81 +12,81 @@ const writeFile = promisify(fs.writeFile)
|
||||
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
handler(newSettings: Settings) {
|
||||
settings = newSettings
|
||||
SetSettingsHandler.saveSettings(settings)
|
||||
}
|
||||
/**
|
||||
* Updates Bridge's settings object to `newSettings` and saves them to Bridge's data directories.
|
||||
*/
|
||||
handler(newSettings: Settings) {
|
||||
settings = newSettings
|
||||
SetSettingsHandler.saveSettings(settings)
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves `settings` to Bridge's data directories.
|
||||
*/
|
||||
static async saveSettings(settings: Settings) {
|
||||
const settingsJSON = JSON.stringify(settings, undefined, 2)
|
||||
await writeFile(settingsPath, settingsJSON, 'utf8')
|
||||
}
|
||||
/**
|
||||
* Saves `settings` to Bridge's data directories.
|
||||
*/
|
||||
static async saveSettings(settings: Settings) {
|
||||
const settingsJSON = JSON.stringify(settings, undefined, 2)
|
||||
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()
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { IPCEmitHandler, IPCInvokeHandler } from '../shared/IPCHandler'
|
||||
import { autoUpdater, UpdateInfo } from 'electron-updater'
|
||||
|
||||
import { emitIPCEvent } from '../main'
|
||||
import { IPCEmitHandler, IPCInvokeHandler } from '../shared/IPCHandler'
|
||||
|
||||
export interface UpdateProgress {
|
||||
bytesPerSecond: number
|
||||
percent: number
|
||||
transferred: number
|
||||
total: number
|
||||
bytesPerSecond: number
|
||||
percent: number
|
||||
transferred: number
|
||||
total: number
|
||||
}
|
||||
|
||||
let updateAvailable = false
|
||||
@@ -15,44 +16,44 @@ let updateAvailable = false
|
||||
* Checks for updates when the program is launched.
|
||||
*/
|
||||
class UpdateChecker implements IPCEmitHandler<'retry-update'> {
|
||||
event: 'retry-update' = 'retry-update'
|
||||
event = 'retry-update' as const
|
||||
|
||||
/**
|
||||
* Check for an update.
|
||||
*/
|
||||
handler() {
|
||||
this.checkForUpdates()
|
||||
}
|
||||
constructor() {
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.logger = null
|
||||
this.registerUpdaterListeners()
|
||||
}
|
||||
|
||||
constructor() {
|
||||
autoUpdater.autoDownload = false
|
||||
autoUpdater.logger = null
|
||||
this.registerUpdaterListeners()
|
||||
}
|
||||
/**
|
||||
* Check for an update.
|
||||
*/
|
||||
handler() {
|
||||
this.checkForUpdates()
|
||||
}
|
||||
|
||||
checkForUpdates() {
|
||||
autoUpdater.checkForUpdates().catch(reason => {
|
||||
updateAvailable = null
|
||||
emitIPCEvent('update-error', reason)
|
||||
})
|
||||
}
|
||||
checkForUpdates() {
|
||||
autoUpdater.checkForUpdates().catch(reason => {
|
||||
updateAvailable = null
|
||||
emitIPCEvent('update-error', reason)
|
||||
})
|
||||
}
|
||||
|
||||
private registerUpdaterListeners() {
|
||||
autoUpdater.on('error', (err: Error) => {
|
||||
updateAvailable = null
|
||||
emitIPCEvent('update-error', err)
|
||||
})
|
||||
private registerUpdaterListeners() {
|
||||
autoUpdater.on('error', (err: Error) => {
|
||||
updateAvailable = null
|
||||
emitIPCEvent('update-error', err)
|
||||
})
|
||||
|
||||
autoUpdater.on('update-available', (info: UpdateInfo) => {
|
||||
updateAvailable = true
|
||||
emitIPCEvent('update-available', info)
|
||||
})
|
||||
autoUpdater.on('update-available', (info: UpdateInfo) => {
|
||||
updateAvailable = true
|
||||
emitIPCEvent('update-available', info)
|
||||
})
|
||||
|
||||
autoUpdater.on('update-not-available', (info: UpdateInfo) => {
|
||||
updateAvailable = false
|
||||
emitIPCEvent('update-available', null)
|
||||
})
|
||||
}
|
||||
autoUpdater.on('update-not-available', (info: UpdateInfo) => {
|
||||
updateAvailable = false
|
||||
emitIPCEvent('update-available', null)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const updateChecker = new UpdateChecker()
|
||||
@@ -61,14 +62,14 @@ export const updateChecker = new UpdateChecker()
|
||||
* Handles the 'get-update-available' event.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
handler() {
|
||||
return updateAvailable
|
||||
}
|
||||
/**
|
||||
* @returns `true` if an update is available.
|
||||
*/
|
||||
handler() {
|
||||
return updateAvailable
|
||||
}
|
||||
}
|
||||
|
||||
export const getUpdateAvailableHandler = new GetUpdateAvailableHandler()
|
||||
@@ -77,14 +78,14 @@ export const getUpdateAvailableHandler = new GetUpdateAvailableHandler()
|
||||
* Handles the 'get-current-version' event.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
handler() {
|
||||
return autoUpdater.currentVersion.raw
|
||||
}
|
||||
/**
|
||||
* @returns the current version of Bridge.
|
||||
*/
|
||||
handler() {
|
||||
return autoUpdater.currentVersion.raw
|
||||
}
|
||||
}
|
||||
|
||||
export const getCurrentVersionHandler = new GetCurrentVersionHandler()
|
||||
@@ -93,26 +94,26 @@ export const getCurrentVersionHandler = new GetCurrentVersionHandler()
|
||||
* Handles the 'download-update' event.
|
||||
*/
|
||||
class DownloadUpdateHandler implements IPCEmitHandler<'download-update'> {
|
||||
event: 'download-update' = 'download-update'
|
||||
downloading = false
|
||||
event = 'download-update' as const
|
||||
downloading = false
|
||||
|
||||
/**
|
||||
* Begins the process of downloading the latest update.
|
||||
*/
|
||||
handler() {
|
||||
if (this.downloading) { return }
|
||||
this.downloading = true
|
||||
/**
|
||||
* Begins the process of downloading the latest update.
|
||||
*/
|
||||
handler() {
|
||||
if (this.downloading) { return }
|
||||
this.downloading = true
|
||||
|
||||
autoUpdater.on('download-progress', (updateProgress: UpdateProgress) => {
|
||||
emitIPCEvent('update-progress', updateProgress)
|
||||
})
|
||||
autoUpdater.on('download-progress', (updateProgress: UpdateProgress) => {
|
||||
emitIPCEvent('update-progress', updateProgress)
|
||||
})
|
||||
|
||||
autoUpdater.on('update-downloaded', () => {
|
||||
emitIPCEvent('update-downloaded', undefined)
|
||||
})
|
||||
autoUpdater.on('update-downloaded', () => {
|
||||
emitIPCEvent('update-downloaded', undefined)
|
||||
})
|
||||
|
||||
autoUpdater.downloadUpdate()
|
||||
}
|
||||
autoUpdater.downloadUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
export const downloadUpdateHandler = new DownloadUpdateHandler()
|
||||
@@ -121,14 +122,14 @@ export const downloadUpdateHandler = new DownloadUpdateHandler()
|
||||
* Handles the 'quit-and-install' event.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
handler() {
|
||||
autoUpdater.quitAndInstall() // autoUpdater installs a downloaded update on the next program restart by default
|
||||
}
|
||||
/**
|
||||
* Immediately closes the application and installs the update.
|
||||
*/
|
||||
handler() {
|
||||
autoUpdater.quitAndInstall() // autoUpdater installs a downloaded update on the next program restart by default
|
||||
}
|
||||
}
|
||||
|
||||
export const quitAndInstallHandler = new QuitAndInstallHandler()
|
||||
@@ -1,20 +1,20 @@
|
||||
import { IPCInvokeHandler } from '../../shared/IPCHandler'
|
||||
import { AlbumArtResult } from '../../shared/interfaces/songDetails.interface'
|
||||
import { IPCInvokeHandler } from '../../shared/IPCHandler'
|
||||
import { serverURL } from '../../shared/Paths'
|
||||
|
||||
/**
|
||||
* Handles the 'album-art' event.
|
||||
*/
|
||||
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`.
|
||||
*/
|
||||
async handler(songID: number): Promise<AlbumArtResult> {
|
||||
const response = await fetch(`https://${serverURL}/api/data/album-art/${songID}`)
|
||||
return await response.json()
|
||||
}
|
||||
/**
|
||||
* @returns an `AlbumArtResult` object containing the album art for the song with `songID`.
|
||||
*/
|
||||
async handler(songID: number): Promise<AlbumArtResult> {
|
||||
const response = await fetch(`https://${serverURL}/api/data/album-art/${songID}`)
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
export const albumArtHandler = new AlbumArtHandler()
|
||||
@@ -1,20 +1,20 @@
|
||||
import { IPCInvokeHandler } from '../../shared/IPCHandler'
|
||||
import { VersionResult } from '../../shared/interfaces/songDetails.interface'
|
||||
import { IPCInvokeHandler } from '../../shared/IPCHandler'
|
||||
import { serverURL } from '../../shared/Paths'
|
||||
|
||||
/**
|
||||
* Handles the 'batch-song-details' event.
|
||||
*/
|
||||
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`.
|
||||
*/
|
||||
async handler(songIDs: number[]): Promise<VersionResult[]> {
|
||||
const response = await fetch(`https://${serverURL}/api/data/song-versions/${songIDs.join(',')}`)
|
||||
return await response.json()
|
||||
}
|
||||
/**
|
||||
* @returns an array of all the chart versions with a songID found in `songIDs`.
|
||||
*/
|
||||
async handler(songIDs: number[]): Promise<VersionResult[]> {
|
||||
const response = await fetch(`https://${serverURL}/api/data/song-versions/${songIDs.join(',')}`)
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
export const batchSongDetailsHandler = new BatchSongDetailsHandler()
|
||||
@@ -1,27 +1,28 @@
|
||||
import { IPCInvokeHandler } from '../../shared/IPCHandler'
|
||||
import { SongResult, SongSearch } from '../../shared/interfaces/search.interface'
|
||||
import { IPCInvokeHandler } from '../../shared/IPCHandler'
|
||||
import { serverURL } from '../../shared/Paths'
|
||||
|
||||
/**
|
||||
* Handles the 'song-search' event.
|
||||
*/
|
||||
class SearchHandler implements IPCInvokeHandler<'song-search'> {
|
||||
event: 'song-search' = 'song-search'
|
||||
event = 'song-search' as const
|
||||
|
||||
/**
|
||||
* @returns the top 50 songs that match `search`.
|
||||
*/
|
||||
async handler(search: SongSearch): Promise<SongResult[]> {
|
||||
const response = await fetch(`https://${serverURL}/api/search`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(search)
|
||||
})
|
||||
/**
|
||||
* @returns the top 50 songs that match `search`.
|
||||
*/
|
||||
async handler(search: SongSearch): Promise<SongResult[]> {
|
||||
const response = await fetch(`https://${serverURL}/api/search`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(search),
|
||||
})
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
export const searchHandler = new SearchHandler()
|
||||
@@ -1,20 +1,20 @@
|
||||
import { IPCInvokeHandler } from '../../shared/IPCHandler'
|
||||
import { VersionResult } from '../../shared/interfaces/songDetails.interface'
|
||||
import { IPCInvokeHandler } from '../../shared/IPCHandler'
|
||||
import { serverURL } from '../../shared/Paths'
|
||||
|
||||
/**
|
||||
* Handles the 'song-details' event.
|
||||
*/
|
||||
class SongDetailsHandler implements IPCInvokeHandler<'song-details'> {
|
||||
event: 'song-details' = 'song-details'
|
||||
event = 'song-details' as const
|
||||
|
||||
/**
|
||||
* @returns the chart versions with `songID`.
|
||||
*/
|
||||
async handler(songID: number): Promise<VersionResult[]> {
|
||||
const response = await fetch(`https://${serverURL}/api/data/song-versions/${songID}`)
|
||||
return await response.json()
|
||||
}
|
||||
/**
|
||||
* @returns the chart versions with `songID`.
|
||||
*/
|
||||
async handler(songID: number): Promise<VersionResult[]> {
|
||||
const response = await fetch(`https://${serverURL}/api/data/song-versions/${songID}`)
|
||||
return await response.json()
|
||||
}
|
||||
}
|
||||
|
||||
export const songDetailsHandler = new SongDetailsHandler()
|
||||
@@ -1,282 +1,284 @@
|
||||
import { FileDownloader, getDownloader } from './FileDownloader'
|
||||
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 { FilesystemChecker } from './FilesystemChecker'
|
||||
import { getSettings } from '../SettingsHandler.ipc'
|
||||
import { hasVideoExtension } from '../../shared/ElectronUtilFunctions'
|
||||
|
||||
type EventCallback = {
|
||||
/** Note: this will not be the last event if `retry()` is called. */
|
||||
'error': () => void
|
||||
'complete': () => void
|
||||
import { NewDownload, ProgressType } from 'src/electron/shared/interfaces/download.interface'
|
||||
import { DriveFile } from 'src/electron/shared/interfaces/songDetails.interface'
|
||||
import { emitIPCEvent } from '../../main'
|
||||
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] }
|
||||
|
||||
export type DownloadError = { header: string; body: string; isLink?: boolean }
|
||||
export interface DownloadError { header: string; body: string; isLink?: boolean }
|
||||
|
||||
export class ChartDownload {
|
||||
|
||||
private retryFn: () => void | Promise<void>
|
||||
private cancelFn: () => void
|
||||
private retryFn: () => void | Promise<void>
|
||||
private cancelFn: () => void
|
||||
|
||||
private callbacks = {} as Callbacks
|
||||
private files: DriveFile[]
|
||||
private percent = 0 // Needs to be stored here because errors won't know the exact percent
|
||||
private tempPath: string
|
||||
private wasCanceled = false
|
||||
private callbacks = {} as Callbacks
|
||||
private files: DriveFile[]
|
||||
private percent = 0 // Needs to be stored here because errors won't know the exact percent
|
||||
private tempPath: string
|
||||
private wasCanceled = false
|
||||
|
||||
private readonly individualFileProgressPortion: number
|
||||
private readonly destinationFolderName: string
|
||||
private readonly individualFileProgressPortion: number
|
||||
private readonly destinationFolderName: string
|
||||
|
||||
private _allFilesProgress = 0
|
||||
get allFilesProgress() { return this._allFilesProgress }
|
||||
private _hasFailed = false
|
||||
/** If this chart download needs to be retried */
|
||||
get hasFailed() { return this._hasFailed }
|
||||
get isArchive() { return this.data.driveData.isArchive }
|
||||
get hash() { return this.data.driveData.filesHash }
|
||||
private _allFilesProgress = 0
|
||||
get allFilesProgress() { return this._allFilesProgress }
|
||||
private _hasFailed = false
|
||||
/** If this chart download needs to be retried */
|
||||
get hasFailed() { return this._hasFailed }
|
||||
get isArchive() { return this.data.driveData.isArchive }
|
||||
get hash() { return this.data.driveData.filesHash }
|
||||
|
||||
constructor(public versionID: number, private data: NewDownload) {
|
||||
this.updateGUI('', 'Waiting for other downloads to finish...', 'good')
|
||||
this.files = this.filterDownloadFiles(data.driveData.files)
|
||||
this.individualFileProgressPortion = 80 / this.files.length
|
||||
if (data.driveData.inChartPack) {
|
||||
this.destinationFolderName = sanitizeFilename(parse(data.driveData.files[0].name).name)
|
||||
} else {
|
||||
this.destinationFolderName = sanitizeFilename(`${this.data.artist} - ${this.data.chartName} (${this.data.charter})`)
|
||||
}
|
||||
}
|
||||
constructor(public versionID: number, private data: NewDownload) {
|
||||
this.updateGUI('', 'Waiting for other downloads to finish...', 'good')
|
||||
this.files = this.filterDownloadFiles(data.driveData.files)
|
||||
this.individualFileProgressPortion = 80 / this.files.length
|
||||
if (data.driveData.inChartPack) {
|
||||
this.destinationFolderName = sanitizeFilename(parse(data.driveData.files[0].name).name)
|
||||
} else {
|
||||
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)
|
||||
*/
|
||||
on<E extends keyof EventCallback>(event: E, callback: EventCallback[E]) {
|
||||
this.callbacks[event] = callback
|
||||
}
|
||||
/**
|
||||
* 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]) {
|
||||
this.callbacks[event] = callback
|
||||
}
|
||||
|
||||
filterDownloadFiles(files: DriveFile[]) {
|
||||
return files.filter(file => {
|
||||
return (file.name != 'ch.dat') && (getSettings().downloadVideos || !hasVideoExtension(file.name))
|
||||
})
|
||||
}
|
||||
filterDownloadFiles(files: DriveFile[]) {
|
||||
return files.filter(file => {
|
||||
return (file.name != 'ch.dat') && (getSettings().downloadVideos || !hasVideoExtension(file.name))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Retries the last failed step if it is running.
|
||||
*/
|
||||
retry() { // Only allow it to be called once
|
||||
if (this.retryFn != undefined) {
|
||||
this._hasFailed = false
|
||||
const retryFn = this.retryFn
|
||||
this.retryFn = undefined
|
||||
retryFn()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Retries the last failed step if it is running.
|
||||
*/
|
||||
retry() { // Only allow it to be called once
|
||||
if (this.retryFn != undefined) {
|
||||
this._hasFailed = false
|
||||
const retryFn = this.retryFn
|
||||
this.retryFn = undefined
|
||||
retryFn()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the GUI to indicate that a retry will be attempted.
|
||||
*/
|
||||
displayRetrying() {
|
||||
this.updateGUI('', 'Waiting for other downloads to finish to retry...', 'good')
|
||||
}
|
||||
/**
|
||||
* Updates the GUI to indicate that a retry will be attempted.
|
||||
*/
|
||||
displayRetrying() {
|
||||
this.updateGUI('', 'Waiting for other downloads to finish to retry...', 'good')
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels the download if it is running.
|
||||
*/
|
||||
cancel() { // Only allow it to be called once
|
||||
if (this.cancelFn != undefined) {
|
||||
const cancelFn = this.cancelFn
|
||||
this.cancelFn = undefined
|
||||
cancelFn()
|
||||
rimraf(this.tempPath).catch(() => { /** Do nothing */ }) // Delete temp folder
|
||||
}
|
||||
this.updateGUI('', '', 'cancel')
|
||||
this.wasCanceled = true
|
||||
}
|
||||
/**
|
||||
* Cancels the download if it is running.
|
||||
*/
|
||||
cancel() { // Only allow it to be called once
|
||||
if (this.cancelFn != undefined) {
|
||||
const cancelFn = this.cancelFn
|
||||
this.cancelFn = undefined
|
||||
cancelFn()
|
||||
rimraf(this.tempPath).catch(() => { /** Do nothing */ }) // Delete temp folder
|
||||
}
|
||||
this.updateGUI('', '', 'cancel')
|
||||
this.wasCanceled = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the GUI with new information about this chart download.
|
||||
*/
|
||||
private updateGUI(header: string, description: string, type: ProgressType, isLink = false) {
|
||||
if (this.wasCanceled) { return }
|
||||
/**
|
||||
* Updates the GUI with new information about this chart download.
|
||||
*/
|
||||
private updateGUI(header: string, description: string, type: ProgressType, isLink = false) {
|
||||
if (this.wasCanceled) { return }
|
||||
|
||||
emitIPCEvent('download-updated', {
|
||||
versionID: this.versionID,
|
||||
title: `${this.data.chartName} - ${this.data.artist}`,
|
||||
header: header,
|
||||
description: description,
|
||||
percent: this.percent,
|
||||
type: type,
|
||||
isLink
|
||||
})
|
||||
}
|
||||
emitIPCEvent('download-updated', {
|
||||
versionID: this.versionID,
|
||||
title: `${this.data.chartName} - ${this.data.artist}`,
|
||||
header: header,
|
||||
description: description,
|
||||
percent: this.percent,
|
||||
type: type,
|
||||
isLink,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the retry function, update the GUI, and call the `error` callback.
|
||||
*/
|
||||
private handleError(err: DownloadError, retry: () => void) {
|
||||
this._hasFailed = true
|
||||
this.retryFn = retry
|
||||
this.updateGUI(err.header, err.body, 'error', err.isLink == true)
|
||||
this.callbacks.error()
|
||||
}
|
||||
/**
|
||||
* Save the retry function, update the GUI, and call the `error` callback.
|
||||
*/
|
||||
private handleError(err: DownloadError, retry: () => void) {
|
||||
this._hasFailed = true
|
||||
this.retryFn = retry
|
||||
this.updateGUI(err.header, err.body, 'error', err.isLink == true)
|
||||
this.callbacks.error()
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the download process.
|
||||
*/
|
||||
async beginDownload() {
|
||||
// CHECK FILESYSTEM ACCESS
|
||||
const checker = new FilesystemChecker(this.destinationFolderName)
|
||||
this.cancelFn = () => checker.cancelCheck()
|
||||
/**
|
||||
* Starts the download process.
|
||||
*/
|
||||
async beginDownload() {
|
||||
// CHECK FILESYSTEM ACCESS
|
||||
const checker = new FilesystemChecker(this.destinationFolderName)
|
||||
this.cancelFn = () => checker.cancelCheck()
|
||||
|
||||
const checkerComplete = this.addFilesystemCheckerEventListeners(checker)
|
||||
checker.beginCheck()
|
||||
await checkerComplete
|
||||
const checkerComplete = this.addFilesystemCheckerEventListeners(checker)
|
||||
checker.beginCheck()
|
||||
await checkerComplete
|
||||
|
||||
// DOWNLOAD FILES
|
||||
for (let i = 0; i < this.files.length; i++) {
|
||||
let wasCanceled = false
|
||||
this.cancelFn = () => { wasCanceled = true }
|
||||
const downloader = getDownloader(this.files[i].webContentLink, join(this.tempPath, this.files[i].name))
|
||||
if (wasCanceled) { return }
|
||||
this.cancelFn = () => downloader.cancelDownload()
|
||||
// DOWNLOAD FILES
|
||||
for (let i = 0; i < this.files.length; i++) {
|
||||
let wasCanceled = false
|
||||
this.cancelFn = () => { wasCanceled = true }
|
||||
const downloader = getDownloader(this.files[i].webContentLink, join(this.tempPath, this.files[i].name))
|
||||
if (wasCanceled) { return }
|
||||
this.cancelFn = () => downloader.cancelDownload()
|
||||
|
||||
const downloadComplete = this.addDownloadEventListeners(downloader, i)
|
||||
downloader.beginDownload()
|
||||
await downloadComplete
|
||||
}
|
||||
const downloadComplete = this.addDownloadEventListeners(downloader, i)
|
||||
downloader.beginDownload()
|
||||
await downloadComplete
|
||||
}
|
||||
|
||||
// EXTRACT FILES
|
||||
if (this.isArchive) {
|
||||
const extractor = new FileExtractor(this.tempPath)
|
||||
this.cancelFn = () => extractor.cancelExtract()
|
||||
// EXTRACT FILES
|
||||
if (this.isArchive) {
|
||||
const extractor = new FileExtractor(this.tempPath)
|
||||
this.cancelFn = () => extractor.cancelExtract()
|
||||
|
||||
const extractComplete = this.addExtractorEventListeners(extractor)
|
||||
extractor.beginExtract()
|
||||
await extractComplete
|
||||
}
|
||||
const extractComplete = this.addExtractorEventListeners(extractor)
|
||||
extractor.beginExtract()
|
||||
await extractComplete
|
||||
}
|
||||
|
||||
// TRANSFER FILES
|
||||
const transfer = new FileTransfer(this.tempPath, this.destinationFolderName)
|
||||
this.cancelFn = () => transfer.cancelTransfer()
|
||||
// TRANSFER FILES
|
||||
const transfer = new FileTransfer(this.tempPath, this.destinationFolderName)
|
||||
this.cancelFn = () => transfer.cancelTransfer()
|
||||
|
||||
const transferComplete = this.addTransferEventListeners(transfer)
|
||||
transfer.beginTransfer()
|
||||
await transferComplete
|
||||
const transferComplete = this.addTransferEventListeners(transfer)
|
||||
transfer.beginTransfer()
|
||||
await transferComplete
|
||||
|
||||
this.callbacks.complete()
|
||||
}
|
||||
this.callbacks.complete()
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines what happens in reponse to `FilesystemChecker` events.
|
||||
* @returns a `Promise` that resolves when the filesystem has been checked.
|
||||
*/
|
||||
private addFilesystemCheckerEventListeners(checker: FilesystemChecker) {
|
||||
checker.on('start', () => {
|
||||
this.updateGUI('Checking filesystem...', '', 'good')
|
||||
})
|
||||
/**
|
||||
* Defines what happens in reponse to `FilesystemChecker` events.
|
||||
* @returns a `Promise` that resolves when the filesystem has been checked.
|
||||
*/
|
||||
private addFilesystemCheckerEventListeners(checker: FilesystemChecker) {
|
||||
checker.on('start', () => {
|
||||
this.updateGUI('Checking filesystem...', '', 'good')
|
||||
})
|
||||
|
||||
checker.on('error', this.handleError.bind(this))
|
||||
checker.on('error', this.handleError.bind(this))
|
||||
|
||||
return new Promise<void>(resolve => {
|
||||
checker.on('complete', (tempPath) => {
|
||||
this.tempPath = tempPath
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
return new Promise<void>(resolve => {
|
||||
checker.on('complete', tempPath => {
|
||||
this.tempPath = tempPath
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines what happens in response to `FileDownloader` events.
|
||||
* @returns a `Promise` that resolves when the download finishes.
|
||||
*/
|
||||
private addDownloadEventListeners(downloader: FileDownloader, fileIndex: number) {
|
||||
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 fileProgress = 0
|
||||
/**
|
||||
* Defines what happens in response to `FileDownloader` events.
|
||||
* @returns a `Promise` that resolves when the download finishes.
|
||||
*/
|
||||
private addDownloadEventListeners(downloader: FileDownloader, fileIndex: number) {
|
||||
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 fileProgress = 0
|
||||
|
||||
downloader.on('waitProgress', (remainingSeconds: number, totalSeconds: number) => {
|
||||
downloadStartPoint = this.individualFileProgressPortion / 2
|
||||
this.percent = this._allFilesProgress + interpolate(remainingSeconds, totalSeconds, 0, 0, this.individualFileProgressPortion / 2)
|
||||
this.updateGUI(downloadHeader, `Waiting for Google rate limit... (${remainingSeconds}s)`, 'good')
|
||||
})
|
||||
downloader.on('waitProgress', (remainingSeconds: number, totalSeconds: number) => {
|
||||
downloadStartPoint = this.individualFileProgressPortion / 2
|
||||
this.percent =
|
||||
this._allFilesProgress + interpolate(remainingSeconds, totalSeconds, 0, 0, this.individualFileProgressPortion / 2)
|
||||
this.updateGUI(downloadHeader, `Waiting for Google rate limit... (${remainingSeconds}s)`, 'good')
|
||||
})
|
||||
|
||||
downloader.on('requestSent', () => {
|
||||
fileProgress = downloadStartPoint
|
||||
this.percent = this._allFilesProgress + fileProgress
|
||||
this.updateGUI(downloadHeader, 'Sending request...', 'good')
|
||||
})
|
||||
downloader.on('requestSent', () => {
|
||||
fileProgress = downloadStartPoint
|
||||
this.percent = this._allFilesProgress + fileProgress
|
||||
this.updateGUI(downloadHeader, 'Sending request...', 'good')
|
||||
})
|
||||
|
||||
downloader.on('downloadProgress', (bytesDownloaded: number) => {
|
||||
downloadHeader = `[${this.files[fileIndex].name}] (file ${fileIndex + 1}/${this.files.length})`
|
||||
const size = Number(this.files[fileIndex].size)
|
||||
fileProgress = interpolate(bytesDownloaded, 0, size, downloadStartPoint, this.individualFileProgressPortion)
|
||||
this.percent = this._allFilesProgress + fileProgress
|
||||
this.updateGUI(downloadHeader, `Downloading... (${Math.round(1000 * bytesDownloaded / size) / 10}%)`, 'good')
|
||||
})
|
||||
downloader.on('downloadProgress', (bytesDownloaded: number) => {
|
||||
downloadHeader = `[${this.files[fileIndex].name}] (file ${fileIndex + 1}/${this.files.length})`
|
||||
const size = Number(this.files[fileIndex].size)
|
||||
fileProgress = interpolate(bytesDownloaded, 0, size, downloadStartPoint, this.individualFileProgressPortion)
|
||||
this.percent = this._allFilesProgress + fileProgress
|
||||
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 => {
|
||||
downloader.on('complete', () => {
|
||||
this._allFilesProgress += this.individualFileProgressPortion
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
return new Promise<void>(resolve => {
|
||||
downloader.on('complete', () => {
|
||||
this._allFilesProgress += this.individualFileProgressPortion
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines what happens in response to `FileExtractor` events.
|
||||
* @returns a `Promise` that resolves when the extraction finishes.
|
||||
*/
|
||||
private addExtractorEventListeners(extractor: FileExtractor) {
|
||||
let archive = ''
|
||||
/**
|
||||
* Defines what happens in response to `FileExtractor` events.
|
||||
* @returns a `Promise` that resolves when the extraction finishes.
|
||||
*/
|
||||
private addExtractorEventListeners(extractor: FileExtractor) {
|
||||
let archive = ''
|
||||
|
||||
extractor.on('start', (filename) => {
|
||||
archive = filename
|
||||
this.updateGUI(`[${archive}]`, 'Extracting...', 'good')
|
||||
})
|
||||
extractor.on('start', filename => {
|
||||
archive = filename
|
||||
this.updateGUI(`[${archive}]`, 'Extracting...', 'good')
|
||||
})
|
||||
|
||||
extractor.on('extractProgress', (percent, filecount) => {
|
||||
this.percent = interpolate(percent, 0, 100, 80, 95)
|
||||
this.updateGUI(`[${archive}] (${filecount} file${filecount == 1 ? '' : 's'} extracted)`, `Extracting... (${percent}%)`, 'good')
|
||||
})
|
||||
extractor.on('extractProgress', (percent, filecount) => {
|
||||
this.percent = interpolate(percent, 0, 100, 80, 95)
|
||||
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 => {
|
||||
extractor.on('complete', () => {
|
||||
this.percent = 95
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
return new Promise<void>(resolve => {
|
||||
extractor.on('complete', () => {
|
||||
this.percent = 95
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines what happens in response to `FileTransfer` events.
|
||||
* @returns a `Promise` that resolves when the transfer finishes.
|
||||
*/
|
||||
private addTransferEventListeners(transfer: FileTransfer) {
|
||||
let destinationFolder: string
|
||||
/**
|
||||
* Defines what happens in response to `FileTransfer` events.
|
||||
* @returns a `Promise` that resolves when the transfer finishes.
|
||||
*/
|
||||
private addTransferEventListeners(transfer: FileTransfer) {
|
||||
let destinationFolder: string
|
||||
|
||||
transfer.on('start', (_destinationFolder) => {
|
||||
destinationFolder = _destinationFolder
|
||||
this.updateGUI('Moving files to library folder...', destinationFolder, 'good', true)
|
||||
})
|
||||
transfer.on('start', _destinationFolder => {
|
||||
destinationFolder = _destinationFolder
|
||||
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 => {
|
||||
transfer.on('complete', () => {
|
||||
this.percent = 100
|
||||
this.updateGUI('Download complete.', destinationFolder, 'done', true)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
return new Promise<void>(resolve => {
|
||||
transfer.on('complete', () => {
|
||||
this.percent = 100
|
||||
this.updateGUI('Download complete.', destinationFolder, 'done', true)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,86 +1,86 @@
|
||||
import { IPCEmitHandler } from '../../shared/IPCHandler'
|
||||
import { Download } from '../../shared/interfaces/download.interface'
|
||||
import { IPCEmitHandler } from '../../shared/IPCHandler'
|
||||
import { ChartDownload } from './ChartDownload'
|
||||
import { DownloadQueue } from './DownloadQueue'
|
||||
|
||||
class DownloadHandler implements IPCEmitHandler<'download'> {
|
||||
event: 'download' = 'download'
|
||||
event = 'download' as const
|
||||
|
||||
downloadQueue: DownloadQueue = new DownloadQueue()
|
||||
currentDownload: ChartDownload = undefined
|
||||
retryWaiting: ChartDownload[] = []
|
||||
downloadQueue: DownloadQueue = new DownloadQueue()
|
||||
currentDownload: ChartDownload = undefined
|
||||
retryWaiting: ChartDownload[] = []
|
||||
|
||||
handler(data: Download) {
|
||||
switch (data.action) {
|
||||
case 'add': this.addDownload(data); break
|
||||
case 'retry': this.retryDownload(data); break
|
||||
case 'cancel': this.cancelDownload(data); break
|
||||
}
|
||||
}
|
||||
handler(data: Download) {
|
||||
switch (data.action) {
|
||||
case 'add': this.addDownload(data); break
|
||||
case 'retry': this.retryDownload(data); break
|
||||
case 'cancel': this.cancelDownload(data); break
|
||||
}
|
||||
}
|
||||
|
||||
private addDownload(data: Download) {
|
||||
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)) {
|
||||
return
|
||||
}
|
||||
private addDownload(data: Download) {
|
||||
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)) {
|
||||
return
|
||||
}
|
||||
|
||||
const newDownload = new ChartDownload(data.versionID, data.data)
|
||||
this.addDownloadEventListeners(newDownload)
|
||||
if (this.currentDownload == undefined) {
|
||||
this.currentDownload = newDownload
|
||||
newDownload.beginDownload()
|
||||
} else {
|
||||
this.downloadQueue.push(newDownload)
|
||||
}
|
||||
}
|
||||
const newDownload = new ChartDownload(data.versionID, data.data)
|
||||
this.addDownloadEventListeners(newDownload)
|
||||
if (this.currentDownload == undefined) {
|
||||
this.currentDownload = newDownload
|
||||
newDownload.beginDownload()
|
||||
} else {
|
||||
this.downloadQueue.push(newDownload)
|
||||
}
|
||||
}
|
||||
|
||||
private retryDownload(data: Download) {
|
||||
const index = this.retryWaiting.findIndex(download => download.versionID == data.versionID)
|
||||
if (index != -1) {
|
||||
const retryDownload = this.retryWaiting.splice(index, 1)[0]
|
||||
retryDownload.displayRetrying()
|
||||
if (this.currentDownload == undefined) {
|
||||
this.currentDownload = retryDownload
|
||||
retryDownload.retry()
|
||||
} else {
|
||||
this.downloadQueue.push(retryDownload)
|
||||
}
|
||||
}
|
||||
}
|
||||
private retryDownload(data: Download) {
|
||||
const index = this.retryWaiting.findIndex(download => download.versionID == data.versionID)
|
||||
if (index != -1) {
|
||||
const retryDownload = this.retryWaiting.splice(index, 1)[0]
|
||||
retryDownload.displayRetrying()
|
||||
if (this.currentDownload == undefined) {
|
||||
this.currentDownload = retryDownload
|
||||
retryDownload.retry()
|
||||
} else {
|
||||
this.downloadQueue.push(retryDownload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private cancelDownload(data: Download) {
|
||||
if (this.currentDownload?.versionID == data.versionID) {
|
||||
this.currentDownload.cancel()
|
||||
this.currentDownload = undefined
|
||||
this.startNextDownload()
|
||||
} else {
|
||||
this.downloadQueue.remove(data.versionID)
|
||||
}
|
||||
}
|
||||
private cancelDownload(data: Download) {
|
||||
if (this.currentDownload?.versionID == data.versionID) {
|
||||
this.currentDownload.cancel()
|
||||
this.currentDownload = undefined
|
||||
this.startNextDownload()
|
||||
} else {
|
||||
this.downloadQueue.remove(data.versionID)
|
||||
}
|
||||
}
|
||||
|
||||
private addDownloadEventListeners(download: ChartDownload) {
|
||||
download.on('complete', () => {
|
||||
this.currentDownload = undefined
|
||||
this.startNextDownload()
|
||||
})
|
||||
private addDownloadEventListeners(download: ChartDownload) {
|
||||
download.on('complete', () => {
|
||||
this.currentDownload = undefined
|
||||
this.startNextDownload()
|
||||
})
|
||||
|
||||
download.on('error', () => {
|
||||
this.retryWaiting.push(this.currentDownload)
|
||||
this.currentDownload = undefined
|
||||
this.startNextDownload()
|
||||
})
|
||||
}
|
||||
download.on('error', () => {
|
||||
this.retryWaiting.push(this.currentDownload)
|
||||
this.currentDownload = undefined
|
||||
this.startNextDownload()
|
||||
})
|
||||
}
|
||||
|
||||
private startNextDownload() {
|
||||
if (!this.downloadQueue.isEmpty()) {
|
||||
this.currentDownload = this.downloadQueue.shift()
|
||||
if (this.currentDownload.hasFailed) {
|
||||
this.currentDownload.retry()
|
||||
} else {
|
||||
this.currentDownload.beginDownload()
|
||||
}
|
||||
}
|
||||
}
|
||||
private startNextDownload() {
|
||||
if (!this.downloadQueue.isEmpty()) {
|
||||
this.currentDownload = this.downloadQueue.shift()
|
||||
if (this.currentDownload.hasFailed) {
|
||||
this.currentDownload.retry()
|
||||
} else {
|
||||
this.currentDownload.beginDownload()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const downloadHandler = new DownloadHandler()
|
||||
@@ -1,50 +1,51 @@
|
||||
import Comparators from 'comparators'
|
||||
import { ChartDownload } from './ChartDownload'
|
||||
|
||||
import { emitIPCEvent } from '../../main'
|
||||
import { ChartDownload } from './ChartDownload'
|
||||
|
||||
export class DownloadQueue {
|
||||
|
||||
private downloadQueue: ChartDownload[] = []
|
||||
private downloadQueue: ChartDownload[] = []
|
||||
|
||||
isDownloadingLink(filesHash: string) {
|
||||
return this.downloadQueue.some(download => download.hash == filesHash)
|
||||
}
|
||||
isDownloadingLink(filesHash: string) {
|
||||
return this.downloadQueue.some(download => download.hash == filesHash)
|
||||
}
|
||||
|
||||
isEmpty() {
|
||||
return this.downloadQueue.length == 0
|
||||
}
|
||||
isEmpty() {
|
||||
return this.downloadQueue.length == 0
|
||||
}
|
||||
|
||||
push(chartDownload: ChartDownload) {
|
||||
this.downloadQueue.push(chartDownload)
|
||||
this.sort()
|
||||
}
|
||||
push(chartDownload: ChartDownload) {
|
||||
this.downloadQueue.push(chartDownload)
|
||||
this.sort()
|
||||
}
|
||||
|
||||
shift() {
|
||||
return this.downloadQueue.shift()
|
||||
}
|
||||
shift() {
|
||||
return this.downloadQueue.shift()
|
||||
}
|
||||
|
||||
get(versionID: number) {
|
||||
return this.downloadQueue.find(download => download.versionID == versionID)
|
||||
}
|
||||
get(versionID: number) {
|
||||
return this.downloadQueue.find(download => download.versionID == versionID)
|
||||
}
|
||||
|
||||
remove(versionID: number) {
|
||||
const index = this.downloadQueue.findIndex(download => download.versionID == versionID)
|
||||
if (index != -1) {
|
||||
this.downloadQueue[index].cancel()
|
||||
this.downloadQueue.splice(index, 1)
|
||||
emitIPCEvent('queue-updated', this.downloadQueue.map(download => download.versionID))
|
||||
}
|
||||
}
|
||||
remove(versionID: number) {
|
||||
const index = this.downloadQueue.findIndex(download => download.versionID == versionID)
|
||||
if (index != -1) {
|
||||
this.downloadQueue[index].cancel()
|
||||
this.downloadQueue.splice(index, 1)
|
||||
emitIPCEvent('queue-updated', this.downloadQueue.map(download => download.versionID))
|
||||
}
|
||||
}
|
||||
|
||||
private sort() {
|
||||
let comparator = Comparators.comparing('allFilesProgress', { reversed: true })
|
||||
private sort() {
|
||||
let comparator = Comparators.comparing('allFilesProgress', { reversed: true })
|
||||
|
||||
const prioritizeArchives = true
|
||||
if (prioritizeArchives) {
|
||||
comparator = comparator.thenComparing('isArchive', { reversed: true })
|
||||
}
|
||||
const prioritizeArchives = true
|
||||
if (prioritizeArchives) {
|
||||
comparator = comparator.thenComparing('isArchive', { reversed: true })
|
||||
}
|
||||
|
||||
this.downloadQueue.sort(comparator)
|
||||
emitIPCEvent('queue-updated', this.downloadQueue.map(download => download.versionID))
|
||||
}
|
||||
this.downloadQueue.sort(comparator)
|
||||
emitIPCEvent('queue-updated', this.downloadQueue.map(download => download.versionID))
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,44 @@
|
||||
import { AnyFunction } from '../../shared/UtilFunctions'
|
||||
import { devLog } from '../../shared/ElectronUtilFunctions'
|
||||
import Bottleneck from 'bottleneck'
|
||||
import { createWriteStream, writeFile as _writeFile } from 'fs'
|
||||
import { google } from 'googleapis'
|
||||
import * as needle from 'needle'
|
||||
import { join } from 'path'
|
||||
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?)
|
||||
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 limiter = new Bottleneck({
|
||||
minTime: 200 // Wait 200 ms between API requests
|
||||
minTime: 200, // Wait 200 ms between API requests
|
||||
})
|
||||
|
||||
const RETRY_MAX = 2
|
||||
const writeFile = promisify(_writeFile)
|
||||
|
||||
type EventCallback = {
|
||||
'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 */
|
||||
'requestSent': () => void
|
||||
'downloadProgress': (bytesDownloaded: number) => void
|
||||
/** Note: after calling retry, the event lifecycle restarts */
|
||||
'error': (err: DownloadError, retry: () => void) => void
|
||||
'complete': () => void
|
||||
interface EventCallback {
|
||||
'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 */
|
||||
'requestSent': () => void
|
||||
'downloadProgress': (bytesDownloaded: number) => void
|
||||
/** Note: after calling retry, the event lifecycle restarts */
|
||||
'error': (err: DownloadError, retry: () => void) => void
|
||||
'complete': () => void
|
||||
}
|
||||
type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
|
||||
export type FileDownloader = APIFileDownloader | SlowFileDownloader
|
||||
|
||||
const downloadErrors = {
|
||||
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}` } },
|
||||
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 } },
|
||||
linkError: (url: string) => { return { header: 'Invalid link', body: `The download link is not formatted correctly: ${url}` } }
|
||||
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}` } },
|
||||
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 } },
|
||||
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).
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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 retryCount: number
|
||||
private wasCanceled = false
|
||||
private fileID: string
|
||||
private downloadStream: Readable
|
||||
private callbacks = {} as Callbacks
|
||||
private retryCount: number
|
||||
private wasCanceled = false
|
||||
private fileID: string
|
||||
private downloadStream: Readable
|
||||
|
||||
/**
|
||||
* @param url The download link.
|
||||
* @param fullPath The full path to where this file should be stored (including the filename).
|
||||
*/
|
||||
constructor(private url: string, private fullPath: string) {
|
||||
// url looks like: "https://drive.google.com/uc?id=1TlxtOZlVgRgX7-1tyW0d5QzXVfL-MC3Q&export=download"
|
||||
this.fileID = this.URL_REGEX.exec(url)[1]
|
||||
}
|
||||
/**
|
||||
* @param url The download link.
|
||||
* @param fullPath The full path to where this file should be stored (including the filename).
|
||||
*/
|
||||
constructor(private url: string, private fullPath: string) {
|
||||
// url looks like: "https://drive.google.com/uc?id=1TlxtOZlVgRgX7-1tyW0d5QzXVfL-MC3Q&export=download"
|
||||
this.fileID = this.URL_REGEX.exec(url)[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]) {
|
||||
this.callbacks[event] = callback
|
||||
}
|
||||
/**
|
||||
* 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]) {
|
||||
this.callbacks[event] = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the file after waiting for the google rate limit.
|
||||
*/
|
||||
beginDownload() {
|
||||
if (this.fileID == undefined) {
|
||||
this.failDownload(downloadErrors.linkError(this.url))
|
||||
}
|
||||
/**
|
||||
* Download the file after waiting for the google rate limit.
|
||||
*/
|
||||
beginDownload() {
|
||||
if (this.fileID == undefined) {
|
||||
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`.
|
||||
*/
|
||||
private startDownloadStream() {
|
||||
limiter.schedule(this.cancelable(async () => {
|
||||
this.callbacks.requestSent()
|
||||
try {
|
||||
this.downloadStream = (await drive.files.get({
|
||||
fileId: this.fileID,
|
||||
alt: 'media'
|
||||
}, {
|
||||
responseType: 'stream'
|
||||
})).data
|
||||
/**
|
||||
* Uses the Google Drive API to start a download stream for the file with `this.fileID`.
|
||||
*/
|
||||
private startDownloadStream() {
|
||||
limiter.schedule(this.cancelable(async () => {
|
||||
this.callbacks.requestSent()
|
||||
try {
|
||||
this.downloadStream = (await drive.files.get({
|
||||
fileId: this.fileID,
|
||||
alt: 'media',
|
||||
}, {
|
||||
responseType: 'stream',
|
||||
})).data
|
||||
|
||||
if (this.wasCanceled) { return }
|
||||
if (this.wasCanceled) { return }
|
||||
|
||||
this.handleDownloadResponse()
|
||||
} catch (err) {
|
||||
this.retryCount++
|
||||
if (this.retryCount <= RETRY_MAX) {
|
||||
devLog(`Failed to get file: Retry attempt ${this.retryCount}...`)
|
||||
if (this.wasCanceled) { return }
|
||||
this.startDownloadStream()
|
||||
} else {
|
||||
devLog(inspect(err))
|
||||
if (err?.code && err?.response?.statusText) {
|
||||
this.failDownload(downloadErrors.responseError(`${err.code} (${err.response.statusText})`))
|
||||
} else {
|
||||
this.failDownload(downloadErrors.responseError(err?.code ?? 'unknown'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
this.handleDownloadResponse()
|
||||
} catch (err) {
|
||||
this.retryCount++
|
||||
if (this.retryCount <= RETRY_MAX) {
|
||||
devLog(`Failed to get file: Retry attempt ${this.retryCount}...`)
|
||||
if (this.wasCanceled) { return }
|
||||
this.startDownloadStream()
|
||||
} else {
|
||||
devLog(inspect(err))
|
||||
if (err?.code && err?.response?.statusText) {
|
||||
this.failDownload(downloadErrors.responseError(`${err.code} (${err.response.statusText})`))
|
||||
} else {
|
||||
this.failDownload(downloadErrors.responseError(err?.code ?? 'unknown'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipes the data from a download response to `this.fullPath`.
|
||||
* @param req The download request.
|
||||
*/
|
||||
private handleDownloadResponse() {
|
||||
this.callbacks.downloadProgress(0)
|
||||
let downloadedSize = 0
|
||||
const writeStream = createWriteStream(this.fullPath)
|
||||
/**
|
||||
* Pipes the data from a download response to `this.fullPath`.
|
||||
* @param req The download request.
|
||||
*/
|
||||
private handleDownloadResponse() {
|
||||
this.callbacks.downloadProgress(0)
|
||||
let downloadedSize = 0
|
||||
const writeStream = createWriteStream(this.fullPath)
|
||||
|
||||
try {
|
||||
this.downloadStream.pipe(writeStream)
|
||||
} catch (err) {
|
||||
this.failDownload(downloadErrors.connectionError(err))
|
||||
}
|
||||
try {
|
||||
this.downloadStream.pipe(writeStream)
|
||||
} catch (err) {
|
||||
this.failDownload(downloadErrors.connectionError(err))
|
||||
}
|
||||
|
||||
this.downloadStream.on('data', this.cancelable((chunk: Buffer) => {
|
||||
downloadedSize += chunk.length
|
||||
}))
|
||||
this.downloadStream.on('data', this.cancelable((chunk: Buffer) => {
|
||||
downloadedSize += chunk.length
|
||||
}))
|
||||
|
||||
const progressUpdater = setInterval(() => {
|
||||
this.callbacks.downloadProgress(downloadedSize)
|
||||
}, 100)
|
||||
const progressUpdater = setInterval(() => {
|
||||
this.callbacks.downloadProgress(downloadedSize)
|
||||
}, 100)
|
||||
|
||||
this.downloadStream.on('error', this.cancelable((err: Error) => {
|
||||
clearInterval(progressUpdater)
|
||||
this.failDownload(downloadErrors.connectionError(err))
|
||||
}))
|
||||
this.downloadStream.on('error', this.cancelable((err: Error) => {
|
||||
clearInterval(progressUpdater)
|
||||
this.failDownload(downloadErrors.connectionError(err))
|
||||
}))
|
||||
|
||||
this.downloadStream.on('end', this.cancelable(() => {
|
||||
clearInterval(progressUpdater)
|
||||
writeStream.end()
|
||||
this.downloadStream.destroy()
|
||||
this.downloadStream = null
|
||||
this.downloadStream.on('end', this.cancelable(() => {
|
||||
clearInterval(progressUpdater)
|
||||
writeStream.end()
|
||||
this.downloadStream.destroy()
|
||||
this.downloadStream = null
|
||||
|
||||
this.callbacks.complete()
|
||||
}))
|
||||
}
|
||||
this.callbacks.complete()
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Display an error message and provide a function to retry the download.
|
||||
*/
|
||||
private failDownload(error: DownloadError) {
|
||||
this.callbacks.error(error, this.cancelable(() => this.beginDownload()))
|
||||
}
|
||||
/**
|
||||
* Display an error message and provide a function to retry the download.
|
||||
*/
|
||||
private failDownload(error: DownloadError) {
|
||||
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)
|
||||
*/
|
||||
cancelDownload() {
|
||||
this.wasCanceled = true
|
||||
googleTimer.cancelTimer() // Prevents timer from trying to activate a download and resetting
|
||||
if (this.downloadStream) {
|
||||
this.downloadStream.destroy()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Stop the process of downloading the file. (no more events will be fired after this is called)
|
||||
*/
|
||||
cancelDownload() {
|
||||
this.wasCanceled = true
|
||||
googleTimer.cancelTimer() // Prevents timer from trying to activate a download and resetting
|
||||
if (this.downloadStream) {
|
||||
this.downloadStream.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a function that is able to be prevented if `this.cancelDownload()` was called.
|
||||
*/
|
||||
private cancelable<F extends AnyFunction>(fn: F) {
|
||||
return (...args: Parameters<F>): ReturnType<F> => {
|
||||
if (this.wasCanceled) { return }
|
||||
return fn(...Array.from(args))
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Wraps a function that is able to be prevented if `this.cancelDownload()` was called.
|
||||
*/
|
||||
private cancelable<F extends AnyFunction>(fn: F) {
|
||||
return (...args: Parameters<F>): ReturnType<F> => {
|
||||
if (this.wasCanceled) { return }
|
||||
return fn(...Array.from(args))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -201,166 +203,166 @@ class APIFileDownloader {
|
||||
*/
|
||||
class SlowFileDownloader {
|
||||
|
||||
private callbacks = {} as Callbacks
|
||||
private retryCount: number
|
||||
private wasCanceled = false
|
||||
private req: NodeJS.ReadableStream
|
||||
private callbacks = {} as Callbacks
|
||||
private retryCount: number
|
||||
private wasCanceled = false
|
||||
private req: NodeJS.ReadableStream
|
||||
|
||||
/**
|
||||
* @param url The download link.
|
||||
* @param fullPath The full path to where this file should be stored (including the filename).
|
||||
*/
|
||||
constructor(private url: string, private fullPath: string) { }
|
||||
/**
|
||||
* @param url The download link.
|
||||
* @param fullPath The full path to where this file should be stored (including the filename).
|
||||
*/
|
||||
constructor(private url: string, private fullPath: string) { }
|
||||
|
||||
/**
|
||||
* 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]) {
|
||||
this.callbacks[event] = callback
|
||||
}
|
||||
/**
|
||||
* 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]) {
|
||||
this.callbacks[event] = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the file after waiting for the google rate limit.
|
||||
*/
|
||||
beginDownload() {
|
||||
googleTimer.on('waitProgress', this.cancelable((remainingSeconds, totalSeconds) => {
|
||||
this.callbacks.waitProgress(remainingSeconds, totalSeconds)
|
||||
}))
|
||||
/**
|
||||
* Download the file after waiting for the google rate limit.
|
||||
*/
|
||||
beginDownload() {
|
||||
googleTimer.on('waitProgress', this.cancelable((remainingSeconds, totalSeconds) => {
|
||||
this.callbacks.waitProgress(remainingSeconds, totalSeconds)
|
||||
}))
|
||||
|
||||
googleTimer.on('complete', this.cancelable(() => {
|
||||
this.requestDownload()
|
||||
}))
|
||||
}
|
||||
googleTimer.on('complete', this.cancelable(() => {
|
||||
this.requestDownload()
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request to download the file at `this.url`.
|
||||
* @param cookieHeader the "cookie=" header to include this request.
|
||||
*/
|
||||
private requestDownload(cookieHeader?: string) {
|
||||
this.callbacks.requestSent()
|
||||
this.req = needle.get(this.url, {
|
||||
'follow_max': 10,
|
||||
'open_timeout': 5000,
|
||||
'headers': Object.assign({
|
||||
'Referer': this.url,
|
||||
'Accept': '*/*'
|
||||
},
|
||||
(cookieHeader ? { 'Cookie': cookieHeader } : undefined)
|
||||
)
|
||||
})
|
||||
/**
|
||||
* Sends a request to download the file at `this.url`.
|
||||
* @param cookieHeader the "cookie=" header to include this request.
|
||||
*/
|
||||
private requestDownload(cookieHeader?: string) {
|
||||
this.callbacks.requestSent()
|
||||
this.req = needle.get(this.url, {
|
||||
'follow_max': 10,
|
||||
'open_timeout': 5000,
|
||||
'headers': Object.assign({
|
||||
'Referer': this.url,
|
||||
'Accept': '*/*',
|
||||
},
|
||||
(cookieHeader ? { 'Cookie': cookieHeader } : undefined)
|
||||
),
|
||||
})
|
||||
|
||||
this.req.on('timeout', this.cancelable((type: string) => {
|
||||
this.retryCount++
|
||||
if (this.retryCount <= RETRY_MAX) {
|
||||
devLog(`TIMEOUT: Retry attempt ${this.retryCount}...`)
|
||||
this.requestDownload(cookieHeader)
|
||||
} else {
|
||||
this.failDownload(downloadErrors.timeout(type))
|
||||
}
|
||||
}))
|
||||
this.req.on('timeout', this.cancelable((type: string) => {
|
||||
this.retryCount++
|
||||
if (this.retryCount <= RETRY_MAX) {
|
||||
devLog(`TIMEOUT: Retry attempt ${this.retryCount}...`)
|
||||
this.requestDownload(cookieHeader)
|
||||
} else {
|
||||
this.failDownload(downloadErrors.timeout(type))
|
||||
}
|
||||
}))
|
||||
|
||||
this.req.on('err', this.cancelable((err: Error) => {
|
||||
this.failDownload(downloadErrors.connectionError(err))
|
||||
}))
|
||||
this.req.on('err', this.cancelable((err: Error) => {
|
||||
this.failDownload(downloadErrors.connectionError(err))
|
||||
}))
|
||||
|
||||
this.req.on('header', this.cancelable((statusCode, headers: Headers) => {
|
||||
if (statusCode != 200) {
|
||||
this.failDownload(downloadErrors.responseError(statusCode))
|
||||
return
|
||||
}
|
||||
this.req.on('header', this.cancelable((statusCode, headers: Headers) => {
|
||||
if (statusCode != 200) {
|
||||
this.failDownload(downloadErrors.responseError(statusCode))
|
||||
return
|
||||
}
|
||||
|
||||
if (headers['content-type'].startsWith('text/html')) {
|
||||
this.handleHTMLResponse(headers['set-cookie'])
|
||||
} else {
|
||||
this.handleDownloadResponse()
|
||||
}
|
||||
}))
|
||||
}
|
||||
if (headers['content-type'].startsWith('text/html')) {
|
||||
this.handleHTMLResponse(headers['set-cookie'])
|
||||
} else {
|
||||
this.handleDownloadResponse()
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param cookieHeader The "cookie=" header of this request.
|
||||
*/
|
||||
private handleHTMLResponse(cookieHeader: string) {
|
||||
let virusScanHTML = ''
|
||||
this.req.on('data', this.cancelable(data => virusScanHTML += data))
|
||||
this.req.on('done', this.cancelable((err: Error) => {
|
||||
if (err) {
|
||||
this.failDownload(downloadErrors.connectionError(err))
|
||||
} else {
|
||||
try {
|
||||
const confirmTokenRegex = /confirm=([0-9A-Za-z\-_]+)&/g
|
||||
const confirmTokenResults = confirmTokenRegex.exec(virusScanHTML)
|
||||
const confirmToken = confirmTokenResults[1]
|
||||
const downloadID = this.url.substr(this.url.indexOf('id=') + 'id='.length)
|
||||
this.url = `https://drive.google.com/uc?confirm=${confirmToken}&id=${downloadID}`
|
||||
const warningCode = /download_warning_([^=]*)=/.exec(cookieHeader)[1]
|
||||
const NID = /NID=([^;]*);/.exec(cookieHeader)[1].replace('=', '%')
|
||||
const newHeader = `download_warning_${warningCode}=${confirmToken}; NID=${NID}`
|
||||
this.requestDownload(newHeader)
|
||||
} catch(e) {
|
||||
this.saveHTMLError(virusScanHTML).then((path) => {
|
||||
this.failDownload(downloadErrors.htmlError(path))
|
||||
})
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
/**
|
||||
* 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.
|
||||
* @param cookieHeader The "cookie=" header of this request.
|
||||
*/
|
||||
private handleHTMLResponse(cookieHeader: string) {
|
||||
let virusScanHTML = ''
|
||||
this.req.on('data', this.cancelable(data => virusScanHTML += data))
|
||||
this.req.on('done', this.cancelable((err: Error) => {
|
||||
if (err) {
|
||||
this.failDownload(downloadErrors.connectionError(err))
|
||||
} else {
|
||||
try {
|
||||
const confirmTokenRegex = /confirm=([0-9A-Za-z\-_]+)&/g
|
||||
const confirmTokenResults = confirmTokenRegex.exec(virusScanHTML)
|
||||
const confirmToken = confirmTokenResults[1]
|
||||
const downloadID = this.url.substr(this.url.indexOf('id=') + 'id='.length)
|
||||
this.url = `https://drive.google.com/uc?confirm=${confirmToken}&id=${downloadID}`
|
||||
const warningCode = /download_warning_([^=]*)=/.exec(cookieHeader)[1]
|
||||
const NID = /NID=([^;]*);/.exec(cookieHeader)[1].replace('=', '%')
|
||||
const newHeader = `download_warning_${warningCode}=${confirmToken}; NID=${NID}`
|
||||
this.requestDownload(newHeader)
|
||||
} catch (e) {
|
||||
this.saveHTMLError(virusScanHTML).then(path => {
|
||||
this.failDownload(downloadErrors.htmlError(path))
|
||||
})
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipes the data from a download response to `this.fullPath`.
|
||||
* @param req The download request.
|
||||
*/
|
||||
private handleDownloadResponse() {
|
||||
this.callbacks.downloadProgress(0)
|
||||
let downloadedSize = 0
|
||||
this.req.pipe(createWriteStream(this.fullPath))
|
||||
this.req.on('data', this.cancelable((data) => {
|
||||
downloadedSize += data.length
|
||||
this.callbacks.downloadProgress(downloadedSize)
|
||||
}))
|
||||
/**
|
||||
* Pipes the data from a download response to `this.fullPath`.
|
||||
* @param req The download request.
|
||||
*/
|
||||
private handleDownloadResponse() {
|
||||
this.callbacks.downloadProgress(0)
|
||||
let downloadedSize = 0
|
||||
this.req.pipe(createWriteStream(this.fullPath))
|
||||
this.req.on('data', this.cancelable(data => {
|
||||
downloadedSize += data.length
|
||||
this.callbacks.downloadProgress(downloadedSize)
|
||||
}))
|
||||
|
||||
this.req.on('err', this.cancelable((err: Error) => {
|
||||
this.failDownload(downloadErrors.connectionError(err))
|
||||
}))
|
||||
this.req.on('err', this.cancelable((err: Error) => {
|
||||
this.failDownload(downloadErrors.connectionError(err))
|
||||
}))
|
||||
|
||||
this.req.on('end', this.cancelable(() => {
|
||||
this.callbacks.complete()
|
||||
}))
|
||||
}
|
||||
this.req.on('end', this.cancelable(() => {
|
||||
this.callbacks.complete()
|
||||
}))
|
||||
}
|
||||
|
||||
private async saveHTMLError(text: string) {
|
||||
const errorPath = join(tempPath, 'HTMLError.html')
|
||||
await writeFile(errorPath, text)
|
||||
return errorPath
|
||||
}
|
||||
private async saveHTMLError(text: string) {
|
||||
const errorPath = join(tempPath, 'HTMLError.html')
|
||||
await writeFile(errorPath, text)
|
||||
return errorPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Display an error message and provide a function to retry the download.
|
||||
*/
|
||||
private failDownload(error: DownloadError) {
|
||||
this.callbacks.error(error, this.cancelable(() => this.beginDownload()))
|
||||
}
|
||||
/**
|
||||
* Display an error message and provide a function to retry the download.
|
||||
*/
|
||||
private failDownload(error: DownloadError) {
|
||||
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)
|
||||
*/
|
||||
cancelDownload() {
|
||||
this.wasCanceled = true
|
||||
googleTimer.cancelTimer() // Prevents timer from trying to activate a download and resetting
|
||||
if (this.req) {
|
||||
// TODO: destroy request
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Stop the process of downloading the file. (no more events will be fired after this is called)
|
||||
*/
|
||||
cancelDownload() {
|
||||
this.wasCanceled = true
|
||||
googleTimer.cancelTimer() // Prevents timer from trying to activate a download and resetting
|
||||
if (this.req) {
|
||||
// TODO: destroy request
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a function that is able to be prevented if `this.cancelDownload()` was called.
|
||||
*/
|
||||
private cancelable<F extends AnyFunction>(fn: F) {
|
||||
return (...args: Parameters<F>): ReturnType<F> => {
|
||||
if (this.wasCanceled) { return }
|
||||
return fn(...Array.from(args))
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Wraps a function that is able to be prevented if `this.cancelDownload()` was called.
|
||||
*/
|
||||
private cancelable<F extends AnyFunction>(fn: F) {
|
||||
return (...args: Parameters<F>): ReturnType<F> => {
|
||||
if (this.wasCanceled) { return }
|
||||
return fn(...Array.from(args))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { 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 { 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'
|
||||
|
||||
const mkdir = promisify(_mkdir)
|
||||
|
||||
type EventCallback = {
|
||||
'start': (filename: string) => void
|
||||
'extractProgress': (percent: number, fileCount: number) => void
|
||||
'error': (err: DownloadError, retry: () => void | Promise<void>) => void
|
||||
'complete': () => void
|
||||
interface EventCallback {
|
||||
'start': (filename: string) => void
|
||||
'extractProgress': (percent: number, fileCount: number) => void
|
||||
'error': (err: DownloadError, retry: () => void | Promise<void>) => void
|
||||
'complete': () => void
|
||||
}
|
||||
type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
|
||||
|
||||
const extractErrors = {
|
||||
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' } },
|
||||
rarmkdirError: (err: NodeJS.ErrnoException, sourceFile: string) => {
|
||||
return { header: `Extracting archive failed. (${err.code})`, body: `${err.name}: ${err.message} (${sourceFile})`}
|
||||
},
|
||||
rarextractError: (result: { reason: FailReason; msg: string }, sourceFile: string) => {
|
||||
return { header: `Extracting archive failed: ${result.reason}`, body: `${result.msg} (${sourceFile})`}
|
||||
}
|
||||
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' } },
|
||||
rarmkdirError: (err: NodeJS.ErrnoException, sourceFile: string) => {
|
||||
return { header: `Extracting archive failed. (${err.code})`, body: `${err.name}: ${err.message} (${sourceFile})` }
|
||||
},
|
||||
rarextractError: (result: { reason: FailReason; msg: string }, sourceFile: string) => {
|
||||
return { header: `Extracting archive failed: ${result.reason}`, body: `${result.msg} (${sourceFile})` }
|
||||
},
|
||||
}
|
||||
|
||||
export class FileExtractor {
|
||||
|
||||
private callbacks = {} as Callbacks
|
||||
private wasCanceled = false
|
||||
constructor(private sourceFolder: string) { }
|
||||
private callbacks = {} as Callbacks
|
||||
private wasCanceled = false
|
||||
constructor(private sourceFolder: string) { }
|
||||
|
||||
/**
|
||||
* 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]) {
|
||||
this.callbacks[event] = callback
|
||||
}
|
||||
/**
|
||||
* 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]) {
|
||||
this.callbacks[event] = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the chart from `this.sourceFolder`. (assumes there is exactly one archive file in that folder)
|
||||
*/
|
||||
beginExtract() {
|
||||
setTimeout(this.cancelable(() => {
|
||||
readdir(this.sourceFolder, (err, files) => {
|
||||
if (err) {
|
||||
this.callbacks.error(extractErrors.readError(err), () => this.beginExtract())
|
||||
} else if (files.length == 0) {
|
||||
this.callbacks.error(extractErrors.emptyError(), () => this.beginExtract())
|
||||
} else {
|
||||
this.callbacks.start(files[0])
|
||||
this.extract(join(this.sourceFolder, files[0]), extname(files[0]) == '.rar')
|
||||
}
|
||||
})
|
||||
}), 100) // Wait for filesystem to process downloaded file
|
||||
}
|
||||
/**
|
||||
* Extract the chart from `this.sourceFolder`. (assumes there is exactly one archive file in that folder)
|
||||
*/
|
||||
beginExtract() {
|
||||
setTimeout(this.cancelable(() => {
|
||||
readdir(this.sourceFolder, (err, files) => {
|
||||
if (err) {
|
||||
this.callbacks.error(extractErrors.readError(err), () => this.beginExtract())
|
||||
} else if (files.length == 0) {
|
||||
this.callbacks.error(extractErrors.emptyError(), () => this.beginExtract())
|
||||
} else {
|
||||
this.callbacks.start(files[0])
|
||||
this.extract(join(this.sourceFolder, files[0]), extname(files[0]) == '.rar')
|
||||
}
|
||||
})
|
||||
}), 100) // Wait for filesystem to process downloaded file
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the file at `fullPath` to `this.sourceFolder`.
|
||||
*/
|
||||
private async extract(fullPath: string, useRarExtractor: boolean) {
|
||||
if (useRarExtractor) {
|
||||
await this.extractRar(fullPath) // Use node-unrar-js to extract the archive
|
||||
} else {
|
||||
this.extract7z(fullPath) // Use node-7z to extract the archive
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Extracts the file at `fullPath` to `this.sourceFolder`.
|
||||
*/
|
||||
private async extract(fullPath: string, useRarExtractor: boolean) {
|
||||
if (useRarExtractor) {
|
||||
await this.extractRar(fullPath) // Use node-unrar-js to extract the archive
|
||||
} else {
|
||||
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`.
|
||||
* @throws an `ExtractError` if this fails.
|
||||
*/
|
||||
private async extractRar(fullPath: string) {
|
||||
const extractor = unrarjs.createExtractorFromFile(fullPath, this.sourceFolder)
|
||||
/**
|
||||
* Extracts a .rar archive found at `fullPath` and puts the extracted results in `this.sourceFolder`.
|
||||
* @throws an `ExtractError` if this fails.
|
||||
*/
|
||||
private async extractRar(fullPath: string) {
|
||||
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)
|
||||
const headers = fileList[1].fileHeaders
|
||||
for (const header of headers) {
|
||||
if (header.flags.directory) {
|
||||
try {
|
||||
await mkdir(join(this.sourceFolder, header.name), { recursive: true })
|
||||
} catch (err) {
|
||||
this.callbacks.error(extractErrors.rarmkdirError(err, fullPath), () => this.extract(fullPath, extname(fullPath) == '.rar'))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Create directories for nested archives (because unrarjs didn't feel like handling that automatically)
|
||||
const headers = fileList[1].fileHeaders
|
||||
for (const header of headers) {
|
||||
if (header.flags.directory) {
|
||||
try {
|
||||
await mkdir(join(this.sourceFolder, header.name), { recursive: true })
|
||||
} catch (err) {
|
||||
this.callbacks.error(extractErrors.rarmkdirError(err, fullPath), () => this.extract(fullPath, extname(fullPath) == '.rar'))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const extractResult = extractor.extractAll()
|
||||
const extractResult = extractor.extractAll()
|
||||
|
||||
if (extractResult[0].state == 'FAIL') {
|
||||
this.callbacks.error(extractErrors.rarextractError(extractResult[0], fullPath), () => this.extract(fullPath, extname(fullPath) == '.rar'))
|
||||
} else {
|
||||
this.deleteArchive(fullPath)
|
||||
}
|
||||
}
|
||||
if (extractResult[0].state == 'FAIL') {
|
||||
this.callbacks.error(extractErrors.rarextractError(extractResult[0], fullPath), () => this.extract(fullPath, extname(fullPath) == '.rar'))
|
||||
} else {
|
||||
this.deleteArchive(fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a .zip or .7z archive found at `fullPath` and puts the extracted results in `this.sourceFolder`.
|
||||
*/
|
||||
private extract7z(fullPath: string) {
|
||||
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 })
|
||||
/**
|
||||
* Extracts a .zip or .7z archive found at `fullPath` and puts the extracted results in `this.sourceFolder`.
|
||||
*/
|
||||
private extract7z(fullPath: string) {
|
||||
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 })
|
||||
|
||||
stream.on('progress', this.cancelable((progress: { percent: number; fileCount: number }) => {
|
||||
this.callbacks.extractProgress(progress.percent, isNaN(progress.fileCount) ? 0 : progress.fileCount)
|
||||
}))
|
||||
stream.on('progress', this.cancelable((progress: { percent: number; fileCount: number }) => {
|
||||
this.callbacks.extractProgress(progress.percent, isNaN(progress.fileCount) ? 0 : progress.fileCount)
|
||||
}))
|
||||
|
||||
let extractErrorOccured = false
|
||||
stream.on('error', this.cancelable(() => {
|
||||
extractErrorOccured = true
|
||||
devLog(`Failed to extract [${fullPath}]; retrying with .rar extractor...`)
|
||||
this.extract(fullPath, true)
|
||||
}))
|
||||
let extractErrorOccured = false
|
||||
stream.on('error', this.cancelable(() => {
|
||||
extractErrorOccured = true
|
||||
devLog(`Failed to extract [${fullPath}]; retrying with .rar extractor...`)
|
||||
this.extract(fullPath, true)
|
||||
}))
|
||||
|
||||
stream.on('end', this.cancelable(() => {
|
||||
if (!extractErrorOccured) {
|
||||
this.deleteArchive(fullPath)
|
||||
}
|
||||
}))
|
||||
}
|
||||
stream.on('end', this.cancelable(() => {
|
||||
if (!extractErrorOccured) {
|
||||
this.deleteArchive(fullPath)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to delete the archive at `fullPath`.
|
||||
*/
|
||||
private deleteArchive(fullPath: string) {
|
||||
unlink(fullPath, this.cancelable((err) => {
|
||||
if (err && err.code != 'ENOENT') {
|
||||
devLog(`Warning: failed to delete archive at [${fullPath}]`)
|
||||
}
|
||||
/**
|
||||
* Tries to delete the archive at `fullPath`.
|
||||
*/
|
||||
private deleteArchive(fullPath: string) {
|
||||
unlink(fullPath, this.cancelable(err => {
|
||||
if (err && err.code != 'ENOENT') {
|
||||
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)
|
||||
*/
|
||||
cancelExtract() {
|
||||
this.wasCanceled = true
|
||||
}
|
||||
/**
|
||||
* Stop the process of extracting the file. (no more events will be fired after this is called)
|
||||
*/
|
||||
cancelExtract() {
|
||||
this.wasCanceled = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a function that is able to be prevented if `this.cancelExtract()` was called.
|
||||
*/
|
||||
private cancelable<F extends AnyFunction>(fn: F) {
|
||||
return (...args: Parameters<F>): ReturnType<F> => {
|
||||
if (this.wasCanceled) { return }
|
||||
return fn(...Array.from(args))
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Wraps a function that is able to be prevented if `this.cancelExtract()` was called.
|
||||
*/
|
||||
private cancelable<F extends AnyFunction>(fn: F) {
|
||||
return (...args: Parameters<F>): ReturnType<F> => {
|
||||
if (this.wasCanceled) { return }
|
||||
return fn(...Array.from(args))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,108 +1,109 @@
|
||||
import { Dirent, readdir as _readdir } from 'fs'
|
||||
import { promisify } from 'util'
|
||||
import { getSettings } from '../SettingsHandler.ipc'
|
||||
import * as mv from 'mv'
|
||||
import { join } from 'path'
|
||||
import { rimraf } from 'rimraf'
|
||||
import { promisify } from 'util'
|
||||
|
||||
import { getSettings } from '../SettingsHandler.ipc'
|
||||
import { DownloadError } from './ChartDownload'
|
||||
|
||||
const readdir = promisify(_readdir)
|
||||
|
||||
type EventCallback = {
|
||||
'start': (destinationFolder: string) => void
|
||||
'error': (err: DownloadError, retry: () => void | Promise<void>) => void
|
||||
'complete': () => void
|
||||
interface EventCallback {
|
||||
'start': (destinationFolder: string) => void
|
||||
'error': (err: DownloadError, retry: () => void | Promise<void>) => void
|
||||
'complete': () => void
|
||||
}
|
||||
type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
|
||||
|
||||
const transferErrors = {
|
||||
readError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to read file.'),
|
||||
deleteError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to delete file.'),
|
||||
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?)' : ''}`)
|
||||
readError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to read file.'),
|
||||
deleteError: (err: NodeJS.ErrnoException) => fsError(err, 'Failed to delete file.'),
|
||||
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?)' : ''}`),
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
private callbacks = {} as Callbacks
|
||||
private wasCanceled = false
|
||||
private destinationFolder: string
|
||||
private nestedSourceFolder: string // The top-level folder that is copied to the library folder
|
||||
constructor(private sourceFolder: string, destinationFolderName: string) {
|
||||
this.destinationFolder = join(getSettings().libraryPath, destinationFolderName)
|
||||
this.nestedSourceFolder = sourceFolder
|
||||
}
|
||||
private callbacks = {} as Callbacks
|
||||
private wasCanceled = false
|
||||
private destinationFolder: string
|
||||
private nestedSourceFolder: string // The top-level folder that is copied to the library folder
|
||||
constructor(private sourceFolder: string, destinationFolderName: string) {
|
||||
this.destinationFolder = join(getSettings().libraryPath, destinationFolderName)
|
||||
this.nestedSourceFolder = sourceFolder
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]) {
|
||||
this.callbacks[event] = callback
|
||||
}
|
||||
/**
|
||||
* 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]) {
|
||||
this.callbacks[event] = callback
|
||||
}
|
||||
|
||||
async beginTransfer() {
|
||||
this.callbacks.start(this.destinationFolder)
|
||||
await this.cleanFolder()
|
||||
if (this.wasCanceled) { return }
|
||||
this.moveFolder()
|
||||
}
|
||||
async beginTransfer() {
|
||||
this.callbacks.start(this.destinationFolder)
|
||||
await this.cleanFolder()
|
||||
if (this.wasCanceled) { return }
|
||||
this.moveFolder()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixes common problems with the download chart folder.
|
||||
*/
|
||||
private async cleanFolder() {
|
||||
let files: Dirent[]
|
||||
try {
|
||||
files = await readdir(this.nestedSourceFolder, { withFileTypes: true })
|
||||
} catch (err) {
|
||||
this.callbacks.error(transferErrors.readError(err), () => this.cleanFolder())
|
||||
return
|
||||
}
|
||||
/**
|
||||
* Fixes common problems with the download chart folder.
|
||||
*/
|
||||
private async cleanFolder() {
|
||||
let files: Dirent[]
|
||||
try {
|
||||
files = await readdir(this.nestedSourceFolder, { withFileTypes: true })
|
||||
} catch (err) {
|
||||
this.callbacks.error(transferErrors.readError(err), () => this.cleanFolder())
|
||||
return
|
||||
}
|
||||
|
||||
// Remove nested folders
|
||||
if (files.length == 1 && !files[0].isFile()) {
|
||||
this.nestedSourceFolder = join(this.nestedSourceFolder, files[0].name)
|
||||
await this.cleanFolder()
|
||||
return
|
||||
}
|
||||
// Remove nested folders
|
||||
if (files.length == 1 && !files[0].isFile()) {
|
||||
this.nestedSourceFolder = join(this.nestedSourceFolder, files[0].name)
|
||||
await this.cleanFolder()
|
||||
return
|
||||
}
|
||||
|
||||
// Delete '__MACOSX' folder
|
||||
for (const file of files) {
|
||||
if (!file.isFile() && file.name == '__MACOSX') {
|
||||
try {
|
||||
await rimraf(join(this.nestedSourceFolder, file.name))
|
||||
} catch (err) {
|
||||
this.callbacks.error(transferErrors.rimrafError(err), () => this.cleanFolder())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// TODO: handle other common problems, like chart/audio files not named correctly
|
||||
}
|
||||
}
|
||||
}
|
||||
// Delete '__MACOSX' folder
|
||||
for (const file of files) {
|
||||
if (!file.isFile() && file.name == '__MACOSX') {
|
||||
try {
|
||||
await rimraf(join(this.nestedSourceFolder, file.name))
|
||||
} catch (err) {
|
||||
this.callbacks.error(transferErrors.rimrafError(err), () => this.cleanFolder())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// TODO: handle other common problems, like chart/audio files not named correctly
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the downloaded chart to the library path.
|
||||
*/
|
||||
private moveFolder() {
|
||||
mv(this.nestedSourceFolder, this.destinationFolder, { mkdirp: true }, (err) => {
|
||||
if (err) {
|
||||
this.callbacks.error(transferErrors.mvError(err), () => this.moveFolder())
|
||||
} else {
|
||||
rimraf(this.sourceFolder) // Delete temp folder
|
||||
this.callbacks.complete()
|
||||
}
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Moves the downloaded chart to the library path.
|
||||
*/
|
||||
private moveFolder() {
|
||||
mv(this.nestedSourceFolder, this.destinationFolder, { mkdirp: true }, err => {
|
||||
if (err) {
|
||||
this.callbacks.error(transferErrors.mvError(err), () => this.moveFolder())
|
||||
} else {
|
||||
rimraf(this.sourceFolder) // Delete temp folder
|
||||
this.callbacks.complete()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the process of transfering the file. (no more events will be fired after this is called)
|
||||
*/
|
||||
cancelTransfer() {
|
||||
this.wasCanceled = true
|
||||
}
|
||||
/**
|
||||
* Stop the process of transfering the file. (no more events will be fired after this is called)
|
||||
*/
|
||||
cancelTransfer() {
|
||||
this.wasCanceled = true
|
||||
}
|
||||
}
|
||||
@@ -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 { mkdir, access, constants } from 'fs'
|
||||
import { access, constants, mkdir } from 'fs'
|
||||
import { join } from 'path'
|
||||
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 { DownloadError } from './ChartDownload'
|
||||
|
||||
const randomBytes = promisify(_randomBytes)
|
||||
|
||||
type EventCallback = {
|
||||
'start': () => void
|
||||
'error': (err: DownloadError, retry: () => void | Promise<void>) => void
|
||||
'complete': (tempPath: string) => void
|
||||
interface EventCallback {
|
||||
'start': () => void
|
||||
'error': (err: DownloadError, retry: () => void | Promise<void>) => void
|
||||
'complete': (tempPath: string) => void
|
||||
}
|
||||
type Callbacks = { [E in keyof EventCallback]: EventCallback[E] }
|
||||
|
||||
const filesystemErrors = {
|
||||
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.'),
|
||||
destinationFolderExists: (destinationPath: string) => {
|
||||
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.')
|
||||
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.'),
|
||||
destinationFolderExists: (destinationPath: string) => {
|
||||
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.'),
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
private callbacks = {} as Callbacks
|
||||
private wasCanceled = false
|
||||
constructor(private destinationFolderName: string) { }
|
||||
private callbacks = {} as Callbacks
|
||||
private wasCanceled = false
|
||||
constructor(private destinationFolderName: string) { }
|
||||
|
||||
/**
|
||||
* 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]) {
|
||||
this.callbacks[event] = callback
|
||||
}
|
||||
/**
|
||||
* 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]) {
|
||||
this.callbacks[event] = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that the filesystem is set up for the download.
|
||||
*/
|
||||
beginCheck() {
|
||||
this.callbacks.start()
|
||||
this.checkLibraryFolder()
|
||||
}
|
||||
/**
|
||||
* Check that the filesystem is set up for the download.
|
||||
*/
|
||||
beginCheck() {
|
||||
this.callbacks.start()
|
||||
this.checkLibraryFolder()
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that the user has specified a library folder.
|
||||
*/
|
||||
private checkLibraryFolder() {
|
||||
if (getSettings().libraryPath == undefined) {
|
||||
this.callbacks.error(filesystemErrors.libraryFolder(), () => this.beginCheck())
|
||||
} else {
|
||||
access(getSettings().libraryPath, constants.W_OK, this.cancelable((err) => {
|
||||
if (err) {
|
||||
this.callbacks.error(filesystemErrors.libraryAccess(err), () => this.beginCheck())
|
||||
} else {
|
||||
this.checkDestinationFolder()
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Verifies that the user has specified a library folder.
|
||||
*/
|
||||
private checkLibraryFolder() {
|
||||
if (getSettings().libraryPath == undefined) {
|
||||
this.callbacks.error(filesystemErrors.libraryFolder(), () => this.beginCheck())
|
||||
} else {
|
||||
access(getSettings().libraryPath, constants.W_OK, this.cancelable(err => {
|
||||
if (err) {
|
||||
this.callbacks.error(filesystemErrors.libraryAccess(err), () => this.beginCheck())
|
||||
} else {
|
||||
this.checkDestinationFolder()
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks that the destination folder doesn't already exist.
|
||||
*/
|
||||
private checkDestinationFolder() {
|
||||
const destinationPath = join(getSettings().libraryPath, this.destinationFolderName)
|
||||
access(destinationPath, constants.F_OK, this.cancelable((err) => {
|
||||
if (err) { // File does not exist
|
||||
this.createDownloadFolder()
|
||||
} else {
|
||||
this.callbacks.error(filesystemErrors.destinationFolderExists(destinationPath), () => this.beginCheck())
|
||||
}
|
||||
}))
|
||||
}
|
||||
/**
|
||||
* Checks that the destination folder doesn't already exist.
|
||||
*/
|
||||
private checkDestinationFolder() {
|
||||
const destinationPath = join(getSettings().libraryPath, this.destinationFolderName)
|
||||
access(destinationPath, constants.F_OK, this.cancelable(err => {
|
||||
if (err) { // File does not exist
|
||||
this.createDownloadFolder()
|
||||
} else {
|
||||
this.callbacks.error(filesystemErrors.destinationFolderExists(destinationPath), () => this.beginCheck())
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to create a unique folder in Bridge's data paths.
|
||||
*/
|
||||
private async createDownloadFolder(retryCount = 0) {
|
||||
const tempChartPath = join(tempPath, `chart_${(await randomBytes(5)).toString('hex')}`)
|
||||
/**
|
||||
* Attempts to create a unique folder in Bridge's data paths.
|
||||
*/
|
||||
private async createDownloadFolder(retryCount = 0) {
|
||||
const tempChartPath = join(tempPath, `chart_${(await randomBytes(5)).toString('hex')}`)
|
||||
|
||||
mkdir(tempChartPath, this.cancelable((err) => {
|
||||
if (err) {
|
||||
if (retryCount < 5) {
|
||||
devLog(`Error creating folder [${tempChartPath}], retrying with a different folder...`)
|
||||
this.createDownloadFolder(retryCount + 1)
|
||||
} else {
|
||||
this.callbacks.error(filesystemErrors.mkdirError(err), () => this.createDownloadFolder())
|
||||
}
|
||||
} else {
|
||||
this.callbacks.complete(tempChartPath)
|
||||
}
|
||||
}))
|
||||
}
|
||||
mkdir(tempChartPath, this.cancelable(err => {
|
||||
if (err) {
|
||||
if (retryCount < 5) {
|
||||
devLog(`Error creating folder [${tempChartPath}], retrying with a different folder...`)
|
||||
this.createDownloadFolder(retryCount + 1)
|
||||
} else {
|
||||
this.callbacks.error(filesystemErrors.mkdirError(err), () => this.createDownloadFolder())
|
||||
}
|
||||
} else {
|
||||
this.callbacks.complete(tempChartPath)
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the process of checking the filesystem permissions. (no more events will be fired after this is called)
|
||||
*/
|
||||
cancelCheck() {
|
||||
this.wasCanceled = true
|
||||
}
|
||||
/**
|
||||
* Stop the process of checking the filesystem permissions. (no more events will be fired after this is called)
|
||||
*/
|
||||
cancelCheck() {
|
||||
this.wasCanceled = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a function that is able to be prevented if `this.cancelCheck()` was called.
|
||||
*/
|
||||
private cancelable<F extends AnyFunction>(fn: F) {
|
||||
return (...args: Parameters<F>): ReturnType<F> => {
|
||||
if (this.wasCanceled) { return }
|
||||
return fn(...Array.from(args))
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Wraps a function that is able to be prevented if `this.cancelCheck()` was called.
|
||||
*/
|
||||
private cancelable<F extends AnyFunction>(fn: F) {
|
||||
return (...args: Parameters<F>): ReturnType<F> => {
|
||||
if (this.wasCanceled) { return }
|
||||
return fn(...Array.from(args))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +1,69 @@
|
||||
import { getSettings } from '../SettingsHandler.ipc'
|
||||
|
||||
type EventCallback = {
|
||||
'waitProgress': (remainingSeconds: number, totalSeconds: number) => void
|
||||
'complete': () => void
|
||||
interface EventCallback {
|
||||
'waitProgress': (remainingSeconds: number, totalSeconds: number) => void
|
||||
'complete': () => void
|
||||
}
|
||||
type Callbacks = { [E in keyof EventCallback]?: EventCallback[E] }
|
||||
|
||||
class GoogleTimer {
|
||||
|
||||
private rateLimitCounter = Infinity
|
||||
private callbacks: Callbacks = {}
|
||||
private rateLimitCounter = Infinity
|
||||
private callbacks: Callbacks = {}
|
||||
|
||||
/**
|
||||
* Initializes the timer to call the callbacks if they are defined.
|
||||
*/
|
||||
constructor() {
|
||||
setInterval(() => {
|
||||
this.rateLimitCounter++
|
||||
this.updateCallbacks()
|
||||
}, 1000)
|
||||
}
|
||||
/**
|
||||
* Initializes the timer to call the callbacks if they are defined.
|
||||
*/
|
||||
constructor() {
|
||||
setInterval(() => {
|
||||
this.rateLimitCounter++
|
||||
this.updateCallbacks()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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]) {
|
||||
this.callbacks[event] = callback
|
||||
this.updateCallbacks() // Fire events immediately after the listeners have been added
|
||||
}
|
||||
/**
|
||||
* 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]) {
|
||||
this.callbacks[event] = callback
|
||||
this.updateCallbacks() // Fire events immediately after the listeners have been added
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the state of the callbacks and call them if necessary.
|
||||
*/
|
||||
private updateCallbacks() {
|
||||
if (this.hasTimerEnded() && this.callbacks.complete != undefined) {
|
||||
this.endTimer()
|
||||
} else if (this.callbacks.waitProgress != undefined) {
|
||||
const delay = getSettings().rateLimitDelay
|
||||
this.callbacks.waitProgress(delay - this.rateLimitCounter, delay)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Check the state of the callbacks and call them if necessary.
|
||||
*/
|
||||
private updateCallbacks() {
|
||||
if (this.hasTimerEnded() && this.callbacks.complete != undefined) {
|
||||
this.endTimer()
|
||||
} else if (this.callbacks.waitProgress != undefined) {
|
||||
const delay = getSettings().rateLimitDelay
|
||||
this.callbacks.waitProgress(delay - this.rateLimitCounter, delay)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevents the callbacks from activating when the timer ends.
|
||||
*/
|
||||
cancelTimer() {
|
||||
this.callbacks = {}
|
||||
}
|
||||
/**
|
||||
* Prevents the callbacks from activating when the timer ends.
|
||||
*/
|
||||
cancelTimer() {
|
||||
this.callbacks = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if enough time has elapsed since the last timer activation.
|
||||
*/
|
||||
private hasTimerEnded() {
|
||||
return this.rateLimitCounter > getSettings().rateLimitDelay
|
||||
}
|
||||
/**
|
||||
* Checks if enough time has elapsed since the last timer activation.
|
||||
*/
|
||||
private hasTimerEnded() {
|
||||
return this.rateLimitCounter > getSettings().rateLimitDelay
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates the completion callback and resets the timer.
|
||||
*/
|
||||
private endTimer() {
|
||||
this.rateLimitCounter = 0
|
||||
const completeCallback = this.callbacks.complete
|
||||
this.callbacks = {}
|
||||
completeCallback()
|
||||
}
|
||||
/**
|
||||
* Activates the completion callback and resets the timer.
|
||||
*/
|
||||
private endTimer() {
|
||||
this.rateLimitCounter = 0
|
||||
const completeCallback = this.callbacks.complete
|
||||
this.callbacks = {}
|
||||
completeCallback()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { app, BrowserWindow, ipcMain } from 'electron'
|
||||
import { updateChecker } from './ipc/UpdateHandler.ipc'
|
||||
import * as windowStateKeeper from 'electron-window-state'
|
||||
import * as path from 'path'
|
||||
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 { updateChecker } from './ipc/UpdateHandler.ipc'
|
||||
// IPC Handlers
|
||||
import { getIPCEmitHandlers, getIPCInvokeHandlers, IPCEmitEvents } from './shared/IPCHandler'
|
||||
import { dataPath } from './shared/Paths'
|
||||
|
||||
require('electron-unhandled')({ showDialog: true })
|
||||
|
||||
export let mainWindow: BrowserWindow
|
||||
const args = process.argv.slice(1)
|
||||
const isDevBuild = args.some(val => val == '--dev')
|
||||
@@ -21,13 +21,13 @@ remote.initialize()
|
||||
restrictToSingleInstance()
|
||||
handleOSXWindowClosed()
|
||||
app.on('ready', () => {
|
||||
// Load settings from file before the window is created
|
||||
getSettingsHandler.initSettings().then(() => {
|
||||
createBridgeWindow()
|
||||
if (!isDevBuild) {
|
||||
updateChecker.checkForUpdates()
|
||||
}
|
||||
})
|
||||
// Load settings from file before the window is created
|
||||
getSettingsHandler.initSettings().then(() => {
|
||||
createBridgeWindow()
|
||||
if (!isDevBuild) {
|
||||
updateChecker.checkForUpdates()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -35,14 +35,14 @@ app.on('ready', () => {
|
||||
* If this is attempted, restore the open window instead.
|
||||
*/
|
||||
function restrictToSingleInstance() {
|
||||
const isFirstBridgeInstance = app.requestSingleInstanceLock()
|
||||
if (!isFirstBridgeInstance) app.quit()
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow != undefined) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
const isFirstBridgeInstance = app.requestSingleInstanceLock()
|
||||
if (!isFirstBridgeInstance) app.quit()
|
||||
app.on('second-instance', () => {
|
||||
if (mainWindow != undefined) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore()
|
||||
mainWindow.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,17 +50,17 @@ function restrictToSingleInstance() {
|
||||
* minimize when closed and maximize when opened.
|
||||
*/
|
||||
function handleOSXWindowClosed() {
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform != 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform != 'darwin') {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('activate', () => {
|
||||
if (mainWindow == undefined) {
|
||||
createBridgeWindow()
|
||||
}
|
||||
})
|
||||
app.on('activate', () => {
|
||||
if (mainWindow == undefined) {
|
||||
createBridgeWindow()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,81 +68,81 @@ function handleOSXWindowClosed() {
|
||||
*/
|
||||
function createBridgeWindow() {
|
||||
|
||||
// Load window size and maximized/restored state from previous session
|
||||
const windowState = windowStateKeeper({
|
||||
defaultWidth: 1000,
|
||||
defaultHeight: 800,
|
||||
path: dataPath
|
||||
})
|
||||
// Load window size and maximized/restored state from previous session
|
||||
const windowState = windowStateKeeper({
|
||||
defaultWidth: 1000,
|
||||
defaultHeight: 800,
|
||||
path: dataPath,
|
||||
})
|
||||
|
||||
// Create the browser window
|
||||
mainWindow = createBrowserWindow(windowState)
|
||||
// Create the browser window
|
||||
mainWindow = createBrowserWindow(windowState)
|
||||
|
||||
// Store window size and maximized/restored state for next session
|
||||
windowState.manage(mainWindow)
|
||||
// Store window size and maximized/restored state for next session
|
||||
windowState.manage(mainWindow)
|
||||
|
||||
// Don't use a system menu
|
||||
mainWindow.setMenu(null)
|
||||
// Don't use a system menu
|
||||
mainWindow.setMenu(null)
|
||||
|
||||
// IPC handlers
|
||||
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])))
|
||||
// IPC handlers
|
||||
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])))
|
||||
|
||||
// Load angular app
|
||||
mainWindow.loadURL(getLoadUrl())
|
||||
// Load angular app
|
||||
mainWindow.loadURL(getLoadUrl())
|
||||
|
||||
if (isDevBuild) {
|
||||
mainWindow.webContents.openDevTools()
|
||||
}
|
||||
if (isDevBuild) {
|
||||
mainWindow.webContents.openDevTools()
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null // Dereference mainWindow when the window is closed
|
||||
})
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null // Dereference mainWindow when the window is closed
|
||||
})
|
||||
|
||||
// enable the remote webcontents
|
||||
remote.enable(mainWindow.webContents)
|
||||
// enable the remote webcontents
|
||||
remote.enable(mainWindow.webContents)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a BrowserWindow object with initial parameters
|
||||
*/
|
||||
function createBrowserWindow(windowState: windowStateKeeper.State) {
|
||||
let options: Electron.BrowserWindowConstructorOptions = {
|
||||
x: windowState.x,
|
||||
y: windowState.y,
|
||||
width: windowState.width,
|
||||
height: windowState.height,
|
||||
frame: false,
|
||||
title: 'Bridge',
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
allowRunningInsecureContent: (isDevBuild) ? true : false,
|
||||
textAreasAreResizable: false,
|
||||
contextIsolation: false
|
||||
},
|
||||
simpleFullscreen: true,
|
||||
fullscreenable: false,
|
||||
backgroundColor: '#121212'
|
||||
}
|
||||
let options: Electron.BrowserWindowConstructorOptions = {
|
||||
x: windowState.x,
|
||||
y: windowState.y,
|
||||
width: windowState.width,
|
||||
height: windowState.height,
|
||||
frame: false,
|
||||
title: 'Bridge',
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
allowRunningInsecureContent: (isDevBuild) ? true : false,
|
||||
textAreasAreResizable: false,
|
||||
contextIsolation: false,
|
||||
},
|
||||
simpleFullscreen: true,
|
||||
fullscreenable: false,
|
||||
backgroundColor: '#121212',
|
||||
}
|
||||
|
||||
if (process.platform == 'linux' && !isDevBuild) {
|
||||
options = Object.assign(options, { icon: path.join(__dirname, '..', 'assets', 'images', 'system', 'icons', 'png', '48x48.png' ) })
|
||||
}
|
||||
if (process.platform == 'linux' && !isDevBuild) {
|
||||
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
|
||||
*/
|
||||
function getLoadUrl() {
|
||||
return url.format({
|
||||
protocol: isDevBuild ? 'http:' : 'file:',
|
||||
pathname: isDevBuild ? '//localhost:4200/' : path.join(__dirname, '..', 'index.html'),
|
||||
slashes: true
|
||||
})
|
||||
return url.format({
|
||||
protocol: isDevBuild ? 'http:' : 'file:',
|
||||
pathname: isDevBuild ? '//localhost:4200/' : path.join(__dirname, '..', 'index.html'),
|
||||
slashes: true,
|
||||
})
|
||||
}
|
||||
|
||||
export function emitIPCEvent<E extends keyof IPCEmitEvents>(event: E, data: IPCEmitEvents[E]) {
|
||||
mainWindow.webContents.send(event, data)
|
||||
mainWindow.webContents.send(event, data)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { basename, parse } from 'path'
|
||||
|
||||
import { getSettingsHandler } from '../ipc/SettingsHandler.ipc'
|
||||
import { emitIPCEvent } from '../main'
|
||||
import { lower } from './UtilFunctions'
|
||||
@@ -7,15 +8,15 @@ import { lower } from './UtilFunctions'
|
||||
* @returns The relative filepath from the library folder to `absoluteFilepath`.
|
||||
*/
|
||||
export function getRelativeFilepath(absoluteFilepath: string) {
|
||||
const settings = getSettingsHandler.getSettings()
|
||||
return basename(settings.libraryPath) + absoluteFilepath.substring(settings.libraryPath.length)
|
||||
const settings = getSettingsHandler.getSettings()
|
||||
return basename(settings.libraryPath) + absoluteFilepath.substring(settings.libraryPath.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns `true` if `name` has a valid video file extension.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
export function devLog(...messages: any[]) {
|
||||
emitIPCEvent('log', messages)
|
||||
emitIPCEvent('log', messages)
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
import { SongSearch, SongResult } from './interfaces/search.interface'
|
||||
import { VersionResult, AlbumArtResult } from './interfaces/songDetails.interface'
|
||||
import { UpdateInfo } from 'electron-updater'
|
||||
|
||||
import { albumArtHandler } from '../ipc/browse/AlbumArtHandler.ipc'
|
||||
import { batchSongDetailsHandler } from '../ipc/browse/BatchSongDetailsHandler.ipc'
|
||||
import { searchHandler } from '../ipc/browse/SearchHandler.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 { updateChecker, UpdateProgress, getCurrentVersionHandler, downloadUpdateHandler, quitAndInstallHandler, getUpdateAvailableHandler } from '../ipc/UpdateHandler.ipc'
|
||||
import { UpdateInfo } from 'electron-updater'
|
||||
import { downloadHandler } from '../ipc/download/DownloadHandler'
|
||||
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:
|
||||
@@ -22,101 +23,101 @@ import { openURLHandler } from '../ipc/OpenURLHandler.ipc'
|
||||
*/
|
||||
|
||||
export function getIPCInvokeHandlers(): IPCInvokeHandler<keyof IPCInvokeEvents>[] {
|
||||
return [
|
||||
getSettingsHandler,
|
||||
clearCacheHandler,
|
||||
searchHandler,
|
||||
songDetailsHandler,
|
||||
batchSongDetailsHandler,
|
||||
albumArtHandler,
|
||||
getCurrentVersionHandler,
|
||||
getUpdateAvailableHandler,
|
||||
]
|
||||
return [
|
||||
getSettingsHandler,
|
||||
clearCacheHandler,
|
||||
searchHandler,
|
||||
songDetailsHandler,
|
||||
batchSongDetailsHandler,
|
||||
albumArtHandler,
|
||||
getCurrentVersionHandler,
|
||||
getUpdateAvailableHandler,
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* The list of possible async IPC events that return values, mapped to their input and output types.
|
||||
*/
|
||||
export type IPCInvokeEvents = {
|
||||
'get-settings': {
|
||||
input: undefined
|
||||
output: Settings
|
||||
}
|
||||
'clear-cache': {
|
||||
input: undefined
|
||||
output: void
|
||||
}
|
||||
'song-search': {
|
||||
input: SongSearch
|
||||
output: SongResult[]
|
||||
}
|
||||
'album-art': {
|
||||
input: SongResult['id']
|
||||
output: AlbumArtResult
|
||||
}
|
||||
'song-details': {
|
||||
input: SongResult['id']
|
||||
output: VersionResult[]
|
||||
}
|
||||
'batch-song-details': {
|
||||
input: number[]
|
||||
output: VersionResult[]
|
||||
}
|
||||
'get-current-version': {
|
||||
input: undefined
|
||||
output: string
|
||||
}
|
||||
'get-update-available': {
|
||||
input: undefined
|
||||
output: boolean
|
||||
}
|
||||
export interface IPCInvokeEvents {
|
||||
'get-settings': {
|
||||
input: undefined
|
||||
output: Settings
|
||||
}
|
||||
'clear-cache': {
|
||||
input: undefined
|
||||
output: void
|
||||
}
|
||||
'song-search': {
|
||||
input: SongSearch
|
||||
output: SongResult[]
|
||||
}
|
||||
'album-art': {
|
||||
input: SongResult['id']
|
||||
output: AlbumArtResult
|
||||
}
|
||||
'song-details': {
|
||||
input: SongResult['id']
|
||||
output: VersionResult[]
|
||||
}
|
||||
'batch-song-details': {
|
||||
input: number[]
|
||||
output: VersionResult[]
|
||||
}
|
||||
'get-current-version': {
|
||||
input: undefined
|
||||
output: string
|
||||
}
|
||||
'get-update-available': {
|
||||
input: undefined
|
||||
output: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes an object that handles the `E` async IPC event that will return a value.
|
||||
*/
|
||||
export interface IPCInvokeHandler<E extends keyof IPCInvokeEvents> {
|
||||
event: E
|
||||
handler(data: IPCInvokeEvents[E]['input']): Promise<IPCInvokeEvents[E]['output']> | IPCInvokeEvents[E]['output']
|
||||
event: E
|
||||
handler(data: IPCInvokeEvents[E]['input']): Promise<IPCInvokeEvents[E]['output']> | IPCInvokeEvents[E]['output']
|
||||
}
|
||||
|
||||
|
||||
export function getIPCEmitHandlers(): IPCEmitHandler<keyof IPCEmitEvents>[] {
|
||||
return [
|
||||
downloadHandler,
|
||||
setSettingsHandler,
|
||||
downloadUpdateHandler,
|
||||
updateChecker,
|
||||
quitAndInstallHandler,
|
||||
openURLHandler
|
||||
]
|
||||
return [
|
||||
downloadHandler,
|
||||
setSettingsHandler,
|
||||
downloadUpdateHandler,
|
||||
updateChecker,
|
||||
quitAndInstallHandler,
|
||||
openURLHandler,
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* The list of possible async IPC events that don't return values, mapped to their input types.
|
||||
*/
|
||||
export type IPCEmitEvents = {
|
||||
'log': any[]
|
||||
export interface IPCEmitEvents {
|
||||
'log': any[]
|
||||
|
||||
'download': Download
|
||||
'download-updated': DownloadProgress
|
||||
'set-settings': Settings
|
||||
'queue-updated': number[]
|
||||
'download': Download
|
||||
'download-updated': DownloadProgress
|
||||
'set-settings': Settings
|
||||
'queue-updated': number[]
|
||||
|
||||
'update-error': Error
|
||||
'update-available': UpdateInfo
|
||||
'update-progress': UpdateProgress
|
||||
'update-downloaded': undefined
|
||||
'download-update': undefined
|
||||
'retry-update': undefined
|
||||
'quit-and-install': undefined
|
||||
'open-url': string
|
||||
'update-error': Error
|
||||
'update-available': UpdateInfo
|
||||
'update-progress': UpdateProgress
|
||||
'update-downloaded': undefined
|
||||
'download-update': undefined
|
||||
'retry-update': undefined
|
||||
'quit-and-install': undefined
|
||||
'open-url': string
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes an object that handles the `E` async IPC event that will not return a value.
|
||||
*/
|
||||
export interface IPCEmitHandler<E extends keyof IPCEmitEvents> {
|
||||
event: E
|
||||
handler(data: IPCEmitEvents[E]): void
|
||||
event: E
|
||||
handler(data: IPCEmitEvents[E]): void
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { join } from 'path'
|
||||
import { app } from 'electron'
|
||||
import { join } from 'path'
|
||||
|
||||
// Data paths
|
||||
export const dataPath = join(app.getPath('userData'), 'bridge_data')
|
||||
|
||||
@@ -2,18 +2,18 @@
|
||||
* Represents Bridge's user settings.
|
||||
*/
|
||||
export interface Settings {
|
||||
rateLimitDelay: number // Number of seconds to wait between each file download from Google servers
|
||||
downloadVideos: boolean // If background videos should be downloaded
|
||||
theme: string // The name of the currently enabled UI theme
|
||||
libraryPath: string // The path to the user's library
|
||||
rateLimitDelay: number // Number of seconds to wait between each file download from Google servers
|
||||
downloadVideos: boolean // If background videos should be downloaded
|
||||
theme: string // The name of the currently enabled UI theme
|
||||
libraryPath: string // The path to the user's library
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge's default user settings.
|
||||
*/
|
||||
export const defaultSettings: Settings = {
|
||||
rateLimitDelay: 31,
|
||||
downloadVideos: true,
|
||||
theme: 'Default',
|
||||
libraryPath: undefined
|
||||
rateLimitDelay: 31,
|
||||
downloadVideos: true,
|
||||
theme: 'Default',
|
||||
libraryPath: undefined,
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as randomBytes from 'randombytes'
|
||||
|
||||
const sanitize = require('sanitize-filename')
|
||||
|
||||
// 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.
|
||||
*/
|
||||
export function sanitizeFilename(filename: string): string {
|
||||
const newFilename = sanitize(filename, {
|
||||
replacement: ((invalidChar: string) => {
|
||||
switch (invalidChar) {
|
||||
case '<': return '❮'
|
||||
case '>': return '❯'
|
||||
case ':': return '꞉'
|
||||
case '"': return "'"
|
||||
case '/': return '/'
|
||||
case '\\': return '⧵'
|
||||
case '|': return '⏐'
|
||||
case '?': return '?'
|
||||
case '*': return '⁎'
|
||||
default: return '_'
|
||||
}
|
||||
})
|
||||
})
|
||||
return (newFilename == '' ? randomBytes(5).toString('hex') : newFilename)
|
||||
const newFilename = sanitize(filename, {
|
||||
replacement: ((invalidChar: string) => {
|
||||
switch (invalidChar) {
|
||||
case '<': return '❮'
|
||||
case '>': return '❯'
|
||||
case ':': return '꞉'
|
||||
case '"': return "'"
|
||||
case '/': return '/'
|
||||
case '\\': return '⧵'
|
||||
case '|': return '⏐'
|
||||
case '?': return '?'
|
||||
case '*': return '⁎'
|
||||
default: return '_'
|
||||
}
|
||||
}),
|
||||
})
|
||||
return (newFilename == '' ? randomBytes(5).toString('hex') : newFilename)
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns `text` converted to lower case.
|
||||
*/
|
||||
export function lower(text: string) {
|
||||
return text.toLowerCase()
|
||||
return text.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
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.
|
||||
*/
|
||||
export function groupBy<T>(objectList: T[], ...keys: (keyof T)[]) {
|
||||
const results: T[][] = []
|
||||
for (const object of objectList) {
|
||||
const matchingGroup = results.find(result => keys.every(key => result[0][key] == object[key]))
|
||||
if (matchingGroup != undefined) {
|
||||
matchingGroup.push(object)
|
||||
} else {
|
||||
results.push([object])
|
||||
}
|
||||
}
|
||||
const results: T[][] = []
|
||||
for (const object of objectList) {
|
||||
const matchingGroup = results.find(result => keys.every(key => result[0][key] == object[key]))
|
||||
if (matchingGroup != undefined) {
|
||||
matchingGroup.push(object)
|
||||
} else {
|
||||
results.push([object])
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
return results
|
||||
}
|
||||
@@ -4,35 +4,35 @@ import { DriveChart } from './songDetails.interface'
|
||||
* Represents a user's request to interact with the download system.
|
||||
*/
|
||||
export interface Download {
|
||||
action: 'add' | 'retry' | 'cancel'
|
||||
versionID: number
|
||||
data?: NewDownload // Should be defined if action == 'add'
|
||||
action: 'add' | 'retry' | 'cancel'
|
||||
versionID: number
|
||||
data?: NewDownload // Should be defined if action == 'add'
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains the data required to start downloading a single chart.
|
||||
*/
|
||||
export interface NewDownload {
|
||||
chartName: string
|
||||
artist: string
|
||||
charter: string
|
||||
driveData: DriveChart & { inChartPack: boolean }
|
||||
chartName: string
|
||||
artist: string
|
||||
charter: string
|
||||
driveData: DriveChart & { inChartPack: boolean }
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the download progress of a single chart.
|
||||
*/
|
||||
export interface DownloadProgress {
|
||||
versionID: number
|
||||
title: string
|
||||
header: string
|
||||
description: string
|
||||
percent: number
|
||||
type: ProgressType
|
||||
/** If `description` contains a filepath that can be clicked */
|
||||
isLink: boolean
|
||||
/** If the download should not appear in the total download progress */
|
||||
stale?: boolean
|
||||
versionID: number
|
||||
title: string
|
||||
header: string
|
||||
description: string
|
||||
percent: number
|
||||
type: ProgressType
|
||||
/** If `description` contains a filepath that can be clicked */
|
||||
isLink: boolean
|
||||
/** If the download should not appear in the total download progress */
|
||||
stale?: boolean
|
||||
}
|
||||
|
||||
export type ProgressType = 'good' | 'error' | 'cancel' | 'done'
|
||||
@@ -2,86 +2,90 @@
|
||||
* Represents a user's song search query.
|
||||
*/
|
||||
export interface SongSearch { // TODO: make limit a setting that's not always 51
|
||||
query: string
|
||||
quantity: 'all' | 'any'
|
||||
similarity: 'similar' | 'exact'
|
||||
fields: SearchFields
|
||||
tags: SearchTags
|
||||
instruments: SearchInstruments
|
||||
difficulties: SearchDifficulties
|
||||
minDiff: number
|
||||
maxDiff: number
|
||||
limit: number
|
||||
offset: number
|
||||
query: string
|
||||
quantity: 'all' | 'any'
|
||||
similarity: 'similar' | 'exact'
|
||||
fields: SearchFields
|
||||
tags: SearchTags
|
||||
instruments: SearchInstruments
|
||||
difficulties: SearchDifficulties
|
||||
minDiff: number
|
||||
maxDiff: number
|
||||
limit: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
export function getDefaultSearch(): SongSearch {
|
||||
return {
|
||||
query: '',
|
||||
quantity: 'all',
|
||||
similarity: 'similar',
|
||||
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,
|
||||
'video': false, 'stems': false, 'solo sections': false, 'open notes': false },
|
||||
instruments: { guitar: false, bass: false, rhythm: false, keys: false,
|
||||
drums: false, guitarghl: false, bassghl: false, vocals: false },
|
||||
difficulties: { expert: false, hard: false, medium: false, easy: false },
|
||||
minDiff: 0,
|
||||
maxDiff: 6,
|
||||
limit: 50 + 1,
|
||||
offset: 0
|
||||
}
|
||||
return {
|
||||
query: '',
|
||||
quantity: 'all',
|
||||
similarity: 'similar',
|
||||
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,
|
||||
'video': false, 'stems': false, 'solo sections': false, 'open notes': false
|
||||
},
|
||||
instruments: {
|
||||
guitar: false, bass: false, rhythm: false, keys: false,
|
||||
drums: false, guitarghl: false, bassghl: false, vocals: false
|
||||
},
|
||||
difficulties: { expert: false, hard: false, medium: false, easy: false },
|
||||
minDiff: 0,
|
||||
maxDiff: 6,
|
||||
limit: 50 + 1,
|
||||
offset: 0,
|
||||
}
|
||||
}
|
||||
|
||||
export interface SearchFields {
|
||||
name: boolean
|
||||
artist: boolean
|
||||
album: boolean
|
||||
genre: boolean
|
||||
year: boolean
|
||||
charter: boolean
|
||||
tag: boolean
|
||||
name: boolean
|
||||
artist: boolean
|
||||
album: boolean
|
||||
genre: boolean
|
||||
year: boolean
|
||||
charter: boolean
|
||||
tag: boolean
|
||||
}
|
||||
|
||||
export interface SearchTags {
|
||||
'sections': boolean // Tag inverted
|
||||
'star power': boolean // Tag inverted
|
||||
'forcing': boolean // Tag inverted
|
||||
'taps': boolean
|
||||
'lyrics': boolean
|
||||
'video': boolean
|
||||
'stems': boolean
|
||||
'solo sections': boolean
|
||||
'open notes': boolean
|
||||
'sections': boolean // Tag inverted
|
||||
'star power': boolean // Tag inverted
|
||||
'forcing': boolean // Tag inverted
|
||||
'taps': boolean
|
||||
'lyrics': boolean
|
||||
'video': boolean
|
||||
'stems': boolean
|
||||
'solo sections': boolean
|
||||
'open notes': boolean
|
||||
}
|
||||
|
||||
export interface SearchInstruments {
|
||||
guitar: boolean
|
||||
bass: boolean
|
||||
rhythm: boolean
|
||||
keys: boolean
|
||||
drums: boolean
|
||||
guitarghl: boolean
|
||||
bassghl: boolean
|
||||
vocals: boolean
|
||||
guitar: boolean
|
||||
bass: boolean
|
||||
rhythm: boolean
|
||||
keys: boolean
|
||||
drums: boolean
|
||||
guitarghl: boolean
|
||||
bassghl: boolean
|
||||
vocals: boolean
|
||||
}
|
||||
|
||||
export interface SearchDifficulties {
|
||||
expert: boolean
|
||||
hard: boolean
|
||||
medium: boolean
|
||||
easy: boolean
|
||||
expert: boolean
|
||||
hard: boolean
|
||||
medium: boolean
|
||||
easy: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single song search result.
|
||||
*/
|
||||
export interface SongResult {
|
||||
id: number
|
||||
chartCount: number
|
||||
name: string
|
||||
artist: string
|
||||
album: string
|
||||
genre: string
|
||||
year: string
|
||||
id: number
|
||||
chartCount: number
|
||||
name: string
|
||||
artist: string
|
||||
album: string
|
||||
genre: string
|
||||
year: string
|
||||
}
|
||||
@@ -2,100 +2,100 @@
|
||||
* The image data for a song's album art.
|
||||
*/
|
||||
export interface AlbumArtResult {
|
||||
base64Art: string
|
||||
base64Art: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a single chart version.
|
||||
*/
|
||||
export interface VersionResult {
|
||||
versionID: number
|
||||
chartID: number
|
||||
songID: number
|
||||
latestVersionID: number
|
||||
latestSetlistVersionID: number
|
||||
name: string
|
||||
chartName: string
|
||||
artist: string
|
||||
album: string
|
||||
genre: string
|
||||
year: string
|
||||
songDataIncorrect: boolean
|
||||
driveData: DriveChart & { inChartPack: boolean }
|
||||
md5: string
|
||||
lastModified: string
|
||||
icon: string
|
||||
charters: string
|
||||
charterIDs: string
|
||||
tags: string | null
|
||||
songLength: number
|
||||
diff_band: number
|
||||
diff_guitar: number
|
||||
diff_bass: number
|
||||
diff_rhythm: number
|
||||
diff_drums: number
|
||||
diff_keys: number
|
||||
diff_guitarghl: number
|
||||
diff_bassghl: number
|
||||
chartData: ChartData
|
||||
versionID: number
|
||||
chartID: number
|
||||
songID: number
|
||||
latestVersionID: number
|
||||
latestSetlistVersionID: number
|
||||
name: string
|
||||
chartName: string
|
||||
artist: string
|
||||
album: string
|
||||
genre: string
|
||||
year: string
|
||||
songDataIncorrect: boolean
|
||||
driveData: DriveChart & { inChartPack: boolean }
|
||||
md5: string
|
||||
lastModified: string
|
||||
icon: string
|
||||
charters: string
|
||||
charterIDs: string
|
||||
tags: string | null
|
||||
songLength: number
|
||||
diff_band: number
|
||||
diff_guitar: number
|
||||
diff_bass: number
|
||||
diff_rhythm: number
|
||||
diff_drums: number
|
||||
diff_keys: number
|
||||
diff_guitarghl: number
|
||||
diff_bassghl: number
|
||||
chartData: ChartData
|
||||
}
|
||||
|
||||
export interface DriveChart {
|
||||
source: DriveSource
|
||||
isArchive: boolean
|
||||
downloadPath: string
|
||||
filesHash: string
|
||||
folderName: string
|
||||
folderID: string
|
||||
files: DriveFile[]
|
||||
source: DriveSource
|
||||
isArchive: boolean
|
||||
downloadPath: string
|
||||
filesHash: string
|
||||
folderName: string
|
||||
folderID: string
|
||||
files: DriveFile[]
|
||||
}
|
||||
|
||||
export interface DriveSource {
|
||||
isSetlistSource: boolean
|
||||
isDriveFileSource?: boolean
|
||||
setlistIcon?: string
|
||||
sourceUserIDs: number[]
|
||||
sourceName: string
|
||||
sourceDriveID: string
|
||||
proxyLink?: string
|
||||
isSetlistSource: boolean
|
||||
isDriveFileSource?: boolean
|
||||
setlistIcon?: string
|
||||
sourceUserIDs: number[]
|
||||
sourceName: string
|
||||
sourceDriveID: string
|
||||
proxyLink?: string
|
||||
}
|
||||
|
||||
export interface DriveFile {
|
||||
id: string
|
||||
name: string
|
||||
mimeType: string
|
||||
webContentLink: string
|
||||
modifiedTime: string
|
||||
md5Checksum: string
|
||||
size: string
|
||||
id: string
|
||||
name: string
|
||||
mimeType: string
|
||||
webContentLink: string
|
||||
modifiedTime: string
|
||||
md5Checksum: string
|
||||
size: string
|
||||
}
|
||||
|
||||
export interface ChartData {
|
||||
hasSections: boolean
|
||||
hasStarPower: boolean
|
||||
hasForced: boolean
|
||||
hasTap: boolean
|
||||
hasOpen: {
|
||||
[instrument: string]: boolean
|
||||
}
|
||||
hasSoloSections: boolean
|
||||
hasLyrics: boolean
|
||||
is120: boolean
|
||||
hasBrokenNotes: boolean
|
||||
noteCounts: {
|
||||
[instrument in Instrument]: {
|
||||
[difficulty in ChartedDifficulty]: number
|
||||
}
|
||||
}
|
||||
/** number of seconds */
|
||||
length: number
|
||||
/** number of seconds */
|
||||
effectiveLength: number
|
||||
hasSections: boolean
|
||||
hasStarPower: boolean
|
||||
hasForced: boolean
|
||||
hasTap: boolean
|
||||
hasOpen: {
|
||||
[instrument: string]: boolean
|
||||
}
|
||||
hasSoloSections: boolean
|
||||
hasLyrics: boolean
|
||||
is120: boolean
|
||||
hasBrokenNotes: boolean
|
||||
noteCounts: {
|
||||
[instrument in Instrument]: {
|
||||
[difficulty in ChartedDifficulty]: number
|
||||
}
|
||||
}
|
||||
/** number of seconds */
|
||||
length: number
|
||||
/** number of seconds */
|
||||
effectiveLength: number
|
||||
}
|
||||
|
||||
export type Instrument = 'guitar' | 'bass' | 'rhythm' | 'keys' | 'drums' | 'guitarghl' | 'bassghl' | 'vocals' | 'undefined'
|
||||
export type ChartedDifficulty = 'x' | 'h' | 'm' | 'e'
|
||||
|
||||
export function getInstrumentIcon(instrument: Instrument) {
|
||||
return `${instrument}.png`
|
||||
return `${instrument}.png`
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
declare module 'node-7z'
|
||||
@@ -1,3 +1,3 @@
|
||||
export const environment = {
|
||||
production: true
|
||||
production: true,
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// The list of file replacements can be found in `angular.json`.
|
||||
|
||||
export const environment = {
|
||||
production: false
|
||||
production: false,
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Bridge</title>
|
||||
<base href="./">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Bridge</title>
|
||||
<base href="./" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -5,8 +5,8 @@ import { AppModule } from './app/app.module'
|
||||
import { environment } from './environments/environment'
|
||||
|
||||
if (environment.production) {
|
||||
enableProdMode()
|
||||
enableProdMode()
|
||||
}
|
||||
|
||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
||||
.catch(err => console.error(err))
|
||||
.catch(err => console.error(err))
|
||||
|
||||
@@ -45,8 +45,7 @@
|
||||
/***************************************************************************************************
|
||||
* 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
|
||||
|
||||
9
src/typings.d.ts
vendored
9
src/typings.d.ts
vendored
@@ -2,14 +2,13 @@
|
||||
/* SystemJS module definition */
|
||||
declare let nodeModule: NodeModule
|
||||
interface NodeModule {
|
||||
id: string
|
||||
id: string
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
// declare let window: Window
|
||||
declare let $: any
|
||||
interface Window {
|
||||
process: any
|
||||
require: any
|
||||
jQuery: any
|
||||
process: any
|
||||
require: any
|
||||
jQuery: any
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"noImplicitAny": true,
|
||||
"suppressImplicitAnyIndexErrors": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"downlevelIteration": true,
|
||||
@@ -19,5 +18,8 @@
|
||||
],
|
||||
"module": "commonjs",
|
||||
"useDefineForClassFields": false
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user