5180 fichiers 38.5 Mo 0 téléchargements 246 vues mis à jour il y a 5 heures ui
TraxEditorView.tsx 46.8 Ko Télécharger le fichier
import { AddLinkEventTracker, GetSoundManager, ILinkEventTracker, ITraxEditorSong, RemoveLinkEventTracker, SoundManagerEvent } from '@octane/renderer';
import { FC, MouseEvent, PointerEvent, ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { GetDiskColor, localizeWithFallback } from '../../api';
import { GetTraxCartridgeUrl } from '../../assets/images/trax';
import { Button, Column, Flex, LayoutCurrencyIcon, Text } from '../../common';
import { useOctaneEvent, usePurse, useTraxEditor } from '../../hooks';
import { OctaneCard, OctaneInput } from '../../layout';
import { CreateEmptyChannels, ITraxPlacement, ParseTraxSong, SerializeTraxSong, TRAX_CHANNEL_COUNT, TRAX_DEFAULT_UNITS, TRAX_MAX_UNITS, TRAX_MIN_UNITS, TraxChannels } from './TraxSongData';
import { GetTraxSampleId, GetTraxSoundSetForSample, TraxSoundSets } from './TraxSoundSets';
import { BaseButton } from '../../common/BaseButton';
import { ToggleButton } from '../../common/ToggleButton';
import { Hint } from '../../common/Tooltip';
import { NativeTextTree } from '../../common/native-text/NativeTextTree';

const CELL_WIDTH = 22;
const CARTRIDGES_PER_PAGE = 8;
const RACK_PAGES = Math.ceil(TraxSoundSets.length / CARTRIDGES_PER_PAGE);

const CHANNEL_COLORS = ['#e0433c', '#3f8fe0', '#43c243', '#e0c23c'];
const DEFAULT_SLOT_SETS = [1, 2, 3, 4];
const ERROR_TEXTS: Record<number, [string, string]> = {
    1: ['trax.editor.error.disabled', 'The song editor is disabled on this hotel.'],
    2: ['trax.editor.error.limit', 'You already own the maximum number of songs.'],
    3: ['trax.editor.error.currency', 'You can not afford a new song.'],
    4: ['trax.editor.error.invalid', 'This song could not be saved. Try shortening it.'],
    5: ['trax.editor.error.notfound', 'This song no longer exists.']
};

const formatStamp = (seconds: number): string => `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`;

interface MachineButtonProps {
    title?: string;
    disabled?: boolean;
    accent?: string;
    onClick: () => void;
    children?: ReactNode;
}

const MachineButton: FC<MachineButtonProps> = ({ title = undefined, disabled = false, accent = undefined, onClick, children = null }) => (
    <NativeTextTree>
        <Hint content={title}><BaseButton
            disabled={disabled}
            onClick={onClick}
            className="flex h-9 w-10 shrink-0 items-center justify-center rounded-[5px] border font-bold"
            style={{
                cursor: disabled ? 'default' : 'pointer',
                opacity: disabled ? 0.35 : 1,
                color: '#ffffff',
                fontSize: 15,
                borderColor: 'rgba(0, 0, 0, 0.55)',
                background: accent
                    ? `linear-gradient(rgba(255, 255, 255, 0.22), rgba(0, 0, 0, 0.25)), ${accent}`
                    : 'linear-gradient(#66798a, #46545e)',
                boxShadow: 'inset 0 1px rgba(255, 255, 255, 0.35), 0 1px 2px rgba(0, 0, 0, 0.45)',
                textShadow: '0 1px 2px rgba(0, 0, 0, 0.7)'
            }}
        >
            {children}
        </BaseButton></Hint>
    </NativeTextTree>
);

export const TraxEditorView: FC<{}> = () =>
{
    const [isVisible, setIsVisible] = useState(false);
    const [editingSongId, setEditingSongId] = useState(-1);
    const [songName, setSongName] = useState('');
    const [channels, setChannels] = useState<TraxChannels>(CreateEmptyChannels);
    const [songLength, setSongLength] = useState(TRAX_DEFAULT_UNITS);
    const [slotSets, setSlotSets] = useState<number[]>(DEFAULT_SLOT_SETS);
    const [slotSamples, setSlotSamples] = useState<number[]>([0, 0, 0, 0]);
    const [activeSlot, setActiveSlot] = useState(0);
    const [rackPage, setRackPage] = useState(0);
    const [isPlaying, setIsPlaying] = useState(false);
    const [isDirty, setIsDirty] = useState(false);
    const [hoverCell, setHoverCell] = useState<{ channel: number; unit: number } | null>(null);
    const [playheadUnit, setPlayheadUnit] = useState(0);
    const pendingBuyRef = useRef(false);
    const { songs, maxSongs, costCurrency, costAmount, lastError, loaded, requestSongs, buySong, saveSong, deleteSong, clearError } = useTraxEditor();
    const { getCurrencyAmount } = usePurse();

    const canAfford = costAmount <= 0 || getCurrencyAmount(costCurrency) >= costAmount;

    const isPlayingRef = useRef(false);
    const playheadRef = useRef<HTMLDivElement | null>(null);
    const playheadRafRef = useRef(0);
    const playheadUnitRef = useRef(0);
    const previewTokenRef = useRef(0);

    const movePlayhead = useCallback((unit: number) =>
    {
        playheadUnitRef.current = unit;
        setPlayheadUnit(unit);

        if (playheadRef.current) playheadRef.current.style.left = `${unit * CELL_WIDTH}px`;
    }, []);

    const stopPlayhead = useCallback(() =>
    {
        cancelAnimationFrame(playheadRafRef.current);

        if (playheadRef.current) playheadRef.current.style.left = `${playheadUnitRef.current * CELL_WIDTH}px`;
    }, []);

    const startPlayhead = useCallback((startUnit: number, totalUnits: number) =>
    {
        const startedAt = performance.now();

        const step = () =>
        {
            const units = startUnit + ((performance.now() - startedAt) / 2000);
            const node = playheadRef.current;

            if (node) node.style.left = `${Math.min(units, totalUnits) * CELL_WIDTH}px`;

            if (units < totalUnits) playheadRafRef.current = requestAnimationFrame(step);
        };

        cancelAnimationFrame(playheadRafRef.current);
        playheadRafRef.current = requestAnimationFrame(step);
    }, []);

    const stopAudition = useCallback(() =>
    {
        GetSoundManager().musicController?.stopSamplePreview();
    }, []);

    const stopPreview = useCallback(() =>
    {
        previewTokenRef.current++;

        if (isPlayingRef.current) GetSoundManager().musicController?.stopPreview();

        isPlayingRef.current = false;
        setIsPlaying(false);
        stopPlayhead();
    }, [stopPlayhead]);

    const hideEditor = useCallback(() =>
    {
        stopAudition();
        stopPreview();
        setIsVisible(false);
    }, [stopAudition, stopPreview]);

    const openSong = useCallback(
        (song: ITraxEditorSong) =>
        {
            stopPreview();
            clearError();

            const parsed = ParseTraxSong(song.data);
            const nextSets = [...DEFAULT_SLOT_SETS];

            parsed.channels.forEach((placements, index) =>
            {
                if (placements.length) nextSets[index] = GetTraxSoundSetForSample(placements[0].sampleId);
            });

            setEditingSongId(song.id);
            setSongName(song.name);
            setChannels(parsed.channels);
            setSongLength(parsed.songLength);
            setSlotSets(nextSets);
            setSlotSamples([0, 0, 0, 0]);
            setActiveSlot(0);
            setIsDirty(false);
            movePlayhead(0);
        },
        [stopPreview, clearError, movePlayhead]
    );

    useEffect(() =>
    {
        if (!pendingBuyRef.current || !songs.length) return;

        pendingBuyRef.current = false;
        openSong(songs[songs.length - 1]);
    }, [songs, openSong]);

    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':
                        hideEditor();
                        return;
                }
            },
            eventUrlPrefix: 'trax-editor/'
        };

        AddLinkEventTracker(linkTracker);

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

    useEffect(() =>
    {
        if (isVisible) requestSongs();
    }, [isVisible, requestSongs]);

    useEffect(() => () =>
    {
        cancelAnimationFrame(playheadRafRef.current);

        if (isPlayingRef.current) GetSoundManager().musicController?.stopPreview();

        GetSoundManager().musicController?.stopSamplePreview();
    }, []);

    useOctaneEvent<SoundManagerEvent>(SoundManagerEvent.TRAX_SONG_COMPLETE, () =>
    {
        isPlayingRef.current = false;
        setIsPlaying(false);
        stopPlayhead();
    });

    const auditionSample = useCallback((sampleId: number) =>
    {
        void GetSoundManager().musicController?.previewSample(sampleId).catch(() =>
        {});
    }, []);

    const loadCartridge = useCallback((cd: number) =>
    {
        setSlotSets((prev) =>
        {
            const next = [...prev];

            next[activeSlot] = cd;

            return next;
        });
        setSlotSamples((prev) =>
        {
            const next = [...prev];

            next[activeSlot] = 0;

            return next;
        });
    }, [activeSlot]);

    const selectSample = useCallback((slot: number, sampleIndex: number) =>
    {
        setActiveSlot(slot);
        setSlotSamples((prev) =>
        {
            const next = [...prev];

            next[slot] = sampleIndex;

            return next;
        });

        if (!isPlayingRef.current) auditionSample(GetTraxSampleId(slotSets[slot], sampleIndex));
    }, [slotSets, auditionSample]);

    const placeSample = useCallback(
        (channel: number, unit: number) =>
        {
            const cd = slotSets[channel];
            const set = TraxSoundSets[cd - 1];
            const sampleIndex = slotSamples[channel];
            const sampleId = GetTraxSampleId(cd, sampleIndex);
            const length = Math.max(1, set?.sampleLengths[sampleIndex] ?? 1);

            if (unit + length > TRAX_MAX_UNITS) return;

            setChannels((prev) =>
            {
                const next = prev.map((placements) => [...placements]);

                next[channel] = next[channel].filter((p) => p.position + p.length <= unit || p.position >= unit + length);
                next[channel].push({ position: unit, sampleId, length });

                return next;
            });
            setSongLength((prev) => Math.max(prev, unit + length));
            setIsDirty(true);
        },
        [slotSets, slotSamples]
    );

    const removePlacement = useCallback((channel: number, placement: ITraxPlacement) =>
    {
        setChannels((prev) =>
        {
            const next = prev.map((placements) => [...placements]);

            next[channel] = next[channel].filter((p) => p !== placement);

            return next;
        });
        setIsDirty(true);
    }, []);

    const shiftSong = useCallback((delta: number) =>
    {
        setChannels((prev) => prev.map((placements) => placements
            .map((p) => ({ ...p, position: p.position + delta }))
            .filter((p) => (p.position >= 0) && ((p.position + p.length) <= TRAX_MAX_UNITS))));
        setIsDirty(true);
    }, []);

    const clearSong = useCallback(() =>
    {
        setChannels(CreateEmptyChannels());
        setIsDirty(true);
    }, []);

    const onChannelClick = useCallback(
        (event: MouseEvent<HTMLDivElement>, channel: number) =>
        {
            const rect = event.currentTarget.getBoundingClientRect();
            const unit = Math.floor((event.clientX - rect.left) / CELL_WIDTH);

            if (unit < 0 || unit >= songLength) return;

            placeSample(channel, unit);
        },
        [placeSample, songLength]
    );

    const onChannelMouseMove = useCallback(
        (event: MouseEvent<HTMLDivElement>, channel: number) =>
        {
            const rect = event.currentTarget.getBoundingClientRect();
            const unit = Math.floor((event.clientX - rect.left) / CELL_WIDTH);

            if (unit < 0 || unit >= songLength)
            {
                setHoverCell(null);
                return;
            }

            setHoverCell((prev) => ((prev && prev.channel === channel && prev.unit === unit) ? prev : { channel, unit }));
        },
        [songLength]
    );

    const startPreviewAt = useCallback((startUnit: number) =>
    {
        if (isPlayingRef.current) GetSoundManager().musicController?.stopPreview();

        const token = ++previewTokenRef.current;

        isPlayingRef.current = true;
        setIsPlaying(true);
        void GetSoundManager()
            .musicController?.previewTraxData(SerializeTraxSong(channels, songLength), startUnit * 2)
            .then(() =>
            {
                if (isPlayingRef.current && (previewTokenRef.current === token)) startPlayhead(startUnit, songLength);
            })
            .catch(() =>
            {
                if (previewTokenRef.current !== token) return;

                isPlayingRef.current = false;
                setIsPlaying(false);
                stopPlayhead();
            });
    }, [startPlayhead, stopPlayhead, channels, songLength]);

    const togglePreview = useCallback(() =>
    {
        stopAudition();

        if (isPlaying)
        {
            stopPreview();
            return;
        }

        startPreviewAt(Math.min(playheadUnit, Math.max(0, songLength - 1)));
    }, [isPlaying, stopAudition, stopPreview, startPreviewAt, songLength, playheadUnit]);

    const scrubbingRef = useRef(false);
    const scrubWasPlayingRef = useRef(false);

    const scrubToPointer = useCallback(
        (event: PointerEvent<HTMLDivElement>) =>
        {
            const rect = event.currentTarget.getBoundingClientRect();
            const unit = Math.min(songLength - 1, Math.max(0, Math.floor((event.clientX - rect.left) / CELL_WIDTH)));

            movePlayhead(unit);
        },
        [songLength, movePlayhead]
    );

    const scrubPointerDown = useCallback(
        (event: PointerEvent<HTMLDivElement>) =>
        {
            event.preventDefault();
            event.currentTarget.setPointerCapture(event.pointerId);
            scrubbingRef.current = true;
            scrubWasPlayingRef.current = isPlayingRef.current;
            stopPlayhead();
            scrubToPointer(event);
        },
        [stopPlayhead, scrubToPointer]
    );

    const scrubPointerMove = useCallback(
        (event: PointerEvent<HTMLDivElement>) =>
        {
            if (scrubbingRef.current) scrubToPointer(event);
        },
        [scrubToPointer]
    );

    const scrubPointerUp = useCallback(
        (event: PointerEvent<HTMLDivElement>) =>
        {
            if (!scrubbingRef.current) return;

            scrubbingRef.current = false;

            if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);

            if (scrubWasPlayingRef.current && isPlayingRef.current) startPreviewAt(playheadUnitRef.current);
        },
        [startPreviewAt]
    );

    const rewindPlayhead = useCallback(() =>
    {
        stopPlayhead();
        movePlayhead(0);

        if (isPlayingRef.current) startPreviewAt(0);
    }, [stopPlayhead, movePlayhead, startPreviewAt]);

    const saveCurrentSong = useCallback(() =>
    {
        if (editingSongId < 0) return;

        saveSong(editingSongId, songName, SerializeTraxSong(channels, songLength));
        setIsDirty(false);
    }, [editingSongId, songName, channels, songLength, saveSong]);

    const deleteCurrentSong = useCallback(() =>
    {
        if (editingSongId < 0) return;

        stopPreview();
        deleteSong(editingSongId);
        setEditingSongId(-1);
    }, [editingSongId, deleteSong, stopPreview]);

    const buyNewSong = useCallback(() =>
    {
        pendingBuyRef.current = true;
        buySong(localizeWithFallback('trax.editor.new.song.name', 'My new song'));
    }, [buySong]);

    if (!isVisible) return null;

    const emptySlots = Math.max(0, maxSongs - songs.length);
    const rackCartridges = TraxSoundSets.slice(rackPage * CARTRIDGES_PER_PAGE, (rackPage + 1) * CARTRIDGES_PER_PAGE);
    return (
        <NativeTextTree>
            <OctaneCard className="w-[920px] max-w-[98vw]" uniqueKey="trax-editor">
                <OctaneCard.Header
                    headerText={localizeWithFallback('trax.editor.title', 'Trax Machine')}
                    onCloseClick={hideEditor}
                />
                <OctaneCard.Content>
                    <div className="relative">
                        <Column gap={2} className="absolute inset-y-0 left-0 w-[200px] overflow-y-auto">
                            <Text bold>{localizeWithFallback('trax.editor.my.songs', 'My songs')}</Text>
                            {loaded &&
                                songs.map((song) =>
                                {
                                    const isOpen = editingSongId === song.id;
                                    const diskColor = GetDiskColor(song.data);

                                    return (
                                        <ToggleButton type="button" pressed={isOpen}
                                            key={song.id}
                                            onClick={() => openSong(song)}
                                            className="flex w-full cursor-pointer items-center gap-2 rounded-lg border p-2 text-left"
                                            style={{
                                                borderColor: isOpen ? '#2e6f8a' : 'rgba(0, 0, 0, 0.2)',
                                                background: isOpen ? 'linear-gradient(#eaf7fe, #d2ecf9)' : 'linear-gradient(#ffffff, #ececec)',
                                                boxShadow: isOpen ? '0 0 5px rgba(46, 111, 138, 0.55)' : '0 1px 2px rgba(0, 0, 0, 0.15)'
                                            }}
                                        >
                                            <div
                                                className="h-9 w-9 shrink-0 rounded-full border"
                                                style={{
                                                    borderColor: 'rgba(0, 0, 0, 0.35)',
                                                    background: `radial-gradient(circle at center, #20262b 0 5px, rgba(255, 255, 255, 0.55) 5px 6px, ${diskColor} 6px 100%)`,
                                                    boxShadow: 'inset 0 1px 2px rgba(255, 255, 255, 0.5), 0 1px 2px rgba(0, 0, 0, 0.3)'
                                                }}
                                            />
                                            <div className="min-w-0 flex-1">
                                                <span className="block truncate text-[12px] font-bold" style={{ color: '#222222' }}>{song.name}</span>
                                                <span className="block text-[10px]" style={{ color: 'rgba(0, 0, 0, 0.5)' }}>♪ {formatStamp(song.length)}</span>
                                            </div>
                                            {isOpen && <span className="shrink-0 text-[12px]" style={{ color: '#2e6f8a' }}>▶</span>}
                                        </ToggleButton>
                                    );
                                })}
                            {loaded &&
                                Array.from({ length: emptySlots }, (_, index) => (
                                    <div
                                        key={`empty-${index}`}
                                        className="flex items-center gap-2 rounded-lg border border-dashed p-2"
                                        style={{ borderColor: 'rgba(0, 0, 0, 0.25)', background: 'rgba(0, 0, 0, 0.03)' }}
                                    >
                                        {index === 0 ? (
                                            <Button fullWidth disabled={!canAfford} variant="success" onClick={buyNewSong}>
                                                <Flex alignItems="center" gap={1} justifyContent="center">
                                                    <span>{localizeWithFallback('trax.editor.buy.song', 'New song')}</span>
                                                    {costAmount > 0 && (
                                                        <>
                                                            <span>{costAmount}</span>
                                                            <LayoutCurrencyIcon type={costCurrency} />
                                                        </>
                                                    )}
                                                </Flex>
                                            </Button>
                                        ) : (
                                            <>
                                                <div
                                                    className="h-9 w-9 shrink-0 rounded-full border border-dashed"
                                                    style={{ borderColor: 'rgba(0, 0, 0, 0.25)' }}
                                                />
                                                <span className="text-[11px]" style={{ color: 'rgba(0, 0, 0, 0.4)' }}>
                                                    {localizeWithFallback('trax.editor.empty.slot', 'Empty slot')}
                                                </span>
                                            </>
                                        )}
                                    </div>
                                ))}
                            {!!lastError && (
                                <Text small className="text-[#a81a12]">
                                    {localizeWithFallback(...(ERROR_TEXTS[lastError] ?? ERROR_TEXTS[4]))}
                                </Text>
                            )}
                        </Column>
                        <Column gap={2} className="ml-[208px] min-h-[320px] min-w-0 rounded-lg p-2" style={{ background: 'linear-gradient(#55656f, #37444d)', boxShadow: 'inset 0 1px rgba(255,255,255,0.2)' }}>
                            {editingSongId < 0 && (
                                <Flex center className="flex-1">
                                    <Text style={{ color: 'rgba(255, 255, 255, 0.6)' }}>
                                        {localizeWithFallback('trax.editor.select.song', 'Select or buy a song on the left to start composing.')}
                                    </Text>
                                </Flex>
                            )}
                            {editingSongId >= 0 && (
                                <>
                                    <Flex alignItems="center" gap={2}>
                                        <MachineButton
                                            disabled={rackPage <= 0}
                                            onClick={() => setRackPage((prev) => Math.max(0, prev - 1))}
                                        >
                                            ◀
                                        </MachineButton>
                                        <div className="grid min-w-0 flex-1 grid-cols-4 gap-1">
                                            {rackCartridges.map((set) => (
                                                <Hint key={set.cd} content={`${set.title} — ${set.artist}`}><BaseButton
                                                    onClick={() => loadCartridge(set.cd)}
                                                    className={`flex h-8 cursor-pointer items-center gap-1 rounded-[3px] border px-1 ${slotSets[activeSlot] === set.cd ? 'border-white' : 'border-black/50'}`}
                                                    style={{
                                                        background: 'linear-gradient(#ececec, #b5b5b5)',
                                                        boxShadow: slotSets[activeSlot] === set.cd ? '0 0 4px rgba(255,255,255,0.8)' : 'inset 0 1px rgba(255,255,255,0.6), 0 1px rgba(0,0,0,0.35)'
                                                    }}
                                                >
                                                    {GetTraxCartridgeUrl(set.cd) ? (
                                                        <img alt="" className="h-[26px] w-[26px] shrink-0 image-rendering-pixelated" src={GetTraxCartridgeUrl(set.cd)} />
                                                    ) : (
                                                        <span className="h-5 w-4 shrink-0 rounded-[2px] border border-black/30" style={{ backgroundColor: set.color }} />
                                                    )}
                                                    <span className="truncate text-left text-[10px] font-bold leading-tight text-black/75">{set.title}</span>
                                                </BaseButton></Hint>
                                            ))}
                                        </div>
                                        <MachineButton
                                            disabled={rackPage >= RACK_PAGES - 1}
                                            onClick={() => setRackPage((prev) => Math.min(RACK_PAGES - 1, prev + 1))}
                                        >
                                            ▶
                                        </MachineButton>
                                        <span className="w-10 shrink-0 text-center text-[11px] font-bold" style={{ color: 'rgba(255, 255, 255, 0.8)' }}>{rackPage + 1}/{RACK_PAGES}</span>
                                    </Flex>
                                    <div className="grid grid-cols-4 gap-1">
                                        {Array.from({ length: TRAX_CHANNEL_COUNT }, (_, slot) =>
                                        {
                                            const set = TraxSoundSets[slotSets[slot] - 1];

                                            return (
                                                <div
                                                    key={slot}
                                                    onClick={() => setActiveSlot(slot)}
                                                    className={`cursor-pointer rounded-[4px] border p-1 ${activeSlot === slot ? 'border-white' : 'border-black/50 opacity-80'}`}
                                                    style={{
                                                        borderTopWidth: 4,
                                                        borderTopColor: CHANNEL_COLORS[slot],
                                                        background: activeSlot === slot ? 'linear-gradient(#5e7280, #46545e)' : 'linear-gradient(#46545e, #313d45)',
                                                        boxShadow: activeSlot === slot ? `0 0 7px ${CHANNEL_COLORS[slot]}, inset 0 1px rgba(255,255,255,0.3)` : 'none'
                                                    }}
                                                >
                                                    <Flex alignItems="center" gap={1} className="mb-1 min-w-0">
                                                        <span
                                                            className="flex h-4 w-4 shrink-0 items-center justify-center rounded-[2px] text-[10px] font-bold text-white"
                                                            style={{ backgroundColor: CHANNEL_COLORS[slot], textShadow: '0 1px 1px rgba(0, 0, 0, 0.6)' }}
                                                        >
                                                            {slot + 1}
                                                        </span>
                                                        {(set && GetTraxCartridgeUrl(set.cd)) ? (
                                                            <img alt="" className="h-[18px] w-[18px] shrink-0 image-rendering-pixelated" src={GetTraxCartridgeUrl(set.cd)} />
                                                        ) : (
                                                            <span className="h-3 w-3 shrink-0 rounded-[2px] border border-black/40" style={{ backgroundColor: set?.color ?? '#CACACA' }} />
                                                        )}
                                                        <span className="truncate text-[10px] font-bold" style={{ color: 'rgba(255, 255, 255, 0.9)' }} title={`${set?.title ?? ''} — ${set?.artist ?? ''}`}>{set?.title ?? ''}</span>
                                                    </Flex>
                                                    <div className="grid grid-cols-9 gap-0.5">
                                                        {(set?.sampleLengths ?? []).map((length, index) => (
                                                            <Hint key={index} content={`#${index + 1} — ${length * 2}s`}><BaseButton
                                                                onClick={(event) =>
                                                                {
                                                                    event.stopPropagation();
                                                                    selectSample(slot, index);
                                                                }}
                                                                className={`h-6 cursor-pointer rounded-[2px] border text-[9px] font-bold text-white ${(slotSamples[slot] === index) ? 'border-white' : 'border-black/40'}`}
                                                                style={{
                                                                    backgroundColor: CHANNEL_COLORS[slot],
                                                                    textShadow: '0 1px 2px rgba(0, 0, 0, 0.9)',
                                                                    boxShadow: (slotSamples[slot] === index) ? '0 0 4px rgba(255,255,255,0.9)' : 'inset 0 1px rgba(255,255,255,0.4)'
                                                                }}
                                                            >
                                                                {index + 1}
                                                            </BaseButton></Hint>
                                                        ))}
                                                    </div>
                                                </div>
                                            );
                                        })}
                                    </div>
                                    <Flex alignItems="center" gap={1}>
                                        <MachineButton accent={isPlaying ? '#c2913f' : '#3f9e4a'} title={isPlaying ? localizeWithFallback('trax.editor.stop', 'Stop') : localizeWithFallback('trax.editor.play', 'Play')} onClick={togglePreview}>
                                            {isPlaying ? '❚❚' : '▶'}
                                        </MachineButton>
                                        <MachineButton accent="#c2493f" title={localizeWithFallback('trax.editor.stop', 'Stop')} disabled={!isPlaying} onClick={stopPreview}>
                                            ■
                                        </MachineButton>
                                        <MachineButton title={localizeWithFallback('trax.editor.rewind', 'Back to start')} disabled={!isPlaying && playheadUnit === 0} onClick={rewindPlayhead}>
                                            ⏮
                                        </MachineButton>
                                        <MachineButton accent="#3f7fc2" title={localizeWithFallback('trax.editor.save', 'Save')} disabled={!isDirty} onClick={saveCurrentSong}>
                                            💾
                                        </MachineButton>
                                        <MachineButton accent="#c2913f" title={localizeWithFallback('trax.editor.clear', 'Clear')} onClick={clearSong}>
                                            🧹
                                        </MachineButton>
                                        <MachineButton title={localizeWithFallback('trax.editor.shift.left', 'Move left')} onClick={() => shiftSong(-1)}>
                                            ⇤
                                        </MachineButton>
                                        <MachineButton title={localizeWithFallback('trax.editor.shift.right', 'Move right')} onClick={() => shiftSong(1)}>
                                            ⇥
                                        </MachineButton>
                                        <div className="min-w-0 flex-1">
                                            <OctaneInput
                                                value={songName}
                                                maxLength={64}
                                                style={{
                                                    color: '#ffffff',
                                                    background: 'linear-gradient(#242f36, #2e3b43)',
                                                    border: '1px solid rgba(0, 0, 0, 0.55)',
                                                    boxShadow: 'inset 0 2px 4px rgba(0, 0, 0, 0.45)',
                                                    textShadow: '0 1px 1px rgba(0, 0, 0, 0.6)'
                                                }}
                                                onChange={(event) =>
                                                {
                                                    setSongName(event.target.value);
                                                    setIsDirty(true);
                                                }}
                                            />
                                        </div>
                                        <MachineButton accent="#c2493f" title={localizeWithFallback('trax.editor.delete', 'Delete')} onClick={deleteCurrentSong}>
                                            🗑
                                        </MachineButton>
                                    </Flex>
                                    {/* Timeline */}
                                    <div className="overflow-x-auto rounded border border-black/40 p-2" style={{ background: 'linear-gradient(#222f36, #1a252b)', boxShadow: 'inset 0 2px 5px rgba(0,0,0,0.5)' }}>
                                        <div className="relative" style={{ width: songLength * CELL_WIDTH }}>
                                            <div
                                                ref={playheadRef}
                                                className="pointer-events-none absolute"
                                                style={{
                                                    left: playheadUnit * CELL_WIDTH,
                                                    top: 0,
                                                    bottom: 18,
                                                    width: 3,
                                                    backgroundColor: '#ffffff',
                                                    boxShadow: '0 0 6px rgba(255, 255, 255, 0.9)',
                                                    borderRadius: 2,
                                                    zIndex: 30
                                                }}
                                            >
                                                <div
                                                    className="absolute"
                                                    style={{
                                                        top: 0,
                                                        left: -4.5,
                                                        width: 0,
                                                        height: 0,
                                                        borderLeft: '6px solid transparent',
                                                        borderRight: '6px solid transparent',
                                                        borderTop: '10px solid #ffffff',
                                                        filter: 'drop-shadow(0 0 3px rgba(255, 255, 255, 0.8))'
                                                    }}
                                                />
                                            </div>
                                            <div
                                                className="relative mb-1 rounded"
                                                style={{
                                                    height: 14,
                                                    cursor: 'col-resize',
                                                    touchAction: 'none',
                                                    background: 'rgba(255, 255, 255, 0.1)',
                                                    backgroundImage: `repeating-linear-gradient(to right, transparent, transparent ${(CELL_WIDTH * 5) - 1}px, rgba(255, 255, 255, 0.35) ${(CELL_WIDTH * 5) - 1}px, rgba(255, 255, 255, 0.35) ${CELL_WIDTH * 5}px)`,
                                                    boxShadow: 'inset 0 1px 2px rgba(0, 0, 0, 0.5)'
                                                }}
                                                title={localizeWithFallback('trax.editor.scrub.hint', 'Click or drag to set the play position')}
                                                onPointerDown={scrubPointerDown}
                                                onPointerMove={scrubPointerMove}
                                                onPointerUp={scrubPointerUp}
                                                onPointerCancel={scrubPointerUp}
                                            />
                                            <Column gap={1}>
                                                {Array.from({ length: TRAX_CHANNEL_COUNT }, (_, channel) => (
                                                    <div
                                                        key={channel}
                                                        onClick={(event) => onChannelClick(event, channel)}
                                                        onMouseMove={(event) => onChannelMouseMove(event, channel)}
                                                        onMouseLeave={() => setHoverCell(null)}
                                                        className="relative h-10 shrink-0 cursor-crosshair rounded bg-[#2b3f4a]"
                                                        style={{
                                                            width: songLength * CELL_WIDTH,
                                                            boxShadow: `inset 3px 0 0 ${CHANNEL_COLORS[channel]}`,
                                                            backgroundImage: 'repeating-linear-gradient(to right, transparent, transparent ' + (CELL_WIDTH - 1) + 'px, rgba(255,255,255,0.08) ' + (CELL_WIDTH - 1) + 'px, rgba(255,255,255,0.08) ' + CELL_WIDTH + 'px)'
                                                        }}
                                                    >
                                                        {hoverCell && hoverCell.channel === channel && (() =>
                                                        {
                                                            const hoverSet = TraxSoundSets[slotSets[channel] - 1];
                                                            const hoverSample = slotSamples[channel];
                                                            const ghostLength = Math.max(1, hoverSet?.sampleLengths[hoverSample] ?? 1);
                                                            const fits = (hoverCell.unit + ghostLength) <= TRAX_MAX_UNITS;

                                                            return (
                                                                <div
                                                                    className={`pointer-events-none absolute top-0 z-10 flex h-full items-center justify-center rounded border-2 border-dashed text-[11px] font-bold ${fits ? 'border-white/80 text-black/60' : 'border-[#a81a12] text-[#a81a12]'}`}
                                                                    style={{
                                                                        left: hoverCell.unit * CELL_WIDTH,
                                                                        width: ghostLength * CELL_WIDTH,
                                                                        backgroundColor: fits ? `${CHANNEL_COLORS[channel]}99` : 'rgba(168,26,18,0.25)'
                                                                    }}
                                                                >
                                                                    {fits ? `#${hoverSample + 1}` : '✕'}
                                                                </div>
                                                            );
                                                        })()}
                                                        {channels[channel].map((placement) =>
                                                        {
                                                            const cd = GetTraxSoundSetForSample(placement.sampleId);
                                                            const sampleNumber = placement.sampleId - (cd - 1) * 9;

                                                            return (
                                                                <div
                                                                    key={`${placement.position}-${placement.sampleId}`}
                                                                    title={`${TraxSoundSets[cd - 1]?.title ?? ''} #${sampleNumber} — ${localizeWithFallback('trax.editor.remove.hint', 'right-click to remove')}`}
                                                                    onClick={(event) => event.stopPropagation()}
                                                                    onContextMenu={(event) =>
                                                                    {
                                                                        event.preventDefault();
                                                                        event.stopPropagation();
                                                                        removePlacement(channel, placement);
                                                                    }}
                                                                    className="absolute top-0 flex h-full items-center justify-center overflow-hidden rounded border border-black/40 text-[11px] font-bold text-black/70"
                                                                    style={{
                                                                        left: placement.position * CELL_WIDTH,
                                                                        width: placement.length * CELL_WIDTH,
                                                                        backgroundColor: CHANNEL_COLORS[channel]
                                                                    }}
                                                                >
                                                                    {sampleNumber}
                                                                </div>
                                                            );
                                                        })}
                                                    </div>
                                                ))}
                                            </Column>
                                            <div
                                                className="relative cursor-pointer"
                                                style={{ height: 18, touchAction: 'none' }}
                                                title={localizeWithFallback('trax.editor.scrub.hint', 'Click or drag to set the play position')}
                                                onPointerDown={scrubPointerDown}
                                                onPointerMove={scrubPointerMove}
                                                onPointerUp={scrubPointerUp}
                                                onPointerCancel={scrubPointerUp}
                                            >
                                                {Array.from({ length: Math.floor(songLength / 5) }, (_, index) => (
                                                    <div
                                                        key={index}
                                                        className="absolute top-1 text-[9px] font-bold"
                                                        style={{ left: ((index + 1) * 5 * CELL_WIDTH) - 12, color: 'rgba(255, 255, 255, 0.5)' }}
                                                    >
                                                        {formatStamp((index + 1) * 10)}
                                                    </div>
                                                ))}
                                            </div>
                                        </div>
                                    </div>
                                    <Flex alignItems="center" gap={2}>
                                        <Text small style={{ color: 'rgba(255, 255, 255, 0.8)' }}>{localizeWithFallback('trax.editor.length', 'Length')}: {formatStamp(songLength * 2)}</Text>
                                        <MachineButton
                                            disabled={songLength <= TRAX_MIN_UNITS}
                                            onClick={() =>
                                            {
                                                const nextLength = Math.max(TRAX_MIN_UNITS, songLength - 4);

                                                setSongLength(nextLength);
                                                setChannels((prev) => prev.map((placements) => placements.filter((p) => p.position + p.length <= nextLength)));
                                                if (playheadUnitRef.current >= nextLength) movePlayhead(nextLength - 1);
                                                setIsDirty(true);
                                            }}
                                        >
                                            −
                                        </MachineButton>
                                        <MachineButton
                                            disabled={songLength >= TRAX_MAX_UNITS}
                                            onClick={() =>
                                            {
                                                setSongLength((prev) => Math.min(TRAX_MAX_UNITS, prev + 4));
                                                setIsDirty(true);
                                            }}
                                        >
                                            +
                                        </MachineButton>
                                    </Flex>
                                </>
                            )}
                        </Column>
                    </div>
                </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