mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
Merge remote-tracking branch 'origin/master' into agent/xtream-vod-download-reliability
This commit is contained in:
commit
b3c3119070
22 files changed
+574
-24
No files matched your search
@@ -0,0 +1,10 @@
|
||||
---
|
||||
type: fix
|
||||
area: ui
|
||||
issues: [1100]
|
||||
---
|
||||
|
||||
IPTVnator is usable on a phone again. The navigation bar no longer sits off
|
||||
screen, category and channel lists take the full width instead of squeezing
|
||||
the content into a sliver, the video keeps a usable share of the screen, and
|
||||
the M3U channel list can be reopened after hiding it.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: fix
|
||||
area: workspace
|
||||
---
|
||||
|
||||
The playlist dropdown in the header shows a "Playlist info" entry for the
|
||||
active playlist again, next to "Account info" and "Add playlist" — it had
|
||||
disappeared when playlist actions moved into the per-row menu.
|
||||
@@ -751,6 +751,9 @@
|
||||
);
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
// Stretched to the full layout width, so its own inline padding would be
|
||||
// added on top and push the settings page into a horizontal scroll.
|
||||
box-sizing: border-box;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -253,9 +253,11 @@ remain local when the meaning is explicit.
|
||||
width is preserved so uncollapsing restores the user's previous resized
|
||||
width. Both rails share the same 180 ms width transition so motion stays in
|
||||
lockstep.
|
||||
- Below 600 px viewport, the M3U layout's mobile bottom-drawer rule overrides
|
||||
the desktop collapse to `height: 0` instead of `width: 0`, and the floating
|
||||
restore handle is hidden.
|
||||
- At the phone breakpoint the M3U layout's bottom-drawer rule overrides the
|
||||
desktop collapse to `height: 0` instead of `width: 0`. The floating restore
|
||||
handle stays visible there: the collapse toggle is reachable by touch, so
|
||||
hiding the handle left a phone with no way to bring the list back short of
|
||||
`Cmd/Ctrl+B`.
|
||||
|
||||
### EPG Card
|
||||
|
||||
@@ -331,6 +333,50 @@ Settings use the same system but are flatter than content-heavy views.
|
||||
- Neutral rows can use low-opacity dark overlays
|
||||
- Keep strong blue tint reserved for active sections and selected items
|
||||
|
||||
## Phone Layout
|
||||
|
||||
`640px` is the phone breakpoint. Use `@media (max-width: 640px)` rather than
|
||||
inventing a nearby value: several surfaces cooperate at this width, and a
|
||||
component that picks `599px` leaves a band where the shell has already stacked
|
||||
but the component has not.
|
||||
|
||||
### Rails become rows or stacks
|
||||
|
||||
- The workspace shell rail turns into a horizontal top bar. Everything inside
|
||||
it has to opt into the row direction — a nested list that keeps
|
||||
`flex-direction: column` stacks its links out of the bar and over the header.
|
||||
The bar scrolls sideways once a portal contributes its sections, and the
|
||||
settings link is `position: sticky` so it never scrolls out of reach.
|
||||
- Side rails stack above the content instead of beside it: the shell context
|
||||
panel, the live-layout channel sidebar, and the M3U channel drawer.
|
||||
|
||||
### Resizable rails need `!important`
|
||||
|
||||
`ResizableDirective` writes the persisted desktop width as an inline style, so
|
||||
a phone rule must be `width: 100% !important` to win. Hide `.resize-handle` in
|
||||
the same rule — dragging is meaningless at full width. Since there is no global
|
||||
`border-box` reset, a full-width rail with its own padding also needs
|
||||
`box-sizing: border-box` or it overflows the viewport.
|
||||
|
||||
### State the content's floor, not the list's ceiling
|
||||
|
||||
On routes that stack two lists above the player (live TV shows the categories
|
||||
panel and the channel list), capping both lists still leaves the video a
|
||||
sliver. Give the player container a `min-height` instead and let the lists
|
||||
shrink into what is left.
|
||||
|
||||
### What to drop
|
||||
|
||||
Prefer removing a control over shrinking everything around it:
|
||||
|
||||
- Keyboard-only affordances — the `⌘K` badge, the shortcuts button.
|
||||
- The `mat-paginator` page-size select, which is the widest part of the
|
||||
control and the least useful one on a phone. The range and arrows stay.
|
||||
- Counts and subtitles that a neighbouring control already states.
|
||||
|
||||
Never drop the only way back to a hidden surface. A collapse toggle that is
|
||||
reachable by touch needs its restore affordance to be reachable too.
|
||||
|
||||
## Theme Guidance
|
||||
|
||||
### Light Theme
|
||||
|
||||
@@ -109,18 +109,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 599px) {
|
||||
// Matches the phone breakpoint in portal-layout's live-layout mixin, which
|
||||
// this component includes; a narrower one here would leave a band of widths
|
||||
// where only the mixin's generic stacking applied.
|
||||
@media only screen and (max-width: 640px) {
|
||||
:host {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
width: 100% !important;
|
||||
min-width: 100%;
|
||||
max-width: 100%;
|
||||
height: 50vh;
|
||||
// The mixin caps the list's share; here the explicit height is the
|
||||
// whole point of the bottom-drawer layout, so release the cap.
|
||||
max-height: none;
|
||||
border-right: none;
|
||||
border-top: 1px solid var(--mat-sys-outline-variant);
|
||||
border-bottom: none;
|
||||
|
||||
// Sidebar collapse uses width=0 on desktop; on mobile the rail flips
|
||||
// to a bottom drawer so collapse it via height instead.
|
||||
@@ -131,11 +138,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Keeps the mixin's player floor rather than releasing it: `height: 50vh`
|
||||
// is a preference, not a reservation, so on a short landscape phone the
|
||||
// drawer could otherwise squeeze the video away entirely.
|
||||
.content-container {
|
||||
height: 50vh;
|
||||
}
|
||||
|
||||
.sidebar-restore {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -202,16 +202,16 @@
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showAddPlaylist() || showAccountInfo()) {
|
||||
@if (showAddPlaylist() || showAccountInfo() || showPlaylistInfo()) {
|
||||
<div class="context-actions-section">
|
||||
@if (showAddPlaylist()) {
|
||||
@if (showPlaylistInfo()) {
|
||||
<button
|
||||
class="context-action-item"
|
||||
(click)="requestAddPlaylist()"
|
||||
(click)="requestPlaylistInfo()"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.SHELL.ADD_PLAYLIST' | translate
|
||||
'WORKSPACE.SHELL.PLAYLIST_INFO' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
@@ -226,6 +226,17 @@
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
@if (showAddPlaylist()) {
|
||||
<button
|
||||
class="context-action-item"
|
||||
(click)="requestAddPlaylist()"
|
||||
>
|
||||
<mat-icon>add</mat-icon>
|
||||
<span>{{
|
||||
'WORKSPACE.SHELL.ADD_PLAYLIST' | translate
|
||||
}}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</mat-menu>
|
||||
|
||||
@@ -90,6 +90,25 @@
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
// A wrapping subtitle pushes the trigger past the 56px header row.
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
// On a phone the header row has no width to spare, and the shell allows this
|
||||
// trigger to shrink to 88px there. The decorative type icon goes so that the
|
||||
// trigger's fixed chrome (refresh, chevron, gaps, padding) actually fits that
|
||||
// floor with the name ellipsizing; the subtitle goes because the name is the
|
||||
// only part worth the room.
|
||||
@media (max-width: 640px) {
|
||||
.trigger-icon-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.playlist-info .channels-count {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -295,6 +295,38 @@ describe('PlaylistSwitcherComponent', () => {
|
||||
expect(accountInfoSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders playlist info, account info, and add playlist context actions in the menu', async () => {
|
||||
await createComponent();
|
||||
fixture.componentRef.setInput('showPlaylistInfo', true);
|
||||
fixture.componentRef.setInput('showAccountInfo', true);
|
||||
fixture.componentRef.setInput('showAddPlaylist', true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const playlistInfoSpy = jest.fn();
|
||||
component.playlistInfoRequested.subscribe(playlistInfoSpy);
|
||||
|
||||
component.menuTrigger().openMenu();
|
||||
fixture.detectChanges();
|
||||
|
||||
const actionButtons = Array.from(
|
||||
document.querySelectorAll<HTMLButtonElement>(
|
||||
'.context-actions-section .context-action-item'
|
||||
)
|
||||
);
|
||||
const labels = actionButtons.map((button) =>
|
||||
button.textContent?.trim()
|
||||
);
|
||||
|
||||
expect(labels).toEqual([
|
||||
expect.stringContaining('PLAYLIST_INFO'),
|
||||
expect.stringContaining('ACCOUNT_INFO'),
|
||||
expect.stringContaining('ADD_PLAYLIST'),
|
||||
]);
|
||||
|
||||
actionButtons[0].click();
|
||||
expect(playlistInfoSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('opens the menu, syncs overlay width, and checks portal statuses for Xtream playlists', fakeAsync(async () => {
|
||||
await createComponent();
|
||||
|
||||
|
||||
+7
@@ -234,6 +234,13 @@ mat-paginator {
|
||||
|
||||
mat-paginator {
|
||||
justify-self: start;
|
||||
|
||||
// The page-size select wraps onto a line of its own here, costing a
|
||||
// row of an already short screen for a control nobody reaches for
|
||||
// while browsing on a phone. The range and the arrows stay.
|
||||
::ng-deep .mat-mdc-paginator-page-size {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -169,3 +169,23 @@
|
||||
@include grid.content-grid;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
// The header packs the title, the type filters and the scope toggle into one
|
||||
// row. On a phone that row is wider than the screen, and the scope checkbox
|
||||
// was the part pushed off the right edge, so the row becomes a column.
|
||||
@media (max-width: 640px) {
|
||||
.header .header-top {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header .header-left {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header .header-right {
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
+29
-1
@@ -12,6 +12,11 @@
|
||||
|
||||
.category-content-header {
|
||||
@include panel.standard-panel-header($sticky: true);
|
||||
|
||||
// Same reason as the Xtream live header: the paginator does not shrink,
|
||||
// so it must be able to drop to its own line rather than crowd the meta
|
||||
// out of existence.
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.category-meta {
|
||||
@@ -49,7 +54,9 @@
|
||||
// painting its own (mismatched) Material surface color.
|
||||
mat-paginator {
|
||||
background: transparent !important;
|
||||
flex-shrink: 0;
|
||||
// Its own container already wraps internally, so letting it shrink turns
|
||||
// a clipped next-page arrow into a second line of the paginator itself.
|
||||
min-width: 0;
|
||||
--mat-paginator-container-size: 40px;
|
||||
--mat-paginator-enabled-icon-color: var(
|
||||
--app-body-color,
|
||||
@@ -70,3 +77,24 @@ app-grid-list.all-items-grid {
|
||||
scrollbar-color: color-mix(in srgb, var(--app-muted-color) 55%, transparent)
|
||||
transparent;
|
||||
}
|
||||
|
||||
// Mirrors the Xtream live header: on a phone the page-size select is the
|
||||
// widest part of the paginator and the least useful one, and the count it
|
||||
// costs is already stated by the range beside it.
|
||||
@media (max-width: 640px) {
|
||||
.category-content-header {
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.category-subtitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
mat-paginator {
|
||||
min-width: 0;
|
||||
|
||||
::ng-deep .mat-mdc-paginator-page-size {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-1
@@ -43,6 +43,11 @@
|
||||
|
||||
.category-content-header {
|
||||
@include panel.standard-panel-header($sticky: true);
|
||||
|
||||
// The paginator does not shrink, so on any window narrow enough that it
|
||||
// and the category meta cannot share a line it took the whole row and the
|
||||
// meta collapsed underneath it. Let it drop to a line of its own instead.
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.category-meta {
|
||||
@@ -59,7 +64,9 @@
|
||||
|
||||
mat-paginator {
|
||||
background: transparent !important;
|
||||
flex-shrink: 0;
|
||||
// Its own container already wraps internally, so letting it shrink turns
|
||||
// a clipped next-page arrow into a second line of the paginator itself.
|
||||
min-width: 0;
|
||||
--mat-paginator-container-size: 40px;
|
||||
--mat-paginator-enabled-icon-color: var(
|
||||
--app-body-color,
|
||||
@@ -116,3 +123,28 @@ app-grid-list.live-all-items-grid {
|
||||
line-height: 1.45;
|
||||
opacity: 0.74;
|
||||
}
|
||||
|
||||
// The header is one flex row of "N channels" plus the paginator. The
|
||||
// paginator's intrinsic width alone exceeds a phone, which crushed the count
|
||||
// to zero width and left the two drawn on top of each other.
|
||||
@media (max-width: 640px) {
|
||||
.category-content-header {
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
// The paginator already states "1 – 25 of 320" right next to it, and the
|
||||
// room it costs here is taken straight out of the category title.
|
||||
.category-subtitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
mat-paginator {
|
||||
min-width: 0;
|
||||
|
||||
// Dropped for the same reason as the catalog grid: it is the widest
|
||||
// part of the control and the least useful one on a phone.
|
||||
::ng-deep .mat-mdc-paginator-page-size {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -542,6 +542,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
// A phone cannot carry the poster and the details side by side: the details
|
||||
// column drops to ~200px, and the action buttons are then squeezed below
|
||||
// their own labels ("Play" clipped down to its icon). Stack them instead.
|
||||
@media (max-width: 640px) {
|
||||
.hero__content {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.poster {
|
||||
width: 140px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.details__title {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Animations
|
||||
// ============================================================================
|
||||
@@ -587,3 +607,14 @@ ngx-skeleton-loader {
|
||||
min-height: 330px; // Fallback
|
||||
aspect-ratio: 2 / 3;
|
||||
}
|
||||
|
||||
// After the rule above so it wins the cascade at equal specificity: that
|
||||
// 330px skeleton fallback is sized for the desktop poster width, while the
|
||||
// stacked phone hero renders the poster 140px wide — its aspect-ratio height
|
||||
// is ~210px, and the leftover minimum was pure empty space pushing the title
|
||||
// and actions ~120px down on every loaded detail page.
|
||||
@media (max-width: 640px) {
|
||||
.poster {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,9 @@
|
||||
align-items: baseline;
|
||||
gap: $gap;
|
||||
flex: 1;
|
||||
// `flex: 1` lets this shrink to zero width, and the nowrap subtitle then
|
||||
// painted outside the box and on top of whatever sits beside it.
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@mixin standard-panel-title($font-size: 0.95rem) {
|
||||
|
||||
@@ -178,4 +178,67 @@
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Phone layout ────────────────────────────────────────────────────────
|
||||
// Side by side, the 400px channel list leaves nothing for the player on a
|
||||
// phone, so the two stack instead. The width needs `!important` because
|
||||
// ResizableDirective writes the persisted desktop width inline; dragging
|
||||
// is meaningless at full width, so the handle goes too.
|
||||
@media (max-width: 640px) {
|
||||
:host {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
max-width: none;
|
||||
flex-shrink: 1;
|
||||
max-height: 42vh;
|
||||
// Without a floor of its own the player's minimum can take the
|
||||
// whole body on a short screen and the list vanishes while still
|
||||
// marked expanded, leaving no way to pick another channel.
|
||||
min-height: 72px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--app-separator);
|
||||
|
||||
&.sidebar-collapsed {
|
||||
max-height: 0;
|
||||
min-height: 0;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.content-container {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
// Inside that container the guide is `flex: 0 0 <basis>` while the
|
||||
// video is `flex: 1 1 0`, so shrinking is distributed by basis and
|
||||
// the video — basis zero — absorbed none of it and collapsed. Here
|
||||
// the video keeps a floor and the guide is what gives way.
|
||||
.video-player {
|
||||
min-height: 96px;
|
||||
}
|
||||
|
||||
.epg.epg--inline {
|
||||
flex-shrink: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// The player's floor, not the list's ceiling, is what has to hold — but
|
||||
// only where the screen can afford it. A landscape phone already spends
|
||||
// 30vh on the stacked categories panel, so claiming 240px more here left
|
||||
// nothing for the channel list; there, the two panes just share what is
|
||||
// left. Keyed on height because that, not width, is what runs out.
|
||||
@media (max-width: 640px) and (min-height: 600px) {
|
||||
.content-container {
|
||||
min-height: 240px;
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
-1
@@ -26,10 +26,17 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// `height: 100%` here does not resolve against a stretched flex item, so the
|
||||
// list kept its full content height and painted over the footer whenever the
|
||||
// panel was shorter than its sections (a phone, or a short desktop window).
|
||||
.settings-sections-list {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.settings-panel-footer {
|
||||
@@ -42,3 +49,22 @@
|
||||
.settings-back-button:hover:not(.active):not(.selected) {
|
||||
background: var(--app-selection-surface);
|
||||
}
|
||||
|
||||
// On a landscape phone the stacked panel gets ~108-148px, and this panel's
|
||||
// fixed chrome (title + footer) alone is about that tall — the section list
|
||||
// was collapsing to nothing behind an overlapping footer. The rail already
|
||||
// labels the page, so the caption is what gives way, and the footer sheds
|
||||
// the padding it carries for taller screens.
|
||||
@media (max-width: 640px) and (max-height: 599px) {
|
||||
.panel-title {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-panel-body {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.settings-panel-footer {
|
||||
padding: 6px 8px calc(6px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
}
|
||||
+54
@@ -57,3 +57,57 @@
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.context-panel--settings app-workspace-settings-context-panel {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Below this width the shell stacks the panel above the content instead of
|
||||
// beside it. `!important` is required because ResizableDirective writes the
|
||||
// persisted desktop width as an inline style; a 300px panel on a 375px screen
|
||||
// leaves ~50px for the actual content. Dragging is meaningless when the panel
|
||||
// spans the full width, so the handle goes away with it.
|
||||
@media (max-width: 640px) {
|
||||
.context-panel {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
// There is no global border-box reset, so the panel's own padding
|
||||
// would push a 100%-wide panel past the viewport.
|
||||
box-sizing: border-box;
|
||||
height: auto;
|
||||
// Roughly a third of the screen: enough to show several entries
|
||||
// without pushing the route's own content off the bottom.
|
||||
max-height: 30vh;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--mat-sys-outline-variant);
|
||||
}
|
||||
|
||||
.context-panel--collapsed {
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.context-panel :is(.resize-handle) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.context-panel--route,
|
||||
.context-panel--sources {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
// The settings route has no player competing for height — its content is one
|
||||
// scrollable form — so on a short landscape phone the panel can take a larger
|
||||
// share. 30vh of a 360px screen is 108px, which the panel's own footer and
|
||||
// padding consume before a single section row renders.
|
||||
@media (max-width: 640px) and (max-height: 599px) {
|
||||
.context-panel--settings {
|
||||
max-height: max(30vh, 148px);
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -55,7 +55,14 @@
|
||||
'WORKSPACE.SHELL.OPEN_COMMAND_PALETTE' | translate
|
||||
"
|
||||
>
|
||||
{{ commandShortcutLabel }}
|
||||
<!-- The shortcut label is the affordance on a keyboard; on a
|
||||
phone it means nothing, but this button is the only way
|
||||
to open the palette, so it becomes an icon rather than
|
||||
disappearing. -->
|
||||
<span class="command-trigger__shortcut">{{
|
||||
commandShortcutLabel
|
||||
}}</span>
|
||||
<mat-icon class="command-trigger__icon">bolt</mat-icon>
|
||||
</button>
|
||||
</label>
|
||||
}
|
||||
@@ -68,6 +75,7 @@
|
||||
<div class="header-actions header-actions--leading">
|
||||
<button
|
||||
type="button"
|
||||
class="shortcuts-trigger"
|
||||
mat-icon-button
|
||||
(click)="onShortcutsRequested()"
|
||||
[matTooltip]="'WORKSPACE.SHORTCUTS.OPEN_TOOLTIP' | translate"
|
||||
|
||||
+60
-3
@@ -116,6 +116,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.command-trigger__icon {
|
||||
display: none;
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.command-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -313,14 +320,64 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.workspace-header {
|
||||
padding-inline: 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.playlist-switcher {
|
||||
width: auto;
|
||||
min-width: 140px;
|
||||
flex: 1;
|
||||
// Fixed at 140px this crowded the search field off a 320px header
|
||||
// whenever a route contributed its shortcut button; the switcher is
|
||||
// the one region whose content can ellipsize, so it is what shrinks.
|
||||
min-width: 88px;
|
||||
flex: 0 1 140px;
|
||||
}
|
||||
|
||||
.search-field {
|
||||
.search-chip--status {
|
||||
// The floor is the field's own chrome — icon, palette trigger, gaps
|
||||
// and padding. Below it the contents spill onto the buttons beside
|
||||
// the field; above it the input just gets whatever space remains.
|
||||
// Zero basis, not auto: the input's intrinsic size would otherwise
|
||||
// count as content and squeeze the switcher even when room is ample.
|
||||
min-width: 74px;
|
||||
flex: 1 1 0;
|
||||
padding-inline: 8px;
|
||||
|
||||
.search-chip--status,
|
||||
.search-chip--scope {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// An <input> carries an intrinsic min-width from its `size`, and
|
||||
// `min-width: auto` on a flex item honours it — so the input refused
|
||||
// to shrink and pushed the trigger out of the field and onto the
|
||||
// buttons beside it.
|
||||
input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
// This button is the only pointer-driven way into the command
|
||||
// palette, so it stays; only its keyboard-shortcut label is swapped
|
||||
// for an icon that means something without a keyboard.
|
||||
.command-trigger {
|
||||
min-width: 28px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.command-trigger__shortcut {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.command-trigger__icon {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
.header-actions--leading {
|
||||
margin-left: 0;
|
||||
|
||||
.shortcuts-trigger {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -97,3 +97,34 @@
|
||||
background: var(--rail-link-active-icon, var(--app-selection-color));
|
||||
box-shadow: 0 0 10px var(--rail-link-active-glow, var(--app-selection-glow));
|
||||
}
|
||||
|
||||
// The rail turns into a horizontal top bar below this width, so the links
|
||||
// have to follow. Without the row direction the host keeps stacking its
|
||||
// links downwards out of the 52px bar and they overlap the header.
|
||||
@media (max-width: 640px) {
|
||||
:host {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.rail-links {
|
||||
flex-direction: row;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.portal-rail-link {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.portal-rail-link.is-active::after,
|
||||
.portal-rail-link.active::after {
|
||||
right: auto;
|
||||
top: auto;
|
||||
bottom: -3px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 18px;
|
||||
height: 3px;
|
||||
}
|
||||
}
|
||||
+45
-3
@@ -227,13 +227,39 @@
|
||||
@media (max-width: 640px) {
|
||||
.app-rail {
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--mat-sys-outline-variant);
|
||||
// A phone cannot fit every portal section at once, so the bar
|
||||
// scrolls sideways instead of squeezing the icons into overlap.
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
|
||||
&.is-macos {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
// The brand links to the dashboard, which the first workspace link
|
||||
// already does. On a bar this narrow that duplicate costs a section.
|
||||
.brand {
|
||||
margin-bottom: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
a {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
a.is-active::after,
|
||||
a.active::after {
|
||||
right: auto;
|
||||
top: auto;
|
||||
bottom: -2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 18px;
|
||||
height: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +271,10 @@
|
||||
|
||||
.rail-context-region {
|
||||
width: auto;
|
||||
// The vertical rail caps this at 48px; in a row that cap would
|
||||
// stack every portal section on top of itself.
|
||||
max-width: none;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
margin: 0;
|
||||
padding: 2px 4px;
|
||||
@@ -261,7 +291,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Settings sits at the end of a bar that scrolls sideways once a portal
|
||||
// adds its sections, so it is pinned instead of scrolling out of reach.
|
||||
.rail-footer {
|
||||
margin-top: 0;
|
||||
margin-left: auto;
|
||||
flex: 0 0 auto;
|
||||
position: sticky;
|
||||
right: 0;
|
||||
padding-left: 4px;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
var(--app-rail-bg, var(--mat-sys-surface-container-low)) 25%
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,19 @@ app-workspace-shell-context-sidebar {
|
||||
.workspace-body {
|
||||
grid-column: 1;
|
||||
grid-row: 3;
|
||||
// Side by side there is no room for both: a 300px context panel on a
|
||||
// 375px screen leaves the content unusable, so they stack instead.
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
|
||||
&.single-pane {
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-content {
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.workspace-now-playing {
|
||||
|
||||
Reference in new issue
Block a user