diff --git a/src/backend/effects/builtin/play-video.js b/src/backend/effects/builtin/play-video.js index e41ac2bed..59c838098 100644 --- a/src/backend/effects/builtin/play-video.js +++ b/src/backend/effects/builtin/play-video.js @@ -10,6 +10,9 @@ const accountAccess = require("../../common/account-access"); const util = require("../../utility"); const fs = require('fs-extra'); const path = require("path"); +const frontendCommunicator = require('../../common/frontend-communicator'); +const { wait } = require("../../utility"); +const { parseYoutubeId } = require("../../../shared/youtube-url-parser"); /** * The Play Video effect @@ -439,40 +442,31 @@ const playVideo = { } let resourceToken; + let duration; if (effect.videoType === "YouTube Video") { - resourceToken = resourceTokenManager.storeResourcePath(data.filepath, effect.length); + const youtubeData = parseYoutubeId(data.youtubeId); + data.youtubeId = youtubeData.id; + const result = await frontendCommunicator.fireEventAsync("getYoutubeVideoDuration", data.youtubeId); + if (!isNaN(result)) { + duration = result; + } + if (data.videoStarttime == null || data.videoStarttime == "" || data.videoStarttime == 0) { + data.videoStarttime = youtubeData.startTime; + } } else { - const durationToken = resourceTokenManager.storeResourcePath(data.filepath, 5); - - const durationPromise = new Promise(async (resolve, reject) => { - const listener = (event) => { - try { - if (event.name === "video-duration" && event.data.resourceToken === durationToken) { - webServer.removeListener("overlay-event", listener); - resolve(event.data.duration); - } - } catch (err) { - logger.error("Error while trying to process overlay-event for getVideoDuration: ", err); - reject(err); - } - }; - webServer.on("overlay-event", listener); - }); - - webServer.sendToOverlay("getVideoDuration", { - resourceToken: durationToken, - overlayInstance: data.overlayInstance - }); - - const duration = await durationPromise; - resourceToken = resourceTokenManager.storeResourcePath(data.filepath, duration + 5); - logger.info(`Retrieved duration for video: ${duration}`); + const result = await frontendCommunicator.fireEventAsync("getVideoDuration", data.filepath); + if (!isNaN(result)) { + duration = result; + } + resourceToken = resourceTokenManager.storeResourcePath(data.filepath, duration); } data.resourceToken = resourceToken; webServer.sendToOverlay("video", data); - if (effect.wait) { + if (effect.wait && effect.videoType === "YouTube Video") { + // Use overlay callback for youtube video, needs local way to get duration for production. + // If effect is ran with "Wait for video to finish" while overlay is not open, it may freeze an effect queue. await new Promise(async (resolve, reject) => { const listener = (event) => { try { @@ -487,6 +481,12 @@ const playVideo = { }; webServer.on("overlay-event", listener); }); + } else if (effect.wait && data.videoType === "Local Video") { + let internalDuration = data.videoDuration; + if (internalDuration == null || internalDuration === 0 || internalDuration === "") { + internalDuration = duration; + } + await wait(internalDuration * 1000); } return true; }, @@ -637,9 +637,6 @@ const playVideo = { const exitVideo = () => { delete startedVidCache[this.id]; // eslint-disable-line no-undef animateVideoExit(`#${wrapperId}`, exitAnimation, exitDuration, inbetweenAnimation); - setTimeout(function(){ - sendWebsocketEvent("video-end", {resourceToken: token}); // eslint-disable-line no-undef - }, millisecondsFromString(exitDuration)); }; // Remove div after X time. @@ -669,29 +666,6 @@ const playVideo = { $(".wrapper").append(wrappedHtml); - try { - const url = new URL(youtubeId); - if (url.hostname.includes("www.youtube.com")) { - for (const [key, value] of url.searchParams) { - if (key === "v") { - youtubeId = value; - } else if (key === "t") { - videoStarttime = value; - } - } - } - if (url.hostname.includes("youtu.be")) { - youtubeId = url.pathname.replace("/", ""); - for (const [key, value] of url.searchParams) { - if (key === "t") { - videoStarttime = value; - } - } - } - } catch (error) { - //failed to convert url - } - // Add iframe. const playerVars = { @@ -737,9 +711,6 @@ const playVideo = { onStateChange: (event) => { if (event.data === 0 && !videoDuration) { animateVideoExit(`#${wrapperId}`, exitAnimation, exitDuration, inbetweenAnimation); - setTimeout(function(){ - sendWebsocketEvent("video-end", {resourceToken: token}); // eslint-disable-line no-undef - }, millisecondsFromString(exitDuration)); } } } @@ -757,9 +728,6 @@ const playVideo = { if (videoDuration) { setTimeout(function () { animateVideoExit(`#${wrapperId}`, exitAnimation, exitDuration, inbetweenAnimation); - setTimeout(function(){ - sendWebsocketEvent("video-end", {resourceToken: token}); // eslint-disable-line no-undef - }, millisecondsFromString(exitDuration)); }, videoDuration); } } diff --git a/src/backend/variables/builtin-variable-loader.js b/src/backend/variables/builtin-variable-loader.js index d4fc49cb4..874754ed6 100644 --- a/src/backend/variables/builtin-variable-loader.js +++ b/src/backend/variables/builtin-variable-loader.js @@ -31,6 +31,7 @@ exports.loadReplaceVariables = () => { 'array-reverse', 'array-shuffle-raw', 'array-shuffle', + 'audio-duration', 'bits-badge-tier', 'bits-badge-unlocked-message', 'bits-cheered', @@ -203,9 +204,11 @@ exports.loadReplaceVariables = () => { 'username-array-raw', 'username-array', 'username', + 'video-duration', 'view-time', 'whisper-message', - 'word' + 'word', + 'youtube-video-duration' ].forEach(filename => { const definition = require(`./builtin/${filename}`); replaceVariableManager.registerReplaceVariable(definition); diff --git a/src/backend/variables/builtin/audio-duration.js b/src/backend/variables/builtin/audio-duration.js new file mode 100644 index 000000000..65642aeba --- /dev/null +++ b/src/backend/variables/builtin/audio-duration.js @@ -0,0 +1,29 @@ +"use strict"; + +const { OutputDataType, VariableCategory } = require("../../../shared/variable-constants"); + +const frontendCommunicator = require("../../common/frontend-communicator"); + +const model = { + definition: { + handle: "audioDuration", + usage: "audioDuration[filePathOrUrl]", + description: "Attempts to retrieve audio duration.", + categories: [VariableCategory.ADVANCED], + possibleDataOutput: [OutputDataType.NUMBER] + }, + evaluator: async (trigger, url) => { + if (url == null) { + return "[NO URL PROVIDED]"; + } + try { + return await frontendCommunicator.fireEventAsync("getSoundDuration", { + path: url + }); + } catch (err) { + return "[ERROR FETCHING DURATION]"; + } + } +}; + +module.exports = model; diff --git a/src/backend/variables/builtin/video-duration.js b/src/backend/variables/builtin/video-duration.js new file mode 100644 index 000000000..ce05d3681 --- /dev/null +++ b/src/backend/variables/builtin/video-duration.js @@ -0,0 +1,30 @@ +"use strict"; + +const { OutputDataType, VariableCategory } = require("../../../shared/variable-constants"); + +const frontendCommunicator = require("../../common/frontend-communicator"); +const logger = require("../../logwrapper"); + +const model = { + definition: { + handle: "videoDuration", + usage: "videoDuration[filePathOrUrl]", + description: "Attempts to retrieve video duration.", + categories: [VariableCategory.ADVANCED], + possibleDataOutput: [OutputDataType.TEXT] + }, + evaluator: async (trigger, url) => { + if (url == null) { + return "[NO URL PROVIDED]"; + } + const result = await frontendCommunicator.fireEventAsync("getVideoDuration", url); + + if (isNaN(result)) { + logger.error("Error while retrieving video duration", result); + return "[ERROR FETCHING DURATION]"; + } + return result; + } +}; + +module.exports = model; diff --git a/src/backend/variables/builtin/youtube-video-duration.js b/src/backend/variables/builtin/youtube-video-duration.js new file mode 100644 index 000000000..9c032ebc0 --- /dev/null +++ b/src/backend/variables/builtin/youtube-video-duration.js @@ -0,0 +1,30 @@ +"use strict"; + +const { OutputDataType, VariableCategory } = require("../../../shared/variable-constants"); +const logger = require("../../logwrapper"); +const frontendCommunicator = require("../../common/frontend-communicator"); +const { parseYoutubeId } = require("../../../shared/youtube-url-parser"); + +const model = { + definition: { + handle: "youtubeVideoDuration", + usage: "youtubeVideoDuration[urlOrId]", + description: "Attempts to retrieve youtube video duration.", + categories: [VariableCategory.ADVANCED], + possibleDataOutput: [OutputDataType.TEXT] + }, + evaluator: async (trigger, id) => { + if (id == null) { + return "[NO VIDEO ID PROVIDED]"; + } + const result = await frontendCommunicator.fireEventAsync("getYoutubeVideoDuration", parseYoutubeId(id).id); + + if (isNaN(result)) { + logger.error("Error while retrieving youtube video duration", result); + return "[ERROR FETCHING DURATION]"; + } + return result; + } +}; + +module.exports = model; diff --git a/src/gui/app/app-main.js b/src/gui/app/app-main.js index 0414afa88..aead197b7 100644 --- a/src/gui/app/app-main.js +++ b/src/gui/app/app-main.js @@ -121,9 +121,10 @@ scheduledTaskService, channelRewardsService, sortTagsService, - iconsService + iconsService, + videoService ) { - // 'chatMessagesService' is included so its instantiated on app start + // 'chatMessagesService' and 'videoService' are included so they're instantiated on app start connectionService.loadProfiles(); diff --git a/src/gui/app/index.html b/src/gui/app/index.html index 79ce6c0ce..20d5df52a 100644 --- a/src/gui/app/index.html +++ b/src/gui/app/index.html @@ -89,6 +89,8 @@ + + diff --git a/src/gui/app/services/video.service.js b/src/gui/app/services/video.service.js new file mode 100644 index 000000000..dc1f1cc38 --- /dev/null +++ b/src/gui/app/services/video.service.js @@ -0,0 +1,87 @@ +"use strict"; + +(function() { + + const uuid = require("uuid"); + + // This provides methods for playing sounds + + angular + .module("firebotApp") + .factory("videoService", function(backendCommunicator) { + const service = {}; + + service.getVideoDuration = function (path) { + return new Promise((resolve) => { + const id = "video-" + uuid(); + const videoElement = ``; + $(document.documentElement).append(videoElement); + const video = document.getElementById(id); + video.onloadedmetadata = () => { + resolve(video.duration); + video.remove(); + }; + + video.onerror = () => { + const result = { + error: video.error.message, + path: video.src + }; + video.remove(); + resolve(result); + }; + + video.src = path; + }); + }; + + service.getYoutubeVideoDuration = function (videoId) { + return new Promise((resolve) => { + const id = "video-" + uuid(); + $(document.documentElement).append(``); + // eslint-disable-next-line no-undef + const player = new YT.Player(id, { + videoId: videoId, + events: { + onReady: (event) => { + event.target.setVolume(0); + event.target.playVideo(); + resolve(player.getDuration()); + document.getElementById(id).remove(); + }, + onError: (event) => { + const error = { + code: event.data, + youtubeId: videoId + }; + if (event.data === 2) { + error.error = "The request contains an invalid parameter value."; + } + if (event.data === 5) { + error.error = "The requested content cannot be played in an HTML5 player or another error related to the HTML5 player has occurred."; + } + if (event.data === 100) { + error.error = "The video requested was not found. This error occurs when a video has been removed (for any reason) or has been marked as private."; + } + if (event.data === 101 || event.data === 150) { + error.error = "The owner of the requested video does not allow it to be played in embedded players."; + } + resolve(error); + document.getElementById(id).remove(); + } + } + }); + }); + }; + + backendCommunicator.onAsync("getVideoDuration", async (path) => { + return service.getVideoDuration(path); + }); + + backendCommunicator.onAsync("getYoutubeVideoDuration", async (youtubeId) => { + return service.getYoutubeVideoDuration(youtubeId); + }); + + return service; + }); +}(window.angular)); diff --git a/src/resources/overlay/js/main.js b/src/resources/overlay/js/main.js index 191d6800c..82fdfa915 100644 --- a/src/resources/overlay/js/main.js +++ b/src/resources/overlay/js/main.js @@ -49,22 +49,6 @@ function overlaySocketConnect(){ return; } - if(event == "getVideoDuration") { - const token = encodeURIComponent(data.meta.resourceToken); - const url = `http://${window.location.hostname}:7472/resource/${token}`; - const videoElement = ` - - `; - $(".wrapper").append(videoElement); - const video = document.getElementById(token); - video.oncanplay = () => { - sendWebsocketEvent("video-duration", {resourceToken: token, duration: Math.ceil(video.duration)}); - video.remove(); - } - } - firebotOverlay.emit(event, data.meta); }; diff --git a/src/shared/youtube-url-parser.js b/src/shared/youtube-url-parser.js new file mode 100644 index 000000000..a2a7bcc51 --- /dev/null +++ b/src/shared/youtube-url-parser.js @@ -0,0 +1,44 @@ +'use strict'; + +function parseYoutubeId(id) { + if (id.length === 11) { // string is same length as YouTube id + return {id: id}; + } + + let url; + let finalId = id; + let startTime; + + if (!id.startsWith("http")) { + id = "http://" + id; + } + + try { + url = new URL(id); + + if (url.hostname === "youtube.com" || url.hostname === "www.youtube.com") { + if (url.pathname.includes("/shorts/")) { + return {id: url.pathname.replace("/shorts/", "")}; + } + } else if (url.hostname === "youtu.be") { + finalId = url.pathname.replace("/", ""); + } else { + return {id: finalId}; + } + + for (const [key, value] of url.searchParams) { + if (key === "v") { + finalId = value; + } else if (key === "t") { + startTime = value; + } + } + + } catch (error) { + return {id: finalId}; + } + + return {id: finalId, startTime: startTime}; +} + +exports.parseYoutubeId = parseYoutubeId; \ No newline at end of file