470 lines
11 KiB
Svelte
470 lines
11 KiB
Svelte
<script>
|
|
import { onMount, onDestroy } from 'svelte'
|
|
import Timer from './components/Timer.svelte'
|
|
import ParticipantList from './components/ParticipantList.svelte'
|
|
import Controls from './components/Controls.svelte'
|
|
import Settings from './components/Settings.svelte'
|
|
import History from './components/History.svelte'
|
|
import Setup from './components/Setup.svelte'
|
|
import { EventsOn, EventsOff, WindowSetSize, ScreenGetAll } from '../wailsjs/runtime/runtime'
|
|
import { GetParticipants, StartMeeting, GetSettings, SkipSpeaker, RemoveFromQueue } from '../wailsjs/go/app/App'
|
|
import { t, initLocale } from './lib/i18n'
|
|
|
|
let currentView = 'main'
|
|
let timerState = null
|
|
let meetingActive = false
|
|
let settings = null
|
|
let participants = []
|
|
let currentTime = ''
|
|
let clockInterval
|
|
|
|
function updateClock() {
|
|
const now = new Date()
|
|
currentTime = now.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
|
}
|
|
|
|
onMount(async () => {
|
|
updateClock()
|
|
clockInterval = setInterval(updateClock, 1000)
|
|
initLocale()
|
|
await loadSettings()
|
|
await loadParticipants()
|
|
EventsOn('timer:tick', handleTimerEvent)
|
|
EventsOn('timer:speaker_warning', handleWarning)
|
|
EventsOn('timer:speaker_timeup', handleTimeUp)
|
|
EventsOn('timer:meeting_warning', handleMeetingWarning)
|
|
EventsOn('timer:meeting_ended', handleMeetingEnded)
|
|
EventsOn('timer:speaker_changed', handleSpeakerChanged)
|
|
})
|
|
|
|
async function loadSettings() {
|
|
try {
|
|
settings = await GetSettings()
|
|
const width = settings?.windowWidth >= 480 ? settings.windowWidth : 800
|
|
|
|
if (settings?.windowFullHeight) {
|
|
// Get screen dimensions and use full height
|
|
try {
|
|
const screens = await ScreenGetAll()
|
|
if (screens && screens.length > 0) {
|
|
const primaryScreen = screens[0]
|
|
const height = primaryScreen.size?.height || 800
|
|
// Leave some space for dock/taskbar (approx 80px)
|
|
WindowSetSize(width, height - 80)
|
|
} else {
|
|
WindowSetSize(width, 800)
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to get screen size:', e)
|
|
WindowSetSize(width, 800)
|
|
}
|
|
} else {
|
|
WindowSetSize(width, 600)
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load settings:', e)
|
|
}
|
|
}
|
|
|
|
onDestroy(() => {
|
|
if (clockInterval) clearInterval(clockInterval)
|
|
EventsOff('timer:tick')
|
|
EventsOff('timer:speaker_warning')
|
|
EventsOff('timer:speaker_timeup')
|
|
EventsOff('timer:meeting_warning')
|
|
EventsOff('timer:meeting_ended')
|
|
EventsOff('timer:speaker_changed')
|
|
})
|
|
|
|
function handleTimerEvent(state) {
|
|
timerState = state
|
|
}
|
|
|
|
function handleWarning(state) {
|
|
timerState = state
|
|
if (settings?.soundEnabled) {
|
|
playSound('warning')
|
|
}
|
|
}
|
|
|
|
function handleTimeUp(state) {
|
|
timerState = state
|
|
if (settings?.soundEnabled) {
|
|
playSound('timeup')
|
|
}
|
|
}
|
|
|
|
function handleMeetingWarning(state) {
|
|
timerState = state
|
|
}
|
|
|
|
function handleMeetingEnded(state) {
|
|
timerState = state
|
|
meetingActive = false
|
|
if (settings?.soundEnabled) {
|
|
playSound('meeting_end')
|
|
}
|
|
}
|
|
|
|
function handleSpeakerChanged(state) {
|
|
timerState = state
|
|
}
|
|
|
|
let audioContext = null
|
|
|
|
function getAudioContext() {
|
|
if (!audioContext) {
|
|
audioContext = new (window.AudioContext || window.webkitAudioContext)()
|
|
}
|
|
return audioContext
|
|
}
|
|
|
|
async function playBeep(frequency, duration, type = 'sine') {
|
|
try {
|
|
const ctx = getAudioContext()
|
|
if (ctx.state === 'suspended') {
|
|
await ctx.resume()
|
|
}
|
|
const oscillator = ctx.createOscillator()
|
|
const gainNode = ctx.createGain()
|
|
|
|
oscillator.connect(gainNode)
|
|
gainNode.connect(ctx.destination)
|
|
|
|
oscillator.frequency.value = frequency
|
|
oscillator.type = type
|
|
|
|
gainNode.gain.setValueAtTime(0.3, ctx.currentTime)
|
|
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration)
|
|
|
|
oscillator.start(ctx.currentTime)
|
|
oscillator.stop(ctx.currentTime + duration)
|
|
} catch (e) {
|
|
console.error('Failed to play sound:', e)
|
|
}
|
|
}
|
|
|
|
function playSound(name) {
|
|
switch (name) {
|
|
case 'warning':
|
|
// Two short warning beeps
|
|
playBeep(880, 0.15)
|
|
setTimeout(() => playBeep(880, 0.15), 200)
|
|
break
|
|
case 'timeup':
|
|
// Descending tone sequence
|
|
playBeep(1200, 0.2)
|
|
setTimeout(() => playBeep(900, 0.2), 250)
|
|
setTimeout(() => playBeep(600, 0.3), 500)
|
|
break
|
|
case 'meeting_end':
|
|
// Final chime - three notes
|
|
playBeep(523, 0.2) // C5
|
|
setTimeout(() => playBeep(659, 0.2), 200) // E5
|
|
setTimeout(() => playBeep(784, 0.4), 400) // G5
|
|
break
|
|
}
|
|
}
|
|
|
|
function handleMeetingStarted() {
|
|
meetingActive = true
|
|
currentView = 'main'
|
|
}
|
|
|
|
function handleSettingsLoaded(s) {
|
|
settings = s
|
|
}
|
|
|
|
async function handleSkipFromList(event) {
|
|
const { speakerId } = event.detail
|
|
try {
|
|
await RemoveFromQueue(speakerId)
|
|
} catch (e) {
|
|
console.error('Failed to remove speaker from queue:', e)
|
|
}
|
|
}
|
|
|
|
async function loadParticipants() {
|
|
try {
|
|
participants = await GetParticipants() || []
|
|
} catch (e) {
|
|
console.error('Failed to load participants:', e)
|
|
participants = []
|
|
}
|
|
}
|
|
|
|
async function handleQuickStart() {
|
|
if (participants.length === 0) return
|
|
|
|
const ids = participants.map(p => p.id)
|
|
const attendance = {}
|
|
participants.forEach(p => { attendance[p.id] = true })
|
|
|
|
try {
|
|
await StartMeeting(ids, attendance)
|
|
meetingActive = true
|
|
} catch (e) {
|
|
console.error('Failed to start meeting:', e)
|
|
alert($t('common.errorStartMeeting') + ': ' + e)
|
|
}
|
|
}
|
|
|
|
// Reload participants when switching to main view
|
|
$: if (currentView === 'main' && !meetingActive) {
|
|
loadParticipants()
|
|
}
|
|
</script>
|
|
|
|
<div class="titlebar"></div>
|
|
|
|
<main>
|
|
<nav class="nav" class:hidden={meetingActive}>
|
|
<button
|
|
class:active={currentView === 'main'}
|
|
on:click={() => currentView = 'main'}
|
|
>
|
|
{$t('nav.timer')}
|
|
</button>
|
|
<button
|
|
class:active={currentView === 'setup'}
|
|
on:click={() => currentView = 'setup'}
|
|
>
|
|
{$t('nav.setup')}
|
|
</button>
|
|
<button
|
|
class:active={currentView === 'history'}
|
|
on:click={() => currentView = 'history'}
|
|
>
|
|
{$t('nav.history')}
|
|
</button>
|
|
<button
|
|
class:active={currentView === 'settings'}
|
|
on:click={() => currentView = 'settings'}
|
|
>
|
|
{$t('nav.settings')}
|
|
</button>
|
|
</nav>
|
|
|
|
<div class="content">
|
|
{#if currentView === 'main'}
|
|
{#if meetingActive && timerState}
|
|
<div class="timer-view">
|
|
<Timer {timerState} />
|
|
<ParticipantList {timerState} on:skip={handleSkipFromList} />
|
|
<Controls {timerState} on:stop={() => meetingActive = false} />
|
|
</div>
|
|
{:else if participants.length > 0}
|
|
<div class="ready-to-start">
|
|
<div class="current-clock">{currentTime}</div>
|
|
<h2>{$t('timer.readyToStart')}</h2>
|
|
<p>{$t('timer.registeredParticipants')}: {participants.length}</p>
|
|
<button class="start-btn big" on:click={handleQuickStart}>
|
|
{$t('setup.startMeeting')}
|
|
</button>
|
|
<button class="secondary-btn" on:click={() => currentView = 'setup'}>
|
|
{$t('timer.editParticipants')}
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
<div class="no-meeting">
|
|
<div class="current-clock">{currentTime}</div>
|
|
<h2>{$t('timer.noParticipants')}</h2>
|
|
<p>{$t('timer.goToParticipants')}</p>
|
|
<button class="start-btn" on:click={() => currentView = 'setup'}>
|
|
{$t('nav.setup')}
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{:else if currentView === 'setup'}
|
|
<Setup on:started={handleMeetingStarted} />
|
|
{:else if currentView === 'history'}
|
|
<History />
|
|
{:else if currentView === 'settings'}
|
|
<Settings on:loaded={handleSettingsLoaded} />
|
|
{/if}
|
|
</div>
|
|
</main>
|
|
|
|
<style>
|
|
:global(body) {
|
|
margin: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
main {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100vh;
|
|
padding-top: 28px; /* macOS titlebar */
|
|
}
|
|
|
|
/* Draggable titlebar area */
|
|
.titlebar {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 28px;
|
|
--wails-draggable: drag;
|
|
-webkit-app-region: drag;
|
|
background: transparent;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.nav {
|
|
display: flex;
|
|
gap: 4px;
|
|
padding: 8px 12px;
|
|
background: #232f3e;
|
|
border-bottom: 1px solid #3d4f61;
|
|
--wails-draggable: drag;
|
|
-webkit-app-region: drag;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
}
|
|
|
|
.nav.hidden {
|
|
display: none;
|
|
}
|
|
|
|
.nav button {
|
|
-webkit-app-region: no-drag;
|
|
padding: 8px 12px;
|
|
border: none;
|
|
background: transparent;
|
|
color: #9ca3af;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
transition: all 0.2s;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.nav button:hover {
|
|
background: #3d4f61;
|
|
color: #e0e0e0;
|
|
}
|
|
|
|
.nav button.active {
|
|
background: #4a90d9;
|
|
color: white;
|
|
}
|
|
|
|
.content {
|
|
flex: 1;
|
|
overflow: auto;
|
|
padding: 12px;
|
|
padding-bottom: 64px;
|
|
}
|
|
|
|
.timer-view {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
height: 100%;
|
|
}
|
|
|
|
.no-meeting {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
text-align: center;
|
|
gap: 16px;
|
|
}
|
|
|
|
.no-meeting h2 {
|
|
color: #e0e0e0;
|
|
margin: 0;
|
|
font-size: 24px;
|
|
}
|
|
|
|
.no-meeting p {
|
|
color: #9ca3af;
|
|
margin: 0;
|
|
}
|
|
|
|
.no-meeting .start-btn {
|
|
padding: 16px 32px;
|
|
background: #166534;
|
|
color: #4ade80;
|
|
border: none;
|
|
border-radius: 12px;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.no-meeting .start-btn:hover {
|
|
background: #15803d;
|
|
}
|
|
|
|
.ready-to-start {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
text-align: center;
|
|
gap: 16px;
|
|
}
|
|
|
|
.ready-to-start h2 {
|
|
color: #e0e0e0;
|
|
margin: 0;
|
|
font-size: 22px;
|
|
}
|
|
|
|
.ready-to-start p {
|
|
color: #9ca3af;
|
|
margin: 0;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.ready-to-start .start-btn.big {
|
|
padding: 20px 36px;
|
|
background: #166534;
|
|
color: #4ade80;
|
|
border: none;
|
|
border-radius: 14px;
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
width: 100%;
|
|
max-width: 300px;
|
|
margin-bottom: 12px;
|
|
}
|
|
|
|
.ready-to-start .start-btn.big:hover {
|
|
background: #15803d;
|
|
transform: scale(1.02);
|
|
}
|
|
|
|
.ready-to-start .secondary-btn {
|
|
padding: 12px 24px;
|
|
background: transparent;
|
|
color: #9ca3af;
|
|
border: 1px solid #3d4f61;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.ready-to-start .secondary-btn:hover {
|
|
background: #3d4f61;
|
|
color: #e0e0e0;
|
|
}
|
|
|
|
.current-clock {
|
|
font-size: 32px;
|
|
color: #4a90d9;
|
|
font-family: 'SF Mono', 'Menlo', monospace;
|
|
font-weight: 600;
|
|
margin-bottom: 8px;
|
|
}
|
|
</style>
|