Skip to content

Commit c2182ef

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. - Group secondary features in dropdown component - Add fullscreen toggle - Sync volume between native video controls and custom controls - Add hotkeys for media controls task-6119464
1 parent db4c5e6 commit c2182ef

11 files changed

Lines changed: 409 additions & 325 deletions

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

Lines changed: 49 additions & 15 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";
@@ -46,10 +47,11 @@ export class CallDebrief extends Component {
4647
currentSegment: undefined,
4748
error: "",
4849
isPlaying: false,
50+
isFullscreen: false,
4951
playbackRate: 1,
50-
volume: 1,
52+
volume: this.env.isSmall ? 1 : 0.5,
5153
isMuted: false,
52-
feedback: { text: "", id: Date.now() },
54+
feedback: { icon: "", text: "", id: Date.now() },
5355
});
5456

5557
this.onMediaLoadedCallback = null;
@@ -72,8 +74,16 @@ export class CallDebrief extends Component {
7274
useHotkey("arrowleft", () => this.seekRelative(-5), { global: true, allowRepeat: true });
7375
useHotkey("arrowright", () => this.seekRelative(5), { global: true, allowRepeat: true });
7476
useHotkey("m", () => this.toggleMute(), { global: true });
77+
// Supports AZERTY keyboard layouts
78+
useHotkey("shift+.", () => this.adjustPlaybackRate(1), { global: true });
79+
useHotkey("shift+?", () => this.adjustPlaybackRate(-1), { global: true });
80+
// Supports QWERTY keyboard layouts
7581
useHotkey("shift+>", () => this.adjustPlaybackRate(1), { global: true });
7682
useHotkey("shift+<", () => this.adjustPlaybackRate(-1), { global: true });
83+
useHotkey("f", () => this.toggleFullscreen(), { global: true });
84+
useListener(document, "fullscreenchange", () => {
85+
this.state.isFullscreen = !!document.fullscreenElement;
86+
});
7787

7888
onWillUnmount(() => {
7989
clearTimeout(this.feedbackTimeout);
@@ -321,16 +331,16 @@ export class CallDebrief extends Component {
321331

322332
/**
323333
* Pauses the media element and optionally displays a feedback message.
324-
* @param {string|false} feedback - The text to display. Pass false to suppress feedback.
334+
* @param {string|boolean} feedback - Optional text to display alongside the pause icon. Pass false to suppress feedback.
325335
*/
326-
_pause(feedback = _t("Pause")) {
336+
_pause(feedback = true) {
327337
const mediaPlayer = this.mediaPlayer();
328338
if (mediaPlayer) {
329339
mediaPlayer.pause();
330340
}
331341
this.state.isPlaying = false;
332-
if (feedback) {
333-
this.showFeedback(feedback);
342+
if (feedback !== false) {
343+
this.showFeedback(typeof feedback === "string" ? feedback : undefined, "fa-pause");
334344
}
335345
}
336346

@@ -408,13 +418,14 @@ export class CallDebrief extends Component {
408418
this.showFeedback(`${newRate}x`);
409419
}
410420

411-
showFeedback(text) {
412-
this.state.feedback = { text, id: Date.now() };
421+
showFeedback(text, icon) {
422+
this.state.feedback = { text, icon, id: Date.now() };
413423
if (this.feedbackTimeout) {
414424
clearTimeout(this.feedbackTimeout);
415425
}
416426
this.feedbackTimeout = setTimeout(() => {
417-
this.state.feedback.text = "";
427+
this.state.feedback.text = undefined;
428+
this.state.feedback.icon = undefined;
418429
}, 750);
419430
}
420431

@@ -435,7 +446,7 @@ export class CallDebrief extends Component {
435446
this.showFeedback(_t("Playback Error"));
436447
});
437448
this.state.isPlaying = true;
438-
this.showFeedback(_t("Play"));
449+
this.showFeedback(undefined, "fa-play");
439450
}
440451
}
441452

@@ -445,12 +456,12 @@ export class CallDebrief extends Component {
445456
Math.min(this.callDurationSeconds, this.state.currentTime + delta)
446457
);
447458
this.setPlaybackTime({ timestamp: newTime });
448-
const direction = delta > 0 ? "+" : "-";
449-
this.showFeedback(`${direction} ${Math.abs(delta)}s`);
459+
const direction = delta > 0 ? "fa-forward" : "fa-backward";
460+
this.showFeedback(undefined, direction);
450461
}
451462

452-
setPlaybackRate(ev) {
453-
this.state.playbackRate = parseFloat(ev.target.value);
463+
setPlaybackRate(rate) {
464+
this.state.playbackRate = rate;
454465
}
455466

456467
adjustVolume(delta) {
@@ -464,12 +475,35 @@ export class CallDebrief extends Component {
464475
this.state.isMuted = this.state.volume === 0;
465476
}
466477

478+
onVolumeChange(ev) {
479+
this.state.volume = ev.target.volume;
480+
this.state.isMuted = ev.target.muted;
481+
}
482+
483+
onRateChange(ev) {
484+
this.state.playbackRate = ev.target.playbackRate;
485+
}
486+
467487
toggleMute() {
468488
this.state.isMuted = !this.state.isMuted;
469489
if (!this.state.isMuted && this.state.volume === 0) {
470490
this.state.volume = 0.5;
471491
}
472-
this.showFeedback(this.state.isMuted ? _t("Muted") : _t("Unmuted"));
492+
this.showFeedback(undefined, this.state.isMuted ? "fa-volume-off" : "fa-volume-up");
493+
}
494+
495+
toggleFullscreen() {
496+
const mediaPlayer = this.mediaPlayer();
497+
if (!mediaPlayer || !this.hasVideo) {
498+
return;
499+
}
500+
if (!document.fullscreenElement) {
501+
mediaPlayer.requestFullscreen().catch((e) => {
502+
console.warn("Fullscreen request failed:", e);
503+
});
504+
} else {
505+
document.exitFullscreen();
506+
}
473507
}
474508
}
475509

Lines changed: 61 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,73 @@
1-
$o-CallDebrief-content-max-width: 1000px;
2-
$o-CallDebrief-video-margin: 0.1em;
1+
@keyframes o-CallDebrief-feedback-pop {
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
18-
}
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+
.o-CallDebrief-media-container {
36+
container-type: inline-size;
37+
min-height: 0;
38+
}
39+
40+
.o-CallDebrief-mediaPlayer {
41+
container: callDebrief-mediaPlayer / inline-size;
42+
padding: var(--CallDebrief__mediaPlayer-padding, #{map-get($spacers, 3)});
43+
}
44+
45+
.o-CallDebrief-video {
46+
background-color: black;
47+
}
48+
49+
.o_feedback_indicator {
50+
.o_feedback_indicator_animate {
51+
animation: o-CallDebrief-feedback-pop 0.75s forwards ease-out;
52+
}
53+
}
1954

20-
.o-CallDebrief-media-container {
21-
display: flex;
22-
gap: map-get($spacers, 2);
23-
flex-grow: 1;
24-
min-height: 0;
55+
.o-CallDebrief-btn {
56+
@include media-breakpoint-up(md) {
57+
--#{$prefix}btn-bg: transparent;
58+
--#{$prefix}btn-border-color: transparent;
59+
--#{$prefix}btn-disabled-bg: transparent;
60+
--#{$prefix}btn-disabled-border-color: transparent;
61+
}
62+
}
2563
}
2664

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

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

Lines changed: 48 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,46 +6,28 @@
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 class="o-CallDebrief d-flex flex-column">
10+
<div class="o-CallDebrief-media-container d-flex flex-column" 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 w-100 border border-bottom-0 rounded-top overflow-hidden cursor-pointer">
12+
<video
3913
t-ref="this.mediaPlayer"
4014
t-att-src="this.state.currentSegment.mediaUrl"
41-
style="width: 100%; height: 100%; object-fit: contain;"
15+
class="w-100 h-auto object-fit-contain"
4216
t-on-loadeddata="this._onMediaLoaded"
4317
t-on-timeupdate="this.onTimeUpdate"
4418
t-on-play="() => this.state.isPlaying = true"
4519
t-on-pause="() => this.state.isPlaying = false"
4620
t-on-ended="this.onMediaEnded"
4721
t-on-error="this.onMediaError"
22+
t-on-volumechange="this.onVolumeChange"
23+
t-on-ratechange="this.onRateChange"
4824
/>
25+
<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">
26+
<span class="o_feedback_indicator_animate d-block rounded-pill text-bg-900">
27+
<i t-if="this.state.feedback.icon" class="fa fa-stack fs-1" t-att-class="this.state.feedback.icon" role="img"/>
28+
<span class="d-block py-2 px-3 fs-2" t-if="this.state.feedback.text" t-out="this.state.feedback.text"/>
29+
</span>
30+
</div>
4931
</div>
5032
<audio t-if="this.state.currentSegment and this.state.currentSegment.type === 'audio'"
5133
t-ref="this.mediaPlayer"
@@ -57,8 +39,43 @@
5739
t-on-pause="() => this.state.isPlaying = false"
5840
t-on-ended="this.onMediaEnded"
5941
t-on-error="this.onMediaError"
42+
t-on-volumechange="this.onVolumeChange"
43+
t-on-ratechange="this.onRateChange"
6044
/>
6145
</div>
46+
<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' }}">
47+
<t t-if="this.hasTimeline">
48+
<CallDebriefTimeline
49+
totalDuration="this.callDurationSeconds"
50+
mediaSegments="this.state.mediaSegments"
51+
onSeek="(options) => this.setPlaybackTime(options)"
52+
currentTime="this.state.currentTime"
53+
isFullscreen="this.state.isFullscreen"
54+
onToggleFullscreen="() => this.toggleFullscreen()"
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="(rate) => this.setPlaybackRate(rate)"
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>
6279
</div>
6380
</t>
6481
</t>

0 commit comments

Comments
 (0)