feat(main): main
This commit is contained in:
@@ -142,6 +142,24 @@ export default function ChannelTuner({ onOpenGuide }) {
|
||||
muted={!isCurrent}
|
||||
loop
|
||||
playsInline
|
||||
onLoadedMetadata={(e) => {
|
||||
const video = e.target;
|
||||
if (currentAiring && currentAiring.starts_at) {
|
||||
const startTime = new Date(currentAiring.starts_at).getTime();
|
||||
const nowTime = Date.now();
|
||||
|
||||
if (nowTime > startTime) {
|
||||
const offsetSeconds = (nowTime - startTime) / 1000;
|
||||
// If the video is shorter than the offset (e.g. repeating a short clip),
|
||||
// modulo the offset by duration to emulate a continuous loop.
|
||||
if (video.duration && video.duration > 0) {
|
||||
video.currentTime = offsetSeconds % video.duration;
|
||||
} else {
|
||||
video.currentTime = offsetSeconds;
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
onError={(e) => {
|
||||
if (e.target.src !== chan.fallbackFile) {
|
||||
console.warn(`Video failed to load: ${e.target.src}, falling back.`);
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
fetchChannels, createChannel, deleteChannel, updateChannel,
|
||||
fetchChannelSources, assignSourceToChannel, removeSourceFromChannel,
|
||||
fetchTemplates, createTemplate, deleteTemplate, generateScheduleToday,
|
||||
fetchTemplateBlocks, createTemplateBlock, deleteTemplateBlock,
|
||||
fetchSources, createSource, syncSource, deleteSource,
|
||||
fetchLibraries,
|
||||
fetchDownloadStatus, triggerCacheUpcoming, downloadItem,
|
||||
@@ -156,7 +157,7 @@ function ChannelsTab() {
|
||||
const [channelSources, setChannelSources] = useState({}); // { channelId: [rules] }
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [form, setForm] = useState({ name: '', slug: '', channel_number: '', description: '', library_id: '', owner_user_id: '' });
|
||||
const [assignForm, setAssignForm] = useState({ source_id: '', rule_mode: 'allow', weight: 1.0 });
|
||||
const [assignForm, setAssignForm] = useState({ source_id: '', rule_mode: 'allow', weight: 1.0, schedule_block_label: '' });
|
||||
const [syncingId, setSyncingId] = useState(null);
|
||||
const [feedback, setFeedback, ok, err] = useFeedback();
|
||||
|
||||
@@ -210,9 +211,10 @@ function ChannelsTab() {
|
||||
source_id: parseInt(assignForm.source_id),
|
||||
rule_mode: assignForm.rule_mode,
|
||||
weight: parseFloat(assignForm.weight),
|
||||
schedule_block_label: assignForm.schedule_block_label || null,
|
||||
});
|
||||
setChannelSources(cs => ({ ...cs, [channelId]: [...(cs[channelId] || []), rule] }));
|
||||
setAssignForm({ source_id: '', rule_mode: 'allow', weight: 1.0 });
|
||||
setAssignForm({ source_id: '', rule_mode: 'allow', weight: 1.0, schedule_block_label: '' });
|
||||
ok('Source assigned to channel.');
|
||||
} catch { err('Failed to assign source.'); }
|
||||
};
|
||||
@@ -367,6 +369,13 @@ function ChannelsTab() {
|
||||
style={{ width: 60 }}
|
||||
title="Weight (higher = more airings)"
|
||||
/>
|
||||
<input
|
||||
placeholder="Target Block Label (Optional)"
|
||||
value={assignForm.schedule_block_label}
|
||||
onChange={e => setAssignForm(f => ({ ...f, schedule_block_label: e.target.value }))}
|
||||
style={{ flex: 1 }}
|
||||
title="If set, this source will ONLY play during blocks with this exact name"
|
||||
/>
|
||||
<button className="btn-sync sm" onClick={() => handleAssign(ch.id)}>+ Add Rule</button>
|
||||
</div>
|
||||
|
||||
@@ -690,12 +699,35 @@ function SchedulingTab() {
|
||||
const [form, setForm] = useState({ name: '', channel_id: '', timezone_name: 'America/New_York', priority: 10 });
|
||||
const [feedback, setFeedback, ok, err] = useFeedback();
|
||||
|
||||
// Block Editor State
|
||||
const [expandedTmplId, setExpandedTmplId] = useState(null);
|
||||
const [templateBlocks, setTemplateBlocks] = useState({}); // { tmplId: [blocks] }
|
||||
const [blockForm, setBlockForm] = useState({
|
||||
name: 'A Block',
|
||||
block_type: 'PROGRAM',
|
||||
start_local_time: '08:00',
|
||||
end_local_time: '12:00',
|
||||
day_of_week_mask: 127,
|
||||
target_content_rating: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([fetchTemplates(), fetchChannels()])
|
||||
.then(([t, c]) => { setTemplates(t); setChannels(c); })
|
||||
.catch(() => err('Failed to load schedule data'));
|
||||
}, []);
|
||||
|
||||
const toggleExpand = async (tmpl) => {
|
||||
const next = expandedTmplId === tmpl.id ? null : tmpl.id;
|
||||
setExpandedTmplId(next);
|
||||
if (next && !templateBlocks[next]) {
|
||||
try {
|
||||
const blocks = await fetchTemplateBlocks(tmpl.id);
|
||||
setTemplateBlocks(tb => ({ ...tb, [tmpl.id]: blocks }));
|
||||
} catch { err('Failed to load blocks'); }
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
@@ -780,25 +812,105 @@ function SchedulingTab() {
|
||||
|
||||
<div className="settings-row-list">
|
||||
{templates.length === 0 && <EmptyState text="No schedule templates yet. Create one above." />}
|
||||
{templates.map(t => (
|
||||
<div key={t.id} className="settings-row">
|
||||
<div className="row-avatar" style={{ fontSize: '1.2rem' }}>📄</div>
|
||||
<div className="row-info">
|
||||
<strong>{t.name}</strong>
|
||||
<span className="row-sub">{channelName(t.channel_id)} · {t.timezone_name}</span>
|
||||
<span className="row-badges">
|
||||
<span className="badge badge-type">Priority {t.priority}</span>
|
||||
{t.is_active
|
||||
? <span className="badge badge-ok">Active</span>
|
||||
: <span className="badge badge-muted">Inactive</span>
|
||||
}
|
||||
</span>
|
||||
{templates.map(t => {
|
||||
const isExpanded = expandedTmplId === t.id;
|
||||
const blocks = templateBlocks[t.id] || [];
|
||||
return (
|
||||
<div key={t.id} className={`settings-row-expandable ${isExpanded ? 'expanded' : ''}`}>
|
||||
<div className="settings-row" onClick={() => toggleExpand(t)}>
|
||||
<div className="row-avatar" style={{ fontSize: '1.2rem' }}>📄</div>
|
||||
<div className="row-info">
|
||||
<strong>{t.name}</strong>
|
||||
<span className="row-sub">{channelName(t.channel_id)} · {t.timezone_name}</span>
|
||||
<span className="row-badges">
|
||||
<span className="badge badge-type">Priority {t.priority}</span>
|
||||
{t.is_active
|
||||
? <span className="badge badge-ok">Active</span>
|
||||
: <span className="badge badge-muted">Inactive</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
<div className="row-actions" onClick={e => e.stopPropagation()}>
|
||||
<IconBtn icon="🗑" kind="danger" onClick={() => handleDelete(t)} />
|
||||
<span className="expand-chevron">{isExpanded ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="channel-expand-panel block-editor" style={{ background: 'rgba(0,0,0,0.1)', borderTop: 'none', padding: '1rem', borderBottomLeftRadius: '6px', borderBottomRightRadius: '6px' }}>
|
||||
<h4 style={{ margin: '0 0 1rem 0', opacity: 0.9 }}>Schedule Blocks</h4>
|
||||
|
||||
{blocks.length === 0 && (
|
||||
<div style={{ fontSize: '0.9rem', opacity: 0.7, marginBottom: '1rem' }}>
|
||||
No blocks defined. By default, PYTV acts as if there is a single 24/7 block. If you define blocks here, you must completely cover the 24 hours of a day to avoid dead air.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1.5rem' }}>
|
||||
{blocks.map(b => (
|
||||
<div key={b.id} style={{ display: 'flex', gap: '0.5rem', background: '#353b48', padding: '0.5rem', borderRadius: '4px', alignItems: 'center', fontSize: '0.9rem' }}>
|
||||
<strong style={{ minWidth: 100 }}>{b.name}</strong>
|
||||
<span style={{ fontFamily: 'monospace', opacity: 0.8 }}>{b.start_local_time.slice(0,5)} - {b.end_local_time.slice(0,5)}</span>
|
||||
<span className={`badge ${b.block_type === 'OFF_AIR' ? 'badge-warn' : 'badge-ok'}`}>{b.block_type}</span>
|
||||
{b.target_content_rating && <span className="badge badge-type">Rating Tier: {b.target_content_rating}</span>}
|
||||
<div style={{ flex: 1 }} />
|
||||
<IconBtn icon="✕" kind="danger" onClick={async () => {
|
||||
try {
|
||||
await deleteTemplateBlock(b.id);
|
||||
setTemplateBlocks(tb => ({ ...tb, [t.id]: tb[t.id].filter(x => x.id !== b.id) }));
|
||||
ok('Block deleted.');
|
||||
} catch { err('Failed to delete block.'); }
|
||||
}} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form className="assign-form" style={{ background: '#2f3640' }} onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
if (!blockForm.name || !blockForm.start_local_time || !blockForm.end_local_time) { err('Fill req fields'); return; }
|
||||
try {
|
||||
const nb = await createTemplateBlock({
|
||||
schedule_template_id: t.id,
|
||||
name: blockForm.name,
|
||||
block_type: blockForm.block_type,
|
||||
start_local_time: blockForm.start_local_time,
|
||||
end_local_time: blockForm.end_local_time,
|
||||
day_of_week_mask: parseInt(blockForm.day_of_week_mask),
|
||||
target_content_rating: blockForm.target_content_rating ? parseInt(blockForm.target_content_rating) : null,
|
||||
});
|
||||
setTemplateBlocks(tb => ({ ...tb, [t.id]: [...(tb[t.id] || []), nb] }));
|
||||
ok('Block created.');
|
||||
} catch { err('Failed to create block'); }
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<input placeholder="Block Name (e.g. Morning News)" required style={{ flex: 1 }} value={blockForm.name} onChange={e => setBlockForm(f => ({...f, name: e.target.value}))} />
|
||||
<select value={blockForm.block_type} onChange={e => setBlockForm(f => ({...f, block_type: e.target.value}))}>
|
||||
<option value="PROGRAM">Programming</option>
|
||||
<option value="OFF_AIR">Off Air / Dead Time</option>
|
||||
</select>
|
||||
<input type="time" required value={blockForm.start_local_time} onChange={e => setBlockForm(f => ({...f, start_local_time: e.target.value}))} />
|
||||
<span style={{ opacity: 0.5 }}>to</span>
|
||||
<input type="time" required value={blockForm.end_local_time} onChange={e => setBlockForm(f => ({...f, end_local_time: e.target.value}))} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginTop: '0.5rem' }}>
|
||||
<select value={blockForm.target_content_rating} onChange={e => setBlockForm(f => ({...f, target_content_rating: e.target.value}))}>
|
||||
<option value="">Any content rating</option>
|
||||
<option value="1">TV-Y</option>
|
||||
<option value="2">TV-Y7</option>
|
||||
<option value="3">TV-G</option>
|
||||
<option value="4">TV-PG</option>
|
||||
<option value="5">TV-14</option>
|
||||
<option value="6">TV-MA</option>
|
||||
</select>
|
||||
<button type="submit" className="btn-sync sm">+ Add Block</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<IconBtn icon="🗑" kind="danger" onClick={() => handleDelete(t)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user