diff --git a/scripts/Actions/Audio.ts b/scripts/Actions/Audio.ts index 86eac194..aac8fda8 100644 --- a/scripts/Actions/Audio.ts +++ b/scripts/Actions/Audio.ts @@ -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) + "%") -} \ No newline at end of file diff --git a/scripts/AnimeNotifier.ts b/scripts/AnimeNotifier.ts index 102610df..ce235368 100644 --- a/scripts/AnimeNotifier.ts +++ b/scripts/AnimeNotifier.ts @@ -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() diff --git a/scripts/AudioPlayer.ts b/scripts/AudioPlayer.ts new file mode 100644 index 00000000..b4b96f02 --- /dev/null +++ b/scripts/AudioPlayer.ts @@ -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"]() + } + } +} \ No newline at end of file