diff --git a/backend/src/FavoritesStore.ts b/backend/src/FavoritesStore.ts index bdfce9c..c075ca2 100644 --- a/backend/src/FavoritesStore.ts +++ b/backend/src/FavoritesStore.ts @@ -61,49 +61,48 @@ export class FavoritesStore { return this.favorites; } - async addFavorite(item: PlaylistItem, fetchMetadata: boolean = true): Promise { + async addFavorite(filename: string): Promise { // 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 { - console.log("Fetching metadata for " + item.filename); - const metadata = await getLinkPreview(item.filename); + private async fetchMetadata(filename: string): Promise { + 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 { - this.favorites = this.favorites.filter(f => f.id !== id); + async removeFavorite(filename: string): Promise { + console.log("Removing favorite " + filename); + this.favorites = this.favorites.filter(f => f.filename !== filename); await this.saveFavorites(); } diff --git a/backend/src/MediaPlayer.ts b/backend/src/MediaPlayer.ts index 6ddaf3e..24736ef 100644 --- a/backend/src/MediaPlayer.ts +++ b/backend/src/MediaPlayer.ts @@ -176,8 +176,8 @@ export class MediaPlayer { return this.favoritesStore.addFavorite(item); } - public async removeFavorite(id: number) { - return this.favoritesStore.removeFavorite(id); + public async removeFavorite(filename: string) { + return this.favoritesStore.removeFavorite(filename); } public async clearFavorites() { diff --git a/backend/src/server.ts b/backend/src/server.ts index 7f9c07b..97fe728 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -140,14 +140,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 })); })); diff --git a/frontend/src/api/player.tsx b/frontend/src/api/player.tsx index c055694..db3c5da 100644 --- a/frontend/src/api/player.tsx +++ b/frontend/src/api/player.tsx @@ -142,28 +142,18 @@ export const API = { return response.json(); }, - async addToFavorites(url: string): Promise { - // 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 { await fetch('/api/favorites', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify(playlistItem), + body: JSON.stringify({ filename }), }); }, - async removeFromFavorites(id: number): Promise { - await fetch(`/api/favorites/${id}`, { method: 'DELETE' }); + async removeFromFavorites(filename: string): Promise { + await fetch(`/api/favorites/${encodeURIComponent(filename)}`, { method: 'DELETE' }); }, async clearFavorites(): Promise { diff --git a/frontend/src/components/AddSongPanel.tsx b/frontend/src/components/AddSongPanel.tsx index ef00105..b886bc4 100644 --- a/frontend/src/components/AddSongPanel.tsx +++ b/frontend/src/components/AddSongPanel.tsx @@ -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; } diff --git a/frontend/src/components/App.tsx b/frontend/src/components/App.tsx index dc10dd0..7bd3df1 100644 --- a/frontend/src/components/App.tsx +++ b/frontend/src/components/App.tsx @@ -1,11 +1,12 @@ -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 { TabView, Tab } from './TabView'; import { API, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player'; -import { FaMusic, FaHeart } from 'react-icons/fa'; +import { FaMusic, FaHeart, FaPlus } from 'react-icons/fa'; import useWebSocket from 'react-use-websocket'; +import classNames from 'classnames'; enum Tabs { Playlist = "playlist", @@ -21,10 +22,11 @@ const EmptyContent: React.FC<{ label: string}> = ({label}) => ( interface SonglistContentProps { songs: PlaylistItem[]; isPlaying: boolean; + auxControlProvider?: (song: PlaylistItem) => ReactNode; onNeedsRefresh: () => void; } -const PlaylistContent: React.FC = ({ songs, isPlaying, onNeedsRefresh }) => { +const PlaylistContent: React.FC = ({ songs, isPlaying, auxControlProvider, onNeedsRefresh }) => { const handleDelete = (index: number) => { API.removeFromPlaylist(index); onNeedsRefresh(); @@ -40,6 +42,7 @@ const PlaylistContent: React.FC = ({ songs, isPlaying, onN @@ -49,9 +52,9 @@ const PlaylistContent: React.FC = ({ songs, isPlaying, onN ); }; -const FavoritesContent: React.FC = ({ songs, isPlaying, onNeedsRefresh }) => { +const FavoritesContent: React.FC = ({ songs, isPlaying, auxControlProvider, onNeedsRefresh }) => { const handleDelete = (index: number) => { - API.removeFromFavorites(index); + API.removeFromFavorites(songs[index].filename); onNeedsRefresh(); }; @@ -66,6 +69,7 @@ const FavoritesContent: React.FC = ({ songs, isPlaying, on @@ -215,6 +219,62 @@ const App: React.FC = () => { fetchFavorites(); }, [fetchPlaylist, fetchNowPlaying, fetchFavorites]); + const AuxButton: React.FC<{ children: ReactNode, className: string, title: string, onClick: () => void }> = (props) => ( + + ); + + const playlistAuxControlProvider = (song: PlaylistItem) => { + const isFavorite = favorites.some(f => f.filename === song.filename); + return ( + { + if (isFavorite) { + API.removeFromFavorites(song.filename); + } else { + API.addToFavorites(song.filename); + } + }} + > + + + ); + }; + + const favoritesAuxControlProvider = (song: PlaylistItem) => { + const isInPlaylist = playlist.some(p => p.filename === song.filename); + return ( + { + if (isInPlaylist) { + API.removeFromPlaylist(playlist.findIndex(p => p.filename === song.filename)); + } else { + API.addToPlaylist(song.filename); + } + }} + > + + + ); + }; + return (
@@ -238,6 +298,7 @@ const App: React.FC = () => { songs={playlist} isPlaying={isPlaying} onNeedsRefresh={refreshContent} + auxControlProvider={playlistAuxControlProvider} /> }> @@ -245,6 +306,7 @@ const App: React.FC = () => { songs={favorites.map(f => ({ ...f, playing: f.filename === nowPlayingFileName }))} isPlaying={isPlaying} onNeedsRefresh={refreshContent} + auxControlProvider={favoritesAuxControlProvider} /> diff --git a/frontend/src/components/SongRow.tsx b/frontend/src/components/SongRow.tsx index 8fc8135..4b7e0f7 100644 --- a/frontend/src/components/SongRow.tsx +++ b/frontend/src/components/SongRow.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; -import React, { useState, useRef, useEffect } from 'react'; -import { FaPlay, FaVolumeUp, FaVolumeOff } from 'react-icons/fa'; +import React, { useState, useRef, useEffect, ReactNode } from 'react'; +import { FaPlay, FaVolumeUp, FaVolumeOff, FaHeart } from 'react-icons/fa'; import { getDisplayTitle, PlaylistItem } from '../api/player'; export enum PlayState { @@ -11,12 +11,13 @@ export enum PlayState { export interface SongRowProps { song: PlaylistItem; + auxControl?: ReactNode; playState: PlayState; onDelete: () => void; onPlay: () => void; } -const SongRow: React.FC = ({ song, playState, onDelete, onPlay }) => { +const SongRow: React.FC = ({ song, auxControl, playState, onDelete, onPlay }) => { const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const buttonRef = useRef(null); @@ -77,6 +78,8 @@ const SongRow: React.FC = ({ song, playState, onDelete, onPlay })
+ {auxControl} +