feat(queue): drag and drop reorder

This commit is contained in:
2024-11-28 02:36:48 +01:00
parent 60613e6502
commit 30e7b758ad
8 changed files with 218 additions and 33 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -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;
}

View File

@@ -77,5 +77,5 @@
}
* {
-webkit-user-drag: none !important;
/* -webkit-user-drag: none !important; */
}

View File

@@ -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>

View 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>

View File

@@ -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);
}
}

View File

@@ -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 }
]);

View File

@@ -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);