5180 fichiers 38.5 Mo 0 téléchargements 255 vues mis à jour il y a 5 heures ui
SoundboardView.tsx 13.6 Ko Télécharger le fichier
import { Progress } from '@base-ui/react/progress';
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@octane/renderer';
import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { LocalizeText } from '../../api';
import { useSoundboard } from '../../hooks';
import { isTextEntryTarget } from '../../hooks/rooms/useRoomKeyboardMovement';
import { useSoundboardFeedStore } from '../../hooks/soundboard/soundboardFeedStore';
import {
    DisplaySoundboardSound,
    filterSoundboardSounds,
    SoundboardCategory
} from '../../hooks/soundboard/soundboardPresentation';
import { OctaneCard } from '../../layout';
import { SoundboardPadView } from './SoundboardPadView';
import { SoundboardShelfView } from './SoundboardShelfView';
import { BaseButton } from '../../common/BaseButton';
import { NativeTextTree } from '../../common/native-text/NativeTextTree';

const PAGE_SIZE = 10;
const PLAYING_MS = 1_500;
const SHELF_SIZE = 6;

const isTypingTarget = isTextEntryTarget;

interface SoundboardContentViewProps {
    sounds: DisplaySoundboardSound[];
    categories: SoundboardCategory[];
    recentSoundIds: number[];
    favoriteIds?: number[];
    isCoolingDown: boolean;
    cooldownRemainingSeconds?: number;
    cooldownTotalSeconds?: number;
    playingSoundId?: number | null;
    silencedCount?: number;
    rightsOnly?: boolean;
    onPlay: (sound: DisplaySoundboardSound) => void;
    onToggleFavorite?: (sound: DisplaySoundboardSound) => void;
    onRestoreSilenced?: () => void;
}

