18 Commits
1.7 ... 2.0.3

Author SHA1 Message Date
8ab927333b SongTable: fix scrolling issue 2025-02-23 17:03:29 -08:00
acd31a9154 FavoritesStore: fix explicit store path 2025-02-23 16:49:30 -08:00
687a7fc555 Smarter/more granular event handling 2025-02-23 16:37:39 -08:00
fe05a27b51 Finish Favorites UI and jumping 2025-02-23 13:46:31 -08:00
d6a375fff3 frontend: add scaffolding for favorites tab 2025-02-23 12:51:21 -08:00
2a0c2c0e41 frontend: better websocket handling 2025-02-23 12:34:00 -08:00
b3cf5fb3c8 reorg: separate frontend and backend 2025-02-23 11:45:40 -08:00
b2b70f3eb1 Adds README and screenshot 2025-02-23 11:14:02 -08:00
5c3f2dbdd2 try to do more resiliant url handling 2025-02-22 02:18:17 -08:00
fbfd20c965 invidiousAPI: handle slashes 2025-02-22 02:04:05 -08:00
e781c6b04a player: websockets ssl 2025-02-22 01:27:08 -08:00
0ec6c2e3b3 Dockerfile: add from_source version as reference 2025-02-22 01:10:07 -08:00
4d4b26e105 Makefile: docker image building 2025-02-22 01:09:46 -08:00
880057b0fc invidious: move searching to backend 2025-02-22 01:09:33 -08:00
cfe48e28f8 invidious: on by default 2025-02-22 00:01:11 -08:00
78deeb65bc player: change display name order preference 2025-02-21 23:48:29 -08:00
ebb6127fb9 frontend: fix build failure 2025-02-21 22:55:36 -08:00
e23977548b frontend pacakge: dont need react-icons here 2025-02-21 22:52:27 -08:00
28 changed files with 8590 additions and 2208 deletions

View File

@@ -4,18 +4,18 @@ FROM --platform=$TARGETPLATFORM node:20-slim AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY backend/package*.json ./backend/
COPY frontend/package*.json ./frontend/
# Install dependencies
RUN npm ci
RUN cd backend && npm ci
RUN cd frontend && npm ci
# Copy source files
COPY . .
# Build frontend and backend
RUN npm run build
RUN npm run build --workspaces
# Production stage
FROM --platform=$TARGETPLATFORM debian:testing-20250203
@@ -27,11 +27,11 @@ RUN apt-get update && apt-get install -y \
WORKDIR /app
# Install only production dependencies
COPY package*.json ./
COPY backend/package*.json ./
RUN npm ci --production
# Copy built files
COPY --from=builder /app/build ./build
COPY --from=builder /app/backend/build ./build
COPY --from=builder /app/frontend/dist ./dist/frontend
EXPOSE 3000

110
Dockerfile.from_source Normal file
View File

