Merge pull request #49 from antebrl/42-playlist-group-selection
42 playlist group selection
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface Channel {
|
||||
headers: CustomHeader[];
|
||||
group: string;
|
||||
playlist: string;
|
||||
playlistName: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
|
||||
Reference in New Issue
Block a user