feat: 支持设置 token

This commit is contained in:
sealday 2024-04-07 01:53:46 +08:00
parent a3814b8adf
commit 25b7dd0f82
13 changed files with 429 additions and 297 deletions

188
app/danmu/controls.tsx Normal file
View File

@ -0,0 +1,188 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { query } from "@/lib/actions/lyrics";
const useLyricsRunner = (runner: (lyric: string) => void, interval: number) => {
const [lyrics, setLyrics] = useState<string[]>([]);
const [offset, setOffset] = useState<number>(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startTime = useRef<number>(Date.now());
const ref = useRef<{ lyrics: string[]; offset: number }>({ lyrics, offset });
const clearTimer = (): void => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
const doLyric = (): void => {
console.log("testing ...", ref.current.lyrics.length);
if (ref.current.lyrics.length > 0) {
const currentTime = Date.now() - startTime.current;
const found = ref.current.lyrics.find((lyric) => {
const m = Number(lyric.slice(1, 3));
const s = Number(lyric.slice(4, 6));
const ms10 = Number(lyric.slice(7, 9));
const ms = (m * 60 + s) * 1000 + ms10 * 10;
return (
currentTime > ms + ref.current.offset &&
ms + ref.current.offset > currentTime - interval
);
});
if (found) {
runner(found);
}
}
};
useEffect(() => {
ref.current.offset = offset;
ref.current.lyrics = lyrics;
}, [offset, lyrics]);
const start = (): void => {
clearTimer();
startTime.current = Date.now();
timerRef.current = setInterval(doLyric, interval);
};
const pause = (): void => {
clearTimer();
};
const resume = (): void => {
clearTimer();
timerRef.current = setInterval(doLyric, interval);
};
return {
offset,
setOffset,
setLyrics,
resume,
start,
pause,
};
};
interface LyricItem {
timestamp: string;
content: string;
}
export default function Controls(): JSX.Element {
const ref = useRef<HTMLDialogElement | null>(null);
const [history, setHistory] = useState<LyricItem[]>([]);
const { start, resume, pause, setLyrics, offset, setOffset } =
useLyricsRunner((line) => {
setHistory((items) => [
{
content: line.slice(0, 10),
timestamp: line.slice(10),
},
...items,
]);
console.log(line);
}, 200);
return (
<div className="flex justify-center p-4 border-t border-base-300">
<div className="columns-1">
<div className="w-full">
<div className="join">
<button
className="btn join-item"
type="button"
onClick={() => {
pause();
}}
>
</button>
<button
className="btn join-item"
type="button"
onClick={() => {
resume();
}}
>
</button>
<button
className="btn join-item"
type="button"
onClick={() => {
setOffset(offset + 1000);
}}
>
+1
</button>
<button
className="btn join-item"
type="button"
onClick={() => {
setOffset(offset - 1000);
}}
>
-1
</button>
<button
className="btn join-item"
onClick={() => {
query({
name: "红豆",
})
.then((result) => {
// eslint-disable-next-line no-console -- temp
console.log(result);
if (result) {
setLyrics(result.lyrics.split("\n"));
setHistory([]);
start();
}
})
.catch((err) => {
// eslint-disable-next-line no-console -- temp
console.error(err);
});
}}
type="button"
>
</button>
</div>
</div>
<div
className="w-full mt-1 pt-0 pb-1 rounded-xl bg-gray-100 border-t-gray-100"
role="alert"
>
<div className="text-center"> {offset / 1000} </div>
</div>
<div className="w-full max-h-40 overflow-auto">
<ul className="timeline timeline-vertical">
{history.map((item, i) => (
<li key={i}>
<div className="timeline-start">{item.timestamp}</div>
<div className="timeline-middle">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="w-5 h-5"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="timeline-end timeline-box">{item.content}</div>
<hr />
</li>
))}
</ul>
</div>
</div>
</div>
);
}

71
app/danmu/danmu.tsx Normal file
View File

@ -0,0 +1,71 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import useWebSocket, { ReadyState } from "react-use-websocket";
import MessageDispatcher from "./message";
import { Token } from "@/lib/types";
const MOMO_IM_URL = "wss://live-ws.immomo.com/ws/im";
export const Danmu: React.FC<{ token: Token }> = ({ token }) => {
const [messageHistory, setMessageHistory] = useState<MessageEvent<any>[]>([]);
const { sendMessage, lastMessage, readyState } = useWebSocket(MOMO_IM_URL, {
heartbeat: {
message: () => {
const pingMessage = {
msg_id: 2,
client_time: Date.now(),
type: "Ping",
data: {},
};
return JSON.stringify(pingMessage);
},
interval: 5000,
},
});
const momoId = token.uid;
const roomId = "1476810196216";
const momoToken = token.token;
const clientTime = Date.now();
const data = {
msg_id: 1,
client_time: clientTime,
type: "Sauth",
data: {
momoid: momoId,
roomid: roomId,
role: 6,
isVisitor: false,
token: momoToken,
},
};
useEffect(() => {
if (ReadyState.OPEN === readyState) {
sendMessage(JSON.stringify(data));
}
}, [readyState]);
useEffect(() => {
if (lastMessage !== null) {
if (lastMessage.data) {
const message = JSON.parse(lastMessage.data);
if (message.type === "pong") {
return;
}
console.log(message);
setMessageHistory((prev) => prev.concat(message));
}
}
}, [lastMessage]);
return (
<div className="h-[50vh] overflow-auto">
{messageHistory.map((message, idx) => (
<MessageDispatcher key={idx} message={message} />
))}
</div>
);
};

21
app/danmu/message.tsx Normal file
View File

@ -0,0 +1,21 @@
export default function MessageDispatcher({ message }: { message: any }) {
const messageType = message?.groups?.[0]?.units?.[0]?.type;
let nick = "wait";
let content = "wait";
if (message?.type === 'sauth_ret') {
return <div>{message?.data?.em}</div>
}
if (messageType === "Message") {
nick = message?.groups?.[0]?.nick;
content = message?.groups?.[0]?.units?.[0]?.["Msg.Message.data"]?.text;
}
return (
<div className="chat chat-start">
<div className="chat-bubble">
{nick}: {content}
</div>
</div>
);
}

View File

@ -1,191 +1,30 @@
/* eslint-disable no-console -- debug */ import { getToken } from "@/lib/actions/token";
"use client"; import Controls from "./controls";
import { useRef, useState, useEffect } from "react"; import { Danmu } from "./danmu";
import { query } from "@/lib/actions/lyrics";
const useLyricsRunner = (runner: (lyric: string) => void, interval: number) => {
const [lyrics, setLyrics] = useState<string[]>([]);
const [offset, setOffset] = useState<number>(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startTime = useRef<number>(Date.now());
const ref = useRef<{ lyrics: string[]; offset: number }>({ lyrics, offset });
const clearTimer = (): void => {
if (timerRef.current) {
clearInterval(timerRef.current);
}
};
const doLyric = (): void => {
console.log("testing ...", ref.current.lyrics.length);
if (ref.current.lyrics.length > 0) {
const currentTime = Date.now() - startTime.current;
const found = ref.current.lyrics.find((lyric) => {
const m = Number(lyric.slice(1, 3));
const s = Number(lyric.slice(4, 6));
const ms10 = Number(lyric.slice(7, 9));
const ms = (m * 60 + s) * 1000 + ms10 * 10;
return (
currentTime > ms + ref.current.offset &&
ms + ref.current.offset > currentTime - interval
);
});
if (found) {
runner(found);
}
}
};
useEffect(() => {
ref.current.offset = offset;
ref.current.lyrics = lyrics;
}, [offset, lyrics]);
const start = (): void => {
clearTimer();
startTime.current = Date.now();
timerRef.current = setInterval(doLyric, interval);
};
const pause = (): void => {
clearTimer();
};
const resume = (): void => {
clearTimer();
timerRef.current = setInterval(doLyric, interval);
};
return {
offset,
setOffset,
setLyrics,
resume,
start,
pause,
};
};
interface LyricItem {
timestamp: string;
content: string;
}
export default function Page(): JSX.Element {
const ref = useRef<HTMLDialogElement | null>(null);
const [history, setHistory] = useState<LyricItem[]>([]);
const { start, resume, pause, setLyrics, offset, setOffset } =
useLyricsRunner((line) => {
setHistory((items) => [
{
content: line.slice(0, 10),
timestamp: line.slice(10),
},
...items,
]);
console.log(line);
}, 200);
export default async function Page() {
const token = await getToken();
return ( return (
<div className="flex flex-col md:flex-row">
<section className="basis-1/3 card">
<div className="card-body max-h-[50vh] overflow-auto"></div>
</section>
<section className="basis-1/3 card">
<div className="card-body max-h-[50vh] overflow-auto"></div>
</section>
<section className="basis-1/3 card">
<div className="card-body">
<div className="mockup-browser border"> <div className="mockup-browser border">
<div className="mockup-browser-toolbar"> <div className="mockup-browser-toolbar">
<div className="input"></div> <div className="input"></div>
</div> </div>
<div className="flex justify-center px-4 py-16 border-t border-base-300"> <div className="divide-y divide-gray-300">
<div className="columns-1"> <Controls />
<div className="w-full"> <Danmu token={token} />
<div className="join">
<div className="stat join-item"> {offset / 1000} </div>
<button
className="btn join-item"
type="button"
onClick={() => {
pause();
}}
>
</button>
<button
className="btn join-item"
type="button"
onClick={() => {
resume();
}}
>
</button>
<button
className="btn join-item"
type="button"
onClick={() => {
setOffset(offset + 1000);
}}
>
+1
</button>
<button
className="btn join-item"
type="button"
onClick={() => {
setOffset(offset - 1000);
}}
>
-1
</button>
<button
className="btn join-item"
onClick={() => {
query({
name: "红豆",
})
.then((result) => {
// eslint-disable-next-line no-console -- temp
console.log(result);
if (result) {
setLyrics(result.lyrics.split("\n"));
setHistory([]);
start();
}
})
.catch((err) => {
// eslint-disable-next-line no-console -- temp
console.error(err);
});
}}
type="button"
>
</button>
</div>
</div>
<div className="w-full mt-8">
<ul className="timeline timeline-vertical">
{history.map((item, i) => (
<li key={i}>
<div className="timeline-start">{item.timestamp}</div>
<div className="timeline-middle">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="w-5 h-5"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="timeline-end timeline-box">
{item.content}
</div>
<hr />
</li>
))}
</ul>
</div> </div>
</div> </div>
</div> </div>
</section>
</div> </div>
); );
} }

View File

@ -6,6 +6,8 @@ import Logo from "@/components/logo";
import BackButton from "@/components/back-button"; import BackButton from "@/components/back-button";
import Link from "next/link"; import Link from "next/link";
import { AdminButton } from "@/components/admin-button"; import { AdminButton } from "@/components/admin-button";
import TokenButton from "@/components/token-button";
import { getToken } from "@/lib/actions/token";
const inter = Inter({ subsets: ["latin"] }); const inter = Inter({ subsets: ["latin"] });
@ -14,11 +16,12 @@ export const metadata: Metadata = {
description: "搜索歌曲,选择直播间,智能发送弹幕", description: "搜索歌曲,选择直播间,智能发送弹幕",
}; };
export default function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const token = await getToken();
return ( return (
<html lang="zh_CN" data-theme="light"> <html lang="zh_CN" data-theme="light">
<body className={inter.className}> <body className={inter.className}>
@ -38,7 +41,7 @@ export default function RootLayout({
<Link href="/songs" className="mr-5 hover:text-gray-900"> <Link href="/songs" className="mr-5 hover:text-gray-900">
</Link> </Link>
<Link href="/live" className="mr-5 hover:text-gray-900"> <Link target="_blank" href="https://tachy.daoyoucloud.com/apps/danmu-sim/admin/9nzr816par6" className="mr-5 hover:text-gray-900">
</Link> </Link>
<Link href="/danmu" className="mr-5 hover:text-gray-900"> <Link href="/danmu" className="mr-5 hover:text-gray-900">
@ -49,8 +52,8 @@ export default function RootLayout({
</Link> </Link>
</nav> </nav>
<div className="join"> <div className="join">
<BackButton className="btn join-item" />
<AdminButton className="btn join-item" /> <AdminButton className="btn join-item" />
<TokenButton className="btn join-item" token={token} />
<SignoutButton className="btn join-item" /> <SignoutButton className="btn join-item" />
</div> </div>
</div> </div>

View File

@ -1,100 +0,0 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import useWebSocket, { ReadyState } from "react-use-websocket";
const MOMO_IM_URL = "wss://live-ws.immomo.com/ws/im";
export const Danmu: React.FC = () => {
//Public API that will echo messages sent to it back to the client
const [socketUrl, setSocketUrl] = useState(MOMO_IM_URL);
const [messageHistory, setMessageHistory] = useState<MessageEvent<any>[]>([]);
// { "msg_id": 1, "client_time": 1712305590234, "type": "Sauth", "data": { "momoid": "404821828", "roomid": "14515460054", "role": 6, "isVisitor": false, "token": "97e721d0bd3bf2e63a9797f9daf50919", "ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36" } }
const { sendMessage, lastMessage, readyState } = useWebSocket(socketUrl, {
heartbeat: {
message: () => {
const pingMessage = {
msg_id: 2,
client_time: Date.now(),
type: "Ping",
data: {},
};
return JSON.stringify(pingMessage);
},
interval: 5000,
},
});
const momoId = "404821828";
const roomId = "14515460054";
const momoToken = "97e721d0bd3bf2e63a9797f9daf50919";
const clientTime = Date.now();
const data = {
msg_id: 1,
client_time: clientTime,
type: "Sauth",
data: {
momoid: momoId,
roomid: roomId,
role: 6,
isVisitor: false,
token: momoToken,
},
};
useEffect(() => {
if (ReadyState.OPEN === readyState) {
sendMessage(JSON.stringify(data));
}
}, [readyState]);
useEffect(() => {
if (lastMessage !== null) {
if (lastMessage.data) {
const message = JSON.parse(lastMessage.data);
if (message.type === "pong") {
return;
}
setMessageHistory((prev) => prev.concat(message));
}
}
}, [lastMessage]);
const connectionStatus = {
[ReadyState.CONNECTING]: "Connecting",
[ReadyState.OPEN]: "Open",
[ReadyState.CLOSING]: "Closing",
[ReadyState.CLOSED]: "Closed",
[ReadyState.UNINSTANTIATED]: "Uninstantiated",
}[readyState];
return (
<div>
{messageHistory.map((message, idx) => (
<MessageDispatcher key={idx} message={message} />
))}
<div className="chat chat-end">
<div className="chat-bubble">You underestimate my power!</div>
</div>
</div>
);
};
const MessageDispatcher: React.FC<{ message: any }> = ({ message }) => {
const messageType = message?.groups?.[0]?.units?.[0]?.type;
let nick = "wait";
let content = "wait";
if (messageType === "Message") {
nick = message?.groups?.[0]?.nick;
content = message?.groups?.[0]?.units?.[0]?.["Msg.Message.data"]?.text;
}
return (
<div className="chat chat-start">
<div className="chat-bubble">
{nick}: {content}
</div>
</div>
);
};

View File

@ -1,5 +0,0 @@
import { Danmu } from "./danmu";
export default function Page() {
return <Danmu />;
}

View File

@ -2,7 +2,7 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import musicSvg from "@/app/music.svg"; import musicSvg from "@/app/music.svg";
export default function Home() { export default async function Home() {
return ( return (
<section className="text-gray-600 body-font"> <section className="text-gray-600 body-font">
<div className="container mx-auto flex px-5 py-24 md:flex-row flex-col items-center"> <div className="container mx-auto flex px-5 py-24 md:flex-row flex-col items-center">
@ -18,12 +18,12 @@ export default function Home() {
</p> </p>
<div className="flex justify-center"> <div className="flex justify-center">
<Link <Link
href="/music" href="/danmu"
className="inline-flex text-white bg-indigo-500 border-0 py-2 px-6 focus:outline-none hover:bg-indigo-600 rounded text-lg" className="inline-flex text-white bg-indigo-500 border-0 py-2 px-6 focus:outline-none hover:bg-indigo-600 rounded text-lg"
> >
</Link> </Link>
<Link href="/danmu" className="ml-4 inline-flex text-gray-700 bg-gray-100 border-0 py-2 px-6 focus:outline-none hover:bg-gray-200 rounded text-lg"> <Link href="/songs" className="ml-4 inline-flex text-gray-700 bg-gray-100 border-0 py-2 px-6 focus:outline-none hover:bg-gray-200 rounded text-lg">
</Link> </Link>
</div> </div>

View File

@ -23,7 +23,7 @@ export default function Page() {
</div> </div>
<form <form
action={dispatch} action={dispatch}
className="lg:w-2/6 md:w-1/2 bg-gray-100 rounded-lg p-8 flex flex-col md:ml-auto w-full mt-10 md:mt-0" className="lg:w-2/6 md:w-1/2 bg-gray-100 rounded-lg p-8 flex flex-col md:ml-auto w-full mt-10 md:mt-0 shadow-xl"
> >
<h2 className="text-gray-900 text-lg font-medium title-font mb-5"> <h2 className="text-gray-900 text-lg font-medium title-font mb-5">

View File

@ -22,7 +22,7 @@ export default function Page() {
</div> </div>
<form <form
action={dispatch} action={dispatch}
className="lg:w-2/6 md:w-1/2 bg-gray-100 rounded-lg p-8 flex flex-col md:ml-auto w-full mt-10 md:mt-0" className="lg:w-2/6 md:w-1/2 bg-gray-100 rounded-lg p-8 flex flex-col md:ml-auto w-full mt-10 md:mt-0 shadow-xl"
> >
<h2 className="text-gray-900 text-lg font-medium title-font mb-5"> <h2 className="text-gray-900 text-lg font-medium title-font mb-5">

View File

@ -0,0 +1,53 @@
"use client";
import { addToken } from "@/lib/actions/token";
import { Token } from "@/lib/types";
import { useEffect, useRef } from "react";
import { useFormState } from "react-dom";
export default function TokenButton({
className,
token,
}: {
className?: string | undefined;
token: Token;
}) {
const ref = useRef<HTMLDialogElement>(null);
const [returnType, dispatch] = useFormState(addToken, token);
// TODO 有没有更合适的方式?
if (returnType) {
ref.current?.close();
}
return (
<>
<button className={className} onClick={() => ref.current?.showModal()}>
Token
</button>
<dialog className="modal" ref={ref}>
<div className="modal-box">
<h3 className="font-bold text-lg">Token </h3>
<form className="join join-vertical w-full mt-4" id="token" action={dispatch}>
<label className="input input-bordered flex items-center gap-2 join-item">
ID
<input type="text" className="grow" name="uid" defaultValue={token.uid} />
</label>
<label className="input input-bordered flex items-center gap-2 join-item">
token
<input type="text" className="grow" name="token" defaultValue={token.token} />
</label>
</form>
<div className="modal-action">
<form method="dialog">
<button className="btn"></button>
</form>
<button className="btn" form="token">
</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button></button>
</form>
</dialog>
</>
);
}

57
lib/actions/token.ts Normal file
View File

@ -0,0 +1,57 @@
"use server";
import { revalidateTag } from "next/cache";
import { cookies } from "next/headers";
import { Token } from "../types";
export async function addToken(_: Token, item: FormData): Promise<Token> {
const token = cookies().get("token");
const result = await fetch(
"https://tachy.daoyoucloud.com/api/tokens:create",
{
method: "POST",
headers: {
"X-App": "danmu-sim",
"X-Authenticator": "basic",
authorization: "Bearer " + token?.value,
"content-type": "application/json",
},
body: JSON.stringify({
uid: item.get("uid") as string,
token: item.get("token") as string,
}),
}
);
const latest = await result.json();
revalidateTag("tokens");
return latest.data
}
export async function getToken(): Promise<Token> {
const token = cookies().get("token");
const params = new URLSearchParams();
params.append("pageSize", "20");
params.append("limit", "1");
params.append("sort", "-createdAt");
params.append(
"filter",
JSON.stringify({ $and: [{ createdBy: { id: { $eq: "{{$user.id}}" } } }] })
);
const liveTokens = await fetch(
"https://tachy.daoyoucloud.com/api/tokens:list?" + params.toString(),
{
method: "GET",
headers: {
"X-App": "danmu-sim",
"X-Authenticator": "basic",
authorization: "Bearer " + token?.value,
"content-type": "application/json",
},
next: {
tags: ["tokens"],
},
}
);
const result = await liveTokens.json();
return result?.data?.[0] ?? { uid: "", token: "" };
}

View File

@ -5,3 +5,8 @@ export enum LyricSource {
NETEASE = "NetEase", NETEASE = "NetEase",
QQMUSIC = "QQMusic", QQMUSIC = "QQMusic",
} }
export interface Token {
uid: string;
token: string;
}