Merge remote-tracking branch 'origin/master' into agent/xtream-vod-download-reliability

This commit is contained in:
4gray committed 2026-08-01 20:47:09 +02:00
commit b3c3119070
22 files changed
+574 -24

No files matched your search

+10
View File
@@ -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;
+49 -3
View File
@@ -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();
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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;
}
}
+3
View File
@@ -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) {
+63
View File
@@ -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;
}
}
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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"
@@ -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;
}
}
@@ -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;
}
}
@@ -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 {