first commit
CI / Type-check & Build (push) Failing after 5m50s
CI / Build & Push image (push) Skipped

This commit is contained in:
ysj7705 committed 2026-08-12 15:01:43 +09:00
commit 69b647145f
58 files changed
+15796

No files matched your search

+21
View File
@@ -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 }
}
+14
View File
@@ -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 }
}
+6
View File
@@ -0,0 +1,6 @@
import { useLocalStorage } from '@vueuse/core'
export function useNickname() {
const nickname = useLocalStorage('sejong.nickname', '')
return { nickname }
}
+33
View File
@@ -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 }
}
+26
View File
@@ -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')}`
}
+52
View File
@@ -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 }
}
+33
View File
@@ -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 }
}
+163
View File
@@ -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,
}
}