Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
545a18cf59 | ||
|
|
7e376f8211 | ||
|
|
c2a17185fd | ||
|
|
b2454f3e9e | ||
|
|
422ff362c3 | ||
|
|
41c3fd4934 | ||
|
|
6783ed8b0a | ||
|
|
fe6a41226c |
7
Makefile
7
Makefile
@@ -81,7 +81,8 @@ release-all: lint
|
|||||||
@ls -lh dist/*.zip
|
@ls -lh dist/*.zip
|
||||||
|
|
||||||
# Upload release to Gitea (requires GITEA_TOKEN env var)
|
# Upload release to Gitea (requires GITEA_TOKEN env var)
|
||||||
release-upload:
|
# Depends on 'release' to ensure dist/ files are up-to-date
|
||||||
|
release-upload: release
|
||||||
@if [ -z "$(GITEA_TOKEN)" ]; then echo "Error: GITEA_TOKEN not set"; exit 1; fi
|
@if [ -z "$(GITEA_TOKEN)" ]; then echo "Error: GITEA_TOKEN not set"; exit 1; fi
|
||||||
@echo "Creating release $(VERSION) on Gitea..."
|
@echo "Creating release $(VERSION) on Gitea..."
|
||||||
@RELEASE_ID=$$(curl -s -X POST \
|
@RELEASE_ID=$$(curl -s -X POST \
|
||||||
@@ -101,8 +102,8 @@ release-upload:
|
|||||||
done
|
done
|
||||||
@echo "Done!"
|
@echo "Done!"
|
||||||
|
|
||||||
# Full release cycle: build + upload
|
# Full release cycle: build + upload (release-upload already depends on release)
|
||||||
release-publish: release release-upload
|
release-publish: release-upload
|
||||||
|
|
||||||
# Help
|
# Help
|
||||||
help:
|
help:
|
||||||
|
|||||||
@@ -105,8 +105,9 @@ xattr -cr "Daily Timer.app"
|
|||||||
2. Таймер показывает текущего спикера с обратным отсчётом
|
2. Таймер показывает текущего спикера с обратным отсчётом
|
||||||
3. Нажать **Следующий** для перехода (или ⌘N)
|
3. Нажать **Следующий** для перехода (или ⌘N)
|
||||||
4. Нажать **Пропустить** чтобы переместить спикера в конец очереди
|
4. Нажать **Пропустить** чтобы переместить спикера в конец очереди
|
||||||
5. Использовать **Пауза/Продолжить** для прерываний
|
5. **Клик по спикеру** в списке - быстро переключиться на него (для done-спикеров таймер продолжится)
|
||||||
6. Нажать **Стоп** для досрочного завершения
|
6. Использовать **Пауза/Продолжить** для прерываний
|
||||||
|
7. Нажать **Стоп** для досрочного завершения
|
||||||
|
|
||||||
### Горячие клавиши
|
### Горячие клавиши
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
import History from './components/History.svelte'
|
import History from './components/History.svelte'
|
||||||
import Setup from './components/Setup.svelte'
|
import Setup from './components/Setup.svelte'
|
||||||
import { EventsOn, EventsOff, WindowSetSize, ScreenGetAll } from '../wailsjs/runtime/runtime'
|
import { EventsOn, EventsOff, WindowSetSize, ScreenGetAll } from '../wailsjs/runtime/runtime'
|
||||||
import { GetParticipants, StartMeeting, GetSettings, SkipSpeaker, RemoveFromQueue } from '../wailsjs/go/app/App'
|
import { GetParticipants, StartMeeting, GetSettings, SkipSpeaker, RemoveFromQueue, SwitchToSpeaker } from '../wailsjs/go/app/App'
|
||||||
import { t, initLocale } from './lib/i18n'
|
import { t, initLocale } from './lib/i18n'
|
||||||
|
import { attendance } from './lib/stores'
|
||||||
|
|
||||||
let currentView = 'main'
|
let currentView = 'main'
|
||||||
let timerState = null
|
let timerState = null
|
||||||
@@ -192,22 +193,37 @@
|
|||||||
currentView = 'main'
|
currentView = 'main'
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSettingsLoaded(s) {
|
function handleSettingsLoaded(event) {
|
||||||
settings = s
|
settings = event.detail
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSkipFromList(event) {
|
async function handleSkipFromList(event) {
|
||||||
const { speakerId } = event.detail
|
const { speakerId } = event.detail
|
||||||
try {
|
try {
|
||||||
|
// If this is the current speaker, use SkipSpeaker, otherwise RemoveFromQueue
|
||||||
|
if (timerState?.currentSpeakerId === speakerId) {
|
||||||
|
await SkipSpeaker()
|
||||||
|
} else {
|
||||||
await RemoveFromQueue(speakerId)
|
await RemoveFromQueue(speakerId)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to remove speaker from queue:', 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() {
|
async function loadParticipants() {
|
||||||
try {
|
try {
|
||||||
participants = await GetParticipants() || []
|
participants = await GetParticipants() || []
|
||||||
|
attendance.init(participants)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load participants:', e)
|
console.error('Failed to load participants:', e)
|
||||||
participants = []
|
participants = []
|
||||||
@@ -217,12 +233,16 @@
|
|||||||
async function handleQuickStart() {
|
async function handleQuickStart() {
|
||||||
if (participants.length === 0) return
|
if (participants.length === 0) return
|
||||||
|
|
||||||
const ids = participants.map(p => p.id)
|
const att = attendance.get()
|
||||||
const attendance = {}
|
const presentIds = participants.filter(p => att[p.id]).map(p => p.id)
|
||||||
participants.forEach(p => { attendance[p.id] = true })
|
|
||||||
|
if (presentIds.length === 0) {
|
||||||
|
alert($t('setup.noParticipants'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await StartMeeting(ids, attendance)
|
await StartMeeting(presentIds, att)
|
||||||
meetingActive = true
|
meetingActive = true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to start meeting:', e)
|
console.error('Failed to start meeting:', e)
|
||||||
@@ -266,12 +286,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content" class:no-nav={meetingActive}>
|
||||||
{#if currentView === 'main'}
|
{#if currentView === 'main'}
|
||||||
{#if meetingActive && timerState}
|
{#if meetingActive && timerState}
|
||||||
<div class="timer-view">
|
<div class="timer-view">
|
||||||
<Timer {timerState} />
|
<Timer {timerState} />
|
||||||
<ParticipantList {timerState} on:skip={handleSkipFromList} />
|
<ParticipantList {timerState} on:skip={handleSkipFromList} on:switch={handleSwitchSpeaker} />
|
||||||
<Controls {timerState} on:stop={() => meetingActive = false} />
|
<Controls {timerState} on:stop={() => meetingActive = false} />
|
||||||
</div>
|
</div>
|
||||||
{:else if participants.length > 0}
|
{:else if participants.length > 0}
|
||||||
@@ -333,6 +353,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 32px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
@@ -373,10 +398,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
position: fixed;
|
||||||
overflow: auto;
|
top: 84px; /* 32px titlebar + 52px nav height */
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
overflow-y: auto;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
padding-bottom: 64px;
|
}
|
||||||
|
|
||||||
|
.content.no-nav {
|
||||||
|
top: 32px; /* Only titlebar when nav is hidden */
|
||||||
}
|
}
|
||||||
|
|
||||||
.timer-view {
|
.timer-view {
|
||||||
|
|||||||
@@ -30,8 +30,33 @@
|
|||||||
await StopMeeting()
|
await StopMeeting()
|
||||||
dispatch('stop')
|
dispatch('stop')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e) {
|
||||||
|
// ⌘N - Next speaker
|
||||||
|
if (e.metaKey && e.key.toLowerCase() === 'n') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleNext()
|
||||||
|
}
|
||||||
|
// ⌘S - Skip speaker
|
||||||
|
if (e.metaKey && e.key.toLowerCase() === 's') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSkip()
|
||||||
|
}
|
||||||
|
// Space - Pause/Resume
|
||||||
|
if (e.code === 'Space' && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handlePauseResume()
|
||||||
|
}
|
||||||
|
// ⌘Q - Stop meeting
|
||||||
|
if (e.metaKey && e.key.toLowerCase() === 'q') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleStop()
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={handleKeydown} />
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button class="btn primary" on:click={handleNext}>
|
<button class="btn primary" on:click={handleNext}>
|
||||||
{hasQueue ? $t('controls.next') : $t('controls.stop')}
|
{hasQueue ? $t('controls.next') : $t('controls.stop')}
|
||||||
|
|||||||
@@ -205,7 +205,15 @@
|
|||||||
<div class="participant-log" class:log-overtime={log.overtime} class:skipped={log.skipped}>
|
<div class="participant-log" class:log-overtime={log.overtime} class:skipped={log.skipped}>
|
||||||
<span class="log-order">#{log.order}</span>
|
<span class="log-order">#{log.order}</span>
|
||||||
<span class="log-name">{log.participant?.name || 'Unknown'}</span>
|
<span class="log-name">{log.participant?.name || 'Unknown'}</span>
|
||||||
<span class="log-duration">{formatTime(log.duration)}</span>
|
<span class="log-duration">
|
||||||
|
<span class:overtime={log.duration > (log.participant?.timeLimit || 0)}>
|
||||||
|
{formatTime(log.duration)}
|
||||||
|
</span>
|
||||||
|
{#if log.participant?.timeLimit}
|
||||||
|
<span class="time-sep">/</span>
|
||||||
|
<span class="time-limit">{formatTime(log.participant.timeLimit)}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
{#if log.overtime}
|
{#if log.overtime}
|
||||||
<span class="overtime-icon">⚠️</span>
|
<span class="overtime-icon">⚠️</span>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -463,6 +471,22 @@
|
|||||||
.log-duration {
|
.log-duration {
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
font-family: 'SF Mono', 'Menlo', monospace;
|
font-family: 'SF Mono', 'Menlo', monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-duration .overtime {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-duration .time-sep {
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 0 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-duration .time-limit {
|
||||||
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading, .empty {
|
.loading, .empty {
|
||||||
|
|||||||
@@ -1,19 +1,49 @@
|
|||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher } from 'svelte'
|
import { createEventDispatcher, tick } from 'svelte'
|
||||||
import { t } from '../lib/i18n'
|
import { t } from '../lib/i18n'
|
||||||
|
|
||||||
export let timerState
|
export let timerState
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
let listEl
|
||||||
|
let lastSpeakingOrder = 0
|
||||||
|
|
||||||
$: allSpeakers = timerState?.allSpeakers || []
|
$: allSpeakers = timerState?.allSpeakers || []
|
||||||
$: currentSpeakerId = timerState?.currentSpeakerId || 0
|
$: currentSpeakerId = timerState?.currentSpeakerId || 0
|
||||||
$: currentElapsed = timerState?.speakerElapsed || 0
|
$: currentElapsed = timerState?.speakerElapsed || 0
|
||||||
|
$: speakingOrder = timerState?.speakingOrder || 0
|
||||||
|
|
||||||
|
// Auto-scroll when speaker changes
|
||||||
|
$: if (speakingOrder !== lastSpeakingOrder && speakingOrder > 0) {
|
||||||
|
lastSpeakingOrder = speakingOrder
|
||||||
|
scrollToCurrentSpeaker()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollToCurrentSpeaker() {
|
||||||
|
await tick() // Wait for DOM update
|
||||||
|
if (!listEl) return
|
||||||
|
|
||||||
|
// Find the index of the current speaking participant
|
||||||
|
const speakingIndex = allSpeakers.findIndex(s => s.status === 'speaking')
|
||||||
|
if (speakingIndex < 0) return
|
||||||
|
|
||||||
|
// Scroll to show previous speaker at top (or current if first)
|
||||||
|
const targetIndex = Math.max(0, speakingIndex - 1)
|
||||||
|
const items = listEl.querySelectorAll('li')
|
||||||
|
if (items[targetIndex]) {
|
||||||
|
items[targetIndex].scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleSkip(speakerId) {
|
function handleSkip(speakerId) {
|
||||||
dispatch('skip', { speakerId })
|
dispatch('skip', { speakerId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleSwitch(speakerId) {
|
||||||
|
dispatch('switch', { speakerId })
|
||||||
|
}
|
||||||
|
|
||||||
function formatTime(seconds) {
|
function formatTime(seconds) {
|
||||||
const mins = Math.floor(seconds / 60)
|
const mins = Math.floor(seconds / 60)
|
||||||
const secs = seconds % 60
|
const secs = seconds % 60
|
||||||
@@ -25,9 +55,14 @@
|
|||||||
<h3>{$t('timer.participants')}</h3>
|
<h3>{$t('timer.participants')}</h3>
|
||||||
|
|
||||||
{#if allSpeakers.length > 0}
|
{#if allSpeakers.length > 0}
|
||||||
<ul>
|
<ul bind:this={listEl}>
|
||||||
{#each allSpeakers as speaker}
|
{#each allSpeakers as speaker}
|
||||||
<li class="speaker-item {speaker.status}">
|
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
|
||||||
|
<li
|
||||||
|
class="speaker-item {speaker.status}"
|
||||||
|
class:clickable={speaker.status !== 'speaking'}
|
||||||
|
on:click={() => speaker.status !== 'speaking' && handleSwitch(speaker.id)}
|
||||||
|
>
|
||||||
<span class="order">{speaker.order}</span>
|
<span class="order">{speaker.order}</span>
|
||||||
<span class="name">{speaker.name}</span>
|
<span class="name">{speaker.name}</span>
|
||||||
<span class="time-display">
|
<span class="time-display">
|
||||||
@@ -47,8 +82,8 @@
|
|||||||
<span class="time-limit">{formatTime(speaker.timeLimit)}</span>
|
<span class="time-limit">{formatTime(speaker.timeLimit)}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{#if speaker.status === 'pending' || speaker.status === 'skipped'}
|
{#if speaker.status === 'pending' || speaker.status === 'speaking'}
|
||||||
<button class="skip-btn" on:click={() => handleSkip(speaker.id)} title="{$t('controls.skip')}">
|
<button class="skip-btn" on:click|stopPropagation={() => handleSkip(speaker.id)} title="{$t('controls.skip')}">
|
||||||
⏭
|
⏭
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -108,6 +143,18 @@
|
|||||||
background: #1b2636;
|
background: #1b2636;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.speaker-item.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-item.clickable:hover {
|
||||||
|
background: #2d3f52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speaker-item.done.clickable:hover {
|
||||||
|
background: #2a4a6f;
|
||||||
|
}
|
||||||
|
|
||||||
.speaker-item.skipped {
|
.speaker-item.skipped {
|
||||||
background: repeating-linear-gradient(
|
background: repeating-linear-gradient(
|
||||||
45deg,
|
45deg,
|
||||||
@@ -181,12 +228,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.time-display {
|
.time-display {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2px;
|
|
||||||
font-family: 'SF Mono', 'Menlo', monospace;
|
font-family: 'SF Mono', 'Menlo', monospace;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-spent {
|
.time-spent {
|
||||||
@@ -199,6 +246,7 @@
|
|||||||
|
|
||||||
.time-sep {
|
.time-sep {
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
|
margin: 0 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-limit {
|
.time-limit {
|
||||||
|
|||||||
@@ -609,12 +609,6 @@
|
|||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sound-test-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-top: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-btn {
|
.test-btn {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
import { onMount, createEventDispatcher } from 'svelte'
|
import { onMount, createEventDispatcher } from 'svelte'
|
||||||
import { GetParticipants, GetMeeting, StartMeeting, AddParticipant, DeleteParticipant, ReorderParticipants, UpdateParticipant, UpdateMeeting } from '../../wailsjs/go/app/App'
|
import { GetParticipants, GetMeeting, StartMeeting, AddParticipant, DeleteParticipant, ReorderParticipants, UpdateParticipant, UpdateMeeting } from '../../wailsjs/go/app/App'
|
||||||
import { t } from '../lib/i18n'
|
import { t } from '../lib/i18n'
|
||||||
|
import { attendance } from '../lib/stores'
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
let participants = []
|
let participants = []
|
||||||
let meeting = null
|
let meeting = null
|
||||||
let selectedOrder = []
|
let selectedOrder = []
|
||||||
let attendance = {}
|
|
||||||
let loading = true
|
let loading = true
|
||||||
let newName = ''
|
let newName = ''
|
||||||
let newTimeLimitMin = 2
|
let newTimeLimitMin = 2
|
||||||
@@ -37,10 +37,7 @@
|
|||||||
meeting = await GetMeeting()
|
meeting = await GetMeeting()
|
||||||
|
|
||||||
selectedOrder = participants.map(p => p.id)
|
selectedOrder = participants.map(p => p.id)
|
||||||
attendance = {}
|
attendance.init(participants)
|
||||||
participants.forEach(p => {
|
|
||||||
attendance[p.id] = true
|
|
||||||
})
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load data:', e)
|
console.error('Failed to load data:', e)
|
||||||
}
|
}
|
||||||
@@ -95,8 +92,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleAttendance(id) {
|
function toggleAttendance(id) {
|
||||||
attendance[id] = !attendance[id]
|
attendance.toggle(id)
|
||||||
attendance = attendance
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drag and drop state
|
// Drag and drop state
|
||||||
@@ -152,14 +148,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleStart() {
|
async function handleStart() {
|
||||||
const presentIds = selectedOrder.filter(id => attendance[id])
|
const att = attendance.get()
|
||||||
|
const presentIds = selectedOrder.filter(id => att[id])
|
||||||
if (presentIds.length === 0) {
|
if (presentIds.length === 0) {
|
||||||
alert($t('setup.noParticipants'))
|
alert($t('setup.noParticipants'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await StartMeeting(presentIds, attendance)
|
await StartMeeting(presentIds, att)
|
||||||
dispatch('started')
|
dispatch('started')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to start meeting:', e)
|
console.error('Failed to start meeting:', e)
|
||||||
@@ -314,7 +311,7 @@
|
|||||||
{@const p = getParticipant(id)}
|
{@const p = getParticipant(id)}
|
||||||
{#if p}
|
{#if p}
|
||||||
<li
|
<li
|
||||||
class:absent={!attendance[id]}
|
class:absent={!$attendance[id]}
|
||||||
class:drag-over={dragOverId === id}
|
class:drag-over={dragOverId === id}
|
||||||
draggable="true"
|
draggable="true"
|
||||||
on:dragstart={(e) => handleDragStart(e, id)}
|
on:dragstart={(e) => handleDragStart(e, id)}
|
||||||
@@ -329,10 +326,10 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
class="attendance-toggle"
|
class="attendance-toggle"
|
||||||
class:present={attendance[id]}
|
class:present={$attendance[id]}
|
||||||
on:click={() => toggleAttendance(id)}
|
on:click={() => toggleAttendance(id)}
|
||||||
>
|
>
|
||||||
{attendance[id] ? '✓' : '✗'}
|
{$attendance[id] ? '✓' : '✗'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<span class="name">{p.name}</span>
|
<span class="name">{p.name}</span>
|
||||||
@@ -375,8 +372,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="summary">
|
<div class="summary">
|
||||||
<span>{$t('setup.participants')}: {Object.values(attendance).filter(Boolean).length} / {participants.length}</span>
|
<span>{$t('setup.participants')}: {Object.values($attendance).filter(Boolean).length} / {participants.length}</span>
|
||||||
<span>≈ {formatTime(selectedOrder.filter(id => attendance[id]).reduce((acc, id) => acc + (getParticipant(id)?.timeLimit || 0), 0))}</span>
|
<span>≈ {formatTime(selectedOrder.filter(id => $attendance[id]).reduce((acc, id) => acc + (getParticipant(id)?.timeLimit || 0), 0))}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="start-btn" on:click={handleStart}>
|
<button class="start-btn" on:click={handleStart}>
|
||||||
|
|||||||
56
frontend/src/lib/stores.js
Normal file
56
frontend/src/lib/stores.js
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { writable, get } from 'svelte/store';
|
||||||
|
|
||||||
|
function createAttendanceStore() {
|
||||||
|
const { subscribe, set, update } = writable({});
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
|
||||||
|
// Initialize attendance for all participants (default: true)
|
||||||
|
init(participants) {
|
||||||
|
const current = get({ subscribe });
|
||||||
|
const newAttendance = {};
|
||||||
|
|
||||||
|
for (const p of participants) {
|
||||||
|
// Keep existing value or default to true
|
||||||
|
newAttendance[p.id] = current[p.id] !== undefined ? current[p.id] : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(newAttendance);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Toggle attendance for a participant
|
||||||
|
toggle(id) {
|
||||||
|
update((att) => {
|
||||||
|
att[id] = !att[id];
|
||||||
|
return { ...att };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Set attendance for a participant
|
||||||
|
set(id, present) {
|
||||||
|
update((att) => {
|
||||||
|
att[id] = present;
|
||||||
|
return { ...att };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get current attendance object
|
||||||
|
get() {
|
||||||
|
return get({ subscribe });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Reset all to true
|
||||||
|
resetAll() {
|
||||||
|
update((att) => {
|
||||||
|
const reset = {};
|
||||||
|
for (const id in att) {
|
||||||
|
reset[id] = true;
|
||||||
|
}
|
||||||
|
return reset;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const attendance = createAttendanceStore();
|
||||||
4
frontend/wailsjs/go/app/App.d.ts
vendored
4
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -53,6 +53,8 @@ export function RestartApp():Promise<void>;
|
|||||||
|
|
||||||
export function ResumeMeeting():Promise<void>;
|
export function ResumeMeeting():Promise<void>;
|
||||||
|
|
||||||
|
export function SaveWindowPosition():Promise<void>;
|
||||||
|
|
||||||
export function SelectCustomSound(arg1:string):Promise<string>;
|
export function SelectCustomSound(arg1:string):Promise<string>;
|
||||||
|
|
||||||
export function SkipSpeaker():Promise<void>;
|
export function SkipSpeaker():Promise<void>;
|
||||||
@@ -61,6 +63,8 @@ export function StartMeeting(arg1:Array<number>,arg2:Record<number, boolean>):Pr
|
|||||||
|
|
||||||
export function StopMeeting():Promise<void>;
|
export function StopMeeting():Promise<void>;
|
||||||
|
|
||||||
|
export function SwitchToSpeaker(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function UpdateMeeting(arg1:string,arg2:number):Promise<void>;
|
export function UpdateMeeting(arg1:string,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function UpdateParticipant(arg1:number,arg2:string,arg3:string,arg4:number):Promise<void>;
|
export function UpdateParticipant(arg1:number,arg2:string,arg3:string,arg4:number):Promise<void>;
|
||||||
|
|||||||
@@ -102,6 +102,10 @@ export function ResumeMeeting() {
|
|||||||
return window['go']['app']['App']['ResumeMeeting']();
|
return window['go']['app']['App']['ResumeMeeting']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveWindowPosition() {
|
||||||
|
return window['go']['app']['App']['SaveWindowPosition']();
|
||||||
|
}
|
||||||
|
|
||||||
export function SelectCustomSound(arg1) {
|
export function SelectCustomSound(arg1) {
|
||||||
return window['go']['app']['App']['SelectCustomSound'](arg1);
|
return window['go']['app']['App']['SelectCustomSound'](arg1);
|
||||||
}
|
}
|
||||||
@@ -118,6 +122,10 @@ export function StopMeeting() {
|
|||||||
return window['go']['app']['App']['StopMeeting']();
|
return window['go']['app']['App']['StopMeeting']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SwitchToSpeaker(arg1) {
|
||||||
|
return window['go']['app']['App']['SwitchToSpeaker'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function UpdateMeeting(arg1, arg2) {
|
export function UpdateMeeting(arg1, arg2) {
|
||||||
return window['go']['app']['App']['UpdateMeeting'](arg1, arg2);
|
return window['go']['app']['App']['UpdateMeeting'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -327,6 +327,8 @@ export namespace models {
|
|||||||
theme: string;
|
theme: string;
|
||||||
windowWidth: number;
|
windowWidth: number;
|
||||||
windowFullHeight: boolean;
|
windowFullHeight: boolean;
|
||||||
|
windowX: number;
|
||||||
|
windowY: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Settings(source);
|
return new Settings(source);
|
||||||
@@ -345,6 +347,8 @@ export namespace models {
|
|||||||
this.theme = source["theme"];
|
this.theme = source["theme"];
|
||||||
this.windowWidth = source["windowWidth"];
|
this.windowWidth = source["windowWidth"];
|
||||||
this.windowFullHeight = source["windowFullHeight"];
|
this.windowFullHeight = source["windowFullHeight"];
|
||||||
|
this.windowX = source["windowX"];
|
||||||
|
this.windowY = source["windowY"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class SpeakerInfo {
|
export class SpeakerInfo {
|
||||||
|
|||||||
@@ -39,10 +39,19 @@ func (a *App) Startup(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) OnDomReady(ctx context.Context) {
|
func (a *App) OnDomReady(ctx context.Context) {
|
||||||
|
// Restore saved window position
|
||||||
|
if settings, err := a.store.GetSettings(); err == nil && settings != nil {
|
||||||
|
if settings.WindowX >= 0 && settings.WindowY >= 0 {
|
||||||
|
runtime.WindowSetPosition(ctx, settings.WindowX, settings.WindowY)
|
||||||
|
}
|
||||||
|
}
|
||||||
runtime.WindowShow(ctx)
|
runtime.WindowShow(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Shutdown(ctx context.Context) {
|
func (a *App) Shutdown(ctx context.Context) {
|
||||||
|
// Save window position before closing
|
||||||
|
a.saveWindowPosition()
|
||||||
|
|
||||||
if a.timer != nil {
|
if a.timer != nil {
|
||||||
a.timer.Close()
|
a.timer.Close()
|
||||||
}
|
}
|
||||||
@@ -51,6 +60,25 @@ func (a *App) Shutdown(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) saveWindowPosition() {
|
||||||
|
if a.ctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
x, y := runtime.WindowGetPosition(a.ctx)
|
||||||
|
if x >= 0 && y >= 0 {
|
||||||
|
if settings, err := a.store.GetSettings(); err == nil && settings != nil {
|
||||||
|
settings.WindowX = x
|
||||||
|
settings.WindowY = y
|
||||||
|
_ = a.store.UpdateSettings(settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveWindowPosition saves current window position (can be called from frontend)
|
||||||
|
func (a *App) SaveWindowPosition() {
|
||||||
|
a.saveWindowPosition()
|
||||||
|
}
|
||||||
|
|
||||||
// Participants
|
// Participants
|
||||||
|
|
||||||
func (a *App) GetParticipants() ([]models.Participant, error) {
|
func (a *App) GetParticipants() ([]models.Participant, error) {
|
||||||
@@ -179,13 +207,37 @@ func (a *App) handleTimerEvents() {
|
|||||||
a.saveSpeakerLog(event.State)
|
a.saveSpeakerLog(event.State)
|
||||||
runtime.EventsEmit(a.ctx, "timer:speaker_changed", event.State)
|
runtime.EventsEmit(a.ctx, "timer:speaker_changed", event.State)
|
||||||
case timer.EventMeetingEnded:
|
case timer.EventMeetingEnded:
|
||||||
a.saveSpeakerLog(event.State)
|
a.finalizeSpeakerLogs(event.State)
|
||||||
a.endMeetingSession(event.State)
|
a.endMeetingSession(event.State)
|
||||||
runtime.EventsEmit(a.ctx, "timer:meeting_ended", event.State)
|
runtime.EventsEmit(a.ctx, "timer:meeting_ended", event.State)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) finalizeSpeakerLogs(state models.TimerState) {
|
||||||
|
if a.session == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only finalize existing logs, don't create new ones
|
||||||
|
for id, log := range a.currentLogs {
|
||||||
|
if log.EndedAt == nil {
|
||||||
|
now := time.Now()
|
||||||
|
log.EndedAt = &now
|
||||||
|
log.Duration = int(now.Sub(log.StartedAt).Seconds())
|
||||||
|
|
||||||
|
participants, _ := a.store.GetParticipants()
|
||||||
|
for _, p := range participants {
|
||||||
|
if p.ID == id {
|
||||||
|
log.Overtime = log.Duration > p.TimeLimit
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = a.store.UpdateParticipantLog(log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) saveSpeakerLog(state models.TimerState) {
|
func (a *App) saveSpeakerLog(state models.TimerState) {
|
||||||
if a.session == nil {
|
if a.session == nil {
|
||||||
return
|
return
|
||||||
@@ -247,6 +299,12 @@ func (a *App) RemoveFromQueue(speakerID uint) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) SwitchToSpeaker(speakerID uint) {
|
||||||
|
if a.timer != nil {
|
||||||
|
a.timer.SwitchToSpeaker(speakerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) PauseMeeting() {
|
func (a *App) PauseMeeting() {
|
||||||
if a.timer != nil {
|
if a.timer != nil {
|
||||||
a.timer.Pause()
|
a.timer.Pause()
|
||||||
|
|||||||
@@ -69,4 +69,6 @@ type Settings struct {
|
|||||||
Theme string `json:"theme" gorm:"default:dark"`
|
Theme string `json:"theme" gorm:"default:dark"`
|
||||||
WindowWidth int `json:"windowWidth" gorm:"default:800"` // minimum 480
|
WindowWidth int `json:"windowWidth" gorm:"default:800"` // minimum 480
|
||||||
WindowFullHeight bool `json:"windowFullHeight" gorm:"default:true"` // use full screen height
|
WindowFullHeight bool `json:"windowFullHeight" gorm:"default:true"` // use full screen height
|
||||||
|
WindowX int `json:"windowX" gorm:"default:-1"` // -1 = not set (center)
|
||||||
|
WindowY int `json:"windowY" gorm:"default:-1"` // -1 = not set (center)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,15 +105,20 @@ func (t *Timer) Start() {
|
|||||||
t.speakerWarned = false
|
t.speakerWarned = false
|
||||||
t.meetingWarned = false
|
t.meetingWarned = false
|
||||||
|
|
||||||
if len(t.queue) > 0 {
|
hasSpeakers := len(t.queue) > 0
|
||||||
t.startNextSpeaker(now)
|
if hasSpeakers {
|
||||||
|
t.startNextSpeaker(now, 0)
|
||||||
}
|
}
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
if hasSpeakers {
|
||||||
|
t.emit(EventSpeakerChanged)
|
||||||
|
}
|
||||||
|
|
||||||
go t.tick()
|
go t.tick()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Timer) startNextSpeaker(now time.Time) {
|
func (t *Timer) startNextSpeaker(now time.Time, offset time.Duration) {
|
||||||
if len(t.queue) == 0 {
|
if len(t.queue) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -137,8 +142,8 @@ func (t *Timer) startNextSpeaker(now time.Time) {
|
|||||||
t.currentSpeakerID = speaker.ID
|
t.currentSpeakerID = speaker.ID
|
||||||
t.currentSpeaker = speaker.Name
|
t.currentSpeaker = speaker.Name
|
||||||
t.speakerLimit = time.Duration(speaker.TimeLimit) * time.Second
|
t.speakerLimit = time.Duration(speaker.TimeLimit) * time.Second
|
||||||
t.speakerStartTime = now
|
t.speakerStartTime = now.Add(-offset)
|
||||||
t.speakerElapsed = 0
|
t.speakerElapsed = offset
|
||||||
t.speakingOrder++
|
t.speakingOrder++
|
||||||
t.speakerWarned = false
|
t.speakerWarned = false
|
||||||
t.speakerTimeUpEmitted = false
|
t.speakerTimeUpEmitted = false
|
||||||
@@ -193,7 +198,7 @@ func (t *Timer) NextSpeaker() {
|
|||||||
|
|
||||||
var eventType EventType
|
var eventType EventType
|
||||||
if len(t.queue) > 0 {
|
if len(t.queue) > 0 {
|
||||||
t.startNextSpeaker(now)
|
t.startNextSpeaker(now, 0)
|
||||||
eventType = EventSpeakerChanged
|
eventType = EventSpeakerChanged
|
||||||
} else {
|
} else {
|
||||||
t.running = false
|
t.running = false
|
||||||
@@ -227,11 +232,14 @@ func (t *Timer) SkipSpeaker() {
|
|||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if len(t.queue) > 1 {
|
if len(t.queue) > 1 {
|
||||||
t.startNextSpeaker(now)
|
t.startNextSpeaker(now, 0)
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
t.emit(EventSpeakerChanged)
|
t.emit(EventSpeakerChanged)
|
||||||
} else {
|
} else {
|
||||||
|
// Only skipped speaker left - they need to speak now
|
||||||
|
t.startNextSpeaker(now, 0)
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
|
t.emit(EventSpeakerChanged)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +256,16 @@ func (t *Timer) RemoveFromQueue(speakerID uint) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove from queue
|
// Find speaker info before removing
|
||||||
|
var speakerInfo models.QueuedSpeaker
|
||||||
|
for _, s := range t.queue {
|
||||||
|
if s.ID == speakerID {
|
||||||
|
speakerInfo = s
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from current position in queue
|
||||||
for i, s := range t.queue {
|
for i, s := range t.queue {
|
||||||
if s.ID == speakerID {
|
if s.ID == speakerID {
|
||||||
t.queue = append(t.queue[:i], t.queue[i+1:]...)
|
t.queue = append(t.queue[:i], t.queue[i+1:]...)
|
||||||
@@ -256,11 +273,124 @@ func (t *Timer) RemoveFromQueue(speakerID uint) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add to end of queue so they can speak later
|
||||||
|
if speakerInfo.ID != 0 {
|
||||||
|
t.queue = append(t.queue, speakerInfo)
|
||||||
|
}
|
||||||
|
|
||||||
// Mark as skipped in allSpeakers and move to end
|
// Mark as skipped in allSpeakers and move to end
|
||||||
t.updateSpeakerStatus(speakerID, models.SpeakerStatusSkipped)
|
t.updateSpeakerStatus(speakerID, models.SpeakerStatusSkipped)
|
||||||
t.moveSpeakerToEnd(speakerID)
|
t.moveSpeakerToEnd(speakerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SwitchToSpeaker moves the specified speaker to front of queue and starts them
|
||||||
|
// If speaker is already done, resumes their timer from accumulated time
|
||||||
|
func (t *Timer) SwitchToSpeaker(speakerID uint) {
|
||||||
|
t.mu.Lock()
|
||||||
|
|
||||||
|
if !t.running {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, find speaker in allSpeakers to get their info and status
|
||||||
|
var speakerInfo *models.SpeakerInfo
|
||||||
|
var speakerInfoIdx int
|
||||||
|
for i := range t.allSpeakers {
|
||||||
|
if t.allSpeakers[i].ID == speakerID {
|
||||||
|
speakerInfo = &t.allSpeakers[i]
|
||||||
|
speakerInfoIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if speakerInfo == nil {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't switch to currently speaking speaker
|
||||||
|
if speakerInfo.Status == models.SpeakerStatusSpeaking {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate offset for resuming (0 for pending/skipped, timeSpent for done)
|
||||||
|
var offset time.Duration
|
||||||
|
if speakerInfo.Status == models.SpeakerStatusDone {
|
||||||
|
offset = time.Duration(speakerInfo.TimeSpent) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save current speaker time
|
||||||
|
now := time.Now()
|
||||||
|
if t.currentSpeakerID != 0 {
|
||||||
|
timeSpent := int(now.Sub(t.speakerStartTime).Seconds())
|
||||||
|
for i := range t.allSpeakers {
|
||||||
|
if t.allSpeakers[i].ID == t.currentSpeakerID {
|
||||||
|
if t.allSpeakers[i].Status == models.SpeakerStatusSpeaking {
|
||||||
|
t.allSpeakers[i].Status = models.SpeakerStatusDone
|
||||||
|
t.allSpeakers[i].TimeSpent = timeSpent
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find speaker in queue (pending/skipped) or create new entry (done)
|
||||||
|
foundIdx := -1
|
||||||
|
for i, s := range t.queue {
|
||||||
|
if s.ID == speakerID {
|
||||||
|
foundIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create QueuedSpeaker from SpeakerInfo
|
||||||
|
queuedSpeaker := models.QueuedSpeaker{
|
||||||
|
ID: speakerInfo.ID,
|
||||||
|
Name: speakerInfo.Name,
|
||||||
|
TimeLimit: speakerInfo.TimeLimit,
|
||||||
|
Order: speakerInfo.Order,
|
||||||
|
}
|
||||||
|
|
||||||
|
if foundIdx >= 0 {
|
||||||
|
// Remove from current position in queue
|
||||||
|
t.queue = append(t.queue[:foundIdx], t.queue[foundIdx+1:]...)
|
||||||
|
}
|
||||||
|
// Insert at front of queue
|
||||||
|
t.queue = append([]models.QueuedSpeaker{queuedSpeaker}, t.queue...)
|
||||||
|
|
||||||
|
// Move the selected speaker in allSpeakers to position after last done/speaking
|
||||||
|
insertPos := 0
|
||||||
|
for i, s := range t.allSpeakers {
|
||||||
|
if s.Status == models.SpeakerStatusDone || s.Status == models.SpeakerStatusSpeaking {
|
||||||
|
insertPos = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if speakerInfoIdx >= 0 && speakerInfoIdx != insertPos {
|
||||||
|
// Save speaker info before removing
|
||||||
|
savedInfo := *speakerInfo
|
||||||
|
// Remove from current position
|
||||||
|
t.allSpeakers = append(t.allSpeakers[:speakerInfoIdx], t.allSpeakers[speakerInfoIdx+1:]...)
|
||||||
|
// Adjust insert position if needed
|
||||||
|
if speakerInfoIdx < insertPos {
|
||||||
|
insertPos--
|
||||||
|
}
|
||||||
|
// Insert at new position
|
||||||
|
t.allSpeakers = append(t.allSpeakers[:insertPos], append([]models.SpeakerInfo{savedInfo}, t.allSpeakers[insertPos:]...)...)
|
||||||
|
// Update order numbers
|
||||||
|
for i := range t.allSpeakers {
|
||||||
|
t.allSpeakers[i].Order = i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start this speaker with offset (0 for new speakers, accumulated time for done)
|
||||||
|
t.startNextSpeaker(now, offset)
|
||||||
|
t.mu.Unlock()
|
||||||
|
t.emit(EventSpeakerChanged)
|
||||||
|
}
|
||||||
|
|
||||||
func (t *Timer) Pause() {
|
func (t *Timer) Pause() {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user