Vod support

This commit is contained in:
dragongoose 2023-07-19 20:26:43 -04:00
parent 4b4d1385d2
commit 820ceda499
No known key found for this signature in database
GPG Key ID: 01397EEC371CDAA5
10 changed files with 282 additions and 40 deletions

View File

@ -1,4 +1,4 @@
import type { Badge, ParsedMessage } from './types' import type { Badge } from './types'
export function getBadges(badges: Badge[], badgesToFind: { setId: string; version: string }[]) { export function getBadges(badges: Badge[], badgesToFind: { setId: string; version: string }[]) {
const foundBadges = [] const foundBadges = []
@ -17,8 +17,8 @@ export function getBadges(badges: Badge[], badgesToFind: { setId: string; versio
return foundBadges return foundBadges
} }
export const getBadgesFromMessage = (message: ParsedMessage, allBadges: Badge[]) => { export const getBadgesFromMessage = (tags: any, allBadges: Badge[]) => {
const badgesString = message.data.tags.badges const badgesString = tags.badges
if (!badgesString) return if (!badgesString) return
const badges = badgesString.split(',') const badges = badgesString.split(',')
const formatedBadges = badges.map((badgeWithVersion: string) => { const formatedBadges = badges.map((badgeWithVersion: string) => {
@ -27,4 +27,4 @@ export const getBadgesFromMessage = (message: ParsedMessage, allBadges: Badge[])
}) })
return getBadges(allBadges, formatedBadges) return getBadges(allBadges, formatedBadges)
} }

View File

@ -1,21 +1,36 @@
import type { Badge, ParsedMessage } from './types' import type { Badge, ParsedMessage } from './types'
import { getBadgesFromMessage } from './badges' import { getBadgesFromMessage } from './badges'
export function parseMessage(messageData: string, allBadges: Badge[]): ParsedMessage { export function parseMessage(messageData: any, allBadges: Badge[]): ParsedMessage {
const message = JSON.parse(messageData) const message = JSON.parse(messageData)
if (message.type === undefined && message.cursor !== "") {
const data: ParsedMessage = {
type: 'PRIVMSG',
data: {
message: message.message,
username: message.messager.name,
badges: message.badges,
offset: message.offset,
color: message.messager.colorHex
}
}
return data
}
switch (message.type) { switch (message.type) {
case 'PRIVMSG': { case 'PRIVMSG': {
const tags = message.tags
const username = message.username
const data: ParsedMessage = { const data: ParsedMessage = {
type: 'PRIVMSG', type: 'PRIVMSG',
data: { message: message.message, username, tags } data: {
message: message.message,
username: message.username,
color: message.tags.color,
badges: getBadgesFromMessage(message.tags, allBadges)
}
} }
const badges = getBadgesFromMessage(data, allBadges)
data.data.badges = badges
return data return data
} }
case 'USERNOTICE': { case 'USERNOTICE': {

View File

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { ref, inject } from 'vue' import { ref, inject, provide } from 'vue'
import BadgeVue from './ChatBadge.vue' import BadgeVue from './ChatBadge.vue'
import { getBadges } from '@/assets/badges' import { getBadges } from '@/assets/badges'
@ -7,11 +7,12 @@ import { parseMessage } from '@/assets/messageParser'
import { getEndpoint } from '@/mixins' import { getEndpoint } from '@/mixins'
import type { Badge, ParsedMessage } from '@/assets/types' import type { Badge, ParsedMessage } from '@/assets/types'
import type { VodComment } from '@/types'
export default { export default {
props: { props: {
isLive: { isVod: {
type: Boolean, type: Boolean,
default() { default() {
return false return false
@ -23,6 +24,7 @@ export default {
}, },
async setup(props) { async setup(props) {
let messages = ref<ParsedMessage[]>([]) let messages = ref<ParsedMessage[]>([])
let vodMessageCache = ref<ParsedMessage[]>([])
let badges: Badge[] = [] let badges: Badge[] = []
let wsLink = inject('wsLink') as string let wsLink = inject('wsLink') as string
@ -32,40 +34,43 @@ export default {
badges = data badges = data
}) })
return { return {
ws: new WebSocket(wsLink), ws: props.isVod ? null : new WebSocket(wsLink),
messages, messages,
badges, badges,
vodMessageCache,
fetchingMoreComments: false
} }
}, },
async mounted() { async mounted() {
const chatList = this.$refs.chatList as Element if (!this.$props.isVod) {
const chatStatusMessage = this.$refs.initConnectingStatus as Element const chatStatusMessage = this.$refs.initConnectingStatus as Element
this.ws.onmessage = (message) => { this.ws!.onmessage = (message) => {
if (message.data == 'OK') { if (message.data == 'OK') {
chatStatusMessage.textContent = this.$t("chat.connected", {username: this.channelName}) chatStatusMessage.textContent = this.$t("chat.connected", {username: this.channelName})
} else { } else {
this.messages.push(parseMessage(message.data, this.badges)) this.messages.push(parseMessage(message.data, this.badges))
this.clearMessages() this.clearMessages()
this.scrollToBottom(chatList) this.scrollToBottom()
}
} }
}
this.ws.onopen = () => { this.ws!.onopen = () => {
this.ws.send('JOIN ' + this.$props.channelName?.toLowerCase()) this.ws!.send('JOIN ' + this.$props.channelName?.toLowerCase())
}
} }
}, },
methods: { methods: {
getChat() { getChat() {
return this.messages return this.messages
}, },
scrollToBottom(el: Element) { scrollToBottom() {
const el = this.$refs.chatList as Element
el.scrollTop = el.scrollHeight el.scrollTop = el.scrollHeight
}, },
clearMessages() { clearMessages() {
if (this.messages.length > 50) { if (this.messages.length > 50) {
this.messages.shift() this.messages.splice(0, this.messages.length-50);
} }
}, },
getBadgesFromMessage(message: ParsedMessage) { getBadgesFromMessage(message: ParsedMessage) {
@ -78,6 +83,51 @@ export default {
}); });
return getBadges(this.badges, formatedBadges); return getBadges(this.badges, formatedBadges);
},
async updateVodComments(time: number) {
time = Math.round(time)
if (!this.isVod) {
return
}
this.clearMessages()
if(this.vodMessageCache.length > 5) {
for (let i = 0; i < this.vodMessageCache.length; i++) {
const offset = this.vodMessageCache[i].data.offset
if (offset <= time) {
this.messages.push(this.vodMessageCache[i])
this.vodMessageCache.splice(i, 1)
}
}
}
this.scrollToBottom()
// do not go further is the newest message offset is greater than the current time
if (this.vodMessageCache[0] != undefined && time < this.vodMessageCache[this.vodMessageCache.length - 1].data.offset) {
return
}
if (this.fetchingMoreComments) {
return
}
// prevents multiple fetches at the same sime causing duplicates
this.fetchingMoreComments = true
await getEndpoint(`api/vods/comments/${this.$route.params.vodID}/${time}` )
.then((data: VodComment[]) => {
for (let message of data) {
let parsedMessage = parseMessage(JSON.stringify(message), this.badges)
parsedMessage.data.badges = getBadges(this.badges, parsedMessage.data.badges)
this.vodMessageCache.push(parsedMessage)
}
this.fetchingMoreComments = false
})
this.scrollToBottom()
} }
}, },
components: { components: {
@ -90,14 +140,14 @@ export default {
} }
</script> </script>
<template> <template>
<div v-if="isLive" class="p-3 bg-ctp-crust rounded-lg w-[99vw] md:max-w-[15.625rem] lg:max-w-[20rem] flex flex-col"> <div class="p-3 bg-ctp-crust rounded-lg w-[99vw] md:max-w-[15.625rem] lg:max-w-[20rem] flex flex-col" @PlayerTimeUpdate="updateVodComments">
<!-- SYSTEM MESSAGES --> <!-- SYSTEM MESSAGES -->
<ul <ul
class="overflow-y-scroll overflow-x-hidden space-y-1 whitespace-pre-wrap h-[46.875rem]" class="overflow-y-scroll overflow-x-hidden space-y-1 whitespace-pre-wrap h-[46.875rem]"
ref="chatList" ref="chatList"
> >
<li> <li v-if="!isVod">
<p ref="initConnectingStatus" class="text-gray-500 text-sm italic"> <p ref="initConnectingStatus" class="text-gray-500 text-sm italic">
{{ $t("chat.connecting", { username: channelName }) }} {{ $t("chat.connecting", { username: channelName }) }}
</p> </p>
@ -108,15 +158,14 @@ export default {
<!-- CHAT MESSAGE--> <!-- CHAT MESSAGE-->
<p class="text-sm items-center"> <p class="text-sm items-center">
<ul class="inline-flex space-x-1 pr-1"> <ul class="inline-flex space-x-1 pr-1">
<li v-for="badge in getBadgesFromMessage(message)" :key="badge.id"> <li v-for="badge in message.data.badges" :key="badge.id">
<badge-vue :badge-info="badge"></badge-vue> <badge-vue :badge-info="badge"></badge-vue>
</li> </li>
</ul> </ul>
<strong <strong
:style="message.data.tags.color ? `color: ${message.data.tags.color};` : ``" :style="message.data.color? `color: ${message.data.color};` : ``"
class="text-ctp-pink font-bold"> class="text-ctp-pink font-bold">
{{ message.data.username }}</strong>: {{ message.data.message }} {{ message.data.username }}</strong>: {{ message.data.message }}
</p> </p>

View File

@ -22,6 +22,7 @@ export default {
} }
} }
}, },
emits: ['PlayerTimeUpdate'],
data() { data() {
let player: any let player: any
return { return {
@ -31,8 +32,14 @@ export default {
// initializing the video player // initializing the video player
// when the component is being mounted // when the component is being mounted
mounted() { mounted() {
const emit = this.$emit
this.player = videojs('video-player', this.options, () => { this.player = videojs('video-player', this.options, () => {
createQualitySelector(this.player) createQualitySelector(this.player)
let i = 0
this.player.on('timeupdate', () => {
emit('PlayerTimeUpdate', this.player.currentTime())
})
}) })
}, },
unmounted() { unmounted() {

View File

@ -1,19 +1,24 @@
<template> <template>
<div class="min-w-[300px]"> <div class="min-w-[300px]">
<div class="relative"> <div class="relative">
<img :src="data.preview" class="rounded-md" width="300"> <RouterLink :to="'/videos/' + data.id">
<img :src="data.preview" class="rounded-md" width="300">
</RouterLink>
<p class="absolute bottom-2 right-2 bg-black p-1 py-0.5 rounded-md bg-opacity-70 text-xs font-bold"> {{ new Date(data.duration * 1000).toISOString().slice(11, 19) }}</p> <p class="absolute bottom-2 right-2 bg-black p-1 py-0.5 rounded-md bg-opacity-70 text-xs font-bold"> {{ new Date(data.duration * 1000).toISOString().slice(11, 19) }}</p>
<p class="absolute bottom-2 left-2 bg-black p-1 py-0.5 rounded-md bg-opacity-70 text-xs font-bold"> {{ abbreviate(data.views) }} {{ $t("main.views") }}</p> <p class="absolute bottom-2 left-2 bg-black p-1 py-0.5 rounded-md bg-opacity-70 text-xs font-bold"> {{ abbreviate(data.views) }} {{ $t("main.views") }}</p>
</div> </div>
<div class="pt-2 space-x-2"> <div class="pt-2 space-x-2">
<div class="space-x-2 inline-flex"> <div class="space-x-2 inline-flex">
<img :src="data.game.image"> <RouterLink :to="'/game/' + data.game.name">
<img :src="data.game.image">
</RouterLink>
<div class="w-full"> <div class="w-full">
<p class="font-bold text-sm truncate h-6 max-w-[255px]">{{ data.title }}</p> <p class="font-bold text-sm truncate h-6 max-w-[255px]">{{ data.title }}</p>
<div class="text-xs text-gray-400"> <div class="text-xs text-gray-400">
<p>{{ data.streamer.name }}</p> <p>{{ data.streamer.login }}</p>
<p>{{ data.game.displayName || data.game.name }}</p> <p>{{ data.game.displayName || data.game.name }}</p>
</div> </div>
</div> </div>

View File

@ -5,6 +5,7 @@ const PrivacyPageView = () => import('../views/PrivacyPageView.vue')
const HomepageView = () => import('../views/HomepageView.vue') const HomepageView = () => import('../views/HomepageView.vue')
const CategoryView = () => import('../views/CategoryView.vue') const CategoryView = () => import('../views/CategoryView.vue')
const SearchPageView = () => import('../views/SearchPageView.vue') const SearchPageView = () => import('../views/SearchPageView.vue')
const VodView = () => import('../views/VodView.vue')
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -30,6 +31,10 @@ const router = createRouter({
path: '/:username', path: '/:username',
component: UserView component: UserView
}, },
{
path: '/videos/:vodID',
component: VodView
},
{ path: '/:pathMatch(.*)*', component: PageNotFound } { path: '/:pathMatch(.*)*', component: PageNotFound }
] ]
}) })

View File

@ -1,3 +1,4 @@
import type { StreamerData } from "./Streamer"
export interface MinifiedCategory { export interface MinifiedCategory {
image: string image: string
@ -21,10 +22,30 @@ export interface Video {
publishedAt: string publishedAt: string
views: number views: number
tag: string[] tag: string[]
streamer: MinifiedStreamer streamer: StreamerData
} }
export interface Shelve { export interface Shelve {
title: string title: string
videos: Video[] videos: Video[]
}
export interface VodMessager {
name: string
login: string
}
export interface VodCommentBadge {
version: number
setId: string
}
export interface VodComment {
message: string
messager: MinifiedStreamer
offset: number
cursor: string
badges: VodCommentBadge[]
} }

View File

@ -4,4 +4,5 @@ export * from './Chat'
export * from './Chat' export * from './Chat'
export * from './Category' export * from './Category'
export * from './CategoryData' export * from './CategoryData'
export * from './ApiResponse' export * from './ApiResponse'
export * from './VOD'

View File

@ -135,7 +135,7 @@ export default {
</div> </div>
<!-- VIDEOS TAB --> <!-- VIDEOS TAB -->
<!-- <video-tab class="mb-4"></video-tab> --> <video-tab class="mb-4"></video-tab>
<!-- ABOUT TAB --> <!-- ABOUT TAB -->
<div class="bg-ctp-mantle mt-1 p-5 pt-3 rounded-lg w-full space-y-3"> <div class="bg-ctp-mantle mt-1 p-5 pt-3 rounded-lg w-full space-y-3">

139
src/views/VodView.vue Normal file
View File

@ -0,0 +1,139 @@
<script lang="ts">
import { ref, inject } from 'vue'
import { useRoute } from 'vue-router'
import VideoPlayer from '@/components/VideoPlayer.vue'
import TwitchChat from '@/components/TwitchChat.vue'
import ErrorMessage from '@/components/ErrorMessage.vue'
import FollowButton from '@/components/FollowButton.vue'
import LoadingScreen from '@/components/LoadingScreen.vue'
import VideoTab from '@/components/user/VideoTab.vue'
import type { Video, ApiResponse } from '@/types'
import { truncate, abbreviate, getEndpoint } from '@/mixins'
interface ChatComponent {
updateVodComments: (time: number) => void
}
export default {
inject: ["rootBackendUrl"],
async setup() {
const route = useRoute()
const vodID = route.params.vodID
const data = ref<Video>()
const status = ref<"ok" | "error">()
const rootBackendUrl = inject('rootBackendUrl')
const videoOptions = {
autoplay: true,
controls: true,
sources: [
{
src: `${rootBackendUrl}/proxy/vod/${vodID}/video.m3u8`,
type: 'application/vnd.apple.mpegurl'
}
],
fluid: true
}
return {
data,
status,
videoOptions,
}
},
async mounted() {
const vodID = this.$route.params.vodID
await getEndpoint("api/vods/" + vodID)
.then((data) => {
this.data = data
})
.catch(() => {
this.status = "error"
})
},
components: {
VideoPlayer,
TwitchChat,
ErrorMessage,
FollowButton,
LoadingScreen,
VideoTab
},
methods: {
truncate, abbreviate,
handlePlayerTimeUpdate(time: number) {
const chat = this.$refs.chat as ChatComponent
chat.updateVodComments(time)
}
}
}
</script>
<template>
<loading-screen v-if="!data && status != 'error'"></loading-screen>
<error-message v-else-if="status == 'error'"></error-message>
<div
v-else-if="data"
class="w-full justify-center md:inline-flex space-y-4 md:space-y-0 md:space-x-4 md:p-4"
>
<div
class="flex bg-ctp-crust flex-col p-6 rounded-lg w-[99vw] md:max-w-prose md:min-w-[65ch] lg:max-w-[70rem] text-white"
>
<div class="w-full mx-auto rounded-lg mb-5">
<video-player :options="videoOptions" @PlayerTimeUpdate="handlePlayerTimeUpdate"> </video-player>
</div>
<div class="w-full flex-wrap md:p-3">
<div class="inline-flex md:w-full">
<img
:src="data.streamer.pfp"
class="rounded-full border-4 p-0.5 w-auto h-20"
:style="`border-color: ${data.streamer.colorHex};`"
/>
<div class="ml-3 content-between">
<h1 class="text-2xl md:text-4xl font-bold">{{ data.streamer.username }}</h1>
<p class="text-sm font-bold text-gray-200 self-end">
{{ truncate(data.title, 130) }}
</p>
</div>
</div>
<div class="pt-2 inline-flex">
<follow-button :username="data.streamer.username"></follow-button>
<p class="align-baseline font-bold ml-3">{{ abbreviate(data.streamer.followers) }} {{ $t("main.followers") }}</p>
</div>
</div>
<!-- VIDEOS TAB -->
<!-- <video-tab class="mb-4"></video-tab> -->
<!-- ABOUT TAB -->
<div class="bg-ctp-mantle mt-1 p-5 pt-3 rounded-lg w-full space-y-3">
<div class="inline-flex w-full">
<span class="pr-3 font-bold text-3xl">{{ $t("streamer.about") }}</span>
</div>
<p class="mb-5">{{ data.streamer.about }}</p>
<hr class="my-auto w-full bg-gray-200 rounded-full opacity-40" />
<ul class="flex font-semibold text-md justify-start flex-wrap flex-row">
<li v-for="link in data.streamer.socials">
<a :href="link.link" class="text-white hover:text-gray-400 mr-4">
<v-icon :name="`bi-${link.type}`" class="w-6 h-6 mr-1"></v-icon>
<span>{{ link.name }}</span>
</a>
</li>
</ul>
</div>
</div>
<twitch-chat :isVod="true" :channelName="data.streamer.login" ref="chat"></twitch-chat>
</div>
</template>