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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion entry/src/main/ets/pages/AppListPageV2.ets
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import { PairState } from '../model/ComputerInfo';
import { OptionPickerDialog, OptionPickerDialogConfig, OptionItem } from '../components/dialogs/OptionPickerDialog';
import { ConfirmDialog, ConfirmDialogConfig } from '../components/dialogs/ConfirmDialog';
import { ToastQueue } from '../utils/ToastQueue';
import { UiSettings } from '../service/SettingsService';
import { UiSettings, SettingsService } from '../service/SettingsService';
import { StreamingSession } from '../service/streaming/StreamingSession';

interface AppListPageParams {
computerId: string;
Expand Down Expand Up @@ -152,6 +153,36 @@ struct AppListPageV2 {
} else if (this.isLoading) {
this.isLoading = false;
}

// 进入应用列表 = 用户即将开始串流,提前预热"启动链路"上的高开销资源:
// 1) Native lib (.so) 加载 + NAPI 注册(首次冷启动 100-500ms)
// 2) 解码器能力查询(StreamingSession 启动时会调用 2 次)
// 3) 流配置 / 输入设置 / 显示设置(首次读取走 Preferences I/O,后续在内存中)
// 全部失败兜底:忽略,正常路径在 startStreaming 里会重新触发。
this.preheatStreamResources();
}

/**
* 预热串流启动链路上的高开销资源(native + 设置)。
* 不阻塞 UI;任何失败都仅打日志,启动链路会重新走完整路径。
*/
private preheatStreamResources(): void {
try {
const caps = StreamingSession.getDecoderCapabilities();
console.info(`AppListPage: native 预热完成, H264=${caps.supportsH264}, HEVC=${caps.supportsHEVC}, AV1=${caps.supportsAV1}`);
} catch (err) {
console.warn(`AppListPage: native 预热失败: ${(err as Error).message}`);
}
const settings = SettingsService.getInstance();
settings.getStreamConfig().catch((err: Error) => {
console.warn(`AppListPage: stream 配置预热失败: ${err.message}`);
});
settings.getInputSettings().catch((err: Error) => {
console.warn(`AppListPage: input 配置预热失败: ${err.message}`);
});
settings.getDisplaySettings().catch((err: Error) => {
console.warn(`AppListPage: display 配置预热失败: ${err.message}`);
});
}

