feat(queue): drag and drop reorder
This commit is contained in:
@@ -15,6 +15,7 @@ service Player {
|
||||
rpc SetVolume(SetVolumeRequest) returns (SetVolumeResponse);
|
||||
rpc PlayTrackNext(TrackRequest) returns (Queue);
|
||||
rpc AddTrackToQueue(TrackRequest) returns (Queue);
|
||||
rpc SwapQueueIndices(SwapQueueIndicesRequest) returns (Queue);
|
||||
rpc SkipTrack(google.protobuf.Empty) returns (PlayerStatus);
|
||||
rpc SkipToQueueIndex(SkipToQueueIndexRequest) returns (PlayerStatus);
|
||||
}
|
||||
@@ -63,3 +64,8 @@ message SetVolumeResponse {
|
||||
message SkipToQueueIndexRequest {
|
||||
uint32 index = 1;
|
||||
}
|
||||
|
||||
message SwapQueueIndicesRequest {
|
||||
uint32 a = 1;
|
||||
uint32 b = 2;
|
||||
}
|
||||
|
||||
@@ -77,5 +77,5 @@
|
||||
}
|
||||
|
||||
* {
|
||||
-webkit-user-drag: none !important;
|
||||
/* -webkit-user-drag: none !important; */
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
import { getCoverUrl } from '$lib/covers';
|
||||
import VolumeSlider from '$lib/components/groove/VolumeSlider.svelte';
|
||||
import type { SubmitFunction } from '../../../routes/player/$types';
|
||||
import Queue from './Queue.svelte';
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
@@ -133,35 +134,7 @@
|
||||
|
||||
<Separator class="my-1" />
|
||||
|
||||
<form
|
||||
class="flex h-full flex-col gap-1 overflow-y-auto pr-3"
|
||||
method="POST"
|
||||
use:enhance={submitPlayerAction}
|
||||
>
|
||||
{#each player.queue as track, i}
|
||||
<button
|
||||
type="submit"
|
||||
class="flex flex-row items-center gap-2 rounded-lg px-3 py-2 transition-all hover:bg-secondary"
|
||||
formaction="/player?/skip-to-queue-index&index={i}"
|
||||
>
|
||||
<div class="min-w-8 overflow-hidden rounded-md">
|
||||
<img src={getCoverUrl(track.hash)} class="aspect-square size-8" alt="Cover" />
|
||||
</div>
|
||||
<div class="flex flex-col overflow-hidden">
|
||||
<p
|
||||
class="w-full self-start overflow-hidden text-ellipsis text-nowrap text-left text-sm text-foreground/80"
|
||||
>
|
||||
{track.name}
|
||||
</p>
|
||||
<p
|
||||
class="w-full self-start overflow-hidden text-left text-xs text-muted-foreground"
|
||||
>
|
||||
{track.artistName}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</form>
|
||||
<Queue />
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
|
||||
108
src/lib/components/groove/Queue.svelte
Normal file
108
src/lib/components/groove/Queue.svelte
Normal file
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { deserialize, enhance } from '$app/forms';
|
||||
import { getCoverUrl } from '$lib/covers';
|
||||
import { getPlayerState } from '$lib/player.svelte';
|
||||
import { flip } from 'svelte/animate';
|
||||
import type { SubmitFunction } from '../../../routes/player/$types';
|
||||
import type { Track } from '$lib/proto/library';
|
||||
|
||||
const player = getPlayerState();
|
||||
|
||||
const submitPlayerAction: SubmitFunction = async () => {
|
||||
return async ({ update, result }) => {
|
||||
await update({
|
||||
invalidateAll: false
|
||||
});
|
||||
|
||||
if (result.type !== 'success' || !result.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('isPaused' in result.data) {
|
||||
if (!('volume' in result.data)) {
|
||||
player.isPaused = result.data.isPaused;
|
||||
} else {
|
||||
player.applyStatus(result.data);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
function onDragStart(e: DragEvent) {
|
||||
const element = e.target as HTMLElement;
|
||||
|
||||
e.dataTransfer?.setData('text/plain', element.getAttribute('data-queue-index')!);
|
||||
}
|
||||
|
||||
async function onDrop(e: DragEvent) {
|
||||
const aIndex = e.dataTransfer?.getData('text/plain')!;
|
||||
const bIndex = (e.target as HTMLElement).getAttribute('data-queue-index')!;
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
formData.set('a', aIndex);
|
||||
formData.set('b', bIndex);
|
||||
|
||||
const response = await fetch(`/player?/swap-queue-indices`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-sveltekit-action': 'true'
|
||||
},
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = deserialize(await response.text());
|
||||
|
||||
if (result.type === 'success' && result.data && 'tracks' in result.data) {
|
||||
player.queue = result.data.tracks as Track[];
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<form
|
||||
class="flex h-full flex-col gap-1 overflow-y-auto pr-3"
|
||||
method="POST"
|
||||
use:enhance={submitPlayerAction}
|
||||
>
|
||||
{#each player.queue as track, i (track.hash)}
|
||||
<button
|
||||
animate:flip={{ duration: 100 }}
|
||||
type="submit"
|
||||
class="flex flex-row items-center gap-2 rounded-lg px-3 py-2 transition-all hover:bg-secondary"
|
||||
formaction="/player?/skip-to-queue-index&index={i}"
|
||||
draggable={true}
|
||||
ondragstart={onDragStart}
|
||||
ondrop={onDrop}
|
||||
ondragover={(e) => e.preventDefault()}
|
||||
data-queue-index={i}
|
||||
>
|
||||
<div class="min-w-8 overflow-hidden rounded-md">
|
||||
<img src={getCoverUrl(track.hash)} class="aspect-square size-8" alt="Cover" />
|
||||
</div>
|
||||
<div class="flex flex-col overflow-hidden">
|
||||
<p
|
||||
class="w-full self-start overflow-hidden text-ellipsis text-nowrap text-left text-sm text-foreground/80"
|
||||
>
|
||||
{track.name}
|
||||
</p>
|
||||
<p class="w-full self-start overflow-hidden text-left text-xs text-muted-foreground">
|
||||
{track.artistName}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</form>
|
||||
|
||||
<style>
|
||||
button {
|
||||
-webkit-user-drag: element !important;
|
||||
}
|
||||
|
||||
button > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@ import type { RpcTransport } from "@protobuf-ts/runtime-rpc";
|
||||
import type { ServiceInfo } from "@protobuf-ts/runtime-rpc";
|
||||
import { Player } from "./player";
|
||||
import type { SkipToQueueIndexRequest } from "./player";
|
||||
import type { SwapQueueIndicesRequest } from "./player";
|
||||
import type { Queue } from "./player";
|
||||
import type { SetVolumeResponse } from "./player";
|
||||
import type { SetVolumeRequest } from "./player";
|
||||
@@ -59,6 +60,10 @@ export interface IPlayerClient {
|
||||
* @generated from protobuf rpc: AddTrackToQueue(player.TrackRequest) returns (player.Queue);
|
||||
*/
|
||||
addTrackToQueue(input: TrackRequest, options?: RpcOptions): UnaryCall<TrackRequest, Queue>;
|
||||
/**
|
||||
* @generated from protobuf rpc: SwapQueueIndices(player.SwapQueueIndicesRequest) returns (player.Queue);
|
||||
*/
|
||||
swapQueueIndices(input: SwapQueueIndicesRequest, options?: RpcOptions): UnaryCall<SwapQueueIndicesRequest, Queue>;
|
||||
/**
|
||||
* @generated from protobuf rpc: SkipTrack(google.protobuf.Empty) returns (player.PlayerStatus);
|
||||
*/
|
||||
@@ -140,18 +145,25 @@ export class PlayerClient implements IPlayerClient, ServiceInfo {
|
||||
const method = this.methods[8], opt = this._transport.mergeOptions(options);
|
||||
return stackIntercept<TrackRequest, Queue>("unary", this._transport, method, opt, input);
|
||||
}
|
||||
/**
|
||||
* @generated from protobuf rpc: SwapQueueIndices(player.SwapQueueIndicesRequest) returns (player.Queue);
|
||||
*/
|
||||
swapQueueIndices(input: SwapQueueIndicesRequest, options?: RpcOptions): UnaryCall<SwapQueueIndicesRequest, Queue> {
|
||||
const method = this.methods[9], opt = this._transport.mergeOptions(options);
|
||||
return stackIntercept<SwapQueueIndicesRequest, Queue>("unary", this._transport, method, opt, input);
|
||||
}
|
||||
/**
|
||||
* @generated from protobuf rpc: SkipTrack(google.protobuf.Empty) returns (player.PlayerStatus);
|
||||
*/
|
||||
skipTrack(input: Empty, options?: RpcOptions): UnaryCall<Empty, PlayerStatus> {
|
||||
const method = this.methods[9], opt = this._transport.mergeOptions(options);
|
||||
const method = this.methods[10], opt = this._transport.mergeOptions(options);
|
||||
return stackIntercept<Empty, PlayerStatus>("unary", this._transport, method, opt, input);
|
||||
}
|
||||
/**
|
||||
* @generated from protobuf rpc: SkipToQueueIndex(player.SkipToQueueIndexRequest) returns (player.PlayerStatus);
|
||||
*/
|
||||
skipToQueueIndex(input: SkipToQueueIndexRequest, options?: RpcOptions): UnaryCall<SkipToQueueIndexRequest, PlayerStatus> {
|
||||
const method = this.methods[10], opt = this._transport.mergeOptions(options);
|
||||
const method = this.methods[11], opt = this._transport.mergeOptions(options);
|
||||
return stackIntercept<SkipToQueueIndexRequest, PlayerStatus>("unary", this._transport, method, opt, input);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +123,19 @@ export interface SkipToQueueIndexRequest {
|
||||
*/
|
||||
index: number;
|
||||
}
|
||||
/**
|
||||
* @generated from protobuf message player.SwapQueueIndicesRequest
|
||||
*/
|
||||
export interface SwapQueueIndicesRequest {
|
||||
/**
|
||||
* @generated from protobuf field: uint32 a = 1;
|
||||
*/
|
||||
a: number;
|
||||
/**
|
||||
* @generated from protobuf field: uint32 b = 2;
|
||||
*/
|
||||
b: number;
|
||||
}
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class TrackRequest$Type extends MessageType<TrackRequest> {
|
||||
constructor() {
|
||||
@@ -631,6 +644,61 @@ class SkipToQueueIndexRequest$Type extends MessageType<SkipToQueueIndexRequest>
|
||||
* @generated MessageType for protobuf message player.SkipToQueueIndexRequest
|
||||
*/
|
||||
export const SkipToQueueIndexRequest = new SkipToQueueIndexRequest$Type();
|
||||
// @generated message type with reflection information, may provide speed optimized methods
|
||||
class SwapQueueIndicesRequest$Type extends MessageType<SwapQueueIndicesRequest> {
|
||||
constructor() {
|
||||
super("player.SwapQueueIndicesRequest", [
|
||||
{ no: 1, name: "a", kind: "scalar", T: 13 /*ScalarType.UINT32*/ },
|
||||
{ no: 2, name: "b", kind: "scalar", T: 13 /*ScalarType.UINT32*/ }
|
||||
]);
|
||||
}
|
||||
create(value?: PartialMessage<SwapQueueIndicesRequest>): SwapQueueIndicesRequest {
|
||||
const message = globalThis.Object.create((this.messagePrototype!));
|
||||
message.a = 0;
|
||||
message.b = 0;
|
||||
if (value !== undefined)
|
||||
reflectionMergePartial<SwapQueueIndicesRequest>(this, message, value);
|
||||
return message;
|
||||
}
|
||||
internalBinaryRead(reader: IBinaryReader, length: number, options: BinaryReadOptions, target?: SwapQueueIndicesRequest): SwapQueueIndicesRequest {
|
||||
let message = target ?? this.create(), end = reader.pos + length;
|
||||
while (reader.pos < end) {
|
||||
let [fieldNo, wireType] = reader.tag();
|
||||
switch (fieldNo) {
|
||||
case /* uint32 a */ 1:
|
||||
message.a = reader.uint32();
|
||||
break;
|
||||
case /* uint32 b */ 2:
|
||||
message.b = reader.uint32();
|
||||
break;
|
||||
default:
|
||||
let u = options.readUnknownField;
|
||||
if (u === "throw")
|
||||
throw new globalThis.Error(`Unknown field ${fieldNo} (wire type ${wireType}) for ${this.typeName}`);
|
||||
let d = reader.skip(wireType);
|
||||
if (u !== false)
|
||||
(u === true ? UnknownFieldHandler.onRead : u)(this.typeName, message, fieldNo, wireType, d);
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
internalBinaryWrite(message: SwapQueueIndicesRequest, writer: IBinaryWriter, options: BinaryWriteOptions): IBinaryWriter {
|
||||
/* uint32 a = 1; */
|
||||
if (message.a !== 0)
|
||||
writer.tag(1, WireType.Varint).uint32(message.a);
|
||||
/* uint32 b = 2; */
|
||||
if (message.b !== 0)
|
||||
writer.tag(2, WireType.Varint).uint32(message.b);
|
||||
let u = options.writeUnknownFields;
|
||||
if (u !== false)
|
||||
(u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer);
|
||||
return writer;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* @generated MessageType for protobuf message player.SwapQueueIndicesRequest
|
||||
*/
|
||||
export const SwapQueueIndicesRequest = new SwapQueueIndicesRequest$Type();
|
||||
/**
|
||||
* @generated ServiceType for protobuf service player.Player
|
||||
*/
|
||||
@@ -644,6 +712,7 @@ export const Player = new ServiceType("player.Player", [
|
||||
{ name: "SetVolume", options: {}, I: SetVolumeRequest, O: SetVolumeResponse },
|
||||
{ name: "PlayTrackNext", options: {}, I: TrackRequest, O: Queue },
|
||||
{ name: "AddTrackToQueue", options: {}, I: TrackRequest, O: Queue },
|
||||
{ name: "SwapQueueIndices", options: {}, I: SwapQueueIndicesRequest, O: Queue },
|
||||
{ name: "SkipTrack", options: {}, I: Empty, O: PlayerStatus },
|
||||
{ name: "SkipToQueueIndex", options: {}, I: SkipToQueueIndexRequest, O: PlayerStatus }
|
||||
]);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { fail } from '@sveltejs/kit';
|
||||
import { protoTransport } from '../../hooks.server';
|
||||
import type { Actions } from './$types';
|
||||
import { serializable } from '$lib/proto';
|
||||
import type { PlayerStatus } from '$lib/proto/player';
|
||||
import { PlayerStatus } from '$lib/proto/player';
|
||||
|
||||
export const actions = {
|
||||
skip: async () => {
|
||||
@@ -26,6 +26,23 @@ export const actions = {
|
||||
|
||||
return serializable<PlayerStatus>(response.response);
|
||||
},
|
||||
'swap-queue-indices': async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
const [a, b] = [formData.get('a')?.toString(), formData.get('b')?.toString()];
|
||||
|
||||
if (!a || !b) {
|
||||
return fail(400);
|
||||
}
|
||||
|
||||
const client = new PlayerClient(protoTransport);
|
||||
|
||||
const response = await client.swapQueueIndices({
|
||||
a: parseInt(a),
|
||||
b: parseInt(b)
|
||||
});
|
||||
|
||||
return serializable<PlayerStatus>(response.response);
|
||||
},
|
||||
resume: async () => {
|
||||
const client = new PlayerClient(protoTransport);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user