@@ -0,0 +1,110 @@
ARG YTDLP_VERSION='2024.03.10'
ARG MPV_VERSION='0.39'
# Build stage for Node.js application
FROM --platform=$TARGETPLATFORM node:23-alpine AS node-builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY frontend/package*.json ./frontend/
# Install dependencies
RUN npm ci
RUN cd frontend && npm ci
# Copy source files
COPY . .
# Build frontend and backend
RUN npm run build
# Build stage for mpv
FROM --platform=$TARGETPLATFORM alpine AS mpv-builder
# Install build dependencies for mpv
RUN apk add --no-cache \
git \
python3 \
python3-dev \
py3-pip \
meson \
ninja \
pkgconfig \
gcc \
g++ \
musl-dev \
make \
ffmpeg-dev \
mesa-dev \
alsa-lib-dev \
libplacebo-dev \
libass-dev \
lua \
lua-dev \
pulseaudio-dev
# Clone and build mpv
ARG MPV_VERSION
WORKDIR /src
RUN git clone --depth 1 -b release/${MPV_VERSION} https://github.com/mpv-player/mpv.git && \
cd mpv && \
meson setup build --prefix=/tmp/mpv-prefix && \
meson compile -C build && \
meson install -C build && \
tar -czf /tmp/mpv-install.tar.gz -C /tmp/mpv-prefix .
# Build stage for yt-dlp
FROM --platform=$TARGETPLATFORM python:3.11-alpine AS ytdlp-builder
# Install build dependencies for yt-dlp
RUN apk add --no-cache \
git \
make \
py3-pip \
python3-dev \
gcc \
musl-dev
# Clone and build yt-dlp
ARG YTDLP_VERSION
WORKDIR /build
RUN git clone https://github.com/yt-dlp/yt-dlp.git --single-branch --branch ${YTDLP_VERSION} .
RUN python3 devscripts/install_deps.py --include pyinstaller
RUN python3 devscripts/make_lazy_extractors.py
RUN python3 -m bundle.pyinstaller --name=yt-dlp
# Production stage
FROM --platform=$TARGETPLATFORM alpine:latest
# Install runtime dependencies
RUN apk add --no-cache \
python3 \
ffmpeg \
mesa \
alsa-lib \
pulseaudio \
pulseaudio-utils \
libstdc++ \
ca-certificates \
npm
WORKDIR /app
# Install only production dependencies for Node.js
COPY package*.json ./
RUN npm ci --production
# Copy built files from previous stages
COPY --from=node-builder /app/build ./build
COPY --from=node-builder /app/frontend/dist ./dist/frontend
COPY --from=ytdlp-builder /build/dist/yt-dlp /usr/bin/
COPY --from=mpv-builder /tmp/mpv-install.tar.gz /tmp/
RUN tar -xzf /tmp/mpv-install.tar.gz -C / && \
rm /tmp/mpv-install.tar.gz
EXPOSE 3000
CMD ["node", "build/server.js"]

View File

@@ -1,8 +1,23 @@
VERSION := $(shell git describe --always --dirty)
.PHONY: build
build:
npm install
npm run build
npm run build --workspaces
.PHONY: dev
dev:
npm run dev
.PHONY: run
run:
npm run start
.PHONY: image
image:
docker build -t queuecube:$(VERSION)-$(shell uname -m) .
.PHONY: images
images:
docker buildx build --platform linux/arm/v7 -t queuecube:$(VERSION)-armv7l .
docker buildx build --platform linux/amd64 -t queuecube:$(VERSION)-amd64 .

34
README.md Normal file
View 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.
![queuecube user interface](screenshots/queuecube.png)
## 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
View File

@@ -0,0 +1 @@
[]

2121
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
backend/package.json Normal file
View 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"
}
}

View 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();
}
}

102
backend/src/InvidiousAPI.ts Normal file
View File

