diff --git a/README.md b/README.md index f9473e1..d1f3bb1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.zh-CN.md b/README.zh-CN.md index b85de3b..c659461 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 与无线连接,查看连接状态并管理会话。 - **局域网访问**:自动识别可用网络地址,便于车机访问。 diff --git a/common/src/androidTest/java/com/shilapi/xcertplay/web/BrowserPageRuntimeTest.kt b/common/src/androidTest/java/com/shilapi/xcertplay/web/BrowserPageRuntimeTest.kt index 1791d1c..93c1a2d 100644 --- a/common/src/androidTest/java/com/shilapi/xcertplay/web/BrowserPageRuntimeTest.kt +++ b/common/src/androidTest/java/com/shilapi/xcertplay/web/BrowserPageRuntimeTest.kt @@ -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", )) { diff --git a/common/src/main/assets/web/app.js b/common/src/main/assets/web/app.js index 89111a3..102d377 100644 --- a/common/src/main/assets/web/app.js +++ b/common/src/main/assets/web/app.js @@ -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); @@ -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); @@ -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 => { @@ -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(); @@ -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; @@ -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(); @@ -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; @@ -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) { diff --git a/common/src/main/assets/web/browser-media-session.js b/common/src/main/assets/web/browser-media-session.js new file mode 100644 index 0000000..caa320b --- /dev/null +++ b/common/src/main/assets/web/browser-media-session.js @@ -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; + }, + }; +} diff --git a/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt b/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt index 412bff7..cd61653 100644 --- a/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt +++ b/common/src/main/java/com/shilapi/xcertplay/DiPlaySessionService.kt @@ -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() { @@ -63,6 +66,7 @@ class DiPlaySessionService : Service() { currentArtwork = artwork if (!destroyed) updateNotification() } + browserMediaBridge = media sessionSubscription = CarPlayBackgroundSession.subscribe { _ -> main.post { @@ -264,6 +268,7 @@ class DiPlaySessionService : Service() { override fun onDestroy() { destroyed = true + if (browserMediaBridge === media) browserMediaBridge = null sessionSubscription?.close() media.close() runWebAction { WebSession.stop() } @@ -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 = + browserMediaBridge?.browserCommand(source, generation, command) + ?: CompletableFuture.completedFuture(MediaCommandResult.UNAVAILABLE) + + internal fun browserMediaSeek(source: CarPlayMediaSource, generation: Long, track: Long, positionMs: Long): CompletableFuture = + 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" diff --git a/common/src/main/java/com/shilapi/xcertplay/media/CarPlayMediaSessionBridge.kt b/common/src/main/java/com/shilapi/xcertplay/media/CarPlayMediaSessionBridge.kt index 905d39b..777f758 100644 --- a/common/src/main/java/com/shilapi/xcertplay/media/CarPlayMediaSessionBridge.kt +++ b/common/src/main/java/com/shilapi/xcertplay/media/CarPlayMediaSessionBridge.kt @@ -13,6 +13,7 @@ import android.media.session.MediaSession import android.media.session.PlaybackState import android.os.Handler import android.os.Looper +import java.util.concurrent.CompletableFuture import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicBoolean @@ -85,7 +86,7 @@ internal class CarPlayMediaSessionBridge( override fun onSkipToNext() = dispatch(MediaCommand.NEXT) override fun onSkipToPrevious() = dispatch(MediaCommand.PREVIOUS) override fun onSeekTo(pos: Long) { - if (!closed && generation == commandGeneration && state.canSeek) source?.seekTo(pos) + if (!closed && generation == commandGeneration && state.canSeek) source?.let { browserSeek(it, state.connectionGeneration, state.trackRevision, pos) } } }, main) } @@ -126,12 +127,35 @@ internal class CarPlayMediaSessionBridge( } fun command(command: MediaCommand) { - if (closed || !state.available) return - val current = source?.mediaSnapshot() ?: return - if (!current.available || current.connectionGeneration != state.connectionGeneration) return + dispatchCommand(command) + } + + /** Called on the main thread; browser controls share native focus and pause handling. */ + fun browserCommand(expected: CarPlayMediaSource, generation: Long, command: MediaCommand): CompletableFuture { + if (source !== expected || state.connectionGeneration != generation) + return CompletableFuture.completedFuture(MediaCommandResult.UNAVAILABLE) + return dispatchCommand(command) + } + + fun browserSeek(expected: CarPlayMediaSource, generation: Long, track: Long, positionMs: Long): CompletableFuture { + if (closed || source !== expected || state.connectionGeneration != generation || state.trackRevision != track) + return CompletableFuture.completedFuture(MediaCommandResult.UNAVAILABLE) + val current = expected.mediaSnapshot() + if (!current.available || current.connectionGeneration != generation || current.trackRevision != track) + return CompletableFuture.completedFuture(MediaCommandResult.UNAVAILABLE) + if (!current.canSeek) return CompletableFuture.completedFuture(MediaCommandResult.UNSUPPORTED) + // Seeking does not imply play, request focus, or release the pause gate. + return expected.seekTo(positionMs, generation, track) + } + + private fun dispatchCommand(command: MediaCommand): CompletableFuture { + fun unavailable() = CompletableFuture.completedFuture(MediaCommandResult.UNAVAILABLE) + if (closed || !state.available) return unavailable() + val current = source?.mediaSnapshot() ?: return unavailable() + if (!current.available || current.connectionGeneration != state.connectionGeneration) return unavailable() if (command == MediaCommand.PLAY || command == MediaCommand.TOGGLE && !state.playing) { focusBlocked = false; pausePending = false; ++pauseRequest - if (!remote && !requestFocus()) return + if (!remote && !requestFocus()) return unavailable() } if (command == MediaCommand.PAUSE || command == MediaCommand.TOGGLE && state.playing) { pausePending = true @@ -144,12 +168,13 @@ internal class CarPlayMediaSessionBridge( } }, 3000) } - source?.sendMediaCommand(command, state.connectionGeneration) + return source?.sendMediaCommand(command, state.connectionGeneration) ?: unavailable() } private fun apply(next: NowPlayingState) { val wasPaused = state.status == PhonePlaybackStatus.PAUSED || state.status == PhonePlaybackStatus.STOPPED - if (state.connectionGeneration != next.connectionGeneration || state.connected && !next.connected) { + if (state.connectionGeneration != next.connectionGeneration || state.trackRevision != next.trackRevision || + state.connected && !next.connected) { ++commandGeneration installCallbacks() } diff --git a/common/src/main/java/com/shilapi/xcertplay/web/BrowserMediaBridge.kt b/common/src/main/java/com/shilapi/xcertplay/web/BrowserMediaBridge.kt new file mode 100644 index 0000000..5e18da3 --- /dev/null +++ b/common/src/main/java/com/shilapi/xcertplay/web/BrowserMediaBridge.kt @@ -0,0 +1,218 @@ +package com.shilapi.xcertplay.web + +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import com.shilapi.xcertplay.CarPlayBackgroundSession +import com.shilapi.xcertplay.DiPlaySessionService +import com.shilapi.xcertplay.media.CarPlayMediaSource +import com.shilapi.xcertplay.media.MediaArtwork +import com.shilapi.xcertplay.media.MediaCommand +import com.shilapi.xcertplay.media.MediaCommandResult +import com.shilapi.xcertplay.media.NowPlayingState +import java.util.UUID +import java.util.concurrent.CompletableFuture +import java.util.concurrent.atomic.AtomicBoolean +import org.json.JSONObject + +/** One paired viewer. All binding/state/command work is serialized on main, never on iAP2. */ +internal class BrowserMediaBridge( + private val send: (String) -> Unit, + private val currentSource: () -> CarPlayMediaSource? = { CarPlayBackgroundSession.snapshot()?.controller }, + private val observeSource: (() -> Unit) -> AutoCloseable = { listener -> CarPlayBackgroundSession.subscribe { listener() } }, + private val dispatch: (CarPlayMediaSource, Long, MediaCommand) -> CompletableFuture = DiPlaySessionService::browserMediaCommand, + private val dispatchSeek: (CarPlayMediaSource, Long, Long, Long) -> CompletableFuture = DiPlaySessionService::browserMediaSeek, + private val main: Handler = Handler(Looper.getMainLooper()), + private val clock: () -> Long = SystemClock::elapsedRealtime, +) : AutoCloseable { + private val closed = AtomicBoolean(false) + private var sourceSubscription: AutoCloseable? = null + private var mediaSubscription: AutoCloseable? = null + private var source: CarPlayMediaSource? = null + private var binding = "" + private var revision = 0L + private var state = NowPlayingState() + private var artwork: MediaArtwork? = null + private data class Cover(val id: String, val bytes: ByteArray, val source: CarPlayMediaSource?, + val generation: Long, val track: Long, val artwork: MediaArtwork) + @Volatile private var cover: Cover? = null + private var pendingCommand: Long? = null + private var lastRequest = 0L + private var lastCommandAt: Long? = null + + private data class Seek(val id: Long, val session: String, val generation: Long, val track: Long, + val positionMs: Long, val source: CarPlayMediaSource, val authorized: () -> Boolean) + private var queuedSeek: Seek? = null + private val seekTask = Runnable { flushSeek() } + + fun start() { main.post { + if (!closed.get() && sourceSubscription == null) { + sourceSubscription = observeSource { main.post { bind() } } + bind() + } + } } + + private fun bind() { + if (closed.get()) return + val next = currentSource() + if (binding.isNotEmpty() && source === next) return + mediaSubscription?.close() + source = next + binding = UUID.randomUUID().toString() + val expected = binding + pendingCommand = null + cancelSeek() + publish(NowPlayingState()) + mediaSubscription = next?.subscribeMediaState { snapshot -> main.post { + if (!closed.get() && binding == expected && currentSource() === next) publish(snapshot) + } } + } + + private fun publish(next: NowPlayingState) { + val boundary = next.connectionGeneration != state.connectionGeneration || next.trackRevision != state.trackRevision + if (boundary || !next.canSeek) cancelSeek() + if (boundary || next.artwork !== artwork || !next.available) { + artwork = next.artwork.takeIf { next.available } + cover = artwork?.let { Cover(UUID.randomUUID().toString(), it.bytes(), source, + next.connectionGeneration, next.trackRevision, it) } + } + state = next + send(JSONObject().put("type", "now-playing").put("sessionId", binding) + .put("revision", ++revision).put("connectionGeneration", next.connectionGeneration) + .put("trackRevision", next.trackRevision).put("connected", next.connected) + .put("available", next.available).put("title", next.title ?: JSONObject.NULL) + .put("artist", next.artist ?: JSONObject.NULL).put("album", next.album ?: JSONObject.NULL) + .put("appName", next.appName ?: JSONObject.NULL) + .put("status", next.status.name.lowercase()).put("durationMs", next.durationMs ?: JSONObject.NULL) + .put("positionMs", next.positionAt(clock()) ?: JSONObject.NULL).put("speed", next.speed) + .put("canSeek", next.canSeek) + .put("artworkUrl", cover?.let { "/now-playing/artwork?id=${it.id}" } ?: JSONObject.NULL) + .toString()) + } + + /** Random capability is scoped to this viewer and the currently published image; never cached. */ + fun artwork(id: String?): ByteArray? { + if (closed.get() || id == null) return null + val image = cover?.takeIf { it.id == id } ?: return null + if (currentSource() !== image.source) return null + val current = image.source?.mediaSnapshot() ?: return null + return image.bytes.takeIf { current.available && current.connectionGeneration == image.generation && + current.trackRevision == image.track && current.artwork === image.artwork } + } + + fun command(data: JSONObject, authorized: () -> Boolean = { true }) { + val requestId = data.getLong("requestId") + require(requestId in 1..9_007_199_254_740_991L) + val sessionId = data.getString("sessionId") + val generation = data.getLong("connectionGeneration") + if (data.getString("action") == "seekto") { + fun integer(name: String): Long { + val value = data.get(name) + require(value is Number) + val number = value.toDouble() + require(number.isFinite() && number >= 0 && number <= 9_007_199_254_740_991.0 && number == number.toLong().toDouble()) + return number.toLong() + } + val position = integer("positionMs") + val track = integer("trackRevision") + main.post { + if (closed.get() || !authorized()) return@post + val current = source + if (requestId <= lastRequest || sessionId != binding || generation != state.connectionGeneration || + track != state.trackRevision || current == null || currentSource() !== current || !state.available) { + result(requestId, MediaCommandResult.UNAVAILABLE) + return@post + } + lastRequest = requestId + if (!state.canSeek) { result(requestId, MediaCommandResult.UNSUPPORTED); return@post } + cancelSeek(MediaCommandResult.SUPERSEDED) + queuedSeek = Seek(requestId, sessionId, generation, track, position, current, authorized) + // Keep the last position from a drag, including when another control is in flight. + main.postDelayed(seekTask, 150) + } + return + } + val command = when (data.getString("action")) { + "play" -> MediaCommand.PLAY + "pause", "stop" -> MediaCommand.PAUSE + "nexttrack" -> MediaCommand.NEXT + "previoustrack" -> MediaCommand.PREVIOUS + else -> error("Unsupported media action") + } + main.post { + // Pairing can be revoked after the socket reader posts the command. + if (closed.get() || !authorized()) return@post + val current = source + val now = clock() + if (requestId <= lastRequest || sessionId != binding || generation != state.connectionGeneration || + current == null || currentSource() !== current || !state.available || pendingCommand != null || + lastCommandAt?.let { now - it < 150 } == true) { + result(requestId, MediaCommandResult.UNAVAILABLE) + return@post + } + lastRequest = requestId + lastCommandAt = now + pendingCommand = requestId + val future = try { dispatch(current, generation, command) } + catch (_: Exception) { CompletableFuture.completedFuture(MediaCommandResult.FAILED) } + future.whenComplete { outcome, error -> main.post { + if (!closed.get() && binding == sessionId && pendingCommand == requestId) { + pendingCommand = null + result(requestId, if (currentSource() !== current || current.mediaSnapshot().connectionGeneration != generation) + MediaCommandResult.UNAVAILABLE else if (error != null) MediaCommandResult.FAILED else outcome) + } + } } + } + } + + private fun cancelSeek(outcome: MediaCommandResult = MediaCommandResult.UNAVAILABLE) { + main.removeCallbacks(seekTask) + queuedSeek?.let { if (!closed.get()) result(it.id, outcome) } + queuedSeek = null + } + + private fun flushSeek() { + val request = queuedSeek ?: return + if (closed.get() || !request.authorized()) { cancelSeek(); return } + val current = request.source.mediaSnapshot() + if (binding != request.session || currentSource() !== request.source || + current.connectionGeneration != request.generation || current.trackRevision != request.track || !current.available) { + cancelSeek(); return + } + if (!current.canSeek) { cancelSeek(MediaCommandResult.UNSUPPORTED); return } + val remaining = lastCommandAt?.let { 150 - (clock() - it) } ?: 0 + if (pendingCommand != null || remaining > 0) { + main.postDelayed(seekTask, maxOf(remaining, 150)); return + } + queuedSeek = null + pendingCommand = request.id + lastCommandAt = clock() + val future = try { dispatchSeek(request.source, request.generation, request.track, request.positionMs) } + catch (_: Exception) { CompletableFuture.completedFuture(MediaCommandResult.FAILED) } + future.whenComplete { outcome, error -> main.post { + if (!closed.get() && binding == request.session && pendingCommand == request.id) { + pendingCommand = null + val latest = request.source.mediaSnapshot() + result(request.id, if (currentSource() !== request.source || latest.connectionGeneration != request.generation || + latest.trackRevision != request.track) MediaCommandResult.UNAVAILABLE + else if (error != null) MediaCommandResult.FAILED else outcome) + } + } } + } + + private fun result(id: Long, result: MediaCommandResult) { + send(JSONObject().put("type", "media-command-result").put("requestId", id) + .put("result", result.name.lowercase()).toString()) + } + + override fun close() { + if (!closed.compareAndSet(false, true)) return + cover = null + main.post { + cancelSeek() + mediaSubscription?.close(); mediaSubscription = null + sourceSubscription?.close(); sourceSubscription = null + source = null; artwork = null; state = NowPlayingState(); pendingCommand = null + } + } +} diff --git a/common/src/main/java/com/shilapi/xcertplay/web/LanWebServer.kt b/common/src/main/java/com/shilapi/xcertplay/web/LanWebServer.kt index 8748729..90a512a 100644 --- a/common/src/main/java/com/shilapi/xcertplay/web/LanWebServer.kt +++ b/common/src/main/java/com/shilapi/xcertplay/web/LanWebServer.kt @@ -20,6 +20,7 @@ internal class LanWebServer( port: Int = WebListenSettings.DEFAULT_HTTP_PORT, private val secure: Boolean = false, val pairing: QrPairing = QrPairing(), + private val mediaBridgeFactory: ((String) -> Unit) -> BrowserMediaBridge = { BrowserMediaBridge(send = it) }, ) : NanoWSD("0.0.0.0", port) { init { require(port == 0 || port in 1024..65535) { @@ -116,6 +117,15 @@ internal class LanWebServer( if (session.uri.startsWith("/pair/")) return servePairing(session) if (session.method != Method.GET) return response(Response.Status.METHOD_NOT_ALLOWED, "GET only") + if (session.uri == "/now-playing/artwork") { + val bytes = viewer?.artwork(session.parameters["id"]?.firstOrNull()) + ?: return response(Response.Status.NOT_FOUND, "Not found") + return newFixedLengthResponse(Response.Status.OK, "image/jpeg", bytes.inputStream(), bytes.size.toLong()).apply { + addHeader("Cache-Control", "private, no-store") + addHeader("X-Content-Type-Options", "nosniff") + addHeader("Referrer-Policy", "no-referrer") + } + } if (session.uri == "/i18n.js") return newFixedLengthResponse( Response.Status.OK, @@ -167,6 +177,7 @@ internal class LanWebServer( "/style.css" -> "style.css" to "text/css; charset=utf-8" "/browser-audio.js" -> "browser-audio.js" to "text/javascript; charset=utf-8" "/browser-location.js" -> "browser-location.js" to "text/javascript; charset=utf-8" + "/browser-media-session.js" -> "browser-media-session.js" to "text/javascript; charset=utf-8" "/audio-worklet.js" -> "audio-worklet.js" to "text/javascript; charset=utf-8" else -> return response(Response.Status.NOT_FOUND, "Not found") } @@ -368,6 +379,16 @@ internal class LanWebServer( } private inner class Client(handshake: IHTTPSession) : WebSocket(handshake) { + private val nowPlayingEnabled = handshake.parameters["nowPlaying"]?.firstOrNull() == "1" + private val media = mediaBridgeFactory { text -> + if (!ended.get() && !closing.get() && viewer === this && authorized()) { + val kind = if (JSONObject(text).getString("type") == "now-playing") + WebStreamSender.Control.NOW_PLAYING else WebStreamSender.Control.MEDIA_COMMAND_RESULT + reply(kind, text) + } + } + fun artwork(id: String?): ByteArray? = + if (nowPlayingEnabled && !ended.get() && !closing.get() && authorized()) media.artwork(id) else null private val token = handshake.parameters["token"]?.firstOrNull()?.takeIf { it.isNotEmpty() } private val mediaVersion = handshake.parameters["media"]?.firstOrNull()?.toIntOrNull()?.takeIf { it in 2..3 } ?: 1 private val imageProtocol = if (mediaVersion == 2 && handshake.parameters["images"]?.firstOrNull() == "3") 3 else mediaVersion @@ -586,6 +607,7 @@ internal class LanWebServer( }, ) } + if (nowPlayingEnabled) media.start() sender.frameAvailable() } @@ -613,6 +635,10 @@ internal class LanWebServer( when (data.getString("type")) { "location-start", "location-stop", "location-clear", "location" -> WebSession.location.control(this, data) + "media-command" -> { + require(nowPlayingEnabled) + media.command(data) { viewer === this && !closing.get() && authorized() } + } "audio-ready" -> { val playback = data.getBoolean("playback") val microphone = data.getBoolean("microphone") @@ -805,6 +831,7 @@ internal class LanWebServer( private fun cleanup() { if (!ended.compareAndSet(false, true)) return + media.close() stopRtc() synchronized(this@LanWebServer) { if (viewer === this) { diff --git a/common/src/main/java/com/shilapi/xcertplay/web/WebStreamSender.kt b/common/src/main/java/com/shilapi/xcertplay/web/WebStreamSender.kt index cf6918f..1199f91 100644 --- a/common/src/main/java/com/shilapi/xcertplay/web/WebStreamSender.kt +++ b/common/src/main/java/com/shilapi/xcertplay/web/WebStreamSender.kt @@ -17,7 +17,7 @@ internal class WebStreamSender( private val versionedVideo: Boolean = false, private val imageProtocol: Int = if (versionedVideo) 2 else 1, ) : Closeable { - enum class Control { STATUS, PHONE_CONNECT, INPUT_UNAVAILABLE, BUSY, PONG, CLOSE, RTC, MIC_CONFIG, MIC_STOP, AUDIO_STOP, AUDIO_RESET, AUDIO_ROUTE } + enum class Control { STATUS, PHONE_CONNECT, INPUT_UNAVAILABLE, BUSY, PONG, CLOSE, RTC, MIC_CONFIG, MIC_STOP, AUDIO_STOP, AUDIO_RESET, AUDIO_ROUTE, NOW_PLAYING, MEDIA_COMMAND_RESULT } private data class Outgoing(val frame: WebSocketFrame, val afterSend: () -> Unit = {}) private val lock = Any() private val controls = linkedMapOf() diff --git a/common/src/test/java/com/shilapi/xcertplay/media/CarPlayMediaSessionBridgeTest.kt b/common/src/test/java/com/shilapi/xcertplay/media/CarPlayMediaSessionBridgeTest.kt index 9b98c9f..3216b02 100644 --- a/common/src/test/java/com/shilapi/xcertplay/media/CarPlayMediaSessionBridgeTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/media/CarPlayMediaSessionBridgeTest.kt @@ -17,6 +17,23 @@ import java.util.concurrent.CompletableFuture @RunWith(RobolectricTestRunner::class) @Config(sdk = [29], shadows = [RecordingMediaSession::class, LinkedMediaController::class, LinkedTransportControls::class]) class CarPlayMediaSessionBridgeTest { + @Test fun browserControlsShareNativePauseGatingAndRejectRetiredSources() { + val source = Source(); val output = Output() + val bridge = CarPlayMediaSessionBridge(RuntimeEnvironment.getApplication()) { _, _, _ -> } + try { + bridge.bind(source, output); source.update(playing().copy(connectionGeneration = 7)); idle() + assertEquals(MediaCommandResult.UNAVAILABLE, bridge.browserCommand(Source(), 7, MediaCommand.PAUSE).get()) + assertEquals(MediaCommandResult.UNAVAILABLE, bridge.browserCommand(source, 6, MediaCommand.PAUSE).get()) + assertTrue(source.sent.isEmpty()) + assertEquals(MediaCommandResult.SENT, bridge.browserCommand(source, 7, MediaCommand.PAUSE).get()) + assertFalse(output.allowed) + assertEquals(listOf(MediaCommand.PAUSE), source.sent) + assertEquals(PlaybackState.STATE_PLAYING, MediaController(RuntimeEnvironment.getApplication(), bridge.session.sessionToken).playbackState!!.state) + bridge.close() + assertEquals(MediaCommandResult.UNAVAILABLE, bridge.browserCommand(source, 7, MediaCommand.NEXT).get()) + } finally { bridge.close() } + } + @Test fun positionStatusAndOutputRouteChangesDoNotRepublishArtworkMetadata() { val context = RuntimeEnvironment.getApplication() val source = Source(); val output = Output() @@ -97,6 +114,7 @@ class CarPlayMediaSessionBridgeTest { var state = NowPlayingState() val listeners = mutableListOf<(NowPlayingState) -> Unit>() val sent = mutableListOf() + val seeks = mutableListOf>() override fun mediaSnapshot() = state override fun subscribeMediaState(listener: (NowPlayingState) -> Unit): AutoCloseable { listeners.add(listener); listener(state) @@ -105,7 +123,10 @@ class CarPlayMediaSessionBridgeTest { override fun sendMediaCommand(command: MediaCommand, connectionGeneration: Long?): CompletableFuture { sent.add(command); return CompletableFuture.completedFuture(MediaCommandResult.SENT) } - override fun seekTo(positionMs: Long) = CompletableFuture.completedFuture(MediaCommandResult.UNSUPPORTED) + override fun seekTo(positionMs: Long, connectionGeneration: Long?, trackRevision: Long?): CompletableFuture { + seeks.add(Triple(positionMs, connectionGeneration, trackRevision)) + return CompletableFuture.completedFuture(MediaCommandResult.SENT) + } fun update(next: NowPlayingState) { state = next; listeners.toList().forEach { it(next) } } } private class Output : MediaOutputControl { @@ -138,12 +159,21 @@ class CarPlayMediaSessionBridgeTest { assertEquals(PlaybackState.STATE_PLAYING, controller.playbackState!!.state) assertEquals(2000L, controller.playbackState!!.position) assertEquals(1234L, controller.playbackState!!.lastPositionUpdateTime) - assertEquals(0L, controller.playbackState!!.actions and PlaybackState.ACTION_SEEK_TO) + assertEquals(PlaybackState.ACTION_SEEK_TO, controller.playbackState!!.actions and PlaybackState.ACTION_SEEK_TO) controller.transportControls.pause(); idle() assertEquals(listOf(MediaCommand.PAUSE), source.sent) assertEquals("Sending pause is not acknowledgement", PlaybackState.STATE_PLAYING, controller.playbackState!!.state) source.update(playing().copy(status = PhonePlaybackStatus.PAUSED, positionMs = 2100)); idle() assertEquals(0f, controller.playbackState!!.playbackSpeed, 0f) + controller.transportControls.seekTo(42000); idle() + assertEquals(listOf(Triple(42000L, 0L, 0L)), source.seeks) + assertEquals(PlaybackState.STATE_PAUSED, controller.playbackState!!.state) + assertEquals(listOf(MediaCommand.PAUSE), source.sent) + assertEquals(MediaCommandResult.UNAVAILABLE, bridge.browserSeek(source, 99, 0, 100).get()) + assertEquals(MediaCommandResult.UNAVAILABLE, bridge.browserSeek(source, 0, 99, 100).get()) + source.update(playing().copy(setElapsedTimeAvailable = false)); idle() + assertEquals(0L, controller.playbackState!!.actions and PlaybackState.ACTION_SEEK_TO) + assertEquals(MediaCommandResult.UNSUPPORTED, bridge.browserSeek(source, 0, 0, 100).get()) bridge.close() assertTrue(source.listeners.isEmpty()); assertNull(output.request) } diff --git a/common/src/test/java/com/shilapi/xcertplay/web/BrowserMediaBridgeTest.kt b/common/src/test/java/com/shilapi/xcertplay/web/BrowserMediaBridgeTest.kt new file mode 100644 index 0000000..7410a1d --- /dev/null +++ b/common/src/test/java/com/shilapi/xcertplay/web/BrowserMediaBridgeTest.kt @@ -0,0 +1,234 @@ +package com.shilapi.xcertplay.web + +import android.os.Looper +import com.shilapi.xcertplay.media.* +import java.util.concurrent.CompletableFuture +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [29]) +class BrowserMediaBridgeTest { + private class Source : CarPlayMediaSource { + var state = NowPlayingState(connected = true, connectionGeneration = 7, trackRevision = 2, + title = "Song", artist = "Artist", album = "Album", status = PhonePlaybackStatus.PLAYING, + durationMs = 100000, positionMs = 2000, positionAtMs = 1000) + val listeners = mutableListOf<(NowPlayingState) -> Unit>() + val commands = mutableListOf() + val seeks = mutableListOf>() + override fun mediaSnapshot() = state + override fun subscribeMediaState(listener: (NowPlayingState) -> Unit): AutoCloseable { + listeners.add(listener); listener(state) + return AutoCloseable { listeners.remove(listener) } + } + override fun sendMediaCommand(command: MediaCommand, connectionGeneration: Long?): CompletableFuture { + commands.add(command); return CompletableFuture.completedFuture(MediaCommandResult.SENT) + } + override fun seekTo(positionMs: Long, connectionGeneration: Long?, trackRevision: Long?): CompletableFuture { + seeks.add(Triple(positionMs, connectionGeneration, trackRevision)) + return CompletableFuture.completedFuture(MediaCommandResult.SENT) + } + fun update(next: NowPlayingState) { state = next; listeners.toList().forEach { it(next) } } + } + private class Fixture { + val first = Source() + var source: Source? = first + var changed: (() -> Unit)? = null + var time = 2000L + var deferred: CompletableFuture? = null + val sent = mutableListOf() + val bridge = BrowserMediaBridge(send = { sent.add(JSONObject(it)) }, currentSource = { source }, + observeSource = { changed = it; it(); AutoCloseable { changed = null } }, + dispatch = { src, gen, cmd -> + val sent = src.sendMediaCommand(cmd, gen) + deferred ?: sent + }, dispatchSeek = { src, gen, track, pos -> deferred ?: src.seekTo(pos, gen, track) }, clock = { time }) + init { bridge.start(); idle() } + fun state() = sent.last { it.getString("type") == "now-playing" } + fun command(action: String = "pause", id: Long = 1, snapshot: JSONObject = state()) = JSONObject() + .put("requestId", id).put("action", action).put("sessionId", snapshot.getString("sessionId")) + .put("connectionGeneration", snapshot.getLong("connectionGeneration")) + fun seek(id: Long, position: Long, snapshot: JSONObject = state()) = command("seekto", id, snapshot) + .put("positionMs", position).put("trackRevision", snapshot.getLong("trackRevision")) + fun tick() { time += 200; shadowOf(Looper.getMainLooper()).idleFor(java.time.Duration.ofMillis(200)) } + fun enableSeek() { first.update(first.state.copy(setElapsedTimeAvailable = true)); idle() } + fun close() { bridge.close(); idle() } + } + companion object { private fun idle() = shadowOf(Looper.getMainLooper()).idle() } + + @Test fun initialAndIncrementalStatesHaveAnchoredProgressAndExplicitNulls() { + val f = Fixture() + try { + assertEquals("Song", f.state().getString("title")) + assertEquals(3000L, f.state().getLong("positionMs")) + assertFalse(f.state().getBoolean("canSeek")) + assertTrue(f.state().isNull("artworkUrl")) + val revision = f.state().getLong("revision") + f.first.update(f.first.state.copy(status = PhonePlaybackStatus.PAUSED, positionMs = 3100, positionAtMs = f.time)) + f.time += 5000; idle() + assertEquals("paused", f.state().getString("status")) + assertEquals(3100L, f.state().getLong("positionMs")) + assertTrue(f.state().getLong("revision") > revision) + f.first.update(NowPlayingState(connectionGeneration = 8)); idle() + assertFalse(f.state().getBoolean("available")); assertTrue(f.state().isNull("title")) + } finally { f.close() } + } + + @Test fun artworkCapabilityIsStableForLyricsButRetiredOnTrackSourceOrViewerChanges() { + val f = Fixture() + try { + val bytes = byteArrayOf(1, 2, 3) + f.first.update(f.first.state.copy(artwork = MediaArtwork(bytes))); idle() + val url = f.state().getString("artworkUrl"); val id = url.substringAfter("id=") + assertArrayEquals(bytes, f.bridge.artwork(id)); assertNull(f.bridge.artwork("guess")) + f.first.update(f.first.state.copy(title = "Live lyrics")); idle() + assertEquals(url, f.state().getString("artworkUrl")) + f.first.update(f.first.state.copy(trackRevision = 3, artwork = null)) + assertNull("Old cover is denied even before main processes the new snapshot", f.bridge.artwork(id)) + idle(); assertTrue(f.state().isNull("artworkUrl")) + f.first.update(f.first.state.copy(artwork = MediaArtwork(bytes))); idle() + val nextId = f.state().getString("artworkUrl").substringAfter("id=") + f.source = Source(); assertNull(f.bridge.artwork(nextId)) + f.changed!!(); idle() + f.source!!.update(f.source!!.state.copy(artwork = MediaArtwork(bytes))); idle() + val lastId = f.state().getString("artworkUrl").substringAfter("id=") + f.bridge.close(); assertNull(f.bridge.artwork(lastId)) + } finally { f.close() } + } + + @Test fun replacementControllerWithSameGenerationRejectsOldCallbacksAndCommands() { + val f = Fixture() + try { + val old = f.state(); val callback = f.first.listeners.single() + f.source = Source(); f.changed!!(); idle() + assertTrue(f.first.listeners.isEmpty()) + assertNotEquals(old.getString("sessionId"), f.state().getString("sessionId")) + callback(f.first.state.copy(title = "Retired")); idle() + assertEquals("Song", f.state().getString("title")) + f.bridge.command(f.command(snapshot = old)); idle() + assertTrue(f.source!!.commands.isEmpty()); assertTrue(f.first.commands.isEmpty()) + assertEquals("unavailable", f.sent.last().getString("result")) + f.bridge.command(f.command(id = 2)); idle() + assertEquals(listOf(MediaCommand.PAUSE), f.source!!.commands) + assertEquals("sent", f.sent.last().getString("result")) + assertEquals("playing", f.state().getString("status")) + } finally { f.close() } + assertTrue(f.source!!.listeners.isEmpty()); assertNull(f.changed) + } + + @Test fun commandsAreBoundedDeduplicatedAndRejectIncompleteSeek() { + val f = Fixture() + try { + f.bridge.command(f.command()); idle() + f.time += 200 + f.bridge.command(f.command()); idle() + assertEquals(1, f.first.commands.size) + f.bridge.command(f.command("nexttrack", 2)); idle() + f.bridge.command(f.command("previoustrack", 3)); idle() + assertEquals(listOf(MediaCommand.PAUSE, MediaCommand.NEXT), f.first.commands) + assertThrows(org.json.JSONException::class.java) { f.bridge.command(f.command("seekto", 4)) } + f.first.update(f.first.state.copy(connectionGeneration = 8)); + f.time += 200 + // Delivery is still guarded by the underlying source/native bridge while main catches up. + idle(); f.bridge.close(); f.bridge.command(f.command(id = 5)); idle() + assertEquals(2, f.first.commands.size) + } finally { f.close() } + } + + @Test fun closeBeforeStartCannotLeakSubscriptionsOrPublish() { + var subscriptions = 0 + val bridge = BrowserMediaBridge(send = { fail("Closed bridge published") }, currentSource = { Source() }, + observeSource = { subscriptions++; AutoCloseable {} }) + bridge.start(); bridge.close(); idle() + assertEquals(0, subscriptions) + } + + @Test fun revokingPairingBeforeMainDispatchDropsTheQueuedControl() { + val f = Fixture() + try { + var authorized = true + f.bridge.command(f.command()) { authorized } + authorized = false + idle() + assertTrue(f.first.commands.isEmpty()) + } finally { f.close() } + } + + @Test fun pendingCommandsStayBoundedAndOldCompletionCannotAcknowledgeANewController() { + val f = Fixture() + try { + val old = CompletableFuture(); f.deferred = old + f.bridge.command(f.command()); idle() + f.time += 200; f.bridge.command(f.command("nexttrack", 2)); idle() + assertEquals(listOf(MediaCommand.PAUSE), f.first.commands) + assertEquals("unavailable", f.sent.last().getString("result")) + f.source = Source(); f.changed!!(); idle() + val count = f.sent.size + old.complete(MediaCommandResult.SENT); idle() + assertEquals(count, f.sent.size) + f.deferred = null; f.bridge.command(f.command("nexttrack", 3)); idle() + assertEquals(listOf(MediaCommand.NEXT), f.source!!.commands) + assertEquals(3L, f.sent.last().getLong("requestId")) + assertEquals("sent", f.sent.last().getString("result")) + } finally { f.close() } + } + @Test fun finalSeekSurvivesRapidDragAndInFlightControlWithoutChangingPhoneState() { + val f = Fixture() + try { + f.enableSeek(); assertTrue(f.state().getBoolean("canSeek")) + f.deferred = CompletableFuture() + f.bridge.command(f.command()); idle() + f.bridge.command(f.seek(2, 10000)); f.bridge.command(f.seek(3, 42000)); idle() + assertEquals("superseded", f.sent.last { it.optLong("requestId") == 2L }.getString("result")) + f.tick(); assertTrue(f.first.seeks.isEmpty()) + f.deferred!!.complete(MediaCommandResult.SENT); f.deferred = null; idle(); f.tick() + assertEquals(listOf(Triple(42000L, 7L, 2L)), f.first.seeks) + assertEquals("sent", f.sent.last().getString("result")) + assertEquals(3L, f.sent.last().getLong("requestId")) + assertEquals(2000L, f.first.state.positionMs) + assertEquals(PhonePlaybackStatus.PLAYING, f.first.state.status) + } finally { f.close() } + } + + @Test fun queuedSeekRechecksTrackConnectionPairingAndCapabilityBeforeDispatch() { + for (change in 0..4) { + val f = Fixture() + try { + f.enableSeek() + var authorized = true + f.bridge.command(f.seek(1, 42000)) { authorized }; idle() + when (change) { + 0 -> f.first.update(f.first.state.copy(trackRevision = 3)) + 1 -> f.first.update(f.first.state.copy(connectionGeneration = 8)) + 2 -> f.first.update(f.first.state.copy(setElapsedTimeAvailable = false)) + 3 -> authorized = false + 4 -> f.source = Source() + } + f.tick(); assertTrue(f.first.seeks.isEmpty()) + } finally { f.close() } + } + } + + @Test fun seeksRejectMalformedPositionsStaleTracksAndUnavailableTimelines() { + val f = Fixture() + try { + f.bridge.command(f.seek(1, 42000)); idle() + assertEquals("unsupported", f.sent.last().getString("result")) + f.enableSeek() + for (value in listOf(-1, 1.5, "42000", JSONObject.NULL, 9007199254740992L)) { + assertThrows(IllegalArgumentException::class.java) { f.bridge.command(f.seek(2, 42000).put("positionMs", value)) } + } + f.bridge.command(f.seek(2, 42000).put("trackRevision", 1)); idle(); f.tick() + assertTrue(f.first.seeks.isEmpty()) + assertEquals("unavailable", f.sent.last().getString("result")) + f.bridge.command(f.seek(3, 42000)); idle(); f.bridge.close(); f.tick() + assertTrue(f.first.seeks.isEmpty()) + } finally { f.close() } + } + +} diff --git a/common/src/test/java/com/shilapi/xcertplay/web/LanWebServerTest.kt b/common/src/test/java/com/shilapi/xcertplay/web/LanWebServerTest.kt index 1bf6415..76e6b4f 100644 --- a/common/src/test/java/com/shilapi/xcertplay/web/LanWebServerTest.kt +++ b/common/src/test/java/com/shilapi/xcertplay/web/LanWebServerTest.kt @@ -20,6 +20,94 @@ import javax.net.ssl.HttpsURLConnection @RunWith(org.robolectric.RobolectricTestRunner::class) @org.robolectric.annotation.Config(qualifiers = "zh-rCN", sdk = [29]) class LanWebServerTest { + @Test fun pairedNowPlayingStreamsStateCommandsAndRevocableArtworkOverRealSockets() { + val context = org.robolectric.RuntimeEnvironment.getApplication() + val bytes = byteArrayOf(0xff.toByte(), 0xd8.toByte(), 1, 2, 3) + val state = com.shilapi.xcertplay.media.NowPlayingState(connected = true, connectionGeneration = 7, + title = "Phone song", status = com.shilapi.xcertplay.media.PhonePlaybackStatus.PLAYING, + durationMs = 100000, positionMs = 1000, setElapsedTimeAvailable = true, + artwork = com.shilapi.xcertplay.media.MediaArtwork(bytes)) + val subscribed = CountDownLatch(1) + val commands = java.util.concurrent.CopyOnWriteArrayList() + val seeks = java.util.concurrent.CopyOnWriteArrayList>() + val source = object : com.shilapi.xcertplay.media.CarPlayMediaSource { + override fun mediaSnapshot() = state + override fun subscribeMediaState(listener: (com.shilapi.xcertplay.media.NowPlayingState) -> Unit): AutoCloseable { + listener(state); subscribed.countDown(); return AutoCloseable {} + } + override fun sendMediaCommand(command: com.shilapi.xcertplay.media.MediaCommand, connectionGeneration: Long?): java.util.concurrent.CompletableFuture { + commands.add(command) + return java.util.concurrent.CompletableFuture.completedFuture(com.shilapi.xcertplay.media.MediaCommandResult.SENT) + } + override fun seekTo(positionMs: Long, connectionGeneration: Long?, trackRevision: Long?): java.util.concurrent.CompletableFuture { + seeks.add(Triple(positionMs, connectionGeneration, trackRevision)) + return java.util.concurrent.CompletableFuture.completedFuture(com.shilapi.xcertplay.media.MediaCommandResult.SENT) + } + } + val server = LanWebServer(context, "123456", port = 0, mediaBridgeFactory = { send -> + BrowserMediaBridge(send, currentSource = { source }, observeSource = { it(); AutoCloseable {} }, + dispatch = { src, gen, cmd -> src.sendMediaCommand(cmd, gen) }, + dispatchSeek = { src, gen, track, pos -> src.seekTo(pos, gen, track) }) + }) + server.start() + val port = server.listeningPort + val host = "127.0.0.1:$port" + val token = server.pairing.issueToken()!! + fun pumpUntil(condition: () -> Boolean) { + val until = System.nanoTime() + TimeUnit.SECONDS.toNanos(3) + while (!condition() && System.nanoTime() < until) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idleFor(java.time.Duration.ofMillis(20)) + Thread.sleep(5) + } + assertTrue(condition()) + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + } + try { + val missing = URL("http://$host/now-playing/artwork?id=unknown").openConnection() as java.net.HttpURLConnection + assertEquals(404, missing.responseCode); missing.disconnect() + assertTrue(URL("http://$host/browser-media-session.js").readText().contains("setPositionState")) + handshake("", "http://$host", token, host, port, nowPlaying = true).use { client -> + assertTrue(readHeaders(client).startsWith("HTTP/1.1 101")) + pumpUntil { subscribed.count == 0L } + var snapshot: org.json.JSONObject + do { snapshot = org.json.JSONObject(frame(client).toString(Charsets.UTF_8)) } + while (snapshot.optString("type") != "now-playing" || !snapshot.optBoolean("available")) + assertEquals("Phone song", snapshot.getString("title")) + val coverUrl = URL("http://$host${snapshot.getString("artworkUrl")}") + val cover = coverUrl.openConnection() as java.net.HttpURLConnection + assertEquals("image/jpeg", cover.contentType) + assertEquals("private, no-store", cover.getHeaderField("Cache-Control")) + assertArrayEquals(bytes, cover.inputStream.use { it.readBytes() }); cover.disconnect() + text(client, org.json.JSONObject().put("type", "media-command").put("requestId", 1) + .put("sessionId", snapshot.getString("sessionId")).put("connectionGeneration", 7) + .put("action", "pause").toString()) + pumpUntil { commands.isNotEmpty() } + var result: org.json.JSONObject + do { result = org.json.JSONObject(frame(client).toString(Charsets.UTF_8)) } + while (result.optString("type") != "media-command-result") + assertEquals("sent", result.getString("result")) + assertEquals(listOf(com.shilapi.xcertplay.media.MediaCommand.PAUSE), commands) + assertTrue(snapshot.getBoolean("canSeek")) + text(client, org.json.JSONObject().put("type", "media-command").put("requestId", 2) + .put("sessionId", snapshot.getString("sessionId")).put("connectionGeneration", 7) + .put("trackRevision", snapshot.getLong("trackRevision")) + .put("action", "seekto").put("positionMs", 42000).toString()) + pumpUntil { seeks.isNotEmpty() } + do { result = org.json.JSONObject(frame(client).toString(Charsets.UTF_8)) } + while (result.optString("type") != "media-command-result" || result.optLong("requestId") != 2L) + assertEquals("sent", result.getString("result")) + assertEquals(listOf(Triple(42000L, 7L, 0L)), seeks) + server.pairing.revoke(token) + val revoked = coverUrl.openConnection() as java.net.HttpURLConnection + assertEquals(404, revoked.responseCode); revoked.disconnect() + server.disconnectUnauthorized() + } + } finally { + server.stop() + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + } + } + @org.junit.Before fun provisionAuthentication() { val identity = com.shilapi.xcertplay.SyntheticMfiIdentity.create() com.shilapi.xcertplay.MfiAssetStore(org.robolectric.RuntimeEnvironment.getApplication().noBackupFilesDir) @@ -771,9 +859,9 @@ class LanWebServerTest { headers.split(" ")[1].toInt() to socket.getInputStream().readBytes().toString(Charsets.UTF_8) } - private fun handshake(code: String, origin: String = "http://127.0.0.1:8080", token: String = "", host: String = "127.0.0.1:8080", port: Int = 8080, media: Int = 1, images: Int = 0): Socket = Socket("127.0.0.1", port).apply { + private fun handshake(code: String, origin: String = "http://127.0.0.1:8080", token: String = "", host: String = "127.0.0.1:8080", port: Int = 8080, media: Int = 1, images: Int = 0, nowPlaying: Boolean = false): Socket = Socket("127.0.0.1", port).apply { soTimeout = 3000 - getOutputStream().write(("GET /stream?code=$code&token=$token&media=$media&images=$images HTTP/1.1\r\nHost: $host\r\n" + + getOutputStream().write(("GET /stream?code=$code&token=$token&media=$media&images=$images&nowPlaying=${if (nowPlaying) 1 else 0} HTTP/1.1\r\nHost: $host\r\n" + "Origin: $origin\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + "Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n").toByteArray()) getOutputStream().flush() @@ -792,10 +880,13 @@ class LanWebServerTest { maskedFrame(socket, 1, message.toByteArray()) } private fun maskedFrame(socket: Socket, opcode: Int, bytes: ByteArray) { - check(bytes.size < 126) + check(bytes.size <= 65535) // RFC6455 client frames are masked; zero mask is sufficient for this protocol fixture. socket.getOutputStream().apply { - write(byteArrayOf((0x80 or opcode).toByte(), (0x80 or bytes.size).toByte(), 0,0,0,0)); write(bytes); flush() + write(0x80 or opcode) + if (bytes.size < 126) write(0x80 or bytes.size) + else { write(0x80 or 126); write(bytes.size ushr 8); write(bytes.size and 255) } + write(byteArrayOf(0,0,0,0)); write(bytes); flush() } } } diff --git a/docs/MEDIA_SESSION.md b/docs/MEDIA_SESSION.md index ae0d9da..ff36da3 100644 --- a/docs/MEDIA_SESSION.md +++ b/docs/MEDIA_SESSION.md @@ -1,6 +1,6 @@ # WheelPlay MediaSession integration -The background controller owns CarPlay media state, and the foreground service owns the native Android media session and notification. Leaving or recreating a page does not release the session. The implementation includes metadata, artwork, playback progress, play/pause, and track skipping. **Seek protocol validation is incomplete, so system scrubbing is currently unavailable.** Device acceptance testing remains incomplete. +The background controller owns CarPlay media state, and the foreground service owns the native Android media session and notification. Leaving or recreating a page does not release the session. The implementation includes metadata, artwork, playback progress, play/pause, track skipping, and capability-gated absolute seeking. Device acceptance testing remains incomplete. ## Protocol fields @@ -19,7 +19,7 @@ The mapping follows Apple's [Accessory Interface Specification R32, publicly hos | 0x5001/1 | 7, 16 | Playback application name, bundle ID | utf8 | | 0x5001/1 | 12 | Playback speed | uint16: 100 means 1x, 0 means unavailable | | 0x5001/1 | 13 | Setting elapsed time supported | bool: 0/1 | -| 0x5003 | 0 | ElapsedTime | uint32; outbound units unspecified in the reference | +| 0x5003 | 0 | ElapsedTime | uint32 milliseconds; unit follows the upstream implementations below | Artwork uses the file transfer session. Setup contains the transfer identifier, 0x04, and a 64-bit byte count; v2 adds the 16-bit file type 0x0002. START 0x01, CANCEL 0x02, SUCCESS 0x05, and FAILURE 0x06 coordinate transfers. Data flags are FIRST 0x80, DATA 0x00, LAST 0x40, and FIRST_LAST 0xC0. Artwork is JPEG. @@ -57,17 +57,51 @@ The renderer's idle maintenance only checks output permission. Actual music PCM Android API references: [media controls](https://developer.android.com/media/implement/surfaces/mobile), [MediaSession](https://developer.android.com/reference/android/media/session/MediaSession), and [audio focus](https://developer.android.com/media/optimize/audio-focus). -## Seek delivery gap +## Absolute seeking -`seekTo(positionMs)` currently returns `UNSUPPORTED`. `PlaybackState` does not declare `ACTION_SEEK_TO`, and identification does not additionally advertise sending 0x5003. The raw encoder accepts a wire value without treating milliseconds as a verified outbound unit. +The outbound unit is supported by open-source implementations, rather than inferred from the inbound elapsed-time field: -Enabling seek requires reliable protocol evidence or a device capture confirming: +- [LIVI message definition](https://github.com/f-io/LIVI/blob/f999f55735278acd76f0fa207218cac00a739047/native/livi-helperd/crates/iap2-csm/src/messages/now_playing.rs#L76) and [runtime sender](https://github.com/f-io/LIVI/blob/f999f55735278acd76f0fa207218cac00a739047/native/livi-helperd/crates/livi-runtime/src/bringup.rs#L497): `0x5003`, parameter `0`, `elapsed_ms: u32`. +- [Nocturne definition](https://github.com/usenocturne/nocturne/blob/7021896b4422a947efa35315cf55f98aa64ffb3e/crates/iap2/src/csm/now_playing.rs#L167): milliseconds, direct user actions, and the phone's `PlaybackSetElapsedTimeAvailable` gate. +- [MacPlay encoding test](https://github.com/Roylyl/MacPlay/blob/12daeecdd5fad38554823ce6166a65370cf75f40/native/livi-helperd/crates/iap2-csm/src/messages/now_playing.rs#L96): 42 seconds produces `4040000e5003000800000000a410`. This is a synthetic test, not a device capture. -1. Units and boundary values for 0x5003 parameter 0, with correct 0x5001 elapsed-time reports after sending on the ready tunnel. -2. AirPlay audio sequence/timestamp continuity around seeking, and which buffered local/browser samples must be discarded. -3. Implemented and tested bounds, latest-request coalescing, disconnect cancellation, and stale-link isolation before enabling `ACTION_SEEK_TO` and identification capabilities. +Wired and wireless identification advertise sending `0x5003`. `canSeek` requires an available phone, an explicit seek capability, a positive duration, and a known position. Android advertises `ACTION_SEEK_TO` only while these conditions hold. A confirmed track change clears the position anchor until fresh progress arrives. As with other incremental playback attributes, the latest duration and seek capability remain in effect unless updated; switching players or disconnecting clears them. -Synthetic fixtures and successful sends cannot establish these protocol behaviors. +`seekTo(positionMs, connectionGeneration, trackRevision)` clamps to the current duration and the unsigned 32-bit limit. The coordinator keeps at most one queued target in addition to the active send. Superseded queued requests complete as `SUPERSEDED`; disconnect completes outstanding futures as `UNAVAILABLE`. The captured iAP2 session, connection generation, track revision and request serial are checked again at link queue admission and dispatch. Cancellable CSM frames must fit in one link packet, so dropping a stale seek cannot leave a partial CSM frame. Already transmitted commands cannot be recalled. + +Seeking does not imply play, acquire audio focus, clear the pause gate, or change the reported playback position optimistically. `SENT` means transport queue acceptance, not confirmation that the phone applied the seek. Subsequent `0x5001` reports update progress. Audio continues through the existing CarPlay audio path; no guessed AirPlay flush boundary is injected. iPhone/app compatibility, resulting elapsed-time reports, and audible continuity on local/browser output remain device acceptance items. + +## Browser / head-unit Media Session + +The paired browser receives the same iPhone-authoritative Now Playing state as the Android native session. `browser-media-session.js` maps it to the optional Web Media Session API (`navigator.mediaSession`): title, artist, album, JPEG artwork, playback state, duration, position, and play/pause/previous/next controls. Stop maps to pause. `seekto`, `seekforward`, and `seekbackward` are registered only while `canSeek` is true and the timeline is valid. Missing or partially implemented APIs do not interrupt video, touch, or browser audio. This also works as a remote control when audio is routed through Android; it does not change the selected audio route or add a synthetic audio track to activate system UI. + +`/stream?...&nowPlaying=1` opts into the protocol; clients without the capability keep their existing messages. `BrowserMediaBridge` follows background controller replacement, subscribes to the initial and incremental media snapshots, and closes both subscriptions when its viewer leaves. Observers only post work to the main thread. Small state messages use the existing coalescing `WebStreamSender` control queue; no writes run on the iAP2 reader. + +Server → browser `now-playing` fields: + +| Field | Meaning | +| --- | --- | +| `sessionId` | Opaque binding ID, replaced whenever the controller changes, including replacement controllers with the same connection generation | +| `revision` | Monotonically increasing snapshot revision within this WebSocket connection | +| `connectionGeneration`, `trackRevision` | iPhone connection and track identity boundaries | +| `connected`, `available` | Connection and Now Playing availability; unavailable snapshots clear the browser session | +| `title`, `artist`, `album`, `appName` | Nullable display metadata, including possible live lyrics | +| `status` | `unknown`, `stopped`, `playing`, `paused`, `seek_forward`, or `seek_backward`; only playing/paused map to those Web API states, others map to `none` | +| `durationMs`, `positionMs`, `speed` | Nullable duration/position in milliseconds, position extrapolated using the server's monotonic clock at publication, and reported playback rate | +| `canSeek` | Available phone with seek capability, positive duration and known position | +| `artworkUrl` | Nullable same-origin `/now-playing/artwork?id=...` capability URL | + +The browser converts milliseconds to seconds, clamps position to duration, clears unknown positions, and uses a positive API playback rate while setting `playbackState` to paused. It does not compare Android `elapsedRealtime` with a browser clock. Metadata is republished only when its display values or media identity change. Artwork URLs remain stable across position and lyric updates. Each URL is an unguessable bearer capability that serves only the current scaled JPEG while its originating viewer remains authorized, and is invalidated on image replacement, track/connection/controller changes, pairing revocation, or viewer close. Responses are `private, no-store`; pairing codes and tokens do not appear in artwork URLs. + +Browser → server controls use `{"type":"media-command","requestId":1,"sessionId":"opaque-binding","connectionGeneration":7,"action":"pause"}`. Basic actions are `play`, `pause`, `stop`, `previoustrack`, `nexttrack`; seek is described below. The server validates pairing, viewer ownership, binding, connection generation and increasing accepted request IDs; it allows one pending command with a 150 ms admission interval. Controls pass through the foreground service's native media bridge so local audio focus and buffered pause gating behave like notification controls. Replies are `{"type":"media-command-result","requestId":1,"result":"sent"}` (or `unavailable`/`failed`). `sent` is transport completion, never a playback acknowledgement. The browser waits for a reply before another command, with a three-second client wait limit, and does not retry successful sends or update playback optimistically. + +Seek requests add the track revision and an absolute millisecond target: `{"type":"media-command","requestId":2,"sessionId":"opaque-binding","connectionGeneration":7,"trackRevision":3,"action":"seekto","positionMs":42000}`. Only `seekto` is sent over the wire; the browser converts relative actions to absolute positions using the reported position and a browser-monotonic progress anchor (default step: 10 seconds). It rejects non-finite inputs, clamps the target, coalesces dragging for 150 ms, and retains the last target while another command is pending. After transport acceptance, it retains the target for successive relative actions until a phone report comes within one second of it, a failure is reported, or three seconds elapse; displayed position still comes only from phone snapshots. Both intermediate `fastSeek` hints and the final callback use absolute seeks. Position is updated only by phone snapshots. + +The server independently validates integral, nonnegative position/track fields, pairing, source, connection and track. A 150 ms trailing queue replaces intermediate seeks and preserves the final target while a control is pending; it rechecks authorization and current capability before dispatch. Results additionally include `unsupported` and `superseded`. Capability loss, track/connection changes, controller replacement, and viewer closure retire queued seeks. Browser handlers also retire on track/capability changes. + +WebSocket closure, page exit, and iPhone disconnection clear metadata, position state, and action handlers. Old snapshot revisions and callbacks are ignored. Native clients embedding a WebView still need a host implementation if their engine does not expose Media Session to the system; this protocol does not define a vendor-specific JS bridge. Actual head-unit media cards, steering-wheel keys, audio-focus ownership, HTTP/HTTPS exposure and Web Audio-only session activation depend on the browser/OS and require device acceptance testing. + +Validation includes Robolectric state/identity/command tests, real HTTP/WebSocket pairing and artwork revocation, native pause gating, Node browser lifecycle/backpressure tests, and real Chromium metadata/cover/control roundtrips under the production CSP. Chromium action callbacks are invoked by the test harness; this does not verify physical head-unit buttons or OS media-card visibility. ## Validation @@ -86,4 +120,4 @@ On 2026-10-08, a V2547A device (API 37) was detected. The attempted Debug APK in | Another player taking focus | Unverified | iPhone and local music pause; resuming requires user action | | A2DP audio output | Unverified | Local PCM follows the system route without new pairing behavior | | AVRCP buttons/metadata/artwork | Unverified | Record compatibility by receiver model and implementation | -| Seek and stale audio after seeking | Unimplemented; protocol evidence pending | No guessed wire encoding or system scrubber | +| Seek and stale audio after seeking | Implemented; automated protocol/browser tests pass; device testing pending | Drag while playing and paused; phone reports the target; no stale audio; live/non-seekable items disable scrubbing | diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md index 93052b4..25c0af9 100644 --- a/docs/VALIDATION.md +++ b/docs/VALIDATION.md @@ -532,3 +532,28 @@ run-1 捕获的第 12 个访问单元(索引 11)为: 1280px 连接页三列、390px 单列,均无横向溢出且 Video 数为 0。测试后 UI 确认恢复 30 fps、HEVC 关闭、原始分辨率、自适应开启和 10 秒容忍时间;新增加的图片格式 WebP / 质量 75 沿用安装后的设置,测试未修改。实际重新协商 H.264 后,触控与直接 Canvas 正常,末次收到 / 解码均为 1038 帧,丢弃为 0,未回退;Video 创建和 play 调用均为 0。随后释放触点,撤销页面观测包装,恢复直接 Canvas 偏好,断开并关闭专用测试浏览器。设备保留最终修复版 APK。 记录、真实码流、设备与构建日志保存至忽略的 `.local/rebase-qa/`;不提交私人画面。目标车机型号及长时间运行仍需单独验证,本轮未操作用户原 Chrome。 + +## 浏览器 Media Session(2026-10-23) + +将 iAP2 Now Playing 状态经已配对 WebSocket 推送到车机网页,再通过 Web Media Session API 发布歌曲、歌手、专辑、封面、播放状态和进度。播放/暂停/切歌回传复用原生媒体桥的音频焦点及暂停逻辑;未启用尚未验证的 seek。协议及兼容性边界见 [MEDIA_SESSION.md](MEDIA_SESSION.md#browser--head-unit-media-session)。 + +- `npm test`:150 项通过;`npm run check` 通过。覆盖 API 缺失/部分支持、暂停进度、过期状态、旧按键回调、WebSocket 背压与重连清理。 +- Android 定向回归累计覆盖 43 个独立测试:`BrowserMediaBridgeTest` 7 项、`CarPlayMediaSessionBridgeTest` 12 项、`LanWebServerTest` 24 项,均通过。最后补充的命令排队后撤销权限、未完成命令限制及旧 controller 完成回调隔离已单独复测。 +- 真实 HTTP / WebSocket 测试验证状态推送、控制结果、封面读取与撤销配对后返回 404;既有不声明 `nowPlaying=1` 的客户端保持原协议。 +- `npm run test:browser:media`:真实 Chromium 在生产 CSP 下通过元数据、JPEG 封面、控制回传、iPhone 回报后暂停、禁用 seek、断线清理及旧回调隔离,同时保留画面/触控回归。媒体按键回调由测试脚本触发,不代表物理按键验收。 +- `:mobile:lintDebug` 通过:0 错误、18 警告。`git diff --check` 通过。云环境使用可写 Android/Gradle/Robolectric 缓存及会话代理运行检查,未修改仓库工具链设置。 +- 未安装到车机或 iPhone。车机系统媒体卡片、方向盘按键、Web Audio 会话激活,以及具体浏览器在 HTTP/HTTPS 下的支持情况仍需实机验收。 + + +## 浏览器与原生 Media Session 进度跳转(2026-10-23) + +本节取代此前“seek 未实现/等待协议依据”的当前状态描述。根据 LIVI 的实际发送代码、Nocturne 的协议定义和 MacPlay 的字节编码测试,接通 `seekto → positionMs → iAP2 0x5003 / 参数 0`,按大端 uint32 毫秒发送。具体上游版本和链接见 [绝对跳转说明](MEDIA_SESSION.md#absolute-seeking)。有线/无线 identification 均声明发送该消息。 + +浏览器和原生 Android 仅在手机允许跳转且时长、位置有效时启用控制。支持绝对拖动与前后跳转;连续拖动合并至最后一个目标,等待中的最终请求不会因前一个控制尚未完成而丢失。请求绑定 controller、连接与曲目,iAP2 队列在接收和发送时复核有效性。暂停时跳转保持暂停,发送成功不冒充手机已更新播放位置。 + +- `npm test`:154 项通过,含拖动合并、秒/毫秒转换、相对进度推算、边界钳制、无效输入、超时和回调失效。 +- `:shared:testDebugUnitTest --tests '*Iap2*' --tests '*CarPlayMediaCoordinatorTest'`:66 项通过。覆盖 42 秒的完整消息字节、真实 iAP2 链路模拟收包、队列背压下丢弃过期跳转、连接复用、切歌/断线隔离,以及已有协议回归。 +- `:common:testDebugUnitTest` 定向运行 `BrowserMediaBridgeTest`、`CarPlayMediaSessionBridgeTest`、`LanWebServerTest`:46 项通过。真实 HTTP/WebSocket 验证 seek 能力、命令字段和结果;Robolectric 验证暂停跳转、配对撤销、能力变化和最终请求保留。 +- `npm run test:browser:media`:真实 Chromium 在生产 CSP 下通过 seek 能力切换、最后一次拖动位置(42.123 秒 → 42123 毫秒)、暂停状态保持、切歌后的旧 seek 回调隔离,以及既有画面/触控/重连检查。回调由测试脚本触发,未验证车机的实体按键或系统进度条 UI。 +- `:mobile:lintDebug`:0 错误、18 警告;`git diff --check` 通过。 +- 未安装或操作真实车机/iPhone。手机音乐应用是否执行跳转、进度回报及本地/浏览器输出的声音连续性仍需实机验收。本次复用现有 CarPlay 音频链路,未根据猜测注入 AirPlay 缓冲清空命令。 diff --git a/scripts/preview.mjs b/scripts/preview.mjs index c3f428b..ca580d9 100644 --- a/scripts/preview.mjs +++ b/scripts/preview.mjs @@ -47,7 +47,7 @@ const server = http.createServer(async (req,res) => { } if (req.url === '/i18n.js') { res.setHeader('Content-Type', 'text/javascript; charset=utf-8'); res.setHeader('Cache-Control', 'no-store'); res.end('globalThis.wheelplayMessages = ' + JSON.stringify(messages) + ';'); return; } const file = { '/':'index.html','/app.js':'app.js','/text.js':'text.js','/browser-audio.js':'browser-audio.js','/browser-location.js':'browser-location.js', - '/audio-worklet.js':'audio-worklet.js','/style.css':'style.css' }[req.url]; + '/audio-worklet.js':'audio-worklet.js','/browser-media-session.js':'browser-media-session.js','/style.css':'style.css' }[req.url]; if (!file) { res.writeHead(404);res.end();return; } try { let body = await readFile(new URL(file,root)); diff --git a/scripts/test-media-presentation.mjs b/scripts/test-media-presentation.mjs index c3502d7..e3ea421 100644 --- a/scripts/test-media-presentation.mjs +++ b/scripts/test-media-presentation.mjs @@ -23,11 +23,15 @@ function image(width, height, codec = 'mjpeg') { const images = { jpeg: [image(320, 180), image(180, 320)], webp: [image(320, 180, 'libwebp'), image(180, 320, 'libwebp')] }; let viewer, connections = 0, acks = 0, paused = false, sequence = 0, format = 'jpeg', jpegFallbacks = 0, webpProbes = 0; const pendingImages = new Map(); -const touches = [], errors = []; +const touches = [], errors = [], mediaCommands = []; let media = { type: 'status', mediaProtocol: 3, sessionId: 7, videoEpoch: 1, width: 320, height: 180, sourceState: 'ready', inputReady: true, streaming: true, stage: 'Synthetic browser test' }; const server = http.createServer(async (req, res) => { res.setHeader('Content-Security-Policy', csp); + if (req.url === '/now-playing/artwork?id=test-cover') { + res.setHeader('Content-Type', 'image/jpeg'); res.setHeader('Cache-Control', 'private, no-store'); + res.end(images.jpeg[0]); return; + } if (req.url.startsWith('/pair/')) { const result = req.url === '/pair/remember' ? { id: 'd'.repeat(32), secret: 's'.repeat(32), name: 'Test' } : req.url === '/pair/request' ? { id: 'qr', secret: 'poll', expiresIn: 120 } : @@ -37,7 +41,7 @@ const server = http.createServer(async (req, res) => { if (req.url === '/i18n.js') { res.setHeader('Content-Type', 'text/javascript'); res.end(`globalThis.wheelplayMessages=${JSON.stringify(messages)};`); return; } - const file = { '/': 'index.html', '/app.js': 'app.js', '/text.js': 'text.js', '/browser-audio.js': 'browser-audio.js', + const file = { '/': 'index.html', '/app.js': 'app.js', '/text.js': 'text.js', '/browser-audio.js': 'browser-audio.js', '/browser-media-session.js': 'browser-media-session.js', '/browser-location.js': 'browser-location.js', '/style.css': 'style.css', '/audio-worklet.js': 'audio-worklet.js' }[req.url]; if (!file) { res.writeHead(404); res.end(); return; } let body = await readFile(new URL(file, root)); @@ -75,6 +79,10 @@ wss.on('connection', ws => { if (connections === 1) frame(); } if (message.type === 'touch') touches.push(message); + if (message.type === 'media-command') { + mediaCommands.push(message); + ws.send(JSON.stringify({ type: 'media-command-result', requestId: message.requestId, result: 'sent' })); + } if (message.type === 'ping' && !paused) { ws.send(JSON.stringify(media)); if (initial && connections === 1) frame(); @@ -135,6 +143,40 @@ try { await wait(`!!window.wheelplayStats && !!document.getElementById('connect') && !document.getElementById('connect').disabled`); await evaluate(`document.getElementById('code').value='123456';document.getElementById('connect-form').requestSubmit()`); await wait(showing); + await evaluate(` + window.mediaHandlers = {}; + const originalActionHandler = MediaSession.prototype.setActionHandler; + MediaSession.prototype.setActionHandler = function(action, handler) { + window.mediaHandlers[action] = handler; + return originalActionHandler.call(this, action, handler); + };`); + const nowPlaying = { type: 'now-playing', sessionId: 'phone-one', revision: 1, connectionGeneration: 7, trackRevision: 1, + connected: true, available: true, title: 'Browser test song', artist: 'Test artist', album: 'Test album', + status: 'playing', durationMs: 100000, positionMs: 12000, speed: 1, canSeek: false, + artworkUrl: '/now-playing/artwork?id=test-cover' }; + viewer.send(JSON.stringify(nowPlaying)); + await wait(`navigator.mediaSession.metadata?.title === 'Browser test song' && navigator.mediaSession.playbackState === 'playing'`); + assert.equal(await evaluate(`new Promise(resolve => { const image = new Image(); image.onload = () => resolve(image.naturalWidth); image.onerror = () => resolve(0); image.src = navigator.mediaSession.metadata.artwork[0].src; })`), 320); + await evaluate(`window.retiredPause = mediaHandlers.pause; mediaHandlers.pause()`); + for (let attempt = 0; !mediaCommands.length && attempt < 100; attempt++) await pause(10); + assert.equal(mediaCommands[0]?.action, 'pause'); assert.equal(mediaCommands[0]?.connectionGeneration, 7); + assert.equal(await evaluate('navigator.mediaSession.playbackState'), 'playing', 'Sending pause is not a phone acknowledgement'); + viewer.send(JSON.stringify({ ...nowPlaying, revision: 2, status: 'paused', speed: 0 })); + await wait(`navigator.mediaSession.playbackState === 'paused'`); + assert.equal(await evaluate('mediaHandlers.seekto === null && mediaHandlers.seekforward === null'), true); + viewer.send(JSON.stringify({ ...nowPlaying, revision: 3, status: 'paused', speed: 0, canSeek: true })); + await wait(`typeof mediaHandlers.seekto === 'function' && typeof mediaHandlers.seekforward === 'function'`); + await evaluate(`window.retiredSeek = mediaHandlers.seekto; mediaHandlers.seekto({seekTime: 10, fastSeek: true}); mediaHandlers.seekto({seekTime: 42.123})`); + for (let attempt = 0; mediaCommands.length < 2 && attempt < 100; attempt++) await pause(10); + assert.equal(mediaCommands.length, 2); + assert.equal(mediaCommands[1].action, 'seekto'); + assert.equal(mediaCommands[1].positionMs, 42123); + assert.equal(mediaCommands[1].trackRevision, 1); + assert.equal(await evaluate('navigator.mediaSession.playbackState'), 'paused'); + viewer.send(JSON.stringify({ ...nowPlaying, revision: 4, trackRevision: 2, title: 'New track', artworkUrl: null, durationMs: null, positionMs: null })); + await wait(`navigator.mediaSession.metadata?.title === 'New track' && navigator.mediaSession.metadata.artwork.length === 0`); + await evaluate(`retiredSeek({seekTime: 60})`); + await pause(200); assert.equal(mediaCommands.length, 2, 'Retired seek cannot control the new track'); await wait(`document.getElementById('status').textContent.includes('WebP')`); assert.equal(webpProbes, 1, 'WebP is advertised only after real decoding succeeds'); await evaluate(`const option=document.getElementById('ignore-browser-zoom');option.checked=true;option.dispatchEvent(new Event('change'))`); @@ -188,13 +230,17 @@ try { viewer.close(); for (let attempt = 0; connections < 2 && attempt < 100; attempt++) await pause(50); assert.equal(connections, 2); + assert.equal(await evaluate(`navigator.mediaSession.metadata === null && navigator.mediaSession.playbackState === 'none'`), true); + await evaluate('retiredPause()'); await pause(50); + assert.equal(mediaCommands.length, 2, 'Retired system controls cannot reach the replacement socket'); assert.equal(await evaluate(`document.getElementById('waiting').hidden`), false, 'Reconnect must wait for its own frame'); frame(); await wait(showing); assert.deepEqual(errors, []); console.log(JSON.stringify({ passed: true, connections, imageAcks: acks, webpProbes, jpegFallbacks, checks: ['WebP capability probe', 'WebP touch with zoom compensation', 'JPEG fallback after corrupt WebP', 'two-image pipeline with exact ACKs', 'decode without overlay flashes', 'gap releases touch', 'deadline without status', 'cancelled deadline', - 'same-size rebuild', 'old frame rejection', 'rotation', 'real socket reconnect'] }, null, 2)); + 'same-size rebuild', 'old frame rejection', 'rotation', 'real socket reconnect', 'native browser Media Session metadata and cover', + 'media action roundtrip', 'phone-authoritative pause', 'capability-gated seek and final drag position', 'media cleanup and retired callback isolation'] }, null, 2)); } finally { devtools?.close(); browser.kill('SIGTERM'); for (const client of wss.clients) client.terminate(); diff --git a/shared/src/main/java/com/shilapi/xcertplay/iap2/message/Iap2NowPlayingCodec.kt b/shared/src/main/java/com/shilapi/xcertplay/iap2/message/Iap2NowPlayingCodec.kt index 80f81f4..c3c79a4 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/iap2/message/Iap2NowPlayingCodec.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/iap2/message/Iap2NowPlayingCodec.kt @@ -50,6 +50,9 @@ object Iap2NowPlayingCodec { ) } - /** Raw wire value, deliberately not a millisecond API until the outbound unit is verified. */ - fun setElapsedTime(wireValue: Long): Iap2Frame = Iap2Messages.buildRaw(SET_INFORMATION) { u32(0, wireValue) } + /** Absolute position in milliseconds, matching LIVI and Nocturne (see docs/MEDIA_SESSION.md). */ + fun setElapsedTime(positionMs: Long): Iap2Frame { + require(positionMs in 0..0xffff_ffffL) + return Iap2Messages.buildRaw(SET_INFORMATION) { u32(0, positionMs) } + } } diff --git a/shared/src/main/java/com/shilapi/xcertplay/iap2/session/Iap2Session.kt b/shared/src/main/java/com/shilapi/xcertplay/iap2/session/Iap2Session.kt index d4660d0..c7525cd 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/iap2/session/Iap2Session.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/iap2/session/Iap2Session.kt @@ -47,6 +47,12 @@ class Iap2Session private constructor( } } + /** Queues an atomic control frame only while its connection/track/request is current. */ + fun sendIfCurrent(frame: Iap2Frame, isCurrent: () -> Boolean): Boolean = + channel.sendIfCurrent(frame, isCurrent).also { queued -> + if (queued) emitFrameTrace(Iap2TraceDirection.TX, frame) + } + fun send( endpoint: Iap2Endpoint, timeoutMillis: Long = DEFAULT_SEND_TIMEOUT_MILLIS, diff --git a/shared/src/main/java/com/shilapi/xcertplay/media/CarPlayMediaCoordinator.kt b/shared/src/main/java/com/shilapi/xcertplay/media/CarPlayMediaCoordinator.kt index 4918c09..453dedd 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/media/CarPlayMediaCoordinator.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/media/CarPlayMediaCoordinator.kt @@ -32,6 +32,12 @@ class CarPlayMediaCoordinator( private var generation = 0L @Volatile private var closed = false + private data class Seek(val positionMs: Long, val source: Iap2Session, val generation: Long, + val track: Long, val serial: Long, val result: CompletableFuture) + private val seekSerial = java.util.concurrent.atomic.AtomicLong() + private var queuedSeek: Seek? = null + private var seekWorkerScheduled = false + fun snapshot(): NowPlayingState = state fun subscribe(listener: (NowPlayingState) -> Unit): AutoCloseable { synchronized(lock) { @@ -188,15 +194,65 @@ class CarPlayMediaCoordinator( return result } - fun seekTo(positionMs: Long): CompletableFuture { - // R32 confirms the parameter width and capability, but omits its outbound time unit. - // Never send a guessed encoding or expose ACTION_SEEK_TO until a capture verifies it. - return CompletableFuture.completedFuture(if (!state.available) MediaCommandResult.UNAVAILABLE else MediaCommandResult.UNSUPPORTED) + fun seekTo(positionMs: Long, connectionGeneration: Long? = null, trackRevision: Long? = null): CompletableFuture { + synchronized(lock) { + fun done(value: MediaCommandResult) = CompletableFuture.completedFuture(value) + val current = channel + if (closed || current == null || !state.available || + connectionGeneration != null && connectionGeneration != state.connectionGeneration || + trackRevision != null && trackRevision != state.trackRevision) return done(MediaCommandResult.UNAVAILABLE) + if (!state.canSeek) return done(MediaCommandResult.UNSUPPORTED) + val result = CompletableFuture() + pending.add(result) + result.whenComplete { _, _ -> pending.remove(result) } + val previous = queuedSeek + queuedSeek = Seek(positionMs.coerceIn(0, minOf(state.durationMs!!, 0xffff_ffffL)), current, + state.connectionGeneration, state.trackRevision, seekSerial.incrementAndGet(), result) + previous?.result?.complete(MediaCommandResult.SUPERSEDED) + if (!seekWorkerScheduled) { + seekWorkerScheduled = true + try { commands.execute(::drainSeeks) } + catch (_: java.util.concurrent.RejectedExecutionException) { + seekWorkerScheduled = false; queuedSeek = null + result.complete(MediaCommandResult.UNAVAILABLE) + } + } + return result + } + } + + private fun drainSeeks() { + while (true) { + val request = synchronized(lock) { + val next = queuedSeek + queuedSeek = null + if (next == null) seekWorkerScheduled = false + next + } ?: return + // This predicate runs under the link queue lock: only volatile/atomic reads, no coordinator lock. + fun current(): Boolean { + val snapshot = state + return !closed && snapshot.connectionGeneration == request.generation && + snapshot.trackRevision == request.track && snapshot.canSeek && seekSerial.get() == request.serial + } + val outcome = try { + if (!request.result.isDone && current() && + request.source.sendIfCurrent(Iap2NowPlayingCodec.setElapsedTime(request.positionMs), ::current)) + MediaCommandResult.SENT else MediaCommandResult.UNAVAILABLE + } catch (_: Exception) { MediaCommandResult.FAILED } + request.result.complete(when { + state.connectionGeneration != request.generation || state.trackRevision != request.track -> MediaCommandResult.UNAVAILABLE + seekSerial.get() != request.serial -> MediaCommandResult.SUPERSEDED + else -> outcome + }) + } } fun disconnected(source: Iap2Session? = null) = synchronized(lock) { if (source != null && channel !== source) return@synchronized ++generation + seekSerial.incrementAndGet() + queuedSeek = null pending.forEach { it.complete(MediaCommandResult.UNAVAILABLE) } channel = null artworkBinding = null diff --git a/shared/src/main/java/com/shilapi/xcertplay/media/CarPlayMediaSource.kt b/shared/src/main/java/com/shilapi/xcertplay/media/CarPlayMediaSource.kt index b8286b9..83d0a8e 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/media/CarPlayMediaSource.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/media/CarPlayMediaSource.kt @@ -6,7 +6,7 @@ interface CarPlayMediaSource { fun mediaSnapshot(): NowPlayingState fun subscribeMediaState(listener: (NowPlayingState) -> Unit): AutoCloseable fun sendMediaCommand(command: MediaCommand, connectionGeneration: Long? = null): CompletableFuture - fun seekTo(positionMs: Long): CompletableFuture + fun seekTo(positionMs: Long, connectionGeneration: Long? = null, trackRevision: Long? = null): CompletableFuture } interface MediaOutputControl { diff --git a/shared/src/main/java/com/shilapi/xcertplay/media/NowPlayingState.kt b/shared/src/main/java/com/shilapi/xcertplay/media/NowPlayingState.kt index efea17b..52c709a 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/media/NowPlayingState.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/media/NowPlayingState.kt @@ -36,8 +36,9 @@ data class NowPlayingState( (!title.isNullOrBlank() || !appName.isNullOrBlank() || !appBundleId.isNullOrBlank()) val playing: Boolean get() = status == PhonePlaybackStatus.PLAYING val speed: Float get() = if (playing) reportedSpeed ?: 1f else 0f - // Outbound units and the audio continuity boundary still require a verified capture. - val canSeek: Boolean get() = false + // Require a finite timeline and an explicit capability from the current phone. + val canSeek: Boolean get() = available && setElapsedTimeAvailable && + (durationMs ?: 0) > 0 && positionMs != null fun positionAt(nowMs: Long): Long? = positionMs?.let { val position = it + ((nowMs - positionAtMs).coerceAtLeast(0) * speed).toLong() if ((durationMs ?: 0) > 0) position.coerceIn(0, durationMs!!) else position.coerceAtLeast(0) diff --git a/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayController.kt b/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayController.kt index 5e0e5b4..970187d 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayController.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/orchestration/CarPlayController.kt @@ -197,7 +197,7 @@ class CarPlayController( override fun mediaSnapshot() = mediaState.snapshot() override fun subscribeMediaState(listener: (NowPlayingState) -> Unit) = mediaState.subscribe(listener) override fun sendMediaCommand(command: MediaCommand, connectionGeneration: Long?) = mediaState.send(command, connectionGeneration) - override fun seekTo(positionMs: Long) = mediaState.seekTo(positionMs) + override fun seekTo(positionMs: Long, connectionGeneration: Long?, trackRevision: Long?) = mediaState.seekTo(positionMs, connectionGeneration, trackRevision) fun setNightMode(night: Boolean): Boolean = activeSession?.setNightMode(night) ?: false private val executor: ExecutorService = Executors.newSingleThreadExecutor() diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2CsmChannel.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2CsmChannel.kt index 9fc4a23..c00cb4f 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2CsmChannel.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2CsmChannel.kt @@ -85,6 +85,19 @@ class Iap2CsmChannel private constructor( } } + /** Only whole, single-packet frames may be cancelled without corrupting CSM framing. + * [isCurrent] must be non-blocking and lock-free, including at link dispatch. + */ + fun sendIfCurrent(frame: Iap2Frame, isCurrent: () -> Boolean): Boolean = synchronized(sendLock) { + checkOpen() + if (!isCurrent()) return@synchronized false + val limit = link.peerMaxControlPayloadBytes() + ?: throw IllegalStateException("CSM send requires a successful iAP2 awaitReady") + val bytes = frame.encodedFrame() + require(bytes.size <= limit) { "Cancellable CSM frame must fit in one link packet" } + link.sendControlAwaitCapacity(bytes, DEFAULT_SEND_TIMEOUT_MILLIS, isCurrent) + } + /** * Returns the next complete CSM frame, null on timeout or clean close, and throws on failure. * A single underlying control payload can decode into several frames; the remainder stays in a diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2IdentificationClient.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2IdentificationClient.kt index 9b69961..b91a261 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2IdentificationClient.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2IdentificationClient.kt @@ -258,6 +258,7 @@ class Iap2IdentificationClient(private val session: Iap2Session) { 0xaa03, // AuthenticationResponse 0x5000, // StartNowPlayingUpdates 0x5002, // StopNowPlayingUpdates + 0x5003, // SetNowPlayingInformation (absolute seek) 0x5200, // StartRouteGuidanceUpdates 0x5203, // StopRouteGuidanceUpdates 0xae00, // StartPowerUpdates diff --git a/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2LinkChannel.kt b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2LinkChannel.kt index c79540c..6b80c45 100644 --- a/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2LinkChannel.kt +++ b/shared/src/main/java/com/shilapi/xcertplay/transport/Iap2LinkChannel.kt @@ -85,7 +85,8 @@ class Iap2LinkChannel private constructor( * Like [sendControl], but waits for command-queue capacity. CSM uses this to queue every * fragment of one frame without converting link backpressure into a partial CSM write. */ - internal fun sendControlAwaitCapacity(bytes: ByteArray, timeoutMillis: Long): Boolean { + internal fun sendControlAwaitCapacity(bytes: ByteArray, timeoutMillis: Long, + isCurrent: () -> Boolean = { true }): Boolean { require(bytes.size <= Iap2LinkEngine.MAX_PAYLOAD_BYTES) { "iAP2 control payload exceeds ${Iap2LinkEngine.MAX_PAYLOAD_BYTES} bytes" } @@ -93,11 +94,11 @@ class Iap2LinkChannel private constructor( val copy = bytes.copyOf() synchronized(lock) { waitFor(timeoutMillis) { - terminated || closing || hasCommandCapacityLocked(copy.size) + terminated || closing || !isCurrent() || hasCommandCapacityLocked(copy.size) } throwTerminalFailureLocked() - if (terminated || closing) return false - return enqueueCommandLocked(copy) + if (terminated || closing || !isCurrent()) return false + return enqueueCommandLocked(copy, isCurrent = isCurrent) } } diff --git a/shared/src/test/java/com/shilapi/xcertplay/iap2/message/Iap2NowPlayingCodecTest.kt b/shared/src/test/java/com/shilapi/xcertplay/iap2/message/Iap2NowPlayingCodecTest.kt index f3d5e75..0034c47 100644 --- a/shared/src/test/java/com/shilapi/xcertplay/iap2/message/Iap2NowPlayingCodecTest.kt +++ b/shared/src/test/java/com/shilapi/xcertplay/iap2/message/Iap2NowPlayingCodecTest.kt @@ -24,7 +24,7 @@ class Iap2NowPlayingCodecTest { assertEquals(91500L, state.positionAt(99000)) assertEquals("Artist", state.artist) assertTrue(state.setElapsedTimeAvailable) - assertFalse("Unverified outbound units must never advertise seek", state.canSeek) + assertTrue(state.canSeek) } @Test fun trackChangesClearArtworkAndProgressButRetainAbsentFields() { @@ -88,6 +88,23 @@ class Iap2NowPlayingCodecTest { assertArrayEquals(byteArrayOf(0x12,0x34,0x56,0x78), Iap2BodyReader.of(raw).raw(0)) } + @Test fun absoluteSeekUsesUnsignedBigEndianMillisecondsAndRejectsOverflow() { + assertArrayEquals(byteArrayOf(0x40, 0x40, 0, 14, 0x50, 3, 0, 8, 0, 0, 0, 0, 0xa4.toByte(), 0x10), + Iap2NowPlayingCodec.setElapsedTime(42000).encodedFrame()) + assertEquals(0xffff_ffffL, Iap2BodyReader.of(Iap2NowPlayingCodec.setElapsedTime(0xffff_ffffL)).u32(0)) + for (value in listOf(-1L, 0x1_0000_0000L)) + assertThrows(IllegalArgumentException::class.java) { Iap2NowPlayingCodec.setElapsedTime(value) } + val state = NowPlayingState(connected = true, title = "Song", durationMs = 100000, + positionMs = 1000, setElapsedTimeAvailable = true) + assertTrue(state.canSeek) + assertFalse(state.copy(connected = false).canSeek) + assertFalse(state.copy(durationMs = null).canSeek) + assertFalse(state.copy(durationMs = 0).canSeek) + assertFalse(state.copy(positionMs = null).canSeek) + assertFalse(state.copy(setElapsedTimeAvailable = false).canSeek) + assertFalse(NowPlayingReducer.update(state.copy(persistentId = 1), NowPlayingUpdate(persistentId = 2), 0).canSeek) + } + @Test fun speedZeroMeansUnknownAndArtworkSnapshotsOwnTheirBytes() { val old = NowPlayingState(reportedSpeed = 2f, status = PhonePlaybackStatus.PLAYING) assertEquals(1f, NowPlayingReducer.update(old, NowPlayingUpdate(speed = 0f), 10).speed, 0f) diff --git a/shared/src/test/java/com/shilapi/xcertplay/media/CarPlayMediaCoordinatorTest.kt b/shared/src/test/java/com/shilapi/xcertplay/media/CarPlayMediaCoordinatorTest.kt index f3c6412..f841613 100644 --- a/shared/src/test/java/com/shilapi/xcertplay/media/CarPlayMediaCoordinatorTest.kt +++ b/shared/src/test/java/com/shilapi/xcertplay/media/CarPlayMediaCoordinatorTest.kt @@ -15,6 +15,7 @@ class CarPlayMediaCoordinatorTest { private val engine = Iap2LinkEngine() private val incoming = java.util.ArrayDeque() val replies = java.util.concurrent.LinkedBlockingQueue() + val controls = java.util.concurrent.LinkedBlockingQueue() private var closed = false init { engine.start(false, 0); drain() } private fun drain() { @@ -22,6 +23,7 @@ class CarPlayMediaCoordinatorTest { while (true) { val event = engine.pollEvent() ?: break if (event is Iap2LinkEngine.Event.FileTransfer) replies.add(event.bytes) + if (event is Iap2LinkEngine.Event.Control) controls.add(event.bytes) } } override fun send(data: ByteArray) = synchronized(lock) { @@ -335,4 +337,81 @@ class CarPlayMediaCoordinatorTest { } finally { release.countDown(); coordinator.close(); source.close() } } + private fun seekable() = Iap2Messages.buildRaw(0x5001) { + group(0) { u64(0, 1); string(1, "Song"); u32(4, 100000) } + group(1) { u8(0, 2); u32(1, 1000); u8(13, 1) } + } + private fun seekPosition(peer: ArtworkPeer): Long { + val bytes = peer.controls.poll(2, TimeUnit.SECONDS) ?: error("Seek never reached the phone") + val frame = com.shilapi.xcertplay.iap2.wire.Iap2CsmFramer().offer(bytes).single() + assertEquals(0x5003, frame.messageId) + return com.shilapi.xcertplay.iap2.body.Iap2BodyReader.of(frame).u32(0) + } + + @Test fun seekReachesReadyPhoneInMillisecondsClampsAndKeepsReportedPauseAndPosition() { + val coordinator = CarPlayMediaCoordinator({ { true } }, { 100L }) + val peer = ArtworkPeer(); val source = Iap2Session.openTunnel(peer) + try { + assertTrue(source.awaitReady(2000)) + coordinator.bind(source); coordinator.receive(source, seekable()) + val state = coordinator.snapshot() + for ((input, expected) in listOf(42000L to 42000L, -1L to 0L, Long.MAX_VALUE to 100000L)) { + assertEquals(MediaCommandResult.SENT, coordinator.seekTo(input, state.connectionGeneration, state.trackRevision).get(2, TimeUnit.SECONDS)) + assertEquals(expected, seekPosition(peer)) + } + assertEquals(PhonePlaybackStatus.PAUSED, coordinator.snapshot().status) + assertEquals(1000L, coordinator.snapshot().positionMs) + coordinator.receive(source, Iap2Messages.buildRaw(0x5001) { group(1) { u32(1, 42000) } }) + assertEquals(42000L, coordinator.snapshot().positionMs) + coordinator.receive(source, Iap2Messages.buildRaw(0x5001) { group(1) { u8(13, 0) } }) + assertEquals(MediaCommandResult.UNSUPPORTED, coordinator.seekTo(1000).get()) + coordinator.disconnected() + assertEquals(MediaCommandResult.UNAVAILABLE, coordinator.seekTo(1000).get()) + assertNull(peer.controls.poll(100, TimeUnit.MILLISECONDS)) + } finally { coordinator.close(); source.close() } + } + + @Test fun queuedSeeksCoalesceAndCannotCrossTrackOrReusedLinkBoundaries() { + val entered = java.util.concurrent.CountDownLatch(1) + val release = java.util.concurrent.CountDownLatch(1) + val coordinator = CarPlayMediaCoordinator({ { entered.countDown(); release.await(); true } }, { 100L }) + val peer = ArtworkPeer(); val source = Iap2Session.openTunnel(peer) + try { + assertTrue(source.awaitReady(2000)) + coordinator.bind(source); coordinator.receive(source, seekable()) + coordinator.send(MediaCommand.PAUSE); assertTrue(entered.await(1, TimeUnit.SECONDS)) + val old = coordinator.seekTo(10000) + val final = coordinator.seekTo(42000) + assertEquals(MediaCommandResult.SUPERSEDED, old.get(1, TimeUnit.SECONDS)) + release.countDown() + assertEquals(MediaCommandResult.SENT, final.get(2, TimeUnit.SECONDS)) + assertEquals(42000L, seekPosition(peer)) + val state = coordinator.snapshot() + coordinator.receive(source, Iap2Messages.buildRaw(0x5001) { group(0) { u64(0, 2) } }) + assertEquals(MediaCommandResult.UNAVAILABLE, coordinator.seekTo(2000, state.connectionGeneration, state.trackRevision).get()) + coordinator.disconnected(); coordinator.bind(source); coordinator.receive(source, seekable()) + assertEquals(MediaCommandResult.UNAVAILABLE, coordinator.seekTo(3000, state.connectionGeneration).get()) + assertNull(peer.controls.poll(100, TimeUnit.MILLISECONDS)) + } finally { release.countDown(); coordinator.close(); source.close() } + } + + @Test fun disconnectCompletesQueuedSeekBeforeBlockedTransportAndNeverReplaysIt() { + val entered = java.util.concurrent.CountDownLatch(1) + val release = java.util.concurrent.CountDownLatch(1) + val coordinator = CarPlayMediaCoordinator({ { entered.countDown(); release.await(); true } }, { 100L }) + val peer = ArtworkPeer(); val source = Iap2Session.openTunnel(peer) + try { + assertTrue(source.awaitReady(2000)) + coordinator.bind(source); coordinator.receive(source, seekable()) + coordinator.send(MediaCommand.PAUSE); assertTrue(entered.await(1, TimeUnit.SECONDS)) + val seek = coordinator.seekTo(42000) + coordinator.disconnected() + assertEquals(MediaCommandResult.UNAVAILABLE, seek.get(1, TimeUnit.SECONDS)) + coordinator.bind(source); coordinator.receive(source, seekable()) + release.countDown() + coordinator.send(MediaCommand.PAUSE).get(1, TimeUnit.SECONDS) + assertNull(peer.controls.poll(100, TimeUnit.MILLISECONDS)) + } finally { release.countDown(); coordinator.close(); source.close() } + } + } diff --git a/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2FileTransferBackpressureTest.kt b/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2FileTransferBackpressureTest.kt index 81c9dca..1c93baf 100644 --- a/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2FileTransferBackpressureTest.kt +++ b/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2FileTransferBackpressureTest.kt @@ -86,6 +86,29 @@ class Iap2FileTransferBackpressureTest { } finally { link.close() } } + @Test fun queuedSeekIsDiscardedBeforeDispatchWhenItsTrackOrConnectionRetires() { + val phone = Phone() + val link = Iap2LinkChannel.openWireless(phone) + val current = java.util.concurrent.atomic.AtomicBoolean(true) + try { + assertTrue(link.awaitReady(2000)) + phone.holdAcknowledgements(true) + repeat(4) { assertTrue(link.sendControl(byteArrayOf(it.toByte()))) } + repeat(2) { assertNotNull(phone.controls.poll(2, TimeUnit.SECONDS)) } + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2) + while (link.awaitReady(0) && System.nanoTime() < deadline) Thread.sleep(5) + assertFalse(link.awaitReady(0)) + val seek = com.shilapi.xcertplay.iap2.message.Iap2NowPlayingCodec.setElapsedTime(42000).encodedFrame() + assertTrue(link.sendControlAwaitCapacity(seek, 1000, current::get)) + current.set(false) + phone.holdAcknowledgements(false) + repeat(2) { assertNotNull(phone.controls.poll(2, TimeUnit.SECONDS)) } + assertNull("Retired seek must never reach the phone", phone.controls.poll(200, TimeUnit.MILLISECONDS)) + assertTrue(link.sendControlAwaitCapacity(seek, 1000)) + assertArrayEquals(seek, phone.controls.poll(2, TimeUnit.SECONDS)) + } finally { link.close() } + } + @Test fun artworkReplyWaitingForQueueCapacityCannotEnterAfterRetirement() { val phone = Phone() val link = Iap2LinkChannel.openWireless(phone) diff --git a/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2RouteGuidanceSubscriptionTest.kt b/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2RouteGuidanceSubscriptionTest.kt index 11ec977..f2b2746 100644 --- a/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2RouteGuidanceSubscriptionTest.kt +++ b/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2RouteGuidanceSubscriptionTest.kt @@ -22,6 +22,10 @@ class Iap2RouteGuidanceSubscriptionTest { val parameters = Iap2ParameterList.parse( Iap2IdentificationClient.identificationInformation(config).payload, ).asList() + val sent = parameters.single { it.id == 6 }.payload.toList().chunked(2).map { + ((it[0].toInt() and 255) shl 8) or (it[1].toInt() and 255) + } + org.junit.Assert.assertTrue(sent.contains(0x5003)) val component = Iap2ParameterList.parse(parameters.single { it.id == 30 }.payload).asList() assertArrayEquals(byteArrayOf(0, 42), component.single { it.id == 0 }.payload) assertArrayEquals("RouteGuidance\u0000".encodeToByteArray(), component.single { it.id == 1 }.payload) diff --git a/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2WirelessControlClientTest.kt b/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2WirelessControlClientTest.kt index 5f4ff44..6780756 100644 --- a/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2WirelessControlClientTest.kt +++ b/shared/src/test/java/com/shilapi/xcertplay/transport/Iap2WirelessControlClientTest.kt @@ -69,6 +69,7 @@ class Iap2WirelessControlClientTest { val received = u16Values(parameters.single { it.id == 7 }.payload) assertTrue(0x5703 in sent) assertTrue(0x4301 in sent) + assertTrue(0x5003 in sent) assertFalse(0xae03 in sent) assertTrue(0x4300 in received) assertTrue(0x4e0d in received) diff --git a/test/browser-media-session.test.js b/test/browser-media-session.test.js new file mode 100644 index 0000000..ce7ea00 --- /dev/null +++ b/test/browser-media-session.test.js @@ -0,0 +1,195 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createBrowserMediaSession } from '../common/src/main/assets/web/browser-media-session.js'; +import { createControlOutbox } from '../common/src/main/assets/web/app.js'; + +function fixture(overrides = {}) { + const handlers = new Map(), positions = [], sent = [], metadata = []; + const session = { setActionHandler: (action, fn) => handlers.set(action, fn), + setPositionState: value => { if (value?.playbackRate === 0) throw new Error('Zero rate'); positions.push(value); }, + set metadata(value) { metadata.push(value); }, playbackState: 'none' }; + let time = 0, timerId = 0; + const timers = new Map(); + const bridge = createBrowserMediaSession({ session, Metadata: class { constructor(value) { Object.assign(this, value); } }, + baseUrl: 'https://car.local:8443/', send: value => { sent.push(value); return true; }, now: () => time, schedule: (fn, delay) => { timers.set(++timerId, { fn, at: time + delay }); return timerId; }, + cancel: id => timers.delete(id), ...overrides }); + bridge.open(); + const state = { type: 'now-playing', sessionId: 'one', revision: 1, connectionGeneration: 2, trackRevision: 3, + connected: true, available: true, title: 'Song', artist: 'Artist', album: 'Album', status: 'playing', + durationMs: 100000, positionMs: 12345, speed: 1, artworkUrl: '/now-playing/artwork?id=secret', canSeek: false }; + return { bridge, state, session, handlers, positions, sent, metadata, advance: ms => { time += ms; }, tick: (ms = 150) => { + time += ms; + for (const [id, timer] of [...timers]) if (timer.at <= time) { timers.delete(id); timer.fn(); } + } }; +} + +test('phone snapshots publish metadata, cover and seconds; pause uses a nonzero rate and no seek controls', () => { + const f = fixture(); f.bridge.receive(f.state); + assert.equal(f.session.playbackState, 'playing'); + assert.equal(f.metadata.at(-1).title, 'Song'); + assert.equal(f.metadata.at(-1).artwork[0].src, 'https://car.local:8443/now-playing/artwork?id=secret'); + assert.deepEqual(f.positions.at(-1), { duration: 100, position: 12.345, playbackRate: 1 }); + const count = f.metadata.length; + f.bridge.receive({ ...f.state, revision: 2, status: 'paused', positionMs: 13000, speed: 0 }); + assert.equal(f.session.playbackState, 'paused'); + assert.deepEqual(f.positions.at(-1), { duration: 100, position: 13, playbackRate: 1 }); + assert.equal(f.metadata.length, count, 'Progress changes must not republish metadata'); + for (const action of ['seekto', 'seekforward', 'seekbackward']) assert.equal(f.handlers.get(action), null); +}); + +test('controls use the current phone generation, wait for results and never optimistically alter playback', () => { + const f = fixture(); f.bridge.receive(f.state); + f.handlers.get('pause')(); + assert.deepEqual(f.sent[0], { type: 'media-command', requestId: 1, sessionId: 'one', connectionGeneration: 2, action: 'pause' }); + assert.equal(f.session.playbackState, 'playing'); + f.handlers.get('nexttrack')(); assert.equal(f.sent.length, 1); + f.bridge.receive({ type: 'media-command-result', requestId: 1, result: 'sent' }); + assert.equal(f.session.playbackState, 'playing'); + f.handlers.get('nexttrack')(); assert.equal(f.sent.at(-1).action, 'nexttrack'); + f.advance(3001); f.handlers.get('previoustrack')(); assert.equal(f.sent.at(-1).action, 'previoustrack'); +}); + +test('stale snapshots and retired handlers cannot restore or control a replacement phone', () => { + const f = fixture(); f.bridge.receive(f.state); + const oldPlay = f.handlers.get('play'); + f.bridge.receive({ ...f.state, sessionId: 'two', revision: 3, title: 'New phone' }); + oldPlay(); assert.equal(f.sent.length, 0); + f.bridge.receive({ ...f.state, revision: 2 }); + assert.equal(f.metadata.at(-1).title, 'New phone'); + const currentPlay = f.handlers.get('play'); + f.bridge.close(); currentPlay(); + assert.equal(f.sent.length, 0); assert.equal(f.metadata.at(-1), null); + assert.equal(f.session.playbackState, 'none'); assert.equal(f.positions.at(-1), undefined); + for (const handler of f.handlers.values()) assert.equal(handler, null); + f.bridge.receive({ ...f.state, revision: 9 }); assert.equal(f.metadata.at(-1), null); + f.bridge.open(); f.bridge.receive(f.state); assert.equal(f.metadata.at(-1).title, 'Song'); + currentPlay(); assert.equal(f.sent.length, 0); +}); + +test('missing duration, track changes and disconnects clear stale position and artwork', () => { + const f = fixture(); f.bridge.receive(f.state); + f.bridge.receive({ ...f.state, revision: 2, trackRevision: 4, artworkUrl: null, durationMs: null, positionMs: null }); + assert.deepEqual(f.metadata.at(-1).artwork, []); assert.equal(f.positions.at(-1), undefined); + f.bridge.receive({ ...f.state, revision: 3, positionMs: 999999, speed: 2 }); + assert.deepEqual(f.positions.at(-1), { duration: 100, position: 100, playbackRate: 2 }); + f.bridge.receive({ ...f.state, revision: 4, connected: false, available: false }); + assert.equal(f.metadata.at(-1), null); assert.equal(f.session.playbackState, 'none'); +}); + +test('absent and partially implemented APIs cannot break the stream; cross-origin artwork is ignored', () => { + for (const session of [null, {}, { setPositionState() { throw new Error('Unsupported'); }, setActionHandler() { throw new Error('Unsupported'); } }]) { + const f = fixture({ session }); + assert.doesNotThrow(() => { f.bridge.receive(f.state); f.bridge.close(); }); + } + const f = fixture(); f.bridge.receive({ ...f.state, artworkUrl: 'https://elsewhere.invalid/cover.jpg' }); + assert.deepEqual(f.metadata.at(-1).artwork, []); + assert.equal(f.bridge.receive({ type: 'status' }), false); +}); + +test('media commands survive socket backpressure once, and disconnect discards queued commands', () => { + const sent = [], scheduled = []; + const socket = { readyState: 1, bufferedAmount: 9000, send: value => sent.push(JSON.parse(value)) }; + const outbox = createControlOutbox(socket, { schedule: fn => { scheduled.push(fn); return 1; }, cancel() {} }); + const f = fixture({ send: value => outbox.send(value) }); + f.bridge.receive(f.state); f.handlers.get('play')(); assert.equal(sent.length, 0); + socket.bufferedAmount = 0; scheduled.shift()(); + assert.equal(sent.length, 1); assert.equal(sent[0].action, 'play'); + f.bridge.receive({ type: 'media-command-result', requestId: 1 }); + socket.bufferedAmount = 9000; f.handlers.get('pause')(); + outbox.close(); f.bridge.close(); socket.bufferedAmount = 0; scheduled.shift()(); + assert.equal(sent.length, 1); +}); + + +test('scrubbing coalesces fastSeek and final positions, converts seconds to ms and keeps phone state authoritative', () => { + const f = fixture(); f.bridge.receive({ ...f.state, canSeek: true }); + const original = f.positions.at(-1); + f.handlers.get('seekto')({ seekTime: 10, fastSeek: true }); + f.handlers.get('seekto')({ seekTime: 30, fastSeek: true }); + f.handlers.get('seekto')({ seekTime: 42.123 }); + assert.equal(f.sent.length, 0); f.tick(); + assert.deepEqual(f.sent, [{ type: 'media-command', requestId: 1, sessionId: 'one', connectionGeneration: 2, + trackRevision: 3, action: 'seekto', positionMs: 42123 }]); + assert.deepEqual(f.positions.at(-1), original); + f.handlers.get('seekto')({ seekTime: 50 }); f.tick(); + f.handlers.get('seekto')({ seekTime: 60 }); f.tick(); + assert.equal(f.sent.length, 1, 'At most one in flight plus the last queued seek'); + f.bridge.receive({ type: 'media-command-result', requestId: 1, result: 'sent' }); f.tick(); + assert.equal(f.sent.length, 2); assert.equal(f.sent[1].positionMs, 60000); + assert.deepEqual(f.positions.at(-1), original); + f.bridge.receive({ ...f.state, canSeek: true, revision: 2, positionMs: 60000 }); + assert.equal(f.positions.at(-1).position, 60); +}); + +test('seek handlers follow capabilities and retire pending drags on track, phone, capability and socket changes', () => { + for (const replacement of [{ trackRevision: 4 }, { connectionGeneration: 3 }, { sessionId: 'two' }, + { canSeek: false }, { durationMs: null }, { connected: false, available: false }]) { + const f = fixture(); f.bridge.receive({ ...f.state, canSeek: true }); + const old = f.handlers.get('seekto'); old({ seekTime: 42 }); + f.bridge.receive({ ...f.state, canSeek: true, ...replacement, revision: 2 }); + old({ seekTime: 60 }); f.tick(1000); + assert.equal(f.sent.length, 0); + } + const f = fixture(); f.bridge.receive({ ...f.state, canSeek: true }); + f.handlers.get('seekto')({ seekTime: 42 }); f.bridge.close(); f.tick(1000); + assert.equal(f.sent.length, 0); +}); + +test('relative seek uses monotonic progress, defaults to ten seconds, accumulates and clamps', () => { + const f = fixture(); f.bridge.receive({ ...f.state, canSeek: true, positionMs: 20000, speed: 2 }); + f.advance(2000); + f.handlers.get('seekforward')(); // 20 + 4 + 10 + f.handlers.get('seekforward')({ seekOffset: 5 }); f.tick(); + assert.equal(f.sent[0].positionMs, 39000); + f.bridge.receive({ type: 'media-command-result', requestId: 1 }); + f.bridge.receive({ ...f.state, canSeek: true, revision: 2, status: 'paused', positionMs: 20000, speed: 0 }); + f.advance(5000); f.handlers.get('seekbackward')({ seekOffset: 30 }); f.tick(); + assert.equal(f.sent[1].positionMs, 0); + assert.equal(f.session.playbackState, 'paused'); + f.bridge.receive({ type: 'media-command-result', requestId: 2 }); + f.handlers.get('seekto')({ seekTime: 999 }); f.tick(); + assert.equal(f.sent[2].positionMs, 100000); +}); + +test('malformed seek inputs are ignored and timeout retains only the final pending position', () => { + const f = fixture(); f.bridge.receive({ ...f.state, canSeek: true }); + for (const seekTime of [undefined, null, '42', NaN, Infinity]) f.handlers.get('seekto')({ seekTime }); + for (const seekOffset of [null, '10', 0, -1, NaN, Infinity]) f.handlers.get('seekforward')({ seekOffset }); + f.tick(); assert.equal(f.sent.length, 0); + f.handlers.get('pause')(); + f.handlers.get('seekto')({ seekTime: 10 }); f.tick(); + f.handlers.get('seekto')({ seekTime: 42 }); f.tick(3001); + assert.equal(f.sent.length, 2); assert.equal(f.sent[1].positionMs, 42000); + f.tick(5000); assert.equal(f.sent.length, 2, 'Never retry a sent seek'); +}); + +test('successive relative seeks accumulate until the phone confirms the new position', () => { + const f = fixture(); + f.bridge.receive({ ...f.state, canSeek: true, status: 'paused', positionMs: 20000 }); + f.handlers.get('seekforward')(); f.tick(); + assert.equal(f.sent[0].positionMs, 30000); + f.bridge.receive({ type: 'media-command-result', requestId: 1, result: 'sent' }); + f.handlers.get('seekforward')(); f.tick(); + assert.equal(f.sent[1].positionMs, 40000); +}); + + +test('relative seek targets expire, follow phone confirmation and clear on rejection', () => { + const f = fixture(); + const report = { ...f.state, canSeek: true, status: 'paused', positionMs: 20000 }; + f.bridge.receive(report); + f.handlers.get('seekforward')(); f.tick(); + f.bridge.receive({ type: 'media-command-result', requestId: 1, result: 'sent' }); + // A metadata/progress snapshot before the phone applies the seek must not lose the target. + f.bridge.receive({ ...report, revision: 2, title: 'Lyric' }); + f.handlers.get('seekforward')(); f.tick(); assert.equal(f.sent.at(-1).positionMs, 40000); + f.bridge.receive({ type: 'media-command-result', requestId: 2, result: 'sent' }); + f.bridge.receive({ ...report, revision: 3, positionMs: 40500 }); + f.handlers.get('seekbackward')(); f.tick(); assert.equal(f.sent.at(-1).positionMs, 30500); + f.bridge.receive({ type: 'media-command-result', requestId: 3, result: 'unsupported' }); + f.handlers.get('seekforward')(); f.tick(); assert.equal(f.sent.at(-1).positionMs, 50500); + f.bridge.receive({ type: 'media-command-result', requestId: 4, result: 'sent' }); + f.advance(3001); f.handlers.get('seekforward')(); f.tick(); + assert.equal(f.sent.at(-1).positionMs, 50500, 'An unconfirmed target expires instead of accumulating forever'); + assert.equal(f.positions.at(-1).position, 40.5, 'Only phone reports affect displayed progress'); +}); diff --git a/test/pairing.test.js b/test/pairing.test.js index 0195938..623093c 100644 --- a/test/pairing.test.js +++ b/test/pairing.test.js @@ -5,12 +5,9 @@ import vm from 'node:vm'; import { resourceStrings } from '../scripts/resource-strings.mjs'; const messages = resourceStrings('values-zh-rCN'); -const source = readFileSync(new URL('../common/src/main/assets/web/text.js', import.meta.url), 'utf8').replace(/^import .*text\.js';$/m, '').replaceAll('export function', 'function') + '\n' + readFileSync(new URL('../common/src/main/assets/web/browser-audio.js', import.meta.url), 'utf8') - .replace(/^import .*text\.js';$/m, '').replaceAll('export function', 'function') + '\n' + - readFileSync(new URL('../common/src/main/assets/web/browser-location.js', import.meta.url), 'utf8') - .replace(/^import .*text\.js';$/m, '').replaceAll('export function', 'function') + '\n' + - readFileSync(new URL('../common/src/main/assets/web/app.js', import.meta.url), 'utf8') - .replace(/^import .*browser-location\.js';$/m, '').replace(/^import .*browser-audio\.js';$/m, '').replace(/^import .*text\.js';$/m, '').replaceAll('export function', 'function'); +const source = ['text.js', 'browser-audio.js', 'browser-location.js', 'browser-media-session.js', 'app.js'] + .map(name => readFileSync(new URL(`../common/src/main/assets/web/${name}`, import.meta.url), 'utf8') + .replace(/^import .*;$/gm, '').replaceAll('export function', 'function')).join('\n'); const flush = () => new Promise(resolve => setImmediate(resolve)); const savedDevice = { id: 'd'.repeat(32), secret: 's'.repeat(32), name: 'Chrome · Android' }; const ok = data => ({ ok: true, json: async () => data }); @@ -69,7 +66,7 @@ test('QR approval remembers the browser and waits for the explicit launch button f.element('hide-toolbar').checked = true; f.element('renderer-video-canvas').checked = true; f.submit(); await flush(); assert.equal(f.sockets.length, 1); - assert.equal(f.sockets[0].url, 'ws://server:8080/stream?token=resumed-token&media=2&images=3'); + assert.equal(f.sockets[0].url, 'ws://server:8080/stream?token=resumed-token&media=2&images=3&nowPlaying=1'); assert.equal(f.element('hide-toolbar').checked, true); assert.equal(f.element('renderer-video-canvas').checked, true); assert.equal(f.requests.some(r => r.url.endsWith('/cancel')), false); @@ -82,7 +79,7 @@ test('late QR approval cannot replace a manual code connection', async () => { resolve(ok({ state: 'approved', token: 'late-token' })); await polling; await flush(); assert.equal(f.sockets.length, 1); - assert.equal(f.sockets[0].url, 'ws://server:8080/stream?token=code-token&media=2&images=3'); + assert.equal(f.sockets[0].url, 'ws://server:8080/stream?token=code-token&media=2&images=3&nowPlaying=1'); assert.equal(f.requests.some(r => r.url.endsWith('/cancel')), true); assert.equal(f.requests.find(r => r.url.endsWith('/remember')).options.headers['X-Pair-Token'], 'code-token'); }); @@ -116,7 +113,7 @@ test('removed device clears saved credentials and offers QR or code pairing', as assert.equal(f.element('qr-pairing').hidden, false); assert.equal(f.sockets.length, 0); assert.match(f.element('message').textContent, /重新配对/); - f.submit(); await flush(); assert.equal(f.sockets[0].url, 'ws://server:8080/stream?token=code-token&media=2&images=3'); + f.submit(); await flush(); assert.equal(f.sockets[0].url, 'ws://server:8080/stream?token=code-token&media=2&images=3&nowPlaying=1'); }); test('temporary service failure preserves remembered credentials and launch retries', async () => { @@ -130,7 +127,7 @@ test('temporary service failure preserves remembered credentials and launch retr assert.equal(f.element('connect').disabled, false); assert.equal(f.requests.some(r => r.url.endsWith('/request')), false); available = true; f.submit(); await flush(); - assert.equal(f.sockets[0].url, 'ws://server:8080/stream?token=recovered-token&media=2&images=3'); + assert.equal(f.sockets[0].url, 'ws://server:8080/stream?token=recovered-token&media=2&images=3&nowPlaying=1'); }); test('quick browser link pairs but keeps launch options available until a click', async () => { @@ -164,7 +161,7 @@ test('revocation between restore and launch forces fresh pairing instead of usin await flush(); revoked = true; f.submit(); await flush(); assert.equal(f.sockets.length, 0); f.submit(); await flush(); - assert.equal(f.sockets[0].url, 'ws://server:8080/stream?token=code-token&media=2&images=3'); + assert.equal(f.sockets[0].url, 'ws://server:8080/stream?token=code-token&media=2&images=3&nowPlaying=1'); }); test('HTTPS setup uses the configured port and strips pairing data, including IPv6 hosts', async () => { diff --git a/test/stream-client.test.js b/test/stream-client.test.js index c3289dd..dc53804 100644 --- a/test/stream-client.test.js +++ b/test/stream-client.test.js @@ -5,14 +5,11 @@ import vm from 'node:vm'; import { resourceStrings } from '../scripts/resource-strings.mjs'; const messages = resourceStrings('values-zh-rCN'); -const source = readFileSync(new URL('../common/src/main/assets/web/text.js', import.meta.url), 'utf8').replace(/^import .*text\.js';$/m, '').replaceAll('export function', 'function') + '\n' + readFileSync(new URL('../common/src/main/assets/web/browser-audio.js', import.meta.url), 'utf8') - .replace(/^import .*text\.js';$/m, '').replaceAll('export function', 'function') + '\n' + - readFileSync(new URL('../common/src/main/assets/web/browser-location.js', import.meta.url), 'utf8') - .replace(/^import .*text\.js';$/m, '').replaceAll('export function', 'function') + '\n' + - readFileSync(new URL('../common/src/main/assets/web/app.js', import.meta.url), 'utf8') - .replace(/^import .*browser-location\.js';$/m, '').replace(/^import .*browser-audio\.js';$/m, '').replace(/^import .*text\.js';$/m, '').replaceAll('export function', 'function'); +const source = ['text.js', 'browser-audio.js', 'browser-location.js', 'browser-media-session.js', 'app.js'] + .map(name => readFileSync(new URL(`../common/src/main/assets/web/${name}`, import.meta.url), 'utf8') + .replace(/^import .*;$/gm, '').replaceAll('export function', 'function')).join('\n'); function fixture({ storedCanvas = null, storedMode = null, processorAvailable = true, storedIgnoreZoom = null, storageDisabled = false, canvasAvailable = true, supportsWebp = false, - geometry = null, visual = null, locationBrowser = null, + geometry = null, visual = null, mediaSession = null, locationBrowser = null, fullscreen = 'supported', codeDenied = false, resume = () => ({ token: 'session-token', name: 'Chrome' }) } = {}) { const elements = new Map(), timers = new Map(), raf = new Map(), sockets = [], peers = [], draws = []; const preferences = new Map(storedCanvas === null ? [] : [['wheelplay.canvasVideo', storedCanvas]]); @@ -75,13 +72,14 @@ function fixture({ storedCanvas = null, storedMode = null, processorAvailable = if (fullscreen === 'denied') return Promise.reject(new Error('fullscreen denied')); enterFullscreen(); return Promise.resolve(); }; - const context = { document, navigator: locationBrowser, window: { isSecureContext: Boolean(locationBrowser), addEventListener() {}, visualViewport, + const context = { document, navigator: { ...locationBrowser, mediaSession }, window: { isSecureContext: Boolean(locationBrowser), addEventListener() {}, visualViewport, localStorage: { getItem(key) { if (storageDisabled) throw new Error('denied'); return preferences.get(key) ?? null; }, setItem(key, value) { if (storageDisabled) throw new Error('denied'); preferences.set(key, value); }, removeItem(key) { preferences.delete(key); }, }, }, + MediaMetadata: class { constructor(value) { Object.assign(this, value); } }, location: { host: 'server:8080', protocol: locationBrowser ? 'https:' : 'http:' }, URL: { createObjectURL: () => 'blob:frame', revokeObjectURL() {} }, Date, WebSocket: Socket, MediaStreamTrackProcessor: processorAvailable ? class { constructor(init) { @@ -387,7 +385,7 @@ test('interrupted display resumes with fresh authentication after a server resta const retry = [...f.timers.values()].find(t => t.delay === 1000); assert.ok(retry); await retry.fn(); assert.equal(f.sockets.length, 2); - assert.equal(f.sockets[1].url, 'ws://server:8080/stream?token=fresh-session&media=2&images=3'); + assert.equal(f.sockets[1].url, 'ws://server:8080/stream?token=fresh-session&media=2&images=3&nowPlaying=1'); }); test('revocation during an active display stops retries and requires fresh pairing', async () => { @@ -863,7 +861,6 @@ test('Direct Canvas retries over v3 WebP without Video and only switches after t assert.equal(f.createdVideos.length, 0); }); - test('browser GPS option forwards through the authenticated stream and stops on socket loss', async () => { let deliver, clears = 0, queries = 0; const f = fixture({ locationBrowser: { @@ -884,3 +881,24 @@ test('browser GPS option forwards through the authenticated stream and stops on deliver({ timestamp: Date.now(), coords: { latitude: 26, longitude: 121, accuracy: 5 } }); assert.equal(ws.sent.length, count); }); + +test('the paired socket publishes phone state to Media Session and clears it before reconnecting', async () => { + const handlers = new Map(), positions = []; + const session = { setActionHandler: (action, fn) => handlers.set(action, fn), setPositionState: value => positions.push(value) }; + const f = fixture({ mediaSession: session }), ws = await f.connect(); + assert.match(ws.url, /nowPlaying=1/); + const state = { type: 'now-playing', sessionId: 'phone', revision: 1, connectionGeneration: 3, trackRevision: 4, + connected: true, available: true, title: 'Song', artist: 'Artist', status: 'playing', durationMs: 60000, positionMs: 1000, speed: 1 }; + ws.onmessage({ data: JSON.stringify(state) }); + assert.equal(session.metadata.title, 'Song'); assert.equal(session.playbackState, 'playing'); + assert.equal(positions.at(-1).position, 1); + const pause = handlers.get('pause'); pause(); + assert.equal(ws.sent.at(-1).type, 'media-command'); assert.equal(ws.sent.at(-1).connectionGeneration, 3); + ws.onmessage({ data: JSON.stringify({ type: 'media-command-result', requestId: 1, result: 'sent' }) }); + assert.equal(session.playbackState, 'playing'); + ws.close(); + assert.equal(session.metadata, null); assert.equal(session.playbackState, 'none'); + const count = ws.sent.length; pause(); assert.equal(ws.sent.length, count); + ws.onmessage({ data: JSON.stringify({ ...state, revision: 2 }) }); + assert.equal(session.metadata, null); +});