Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd79fd9ecd | |||
| 729279ea78 | |||
| 9303c5acfe | |||
| 2b533cf1db | |||
| 0a86dbed49 | |||
| 6a574597c5 | |||
| de5eefb9f4 | |||
| 29ce0863ca | |||
| 647ee74bf6 | |||
| 5ca056dbc8 | |||
| e7bb991df7 | |||
| 92ab7d572c | |||
| 795a6b7290 | |||
| d010d68056 | |||
| 8ab927333b | |||
| acd31a9154 | |||
| 687a7fc555 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
build/
|
build/
|
||||||
|
result
|
||||||
node_modules/
|
node_modules/
|
||||||
tsconfig.tsbuildinfo
|
tsconfig.tsbuildinfo
|
||||||
|
|||||||
@@ -7,14 +7,11 @@ WORKDIR /app
|
|||||||
COPY backend/package*.json ./backend/
|
COPY backend/package*.json ./backend/
|
||||||
COPY frontend/package*.json ./frontend/
|
COPY frontend/package*.json ./frontend/
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
RUN cd backend && npm ci
|
|
||||||
RUN cd frontend && npm ci
|
|
||||||
|
|
||||||
# Copy source files
|
# Copy source files
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Build frontend and backend
|
# Build frontend and backend
|
||||||
|
RUN npm install
|
||||||
RUN npm run build --workspaces
|
RUN npm run build --workspaces
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
|
|||||||
37
README.md
37
README.md
@@ -32,3 +32,40 @@ On some systems, you may need to add `--security-opt seccomp=unconfined` to allo
|
|||||||
|
|
||||||
Once running, you should be able to access the UI via http://localhost:8080.
|
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`).
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
[]
|
|
||||||
@@ -25,7 +25,7 @@ export class FavoritesStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// In production (in a container), use /app/data
|
// In production (in a container), use /app/data
|
||||||
if (process.env.NODE_ENV === 'production') {
|
else if (process.env.NODE_ENV === 'production') {
|
||||||
storePath = path.resolve('/app/data');
|
storePath = path.resolve('/app/data');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,7 +33,9 @@ export class FavoritesStore {
|
|||||||
console.error('Failed to create intermediate directory:', err);
|
console.error('Failed to create intermediate directory:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
return path.join(storePath, storeFilename);
|
const fullPath = path.join(storePath, storeFilename);
|
||||||
|
console.log("Favorites store path: " + fullPath);
|
||||||
|
return fullPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async loadFavorites() {
|
private async loadFavorites() {
|
||||||
@@ -59,52 +61,69 @@ export class FavoritesStore {
|
|||||||
return this.favorites;
|
return this.favorites;
|
||||||
}
|
}
|
||||||
|
|
||||||
async addFavorite(item: PlaylistItem, fetchMetadata: boolean = true): Promise<void> {
|
async addFavorite(filename: string): Promise<void> {
|
||||||
// Check if the item already exists by filename
|
// Check if the item already exists by filename
|
||||||
const exists = this.favorites.some(f => f.filename === item.filename);
|
const exists = this.favorites.some(f => f.filename === filename);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
this.favorites.push({
|
this.favorites.push({
|
||||||
...item,
|
filename: filename,
|
||||||
id: this.favorites.length // Generate new ID
|
id: this.favorites.length // Generate new ID
|
||||||
});
|
});
|
||||||
await this.saveFavorites();
|
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
|
// Fetch metadata for the new favorite
|
||||||
if (fetchMetadata && !item.metadata) {
|
await this.fetchMetadata(filename);
|
||||||
await this.fetchMetadata(item);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchMetadata(item: PlaylistItem): Promise<void> {
|
private async fetchMetadata(filename: string): Promise<void> {
|
||||||
console.log("Fetching metadata for " + item.filename);
|
console.log("Fetching metadata for " + filename);
|
||||||
const metadata = await getLinkPreview(item.filename);
|
const metadata = await getLinkPreview(filename);
|
||||||
|
|
||||||
item.metadata = {
|
const item: PlaylistItem = {
|
||||||
title: (metadata as any)?.title,
|
filename: filename,
|
||||||
description: (metadata as any)?.description,
|
id: this.favorites.length,
|
||||||
siteName: (metadata as any)?.siteName,
|
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("Metadata fetched for " + item.filename);
|
||||||
console.log(item);
|
console.log(item);
|
||||||
|
|
||||||
await this.addFavorite(item, false);
|
const index = this.favorites.findIndex(f => f.filename === filename);
|
||||||
|
if (index !== -1) {
|
||||||
|
this.favorites[index] = item;
|
||||||
|
await this.saveFavorites();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async removeFavorite(id: number): Promise<void> {
|
async removeFavorite(filename: string): Promise<void> {
|
||||||
this.favorites = this.favorites.filter(f => f.id !== id);
|
console.log("Removing favorite " + filename);
|
||||||
|
this.favorites = this.favorites.filter(f => f.filename !== filename);
|
||||||
await this.saveFavorites();
|
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> {
|
async clearFavorites(): Promise<void> {
|
||||||
this.favorites = [];
|
this.favorites = [];
|
||||||
await this.saveFavorites();
|
await this.saveFavorites();
|
||||||
|
|||||||
@@ -4,11 +4,21 @@ import { WebSocket } from "ws";
|
|||||||
import { getLinkPreview } from "link-preview-js";
|
import { getLinkPreview } from "link-preview-js";
|
||||||
import { PlaylistItem, LinkMetadata } from './types';
|
import { PlaylistItem, LinkMetadata } from './types';
|
||||||
import { FavoritesStore } from "./FavoritesStore";
|
import { FavoritesStore } from "./FavoritesStore";
|
||||||
|
|
||||||
interface PendingCommand {
|
interface PendingCommand {
|
||||||
resolve: (value: any) => void;
|
resolve: (value: any) => void;
|
||||||
reject: (reason: any) => void;
|
reject: (reason: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum UserEvent {
|
||||||
|
PlaylistUpdate = "playlist_update",
|
||||||
|
NowPlayingUpdate = "now_playing_update",
|
||||||
|
VolumeUpdate = "volume_update",
|
||||||
|
FavoritesUpdate = "favorites_update",
|
||||||
|
MetadataUpdate = "metadata_update",
|
||||||
|
MPDUpdate = "mpd_update",
|
||||||
|
}
|
||||||
|
|
||||||
export class MediaPlayer {
|
export class MediaPlayer {
|
||||||
private playerProcess: ChildProcess;
|
private playerProcess: ChildProcess;
|
||||||
private socket: Socket;
|
private socket: Socket;
|
||||||
@@ -23,10 +33,12 @@ export class MediaPlayer {
|
|||||||
constructor() {
|
constructor() {
|
||||||
const socketFilename = Math.random().toString(36).substring(2, 10);
|
const socketFilename = Math.random().toString(36).substring(2, 10);
|
||||||
const socketPath = `/tmp/mpv-${socketFilename}`;
|
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", [
|
this.playerProcess = spawn("mpv", [
|
||||||
"--no-video",
|
"--video=" + (enableVideo ? "auto" : "no"),
|
||||||
|
"--fullscreen",
|
||||||
"--no-terminal",
|
"--no-terminal",
|
||||||
"--idle=yes",
|
"--idle=yes",
|
||||||
"--input-ipc-server=" + socketPath
|
"--input-ipc-server=" + socketPath
|
||||||
@@ -43,7 +55,7 @@ export class MediaPlayer {
|
|||||||
|
|
||||||
this.favoritesStore = new FavoritesStore();
|
this.favoritesStore = new FavoritesStore();
|
||||||
this.favoritesStore.onFavoritesChanged = (favorites) => {
|
this.favoritesStore.onFavoritesChanged = (favorites) => {
|
||||||
this.handleEvent("favorites_update", { favorites });
|
this.handleEvent(UserEvent.FavoritesUpdate, { favorites });
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,31 +135,31 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async play() {
|
public async play() {
|
||||||
return this.modify(() => this.writeCommand("set_property", ["pause", false]));
|
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", false]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async pause() {
|
public async pause() {
|
||||||
return this.modify(() => this.writeCommand("set_property", ["pause", true]));
|
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", true]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async skip() {
|
public async skip() {
|
||||||
return this.modify(() => this.writeCommand("playlist-next", []));
|
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-next", []));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async skipTo(index: number) {
|
public async skipTo(index: number) {
|
||||||
return this.modify(() => this.writeCommand("playlist-play-index", [index]));
|
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-play-index", [index]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async previous() {
|
public async previous() {
|
||||||
return this.modify(() => this.writeCommand("playlist-prev", []));
|
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-prev", []));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async deletePlaylistItem(index: number) {
|
public async deletePlaylistItem(index: number) {
|
||||||
return this.modify(() => this.writeCommand("playlist-remove", [index]));
|
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-remove", [index]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async setVolume(volume: number) {
|
public async setVolume(volume: number) {
|
||||||
return this.modify(() => this.writeCommand("set_property", ["volume", volume]));
|
return this.modify(UserEvent.VolumeUpdate, () => this.writeCommand("set_property", ["volume", volume]));
|
||||||
}
|
}
|
||||||
|
|
||||||
public subscribe(ws: WebSocket) {
|
public subscribe(ws: WebSocket) {
|
||||||
@@ -162,31 +174,35 @@ export class MediaPlayer {
|
|||||||
return this.favoritesStore.getFavorites();
|
return this.favoritesStore.getFavorites();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async addFavorite(item: PlaylistItem) {
|
public async addFavorite(filename: string) {
|
||||||
return this.favoritesStore.addFavorite(item);
|
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.addFavorite(filename));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async removeFavorite(id: number) {
|
public async removeFavorite(filename: string) {
|
||||||
return this.favoritesStore.removeFavorite(id);
|
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.removeFavorite(filename));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async clearFavorites() {
|
public async clearFavorites() {
|
||||||
return this.favoritesStore.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) {
|
private async loadFile(url: string, mode: string) {
|
||||||
this.modify(() => this.writeCommand("loadfile", [url, mode]));
|
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode]));
|
||||||
|
|
||||||
this.fetchMetadataAndNotify(url).catch(error => {
|
this.fetchMetadataAndNotify(url).catch(error => {
|
||||||
console.warn(`Failed to fetch metadata for ${url}:`, error);
|
console.warn(`Failed to fetch metadata for ${url}:`, error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async modify<T>(func: () => Promise<T>): Promise<T> {
|
private async modify<T>(event: UserEvent, func: () => Promise<T>): Promise<T> {
|
||||||
return func()
|
return func()
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
// Notify all subscribers
|
// Notify all subscribers
|
||||||
this.handleEvent("user_modify", {});
|
this.handleEvent(event, {});
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -230,7 +246,7 @@ export class MediaPlayer {
|
|||||||
console.log(this.metadata.get(url));
|
console.log(this.metadata.get(url));
|
||||||
|
|
||||||
// Notify clients that metadata has been updated
|
// Notify clients that metadata has been updated
|
||||||
this.handleEvent("metadata_update", {
|
this.handleEvent(UserEvent.MetadataUpdate, {
|
||||||
url,
|
url,
|
||||||
metadata: this.metadata.get(url)
|
metadata: this.metadata.get(url)
|
||||||
});
|
});
|
||||||
@@ -245,7 +261,7 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleEvent(event: string, data: any) {
|
private handleEvent(event: string, data: any) {
|
||||||
console.log("Event [" + event + "]: " + JSON.stringify(data, null, 2));
|
console.log("Event [" + event + "]: ", data);
|
||||||
|
|
||||||
// Notify all subscribers
|
// Notify all subscribers
|
||||||
this.eventSubscribers.forEach(subscriber => {
|
this.eventSubscribers.forEach(subscriber => {
|
||||||
@@ -272,7 +288,7 @@ export class MediaPlayer {
|
|||||||
this.pendingCommands.delete(response.request_id);
|
this.pendingCommands.delete(response.request_id);
|
||||||
}
|
}
|
||||||
} else if (response.event) {
|
} else if (response.event) {
|
||||||
this.handleEvent(response.event, response);
|
this.handleEvent(UserEvent.MPDUpdate, response);
|
||||||
} else {
|
} else {
|
||||||
console.log(response);
|
console.log(response);
|
||||||
}
|
}
|
||||||
@@ -282,4 +298,4 @@ export class MediaPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import expressWs from "express-ws";
|
import expressWs from "express-ws";
|
||||||
|
import path from "path";
|
||||||
import { MediaPlayer } from "./MediaPlayer";
|
import { MediaPlayer } from "./MediaPlayer";
|
||||||
import { searchInvidious, fetchThumbnail } from "./InvidiousAPI";
|
import { searchInvidious, fetchThumbnail } from "./InvidiousAPI";
|
||||||
import { PlaylistItem } from './types';
|
import { PlaylistItem } from './types';
|
||||||
@@ -140,14 +141,15 @@ apiRouter.get("/favorites", withErrorHandling(async (req, res) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
apiRouter.post("/favorites", withErrorHandling(async (req, res) => {
|
apiRouter.post("/favorites", withErrorHandling(async (req, res) => {
|
||||||
const item = req.body as PlaylistItem;
|
const { filename } = req.body as { filename: string };
|
||||||
await mediaPlayer.addFavorite(item);
|
console.log("Adding favorite: " + filename);
|
||||||
|
await mediaPlayer.addFavorite(filename);
|
||||||
res.send(JSON.stringify({ success: true }));
|
res.send(JSON.stringify({ success: true }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
apiRouter.delete("/favorites/:id", withErrorHandling(async (req, res) => {
|
apiRouter.delete("/favorites/:filename", withErrorHandling(async (req, res) => {
|
||||||
const { id } = req.params;
|
const { filename } = req.params as { filename: string };
|
||||||
await mediaPlayer.removeFavorite(parseInt(id));
|
await mediaPlayer.removeFavorite(filename);
|
||||||
res.send(JSON.stringify({ success: true }));
|
res.send(JSON.stringify({ success: true }));
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -156,15 +158,31 @@ apiRouter.delete("/favorites", withErrorHandling(async (req, res) => {
|
|||||||
res.send(JSON.stringify({ success: true }));
|
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)
|
// 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
|
// Mount API routes under /api
|
||||||
app.use("/api", apiRouter);
|
app.use("/api", apiRouter);
|
||||||
|
|
||||||
// Serve React app for all other routes (client-side routing)
|
// Serve React app for all other routes (client-side routing)
|
||||||
app.get("*", (req, res) => {
|
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;
|
const port = process.env.PORT || 3000;
|
||||||
|
|||||||
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -46,6 +46,15 @@ export interface SearchResponse {
|
|||||||
results: SearchResult[];
|
results: SearchResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ServerEvent {
|
||||||
|
PlaylistUpdate = "playlist_update",
|
||||||
|
NowPlayingUpdate = "now_playing_update",
|
||||||
|
VolumeUpdate = "volume_update",
|
||||||
|
FavoritesUpdate = "favorites_update",
|
||||||
|
MetadataUpdate = "metadata_update",
|
||||||
|
MPDUpdate = "mpd_update",
|
||||||
|
}
|
||||||
|
|
||||||
export const API = {
|
export const API = {
|
||||||
async getPlaylist(): Promise<PlaylistItem[]> {
|
async getPlaylist(): Promise<PlaylistItem[]> {
|
||||||
const response = await fetch('/api/playlist');
|
const response = await fetch('/api/playlist');
|
||||||
@@ -133,31 +142,31 @@ export const API = {
|
|||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
|
|
||||||
async addToFavorites(url: string): Promise<void> {
|
async addToFavorites(filename: 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', {
|
await fetch('/api/favorites', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(playlistItem),
|
body: JSON.stringify({ filename }),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async removeFromFavorites(id: number): Promise<void> {
|
async removeFromFavorites(filename: string): Promise<void> {
|
||||||
await fetch(`/api/favorites/${id}`, { method: 'DELETE' });
|
await fetch(`/api/favorites/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
|
|
||||||
async clearFavorites(): Promise<void> {
|
async clearFavorites(): Promise<void> {
|
||||||
await fetch('/api/favorites', { method: 'DELETE' });
|
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 }),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, KeyboardEvent, ChangeEvent } from 'react';
|
|||||||
import { FaSearch } from 'react-icons/fa';
|
import { FaSearch } from 'react-icons/fa';
|
||||||
import InvidiousSearchModal from './InvidiousSearchModal';
|
import InvidiousSearchModal from './InvidiousSearchModal';
|
||||||
import { USE_INVIDIOUS } from '../config';
|
import { USE_INVIDIOUS } from '../config';
|
||||||
|
|
||||||
interface AddSongPanelProps {
|
interface AddSongPanelProps {
|
||||||
onAddURL: (url: string) => void;
|
onAddURL: (url: string) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, ReactNode } from 'react';
|
||||||
import SongTable from './SongTable';
|
import SongTable from './SongTable';
|
||||||
import NowPlaying from './NowPlaying';
|
import NowPlaying from './NowPlaying';
|
||||||
import AddSongPanel from './AddSongPanel';
|
import AddSongPanel from './AddSongPanel';
|
||||||
|
import RenameFavoriteModal from './RenameFavoriteModal';
|
||||||
import { TabView, Tab } from './TabView';
|
import { TabView, Tab } from './TabView';
|
||||||
import { API, getDisplayTitle, PlaylistItem } from '../api/player';
|
import { API, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player';
|
||||||
import { useEventWebSocket } from '../hooks/useEventWebsocket';
|
import { FaMusic, FaHeart, FaPlus, FaEdit } from 'react-icons/fa';
|
||||||
import { FaMusic, FaHeart } from 'react-icons/fa';
|
import useWebSocket from 'react-use-websocket';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
enum Tabs {
|
enum Tabs {
|
||||||
Playlist = "playlist",
|
Playlist = "playlist",
|
||||||
@@ -21,10 +23,11 @@ const EmptyContent: React.FC<{ label: string}> = ({label}) => (
|
|||||||
interface SonglistContentProps {
|
interface SonglistContentProps {
|
||||||
songs: PlaylistItem[];
|
songs: PlaylistItem[];
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
|
auxControlProvider?: (song: PlaylistItem) => ReactNode;
|
||||||
onNeedsRefresh: () => void;
|
onNeedsRefresh: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlaylistContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, onNeedsRefresh }) => {
|
const PlaylistContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, auxControlProvider, onNeedsRefresh }) => {
|
||||||
const handleDelete = (index: number) => {
|
const handleDelete = (index: number) => {
|
||||||
API.removeFromPlaylist(index);
|
API.removeFromPlaylist(index);
|
||||||
onNeedsRefresh();
|
onNeedsRefresh();
|
||||||
@@ -40,6 +43,7 @@ const PlaylistContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, onN
|
|||||||
<SongTable
|
<SongTable
|
||||||
songs={songs}
|
songs={songs}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
|
auxControlProvider={auxControlProvider}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onSkipTo={handleSkipTo}
|
onSkipTo={handleSkipTo}
|
||||||
/>
|
/>
|
||||||
@@ -49,9 +53,9 @@ const PlaylistContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, onN
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const FavoritesContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, onNeedsRefresh }) => {
|
const FavoritesContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, auxControlProvider, onNeedsRefresh }) => {
|
||||||
const handleDelete = (index: number) => {
|
const handleDelete = (index: number) => {
|
||||||
API.removeFromFavorites(index);
|
API.removeFromFavorites(songs[index].filename);
|
||||||
onNeedsRefresh();
|
onNeedsRefresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -66,6 +70,7 @@ const FavoritesContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, on
|
|||||||
<SongTable
|
<SongTable
|
||||||
songs={songs}
|
songs={songs}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
|
auxControlProvider={auxControlProvider}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onSkipTo={handleSkipTo}
|
onSkipTo={handleSkipTo}
|
||||||
/>
|
/>
|
||||||
@@ -84,6 +89,8 @@ const App: React.FC = () => {
|
|||||||
const [playlist, setPlaylist] = useState<PlaylistItem[]>([]);
|
const [playlist, setPlaylist] = useState<PlaylistItem[]>([]);
|
||||||
const [favorites, setFavorites] = useState<PlaylistItem[]>([]);
|
const [favorites, setFavorites] = useState<PlaylistItem[]>([]);
|
||||||
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.Playlist);
|
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.Playlist);
|
||||||
|
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
|
||||||
|
const [favoriteToRename, setFavoriteToRename] = useState<PlaylistItem | null>(null);
|
||||||
|
|
||||||
const fetchPlaylist = useCallback(async () => {
|
const fetchPlaylist = useCallback(async () => {
|
||||||
const playlist = await API.getPlaylist();
|
const playlist = await API.getPlaylist();
|
||||||
@@ -96,14 +103,17 @@ const App: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchNowPlaying = useCallback(async () => {
|
const fetchNowPlaying = useCallback(async () => {
|
||||||
|
if (volumeSettingIsLocked) {
|
||||||
|
// We are actively changing the volume, which we do actually want to send events
|
||||||
|
// continuously to the server, but we don't want to refresh our state while doing that.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const nowPlaying = await API.getNowPlaying();
|
const nowPlaying = await API.getNowPlaying();
|
||||||
setNowPlayingSong(getDisplayTitle(nowPlaying.playingItem));
|
setNowPlayingSong(getDisplayTitle(nowPlaying.playingItem));
|
||||||
setNowPlayingFileName(nowPlaying.playingItem.filename);
|
setNowPlayingFileName(nowPlaying.playingItem.filename);
|
||||||
setIsPlaying(!nowPlaying.isPaused);
|
setIsPlaying(!nowPlaying.isPaused);
|
||||||
|
setVolume(nowPlaying.volume);
|
||||||
if (!volumeSettingIsLocked) {
|
|
||||||
setVolume(nowPlaying.volume);
|
|
||||||
}
|
|
||||||
}, [volumeSettingIsLocked]);
|
}, [volumeSettingIsLocked]);
|
||||||
|
|
||||||
const handleAddURL = async (url: string) => {
|
const handleAddURL = async (url: string) => {
|
||||||
@@ -148,23 +158,43 @@ const App: React.FC = () => {
|
|||||||
await API.setVolume(volume);
|
await API.setVolume(volume);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWebSocketEvent = useCallback((event: any) => {
|
const handleWebSocketEvent = useCallback((message: MessageEvent) => {
|
||||||
|
const event = JSON.parse(message.data);
|
||||||
switch (event.event) {
|
switch (event.event) {
|
||||||
case 'user_modify':
|
case ServerEvent.PlaylistUpdate:
|
||||||
case 'end-file':
|
case ServerEvent.NowPlayingUpdate:
|
||||||
case 'playback-restart':
|
case ServerEvent.MetadataUpdate:
|
||||||
case 'metadata_update':
|
case ServerEvent.MPDUpdate:
|
||||||
fetchPlaylist();
|
fetchPlaylist();
|
||||||
fetchNowPlaying();
|
fetchNowPlaying();
|
||||||
break;
|
break;
|
||||||
case 'favorites_update':
|
case ServerEvent.VolumeUpdate:
|
||||||
|
if (!volumeSettingIsLocked) {
|
||||||
|
fetchNowPlaying();
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
case ServerEvent.FavoritesUpdate:
|
||||||
fetchFavorites();
|
fetchFavorites();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
|
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
|
||||||
|
|
||||||
// Use the hook
|
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/events`;
|
||||||
useEventWebSocket(handleWebSocketEvent);
|
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
|
// Handle visibility changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -194,6 +224,75 @@ const App: React.FC = () => {
|
|||||||
fetchFavorites();
|
fetchFavorites();
|
||||||
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
|
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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 (
|
return (
|
||||||
<div className="flex items-center justify-center h-screen w-screen bg-black md:py-10">
|
<div className="flex items-center justify-center h-screen w-screen bg-black md:py-10">
|
||||||
<div className="bg-violet-900 w-full md:max-w-2xl h-full md:max-h-xl md:border md:rounded-2xl flex flex-col">
|
<div className="bg-violet-900 w-full md:max-w-2xl h-full md:max-h-xl md:border md:rounded-2xl flex flex-col">
|
||||||
@@ -217,6 +316,7 @@ const App: React.FC = () => {
|
|||||||
songs={playlist}
|
songs={playlist}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
onNeedsRefresh={refreshContent}
|
onNeedsRefresh={refreshContent}
|
||||||
|
auxControlProvider={playlistAuxControlProvider}
|
||||||
/>
|
/>
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab label="Favorites" identifier={Tabs.Favorites} icon={<FaHeart />}>
|
<Tab label="Favorites" identifier={Tabs.Favorites} icon={<FaHeart />}>
|
||||||
@@ -224,14 +324,21 @@ const App: React.FC = () => {
|
|||||||
songs={favorites.map(f => ({ ...f, playing: f.filename === nowPlayingFileName }))}
|
songs={favorites.map(f => ({ ...f, playing: f.filename === nowPlayingFileName }))}
|
||||||
isPlaying={isPlaying}
|
isPlaying={isPlaying}
|
||||||
onNeedsRefresh={refreshContent}
|
onNeedsRefresh={refreshContent}
|
||||||
|
auxControlProvider={favoritesAuxControlProvider}
|
||||||
/>
|
/>
|
||||||
</Tab>
|
</Tab>
|
||||||
</TabView>
|
</TabView>
|
||||||
|
|
||||||
<AddSongPanel onAddURL={handleAddURL} />
|
<AddSongPanel onAddURL={handleAddURL} />
|
||||||
|
|
||||||
|
<RenameFavoriteModal
|
||||||
|
isOpen={isRenameModalOpen}
|
||||||
|
onClose={() => setIsRenameModalOpen(false)}
|
||||||
|
favorite={favoriteToRename}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
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 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 { FaPlay, FaVolumeUp, FaVolumeOff } from 'react-icons/fa';
|
||||||
import { getDisplayTitle, PlaylistItem } from '../api/player';
|
import { getDisplayTitle, PlaylistItem } from '../api/player';
|
||||||
|
|
||||||
@@ -11,12 +11,13 @@ export enum PlayState {
|
|||||||
|
|
||||||
export interface SongRowProps {
|
export interface SongRowProps {
|
||||||
song: PlaylistItem;
|
song: PlaylistItem;
|
||||||
|
auxControl?: ReactNode;
|
||||||
playState: PlayState;
|
playState: PlayState;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onPlay: () => 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 [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
@@ -39,7 +40,8 @@ const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay })
|
|||||||
const displayTitle = getDisplayTitle(song);
|
const displayTitle = getDisplayTitle(song);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames("flex flex-row w-full h-24 px-2 py-5 items-center border-b gap-2 transition-colors", {
|
<div className={classNames(
|
||||||
|
"flex flex-row w-full h-20 px-2 py-2 items-center border-b gap-2 transition-colors shrink-0", {
|
||||||
"qc-highlighted": (playState === PlayState.Playing || playState === PlayState.Paused),
|
"qc-highlighted": (playState === PlayState.Playing || playState === PlayState.Paused),
|
||||||
"bg-black/30": playState === PlayState.NotPlaying,
|
"bg-black/30": playState === PlayState.NotPlaying,
|
||||||
})}>
|
})}>
|
||||||
@@ -76,6 +78,8 @@ const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay })
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row gap-2">
|
<div className="flex flex-row gap-2">
|
||||||
|
{auxControl}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
className="text-red-100 px-3 py-1 bg-red-500/40 rounded"
|
className="text-red-100 px-3 py-1 bg-red-500/40 rounded"
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef, ReactNode } from "react";
|
||||||
import SongRow, { PlayState } from "./SongRow";
|
import SongRow, { PlayState } from "./SongRow";
|
||||||
import { PlaylistItem } from "../api/player";
|
import { PlaylistItem } from "../api/player";
|
||||||
|
|
||||||
interface SongTableProps {
|
interface SongTableProps {
|
||||||
songs: PlaylistItem[];
|
songs: PlaylistItem[];
|
||||||
isPlaying: boolean;
|
isPlaying: boolean
|
||||||
|
auxControlProvider?: (song: PlaylistItem) => ReactNode;
|
||||||
onDelete: (index: number) => void;
|
onDelete: (index: number) => void;
|
||||||
onSkipTo: (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 nowPlayingIndex = songs.findIndex(song => song.playing ?? false);
|
||||||
const songTableRef = useRef<HTMLDivElement>(null);
|
const songTableRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -21,11 +22,12 @@ const SongTable: React.FC<SongTableProps> = ({ songs, isPlaying, onDelete, onSki
|
|||||||
}, [nowPlayingIndex]);
|
}, [nowPlayingIndex]);
|
||||||
|
|
||||||
return (
|
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) => (
|
{songs.map((song, index) => (
|
||||||
<SongRow
|
<SongRow
|
||||||
key={index}
|
key={index}
|
||||||
song={song}
|
song={song}
|
||||||
|
auxControl={auxControlProvider ? auxControlProvider(song) : undefined}
|
||||||
playState={
|
playState={
|
||||||
(song.playing ?? false) ? (isPlaying ? PlayState.Playing : PlayState.Paused)
|
(song.playing ?? false) ? (isPlaying ? PlayState.Playing : PlayState.Paused)
|
||||||
: PlayState.NotPlaying
|
: PlayState.NotPlaying
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const TabView = <T,>({ children, selectedTab, onTabChange }: TabViewProps
|
|||||||
) as React.ReactElement<TabProps<T>>[];
|
) as React.ReactElement<TabProps<T>>[];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col flex-1 overflow-hidden">
|
||||||
<div className="flex flex-row h-11 border-b border-white/20">
|
<div className="flex flex-row h-11 border-b border-white/20">
|
||||||
{tabs.map((tab, index) => {
|
{tabs.map((tab, index) => {
|
||||||
const isSelected = selectedTab === tab.props.identifier;
|
const isSelected = selectedTab === tab.props.identifier;
|
||||||
@@ -47,7 +47,7 @@ export const TabView = <T,>({ children, selectedTab, onTabChange }: TabViewProps
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1 overflow-hidden">
|
||||||
{tabs.map((tab, index) => (
|
{tabs.map((tab, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { API } from '../api/player';
|
|
||||||
|
|
||||||
export const useEventWebSocket = (onEvent: (event: any) => void) => {
|
|
||||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
|
||||||
const [wsReconnectTimeout, setWsReconnectTimeout] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const connectWebSocket = useCallback(() => {
|
|
||||||
// Clear any existing reconnection timeout
|
|
||||||
if (wsReconnectTimeout) {
|
|
||||||
window.clearTimeout(wsReconnectTimeout);
|
|
||||||
setWsReconnectTimeout(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close existing websocket if it exists
|
|
||||||
if (ws) {
|
|
||||||
ws.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
const websocket = API.subscribeToEvents(onEvent);
|
|
||||||
|
|
||||||
websocket.addEventListener('close', () => {
|
|
||||||
console.log('WebSocket closed. Attempting to reconnect...');
|
|
||||||
|
|
||||||
// Attempt to reconnect after 2 seconds
|
|
||||||
const timeout = window.setTimeout(() => {
|
|
||||||
connectWebSocket();
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
setWsReconnectTimeout(timeout);
|
|
||||||
});
|
|
||||||
|
|
||||||
websocket.addEventListener('error', (error) => {
|
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
websocket.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
setWs(websocket);
|
|
||||||
}, [wsReconnectTimeout, onEvent]);
|
|
||||||
|
|
||||||
// Check WebSocket health periodically
|
|
||||||
useEffect(() => {
|
|
||||||
const healthCheck = setInterval(() => {
|
|
||||||
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
|
|
||||||
console.log('WebSocket unhealthy, reconnecting...');
|
|
||||||
connectWebSocket();
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(healthCheck);
|
|
||||||
};
|
|
||||||
}, [ws, connectWebSocket]);
|
|
||||||
|
|
||||||
// Initial WebSocket connection
|
|
||||||
useEffect(() => {
|
|
||||||
connectWebSocket();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (ws) {
|
|
||||||
ws.close();
|
|
||||||
}
|
|
||||||
if (wsReconnectTimeout) {
|
|
||||||
window.clearTimeout(wsReconnectTimeout);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [connectWebSocket]);
|
|
||||||
|
|
||||||
return ws;
|
|
||||||
};
|
|
||||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -10,7 +10,10 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"backend",
|
"backend",
|
||||||
"frontend"
|
"frontend"
|
||||||
]
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"react-use-websocket": "^4.13.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"backend": {
|
"backend": {
|
||||||
"name": "mpvqueue",
|
"name": "mpvqueue",
|
||||||
@@ -4685,6 +4688,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-use-websocket": {
|
||||||
|
"version": "4.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-use-websocket/-/react-use-websocket-4.13.0.tgz",
|
||||||
|
"integrity": "sha512-anMuVoV//g2N76Wxqvqjjo1X48r9Np3y1/gMl7arX84tAPXdy5R7sB5lO5hvCzQRYjqXwV8XMAiEBOUbyrZFrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
|
|||||||
@@ -8,5 +8,8 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"backend",
|
"backend",
|
||||||
"frontend"
|
"frontend"
|
||||||
]
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"react-use-websocket": "^4.13.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user