update discordeno-audio-plugin, add playlist ability
This commit is contained in:
parent
c7c049a097
commit
50e3c4ed21
|
@ -1 +1,4 @@
|
||||||
/configs.ts
|
/configs.ts
|
||||||
|
/errors.txt
|
||||||
|
/unused_extras.ts
|
||||||
|
/test
|
|
@ -46,6 +46,10 @@ export async function parseCommand(bot: Bot, interaction: Interaction) {
|
||||||
await skip(bot, interaction);
|
await skip(bot, interaction);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "stop": {
|
||||||
|
await pause(bot, interaction);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "unloop": {
|
case "unloop": {
|
||||||
await unloop(bot, interaction);
|
await unloop(bot, interaction);
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -7,9 +7,17 @@ import {
|
||||||
type CreateSlashApplicationCommand
|
type CreateSlashApplicationCommand
|
||||||
} from "../deps.ts";
|
} from "../deps.ts";
|
||||||
|
|
||||||
import { ensureVoiceConnection, formatCallbackData, waitingForResponse } from "../utils.ts";
|
import { YouTube } from "../discordeno-audio-plugin/deps.ts";
|
||||||
|
|
||||||
function addedToQueueResponse(interaction: Interaction, title: string) {
|
import { ensureVoiceConnection, formatCallbackData, isPlaylist, waitingForResponse } from "../utils.ts";
|
||||||
|
|
||||||
|
async function addedPlaylistResponse(interaction: Interaction, url: string) {
|
||||||
|
const playlist = await YouTube.getPlaylist(url);
|
||||||
|
return formatCallbackData(`${interaction.user.username} added ${playlist.videoCount} videos from [**${playlist.title}**](${interaction!.data!.options![0].value}) to the queue.`,
|
||||||
|
"Added to queue");
|
||||||
|
}
|
||||||
|
|
||||||
|
function addedSongResponse(interaction: Interaction, title: string) {
|
||||||
return formatCallbackData(`${interaction.user.username} added [**${title}**](${interaction!.data!.options![0].value}) to the queue.`, "Added to queue");
|
return formatCallbackData(`${interaction.user.username} added [**${title}**](${interaction!.data!.options![0].value}) to the queue.`, "Added to queue");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,7 +82,12 @@ export async function play(bot: Bot, interaction: Interaction) {
|
||||||
|
|
||||||
const result = await player.pushQuery(interaction.guildId, interaction.user.username, href);
|
const result = await player.pushQuery(interaction.guildId, interaction.user.username, href);
|
||||||
if(result && result[0] && parsed_url.href.indexOf("youtube.com") !== -1 || parsed_url.href.indexOf("youtu.be") !== -1 && result[0].title) {
|
if(result && result[0] && parsed_url.href.indexOf("youtube.com") !== -1 || parsed_url.href.indexOf("youtu.be") !== -1 && result[0].title) {
|
||||||
await editOriginalInteractionResponse(bot, interaction.token, addedToQueueResponse(interaction, result[0].title));
|
if(isPlaylist(parsed_url.href))
|
||||||
|
{
|
||||||
|
await editOriginalInteractionResponse(bot, interaction.token, await addedPlaylistResponse(interaction, parsed_url.href));
|
||||||
|
} else {
|
||||||
|
await editOriginalInteractionResponse(bot, interaction.token, addedSongResponse(interaction, result[0].title));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// restart the player if there's no url
|
// restart the player if there's no url
|
||||||
|
|
|
@ -2,5 +2,5 @@ export * from "https://deno.land/x/discordeno@18.0.1/mod.ts";
|
||||||
export * from "https://deno.land/x/discordeno@18.0.1/plugins/cache/mod.ts";
|
export * from "https://deno.land/x/discordeno@18.0.1/plugins/cache/mod.ts";
|
||||||
export * as opus from "https://unpkg.com/@evan/wasm@0.0.95/target/opus/deno.js";
|
export * as opus from "https://unpkg.com/@evan/wasm@0.0.95/target/opus/deno.js";
|
||||||
export * from "https://unpkg.com/@evan/wasm@0.0.95/target/nacl/deno.js";
|
export * from "https://unpkg.com/@evan/wasm@0.0.95/target/nacl/deno.js";
|
||||||
export { ytDownload } from "https://deno.land/x/yt_download@1.7/mod.ts";
|
export { getVideoInfo, ytDownload } from "https://deno.land/x/yt_download@1.7/mod.ts";
|
||||||
export { default as YouTube } from "https://deno.land/x/youtube_sr@v4.1.17/mod.ts";
|
export { default as YouTube } from "https://deno.land/x/youtube_sr@v4.1.17/mod.ts";
|
|
@ -1,7 +1,23 @@
|
||||||
|
import { YouTube } from "../../deps.ts";
|
||||||
import { getYoutubeSources } from "./youtube.ts";
|
import { getYoutubeSources } from "./youtube.ts";
|
||||||
|
|
||||||
|
import { isPlaylist } from "../../../utils.ts";
|
||||||
|
|
||||||
export type LoadSource = typeof loadLocalOrYoutube;
|
export type LoadSource = typeof loadLocalOrYoutube;
|
||||||
|
|
||||||
export function loadLocalOrYoutube(query: string, guildId: bigint, added_by?: string) {
|
export async function loadLocalOrYoutube(query: string, guildId: bigint, added_by?: string) {
|
||||||
return getYoutubeSources(guildId, String(added_by), query);
|
const queries = [];
|
||||||
|
|
||||||
|
if(isPlaylist(query))
|
||||||
|
{
|
||||||
|
const playlist = await YouTube.getPlaylist(query);
|
||||||
|
for(const video of playlist.videos) {
|
||||||
|
const videoId = video.id ? video.id : "";
|
||||||
|
queries.push(videoId);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
queries.push(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getYoutubeSources(guildId, String(added_by), queries);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,22 +1,63 @@
|
||||||
import { YouTube, ytDownload } from "../../deps.ts";
|
import { getVideoInfo, YouTube, ytDownload } from "../../deps.ts";
|
||||||
import { bufferIter, retry } from "../../utils/mod.ts";
|
import { bufferIter, retry } from "../../utils/mod.ts";
|
||||||
import { demux } from "../demux/mod.ts";
|
import { demux } from "../demux/mod.ts";
|
||||||
import { createAudioSource, empty } from "./audio-source.ts";
|
import { createAudioSource, empty } from "./audio-source.ts";
|
||||||
|
|
||||||
import { errorMessageCallback, parseYoutubeId } from "../../../utils.ts";
|
import { errorMessageCallback, isPlaylist } from "../../../utils.ts";
|
||||||
|
|
||||||
export async function getYoutubeSources(guildId: bigint, added_by?: string, ...queries: string[]) {
|
export async function getYoutubeSources(guildId: bigint, added_by?: string, queries: string[]) {
|
||||||
const sources = queries.map((query) => getYoutubeSource(query, guildId, added_by));
|
const sources = queries.map((query) => getYoutubeSource(query, guildId, added_by));
|
||||||
const awaitedSources = await Promise.all(sources);
|
const awaitedSources = await Promise.all(sources);
|
||||||
|
|
||||||
return awaitedSources
|
return awaitedSources
|
||||||
.filter((source) => source !== undefined)
|
.filter((source) => source !== undefined)
|
||||||
.map((source) => source!);
|
.map((source) => source!);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getYoutubeSource(query: string, guildId: bigint, added_by?: string) {
|
/*export async function getYoutubeSource(query: string, guildId: bigint, added_by?: string) {
|
||||||
|
if(isPlaylist(query)) {
|
||||||
|
const playlist = await YouTube.getPlaylist(query);
|
||||||
|
const count = playlist.videoCount;
|
||||||
|
const sources = [];
|
||||||
|
|
||||||
|
for(const video of playlist.videos) {
|
||||||
|
const videoId = video.id ? video.id : "";
|
||||||
|
sources.push(getVideo(videoId, guildId, added_by));
|
||||||
|
}
|
||||||
|
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await getVideo(query, guildId, added_by);
|
||||||
|
}*/
|
||||||
|
|
||||||
|
async function getYoutubeSource(query: string, guildId: bigint, added_by?: string) {
|
||||||
try {
|
try {
|
||||||
query = parseYoutubeId(query);
|
const result = await getVideoInfo(query);
|
||||||
const results = await YouTube.search(query, { limit: 1, type: "video" });
|
if(result.videoDetails.videoId) {
|
||||||
|
const id = result.videoDetails.videoId;
|
||||||
|
const title = result.videoDetails.title;
|
||||||
|
|
||||||
|
return createAudioSource(title, async () => {
|
||||||
|
const stream = await retry(
|
||||||
|
async () =>
|
||||||
|
await ytDownload(id, {
|
||||||
|
mimeType: `audio/webm; codecs="opus"`,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
if (stream === undefined) {
|
||||||
|
errorMessageCallback(guildId, `There was an error trying to play **${title}**:\n
|
||||||
|
The stream couldn't be found`);
|
||||||
|
console.log(`Failed to play ${title}\n Returning empty stream`);
|
||||||
|
return empty();
|
||||||
|
}
|
||||||
|
return bufferIter(demux(stream));
|
||||||
|
}, guildId, added_by);
|
||||||
|
}
|
||||||
|
//const result = await ytDownload(query, { mimeType: `audio/webm; codecs="opus"`, });
|
||||||
|
//console.log(result);
|
||||||
|
|
||||||
|
/*const results = await YouTube.search(query, { limit: 1, type: "video" });
|
||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
const { id, title } = results[0];
|
const { id, title } = results[0];
|
||||||
return createAudioSource(title!, async () => {
|
return createAudioSource(title!, async () => {
|
||||||
|
@ -28,13 +69,13 @@ export async function getYoutubeSource(query: string, guildId: bigint, added_by?
|
||||||
);
|
);
|
||||||
if (stream === undefined) {
|
if (stream === undefined) {
|
||||||
errorMessageCallback(guildId, `There was an error trying to play **${title}**:\n
|
errorMessageCallback(guildId, `There was an error trying to play **${title}**:\n
|
||||||
something broke in getYoutubeSource`);
|
The stream couldn't be found`);
|
||||||
console.log(`Failed to play ${title}\n Returning empty stream`);
|
console.log(`Failed to play ${title}\n Returning empty stream`);
|
||||||
return empty();
|
return empty();
|
||||||
}
|
}
|
||||||
return bufferIter(demux(stream));
|
return bufferIter(demux(stream));
|
||||||
}, guildId, added_by);
|
}, guildId, added_by);
|
||||||
}
|
}*/
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
|
@ -7,17 +7,17 @@ import { sendAudioPacket } from "./udp/packet.ts";
|
||||||
export type BotData = {
|
export type BotData = {
|
||||||
bot: Bot;
|
bot: Bot;
|
||||||
guildData: Map<bigint, ConnectionData>;
|
guildData: Map<bigint, ConnectionData>;
|
||||||
udpSource: EventSource<[UdpArgs]>;
|
udpSource: EventSource<UdpArgs>;
|
||||||
bufferSize: number;
|
bufferSize: number;
|
||||||
loadSource: (query: string, guild_id: bigint, added_by?: string) => AudioSource[] | Promise<AudioSource[]>;
|
loadSource: (query: string, guild_id: bigint, added_by?: string) => AudioSource[] | Promise<AudioSource[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConnectionData = {
|
export type ConnectionData = {
|
||||||
player: QueuePlayer;
|
player: QueuePlayer;
|
||||||
audio: EventSource<[Uint8Array]>;
|
audio: EventSource<Uint8Array>;
|
||||||
guildId: bigint;
|
guildId: bigint;
|
||||||
udpSocket: Deno.DatagramConn;
|
udpSocket: Deno.DatagramConn;
|
||||||
udpStream: () => AsyncIterableIterator<Uint8Array>;
|
udpRaw: EventSource<Uint8Array>;
|
||||||
ssrcToUser: Map<number, bigint>;
|
ssrcToUser: Map<number, bigint>;
|
||||||
usersToSsrc: Map<bigint, number>;
|
usersToSsrc: Map<bigint, number>;
|
||||||
context: {
|
context: {
|
||||||
|
@ -56,7 +56,7 @@ function randomNBit(n: number) {
|
||||||
|
|
||||||
export function createBotData(
|
export function createBotData(
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
udpSource: EventSource<[UdpArgs]>,
|
udpSource: EventSource<UdpArgs>,
|
||||||
loadSource: LoadSource,
|
loadSource: LoadSource,
|
||||||
bufferSize = 10
|
bufferSize = 10
|
||||||
) {
|
) {
|
||||||
|
@ -97,12 +97,12 @@ export function getConnectionData(botId: bigint, guildId: bigint) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
udpSocket = udpSocket as Deno.DatagramConn;
|
udpSocket = udpSocket as Deno.DatagramConn;
|
||||||
const udpReceive = new EventSource<[Uint8Array]>();
|
const udpRaw = new EventSource<Uint8Array>();
|
||||||
data = {
|
data = {
|
||||||
player: undefined as unknown as QueuePlayer,
|
player: undefined as unknown as QueuePlayer,
|
||||||
guildId,
|
guildId,
|
||||||
udpSocket,
|
udpSocket,
|
||||||
udpStream: () => udpReceive.iter().map(([packet]) => packet),
|
udpRaw,
|
||||||
context: {
|
context: {
|
||||||
ssrc: 1,
|
ssrc: 1,
|
||||||
ready: false,
|
ready: false,
|
||||||
|
@ -113,7 +113,7 @@ export function getConnectionData(botId: bigint, guildId: bigint) {
|
||||||
reconnect: 0,
|
reconnect: 0,
|
||||||
},
|
},
|
||||||
connectInfo: {},
|
connectInfo: {},
|
||||||
audio: new EventSource<[Uint8Array]>(),
|
audio: new EventSource<Uint8Array>(),
|
||||||
ssrcToUser: new Map<number, bigint>(),
|
ssrcToUser: new Map<number, bigint>(),
|
||||||
usersToSsrc: new Map<bigint, number>(),
|
usersToSsrc: new Map<bigint, number>(),
|
||||||
stopHeart: () => {},
|
stopHeart: () => {},
|
||||||
|
@ -125,7 +125,7 @@ export function getConnectionData(botId: bigint, guildId: bigint) {
|
||||||
guildId,
|
guildId,
|
||||||
udpSocket,
|
udpSocket,
|
||||||
botData.udpSource,
|
botData.udpSource,
|
||||||
udpReceive
|
udpRaw
|
||||||
);
|
);
|
||||||
connectAudioIterable(data);
|
connectAudioIterable(data);
|
||||||
}
|
}
|
||||||
|
@ -133,7 +133,7 @@ export function getConnectionData(botId: bigint, guildId: bigint) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectAudioIterable(conn: ConnectionData) {
|
async function connectAudioIterable(conn: ConnectionData) {
|
||||||
for await (const [chunk] of conn.audio.iter()) {
|
for await (const chunk of conn.audio.iter()) {
|
||||||
await sendAudioPacket(conn, chunk);
|
await sendAudioPacket(conn, chunk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -155,8 +155,8 @@ async function connectSocketToSource(
|
||||||
bot: Bot,
|
bot: Bot,
|
||||||
guildId: bigint,
|
guildId: bigint,
|
||||||
socket: Deno.DatagramConn,
|
socket: Deno.DatagramConn,
|
||||||
source: EventSource<[UdpArgs]>,
|
source: EventSource<UdpArgs>,
|
||||||
localSource: EventSource<[Uint8Array]>
|
localSource: EventSource<Uint8Array>
|
||||||
) {
|
) {
|
||||||
for await (const [data, _address] of socket) {
|
for await (const [data, _address] of socket) {
|
||||||
source.trigger({ bot, guildId, data });
|
source.trigger({ bot, guildId, data });
|
||||||
|
|
|
@ -26,15 +26,11 @@ type ReceivedAudio = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createOnAudio(
|
export function createOnAudio(
|
||||||
source: EventSource<
|
source: EventSource<{
|
||||||
[
|
bot: Bot;
|
||||||
{
|
guildId: bigint;
|
||||||
bot: Bot;
|
data: Uint8Array;
|
||||||
guildId: bigint;
|
}>
|
||||||
data: Uint8Array;
|
|
||||||
}
|
|
||||||
]
|
|
||||||
>
|
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
guild: bigint | bigint[],
|
guild: bigint | bigint[],
|
||||||
|
@ -44,7 +40,6 @@ export function createOnAudio(
|
||||||
const users = asArray(user);
|
const users = asArray(user);
|
||||||
return source
|
return source
|
||||||
.iter()
|
.iter()
|
||||||
.map(([udp]) => udp)
|
|
||||||
.filter(({ data }) => {
|
.filter(({ data }) => {
|
||||||
return data[1] === 120;
|
return data[1] === 120;
|
||||||
})
|
})
|
||||||
|
|
|
@ -33,7 +33,7 @@ export function enableAudioPlugin<T extends Bot>(
|
||||||
}
|
}
|
||||||
|
|
||||||
function createAudioHelpers(bot: Bot, loadSource: LoadSource) {
|
function createAudioHelpers(bot: Bot, loadSource: LoadSource) {
|
||||||
const udpSource = new EventSource<[UdpArgs]>();
|
const udpSource = new EventSource<UdpArgs>();
|
||||||
createBotData(bot, udpSource, loadSource);
|
createBotData(bot, udpSource, loadSource);
|
||||||
const resetPlayer = (guildId: bigint) => {
|
const resetPlayer = (guildId: bigint) => {
|
||||||
const conn = getConnectionData(bot.id, guildId);
|
const conn = getConnectionData(bot.id, guildId);
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { BasicSource } from "../../utils/event-source.ts";
|
||||||
|
|
||||||
|
export type AllEventTypes = RawEventTypes | QueueEventTypes;
|
||||||
|
export type RawEventTypes = "next" | "done";
|
||||||
|
export type QueueEventTypes = "loop";
|
||||||
|
|
||||||
|
export type PlayerListener<T, J extends AllEventTypes> = (
|
||||||
|
data: EventData<T>[J]
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
type PlayerEvent<T, J extends AllEventTypes> = {
|
||||||
|
type: J;
|
||||||
|
data: EventData<T>[J];
|
||||||
|
};
|
||||||
|
type EventData<T> = {
|
||||||
|
next: T;
|
||||||
|
done: T;
|
||||||
|
loop: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PlayerEventSource<T, K extends AllEventTypes> {
|
||||||
|
#source = new BasicSource<PlayerEvent<T, K>>();
|
||||||
|
|
||||||
|
on<J extends K>(event: J, listener: PlayerListener<T, J>) {
|
||||||
|
return this.#source.listen((value) => {
|
||||||
|
if (value.type === event) {
|
||||||
|
listener((value as PlayerEvent<T, J>).data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger<J extends K>(event: J, data: EventData<T>[J]) {
|
||||||
|
this.#source.trigger({ type: event, data });
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,50 +1,64 @@
|
||||||
import { Queue } from "../../utils/mod.ts";
|
import { Queue } from "../../utils/mod.ts";
|
||||||
import { AudioSource, LoadSource } from "../audio-source/mod.ts";
|
import { AudioSource, LoadSource } from "../audio-source/mod.ts";
|
||||||
import { ConnectionData } from "../connection-data.ts";
|
import { ConnectionData } from "../connection-data.ts";
|
||||||
|
import { PlayerEventSource, AllEventTypes, PlayerListener } from "./events.ts";
|
||||||
import { RawPlayer } from "./raw-player.ts";
|
import { RawPlayer } from "./raw-player.ts";
|
||||||
import { Player } from "./types.ts";
|
import { Player } from "./types.ts";
|
||||||
|
|
||||||
import { nowPlayingCallback } from "../../../commands/np.ts";
|
import { nowPlayingCallback } from "../../../commands/np.ts";
|
||||||
|
|
||||||
export class QueuePlayer extends Queue<AudioSource> implements Player {
|
export class QueuePlayer
|
||||||
playing = false;
|
extends Queue<AudioSource>
|
||||||
|
implements Player<AudioSource>
|
||||||
|
{
|
||||||
|
playing = true;
|
||||||
looping = false;
|
looping = false;
|
||||||
playingSince?: number;
|
playingSince?: number;
|
||||||
nowPlaying?: AudioSource;
|
nowPlaying?: AudioSource;
|
||||||
#rawPlayer: RawPlayer;
|
#rawPlayer: RawPlayer;
|
||||||
#loadSource: LoadSource;
|
#loadSource: LoadSource;
|
||||||
|
#events = new PlayerEventSource<AudioSource, AllEventTypes>();
|
||||||
|
|
||||||
constructor(conn: ConnectionData, loadSource: LoadSource) {
|
constructor(conn: ConnectionData, loadSource: LoadSource) {
|
||||||
super();
|
super();
|
||||||
this.#loadSource = loadSource;
|
this.#loadSource = loadSource;
|
||||||
this.#rawPlayer = new RawPlayer(conn);
|
this.#rawPlayer = new RawPlayer(conn);
|
||||||
this.#startQueue();
|
this.playNext();
|
||||||
this.playing = true;
|
this.#rawPlayer.on("done", async () => {
|
||||||
super.waiting = true;
|
const current = this.current();
|
||||||
|
if (current) {
|
||||||
|
this.#events.trigger("done", current);
|
||||||
|
}
|
||||||
|
await this.playNext();
|
||||||
|
if (!this.playing) {
|
||||||
|
this.pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async #setSong(song: AudioSource) {
|
async playNext() {
|
||||||
|
let song;
|
||||||
|
const current = this.current();
|
||||||
|
if (this.looping && current !== undefined) {
|
||||||
|
song = current;
|
||||||
|
this.#events.trigger("loop", song);
|
||||||
|
} else {
|
||||||
|
song = await super.next();
|
||||||
|
this.#events.trigger("next", song);
|
||||||
|
}
|
||||||
this.playingSince = Date.now();
|
this.playingSince = Date.now();
|
||||||
this.nowPlaying = song;
|
this.nowPlaying = song;
|
||||||
this.#rawPlayer.setAudio(await song.data());
|
this.#rawPlayer.setAudio(await song.data());
|
||||||
await nowPlayingCallback(this.#rawPlayer.conn);
|
await nowPlayingCallback(this.#rawPlayer.conn);
|
||||||
await this.#rawPlayer.onDone();
|
|
||||||
if (this.looping) {
|
|
||||||
this.#setSong(song);
|
|
||||||
} else {
|
|
||||||
this.triggerNext();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async #startQueue() {
|
clear() {
|
||||||
for await (const [song] of this.stream()) {
|
return super.clear();
|
||||||
await this.#setSong(song);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
play() {
|
play() {
|
||||||
this.#rawPlayer.play();
|
|
||||||
this.playing = true;
|
this.playing = true;
|
||||||
|
this.#rawPlayer.play();
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -54,12 +68,12 @@ export class QueuePlayer extends Queue<AudioSource> implements Player {
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
this.playingSince = undefined;
|
this.skip();
|
||||||
this.#rawPlayer.clear();
|
|
||||||
this.pause();
|
this.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
skip() {
|
skip() {
|
||||||
|
this.looping = false;
|
||||||
this.#rawPlayer.clear();
|
this.#rawPlayer.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,8 +85,23 @@ export class QueuePlayer extends Queue<AudioSource> implements Player {
|
||||||
this.#rawPlayer.interrupt(undefined);
|
this.#rawPlayer.interrupt(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
interrupt(audio: AsyncIterableIterator<Uint8Array>) {
|
/**
|
||||||
this.#rawPlayer.interrupt(audio);
|
* Listen to events:
|
||||||
|
*
|
||||||
|
* `next`: New sound started playing
|
||||||
|
*
|
||||||
|
* `done`: Last sound is done playing
|
||||||
|
*
|
||||||
|
* `loop`: New loop iteration was started
|
||||||
|
* @param event Event to listen to
|
||||||
|
* @param listener Triggered on event
|
||||||
|
* @returns Function that disconnects the listener
|
||||||
|
*/
|
||||||
|
on<J extends AllEventTypes>(
|
||||||
|
event: J,
|
||||||
|
listener: PlayerListener<AudioSource, J>
|
||||||
|
) {
|
||||||
|
return this.#events.on(event, listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -1,33 +1,23 @@
|
||||||
import { EventSource } from "../../utils/mod.ts";
|
|
||||||
import { ConnectionData } from "../connection-data.ts";
|
import { ConnectionData } from "../connection-data.ts";
|
||||||
import { FRAME_DURATION } from "../sample-consts.ts";
|
import { FRAME_DURATION } from "../sample-consts.ts";
|
||||||
import { Player } from "./types.ts";
|
import { Player } from "./types.ts";
|
||||||
import { setDriftlessInterval, clearDriftless } from "npm:driftless";
|
import { setDriftlessInterval, clearDriftless } from "npm:driftless";
|
||||||
|
import { PlayerEventSource, RawEventTypes, PlayerListener } from "./events.ts";
|
||||||
|
|
||||||
export class RawPlayer implements Player {
|
import { errorMessageCallback } from "../../../utils.ts";
|
||||||
|
|
||||||
|
export class RawPlayer implements Player<AsyncIterableIterator<Uint8Array>> {
|
||||||
#audio?: AsyncIterableIterator<Uint8Array>;
|
#audio?: AsyncIterableIterator<Uint8Array>;
|
||||||
#interrupt?: AsyncIterableIterator<Uint8Array>;
|
#interrupt?: AsyncIterableIterator<Uint8Array>;
|
||||||
playing = false;
|
playing = false;
|
||||||
conn: ConnectionData;
|
conn: ConnectionData;
|
||||||
#doneSource = new EventSource<[]>();
|
#events = new PlayerEventSource<
|
||||||
#nextSource = new EventSource<[]>();
|
AsyncIterableIterator<Uint8Array>,
|
||||||
#onNext = () => this.#nextSource.iter().nextValue();
|
RawEventTypes
|
||||||
|
>();
|
||||||
|
|
||||||
constructor(conn: ConnectionData) {
|
constructor(conn: ConnectionData) {
|
||||||
this.conn = conn;
|
this.conn = conn;
|
||||||
this.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
setAudio(audio: AsyncIterableIterator<Uint8Array>) {
|
|
||||||
this.#audio = audio;
|
|
||||||
this.#nextSource.trigger();
|
|
||||||
}
|
|
||||||
|
|
||||||
interrupt(audio?: AsyncIterableIterator<Uint8Array>) {
|
|
||||||
this.#interrupt = audio;
|
|
||||||
if (this.#audio === undefined) {
|
|
||||||
this.#nextSource.trigger();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
play() {
|
play() {
|
||||||
|
@ -40,28 +30,16 @@ export class RawPlayer implements Player {
|
||||||
const inter = setDriftlessInterval(async () => {
|
const inter = setDriftlessInterval(async () => {
|
||||||
if (this.playing === false) {
|
if (this.playing === false) {
|
||||||
clearDriftless(inter);
|
clearDriftless(inter);
|
||||||
}
|
|
||||||
if (this.#interrupt) {
|
|
||||||
const { done, value } = await this.#interrupt.next();
|
|
||||||
if (done) {
|
|
||||||
this.#interrupt = undefined;
|
|
||||||
} else {
|
|
||||||
this.conn.audio.trigger(value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const nextAudioIter = await this.#audio?.next();
|
|
||||||
if (nextAudioIter === undefined || nextAudioIter.done) {
|
|
||||||
this.#audio = undefined;
|
|
||||||
this.#doneSource.trigger();
|
|
||||||
this.playing = false;
|
|
||||||
await this.#onNext();
|
|
||||||
this.play();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.conn.audio.trigger(nextAudioIter.value);
|
const frame = await this.#getFrame();
|
||||||
|
if (frame === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.conn.audio.trigger(frame);
|
||||||
}, FRAME_DURATION);
|
}, FRAME_DURATION);
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
|
errorMessageCallback(this.conn.guildId, `The player broke for some reason: ${err}`);
|
||||||
console.log("error while playing!!");
|
console.log("error while playing!!");
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
|
@ -72,16 +50,61 @@ export class RawPlayer implements Player {
|
||||||
}
|
}
|
||||||
|
|
||||||
stop() {
|
stop() {
|
||||||
this.pause();
|
|
||||||
this.clear();
|
this.clear();
|
||||||
|
this.pause();
|
||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.#audio = undefined;
|
if (this.#audio) {
|
||||||
|
this.#events.trigger("done", this.#audio);
|
||||||
|
}
|
||||||
this.#interrupt = undefined;
|
this.#interrupt = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async onDone() {
|
setAudio(audio: AsyncIterableIterator<Uint8Array>) {
|
||||||
await this.#doneSource.iter().nextValue();
|
this.#audio = audio;
|
||||||
|
this.#events.trigger("next", audio);
|
||||||
|
this.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
interrupt(audio?: AsyncIterableIterator<Uint8Array>) {
|
||||||
|
this.#interrupt = audio;
|
||||||
|
if (!this.playing) {
|
||||||
|
this.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on<J extends RawEventTypes>(
|
||||||
|
event: J,
|
||||||
|
listener: PlayerListener<AsyncIterableIterator<Uint8Array>, J>
|
||||||
|
) {
|
||||||
|
return this.#events.on(event, listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
async #getFrame() {
|
||||||
|
const interrupt = await this.#getNextFrame(this.#interrupt);
|
||||||
|
if (interrupt !== undefined) {
|
||||||
|
return interrupt;
|
||||||
|
}
|
||||||
|
const audio = await this.#getNextFrame(this.#audio);
|
||||||
|
if (audio === undefined) {
|
||||||
|
this.#handleAudioStopped();
|
||||||
|
}
|
||||||
|
return audio;
|
||||||
|
}
|
||||||
|
|
||||||
|
async #getNextFrame(source: AsyncIterableIterator<Uint8Array> | undefined) {
|
||||||
|
const nextResult = await source?.next();
|
||||||
|
return nextResult !== undefined && !nextResult?.done
|
||||||
|
? nextResult.value
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
#handleAudioStopped() {
|
||||||
|
if (this.#audio !== undefined) {
|
||||||
|
this.#events.trigger("done", this.#audio);
|
||||||
|
}
|
||||||
|
this.#audio = undefined;
|
||||||
|
this.playing = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,13 @@
|
||||||
export type Player = {
|
import { PlayerListener, RawEventTypes } from "./events.ts";
|
||||||
|
|
||||||
|
export type Player<T> = {
|
||||||
playing: boolean;
|
playing: boolean;
|
||||||
play(): void;
|
play(): void;
|
||||||
pause(): void;
|
pause(): void;
|
||||||
stop(): void;
|
stop(): void;
|
||||||
clear(): void;
|
clear(): void;
|
||||||
interrupt(audio: AsyncIterableIterator<Uint8Array>): void;
|
on<J extends RawEventTypes>(
|
||||||
|
event: J,
|
||||||
|
listener: PlayerListener<T, J>
|
||||||
|
): () => void;
|
||||||
};
|
};
|
||||||
|
|
|
@ -12,7 +12,7 @@ export async function discoverIP(
|
||||||
port,
|
port,
|
||||||
transport: "udp",
|
transport: "udp",
|
||||||
});
|
});
|
||||||
const { value } = await conn.udpStream().next();
|
const value = await conn.udpRaw.next();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const localIp = decoder.decode(value.slice(8, value.indexOf(0, 8)));
|
const localIp = decoder.decode(value.slice(8, value.indexOf(0, 8)));
|
||||||
const localPort = new DataView(value.buffer).getUint16(72, false);
|
const localPort = new DataView(value.buffer).getUint16(72, false);
|
||||||
|
|
|
@ -1,28 +1,17 @@
|
||||||
import { IterSource, fromCallback } from "./iterator/mod.ts";
|
import { IterSource, fromCallback } from "./iterator/mod.ts";
|
||||||
import { Arr } from "./types.ts";
|
|
||||||
|
|
||||||
type Listener<T extends Arr> = (...arg: T) => void;
|
type Listener<T> = (arg: T) => void;
|
||||||
|
|
||||||
export class EventSource<T extends Arr> {
|
export class BasicSource<T> {
|
||||||
listeners: Listener<T>[] = [];
|
listeners: Listener<T>[] = [];
|
||||||
iter: IterSource<T>["iterator"];
|
|
||||||
disconnect: IterSource<T>["disconnect"];
|
|
||||||
|
|
||||||
constructor() {
|
trigger(value: T) {
|
||||||
const { iterator, disconnect } = fromCallback<T>((listener) =>
|
|
||||||
this.addListener(listener)
|
|
||||||
);
|
|
||||||
this.iter = iterator;
|
|
||||||
this.disconnect = disconnect;
|
|
||||||
}
|
|
||||||
|
|
||||||
trigger(...arg: T) {
|
|
||||||
for (const listener of this.listeners) {
|
for (const listener of this.listeners) {
|
||||||
listener(...arg);
|
listener(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
addListener(listener: Listener<T>) {
|
listen(listener: Listener<T>) {
|
||||||
this.listeners.push(listener);
|
this.listeners.push(listener);
|
||||||
return () => {
|
return () => {
|
||||||
this.removeListener(listener);
|
this.removeListener(listener);
|
||||||
|
@ -33,4 +22,31 @@ export class EventSource<T extends Arr> {
|
||||||
const index = this.listeners.indexOf(listener);
|
const index = this.listeners.indexOf(listener);
|
||||||
this.listeners.splice(index, 1);
|
this.listeners.splice(index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
listenOnce(listener: Listener<T>) {
|
||||||
|
const disconnect = this.listen((value) => {
|
||||||
|
disconnect();
|
||||||
|
listener(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
next() {
|
||||||
|
return new Promise<T>((resolve) => {
|
||||||
|
this.listenOnce(resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class EventSource<T> extends BasicSource<T> {
|
||||||
|
iter: IterSource<T>["iterator"];
|
||||||
|
disconnect: IterSource<T>["disconnect"];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
const { iterator, disconnect } = fromCallback<T>((listener) =>
|
||||||
|
this.listen(listener)
|
||||||
|
);
|
||||||
|
this.iter = iterator;
|
||||||
|
this.disconnect = disconnect;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,22 +1,21 @@
|
||||||
import { Arr } from "../types.ts";
|
|
||||||
import { pushIter, addIterUtils } from "./util/mod.ts";
|
import { pushIter, addIterUtils } from "./util/mod.ts";
|
||||||
|
|
||||||
type Listener<T> = { push: (arg: T) => void; done: () => void };
|
type Listener<T> = { push: (arg: T) => void; done: () => void };
|
||||||
export type IterSource<T extends Arr> = ReturnType<typeof fromCallback<T>>;
|
export type IterSource<T> = ReturnType<typeof fromCallback<T>>;
|
||||||
|
|
||||||
export function fromCallback<T extends Arr>(
|
export function fromCallback<T>(
|
||||||
source: (listener: (...values: T) => void) => void,
|
source: (listener: (value: T) => void) => void,
|
||||||
disconnect?: () => void
|
disconnect?: () => void
|
||||||
) {
|
) {
|
||||||
let isDone = false;
|
let isDone = false;
|
||||||
let listeners: Listener<T>[] = [];
|
let listeners: Listener<T>[] = [];
|
||||||
|
|
||||||
function trigger(...values: T) {
|
function trigger(value: T) {
|
||||||
if (isDone) {
|
if (isDone) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
for (const listener of listeners) {
|
for (const listener of listeners) {
|
||||||
listener.push(values);
|
listener.push(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
source(trigger);
|
source(trigger);
|
||||||
|
@ -53,4 +52,4 @@ export function fromCallback<T extends Arr>(
|
||||||
},
|
},
|
||||||
disconnect: done,
|
disconnect: done,
|
||||||
};
|
};
|
||||||
}
|
}
|
|
@ -1,15 +1,15 @@
|
||||||
import { assertEquals } from "https://deno.land/std@0.104.0/testing/asserts.ts";
|
import { assertEquals } from "https://deno.land/std@0.104.0/testing/asserts.ts";
|
||||||
import { arrayMove, arrayShuffle } from "./array.ts";
|
import { arrayMove, arrayShuffle } from "./array.ts";
|
||||||
import { EventSource } from "./event-source.ts";
|
|
||||||
|
|
||||||
export class Queue<T> {
|
export class Queue<T> {
|
||||||
#current: T | undefined;
|
#current: T | undefined;
|
||||||
#queue: T[] = [];
|
#queue: T[] = [];
|
||||||
#source = new EventSource<[T]>();
|
#waiting: ((value: T) => void)[] = [];
|
||||||
waiting = false;
|
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
|
const cleared = this.#queue;
|
||||||
this.#queue = [];
|
this.#queue = [];
|
||||||
|
return cleared;
|
||||||
}
|
}
|
||||||
|
|
||||||
current() {
|
current() {
|
||||||
|
@ -22,10 +22,15 @@ export class Queue<T> {
|
||||||
|
|
||||||
push(...values: T[]) {
|
push(...values: T[]) {
|
||||||
this.#queue.push(...values);
|
this.#queue.push(...values);
|
||||||
if (this.waiting) {
|
for (const waiting of this.#waiting) {
|
||||||
this.triggerNext();
|
const value = this.#queue.shift();
|
||||||
this.waiting = false;
|
this.#current = value;
|
||||||
|
if (value == undefined) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
waiting(value);
|
||||||
}
|
}
|
||||||
|
this.#waiting = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
unshift(...values: T[]) {
|
unshift(...values: T[]) {
|
||||||
|
@ -52,18 +57,15 @@ export class Queue<T> {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
triggerNext() {
|
async next() {
|
||||||
const value = this.#queue.shift();
|
let value = this.#queue.shift();
|
||||||
this.#current = value;
|
this.#current = value;
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
this.waiting = true;
|
value = await new Promise<T>((resolve) => {
|
||||||
} else {
|
this.#waiting.push(resolve);
|
||||||
this.#source.trigger(value);
|
});
|
||||||
}
|
}
|
||||||
}
|
return value;
|
||||||
|
|
||||||
stream() {
|
|
||||||
return this.#source.iter();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -71,18 +73,15 @@ Deno.test({
|
||||||
name: "Test",
|
name: "Test",
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const queue = new Queue<string>();
|
const queue = new Queue<string>();
|
||||||
|
const promise0 = queue.next();
|
||||||
queue.push("Hello");
|
queue.push("Hello");
|
||||||
queue.push("World!");
|
queue.push("World!");
|
||||||
const messages = queue.stream();
|
assertEquals("Hello", await promise0);
|
||||||
queue.triggerNext();
|
assertEquals("World!", await queue.next());
|
||||||
queue.triggerNext();
|
const promise1 = queue.next();
|
||||||
queue.triggerNext();
|
const promise2 = queue.next();
|
||||||
queue.triggerNext();
|
queue.push("Multiple", "Words!");
|
||||||
queue.triggerNext();
|
assertEquals("Multiple", await promise1);
|
||||||
assertEquals("Hello", await messages.nextValue());
|
assertEquals("Words!", await promise2);
|
||||||
assertEquals("World!", await messages.nextValue());
|
|
||||||
assertEquals(undefined, await messages.nextValue());
|
|
||||||
assertEquals(undefined, await messages.nextValue());
|
|
||||||
assertEquals(undefined, await messages.nextValue());
|
|
||||||
},
|
},
|
||||||
});
|
});
|
|
@ -1 +0,0 @@
|
||||||
export type Arr = readonly unknown[];
|
|
11
main.ts
11
main.ts
|
@ -7,20 +7,18 @@ import {
|
||||||
upsertGlobalApplicationCommands
|
upsertGlobalApplicationCommands
|
||||||
} from "./deps.ts";
|
} from "./deps.ts";
|
||||||
import { parseCommand } from "./commands.ts";
|
import { parseCommand } from "./commands.ts";
|
||||||
import { cyan, yellow } from "https://deno.land/std@0.161.0/fmt/colors.ts";
|
import { cyan } from "https://deno.land/std@0.161.0/fmt/colors.ts";
|
||||||
import { helpCommand } from "./commands/help.ts";
|
import { helpCommand } from "./commands/help.ts";
|
||||||
import { leaveCommand } from "./commands/leave.ts";
|
import { leaveCommand } from "./commands/leave.ts";
|
||||||
import { loopCommand } from "./commands/loop.ts";
|
import { loopCommand } from "./commands/loop.ts";
|
||||||
import { npCommand } from "./commands/np.ts";
|
import { npCommand } from "./commands/np.ts";
|
||||||
import { pauseCommand } from "./commands/pause.ts";
|
import { pauseCommand, stopCommand } from "./commands/pause.ts";
|
||||||
import { playCommand } from "./commands/play.ts";
|
import { playCommand } from "./commands/play.ts";
|
||||||
import { skipCommand } from "./commands/skip.ts";
|
import { skipCommand } from "./commands/skip.ts";
|
||||||
import { unloopCommand } from "./commands/unloop.ts";
|
import { unloopCommand } from "./commands/unloop.ts";
|
||||||
|
|
||||||
import { enableAudioPlugin } from "./discordeno-audio-plugin/mod.ts";
|
import { enableAudioPlugin } from "./discordeno-audio-plugin/mod.ts";
|
||||||
|
|
||||||
let sessionId = "";
|
|
||||||
|
|
||||||
const baseBot = createBot({
|
const baseBot = createBot({
|
||||||
token: configs.discord_token,
|
token: configs.discord_token,
|
||||||
intents: Intents.Guilds | Intents.GuildMessages | Intents.GuildVoiceStates,
|
intents: Intents.Guilds | Intents.GuildMessages | Intents.GuildVoiceStates,
|
||||||
|
@ -28,10 +26,9 @@ const baseBot = createBot({
|
||||||
|
|
||||||
export const bot = enableAudioPlugin(baseBot);
|
export const bot = enableAudioPlugin(baseBot);
|
||||||
|
|
||||||
bot.events.ready = async function (bot, payload) {
|
bot.events.ready = async function () {
|
||||||
//await registerCommands(bot);
|
//await registerCommands(bot);
|
||||||
console.log(`${cyan("permanent waves")} is ready to go`);
|
console.log(`${cyan("permanent waves")} is ready to go`);
|
||||||
sessionId = payload.sessionId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Another way to do events
|
// Another way to do events
|
||||||
|
@ -42,7 +39,7 @@ bot.events.interactionCreate = async function (bot, interaction) {
|
||||||
await startBot(bot);
|
await startBot(bot);
|
||||||
|
|
||||||
async function registerCommands(bot: Bot) {
|
async function registerCommands(bot: Bot) {
|
||||||
console.log(await upsertGlobalApplicationCommands(bot, [helpCommand, leaveCommand, loopCommand, npCommand, pauseCommand, playCommand, skipCommand, unloopCommand]));
|
console.log(await upsertGlobalApplicationCommands(bot, [helpCommand, leaveCommand, loopCommand, npCommand, pauseCommand, playCommand, skipCommand, stopCommand, unloopCommand]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
@ -1,9 +0,0 @@
|
||||||
import { assertEquals } from "https://deno.land/std@0.177.0/testing/asserts.ts";
|
|
||||||
|
|
||||||
import { parseYoutubeId } from "../utils.ts";
|
|
||||||
|
|
||||||
Deno.test("parseYoutubeId", () => {
|
|
||||||
const url = parseYoutubeId("https://www.youtube.com/watch?v=ylAF0WNvLx0");
|
|
||||||
|
|
||||||
assertEquals(url, "ylAF0WNvLx0");
|
|
||||||
});
|
|
8
utils.ts
8
utils.ts
|
@ -95,6 +95,14 @@ export async function errorMessageCallback(guildId: bigint, message: string) {
|
||||||
await sendMessage(bot, channel.id, errorMessage(message));
|
await sendMessage(bot, channel.id, errorMessage(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isPlaylist(query: string) {
|
||||||
|
if(query.includes("playlist")){
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseYoutubeId(url: string) {
|
export function parseYoutubeId(url: string) {
|
||||||
return url.substring(url.indexOf("?")+3);
|
return url.substring(url.indexOf("?")+3);
|
||||||
}
|
}
|
Loading…
Reference in New Issue