CASTRECEIVERDESIGN

July 2, 2026 · View on GitHub

立项日期:2026-07-01 分支:feature/cast-capability 关联:CAST_FEATURE_PLAN.md · CAST_TEST_PLAYBOOK.md


0. 立项动因

手机真机 + 桌面接收器组合是投屏开发/回归的最小闭环

  • 手机端就是真实生产代码(无 mock),符合"测试要测到功能正常"(JAVA_TEST_PLAYBOOK.md#L11-L15
  • 桌面端做一台"假电视",随开随用,不用采购真实智能电视
  • 桌面端可暴露 HTTP 日志端点,供自动化脚本 curl 断言

约束:桌面接收器只用于本地测试,不参与 publish.gradle / maven-central-publish.gradle 发布链。


1. 整体架构

┌─────────────────────────┐              ┌──────────────────────────────┐
│  手机真机(发送端)      │              │  桌面接收器 gsy-cast-receiver │
│  Android App / Debug    │              │  纯 JVM,Java 17+             │
│                         │              │                              │
│  GSYVideoPlayer Demo    │              │  ┌────────────────────────┐  │
│  ├─ SampleCastControl…  │              │  │ jUPnP Registry         │  │
│  ├─ GSYCastManager      │  SSDP 组播   │  │ + LocalDevice(Renderer)│  │
│  └─ DLNAProvider        │◄────────────►│  │   ├─ AVTransport       │  │
│                         │              │  │   ├─ RenderingControl  │  │
│                         │  HTTP 拉流   │  │   └─ ConnectionManager │  │
│                         │◄────────────►│  └────────────────────────┘  │
│                         │              │  ┌────────────────────────┐  │
│                         │              │  │ ReceiverPlayer         │  │
│                         │              │  │ ├─ VLCJ (默认)          │  │
│                         │              │  │ └─ HeadlessPlayer (CI)  │  │
│                         │              │  └────────────────────────┘  │
│                         │              │  ┌────────────────────────┐  │
│                         │              │  │ Swing UI + LogPanel    │  │
│                         │              │  │ HTTP /log/tail 端点     │  │
│                         │              │  └────────────────────────┘  │
└─────────────────────────┘              └──────────────────────────────┘
        ▲                                             ▲
        └─────────── 同一 Wi-Fi / 网段 ────────────────┘

2. 技术选型

选型版本锚点理由
语言Java 17gradle.properties 现有 target 保持一致,不引入 Kotlin/JVM 冲突
UPnPorg.jupnp:org.jupnp:3.0.2Cling 官方继任者,社区活跃,接收/发送端可共用协议实现
视频渲染uk.co.caprica:vlcj:4.8.2 + 系统 libvlc支持 HLS/DASH/RTMP/HTTP/本地文件,无需自己写解码
UISwing + CallbackMediaPlayerComponentVLCJ 与 JavaFX 兼容有坑,Swing 是最稳的桌面方案
构建Gradle Application plugin与主项目 build.gradle 一体化
日志java.util.logging + 内嵌 SimpleHttpServer无三方依赖,格式对齐 Android 端 Debuger.java

为什么不用 Kotlin/Compose Desktop:VLCJ 需要 native window handle,Compose Desktop 的 SwingPanel 嵌套 native window 在部分 macOS/Linux 版本上有闪烁 bug。Swing 是最稳的路径。


3. 模块结构

gsy-cast-receiver/                             # 项目根新增,与 gsyVideoPlayer-* 平级
├── build.gradle                               # Java 17 + Application plugin
├── README.md                                  # 快速启动
└── src/main/
    ├── java/com/shuyu/cast/receiver/
    │   ├── CastReceiverMain.java              # 入口
    │   ├── config/
    │   │   ├── ReceiverConfig.java            # 设备名 / UDN / 端口 / 播放器类型
    │   │   └── Args.java                      # CLI 参数解析
    │   ├── upnp/
    │   │   ├── VirtualRendererDevice.java     # 组装 UPnP 设备
    │   │   ├── AVTransportServiceImpl.java    # ★ 核心
    │   │   ├── RenderingControlServiceImpl.java
    │   │   ├── ConnectionManagerServiceImpl.java
    │   │   └── DidlLiteParser.java            # metadata 解析
    │   ├── player/
    │   │   ├── ReceiverPlayer.java            # 抽象接口
    │   │   ├── VlcjReceiverPlayer.java        # VLCJ 实现
    │   │   └── HeadlessReceiverPlayer.java    # CI 用,只记录不渲染
    │   ├── ui/
    │   │   ├── ReceiverFrame.java             # Swing 主窗口
    │   │   ├── VideoCanvas.java               # VLCJ 渲染画布
    │   │   ├── StatusBar.java                 # State / Position / Volume
    │   │   └── LogPanel.java                  # 内嵌日志面板
    │   ├── http/
    │   │   └── LogHttpServer.java             # /log/tail /state /health
    │   ├── fault/
    │   │   └── FaultInjector.java             # --reject-uri / --slow-response 等
    │   └── util/
    │       └── CastLogger.java                # 格式对齐 Android 端 Debuger
    └── resources/
        └── device-description.xml.template

4. 关键设计点

4.1 UPnP 服务组装(VirtualRendererDevice)

LocalDevice device = new LocalDevice(
    new DeviceIdentity(UDN.uniqueSystemIdentifier(config.deviceName)),
    new UDADeviceType("MediaRenderer", 1),
    new DeviceDetails(
        config.deviceName,                               // 用户在手机端看到的名字
        new ManufacturerDetails("shuyu"),
        new ModelDetails("GSYReceiver", "GSY 投屏测试接收器", "v1")
    ),
    new LocalService[]{
        avTransportService,
        renderingControlService,
        connectionManagerService
    }
);
upnpService.getRegistry().addDevice(device);

要点

  • UDN 使用系统唯一 ID + config.deviceName 加盐,保证同机多实例可共存
  • MediaRenderer:1 兼容主流 DLNA 客户端(含 GSYCastManager 的 DLNA provider)

4.2 AVTransport 动作 → ReceiverPlayer

@UpnpAction
public void setAVTransportURI(
        @UpnpInputArgument(name = "InstanceID") UnsignedIntegerFourBytes id,
        @UpnpInputArgument(name = "CurrentURI") String uri,
        @UpnpInputArgument(name = "CurrentURIMetaData") String metadata) throws Exception {
    if (faultInjector.shouldRejectUri()) throw new ActionException(ErrorCode.ACTION_FAILED);
    CastLogger.i("SetAVTransportURI " + uri);                // ← 断言点
    Map<String, String> headers = DidlLiteParser.parseHeaders(metadata);
    receiverPlayer.load(uri, headers);
    setTransportState(TransportState.STOPPED);
}

@UpnpAction public void play(...) throws Exception {
    if (faultInjector.shouldDelay()) Thread.sleep(3000);
    receiverPlayer.play();
    CastLogger.i("onCastPlay");                              // ← 断言点
    setTransportState(TransportState.PLAYING);
}

@UpnpAction public void pause(...) { receiverPlayer.pause(); CastLogger.i("onCastPause"); setTransportState(TransportState.PAUSED_PLAYBACK); }
@UpnpAction public void seek(...)  { long ms = parseSeekMs(target); receiverPlayer.seek(ms); CastLogger.i("onCastSeek " + ms); }
@UpnpAction public void stop(...)  { receiverPlayer.stop();  CastLogger.i("onCastStop"); setTransportState(TransportState.STOPPED); }

@UpnpAction(out = { @UpnpOutputArgument(name = "Track", getterName = "getTrack") })
public PositionInfo getPositionInfo(...) {
    long duration = receiverPlayer.getDuration();
    long position = receiverPlayer.getPosition();
    return new PositionInfo(0, ProtocolInfo.formatDuration(duration), currentUri,
                            ProtocolInfo.formatDuration(position), ProtocolInfo.formatDuration(position));
}

要点:每一条 action 都产生唯一格式的日志onCast<Action> [payload]),测试脚本 curl /log/tail 后 grep 即可断言。

