Created
May 7, 2023 14:38
-
-
Save snuffyDev/5f0a5d15ca2728c6337cc1b5d2abef72 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* eslint-disable @typescript-eslint/no-inferrable-types */ | |
| import { SessionListService } from "$stores/list/sessionList"; | |
| import { tweened } from "svelte/motion"; | |
| import { sort, type PlayerFormats } from "./parsers/player"; | |
| import { isAppleMobileDevice } from "./utils/browserDetection"; | |
| import { browser } from "$app/environment"; | |
| import type { UserSettings } from "$stores/settings"; | |
| import { get, writable } from "svelte/store"; | |
| import { settings, type ISessionListProvider } from "./stores"; | |
| import { groupSession, type ConnectionState } from "./stores/sessions"; | |
| import { notify, type Maybe, type ResponseBody } from "./utils"; | |
| let userSettings: UserSettings | undefined = undefined; | |
| if (browser && globalThis.self.name !== "IDB" && settings) { | |
| settings.subscribe((value) => { | |
| userSettings = value; | |
| }); | |
| } | |
| export type Callback<K extends keyof HTMLElementEventMap> = (this: HTMLElement, event: HTMLElementEventMap[K]) => void; | |
| export type Listeners = Map<string, Callback<keyof HTMLElementEventMap>[]>; | |
| export interface IEventHandler { | |
| onEvent<K extends keyof HTMLElementEventMap>(type: K, cb: Callback<K>): void; | |
| } | |
| type SrcDict = { original_url: string; url: string }; | |
| interface AudioPlayerEvents { | |
| play: unknown; | |
| } | |
| const setPosition = () => { | |
| if ("mediaSession" in navigator) { | |
| navigator.mediaSession.setPositionState({ | |
| duration: AudioPlayer.isWebkit ? AudioPlayer.duration / 2 : AudioPlayer.duration, | |
| position: AudioPlayer.currentTime, | |
| }); | |
| } | |
| }; | |
| function metaDataHandler(sessionList: Maybe<ISessionListProvider>) { | |
| if (!sessionList) sessionList = get(AudioPlayer); | |
| if ("mediaSession" in navigator) { | |
| const position = sessionList.position; | |
| const currentTrack = sessionList.mix[position]; | |
| navigator.mediaSession.metadata = new MediaMetadata({ | |
| title: currentTrack?.title, | |
| artist: currentTrack?.artistInfo?.artist[0].text || null, | |
| album: currentTrack?.album?.title ?? undefined, | |
| artwork: [ | |
| { | |
| src: currentTrack?.thumbnails[currentTrack?.thumbnails.length - 1].url, | |
| sizes: `${currentTrack?.thumbnails[currentTrack?.thumbnails.length - 1].width}x${ | |
| currentTrack?.thumbnails[currentTrack?.thumbnails.length - 1].height | |
| }`, | |
| type: "image/jpeg", | |
| }, | |
| ], | |
| }); | |
| navigator.mediaSession.setActionHandler("play", () => { | |
| AudioPlayer.play(); | |
| }); | |
| navigator.mediaSession.setActionHandler("pause", () => AudioPlayer.pause()); | |
| navigator.mediaSession.setActionHandler("seekto", (session) => { | |
| if (session.fastSeek && "fastSeek" in AudioPlayer.player) { | |
| AudioPlayer.player.fastSeek(session.seekTime); | |
| setPosition(); | |
| return; | |
| } | |
| AudioPlayer.seek(session.seekTime); | |
| setPosition(); | |
| }); | |
| navigator.mediaSession.setActionHandler("previoustrack", () => AudioPlayer.previous()); | |
| navigator.mediaSession.setActionHandler("nexttrack", () => AudioPlayer.next()); | |
| } | |
| } | |
| // eslint-disable-next-line import/no-unused-modules | |
| export const updateGroupState = (opts: { client: string; state: ConnectionState }): void => | |
| groupSession.sendGroupState(opts); | |
| // eslint-disable-next-line import/no-unused-modules | |
| export const updateGroupPosition = (dir: "<-" | "->" | undefined, position: number): void => | |
| groupSession.send("PATCH", "state.update.position", { dir, position }, groupSession.client); | |
| // Helper to generate a fallback URL if the current src fails to play | |
| function createFallbackUrl(currentUrl: string) { | |
| if (typeof currentUrl !== "string") | |
| throw Error(`Expected parameter 'currentUrl' to be a string, received ${currentUrl}`); | |
| const srcUrl = new URL(currentUrl); | |
| if (!srcUrl.hostname.includes("googlevideo.com")) return currentUrl; | |
| // example: [ rr4---sn-p5ql61yl , googlevideo , com ] | |
| const [subdomain, domain, ext] = srcUrl.hostname.split("."); | |
| const fvip = srcUrl.searchParams.get("fvip"); | |
| // comma-separated list of fallback server hosts | |
| const mn = srcUrl.searchParams.get("mn"); | |
| let [preDashes, postDashes] = subdomain.split("---"); | |
| // step 1: replace digits in first part of subdomain with fvip | |
| preDashes = preDashes.replace(/\d/g, fvip); | |
| // step 2: use one of the fallback server names found in mn | |
| postDashes = mn.split(",")[1]; | |
| /** */ | |
| srcUrl.hostname = `${`${preDashes}---${postDashes}`}.${domain}.${ext}`; | |
| return srcUrl.toString(); | |
| } | |
| type EventCallbackFn<T> = (data: T) => void; | |
| class EventEmitter<Events extends Record<string, any>> { | |
| private listeners: Map<keyof Events, EventCallbackFn<Events[keyof Events] & string>[]> = new Map(); | |
| constructor() { | |
| // | |
| } | |
| dispatch<Key extends keyof Events = keyof Events & string>(name: Key, data: Events[Key]) { | |
| const listeners = this.listeners.get(name) ?? []; | |
| for (const cb of listeners) { | |
| cb?.(data); | |
| } | |
| } | |
| off<Key extends keyof Events = keyof Events & string>(name: Key, callback: EventCallbackFn<Events[Key]>) { | |
| const listeners = this.listeners.get(name) ?? []; | |
| const index = listeners.indexOf(callback); | |
| if (index > 0) { | |
| listeners.splice(index, 1); | |
| } | |
| this.listeners.set(name, listeners); | |
| } | |
| on<Key extends keyof Events = keyof Events & string>(name: Key, callback: EventCallbackFn<Events[Key]>) { | |
| const listeners = this.listeners.get(name) ?? []; | |
| listeners.push(callback); | |
| this.listeners.set(name, listeners); | |
| } | |
| } | |
| const getPlayerVolumeFromLS = (player: HTMLAudioElement) => { | |
| const storedLevel = localStorage.getItem("volume"); | |
| const setDefaultVolume = () => localStorage.setItem("volume", "0.5"); | |
| if (storedLevel !== null) { | |
| try { | |
| player.volume = parseInt(storedLevel); | |
| } catch { | |
| setDefaultVolume(); | |
| } | |
| } else { | |
| setDefaultVolume(); | |
| } | |
| }; | |
| class AudioPlayerImpl extends EventEmitter<AudioPlayerEvents> { | |
| private _currentTimeStore = writable<number>(0); | |
| private _durationStore = writable<number>(0); | |
| private _paused = writable(true); | |
| private _progress = tweened<number>(0); | |
| private _taskQueue: [name: keyof AudioPlayerImpl, args: [...rest: any[]]][] = []; | |
| private audioNodeListeners: Record<string, () => void> = {}; | |
| private invalidationTimer: ReturnType<typeof setTimeout> | null = null; | |
| private nextSrc: { stale: boolean; url: string } = { stale: false, url: "" }; | |
| private declare player: HTMLAudioElement; | |
| constructor() { | |
| super(); | |
| if (!browser) return; | |
| const onUserInteractionCallback = () => { | |
| if (!this.player) { | |
| this.createAudioNode(); | |
| } | |
| }; | |
| document.addEventListener("click", onUserInteractionCallback, { capture: true, once: true }); | |
| const unsubscriber = this.currentTimeStore.subscribe((currentTime) => { | |
| if (!this.player) return; | |
| const remainingTime = this.player.duration - this.player.currentTime; | |
| const halfwayTime = this.player.duration / 2; | |
| const timeoutDuration = Math.max(halfwayTime - remainingTime / 2, 0); | |
| console.log({ timeoutDuration }); | |
| console.log({ remainingTime, halfwayTime }); | |
| }); | |
| } | |
| public get currentTimeStore() { | |
| return this._currentTimeStore; | |
| } | |
| public set currentTimeStore(value) { | |
| this._currentTimeStore = value; | |
| } | |
| public get durationStore() { | |
| return this._durationStore; | |
| } | |
| public get paused() { | |
| return this._paused; | |
| } | |
| public get progress() { | |
| return this._progress; | |
| } | |
| public set progress(value) { | |
| this._progress = value; | |
| } | |
| public get subscribe() { | |
| return () => { | |
| // eslint-disable-next-line @typescript-eslint/no-empty-function | |
| return () => {}; | |
| }; | |
| } | |
| public set volume(value: number) { | |
| if (!this.player) return; | |
| this.player.volume = value; | |
| } | |
| public dispose() { | |
| for (const key in this.audioNodeListeners) { | |
| const callback = this.audioNodeListeners[key]; | |
| this.player.removeEventListener(key, callback); | |
| } | |
| } | |
| public pause() { | |
| if (!this.player) { | |
| this.addTaskToTaskQueue("pause"); | |
| return; | |
| } | |
| this.player.pause(); | |
| } | |
| public play() { | |
| if (!this.player) { | |
| this.addTaskToTaskQueue("play"); | |
| return; | |
| } | |
| const promise = this.player.play(); | |
| if (promise) { | |
| promise | |
| .then(() => { | |
| this.player.play(); | |
| }) | |
| .catch((e) => console.error("ERROR", e)); | |
| } | |
| } | |
| public seek(to: number) { | |
| this.player.currentTime = to; | |
| } | |
| public setNextTrackPrefetchedUrl(trackUrl: string) { | |
| this.setStaleTimeout(); | |
| this.nextSrc.url = trackUrl; | |
| this.nextSrc.stale = false; | |
| } | |
| public updateSrc({ original_url, url }: { original_url: string; url: string }) { | |
| this.player.src = original_url; | |
| } | |
| private addTaskToTaskQueue(name: keyof AudioPlayerImpl, ...args: any[]) { | |
| this._taskQueue.push([name, args]); | |
| } | |
| private createAudioNode() { | |
| this.player = new Audio(); | |
| this.player.autoplay = true; | |
| getPlayerVolumeFromLS(this.player); | |
| this.onEvent("timeupdate", async () => { | |
| this._currentTimeStore.set(this.player.currentTime); | |
| const duration = isAppleMobileDevice ? this.player.duration / 2 : this.player.duration; | |
| this._durationStore.set(duration); | |
| if (this.nextSrc.stale && this.player.currentTime >= duration / 2) { | |
| if (this.nextSrc.stale) await SessionListService.prefetchNextTrack(); | |
| } | |
| if (this.player.currentTime >= duration - 0.1) { | |
| await SessionListService.next(); | |
| } | |
| }); | |
| this.on("play", () => { | |
| console.log(this); | |
| this.play(); | |
| }); | |
| this.onEvent("loadedmetadata", () => { | |
| this._paused.set(false); | |
| this.setStaleTimeout(); | |
| }); | |
| console.log(this); | |
| this.onEvent("play", () => { | |
| console.log(this); | |
| this._paused.set(false); | |
| const promise = this.player.play(); | |
| if (promise) { | |
| promise | |
| .then(() => { | |
| this.player.play(); | |
| }) | |
| .catch((e) => console.error("ERROR", e)); | |
| } | |
| }); | |
| if (this._taskQueue.length) { | |
| while (this._taskQueue.length) { | |
| const [name, args] = this._taskQueue.shift()!; | |
| const method = this[name]; | |
| //@ts-expect-error It's fine | |
| if (typeof method === "function") method(...args); | |
| } | |
| } | |
| } | |
| private onEvent(name: keyof HTMLMediaElementEventMap, callback: () => void) { | |
| if (!this.player) this.createAudioNode(); | |
| this.audioNodeListeners[name] = callback; | |
| this.player.addEventListener(name, callback); | |
| } | |
| private setStaleTimeout() { | |
| if (this.invalidationTimer) clearTimeout(this.invalidationTimer); | |
| const remainingTime = this.player.duration - this.player.currentTime; | |
| const halfwayTime = this.player.duration / 2; | |
| const timeoutDuration = Math.max(halfwayTime - remainingTime / 2, 0); | |
| this.invalidationTimer = setTimeout(() => { | |
| this.nextSrc.stale = true; | |
| }, timeoutDuration); | |
| } | |
| } | |
| export const AudioPlayer = new AudioPlayerImpl(); | |
| /** Updates the current track for the audio player */ | |
| export function updatePlayerSrc({ original_url, url }: SrcDict): void { | |
| AudioPlayer.updateSrc({ original_url, url }); | |
| } | |
| // Get source URLs | |
| export const getSrc = async ( | |
| videoId?: string, | |
| playlistId?: string, | |
| params?: string, | |
| shouldAutoplay = true, | |
| ): Promise<{ | |
| body: ResponseBody; | |
| error?: boolean; | |
| } | undefined> => { | |
| try { | |
| const res = await fetch( | |
| `/api/v1/player.json?videoId=${videoId}${playlistId ? `&playlistId=${playlistId}` : ""}${ | |
| params ? `&playerParams=${params}` : "" | |
| }`, | |
| ).then((res) => res.json()); | |
| if (res && !res?.streamingData && res?.playabilityStatus.status === "UNPLAYABLE") { | |
| return handleError(); | |
| } | |
| const formats = sort({ | |
| data: res, | |
| dash: false, | |
| proxyUrl: userSettings?.network["HLS Stream Proxy"] ?? "", | |
| }); | |
| const src = setTrack(formats, shouldAutoplay); | |
| return src; | |
| } catch (err) { | |
| console.error(err); | |
| notify(err, "error"); | |
| } | |
| }; | |
| function setTrack(formats: PlayerFormats, shouldAutoplay: boolean) { | |
| let format = undefined; | |
| if (userSettings?.playback?.Stream === "HLS") { | |
| format = { original_url: formats?.hls, url: formats.hls }; | |
| } else { | |
| format = formats.streams[0]; | |
| } | |
| if (shouldAutoplay) updatePlayerSrc({ original_url: format.original_url, url: format.url }); | |
| return { | |
| body: { original_url: format.original_url, url: format.url }, | |
| error: false, | |
| }; | |
| } | |
| function handleError() { | |
| console.log("error"); | |
| notify("An error occurred while initiating playback, skipping...", "error", "getNextTrack"); | |
| return { | |
| body: null, | |
| error: true, | |
| }; | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment