diff --git a/internal/apimodules/overlays/default/eventfeed.custom.js b/internal/apimodules/overlays/default/eventfeed.custom.js new file mode 100644 index 0000000..d1f541d --- /dev/null +++ b/internal/apimodules/overlays/default/eventfeed.custom.js @@ -0,0 +1,19 @@ +/** + * Allows to add filters for custom events created through the customHandler + * + * @returns {Object} Custom filter definitions as `filterKey: {name: "Name", visible: true}` + */ +const customFilters = () => ({}) + +/** + * Handles custom events and creates feed items from them + * + * @param {*} param0 Event-Object as returned by the websocket + * @returns {Object} Event to add to the event list of the feed + */ +const customHandler = eventObj => { + console.log('custom event unhandled:', eventObj) + return null +} + +export { customFilters, customHandler } diff --git a/internal/apimodules/overlays/default/eventfeed.html b/internal/apimodules/overlays/default/eventfeed.html new file mode 100644 index 0000000..a7d6ebd --- /dev/null +++ b/internal/apimodules/overlays/default/eventfeed.html @@ -0,0 +1,156 @@ + + + Event-Feed + + + + + + + +
+
+
+
+ + +
+
+
+ + + + {{ item.value }} + + +
+
+
+ + +
+
+ Recent events +
+
+ + +
+ + +
+
+ +
+ + +
+
+
+ + Hypetrain in progress towards Level {{ hypetrain.level }}… +
+
+ +
+
+
+
+ + +
+
+
{{ event.title }}
+ + + {{ timeSince(event.time) }} + +
+ +
+ {{ event.text }} +
+

+ + {{ resolveSubtext(event.subtext) }} + +

+
+ +
+
+ +
+
+
+
+ + + + + diff --git a/internal/apimodules/overlays/default/eventfeed.js b/internal/apimodules/overlays/default/eventfeed.js new file mode 100644 index 0000000..336d4c4 --- /dev/null +++ b/internal/apimodules/overlays/default/eventfeed.js @@ -0,0 +1,585 @@ +/** + * @typedef {Object} Event + * @property {number} eventId ID of the event as returned by the server + * @property {Object|undefined} extraData Any additional data specific to this event type + * @property {string} filterKey Event-Type key + * @property {string|undefined} originId ID from the Twitch server for de-duplication + * @property {string|function|undefined} subtext Additional text, usually user-message + * @property {string|undefined} text Descriptive text of the event + * @property {Date} time The moment the event occurred + * @property {string} title The title of the event + * @property {boolean} hasReplay Whether the replay button should be shown + * @property {boolean} isMeta Whether not to display event in frontend + */ + +import { customFilters, customHandler } from './eventfeed.custom.js' +import { createApp } from 'https://cdn.jsdelivr.net/npm/vue@3.4/dist/vue.esm-browser.prod.js' +import dayjs from 'https://cdn.jsdelivr.net/npm/dayjs@1.11/+esm' +import dayjsLocalizedFormat from 'https://cdn.jsdelivr.net/npm/dayjs@1.11/plugin/localizedFormat.js/+esm' +import dayjsRelativeTime from 'https://cdn.jsdelivr.net/npm/dayjs@1.11/plugin/relativeTime.js/+esm' +import EventClient from './eventclient.mjs' + +const STORAGE_KEY = 'io.luzifer.eventfeed' + +const defaultFilters = { + adbreak: { name: 'Adbreaks', visible: true }, + ban: { name: 'Bans / Timeouts', visible: true }, + bits: { name: 'Bits', visible: true }, + channelpoint: { name: 'Channel-Points', visible: true }, + donation: { name: 'Donations', visible: true }, + follow: { name: 'Follows', visible: true }, + hypetrain: { name: 'Hypetrains', visible: true }, + pollEnd: { name: 'Poll-Summary', visible: true }, + raid: { name: 'Raids', visible: true }, + shoutout: { name: 'Shoutouts', visible: true }, + streamOffline: { name: 'Stream-Offline', visible: true }, + streamUpdate: { name: 'Stream-Update', visible: true }, + subs: { name: 'Subs', visible: true }, + watchStreak: { name: 'Watchstreaks', visible: true }, +} + +const userAnonSubgifter = 'ananonymousgifter' +const userAnonCheerer = 'ananonymouscheerer' + +const app = createApp({ + computed: { + filterCount() { + const filters = Object.values(this.filters) + return `${filters.filter(f => f.visible).length} / ${filters.length}` + }, + + filters() { + return Object.fromEntries(Object.entries({ + ...defaultFilters, + ...customFilters(), + ...this.storedData.filters || {}, + }) + .filter(e => Object.keys(defaultFilters).includes(e[0]) || Object.keys(customFilters()).includes(e[0])) + .sort((a, b) => a[1].name.localeCompare(b[1].name))) + }, + + hypetrain() { + const evts = [...this.events] + .filter(evt => evt.filterKey === 'hypetrain') + .sort((b, a) => a.time.getTime() - b.time.getTime()) + + if (evts.length < 1) { + return { + active: false, + } + } + + return evts[0].extraData + }, + + recentEvents() { + return [...this.events] + .filter(evt => !evt.isMeta) + .filter(evt => this.filters[evt.filterKey]?.visible !== false) + .filter(evt => !this.knownMultiGiftIDs.includes(evt.originId)) + .sort((b, a) => a.time.getTime() - b.time.getTime()) + }, + + sortedStats() { + const evts = [...this.events] + .filter(evt => evt.time.getTime() > this.streamOfflineTime.getTime()) + + + return [ + { + icon: 'fas fa-gem', + key: 'bits', + value: evts + .filter(evt => evt.filterKey === 'bits') + .reduce((sum, evt) => sum + evt.extraData.bits, 0), + }, + { + icon: 'fas fa-circle-dollar-to-slot', + key: 'donation', + value: evts + .filter(evt => evt.filterKey === 'donation') + .reduce((sum, evt) => sum + evt.extraData.amount, 0) + .toFixed(2), + }, + { + icon: 'fas fa-heart', + key: 'follow', + value: evts + .filter(evt => evt.filterKey === 'follow') + .length, + }, + { + icon: 'fas fa-parachute-box', + key: 'raid', + value: evts + .filter(evt => evt.filterKey === 'raid') + .length, + }, + { + icon: 'fas fa-star', + key: 'sub', + value: evts + .filter(evt => evt.filterKey === 'subs') + .filter(evt => !this.knownMultiGiftIDs.includes(evt.originId)) + .reduce((sum, evt) => sum + evt.extraData.count, 0), + }, + ] + }, + }, + + created() { + window.setInterval(() => { + this.now = new Date() + }, 60000) + + this.eventClient = new EventClient({ + handlers: { + adbreak_begin: ({ event_id, fields, time }) => this.handleAdBreak(event_id, fields, time), + ban: ({ event_id, fields, time }) => this.handleBan(event_id, fields, time), + bits: ({ event_id, fields, time }) => this.handleBits(event_id, fields, time), + category_update: ({ event_id, fields, time }) => this.handleCategoryUpdate(event_id, fields, time), + channelpoint_redeem: ({ event_id, fields, time }) => this.handleChannelPoints(event_id, fields, time), + custom: eventobj => this.handleCustom(eventobj), + follow: ({ event_id, fields, time }) => this.handleFollow(event_id, fields, time), + hypetrain_begin: ({ event_id, fields, time }) => this.handleHypetrain(event_id, fields, time, 'start'), + hypetrain_end: ({ event_id, fields, time }) => this.handleHypetrain(event_id, fields, time, 'end'), + hypetrain_progress: ({ event_id, fields, time }) => this.handleHypetrain(event_id, fields, time, 'progress'), + kofi_donation: ({ event_id, fields, time }) => this.handleKoFiDonation(event_id, fields, time), + poll_end: ({ event_id, fields, time }) => this.handlePollEnd(event_id, fields, time), + raid: ({ event_id, fields, time }) => this.handleRaid(event_id, fields, time), + resub: ({ event_id, fields, reason, time, type }) => this.handleSub(type, event_id, fields, time, reason), + shoutout_created: ({ event_id, fields, time }) => this.handleShoutoutCreated(event_id, fields, time), + shoutout_received: ({ event_id, fields, time }) => this.handleShoutoutReceived(event_id, fields, time), + stream_offline: ({ event_id, time }) => this.handleStreamOffline(event_id, time), + sub: ({ event_id, fields, time, type }) => this.handleSub(type, event_id, fields, time), + subgift: ({ event_id, fields, time, type }) => this.handleSubgift(type, event_id, fields, time), + submysterygift: ({ event_id, fields, time, type }) => this.handleSubgift(type, event_id, fields, time), + timeout: ({ event_id, fields, time }) => this.handleTimeout(event_id, fields, time), + title_update: ({ event_id, fields, time }) => this.handleTitleUpdate(event_id, fields, time), + watch_streak: ({ event_id, fields, time }) => this.handleWatchStreak(event_id, fields, time), + }, + + maxReplayAge: 168, + replay: true, + }) + + this.storageLoad() + window.addEventListener('storage', ev => { + if (ev.key !== this.storageKey()) { + return + } + + // Our key has been changed, reload stored data + this.storageLoad() + }) + }, + + data() { + return { + eventClient: null, + events: [], + now: new Date(), + storedData: {}, + + // Workaround for Twitch not sending hypetrain progress in end-event + // eslint-disable-next-line sort-keys + hypetrainProgress: 0, + knownMultiGiftIDs: [], + streamOfflineTime: new Date(0), + subgiftRecipients: {}, + } + }, + + methods: { + /** + * @param {Event} event + */ + addEvent(event) { + if (!event.eventId || !event.filterKey || !event.time || !event.title) { + throw new Error(`Event missing fields: ${event}`) + } + + this.events = [ + ...this.events.filter(evt => evt.eventId !== event.eventId), + event, + ] + }, + + eventClass(event) { + const classes = ['border-event', 'list-group-item'] + + if (this.storedData.readDate && this.storedData.readDate > event.time.getTime()) { + classes.push('disabled') + } + + if (event.filterKey) { + classes.push(`event-${event.filterKey}`) + } + + return classes.join(' ') + }, + + handleAdBreak(eventId, data, time) { + this.addEvent({ + eventId, + filterKey: 'adbreak', + icon: 'fas fa-rectangle-ad text-warning', + text: `${data.duration}s ad-break is now running`, + time: time ? new Date(time) : null, + title: 'Ad-Break started', + }) + }, + + handleBan(eventId, data, time) { + this.addEvent({ + eventId, + filterKey: 'ban', + icon: 'fas fa-ban', + time: new Date(time), + title: `${data.target_name} has been banned`, + }) + }, + + handleBits(eventId, data, time) { + const from = data.user === userAnonCheerer ? 'Someone' : data.user + + this.addEvent({ + eventId, + extraData: { bits: data.bits }, + filterKey: 'bits', + hasReplay: true, + icon: 'fas fa-gem', + subtext: data.message, + text: `${from} just spent ${data.bits} Bits`, + time: time ? new Date(time) : null, + title: 'Bits donated', + }) + }, + + handleCategoryUpdate(eventId, data, time) { + this.addEvent({ + eventId, + filterKey: 'streamUpdate', + icon: 'fas fa-gamepad', + text: data.category, + time: new Date(time), + title: 'Category updated', + }) + }, + + handleChannelPoints(eventId, data, time) { + this.addEvent({ + eventId, + filterKey: 'channelpoint', + hasReplay: true, + icon: 'fas fa-diamond', + subtext: data.user_input, + text: `${data.user} redeemed "${data.reward_title}"`, + time: new Date(time), + title: 'Reward Redeemed', + }) + }, + + handleCustom(eventObj) { + const evt = customHandler(eventObj) + if (evt !== null) { + this.addEvent(evt) + } + }, + + handleFollow(eventId, data, time) { + this.addEvent({ + eventId, + filterKey: 'follow', + hasReplay: true, + icon: 'fas fa-user', + text: `${data.user} just followed`, + time: new Date(time), + title: 'New Follower', + }) + }, + + handleHypetrain(eventId, data, time, phase) { + const evt = { + eventId, + extraData: { + active: phase !== 'end', + level: data.level, + progress: data.levelProgress || this.hypetrainProgress, + }, + + filterKey: 'hypetrain', + icon: 'fas fa-train', + time: new Date(time), + } + + this.hypetrainProgress = evt.extraData.progress + + switch (phase) { + case 'start': + this.addEvent({ + ...evt, + text: `A hypetrain started on ${(data.levelProgress * 100).toFixed(0)}% towards level ${data.level}`, + title: 'Hypetrain started', + }) + break + + case 'progress': + this.addEvent({ + ...evt, + isMeta: true, + title: 'Hypetrain progressed', + }) + break + + case 'end': + this.addEvent({ + ...evt, + text: `A hypetrain ended on ${(this.hypetrainProgress * 100).toFixed(0)}% towards level ${data.level}`, + title: 'Hypetrain ended', + }) + break + } + }, + + handleKoFiDonation(eventId, data, time) { + let text + if (data.isSubscription && data.isFirstSubPayment) { + text = `${data.from} just started a monthly subscription of ${Number(data.amount).toFixed(2)}€` + } else if (data.isSubscription && !data.isFirstSubPayment) { + text = `${data.from} continued their monthly subscription of ${Number(data.amount).toFixed(2)}€` + } else { + text = `${data.from} just donated ${Number(data.amount).toFixed(2)}€` + } + + this.addEvent({ + eventId, + extraData: { amount: Number(data.amount) }, + filterKey: 'donation', + icon: 'fas fa-circle-dollar-to-slot', + subtext: data.message ? data.message : undefined, + text, + time: new Date(time), + title: 'Ko-fi Donation received', + }) + }, + + handlePollEnd(eventId, data, time) { + if (data.poll.status === 'archived') { + return + } + + this.addEvent({ + eventId, + filterKey: 'pollEnd', + icon: 'fas fa-square-poll-vertical', + subtext: data.poll.choices.map(choice => `${choice.title} (${choice.votes})`).join(' | '), + text: data.poll.title, + time: new Date(time), + title: `Poll Ended (${data.poll.status})`, + }) + }, + + handleRaid(eventId, data, time) { + this.addEvent({ + eventId, + filterKey: 'raid', + hasReplay: true, + icon: 'fas fa-parachute-box', + soundUrl: '/public/fanfare.webm', + text: `${data.from} just raided with ${data.viewercount} raiders`, + time: new Date(time), + title: 'Incoming raid', + }) + }, + + handleShoutoutCreated(eventId, data, time) { + this.addEvent({ + eventId, + filterKey: 'shoutout', + icon: 'fas fa-bullhorn', + text: `We gave a shoutout for ${data.to} to ${data.viewers} viewers`, + time: new Date(time), + title: 'Shoutout created', + }) + }, + + handleShoutoutReceived(eventId, data, time) { + this.addEvent({ + eventId, + filterKey: 'shoutout', + icon: 'fas fa-bullhorn', + text: `${data.from} just gave us a shoutout to ${data.viewers} viewers`, + time: new Date(time), + title: 'Shoutout received', + }) + }, + + handleStreamOffline(eventId, time) { + this.addEvent({ + eventId, + filterKey: 'streamOffline', + icon: 'fas fa-clapperboard text-danger', + time: new Date(time), + title: 'Stream Offline', + }) + + this.streamOfflineTime = new Date(time) + }, + + handleSub(evt, eventId, data, time) { + const text = evt === 'resub' ? `resubscribed for the ${data.subscribed_months}. time` : 'subscribed' + const tier = data.plan === 'Prime' ? 'P' : `T${Number(data.plan) / 1000}` + const title = evt === 'resub' ? `Resub (${tier})` : `New Sub (${tier})` + this.addEvent({ + eventId, + extraData: { count: 1 }, + filterKey: 'subs', + hasReplay: true, + icon: 'fas fa-star', + subtext: data.message, + text: `${data.user} just ${text} (${tier})`, + time: new Date(time), + title, + }) + }, + + handleSubgift(evt, eventId, data, time) { + const from = data.user === userAnonSubgifter ? 'ANON' : data.from + + const tier = data.plan === 'Prime' ? 'Prime' : `Tier ${Number(data.plan) / 1000}` + + if (evt === 'submysterygift') { + this.addEvent({ + eventId, + extraData: { count: data.number }, + filterKey: 'subs', + hasReplay: true, + icon: 'fas fa-gift', + subtext: () => this.subgiftRecipients[data.origin_id] ? `To: ${this.subgiftRecipients[data.origin_id].join(', ')}` : undefined, + text: `${from} just gifted ${data.number} subs`, + time: time ? new Date(time) : null, + title: `Subs gifted (${tier})`, + variant: 'warning', + }) + + this.knownMultiGiftIDs.push(data.origin_id) + return + } + + if (data.origin_id) { + this.subgiftRecipients[data.origin_id] = [ + ...this.subgiftRecipients[data.origin_id] || [], + data.to, + ].sort((a, b) => a.localeCompare(b)) + } + + this.addEvent({ + eventId, + extraData: { count: 1 }, + filterKey: 'subs', + hasReplay: true, + icon: 'fas fa-gift', + originId: data.origin_id, + text: `${from} just gifted ${data.to} a sub`, + time: time ? new Date(time) : null, + title: `Sub gifted (${tier})`, + variant: 'warning', + }) + }, + + handleTimeout(eventId, data, time) { + this.addEvent({ + eventId, + filterKey: 'ban', + icon: 'fas fa-ban', + time: new Date(time), + title: `${data.target_name} has been timed out for ${data.seconds}s`, + }) + }, + + handleTitleUpdate(eventId, data, time) { + this.addEvent({ + eventId, + filterKey: 'streamUpdate', + icon: 'fas fa-heading', + text: data.title, + time: new Date(time), + title: 'Title updated', + }) + }, + + handleWatchStreak(eventId, data, time) { + this.addEvent({ + eventId, + filterKey: 'watchStreak', + icon: 'fas fa-circle-info', + subtext: data.message, + text: `${data.user} watched ${data.streak} consecutive streams`, + time: new Date(time), + title: 'Watch-Streak shared', + }) + }, + + markRead() { + this.storedData.readDate = new Date().getTime() + this.storageSave() + }, + + repeatEvent(eventId) { + return this.eventClient.replayEvent(eventId) + }, + + resolveSubtext(subtext) { + if (typeof subtext === 'function') { + return subtext() + } + + return subtext + }, + + storageKey() { + const channel = this.eventClient.paramOptionFallback('channel').replace(/^#*/, '') + return [STORAGE_KEY, channel].join('.') + }, + + storageLoad() { + this.storedData = { + // Default values + filters: {}, + readDate: 0, + + // Stored data + ...JSON.parse(window.localStorage.getItem(this.storageKey()) || '{}'), + } + }, + + storageSave() { + window.localStorage.setItem(this.storageKey(), JSON.stringify(this.storedData)) + }, + + timeDisplay(time) { + return dayjs(time).format('llll') + }, + + timeSince(time) { + return dayjs(time).from(this.now) + }, + + toggleFilterVisibility(filter) { + if (!this.storedData.filters[filter]) { + this.storedData.filters[filter] = this.filters[filter] + } + + this.storedData.filters[filter].visible = !this.storedData.filters[filter].visible + this.storageSave() + }, + }, + + name: 'EventFeed', +}) + +dayjs.extend(dayjsLocalizedFormat) +dayjs.extend(dayjsRelativeTime) + +app.mount('#app')