Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Install the app on an Android device and open the displayed address in your car'
- **Browser display and touch control**: CarPlay streaming, touch input, and fullscreen display.
- **Selectable streaming modes**: Choose WebRTC video passthrough (H.264 / HEVC) or image compatibility mode in settings, with JPEG / WebP format and image quality from 1 to 100. Image streaming retains JPEG compatibility fallback and overlaps up to two frames while audio and touch keep their existing routes.
- **Optional browser audio**: After pairing, the browser can play CarPlay audio. The browser microphone can provide CarPlay voice input through a trusted HTTPS page; Android handles audio and microphone input by default.
- **Browser media information and controls**: Browsers supporting the Media Session API receive song metadata, artwork, playback state and progress, and can send play/pause, track controls and capability-gated seek requests. System media cards and steering-wheel buttons depend on the head-unit browser and phone app; seek requires a known timeline and phone support. Refresh the page after updating the app. See [media session integration](docs/MEDIA_SESSION.md#browser--head-unit-media-session).
- **Easy pairing**: Scan the QR code or enter a code once. Remembered browsers restore pairing automatically and retain a launch button for selecting options before streaming.
- **iPhone connection management**: USB and wireless connections, connection status, and session management.
- **Local network access**: Automatically detects available network addresses for browser access.
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ WheelPlay 是一款 Android 局域网 CarPlay 服务端应用,可将 iPhone
- **浏览器显示与触控**:支持 CarPlay 画面串流、触摸操作和全屏显示。
- **可选串流技术**:设置中可选 WebRTC 视频直通(H.264 / HEVC)或图片兼容模式,并调整 JPEG / WebP 格式和 1–100 的图片质量。图片串流保留 JPEG 兼容回退,最多两帧并行传输,音频与触控保持原有路径。详见[图片串流与多缓冲](docs/IMAGE_STREAM.md)。
- **可选浏览器音频**:配对后可改由浏览器播放 CarPlay 音频;浏览器麦克风可通过可信 HTTPS 页面接入 CarPlay 语音输入,默认仍由 Android 处理。
- **浏览器媒体信息与控制**:支持 Media Session API 的车机浏览器可接收歌曲、歌手、专辑、封面、播放状态与进度,并回传播放、暂停、切歌和拖动进度操作。系统媒体卡片和方向盘按键是否可用取决于车机浏览器;拖动进度需手机应用支持且歌曲时长、当前位置已知。更新 App 后刷新网页生效,详见 [媒体会话说明](docs/MEDIA_SESSION.md#browser--head-unit-media-session)。
- **便捷配对**:首次扫描车机网页二维码或输入配对码,之后自动恢复已记住的浏览器配对;启动前仍可选择网页选项。
- **iPhone 连接管理**:支持 USB 与无线连接,查看连接状态并管理会话。
- **局域网访问**:自动识别可用网络地址,便于车机访问。
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ class BrowserPageRuntimeTest {
"app.js",
"text.js",
"browser-audio.js",
"browser-media-session.js",
"browser-location.js",
"audio-worklet.js",
"style.css",
)) {
Expand Down
13 changes: 8 additions & 5 deletions common/src/main/assets/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ export function createControlOutbox(socket, {
if (kind === 'move' && tail && tail.kind === 'move') tail.json = json;
else if (touches.length < 64) touches.push({ kind, json });
else { fail(); return false; }
} else if (['rtc-start', 'rtc-answer', 'rtc-ready', 'rtc-fallback', 'phone-connect', 'image-format', 'location-start', 'location-stop', 'location-clear'].includes(data.type)) {
} else if (['rtc-start', 'rtc-answer', 'rtc-ready', 'rtc-fallback', 'phone-connect', 'image-format', 'location-start', 'location-stop', 'location-clear', 'media-command'].includes(data.type)) {
if (signals.length >= 8 || json.length > 32768) { fail(); return false; }
if (data.type.startsWith('location-')) locationFix = null;
signals.push(json);
Expand Down Expand Up @@ -675,6 +675,7 @@ export function createRtcReceiver(createRenderer, {

import { createBrowserAudio } from './browser-audio.js';
import { createBrowserLocation } from './browser-location.js';
import { createBrowserMediaSession } from './browser-media-session.js';

if (typeof document !== 'undefined') {
const $ = id => document.getElementById(id);
Expand Down Expand Up @@ -937,6 +938,7 @@ if (typeof document !== 'undefined') {
}
return outbox ? outbox.send(data, kind) : false;
};
const browserMedia = createBrowserMediaSession({ send });
const browserAudio = createBrowserAudio({
send,
sendBinary: packet => {
Expand Down Expand Up @@ -1061,7 +1063,7 @@ if (typeof document !== 'undefined') {
clearTimeout(retry); clearInterval(heartbeat);
if (socket) { const old = socket; socket = null; old.close(); }
presentation.close(); cleanupRtc(); cleanupImage();
browserAudio.close(); $('audio-message').hidden = true;
browserMedia.close(); browserAudio.close(); $('audio-message').hidden = true;
browserLocation.close();
$('topbar').hidden = false; $('pairing').hidden = false; $('display').hidden = true;
syncDisplayZoom();
Expand All @@ -1074,7 +1076,7 @@ if (typeof document !== 'undefined') {
$('connect').disabled = true;
$('status').textContent = paired ? t("web_reconnecting") : t("web_connecting");
const auth = pairingToken ? `token=${encodeURIComponent(pairingToken)}` : `code=${encodeURIComponent($('code').value)}`;
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/stream?${auth}&media=2&images=3`);
const ws = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/stream?${auth}&media=2&images=3&nowPlaying=1`);
ws.binaryType = 'arraybuffer';
const mode = launchMode;
socket = ws;
Expand All @@ -1088,7 +1090,7 @@ if (typeof document !== 'undefined') {
cancelMove(); pointers.clear(); presentation.close(); ws.close();
} });
paired = true; attempts = 0; lastStatus = Date.now();
browserAudio.attach();
browserMedia.open(); browserAudio.attach();
browserLocation.attach();
cleanupRtc();
presentation.open();
Expand Down Expand Up @@ -1145,6 +1147,7 @@ if (typeof document !== 'undefined') {
if (socket !== ws || ws.readyState !== WebSocket.OPEN) return;
if (typeof event.data === 'string') {
const data = JSON.parse(event.data);
if (browserMedia.receive(data)) return;
if (browserAudio.control(data)) return;
if (data.type === 'rtc-offer' || data.type === 'rtc-stop') {
if (data.type === 'rtc-stop') rtcRequested = false;
Expand Down Expand Up @@ -1213,7 +1216,7 @@ if (typeof document !== 'undefined') {
if (outbox) { outbox.close(); outbox = null; }
cancelMove();
socket = null; pointers.clear(); clearInterval(heartbeat); presentation.close(); cleanupRtc(); cleanupImage();
browserAudio.close(); $('audio-message').hidden = true;
browserMedia.close(); browserAudio.close(); $('audio-message').hidden = true;
browserLocation.close();
if (stopped) return;
if (!paired || ++attempts > 8) {
Expand Down
142 changes: 142 additions & 0 deletions common/src/main/assets/web/browser-media-session.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Media Session is optional: missing/partial implementations must not interrupt video or audio.
export function createBrowserMediaSession({
send,
session = globalThis.navigator?.mediaSession,
Metadata = globalThis.MediaMetadata,
baseUrl = globalThis.location?.href,
now = () => performance.now(),
schedule = setTimeout, cancel = clearTimeout,
} = {}) {
const actions = ['play', 'pause', 'stop', 'previoustrack', 'nexttrack'];
const seekActions = ['seekto', 'seekbackward', 'seekforward'];
let opened = false, state = null, revision = -1, epoch = 0, controls = null, metadataKey = null;
let request = 0, pending = null, queuedSeek = null, seekTimer = null, stateAt = 0, seekTarget = null;
const safely = operation => { try { operation(); } catch (_) { /* Optional browser capability. */ } };

function clear() {
epoch++; controls = null; metadataKey = null; pending = null;
cancel(seekTimer); seekTimer = null; queuedSeek = null; seekTarget = null;
if (!session) return;
safely(() => { session.metadata = null; });
safely(() => { session.playbackState = 'none'; });
safely(() => session.setPositionState());
for (const action of [...actions, ...seekActions]) safely(() => session.setActionHandler(action, null));
}

const seekable = () => state?.canSeek === true && Number.isFinite(state.durationMs) && state.durationMs > 0 &&
Number.isFinite(state.positionMs);

function targetPosition() {
if (!seekTarget || now() - seekTarget.at >= 3000) return null;
const elapsed = state.status === 'playing' ? Math.max(0, now() - seekTarget.at) *
(Number.isFinite(state.speed) && state.speed > 0 ? state.speed : 1) : 0;
return Math.min(state.durationMs, seekTarget.positionMs + elapsed);
}

function flushSeek() {
seekTimer = null;
if (queuedSeek === null || !opened || !state?.available || !seekable()) return;
if (pending && now() - pending.at < 3000) {
seekTimer = schedule(flushSeek, 150); return;
}
const positionMs = queuedSeek; queuedSeek = null;
const id = ++request;
pending = { id, at: now(), positionMs };
seekTarget = pending;
const accepted = send({ type: 'media-command', requestId: id, sessionId: state.sessionId,
connectionGeneration: state.connectionGeneration, trackRevision: state.trackRevision,
action: 'seekto', positionMs });
if (!accepted) { pending = null; seekTarget = null; }
// The phone's subsequent position report is authoritative, including when it rejects a seek.
}

function installControls() {
const key = `${state.sessionId}/${state.connectionGeneration}/${state.trackRevision}/${seekable()}`;
if (controls === key) return;
controls = key; pending = null;
cancel(seekTimer); seekTimer = null; queuedSeek = null; seekTarget = null;
const expectedEpoch = ++epoch;
for (const action of seekActions) safely(() => session.setActionHandler(action, seekable() ? (details = {}) => {
if (!opened || !state?.available || epoch !== expectedEpoch || !seekable()) return;
let target;
if (action === 'seekto') {
if (!Number.isFinite(details.seekTime)) return;
target = details.seekTime * 1000;
} else {
const offset = details.seekOffset === undefined ? 10 : details.seekOffset;
if (!Number.isFinite(offset) || offset <= 0) return;
const elapsed = state.status === 'playing' ? Math.max(0, now() - stateAt) *
(Number.isFinite(state.speed) && state.speed > 0 ? state.speed : 1) : 0;
const position = queuedSeek ?? targetPosition() ?? Math.min(state.durationMs, state.positionMs + elapsed);
target = position + (action === 'seekforward' ? 1 : -1) * offset * 1000;
}
if (!Number.isFinite(target)) return;
queuedSeek = Math.round(Math.max(0, Math.min(state.durationMs, 0xffffffff, target)));
cancel(seekTimer);
// fastSeek is only a browser hint. Coalesce both intermediate and final absolute requests.
seekTimer = schedule(flushSeek, 150);
} : null));
for (const action of actions) safely(() => session.setActionHandler(action, () => {
if (!opened || !state?.available || epoch !== expectedEpoch || pending && now() - pending.at < 3000) return;
const id = ++request;
pending = { id, at: now() };
const accepted = send({ type: 'media-command', requestId: id, sessionId: state.sessionId,
connectionGeneration: state.connectionGeneration, action });
if (!accepted) pending = null;
// SENT acknowledges transport only. Playback state always comes from the iPhone.
}));
}

function render() {
if (!session) return;
if (!state.connected || !state.available) { clear(); return; }
let artwork = [];
if (state.artworkUrl) safely(() => {
const url = new URL(state.artworkUrl, baseUrl);
if (url.origin === new URL(baseUrl).origin && url.pathname === '/now-playing/artwork')
artwork = [{ src: url.href, type: 'image/jpeg' }];
});
const metadata = { title: state.title || state.appName || '', artist: state.artist || '', album: state.album || '', artwork };
const key = JSON.stringify([state.sessionId, state.connectionGeneration, state.trackRevision, metadata]);
if (metadataKey !== key) safely(() => {
session.metadata = new Metadata(metadata);
metadataKey = key;
});
const playback = state.status === 'playing' ? 'playing' : state.status === 'paused' ? 'paused' : 'none';
safely(() => { session.playbackState = playback; });
safely(() => {
if (playback === 'none' || !Number.isFinite(state.durationMs) || state.durationMs <= 0 || !Number.isFinite(state.positionMs)) {
session.setPositionState(); return;
}
// Android's elapsedRealtime is not the browser clock. The server sends an extrapolated position.
session.setPositionState({ duration: state.durationMs / 1000,
position: Math.max(0, Math.min(state.durationMs, state.positionMs)) / 1000,
playbackRate: Number.isFinite(state.speed) && state.speed > 0 ? state.speed : 1 });
});
installControls();
}

return {
open() { clear(); opened = true; state = null; revision = -1; },
close() { opened = false; state = null; revision = -1; clear(); },
receive(data) {
if (data.type === 'media-command-result') {
if (seekTarget?.id === data.requestId && data.result !== 'sent') seekTarget = null;
if (opened && pending?.id === data.requestId) pending = null;
return true;
}
if (data.type !== 'now-playing') return false;
if (!opened || typeof data.sessionId !== 'string' || !Number.isSafeInteger(data.revision) || data.revision <= revision ||
!Number.isSafeInteger(data.connectionGeneration) || !Number.isSafeInteger(data.trackRevision)) return true;
revision = data.revision;
state = data; stateAt = now();
// A transport receipt can precede the phone's position update. Retain the target for
// successive relative actions, but never use it as the displayed playback position.
const target = targetPosition();
if (target === null || Number.isFinite(data.positionMs) && Math.abs(data.positionMs - target) <= 1000)
seekTarget = null;
render();
return true;
},
};
}
15 changes: 15 additions & 0 deletions common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,15 @@ import android.os.Looper
import android.os.PowerManager
import com.shilapi.xcertplay.host.R
import com.shilapi.xcertplay.media.CarPlayMediaSessionBridge
import com.shilapi.xcertplay.media.CarPlayMediaSource
import com.shilapi.xcertplay.media.MediaCommand
import com.shilapi.xcertplay.media.MediaCommandResult
import com.shilapi.xcertplay.media.NowPlayingState
import com.shilapi.xcertplay.shared.R as UiR
import com.shilapi.xcertplay.web.RootAccess
import com.shilapi.xcertplay.web.WebListenSettings
import com.shilapi.xcertplay.web.WebSession
import java.util.concurrent.CompletableFuture

/** Keeps the connection and its media session alive while no dashboard is visible. */
class DiPlaySessionService : Service() {
Expand Down Expand Up @@ -63,6 +66,7 @@ class DiPlaySessionService : Service() {
currentArtwork = artwork
if (!destroyed) updateNotification()
}
browserMediaBridge = media
sessionSubscription =
CarPlayBackgroundSession.subscribe { _ ->
main.post {
Expand Down Expand Up @@ -264,6 +268,7 @@ class DiPlaySessionService : Service() {

override fun onDestroy() {
destroyed = true
if (browserMediaBridge === media) browserMediaBridge = null
sessionSubscription?.close()
media.close()
runWebAction { WebSession.stop() }
Expand All @@ -274,6 +279,16 @@ class DiPlaySessionService : Service() {
}

companion object {
// Only accessed on the main thread, like the native MediaSession callbacks.
private var browserMediaBridge: CarPlayMediaSessionBridge? = null
internal fun browserMediaCommand(source: CarPlayMediaSource, generation: Long, command: MediaCommand): CompletableFuture<MediaCommandResult> =
browserMediaBridge?.browserCommand(source, generation, command)
?: CompletableFuture.completedFuture(MediaCommandResult.UNAVAILABLE)

internal fun browserMediaSeek(source: CarPlayMediaSource, generation: Long, track: Long, positionMs: Long): CompletableFuture<MediaCommandResult> =
browserMediaBridge?.browserSeek(source, generation, track, positionMs)
?: CompletableFuture.completedFuture(MediaCommandResult.UNAVAILABLE)

const val ACTION_STOP = "com.shihab.diplay.DISCONNECT"
private const val ACTION_MEDIA = "com.shilapi.xcertplay.MEDIA_COMMAND"
private const val EXTRA_COMMAND = "command"
Expand Down
Loading
Loading