fix: layout, hotkeys, skip/switch speaker logic
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
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'
|
||||
|
||||
let currentView = 'main'
|
||||
@@ -192,16 +192,30 @@
|
||||
currentView = 'main'
|
||||
}
|
||||
|
||||
function handleSettingsLoaded(s) {
|
||||
settings = s
|
||||
function handleSettingsLoaded(event) {
|
||||
settings = event.detail
|
||||
}
|
||||
|
||||
async function handleSkipFromList(event) {
|
||||
const { speakerId } = event.detail
|
||||
try {
|
||||
await RemoveFromQueue(speakerId)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,12 +280,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}
|
||||
@@ -333,6 +347,11 @@
|
||||
}
|
||||
|
||||
.nav {
|
||||
position: fixed;
|
||||
top: 32px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
@@ -373,10 +392,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,19 +1,49 @@
|
||||
<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
|
||||
@@ -25,9 +55,14 @@
|
||||
<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-display">
|
||||
@@ -47,8 +82,8 @@
|
||||
<span class="time-limit">{formatTime(speaker.timeLimit)}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{#if speaker.status === 'pending' || speaker.status === 'skipped'}
|
||||
<button class="skip-btn" on:click={() => handleSkip(speaker.id)} title="{$t('controls.skip')}">
|
||||
{#if speaker.status === 'pending' || speaker.status === 'speaking'}
|
||||
<button class="skip-btn" on:click|stopPropagation={() => handleSkip(speaker.id)} title="{$t('controls.skip')}">
|
||||
⏭
|
||||
</button>
|
||||
{/if}
|
||||
@@ -108,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,
|
||||
@@ -181,12 +228,12 @@
|
||||
}
|
||||
|
||||
.time-display {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time-spent {
|
||||
@@ -199,6 +246,7 @@
|
||||
|
||||
.time-sep {
|
||||
color: #6b7280;
|
||||
margin: 0 3px;
|
||||
}
|
||||
|
||||
.time-limit {
|
||||
|
||||
2
frontend/wailsjs/go/app/App.d.ts
vendored
2
frontend/wailsjs/go/app/App.d.ts
vendored
@@ -61,6 +61,8 @@ export function StartMeeting(arg1:Array<number>,arg2:Record<number, boolean>):Pr
|
||||
|
||||
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>;
|
||||
|
||||
@@ -118,6 +118,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);
|
||||
}
|
||||
|
||||
@@ -179,13 +179,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
|
||||
@@ -247,6 +271,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()
|
||||
|
||||
@@ -106,14 +106,14 @@ func (t *Timer) Start() {
|
||||
t.meetingWarned = false
|
||||
|
||||
if len(t.queue) > 0 {
|
||||
t.startNextSpeaker(now)
|
||||
t.startNextSpeaker(now, 0)
|
||||
}
|
||||
t.mu.Unlock()
|
||||
|
||||
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
|
||||
}
|
||||
@@ -137,8 +137,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
|
||||
@@ -193,7 +193,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
|
||||
@@ -227,11 +227,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,7 +251,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:]...)
|
||||
@@ -256,11 +268,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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user