also looking at this
feat: native trail maps and complete native mobile screens
#1017
60 files
+2505
-823
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -0,0 +1,48 @@ | ||
| 1 | import type { NativeStorage } from '@stacksjs/stx/native' | |
| 2 | import type { NativeRecording } from './native-recording' | |
| 3 | ||
| 4 | const KEY = 'wildloop-native-recording-v1' | |
| 5 | const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value)) | |
| 6 | const conflict = () => new Error('The saved recording changed on this device. Pull down to recover it before trying again. Your track has been retained.') | |
| 7 | ||
| 8 | /** Serialize exact native CAS writes; a stale screen never replaces another track. */ | |
| 9 | export function createNativeRecordingCheckpoint(storage: Pick<NativeStorage, 'get' | 'setIfAbsent' | 'compareAndSet'>) { | |
| 10 | let saved: NativeRecording | null = null | |
| 11 | let writes = Promise.resolve() | |
| 12 | function serialize<T>(work: () => Promise<T>): Promise<T> { | |
| 13 | const pending = writes.then(work) | |
| 14 | writes = pending.then(() => {}, () => {}) | |
| 15 | return pending | |
| 16 | } | |
| 17 | function sameRecording(value: NativeRecording): void { | |
| 18 | if (!saved || saved.id !== value.id || saved.userId !== value.userId) throw conflict() | |
| 19 | } | |
| 20 | return { | |
| 21 | read: () => serialize(async () => { | |
| 22 | saved = copy(await storage.get<NativeRecording>(KEY)) | |
| 23 | return copy(saved) | |
| 24 | }), | |
| 25 | register: (value: NativeRecording) => { | |
| 26 | const snapshot = copy(value) | |
| 27 | return serialize(async () => { | |
| 28 | // CAS null updates an existing tombstone; setIfAbsent creates a key. | |
| 29 | // Neither operation may replace an existing account's checkpoint. | |
| 30 | if (!await storage.setIfAbsent(KEY, snapshot) && !await storage.compareAndSet(KEY, null, snapshot)) throw conflict() | |
| 31 | saved = snapshot | |
| 32 | }) | |
| 33 | }, | |
| 34 | save: (value: NativeRecording) => { | |
| 35 | const snapshot = copy(value) | |
| 36 | return serialize(async () => { | |
| 37 | sameRecording(snapshot) | |
| 38 | if (!await storage.compareAndSet(KEY, saved, snapshot)) throw conflict() | |
| 39 | saved = snapshot | |
| 40 | }) | |
| 41 | }, | |
| 42 | clear: (value: NativeRecording) => serialize(async () => { | |
| 43 | sameRecording(value) | |
| 44 | if (!await storage.compareAndSet(KEY, saved, null)) throw conflict() | |
| 45 | saved = null | |
| 46 | }), | |
| 47 | } | |
| 48 | } | |
| @@ -16,6 +16,8 @@ export interface NativeRecording { | ||
| 16 | 16 | targetTerritoryId: number | null |
| 17 | 17 | pauses: Array<{ start: number, end: number | null }> |
| 18 | 18 | samples: RecorderSample[] |
| 19 | phase?: 'starting' | 'recording' | 'stopped' | |
| 20 | endedAt?: number | |
| 19 | 21 | pending?: Record<string, unknown> |
| 20 | 22 | } |
| 21 | 23 | |
| @@ -5,12 +5,14 @@ import { createHealthWorkout } from '../functions/health-workout' | ||
| 5 | 5 | import { useIntervalFn } from '@stacksjs/composables' |
| 6 | 6 | import { readNativeAccount } from '../functions/native-session' |
| 7 | 7 | import { onNativeAccountChange, createNativeApi } from '../functions/native-api' |
| 8 | import { createNativeRecordingCheckpoint } from '../functions/native-recording-checkpoint' | |
| 8 | 9 | import { nativeOutdoorTiles } from '../functions/native-basemaps' |
| 9 | 10 | import { chooseNativeChoice, nativeChoiceLabel } from '../functions/native-choice' |
| 10 | 11 | import { nativeUi as ui } from '../functions/native-ui' |
| 11 | 12 | import { nativeRecordingDuration, movingSeconds, recordingFigures, recordingPayload, recordingSamples, type NativeRecording } from '../functions/native-recording' |
| 12 | 13 | const craft = (globalThis as typeof globalThis & {craft:NativeCraft}).craft |
| 13 | const KEY = 'wildloop-native-recording-v1' | |
| 14 | const checkpoint = createNativeRecordingCheckpoint(craft.storage) | |
| 15 | let api = createNativeApi(craft) | |
| 14 | 16 | const recording = state<NativeRecording|null>(null) |
| 15 | 17 | const running = state(false) |
| 16 | 18 | const paused = state(false) |
| @@ -23,12 +25,13 @@ const visibility = state('followers') | ||
| 23 | 25 | const gameMode = state('capture') |
| 24 | 26 | const now = state(Date.now()) |
| 25 | 27 | const figures = derived(() => recording() ? recordingFigures(recording()!) : {distance:0,elevation:0}) |
| 26 | const movingTime = derived(() => { const value=recording(); return value ? movingSeconds(value,now()) : 0 }) | |
| 28 | const movingTime = derived(() => { const value=recording(); return value ? movingSeconds(value,value.endedAt ?? (value.pending ? Date.parse(String(value.pending.completed_at)) : now())) : 0 }) | |
| 27 | 29 | const coordinates = derived(() => recording()?.samples.flatMap(sample => [sample.lat,sample.lng]) ?? []) |
| 28 | 30 | let alive = true |
| 29 | 31 | let owner = 0 |
| 30 | 32 | let watch: number | null = null |
| 31 | let writes = Promise.resolve() | |
| 33 | let accountGeneration = 0 | |
| 34 | let recoverQueued = false | |
| 32 | 35 | let recovering = false |
| 33 | 36 | const live=createLiveActivity(craft.liveActivity) |
| 34 | 37 | let lastLiveUpdate=0 |
| @@ -40,17 +43,24 @@ function updateLive(force=false) { | ||
| 40 | 43 | const moving=movingSeconds(value,at) |
| 41 | 44 | void live.update({activityId:'wildloop:'+value.id,title:value.type,symbol:'figure.run',deepLink:'wildloop://record',tint:'#24724b',status:paused()?'Paused':'Recording',paused:paused(),distanceMeters:recordingFigures(value).distance*1609.344,durationSeconds:moving,timerStartedAt:paused()?null:at-moving*1000}).catch(()=>{}) |
| 42 | 45 | } |
| 43 | function persist(value: NativeRecording): Promise<void> { | |
| 44 | const snapshot = JSON.parse(JSON.stringify(value)) | |
| 45 | writes = writes.catch(() => {}).then(async () => {await craft.storage.set(KEY,snapshot)}) | |
| 46 | return writes | |
| 46 | function persist(value: NativeRecording): Promise<void> { return checkpoint.save(value) } | |
| 47 | async function ownedSession(value?: NativeRecording) { | |
| 48 | const bound = await api.bindSession() | |
| 49 | const cached = await readNativeAccount(craft) | |
| 50 | const me = cached ? null : await bound.send('/api/me') | |
| 51 | await bound.readToken() | |
| 52 | const userId = cached?.id ?? (Number(me?.body?.user?.id) || 0) | |
| 53 | if (!userId || (value && value.userId !== userId)) throw new Error('Log in as the account that started this recording before changing or uploading it.') | |
| 54 | return { api: bound, userId } | |
| 47 | 55 | } |
| 48 | async function perform(work:()=>Promise<void>) { | |
| 56 | async function perform(work:(current:()=>boolean)=>Promise<void>) { | |
| 49 | 57 | if (busy()) return |
| 58 | const generation = accountGeneration | |
| 59 | const current = () => alive && generation === accountGeneration | |
| 50 | 60 | busy.set(true);error.set('') |
| 51 | try { await work() } | |
| 52 | catch (cause) { error.set(cause instanceof Error ? cause.message : 'The recording could not be updated. Your track remains on this device.') } | |
| 53 | finally { busy.set(false) } | |
| 61 | try { await work(current) } | |
| 62 | catch (cause) { if (current()) error.set(cause instanceof Error ? cause.message : 'The recording could not be updated. Your track remains on this device.') } | |
| 63 | finally { busy.set(false);if(recoverQueued && alive) void recover() } | |
| 54 | 64 | } |
| 55 | 65 | function append(locations:any[]) { |
| 56 | 66 | const value = recording() |
| @@ -61,22 +71,27 @@ function append(locations:any[]) { | ||
| 61 | 71 | } |
| 62 | 72 | function attachWatch() { |
| 63 | 73 | if (watch !== null || !craft.location) return |
| 74 | const generation=accountGeneration | |
| 75 | const id=recording()?.id | |
| 64 | 76 | watch = craft.location.watchPosition(location => { |
| 65 | if (!alive || busy() || paused() || !running()) return | |
| 77 | if (!alive || generation!==accountGeneration || recording()?.id!==id || busy() || paused() || !running()) return | |
| 66 | 78 | append([location]) |
| 67 | if (recording()) void persist(recording()!).catch(() => error.set('Device storage could not save a checkpoint. Keep the app open until your activity is saved.')) | |
| 79 | if (recording()) void persist(recording()!).catch(() => {if(alive && generation===accountGeneration) error.set('Device storage could not save a checkpoint. Keep the app open until your activity is saved.')}) | |
| 68 | 80 | },{enableHighAccuracy:true}) |
| 69 | 81 | } |
| 70 | 82 | async function recover() { |
| 71 | if (recovering || busy()) return | |
| 83 | if (recovering || busy()) {recoverQueued=true;return} | |
| 84 | recoverQueued=false | |
| 85 | const generation=accountGeneration | |
| 86 | const current=()=>alive && generation===accountGeneration | |
| 72 | 87 | recovering=true;loading.set(true) |
| 73 | 88 | try { |
| 74 | const api=createNativeApi(craft) | |
| 75 | const cached=await readNativeAccount(craft) | |
| 76 | const me=cached ? null : await api.send('/api/me') | |
| 77 | if (!alive) return | |
| 78 | owner=cached?.id ?? (Number(me?.body?.user?.id)||0) | |
| 79 | const saved=await craft.storage.get<NativeRecording>(KEY) | |
| 89 | const token=await api.readToken() | |
| 90 | const session=token ? await ownedSession() : null | |
| 91 | if (!current()) return | |
| 92 | owner=session?.userId ?? 0 | |
| 93 | const saved=await checkpoint.read() | |
| 94 | if (!current()) return | |
| 80 | 95 | if (saved && saved.userId !== owner) { |
| 81 | 96 | recording.set(null);running.set(false) |
| 82 | 97 | error.set('Log in as the account that started the saved recording before resuming or uploading it.') |
| @@ -94,8 +109,15 @@ async function recover() { | ||
| 94 | 109 | notice.set('Your finished recording is saved on this device. Retry the upload when connected.') |
| 95 | 110 | return |
| 96 | 111 | } |
| 112 | if (saved?.phase==='starting') { | |
| 113 | recording.set(saved);running.set(false);paused.set(false) | |
| 114 | notice.set('The recording start was interrupted. Retry starting with its saved identity, or discard the intent.') | |
| 115 | return | |
| 116 | } | |
| 117 | if (!owner) {recording.set(null);running.set(false);paused.set(false);return} | |
| 118 | await session!.api.readToken() | |
| 97 | 119 | const host=await craft.location?.getRecordingState(saved?.id ? {expectedId:saved.id} : undefined) |
| 98 | if (!alive) return | |
| 120 | if (!current()) return | |
| 99 | 121 | if (host?.active && (!saved || (saved.id && saved.id !== host.id))) { |
| 100 | 122 | error.set('A device recording is already active. Its account checkpoint could not be verified, so it has been retained without starting another recording.') |
| 101 | 123 | return |
| @@ -103,66 +125,91 @@ async function recover() { | ||
| 103 | 125 | recording.set(saved) |
| 104 | 126 | running.set(Boolean(host?.active)) |
| 105 | 127 | paused.set(Boolean(host?.paused)) |
| 106 | if (saved && host?.active && !saved.pending) { | |
| 107 | append(await craft.location!.readRecording({expectedId:saved.id!})) | |
| 128 | if (saved && host?.id===saved.id && !saved.pending) { | |
| 129 | const fixes=await craft.location!.readRecording({expectedId:saved.id!}) | |
| 130 | if (!current()) return | |
| 131 | append(fixes) | |
| 132 | if (!host.active) recording.set({...recording()!,phase:'stopped',endedAt:saved.endedAt ?? Date.now()}) | |
| 108 | 133 | await persist(recording()!) |
| 109 | attachWatch() | |
| 134 | if (!current()) return | |
| 135 | if(host.active) attachWatch() | |
| 110 | 136 | notice.set('Your recording has been recovered from this device.');updateLive(true) |
| 111 | 137 | } |
| 112 | 138 | if (saved?.pending) notice.set('Your finished recording is saved on this device. Retry the upload when connected.') |
| 113 | 139 | now.set(Date.now()) |
| 114 | 140 | } |
| 115 | catch { if (alive) error.set('Could not check the device recording. Pull down to retry before starting another activity.') } | |
| 116 | finally { recovering=false;if(alive) loading.set(false) } | |
| 141 | catch (cause) { if (current()) error.set(cause instanceof Error ? cause.message : 'Could not check the device recording. Pull down to retry before starting another activity.') } | |
| 142 | finally { recovering=false;if(current()) loading.set(false);if(recoverQueued && alive) void recover() } | |
| 117 | 143 | } |
| 118 | function start() { void perform(async () => { | |
| 119 | if (!craft.location) throw new Error('Location recording is unavailable in this app build.') | |
| 120 | const api=createNativeApi(craft) | |
| 121 | const cached=await readNativeAccount(craft) | |
| 122 | const me=cached ? null : await api.send('/api/me') | |
| 123 | const userId=cached?.id ?? (Number(me?.body?.user?.id)||0) | |
| 124 | if (!userId) {craft.navigation.open('/login?next=/record');return} | |
| 125 | if (recording()) throw new Error('Save your previous recording before starting another.') | |
| 126 | if ((await craft.location.getRecordingState()).active) throw new Error('A recording is already active on this device. Pull down to recover it.') | |
| 127 | const value:NativeRecording={userId,id:null,startedAt:Date.now(),type:activityType(),visibility:visibility(),gameMode:gameMode() === 'capture' ? 'capture':'free',targetTerritoryId:Number(craft.route.params.target)||null,pauses:[],samples:[]} | |
| 128 | // Bind the account before location starts. The host owns background fixes; | |
| 129 | // this checkpoint owns the account and upload identity across app restarts. | |
| 144 | function start() { void perform(async current => { | |
| 145 | if (!craft.location || !craft.crypto) throw new Error('Secure location recording is unavailable in this app build.') | |
| 146 | const previous=recording() | |
| 147 | const session=await ownedSession(previous ?? undefined) | |
| 148 | if (!current()) return | |
| 149 | if (previous && previous.phase!=='starting') throw new Error('Save your previous recording before starting another.') | |
| 150 | const host=await craft.location.getRecordingState() | |
| 151 | if (!current()) return | |
| 152 | let value=previous | |
| 153 | if (!value) { | |
| 154 | if (host.active) throw new Error('A recording is already active on this device. Pull down to recover it.') | |
| 155 | const id=await craft.crypto.randomUUID() | |
| 156 | if (!current()) return | |
| 157 | await session.api.readToken() | |
| 158 | value={userId:session.userId,id,phase:'starting',startedAt:Date.now(),type:activityType(),visibility:visibility(),gameMode:gameMode() === 'capture' ? 'capture':'free',targetTerritoryId:Number(craft.route.params.target)||null,pauses:[],samples:[]} | |
| 159 | await checkpoint.register(value) | |
| 160 | if (!current()) return | |
| 161 | recording.set(value) | |
| 162 | } | |
| 163 | // A retry can attach only to the same registered start. A different active | |
| 164 | // native recording is retained and never stopped or replaced here. | |
| 165 | if (host.active && host.id!==value.id) throw new Error('Another recording is active on this device. Your saved intent has been retained.') | |
| 166 | await session.api.readToken() | |
| 167 | const started=host.active ? await craft.location.getRecordingState({expectedId:value.id!}) : await craft.location.startRecording({enableHighAccuracy:true,recordingId:value.id!}) | |
| 168 | if (!current()) return | |
| 169 | if (!started.active || started.id!==value.id) throw new Error('Location recording did not start with its saved identity. Your checkpoint has been retained.') | |
| 170 | value={...value,phase:'recording',startedAt:started.startedAt ?? value.startedAt} | |
| 130 | 171 | await persist(value) |
| 131 | recording.set(value) | |
| 132 | const host=await craft.location.startRecording({enableHighAccuracy:true}) | |
| 133 | if (!host.active || !host.id) throw new Error('Location recording did not start. Check location permission and try again.') | |
| 134 | recording.set({...value,id:host.id,startedAt:host.startedAt ?? value.startedAt}) | |
| 135 | owner=value.userId | |
| 136 | await persist(recording()!) | |
| 137 | running.set(true);paused.set(false);now.set(Date.now());notice.set('Recording continues with your phone locked.') | |
| 138 | attachWatch() | |
| 139 | updateLive(true) | |
| 172 | if (!current()) return | |
| 173 | recording.set(value);owner=value.userId | |
| 174 | running.set(true);paused.set(Boolean(started.paused));now.set(Date.now());notice.set('Recording continues with your phone locked.') | |
| 175 | attachWatch();updateLive(true) | |
| 140 | 176 | await craft.setKeepAwake?.(true) |
| 141 | 177 | }) } |
| 142 | function togglePause() { void perform(async () => { | |
| 178 | function togglePause() { void perform(async current => { | |
| 143 | 179 | const value=recording() |
| 144 | if (!value || !running() || !craft.location) return | |
| 180 | if (!value?.id || !running() || !craft.location) return | |
| 181 | const session=await ownedSession(value) | |
| 182 | if (!current()) return | |
| 183 | await session.api.readToken() | |
| 145 | 184 | const at=Date.now() |
| 185 | let next=value | |
| 146 | 186 | if (paused()) { |
| 147 | await craft.location.resumeRecording() | |
| 148 | const pauses=value.pauses.map((pause,index) => index === value.pauses.length-1 && pause.end === null ? {...pause,end:at}:pause) | |
| 149 | recording.set({...value,pauses});paused.set(false) | |
| 187 | await craft.location.resumeRecording({expectedId:value.id}) | |
| 188 | if (!current()) return | |
| 189 | next={...value,pauses:value.pauses.map((pause,index) => index===value.pauses.length-1 && pause.end===null ? {...pause,end:at}:pause)} | |
| 190 | paused.set(false) | |
| 150 | 191 | } |
| 151 | 192 | else { |
| 152 | await craft.location.pauseRecording() | |
| 153 | append(await craft.location.readRecording()) | |
| 154 | recording.set({...recording()!,pauses:[...value.pauses,{start:at,end:null}]});paused.set(true) | |
| 193 | await craft.location.pauseRecording({expectedId:value.id}) | |
| 194 | const fixes=await craft.location.readRecording({expectedId:value.id}) | |
| 195 | if (!current()) return | |
| 196 | next={...value,samples:recordingSamples(value,fixes),pauses:[...value.pauses,{start:at,end:null}]} | |
| 197 | paused.set(true) | |
| 155 | 198 | } |
| 156 | await persist(recording()!);now.set(at);updateLive(true) | |
| 199 | await persist(next) | |
| 200 | if (!current()) return | |
| 201 | recording.set(next);now.set(at);updateLive(true) | |
| 157 | 202 | }) } |
| 158 | async function upload() { | |
| 203 | async function upload(current:()=>boolean) { | |
| 159 | 204 | const value=recording() |
| 160 | 205 | if (!value?.pending) return |
| 161 | const api=await createNativeApi(craft).bindSession() | |
| 206 | const {api}=await ownedSession(value) | |
| 207 | if (!current()) return | |
| 162 | 208 | const me=await api.send('/api/me') |
| 163 | 209 | if (!me.ok || Number(me.body?.user?.id) !== value.userId) throw new Error('Log in as the account that started this recording before uploading it.') |
| 164 | 210 | const response=await api.send('/api/activities','POST',value.pending) |
| 165 | 211 | if (!response.ok || !response.body?.activity?.id) throw new Error(response.body?.error || 'Upload failed. Your finished track is saved on this device; retry when connected.') |
| 212 | if (!current()) return | |
| 166 | 213 | const activity=response.body.activity |
| 167 | 214 | let result='Activity saved.' |
| 168 | 215 | if (value.gameMode === 'capture' && activity.captureEligible) { |
| @@ -177,34 +224,68 @@ async function upload() { | ||
| 177 | 224 | catch {result+=' Your workout could not be exported to Health; check its permission in Settings.'} |
| 178 | 225 | } |
| 179 | 226 | await craft.snapshots.set('wildloop-activity-notice',{activityId:activity.id,message:result}) |
| 180 | await writes.catch(()=>{}) | |
| 181 | await craft.storage.remove(KEY) | |
| 227 | if (!current()) return | |
| 228 | if (value.id && value.phase==='stopped') await craft.location!.acknowledgeRecording({expectedId:value.id}) | |
| 229 | await checkpoint.clear(value) | |
| 230 | if (!current()) return | |
| 182 | 231 | recording.set(null);notice.set(result) |
| 183 | 232 | craft.navigation.open(`/activity/${activity.id}`) |
| 184 | 233 | } |
| 185 | function finish() { void perform(async () => { | |
| 186 | if (!recording() || !craft.location) return | |
| 234 | function finish() { void perform(async current => { | |
| 235 | let value=recording() | |
| 236 | if (!value?.id || !craft.location) throw new Error('The native recording identity is unavailable. Your checkpoint has been retained.') | |
| 237 | const session=await ownedSession(value) | |
| 238 | if (!current()) return | |
| 239 | await session.api.readToken() | |
| 187 | 240 | if (running()) { |
| 188 | const ended=await craft.location.stopRecording() | |
| 189 | append(ended.locations) | |
| 190 | running.set(false);paused.set(false) | |
| 241 | const ended=await craft.location.stopRecording({expectedId:value.id}) | |
| 242 | if (!current()) return | |
| 243 | const endedAt=Date.now() | |
| 244 | value={...value,samples:recordingSamples(value,ended.locations),phase:'stopped',endedAt} | |
| 245 | await persist(value) | |
| 246 | if (!current()) return | |
| 247 | recording.set(value);running.set(false);paused.set(false) | |
| 191 | 248 | await live.end().catch(()=>{}) |
| 192 | 249 | await craft.setKeepAwake?.(false) |
| 250 | if (!current()) return | |
| 193 | 251 | } |
| 194 | const value=recording()! | |
| 195 | if (value.samples.length < 2) {await persist(value);throw new Error('Not enough GPS fixes to save an activity. Your available fixes remain on this device.')} | |
| 196 | if (!value.id) throw new Error('The native recording identity is unavailable. Your fixes have been retained.') | |
| 197 | if (!value.pending) recording.set({...value,pending:recordingPayload(value,Date.now())}) | |
| 198 | await persist(recording()!) | |
| 199 | await upload() | |
| 252 | if (value.phase==='starting') throw new Error('Retry starting this recording before finishing it.') | |
| 253 | if (value.samples.length < 2) throw new Error('Not enough GPS fixes to save an activity. Your available fixes remain on this device.') | |
| 254 | if (!value.pending) { | |
| 255 | value={...value,pending:recordingPayload(value,value.endedAt ?? Date.now())} | |
| 256 | await persist(value) | |
| 257 | if (!current()) return | |
| 258 | recording.set(value) | |
| 259 | } | |
| 260 | await upload(current) | |
| 200 | 261 | }) } |
| 201 | 262 | function retry() {void perform(upload)} |
| 202 | function discard() {void perform(async()=>{ | |
| 263 | function discard() {void perform(async current=>{ | |
| 264 | const value=recording() | |
| 265 | if (!value) return | |
| 203 | 266 | if (running()) throw new Error('Finish your recording before discarding it.') |
| 267 | const session=await ownedSession(value) | |
| 268 | if (!current()) return | |
| 204 | 269 | if (!await craft.dialog?.confirm({title:'Discard this recording?',message:'This removes the unsaved track from this device.',confirmLabel:'Discard',destructive:true})) return |
| 205 | await writes.catch(()=>{});await craft.storage.remove(KEY);recording.set(null);notice.set('Recording discarded.') | |
| 270 | if (!current()) return | |
| 271 | await session.api.readToken() | |
| 272 | // A saved start may have reached native GPS before its acknowledgement was | |
| 273 | // delivered. Never clear its owner checkpoint while it could still be active. | |
| 274 | if (value.id && !value.pending && value.phase!=='starting') { | |
| 275 | const host=await craft.location!.getRecordingState({expectedId:value.id}) | |
| 276 | if (host.active) throw new Error('This recording is still active. Pull down to recover and finish it before discarding.') | |
| 277 | } | |
| 278 | if (value.phase==='starting') { | |
| 279 | const host=await craft.location!.getRecordingState() | |
| 280 | if (host.active && host.id===value.id) throw new Error('This recording started on the device. Retry starting to recover it before finishing.') | |
| 281 | } | |
| 282 | if (!current()) return | |
| 283 | if (value.id && value.phase==='stopped') await craft.location!.acknowledgeRecording({expectedId:value.id}) | |
| 284 | await checkpoint.clear(value) | |
| 285 | if (!current()) return | |
| 286 | recording.set(null);notice.set('Recording discarded.') | |
| 206 | 287 | })} |
| 207 | onMount(()=>onNativeAccountChange(craft,()=>{recording.set(null);running.set(false);paused.set(false);owner=0;if(watch!==null){craft.location?.clearWatch(watch);watch=null}void recover()})) | |
| 288 | onMount(()=>onNativeAccountChange(craft,()=>{accountGeneration++;api=createNativeApi(craft);recoverQueued=true;loading.set(true);recording.set(null);running.set(false);paused.set(false);notice.set('');error.set('');owner=0;if(watch!==null){craft.location?.clearWatch(watch);watch=null}void recover()})) | |
| 208 | 289 | onMount(()=>{ |
| 209 | 290 | craft.navigation.setOptions({title:'Record',largeTitle:false}) |
| 210 | 291 | void recover() |
| @@ -242,7 +323,8 @@ onMount(()=>{ | ||
| 242 | 323 | <Button testID="record-finish" :disabled="busy" :class="ui.action" class="flex-1" @click="finish()" title="Finish and save" /> |
| 243 | 324 | </View> |
| 244 | 325 | <Button :show="recording()?.pending" testID="record-retry" :disabled="busy" :class="ui.action" @click="retry()" title="Retry upload" /> |
| 245 | <Button :show="recording() && !running() && !recording()?.pending" :disabled="busy" @click="finish()">Save retained recording</Button> | |
| 326 | <Button :show="recording()?.phase==='starting' && !running()" testID="record-retry-start" :disabled="busy || loading" :class="ui.action" @click="start()">Retry starting</Button> | |
| 327 | <Button :show="recording() && !running() && !recording()?.pending && recording()?.phase!=='starting'" :disabled="busy" @click="finish()">Save retained recording</Button> | |
| 246 | 328 | <Button :show="recording() && !running()" :disabled="busy" :class="ui.danger" @click="discard()">Discard recording</Button> |
| 247 | 329 | </View> |
| 248 | 330 | </View> |