export const SoundboardContentView: FC<SoundboardContentViewProps> = ({
    sounds,
    categories,
    recentSoundIds,
    favoriteIds = [],
    isCoolingDown,
    cooldownRemainingSeconds = 0,
    cooldownTotalSeconds = 0,
    playingSoundId = null,
    silencedCount = 0,
    rightsOnly = false,
    onPlay,
    onToggleFavorite,
    onRestoreSilenced
}) => {
    const [query, setQuery] = useState('');
    const [categoryId, setCategoryId] = useState('all');
    const [page, setPage] = useState(0);
    const searchRef = useRef<HTMLInputElement>(null);
    const filteredSounds = useMemo(
        () => filterSoundboardSounds(sounds, query, categoryId, recentSoundIds, favoriteIds),
        [sounds, query, categoryId, recentSoundIds, favoriteIds]
    );
    const totalPages = Math.max(1, Math.ceil(filteredSounds.length / PAGE_SIZE));
    const pageSounds = filteredSounds.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE);
    const favoriteSet = useMemo(() => new Set(favoriteIds), [favoriteIds]);
    const shelfIds = favoriteIds.length ? favoriteIds : recentSoundIds;
    const shelfSounds = useMemo(
        () => shelfIds.map((id) => sounds.find((sound) => sound.id === id)).filter((sound): sound is DisplaySoundboardSound => !!sound).slice(0, SHELF_SIZE),
        [shelfIds, sounds]
    );
    const cooldownPercent = isCoolingDown && cooldownTotalSeconds > 0
        ? Math.min(100, Math.round((cooldownRemainingSeconds / cooldownTotalSeconds) * 100))
        : 0;

    useEffect(() => setPage(0), [query, categoryId, sounds, recentSoundIds]);

    useEffect(() => {
        if (page >= totalPages) setPage(totalPages - 1);
    }, [page, totalPages]);

    useEffect(() => {
        if (categoryId === 'favorites' && !favoriteIds.length) setCategoryId('all');
    }, [categoryId, favoriteIds.length]);

    const changePage = (delta: number) => setPage((current) => Math.min(totalPages - 1, Math.max(0, current + delta)));

    // Shortcuts while the panel is open: 1-9 and 0 play the visible pads, arrows page, "/" searches.
    useEffect(() => {
        const onKeyDown = (event: KeyboardEvent) => {
            if (event.ctrlKey || event.altKey || event.metaKey || isTypingTarget(event.target)) return;

            if (event.key === '/') {
                event.preventDefault();
                searchRef.current?.focus();
                return;
            }

            if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
                event.preventDefault();
                changePage(event.key === 'ArrowLeft' ? -1 : 1);
                return;
            }

            if (/^[0-9]$/.test(event.key)) {
                const sound = pageSounds[(Number(event.key) + 9) % 10];
                if (!sound || isCoolingDown) return;

                event.preventDefault();
                onPlay(sound);
            }
        };

        window.addEventListener('keydown', onKeyDown);
        return () => window.removeEventListener('keydown', onKeyDown);
    });

    const selectCategory = (value: string) => {
        setCategoryId(value);
        setPage(0);
    };

    const categoryClassName = (value: string) => `shrink-0 cursor-pointer rounded-full border px-2.5 py-1 text-[11px] font-bold transition-colors ${
        categoryId === value
            ? 'border-[#286889] bg-[#3d8fba] text-white'
            : 'border-[#8ca9b8] bg-white/70 text-[#28566f] hover:bg-white'
    }`;

    return (
        <NativeTextTree>
            <div className="flex flex-col gap-2">
                <input
                    ref={searchRef}
                    type="search"
                    value={query}
                    aria-label={LocalizeText('soundboard.search')}
                    placeholder={`${LocalizeText('soundboard.search')}  ( / )`}
                    onChange={(event) => setQuery(event.target.value)}
                    onKeyDown={(event) => event.key === 'Escape' && event.currentTarget.blur()}
                    className="h-8 w-full rounded-md border border-[#8ca9b8] bg-white px-2.5 text-xs text-[#17384b] outline-none focus:border-[#3d8fba] focus:ring-1 focus:ring-[#3d8fba]"
                />

                {!query.trim() && !!shelfSounds.length && (
                    <SoundboardShelfView
                        label={favoriteIds.length ? `★ ${LocalizeText('soundboard.category.favorites')}` : LocalizeText('soundboard.category.recent')}
                        sounds={shelfSounds}
                        disabled={isCoolingDown}
                        playingSoundId={playingSoundId}
                        onPlay={onPlay}
                    />
                )}

                <div className="flex gap-1.5 overflow-x-auto pb-0.5" aria-label={LocalizeText('soundboard.categories')}>
                    {!!favoriteIds.length && (
                        <BaseButton type="button" onClick={() => selectCategory('favorites')} className={categoryClassName('favorites')}>
                            ★ {LocalizeText('soundboard.category.favorites')}
                        </BaseButton>
                    )}
                    <BaseButton type="button" onClick={() => selectCategory('all')} className={categoryClassName('all')}>
                        {LocalizeText('soundboard.category.all')}
                    </BaseButton>
                    {!!recentSoundIds.length && (
                        <BaseButton type="button" onClick={() => selectCategory('recent')} className={categoryClassName('recent')}>
                            {LocalizeText('soundboard.category.recent')}
                        </BaseButton>
                    )}
                    {categories.map((category) => (
                        <BaseButton key={category.id} type="button" onClick={() => selectCategory(category.id)} className={categoryClassName(category.id)}>
                            {category.label}
                        </BaseButton>
                    ))}
                </div>

                {!filteredSounds.length ? (
                    <div className="py-4 text-center text-xs text-black/50">{LocalizeText('soundboard.empty')}</div>
                ) : (
                    <div
                        className="soundboard-list"
                        data-testid="soundboard-grid"
                        onWheel={(event) => totalPages > 1 && event.deltaY !== 0 && changePage(event.deltaY > 0 ? 1 : -1)}
                    >
                        {pageSounds.map((sound, index) => (
                            <SoundboardPadView
                                key={sound.id}
                                sound={sound}
                                hotkey={String((index + 1) % 10)}
                                favorite={favoriteSet.has(sound.id)}
                                playing={sound.id === playingSoundId}
                                disabled={isCoolingDown}
                                onPlay={onPlay}
                                onToggleFavorite={onToggleFavorite}
                            />
                        ))}
                    </div>
                )}

                <Progress.Root className="soundboard-cooldown" aria-label={LocalizeText('soundboard.cooldown')} max={100} min={0} value={cooldownPercent}>
                    <Progress.Track className="soundboard-cooldown__track">
                        <Progress.Indicator className="soundboard-cooldown__fill" />
                    </Progress.Track>
                </Progress.Root>

                {totalPages > 1 && (
                    <div className="flex select-none items-center justify-center gap-1.5">
                        {Array.from({ length: totalPages }, (_, index) => (
                            <BaseButton
                                key={index}
                                type="button"
                                aria-label={LocalizeText('soundboard.pagination.page', ['page'], [String(index + 1)])}
                                aria-current={index === page ? 'page' : undefined}
                                onClick={() => setPage(index)}
                                className={`h-2 w-2 cursor-pointer rounded-full transition-colors ${index === page ? 'bg-[#3d8fba]' : 'bg-[#9bb7c7] hover:bg-[#6f98b0]'}`}
                            />
                        ))}
                    </div>
                )}

                {silencedCount > 0 && (
                    <div className="soundboard-silenced text-black/60">
                        <span>{LocalizeText('soundboard.silenced.count', ['count'], [String(silencedCount)])}</span>
                        <BaseButton type="button" className="soundboard-silenced__restore" onClick={onRestoreSilenced}>
                            {LocalizeText('soundboard.silenced.restore')}
                        </BaseButton>
                    </div>
                )}

                {rightsOnly && <div className="text-center text-[10px] text-black/60">{LocalizeText('soundboard.room.mode.rights')}</div>}

                <div className="text-center text-[10px] text-black/45">{LocalizeText('soundboard.hint')}</div>
            </div>
        </NativeTextTree>
    );
};

