A decentralized music tracking and discovery platform built on AT Protocol 🎵

Use WebSocket for live feed and now playing

Add VITE_WS_URL and WS_URL constant. Remove polling refetchInterval from
feed and now-playing queries and update React Query cache via WebSocket
messages. Implement ping/pong heartbeat and proper socket cleanup on
unmount.

+77 -5
+2 -1
apps/web/.env.example
··· 1 - VITE_API_URL=http://localhost:3004 1 + VITE_API_URL=http://localhost:3004 2 + VITE_WS_URL=ws://localhost:8002
+1
apps/web/src/consts.ts
··· 1 1 export const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"; 2 + export const WS_URL = import.meta.env.VITE_WS_URL || "ws://localhost:8002"; 2 3 3 4 export const AUDIO_EXTENSIONS = [ 4 5 "mp3",
-1
apps/web/src/hooks/useFeed.tsx
··· 9 9 client.get("/xrpc/app.rocksky.scrobble.getScrobbles", { 10 10 params: { limit }, 11 11 }), 12 - refetchInterval: 7000, 13 12 select: (res) => res.data.scrobbles || [], 14 13 }); 15 14
-1
apps/web/src/hooks/useNowPlaying.tsx
··· 24 24 "/xrpc/app.rocksky.feed.getNowPlayings", 25 25 { params: { size: 7 } }, 26 26 ), 27 - refetchInterval: 6000, 28 27 select: (res) => res.data.nowPlayings || [], 29 28 });
+37 -1
apps/web/src/pages/home/feed/Feed.tsx
··· 11 11 import Handle from "../../../components/Handle"; 12 12 import SongCover from "../../../components/SongCover"; 13 13 import { useFeedQuery } from "../../../hooks/useFeed"; 14 + import { useEffect, useRef } from "react"; 15 + import { WS_URL } from "../../../consts"; 16 + import { useQueryClient } from "@tanstack/react-query"; 14 17 15 18 dayjs.extend(relativeTime); 16 19 ··· 28 31 `; 29 32 30 33 function Feed() { 34 + const queryClient = useQueryClient(); 35 + const socketRef = useRef<WebSocket | null>(null); 36 + const heartbeatInterval = useRef<number | null>(null); 31 37 const { data, isLoading } = useFeedQuery(); 32 - console.log(data); 38 + 39 + useEffect(() => { 40 + const ws = new WebSocket(`${WS_URL.replace("http", "ws")}/ws`); 41 + socketRef.current = ws; 42 + 43 + ws.onopen = () => { 44 + heartbeatInterval.current = window.setInterval(() => { 45 + ws.send("ping"); 46 + }, 3000); 47 + }; 48 + 49 + ws.onmessage = (event) => { 50 + if (event.data === "pong") { 51 + return; 52 + } 53 + 54 + const message = JSON.parse(event.data); 55 + queryClient.setQueryData(["feed"], message.scrobbles); 56 + }; 57 + 58 + return () => { 59 + if (ws) { 60 + if (heartbeatInterval.current) { 61 + clearInterval(heartbeatInterval.current); 62 + } 63 + ws.close(); 64 + } 65 + console.log(">> WebSocket connection closed"); 66 + }; 67 + }, []); 68 + 33 69 return ( 34 70 <Container> 35 71 <HeadingMedium
+37 -1
apps/web/src/pages/home/nowplayings/NowPlayings.tsx
··· 7 7 import dayjs from "dayjs"; 8 8 import relativeTime from "dayjs/plugin/relativeTime"; 9 9 import utc from "dayjs/plugin/utc"; 10 - import { useEffect, useState } from "react"; 10 + import { useEffect, useRef, useState } from "react"; 11 11 import { useNowPlayingsQuery } from "../../../hooks/useNowPlaying"; 12 12 import styles from "./styles"; 13 + import { WS_URL } from "../../../consts"; 14 + import { useQueryClient } from "@tanstack/react-query"; 13 15 14 16 dayjs.extend(relativeTime); 15 17 dayjs.extend(utc); ··· 88 90 `; 89 91 90 92 function NowPlayings() { 93 + const queryClient = useQueryClient(); 91 94 const [isOpen, setIsOpen] = useState(false); 95 + const socketRef = useRef<WebSocket | null>(null); 96 + const heartbeatInterval = useRef<number | null>(null); 92 97 const { data: nowPlayings, isLoading } = useNowPlayingsQuery(); 93 98 const [currentlyPlaying, setCurrentlyPlaying] = useState<{ 94 99 id: string; ··· 106 111 } | null>(null); 107 112 const [currentIndex, setCurrentIndex] = useState(0); 108 113 const [progress, setProgress] = useState(0); 114 + 115 + useEffect(() => { 116 + const ws = new WebSocket(`${WS_URL.replace("http", "ws")}/ws`); 117 + socketRef.current = ws; 118 + 119 + ws.onopen = () => { 120 + heartbeatInterval.current = window.setInterval(() => { 121 + ws.send("ping"); 122 + }, 3000); 123 + }; 124 + 125 + ws.onmessage = (event) => { 126 + if (event.data === "pong") { 127 + return; 128 + } 129 + 130 + const message = JSON.parse(event.data); 131 + queryClient.setQueryData(["now-playings"], message.nowPlayings); 132 + queryClient.setQueryData(["scrobblesChart"], message.scrobblesChart); 133 + }; 134 + 135 + return () => { 136 + if (ws) { 137 + if (heartbeatInterval.current) { 138 + clearInterval(heartbeatInterval.current); 139 + } 140 + ws.close(); 141 + } 142 + console.log(">> WebSocket connection closed"); 143 + }; 144 + }, []); 109 145 110 146 const onNext = () => { 111 147 const nextIndex = currentIndex + 1;