4.3 播放器抽象

public interface ReceiverPlayer {
    void load(String uri, Map<String, String> headers);
    void play(); void pause(); void stop();
    void seek(long positionMs);
    long getPosition(); long getDuration();
    void addListener(PlayerEventListener l);
    void release();
}
  • VlcjReceiverPlayer(默认):MediaPlayerFactory.newEmbeddedMediaPlayer(),Canvas 挂 Swing Frame
  • HeadlessReceiverPlayer--headless / CI 用):仅记录 URI / position,用 ScheduledExecutorService 模拟推进 position

4.4 UI 布局(Swing)

┌─────────────────────────────────────────────────────────────┐
│ GSY Cast Receiver  [Device: GSY-Desk-01]  [IP: 192.168.1.5] │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│                                                             │
│                   [VLCJ VideoCanvas]                        │
│                                                             │
│                                                             │
├─────────────────────────────────────────────────────────────┤
│ State: PLAYING   Pos: 00:12/03:20   Vol: 60%   URI: http... │
├─────────────────────────────────────────────────────────────┤
│ [Log] tail 200                                              │
│ 14:22:01.123 SetAVTransportURI http://.../test.mp4          │
│ 14:22:01.130 VLCJ opening media...                          │
│ 14:22:02.001 onCastPlay                                     │
│ 14:22:15.412 onCastSeek 30000                               │
│ 14:22:15.610 VLCJ time-changed 30012                        │
└─────────────────────────────────────────────────────────────┘

日志面板与 LogHttpServer 共享 ring buffer(ArrayDeque<String>, size=500)。

4.5 HTTP 端点(供测试脚本使用)

