feat(splash): hold Android system splash on screen until web mascot paints via custom LaunchSplash plugin
|
|
@ -0,0 +1,16 @@
|
||||||
|
package chat.vojo.app;
|
||||||
|
|
||||||
|
import com.getcapacitor.Plugin;
|
||||||
|
import com.getcapacitor.PluginCall;
|
||||||
|
import com.getcapacitor.PluginMethod;
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||||
|
|
||||||
|
@CapacitorPlugin(name = "LaunchSplash")
|
||||||
|
public class LaunchSplashPlugin extends Plugin {
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
public void ready(PluginCall call) {
|
||||||
|
MainActivity.releaseLaunchSplash();
|
||||||
|
call.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,12 +4,26 @@ import android.os.Bundle;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import androidx.activity.EdgeToEdge;
|
import androidx.activity.EdgeToEdge;
|
||||||
|
import androidx.core.splashscreen.SplashScreen;
|
||||||
import androidx.core.view.WindowCompat;
|
import androidx.core.view.WindowCompat;
|
||||||
import androidx.core.view.WindowInsetsControllerCompat;
|
import androidx.core.view.WindowInsetsControllerCompat;
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
public class MainActivity extends BridgeActivity {
|
public class MainActivity extends BridgeActivity {
|
||||||
public static volatile boolean isInForeground = false;
|
public static volatile boolean isInForeground = false;
|
||||||
|
private static volatile boolean launchSplashReady = false;
|
||||||
|
|
||||||
|
// Safety net for setKeepOnScreenCondition: if JS never calls
|
||||||
|
// launchSplash.ready() (boot crash, exception during config load before
|
||||||
|
// AuthMascot mounts, network hang in useClientConfig, deep-link straight
|
||||||
|
// into AuthLayout where the centered AuthMascot variant doesn't render,
|
||||||
|
// …) the splash would otherwise hang indefinitely and the user can't
|
||||||
|
// interact with anything. 5s covers normal cold boots on mid-range
|
||||||
|
// Android (config + bundle parse + first paint typically lands inside
|
||||||
|
// 1-2s) with comfortable headroom; past it we drop the splash and let
|
||||||
|
// whatever the web side has rendered take over — including blank
|
||||||
|
// AuthLayout, which is at least recoverable.
|
||||||
|
private static final long SPLASH_SAFETY_TIMEOUT_MS = 5000L;
|
||||||
|
|
||||||
// Short debounce on the onPause→renderRegistry edge so an in-flight JS
|
// Short debounce on the onPause→renderRegistry edge so an in-flight JS
|
||||||
// removeIncomingRing bridge call (e.g. user accepted/declined, then
|
// removeIncomingRing bridge call (e.g. user accepted/declined, then
|
||||||
|
|
@ -33,13 +47,33 @@ public class MainActivity extends BridgeActivity {
|
||||||
private final Runnable cancelRunnable = () ->
|
private final Runnable cancelRunnable = () ->
|
||||||
VojoFirebaseMessagingService.cancelRenderedIncomingRings(this);
|
VojoFirebaseMessagingService.cancelRenderedIncomingRings(this);
|
||||||
|
|
||||||
|
public static void releaseLaunchSplash() {
|
||||||
|
launchSplashReady = true;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
if (savedInstanceState == null) {
|
||||||
|
launchSplashReady = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Custom plugins must be registered before super.onCreate so BridgeActivity
|
// Custom plugins must be registered before super.onCreate so BridgeActivity
|
||||||
// can wire them into the WebView bridge on load. Registering after
|
// can wire them into the WebView bridge on load. Registering after
|
||||||
// super.onCreate would make the plugin invisible to JS until the next relaunch.
|
// super.onCreate would make the plugin invisible to JS until the next relaunch.
|
||||||
registerPlugin(FullScreenIntentPlugin.class);
|
registerPlugin(FullScreenIntentPlugin.class);
|
||||||
registerPlugin(CallForegroundPlugin.class);
|
registerPlugin(CallForegroundPlugin.class);
|
||||||
|
registerPlugin(LaunchSplashPlugin.class);
|
||||||
|
|
||||||
|
// AndroidX SplashScreen must be installed before super.onCreate().
|
||||||
|
// Keep it until the web splash confirms its first visible frame is
|
||||||
|
// ready, OR the safety timeout elapses (see SPLASH_SAFETY_TIMEOUT_MS).
|
||||||
|
final long splashStartMs = System.currentTimeMillis();
|
||||||
|
SplashScreen splashScreen = SplashScreen.installSplashScreen(this);
|
||||||
|
splashScreen.setKeepOnScreenCondition(() -> {
|
||||||
|
if (launchSplashReady) return false;
|
||||||
|
return System.currentTimeMillis() - splashStartMs < SPLASH_SAFETY_TIMEOUT_MS;
|
||||||
|
});
|
||||||
|
|
||||||
EdgeToEdge.enable(this);
|
EdgeToEdge.enable(this);
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
// Force light icons on both system bars: our CSS is permanently dark
|
// Force light icons on both system bars: our CSS is permanently dark
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 9 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/main/res/drawable/vojo_mascot_splash.png
Normal file
|
After Width: | Height: | Size: 164 KiB |
7
android/app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<!-- Matches web safe-area / DM 1:1 chat background (DAWN.bg2) so the
|
||||||
|
native splash, the WebView body, and the in-app AuthSplashScreen all
|
||||||
|
share a single backdrop and read as one continuous splash. -->
|
||||||
|
<color name="splash_bg">#0d0e11</color>
|
||||||
|
</resources>
|
||||||
|
|
@ -2,10 +2,12 @@
|
||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<!-- Base application theme. -->
|
<!-- Base application theme. -->
|
||||||
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
|
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
<item name="colorPrimary">@color/colorPrimary</item>
|
<item name="colorPrimary">@color/colorPrimary</item>
|
||||||
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
|
||||||
<item name="colorAccent">@color/colorAccent</item>
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
|
<item name="windowActionBar">false</item>
|
||||||
|
<item name="windowNoTitle">true</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
|
|
@ -13,12 +15,39 @@
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:background">@null</item>
|
<item name="android:background">@null</item>
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
|
<!-- Bridges the gap between native splash exit and the WebView's first
|
||||||
|
body paint: without this the window paints transparent/black for
|
||||||
|
~200ms while the bundle hydrates, producing a visible black flash
|
||||||
|
between the native and the in-app splash. Matches splash_bg so
|
||||||
|
cold start reads as one continuous backdrop. -->
|
||||||
|
<item name="android:windowBackground">@color/splash_bg</item>
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.AppCompat.DayNight.NoActionBar">
|
<!-- Launch theme: Android 12+ system splash (Theme.SplashScreen via
|
||||||
|
androidx.core.splashscreen). Renders the mascot centered on the same
|
||||||
|
#0d0e11 backdrop the web AuthSplashScreen uses, so cold start reads
|
||||||
|
as one continuous splash (native → WebView mount → web splash) instead
|
||||||
|
of three visual jumps. MainActivity installs AndroidX SplashScreen
|
||||||
|
before super.onCreate() and keeps it visible until Capacitor's local
|
||||||
|
WebView has loaded the app shell. -->
|
||||||
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
|
<!-- Theme.SplashScreen only sets the native android:windowActionBar /
|
||||||
|
android:windowNoTitle attrs. Capacitor's BridgeActivity extends
|
||||||
|
AppCompatActivity, whose ActionBar delegate reads the un-prefixed
|
||||||
|
AppCompat attrs — without these two overrides, AppCompat keeps
|
||||||
|
its ActionBar enabled, paints the activity label ("Vojo" from
|
||||||
|
strings.xml/title_activity_main) at the top of the WebView, and
|
||||||
|
persists past the splash exit. -->
|
||||||
<item name="windowActionBar">false</item>
|
<item name="windowActionBar">false</item>
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:windowBackground">@android:color/black</item>
|
<item name="windowSplashScreenBackground">@color/splash_bg</item>
|
||||||
|
<item name="windowSplashScreenAnimatedIcon">@drawable/vojo_mascot_splash</item>
|
||||||
|
<!-- Intentionally NO windowSplashScreenIconBackgroundColor: setting it
|
||||||
|
switches the system to the "with-background" canvas, which is
|
||||||
|
actually 240dp (vs 288dp without) — the colored ring would just
|
||||||
|
shrink the visible icon zone. Background already matches via
|
||||||
|
windowSplashScreenBackground above. -->
|
||||||
|
<item name="postSplashScreenTheme">@style/AppTheme.NoActionBar</item>
|
||||||
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
</style>
|
</style>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,13 @@ const config: CapacitorConfig = {
|
||||||
android: {
|
android: {
|
||||||
// Keep default: resolveServiceWorkerRequests = true
|
// Keep default: resolveServiceWorkerRequests = true
|
||||||
// SW is critical for authenticated Matrix media (MSC3916 / spec v1.11+)
|
// SW is critical for authenticated Matrix media (MSC3916 / spec v1.11+)
|
||||||
|
//
|
||||||
|
// WebView bg color before first body paint. Without this the WebView
|
||||||
|
// paints transparent/black during bundle hydration, which combined with
|
||||||
|
// the post-splash window theme produced a visible black flash between
|
||||||
|
// the Android 12+ system splash and the in-app AuthSplashScreen mascot.
|
||||||
|
// Matches the native splash + AuthLayout + body backgrounds.
|
||||||
|
backgroundColor: '#0d0e11',
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
PushNotifications: {
|
PushNotifications: {
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,82 @@
|
||||||
import React, { Ref } from 'react';
|
import React, { Ref, useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
||||||
|
|
||||||
import mascotPoster from '../../../../public/res/img/mascot.png';
|
import { launchSplash } from '../../plugins/launchSplash';
|
||||||
import mascotWebm from '../../../../public/res/img/mascot.webm';
|
import {
|
||||||
|
attachMascotVideo,
|
||||||
|
detachMascotVideo,
|
||||||
|
mascotVideoReady,
|
||||||
|
} from './mascotSingleton';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
|
|
||||||
type AuthMascotProps = {
|
type AuthMascotProps = {
|
||||||
mascotRef?: Ref<HTMLDivElement>;
|
mascotRef?: Ref<HTMLDivElement>;
|
||||||
|
// True for the splash-gate variant (cold start, sync wait): mascot is
|
||||||
|
// centered both axes to mirror the Android 12+ system splash icon, so the
|
||||||
|
// native → WebView → web-splash handoff reads as one continuous splash.
|
||||||
|
// False (default) keeps the auth-page positioning (top-anchored) used by
|
||||||
|
// login / register / reset-password.
|
||||||
|
centered?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AuthMascot({ mascotRef }: AuthMascotProps) {
|
export function AuthMascot({ mascotRef, centered }: AuthMascotProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const didSignalLaunchReadyRef = useRef(false);
|
||||||
|
|
||||||
|
// Singleton <video> attach/detach. Preserves currentTime across the
|
||||||
|
// ConfigConfigLoading → SpecVersionsLoader → ClientRootLoading remounts
|
||||||
|
// so the mascot loop reads as one continuous animation instead of two
|
||||||
|
// restarts mid-boot.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const container = containerRef.current;
|
||||||
|
if (!container) return undefined;
|
||||||
|
attachMascotVideo(container, css.AuthMascotVideo);
|
||||||
|
return () => {
|
||||||
|
detachMascotVideo(container);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Native splash hand-off: only the centered (splash-gate) variant signals
|
||||||
|
// the Android system splash to drop. Auth pages (login/register) don't
|
||||||
|
// signal — they're already past the cold-boot phase by the time they
|
||||||
|
// mount. requestAnimationFrame×2 ensures the browser has actually
|
||||||
|
// committed the paint before we ask the OS to release its splash.
|
||||||
|
const signalLaunchReady = useCallback(() => {
|
||||||
|
if (!centered || didSignalLaunchReadyRef.current) return;
|
||||||
|
didSignalLaunchReadyRef.current = true;
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
launchSplash.ready();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [centered]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!centered) return undefined;
|
||||||
|
let cancelled = false;
|
||||||
|
mascotVideoReady().then(() => {
|
||||||
|
if (!cancelled) signalLaunchReady();
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [centered, signalLaunchReady]);
|
||||||
|
|
||||||
|
const setRefs = useCallback(
|
||||||
|
(node: HTMLDivElement | null) => {
|
||||||
|
containerRef.current = node;
|
||||||
|
if (typeof mascotRef === 'function') mascotRef(node);
|
||||||
|
else if (mascotRef && typeof mascotRef === 'object') {
|
||||||
|
(mascotRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[mascotRef]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css.AuthMascot} aria-hidden="true" ref={mascotRef}>
|
<div
|
||||||
<video
|
className={centered ? css.AuthMascotCentered : css.AuthMascot}
|
||||||
className={css.AuthMascotVideo}
|
aria-hidden="true"
|
||||||
autoPlay
|
ref={setRefs}
|
||||||
loop
|
/>
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
preload="auto"
|
|
||||||
poster={mascotPoster}
|
|
||||||
>
|
|
||||||
<source src={mascotWebm} type="video/webm" />
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,18 @@ type AuthSplashScreenProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AuthSplashScreen({ children }: AuthSplashScreenProps) {
|
export function AuthSplashScreen({ children }: AuthSplashScreenProps) {
|
||||||
|
// No children = bare splash gate (cold init, sync wait): center the mascot
|
||||||
|
// to match the Android 12+ native splash and bridge the native → web
|
||||||
|
// handoff. With children (errors, missing-IDB, retry dialogs) keep the
|
||||||
|
// top-anchored mascot so the dialog has the lower half free.
|
||||||
|
const splash = !children;
|
||||||
return (
|
return (
|
||||||
<div className={css.AuthLayout} style={authLayoutRootVars}>
|
<div className={css.AuthLayout} style={authLayoutRootVars}>
|
||||||
<div className={css.AuthStack}>
|
<div className={splash ? `${css.AuthStack} ${css.AuthStackSplash}` : css.AuthStack}>
|
||||||
<AuthMascot />
|
<AuthMascot centered={splash} />
|
||||||
{children && <div className={css.AuthSplashContent}>{children}</div>}
|
{children && <div className={css.AuthSplashContent}>{children}</div>}
|
||||||
</div>
|
</div>
|
||||||
<AuthFooter />
|
{!splash && <AuthFooter />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
src/app/pages/auth/mascotSingleton.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
import mascotPoster from '../../../../public/res/img/mascot.png';
|
||||||
|
import mascotWebm from '../../../../public/res/img/mascot.webm';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Module-level singleton <video> for the auth/splash mascot.
|
||||||
|
*
|
||||||
|
* AuthSplashScreen renders in three different React tree positions during
|
||||||
|
* cold boot (ConfigConfigLoading → SpecVersionsLoader fallback →
|
||||||
|
* ClientRootLoading), and React unmount/mount across positions destroys and
|
||||||
|
* re-creates the underlying <video> element. autoplay then restarts from
|
||||||
|
* frame 0 for every new instance, which the user perceives as the mascot
|
||||||
|
* loop "skipping" mid-boot.
|
||||||
|
*
|
||||||
|
* Keeping one <video> on the module level and attaching/detaching it across
|
||||||
|
* AuthMascot mounts preserves currentTime, so the animation reads as one
|
||||||
|
* continuous loop. We also expose a readiness Promise so AuthMascot can
|
||||||
|
* still gate the native splash hand-off on the FIRST canplay/loadeddata
|
||||||
|
* event — subsequent attaches resolve instantly because the media is
|
||||||
|
* already buffered.
|
||||||
|
*
|
||||||
|
* KNOWN ARCHITECTURAL CAVEAT: this is a module-level singleton with mutable
|
||||||
|
* state and imperative DOM manipulation — pragmatic but non-idiomatic for
|
||||||
|
* Concurrent React. The canonical fix is React 19.2's <Activity mode="hidden">
|
||||||
|
* (formerly <Offscreen>), which preserves video currentTime by design. When
|
||||||
|
* we upgrade React to 19.2+, replace this file with an <Activity>-wrapped
|
||||||
|
* AuthMascot rendered once at App.tsx top-level, controlled by a jotai atom
|
||||||
|
* that AuthSplashScreen sets/clears on mount. See:
|
||||||
|
* https://react.dev/reference/react/Activity
|
||||||
|
* Constraints of the current implementation:
|
||||||
|
* - Single instance only (rendering two AuthMascot simultaneously breaks).
|
||||||
|
* - Not SSR-safe (createElement at module load).
|
||||||
|
* - HMR-fragile (the singleton survives reloads with stale closure refs).
|
||||||
|
*/
|
||||||
|
|
||||||
|
let videoEl: HTMLVideoElement | null = null;
|
||||||
|
let readyPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
const ensureVideo = (): HTMLVideoElement => {
|
||||||
|
if (videoEl) return videoEl;
|
||||||
|
|
||||||
|
const v = document.createElement('video');
|
||||||
|
v.autoplay = true;
|
||||||
|
v.loop = true;
|
||||||
|
v.muted = true;
|
||||||
|
v.playsInline = true;
|
||||||
|
v.preload = 'auto';
|
||||||
|
v.poster = mascotPoster;
|
||||||
|
v.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
const source = document.createElement('source');
|
||||||
|
source.src = mascotWebm;
|
||||||
|
source.type = 'video/webm';
|
||||||
|
v.appendChild(source);
|
||||||
|
|
||||||
|
readyPromise = new Promise<void>((resolve) => {
|
||||||
|
if (v.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const onReady = () => {
|
||||||
|
v.removeEventListener('canplay', onReady);
|
||||||
|
v.removeEventListener('loadeddata', onReady);
|
||||||
|
v.removeEventListener('error', onReady);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
v.addEventListener('canplay', onReady, { once: true });
|
||||||
|
v.addEventListener('loadeddata', onReady, { once: true });
|
||||||
|
// Resolve on error too: if the codec is unsupported (e.g. legacy WebView
|
||||||
|
// that doesn't decode webm) we still need to release the native splash so
|
||||||
|
// the user sees AuthLayout / login. The poster <img> falls back as the
|
||||||
|
// visible mascot. Without this branch readyPromise hangs and the native
|
||||||
|
// splash sits on the safety timeout.
|
||||||
|
v.addEventListener('error', onReady, { once: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
videoEl = v;
|
||||||
|
return v;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const attachMascotVideo = (container: HTMLElement, className: string): void => {
|
||||||
|
const v = ensureVideo();
|
||||||
|
if (v.className !== className) v.className = className;
|
||||||
|
if (v.parentElement === container) return;
|
||||||
|
container.appendChild(v);
|
||||||
|
// detach from previous parent is implicit (DOM moves the node), and
|
||||||
|
// re-running play() handles the rare case where Chrome paused the media
|
||||||
|
// while it was orphan-detached between commits.
|
||||||
|
v.play().catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const detachMascotVideo = (container: HTMLElement): void => {
|
||||||
|
if (!videoEl) return;
|
||||||
|
if (videoEl.parentElement === container) {
|
||||||
|
container.removeChild(videoEl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// First frame ready (poster decoded OR video buffered to canplay).
|
||||||
|
// Subsequent calls return the resolved Promise instantly.
|
||||||
|
export const mascotVideoReady = (): Promise<void> => {
|
||||||
|
ensureVideo();
|
||||||
|
return readyPromise ?? Promise.resolve();
|
||||||
|
};
|
||||||
|
|
@ -14,8 +14,9 @@ export const AuthLayout = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
background:
|
// Flat DAWN.bg2 — same backdrop as the Android native splash and the DM 1:1
|
||||||
'radial-gradient(ellipse 80% 60% at 50% 25%, rgba(45, 47, 50, 0.4) 0%, rgba(45, 47, 50, 0) 70%), radial-gradient(ellipse at center, #161718 0%, #121314 45%, #0c0d0e 100%)',
|
// chat surface so cold start / auth read as one continuous canvas.
|
||||||
|
background: '#0d0e11',
|
||||||
color: '#e8e4df',
|
color: '#e8e4df',
|
||||||
|
|
||||||
'@media': {
|
'@media': {
|
||||||
|
|
@ -52,6 +53,10 @@ export const AuthStack = style({
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const AuthStackSplash = style({
|
||||||
|
padding: 0,
|
||||||
|
});
|
||||||
|
|
||||||
/* ── Mascot ── */
|
/* ── Mascot ── */
|
||||||
export const AuthMascot = style({
|
export const AuthMascot = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|
@ -64,6 +69,24 @@ export const AuthMascot = style({
|
||||||
transform: 'translateX(-50%)',
|
transform: 'translateX(-50%)',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Splash-gate variant: mascot fills the viewport-sized stack and centers both axes.
|
||||||
|
* Mirrors the Android 12+ system splash (centered icon on flat background)
|
||||||
|
* so the native splash → WebView mount → web AuthSplashScreen handoff reads
|
||||||
|
* as one continuous splash that "matures" into a richer render, instead of
|
||||||
|
* the previous three-jump sequence (centered launcher icon on black →
|
||||||
|
* blank dark body → top-anchored mascot on dark gradient).
|
||||||
|
*/
|
||||||
|
export const AuthMascotCentered = style({
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 0,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
export const AuthMascotHidden = style({
|
export const AuthMascotHidden = style({
|
||||||
display: 'none',
|
display: 'none',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
22
src/app/plugins/launchSplash.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { registerPlugin } from '@capacitor/core';
|
||||||
|
import { isAndroidPlatform } from '../utils/capacitor';
|
||||||
|
|
||||||
|
interface LaunchSplashPlugin {
|
||||||
|
ready(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugin = registerPlugin<LaunchSplashPlugin>('LaunchSplash', {
|
||||||
|
web: {
|
||||||
|
ready: async () => undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let didRelease = false;
|
||||||
|
|
||||||
|
export const launchSplash = {
|
||||||
|
ready(): Promise<void> {
|
||||||
|
if (didRelease || !isAndroidPlatform()) return Promise.resolve();
|
||||||
|
didRelease = true;
|
||||||
|
return plugin.ready().catch(() => undefined);
|
||||||
|
},
|
||||||
|
};
|
||||||