Audio player rework

This commit is contained in:
Eduard Urbach 2018-03-24 23:04:31 +01:00
parent 2cbf7c4e5c
commit eb5a8e49aa
3 changed files with 311 additions and 250 deletions

View File

@ -1,177 +1,44 @@
import { AnimeNotifier } from "../AnimeNotifier"
var audioContext: AudioContext
var audioNode: AudioBufferSourceNode
var gainNode: GainNode
var volume = 0.5
var volumeTimeConstant = 0.01
var volumeSmoothingDelay = 0.05
var targetSpeed = 1.0
var playId = 0
var audioPlayer = document.getElementById("audio-player")
var audioPlayerPlay = document.getElementById("audio-player-play") as HTMLButtonElement
var audioPlayerPause = document.getElementById("audio-player-pause") as HTMLButtonElement
var trackLink = document.getElementById("audio-player-track-title") as HTMLLinkElement
var animeInfo = document.getElementById("audio-player-anime-info") as HTMLElement
var animeLink = document.getElementById("audio-player-anime-link") as HTMLLinkElement
var animeImage = document.getElementById("audio-player-anime-image") as HTMLImageElement
var lastRequest: XMLHttpRequest
// Play audio
export function playAudio(arn: AnimeNotifier, element: HTMLElement) {
playAudioFile(arn, element.dataset.soundtrackId, element.dataset.audioSrc)
arn.audioPlayer.play(element.dataset.soundtrackId, element.dataset.audioSrc)
}
// Play audio file
function playAudioFile(arn: AnimeNotifier, trackId: string, trackUrl: string) {
if(!audioContext) {
audioContext = new AudioContext()
gainNode = audioContext.createGain()
gainNode.gain.setTargetAtTime(volume, audioContext.currentTime + volumeSmoothingDelay, volumeTimeConstant)
}
playId++
let currentPlayId = playId
if(lastRequest) {
lastRequest.abort()
lastRequest = null
}
// Stop current track
stopAudio(arn)
arn.currentSoundTrackId = trackId
arn.markPlayingSoundTrack()
arn.loading(true)
// Mark as loading
audioPlayer.classList.add("loading-network")
audioPlayer.classList.remove("decoding-audio")
audioPlayer.classList.remove("decoded")
// Request
let request = new XMLHttpRequest()
request.open("GET", trackUrl, true)
request.responseType = "arraybuffer"
request.onload = () => {
if(currentPlayId !== playId) {
return
}
// Mark as loading finished, now decoding starts
audioPlayer.classList.add("decoding-audio")
arn.loading(false)
audioContext.decodeAudioData(request.response, async buffer => {
if(currentPlayId !== playId) {
return
}
// Mark as ready
audioPlayer.classList.add("decoded")
audioNode = audioContext.createBufferSource()
audioNode.buffer = buffer
audioNode.connect(gainNode)
gainNode.connect(audioContext.destination)
audioNode.playbackRate.setValueAtTime(targetSpeed, 0)
audioNode.start(0)
audioNode.onended = (event: MediaStreamErrorEvent) => {
if(currentPlayId !== playId) {
return
}
playNextTrack(arn)
// stopAudio(arn)
}
}, console.error)
}
request.onerror = () => {
arn.loading(false)
}
lastRequest = request
request.send()
// Update track info
updateTrackInfo(trackId)
// Show audio player
audioPlayer.classList.remove("fade-out")
audioPlayerPlay.classList.add("fade-out")
audioPlayerPause.classList.remove("fade-out")
// Pause audio
export function pauseAudio(arn: AnimeNotifier, button: HTMLButtonElement) {
arn.audioPlayer.pause()
}
// Update track info
async function updateTrackInfo(trackId: string) {
// Set track title
let trackInfoResponse = await fetch("/api/soundtrack/" + trackId)
let track = await trackInfoResponse.json()
trackLink.href = "/soundtrack/" + track.id
trackLink.innerText = track.title
let animeId = ""
for(let tag of (track.tags as string[])) {
if(tag.startsWith("anime:")) {
animeId = tag.split(":")[1]
break
}
}
// Set anime info
if(animeId !== "") {
animeInfo.classList.remove("hidden")
let animeResponse = await fetch("/api/anime/" + animeId)
let anime = await animeResponse.json()
animeLink.title = anime.title.canonical
animeLink.href = "/anime/" + anime.id
animeImage.dataset.src = "//media.notify.moe/images/anime/medium/" + anime.id + ".jpg"
animeImage.classList.remove("hidden")
animeImage["became visible"]()
}
// Resume audio
export function resumeAudio(arn: AnimeNotifier, button: HTMLButtonElement) {
arn.audioPlayer.resume()
}
// Stop audio
export function stopAudio(arn: AnimeNotifier) {
arn.currentSoundTrackId = undefined
arn.audioPlayer.stop()
}
// Remove CSS class "playing"
let playingElements = document.getElementsByClassName("playing")
// Play previous track
export async function playPreviousTrack(arn: AnimeNotifier) {
arn.audioPlayer.previous()
}
for(let playing of playingElements) {
playing.classList.remove("playing")
}
// Play next track
export async function playNextTrack(arn: AnimeNotifier) {
arn.audioPlayer.next()
}
// Fade out sidebar player
// audioPlayer.classList.add("fade-out")
// Set volume
export function setVolume(arn: AnimeNotifier, element: HTMLInputElement) {
let volume = parseFloat(element.value) / 100.0
arn.audioPlayer.setVolume(volume)
}
// Remove title
trackLink.href = ""
trackLink.innerText = ""
// Hide anime info
animeLink.href = ""
animeInfo.classList.add("hidden")
animeImage.classList.add("hidden")
// Show play button
audioPlayerPlay.classList.remove("fade-out")
audioPlayerPause.classList.add("fade-out")
if(gainNode) {
gainNode.disconnect()
}
if(audioNode) {
audioNode.stop()
audioNode.disconnect()
audioNode = null
}
// Play or pause audio
export function playPauseAudio(arn: AnimeNotifier) {
arn.audioPlayer.playPause()
}
// Toggle audio
@ -184,86 +51,3 @@ export function toggleAudio(arn: AnimeNotifier, element: HTMLElement) {
playAudio(arn, element)
}
}
// Play or pause audio
export function playPauseAudio(arn: AnimeNotifier) {
if(!audioNode) {
playNextTrack(arn)
return
}
if(audioNode.playbackRate.value === 0) {
resumeAudio(arn, audioPlayerPlay)
} else {
pauseAudio(arn, audioPlayerPlay)
}
}
// Play previous track
export async function playPreviousTrack(arn: AnimeNotifier) {
alert("Previous track is currently work in progress! Check back later :)")
}
// Play next track
export async function playNextTrack(arn: AnimeNotifier) {
// Get random track
let response = await fetch("/api/next/soundtrack")
let track = await response.json()
playAudioFile(arn, track.id, "https://notify.moe/audio/" + track.file)
// arn.statusMessage.showInfo("Now playing: " + track.title)
return track
}
// Set volume
export function setVolume(arn: AnimeNotifier, element: HTMLInputElement) {
volume = parseFloat(element.value) / 100.0
if(gainNode) {
gainNode.gain.setTargetAtTime(volume, audioContext.currentTime + volumeSmoothingDelay, volumeTimeConstant)
}
}
// Pause audio
export function pauseAudio(arn: AnimeNotifier, button: HTMLButtonElement) {
if(!audioNode) {
return
}
audioNode.playbackRate.setValueAtTime(0.0, 0)
audioPlayerPlay.classList.remove("fade-out")
audioPlayerPause.classList.add("fade-out")
}
// Resume audio
export function resumeAudio(arn: AnimeNotifier, button: HTMLButtonElement) {
if(!audioNode) {
playNextTrack(arn)
return
}
audioNode.playbackRate.setValueAtTime(targetSpeed, 0)
audioPlayerPlay.classList.add("fade-out")
audioPlayerPause.classList.remove("fade-out")
}
// Add speed
export function addSpeed(arn: AnimeNotifier, speed: number) {
if(!audioNode || audioNode.playbackRate.value === 0) {
return
}
targetSpeed += speed
if(targetSpeed < 0.5) {
targetSpeed = 0.5
} else if(targetSpeed > 2) {
targetSpeed = 2
}
audioNode.playbackRate.setValueAtTime(targetSpeed, 0)
arn.statusMessage.showInfo("Playback speed: " + Math.round(targetSpeed * 100) + "%")
}

View File

@ -4,6 +4,7 @@ import { StatusMessage } from "./StatusMessage"
import { PushManager } from "./PushManager"
import { TouchController } from "./TouchController"
import { NotificationManager } from "./NotificationManager"
import { AudioPlayer } from "./AudioPlayer"
import { Analytics } from "./Analytics"
import { SideBar } from "./SideBar"
import { InfiniteScroller } from "./InfiniteScroller"
@ -11,8 +12,6 @@ import { ServiceWorkerManager } from "./ServiceWorkerManager"
import { displayAiringDate, displayDate, displayTime } from "./DateView"
import { findAll, delay, canUseWebP, swapElements } from "./Utils"
import * as actions from "./Actions"
import { darkTheme, addSpeed, playPreviousTrack, search } from "./Actions";
import { playPauseAudio, playNextTrack } from "./Actions/Audio"
export class AnimeNotifier {
app: Application
@ -27,6 +26,7 @@ export class AnimeNotifier {
serviceWorkerManager: ServiceWorkerManager
notificationManager: NotificationManager
touchController: TouchController
audioPlayer: AudioPlayer
sideBar: SideBar
infiniteScroller: InfiniteScroller
mainPageLoaded: boolean
@ -116,7 +116,7 @@ export class AnimeNotifier {
// Theme
if(this.user && this.user.dataset.pro === "true" && this.user.dataset.theme !== "light") {
darkTheme(this)
actions.darkTheme(this)
}
// Status message
@ -131,6 +131,9 @@ export class AnimeNotifier {
// Notification manager
this.notificationManager = new NotificationManager()
// Audio player
this.audioPlayer = new AudioPlayer(this)
// Analytics
this.analytics = new Analytics()
@ -877,7 +880,7 @@ export class AnimeNotifier {
case "INPUT":
//
if(activeElement.id === "search" && e.keyCode === 13) {
search(this, activeElement as HTMLInputElement, e)
actions.search(this, activeElement as HTMLInputElement, e)
}
return
@ -938,7 +941,7 @@ export class AnimeNotifier {
// "+" = Audio speed up
if(e.keyCode === 107 || e.keyCode === 187) {
addSpeed(this, 0.05)
this.audioPlayer.addSpeed(0.05)
e.preventDefault()
e.stopPropagation()
@ -947,7 +950,7 @@ export class AnimeNotifier {
// "-" = Audio speed down
if(e.keyCode === 109 || e.keyCode === 189) {
addSpeed(this, -0.05)
this.audioPlayer.addSpeed(-0.05)
e.preventDefault()
e.stopPropagation()
@ -956,7 +959,7 @@ export class AnimeNotifier {
// "J" = Previous track
if(e.keyCode === 74) {
playPreviousTrack(this)
this.audioPlayer.previous()
e.preventDefault()
e.stopPropagation()
@ -965,7 +968,7 @@ export class AnimeNotifier {
// "K" = Play/pause
if(e.keyCode === 75) {
playPauseAudio(this)
this.audioPlayer.playPause()
e.preventDefault()
e.stopPropagation()
@ -974,7 +977,7 @@ export class AnimeNotifier {
// "L" = Next track
if(e.keyCode === 76) {
playNextTrack(this)
this.audioPlayer.next()
e.preventDefault()
e.stopPropagation()

274
scripts/AudioPlayer.ts Normal file
View File

@ -0,0 +1,274 @@
import { AnimeNotifier } from "./AnimeNotifier"
export class AudioPlayer {
arn: AnimeNotifier
// Web audio
audioContext: AudioContext
audioNode: AudioBufferSourceNode
gainNode: GainNode
// Parameters
volume = 0.5
volumeTimeConstant = 0.01
volumeSmoothingDelay = 0.05
targetSpeed = 1.0
playId = 0
// Save last request so that we can cancel it
lastRequest: XMLHttpRequest
// DOM elements
audioPlayer: HTMLElement
audioPlayerPlay: HTMLButtonElement
audioPlayerPause: HTMLButtonElement
trackLink: HTMLLinkElement
animeInfo: HTMLElement
animeLink: HTMLLinkElement
animeImage: HTMLImageElement
constructor(arn: AnimeNotifier) {
this.arn = arn
this.audioPlayer = document.getElementById("audio-player")
this.audioPlayerPlay = document.getElementById("audio-player-play") as HTMLButtonElement
this.audioPlayerPause = document.getElementById("audio-player-pause") as HTMLButtonElement
this.trackLink = document.getElementById("audio-player-track-title") as HTMLLinkElement
this.animeInfo = document.getElementById("audio-player-anime-info") as HTMLElement
this.animeLink = document.getElementById("audio-player-anime-link") as HTMLLinkElement
this.animeImage = document.getElementById("audio-player-anime-image") as HTMLImageElement
}
// Play audio file
play(trackId: string, trackUrl: string) {
if(!this.audioContext) {
this.audioContext = new AudioContext()
this.gainNode = this.audioContext.createGain()
this.gainNode.gain.setTargetAtTime(this.volume, this.audioContext.currentTime + this.volumeSmoothingDelay, this.volumeTimeConstant)
}
this.playId++
let currentPlayId = this.playId
if(this.lastRequest) {
this.lastRequest.abort()
this.lastRequest = null
}
// Stop current track
this.stop()
this.arn.currentSoundTrackId = trackId
this.arn.markPlayingSoundTrack()
this.arn.loading(true)
// Mark as loading
this.audioPlayer.classList.add("loading-network")
this.audioPlayer.classList.remove("decoding-audio")
this.audioPlayer.classList.remove("decoded")
// Request
let request = new XMLHttpRequest()
request.open("GET", trackUrl, true)
request.responseType = "arraybuffer"
request.onload = () => {
if(currentPlayId !== this.playId) {
return
}
// Mark as loading finished, now decoding starts
this.audioPlayer.classList.add("decoding-audio")
this.arn.loading(false)
this.audioContext.decodeAudioData(request.response, async buffer => {
if(currentPlayId !== this.playId) {
return
}
// Mark as ready
this.audioPlayer.classList.add("decoded")
this.audioNode = this.audioContext.createBufferSource()
this.audioNode.buffer = buffer
this.audioNode.connect(this.gainNode)
this.gainNode.connect(this.audioContext.destination)
this.audioNode.playbackRate.setValueAtTime(this.targetSpeed, 0)
this.audioNode.start(0)
this.audioNode.onended = (event: MediaStreamErrorEvent) => {
if(currentPlayId !== this.playId) {
return
}
this.next()
}
}, console.error)
}
request.onerror = () => {
this.arn.loading(false)
}
this.lastRequest = request
request.send()
// Update track info
this.updateTrackInfo(trackId)
// Show audio player
this.audioPlayer.classList.remove("fade-out")
this.audioPlayerPlay.classList.add("fade-out")
this.audioPlayerPause.classList.remove("fade-out")
}
// Pause
pause() {
if(!this.audioNode) {
return
}
this.audioNode.playbackRate.setValueAtTime(0.0, 0)
this.audioPlayerPlay.classList.remove("fade-out")
this.audioPlayerPause.classList.add("fade-out")
}
// Resume
resume() {
if(!this.audioNode) {
this.next()
return
}
this.audioNode.playbackRate.setValueAtTime(this.targetSpeed, 0)
this.audioPlayerPlay.classList.add("fade-out")
this.audioPlayerPause.classList.remove("fade-out")
}
// Stop
stop() {
this.arn.currentSoundTrackId = undefined
// Remove CSS class "playing"
let playingElements = document.getElementsByClassName("playing")
for(let playing of playingElements) {
playing.classList.remove("playing")
}
// Fade out sidebar player
// audioPlayer.classList.add("fade-out")
// Remove title
this.trackLink.href = ""
this.trackLink.innerText = ""
// Hide anime info
this.animeLink.href = ""
this.animeInfo.classList.add("hidden")
this.animeImage.classList.add("hidden")
// Show play button
this.audioPlayerPlay.classList.remove("fade-out")
this.audioPlayerPause.classList.add("fade-out")
if(this.gainNode) {
this.gainNode.disconnect()
}
if(this.audioNode) {
this.audioNode.stop()
this.audioNode.disconnect()
this.audioNode = null
}
}
// Previous track
async previous() {
alert("Previous track is currently work in progress! Check back later :)")
}
// Next track
async next() {
// Get random track
let response = await fetch("/api/next/soundtrack")
let track = await response.json()
this.play(track.id, "https://notify.moe/audio/" + track.file)
// arn.statusMessage.showInfo("Now playing: " + track.title)
return track
}
// Set volume
setVolume(volume: number) {
if(!this.gainNode) {
return
}
this.gainNode.gain.setTargetAtTime(volume, this.audioContext.currentTime + this.volumeSmoothingDelay, this.volumeTimeConstant)
}
// Add speed
addSpeed(speed: number) {
if(!this.audioNode || this.audioNode.playbackRate.value === 0) {
return
}
this.targetSpeed += speed
if(this.targetSpeed < 0.5) {
this.targetSpeed = 0.5
} else if(this.targetSpeed > 2) {
this.targetSpeed = 2
}
this.audioNode.playbackRate.setValueAtTime(this.targetSpeed, 0)
this.arn.statusMessage.showInfo("Playback speed: " + Math.round(this.targetSpeed * 100) + "%")
}
// Play or pause
playPause() {
if(!this.audioNode) {
this.next()
return
}
if(this.audioNode.playbackRate.value === 0) {
this.resume()
} else {
this.pause()
}
}
// Update track info
async updateTrackInfo(trackId: string) {
// Set track title
let trackInfoResponse = await fetch("/api/soundtrack/" + trackId)
let track = await trackInfoResponse.json()
this.trackLink.href = "/soundtrack/" + track.id
this.trackLink.innerText = track.title
let animeId = ""
for(let tag of (track.tags as string[])) {
if(tag.startsWith("anime:")) {
animeId = tag.split(":")[1]
break
}
}
// Set anime info
if(animeId !== "") {
this.animeInfo.classList.remove("hidden")
let animeResponse = await fetch("/api/anime/" + animeId)
let anime = await animeResponse.json()
this.animeLink.title = anime.title.canonical
this.animeLink.href = "/anime/" + anime.id
this.animeImage.dataset.src = "//media.notify.moe/images/anime/medium/" + anime.id + ".jpg"
this.animeImage.classList.remove("hidden")
this.animeImage["became visible"]()
}
}
}