fix: audio on GNU/Linux

This commit is contained in:
neoapps-dev 2026-04-25 20:24:19 +03:00
parent 9d80eea7d7
commit 87e841f565
2 changed files with 797 additions and 243 deletions

View file

@ -13,10 +13,21 @@ export default function PckEditorView() {
const [openedPath, setOpenedPath] = useState<string | null>(null);
const [selectedAssetId, setSelectedAssetId] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [isEditingProperty, setIsEditingProperty] = useState<{ idx: number, key: string, val: string } | null>(null);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null);
const [showTypeModal, setShowTypeModal] = useState<{ file: { name: string, data: Uint8Array } } | null>(null);
const [isEditingProperty, setIsEditingProperty] = useState<{
idx: number;
key: string;
val: string;
} | null>(null);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set(),
);
const [notification, setNotification] = useState<{
message: string;
type: "success" | "error";
} | null>(null);
const [showTypeModal, setShowTypeModal] = useState<{
file: { name: string; data: Uint8Array };
} | null>(null);
const [isChangingType, setIsChangingType] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const replaceInputRef = useRef<HTMLInputElement>(null);
@ -32,8 +43,12 @@ export default function PckEditorView() {
}
const root: Record<string, TempNode> = {};
pck.files.forEach(asset => {
if (searchTerm && !asset.path.toLowerCase().includes(searchTerm.toLowerCase())) return;
pck.files.forEach((asset) => {
if (
searchTerm &&
!asset.path.toLowerCase().includes(searchTerm.toLowerCase())
)
return;
const parts = asset.path.split("/");
let currentLevel = root;
let currentPath = "";
@ -46,7 +61,7 @@ export default function PckEditorView() {
path: currentPath,
isFolder: !isLast,
asset: isLast ? asset : undefined,
children: {}
children: {},
};
}
currentLevel = currentLevel[part].children;
@ -60,9 +75,9 @@ export default function PckEditorView() {
if (!a.isFolder && b.isFolder) return 1;
return a.name.localeCompare(b.name);
})
.map(node => ({
.map((node) => ({
...node,
children: convert(node.children)
children: convert(node.children),
}));
};
@ -70,13 +85,24 @@ export default function PckEditorView() {
}, [pck, searchTerm]);
const selectedAsset = useMemo(() => {
return pck?.files.find(f => f.id === selectedAssetId) || null;
return pck?.files.find((f) => f.id === selectedAssetId) || null;
}, [pck, selectedAssetId]);
const [assetPreview, setAssetPreview] = useState<{ id: string, url: string } | null>(null);
const [assetPreview, setAssetPreview] = useState<{
id: string;
url: string;
} | null>(null);
useEffect(() => {
if (!selectedAsset || ![PCKAssetType.SKIN, PCKAssetType.CAPE, PCKAssetType.TEXTURE, PCKAssetType.SKIN_DATA].includes(selectedAsset.type)) {
if (
!selectedAsset ||
![
PCKAssetType.SKIN,
PCKAssetType.CAPE,
PCKAssetType.TEXTURE,
PCKAssetType.SKIN_DATA,
].includes(selectedAsset.type)
) {
setAssetPreview(null);
return;
}
@ -90,7 +116,10 @@ export default function PckEditorView() {
};
}, [selectedAsset]);
const assetPreviewUrl = (assetPreview && selectedAsset && assetPreview.id === selectedAsset.id) ? assetPreview.url : null;
const assetPreviewUrl =
assetPreview && selectedAsset && assetPreview.id === selectedAsset.id
? assetPreview.url
: null;
const toggleFolder = (path: string) => {
const next = new Set(expandedFolders);
@ -100,7 +129,7 @@ export default function PckEditorView() {
};
const renderTree = (nodes: any[], depth = 0) => {
return nodes.map(node => {
return nodes.map((node) => {
const isExpanded = expandedFolders.has(node.path) || !!searchTerm;
const isSelected = selectedAssetId === node.asset?.id;
return (
@ -115,14 +144,19 @@ export default function PckEditorView() {
}
}}
style={{ paddingLeft: `${depth * 16 + 12}px` }}
className={`flex items-center gap-2 p-2 cursor-pointer transition-all border-l-2 ${isSelected
? "bg-[#FFFF55]/10 border-[#FFFF55] text-[#FFFF55]"
: "border-transparent hover:bg-white/5 text-white"
} ${node.isFolder ? "font-bold" : ""}`}
className={`flex items-center gap-2 p-2 cursor-pointer transition-all border-l-2 ${
isSelected
? "bg-[#FFFF55]/10 border-[#FFFF55] text-[#FFFF55]"
: "border-transparent hover:bg-white/5 text-white"
} ${node.isFolder ? "font-bold" : ""}`}
>
{node.isFolder ? (
<img
src={isExpanded ? "/images/Settings_Arrow_Down.png" : "/images/Settings_Arrow_Right.png"}
src={
isExpanded
? "/images/Settings_Arrow_Down.png"
: "/images/Settings_Arrow_Right.png"
}
className="w-3 h-3 object-contain opacity-80"
style={{ imageRendering: "pixelated" }}
/>
@ -130,7 +164,11 @@ export default function PckEditorView() {
<div className="w-3" />
)}
<img
src={node.isFolder ? "/images/Folder_Icon.png" : "/images/tools/pck.png"}
src={
node.isFolder
? "/images/Folder_Icon.png"
: "/images/tools/pck.png"
}
className={`w-4 h-4 object-contain ${isSelected ? "" : "grayscale opacity-60"}`}
style={{ imageRendering: "pixelated" }}
/>
@ -179,7 +217,7 @@ export default function PckEditorView() {
endianness: "little",
xmlSupport: false,
properties: ["ANIM", "BOX"],
files: []
files: [],
};
setPck(newPck);
setOpenedPath(null);
@ -188,7 +226,10 @@ export default function PckEditorView() {
showNotification("New PCK Created");
};
const showNotification = (message: string, type: "success" | "error" = "success") => {
const showNotification = (
message: string,
type: "success" | "error" = "success",
) => {
setNotification({ message, type });
setTimeout(() => setNotification(null), 3000);
};
@ -196,7 +237,11 @@ export default function PckEditorView() {
const handleExportAsset = async (asset: PCKAsset) => {
try {
const fileName = asset.path.split("/").pop() || "asset";
const path = await TauriService.saveFileDialog("Export Asset", fileName, []);
const path = await TauriService.saveFileDialog(
"Export Asset",
fileName,
[],
);
if (!path) return;
playPressSound();
await TauriService.writeBinaryFile(path, asset.data);
@ -209,8 +254,8 @@ export default function PckEditorView() {
const handleDeleteAsset = (id: string) => {
if (!pck) return;
playBackSound();
const newFiles = pck.files.filter(f => f.id !== id);
const assetPath = pck.files.find(f => f.id === id)?.path;
const newFiles = pck.files.filter((f) => f.id !== id);
const assetPath = pck.files.find((f) => f.id === id)?.path;
setPck({ ...pck, files: newFiles });
if (selectedAssetId === id) setSelectedAssetId(newFiles[0]?.id || null);
showNotification(`Deleted: ${assetPath?.split("/").pop()}`);
@ -225,7 +270,9 @@ export default function PckEditorView() {
const buffer = await file.arrayBuffer();
const data = new Uint8Array(buffer);
const newFiles = pck.files.map(f => f.id === selectedAssetId ? { ...f, data, size: data.length } : f);
const newFiles = pck.files.map((f) =>
f.id === selectedAssetId ? { ...f, data, size: data.length } : f,
);
setPck({ ...pck, files: newFiles });
e.target.value = "";
showNotification("Asset Replaced");
@ -251,7 +298,7 @@ export default function PckEditorView() {
type,
size: file.data.length,
data: file.data,
properties: []
properties: [],
};
if (type === PCKAssetType.SKIN || type === PCKAssetType.CAPE) {
@ -266,7 +313,7 @@ export default function PckEditorView() {
const handlePropertyEdit = (idx: number, newVal: string, isKey = false) => {
if (!pck || !selectedAssetId) return;
const newFiles = pck.files.map(f => {
const newFiles = pck.files.map((f) => {
if (f.id === selectedAssetId) {
const newProps = [...f.properties];
if (isKey) {
@ -287,11 +334,11 @@ export default function PckEditorView() {
const handleAddProperty = () => {
if (!pck || !selectedAssetId) return;
playPressSound();
const newFiles = pck.files.map(f => {
const newFiles = pck.files.map((f) => {
if (f.id === selectedAssetId) {
return {
...f,
properties: [...f.properties, { key: "NEW_PROPERTY", value: "0" }]
properties: [...f.properties, { key: "NEW_PROPERTY", value: "0" }],
};
}
return f;
@ -302,7 +349,7 @@ export default function PckEditorView() {
const handleRemoveProperty = (idx: number) => {
if (!pck || !selectedAssetId) return;
playBackSound();
const newFiles = pck.files.map(f => {
const newFiles = pck.files.map((f) => {
if (f.id === selectedAssetId) {
const newProps = [...f.properties];
newProps.splice(idx, 1);
@ -316,7 +363,7 @@ export default function PckEditorView() {
const handleTypeChange = (newType: PCKAssetType) => {
if (!pck || !selectedAssetId) return;
playPressSound();
const newFiles = pck.files.map(f => {
const newFiles = pck.files.map((f) => {
if (f.id === selectedAssetId) {
return { ...f, type: newType };
}
@ -325,11 +372,11 @@ export default function PckEditorView() {
setPck({ ...pck, files: newFiles });
};
const handleMoveAsset = (direction: 'up' | 'down') => {
const handleMoveAsset = (direction: "up" | "down") => {
if (!pck || !selectedAssetId) return;
const idx = pck.files.findIndex(f => f.id === selectedAssetId);
const idx = pck.files.findIndex((f) => f.id === selectedAssetId);
if (idx === -1) return;
const newIdx = direction === 'up' ? idx - 1 : idx + 1;
const newIdx = direction === "up" ? idx - 1 : idx + 1;
if (newIdx < 0 || newIdx >= pck.files.length) return;
playPressSound();
const newFiles = [...pck.files];
@ -340,7 +387,7 @@ export default function PckEditorView() {
const handleRenameAsset = (id: string, newPath: string) => {
if (!pck) return;
playPressSound();
const newFiles = pck.files.map(f => {
const newFiles = pck.files.map((f) => {
if (f.id === id) {
return { ...f, path: newPath };
}
@ -359,9 +406,11 @@ export default function PckEditorView() {
showNotification("Exporting all assets...");
for (const asset of pck.files) {
const parts = asset.path.split("/");
let currentPath = baseFolder;
const fileName = parts.join("_");
await TauriService.writeBinaryFile(`${baseFolder}/${fileName}`, asset.data);
await TauriService.writeBinaryFile(
`${baseFolder}/${fileName}`,
asset.data,
);
}
showNotification("All Assets Exported");
} catch (err: any) {
@ -377,7 +426,11 @@ export default function PckEditorView() {
try {
let targetPath = openedPath;
if (!targetPath) {
targetPath = await TauriService.saveFileDialog("Save PCK", pck.files.length > 0 ? "new.pck" : "empty.pck", ["pck"]);
targetPath = await TauriService.saveFileDialog(
"Save PCK",
pck.files.length > 0 ? "new.pck" : "empty.pck",
["pck"],
);
}
if (targetPath) {
@ -409,15 +462,24 @@ export default function PckEditorView() {
const getTypeColor = (type: PCKAssetType) => {
switch (type) {
case PCKAssetType.SKIN: return "#FFFF55";
case PCKAssetType.SKIN_DATA: return "#FFFF55";
case PCKAssetType.CAPE: return "#AA00AA";
case PCKAssetType.TEXTURE: return "#55FFFF";
case PCKAssetType.AUDIO_DATA: return "#55FF55";
case PCKAssetType.UI_DATA: return "#FFAA00";
case PCKAssetType.LOCALISATION: return "#FF55FF";
case PCKAssetType.MODELS: return "#5555FF";
default: return "#AAAAAA";
case PCKAssetType.SKIN:
return "#FFFF55";
case PCKAssetType.SKIN_DATA:
return "#FFFF55";
case PCKAssetType.CAPE:
return "#AA00AA";
case PCKAssetType.TEXTURE:
return "#55FFFF";
case PCKAssetType.AUDIO_DATA:
return "#55FF55";
case PCKAssetType.UI_DATA:
return "#FFAA00";
case PCKAssetType.LOCALISATION:
return "#FF55FF";
case PCKAssetType.MODELS:
return "#5555FF";
default:
return "#AAAAAA";
}
};
@ -440,22 +502,31 @@ export default function PckEditorView() {
<button
onClick={handleFileLoad}
className="px-6 py-2 text-white mc-text-shadow transition-all hover:text-[#FFFF55] text-lg outline-none"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
}}
>
Open PCK
</button>
<button
onClick={handleNewPCK}
className="px-6 py-2 text-white mc-text-shadow transition-all hover:text-[#FFFF55] text-lg outline-none"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
}}
>
New PCK
</button>
<button
onClick={handleExportAll}
disabled={!pck || pck.files.length === 0}
className={`px-6 py-2 text-white mc-text-shadow transition-all hover:text-[#FFFF55] text-lg outline-none ${(!pck || pck.files.length === 0) ? "opacity-50 grayscale" : ""}`}
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
className={`px-6 py-2 text-white mc-text-shadow transition-all hover:text-[#FFFF55] text-lg outline-none ${!pck || pck.files.length === 0 ? "opacity-50 grayscale" : ""}`}
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
}}
>
Export All
</button>
@ -463,7 +534,10 @@ export default function PckEditorView() {
onClick={handleSavePCK}
disabled={!pck}
className={`px-6 py-2 text-white mc-text-shadow transition-all hover:text-[#FFFF55] text-lg outline-none ${!pck ? "opacity-50 grayscale" : ""}`}
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
}}
>
Save PCK
</button>
@ -474,42 +548,86 @@ export default function PckEditorView() {
<div className="w-full flex gap-4 px-8 mb-4">
<div className="flex gap-6 bg-black/40 border-2 border-[#373737] p-3 w-full">
<div className="flex items-center gap-2">
<span className="text-white/40 text-xs uppercase font-bold tracking-widest">Endianness:</span>
<span className="text-white/40 text-xs uppercase font-bold tracking-widest">
Endianness:
</span>
<button
onClick={() => { playPressSound(); setPck({ ...pck, endianness: pck.endianness === "little" ? "big" : "little" }); }}
onClick={() => {
playPressSound();
setPck({
...pck,
endianness: pck.endianness === "little" ? "big" : "little",
});
}}
className="text-[#FFFF55] text-sm uppercase hover:underline"
>
{pck.endianness}
</button>
</div>
<div className="flex items-center gap-2">
<span className="text-white/40 text-xs uppercase font-bold tracking-widest">XML Support:</span>
<span className="text-white/40 text-xs uppercase font-bold tracking-widest">
XML Support:
</span>
<button
onClick={() => { playPressSound(); setPck({ ...pck, xmlSupport: !pck.xmlSupport }); }}
onClick={() => {
playPressSound();
setPck({ ...pck, xmlSupport: !pck.xmlSupport });
}}
className={`${pck.xmlSupport ? "text-[#FFFF55]" : "text-white/20"} text-sm uppercase hover:underline`}
>
{pck.xmlSupport ? "Enabled" : "Disabled"}
</button>
</div>
<div className="flex items-center gap-2 ml-auto">
<span className="text-white/40 text-xs uppercase font-bold tracking-widest">Version:</span>
<span className="text-white/40 text-xs uppercase font-bold tracking-widest">
Version:
</span>
<span className="text-white text-sm">{pck.version}</span>
</div>
</div>
</div>
)}
<input type="file" ref={replaceInputRef} onChange={handleReplaceAsset} className="hidden" />
<input type="file" ref={addAssetInputRef} onChange={handleAddAsset} className="hidden" />
<input
type="file"
ref={replaceInputRef}
onChange={handleReplaceAsset}
className="hidden"
/>
<input
type="file"
ref={addAssetInputRef}
onChange={handleAddAsset}
className="hidden"
/>
{!pck ? (
<div className="flex-1 w-full flex flex-col items-center justify-center p-12"
style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
<img src="/images/tools/pck.png" className="w-32 h-32 mb-8 opacity-20 grayscale" style={{ imageRendering: "pixelated" }} />
<h3 className="text-2xl text-white/40 mc-text-shadow italic">Open a PCK file to begin editing</h3>
<div
className="flex-1 w-full flex flex-col items-center justify-center p-12"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<img
src="/images/tools/pck.png"
className="w-32 h-32 mb-8 opacity-20 grayscale"
style={{ imageRendering: "pixelated" }}
/>
<h3 className="text-2xl text-white/40 mc-text-shadow italic">
Open a PCK file to begin editing
</h3>
</div>
) : (
<div className="flex-1 w-full flex gap-4 overflow-hidden">
<div className="w-2/3 flex flex-col p-4" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
<div
className="w-2/3 flex flex-col p-4"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<div className="mb-4 flex gap-4">
<input
type="text"
@ -521,7 +639,10 @@ export default function PckEditorView() {
<button
onClick={() => addAssetInputRef.current?.click()}
className="px-4 py-2 text-white mc-text-shadow text-sm shrink-0"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
}}
>
Add Asset
</button>
@ -530,11 +651,22 @@ export default function PckEditorView() {
{renderTree(treeData)}
</div>
</div>
<div className="w-1/3 flex flex-col p-6 overflow-y-auto" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
<div
className="w-1/3 flex flex-col p-6 overflow-y-auto"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<AnimatePresence mode="wait">
{!selectedAsset ? (
<div className="flex-1 flex flex-col items-center justify-center text-white/20 italic gap-4">
<img src="/images/tools/pck.png" className="w-16 h-16 opacity-10 grayscale" style={{ imageRendering: "pixelated" }} />
<img
src="/images/tools/pck.png"
className="w-16 h-16 opacity-10 grayscale"
style={{ imageRendering: "pixelated" }}
/>
<span>Select an asset to view details</span>
</div>
) : (
@ -554,8 +686,14 @@ export default function PckEditorView() {
onClick={() => setIsChangingType(!isChangingType)}
className="flex items-center gap-1.5 px-2 py-0.5 bg-white/5 hover:bg-white/10 border border-white/10 rounded-sm transition-all group/type"
>
<span className="text-[10px] uppercase tracking-widest mc-text-shadow font-bold" style={{ color: getTypeColor(selectedAsset.type) }}>
{PCKAssetType[selectedAsset.type].replace(/_/g, " ")}
<span
className="text-[10px] uppercase tracking-widest mc-text-shadow font-bold"
style={{ color: getTypeColor(selectedAsset.type) }}
>
{PCKAssetType[selectedAsset.type].replace(
/_/g,
" ",
)}
</span>
<img
src="/images/Settings_Arrow_Down.png"
@ -580,16 +718,21 @@ export default function PckEditorView() {
exit={{ opacity: 0, scale: 0.95, y: -10 }}
className="absolute top-full left-0 mt-2 z-[130] p-2 min-w-[180px] grid grid-cols-1 gap-1 shadow-2xl"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundImage:
"url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated"
imageRendering: "pixelated",
}}
>
{Object.keys(PCKAssetType)
.filter(k => isNaN(Number(k)))
.filter((k) => isNaN(Number(k)))
.map((typeName) => {
const typeVal = PCKAssetType[typeName as keyof typeof PCKAssetType];
const isActive = selectedAsset.type === typeVal;
const typeVal =
PCKAssetType[
typeName as keyof typeof PCKAssetType
];
const isActive =
selectedAsset.type === typeVal;
return (
<button
key={typeName}
@ -597,13 +740,20 @@ export default function PckEditorView() {
handleTypeChange(typeVal);
setIsChangingType(false);
}}
className={`w-full text-left px-3 py-2 text-[10px] uppercase tracking-widest transition-all border-l-2 ${isActive
? "bg-white/10 border-[#FFFF55] text-white"
: "border-transparent text-white/40 hover:text-white/80 hover:bg-white/5"
}`}
className={`w-full text-left px-3 py-2 text-[10px] uppercase tracking-widest transition-all border-l-2 ${
isActive
? "bg-white/10 border-[#FFFF55] text-white"
: "border-transparent text-white/40 hover:text-white/80 hover:bg-white/5"
}`}
>
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: getTypeColor(typeVal) }} />
<div
className="w-1.5 h-1.5 rounded-full"
style={{
backgroundColor:
getTypeColor(typeVal),
}}
/>
{typeName.replace(/_/g, " ")}
</div>
</button>
@ -616,24 +766,53 @@ export default function PckEditorView() {
</div>
</div>
<div className="flex gap-2 shrink-0">
<button onClick={() => handleMoveAsset('up')} className="hover:scale-110 active:scale-95 transition-transform">
<img src="/images/Settings_Arrow_Up.png" className="w-4 h-4 object-contain" style={{ imageRendering: "pixelated" }} />
<button
onClick={() => handleMoveAsset("up")}
className="hover:scale-110 active:scale-95 transition-transform"
>
<img
src="/images/Settings_Arrow_Up.png"
className="w-4 h-4 object-contain"
style={{ imageRendering: "pixelated" }}
/>
</button>
<button onClick={() => handleMoveAsset('down')} className="hover:scale-110 active:scale-95 transition-transform">
<img src="/images/Settings_Arrow_Down.png" className="w-4 h-4 object-contain" style={{ imageRendering: "pixelated" }} />
<button
onClick={() => handleMoveAsset("down")}
className="hover:scale-110 active:scale-95 transition-transform"
>
<img
src="/images/Settings_Arrow_Down.png"
className="w-4 h-4 object-contain"
style={{ imageRendering: "pixelated" }}
/>
</button>
</div>
</div>
{assetPreviewUrl && (
<div className="w-full h-[550px] bg-black/40 border-2 border-[#373737] mb-6 flex items-center justify-center overflow-hidden relative group">
{(selectedAsset.type === PCKAssetType.SKIN || selectedAsset.type === PCKAssetType.CAPE || selectedAsset.type === PCKAssetType.SKIN_DATA) ? (
<SkinPreview3D key={selectedAsset.id} asset={selectedAsset} previewUrl={assetPreviewUrl || undefined} className="w-full h-full" />
{selectedAsset.type === PCKAssetType.SKIN ||
selectedAsset.type === PCKAssetType.CAPE ||
selectedAsset.type === PCKAssetType.SKIN_DATA ? (
<SkinPreview3D
key={selectedAsset.id}
asset={selectedAsset}
previewUrl={assetPreviewUrl || undefined}
className="w-full h-full"
/>
) : (
<img src={assetPreviewUrl} className="max-w-full max-h-full object-contain" style={{ imageRendering: "pixelated" }} />
<img
src={assetPreviewUrl}
className="max-w-full max-h-full object-contain"
style={{ imageRendering: "pixelated" }}
/>
)}
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-black/60 px-2 py-1 rounded text-[10px] text-white/60 pointer-events-none uppercase tracking-widest">
{selectedAsset.type === PCKAssetType.SKIN ? "3D Skin View" : selectedAsset.type === PCKAssetType.CAPE ? "3D Cape View" : "Texture Preview"}
{selectedAsset.type === PCKAssetType.SKIN
? "3D Skin View"
: selectedAsset.type === PCKAssetType.CAPE
? "3D Cape View"
: "Texture Preview"}
</div>
</div>
)}
@ -641,7 +820,9 @@ export default function PckEditorView() {
<div className="space-y-6 flex-1">
<div>
<div className="flex justify-between items-end mb-2 px-1">
<div className="text-white/40 text-[10px] uppercase tracking-widest text-[#FFFF55]/60">Metadata Properties</div>
<div className="text-white/40 text-[10px] uppercase tracking-widest text-[#FFFF55]/60">
Metadata Properties
</div>
<button
onClick={handleAddProperty}
className="text-[#FFFF55] text-[10px] uppercase hover:underline"
@ -651,12 +832,17 @@ export default function PckEditorView() {
</div>
<div className="space-y-4">
{selectedAsset.properties.map((prop, idx) => (
<div key={idx} className="flex flex-col gap-1 group/prop">
<div
key={idx}
className="flex flex-col gap-1 group/prop"
>
<div className="flex justify-between items-center px-1">
<input
type="text"
value={prop.key}
onChange={(e) => handlePropertyEdit(idx, e.target.value, true)}
onChange={(e) =>
handlePropertyEdit(idx, e.target.value, true)
}
className="bg-transparent text-white/40 text-[10px] outline-none hover:text-white/60 focus:text-[#FFFF55] w-2/3"
/>
<button
@ -670,7 +856,9 @@ export default function PckEditorView() {
<input
type="text"
value={prop.value}
onChange={(e) => handlePropertyEdit(idx, e.target.value)}
onChange={(e) =>
handlePropertyEdit(idx, e.target.value)
}
className="w-full bg-black/40 p-2 text-white border border-[#373737] text-sm focus:border-[#FFFF55] outline-none transition-colors"
/>
</div>
@ -694,7 +882,10 @@ export default function PckEditorView() {
{ label: "04: No Body", flag: 0x2000 },
{ label: "04: No R. Leg", flag: 0x4000 },
{ label: "04: No L. Leg", flag: 0x8000 },
{ label: "05: No Head Overlay", flag: 0x10000 },
{
label: "05: No Head Overlay",
flag: 0x10000,
},
{ label: "05: Back Crouch", flag: 0x20000 },
{ label: "05: Modern Skin", flag: 0x40000 },
{ label: "05: Slim Skin", flag: 0x80000 },
@ -703,18 +894,40 @@ export default function PckEditorView() {
{ label: "06: No L. Pant", flag: 0x400000 },
{ label: "06: No R. Pant", flag: 0x800000 },
{ label: "07: No Jacket", flag: 0x1000000 },
{ label: "07: Rend Head Arm", flag: 0x2000000 },
{ label: "07: Rend R.Arm Arm", flag: 0x4000000 },
{ label: "07: Rend L.Arm Arm", flag: 0x8000000 },
{ label: "08: Rend Body Arm", flag: 0x10000000 },
{ label: "08: Rend R.Leg Arm", flag: 0x20000000 },
{ label: "08: Rend L.Leg Arm", flag: 0x40000000 },
{
label: "07: Rend Head Arm",
flag: 0x2000000,
},
{
label: "07: Rend R.Arm Arm",
flag: 0x4000000,
},
{
label: "07: Rend L.Arm Arm",
flag: 0x8000000,
},
{
label: "08: Rend Body Arm",
flag: 0x10000000,
},
{
label: "08: Rend R.Leg Arm",
flag: 0x20000000,
},
{
label: "08: Rend L.Leg Arm",
flag: 0x40000000,
},
{ label: "08: Dinnerbone", flag: 0x80000000 },
].map((item) => {
const currentVal = parseInt(prop.value) || 0;
const isChecked = (currentVal & item.flag) !== 0;
const isChecked =
(currentVal & item.flag) !== 0;
return (
<label key={item.label} className="flex items-center gap-2 cursor-pointer group/flag">
<label
key={item.label}
className="flex items-center gap-2 cursor-pointer group/flag"
>
<input
type="checkbox"
checked={isChecked}
@ -722,12 +935,19 @@ export default function PckEditorView() {
const newVal = e.target.checked
? currentVal | item.flag
: currentVal & ~item.flag;
handlePropertyEdit(idx, `0x${newVal.toString(16).toUpperCase().padStart(8, "0")}`);
handlePropertyEdit(
idx,
`0x${newVal.toString(16).toUpperCase().padStart(8, "0")}`,
);
}}
className="hidden"
/>
<div className={`w-3 h-3 border transition-colors ${isChecked ? "bg-[#FFFF55] border-[#FFFF55]" : "border-white/20 group-hover/flag:border-white/40"}`} />
<span className={`text-[9px] uppercase tracking-tight ${isChecked ? "text-[#FFFF55]" : "text-white/40 group-hover/flag:text-white/60"}`}>
<div
className={`w-3 h-3 border transition-colors ${isChecked ? "bg-[#FFFF55] border-[#FFFF55]" : "border-white/20 group-hover/flag:border-white/40"}`}
/>
<span
className={`text-[9px] uppercase tracking-tight ${isChecked ? "text-[#FFFF55]" : "text-white/40 group-hover/flag:text-white/60"}`}
>
{item.label}
</span>
</label>
@ -738,7 +958,9 @@ export default function PckEditorView() {
</div>
))}
{selectedAsset.properties.length === 0 && (
<div className="text-white/20 italic text-sm px-1 py-4 border-2 border-dashed border-[#373737] text-center">No metadata properties</div>
<div className="text-white/20 italic text-sm px-1 py-4 border-2 border-dashed border-[#373737] text-center">
No metadata properties
</div>
)}
</div>
</div>
@ -748,14 +970,22 @@ export default function PckEditorView() {
<button
onClick={() => handleExportAsset(selectedAsset)}
className="py-2 text-white mc-text-shadow text-sm transition-all hover:text-[#FFFF55]"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
style={{
backgroundImage:
"url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
}}
>
Export
</button>
<button
onClick={() => replaceInputRef.current?.click()}
className="py-2 text-white mc-text-shadow text-sm transition-all hover:text-[#FFFF55]"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
style={{
backgroundImage:
"url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
}}
>
Replace
</button>
@ -763,14 +993,22 @@ export default function PckEditorView() {
<button
onClick={() => setIsRenamingAsset(selectedAsset.id)}
className="w-full py-2 text-white mc-text-shadow text-sm transition-all hover:text-[#FFFF55]"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
style={{
backgroundImage:
"url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
}}
>
Rename Asset (Path)
</button>
<button
onClick={() => handleDeleteAsset(selectedAsset.id)}
className="w-full py-2 text-red-500/80 mc-text-shadow text-sm transition-all hover:text-red-500 hover:scale-[1.02]"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
style={{
backgroundImage:
"url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
}}
>
Delete This Asset
</button>
@ -808,7 +1046,7 @@ export default function PckEditorView() {
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated"
imageRendering: "pixelated",
}}
>
<span className="text-white text-lg mc-text-shadow font-bold tracking-widest uppercase">
@ -836,17 +1074,23 @@ export default function PckEditorView() {
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated"
imageRendering: "pixelated",
}}
>
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow font-bold mb-6 tracking-widest uppercase">Select Asset Type</h3>
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow font-bold mb-6 tracking-widest uppercase">
Select Asset Type
</h3>
<div className="grid grid-cols-2 gap-4 w-full">
{Object.keys(PCKAssetType)
.filter(k => isNaN(Number(k)))
.filter((k) => isNaN(Number(k)))
.map((typeName) => (
<button
key={typeName}
onClick={() => confirmAddAsset(PCKAssetType[typeName as keyof typeof PCKAssetType])}
onClick={() =>
confirmAddAsset(
PCKAssetType[typeName as keyof typeof PCKAssetType],
)
}
className="py-3 px-4 text-white mc-text-shadow text-sm transition-all hover:text-[#FFFF55] border-2 border-transparent hover:border-[#FFFF55]/30 bg-black/40"
>
{typeName.replace(/_/g, " ")}
@ -866,7 +1110,9 @@ export default function PckEditorView() {
<AnimatePresence>
{isRenamingAsset && (
<RenameAssetModal
initialPath={pck?.files.find(f => f.id === isRenamingAsset)?.path || ""}
initialPath={
pck?.files.find((f) => f.id === isRenamingAsset)?.path || ""
}
onClose={() => setIsRenamingAsset(null)}
onConfirm={(newPath) => {
handleRenameAsset(isRenamingAsset, newPath);
@ -879,7 +1125,15 @@ export default function PckEditorView() {
);
}
function RenameAssetModal({ initialPath, onClose, onConfirm }: { initialPath: string, onClose: () => void, onConfirm: (path: string) => void }) {
function RenameAssetModal({
initialPath,
onClose,
onConfirm,
}: {
initialPath: string;
onClose: () => void;
onConfirm: (path: string) => void;
}) {
const [path, setPath] = useState(initialPath);
return (
<div className="fixed inset-0 z-[150] flex items-center justify-center p-4">
@ -898,13 +1152,17 @@ function RenameAssetModal({ initialPath, onClose, onConfirm }: { initialPath: st
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated"
imageRendering: "pixelated",
}}
>
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow font-bold mb-6 tracking-widest uppercase">Rename Asset</h3>
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow font-bold mb-6 tracking-widest uppercase">
Rename Asset
</h3>
<div className="flex flex-col gap-4">
<div>
<label className="text-white/40 text-[10px] uppercase tracking-widest mb-1 block">New Asset Path</label>
<label className="text-white/40 text-[10px] uppercase tracking-widest mb-1 block">
New Asset Path
</label>
<input
type="text"
value={path}
@ -923,7 +1181,10 @@ function RenameAssetModal({ initialPath, onClose, onConfirm }: { initialPath: st
<button
onClick={() => onConfirm(path)}
className="px-8 py-2 text-white mc-text-shadow transition-all hover:text-[#FFFF55] text-lg outline-none"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
style={{
backgroundImage: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
}}
>
Rename
</button>

View file

@ -1,50 +1,157 @@
import { useState, useEffect, useRef, useCallback } from "react";
const TRACKS = [
"/music/Blind Spots.ogg",
"/music/Key.ogg",
"/music/Living Mice.ogg",
"/music/Oxygene.ogg",
"/music/Subwoofer Lullaby.ogg",
"music/Blind Spots.ogg",
"music/Key.ogg",
"music/Living Mice.ogg",
"music/Oxygene.ogg",
"music/Subwoofer Lullaby.ogg",
];
const resolveAudioUrl = (path: string) => {
const relativePath = path.startsWith("/") ? path.slice(1) : path;
return new URL(relativePath, window.location.origin).href;
};
const SPLASHES = [
"Legacy is back!", "Pixelated goodness!", "Console Edition vibe!", "100% Not Microsoft!",
"Symmetry is key!", "Does anyone even read these?", "Task failed successfully.",
"Hardware accelerated!", "It's a feature, not a bug.", "Look behind you.",
"Works on my machine.", "Now gluten-free!", "Mom, get the camera!", "Batteries not included.",
"May contain nuts.", "Press Alt+F4 for diamonds!", "Downloading more RAM...",
"Reinventing the wheel!", "The cake is a lie.", "Powered by copious amounts of coffee.",
"I'm running out of ideas.", "That's no moon...", "Now with 100% more nostalgia!",
"Legacy is the new modern.", "No microtransactions!", "As seen on TV!", "Ironic, isn't it?",
"Creeper? Aww man.", "Technoblade never dies!", "is smartcmd dead ?", "NO BUILT IN MS AUTH !",
"Mr_Anilex wasn't here!", "Who's Jack ?", "This text is blue!", "Bonjour!", "Hola!",
"Salam!", "Hi!", "Reverse engineering WiiU version", "Try Terraria", "Don't try Valorant",
"This could never be a sad place!", "Made without microslop", "Thank you C418", "Bread is pain",
"From the star!", "Never gonna give you up!", "9+10=21", ".party() was successful", "Not Kogama",
"You can be proud of you!", "Let's drink Orange Joe", "Kirater is a great singer!",
"Mirkette My beloved", "Started in Bordeaux", "Oui Oui Baguette", "Milk In The Microwave",
"8-3: DISINTEGRATION LOOP", "Turn the light OFF", "Not written by Mr_Anilex",
"The One Who's Running the Show!", "Playing Forever", "The World looks cubic!", "huh?",
"Sybau", "Available on Toaster", "Try ArchLinux", "67% Accurate", "A molecule of meow",
"http://localhost:3000", "uuhhhh...", "Oyasumi", "XDDCC", "I don't want to set the world on fire",
"Directed by Michael Bay", "We see you, Opal!", "A Cool Cat in Town", "pikmin",
"Not BrainRotted!", "Farting is Natural -Leon", "93/100 on metacritic", "Not (anymore) on Steam",
"Sudo apt install EmeraldLauncher", "Sudo pacman -S EmeraldLauncher", "Kay-Chan my beloved! <3",
"Peak!", "OpenSource!", "made by human with bone and flesh", "Made with hate against microslop",
"Steelorse :fire:", "It's Minecraft but i'm not sure", "Look at you!", "You're beautiful",
"Mr_Anilex has a big ego", "Traduis-moi !", "May contains Mr_Anilex", "Neoapps didn't write this splash",
"Where's Kinger ?", "KayJann, Breakcore and code", "Hey Goku!", "Vegeta is a DZ mashallah",
"Bogos Binted? Vorp", "YOU SHALL NOT PASS !", "Bready, Steady, GO !", "Not-so-Empty-house",
"We'll Meet Again", "idk", "wdym", "Not making sense", "Dw!", "i forgor", "Remember to be patient!",
"NOW'S YOUR CHANCE TO BE A.", "BIG SHOT", "A burning memory", "FREE MONEY!",
"Legacy is back!",
"Pixelated goodness!",
"Console Edition vibe!",
"100% Not Microsoft!",
"Symmetry is key!",
"Does anyone even read these?",
"Task failed successfully.",
"Hardware accelerated!",
"It's a feature, not a bug.",
"Look behind you.",
"Works on my machine.",
"Now gluten-free!",
"Mom, get the camera!",
"Batteries not included.",
"May contain nuts.",
"Press Alt+F4 for diamonds!",
"Downloading more RAM...",
"Reinventing the wheel!",
"The cake is a lie.",
"Powered by copious amounts of coffee.",
"I'm running out of ideas.",
"That's no moon...",
"Now with 100% more nostalgia!",
"Legacy is the new modern.",
"No microtransactions!",
"As seen on TV!",
"Ironic, isn't it?",
"Creeper? Aww man.",
"Technoblade never dies!",
"is smartcmd dead ?",
"NO BUILT IN MS AUTH !",
"Mr_Anilex wasn't here!",
"Who's Jack ?",
"This text is blue!",
"Bonjour!",
"Salam!",
"Reverse engineering Wii U version",
"Don't try Valorant!",
"This could never be a sad place!",
"Made without microslop",
"Thank you C418",
"Bread is pain",
"From the star!",
"Never gonna give you up!",
"9+10=21",
".party() was successful",
"Not Kogama",
"You can be proud of you!",
"Let's drink Orange Joe",
"Kirater is a great singer!",
"Mirkette My beloved",
"Started in Bordeaux",
"Oui Oui Baguette",
"Milk In The Microwave",
"8-3: DISINTEGRATION LOOP",
"Turn the light OFF",
"Not written by Mr_Anilex",
"The One Who's Running the Show!",
"Playing Forever",
"The World looks cubic!",
"huh?",
"Sybau",
"Available on Toaster",
"Try ArchLinux",
"69% Accurate",
"A molecule of meow",
"http://localhost:3000",
"uuhhhh...",
"Oyasumi",
"I don't want to set the world on fire",
"Directed by Michael Bay",
"We see you, Opal!",
"A Cool Cat in Town",
"Not BrainRotted!",
"Farting is Natural -Leon",
"93/100 on metacritic",
"Not (anymore) on Steam",
"Sudo apt install EmeraldLauncher",
"Sudo pacman -S EmeraldLauncher",
"Kay-Chan my beloved! <3",
"Peak!",
"OpenSource!",
"made by human with bone and flesh",
"Made with hate against microslop",
"Steelorse :fire:",
"It's Minecraft but i'm not sure",
"Look at you!",
"You're beautiful",
"Mr_Anilex has a big ego",
"Traduis-moi !",
"May contains Mr_Anilex",
"Neoapps didn't write this splash",
"Where's Kinger ?",
"KayJann, Breakcore and code",
"Hey Goku!",
"Vegeta is a DZ mashallah",
"Bogos Binted? Vorp",
"YOU SHALL NOT PASS !",
"Bready, Steady, GO !",
"Not-so-Empty-house",
"We'll Meet Again",
"idk",
"wdym",
"Not making sense",
"Dw!",
"i forgor",
"Remember to be patient!",
"NOW'S YOUR CHANCE TO BE A.",
"BIG SHOT",
"A burning memory",
"FREE MONEY!",
"Can You Really Call This A Hotel. I didn't Reveive A Mint On My Pillow Or Anything",
"Try Indie Game", "SHARK WITH LEGS!", "it's a seal!", "Shrimp.", "Limited edition!",
"Fat free!", "GOTY!", "Water proof!", "LALALA-LAVA", "CHICHICHI-CHICKEN", "Tasty ah hell",
"1% sugar!", "150% hyperbole!", "Hotter than the sun!", "Woo, reddit!",
"J'ai fait une blague à un poisson il m'a dit que c'était trop marin !",
"piebot was here!", "Legacy in an evolved manner.", "neoapps is cool!", "Also try neoLegacy!",
"$20 is $20", "no RenderDragon!", "Iggy Jiggy", "Arch, btw!", "Bedrock bad!"
"Try Indie Game",
"SHARK WITH LEGS!",
"it's a seal!",
"Shrimp.",
"Limited edition!",
"Fat free!",
"GOTY!",
"Water proof!",
"LALALA-LAVA",
"CHICHICHI-CHICKEN",
"Tasty ah hell",
"1% sugar!",
"150% hyperbole!",
"Hotter than the sun!",
"Woo, reddit!",
"piebot was here!",
"Legacy in an evolved manner.",
"neoapps is cool!",
"Also try neoLegacy!",
"$20 is $20",
"no RenderDragon!",
"Iggy Jiggy",
"Arch, btw!",
"Bedrock bad!",
"Also try Terraria!",
"Also try LC Launcher!",
];
interface AudioControllerProps {
@ -55,68 +162,215 @@ interface AudioControllerProps {
isWindowVisible: boolean;
}
export function useAudioController({ musicVol, sfxVol, showIntro, isGameRunning, isWindowVisible }: AudioControllerProps) {
export function useAudioController({
musicVol,
sfxVol,
showIntro,
isGameRunning,
isWindowVisible,
}: AudioControllerProps) {
const [currentTrack, setCurrentTrack] = useState(0);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
const [splashIndex, setSplashIndex] = useState(-1);
const audioContextRef = useRef<AudioContext | null>(null);
const musicSourceRef = useRef<AudioBufferSourceNode | null>(null);
const musicGainRef = useRef<GainNode | null>(null);
const trackBuffersRef = useRef<Map<number, AudioBuffer>>(new Map());
const sfxBufferCacheRef = useRef<Map<string, AudioBuffer>>(new Map());
const musicPausedRef = useRef<{ at: number; track: number } | null>(null);
const fadeIntervalRef = useRef<any>(null);
const playSfx = useCallback((file: string) => {
const a = new Audio(`/sounds/${file}`);
a.volume = sfxVol / 100;
a.play().catch(() => { });
}, [sfxVol]);
const fadeIntervalRef = useRef<number | null>(null);
const targetVolumeRef = useRef(musicVol / 100);
const getAudioContext = useCallback(() => {
if (!audioContextRef.current) {
audioContextRef.current = new AudioContext();
}
if (audioContextRef.current.state === "suspended") {
audioContextRef.current.resume();
}
return audioContextRef.current;
}, []);
const loadAudioBuffer = useCallback(
async (url: string): Promise<AudioBuffer | undefined> => {
try {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const ctx = getAudioContext();
return await ctx.decodeAudioData(arrayBuffer);
} catch (error) {
console.error("Failed to load audio:", url, error);
return undefined;
}
},
[getAudioContext],
);
const playSfx = useCallback(
async (file: string) => {
const url = resolveAudioUrl(`/sounds/${file}`);
let buffer = sfxBufferCacheRef.current.get(file);
if (!buffer) {
buffer = await loadAudioBuffer(url);
if (buffer) {
sfxBufferCacheRef.current.set(file, buffer);
}
}
if (buffer) {
const ctx = getAudioContext();
const source = ctx.createBufferSource();
const gainNode = ctx.createGain();
source.buffer = buffer;
gainNode.gain.value = sfxVol / 100;
source.connect(gainNode);
gainNode.connect(ctx.destination);
source.start();
}
},
[sfxVol, loadAudioBuffer, getAudioContext],
);
const playPressSound = useCallback(() => playSfx("press.wav"), [playSfx]);
const playBackSound = useCallback(() => playSfx("back.ogg"), [playSfx]);
const playSplashSound = useCallback(() => playSfx("orb.ogg"), [playSfx]);
const fadeOut = useCallback((audio: HTMLAudioElement, duration: number = 500) => {
return new Promise<void>((resolve) => {
if (fadeIntervalRef.current) clearInterval(fadeIntervalRef.current);
const initialVolume = audio.volume;
const steps = 5;
const stepDuration = duration / steps;
let currentStep = 0;
fadeIntervalRef.current = setInterval(() => {
currentStep++;
const progress = currentStep / steps;
audio.volume = initialVolume * (1 - progress);
if (currentStep >= steps) {
if (fadeIntervalRef.current) clearInterval(fadeIntervalRef.current);
fadeIntervalRef.current = null;
audio.pause();
audio.volume = initialVolume;
resolve();
}
}, stepDuration);
});
const stopMusic = useCallback(() => {
if (musicSourceRef.current) {
try {
musicSourceRef.current.stop();
} catch (e) {}
musicSourceRef.current.disconnect();
musicSourceRef.current = null;
}
if (fadeIntervalRef.current) {
clearInterval(fadeIntervalRef.current);
fadeIntervalRef.current = null;
}
}, []);
const fadeIn = useCallback((audio: HTMLAudioElement, targetVolume: number, duration: number = 500) => {
return new Promise<void>((resolve) => {
if (fadeIntervalRef.current) clearInterval(fadeIntervalRef.current);
audio.volume = 0;
const playPromise = audio.play();
if (playPromise !== undefined) {
playPromise.catch(() => { });
}
const playMusicBuffer = useCallback(
(buffer: AudioBuffer, startTime: number = 0) => {
stopMusic();
const ctx = getAudioContext();
const source = ctx.createBufferSource();
const gainNode = ctx.createGain();
source.buffer = buffer;
gainNode.gain.value = 0;
source.connect(gainNode);
gainNode.connect(ctx.destination);
const offset = startTime % buffer.duration;
source.start(0, offset);
musicSourceRef.current = source;
musicGainRef.current = gainNode;
const steps = 5;
const stepDuration = 100;
let currentStep = 0;
fadeIntervalRef.current = window.setInterval(() => {
currentStep++;
const progress = currentStep / steps;
if (musicGainRef.current) {
musicGainRef.current.gain.value = targetVolumeRef.current * progress;
}
if (currentStep >= steps) {
clearInterval(fadeIntervalRef.current || undefined);
fadeIntervalRef.current = null;
if (musicGainRef.current) {
musicGainRef.current.gain.value = targetVolumeRef.current;
}
}
}, stepDuration);
source.onended = () => {
if (musicSourceRef.current) {
setCurrentTrack((prev) => (prev + 1) % TRACKS.length);
}
};
},
[stopMusic, getAudioContext],
);
const fadeOutMusic = useCallback(
(duration: number = 500): Promise<void> => {
return new Promise((resolve) => {
if (fadeIntervalRef.current) clearInterval(fadeIntervalRef.current);
if (!musicGainRef.current) {
resolve();
return;
}
const steps = 5;
const stepDuration = duration / steps;
const initialVolume = musicGainRef.current.gain.value;
let currentStep = 0;
fadeIntervalRef.current = window.setInterval(() => {
currentStep++;
const progress = currentStep / steps;
if (musicGainRef.current) {
musicGainRef.current.gain.value = initialVolume * (1 - progress);
}
if (currentStep >= steps) {
clearInterval(fadeIntervalRef.current || undefined);
fadeIntervalRef.current = null;
stopMusic();
if (musicGainRef.current) {
musicGainRef.current.gain.value = initialVolume;
}
resolve();
}
}, stepDuration);
});
},
[stopMusic],
);
const fadeInMusic = useCallback(
(buffer: AudioBuffer, targetVolume: number, duration: number = 500) => {
stopMusic();
const ctx = getAudioContext();
const source = ctx.createBufferSource();
const gainNode = ctx.createGain();
source.buffer = buffer;
gainNode.gain.value = 0;
source.connect(gainNode);
gainNode.connect(ctx.destination);
source.start();
musicSourceRef.current = source;
musicGainRef.current = gainNode;
targetVolumeRef.current = targetVolume;
const steps = 5;
const stepDuration = duration / steps;
let currentStep = 0;
fadeIntervalRef.current = setInterval(() => {
fadeIntervalRef.current = window.setInterval(() => {
currentStep++;
const progress = currentStep / steps;
audio.volume = targetVolume * progress;
if (musicGainRef.current) {
musicGainRef.current.gain.value = targetVolume * progress;
}
if (currentStep >= steps) {
if (fadeIntervalRef.current) clearInterval(fadeIntervalRef.current);
clearInterval(fadeIntervalRef.current || undefined);
fadeIntervalRef.current = null;
audio.volume = targetVolume;
resolve();
if (musicGainRef.current) {
musicGainRef.current.gain.value = targetVolume;
}
}
}, stepDuration);
});
}, []);
source.onended = () => {
if (musicSourceRef.current) {
setCurrentTrack((prev) => (prev + 1) % TRACKS.length);
}
};
},
[stopMusic, getAudioContext],
);
const cycleSplash = useCallback(() => {
playSplashSound();
@ -129,66 +383,105 @@ export function useAudioController({ musicVol, sfxVol, showIntro, isGameRunning,
useEffect(() => {
if (showIntro) return;
if (audioElement) return;
const audio = new Audio(TRACKS[currentTrack]);
audio.volume = musicVol / 100;
const handleEnded = () => setCurrentTrack((prev) => (prev + 1) % TRACKS.length);
audio.addEventListener("ended", handleEnded);
const playPromise = audio.play();
if (playPromise !== undefined) {
playPromise.catch(() => {
const startMusic = () => {
audio.play().catch(() => { });
document.removeEventListener("click", startMusic);
document.removeEventListener("keydown", startMusic);
};
document.addEventListener("click", startMusic, { once: true });
document.addEventListener("keydown", startMusic, { once: true });
});
}
setAudioElement(audio);
return () => {
audio.removeEventListener("ended", handleEnded);
audio.pause();
const loadAndPlay = async () => {
let buffer = trackBuffersRef.current.get(currentTrack);
if (!buffer) {
buffer = await loadAudioBuffer(resolveAudioUrl(TRACKS[currentTrack]));
if (buffer) {
trackBuffersRef.current.set(currentTrack, buffer);
}
}
if (buffer) {
playMusicBuffer(buffer);
}
};
}, [showIntro, audioElement, currentTrack, musicVol]);
loadAndPlay();
return () => {
stopMusic();
};
}, [showIntro, currentTrack, loadAudioBuffer, playMusicBuffer, stopMusic]);
useEffect(() => {
if (!audioElement) return;
audioElement.src = TRACKS[currentTrack];
audioElement.play().catch(() => { });
}, [currentTrack]);
if (!audioContextRef.current || showIntro) return;
const loadAndPlay = async () => {
let buffer = trackBuffersRef.current.get(currentTrack);
if (!buffer) {
buffer = await loadAudioBuffer(resolveAudioUrl(TRACKS[currentTrack]));
if (buffer) {
trackBuffersRef.current.set(currentTrack, buffer);
}
}
if (buffer) {
fadeInMusic(buffer, musicVol / 100, 500);
}
};
loadAndPlay();
}, [currentTrack, showIntro, musicVol, loadAudioBuffer, fadeInMusic]);
useEffect(() => {
if (!audioElement) return;
const shouldPause = isGameRunning || !isWindowVisible;
if (shouldPause) {
if (!audioElement.paused || fadeIntervalRef.current) {
if (musicSourceRef.current || fadeIntervalRef.current) {
if (!musicPausedRef.current) {
musicPausedRef.current = {
at: audioElement.currentTime,
track: currentTrack,
};
const ctx = getAudioContext();
if (musicGainRef.current) {
musicPausedRef.current = {
at: ctx.currentTime,
track: currentTrack,
};
} else {
musicPausedRef.current = { at: 0, track: currentTrack };
}
}
fadeOut(audioElement, 500);
fadeOutMusic(500);
}
} else if (musicPausedRef.current) {
const { at, track } = musicPausedRef.current;
const { track } = musicPausedRef.current;
musicPausedRef.current = null;
targetVolumeRef.current = musicVol / 100;
const playWithPos = async () => {
let buffer = trackBuffersRef.current.get(currentTrack);
if (!buffer) {
buffer = await loadAudioBuffer(resolveAudioUrl(TRACKS[currentTrack]));
if (buffer) {
trackBuffersRef.current.set(currentTrack, buffer);
}
}
if (buffer) {
fadeInMusic(buffer, musicVol / 100, 500);
}
};
if (track === currentTrack) {
audioElement.currentTime = at;
playWithPos();
} else {
setCurrentTrack(track);
}
fadeIn(audioElement, musicVol / 100, 500);
}
}, [isGameRunning, isWindowVisible, audioElement, currentTrack, musicVol, fadeOut, fadeIn]);
}, [
isGameRunning,
isWindowVisible,
currentTrack,
musicVol,
fadeOutMusic,
fadeInMusic,
loadAudioBuffer,
getAudioContext,
]);
useEffect(() => {
if (audioElement) {
audioElement.volume = musicVol / 100;
targetVolumeRef.current = musicVol / 100;
if (musicGainRef.current && !fadeIntervalRef.current) {
musicGainRef.current.gain.value = musicVol / 100;
}
}, [musicVol, audioElement]);
}, [musicVol]);
return {
currentTrack,