added ability to right click -> queue next
This commit is contained in:
parent
02e600f921
commit
adf53cadaa
61
AGENTS.md
61
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
14
channel.ts
14
channel.ts
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,7 +236,11 @@ export async function handleModifyQueue(req: Request, server: any, channelId: st
|
|||
|
||||
if (Array.isArray(add) && add.length > 0) {
|
||||
const tracks = buildTracksFromIds(add, state.library);
|
||||
channel.addTracks(tracks);
|
||||
if (typeof insertAt === "number") {
|
||||
channel.insertTracksAt(tracks, insertAt);
|
||||
} else {
|
||||
channel.addTracks(tracks);
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ success: true, queueLength: channel.queue.length });
|
||||
|
|
|
|||
Loading…
Reference in New Issue