- {{ !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;