Vod support
All checks were successful
ci/woodpecker/push/<no value> Pipeline was successful

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 }[]) {
const foundBadges = []
@ -17,8 +17,8 @@ export function getBadges(badges: Badge[], badgesToFind: { setId: string; versio
return foundBadges
}
export const getBadgesFromMessage = (message: ParsedMessage, allBadges: Badge[]) => {
const badgesString = message.data.tags.badges
export const getBadgesFromMessage = (tags: any, allBadges: Badge[]) => {
const badgesString = tags.badges
if (!badgesString) return
const badges = badgesString.split(',')
const formatedBadges = badges.map((badgeWithVersion: string) => {
@ -27,4 +27,4 @@ export const getBadgesFromMessage = (message: ParsedMessage, allBadges: Badge[])
})
return getBadges(allBadges, formatedBadges)
}
}

View file

@ -1,21 +1,36 @@
import type { Badge, ParsedMessage } from './types'
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)
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) {
case 'PRIVMSG': {
const tags = message.tags
const username = message.username
const data: ParsedMessage = {
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
}
case 'USERNOTICE': {

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { ref, inject } from 'vue'
import { ref, inject, provide } from 'vue'
import BadgeVue from './ChatBadge.vue'
import { getBadges } from '@/assets/badges'
@ -7,11 +7,12 @@ import { parseMessage } from '@/assets/messageParser'
import { getEndpoint } from '@/mixins'
import type { Badge, ParsedMessage } from '@/assets/types'
import type { VodComment } from '@/types'
export default {
props: {
isLive: {
isVod: {
type: Boolean,
default() {
return false
@ -23,6 +24,7 @@ export default {
},
async setup(props) {
let messages = ref<ParsedMessage[]>([])
let vodMessageCache = ref<ParsedMessage[]>([])
let badges: Badge[] = []
let wsLink = inject('wsLink') as string
@ -32,40 +34,43 @@ export default {
badges = data
})
return {
ws: new WebSocket(wsLink),
ws: props.isVod ? null : new WebSocket(wsLink),
messages,
badges,
vodMessageCache,
fetchingMoreComments: false
}
},
async mounted() {
const chatList = this.$refs.chatList as Element
const chatStatusMessage = this.$refs.initConnectingStatus as Element
this.ws.onmessage = (message) => {
if (message.data == 'OK') {
chatStatusMessage.textContent = this.$t("chat.connected", {username: this.channelName})
} else {
this.messages.push(parseMessage(message.data, this.badges))
this.clearMessages()
this.scrollToBottom(chatList)
if (!this.$props.isVod) {
const chatStatusMessage = this.$refs.initConnectingStatus as Element
this.ws!.onmessage = (message) => {
if (message.data == 'OK') {
chatStatusMessage.textContent = this.$t("chat.connected", {username: this.channelName})
} else {
this.messages.push(parseMessage(message.data, this.badges))
this.clearMessages()
this.scrollToBottom()
}
}
}
this.ws.onopen = () => {
this.ws.send('JOIN ' + this.$props.channelName?.toLowerCase())
this.ws!.onopen = () => {
this.ws!.send('JOIN ' + this.$props.channelName?.toLowerCase())
}
}
},
methods: {
getChat() {
return this.messages
},
scrollToBottom(el: Element) {
scrollToBottom() {
const el = this.$refs.chatList as Element
el.scrollTop = el.scrollHeight
},
clearMessages() {
if (this.messages.length > 50) {
this.messages.shift()
this.messages.splice(0, this.messages.length-50);
}
},
getBadgesFromMessage(message: ParsedMessage) {
@ -78,6 +83,51 @@ export default {
});
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: {
@ -90,14 +140,14 @@ export default {
}
</script>
<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 -->
<ul
class="overflow-y-scroll overflow-x-hidden space-y-1 whitespace-pre-wrap h-[46.875rem]"
ref="chatList"
>
<li>
<li v-if="!isVod">
<p ref="initConnectingStatus" class="text-gray-500 text-sm italic">
{{ $t("chat.connecting", { username: channelName }) }}
</p>
@ -108,15 +158,14 @@ export default {
<!-- CHAT MESSAGE-->
<p class="text-sm items-center">
<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>
</li>
</ul>
<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">
{{ message.data.username }}</strong>: {{ message.data.message }}
</p>

View file

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

View file

@ -1,19 +1,24 @@
<template>
<div class="min-w-[300px]">
<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 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 class="pt-2 space-x-2">
<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">
<p class="font-bold text-sm truncate h-6 max-w-[255px]">{{ data.title }}</p>
<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>
</div>
</div>

View file

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

View file

@ -1,3 +1,4 @@
import type { StreamerData } from "./Streamer"
export interface MinifiedCategory {
image: string
@ -21,10 +22,30 @@ export interface Video {
publishedAt: string
views: number
tag: string[]
streamer: MinifiedStreamer
streamer: StreamerData
}
export interface Shelve {
title: string
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 './Category'
export * from './CategoryData'
export * from './ApiResponse'
export * from './ApiResponse'
export * from './VOD'

View file

@ -135,7 +135,7 @@ export default {
</div>
<!-- VIDEOS TAB -->
<!-- <video-tab class="mb-4"></video-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">

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>