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 0000000..e763a5a Binary files /dev/null and b/web/public/img/avatars/youtube.png differ