fix(install): LiveKit перезапускается при изменении конфига
LiveKit читает livekit.yaml только при старте, а compose не замечает правок в примонтированном файле — из-за этого вебхуки не включались после обновления. Установщик сравнивает sha256 конфига с сохранённым и перезапускает сервис.
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
import { useCallback, useRef, useState, type DragEvent, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { Channel } from '@/api/types';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { Card, Button } from '@/components/ui/primitives';
|
||||
import { RemoteAudio, RemoteVideo } from '@/components/voice/RemoteAudio';
|
||||
import { VoiceFlags, VoiceQualityDot, speakingRingClass } from '@/components/voice/VoiceIndicators';
|
||||
import { VoiceOccupantMenu } from '@/components/voice/VoiceOccupantMenu';
|
||||
import { useVoiceEnabled } from '@/components/voice/useVoiceEnabled';
|
||||
import { useGuildVoiceStates } from '@/components/voice/useGuildVoiceStates';
|
||||
import {
|
||||
useVoiceChannels,
|
||||
useVoiceModeration,
|
||||
useVoiceOccupants,
|
||||
type VoiceOccupant,
|
||||
} from '@/components/voice/useVoiceOccupants';
|
||||
import { VOICE_DRAG_MIME } from '@/components/voice/voiceDrag';
|
||||
import type { VoiceModerationPermissions } from '@/lib/identity';
|
||||
import { useApiErrorMessage } from '@/lib/useApiErrorMessage';
|
||||
import { useVoiceStore } from '@/stores/voice';
|
||||
import { useVoiceConnectionStore } from '@/stores/voiceConnection';
|
||||
|
||||
/** Плитка участника: видео или аватар, имя, флаги, качество связи. */
|
||||
function VoiceTile({
|
||||
occupant,
|
||||
permissions,
|
||||
moveTargets,
|
||||
onMute,
|
||||
onDeafen,
|
||||
onMove,
|
||||
children,
|
||||
}: {
|
||||
occupant: VoiceOccupant;
|
||||
permissions: VoiceModerationPermissions;
|
||||
moveTargets: Channel[];
|
||||
onMute: (userId: string, muted: boolean) => void;
|
||||
onDeafen: (userId: string, deafened: boolean) => void;
|
||||
onMove: (userId: string, targetId: string) => void;
|
||||
/** Кнопки управления — только на своей плитке. */
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const volume = useVoiceStore((state) => state.participantVolumes[occupant.userId] ?? 100);
|
||||
const deafened = useVoiceConnectionStore((state) => state.deafened);
|
||||
const media = occupant.participant;
|
||||
const video = media?.screen ?? media?.video;
|
||||
|
||||
const onDragStart = (event: DragEvent<HTMLDivElement>): void => {
|
||||
if (occupant.isLocal || !permissions.move) {
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.setData(VOICE_DRAG_MIME, occupant.userId);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`voice-tile-${occupant.userId}`}
|
||||
data-speaking={occupant.isSpeaking}
|
||||
draggable={permissions.move && !occupant.isLocal}
|
||||
onDragStart={onDragStart}
|
||||
className={`flex flex-col gap-2 rounded-[var(--radius-lg)] border bg-surface-1 p-3 transition-colors duration-[var(--duration-fast)] ${
|
||||
occupant.isSpeaking ? 'border-success/70' : 'border-border/60'
|
||||
}`}
|
||||
>
|
||||
<div className="relative flex min-h-28 items-center justify-center overflow-hidden rounded-[var(--radius-md)] bg-surface-2">
|
||||
{video === undefined ? (
|
||||
<span className={`rounded-full ${speakingRingClass(occupant.isSpeaking)}`}>
|
||||
<Avatar
|
||||
name={occupant.name}
|
||||
seed={occupant.userId}
|
||||
fileId={occupant.avatarFileId}
|
||||
size="lg"
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
<RemoteVideo track={video} muted={occupant.isLocal} />
|
||||
)}
|
||||
{media?.audio === undefined ? null : (
|
||||
<RemoteAudio track={media.audio} volume={volume} muted={deafened} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium" title={occupant.name}>
|
||||
{occupant.name}
|
||||
</span>
|
||||
<VoiceQualityDot quality={occupant.quality} />
|
||||
{occupant.isLocal ? (
|
||||
<span className="text-xs text-fg-muted">{t('voice.you')}</span>
|
||||
) : (
|
||||
<button
|
||||
ref={anchorRef}
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menuOpen}
|
||||
aria-label={t('voice.menu.label', { name: occupant.name })}
|
||||
title={t('voice.menu.label', { name: occupant.name })}
|
||||
onClick={() => setMenuOpen((value) => !value)}
|
||||
className="rounded-[var(--radius-sm)] px-1 text-fg-muted transition-colors duration-[var(--duration-fast)] hover:bg-surface-3 hover:text-fg"
|
||||
>
|
||||
<span aria-hidden="true">⋯</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<VoiceFlags
|
||||
selfMute={occupant.selfMute}
|
||||
selfDeaf={occupant.selfDeaf}
|
||||
serverMute={occupant.serverMute}
|
||||
serverDeaf={occupant.serverDeaf}
|
||||
camera={occupant.camera}
|
||||
screen={occupant.screen}
|
||||
/>
|
||||
|
||||
{occupant.isSpeaking ? <p className="sr-only">{t('voice.speaking')}</p> : null}
|
||||
{children}
|
||||
|
||||
<VoiceOccupantMenu
|
||||
open={menuOpen}
|
||||
occupant={occupant}
|
||||
anchorRef={anchorRef}
|
||||
onClose={() => setMenuOpen(false)}
|
||||
permissions={permissions}
|
||||
moveTargets={moveTargets}
|
||||
onMute={onMute}
|
||||
onDeafen={onDeafen}
|
||||
onMove={onMove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Кнопки управления своей сессией: микрофон, звук, камера, экран, выход. */
|
||||
function VoiceControlsRow({
|
||||
micEnabled,
|
||||
deafened,
|
||||
cameraEnabled,
|
||||
screenEnabled,
|
||||
serverMuted,
|
||||
serverDeafened,
|
||||
onMic,
|
||||
onSound,
|
||||
onCamera,
|
||||
onScreen,
|
||||
onLeave,
|
||||
}: {
|
||||
micEnabled: boolean;
|
||||
deafened: boolean;
|
||||
cameraEnabled: boolean;
|
||||
screenEnabled: boolean;
|
||||
serverMuted: boolean;
|
||||
serverDeafened: boolean;
|
||||
onMic: () => void;
|
||||
onSound: () => void;
|
||||
onCamera: () => void;
|
||||
onScreen: () => void;
|
||||
onLeave: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-1"
|
||||
role="group"
|
||||
aria-label={t('voice.controls')}
|
||||
>
|
||||
<VoiceControlButton
|
||||
glyph="🎤"
|
||||
label={t(micEnabled ? 'voice.mic.off' : 'voice.mic.on')}
|
||||
active={micEnabled}
|
||||
disabled={serverMuted}
|
||||
{...(serverMuted ? { disabledReason: t('voice.serverMuted') } : {})}
|
||||
onClick={onMic}
|
||||
/>
|
||||
<VoiceControlButton
|
||||
glyph="🎧"
|
||||
label={t(deafened ? 'voice.sound.on' : 'voice.sound.off')}
|
||||
active={!deafened}
|
||||
disabled={serverDeafened}
|
||||
{...(serverDeafened ? { disabledReason: t('voice.serverDeafened') } : {})}
|
||||
onClick={onSound}
|
||||
/>
|
||||
<VoiceControlButton
|
||||
glyph="🎥"
|
||||
label={t(cameraEnabled ? 'voice.camera.off' : 'voice.camera.on')}
|
||||
active={cameraEnabled}
|
||||
onClick={onCamera}
|
||||
/>
|
||||
<VoiceControlButton
|
||||
glyph="🖥"
|
||||
label={t(screenEnabled ? 'voice.screen.off' : 'voice.screen.on')}
|
||||
active={screenEnabled}
|
||||
onClick={onScreen}
|
||||
/>
|
||||
<VoiceControlButton
|
||||
glyph="⏻"
|
||||
label={t('voice.disconnect')}
|
||||
active
|
||||
onClick={onLeave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Кнопка управления голосом: иконка с состоянием и подписью. */
|
||||
function VoiceControlButton({
|
||||
glyph,
|
||||
label,
|
||||
active,
|
||||
disabled = false,
|
||||
disabledReason,
|
||||
onClick,
|
||||
}: {
|
||||
glyph: string;
|
||||
label: string;
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const title = disabled && disabledReason !== undefined ? disabledReason : label;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
aria-label={title}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={`rounded-[var(--radius-md)] border px-3 py-1.5 text-base leading-none transition-[color,background-color,transform] duration-[var(--duration-fast)] active:scale-95 disabled:cursor-not-allowed disabled:opacity-50 ${
|
||||
active ? 'border-border/60 bg-surface-2 text-fg' : 'border-danger/60 bg-danger/10 text-danger'
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden="true">{glyph}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Панель голосовой комнаты в области контента: крупные плитки участников,
|
||||
* управление своей сессией и модерация при наличии прав (AGENT.md 7.14).
|
||||
*/
|
||||
export function VoiceRoomPanel({ guildId, channel }: { guildId: string; channel: Channel }) {
|
||||
const { t } = useTranslation();
|
||||
const voiceEnabled = useVoiceEnabled();
|
||||
const describeError = useApiErrorMessage();
|
||||
useGuildVoiceStates(guildId);
|
||||
|
||||
const occupants = useVoiceOccupants(guildId, channel.id);
|
||||
const permissions = useVoiceModeration(guildId);
|
||||
const moveTargets = useVoiceChannels(guildId).filter((item) => item.id !== channel.id);
|
||||
const status = useVoiceConnectionStore((state) => state.status);
|
||||
const activeChannelId = useVoiceConnectionStore((state) => state.channelId);
|
||||
const error = useVoiceConnectionStore((state) => state.error);
|
||||
const micEnabled = useVoiceConnectionStore((state) => state.micEnabled);
|
||||
const cameraEnabled = useVoiceConnectionStore((state) => state.cameraEnabled);
|
||||
const screenEnabled = useVoiceConnectionStore((state) => state.screenEnabled);
|
||||
const deafened = useVoiceConnectionStore((state) => state.deafened);
|
||||
const serverMuted = useVoiceConnectionStore((state) => state.serverMuted);
|
||||
const serverDeafened = useVoiceConnectionStore((state) => state.serverDeafened);
|
||||
const refreshing = useVoiceConnectionStore((state) => state.refreshing);
|
||||
const join = useVoiceConnectionStore((state) => state.join);
|
||||
const leave = useVoiceConnectionStore((state) => state.leave);
|
||||
const setMicEnabled = useVoiceConnectionStore((state) => state.setMicEnabled);
|
||||
const setDeafened = useVoiceConnectionStore((state) => state.setDeafened);
|
||||
const setCameraEnabled = useVoiceConnectionStore((state) => state.setCameraEnabled);
|
||||
const setScreenShareEnabled = useVoiceConnectionStore((state) => state.setScreenShareEnabled);
|
||||
const setParticipantMuted = useVoiceConnectionStore((state) => state.setParticipantMuted);
|
||||
const setParticipantDeafened = useVoiceConnectionStore((state) => state.setParticipantDeafened);
|
||||
const moveParticipant = useVoiceConnectionStore((state) => state.moveParticipant);
|
||||
|
||||
const here = activeChannelId === channel.id;
|
||||
const connecting = here && status === 'connecting';
|
||||
const inRoom = here && (status === 'connected' || status === 'reconnecting');
|
||||
const failed = here && status === 'error';
|
||||
|
||||
const onMute = useCallback(
|
||||
(userId: string, muted: boolean) => void setParticipantMuted(userId, muted),
|
||||
[setParticipantMuted],
|
||||
);
|
||||
const onDeafen = useCallback(
|
||||
(userId: string, deafenedNext: boolean) => void setParticipantDeafened(userId, deafenedNext),
|
||||
[setParticipantDeafened],
|
||||
);
|
||||
const onMove = useCallback(
|
||||
(userId: string, targetId: string) => void moveParticipant(userId, targetId),
|
||||
[moveParticipant],
|
||||
);
|
||||
|
||||
if (!voiceEnabled) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<Card className="max-w-md text-center" data-testid="voice-disabled">
|
||||
<h3 className="text-lg font-semibold">{t('voice.title')}</h3>
|
||||
<p className="mt-2 text-sm text-fg-muted">{t('voice.disabled')}</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{inRoom ? (
|
||||
<p role="status" className="text-xs text-fg-muted">
|
||||
{t(refreshing ? 'voice.refreshing' : 'voice.connected')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
disabled={connecting}
|
||||
onClick={() => void join(guildId, channel.id)}
|
||||
data-testid="voice-join"
|
||||
>
|
||||
{t(connecting ? 'voice.joining' : 'voice.join')}
|
||||
</Button>
|
||||
{connecting ? (
|
||||
<p role="status" className="text-xs text-fg-muted">
|
||||
{t('voice.joining')}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{failed ? (
|
||||
<p role="alert" className="text-sm text-danger">
|
||||
{describeError(error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{occupants.length === 0 ? (
|
||||
<p className="text-sm text-fg-muted">{t('voice.empty')}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-3">
|
||||
{occupants.map((occupant) => (
|
||||
<VoiceTile
|
||||
key={occupant.userId}
|
||||
occupant={occupant}
|
||||
permissions={permissions}
|
||||
moveTargets={moveTargets}
|
||||
onMute={onMute}
|
||||
onDeafen={onDeafen}
|
||||
onMove={onMove}
|
||||
>
|
||||
{occupant.isLocal && inRoom ? (
|
||||
<VoiceControlsRow
|
||||
micEnabled={micEnabled}
|
||||
deafened={deafened}
|
||||
cameraEnabled={cameraEnabled}
|
||||
screenEnabled={screenEnabled}
|
||||
serverMuted={serverMuted}
|
||||
serverDeafened={serverDeafened}
|
||||
onMic={() => void setMicEnabled(!micEnabled)}
|
||||
onSound={() => void setDeafened(!deafened)}
|
||||
onCamera={() => void setCameraEnabled(!cameraEnabled)}
|
||||
onScreen={() => void setScreenShareEnabled(!screenEnabled)}
|
||||
onLeave={() => void leave()}
|
||||
/>
|
||||
) : null}
|
||||
</VoiceTile>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user