Merge pull request #49 from antebrl/42-playlist-group-selection

42 playlist group selection
This commit is contained in:
Ante Brähler
2025-01-06 19:52:41 +01:00
committed by GitHub
8 changed files with 189 additions and 39 deletions

View File

@@ -1,6 +1,6 @@
class Channel {
static nextId = 0;
constructor(name, url, avatar, mode, headers = [], group = null, playlist = null) {
constructor(name, url, avatar, mode, headers = [], group = null, playlist = null, playlistName = null) {
this.id = Channel.nextId++;
this.name = name;
this.url = url;
@@ -10,6 +10,7 @@ class Channel {
this.headers = headers;
this.group = group;
this.playlist = playlist;
this.playlistName = playlistName;
}
restream() {

View File

@@ -34,10 +34,10 @@ class ChannelService {
return this.channels;
}
getFilteredChannels({ playlist, group }) {
getFilteredChannels({ playlistName, group }) {
let filtered = this.channels;
if (playlist) {
filtered = filtered.filter(ch => ch.playlist && ch.playlist == playlist);
if (playlistName) {
filtered = filtered.filter(ch => ch.playlistName && ch.playlistName.toLowerCase() == playlistName.toLowerCase());
}
if (group) {
filtered = filtered.filter(ch => ch.group && ch.group.toLowerCase() === group.toLowerCase());
@@ -45,7 +45,7 @@ class ChannelService {
return filtered;
}
addChannel({ name, url, avatar, mode, headersJson, group = false, playlist = false }) {
addChannel({ name, url, avatar, mode, headersJson, group = null, playlist = null, playlistName = null }) {
const existing = this.channels.find(channel => channel.url === url);
if (existing) {
@@ -53,7 +53,7 @@ class ChannelService {
}
const headers = JSON.parse(headersJson);
const newChannel = new Channel(name, url, avatar, mode, headers, group, playlist);
const newChannel = new Channel(name, url, avatar, mode, headers, group, playlist, playlistName);
this.channels.push(newChannel);
return newChannel;

View File

@@ -3,7 +3,7 @@ const ChannelService = require('./ChannelService');
class PlaylistService {
async addPlaylist(playlistUrl, mode, headersJson) {
async addPlaylist(playlistUrl, playlistName, mode, headersJson) {
const response = await fetch(playlistUrl);
const content = await response.text();
@@ -21,7 +21,8 @@ class PlaylistService {
mode: mode,
headersJson: headersJson,
group: channel.group.title,
playlist: playlistUrl
playlist: playlistUrl,
playlistName: playlistName
});
} catch (error) {
console.error(error);
@@ -42,7 +43,7 @@ class PlaylistService {
for(let channel of channels) {
channel = await ChannelService.updateChannel(channel.id, updatedAttributes);
}
return channels;
}

View File

@@ -4,9 +4,10 @@ const Channel = require('../models/Channel');
module.exports = (io, socket) => {
socket.on('add-playlist', async ({ playlist, mode, headersJson }) => {
socket.on('add-playlist', async ({ playlist, playlistName, mode, headersJson }) => {
try {
const channels = await PlaylistService.addPlaylist(playlist, mode, headersJson);
const channels = await PlaylistService.addPlaylist(playlist, playlistName, mode, headersJson);
if (channels) {
channels.forEach(channel => {
io.emit('channel-added', channel);
@@ -21,7 +22,8 @@ module.exports = (io, socket) => {
socket.on('update-playlist', async ({ playlist, updatedAttributes }) => {
try {
const channels = PlaylistService.updatePlaylist(playlist, updatedAttributes);
const channels = await PlaylistService.updatePlaylist(playlist, updatedAttributes);
channels.forEach(channel => {
io.emit('channel-updated', channel.toFrontendJson());
});
@@ -34,7 +36,7 @@ module.exports = (io, socket) => {
socket.on('delete-playlist', async (playlist) => {
try {
const channels = PlaylistService.deletePlaylist(playlist);
const channels = await PlaylistService.deletePlaylist(playlist);
channels.forEach(channel => {
io.emit('channel-deleted', channel.id);
});

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Search, Plus, Settings, Users, Radio, Tv2 } from 'lucide-react';
import { useState, useEffect, useMemo } from 'react';
import { Search, Plus, Settings, Users, Radio, Tv2, ChevronDown } from 'lucide-react';
import VideoPlayer from './components/VideoPlayer';
import ChannelList from './components/ChannelList';
import Chat from './components/chat/Chat';
@@ -28,6 +28,45 @@ function App() {
const [sessionProvider, setSessionProvider] = useState<SessionHandler | null>(null);
const [sessionQuery, setSessionQuery] = useState<string | undefined>(undefined);
const [selectedPlaylist, setSelectedPlaylist] = useState<string>('All Channels');
const [selectedGroup, setSelectedGroup] = useState<string>('Category');
const [isPlaylistDropdownOpen, setIsPlaylistDropdownOpen] = useState(false);
const [isGroupDropdownOpen, setIsGroupDropdownOpen] = useState(false);
// Get unique playlists from channels
const playlists = useMemo(() => {
const uniquePlaylists = new Set(channels.map(channel => channel.playlistName).filter(playlistName => playlistName !== null));
return ['All Channels', ...Array.from(uniquePlaylists)];
}, [channels]);
const filteredChannels = useMemo(() => {
//Filter by playlist
let filteredByPlaylist = selectedPlaylist === 'All Channels' ? channels : channels.filter(channel =>
channel.playlistName === selectedPlaylist
);
//Filter by group
filteredByPlaylist = selectedGroup === 'Category' ? filteredByPlaylist : filteredByPlaylist.filter(channel =>
channel.group === selectedGroup
);
//Filter by name search
return filteredByPlaylist.filter(channel =>
channel.name.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [channels, selectedPlaylist, selectedGroup, searchQuery]);
const groups = useMemo(() => {
let uniqueGroups;
if(selectedPlaylist === 'All Channels') {
uniqueGroups = new Set(channels.map(channel => channel.group).filter(group => group !== null));
} else {
uniqueGroups = new Set(channels.filter(channel => channel.group !== null && channel.playlistName === selectedPlaylist).map(channel => channel.group));
}
return ['Category', ...Array.from(uniqueGroups)];
}, [selectedPlaylist, channels]);
useEffect(() => {
apiService
.request<Channel[]>('/channels/', 'GET')
@@ -115,9 +154,6 @@ function App() {
};
}, []);
const filteredChannels = channels.filter((channel) =>
channel.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleEditChannel = (channel: Channel) => {
setEditChannel(channel);
@@ -158,12 +194,103 @@ function App() {
<div className="col-span-12 lg:col-span-8 space-y-4">
<div className="bg-gray-800 rounded-lg p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-2">
<Tv2 className="w-5 h-5 text-blue-500" />
<h2 className="text-xl font-semibold">Live Channels</h2>
<div className="flex items-center space-x-4">
<div className="relative">
<button
onClick={() => {
setIsPlaylistDropdownOpen(!isPlaylistDropdownOpen);
setIsGroupDropdownOpen(false);
}}
className="flex items-center space-x-2 group"
>
<div className="flex items-center space-x-2">
<Tv2 className="w-5 h-5 text-blue-500" />
<h2 className="text-xl font-semibold group-hover:text-blue-400 transition-colors">
{selectedPlaylist}
</h2>
</div>
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform duration-200 ${isPlaylistDropdownOpen ? 'rotate-180' : ''}`} />
</button>
{isPlaylistDropdownOpen && (
<div className="absolute top-full left-0 mt-1 w-48 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50 overflow-hidden">
<div className="max-h-72 overflow-y-auto scroll-container">
{playlists.map((playlist) => (
<button
key={playlist}
onClick={() => {
setSelectedPlaylist(playlist);
setSelectedGroup('Category');
setIsPlaylistDropdownOpen(false);
}}
className={`w-full text-left px-4 py-2 text-sm transition-colors hover:bg-gray-700 ${
selectedPlaylist === playlist ? 'text-blue-400 text-base font-semibold' : 'text-gray-200'
}`}
style={{
whiteSpace: 'normal',
wordWrap: 'break-word',
overflowWrap: 'anywhere',
}}
>
{playlist}
</button>
))}
</div>
</div>
)}
</div>
{/* Group Dropdown */}
<div className="relative">
<button
onClick={() => {
setIsGroupDropdownOpen(!isGroupDropdownOpen);
setIsPlaylistDropdownOpen(false);
}}
className="flex items-center space-x-2 group py-0.5 px-1.5 rounded-lg transition-all bg-white bg-opacity-10"
>
<div className="flex items-center space-x-2">
<h4 className="text-base text-gray-300 group-hover:text-blue-400 transition-colors">
{selectedGroup}
</h4>
</div>
<ChevronDown className={`w-3 h-3 text-gray-400 transition-transform duration-200 ${isGroupDropdownOpen ? 'rotate-180' : ''}`} />
</button>
{isGroupDropdownOpen && (
<div className="absolute top-full left-0 mt-1 w-48 bg-gray-800 rounded-lg shadow-xl border border-gray-700 z-50 overflow-hidden">
<div className="max-h-72 overflow-y-auto scroll-container">
{groups.map((group) => (
<button
key={group}
onClick={() => {
setSelectedGroup(group);
setIsGroupDropdownOpen(false);
}}
className={`w-full text-left px-4 py-2 text-sm transition-colors hover:bg-gray-700 ${
selectedGroup === group ? 'text-blue-400 text-base font-semibold' : 'text-gray-200'
}`}
style={{
whiteSpace: 'normal',
wordWrap: 'break-word',
overflowWrap: 'anywhere',
}}
>
{group === 'Category' ? 'All Categories' : group}
</button>
))}
</div>
</div>
)}
</div>
</div>
<button
onClick={() => setIsModalOpen(true)}
onClick={() => {
setIsModalOpen(true);
setIsGroupDropdownOpen(false);
setIsPlaylistDropdownOpen(false);
}}
className="p-2 bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-5 h-5" />
@@ -187,14 +314,15 @@ function App() {
</div>
</div>
<ChannelModal
isOpen={isModalOpen}
onClose={() => {
setIsModalOpen(false)
setEditChannel(null);
}}
channel={editChannel}
/>
{isModalOpen && (
<ChannelModal
onClose={() => {
setIsModalOpen(false);
setEditChannel(null);
}}
channel={editChannel}
/>
)}
<SettingsModal
isOpen={isSettingsOpen}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useContext } from 'react';
import { Info, Plus, Trash2, X } from 'lucide-react';
import { Plus, Trash2, X } from 'lucide-react';
import socketService from '../../services/SocketService';
import { CustomHeader, Channel, ChannelMode } from '../../types';
import CustomHeaderInput from './CustomHeaderInput';
@@ -7,12 +7,11 @@ import { ToastContext } from '../notifications/ToastContext';
import { ModeTooltipContent, Tooltip } from '../Tooltip';
interface ChannelModalProps {
isOpen: boolean;
onClose: () => void;
channel?: Channel | null;
}
function ChannelModal({ isOpen, onClose, channel }: ChannelModalProps) {
function ChannelModal({ onClose, channel }: ChannelModalProps) {
const [type, setType] = useState<'channel' | 'playlist'>('channel');
const [isEditMode, setIsEditMode] = useState(false);
@@ -22,6 +21,7 @@ function ChannelModal({ isOpen, onClose, channel }: ChannelModalProps) {
const [mode, setMode] = useState<ChannelMode>('proxy');
const [headers, setHeaders] = useState<CustomHeader[]>([]);
const [playlistName, setPlaylistName] = useState('');
const [playlistUrl, setPlaylistUrl] = useState('');
const { addToast } = useContext(ToastContext);
@@ -33,17 +33,21 @@ function ChannelModal({ isOpen, onClose, channel }: ChannelModalProps) {
setAvatar(channel.avatar);
setMode(channel.mode);
setHeaders(channel.headers);
setPlaylistName(channel.playlistName);
setPlaylistUrl(channel.playlist);
setIsEditMode(true);
setType('channel'); // Default to "channel" if a channel object exists
console.log("NOT");
} else {
setName('');
setUrl('');
setAvatar('');
setMode('proxy');
setHeaders([]);
setPlaylistName('');
setPlaylistUrl('');
setIsEditMode(false);
console.log("CLEAR");
setType('channel'); // Default to "channel" if a channel object exists
}
}, [channel]);
@@ -81,7 +85,7 @@ function ChannelModal({ isOpen, onClose, channel }: ChannelModalProps) {
);
} else if (type === 'playlist') {
if (!playlistUrl.trim()) return;
socketService.addPlaylist(playlistUrl.trim(), mode, JSON.stringify(headers));
socketService.addPlaylist(playlistUrl.trim(), playlistName.trim(), mode, JSON.stringify(headers));
}
addToast({
@@ -107,10 +111,11 @@ function ChannelModal({ isOpen, onClose, channel }: ChannelModalProps) {
if(channel!.playlist !== playlistUrl.trim()) {
// If the playlist URL has changed, we need to reload the playlist (delete old channels and fetch again)
socketService.deletePlaylist(channel!.playlist);
socketService.addPlaylist(playlistUrl.trim(), mode, JSON.stringify(headers));
socketService.addPlaylist(playlistUrl.trim(), playlistName.trim(), mode, JSON.stringify(headers));
} else {
socketService.updatePlaylist(playlistUrl.trim(), {
playlist: playlistUrl.trim(),
playlistName: playlistName.trim(),
mode: mode,
headers: headers,
});
@@ -142,8 +147,6 @@ function ChannelModal({ isOpen, onClose, channel }: ChannelModalProps) {
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4">
<div className="bg-gray-800 rounded-lg w-full max-w-md">
@@ -277,6 +280,20 @@ function ChannelModal({ isOpen, onClose, channel }: ChannelModalProps) {
{type === 'playlist' && (
<>
{/* Playlist fields */}
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Playlist Name
</label>
<input
type="text"
id="playlistName"
value={playlistName}
onChange={(e) => setPlaylistName(e.target.value)}
className="w-full bg-gray-700 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter playlist name"
required
/>
</div>
<div>
<label htmlFor="playlistUrl" className="block text-sm font-medium mb-1">
M3U Playlist URL

View File

@@ -98,10 +98,10 @@ class SocketService {
}
// Add playlist
addPlaylist(playlist: string, mode: ChannelMode, headersJson: string) {
addPlaylist(playlist: string, playlistName: string, mode: ChannelMode, headersJson: string) {
if (!this.socket) throw new Error('Socket is not connected.');
this.socket.emit('add-playlist', { playlist, mode, headersJson });
this.socket.emit('add-playlist', { playlist, playlistName, mode, headersJson });
}
// Update playlist

View File

@@ -29,6 +29,7 @@ export interface Channel {
headers: CustomHeader[];
group: string;
playlist: string;
playlistName: string;
}
export interface ChatMessage {