ENH - clock app

This commit is contained in:
Eichenholz
2026-08-04 15:20:42 +02:00
parent 723eec5ef9
commit c6bac5b2c6
13 changed files with 944 additions and 54 deletions
+48 -3
View File
@@ -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<CSSProperties>(() => ({
@@ -52,6 +56,8 @@ const phoneFrameImage = computed(
() => PHONE_FRAME_IMAGES[phone.preferences.settings.frame],
)
let clockTicker: ReturnType<typeof setInterval> | 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,
}"
>
<PhoneStatusBar />
<PhoneStatusBar v-if="!isLocked" />
<SpringboardView />
<RouterView v-slot="{ Component }">
<Transition name="app-window">
<component :is="Component" v-if="isAppRoute" />
</Transition>
</RouterView>
<PhoneHomeIndicator />
<PhoneHomeIndicator v-if="!isLocked" />
<Transition name="lock-screen">
<PhoneLockScreen v-if="isLocked" @unlock="unlockPhone" />
</Transition>
<PhoneNotifications />
</k-app>
</div>
+202 -3
View File
@@ -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;
}
+2 -2
View File
@@ -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)
+155
View File
@@ -0,0 +1,155 @@
<script setup lang="ts">
import {
BatteryMedium,
Camera,
Flashlight,
LockKeyhole,
Signal,
Wifi,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { usePhoneStore } from '@/stores/phone'
const emit = defineEmits<{
unlock: [destination?: 'camera']
}>()
const phone = usePhoneStore()
const now = ref(new Date())
const dragOffset = ref(0)
const dragging = ref(false)
const flashlightActive = ref(false)
let pointerStart = 0
let pointerStartedAt = 0
let clockTicker: number | undefined
const date = computed(() =>
new Intl.DateTimeFormat(phone.lang, {
day: 'numeric',
month: 'long',
weekday: 'long',
}).format(now.value),
)
const time = computed(() =>
new Intl.DateTimeFormat(phone.lang, {
hour: 'numeric',
minute: '2-digit',
})
.formatToParts(now.value)
.filter((part) => part.type !== 'dayPeriod')
.map((part) => part.value)
.join('')
.trim(),
)
const dragStyle = computed(() => ({
'--lock-drag': `${dragOffset.value}px`,
}))
function onPointerDown(event: PointerEvent): void {
if ((event.target as HTMLElement).closest('button')) return
pointerStart = event.clientY
pointerStartedAt = Date.now()
dragging.value = true
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
function onPointerMove(event: PointerEvent): void {
if (!dragging.value) return
dragOffset.value = Math.min(0, event.clientY - pointerStart)
}
function finishPointer(event: PointerEvent): void {
if (!dragging.value) return
const distance = event.clientY - pointerStart
const elapsed = Math.max(1, Date.now() - pointerStartedAt)
const velocity = Math.abs(distance) / elapsed
dragging.value = false
if (distance < -74 || (distance < -22 && velocity > 0.55)) {
emit('unlock')
return
}
dragOffset.value = 0
}
function unlockWithKeyboard(event: KeyboardEvent): void {
if (event.key === 'Enter' || event.key === ' ') emit('unlock')
}
onMounted(() => {
clockTicker = window.setInterval(() => {
now.value = new Date()
}, 15_000)
})
onBeforeUnmount(() => {
if (clockTicker !== undefined) window.clearInterval(clockTicker)
})
</script>
<template>
<section
class="lock-screen"
:class="[
`wallpaper--${phone.preferences.settings.wallpaper}`,
{ 'lock-screen--dragging': dragging },
]"
:style="dragStyle"
:aria-label="phone.t('LockScreen.label')"
tabindex="0"
@keydown="unlockWithKeyboard"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="finishPointer"
@pointercancel="finishPointer"
>
<div class="lock-screen__shade" aria-hidden="true"></div>
<header class="lock-screen__status">
<LockKeyhole :size="13" :stroke-width="2.5" aria-hidden="true" />
<div class="lock-screen__indicators" aria-hidden="true">
<Signal :size="12" :stroke-width="2.5" />
<Wifi :size="13" :stroke-width="2.5" />
<BatteryMedium :size="17" :stroke-width="2.4" />
</div>
</header>
<div class="lock-screen__content">
<time class="lock-screen__date">{{ date }}</time>
<time class="lock-screen__time">{{ time }}</time>
</div>
<div class="lock-screen__footer">
<div class="lock-screen__shortcuts">
<button
class="lock-screen__shortcut"
:class="{ 'lock-screen__shortcut--active': flashlightActive }"
type="button"
:aria-label="phone.t('LockScreen.flashlight')"
@click="flashlightActive = !flashlightActive"
>
<Flashlight :size="22" fill="currentColor" />
</button>
<button
class="lock-screen__shortcut"
type="button"
:aria-label="phone.t('LockScreen.camera')"
@click="emit('unlock', 'camera')"
>
<Camera :size="24" fill="currentColor" />
</button>
</div>
<button
class="lock-screen__swipe"
type="button"
@click="emit('unlock')"
>
<span class="lock-screen__swipe-chevron" aria-hidden="true"></span>
{{ phone.t('LockScreen.swipeUp') }}
</button>
</div>
</section>
</template>
+17 -1
View File
@@ -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',
+12 -3
View File
@@ -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)
)
}
+8
View File
@@ -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)
})
})
+8
View File
@@ -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)
+13
View File
@@ -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)
}
})
})
+434 -27
View File
@@ -3,13 +3,408 @@ import type { NotificationSoundId } from '@/utils/preferences'
export type PhoneToneId = AlarmSoundId | NotificationSoundId
const TONE_PATTERNS: Record<PhoneToneId, number[]> = {
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<PhoneToneId, TonePattern> = {
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<typeof setTimeout> | 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
+36 -12
View File
@@ -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') }}
</h1>
<div v-if="clock.timerStartedAt !== null" class="timer-ring">
<div
v-if="clock.timerStartedAt !== null"
class="timer-ring"
role="progressbar"
aria-valuemin="0"
aria-valuemax="100"
:aria-valuenow="Math.round(timerProgress * 100)"
:aria-valuetext="formatTimer(timerValue)"
>
<svg viewBox="0 0 170 170" aria-hidden="true">
<circle class="timer-ring-track" cx="85" cy="85" r="72" />
<circle
class="timer-ring-progress"
cx="85"
cy="85"
r="72"
pathLength="100"
:style="{ strokeDashoffset: 100 - timerProgress * 100 }"
/>
</svg>
<span>{{ formatTimer(timerValue) }}</span>
</div>
<TimeWheelPicker
@@ -384,7 +404,11 @@ onBeforeUnmount(() => {
:seconds-unit-label="phone.t('Apps.clock.timer.secondsShort')"
/>
<k-list strong inset class="clock-konsta-list clock-timer-settings">
<k-list
strong
inset
class="clock-konsta-list clock-timer-settings clock-timer-sound"
>
<k-list-input
:label="phone.t('Apps.clock.timer.note')"
:placeholder="phone.t('Apps.clock.timer.notePlaceholder')"
+7 -1
View File
@@ -7,6 +7,9 @@ Locales["en"] = {
save = "Save", search = "Search", send = "Send", start = "Start", 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", allApps = "All Apps", apps = "Apps",
dock = "Dock", noApps = "No apps found", page = "Page", pages = "Home screen pages",
@@ -34,7 +37,10 @@ Locales["en"] = {
everyDay = "Every Day", weekdays = "Weekdays", weekends = "Weekends",
days = { sunday = "Sunday", monday = "Monday", tuesday = "Tuesday", wednesday = "Wednesday", thursday = "Thursday", friday = "Friday", saturday = "Saturday" },
daysShort = { sunday = "Sun", monday = "Mon", tuesday = "Tue", wednesday = "Wed", thursday = "Thu", 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", hours = "Hours", hoursShort = "hr", minutes = "Minutes", minutesShort = "min",
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-CHcZ50p1.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-BQVZ7udM.css">
<script type="module" crossorigin src="./assets/sky-index-u01-18EF.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-DLFsufyp.css">
</head>
<body>
<div id="app"></div>