Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfe48e28f8 | |||
| 78deeb65bc | |||
| ebb6127fb9 | |||
| e23977548b | |||
| 803cdd2cdf | |||
| f677026072 | |||
| 5197c78897 | |||
| 5f1aeeca78 | |||
| 92c5582c98 |
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM node:20-slim AS builder
|
||||
FROM --platform=$TARGETPLATFORM node:20-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -18,9 +18,11 @@ COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
9
frontend/package-lock.json
generated
9
frontend/package-lock.json
generated
@@ -15,7 +15,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.19.0",
|
||||
@@ -1564,11 +1564,10 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.8.tgz",
|
||||
"integrity": "sha512-9P/o1IGdfmQxrujGbIMDyYaaCykhLKc0NGCtYcECNUr9UAaDe4gwvV9bR6tvd5Br1SG0j+PBpbKr2UYY8CwqSw==",
|
||||
"version": "19.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz",
|
||||
"integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.19.0",
|
||||
|
||||
BIN
frontend/public/assets/placeholder.jpg
Normal file
BIN
frontend/public/assets/placeholder.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.9 KiB |
@@ -22,7 +22,7 @@ export interface PlaylistItem {
|
||||
}
|
||||
|
||||
export const getDisplayTitle = (item: PlaylistItem): string => {
|
||||
return item.metadata?.title || item.title || item.filename;
|
||||
return item.title || item.metadata?.title || item.filename;
|
||||
}
|
||||
|
||||
export interface MetadataUpdateEvent {
|
||||
|
||||
@@ -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 {
|
||||
onAddURL: (url: string) => void;
|
||||
}
|
||||
|
||||
const AddSongPanel: React.FC<AddSongPanelProps> = ({ onAddURL }) => {
|
||||
const [url, setUrl] = useState('');
|
||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
||||
|
||||
const handleAddURL = () => {
|
||||
onAddURL(url);
|
||||
@@ -15,13 +18,22 @@ const AddSongPanel: React.FC<AddSongPanelProps> = ({ onAddURL }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-fit bg-black/50 md:rounded-b-2xl text-white">
|
||||
<div className="flex flex-row items-center gap-4 w-full px-8 py-4">
|
||||
{USE_INVIDIOUS && (
|
||||
<button
|
||||
className="bg-violet-500/20 text-white p-2 rounded-lg border-2 border-violet-500"
|
||||
onClick={() => setIsSearchModalOpen(true)}
|
||||
>
|
||||
<FaSearch />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setUrl(e.target.value)}
|
||||
placeholder="Add any URL..."
|
||||
className="p-2 rounded-lg border-2 border-violet-500 flex-grow"
|
||||
onKeyDown={(e) => {
|
||||
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleAddURL();
|
||||
}
|
||||
@@ -35,6 +47,15 @@ const AddSongPanel: React.FC<AddSongPanelProps> = ({ onAddURL }) => {
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<InvidiousSearchModal
|
||||
isOpen={isSearchModalOpen}
|
||||
onClose={() => setIsSearchModalOpen(false)}
|
||||
onSelectVideo={(videoUrl) => {
|
||||
onAddURL(videoUrl);
|
||||
setIsSearchModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ const App: React.FC = () => {
|
||||
const [volume, setVolume] = useState(100);
|
||||
const [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false);
|
||||
const [songs, setSongs] = useState<PlaylistItem[]>([]);
|
||||
const [_, setWs] = useState<WebSocket | null>(null);
|
||||
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||
|
||||
const fetchPlaylist = useCallback(async () => {
|
||||
const playlist = await API.getPlaylist();
|
||||
@@ -104,14 +104,25 @@ const App: React.FC = () => {
|
||||
fetchNowPlaying();
|
||||
}, [fetchPlaylist, fetchNowPlaying]);
|
||||
|
||||
// WebSocket connection
|
||||
// Update WebSocket connection
|
||||
useEffect(() => {
|
||||
const ws = API.subscribeToEvents(handleWebSocketEvent);
|
||||
setWs(ws);
|
||||
const websocket = API.subscribeToEvents(handleWebSocketEvent);
|
||||
setWs(websocket);
|
||||
|
||||
// Handle page visibility changes, so if the user navigates back to this tab, we reconnect the WebSocket
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible' && (!ws || ws.readyState === WebSocket.CLOSED)) {
|
||||
const newWs = API.subscribeToEvents(handleWebSocketEvent);
|
||||
setWs(newWs);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
if (ws) {
|
||||
ws.close();
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
if (websocket) {
|
||||
websocket.close();
|
||||
}
|
||||
};
|
||||
}, [handleWebSocketEvent]);
|
||||
|
||||
135
frontend/src/components/InvidiousSearchModal.tsx
Normal file
135
frontend/src/components/InvidiousSearchModal.tsx
Normal 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;
|
||||
@@ -39,7 +39,7 @@ const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay })
|
||||
const displayTitle = getDisplayTitle(song);
|
||||
|
||||
return (
|
||||
<div className={classNames("flex flex-row w-full h-[100px] px-2 py-5 items-center border-b gap-2 transition-colors", {
|
||||
<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/30": playState === PlayState.NotPlaying,
|
||||
})}>
|
||||
|
||||
6
frontend/src/config.ts
Normal file
6
frontend/src/config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const USE_INVIDIOUS = import.meta.env.VITE_USE_INVIDIOUS || true;
|
||||
export const INVIDIOUS_BASE_URL = import.meta.env.VITE_INVIDIOUS_BASE_URL || 'http://invidious.nor';
|
||||
export const INVIDIOUS_API_ENDPOINT = `${INVIDIOUS_BASE_URL}/api/v1`;
|
||||
|
||||
export const getInvidiousSearchURL = (query: string): string =>
|
||||
`${INVIDIOUS_API_ENDPOINT}/search?q=${encodeURIComponent(query)}`;
|
||||
Reference in New Issue
Block a user