Repository navigation
Media
The Media module provides one interface for projected phone media and audio files stored locally on the V-Link device or mounted storage.
It is responsible for:
- Receiving metadata, artwork, playback state, and projection source from CarPlay or Android Auto.
- Presenting projected media in the Now Playing screen.
- Sending previous, play/pause, and next commands to the projection dongle.
- Browsing local and mounted storage through a protected backend API.
- Browsing a user-selected directory through the browser File System Access API.
- Playing supported local audio files with the native HTML audio element.
- Coordinating audio focus between projected and local media.
- Routing projected media, navigation, call, Siri, and alert audio.
- Applying navigation ducking to projected and local media.
- Configuring navigation volume, call volume, ducking intensity, and microphone gain.
Phone projection
|
v
Carlinkit / node-carplay
|
v
CarPlay.worker.ts
|
+-- media metadata/artwork --> Carplay.tsx --> Zustand media state
|
+-- PCM audio -------------> shared ring buffers --> PcmPlayer
|
+-- audio commands --------> routing, priority, ducking, mic control
|
<-- Now Playing controls / MMI commands
Mounted/local storage
|
+-- Flask /api/media -----------+
| |
+-- File System Access API -----+--> LocalMediaProvider --> <audio>
|
+-> NativePlayer
+-> audio focus
+-> navigation ducking
Music page
|
+-> projected Now Playing
+-> local file browser
+-> native local player
| File | Responsibility |
|---|---|
backend/media.py |
Defines the media API blueprint for safe roots, directory browsing, and audio-file streaming. |
backend/server.py |
Configures Flask and registers the media API blueprint. |
frontend/src/carplay/worker/CarPlay.worker.ts |
Receives projection messages and forwards media/audio data to React or shared audio buffers. |
frontend/src/carplay/Carplay.tsx |
Converts worker media/audio events into store updates and audio actions. |
frontend/src/carplay/mediaState.ts |
Defines projected media state and merges partial metadata/artwork updates. |
frontend/src/carplay/mediaCommands.ts |
Dispatches projected-media commands from UI components to the CarPlay runtime. |
frontend/src/carplay/useCarplayAudio.ts |
Creates PCM players, classifies routes, manages volume priority, ducking, and microphone gain. |
frontend/src/app/pages/music/Music.tsx |
Connects the Music page to projected media state. |
frontend/src/app/pages/music/components/NowPlaying.tsx |
Selects projected, local, browser, or empty media UI. |
frontend/src/app/pages/music/LocalMediaProvider.tsx |
Owns the native audio player, playlist, focus, persistence, shuffle, repeat, and folder access. |
frontend/src/app/pages/music/components/UsbMediaBrowser.tsx |
Browses backend-mounted locations or a browser-selected folder. |
frontend/src/app/pages/music/components/NativePlayer.tsx |
Renders local playback state and controls. |
frontend/src/app/pages/music/localMediaCommands.ts |
Routes global media keys to the native player. |
frontend/src/app/pages/settings/AudioSettings.tsx |
Renders audio sliders, microphone test, and reset controls. |
frontend/src/app/pages/settings/audioSettingsState.ts |
Persists and distributes audio settings and navigation-ducking events. |
frontend/src/store/Store.ts |
Stores projected media, projection source, and current audio focus. |
frontend/src/App.tsx |
Mounts the local-media provider and routes global key commands according to audio focus. |
The Zustand application store contains two pieces of state used by the Media module.
APP.system.carplay.media has this shape:
interface CarplayMediaState {
title: string
artist: string
album: string
appName: string
durationMs: number
positionMs: number
playbackStatus: number
artworkBase64: string | null
}The initial value contains empty strings, zero times/status, and no artwork.
APP.system.carplay.source identifies the connected projection protocol as CarPlay, Android Auto, or null.
APP.system.audioSource is either:
-
carplay: projected media owns normal media playback. -
local: the native HTML audio player owns normal media playback.
This value coordinates the two media players. It does not disable high-priority projected audio such as navigation, calls, Siri, or alerts.
When node-carplay reports a Plugged message, the projection worker reads phoneType:
-
AndroidAutoandAndroidMirrorbecomeAndroid Auto. - Other supported phone types become
CarPlay.
The worker sends a projectionSource message to the React runtime, which stores the source in APP.system.carplay.source. The Music page uses it as a fallback label when the media application name is unavailable.
The source is cleared when the phone or dongle disconnects.
CarPlay.worker.ts forwards node-carplay media messages to the React runtime. In Carplay.tsx, the media worker event extracts message.payload and passes it to mergeCarplayMedia().
The dongle sends metadata and artwork as separate payloads. The merge function deliberately updates only fields present in the new payload so a partial message does not erase previously received information.
For payload type === 1, these dongle fields are mapped:
| Dongle field | Store field |
|---|---|
MediaSongName |
title |
MediaArtistName |
artist |
MediaAlbumName |
album |
MediaAPPName |
appName |
MediaSongDuration |
durationMs |
MediaSongPlayTime |
positionMs |
MediaPlayStatus |
playbackStatus |
MediaPlayStatus is handled even though the installed node-carplay TypeScript definition does not declare that optional field.
Non-metadata payloads update artworkBase64 without clearing title, artist, album, timing, or status.
The Now Playing helper accepts an already complete data: URL. Otherwise, it treats the value as JPEG base64 and adds:
data:image/jpeg;base64,
Metadata does not always provide timely play status. Audio commands therefore also update it:
-
AudioMediaStartandAudioOutputStartset status to playing. -
AudioMediaStopandAudioOutputStopset status to stopped. - Navigation, calls, Siri, alerts, and unrelated commands leave the existing media status unchanged.
When the phone disconnects, the source and complete projected-media object are cleared.
Music.tsx reads projected media, phone connection, and source from the store and passes them to NowPlaying.
When projected audio owns focus, Now Playing displays:
- Album artwork or a music-note placeholder.
- Media app name, or projection source as fallback.
- Track title.
- Artist and album.
- Elapsed and total time.
- A progress bar derived from position/duration.
- Previous, play/pause, and next controls.
- A Local Media button.
Missing metadata is handled explicitly:
- Title falls back to “Now Playing”.
- Artist becomes “Unknown artist” when other media data exists.
- With no useful metadata, it displays “Metadata unavailable”.
- Invalid or zero duration produces zero progress and
0:00.
When no phone is connected, the page shows an empty state with an option to open Local Media.
UI controls call sendCarplayMediaCommand(), which dispatches the internal carplayMediaCommand event. The persistent CarPlay runtime receives the event and sends a keyCommand message to its worker.
Supported commands are:
prevplaypauseplayOrPausenext
The worker wraps the command in SendCommand and writes it to the dongle driver.
For responsive UI feedback, playOrPause optimistically toggles the stored playback status before confirmation arrives from the phone.
LocalMediaProvider is mounted around the running application in App.tsx. This keeps the native <audio> element and selected playlist alive while the user changes pages.
The provider owns:
- Current folder name and track list.
- Current track index.
- Playback error.
- Playing/paused state.
- Position and duration in seconds.
- Shuffle state and shuffle history.
- Repeat mode.
- The hidden HTML audio element.
- Object URLs created for browser-selected files.
Supported extensions are:
- AAC
- FLAC
- M4A
- MP3
- OGG
- Opus
- WAV
- WebM
For browser-selected files, a valid audio/* MIME type is also accepted.
There are two file-access strategies.
The production UI first requests media locations from the Python backend. By default, the available roots are existing directories among:
~/Music
/media
/mnt
This covers media stored in the Pi user's music directory and storage commonly mounted under /media or /mnt. Deployments can override this list through the Flask MEDIA_ROOTS configuration value.
The Choose Folder action uses window.showDirectoryPicker() when supported. This is useful in development or when the user needs to select a directory directly through Chromium.
Only files in the selected directory are loaded; this browser path does not recursively scan subdirectories. Files are sorted by name using numeric-aware ordering.
The selected directory handle is stored in IndexedDB when possible. On a later session, the provider queries its read permission:
- If permission is still granted, the directory is loaded automatically.
- Otherwise, the handle is retained and permission is requested the next time the user selects Choose Folder.
IndexedDB and persistent handles are optional. Folder selection and playback continue to work when storage persistence is unavailable.
The backend provides three HTTP endpoints on port 4001. These endpoints intentionally do not require authentication because V-Link is designed to run in an isolated, air-gapped environment; deployments on a shared network must add an appropriate access-control boundary.
Returns the currently available allowed roots:
[
{ "name": "Music", "path": "/home/pi/Music" },
{ "name": "media", "path": "/media" }
]Returns the selected directory and its immediate entries:
{
"name": "Album",
"path": "/media/usb/Album",
"entries": [
{ "kind": "directory", "name": "Disc 2", "path": "/media/usb/Album/Disc 2" },
{ "kind": "file", "name": "01 Track.mp3", "path": "/media/usb/Album/01 Track.mp3" }
]
}Hidden entries are omitted. Directories are sorted before files, then alphabetically. Directory and file checks do not follow symbolic links while listing.
Streams a supported audio file with Flask send_file(..., conditional=True). Conditional responses allow the browser media element to request byte ranges and seek efficiently.
Every requested path is normalized with realpath() and must equal an allowed root or remain below one. Requests escaping the allowed roots return HTTP 403. Missing directories/files and unsupported file extensions return HTTP 404.
This validation also prevents a path passed through a symbolic link from escaping the configured media roots.
UsbMediaBrowser attempts to load backend roots when it opens.
If the backend responds, the user can:
- Enter allowed roots and nested directories.
- See directories and supported audio files.
- Navigate back through a local path-history stack.
- Select a file, which turns the other files in that directory into the active playlist.
If the backend is unavailable, the component falls back to the browser-selected playlist and keeps the native Choose Folder action available.
The browser supports pointer input and MMI/key bindings. From the main Music view, Down opens the browser while Up remains reserved for switching top-level V-Link pages:
- Left/right changes the focused entry and wraps at the ends.
- Select enters a directory or starts a file.
- Back closes the browser and returns to the main Music view.
- The on-screen Back button moves to the previous backend directory, returns to roots, or closes the browser.
- The focused entry is scrolled into view.
When a local track is selected:
- If a projected phone session exists, V-Link sends
pauseto the projection session. -
APP.system.audioSourcechanges tolocalbefore playback begins. - The current playlist index and shuffle history are updated.
- The HTML audio element receives either an object URL or backend file URL.
- The chosen track identity is stored.
-
audio.play()starts playback.
Claiming audio focus before calling play() ensures projected media is muted before the first local audio sample reaches the speakers.
If local playback fails, focus returns to carplay and a user-facing error identifies the unsupported file.
When projected media later sends AudioMediaStart, focus switches back to carplay. The provider watches that value and pauses an active local player.
When local focus is active and a track exists, NowPlaying renders NativePlayer instead of projected metadata.
The native player displays:
- The filename without its extension as title.
- The selected folder name as secondary text.
- Remaining and total time.
- Shuffle, previous, play/pause, next, and repeat controls.
- A Browse Files action.
Local files currently do not parse embedded title, artist, album, or artwork tags. The filename and folder name provide the visible metadata.
Shuffle chooses a random track different from the current one when possible. A history stack supports returning to previously selected shuffled tracks.
Repeat cycles through:
-
off: stop at the end of the final track. -
all: return to the first track after the final track. -
one: restart the current track when it ends.
Without shuffle, Previous selects the preceding track or restarts the first track. Next advances normally and respects repeat-all at the end.
The native player has five focusable controls. Left/right moves focus with wraparound; Select activates the focused control; Back opens the file browser.
Global next, previous, and play/pause bindings are intercepted in App.tsx while local audio owns focus. They are dispatched as localMediaCommand events and are not duplicated into the phone projection session.
The provider uses three localStorage entries:
| Key | Stored value |
|---|---|
v-link-local-media-current-track |
Track kind, name, and backend source path when available. |
v-link-local-media-shuffle |
Shuffle enabled/disabled. |
v-link-local-media-repeat |
off, all, or one. |
Browser directory handles are stored separately in IndexedDB database v-link-local-media, object store directories, key selected.
The current playback position and playing/paused state are not persisted.
For a backend track, the provider requests its parent directory, rebuilds the playlist, and restores the selected file.
For a browser-selected file, the saved directory is reopened when permission allows and the matching filename is selected.
Automatic resume waits until projection-device detection is complete. It resumes local playback only when no dongle or phone session is connected. This prevents restored local media from competing with a projection session during application startup.
Browser autoplay restrictions may reject automatic resume. If that happens, audio focus returns to carplay without breaking manual local playback.
Projected PCM audio is handled separately from media metadata.
The projection worker creates one shared ring buffer for each unique combination of:
- Decoded sample frequency.
- Channel count.
- Dongle
audioType.
createAudioPlayerKey() combines those values into a stable route key. useCarplayPcmPlayers() creates one PcmPlayer for each key and transfers its SharedArrayBuffer to the worker.
When audio arrives before its player exists, the worker queues up to eight PCM frames for that route, requests a buffer from React, and flushes the pending frames after registration.
The pure audio-routing reducer tracks the roles active on each route key:
| Route class | Triggering commands | Applied level |
|---|---|---|
| Media |
AudioMediaStart, unclassified AudioOutputStart
|
Full volume, muted when local media owns focus, ducked during navigation. |
| Navigation | AudioNaviStart |
Configured navigation volume. |
| Call | AudioPhonecallStart |
Configured call volume. |
| Priority |
AudioSiriStart, AudioAlertStart
|
Full volume. |
A route can retain its media identity while a transient navigation, call, Siri, or alert role is active. This is important when the dongle mixes multiple roles into the same PCM route: the matching stop command removes only its own role, allowing the previous media policy to resume. AudioOutputStart is treated as media only if the key is not already known exclusively as navigation, call, or priority audio.
All matching stop commands update route state. Navigation activity is derived across every route, so stopping one navigation route does not release global ducking while another remains active. Route state is reset when the phone disconnects.
getTargetVolume() is the single policy entry point for dongle volume messages, route transitions, focus changes, and live settings changes. Its explicit priority order is navigation, calls, Siri/alerts, then focused or ducked media.
The effective policy implemented by the current code is:
- Navigation, calls, Siri, and alerts remain audible at their configured/full levels.
- Local and projected music are mutually exclusive normal media sources.
- Starting local playback asks the phone to pause and mutes projected media routes.
- Starting projected media claims
carplayfocus and pauses local playback. - Navigation instructions reduce whichever normal media source is active.
The route sets are independent because the dongle may use different PCM formats or audioType values for each purpose.
Calls and priority routes receive their own levels, but the code does not explicitly duck every separate media route when a call, Siri, or alert begins. Any additional mixing behavior in those cases may be controlled by the phone/dongle stream layout.
navigationDuckingAmount is expressed as 0–100%. It is converted to output amplitude with:
remaining = 1 - amount / 100
output = remaining * remainingExamples:
| Setting | Output amplitude |
|---|---|
| 0% | 1.00 |
| 50% | 0.25 |
| 80% (default) | 0.04 |
| 100% | 0.00 |
The squared curve makes high ducking settings reduce media more strongly than a linear percentage.
On AudioNaviStart:
- Navigation is ramped to the configured navigation volume over 200 ms.
- Known projected-media routes are ramped to the ducked level over 200 ms.
- If local focus is active, projected media remains fully muted.
On AudioNaviStop, projected media returns to full volume over 650 ms, unless local audio still owns focus.
If navigation and media share the same PCM route key, that route is reclassified as navigation. V-Link logs that projected-media ducking for the shared route is controlled by the phone, because one PCM player cannot independently attenuate two signals already mixed into the same stream.
Navigation start/stop also dispatches the global v-link-navigation-ducking event.
LocalMediaProvider listens for it and ramps the HTML audio element:
- Down to the calculated ducked level over 200 ms.
- Back to full volume over 650 ms.
The ramp uses smoothstep interpolation to avoid abrupt volume changes.
useCarplayPcmPlayers() ramps projected PCM players with requestAnimationFrame. Starting a new ramp for the same route cancels its previous animation.
Each step interpolates from the tracked current volume to a clamped 0–1 target using smoothstep easing. PcmPlayer.volume() receives a very small duration because its duration parameter delays automation; V-Link performs the actual ramp itself.
Dongle volumeDuration messages are respected, but the target is overridden when route policy requires navigation volume, call volume, priority level, local-focus muting, or navigation ducking.
The Settings sidebar contains a dedicated Audio page rendered by AudioSettings.tsx.
| Setting | Default | Range | Purpose |
|---|---|---|---|
| Navigation instructions | 80% | 0–100% | Volume of projected navigation audio routes. |
| Phone calls | 85% | 0–100% | Volume of projected phone-call routes. |
| Navigation ducking intensity | 80% | 0–100% | Reduction applied to active music during navigation. |
| Microphone input gain | 0 dB | -20 to +2 dB | Gain applied before microphone audio is sent to projection. |
Slider changes save immediately and dispatch an in-page change event. useAudioSettings() subscribers receive updated values without reloading.
Restore Defaults resets all four values at once.
Audio settings are stored in browser localStorage under:
v-link-audio-settings
They are not currently part of backend app.json and are therefore local to the Chromium profile. Clearing browser storage/profile data resets them to defaults.
If stored JSON is missing or invalid, the default settings are used.
The CarPlay audio hook requests a microphone MediaStream and builds this Web Audio graph:
microphone MediaStream
|
v
MediaStreamAudioSourceNode
|
v
GainNode
|
v
MediaStreamDestination
|
v
WebMicrophone -> MessagePort -> projection worker -> dongle
The dB setting is converted to linear gain with:
gain = 10 ** (microphoneGainDb / 20)Live changes use setTargetAtTime() with a short time constant, avoiding a hard discontinuity in microphone gain.
The dongle controls capture through startRecordAudio and stopRecordAudio commands.
The Audio Settings page can independently request microphone access and build a temporary source → gain → analyser graph.
It samples the time-domain signal every animation frame, displays a peak meter, warns above 92% that the input is clipping, and suggests occasional peaks around 75%.
Stopping the test or leaving the page cancels animation, stops microphone tracks, closes the AudioContext, and resets the meter.
The Media page reuses dongle_bindings from app.json for projected and local controls.
- From the main Music view, Down opens Local Media.
- In the browser, Left/Right changes the focused item, Select opens it, and Back returns to the main Music view.
- In projected Now Playing, Left/Right focuses Previous, Play/Pause, or Next and Select activates it.
- In Native Player, Left/Right/Select/Back operate its five controls and browser action.
- Up switches the top-level V-Link page from every Music subview.
- Global Next/Previous work outside the CarPlay view.
- When local focus is active, global media actions are routed only to local playback.
- When projected focus is active, they are sent to the CarPlay worker/dongle.
Content.tsx does not use the page-switch binding while the Music view is active, preventing the Music page's Select/navigation interaction from being mistaken for view cycling.
NowPlaying chooses its screen in this order:
- If the file browser is open, render
UsbMediaBrowser. - If local audio owns focus and a local track exists, render
NativePlayer. - If no projected phone is connected, render the empty state and Local Media option.
- Otherwise, render projected Now Playing.
This ordering lets local playback remain visible even without a phone and keeps browsing available from every Media state.
Projected audio route decisions are logged with command, audioType, decodeType, and derived route key. A specific warning is logged when navigation and media share a PCM route.
Frontend logs are sent through the /log Socket.IO namespace and written by the Pi backend to:
~/v-link/logs/logfile.txt
Useful messages include:
- Audio player creation.
- Audio route classification.
- Shared navigation/media route detection.
- Microphone initialization failures.
- Projection metadata received in the browser console.
Local browser/file errors are primarily shown in the Media UI rather than sent to the backend logger.
Current media-specific automated coverage is concentrated in frontend/src/carplay/mediaState.test.ts. It verifies:
- Partial metadata merges without clearing previous values.
- Artwork updates preserve metadata.
- Media/output audio start and stop commands derive playback status correctly.
- Unrelated audio commands do not change playback status.
The H.264 renderer and projection session state have separate tests under frontend/src/carplay.
Useful validation commands are:
cd frontend
npm run typecheck
npm testThe following areas currently require integration/manual testing because they depend on browser APIs, mounted storage, or real projection audio:
- Backend storage browsing and streaming.
- File System Access permissions and IndexedDB restoration.
- HTML audio codec support.
- Audio focus transitions.
- Navigation ducking and route sharing.
- Microphone gain and clipping meter.
- Confirm the phone media application publishes metadata through the projection protocol.
- Inspect the browser console for “Media metadata received”.
- Check whether artwork arrives separately from type-1 metadata.
- Audio may still play when the phone application does not expose title/artist data.
- Verify the directory exists under
~/Music,/media, or/mnt. - Confirm the V-Link backend user can read the directory.
- Check
/api/media/rootsand/api/media/browseresponses. - Hidden files and unsupported extensions are intentionally omitted.
- Confirm the extension is supported by the module.
- Browser codec support may vary even for an accepted extension.
- Open the browser console/network panel and inspect
/api/media/file. - Verify the file remains mounted and readable.
- Check IndexedDB availability and File System Access API support.
- Chromium may require directory permission again after restart.
- Browser storage or profile cleanup removes saved handles and localStorage values.
- Inspect
APP.system.audioSourceand projectedAudioMediaStartevents. - Confirm local track selection sends
pauseto the phone. - Check route-classification logs to ensure the projected PCM stream is known as media.
- An unusual dongle audio route may remain unclassified until an appropriate start command arrives.
- Confirm the dongle sends
AudioNaviStartandAudioNaviStop. - Check the navigation ducking setting.
- Inspect logs for a shared navigation/media PCM route.
- Shared routes depend on phone-side mixing for projected media, although local media still responds to the global ducking event.
- Confirm the route was classified by
AudioPhonecallStartorAudioNaviStart. - Check the route key in frontend logs.
- Verify the Audio Settings values in the active Chromium profile.
- Grant Chromium microphone permission.
- Check the selected OS audio input.
- Inspect logs for microphone initialization errors.
- Use the meter to verify signal before debugging the dongle path.
- Local tracks use filenames rather than embedded metadata tags.
- Local artwork is not extracted.
- Browser-selected directories are scanned only at their top level.
- Playback position is not restored between sessions.
- Audio settings are browser-local rather than stored in
app.json. - Calls, Siri, and alert starts receive priority levels, but do not explicitly duck all independent media routes.
- Route cleanup is explicit for navigation stop; other stop commands do not currently reclassify route sets.
- Backend media roots default to
~/Music,/media, and/mntunless overridden through Flask configuration.