Added v1 of chat sidebar for Twitch VODs
parent
8938844ffa
commit
d08fee1223
@ -0,0 +1,71 @@
|
|||||||
|
var moment = require('moment');
|
||||||
|
var Axios = require('axios');
|
||||||
|
|
||||||
|
async function getCommentsForVOD(clientID, vodId) {
|
||||||
|
let url = `https://api.twitch.tv/v5/videos/${vodId}/comments?content_offset_seconds=0`,
|
||||||
|
batch,
|
||||||
|
cursor;
|
||||||
|
|
||||||
|
let comments = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
batch = (await Axios.get(url, {
|
||||||
|
headers: {
|
||||||
|
'Client-ID': clientID,
|
||||||
|
Accept: 'application/vnd.twitchtv.v5+json; charset=UTF-8',
|
||||||
|
'Content-Type': 'application/json; charset=UTF-8',
|
||||||
|
}
|
||||||
|
})).data;
|
||||||
|
|
||||||
|
const str = batch.comments.map(c => {
|
||||||
|
let {
|
||||||
|
created_at: msgCreated,
|
||||||
|
content_offset_seconds: timestamp,
|
||||||
|
commenter: {
|
||||||
|
name,
|
||||||
|
_id,
|
||||||
|
created_at: acctCreated
|
||||||
|
},
|
||||||
|
message: {
|
||||||
|
body: msg
|
||||||
|
}
|
||||||
|
} = c;
|
||||||
|
|
||||||
|
const timestamp_str = moment.duration(timestamp, 'seconds')
|
||||||
|
.toISOString()
|
||||||
|
.replace(/P.*?T(?:(\d+?)H)?(?:(\d+?)M)?(?:(\d+).*?S)?/,
|
||||||
|
(_, ...ms) => {
|
||||||
|
const seg = v => v ? v.padStart(2, '0') : '00';
|
||||||
|
return `${seg(ms[0])}:${seg(ms[1])}:${seg(ms[2])}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
acctCreated = moment(acctCreated).utc();
|
||||||
|
msgCreated = moment(msgCreated).utc();
|
||||||
|
|
||||||
|
if (!comments) comments = [];
|
||||||
|
|
||||||
|
comments.push({
|
||||||
|
timestamp: timestamp,
|
||||||
|
timestamp_str: timestamp_str,
|
||||||
|
name: name,
|
||||||
|
message: msg
|
||||||
|
});
|
||||||
|
// let line = `${timestamp},${msgCreated.format(tsFormat)},${name},${_id},"${msg.replace(/"/g, '""')}",${acctCreated.format(tsFormat)}`;
|
||||||
|
// return line;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
cursor = batch._next;
|
||||||
|
url = `https://api.twitch.tv/v5/videos/${vodId}/comments?cursor=${cursor}`;
|
||||||
|
await new Promise(res => setTimeout(res, 300));
|
||||||
|
} while (cursor);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return comments;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getCommentsForVOD: getCommentsForVOD
|
||||||
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
<div class="chat-container" #scrollContainer *ngIf="visible_chat">
|
||||||
|
<div style="width: 250px; text-align: center;"><strong>Twitch Chat</strong></div>
|
||||||
|
<div style="max-width: 250px" *ngFor="let chat of visible_chat">
|
||||||
|
{{chat.timestamp_str}} - <strong>{{chat.name}}</strong>: {{chat.message}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ng-container *ngIf="chat_response_received && !full_chat">
|
||||||
|
<button (click)="downloadTwitchChat()" class="download-button" mat-raised-button color="accent"><ng-container i18n="Download Twitch Chat button">Download Twitch Chat</ng-container></button>
|
||||||
|
<mat-spinner *ngIf="downloading_chat" class="downloading-spinner" [diameter]="30"></mat-spinner>
|
||||||
|
</ng-container>
|
@ -0,0 +1,13 @@
|
|||||||
|
.chat-container {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-button {
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloading-spinner {
|
||||||
|
top: 50%;
|
||||||
|
left: 80px;
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { TwitchChatComponent } from './twitch-chat.component';
|
||||||
|
|
||||||
|
describe('TwitchChatComponent', () => {
|
||||||
|
let component: TwitchChatComponent;
|
||||||
|
let fixture: ComponentFixture<TwitchChatComponent>;
|
||||||
|
|
||||||
|
beforeEach(async(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [ TwitchChatComponent ]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(TwitchChatComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,135 @@
|
|||||||
|
import { AfterViewInit, Component, ElementRef, Input, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core';
|
||||||
|
import { PostsService } from 'app/posts.services';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-twitch-chat',
|
||||||
|
templateUrl: './twitch-chat.component.html',
|
||||||
|
styleUrls: ['./twitch-chat.component.scss']
|
||||||
|
})
|
||||||
|
export class TwitchChatComponent implements OnInit, AfterViewInit {
|
||||||
|
|
||||||
|
full_chat = null;
|
||||||
|
visible_chat = null;
|
||||||
|
chat_response_received = false;
|
||||||
|
downloading_chat = false;
|
||||||
|
|
||||||
|
current_chat_index = null;
|
||||||
|
|
||||||
|
CHAT_CHECK_INTERVAL_MS = 200;
|
||||||
|
chat_check_interval_obj = null;
|
||||||
|
|
||||||
|
scrollContainer = null;
|
||||||
|
|
||||||
|
@Input() db_file = null;
|
||||||
|
@Input() current_timestamp = null;
|
||||||
|
|
||||||
|
@ViewChild('scrollContainer') scrollRef: ElementRef;
|
||||||
|
|
||||||
|
constructor(private postsService: PostsService) { }
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.getFullChat();
|
||||||
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private isUserNearBottom(): boolean {
|
||||||
|
const threshold = 300;
|
||||||
|
const position = this.scrollContainer.scrollTop + this.scrollContainer.offsetHeight;
|
||||||
|
const height = this.scrollContainer.scrollHeight;
|
||||||
|
return position > height - threshold;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToBottom = () => {
|
||||||
|
this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
addNewChatMessages() {
|
||||||
|
if (!this.scrollContainer) {
|
||||||
|
this.scrollContainer = this.scrollRef.nativeElement;
|
||||||
|
}
|
||||||
|
if (this.current_chat_index === null) {
|
||||||
|
this.current_chat_index = this.getIndexOfNextChat();
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest_chat_timestamp = this.visible_chat.length ? this.visible_chat[this.visible_chat.length - 1]['timestamp'] : 0;
|
||||||
|
|
||||||
|
for (let i = this.current_chat_index + 1; i < this.full_chat.length; i++) {
|
||||||
|
if (this.full_chat[i]['timestamp'] >= latest_chat_timestamp && this.full_chat[i]['timestamp'] <= this.current_timestamp) {
|
||||||
|
this.visible_chat.push(this.full_chat[i]);
|
||||||
|
this.current_chat_index = i;
|
||||||
|
if (this.isUserNearBottom()) {
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
} else if (this.full_chat[i]['timestamp'] > this.current_timestamp) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getIndexOfNextChat() {
|
||||||
|
const index = binarySearch(this.full_chat, 'timestamp', this.current_timestamp);
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
getFullChat() {
|
||||||
|
this.postsService.getFullTwitchChat(this.db_file.id, this.db_file.isAudio ? 'audio' : 'video', null).subscribe(res => {
|
||||||
|
this.chat_response_received = true;
|
||||||
|
if (res['chat']) {
|
||||||
|
this.initializeChatCheck(res['chat']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renewChat() {
|
||||||
|
this.visible_chat = [];
|
||||||
|
this.current_chat_index = this.getIndexOfNextChat();
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadTwitchChat() {
|
||||||
|
this.downloading_chat = true;
|
||||||
|
let vodId = this.db_file.url.split('videos/').length > 1 && this.db_file.url.split('videos/')[1];
|
||||||
|
vodId = vodId.split('?')[0];
|
||||||
|
if (!vodId) {
|
||||||
|
this.postsService.openSnackBar('VOD url for this video is not supported. VOD ID must be after "twitch.tv/videos/"');
|
||||||
|
}
|
||||||
|
this.postsService.downloadTwitchChat(this.db_file.id, this.db_file.isAudio ? 'audio' : 'video', vodId, null).subscribe(res => {
|
||||||
|
if (res['chat']) {
|
||||||
|
this.initializeChatCheck(res['chat']);
|
||||||
|
} else {
|
||||||
|
this.downloading_chat = false;
|
||||||
|
this.postsService.openSnackBar('Download failed.')
|
||||||
|
}
|
||||||
|
}, err => {
|
||||||
|
this.downloading_chat = false;
|
||||||
|
this.postsService.openSnackBar('Chat could not be downloaded.')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeChatCheck(full_chat) {
|
||||||
|
this.full_chat = full_chat;
|
||||||
|
this.visible_chat = [];
|
||||||
|
this.chat_check_interval_obj = setInterval(() => this.addNewChatMessages(), this.CHAT_CHECK_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function binarySearch(arr, key, n) {
|
||||||
|
let min = 0;
|
||||||
|
let max = arr.length - 1;
|
||||||
|
let mid;
|
||||||
|
while (min <= max) {
|
||||||
|
// tslint:disable-next-line: no-bitwise
|
||||||
|
mid = (min + max) >>> 1;
|
||||||
|
if (arr[mid][key] === n) {
|
||||||
|
return mid;
|
||||||
|
} else if (arr[mid][key] < n) {
|
||||||
|
min = mid + 1;
|
||||||
|
} else {
|
||||||
|
max = mid - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return min;
|
||||||
|
}
|
Loading…
Reference in New Issue