refactor(playlist-switcher): add context actions for playlist and account info

Entire-Checkpoint: 0e4488af6c76
This commit is contained in:
4gray committed 2026-03-22 18:38:11 +01:00
1 parent e579c21486
commit eaaf634b17
6 files changed
+149 -60

No files matched your search

@@ -137,4 +137,32 @@
</div>
}
</div>
@if (showPlaylistInfo() || showAccountInfo()) {
<mat-divider />
<div class="context-actions-section">
@if (showPlaylistInfo()) {
<button
class="context-action-item"
(click)="playlistInfoRequested.emit()"
>
<mat-icon>playlist_play</mat-icon>
<span>{{
'WORKSPACE.SHELL.PLAYLIST_INFO' | translate
}}</span>
</button>
}
@if (showAccountInfo()) {
<button
class="context-action-item"
(click)="accountInfoRequested.emit()"
>
<mat-icon>person</mat-icon>
<span>{{
'WORKSPACE.SHELL.ACCOUNT_INFO' | translate
}}</span>
</button>
}
</div>
}
</mat-menu>
@@ -371,6 +371,40 @@
}
}
.context-actions-section {
padding: 6px 8px 8px;
.context-action-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--mat-sys-on-surface-variant);
font: inherit;
font-size: 0.86em;
font-weight: 500;
cursor: pointer;
transition:
background 0.15s ease,
color 0.15s ease;
&:hover {
background: var(--mat-sys-surface-container);
color: var(--mat-sys-on-surface);
}
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
}
}
@media only screen and (max-width: 480px) {
.playlist-info {
max-width: 140px;
@@ -15,6 +15,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms';
import { MatIconButton } from '@angular/material/button';
import { MatRippleModule } from '@angular/material/core';
import { MatDivider } from '@angular/material/divider';
import { MatIcon } from '@angular/material/icon';
import { MatInputModule } from '@angular/material/input';
import { MatMenu, MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
@@ -92,6 +93,7 @@ const M3U_SECTIONS = ['all', 'groups', 'favorites', 'recent'] as const;
imports: [
DatePipe,
FormsModule,
MatDivider,
MatIcon,
MatIconButton,
MatInputModule,
@@ -112,8 +114,16 @@ export class PlaylistSwitcherComponent {
readonly currentTitle = input.required<string>();
/** Subtitle to display (e.g., "123 Channels" or "Xtream Code") */
readonly subtitle = input<string>('');
/** Whether the "Playlist Info" action is available */
readonly showPlaylistInfo = input(false);
/** Whether the "Account Info" action is available */
readonly showAccountInfo = input(false);
/** Emitted when a different playlist is selected */
readonly playlistSelected = output<string>();
/** Emitted when user clicks "Playlist Info" */
readonly playlistInfoRequested = output<void>();
/** Emitted when user clicks "Account Info" */
readonly accountInfoRequested = output<void>();
readonly menuTrigger = viewChild.required<MatMenuTrigger>('menuTrigger');
readonly playlistMenu = viewChild.required<MatMenu>('playlistMenu');
@@ -21,6 +21,13 @@
@if (primaryContextLinks().length > 0) {
<div class="rail-divider"></div>
@if (railPlaylistLabel(); as label) {
<span
class="rail-context-label"
[matTooltip]="label"
[matTooltipPosition]="'right'"
>{{ label }}</span>
}
<app-portal-rail-links
variant="rail"
activeClass="is-active"
@@ -58,6 +65,10 @@
class="playlist-switcher"
[currentTitle]="playlistTitle()"
[subtitle]="playlistSubtitle()"
[showPlaylistInfo]="canOpenPlaylistInfo()"
[showAccountInfo]="canOpenAccountInfo()"
(playlistInfoRequested)="openPlaylistInfo()"
(accountInfoRequested)="openAccountInfo()"
/>
<label class="search-field">
@@ -103,7 +114,7 @@
/>
<span class="header-actions-divider" aria-hidden="true"></span>
<a
class="header-shortcut"
class="header-shortcut header-shortcut--global"
mat-icon-button
routerLink="/workspace/global-favorites"
[class.is-active]="isGlobalFavoritesRoute()"
@@ -141,71 +152,20 @@
<mat-icon>download</mat-icon>
</button>
}
@if (hasContextActions()) {
@if (headerBulkAction(); as action) {
<span class="header-actions-divider" aria-hidden="true"></span>
<button
type="button"
mat-icon-button
[matMenuTriggerFor]="contextActionsMenu"
[matTooltip]="
'WORKSPACE.SHELL.RAIL_CONTEXT_ACTIONS' | translate
"
[attr.aria-label]="
'WORKSPACE.SHELL.OPEN_CONTEXT_ACTIONS' | translate
"
(click)="runHeaderBulkAction()"
[disabled]="action.disabled"
[matTooltip]="action.tooltip"
[attr.aria-label]="action.ariaLabel"
>
<mat-icon>more_horiz</mat-icon>
<mat-icon>{{ action.icon }}</mat-icon>
</button>
}
</div>
<mat-menu #contextActionsMenu="matMenu">
@if (!hasContextActions()) {
<button mat-menu-item disabled>
<span>{{
'WORKSPACE.SHELL.NO_SECTION_ACTIONS' | translate
}}</span>
</button>
} @else {
@if (contextActionGroups().hasPlaylistActions) {
@if (canOpenPlaylistInfo()) {
<button mat-menu-item (click)="openPlaylistInfo()">
<mat-icon>playlist_play</mat-icon>
<span>{{
'WORKSPACE.SHELL.PLAYLIST_INFO' | translate
}}</span>
</button>
}
@if (canOpenAccountInfo()) {
<button mat-menu-item (click)="openAccountInfo()">
<mat-icon>person</mat-icon>
<span>{{
'WORKSPACE.SHELL.ACCOUNT_INFO' | translate
}}</span>
</button>
}
}
@if (
contextActionGroups().hasCleanupActions &&
contextActionGroups().hasPlaylistActions
) {
<mat-divider />
}
@if (headerBulkAction(); as action) {
<button
mat-menu-item
(click)="runHeaderBulkAction()"
[disabled]="action.disabled"
[attr.aria-label]="action.ariaLabel"
>
<mat-icon>{{ action.icon }}</mat-icon>
<span>{{ action.tooltip }}</span>
</button>
}
}
</mat-menu>
</header>
<div class="workspace-body" [class.single-pane]="!showContextPanel()">
@@ -137,6 +137,37 @@
object-fit: contain;
}
// ─── Rail Context Label ──────────────────────────────────────────────────────
.rail-context-label {
font-size: 9px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--mat-sys-on-surface-variant);
opacity: 0.55;
max-width: 44px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 2px;
margin-bottom: 2px;
cursor: default;
app-region: no-drag;
animation: rail-label-enter 0.2s ease-out both;
}
@keyframes rail-label-enter {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 0.55;
transform: translateY(0);
}
}
// ─── Rail Divider ─────────────────────────────────────────────────────────────
.rail-divider {
width: 44px;
@@ -323,6 +354,24 @@
}
}
.header-shortcut--global {
position: relative;
&::before {
content: '';
position: absolute;
bottom: 5px;
right: 5px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--mat-sys-tertiary);
border: 1.5px solid
var(--app-header-bg, var(--mat-sys-surface-container-low));
z-index: 1;
}
}
.header-actions-divider {
width: 1px;
height: 20px;
@@ -533,6 +582,10 @@
margin: 0 4px;
}
.rail-context-label {
display: none;
}
.rail-footer {
margin-top: 0;
}
@@ -13,7 +13,6 @@ import {
} from '@angular/core/rxjs-interop';
import { MatIconButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatDividerModule } from '@angular/material/divider';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltip } from '@angular/material/tooltip';
@@ -98,7 +97,6 @@ const SEARCH_INPUT_DEBOUNCE_MS = 350;
imports: [
MatIcon,
MatIconButton,
MatDividerModule,
MatMenuModule,
MatTooltip,
AddPlaylistMenuComponent,
@@ -365,6 +363,12 @@ export class WorkspaceShellComponent {
return 'WORKSPACE.SHELL.SEARCH_PLAYLIST_PLACEHOLDER';
});
readonly railPlaylistLabel = computed<string | null>(() => {
const context = this.railContext();
if (!context) return null;
const active = this.activePlaylist();
return active?.title || active?.filename || null;
});
readonly primaryContextLinks = computed<PortalRailLink[]>(() => {
this.languageTick();