's'
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, MonitorPlay, Plus, Radio, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { Ban, Loader2, MonitorPlay, Plus, Radio, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
buildYoutubeIniFromFields,
|
||||
extractDouyinLiveUrls,
|
||||
parseYoutubeIniFields,
|
||||
parseYoutubeStreamKeySuffix,
|
||||
type YoutubeIniFields,
|
||||
@@ -28,9 +29,108 @@ function isYoutubeProcess(e: ProcessEntry) {
|
||||
|
||||
const MODE_KEY = "live-hub-youtube-mode-v1";
|
||||
|
||||
type LiveProcRow = { pm2: string; process_status?: string };
|
||||
|
||||
function pm2StatusOnline(st?: string) {
|
||||
return /^(online|running)$/i.test((st || "").trim());
|
||||
}
|
||||
|
||||
function parseProcBusy(busy: string | null): { action: string; pm2: string } | null {
|
||||
if (!busy?.startsWith("proc:")) return null;
|
||||
const rest = busy.slice(5);
|
||||
const idx = rest.indexOf(":");
|
||||
if (idx <= 0) return null;
|
||||
const action = rest.slice(0, idx);
|
||||
const pm2 = rest.slice(idx + 1);
|
||||
if (!pm2 || !["start", "stop", "restart"].includes(action)) return null;
|
||||
return { action, pm2 };
|
||||
}
|
||||
|
||||
function LiveStreamActionBtn({
|
||||
kind,
|
||||
targetPm2,
|
||||
busy,
|
||||
online,
|
||||
label,
|
||||
className,
|
||||
baseLock,
|
||||
onRun,
|
||||
notify,
|
||||
t,
|
||||
compact,
|
||||
}: {
|
||||
kind: "start" | "stop" | "restart";
|
||||
targetPm2: string;
|
||||
busy: string | null;
|
||||
online: boolean;
|
||||
label: string;
|
||||
className: string;
|
||||
baseLock: boolean;
|
||||
onRun: () => void;
|
||||
notify: (s: string) => void;
|
||||
t: TFn;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const pb = parseProcBusy(busy);
|
||||
const forThisProc = pb && pb.pm2 === targetPm2;
|
||||
const loading = !!(forThisProc && pb.action === kind);
|
||||
const siblingBusy = !!(forThisProc && pb.action !== kind);
|
||||
const blockStart = kind === "start" && online;
|
||||
const blockStop = kind === "stop" && !online;
|
||||
const blockRestart = kind === "restart" && !online;
|
||||
const blocked = blockStart || blockStop || blockRestart;
|
||||
const hardLock = baseLock || loading || siblingBusy;
|
||||
|
||||
const onClick = () => {
|
||||
if (loading) return;
|
||||
if (siblingBusy) {
|
||||
notify(t("请等待当前操作完成", "Wait for the current action to finish"));
|
||||
return;
|
||||
}
|
||||
if (blockStart) {
|
||||
notify(t("已在直播中,无需重复开始", "Already live"));
|
||||
return;
|
||||
}
|
||||
if (blockStop) {
|
||||
notify(t("当前未在直播,无法停止", "Not live — cannot stop"));
|
||||
return;
|
||||
}
|
||||
if (blockRestart) {
|
||||
notify(t("进程未运行时请使用「开始直播」", "Use Start when the process is not running"));
|
||||
return;
|
||||
}
|
||||
if (baseLock) return;
|
||||
onRun();
|
||||
};
|
||||
|
||||
const sizeCls = compact
|
||||
? "min-h-[2rem] px-3 py-1.5 text-xs font-medium"
|
||||
: "min-h-[2.75rem] px-4 py-3 text-sm font-medium";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`relative overflow-hidden rounded-xl ring-1 transition ${sizeCls} ${className} ${
|
||||
blocked || hardLock ? "cursor-not-allowed" : ""
|
||||
} ${blocked && !loading ? "opacity-45" : ""} ${siblingBusy ? "opacity-35" : ""} ${loading ? "ring-violet-400/40" : ""}`}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="absolute inset-0 flex items-center justify-center bg-black/25 backdrop-blur-[2px]">
|
||||
<Loader2 className={`${compact ? "h-4 w-4" : "h-6 w-6"} animate-spin text-white`} />
|
||||
</span>
|
||||
) : null}
|
||||
<span className={`inline-flex items-center justify-center gap-1.5 ${loading ? "invisible" : ""}`}>
|
||||
{blocked ? <Ban className={`${compact ? "h-3 w-3" : "h-4 w-4"} shrink-0 opacity-90`} aria-hidden /> : null}
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
t: TFn;
|
||||
busy: boolean;
|
||||
busy: string | null;
|
||||
entries: ProcessEntry[];
|
||||
currentProc: string;
|
||||
setCurrentProc: (v: string) => void;
|
||||
@@ -39,6 +139,8 @@ type Props = {
|
||||
onRunProcess: (a: "start" | "stop" | "restart", processPm2?: string) => void;
|
||||
onSaveUrlConfig: (content?: string) => void | Promise<void>;
|
||||
fetchJson: <T,>(path: string, init?: RequestInit) => Promise<T>;
|
||||
liveProcesses: LiveProcRow[];
|
||||
notify: (msg: string) => void;
|
||||
};
|
||||
|
||||
export function LiveYoutubeUnmannedView({
|
||||
@@ -52,6 +154,8 @@ export function LiveYoutubeUnmannedView({
|
||||
onRunProcess,
|
||||
onSaveUrlConfig,
|
||||
fetchJson,
|
||||
liveProcesses,
|
||||
notify,
|
||||
}: Props) {
|
||||
const ytEntries = useMemo(() => entries.filter(isYoutubeProcess), [entries]);
|
||||
const processOptions = ytEntries.length ? ytEntries : entries;
|
||||
@@ -363,7 +467,14 @@ export function LiveYoutubeUnmannedView({
|
||||
updateActive({ streamKey: f.key });
|
||||
};
|
||||
|
||||
const lock = busy || ytIniLoading || urlReloading || urlOptimizing;
|
||||
const lock = !!busy || ytIniLoading || urlReloading || urlOptimizing;
|
||||
|
||||
const statusForPm2 = useCallback(
|
||||
(pm2: string) => liveProcesses.find((p) => p.pm2 === pm2)?.process_status,
|
||||
[liveProcesses],
|
||||
);
|
||||
|
||||
const singleOnline = pm2StatusOnline(statusForPm2(currentProc));
|
||||
|
||||
const urlToolbar = (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-4 text-xs text-zinc-400">
|
||||
@@ -461,23 +572,42 @@ export function LiveYoutubeUnmannedView({
|
||||
))}
|
||||
</select>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{(
|
||||
[
|
||||
["start", t("开始直播", "Start"), "bg-emerald-500/20 text-emerald-200 ring-emerald-500/30"],
|
||||
["stop", t("停止直播", "Stop"), "bg-rose-500/15 text-rose-200 ring-rose-500/25"],
|
||||
["restart", t("重新开始", "Restart"), "bg-amber-500/15 text-amber-200 ring-amber-500/25"],
|
||||
] as const
|
||||
).map(([a, label, cls]) => (
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => onRunProcess(a)}
|
||||
className={`rounded-xl px-4 py-3 text-sm font-medium ring-1 ${cls}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<LiveStreamActionBtn
|
||||
kind="start"
|
||||
targetPm2={currentProc}
|
||||
busy={busy}
|
||||
online={singleOnline}
|
||||
label={t("开始直播", "Start")}
|
||||
className="bg-emerald-500/20 text-emerald-200 ring-emerald-500/30"
|
||||
baseLock={ytIniLoading || urlReloading || urlOptimizing}
|
||||
onRun={() => onRunProcess("start")}
|
||||
notify={notify}
|
||||
t={t}
|
||||
/>
|
||||
<LiveStreamActionBtn
|
||||
kind="stop"
|
||||
targetPm2={currentProc}
|
||||
busy={busy}
|
||||
online={singleOnline}
|
||||
label={t("停止直播", "Stop")}
|
||||
className="bg-rose-500/15 text-rose-200 ring-rose-500/25"
|
||||
baseLock={ytIniLoading || urlReloading || urlOptimizing}
|
||||
onRun={() => onRunProcess("stop")}
|
||||
notify={notify}
|
||||
t={t}
|
||||
/>
|
||||
<LiveStreamActionBtn
|
||||
kind="restart"
|
||||
targetPm2={currentProc}
|
||||
busy={busy}
|
||||
online={singleOnline}
|
||||
label={t("重新开始", "Restart")}
|
||||
className="bg-amber-500/15 text-amber-200 ring-amber-500/25"
|
||||
baseLock={ytIniLoading || urlReloading || urlOptimizing}
|
||||
onRun={() => onRunProcess("restart")}
|
||||
notify={notify}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-3 text-center font-mono text-[11px] text-zinc-600">
|
||||
key {t("尾码", "suffix")}: {keySuffixUi || "—"}
|
||||
@@ -485,8 +615,69 @@ export function LiveYoutubeUnmannedView({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-4 rounded-xl border border-cyan-500/25 bg-gradient-to-br from-cyan-500/[0.1] via-zinc-950/50 to-violet-500/[0.06] p-4 ring-1 ring-white/[0.04]">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-cyan-200/90">
|
||||
{t("Pro 转播实况", "Pro relay status")}
|
||||
</h4>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
{t("对齐桌面端工作台:每路密钥、源站、进程状态一览;点行可切换编辑。", "Like the desktop workspace: per-lane key, source, PM2 status.")}
|
||||
</p>
|
||||
<div className="mt-3 max-h-52 space-y-2 overflow-y-auto pr-1">
|
||||
{channels.map((c) => {
|
||||
const pm = (c.pm2Name ?? "").trim() || defaultPm2NameForChannel(c.id);
|
||||
const st = statusForPm2(pm);
|
||||
const on = pm2StatusOnline(st);
|
||||
const dUrl = extractDouyinLiveUrls((c.urlLines ?? "").trim())[0];
|
||||
const suf = parseYoutubeStreamKeySuffix(`key = ${c.streamKey ?? ""}`);
|
||||
const sel = activeCh === c.id;
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className={`flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 text-left text-xs transition ${
|
||||
sel ? "border-violet-500/45 bg-violet-500/[0.1]" : "border-white/10 bg-black/30 hover:border-white/18"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={() => selectProChannel(c)}
|
||||
>
|
||||
<span className="font-medium text-zinc-100">{c.name}</span>
|
||||
<span className="ml-2 font-mono text-[10px] text-zinc-500">
|
||||
key …{suf || "—"}
|
||||
</span>
|
||||
</button>
|
||||
<span
|
||||
className={`shrink-0 rounded-md px-2 py-0.5 text-[10px] font-medium ring-1 ${
|
||||
on
|
||||
? "bg-emerald-500/15 text-emerald-200 ring-emerald-500/30"
|
||||
: "bg-zinc-600/20 text-zinc-400 ring-zinc-500/25"
|
||||
}`}
|
||||
>
|
||||
{on ? t("运行中", "Live") : st || t("未运行", "Idle")}
|
||||
</span>
|
||||
{dUrl ? (
|
||||
<a
|
||||
href={dUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="max-w-[200px] truncate text-cyan-300/90 underline-offset-2 hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
title={dUrl}
|
||||
>
|
||||
{dUrl.replace(/^https?:\/\//, "")}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-zinc-600">—</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-zinc-500">
|
||||
{t("点选一行即可编辑该线路;此处只列出已保存过密钥的线路。", "Pick a row to edit. Only lanes with a saved key are listed.")}
|
||||
{t("点选一行即可编辑该线路;下方列表为已保存密钥的线路控制。", "Pick a row above to edit; the list below is lanes with a saved key.")}
|
||||
</p>
|
||||
{proLanesWithKey.length === 0 ? (
|
||||
<p className="mt-4 rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-zinc-400">
|
||||
@@ -500,6 +691,7 @@ export function LiveYoutubeUnmannedView({
|
||||
const pm = (c.pm2Name ?? "").trim() || defaultPm2NameForChannel(c.id);
|
||||
const suffix = parseYoutubeStreamKeySuffix(`key = ${c.streamKey ?? ""}`);
|
||||
const sel = activeCh === c.id;
|
||||
const rowOnline = pm2StatusOnline(statusForPm2(pm));
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
@@ -521,31 +713,50 @@ export function LiveYoutubeUnmannedView({
|
||||
<code className="shrink-0 font-mono text-xs text-cyan-300/90">{suffix || "—"}</code>
|
||||
<span className="truncate text-sm text-zinc-200">{c.name}</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => onRunProcess("start", pm)}
|
||||
className="rounded-lg bg-emerald-500/20 px-3 py-1.5 text-xs font-medium text-emerald-100 ring-1 ring-emerald-500/30 disabled:opacity-30"
|
||||
>
|
||||
{t("开始", "Start")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => onRunProcess("stop", pm)}
|
||||
className="rounded-lg bg-rose-500/15 px-3 py-1.5 text-xs font-medium text-rose-100 ring-1 ring-rose-500/25 disabled:opacity-30"
|
||||
>
|
||||
{t("停止", "Stop")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={lock}
|
||||
onClick={() => onRunProcess("restart", pm)}
|
||||
className="rounded-lg bg-amber-500/15 px-3 py-1.5 text-xs font-medium text-amber-100 ring-1 ring-amber-500/25 disabled:opacity-30"
|
||||
>
|
||||
{t("重新开始", "Restart")}
|
||||
</button>
|
||||
<div
|
||||
className="flex shrink-0 flex-wrap gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<LiveStreamActionBtn
|
||||
kind="start"
|
||||
targetPm2={pm}
|
||||
busy={busy}
|
||||
online={rowOnline}
|
||||
label={t("开始", "Start")}
|
||||
className="rounded-lg bg-emerald-500/20 text-emerald-100 ring-emerald-500/30"
|
||||
baseLock={ytIniLoading || urlReloading || urlOptimizing}
|
||||
onRun={() => onRunProcess("start", pm)}
|
||||
notify={notify}
|
||||
t={t}
|
||||
compact
|
||||
/>
|
||||
<LiveStreamActionBtn
|
||||
kind="stop"
|
||||
targetPm2={pm}
|
||||
busy={busy}
|
||||
online={rowOnline}
|
||||
label={t("停止", "Stop")}
|
||||
className="rounded-lg bg-rose-500/15 text-rose-100 ring-rose-500/25"
|
||||
baseLock={ytIniLoading || urlReloading || urlOptimizing}
|
||||
onRun={() => onRunProcess("stop", pm)}
|
||||
notify={notify}
|
||||
t={t}
|
||||
compact
|
||||
/>
|
||||
<LiveStreamActionBtn
|
||||
kind="restart"
|
||||
targetPm2={pm}
|
||||
busy={busy}
|
||||
online={rowOnline}
|
||||
label={t("重新开始", "Restart")}
|
||||
className="rounded-lg bg-amber-500/15 text-amber-100 ring-amber-500/25"
|
||||
baseLock={ytIniLoading || urlReloading || urlOptimizing}
|
||||
onRun={() => onRunProcess("restart", pm)}
|
||||
notify={notify}
|
||||
t={t}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user