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 @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 | null = null private textures: Awaited> | null = null private audioFiles: Uint8Array[] | null = null public seekBar: FormControl public volumeBar: FormControl public timestampUpdateInterval: NodeJS.Timer public timestampText: string = '' constructor( private cdr: ChangeDetectorRef, private settingsService: SettingsService, ) { } ngOnInit() { this.seekBar = new FormControl( (100 * (this.selectedChart.preview_start_time ?? 0)) / (this.selectedChart.song_length ?? 5 * 60 * 1000), { 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(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, 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(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 }