diff --git a/cspell.config.yml b/cspell.config.yml index 991142f97b..1c82afca46 100644 --- a/cspell.config.yml +++ b/cspell.config.yml @@ -37,6 +37,7 @@ "tmpl", "venv", "viewbox", + "youtube", ] # flagWords - list of words to be always considered incorrect # This is useful for offensive words and common spelling errors. diff --git a/packages/data-models/flowTypes.ts b/packages/data-models/flowTypes.ts index aba26f02f2..5832cb41d7 100644 --- a/packages/data-models/flowTypes.ts +++ b/packages/data-models/flowTypes.ts @@ -314,7 +314,8 @@ export namespace FlowTypes { | "toggle_bar" | "update_action_list" | "video" - | "workshops_accordion"; + | "workshops_accordion" + | "youtube"; export interface TemplateRow extends Row_with_translations { type: TemplateRowType; diff --git a/src/app/shared/components/template/components/index.ts b/src/app/shared/components/template/components/index.ts index 6ddf92c4b8..1c65e5495e 100644 --- a/src/app/shared/components/template/components/index.ts +++ b/src/app/shared/components/template/components/index.ts @@ -20,7 +20,6 @@ import { TemplateBaseComponent } from "./base"; import { TemplateDebuggerComponent } from "./debugger"; import { TemplateHTMLComponent } from "./html/html.component"; import { TemplatePopupComponent } from "./layout/popup/popup.component"; - import { TmplAccordionComponent } from "./accordion/accordion.component"; import { TmplAdvancedDashedBoxComponent } from "./layout/advanced-dashed-box/advanced-dashed-box.component"; import { TmplAnimatedSlidesComponent } from "./animated-slides/animated-slides.component"; @@ -54,14 +53,15 @@ import { TmplTaskProgressBarComponent } from "./task-progress-bar/task-progress- import { TmplTextAreaComponent } from "./text-area/text-area.component"; import { TmplTextBoxComponent } from "./text-box/text-box.component"; import { TmplTextComponent } from "./text/text.component"; +import { TmplTextBubbleComponent } from "./text-bubble/text-bubble.component"; import { TmplTileComponent } from "./tile-component/tile-component.component"; import { TmplTitleComponent } from "./title"; import { TmplTimerComponent } from "./timer/timer.component"; import { TmplToggleBarComponent } from "./toggle-bar/toggle-bar"; import { TmplVideoComponent } from "./video"; - import { WorkshopsComponent } from "./layout/workshops_accordion"; -import { TmplTextBubbleComponent } from "./text-bubble/text-bubble.component"; +import { YoutubeComponent } from "./youtube/youtube.component"; + import { DEMO_COMPONENT_MAPPING } from "packages/components/demo"; import { PLH_COMPONENT_MAPPING } from "packages/components/plh"; @@ -189,6 +189,7 @@ const CORE_COMPONENT_MAPPING: Record + + @if (params().allowFullScreen) { + + } @else { + + } + +} diff --git a/src/app/shared/components/template/components/youtube/youtube.component.scss b/src/app/shared/components/template/components/youtube/youtube.component.scss new file mode 100644 index 0000000000..ac820947a9 --- /dev/null +++ b/src/app/shared/components/template/components/youtube/youtube.component.scss @@ -0,0 +1,8 @@ +.youtube-container { + width: 100%; + aspect-ratio: 16 / 9; // see https://caniuse.com/mdn-css_properties_aspect-ratio + iframe { + width: 100%; + height: 100%; + } +} diff --git a/src/app/shared/components/template/components/youtube/youtube.component.spec.ts b/src/app/shared/components/template/components/youtube/youtube.component.spec.ts new file mode 100644 index 0000000000..c1b73443db --- /dev/null +++ b/src/app/shared/components/template/components/youtube/youtube.component.spec.ts @@ -0,0 +1,24 @@ +import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing"; +import { IonicModule } from "@ionic/angular"; + +import { YoutubeComponent } from "./youtube.component"; + +describe("YoutubeComponent", () => { + let component: YoutubeComponent; + let fixture: ComponentFixture; + + beforeEach(waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [YoutubeComponent], + imports: [IonicModule.forRoot()], + }).compileComponents(); + + fixture = TestBed.createComponent(YoutubeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + })); + + it("should create", () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/shared/components/template/components/youtube/youtube.component.ts b/src/app/shared/components/template/components/youtube/youtube.component.ts new file mode 100644 index 0000000000..fbc37c4331 --- /dev/null +++ b/src/app/shared/components/template/components/youtube/youtube.component.ts @@ -0,0 +1,112 @@ +import { Component, computed } from "@angular/core"; +import { TemplateBaseComponent } from "../base"; +import { DomSanitizer } from "@angular/platform-browser"; +import { getBooleanParamFromTemplateRow } from "src/app/shared/utils"; +import { TemplateTranslateService } from "../../services/template-translate.service"; + +interface ITemplateParams { + allow_fullscreen?: string; +} + +interface IYoutubeParams { + /** TEMPLATE PARAMETER: allow_fullscreen. Default true */ + allowFullScreen?: boolean; +} + +/** + * The names of the Youtube-specific query params that will be added to the url + * For a full list and explanation, see https://developers.google.com/youtube/player_parameters + * */ +const YOUTUBE_URL_QUERY_PARAMS: { [K in keyof YouTubeUrlQueryParamValues]: string } = { + videoId: "v", + color: "color", + showFullscreenButton: "fs", + interfaceLanguage: "hl", + showRelatedVideos: "rel", +}; + +/** Possible values of the supported query params */ +interface YouTubeUrlQueryParamValues { + videoId: string; + color: "red" | "white"; + showFullscreenButton: "0" | "1"; + interfaceLanguage: string; // 2-letter ISO 639-1 code + showRelatedVideos: "0" | "1"; +} + +@Component({ + selector: "youtube", + templateUrl: "./youtube.component.html", + styleUrls: ["./youtube.component.scss"], +}) +export class YoutubeComponent extends TemplateBaseComponent { + constructor( + private domSanitizer: DomSanitizer, + private templateTranslateService: TemplateTranslateService + ) { + super(); + } + + public params = computed(() => this.parseParams(this.parameterList())); + + public src = computed(() => { + const url = this.parseValue(this.value()); + if (url) { + const urlWithParams = this.setUrlParams(url, this.params()); + return this.domSanitizer.bypassSecurityTrustResourceUrl(urlWithParams.toString()); + } + return undefined; + }); + + private parseParams(parameterList: ITemplateParams): IYoutubeParams { + // NOTE - param parsing takes full row not just parameterList + // Still included as function arg to prompt re-evaluate if parameters change + return { allowFullScreen: getBooleanParamFromTemplateRow(this._row, "allow_fullscreen", true) }; + } + + /** Validate template value field and convert to URL object **/ + private parseValue(value: any) { + // Expect valid url. Don't specify domain as could start youtube.com, youtu.be, m.youtube.com, etc. + // https://stackoverflow.com/a/70512384/5693245 + if (value && typeof value === "string" && value.startsWith("https://")) { + const url = new URL(value); + // only support urls that include video id through parameter (e.g. not youtu.be/12345678901) + const videoId = url.searchParams.get(YOUTUBE_URL_QUERY_PARAMS.videoId); + if (videoId) { + url.searchParams.delete(YOUTUBE_URL_QUERY_PARAMS.videoId); + // rewrite host and pathname to use youtube embed version + url.host = "youtube.com"; + url.pathname = `/embed/${videoId}`; + return url; + } + } + console.error("[Youtube] Invalid value:", value); + } + + /** + * Update player parameters from authored + * See https://developers.google.com/youtube/player_parameters + * NOTE - these will be merged with any params passed with the url itself + */ + private setUrlParams(url: URL, params: IYoutubeParams) { + // Favour white over red for more theme compatibility + this.setYouTubeParam(url, "color", "white"); + // hide the fullscreen button if allow_fullscreen is false + this.setYouTubeParam(url, "showFullscreenButton", params.allowFullScreen ? "1" : "0"); + // Attempt to set the player's interface language to match the app language + const languageCode = this.templateTranslateService.app_language_code; + this.setYouTubeParam(url, "interfaceLanguage", languageCode); + // Disable related videos (at least those from external channels) + this.setYouTubeParam(url, "showRelatedVideos", "0"); + return url; + } + + private setYouTubeParam = ( + url: URL, + key: K, + value: YouTubeUrlQueryParamValues[K] + ) => { + const paramName = YOUTUBE_URL_QUERY_PARAMS[key]; + url.searchParams.set(paramName, value); + }; +} diff --git a/src/app/shared/components/template/services/template-translate.service.ts b/src/app/shared/components/template/services/template-translate.service.ts index 5c8d86092c..921f771470 100644 --- a/src/app/shared/components/template/services/template-translate.service.ts +++ b/src/app/shared/components/template/services/template-translate.service.ts @@ -52,6 +52,21 @@ export class TemplateTranslateService extends AsyncServiceBase { return this.app_language$.value; } + /** + * Extracts the two-letter language code from a given country language string. + * Two-letter ISO 639-1 Codes: https://www.loc.gov/standards/iso639-2/php/code_list.php + * @param {string} languageCode Country language code from in `xx_yy` format, where `yy` is the two-letter language code + * @returns {string} The extracted two-letter language code, e.g. `yy`, or the original country language code if the format is invalid + */ + get app_language_code() { + const parts = this.app_language.split("_"); + if (parts.length === 2 && parts[1].length === 2) { + return parts[1]; + } + // Return original language code if the format is invalid + return this.app_language; + } + /** Set the local storage variable that tracks the app language */ async setLanguage(code: string, updateDB = true) { if (code) {