- Update API

- Add Chart Preview
- Add Drum Type dropdown when the "drums" instrument is selected
- Add Min/Max Year to advanced search
- Add Track Hash to advanced search
- Add "Download Video Backgrounds" setting
- Updated and improved detected chart issues
This commit is contained in:
Geomitron
2024-07-16 15:20:58 -05:00
parent 627896a8c8
commit 353994b8e1
43 changed files with 1808 additions and 510 deletions

View File

@@ -5,7 +5,6 @@ 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' },

View File

@@ -8,6 +8,7 @@ import { AppComponent } from './app.component'
import { BrowseComponent } from './components/browse/browse.component'
import { ChartSidebarInstrumentComponent } from './components/browse/chart-sidebar/chart-sidebar-instrument/chart-sidebar-instrument.component'
import { ChartSidebarMenutComponent } from './components/browse/chart-sidebar/chart-sidebar-menu/chart-sidebar-menu.component'
import { ChartSidebarPreviewComponent } from './components/browse/chart-sidebar/chart-sidebar-preview/chart-sidebar-preview.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'
@@ -29,6 +30,7 @@ import { RemoveStyleTagsPipe } from './core/pipes/remove-style-tags.pipe'
ChartSidebarComponent,
ChartSidebarInstrumentComponent,
ChartSidebarMenutComponent,
ChartSidebarPreviewComponent,
ResultTableRowComponent,
DownloadsModalComponent,
RemoveStyleTagsPipe,

View File

@@ -1,5 +1,5 @@
<div class="flex gap-2 items-center">
<img class="w-11 h-11" src="assets/images/instruments/{{ instrument }}.png" />
<img class="w-11 h-11" src="https://static.enchor.us/instrument-{{ instrument }}.png" />
<div class="leading-4">
<span>Diff: {{ getDiff() }}</span>
<div>

View File

@@ -162,9 +162,9 @@
</td>
<td>
<div class="flex flex-nowrap items-center">
{{ version.chartMd5.substring(0, 7) }}
{{ version.chartHash.substring(0, 7) }}
<div class="tooltip tooltip-accent" data-tip="Copy full hash">
<button class="btn btn-circle btn-ghost btn-xs" (click)="copyHash(version.chartMd5)">
<button class="btn btn-circle btn-ghost btn-xs" (click)="copyHash(version.chartHash)">
<i class="bi bi-copy text-xs"></i>
</button>
</div>

View File

