From c200020e556589782e053954fb6de42fb23718b0 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 10 Oct 2017 20:44:09 -0600 Subject: [PATCH] Support video widgets (youtube, vimeo, dailymotion) Adds #89 --- config/integrations/youtube_widget.yaml | 7 + package-lock.json | 48 ++++- package.json | 1 + web/app/app.module.ts | 5 + web/app/app.routing.ts | 2 + .../custom_widget-config.component.html | 2 +- .../custom_widget-config.component.ts | 146 ++------------- web/app/configs/widget/widget.component.ts | 168 +++++++++++++++++- .../youtube/youtube-config.component.html | 65 +++++++ .../youtube/youtube-config.component.scss | 4 + .../youtube/youtube-config.component.ts | 72 ++++++++ web/app/riot/riot.component.ts | 5 +- web/app/shared/integration.service.ts | 5 +- web/app/shared/models/widget.ts | 1 + .../video/video.component.html | 1 + .../video/video.component.scss | 10 ++ .../widget_wrappers/video/video.component.ts | 19 ++ web/public/img/avatars/youtube.png | Bin 0 -> 7200 bytes 18 files changed, 417 insertions(+), 144 deletions(-) create mode 100644 config/integrations/youtube_widget.yaml create mode 100644 web/app/configs/widget/youtube/youtube-config.component.html create mode 100644 web/app/configs/widget/youtube/youtube-config.component.scss create mode 100644 web/app/configs/widget/youtube/youtube-config.component.ts create mode 100644 web/app/widget_wrappers/video/video.component.html create mode 100644 web/app/widget_wrappers/video/video.component.scss create mode 100644 web/app/widget_wrappers/video/video.component.ts create mode 100644 web/public/img/avatars/youtube.png diff --git a/config/integrations/youtube_widget.yaml b/config/integrations/youtube_widget.yaml new file mode 100644 index 0000000..36ed516 --- /dev/null +++ b/config/integrations/youtube_widget.yaml @@ -0,0 +1,7 @@ +# All this configuration does is make "Youtube Widget" available in the UI +type: "widget" +integrationType: "youtube" +enabled: true +name: "YouTube Video" +about: "Embed a YouTube, Vimeo, or DailyMotion video" +avatar: "img/avatars/youtube.png" diff --git a/package-lock.json b/package-lock.json index 78190f5..76556aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2096,6 +2096,16 @@ "minimalistic-crypto-utils": "1.0.1" } }, + "embed-video": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/embed-video/-/embed-video-2.0.0.tgz", + "integrity": "sha1-1/JouzRkIg9pXbM6YCHhpgjI4fk=", + "requires": { + "fetch-ponyfill": "4.1.0", + "lodash.escape": "4.0.1", + "promise-polyfill": "6.0.2" + } + }, "emojis-list": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-2.1.0.tgz", @@ -2107,6 +2117,14 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.1.tgz", "integrity": "sha1-eePVhlU0aQn+bw9Fpd5oEDspTSA=" }, + "encoding": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.12.tgz", + "integrity": "sha1-U4tm8+5izRq1HsMjgp0flIDHS+s=", + "requires": { + "iconv-lite": "0.4.15" + } + }, "enhanced-resolve": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-3.3.0.tgz", @@ -2621,6 +2639,14 @@ "websocket-driver": "0.6.5" } }, + "fetch-ponyfill": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/fetch-ponyfill/-/fetch-ponyfill-4.1.0.tgz", + "integrity": "sha1-rjzl9zLGReq4fkroeTQUcJsjmJM=", + "requires": { + "node-fetch": "1.7.3" + } + }, "file-loader": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-1.0.0.tgz", @@ -3739,8 +3765,7 @@ "is-stream": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", - "dev": true + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=" }, "is-svg": { "version": "2.1.0", @@ -4028,6 +4053,11 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=" }, + "lodash.escape": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz", + "integrity": "sha1-yQRGkMIeBClL6qUXcS/e0fqI3pg=" + }, "lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -4470,6 +4500,15 @@ "minimatch": "3.0.4" } }, + "node-fetch": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", + "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==", + "requires": { + "encoding": "0.1.12", + "is-stream": "1.1.0" + } + }, "node-forge": { "version": "0.6.33", "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-0.6.33.tgz", @@ -6555,6 +6594,11 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=" }, + "promise-polyfill": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-6.0.2.tgz", + "integrity": "sha1-2chtPcTcLfkBboiUbe/Wm0m0EWI=" + }, "prompt": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/prompt/-/prompt-1.0.0.tgz", diff --git a/package.json b/package.json index 0457607..d2e0f3e 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "db-migrate": "^0.10.0-beta.23", "db-migrate-sqlite3": "^0.2.1", "dns-then": "^0.1.0", + "embed-video": "^2.0.0", "express": "^4.15.4", "js-yaml": "^3.9.1", "lodash": "^4.17.4", diff --git a/web/app/app.module.ts b/web/app/app.module.ts index d91123d..74a6208 100644 --- a/web/app/app.module.ts +++ b/web/app/app.module.ts @@ -27,6 +27,8 @@ import { MyFilterPipe } from "./shared/my-filter.pipe"; import { GenericWidgetWrapperComponent } from "./widget_wrappers/generic/generic.component"; import { ToggleFullscreenDirective } from "./shared/toggle-fullscreen.directive"; import { FullscreenButtonComponent } from "./fullscreen-button/fullscreen-button.component"; +import { YoutubeWidgetConfigComponent } from "./configs/widget/youtube/youtube-config.component"; +import { VideoWidgetWrapperComponent } from "./widget_wrappers/video/video.component"; @NgModule({ imports: [ @@ -55,6 +57,8 @@ import { FullscreenButtonComponent } from "./fullscreen-button/fullscreen-button GenericWidgetWrapperComponent, ToggleFullscreenDirective, FullscreenButtonComponent, + YoutubeWidgetConfigComponent, + VideoWidgetWrapperComponent, // Vendor ], @@ -73,6 +77,7 @@ import { FullscreenButtonComponent } from "./fullscreen-button/fullscreen-button TravisCiConfigComponent, IrcConfigComponent, CustomWidgetConfigComponent, + YoutubeWidgetConfigComponent, ] }) export class AppModule { diff --git a/web/app/app.routing.ts b/web/app/app.routing.ts index dc32450..4de7ff9 100644 --- a/web/app/app.routing.ts +++ b/web/app/app.routing.ts @@ -2,11 +2,13 @@ import { RouterModule, Routes } from "@angular/router"; import { HomeComponent } from "./home/home.component"; import { RiotComponent } from "./riot/riot.component"; import { GenericWidgetWrapperComponent } from "./widget_wrappers/generic/generic.component"; +import { VideoWidgetWrapperComponent } from "./widget_wrappers/video/video.component"; const routes: Routes = [ {path: "", component: HomeComponent}, {path: "riot", component: RiotComponent}, {path: "widgets/generic", component: GenericWidgetWrapperComponent}, + {path: "widgets/video", component: VideoWidgetWrapperComponent}, ]; export const routing = RouterModule.forRoot(routes); diff --git a/web/app/configs/widget/custom_widget/custom_widget-config.component.html b/web/app/configs/widget/custom_widget/custom_widget-config.component.html index 1429f5d..ab7cb23 100644 --- a/web/app/configs/widget/custom_widget/custom_widget-config.component.html +++ b/web/app/configs/widget/custom_widget/custom_widget-config.component.html @@ -18,7 +18,7 @@
+ +
+ +
+ {{ widget.name || widget.url }} (added by {{ widget.ownerId }}) + + +
+ + + + +
+
+ + + + \ No newline at end of file diff --git a/web/app/configs/widget/youtube/youtube-config.component.scss b/web/app/configs/widget/youtube/youtube-config.component.scss new file mode 100644 index 0000000..92dce18 --- /dev/null +++ b/web/app/configs/widget/youtube/youtube-config.component.scss @@ -0,0 +1,4 @@ +// component styles are encapsulated and only applied to their components +.widget-item { + margin-top: 3px; +} diff --git a/web/app/configs/widget/youtube/youtube-config.component.ts b/web/app/configs/widget/youtube/youtube-config.component.ts new file mode 100644 index 0000000..a8ee089 --- /dev/null +++ b/web/app/configs/widget/youtube/youtube-config.component.ts @@ -0,0 +1,72 @@ +import { Component } from "@angular/core"; +import { ModalComponent, DialogRef } from "ngx-modialog"; +import { WidgetComponent } from "../widget.component"; +import { ScalarService } from "../../../shared/scalar.service"; +import { ConfigModalContext } from "../../../integration/integration.component"; +import { ToasterService } from "angular2-toaster"; +import { Widget, WIDGET_SCALAR_YOUTUBE, WIDGET_DIM_YOUTUBE } from "../../../shared/models/widget"; +import * as embed from "embed-video"; +import * as $ from "jquery"; + +@Component({ + selector: "my-youtubewidget-config", + templateUrl: "youtube-config.component.html", + styleUrls: ["youtube-config.component.scss", "./../../config.component.scss"], +}) +export class YoutubeWidgetConfigComponent extends WidgetComponent implements ModalComponent { + + constructor(public dialog: DialogRef, + toaster: ToasterService, + scalarService: ScalarService, + window: Window) { + super( + toaster, + scalarService, + dialog.context.roomId, + window, + WIDGET_DIM_YOUTUBE, + WIDGET_SCALAR_YOUTUBE, + dialog.context.integrationId, + "Youtube Widget", + "video", // wrapper + "youtube" // scalar wrapper + ); + } + + public validateAndAddWidget() { + const url = this.getSafeUrl(this.newWidgetUrl); + if (!url) { + this.toaster.pop("warning", "Please enter a YouTube, Vimeo, or DailyMotion video URL"); + return; + } + + const originalUrl = this.newWidgetUrl; + this.newWidgetUrl = url; + this.addWidget({dimOriginalUrl: originalUrl}); + } + + public validateAndSaveWidget(widget: Widget) { + const url = this.getSafeUrl(widget.newUrl); + if (!url) { + this.toaster.pop("warning", "Please enter a YouTube, Vimeo, or DailyMotion video URL"); + return; + } + + widget.data = {dimOriginalUrl: widget.newUrl}; + widget.newUrl = url; + this.saveWidget(widget); + } + + private getSafeUrl(url) { + const embedCode = embed(url); + if (!embedCode) { + return null; + } + + // HACK: Grab the video URL from the iframe + url = $(embedCode).attr("src"); + if (url.startsWith("//")) url = "https:" + url; + + return url; + } +} diff --git a/web/app/riot/riot.component.ts b/web/app/riot/riot.component.ts index 8fc17a2..d41dec6 100644 --- a/web/app/riot/riot.component.ts +++ b/web/app/riot/riot.component.ts @@ -6,7 +6,7 @@ import { ToasterService } from "angular2-toaster"; import { Integration } from "../shared/models/integration"; import { IntegrationService } from "../shared/integration.service"; import * as _ from "lodash"; -import { WIDGET_DIM_CUSTOM } from "../shared/models/widget"; +import { WIDGET_DIM_CUSTOM, WIDGET_DIM_YOUTUBE } from "../shared/models/widget"; import { IntegrationComponent } from "../integration/integration.component"; @Component({ @@ -73,6 +73,9 @@ export class RiotComponent { if (this.requestedScreen === "type_" + WIDGET_DIM_CUSTOM) { type = "widget"; integrationType = "customwidget"; + } else if (this.requestedScreen === "type_" + WIDGET_DIM_YOUTUBE) { + type = "widget"; + integrationType = "youtube"; } else { console.log("Unknown screen requested: " + this.requestedScreen); } diff --git a/web/app/shared/integration.service.ts b/web/app/shared/integration.service.ts index 2705602..a6e65a6 100644 --- a/web/app/shared/integration.service.ts +++ b/web/app/shared/integration.service.ts @@ -5,6 +5,7 @@ import { ContainerContent } from "ngx-modialog"; import { IrcConfigComponent } from "../configs/irc/irc-config.component"; import { TravisCiConfigComponent } from "../configs/travisci/travisci-config.component"; import { CustomWidgetConfigComponent } from "../configs/widget/custom_widget/custom_widget-config.component"; +import { YoutubeWidgetConfigComponent } from "../configs/widget/youtube/youtube-config.component"; @Injectable() export class IntegrationService { @@ -19,7 +20,8 @@ export class IntegrationService { "irc": true, }, "widget": { - "customwidget": true + "customwidget": true, + "youtube": true, }, }; @@ -33,6 +35,7 @@ export class IntegrationService { }, "widget": { "customwidget": CustomWidgetConfigComponent, + "youtube": YoutubeWidgetConfigComponent, }, }; diff --git a/web/app/shared/models/widget.ts b/web/app/shared/models/widget.ts index 9446ef0..e1017f4 100644 --- a/web/app/shared/models/widget.ts +++ b/web/app/shared/models/widget.ts @@ -10,6 +10,7 @@ export const WIDGET_SCALAR_GRAFANA = "grafana"; // Dimension has its own set of types to ensure that we don't conflict with Scalar export const WIDGET_DIM_CUSTOM = "dimension-customwidget"; +export const WIDGET_DIM_YOUTUBE = "dimension-youtube"; export interface Widget { id: string; diff --git a/web/app/widget_wrappers/video/video.component.html b/web/app/widget_wrappers/video/video.component.html new file mode 100644 index 0000000..246cda2 --- /dev/null +++ b/web/app/widget_wrappers/video/video.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/app/widget_wrappers/video/video.component.scss b/web/app/widget_wrappers/video/video.component.scss new file mode 100644 index 0000000..10e3be2 --- /dev/null +++ b/web/app/widget_wrappers/video/video.component.scss @@ -0,0 +1,10 @@ +// component styles are encapsulated and only applied to their components +iframe { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + width: 100%; + height: 100%; +} \ No newline at end of file diff --git a/web/app/widget_wrappers/video/video.component.ts b/web/app/widget_wrappers/video/video.component.ts new file mode 100644 index 0000000..b26a990 --- /dev/null +++ b/web/app/widget_wrappers/video/video.component.ts @@ -0,0 +1,19 @@ +import { Component } from "@angular/core"; +import { ActivatedRoute } from "@angular/router"; +import { DomSanitizer, SafeUrl } from "@angular/platform-browser"; + +@Component({ + selector: "my-video-widget-wrapper", + templateUrl: "video.component.html", + styleUrls: ["video.component.scss"], +}) +export class VideoWidgetWrapperComponent { + + public embedUrl: SafeUrl = null; + + constructor(activatedRoute: ActivatedRoute, sanitizer: DomSanitizer) { + let params: any = activatedRoute.snapshot.queryParams; + this.embedUrl = sanitizer.bypassSecurityTrustResourceUrl(params.url); + } + +} diff --git a/web/public/img/avatars/youtube.png b/web/public/img/avatars/youtube.png new file mode 100644 index 0000000000000000000000000000000000000000..e763a5aa61bbdad02d03c135cf48b962518f2fb5 GIT binary patch literal 7200 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 =butt8I*~}q5p`%z;7psm zh^FSi48nNNM1Og)7JF&SrUF_2N6lCYOE@ zF=B`w0#RI8_~TN!%^>S0J`*=$;)B043O{|84;;{~gT1ffzQ;2CZj^T7)BZiikCbD& zenZl_Irt!&^8T|42vD{lYa-=$HUfd06{~{7`*)3u z9u&QLMp(J(Bco0qF1=%DNEm(~pxS;0F)1hfTI-Lmy{HV@FXStrXXsrLiPfYWb4)pA zVC-{RS?k}5jI_l&$~^QRYYob|lRP;Hah>@FxvBoVx9lGN!{?@pU9Ft9mXHvFKZ2*z znnk8mzuWql6i5n9iy$H+kaEx24RhvuuPFvc7_EiBO!N=fmgMf#)6IV9wCry{1bicP zovxQMze~C=fsvCx^UB}&bBp0 z7Q^?by&)RdYN!>It+5gryX;R!} zv<$@d4rnt)8oPks5pQH5=^~4f{GEtfr=7yxyDD%%=QlC|PHQZEbpn_0DV=N^QnJGJ z%>)YWovyWvM3_3P=wQ0YtFUj6IY8(-K>cUBheBURVSnFvW*qht>*53HBO^nIGck0< z>XRbx6+EceYuZ2Wh5*U4hZ7Hs58lv9+kO1T$+kUxzOohU6nf(Z{^FD#kd*UY>tpo2 z_4V`Th4UTQBWxxQa&@I~t+=5?97bD31HIp~oCy2mWJ24iteZ{Ra-6#xguEjU=AiSZ z3(JW&D3`9{SYRSnWt|13%fKH$49K50U($v+ETBMY>EM@nzX!Boqm?9Xd=wQu`M#>L zNvQ8u>Mss+QE50LBgEKvWoY+H*{y$TKp$`DM0u!9$90}UK^VL*UiXVq`x&^*VPSj@ z0T%%=(d%6ql3p+hGNUtz34?Aq%3hk2@x1Fog>V{Gk-rx)x`d5wpzm}yBvV%RyHhvi z&vpa4?^z`Om4yER-!bEu`0Ms)M)Y>@iA^iDg%a551iag4$ZIj9fX-tXXF$Y4AROCt zDk{nW02c?bsfNGW^PqffMIbQVstT^pomaL4?meDmoochvvLM?h?s##RMO-i5+8qvj zyVx9ii;opIj_YhzzG|5{?dYhxMHSo(+ylWJvmAAUN9Yn?F0YjFySJBoYJ(v$2UM(l zX;GQCbd@z-gsT0ktQ|_u9^4yxr{^$A+)UI-$9;7?ALQ1<=?B^DOBcnf90S^k#x0$` z=5@(4DQ&@*2ZtA*#r(Z$BA)1S3sfHLb&P#DG+930CC!)GnuO5_06hst-;4dsYm+M4 zEZhm_CO3PA*dIT}8>m7IZlOI>-UR-U9E~B1n^#7l9Q^(yB@Zb18~<4m%2DdKQdRhA zTH@B$+W72{RuJTO0~^=8$MYe3t6BJ+*>}=dZ!I9L(F2kpOBHZz#Tv$vDFF!}+(Qa< z*Xm)O!VQP!T0snsmzy8CK7h|x0}(ke+mzXMqd`R+;1lSJ;(PJ~PzkD0xk#(D;t#o+ zc7J@~158K+NL0JAfzUN634gT51fu1{TTs94WQOMv)cIQ7328)02Db`$M>0_Lg=PZv#j7GY6eE$Wl+WB@|+g(0Q4rc4De4+@Z6E{xVV;0C*o4Da+ZV!mHr z*eGe_Nu|bjuq%;Vtf}H#0#W+Z(;crhwEBGJQVgJOzp7Q%ffgMjM0J3qgcJ2%2aPL@ zdvdl2Me9zZ_C}8XIxz6H;I=Uh_TTe`6?FXzLFXToU}=i}y)zQJtp?9Wg5bfkDDIY% zJ-D?~qqoVrl*mCb6I3O#IfnVnp$PD#rhZ#Z$_o}qly;gDNEm65^yuM`ict-y{)0fT z1GR~iet&?r6X?T_>i2oQ{CT;N!1|b`*1mQ;^ci6(Czl#O+}r!A4$da4ZB>vZ#z5wYy;iYE;%&1#+&+l-v{WVdKET7 z&aS{Sp74SOCa4eeB;NHh_NyOIoN_&QWZcq1zW%+I6 zswF`5%K_|5$!2N56%@wa%7;9{4i6HGSu?JI0I;jec0^P2Amq!JB@xiiEvu}pIgN}^ pJ~AWK1rdLYjFyPxQEPG+s+0s@)mBT53~(