Files
Bridge-Multi/src-angular/app/components/browse/chart-sidebar/chart-sidebar-preview/chart-sidebar-preview.component.ts
2024-08-04 15:19:58 -05:00

284 lines
9.2 KiB
TypeScript

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>(
(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<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,
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
}