diff --git a/components/SettingsForm.vue b/components/SettingsForm.vue index 12dee74..a5eac37 100644 --- a/components/SettingsForm.vue +++ b/components/SettingsForm.vue @@ -48,6 +48,24 @@

{{ gpuMessage }}

+ +

{{ notifyMessage }}

@@ -460,6 +478,8 @@ const gpuSleeping = ref(false) const gpuWaking = ref(false) const gpuError = ref(false) const gpuMessage = ref('') +const notifyComplete = ref(true) +const notifyMessage = ref('') const storage = ref<{ libraryBytes: number breakdown: { clips: number; stills: number; tmp: number; other: number } @@ -533,6 +553,7 @@ async function loadAccount() { } onMounted(() => { + notifyComplete.value = generationNotifyPref() void loadAccount() void loadPresets() void loadStorage() @@ -620,6 +641,25 @@ async function loadStorage() { } } +async function onNotifyCompleteChange() { + const on = notifyComplete.value + setGenerationNotifyPref(on) + notifyMessage.value = '' + if (!on) return + if (!generationNotifySupported()) { + notifyMessage.value = 'This browser does not support notifications.' + return + } + const granted = await ensureGenerationNotifyPermission() + if (!granted) { + notifyComplete.value = false + setGenerationNotifyPref(false) + notifyMessage.value = Notification.permission === 'denied' + ? 'Notifications are blocked in the browser. Allow them for this site, then turn this on again.' + : 'Could not enable notifications.' + } +} + async function sleepGpu() { if (gpuSleeping.value || gpuWaking.value) return gpuSleeping.value = true diff --git a/pages/index.vue b/pages/index.vue index 149025a..4f35382 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -5743,6 +5743,7 @@ async function generateExtension() { statusMessage.value = 'Extracting last frame & stitching extension...' startTimer('video') try { + await ensureGenerationNotifyPermission() const started = await $fetch<{ jobId: string; steps: number; hideThumbnail?: boolean; folderLocked?: boolean }>('/api/extend', { method: 'POST', body: { @@ -6463,6 +6464,7 @@ async function editImage() { const extraPasses = editPassQueue.value.map(item => ({ prompt: item.prompt.trim() })) const hideOut = hideThumbnail.value try { + await ensureGenerationNotifyPermission() const body = new FormData() body.append('image', file.value) if (editRefFile.value) body.append('image2', editRefFile.value) @@ -6679,6 +6681,7 @@ async function editImageV2() { const extraPasses = v2PassChaining.value ? editPassQueue.value.map(item => ({ prompt: item.prompt.trim() })) : [] const hideOut = hideThumbnail.value try { + await ensureGenerationNotifyPermission() const body = new FormData() body.append('mode', v2Mode.value) body.append('task', v2Mode.value === 'generate' ? 't2i' : v2Mode.value === 'refine' ? 'refine' : v2Mode.value === 'compose' ? v2Task.value : 'scene') @@ -6900,6 +6903,7 @@ async function generate() { const hideOut = hideThumbnail.value const folderLockedPref = false try { + await ensureGenerationNotifyPermission() const body = new FormData() if (file.value) body.append('image', file.value) if (inputStillId.value) body.append('stillId', inputStillId.value) @@ -7132,6 +7136,7 @@ function applyEditEvent(payload: Record, hidden: boolean, folderLoc scheduleLibraryRefresh() void pollComfyHealth() void refreshStudioQueue() + void notifyGenerationComplete('image') return } @@ -7246,6 +7251,7 @@ function applyVideoEvent(payload: Record, hidden: boolean, folderLo toast(locked ? 'Saved to the locked folder. Unlock it to view.' : 'New clip saved to the library') + void notifyGenerationComplete('video') return } if (locked) { @@ -7277,6 +7283,7 @@ function applyVideoEvent(payload: Record, hidden: boolean, folderLo scheduleLibraryRefresh() void pollComfyHealth() void refreshStudioQueue() + void notifyGenerationComplete('video') return } diff --git a/pages/queue.vue b/pages/queue.vue index e6212ea..3d6f57d 100644 --- a/pages/queue.vue +++ b/pages/queue.vue @@ -727,11 +727,14 @@ function listen(queue: Queue) { sources.set(queue.id, source) source.onmessage = (event) => { try { - const payload = JSON.parse(event.data) as { message?: string; type?: string } + const payload = JSON.parse(event.data) as { message?: string; type?: string; queuedRemaining?: number } if (payload.message) liveMessage.value = { ...liveMessage.value, [queue.id]: payload.message } if (payload.type === 'checkpoint' || payload.type === 'complete' || payload.type === 'error') { void loadQueues() } + if (payload.type === 'complete' && !(Number(payload.queuedRemaining) > 0)) { + void notifyGenerationComplete('video') + } if (payload.type === 'complete' || payload.type === 'error') { source.close() sources.delete(queue.id) @@ -759,11 +762,14 @@ function listenJob(job: StudioJobRow) { sources.set(job.id, source) source.onmessage = (event) => { try { - const payload = JSON.parse(event.data) as { message?: string; type?: string } + const payload = JSON.parse(event.data) as { message?: string; type?: string; queuedRemaining?: number; stillId?: string } if (payload.message) liveMessage.value = { ...liveMessage.value, [job.id]: payload.message } if (payload.type === 'checkpoint' || payload.type === 'complete' || payload.type === 'error') { void loadQueues() } + if (payload.type === 'complete' && !(Number(payload.queuedRemaining) > 0)) { + void notifyGenerationComplete(job.kind === 'edit' || payload.stillId ? 'image' : 'video') + } if (payload.type === 'complete' || payload.type === 'error') { source.close() sources.delete(job.id) diff --git a/plugins/pwa.client.ts b/plugins/pwa.client.ts new file mode 100644 index 0000000..34f9a10 --- /dev/null +++ b/plugins/pwa.client.ts @@ -0,0 +1,16 @@ +export default defineNuxtPlugin(() => { + const config = useRuntimeConfig() + const name = String(config.public.instanceName || 'AIGen') + useHead({ + meta: [ + { name: 'apple-mobile-web-app-capable', content: 'yes' }, + { name: 'apple-mobile-web-app-title', content: name }, + { name: 'mobile-web-app-capable', content: 'yes' } + ], + link: [ + { rel: 'manifest', href: '/manifest.webmanifest' } + ] + }) + if (!('serviceWorker' in navigator)) return + void navigator.serviceWorker.register('/sw.js').catch(() => null) +}) diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..2431cad --- /dev/null +++ b/public/sw.js @@ -0,0 +1,18 @@ +self.addEventListener('install', (event) => { + event.waitUntil(self.skipWaiting()) +}) + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()) +}) + +self.addEventListener('notificationclick', (event) => { + event.notification.close() + event.waitUntil((async () => { + const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }) + for (const client of windows) { + if ('focus' in client) return client.focus() + } + if (self.clients.openWindow) return self.clients.openWindow('/') + })()) +}) diff --git a/server/routes/manifest.webmanifest.ts b/server/routes/manifest.webmanifest.ts new file mode 100644 index 0000000..8f19926 --- /dev/null +++ b/server/routes/manifest.webmanifest.ts @@ -0,0 +1,24 @@ +import { isXaigenStudio } from '~/utils/videoModels' + +export default defineEventHandler((event) => { + const pub = useRuntimeConfig().public as { instanceName?: string; studio?: string } + const xaigen = isXaigenStudio(pub) + const name = String(pub.instanceName || (xaigen ? 'xAIGen' : 'AIGen')) + setHeader(event, 'Content-Type', 'application/manifest+json') + setHeader(event, 'Cache-Control', 'no-cache') + return { + name, + short_name: name, + start_url: '/', + scope: '/', + display: 'standalone', + background_color: '#09090b', + theme_color: '#09090b', + icons: [{ + src: xaigen ? '/favicon.svg' : '/favicon-aigen.svg', + sizes: 'any', + type: 'image/svg+xml', + purpose: 'any' + }] + } +}) diff --git a/utils/generationNotify.ts b/utils/generationNotify.ts new file mode 100644 index 0000000..a4c855c --- /dev/null +++ b/utils/generationNotify.ts @@ -0,0 +1,82 @@ +import { isXaigenStudio } from '~/utils/videoModels' + +const PREF_KEY = 'aigen-notify-complete' +const TAG = 'aigen-generation-complete' + +function appName() { + try { + const pub = useRuntimeConfig().public as { instanceName?: string; studio?: string } + return String(pub.instanceName || (isXaigenStudio(pub) ? 'xAIGen' : 'AIGen')) + } catch { + return 'AIGen' + } +} + +function appIcon() { + try { + const pub = useRuntimeConfig().public as { instanceName?: string; studio?: string } + return isXaigenStudio(pub) ? '/favicon.svg' : '/favicon-aigen.svg' + } catch { + return '/favicon-aigen.svg' + } +} + +export function generationNotifySupported() { + return typeof window !== 'undefined' && 'Notification' in window +} + +export function generationNotifyPref() { + if (typeof window === 'undefined') return true + try { + return localStorage.getItem(PREF_KEY) !== '0' + } catch { + return true + } +} + +export function setGenerationNotifyPref(on: boolean) { + try { + localStorage.setItem(PREF_KEY, on ? '1' : '0') + } catch { /* ignore */ } +} + +export async function ensureGenerationNotifyPermission() { + if (!generationNotifySupported() || !generationNotifyPref()) return false + if (Notification.permission === 'granted') return true + if (Notification.permission === 'denied') return false + try { + const result = await Notification.requestPermission() + return result === 'granted' + } catch { + return false + } +} + +export async function notifyGenerationComplete(kind: 'video' | 'image' = 'video') { + if (!generationNotifySupported() || !generationNotifyPref()) return + if (Notification.permission !== 'granted') return + if (typeof document !== 'undefined' && !document.hidden && document.hasFocus()) return + const title = appName() + const body = kind === 'image' ? 'Image generation is complete.' : 'Generation is complete.' + const options: NotificationOptions = { + body, + icon: appIcon(), + tag: TAG, + renotify: true, + silent: false + } + try { + const registration = await navigator.serviceWorker?.ready + if (registration?.showNotification) { + await registration.showNotification(title, options) + return + } + } catch { /* fall through */ } + try { + const note = new Notification(title, options) + note.onclick = () => { + window.focus() + note.close() + } + } catch { /* ignore */ } +}