import { Head, Link, router, useForm, usePage, usePoll, } from '@inertiajs/react'; import { Crown, Heart, LogIn, MessageSquare, Radio, Send, SmilePlus, Users, Video, } from 'lucide-react'; import type { FormEvent } from 'react'; import { useEffect, useRef, useState } from 'react'; import { Avatar as ChatAvatar, AvatarFallback, AvatarImage, } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, } from '@/components/ui/sheet'; import { VideoPlayer } from '@/components/video-player'; import { useInitials } from '@/hooks/use-initials'; import { useTranslation } from '@/lib/translations'; import { cn } from '@/lib/utils'; import { home, login } from '@/routes'; import { follow, unfollow } from '@/routes/channels'; import { store as storeChat } from '@/routes/channels/chat'; import type { BroadcastSummary, ChannelDetail, ChatMessage, ViewerStats, VodSummary, } from '@/types'; type Props = { channel: ChannelDetail; currentBroadcast: BroadcastSummary | null; viewerStats: ViewerStats; vods: VodSummary[]; chatMessages: ChatMessage[]; isFollowing: boolean; }; const chatEmojis = [ '😀', '😂', '😍', '😎', '🔥', '👏', '🙌', '❤️', '💯', '🎉', '👍', '👀', '🤯', '😭', '🙏', '✨', ]; export default function ChannelShow({ channel, currentBroadcast, viewerStats, vods, chatMessages, isFollowing, }: Props) { const { auth } = usePage().props; const { t } = useTranslation(); const [chatOpen, setChatOpen] = useState(false); const chatForm = useForm({ body: '' }); const currentViewers = viewerStats.current; const viewerStatsRefreshInterval = viewerStats.refresh_interval_ms ?? 30000; usePoll( viewerStatsRefreshInterval, { only: ['viewerStats'], }, { autoStart: channel.is_live && Boolean(currentBroadcast), }, ); function submitChat(event: FormEvent) { event.preventDefault(); chatForm.submit(storeChat(channel.slug), { preserveScroll: true, onSuccess: () => chatForm.reset('body'), }); } function toggleFollow() { if (isFollowing) { router.delete(unfollow(channel.slug), { preserveScroll: true, }); return; } router.post(follow(channel.slug), undefined, { preserveScroll: true, }); } return ( <>
{currentBroadcast?.hls_url ? ( ) : ( )}
{channel.category && ( {channel.category.name} )}

{currentBroadcast?.title ?? channel.display_name}

{channel.display_name} {t(':count viewers', { count: currentViewers, })} {t(':count followers', { count: channel.followers_count, })}
{channel.description && (

{channel.description}

)}
{t('Live chat')} {auth.user ? ( ) : ( )}
{t(':count available', { count: vods.length, })}
{vods.length > 0 ? (
{vods.map((vod) => ( ))}
) : (
{t('No public recordings yet.')}
)}
); } ChannelShow.layout = { breadcrumbs: [ { title: 'Channel', href: home(), }, ], }; function OfflinePlayer({ channel }: { channel: ChannelDetail }) { const { t } = useTranslation(); return (
{channel.banner_url ? ( ) : null}
{t(':channel is offline', { channel: channel.display_name, })}

{t( 'Recent recordings remain available below when the creator publishes VODs.', )}

); } function ChatPanel({ channel, authUser, currentUserId, messages, form, onSubmit, compact = false, }: { channel: ChannelDetail; authUser: boolean; currentUserId: number | null; messages: ChatMessage[]; form: ReturnType>; onSubmit: (event: FormEvent) => void; compact?: boolean; }) { const getInitials = useInitials(); const { t } = useTranslation(); const inputRef = useRef(null); const messagesEndRef = useRef(null); const [emojiPickerOpen, setEmojiPickerOpen] = useState(false); const messageLength = form.data.body.length; const canSend = channel.is_live && form.data.body.trim().length > 0 && !form.processing; useEffect(() => { messagesEndRef.current?.scrollIntoView({ block: 'end' }); }, [messages.length]); function submitChatForm(event: FormEvent) { setEmojiPickerOpen(false); onSubmit(event); } function insertEmoji(emoji: string) { const input = inputRef.current; const cursorStart = input?.selectionStart ?? form.data.body.length; const cursorEnd = input?.selectionEnd ?? form.data.body.length; const nextBody = ( form.data.body.slice(0, cursorStart) + emoji + form.data.body.slice(cursorEnd) ).slice(0, 500); const nextCursor = Math.min( cursorStart + emoji.length, nextBody.length, ); form.setData('body', nextBody); window.requestAnimationFrame(() => { inputRef.current?.focus(); inputRef.current?.setSelectionRange(nextCursor, nextCursor); }); } return (

{t('Live chat')}

{t(':count messages', { count: messages.length, })}

{messages.map((message) => { const isOwnMessage = currentUserId === message.user.id; const isChannelOwner = channel.owner.id === message.user.id; return (
{getInitials(message.user.name)}
{isOwnMessage ? t('You') : message.user.name} {isChannelOwner && ( {t('Owner')} )} {message.created_at && ( )}
{message.body}
); })} {messages.length === 0 && (
{channel.is_live ? t('No messages yet.') : t( 'Chat appears when the channel is live.', )}
)}
{authUser ? (
form.setData('body', event.target.value) } placeholder={ channel.is_live ? t('Send a message') : t('Channel is offline') } disabled={!channel.is_live || form.processing} maxLength={500} />
{emojiPickerOpen && channel.is_live && (
{chatEmojis.map((emoji) => ( ))}
)}
{form.errors.body ?? (!channel.is_live ? t('Channel is offline') : '')} 450 && 'text-primary', )} > {messageLength}/500
) : ( )}
); } function formatChatTime(value: string): string { return new Date(value).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', }); } function formatChatDateTime(value: string): string { return new Date(value).toLocaleString(); } function VodCard({ vod }: { vod: VodSummary }) { const { t } = useTranslation(); return (
{vod.title}
{t('Expires :date', { date: vod.expires_at ? new Date(vod.expires_at).toLocaleDateString() : t('later'), })}
{vod.playback_url ? ( ) : (
{t('Processing recording')}
)}
); } function LiveState({ channel, compact = false, }: { channel: ChannelDetail; compact?: boolean; }) { const { t } = useTranslation(); if (channel.is_live) { return ( {t('LIVE')} ); } return ( {t('Offline')} ); } function Avatar({ channel }: { channel: ChannelDetail }) { return (
{channel.avatar_url ? ( ) : ( )}
); }