@@ -0,0 +1,102 @@
import fetch from 'node-fetch';
interface InvidiousVideoThumbnail {
quality: string;
url: string;
width: number;
height: number;
}
interface InvidiousResult {
type: string;
title: string;
videoId: string;
playlistId: string;
author: string;
videoThumbnails?: InvidiousVideoThumbnail[];
}
export interface SearchResult {
type: string;
title: string;
author: string;
mediaUrl: string;
thumbnailUrl: string;
}
export interface ThumbnailResponse {
data: NodeJS.ReadableStream;
contentType: string;
}
const USE_INVIDIOUS = process.env.USE_INVIDIOUS || true;
const INVIDIOUS_BASE_URL = process.env.INVIDIOUS_BASE_URL || 'http://invidious.nor';
const INVIDIOUS_API_ENDPOINT = `${INVIDIOUS_BASE_URL}/api/v1`;
export const getInvidiousSearchURL = (query: string): string =>
`${INVIDIOUS_API_ENDPOINT}/search?q=${encodeURIComponent(query)}`;
export const getInvidiousThumbnailURL = (url: string): string =>
`${INVIDIOUS_BASE_URL}/${url}`;
const preferredThumbnailAPIURL = (thumbnails: InvidiousVideoThumbnail[] | undefined): string => {
if (!thumbnails || thumbnails.length === 0) {
return '/assets/placeholder.jpg';
}
const mediumThumbnail = thumbnails.find(t => t.quality === 'medium');
const thumbnail = mediumThumbnail || thumbnails[0];
return `/api/thumbnail?url=${encodeURIComponent(thumbnail.url)}`;
};
const getMediaURL = (result: InvidiousResult): string => {
if (result.type === 'video') {
return `https://www.youtube.com/watch?v=${result.videoId}`;
} else if (result.type === 'playlist') {
return `https://www.youtube.com/playlist?list=${result.playlistId}`;
}
throw new Error(`Unknown result type: ${result.type}`);
};
export const searchInvidious = async (query: string): Promise<SearchResult[]> => {
try {
const response = await fetch(getInvidiousSearchURL(query));
if (!response.ok) {
throw new Error(`Invidious HTTP error: ${response.status}`);
}
const data = await response.json() as Array<InvidiousResult>;
return data.filter(item => {
return item.type === 'video' || item.type === 'playlist';
}).map(item => ({
type: item.type,
title: item.title,
author: item.author,
mediaUrl: getMediaURL(item),
thumbnailUrl: preferredThumbnailAPIURL(item.videoThumbnails)
}));
} catch (error) {
console.error('Failed to search Invidious:', 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'
};
};

View File

@@ -2,31 +2,28 @@ import { ChildProcess, spawn } from "child_process";
import { Socket } from "net";
import { WebSocket } from "ws";
import { getLinkPreview } from "link-preview-js";
import { PlaylistItem, LinkMetadata } from './types';
import { FavoritesStore } from "./FavoritesStore";
interface PendingCommand {
resolve: (value: any) => void;
reject: (reason: any) => void;
}
interface LinkMetadata {
title?: string;
description?: string;
siteName?: string;
}
interface PlaylistItem {
id: number;
filename: string;
title?: string;
playing?: boolean;
current?: boolean;
metadata?: LinkMetadata;
enum UserEvent {
PlaylistUpdate = "playlist_update",
NowPlayingUpdate = "now_playing_update",
VolumeUpdate = "volume_update",
FavoritesUpdate = "favorites_update",
MetadataUpdate = "metadata_update",
MPDUpdate = "mpd_update",
}
export class MediaPlayer {
private playerProcess: ChildProcess;
private socket: Socket;
private eventSubscribers: WebSocket[] = [];
private favoritesStore: FavoritesStore;
private pendingCommands: Map<number, PendingCommand> = new Map();
private requestId: number = 1;
@@ -53,6 +50,11 @@ export class MediaPlayer {
this.connectToSocket(socketPath);
}, 500);
});
this.favoritesStore = new FavoritesStore();
this.favoritesStore.onFavoritesChanged = (favorites) => {
this.handleEvent(UserEvent.FavoritesUpdate, { favorites });
};
}
public async getPlaylist(): Promise<PlaylistItem[]> {
@@ -123,42 +125,39 @@ export class MediaPlayer {
}
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
this.fetchMetadataAndNotify(url).catch(error => {
console.warn(`Failed to fetch metadata for ${url}:`, error);
});
return result;
public async replace(url: string) {
await this.loadFile(url, "replace");
}
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() {
return this.modify(() => this.writeCommand("set_property", ["pause", true]));
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", true]));
}
public async skip() {
return this.modify(() => this.writeCommand("playlist-next", []));
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-next", []));
}
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() {
return this.modify(() => this.writeCommand("playlist-prev", []));
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-prev", []));
}
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) {
return this.modify(() => this.writeCommand("set_property", ["volume", volume]));
return this.modify(UserEvent.VolumeUpdate, () => this.writeCommand("set_property", ["volume", volume]));
}
public subscribe(ws: WebSocket) {
@@ -169,11 +168,35 @@ export class MediaPlayer {
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()
.then((result) => {
// Notify all subscribers
this.handleEvent("user_modify", {});
this.handleEvent(event, {});
return result;
});
}
@@ -205,6 +228,7 @@ export class MediaPlayer {
private async fetchMetadataAndNotify(url: string) {
try {
console.log("Fetching metadata for " + url);
const metadata = await getLinkPreview(url);
this.metadata.set(url, {
title: (metadata as any)?.title,
@@ -212,8 +236,11 @@ export class MediaPlayer {
siteName: (metadata as any)?.siteName,
});
console.log("Metadata fetched for " + url);
console.log(this.metadata.get(url));
// Notify clients that metadata has been updated
this.handleEvent("metadata_update", {
this.handleEvent(UserEvent.MetadataUpdate, {
url,
metadata: this.metadata.get(url)
});
@@ -228,7 +255,7 @@ export class MediaPlayer {
}
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
this.eventSubscribers.forEach(subscriber => {
@@ -255,7 +282,7 @@ export class MediaPlayer {
this.pendingCommands.delete(response.request_id);
}
} else if (response.event) {
this.handleEvent(response.event, response);
this.handleEvent(UserEvent.MPDUpdate, response);
} else {
console.log(response);
}

View File

@@ -1,6 +1,8 @@
import express from "express";
import expressWs from "express-ws";
import { MediaPlayer } from "./MediaPlayer";
import { searchInvidious, fetchThumbnail } from "./InvidiousAPI";
import { PlaylistItem } from './types';
const app = express();
app.use(express.json());
@@ -36,6 +38,12 @@ apiRouter.delete("/playlist/:index", withErrorHandling(async (req, res) => {
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) => {
await mediaPlayer.play();
res.send(JSON.stringify({ success: true }));
@@ -95,6 +103,59 @@ apiRouter.ws("/events", (ws, req) => {
});
});
apiRouter.get("/search", withErrorHandling(async (req, res) => {
const query = req.query.q as string;
if (!query) {
res.status(400)
.send(JSON.stringify({ success: false, error: "Query parameter 'q' is required" }));
return;
}
const results = await searchInvidious(query);
res.send(JSON.stringify({ success: true, results }));
}));
apiRouter.get("/thumbnail", withErrorHandling(async (req, res) => {
const thumbnailUrl = req.query.url as string;
if (!thumbnailUrl) {
res.status(400)
.send(JSON.stringify({ success: false, error: "URL parameter is required" }));
return;
}
try {
const { data, contentType } = await fetchThumbnail(thumbnailUrl);
res.set('Content-Type', contentType);
data.pipe(res);
} catch (error) {
console.error('Failed to proxy thumbnail:', error);
res.status(500)
.send(JSON.stringify({ success: false, error: 'Failed to fetch thumbnail' }));
}
}));
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)
app.use(express.static("dist/frontend"));

14
backend/src/types.ts Normal file
View 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;
}

View File

@@ -107,10 +107,10 @@
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"include": ["src/**/*"], // Only include backend files
"exclude": ["frontend/**/*"], // Explicitly exclude frontend files
"exclude": ["../frontend/**/*"], // Explicitly exclude frontend files
"files": [],
"references": [
{ "path": "./src" }, // Backend reference
{ "path": "./frontend" } // Frontend reference
{ "path": "../frontend" } // Frontend reference
]
}

View File

@@ -17,7 +17,6 @@
"@eslint/js": "^9.19.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.3",
"@types/react-icons": "^3.0.0",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.19.0",
"eslint-plugin-react-hooks": "^5.0.0",
@@ -1583,16 +1582,6 @@
"@types/react": "^19.0.0"
}
},
"node_modules/@types/react-icons": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/react-icons/-/react-icons-3.0.0.tgz",
"integrity": "sha512-Vefs6LkLqF61vfV7AiAqls+vpR94q67gunhMueDznG+msAkrYgRxl7gYjNem/kZ+as2l2mNChmF1jRZzzQQtMg==",
"deprecated": "This is a stub types definition. react-icons provides its own type definitions, so you do not need this installed.",
"dev": true,
"dependencies": {
"react-icons": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.24.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.0.tgz",
@@ -3264,15 +3253,6 @@
"react": "^19.0.0"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"dev": true,
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",

View File

@@ -19,7 +19,6 @@
"@eslint/js": "^9.19.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.3",
"@types/react-icons": "^3.0.0",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.19.0",
"eslint-plugin-react-hooks": "^5.0.0",

View File

@@ -22,7 +22,7 @@ export interface PlaylistItem {
}
export const getDisplayTitle = (item: PlaylistItem): string => {
return item.metadata?.title || item.title || item.filename;
return item.title || item.metadata?.title || item.filename;
}
export interface MetadataUpdateEvent {
@@ -33,6 +33,28 @@ export interface MetadataUpdateEvent {
};
}
export interface SearchResult {
type: string;
title: string;
author: string;
mediaUrl: string;
thumbnailUrl: string;
}
export interface SearchResponse {
success: boolean;
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 = {
async getPlaylist(): Promise<PlaylistItem[]> {
const response = await fetch('/api/playlist');
@@ -49,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> {
await fetch(`/api/playlist/${index}`, {
method: 'DELETE',
@@ -90,12 +122,51 @@ export const API = {
});
},
async search(query: string): Promise<SearchResponse> {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
return response.json();
},
subscribeToEvents(onMessage: (event: any) => void): WebSocket {
const ws = new WebSocket(`ws://${window.location.host}/api/events`);
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${protocol}://${window.location.host}/api/events`);
ws.onmessage = (event) => {
onMessage(JSON.parse(event.data));
};
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' });
}
};

View File

@@ -2,7 +2,78 @@ import React, { useState, useEffect, useCallback } from 'react';
import SongTable from './SongTable';
import NowPlaying from './NowPlaying';
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 [isPlaying, setIsPlaying] = useState(false);
@@ -10,30 +81,44 @@ const App: React.FC = () => {
const [nowPlayingFileName, setNowPlayingFileName] = useState<string | null>(null);
const [volume, setVolume] = useState(100);
const [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false);
const [songs, setSongs] = useState<PlaylistItem[]>([]);
const [ws, setWs] = useState<WebSocket | null>(null);
const [playlist, setPlaylist] = useState<PlaylistItem[]>([]);
const [favorites, setFavorites] = useState<PlaylistItem[]>([]);
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.Playlist);
const fetchPlaylist = useCallback(async () => {
const playlist = await API.getPlaylist();
setSongs(playlist);
setPlaylist(playlist);
}, []);
const fetchFavorites = useCallback(async () => {
const favorites = await API.getFavorites();
setFavorites(favorites);
}, []);
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();
setNowPlayingSong(getDisplayTitle(nowPlaying.playingItem));
setNowPlayingFileName(nowPlaying.playingItem.filename);
setIsPlaying(!nowPlaying.isPaused);
if (!volumeSettingIsLocked) {
setVolume(nowPlaying.volume);
}
}, [volumeSettingIsLocked]);
const handleAddURL = async (url: string) => {
const urlToAdd = url.trim();
if (urlToAdd) {
if (selectedTab === Tabs.Favorites) {
await API.addToFavorites(urlToAdd);
fetchFavorites();
} else {
await API.addToPlaylist(urlToAdd);
fetchPlaylist();
}
if (!isPlaying) {
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 () => {
if (isPlaying) {
await API.pause();
@@ -86,46 +151,69 @@ const App: React.FC = () => {
await API.setVolume(volume);
};
const handleWebSocketEvent = useCallback((event: any) => {
const handleWebSocketEvent = useCallback((message: MessageEvent) => {
const event = JSON.parse(message.data);
switch (event.event) {
case 'user_modify':
case 'end-file':
case 'playback-restart':
case 'metadata_update':
case ServerEvent.PlaylistUpdate:
case ServerEvent.NowPlayingUpdate:
case ServerEvent.MetadataUpdate:
case ServerEvent.MPDUpdate:
fetchPlaylist();
fetchNowPlaying();
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]);
const refreshContent = () => {
fetchPlaylist();
fetchNowPlaying();
fetchFavorites();
}
// Initial data fetch
useEffect(() => {
fetchPlaylist();
fetchNowPlaying();
}, [fetchPlaylist, fetchNowPlaying]);
// 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]);
fetchFavorites();
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
return (
<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)}
/>
{songs.length > 0 ? (
<SongTable
songs={songs}
<TabView selectedTab={selectedTab} onTabChange={setSelectedTab}>
<Tab label="Playlist" identifier={Tabs.Playlist} icon={<FaMusic />}>
<PlaylistContent
songs={playlist}
isPlaying={isPlaying}
onDelete={handleDelete}
onSkipTo={handleSkipTo}
onNeedsRefresh={refreshContent}
/>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-white text-2xl font-bold">Playlist is empty</div>
</div>
)}
</Tab>
<Tab label="Favorites" identifier={Tabs.Favorites} icon={<FaHeart />}>
<FavoritesContent
songs={favorites.map(f => ({ ...f, playing: f.filename === nowPlayingFileName }))}
isPlaying={isPlaying}
onNeedsRefresh={refreshContent}
/>
</Tab>
</TabView>
<AddSongPanel onAddURL={handleAddURL} />
</div>

View File

@@ -1,21 +1,6 @@
import React, { useState, KeyboardEvent } from 'react';
import { FaSearch, FaSpinner, FaTimes } from 'react-icons/fa';
import { getInvidiousSearchURL, INVIDIOUS_BASE_URL } from '../config';
interface InvidiousVideoThumbnail {
quality: string;
url: string;
width: number;
height: number;
}
interface InvidiousResult {
type: string;
title: string;
videoId: string;
author: string;
videoThumbnails?: InvidiousVideoThumbnail[];
}
import { API, SearchResult } from '../api/player';
interface InvidiousSearchModalProps {
isOpen: boolean;
@@ -23,17 +8,10 @@ interface InvidiousSearchModalProps {
onSelectVideo: (url: string) => void;
}
const ResultCell: React.FC<{ result: InvidiousResult, onClick: () => void }> = ({ result, onClick, ...props }) => {
const thumbnailUrl = (result: InvidiousResult) => {
if (!result.videoThumbnails) return '/assets/placeholder.jpg';
const thumbnail = result.videoThumbnails.find(t => t.quality === 'medium');
return thumbnail ? `${INVIDIOUS_BASE_URL}${thumbnail.url}` : '/assets/placeholder.jpg';
};
const ResultCell: React.FC<{ result: SearchResult, onClick: () => void }> = ({ result, onClick, ...props }) => {
return (
<div className="flex gap-4 bg-black/20 p-2 rounded-lg cursor-pointer hover:bg-black/30 transition-colors" onClick={onClick} {...props}>
<img src={thumbnailUrl(result)} alt={result.title} className="w-32 h-18 object-cover rounded" />
<img src={result.thumbnailUrl} alt={result.title} className="w-32 h-18 object-cover rounded" />
<div className="flex flex-col justify-center">
<h3 className="text-white font-semibold">{result.title}</h3>
<p className="text-white/60">{result.author}</p>
@@ -44,7 +22,7 @@ const ResultCell: React.FC<{ result: InvidiousResult, onClick: () => void }> = (
const InvidiousSearchModal: React.FC<InvidiousSearchModalProps> = ({ isOpen, onClose, onSelectVideo }) => {
const [searchQuery, setSearchQuery] = useState('');
const [results, setResults] = useState<InvidiousResult[]>([]);
const [results, setResults] = useState<SearchResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const handleSearch = async () => {
@@ -52,14 +30,12 @@ const InvidiousSearchModal: React.FC<InvidiousSearchModalProps> = ({ isOpen, onC
setIsLoading(true);
try {
const response = await fetch(getInvidiousSearchURL(searchQuery));
const data = await response.json();
const videoResults = data.filter((item: InvidiousResult) => {
return item.type === 'video' || item.type === 'playlist'
});
setResults(videoResults);
const response = await API.search(searchQuery);
if (response.success) {
setResults(response.results);
} else {
console.error('Search failed:', response);
}
} catch (error) {
console.error('Failed to search:', error);
} finally {
@@ -108,7 +84,7 @@ const InvidiousSearchModal: React.FC<InvidiousSearchModalProps> = ({ isOpen, onC
disabled={isLoading}
className="bg-violet-500 text-white p-2 rounded-lg px-4 border-2 border-violet-500 disabled:opacity-50"
>
{isLoading ? <FaSpinner className="animate-spin" /> : <FaSearch />}
{isLoading ? <span className="animate-spin"><FaSpinner /></span> : <span><FaSearch /></span>}
</button>
</div>
@@ -119,9 +95,9 @@ const InvidiousSearchModal: React.FC<InvidiousSearchModalProps> = ({ isOpen, onC
<div className="grid gap-4">
{results.map((result) => (
<ResultCell
key={result.videoId}
key={result.mediaUrl}
result={result}
onClick={() => _onSelectVideo(`https://www.youtube.com/watch?v=${result.videoId}`)}
onClick={() => _onSelectVideo(result.mediaUrl)}
/>
))}
</div>

View File

@@ -39,8 +39,9 @@ const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay })
const displayTitle = getDisplayTitle(song);
return (
<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),
<div className={classNames(
"flex flex-row w-full h-20 px-2 py-2 items-center border-b gap-2 transition-colors shrink-0", {
"qc-highlighted": (playState === PlayState.Playing || playState === PlayState.Paused),
"bg-black/30": playState === PlayState.NotPlaying,
})}>
<div className="flex flex-row gap-2">
@@ -78,7 +79,7 @@ const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay })
<div className="flex flex-row gap-2">
<button
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) => {
e.stopPropagation();
if (showDeleteConfirm) {

View File

@@ -21,7 +21,7 @@ const SongTable: React.FC<SongTableProps> = ({ songs, isPlaying, onDelete, onSki
}, [nowPlayingIndex]);
return (
<div className="flex flex-col w-full h-full overflow-y-auto border-y" ref={songTableRef}>
<div className="flex flex-col w-full h-full overflow-y-auto" ref={songTableRef}>
{songs.map((song, index) => (
<SongRow
key={index}

View 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 flex-1 overflow-hidden">
<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 overflow-hidden">
{tabs.map((tab, index) => (
<div
key={index}
className={classNames("w-full h-full", {
hidden: selectedTab !== tab.props.identifier,
})}
>
{tab.props.children}
</div>
))}
</div>
</div>
);
};

View File

@@ -1,4 +1,4 @@
export const USE_INVIDIOUS = import.meta.env.VITE_USE_INVIDIOUS || false;
export const USE_INVIDIOUS = import.meta.env.VITE_USE_INVIDIOUS || true;
export const INVIDIOUS_BASE_URL = import.meta.env.VITE_INVIDIOUS_BASE_URL || 'http://invidious.nor';
export const INVIDIOUS_API_ENDPOINT = `${INVIDIOUS_BASE_URL}/api/v1`;

View File

@@ -8,4 +8,8 @@
[&::-webkit-slider-thumb]:rounded-full
hover:[&::-webkit-slider-thumb]:bg-violet-300;
}
.qc-highlighted {
@apply shadow-[inset_0_0_35px_rgba(147,51,234,0.8)];
}
}

3729
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,30 +1,15 @@
{
"name": "mpvqueue",
"version": "1.0.0",
"main": "build/server.js",
"name": "queuecube",
"version": "1.82",
"private": true,
"scripts": {
"build": "tsc -b && cd frontend && npm run build",
"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"
"dev": "concurrently \"cd frontend && npm run dev\" \"cd backend && npm run dev\""
},
"workspaces": [
"backend",
"frontend"
],
"dependencies": {
"classnames": "^2.5.1",
"express": "^4.21.2",
"express-ws": "^5.0.2",
"link-preview-js": "^3.0.14",
"react-icons": "^5.4.0",
"ws": "^8.18.0"
"react-use-websocket": "^4.13.0"
}
}

BIN
screenshots/queuecube.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB