From e5eb095e35c50771071cd1feab6a693af243907f Mon Sep 17 00:00:00 2001 From: 619dev <86543624+619dev@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:36:20 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8C=E6=AD=A5=20Android=202.3.3=20?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/package-lock.json | 4 +- client/package.json | 2 +- client/src/App.tsx | 2 + client/src/components/CallKeepAwake.tsx | 13 ++++++ client/src/hooks/useKeepAwake.ts | 47 +++++++++++++++++++ client/src/index.css | 60 +++++++++++-------------- client/src/pages/Chat.tsx | 45 ++++++++++++++++--- client/src/pages/Profile.tsx | 2 +- client/tsconfig.tsbuildinfo | 2 +- 9 files changed, 133 insertions(+), 44 deletions(-) create mode 100644 client/src/components/CallKeepAwake.tsx create mode 100644 client/src/hooks/useKeepAwake.ts diff --git a/client/package-lock.json b/client/package-lock.json index 60c4810..0fc0084 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "paperphone-plus-client", - "version": "2.2.9", + "version": "2.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "paperphone-plus-client", - "version": "2.2.9", + "version": "2.3.3", "dependencies": { "crystals-kyber-js": "^2.7.0", "jsqr": "^1.4.0", diff --git a/client/package.json b/client/package.json index 5e47e5d..d26b479 100644 --- a/client/package.json +++ b/client/package.json @@ -1,7 +1,7 @@ { "name": "paperphone-plus-client", "private": true, - "version": "2.2.9", + "version": "2.3.3", "type": "module", "scripts": { "dev": "vite", diff --git a/client/src/App.tsx b/client/src/App.tsx index 2893914..abe9569 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -19,6 +19,7 @@ import TermsOfUse from './pages/TermsOfUse' import TabBar from './components/TabBar' import CallOverlay from './components/CallOverlay' import GroupCallOverlay from './components/GroupCallOverlay' +import CallKeepAwake from './components/CallKeepAwake' import NotificationToast from './components/NotificationToast' import { CallProvider } from './contexts/CallContext' import { GroupCallProvider } from './contexts/GroupCallContext' @@ -150,6 +151,7 @@ function ProtectedLayout() { return ( + } /> } /> diff --git a/client/src/components/CallKeepAwake.tsx b/client/src/components/CallKeepAwake.tsx new file mode 100644 index 0000000..7ac93bb --- /dev/null +++ b/client/src/components/CallKeepAwake.tsx @@ -0,0 +1,13 @@ +import { useCallContext } from '../contexts/CallContext' +import { useGroupCallContext } from '../contexts/GroupCallContext' +import { useKeepAwake } from '../hooks/useKeepAwake' + +/** Coordinates browser screen wake state across direct and group calls. */ +export default function CallKeepAwake() { + const { callState } = useCallContext() + const { status: groupCallStatus } = useGroupCallContext() + const isCallActive = (callState !== 'idle' && callState !== 'error') || groupCallStatus !== 'idle' + + useKeepAwake(isCallActive) + return null +} diff --git a/client/src/hooks/useKeepAwake.ts b/client/src/hooks/useKeepAwake.ts new file mode 100644 index 0000000..3903838 --- /dev/null +++ b/client/src/hooks/useKeepAwake.ts @@ -0,0 +1,47 @@ +import { useEffect } from 'react' + +type WakeLockSentinel = EventTarget & { + released: boolean + release(): Promise +} + +type WakeLockNavigator = Navigator & { + wakeLock?: { + request(type: 'screen'): Promise + } +} + +/** Keep the screen awake while an activity such as a call or recording is active. */ +export function useKeepAwake(enabled: boolean) { + useEffect(() => { + let sentinel: WakeLockSentinel | null = null + let disposed = false + + const request = async () => { + if (!enabled || disposed || document.visibilityState !== 'visible') return + try { + const wakeLock = (navigator as WakeLockNavigator).wakeLock + if (!wakeLock) return + sentinel = await wakeLock.request('screen') + } catch (error) { + console.warn('[KeepAwake] Unable to acquire screen wake lock:', error) + } + } + + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible' && (!sentinel || sentinel.released)) { + void request() + } + } + + void request() + document.addEventListener('visibilitychange', handleVisibilityChange) + + return () => { + disposed = true + document.removeEventListener('visibilitychange', handleVisibilityChange) + void sentinel?.release().catch(() => {}) + sentinel = null + } + }, [enabled]) +} diff --git a/client/src/index.css b/client/src/index.css index 1c10ee6..c8ab812 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -59,10 +59,11 @@ --header-h: 56px; --tabbar-h: 52px; - --safe-top: env(safe-area-inset-top, 0px); - --safe-bottom: env(safe-area-inset-bottom, 0px); - --safe-left: env(safe-area-inset-left, 0px); - --safe-right: env(safe-area-inset-right, 0px); + /* Prefer the larger inset exposed by the browser or a native WebView. */ + --safe-top: max(env(safe-area-inset-top, 0px), var(--safe-area-inset-top, 0px)); + --safe-bottom: max(env(safe-area-inset-bottom, 0px), var(--safe-area-inset-bottom, 0px)); + --safe-left: max(env(safe-area-inset-left, 0px), var(--safe-area-inset-left, 0px)); + --safe-right: max(env(safe-area-inset-right, 0px), var(--safe-area-inset-right, 0px)); /* ── Motion (iOS spring curves) ─────────────────────────── */ --ease: cubic-bezier(0.2, 0.8, 0.2, 1); @@ -213,7 +214,8 @@ body { min-height: calc(var(--header-h) + var(--safe-top)); display: flex; align-items: center; - padding: 0 16px; + padding-left: max(16px, var(--safe-left)); + padding-right: max(16px, var(--safe-right)); padding-top: var(--safe-top); border-bottom: 0.5px solid var(--glass-border-bottom); z-index: 10; @@ -260,7 +262,10 @@ body { border-bottom: none; border-left: none; border-right: none; - margin: 0 12px calc(8px + var(--safe-bottom)); + margin-top: 0; + margin-right: max(12px, var(--safe-right)); + margin-bottom: calc(8px + var(--safe-bottom)); + margin-left: max(12px, var(--safe-left)); border-radius: var(--radius-xl); overflow: hidden; flex-shrink: 0; @@ -1414,7 +1419,7 @@ textarea.input { } /* ═══════════════════════════════════════════════════════════════ - iPhone 12+ Responsive Adaptations + Compact Phone Responsive Adaptations (iPhone + Google Pixel) iPhone 12 mini: 375 × 812pt (safe-top: 47pt, safe-bottom: 34pt) iPhone 12/12 Pro: 390 × 844pt (safe-top: 47pt, safe-bottom: 34pt) iPhone 13/13 Pro/14: 390 × 844pt (safe-top: 47pt, safe-bottom: 34pt) @@ -1427,7 +1432,7 @@ textarea.input { iPhone 16 Pro Max: 440 × 956pt (safe-top: 59pt Dynamic Island, safe-bottom: 34pt) ═══════════════════════════════════════════════════════════════ */ -/* ── iPhone 12 mini (375pt wide, smallest notch iPhone) ───── */ +/* ── Compact phones (Pixel 2–5, Pixel 6a–9a, iPhone mini) ─── */ @media screen and (max-width: 375px) { :root { --header-h: 48px; @@ -1439,7 +1444,8 @@ textarea.input { } .page-header { - padding: 0 12px; + padding-left: max(12px, var(--safe-left)); + padding-right: max(12px, var(--safe-right)); padding-top: var(--safe-top); } @@ -1469,7 +1475,9 @@ textarea.input { } .tabbar { - margin: 0 8px calc(6px + var(--safe-bottom)); + margin-right: max(8px, var(--safe-right)); + margin-bottom: calc(6px + var(--safe-bottom)); + margin-left: max(8px, var(--safe-left)); } .tabbar-item { @@ -1561,7 +1569,7 @@ textarea.input { } } -/* ── iPhone 12/13/14 standard (390pt wide) ────────────────── */ +/* ── Standard compact phones (390pt wide) ─────────────────── */ @media screen and (min-width: 376px) and (max-width: 393px) { :root { --header-h: 52px; @@ -1569,16 +1577,19 @@ textarea.input { } .page-header { - padding: 0 14px; + padding-left: max(14px, var(--safe-left)); + padding-right: max(14px, var(--safe-right)); padding-top: var(--safe-top); } .tabbar { - margin: 0 10px calc(6px + var(--safe-bottom)); + margin-right: max(10px, var(--safe-right)); + margin-bottom: calc(6px + var(--safe-bottom)); + margin-left: max(10px, var(--safe-left)); } } -/* ── iPhone 14 Pro / 15 / 16 (393pt, Dynamic Island) ──────── */ +/* ── Standard phones (Pixel 6–10/Pro, recent iPhones) ─────── */ @media screen and (min-width: 393px) and (max-width: 430px) { :root { --header-h: 54px; @@ -1586,7 +1597,7 @@ textarea.input { } } -/* ── iPhone Pro Max / Plus models (428-440pt) ─────────────── */ +/* ── Large phones and foldable cover screens ──────────────── */ @media screen and (min-width: 428px) and (max-width: 480px) { :root { --header-h: 56px; @@ -1776,25 +1787,6 @@ textarea.input { } } -/* ═══════════════════════════════════════════════════════════════ - Notch / Dynamic Island — Prevent Content Overlap - Uses @supports to only apply when env() is available (iOS 11+) - ═══════════════════════════════════════════════════════════════ */ -@supports (padding-top: env(safe-area-inset-top)) { - /* Ensure fixed/absolute positioned elements don't slip behind notch */ - .page-header { - padding-top: env(safe-area-inset-top, 0px); - } - - .chat-input-bar { - padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px)); - } - - .tabbar { - margin-bottom: calc(8px + env(safe-area-inset-bottom, 0px)); - } -} - /* ═══════════════════════════════════════════════════════════════ Login Page — Privacy Link ═══════════════════════════════════════════════════════════════ */ diff --git a/client/src/pages/Chat.tsx b/client/src/pages/Chat.tsx index 797d3ca..4e673ba 100644 --- a/client/src/pages/Chat.tsx +++ b/client/src/pages/Chat.tsx @@ -15,6 +15,7 @@ import StickerMedia from '../components/StickerMedia' import { decodeMessagePayload, encodeMessagePayload, type ReplyReference } from '../utils/messagePayload' import { cacheSticker, cacheStickerPack } from '../utils/stickerCache' import { readOfflineData, writeOfflineData } from '../utils/offlineCache' +import { useKeepAwake } from '../hooks/useKeepAwake' // Auto-delete options (seconds) const AUTO_DELETE_OPTIONS = [ @@ -40,6 +41,7 @@ const EMOJI_CATEGORIES = [ const RECENT_EMOJI_KEY = 'pp_recent_emoji' const STICKER_PACKS_CACHE_KEY = 'sticker-packs' const STICKER_PACK_CACHE_PREFIX = 'sticker-pack:' +const MAX_VOICE_DURATION_SECONDS = 120 function formatFileSize(bytes: number): string { if (bytes < 1024) return bytes + ' B' @@ -363,12 +365,14 @@ export default function Chat() { const recChunksRef = useRef([]) const recStartRef = useRef(0) const recIntervalRef = useRef(null) + const recTimeoutRef = useRef | null>(null) const mediaStreamRef = useRef(null) const recordVoiceModeRef = useRef<'normal'|'slow'|'fast'>('normal') const longPressTimerRef = useRef | null>(null) const longPressOriginRef = useRef<{ x: number; y: number } | null>(null) const suppressMessageClickRef = useRef(false) recordVoiceModeRef.current = recordVoiceMode + useKeepAwake(isRecording) const resizeInput = useCallback((element?: HTMLTextAreaElement | null) => { const textarea = element || inputRef.current @@ -383,6 +387,17 @@ export default function Chat() { resizeInput() }, [input, resizeInput]) + useEffect(() => () => { + clearInterval(recIntervalRef.current) + if (recTimeoutRef.current) clearTimeout(recTimeoutRef.current) + const recorder = mediaRecRef.current + if (recorder && recorder.state !== 'inactive') { + recorder.onstop = null + recorder.stop() + } + mediaStreamRef.current?.getTracks().forEach(track => track.stop()) + }, []) + const friend = friends.find(f => f.id === id) const group = groups.find(g => g.id === id) const chatName = isGroup ? (group?.name || id) : (friend?.nickname || id) @@ -880,7 +895,10 @@ export default function Chat() { const srcBuffer = await audioCtx.decodeAudioData(arrayBuf) const offline = new OfflineAudioContext( srcBuffer.numberOfChannels, - Math.ceil(srcBuffer.length / rate), + Math.min( + Math.ceil(srcBuffer.length / rate), + srcBuffer.sampleRate * MAX_VOICE_DURATION_SECONDS, + ), srcBuffer.sampleRate, ) const source = offline.createBufferSource() @@ -912,8 +930,14 @@ export default function Chat() { mediaRecRef.current = rec setIsRecording(true) recIntervalRef.current = setInterval(() => { - setRecordDuration(Math.floor((Date.now() - recStartRef.current) / 1000)) + setRecordDuration(Math.min( + MAX_VOICE_DURATION_SECONDS, + Math.floor((Date.now() - recStartRef.current) / 1000), + )) }, 500) + recTimeoutRef.current = setTimeout(() => { + void stopVoice() + }, MAX_VOICE_DURATION_SECONDS * 1000) } catch { alert(t('chat.mic_failed')) } @@ -923,23 +947,33 @@ export default function Chat() { const rec = mediaRecRef.current if (!rec || rec.state === 'inactive') return clearInterval(recIntervalRef.current) + if (recTimeoutRef.current) { + clearTimeout(recTimeoutRef.current) + recTimeoutRef.current = null + } setIsRecording(false) - const duration = Math.round((Date.now() - recStartRef.current) / 1000) + const duration = Math.min( + MAX_VOICE_DURATION_SECONDS, + Math.round((Date.now() - recStartRef.current) / 1000), + ) const mode = recordVoiceModeRef.current - rec.stop() rec.onstop = async () => { const raw = new Blob(recChunksRef.current, { type: 'audio/webm' }) try { const { blob, ext, mime, rateApplied } = await processVoiceBlob(raw, mode) const file = new File([blob], `voice_${Date.now()}.${ext}`, { type: mime }) // 变速会改变时长:实际时长 = 原时长 / rate - const finalDuration = Math.max(1, Math.round(duration / rateApplied)) + const finalDuration = Math.min( + MAX_VOICE_DURATION_SECONDS, + Math.max(1, Math.round(duration / rateApplied)), + ) const { url } = await uploadWithProgress(file, t('chat.uploading_voice')) sendMessage(url, 'voice', { url, duration: finalDuration }) } catch { alert(t('chat.upload_failed')) } } + rec.stop() rec.stream.getTracks().forEach(t => t.stop()) if (mediaStreamRef.current) { mediaStreamRef.current.getTracks().forEach(t => t.stop()) @@ -1188,6 +1222,7 @@ export default function Chat() {
{Math.floor(recordDuration / 60).toString().padStart(2, '0')}:{(recordDuration % 60).toString().padStart(2, '0')} + / 02:00
{t('chat.recording')}
diff --git a/client/src/pages/Profile.tsx b/client/src/pages/Profile.tsx index ed82d8f..1612214 100644 --- a/client/src/pages/Profile.tsx +++ b/client/src/pages/Profile.tsx @@ -14,7 +14,7 @@ import { Camera, ChevronLeft, ChevronRight, Smartphone, Check, Copy, KeyRound, S import { clearOfflineCache } from '../utils/offlineCache' type SubView = null | 'password' | 'avatar' | '2fa' | 'sessions' | 'language' | 'fingerprint' | 'myqr' | 'proxy' -const APP_VERSION = '2.2.9' +const APP_VERSION = '2.3.3' export default function Profile() { const { t } = useI18n() diff --git a/client/tsconfig.tsbuildinfo b/client/tsconfig.tsbuildinfo index aef51d4..a45d7ef 100644 --- a/client/tsconfig.tsbuildinfo +++ b/client/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/http.ts","./src/api/nativepush.ts","./src/api/onesignal.ts","./src/api/proxy-bridge.ts","./src/api/push.ts","./src/api/socket.ts","./src/api/sync.ts","./src/components/calloverlay.tsx","./src/components/groupcalloverlay.tsx","./src/components/notificationtoast.tsx","./src/components/qrcode.tsx","./src/components/stickermedia.tsx","./src/components/tabbar.tsx","./src/components/tgsplayer.tsx","./src/contexts/callcontext.tsx","./src/contexts/groupcallcontext.tsx","./src/crypto/groupcrypto.ts","./src/crypto/keystore.ts","./src/crypto/ratchet.ts","./src/hooks/useauth.ts","./src/hooks/useautodeletecleanup.ts","./src/hooks/usecall.ts","./src/hooks/usegroupcall.ts","./src/hooks/usei18n.ts","./src/hooks/usesocket.ts","./src/i18n/index.ts","./src/i18n/locales/de.ts","./src/i18n/locales/en.ts","./src/i18n/locales/es.ts","./src/i18n/locales/fr.ts","./src/i18n/locales/ja.ts","./src/i18n/locales/ko.ts","./src/i18n/locales/ru.ts","./src/i18n/locales/zh.ts","./src/pages/chat.tsx","./src/pages/chats.tsx","./src/pages/contacts.tsx","./src/pages/discover.tsx","./src/pages/groupinfo.tsx","./src/pages/login.tsx","./src/pages/moments.tsx","./src/pages/privacypolicy.tsx","./src/pages/profile.tsx","./src/pages/termsofuse.tsx","./src/pages/timeline.tsx","./src/pages/userprofile.tsx","./src/store/index.ts","./src/store/notificationstore.ts","./src/utils/messagepayload.ts","./src/utils/notification.ts","./src/utils/offlinecache.ts","./src/utils/platform.ts","./src/utils/session.ts","./src/utils/stickercache.ts"],"version":"5.7.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/http.ts","./src/api/nativepush.ts","./src/api/onesignal.ts","./src/api/proxy-bridge.ts","./src/api/push.ts","./src/api/socket.ts","./src/api/sync.ts","./src/components/callkeepawake.tsx","./src/components/calloverlay.tsx","./src/components/groupcalloverlay.tsx","./src/components/notificationtoast.tsx","./src/components/qrcode.tsx","./src/components/stickermedia.tsx","./src/components/tabbar.tsx","./src/components/tgsplayer.tsx","./src/contexts/callcontext.tsx","./src/contexts/groupcallcontext.tsx","./src/crypto/groupcrypto.ts","./src/crypto/keystore.ts","./src/crypto/ratchet.ts","./src/hooks/useauth.ts","./src/hooks/useautodeletecleanup.ts","./src/hooks/usecall.ts","./src/hooks/usegroupcall.ts","./src/hooks/usei18n.ts","./src/hooks/usekeepawake.ts","./src/hooks/usesocket.ts","./src/i18n/index.ts","./src/i18n/locales/de.ts","./src/i18n/locales/en.ts","./src/i18n/locales/es.ts","./src/i18n/locales/fr.ts","./src/i18n/locales/ja.ts","./src/i18n/locales/ko.ts","./src/i18n/locales/ru.ts","./src/i18n/locales/zh.ts","./src/pages/chat.tsx","./src/pages/chats.tsx","./src/pages/contacts.tsx","./src/pages/discover.tsx","./src/pages/groupinfo.tsx","./src/pages/login.tsx","./src/pages/moments.tsx","./src/pages/privacypolicy.tsx","./src/pages/profile.tsx","./src/pages/termsofuse.tsx","./src/pages/timeline.tsx","./src/pages/userprofile.tsx","./src/store/index.ts","./src/store/notificationstore.ts","./src/utils/messagepayload.ts","./src/utils/notification.ts","./src/utils/offlinecache.ts","./src/utils/platform.ts","./src/utils/session.ts","./src/utils/stickercache.ts"],"version":"5.7.3"} \ No newline at end of file