diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx
index 38c4539e..9bbb9826 100644
--- a/frontend/src/pages/Guide.jsx
+++ b/frontend/src/pages/Guide.jsx
@@ -10,32 +10,28 @@ import useSettingsStore from '../store/settings';
import {
Title,
Box,
- Modal,
Flex,
Button,
Text,
Paper,
- Grid,
Group,
TextInput,
Select,
ActionIcon,
Tooltip,
+ Transition,
} from '@mantine/core';
-import { Search, X, Clock } from 'lucide-react';
+import { Search, X, Clock, Video, Calendar } from 'lucide-react';
import './guide.css';
/** Layout constants */
const CHANNEL_WIDTH = 120; // Width of the channel/logo column
const PROGRAM_HEIGHT = 90; // Height of each channel row
+const EXPANDED_PROGRAM_HEIGHT = 180; // Height for expanded program rows
const HOUR_WIDTH = 450; // Increased from 300 to 450 to make each program wider
const MINUTE_INCREMENT = 15; // For positioning programs every 15 min
const MINUTE_BLOCK_WIDTH = HOUR_WIDTH / (60 / MINUTE_INCREMENT);
-// Modal size constants
-const MODAL_WIDTH = 600;
-const MODAL_HEIGHT = 400;
-
export default function TVChannelGuide({ startDate, endDate }) {
const { channels, recordings, channelGroups, profiles } = useChannelsStore();
@@ -43,8 +39,8 @@ export default function TVChannelGuide({ startDate, endDate }) {
const [guideChannels, setGuideChannels] = useState([]);
const [filteredChannels, setFilteredChannels] = useState([]);
const [now, setNow] = useState(dayjs());
- const [selectedProgram, setSelectedProgram] = useState(null);
- const [recording, setRecording] = useState(null);
+ const [expandedProgramId, setExpandedProgramId] = useState(null); // Track expanded program
+ const [recordingForProgram, setRecordingForProgram] = useState(null);
const [loading, setLoading] = useState(true);
const [initialScrollComplete, setInitialScrollComplete] = useState(false);
@@ -250,32 +246,74 @@ export default function TVChannelGuide({ startDate, endDate }) {
}
showVideo(vidUrl);
-
- // Optionally close the modal
- setSelectedProgram(null);
}
- // On program click, open the details modal
+ // On program click, toggle the expanded state
function handleProgramClick(program, event) {
- setSelectedProgram(program);
- setRecording(
- recordings.find((recording) => {
- if (recording.custom_properties) {
- const customProps = JSON.parse(recording.custom_properties);
- if (customProps.program && customProps.program.id == program.id) {
- return recording;
- }
+ // Prevent event from bubbling up to parent elements
+ event.stopPropagation();
+
+ // Get the program's start time and calculate its position
+ const programStart = dayjs(program.start_time);
+ const startOffsetMinutes = programStart.diff(start, 'minute');
+ const leftPx = (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
+
+ // Calculate desired scroll position (account for channel column width)
+ const desiredScrollPosition = Math.max(0, leftPx - 20); // 20px buffer
+
+ // If already expanded, collapse it
+ if (expandedProgramId === program.id) {
+ setExpandedProgramId(null);
+ setRecordingForProgram(null);
+ return;
+ }
+
+ // Otherwise expand this program
+ setExpandedProgramId(program.id);
+
+ // Check if this program has a recording
+ const programRecording = recordings.find((recording) => {
+ if (recording.custom_properties) {
+ const customProps = JSON.parse(recording.custom_properties);
+ if (customProps.program && customProps.program.id == program.id) {
+ return true;
}
+ }
+ return false;
+ });
- return null;
- })
- );
+ setRecordingForProgram(programRecording);
+
+ // Scroll to show the start of the program if it's not already fully visible
+ if (guideRef.current && timelineRef.current) {
+ const currentScrollPosition = guideRef.current.scrollLeft;
+
+ // Check if we need to scroll (if program start is before current view or too close to edge)
+ if (desiredScrollPosition < currentScrollPosition ||
+ leftPx - currentScrollPosition < 100) { // 100px from left edge
+
+ // Smooth scroll to the program's start
+ guideRef.current.scrollTo({
+ left: desiredScrollPosition,
+ behavior: 'smooth'
+ });
+
+ // Also sync the timeline scroll
+ timelineRef.current.scrollTo({
+ left: desiredScrollPosition,
+ behavior: 'smooth'
+ });
+ }
+ }
}
- // Close the modal
- function handleCloseModal() {
- setSelectedProgram(null);
- }
+ // Close the expanded program when clicking elsewhere
+ const handleClickOutside = () => {
+ if (expandedProgramId) {
+ setExpandedProgramId(null);
+ setRecordingForProgram(null);
+ }
+ };
// Function to scroll to current time - matches initial loading position
const scrollToNow = () => {
@@ -380,6 +418,8 @@ export default function TVChannelGuide({ startDate, endDate }) {
const durationMinutes = programEnd.diff(programStart, 'minute');
const leftPx = (startOffsetMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
const widthPx = (durationMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
+
+ // Check if we have a recording for this program
const recording = recordings.find((recording) => {
if (recording.custom_properties) {
const customProps = JSON.parse(recording.custom_properties);
@@ -387,7 +427,6 @@ export default function TVChannelGuide({ startDate, endDate }) {
return recording;
}
}
-
return null;
});
@@ -397,10 +436,21 @@ export default function TVChannelGuide({ startDate, endDate }) {
// Determine if the program has ended
const isPast = now.isAfter(programEnd);
+ // Check if this program is expanded
+ const isExpanded = expandedProgramId === program.id;
+
// Calculate how much of the program is cut off
const cutOffMinutes = Math.max(0, channelStart.diff(programStart, 'minute'));
const cutOffPx = (cutOffMinutes / MINUTE_INCREMENT) * MINUTE_BLOCK_WIDTH;
+ // Set the height based on expanded state
+ const rowHeight = isExpanded ? EXPANDED_PROGRAM_HEIGHT : PROGRAM_HEIGHT;
+
+ // Determine expanded width - if program is short, ensure it has a minimum expanded width
+ // This will allow it to overlap programs to the right
+ const MIN_EXPANDED_WIDTH = 450; // Minimum width in pixels when expanded
+ const expandedWidthPx = Math.max(widthPx, MIN_EXPANDED_WIDTH);
+
return (
handleProgramClick(program, e)}
>
+
+ {/* Description is always shown but expands when row is expanded */}
{program.description && (
{program.description}
)}
+
+ {/* Expanded content */}
+ {isExpanded && (
+
+
+ {/* Only show Record button if not already recording AND not in the past */}
+ {!recording && !isPast && (
+ }
+ variant="filled"
+ color="red"
+ size="xs"
+ onClick={(e) => {
+ e.stopPropagation();
+ record(program);
+ }}
+ >
+ Record
+
+ )}
+
+ {isLive && (
+ }
+ variant="filled"
+ color="blue"
+ size="xs"
+ onClick={(e) => {
+ e.stopPropagation();
+ handleWatchStream(program);
+ }}
+ >
+ Watch Now
+
+ )}
+
+
+ )}
);
@@ -541,6 +643,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
color: '#fff',
fontFamily: 'Roboto, sans-serif',
}}
+ onClick={handleClickOutside} // Close expanded program when clicking outside
>
{/* Sticky top bar */}
p.tvg_id === channel.epg_data?.tvg_id
);
+ // Check if any program in this channel is expanded
+ const hasExpandedProgram = channelPrograms.some(prog => prog.id === expandedProgramId);
+ const rowHeight = hasExpandedProgram ? EXPANDED_PROGRAM_HEIGHT : PROGRAM_HEIGHT;
+
return (
{/* Channel logo - sticky horizontally */}
- {/* Logo content - unchanged */}
+ {/* Logo content */}
{/* Programs for this channel */}
-
+
{channelPrograms.map((prog) => renderProgram(prog, start))}
@@ -856,48 +977,7 @@ export default function TVChannelGuide({ startDate, endDate }) {
- {/* Modal for program details */}
-
- {selectedProgram && (
- <>
-
- {dayjs(selectedProgram.start_time).format('h:mma')} -{' '}
- {dayjs(selectedProgram.end_time).format('h:mma')}
-
-
- {selectedProgram.description || 'No description available.'}
-
- {/* Only show the Watch button if currently live */}
-
- {!recording && (
-
- )}
-
- {now.isAfter(dayjs(selectedProgram.start_time)) &&
- now.isBefore(dayjs(selectedProgram.end_time)) && (
-
- )}
-
- >
- )}
-
+ {/* Modal removed since we're using expanded rows instead */}
);
}
diff --git a/frontend/src/pages/guide.css b/frontend/src/pages/guide.css
index 93161a5e..5b6568c1 100644
--- a/frontend/src/pages/guide.css
+++ b/frontend/src/pages/guide.css
@@ -7,9 +7,10 @@
white-space: nowrap;
text-overflow: ellipsis;
border-radius: 8px;
- background: linear-gradient(to right, #2d3748, #2d3748); /* Default background */
+ background: linear-gradient(to right, #2d3748, #2d3748);
+ /* Default background */
color: #fff;
- transition: background 0.3s ease;
+ transition: all 0.2s ease-out;
}
.tv-guide .guide-program-container .guide-program.live {
@@ -23,3 +24,46 @@
.tv-guide .guide-program-container .guide-program.not-live:hover {
background: linear-gradient(to right, #2d3748, #1a202c);
}
+
+/* New styles for expanded programs */
+.tv-guide .guide-program-container .guide-program.expanded {
+ box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
+ z-index: 26;
+ /* Above other programs but below channel logo */
+}
+
+.tv-guide .guide-program-container .guide-program.expanded.live {
+ background: linear-gradient(to right, #1a365d, #1e40af);
+}
+
+.tv-guide .guide-program-container .guide-program.expanded.not-live {
+ background: linear-gradient(to right, #1a365d, #1e3a8a);
+}
+
+.tv-guide .guide-program-container .guide-program.expanded.past {
+ background: linear-gradient(to right, #2d3748, #4a5568);
+}
+
+/* Ensure channel logo is always on top */
+.tv-guide .channel-logo {
+ z-index: 30;
+ position: relative;
+ position: sticky !important;
+ left: 0;
+ z-index: 30;
+}
+
+/* Give focus to expanded elements */
+.tv-guide .guide-program-container {
+ transform-origin: left center;
+ transition: all 0.2s ease-out;
+}
+
+.tv-guide .guide-program-container.expanded {
+ z-index: 25;
+}
+
+/* Make sure the main scrolling container establishes the right stacking context */
+.tv-guide {
+ position: relative;
+}
\ No newline at end of file