7 Commits
1.5 ... 1.71

9 changed files with 178 additions and 15 deletions

View File

@@ -1,5 +1,5 @@
# Build stage # Build stage
FROM node:20-slim AS builder FROM --platform=$TARGETPLATFORM node:20-slim AS builder
WORKDIR /app WORKDIR /app
@@ -18,9 +18,11 @@ COPY . .
RUN npm run build RUN npm run build
# Production stage # 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 WORKDIR /app

View File

@@ -15,7 +15,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@types/react": "^19.0.8", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.3", "@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.19.0", "eslint": "^9.19.0",
@@ -1564,11 +1564,10 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.0.8", "version": "19.0.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz",
"integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==", "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
"dev": true, "dev": true,
"license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }

View File

@@ -17,7 +17,7 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.19.0", "@eslint/js": "^9.19.0",
"@types/react": "^19.0.8", "@types/react": "^19.0.10",
"@types/react-dom": "^19.0.3", "@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.19.0", "eslint": "^9.19.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@@ -22,7 +22,7 @@ export interface PlaylistItem {
} }
export const getDisplayTitle = (item: PlaylistItem): string => { 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 { export interface MetadataUpdateEvent {

View File

@@ -1,11 +1,14 @@
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 { interface AddSongPanelProps {
onAddURL: (url: string) => void; onAddURL: (url: string) => void;
} }
const AddSongPanel: React.FC<AddSongPanelProps> = ({ onAddURL }) => { const AddSongPanel: React.FC<AddSongPanelProps> = ({ onAddURL }) => {
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const handleAddURL = () => { const handleAddURL = () => {
onAddURL(url); onAddURL(url);
@@ -15,13 +18,22 @@ const AddSongPanel: React.FC<AddSongPanelProps> = ({ onAddURL }) => {
return ( return (
<div className="flex items-center justify-center h-fit bg-black/50 md:rounded-b-2xl text-white"> <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"> <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 <input
type="text" type="text"
value={url} value={url}
onChange={(e) => setUrl(e.target.value)} onChange={(e: ChangeEvent<HTMLInputElement>) => setUrl(e.target.value)}
placeholder="Add any URL..." placeholder="Add any URL..."
className="p-2 rounded-lg border-2 border-violet-500 flex-grow" className="p-2 rounded-lg border-2 border-violet-500 flex-grow"
onKeyDown={(e) => { onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleAddURL(); handleAddURL();
} }
@@ -35,6 +47,15 @@ const AddSongPanel: React.FC<AddSongPanelProps> = ({ onAddURL }) => {
Add Add
</button> </button>
</div> </div>
<InvidiousSearchModal
isOpen={isSearchModalOpen}
onClose={() => setIsSearchModalOpen(false)}
onSelectVideo={(videoUrl) => {
onAddURL(videoUrl);
setIsSearchModalOpen(false);
}}
/>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,135 @@
import React, { useState, KeyboardEvent } from 'react';
import { FaSearch, FaSpinner, FaTimes } from 'react-icons/fa';
import { getInvidiousSearchURL, INVIDIOUS_BASE_URL } from '../config';
interface InvidiousVideoThumbnail {
quality: string;
url: string;
width: number;
height: number;
}
interface InvidiousResult {
type: string;
title: string;
videoId: string;
author: string;
videoThumbnails?: InvidiousVideoThumbnail[];
}
interface InvidiousSearchModalProps {
isOpen: boolean;
onClose: () => void;
onSelectVideo: (url: string) => void;
}
const ResultCell: React.FC<{ result: InvidiousResult, onClick: () => void }> = ({ result, onClick, ...props }) => {
const thumbnailUrl = (result: InvidiousResult) => {
if (!result.videoThumbnails) return '/assets/placeholder.jpg';
const thumbnail = result.videoThumbnails.find(t => t.quality === 'medium');
return thumbnail ? `${INVIDIOUS_BASE_URL}${thumbnail.url}` : '/assets/placeholder.jpg';
};
return (
<div className="flex gap-4 bg-black/20 p-2 rounded-lg cursor-pointer hover:bg-black/30 transition-colors" onClick={onClick} {...props}>
<img src={thumbnailUrl(result)} alt={result.title} className="w-32 h-18 object-cover rounded" />
<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<InvidiousResult[]>([]);
const [isLoading, setIsLoading] = useState(false);
const handleSearch = async () => {
if (!searchQuery.trim()) return;
setIsLoading(true);
try {
const response = await fetch(getInvidiousSearchURL(searchQuery));
const data = await response.json();
const videoResults = data.filter((item: InvidiousResult) => {
return item.type === 'video' || item.type === 'playlist'
});
setResults(videoResults);
} 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.videoId}
result={result}
onClick={() => _onSelectVideo(`https://www.youtube.com/watch?v=${result.videoId}`)}
/>
))}
</div>
)}
</div>
</div>
</div>
);
};
export default InvidiousSearchModal;

View File

@@ -39,7 +39,7 @@ 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-[100px] px-2 py-5 items-center border-b gap-2 transition-colors", { <div className={classNames("flex flex-row w-full h-24 px-2 py-5 items-center border-b gap-2 transition-colors", {
"bg-black/10": (playState === PlayState.Playing || playState === PlayState.Paused), "bg-black/10": (playState === PlayState.Playing || playState === PlayState.Paused),
"bg-black/30": playState === PlayState.NotPlaying, "bg-black/30": playState === PlayState.NotPlaying,
})}> })}>

6
frontend/src/config.ts Normal file
View File

@@ -0,0 +1,6 @@
export const USE_INVIDIOUS = import.meta.env.VITE_USE_INVIDIOUS || false;
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)}`;