mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 12:06:16 -08:00
feat(playback): give the fullscreen side panel the settings panel's glass look (#1767)
* feat(playback): give the fullscreen side panel the settings panel's glass look The fullscreen channel/episode panel was a full-height graphite strip while the controls' settings panel is an inset, rounded glass card. The side panel now wears the same surface: 16px inset (8px under 560px), 20px corners, the controls' glass fill, hairline border, blur and shadow, the same open motion and a 32px square close button. The edge hint uses the same glass, and the episode list marks the playing row with the settings panel's selected cyan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): dismiss the side panel from the exposed edge gutter The inset card leaves the hot zone's left strip visible beside it, above the scrim, so a click there re-ran show() instead of dismissing. A completed primary press in the zone now closes an open panel; hovering there still counts as inside, since a hover-opened panel leaves the pointer resting in that strip. The playing episode row also keeps its cyan tint on hover/focus. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(playback): decide an edge press from the panel state at pointerdown A press held on the edge past the hover dwell opened the panel before the release, which then read the open state and dismissed it. The press now remembers whether it began on an open panel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0524e72b9f
commit
d4118ad442
6 files changed
+141
-42
No files matched your search
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: feature
|
||||
area: playback
|
||||
---
|
||||
|
||||
The fullscreen channel and episode list now floats over the video as a rounded
|
||||
glass card, matching the player's settings panel, and the playing episode is
|
||||
highlighted the same way as a selected setting.
|
||||
@@ -732,7 +732,10 @@ neither opens nor arms the hint: the zone re-arms on the next real
|
||||
scrim and below the panel while open, preserving the pointer target until
|
||||
the opening animation covers it. A delayed fullscreen paint therefore cannot
|
||||
turn a stationary edge hover into a synthetic mouse-leave that closes the
|
||||
panel. The zone stops above the controls bar (`bottom: max(25%, 140px)`) so
|
||||
panel. The open card is inset from the edge, so the zone's left strip stays
|
||||
exposed beside it: hovering there still counts as inside (a hover-opened
|
||||
panel leaves the pointer resting in that strip), but a completed primary
|
||||
press there closes the panel as the scrim would. The zone stops above the controls bar (`bottom: max(25%, 140px)`) so
|
||||
the leftmost transport button never loses a click or tap to it. The `C` key
|
||||
opens it too and focuses the search field — or, for a host without one, the
|
||||
panel itself (`tabindex="-1"`), so the next Tab reaches its first control
|
||||
@@ -767,9 +770,16 @@ ends. The panel carries the `dark-theme` context class, so app and Material
|
||||
tokens inside it resolve to the dark palette whatever the app theme is — and
|
||||
because the app's global `.dark-theme { background: … !important }` rule
|
||||
(`apps/web/src/styles.scss`) claims the background of every element wearing
|
||||
that class, the panel's translucent gradient is declared on the compound
|
||||
that class, the panel's glass fill is declared on the compound
|
||||
`.fullscreen-channel-panel.dark-theme` selector with `!important`; without
|
||||
that the panel painted no background at all.
|
||||
that the panel painted no background at all. The panel wears the same surface
|
||||
as the controls' settings panel — a card inset 16px from the player's top,
|
||||
bottom and left edges (8px in a player narrower than 560px), 20px corners,
|
||||
the `--pc-glass-bg` fill, hairline `--pc-glass-border`, the same blur and
|
||||
shadow, and a 32px square close button — with the values written literally,
|
||||
because the panel is a sibling of the controls host, outside the scope that
|
||||
declares the `--pc-*` palette. Its episode list marks the playing row with the
|
||||
settings panel's selected-option cyan tint.
|
||||
Keyboard: `C` is ignored while any editable element has focus and while the
|
||||
player sits inside an `inert` region; the search field is an ordinary input,
|
||||
so the controls' Space/K/F/M shortcuts stay out of it. That is also why `C` is
|
||||
|
||||
+65
-36
@@ -34,12 +34,12 @@
|
||||
left: 0;
|
||||
width: 22px;
|
||||
height: 96px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border-left: 0;
|
||||
border-radius: 0 12px 12px 0;
|
||||
background: rgba(13, 15, 18, 0.72);
|
||||
background: rgba(12, 16, 23, 0.72);
|
||||
box-shadow: 4px 0 18px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(18px) saturate(1.3);
|
||||
opacity: 0;
|
||||
transform: translate(-100%, -50%);
|
||||
pointer-events: none;
|
||||
@@ -68,8 +68,8 @@
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__edge-hint--armed {
|
||||
border-color: var(--app-selection-color, #78adff);
|
||||
background: rgba(13, 15, 18, 0.9);
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
background: rgba(12, 16, 23, 0.88);
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__edge-hint--armed::after {
|
||||
@@ -78,6 +78,12 @@
|
||||
|
||||
// ─── Open state ──────────────────────────────────────────────────────────────
|
||||
|
||||
// A floating glass card inset from the player edges — the same surface as the
|
||||
// player's settings panel (`player-settings-panel.component.scss`): 20px
|
||||
// corners, the controls' glass fill, hairline border and blur. The values are
|
||||
// literal because the panel sits beside the controls host, outside the scope
|
||||
// that declares the `--pc-*` palette.
|
||||
|
||||
.fullscreen-channel-panel__scrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -86,43 +92,46 @@
|
||||
|
||||
.fullscreen-channel-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
top: 16px;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
z-index: 8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(400px, calc(100% - 56px));
|
||||
color: var(--app-heading-color, #d8dce8);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 18px 0 48px rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(24px) saturate(140%);
|
||||
transform: translateX(-100%);
|
||||
box-sizing: border-box;
|
||||
width: min(400px, calc(100% - 72px));
|
||||
color: #e7ecf3;
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(18px) saturate(1.3);
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
// Past its own inset, so no rounded corner or shadow peeks in while shut.
|
||||
transform: translateX(calc(-100% - 24px));
|
||||
visibility: hidden;
|
||||
transition:
|
||||
transform 240ms cubic-bezier(0.2, 0.7, 0.2, 1),
|
||||
visibility 0s linear 240ms;
|
||||
transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
opacity 220ms ease-out,
|
||||
visibility 0s linear 220ms;
|
||||
}
|
||||
|
||||
// Translucent graphite over the video, matching the controls' popovers. The
|
||||
// panel carries `dark-theme` for its tokens, and the app's global
|
||||
// The controls' glass fill over the video. The panel carries `dark-theme` for its tokens, and the app's global
|
||||
// `.dark-theme { background: var(--mat-sys-surface) !important }`
|
||||
// (apps/web/src/styles.scss) would replace this gradient with the flat
|
||||
// surface — or with no background at all where that token is not defined —
|
||||
// so the compound selector and `!important` are required to out-rank it.
|
||||
.fullscreen-channel-panel.dark-theme {
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
rgba(13, 15, 18, 0.94),
|
||||
rgba(13, 15, 18, 0.88)
|
||||
) !important;
|
||||
background: rgba(12, 16, 23, 0.72) !important;
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel--open {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
visibility: visible;
|
||||
transition:
|
||||
transform 240ms cubic-bezier(0.2, 0.7, 0.2, 1),
|
||||
transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
opacity 220ms ease-out,
|
||||
visibility 0s;
|
||||
}
|
||||
|
||||
@@ -131,9 +140,9 @@
|
||||
.fullscreen-channel-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding: 10px 6px 10px 12px;
|
||||
padding: 14px 12px 12px 16px;
|
||||
}
|
||||
|
||||
// A host without a search field (`panelSearchEnabled` false) shows its title in
|
||||
@@ -144,9 +153,9 @@
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
line-height: 36px;
|
||||
font-size: 0.95rem;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
color: #e7ecf3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -166,9 +175,9 @@
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
padding: 0 6px 0 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
cursor: text;
|
||||
transition:
|
||||
border-color 150ms ease-out,
|
||||
@@ -176,7 +185,7 @@
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__search:focus-within {
|
||||
border-color: var(--app-selection-color, #78adff);
|
||||
border-color: rgba(255, 255, 255, 0.28);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
@@ -217,7 +226,7 @@
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
border-radius: 8px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
@@ -226,7 +235,7 @@
|
||||
.fullscreen-channel-panel__search-clear:hover,
|
||||
.fullscreen-channel-panel__search-clear:focus-visible {
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__search-clear mat-icon {
|
||||
@@ -235,15 +244,31 @@
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
// The settings panel's close button: a 32px, 10px-cornered square with a flat
|
||||
// hover fill instead of Material's round state layer.
|
||||
.fullscreen-channel-panel__close {
|
||||
flex-shrink: 0;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 4px;
|
||||
border-radius: 10px;
|
||||
color: #e7ecf3;
|
||||
--mdc-icon-button-icon-color: currentColor;
|
||||
--mat-icon-button-icon-color: currentColor;
|
||||
--mat-icon-button-state-layer-size: 32px;
|
||||
--mat-icon-button-container-shape: 10px;
|
||||
--mat-icon-button-icon-size: 20px;
|
||||
--mat-icon-button-hover-state-layer-opacity: 0;
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__close mat-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.fullscreen-channel-panel__close:hover {
|
||||
color: #ffffff;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
// ─── Body ────────────────────────────────────────────────────────────────────
|
||||
@@ -278,7 +303,11 @@
|
||||
|
||||
@container (max-width: 560px) {
|
||||
.fullscreen-channel-panel {
|
||||
width: calc(100% - 44px);
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
width: calc(100% - 52px);
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+26
@@ -547,6 +547,32 @@ describe('FullscreenChannelPanelComponent', () => {
|
||||
expect(query('host-list')?.textContent?.trim()).toBe('');
|
||||
});
|
||||
|
||||
it('closes on a click in the edge gutter the inset card leaves exposed', () => {
|
||||
setFullscreen(stage);
|
||||
openByHover();
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerdown'));
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerup'));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(isOpen()).toBe(false);
|
||||
expect(query('host-list')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the panel open when the hover dwell opens it during a held press', () => {
|
||||
setFullscreen(stage);
|
||||
stage.dispatchEvent(pointerEvent('pointermove'));
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerenter'));
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerdown'));
|
||||
jest.advanceTimersByTime(CHANNEL_PANEL_OPEN_DWELL_MS);
|
||||
fixture.detectChanges();
|
||||
expect(isOpen()).toBe(true);
|
||||
|
||||
hotZone()?.dispatchEvent(pointerEvent('pointerup'));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(isOpen()).toBe(true);
|
||||
});
|
||||
|
||||
it('closes on the scrim but keeps the list mounted for the next opening', () => {
|
||||
setFullscreen(stage);
|
||||
openByHover();
|
||||
|
||||
+13
@@ -151,6 +151,12 @@ export class FullscreenChannelPanelComponent implements OnDestroy {
|
||||
|
||||
/** Pointer whose primary press began inside the hot zone, if any. */
|
||||
private hotZonePressPointerId: number | null = null;
|
||||
/**
|
||||
* Whether that press began on an open panel. Decided at pointerdown: the
|
||||
* hover dwell can open the panel while the button is still held, and that
|
||||
* release must not read as a dismissal.
|
||||
*/
|
||||
private hotZonePressBeganOpen = false;
|
||||
|
||||
private readonly onDocumentKeydown = (event: KeyboardEvent) =>
|
||||
this.handleKeydown(event);
|
||||
@@ -230,10 +236,13 @@ export class FullscreenChannelPanelComponent implements OnDestroy {
|
||||
* pointerup, not pointerdown: the hot zone must still be the click
|
||||
* target when the press completes, so the click that follows dies on it
|
||||
* instead of reaching the video's click-to-pause.
|
||||
* While the panel is open the zone is still exposed in the card's left
|
||||
* inset, above the scrim, so a click there dismisses like the scrim does.
|
||||
*/
|
||||
onHotZonePointerDown(event: PointerEvent): void {
|
||||
this.hotZonePressPointerId =
|
||||
event.button === 0 ? event.pointerId : null;
|
||||
this.hotZonePressBeganOpen = this.state.open();
|
||||
}
|
||||
|
||||
onHotZonePointerUp(event: PointerEvent): void {
|
||||
@@ -242,6 +251,10 @@ export class FullscreenChannelPanelComponent implements OnDestroy {
|
||||
if (pressed === null || pressed !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
if (this.hotZonePressBeganOpen) {
|
||||
this.state.hide();
|
||||
return;
|
||||
}
|
||||
this.state.show('pointer');
|
||||
}
|
||||
|
||||
|
||||
+16
-3
@@ -121,6 +121,11 @@
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #e7ecf3;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@@ -152,14 +157,22 @@
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--app-selection-color, #78adff);
|
||||
outline: 2px solid #e7ecf3;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
}
|
||||
|
||||
// The playing row wears the settings panel's selected-option tint: cyan means
|
||||
// "this is on" across the player chrome.
|
||||
.episode-panel__item--playing {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
background: rgba(92, 214, 255, 0.14);
|
||||
cursor: default;
|
||||
|
||||
// Keep the cue while the row is hovered or focused.
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: rgba(92, 214, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// Watched rows step back so the unwatched ones read as "what's left".
|
||||
@@ -266,7 +279,7 @@
|
||||
font-size: 0.62rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: #7dd87d;
|
||||
color: #5cd6ff;
|
||||
}
|
||||
|
||||
.episode-panel__title {
|
||||
|
||||
Reference in new issue
Block a user