Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 803cdd2cdf | |||
| f677026072 | |||
| 5197c78897 | |||
| 5f1aeeca78 | |||
| 92c5582c98 | |||
| a3801f6698 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
29
frontend/package-lock.json
generated
29
frontend/package-lock.json
generated
@@ -15,8 +15,9 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
|
"@types/react-icons": "^3.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"eslint": "^9.19.0",
|
"eslint": "^9.19.0",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
@@ -1564,11 +1565,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"
|
||||||
}
|
}
|
||||||
@@ -1583,6 +1583,16 @@
|
|||||||
"@types/react": "^19.0.0"
|
"@types/react": "^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/react-icons": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-icons/-/react-icons-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-Vefs6LkLqF61vfV7AiAqls+vpR94q67gunhMueDznG+msAkrYgRxl7gYjNem/kZ+as2l2mNChmF1jRZzzQQtMg==",
|
||||||
|
"deprecated": "This is a stub types definition. react-icons provides its own type definitions, so you do not need this installed.",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"react-icons": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.24.0",
|
"version": "8.24.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.0.tgz",
|
||||||
@@ -3254,6 +3264,15 @@
|
|||||||
"react": "^19.0.0"
|
"react": "^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-icons": {
|
||||||
|
"version": "5.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||||
|
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
|
||||||
|
"dev": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.14.2",
|
"version": "0.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
||||||
|
|||||||
@@ -17,8 +17,9 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
|
"@types/react-icons": "^3.0.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"eslint": "^9.19.0",
|
"eslint": "^9.19.0",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.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 |
@@ -1,6 +1,6 @@
|
|||||||
export interface NowPlayingResponse {
|
export interface NowPlayingResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
nowPlaying: string;
|
playingItem: PlaylistItem;
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
volume: number;
|
volume: number;
|
||||||
isIdle: boolean;
|
isIdle: boolean;
|
||||||
@@ -21,6 +21,10 @@ export interface PlaylistItem {
|
|||||||
metadata?: Metadata;
|
metadata?: Metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getDisplayTitle = (item: PlaylistItem): string => {
|
||||||
|
return item.metadata?.title || item.title || item.filename;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MetadataUpdateEvent {
|
export interface MetadataUpdateEvent {
|
||||||
event: 'metadata_update';
|
event: 'metadata_update';
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useCallback } 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 { API, PlaylistItem } from '../api/player';
|
import { API, getDisplayTitle, PlaylistItem } from '../api/player';
|
||||||
|
|
||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
@@ -11,7 +11,7 @@ const App: React.FC = () => {
|
|||||||
const [volume, setVolume] = useState(100);
|
const [volume, setVolume] = useState(100);
|
||||||
const [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false);
|
const [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false);
|
||||||
const [songs, setSongs] = useState<PlaylistItem[]>([]);
|
const [songs, setSongs] = useState<PlaylistItem[]>([]);
|
||||||
const [_, setWs] = useState<WebSocket | null>(null);
|
const [ws, setWs] = useState<WebSocket | null>(null);
|
||||||
|
|
||||||
const fetchPlaylist = useCallback(async () => {
|
const fetchPlaylist = useCallback(async () => {
|
||||||
const playlist = await API.getPlaylist();
|
const playlist = await API.getPlaylist();
|
||||||
@@ -20,8 +20,8 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
const fetchNowPlaying = useCallback(async () => {
|
const fetchNowPlaying = useCallback(async () => {
|
||||||
const nowPlaying = await API.getNowPlaying();
|
const nowPlaying = await API.getNowPlaying();
|
||||||
setNowPlayingSong(nowPlaying.nowPlaying);
|
setNowPlayingSong(getDisplayTitle(nowPlaying.playingItem));
|
||||||
setNowPlayingFileName(nowPlaying.currentFile);
|
setNowPlayingFileName(nowPlaying.playingItem.filename);
|
||||||
setIsPlaying(!nowPlaying.isPaused);
|
setIsPlaying(!nowPlaying.isPaused);
|
||||||
|
|
||||||
if (!volumeSettingIsLocked) {
|
if (!volumeSettingIsLocked) {
|
||||||
@@ -29,11 +29,15 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [volumeSettingIsLocked]);
|
}, [volumeSettingIsLocked]);
|
||||||
|
|
||||||
const handleAddURL = (url: string) => {
|
const handleAddURL = async (url: string) => {
|
||||||
const urlToAdd = url.trim();
|
const urlToAdd = url.trim();
|
||||||
if (urlToAdd) {
|
if (urlToAdd) {
|
||||||
API.addToPlaylist(urlToAdd);
|
await API.addToPlaylist(urlToAdd);
|
||||||
fetchPlaylist();
|
fetchPlaylist();
|
||||||
|
|
||||||
|
if (!isPlaying) {
|
||||||
|
await API.play();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,14 +104,25 @@ const App: React.FC = () => {
|
|||||||
fetchNowPlaying();
|
fetchNowPlaying();
|
||||||
}, [fetchPlaylist, fetchNowPlaying]);
|
}, [fetchPlaylist, fetchNowPlaying]);
|
||||||
|
|
||||||
// WebSocket connection
|
// Update WebSocket connection
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ws = API.subscribeToEvents(handleWebSocketEvent);
|
const websocket = API.subscribeToEvents(handleWebSocketEvent);
|
||||||
setWs(ws);
|
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 () => {
|
return () => {
|
||||||
if (ws) {
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
ws.close();
|
if (websocket) {
|
||||||
|
websocket.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [handleWebSocketEvent]);
|
}, [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 ? <FaSpinner className="animate-spin" /> : <FaSearch />}
|
||||||
|
</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;
|
||||||
@@ -23,15 +23,14 @@ interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
|
|||||||
|
|
||||||
const NowPlaying: React.FC<NowPlayingProps> = (props) => {
|
const NowPlaying: React.FC<NowPlayingProps> = (props) => {
|
||||||
return (
|
return (
|
||||||
<div className={classNames(props.className, 'bg-black/50 h-[150px] p-5')}>
|
<div className={classNames(props.className, 'bg-black/50 h-fit p-5')}>
|
||||||
<div className="flex flex-col w-full gap-2">
|
<div className="flex flex-col w-full gap-2">
|
||||||
<div className="flex flex-row w-full h-full bg-black/50 rounded-lg p-5 items-center">
|
<div className="flex flex-col md:flex-row w-full h-full bg-black/50 rounded-lg p-5 items-center gap-4">
|
||||||
<div className="flex-grow min-w-0">
|
<div className="flex-grow min-w-0 w-full md:w-auto text-white">
|
||||||
<div className="text-white text-lg font-bold truncate">{props.songName}</div>
|
<div className="text-lg font-bold truncate text-center md:text-left">{props.songName}</div>
|
||||||
<div className="text-white text-sm truncate">{props.fileName}</div>
|
<div className="text-sm truncate text-center md:text-left">{props.fileName}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row items-center gap-4">
|
<div className="flex flex-row items-center gap-4 justify-center md:justify-end">
|
||||||
|
|
||||||
<div className="flex items-center gap-2 text-white">
|
<div className="flex items-center gap-2 text-white">
|
||||||
<FaVolumeUp size={20} />
|
<FaVolumeUp size={20} />
|
||||||
<input
|
<input
|
||||||
@@ -42,7 +41,7 @@ const NowPlaying: React.FC<NowPlayingProps> = (props) => {
|
|||||||
onMouseDown={() => props.onVolumeWillChange(props.volume)}
|
onMouseDown={() => props.onVolumeWillChange(props.volume)}
|
||||||
onMouseUp={() => props.onVolumeDidChange(props.volume)}
|
onMouseUp={() => props.onVolumeDidChange(props.volume)}
|
||||||
onChange={(e) => props.onVolumeSettingChange(Number(e.target.value))}
|
onChange={(e) => props.onVolumeSettingChange(Number(e.target.value))}
|
||||||
className="w-24 h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full hover:[&::-webkit-slider-thumb]:bg-violet-300"
|
className="fancy-slider w-48 md:w-24 h-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { FaPlay, FaVolumeUp, FaVolumeOff } from 'react-icons/fa';
|
import { FaPlay, FaVolumeUp, FaVolumeOff } from 'react-icons/fa';
|
||||||
import { PlaylistItem } from '../api/player';
|
import { getDisplayTitle, PlaylistItem } from '../api/player';
|
||||||
|
|
||||||
export enum PlayState {
|
export enum PlayState {
|
||||||
NotPlaying,
|
NotPlaying,
|
||||||
@@ -36,10 +36,10 @@ const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay })
|
|||||||
};
|
};
|
||||||
}, [showDeleteConfirm]);
|
}, [showDeleteConfirm]);
|
||||||
|
|
||||||
const displayTitle = song.metadata?.title || song.title || song.filename;
|
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
6
frontend/src/config.ts
Normal 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)}`;
|
||||||
@@ -1 +1,11 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.fancy-slider {
|
||||||
|
@apply bg-gray-700 rounded-lg appearance-none cursor-pointer
|
||||||
|
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3
|
||||||
|
[&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-white
|
||||||
|
[&::-webkit-slider-thumb]:rounded-full
|
||||||
|
hover:[&::-webkit-slider-thumb]:bg-violet-300;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,15 @@ interface LinkMetadata {
|
|||||||
siteName?: string;
|
siteName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PlaylistItem {
|
||||||
|
id: number;
|
||||||
|
filename: string;
|
||||||
|
title?: string;
|
||||||
|
playing?: boolean;
|
||||||
|
current?: boolean;
|
||||||
|
metadata?: LinkMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
export class MediaPlayer {
|
export class MediaPlayer {
|
||||||
private playerProcess: ChildProcess;
|
private playerProcess: ChildProcess;
|
||||||
private socket: Socket;
|
private socket: Socket;
|
||||||
@@ -46,23 +55,43 @@ export class MediaPlayer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getPlaylist(): Promise<any> {
|
public async getPlaylist(): Promise<PlaylistItem[]> {
|
||||||
return this.writeCommand("get_property", ["playlist"])
|
return this.writeCommand("get_property", ["playlist"])
|
||||||
.then((response) => {
|
.then((response) => {
|
||||||
// Enhance playlist items with metadata
|
// Enhance playlist items with metadata
|
||||||
const playlist = response.data;
|
const playlist = response.data as PlaylistItem[];
|
||||||
return playlist.map((item: any) => ({
|
return playlist.map((item: PlaylistItem) => ({
|
||||||
...item,
|
...item,
|
||||||
metadata: this.metadata.get(item.filename) || {}
|
metadata: this.metadata.get(item.filename) || {}
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getNowPlaying(): Promise<string> {
|
public async getNowPlaying(): Promise<PlaylistItem> {
|
||||||
return this.writeCommand("get_property", ["media-title"])
|
const playlist = await this.getPlaylist();
|
||||||
.then((response) => {
|
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
|
||||||
return response.data;
|
const fetchMediaTitle = async (): Promise<string> => {
|
||||||
});
|
return (await this.writeCommand("get_property", ["media-title"])).data;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (currentlyPlayingSong !== undefined) {
|
||||||
|
// Use media title if we don't have a title
|
||||||
|
if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) {
|
||||||
|
return {
|
||||||
|
...currentlyPlayingSong,
|
||||||
|
title: await fetchMediaTitle()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentlyPlayingSong;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaTitle = await fetchMediaTitle();
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
filename: mediaTitle,
|
||||||
|
title: mediaTitle
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getCurrentFile(): Promise<string> {
|
public async getCurrentFile(): Promise<string> {
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ apiRouter.post("/previous", withErrorHandling(async (req, res) => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
apiRouter.get("/nowplaying", withErrorHandling(async (req, res) => {
|
apiRouter.get("/nowplaying", withErrorHandling(async (req, res) => {
|
||||||
const nowPlaying = await mediaPlayer.getNowPlaying();
|
const playingItem = await mediaPlayer.getNowPlaying();
|
||||||
const currentFile = await mediaPlayer.getCurrentFile();
|
const currentFile = await mediaPlayer.getCurrentFile();
|
||||||
const pauseState = await mediaPlayer.getPauseState();
|
const pauseState = await mediaPlayer.getPauseState();
|
||||||
const volume = await mediaPlayer.getVolume();
|
const volume = await mediaPlayer.getVolume();
|
||||||
@@ -71,7 +71,7 @@ apiRouter.get("/nowplaying", withErrorHandling(async (req, res) => {
|
|||||||
|
|
||||||
res.send(JSON.stringify({
|
res.send(JSON.stringify({
|
||||||
success: true,
|
success: true,
|
||||||
nowPlaying: nowPlaying,
|
playingItem: playingItem,
|
||||||
isPaused: pauseState,
|
isPaused: pauseState,
|
||||||
volume: volume,
|
volume: volume,
|
||||||
isIdle: idle,
|
isIdle: idle,
|
||||||
|
|||||||
Reference in New Issue
Block a user