From 83d43fc6679ebe3226892f32260bce7b1f424910 Mon Sep 17 00:00:00 2001 From: "Andrzej(pian)" Date: Fri, 29 May 2026 12:36:51 +0200 Subject: [PATCH 1/4] [FIX] fixup props again --- .../src/views/fields/call_debrief/call_debrief.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief.js b/addons/mail/static/src/views/fields/call_debrief/call_debrief.js index e0689fab0e43f2..cf98e409dbe302 100644 --- a/addons/mail/static/src/views/fields/call_debrief/call_debrief.js +++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief.js @@ -18,17 +18,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; From daab02ad8a80d9a9dde9b9f0bfcbadecf68203a8 Mon Sep 17 00:00:00 2001 From: "Andrzej(pian)" Date: Fri, 29 May 2026 12:56:14 +0200 Subject: [PATCH 2/4] [FIX] fixup: back to onWillPropsUpdate; OWL doesn't pass no Signal to widgets :( --- .../views/fields/call_debrief/call_debrief.js | 25 ++++++++----------- 1 file changed, 11 insertions(+), 14 deletions(-) diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief.js b/addons/mail/static/src/views/fields/call_debrief/call_debrief.js index cf98e409dbe302..29ceef69e44581 100644 --- a/addons/mail/static/src/views/fields/call_debrief/call_debrief.js +++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief.js @@ -1,6 +1,7 @@ import { Component, onWillStart, + onWillUpdateProps, onWillUnmount, props, proxy, @@ -50,10 +51,18 @@ export class CallDebrief extends Component { }); 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 }); @@ -68,19 +77,7 @@ export class CallDebrief extends Component { 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) { From e6895a22da4e7691cca24a042cd3153109caa943 Mon Sep 17 00:00:00 2001 From: Brieuc-brd Date: Wed, 6 May 2026 08:50:33 +0200 Subject: [PATCH 3/4] [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 --- .../views/fields/call_debrief/call_debrief.js | 61 ++++++--- .../fields/call_debrief/call_debrief.scss | 80 ++++++++--- .../fields/call_debrief/call_debrief.xml | 77 ++++++----- .../call_debrief_media_controls.js | 32 +++-- .../call_debrief_media_controls.scss | 124 +++++------------- .../call_debrief_media_controls.xml | 94 ++++++------- .../call_debrief/call_debrief_timeline.js | 94 +++++++++---- .../call_debrief/call_debrief_timeline.scss | 109 +++++++-------- .../call_debrief/call_debrief_timeline.xml | 28 ++-- .../fields/call_debrief/call_debrief_utils.js | 20 +++ .../tests/web/fields/call_debrief.test.js | 6 +- 11 files changed, 403 insertions(+), 322 deletions(-) create mode 100644 addons/mail/static/src/views/fields/call_debrief/call_debrief_utils.js diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief.js b/addons/mail/static/src/views/fields/call_debrief/call_debrief.js index 29ceef69e44581..82a2392146d2b2 100644 --- a/addons/mail/static/src/views/fields/call_debrief/call_debrief.js +++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief.js @@ -7,6 +7,7 @@ import { 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"; @@ -31,7 +32,7 @@ export class CallDebrief extends Component { setup() { this.callDurationSeconds = 0; - this.playbackRates = [0.25, 0.5, 0.75, 0.9, 1, 1.25, 1.5, 1.75, 2, 3]; + this.playbackRates = [0.25, 0.50, 0.75, 0.90, 1.00, 1.25, 1.50, 1.75, 2.00, 3.00]; this.skipNextTimeUpdate = false; this.isSwitchingSegment = false; @@ -44,10 +45,11 @@ 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; @@ -72,6 +74,9 @@ export class CallDebrief extends Component { useHotkey("m", () => this.toggleMute(), { global: true }); useHotkey("shift+>", () => this.adjustPlaybackRate(1), { global: true }); useHotkey("shift+<", () => this.adjustPlaybackRate(-1), { global: true }); + useListener(document, "fullscreenchange", () => { + this.state.isFullscreen = !!document.fullscreenElement; + }); onWillUnmount(() => { clearTimeout(this.feedbackTimeout); @@ -306,16 +311,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"); } } @@ -393,13 +398,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); } @@ -420,7 +426,7 @@ export class CallDebrief extends Component { this.showFeedback(_t("Playback Error")); }); this.state.isPlaying = true; - this.showFeedback(_t("Play")); + this.showFeedback(undefined, "fa-play"); } } @@ -430,12 +436,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) { @@ -449,12 +455,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) { + return; + } + if (!document.fullscreenElement) { + mediaPlayer.requestFullscreen().catch((e) => { + console.warn("Fullscreen request failed:", e); + }); + } else { + document.exitFullscreen(); + } } } diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief.scss b/addons/mail/static/src/views/fields/call_debrief/call_debrief.scss index 1481f8eced6e10..47b86d6e457b6f 100644 --- a/addons/mail/static/src/views/fields/call_debrief/call_debrief.scss +++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief.scss @@ -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, 75vh); + max-width: var(--CallDebrief-max-width, #{map-get($container-max-widths, lg)}); + 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; + } + } } diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief.xml b/addons/mail/static/src/views/fields/call_debrief/call_debrief.xml index b78d404fae80fc..74a7164acab5f2 100644 --- a/addons/mail/static/src/views/fields/call_debrief/call_debrief.xml +++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief.xml @@ -6,46 +6,28 @@ -
- - - - - - -
-
-
diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.js b/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.js index 95dad38d596e95..6c2066e8b63c0c 100644 --- a/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.js +++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.js @@ -1,7 +1,11 @@ import { Component, props, signal } from "@odoo/owl"; +import { Dropdown } from "@web/core/dropdown/dropdown"; +import { DropdownItem } from "@web/core/dropdown/dropdown_item"; +import { formatDuration } from "@mail/views/fields/call_debrief/call_debrief_utils"; export class CallDebriefMediaControls extends Component { static template = "mail.CallDebriefMediaControls"; + static components = { Dropdown, DropdownItem }; static props = { isPlaying: { type: Boolean }, volume: { type: Number }, @@ -10,7 +14,7 @@ export class CallDebriefMediaControls extends Component { playbackRates: { type: Array }, currentTime: { type: Number }, totalDuration: { type: Number }, - media: { type: Object, optional: true }, + mediaUrl: { type: String, optional: true }, onTogglePlay: { type: Function }, onSeek: { type: Function }, onSetPlaybackRate: { type: Function }, @@ -21,25 +25,25 @@ export class CallDebriefMediaControls extends Component { props = props(); - setup() { - this.isVolumeSliderVisible = signal(false); + formatDuration(seconds) { + return formatDuration(seconds, this.props.totalDuration); + } + + downloadMedia() { + window.open(this.props.mediaUrl + "?download=1", "_blank"); + } + + get formattedTotalDuration() { + return this.formatDuration(this.props.totalDuration); } get volumeIconClass() { if (this.props.isMuted || this.props.volume === 0) { - return "fa fa-volume-off"; + return "fa-volume-off"; } if (this.props.volume < 0.5) { - return "fa fa-volume-down"; + return "fa-volume-down"; } - return "fa fa-volume-up"; - } - - showVolumeSlider() { - this.isVolumeSliderVisible.set(true); - } - - hideVolumeSlider() { - this.isVolumeSliderVisible.set(false); + return "fa-volume-up"; } } diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.scss b/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.scss index b87c00d24cfc74..f417c7fea80a25 100644 --- a/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.scss +++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.scss @@ -1,113 +1,53 @@ -.o-CallDebrief-media-controls { - position: relative; // positioned ancestor for feedback_indicator - - .o_media_controls_spacer { - flex: 1 1 0; +.o-CallDebriefMediaControls { + @container callDebrief-mediaPlayer (max-width: 370px) { + --CallDebriefMediaControls__timeLabelClassic-display: none; + --CallDebriefMediaControls__timeLabelCondensed-display: block; } - .o_volume_button { - width: 2rem; + @container callDebrief-mediaPlayer (max-width: 280px) { + --CallDebriefMediaControls__timeLabelClassic-display: none; + --CallDebriefMediaControls__timeLabelCondensed-display: none; } - .o_volume_slider_container { - left: 100%; - top: 50%; - transform: translateY(-50%); - z-index: 10; - background-color: var(--o-view-background-color); - padding: 0.25rem; - border-radius: $border-radius-sm; - box-shadow: var(--o-shadow-lg); - - .form-range { - width: 6.25rem; - } + .o_timeLabel_classic { + display: var(--CallDebriefMediaControls__timeLabelClassic-display, block); } - .btn.o-CallDebrief-btn { - --btn-line-height: 1; - --btn-font-size: 0.75rem; - height: 1.125rem; - display: inline-flex; - align-items: center; - justify-content: center; + .o_timeLabel_condensed { + display: var(--CallDebriefMediaControls__timeLabelCondensed-display, none); } - .o-CallDebrief-settings-inline { - height: 1.6rem; // Match the thickness you liked earlier - font-size: 0.75rem; - background-color: var(--o-view-background-color) !important; // override bootstrap bg-secondary - border: 1px solid var(--border-color); + .o-CallDebriefMediaControls-timeLabel, .o-CallDebriefMediaControls-playbackRate { + font-variant-numeric: tabular-nums; + user-select: none; + } - .o_speed_selector_group { - .form-label { - font-size: 0.65rem; - text-transform: uppercase; - font-weight: bold; - color: var(--text-muted); - } + .o-CallDebriefMediaControls-playbackRate { + --#{$prefix}box-shadow-inset: none; - .form-select { - width: auto; - height: 1.25rem; - padding-top: 0; - padding-bottom: 0; - padding-right: 1.5rem; // Room for chevron - font-size: 0.75rem; - border: none; - background-color: transparent; - cursor: pointer; + @include media-breakpoint-up(md) { + --#{$prefix}bg-opacity: 0; + } - &:focus { - box-shadow: none; - } - } + &:hover, &:focus { + --#{$prefix}bg-opacity: 1; } - .o-CallDebrief-download-separator { - border-left: 1px solid var(--border-color); - padding-left: map-get($spacers, 2); - display: flex; - align-items: center; - height: 1rem; // Visual separator height + &:focus-visible { + box-shadow: $focus-ring-box-shadow; } } - .o_feedback_indicator { - position: absolute; - right: 50%; - margin-right: 8.75rem; /* Position to the left of the centered buttons (~200px wide) */ - top: 50%; - transform: translateY(-50%); - pointer-events: none; - font-weight: bold; - color: var(--text-muted); - z-index: 5; - white-space: nowrap; - - .o_animate_fade { - display: inline-block; - animation: o-CallDebrief-feedback-pop 0.75s forwards ease-out; + .o-CallDebriefMediaControls-volumeWrapper { + &:hover .o-CallDebriefMediaControls-volumeRange { + --CallDebriefMediaControls__volumeRange-display: block; } } -} -@keyframes o-CallDebrief-feedback-pop { - 0% { - opacity: 0; - transform: scale(0.5); - } - 15% { - opacity: 1; - transform: scale(1.1); - } - 30% { - transform: scale(1); - } - 70% { - opacity: 1; - } - 100% { - opacity: 0; + .o-CallDebriefMediaControls-volumeRange { + display: var(--CallDebriefMediaControls__volumeRange-display, none); + width: map-get($spacers, 5) * 2; + transform: rotate(-90deg); + transform-origin: calc((1em + #{map-get($spacers, 1)} + #{$border-width}) * -1) center; } } diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.xml b/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.xml index 826546976ba671..390f0fc2908f57 100644 --- a/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.xml +++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.xml @@ -1,58 +1,62 @@ -
-
- -
- -
- -
- - - -
- -
- +
+
+ - +
+
+ + / +
- -
-
-
- - -
-
- - - -
+
+
+ +
+ + + + + + + + +
diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief_timeline.js b/addons/mail/static/src/views/fields/call_debrief/call_debrief_timeline.js index 3aed93225daf7b..59574f8ef19355 100644 --- a/addons/mail/static/src/views/fields/call_debrief/call_debrief_timeline.js +++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief_timeline.js @@ -1,5 +1,5 @@ -import { Component, onWillUnmount, props, signal } from "@odoo/owl"; -import { formatFloatTime } from "@web/views/fields/formatters"; +import { Component, onWillUnmount, props, signal, proxy } from "@odoo/owl"; +import { formatDuration } from "@mail/views/fields/call_debrief/call_debrief_utils"; export class CallDebriefTimeline extends Component { static template = "mail.CallDebriefTimeline"; @@ -8,37 +8,39 @@ export class CallDebriefTimeline extends Component { totalDuration: { type: Number }, // Array of media segment objects { id, startSec, endSec, duration, ... } mediaSegments: { type: Array, optional: true }, + media: { type: Object, optional: true }, // Callback function called when the user clicks/drags to seek: ({ timestamp }) => void onSeek: { type: Function }, // The current playback position in global call seconds. currentTime: { type: Number, optional: true }, + hasVideo: { type: Boolean, optional: true }, + isFullscreen: { type: Boolean, optional: true }, + onToggleFullscreen: { type: Function, optional: true }, }; props = props(); setup() { this.timeline = signal(null); + this.timestamp = signal(null); this.isDragging = false; + this.state = proxy({ + hoverTimestamp: 0, + hasHoverPosition: false, + }); this.onDragMove = this.onDragMove.bind(this); this.onDragEnd = this.onDragEnd.bind(this); onWillUnmount(() => { - window.removeEventListener("mousemove", this.onDragMove); - window.removeEventListener("mouseup", this.onDragEnd); + window.removeEventListener("pointermove", this.onDragMove); + window.removeEventListener("pointerup", this.onDragEnd); + window.removeEventListener("pointercancel", this.onDragEnd); }); } formatDuration(seconds) { - const formatted = formatFloatTime(seconds || 0, { - unit: "seconds", - showSeconds: true, - numeric: true, - }); - if (this.props.totalDuration < 3600) { - return formatted.slice(2); - } - return formatted; + return formatDuration(seconds, this.props.totalDuration); } _stylePositionPlayhead() { @@ -49,34 +51,56 @@ export class CallDebriefTimeline extends Component { return `left: ${percentage}%;`; } + _stylePositionTimestamp() { + if (!this.props.totalDuration) { + return "left: 0%;"; + } + const el = this.timestamp(); + const timestampWidth = el ? el.getBoundingClientRect().width : 0; + const minOffset = timestampWidth / 2; + + const currentTimestamp = this.displayedTimestamp; + const percentage = Math.min(100, (currentTimestamp / this.props.totalDuration) * 100); + return `left: clamp(${minOffset}px, ${percentage}%, calc(100% - ${minOffset}px));`; + } + _stylePositionMediaSegment(media) { if (!this.props.totalDuration || !media || !media.duration) { - return `left: 0%; width: 0%;`; + return `width: 0%;`; } - const start = media.startSec || 0; const width = Math.min(100, (media.duration / this.props.totalDuration) * 100); - const left = (start / this.props.totalDuration) * 100; - return `left: ${left}%; width: ${width}%;`; + return `width: ${width}%;`; + } + + _stylePositionMediaProgress(media) { + if (!this.props.totalDuration || !media || !media.duration) { + return `width: 0%;`; + } + const currentTime = this.props.currentTime || 0; + const playedDuration = Math.max(0, Math.min(currentTime - media.startSec, media.duration)); + const width = (playedDuration / media.duration) * 100; + return `width: ${width}%;`; } onDragStart(ev) { - // Only on left click - if (ev.button !== 0) { + // For mouse, only react to left click. + if (ev.pointerType === "mouse" && ev.button !== 0) { return; } ev.stopPropagation(); ev.preventDefault(); // Prevents defualt text selection this.isDragging = true; - window.addEventListener("mousemove", this.onDragMove); - window.addEventListener("mouseup", this.onDragEnd); + window.addEventListener("pointermove", this.onDragMove); + window.addEventListener("pointerup", this.onDragEnd); + window.addEventListener("pointercancel", this.onDragEnd); this._updateSeek(ev); } onDragMove(ev) { if (this.isDragging) { ev.stopPropagation(); - // Mouse realased outside the window - if (ev.buttons === 0) { + // Mouse released outside the timeline. + if (ev.pointerType === "mouse" && ev.buttons === 0) { this.onDragEnd(); return; } @@ -89,8 +113,9 @@ export class CallDebriefTimeline extends Component { ev.stopPropagation(); } this.isDragging = false; - window.removeEventListener("mousemove", this.onDragMove); - window.removeEventListener("mouseup", this.onDragEnd); + window.removeEventListener("pointermove", this.onDragMove); + window.removeEventListener("pointerup", this.onDragEnd); + window.removeEventListener("pointercancel", this.onDragEnd); } _getTimestampFromClientX(clientX) { @@ -99,15 +124,32 @@ export class CallDebriefTimeline extends Component { return 0; } const rect = el.getBoundingClientRect(); - const progress = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + const edgeOffset = 8; // Half of the playhead size + const extendedLeft = rect.left - edgeOffset; + const extendedWidth = rect.width + edgeOffset * 2; + const progress = Math.max(0, Math.min(1, (clientX - extendedLeft) / extendedWidth)); return progress * this.props.totalDuration; } _updateSeek(ev) { const newTimestamp = this._getTimestampFromClientX(ev.clientX); + this.state.hoverTimestamp = newTimestamp; + this.state.hasHoverPosition = true; this.props.onSeek({ timestamp: newTimestamp }); } + onHoverMove(ev) { + this.state.hoverTimestamp = this._getTimestampFromClientX(ev.clientX); + this.state.hasHoverPosition = true; + } + + get displayedTimestamp() { + if (this.state.hasHoverPosition) { + return this.state.hoverTimestamp; + } + return this.props.currentTime; + } + get formattedTotalDuration() { return this.formatDuration(this.props.totalDuration); } diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief_timeline.scss b/addons/mail/static/src/views/fields/call_debrief/call_debrief_timeline.scss index c43b046bb49dbb..13823f055368fc 100644 --- a/addons/mail/static/src/views/fields/call_debrief/call_debrief_timeline.scss +++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief_timeline.scss @@ -1,72 +1,53 @@ .o-CallDebriefTimeline-container { - text-align: center; - margin: 0.2em; -} -.o-CallDebriefTimeline-labels { - display: flex; - justify-content: space-between; - margin-top: 6px; - color: $text-muted; - font-size: 0.9rem; -} -.o-CallDebriefTimeline { - position: relative; - height: 1.25rem; - padding-top: 15px; -} -.o-CallDebriefTimeline-track { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: $gray-200; - border-radius: $border-radius-pill; - overflow: hidden; - box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); -} + .o-CallDebriefTimeline { + height: var(--CallDebriedTimeline-height, #{map-get($spacers, 3) * 2}); + cursor: grab; + touch-action: none; -.o-CallDebriefTimeline-media-segment { - position: absolute; - top: 0; - left: 0; - height: 100%; - background: $gray-500; - opacity: 0.6; - z-index: 3; -} + &:active, &:hover { + .o-CallDebriefTimeline-timestamp { + opacity: 1; + transform: translate(-50%, -25%); + } -.o-CallDebriefTimeline-playhead { - position: absolute; - top: -1.625rem; - left: var(--progress, 50%); - transform: translateX(-50%); - width: 3.125rem; - height: 1.5rem; - background: $primary; - border-radius: $border-radius-pill; - display: flex; - align-items: center; - justify-content: center; - z-index: 10; - box-shadow: 0 0 6px rgba($primary, 0.6); - user-select: none; - cursor: grab; + .o-CallDebriefTimeline-playhead:before { + transform: scale(1); + } + } + } - &::after { - content: ""; - position: absolute; - bottom: -1.875rem; - width: 1px; - height: 1.875rem; - background: rgba($primary, 0.76); - z-index: -1; + .o-CallDebriefTimeline-media-segment { + height: var(--CallDebriedTimeline__mediaSegment-height, #{map-get($spacers, 2)}); + } + + .o-CallDebriefTimeline-playhead { + left: 0; + height: var(--CallDebriefTimeline__playhead-size, #{map-get($spacers, 3)}); + width: var(--CallDebriefTimeline__playhead-size, #{map-get($spacers, 3)}); + + &:before { + content: ""; + position: absolute; + inset: 0; + display: block; + background: $primary; + transform: scale(.1, 1); + transition: transform .15s ease; + } + } + + .o-CallDebriefTimeline-timestamp { + left: 0; + opacity: 0; + transition: opacity .2s ease-in-out, transform .2s ease-in-out; + user-select: none; } } -.o-CallDebriefTimeline-playhead-timestamp { - font-size: 0.7rem; - color: white; - white-space: nowrap; - text-shadow: 0 0 2px rgba(0, 0, 0, 0.5); +.o-CallDebrief-mediaPlayer { + touch-action: manipulation; + + .o-CallDebriefControls-volumeSlider { + touch-action: none; + } } diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief_timeline.xml b/addons/mail/static/src/views/fields/call_debrief/call_debrief_timeline.xml index 18cd99f3b20178..e506bab6c71823 100644 --- a/addons/mail/static/src/views/fields/call_debrief/call_debrief_timeline.xml +++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief_timeline.xml @@ -1,27 +1,31 @@ -
-
- 00:00 - -
-
-
+
+
+
-
+
+
+
-
- +
+
+
diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief_utils.js b/addons/mail/static/src/views/fields/call_debrief/call_debrief_utils.js new file mode 100644 index 00000000000000..641f08424b0616 --- /dev/null +++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief_utils.js @@ -0,0 +1,20 @@ +import { formatFloatTime } from "@web/views/fields/formatters"; + +/** + * Formats a duration in seconds as a timestamp. + * Omits the hours portion when totalDuration is under an hour. + * @param {number} seconds + * @param {number} totalDuration + * @returns {string} + */ +export function formatDuration(seconds, totalDuration) { + const formatted = formatFloatTime(seconds || 0, { + unit: "seconds", + showSeconds: true, + numeric: true, + }); + if (totalDuration < 3600) { + return formatted.slice(2); + } + return formatted; +} diff --git a/addons/mail/static/tests/web/fields/call_debrief.test.js b/addons/mail/static/tests/web/fields/call_debrief.test.js index 04654c00e2bb61..e1dd511e68b8df 100644 --- a/addons/mail/static/tests/web/fields/call_debrief.test.js +++ b/addons/mail/static/tests/web/fields/call_debrief.test.js @@ -99,7 +99,7 @@ test("CallDebrief: renders video with playback", async () => { expect(".o-CallDebrief-video video").toHaveCount(1); // Mute first to avoid noise - await click("button[title*='Mute']"); + await click("button.o-CallDebrief-muteBtn"); // Start playback const video = queryOne("video"); @@ -143,7 +143,7 @@ test("CallDebrief: timeline-media synchronization", async () => { queryOne("audio").dispatchEvent(new Event("loadeddata")); await animationFrame(); - const timestampText = queryOne(".o-CallDebriefTimeline-playhead-timestamp").innerText; + const timestampText = queryOne(".o-CallDebriefTimeline-timestamp").innerText; const [minutes, seconds] = timestampText.split(":").map(Number); const totalSeconds = minutes * 60 + seconds; expect(Math.abs(totalSeconds - 90) <= 2).toBe(true, { @@ -164,7 +164,7 @@ test("CallDebrief: timeline-media synchronization", async () => { audio.dispatchEvent(new Event("timeupdate")); await animationFrame(); - const finalTimestampText = queryOne(".o-CallDebriefTimeline-playhead-timestamp").innerText; + const finalTimestampText = queryOne(".o-CallDebriefMediaControls-timeLabel .o_current_time").innerText; const [finalM, finalS] = finalTimestampText.split(":").map(Number); const finalTotalSeconds = finalM * 60 + finalS; // Segment 2 starts at 60s, so 60 + 6 = 66s (01:06) From 2a67db12f1acac640de397da93d3acafd0063704 Mon Sep 17 00:00:00 2001 From: Brieuc-brd Date: Tue, 2 Jun 2026 13:13:59 +0200 Subject: [PATCH 4/4] [IMP] mail: wip --- .../views/fields/call_debrief/call_debrief.js | 9 +++-- .../fields/call_debrief/call_debrief.scss | 4 +- .../fields/call_debrief/call_debrief.xml | 4 +- .../call_debrief_media_controls.js | 3 ++ .../call_debrief_media_controls.scss | 29 ++++++-------- .../call_debrief_media_controls.xml | 40 +++++++++---------- .../call_debrief/call_debrief_timeline.js | 3 -- .../call_debrief/call_debrief_timeline.xml | 5 +-- 8 files changed, 45 insertions(+), 52 deletions(-) diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief.js b/addons/mail/static/src/views/fields/call_debrief/call_debrief.js index 82a2392146d2b2..c1b759f2a23727 100644 --- a/addons/mail/static/src/views/fields/call_debrief/call_debrief.js +++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief.js @@ -32,7 +32,7 @@ export class CallDebrief extends Component { setup() { this.callDurationSeconds = 0; - this.playbackRates = [0.25, 0.50, 0.75, 0.90, 1.00, 1.25, 1.50, 1.75, 2.00, 3.00]; + this.playbackRates = [0.25, 0.5, 0.75, 0.9, 1, 1.25, 1.5, 1.75, 2, 3]; this.skipNextTimeUpdate = false; this.isSwitchingSegment = false; @@ -72,8 +72,9 @@ export class CallDebrief extends Component { 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; }); @@ -474,7 +475,7 @@ export class CallDebrief extends Component { toggleFullscreen() { const mediaPlayer = this.mediaPlayer(); - if (!mediaPlayer) { + if (!mediaPlayer || !this.hasVideo) { return; } if (!document.fullscreenElement) { diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief.scss b/addons/mail/static/src/views/fields/call_debrief/call_debrief.scss index 47b86d6e457b6f..e93691f8590acf 100644 --- a/addons/mail/static/src/views/fields/call_debrief/call_debrief.scss +++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief.scss @@ -27,8 +27,8 @@ } .o-CallDebrief { - max-height: var(--CallDebrief-max-height, 75vh); - max-width: var(--CallDebrief-max-width, #{map-get($container-max-widths, lg)}); + 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; diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief.xml b/addons/mail/static/src/views/fields/call_debrief/call_debrief.xml index 74a7164acab5f2..ba438d18c1fcfe 100644 --- a/addons/mail/static/src/views/fields/call_debrief/call_debrief.xml +++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief.xml @@ -50,7 +50,6 @@ mediaSegments="this.state.mediaSegments" onSeek="(options) => this.setPlaybackTime(options)" currentTime="this.state.currentTime" - hasVideo="this.hasVideo" isFullscreen="this.state.isFullscreen" onToggleFullscreen="() => this.toggleFullscreen()" /> @@ -71,6 +70,9 @@ onSetVolume="(ev) => this.setVolume(ev)" onToggleMute="() => this.toggleMute()" mediaUrl="this.state.currentSegment?.mediaUrl" + hasVideo="this.hasVideo" + isFullscreen="this.state.isFullscreen" + onToggleFullscreen="() => this.toggleFullscreen()" />
diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.js b/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.js index 6c2066e8b63c0c..bf6a5896838b79 100644 --- a/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.js +++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.js @@ -21,6 +21,9 @@ export class CallDebriefMediaControls extends Component { onSetVolume: { type: Function }, onToggleMute: { type: Function }, feedback: { type: Object, optional: true }, + hasVideo: { type: Boolean, optional: true }, + isFullscreen: { type: Boolean, optional: true }, + onToggleFullscreen: { type: Function, optional: true }, }; props = props(); diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.scss b/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.scss index f417c7fea80a25..1776b36a15848b 100644 --- a/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.scss +++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.scss @@ -1,20 +1,7 @@ .o-CallDebriefMediaControls { - @container callDebrief-mediaPlayer (max-width: 370px) { - --CallDebriefMediaControls__timeLabelClassic-display: none; - --CallDebriefMediaControls__timeLabelCondensed-display: block; - } - - @container callDebrief-mediaPlayer (max-width: 280px) { - --CallDebriefMediaControls__timeLabelClassic-display: none; - --CallDebriefMediaControls__timeLabelCondensed-display: none; - } - - .o_timeLabel_classic { - display: var(--CallDebriefMediaControls__timeLabelClassic-display, block); - } - - .o_timeLabel_condensed { - display: var(--CallDebriefMediaControls__timeLabelCondensed-display, none); + @container callDebrief-mediaPlayer (max-width: 475px) { + --CallDebriefMediaControls__timeLabel-order: -1; + --CallDebriefMediaControls__timeLabel-width: 100%; } .o-CallDebriefMediaControls-timeLabel, .o-CallDebriefMediaControls-playbackRate { @@ -22,6 +9,11 @@ user-select: none; } + .o-CallDebriefMediaControls-timeLabel { + order: var(--CallDebriefMediaControls__timeLabel-order); + width: var(--CallDebriefMediaControls__timeLabel-width, auto); + } + .o-CallDebriefMediaControls-playbackRate { --#{$prefix}box-shadow-inset: none; @@ -50,4 +42,9 @@ transform: rotate(-90deg); transform-origin: calc((1em + #{map-get($spacers, 1)} + #{$border-width}) * -1) center; } + + .o-CallDebrief-playbackRateLabel { + width: 5ch; + height: 2em; + } } diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.xml b/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.xml index 390f0fc2908f57..9dcb2acc09f5ee 100644 --- a/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.xml +++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief_media_controls.xml @@ -1,31 +1,26 @@ -
+
- - - -
-
- - -
-
- - / - -
-
+
+
+ + / +
-
- @@ -50,13 +45,14 @@ -
diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief_timeline.js b/addons/mail/static/src/views/fields/call_debrief/call_debrief_timeline.js index 59574f8ef19355..38df6a4fa9f497 100644 --- a/addons/mail/static/src/views/fields/call_debrief/call_debrief_timeline.js +++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief_timeline.js @@ -13,9 +13,6 @@ export class CallDebriefTimeline extends Component { onSeek: { type: Function }, // The current playback position in global call seconds. currentTime: { type: Number, optional: true }, - hasVideo: { type: Boolean, optional: true }, - isFullscreen: { type: Boolean, optional: true }, - onToggleFullscreen: { type: Function, optional: true }, }; props = props(); diff --git a/addons/mail/static/src/views/fields/call_debrief/call_debrief_timeline.xml b/addons/mail/static/src/views/fields/call_debrief/call_debrief_timeline.xml index e506bab6c71823..13099b4760f35a 100644 --- a/addons/mail/static/src/views/fields/call_debrief/call_debrief_timeline.xml +++ b/addons/mail/static/src/views/fields/call_debrief/call_debrief_timeline.xml @@ -1,7 +1,7 @@ -
+
@@ -23,9 +23,6 @@
-