@@ -71,13 +71,21 @@ export class ChartSidebarMenutComponent implements OnInit {
breadcrumbs.push({ name: version.driveFileName!, link: driveLink(version.driveFileId) })
if (version.driveChartIsPack) {
breadcrumbs.push({ name: this.joinPaths(version.archivePath!, version.chartFileName ?? ''), link: null })
breadcrumbs.push({ name: this.removeFirstPathSegment(version.internalPath), link: null })
}
}
return breadcrumbs
}
private removeFirstPathSegment(path: string) {
const segments = path.split('/').filter(p => p.length > 0)
if (segments.length > 1) {
return segments.slice(1).join('/')
}
return path
}
isFalseReportOption() {
switch (this.reportOption.value) {
case 'No notes / chart ends immediately': return true
@@ -91,12 +99,6 @@ export class ChartSidebarMenutComponent implements OnInit {
window.electron.emit.openUrl(url)
}
joinPaths(...args: string[]) {
return args.join('/')
.replace(/\/+/g, '/')
.replace(/^\/|\/$/g, '')
}
copyLink(hash: string) {
navigator.clipboard.writeText(`https://enchor.us/?hash=${hash}`)
}

View File

@@ -0,0 +1,41 @@
<div class="h-full w-full flex flex-col pt-3">
<div #previewDiv class="flex-1 w-full bg-black" (click)="togglePlaying()"></div>
<div class="join flex">
<button class="btn join-item btn-neutral btn-sm rounded-none px-2" (click)="togglePlaying()">
@switch (playState) {
@case ('end') {
<i class="bi bi-arrow-counterclockwise text-xl text-neutral-content"></i>
}
@case ('paused') {
<i class="bi bi-play text-xl text-neutral-content"></i>
}
@case ('loading') {
<span class="loading loading-spinner loading-sm self-center"></span>
}
@case ('play') {
<i class="bi bi-pause text-xl text-neutral-content"></i>
}
}
</button>
<div class="dropdown dropdown-top dropdown-hover join-item">
<button tabindex="0" class="btn btn-neutral btn-sm rounded-none px-2" (click)="toggleMuted()">
@if (volumeBar.value === 0) {
<i class="bi bi-volume-mute text-xl text-neutral-content"></i>
} @else if (volumeBar.value <= 50) {
<i class="bi bi-volume-down text-xl text-neutral-content"></i>
} @else {
<i class="bi bi-volume-up text-xl text-neutral-content"></i>
}
</button>
<ul tabindex="0" class="menu dropdown-content z-[1] ml-9 w-28 !origin-bottom-left -rotate-90 bg-base-100 p-2 shadow">
<input type="range" min="0" max="100" class="range range-xs" [formControl]="volumeBar" />
</ul>
</div>
@if (chartPreview) {
<button class="btn join-item btn-neutral no-animation btn-sm rounded-none px-1">{{ timestampText }}</button>
}
<button class="btn join-item btn-neutral no-animation btn-sm flex-grow rounded-none px-2">
<input type="range" min="0" max="100" class="range range-primary range-xs" [formControl]="seekBar" />
</button>
</div>
</div>

View File

@@ -0,0 +1,280 @@
import { ChangeDetectorRef, Component, ElementRef, HostBinding, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'
import { FormControl } from '@angular/forms'
import { chain, sortBy } from 'lodash'
import { SngHeader, SngStream } from 'parse-sng'
import { from, switchMap, throttleTime } from 'rxjs'
import { Difficulty, getInstrumentType, Instrument, parseChartFile } from 'scan-chart'
import { SettingsService } from 'src-angular/app/core/services/settings.service.js'
import { ChartData } from 'src-shared/interfaces/search.interface.js'
import { getBasename, getExtension, hasAudioExtension, hasAudioName, hasChartExtension, hasChartName, hasVideoName, msToRoughTime } from 'src-shared/UtilFunctions.js'
import { ChartPreview } from './render.js'
@Component({
selector: 'app-chart-sidebar-preview',
templateUrl: './chart-sidebar-preview.component.html',
})
export class ChartSidebarPreviewComponent implements OnInit, OnDestroy {
@HostBinding('class.h-full') height = true
@ViewChild('previewDiv') previewDiv: ElementRef<HTMLDivElement>
@Input() selectedChart: ChartData
@Input() instrument: Instrument
@Input() difficulty: Difficulty
private lastVolume: number | null = null
public isMuted = true
public playState: 'paused' | 'loading' | 'play' | 'end' = 'paused'
public chartPreview: ChartPreview | null = null
private parsedChart: ReturnType<typeof parseChartFile> | null = null
private textures: Awaited<ReturnType<typeof ChartPreview.loadTextures>> | null = null
private audioFiles: Uint8Array[] | null = null
public seekBar: FormControl<number>
public volumeBar: FormControl<number>
public timestampUpdateInterval: NodeJS.Timer
public timestampText: string = ''
constructor(
private cdr: ChangeDetectorRef,
private settingsService: SettingsService,
) { }
ngOnInit() {
this.seekBar = new FormControl<number>(0, { nonNullable: true })
this.seekBar.valueChanges
.pipe(
throttleTime(30, undefined, { leading: true, trailing: true }),
switchMap(progress =>
from(
(async () => {
this.playState = 'loading'
await this.chartPreview?.seek(progress / 100)
this.playState = 'paused'
})(),
),
),
)
.subscribe()
this.volumeBar = new FormControl<number>(this.settingsService.volume, { nonNullable: true })
this.isMuted = this.settingsService.volume === 0
this.volumeBar.valueChanges.subscribe(volume => {
this.settingsService.volume = volume
if (this.chartPreview) {
this.chartPreview.volume = volume / 100
}
})
}
ngOnDestroy() {
this.endChartPreview()
}
private spaceListener = (event: KeyboardEvent) => {
if (event.code === 'Space') {
this.togglePlaying()
event.preventDefault()
}
}
async resetChartPreview(checkInstrumentType = true) {
if (this.parsedChart && this.textures && this.audioFiles) {
this.playState = 'loading'
if (checkInstrumentType && this.chartPreview?.instrumentType !== getInstrumentType(this.instrument)) {
this.textures = await ChartPreview.loadTextures(getInstrumentType(this.instrument))
}
this.chartPreview?.dispose()
this.chartPreview = await ChartPreview.create(
this.parsedChart,
this.textures,
this.audioFiles,
this.instrument,
this.difficulty,
(this.selectedChart.delay ?? 0) + (this.selectedChart.chart_offset ?? 0) * 1000,
this.selectedChart.song_length ?? 5 * 60 * 1000, // TODO: have a better way to detect the audio length?
this.previewDiv.nativeElement,
)
this.chartPreview.on('progress', percentComplete => {
this.seekBar.setValue(percentComplete * 100, { emitEvent: false })
})
this.chartPreview.on('end', async () => {
await this.chartPreview!.togglePaused()
this.playState = 'end'
this.cdr.detectChanges()
})
this.chartPreview.volume = this.volumeBar.value / 100
await this.chartPreview.seek(this.seekBar.value / 100)
document.addEventListener('keydown', this.spaceListener)
this.timestampUpdateInterval = setInterval(
() => (this.timestampText = msToRoughTime(this.chartPreview!.chartCurrentTimeMs) + ' / ' + msToRoughTime(this.chartPreview!.chartEndTimeMs)),
100,
)
this.playState = 'paused'
}
}
endChartPreview() {
this.previewDiv.nativeElement.firstChild?.remove()
this.chartPreview?.dispose()
this.chartPreview = null
this.parsedChart = null
this.textures = null
this.audioFiles = null
this.playState = 'paused'
this.seekBar.setValue(0, { emitEvent: false })
document.removeEventListener('keydown', this.spaceListener)
clearInterval(this.timestampUpdateInterval)
}
async togglePlaying() {
if (this.playState === 'end') {
await this.chartPreview!.seek(0)
this.playState = 'paused'
}
if (this.playState === 'paused') {
this.playState = 'loading'
if (this.chartPreview === null) {
const filesPromise = getChartFiles(this.selectedChart)
const [parsedChart, textures, audioFiles] = await Promise.all([
(async () => {
const { chartData, format } = findChartData(await filesPromise)
const iniChartModifiers = Object.assign(
{
song_length: 0,
hopo_frequency: 0,
eighthnote_hopo: false,
multiplier_note: 0,
sustain_cutoff_threshold: -1,
chord_snap_threshold: 0,
five_lane_drums: false,
pro_drums: false,
},
this.selectedChart,
)
return parseChartFile(chartData, format, iniChartModifiers)
})(),
ChartPreview.loadTextures(getInstrumentType(this.instrument)),
(async () => findAudioData(await filesPromise))(),
])
this.parsedChart = parsedChart
this.textures = textures
this.audioFiles = audioFiles
await this.resetChartPreview(false)
}
await this.chartPreview!.togglePaused()
this.playState = 'play'
} else if (this.playState === 'play') {
this.playState = 'loading'
await this.chartPreview!.togglePaused()
this.playState = 'paused'
}
}
toggleMuted() {
this.isMuted = !this.isMuted
if (this.isMuted) {
this.lastVolume = this.volumeBar.value
this.volumeBar.setValue(0)
} else {
this.volumeBar.setValue(this.lastVolume ?? 50)
}
}
}
async function getChartFiles(chartData: ChartData) {
const chartUrl = `https://files.enchor.us/${chartData.md5 + (chartData.hasVideoBackground ? '_novideo' : '')}.sng`
const sngResponse = await fetch(chartUrl, { mode: 'cors', referrerPolicy: 'no-referrer' })
if (!sngResponse.ok) {
throw new Error('Failed to fetch the .sng file')
}
const sngStream = new SngStream(sngResponse.body!, { generateSongIni: true })
let header: SngHeader
sngStream.on('header', h => (header = h))
const isFileTruncated = (fileName: string) => {
const MAX_FILE_MIB = 2048
const MAX_FILES_MIB = 5000
const sortedFiles = sortBy(header.fileMeta, f => f.contentsLen)
let usedSizeMib = 0
for (const sortedFile of sortedFiles) {
usedSizeMib += Number(sortedFile.contentsLen / BigInt(1024) / BigInt(1024))
if (sortedFile.filename === fileName) {
return usedSizeMib > MAX_FILES_MIB || sortedFile.contentsLen / BigInt(1024) / BigInt(1024) >= MAX_FILE_MIB
}
}
}
const files: { fileName: string; data: Uint8Array }[] = []
return await new Promise<{ fileName: string; data: Uint8Array }[]>((resolve, reject) => {
sngStream.on('file', async (fileName: string, fileStream: ReadableStream<Uint8Array>, nextFile) => {
const matchingFileMeta = header.fileMeta.find(f => f.filename === fileName)
if (hasVideoName(fileName) || isFileTruncated(fileName) || !matchingFileMeta) {
const reader = fileStream.getReader()
// eslint-disable-next-line no-constant-condition
while (true) {
const result = await reader.read()
if (result.done) {
break
}
}
} else {
const data = new Uint8Array(Number(matchingFileMeta.contentsLen))
let offset = 0
let readCount = 0
const reader = fileStream.getReader()
// eslint-disable-next-line no-constant-condition
while (true) {
const result = await reader.read()
if (result.done) {
break
}
readCount++
if (readCount % 5 === 0) {
await new Promise<void>(resolve => setTimeout(resolve, 2))
} // Allow other processing to happen
data.set(result.value, offset)
offset += result.value.length
}
files.push({ fileName, data })
}
if (nextFile) {
nextFile()
} else {
resolve(files)
}
})
sngStream.on('error', err => reject(err))
sngStream.start()
})
}
function findChartData(files: { fileName: string; data: Uint8Array }[]) {
const chartFiles = chain(files)
.filter(f => hasChartExtension(f.fileName))
.orderBy([f => hasChartName(f.fileName), f => getExtension(f.fileName).toLowerCase() === 'mid'], ['desc', 'desc'])
.value()
return {
chartData: chartFiles[0].data,
format: (getExtension(chartFiles[0].fileName).toLowerCase() === 'mid' ? 'mid' : 'chart') as 'mid' | 'chart',
}
}
function findAudioData(files: { fileName: string; data: Uint8Array }[]) {
const audioData: Uint8Array[] = []
for (const file of files) {
if (hasAudioExtension(file.fileName)) {
if (hasAudioName(file.fileName)) {
if (!['preview', 'crowd'].includes(getBasename(file.fileName).toLowerCase())) {
audioData.push(file.data)
}
}
}
}
return audioData
}

View File

@@ -0,0 +1,942 @@
import { EventEmitter } from 'eventemitter3'
import _ from 'lodash'
import { Difficulty, getInstrumentType, Instrument, InstrumentType, instrumentTypes, NoteEvent, noteFlags, NoteType, noteTypes, parseChartFile } from 'scan-chart'
import { interpolate } from 'src-shared/UtilFunctions'
import * as THREE from 'three'
type ParsedChart = ReturnType<typeof parseChartFile>
const HIGHWAY_DURATION_MS = 1500
const SCALE = 0.105
const NOTE_SPAN_WIDTH = 0.95
// Sprite for 6-fret barre notes are the only sprites without a dedicated NoteType
type BARRE_TYPES = typeof BARRE1_TYPE | typeof BARRE2_TYPE | typeof BARRE3_TYPE
const BARRE1_TYPE = 99991
const BARRE2_TYPE = 99992
const BARRE3_TYPE = 99993
const barreTypes = [BARRE1_TYPE, BARRE2_TYPE, BARRE3_TYPE] as const
// Sprites for star power versions are the only sprites without a dedicated NoteFlag
const SP_FLAG = 2147483648
interface ChartPreviewEvents {
progress: (percentComplete: number) => void
end: () => void
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const AudioContext = window.AudioContext || (window as any).webkitAudioContext
/**
* Renders a chart preview of `parsedChart` inside `divContainer`, and plays `audioFiles` in sync with the render.
*/
export class ChartPreview {
private eventEmitter = new EventEmitter<ChartPreviewEvents>()
public instrumentType: InstrumentType
private paused = true
private scene = new THREE.Scene()
private highwayTexture: THREE.Texture
private camera: ChartCamera
private renderer: ChartRenderer
private audioManager: AudioManager | SilentAudioManager
private notesManager: NotesManager
static loadTextures = loadTextures
private constructor() { }
/**
* Available events:
* - `progress`: called every frame during playback.
* - `end`: called when the chart preview ends.
*/
on<T extends keyof ChartPreviewEvents>(event: T, listener: ChartPreviewEvents[T]) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.eventEmitter.on(event, listener as any)
}
/**
* @param chartDataPromise The `Uint8Array[]` of the audio files to be played, and the `ParsedChart` containing the notes to preview.
* @param instrument The instrument to play.
* @param difficulty The difficulty to play.
* @param startDelayMs The amount of time to delay the start of the audio. (can be negative)
* @param audioLengthMs The length of the longest audio file stem.
* @param divContainer The <div> element where the preview should be rendered.
*
* Will throw an exception if textures fail to load or if `audioFilesPromise` rejects.
*/
static async create(
parsedChart: ParsedChart,
textures: Awaited<ReturnType<typeof loadTextures>>,
audioFiles: Uint8Array[],
instrument: Instrument,
difficulty: Difficulty,
startDelayMs: number,
audioLengthMs: number,
divContainer: HTMLDivElement,
) {
const chartPreview = new ChartPreview()
chartPreview.instrumentType = getInstrumentType(instrument)
chartPreview.highwayTexture = textures.highwayTexture
chartPreview.camera = new ChartCamera(divContainer)
chartPreview.renderer = new ChartRenderer(divContainer)
chartPreview.audioManager = await (AudioContext ?
AudioManager.create(audioFiles, startDelayMs, audioLengthMs)
: SilentAudioManager.create(startDelayMs, audioLengthMs))
chartPreview.audioManager.on('end', () => chartPreview.eventEmitter.emit('end'))
chartPreview.notesManager = new NotesManager(parsedChart, instrument, difficulty, chartPreview.scene, textures.noteTextures)
chartPreview.addHighwayToScene(textures.highwayTexture)
chartPreview.addStrikelineToScene(textures.strikelineTexture)
divContainer.firstChild?.remove()
divContainer.appendChild(chartPreview.renderer.domElement)
return chartPreview
}
async togglePaused() {
if (this.paused) {
await this.audioManager.play()
this.renderer.setAnimationLoop(() => this.animateFrame())
} else {
await this.audioManager.pause()
this.renderer.setAnimationLoop(null)
}
this.paused = !this.paused
}
/**
* Moves the playback time to `percentComplete` of the way through the preview.
* @param percentComplete A number between 0 and 1 (inclusive)
*/
async seek(percentComplete: number) {
await this.audioManager.seek(percentComplete)
this.animateFrame(false)
this.renderer.setAnimationLoop(null)
this.paused = true
}
/** `volume` is a number between 0 and 1 (inclusive). May be `null` if audio isn't loaded. */
set volume(volume: number | null) {
this.audioManager.volume = volume
}
/** `volume` is a number between 0 and 1 (inclusive). May be `null` if audio isn't loaded. */
get volume() {
return this.audioManager.volume
}
get chartCurrentTimeMs() {
return this.audioManager.chartCurrentTimeMs
}
get chartEndTimeMs() {
return this.audioManager.chartEndTimeMs
}
/*
* Should be called when discarding the preview, and the preview should not be used after this is called.
*/
dispose() {
this.eventEmitter.removeAllListeners()
this.camera.dispose()
this.renderer.setAnimationLoop(null)
this.renderer.renderLists.dispose()
this.renderer.dispose()
this.renderer.forceContextLoss()
this.audioManager.closeAudio()
}
private addHighwayToScene(highwayTexture: THREE.Texture) {
const mat = new THREE.MeshBasicMaterial({ map: highwayTexture })
const geometry = new THREE.PlaneGeometry(
this.instrumentType === instrumentTypes.drums ? 0.9
: this.instrumentType === instrumentTypes.sixFret ? 0.7
: 1,
2,
)
const plane = new THREE.Mesh(geometry, mat)
plane.position.y = -0.1
plane.renderOrder = 1
this.scene.add(plane)
}
private addStrikelineToScene(strikelineTexture: THREE.Texture) {
const material = new THREE.SpriteMaterial({
map: strikelineTexture,
sizeAttenuation: true,
transparent: true,
depthTest: false,
})
const aspectRatio = strikelineTexture.image.width / strikelineTexture.image.height
const scale = this.instrumentType === instrumentTypes.sixFret ? 0.141 : 0.19
const sprite = new THREE.Sprite(material)
if (aspectRatio > 1) {
// Texture is wider than it is tall
sprite.scale.set(aspectRatio * scale, 1 * scale, 1)
} else {
// Texture is taller than it is wide or square
sprite.scale.set(1 * scale, (1 / aspectRatio) * scale, 1)
}
sprite.position.y = -1
sprite.renderOrder = 3
this.scene.add(sprite)
}
private animateFrame(emit = true) {
this.notesManager.updateDisplayedNotes(this.audioManager.chartCurrentTimeMs)
// Shift highway position
const scrollPosition = -0.9 * (this.audioManager.chartCurrentTimeMs / 1000) * (HIGHWAY_DURATION_MS / 1000)
this.highwayTexture.offset.y = -1 * scrollPosition
// Y position goes from -0.1 to 2-0.1
this.renderer.render(this.scene, this.camera)
if (emit) {
this.eventEmitter.emit('progress', this.audioManager.chartCurrentTimeMs / this.audioManager.chartEndTimeMs)
}
}
}
class ChartCamera extends THREE.PerspectiveCamera {
constructor(private divContainer: HTMLDivElement) {
super(90, 1 / 1, 0.01, 10)
this.position.z = 0.8
this.position.y = -1.3
this.rotation.x = THREE.MathUtils.degToRad(60)
this.onResize()
window.addEventListener('resize', this.resizeListener)
}
private resizeListener = () => this.onResize()
private onResize() {
const width = this.divContainer.offsetWidth ?? window.innerWidth
const height = this.divContainer.offsetHeight ?? window.innerHeight
this.aspect = width / height
this.updateProjectionMatrix()
}
dispose() {
window.removeEventListener('resize', this.resizeListener)
this.clear()
}
}
class ChartRenderer extends THREE.WebGLRenderer {
constructor(private divContainer: HTMLDivElement) {
super({
antialias: true,
})
this.localClippingEnabled = true
this.outputColorSpace = THREE.LinearSRGBColorSpace
this.onResize()
window.addEventListener('resize', this.resizeListener)
}
private resizeListener = () => this.onResize()
private onResize() {
const width = this.divContainer.offsetWidth ?? window.innerWidth
const height = this.divContainer.offsetHeight ?? window.innerHeight
this.setSize(width, height)
}
override dispose() {
window.removeEventListener('resize', this.resizeListener)
super.dispose()
}
}
interface AudioManagerEvents {
end: () => void
}
class AudioManager {
private eventEmitter = new EventEmitter()
private audioCtx = new AudioContext()
private gainNode: GainNode | null = null
private _volume = 0.5
private lastSeekChartTimeMs = 0
private lastAudioCtxCurrentTime = 0 // Necessary because audioCtx.currentTime doesn't reset to 0 on seek
private constructor(
private audioFiles: Uint8Array[],
private startDelayMs: number,
private audioLengthMs: number,
) { }
/**
* @param audioFiles The `Uint8Array[]` of the audio files to be played.
* @param startDelayMs The amount of time to delay the start of the audio. (can be negative)
* @param audioLengthMs The length of the longest audio file stem.
*/
static async create(audioFiles: Uint8Array[], startDelayMs: number, audioLengthMs: number) {
const audioManager = new AudioManager(audioFiles, startDelayMs, audioLengthMs)
await audioManager.initAudio()
return audioManager
}
/**
* Available events:
* - `end`: called when the audio playback ends.
*/
on<T extends keyof AudioManagerEvents>(event: T, listener: AudioManagerEvents[T]) {
this.eventEmitter.on(event, listener)
}
/** `volume` is a number between 0 and 1 (inclusive) */
set volume(volume: number) {
this._volume = volume * volume
if (this.gainNode) {
this.gainNode.gain.value = this._volume
}
}
/** `volume` is a number between 0 and 1 (inclusive) */
get volume() {
return Math.sqrt(this._volume)
}
/** Nonnegative number of milliseconds representing time elapsed since the chart preview start. */
get chartCurrentTimeMs() {
const isPaused = this.audioCtx.state === 'suspended'
// outputLatency is not implemented in safari
const audioLatency = (this.audioCtx.baseLatency + (this.audioCtx.outputLatency || 0)) * 1000
const audioTimeSinceLastSeekMs = (this.audioCtx.currentTime - this.lastAudioCtxCurrentTime) * 1000
// Note: when paused, the queued audio during the latency period is skipped and never heard.
// The solution here is to represent that visually by jumping ahead slightly by ignoring latency when paused.
// If this is a more significant problem, it can be fixed by seeking backward by `audioLatency`.
return this.lastSeekChartTimeMs + audioTimeSinceLastSeekMs - (isPaused ? 0 : audioLatency)
}
/** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
get chartEndTimeMs() {
return Math.max(this.startDelayMs + this.audioLengthMs, 0)
}
async play() {
if (this.audioCtx.state === 'suspended') {
if (this.gainNode === null) {
await this.initAudio()
}
await this.audioCtx.resume()
}
}
async pause() {
if (this.audioCtx.state === 'running') {
await this.audioCtx.suspend()
}
}
closeAudio() {
this.eventEmitter.removeAllListeners()
this.gainNode?.disconnect()
this.audioCtx.close()
}
async initAudio() {
const audioBuffers = await Promise.all(
// Must be recreated on each seek because seek is not supported by the Web Audio API
// TODO: use audio-decode library instead if this fails
this.audioFiles.map(file => this.audioCtx.decodeAudioData(file.slice(0).buffer)),
)
this.gainNode = this.audioCtx.createGain()
this.gainNode.gain.value = this._volume
this.gainNode.connect(this.audioCtx.destination)
let endedCount = 0
const audioStartOffsetSeconds = (this.lastSeekChartTimeMs - this.startDelayMs) / 1000
for (const audioBuffer of audioBuffers) {
const source = this.audioCtx.createBufferSource()
source.buffer = audioBuffer
source.onended = () => {
endedCount++
if (endedCount === audioBuffers.length) {
this.eventEmitter.emit('end')
}
}
source.connect(this.gainNode!)
const when = Math.abs(Math.min(audioStartOffsetSeconds, 0))
const offset = Math.max(audioStartOffsetSeconds, 0)
source.start(when, offset)
}
this.lastAudioCtxCurrentTime = this.audioCtx.currentTime
this.pause()
}
/**
* @param percentComplete The progress between the start and end of the preview.
*/
async seek(percentComplete: number) {
await this.audioCtx.suspend()
this.gainNode?.disconnect()
this.gainNode = null
const chartSeekTimeMs = percentComplete * this.chartEndTimeMs
this.lastSeekChartTimeMs = chartSeekTimeMs
this.lastAudioCtxCurrentTime = this.audioCtx.currentTime
}
}
/** Used if window.AudioContext || window.webkitAudioContext is undefined. */
class SilentAudioManager {
private eventEmitter = new EventEmitter()
private endEventTimeout: NodeJS.Timeout | null = null
private isPaused = true
private lastResumeChartTimeMs: number
private lastResumeClockTimeMs: number
private constructor(
private startDelayMs: number,
private audioLengthMs: number,
) { }
/**
* @param audioFiles The `ArrayBuffer[]` of the audio files to be played.
* @param startDelayMs The amount of time to delay the start of the audio. (can be negative)
* @param audioLengthMs The length of the longest audio file stem.
*/
static async create(startDelayMs: number, audioLengthMs: number) {
const audioManager = new SilentAudioManager(startDelayMs, audioLengthMs)
await audioManager.seek(0)
return audioManager
}
/**
* Available events:
* - `end`: called when the playback ends.
*/
on<T extends keyof AudioManagerEvents>(event: T, listener: AudioManagerEvents[T]) {
this.eventEmitter.on(event, listener)
}
/** `volume` is invalid for silent playback. */
set volume(_null: number | null) {
return
}
/** `volume` is invalid for silent playback. */
get volume() {
return null
}
/** Nonnegative number of milliseconds representing time elapsed since the chart preview start. */
get chartCurrentTimeMs() {
if (this.isPaused) {
return this.lastResumeChartTimeMs
} else {
return this.lastResumeChartTimeMs + performance.now() - this.lastResumeClockTimeMs
}
}
/** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
get chartEndTimeMs() {
return Math.max(this.startDelayMs + this.audioLengthMs, 0)
}
async play() {
if (this.lastResumeChartTimeMs >= this.chartEndTimeMs - 2) {
this.lastResumeChartTimeMs = 0 // Restart at the end
}
this.lastResumeClockTimeMs = performance.now()
this.endEventTimeout = setTimeout(() => {
this.pause()
this.eventEmitter.emit('end')
}, this.chartEndTimeMs - this.lastResumeChartTimeMs)
this.isPaused = false
}
async pause() {
this.lastResumeChartTimeMs = this.chartCurrentTimeMs
if (this.endEventTimeout) {
clearTimeout(this.endEventTimeout)
}
this.isPaused = true
}
closeAudio() {
this.eventEmitter.removeAllListeners()
if (this.endEventTimeout) {
clearTimeout(this.endEventTimeout)
}
}
/**
* @param percentComplete The progress between the start and end of the preview.
*/
async seek(percentComplete: number) {
this.lastResumeChartTimeMs = this.chartEndTimeMs * percentComplete
if (!this.isPaused) {
this.play()
}
}
}
/**
* Handles adding/removing/moving the notes in `scene` at the given `chartCurrentTimeMs` value.
* TODO: Potential optimization: use InstancedMesh and a custom shader to render multiple sprites in a single draw call
*/
class NotesManager {
private noteMaterials = new Map<NoteType | BARRE_TYPES, Map<number, THREE.SpriteMaterial>>()
private clippingPlanes = [new THREE.Plane(new THREE.Vector3(0, 1, 0), 1), new THREE.Plane(new THREE.Vector3(0, -1, 0), 0.9)]
private instrumentType: InstrumentType
private noteEvents: NoteEvent[]
private notes: EventSequence<ParsedChart['trackData'][number]['noteEventGroups'][number][number]>
private soloSections: EventSequence<ParsedChart['trackData'][number]['soloSections'][number]>
private flexLanes: EventSequence<ParsedChart['trackData'][number]['flexLanes'][number]>
private drumFreestyleSections: EventSequence<ParsedChart['trackData'][number]['drumFreestyleSections'][number]>
private noteGroups = new Map<number, THREE.Group<THREE.Object3DEventMap>>()
constructor(
private chartData: ParsedChart,
private instrument: Instrument,
private difficulty: Difficulty,
private scene: THREE.Scene,
noteTextures: Map<NoteType | BARRE_TYPES, Map<number, THREE.Texture>>,
) {
adjustParsedChart(chartData, instrument, difficulty)
_.values(noteTypes).forEach(noteType => this.noteMaterials.set(noteType, new Map()))
barreTypes.forEach(barreType => this.noteMaterials.set(barreType, new Map()))
noteTextures.forEach((flagTextures, noteType) => {
flagTextures.forEach((texture, noteFlags) => {
this.noteMaterials.get(noteType)!.set(noteFlags, new THREE.SpriteMaterial({ map: texture }))
})
})
const track = chartData.trackData.find(t => t.instrument === instrument && t.difficulty === difficulty)!
this.instrumentType = getInstrumentType(instrument)
this.noteEvents = _.flatten(track.noteEventGroups)
this.notes = new EventSequence(this.noteEvents)
this.soloSections = new EventSequence(track.soloSections)
this.flexLanes = new EventSequence(track.flexLanes)
this.drumFreestyleSections = new EventSequence(track.drumFreestyleSections)
}
updateDisplayedNotes(chartCurrentTimeMs: number) {
const noteStartIndex = this.notes.getEarliestActiveEventIndex(chartCurrentTimeMs)
// TODO: render beat lines
// TODO: const renderedSoloSections = this.soloSections.getEventRange(chartCurrentTimeMs, chartCurrentTimeMs + 1)
// TODO: const renderedDrumRollLanes = this.drumRollLanes.getEventRange(chartCurrentTimeMs, renderEndTimeMs)
// TODO: const renderedDrumFreestyleSections = this.drumFreestyleSections.getEventRange(chartCurrentTimeMs, renderEndTimeMs)
const renderEndTimeMs = chartCurrentTimeMs + HIGHWAY_DURATION_MS
let maxNoteEventIndex = noteStartIndex - 1
for (const [noteEventIndex, sprite] of this.noteGroups) {
if (noteEventIndex < noteStartIndex || this.noteEvents[noteEventIndex].msTime > renderEndTimeMs) {
this.scene.remove(sprite)
this.noteGroups.delete(noteEventIndex)
} else {
// TODO: update animation frame (.webp or sprite sheet?)
sprite.position.y = interpolate(this.noteEvents[noteEventIndex].msTime, chartCurrentTimeMs, renderEndTimeMs, -1, 1)
if (noteEventIndex > maxNoteEventIndex) {
maxNoteEventIndex = noteEventIndex
}
}
}
for (let i = maxNoteEventIndex + 1; this.noteEvents[i] && this.noteEvents[i].msTime < renderEndTimeMs; i++) {
const note = this.noteEvents[i]
const noteGroup = new THREE.Group()
const scale =
note.type === noteTypes.kick ? 0.045
: note.type === noteTypes.open && this.instrumentType === instrumentTypes.sixFret ? 0.04
: SCALE
const sprite = new THREE.Sprite(this.noteMaterials.get(note.type)!.get(note.flags)!)
noteGroup.add(sprite)
sprite.center = new THREE.Vector2(note.type === noteTypes.kick ? 0.62 : 0.5, note.type === noteTypes.kick ? -0.5 : 0)
const aspectRatio = sprite.material.map!.image.width / sprite.material.map!.image.height
sprite.scale.set(scale * aspectRatio, scale, scale)
noteGroup.position.x = calculateNoteXOffset(this.instrumentType, note.type)
noteGroup.position.y = interpolate(note.msTime, chartCurrentTimeMs, renderEndTimeMs, -1, 1)
noteGroup.position.z = 0
sprite.material.clippingPlanes = this.clippingPlanes
sprite.material.depthTest = false
sprite.material.transparent = true
sprite.renderOrder = note.type === noteTypes.kick ? 1 : 4
if (note.msLength > 0) {
const mat = new THREE.MeshBasicMaterial({
color: calculateColor(note.type),
side: THREE.DoubleSide,
})
mat.clippingPlanes = this.clippingPlanes
mat.depthTest = false
mat.transparent = true
const geometry = new THREE.PlaneGeometry(SCALE * (note.type === noteTypes.open ? 5 : 0.3), 2 * (note.msLength / HIGHWAY_DURATION_MS))
const plane = new THREE.Mesh(geometry, mat)
plane.position.y = 0.03 + note.msLength / HIGHWAY_DURATION_MS
plane.renderOrder = 2
noteGroup.add(plane)
}
this.noteGroups.set(i, noteGroup)
this.scene.add(noteGroup)
}
}
}
class EventSequence<T extends { msTime: number; msLength: number; type?: NoteType }> {
/** Contains the closest events before msTime, grouped by type */
private lastPrecedingEventIndexesOfType = new Map<NoteType | undefined, number>()
private lastPrecedingEventIndex = -1
/** Assumes `events` are already sorted in `msTime` order. */
constructor(private events: T[]) { }
getEarliestActiveEventIndex(startMs: number) {
if (this.lastPrecedingEventIndex !== -1 && startMs < this.events[this.lastPrecedingEventIndex].msTime) {
this.lastPrecedingEventIndexesOfType = new Map<NoteType | undefined, number>()
this.lastPrecedingEventIndex = -1
}
while (this.events[this.lastPrecedingEventIndex + 1] && this.events[this.lastPrecedingEventIndex + 1].msTime < startMs) {
this.lastPrecedingEventIndexesOfType.set(this.events[this.lastPrecedingEventIndex + 1].type, this.lastPrecedingEventIndex + 1)
this.lastPrecedingEventIndex++
}
let earliestActiveEventIndex: number | null = null
for (const [, index] of this.lastPrecedingEventIndexesOfType) {
if (this.events[index].msTime + this.events[index].msLength > startMs) {
if (earliestActiveEventIndex === null || earliestActiveEventIndex > index) {
earliestActiveEventIndex = index
}
}
}
return earliestActiveEventIndex === null ? this.lastPrecedingEventIndex + 1 : earliestActiveEventIndex
}
}
async function loadTextures(instrumentType: InstrumentType) {
const textureLoader = new THREE.TextureLoader()
const load = (path: string) => textureLoader.loadAsync('https://static.enchor.us/' + path)
const [highwayTexture, strikelineTexture, noteTextures] = await Promise.all([
(async () => {
const texture = await load('preview-highway.png')
texture.wrapS = THREE.RepeatWrapping
texture.wrapT = THREE.RepeatWrapping
texture.repeat.set(1, 2)
return texture
})(),
(async () => {
switch (instrumentType) {
case instrumentTypes.drums:
return await load('preview-drums-strikeline.png')
case instrumentTypes.sixFret:
return await load('preview-6fret-strikeline.png')
case instrumentTypes.fiveFret:
return await load('preview-5fret-strikeline.png')
}
})(),
(async () => {
const texturePromises: { type: NoteType | BARRE_TYPES; flags: number; texture: Promise<THREE.Texture> }[] = []
const addTexture = (type: NoteType | BARRE_TYPES, flags: number, path: string) => {
const texture = textureLoader.loadAsync(`https://static.enchor.us/preview-${path}.webp`)
texturePromises.push({ type, flags, texture })
return texture
}
const reuseTexture = (type: NoteType | BARRE_TYPES, flags: number, texture: Promise<THREE.Texture>) => {
texturePromises.push({ type, flags, texture })
}
// TODO: use VideoTexture and make all note extensions consistent
// https://stackoverflow.com/questions/18383470/using-video-as-texture-with-three-js/77077409#77077409
if (instrumentType === instrumentTypes.drums) {
const colors = new Map([
[noteTypes.redDrum, 'red'],
[noteTypes.yellowDrum, 'yellow'],
[noteTypes.blueDrum, 'blue'],
[noteTypes.greenDrum, 'green'],
])
const dynamicFlags = new Map([
[noteFlags.none, ''],
[noteFlags.ghost, '-ghost'],
[noteFlags.accent, '-accent'],
])
const spFlags = new Map([
[noteFlags.none, ''],
[SP_FLAG, '-sp'],
])
addTexture(noteTypes.kick, noteFlags.none, 'drums-kick')
addTexture(noteTypes.kick, noteFlags.doubleKick, 'drums-kick')
addTexture(noteTypes.kick, noteFlags.none | SP_FLAG, 'drums-kick-sp')
addTexture(noteTypes.kick, noteFlags.doubleKick | SP_FLAG, 'drums-kick-sp')
for (const [colorKey, colorName] of colors) {
for (const [dynamicFlagKey, dynamicFlagName] of dynamicFlags) {
for (const [spFlagKey, spFlagName] of spFlags) {
addTexture(colorKey, spFlagKey | dynamicFlagKey | noteFlags.tom, `drums-${colorName}-tom${dynamicFlagName}${spFlagName}`)
if (colorKey !== noteTypes.redDrum) {
addTexture(colorKey, spFlagKey | dynamicFlagKey | noteFlags.cymbal, `drums-${colorName}-cymbal${dynamicFlagName}${spFlagName}`)
}
}
}
}
} else if (instrumentType === instrumentTypes.sixFret) {
const lanes = new Map<NoteType | BARRE_TYPES, string>([
[noteTypes.open, 'open'],
[noteTypes.black1, 'black'],
[noteTypes.white1, 'white'],
[BARRE1_TYPE, 'barre'],
])
const modifiers = new Map([
[noteFlags.strum, '-strum'],
[noteFlags.hopo, '-hopo'],
[noteFlags.tap, '-tap'],
])
const spFlags = new Map([
[noteFlags.none, ''],
[SP_FLAG, '-sp'],
])
for (const [laneKey, laneName] of lanes) {
for (const [modifierKey, modifierName] of modifiers) {
for (const [spFlagKey, spFlagName] of spFlags) {
const texturePromise = addTexture(laneKey, modifierKey | spFlagKey, `6fret-${laneName}${modifierName}${spFlagName}`)
// Same texture used for all three lanes
if (laneKey === noteTypes.black1) {
reuseTexture(noteTypes.black2, modifierKey | spFlagKey, texturePromise)
reuseTexture(noteTypes.black3, modifierKey | spFlagKey, texturePromise)
} else if (laneKey === noteTypes.white1) {
reuseTexture(noteTypes.white2, modifierKey | spFlagKey, texturePromise)
reuseTexture(noteTypes.white3, modifierKey | spFlagKey, texturePromise)
} else if (laneKey === BARRE1_TYPE) {
reuseTexture(BARRE2_TYPE, modifierKey | spFlagKey, texturePromise)
reuseTexture(BARRE3_TYPE, modifierKey | spFlagKey, texturePromise)
}
}
}
}
} else if (instrumentType === instrumentTypes.fiveFret) {
const lanes = new Map([
[noteTypes.open, 'open'],
[noteTypes.green, 'green'],
[noteTypes.red, 'red'],
[noteTypes.yellow, 'yellow'],
[noteTypes.blue, 'blue'],
[noteTypes.orange, 'orange'],
])
const modifiers = new Map([
[noteFlags.strum, '-strum'],
[noteFlags.hopo, '-hopo'],
[noteFlags.tap, '-tap'],
])
const spFlags = new Map([
[noteFlags.none, ''],
[SP_FLAG, '-sp'],
])
for (const [laneKey, laneName] of lanes) {
for (let [modifierKey, modifierName] of modifiers) {
for (const [spFlagKey, spFlagName] of spFlags) {
if (laneKey === noteTypes.open && modifierKey === noteFlags.tap) {
modifierName = '-hopo'
modifierKey = noteFlags.hopo
}
addTexture(laneKey, modifierKey | spFlagKey, `5fret-${laneName}${modifierName}${spFlagName}`)
}
}
}
}
const textures = await Promise.all(texturePromises.map(async t => ({ type: t.type, flags: t.flags, texture: await t.texture })))
const textureMap = new Map<NoteType | BARRE_TYPES, Map<number, THREE.Texture>>()
_.values(noteTypes).forEach(noteType => textureMap.set(noteType, new Map()))
barreTypes.forEach(barreType => textureMap.set(barreType, new Map()))
for (const texture of textures) {
textureMap.get(texture.type)!.set(texture.flags, texture.texture)
}
return textureMap
})(),
])
return {
highwayTexture,
strikelineTexture,
noteTextures,
}
}
function adjustParsedChart(parsedChart: ParsedChart, instrument: Instrument, difficulty: Difficulty) {
const track = parsedChart.trackData.find(t => t.instrument === instrument && t.difficulty === difficulty)!
const starPower = track.starPowerSections
if (starPower.length > 0) {
let starPowerIndex = 0
for (const noteGroup of track.noteEventGroups) {
while (starPowerIndex < starPower.length && starPower[starPowerIndex].tick + starPower[starPowerIndex].length < noteGroup[0].tick) {
starPowerIndex++
}
if (starPowerIndex === starPower.length) {
break
}
if (
noteGroup[0].tick >= starPower[starPowerIndex].tick &&
noteGroup[0].tick < starPower[starPowerIndex].tick + starPower[starPowerIndex].length
) {
for (const note of noteGroup) {
note.flags |= SP_FLAG
}
}
}
}
if (getInstrumentType(instrument) === instrumentTypes.sixFret) {
for (const noteGroup of track.noteEventGroups) {
let oneCount = 0
let twoCount = 0
let threeCount = 0
for (const note of noteGroup) {
switch (note.type) {
case noteTypes.black1:
case noteTypes.white1:
oneCount++
break
case noteTypes.black2:
case noteTypes.white2:
twoCount++
break
case noteTypes.black3:
case noteTypes.white3:
threeCount++
break
}
}
if (oneCount > 1) {
const removed = _.remove(noteGroup, n => n.type === noteTypes.black1 || n.type === noteTypes.white1)
removed[0].type = BARRE1_TYPE as NoteType
noteGroup.push(removed[0])
}
if (twoCount > 1) {
const removed = _.remove(noteGroup, n => n.type === noteTypes.black2 || n.type === noteTypes.white2)
removed[0].type = BARRE2_TYPE as NoteType
noteGroup.push(removed[0])
}
if (threeCount > 1) {
const removed = _.remove(noteGroup, n => n.type === noteTypes.black3 || n.type === noteTypes.white3)
removed[0].type = BARRE3_TYPE as NoteType
noteGroup.push(removed[0])
}
}
}
return parsedChart
}
// TODO: Consider doing document.createElement('video') instead, and use webp
// class SpriteSheetTexture extends THREE.CanvasTexture {
// private timer: NodeJS.Timer
// private currentFrameIndex = 0
// private canvas: HTMLCanvasElement
// private ctx: CanvasRenderingContext2D
// private img = new Image()
// constructor(
// imageUrl: string,
// private framesX: number,
// framesY: number,
// private endFrame = framesX * framesY,
// ) {
// const canvas = document.createElement('canvas')
// super(canvas)
// this.canvas = canvas
// this.ctx = canvas.getContext('2d')!
// this.img.src = imageUrl
// this.img.onload = () => {
// canvas.width = this.img.width / framesX
// canvas.height = this.img.height / framesY
// this.timer = setInterval(() => this.nextFrame(), 16.67)
// }
// }
// nextFrame() {
// this.currentFrameIndex++
// if (this.currentFrameIndex >= this.endFrame) {
// this.currentFrameIndex = 0
// }
// const x = (this.currentFrameIndex % this.framesX) * this.canvas.width
// const y = ((this.currentFrameIndex / this.framesX) | 0) * this.canvas.height
// this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
// this.ctx.drawImage(this.img, x, y, this.canvas.width, this.canvas.height, 0, 0, this.canvas.width, this.canvas.height)
// this.needsUpdate = true
// }
// }
function calculateNoteXOffset(instrumentType: InstrumentType, noteType: NoteType) {
const lane = calculateLane(noteType)
const leftOffset =
instrumentType === instrumentTypes.drums ? 0.135
: instrumentType === instrumentTypes.sixFret && noteType !== noteTypes.open ? 0.2
: instrumentType === instrumentTypes.sixFret && noteType === noteTypes.open ? 0.035
: 0.035
return leftOffset + -(NOTE_SPAN_WIDTH / 2) + SCALE + ((NOTE_SPAN_WIDTH - SCALE) / 5) * lane
}
function calculateLane(noteType: NoteType) {
switch (noteType) {
case noteTypes.green:
case noteTypes.redDrum:
case noteTypes.black1:
case noteTypes.white1:
case BARRE1_TYPE as NoteType:
return 0
case noteTypes.red:
case noteTypes.yellowDrum:
case noteTypes.black2:
case noteTypes.white2:
case BARRE2_TYPE as NoteType:
return 1
case noteTypes.yellow:
case noteTypes.blueDrum:
case noteTypes.open:
case noteTypes.kick:
case noteTypes.black3:
case noteTypes.white3:
case BARRE3_TYPE as NoteType:
return 2
case noteTypes.blue:
case noteTypes.greenDrum:
return 3
case noteTypes.orange:
return 4
default:
return 0
}
}
function calculateColor(noteType: NoteType) {
switch (noteType) {
case noteTypes.green:
case noteTypes.greenDrum:
return '#01B11A'
case noteTypes.red:
case noteTypes.redDrum:
return '#DD2214'
case noteTypes.yellow:
case noteTypes.yellowDrum:
return '#DEEB52'
case noteTypes.blue:
case noteTypes.blueDrum:
return '#006CAF'
case noteTypes.open:
return '#8A0BB5'
case noteTypes.orange:
return '#F8B272'
default:
return '#FFFFFF'
}
}

View File

@@ -28,7 +28,6 @@
class="flex flex-1 p-2 gap-1 justify-between overflow-x-hidden overflow-y-auto scrollbar scrollbar-w-2 scrollbar-h-2 scrollbar-track-base-300 scrollbar-thumb-neutral scrollbar-thumb-rounded-full">
<div class="flex flex-1 flex-col gap-1">
<div>
<!-- TODO: Change this to a dropdown if there is more than one chart -->
<b>Charter: </b>
<a class="link link-hover" (click)="onSourceLinkClicked()">{{ selectedChart.charter | removeStyleTags }}</a>
</div>
@@ -50,25 +49,25 @@
@if (metadataIssues.length > 0) {
<div class="menu-title">Metadata Issues Found:</div>
<ul class="list-disc ml-9 min-w-[246px] max-w-[min(26.1vw,444px)]">
<li *ngFor="let issue of metadataIssues" class="list-item">{{ getMetadataIssueText(issue) }}</li>
<li *ngFor="let issue of metadataIssues" class="list-item">{{ issue.description }}</li>
</ul>
}
@if (folderIssues.length > 0) {
<div class="menu-title">Chart Folder Issues Found:</div>
<ul class="list-disc ml-9 min-w-[246px] max-w-[min(26.1vw,444px)]">
<li *ngFor="let issue of folderIssues" class="list-item">{{ getFolderIssueText(issue) }}</li>
<li *ngFor="let issue of folderIssues" class="list-item">{{ issue.description }}</li>
</ul>
}
@if (chartIssues.length > 0) {
@if (globalChartIssues.length > 0) {
<div class="menu-title">Chart Issues Found:</div>
<ul class="list-disc ml-9 min-w-[246px] max-w-[min(26.1vw,444px)]">
<li *ngFor="let issue of chartIssues" class="list-item">{{ getChartIssueText(issue) }}</li>
<li *ngFor="let issue of globalChartIssues" class="list-item">{{ issue.description }}</li>
</ul>
}
@for (trackIssues of trackIssuesGroups; track $index) {
<div class="menu-title">{{ trackIssues.groupName }}</div>
<ul class="list-disc ml-9 min-w-[246px] max-w-[min(26.1vw,444px)]">
<li *ngFor="let issue of trackIssues.issues" class="list-item">{{ getTrackIssueText(issue) }}</li>
<li *ngFor="let issue of trackIssues.issues" class="list-item">{{ issue }}</li>
</ul>
}
</div>
@@ -118,6 +117,27 @@
<p class="font-bold whitespace-nowrap">Average NPS: {{ averageNps || 'N/A' }}</p>
<p class="font-bold whitespace-nowrap">Maximum NPS: {{ maximumNps }}</p>
<p class="font-bold whitespace-nowrap">Note Count: {{ noteCount }}</p>
<button class="btn btn-sm btn-neutral my-1 max-w-fit" (click)="previewModal.showModal()">
<i class="bi bi-play text-lg text-neutral-content"></i>
Chart Preview
</button>
<dialog #previewModal class="modal" (close)="chartPreview.endChartPreview()">
<div class="modal-box bg-base-100 text-base-content flex flex-col gap-2 h-[50vh] w-[80vw] max-w-full max-h-full">
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">
<i class="bi bi-x-lg text-lg"></i>
</button>
</form>
<app-chart-sidebar-preview
#chartPreview
[selectedChart]="selectedChart"
[instrument]="instrumentDropdown.value"
[difficulty]="difficultyDropdown.value" />
</div>
<form method="dialog" class="modal-backdrop">
<button>close</button>
</form>
</dialog>
</div>
</div>
</div>

View File

@@ -2,7 +2,7 @@ import { Component, ElementRef, HostBinding, OnInit, Renderer2, ViewChild } from
import { FormControl } from '@angular/forms'
import _ from 'lodash'
import { ChartIssueType, Difficulty, FolderIssueType, Instrument, MetadataIssueType, NoteIssueType, TrackIssueType } from 'scan-chart'
import { Difficulty, Instrument, NotesData } from 'scan-chart'
import { DownloadService } from 'src-angular/app/core/services/download.service'
import { SearchService } from 'src-angular/app/core/services/search.service'
import { SettingsService } from 'src-angular/app/core/services/settings.service'
@@ -34,8 +34,8 @@ export class ChartSidebarComponent implements OnInit {
selectedChart: ChartData | null = null
charts: ChartData[][] | null = null
public instrumentDropdown: FormControl<Instrument | null>
public difficultyDropdown: FormControl<Difficulty | null>
public instrumentDropdown: FormControl<Instrument>
public difficultyDropdown: FormControl<Difficulty>
constructor(
private renderer: Renderer2,
@@ -49,21 +49,21 @@ export class ChartSidebarComponent implements OnInit {
this.charts = null
this.selectedChart = null
})
this.instrumentDropdown = new FormControl<Instrument | null>(this.defaultInstrument)
this.instrumentDropdown = new FormControl<Instrument>(this.defaultInstrument, { nonNullable: true })
this.searchService.instrument.valueChanges.subscribe(instrument => {
if (this.instruments.some(i => i === instrument)) {
this.instrumentDropdown.setValue(instrument)
this.instrumentDropdown.setValue(instrument!)
}
})
this.instrumentDropdown.valueChanges.subscribe(() => {
if (!this.difficulties.some(d => d === this.difficultyDropdown.value)) {
this.difficultyDropdown.setValue(this.defaultDifficulty)
this.difficultyDropdown.setValue(this.defaultDifficulty, { emitEvent: false })
}
})
this.difficultyDropdown = new FormControl<Difficulty | null>(this.defaultDifficulty)
this.difficultyDropdown = new FormControl<Difficulty>(this.defaultDifficulty, { nonNullable: true })
this.searchService.difficulty.valueChanges.subscribe(difficulty => {
if (this.difficulties.some(d => d === difficulty)) {
this.difficultyDropdown.setValue(difficulty)
this.difficultyDropdown.setValue(difficulty!)
}
})
}
@@ -91,7 +91,9 @@ export class ChartSidebarComponent implements OnInit {
}
public get extraLengthSeconds() {
return _.round((this.selectedChart!.notesData.length - this.selectedChart!.notesData.effectiveLength) / 1000, 1)
return this.selectedChart!.song_length ?
_.round((this.selectedChart!.song_length - this.selectedChart!.notesData.effectiveLength) / 1000, 1)
: _.round(this.selectedChart!.notesData.effectiveLength / 1000, 1)
}
public get hasIssues() {
@@ -99,96 +101,68 @@ export class ChartSidebarComponent implements OnInit {
}
public get metadataIssues() {
return this.selectedChart!.metadataIssues
}
public getMetadataIssueText(issue: MetadataIssueType) {
switch (issue) {
case 'noName': return 'Chart has no name'
case 'noArtist': return 'Chart has no artist'
case 'noAlbum': return 'Chart has no album'
case 'noGenre': return 'Chart has no genre'
case 'noYear': return 'Chart has no year'
case 'noCharter': return 'Chart has no charter'
case 'missingInstrumentDiff': return 'Metadata is missing an instrument intensity rating'
case 'extraInstrumentDiff': return 'Metadata contains an instrument intensity rating for an uncharted instrument'
case 'nonzeroDelay': return 'Chart uses "delay" for the audio offset'
case 'drumsSetTo4And5Lane': return 'It is unclear if the drums chart is intended to be 4-lane or 5-lane'
case 'nonzeroOffset': return 'Chart uses "delay" for the audio offset'
}
return this.selectedChart!.metadataIssues.filter(i => !['extraValue'].includes(i.metadataIssue))
}
public get folderIssues() {
return _.chain(this.selectedChart!.folderIssues)
.filter(i => !['albumArtSize', 'invalidIni', 'multipleVideo', 'badIniLine'].includes(i.folderIssue))
.map(i => i.folderIssue)
.uniq()
.uniqBy(i => i.folderIssue)
.value()
}
public getFolderIssueText(folderIssue: FolderIssueType) {
switch (folderIssue) {
case 'noMetadata': return `Metadata file is missing`
case 'invalidMetadata': return `Metadata file is invalid`
case 'multipleIniFiles': return `Multiple metadata files`
case 'noAlbumArt': return `Album art is missing`
case 'badAlbumArt': return `Album art is invalid`
case 'multipleAlbumArt': return `Multiple album art files`
case 'noAudio': return `Audio file is missing`
case 'invalidAudio': return `Audio file is invalid`
case 'badAudio': return `Audio file is invalid`
case 'multipleAudio': return `Audio file is invalid`
case 'noChart': return `Notes file is missing`
case 'invalidChart': return `Notes file is invalid`
case 'badChart': return `Notes file is invalid`
case 'multipleChart': return `Multiple notes files`
case 'badVideo': return `Video background won't work on Linux`
}
}
public get chartIssues() {
return this.selectedChart!.notesData?.chartIssues.filter(i => i !== 'isDefaultBPM')
}
public getChartIssueText(issue: ChartIssueType) {
switch (issue) {
case 'noResolution': return 'No resolution in chart file'
case 'noSyncTrackSection': return 'No tempo map in chart file'
case 'noNotes': return 'No notes in chart file'
case 'noExpert': return 'Expert is not charted'
case 'misalignedTimeSignatures': return 'Broken time signatures'
case 'noSections': return 'No sections'
}
public get globalChartIssues() {
return _.chain(this.selectedChart!.notesData.chartIssues)
.filter(i => i.instrument === null)
.filter(i => i.noteIssue !== 'isDefaultBPM')
.value()
}
public get trackIssuesGroups() {
return _.chain([
...this.selectedChart!.notesData.trackIssues.map(i => ({ ...i, issues: i.trackIssues })),
...this.selectedChart!.notesData.noteIssues.map(i => ({ ...i, issues: i.noteIssues.map(ni => ni.issueType) })),
])
.sortBy(g => instruments.indexOf(g.instrument), g => difficulties.indexOf(g.difficulty))
.groupBy(g => `${_.capitalize(g.instrument)} - ${_.capitalize(g.difficulty)} Issues Found:`)
return _.chain(this.selectedChart!.notesData.chartIssues)
.filter(g => g.instrument !== null)
.sortBy(
g => instruments.indexOf(g.instrument!),
g => difficulties.indexOf(g.difficulty || '(all difficulties)' as Difficulty),
)
.groupBy(
g => `${_.capitalize(g.instrument!)
} - ${_.capitalize(g.difficulty || '(all difficulties)' as Difficulty)} Issues Found:`
)
.toPairs()
.map(([groupName, group]) => ({
groupName,
issues: _.chain(group)
.flatMap(g => g.issues)
.filter(i => i !== 'babySustain' && i !== 'noNotesOnNonemptyTrack')
.uniq()
.filter(
i => !['badEndEvent', 'emptyStarPower', 'emptySoloSection', 'emptyFlexLane', 'babySustain'].includes(i.noteIssue)
)
.groupBy(i => i.noteIssue)
.values()
.map(issueGroup => this.getTrackIssueText(issueGroup))
.value(),
}))
.filter(g => g.issues.length > 0)
.value()
}
public getTrackIssueText(issue: NoteIssueType | TrackIssueType) {
switch (issue) {
case 'babySustain': return 'Has baby sustains'
case 'badSustainGap': return 'Has sustain gaps that are too small'
case 'brokenNote': return 'Has broken notes'
case 'difficultyForbiddenNote': return 'Has notes not allowed on this difficulty'
case 'fiveNoteChord': return 'Has five-note chords'
case 'noDrumActivationLanes': return 'Has no activation lanes'
case 'has4And5LaneFeatures': return 'Has a mix of 4 and 5 lane features on the drum chart'
case 'noStarPower': return 'Has no star power'
case 'smallLeadingSilence': return 'Leading silence is too small'
case 'threeNoteDrumChord': return 'Has three-note drum chords'
private getTrackIssueText(issueGroup: NotesData['chartIssues']) {
const one = issueGroup.length === 1
const len = issueGroup.length
switch (issueGroup[0].noteIssue) {
case 'badStarPower':
return `There ${one ? 'is' : 'are'} ${len} ignored star power event${one ? '' : 's'
} due to the .ini "multiplier_note" setting.`
case 'difficultyForbiddenNote':
return `There ${one ? 'is' : 'are'} ${len} note${one ? '' : 's'
} that ${one ? 'is' : 'are'}n't allowed on this track's difficulty.`
case 'invalidChord':
return `There ${one ? 'is' : 'are'} ${len} chord${one ? '' : 's'
} that ${one ? 'is' : 'are'}n't allowed for this instrument type.`
case 'brokenNote':
return `There ${one ? 'is' : 'are'} ${len} broken note${one ? '' : 's'} on this track.`
case 'badSustainGap':
return one ? 'There is 1 note that has a sustain gap that is too small.' : `There are ${len
} notes that have sustain gaps that are too small.`
default:
return issueGroup[0].description
}
}
@@ -203,7 +177,7 @@ export class ChartSidebarComponent implements OnInit {
showGuitarlikeProperties ? { value: notesData.hasTapNotes, text: 'Tap Notes' } : null,
showGuitarlikeProperties ? { value: notesData.hasOpenNotes, text: 'Open Notes' } : null,
showDrumlikeProperties ? { value: notesData.has2xKick, text: '2x Kick' } : null,
showDrumlikeProperties ? { value: notesData.hasRollLanes, text: 'Roll Lanes' } : null,
showDrumlikeProperties ? { value: notesData.hasFlexLanes, text: 'Roll Lanes' } : null,
{ value: this.selectedChart!.hasVideoBackground, text: 'Video Background' },
])
}
@@ -267,7 +241,7 @@ export class ChartSidebarComponent implements OnInit {
}
}
private currentTrackFilter = (track: { instrument: Instrument; difficulty: Difficulty }) => {
private currentTrackFilter = (track: { instrument: Instrument | null; difficulty: Difficulty | null }) => {
return track.instrument === this.instrumentDropdown.value && track.difficulty === this.difficultyDropdown.value
}
public get maximumNps() {

View File

@@ -11,4 +11,3 @@
<td *ngIf="hasColumn('charter')">{{ song[0].charter || 'Various' }}</td>
<td *ngIf="hasColumn('length')">{{ songLength }}</td>
<td *ngIf="hasColumn('difficulty')">{{ songDifficulty }}</td>
<!-- TODO: "Various" will never display -->

View File

@@ -18,7 +18,7 @@
<div class="dropdown">
<label tabindex="0" class="btn btn-neutral rounded-btn rounded-r-none uppercase">
@if (instrument) {
<img class="w-8 hidden sm:block" src="assets/images/instruments/{{ instrument }}.png" />
<img class="w-8 hidden sm:block" src="https://static.enchor.us/instrument-{{ instrument }}.png" />
}
{{ instrumentDisplay(instrument) }}
</label>
@@ -29,7 +29,7 @@
@for (instrument of instruments; track $index) {
<li>
<a (click)="setInstrument(instrument, $event)">
<img class="w-8" src="assets/images/instruments/{{ instrument }}.png" />
<img class="w-8" src="https://static.enchor.us/instrument-{{ instrument }}.png" />
{{ instrumentDisplay(instrument) }}
</a>
</li>
@@ -38,7 +38,9 @@
</div>
<!-- Difficulty Dropdown -->
<div class="dropdown">
<label tabindex="0" class="btn btn-neutral rounded-btn rounded-l-none uppercase">{{ difficultyDisplay(difficulty) }}</label>
<label tabindex="0" class="btn btn-neutral rounded-btn rounded-l-none uppercase" [class.rounded-r-none]="instrument === 'drums'">{{
difficultyDisplay(difficulty)
}}</label>
<ul tabindex="0" class="menu dropdown-content z-[2] p-2 shadow bg-neutral text-neutral-content rounded-box w-40">
<li>
<a (click)="setDifficulty(null, $event)">{{ difficultyDisplay(null) }}</a>
@@ -50,6 +52,22 @@
}
</ul>
</div>
@if (instrument === 'drums') {
<!-- Drum Type Dropdown -->
<div class="dropdown">
<label tabindex="0" class="btn btn-neutral rounded-btn rounded-l-none uppercase">{{ drumTypeDisplay(drumType) }}</label>
<ul tabindex="0" class="menu dropdown-content z-[2] p-2 shadow bg-neutral text-neutral-content rounded-box w-40">
<li>
<a (click)="setDrumType(null, $event)">{{ drumTypeDisplay(null) }}</a>
</li>
@for (drumType of drumTypes; track $index) {
<li>
<a (click)="setDrumType(drumType, $event)">{{ drumTypeDisplay(drumType) }}</a>
</li>
}
</ul>
</div>
}
</div>
<div class="flex-1 flex-grow-[3] h-0"></div>
<!-- Advanced Search -->
@@ -125,88 +143,117 @@
</tbody>
</table>
</div>
<div class="flex flex-col gap-2 justify-end">
<table class="table table-xs">
<tbody>
<tr class="border-b-0">
<td class="text-sm">Length (minutes)</td>
<td>
<div class="join">
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minLength" />
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxLength" />
</div>
</td>
</tr>
<tr class="border-b-0">
<td class="text-sm">
<span
class="label-text underline decoration-dotted cursor-help tooltip [text-wrap:balance]"
data-tip="Also known as chart difficulty. Typically a number between 0 and 6.">
Intensity
</span>
</td>
<td>
<div class="join">
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minIntensity" />
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxIntensity" />
</div>
</td>
</tr>
<tr class="border-b-0">
<td class="text-sm">Average NPS</td>
<td>
<div class="join">
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minAverageNPS" />
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxAverageNPS" />
</div>
</td>
</tr>
<tr class="border-b-0">
<td class="text-sm">Max NPS</td>
<td>
<div class="join">
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minMaxNPS" />
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxMaxNPS" />
</div>
</td>
</tr>
<tr class="border-b-0">
<td class="text-sm">
<span
class="label-text underline decoration-dotted cursor-help tooltip [text-wrap:balance]"
data-tip="The date of the last time this chart was modified in Google Drive.">
Modified After
</span>
</td>
<td>
<input
type="date"
min="2012-01-01"
[max]="todayDate"
placeholder="YYYY/MM/DD"
class="input input-bordered join-item input-sm w-32"
formControlName="modifiedAfter"
(blur)="startValidation = true"
[class.input-error]="advancedSearchForm.invalid && startValidation" />
</td>
</tr>
<tr class="border-b-0">
<td class="text-sm">
<span
class="label-text underline decoration-dotted cursor-help tooltip [text-wrap:balance]"
data-tip="The MD5 hash of the chart folder or .sng file. You can enter multiple values if they are separated by commas.">
Hash
</span>
</td>
<td>
<input type="text" class="input input-bordered join-item input-sm w-32" formControlName="hash" />
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex flex-col justify-between">
<div class="flex gap-2">
<div class="flex flex-col">
<div class="flex flex-wrap justify-center gap-5">
<div class="flex flex-col gap-2 justify-end">
<table class="table table-xs">
<tbody>
<tr class="border-b-0">
<td class="text-sm">Length (minutes)</td>
<td>
<div class="join">
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minLength" />
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxLength" />
</div>
</td>
</tr>
<tr class="border-b-0">
<td class="text-sm">
<span
class="label-text underline decoration-dotted cursor-help tooltip [text-wrap:balance]"
data-tip="Also known as chart difficulty. Typically a number between 0 and 6.">
Intensity
</span>
</td>
<td>
<div class="join">
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minIntensity" />
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxIntensity" />
</div>
</td>
</tr>
<tr class="border-b-0">
<td class="text-sm">Average NPS</td>
<td>
<div class="join">
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minAverageNPS" />
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxAverageNPS" />
</div>
</td>
</tr>
<tr class="border-b-0">
<td class="text-sm">Max NPS</td>
<td>
<div class="join">
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minMaxNPS" />
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxMaxNPS" />
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex flex-col gap-2 justify-end">
<table class="table table-xs">
<tbody>
<tr class="border-b-0">
<td class="text-sm">Year</td>
<td>
<div class="join">
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minYear" />
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxYear" />
</div>
</td>
</tr>
<tr class="border-b-0">
<td class="text-sm">
<span
class="label-text underline decoration-dotted cursor-help tooltip [text-wrap:balance]"
data-tip="The date of the last time this chart was modified in Google Drive.">
Modified After
</span>
</td>
<td>
<input
type="date"
min="2012-01-01"
[max]="todayDate"
placeholder="YYYY/MM/DD"
class="input input-bordered join-item input-sm w-32"
formControlName="modifiedAfter"
(blur)="startValidation = true"
[class.input-error]="advancedSearchForm.invalid && startValidation" />
</td>
</tr>
<tr class="border-b-0">
<td class="text-sm">
<span
class="label-text underline decoration-dotted cursor-help tooltip [text-wrap:balance]"
data-tip="The MD5 hash of the chart folder or .sng file. You can enter multiple values if they are separated by commas.">
Hash
</span>
</td>
<td>
<input type="text" class="input input-bordered join-item input-sm w-32" formControlName="hash" />
</td>
</tr>
<tr class="border-b-0">
<td class="text-sm">
<span
class="label-text tooltip cursor-help underline decoration-dotted [text-wrap:balance]"
data-tip="The hash of only things that impact scoring on a specific track. You can enter multiple values if they are separated by commas. (this is used by leaderboards to distinguish charts)">
Track Hash
</span>
</td>
<td>
<input type="text" class="input join-item input-bordered input-sm w-32" formControlName="trackHash" />
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="flex flex-wrap justify-center gap-2">
<div class="flex flex-col">
<div class="form-control">
<label class="label cursor-pointer justify-normal gap-2">
@@ -247,6 +294,8 @@
</span>
</label>
</div>
</div>
<div class="flex flex-col">
<div class="form-control">
<label class="label cursor-pointer justify-normal gap-2">
<input
@@ -278,27 +327,6 @@
</div>
</div>
<div class="flex flex-col">
<div class="form-control">
<label class="label cursor-pointer justify-normal gap-2">
<input
#hasRollLanes
type="checkbox"
class="toggle toggle-sm"
[indeterminate]="true"
(click)="clickCheckbox('hasRollLanes', $event)" />
<span class="label-text" [class.text-opacity-70]="formValue('hasRollLanes') === null">
{{ formValue('hasRollLanes') === false ? 'No ' : '' }}Roll Lanes
</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-normal gap-2">
<input #has2xKick type="checkbox" class="toggle toggle-sm" [indeterminate]="true" (click)="clickCheckbox('has2xKick', $event)" />
<span class="label-text" [class.text-opacity-70]="formValue('has2xKick') === null">
{{ formValue('has2xKick') === false ? 'No ' : '' }}2x Kick
</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-normal gap-2">
<input type="checkbox" class="toggle toggle-sm" [indeterminate]="true" (click)="clickCheckbox('hasIssues', $event)" />
@@ -324,13 +352,36 @@
</label>
</div>
</div>
<div class="flex flex-col">
<div class="form-control">
<label class="label cursor-pointer justify-normal gap-2">
<input
#hasRollLanes
type="checkbox"
class="toggle toggle-sm"
[indeterminate]="true"
(click)="clickCheckbox('hasRollLanes', $event)" />
<span class="label-text" [class.text-opacity-70]="formValue('hasRollLanes') === null">
{{ formValue('hasRollLanes') === false ? 'No ' : '' }}Roll Lanes
</span>
</label>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-normal gap-2">
<input #has2xKick type="checkbox" class="toggle toggle-sm" [indeterminate]="true" (click)="clickCheckbox('has2xKick', $event)" />
<span class="label-text" [class.text-opacity-70]="formValue('has2xKick') === null">
{{ formValue('has2xKick') === false ? 'No ' : '' }}2x Kick
</span>
</label>
</div>
<button
class="btn btn-sm btn-primary uppercase"
[class.btn-disabled]="advancedSearchForm.invalid && startValidation"
(click)="searchAdvanced()">
Search{{ advancedSearchForm.invalid && startValidation ? ' ("Modified After" is invalid)' : '' }}
</button>
</div>
</div>
<button
class="btn btn-sm btn-primary uppercase"
[class.btn-disabled]="advancedSearchForm.invalid && startValidation"
(click)="searchAdvanced()">
Search{{ advancedSearchForm.invalid && startValidation ? ' ("Modified After" is invalid)' : '' }}
</button>
</div>
</div>
</form>

View File

@@ -5,7 +5,7 @@ import dayjs from 'dayjs'
import { distinctUntilChanged, switchMap, throttleTime } from 'rxjs'
import { Difficulty, Instrument } from 'scan-chart'
import { SearchService } from 'src-angular/app/core/services/search.service'
import { difficulties, difficultyDisplay, instrumentDisplay, instruments } from 'src-shared/UtilFunctions'
import { difficulties, difficultyDisplay, drumTypeDisplay, DrumTypeName, drumTypeNames, instrumentDisplay, instruments } from 'src-shared/UtilFunctions'
@Component({
selector: 'app-search-bar',
@@ -25,8 +25,10 @@ export class SearchBarComponent implements OnInit, AfterViewInit {
public showAdvanced = false
public instruments = instruments
public difficulties = difficulties
public drumTypes = drumTypeNames
public instrumentDisplay = instrumentDisplay
public difficultyDisplay = difficultyDisplay
public drumTypeDisplay = drumTypeDisplay
public advancedSearchForm: ReturnType<this['getAdvancedSearchForm']>
public startValidation = false
@@ -90,6 +92,16 @@ export class SearchBarComponent implements OnInit, AfterViewInit {
}
}
get drumType() {
return this.searchService.drumType.value
}
setDrumType(drumType: DrumTypeName | null, event: MouseEvent) {
this.searchService.drumType.setValue(drumType)
if (event.target instanceof HTMLElement) {
event.target.parentElement?.parentElement?.blur()
}
}
get todayDate() {
return dayjs().format('YYYY-MM-DD')
}
@@ -164,8 +176,11 @@ export class SearchBarComponent implements OnInit, AfterViewInit {
maxAverageNPS: null as number | null,
minMaxNPS: null as number | null,
maxMaxNPS: null as number | null,
minYear: null as number | null,
maxYear: null as number | null,
modifiedAfter: this.fb.nonNullable.control('', { validators: dateVaidator }),
hash: this.fb.nonNullable.control(''),
trackHash: this.fb.nonNullable.control(''),
hasSoloSections: null as boolean | null,
hasForcedNotes: null as boolean | null,
hasOpenNotes: null as boolean | null,
@@ -208,6 +223,7 @@ export class SearchBarComponent implements OnInit, AfterViewInit {
this.searchService.advancedSearch({
instrument: this.instrument,
difficulty: this.difficulty,
drumType: this.drumType,
source: 'bridge' as const,
...this.advancedSearchForm.getRawValue(),
}).subscribe()

View File

@@ -158,6 +158,12 @@
Chart Folder
</label>
</div>
<div class="flex">
<label class="label cursor-pointer" for="downloadVideos">
<input id="downloadVideos" type="checkbox" checked="checked" class="checkbox mr-1" [formControl]="downloadVideos" />
Download Video Backgrounds
</label>
</div>
</div>
<div class="form-control">

View File

@@ -14,6 +14,7 @@ export class SettingsComponent implements OnInit {
public chartFolderName: FormControl<string>
public isSng: FormControl<boolean>
public downloadVideos: FormControl<boolean>
public isCompactTable: FormControl<boolean>
public artistColumn: FormControl<boolean>
@@ -44,6 +45,8 @@ export class SettingsComponent implements OnInit {
this.isSng = new FormControl<boolean>(ss.isSng, { nonNullable: true })
this.isSng.valueChanges.subscribe(value => settingsService.isSng = value)
this.downloadVideos = new FormControl<boolean>(ss.downloadVideos, { nonNullable: true })
this.downloadVideos.valueChanges.subscribe(value => settingsService.downloadVideos = value)
this.isCompactTable = new FormControl<boolean>(settingsService.isCompactTable, { nonNullable: true })
this.isCompactTable.valueChanges.subscribe(value => ss.isCompactTable = value)
@@ -91,10 +94,6 @@ export class SettingsComponent implements OnInit {
})
}
async downloadVideos(isChecked: boolean) {
this.settingsService.downloadVideos = isChecked
}
async getLibraryDirectory() {
const result = await window.electron.invoke.showOpenDialog({
title: 'Choose library folder',

View File

@@ -1,7 +1,6 @@
<div class="navbar p-0 min-h-0 bg-base-100" style="-webkit-app-region: drag">
<div style="-webkit-app-region: no-drag">
<button class="btn btn-ghost rounded-none" routerLinkActive="btn-active" routerLink="/browse">Browse</button>
<!-- TODO <a class="btn btn-ghost rounded-none" routerLinkActive="btn-active" routerLink="/library">Library</a> -->
<button class="btn btn-ghost rounded-none flex flex-nowrap" routerLinkActive="btn-active" routerLink="/settings">
<i *ngIf="updateAvailable === 'error'" class="bi bi-exclamation-triangle-fill text-warning"></i>
Settings

View File

@@ -85,7 +85,7 @@ export class DownloadService {
type: 'good',
isPath: false,
})
window.electron.emit.download({ action: 'add', md5: chart.md5, chart: newChart })
window.electron.emit.download({ action: 'add', md5: chart.md5, hasVideoBackground: chart.hasVideoBackground, chart: newChart })
}
this.downloadCountChanges.emit(this.downloadCount)
}

View File

@@ -7,6 +7,7 @@ import { catchError, mergeMap, tap, throwError, timer } from 'rxjs'
import { Difficulty, Instrument } from 'scan-chart'
import { environment } from 'src-angular/environments/environment'
import { AdvancedSearch, ChartData, SearchResult } from 'src-shared/interfaces/search.interface'
import { DrumTypeName } from 'src-shared/UtilFunctions'
const resultsPerPage = 25
@@ -29,6 +30,7 @@ export class SearchService {
public searchControl = new FormControl('', { nonNullable: true })
public instrument: FormControl<Instrument | null>
public difficulty: FormControl<Difficulty | null>
public drumType: FormControl<DrumTypeName | null>
constructor(
private http: HttpClient,
@@ -53,6 +55,16 @@ export class SearchService {
}
})
this.drumType = new FormControl<DrumTypeName>(
(localStorage.getItem('drumType') === 'null' ? null : localStorage.getItem('drumType')) as DrumTypeName
)
this.drumType.valueChanges.subscribe(drumType => {
localStorage.setItem('drumType', `${drumType}`)
if (this.songsResponse.page) {
this.search(this.searchControl.value || '*').subscribe()
}
})
this.http.get<{ "name": string; "sha1": string }[]>('https://clonehero.gitlab.io/sources/icons.json').subscribe(result => {
this.availableIcons = result.map(r => r.name)
})
@@ -86,6 +98,7 @@ export class SearchService {
page: this.currentPage,
instrument: this.instrument.value,
difficulty: this.difficulty.value,
drumType: this.drumType.value,
source: 'bridge',
}).pipe(
catchError((err, caught) => {

View File

@@ -153,6 +153,14 @@ export class SettingsService {
this.zoomFactor = _.round(this.zoomFactor - 0.1, 3)
}
}
get volume() {
return this.settings.volume
}
set volume(value: number) {
this.settings.volume = value
this.saveSettings()
}
}
function setThemeColors(themeColors: ThemeColors) {