Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
545a18cf59 | ||
|
|
7e376f8211 | ||
|
|
c2a17185fd | ||
|
|
b2454f3e9e | ||
|
|
422ff362c3 | ||
|
|
41c3fd4934 | ||
|
|
6783ed8b0a | ||
|
|
fe6a41226c | ||
|
|
fc93ebbd26 | ||
|
|
f0a8c32ea2 | ||
|
|
850d1deed2 | ||
|
|
5131a72983 | ||
|
|
6dac14e0c1 | ||
|
|
482786a34b | ||
|
|
906f504d49 | ||
|
|
649b1c039d | ||
|
|
5fd85bfc50 | ||
|
|
809f64b93d | ||
|
|
30af8729b8 | ||
|
|
cf0d60f40c | ||
|
|
9f5c9d568d | ||
|
|
2b86eb9d20 | ||
|
|
75dc03b0fd | ||
|
|
a81540646e |
38
Makefile
38
Makefile
@@ -1,17 +1,23 @@
|
||||
.PHONY: dev build clean install frontend
|
||||
|
||||
# Get version from git tag
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
BUILD_TIME := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
LDFLAGS := -X 'daily-timer/internal/version.Version=$(VERSION)' -X 'daily-timer/internal/version.GitCommit=$(GIT_COMMIT)' -X 'daily-timer/internal/version.BuildTime=$(BUILD_TIME)'
|
||||
|
||||
# Development (fixed ports: Vite 5173, Wails DevServer 34115)
|
||||
dev:
|
||||
wails dev -devserver localhost:34115
|
||||
|
||||
# Build for macOS
|
||||
build:
|
||||
wails build -clean
|
||||
build: lint
|
||||
wails build -clean -ldflags "$(LDFLAGS)"
|
||||
@xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true
|
||||
|
||||
# Build for macOS (universal binary)
|
||||
build-universal:
|
||||
wails build -clean -platform darwin/universal
|
||||
build-universal: lint
|
||||
wails build -clean -platform darwin/universal -ldflags "$(LDFLAGS)"
|
||||
@xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true
|
||||
|
||||
# Install frontend dependencies
|
||||
@@ -51,21 +57,20 @@ deps:
|
||||
init: deps frontend
|
||||
@echo "Project initialized. Run 'make dev' to start development."
|
||||
|
||||
# Get version from git tag
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
|
||||
# Release - build and package
|
||||
release:
|
||||
release: lint
|
||||
@echo "Building release $(VERSION)..."
|
||||
wails build -clean
|
||||
wails build -clean -ldflags "$(LDFLAGS)"
|
||||
@xattr -cr "build/bin/Daily Timer.app" 2>/dev/null || true
|
||||
@mkdir -p dist
|
||||
@rm -rf dist && mkdir -p dist
|
||||
cd build/bin && zip -r "../../dist/Daily-Timer-$(VERSION)-macos-arm64.zip" "Daily Timer.app"
|
||||
@shasum -a 256 "build/bin/Daily Timer.app/Contents/MacOS/daily-timer" | awk '{print $$1}' > "dist/Daily-Timer-$(VERSION)-macos-arm64.sha256"
|
||||
@echo "Release package: dist/Daily-Timer-$(VERSION)-macos-arm64.zip"
|
||||
@ls -lh dist/*.zip
|
||||
@echo "Checksum: $$(cat dist/Daily-Timer-$(VERSION)-macos-arm64.sha256)"
|
||||
@ls -lh dist/*
|
||||
|
||||
# Release for both architectures
|
||||
release-all:
|
||||
release-all: lint
|
||||
@echo "Building release $(VERSION) for all platforms..."
|
||||
@mkdir -p dist
|
||||
GOOS=darwin GOARCH=arm64 wails build -clean -o daily-timer-arm64
|
||||
@@ -76,7 +81,8 @@ release-all:
|
||||
@ls -lh dist/*.zip
|
||||
|
||||
# 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
|
||||
@echo "Creating release $(VERSION) on Gitea..."
|
||||
@RELEASE_ID=$$(curl -s -X POST \
|
||||
@@ -86,7 +92,7 @@ release-upload:
|
||||
-d '{"tag_name": "$(VERSION)", "name": "$(VERSION)", "body": "Release $(VERSION)"}' \
|
||||
| jq -r '.id'); \
|
||||
echo "Created release ID: $$RELEASE_ID"; \
|
||||
for file in dist/*.zip; do \
|
||||
for file in dist/*; do \
|
||||
filename=$$(basename "$$file"); \
|
||||
echo "Uploading $$filename..."; \
|
||||
curl -s -X POST \
|
||||
@@ -96,8 +102,8 @@ release-upload:
|
||||
done
|
||||
@echo "Done!"
|
||||
|
||||
# Full release cycle: build + upload
|
||||
release-publish: release release-upload
|
||||
# Full release cycle: build + upload (release-upload already depends on release)
|
||||
release-publish: release-upload
|
||||
|
||||
# Help
|
||||
help:
|
||||
|
||||
25
README.md
25
README.md
@@ -15,6 +15,7 @@
|
||||
- 💾 **Экспорт** - экспорт данных в JSON или CSV
|
||||
- 🔊 **Звуковые уведомления** - настраиваемые звуковые оповещения
|
||||
- 🌐 **Локализация** - русский и английский интерфейс
|
||||
- 🔄 **Автообновление** - проверка и установка обновлений из приложения
|
||||
|
||||
## Скриншоты
|
||||
|
||||
@@ -36,6 +37,24 @@ _(Скоро)_
|
||||
3. Переместить `Daily Timer.app` в `/Applications/`
|
||||
4. Убрать атрибут карантина (см. ниже)
|
||||
|
||||
### Автообновление
|
||||
|
||||
Приложение поддерживает автоматическое обновление:
|
||||
|
||||
1. Открыть **Настройки** → секция "Обновления"
|
||||
2. Нажать **Проверить сейчас**
|
||||
3. Если доступно обновление → **Скачать и установить**
|
||||
4. После установки нажать **Перезапустить**
|
||||
|
||||
Приложение автоматически:
|
||||
|
||||
- Скачивает новую версию
|
||||
- Устанавливает в `/Applications/`
|
||||
- Снимает карантин (`xattr -cr`)
|
||||
- Перезапускается
|
||||
|
||||
**Обнаружение пересборок**: Если версия та же, но бинарник изменился (пересборка) - приложение также предложит обновление.
|
||||
|
||||
### Установка Wails CLI
|
||||
|
||||
```bash
|
||||
@@ -86,8 +105,9 @@ xattr -cr "Daily Timer.app"
|
||||
2. Таймер показывает текущего спикера с обратным отсчётом
|
||||
3. Нажать **Следующий** для перехода (или ⌘N)
|
||||
4. Нажать **Пропустить** чтобы переместить спикера в конец очереди
|
||||
5. Использовать **Пауза/Продолжить** для прерываний
|
||||
6. Нажать **Стоп** для досрочного завершения
|
||||
5. **Клик по спикеру** в списке - быстро переключиться на него (для done-спикеров таймер продолжится)
|
||||
6. Использовать **Пауза/Продолжить** для прерываний
|
||||
7. Нажать **Стоп** для досрочного завершения
|
||||
|
||||
### Горячие клавиши
|
||||
|
||||
@@ -172,6 +192,7 @@ GITEA_TOKEN=<token> make release-publish
|
||||
- [ ] Шаблоны команд
|
||||
- [ ] Облачная синхронизация
|
||||
- [ ] Поддержка Windows/Linux
|
||||
- [x] Автообновление приложения
|
||||
|
||||
## Лицензия
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@
|
||||
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 { GetParticipants, StartMeeting, GetSettings, SkipSpeaker, RemoveFromQueue, SwitchToSpeaker } from '../wailsjs/go/app/App'
|
||||
import { t, initLocale } from './lib/i18n'
|
||||
import { attendance } from './lib/stores'
|
||||
|
||||
let currentView = 'main'
|
||||
let timerState = null
|
||||
@@ -35,6 +36,27 @@
|
||||
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() {
|
||||
@@ -80,17 +102,17 @@
|
||||
timerState = state
|
||||
}
|
||||
|
||||
function handleWarning(state) {
|
||||
async function handleWarning(state) {
|
||||
timerState = state
|
||||
if (settings?.soundEnabled) {
|
||||
playSound('warning')
|
||||
await playSound('warning')
|
||||
}
|
||||
}
|
||||
|
||||
function handleTimeUp(state) {
|
||||
async function handleTimeUp(state) {
|
||||
timerState = state
|
||||
if (settings?.soundEnabled) {
|
||||
playSound('timeup')
|
||||
await playSound('timeup')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,11 +120,11 @@
|
||||
timerState = state
|
||||
}
|
||||
|
||||
function handleMeetingEnded(state) {
|
||||
async function handleMeetingEnded(state) {
|
||||
timerState = state
|
||||
meetingActive = false
|
||||
if (settings?.soundEnabled) {
|
||||
playSound('meeting_end')
|
||||
await playSound('meeting_end')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,24 +163,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
function playSound(name) {
|
||||
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':
|
||||
// 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
|
||||
playBeep(523, 0.2)
|
||||
setTimeout(() => playBeep(659, 0.2), 200)
|
||||
setTimeout(() => playBeep(784, 0.4), 400)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -168,22 +193,37 @@
|
||||
currentView = 'main'
|
||||
}
|
||||
|
||||
function handleSettingsLoaded(s) {
|
||||
settings = s
|
||||
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 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() {
|
||||
try {
|
||||
participants = await GetParticipants() || []
|
||||
attendance.init(participants)
|
||||
} catch (e) {
|
||||
console.error('Failed to load participants:', e)
|
||||
participants = []
|
||||
@@ -193,12 +233,16 @@
|
||||
async function handleQuickStart() {
|
||||
if (participants.length === 0) return
|
||||
|
||||
const ids = participants.map(p => p.id)
|
||||
const attendance = {}
|
||||
participants.forEach(p => { attendance[p.id] = true })
|
||||
const att = attendance.get()
|
||||
const presentIds = participants.filter(p => att[p.id]).map(p => p.id)
|
||||
|
||||
if (presentIds.length === 0) {
|
||||
alert($t('setup.noParticipants'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await StartMeeting(ids, attendance)
|
||||
await StartMeeting(presentIds, att)
|
||||
meetingActive = true
|
||||
} catch (e) {
|
||||
console.error('Failed to start meeting:', e)
|
||||
@@ -242,12 +286,12 @@
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="content">
|
||||
<div class="content" class:no-nav={meetingActive}>
|
||||
{#if currentView === 'main'}
|
||||
{#if meetingActive && timerState}
|
||||
<div class="timer-view">
|
||||
<Timer {timerState} />
|
||||
<ParticipantList {timerState} on:skip={handleSkipFromList} />
|
||||
<ParticipantList {timerState} on:skip={handleSkipFromList} on:switch={handleSwitchSpeaker} />
|
||||
<Controls {timerState} on:stop={() => meetingActive = false} />
|
||||
</div>
|
||||
{:else if participants.length > 0}
|
||||
@@ -309,6 +353,11 @@
|
||||
}
|
||||
|
||||
.nav {
|
||||
position: fixed;
|
||||
top: 32px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
@@ -335,6 +384,7 @@
|
||||
font-size: 13px;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.nav button:hover {
|
||||
@@ -348,10 +398,17 @@
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
position: fixed;
|
||||
top: 84px; /* 32px titlebar + 52px nav height */
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
padding-bottom: 64px;
|
||||
}
|
||||
|
||||
.content.no-nav {
|
||||
top: 32px; /* Only titlebar when nav is hidden */
|
||||
}
|
||||
|
||||
.timer-view {
|
||||
|
||||
@@ -30,8 +30,33 @@
|
||||
await StopMeeting()
|
||||
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>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
<div class="controls">
|
||||
<button class="btn primary" on:click={handleNext}>
|
||||
{hasQueue ? $t('controls.next') : $t('controls.stop')}
|
||||
|
||||
@@ -205,7 +205,15 @@
|
||||
<div class="participant-log" class:log-overtime={log.overtime} class:skipped={log.skipped}>
|
||||
<span class="log-order">#{log.order}</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}
|
||||
<span class="overtime-icon">⚠️</span>
|
||||
{/if}
|
||||
@@ -463,6 +471,22 @@
|
||||
.log-duration {
|
||||
color: #9ca3af;
|
||||
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 {
|
||||
|
||||
@@ -1,31 +1,89 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { createEventDispatcher, tick } from 'svelte'
|
||||
import { t } from '../lib/i18n'
|
||||
|
||||
export let timerState
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let listEl
|
||||
let lastSpeakingOrder = 0
|
||||
|
||||
$: allSpeakers = timerState?.allSpeakers || []
|
||||
$: currentSpeakerId = timerState?.currentSpeakerId || 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) {
|
||||
dispatch('skip', { speakerId })
|
||||
}
|
||||
|
||||
function handleSwitch(speakerId) {
|
||||
dispatch('switch', { speakerId })
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="participant-list">
|
||||
<h3>{$t('timer.participants')}</h3>
|
||||
|
||||
{#if allSpeakers.length > 0}
|
||||
<ul>
|
||||
<ul bind:this={listEl}>
|
||||
{#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="name">{speaker.name}</span>
|
||||
<span class="time-limit">{Math.floor(speaker.timeLimit / 60)}:{(speaker.timeLimit % 60).toString().padStart(2, '0')}</span>
|
||||
{#if speaker.status === 'pending' || speaker.status === 'skipped'}
|
||||
<button class="skip-btn" on:click={() => handleSkip(speaker.id)} title="{$t('controls.skip')}">
|
||||
<span class="time-display">
|
||||
{#if speaker.status === 'done'}
|
||||
<span class="time-spent" class:overtime={speaker.timeSpent > speaker.timeLimit}>
|
||||
{formatTime(speaker.timeSpent)}
|
||||
</span>
|
||||
<span class="time-sep">/</span>
|
||||
<span class="time-limit">{formatTime(speaker.timeLimit)}</span>
|
||||
{:else if speaker.status === 'speaking'}
|
||||
<span class="time-spent" class:overtime={currentElapsed > speaker.timeLimit}>
|
||||
{formatTime(currentElapsed)}
|
||||
</span>
|
||||
<span class="time-sep">/</span>
|
||||
<span class="time-limit">{formatTime(speaker.timeLimit)}</span>
|
||||
{:else}
|
||||
<span class="time-limit">{formatTime(speaker.timeLimit)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if speaker.status === 'pending' || speaker.status === 'speaking'}
|
||||
<button class="skip-btn" on:click|stopPropagation={() => handleSkip(speaker.id)} title="{$t('controls.skip')}">
|
||||
⏭
|
||||
</button>
|
||||
{/if}
|
||||
@@ -85,6 +143,18 @@
|
||||
background: #1b2636;
|
||||
}
|
||||
|
||||
.speaker-item.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.speaker-item.clickable:hover {
|
||||
background: #2d3f52;
|
||||
}
|
||||
|
||||
.speaker-item.done.clickable:hover {
|
||||
background: #2a4a6f;
|
||||
}
|
||||
|
||||
.speaker-item.skipped {
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
@@ -129,13 +199,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time-limit {
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skip-btn {
|
||||
margin-left: 8px;
|
||||
padding: 4px 8px;
|
||||
@@ -163,4 +226,30 @@
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time-spent {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.time-spent.overtime {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.time-sep {
|
||||
color: #6b7280;
|
||||
margin: 0 3px;
|
||||
}
|
||||
|
||||
.time-limit {
|
||||
color: #6b7280;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { onMount, createEventDispatcher } from 'svelte'
|
||||
import { GetSettings, UpdateSettings, GetMeeting, UpdateMeeting } from '../../wailsjs/go/app/App'
|
||||
import { WindowSetSize, ScreenGetAll } from '../../wailsjs/runtime/runtime'
|
||||
import { GetSettings, UpdateSettings, GetMeeting, UpdateMeeting, GetVersion, CheckForUpdates, DownloadAndInstallUpdate, RestartApp, SelectCustomSound, GetCustomSoundPath, ClearCustomSound } from '../../wailsjs/go/app/App'
|
||||
import { WindowSetSize, ScreenGetAll, EventsOn, EventsOff } from '../../wailsjs/runtime/runtime'
|
||||
import { t, locale, setLocale } from '../lib/i18n'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -16,6 +16,23 @@
|
||||
let windowFullHeight = true
|
||||
let audioContext = null
|
||||
|
||||
// Custom sounds state
|
||||
let customSounds = {
|
||||
warning: null,
|
||||
timeup: null,
|
||||
meeting_end: null
|
||||
}
|
||||
let audioElements = {}
|
||||
|
||||
// Update state
|
||||
let currentVersion = 'dev'
|
||||
let updateInfo = null
|
||||
let checkingUpdate = false
|
||||
let downloadingUpdate = false
|
||||
let downloadProgress = 0
|
||||
let updateError = null
|
||||
let updateComplete = false
|
||||
|
||||
function getAudioContext() {
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)()
|
||||
@@ -42,11 +59,17 @@
|
||||
oscillator.stop(ctx.currentTime + duration)
|
||||
} catch (e) {
|
||||
console.error('Failed to play sound:', e)
|
||||
alert('Sound error: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
function testSound(name) {
|
||||
// If custom sound exists, play it
|
||||
if (customSounds[name]) {
|
||||
playCustomSound(name)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise play default beep sounds
|
||||
switch (name) {
|
||||
case 'warning':
|
||||
playBeep(880, 0.15)
|
||||
@@ -65,9 +88,139 @@
|
||||
}
|
||||
}
|
||||
|
||||
function playCustomSound(name) {
|
||||
try {
|
||||
if (!audioElements[name]) {
|
||||
audioElements[name] = new Audio('file://' + customSounds[name])
|
||||
}
|
||||
audioElements[name].currentTime = 0
|
||||
audioElements[name].play()
|
||||
} catch (e) {
|
||||
console.error('Failed to play custom sound:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomSounds() {
|
||||
const types = ['warning', 'timeup', 'meeting_end']
|
||||
for (const type of types) {
|
||||
try {
|
||||
const path = await GetCustomSoundPath(type)
|
||||
if (path) {
|
||||
customSounds[type] = path
|
||||
// Pre-create audio element
|
||||
audioElements[type] = new Audio('file://' + path)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Failed to get custom sound for ${type}:`, e)
|
||||
}
|
||||
}
|
||||
customSounds = { ...customSounds } // Trigger reactivity
|
||||
}
|
||||
|
||||
async function handleUploadSound(soundType) {
|
||||
try {
|
||||
const path = await SelectCustomSound(soundType)
|
||||
if (path) {
|
||||
customSounds[soundType] = path
|
||||
// Recreate audio element with new file
|
||||
audioElements[soundType] = new Audio('file://' + path)
|
||||
customSounds = { ...customSounds }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to upload sound:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClearSound(soundType) {
|
||||
try {
|
||||
await ClearCustomSound(soundType)
|
||||
customSounds[soundType] = null
|
||||
delete audioElements[soundType]
|
||||
customSounds = { ...customSounds }
|
||||
} catch (e) {
|
||||
console.error('Failed to clear sound:', e)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await loadData()
|
||||
await loadCustomSounds()
|
||||
|
||||
// Load version and check for updates
|
||||
try {
|
||||
currentVersion = await GetVersion()
|
||||
checkForUpdates()
|
||||
} catch (e) {
|
||||
console.error('Failed to get version:', e)
|
||||
}
|
||||
|
||||
// Listen for update progress events
|
||||
EventsOn('update:progress', (progress) => {
|
||||
downloadProgress = progress
|
||||
})
|
||||
EventsOn('update:complete', () => {
|
||||
downloadingUpdate = false
|
||||
updateComplete = true
|
||||
})
|
||||
|
||||
// Warm up AudioContext on first user interaction (for sound tests)
|
||||
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.addEventListener('click', warmUpAudio)
|
||||
|
||||
return () => {
|
||||
EventsOff('update:progress')
|
||||
EventsOff('update:complete')
|
||||
document.removeEventListener('click', warmUpAudio)
|
||||
}
|
||||
})
|
||||
|
||||
async function checkForUpdates() {
|
||||
checkingUpdate = true
|
||||
updateError = null
|
||||
updateInfo = null
|
||||
|
||||
try {
|
||||
updateInfo = await CheckForUpdates()
|
||||
} catch (e) {
|
||||
console.error('Failed to check for updates:', e)
|
||||
updateError = e.message || 'Unknown error'
|
||||
} finally {
|
||||
checkingUpdate = false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAndInstall() {
|
||||
downloadingUpdate = true
|
||||
downloadProgress = 0
|
||||
updateError = null
|
||||
|
||||
try {
|
||||
await DownloadAndInstallUpdate()
|
||||
} catch (e) {
|
||||
console.error('Failed to download update:', e)
|
||||
updateError = e.message || 'Download failed'
|
||||
downloadingUpdate = false
|
||||
}
|
||||
}
|
||||
|
||||
async function restartApp() {
|
||||
await RestartApp()
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading = true
|
||||
@@ -183,10 +336,60 @@
|
||||
<label for="soundEnabled">{settings.soundEnabled ? $t('settings.soundEnabled') : $t('settings.soundDisabled')}</label>
|
||||
</div>
|
||||
|
||||
<div class="sound-test-buttons">
|
||||
<button type="button" class="test-btn" on:click={() => testSound('warning')}>🔔 Test Warning</button>
|
||||
<button type="button" class="test-btn" on:click={() => testSound('timeup')}>⏰ Test Time Up</button>
|
||||
<button type="button" class="test-btn" on:click={() => testSound('meeting_end')}>🏁 Test Meeting End</button>
|
||||
<div class="sound-items">
|
||||
<div class="sound-item">
|
||||
<div class="sound-info">
|
||||
<span class="sound-label">🔔 {$t('settings.testWarning')}</span>
|
||||
{#if customSounds.warning}
|
||||
<span class="sound-status custom">{$t('settings.customSound')}</span>
|
||||
{:else}
|
||||
<span class="sound-status default">{$t('settings.defaultSound')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="sound-actions">
|
||||
<button type="button" class="test-btn" on:click={() => testSound('warning')}>▶</button>
|
||||
<button type="button" class="upload-btn" on:click={() => handleUploadSound('warning')}>📁</button>
|
||||
{#if customSounds.warning}
|
||||
<button type="button" class="clear-btn" on:click={() => handleClearSound('warning')}>✕</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sound-item">
|
||||
<div class="sound-info">
|
||||
<span class="sound-label">⏰ {$t('settings.testTimeUp')}</span>
|
||||
{#if customSounds.timeup}
|
||||
<span class="sound-status custom">{$t('settings.customSound')}</span>
|
||||
{:else}
|
||||
<span class="sound-status default">{$t('settings.defaultSound')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="sound-actions">
|
||||
<button type="button" class="test-btn" on:click={() => testSound('timeup')}>▶</button>
|
||||
<button type="button" class="upload-btn" on:click={() => handleUploadSound('timeup')}>📁</button>
|
||||
{#if customSounds.timeup}
|
||||
<button type="button" class="clear-btn" on:click={() => handleClearSound('timeup')}>✕</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sound-item">
|
||||
<div class="sound-info">
|
||||
<span class="sound-label">🏁 {$t('settings.testMeetingEnd')}</span>
|
||||
{#if customSounds.meeting_end}
|
||||
<span class="sound-status custom">{$t('settings.customSound')}</span>
|
||||
{:else}
|
||||
<span class="sound-status default">{$t('settings.defaultSound')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="sound-actions">
|
||||
<button type="button" class="test-btn" on:click={() => testSound('meeting_end')}>▶</button>
|
||||
<button type="button" class="upload-btn" on:click={() => handleUploadSound('meeting_end')}>📁</button>
|
||||
{#if customSounds.meeting_end}
|
||||
<button type="button" class="clear-btn" on:click={() => handleClearSound('meeting_end')}>✕</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -204,6 +407,70 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="updates-section">
|
||||
<h2>{$t('updates.title')}</h2>
|
||||
|
||||
<div class="version-info">
|
||||
<span class="version-label">{$t('updates.currentVersion')}:</span>
|
||||
<span class="version-value">{currentVersion}</span>
|
||||
</div>
|
||||
|
||||
{#if checkingUpdate}
|
||||
<div class="update-status checking">
|
||||
<span class="spinner"></span>
|
||||
{$t('updates.checkingForUpdates')}
|
||||
</div>
|
||||
{:else if updateError}
|
||||
<div class="update-status error">
|
||||
{$t('updates.error')}: {updateError}
|
||||
</div>
|
||||
<button class="update-btn" on:click={checkForUpdates}>
|
||||
{$t('updates.checkNow')}
|
||||
</button>
|
||||
{:else if updateComplete}
|
||||
<div class="update-status success">
|
||||
{$t('updates.restartRequired')}
|
||||
</div>
|
||||
<div class="update-buttons">
|
||||
<button class="update-btn primary" on:click={restartApp}>
|
||||
{$t('updates.restart')}
|
||||
</button>
|
||||
<button class="update-btn" on:click={() => updateComplete = false}>
|
||||
{$t('updates.later')}
|
||||
</button>
|
||||
</div>
|
||||
{:else if downloadingUpdate}
|
||||
<div class="update-status downloading">
|
||||
{$t('updates.downloading')} {Math.round(downloadProgress * 100)}%
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {downloadProgress * 100}%"></div>
|
||||
</div>
|
||||
{:else if updateInfo?.available}
|
||||
<div class="update-status available">
|
||||
{#if updateInfo.isRebuild}
|
||||
{$t('updates.rebuildAvailable')}: <strong>{updateInfo.latestVersion}</strong>
|
||||
{:else}
|
||||
{$t('updates.updateAvailable')}: <strong>{updateInfo.latestVersion}</strong>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="update-btn primary" on:click={downloadAndInstall}>
|
||||
{$t('updates.downloadAndInstall')}
|
||||
</button>
|
||||
{:else if updateInfo}
|
||||
<div class="update-status uptodate">
|
||||
✓ {$t('updates.upToDate')}
|
||||
</div>
|
||||
<button class="update-btn" on:click={checkForUpdates}>
|
||||
{$t('updates.checkNow')}
|
||||
</button>
|
||||
{:else}
|
||||
<button class="update-btn" on:click={checkForUpdates}>
|
||||
{$t('updates.checkNow')}
|
||||
</button>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<button class="save-btn" on:click={saveSettings} disabled={saving}>
|
||||
{saving ? $t('common.loading') : $t('settings.save')}
|
||||
</button>
|
||||
@@ -342,12 +609,6 @@
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.sound-test-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.test-btn {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
@@ -369,4 +630,204 @@
|
||||
.test-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* Sound items */
|
||||
.sound-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.sound-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: #1b2636;
|
||||
border: 1px solid #3d4f61;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sound-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sound-label {
|
||||
font-size: 14px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.sound-status {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sound-status.custom {
|
||||
background: #2a4a3a;
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.sound-status.default {
|
||||
background: #3d4f61;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.sound-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sound-actions button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid #3d4f61;
|
||||
border-radius: 6px;
|
||||
background: #2a3a4e;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sound-actions button:hover {
|
||||
border-color: #4a90d9;
|
||||
background: #3a4a5e;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.sound-actions .clear-btn:hover {
|
||||
border-color: #ef4444;
|
||||
background: #4a2a2a;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
/* Updates section */
|
||||
.updates-section {
|
||||
border: 1px solid #3d4f61;
|
||||
}
|
||||
|
||||
.version-info {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.version-label {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.version-value {
|
||||
color: #4a90d9;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.update-status {
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.update-status.checking {
|
||||
background: #1b2636;
|
||||
color: #9ca3af;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.update-status.error {
|
||||
background: #7f1d1d;
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.update-status.available {
|
||||
background: #164e63;
|
||||
color: #67e8f9;
|
||||
}
|
||||
|
||||
.update-status.uptodate {
|
||||
background: #14532d;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.update-status.downloading {
|
||||
background: #1e3a5f;
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.update-status.success {
|
||||
background: #14532d;
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #4a90d9;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #1b2636;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #4a90d9;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.update-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.update-btn {
|
||||
padding: 10px 16px;
|
||||
border: 2px solid #3d4f61;
|
||||
border-radius: 8px;
|
||||
background: #1b2636;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.update-btn:hover {
|
||||
border-color: #4a90d9;
|
||||
background: #2a3a4e;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.update-btn.primary {
|
||||
background: #4a90d9;
|
||||
border-color: #4a90d9;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.update-btn.primary:hover {
|
||||
background: #3b7dc9;
|
||||
border-color: #3b7dc9;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
import { onMount, createEventDispatcher } from 'svelte'
|
||||
import { GetParticipants, GetMeeting, StartMeeting, AddParticipant, DeleteParticipant, ReorderParticipants, UpdateParticipant, UpdateMeeting } from '../../wailsjs/go/app/App'
|
||||
import { t } from '../lib/i18n'
|
||||
import { attendance } from '../lib/stores'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let participants = []
|
||||
let meeting = null
|
||||
let selectedOrder = []
|
||||
let attendance = {}
|
||||
let loading = true
|
||||
let newName = ''
|
||||
let newTimeLimitMin = 2
|
||||
@@ -37,10 +37,7 @@
|
||||
meeting = await GetMeeting()
|
||||
|
||||
selectedOrder = participants.map(p => p.id)
|
||||
attendance = {}
|
||||
participants.forEach(p => {
|
||||
attendance[p.id] = true
|
||||
})
|
||||
attendance.init(participants)
|
||||
} catch (e) {
|
||||
console.error('Failed to load data:', e)
|
||||
}
|
||||
@@ -95,8 +92,7 @@
|
||||
}
|
||||
|
||||
function toggleAttendance(id) {
|
||||
attendance[id] = !attendance[id]
|
||||
attendance = attendance
|
||||
attendance.toggle(id)
|
||||
}
|
||||
|
||||
// Drag and drop state
|
||||
@@ -152,14 +148,15 @@
|
||||
}
|
||||
|
||||
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) {
|
||||
alert($t('setup.noParticipants'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await StartMeeting(presentIds, attendance)
|
||||
await StartMeeting(presentIds, att)
|
||||
dispatch('started')
|
||||
} catch (e) {
|
||||
console.error('Failed to start meeting:', e)
|
||||
@@ -314,7 +311,7 @@
|
||||
{@const p = getParticipant(id)}
|
||||
{#if p}
|
||||
<li
|
||||
class:absent={!attendance[id]}
|
||||
class:absent={!$attendance[id]}
|
||||
class:drag-over={dragOverId === id}
|
||||
draggable="true"
|
||||
on:dragstart={(e) => handleDragStart(e, id)}
|
||||
@@ -329,10 +326,10 @@
|
||||
|
||||
<button
|
||||
class="attendance-toggle"
|
||||
class:present={attendance[id]}
|
||||
class:present={$attendance[id]}
|
||||
on:click={() => toggleAttendance(id)}
|
||||
>
|
||||
{attendance[id] ? '✓' : '✗'}
|
||||
{$attendance[id] ? '✓' : '✗'}
|
||||
</button>
|
||||
|
||||
<span class="name">{p.name}</span>
|
||||
@@ -375,8 +372,8 @@
|
||||
{/if}
|
||||
|
||||
<div class="summary">
|
||||
<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>{$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>
|
||||
</div>
|
||||
|
||||
<button class="start-btn" on:click={handleStart}>
|
||||
|
||||
@@ -94,6 +94,11 @@ export const translations = {
|
||||
sound: 'Звуковые уведомления',
|
||||
soundEnabled: 'Включены',
|
||||
soundDisabled: 'Выключены',
|
||||
testWarning: 'Предупреждение',
|
||||
testTimeUp: 'Время вышло',
|
||||
testMeetingEnd: 'Конец собрания',
|
||||
customSound: 'свой',
|
||||
defaultSound: 'стандартный',
|
||||
warningTime: 'Предупреждение за',
|
||||
seconds: 'сек',
|
||||
defaultSpeakerTime: 'Время на спикера по умолчанию',
|
||||
@@ -108,6 +113,25 @@ export const translations = {
|
||||
windowFullHeight: 'Окно на всю высоту экрана',
|
||||
},
|
||||
|
||||
// Updates
|
||||
updates: {
|
||||
title: 'Обновления',
|
||||
currentVersion: 'Текущая версия',
|
||||
checkingForUpdates: 'Проверка обновлений...',
|
||||
updateAvailable: 'Доступно обновление',
|
||||
rebuildAvailable: 'Доступна пересборка',
|
||||
upToDate: 'У вас последняя версия',
|
||||
downloadAndInstall: 'Скачать и установить',
|
||||
downloading: 'Загрузка...',
|
||||
installing: 'Установка...',
|
||||
restartRequired: 'Для завершения обновления требуется перезапуск',
|
||||
restart: 'Перезапустить',
|
||||
later: 'Позже',
|
||||
error: 'Ошибка проверки обновлений',
|
||||
downloadError: 'Ошибка загрузки обновления',
|
||||
checkNow: 'Проверить сейчас',
|
||||
},
|
||||
|
||||
// Participant management
|
||||
participants: {
|
||||
title: 'Управление участниками',
|
||||
@@ -243,6 +267,11 @@ export const translations = {
|
||||
sound: 'Sound Notifications',
|
||||
soundEnabled: 'Enabled',
|
||||
soundDisabled: 'Disabled',
|
||||
testWarning: 'Warning',
|
||||
testTimeUp: 'Time Up',
|
||||
testMeetingEnd: 'Meeting End',
|
||||
customSound: 'custom',
|
||||
defaultSound: 'default',
|
||||
warningTime: 'Warning before',
|
||||
seconds: 'sec',
|
||||
defaultSpeakerTime: 'Default Speaker Time',
|
||||
@@ -257,6 +286,25 @@ export const translations = {
|
||||
windowFullHeight: 'Full screen height window',
|
||||
},
|
||||
|
||||
// Updates
|
||||
updates: {
|
||||
title: 'Updates',
|
||||
currentVersion: 'Current version',
|
||||
checkingForUpdates: 'Checking for updates...',
|
||||
updateAvailable: 'Update available',
|
||||
rebuildAvailable: 'Rebuild available',
|
||||
upToDate: 'You have the latest version',
|
||||
downloadAndInstall: 'Download and install',
|
||||
downloading: 'Downloading...',
|
||||
installing: 'Installing...',
|
||||
restartRequired: 'Restart required to complete the update',
|
||||
restart: 'Restart',
|
||||
later: 'Later',
|
||||
error: 'Error checking for updates',
|
||||
downloadError: 'Error downloading update',
|
||||
checkNow: 'Check now',
|
||||
},
|
||||
|
||||
// Participant management
|
||||
participants: {
|
||||
title: 'Manage Participants',
|
||||
|
||||
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();
|
||||
19
frontend/wailsjs/go/app/App.d.ts
vendored
19
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -1,19 +1,28 @@
|
||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||
// This file is automatically generated. DO NOT EDIT
|
||||
import {models} from '../models';
|
||||
import {updater} from '../models';
|
||||
|
||||
export function AddParticipant(arg1:string,arg2:string,arg3:number):Promise<models.Participant>;
|
||||
|
||||
export function CheckForUpdates():Promise<updater.UpdateInfo>;
|
||||
|
||||
export function ClearCustomSound(arg1:string):Promise<void>;
|
||||
|
||||
export function DeleteAllSessions():Promise<void>;
|
||||
|
||||
export function DeleteParticipant(arg1:number):Promise<void>;
|
||||
|
||||
export function DeleteSession(arg1:number):Promise<void>;
|
||||
|
||||
export function DownloadAndInstallUpdate():Promise<void>;
|
||||
|
||||
export function ExportCSV(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function ExportData(arg1:string,arg2:string):Promise<string>;
|
||||
|
||||
export function GetCustomSoundPath(arg1:string):Promise<string>;
|
||||
|
||||
export function GetMeeting():Promise<models.Meeting>;
|
||||
|
||||
export function GetParticipants():Promise<Array<models.Participant>>;
|
||||
@@ -30,6 +39,8 @@ export function GetStatistics(arg1:string,arg2:string):Promise<models.Aggregated
|
||||
|
||||
export function GetTimerState():Promise<models.TimerState>;
|
||||
|
||||
export function GetVersion():Promise<string>;
|
||||
|
||||
export function NextSpeaker():Promise<void>;
|
||||
|
||||
export function PauseMeeting():Promise<void>;
|
||||
@@ -38,14 +49,22 @@ export function RemoveFromQueue(arg1:number):Promise<void>;
|
||||
|
||||
export function ReorderParticipants(arg1:Array<number>):Promise<void>;
|
||||
|
||||
export function RestartApp():Promise<void>;
|
||||
|
||||
export function ResumeMeeting():Promise<void>;
|
||||
|
||||
export function SaveWindowPosition():Promise<void>;
|
||||
|
||||
export function SelectCustomSound(arg1:string):Promise<string>;
|
||||
|
||||
export function SkipSpeaker():Promise<void>;
|
||||
|
||||
export function StartMeeting(arg1:Array<number>,arg2:Record<number, boolean>):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 UpdateParticipant(arg1:number,arg2:string,arg3:string,arg4:number):Promise<void>;
|
||||
|
||||
@@ -6,6 +6,14 @@ export function AddParticipant(arg1, arg2, arg3) {
|
||||
return window['go']['app']['App']['AddParticipant'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function CheckForUpdates() {
|
||||
return window['go']['app']['App']['CheckForUpdates']();
|
||||
}
|
||||
|
||||
export function ClearCustomSound(arg1) {
|
||||
return window['go']['app']['App']['ClearCustomSound'](arg1);
|
||||
}
|
||||
|
||||
export function DeleteAllSessions() {
|
||||
return window['go']['app']['App']['DeleteAllSessions']();
|
||||
}
|
||||
@@ -18,6 +26,10 @@ export function DeleteSession(arg1) {
|
||||
return window['go']['app']['App']['DeleteSession'](arg1);
|
||||
}
|
||||
|
||||
export function DownloadAndInstallUpdate() {
|
||||
return window['go']['app']['App']['DownloadAndInstallUpdate']();
|
||||
}
|
||||
|
||||
export function ExportCSV(arg1, arg2) {
|
||||
return window['go']['app']['App']['ExportCSV'](arg1, arg2);
|
||||
}
|
||||
@@ -26,6 +38,10 @@ export function ExportData(arg1, arg2) {
|
||||
return window['go']['app']['App']['ExportData'](arg1, arg2);
|
||||
}
|
||||
|
||||
export function GetCustomSoundPath(arg1) {
|
||||
return window['go']['app']['App']['GetCustomSoundPath'](arg1);
|
||||
}
|
||||
|
||||
export function GetMeeting() {
|
||||
return window['go']['app']['App']['GetMeeting']();
|
||||
}
|
||||
@@ -58,6 +74,10 @@ export function GetTimerState() {
|
||||
return window['go']['app']['App']['GetTimerState']();
|
||||
}
|
||||
|
||||
export function GetVersion() {
|
||||
return window['go']['app']['App']['GetVersion']();
|
||||
}
|
||||
|
||||
export function NextSpeaker() {
|
||||
return window['go']['app']['App']['NextSpeaker']();
|
||||
}
|
||||
@@ -74,10 +94,22 @@ export function ReorderParticipants(arg1) {
|
||||
return window['go']['app']['App']['ReorderParticipants'](arg1);
|
||||
}
|
||||
|
||||
export function RestartApp() {
|
||||
return window['go']['app']['App']['RestartApp']();
|
||||
}
|
||||
|
||||
export function ResumeMeeting() {
|
||||
return window['go']['app']['App']['ResumeMeeting']();
|
||||
}
|
||||
|
||||
export function SaveWindowPosition() {
|
||||
return window['go']['app']['App']['SaveWindowPosition']();
|
||||
}
|
||||
|
||||
export function SelectCustomSound(arg1) {
|
||||
return window['go']['app']['App']['SelectCustomSound'](arg1);
|
||||
}
|
||||
|
||||
export function SkipSpeaker() {
|
||||
return window['go']['app']['App']['SkipSpeaker']();
|
||||
}
|
||||
@@ -90,6 +122,10 @@ export function StopMeeting() {
|
||||
return window['go']['app']['App']['StopMeeting']();
|
||||
}
|
||||
|
||||
export function SwitchToSpeaker(arg1) {
|
||||
return window['go']['app']['App']['SwitchToSpeaker'](arg1);
|
||||
}
|
||||
|
||||
export function UpdateMeeting(arg1, arg2) {
|
||||
return window['go']['app']['App']['UpdateMeeting'](arg1, arg2);
|
||||
}
|
||||
|
||||
@@ -327,6 +327,8 @@ export namespace models {
|
||||
theme: string;
|
||||
windowWidth: number;
|
||||
windowFullHeight: boolean;
|
||||
windowX: number;
|
||||
windowY: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Settings(source);
|
||||
@@ -345,12 +347,15 @@ export namespace models {
|
||||
this.theme = source["theme"];
|
||||
this.windowWidth = source["windowWidth"];
|
||||
this.windowFullHeight = source["windowFullHeight"];
|
||||
this.windowX = source["windowX"];
|
||||
this.windowY = source["windowY"];
|
||||
}
|
||||
}
|
||||
export class SpeakerInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
timeLimit: number;
|
||||
timeSpent: number;
|
||||
order: number;
|
||||
status: string;
|
||||
|
||||
@@ -363,6 +368,7 @@ export namespace models {
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.timeLimit = source["timeLimit"];
|
||||
this.timeSpent = source["timeSpent"];
|
||||
this.order = source["order"];
|
||||
this.status = source["status"];
|
||||
}
|
||||
@@ -432,3 +438,32 @@ export namespace models {
|
||||
|
||||
}
|
||||
|
||||
export namespace updater {
|
||||
|
||||
export class UpdateInfo {
|
||||
available: boolean;
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
releaseNotes: string;
|
||||
downloadURL: string;
|
||||
downloadSize: number;
|
||||
isRebuild: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new UpdateInfo(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.available = source["available"];
|
||||
this.currentVersion = source["currentVersion"];
|
||||
this.latestVersion = source["latestVersion"];
|
||||
this.releaseNotes = source["releaseNotes"];
|
||||
this.downloadURL = source["downloadURL"];
|
||||
this.downloadSize = source["downloadSize"];
|
||||
this.isRebuild = source["isRebuild"];
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
"time"
|
||||
|
||||
"daily-timer/internal/models"
|
||||
"daily-timer/internal/services/updater"
|
||||
"daily-timer/internal/storage"
|
||||
"daily-timer/internal/timer"
|
||||
"daily-timer/internal/version"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
)
|
||||
@@ -21,12 +23,14 @@ type App struct {
|
||||
timer *timer.Timer
|
||||
session *models.MeetingSession
|
||||
currentLogs map[uint]*models.ParticipantLog
|
||||
updater *updater.Updater
|
||||
}
|
||||
|
||||
func New(store *storage.Storage) *App {
|
||||
return &App{
|
||||
store: store,
|
||||
currentLogs: make(map[uint]*models.ParticipantLog),
|
||||
updater: updater.New(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +39,19 @@ func (a *App) Startup(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)
|
||||
}
|
||||
|
||||
func (a *App) Shutdown(ctx context.Context) {
|
||||
// Save window position before closing
|
||||
a.saveWindowPosition()
|
||||
|
||||
if a.timer != nil {
|
||||
a.timer.Close()
|
||||
}
|
||||
@@ -47,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
|
||||
|
||||
func (a *App) GetParticipants() ([]models.Participant, error) {
|
||||
@@ -175,13 +207,37 @@ func (a *App) handleTimerEvents() {
|
||||
a.saveSpeakerLog(event.State)
|
||||
runtime.EventsEmit(a.ctx, "timer:speaker_changed", event.State)
|
||||
case timer.EventMeetingEnded:
|
||||
a.saveSpeakerLog(event.State)
|
||||
a.finalizeSpeakerLogs(event.State)
|
||||
a.endMeetingSession(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) {
|
||||
if a.session == nil {
|
||||
return
|
||||
@@ -243,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() {
|
||||
if a.timer != nil {
|
||||
a.timer.Pause()
|
||||
@@ -479,3 +541,130 @@ func (a *App) GetSoundsDir() string {
|
||||
_ = os.MkdirAll(soundsDir, 0755)
|
||||
return soundsDir
|
||||
}
|
||||
|
||||
// Updates
|
||||
|
||||
func (a *App) GetVersion() string {
|
||||
return version.Version
|
||||
}
|
||||
|
||||
func (a *App) CheckForUpdates() (*updater.UpdateInfo, error) {
|
||||
return a.updater.CheckForUpdates()
|
||||
}
|
||||
|
||||
func (a *App) DownloadAndInstallUpdate() error {
|
||||
err := a.updater.DownloadAndInstall(func(progress float64) {
|
||||
runtime.EventsEmit(a.ctx, "update:progress", progress)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.EventsEmit(a.ctx, "update:complete", true)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) RestartApp() error {
|
||||
return a.updater.RestartApp()
|
||||
}
|
||||
|
||||
// Sound Management
|
||||
|
||||
func (a *App) getSoundsDir() (string, error) {
|
||||
configDir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
soundsDir := filepath.Join(configDir, "DailyTimer", "sounds")
|
||||
if err := os.MkdirAll(soundsDir, 0755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return soundsDir, nil
|
||||
}
|
||||
|
||||
func (a *App) SelectCustomSound(soundType string) (string, error) {
|
||||
// Validate sound type
|
||||
validTypes := map[string]bool{"warning": true, "timeup": true, "meeting_end": true}
|
||||
if !validTypes[soundType] {
|
||||
return "", fmt.Errorf("invalid sound type: %s", soundType)
|
||||
}
|
||||
|
||||
// Open file dialog
|
||||
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: "Select Sound File",
|
||||
Filters: []runtime.FileFilter{
|
||||
{DisplayName: "Audio Files", Pattern: "*.mp3;*.wav;*.m4a;*.ogg"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if selection == "" {
|
||||
return "", nil // User cancelled
|
||||
}
|
||||
|
||||
// Get sounds directory
|
||||
soundsDir, err := a.getSoundsDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Determine destination filename
|
||||
ext := filepath.Ext(selection)
|
||||
destPath := filepath.Join(soundsDir, soundType+ext)
|
||||
|
||||
// Copy file
|
||||
src, err := os.Open(selection)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = src.Close() }()
|
||||
|
||||
dst, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = dst.Close() }()
|
||||
|
||||
if _, err := dst.ReadFrom(src); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return destPath, nil
|
||||
}
|
||||
|
||||
func (a *App) GetCustomSoundPath(soundType string) string {
|
||||
soundsDir, err := a.getSoundsDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check for common audio extensions
|
||||
extensions := []string{".mp3", ".wav", ".m4a", ".ogg"}
|
||||
for _, ext := range extensions {
|
||||
path := filepath.Join(soundsDir, soundType+ext)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *App) ClearCustomSound(soundType string) error {
|
||||
soundsDir, err := a.getSoundsDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
extensions := []string{".mp3", ".wav", ".m4a", ".ogg"}
|
||||
for _, ext := range extensions {
|
||||
path := filepath.Join(soundsDir, soundType+ext)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
if err := os.Remove(path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -69,4 +69,6 @@ type Settings struct {
|
||||
Theme string `json:"theme" gorm:"default:dark"`
|
||||
WindowWidth int `json:"windowWidth" gorm:"default:800"` // minimum 480
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ type SpeakerInfo struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
TimeLimit int `json:"timeLimit"`
|
||||
TimeSpent int `json:"timeSpent"`
|
||||
Order int `json:"order"`
|
||||
Status SpeakerStatus `json:"status"`
|
||||
}
|
||||
|
||||
428
internal/services/updater/updater.go
Normal file
428
internal/services/updater/updater.go
Normal file
@@ -0,0 +1,428 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"daily-timer/internal/version"
|
||||
)
|
||||
|
||||
const (
|
||||
GiteaAPIURL = "https://git.movida.biz/api/v1/repos/bell/daily-timer/releases/latest"
|
||||
AppName = "Daily Timer.app"
|
||||
InstallPath = "/Applications"
|
||||
DownloadPrefix = "Daily-Timer-"
|
||||
)
|
||||
|
||||
type Release struct {
|
||||
TagName string `json:"tag_name"`
|
||||
Name string `json:"name"`
|
||||
Body string `json:"body"`
|
||||
PublishedAt string `json:"published_at"`
|
||||
Assets []Asset `json:"assets"`
|
||||
}
|
||||
|
||||
type Asset struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
BrowserDownloadURL string `json:"browser_download_url"`
|
||||
}
|
||||
|
||||
type UpdateInfo struct {
|
||||
Available bool `json:"available"`
|
||||
CurrentVersion string `json:"currentVersion"`
|
||||
LatestVersion string `json:"latestVersion"`
|
||||
ReleaseNotes string `json:"releaseNotes"`
|
||||
DownloadURL string `json:"downloadURL"`
|
||||
DownloadSize int64 `json:"downloadSize"`
|
||||
IsRebuild bool `json:"isRebuild"`
|
||||
}
|
||||
|
||||
type Updater struct {
|
||||
latestRelease *Release
|
||||
downloadURL string
|
||||
}
|
||||
|
||||
func New() *Updater {
|
||||
return &Updater{}
|
||||
}
|
||||
|
||||
func (u *Updater) CheckForUpdates() (*UpdateInfo, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
resp, err := client.Get(GiteaAPIURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check for updates: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var release Release
|
||||
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse release info: %w", err)
|
||||
}
|
||||
|
||||
u.latestRelease = &release
|
||||
|
||||
// Find macOS arm64 asset
|
||||
var downloadAsset *Asset
|
||||
for i := range release.Assets {
|
||||
if strings.Contains(release.Assets[i].Name, "macos-arm64") && strings.HasSuffix(release.Assets[i].Name, ".zip") {
|
||||
downloadAsset = &release.Assets[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if downloadAsset == nil {
|
||||
return nil, fmt.Errorf("no macOS arm64 asset found")
|
||||
}
|
||||
|
||||
u.downloadURL = downloadAsset.BrowserDownloadURL
|
||||
|
||||
// Find checksum asset
|
||||
var checksumAsset *Asset
|
||||
for i := range release.Assets {
|
||||
if strings.Contains(release.Assets[i].Name, "macos-arm64") && strings.HasSuffix(release.Assets[i].Name, ".sha256") {
|
||||
checksumAsset = &release.Assets[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
latestVersion := strings.TrimPrefix(release.TagName, "v")
|
||||
currentVersion := strings.TrimPrefix(version.Version, "v")
|
||||
|
||||
isNewer := isNewerVersion(latestVersion, currentVersion)
|
||||
isRebuild := false
|
||||
|
||||
// Check if same version but different checksum (rebuild)
|
||||
if !isNewer && checksumAsset != nil {
|
||||
remoteChecksum, err := u.downloadChecksum(checksumAsset.BrowserDownloadURL)
|
||||
if err == nil {
|
||||
localChecksum, err := u.calculateBinaryChecksum()
|
||||
if err == nil && remoteChecksum != localChecksum {
|
||||
isRebuild = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info := &UpdateInfo{
|
||||
Available: isNewer || isRebuild,
|
||||
CurrentVersion: version.Version,
|
||||
LatestVersion: release.TagName,
|
||||
ReleaseNotes: release.Body,
|
||||
DownloadURL: downloadAsset.BrowserDownloadURL,
|
||||
DownloadSize: downloadAsset.Size,
|
||||
IsRebuild: isRebuild,
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func (u *Updater) DownloadAndInstall(progressCallback func(float64)) error {
|
||||
if u.downloadURL == "" {
|
||||
return fmt.Errorf("no download URL available, run CheckForUpdates first")
|
||||
}
|
||||
|
||||
// Create temp directory
|
||||
tmpDir, err := os.MkdirTemp("", "daily-timer-update-")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp directory: %w", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||
|
||||
zipPath := filepath.Join(tmpDir, "update.zip")
|
||||
|
||||
// Download ZIP
|
||||
if err := u.downloadFile(zipPath, progressCallback); err != nil {
|
||||
return fmt.Errorf("failed to download update: %w", err)
|
||||
}
|
||||
|
||||
// Extract ZIP
|
||||
extractPath := filepath.Join(tmpDir, "extracted")
|
||||
if err := u.extractZip(zipPath, extractPath); err != nil {
|
||||
return fmt.Errorf("failed to extract update: %w", err)
|
||||
}
|
||||
|
||||
// Find .app in extracted folder
|
||||
appPath := filepath.Join(extractPath, AppName)
|
||||
if _, err := os.Stat(appPath); os.IsNotExist(err) {
|
||||
return fmt.Errorf("app not found in update package")
|
||||
}
|
||||
|
||||
// Remove old app from /Applications
|
||||
destPath := filepath.Join(InstallPath, AppName)
|
||||
if _, err := os.Stat(destPath); err == nil {
|
||||
if err := os.RemoveAll(destPath); err != nil {
|
||||
return fmt.Errorf("failed to remove old app: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy new app to /Applications
|
||||
if err := u.copyDir(appPath, destPath); err != nil {
|
||||
return fmt.Errorf("failed to install update: %w", err)
|
||||
}
|
||||
|
||||
// Remove quarantine attribute
|
||||
cmd := exec.Command("xattr", "-cr", destPath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Printf("Warning: failed to remove quarantine: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *Updater) RestartApp() error {
|
||||
destPath := filepath.Join(InstallPath, AppName)
|
||||
|
||||
// Use shell to launch new app after this process exits
|
||||
// The sleep ensures the current app has time to exit
|
||||
script := fmt.Sprintf(`sleep 1 && open "%s"`, destPath)
|
||||
cmd := exec.Command("sh", "-c", script)
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
cmd.Stdin = nil
|
||||
|
||||
// Detach the process so it continues after we exit
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("failed to launch updated app: %w", err)
|
||||
}
|
||||
|
||||
// Release the process so it doesn't become a zombie
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
}()
|
||||
|
||||
// Give the shell time to start
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Exit current app
|
||||
os.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *Updater) downloadFile(destPath string, progressCallback func(float64)) error {
|
||||
resp, err := http.Get(u.downloadURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = out.Close() }()
|
||||
|
||||
totalSize := resp.ContentLength
|
||||
var downloaded int64 = 0
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, writeErr := out.Write(buf[:n]); writeErr != nil {
|
||||
return writeErr
|
||||
}
|
||||
downloaded += int64(n)
|
||||
if progressCallback != nil && totalSize > 0 {
|
||||
progressCallback(float64(downloaded) / float64(totalSize))
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *Updater) extractZip(zipPath, destPath string) error {
|
||||
r, err := zip.OpenReader(zipPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = r.Close() }()
|
||||
|
||||
if err := os.MkdirAll(destPath, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, f := range r.File {
|
||||
fpath := filepath.Join(destPath, f.Name)
|
||||
|
||||
// Prevent ZipSlip vulnerability
|
||||
if !strings.HasPrefix(fpath, filepath.Clean(destPath)+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("illegal file path: %s", fpath)
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
_ = os.MkdirAll(fpath, f.Mode())
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outFile, err := os.OpenFile(fpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
_ = outFile.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = io.Copy(outFile, rc)
|
||||
_ = outFile.Close()
|
||||
_ = rc.Close()
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *Updater) copyDir(src, dst string) error {
|
||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstPath := filepath.Join(dst, relPath)
|
||||
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(dstPath, info.Mode())
|
||||
}
|
||||
|
||||
// Handle symlinks
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
link, err := os.Readlink(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Symlink(link, dstPath)
|
||||
}
|
||||
|
||||
srcFile, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = srcFile.Close() }()
|
||||
|
||||
dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = dstFile.Close() }()
|
||||
|
||||
_, err = io.Copy(dstFile, srcFile)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// downloadChecksum fetches the remote SHA256 checksum file
|
||||
func (u *Updater) downloadChecksum(url string) (string, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("failed to download checksum: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
// calculateBinaryChecksum calculates SHA256 of the current running binary
|
||||
func (u *Updater) calculateBinaryChecksum() (string, error) {
|
||||
execPath, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Resolve symlinks to get actual binary path
|
||||
execPath, err = filepath.EvalSymlinks(execPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
file, err := os.Open(execPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = file.Close() }()
|
||||
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return hex.EncodeToString(hasher.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// isNewerVersion compares semver-like versions (e.g., "0.1.0" vs "0.2.0")
|
||||
func isNewerVersion(latest, current string) bool {
|
||||
if current == "dev" || current == "unknown" {
|
||||
return true
|
||||
}
|
||||
|
||||
latest = strings.TrimPrefix(latest, "v")
|
||||
current = strings.TrimPrefix(current, "v")
|
||||
|
||||
// Handle dirty versions (e.g., "0.1.0-dirty" or "0.1.0-3-g1234567")
|
||||
if strings.Contains(current, "-") {
|
||||
parts := strings.Split(current, "-")
|
||||
current = parts[0]
|
||||
}
|
||||
|
||||
latestParts := strings.Split(latest, ".")
|
||||
currentParts := strings.Split(current, ".")
|
||||
|
||||
for i := 0; i < len(latestParts) && i < len(currentParts); i++ {
|
||||
var l, c int
|
||||
_, _ = fmt.Sscanf(latestParts[i], "%d", &l)
|
||||
_, _ = fmt.Sscanf(currentParts[i], "%d", &c)
|
||||
|
||||
if l > c {
|
||||
return true
|
||||
} else if l < c {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return len(latestParts) > len(currentParts)
|
||||
}
|
||||
@@ -105,25 +105,32 @@ func (t *Timer) Start() {
|
||||
t.speakerWarned = false
|
||||
t.meetingWarned = false
|
||||
|
||||
if len(t.queue) > 0 {
|
||||
t.startNextSpeaker(now)
|
||||
hasSpeakers := len(t.queue) > 0
|
||||
if hasSpeakers {
|
||||
t.startNextSpeaker(now, 0)
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
if hasSpeakers {
|
||||
t.emit(EventSpeakerChanged)
|
||||
}
|
||||
|
||||
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 {
|
||||
return
|
||||
}
|
||||
|
||||
// Mark previous speaker as done (only if they were speaking, not skipped)
|
||||
// Mark previous speaker as done and save their time spent
|
||||
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
|
||||
}
|
||||
@@ -135,8 +142,8 @@ func (t *Timer) startNextSpeaker(now time.Time) {
|
||||
t.currentSpeakerID = speaker.ID
|
||||
t.currentSpeaker = speaker.Name
|
||||
t.speakerLimit = time.Duration(speaker.TimeLimit) * time.Second
|
||||
t.speakerStartTime = now
|
||||
t.speakerElapsed = 0
|
||||
t.speakerStartTime = now.Add(-offset)
|
||||
t.speakerElapsed = offset
|
||||
t.speakingOrder++
|
||||
t.speakerWarned = false
|
||||
t.speakerTimeUpEmitted = false
|
||||
@@ -191,7 +198,7 @@ func (t *Timer) NextSpeaker() {
|
||||
|
||||
var eventType EventType
|
||||
if len(t.queue) > 0 {
|
||||
t.startNextSpeaker(now)
|
||||
t.startNextSpeaker(now, 0)
|
||||
eventType = EventSpeakerChanged
|
||||
} else {
|
||||
t.running = false
|
||||
@@ -225,11 +232,14 @@ func (t *Timer) SkipSpeaker() {
|
||||
|
||||
now := time.Now()
|
||||
if len(t.queue) > 1 {
|
||||
t.startNextSpeaker(now)
|
||||
t.startNextSpeaker(now, 0)
|
||||
t.mu.Unlock()
|
||||
t.emit(EventSpeakerChanged)
|
||||
} else {
|
||||
// Only skipped speaker left - they need to speak now
|
||||
t.startNextSpeaker(now, 0)
|
||||
t.mu.Unlock()
|
||||
t.emit(EventSpeakerChanged)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,7 +256,16 @@ func (t *Timer) RemoveFromQueue(speakerID uint) {
|
||||
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 {
|
||||
if s.ID == speakerID {
|
||||
t.queue = append(t.queue[:i], t.queue[i+1:]...)
|
||||
@@ -254,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
|
||||
t.updateSpeakerStatus(speakerID, models.SpeakerStatusSkipped)
|
||||
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() {
|
||||
t.mu.Lock()
|
||||
|
||||
@@ -299,9 +431,17 @@ func (t *Timer) Stop() {
|
||||
t.mu.Unlock()
|
||||
return
|
||||
}
|
||||
// Mark current speaker as done before stopping
|
||||
// Mark current speaker as done and save their time spent
|
||||
if t.currentSpeakerID != 0 {
|
||||
t.updateSpeakerStatus(t.currentSpeakerID, models.SpeakerStatusDone)
|
||||
now := time.Now()
|
||||
timeSpent := int(now.Sub(t.speakerStartTime).Seconds())
|
||||
for i := range t.allSpeakers {
|
||||
if t.allSpeakers[i].ID == t.currentSpeakerID {
|
||||
t.allSpeakers[i].Status = models.SpeakerStatusDone
|
||||
t.allSpeakers[i].TimeSpent = timeSpent
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
t.running = false
|
||||
t.paused = false
|
||||
|
||||
10
internal/version/version.go
Normal file
10
internal/version/version.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package version
|
||||
|
||||
// Version is set at build time via ldflags
|
||||
var Version = "dev"
|
||||
|
||||
// GitCommit is set at build time via ldflags
|
||||
var GitCommit = "unknown"
|
||||
|
||||
// BuildTime is set at build time via ldflags
|
||||
var BuildTime = "unknown"
|
||||
@@ -13,7 +13,7 @@
|
||||
"info": {
|
||||
"companyName": "Movida.Biz",
|
||||
"productName": "Daily Timer",
|
||||
"productVersion": "1.0.0",
|
||||
"productVersion": "0.1.0",
|
||||
"comments": "Meeting timer with participant time tracking",
|
||||
"copyright": "Copyright © 2026 Movida.Biz"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user