fix(voice): media queue field mismatch, voice joinable filter, connect error toast
Audit voice (kirim/terima/music/screenshare) menemukan 3 masalah:
1. media:queue SILENT no-op — backend publish {source,mode} tapi gateway
handler baca payload.url → selalu 'received without a URL'. Backend
sekarang kirim {url,mode}, gateway terima url ATAU source (robust).
2. Voice connect gagal diam-diam saat user pilih channel tanpa permission
(joinable=false, contoh Music 32/64/128/256k). Backend+gateway sekarang
expose joinable; FE disable channel 'no akses' + empty state.
3. FE tidak kasih feedback saat connect gagal — tambah toast.error dengan
pesan dari backend.
Verified live: @discordjs/voice connect ke Lofi Radio joinable sukses
(VOICE READY, DAVE session OK) — pipeline voice sebenarnya sehat, masalah
utama UX. media:queue fix akan di-verify setelah deploy.
This commit is contained in:
@@ -99,7 +99,9 @@ export async function queue(
|
|||||||
() =>
|
() =>
|
||||||
publishCommand<MediaState>(
|
publishCommand<MediaState>(
|
||||||
COMMAND_MEDIA_QUEUE,
|
COMMAND_MEDIA_QUEUE,
|
||||||
{ source, mode },
|
// NOTE: gateway MediaHandler reads `payload.url` (not `source`) —
|
||||||
|
// keep the field name aligned or playback silently no-ops.
|
||||||
|
{ url: source, mode },
|
||||||
DEFAULT_COMMAND_TIMEOUT_MS,
|
DEFAULT_COMMAND_TIMEOUT_MS,
|
||||||
),
|
),
|
||||||
() => readStatusFallback(),
|
() => readStatusFallback(),
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export interface Channel {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: "voice" | "text";
|
type: "voice" | "text";
|
||||||
|
/** Whether the selfbot account can actually join this voice channel. */
|
||||||
|
joinable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GuildVoiceEntry {
|
export interface GuildVoiceEntry {
|
||||||
|
|||||||
@@ -124,7 +124,8 @@ export class MediaHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async handleMediaQueue(cmd: CommandMessage): Promise<CommandReply<unknown>> {
|
async handleMediaQueue(cmd: CommandMessage): Promise<CommandReply<unknown>> {
|
||||||
const url = String(cmd.payload.url ?? "").trim();
|
// Accept both `url` (canonical) and `source` (legacy FE) for resilience.
|
||||||
|
const url = String(cmd.payload.url ?? cmd.payload.source ?? "").trim();
|
||||||
const mode: MediaMode = cmd.payload.mode === "screen" ? "screen" : "music";
|
const mode: MediaMode = cmd.payload.mode === "screen" ? "screen" : "music";
|
||||||
const requestedBy = String(cmd.payload.requestedBy ?? "unknown");
|
const requestedBy = String(cmd.payload.requestedBy ?? "unknown");
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ export class VoiceHandler {
|
|||||||
id: c.id,
|
id: c.id,
|
||||||
name: c.name,
|
name: c.name,
|
||||||
type: "voice" as const,
|
type: "voice" as const,
|
||||||
|
// selfbot exposes joinable (permission check) — let FE filter
|
||||||
|
// channels the account actually may join.
|
||||||
|
joinable: (c as { joinable?: boolean }).joinable ?? true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { id: cmd.id, success: true, data: voiceChannels };
|
return { id: cmd.id, success: true, data: voiceChannels };
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
useVoiceStatus,
|
useVoiceStatus,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
type VoiceTab = "connection" | "activity";
|
type VoiceTab = "connection" | "activity";
|
||||||
|
|
||||||
@@ -104,12 +105,22 @@ export default function VoicePage() {
|
|||||||
selectedChannel={selectedChannel}
|
selectedChannel={selectedChannel}
|
||||||
onGuildChange={handleGuildChange}
|
onGuildChange={handleGuildChange}
|
||||||
onChannelChange={(v) => setSelectedChannel(v ?? "")}
|
onChannelChange={(v) => setSelectedChannel(v ?? "")}
|
||||||
onConnect={() =>
|
onConnect={() => {
|
||||||
connectMut.mutate({
|
void connectMut
|
||||||
|
.mutateAsync({
|
||||||
guildId: selectedGuild,
|
guildId: selectedGuild,
|
||||||
channelId: selectedChannel,
|
channelId: selectedChannel,
|
||||||
})
|
})
|
||||||
}
|
.catch((err: unknown) => {
|
||||||
|
const msg =
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Gagal connect ke voice channel";
|
||||||
|
toast.error("Voice connect gagal", {
|
||||||
|
description: msg,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}}
|
||||||
onDisconnect={() => {
|
onDisconnect={() => {
|
||||||
if (micActive) {
|
if (micActive) {
|
||||||
setMicActive(false);
|
setMicActive(false);
|
||||||
|
|||||||
@@ -119,10 +119,19 @@ export function VoiceConnectionCard({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{voiceChannels.map((c) => (
|
{voiceChannels.map((c) => (
|
||||||
<SelectItem key={c.id} value={c.id}>
|
<SelectItem
|
||||||
{c.name}
|
key={c.id}
|
||||||
|
value={c.id}
|
||||||
|
disabled={c.joinable === false}
|
||||||
|
>
|
||||||
|
{c.joinable === false ? `${c.name} (no akses)` : c.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
{voiceChannels.length === 0 && (
|
||||||
|
<div className="px-3 py-2 text-xs text-text-secondary/60">
|
||||||
|
Tidak ada voice channel
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export interface Channel {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: "voice" | "text";
|
type: "voice" | "text";
|
||||||
|
/** Whether the selfbot account can actually join this voice channel. */
|
||||||
|
joinable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shape of the /api/config response (camelCase keys from backend). */
|
/** Shape of the /api/config response (camelCase keys from backend). */
|
||||||
|
|||||||
Reference in New Issue
Block a user