Skip to content

Instantly share code, notes, and snippets.

@snuffyDev
Created May 7, 2023 14:38
Show Gist options
  • Select an option

  • Save snuffyDev/5f0a5d15ca2728c6337cc1b5d2abef72 to your computer and use it in GitHub Desktop.

Select an option

Save snuffyDev/5f0a5d15ca2728c6337cc1b5d2abef72 to your computer and use it in GitHub Desktop.
/* 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