first commit
This commit is contained in:
commit
69b647145f
58 files changed
+15796
No files matched your search
@@ -0,0 +1,21 @@
|
||||
let audioCtx: AudioContext | null = null
|
||||
|
||||
export function useAudioUnlock() {
|
||||
const unlocked = useState('audio-unlocked', () => false)
|
||||
|
||||
async function unlockAudio() {
|
||||
if (!import.meta.client) return
|
||||
try {
|
||||
const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
|
||||
if (!audioCtx) audioCtx = new Ctx()
|
||||
if (audioCtx.state === 'suspended') {
|
||||
await audioCtx.resume()
|
||||
}
|
||||
unlocked.value = true
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
return { unlocked, unlockAudio }
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
|
||||
export function useClientId() {
|
||||
const clientId = useLocalStorage('sejong.clientId', '')
|
||||
|
||||
if (import.meta.client && !clientId.value) {
|
||||
clientId.value =
|
||||
typeof crypto !== 'undefined' && crypto.randomUUID
|
||||
? crypto.randomUUID()
|
||||
: `client-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
return { clientId }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
|
||||
export function useNickname() {
|
||||
const nickname = useLocalStorage('sejong.nickname', '')
|
||||
return { nickname }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
|
||||
export function useRoomAccess(roomId: MaybeRefOrGetter<string>) {
|
||||
const storageKey = computed(() => `sejong.roomAccess.${toValue(roomId)}`)
|
||||
const accessToken = useLocalStorage(storageKey, '')
|
||||
|
||||
async function requestAccess(options: {
|
||||
clientId: string
|
||||
password?: string
|
||||
accessToken?: string
|
||||
}) {
|
||||
const id = toValue(roomId)
|
||||
const res = await $fetch<{ accessToken: string; room: { id: string; name: string } }>(
|
||||
`/api/rooms/${id}/access`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: {
|
||||
clientId: options.clientId,
|
||||
password: options.password,
|
||||
accessToken: options.accessToken || accessToken.value || undefined,
|
||||
},
|
||||
},
|
||||
)
|
||||
accessToken.value = res.accessToken
|
||||
return res
|
||||
}
|
||||
|
||||
function clearAccess() {
|
||||
accessToken.value = ''
|
||||
}
|
||||
|
||||
return { accessToken, requestAccess, clearAccess }
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useTimestamp } from '@vueuse/core'
|
||||
|
||||
export function useRoomTimer(startedAt: MaybeRefOrGetter<number | null>) {
|
||||
const now = useTimestamp({ interval: 1000 })
|
||||
|
||||
const elapsedMs = computed(() => {
|
||||
const start = toValue(startedAt)
|
||||
if (!start) return 0
|
||||
return Math.max(0, now.value - start)
|
||||
})
|
||||
|
||||
const formatted = computed(() => formatDuration(elapsedMs.value))
|
||||
|
||||
return { elapsedMs, formatted }
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number) {
|
||||
const totalSec = Math.floor(ms / 1000)
|
||||
const h = Math.floor(totalSec / 3600)
|
||||
const m = Math.floor((totalSec % 3600) / 60)
|
||||
const s = totalSec % 60
|
||||
if (h > 0) {
|
||||
return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export function useScreenShare() {
|
||||
const localStream = ref<MediaStream | null>(null)
|
||||
const streamId = ref<string | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
const isSharing = computed(() => Boolean(localStream.value))
|
||||
|
||||
async function startShare(clientId: string) {
|
||||
error.value = null
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 },
|
||||
frameRate: { ideal: 60, max: 60 },
|
||||
// @ts-expect-error displaySurface is supported in Chromium
|
||||
displaySurface: 'window',
|
||||
},
|
||||
audio: {
|
||||
// @ts-expect-error Chromium system audio options
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
},
|
||||
})
|
||||
|
||||
localStream.value = stream
|
||||
streamId.value = `${clientId}-${Date.now()}`
|
||||
|
||||
const videoTrack = stream.getVideoTracks()[0]
|
||||
videoTrack?.addEventListener('ended', () => {
|
||||
stopShare()
|
||||
})
|
||||
|
||||
return { stream, streamId: streamId.value }
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Screen share failed'
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
function stopShare() {
|
||||
localStream.value?.getTracks().forEach((t) => t.stop())
|
||||
localStream.value = null
|
||||
streamId.value = null
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopShare()
|
||||
})
|
||||
|
||||
return { localStream, streamId, isSharing, error, startShare, stopShare }
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import type { ClientToServerEvents, ServerToClientEvents } from '~/types/signaling'
|
||||
|
||||
export type AppSocket = Socket<ServerToClientEvents, ClientToServerEvents>
|
||||
|
||||
let socketSingleton: AppSocket | null = null
|
||||
|
||||
export function useSocket() {
|
||||
const socket = useState<AppSocket | null>('app-socket', () => null)
|
||||
|
||||
function connect() {
|
||||
if (!import.meta.client) return null
|
||||
if (socketSingleton?.connected) {
|
||||
socket.value = socketSingleton
|
||||
return socketSingleton
|
||||
}
|
||||
socketSingleton = io({
|
||||
path: '/socket.io/',
|
||||
transports: ['websocket', 'polling'],
|
||||
autoConnect: true,
|
||||
})
|
||||
socket.value = socketSingleton
|
||||
return socketSingleton
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
socketSingleton?.disconnect()
|
||||
socketSingleton = null
|
||||
socket.value = null
|
||||
}
|
||||
|
||||
return { socket, connect, disconnect }
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { AppSocket } from './useSocket'
|
||||
|
||||
type PeerKey = string
|
||||
|
||||
function peerKey(remoteId: string, streamId: string) {
|
||||
return `${remoteId}:${streamId}`
|
||||
}
|
||||
|
||||
export function useWebRTC(options: {
|
||||
socket: Ref<AppSocket | null>
|
||||
clientId: Ref<string>
|
||||
getLocalStream: () => MediaStream | null
|
||||
}) {
|
||||
const peers = new Map<PeerKey, RTCPeerConnection>()
|
||||
const remoteStreams = ref<Record<string, MediaStream>>({})
|
||||
const iceServers: RTCIceServer[] = [{ urls: 'stun:stun.l.google.com:19302' }]
|
||||
|
||||
function getPc(remoteId: string, streamId: string) {
|
||||
const key = peerKey(remoteId, streamId)
|
||||
let pc = peers.get(key)
|
||||
if (pc) return pc
|
||||
|
||||
pc = new RTCPeerConnection({ iceServers })
|
||||
peers.set(key, pc)
|
||||
|
||||
pc.onicecandidate = (ev) => {
|
||||
if (!ev.candidate || !options.socket.value) return
|
||||
options.socket.value.emit('webrtc:ice', {
|
||||
to: remoteId,
|
||||
streamId,
|
||||
candidate: ev.candidate.toJSON(),
|
||||
})
|
||||
}
|
||||
|
||||
pc.ontrack = (ev) => {
|
||||
const stream = ev.streams[0] || new MediaStream([ev.track])
|
||||
remoteStreams.value = { ...remoteStreams.value, [streamId]: stream }
|
||||
}
|
||||
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (pc?.connectionState === 'failed' || pc?.connectionState === 'closed') {
|
||||
closePeer(remoteId, streamId)
|
||||
}
|
||||
}
|
||||
|
||||
return pc
|
||||
}
|
||||
|
||||
function closePeer(remoteId: string, streamId: string) {
|
||||
const key = peerKey(remoteId, streamId)
|
||||
const pc = peers.get(key)
|
||||
if (pc) {
|
||||
pc.close()
|
||||
peers.delete(key)
|
||||
}
|
||||
if (remoteStreams.value[streamId]) {
|
||||
const next = { ...remoteStreams.value }
|
||||
delete next[streamId]
|
||||
remoteStreams.value = next
|
||||
}
|
||||
}
|
||||
|
||||
function closeAll() {
|
||||
for (const pc of peers.values()) pc.close()
|
||||
peers.clear()
|
||||
remoteStreams.value = {}
|
||||
}
|
||||
|
||||
async function createOfferAsViewer(broadcasterId: string, streamId: string) {
|
||||
const pc = getPc(broadcasterId, streamId)
|
||||
const offer = await pc.createOffer()
|
||||
await pc.setLocalDescription(offer)
|
||||
options.socket.value?.emit('webrtc:offer', {
|
||||
to: broadcasterId,
|
||||
streamId,
|
||||
sdp: offer,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleOffer(from: string, streamId: string, sdp: RTCSessionDescriptionInit) {
|
||||
const pc = getPc(from, streamId)
|
||||
const local = options.getLocalStream()
|
||||
if (local) {
|
||||
for (const track of local.getTracks()) {
|
||||
const exists = pc.getSenders().some((s) => s.track?.id === track.id)
|
||||
if (!exists) pc.addTrack(track, local)
|
||||
}
|
||||
}
|
||||
await pc.setRemoteDescription(sdp)
|
||||
const answer = await pc.createAnswer()
|
||||
await pc.setLocalDescription(answer)
|
||||
options.socket.value?.emit('webrtc:answer', {
|
||||
to: from,
|
||||
streamId,
|
||||
sdp: answer,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleAnswer(from: string, streamId: string, sdp: RTCSessionDescriptionInit) {
|
||||
const pc = peers.get(peerKey(from, streamId))
|
||||
if (!pc) return
|
||||
await pc.setRemoteDescription(sdp)
|
||||
}
|
||||
|
||||
async function handleIce(from: string, streamId: string, candidate: RTCIceCandidateInit) {
|
||||
const pc = peers.get(peerKey(from, streamId))
|
||||
if (!pc) return
|
||||
try {
|
||||
await pc.addIceCandidate(candidate)
|
||||
} catch {
|
||||
// ignore late candidates
|
||||
}
|
||||
}
|
||||
|
||||
/** Broadcaster: when a viewer starts watching, wait for their offer (viewer-initiated). */
|
||||
function attachLocalTracksToPeer(remoteId: string, streamId: string) {
|
||||
const local = options.getLocalStream()
|
||||
if (!local) return
|
||||
const pc = getPc(remoteId, streamId)
|
||||
for (const track of local.getTracks()) {
|
||||
const exists = pc.getSenders().some((s) => s.track?.id === track.id)
|
||||
if (!exists) pc.addTrack(track, local)
|
||||
}
|
||||
}
|
||||
|
||||
function bindSocketHandlers() {
|
||||
const s = options.socket.value
|
||||
if (!s) return
|
||||
|
||||
s.on('webrtc:offer', async (payload) => {
|
||||
if (!payload.from || !payload.sdp || !payload.streamId) return
|
||||
await handleOffer(payload.from, payload.streamId, payload.sdp)
|
||||
})
|
||||
|
||||
s.on('webrtc:answer', async (payload) => {
|
||||
if (!payload.from || !payload.sdp || !payload.streamId) return
|
||||
await handleAnswer(payload.from, payload.streamId, payload.sdp)
|
||||
})
|
||||
|
||||
s.on('webrtc:ice', async (payload) => {
|
||||
if (!payload.from || !payload.candidate || !payload.streamId) return
|
||||
await handleIce(payload.from, payload.streamId, payload.candidate)
|
||||
})
|
||||
|
||||
s.on('peer:left', (payload) => {
|
||||
for (const key of [...peers.keys()]) {
|
||||
if (key.startsWith(`${payload.clientId}:`)) {
|
||||
const streamId = key.split(':')[1]
|
||||
closePeer(payload.clientId, streamId)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
remoteStreams,
|
||||
createOfferAsViewer,
|
||||
attachLocalTracksToPeer,
|
||||
closePeer,
|
||||
closeAll,
|
||||
bindSocketHandlers,
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user