diff --git a/frontend/src/App.vue b/frontend/src/App.vue index aec42e3..ed6c6f0 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -8,9 +8,10 @@ import { type CSSProperties, watch, } from 'vue' -import { useRoute } from 'vue-router' +import { useRoute, useRouter } from 'vue-router' import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue' +import PhoneLockScreen from '@/components/PhoneLockScreen.vue' import PhoneNotifications from '@/components/PhoneNotifications.vue' import PhoneStatusBar from '@/components/PhoneStatusBar.vue' import { PHONE_FRAME_IMAGES } from '@/config/appearance' @@ -38,7 +39,10 @@ const phone = usePhoneStore() const clock = useClockStore() const notifications = useNotificationsStore() const route = useRoute() +const router = useRouter() const isAppRoute = computed(() => route.name === 'app') +const isLocked = ref(false) +const isUnlocking = ref(false) const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)') const viewportScale = ref(getViewportScale()) const phoneResolutionStyle = computed(() => ({ @@ -52,6 +56,8 @@ const phoneFrameImage = computed( () => PHONE_FRAME_IMAGES[phone.preferences.settings.frame], ) let clockTicker: ReturnType | undefined +let unlockTimer: number | undefined +let cameraTimer: number | undefined function getViewportScale(): number { const heightScale = window.innerHeight / REFERENCE_VIEWPORT_HEIGHT @@ -87,6 +93,22 @@ function updateViewportScale(): void { viewportScale.value = getViewportScale() } +function unlockPhone(destination?: 'camera'): void { + if (!isLocked.value) return + isUnlocking.value = true + isLocked.value = false + + if (destination === 'camera') { + cameraTimer = window.setTimeout(() => { + void router.push('/apps/camera') + }, 260) + } + + unlockTimer = window.setTimeout(() => { + isUnlocking.value = false + }, 720) +} + onMounted(() => { window.addEventListener('message', onMessage) window.addEventListener('keydown', onKeydown) @@ -131,8 +153,27 @@ watch( }, ) +watch( + () => phone.isOpen, + (isOpen) => { + if (unlockTimer !== undefined) window.clearTimeout(unlockTimer) + if (cameraTimer !== undefined) window.clearTimeout(cameraTimer) + if (!isOpen) { + isLocked.value = false + isUnlocking.value = false + return + } + isLocked.value = true + isUnlocking.value = false + phone.setLaunchOrigin(null) + void router.replace('/') + }, +) + onBeforeUnmount(() => { if (clockTicker) clearInterval(clockTicker) + if (unlockTimer !== undefined) window.clearTimeout(unlockTimer) + if (cameraTimer !== undefined) window.clearTimeout(cameraTimer) window.removeEventListener('message', onMessage) window.removeEventListener('keydown', onKeydown) window.removeEventListener('resize', updateViewportScale) @@ -165,16 +206,20 @@ onBeforeUnmount(() => { :class="{ dark: phone.isDarkMode, 'phone-app--light': !phone.isDarkMode, + 'phone-app--unlocking': isUnlocking, }" > - + - + + + + diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css index 7ef877a..7876660 100644 --- a/frontend/src/assets/main.css +++ b/frontend/src/assets/main.css @@ -205,6 +205,175 @@ button { filter: blur(5px); pointer-events: none; } +.phone-app--unlocking .springboard { + animation: springboard-unlock 0.72s cubic-bezier(0.16, 1, 0.3, 1) both; +} +.lock-screen { + position: absolute; + z-index: 80; + inset: 0; + overflow: hidden; + color: #fff; + background-position: center; + background-size: cover; + outline: none; + touch-action: none; + transform: translateY(var(--lock-drag)); + transition: transform 0.42s cubic-bezier(0.2, 0.9, 0.24, 1); + will-change: transform, opacity, filter; +} +.lock-screen--dragging { + transition: none; +} +.lock-screen__shade { + position: absolute; + inset: 0; + background: + linear-gradient(180deg, #00000012 0%, transparent 34%, #00000012 70%), + radial-gradient(circle at 50% 34%, transparent 26%, #0000001f 100%); + pointer-events: none; +} +.lock-screen__status { + position: absolute; + z-index: 1; + top: 11px; + right: 26px; + left: 26px; + display: flex; + align-items: center; + justify-content: center; + min-height: 18px; + filter: drop-shadow(0 1px 2px #0009); +} +.lock-screen__indicators { + position: absolute; + right: 0; + display: flex; + align-items: center; + gap: 3px; +} +.lock-screen__content { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + padding-top: 80px; + text-shadow: 0 2px 14px #0005; +} +.lock-screen__date { + font-size: 20px; + font-weight: 500; + letter-spacing: -0.35px; +} +.lock-screen__time { + margin-top: -8px; + font-size: 92px; + font-weight: 250; + line-height: 1.1; + letter-spacing: -6px; + font-variant-numeric: tabular-nums; +} +.lock-screen__footer { + position: absolute; + z-index: 1; + right: 0; + bottom: 25px; + left: 0; +} +.lock-screen__shortcuts { + display: flex; + justify-content: space-between; + padding: 0 43px; +} +.lock-screen__shortcut { + width: 50px; + height: 50px; + border: 0; + border-radius: 50%; + display: grid; + place-items: center; + color: #fff; + background: #2424248f; + backdrop-filter: blur(18px) saturate(135%); + -webkit-backdrop-filter: blur(18px) saturate(135%); + box-shadow: inset 0 0 0 1px #ffffff17; + transition: + transform 0.18s cubic-bezier(0.2, 0.8, 0.2, 1), + color 0.18s, + background 0.18s; +} +.lock-screen__shortcut:active { + transform: scale(0.88); +} +.lock-screen__shortcut--active { + color: #171717; + background: #fff; +} +.lock-screen__swipe { + width: 100%; + margin-top: 18px; + border: 0; + padding: 0; + color: #fff; + background: transparent; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.15px; + text-shadow: 0 1px 4px #0009; +} +.lock-screen__swipe-chevron { + display: block; + width: 8px; + height: 8px; + margin: 0 auto 2px; + border-top: 2px solid currentColor; + border-left: 2px solid currentColor; + transform: rotate(45deg); + animation: lock-chevron 1.8s ease-in-out infinite; +} +.lock-screen-leave-active { + transition: + transform 0.64s cubic-bezier(0.32, 0.72, 0, 1), + opacity 0.42s ease-out, + filter 0.64s ease-out; +} +.lock-screen-leave-to { + transform: translateY(-104%) scale(1.035); + opacity: 0; + filter: blur(8px); +} +@keyframes springboard-unlock { + from { + transform: scale(0.92); + filter: blur(8px) brightness(0.72); + } + to { + transform: none; + filter: none; + } +} +@keyframes lock-chevron { + 0%, + 55%, + 100% { + opacity: 0.55; + transform: translateY(2px) rotate(45deg); + } + 72% { + opacity: 1; + transform: translateY(-2px) rotate(45deg); + } +} +@media (prefers-reduced-motion: reduce) { + .phone-app--unlocking .springboard, + .lock-screen__swipe-chevron { + animation: none; + } + .lock-screen-leave-active { + transition-duration: 0.18s; + } +} .springboard-track { width: 300%; height: 100%; @@ -925,7 +1094,11 @@ button { font-weight: 200; } .clock-sound-menu { + min-height: 0; + flex: 1; margin-top: 8px; + margin-bottom: 0; + overflow-y: auto; } .clock-sound-selection { display: inline-flex; @@ -952,20 +1125,46 @@ button { margin-top: 24px; } .timer-ring { + position: relative; width: 170px; height: 170px; margin: 12px auto 16px; display: grid; place-items: center; - border: 6px solid #ff9f0a33; - border-top-color: #ff9f0a; - border-radius: 50%; font-size: 34px; } +.timer-ring svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + transform: rotate(-90deg); +} +.timer-ring circle { + fill: none; + stroke-width: 7; +} +.timer-ring-track { + stroke: #ff9f0a33; +} +.timer-ring-progress { + stroke: #ff9f0a; + stroke-dasharray: 100; + stroke-linecap: round; + transition: stroke-dashoffset 250ms linear; +} +.timer-ring span { + position: relative; + z-index: 1; + font-variant-numeric: tabular-nums; +} .clock-timer-settings { margin-right: 0; margin-left: 0; } +.clock-timer-sound { + margin-top: 16px; +} .clock-timer { padding-bottom: 28px; } diff --git a/frontend/src/components/AlarmSoundMenu.vue b/frontend/src/components/AlarmSoundMenu.vue index 36143fe..33e22b8 100644 --- a/frontend/src/components/AlarmSoundMenu.vue +++ b/frontend/src/components/AlarmSoundMenu.vue @@ -5,7 +5,7 @@ import { onBeforeUnmount } from 'vue' import { usePhoneStore } from '@/stores/phone' import { ALARM_SOUND_IDS, type AlarmSoundId } from '@/utils/alarms' -import { playPhoneTone } from '@/utils/tones' +import { phoneToneDuration, playPhoneTone } from '@/utils/tones' defineProps<{ backLabel: string; selectedSound: AlarmSoundId }>() const emit = defineEmits<{ @@ -31,7 +31,7 @@ function selectSound(sound: AlarmSoundId): void { phone.preferences.settings.ringtoneVolume, false, ) - previewTimer = setTimeout(stopSoundPreview, 1800) + previewTimer = setTimeout(stopSoundPreview, phoneToneDuration(sound) + 100) } onBeforeUnmount(stopSoundPreview) diff --git a/frontend/src/components/PhoneLockScreen.vue b/frontend/src/components/PhoneLockScreen.vue new file mode 100644 index 0000000..ccc62d0 --- /dev/null +++ b/frontend/src/components/PhoneLockScreen.vue @@ -0,0 +1,155 @@ + + + diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 64104f2..83ad82c 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -120,7 +120,17 @@ const defaultLocales: LocaleTree = { friday: 'Fri', saturday: 'Sat', }, - sounds: { radar: 'Radar', beacon: 'Beacon', chimes: 'Chimes' }, + sounds: { + radar: 'Radar', + beacon: 'Beacon', + chimes: 'Chimes', + apex: 'Apex', + aurora: 'Aurora', + circuit: 'Circuit', + constellation: 'Constellation', + daybreak: 'Daybreak', + uplift: 'Uplift', + }, }, timer: { time: 'Timer duration', @@ -353,6 +363,12 @@ const defaultLocales: LocaleTree = { stop: 'Stop', }, Notifications: { now: 'now' }, + LockScreen: { + label: 'Lock Screen', + flashlight: 'Flashlight', + camera: 'Camera', + swipeUp: 'Swipe up to open', + }, Home: { appLibrary: 'App Library', appLibrarySearch: 'Search apps', diff --git a/frontend/src/utils/alarms.ts b/frontend/src/utils/alarms.ts index 3e30955..dd7b4d4 100644 --- a/frontend/src/utils/alarms.ts +++ b/frontend/src/utils/alarms.ts @@ -1,6 +1,16 @@ export const ALARMS_STORAGE_KEY = 'sky_phone.clock.alarms.v1' -export const ALARM_SOUND_IDS = ['radar', 'beacon', 'chimes'] as const +export const ALARM_SOUND_IDS = [ + 'radar', + 'beacon', + 'chimes', + 'apex', + 'aurora', + 'circuit', + 'constellation', + 'daybreak', + 'uplift', +] as const export const WEEKDAY_IDS = [1, 2, 3, 4, 5, 6, 0] as const export type AlarmSoundId = (typeof ALARM_SOUND_IDS)[number] @@ -40,8 +50,7 @@ const DEFAULT_ALARMS: Alarm[] = [ function isAlarmSound(value: unknown): value is AlarmSoundId { return ( - typeof value === 'string' && - ALARM_SOUND_IDS.includes(value as AlarmSoundId) + typeof value === 'string' && ALARM_SOUND_IDS.includes(value as AlarmSoundId) ) } diff --git a/frontend/src/utils/clock.test.ts b/frontend/src/utils/clock.test.ts index 7439f32..75cbecf 100644 --- a/frontend/src/utils/clock.test.ts +++ b/frontend/src/utils/clock.test.ts @@ -4,6 +4,7 @@ import { formatStopwatch, formatTimer, remainingMilliseconds, + timerProgressRatio, timerPickerMilliseconds, timerPickerValue, } from './clock' @@ -23,4 +24,11 @@ describe('timestamp clocks', () => { it('formats stopwatch durations with actual milliseconds', () => { expect(formatStopwatch(61_234)).toBe('01:01.234') }) + + it('calculates bounded timer progress from remaining duration', () => { + expect(timerProgressRatio(7_500, 10_000)).toBe(0.75) + expect(timerProgressRatio(-1, 10_000)).toBe(0) + expect(timerProgressRatio(12_000, 10_000)).toBe(1) + expect(timerProgressRatio(0, 0)).toBe(0) + }) }) diff --git a/frontend/src/utils/clock.ts b/frontend/src/utils/clock.ts index 831316a..b95a7db 100644 --- a/frontend/src/utils/clock.ts +++ b/frontend/src/utils/clock.ts @@ -33,6 +33,14 @@ export function formatTimer(milliseconds: number): string { return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}` } +export function timerProgressRatio( + remainingMilliseconds: number, + durationMilliseconds: number, +): number { + if (durationMilliseconds <= 0) return 0 + return Math.max(0, Math.min(1, remainingMilliseconds / durationMilliseconds)) +} + export function timerPickerValue(milliseconds: number): string { const totalSeconds = Math.floor(milliseconds / 1000) const hours = Math.floor(totalSeconds / 3600) diff --git a/frontend/src/utils/tones.test.ts b/frontend/src/utils/tones.test.ts new file mode 100644 index 0000000..d9e7d50 --- /dev/null +++ b/frontend/src/utils/tones.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' + +import { ALARM_SOUND_IDS } from './alarms' +import { phoneToneDuration } from './tones' + +describe('phone tones', () => { + it('defines a playable duration for every alarm sound', () => { + for (const sound of ALARM_SOUND_IDS) { + expect(phoneToneDuration(sound)).toBeGreaterThan(0) + expect(phoneToneDuration(sound)).toBeLessThanOrEqual(1500) + } + }) +}) diff --git a/frontend/src/utils/tones.ts b/frontend/src/utils/tones.ts index 1e37465..3eb8d5d 100644 --- a/frontend/src/utils/tones.ts +++ b/frontend/src/utils/tones.ts @@ -3,13 +3,408 @@ import type { NotificationSoundId } from '@/utils/preferences' export type PhoneToneId = AlarmSoundId | NotificationSoundId -const TONE_PATTERNS: Record = { - beacon: [660, 880, 660, 880], - chime: [784, 1047], - chimes: [523, 659, 784, 1047], - radar: [880, 0, 880, 0, 1175], - signal: [740, 988, 740], - soft: [523, 659], +type ToneVoice = { + detune?: number + frequency: number + gain?: number + type?: OscillatorType +} + +type ToneStep = { + attackMs?: number + durationMs: number + offsetMs: number + releaseMs?: number + voices: ToneVoice[] +} + +type TonePattern = { + loopPauseMs: number + steps: ToneStep[] +} + +const TONE_PATTERNS: Record = { + apex: { + loopPauseMs: 420, + steps: [ + { + offsetMs: 0, + durationMs: 260, + voices: [{ frequency: 523 }, { frequency: 1047, gain: 0.16 }], + }, + { + offsetMs: 190, + durationMs: 280, + voices: [{ frequency: 659 }, { frequency: 1319, gain: 0.14 }], + }, + { + offsetMs: 390, + durationMs: 320, + voices: [{ frequency: 784 }, { frequency: 1568, gain: 0.12 }], + }, + { + offsetMs: 620, + durationMs: 520, + releaseMs: 260, + voices: [{ frequency: 1047 }, { frequency: 1568, gain: 0.18 }], + }, + ], + }, + aurora: { + loopPauseMs: 500, + steps: [ + { + offsetMs: 0, + durationMs: 560, + attackMs: 80, + releaseMs: 300, + voices: [ + { frequency: 440, type: 'triangle' }, + { frequency: 659, gain: 0.32, type: 'sine' }, + ], + }, + { + offsetMs: 310, + durationMs: 620, + attackMs: 90, + releaseMs: 340, + voices: [ + { frequency: 554, type: 'triangle' }, + { frequency: 831, gain: 0.28 }, + ], + }, + { + offsetMs: 690, + durationMs: 700, + attackMs: 100, + releaseMs: 400, + voices: [ + { frequency: 659, type: 'triangle' }, + { frequency: 988, gain: 0.24 }, + ], + }, + ], + }, + beacon: { + loopPauseMs: 460, + steps: [ + { + offsetMs: 0, + durationMs: 240, + voices: [ + { frequency: 660, type: 'triangle' }, + { frequency: 1320, gain: 0.12 }, + ], + }, + { + offsetMs: 250, + durationMs: 260, + voices: [ + { frequency: 880, type: 'triangle' }, + { frequency: 1760, gain: 0.1 }, + ], + }, + { + offsetMs: 540, + durationMs: 240, + voices: [ + { frequency: 660, type: 'triangle' }, + { frequency: 1320, gain: 0.12 }, + ], + }, + { + offsetMs: 790, + durationMs: 340, + releaseMs: 160, + voices: [ + { frequency: 880, type: 'triangle' }, + { frequency: 1320, gain: 0.16 }, + ], + }, + ], + }, + chime: { + loopPauseMs: 360, + steps: [ + { + offsetMs: 0, + durationMs: 360, + releaseMs: 220, + voices: [{ frequency: 784 }, { frequency: 1568, gain: 0.16 }], + }, + { + offsetMs: 250, + durationMs: 500, + releaseMs: 320, + voices: [{ frequency: 1047 }, { frequency: 2093, gain: 0.12 }], + }, + ], + }, + chimes: { + loopPauseMs: 480, + steps: [ + { + offsetMs: 0, + durationMs: 380, + releaseMs: 240, + voices: [{ frequency: 523 }, { frequency: 1047, gain: 0.15 }], + }, + { + offsetMs: 180, + durationMs: 420, + releaseMs: 260, + voices: [{ frequency: 659 }, { frequency: 1319, gain: 0.14 }], + }, + { + offsetMs: 370, + durationMs: 460, + releaseMs: 280, + voices: [{ frequency: 784 }, { frequency: 1568, gain: 0.13 }], + }, + { + offsetMs: 580, + durationMs: 620, + releaseMs: 380, + voices: [{ frequency: 1047 }, { frequency: 1568, gain: 0.18 }], + }, + ], + }, + circuit: { + loopPauseMs: 380, + steps: [ + { + offsetMs: 0, + durationMs: 150, + attackMs: 8, + releaseMs: 70, + voices: [ + { frequency: 740, type: 'square', gain: 0.55 }, + { frequency: 1480, type: 'sine', gain: 0.1 }, + ], + }, + { + offsetMs: 170, + durationMs: 150, + attackMs: 8, + releaseMs: 70, + voices: [{ frequency: 932, type: 'square', gain: 0.5 }], + }, + { + offsetMs: 350, + durationMs: 150, + attackMs: 8, + releaseMs: 70, + voices: [{ frequency: 1109, type: 'square', gain: 0.46 }], + }, + { + offsetMs: 540, + durationMs: 300, + attackMs: 10, + releaseMs: 170, + voices: [ + { frequency: 1480, type: 'triangle', gain: 0.58 }, + { frequency: 740, type: 'sine', gain: 0.2 }, + ], + }, + ], + }, + constellation: { + loopPauseMs: 520, + steps: [ + { + offsetMs: 0, + durationMs: 500, + releaseMs: 340, + voices: [{ frequency: 988 }, { frequency: 1976, detune: 7, gain: 0.1 }], + }, + { + offsetMs: 260, + durationMs: 540, + releaseMs: 360, + voices: [ + { frequency: 784 }, + { frequency: 1568, detune: -6, gain: 0.13 }, + ], + }, + { + offsetMs: 540, + durationMs: 580, + releaseMs: 380, + voices: [{ frequency: 1175 }, { frequency: 1760, gain: 0.14 }], + }, + { + offsetMs: 820, + durationMs: 680, + releaseMs: 440, + voices: [{ frequency: 1047 }, { frequency: 1568, gain: 0.17 }], + }, + ], + }, + daybreak: { + loopPauseMs: 500, + steps: [ + { + offsetMs: 0, + durationMs: 360, + voices: [ + { frequency: 392, type: 'triangle' }, + { frequency: 784, gain: 0.14 }, + ], + }, + { + offsetMs: 230, + durationMs: 400, + voices: [ + { frequency: 494, type: 'triangle' }, + { frequency: 988, gain: 0.13 }, + ], + }, + { + offsetMs: 480, + durationMs: 440, + voices: [ + { frequency: 587, type: 'triangle' }, + { frequency: 1175, gain: 0.12 }, + ], + }, + { + offsetMs: 750, + durationMs: 600, + releaseMs: 340, + voices: [ + { frequency: 784, type: 'triangle' }, + { frequency: 1175, gain: 0.2 }, + ], + }, + ], + }, + radar: { + loopPauseMs: 440, + steps: [ + { + offsetMs: 0, + durationMs: 190, + attackMs: 12, + releaseMs: 100, + voices: [ + { frequency: 880, type: 'triangle' }, + { frequency: 1760, gain: 0.12 }, + ], + }, + { + offsetMs: 360, + durationMs: 190, + attackMs: 12, + releaseMs: 100, + voices: [ + { frequency: 880, type: 'triangle' }, + { frequency: 1760, gain: 0.12 }, + ], + }, + { + offsetMs: 720, + durationMs: 390, + attackMs: 18, + releaseMs: 220, + voices: [ + { frequency: 1175, type: 'triangle' }, + { frequency: 1760, gain: 0.15 }, + ], + }, + ], + }, + signal: { + loopPauseMs: 360, + steps: [ + { + offsetMs: 0, + durationMs: 180, + voices: [{ frequency: 740, type: 'triangle' }], + }, + { + offsetMs: 210, + durationMs: 220, + voices: [{ frequency: 988, type: 'triangle' }], + }, + { + offsetMs: 470, + durationMs: 300, + voices: [ + { frequency: 740, type: 'triangle' }, + { frequency: 1110, gain: 0.12 }, + ], + }, + ], + }, + soft: { + loopPauseMs: 420, + steps: [ + { + offsetMs: 0, + durationMs: 420, + attackMs: 55, + releaseMs: 260, + voices: [ + { frequency: 523, gain: 0.72 }, + { frequency: 784, gain: 0.16 }, + ], + }, + { + offsetMs: 300, + durationMs: 520, + attackMs: 65, + releaseMs: 320, + voices: [ + { frequency: 659, gain: 0.7 }, + { frequency: 988, gain: 0.14 }, + ], + }, + ], + }, + uplift: { + loopPauseMs: 440, + steps: [ + { + offsetMs: 0, + durationMs: 260, + voices: [ + { frequency: 440, type: 'triangle' }, + { frequency: 660, gain: 0.18 }, + ], + }, + { + offsetMs: 170, + durationMs: 280, + voices: [ + { frequency: 554, type: 'triangle' }, + { frequency: 831, gain: 0.17 }, + ], + }, + { + offsetMs: 350, + durationMs: 300, + voices: [ + { frequency: 659, type: 'triangle' }, + { frequency: 988, gain: 0.16 }, + ], + }, + { + offsetMs: 550, + durationMs: 520, + releaseMs: 300, + voices: [ + { frequency: 880, type: 'triangle' }, + { frequency: 1320, gain: 0.2 }, + ], + }, + ], + }, +} + +export function phoneToneDuration(tone: PhoneToneId): number { + return Math.max( + ...TONE_PATTERNS[tone].steps.map((step) => step.offsetMs + step.durationMs), + ) } export function playPhoneTone( @@ -27,7 +422,8 @@ export function playPhoneTone( } const context = new AudioContextConstructor() - const frequencies = TONE_PATTERNS[tone] + const pattern = TONE_PATTERNS[tone] + const patternDuration = phoneToneDuration(tone) const volume = Math.max(0, Math.min(1, volumePercent / 100)) * 0.16 let stopped = false let nextPatternTimer: ReturnType | undefined @@ -36,33 +432,44 @@ export function playPhoneTone( const schedulePattern = (): void => { if (stopped) return const startAt = context.currentTime + 0.02 - oscillators = frequencies.flatMap((frequency, index) => { - if (!frequency) return [] - const oscillator = context.createOscillator() - const gain = context.createGain() - const toneStart = startAt + index * 0.24 - oscillator.type = 'sine' - oscillator.frequency.value = frequency - gain.gain.setValueAtTime(0, toneStart) - gain.gain.linearRampToValueAtTime(volume, toneStart + 0.025) - gain.gain.exponentialRampToValueAtTime(0.001, toneStart + 0.19) - oscillator.connect(gain).connect(context.destination) - oscillator.start(toneStart) - oscillator.stop(toneStart + 0.2) - return [oscillator] - }) + oscillators = pattern.steps.flatMap((step) => + step.voices.map((voice) => { + const oscillator = context.createOscillator() + const gain = context.createGain() + const toneStart = startAt + step.offsetMs / 1000 + const toneEnd = toneStart + step.durationMs / 1000 + const attackEnd = toneStart + (step.attackMs ?? 25) / 1000 + const releaseStart = toneEnd - (step.releaseMs ?? 110) / 1000 + const peakVolume = Math.max(0.0001, volume * (voice.gain ?? 1)) + + oscillator.type = voice.type ?? 'sine' + oscillator.frequency.value = voice.frequency + oscillator.detune.value = voice.detune ?? 0 + gain.gain.setValueAtTime(0.0001, toneStart) + gain.gain.linearRampToValueAtTime(peakVolume, attackEnd) + gain.gain.setValueAtTime(peakVolume, releaseStart) + gain.gain.exponentialRampToValueAtTime(0.0001, toneEnd) + oscillator.connect(gain).connect(context.destination) + oscillator.start(toneStart) + oscillator.stop(toneEnd + 0.01) + return oscillator + }), + ) if (loop) { nextPatternTimer = setTimeout( schedulePattern, - frequencies.length * 240 + 520, + patternDuration + pattern.loopPauseMs, ) } } - void context.resume().then(schedulePattern).catch((error: unknown) => { - console.error('[Phone audio] Failed to start tone', error) - }) + void context + .resume() + .then(schedulePattern) + .catch((error: unknown) => { + if (!stopped) console.error('[Phone audio] Failed to start tone', error) + }) return () => { stopped = true diff --git a/frontend/src/views/apps/ClockApp.vue b/frontend/src/views/apps/ClockApp.vue index 2af3817..132d67a 100644 --- a/frontend/src/views/apps/ClockApp.vue +++ b/frontend/src/views/apps/ClockApp.vue @@ -36,6 +36,7 @@ import { remainingMilliseconds, timerPickerMilliseconds, timerPickerValue, + timerProgressRatio, } from '@/utils/clock' const phone = usePhoneStore() @@ -65,6 +66,9 @@ const timerValue = computed(() => now.value, ), ) +const timerProgress = computed(() => + timerProgressRatio(timerValue.value, clock.timerDuration), +) const timerPicker = computed({ get: () => timerPickerValue(clock.timerRemainingAtStart), set: (value: string) => @@ -188,16 +192,13 @@ function updateStopwatchFrame(): void { stopwatchFrame = requestAnimationFrame(updateStopwatchFrame) } -watch( - [() => clock.stopwatchStartedAt, tab], - ([startedAt, activeTab]) => { - if (stopwatchFrame !== undefined) cancelAnimationFrame(stopwatchFrame) - stopwatchFrame = - startedAt === null || activeTab !== 'stopwatch' - ? undefined - : requestAnimationFrame(updateStopwatchFrame) - }, -) +watch([() => clock.stopwatchStartedAt, tab], ([startedAt, activeTab]) => { + if (stopwatchFrame !== undefined) cancelAnimationFrame(stopwatchFrame) + stopwatchFrame = + startedAt === null || activeTab !== 'stopwatch' + ? undefined + : requestAnimationFrame(updateStopwatchFrame) +}) onMounted(() => { ticker = setInterval(() => { @@ -368,7 +369,26 @@ onBeforeUnmount(() => { {{ phone.t('Apps.clock.tabs.timer') }} -
+
+ {{ formatTimer(timerValue) }}
{ :seconds-unit-label="phone.t('Apps.clock.timer.secondsShort')" /> - + Sky Phone - - + +