Vod support
This commit is contained in:
parent
4b4d1385d2
commit
820ceda499
@ -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) => {
|
||||
|
@ -1,20 +1,35 @@
|
||||
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
|
||||
}
|
||||
|
@ -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
|
||||
if (!this.$props.isVod) {
|
||||
const chatStatusMessage = this.$refs.initConnectingStatus as Element
|
||||
this.ws.onmessage = (message) => {
|
||||
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)
|
||||
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>
|
||||
|
@ -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() {
|
||||
|
@ -1,19 +1,24 @@
|
||||
<template>
|
||||
<div class="min-w-[300px]">
|
||||
<div class="relative">
|
||||
<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">
|
||||
<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>
|
||||
|
@ -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 }
|
||||
]
|
||||
})
|
||||
|
@ -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[]
|
||||
}
|
@ -5,3 +5,4 @@ export * from './Chat'
|
||||
export * from './Category'
|
||||
export * from './CategoryData'
|
||||
export * from './ApiResponse'
|
||||
export * from './VOD'
|
@ -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
139
src/views/VodView.vue
Normal 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>
|
Loading…
x
Reference in New Issue
Block a user