Files
QueueCube/web/frontend/src/components/SongRow.tsx
T
2025-11-15 17:58:29 -08:00

136 lines
4.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import classNames from 'classnames';
import React, { useState, useRef, useEffect, ReactNode } from 'react';
import { FaPlay, FaVolumeUp, FaVolumeOff, FaExclamationTriangle } from 'react-icons/fa';
import { getDisplayTitle, PlaylistItem } from '../api/player';
export enum PlayState {
NotPlaying,
Playing,
Paused,
}
export interface SongRowProps {
song: PlaylistItem;
auxControl?: ReactNode;
playState: PlayState;
onDelete: () => void;
onPlay: () => void;
}
const SongRow: React.FC<SongRowProps> = ({ song, auxControl, playState, onDelete, onPlay }) => {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showErrorDetails, setShowErrorDetails] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
setShowDeleteConfirm(false);
}
};
if (showDeleteConfirm) {
document.addEventListener('click', handleClickOutside);
}
return () => {
document.removeEventListener('click', handleClickOutside);
};
}, [showDeleteConfirm]);
const displayTitle = getDisplayTitle(song);
const hasError = !!song.playbackError;
return (
<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">
<div className="relative">
<button
className={classNames(
"transition-colors px-3 py-1 rounded",
hasError ? "text-amber-300 hover:text-amber-100" : "text-white/40 hover:text-white"
)}
onClick={() => {
if (hasError) {
setShowErrorDetails((prev) => !prev);
} else {
onPlay();
}
}}
onMouseEnter={() => {
if (hasError) {
setShowErrorDetails(true);
}
}}
onMouseLeave={() => {
if (hasError) {
setShowErrorDetails(false);
}
}}
title={hasError ? song.playbackError : undefined}
>
{
hasError ? <FaExclamationTriangle size={12} /> :
playState === PlayState.Playing ? <FaVolumeUp size={12} />
: playState === PlayState.Paused ? <FaVolumeOff size={12} />
: <FaPlay size={12} />
}
</button>
{hasError && showErrorDetails && (
<div className="absolute z-10 top-full left-0 mt-1 w-64 p-2 text-xs text-white bg-red-600/90 rounded shadow-lg">
<div className="font-semibold mb-1">Playback error</div>
<div className="break-words">{song.playbackError}</div>
</div>
)}
</div>
</div>
<div className="flex-grow min-w-0">
{
displayTitle ? (
<div>
<div className="text-white text-md truncate text-bold">
{displayTitle}
</div>
<div className="text-white/80 text-xs truncate">
{song.filename}
</div>
</div>
) : (
<div className="text-white text-md truncate text-bold">
{song.filename}
</div>
)
}
</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"
onClick={(e) => {
e.stopPropagation();
if (showDeleteConfirm) {
setShowDeleteConfirm(false);
onDelete();
} else {
setShowDeleteConfirm(true);
}
}}
>
{showDeleteConfirm ? 'Delete' : '×'}
</button>
</div>
</div>
);
};
export default SongRow;