Skip to content

Commit 2bd02f1

Browse files
committed
folder options synchronized
1 parent d8019f6 commit 2bd02f1

11 files changed

Lines changed: 216 additions & 288 deletions

frontend/nextcp-ui/src/app/mediaserver/display-container/container-tile/container-tile.component.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,17 @@
2121
<span class="likeBadge material-symbols-outlined" aria-label="Liked">favorite</span>
2222
}
2323
<!--
24-
Makes the rating sheet findable. On the artwork, like Roon's overflow
24+
Makes the options menu findable. On the artwork, like Roon's overflow
2525
menu — the biggest target on the tile, and it leaves the title its full
2626
width. Unlike Roon it does not wait for hover: the main target here is a
2727
tablet, where the long press was the only way in and nothing announced
2828
it.
2929
-->
30-
@if (ratingPossible()) {
30+
@if (optionsPossible()) {
3131
<button type="button" class="tileOptions" (click)="openOptions($event, container)"
3232
(pointerdown)="$event.stopPropagation()"
33-
[title]="'Rate ' + container.title"
34-
[attr.aria-label]="'Rate ' + container.title">
33+
[title]="'Options for ' + container.title"
34+
[attr.aria-label]="'Options for ' + container.title">
3535
<span class="material-symbols-outlined">more_horiz</span>
3636
</button>
3737
}

frontend/nextcp-ui/src/app/mediaserver/display-container/container-tile/container-tile.component.ts

Lines changed: 75 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ import { DeviceService } from 'src/app/service/device.service';
1919
import {
2020
RATING_LIKED,
2121
RatingFilter,
22+
RatingServiceService,
2223
} from 'src/app/service/rating-service.service';
2324
import { filterContainers } from 'src/app/util/browse-filter';
2425
import { AlbumArtService } from 'src/app/util/album-art.service';
25-
import { ContainerRatingComponent } from '../../popup/container-rating/container-rating.component';
26+
import { DisplayHeaderOptionsComponent } from '../../popup/display-header-options/display-header-options.component';
2627

2728
@Component({
2829
selector: 'container-tile',
@@ -54,25 +55,33 @@ export class ContainerTileComponent {
5455
);
5556

5657
browseClicked = output<ContainerDto>();
58+
/** Handed to the options menu, which emits on it - the tile itself has no queue to add to. */
59+
addToPlaylistClicked = output<ContainerDto>();
5760

5861
//
59-
// Rating by long press
62+
// Options menu, by long press or by the tile's options button
6063
// ============================================================================
6164
// A tap on a tile navigates into the container, so the only spare gesture for
62-
// rating is a long press. The press is cancelled as soon as the finger moves,
65+
// the menu is a long press. The press is cancelled as soon as the finger moves,
6366
// otherwise it would fire while scrolling the grid.
6467

6568
private readonly dialog = inject(MatDialog);
6669
readonly albumArt = inject(AlbumArtService);
6770
private readonly deviceService = inject(DeviceService);
71+
private readonly ratingService = inject(RatingServiceService);
6872
private readonly LONG_PRESS_MS = 500;
6973
private readonly MOVE_TOLERANCE_PX = 10;
7074

7175
private pressTimer: ReturnType<typeof setTimeout> | undefined;
7276
private pressStart: { x: number; y: number } | undefined;
7377
private pressHandled = false;
7478

75-
ratingPossible(): boolean {
79+
/**
80+
* Everything the menu offers - the like, the radio station, the album art - is an extension of
81+
* the standard the media server either has or has not. Without it the button would open a menu
82+
* with nothing in it that works.
83+
*/
84+
optionsPossible(): boolean {
7685
return this.deviceService.selectedMediaServerDevice().extendedApi;
7786
}
7887

@@ -100,15 +109,16 @@ export class ContainerTileComponent {
100109
}
101110

102111
onPressStart(event: PointerEvent, container: ContainerDto): void {
103-
if (!this.ratingPossible()) {
112+
if (!this.optionsPossible()) {
104113
return;
105114
}
106115
this.cancelPress();
107116
this.pressHandled = false;
108117
this.pressStart = { x: event.clientX, y: event.clientY };
118+
const trigger = event.currentTarget;
109119
this.pressTimer = setTimeout(() => {
110120
this.pressHandled = true;
111-
this.openRatingDialog(container);
121+
this.openOptionsMenu(container, trigger);
112122
}, this.LONG_PRESS_MS);
113123
}
114124

@@ -136,31 +146,78 @@ export class ContainerTileComponent {
136146
}
137147

138148
/**
139-
* Same sheet as the long press, reached by tapping the tile's options button.
149+
* Same menu as the long press, reached by tapping the tile's options button.
140150
* Stops the event so the tile does not also navigate into the container.
141151
*/
142152
openOptions(event: Event, container: ContainerDto): void {
143153
event.stopPropagation();
144154
event.preventDefault();
145155
this.cancelPress();
146-
this.openRatingDialog(container);
156+
this.openOptionsMenu(container, event.currentTarget);
147157
}
148158

149-
private openRatingDialog(container: ContainerDto): void {
150-
const dialogRef = this.dialog.open(ContainerRatingComponent, {
151-
data: { container: container, rating: this.effectiveRating(container) },
152-
panelClass: ['popup', 'popup-glass'],
159+
/**
160+
* The menu the header opens for the container being browsed, on the tile of one that is not -
161+
* the two offer the same actions, so there is one component for both.
162+
*
163+
* The menu places itself next to what it came out of, so the trigger goes along: the options
164+
* button for a tap, the tile for a long press.
165+
*/
166+
private openOptionsMenu(container: ContainerDto, trigger: EventTarget | null): void {
167+
const dialogRef = this.dialog.open(DisplayHeaderOptionsComponent, {
168+
hasBackdrop: true,
169+
panelClass: ['popup-glass'],
170+
data: {
171+
trigger: new ElementRef(trigger),
172+
addToPlaylistOutput: this.addToPlaylistClicked,
173+
currentContainer: container,
174+
// What is on screen is the container this one is listed in, so that is what a change here
175+
// has to re-read.
176+
listingContainerId: container.parentID,
177+
canLike: this.optionsPossible(),
178+
isLiked: this.isLiked(container),
179+
// A tile knows the container, not what is in it - and "Set artist folder" is decided by the
180+
// number of folders inside. It stays with the header of the folder itself.
181+
childFolderCount: 0,
182+
},
153183
});
154-
dialogRef.afterClosed().subscribe((newRating) => {
155-
if (newRating !== undefined) {
156-
// Reflect the new state on the tile without re-browsing.
157-
const next = new Map(this.ratingOverrides());
158-
next.set(container.id, newRating === null ? undefined : newRating);
159-
this.ratingOverrides.set(next);
184+
// The menu only reports the choice; the rating call stays with the view that holds the entry.
185+
dialogRef.afterClosed().subscribe((result) => {
186+
if (result === 'toggleLike') {
187+
this.toggleLike(container);
160188
}
161189
});
162190
}
163191

192+
/**
193+
* Likes the container, or takes the like back - which clears the rating rather than storing a
194+
* dislike. Same two states as everywhere else in the app.
195+
*/
196+
private toggleLike(container: ContainerDto): void {
197+
if (!this.optionsPossible()) {
198+
return;
199+
}
200+
const previousRating = this.effectiveRating(container);
201+
const newRating = this.isLiked(container) ? undefined : RATING_LIKED;
202+
this.ratingService
203+
.setResourceRating(
204+
container.id,
205+
previousRating,
206+
newRating,
207+
container.parentID,
208+
container.objectClass,
209+
)
210+
.subscribe({
211+
next: () => {
212+
// Reflect the new state on the tile without re-browsing.
213+
const next = new Map(this.ratingOverrides());
214+
next.set(container.id, newRating);
215+
this.ratingOverrides.set(next);
216+
},
217+
error: (err) => console.error('cannot rate container', err),
218+
});
219+
}
220+
164221
//
165222
// Virtual scrolling (windowing)
166223
// ============================================================================

frontend/nextcp-ui/src/app/mediaserver/display-container/display-container-header/display-container-header.component.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ export class DisplayContainerHeaderComponent implements OnInit {
7070
// Kept short on purpose: a digit plus the heart says it without a sentence. ANY
7171
// has no digit, it is the switched-off state and carries an icon instead. The
7272
// trailing plus is what tells the reader the entry is a lower bound; 5 has none
73-
// because there is nothing above it, and 0 is the disliked bucket, not a bound.
73+
// because there is nothing above it, and 0 is what carries no rating, not a bound.
7474
readonly ratingOptions: ReadonlyArray<{
7575
value: RatingFilter;
7676
digits?: string;
@@ -83,7 +83,7 @@ export class DisplayContainerHeaderComponent implements OnInit {
8383
{ value: '3', digits: '3+', title: '3 and better' },
8484
{ value: '2', digits: '2+', title: '2 and better' },
8585
{ value: '1', digits: '1+', title: '1 and better' },
86-
{ value: '0', digits: '0', title: 'Disliked, 0' },
86+
{ value: '0', digits: '0', title: 'Not rated' },
8787
];
8888

8989
ratingOption = computed(
@@ -721,7 +721,6 @@ export class DisplayContainerHeaderComponent implements OnInit {
721721
data: {
722722
trigger: target,
723723
addToPlaylistOutput: this.addToPlaylistClicked,
724-
event: event,
725724
currentContainer: this.currentContainer,
726725
// The like only shows up here for containers that do not wear it next
727726
// to their title (see showTitleLike).

frontend/nextcp-ui/src/app/mediaserver/display-container/display-container.component.html

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@
4848
[quickSearchString]="displayFilterString()"
4949
[selectedGenres]="selectedGenres()"
5050
[ratingFilter]="ratingFilter()"
51-
(browseClicked)="browseTo($event)">
51+
(browseClicked)="browseTo($event)"
52+
(addToPlaylistClicked)="addContainerToPlaylist($event)">
5253
</container-tile>
5354
</div>
5455
}
@@ -70,7 +71,8 @@
7071
[ratingFilter]="ratingFilter()"
7172
[sortCriteria]="sortCriteria()"
7273
[virtualize]="true"
73-
(browseClicked)="browseTo($event)">
74+
(browseClicked)="browseTo($event)"
75+
(addToPlaylistClicked)="addContainerToPlaylist($event)">
7476
</container-tile>
7577
</div>
7678
}
@@ -91,7 +93,8 @@
9193
[selectedGenres]="selectedGenres()"
9294
[ratingFilter]="ratingFilter()"
9395
[virtualize]="true"
94-
(browseClicked)="browseTo($event)">
96+
(browseClicked)="browseTo($event)"
97+
(addToPlaylistClicked)="addContainerToPlaylist($event)">
9598
</container-tile>
9699
</div>
97100
}
@@ -134,7 +137,8 @@
134137
[ratingFilter]="ratingFilter()"
135138
[quickSearchString]="displayFilterString()"
136139
[virtualize]="true"
137-
(browseClicked)="browseTo($event)">
140+
(browseClicked)="browseTo($event)"
141+
(addToPlaylistClicked)="addContainerToPlaylist($event)">
138142
</container-tile>
139143
</div>
140144
}

frontend/nextcp-ui/src/app/mediaserver/display-container/display-container.component.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -517,6 +517,15 @@ export class DisplayContainerComponent {
517517
);
518518
}
519519

520+
/**
521+
* Adds a container from the listing - not the one on screen - to the renderer's queue. It goes as
522+
* it stands on the server: the quick filter narrows the tracks of the container being browsed,
523+
* which says nothing about what belongs in another one.
524+
*/
525+
addContainerToPlaylist(container: ContainerDto): void {
526+
this.playlistService.addContainerToPlaylist(container);
527+
}
528+
520529
addItemToPlaylist(item: MusicItemDto): void {
521530
this.playlistService.addToPlaylist(item);
522531
}

frontend/nextcp-ui/src/app/mediaserver/popup/container-rating/container-rating.component.html

Lines changed: 0 additions & 42 deletions
This file was deleted.

frontend/nextcp-ui/src/app/mediaserver/popup/container-rating/container-rating.component.scss

Lines changed: 0 additions & 92 deletions
This file was deleted.

0 commit comments

Comments
 (0)