15 Commits
2.0.2 ... 2.3

16 changed files with 598 additions and 76 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
build/
result
node_modules/
tsconfig.tsbuildinfo

View File

@@ -7,14 +7,11 @@ WORKDIR /app
COPY backend/package*.json ./backend/
COPY frontend/package*.json ./frontend/
# Install dependencies
RUN cd backend && npm ci
RUN cd frontend && npm ci
# Copy source files
COPY . .
# Build frontend and backend
RUN npm install
RUN npm run build --workspaces
# Production stage

View File

@@ -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.
### 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`).

View File

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

View File

@@ -61,52 +61,69 @@ export class FavoritesStore {
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
const exists = this.favorites.some(f => f.filename === item.filename);
const exists = this.favorites.some(f => f.filename === filename);
if (!exists) {
this.favorites.push({
...item,
filename: filename,
id: this.favorites.length // Generate new ID
});
await this.saveFavorites();
} else {
// Otherwise, update the item with the new metadata
const index = this.favorites.findIndex(f => f.filename === item.filename);
this.favorites[index] = {
...this.favorites[index],
...item,
};
await this.saveFavorites();
}
// If the item is missing metadata, fetch it
if (fetchMetadata && !item.metadata) {
await this.fetchMetadata(item);
// Fetch metadata for the new favorite
await this.fetchMetadata(filename);
}
}
private async fetchMetadata(item: PlaylistItem): Promise<void> {
console.log("Fetching metadata for " + item.filename);
const metadata = await getLinkPreview(item.filename);
private async fetchMetadata(filename: string): Promise<void> {
console.log("Fetching metadata for " + filename);
const metadata = await getLinkPreview(filename);
item.metadata = {
title: (metadata as any)?.title,
description: (metadata as any)?.description,
siteName: (metadata as any)?.siteName,
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);
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> {
this.favorites = this.favorites.filter(f => f.id !== id);
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();

View File

@@ -33,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
@@ -172,16 +174,20 @@ export class MediaPlayer {
return this.favoritesStore.getFavorites();
}
public async addFavorite(item: PlaylistItem) {
return this.favoritesStore.addFavorite(item);
public async addFavorite(filename: string) {
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.addFavorite(filename));
}
public async removeFavorite(id: number) {
return this.favoritesStore.removeFavorite(id);
public async removeFavorite(filename: string) {
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.removeFavorite(filename));
}
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) {
@@ -255,7 +261,7 @@ export class MediaPlayer {
}
private handleEvent(event: string, data: any) {
console.log("Event [" + event + "]: " + JSON.stringify(data, null, 2));
console.log("Event [" + event + "]: ", data);
// Notify all subscribers
this.eventSubscribers.forEach(subscriber => {

View File

@@ -1,5 +1,6 @@
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';
@@ -140,14 +141,15 @@ apiRouter.get("/favorites", withErrorHandling(async (req, res) => {
}));
apiRouter.post("/favorites", withErrorHandling(async (req, res) => {
const item = req.body as PlaylistItem;
await mediaPlayer.addFavorite(item);
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/:id", withErrorHandling(async (req, res) => {
const { id } = req.params;
await mediaPlayer.removeFavorite(parseInt(id));
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 }));
}));
@@ -156,15 +158,31 @@ apiRouter.delete("/favorites", withErrorHandling(async (req, res) => {
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;

61
flake.lock generated Normal file
View 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
View 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;
};
}

View File

@@ -142,31 +142,31 @@ export const API = {
return response.json();
},
async addToFavorites(url: string): Promise<void> {
// Maybe a little weird to make an empty PlaylistItem here, but we do want to support adding
// known PlaylistItems to favorites in the future.
const playlistItem: PlaylistItem = {
filename: url,
title: null,
id: 0,
playing: null,
metadata: undefined,
};
async addToFavorites(filename: string): Promise<void> {
await fetch('/api/favorites', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(playlistItem),
body: JSON.stringify({ filename }),
});
},
async removeFromFavorites(id: number): Promise<void> {
await fetch(`/api/favorites/${id}`, { method: 'DELETE' });
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 }),
});
}
};

View File

@@ -2,6 +2,7 @@ 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;
}

View File

@@ -1,11 +1,13 @@
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 RenameFavoriteModal from './RenameFavoriteModal';
import { TabView, Tab } from './TabView';
import { API, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player';
import { FaMusic, FaHeart } from 'react-icons/fa';
import { FaMusic, FaHeart, FaPlus, FaEdit } from 'react-icons/fa';
import useWebSocket from 'react-use-websocket';
import classNames from 'classnames';
enum Tabs {
Playlist = "playlist",
@@ -21,10 +23,11 @@ const EmptyContent: React.FC<{ label: string}> = ({label}) => (
interface SonglistContentProps {
songs: PlaylistItem[];
isPlaying: boolean;
auxControlProvider?: (song: PlaylistItem) => ReactNode;
onNeedsRefresh: () => void;
}
const PlaylistContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, onNeedsRefresh }) => {
const PlaylistContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, auxControlProvider, onNeedsRefresh }) => {
const handleDelete = (index: number) => {
API.removeFromPlaylist(index);
onNeedsRefresh();
@@ -40,6 +43,7 @@ const PlaylistContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, onN
<SongTable
songs={songs}
isPlaying={isPlaying}
auxControlProvider={auxControlProvider}
onDelete={handleDelete}
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) => {
API.removeFromFavorites(index);
API.removeFromFavorites(songs[index].filename);
onNeedsRefresh();
};
@@ -66,6 +70,7 @@ const FavoritesContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, on
<SongTable
songs={songs}
isPlaying={isPlaying}
auxControlProvider={auxControlProvider}
onDelete={handleDelete}
onSkipTo={handleSkipTo}
/>
@@ -84,6 +89,8 @@ const App: React.FC = () => {
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();
@@ -173,7 +180,9 @@ const App: React.FC = () => {
}
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
useWebSocket('/api/events', {
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');
},
@@ -215,6 +224,75 @@ const App: React.FC = () => {
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 (
<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">
@@ -238,6 +316,7 @@ const App: React.FC = () => {
songs={playlist}
isPlaying={isPlaying}
onNeedsRefresh={refreshContent}
auxControlProvider={playlistAuxControlProvider}
/>
</Tab>
<Tab label="Favorites" identifier={Tabs.Favorites} icon={<FaHeart />}>
@@ -245,11 +324,18 @@ const App: React.FC = () => {
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>
);

View 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;

View File

@@ -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,7 +40,8 @@ const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay })
const displayTitle = getDisplayTitle(song);
return (
<div className={classNames("flex flex-row w-full h-24 px-2 py-5 items-center border-b gap-2 transition-colors", {
<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,
})}>
@@ -76,6 +78,8 @@ const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay })
</div>
<div className="flex flex-row gap-2">
{auxControl}
<button
ref={buttonRef}
className="text-red-100 px-3 py-1 bg-red-500/40 rounded"

View File

@@ -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

View File

@@ -26,7 +26,7 @@ export const TabView = <T,>({ children, selectedTab, onTabChange }: TabViewProps
) as React.ReactElement<TabProps<T>>[];
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">
{tabs.map((tab, index) => {
const isSelected = selectedTab === tab.props.identifier;
@@ -47,7 +47,7 @@ export const TabView = <T,>({ children, selectedTab, onTabChange }: TabViewProps
);
})}
</div>
<div className="flex-1">
<div className="flex-1 overflow-hidden">
{tabs.map((tab, index) => (
<div
key={index}