diff --git a/package-lock.json b/package-lock.json index 81d9771a..a9730d37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4865,7 +4865,7 @@ "version": "1.57.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.57.0" @@ -8359,7 +8359,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -11838,7 +11838,7 @@ "version": "1.57.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.57.0" @@ -11857,7 +11857,7 @@ "version": "1.57.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -12195,7 +12195,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12215,7 +12214,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", - "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -12749,7 +12747,6 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "dev": true, "license": "MIT" }, "node_modules/semver": { diff --git a/packages/web/app/components/play-view/play-view-drawer.tsx b/packages/web/app/components/play-view/play-view-drawer.tsx index 4968ae5b..18e67279 100644 --- a/packages/web/app/components/play-view/play-view-drawer.tsx +++ b/packages/web/app/components/play-view/play-view-drawer.tsx @@ -5,6 +5,11 @@ import MuiBadge from '@mui/material/Badge'; import MuiButton from '@mui/material/Button'; import IconButton from '@mui/material/IconButton'; import Stack from '@mui/material/Stack'; +import Dialog from '@mui/material/Dialog'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogContentText from '@mui/material/DialogContentText'; +import DialogActions from '@mui/material/DialogActions'; import SyncOutlined from '@mui/icons-material/SyncOutlined'; import FavoriteBorderOutlined from '@mui/icons-material/FavoriteBorderOutlined'; import Favorite from '@mui/icons-material/Favorite'; @@ -16,8 +21,11 @@ import DeleteOutlined from '@mui/icons-material/DeleteOutlined'; import EditOutlined from '@mui/icons-material/EditOutlined'; import CloseOutlined from '@mui/icons-material/CloseOutlined'; import HistoryOutlined from '@mui/icons-material/HistoryOutlined'; +import HeadsetOutlined from '@mui/icons-material/HeadsetOutlined'; import dynamic from 'next/dynamic'; import { useQueueContext } from '../graphql-queue'; +import { useMediaSession } from '@/app/hooks/use-media-session'; +import { getPreference, setPreference } from '@/app/lib/user-preferences-db'; import { useFavorite, ClimbActions } from '../climb-actions'; import { ShareBoardButton } from '../board-page/share-button'; import { TickButton } from '../logbook/tick-button'; @@ -82,6 +90,37 @@ const PlayViewDrawer: React.FC = ({ climbUuid: currentClimb?.uuid ?? '', }); + const { activate: activateMediaSession, deactivate: deactivateMediaSession, isActive: isMediaSessionActive } = useMediaSession(); + const [showMediaSessionDialog, setShowMediaSessionDialog] = useState(false); + const hasAcknowledgedRef = useRef(null); + + // Load the acknowledgment preference on mount + useEffect(() => { + getPreference('mediaSessionAcknowledged').then((val) => { + hasAcknowledgedRef.current = val === true; + }); + }, []); + + const handleMediaSessionToggle = useCallback(() => { + if (isMediaSessionActive) { + deactivateMediaSession(); + return; + } + // First time: show warning dialog + if (!hasAcknowledgedRef.current) { + setShowMediaSessionDialog(true); + return; + } + activateMediaSession(); + }, [isMediaSessionActive, activateMediaSession, deactivateMediaSession]); + + const handleMediaSessionConfirm = useCallback(() => { + setShowMediaSessionDialog(false); + hasAcknowledgedRef.current = true; + setPreference('mediaSessionAcknowledged', true); + activateMediaSession(); + }, [activateMediaSession]); + const currentQueueIndex = currentClimbQueueItem ? queue.findIndex(item => item.uuid === currentClimbQueueItem.uuid) : -1; @@ -270,6 +309,20 @@ const PlayViewDrawer: React.FC = ({ {/* LED */} + {/* Media session controls (lock screen next/prev) */} + + + + {/* More actions */} { @@ -418,6 +471,24 @@ const PlayViewDrawer: React.FC = ({ )} + + {/* Media session confirmation dialog */} + setShowMediaSessionDialog(false)} + > + Enable lock screen controls? + + + This lets you skip between climbs from your lock screen or Control Center. + It will take over your media controls and may pause any music that's currently playing. + + + + setShowMediaSessionDialog(false)}>Cancel + Enable + + ); }; diff --git a/packages/web/app/hooks/use-media-session.ts b/packages/web/app/hooks/use-media-session.ts new file mode 100644 index 00000000..057ca292 --- /dev/null +++ b/packages/web/app/hooks/use-media-session.ts @@ -0,0 +1,223 @@ +'use client'; + +import { useEffect, useRef, useCallback, useState } from 'react'; +import { useQueueContext } from '../components/graphql-queue'; + +/** + * Hook that integrates with the Media Session API to allow controlling + * the climb queue from OS-level media controls (iOS Control Center, + * Android notification shade, lock screen, etc.) + * + * Maps media controls to queue actions: + * - Next Track -> Next climb in queue + * - Previous Track -> Previous climb in queue + * + * Requires a silent audio element to be "playing" to activate the + * media session on iOS Safari. + */ +export function useMediaSession() { + const { + currentClimb, + getNextClimbQueueItem, + getPreviousClimbQueueItem, + setCurrentClimbQueueItem, + viewOnlyMode, + } = useQueueContext(); + + const audioRef = useRef(null); + const blobUrlRef = useRef(null); + const [isActive, setIsActive] = useState(false); + + // Create a silent WAV audio element + const createSilentAudio = useCallback(() => { + if (typeof window === 'undefined' || audioRef.current) return; + + // Generate a 1-second silent WAV file (44-byte header + PCM silence) + const sampleRate = 8000; + const numSamples = sampleRate; + const dataSize = numSamples * 2; // 16-bit = 2 bytes per sample + const fileSize = 44 + dataSize; + + const buffer = new ArrayBuffer(fileSize); + const view = new DataView(buffer); + + const writeString = (offset: number, str: string) => { + for (let i = 0; i < str.length; i++) { + view.setUint8(offset + i, str.charCodeAt(i)); + } + }; + + writeString(0, 'RIFF'); + view.setUint32(4, fileSize - 8, true); + writeString(8, 'WAVE'); + writeString(12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); // PCM format + view.setUint16(22, 1, true); // Mono + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * 2, true); + view.setUint16(32, 2, true); + view.setUint16(34, 16, true); + writeString(36, 'data'); + view.setUint32(40, dataSize, true); + // Data bytes are already 0 (silence) + + const blob = new Blob([buffer], { type: 'audio/wav' }); + const url = URL.createObjectURL(blob); + blobUrlRef.current = url; + + const audio = new Audio(url); + audio.loop = true; + audio.volume = 0.01; // Near-silent but nonzero for iOS + audioRef.current = audio; + }, []); + + // Activate the media session by playing silent audio. + // Must be called from a user gesture on iOS. + const activate = useCallback(() => { + if (typeof window === 'undefined' || !('mediaSession' in navigator)) return; + if (isActive) return; + + createSilentAudio(); + const audio = audioRef.current; + if (!audio) return; + + const playPromise = audio.play(); + if (playPromise) { + playPromise + .then(() => { + setIsActive(true); + }) + .catch(() => { + // Autoplay blocked - needs user gesture + }); + } + }, [createSilentAudio, isActive]); + + // Deactivate the media session + const deactivate = useCallback(() => { + const audio = audioRef.current; + if (audio) { + audio.pause(); + audio.removeAttribute('src'); + audio.load(); + audioRef.current = null; + } + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + setIsActive(false); + }, []); + + // Update metadata when the current climb changes + useEffect(() => { + if (typeof window === 'undefined' || !('mediaSession' in navigator)) return; + if (!isActive) return; + + if (currentClimb) { + navigator.mediaSession.metadata = new MediaMetadata({ + title: currentClimb.name || 'Unknown Climb', + artist: currentClimb.difficulty + ? `${currentClimb.difficulty} ยท ${currentClimb.setter_username}` + : currentClimb.setter_username, + album: 'Boardsesh', + artwork: [ + { + src: '/favicon.ico', + sizes: '48x48', + type: 'image/x-icon', + }, + ], + }); + } else { + navigator.mediaSession.metadata = new MediaMetadata({ + title: 'No climb selected', + artist: 'Boardsesh', + album: 'Boardsesh', + }); + } + }, [currentClimb, isActive]); + + // Register action handlers when active + useEffect(() => { + if (typeof window === 'undefined' || !('mediaSession' in navigator)) return; + if (!isActive) return; + + const handleNextTrack = () => { + if (viewOnlyMode) return; + const nextClimb = getNextClimbQueueItem(); + if (nextClimb) { + setCurrentClimbQueueItem(nextClimb); + } + }; + + const handlePreviousTrack = () => { + if (viewOnlyMode) return; + const previousClimb = getPreviousClimbQueueItem(); + if (previousClimb) { + setCurrentClimbQueueItem(previousClimb); + } + }; + + // Keep playback state as "playing" so the controls stay visible + const handlePlay = () => { + audioRef.current?.play().catch(() => {}); + navigator.mediaSession.playbackState = 'playing'; + }; + + const handlePause = () => { + // Don't actually pause - keep the session alive so controls remain visible + navigator.mediaSession.playbackState = 'playing'; + if (audioRef.current?.paused) { + audioRef.current.play().catch(() => {}); + } + }; + + try { + navigator.mediaSession.setActionHandler('nexttrack', handleNextTrack); + navigator.mediaSession.setActionHandler('previoustrack', handlePreviousTrack); + navigator.mediaSession.setActionHandler('play', handlePlay); + navigator.mediaSession.setActionHandler('pause', handlePause); + navigator.mediaSession.playbackState = 'playing'; + } catch { + // Some handlers may not be supported on all browsers + } + + return () => { + try { + navigator.mediaSession.setActionHandler('nexttrack', null); + navigator.mediaSession.setActionHandler('previoustrack', null); + navigator.mediaSession.setActionHandler('play', null); + navigator.mediaSession.setActionHandler('pause', null); + } catch { + // Cleanup errors can be ignored + } + }; + }, [isActive, getNextClimbQueueItem, getPreviousClimbQueueItem, setCurrentClimbQueueItem, viewOnlyMode]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.removeAttribute('src'); + audioRef.current.load(); + audioRef.current = null; + } + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + }; + }, []); + + return { + /** Call from a user gesture to activate media session controls */ + activate, + /** Deactivate media session controls */ + deactivate, + /** Whether the media session is currently active */ + isActive, + }; +}