mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 00:01:29 +00:00
ENH - clock app
This commit is contained in:
+48
-3
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,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,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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user