Spaces:
Sleeping
Sleeping
Merge pull request #2 from ArnavSingh76533/copilot/fix-default-video-playlist
Browse files- .env +3 -0
- components/chat/ChatPanel.tsx +54 -12
- components/icon/IconPip.tsx +15 -0
- components/player/Controls.tsx +46 -5
- components/player/Player.tsx +30 -2
- lib/env.ts +7 -0
- lib/room.ts +11 -4
- pages/api/socketio.ts +32 -9
- public/notification.mp3.txt +3 -0
- yarn.lock +17 -0
.env
CHANGED
|
@@ -8,3 +8,6 @@ SITE_NAME="Web-SyncPlay"
|
|
| 8 |
PUBLIC_DOMAIN="shivam413-Streamer.hf.space"
|
| 9 |
|
| 10 |
YT_API_KEY=AIzaSyAhgfsfoHDpCmR-vtMu0gMyeimBtHwQFs8
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
PUBLIC_DOMAIN="shivam413-Streamer.hf.space"
|
| 9 |
|
| 10 |
YT_API_KEY=AIzaSyAhgfsfoHDpCmR-vtMu0gMyeimBtHwQFs8
|
| 11 |
+
|
| 12 |
+
# Default image to display when a new room is created (instead of default video)
|
| 13 |
+
DEFAULT_IMG=/logo_white.png
|
components/chat/ChatPanel.tsx
CHANGED
|
@@ -16,6 +16,22 @@ interface Props {
|
|
| 16 |
className?: string
|
| 17 |
}
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
const ChatPanel: FC<Props> = ({ socket, className }) => {
|
| 20 |
const [messages, _setMessages] = useState<ChatMessage[]>([])
|
| 21 |
const [text, setText] = useState("")
|
|
@@ -31,6 +47,26 @@ const ChatPanel: FC<Props> = ({ socket, className }) => {
|
|
| 31 |
}
|
| 32 |
const onNew = (msg: ChatMessage) => {
|
| 33 |
setMessages([...messagesRef.current, msg].slice(-200))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
}
|
| 35 |
|
| 36 |
socket.on("chatHistory", onHistory)
|
|
@@ -50,21 +86,27 @@ const ChatPanel: FC<Props> = ({ socket, className }) => {
|
|
| 50 |
|
| 51 |
return (
|
| 52 |
<div className={className ?? "flex flex-col h-64 border border-dark-700/50 rounded-xl overflow-hidden shadow-lg bg-dark-900"}>
|
| 53 |
-
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-dark-900/50">
|
| 54 |
-
{messages.
|
| 55 |
-
<div key={m.id} className="text-sm bg-dark-800/50 rounded-lg p-3 border border-dark-700/30">
|
| 56 |
-
<div className="flex items-center gap-2 mb-1">
|
| 57 |
-
<span className="font-semibold text-primary-400">{m.name}</span>
|
| 58 |
-
<span className="text-dark-500 text-xs">•</span>
|
| 59 |
-
<span className="text-dark-500 text-xs">{new Date(m.ts).toLocaleTimeString()}</span>
|
| 60 |
-
</div>
|
| 61 |
-
<div className="break-words text-dark-200">{m.text}</div>
|
| 62 |
-
</div>
|
| 63 |
-
))}
|
| 64 |
-
{messages.length === 0 && (
|
| 65 |
<div className="text-dark-500 text-sm text-center py-8">
|
| 66 |
No messages yet. Be the first to say hello! 👋
|
| 67 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
)}
|
| 69 |
</div>
|
| 70 |
<div className="p-3 flex gap-2 bg-dark-800/50 border-t border-dark-700/50">
|
|
|
|
| 16 |
className?: string
|
| 17 |
}
|
| 18 |
|
| 19 |
+
// Audio notification constants
|
| 20 |
+
const NOTIFICATION_FREQUENCY = 800
|
| 21 |
+
const NOTIFICATION_VOLUME = 0.3
|
| 22 |
+
const NOTIFICATION_VOLUME_END = 0.01
|
| 23 |
+
const NOTIFICATION_DURATION = 0.1
|
| 24 |
+
|
| 25 |
+
// Reusable audio context for notifications
|
| 26 |
+
let audioContext: AudioContext | null = null
|
| 27 |
+
|
| 28 |
+
const getAudioContext = () => {
|
| 29 |
+
if (!audioContext) {
|
| 30 |
+
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
| 31 |
+
}
|
| 32 |
+
return audioContext
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
const ChatPanel: FC<Props> = ({ socket, className }) => {
|
| 36 |
const [messages, _setMessages] = useState<ChatMessage[]>([])
|
| 37 |
const [text, setText] = useState("")
|
|
|
|
| 47 |
}
|
| 48 |
const onNew = (msg: ChatMessage) => {
|
| 49 |
setMessages([...messagesRef.current, msg].slice(-200))
|
| 50 |
+
// Play notification sound using Web Audio API
|
| 51 |
+
try {
|
| 52 |
+
const ctx = getAudioContext()
|
| 53 |
+
const oscillator = ctx.createOscillator()
|
| 54 |
+
const gainNode = ctx.createGain()
|
| 55 |
+
|
| 56 |
+
oscillator.connect(gainNode)
|
| 57 |
+
gainNode.connect(ctx.destination)
|
| 58 |
+
|
| 59 |
+
oscillator.frequency.value = NOTIFICATION_FREQUENCY
|
| 60 |
+
oscillator.type = 'sine'
|
| 61 |
+
|
| 62 |
+
gainNode.gain.setValueAtTime(NOTIFICATION_VOLUME, ctx.currentTime)
|
| 63 |
+
gainNode.gain.exponentialRampToValueAtTime(NOTIFICATION_VOLUME_END, ctx.currentTime + NOTIFICATION_DURATION)
|
| 64 |
+
|
| 65 |
+
oscillator.start(ctx.currentTime)
|
| 66 |
+
oscillator.stop(ctx.currentTime + NOTIFICATION_DURATION)
|
| 67 |
+
} catch (err) {
|
| 68 |
+
console.log("Audio notification failed:", err)
|
| 69 |
+
}
|
| 70 |
}
|
| 71 |
|
| 72 |
socket.on("chatHistory", onHistory)
|
|
|
|
| 86 |
|
| 87 |
return (
|
| 88 |
<div className={className ?? "flex flex-col h-64 border border-dark-700/50 rounded-xl overflow-hidden shadow-lg bg-dark-900"}>
|
| 89 |
+
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-dark-900/50 flex flex-col-reverse">
|
| 90 |
+
{messages.length === 0 ? (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
<div className="text-dark-500 text-sm text-center py-8">
|
| 92 |
No messages yet. Be the first to say hello! 👋
|
| 93 |
</div>
|
| 94 |
+
) : (
|
| 95 |
+
// Render messages in reverse order without creating a new array
|
| 96 |
+
messages.map((_, idx) => {
|
| 97 |
+
const reverseIdx = messages.length - 1 - idx
|
| 98 |
+
const msg = messages[reverseIdx]
|
| 99 |
+
return (
|
| 100 |
+
<div key={msg.id} className="text-sm bg-dark-800/50 rounded-lg p-3 border border-dark-700/30">
|
| 101 |
+
<div className="flex items-center gap-2 mb-1">
|
| 102 |
+
<span className="font-semibold text-primary-400">{msg.name}</span>
|
| 103 |
+
<span className="text-dark-500 text-xs">•</span>
|
| 104 |
+
<span className="text-dark-500 text-xs">{new Date(msg.ts).toLocaleTimeString()}</span>
|
| 105 |
+
</div>
|
| 106 |
+
<div className="break-words text-dark-200">{msg.text}</div>
|
| 107 |
+
</div>
|
| 108 |
+
)
|
| 109 |
+
})
|
| 110 |
)}
|
| 111 |
</div>
|
| 112 |
<div className="p-3 flex gap-2 bg-dark-800/50 border-t border-dark-700/50">
|
components/icon/IconPip.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { FC } from "react"
|
| 2 |
+
import Icon, { IconProps } from "./Icon"
|
| 3 |
+
|
| 4 |
+
const IconPip: FC<IconProps> = ({ className = "" }) => {
|
| 5 |
+
return (
|
| 6 |
+
<Icon className={className} viewBox='0 0 24 24'>
|
| 7 |
+
<path
|
| 8 |
+
fill='currentColor'
|
| 9 |
+
d='M19 7h-8v6h8V7zm2-4H3c-1.1 0-2 .9-2 2v14c0 1.1.9 1.98 2 1.98h18c1.1 0 2-.88 2-1.98V5c0-1.1-.9-2-2-2zm0 16.01H3V4.98h18v14.03z'
|
| 10 |
+
/>
|
| 11 |
+
</Icon>
|
| 12 |
+
)
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export default IconPip
|
components/player/Controls.tsx
CHANGED
|
@@ -19,6 +19,8 @@ import PlayerMenu from "./PlayerMenu"
|
|
| 19 |
import { Tooltip } from "react-tooltip"
|
| 20 |
import IconNewTab from "../icon/IconNewTab"
|
| 21 |
import Link from "next/link"
|
|
|
|
|
|
|
| 22 |
|
| 23 |
interface Props extends PlayerState {
|
| 24 |
roomId: string
|
|
@@ -34,6 +36,11 @@ interface Props extends PlayerState {
|
|
| 34 |
playIndex: (index: number) => void
|
| 35 |
setSeeking: (seeking: boolean) => void
|
| 36 |
playAgain: () => void
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
}
|
| 38 |
|
| 39 |
let interaction = false
|
|
@@ -67,6 +74,11 @@ const Controls: FC<Props> = ({
|
|
| 67 |
playIndex,
|
| 68 |
setSeeking,
|
| 69 |
playAgain,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
}) => {
|
| 71 |
const [showControls, setShowControls] = useState(true)
|
| 72 |
const [showTimePlayed, setShowTimePlayed] = useState(true)
|
|
@@ -80,10 +92,12 @@ const Controls: FC<Props> = ({
|
|
| 80 |
if (new Date().getTime() - interactionTime > 350) {
|
| 81 |
if (interaction && !doubleClick) {
|
| 82 |
doubleClick = false
|
| 83 |
-
if (
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
| 87 |
}
|
| 88 |
}
|
| 89 |
|
|
@@ -190,7 +204,7 @@ const Controls: FC<Props> = ({
|
|
| 190 |
<ControlButton
|
| 191 |
tooltip={playEnded() ? "Play again" : paused ? "Play" : "Pause"}
|
| 192 |
onClick={() => {
|
| 193 |
-
if (show) {
|
| 194 |
if (playEnded()) {
|
| 195 |
playAgain()
|
| 196 |
} else {
|
|
@@ -199,6 +213,7 @@ const Controls: FC<Props> = ({
|
|
| 199 |
}
|
| 200 |
}}
|
| 201 |
interaction={showControlsAction}
|
|
|
|
| 202 |
>
|
| 203 |
{playEnded() ? (
|
| 204 |
<IconReplay />
|
|
@@ -275,6 +290,32 @@ const Controls: FC<Props> = ({
|
|
| 275 |
setMenuOpen={setMenuOpen}
|
| 276 |
/>
|
| 277 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
<ControlButton
|
| 279 |
tooltip={fullscreen ? "Leave fullscreen" : "Enter fullscreen"}
|
| 280 |
onClick={() => {
|
|
|
|
| 19 |
import { Tooltip } from "react-tooltip"
|
| 20 |
import IconNewTab from "../icon/IconNewTab"
|
| 21 |
import Link from "next/link"
|
| 22 |
+
import IconMusic from "../icon/IconMusic"
|
| 23 |
+
import IconPip from "../icon/IconPip"
|
| 24 |
|
| 25 |
interface Props extends PlayerState {
|
| 26 |
roomId: string
|
|
|
|
| 36 |
playIndex: (index: number) => void
|
| 37 |
setSeeking: (seeking: boolean) => void
|
| 38 |
playAgain: () => void
|
| 39 |
+
isOwner: boolean
|
| 40 |
+
pipEnabled: boolean
|
| 41 |
+
setPipEnabled: (enabled: boolean) => void
|
| 42 |
+
musicMode: boolean
|
| 43 |
+
setMusicMode: (enabled: boolean) => void
|
| 44 |
}
|
| 45 |
|
| 46 |
let interaction = false
|
|
|
|
| 74 |
playIndex,
|
| 75 |
setSeeking,
|
| 76 |
playAgain,
|
| 77 |
+
isOwner,
|
| 78 |
+
pipEnabled,
|
| 79 |
+
setPipEnabled,
|
| 80 |
+
musicMode,
|
| 81 |
+
setMusicMode,
|
| 82 |
}) => {
|
| 83 |
const [showControls, setShowControls] = useState(true)
|
| 84 |
const [showTimePlayed, setShowTimePlayed] = useState(true)
|
|
|
|
| 92 |
if (new Date().getTime() - interactionTime > 350) {
|
| 93 |
if (interaction && !doubleClick) {
|
| 94 |
doubleClick = false
|
| 95 |
+
if (isOwner) {
|
| 96 |
+
if (playEnded()) {
|
| 97 |
+
playAgain()
|
| 98 |
+
} else {
|
| 99 |
+
setPaused(!paused)
|
| 100 |
+
}
|
| 101 |
}
|
| 102 |
}
|
| 103 |
|
|
|
|
| 204 |
<ControlButton
|
| 205 |
tooltip={playEnded() ? "Play again" : paused ? "Play" : "Pause"}
|
| 206 |
onClick={() => {
|
| 207 |
+
if (show && isOwner) {
|
| 208 |
if (playEnded()) {
|
| 209 |
playAgain()
|
| 210 |
} else {
|
|
|
|
| 213 |
}
|
| 214 |
}}
|
| 215 |
interaction={showControlsAction}
|
| 216 |
+
className={!isOwner ? "opacity-50 cursor-not-allowed" : ""}
|
| 217 |
>
|
| 218 |
{playEnded() ? (
|
| 219 |
<IconReplay />
|
|
|
|
| 290 |
setMenuOpen={setMenuOpen}
|
| 291 |
/>
|
| 292 |
|
| 293 |
+
<ControlButton
|
| 294 |
+
tooltip={musicMode ? "Exit music mode" : "Enter music mode"}
|
| 295 |
+
onClick={() => {
|
| 296 |
+
setMusicMode(!musicMode)
|
| 297 |
+
if (!musicMode && pipEnabled) {
|
| 298 |
+
setPipEnabled(false)
|
| 299 |
+
}
|
| 300 |
+
}}
|
| 301 |
+
interaction={showControlsAction}
|
| 302 |
+
>
|
| 303 |
+
<IconMusic />
|
| 304 |
+
</ControlButton>
|
| 305 |
+
|
| 306 |
+
<ControlButton
|
| 307 |
+
tooltip={pipEnabled ? "Exit PiP" : "Enter PiP"}
|
| 308 |
+
onClick={() => {
|
| 309 |
+
setPipEnabled(!pipEnabled)
|
| 310 |
+
if (!pipEnabled && musicMode) {
|
| 311 |
+
setMusicMode(false)
|
| 312 |
+
}
|
| 313 |
+
}}
|
| 314 |
+
interaction={showControlsAction}
|
| 315 |
+
>
|
| 316 |
+
<IconPip />
|
| 317 |
+
</ControlButton>
|
| 318 |
+
|
| 319 |
<ControlButton
|
| 320 |
tooltip={fullscreen ? "Leave fullscreen" : "Enter fullscreen"}
|
| 321 |
onClick={() => {
|
components/player/Player.tsx
CHANGED
|
@@ -110,6 +110,8 @@ const Player: FC<Props> = ({ roomId, socket, fullHeight }) => {
|
|
| 110 |
resolution: "",
|
| 111 |
})
|
| 112 |
const [currentSub, setCurrentSub] = useState<Subtitle>({ src: "", lang: "" })
|
|
|
|
|
|
|
| 113 |
|
| 114 |
const [error, setError] = useState(null)
|
| 115 |
const [ready, _setReady] = useState(false)
|
|
@@ -128,6 +130,8 @@ const Player: FC<Props> = ({ roomId, socket, fullHeight }) => {
|
|
| 128 |
const [connected, setConnected] = useState(false)
|
| 129 |
const [unmuted, setUnmuted] = useState(false)
|
| 130 |
const [fullscreen, setFullscreen] = useState(false)
|
|
|
|
|
|
|
| 131 |
const fullscreenHandle = useFullScreenHandle()
|
| 132 |
const player = useRef<ReactPlayer>(null)
|
| 133 |
|
|
@@ -193,6 +197,12 @@ const Player: FC<Props> = ({ roomId, socket, fullHeight }) => {
|
|
| 193 |
setDeltaServerTime((room.serverTime - new Date().getTime()) / 1000)
|
| 194 |
}
|
| 195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
const update = room.targetState
|
| 197 |
if (update.lastSync !== lastSyncRef.current) {
|
| 198 |
_setLastSync(update.lastSync)
|
|
@@ -222,7 +232,7 @@ const Player: FC<Props> = ({ roomId, socket, fullHeight }) => {
|
|
| 222 |
_setPlaylist(update.playlist)
|
| 223 |
}
|
| 224 |
})
|
| 225 |
-
}, [socket])
|
| 226 |
|
| 227 |
useEffect(() => {
|
| 228 |
if (ready) {
|
|
@@ -243,9 +253,22 @@ const Player: FC<Props> = ({ roomId, socket, fullHeight }) => {
|
|
| 243 |
}
|
| 244 |
}}
|
| 245 |
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
<ReactPlayer
|
| 247 |
style={{
|
| 248 |
maxHeight: fullscreen || fullHeight ? "100vh" : "calc(100vh - 210px)",
|
|
|
|
|
|
|
| 249 |
}}
|
| 250 |
ref={player}
|
| 251 |
width={"100%"}
|
|
@@ -265,7 +288,7 @@ const Player: FC<Props> = ({ roomId, socket, fullHeight }) => {
|
|
| 265 |
},
|
| 266 |
}}
|
| 267 |
url={currentSrc.src}
|
| 268 |
-
pip={
|
| 269 |
playing={!paused}
|
| 270 |
controls={false}
|
| 271 |
loop={loop}
|
|
@@ -419,6 +442,11 @@ const Player: FC<Props> = ({ roomId, socket, fullHeight }) => {
|
|
| 419 |
lastSync={lastSync}
|
| 420 |
error={error}
|
| 421 |
playAgain={() => socket?.emit("playAgain")}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 422 |
/>
|
| 423 |
|
| 424 |
<div className={"absolute top-1 left-1 flex flex-col gap-1 p-1"}>
|
|
|
|
| 110 |
resolution: "",
|
| 111 |
})
|
| 112 |
const [currentSub, setCurrentSub] = useState<Subtitle>({ src: "", lang: "" })
|
| 113 |
+
const [ownerId, setOwnerId] = useState<string>("")
|
| 114 |
+
const [isOwner, setIsOwner] = useState(false)
|
| 115 |
|
| 116 |
const [error, setError] = useState(null)
|
| 117 |
const [ready, _setReady] = useState(false)
|
|
|
|
| 130 |
const [connected, setConnected] = useState(false)
|
| 131 |
const [unmuted, setUnmuted] = useState(false)
|
| 132 |
const [fullscreen, setFullscreen] = useState(false)
|
| 133 |
+
const [pipEnabled, setPipEnabled] = useState(false)
|
| 134 |
+
const [musicMode, setMusicMode] = useState(false)
|
| 135 |
const fullscreenHandle = useFullScreenHandle()
|
| 136 |
const player = useRef<ReactPlayer>(null)
|
| 137 |
|
|
|
|
| 197 |
setDeltaServerTime((room.serverTime - new Date().getTime()) / 1000)
|
| 198 |
}
|
| 199 |
|
| 200 |
+
// Update owner info
|
| 201 |
+
if (room.ownerId !== ownerId) {
|
| 202 |
+
setOwnerId(room.ownerId)
|
| 203 |
+
setIsOwner(socket.id === room.ownerId)
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
const update = room.targetState
|
| 207 |
if (update.lastSync !== lastSyncRef.current) {
|
| 208 |
_setLastSync(update.lastSync)
|
|
|
|
| 232 |
_setPlaylist(update.playlist)
|
| 233 |
}
|
| 234 |
})
|
| 235 |
+
}, [socket, ownerId])
|
| 236 |
|
| 237 |
useEffect(() => {
|
| 238 |
if (ready) {
|
|
|
|
| 253 |
}
|
| 254 |
}}
|
| 255 |
>
|
| 256 |
+
{musicMode && (
|
| 257 |
+
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-primary-900 to-dark-900">
|
| 258 |
+
<div className="flex flex-col items-center gap-4 text-primary-300">
|
| 259 |
+
<svg className="w-32 h-32" fill="currentColor" viewBox="0 0 512 512">
|
| 260 |
+
<path d='M470.38 1.51L150.41 96A32 32 0 0 0 128 126.51v261.41A139 139 0 0 0 96 384c-53 0-96 28.66-96 64s43 64 96 64 96-28.66 96-64V214.32l256-75v184.61a138.4 138.4 0 0 0-32-3.93c-53 0-96 28.66-96 64s43 64 96 64 96-28.65 96-64V32a32 32 0 0 0-41.62-30.49z' />
|
| 261 |
+
</svg>
|
| 262 |
+
<div className="text-2xl font-semibold">Music Mode</div>
|
| 263 |
+
<div className="text-sm text-primary-400">Audio continues playing</div>
|
| 264 |
+
</div>
|
| 265 |
+
</div>
|
| 266 |
+
)}
|
| 267 |
<ReactPlayer
|
| 268 |
style={{
|
| 269 |
maxHeight: fullscreen || fullHeight ? "100vh" : "calc(100vh - 210px)",
|
| 270 |
+
visibility: musicMode ? "hidden" : "visible",
|
| 271 |
+
height: musicMode ? "0px" : (fullscreen || fullHeight ? "100vh" : "calc((9 / 16) * 100vw)"),
|
| 272 |
}}
|
| 273 |
ref={player}
|
| 274 |
width={"100%"}
|
|
|
|
| 288 |
},
|
| 289 |
}}
|
| 290 |
url={currentSrc.src}
|
| 291 |
+
pip={pipEnabled}
|
| 292 |
playing={!paused}
|
| 293 |
controls={false}
|
| 294 |
loop={loop}
|
|
|
|
| 442 |
lastSync={lastSync}
|
| 443 |
error={error}
|
| 444 |
playAgain={() => socket?.emit("playAgain")}
|
| 445 |
+
isOwner={isOwner}
|
| 446 |
+
pipEnabled={pipEnabled}
|
| 447 |
+
setPipEnabled={setPipEnabled}
|
| 448 |
+
musicMode={musicMode}
|
| 449 |
+
setMusicMode={setMusicMode}
|
| 450 |
/>
|
| 451 |
|
| 452 |
<div className={"absolute top-1 left-1 flex flex-col gap-1 p-1"}>
|
lib/env.ts
CHANGED
|
@@ -40,3 +40,10 @@ export function getDefaultSrc(): string {
|
|
| 40 |
// console.warn("ENV 'DEFAULT_SRC' has no value, using no src")
|
| 41 |
return "https://youtu.be/c-FKlE3_kHo"
|
| 42 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
// console.warn("ENV 'DEFAULT_SRC' has no value, using no src")
|
| 41 |
return "https://youtu.be/c-FKlE3_kHo"
|
| 42 |
}
|
| 43 |
+
|
| 44 |
+
export function getDefaultImg(): string | null {
|
| 45 |
+
if ("DEFAULT_IMG" in process.env) {
|
| 46 |
+
return <string>process.env.DEFAULT_IMG
|
| 47 |
+
}
|
| 48 |
+
return null
|
| 49 |
+
}
|
lib/room.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { PlayerState, RoomState } from "./types"
|
| 2 |
import { getRandomName, getTargetTime } from "./utils"
|
| 3 |
-
import { getDefaultSrc } from "./env"
|
| 4 |
import { getRoom, setRoom } from "./cache"
|
| 5 |
|
| 6 |
export const updateLastSync = (room: RoomState) => {
|
|
@@ -52,6 +52,11 @@ export const createNewUser = async (roomId: string, socketId: string) => {
|
|
| 52 |
}
|
| 53 |
|
| 54 |
export const createNewRoom = async (roomId: string, socketId: string) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
await setRoom(roomId, {
|
| 56 |
serverTime: 0,
|
| 57 |
commandHistory: [],
|
|
@@ -61,17 +66,19 @@ export const createNewRoom = async (roomId: string, socketId: string) => {
|
|
| 61 |
playlist: {
|
| 62 |
items: [
|
| 63 |
{
|
| 64 |
-
src: [{ src:
|
| 65 |
sub: [],
|
|
|
|
| 66 |
},
|
| 67 |
],
|
| 68 |
currentIndex: 0,
|
| 69 |
},
|
| 70 |
playing: {
|
| 71 |
-
src: [{ src:
|
| 72 |
sub: [],
|
|
|
|
| 73 |
},
|
| 74 |
-
paused:
|
| 75 |
progress: 0,
|
| 76 |
playbackRate: 1,
|
| 77 |
loop: false,
|
|
|
|
| 1 |
import { PlayerState, RoomState } from "./types"
|
| 2 |
import { getRandomName, getTargetTime } from "./utils"
|
| 3 |
+
import { getDefaultSrc, getDefaultImg } from "./env"
|
| 4 |
import { getRoom, setRoom } from "./cache"
|
| 5 |
|
| 6 |
export const updateLastSync = (room: RoomState) => {
|
|
|
|
| 52 |
}
|
| 53 |
|
| 54 |
export const createNewRoom = async (roomId: string, socketId: string) => {
|
| 55 |
+
// Use default image if available, otherwise use default video
|
| 56 |
+
const defaultImg = getDefaultImg()
|
| 57 |
+
const defaultMedia = defaultImg || getDefaultSrc()
|
| 58 |
+
const isImage = !!defaultImg
|
| 59 |
+
|
| 60 |
await setRoom(roomId, {
|
| 61 |
serverTime: 0,
|
| 62 |
commandHistory: [],
|
|
|
|
| 66 |
playlist: {
|
| 67 |
items: [
|
| 68 |
{
|
| 69 |
+
src: [{ src: defaultMedia, resolution: "" }],
|
| 70 |
sub: [],
|
| 71 |
+
title: isImage ? "Welcome" : undefined,
|
| 72 |
},
|
| 73 |
],
|
| 74 |
currentIndex: 0,
|
| 75 |
},
|
| 76 |
playing: {
|
| 77 |
+
src: [{ src: defaultMedia, resolution: "" }],
|
| 78 |
sub: [],
|
| 79 |
+
title: isImage ? "Welcome" : undefined,
|
| 80 |
},
|
| 81 |
+
paused: isImage, // Pause by default if it's an image
|
| 82 |
progress: 0,
|
| 83 |
playbackRate: 1,
|
| 84 |
loop: false,
|
pages/api/socketio.ts
CHANGED
|
@@ -11,8 +11,19 @@ import {
|
|
| 11 |
setRoom,
|
| 12 |
} from "../../lib/cache"
|
| 13 |
import { createNewRoom, createNewUser, updateLastSync } from "../../lib/room"
|
| 14 |
-
import { Playlist, RoomState, UserState, ChatMessage } from "../../lib/types"
|
| 15 |
import { isUrl } from "../../lib/utils"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
const ioHandler = (_: NextApiRequest, res: NextApiResponse) => {
|
| 18 |
// @ts-ignore
|
|
@@ -299,13 +310,28 @@ const ioHandler = (_: NextApiRequest, res: NextApiResponse) => {
|
|
| 299 |
return
|
| 300 |
}
|
| 301 |
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
}
|
| 306 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
room.targetState.progress = 0
|
| 308 |
room.targetState.lastSync = new Date().getTime() / 1000
|
|
|
|
| 309 |
await broadcast(room)
|
| 310 |
})
|
| 311 |
|
|
@@ -320,10 +346,7 @@ const ioHandler = (_: NextApiRequest, res: NextApiResponse) => {
|
|
| 320 |
if (!isUrl(url)) return log("addToPlaylist invalid url", url)
|
| 321 |
log("add to playlist", url)
|
| 322 |
|
| 323 |
-
room.targetState.playlist.items.push(
|
| 324 |
-
src: [{ src: url, resolution: "" }],
|
| 325 |
-
sub: [],
|
| 326 |
-
})
|
| 327 |
|
| 328 |
await broadcast(room)
|
| 329 |
})
|
|
|
|
| 11 |
setRoom,
|
| 12 |
} from "../../lib/cache"
|
| 13 |
import { createNewRoom, createNewUser, updateLastSync } from "../../lib/room"
|
| 14 |
+
import { Playlist, RoomState, UserState, ChatMessage, MediaElement } from "../../lib/types"
|
| 15 |
import { isUrl } from "../../lib/utils"
|
| 16 |
+
import { getDefaultImg, getDefaultSrc } from "../../lib/env"
|
| 17 |
+
|
| 18 |
+
/**
|
| 19 |
+
* Helper function to create a MediaElement from a URL
|
| 20 |
+
* @param url - The media URL to wrap
|
| 21 |
+
* @returns MediaElement with a single source and no subtitles
|
| 22 |
+
*/
|
| 23 |
+
const createMediaElement = (url: string): MediaElement => ({
|
| 24 |
+
src: [{ src: url, resolution: "" }],
|
| 25 |
+
sub: [],
|
| 26 |
+
})
|
| 27 |
|
| 28 |
const ioHandler = (_: NextApiRequest, res: NextApiResponse) => {
|
| 29 |
// @ts-ignore
|
|
|
|
| 310 |
return
|
| 311 |
}
|
| 312 |
|
| 313 |
+
// Remove default image/video from playlist if it's the only item
|
| 314 |
+
const defaultImg = getDefaultImg()
|
| 315 |
+
const defaultMedia = defaultImg || getDefaultSrc()
|
| 316 |
+
|
| 317 |
+
if (room.targetState.playlist.items.length === 1) {
|
| 318 |
+
const firstItem = room.targetState.playlist.items[0]
|
| 319 |
+
if (firstItem?.src?.[0]?.src === defaultMedia) {
|
| 320 |
+
// Remove the default item
|
| 321 |
+
room.targetState.playlist.items = []
|
| 322 |
+
log("Removed default media from playlist")
|
| 323 |
+
}
|
| 324 |
}
|
| 325 |
+
|
| 326 |
+
// Add new video to playlist at position 0
|
| 327 |
+
const newMedia = createMediaElement(url)
|
| 328 |
+
room.targetState.playlist.items.unshift(newMedia)
|
| 329 |
+
|
| 330 |
+
room.targetState.playing = newMedia
|
| 331 |
+
room.targetState.playlist.currentIndex = 0
|
| 332 |
room.targetState.progress = 0
|
| 333 |
room.targetState.lastSync = new Date().getTime() / 1000
|
| 334 |
+
room.targetState.paused = false
|
| 335 |
await broadcast(room)
|
| 336 |
})
|
| 337 |
|
|
|
|
| 346 |
if (!isUrl(url)) return log("addToPlaylist invalid url", url)
|
| 347 |
log("add to playlist", url)
|
| 348 |
|
| 349 |
+
room.targetState.playlist.items.push(createMediaElement(url))
|
|
|
|
|
|
|
|
|
|
| 350 |
|
| 351 |
await broadcast(room)
|
| 352 |
})
|
public/notification.mp3.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
This is a placeholder for notification sound.
|
| 2 |
+
To add a real notification sound, place an MP3 file here named notification.mp3
|
| 3 |
+
For now, the code will try to play it but gracefully handle if not found.
|
yarn.lock
CHANGED
|
@@ -102,6 +102,11 @@
|
|
| 102 |
resolved "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.0.tgz"
|
| 103 |
integrity sha512-naldaJy4hSVhWBgEjfdBY85CAa4UO+W1nx6a1sWStHZ7EUfNiuBTTN2KUYT5dH1+p/xij1t2QSXfCiFJoC5S/Q==
|
| 104 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
"@img/sharp-linux-x64@0.33.1":
|
| 106 |
version "0.33.1"
|
| 107 |
resolved "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.1.tgz"
|
|
@@ -109,6 +114,13 @@
|
|
| 109 |
optionalDependencies:
|
| 110 |
"@img/sharp-libvips-linux-x64" "1.0.0"
|
| 111 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
"@jridgewell/gen-mapping@^0.3.2":
|
| 113 |
version "0.3.3"
|
| 114 |
resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz"
|
|
@@ -166,6 +178,11 @@
|
|
| 166 |
resolved "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.10.tgz"
|
| 167 |
integrity sha512-GXvajAWh2woTT0GKEDlkVhFNxhJS/XdDmrVHrPOA83pLzlGPQnixqxD8u3bBB9oATBKB//5e4vpACnx5Vaxdqg==
|
| 168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
"@nodelib/fs.scandir@2.1.5":
|
| 170 |
version "2.1.5"
|
| 171 |
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
|
|
|
|
| 102 |
resolved "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.0.tgz"
|
| 103 |
integrity sha512-naldaJy4hSVhWBgEjfdBY85CAa4UO+W1nx6a1sWStHZ7EUfNiuBTTN2KUYT5dH1+p/xij1t2QSXfCiFJoC5S/Q==
|
| 104 |
|
| 105 |
+
"@img/sharp-libvips-linuxmusl-x64@1.0.0":
|
| 106 |
+
version "1.0.0"
|
| 107 |
+
resolved "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.0.tgz"
|
| 108 |
+
integrity sha512-FW8iK6rJrg+X2jKD0Ajhjv6y74lToIBEvkZhl42nZt563FfxkCYacrXZtd+q/sRQDypQLzY5WdLkVTbJoPyqNg==
|
| 109 |
+
|
| 110 |
"@img/sharp-linux-x64@0.33.1":
|
| 111 |
version "0.33.1"
|
| 112 |
resolved "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.1.tgz"
|
|
|
|
| 114 |
optionalDependencies:
|
| 115 |
"@img/sharp-libvips-linux-x64" "1.0.0"
|
| 116 |
|
| 117 |
+
"@img/sharp-linuxmusl-x64@0.33.1":
|
| 118 |
+
version "0.33.1"
|
| 119 |
+
resolved "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.1.tgz"
|
| 120 |
+
integrity sha512-LOGKNu5w8uu1evVqUAUKTix2sQu1XDRIYbsi5Q0c/SrXhvJ4QyOx+GaajxmOg5PZSsSnCYPSmhjHHsRBx06/wQ==
|
| 121 |
+
optionalDependencies:
|
| 122 |
+
"@img/sharp-libvips-linuxmusl-x64" "1.0.0"
|
| 123 |
+
|
| 124 |
"@jridgewell/gen-mapping@^0.3.2":
|
| 125 |
version "0.3.3"
|
| 126 |
resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz"
|
|
|
|
| 178 |
resolved "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.10.tgz"
|
| 179 |
integrity sha512-GXvajAWh2woTT0GKEDlkVhFNxhJS/XdDmrVHrPOA83pLzlGPQnixqxD8u3bBB9oATBKB//5e4vpACnx5Vaxdqg==
|
| 180 |
|
| 181 |
+
"@next/swc-linux-x64-musl@14.2.10":
|
| 182 |
+
version "14.2.10"
|
| 183 |
+
resolved "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.10.tgz"
|
| 184 |
+
integrity sha512-opFFN5B0SnO+HTz4Wq4HaylXGFV+iHrVxd3YvREUX9K+xfc4ePbRrxqOuPOFjtSuiVouwe6uLeDtabjEIbkmDA==
|
| 185 |
+
|
| 186 |
"@nodelib/fs.scandir@2.1.5":
|
| 187 |
version "2.1.5"
|
| 188 |
resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz"
|