mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-01 10:48:55 +00:00
1193 lines
31 KiB
Vue
1193 lines
31 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
Gauge,
|
|
Maximize2,
|
|
Mic,
|
|
Pause,
|
|
Phone,
|
|
PhoneCall,
|
|
PhoneOff,
|
|
Play,
|
|
RotateCcw,
|
|
SkipBack,
|
|
SkipForward,
|
|
Square,
|
|
Timer,
|
|
} from 'lucide-vue-next'
|
|
import {
|
|
computed,
|
|
onBeforeUnmount,
|
|
onMounted,
|
|
ref,
|
|
watch,
|
|
type CSSProperties,
|
|
} from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
|
|
import { useCallsStore } from '@/stores/calls'
|
|
import { useClockStore } from '@/stores/clock'
|
|
import { useMusicStore } from '@/stores/music'
|
|
import { usePhoneStore } from '@/stores/phone'
|
|
import type { MemoRecorderState } from '@/types/memos'
|
|
import {
|
|
elapsedMilliseconds,
|
|
formatStopwatch,
|
|
formatTimer,
|
|
remainingMilliseconds,
|
|
timerProgressRatio,
|
|
} from '@/utils/clock'
|
|
import { formatPhoneNumber } from '@/utils/phone'
|
|
import { isTrustedRootMessageSource } from '@/utils/windowMessages'
|
|
|
|
type IslandActivity =
|
|
| 'call'
|
|
| 'incoming-call'
|
|
| 'music'
|
|
| 'recording'
|
|
| 'stopwatch'
|
|
| 'timer'
|
|
|
|
const router = useRouter()
|
|
const calls = useCallsStore()
|
|
const clock = useClockStore()
|
|
const music = useMusicStore()
|
|
const phone = usePhoneStore()
|
|
const expanded = ref(false)
|
|
const now = ref(Date.now())
|
|
const recorderState = ref<MemoRecorderState>({
|
|
elapsedMs: 0,
|
|
levels: Array(32).fill(0.08),
|
|
state: 'idle',
|
|
})
|
|
let collapseTimer: number | undefined
|
|
let ticker: number | undefined
|
|
|
|
const recordingActive = computed(() =>
|
|
['paused', 'recording', 'starting', 'stopping', 'uploading'].includes(
|
|
recorderState.value.state,
|
|
),
|
|
)
|
|
const pausedTimerActive = computed(
|
|
() =>
|
|
clock.timerDuration > 0 &&
|
|
clock.timerRemainingAtStart > 0 &&
|
|
clock.timerRemainingAtStart < clock.timerDuration,
|
|
)
|
|
const timerActive = computed(
|
|
() => clock.timerStartedAt !== null || pausedTimerActive.value,
|
|
)
|
|
const stopwatchActive = computed(
|
|
() => clock.stopwatchStartedAt !== null || clock.stopwatchAccumulated > 0,
|
|
)
|
|
const activity = computed<IslandActivity | null>(() => {
|
|
const call = calls.activeCall
|
|
if (call?.direction === 'incoming' && call.state === 'ringing') {
|
|
return 'incoming-call'
|
|
}
|
|
if (call) return 'call'
|
|
if (recordingActive.value) return 'recording'
|
|
if (timerActive.value) return 'timer'
|
|
if (stopwatchActive.value) return 'stopwatch'
|
|
if (music.currentTrack) return 'music'
|
|
return null
|
|
})
|
|
const activityKey = computed(() => {
|
|
if (activity.value === 'incoming-call' || activity.value === 'call') {
|
|
return `${activity.value}:${calls.activeCall?.id ?? ''}`
|
|
}
|
|
if (activity.value === 'music') {
|
|
return `music:${music.currentTrack?.source ?? ''}:${music.currentTrack?.id ?? ''}`
|
|
}
|
|
return activity.value
|
|
})
|
|
const isExpanded = computed(
|
|
() => activity.value === 'incoming-call' || expanded.value,
|
|
)
|
|
const callContact = computed(() => {
|
|
const number = calls.activeCall?.otherNumber
|
|
return number
|
|
? (calls.contacts.find((contact) => contact.phone_number === number) ??
|
|
null)
|
|
: null
|
|
})
|
|
const callName = computed(() => {
|
|
const number = calls.activeCall?.otherNumber ?? ''
|
|
return callContact.value?.name ?? formatPhoneNumber(number)
|
|
})
|
|
const callInitials = computed(() =>
|
|
(callContact.value?.name ?? calls.activeCall?.otherNumber ?? '')
|
|
.split(/\s+/)
|
|
.map((part) => part.charAt(0).toUpperCase())
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.join(''),
|
|
)
|
|
const callDuration = computed(() => {
|
|
const call = calls.activeCall
|
|
if (!call || call.state !== 'connected') return ''
|
|
return formatDuration(now.value - (call.answeredAt ?? call.startedAt))
|
|
})
|
|
const callStatus = computed(() => {
|
|
const call = calls.activeCall
|
|
if (!call) return ''
|
|
if (call.state === 'connected') return callDuration.value
|
|
if (call.state === 'ringing') {
|
|
return phone.t(
|
|
call.direction === 'incoming'
|
|
? 'Apps.phone.incoming'
|
|
: 'Apps.phone.calling',
|
|
)
|
|
}
|
|
const state = call.state === 'no_answer' ? 'noAnswer' : call.state
|
|
return phone.t(`Apps.phone.${state}`)
|
|
})
|
|
const timerValue = computed(() =>
|
|
remainingMilliseconds(
|
|
clock.timerRemainingAtStart,
|
|
clock.timerStartedAt,
|
|
now.value,
|
|
),
|
|
)
|
|
const timerProgress = computed(() =>
|
|
timerProgressRatio(timerValue.value, clock.timerDuration),
|
|
)
|
|
const stopwatchValue = computed(() =>
|
|
elapsedMilliseconds(
|
|
clock.stopwatchAccumulated,
|
|
clock.stopwatchStartedAt,
|
|
now.value,
|
|
),
|
|
)
|
|
const musicProgress = computed(() =>
|
|
music.duration > 0
|
|
? Math.max(0, Math.min(1, music.currentTime / music.duration))
|
|
: 0,
|
|
)
|
|
const progressStyle = computed<CSSProperties>(() => ({
|
|
'--dynamic-island-progress': `${musicProgress.value * 100}%`,
|
|
}))
|
|
const activityTitle = computed(() => {
|
|
switch (activity.value) {
|
|
case 'incoming-call':
|
|
return phone.t('Apps.phone.incoming')
|
|
case 'call':
|
|
return callName.value
|
|
case 'recording':
|
|
return recordingStatus.value
|
|
case 'timer':
|
|
return phone.t('Apps.clock.tabs.timer')
|
|
case 'stopwatch':
|
|
return phone.t('Apps.clock.tabs.stopwatch')
|
|
case 'music':
|
|
return music.currentTrack?.title ?? phone.t('Apps.music.name')
|
|
default:
|
|
return ''
|
|
}
|
|
})
|
|
const recordingStatus = computed(() => {
|
|
switch (recorderState.value.state) {
|
|
case 'paused':
|
|
return phone.t('Apps.memos.paused')
|
|
case 'starting':
|
|
return phone.t('Apps.memos.preparing')
|
|
case 'stopping':
|
|
case 'uploading':
|
|
return phone.t('Apps.memos.saving')
|
|
default:
|
|
return phone.t('Apps.memos.recording')
|
|
}
|
|
})
|
|
const compactValue = computed(() => {
|
|
switch (activity.value) {
|
|
case 'call':
|
|
return callDuration.value || callStatus.value
|
|
case 'recording':
|
|
return formatDuration(recorderState.value.elapsedMs)
|
|
case 'timer':
|
|
return formatTimer(timerValue.value)
|
|
case 'stopwatch':
|
|
return formatStopwatch(stopwatchValue.value)
|
|
case 'music':
|
|
return music.currentTrack?.title ?? ''
|
|
default:
|
|
return ''
|
|
}
|
|
})
|
|
const visibleLevels = computed(() =>
|
|
recorderState.value.levels
|
|
.slice(-12)
|
|
.map((level) => Math.max(0.12, Math.min(1, Number(level) || 0))),
|
|
)
|
|
|
|
watch(activityKey, (nextActivity) => {
|
|
window.clearTimeout(collapseTimer)
|
|
if (!nextActivity) {
|
|
expanded.value = false
|
|
return
|
|
}
|
|
expanded.value = true
|
|
if (activity.value !== 'incoming-call') {
|
|
collapseTimer = window.setTimeout(() => {
|
|
expanded.value = false
|
|
}, 3200)
|
|
}
|
|
})
|
|
|
|
function formatDuration(milliseconds: number): string {
|
|
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000))
|
|
const hours = Math.floor(totalSeconds / 3600)
|
|
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
|
const seconds = String(totalSeconds % 60).padStart(2, '0')
|
|
return hours > 0
|
|
? `${hours}:${String(minutes).padStart(2, '0')}:${seconds}`
|
|
: `${String(minutes).padStart(2, '0')}:${seconds}`
|
|
}
|
|
|
|
function toggleExpanded(): void {
|
|
if (activity.value === 'incoming-call') return
|
|
window.clearTimeout(collapseTimer)
|
|
expanded.value = !expanded.value
|
|
}
|
|
|
|
function openActivity(): void {
|
|
const currentActivity = activity.value
|
|
if (!currentActivity) return
|
|
const target = {
|
|
call: '/apps/phone',
|
|
'incoming-call': '/apps/phone',
|
|
music: '/apps/music',
|
|
recording: '/apps/memos',
|
|
stopwatch: '/apps/clock?section=stopwatch',
|
|
timer: '/apps/clock?section=timer',
|
|
}[currentActivity]
|
|
if (target) void router.push(target)
|
|
}
|
|
|
|
function endCall(): void {
|
|
const call = calls.activeCall
|
|
if (!call) return
|
|
if (call.direction === 'incoming' && call.state === 'ringing') {
|
|
void calls.decline()
|
|
return
|
|
}
|
|
void calls.hangup()
|
|
}
|
|
|
|
function postRecorderCommand(type: string): void {
|
|
window.postMessage({ data: {}, type }, '*')
|
|
}
|
|
|
|
function pauseOrResumeRecording(): void {
|
|
postRecorderCommand(
|
|
recorderState.value.state === 'paused'
|
|
? 'memo:recordResume'
|
|
: 'memo:recordPause',
|
|
)
|
|
}
|
|
|
|
function toggleTimer(): void {
|
|
if (clock.timerStartedAt === null) clock.startTimer(Date.now())
|
|
else clock.pauseTimer(Date.now())
|
|
}
|
|
|
|
function toggleStopwatch(): void {
|
|
if (clock.stopwatchStartedAt === null) clock.startStopwatch(Date.now())
|
|
else clock.pauseStopwatch(Date.now())
|
|
}
|
|
|
|
function onMessage(event: MessageEvent): void {
|
|
if (!isTrustedRootMessageSource(event.source, window)) return
|
|
const message = event.data as { data?: unknown; type?: string }
|
|
if (message.type !== 'memo:recordState') return
|
|
const state = message.data as MemoRecorderState
|
|
if (
|
|
!state ||
|
|
typeof state.elapsedMs !== 'number' ||
|
|
!Array.isArray(state.levels) ||
|
|
typeof state.state !== 'string'
|
|
) {
|
|
return
|
|
}
|
|
recorderState.value = state
|
|
}
|
|
|
|
onMounted(() => {
|
|
window.addEventListener('message', onMessage)
|
|
ticker = window.setInterval(() => {
|
|
now.value = Date.now()
|
|
}, 250)
|
|
postRecorderCommand('memo:recordStateRequest')
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener('message', onMessage)
|
|
window.clearInterval(ticker)
|
|
window.clearTimeout(collapseTimer)
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<Transition name="phone-dynamic-island">
|
|
<section
|
|
v-if="activity"
|
|
class="phone-dynamic-island"
|
|
:class="`phone-dynamic-island--${activity}`"
|
|
:data-expanded="isExpanded"
|
|
:aria-label="activityTitle"
|
|
:role="activity === 'incoming-call' ? 'alert' : undefined"
|
|
:aria-atomic="activity === 'incoming-call' ? 'true' : undefined"
|
|
>
|
|
<Transition name="phone-dynamic-island-content" mode="out-in">
|
|
<button
|
|
v-if="!isExpanded"
|
|
:key="`compact-${activity}`"
|
|
type="button"
|
|
class="phone-dynamic-island__compact"
|
|
:aria-label="activityTitle"
|
|
@click.stop="toggleExpanded"
|
|
>
|
|
<PhoneCall
|
|
v-if="activity === 'call'"
|
|
class="phone-dynamic-island__compact-icon phone-dynamic-island__compact-icon--call"
|
|
aria-hidden="true"
|
|
/>
|
|
<Mic
|
|
v-else-if="activity === 'recording'"
|
|
class="phone-dynamic-island__compact-icon phone-dynamic-island__compact-icon--recording"
|
|
aria-hidden="true"
|
|
/>
|
|
<Timer
|
|
v-else-if="activity === 'timer'"
|
|
class="phone-dynamic-island__compact-icon phone-dynamic-island__compact-icon--timer"
|
|
aria-hidden="true"
|
|
/>
|
|
<Gauge
|
|
v-else-if="activity === 'stopwatch'"
|
|
class="phone-dynamic-island__compact-icon phone-dynamic-island__compact-icon--timer"
|
|
aria-hidden="true"
|
|
/>
|
|
<img
|
|
v-else-if="music.currentTrack?.artwork"
|
|
class="phone-dynamic-island__compact-artwork"
|
|
:src="music.currentTrack.artwork"
|
|
alt=""
|
|
/>
|
|
<Play
|
|
v-else
|
|
class="phone-dynamic-island__compact-icon phone-dynamic-island__compact-icon--music"
|
|
aria-hidden="true"
|
|
/>
|
|
<span class="phone-dynamic-island__compact-value">{{
|
|
compactValue
|
|
}}</span>
|
|
<span
|
|
v-if="activity === 'recording'"
|
|
class="phone-dynamic-island__recording-dot"
|
|
aria-hidden="true"
|
|
></span>
|
|
<span
|
|
v-else-if="activity === 'music'"
|
|
class="phone-dynamic-island__music-bars"
|
|
:class="{
|
|
'phone-dynamic-island__music-bars--paused': !music.isPlaying,
|
|
}"
|
|
aria-hidden="true"
|
|
>
|
|
<i></i><i></i><i></i>
|
|
</span>
|
|
</button>
|
|
|
|
<div
|
|
v-else
|
|
:key="`expanded-${activity}`"
|
|
class="phone-dynamic-island__expanded"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="phone-dynamic-island__summary"
|
|
:aria-label="activityTitle"
|
|
@click.stop="openActivity"
|
|
>
|
|
<span class="phone-dynamic-island__leading" aria-hidden="true">
|
|
<img
|
|
v-if="
|
|
(activity === 'call' || activity === 'incoming-call') &&
|
|
callContact?.avatar_url
|
|
"
|
|
:src="callContact.avatar_url"
|
|
alt=""
|
|
/>
|
|
<span
|
|
v-else-if="activity === 'call' || activity === 'incoming-call'"
|
|
class="phone-dynamic-island__avatar"
|
|
>{{ callInitials }}</span
|
|
>
|
|
<img
|
|
v-else-if="activity === 'music' && music.currentTrack?.artwork"
|
|
:src="music.currentTrack.artwork"
|
|
alt=""
|
|
/>
|
|
<span
|
|
v-else-if="activity === 'music'"
|
|
class="phone-dynamic-island__symbol phone-dynamic-island__symbol--music"
|
|
><Play
|
|
/></span>
|
|
<span
|
|
v-else-if="activity === 'recording'"
|
|
class="phone-dynamic-island__symbol phone-dynamic-island__symbol--recording"
|
|
><Mic
|
|
/></span>
|
|
<span
|
|
v-else
|
|
class="phone-dynamic-island__symbol phone-dynamic-island__symbol--timer"
|
|
>
|
|
<Timer v-if="activity === 'timer'" />
|
|
<Gauge v-else />
|
|
</span>
|
|
</span>
|
|
<span class="phone-dynamic-island__copy">
|
|
<span class="phone-dynamic-island__eyebrow">
|
|
{{
|
|
activity === 'incoming-call'
|
|
? phone.t('Apps.phone.incoming')
|
|
: activity === 'recording'
|
|
? recordingStatus
|
|
: activity === 'music'
|
|
? phone.t('Apps.music.nowPlaying')
|
|
: activity === 'timer'
|
|
? phone.t('Apps.clock.tabs.timer')
|
|
: activity === 'stopwatch'
|
|
? phone.t('Apps.clock.tabs.stopwatch')
|
|
: phone.t('Apps.phone.connected')
|
|
}}
|
|
</span>
|
|
<strong class="phone-dynamic-island__title">
|
|
{{
|
|
activity === 'call' || activity === 'incoming-call'
|
|
? callName
|
|
: activity === 'music'
|
|
? music.currentTrack?.title
|
|
: activity === 'recording'
|
|
? formatDuration(recorderState.elapsedMs)
|
|
: activity === 'timer'
|
|
? formatTimer(timerValue)
|
|
: formatStopwatch(stopwatchValue)
|
|
}}
|
|
</strong>
|
|
<span class="phone-dynamic-island__subtitle">
|
|
{{
|
|
activity === 'call' || activity === 'incoming-call'
|
|
? callStatus
|
|
: activity === 'music'
|
|
? music.currentTrack?.artist
|
|
: activity === 'timer'
|
|
? clock.timerNote
|
|
: activity === 'recording'
|
|
? recordingStatus
|
|
: phone.t(
|
|
clock.stopwatchStartedAt === null
|
|
? 'Common.start'
|
|
: 'Common.stop',
|
|
)
|
|
}}
|
|
</span>
|
|
</span>
|
|
<Maximize2
|
|
v-if="activity !== 'incoming-call'"
|
|
class="phone-dynamic-island__open-icon"
|
|
aria-hidden="true"
|
|
/>
|
|
</button>
|
|
|
|
<div
|
|
v-if="activity === 'recording'"
|
|
class="phone-dynamic-island__waveform"
|
|
aria-hidden="true"
|
|
>
|
|
<i
|
|
v-for="(level, index) in visibleLevels"
|
|
:key="index"
|
|
:style="{ height: `${Math.round(level * 22)}px` }"
|
|
></i>
|
|
</div>
|
|
<div
|
|
v-else-if="activity === 'music'"
|
|
class="phone-dynamic-island__progress"
|
|
:style="progressStyle"
|
|
aria-hidden="true"
|
|
></div>
|
|
<div
|
|
v-else-if="activity === 'timer'"
|
|
class="phone-dynamic-island__progress phone-dynamic-island__progress--timer"
|
|
:style="{
|
|
'--dynamic-island-progress': `${timerProgress * 100}%`,
|
|
}"
|
|
aria-hidden="true"
|
|
></div>
|
|
|
|
<div
|
|
v-if="activity === 'incoming-call'"
|
|
class="phone-dynamic-island__actions phone-dynamic-island__actions--incoming"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="phone-dynamic-island__call-action phone-dynamic-island__call-action--decline"
|
|
@click.stop="endCall"
|
|
>
|
|
<PhoneOff aria-hidden="true" />
|
|
<span>{{ phone.t('Apps.phone.decline') }}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="phone-dynamic-island__call-action phone-dynamic-island__call-action--answer"
|
|
@click.stop="calls.answer()"
|
|
>
|
|
<Phone aria-hidden="true" />
|
|
<span>{{ phone.t('Apps.phone.answer') }}</span>
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-else-if="activity === 'call'"
|
|
class="phone-dynamic-island__actions"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="phone-dynamic-island__action"
|
|
:aria-label="phone.t('Apps.phone.returnToCall')"
|
|
@click.stop="openActivity"
|
|
>
|
|
<PhoneCall aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="phone-dynamic-island__action phone-dynamic-island__action--danger"
|
|
:aria-label="phone.t('Apps.phone.hangup')"
|
|
@click.stop="endCall"
|
|
>
|
|
<PhoneOff aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-else-if="activity === 'music'"
|
|
class="phone-dynamic-island__actions phone-dynamic-island__actions--media"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="phone-dynamic-island__action"
|
|
:aria-label="phone.t('Apps.music.previous')"
|
|
@click.stop="music.previous()"
|
|
>
|
|
<SkipBack aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="phone-dynamic-island__action phone-dynamic-island__action--primary"
|
|
:aria-label="
|
|
phone.t(music.isPlaying ? 'Common.pause' : 'ControlCenter.play')
|
|
"
|
|
@click.stop="music.toggle()"
|
|
>
|
|
<Pause v-if="music.isPlaying" aria-hidden="true" />
|
|
<Play v-else aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="phone-dynamic-island__action"
|
|
:aria-label="phone.t('Apps.music.next')"
|
|
@click.stop="music.next()"
|
|
>
|
|
<SkipForward aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-else-if="activity === 'recording'"
|
|
class="phone-dynamic-island__actions"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="phone-dynamic-island__action"
|
|
:disabled="!['paused', 'recording'].includes(recorderState.state)"
|
|
:aria-label="
|
|
phone.t(
|
|
recorderState.state === 'paused'
|
|
? 'Apps.memos.resume'
|
|
: 'Apps.memos.pause',
|
|
)
|
|
"
|
|
@click.stop="pauseOrResumeRecording"
|
|
>
|
|
<Play
|
|
v-if="recorderState.state === 'paused'"
|
|
aria-hidden="true"
|
|
/>
|
|
<Pause v-else aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="phone-dynamic-island__action phone-dynamic-island__action--recording"
|
|
:disabled="!['paused', 'recording'].includes(recorderState.state)"
|
|
:aria-label="phone.t('Apps.memos.stop')"
|
|
@click.stop="postRecorderCommand('memo:recordStop')"
|
|
>
|
|
<Square aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
<div
|
|
v-else-if="activity === 'timer'"
|
|
class="phone-dynamic-island__actions"
|
|
>
|
|
<button
|
|
type="button"
|
|
class="phone-dynamic-island__action"
|
|
:aria-label="phone.t('Common.reset')"
|
|
@click.stop="clock.resetTimer()"
|
|
>
|
|
<RotateCcw aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="phone-dynamic-island__action phone-dynamic-island__action--timer"
|
|
:aria-label="
|
|
phone.t(
|
|
clock.timerStartedAt === null
|
|
? 'Common.start'
|
|
: 'Common.pause',
|
|
)
|
|
"
|
|
@click.stop="toggleTimer"
|
|
>
|
|
<Play v-if="clock.timerStartedAt === null" aria-hidden="true" />
|
|
<Pause v-else aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
<div v-else class="phone-dynamic-island__actions">
|
|
<button
|
|
type="button"
|
|
class="phone-dynamic-island__action"
|
|
:aria-label="
|
|
phone.t(
|
|
clock.stopwatchStartedAt === null
|
|
? 'Common.reset'
|
|
: 'Apps.clock.lap',
|
|
)
|
|
"
|
|
@click.stop="
|
|
clock.stopwatchStartedAt === null
|
|
? clock.resetStopwatch()
|
|
: clock.addLap(Date.now())
|
|
"
|
|
>
|
|
<RotateCcw
|
|
v-if="clock.stopwatchStartedAt === null"
|
|
aria-hidden="true"
|
|
/>
|
|
<span v-else class="phone-dynamic-island__lap">+1</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="phone-dynamic-island__action phone-dynamic-island__action--timer"
|
|
:aria-label="
|
|
phone.t(
|
|
clock.stopwatchStartedAt === null
|
|
? 'Common.start'
|
|
: 'Common.pause',
|
|
)
|
|
"
|
|
@click.stop="toggleStopwatch"
|
|
>
|
|
<Play
|
|
v-if="clock.stopwatchStartedAt === null"
|
|
aria-hidden="true"
|
|
/>
|
|
<Pause v-else aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</section>
|
|
</Transition>
|
|
</template>
|
|
|
|
<style>
|
|
.phone-dynamic-island[data-expanded='true']
|
|
~ .phone-notification-provider
|
|
.phone-notification {
|
|
top: 130px !important;
|
|
}
|
|
</style>
|
|
|
|
<style scoped>
|
|
.phone-dynamic-island {
|
|
position: absolute;
|
|
z-index: 99;
|
|
top: 6px;
|
|
left: 50%;
|
|
width: 132px;
|
|
max-width: calc(100% - 24px);
|
|
height: 44px;
|
|
overflow: hidden;
|
|
border-radius: 24px;
|
|
color: #fff;
|
|
background: #000;
|
|
box-shadow: 0 5px 18px rgb(0 0 0 / 22%);
|
|
transform: translateX(-50%);
|
|
transform-origin: 50% 0;
|
|
transition:
|
|
width 420ms cubic-bezier(0.2, 0.9, 0.2, 1),
|
|
height 420ms cubic-bezier(0.2, 0.9, 0.2, 1),
|
|
border-radius 360ms ease,
|
|
box-shadow 360ms ease;
|
|
}
|
|
|
|
.phone-dynamic-island[data-expanded='true'] {
|
|
width: 356px;
|
|
height: 118px;
|
|
border-radius: 30px;
|
|
box-shadow: 0 12px 32px rgb(0 0 0 / 32%);
|
|
}
|
|
|
|
.phone-dynamic-island--incoming-call[data-expanded='true'] {
|
|
height: 124px;
|
|
}
|
|
|
|
.phone-dynamic-island__compact,
|
|
.phone-dynamic-island__summary,
|
|
.phone-dynamic-island__action,
|
|
.phone-dynamic-island__call-action {
|
|
border: 0;
|
|
color: inherit;
|
|
font: inherit;
|
|
cursor: pointer;
|
|
-webkit-tap-highlight-color: transparent;
|
|
}
|
|
|
|
.phone-dynamic-island__compact {
|
|
display: flex;
|
|
width: 100%;
|
|
height: 44px;
|
|
padding: 0 10px;
|
|
align-items: center;
|
|
gap: 7px;
|
|
background: transparent;
|
|
}
|
|
|
|
.phone-dynamic-island__compact-icon {
|
|
width: 17px;
|
|
height: 17px;
|
|
flex: 0 0 auto;
|
|
stroke-width: 2.4;
|
|
}
|
|
|
|
.phone-dynamic-island__compact-icon--call {
|
|
color: #30d158;
|
|
}
|
|
|
|
.phone-dynamic-island__compact-icon--recording {
|
|
color: #ff453a;
|
|
}
|
|
|
|
.phone-dynamic-island__compact-icon--timer {
|
|
color: #ff9f0a;
|
|
}
|
|
|
|
.phone-dynamic-island__compact-icon--music {
|
|
color: #bf5af2;
|
|
}
|
|
|
|
.phone-dynamic-island__compact-artwork {
|
|
width: 24px;
|
|
height: 24px;
|
|
flex: 0 0 auto;
|
|
border-radius: 7px;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.phone-dynamic-island__compact-value {
|
|
min-width: 0;
|
|
overflow: hidden;
|
|
flex: 1 1 auto;
|
|
font-size: 12px;
|
|
font-variant-numeric: tabular-nums;
|
|
font-weight: 650;
|
|
letter-spacing: -0.15px;
|
|
text-align: left;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.phone-dynamic-island__recording-dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
flex: 0 0 auto;
|
|
border-radius: 50%;
|
|
background: #ff453a;
|
|
animation: dynamic-island-recording-pulse 1.25s ease-in-out infinite;
|
|
}
|
|
|
|
.phone-dynamic-island__music-bars {
|
|
display: flex;
|
|
height: 16px;
|
|
align-items: center;
|
|
gap: 2px;
|
|
}
|
|
|
|
.phone-dynamic-island__music-bars i {
|
|
width: 2px;
|
|
border-radius: 2px;
|
|
background: #bf5af2;
|
|
animation: dynamic-island-music-bar 0.8s ease-in-out infinite alternate;
|
|
}
|
|
|
|
.phone-dynamic-island__music-bars i:nth-child(1) {
|
|
height: 7px;
|
|
}
|
|
|
|
.phone-dynamic-island__music-bars i:nth-child(2) {
|
|
height: 13px;
|
|
animation-delay: -0.35s;
|
|
}
|
|
|
|
.phone-dynamic-island__music-bars i:nth-child(3) {
|
|
height: 9px;
|
|
animation-delay: -0.55s;
|
|
}
|
|
|
|
.phone-dynamic-island__music-bars--paused i {
|
|
animation-play-state: paused;
|
|
}
|
|
|
|
.phone-dynamic-island__expanded {
|
|
position: relative;
|
|
display: grid;
|
|
height: 100%;
|
|
padding: 10px 11px;
|
|
grid-template-columns: minmax(0, 1fr) auto;
|
|
grid-template-rows: minmax(0, 1fr) auto;
|
|
column-gap: 10px;
|
|
}
|
|
|
|
.phone-dynamic-island__summary {
|
|
display: flex;
|
|
min-width: 0;
|
|
padding: 0;
|
|
align-items: center;
|
|
gap: 9px;
|
|
background: transparent;
|
|
text-align: left;
|
|
}
|
|
|
|
.phone-dynamic-island__leading,
|
|
.phone-dynamic-island__leading img,
|
|
.phone-dynamic-island__avatar,
|
|
.phone-dynamic-island__symbol {
|
|
width: 42px;
|
|
height: 42px;
|
|
flex: 0 0 42px;
|
|
border-radius: 14px;
|
|
}
|
|
|
|
.phone-dynamic-island__leading img {
|
|
display: block;
|
|
object-fit: cover;
|
|
}
|
|
|
|
.phone-dynamic-island__avatar,
|
|
.phone-dynamic-island__symbol {
|
|
display: grid;
|
|
place-items: center;
|
|
}
|
|
|
|
.phone-dynamic-island__avatar {
|
|
color: #b8f7ca;
|
|
background: #183d24;
|
|
font-size: 15px;
|
|
font-weight: 750;
|
|
}
|
|
|
|
.phone-dynamic-island__symbol svg {
|
|
width: 21px;
|
|
height: 21px;
|
|
}
|
|
|
|
.phone-dynamic-island__symbol--music {
|
|
color: #e6bbff;
|
|
background: #3d184d;
|
|
}
|
|
|
|
.phone-dynamic-island__symbol--recording {
|
|
color: #ffb4ae;
|
|
background: #4b1717;
|
|
}
|
|
|
|
.phone-dynamic-island__symbol--timer {
|
|
color: #ffd19a;
|
|
background: #4a2d0b;
|
|
}
|
|
|
|
.phone-dynamic-island__copy {
|
|
display: flex;
|
|
min-width: 0;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.phone-dynamic-island__eyebrow {
|
|
overflow: hidden;
|
|
color: #a1a1aa;
|
|
font-size: 10px;
|
|
font-weight: 650;
|
|
letter-spacing: 0.25px;
|
|
line-height: 13px;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.phone-dynamic-island--incoming-call .phone-dynamic-island__eyebrow,
|
|
.phone-dynamic-island--call .phone-dynamic-island__eyebrow {
|
|
color: #58df7c;
|
|
}
|
|
|
|
.phone-dynamic-island--recording .phone-dynamic-island__eyebrow {
|
|
color: #ff6961;
|
|
}
|
|
|
|
.phone-dynamic-island--timer .phone-dynamic-island__eyebrow,
|
|
.phone-dynamic-island--stopwatch .phone-dynamic-island__eyebrow {
|
|
color: #ffad33;
|
|
}
|
|
|
|
.phone-dynamic-island--music .phone-dynamic-island__eyebrow {
|
|
color: #d28cff;
|
|
}
|
|
|
|
.phone-dynamic-island__title,
|
|
.phone-dynamic-island__subtitle {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.phone-dynamic-island__title {
|
|
font-size: 14px;
|
|
font-variant-numeric: tabular-nums;
|
|
font-weight: 720;
|
|
letter-spacing: -0.25px;
|
|
line-height: 17px;
|
|
}
|
|
|
|
.phone-dynamic-island__subtitle {
|
|
color: #a1a1aa;
|
|
font-size: 10px;
|
|
font-variant-numeric: tabular-nums;
|
|
line-height: 13px;
|
|
}
|
|
|
|
.phone-dynamic-island__open-icon {
|
|
width: 13px;
|
|
height: 13px;
|
|
margin-left: auto;
|
|
color: #71717a;
|
|
}
|
|
|
|
.phone-dynamic-island__waveform {
|
|
position: absolute;
|
|
right: 118px;
|
|
bottom: 9px;
|
|
left: 62px;
|
|
display: flex;
|
|
height: 24px;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 2px;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.phone-dynamic-island__waveform i {
|
|
width: 3px;
|
|
min-height: 3px;
|
|
border-radius: 3px;
|
|
background: #ff453a;
|
|
transition: height 120ms ease;
|
|
}
|
|
|
|
.phone-dynamic-island__progress {
|
|
position: absolute;
|
|
right: 12px;
|
|
bottom: 10px;
|
|
left: 12px;
|
|
height: 2px;
|
|
overflow: hidden;
|
|
border-radius: 2px;
|
|
background: #27272a;
|
|
}
|
|
|
|
.phone-dynamic-island__progress::after {
|
|
display: block;
|
|
width: var(--dynamic-island-progress);
|
|
height: 100%;
|
|
border-radius: inherit;
|
|
background: #bf5af2;
|
|
content: '';
|
|
transition: width 250ms linear;
|
|
}
|
|
|
|
.phone-dynamic-island__progress--timer::after {
|
|
background: #ff9f0a;
|
|
}
|
|
|
|
.phone-dynamic-island__actions {
|
|
display: flex;
|
|
min-width: 98px;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
grid-column: 2;
|
|
grid-row: 1 / 3;
|
|
}
|
|
|
|
.phone-dynamic-island__actions--media {
|
|
min-width: 142px;
|
|
gap: 5px;
|
|
}
|
|
|
|
.phone-dynamic-island__actions--incoming {
|
|
min-width: 126px;
|
|
gap: 10px;
|
|
}
|
|
|
|
.phone-dynamic-island__action {
|
|
display: grid;
|
|
width: 44px;
|
|
height: 44px;
|
|
padding: 0;
|
|
place-items: center;
|
|
border-radius: 50%;
|
|
background: #242427;
|
|
transition:
|
|
background 140ms ease,
|
|
transform 140ms ease;
|
|
}
|
|
|
|
.phone-dynamic-island__action svg {
|
|
width: 19px;
|
|
height: 19px;
|
|
}
|
|
|
|
.phone-dynamic-island__action--primary {
|
|
color: #000;
|
|
background: #fff;
|
|
}
|
|
|
|
.phone-dynamic-island__action--danger,
|
|
.phone-dynamic-island__action--recording {
|
|
background: #ff453a;
|
|
}
|
|
|
|
.phone-dynamic-island__action--timer {
|
|
color: #000;
|
|
background: #ff9f0a;
|
|
}
|
|
|
|
.phone-dynamic-island__action:disabled {
|
|
opacity: 0.45;
|
|
cursor: default;
|
|
}
|
|
|
|
.phone-dynamic-island__action:not(:disabled):active,
|
|
.phone-dynamic-island__call-action:active {
|
|
transform: scale(0.91);
|
|
}
|
|
|
|
.phone-dynamic-island__call-action {
|
|
display: flex;
|
|
width: 58px;
|
|
min-height: 64px;
|
|
padding: 4px 0 0;
|
|
align-items: center;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
background: transparent;
|
|
font-size: 9px;
|
|
}
|
|
|
|
.phone-dynamic-island__call-action svg {
|
|
width: 44px;
|
|
height: 44px;
|
|
padding: 12px;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.phone-dynamic-island__call-action--decline svg {
|
|
background: #ff453a;
|
|
}
|
|
|
|
.phone-dynamic-island__call-action--answer svg {
|
|
background: #30d158;
|
|
}
|
|
|
|
.phone-dynamic-island__lap {
|
|
font-size: 13px;
|
|
font-weight: 750;
|
|
}
|
|
|
|
.phone-dynamic-island-enter-active,
|
|
.phone-dynamic-island-leave-active {
|
|
transition:
|
|
opacity 220ms ease,
|
|
transform 360ms cubic-bezier(0.2, 0.9, 0.2, 1);
|
|
}
|
|
|
|
.phone-dynamic-island-enter-from,
|
|
.phone-dynamic-island-leave-to {
|
|
opacity: 0;
|
|
transform: translateX(-50%) scale(0.72, 0.55);
|
|
}
|
|
|
|
.phone-dynamic-island-content-enter-active,
|
|
.phone-dynamic-island-content-leave-active {
|
|
transition:
|
|
opacity 150ms ease,
|
|
transform 220ms ease;
|
|
}
|
|
|
|
.phone-dynamic-island-content-enter-from {
|
|
opacity: 0;
|
|
transform: scale(0.94);
|
|
}
|
|
|
|
.phone-dynamic-island-content-leave-to {
|
|
opacity: 0;
|
|
transform: scale(1.03);
|
|
}
|
|
|
|
@keyframes dynamic-island-recording-pulse {
|
|
50% {
|
|
opacity: 0.35;
|
|
transform: scale(0.76);
|
|
}
|
|
}
|
|
|
|
@keyframes dynamic-island-music-bar {
|
|
to {
|
|
transform: scaleY(0.45);
|
|
}
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.phone-dynamic-island,
|
|
.phone-dynamic-island-enter-active,
|
|
.phone-dynamic-island-leave-active,
|
|
.phone-dynamic-island-content-enter-active,
|
|
.phone-dynamic-island-content-leave-active,
|
|
.phone-dynamic-island__action,
|
|
.phone-dynamic-island__waveform i,
|
|
.phone-dynamic-island__progress::after {
|
|
transition: none;
|
|
}
|
|
|
|
.phone-dynamic-island__recording-dot,
|
|
.phone-dynamic-island__music-bars i {
|
|
animation: none;
|
|
}
|
|
}
|
|
</style>
|