diff --git a/CHANGELOG.md b/CHANGELOG.md index bf803bb2..15420a07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Floating video player now displays the channel, stream, or VOD title in the player header bar. Title is passed through from all preview entry points: channel table, stream table, stream connection card, guide, DVR, recording cards, recording details modal, VOD modal, and series modal. - New Client Buffer proxy setting: new clients joining an active channel are now positioned a configurable number of seconds behind live rather than a fixed chunk count. The start position is determined by wall-clock chunk receive time (stored as a Redis sorted set alongside the buffer), so the buffer depth is consistent in seconds regardless of stream bitrate. Setting the value to `0` starts clients at live with no buffer. Defaults to 2 seconds. Existing chunk-count gating for the first client connecting to a channel is unchanged. The setting is exposed in Settings → Proxy as "New Client Buffer (seconds)". - Channel table filter for channels that have stale streams: A new "Has Stale Streams" filter option in the channel table header menu highlights and filters channels containing at least one stale stream. Channels with stale streams are visually distinguished with an orange tint. The filter is mutually exclusive with "Only Empty Channels". - Thanks [@JCBird1012](https://github.com/JCBird1012) - DVR enhancements — Thanks [@CodeBormen](https://github.com/CodeBormen) diff --git a/frontend/src/components/FloatingVideo.jsx b/frontend/src/components/FloatingVideo.jsx index 557767ed..529a605c 100644 --- a/frontend/src/components/FloatingVideo.jsx +++ b/frontend/src/components/FloatingVideo.jsx @@ -718,13 +718,35 @@ export default function FloatingVideo() { boxShadow: '0 2px 10px rgba(0,0,0,0.7)', }} > - {/* Simple header row with a close button */} + {/* Header row with optional title and close button */} + {metadata?.name ? ( + + {metadata.name} + + ) : ( + + )} diff --git a/frontend/src/components/cards/RecordingCard.jsx b/frontend/src/components/cards/RecordingCard.jsx index 903bb7dd..05620053 100644 --- a/frontend/src/components/cards/RecordingCard.jsx +++ b/frontend/src/components/cards/RecordingCard.jsx @@ -99,7 +99,11 @@ const RecordingCard = ({ const status = customProps.status; const isTimeActive = now.isAfter(start) && now.isBefore(end); const isInterrupted = status === 'interrupted'; - const isInProgress = isTimeActive && !isInterrupted && status !== 'completed' && status !== 'stopped'; + const isInProgress = + isTimeActive && + !isInterrupted && + status !== 'completed' && + status !== 'stopped'; const isUpcoming = now.isBefore(start); const isSeriesGroup = Boolean( recording._group_count && recording._group_count > 1 @@ -114,7 +118,9 @@ const RecordingCard = ({ const handleWatchLive = () => { if (!channel) return; - showVideo(getShowVideoUrl(channel, env_mode), 'live'); + showVideo(getShowVideoUrl(channel, env_mode), 'live', { + name: channel.name, + }); }; const handleWatchRecording = () => { @@ -197,7 +203,9 @@ const RecordingCard = ({ } finally { setBusy(false); setStopConfirmOpen(false); - try { await fetchRecordings(); } catch {} + try { + await fetchRecordings(); + } catch {} } }; @@ -371,9 +379,15 @@ const RecordingCard = ({ e.stopPropagation()}> Extend recording by - handleExtend(15, e)}>+15 minutes - handleExtend(30, e)}>+30 minutes - handleExtend(60, e)}>+1 hour + handleExtend(15, e)}> + +15 minutes + + handleExtend(30, e)}> + +30 minutes + + handleExtend(60, e)}> + +1 hour + @@ -391,7 +405,15 @@ const RecordingCard = ({ )} - + )} - + {isInProgress && } {!isUpcoming && } {!isUpcoming && - (customProps?.status === 'completed' || customProps?.status === 'stopped' || customProps?.status === 'interrupted') && + (customProps?.status === 'completed' || + customProps?.status === 'stopped' || + customProps?.status === 'interrupted') && (!customProps?.comskip || customProps?.comskip?.status !== 'completed') && ( @@ -556,12 +599,13 @@ const RecordingCard = ({ ); - if (!isSeriesGroup) return ( - <> - {ConfirmModals} - {MainCard} - - ); + if (!isSeriesGroup) + return ( + <> + {ConfirmModals} + {MainCard} + + ); // Stacked look for series groups: render two shadow layers behind the main card return ( diff --git a/frontend/src/components/cards/StreamConnectionCard.jsx b/frontend/src/components/cards/StreamConnectionCard.jsx index bf72b109..4952f6fe 100644 --- a/frontend/src/components/cards/StreamConnectionCard.jsx +++ b/frontend/src/components/cards/StreamConnectionCard.jsx @@ -431,7 +431,7 @@ const StreamConnectionCard = ({ url = `${window.location.protocol}//${window.location.hostname}:5656${uri}`; } - showVideo(url); + showVideo(url, 'live', { name: actualChannel.name }); }; if (location.pathname !== '/stats') { diff --git a/frontend/src/components/forms/RecordingDetailsModal.jsx b/frontend/src/components/forms/RecordingDetailsModal.jsx index 4b2610c3..39643d27 100644 --- a/frontend/src/components/forms/RecordingDetailsModal.jsx +++ b/frontend/src/components/forms/RecordingDetailsModal.jsx @@ -82,7 +82,12 @@ const RecordingDetailsModal = ({ // Derive poster URL from live store data instead of the stale prop snapshot. const livePosterUrl = React.useMemo( - () => getPosterUrl(customProps.poster_logo_id, customProps, getChannelLogoUrl(channel)), + () => + getPosterUrl( + customProps.poster_logo_id, + customProps, + getChannelLogoUrl(channel) + ), [customProps.poster_logo_id, customProps, channel] ); @@ -91,7 +96,8 @@ const RecordingDetailsModal = ({ const [savedTitle, setSavedTitle] = React.useState(null); const [savedDescription, setSavedDescription] = React.useState(null); const recordingName = savedTitle ?? (program.title || 'Custom Recording'); - const description = savedDescription ?? (program.description || customProps.description || ''); + const description = + savedDescription ?? (program.description || customProps.description || ''); const [editTitle, setEditTitle] = React.useState(''); const [editDescription, setEditDescription] = React.useState(''); @@ -186,7 +192,9 @@ const RecordingDetailsModal = ({ channelsById[rec.channel] || (rec.channel === recording?.channel ? channel : null); if (!ch) return; - useVideoStore.getState().showVideo(getShowVideoUrl(ch, env_mode), 'live'); + useVideoStore + .getState() + .showVideo(getShowVideoUrl(ch, env_mode), 'live', { name: ch.name }); } }; @@ -281,7 +289,9 @@ const RecordingDetailsModal = ({ const se = getSeasonLabel(season, episode, onscreen); const posterLogoId = cp.poster_logo_id; const purl = getPosterUrl(posterLogoId, cp, livePosterUrl); - const epChannel = channelsById[rec.channel] || (rec.channel === recording?.channel ? channel : null); + const epChannel = + channelsById[rec.channel] || + (rec.channel === recording?.channel ? channel : null); const onRemove = async (e) => { e?.stopPropagation?.(); @@ -533,12 +543,25 @@ const RecordingDetailsModal = ({ size="sm" style={{ flex: 1 }} autoFocus - onKeyDown={(e) => { if (e.key === 'Enter') saveMetadata(); if (e.key === 'Escape') cancelEditing(); }} + onKeyDown={(e) => { + if (e.key === 'Enter') saveMetadata(); + if (e.key === 'Escape') cancelEditing(); + }} /> - + - + @@ -552,7 +575,12 @@ const RecordingDetailsModal = ({ : `${recordingName}${program.sub_title ? ` - ${program.sub_title}` : ''}`} {!isSeriesGroup && ( - + )} diff --git a/frontend/src/components/tables/ChannelTableStreams.jsx b/frontend/src/components/tables/ChannelTableStreams.jsx index 40771673..4344fdb2 100644 --- a/frontend/src/components/tables/ChannelTableStreams.jsx +++ b/frontend/src/components/tables/ChannelTableStreams.jsx @@ -140,12 +140,12 @@ const ChannelStreams = ({ channel, isExpanded }) => { const authUser = useAuthStore((s) => s.user); const showVideo = useVideoStore((s) => s.showVideo); const env_mode = useSettingsStore((s) => s.environment.env_mode); - function handleWatchStream(streamHash) { + function handleWatchStream(streamHash, streamName) { let vidUrl = `/proxy/ts/stream/${streamHash}`; if (env_mode === 'dev') { vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; } - showVideo(vidUrl); + showVideo(vidUrl, 'live', streamName ? { name: streamName } : null); } const [data, setData] = useState(channelStreams || []); @@ -390,7 +390,10 @@ const ChannelStreams = ({ channel, isExpanded }) => { color="blue" variant="light" onClick={() => - handleWatchStream(stream.stream_hash || stream.id) + handleWatchStream( + stream.stream_hash || stream.id, + stream.name + ) } style={{ marginLeft: 2 }} > diff --git a/frontend/src/components/tables/ChannelsTable.jsx b/frontend/src/components/tables/ChannelsTable.jsx index b6b93f43..7f92bfb4 100644 --- a/frontend/src/components/tables/ChannelsTable.jsx +++ b/frontend/src/components/tables/ChannelsTable.jsx @@ -693,7 +693,7 @@ const ChannelsTable = ({ onReady }) => { ); const url = getChannelURL(channel); console.log(`Stream URL: ${url}`); - showVideo(url); + showVideo(url, 'live', { name: channel.name }); }; const onRowSelectionChange = (newSelection) => { diff --git a/frontend/src/components/tables/StreamsTable.jsx b/frontend/src/components/tables/StreamsTable.jsx index 65d2a418..7d4fd2c1 100644 --- a/frontend/src/components/tables/StreamsTable.jsx +++ b/frontend/src/components/tables/StreamsTable.jsx @@ -112,7 +112,7 @@ const StreamRowActions = ({ 'Hash:', row.original.stream_hash ); - handleWatchStream(row.original.stream_hash); + handleWatchStream(row.original.stream_hash, row.original.name); }, [row.original, handleWatchStream]); // Add proper dependencies to ensure correct stream const iconSize = @@ -1012,12 +1012,12 @@ const StreamsTable = ({ onReady }) => { }); }; - function handleWatchStream(streamHash) { + function handleWatchStream(streamHash, streamName) { let vidUrl = `/proxy/ts/stream/${streamHash}`; if (env_mode == 'dev') { vidUrl = `${window.location.protocol}//${window.location.hostname}:5656${vidUrl}`; } - showVideo(vidUrl); + showVideo(vidUrl, 'live', streamName ? { name: streamName } : null); } const onSortingChange = (column) => { diff --git a/frontend/src/pages/DVR.jsx b/frontend/src/pages/DVR.jsx index a8e0d6ed..31e6f1ba 100644 --- a/frontend/src/pages/DVR.jsx +++ b/frontend/src/pages/DVR.jsx @@ -26,7 +26,11 @@ const RecordingDetailsModal = lazy( ); import RecurringRuleModal from '../components/forms/RecurringRuleModal.jsx'; import RecordingCard from '../components/cards/RecordingCard.jsx'; -import { categorizeRecordings, filterRecordings, buildChannelOptions } from '../utils/pages/DVRUtils.js'; +import { + categorizeRecordings, + filterRecordings, + buildChannelOptions, +} from '../utils/pages/DVRUtils.js'; import { getChannelLogoUrl, getPosterUrl, @@ -101,7 +105,12 @@ const DVRPage = () => { setDetailsOpen(false); setDetailsRecording(null); setEditRecording(null); - setRuleModal({ open: true, ruleId, recording, isDelete: isDelete || false }); + setRuleModal({ + open: true, + ruleId, + recording, + isDelete: isDelete || false, + }); }; const closeRuleModal = () => setRuleModal({ open: false, ruleId: null }); @@ -152,7 +161,8 @@ const DVRPage = () => { }, [channelsById, inProgress, upcoming, completed]); // Filtered buckets - const hasActiveFilters = searchQuery !== '' || selectedChannelId !== null || selectedStatus !== null; + const hasActiveFilters = + searchQuery !== '' || selectedChannelId !== null || selectedStatus !== null; const filteredInProgress = useMemo(() => { if (selectedStatus && selectedStatus !== 'recording') return []; @@ -165,13 +175,21 @@ const DVRPage = () => { }, [upcoming, searchQuery, selectedChannelId, selectedStatus]); const filteredCompleted = useMemo(() => { - if (selectedStatus && !['completed', 'interrupted'].includes(selectedStatus)) return []; + if ( + selectedStatus && + !['completed', 'interrupted'].includes(selectedStatus) + ) + return []; let filtered = filterRecordings(completed, searchQuery, selectedChannelId); if (selectedStatus === 'interrupted') { - filtered = filtered.filter((rec) => rec.custom_properties?.status === 'interrupted'); + filtered = filtered.filter( + (rec) => rec.custom_properties?.status === 'interrupted' + ); } else if (selectedStatus === 'completed') { // "Completed" includes both completed and stopped recordings - filtered = filtered.filter((rec) => rec.custom_properties?.status !== 'interrupted'); + filtered = filtered.filter( + (rec) => rec.custom_properties?.status !== 'interrupted' + ); } return filtered; }, [completed, searchQuery, selectedChannelId, selectedStatus]); @@ -196,7 +214,7 @@ const DVRPage = () => { channel, useSettingsStore.getState().environment.env_mode ); - useVideoStore.getState().showVideo(url, 'live'); + useVideoStore.getState().showVideo(url, 'live', { name: channel.name }); } }; diff --git a/frontend/src/pages/Guide.jsx b/frontend/src/pages/Guide.jsx index 980f145e..51133323 100644 --- a/frontend/src/pages/Guide.jsx +++ b/frontend/src/pages/Guide.jsx @@ -724,7 +724,9 @@ export default function TVChannelGuide({ startDate, endDate }) { return; } - showVideo(getShowVideoUrl(matched, env_mode)); + showVideo(getShowVideoUrl(matched, env_mode), 'live', { + name: matched.name, + }); }, [env_mode, findChannelByTvgId, showVideo] ); @@ -733,7 +735,9 @@ export default function TVChannelGuide({ startDate, endDate }) { (channel, event) => { event.stopPropagation(); - showVideo(getShowVideoUrl(channel, env_mode)); + showVideo(getShowVideoUrl(channel, env_mode), 'live', { + name: channel.name, + }); }, [env_mode, showVideo] ); @@ -1342,9 +1346,7 @@ export default function TVChannelGuide({ startDate, endDate }) { }} pos="relative" > - + {nowPosition >= 0 && (