Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

71 changes: 71 additions & 0 deletions packages/web/app/components/play-view/play-view-drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -82,6 +90,37 @@ const PlayViewDrawer: React.FC<PlayViewDrawerProps> = ({
climbUuid: currentClimb?.uuid ?? '',
});

const { activate: activateMediaSession, deactivate: deactivateMediaSession, isActive: isMediaSessionActive } = useMediaSession();
const [showMediaSessionDialog, setShowMediaSessionDialog] = useState(false);
const hasAcknowledgedRef = useRef<boolean | null>(null);

// Load the acknowledgment preference on mount
useEffect(() => {
getPreference<boolean>('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;
Expand Down Expand Up @@ -270,6 +309,20 @@ const PlayViewDrawer: React.FC<PlayViewDrawerProps> = ({
{/* LED */}
<SendClimbToBoardButton buttonType="text" />

{/* Media session controls (lock screen next/prev) */}
<IconButton
onClick={handleMediaSessionToggle}
aria-label={isMediaSessionActive ? 'Disable lock screen controls' : 'Enable lock screen controls'}
color={isMediaSessionActive ? 'primary' : 'default'}
sx={
isMediaSessionActive
? { backgroundColor: themeTokens.colors.primary, color: 'common.white', '&:hover': { backgroundColor: themeTokens.colors.primaryHover } }
: undefined
}
>
<HeadsetOutlined />
</IconButton>

{/* More actions */}
<IconButton
onClick={() => {
Expand Down Expand Up @@ -418,6 +471,24 @@ const PlayViewDrawer: React.FC<PlayViewDrawerProps> = ({
)}
</div>
</SwipeableDrawer>

{/* Media session confirmation dialog */}
<Dialog
open={showMediaSessionDialog}
onClose={() => setShowMediaSessionDialog(false)}
>
<DialogTitle>Enable lock screen controls?</DialogTitle>
<DialogContent>
<DialogContentText>
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&apos;s currently playing.
</DialogContentText>
</DialogContent>
<DialogActions>
<MuiButton onClick={() => setShowMediaSessionDialog(false)}>Cancel</MuiButton>
<MuiButton onClick={handleMediaSessionConfirm} variant="contained">Enable</MuiButton>
</DialogActions>
</Dialog>
</SwipeableDrawer>
);
};
Expand Down
223 changes: 223 additions & 0 deletions packages/web/app/hooks/use-media-session.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLAudioElement | null>(null);
const blobUrlRef = useRef<string | null>(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,
};
}
Loading