added ability to right click -> queue next

This commit is contained in:
peterino2 2026-02-05 23:50:01 -08:00
parent 02e600f921
commit adf53cadaa
4 changed files with 148 additions and 2 deletions

View File

@ -216,3 +216,64 @@ Default port 3001 (override with `PORT` env var). Track durations read from file
- **Username**: test
- **Password**: testuser
## UI/UX Design Principles
### No Alerts/Prompts
Never use `alert()` or `prompt()`. Use inline editing, toasts, or modals instead.
- Rename actions → inline text input that appears in place
- Confirmations → toast notifications
- Errors → toast with error styling
### Inline Editing
Editable fields should transform in-place:
- Click edit icon → field becomes input
- Enter to save, Escape to cancel
- Blur (click away) saves changes
### Drag and Drop
Support drag-and-drop for reordering and moving items:
- Tracks in queue can be reordered via drag
- Tracks from library can be dragged to queue
- Visual feedback with drop indicators
### Context Menus
Right-click context menus for all actionable items:
- Tracks (both library and queue) have context menus
- Context menu options are consistent across views
- Disabled options are hidden, not grayed out
### Track Behavior Consistency
Tracks in Library and Queue should behave identically where applicable:
| Feature | Library | Queue |
|---------|---------|-------|
| Click to select | ✓ | ✓ |
| Shift+click range select | ✓ | ✓ |
| Right-click context menu | ✓ | ✓ |
| Drag to reorder | ✗ | ✓ |
| Cache status indicator | ✓ | ✓ |
### Context Menu Options by View
**Library tracks:**
- ▶ Play track (local mode, single track)
- ⏭ Play next (insert after current)
- Add to queue (append to end)
**Queue tracks:**
- ▶ Play track (jump to track)
- ⏭ Play next (re-add after current)
- Add again (duplicate at end)
- ✕ Remove from queue
### Mobile/Touch Support
- Larger touch targets (min 44px)
- No hover-dependent features (always show action buttons)
- Tab-based navigation for panels
- Sticky headers for scrollable lists
### Responsive Layout
- Desktop: side-by-side panels (Channels | Library | Queue)
- Mobile (<768px): tab-switched single panel view
- Player bar adapts: horizontal on desktop, stacked on mobile

View File

@ -247,6 +247,20 @@ export class Channel {
this.broadcast();
}
insertTracksAt(tracks: Track[], position: number) {
if (tracks.length === 0) return;
// Clamp position to valid range
const insertPos = Math.max(0, Math.min(position, this.queue.length));
this.queue.splice(insertPos, 0, ...tracks);
// Adjust currentIndex if insertion is at or before current
if (insertPos <= this.currentIndex) {
this.currentIndex += tracks.length;
}
this.queueDirty = true;
this.persistQueue();
this.broadcast();
}
removeTracksByIndex(indices: number[]) {
if (indices.length === 0) return;

View File

@ -593,6 +593,50 @@
// Remove track(s) option (if user can edit)
if (canEdit) {
// Get track IDs for the selected indices
const idsToAdd = hasSelection
? [...M.selectedQueueIndices].map(idx => M.queue[idx]?.id).filter(Boolean)
: [trackId];
// Add again option - duplicate tracks at end of queue
const addAgainLabel = selectedCount > 1 ? ` Add ${selectedCount} again` : " Add again";
menuItems.push({
label: addAgainLabel,
action: async () => {
if (!M.currentChannelId) return;
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ add: idsToAdd })
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
M.showToast(selectedCount > 1 ? `Added ${selectedCount} tracks again` : "Track added again");
M.clearSelections();
}
}
});
// Play next option - insert after current track
const playNextLabel = selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next";
menuItems.push({
label: playNextLabel,
action: async () => {
if (!M.currentChannelId) return;
const insertAt = (M.currentIndex ?? 0) + 1;
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ add: idsToAdd, insertAt })
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
M.showToast(selectedCount > 1 ? `${selectedCount} tracks playing next` : "Track playing next");
M.clearSelections();
}
}
});
const label = selectedCount > 1 ? `✕ Remove ${selectedCount} tracks` : "✕ Remove track";
menuItems.push({
label,
@ -820,6 +864,29 @@
}
}
});
// Play next option - insert after current track
const playNextLabel = selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next";
menuItems.push({
label: playNextLabel,
action: async () => {
if (!M.currentChannelId) {
M.showToast("No channel selected");
return;
}
const insertAt = (M.currentIndex ?? 0) + 1;
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ add: idsToAdd, insertAt })
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
M.showToast(selectedCount > 1 ? `${selectedCount} tracks playing next` : "Track playing next");
M.clearSelections();
}
}
});
}
// Preload track(s) option - only show if not in stream-only mode

View File

@ -221,7 +221,7 @@ export async function handleModifyQueue(req: Request, server: any, channelId: st
try {
const body = await req.json();
const { add, remove, set } = body;
const { add, remove, set, insertAt } = body;
if (Array.isArray(set)) {
const tracks = buildTracksFromIds(set, state.library);
@ -236,8 +236,12 @@ export async function handleModifyQueue(req: Request, server: any, channelId: st
if (Array.isArray(add) && add.length > 0) {
const tracks = buildTracksFromIds(add, state.library);
if (typeof insertAt === "number") {
channel.insertTracksAt(tracks, insertAt);
} else {
channel.addTracks(tracks);
}
}
return Response.json({ success: true, queueLength: channel.queue.length });
} catch {