export const SoundboardView: FC<{}> = () => {
    const [isVisible, setIsVisible] = useState(false);
    const {
        enabled,
        roomMode,
        sounds,
        categories,
        recentSoundIds,
        favoriteIds,
        silencedUserIds,
        isCoolingDown,
        cooldownRemainingSeconds,
        cooldownTotalSeconds,
        play,
        toggleFavorite,
        restoreSilencedUsers,
        refresh
    } = useSoundboard();
    const lastPlayed = useSoundboardFeedStore((state) => state.lastPlayed);
    const [playingSoundId, setPlayingSoundId] = useState<number | null>(null);
    const wasVisibleRef = useRef(false);

    useEffect(() => {
        const linkTracker: ILinkEventTracker = {
            linkReceived: (url: string) => {
                const parts = url.split('/');
                if (parts.length < 2) return;

                switch (parts[1]) {
                    case 'show':
                        setIsVisible(true);
                        return;
                    case 'hide':
                        setIsVisible(false);
                        return;
                    case 'toggle':
                        setIsVisible((current) => !current);
                        return;
                }
            },
            eventUrlPrefix: 'soundboard/'
        };

        AddLinkEventTracker(linkTracker);
        return () => RemoveLinkEventTracker(linkTracker);
    }, []);

    useEffect(() => {
        if (!enabled) setIsVisible(false);
    }, [enabled]);

    useEffect(() => {
        const visible = isVisible && enabled;

        if (visible && !wasVisibleRef.current) refresh();
        wasVisibleRef.current = visible;
    }, [enabled, isVisible, refresh]);

    useEffect(() => {
        if (!lastPlayed) return;

        setPlayingSoundId(lastPlayed.soundId);
        const timer = window.setTimeout(() => setPlayingSoundId(null), PLAYING_MS);

        return () => window.clearTimeout(timer);
    }, [lastPlayed]);

    if (!isVisible || !enabled) return null;

    return (
        <NativeTextTree>
            <OctaneCard className="w-[420px] max-w-[96vw]" uniqueKey="soundboard">
                <OctaneCard.Header headerText={LocalizeText('soundboard.title')} onCloseClick={() => setIsVisible(false)} />
                <OctaneCard.Content>
                    <SoundboardContentView
                        sounds={sounds}
                        categories={categories}
                        recentSoundIds={recentSoundIds}
                        favoriteIds={favoriteIds}
                        isCoolingDown={isCoolingDown}
                        cooldownRemainingSeconds={cooldownRemainingSeconds}
                        cooldownTotalSeconds={cooldownTotalSeconds}
                        playingSoundId={playingSoundId}
                        silencedCount={silencedUserIds.length}
                        rightsOnly={roomMode === 2}
                        onPlay={play}
                        onToggleFavorite={(sound) => toggleFavorite(sound.id)}
                        onRestoreSilenced={restoreSilencedUsers}
                    />
                </OctaneCard.Content>
            </OctaneCard>
        </NativeTextTree>
    );
};
15Sujets
1Dépôts
20Membres
2En ligne
Profile
Inscrit
Dernière visite
Messages
0
Votes positifs
0
Votes négatifs
0

Nous utilisons des cookies pour assurer le bon fonctionnement du site et améliorer votre expérience. en savoir plus

Autoriser