Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd79fd9ecd | |||
| 729279ea78 | |||
| 9303c5acfe | |||
| 2b533cf1db | |||
| 0a86dbed49 | |||
| 6a574597c5 | |||
| de5eefb9f4 | |||
| 29ce0863ca | |||
| 647ee74bf6 | |||
| 5ca056dbc8 | |||
| e7bb991df7 | |||
| 92ab7d572c | |||
| 795a6b7290 | |||
| d010d68056 | |||
| 8ab927333b | |||
| acd31a9154 | |||
| 687a7fc555 | |||
| fe05a27b51 | |||
| d6a375fff3 | |||
| 2a0c2c0e41 | |||
| b3cf5fb3c8 | |||
| b2b70f3eb1 | |||
| 5c3f2dbdd2 | |||
| fbfd20c965 | |||
| e781c6b04a | |||
| 0ec6c2e3b3 | |||
| 4d4b26e105 | |||
| 880057b0fc | |||
| cfe48e28f8 | |||
| 78deeb65bc | |||
| ebb6127fb9 | |||
| e23977548b | |||
| 803cdd2cdf | |||
| f677026072 | |||
| 5197c78897 | |||
| 5f1aeeca78 | |||
| 92c5582c98 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
build/
|
||||
result
|
||||
node_modules/
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
21
Dockerfile
21
Dockerfile
@@ -1,35 +1,34 @@
|
||||
# Build stage
|
||||
FROM node:20-slim AS builder
|
||||
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 frontend && npm ci
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
# Build frontend and backend
|
||||
RUN npm run build
|
||||
RUN npm install
|
||||
RUN npm run build --workspaces
|
||||
|
||||
# Production stage
|
||||
FROM alpine:edge
|
||||
FROM --platform=$TARGETPLATFORM debian:testing-20250203
|
||||
|
||||
RUN apk update && apk add mpv npm yt-dlp pulseaudio pulseaudio-utils
|
||||
RUN apt-get update && apt-get install -y \
|
||||
mpv npm yt-dlp pulseaudio pulseaudio-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
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
110
Dockerfile.from_source
Normal 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"]
|
||||
|
||||
17
Makefile
17
Makefile
@@ -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 .
|
||||
|
||||
71
README.md
Normal file
71
README.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# 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.
|
||||
|
||||
### Video
|
||||
|
||||
QueueCube supports video as well. Just set the environment variable `ENABLE_VIDEO=1`.
|
||||
|
||||
#### Video + Docker
|
||||
|
||||
When running in a Docker container, there are a few extra steps needed to make this work.
|
||||
|
||||
First, make sure you're passing in `/dev/dri` as a volume in the container. Since mpd is capable of rendering directly to the GPU, this would be the most performant.
|
||||
|
||||
```
|
||||
volumes:
|
||||
- /dev/dri:/dev/dri
|
||||
```
|
||||
|
||||
as well as the X11 socket:
|
||||
|
||||
```
|
||||
- /tmp/.X11-unix:/tmp/.X11-unix
|
||||
```
|
||||
|
||||
In order to be able to interact with the X session from the Docker container, you may also need to explicitly allow connections to the host via:
|
||||
|
||||
```
|
||||
$ xhost +local:
|
||||
```
|
||||
|
||||
Since the container will also need to be able to access the GPU device, it may be required to run the container in "privileged" mode:
|
||||
|
||||
```
|
||||
privileged: true
|
||||
```
|
||||
|
||||
(or `--privileged`).
|
||||
|
||||
On Podman rootless, it seems to be enough to just run it with the "unconfined" security profile (`seccomp=unconfined`).
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
131
backend/src/FavoritesStore.ts
Normal file
131
backend/src/FavoritesStore.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
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(filename: string): Promise<void> {
|
||||
// Check if the item already exists by filename
|
||||
const exists = this.favorites.some(f => f.filename === filename);
|
||||
if (!exists) {
|
||||
this.favorites.push({
|
||||
filename: filename,
|
||||
id: this.favorites.length // Generate new ID
|
||||
});
|
||||
await this.saveFavorites();
|
||||
|
||||
// Fetch metadata for the new favorite
|
||||
await this.fetchMetadata(filename);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchMetadata(filename: string): Promise<void> {
|
||||
console.log("Fetching metadata for " + filename);
|
||||
const metadata = await getLinkPreview(filename);
|
||||
|
||||
const item: PlaylistItem = {
|
||||
filename: filename,
|
||||
id: this.favorites.length,
|
||||
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);
|
||||
|
||||
const index = this.favorites.findIndex(f => f.filename === filename);
|
||||
if (index !== -1) {
|
||||
this.favorites[index] = item;
|
||||
await this.saveFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
async removeFavorite(filename: string): Promise<void> {
|
||||
console.log("Removing favorite " + filename);
|
||||
this.favorites = this.favorites.filter(f => f.filename !== filename);
|
||||
await this.saveFavorites();
|
||||
}
|
||||
|
||||
async updateFavoriteTitle(filename: string, title: string): Promise<void> {
|
||||
console.log(`Updating title for favorite ${filename} to "${title}"`);
|
||||
const index = this.favorites.findIndex(f => f.filename === filename);
|
||||
if (index !== -1) {
|
||||
// Create metadata object if it doesn't exist
|
||||
if (!this.favorites[index].metadata) {
|
||||
this.favorites[index].metadata = {};
|
||||
}
|
||||
|
||||
// Update the title in metadata
|
||||
this.favorites[index].metadata!.title = title;
|
||||
|
||||
await this.saveFavorites();
|
||||
} else {
|
||||
throw new Error(`Favorite with filename ${filename} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
async clearFavorites(): Promise<void> {
|
||||
this.favorites = [];
|
||||
await this.saveFavorites();
|
||||
}
|
||||
}
|
||||
102
backend/src/InvidiousAPI.ts
Normal file
102
backend/src/InvidiousAPI.ts
Normal 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'
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -36,10 +33,12 @@ export class MediaPlayer {
|
||||
constructor() {
|
||||
const socketFilename = Math.random().toString(36).substring(2, 10);
|
||||
const socketPath = `/tmp/mpv-${socketFilename}`;
|
||||
const enableVideo = process.env.ENABLE_VIDEO || false;
|
||||
|
||||
console.log("Starting player process");
|
||||
console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")");
|
||||
this.playerProcess = spawn("mpv", [
|
||||
"--no-video",
|
||||
"--video=" + (enableVideo ? "auto" : "no"),
|
||||
"--fullscreen",
|
||||
"--no-terminal",
|
||||
"--idle=yes",
|
||||
"--input-ipc-server=" + socketPath
|
||||
@@ -53,6 +52,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 +127,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 +170,39 @@ 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(filename: string) {
|
||||
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.addFavorite(filename));
|
||||
}
|
||||
|
||||
public async removeFavorite(filename: string) {
|
||||
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.removeFavorite(filename));
|
||||
}
|
||||
|
||||
public async clearFavorites() {
|
||||
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.clearFavorites());
|
||||
}
|
||||
|
||||
public async updateFavoriteTitle(filename: string, title: string) {
|
||||
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.updateFavoriteTitle(filename, title));
|
||||
}
|
||||
|
||||
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 +234,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 +242,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 +261,7 @@ export class MediaPlayer {
|
||||
}
|
||||
|
||||
private handleEvent(event: string, data: any) {
|
||||
console.log("MPV Event [" + event + "]: " + JSON.stringify(data, null, 2));
|
||||
console.log("Event [" + event + "]: ", data);
|
||||
|
||||
// Notify all subscribers
|
||||
this.eventSubscribers.forEach(subscriber => {
|
||||
@@ -255,7 +288,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);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import express from "express";
|
||||
import expressWs from "express-ws";
|
||||
import path from "path";
|
||||
import { MediaPlayer } from "./MediaPlayer";
|
||||
import { searchInvidious, fetchThumbnail } from "./InvidiousAPI";
|
||||
import { PlaylistItem } from './types';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
@@ -36,6 +39,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,15 +104,85 @@ 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 { filename } = req.body as { filename: string };
|
||||
console.log("Adding favorite: " + filename);
|
||||
await mediaPlayer.addFavorite(filename);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.delete("/favorites/:filename", withErrorHandling(async (req, res) => {
|
||||
const { filename } = req.params as { filename: string };
|
||||
await mediaPlayer.removeFavorite(filename);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.delete("/favorites", withErrorHandling(async (req, res) => {
|
||||
await mediaPlayer.clearFavorites();
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.put("/favorites/:filename/title", withErrorHandling(async (req, res) => {
|
||||
const { filename } = req.params as { filename: string };
|
||||
const { title } = req.body as { title: string };
|
||||
|
||||
if (!title) {
|
||||
res.status(400).send(JSON.stringify({
|
||||
success: false,
|
||||
error: "Title is required"
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
await mediaPlayer.updateFavoriteTitle(filename, title);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
// Serve static files for React app (after building)
|
||||
app.use(express.static("dist/frontend"));
|
||||
app.use(express.static(path.join(__dirname, "../dist/frontend")));
|
||||
|
||||
// Mount API routes under /api
|
||||
app.use("/api", apiRouter);
|
||||
|
||||
// Serve React app for all other routes (client-side routing)
|
||||
app.get("*", (req, res) => {
|
||||
res.sendFile("dist/frontend/index.html", { root: "." });
|
||||
res.sendFile(path.join(__dirname, "../dist/frontend/index.html"));
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
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. */
|
||||
},
|
||||
"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": "./src" }, // Backend reference
|
||||
{ "path": "../frontend" } // Frontend reference
|
||||
]
|
||||
}
|
||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1740828860,
|
||||
"narHash": "sha256-cjbHI+zUzK5CPsQZqMhE3npTyYFt9tJ3+ohcfaOF/WM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "303bd8071377433a2d8f76e684ec773d70c5b642",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
196
flake.nix
Normal file
196
flake.nix
Normal file
@@ -0,0 +1,196 @@
|
||||
{
|
||||
description = "NodeJS application with mpv, yt-dlp, and pulseaudio dependencies";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
let
|
||||
# Define the NixOS module for the systemd service
|
||||
nixosModule = { config, lib, pkgs, ... }:
|
||||
let
|
||||
cfg = config.services.queuecube;
|
||||
in {
|
||||
options.services.queuecube = {
|
||||
enable = lib.mkEnableOption "QueueCube media player service";
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 3000;
|
||||
description = "Port on which QueueCube will listen";
|
||||
};
|
||||
|
||||
enable_video = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Enable video playback";
|
||||
};
|
||||
|
||||
store_path = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/var/tmp/queuecube";
|
||||
description = "Path to the store for QueueCube";
|
||||
};
|
||||
|
||||
invidious = lib.mkOption {
|
||||
type = lib.types.submodule {
|
||||
options = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Enable Invidious";
|
||||
};
|
||||
|
||||
url = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "http://invidious.nor";
|
||||
description = "URL of the Invidious instance to use";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
default = {
|
||||
enable = false;
|
||||
url = "http://invidious.nor";
|
||||
};
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "User account under which QueueCube runs (required)";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.users.${cfg.user} = {
|
||||
packages = [ self.packages.${pkgs.system}.queuecube ];
|
||||
};
|
||||
|
||||
systemd.user.services.queuecube = {
|
||||
description = "QueueCube media player service";
|
||||
wantedBy = [ "default.target" ];
|
||||
after = [ "pipewire.service" "pipewire-pulse.service" ];
|
||||
|
||||
serviceConfig = {
|
||||
ExecStart = "${self.packages.${pkgs.system}.queuecube}/bin/queuecube";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
|
||||
# Allow access to X11 for mpv
|
||||
Environment = [ "DISPLAY=:0" ];
|
||||
};
|
||||
|
||||
environment = {
|
||||
PORT = toString cfg.port;
|
||||
ENABLE_VIDEO = if cfg.enable_video then "1" else "0";
|
||||
USE_INVIDIOUS = if cfg.invidious.enable then "1" else "0";
|
||||
INVIDIOUS_URL = cfg.invidious.url;
|
||||
STORE_PATH = cfg.store_path;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
|
||||
# Define the package using buildNpmPackage
|
||||
queuecube = pkgs.buildNpmPackage {
|
||||
pname = "queuecube";
|
||||
version = "0.1.0";
|
||||
|
||||
src = ./.;
|
||||
|
||||
# Skip the standard buildPhase and provide our own
|
||||
dontNpmBuild = true;
|
||||
buildPhase = ''
|
||||
# First install all dependencies
|
||||
npm install
|
||||
|
||||
# Then run the build with workspaces flag
|
||||
npm run build --workspaces
|
||||
'';
|
||||
|
||||
# Runtime dependencies
|
||||
buildInputs = with pkgs; [
|
||||
mpv
|
||||
yt-dlp
|
||||
pulseaudio
|
||||
];
|
||||
|
||||
# Create a wrapper script to ensure runtime deps are available
|
||||
postInstall = ''
|
||||
# Create the necessary directories
|
||||
mkdir -p $out/lib/node_modules/queuecube
|
||||
|
||||
# Copy the entire project with built files
|
||||
cp -r . $out/lib/node_modules/queuecube
|
||||
|
||||
# Install the frontend build to the backend dist directory
|
||||
mkdir -p $out/lib/node_modules/queuecube/backend/dist/
|
||||
cp -r frontend/dist $out/lib/node_modules/queuecube/backend/dist/frontend
|
||||
|
||||
# Create bin directory if it doesn't exist
|
||||
mkdir -p $out/bin
|
||||
|
||||
# Create executable script
|
||||
cat > $out/bin/queuecube <<EOF
|
||||
#!/bin/sh
|
||||
exec ${pkgs.nodejs}/bin/node $out/lib/node_modules/queuecube/backend/build/server.js
|
||||
EOF
|
||||
|
||||
# Make it executable
|
||||
chmod +x $out/bin/queuecube
|
||||
|
||||
# Wrap the program to include runtime deps in PATH
|
||||
wrapProgram $out/bin/queuecube \
|
||||
--prefix PATH : ${pkgs.lib.makeBinPath [
|
||||
pkgs.mpv
|
||||
pkgs.yt-dlp
|
||||
pkgs.pulseaudio
|
||||
]}
|
||||
'';
|
||||
|
||||
# Let buildNpmPackage handle npm package hash
|
||||
npmDepsHash = "sha256-BqjJ4CxTPc14Od88sAm/ASwsLszkvcHHeNoZupotlFw=";
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "NodeJS application with media playback capabilities";
|
||||
platforms = platforms.linux;
|
||||
};
|
||||
};
|
||||
|
||||
in {
|
||||
packages = {
|
||||
default = queuecube;
|
||||
queuecube = queuecube;
|
||||
};
|
||||
|
||||
apps.default = {
|
||||
type = "app";
|
||||
program = "${queuecube}/bin/queuecube";
|
||||
};
|
||||
|
||||
# Development environment
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
nodejs_20
|
||||
nodePackages.npm
|
||||
mpv
|
||||
yt-dlp
|
||||
pulseaudio
|
||||
];
|
||||
};
|
||||
|
||||
# Add a basic check to verify the package builds
|
||||
checks.queuecube = queuecube;
|
||||
}
|
||||
) // {
|
||||
# Export the NixOS module
|
||||
nixosModules.default = nixosModule;
|
||||
nixosModules.queuecube = nixosModule;
|
||||
};
|
||||
}
|
||||
9
frontend/package-lock.json
generated
9
frontend/package-lock.json
generated
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.19.0",
|
||||
@@ -1564,11 +1564,10 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz",
|
||||
"integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==",
|
||||
"version": "19.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz",
|
||||
"integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.19.0",
|
||||
|
||||
BIN
frontend/public/assets/placeholder.jpg
Normal file
BIN
frontend/public/assets/placeholder.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
@@ -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(filename: string): Promise<void> {
|
||||
await fetch('/api/favorites', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ filename }),
|
||||
});
|
||||
},
|
||||
|
||||
async removeFromFavorites(filename: string): Promise<void> {
|
||||
await fetch(`/api/favorites/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async clearFavorites(): Promise<void> {
|
||||
await fetch('/api/favorites', { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async updateFavoriteTitle(filename: string, title: string): Promise<void> {
|
||||
await fetch(`/api/favorites/${encodeURIComponent(filename)}/title`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, KeyboardEvent, ChangeEvent } from 'react';
|
||||
import { FaSearch } from 'react-icons/fa';
|
||||
import InvidiousSearchModal from './InvidiousSearchModal';
|
||||
import { USE_INVIDIOUS } from '../config';
|
||||
|
||||
interface AddSongPanelProps {
|
||||
onAddURL: (url: string) => void;
|
||||
@@ -6,6 +9,7 @@ interface AddSongPanelProps {
|
||||
|
||||
const AddSongPanel: React.FC<AddSongPanelProps> = ({ onAddURL }) => {
|
||||
const [url, setUrl] = useState('');
|
||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
||||
|
||||
const handleAddURL = () => {
|
||||
onAddURL(url);
|
||||
@@ -15,13 +19,22 @@ const AddSongPanel: React.FC<AddSongPanelProps> = ({ onAddURL }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-fit bg-black/50 md:rounded-b-2xl text-white">
|
||||
<div className="flex flex-row items-center gap-4 w-full px-8 py-4">
|
||||
{USE_INVIDIOUS && (
|
||||
<button
|
||||
className="bg-violet-500/20 text-white p-2 rounded-lg border-2 border-violet-500"
|
||||
onClick={() => setIsSearchModalOpen(true)}
|
||||
>
|
||||
<FaSearch />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setUrl(e.target.value)}
|
||||
placeholder="Add any URL..."
|
||||
className="p-2 rounded-lg border-2 border-violet-500 flex-grow"
|
||||
onKeyDown={(e) => {
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleAddURL();
|
||||
}
|
||||
@@ -35,6 +48,15 @@ const AddSongPanel: React.FC<AddSongPanelProps> = ({ onAddURL }) => {
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<InvidiousSearchModal
|
||||
isOpen={isSearchModalOpen}
|
||||
onClose={() => setIsSearchModalOpen(false)}
|
||||
onSelectVideo={(videoUrl) => {
|
||||
onAddURL(videoUrl);
|
||||
setIsSearchModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,8 +1,84 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, ReactNode } from 'react';
|
||||
import SongTable from './SongTable';
|
||||
import NowPlaying from './NowPlaying';
|
||||
import AddSongPanel from './AddSongPanel';
|
||||
import { API, getDisplayTitle, PlaylistItem } from '../api/player';
|
||||
import RenameFavoriteModal from './RenameFavoriteModal';
|
||||
import { TabView, Tab } from './TabView';
|
||||
import { API, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player';
|
||||
import { FaMusic, FaHeart, FaPlus, FaEdit } from 'react-icons/fa';
|
||||
import useWebSocket from 'react-use-websocket';
|
||||
import classNames from 'classnames';
|
||||
|
||||
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;
|
||||
auxControlProvider?: (song: PlaylistItem) => ReactNode;
|
||||
onNeedsRefresh: () => void;
|
||||
}
|
||||
|
||||
const PlaylistContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, auxControlProvider, 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}
|
||||
auxControlProvider={auxControlProvider}
|
||||
onDelete={handleDelete}
|
||||
onSkipTo={handleSkipTo}
|
||||
/>
|
||||
) : (
|
||||
<EmptyContent label="Playlist is empty" />
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const FavoritesContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, auxControlProvider, onNeedsRefresh }) => {
|
||||
const handleDelete = (index: number) => {
|
||||
API.removeFromFavorites(songs[index].filename);
|
||||
onNeedsRefresh();
|
||||
};
|
||||
|
||||
const handleSkipTo = (index: number) => {
|
||||
API.replaceCurrentFile(songs[index].filename);
|
||||
API.play();
|
||||
onNeedsRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
songs.length > 0 ? (
|
||||
<SongTable
|
||||
songs={songs}
|
||||
isPlaying={isPlaying}
|
||||
auxControlProvider={auxControlProvider}
|
||||
onDelete={handleDelete}
|
||||
onSkipTo={handleSkipTo}
|
||||
/>
|
||||
) : (
|
||||
<EmptyContent label="Favorites are empty" />
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
@@ -10,30 +86,46 @@ 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 [_, setWs] = useState<WebSocket | null>(null);
|
||||
const [playlist, setPlaylist] = useState<PlaylistItem[]>([]);
|
||||
const [favorites, setFavorites] = useState<PlaylistItem[]>([]);
|
||||
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.Playlist);
|
||||
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
|
||||
const [favoriteToRename, setFavoriteToRename] = useState<PlaylistItem | null>(null);
|
||||
|
||||
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);
|
||||
}
|
||||
setVolume(nowPlaying.volume);
|
||||
}, [volumeSettingIsLocked]);
|
||||
|
||||
const handleAddURL = async (url: string) => {
|
||||
const urlToAdd = url.trim();
|
||||
if (urlToAdd) {
|
||||
await API.addToPlaylist(urlToAdd);
|
||||
fetchPlaylist();
|
||||
if (selectedTab === Tabs.Favorites) {
|
||||
await API.addToFavorites(urlToAdd);
|
||||
fetchFavorites();
|
||||
} else {
|
||||
await API.addToPlaylist(urlToAdd);
|
||||
fetchPlaylist();
|
||||
}
|
||||
|
||||
if (!isPlaying) {
|
||||
await API.play();
|
||||
@@ -41,26 +133,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,35 +158,140 @@ 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]);
|
||||
|
||||
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/events`;
|
||||
console.log('Connecting to WebSocket at', wsUrl);
|
||||
useWebSocket(wsUrl, {
|
||||
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]);
|
||||
fetchFavorites();
|
||||
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
|
||||
|
||||
// WebSocket connection
|
||||
useEffect(() => {
|
||||
const ws = API.subscribeToEvents(handleWebSocketEvent);
|
||||
setWs(ws);
|
||||
|
||||
return () => {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
const AuxButton: React.FC<{ children: ReactNode, className: string, title: string, onClick: () => void }> = (props) => (
|
||||
<button
|
||||
className={
|
||||
classNames("hover:text-white transition-colors px-3 py-1 rounded", props.className)
|
||||
}
|
||||
};
|
||||
}, [handleWebSocketEvent]);
|
||||
title={props.title}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
|
||||
const playlistAuxControlProvider = (song: PlaylistItem) => {
|
||||
const isFavorite = favorites.some(f => f.filename === song.filename);
|
||||
return (
|
||||
<AuxButton
|
||||
className={classNames({
|
||||
"text-red-500": isFavorite,
|
||||
"text-white/40": !isFavorite,
|
||||
})}
|
||||
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
|
||||
onClick={() => {
|
||||
if (isFavorite) {
|
||||
API.removeFromFavorites(song.filename);
|
||||
} else {
|
||||
API.addToFavorites(song.filename);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FaHeart />
|
||||
</AuxButton>
|
||||
);
|
||||
};
|
||||
|
||||
const favoritesAuxControlProvider = (song: PlaylistItem) => {
|
||||
const isInPlaylist = playlist.some(p => p.filename === song.filename);
|
||||
return (
|
||||
<div className="flex">
|
||||
<AuxButton
|
||||
className="text-white hover:text-white"
|
||||
title="Rename favorite"
|
||||
onClick={() => {
|
||||
setFavoriteToRename(song);
|
||||
setIsRenameModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<FaEdit />
|
||||
</AuxButton>
|
||||
|
||||
<AuxButton
|
||||
className={classNames({
|
||||
"text-white/40": isInPlaylist,
|
||||
"text-white": !isInPlaylist,
|
||||
})}
|
||||
title={isInPlaylist ? "Remove from playlist" : "Add to playlist"}
|
||||
onClick={() => {
|
||||
if (isInPlaylist) {
|
||||
API.removeFromPlaylist(playlist.findIndex(p => p.filename === song.filename));
|
||||
} else {
|
||||
API.addToPlaylist(song.filename);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FaPlus />
|
||||
</AuxButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen w-screen bg-black md:py-10">
|
||||
@@ -133,20 +310,32 @@ const App: React.FC = () => {
|
||||
onVolumeDidChange={() => setVolumeSettingIsLocked(false)}
|
||||
/>
|
||||
|
||||
{songs.length > 0 ? (
|
||||
<SongTable
|
||||
songs={songs}
|
||||
isPlaying={isPlaying}
|
||||
onDelete={handleDelete}
|
||||
onSkipTo={handleSkipTo}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-white text-2xl font-bold">Playlist is empty</div>
|
||||
</div>
|
||||
)}
|
||||
<TabView selectedTab={selectedTab} onTabChange={setSelectedTab}>
|
||||
<Tab label="Playlist" identifier={Tabs.Playlist} icon={<FaMusic />}>
|
||||
<PlaylistContent
|
||||
songs={playlist}
|
||||
isPlaying={isPlaying}
|
||||
onNeedsRefresh={refreshContent}
|
||||
auxControlProvider={playlistAuxControlProvider}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab label="Favorites" identifier={Tabs.Favorites} icon={<FaHeart />}>
|
||||
<FavoritesContent
|
||||
songs={favorites.map(f => ({ ...f, playing: f.filename === nowPlayingFileName }))}
|
||||
isPlaying={isPlaying}
|
||||
onNeedsRefresh={refreshContent}
|
||||
auxControlProvider={favoritesAuxControlProvider}
|
||||
/>
|
||||
</Tab>
|
||||
</TabView>
|
||||
|
||||
<AddSongPanel onAddURL={handleAddURL} />
|
||||
|
||||
<RenameFavoriteModal
|
||||
isOpen={isRenameModalOpen}
|
||||
onClose={() => setIsRenameModalOpen(false)}
|
||||
favorite={favoriteToRename}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
111
frontend/src/components/InvidiousSearchModal.tsx
Normal file
111
frontend/src/components/InvidiousSearchModal.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState, KeyboardEvent } from 'react';
|
||||
import { FaSearch, FaSpinner, FaTimes } from 'react-icons/fa';
|
||||
import { API, SearchResult } from '../api/player';
|
||||
|
||||
interface InvidiousSearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelectVideo: (url: string) => void;
|
||||
}
|
||||
|
||||
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={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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const InvidiousSearchModal: React.FC<InvidiousSearchModalProps> = ({ isOpen, onClose, onSelectVideo }) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchQuery.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
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 {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const _onSelectVideo = (url: string) => {
|
||||
setSearchQuery('');
|
||||
setResults([]);
|
||||
onSelectVideo(url);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-violet-900 w-full max-w-xl rounded-lg p-4 shadow-lg">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-white text-xl font-bold">Search YouTube (Invidious)</h2>
|
||||
|
||||
<button onClick={onClose} className="text-white/60 hover:text-white">
|
||||
<FaTimes size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-4">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Search videos..."
|
||||
className="p-2 rounded-lg border-2 border-violet-500 flex-grow bg-black/20 text-white"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={isLoading}
|
||||
className="bg-violet-500 text-white p-2 rounded-lg px-4 border-2 border-violet-500 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? <span className="animate-spin"><FaSpinner /></span> : <span><FaSearch /></span>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="text-white text-center py-12">Searching...</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{results.map((result) => (
|
||||
<ResultCell
|
||||
key={result.mediaUrl}
|
||||
result={result}
|
||||
onClick={() => _onSelectVideo(result.mediaUrl)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvidiousSearchModal;
|
||||
97
frontend/src/components/RenameFavoriteModal.tsx
Normal file
97
frontend/src/components/RenameFavoriteModal.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useState, KeyboardEvent, useEffect } from 'react';
|
||||
import { FaTimes, FaCheck } from 'react-icons/fa';
|
||||
import { API, PlaylistItem, getDisplayTitle } from '../api/player';
|
||||
|
||||
interface RenameFavoriteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
favorite: PlaylistItem | null;
|
||||
}
|
||||
|
||||
const RenameFavoriteModal: React.FC<RenameFavoriteModalProps> = ({ isOpen, onClose, favorite }) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (favorite) {
|
||||
setTitle(getDisplayTitle(favorite));
|
||||
}
|
||||
}, [favorite]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!favorite || !title.trim()) return;
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await API.updateFavoriteTitle(favorite.filename, title);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to rename favorite:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen || !favorite) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-violet-900 w-full max-w-md rounded-lg p-4 shadow-lg">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-white text-xl font-bold">Rename Favorite</h2>
|
||||
|
||||
<button onClick={onClose} className="text-white/60 hover:text-white">
|
||||
<FaTimes size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="text-white/80 block mb-2">Original filename:</label>
|
||||
<div className="text-white/60 text-sm truncate bg-black/20 p-2 rounded-lg mb-4">
|
||||
{favorite.filename}
|
||||
</div>
|
||||
|
||||
<label className="text-white/80 block mb-2">Title:</label>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter new title..."
|
||||
className="p-2 rounded-lg border-2 border-violet-500 w-full bg-black/20 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="bg-black/30 text-white p-2 rounded-lg px-4 hover:bg-black/40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !title.trim()}
|
||||
className="bg-violet-500 text-white p-2 rounded-lg px-4 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
<FaCheck size={16} />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RenameFavoriteModal;
|
||||
@@ -1,5 +1,5 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect, ReactNode } from 'react';
|
||||
import { FaPlay, FaVolumeUp, FaVolumeOff } from 'react-icons/fa';
|
||||
import { getDisplayTitle, PlaylistItem } from '../api/player';
|
||||
|
||||
@@ -11,12 +11,13 @@ export enum PlayState {
|
||||
|
||||
export interface SongRowProps {
|
||||
song: PlaylistItem;
|
||||
auxControl?: ReactNode;
|
||||
playState: PlayState;
|
||||
onDelete: () => void;
|
||||
onPlay: () => void;
|
||||
}
|
||||
|
||||
const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay }) => {
|
||||
const SongRow: React.FC<SongRowProps> = ({ song, auxControl, playState, onDelete, onPlay }) => {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -39,8 +40,9 @@ const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay })
|
||||
const displayTitle = getDisplayTitle(song);
|
||||
|
||||
return (
|
||||
<div className={classNames("flex flex-row w-full h-[100px] 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">
|
||||
@@ -76,9 +78,11 @@ const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay })
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
{auxControl}
|
||||
|
||||
<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) {
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import React, { useEffect, useRef, ReactNode } from "react";
|
||||
import SongRow, { PlayState } from "./SongRow";
|
||||
import { PlaylistItem } from "../api/player";
|
||||
|
||||
interface SongTableProps {
|
||||
songs: PlaylistItem[];
|
||||
isPlaying: boolean;
|
||||
isPlaying: boolean
|
||||
auxControlProvider?: (song: PlaylistItem) => ReactNode;
|
||||
onDelete: (index: number) => void;
|
||||
onSkipTo: (index: number) => void;
|
||||
}
|
||||
|
||||
const SongTable: React.FC<SongTableProps> = ({ songs, isPlaying, onDelete, onSkipTo }) => {
|
||||
const SongTable: React.FC<SongTableProps> = ({ songs, isPlaying, auxControlProvider, onDelete, onSkipTo }) => {
|
||||
const nowPlayingIndex = songs.findIndex(song => song.playing ?? false);
|
||||
const songTableRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -21,11 +22,12 @@ 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}
|
||||
song={song}
|
||||
auxControl={auxControlProvider ? auxControlProvider(song) : undefined}
|
||||
playState={
|
||||
(song.playing ?? false) ? (isPlaying ? PlayState.Playing : PlayState.Paused)
|
||||
: PlayState.NotPlaying
|
||||
|
||||
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 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>
|
||||
);
|
||||
};
|
||||
6
frontend/src/config.ts
Normal file
6
frontend/src/config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
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`;
|
||||
|
||||
export const getInvidiousSearchURL = (query: string): string =>
|
||||
`${INVIDIOUS_API_ENDPOINT}/search?q=${encodeURIComponent(query)}`;
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
7579
package-lock.json
generated
7579
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
41
package.json
41
package.json
@@ -1,30 +1,15 @@
|
||||
{
|
||||
"name": "mpvqueue",
|
||||
"version": "1.0.0",
|
||||
"main": "build/server.js",
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
"name": "queuecube",
|
||||
"version": "1.82",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently \"cd frontend && npm run dev\" \"cd backend && npm run dev\""
|
||||
},
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
],
|
||||
"dependencies": {
|
||||
"react-use-websocket": "^4.13.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