Add Support for Live Videos and Live Video Chat

This commit is contained in:
Preston 2020-05-23 17:29:42 -04:00
parent 8980dc74d2
commit 009174b89b
13 changed files with 809 additions and 60 deletions

View File

@ -65,17 +65,29 @@ if (isDevMode) {
)
} else {
config.plugins.push(
new CopyWebpackPlugin([
{
from: path.join(__dirname, '../src/data'),
to: path.join(__dirname, '../dist/data'),
},
{
from: path.join(__dirname, '../static'),
to: path.join(__dirname, '../dist/static'),
ignore: ['.*'],
},
]),
new CopyWebpackPlugin({
patterns: [
{
from: path.join(__dirname, '../static/pwabuilder-sw.js'),
to: path.join(__dirname, '../dist/web/pwabuilder-sw.js'),
},
{
from: path.join(__dirname, '../static'),
to: path.join(__dirname, '../dist/web/static'),
globOptions: {
ignore: ['.*', 'pwabuilder-sw.js'],
},
},
{
from: path.join(__dirname, '../_icons'),
to: path.join(__dirname, '../dist/web/_icons'),
globOptions: {
ignore: ['.*'],
},
},
]
}
),
new webpack.LoaderOptionsPlugin({
minimize: true,
})

View File

@ -151,13 +151,29 @@ if (isDevMode) {
)
} else {
config.plugins.push(
new CopyWebpackPlugin([
{
from: path.join(__dirname, '../static'),
to: path.join(__dirname, '../dist/static'),
ignore: ['.*'],
},
]),
new CopyWebpackPlugin({
patterns: [
{
from: path.join(__dirname, '../static/pwabuilder-sw.js'),
to: path.join(__dirname, '../dist/web/pwabuilder-sw.js'),
},
{
from: path.join(__dirname, '../static'),
to: path.join(__dirname, '../dist/web/static'),
globOptions: {
ignore: ['.*', 'pwabuilder-sw.js'],
},
},
{
from: path.join(__dirname, '../_icons'),
to: path.join(__dirname, '../dist/web/_icons'),
globOptions: {
ignore: ['.*'],
},
},
]
}
),
new webpack.LoaderOptionsPlugin({
minimize: true,
})

View File

@ -154,22 +154,29 @@ if (isDevMode) {
)
} else {
config.plugins.push(
new CopyWebpackPlugin([
{
from: path.join(__dirname, '../static/pwabuilder-sw.js'),
to: path.join(__dirname, '../dist/web/pwabuilder-sw.js'),
},
{
from: path.join(__dirname, '../static'),
to: path.join(__dirname, '../dist/web/static'),
ignore: ['.*', 'pwabuilder-sw.js'],
},
{
from: path.join(__dirname, '../_icons'),
to: path.join(__dirname, '../dist/web/_icons'),
ignore: ['.*'],
},
]),
new CopyWebpackPlugin({
patterns: [
{
from: path.join(__dirname, '../static/pwabuilder-sw.js'),
to: path.join(__dirname, '../dist/web/pwabuilder-sw.js'),
},
{
from: path.join(__dirname, '../static'),
to: path.join(__dirname, '../dist/web/static'),
globOptions: {
ignore: ['.*', 'pwabuilder-sw.js'],
},
},
{
from: path.join(__dirname, '../_icons'),
to: path.join(__dirname, '../dist/web/_icons'),
globOptions: {
ignore: ['.*'],
},
},
]
}
),
new webpack.LoaderOptionsPlugin({
minimize: true,
})

View File

@ -95,9 +95,9 @@
},
"license": "GPL-3.0-or-later",
"main": "./dist/main.js",
"name": "freetube",
"name": "freetube-vue",
"private": true,
"productName": "FreeTube",
"productName": "FreeTube-Vue",
"repository": {
"type": "git",
"url": "git+https://github.com/mubaidr/vue-electron-template.git"

View File

@ -191,11 +191,7 @@ export default Vue.extend({
this.hideViews = true
}
if (typeof (this.data.uploaded_at) !== 'undefined' && this.data.uploaded_at !== null && this.data.uploaded_at.includes('watching')) {
const uploadSplit = this.data.uploaded_at.split(' ')
this.viewCount = parseInt(uploadSplit[0])
this.isLive = true
}
this.isLive = this.data.live
}
}
})

View File

@ -217,7 +217,7 @@ export default Vue.extend({
src: this.storyboardSrc
})
if (this.useDash) {
if (this.useDash || this.useHls) {
this.dataSetup.plugins.httpSourceSelector = {
default: 'auto'
}

View File

@ -0,0 +1,211 @@
.relative {
position: relative;
}
.messageContainer {
width: 100%;
height: 100%;
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
text-align: center;
}
.message {
font-size: 18px;
color: var(--tertiary-text-color);
padding: 0;
margin: 0;
}
.errorIcon {
width: 100%;
color: var(--tertiary-text-color);
font-size: 100px;
}
.enableLiveChat {
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.superChatComments {
width: 100%;
height: 50px;
overflow-x: auto;
white-space: nowrap;
}
.superChat {
display: inline-block;
padding: 1px;
padding-right: 10px;
margin-left: 2px;
margin-right: 2px;
height: 30px;
cursor: pointer;
border-radius: 200px 200px 200px 200px;
-webkit-border-radius: 200px 200px 200px 200px;
}
.superChatContent {
margin-left: 32px;
margin-top: 6px;
}
.superChat .channelThumbnail {
margin-top: 3px;
margin-left: 3px;
}
.donationAmount {
color: var(--text-with-main-color);
}
.openedSuperChat {
background-color: rgba(0, 0, 0, 0.7);
width: 100%;
height: 415px;
position: absolute;
margin-left: -16px;
padding-right: 32px;
bottom: -15px;
cursor: auto;
z-index: 1;
}
.openedSuperChat .superChatMessage {
position: absolute;
}
.superChatMessage {
width: 90%;
margin-left: 5%;
margin-right: 5%;
margin-top: 10px;
background-color: var(--primary-color);
border-radius: 5px 5px 5px 5px;
-webkit-border-radius: 5px 5px 5px 5px;
}
.upperSuperChatMessage {
margin-top: -15px;
width: 100%;
height: 55px;
background-color: var(--primary-color-hover);
}
.upperSuperChatMessage .channelThumbnail {
width: 45px;
margin-left: 10px;
margin-top: 5px;
}
.upperSuperChatMessage .channelName {
color: var(--text-with-main-color);
opacity: 0.7;
position: relative;
top: 5px;
margin-left: 60px;
}
.upperSuperChatMessage .donationAmount {
color: var(--text-with-main-color);
font-weight: bold;
margin-left: 65px;
position: relative;
bottom: 5px;
}
.superChatMessage .chatMessage {
color: var(--text-with-main-color);
margin-left: 20px;
}
.liveChatComments {
width: 100%;
overflow-y: auto;
}
.comment .superChatMessage {
padding: 5px;
}
.comment .upperSuperChatMessage {
padding: 0px;
}
.comment {
width: 100%;
padding-top: 5px;
padding-bottom: 7px;
}
.channelThumbnail {
width: 25px;
float: left;
border-radius: 200px 200px 200px 200px;
-webkit-border-radius: 200px 200px 200px 200px;
}
.chatContent {
margin-left: 30px;
margin-top: 5px;
margin-bottom: 2px;
font-size: 12px;
word-wrap: break-word;
}
.channelName {
color: var(--tertiary-text-color);
font-weight: bold;
padding-left: 5px;
padding-right: 5px;
}
.member {
color: #4CAF50;
}
.moderator {
color: #2196F3;
}
.owner {
margin-right: 2px;
background-color: var(--primary-color);
color: var(--text-with-main-color);
}
.badgeImage {
width: 14px;
}
.scrollToBottom {
background-color: var(--accent-color);
width: 35px;
height: 35px;
position: absolute;
left: 45%;
bottom: 20px;
cursor: pointer;
border-radius: 200px 200px 200px 200px;
-webkit-border-radius: 200px 200px 200px 200px;
transition: background 0.2s ease-out;
}
.scrollToBottom:hover {
background-color: var(--accent-color-light);
transition: background 0.2s ease-in;
}
.icon {
color: var(--text-with-accent-color);
font-size: 22px;
position: relative;
left: 0.5rem;
top: 0.45rem;
}

View File

@ -0,0 +1,240 @@
import Vue from 'vue'
import FtLoader from '../ft-loader/ft-loader.vue'
import FtCard from '../ft-card/ft-card.vue'
import FtButton from '../ft-button/ft-button.vue'
import FtListVideo from '../ft-list-video/ft-list-video.vue'
import $ from 'jquery'
import autolinker from 'autolinker'
import { LiveChat } from 'youtube-chat'
export default Vue.extend({
name: 'WatchVideoLiveChat',
components: {
'ft-loader': FtLoader,
'ft-card': FtCard,
'ft-button': FtButton,
'ft-list-video': FtListVideo
},
props: {
videoId: {
type: String,
required: true
},
channelName: {
type: String,
required: true
}
},
data: function () {
return {
liveChat: null,
isLoading: true,
hasError: false,
hasEnded: false,
showEnableChat: false,
errorMessage: '',
stayAtBottom: true,
showSuperChat: false,
showScrollToBottom: false,
comments: [],
superChatComments: [],
superChat: {
author: {
name: '',
thumbnail: ''
},
message: [
''
],
superChat: {
amount: ''
}
}
}
},
computed: {
usingElectron: function () {
return this.$store.getters.getUsingElectron
},
backendPreference: function () {
return this.$store.getters.getBackendPreference
},
backendFallback: function () {
return this.$store.getters.getBackendFallback
},
chatHeight: function () {
if (this.superChatComments.length > 0) {
return '390px'
} else {
return '445px'
}
}
},
created: function () {
if (!this.usingElectron) {
this.hasError = true
this.errorMessage = 'Live Chat is currently not supported in this build.'
} else {
switch (this.backendPreference) {
case 'local':
console.log('Getting Chat')
this.getLiveChatLocal()
break
case 'invidious':
if (this.backendFallback) {
this.getLiveChatLocal()
} else {
this.hasError = true
this.errorMessage = 'Live Chat is currently not supported with the Invidious API. A direct connection to YouTube is required.'
this.showEnableChat = true
this.isLoading = false
}
break
}
}
},
methods: {
enableLiveChat: function () {
this.hasError = false
this.showEnableChat = false
this.isLoading = true
this.getLiveChatLocal()
},
getLiveChatLocal: function () {
this.liveChat = new LiveChat({ liveId: this.videoId })
this.isLoading = false
this.liveChat.on('start', (liveId) => {
console.log('Live chat is enabled')
this.isLoading = false
})
this.liveChat.on('end', (reason) => {
console.log('Live chat has ended')
console.log(reason)
})
this.liveChat.on('error', (err) => {
this.hasError = true
this.errorMessage = err
this.showEnableChat = false
})
this.liveChat.on('comment', (comment) => {
this.parseLiveChatComment(comment)
})
this.liveChat.start()
},
parseLiveChatComment: function (comment) {
if (this.hasEnded) {
return
}
comment.messageHtml = ''
comment.message.forEach((text) => {
comment.messageHtml = comment.messageHtml + text.text
})
comment.messageHtml = autolinker.link(comment.messageHtml)
const liveChatComments = $('.liveChatComments')
if (typeof (liveChatComments.get(0)) === 'undefined' && this.comments.length !== 0) {
this.liveChat.stop()
return
}
this.comments.push(comment)
if (typeof (comment.superchat) !== 'undefined') {
this.$store.dispatch('getRandomColorClass').then((data) => {
comment.superchat.colorClass = data
this.superChatComments.unshift(comment)
setTimeout(() => {
this.removeFromSuperChat(comment.id)
}, 120000)
})
}
if (comment.author.name[0] === 'Ge' || comment.author.name[0] === 'Ne') {
this.$store.dispatch('getRandomColorClass').then((data) => {
comment.superChat = {
amount: '$5.00',
colorClass: data
}
this.superChatComments.unshift(comment)
setTimeout(() => {
this.removeFromSuperChat(comment.id)
}, 120000)
})
}
if (this.stayAtBottom) {
liveChatComments.animate({ scrollTop: liveChatComments.prop('scrollHeight') })
}
},
removeFromSuperChat: function (id) {
this.superChatComments = this.superChatComments.filter((comment) => {
return comment.id !== id
})
},
showSuperChatComment: function (comment) {
if (this.superChat.id === comment.id && this.showSuperChat) {
this.showSuperChat = false
} else {
this.superChat = comment
this.showSuperChat = true
}
},
onScroll: function (event) {
const liveChatComments = $('.liveChatComments').get(0)
const scrollTop = liveChatComments.scrollTop
const scrollHeight = liveChatComments.scrollHeight
const clientHeight = liveChatComments.clientHeight
if (event.wheelDelta >= 0 && this.stayAtBottom) {
$('.liveChatComments').data('animating', 0)
this.stayAtBottom = false
if (liveChatComments.scrollHeight > liveChatComments.clientHeight) {
this.showScrollToBottom = true
}
} else if (event.wheelDelta < 0 && !this.stayAtBottom) {
if ((liveChatComments.scrollHeight - liveChatComments.scrollTop) === liveChatComments.clientHeight) {
this.scrollToBottom()
}
}
},
scrollToBottom: function () {
const liveChatComments = $('.liveChatComments')
liveChatComments.animate({ scrollTop: liveChatComments.prop('scrollHeight') })
this.stayAtBottom = true
this.showScrollToBottom = false
},
preventDefault: function (event) {
event.stopPropagation()
event.preventDefault()
}
},
beforeRouteLeave: function () {
this.liveChat.stop()
this.hasEnded = true
}
})

View File

@ -0,0 +1,198 @@
<template>
<ft-card class="relative">
<ft-loader
v-if="isLoading"
/>
<div
v-else-if="hasError"
class="messageContainer"
>
<p
class="message"
>
{{ errorMessage }}
</p>
<font-awesome-icon
icon="exclamation-circle"
class="errorIcon"
/>
<ft-button
label="Enable Live Chat"
class="enableLiveChat"
@click="enableLiveChat"
/>
</div>
<div
v-else-if="comments.length === 0"
class="messageContainer"
>
<p
class="message"
>
Live chat is enabled. Chat messages will appear here once sent.
</p>
</div>
<div
v-else
class="relative"
>
<h4>Live Chat</h4>
<div
v-if="superChatComments.length > 0"
class="superChatComments"
>
<div
v-for="(comment, index) in superChatComments"
:key="index"
:style="{ backgroundColor: 'var(--primary-color)' }"
class="superChat"
:class="comment.superchat.colorClass"
@click="showSuperChatComment(comment)"
>
<img
:src="comment.author.thumbnail.url"
class="channelThumbnail"
/>
<p
class="superChatContent"
:style="{ color: 'var(--text-with-main-color)' }"
>
<span
class="donationAmount"
>
{{ comment.superchat.amount }}
</span>
</p>
</div>
</div>
<div
class="openedSuperChat"
v-if="showSuperChat"
:class="superChat.superchat.colorClass"
@click="showSuperChat = false"
>
<div
class="superChatMessage"
@click="e => preventDefault(e)"
>
<div
class="upperSuperChatMessage"
>
<img
:src="superChat.author.thumbnail.url"
class="channelThumbnail"
/>
<p
class="channelName"
>
{{ superChat.author.name }}
</p>
<p
class="donationAmount"
>
{{ superChat.superchat.amount }}
</p>
</div>
<p
class="chatMessage"
v-if="superChat.message.length > 0"
v-html="comment.messageHtml"
>
</p>
</div>
</div>
<div
class="liveChatComments"
:style="{ height: chatHeight }"
@mousewheel="e => onScroll(e)"
>
<div v-for="(comment, index) in comments"
:key="index"
class="comment">
<div
v-if="typeof (comment.superchat) !== 'undefined'"
class="superChatMessage"
:class="comment.superchat.colorClass"
>
<div
class="upperSuperChatMessage"
>
<img
:src="comment.author.thumbnail.url"
class="channelThumbnail"
/>
<p
class="channelName"
>
{{ comment.author.name }}
</p>
<p
class="donationAmount"
>
{{ comment.superchat.amount }}
</p>
</div>
<p
class="chatMessage"
v-if="comment.message.length > 0"
v-html="comment.messageHtml"
>
</p>
</div>
<div
v-else
>
<img
:src="comment.author.thumbnail.url"
class="channelThumbnail"
/>
<p
class="chatContent"
>
<span
class="channelName"
:class="{
member: typeof (comment.author.badge) !== 'undefined' || comment.membership,
moderator: comment.isOwner,
owner: comment.author.name === channelName
}"
>
{{ comment.author.name }}
</span>
<span
v-if="typeof (comment.author.badge) !== 'undefined'"
class="badge"
>
<img
:src="comment.author.badge.thumbnail.url"
:alt="comment.author.badge.thumbnail.alt"
:title="comment.author.badge.thumbnail.alt"
class="badgeImage"
/>
</span>
<span
class="chatMessage"
v-if="comment.message.length > 0"
v-html="comment.messageHtml"
>
</span>
</p>
</div>
</div>
</div>
<div
v-if="showScrollToBottom"
class="scrollToBottom"
@click="scrollToBottom"
>
<font-awesome-icon
class="icon"
icon="arrow-down"
/>
</div>
</div>
</ft-card>
</template>
<script src="./watch-video-live-chat.js" />
<style scoped src="./watch-video-live-chat.css" />

View File

@ -8,7 +8,25 @@ const state = {
time: '',
type: 'all',
duration: ''
}
},
colorClasses: [
'mainRed',
'mainPink',
'mainPurple',
'mainDeepPurple',
'mainIndigo',
'mainBlue',
'mainLightBlue',
'mainCyan',
'mainTeal',
'mainGreen',
'mainLightGreen',
'mainLime',
'mainYellow',
'mainAmber',
'mainOrange',
'mainDeepOrange'
]
}
const getters = {
@ -29,7 +47,12 @@ const getters = {
}
}
const actions = {}
const actions = {
getRandomColorClass () {
const randomInt = Math.floor(Math.random() * state.colorClasses.length)
return state.colorClasses[randomInt]
}
}
const mutations = {
toggleSideNav (state) {

View File

@ -107,6 +107,7 @@
.watchVideoPlaylist {
float: none;
margin: 0 auto;
margin-bottom: 10px;
width: 85%;
max-width: none;
position: static;

View File

@ -8,6 +8,7 @@ import FtVideoPlayer from '../../components/ft-video-player/ft-video-player.vue'
import WatchVideoInfo from '../../components/watch-video-info/watch-video-info.vue'
import WatchVideoDescription from '../../components/watch-video-description/watch-video-description.vue'
import WatchVideoComments from '../../components/watch-video-comments/watch-video-comments.vue'
import WatchVideoLiveChat from '../../components/watch-video-live-chat/watch-video-live-chat.vue'
import WatchVideoPlaylist from '../../components/watch-video-playlist/watch-video-playlist.vue'
import WatchVideoRecommendations from '../../components/watch-video-recommendations/watch-video-recommendations.vue'
@ -21,6 +22,7 @@ export default Vue.extend({
'watch-video-info': WatchVideoInfo,
'watch-video-description': WatchVideoDescription,
'watch-video-comments': WatchVideoComments,
'watch-video-live-chat': WatchVideoLiveChat,
'watch-video-playlist': WatchVideoPlaylist,
'watch-video-recommendations': WatchVideoRecommendations,
},
@ -185,16 +187,21 @@ export default Vue.extend({
this.isLive = result.player_response.videoDetails.isLive
if (this.isLive) {
this.showLegacyPlayer = false
this.showDashPlayer = true
this.videoSourceList = [
{
url: 'https://invidious.snopyta.org/api/manifest/dash/id/EEIk7gwjgIM',
type: 'application/dash+xml',
this.showLegacyPlayer = true
this.showDashPlayer = false
this.videoSourceList = result.formats.filter((format) => {
if (typeof (format.mimeType) !== 'undefined') {
return format.mimeType.includes('video/ts')
}
}).map((format) => {
return {
url: format.url,
type: 'application/x-mpegURL',
label: 'Dash',
qualityLabel: 'Auto'
},
]
qualityLabel: format.qualityLabel
}
})
} else {
this.videoSourceList = result.player_response.streamingData.formats
}
@ -271,6 +278,7 @@ export default Vue.extend({
this.videoPublished = result.published * 1000
this.videoDescriptionHtml = result.descriptionHtml
this.recommendedVideos = result.recommendedVideos
this.isLive = result.liveNow
this.captionSourceList = result.captions.map(caption => {
caption.url = this.invidiousInstance + caption.url
caption.type = ''
@ -278,7 +286,35 @@ export default Vue.extend({
return caption
})
if (this.forceLocalBackendForLegacy) {
if (this.isLive) {
this.showLegacyPlayer = true
this.showDashPlayer = false
this.activeFormat = 'legacy'
this.videoSourceList = [
{
url: result.hlsUrl,
type: 'application/x-mpegURL',
label: 'Dash',
qualityLabel: 'Live'
}
]
// Grabs the adaptive formats from Invidious. Might be worth making these work.
// The type likely needs to be changed in order for these to be played properly.
// this.videoSourceList = result.adaptiveFormats.filter((format) => {
// if (typeof (format.type) !== 'undefined') {
// return format.type.includes('video/mp4')
// }
// }).map((format) => {
// return {
// url: format.url,
// type: 'application/x-mpegURL',
// label: 'Dash',
// qualityLabel: format.qualityLabel
// }
// })
} else if (this.forceLocalBackendForLegacy) {
this.getLegacyFormats()
} else {
this.videoSourceList = result.formatStreams.reverse()
@ -302,8 +338,6 @@ export default Vue.extend({
checkIfPlaylist: function () {
if (typeof (this.$route.query) !== 'undefined') {
console.log('defined')
console.log(this.$route.query)
this.playlistId = this.$route.query.playlistId
if (typeof (this.playlistId) !== 'undefined') {
@ -325,7 +359,7 @@ export default Vue.extend({
},
enableDashFormat: function () {
if (this.activeFormat === 'dash') {
if (this.activeFormat === 'dash' || this.isLive) {
return
}
@ -361,6 +395,10 @@ export default Vue.extend({
handleVideoError: function(error) {
console.log(error)
if (this.isLive) {
return
}
if (error.code === 4) {
if (this.activeFormat === 'dash') {
console.log(

View File

@ -41,11 +41,18 @@
:class="{ theatreWatchVideo: useTheatreMode }"
/>
<watch-video-comments
v-if="!isLoading"
v-if="!isLoading && !isLive"
:id="videoId"
class="watchVideo"
:class="{ theatreWatchVideo: useTheatreMode }"
/>
<watch-video-live-chat
v-if="!isLoading && isLive"
:video-id="videoId"
:channel-name="channelName"
class="watchVideoSideBar watchVideoPlaylist"
:class="{ theatrePlaylist: useTheatreMode }"
/>
<watch-video-playlist
v-if="watchingPlaylist"
v-show="!isLoading"
@ -61,8 +68,8 @@
class="watchVideoSideBar watchVideoRecommendations"
:class="{
theatreRecommendations: useTheatreMode,
watchVideoRecommendationsLowerCard: watchingPlaylist,
watchVideoRecommendationsNoCard: !watchingPlaylist
watchVideoRecommendationsLowerCard: watchingPlaylist || isLive,
watchVideoRecommendationsNoCard: !watchingPlaylist || !isLive
}"
/>
</div>