From d6f21a9770491a2ec8820168ca58fa69a7e3f9f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Feb 2026 08:18:57 +0000 Subject: [PATCH 1/3] Add Media Session API integration for lock screen queue controls Enables iPhone/Android users to control climb queue navigation (next/previous) from the lock screen, Control Center, or notification shade media controls. Uses a silent audio loop to activate the media session, with metadata showing current climb name, grade, and setter. https://claude.ai/code/session_01Q1guH3MapHSsEk2BhmwTXm --- .../components/play-view/play-view-drawer.tsx | 18 ++ packages/web/app/hooks/use-media-session.ts | 234 ++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 packages/web/app/hooks/use-media-session.ts 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..8f773e7e 100644 --- a/packages/web/app/components/play-view/play-view-drawer.tsx +++ b/packages/web/app/components/play-view/play-view-drawer.tsx @@ -16,8 +16,10 @@ 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 { useFavorite, ClimbActions } from '../climb-actions'; import { ShareBoardButton } from '../board-page/share-button'; import { TickButton } from '../logbook/tick-button'; @@ -82,6 +84,8 @@ const PlayViewDrawer: React.FC = ({ climbUuid: currentClimb?.uuid ?? '', }); + const { toggle: toggleMediaSession, isActive: isMediaSessionActive } = useMediaSession(); + const currentQueueIndex = currentClimbQueueItem ? queue.findIndex(item => item.uuid === currentClimbQueueItem.uuid) : -1; @@ -270,6 +274,20 @@ const PlayViewDrawer: React.FC = ({ {/* LED */} + {/* Media session controls (lock screen next/prev) */} + + + + {/* More actions */} { 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..da284c88 --- /dev/null +++ b/packages/web/app/hooks/use-media-session.ts @@ -0,0 +1,234 @@ +'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); + }, []); + + // Toggle activation + const toggle = useCallback(() => { + if (isActive) { + deactivate(); + } else { + activate(); + } + }, [isActive, activate, deactivate]); + + // 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, + /** Toggle media session on/off - must be called from user gesture to activate */ + toggle, + /** Whether the media session is currently active */ + isActive, + }; +} From 83fe758ab35d37d1f27dfd3058746d352e69eb08 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Feb 2026 08:19:41 +0000 Subject: [PATCH 2/3] Update package-lock.json after npm install https://claude.ai/code/session_01Q1guH3MapHSsEk2BhmwTXm --- package-lock.json | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) 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": { From 3484773ba850ab4233d2ca72732648a172b9e3a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Feb 2026 08:43:30 +0000 Subject: [PATCH 3/3] Add confirmation dialog before enabling media session controls Warns users that enabling lock screen controls will take over media controls and may pause any music currently playing. The acknowledgment is persisted in IndexedDB so the dialog only shows once. Subsequent toggles activate/ deactivate directly. https://claude.ai/code/session_01Q1guH3MapHSsEk2BhmwTXm --- .../components/play-view/play-view-drawer.tsx | 57 ++++++++++++++++++- packages/web/app/hooks/use-media-session.ts | 11 ---- 2 files changed, 55 insertions(+), 13 deletions(-) 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 8f773e7e..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'; @@ -20,6 +25,7 @@ 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'; @@ -84,7 +90,36 @@ const PlayViewDrawer: React.FC = ({ climbUuid: currentClimb?.uuid ?? '', }); - const { toggle: toggleMediaSession, isActive: isMediaSessionActive } = useMediaSession(); + 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) @@ -276,7 +311,7 @@ const PlayViewDrawer: React.FC = ({ {/* Media session controls (lock screen next/prev) */} = ({ )} + + {/* 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 index da284c88..057ca292 100644 --- a/packages/web/app/hooks/use-media-session.ts +++ b/packages/web/app/hooks/use-media-session.ts @@ -110,15 +110,6 @@ export function useMediaSession() { setIsActive(false); }, []); - // Toggle activation - const toggle = useCallback(() => { - if (isActive) { - deactivate(); - } else { - activate(); - } - }, [isActive, activate, deactivate]); - // Update metadata when the current climb changes useEffect(() => { if (typeof window === 'undefined' || !('mediaSession' in navigator)) return; @@ -226,8 +217,6 @@ export function useMediaSession() { activate, /** Deactivate media session controls */ deactivate, - /** Toggle media session on/off - must be called from user gesture to activate */ - toggle, /** Whether the media session is currently active */ isActive, };