Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 62 additions & 37 deletions addons/mail/static/src/views/fields/call_debrief/call_debrief.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {
Component,
onWillStart,
onWillUpdateProps,
onWillUnmount,
props,
proxy,
signal,
useEffect,
useListener,
} from "@odoo/owl";
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
import { CallDebriefTimeline } from "@mail/views/fields/call_debrief/call_debrief_timeline";
Expand All @@ -18,17 +20,15 @@ import { _t } from "@web/core/l10n/translation";

export class CallDebrief extends Component {
static template = "mail.CallDebrief";
static props = {
static components = { CallDebriefTimeline, CallDebriefMediaControls };

props = props({
...standardFieldProps,
// The name of the field on the record that stores the call's start datetime.
callStartDateField: { type: String },
// The name of the field on the record that stores the call's end datetime.
callEndDateField: { type: String },
};

static components = { CallDebriefTimeline, CallDebriefMediaControls };

props = props();
});

setup() {
this.callDurationSeconds = 0;
Expand All @@ -45,44 +45,45 @@ export class CallDebrief extends Component {
currentSegment: undefined,
error: "",
isPlaying: false,
isFullscreen: false,
playbackRate: 1,
volume: 1,
volume: this.env.isSmall ? 1 : 0.5,
isMuted: false,
feedback: { text: "", id: Date.now() },
feedback: { icon: "", text: "", id: Date.now() },
});

this.onMediaLoadedCallback = null;
this.isUpdate = false;

onWillStart(() => this._loadData(this.props));

onWillUpdateProps(async (nextProps) => {
const hasIdChanged = this.props.record.resId !== nextProps.record.resId;
const hasFieldChanged =
this.props.record.data[this.props.name] !== nextProps.record.data[nextProps.name];
if (hasIdChanged || hasFieldChanged) {
await this._loadData(nextProps);
}
});

useHotkey("k", () => this.togglePlay(), { global: true });
useHotkey("space", () => this.togglePlay(), { global: true });
useHotkey("j", () => this.seekRelative(-5), { global: true, allowRepeat: true });
useHotkey("l", () => this.seekRelative(5), { global: true, allowRepeat: true });
useHotkey("arrowleft", () => this.seekRelative(-5), { global: true, allowRepeat: true });
useHotkey("arrowright", () => this.seekRelative(5), { global: true, allowRepeat: true });
useHotkey("m", () => this.toggleMute(), { global: true });
useHotkey("shift+>", () => this.adjustPlaybackRate(1), { global: true });
useHotkey("shift+<", () => this.adjustPlaybackRate(-1), { global: true });
// useHotkey("shift+.", () => this.adjustPlaybackRate(1), { global: true });
// useHotkey("shift+?", () => this.adjustPlaybackRate(-1), { global: true });
useHotkey("f", () => this.toggleFullscreen(), { global: true });
useListener(document, "fullscreenchange", () => {
this.state.isFullscreen = !!document.fullscreenElement;
});

onWillUnmount(() => {
clearTimeout(this.feedbackTimeout);
});

// Effect 1: Handle record changes (e.g. from the Pager)
useEffect(() => {
this.props.record.resId;
this.props.record.data[this.props.name];

if (this.isUpdate) {
this._loadData(this.props);
}
// skip the initial useLayoutEffect execution; onWillStart already does it
this.isUpdate = true;
});

// Effect 2: Synchronize hardware media settings with the UI state
// Effect for hardware media synchronization
useEffect(() => {
const media = this.mediaPlayer();
if (media) {
Expand Down Expand Up @@ -311,16 +312,16 @@ export class CallDebrief extends Component {

/**
* Pauses the media element and optionally displays a feedback message.
* @param {string|false} feedback - The text to display. Pass false to suppress feedback.
* @param {string|boolean} feedback - Optional text to display alongside the pause icon. Pass false to suppress feedback.
*/
_pause(feedback = _t("Pause")) {
_pause(feedback = true) {
const mediaPlayer = this.mediaPlayer();
if (mediaPlayer) {
mediaPlayer.pause();
}
this.state.isPlaying = false;
if (feedback) {
this.showFeedback(feedback);
if (feedback !== false) {
this.showFeedback(typeof feedback === "string" ? feedback : undefined, "fa-pause");
}
}

Expand Down Expand Up @@ -398,13 +399,14 @@ export class CallDebrief extends Component {
this.showFeedback(`${newRate}x`);
}

showFeedback(text) {
this.state.feedback = { text, id: Date.now() };
showFeedback(text, icon) {
this.state.feedback = { text, icon, id: Date.now() };
if (this.feedbackTimeout) {
clearTimeout(this.feedbackTimeout);
}
this.feedbackTimeout = setTimeout(() => {
this.state.feedback.text = "";
this.state.feedback.text = undefined;
this.state.feedback.icon = undefined;
}, 750);
}

Expand All @@ -425,7 +427,7 @@ export class CallDebrief extends Component {
this.showFeedback(_t("Playback Error"));
});
this.state.isPlaying = true;
this.showFeedback(_t("Play"));
this.showFeedback(undefined, "fa-play");
}
}

Expand All @@ -435,12 +437,12 @@ export class CallDebrief extends Component {
Math.min(this.callDurationSeconds, this.state.currentTime + delta)
);
this.setPlaybackTime({ timestamp: newTime });
const direction = delta > 0 ? "+" : "-";
this.showFeedback(`${direction} ${Math.abs(delta)}s`);
const direction = delta > 0 ? "fa-forward" : "fa-backward";
this.showFeedback(undefined, direction);
}

setPlaybackRate(ev) {
this.state.playbackRate = parseFloat(ev.target.value);
setPlaybackRate(rate) {
this.state.playbackRate = rate;
}

adjustVolume(delta) {
Expand All @@ -454,12 +456,35 @@ export class CallDebrief extends Component {
this.state.isMuted = this.state.volume === 0;
}

onVolumeChange(ev) {
this.state.volume = ev.target.volume;
this.state.isMuted = ev.target.muted;
}

onRateChange(ev) {
this.state.playbackRate = ev.target.playbackRate;
}

toggleMute() {
this.state.isMuted = !this.state.isMuted;
if (!this.state.isMuted && this.state.volume === 0) {
this.state.volume = 0.5;
}
this.showFeedback(this.state.isMuted ? _t("Muted") : _t("Unmuted"));
this.showFeedback(undefined, this.state.isMuted ? "fa-volume-off" : "fa-volume-up");
}

toggleFullscreen() {
const mediaPlayer = this.mediaPlayer();
if (!mediaPlayer || !this.hasVideo) {
return;
}
if (!document.fullscreenElement) {
mediaPlayer.requestFullscreen().catch((e) => {
console.warn("Fullscreen request failed:", e);
});
} else {
document.exitFullscreen();
}
}
}

Expand Down
80 changes: 61 additions & 19 deletions addons/mail/static/src/views/fields/call_debrief/call_debrief.scss
Original file line number Diff line number Diff line change
@@ -1,31 +1,73 @@
$o-CallDebrief-content-max-width: 1000px;
$o-CallDebrief-video-margin: 0.1em;
@keyframes o-CallDebrief-feedback-pop {
0% {
opacity: 0;
transform: scale(0.75);
}
15% {
opacity: 1;
transform: scale(1.1);
}
30% {
transform: scale(1);
}
70% {
opacity: 1;
}
100% {
opacity: 0;
}
}

/*
Form renderer wraps field widgets in a container `display: inline-block`,
Form renderer wraps field widgets in a container `display: inline-block`,
which causes the container to shrink to the width of its content.
*/
.o_field_widget.o_field_call_debrief {
width: 100%;
display: block;
--fieldWidget-display: block;
}

.o-CallDebrief {
display: flex;
flex-direction: column;
max-height: 45vh;
contain: paint; // render performance
}
max-height: var(--CallDebrief-max-height, 85vh);
max-width: var(--CallDebrief-max-width, #{map-get($container-max-widths, xxl)});
scrollbar-width: thin;
scrollbar-color: currentColor transparent;

.o-CallDebrief-media-container {
container-type: inline-size;
min-height: 0;
}

.o-CallDebrief-mediaPlayer {
container: callDebrief-mediaPlayer / inline-size;
padding: var(--CallDebrief__mediaPlayer-padding, #{map-get($spacers, 3)});
}

.o-CallDebrief-video {
background-color: black;
}

.o_feedback_indicator {
.o_feedback_indicator_animate {
animation: o-CallDebrief-feedback-pop 0.75s forwards ease-out;
}
}

.o-CallDebrief-media-container {
display: flex;
gap: map-get($spacers, 2);
flex-grow: 1;
min-height: 0;
.o-CallDebrief-btn {
@include media-breakpoint-up(md) {
--#{$prefix}btn-bg: transparent;
--#{$prefix}btn-border-color: transparent;
--#{$prefix}btn-disabled-bg: transparent;
--#{$prefix}btn-disabled-border-color: transparent;
}
}
}

.o-CallDebrief-video {
flex: 1 1 100%;
min-width: 0;
margin: $o-CallDebrief-video-margin;
.o_cell:has(.o-CallDebrief) {
@include media-breakpoint-down(md) {
.o-CallDebrief-mediaPlayer {
--#{$prefix}border-width: 0;
--#{$prefix}background-color: none;
--CallDebrief__mediaPlayer-padding: #{map-get($spacers, 3)} 0 0;
}
}
}
79 changes: 48 additions & 31 deletions addons/mail/static/src/views/fields/call_debrief/call_debrief.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,46 +6,28 @@
</div>
</t>
<t t-else="">
<div class="o-CallDebrief">
<t t-if="this.hasTimeline">
<CallDebriefTimeline
totalDuration="this.callDurationSeconds"
mediaSegments="this.state.mediaSegments"
onSeek="(options) => this.setPlaybackTime(options)"
currentTime="this.state.currentTime"
/>
</t>
<t t-if="this.hasMedia">
<CallDebriefMediaControls
isPlaying="this.state.isPlaying"
volume="this.state.volume"
isMuted="this.state.isMuted"
playbackRate="this.state.playbackRate"
playbackRates="this.playbackRates"
currentTime="this.state.currentTime"
totalDuration="this.callDurationSeconds"
media="this.state.currentSegment"
feedback="this.state.feedback"
onTogglePlay="() => this.togglePlay()"
onSeek="(delta) => this.seekRelative(delta)"
onSetPlaybackRate="(ev) => this.setPlaybackRate(ev)"
onSetVolume="(ev) => this.setVolume(ev)"
onToggleMute="() => this.toggleMute()"
/>
</t>
<div class="o-CallDebrief-media-container" t-att-class="{ 'o-CallDebrief-media-container--no-video': !this.hasVideo }">
<div t-if="this.hasVideo" class="o-CallDebrief-video">
<video t-if="this.state.currentSegment and this.state.currentSegment.type === 'video'"
<div class="o-CallDebrief d-flex flex-column">
<div class="o-CallDebrief-media-container d-flex flex-column" t-att-class="{ 'o-CallDebrief-media-container--no-video': !this.hasVideo }">
<div t-if="this.hasVideo" t-on-click="() => this.togglePlay()" class="o-CallDebrief-video position-relative d-flex w-100 border border-bottom-0 rounded-top overflow-hidden cursor-pointer">
<video
t-ref="this.mediaPlayer"
t-att-src="this.state.currentSegment.mediaUrl"
style="width: 100%; height: 100%; object-fit: contain;"
class="w-100 h-auto object-fit-contain"
t-on-loadeddata="this._onMediaLoaded"
t-on-timeupdate="this.onTimeUpdate"
t-on-play="() => this.state.isPlaying = true"
t-on-pause="() => this.state.isPlaying = false"
t-on-ended="this.onMediaEnded"
t-on-error="this.onMediaError"
t-on-volumechange="this.onVolumeChange"
t-on-ratechange="this.onRateChange"
/>
<div class="o_feedback_indicator position-absolute top-50 start-50 translate-middle text-center pe-none" t-if="this.state.feedback" t-key="this.state.feedback.id">
<span class="o_feedback_indicator_animate d-block rounded-pill text-bg-900">
<i t-if="this.state.feedback.icon" class="fa fa-stack fs-1" t-att-class="this.state.feedback.icon" role="img"/>
<span class="d-block py-2 px-3 fs-2" t-if="this.state.feedback.text" t-out="this.state.feedback.text"/>
</span>
</div>
</div>
<audio t-if="this.state.currentSegment and this.state.currentSegment.type === 'audio'"
t-ref="this.mediaPlayer"
Expand All @@ -57,8 +39,43 @@
t-on-pause="() => this.state.isPlaying = false"
t-on-ended="this.onMediaEnded"
t-on-error="this.onMediaError"
t-on-volumechange="this.onVolumeChange"
t-on-ratechange="this.onRateChange"
/>
</div>
<div t-if="this.hasTimeline or this.hasMedia" class="o-CallDebrief-mediaPlayer d-flex flex-column gap-2 border bg-100" t-attf-class="{{ this.hasVideo ? 'rounded-bottom' : 'rounded' }}">
<t t-if="this.hasTimeline">
<CallDebriefTimeline
totalDuration="this.callDurationSeconds"
mediaSegments="this.state.mediaSegments"
onSeek="(options) => this.setPlaybackTime(options)"
currentTime="this.state.currentTime"
isFullscreen="this.state.isFullscreen"
onToggleFullscreen="() => this.toggleFullscreen()"
/>
</t>
<t t-if="this.hasMedia">
<CallDebriefMediaControls
isPlaying="this.state.isPlaying"
volume="this.state.volume"
isMuted="this.state.isMuted"
playbackRate="this.state.playbackRate"
playbackRates="this.playbackRates"
currentTime="this.state.currentTime"
totalDuration="this.callDurationSeconds"
feedback="this.state.feedback"
onTogglePlay="() => this.togglePlay()"
onSeek="(delta) => this.seekRelative(delta)"
onSetPlaybackRate="(rate) => this.setPlaybackRate(rate)"
onSetVolume="(ev) => this.setVolume(ev)"
onToggleMute="() => this.toggleMute()"
mediaUrl="this.state.currentSegment?.mediaUrl"
hasVideo="this.hasVideo"
isFullscreen="this.state.isFullscreen"
onToggleFullscreen="() => this.toggleFullscreen()"
/>
</t>
</div>
</div>
</t>
</t>
Expand Down
Loading