feat(queue): drag and drop reorder
This commit is contained in:
@@ -15,6 +15,7 @@ service Player {
|
|||||||
rpc SetVolume(SetVolumeRequest) returns (SetVolumeResponse);
|
rpc SetVolume(SetVolumeRequest) returns (SetVolumeResponse);
|
||||||
rpc PlayTrackNext(TrackRequest) returns (Queue);
|
rpc PlayTrackNext(TrackRequest) returns (Queue);
|
||||||
rpc AddTrackToQueue(TrackRequest) returns (Queue);
|
rpc AddTrackToQueue(TrackRequest) returns (Queue);
|
||||||
|
rpc SwapQueueIndices(SwapQueueIndicesRequest) returns (Queue);
|
||||||
rpc SkipTrack(google.protobuf.Empty) returns (PlayerStatus);
|
rpc SkipTrack(google.protobuf.Empty) returns (PlayerStatus);
|
||||||
rpc SkipToQueueIndex(SkipToQueueIndexRequest) returns (PlayerStatus);
|
rpc SkipToQueueIndex(SkipToQueueIndexRequest) returns (PlayerStatus);
|
||||||
}
|
}
|
||||||
@@ -63,3 +64,8 @@ message SetVolumeResponse {
|
|||||||
message SkipToQueueIndexRequest {
|
message SkipToQueueIndexRequest {
|
||||||
uint32 index = 1;
|
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 { getCoverUrl } from '$lib/covers';
|
||||||
import VolumeSlider from '$lib/components/groove/VolumeSlider.svelte';
|
import VolumeSlider from '$lib/components/groove/VolumeSlider.svelte';
|
||||||
import type { SubmitFunction } from '../../../routes/player/$types';
|
import type { SubmitFunction } from '../../../routes/player/$types';
|
||||||
|
import Queue from './Queue.svelte';
|
||||||
|
|
||||||
dayjs.extend(duration);
|
dayjs.extend(duration);
|
||||||
|
|
||||||
@@ -133,35 +134,7 @@
|
|||||||
|
|
||||||
<Separator class="my-1" />
|
<Separator class="my-1" />
|
||||||
|
|
||||||
<form
|
<Queue />
|
||||||
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>
|
|
||||||
</Popover.Content>
|
</Popover.Content>
|
||||||
</Popover.Root>
|
</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 type { ServiceInfo } from "@protobuf-ts/runtime-rpc";
|
||||||
import { Player } from "./player";
|
import { Player } from "./player";
|
||||||
import type { SkipToQueueIndexRequest } from "./player";
|
import type { SkipToQueueIndexRequest } from "./player";
|
||||||
|
import type { SwapQueueIndicesRequest } from "./player";
|
||||||
import type { Queue } from "./player";
|
import type { Queue } from "./player";
|
||||||
import type { SetVolumeResponse } from "./player";
|
import type { SetVolumeResponse } from "./player";
|
||||||
import type { SetVolumeRequest } from "./player";
|
import type { SetVolumeRequest } from "./player";
|
||||||
@@ -59,6 +60,10 @@ export interface IPlayerClient {
|
|||||||
* @generated from protobuf rpc: AddTrackToQueue(player.TrackRequest) returns (player.Queue);
|
* @generated from protobuf rpc: AddTrackToQueue(player.TrackRequest) returns (player.Queue);
|
||||||
*/
|
*/
|
||||||
addTrackToQueue(input: TrackRequest, options?: RpcOptions): UnaryCall<TrackRequest, 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);
|
* @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);
|
const method = this.methods[8], opt = this._transport.mergeOptions(options);
|
||||||
return stackIntercept<TrackRequest, Queue>("unary", this._transport, method, opt, input);
|
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);
|
* @generated from protobuf rpc: SkipTrack(google.protobuf.Empty) returns (player.PlayerStatus);
|
||||||
*/
|
*/
|
||||||
skipTrack(input: Empty, options?: RpcOptions): UnaryCall<Empty, 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);
|
return stackIntercept<Empty, PlayerStatus>("unary", this._transport, method, opt, input);
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* @generated from protobuf rpc: SkipToQueueIndex(player.SkipToQueueIndexRequest) returns (player.PlayerStatus);
|
* @generated from protobuf rpc: SkipToQueueIndex(player.SkipToQueueIndexRequest) returns (player.PlayerStatus);
|
||||||
*/
|
*/
|
||||||
skipToQueueIndex(input: SkipToQueueIndexRequest, options?: RpcOptions): UnaryCall<SkipToQueueIndexRequest, 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);
|
return stackIntercept<SkipToQueueIndexRequest, PlayerStatus>("unary", this._transport, method, opt, input);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,19 @@ export interface SkipToQueueIndexRequest {
|
|||||||
*/
|
*/
|
||||||
index: number;
|
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
|
// @generated message type with reflection information, may provide speed optimized methods
|
||||||
class TrackRequest$Type extends MessageType<TrackRequest> {
|
class TrackRequest$Type extends MessageType<TrackRequest> {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -631,6 +644,61 @@ class SkipToQueueIndexRequest$Type extends MessageType<SkipToQueueIndexRequest>
|
|||||||
* @generated MessageType for protobuf message player.SkipToQueueIndexRequest
|
* @generated MessageType for protobuf message player.SkipToQueueIndexRequest
|
||||||
*/
|
*/
|
||||||
export const SkipToQueueIndexRequest = new SkipToQueueIndexRequest$Type();
|
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
|
* @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: "SetVolume", options: {}, I: SetVolumeRequest, O: SetVolumeResponse },
|
||||||
{ name: "PlayTrackNext", options: {}, I: TrackRequest, O: Queue },
|
{ name: "PlayTrackNext", options: {}, I: TrackRequest, O: Queue },
|
||||||
{ name: "AddTrackToQueue", 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: "SkipTrack", options: {}, I: Empty, O: PlayerStatus },
|
||||||
{ name: "SkipToQueueIndex", options: {}, I: SkipToQueueIndexRequest, O: PlayerStatus }
|
{ name: "SkipToQueueIndex", options: {}, I: SkipToQueueIndexRequest, O: PlayerStatus }
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { fail } from '@sveltejs/kit';
|
|||||||
import { protoTransport } from '../../hooks.server';
|
import { protoTransport } from '../../hooks.server';
|
||||||
import type { Actions } from './$types';
|
import type { Actions } from './$types';
|
||||||
import { serializable } from '$lib/proto';
|
import { serializable } from '$lib/proto';
|
||||||
import type { PlayerStatus } from '$lib/proto/player';
|
import { PlayerStatus } from '$lib/proto/player';
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
skip: async () => {
|
skip: async () => {
|
||||||
@@ -26,6 +26,23 @@ export const actions = {
|
|||||||
|
|
||||||
return serializable<PlayerStatus>(response.response);
|
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 () => {
|
resume: async () => {
|
||||||
const client = new PlayerClient(protoTransport);
|
const client = new PlayerClient(protoTransport);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user