mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-08-11 00:20:54 +00:00
4.20.19
* Changed: Update the styling for rich chat embeds to match modern vanilla Twitch. * Fixed: Do not display FFZ's Alternative Viewer Count if the stream is not live. * Fixed: Automatically opening the chat when accessing a channel. (Now using route state instead of page elements.) * Fixed: Dual channel points redemption messages when FFZ is rendering them. * Fixed: Stopping the host video player when accessing a user's home page. * Removed: The old, unfinished logviewer module. Logviewer is a dead project. * API Changed: Add support for rich formatting in rich chat embeds. * API Changed: `fine-router` can now check state when determining the current route.
This commit is contained in:
parent
fa3d73e05a
commit
a4fa1d1491
21 changed files with 451 additions and 629 deletions
|
@ -1,77 +1,8 @@
|
|||
<template>
|
||||
<a :href="url" class="chat-card__link" target="_blank" rel="noreferrer noopener">
|
||||
<div class="ffz--chat-card tw-elevation-1 tw-mg-t">
|
||||
<div class="tw-c-background-base tw-flex tw-flex-nowrap tw-pd-05">
|
||||
<div class="chat-card__preview-img tw-align-items-center tw-c-background-alt-2 tw-flex tw-flex-shrink-0 tw-justify-content-center">
|
||||
<img
|
||||
v-if="error"
|
||||
class="chat-card__error-img"
|
||||
src=""
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="tw-card-img tw-flex-shrink-0 tw-overflow-hidden"
|
||||
>
|
||||
<aspect :ratio="16/9">
|
||||
<img
|
||||
v-if="loaded && image"
|
||||
:src="image"
|
||||
:alt="title"
|
||||
class="tw-image"
|
||||
>
|
||||
</aspect>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
:class="{'ffz--two-line': desc_2}"
|
||||
class="ffz--card-text tw-overflow-hidden tw-align-items-center tw-flex"
|
||||
>
|
||||
<div class="tw-full-width tw-pd-l-1">
|
||||
<div class="chat-card__title tw-ellipsis">
|
||||
<span
|
||||
v-if="! loaded"
|
||||
class="tw-font-size-5"
|
||||
data-test-selector="chat-card-title"
|
||||
>
|
||||
{{ t('card.loading', 'Loading...') }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
:title="title"
|
||||
class="tw-font-size-5"
|
||||
data-test-selector="chat-card-title"
|
||||
>
|
||||
{{ title }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="loaded" class="tw-ellipsis">
|
||||
<span
|
||||
:title="desc_1"
|
||||
class="tw-c-text-alt-2 tw-font-size-6"
|
||||
data-test-selector="chat-card-description"
|
||||
>
|
||||
{{ desc_1 }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="loaded && desc_2" class="tw-ellipsis">
|
||||
<span
|
||||
:title="desc_2"
|
||||
class="tw-c-text-alt-2 tw-font-size-6"
|
||||
data-test-selector="chat-card-description"
|
||||
>
|
||||
{{ desc_2 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import {timeout} from 'utilities/object';
|
||||
import {has, timeout} from 'utilities/object';
|
||||
|
||||
const ERROR_IMAGE = 'https://static-cdn.jtvnw.net/emoticons/v1/58765/2.0';
|
||||
|
||||
export default {
|
||||
props: ['data', 'url'],
|
||||
|
@ -80,10 +11,17 @@ export default {
|
|||
return {
|
||||
loaded: false,
|
||||
error: false,
|
||||
title: null,
|
||||
html: null,
|
||||
title: this.t('card.loading', 'Loading...'),
|
||||
title_tokens: null,
|
||||
desc_1: null,
|
||||
desc_1_tokens: null,
|
||||
desc_2: null,
|
||||
image: null
|
||||
desc_2_tokens: null,
|
||||
image: null,
|
||||
image_title: null,
|
||||
image_square: false,
|
||||
accent: null
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -92,7 +30,7 @@ export default {
|
|||
try {
|
||||
data = this.data.getData();
|
||||
if ( data instanceof Promise ) {
|
||||
const to_wait = this.data.timeout || 1000;
|
||||
const to_wait = has(this.data, 'timeout') ? this.data.timeout : 1000;
|
||||
if ( to_wait )
|
||||
data = await timeout(data, to_wait);
|
||||
else
|
||||
|
@ -105,7 +43,6 @@ export default {
|
|||
title: this.t('card.error', 'An error occured.'),
|
||||
desc_1: this.t('card.empty', 'No data was returned.')
|
||||
}
|
||||
|
||||
} catch(err) {
|
||||
data = {
|
||||
error: true,
|
||||
|
@ -116,11 +53,157 @@ export default {
|
|||
|
||||
this.loaded = true;
|
||||
this.error = data.error;
|
||||
this.image = data.image;
|
||||
this.html = data.html;
|
||||
this.title = data.title;
|
||||
this.title_tokens = data.title_tokens;
|
||||
this.desc_1 = data.desc_1;
|
||||
this.desc_1_tokens = data.desc_1_tokens;
|
||||
this.desc_2 = data.desc_2;
|
||||
this.desc_2_tokens = data.desc_2_tokens;
|
||||
this.image = data.image;
|
||||
this.image_square = data.image_square;
|
||||
this.image_title = data.image_title;
|
||||
},
|
||||
|
||||
methods: {
|
||||
renderCard(h) {
|
||||
if ( this.data.renderBody )
|
||||
return [this.data.renderBody(h)];
|
||||
|
||||
if ( this.html )
|
||||
return [h('div', {
|
||||
domProps: {
|
||||
innerHTML: this.html
|
||||
}
|
||||
})];
|
||||
|
||||
return [
|
||||
this.renderImage(h),
|
||||
this.renderDescription(h)
|
||||
];
|
||||
},
|
||||
|
||||
renderTokens(tokens, h) {
|
||||
let out = [];
|
||||
if ( ! Array.isArray(tokens) )
|
||||
tokens = [tokens];
|
||||
|
||||
for(const token of tokens) {
|
||||
if ( Array.isArray(token) )
|
||||
out = out.concat(this.renderTokens(token, h));
|
||||
|
||||
else if ( typeof token !== 'object' )
|
||||
out.push(token);
|
||||
|
||||
else {
|
||||
const el = h(token.tag || 'span', {
|
||||
class: token.class,
|
||||
}, this.renderTokens(token.content, h));
|
||||
|
||||
out.push(el);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
},
|
||||
|
||||
renderDescription(h) {
|
||||
let title = this.title,
|
||||
title_tokens = this.title_tokens,
|
||||
desc_1 = this.desc_1,
|
||||
desc_1_tokens = this.desc_1_tokens,
|
||||
desc_2 = this.desc_2,
|
||||
desc_2_tokens = this.desc_2_tokens;
|
||||
|
||||
if ( ! this.loaded ) {
|
||||
desc_1 = this.t('card.loading', 'Loading...');
|
||||
desc_1_tokens = desc_2 = desc_2_tokens = title = title_tokens = null;
|
||||
}
|
||||
|
||||
return h('div', {
|
||||
class: [
|
||||
'ffz--card-text tw-overflow-hidden tw-align-items-center tw-flex',
|
||||
desc_2 && 'ffz--two-line'
|
||||
]
|
||||
}, [h('div', {class: 'tw-full-width tw-pd-l-1'}, [
|
||||
h('div', {class: 'chat-card__title tw-ellipsis'},
|
||||
[h('span', {class: 'tw-strong', attrs: {title}}, title_tokens ? this.renderTokens(title_tokens, h) : title)]),
|
||||
h('div', {class: 'tw-ellipsis'},
|
||||
[h('span', {class: 'tw-c-text-alt-2', attrs: {title: desc_1}}, desc_1_tokens ? this.renderTokens(desc_1_tokens, h) : desc_1)]),
|
||||
desc_2 && h('div', {class: 'tw-ellipsis'},
|
||||
[h('span', {class: 'tw-c-text-alt-2', attrs: {title: desc_2}}, desc_2_tokens ? this.renderTokens(desc_2_tokens, h) : desc_2)])
|
||||
])]);
|
||||
},
|
||||
|
||||
renderImage(h) {
|
||||
let content;
|
||||
if ( this.error )
|
||||
content = h('img', {
|
||||
class: 'chat-card__error-img',
|
||||
attrs: {
|
||||
src: ERROR_IMAGE
|
||||
}
|
||||
});
|
||||
else {
|
||||
content = h('div', {
|
||||
class: 'tw-card-img tw-flex-shrink-0 tw-overflow-hidden'
|
||||
}, [h('aspect', {
|
||||
props: {
|
||||
ratio: 16/9
|
||||
}
|
||||
}, [this.loaded && this.image && h('img', {
|
||||
class: 'tw-image',
|
||||
attrs: {
|
||||
src: this.image,
|
||||
alt: this.image_title ?? this.title
|
||||
}
|
||||
})])]);
|
||||
}
|
||||
|
||||
return h('div', {
|
||||
class: [
|
||||
'chat-card__preview-img tw-align-items-center tw-c-background-alt-2 tw-flex tw-flex-shrink-0 tw-justify-content-center',
|
||||
this.image_square && 'square'
|
||||
]
|
||||
}, [content])
|
||||
}
|
||||
},
|
||||
|
||||
render(h) {
|
||||
let content = h('div', {
|
||||
class: 'tw-flex tw-flex-nowrap tw-pd-05'
|
||||
}, this.renderCard(h));
|
||||
|
||||
if ( this.url ) {
|
||||
const tooltip = this.data.card_tooltip;
|
||||
content = h('a', {
|
||||
class: [
|
||||
tooltip && 'ffz-tooltip',
|
||||
this.accent && 'ffz-accent-card',
|
||||
!this.error && 'tw-interactable--hover-enabled',
|
||||
'tw-block tw-border-radius-medium tw-full-width tw-interactable tw-interactable--alpha tw-interactive'
|
||||
],
|
||||
attrs: {
|
||||
'data-tooltip-type': 'link',
|
||||
'data-url': this.url,
|
||||
'data-is-mail': false,
|
||||
target: '_blank',
|
||||
rel: 'noreferrer noopener',
|
||||
href: this.url
|
||||
}
|
||||
}, [content]);
|
||||
}
|
||||
|
||||
return h('div', {
|
||||
class: 'tw-border-radius-medium tw-elevation-1 ffz--chat-card',
|
||||
style: {
|
||||
'--ffz-color-accent': this.accent
|
||||
}
|
||||
}, [h('div', {
|
||||
class: 'tw-border-radius-medium tw-c-background-base tw-flex tw-full-width'
|
||||
}, [content])]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
</script>
|
|
@ -7,6 +7,12 @@
|
|||
const CLIP_URL = /^(?:https?:\/\/)?clips\.twitch\.tv\/(\w+)(?:\/)?(\w+)?(?:\/edit)?/;
|
||||
const NEW_CLIP_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/\w+\/clip\/(\w+)/;
|
||||
const VIDEO_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/(?:\w+\/v|videos)\/(\w+)/;
|
||||
const USER_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/([^/]+)$/;
|
||||
|
||||
const BAD_USERS = [
|
||||
'directory', '_deck', 'p', 'downloads', 'jobs', 'turbo', 'settings', 'friends',
|
||||
'subscriptions', 'inventory', 'wallet'
|
||||
];
|
||||
|
||||
import GET_CLIP from './clip_info.gql';
|
||||
import GET_VIDEO from './video_info.gql';
|
||||
|
@ -55,7 +61,9 @@ export const Links = {
|
|||
|
||||
return {
|
||||
url: token.url,
|
||||
accent: data.accent,
|
||||
image: this.context.get('tooltip.link-images') ? (data.image_safe || this.context.get('tooltip.link-nsfw-images') ) ? data.preview || data.image : null : null,
|
||||
image_square: data.image_square,
|
||||
title: data.title,
|
||||
desc_1: data.desc_1,
|
||||
desc_2: data.desc_2
|
||||
|
@ -66,13 +74,100 @@ export const Links = {
|
|||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Users
|
||||
// ============================================================================
|
||||
|
||||
export const Users = {
|
||||
type: 'user',
|
||||
hide_token: false,
|
||||
|
||||
test(token) {
|
||||
if ( token.type !== 'link' || (! this.context.get('chat.rich.all-links') && ! token.force_rich) )
|
||||
return false;
|
||||
|
||||
return USER_URL.test(token.url);
|
||||
},
|
||||
|
||||
process(token) {
|
||||
const match = USER_URL.exec(token.url),
|
||||
twitch_data = this.resolve('site.twitch_data');
|
||||
|
||||
if ( ! twitch_data || ! match || BAD_USERS.includes(match[1]) )
|
||||
return;
|
||||
|
||||
return {
|
||||
url: token.url,
|
||||
|
||||
getData: async () => {
|
||||
const user = await twitch_data.getUser(null, match[1]);
|
||||
if ( ! user || ! user.id )
|
||||
return null;
|
||||
|
||||
const game = user.broadcastSettings?.game?.displayName;
|
||||
|
||||
let desc_1 = null, desc_2 = null, desc_1_tokens = null, desc_2_tokens = null;
|
||||
if ( user.stream?.id && game ) {
|
||||
desc_1_tokens = this.i18n.tList('cards.user.streaming', 'streaming {game}', {
|
||||
game: {class: 'tw-semibold', content: [game]}
|
||||
});
|
||||
desc_1 = this.i18n.t('cards.user.streaming', 'streaming {game}', {
|
||||
game
|
||||
});
|
||||
}
|
||||
|
||||
const bits_tokens = this.i18n.tList('cards.user.stats', 'Views: {views,number} • Followers: {followers,number}', {
|
||||
views: {class: 'tw-semibold', content: [this.i18n.formatNumber(user.profileViewCount || 0)]},
|
||||
followers: {class: 'tw-semibold', content: [this.i18n.formatNumber(user.followers?.totalCount || 0)]}
|
||||
}),
|
||||
bits = this.i18n.t('cards.user.stats', 'Views: {views,number} • Followers: {followers,number}', {
|
||||
views: user.profileViewCount || 0,
|
||||
followers: user.followers?.totalCount || 0
|
||||
});
|
||||
|
||||
if ( desc_1 ) {
|
||||
desc_2 = bits;
|
||||
desc_2_tokens = bits_tokens;
|
||||
} else {
|
||||
desc_1 = bits;
|
||||
desc_1_tokens = bits_tokens;
|
||||
}
|
||||
|
||||
const has_i18n = user.displayName.trim().toLowerCase() !== user.login;
|
||||
let title = user.displayName, title_tokens = null;
|
||||
if ( has_i18n ) {
|
||||
title = `${user.displayName} (${user.login})`;
|
||||
title_tokens = [
|
||||
user.displayName,
|
||||
{class: 'chat-author__intl-login', content: ` (${user.login})`}
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
url: token.url,
|
||||
accent: user.primaryColorHex ? `#${user.primaryColorHex}` : null,
|
||||
image: user.profileImageURL,
|
||||
image_square: true,
|
||||
title,
|
||||
title_tokens,
|
||||
desc_1,
|
||||
desc_1_tokens,
|
||||
desc_2,
|
||||
desc_2_tokens
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// Clips
|
||||
// ============================================================================
|
||||
|
||||
export const Clips = {
|
||||
type: 'clip',
|
||||
hide_token: true,
|
||||
hide_token: false,
|
||||
|
||||
test(token) {
|
||||
if ( token.type !== 'link' )
|
||||
|
@ -110,29 +205,47 @@ export const Clips = {
|
|||
game_name = game && game.name,
|
||||
game_display = game && game.displayName;
|
||||
|
||||
let desc_1;
|
||||
if ( game_name === 'creative' )
|
||||
let desc_1, desc_1_tokens;
|
||||
if ( game_name === 'creative' ) {
|
||||
desc_1_tokens = this.i18n.tList('clip.desc.1.creative', '{user} being Creative', {
|
||||
user: {class: 'tw-semibold', content: user}
|
||||
});
|
||||
desc_1 = this.i18n.t('clip.desc.1.creative', '{user} being Creative', {
|
||||
user
|
||||
});
|
||||
|
||||
else if ( game )
|
||||
} else if ( game ) {
|
||||
desc_1_tokens = this.i18n.tList('clip.desc.1.playing', '{user} playing {game}', {
|
||||
user: {class: 'tw-semibold', content: user},
|
||||
game: {class: 'tw-semibold', game_display}
|
||||
});
|
||||
desc_1 = this.i18n.t('clip.desc.1.playing', '{user} playing {game}', {
|
||||
user,
|
||||
game: game_display
|
||||
});
|
||||
|
||||
else
|
||||
} else {
|
||||
desc_1_tokens = this.i18n.tList('clip.desc.1', 'Clip of {user}', {
|
||||
user: {class: 'tw-semibold', content: user}
|
||||
});
|
||||
desc_1 = this.i18n.t('clip.desc.1', 'Clip of {user}', {user});
|
||||
}
|
||||
|
||||
const curator = clip.curator ? clip.curator.displayName : this.i18n.t('clip.unknown', 'Unknown');
|
||||
|
||||
return {
|
||||
url: token.url,
|
||||
image: clip.thumbnailURL,
|
||||
title: clip.title,
|
||||
desc_1,
|
||||
desc_1_tokens,
|
||||
desc_2: this.i18n.t('clip.desc.2', 'Clipped by {curator} — {views,number} View{views,en_plural}', {
|
||||
curator: clip.curator ? clip.curator.displayName : this.i18n.t('clip.unknown', 'Unknown'),
|
||||
curator,
|
||||
views: clip.viewCount
|
||||
}),
|
||||
desc_2_tokens: this.i18n.tList('clip.desc.2', 'Clipped by {curator} — {views,number} View{views,en_plural}', {
|
||||
curator: clip.curator ? {class: 'tw-semibold', content: curator} : curator,
|
||||
views: {class: 'tw-semibold', content: this.i18n.formatNumber(clip.viewCount)}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -143,7 +256,7 @@ export const Clips = {
|
|||
|
||||
export const Videos = {
|
||||
type: 'video',
|
||||
hide_token: true,
|
||||
hide_token: false,
|
||||
|
||||
test(token) {
|
||||
return token.type === 'link' && VIDEO_URL.test(token.url)
|
||||
|
@ -174,26 +287,38 @@ export const Videos = {
|
|||
game_name = game && game.name,
|
||||
game_display = game && game.displayName;
|
||||
|
||||
let desc_1;
|
||||
if ( game_name === 'creative' )
|
||||
let desc_1, desc_1_tokens;
|
||||
if ( game_name === 'creative' ) {
|
||||
desc_1_tokens = this.i18n.tList('clip.desc.1.creative', '{user} being Creative', {
|
||||
user: {class: 'tw-semibold', content: user}
|
||||
});
|
||||
desc_1 = this.i18n.t('clip.desc.1.creative', '{user} being Creative', {
|
||||
user
|
||||
});
|
||||
|
||||
else if ( game )
|
||||
} else if ( game ) {
|
||||
desc_1_tokens = this.i18n.tList('clip.desc.1.playing', '{user} playing {game}', {
|
||||
user: {class: 'tw-semibold', content: user},
|
||||
game: {class: 'tw-semibold', content: game_display}
|
||||
});
|
||||
desc_1 = this.i18n.t('clip.desc.1.playing', '{user} playing {game}', {
|
||||
user,
|
||||
game: game_display
|
||||
});
|
||||
|
||||
else
|
||||
} else {
|
||||
desc_1_tokens = this.i18n.tList('video.desc.1', 'Video of {user}', {
|
||||
user: {class: 'tw-semibold', content: user}
|
||||
});
|
||||
desc_1 = this.i18n.t('video.desc.1', 'Video of {user}', {user});
|
||||
}
|
||||
|
||||
return {
|
||||
url: token.url,
|
||||
image: video.previewThumbnailURL,
|
||||
title: video.title,
|
||||
desc_1,
|
||||
desc_1_tokens,
|
||||
desc_2: this.i18n.t('video.desc.2', '{length,duration} — {views,number} Views - {date,datetime}', {
|
||||
length: video.lengthSeconds,
|
||||
views: video.viewCount,
|
||||
|
|
|
@ -1,97 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Logviewer Integration
|
||||
// ============================================================================
|
||||
|
||||
import {once} from 'utilities/object';
|
||||
import Module from 'utilities/module';
|
||||
|
||||
import LVSocketClient from './socket';
|
||||
|
||||
export default class Logviewer extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this.should_enable = true;
|
||||
|
||||
this.inject('site');
|
||||
this.inject('socket');
|
||||
this.inject('viewer_cards');
|
||||
|
||||
this.inject('lv_socket', LVSocketClient);
|
||||
}
|
||||
|
||||
|
||||
get token() {
|
||||
const token = this._token;
|
||||
if ( token && token.token && token.expires > ((Date.now() / 1000) + 300) )
|
||||
return token.token;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
onEnable() {
|
||||
this.viewer_cards.addTab('logs', {
|
||||
visible: true,
|
||||
label: 'Chat History',
|
||||
|
||||
component: () => import(/* webpackChunkName: 'viewer-cards' */ './tab-logs.vue')
|
||||
});
|
||||
|
||||
this.on('viewer_cards:open', this.onCardOpen);
|
||||
this.on('viewer_cards:close', this.onCardClose);
|
||||
this.on('viewer_cards:load', this.onCardLoad);
|
||||
}
|
||||
|
||||
|
||||
|
||||
onCardOpen() {
|
||||
// We're going to need a token soon, so make sure we have one.
|
||||
this.getToken();
|
||||
}
|
||||
|
||||
|
||||
onCardLoad(card) {
|
||||
this.log.info('card:load', card);
|
||||
|
||||
if ( ! card.channel || ! card.user )
|
||||
return;
|
||||
|
||||
card.lv_topic = `logs-${card.channel.login}-${card.user.login}`;
|
||||
this.lv_socket.subscribe(card, card.lv_topic);
|
||||
}
|
||||
|
||||
|
||||
onCardClose(card) {
|
||||
this.log.info('card:close', card);
|
||||
|
||||
if ( card.lv_topic ) {
|
||||
this.lv_socket.unsubscribe(card, card.lv_topic);
|
||||
card.lv_topic = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async getToken() {
|
||||
const user = this.site.getUser(),
|
||||
token = this._token,
|
||||
now = Date.now() / 1000;
|
||||
|
||||
if ( ! user || ! user.login )
|
||||
return null;
|
||||
|
||||
if ( token && token.token && token.expires > (now + 300) )
|
||||
return token.token;
|
||||
|
||||
const new_token = this._token = await this.socket.call('get_logviewer_token');
|
||||
if ( new_token ) {
|
||||
this.lv_socket.maybeSendToken();
|
||||
return new_token.token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Logviewer.getToken = once(Logviewer.getToken);
|
|
@ -1,339 +0,0 @@
|
|||
'use strict';
|
||||
|
||||
// ============================================================================
|
||||
// Socket Client
|
||||
// This connects to Logviewer's socket.io server for PubSub.
|
||||
// ============================================================================
|
||||
|
||||
import Module from 'utilities/module';
|
||||
import {LV_SOCKET_SERVER} from 'utilities/constants';
|
||||
|
||||
|
||||
export const State = {
|
||||
DISCONNECTED: 0,
|
||||
CONNECTING: 1,
|
||||
CONNECTED: 2
|
||||
}
|
||||
|
||||
|
||||
export default class LVSocketClient extends Module {
|
||||
constructor(...args) {
|
||||
super(...args);
|
||||
|
||||
this._topics = new Map;
|
||||
|
||||
this._socket = null;
|
||||
this._state = 0;
|
||||
this._delay = 0;
|
||||
|
||||
this.ping_interval = 25000;
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Properties
|
||||
// ========================================================================
|
||||
|
||||
get connected() {
|
||||
return this._state === State.CONNECTED;
|
||||
}
|
||||
|
||||
|
||||
get connecting() {
|
||||
return this._state === State.CONNECTING;
|
||||
}
|
||||
|
||||
|
||||
get disconnected() {
|
||||
return this._state === State.DISCONNECTED;
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Connection Logic
|
||||
// ========================================================================
|
||||
|
||||
scheduleDisconnect() {
|
||||
if ( this._disconnect_timer ) {
|
||||
clearTimeout(this._disconnect_timer);
|
||||
this._disconnect_timer = null;
|
||||
}
|
||||
|
||||
if ( this.disconnected || this._topics.size )
|
||||
return;
|
||||
|
||||
this._disconnect_timer = setTimeout(() => this.disconnect(), 5000);
|
||||
}
|
||||
|
||||
|
||||
scheduleReconnect() {
|
||||
if ( this._reconnect_timer )
|
||||
return;
|
||||
|
||||
if ( this._delay < 60000 )
|
||||
this._delay += (Math.floor(Math.random() * 10) + 5) * 1000;
|
||||
else
|
||||
this._delay = (Math.floor(Math.random() * 60) + 30) * 1000;
|
||||
|
||||
this._reconnect_timer = setTimeout(() => this.connect(), this._delay);
|
||||
}
|
||||
|
||||
|
||||
reconnect() {
|
||||
this.disconnect();
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
|
||||
connect() {
|
||||
this.want_connection = true;
|
||||
this.clearTimers();
|
||||
|
||||
if ( ! this.disconnected )
|
||||
return;
|
||||
|
||||
this._state = State.CONNECTING;
|
||||
this._delay = 0;
|
||||
|
||||
const host = `${LV_SOCKET_SERVER}?EIO=3&transport=websocket`;
|
||||
this.log.info(`Using Server: ${host}`);
|
||||
|
||||
let ws;
|
||||
|
||||
try {
|
||||
ws = this._socket = new WebSocket(host);
|
||||
} catch(err) {
|
||||
this._state = State.DISCONNECTED;
|
||||
this.scheduleReconnect();
|
||||
this.log.error('Unable to create WebSocket.', err);
|
||||
return;
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
if ( this._socket !== ws ) {
|
||||
this.log.warn('A socket connected that is not our primary socket.');
|
||||
return ws.close();
|
||||
}
|
||||
|
||||
this._state = State.CONNECTED;
|
||||
this._sent_token = false;
|
||||
|
||||
ws.send('2probe');
|
||||
ws.send('5');
|
||||
|
||||
this.maybeSendToken();
|
||||
|
||||
for(const topic of this._topics.keys())
|
||||
this.send('subscribe', topic);
|
||||
|
||||
this.log.info('Connected.');
|
||||
this.emit(':connected');
|
||||
}
|
||||
|
||||
|
||||
ws.onclose = event => {
|
||||
if ( ws !== this._socket )
|
||||
return;
|
||||
|
||||
this._state = State.DISCONNECTED;
|
||||
|
||||
this.log.info(`Disconnected. (${event.code}:${event.reason})`);
|
||||
this.emit(':closed', event.code, event.reason);
|
||||
|
||||
if ( ! this.want_connection )
|
||||
return;
|
||||
|
||||
this.clearTimers();
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
|
||||
|
||||
ws.onmessage = event => {
|
||||
if ( ws !== this._socket || ! event.data )
|
||||
return;
|
||||
|
||||
const raw = event.data,
|
||||
type = raw.charAt(0);
|
||||
|
||||
if ( type === '0' ) {
|
||||
// OPEN. Try reading ping interval.
|
||||
try {
|
||||
const data = JSON.parse(raw.slice(1));
|
||||
this.ping_interval = data.ping_interval || 25000;
|
||||
} catch(err) { /* don't care */ }
|
||||
|
||||
} else if ( type === '1' ) {
|
||||
// CLOSE. We should never get this, but whatever.
|
||||
ws.close();
|
||||
|
||||
} else if ( type === '2' ) {
|
||||
// PING. Respone with a PONG. Shouldn't get this.
|
||||
ws.send(`3${raw.slice(1)}`);
|
||||
|
||||
} else if ( type === '3' ) {
|
||||
// PONG. Wait for the next ping.
|
||||
this.schedulePing();
|
||||
|
||||
} else if ( type === '4' ) {
|
||||
const dt = raw.charAt(1);
|
||||
if ( dt === '0' ) {
|
||||
// This is sent at connection. Who knows what it is.
|
||||
|
||||
} else if ( dt === '2' ) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(raw.slice(2));
|
||||
} catch(err) {
|
||||
this.log.warn('Error decoding packet.', err);
|
||||
return;
|
||||
}
|
||||
|
||||
this.emit(':message', ...data);
|
||||
|
||||
} else
|
||||
this.log.debug('Unexpected Data Type', raw);
|
||||
|
||||
} else if ( type === '6' ) {
|
||||
// NOOP.
|
||||
|
||||
} else
|
||||
this.log.debug('Unexpected Packet Type', raw);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
clearTimers() {
|
||||
if ( this._ping_timer ) {
|
||||
clearTimeout(this._ping_timer);
|
||||
this._ping_timer = null;
|
||||
}
|
||||
|
||||
if ( this._reconnect_timer ) {
|
||||
clearTimeout(this._reconnect_timer);
|
||||
this._reconnect_timer = null;
|
||||
}
|
||||
|
||||
if ( this._disconnect_timer ) {
|
||||
clearTimeout(this._disconnect_timer);
|
||||
this._disconnect_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
disconnect() {
|
||||
this.want_connection = false;
|
||||
this.clearTimers();
|
||||
|
||||
if ( this.disconnected )
|
||||
return;
|
||||
|
||||
try {
|
||||
this._socket.close();
|
||||
} catch(err) { /* if this caused an exception, we don't care -- it's still closed */ }
|
||||
|
||||
this._socket = null;
|
||||
this._state = State.DISCONNECTED;
|
||||
|
||||
this.log.info(`Disconnected. (1000:)`);
|
||||
this.emit(':closed', 1000, null);
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Communication
|
||||
// ========================================================================
|
||||
|
||||
maybeSendToken() {
|
||||
if ( ! this.connected )
|
||||
return;
|
||||
|
||||
const token = this.parent.token;
|
||||
if ( token ) {
|
||||
this.send('token', token);
|
||||
this._sent_token = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
send(...args) {
|
||||
if ( ! this.connected )
|
||||
return;
|
||||
|
||||
this._socket.send(`42${JSON.stringify(args)}`);
|
||||
}
|
||||
|
||||
|
||||
schedulePing() {
|
||||
if ( this._ping_timer )
|
||||
clearTimeout(this._ping_timer);
|
||||
|
||||
this._ping_timer = setTimeout(() => this.ping(), this.ping_interval);
|
||||
}
|
||||
|
||||
|
||||
ping() {
|
||||
if ( ! this.connected )
|
||||
return;
|
||||
|
||||
if ( this._ping_timer ) {
|
||||
clearTimeout(this._ping_timer);
|
||||
this._ping_timer = null;
|
||||
}
|
||||
|
||||
this._socket.send('2');
|
||||
}
|
||||
|
||||
|
||||
// ========================================================================
|
||||
// Topics
|
||||
// ========================================================================
|
||||
|
||||
subscribe(referrer, ...topics) {
|
||||
const t = this._topics;
|
||||
for(const topic of topics) {
|
||||
if ( ! t.has(topic) ) {
|
||||
if ( this.connected )
|
||||
this.send('subscribe', topic);
|
||||
|
||||
else if ( this.disconnected )
|
||||
this.connect();
|
||||
|
||||
t.set(topic, new Set);
|
||||
}
|
||||
|
||||
const tp = t.get(topic);
|
||||
tp.add(referrer);
|
||||
}
|
||||
|
||||
this.scheduleDisconnect();
|
||||
}
|
||||
|
||||
|
||||
unsubscribe(referrer, ...topics) {
|
||||
const t = this._topics;
|
||||
for(const topic of topics) {
|
||||
if ( ! t.has(topic) )
|
||||
continue;
|
||||
|
||||
const tp = t.get(topic);
|
||||
tp.delete(referrer);
|
||||
|
||||
if ( ! tp.size ) {
|
||||
t.delete(topic);
|
||||
if ( this.connected )
|
||||
this.send('unsubscribe', topic);
|
||||
}
|
||||
}
|
||||
|
||||
this.scheduleDisconnect();
|
||||
}
|
||||
|
||||
|
||||
get topics() {
|
||||
return Array.from(this._topics.keys());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
LVSocketClient.State = State;
|
|
@ -1,33 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<error-tab v-if="errored" />
|
||||
<loading-tab v-else-if="loading" />
|
||||
<template v-else>
|
||||
{{ data }}
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
import LoadingTab from 'src/modules/viewer_cards/components/loading-tab.vue';
|
||||
import ErrorTab from 'src/modules/viewer_cards/components/error-tab.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
'loading-tab': LoadingTab,
|
||||
'error-tab': ErrorTab
|
||||
},
|
||||
|
||||
props: ['tab', 'channel', 'user', 'self', 'getFFZ'],
|
||||
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
errored: false,
|
||||
|
||||
data: null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -13,7 +13,9 @@
|
|||
<script>
|
||||
|
||||
const VIDEOS = [
|
||||
'https://www.youtube.com/watch?v=BFSWlDpA6C4'
|
||||
'https://www.twitch.tv/dansalvato',
|
||||
'https://www.twitch.tv/sirstendec',
|
||||
//'https://www.youtube.com/watch?v=BFSWlDpA6C4'
|
||||
];
|
||||
|
||||
export default {
|
||||
|
|
|
@ -109,17 +109,20 @@ export default class Metadata extends Module {
|
|||
refresh() { return this.settings.get('metadata.viewers') },
|
||||
|
||||
setup(data) {
|
||||
return data.getViewerCount();
|
||||
return {
|
||||
live: data.channel?.live_since != null,
|
||||
count: data.getViewerCount()
|
||||
}
|
||||
},
|
||||
|
||||
order: 1,
|
||||
icon: 'ffz-i-viewers',
|
||||
|
||||
label(data) {
|
||||
if ( ! this.settings.get('metadata.viewers') )
|
||||
if ( ! this.settings.get('metadata.viewers') || ! data.live )
|
||||
return null;
|
||||
|
||||
return this.i18n.formatNumber(data)
|
||||
return this.i18n.formatNumber(data.count)
|
||||
},
|
||||
|
||||
tooltip() {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue