diff --git a/src/components/views/PckEditorView.tsx b/src/components/views/PckEditorView.tsx index cc373f2..21e8e46 100644 --- a/src/components/views/PckEditorView.tsx +++ b/src/components/views/PckEditorView.tsx @@ -13,10 +13,21 @@ export default function PckEditorView() { const [openedPath, setOpenedPath] = useState(null); const [selectedAssetId, setSelectedAssetId] = useState(null); const [searchTerm, setSearchTerm] = useState(""); - const [isEditingProperty, setIsEditingProperty] = useState<{ idx: number, key: string, val: string } | null>(null); - const [expandedFolders, setExpandedFolders] = useState>(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>( + 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(null); const replaceInputRef = useRef(null); @@ -32,8 +43,12 @@ export default function PckEditorView() { } const root: Record = {}; - 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 ? ( @@ -130,7 +164,11 @@ export default function PckEditorView() {
)} @@ -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() { @@ -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 @@ -474,42 +548,86 @@ export default function PckEditorView() {
- Endianness: + + Endianness: +
- XML Support: + + XML Support: +
- Version: + + Version: + {pck.version}
)} - - + + {!pck ? ( -
- -

Open a PCK file to begin editing

+
+ +

+ Open a PCK file to begin editing +

) : (
-
+
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 @@ -530,11 +651,22 @@ export default function PckEditorView() { {renderTree(treeData)}
-
+
{!selectedAsset ? (
- + Select an asset to view details
) : ( @@ -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" > - - {PCKAssetType[selectedAsset.type].replace(/_/g, " ")} + + {PCKAssetType[selectedAsset.type].replace( + /_/g, + " ", + )} {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 ( @@ -616,24 +766,53 @@ export default function PckEditorView() {
- -
{assetPreviewUrl && (
- {(selectedAsset.type === PCKAssetType.SKIN || selectedAsset.type === PCKAssetType.CAPE || selectedAsset.type === PCKAssetType.SKIN_DATA) ? ( - + {selectedAsset.type === PCKAssetType.SKIN || + selectedAsset.type === PCKAssetType.CAPE || + selectedAsset.type === PCKAssetType.SKIN_DATA ? ( + ) : ( - + )}
- {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"}
)} @@ -641,7 +820,9 @@ export default function PckEditorView() {
-
Metadata Properties
+
+ Metadata Properties +
@@ -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 ( -
@@ -748,14 +970,22 @@ export default function PckEditorView() { @@ -763,14 +993,22 @@ export default function PckEditorView() { @@ -808,7 +1046,7 @@ export default function PckEditorView() { style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", - imageRendering: "pixelated" + imageRendering: "pixelated", }} > @@ -836,17 +1074,23 @@ export default function PckEditorView() { style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", - imageRendering: "pixelated" + imageRendering: "pixelated", }} > -

Select Asset Type

+

+ Select Asset Type +

{Object.keys(PCKAssetType) - .filter(k => isNaN(Number(k))) + .filter((k) => isNaN(Number(k))) .map((typeName) => ( diff --git a/src/hooks/useAudioController.ts b/src/hooks/useAudioController.ts index f752ad0..7e5a359 100644 --- a/src/hooks/useAudioController.ts +++ b/src/hooks/useAudioController.ts @@ -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(null); const [splashIndex, setSplashIndex] = useState(-1); + const audioContextRef = useRef(null); + const musicSourceRef = useRef(null); + const musicGainRef = useRef(null); + const trackBuffersRef = useRef>(new Map()); + const sfxBufferCacheRef = useRef>(new Map()); const musicPausedRef = useRef<{ at: number; track: number } | null>(null); - const fadeIntervalRef = useRef(null); - const playSfx = useCallback((file: string) => { - const a = new Audio(`/sounds/${file}`); - a.volume = sfxVol / 100; - a.play().catch(() => { }); - }, [sfxVol]); + const fadeIntervalRef = useRef(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 => { + 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((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((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 => { + 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,