@@ -212,6 +212,8 @@ const extension = {
212212} ;
213213
214214const WSS_PLATFORM = 'kick' ;
215+ const KICK_VIEWER_HEARTBEAT_INTERVAL_MS = 30000 ;
216+ const KICK_VIEWER_DISCONNECT_EMIT_DEBOUNCE_MS = 1500 ;
215217let extensionInitialized = false ;
216218let lastBridgeNotifyStatus = null ;
217219let lastAuthNotifyStatus = null ;
@@ -220,6 +222,16 @@ let socketBridgeInitialized = false;
220222let kickWsEventLogCount = 0 ;
221223let kickWsLastEventLogAt = 0 ;
222224const ignoredEventTypesLogged = new Set ( ) ;
225+ const kickViewerHeartbeat = {
226+ intervalId : null ,
227+ pollInFlight : false ,
228+ lastKnownCount : 0 ,
229+ hasKnownCount : false ,
230+ hadConnectedTransport : false ,
231+ isLive : null ,
232+ lastSentAt : 0 ,
233+ lastPollErrorAt : 0
234+ } ;
223235
224236const LITE_MESSAGE_PREFIX = 'kick-lite-' ;
225237let liteBridgeCoreReady = false ;
@@ -1588,6 +1600,7 @@ function setChannelSlug(value, options = {}) {
15881600 state . channelId = null ;
15891601 state . lastResolvedSlug = '' ;
15901602 state . autoStart . lastSlug = '' ;
1603+ resetKickViewerHeartbeatState ( ) ;
15911604 resetThirdPartyEmoteCache ( ) ;
15921605 resetChatFeed ( ) ;
15931606 } else {
@@ -2039,7 +2052,248 @@ function supportsLocalSocket() {
20392052 return ! ! ( window . ninjafy && typeof window . ninjafy . startKickWebSocket === 'function' ) ;
20402053}
20412054
2055+ function parseViewerCountCandidate ( candidate ) {
2056+ if ( typeof candidate === 'number' && Number . isFinite ( candidate ) ) {
2057+ return Math . max ( 0 , Math . floor ( candidate ) ) ;
2058+ }
2059+ if ( typeof candidate === 'string' ) {
2060+ const digits = candidate . replace ( / [ ^ 0 - 9 ] / g, '' ) ;
2061+ if ( ! digits ) {
2062+ return null ;
2063+ }
2064+ const parsed = parseInt ( digits , 10 ) ;
2065+ if ( Number . isFinite ( parsed ) ) {
2066+ return parsed ;
2067+ }
2068+ }
2069+ return null ;
2070+ }
2071+
2072+ function extractKickViewerCount ( payload ) {
2073+ if ( ! payload || typeof payload !== 'object' ) {
2074+ return null ;
2075+ }
2076+ const viewerCountCandidates = [
2077+ payload ?. viewer_count ,
2078+ payload ?. viewers ,
2079+ payload ?. viewerCount ,
2080+ payload ?. concurrent_viewers ,
2081+ payload ?. concurrent ,
2082+ payload ?. meta ?. viewer_count ,
2083+ payload ?. meta ?. viewers ,
2084+ payload ?. summary ?. viewer_count ,
2085+ payload ?. summary ?. viewers ,
2086+ payload ?. channel ?. viewer_count ,
2087+ payload ?. channel ?. viewers_count ,
2088+ payload ?. channel ?. viewers ,
2089+ payload ?. livestream ?. viewer_count ,
2090+ payload ?. livestream ?. viewers ,
2091+ payload ?. stream ?. viewer_count ,
2092+ payload ?. stream ?. viewers
2093+ ] ;
2094+ for ( const candidate of viewerCountCandidates ) {
2095+ const normalized = parseViewerCountCandidate ( candidate ) ;
2096+ if ( normalized != null ) {
2097+ return normalized ;
2098+ }
2099+ }
2100+ return null ;
2101+ }
2102+
2103+ function extractKickLiveFlag ( payload ) {
2104+ if ( ! payload || typeof payload !== 'object' ) {
2105+ return null ;
2106+ }
2107+ const liveCandidates = [
2108+ payload ?. is_live ,
2109+ payload ?. isLive ,
2110+ payload ?. online ,
2111+ payload ?. status ,
2112+ payload ?. stream_status ,
2113+ payload ?. livestream ?. is_live ,
2114+ payload ?. livestream ?. isLive ,
2115+ payload ?. livestream ?. online ,
2116+ payload ?. livestream ?. status ,
2117+ payload ?. stream ?. is_live ,
2118+ payload ?. stream ?. isLive ,
2119+ payload ?. stream ?. online ,
2120+ payload ?. stream ?. status
2121+ ] ;
2122+ for ( const candidate of liveCandidates ) {
2123+ if ( typeof candidate === 'boolean' ) {
2124+ return candidate ;
2125+ }
2126+ if ( typeof candidate === 'number' ) {
2127+ if ( candidate === 1 ) return true ;
2128+ if ( candidate === 0 ) return false ;
2129+ }
2130+ if ( typeof candidate === 'string' ) {
2131+ const value = candidate . trim ( ) . toLowerCase ( ) ;
2132+ if ( ! value ) {
2133+ continue ;
2134+ }
2135+ if ( [ 'live' , 'online' , 'started' , 'active' , 'on' ] . includes ( value ) ) {
2136+ return true ;
2137+ }
2138+ if ( [ 'offline' , 'ended' , 'stopped' , 'inactive' , 'off' ] . includes ( value ) ) {
2139+ return false ;
2140+ }
2141+ }
2142+ }
2143+ return null ;
2144+ }
2145+
2146+ function isKickViewerTransportConnected ( ) {
2147+ return state . bridge ?. status === 'connected' || state . socket ?. status === 'connected' ;
2148+ }
2149+
2150+ function shouldRunKickViewerHeartbeat ( ) {
2151+ if ( ! state . channelSlug ) {
2152+ return false ;
2153+ }
2154+ if ( ! isKickViewerTransportConnected ( ) ) {
2155+ return false ;
2156+ }
2157+ // Explicit offline status pauses heartbeat until we get a live/unknown state again.
2158+ return kickViewerHeartbeat . isLive !== false ;
2159+ }
2160+
2161+ function emitKickViewerUpdate ( count ) {
2162+ const normalizedCount = Number . isFinite ( count ) ? Math . max ( 0 , Math . floor ( count ) ) : 0 ;
2163+ kickViewerHeartbeat . lastKnownCount = normalizedCount ;
2164+ kickViewerHeartbeat . hasKnownCount = true ;
2165+ kickViewerHeartbeat . lastSentAt = Date . now ( ) ;
2166+ pushMessage ( {
2167+ type : 'kick' ,
2168+ event : 'viewer_update' ,
2169+ meta : normalizedCount
2170+ } ) ;
2171+ return normalizedCount ;
2172+ }
2173+
2174+ async function fetchKickViewerSnapshot ( ) {
2175+ const slugInput = state . channelSlug ?. trim ( ) ;
2176+ if ( ! slugInput || ! state . tokens ?. access_token ) {
2177+ return null ;
2178+ }
2179+ const slugLower = normalizeChannel ( slugInput ) ;
2180+ if ( ! slugLower ) {
2181+ return null ;
2182+ }
2183+ const params = new URLSearchParams ( { slug : slugLower } ) ;
2184+ const data = await apiFetch ( `/public/v1/channels?${ params . toString ( ) } ` ) ;
2185+ const entries = Array . isArray ( data ?. data ) ? data . data : [ ] ;
2186+ const channel = entries . find ( item => normalizeChannel ( item ?. slug ) === slugLower ) || entries [ 0 ] ;
2187+ if ( ! channel || typeof channel !== 'object' ) {
2188+ return null ;
2189+ }
2190+ return {
2191+ viewerCount : extractKickViewerCount ( channel ) ,
2192+ isLive : extractKickLiveFlag ( channel )
2193+ } ;
2194+ }
2195+
2196+ async function sendKickViewerHeartbeat ( reason = 'interval' ) {
2197+ if ( ! shouldRunKickViewerHeartbeat ( ) ) {
2198+ return ;
2199+ }
2200+ if ( kickViewerHeartbeat . pollInFlight ) {
2201+ return ;
2202+ }
2203+ kickViewerHeartbeat . pollInFlight = true ;
2204+ try {
2205+ let nextViewerCount = null ;
2206+ const snapshot = await fetchKickViewerSnapshot ( ) ;
2207+ if ( snapshot ) {
2208+ if ( typeof snapshot . isLive === 'boolean' ) {
2209+ kickViewerHeartbeat . isLive = snapshot . isLive ;
2210+ }
2211+ if ( snapshot . viewerCount != null ) {
2212+ nextViewerCount = snapshot . viewerCount ;
2213+ } else if ( kickViewerHeartbeat . isLive === false ) {
2214+ nextViewerCount = 0 ;
2215+ }
2216+ }
2217+ if ( nextViewerCount == null ) {
2218+ nextViewerCount = kickViewerHeartbeat . hasKnownCount ? kickViewerHeartbeat . lastKnownCount : 0 ;
2219+ }
2220+ emitKickViewerUpdate ( nextViewerCount ) ;
2221+ } catch ( err ) {
2222+ const now = Date . now ( ) ;
2223+ if ( ! kickViewerHeartbeat . lastPollErrorAt || now - kickViewerHeartbeat . lastPollErrorAt > 120000 ) {
2224+ kickViewerHeartbeat . lastPollErrorAt = now ;
2225+ logKickWs ( `Viewer heartbeat fallback (${ reason } ): ${ err ?. message || err } ` , 'warning' ) ;
2226+ }
2227+ const fallbackCount = kickViewerHeartbeat . hasKnownCount ? kickViewerHeartbeat . lastKnownCount : 0 ;
2228+ emitKickViewerUpdate ( fallbackCount ) ;
2229+ } finally {
2230+ kickViewerHeartbeat . pollInFlight = false ;
2231+ if ( ! shouldRunKickViewerHeartbeat ( ) ) {
2232+ syncKickViewerHeartbeat ( false ) ;
2233+ }
2234+ }
2235+ }
2236+
2237+ function syncKickViewerHeartbeat ( emitZeroOnStop = false ) {
2238+ const transportConnected = isKickViewerTransportConnected ( ) ;
2239+ if ( transportConnected ) {
2240+ kickViewerHeartbeat . hadConnectedTransport = true ;
2241+ }
2242+ const shouldRun = shouldRunKickViewerHeartbeat ( ) ;
2243+ if ( shouldRun ) {
2244+ if ( ! kickViewerHeartbeat . intervalId ) {
2245+ kickViewerHeartbeat . intervalId = setInterval ( ( ) => {
2246+ void sendKickViewerHeartbeat ( 'interval' ) ;
2247+ } , KICK_VIEWER_HEARTBEAT_INTERVAL_MS ) ;
2248+ }
2249+ if (
2250+ ! kickViewerHeartbeat . lastSentAt ||
2251+ ( Date . now ( ) - kickViewerHeartbeat . lastSentAt ) >= KICK_VIEWER_HEARTBEAT_INTERVAL_MS
2252+ ) {
2253+ void sendKickViewerHeartbeat ( 'start' ) ;
2254+ }
2255+ return ;
2256+ }
2257+
2258+ if ( kickViewerHeartbeat . intervalId ) {
2259+ clearInterval ( kickViewerHeartbeat . intervalId ) ;
2260+ kickViewerHeartbeat . intervalId = null ;
2261+ }
2262+
2263+ if ( emitZeroOnStop && ! transportConnected ) {
2264+ const hadSession = kickViewerHeartbeat . hadConnectedTransport || kickViewerHeartbeat . hasKnownCount ;
2265+ const now = Date . now ( ) ;
2266+ const shouldEmitDisconnectZero =
2267+ hadSession &&
2268+ (
2269+ ! kickViewerHeartbeat . lastSentAt ||
2270+ ( now - kickViewerHeartbeat . lastSentAt ) > KICK_VIEWER_DISCONNECT_EMIT_DEBOUNCE_MS ||
2271+ kickViewerHeartbeat . lastKnownCount !== 0
2272+ ) ;
2273+ if ( shouldEmitDisconnectZero ) {
2274+ emitKickViewerUpdate ( 0 ) ;
2275+ }
2276+ kickViewerHeartbeat . hadConnectedTransport = false ;
2277+ kickViewerHeartbeat . isLive = false ;
2278+ }
2279+ }
2280+
2281+ function resetKickViewerHeartbeatState ( ) {
2282+ if ( kickViewerHeartbeat . intervalId ) {
2283+ clearInterval ( kickViewerHeartbeat . intervalId ) ;
2284+ kickViewerHeartbeat . intervalId = null ;
2285+ }
2286+ kickViewerHeartbeat . pollInFlight = false ;
2287+ kickViewerHeartbeat . lastKnownCount = 0 ;
2288+ kickViewerHeartbeat . hasKnownCount = false ;
2289+ kickViewerHeartbeat . hadConnectedTransport = false ;
2290+ kickViewerHeartbeat . isLive = null ;
2291+ kickViewerHeartbeat . lastSentAt = 0 ;
2292+ kickViewerHeartbeat . lastPollErrorAt = 0 ;
2293+ }
2294+
20422295function updateSocketState ( payload = { } ) {
2296+ syncKickViewerHeartbeat ( true ) ;
20432297 if ( ! els . socketState ) return ;
20442298 if ( ! supportsLocalSocket ( ) ) {
20452299 // Hide socket status in browser - it's only relevant for desktop app
@@ -2156,6 +2410,7 @@ async function connectLocalSocket(force = false) {
21562410
21572411 state . socket . connecting = true ;
21582412 state . socket . status = 'connecting' ;
2413+ kickViewerHeartbeat . isLive = null ;
21592414 updateSocketState ( { status : 'connecting' } ) ;
21602415 if ( ! state . tokens ?. access_token ) {
21612416 logKickWs ( 'No Kick access token found for socket lookup.' , 'warning' ) ;
@@ -3113,6 +3368,7 @@ function connectBridge() {
31133368 const source = new EventSource ( bridgeUrl , { withCredentials : false } ) ;
31143369 state . bridge . source = source ;
31153370 state . bridge . status = 'connecting' ;
3371+ kickViewerHeartbeat . isLive = null ;
31163372 updateBridgeState ( ) ;
31173373
31183374 source . onopen = ( ) => {
@@ -3366,6 +3622,7 @@ function bridgeEventMatchesCurrentChannel(packet) {
33663622}
33673623
33683624function updateBridgeState ( ) {
3625+ syncKickViewerHeartbeat ( true ) ;
33693626 if ( ! els . bridgeState ) return ;
33703627 if ( state . bridge . status === 'connected' ) {
33713628 els . bridgeState . textContent = 'Bridge connected' ;
@@ -4197,7 +4454,9 @@ function forwardSupportEvent(eventType, evt, bridgeMeta) {
41974454}
41984455
41994456function forwardLiveStatus ( evt , bridgeMeta ) {
4200- const isLive = Boolean ( evt ?. is_live ) ;
4457+ const explicitLiveState = extractKickLiveFlag ( evt ) ;
4458+ const isLive = explicitLiveState === true || evt ?. is_live === true ;
4459+ const hasExplicitOffline = explicitLiveState === false || evt ?. is_live === false ;
42014460 const chatname = 'Kick' ;
42024461 const chatmessage = isLive ? 'Stream is now LIVE' : 'Stream is now OFFLINE' ;
42034462 pushMessage ( {
@@ -4208,52 +4467,22 @@ function forwardLiveStatus(evt, bridgeMeta) {
42084467 meta : evt
42094468 } ) ;
42104469
4211- const viewerCountCandidates = [
4212- evt ?. viewer_count ,
4213- evt ?. viewers ,
4214- evt ?. viewerCount ,
4215- evt ?. concurrent_viewers ,
4216- evt ?. concurrent ,
4217- evt ?. meta ?. viewer_count ,
4218- evt ?. meta ?. viewers ,
4219- evt ?. summary ?. viewer_count ,
4220- evt ?. summary ?. viewers ,
4221- evt ?. channel ?. viewer_count ,
4222- evt ?. channel ?. viewers_count ,
4223- evt ?. channel ?. viewers ,
4224- evt ?. livestream ?. viewer_count ,
4225- evt ?. livestream ?. viewers ,
4226- evt ?. stream ?. viewer_count ,
4227- evt ?. stream ?. viewers
4228- ] ;
4229- let viewerTotal = null ;
4230- for ( const candidate of viewerCountCandidates ) {
4231- if ( typeof candidate === 'number' && Number . isFinite ( candidate ) ) {
4232- viewerTotal = Math . max ( 0 , Math . floor ( candidate ) ) ;
4233- break ;
4234- }
4235- if ( typeof candidate === 'string' ) {
4236- const digits = candidate . replace ( / [ ^ 0 - 9 ] / g, '' ) ;
4237- if ( ! digits ) {
4238- continue ;
4239- }
4240- const parsed = parseInt ( digits , 10 ) ;
4241- if ( Number . isFinite ( parsed ) ) {
4242- viewerTotal = parsed ;
4243- break ;
4244- }
4245- }
4470+ if ( typeof explicitLiveState === 'boolean' ) {
4471+ kickViewerHeartbeat . isLive = explicitLiveState ;
4472+ } else if ( evt ?. is_live === true ) {
4473+ kickViewerHeartbeat . isLive = true ;
4474+ } else if ( evt ?. is_live === false ) {
4475+ kickViewerHeartbeat . isLive = false ;
42464476 }
4247- if ( ! isLive ) {
4477+
4478+ let viewerTotal = extractKickViewerCount ( evt ) ;
4479+ if ( hasExplicitOffline ) {
42484480 viewerTotal = 0 ;
42494481 }
42504482 if ( viewerTotal != null ) {
4251- pushMessage ( {
4252- type : 'kick' ,
4253- event : 'viewer_update' ,
4254- meta : viewerTotal
4255- } ) ;
4483+ emitKickViewerUpdate ( viewerTotal ) ;
42564484 }
4485+ syncKickViewerHeartbeat ( false ) ;
42574486
42584487 const prefix = bridgeMeta ?. verified === false ? '[LIVE ⚠]' : '[LIVE]' ;
42594488 if ( viewerTotal != null ) {
0 commit comments