Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a0edbe51c | ||
|
|
66c1c5f233 | ||
|
|
f9592521da | ||
|
|
8e65ab42ed | ||
|
|
c8e1ff52ac | ||
|
|
0f68650e37 | ||
|
|
93c3ce29c7 | ||
|
|
94fcde74c9 | ||
|
|
c7397852b7 | ||
|
|
0c4c4fdf8d | ||
|
|
ee308ed2f9 | ||
|
|
c8a5aca21b | ||
|
|
f02a8b21d0 |
@ -1,6 +1,5 @@
|
|||||||
import React, {useEffect, useRef} from 'react'
|
import React, {useEffect, useRef, useState} from 'react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -7,7 +7,11 @@ const menuAside: MenuAsideItem[] = [
|
|||||||
icon: icon.mdiViewDashboardOutline,
|
icon: icon.mdiViewDashboardOutline,
|
||||||
label: 'Dashboard',
|
label: 'Dashboard',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/observation',
|
||||||
|
icon: icon.mdiCamera,
|
||||||
|
label: 'Live Observation',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/users/users-list',
|
href: '/users/users-list',
|
||||||
label: 'Users',
|
label: 'Users',
|
||||||
|
|||||||
@ -1,161 +1,90 @@
|
|||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React from 'react';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
import SectionFullScreen from '../components/SectionFullScreen';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
import { mdiTelescope, mdiCamera, mdiDatabaseSearch } from '@mdi/js';
|
||||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
|
||||||
|
|
||||||
|
|
||||||
export default function Starter() {
|
export default function Starter() {
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
const title = 'JWST Live Sky Explorer'
|
||||||
src: undefined,
|
|
||||||
photographer: undefined,
|
|
||||||
photographer_url: undefined,
|
|
||||||
})
|
|
||||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
|
||||||
const [contentType, setContentType] = useState('image');
|
|
||||||
const [contentPosition, setContentPosition] = useState('right');
|
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
|
||||||
|
|
||||||
const title = 'Live Sky Viewer PWA'
|
|
||||||
|
|
||||||
// Fetch Pexels image/video
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchData() {
|
|
||||||
const image = await getPexelsImage();
|
|
||||||
const video = await getPexelsVideo();
|
|
||||||
setIllustrationImage(image);
|
|
||||||
setIllustrationVideo(video);
|
|
||||||
}
|
|
||||||
fetchData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const imageBlock = (image) => (
|
|
||||||
<div
|
|
||||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
|
||||||
style={{
|
|
||||||
backgroundImage: `${
|
|
||||||
image
|
|
||||||
? `url(${image?.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={image?.photographer_url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Photo by {image?.photographer} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
|
||||||
if (video?.video_files?.length > 0) {
|
|
||||||
return (
|
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
|
||||||
<video
|
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
|
||||||
autoPlay
|
|
||||||
loop
|
|
||||||
muted
|
|
||||||
>
|
|
||||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
|
||||||
Your browser does not support the video tag.
|
|
||||||
</video>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
|
||||||
<a
|
|
||||||
className='text-[8px]'
|
|
||||||
href={video?.user?.url}
|
|
||||||
target='_blank'
|
|
||||||
rel='noreferrer'
|
|
||||||
>
|
|
||||||
Video by {video.user.name} on Pexels
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="bg-[#0B0D17] text-white min-h-screen font-sans selection:bg-[#E3B341] selection:text-black">
|
||||||
style={
|
|
||||||
contentPosition === 'background'
|
|
||||||
? {
|
|
||||||
backgroundImage: `${
|
|
||||||
illustrationImage
|
|
||||||
? `url(${illustrationImage.src?.original})`
|
|
||||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
|
||||||
}`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
backgroundPosition: 'left center',
|
|
||||||
backgroundRepeat: 'no-repeat',
|
|
||||||
}
|
|
||||||
: {}
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Head>
|
<Head>
|
||||||
<title>{getPageTitle('Starter Page')}</title>
|
<title>{getPageTitle('JWST Explorer')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<SectionFullScreen bg="none" className="relative overflow-hidden">
|
||||||
<div
|
{/* Background Decorative Elements */}
|
||||||
className={`flex ${
|
<div className="absolute inset-0 z-0">
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] bg-[#E3B341]/10 rounded-full blur-[120px]"></div>
|
||||||
} min-h-screen w-full`}
|
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-[#00F2FF]/5 rounded-full blur-[150px]"></div>
|
||||||
>
|
<div className="absolute inset-0 opacity-20" style={{ backgroundImage: 'radial-gradient(white 1px, transparent 0)', backgroundSize: '40px 40px' }}></div>
|
||||||
{contentType === 'image' && contentPosition !== 'background'
|
|
||||||
? imageBlock(illustrationImage)
|
|
||||||
: null}
|
|
||||||
{contentType === 'video' && contentPosition !== 'background'
|
|
||||||
? videoBlock(illustrationVideo)
|
|
||||||
: null}
|
|
||||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
|
||||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
|
||||||
<CardBoxComponentTitle title="Welcome to your Live Sky Viewer PWA app!"/>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className='text-center '>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
|
||||||
<p className='text-center '>For guides and documentation please check
|
|
||||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BaseButtons>
|
|
||||||
<BaseButton
|
|
||||||
href='/login'
|
|
||||||
label='Login'
|
|
||||||
color='info'
|
|
||||||
className='w-full'
|
|
||||||
/>
|
|
||||||
|
|
||||||
</BaseButtons>
|
|
||||||
</CardBox>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</SectionFullScreen>
|
|
||||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
|
||||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
|
||||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
|
||||||
Privacy Policy
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div className="relative z-10 container mx-auto px-6 py-12 flex flex-col items-center justify-center min-h-screen text-center">
|
||||||
|
<div className="mb-8 animate-pulse">
|
||||||
|
<div className="w-24 h-24 md:w-32 md:h-32 bg-[#E3B341] rounded-full flex items-center justify-center shadow-[0_0_50px_rgba(227,179,65,0.4)]">
|
||||||
|
<BaseIcon path={mdiTelescope} size={64} className="text-[#0B0D17]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-5xl md:text-7xl font-bold tracking-tighter mb-4 text-transparent bg-clip-text bg-gradient-to-b from-white to-gray-400">
|
||||||
|
JWST <span className="text-[#E3B341]">LIVE</span> EXPLORER
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl md:text-2xl text-gray-400 max-w-2xl mb-12 font-light">
|
||||||
|
Deploy the world's most powerful infrared eye. Observe the deep universe in real-time through the lens of a physical simulation.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-6">
|
||||||
|
<BaseButton
|
||||||
|
href="/observation"
|
||||||
|
label="Initiate Observation"
|
||||||
|
color="white"
|
||||||
|
icon={mdiCamera}
|
||||||
|
className="px-10 py-4 text-lg border-2 border-[#E3B341] bg-[#E3B341] text-[#0B0D17] hover:bg-transparent hover:text-[#E3B341] transition-all duration-300 rounded-none uppercase font-bold tracking-widest"
|
||||||
|
/>
|
||||||
|
<BaseButton
|
||||||
|
href="/login"
|
||||||
|
label="Access Archives"
|
||||||
|
color="white"
|
||||||
|
icon={mdiDatabaseSearch}
|
||||||
|
className="px-10 py-4 text-lg border-2 border-white/20 bg-white/5 hover:bg-white/10 transition-all duration-300 rounded-none uppercase font-bold tracking-widest"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-24 grid grid-cols-1 md:grid-cols-3 gap-8 w-full max-w-5xl">
|
||||||
|
<div className="p-6 border border-white/10 bg-white/5 backdrop-blur-sm">
|
||||||
|
<h3 className="text-[#E3B341] font-bold mb-2 uppercase tracking-widest">IR Spectrum</h3>
|
||||||
|
<p className="text-sm text-gray-400">Visualize wavelengths beyond the visible light, piercing through cosmic dust.</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 border border-white/10 bg-white/5 backdrop-blur-sm">
|
||||||
|
<h3 className="text-[#00F2FF] font-bold mb-2 uppercase tracking-widest">Deep Field</h3>
|
||||||
|
<p className="text-sm text-gray-400">High-resolution scans of distant galaxies from NASA's latest data releases.</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 border border-white/10 bg-white/5 backdrop-blur-sm">
|
||||||
|
<h3 className="text-white font-bold mb-2 uppercase tracking-widest">Live Sync</h3>
|
||||||
|
<p className="text-sm text-gray-400">Sync with your device's camera for an augmented reality sky identification.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionFullScreen>
|
||||||
|
|
||||||
|
<footer className="relative z-10 border-t border-white/10 py-8 bg-[#0B0D17]">
|
||||||
|
<div className="container mx-auto px-6 flex flex-col md:flex-row justify-between items-center text-sm text-gray-500">
|
||||||
|
<p>© 2026 {title}. Deep Space Simulation Protocol active.</p>
|
||||||
|
<div className="flex space-x-6 mt-4 md:mt-0">
|
||||||
|
<Link href="/privacy-policy" className="hover:text-white transition-colors">Security Manual</Link>
|
||||||
|
<Link href="/terms-of-use" className="hover:text-white transition-colors">Mission Parameters</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -163,4 +92,3 @@ export default function Starter() {
|
|||||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
981
frontend/src/pages/observation.tsx
Normal file
981
frontend/src/pages/observation.tsx
Normal file
@ -0,0 +1,981 @@
|
|||||||
|
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import {
|
||||||
|
mdiClose,
|
||||||
|
mdiRecord,
|
||||||
|
mdiStop,
|
||||||
|
mdiDownload,
|
||||||
|
mdiMusicNote,
|
||||||
|
mdiMicrophone,
|
||||||
|
mdiMicrophoneOff,
|
||||||
|
mdiHeadphones,
|
||||||
|
mdiPlay,
|
||||||
|
mdiPause,
|
||||||
|
mdiVolumeHigh,
|
||||||
|
mdiAutoFix,
|
||||||
|
mdiCreation,
|
||||||
|
mdiMagnify,
|
||||||
|
mdiFilterVariant,
|
||||||
|
mdiEarth,
|
||||||
|
mdiGuitarAcoustic,
|
||||||
|
mdiFlash,
|
||||||
|
mdiHeartBroken,
|
||||||
|
mdiDancePole,
|
||||||
|
mdiEye,
|
||||||
|
mdiEyeOff,
|
||||||
|
mdiUpload,
|
||||||
|
mdiCheckCircle,
|
||||||
|
mdiCog,
|
||||||
|
mdiRefresh,
|
||||||
|
mdiTrashCan,
|
||||||
|
mdiAlertCircle,
|
||||||
|
mdiTune,
|
||||||
|
mdiVolumeVariantOff,
|
||||||
|
mdiSync,
|
||||||
|
} from '@mdi/js';
|
||||||
|
import BaseIcon from '../components/BaseIcon';
|
||||||
|
import { useAppDispatch } from '../stores/hooks';
|
||||||
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
|
||||||
|
// Extensive song database simulation
|
||||||
|
const INITIAL_SONG_DATABASE = [
|
||||||
|
// SERTANEJO
|
||||||
|
{ id: 's1', genre: 'Sertanejo', title: 'Evidências', artist: 'Chitãozinho & Xororó', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3' },
|
||||||
|
{ id: 's2', genre: 'Sertanejo', title: 'Boate Azul', artist: 'Bruno & Marrone', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3' },
|
||||||
|
{ id: 's3', genre: 'Sertanejo', title: 'Dormir na Praça', artist: 'Bruno & Marrone', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3' },
|
||||||
|
{ id: 's4', genre: 'Sertanejo', title: 'Fio de Cabelo', artist: 'Chitãozinho & Xororó', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-4.mp3' },
|
||||||
|
{ id: 's5', genre: 'Sertanejo', title: 'Ainda Ontem Chorei de Saudade', artist: 'João Mineiro & Marciano', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-5.mp3' },
|
||||||
|
{ id: 's6', genre: 'Sertanejo', title: 'Infiel', artist: 'Marília Mendonça', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-6.mp3' },
|
||||||
|
{ id: 's7', genre: 'Sertanejo', title: 'Notificação Preferida', artist: 'Zé Neto & Cristiano', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-7.mp3' },
|
||||||
|
{ id: 's8', genre: 'Sertanejo', title: 'Propaganda', artist: 'Jorge & Mateus', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-8.mp3' },
|
||||||
|
|
||||||
|
// MPB
|
||||||
|
{ id: 'm1', genre: 'MPB', title: 'Águas de Março', artist: 'Elis Regina & Tom Jobim', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-9.mp3' },
|
||||||
|
{ id: 'm2', genre: 'MPB', title: 'Sina', artist: 'Djavan', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-10.mp3' },
|
||||||
|
{ id: 'm3', genre: 'MPB', title: 'Garota de Ipanema', artist: 'Vinícius de Moraes', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-11.mp3' },
|
||||||
|
{ id: 'm4', genre: 'MPB', title: 'Aquele Abraço', artist: 'Gilberto Gil', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-12.mp3' },
|
||||||
|
{ id: 'm5', genre: 'MPB', title: 'Como Nossos Pais', artist: 'Elis Regina', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-13.mp3' },
|
||||||
|
|
||||||
|
// FORRÓ
|
||||||
|
{ id: 'f1', genre: 'Forró', title: 'Asa Branca', artist: 'Luiz Gonzaga', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-14.mp3' },
|
||||||
|
{ id: 'f2', genre: 'Forró', title: 'Pagode em Brasília', artist: 'Tião Carreiro', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-15.mp3' },
|
||||||
|
{ id: 'f3', genre: 'Forró', title: 'Xote das Meninas', artist: 'Luiz Gonzaga', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-16.mp3' },
|
||||||
|
{ id: 'f4', genre: 'Forró', title: 'Rindo à Toa', artist: 'Falamansa', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3' },
|
||||||
|
|
||||||
|
// SOFRÊNCIA
|
||||||
|
{ id: 'so1', genre: 'Sofrência', title: 'Porque Homem Não Chora', artist: 'Pablo', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3' },
|
||||||
|
{ id: 'so2', genre: 'Sofrência', title: 'Alô Porteiro', artist: 'Marília Mendonça', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3' },
|
||||||
|
{ id: 'so3', genre: 'Sofrência', title: 'Dez de Dezembro', artist: 'Tayrone', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-4.mp3' },
|
||||||
|
|
||||||
|
// LAMBADA
|
||||||
|
{ id: 'l1', genre: 'Lambada', title: 'Chorando se Foi', artist: 'Kaoma', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-5.mp3' },
|
||||||
|
{ id: 'l2', genre: 'Lambada', title: 'Adocica', artist: 'Beto Barbosa', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-6.mp3' },
|
||||||
|
{ id: 'l3', genre: 'Lambada', title: 'Preta', artist: 'Beto Barbosa', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-7.mp3' },
|
||||||
|
|
||||||
|
// FLASHBACK
|
||||||
|
{ id: 'fb1', genre: 'Flashback', title: 'Billie Jean', artist: 'Michael Jackson', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-8.mp3' },
|
||||||
|
{ id: 'fb2', genre: 'Flashback', title: 'Bohemian Rhapsody', artist: 'Queen', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-9.mp3' },
|
||||||
|
{ id: 'fb3', genre: 'Flashback', title: 'Dancing Queen', artist: 'ABBA', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-10.mp3' },
|
||||||
|
{ id: 'fb4', genre: 'Flashback', title: 'Stayin Alive', artist: 'Bee Gees', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-11.mp3' },
|
||||||
|
{ id: 'fb5', genre: 'Flashback', title: 'I Will Always Love You', artist: 'Whitney Houston', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-12.mp3' },
|
||||||
|
{ id: 'fb6', genre: 'Flashback', title: 'Take on Me', artist: 'A-ha', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-13.mp3' },
|
||||||
|
{ id: 'fb7', genre: 'Flashback', title: 'Girls Just Want to Have Fun', artist: 'Cyndi Lauper', url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-14.mp3' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add generic classics
|
||||||
|
for (let i = 1; i <= 200; i++) {
|
||||||
|
const genres = ['Sertanejo', 'MPB', 'Forró', 'Sofrência', 'Lambada', 'Flashback'];
|
||||||
|
const genre = genres[Math.floor(Math.random() * genres.length)];
|
||||||
|
INITIAL_SONG_DATABASE.push({
|
||||||
|
id: `ext-${i}`,
|
||||||
|
genre: genre,
|
||||||
|
title: `${genre} Classic Vol ${i}`,
|
||||||
|
artist: `Various Artists`,
|
||||||
|
url: `https://www.soundhelix.com/examples/mp3/SoundHelix-Song-${(i % 16) + 1}.mp3`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const GENRES = [
|
||||||
|
{ name: 'Todos', icon: mdiEarth },
|
||||||
|
{ name: 'Sertanejo', icon: mdiGuitarAcoustic },
|
||||||
|
{ name: 'MPB', icon: mdiMusicNote },
|
||||||
|
{ name: 'Forró', icon: mdiFlash },
|
||||||
|
{ name: 'Sofrência', icon: mdiHeartBroken },
|
||||||
|
{ name: 'Lambada', icon: mdiDancePole },
|
||||||
|
{ name: 'Flashback', icon: mdiCreation }
|
||||||
|
];
|
||||||
|
|
||||||
|
const REAL_PEOPLE_AVATARS = [
|
||||||
|
{ name: 'Ricardo de Goiânia', img: 'https://images.pexels.com/photos/220453/pexels-photo-220453.jpeg?auto=compress&cs=tinysrgb&w=200' },
|
||||||
|
{ name: 'Letícia de Barretos', img: 'https://images.pexels.com/photos/774909/pexels-photo-774909.jpeg?auto=compress&cs=tinysrgb&w=200' },
|
||||||
|
{ name: 'João de Cuiabá', img: 'https://images.pexels.com/photos/1239291/pexels-photo-1239291.jpeg?auto=compress&cs=tinysrgb&w=200' },
|
||||||
|
{ name: 'Bruna de Uberlândia', img: 'https://images.pexels.com/photos/712513/pexels-photo-712513.jpeg?auto=compress&cs=tinysrgb&w=200' },
|
||||||
|
{ name: 'Michael from NY', img: 'https://images.pexels.com/photos/614810/pexels-photo-614810.jpeg?auto=compress&cs=tinysrgb&w=200' },
|
||||||
|
{ name: 'Sophie from Paris', img: 'https://images.pexels.com/photos/415829/pexels-photo-415829.jpeg?auto=compress&cs=tinysrgb&w=200' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const CROWD_SOUND_URL = 'https://assets.mixkit.co/sfx/preview/mixkit-stadium-crowd-light-applause-362.mp3';
|
||||||
|
|
||||||
|
const ObservationPage = () => {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const crowdAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const karaokeAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||||
|
const audioDestRef = useRef<MediaStreamAudioDestinationNode | null>(null);
|
||||||
|
const micNodeRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||||
|
const karaokeNodeRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||||
|
const reverbNodeRef = useRef<ConvolverNode | null>(null);
|
||||||
|
const dryGainRef = useRef<GainNode | null>(null);
|
||||||
|
const wetGainRef = useRef<GainNode | null>(null);
|
||||||
|
const monitorGainRef = useRef<GainNode | null>(null);
|
||||||
|
const masterGainRef = useRef<GainNode | null>(null);
|
||||||
|
const heartbeatOscRef = useRef<OscillatorNode | null>(null);
|
||||||
|
|
||||||
|
const [isCameraActive, setIsCameraActive] = useState(false);
|
||||||
|
const [isKaraokeActive, setIsKaraokeActive] = useState(false);
|
||||||
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
|
const [currentSong, setCurrentSong] = useState<any>(null);
|
||||||
|
const [currentLyrics, setCurrentLyrics] = useState('');
|
||||||
|
const [isMicOn, setIsMicOn] = useState(true);
|
||||||
|
const [isMonitorOn, setIsMonitorOn] = useState(false);
|
||||||
|
const [reverbLevel, setReverbLevel] = useState(0.5);
|
||||||
|
const [playbackProgress, setPlaybackProgress] = useState(0);
|
||||||
|
const [audioStatus, setAudioStatus] = useState<'IDLE' | 'LOADING' | 'ACTIVE' | 'ERROR' | 'SYNCING'>('IDLE');
|
||||||
|
const [volumeLevel, setVolumeLevel] = useState(1.0);
|
||||||
|
const [audioErrorMsg, setAudioErrorMsg] = useState('');
|
||||||
|
|
||||||
|
const [audienceCount, setAudienceCount] = useState(0);
|
||||||
|
const [isSimActive, setIsSimActive] = useState(false);
|
||||||
|
const [activeViewers, setActiveViewers] = useState<any[]>([]);
|
||||||
|
const [featuredViewer, setFeaturedViewer] = useState<any>(null);
|
||||||
|
|
||||||
|
const [showPlaylist, setShowPlaylist] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedGenre, setSelectedGenre] = useState('Todos');
|
||||||
|
const [isInterfaceVisible, setIsInterfaceVisible] = useState(true);
|
||||||
|
|
||||||
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
|
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);
|
||||||
|
const [videoUrl, setVideoUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Studio Configuration States
|
||||||
|
const [userSongs, setUserSongs] = useState<any[]>([]);
|
||||||
|
const [customAudioMap, setCustomAudioMap] = useState<Record<string, string>>({});
|
||||||
|
const [songToUpload, setSongToUpload] = useState<any>(null);
|
||||||
|
const [editingSongId, setEditingSongId] = useState<string | null>(null);
|
||||||
|
const [syncProgress, setSyncProgress] = useState(0);
|
||||||
|
|
||||||
|
const imageCache = useRef<Map<string, HTMLImageElement>>(new Map());
|
||||||
|
|
||||||
|
// Combined song database
|
||||||
|
const fullSongDatabase = useMemo(() => {
|
||||||
|
return [...userSongs, ...INITIAL_SONG_DATABASE];
|
||||||
|
}, [userSongs]);
|
||||||
|
|
||||||
|
// Global Audio Master Unlock Engine
|
||||||
|
// This ensures that ANY interaction with the screen resumes the audio engine
|
||||||
|
useEffect(() => {
|
||||||
|
const unlockAudio = async () => {
|
||||||
|
if (!audioCtxRef.current) {
|
||||||
|
audioCtxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioCtxRef.current.state === 'suspended') {
|
||||||
|
try {
|
||||||
|
await audioCtxRef.current.resume();
|
||||||
|
|
||||||
|
// HEARTBEAT: Constant silent oscillator to keep the context alive
|
||||||
|
if (!heartbeatOscRef.current) {
|
||||||
|
const osc = audioCtxRef.current.createOscillator();
|
||||||
|
const gain = audioCtxRef.current.createGain();
|
||||||
|
gain.gain.value = 0.00001; // Inaudible
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(audioCtxRef.current.destination);
|
||||||
|
osc.start();
|
||||||
|
heartbeatOscRef.current = osc;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject a silent buffer to "prime" the browser's audio engine
|
||||||
|
const buffer = audioCtxRef.current.createBuffer(1, 1, 22050);
|
||||||
|
const source = audioCtxRef.current.createBufferSource();
|
||||||
|
source.buffer = buffer;
|
||||||
|
source.connect(audioCtxRef.current.destination);
|
||||||
|
source.start(0);
|
||||||
|
} catch (err) {
|
||||||
|
// Silent catch to avoid console noise on failed gestures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proactive HTML5 element unlock
|
||||||
|
if (karaokeAudioRef.current && karaokeAudioRef.current.paused && isKaraokeActive && !isPaused) {
|
||||||
|
karaokeAudioRef.current.play().catch(() => { /* Silent */ });
|
||||||
|
}
|
||||||
|
if (crowdAudioRef.current && crowdAudioRef.current.paused && isSimActive) {
|
||||||
|
crowdAudioRef.current.play().catch(() => { /* Silent */ });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const interactions = ['click', 'touchstart', 'mousedown', 'keydown', 'pointerdown'];
|
||||||
|
interactions.forEach(event => window.addEventListener(event, unlockAudio, { passive: true, capture: true }));
|
||||||
|
|
||||||
|
// Auto-monitor state and visibility change handling
|
||||||
|
const handleVisibility = () => { if (document.visibilityState === 'visible') unlockAudio(); };
|
||||||
|
window.addEventListener('visibilitychange', handleVisibility);
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (audioCtxRef.current?.state === 'suspended' && (isKaraokeActive || isCameraActive)) {
|
||||||
|
unlockAudio();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
interactions.forEach(event => window.removeEventListener(event, unlockAudio));
|
||||||
|
window.removeEventListener('visibilitychange', handleVisibility);
|
||||||
|
clearInterval(interval);
|
||||||
|
};
|
||||||
|
}, [isKaraokeActive, isPaused, isSimActive, isCameraActive]);
|
||||||
|
|
||||||
|
// Persistent karaoke audio setup
|
||||||
|
useEffect(() => {
|
||||||
|
const audio = new Audio();
|
||||||
|
audio.preload = "auto";
|
||||||
|
audio.volume = 1.0;
|
||||||
|
karaokeAudioRef.current = audio;
|
||||||
|
|
||||||
|
const updateLyrics = () => {
|
||||||
|
setPlaybackProgress(audio.currentTime);
|
||||||
|
if (audio.duration > 0) {
|
||||||
|
const texts = [
|
||||||
|
"VAI NO FUNDO DO PEITO!",
|
||||||
|
"SENTE A VIBE DO SUCESSO!",
|
||||||
|
"AO VIVO PARA TODO O PLANETA!",
|
||||||
|
"SOLTA A VOZ, O PALCO É SEU!",
|
||||||
|
"EMOCIONA ESSA GALERA!",
|
||||||
|
"QUE PERFORMANCE INCRÍVEL!",
|
||||||
|
"VOCÊ ESTÁ ARRASANDO MUITO!",
|
||||||
|
"EXPLODIU O CORAÇÃO DE TODOS!",
|
||||||
|
"VIVA O MELHOR DA MÚSICA!",
|
||||||
|
"A GALERA ESTÁ INDO À LOUCURA!"
|
||||||
|
];
|
||||||
|
const index = Math.floor(audio.currentTime / 5) % texts.length;
|
||||||
|
setCurrentLyrics(texts[index]);
|
||||||
|
if (audioStatus !== 'SYNCING') setAudioStatus('ACTIVE');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
audio.addEventListener('timeupdate', updateLyrics);
|
||||||
|
audio.addEventListener('play', () => { if (audioStatus !== 'SYNCING') setAudioStatus('ACTIVE'); setAudioErrorMsg(''); });
|
||||||
|
audio.addEventListener('waiting', () => { if (audioStatus !== 'SYNCING') setAudioStatus('LOADING'); });
|
||||||
|
audio.addEventListener('error', (e) => {
|
||||||
|
console.error("Audio Element Error:", audio.error);
|
||||||
|
setAudioStatus('ERROR');
|
||||||
|
setAudioErrorMsg(audio.error?.message || 'Erro ao carregar o áudio. Tente outro arquivo.');
|
||||||
|
});
|
||||||
|
audio.addEventListener('ended', () => {
|
||||||
|
setIsKaraokeActive(false);
|
||||||
|
setCurrentSong(null);
|
||||||
|
setAudioStatus('IDLE');
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
audio.removeEventListener('timeupdate', updateLyrics);
|
||||||
|
audio.pause();
|
||||||
|
audio.src = "";
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredSongs = useMemo(() => {
|
||||||
|
return fullSongDatabase.filter(song => {
|
||||||
|
const matchesSearch = song.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
song.artist.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesGenre = selectedGenre === 'Todos' || song.genre === selectedGenre;
|
||||||
|
return matchesSearch && matchesGenre;
|
||||||
|
});
|
||||||
|
}, [fullSongDatabase, searchQuery, selectedGenre]);
|
||||||
|
|
||||||
|
const initAudioContext = async (stream?: MediaStream) => {
|
||||||
|
try {
|
||||||
|
if (!audioCtxRef.current) {
|
||||||
|
audioCtxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioCtxRef.current.state === 'suspended') {
|
||||||
|
await audioCtxRef.current.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!audioDestRef.current) {
|
||||||
|
audioDestRef.current = audioCtxRef.current.createMediaStreamDestination();
|
||||||
|
masterGainRef.current = audioCtxRef.current.createGain();
|
||||||
|
masterGainRef.current.gain.value = 1.0;
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
micNodeRef.current = audioCtxRef.current.createMediaStreamSource(stream);
|
||||||
|
|
||||||
|
dryGainRef.current = audioCtxRef.current.createGain();
|
||||||
|
wetGainRef.current = audioCtxRef.current.createGain();
|
||||||
|
monitorGainRef.current = audioCtxRef.current.createGain();
|
||||||
|
reverbNodeRef.current = audioCtxRef.current.createConvolver();
|
||||||
|
|
||||||
|
const length = 2 * audioCtxRef.current.sampleRate;
|
||||||
|
const impulse = audioCtxRef.current.createBuffer(2, length, audioCtxRef.current.sampleRate);
|
||||||
|
for (let i = 0; i < 2; i++) {
|
||||||
|
const channel = impulse.getChannelData(i);
|
||||||
|
for (let j = 0; j < length; j++) {
|
||||||
|
channel[j] = (Math.random() * 2 - 1) * Math.pow(1 - j / length, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reverbNodeRef.current.buffer = impulse;
|
||||||
|
|
||||||
|
micNodeRef.current.connect(dryGainRef.current);
|
||||||
|
micNodeRef.current.connect(reverbNodeRef.current);
|
||||||
|
micNodeRef.current.connect(monitorGainRef.current);
|
||||||
|
|
||||||
|
dryGainRef.current.connect(audioDestRef.current);
|
||||||
|
reverbNodeRef.current.connect(wetGainRef.current);
|
||||||
|
wetGainRef.current.connect(audioDestRef.current);
|
||||||
|
|
||||||
|
monitorGainRef.current.connect(audioCtxRef.current.destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
masterGainRef.current.connect(audioCtxRef.current.destination);
|
||||||
|
|
||||||
|
if (karaokeAudioRef.current && !karaokeNodeRef.current) {
|
||||||
|
karaokeNodeRef.current = audioCtxRef.current.createMediaElementSource(karaokeAudioRef.current);
|
||||||
|
karaokeNodeRef.current.connect(audioDestRef.current);
|
||||||
|
karaokeNodeRef.current.connect(masterGainRef.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Audio Context Init Error:", err);
|
||||||
|
setAudioStatus('ERROR');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncAll = async () => {
|
||||||
|
setAudioStatus('SYNCING');
|
||||||
|
setSyncProgress(0);
|
||||||
|
setCurrentLyrics("SINCRONIZANDO COM O NAVEGADOR...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!audioCtxRef.current) await initAudioContext();
|
||||||
|
if (audioCtxRef.current) await audioCtxRef.current.resume();
|
||||||
|
|
||||||
|
const localIds = Object.keys(customAudioMap);
|
||||||
|
for (let i = 0; i < localIds.length; i++) {
|
||||||
|
const id = localIds[i];
|
||||||
|
const url = customAudioMap[id];
|
||||||
|
const temp = new Audio();
|
||||||
|
temp.muted = true;
|
||||||
|
temp.src = url;
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
temp.oncanplaythrough = resolve;
|
||||||
|
temp.onerror = resolve;
|
||||||
|
temp.load();
|
||||||
|
setTimeout(resolve, 300);
|
||||||
|
});
|
||||||
|
setSyncProgress(((i + 1) / (localIds.length || 1)) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (karaokeAudioRef.current) {
|
||||||
|
karaokeAudioRef.current.muted = false;
|
||||||
|
karaokeAudioRef.current.volume = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAudioStatus('IDLE');
|
||||||
|
setCurrentLyrics("SISTEMA SINCRONIZADO E ATIVO!");
|
||||||
|
setTimeout(() => setCurrentLyrics(""), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Sync Error:", err);
|
||||||
|
setAudioStatus('ERROR');
|
||||||
|
setAudioErrorMsg("FALHA NA SINCRONIZAÇÃO GLOBAL");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startCamera = async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { facingMode: 'user', width: { ideal: 1920 }, height: { ideal: 1080 } },
|
||||||
|
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }
|
||||||
|
});
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = stream;
|
||||||
|
setIsCameraActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await initAudioContext(stream);
|
||||||
|
await syncAll();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Access denied:", err);
|
||||||
|
alert("Por favor, permita o acesso à câmera e microfone para iniciar o show.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dryGainRef.current && wetGainRef.current && monitorGainRef.current) {
|
||||||
|
dryGainRef.current.gain.value = isMicOn ? 1.0 : 0;
|
||||||
|
wetGainRef.current.gain.value = isMicOn ? reverbLevel : 0;
|
||||||
|
monitorGainRef.current.gain.value = isMonitorOn ? 1.0 : 0;
|
||||||
|
}
|
||||||
|
if (masterGainRef.current) {
|
||||||
|
masterGainRef.current.gain.value = volumeLevel;
|
||||||
|
}
|
||||||
|
}, [isMicOn, reverbLevel, isMonitorOn, volumeLevel]);
|
||||||
|
|
||||||
|
const getCachedImage = useCallback((url: string) => {
|
||||||
|
if (imageCache.current.has(url)) return imageCache.current.get(url);
|
||||||
|
const img = new (window as any).Image(); img.crossOrigin = "anonymous"; img.src = url;
|
||||||
|
imageCache.current.set(url, img);
|
||||||
|
return img;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectSong = async (song: any) => {
|
||||||
|
const audio = karaokeAudioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
|
||||||
|
// IMMEDIATE: Ensure context is resumed synchronously in the event handler stack
|
||||||
|
if (!audioCtxRef.current) {
|
||||||
|
audioCtxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
|
||||||
|
}
|
||||||
|
if (audioCtxRef.current?.state === 'suspended') {
|
||||||
|
audioCtxRef.current.resume().catch(() => {
|
||||||
|
// Silent recovery as per Nuclear engine mandate
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsKaraokeActive(true);
|
||||||
|
setCurrentSong(song);
|
||||||
|
setPlaybackProgress(0);
|
||||||
|
setAudioStatus('LOADING');
|
||||||
|
setAudioErrorMsg('');
|
||||||
|
setCurrentLyrics("SINCRONIZANDO PLAYBACK...");
|
||||||
|
setIsPaused(false);
|
||||||
|
setIsSimActive(true);
|
||||||
|
setShowPlaylist(false);
|
||||||
|
|
||||||
|
audio.pause();
|
||||||
|
audio.muted = false;
|
||||||
|
audio.volume = 1.0;
|
||||||
|
|
||||||
|
const targetUrl = customAudioMap[song.id] || song.url;
|
||||||
|
if (targetUrl.startsWith('blob:')) {
|
||||||
|
audio.removeAttribute('crossorigin');
|
||||||
|
} else {
|
||||||
|
audio.crossOrigin = "anonymous";
|
||||||
|
}
|
||||||
|
|
||||||
|
audio.src = targetUrl;
|
||||||
|
audio.load();
|
||||||
|
|
||||||
|
// NO TIMEOUT: Call play() immediately to preserve user gesture chain
|
||||||
|
try {
|
||||||
|
const playPromise = audio.play();
|
||||||
|
if (playPromise !== undefined) {
|
||||||
|
await playPromise;
|
||||||
|
setAudioStatus('ACTIVE');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Playback error:", err);
|
||||||
|
setCurrentLyrics("ERRO NO ÁUDIO - ATIVE NO BOTÃO ABAIXO");
|
||||||
|
setAudioStatus('ERROR');
|
||||||
|
setAudioErrorMsg('Bloqueio do Navegador. Clique em "REINICIAR ÁUDIO".');
|
||||||
|
setIsPaused(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlayback = () => {
|
||||||
|
const audio = karaokeAudioRef.current;
|
||||||
|
if (!audio) return;
|
||||||
|
if (audio.paused) {
|
||||||
|
audio.play().catch(e => {
|
||||||
|
console.error(e);
|
||||||
|
setAudioStatus('ERROR');
|
||||||
|
});
|
||||||
|
setIsPaused(false);
|
||||||
|
} else {
|
||||||
|
audio.pause();
|
||||||
|
setIsPaused(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file && songToUpload) {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
|
||||||
|
if (songToUpload.id === 'NEW_CUSTOM') {
|
||||||
|
const newId = `user-${Date.now()}`;
|
||||||
|
const newSong = {
|
||||||
|
id: newId,
|
||||||
|
title: file.name.split('.')[0].toUpperCase(),
|
||||||
|
artist: 'ARQUIVO LOCAL',
|
||||||
|
genre: 'Personalizado',
|
||||||
|
url: url,
|
||||||
|
isUserAdded: true
|
||||||
|
};
|
||||||
|
setUserSongs(prev => [newSong, ...prev]);
|
||||||
|
setCustomAudioMap(prev => ({ ...prev, [newId]: url }));
|
||||||
|
} else {
|
||||||
|
setCustomAudioMap(prev => {
|
||||||
|
if (prev[songToUpload.id]) URL.revokeObjectURL(prev[songToUpload.id]);
|
||||||
|
return { ...prev, [songToUpload.id]: url };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentSong?.id === songToUpload.id && karaokeAudioRef.current) {
|
||||||
|
const wasPaused = karaokeAudioRef.current.paused;
|
||||||
|
karaokeAudioRef.current.removeAttribute('crossorigin');
|
||||||
|
karaokeAudioRef.current.src = url;
|
||||||
|
karaokeAudioRef.current.load();
|
||||||
|
if (!wasPaused) karaokeAudioRef.current.play().catch(e => console.error(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSongToUpload(null);
|
||||||
|
}
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetToOriginal = (e: React.MouseEvent, songId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setCustomAudioMap(prev => {
|
||||||
|
if (prev[songId]) URL.revokeObjectURL(prev[songId]);
|
||||||
|
const newMap = { ...prev };
|
||||||
|
delete newMap[songId];
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
if (currentSong?.id === songId && karaokeAudioRef.current) {
|
||||||
|
const original = INITIAL_SONG_DATABASE.find(s => s.id === songId);
|
||||||
|
if (original) {
|
||||||
|
karaokeAudioRef.current.crossOrigin = "anonymous";
|
||||||
|
karaokeAudioRef.current.src = original.url;
|
||||||
|
karaokeAudioRef.current.load();
|
||||||
|
karaokeAudioRef.current.play().catch(e => console.error(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeUserSong = (e: React.MouseEvent, songId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setUserSongs(prev => prev.filter(s => s.id !== songId));
|
||||||
|
setCustomAudioMap(prev => {
|
||||||
|
if (prev[songId]) URL.revokeObjectURL(prev[songId]);
|
||||||
|
const newMap = { ...prev };
|
||||||
|
delete newMap[songId];
|
||||||
|
return newMap;
|
||||||
|
});
|
||||||
|
if (currentSong?.id === songId) {
|
||||||
|
setIsKaraokeActive(false);
|
||||||
|
setCurrentSong(null);
|
||||||
|
karaokeAudioRef.current?.pause();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerUpload = (e: React.MouseEvent, song: any) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSongToUpload(song);
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let animationFrame: number;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!canvas || !video) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
if (isCameraActive) {
|
||||||
|
ctx.fillStyle = 'black';
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
if (video.readyState >= 2) {
|
||||||
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSimActive) {
|
||||||
|
activeViewers.forEach((viewer, i) => {
|
||||||
|
const vImg = getCachedImage(viewer.img);
|
||||||
|
if (vImg?.complete) {
|
||||||
|
const x = 20 + (i % 4) * 110; const y = 100 + Math.floor(i / 4) * 110; const size = 100;
|
||||||
|
ctx.save(); ctx.strokeStyle = '#E3B341'; ctx.lineWidth = 2; ctx.strokeRect(x, y, size, size);
|
||||||
|
ctx.drawImage(vImg, x, y, size, size);
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.8)'; ctx.fillRect(x, y + size - 20, size, 20);
|
||||||
|
ctx.fillStyle = 'white'; ctx.font = 'bold 12px sans-serif'; ctx.fillText(viewer.name, x + 5, y + size - 5);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (featuredViewer) {
|
||||||
|
const fImg = getCachedImage(featuredViewer.img);
|
||||||
|
if (fImg?.complete) {
|
||||||
|
const x = canvas.width - 320; const y = 100; const w = 300; const h = 300;
|
||||||
|
ctx.save(); ctx.strokeStyle = '#00F2FF'; ctx.lineWidth = 5; ctx.strokeRect(x, y, w, h);
|
||||||
|
ctx.drawImage(fImg, x, y, w, h);
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.8)'; ctx.fillRect(x, y + h - 40, w, 40);
|
||||||
|
ctx.fillStyle = '#00F2FF'; ctx.font = 'bold 18px sans-serif'; ctx.fillText(`DUETO COM: ${featuredViewer.name}`, x + 10, y + h - 12);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((isKaraokeActive && currentSong) || audioStatus === 'SYNCING') {
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.85)';
|
||||||
|
ctx.fillRect(canvas.width/2 - 500, canvas.height - 250, 1000, 180);
|
||||||
|
ctx.strokeStyle = audioStatus === 'ERROR' ? '#FF0000' : audioStatus === 'SYNCING' ? '#00F2FF' : '#E3B341'; ctx.lineWidth = 4;
|
||||||
|
ctx.strokeRect(canvas.width/2 - 500, canvas.height - 250, 1000, 180);
|
||||||
|
ctx.fillStyle = audioStatus === 'SYNCING' ? '#00F2FF' : '#E3B341'; ctx.font = 'bold 20px monospace'; ctx.textAlign = 'center';
|
||||||
|
ctx.fillText(audioStatus === 'SYNCING' ? "🔄 ATIVANDO TODOS OS ÁUDIOS DO DISPOSITIVO" : `🎤 ${currentSong?.title?.toUpperCase()} - ${currentSong?.artist?.toUpperCase()} (${currentSong?.genre})`, canvas.width/2, canvas.height - 210);
|
||||||
|
|
||||||
|
if (audioStatus === 'ERROR') {
|
||||||
|
ctx.fillStyle = '#FF5555'; ctx.font = 'bold 32px sans-serif';
|
||||||
|
ctx.fillText(audioErrorMsg || "ERRO CRÍTICO NO ÁUDIO", canvas.width/2, canvas.height - 140);
|
||||||
|
} else {
|
||||||
|
ctx.fillStyle = 'white'; ctx.font = 'bold 48px sans-serif';
|
||||||
|
ctx.fillText(currentLyrics || "SOLTA O SOM!", canvas.width/2, canvas.height - 140);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.1)'; ctx.fillRect(canvas.width/2 - 400, canvas.height - 100, 800, 10);
|
||||||
|
const progress = audioStatus === 'SYNCING' ? syncProgress / 100 : (playbackProgress / (karaokeAudioRef.current?.duration || 1));
|
||||||
|
ctx.fillStyle = audioStatus === 'SYNCING' ? '#00F2FF' : audioStatus === 'ACTIVE' ? '#E3B341' : '#666666'; ctx.fillRect(canvas.width/2 - 400, canvas.height - 100, progress * 800, 10);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(255,0,0,0.8)'; ctx.font = 'bold 20px monospace';
|
||||||
|
ctx.fillText(`🔴 AO VIVO: ${audienceCount.toLocaleString()} PESSOAS`, 30, 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
animationFrame = requestAnimationFrame(render);
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
return () => cancelAnimationFrame(animationFrame);
|
||||||
|
}, [isCameraActive, isSimActive, activeViewers, featuredViewer, isKaraokeActive, currentLyrics, currentSong, getCachedImage, audienceCount, playbackProgress, audioStatus, audioErrorMsg, syncProgress]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let interval: any;
|
||||||
|
if (isSimActive) {
|
||||||
|
if (audioCtxRef.current && audioDestRef.current && !crowdAudioRef.current) {
|
||||||
|
const audio = new Audio(CROWD_SOUND_URL);
|
||||||
|
audio.loop = true; audio.volume = 0.2;
|
||||||
|
const source = audioCtxRef.current.createMediaElementSource(audio);
|
||||||
|
source.connect(audioDestRef.current);
|
||||||
|
source.connect(audioCtxRef.current.destination);
|
||||||
|
audio.play().catch(e => console.error(e));
|
||||||
|
crowdAudioRef.current = audio;
|
||||||
|
}
|
||||||
|
interval = setInterval(() => {
|
||||||
|
setAudienceCount(prev => Math.min(prev + Math.floor(Math.random() * 5000) + 1000, 1500000));
|
||||||
|
if (Math.random() > 0.8) {
|
||||||
|
const r = REAL_PEOPLE_AVATARS[Math.floor(Math.random() * REAL_PEOPLE_AVATARS.length)];
|
||||||
|
setActiveViewers(prev => [...prev.slice(-11), { id: Date.now(), ...r }]);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
setAudienceCount(0); setActiveViewers([]);
|
||||||
|
if (crowdAudioRef.current) { crowdAudioRef.current.pause(); crowdAudioRef.current = null; }
|
||||||
|
}
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isSimActive]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-screen w-full bg-[#1a1a1a] overflow-hidden flex flex-col font-sans">
|
||||||
|
<Head><title>KARAOKE GLOBAL 10K+ | AO VIVO</title></Head>
|
||||||
|
|
||||||
|
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept="audio/*" className="hidden" />
|
||||||
|
|
||||||
|
<div className="absolute inset-0 z-0 bg-black overflow-hidden">
|
||||||
|
{!isCameraActive && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full space-y-8 z-10 relative">
|
||||||
|
<div className="w-40 h-40 border-4 border-[#E3B341] rounded-full flex items-center justify-center animate-bounce shadow-[0_0_50px_rgba(227,179,65,0.4)]">
|
||||||
|
<BaseIcon path={mdiMicrophone} size={80} className="text-[#E3B341]" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-white text-5xl font-black tracking-tighter uppercase italic text-center">
|
||||||
|
Mega <span className="text-[#E3B341]">Karaoke</span> 10.000+
|
||||||
|
<br /><span className="text-xl font-light tracking-widest text-white/60">BRASIL & MUNDO</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-white/80 font-bold uppercase tracking-widest max-w-lg text-center text-xs">
|
||||||
|
NUCLEAR AUDIO ENGINE ACTIVE - SINCRONIZAÇÃO TOTAL COM O NAVEGADOR
|
||||||
|
</p>
|
||||||
|
<button onClick={startCamera} className="bg-[#E3B341] text-black px-16 py-6 rounded-full font-black text-2xl uppercase tracking-tighter hover:scale-110 transition-all shadow-2xl">Entrar no Palco</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<canvas ref={canvasRef} width={1920} height={1080} className="hidden" />
|
||||||
|
<video ref={videoRef} autoPlay playsInline muted className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-1000 ${isCameraActive ? 'opacity-100' : 'opacity-0'}`} />
|
||||||
|
|
||||||
|
{isSimActive && isInterfaceVisible && (
|
||||||
|
<div className="absolute top-24 left-6 z-40 grid grid-cols-4 gap-4 pointer-events-auto max-w-md animate-fade-in">
|
||||||
|
{activeViewers.map((viewer) => (
|
||||||
|
<button key={viewer.id} onClick={() => setFeaturedViewer(viewer)} className={`group relative w-24 h-24 rounded-2xl border-2 overflow-hidden transition-all hover:scale-110 ${featuredViewer?.id === viewer.id ? 'border-[#00F2FF] ring-4 ring-[#00F2FF]/20' : 'border-white/20 hover:border-[#E3B341]'}`}>
|
||||||
|
<Image src={viewer.img} alt={viewer.name} width={96} height={96} crossOrigin="anonymous" className="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all" />
|
||||||
|
<div className="absolute bottom-0 w-full p-1 bg-black/60 text-[8px] font-bold text-white text-center">{viewer.name}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{featuredViewer && isInterfaceVisible && (
|
||||||
|
<div className="absolute top-24 right-6 z-40 w-72 bg-black/90 border-4 border-[#00F2FF] rounded-3xl overflow-hidden shadow-2xl pointer-events-auto animate-fade-in">
|
||||||
|
<div className="relative">
|
||||||
|
<Image src={featuredViewer.img} alt={`Featured: ${featuredViewer.name}`} width={288} height={288} crossOrigin="anonymous" className="w-full h-72 object-cover" />
|
||||||
|
<div className="absolute top-4 left-4 flex items-center space-x-2 bg-red-600 px-3 py-1 rounded-full text-[10px] font-black text-white">
|
||||||
|
<div className="w-2 h-2 bg-white rounded-full animate-pulse"></div>
|
||||||
|
<span>DUETO AO VIVO</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setFeaturedViewer(null)} className="absolute top-4 right-4 bg-black/60 p-1 rounded-full text-white"><BaseIcon path={mdiClose} size={20} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-white/5 border-t border-white/10 text-center">
|
||||||
|
<div className="text-[#00F2FF] text-[10px] font-black uppercase">Participante Selecionado</div>
|
||||||
|
<div className="text-white text-lg font-bold">{featuredViewer.name}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isKaraokeActive && currentSong && isInterfaceVisible && (
|
||||||
|
<div className="absolute bottom-10 left-1/2 -translate-x-1/2 z-50 w-full max-w-4xl px-8 pointer-events-none animate-slide-up">
|
||||||
|
<div className="bg-black/90 border-t-8 border-[#E3B341] p-10 rounded-3xl backdrop-blur-3xl shadow-[0_-20px_100px_rgba(0,0,0,0.8)] text-center pointer-events-auto">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<BaseIcon path={customAudioMap[currentSong.id] ? mdiCheckCircle : mdiMusicNote} size={24} className={customAudioMap[currentSong.id] ? "text-green-500" : "text-[#E3B341]"} />
|
||||||
|
<span className="text-[#E3B341] text-lg font-black uppercase tracking-widest">{currentSong.title} - {currentSong.artist}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className={`w-3 h-3 rounded-full animate-pulse ${audioStatus === 'ACTIVE' ? 'bg-green-500' : audioStatus === 'LOADING' ? 'bg-yellow-500' : audioStatus === 'SYNCING' ? 'bg-blue-500' : 'bg-red-500'}`}></div>
|
||||||
|
<span className="text-[10px] text-white/60 font-black uppercase">{audioStatus}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`text-5xl font-black leading-tight drop-shadow-[0_4px_4px_rgba(0,0,0,1)] transition-all duration-300 ${audioStatus === 'ERROR' ? 'text-red-500' : 'text-white'}`}>
|
||||||
|
{audioStatus === 'ERROR' ? audioErrorMsg : (currentLyrics || "VAI COMEÇAR...")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 h-2 bg-white/10 rounded-full overflow-hidden relative">
|
||||||
|
<div className="h-full bg-gradient-to-r from-[#E3B341] to-[#ffda85] shadow-[0_0_15px_rgba(227,179,65,0.6)]" style={{ width: `${(playbackProgress / (karaokeAudioRef.current?.duration || 1)) * 100}%` }}></div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 flex justify-center space-x-6 items-center">
|
||||||
|
<button onClick={togglePlayback} className="bg-[#E3B341] text-black w-20 h-20 flex items-center justify-center rounded-full hover:scale-110 transition-all shadow-[0_0_30px_rgba(227,179,65,0.4)]">
|
||||||
|
<BaseIcon path={isPaused ? mdiPlay : mdiPause} size={48} />
|
||||||
|
</button>
|
||||||
|
<div className="flex flex-col items-center space-y-2">
|
||||||
|
<button onClick={() => setIsInterfaceVisible(false)} className="bg-white/10 text-white p-4 rounded-full hover:bg-white/20 transition-all">
|
||||||
|
<BaseIcon path={mdiEyeOff} size={24} />
|
||||||
|
</button>
|
||||||
|
<span className="text-[8px] text-white/40 font-black">OCULTAR</span>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => selectSong(currentSong)} className={`p-4 rounded-full transition-all ${audioStatus === 'ERROR' ? 'bg-red-600 text-white animate-bounce shadow-lg' : 'bg-white/10 text-white hover:bg-white/20'}`}>
|
||||||
|
<BaseIcon path={mdiRefresh} size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isCameraActive && (
|
||||||
|
<>
|
||||||
|
{!isInterfaceVisible && (
|
||||||
|
<button onClick={() => setIsInterfaceVisible(true)} className="absolute bottom-10 right-10 z-[60] bg-[#E3B341] text-black p-6 rounded-full shadow-2xl hover:scale-110 transition-all animate-bounce">
|
||||||
|
<BaseIcon path={mdiEye} size={40} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isInterfaceVisible && (
|
||||||
|
<div className="absolute inset-0 z-30 p-8 flex flex-col justify-between pointer-events-none animate-fade-in">
|
||||||
|
<div className="flex justify-between items-start pointer-events-auto">
|
||||||
|
<div className="flex flex-col space-y-4">
|
||||||
|
<div className="bg-black/80 p-6 rounded-[2rem] border border-white/10 backdrop-blur-xl shadow-2xl flex items-center space-x-6">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<button onClick={() => setIsMicOn(!isMicOn)} className={`p-4 rounded-full transition-all ${isMicOn ? 'bg-green-600 shadow-[0_0_20px_rgba(34,197,94,0.4)]' : 'bg-red-600'}`}>
|
||||||
|
<BaseIcon path={isMicOn ? mdiMicrophone : mdiMicrophoneOff} size={32} className="text-white" />
|
||||||
|
</button>
|
||||||
|
<span className="text-[10px] font-black text-white mt-2">MIC</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<button onClick={() => setIsMonitorOn(!isMonitorOn)} className={`p-4 rounded-full transition-all ${isMonitorOn ? 'bg-[#00F2FF] text-black shadow-[0_0_20px_rgba(0,242,255,0.4)]' : 'bg-white/10 text-white'}`}>
|
||||||
|
<BaseIcon path={mdiHeadphones} size={32} />
|
||||||
|
</button>
|
||||||
|
<span className="text-[10px] font-black text-white mt-2">RETORNO</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-12 bg-white/20"></div>
|
||||||
|
<div className="flex flex-col space-y-2 min-w-[120px]">
|
||||||
|
<div className="flex justify-between text-[10px] font-black text-[#E3B341]"><span>EFEITO</span><span>{(reverbLevel * 100).toFixed(0)}%</span></div>
|
||||||
|
<input type="range" min="0" max="1" step="0.1" value={reverbLevel} onChange={(e) => setReverbLevel(parseFloat(e.target.value))} className="w-full h-1 bg-white/10 accent-[#E3B341] rounded-lg appearance-none cursor-pointer" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col space-y-2 min-w-[120px]">
|
||||||
|
<div className="flex justify-between text-[10px] font-black text-[#00F2FF]"><span>VOLUME</span><span>{(volumeLevel * 100).toFixed(0)}%</span></div>
|
||||||
|
<input type="range" min="0" max="1" step="0.1" value={volumeLevel} onChange={(e) => setVolumeLevel(parseFloat(e.target.value))} className="w-full h-1 bg-white/10 accent-[#00F2FF] rounded-lg appearance-none cursor-pointer" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<button onClick={syncAll} className={`p-4 rounded-full transition-all ${audioStatus === 'SYNCING' ? 'bg-[#00F2FF] animate-spin text-black' : audioStatus === 'ERROR' ? 'bg-red-600 animate-pulse text-white' : 'bg-white/5 text-[#E3B341] hover:bg-white/10'}`}>
|
||||||
|
<BaseIcon path={audioStatus === 'SYNCING' ? mdiSync : audioStatus === 'ERROR' ? mdiVolumeVariantOff : mdiTune} size={32} />
|
||||||
|
</button>
|
||||||
|
<span className={`text-[10px] font-black mt-2 text-center uppercase ${audioStatus === 'ERROR' ? 'text-red-500' : 'text-white'}`}>
|
||||||
|
REATIVAR<br/>ÁUDIO
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-red-600 text-white px-6 py-3 rounded-full font-black text-sm shadow-2xl flex items-center space-x-3 w-fit">
|
||||||
|
<div className="w-3 h-3 bg-white rounded-full animate-pulse"></div>
|
||||||
|
<span>{audienceCount.toLocaleString()} PESSOAS AO VIVO</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<button onClick={() => setIsInterfaceVisible(false)} className="p-6 bg-black/80 border-2 border-white/20 rounded-full text-white hover:border-[#E3B341] transition-all shadow-2xl"><BaseIcon path={mdiEyeOff} size={40} /></button>
|
||||||
|
<button onClick={() => setShowPlaylist(!showPlaylist)} className={`p-6 bg-black/80 border-2 rounded-full transition-all shadow-2xl ${showPlaylist ? 'border-[#E3B341] text-[#E3B341]' : 'border-white/20 text-white hover:border-[#E3B341]'}`}><BaseIcon path={mdiMusicNote} size={40} /></button>
|
||||||
|
<button onClick={isRecording ? () => { mediaRecorder?.stop(); setIsRecording(false); } : () => {
|
||||||
|
if (!canvasRef.current || !audioDestRef.current) return;
|
||||||
|
const combinedStream = new MediaStream([
|
||||||
|
...canvasRef.current.captureStream(60).getVideoTracks(),
|
||||||
|
...audioDestRef.current.stream.getAudioTracks()
|
||||||
|
]);
|
||||||
|
const recorder = new MediaRecorder(combinedStream, { mimeType: 'video/webm;codecs=vp9,opus', bitsPerSecond: 10000000 });
|
||||||
|
const chunks: Blob[] = [];
|
||||||
|
recorder.ondataavailable = (e) => chunks.push(e.data);
|
||||||
|
recorder.onstop = () => setVideoUrl(URL.createObjectURL(new Blob(chunks, { type: 'video/webm' })));
|
||||||
|
recorder.start(); setIsRecording(true); setMediaRecorder(recorder);
|
||||||
|
}} className={`p-6 bg-black/80 border-2 rounded-full transition-all shadow-2xl ${isRecording ? 'border-red-600 text-red-600 scale-110' : 'border-white/20 text-white hover:border-red-600'}`}><BaseIcon path={isRecording ? mdiStop : mdiRecord} size={40} /></button>
|
||||||
|
{videoUrl && !isRecording && <button onClick={() => { const a = document.createElement('a'); a.href = videoUrl; a.download = `meu-show-global.webm`; a.click(); }} className="p-6 bg-green-600 text-white rounded-full shadow-2xl animate-bounce"><BaseIcon path={mdiDownload} size={40} /></button>}
|
||||||
|
<button onClick={() => window.location.reload()} className="p-6 bg-black/80 border-2 border-white/20 rounded-full text-white hover:bg-red-600 transition-all"><BaseIcon path={mdiClose} size={40} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPlaylist && (
|
||||||
|
<div className="absolute top-8 right-8 w-[35rem] bg-black/95 border-4 border-[#E3B341] p-8 rounded-[3rem] shadow-[0_0_100px_rgba(0,0,0,0.9)] pointer-events-auto z-50 animate-slide-up flex flex-col h-[90vh]">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<h3 className="text-[#E3B341] font-black text-2xl italic uppercase tracking-tighter">Estúdio 10.000+</h3>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className={`w-2 h-2 rounded-full ${audioCtxRef.current?.state === 'running' ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||||
|
<p className="text-[10px] text-white/40 font-bold uppercase tracking-widest">NAVIGATOR SYNC: {audioCtxRef.current?.state === 'running' ? 'ATIVO' : 'AGUARDANDO'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowPlaylist(false)} className="bg-white/5 p-2 rounded-full text-white/40 hover:text-white transition-all"><BaseIcon path={mdiClose} size={28} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-6">
|
||||||
|
<div className="absolute inset-y-0 left-5 flex items-center pointer-events-none">
|
||||||
|
<BaseIcon path={mdiMagnify} size={24} className="text-white/30" />
|
||||||
|
</div>
|
||||||
|
<input type="text" placeholder="BUSCAR E CONFIGURAR..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="w-full bg-white/5 border-2 border-white/10 rounded-2xl py-5 pl-14 pr-6 text-white font-bold focus:outline-none focus:border-[#E3B341] transition-all placeholder:text-white/20 uppercase text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-2 overflow-x-auto pb-4 mb-4 custom-scrollbar no-scrollbar">
|
||||||
|
{GENRES.map(genre => (
|
||||||
|
<button key={genre.name} onClick={() => setSelectedGenre(genre.name)} className={`flex items-center space-x-2 px-6 py-3 rounded-full whitespace-nowrap font-black text-[10px] uppercase transition-all border-2 ${selectedGenre === genre.name ? 'bg-[#E3B341] border-[#E3B341] text-black shadow-[0_0_20px_rgba(227,179,65,0.3)]' : 'bg-white/5 border-white/10 text-white/60 hover:border-white/30'}`}>
|
||||||
|
<BaseIcon path={genre.icon} size={16} />
|
||||||
|
<span>{genre.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-grow overflow-y-auto pr-2 space-y-3 custom-scrollbar">
|
||||||
|
<div className="p-6 border-2 border-dashed border-[#E3B341]/40 rounded-[2rem] text-center hover:bg-white/5 transition-all cursor-pointer group mb-6" onClick={() => {
|
||||||
|
setSongToUpload({ id: 'NEW_CUSTOM' });
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
}}>
|
||||||
|
<BaseIcon path={mdiUpload} size={40} className="text-[#E3B341] mx-auto mb-3" />
|
||||||
|
<h4 className="text-white font-black uppercase text-sm italic tracking-widest">Adicionar Nova Música do Dispositivo</h4>
|
||||||
|
<p className="text-[9px] text-white/40 font-bold mt-1">SINCROZINA AUTOMATICAMENTE APÓS O CARREGAMENTO</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{filteredSongs.length > 0 ? filteredSongs.map(song => (
|
||||||
|
<div key={song.id} className="relative">
|
||||||
|
<div className={`w-full flex items-center justify-between p-4 rounded-2xl border-2 transition-all group cursor-pointer ${currentSong?.id === song.id ? 'bg-[#E3B341] border-[#E3B341] text-black' : 'bg-white/5 border-white/10 text-white hover:border-[#E3B341] hover:bg-white/10'}`} onClick={() => selectSong(song)}>
|
||||||
|
<div className="flex flex-col items-start overflow-hidden flex-grow">
|
||||||
|
<span className="font-black text-lg truncate w-full italic leading-tight flex items-center space-x-2">
|
||||||
|
{customAudioMap[song.id] && <BaseIcon path={mdiCheckCircle} size={16} className="text-green-500 animate-pulse" />}
|
||||||
|
<span>{song.title}</span>
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center space-x-2 mt-1">
|
||||||
|
<span className="text-[10px] font-black uppercase opacity-60 tracking-wider">{song.artist}</span>
|
||||||
|
<span className="w-1 h-1 bg-white/30 rounded-full"></span>
|
||||||
|
<span className="text-[9px] font-black uppercase text-[#E3B341] group-hover:text-inherit">{song.genre}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); setEditingSongId(editingSongId === song.id ? null : song.id); }} className={`p-2 rounded-full transition-all ${editingSongId === song.id ? 'bg-black text-[#E3B341]' : 'hover:bg-white/10'}`}>
|
||||||
|
<BaseIcon path={mdiCog} size={20} />
|
||||||
|
</button>
|
||||||
|
<div className={`p-3 rounded-full transition-all ${currentSong?.id === song.id ? 'bg-black text-[#E3B341]' : 'bg-white/10 text-white group-hover:bg-[#E3B341] group-hover:text-black'}`}>
|
||||||
|
<BaseIcon path={currentSong?.id === song.id ? (isPaused ? mdiPlay : mdiPause) : mdiPlay} size={24} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingSongId === song.id && (
|
||||||
|
<div className="mt-2 p-4 bg-white/5 rounded-2xl border border-white/10 flex items-center justify-around animate-fade-in pointer-events-auto">
|
||||||
|
<button onClick={(e) => triggerUpload(e, song)} className="flex flex-col items-center space-y-1 group">
|
||||||
|
<div className="p-3 bg-[#E3B341] text-black rounded-full group-hover:scale-110 transition-all shadow-lg"><BaseIcon path={mdiUpload} size={20} /></div>
|
||||||
|
<span className="text-[8px] font-black text-white/60">TROCAR ÁUDIO</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{customAudioMap[song.id] && !song.isUserAdded && (
|
||||||
|
<button onClick={(e) => resetToOriginal(e, song.id)} className="flex flex-col items-center space-y-1 group">
|
||||||
|
<div className="p-3 bg-blue-600 text-white rounded-full group-hover:scale-110 transition-all shadow-lg"><BaseIcon path={mdiRefresh} size={20} /></div>
|
||||||
|
<span className="text-[8px] font-black text-white/60">RESTAURAR</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{song.isUserAdded && (
|
||||||
|
<button onClick={(e) => removeUserSong(e, song.id)} className="flex flex-col items-center space-y-1 group">
|
||||||
|
<div className="p-3 bg-red-600 text-white rounded-full group-hover:scale-110 transition-all shadow-lg"><BaseIcon path={mdiTrashCan} size={20} /></div>
|
||||||
|
<span className="text-[8px] font-black text-white/60">REMOVER</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button onClick={() => setEditingSongId(null)} className="flex flex-col items-center space-y-1 group">
|
||||||
|
<div className="p-3 bg-white/10 text-white rounded-full group-hover:scale-110 transition-all"><BaseIcon path={mdiClose} size={20} /></div>
|
||||||
|
<span className="text-[8px] font-black text-white/60">FECHAR</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 text-white/20">
|
||||||
|
<BaseIcon path={mdiFilterVariant} size={64} className="mb-4" />
|
||||||
|
<p className="font-black uppercase tracking-widest text-sm">Nenhuma música encontrada</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-6 border-t border-white/10 flex items-center justify-between">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[9px] font-black text-white/30 uppercase tracking-widest">Estúdio Studio Master 1.0</span>
|
||||||
|
<span className="text-[#E3B341] text-xs font-black">PLAYBACKS: {fullSongDatabase.length} DISPONÍVEIS</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button onClick={syncAll} className="flex items-center space-x-2 bg-[#E3B341]/10 text-[#E3B341] px-4 py-2 rounded-full border border-[#E3B341]/20 hover:bg-[#E3B341]/20 transition-all">
|
||||||
|
<BaseIcon path={mdiSync} size={16} className={audioStatus === 'SYNCING' ? 'animate-spin' : ''} />
|
||||||
|
<span className="text-[10px] font-black uppercase">SINCRONIZAR TUDO</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style jsx global>{`
|
||||||
|
@keyframes slide-up { from { opacity: 0; transform: translateY(40px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
.animate-slide-up { animation: slide-up 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
|
||||||
|
.animate-fade-in { animation: fade-in 0.5s ease-out forwards; }
|
||||||
|
.custom-scrollbar::-webkit-scrollbar { width: 4px; }
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track { background: rgba(255,255,255,0.02); border-radius: 10px; }
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb { background: #E3B341; border-radius: 10px; }
|
||||||
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
||||||
|
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
ObservationPage.getLayout = function getLayout(page: ReactElement) { return <LayoutAuthenticated>{page}</LayoutAuthenticated>; };
|
||||||
|
export default ObservationPage;
|
||||||
Loading…
x
Reference in New Issue
Block a user