Skip to content

Commit 93bf374

Browse files
committed
[IMP] mail: fine-tune call debrief design
This commit redesigns the call debrief field to make it more user-friendly and intuitive, while also improving its usability on mobile devices. task-6119464
1 parent 6e3bf80 commit 93bf374

11 files changed

Lines changed: 448 additions & 324 deletions

addons/mail/static/src/views/fields/call_debrief/call_debrief.js

Lines changed: 66 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
proxy,
88
signal,
99
useEffect,
10+
useListener,
1011
} from "@odoo/owl";
1112
import { useHotkey } from "@web/core/hotkeys/hotkey_hook";
1213
import { CallDebriefTimeline } from "@mail/views/fields/call_debrief/call_debrief_timeline";
@@ -38,6 +39,7 @@ export class CallDebrief extends Component {
3839
this.isSwitchingSegment = false;
3940

4041
this.mediaPlayer = signal(null);
42+
this.rootElement = signal(null);
4143

4244
this.orm = useService("orm");
4345
this.state = proxy({
@@ -46,10 +48,11 @@ export class CallDebrief extends Component {
4648
currentSegment: undefined,
4749
error: "",
4850
isPlaying: false,
51+
isFullscreen: false,
4952
playbackRate: 1,
5053
volume: 1,
5154
isMuted: false,
52-
feedback: { text: "", id: Date.now() },
55+
feedback: { icon: "", text: "", id: Date.now() },
5356
});
5457

5558
this.onMediaLoadedCallback = null;
@@ -71,13 +74,21 @@ export class CallDebrief extends Component {
7174

7275
useHotkey("k", () => this.togglePlay(), { global: true });
7376
useHotkey("space", () => this.togglePlay(), { global: true });
74-
useHotkey("j", () => this.seekRelative(-5), { global: true, allowRepeat: true });
75-
useHotkey("l", () => this.seekRelative(5), { global: true, allowRepeat: true });
76-
useHotkey("arrowleft", () => this.seekRelative(-5), { global: true, allowRepeat: true });
77-
useHotkey("arrowright", () => this.seekRelative(5), { global: true, allowRepeat: true });
78-
useHotkey("m", () => this.toggleMute(), { global: true });
79-
useHotkey("shift+>", () => this.adjustPlaybackRate(1), { global: true });
80-
useHotkey("shift+<", () => this.adjustPlaybackRate(-1), { global: true });
77+
useHotkey("j", () => { this.seekRelative(-5), this.showMediaControlsFeedback("skip-backward"); }, { global: true, allowRepeat: true });
78+
useHotkey("l", () => { this.seekRelative(5), this.showMediaControlsFeedback("skip-forward"); }, { global: true, allowRepeat: true });
79+
useHotkey("arrowleft", () => { this.seekRelative(-5), this.showMediaControlsFeedback("skip-backward"); }, { global: true, allowRepeat: true });
80+
useHotkey("arrowright", () => { this.seekRelative(5), this.showMediaControlsFeedback("skip-forward"); }, { global: true, allowRepeat: true });
81+
useHotkey("m", () => { this.toggleMute(); this.showMediaControlsFeedback("mute"); }, { global: true });
82+
// Supports AZERTY keyboard layouts
83+
useHotkey("shift+.", () => { this.adjustPlaybackRate(1); this.showMediaControlsFeedback("playback-speed"); }, { global: true });
84+
useHotkey("shift+?", () => { this.adjustPlaybackRate(-1); this.showMediaControlsFeedback("playback-speed"); }, { global: true });
85+
// Supports QWERTY keyboard layouts
86+
useHotkey("shift+>", () => { this.adjustPlaybackRate(1); this.showMediaControlsFeedback("playback-speed"); }, { global: true });
87+
useHotkey("shift+<", () => { this.adjustPlaybackRate(-1); this.showMediaControlsFeedback("playback-speed"); }, { global: true });
88+
useHotkey("f", () => { this.toggleFullscreen(); this.showMediaControlsFeedback("fullscreen"); }, { global: true });
89+
useListener(document, "fullscreenchange", () => {
90+
this.state.isFullscreen = !!document.fullscreenElement;
91+
});
8192

8293
onWillUnmount(() => {
8394
clearTimeout(this.feedbackTimeout);
@@ -107,7 +118,7 @@ export class CallDebrief extends Component {
107118
}
108119

109120
onMediaError() {
110-
this.showFeedback(_t("Media Error"));
121+
this.showVideoFeedback(_t("Media Error"));
111122
console.warn("Media playback error. The format might not be supported by your browser.");
112123
}
113124

@@ -338,16 +349,16 @@ export class CallDebrief extends Component {
338349

339350
/**
340351
* Pauses the media element and optionally displays a feedback message.
341-
* @param {string|false} feedback - The text to display. Pass false to suppress feedback.
352+
* @param {string|boolean} feedback - Optional text to display alongside the pause icon. Pass false to suppress feedback.
342353
*/
343-
_pause(feedback = _t("Pause")) {
354+
_pause(feedback = true) {
344355
const mediaPlayer = this.mediaPlayer();
345356
if (mediaPlayer) {
346357
mediaPlayer.pause();
347358
}
348359
this.state.isPlaying = false;
349-
if (feedback) {
350-
this.showFeedback(feedback);
360+
if (feedback !== false) {
361+
this.showVideoFeedback(typeof feedback === "string" ? feedback : undefined, "fa-pause");
351362
}
352363
}
353364

@@ -432,37 +443,48 @@ export class CallDebrief extends Component {
432443

433444
const newRate = this.playbackRates[newIndex];
434445
this.state.playbackRate = newRate;
435-
this.showFeedback(`${newRate}x`);
446+
this.showVideoFeedback(`${newRate}x`);
436447
}
437448

438-
showFeedback(text) {
439-
this.state.feedback = { text, id: Date.now() };
449+
showVideoFeedback(text, icon) {
450+
this.state.feedback = { text, icon, id: Date.now() };
440451
if (this.feedbackTimeout) {
441452
clearTimeout(this.feedbackTimeout);
442453
}
443454
this.feedbackTimeout = setTimeout(() => {
444-
this.state.feedback.text = "";
455+
this.state.feedback = null;
445456
}, 750);
446457
}
447458

459+
showMediaControlsFeedback(action) {
460+
const el = this.rootElement()?.querySelector(`[data-control-feedback="${action}"]`);
461+
if (!el) {
462+
return;
463+
}
464+
el.classList.remove("o-CallDebrief-hotkeyFeedback");
465+
void el.offsetWidth; // Force reflow to restart animation
466+
el.classList.add("o-CallDebrief-hotkeyFeedback");
467+
el.addEventListener("animationend", () => el.classList.remove("o-CallDebrief-hotkeyFeedback"), { once: true });
468+
}
469+
448470
togglePlay() {
449471
const media = this.mediaPlayer();
450472
if (!media) {
451473
return;
452474
}
453475
if (this.state.currentTime >= this.callDurationSeconds - 0.5) {
454-
this.showFeedback(_t("End of Media"));
476+
this.showVideoFeedback(_t("End of Media"));
455477
return;
456478
}
457479
if (this.state.isPlaying) {
458480
this._pause();
459481
} else {
460482
media.play().catch((e) => {
461483
this.state.isPlaying = false;
462-
this.showFeedback(_t("Playback Error"));
484+
this.showVideoFeedback(_t("Playback Error"));
463485
});
464486
this.state.isPlaying = true;
465-
this.showFeedback(_t("Play"));
487+
this.showVideoFeedback(undefined, "fa-play");
466488
}
467489
}
468490

@@ -472,8 +494,8 @@ export class CallDebrief extends Component {
472494
Math.min(this.callDurationSeconds, this.state.currentTime + delta)
473495
);
474496
this.setPlaybackTime({ timestamp: newTime });
475-
const direction = delta > 0 ? "+" : "-";
476-
this.showFeedback(`${direction} ${Math.abs(delta)}s`);
497+
const direction = delta > 0 ? "fa-forward" : "fa-backward";
498+
this.showVideoFeedback(undefined, direction);
477499
}
478500

479501
setPlaybackRate(ev) {
@@ -491,12 +513,33 @@ export class CallDebrief extends Component {
491513
this.state.isMuted = this.state.volume === 0;
492514
}
493515

516+
onVolumeChange(ev) {
517+
this.state.volume = ev.target.volume;
518+
this.state.isMuted = ev.target.muted;
519+
}
520+
521+
onRateChange(ev) {
522+
this.state.playbackRate = ev.target.playbackRate;
523+
}
524+
494525
toggleMute() {
495526
this.state.isMuted = !this.state.isMuted;
496527
if (!this.state.isMuted && this.state.volume === 0) {
497528
this.state.volume = 0.5;
498529
}
499-
this.showFeedback(this.state.isMuted ? _t("Muted") : _t("Unmuted"));
530+
this.showVideoFeedback(undefined, this.state.isMuted ? "fa-volume-off" : "fa-volume-up");
531+
}
532+
533+
toggleFullscreen() {
534+
const rootEl = this.rootElement();
535+
if (!rootEl || !this.hasVideo) {
536+
return;
537+
}
538+
if (!document.fullscreenElement) {
539+
rootEl.requestFullscreen();
540+
} else {
541+
document.exitFullscreen();
542+
}
500543
}
501544
}
502545

Lines changed: 59 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,72 @@
1-
$o-CallDebrief-content-max-width: 1000px;
2-
$o-CallDebrief-video-margin: 0.1em;
1+
@keyframes o-CallDebrief-videoFeedbackPop {
2+
0% {
3+
opacity: 0;
4+
transform: scale(0.75);
5+
}
6+
15% {
7+
opacity: 1;
8+
transform: scale(1.1);
9+
}
10+
30% {
11+
transform: scale(1);
12+
}
13+
70% {
14+
opacity: 1;
15+
}
16+
100% {
17+
opacity: 0;
18+
}
19+
}
320

421
/*
5-
Form renderer wraps field widgets in a container `display: inline-block`,
22+
Form renderer wraps field widgets in a container `display: inline-block`,
623
which causes the container to shrink to the width of its content.
724
*/
825
.o_field_widget.o_field_call_debrief {
9-
width: 100%;
10-
display: block;
26+
--fieldWidget-display: block;
1127
}
1228

1329
.o-CallDebrief {
14-
display: flex;
15-
flex-direction: column;
16-
max-height: 45vh;
17-
contain: paint; // render performance
30+
max-height: var(--CallDebrief-max-height, 85vh);
31+
max-width: var(--CallDebrief-max-width, #{map-get($container-max-widths, xxl)});
32+
scrollbar-width: thin;
33+
scrollbar-color: currentColor transparent;
34+
35+
&:fullscreen {
36+
--CallDebrief__video-flex-shrink: 0;
37+
--CallDebrief__video-max-height: 66%;
1838
}
1939

20-
.o-CallDebrief-media-container {
21-
display: flex;
22-
gap: map-get($spacers, 2);
23-
flex-grow: 1;
24-
min-height: 0;
40+
.o-CallDebrief-media-container {
41+
container-type: inline-size;
42+
min-height: 0;
43+
}
44+
45+
.o-CallDebrief-mediaPlayer {
46+
container: callDebrief-mediaPlayer / inline-size;
47+
padding: var(--CallDebrief__mediaPlayer-padding, #{map-get($spacers, 3)});
48+
}
49+
50+
.o-CallDebrief-video {
51+
flex-shrink: var(--CallDebrief__video-flex-shrink, 1);
52+
min-height: 200px;
53+
max-height: var(--CallDebrief__video-max-height, auto);
54+
background-color: black;
55+
}
56+
57+
.o_feedback_indicator {
58+
.o_feedback_indicator_animate {
59+
animation: o-CallDebrief-videoFeedbackPop 0.75s forwards ease-out;
60+
}
61+
}
2562
}
2663

27-
.o-CallDebrief-video {
28-
flex: 1 1 100%;
29-
min-width: 0;
30-
margin: $o-CallDebrief-video-margin;
64+
.o_cell:has(.o-CallDebrief) {
65+
@include media-breakpoint-down(md) {
66+
.o-CallDebrief-mediaPlayer {
67+
--#{$prefix}border-width: 0;
68+
--#{$prefix}background-color: none;
69+
--CallDebrief__mediaPlayer-padding: #{map-get($spacers, 3)} 0 0;
70+
}
71+
}
3172
}

addons/mail/static/src/views/fields/call_debrief/call_debrief.xml

Lines changed: 46 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,47 +6,29 @@
66
</div>
77
</t>
88
<t t-else="">
9-
<div class="o-CallDebrief">
10-
<t t-if="this.hasTimeline">
11-
<CallDebriefTimeline
12-
totalDuration="this.callDurationSeconds"
13-
mediaSegments="this.state.mediaSegments"
14-
onSeek="(options) => this.setPlaybackTime(options)"
15-
currentTime="this.state.currentTime"
16-
/>
17-
</t>
18-
<t t-if="this.hasMedia">
19-
<CallDebriefMediaControls
20-
isPlaying="this.state.isPlaying"
21-
volume="this.state.volume"
22-
isMuted="this.state.isMuted"
23-
playbackRate="this.state.playbackRate"
24-
playbackRates="this.playbackRates"
25-
currentTime="this.state.currentTime"
26-
totalDuration="this.callDurationSeconds"
27-
media="this.state.currentSegment"
28-
feedback="this.state.feedback"
29-
onTogglePlay="() => this.togglePlay()"
30-
onSeek="(delta) => this.seekRelative(delta)"
31-
onSetPlaybackRate="(ev) => this.setPlaybackRate(ev)"
32-
onSetVolume="(ev) => this.setVolume(ev)"
33-
onToggleMute="() => this.toggleMute()"
34-
/>
35-
</t>
36-
<div class="o-CallDebrief-media-container" t-att-class="{ 'o-CallDebrief-media-container--no-video': !this.hasVideo }">
37-
<div t-if="this.hasVideo" class="o-CallDebrief-video">
38-
<video t-if="this.state.currentSegment and this.state.currentSegment.type === 'video'"
9+
<div t-ref="this.rootElement" class="o-CallDebrief d-flex flex-column bg-view">
10+
<div class="o-CallDebrief-media-container d-flex flex-column h-100" t-att-class="{ 'o-CallDebrief-media-container--no-video': !this.hasVideo }">
11+
<div t-if="this.hasVideo" t-on-click="() => this.togglePlay()" class="o-CallDebrief-video position-relative d-flex flex-grow-1 w-100 border border-bottom-0 rounded-top overflow-hidden cursor-pointer">
12+
<video
3913
t-key="this.state.currentSegment.id"
4014
t-ref="this.mediaPlayer"
4115
t-att-src="this.state.currentSegment.mediaUrl"
42-
style="width: 100%; height: 100%; object-fit: contain;"
16+
class="w-100 h-auto object-fit-contain"
4317
t-on-loadeddata="this._onMediaLoaded"
4418
t-on-timeupdate="this.onTimeUpdate"
4519
t-on-play="() => this.state.isPlaying = true"
4620
t-on-pause="() => this.state.isPlaying = false"
4721
t-on-ended="this.onMediaEnded"
4822
t-on-error="this.onMediaError"
23+
t-on-volumechange="this.onVolumeChange"
24+
t-on-ratechange="this.onRateChange"
4925
/>
26+
<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">
27+
<span class="o_feedback_indicator_animate d-block rounded-pill text-bg-900">
28+
<i t-if="this.state.feedback.icon" class="fa fa-stack fs-1" t-att-class="this.state.feedback.icon" role="img"/>
29+
<span class="d-block py-2 px-3 fs-2" t-if="this.state.feedback.text" t-out="this.state.feedback.text"/>
30+
</span>
31+
</div>
5032
</div>
5133
<audio t-if="this.state.currentSegment and this.state.currentSegment.type === 'audio'"
5234
t-key="this.state.currentSegment.id"
@@ -59,8 +41,41 @@
5941
t-on-pause="() => this.state.isPlaying = false"
6042
t-on-ended="this.onMediaEnded"
6143
t-on-error="this.onMediaError"
44+
t-on-volumechange="this.onVolumeChange"
45+
t-on-ratechange="this.onRateChange"
6246
/>
6347
</div>
48+
<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' }}">
49+
<t t-if="this.hasTimeline">
50+
<CallDebriefTimeline
51+
totalDuration="this.callDurationSeconds"
52+
mediaSegments="this.state.mediaSegments"
53+
onSeek="(options) => this.setPlaybackTime(options)"
54+
currentTime="this.state.currentTime"
55+
/>
56+
</t>
57+
<t t-if="this.hasMedia">
58+
<CallDebriefMediaControls
59+
isPlaying="this.state.isPlaying"
60+
volume="this.state.volume"
61+
isMuted="this.state.isMuted"
62+
playbackRate="this.state.playbackRate"
63+
playbackRates="this.playbackRates"
64+
currentTime="this.state.currentTime"
65+
totalDuration="this.callDurationSeconds"
66+
feedback="this.state.feedback"
67+
onTogglePlay="() => this.togglePlay()"
68+
onSeek="(delta) => this.seekRelative(delta)"
69+
onSetPlaybackRate="(ev) => this.setPlaybackRate(ev)"
70+
onSetVolume="(ev) => this.setVolume(ev)"
71+
onToggleMute="() => this.toggleMute()"
72+
mediaUrl="this.state.currentSegment?.mediaUrl"
73+
hasVideo="this.hasVideo"
74+
isFullscreen="this.state.isFullscreen"
75+
onToggleFullscreen="() => this.toggleFullscreen()"
76+
/>
77+
</t>
78+
</div>
6479
</div>
6580
</t>
6681
</t>

0 commit comments

Comments
 (0)