/** * player.js — 「看视频」这件事:播放存活探测 + 有界观看 * * 需求原话是"随机观看 10-35 秒,且不单靠 sleep"。 * 为什么裸 sleep 不行:sleep(20000) 只保证**墙上时钟**走了 20 秒, * 它不关心这 20 秒里视频是否卡在加载、是否弹了窗、是否被切走。 * 网络一抖,sleep 出来的"观看"就是假的。 * * 这里的做法:把"观看"实现成一个**带守卫的截止时间循环**—— * 1. 先等"播放真的开始"(有界等待,而不是假定它已经开始); * 2. 观看期间持续做两件事: * a) 校验"还在播放页"(页面身份漂移就立刻记账,连续漂移就放弃并上报); * b) 按固定节拍采样**播放存活证据**,连续多次无证据即判定卡顿,尝试一次唤醒; * 3. 循环里那个 sleep 是**轮询节拍**(多久检查一次),不是"等待多少秒"。 * * 播放存活证据(按可用性排序,可配置): * - useAudio : AudioManager.isMusicActive() —— 不依赖截图权限,实测可用 * - usePixels: captureScreen() 两点像素差 —— 最强,但 Android 14 需要 MediaProjection 授权 * 两者都不可用时,脚本**不会假装成功**:会退化为"页面守卫",并在结果里 * 明确标记 probeMode='page-guard-only',让上层知道这次的"观看"可信度较低。 * * 依赖:TikTokAuto.logger、TikTokAuto.layout、TikTokAuto.tiktok、TikTokAuto.wait、TikTokAuto.ui */ TikTokAuto.player = (function () { 'use strict'; var PIXEL_SAMPLE_POINTS = [ [0.5, 0.35], [0.5, 0.5], [0.5, 0.65], [0.25, 0.45], [0.75, 0.45] ]; // ------------------------------------------------------------------ 证据源 function createAudioProbe() { var manager = null; try { if (typeof context !== 'undefined' && context) { manager = context.getSystemService('audio'); } } catch (error) { manager = null; } if (!manager) { return null; } return { name: 'audio', read: function () { try { return manager.isMusicActive() === true; } catch (error) { return false; } } }; } function createPixelProbe() { var first = null; function grab() { var image = captureScreen(); if (!image) { return null; } var width = image.getWidth(); var height = image.getHeight(); var samples = []; for (var i = 0; i < PIXEL_SAMPLE_POINTS.length; i += 1) { var x = Math.floor(width * PIXEL_SAMPLE_POINTS[i][0]); var y = Math.floor(height * PIXEL_SAMPLE_POINTS[i][1]); samples.push(image.pixel(x, y)); } return samples.join(','); } try { first = grab(); } catch (error) { return null; // 没有 MediaProjection 授权,直接放弃这条证据源 } if (first === null) { return null; } return { name: 'pixels', read: function () { try { var now = grab(); return now !== null && now !== first; } catch (error) { return false; } } }; } /** * 组装可用的探测源。 * 返回的 probeMode 会一路带到最终结果里——**不要**把降级悄悄藏起来。 */ function createProbe(config) { var sources = []; if (config.probe.useAudio) { var audio = createAudioProbe(); if (audio) { sources.push(audio); } } if (config.probe.usePixels) { var pixels = createPixelProbe(); if (pixels) { sources.push(pixels); } } return { mode: sources.length === 0 ? 'page-guard-only' : sources.map(function (s) { return s.name; }).join('+'), degraded: sources.length === 0, /** 任一证据源报"在动"就算活着 */ alive: function () { for (var i = 0; i < sources.length; i += 1) { if (sources[i].read()) { return true; } } return false; } }; } // ------------------------------------------------------------------ 等待播放 /** 等播放真正开始(而不是假定点开就在播) */ function waitPlaybackStart(config, step) { var logger = TikTokAuto.logger; var probe = createProbe(config); if (probe.degraded) { logger.warn(step, '没有可用的播放证据源(无音频/无截图权限),退化为页面守卫模式'); return { started: false, probe: probe }; } var started = TikTokAuto.wait.until(function () { return probe.alive() ? true : null; }, config.timeouts.playbackStartMs, config.probe.pollTickMs); if (started) { logger.info(step, '检测到播放已开始(证据源 ' + probe.mode + ')'); } else { logger.warn(step, '等待 ' + config.timeouts.playbackStartMs + 'ms 未检测到播放证据,仍继续(可能是静音视频)'); } return { started: started === true, probe: probe }; } // ------------------------------------------------------------------ 观看 function randomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min; } /** * 观看视频。 * * @param config 生效配置 * @param options { step, onInterrupt } * @returns 观看报告(对象,不是布尔——上层需要知道"为什么/可信度如何") */ function watch(config, options) { var settings = options || {}; var step = settings.step || 'watch'; var logger = TikTokAuto.logger; var tiktok = TikTokAuto.tiktok; var targetMs = randomInt(config.watch.minMs, config.watch.maxMs); var start = waitPlaybackStart(config, step); var probe = start.probe; var beganAt = Date.now(); var deadline = beganAt + targetMs; var tickMs = config.probe.pollTickMs; var nextLivenessAt = beganAt + config.probe.livenessIntervalMs; var stalls = 0; var offPage = 0; var maxOffPage = 3; var nudges = 0; var aborted = null; logger.info(step, '开始观看,计划 ' + targetMs + 'ms(区间 ' + config.watch.minMs + '-' + config.watch.maxMs + 'ms),证据源=' + probe.mode); while (Date.now() < deadline) { // (a) 页面身份守卫:不在播放页就不是"在看视频" if (!tiktok.onPlayerPage()) { offPage += 1; logger.warn(step, '已离开播放页(第 ' + offPage + ' 次检测)'); if (offPage >= maxOffPage) { aborted = 'left-player-page'; break; } } else { offPage = 0; } // (b) 播放存活采样 if (Date.now() >= nextLivenessAt) { nextLivenessAt = Date.now() + config.probe.livenessIntervalMs; if (!probe.degraded) { if (probe.alive()) { stalls = 0; } else { stalls += 1; logger.warn(step, '未检测到播放证据(连续 ' + stalls + '/' + config.probe.stallTolerance + ')'); if (stalls >= config.probe.stallTolerance) { // 只尝试一次唤醒,不无限戳屏幕——那会把页面点出别的东西 if (nudges < 1) { nudge(step); nudges += 1; stalls = 0; } else { logger.warn(step, '判定卡顿且唤醒无效,提前结束观看'); aborted = 'stalled'; break; } } } } } TikTokAuto.wait.tick(tickMs); } var actualMs = Date.now() - beganAt; var report = { requestedMs: targetMs, actualMs: actualMs, probeMode: probe.mode, degraded: probe.degraded, nudges: nudges, aborted: aborted, completed: aborted === null && actualMs >= targetMs - tickMs }; if (report.completed) { logger.info(step, '观看完成 ' + actualMs + 'ms / 目标 ' + targetMs + 'ms'); } else { logger.warn(step, '观看未按计划完成:' + JSON.stringify(report)); } return report; } /** 轻点视频区域一次,尝试恢复播放(不会点到右侧动作栏) */ function nudge(step) { var layout = TikTokAuto.layout; var point = { x: Math.floor(layout.screen().width * 0.5), y: Math.floor(layout.screen().height * 0.4) }; TikTokAuto.logger.info(step, '尝试轻点画面恢复播放 @(' + point.x + ',' + point.y + ')'); TikTokAuto.ui.tapPoint(point.x, point.y); TikTokAuto.wait.tick(300); } /** 等播放页就绪(点开卡片之后、开始计时之前) */ function waitPlayerReady(config, step) { var logger = TikTokAuto.logger; var tiktok = TikTokAuto.tiktok; /* 先花最多 4 秒分辨"打开的是不是视频"。 * * 搜索结果里混着照片轮播帖,它的信息行是 `Photo · 1w ago`,**永远不会有播放页**, * 而 `onPlayerPage()` 也认不出来 —— 不提前判掉就要白等满 playerReadyMs(20 秒)。 * 真机实测:一轮 4 个目标里撞到 1 个照片帖,就白白浪费掉 20 秒。 * * 认出是视频(或两种都不是)之后仍走原来的等待,不改变正常路径的行为。 */ var kind = TikTokAuto.wait.waitFor(step + ':kind', function () { if (tiktok.onPlayerPage()) { return 'player'; } if (tiktok.photoPost()) { return 'photo'; } return null; }, 4000, config.probe.pollTickMs); if (kind === 'photo') { logger.info(step, '打开的是照片帖(没有播放页),跳过'); return false; } var ready = TikTokAuto.wait.waitFor(step, function () { return tiktok.onPlayerPage() ? true : null; }, config.timeouts.playerReadyMs, config.probe.pollTickMs); if (ready) { TikTokAuto.wait.waitQuiet(function () { return TikTokAuto.layout.signature(); }, { maxMs: 3000, tickMs: 250, stableTimes: 3 }); } return ready === true; } return { createProbe: createProbe, waitPlaybackStart: waitPlaybackStart, waitPlayerReady: waitPlayerReady, watch: watch, randomInt: randomInt }; }());