mirror of
https://github.com/Myxelium/Bridge-Multi.git
synced 2026-04-09 05:09:39 +00:00
- 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
281 lines
9.1 KiB
TypeScript
281 lines
9.1 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>(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
|
|
}
|