端点方法用途
/healthGET健康检查,返回 OK + 版本
/stateGET返回 JSON:{state, uri, position, duration, device_name}
/log/tail?n=200GET返回最近 N 行日志
/log/grep?q=xxx&since=tsGET供脚本按关键词轮询
/fault?mode=reject-uri&enable=1POST动态开关故障注入(无需重启)

4.6 故障注入模式

模式CLI / API发送端预期
--reject-uriSetAVTransportURI 返回 501Android onCastError,回退本地
--slow-response=3000所有 action 延迟 3sAndroid loading UI 出现,不 ANR
--drop-after-play=10000起播 10s 后关 SSDP 通告Android 检测断连,触发退投
--wrong-position=+30000getPositionInfo 上报位置偏移 30sAndroid UI 进度不错乱

5. 命令行参数

Usage: java -jar gsy-cast-receiver.jar [options]

  --device-name <name>       设备名 (default: GSY-Desk-01)
  --port <port>              UPnP HTTP 端口 (default: 0 = 随机)
  --log-port <port>          日志 HTTP 端点端口 (default: 18080)
  --player {vlcj|headless}   播放器实现 (default: vlcj)
  --headless                 无 UI 模式,等价于 --player=headless
  --network <cidr>           限定 SSDP 通告的网段 (default: 全部)
  --reject-uri               故障:拒绝 SetAVTransportURI
  --slow-response <ms>       故障:所有 action 延迟 ms 毫秒
  --drop-after-play <ms>     故障:起播后 ms 断开
  --wrong-position <delta>   故障:位置上报偏移
  --log-level {info|debug|trace}

6. Gradle 集成

6.1 settings.gradle(追加)

include ':gsy-cast-receiver'

注意gsy-cast-receiver纯 JVM 项目,不能被 Android app 依赖,app/build.gradle 不要 include 它。

6.2 gsy-cast-receiver/build.gradle

plugins { id 'application'; id 'java' }
java { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
application { mainClass = 'com.shuyu.cast.receiver.CastReceiverMain' }
dependencies {
    implementation 'org.jupnp:org.jupnp:3.0.2'
    implementation 'uk.co.caprica:vlcj:4.8.2'
    implementation 'org.slf4j:slf4j-simple:2.0.13'
    testImplementation 'junit:junit:4.13.2'
}
run { standardInput = System.in; args = project.hasProperty('appArgs') ? project.appArgs.split(' ') : [] }

6.3 启动命令

# 默认模式(Swing UI + VLCJ)
./gradlew :gsy-cast-receiver:run

# 传参
./gradlew :gsy-cast-receiver:run -PappArgs="--device-name GSY-Desk-01 --port 8080"

# CI headless
./gradlew :gsy-cast-receiver:run -PappArgs="--headless"

# 打包 fat jar
./gradlew :gsy-cast-receiver:jar
java -jar gsy-cast-receiver/build/libs/gsy-cast-receiver-*.jar --device-name GSY-Desk-01

7. VLCJ 安装要求(人工)

VLCJ 是 JNA 绑定,需要系统安装 libvlc(VLC Player 官方版即可)。

平台安装方式
Windows官网下载 VLC 3.x 64bit,安装后自动可用
macOSbrew install --cask vlc
Linuxsudo apt install vlcsudo dnf install vlc

首次运行若报 NativeLibraryNotFoundException,需在 CastReceiverMain 中通过 System.setProperty("jna.library.path", "<vlc install path>") 手动指定路径。启动脚本会自动探测常见路径。


8. 与已有能力的隔离保证

现有资产影响保证
Android 主项目编译链gsy-cast-receiver 是 Java application 插件,与 Android Gradle Plugin 完全隔离
publish.gradle 发布gsy-cast-receiver 不 apply 任何 publish 脚本
现有 39 个 Java Demo 冷烟测桌面接收器不在 Android 侧,不影响 APK
CI .github/workflows/ci.yml微增追加一步 ./gradlew :gsy-cast-receiver:build,若失败不阻断 Android 发布

9. 测试建议

参见 CAST_TEST_PLAYBOOK.md。桌面接收器自身单元测试重点:

  • DidlLiteParserTest:不同电视厂商 metadata 兼容
  • AVTransportServiceImplTest:action 序列 + 状态机
  • FaultInjectorTest:4 种故障模式行为符合预期
  • LogHttpServerTest/log/tail/log/grep 端点稳定

运行:

./gradlew :gsy-cast-receiver:test

10. 后续演进

阶段内容
M2 完成桌面接收器覆盖 DLNA 全动作,作为默认测试端
M3引入 --record-session 录制手机指令流,回放做回归
M4追加 Chromecast Discovery Emulator(可选,参考 castmate / pychromecast 思路)
长期提供 Docker 镜像 gsy-cast-receiver:latest,一键 docker run 起协议链

11. 变更记录

日期版本内容
2026-07-01v0.1桌面接收器初始设计,M0 落地