Files
daily-timer/frontend/src/App.svelte
2026-02-10 23:10:02 +03:00

518 lines
13 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, SwitchToSpeaker } 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)
// Warm up AudioContext on first user interaction
const warmUpAudio = async () => {
const ctx = getAudioContext()
if (ctx.state === 'suspended') {
await ctx.resume()
}
// Play silent sound to fully unlock audio
const oscillator = ctx.createOscillator()
const gainNode = ctx.createGain()
oscillator.connect(gainNode)
gainNode.connect(ctx.destination)
gainNode.gain.value = 0 // Silent
oscillator.start()
oscillator.stop(ctx.currentTime + 0.001)
// Remove listener after first interaction
document.removeEventListener('click', warmUpAudio)
document.removeEventListener('keydown', warmUpAudio)
}
document.addEventListener('click', warmUpAudio)
document.addEventListener('keydown', warmUpAudio)
})
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
}
async function handleWarning(state) {
timerState = state
if (settings?.soundEnabled) {
await playSound('warning')
}
}
async function handleTimeUp(state) {
timerState = state
if (settings?.soundEnabled) {
await playSound('timeup')
}
}
function handleMeetingWarning(state) {
timerState = state
}
async function handleMeetingEnded(state) {
timerState = state
meetingActive = false
if (settings?.soundEnabled) {
await playSound('meeting_end')
}
}
function handleSpeakerChanged(state) {
timerState = state
}
let audioContext = null
function getAudioContext() {
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)()
}
return audioContext
}
function playBeep(frequency, duration, type = 'sine') {
try {
const ctx = getAudioContext()
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)
}
}
async function playSound(name) {
// Ensure AudioContext is running (may be suspended after inactivity)
const ctx = getAudioContext()
if (ctx.state === 'suspended') {
await ctx.resume()
}
switch (name) {
case 'warning':
playBeep(880, 0.15)
setTimeout(() => playBeep(880, 0.15), 200)
break
case 'timeup':
playBeep(1200, 0.2)
setTimeout(() => playBeep(900, 0.2), 250)
setTimeout(() => playBeep(600, 0.3), 500)
break
case 'meeting_end':
playBeep(523, 0.2)
setTimeout(() => playBeep(659, 0.2), 200)
setTimeout(() => playBeep(784, 0.4), 400)
break
}
}
function handleMeetingStarted() {
meetingActive = true
currentView = 'main'
}
function handleSettingsLoaded(event) {
settings = event.detail
}
async function handleSkipFromList(event) {
const { speakerId } = event.detail
try {
// If this is the current speaker, use SkipSpeaker, otherwise RemoveFromQueue
if (timerState?.currentSpeakerId === speakerId) {
await SkipSpeaker()
} else {
await RemoveFromQueue(speakerId)
}
} catch (e) {
console.error('Failed to skip speaker:', e)
}
}
async function handleSwitchSpeaker(event) {
const { speakerId } = event.detail
try {
await SwitchToSpeaker(speakerId)
} catch (e) {
console.error('Failed to switch to speaker:', 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" class:no-nav={meetingActive}>
{#if currentView === 'main'}
{#if meetingActive && timerState}
<div class="timer-view">
<Timer {timerState} />
<ParticipantList {timerState} on:skip={handleSkipFromList} on:switch={handleSwitchSpeaker} />
<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 {
position: fixed;
top: 32px;
left: 0;
right: 0;
z-index: 100;
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;
outline: none;
}
.nav button:hover {
background: #3d4f61;
color: #e0e0e0;
}
.nav button.active {
background: #4a90d9;
color: white;
}
.content {
position: fixed;
top: 84px; /* 32px titlebar + 52px nav height */
bottom: 0;
left: 0;
right: 0;
overflow-y: auto;
padding: 12px;
}
.content.no-nav {
top: 32px; /* Only titlebar when nav is hidden */
}
.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>