|
@@ -10,19 +10,24 @@
|
|
|
* The above copyright notice and this permission notice shall be included in all
|
|
* The above copyright notice and this permission notice shall be included in all
|
|
|
* copies or substantial portions of the Software.
|
|
* copies or substantial portions of the Software.
|
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
|
- * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
|
|
|
- * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
|
|
|
- * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
|
|
|
- * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
|
|
|
- * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
|
|
|
|
|
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
|
|
|
|
+ * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
|
|
|
+ * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
|
|
|
+ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
|
|
|
+ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
|
*/
|
|
*/
|
|
|
import { selectVideoMedia, VideoRequestError } from './videoApi.js'
|
|
import { selectVideoMedia, VideoRequestError } from './videoApi.js'
|
|
|
|
|
|
|
|
-/** One visible attempt, owned preparation before media, and a bounded cleanup observer. */
|
|
|
|
|
|
|
+/** One visible attempt, owned preparation before media, a bounded retry budget and cleanup observer. */
|
|
|
export function createVideoLiveSession(api, update, timers = globalThis) {
|
|
export function createVideoLiveSession(api, update, timers = globalThis) {
|
|
|
let generation = 0, current = null, cleanupTimer, cleanupChecks = 0, disposed = false
|
|
let generation = 0, current = null, cleanupTimer, cleanupChecks = 0, disposed = false
|
|
|
const releases = new Set()
|
|
const releases = new Set()
|
|
|
const cleanupMessage = '会话释放未确认,等待服务端回收;可重试清理'
|
|
const cleanupMessage = '会话释放未确认,等待服务端回收;可重试清理'
|
|
|
|
|
+ // Retry policy: five automatic attempts after the first normal play, spaced 2/4/8/16/30s.
|
|
|
|
|
+ const retryDelays = [2000, 4000, 8000, 16000, 30000]
|
|
|
|
|
+ const stablePlaybackMs = 30000
|
|
|
|
|
+ const unrecoverable = new Set(['DEVICE_NOT_FOUND', 'VIDEO_UNAVAILABLE', 'SESSION_FORBIDDEN', 'INVALID_RESPONSE', 'NOT_CONFIGURED', 'INVALID_REQUEST', 'UNSUPPORTED_MEDIA'])
|
|
|
|
|
+ let retryBudget = retryDelays.length, retryTimer, retryAttempt = 0, stableTimer
|
|
|
const publish = (patch) => { if (!disposed) update(patch) }
|
|
const publish = (patch) => { if (!disposed) update(patch) }
|
|
|
const reportCleanup = () => publish({ cleanupError: releases.size ? cleanupMessage : '' })
|
|
const reportCleanup = () => publish({ cleanupError: releases.size ? cleanupMessage : '' })
|
|
|
const active = (attempt) => !disposed && current === attempt && attempt.version === generation
|
|
const active = (attempt) => !disposed && current === attempt && attempt.version === generation
|
|
@@ -30,6 +35,10 @@ export function createVideoLiveSession(api, update, timers = globalThis) {
|
|
|
if (!attempt) return
|
|
if (!attempt) return
|
|
|
timers.clearTimeout(attempt.startTimer); timers.clearTimeout(attempt.readyTimer); timers.clearInterval(attempt.heartbeat)
|
|
timers.clearTimeout(attempt.startTimer); timers.clearTimeout(attempt.readyTimer); timers.clearInterval(attempt.heartbeat)
|
|
|
}
|
|
}
|
|
|
|
|
+ const cancelRetry = () => {
|
|
|
|
|
+ timers.clearTimeout(retryTimer); retryTimer = undefined
|
|
|
|
|
+ timers.clearTimeout(stableTimer); stableTimer = undefined
|
|
|
|
|
+ }
|
|
|
function scheduleCleanup() {
|
|
function scheduleCleanup() {
|
|
|
if (disposed || cleanupTimer || !releases.size || cleanupChecks >= 40) return
|
|
if (disposed || cleanupTimer || !releases.size || cleanupChecks >= 40) return
|
|
|
cleanupTimer = timers.setTimeout(async () => {
|
|
cleanupTimer = timers.setTimeout(async () => {
|
|
@@ -63,6 +72,7 @@ export function createVideoLiveSession(api, update, timers = globalThis) {
|
|
|
}
|
|
}
|
|
|
async function stop() {
|
|
async function stop() {
|
|
|
++generation
|
|
++generation
|
|
|
|
|
+ cancelRetry()
|
|
|
const attempt = current; current = null; clear(attempt)
|
|
const attempt = current; current = null; clear(attempt)
|
|
|
publish({ media: null, phase: 'idle' })
|
|
publish({ media: null, phase: 'idle' })
|
|
|
if (attempt?.identity) releases.add(attempt.identity)
|
|
if (attempt?.identity) releases.add(attempt.identity)
|
|
@@ -71,25 +81,59 @@ export function createVideoLiveSession(api, update, timers = globalThis) {
|
|
|
reportCleanup()
|
|
reportCleanup()
|
|
|
return releases.size === 0
|
|
return releases.size === 0
|
|
|
}
|
|
}
|
|
|
|
|
+ /** Only a temporary failure with budget left is retried; capability/ownership errors are final. */
|
|
|
|
|
+ function shouldRetry(error) {
|
|
|
|
|
+ if (disposed || !current || releases.size || retryBudget <= 0) return false
|
|
|
|
|
+ // VideoRequestError carries `category`; accepted errors use `code`.
|
|
|
|
|
+ const reason = error?.category || error?.code
|
|
|
|
|
+ return !unrecoverable.has(reason)
|
|
|
|
|
+ } function scheduleRetry(deviceCode, message) {
|
|
|
|
|
+ const attempt = current
|
|
|
|
|
+ // Spend the budget here, before the timer exists, so the last retry cannot schedule another.
|
|
|
|
|
+ retryBudget -= 1
|
|
|
|
|
+ retryAttempt += 1
|
|
|
|
|
+ const delay = retryDelays[retryDelays.length - retryBudget - 1] ?? retryDelays[retryDelays.length - 1]
|
|
|
|
|
+ publish({ phase: 'retrying', error: message, retryAttempt, retryDelayMs: delay, retriesLeft: retryBudget })
|
|
|
|
|
+ retryTimer = timers.setTimeout(() => {
|
|
|
|
|
+ retryTimer = undefined
|
|
|
|
|
+ if (!active(attempt) || releases.size) return
|
|
|
|
|
+ void begin(deviceCode)
|
|
|
|
|
+ }, delay)
|
|
|
|
|
+ }
|
|
|
async function fail(message, media) {
|
|
async function fail(message, media) {
|
|
|
if (!current || (media && current.media !== media)) return
|
|
if (!current || (media && current.media !== media)) return
|
|
|
- publish({ error: message || '视频播放失败' })
|
|
|
|
|
|
|
+ const error = message instanceof Error ? message : Object.assign(new Error(message || '视频播放失败'), {})
|
|
|
|
|
+ const deviceCode = current.deviceCode
|
|
|
|
|
+ publish({ error: error.message || '视频播放失败' })
|
|
|
|
|
+ const attempt = current
|
|
|
|
|
+ const retryable = shouldRetry(error)
|
|
|
const stopping = stop(), version = generation
|
|
const stopping = stop(), version = generation
|
|
|
- await stopping
|
|
|
|
|
- if (version === generation) publish({ phase: 'failed' })
|
|
|
|
|
|
|
+ const released = await stopping
|
|
|
|
|
+ if (disposed || version !== generation) return
|
|
|
|
|
+ if (!retryable || !released) { publish({ phase: 'failed' }); return }
|
|
|
|
|
+ generation = version
|
|
|
|
|
+ current = attempt
|
|
|
|
|
+ attempt.version = version
|
|
|
|
|
+ scheduleRetry(deviceCode, error.message)
|
|
|
}
|
|
}
|
|
|
function ready(media) {
|
|
function ready(media) {
|
|
|
if (!current?.media || current.identity?.released || (media && current.media !== media)) return
|
|
if (!current?.media || current.identity?.released || (media && current.media !== media)) return
|
|
|
timers.clearTimeout(current.readyTimer)
|
|
timers.clearTimeout(current.readyTimer)
|
|
|
publish({ phase: 'playing' })
|
|
publish({ phase: 'playing' })
|
|
|
|
|
+ // A retry budget is earned only after real, sustained playback.
|
|
|
|
|
+ timers.clearTimeout(stableTimer)
|
|
|
|
|
+ const attempt = current
|
|
|
|
|
+ stableTimer = timers.setTimeout(() => {
|
|
|
|
|
+ if (!active(attempt)) return
|
|
|
|
|
+ retryBudget = retryDelays.length; retryAttempt = 0
|
|
|
|
|
+ }, stablePlaybackMs)
|
|
|
}
|
|
}
|
|
|
- async function start(deviceCode) {
|
|
|
|
|
- const stopping = stop(), version = generation
|
|
|
|
|
- await stopping
|
|
|
|
|
- if (disposed || version !== generation) return
|
|
|
|
|
|
|
+ async function begin(deviceCode) {
|
|
|
|
|
+ if (disposed) return
|
|
|
if (releases.size) {
|
|
if (releases.size) {
|
|
|
publish({ phase: 'failed', error: '上次清理结果尚未确认,暂不能重复建流' }); return
|
|
publish({ phase: 'failed', error: '上次清理结果尚未确认,暂不能重复建流' }); return
|
|
|
}
|
|
}
|
|
|
|
|
+ const version = generation
|
|
|
const attempt = { version, deviceCode, identity: null, media: null }
|
|
const attempt = { version, deviceCode, identity: null, media: null }
|
|
|
current = attempt
|
|
current = attempt
|
|
|
publish({ phase: 'starting', media: null, error: '', cleanupError: '' })
|
|
publish({ phase: 'starting', media: null, error: '', cleanupError: '' })
|
|
@@ -134,13 +178,21 @@ export function createVideoLiveSession(api, update, timers = globalThis) {
|
|
|
finally { beating = false }
|
|
finally { beating = false }
|
|
|
}, 30000)
|
|
}, 30000)
|
|
|
} catch (error) {
|
|
} catch (error) {
|
|
|
- if (active(attempt)) await fail(error.message || '视频连接失败')
|
|
|
|
|
|
|
+ if (active(attempt)) await fail(error)
|
|
|
else if (attempt.identity) await release(attempt.identity)
|
|
else if (attempt.identity) await release(attempt.identity)
|
|
|
} finally {
|
|
} finally {
|
|
|
timers.clearTimeout(attempt.startTimer)
|
|
timers.clearTimeout(attempt.startTimer)
|
|
|
reportCleanup()
|
|
reportCleanup()
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
+ async function start(deviceCode) {
|
|
|
|
|
+ const stopping = stop(), version = generation
|
|
|
|
|
+ await stopping
|
|
|
|
|
+ if (disposed || version !== generation) return
|
|
|
|
|
+ // A manual replay starts a fresh budget; automatic retries never call this.
|
|
|
|
|
+ retryBudget = retryDelays.length; retryAttempt = 0
|
|
|
|
|
+ await begin(deviceCode)
|
|
|
|
|
+ }
|
|
|
async function dispose() { await stop(); disposed = true; timers.clearTimeout(cleanupTimer) }
|
|
async function dispose() { await stop(); disposed = true; timers.clearTimeout(cleanupTimer) }
|
|
|
return { start, stop, dispose, ready, fail }
|
|
return { start, stop, dispose, ready, fail }
|
|
|
}
|
|
}
|