aboutToDisappear(): void {
Expand Down Expand Up @@ -450,6 +481,8 @@ struct AppListPageV2 {
if (computer) {
computer.runningGameId = serverInfo.currentGame;
}
// 写入 ServerInfo 缓存:用户随后点击应用进入串流时可省一次 HTTPS round-trip
this.computerManager.cacheServerInfo(this.computerId, serverInfo);
} catch {
// 忽略服务器状态获取失败
}
Expand Down
57 changes: 45 additions & 12 deletions entry/src/main/ets/pages/StreamPage.ets
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,9 @@ struct StreamPage {
private panZoomHandler: PanZoomHandler = new PanZoomHandler();
private controllerTypes: Map<number, number> = new Map(); // slot → MoonlightControllerType
private aiKeyService: AiKeyService | null = null;
private aiKeyNvHttp: NvHttp | null = null;
// AI 可用性探测的延时定时器,aboutToDisappear 时需清理
private aiProbeTimer: number | null = null;

// 页面参数
private computerId: string = '';
Expand Down Expand Up @@ -275,24 +278,15 @@ struct StreamPage {
}

// 初始化 AI 按键推荐服务
// 注意:AI 探测的 HTTPS 请求会与串流启动链路抢带宽 / TLS handshake,因此这里只创建
// 服务实例,把可用性探测推迟到 startStreaming() 成功之后再触发(见 probeAiAvailable)。
if (this.computerId) {
try {
const computer = ComputerManager.getInstance().getComputer(this.computerId);
if (computer) {
const nvHttp = NvHttp.fromComputer(computer, this.context);
this.aiKeyService = new AiKeyService(nvHttp, this.context);

// 异步探测 AI 可用性,延迟显示避免与手柄连接等 toast 冲突
nvHttp.checkAiAvailable().then((available: boolean) => {
if (available) {
setTimeout(() => {
ToastQueue.show({
message: '✨ AI LLM 已连接,可使用 AIV+ 功能',
duration: 2500
});
}, 3000);
}
});
this.aiKeyNvHttp = nvHttp;
}
} catch (err) {
console.warn(`[StreamPage] AI 服务初始化失败: ${err}`);
Expand Down Expand Up @@ -462,6 +456,35 @@ struct StreamPage {
private static readonly UNHANDLED_USB_DETECT_DELAY_MS: number = 5000;
private static readonly UNHANDLED_USB_DETECT_REPEAT_MS: number = 10000;

/**
* 串流就绪后异步探测 AI LLM 可用性。
* 推迟到此处而非 aboutToAppear,是为了避免与启动链路(serverinfo / launchApp / RTSP)
* 抢带宽和 TLS 资源;用户也已经看到画面,多 1-3 秒延迟感知不到。
*/
private probeAiAvailableDeferred(): void {
const nvHttp = this.aiKeyNvHttp;
if (!nvHttp) return;
if (this.aiProbeTimer !== null) {
clearTimeout(this.aiProbeTimer);
this.aiProbeTimer = null;
}
this.aiProbeTimer = setTimeout(() => {
this.aiProbeTimer = null;
// 二次校验:页面已销毁则 aiKeyNvHttp 会被置空
if (!this.aiKeyNvHttp) return;
nvHttp.checkAiAvailable().then((available: boolean) => {
if (available && this.aiKeyNvHttp) {
ToastQueue.show({
message: '✨ AI LLM 已连接,可使用 AIV+ 功能',
duration: 2500
});
}
}).catch((err: Error) => {
console.warn(`[StreamPage] AI 可用性探测失败: ${err.message}`);
});
}, 2000);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* 调度未识别 USB 手柄检测:
* - 串流连接成功后延时执行(让 GCK 充分扫描)
Expand Down Expand Up @@ -674,6 +697,13 @@ struct StreamPage {
aboutToDisappear(): void {
console.info('StreamPage aboutToDisappear - cleaning up');

// 取消 AI 可用性延时探测,避免页面销毁后回调触发 Toast / 网络请求
if (this.aiProbeTimer !== null) {
clearTimeout(this.aiProbeTimer);
this.aiProbeTimer = null;
}
this.aiKeyNvHttp = null;

this.stopClipboardSync();

// 取消 Network Boost 场景监听
Expand Down Expand Up @@ -1311,6 +1341,9 @@ struct StreamPage {
await this.launchStream();
this.streamStartedAtMs = Date.now();

// 串流就绪后再做 AI 服务可用性探测,避免与启动链路抢带宽
this.probeAiAvailableDeferred();

await this.startClipboardSync();

// 超分辨率状态 Toast
Expand Down
56 changes: 56 additions & 0 deletions entry/src/main/ets/service/ComputerManager.ets
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ interface PollResult {
/** 轮询间隔(毫秒),参照 Android ComputerManagerService */
const SERVERINFO_POLLING_PERIOD_MS = 5000;

/**
* ServerInfo 缓存有效期(毫秒)。
*
* 用于在 ComputerManager 轮询 / AppListPage 刷新拿到的 ServerInfo 之上,
* 让后续"进入串流"链路可以跳过一次 HTTPS round-trip。仅复用与连接无关的字段
* (hostname/uniqueId/serverCodecModeSupport/currentGame 等),不会影响诊断准确性。
*/
const SERVER_INFO_CACHE_TTL_MS = 30_000;

/**
* ServerInfo 缓存项
*/
interface CachedServerInfo {
info: ServerInfo;
timestamp: number;
}

/** 全 0 MAC:远程响应或 HTTP 未配对响应中的占位值,不能用它覆盖已发现的真实 MAC */
const ZERO_MAC = '00:00:00:00:00:00';

Expand Down Expand Up @@ -63,6 +80,12 @@ export class ComputerManager {
/** 离线轮询计数:连续失败次数,达到 INITIAL_POLL_TRIES/OFFLINE_POLL_TRIES 阈值后才标 OFFLINE */
private offlineCount: Map<string, number> = new Map();

/**
* ServerInfo 缓存(key=computer uuid)。
* 由轮询 / 刷新链路写入;StreamingSession.fetchServerInfo 命中即复用,省一次 HTTPS。
*/
private serverInfoCache: Map<string, CachedServerInfo> = new Map();

// ═══════════════════════════════════════════════════════════
// 初始化 & 单例
// ═══════════════════════════════════════════════════════════
Expand Down Expand Up @@ -368,6 +391,36 @@ export class ComputerManager {
return computer ? ComputerPersistence.cloneComputer(computer) : undefined;
}

/**
* 缓存最新的 ServerInfo(轮询 / AppList 刷新成功时调用)
* 用于让"进入串流"链路跳过一次 HTTPS /serverinfo 请求。
*/
cacheServerInfo(uuid: string, info: ServerInfo): void {
if (!uuid) return;
this.serverInfoCache.set(uuid, { info, timestamp: Date.now() });
}

/**
* 获取最近 ${SERVER_INFO_CACHE_TTL_MS}ms 内缓存的 ServerInfo,过期/缺失返回 null。
*/
getCachedServerInfo(uuid: string): ServerInfo | null {
if (!uuid) return null;
const entry = this.serverInfoCache.get(uuid);
if (!entry) return null;
if (Date.now() - entry.timestamp > SERVER_INFO_CACHE_TTL_MS) {
this.serverInfoCache.delete(uuid);
return null;
}
return entry.info;
}

/**
* 主动让某台电脑的 ServerInfo 缓存失效(例如配对/解配后状态可能改变时)
*/
invalidateServerInfoCache(uuid: string): void {
this.serverInfoCache.delete(uuid);
}

/**
* 更新电脑信息(用于配对成功后更新 serverCert 等)
* @param uuid 电脑 UUID
Expand Down Expand Up @@ -854,6 +907,9 @@ export class ComputerManager {
// 合并服务器信息(所有字段守卫规则集中在 mergeServerInfo)
this.mergeServerInfo(target, info, result.address);

// 缓存最新 ServerInfo,让"进入串流"链路省一次 HTTPS round-trip
this.cacheServerInfo(target.uuid, info);

// 如果仍缺少 IPv6 全局地址,异步补充(不阻塞轮询流程)
if (!target.ipv6Address && info.hostname) {
this.lookupAndSetIpv6(target, info.hostname);
Expand Down
53 changes: 50 additions & 3 deletions entry/src/main/ets/service/streaming/StreamingSession.ets
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,8 @@ export class StreamingSession {
private resolutionChangedCallback: ResolutionChangedCallback | null = null;
private stageProgressCallback: ((stage: number, stageName: string) => void) | null = null;
private connectionStatusCallback: ((connectionStatus: number) => void) | null = null;
/** 视频解码器启动回调(drStart):在 LiStartConnection 内部触发,早于 start() resolve */
private decoderStartedCallback: (() => void) | null = null;

// ---------------------------------------------------------------------------
// 成员变量 — 手柄状态
Expand Down Expand Up @@ -440,6 +442,15 @@ export class StreamingSession {
this.connectionStatusCallback = callback;
}

/**
* 设置"视频解码器已启动"回调(对应 native drStart)。
* 此回调在 LiStartConnection 内部、首帧渲染前触发,比 session.start() resolve 更早。
* UI 可以借此提前隐藏 loading 蒙层,让用户更快看到画面。
*/
setDecoderStartedCallback(callback: (() => void) | null): void {
this.decoderStartedCallback = callback;
}

// ---------------------------------------------------------------------------
// 生命周期 — 启动 / 停止 / 恢复
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -473,9 +484,14 @@ export class StreamingSession {

try {
await this.resolveComputer(computerId, context);
await this.fetchServerInfo();
this.generateInputKey();
await this.initializeNative();
// 并行执行:网络请求(fetchServerInfo)与本地 native 初始化没有依赖,
// 可重叠执行节省 50-300ms。两者都会更新 stageProgressCallback 文本,
// 后到者覆盖前者,对用户感知无影响。
await Promise.all([
this.fetchServerInfo(),
this.initializeNative(),
]);
this.applyPreConnectionSettings(config);
await this.setupVideoSurface();

Expand All @@ -484,6 +500,9 @@ export class StreamingSession {
}
const cachedCurrentGame = this.getCachedCurrentGame();
await this.launchApp(cachedCurrentGame);
// 成功 launch/resume 后 currentGame 必然变化(变为本次 appId),
// 让缓存立即失效,避免下次进入串流复用陈旧的 currentGame 导致误判
ComputerManager.getInstance().invalidateServerInfoCache(this.computerId);
await this.connectToServer();
this.applyPostConnectionSettings(config);

Expand Down Expand Up @@ -896,6 +915,21 @@ export class StreamingSession {
private async fetchServerInfo(): Promise<void> {
if (!this.nvHttp) throw new Error('nvHttp 未初始化');

// 命中 ComputerManager 的 ServerInfo 缓存:避免与轮询/AppList 刷新刚拿到的
// 数据重复一次 HTTPS round-trip(典型局域网 50-200ms,远程更长)。
const cached = ComputerManager.getInstance().getCachedServerInfo(this.computerId);
if (cached) {
this.serverAppVersion = cached.appVersion || '';
this.serverGfeVersion = cached.gfeVersion || '';
this.serverCodecModeSupport = cached.serverCodecModeSupport || 0;
this.cachedCurrentGame = cached.currentGame || 0;
console.info(`串流: 命中 ServerInfo 缓存, currentGame=${this.cachedCurrentGame}, codecSupport=${this.serverCodecModeSupport}`);
if (this.stageProgressCallback) {
this.stageProgressCallback(0, '正在初始化...');
}
return;
}

// 主机/Sunshine 重启窗口(mDNS/ICMP 已恢复但 HTTPS 监听尚未 ready)会让单次 ServerInfo
// 请求必败。委托给 NvHttp.getServerInfoRobust 处理瞬时错误重试 + 取消响应,
// 避免在业务层重复维护错误识别 / 重试 / 取消的细节。
Expand All @@ -916,6 +950,9 @@ export class StreamingSession {
console.info(`服务器版本: app=${this.serverAppVersion}, gfe=${this.serverGfeVersion}, ` +
`codecSupport=${this.serverCodecModeSupport}, currentGame=${this.cachedCurrentGame}`);

// 写回缓存供后续短期内复用(场景:连接失败后用户立即重试)
ComputerManager.getInstance().cacheServerInfo(this.computerId, serverInfo);

if (this.stageProgressCallback) {
this.stageProgressCallback(0, '正在初始化...');
}
Expand Down Expand Up @@ -980,7 +1017,17 @@ export class StreamingSession {
if (this.codecStateCallback) this.codecStateCallback(actualCodec);
if (this.hdrStateCallback) this.hdrStateCallback(actualHdr);
},
drStart: (): void => { console.info('视频解码器启动'); },
drStart: (): void => {
console.info('视频解码器启动');
// 隔离外部回调异常,避免冒泡进 native 回调链导致连接流程不稳定
if (this.decoderStartedCallback) {
try {
this.decoderStartedCallback();
} catch (err) {
console.error(`[StreamingSession] decoderStartedCallback 执行失败: ${err}`);
}
}
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
drStop: (): void => { console.info('视频解码器停止'); },
drCleanup: (): void => { console.info('视频解码器清理'); },
arInit: (audioConfiguration: number, sampleRate: number, samplesPerFrame: number): void => {
Expand Down
6 changes: 5 additions & 1 deletion entry/src/main/ets/service/usbdriver/UsbDriverService.ets
Original file line number Diff line number Diff line change
Expand Up @@ -668,7 +668,11 @@ export class UsbDriverService implements UsbDriverListener {
static listKnownUsbGamepads(): KnownUsbGamepadInfo[] {
const result: KnownUsbGamepadInfo[] = [];
try {
const devices: usbManager.USBDevice[] = usbManager.getDevices();
// 模拟器/无 USB 权限场景下 getDevices() 可能返回 undefined
const devices: usbManager.USBDevice[] | undefined = usbManager.getDevices();
if (!devices || devices.length === 0) {
return result;
}
for (let i = 0; i < devices.length; i++) {
const d = devices[i];
const name = UsbDriverService.identifyKnownGamepad(d);
Expand Down
17 changes: 17 additions & 0 deletions entry/src/main/ets/viewmodel/StreamViewModel.ets
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,23 @@ export class StreamViewModel {
this.connectionProgress = Math.min(nativeProgress, 1.0);
this.connectionStageText = stageName;
});

// 视频解码器启动回调:drStart 在 LiStartConnection 内部触发,
// 比 session.start() resolve 更早。提前切到 CONNECTED 让 UI 立即隐藏 loading,
// 缩短"已连接 → 首帧"之间的视觉空窗。
// 捕获当前 session 实例,防止旧会话延迟回调污染新会话或把 ERROR/DISCONNECTED 改回 CONNECTED。
const currentSession = this.streamingSession;
this.streamingSession.setDecoderStartedCallback(() => {
if (this.streamingSession !== currentSession) {
return;
}
if (this.connectionState === StreamConnectionState.CONNECTING) {
console.info('StreamViewModel: drStart 触发,提前进入 CONNECTED 状态');
this.connectionProgress = 1.0;
this.connectionStageText = '已连接';
this.connectionState = StreamConnectionState.CONNECTED;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

// 更新进度:获取服务器信息
this.connectionProgress = 0.05;
Expand Down
Loading