Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| acd31a9154 | |||
| 687a7fc555 | |||
| fe05a27b51 | |||
| d6a375fff3 | |||
| 2a0c2c0e41 | |||
| b3cf5fb3c8 | |||
| b2b70f3eb1 | |||
| 5c3f2dbdd2 |
10
Dockerfile
10
Dockerfile
@@ -4,18 +4,18 @@ FROM --platform=$TARGETPLATFORM node:20-slim AS builder
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY backend/package*.json ./backend/
|
||||||
COPY frontend/package*.json ./frontend/
|
COPY frontend/package*.json ./frontend/
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN npm ci
|
RUN cd backend && npm ci
|
||||||
RUN cd frontend && npm ci
|
RUN cd frontend && npm ci
|
||||||
|
|
||||||
# Copy source files
|
# Copy source files
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build frontend and backend
|
# Build frontend and backend
|
||||||
RUN npm run build
|
RUN npm run build --workspaces
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM --platform=$TARGETPLATFORM debian:testing-20250203
|
FROM --platform=$TARGETPLATFORM debian:testing-20250203
|
||||||
@@ -27,11 +27,11 @@ RUN apt-get update && apt-get install -y \
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install only production dependencies
|
# Install only production dependencies
|
||||||
COPY package*.json ./
|
COPY backend/package*.json ./
|
||||||
RUN npm ci --production
|
RUN npm ci --production
|
||||||
|
|
||||||
# Copy built files
|
# Copy built files
|
||||||
COPY --from=builder /app/build ./build
|
COPY --from=builder /app/backend/build ./build
|
||||||
COPY --from=builder /app/frontend/dist ./dist/frontend
|
COPY --from=builder /app/frontend/dist ./dist/frontend
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
14
Makefile
14
Makefile
@@ -3,13 +3,21 @@ VERSION := $(shell git describe --always --dirty)
|
|||||||
.PHONY: build
|
.PHONY: build
|
||||||
build:
|
build:
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npm run build --workspaces
|
||||||
|
|
||||||
|
.PHONY: dev
|
||||||
|
dev:
|
||||||
|
npm run dev
|
||||||
|
|
||||||
.PHONY: run
|
.PHONY: run
|
||||||
run:
|
run:
|
||||||
npm run start
|
npm run start
|
||||||
|
|
||||||
|
.PHONY: image
|
||||||
|
image:
|
||||||
|
docker build -t queuecube:$(VERSION)-$(shell uname -m) .
|
||||||
|
|
||||||
.PHONY: images
|
.PHONY: images
|
||||||
images:
|
images:
|
||||||
docker buildx build --platform linux/arm/v7 -t musicqueue:$(VERSION)-armv7l .
|
docker buildx build --platform linux/arm/v7 -t queuecube:$(VERSION)-armv7l .
|
||||||
docker buildx build --platform linux/amd64 -t musicqueue:$(VERSION)-amd64 .
|
docker buildx build --platform linux/amd64 -t queuecube:$(VERSION)-amd64 .
|
||||||
|
|||||||
34
README.md
Normal file
34
README.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# QueueCube
|
||||||
|
|
||||||
|
QueueCube is a web frontend for [mpv](https://github.com/mpv-player/mpv).
|
||||||
|
|
||||||
|
It allows you to remotely manage mpv's playqueue as well as the ability to search YouTube via a locally running [Invidious](https://github.com/iv-org/invidious) instance.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
The easiest way to run QueueCube is by using Docker. An MPV instance is created in the Docker container, so all you have to do is make it so the MPV instance can access your host's soundcard. On Linux, this is accomplished by mapping the PulseAudio socket to the container:
|
||||||
|
|
||||||
|
```
|
||||||
|
volumes:
|
||||||
|
- ~/.config/pulse/cookie:/root/.config/pulse/cookie
|
||||||
|
- /var/run/user/1000/pulse:/var/run/pulse
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building the Docker image
|
||||||
|
|
||||||
|
```
|
||||||
|
docker build -t queuecube:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
If not using docker-compose, create an instance of the container by using `docker create` using the volume mapping specified above for mapping the PulseAudio socket:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker create -p 8080:3000 -v /run/user/1000/pulse:/var/run/pulse -v ~/.config/pulse/cookie:/root/.config/pulse/cookie --name queuecube queuecube:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
On some systems, you may need to add `--security-opt seccomp=unconfined` to allow containerized processes to write to your host's PulseAudio socket.
|
||||||
|
|
||||||
|
Once running, you should be able to access the UI via http://localhost:8080.
|
||||||
|
|
||||||
1
backend/favorites.json
Normal file
1
backend/favorites.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
2121
backend/package-lock.json
generated
Normal file
2121
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
backend/package.json
Normal file
32
backend/package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "mpvqueue",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "build/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -b",
|
||||||
|
"dev": "concurrently \"tsc -w -p src\" \"nodemon build/server.js\"",
|
||||||
|
"start": "node build/index.js"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/express-ws": "^3.0.5",
|
||||||
|
"@types/node": "^22.13.4",
|
||||||
|
"@types/ws": "^8.5.14",
|
||||||
|
"concurrently": "^9.1.2",
|
||||||
|
"nodemon": "^3.1.9",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node-fetch": "^2.6.12",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
|
"express": "^4.21.2",
|
||||||
|
"express-ws": "^5.0.2",
|
||||||
|
"link-preview-js": "^3.0.14",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
|
"react-icons": "^5.4.0",
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
114
backend/src/FavoritesStore.ts
Normal file
114
backend/src/FavoritesStore.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import fs from 'fs/promises';
|
||||||
|
import path from 'path';
|
||||||
|
import os from 'os';
|
||||||
|
import { PlaylistItem } from './types';
|
||||||
|
import { getLinkPreview } from 'link-preview-js';
|
||||||
|
|
||||||
|
export class FavoritesStore {
|
||||||
|
onFavoritesChanged: (favorites: PlaylistItem[]) => void = () => {};
|
||||||
|
|
||||||
|
private storePath: string;
|
||||||
|
private favorites: PlaylistItem[] = [];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.storePath = this.determineStorePath();
|
||||||
|
this.loadFavorites();
|
||||||
|
}
|
||||||
|
|
||||||
|
private determineStorePath(): string {
|
||||||
|
const storeFilename = 'favorites.json';
|
||||||
|
var storePath = path.join(os.tmpdir(), 'queuecube');
|
||||||
|
|
||||||
|
// Check for explicitly set path
|
||||||
|
if (process.env.STORE_PATH) {
|
||||||
|
storePath = path.resolve(process.env.STORE_PATH);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production (in a container), use /app/data
|
||||||
|
else if (process.env.NODE_ENV === 'production') {
|
||||||
|
storePath = path.resolve('/app/data');
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.mkdir(storePath, { recursive: true }).catch(err => {
|
||||||
|
console.error('Failed to create intermediate directory:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fullPath = path.join(storePath, storeFilename);
|
||||||
|
console.log("Favorites store path: " + fullPath);
|
||||||
|
return fullPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadFavorites() {
|
||||||
|
try {
|
||||||
|
// Ensure parent directory exists
|
||||||
|
await fs.mkdir(path.dirname(this.storePath), { recursive: true });
|
||||||
|
|
||||||
|
const data = await fs.readFile(this.storePath, 'utf-8');
|
||||||
|
this.favorites = JSON.parse(data);
|
||||||
|
} catch (error) {
|
||||||
|
// If file doesn't exist or is invalid, start with empty array
|
||||||
|
this.favorites = [];
|
||||||
|
await this.saveFavorites();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveFavorites() {
|
||||||
|
await fs.writeFile(this.storePath, JSON.stringify(this.favorites, null, 2));
|
||||||
|
this.onFavoritesChanged(this.favorites);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFavorites(): Promise<PlaylistItem[]> {
|
||||||
|
return this.favorites;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addFavorite(item: PlaylistItem, fetchMetadata: boolean = true): Promise<void> {
|
||||||
|
// Check if the item already exists by filename
|
||||||
|
const exists = this.favorites.some(f => f.filename === item.filename);
|
||||||
|
if (!exists) {
|
||||||
|
this.favorites.push({
|
||||||
|
...item,
|
||||||
|
id: this.favorites.length // Generate new ID
|
||||||
|
});
|
||||||
|
await this.saveFavorites();
|
||||||
|
} else {
|
||||||
|
// Otherwise, update the item with the new metadata
|
||||||
|
const index = this.favorites.findIndex(f => f.filename === item.filename);
|
||||||
|
this.favorites[index] = {
|
||||||
|
...this.favorites[index],
|
||||||
|
...item,
|
||||||
|
};
|
||||||
|
await this.saveFavorites();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the item is missing metadata, fetch it
|
||||||
|
if (fetchMetadata && !item.metadata) {
|
||||||
|
await this.fetchMetadata(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchMetadata(item: PlaylistItem): Promise<void> {
|
||||||
|
console.log("Fetching metadata for " + item.filename);
|
||||||
|
const metadata = await getLinkPreview(item.filename);
|
||||||
|
|
||||||
|
item.metadata = {
|
||||||
|
title: (metadata as any)?.title,
|
||||||
|
description: (metadata as any)?.description,
|
||||||
|
siteName: (metadata as any)?.siteName,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("Metadata fetched for " + item.filename);
|
||||||
|
console.log(item);
|
||||||
|
|
||||||
|
await this.addFavorite(item, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeFavorite(id: number): Promise<void> {
|
||||||
|
this.favorites = this.favorites.filter(f => f.id !== id);
|
||||||
|
await this.saveFavorites();
|
||||||
|
}
|
||||||
|
|
||||||
|
async clearFavorites(): Promise<void> {
|
||||||
|
this.favorites = [];
|
||||||
|
await this.saveFavorites();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,11 @@ export interface SearchResult {
|
|||||||
thumbnailUrl: string;
|
thumbnailUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ThumbnailResponse {
|
||||||
|
data: NodeJS.ReadableStream;
|
||||||
|
contentType: string;
|
||||||
|
}
|
||||||
|
|
||||||
const USE_INVIDIOUS = process.env.USE_INVIDIOUS || true;
|
const USE_INVIDIOUS = process.env.USE_INVIDIOUS || true;
|
||||||
const INVIDIOUS_BASE_URL = process.env.INVIDIOUS_BASE_URL || 'http://invidious.nor';
|
const INVIDIOUS_BASE_URL = process.env.INVIDIOUS_BASE_URL || 'http://invidious.nor';
|
||||||
const INVIDIOUS_API_ENDPOINT = `${INVIDIOUS_BASE_URL}/api/v1`;
|
const INVIDIOUS_API_ENDPOINT = `${INVIDIOUS_BASE_URL}/api/v1`;
|
||||||
@@ -76,3 +81,22 @@ export const searchInvidious = async (query: string): Promise<SearchResult[]> =>
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const fetchThumbnail = async (thumbnailUrl: string): Promise<ThumbnailResponse> => {
|
||||||
|
let path = thumbnailUrl;
|
||||||
|
if (thumbnailUrl.startsWith('http://') || thumbnailUrl.startsWith('https://')) {
|
||||||
|
const url = new URL(thumbnailUrl);
|
||||||
|
path = url.pathname + url.search;
|
||||||
|
}
|
||||||
|
path = path.replace(/^\/+/, ''); // Strip leading slash
|
||||||
|
|
||||||
|
const response = await fetch(getInvidiousThumbnailURL(path));
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: response.body,
|
||||||
|
contentType: response.headers.get('content-type') || 'image/jpeg'
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -2,31 +2,28 @@ import { ChildProcess, spawn } from "child_process";
|
|||||||
import { Socket } from "net";
|
import { Socket } from "net";
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import { getLinkPreview } from "link-preview-js";
|
import { getLinkPreview } from "link-preview-js";
|
||||||
|
import { PlaylistItem, LinkMetadata } from './types';
|
||||||
|
import { FavoritesStore } from "./FavoritesStore";
|
||||||
|
|
||||||
interface PendingCommand {
|
interface PendingCommand {
|
||||||
resolve: (value: any) => void;
|
resolve: (value: any) => void;
|
||||||
reject: (reason: any) => void;
|
reject: (reason: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LinkMetadata {
|
enum UserEvent {
|
||||||
title?: string;
|
PlaylistUpdate = "playlist_update",
|
||||||
description?: string;
|
NowPlayingUpdate = "now_playing_update",
|
||||||
siteName?: string;
|
VolumeUpdate = "volume_update",
|
||||||
}
|
FavoritesUpdate = "favorites_update",
|
||||||
|
MetadataUpdate = "metadata_update",
|
||||||
interface PlaylistItem {
|
MPDUpdate = "mpd_update",
|
||||||
id: number;
|
|
||||||
filename: string;
|
|
||||||
title?: string;
|
|
||||||
playing?: boolean;
|
|
||||||
current?: boolean;
|
|
||||||
metadata?: LinkMetadata;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MediaPlayer {
|
export class MediaPlayer {
|
||||||
private playerProcess: ChildProcess;
|
private playerProcess: ChildProcess;
|
||||||
private socket: Socket;
|
private socket: Socket;
|
||||||
private eventSubscribers: WebSocket[] = [];
|
private eventSubscribers: WebSocket[] = [];
|
||||||
|
private favoritesStore: FavoritesStore;
|
||||||
|
|
||||||
private pendingCommands: Map<number, PendingCommand> = new Map();
|
private pendingCommands: Map<number, PendingCommand> = new Map();
|
||||||
private requestId: number = 1;
|
private requestId: number = 1;
|
||||||
@@ -53,6 +50,11 @@ export class MediaPlayer {
|
|||||||
this.connectToSocket(socketPath);
|
this.connectToSocket(socketPath);
|
||||||
}, 500);
|
}, 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.favoritesStore = new FavoritesStore();
|
||||||
|
this.favoritesStore.onFavoritesChanged = (favorites) => {
|
||||||
|
this.handleEvent(UserEvent.FavoritesUpdate, { favorites });
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getPlaylist(): Promise<PlaylistItem[]> {
|
public async getPlaylist(): Promise<PlaylistItem[]> {
|
||||||
@@ -123,42 +125,39 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async append(url: string) {
|
public async append(url: string) {
|
||||||
const result = await this.modify(() => this.writeCommand("loadfile", [url, "append-play"]));
|
await this.loadFile(url, "append-play");
|
||||||
|
}
|
||||||
|
|
||||||
// Asynchronously fetch the metadata for this after we update the playlist
|
public async replace(url: string) {
|
||||||
this.fetchMetadataAndNotify(url).catch(error => {
|
await this.loadFile(url, "replace");
|
||||||
console.warn(`Failed to fetch metadata for ${url}:`, error);
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async play() {
|
public async play() {
|
||||||
return this.modify(() => this.writeCommand("set_property", ["pause", false]));
|
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", false]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async pause() {
|
public async pause() {
|
||||||
return this.modify(() => this.writeCommand("set_property", ["pause", true]));
|
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", true]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async skip() {
|
public async skip() {
|
||||||
return this.modify(() => this.writeCommand("playlist-next", []));
|
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-next", []));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async skipTo(index: number) {
|
public async skipTo(index: number) {
|
||||||
return this.modify(() => this.writeCommand("playlist-play-index", [index]));
|
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-play-index", [index]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async previous() {
|
public async previous() {
|
||||||
return this.modify(() => this.writeCommand("playlist-prev", []));
|
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-prev", []));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deletePlaylistItem(index: number) {
|
public async deletePlaylistItem(index: number) {
|
||||||
return this.modify(() => this.writeCommand("playlist-remove", [index]));
|
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-remove", [index]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async setVolume(volume: number) {
|
public async setVolume(volume: number) {
|
||||||
return this.modify(() => this.writeCommand("set_property", ["volume", volume]));
|
return this.modify(UserEvent.VolumeUpdate, () => this.writeCommand("set_property", ["volume", volume]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public subscribe(ws: WebSocket) {
|
public subscribe(ws: WebSocket) {
|
||||||
@@ -169,11 +168,35 @@ export class MediaPlayer {
|
|||||||
this.eventSubscribers = this.eventSubscribers.filter(subscriber => subscriber !== ws);
|
this.eventSubscribers = this.eventSubscribers.filter(subscriber => subscriber !== ws);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async modify<T>(func: () => Promise<T>): Promise<T> {
|
public async getFavorites(): Promise<PlaylistItem[]> {
|
||||||
|
return this.favoritesStore.getFavorites();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async addFavorite(item: PlaylistItem) {
|
||||||
|
return this.favoritesStore.addFavorite(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async removeFavorite(id: number) {
|
||||||
|
return this.favoritesStore.removeFavorite(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async clearFavorites() {
|
||||||
|
return this.favoritesStore.clearFavorites();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadFile(url: string, mode: string) {
|
||||||
|
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode]));
|
||||||
|
|
||||||
|
this.fetchMetadataAndNotify(url).catch(error => {
|
||||||
|
console.warn(`Failed to fetch metadata for ${url}:`, error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async modify<T>(event: UserEvent, func: () => Promise<T>): Promise<T> {
|
||||||
return func()
|
return func()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
// Notify all subscribers
|
// Notify all subscribers
|
||||||
this.handleEvent("user_modify", {});
|
this.handleEvent(event, {});
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -205,6 +228,7 @@ export class MediaPlayer {
|
|||||||
|
|
||||||
private async fetchMetadataAndNotify(url: string) {
|
private async fetchMetadataAndNotify(url: string) {
|
||||||
try {
|
try {
|
||||||
|
console.log("Fetching metadata for " + url);
|
||||||
const metadata = await getLinkPreview(url);
|
const metadata = await getLinkPreview(url);
|
||||||
this.metadata.set(url, {
|
this.metadata.set(url, {
|
||||||
title: (metadata as any)?.title,
|
title: (metadata as any)?.title,
|
||||||
@@ -212,8 +236,11 @@ export class MediaPlayer {
|
|||||||
siteName: (metadata as any)?.siteName,
|
siteName: (metadata as any)?.siteName,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("Metadata fetched for " + url);
|
||||||
|
console.log(this.metadata.get(url));
|
||||||
|
|
||||||
// Notify clients that metadata has been updated
|
// Notify clients that metadata has been updated
|
||||||
this.handleEvent("metadata_update", {
|
this.handleEvent(UserEvent.MetadataUpdate, {
|
||||||
url,
|
url,
|
||||||
metadata: this.metadata.get(url)
|
metadata: this.metadata.get(url)
|
||||||
});
|
});
|
||||||
@@ -228,7 +255,7 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleEvent(event: string, data: any) {
|
private handleEvent(event: string, data: any) {
|
||||||
console.log("MPV Event [" + event + "]: " + JSON.stringify(data, null, 2));
|
console.log("Event [" + event + "]: " + JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
// Notify all subscribers
|
// Notify all subscribers
|
||||||
this.eventSubscribers.forEach(subscriber => {
|
this.eventSubscribers.forEach(subscriber => {
|
||||||
@@ -255,7 +282,7 @@ export class MediaPlayer {
|
|||||||
this.pendingCommands.delete(response.request_id);
|
this.pendingCommands.delete(response.request_id);
|
||||||
}
|
}
|
||||||
} else if (response.event) {
|
} else if (response.event) {
|
||||||
this.handleEvent(response.event, response);
|
this.handleEvent(UserEvent.MPDUpdate, response);
|
||||||
} else {
|
} else {
|
||||||
console.log(response);
|
console.log(response);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import expressWs from "express-ws";
|
import expressWs from "express-ws";
|
||||||
import { MediaPlayer } from "./MediaPlayer";
|
import { MediaPlayer } from "./MediaPlayer";
|
||||||
import { searchInvidious, getInvidiousThumbnailURL } from "./InvidiousAPI";
|
import { searchInvidious, fetchThumbnail } from "./InvidiousAPI";
|
||||||
import fetch from "node-fetch";
|
import { PlaylistItem } from './types';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@@ -38,6 +38,12 @@ apiRouter.delete("/playlist/:index", withErrorHandling(async (req, res) => {
|
|||||||
res.send(JSON.stringify({ success: true }));
|
res.send(JSON.stringify({ success: true }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
apiRouter.post("/playlist/replace", withErrorHandling(async (req, res) => {
|
||||||
|
const { url } = req.body as { url: string };
|
||||||
|
await mediaPlayer.replace(url);
|
||||||
|
res.send(JSON.stringify({ success: true }));
|
||||||
|
}));
|
||||||
|
|
||||||
apiRouter.post("/play", withErrorHandling(async (req, res) => {
|
apiRouter.post("/play", withErrorHandling(async (req, res) => {
|
||||||
await mediaPlayer.play();
|
await mediaPlayer.play();
|
||||||
res.send(JSON.stringify({ success: true }));
|
res.send(JSON.stringify({ success: true }));
|
||||||
@@ -118,17 +124,9 @@ apiRouter.get("/thumbnail", withErrorHandling(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const thumbnailUrlWithoutLeadingSlash = thumbnailUrl.startsWith('/') ? thumbnailUrl.slice(1) : thumbnailUrl;
|
const { data, contentType } = await fetchThumbnail(thumbnailUrl);
|
||||||
const response = await fetch(getInvidiousThumbnailURL(thumbnailUrlWithoutLeadingSlash));
|
res.set('Content-Type', contentType);
|
||||||
if (!response.ok) {
|
data.pipe(res);
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Forward the content type header
|
|
||||||
res.set('Content-Type', response.headers.get('content-type') || 'image/jpeg');
|
|
||||||
|
|
||||||
// Pipe the thumbnail data directly to the response
|
|
||||||
response.body.pipe(res);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to proxy thumbnail:', error);
|
console.error('Failed to proxy thumbnail:', error);
|
||||||
res.status(500)
|
res.status(500)
|
||||||
@@ -136,6 +134,28 @@ apiRouter.get("/thumbnail", withErrorHandling(async (req, res) => {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
apiRouter.get("/favorites", withErrorHandling(async (req, res) => {
|
||||||
|
const favorites = await mediaPlayer.getFavorites();
|
||||||
|
res.send(JSON.stringify(favorites));
|
||||||
|
}));
|
||||||
|
|
||||||
|
apiRouter.post("/favorites", withErrorHandling(async (req, res) => {
|
||||||
|
const item = req.body as PlaylistItem;
|
||||||
|
await mediaPlayer.addFavorite(item);
|
||||||
|
res.send(JSON.stringify({ success: true }));
|
||||||
|
}));
|
||||||
|
|
||||||
|
apiRouter.delete("/favorites/:id", withErrorHandling(async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
await mediaPlayer.removeFavorite(parseInt(id));
|
||||||
|
res.send(JSON.stringify({ success: true }));
|
||||||
|
}));
|
||||||
|
|
||||||
|
apiRouter.delete("/favorites", withErrorHandling(async (req, res) => {
|
||||||
|
await mediaPlayer.clearFavorites();
|
||||||
|
res.send(JSON.stringify({ success: true }));
|
||||||
|
}));
|
||||||
|
|
||||||
// Serve static files for React app (after building)
|
// Serve static files for React app (after building)
|
||||||
app.use(express.static("dist/frontend"));
|
app.use(express.static("dist/frontend"));
|
||||||
|
|
||||||
14
backend/src/types.ts
Normal file
14
backend/src/types.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export interface LinkMetadata {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
siteName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlaylistItem {
|
||||||
|
id: number;
|
||||||
|
filename: string;
|
||||||
|
title?: string;
|
||||||
|
playing?: boolean;
|
||||||
|
current?: boolean;
|
||||||
|
metadata?: LinkMetadata;
|
||||||
|
}
|
||||||
@@ -107,10 +107,10 @@
|
|||||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"], // Only include backend files
|
"include": ["src/**/*"], // Only include backend files
|
||||||
"exclude": ["frontend/**/*"], // Explicitly exclude frontend files
|
"exclude": ["../frontend/**/*"], // Explicitly exclude frontend files
|
||||||
"files": [],
|
"files": [],
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "./src" }, // Backend reference
|
{ "path": "./src" }, // Backend reference
|
||||||
{ "path": "./frontend" } // Frontend reference
|
{ "path": "../frontend" } // Frontend reference
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -46,6 +46,15 @@ export interface SearchResponse {
|
|||||||
results: SearchResult[];
|
results: SearchResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ServerEvent {
|
||||||
|
PlaylistUpdate = "playlist_update",
|
||||||
|
NowPlayingUpdate = "now_playing_update",
|
||||||
|
VolumeUpdate = "volume_update",
|
||||||
|
FavoritesUpdate = "favorites_update",
|
||||||
|
MetadataUpdate = "metadata_update",
|
||||||
|
MPDUpdate = "mpd_update",
|
||||||
|
}
|
||||||
|
|
||||||
export const API = {
|
export const API = {
|
||||||
async getPlaylist(): Promise<PlaylistItem[]> {
|
async getPlaylist(): Promise<PlaylistItem[]> {
|
||||||
const response = await fetch('/api/playlist');
|
const response = await fetch('/api/playlist');
|
||||||
@@ -62,6 +71,16 @@ export const API = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async replaceCurrentFile(url: string): Promise<void> {
|
||||||
|
await fetch('/api/playlist/replace', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ url }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
async removeFromPlaylist(index: number): Promise<void> {
|
async removeFromPlaylist(index: number): Promise<void> {
|
||||||
await fetch(`/api/playlist/${index}`, {
|
await fetch(`/api/playlist/${index}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -116,5 +135,38 @@ export const API = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return ws;
|
return ws;
|
||||||
|
},
|
||||||
|
|
||||||
|
async getFavorites(): Promise<PlaylistItem[]> {
|
||||||
|
const response = await fetch('/api/favorites');
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async addToFavorites(url: string): Promise<void> {
|
||||||
|
// Maybe a little weird to make an empty PlaylistItem here, but we do want to support adding
|
||||||
|
// known PlaylistItems to favorites in the future.
|
||||||
|
const playlistItem: PlaylistItem = {
|
||||||
|
filename: url,
|
||||||
|
title: null,
|
||||||
|
id: 0,
|
||||||
|
playing: null,
|
||||||
|
metadata: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
await fetch('/api/favorites', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(playlistItem),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeFromFavorites(id: number): Promise<void> {
|
||||||
|
await fetch(`/api/favorites/${id}`, { method: 'DELETE' });
|
||||||
|
},
|
||||||
|
|
||||||
|
async clearFavorites(): Promise<void> {
|
||||||
|
await fetch('/api/favorites', { method: 'DELETE' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,78 @@ import React, { useState, useEffect, useCallback } from 'react';
|
|||||||
import SongTable from './SongTable';
|
import SongTable from './SongTable';
|
||||||
import NowPlaying from './NowPlaying';
|
import NowPlaying from './NowPlaying';
|
||||||
import AddSongPanel from './AddSongPanel';
|
import AddSongPanel from './AddSongPanel';
|
||||||
import { API, getDisplayTitle, PlaylistItem } from '../api/player';
|
import { TabView, Tab } from './TabView';
|
||||||
|
import { API, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player';
|
||||||
|
import { FaMusic, FaHeart } from 'react-icons/fa';
|
||||||
|
import useWebSocket from 'react-use-websocket';
|
||||||
|
|
||||||
|
enum Tabs {
|
||||||
|
Playlist = "playlist",
|
||||||
|
Favorites = "favorites",
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmptyContent: React.FC<{ label: string}> = ({label}) => (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-white text-2xl font-bold">{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SonglistContentProps {
|
||||||
|
songs: PlaylistItem[];
|
||||||
|
isPlaying: boolean;
|
||||||
|
onNeedsRefresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PlaylistContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, onNeedsRefresh }) => {
|
||||||
|
const handleDelete = (index: number) => {
|
||||||
|
API.removeFromPlaylist(index);
|
||||||
|
onNeedsRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkipTo = (index: number) => {
|
||||||
|
API.skipTo(index);
|
||||||
|
onNeedsRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
songs.length > 0 ? (
|
||||||
|
<SongTable
|
||||||
|
songs={songs}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onSkipTo={handleSkipTo}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyContent label="Playlist is empty" />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const FavoritesContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, onNeedsRefresh }) => {
|
||||||
|
const handleDelete = (index: number) => {
|
||||||
|
API.removeFromFavorites(index);
|
||||||
|
onNeedsRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkipTo = (index: number) => {
|
||||||
|
API.replaceCurrentFile(songs[index].filename);
|
||||||
|
API.play();
|
||||||
|
onNeedsRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
songs.length > 0 ? (
|
||||||
|
<SongTable
|
||||||
|
songs={songs}
|
||||||
|
isPlaying={isPlaying}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onSkipTo={handleSkipTo}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyContent label="Favorites are empty" />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
@@ -10,30 +81,44 @@ const App: React.FC = () => {
|
|||||||
const [nowPlayingFileName, setNowPlayingFileName] = useState<string | null>(null);
|
const [nowPlayingFileName, setNowPlayingFileName] = useState<string | null>(null);
|
||||||
const [volume, setVolume] = useState(100);
|
const [volume, setVolume] = useState(100);
|
||||||
const [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false);
|
const [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false);
|
||||||
const [songs, setSongs] = useState<PlaylistItem[]>([]);
|
const [playlist, setPlaylist] = useState<PlaylistItem[]>([]);
|
||||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
const [favorites, setFavorites] = useState<PlaylistItem[]>([]);
|
||||||
|
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.Playlist);
|
||||||
|
|
||||||
const fetchPlaylist = useCallback(async () => {
|
const fetchPlaylist = useCallback(async () => {
|
||||||
const playlist = await API.getPlaylist();
|
const playlist = await API.getPlaylist();
|
||||||
setSongs(playlist);
|
setPlaylist(playlist);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchFavorites = useCallback(async () => {
|
||||||
|
const favorites = await API.getFavorites();
|
||||||
|
setFavorites(favorites);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchNowPlaying = useCallback(async () => {
|
const fetchNowPlaying = useCallback(async () => {
|
||||||
|
if (volumeSettingIsLocked) {
|
||||||
|
// We are actively changing the volume, which we do actually want to send events
|
||||||
|
// continuously to the server, but we don't want to refresh our state while doing that.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const nowPlaying = await API.getNowPlaying();
|
const nowPlaying = await API.getNowPlaying();
|
||||||
setNowPlayingSong(getDisplayTitle(nowPlaying.playingItem));
|
setNowPlayingSong(getDisplayTitle(nowPlaying.playingItem));
|
||||||
setNowPlayingFileName(nowPlaying.playingItem.filename);
|
setNowPlayingFileName(nowPlaying.playingItem.filename);
|
||||||
setIsPlaying(!nowPlaying.isPaused);
|
setIsPlaying(!nowPlaying.isPaused);
|
||||||
|
|
||||||
if (!volumeSettingIsLocked) {
|
|
||||||
setVolume(nowPlaying.volume);
|
setVolume(nowPlaying.volume);
|
||||||
}
|
|
||||||
}, [volumeSettingIsLocked]);
|
}, [volumeSettingIsLocked]);
|
||||||
|
|
||||||
const handleAddURL = async (url: string) => {
|
const handleAddURL = async (url: string) => {
|
||||||
const urlToAdd = url.trim();
|
const urlToAdd = url.trim();
|
||||||
if (urlToAdd) {
|
if (urlToAdd) {
|
||||||
|
if (selectedTab === Tabs.Favorites) {
|
||||||
|
await API.addToFavorites(urlToAdd);
|
||||||
|
fetchFavorites();
|
||||||
|
} else {
|
||||||
await API.addToPlaylist(urlToAdd);
|
await API.addToPlaylist(urlToAdd);
|
||||||
fetchPlaylist();
|
fetchPlaylist();
|
||||||
|
}
|
||||||
|
|
||||||
if (!isPlaying) {
|
if (!isPlaying) {
|
||||||
await API.play();
|
await API.play();
|
||||||
@@ -41,26 +126,6 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (index: number) => {
|
|
||||||
setSongs(songs.filter((_, i) => i !== index));
|
|
||||||
API.removeFromPlaylist(index);
|
|
||||||
fetchPlaylist();
|
|
||||||
fetchNowPlaying();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSkipTo = async (index: number) => {
|
|
||||||
const song = songs[index];
|
|
||||||
if (song.playing) {
|
|
||||||
togglePlayPause();
|
|
||||||
} else {
|
|
||||||
await API.skipTo(index);
|
|
||||||
await API.play();
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchNowPlaying();
|
|
||||||
fetchPlaylist();
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePlayPause = async () => {
|
const togglePlayPause = async () => {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
await API.pause();
|
await API.pause();
|
||||||
@@ -86,46 +151,69 @@ const App: React.FC = () => {
|
|||||||
await API.setVolume(volume);
|
await API.setVolume(volume);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWebSocketEvent = useCallback((event: any) => {
|
const handleWebSocketEvent = useCallback((message: MessageEvent) => {
|
||||||
|
const event = JSON.parse(message.data);
|
||||||
switch (event.event) {
|
switch (event.event) {
|
||||||
case 'user_modify':
|
case ServerEvent.PlaylistUpdate:
|
||||||
case 'end-file':
|
case ServerEvent.NowPlayingUpdate:
|
||||||
case 'playback-restart':
|
case ServerEvent.MetadataUpdate:
|
||||||
case 'metadata_update':
|
case ServerEvent.MPDUpdate:
|
||||||
fetchPlaylist();
|
fetchPlaylist();
|
||||||
fetchNowPlaying();
|
fetchNowPlaying();
|
||||||
break;
|
break;
|
||||||
|
case ServerEvent.VolumeUpdate:
|
||||||
|
if (!volumeSettingIsLocked) {
|
||||||
|
fetchNowPlaying();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case ServerEvent.FavoritesUpdate:
|
||||||
|
fetchFavorites();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
|
||||||
|
|
||||||
|
useWebSocket('/api/events', {
|
||||||
|
onOpen: () => {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
},
|
||||||
|
onClose: () => {
|
||||||
|
console.log('WebSocket disconnected');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
},
|
||||||
|
onMessage: handleWebSocketEvent,
|
||||||
|
shouldReconnect: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle visibility changes
|
||||||
|
useEffect(() => {
|
||||||
|
const handleVisibilityChange = () => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
fetchPlaylist();
|
||||||
|
fetchNowPlaying();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
}, [fetchPlaylist, fetchNowPlaying]);
|
}, [fetchPlaylist, fetchNowPlaying]);
|
||||||
|
|
||||||
|
const refreshContent = () => {
|
||||||
|
fetchPlaylist();
|
||||||
|
fetchNowPlaying();
|
||||||
|
fetchFavorites();
|
||||||
|
}
|
||||||
|
|
||||||
// Initial data fetch
|
// Initial data fetch
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchPlaylist();
|
fetchPlaylist();
|
||||||
fetchNowPlaying();
|
fetchNowPlaying();
|
||||||
}, [fetchPlaylist, fetchNowPlaying]);
|
fetchFavorites();
|
||||||
|
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
|
||||||
// Update WebSocket connection
|
|
||||||
useEffect(() => {
|
|
||||||
const websocket = API.subscribeToEvents(handleWebSocketEvent);
|
|
||||||
setWs(websocket);
|
|
||||||
|
|
||||||
// Handle page visibility changes, so if the user navigates back to this tab, we reconnect the WebSocket
|
|
||||||
const handleVisibilityChange = () => {
|
|
||||||
if (document.visibilityState === 'visible' && (!ws || ws.readyState === WebSocket.CLOSED)) {
|
|
||||||
const newWs = API.subscribeToEvents(handleWebSocketEvent);
|
|
||||||
setWs(newWs);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
||||||
if (websocket) {
|
|
||||||
websocket.close();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [handleWebSocketEvent]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen w-screen bg-black md:py-10">
|
<div className="flex items-center justify-center h-screen w-screen bg-black md:py-10">
|
||||||
@@ -144,18 +232,22 @@ const App: React.FC = () => {
|
|||||||
onVolumeDidChange={() => setVolumeSettingIsLocked(false)}
|
onVolumeDidChange={() => setVolumeSettingIsLocked(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{songs.length > 0 ? (
|
<TabView selectedTab={selectedTab} onTabChange={setSelectedTab}>
|
||||||
<SongTable
|
<Tab label="Playlist" identifier={Tabs.Playlist} icon={<FaMusic />}>
|
||||||
songs={songs}
|
<PlaylistContent
|
||||||
|
songs={playlist}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
onDelete={handleDelete}
|
onNeedsRefresh={refreshContent}
|
||||||
onSkipTo={handleSkipTo}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
</Tab>
|
||||||
<div className="flex items-center justify-center h-full">
|
<Tab label="Favorites" identifier={Tabs.Favorites} icon={<FaHeart />}>
|
||||||
<div className="text-white text-2xl font-bold">Playlist is empty</div>
|
<FavoritesContent
|
||||||
</div>
|
songs={favorites.map(f => ({ ...f, playing: f.filename === nowPlayingFileName }))}
|
||||||
)}
|
isPlaying={isPlaying}
|
||||||
|
onNeedsRefresh={refreshContent}
|
||||||
|
/>
|
||||||
|
</Tab>
|
||||||
|
</TabView>
|
||||||
|
|
||||||
<AddSongPanel onAddURL={handleAddURL} />
|
<AddSongPanel onAddURL={handleAddURL} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames("flex flex-row w-full h-24 px-2 py-5 items-center border-b gap-2 transition-colors", {
|
<div className={classNames("flex flex-row w-full h-24 px-2 py-5 items-center border-b gap-2 transition-colors", {
|
||||||
"bg-black/10": (playState === PlayState.Playing || playState === PlayState.Paused),
|
"qc-highlighted": (playState === PlayState.Playing || playState === PlayState.Paused),
|
||||||
"bg-black/30": playState === PlayState.NotPlaying,
|
"bg-black/30": playState === PlayState.NotPlaying,
|
||||||
})}>
|
})}>
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
@@ -78,7 +78,7 @@ const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay })
|
|||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
className="text-red-500 px-3 py-1 bg-red-500/10 rounded"
|
className="text-red-100 px-3 py-1 bg-red-500/40 rounded"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (showDeleteConfirm) {
|
if (showDeleteConfirm) {
|
||||||
|
|||||||
64
frontend/src/components/TabView.tsx
Normal file
64
frontend/src/components/TabView.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React, { ReactNode } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
interface TabProps<T> {
|
||||||
|
label: string;
|
||||||
|
identifier: T;
|
||||||
|
icon?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tab = <T,>({ children }: TabProps<T>) => {
|
||||||
|
// Wrapper component
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TabViewProps<T> {
|
||||||
|
children: ReactNode;
|
||||||
|
selectedTab: T;
|
||||||
|
onTabChange: (tab: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TabView = <T,>({ children, selectedTab, onTabChange }: TabViewProps<T>) => {
|
||||||
|
// Filter and validate children to only get Tab components
|
||||||
|
const tabs = React.Children.toArray(children).filter(
|
||||||
|
(child) => React.isValidElement(child) && child.type === Tab
|
||||||
|
) as React.ReactElement<TabProps<T>>[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex flex-row h-11 border-b border-white/20">
|
||||||
|
{tabs.map((tab, index) => {
|
||||||
|
const isSelected = selectedTab === tab.props.identifier;
|
||||||
|
const rowClassName = classNames(
|
||||||
|
"flex flex-row items-center justify-center w-full gap-2 text-white",
|
||||||
|
{ "qc-highlighted": isSelected }
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={rowClassName}
|
||||||
|
onClick={() => onTabChange(tab.props.identifier)}
|
||||||
|
>
|
||||||
|
{tab.props.icon}
|
||||||
|
<div className="text-sm font-bold">{tab.props.label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
{tabs.map((tab, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={classNames("w-full h-full", {
|
||||||
|
hidden: selectedTab !== tab.props.identifier,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{tab.props.children}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,4 +8,8 @@
|
|||||||
[&::-webkit-slider-thumb]:rounded-full
|
[&::-webkit-slider-thumb]:rounded-full
|
||||||
hover:[&::-webkit-slider-thumb]:bg-violet-300;
|
hover:[&::-webkit-slider-thumb]:bg-violet-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.qc-highlighted {
|
||||||
|
@apply shadow-[inset_0_0_35px_rgba(147,51,234,0.8)];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
3618
package-lock.json
generated
3618
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
35
package.json
35
package.json
@@ -1,32 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "mpvqueue",
|
"name": "queuecube",
|
||||||
"version": "1.0.0",
|
"version": "1.82",
|
||||||
"main": "build/server.js",
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -b && cd frontend && npm run build",
|
"dev": "concurrently \"cd frontend && npm run dev\" \"cd backend && npm run dev\""
|
||||||
"dev": "concurrently \"cd frontend && npm run dev\" \"tsc -w -p src\" \"nodemon build/server.js\"",
|
|
||||||
"start": "node build/index.js"
|
|
||||||
},
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"description": "",
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/express": "^5.0.0",
|
|
||||||
"@types/express-ws": "^3.0.5",
|
|
||||||
"@types/node": "^22.13.4",
|
|
||||||
"@types/ws": "^8.5.14",
|
|
||||||
"concurrently": "^9.1.2",
|
|
||||||
"nodemon": "^3.1.9",
|
|
||||||
"typescript": "^5.7.3"
|
|
||||||
},
|
},
|
||||||
|
"workspaces": [
|
||||||
|
"backend",
|
||||||
|
"frontend"
|
||||||
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node-fetch": "^2.6.12",
|
"react-use-websocket": "^4.13.0"
|
||||||
"classnames": "^2.5.1",
|
|
||||||
"express": "^4.21.2",
|
|
||||||
"express-ws": "^5.0.2",
|
|
||||||
"link-preview-js": "^3.0.14",
|
|
||||||
"node-fetch": "^2.7.0",
|
|
||||||
"react-icons": "^5.4.0",
|
|
||||||
"ws": "^8.18.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
screenshots/queuecube.png
Normal file
BIN
screenshots/queuecube.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
Reference in New Issue
Block a user