diff --git a/src/app/shared/components/template/components/audio/audio.component.html b/src/app/shared/components/template/components/audio/audio.component.html index 4d7fc3cd4e..aa59abb069 100644 --- a/src/app/shared/components/template/components/audio/audio.component.html +++ b/src/app/shared/components/template/components/audio/audio.component.html @@ -1,65 +1,81 @@ -
-
-
-

{{ params.title }}

- +
+ @if (params.title) { +
+

{{ params.title }}

+ @if (params.variant.includes("large") && params.showInfoButton) { +
+ @if (params.infoIconAsset) { + + } @else { + + } +
+ }
-
-
- -
-
-
- {{ +currentTimeSong * 1000 | date: "mm:ss" }} -
-
- {{ !player ? "00:00" : (player.duration() * 1000 | date: "mm:ss") }} + } +
+ @if (params.variant.includes("compact") && params.showInfoButton) { +
+ @if (params.infoIconAsset) { + + } @else { + + } +
+ } +
+ +
+ @if (params.variant.includes("large")) { +
+ {{ progressSeconds() * 1000 | date: "mm:ss" }} +
+
+ {{ !player ? "00:00" : (player?.duration() * 1000 | date: "mm:ss") }} +
+ } @else { +
+ {{ + hasStarted + ? (progressSeconds() * 1000 | date: "mm:ss") + : (player?.duration() * 1000 | date: "mm:ss") + }} +
+ } +
-
-
-
- -
-
- +
+
+
+ + +
+ +
-
- -
-
-
- {{ errorTxt }}
diff --git a/src/app/shared/components/template/components/audio/audio.component.scss b/src/app/shared/components/template/components/audio/audio.component.scss index e71f0f8891..2308cb153a 100644 --- a/src/app/shared/components/template/components/audio/audio.component.scss +++ b/src/app/shared/components/template/components/audio/audio.component.scss @@ -2,86 +2,177 @@ $control-background: var(--audio-control-background, var(--ion-color-primary-500)); -.container-player { - background: var(--ion-color-primary-contrast); +.container-player[data-variant~="compact"] { + background: white; border: var(--ion-border-standard); box-sizing: border-box; box-shadow: var(--ion-default-box-shadow); border-radius: var(--ion-border-radius-standard); - padding: var(--regular-padding); + padding: var(--small-padding); display: flex; position: relative; flex-direction: column; .top-row { @include mixins.flex-space-between; - .title_and_help { + width: 100%; + display: flex; + min-height: var(--audio-title-and-help-height); + h3 { + margin: 0; + margin-right: var(--small-margin); + color: var(--ion-color-primary); + max-width: 85%; + font-weight: var(--font-weight-bold); + font-size: var(--audio-title-size); + } + } + + .second-row { + display: flex; + flex-direction: row-reverse; + + .controls { + @include mixins.flex-space-between; + align-self: center; + .rewind, + .forward { + display: none; + } + ion-button.btn-play { + @include mixins.medium-square; + margin-right: var(--small-margin); + --background: #{$control-background}; + --border-radius: var(--ion-border-radius-rounded); + --box-shadow: var(--ion-default-box-shadow); + ion-icon { + position: absolute; + font-size: var(--icon-size-tiny); + // Fine-tune spacing of 'play' icon so that triangle appears to be centred visually + &.play-icon { + padding-left: 4px; + } + } + } + } + + .progress-block { width: 100%; display: flex; - align-items: center; - justify-content: space-between; - min-height: var(--audio-title-and-help-height); - h3 { - margin: 0; - margin-right: var(--small-margin); - color: var(--ion-color-primary); - max-width: 85%; - font-weight: var(--font-weight-bold); - font-size: var(--audio-title-size); + margin-left: var(--small-margin); + ion-range { + width: 100%; + --bar-background: var(--ion-color-gray-200); + --bar-height: 4px; + --knob-size: 18px; + --knob-background: var(--ion-color-primary); + --bar-border-radius: 10px; } - .audio-help { + .time { + @include mixins.flex-space-between; + margin-left: var(--small-margin); + .time-value { + font-size: var(--font-size-text-small); + line-height: var(--line-height-text-small); + color: var(--ion-color-primary); + } + } + } + .info-icon-container { + @include mixins.flex-centered; + margin-left: var(--small-margin); + ion-icon { color: var(--ion-color-primary); - height: var(--help-icon-standard-size); - width: var(--help-icon-standard-size); + font-size: var(--icon-size-tiny); } } } - .progress-block { - .audio-range { - --bar-background-active: #{$control-background}; - --bar-background: transparent; - --bar-height: 4px; - --bar-border-radius: var(--ion-border-radius-secondary); - --knob-size: 0px; - --pin-background: var(--ion-color-primary); - --knob-background: var(--ion-color-primary); - padding-inline: 0; - } - - ion-range::part(bar) { - border: var(--ion-border-thin-standard); - height: var(--audio-bar-height); - } +} - ion-range::part(bar-active) { - top: 4px; - margin-left: 3px; - } - } - .time { +.container-player[data-variant~="large"] { + background: var(--ion-color-primary-contrast); + border: var(--ion-border-standard); + box-sizing: border-box; + box-shadow: var(--ion-default-box-shadow); + border-radius: var(--ion-border-radius-standard); + padding: var(--regular-padding); + display: flex; + position: relative; + flex-direction: column; + .top-row { @include mixins.flex-space-between; - &-value { - font-size: var(--font-size-text-large); - line-height: var(--line-height-text-small); + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + min-height: var(--audio-title-and-help-height); + h3 { + margin: 0; + margin-right: var(--small-margin); color: var(--ion-color-primary); + max-width: 85%; + font-weight: var(--font-weight-bold); + font-size: var(--audio-title-size); } - } - .controls { - @include mixins.flex-space-between; - min-width: var(--audio-controls-width); - align-self: center; - .rewind, - .forward { + .info-icon-container { + @include mixins.flex-centered; ion-icon { - font-size: var(--icon-size-largest); color: var(--ion-color-primary); + font-size: var(--icon-size-small); } } - .rewind { - ion-icon { - transform: rotate(180deg); + } + + .second-row { + display: flex; + flex-direction: column; + + .progress-block { + width: 100%; + .audio-range { + --bar-background-active: #{$control-background}; + --bar-background: transparent; + --bar-height: 4px; + --bar-border-radius: var(--ion-border-radius-secondary); + --knob-size: 0px; + --pin-background: var(--ion-color-primary); + --knob-background: var(--ion-color-primary); + padding-inline: 0; + } + + ion-range::part(bar) { + border: var(--ion-border-thin-standard); + height: var(--audio-bar-height); + } + + ion-range::part(bar-active) { + top: 4px; + margin-left: 3px; + } + .time { + @include mixins.flex-space-between; + &-value { + font-size: var(--font-size-text-large); + line-height: var(--line-height-text-small); + color: var(--ion-color-primary); + } } } - .play { + .controls { + @include mixins.flex-space-between; + width: var(--audio-controls-width); + align-self: center; + .rewind, + .forward { + ion-icon { + font-size: var(--icon-size-largest); + color: var(--ion-color-primary); + } + } + .rewind { + ion-icon { + transform: rotate(180deg); + } + } .btn-play { @include mixins.large-square; --background: #{$control-background}; @@ -99,10 +190,6 @@ $control-background: var(--audio-control-background, var(--ion-color-primary-500 } } } - .error-text { - margin: var(--small-margin) 0; - text-align: center; - } } .disabled { pointer-events: none; diff --git a/src/app/shared/components/template/components/audio/audio.component.ts b/src/app/shared/components/template/components/audio/audio.component.ts index 8fe948fe2e..d536ac66e2 100644 --- a/src/app/shared/components/template/components/audio/audio.component.ts +++ b/src/app/shared/components/template/components/audio/audio.component.ts @@ -1,4 +1,4 @@ -import { Component, Input, OnDestroy, OnInit, signal, ViewChild } from "@angular/core"; +import { Component, computed, Input, OnDestroy, OnInit, signal } from "@angular/core"; import { FlowTypes } from "../../../../model"; import { getBooleanParamFromTemplateRow, @@ -6,7 +6,6 @@ import { getStringParamFromTemplateRow, } from "../../../../utils"; import { Howl } from "howler"; -import { IonRange } from "@ionic/angular"; import { ITemplateRowProps } from "../../models"; import { TemplateBaseComponent } from "../base"; import { PLHAssetPipe } from "../../pipes/plh-asset.pipe"; @@ -18,6 +17,8 @@ const PAUSE_ICON_DEFAULT = "pause-outline"; const FORWARD_ICON_DEFAULT = "play-forward"; interface IAudioParams { + /** TEMPLATE PARAMETER: "variant". Default "large". */ + variant: "large" | "compact"; /** TEMPLATE PARAMETER: "src". Will be overridden by a value passed as "value" to the component. */ src: string; /** TEMPLATE PARAMETER: "title" */ @@ -30,19 +31,18 @@ interface IAudioParams { * Will be mirrored to be used as the reqind icon. Default icon is ion's "play-forward" * */ forwardIconAsset: string; - /** TEMPLATE PARAMETER: "help_icon_asset". The path to an svg to override the default help icon. */ - helpIconAsset: string; - /** TEMPLATE PARAMETER: "help_text". Text to be displayed as a tooltip when clicking the "help" icon. - * Icon and tooltip will not be displayed if value not provided. Default null */ - helpText: string; + /** TEMPLATE PARAMETER: "show_info_button". Should show the info button. Default false (unless info_icon_asset is provided) */ + showInfoButton: boolean; + /** TEMPLATE PARAMETER: "info_icon_asset". The path to an svg to override the default info icon. The default is an icon indicating a transcript */ + infoIconAsset: string; /** TEMPLATE PARAMETER: "range_bar_disabled". If true, the use cannot scrub through the audio using the range bar. * Default false. */ rangeBarDisabled: boolean; - /** TEMPLATE PARAMETER: "time_to_rewind". The increment of time, in seconds, that will be applied when clicking the forward or backward buttons. + /** TEMPLATE PARAMETER: "time_to_skip". The increment of time, in seconds, that will be applied when clicking the forward or backward buttons. * Default 15. */ - timeToRewind: number; + timeToSkip: number; } @Component({ @@ -55,24 +55,33 @@ export class TmplAudioComponent implements ITemplateRowProps, OnInit, OnDestroy { @Input() template: FlowTypes.Template; - @ViewChild("range", { static: false }) range: IonRange; params: Partial = {}; /** @ignore */ player: Howl = null; - /** @ignore */ - isPlayed: boolean = false; - /** @ignore */ - errorTxt: string | null; - /** @ignore */ + /** + * Track the playing state of the player UI. Decoupled from whether the actual audio is playing (this.player.playing()) + * so that manually seeking works as expected: if player is playing before dragging the slider, playing continues after dragging + * @ignore + * */ + isPlaying: boolean = false; + /** + * Progress, as a percentage of total duration + * @ignore + * */ progress = signal(0); - /** @ignore */ - rangeBarTouched: boolean = false; - /** @ignore */ - currentTimeSong: string = "0"; + /** + * Progress in seconds + * @ignore + * */ + progressSeconds = computed(() => { + return (this.progress() / 100) * this.player?.duration(); + }); /** @ignore */ hasStarted: boolean = false; + /** @ignore */ + trackerInterval: NodeJS.Timeout; constructor(private plhAssetPipe: PLHAssetPipe) { super(); @@ -83,7 +92,10 @@ export class TmplAudioComponent this.initPlayer(); } - getParams() { + private getParams() { + this.params.variant = getStringParamFromTemplateRow(this._row, "variant", "compact") + .split(",") + .join(" ") as IAudioParams["variant"]; this.params.src = this.plhAssetPipe.transform( this._row.value || getStringParamFromTemplateRow(this._row, "src", null) ); @@ -100,107 +112,126 @@ export class TmplAudioComponent FORWARD_ICON_DEFAULT ); this.params.title = getStringParamFromTemplateRow(this._row, "title", ""); - this.params.helpText = getStringParamFromTemplateRow(this._row, "help_text", null); - this.params.helpIconAsset = getStringParamFromTemplateRow(this._row, "help_icon_asset", null); + this.params.infoIconAsset = getStringParamFromTemplateRow(this._row, "info_icon_asset", null); + this.params.showInfoButton = + !!this.params.infoIconAsset || + getBooleanParamFromTemplateRow(this._row, "show_info_button", false); this.params.rangeBarDisabled = getBooleanParamFromTemplateRow( this._row, "range_bar_disabled", false ); - this.params.timeToRewind = getNumberParamFromTemplateRow(this._row, "time_to_rewind", 15); + this.params.timeToSkip = getNumberParamFromTemplateRow(this._row, "time_to_skip", 15); } - getAssetParamFromTemplateRow(parameterName: string, _default: string | null) { + private getAssetParamFromTemplateRow(parameterName: string, _default: string | null) { const value = getStringParamFromTemplateRow(this._row, parameterName, null); return value ? this.plhAssetPipe.transform(value) : _default; } - initPlayer() { - return this.params.src - ? (this.player = new Howl({ - src: [this.params.src], - onplay: () => { - this.isPlayed = true; - this.updateProgress(); - if (!this.hasStarted) { - this.hasStarted = true; - this.triggerActions("audio_first_start"); - } - this.triggerActions("audio_play"); - }, - onend: () => { - this.isPlayed = false; - this.range.value = 0; - this.currentTimeSong = "0"; - this.updateProgress(); - this.triggerActions("audio_end"); - }, - onpause: () => { - this.isPlayed = false; - this.updateProgress(); - this.triggerActions("audio_pause"); - }, - })) - : (this.errorTxt = "Src is undefined, player not initialized"); + private initPlayer() { + if (this.params.src) { + this.player = new Howl({ + src: [this.params.src], + onplay: () => { + this.startProgressTracker(); + if (!this.hasStarted) { + this.hasStarted = true; + this.triggerActions("audio_first_start"); + } + this.triggerActions("audio_play"); + }, + onend: () => { + this.isPlaying = false; + this.progress.set(0); + this.startProgressTracker(); + this.triggerActions("audio_end"); + }, + onpause: () => { + this.startProgressTracker(); + this.triggerActions("audio_pause"); + }, + }); + } else { + console.error( + "[AUDIO COMPONENT] No audio source provided (path to audio asset should be passed as value or 'src' param)" + ); + } } - togglePlayer() { - this.isPlayed = !this.isPlayed; - return this.isPlayed ? this.player.play() : this.player.pause(); + public togglePlaying() { + if (this.isPlaying) { + this.player.pause(); + } else { + this.player.play(); + } + this.isPlaying = !this.isPlaying; } - rewindNext() { - this.player.seek((this.player.seek() as any) + this.params.timeToRewind); - this.customUpdateWhenRewind(); + public async clickInfo() { + await this.triggerActions("info_click"); } - rewindPrev() { - this.player.seek( - (this.player.seek() as any) < this.params.timeToRewind - ? (this.player.seek(0) as any) - : (this.player.seek() as any) - this.params.timeToRewind - ); - this.customUpdateWhenRewind(); + /** + * Handle dragging of the range bar. Does not seek within the actual audio file, + * this should only be triggered when the handle is released (on ionChange) + */ + public onProgressDrag(event) { + this.progress.set(event.detail.value); } - seek() { - let newValue = +this.range.value; - let duration = this.player.duration(); - this.player.seek(duration * (newValue / 100)); - this.rangeBarTouched = false; - this.updateProgress(); + /** + * Jump to a specific time in the audio (i.e. "seek") + * @param targetTime in milliseconds, default is current progress + */ + public seekToTime(targetTime: number = this.progressSeconds()) { + // Ensure targetTime does not go below 0 + if (targetTime < 0) { + targetTime = 0; + } + this.player.seek(targetTime); + this.progress.set((targetTime / this.player.duration()) * 100 || 0); } - updateProgress() { - const ref = setInterval(() => { - if (!this.isPlayed || this.rangeBarTouched) { - clearInterval(ref); - return; - } - let seek: any = this.player.seek(); - this.progress.set((seek / this.player.duration()) * 100 || 0); - this.currentTimeSong = this.player.seek() ? (this.player.seek() as any).toString() : "0"; - }, 1000); + public skipForward() { + this.skip(this.params.timeToSkip); } - checkFocus() { - this.rangeBarTouched = true; + public skipBackward() { + this.skip(-this.params.timeToSkip); } - checkChange() { - if (this.rangeBarTouched) { - let newValue = +this.range.value; - let duration = this.player.duration(); - this.player.seek(duration * (newValue / 100)); - this.currentTimeSong = this.player.seek() ? (this.player.seek() as any).toString() : "0"; - } + /** + * Skip forward or backward by a specified interval + * @param timeToSkip in seconds + */ + public skip(timeToSkip: number) { + const currentTime = this.player.seek(); + const targetTime = currentTime + timeToSkip; + this.seekToTime(targetTime); + } + + /** + * Begins tracking audio playback progress, updating this.progress() with the current playback percentage + */ + private startProgressTracker() { + const duration = this.player.duration(); + // Caculate interval (in milliseconds) as 1/200th of the audio duration (in seconds) + const interval = duration * 5; + this.stopProgressTracker(); // Ensure any existing tracker is stopped + this.trackerInterval = setInterval(() => { + if (!this.player.playing()) { + this.stopProgressTracker(); + return; + } + this.progress.set((this.player.seek() / duration) * 100 || 0); + }, interval); } - customUpdateWhenRewind() { - if (!this.isPlayed) { - let seek: any = this.player.seek(); - this.progress.set((seek / this.player.duration()) * 100 || 0); - this.currentTimeSong = this.player.seek() ? (this.player.seek() as any).toString() : "0"; + private stopProgressTracker() { + if (this.trackerInterval) { + clearInterval(this.trackerInterval); + this.trackerInterval = null; } } diff --git a/src/app/shared/components/template/pipes/plh-asset.pipe.ts b/src/app/shared/components/template/pipes/plh-asset.pipe.ts index 587b779106..084d5b7a29 100644 --- a/src/app/shared/components/template/pipes/plh-asset.pipe.ts +++ b/src/app/shared/components/template/pipes/plh-asset.pipe.ts @@ -11,6 +11,7 @@ export class PLHAssetPipe implements PipeTransform { constructor(private templateAssetService: TemplateAssetService) {} transform(value: string) { + if (!value) return ""; // keep external links if (value.startsWith("http")) { return value; diff --git a/src/theme/deployment/_overrides.scss b/src/theme/deployment/_overrides.scss index 9ec717d482..cf08baa647 100644 --- a/src/theme/deployment/_overrides.scss +++ b/src/theme/deployment/_overrides.scss @@ -9,6 +9,12 @@ ion-content { --padding-bottom: 24px; --padding-start: 24px; --padding-end: 24px; + + // HACK: prevent scrollbar from hiding when interacting with ion-range, for example (this can cause visual glitches). + // see https://github.com/ionic-team/ionic-framework/issues/25595#issuecomment-1330293954 + &::part(scroll) { + overflow-y: auto !important; + } } ion-content.no-padding { --padding-top: 0;