diff --git a/app/app.py b/app/app.py index a3d3044..3fddf03 100644 --- a/app/app.py +++ b/app/app.py @@ -30,7 +30,7 @@ def create_app(): browser_service.check_chrome_installation() # Initialize and register routes (pass config to browser routes for test endpoint) - browser_bp = init_browser_routes(browser_service, download_service, config) + browser_bp = init_browser_routes(browser_service, download_service, config, scheduler) download_bp = init_download_routes(download_service, config.DOWNLOAD_DIR, scheduler) scheduler_bp = init_scheduler_routes(scheduler) events_bp = init_events_routes(browser_service, download_service) diff --git a/app/routes/browser_routes.py b/app/routes/browser_routes.py index a8b15cd..b7ca677 100644 --- a/app/routes/browser_routes.py +++ b/app/routes/browser_routes.py @@ -11,7 +11,7 @@ browser_bp = Blueprint('browser', __name__, url_prefix='/api/browser') -def init_browser_routes(browser_service, download_service, config): +def init_browser_routes(browser_service, download_service, config, scheduler=None): """Initialize browser routes with services""" @browser_bp.route('/start', methods=['POST']) @@ -34,6 +34,12 @@ def start_browser(): # Generate browser ID browser_id = f"browser_{int(time.time())}" + # Block scheduled checks BEFORE starting so the browser service + # queue processor drops in-flight scheduled launches immediately + if scheduler: + scheduler.pause_all_for_manual(browser_id) + browser_service.set_manual_active(True) + # Start browser success, detector = browser_service.start_browser( url, browser_id, resolution, framerate, auto_download, filename, output_format @@ -47,6 +53,10 @@ def start_browser(): 'vnc_url': f'/vnc' }) else: + # Roll back manual lock on failure + if scheduler: + scheduler.resume_after_manual(browser_id) + browser_service.set_manual_active(False) return jsonify({'error': 'Failed to start browser'}), 500 except Exception as e: @@ -86,6 +96,9 @@ def close_browser(browser_id): """Close browser manually""" try: if browser_service.close_browser(browser_id): + browser_service.set_manual_active(False) + if scheduler: + scheduler.resume_after_manual(browser_id) return jsonify({'success': True, 'message': 'Browser closed'}) else: return jsonify({'error': 'Browser not found'}), 404 diff --git a/app/routes/download_routes.py b/app/routes/download_routes.py index daa8f1d..fe58f5b 100644 --- a/app/routes/download_routes.py +++ b/app/routes/download_routes.py @@ -144,9 +144,16 @@ def download_direct(): default_filename = f"{timestamp_str}.mp4" filename = data.get('filename', default_filename) + browser_id = f"direct_{timestamp}" + + # Block scheduled checks before starting the download + if scheduler: + scheduler.pause_all_for_manual(browser_id) + scheduler.browser_service.set_manual_active(True) + # Start download - browser_id, output_path = download_service.start_direct_download( - f"direct_{timestamp}", + _, output_path = download_service.start_direct_download( + browser_id, stream_url, filename ) @@ -241,10 +248,13 @@ def stop_download(browser_id): """Stop an active download""" try: if download_service.stop_download(browser_id): - # If this is a scheduled download, move it to the next time slot - if scheduler and browser_id.startswith('sched_'): - scheduler.move_to_next_slot(browser_id) - logger.info(f"Moved scheduled download {browser_id} to next time slot") + if scheduler: + if browser_id.startswith('sched_'): + scheduler.move_to_next_slot(browser_id) + logger.info(f"Moved scheduled download {browser_id} to next time slot") + else: + scheduler.browser_service.set_manual_active(False) + scheduler.resume_after_manual(browser_id) return jsonify({'success': True, 'message': 'Download stopped'}) else: diff --git a/app/routes/scheduler_routes.py b/app/routes/scheduler_routes.py index 1bc83b2..3bd8ab9 100644 --- a/app/routes/scheduler_routes.py +++ b/app/routes/scheduler_routes.py @@ -32,11 +32,12 @@ def add_schedule(): resolution = data.get('resolution', '1080p') framerate = data.get('framerate', 'any') format = data.get('format', 'mp4') + timezone = data.get('timezone') if not all([url, start_time, end_time]): return jsonify({'error': 'Missing required fields'}), 400 - schedule = scheduler.add_schedule(url, start_time, end_time, repeat, daily, name, resolution, framerate, format) + schedule = scheduler.add_schedule(url, start_time, end_time, repeat, daily, name, resolution, framerate, format, timezone) return jsonify({ 'success': True, @@ -74,13 +75,14 @@ def update_schedule(schedule_id): resolution = data.get('resolution', '1080p') framerate = data.get('framerate', 'any') format = data.get('format', 'mp4') + timezone = data.get('timezone') if not all([url, start_time, end_time]): return jsonify({'error': 'Missing required fields'}), 400 updated = scheduler.update_schedule( schedule_id, url, start_time, end_time, - repeat, daily, name, resolution, framerate, format + repeat, daily, name, resolution, framerate, format, timezone ) if updated: diff --git a/app/scheduler.py b/app/scheduler.py index e166b26..35fbd3c 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -5,7 +5,8 @@ import logging import threading import random -from datetime import datetime, timedelta +from datetime import datetime, timedelta, time as dtime +from zoneinfo import ZoneInfo logger = logging.getLogger(__name__) @@ -30,6 +31,7 @@ def __init__(self, config, browser_service): self.thread = None self.lock = threading.Lock() self._dirty = False # True when in-memory schedules differ from disk + self._auto_paused = {} # browser_id -> [schedule_ids paused for that manual session] self.load_schedules() @@ -43,6 +45,9 @@ def load_schedules(self): # Ensure all schedules have next_check calculated for schedule in self.schedules: + # Clear in-memory-only auto_paused flag that may have been + # left over if a previous session ended without calling close + schedule.pop('auto_paused', None) if not schedule.get('next_check'): self._update_next_check(schedule) @@ -67,7 +72,55 @@ def _mark_dirty(self): """Mark schedules as needing a save.""" self._dirty = True - def add_schedule(self, url, start_time, end_time, repeat=False, daily=False, name=None, resolution='1080p', framerate='any', format='mp4'): + # ── Timezone helpers ────────────────────────────────────────── + + def _get_tz(self, schedule): + """Return a ZoneInfo for the schedule's timezone, or None for naive.""" + tz_name = schedule.get('timezone') if schedule else None + if tz_name: + try: + return ZoneInfo(tz_name) + except Exception: + pass + return None + + def _now(self, schedule=None): + """Return datetime.now(), timezone-aware when the schedule carries one.""" + tz = self._get_tz(schedule) + return datetime.now(tz) if tz else datetime.now() + + def _parse_dt(self, dt_str, tz): + """Parse an ISO datetime string and attach timezone if provided. + If the string already carries a UTC offset (e.g. from _store_dt), return it + aware so Python can compare two aware datetimes directly. When the schedule + has no timezone and we would otherwise compare aware vs naive, strip the tz + so both sides stay naive-UTC.""" + dt = datetime.fromisoformat(dt_str) + if dt.tzinfo is None: + if tz: + dt = dt.replace(tzinfo=tz) + # else: remains naive UTC — matches _now() for no-tz schedules + elif tz is None: + # Stored with Z (UTC-aware) but schedule has no timezone — strip so + # comparison with naive _now() doesn't raise TypeError + dt = dt.replace(tzinfo=None) + return dt + + def _strip_tz(self, dt): + """Strip timezone for storage (timezone is persisted separately).""" + return dt.replace(tzinfo=None) if dt.tzinfo else dt + + def _store_dt(self, dt): + """Serialize a datetime to UTC ISO with Z suffix for next_check storage. + JavaScript Date() correctly converts UTC+Z to the browser's local timezone.""" + if dt.tzinfo is not None: + return dt.astimezone(ZoneInfo('UTC')).strftime('%Y-%m-%dT%H:%M:%SZ') + # Naive datetime — container runs UTC, so treat as UTC + return dt.isoformat() + 'Z' + + # ── CRUD ────────────────────────────────────────────────────── + + def add_schedule(self, url, start_time, end_time, repeat=False, daily=False, name=None, resolution='1080p', framerate='any', format='mp4', timezone=None): """Add a new schedule""" with self.lock: schedule = { @@ -81,6 +134,7 @@ def add_schedule(self, url, start_time, end_time, repeat=False, daily=False, nam 'end_time': end_time, 'repeat': repeat, 'daily': daily, + 'timezone': timezone, 'status': 'pending', 'next_check': None, 'last_check': None, @@ -100,7 +154,7 @@ def remove_schedule(self, schedule_id): self.save_schedules() return True - def update_schedule(self, schedule_id, url, start_time, end_time, repeat=False, daily=False, name=None, resolution='1080p', framerate='any', format='mp4'): + def update_schedule(self, schedule_id, url, start_time, end_time, repeat=False, daily=False, name=None, resolution='1080p', framerate='any', format='mp4', timezone=None): """Update an existing schedule""" with self.lock: for schedule in self.schedules: @@ -114,6 +168,8 @@ def update_schedule(self, schedule_id, url, start_time, end_time, repeat=False, schedule['resolution'] = resolution schedule['framerate'] = framerate schedule['format'] = format + if timezone: + schedule['timezone'] = timezone schedule['status'] = 'pending' self._update_next_check(schedule) self._mark_dirty() @@ -151,10 +207,14 @@ def move_to_next_slot(self, browser_id): logger.info(f"Moving daily schedule {schedule_id} to next day") start_time_str = schedule['start_time'] start_hour, start_min = map(int, start_time_str.split(':')) - now = datetime.now() - tomorrow = now.date() + timedelta(days=1) - next_start = datetime.combine(tomorrow, datetime.min.time().replace(hour=start_hour, minute=start_min)) - schedule['next_check'] = next_start.isoformat() + tz = self._get_tz(schedule) + now_local = self._now(schedule) + tomorrow = now_local.date() + timedelta(days=1) + if tz: + next_start = datetime.combine(tomorrow, dtime(start_hour, start_min), tzinfo=tz) + else: + next_start = datetime.combine(tomorrow, dtime(start_hour, start_min)) + schedule['next_check'] = self._store_dt(next_start) logger.info(f"Daily schedule {schedule_id} moved to {next_start}") elif schedule.get('repeat'): logger.info(f"Moving weekly schedule {schedule_id} to next week") @@ -190,6 +250,34 @@ def pause_schedule(self, schedule_id): return schedule return None + def pause_all_for_manual(self, browser_id): + """Mark all active schedules as auto_paused (in memory only — never persisted). + Called BEFORE the manual browser starts so the scheduler stops queuing checks.""" + paused_ids = [] + with self.lock: + for schedule in self.schedules: + if not schedule.get('paused', False) and not schedule.get('auto_paused', False): + schedule['auto_paused'] = True + paused_ids.append(schedule['id']) + if paused_ids: + self._auto_paused[browser_id] = paused_ids + if paused_ids: + logger.info(f"Auto-paused {len(paused_ids)} schedule(s) for manual session {browser_id}") + + def resume_after_manual(self, browser_id): + """Lift auto_paused flag for schedules held for a manual session.""" + ids_to_resume = self._auto_paused.pop(browser_id, []) + if not ids_to_resume: + return + with self.lock: + for schedule in self.schedules: + if schedule['id'] in ids_to_resume and schedule.get('auto_paused'): + del schedule['auto_paused'] + if not schedule.get('paused', False): + schedule['status'] = 'pending' + self._update_next_check(schedule) + logger.info(f"Auto-resumed {len(ids_to_resume)} schedule(s) after manual session {browser_id}") + def get_schedules(self): """Get all schedules — active schedules sorted by next_check, paused schedules at the bottom.""" sorted_schedules = sorted( @@ -259,12 +347,10 @@ def _run_loop(self): def _check_schedules(self): """Check all schedules and run tasks if needed""" - now = datetime.now() - with self.lock: for schedule in self.schedules: - # Skip paused schedules entirely - if schedule.get('paused', False): + # Skip paused schedules (user-paused or auto-paused for manual session) + if schedule.get('paused', False) or schedule.get('auto_paused', False): continue if schedule['status'] == 'completed' and not schedule.get('daily'): @@ -275,11 +361,15 @@ def _check_schedules(self): continue try: + # Compute now in the schedule's timezone (or naive if none stored) + tz = self._get_tz(schedule) + now = self._now(schedule) + if schedule.get('daily'): self._check_daily_schedule(schedule, now) else: - start_dt = datetime.fromisoformat(schedule['start_time']) - end_dt = datetime.fromisoformat(schedule['end_time']) + start_dt = self._parse_dt(schedule['start_time'], tz) + end_dt = self._parse_dt(schedule['end_time'], tz) if now > end_dt: if schedule['repeat']: @@ -300,12 +390,13 @@ def _check_schedules(self): logger.info(f"Download {active_browser_id} stopped for schedule {schedule['id']}, resuming stream checks") schedule['status'] = 'active' schedule['active_browser_id'] = None + schedule['next_check'] = None # re-check immediately on next tick if schedule['status'] != 'active': next_check_val = schedule.get('next_check') if next_check_val: try: - next_check_dt = datetime.fromisoformat(next_check_val) + next_check_dt = self._parse_dt(next_check_val, tz) if next_check_dt <= end_dt: schedule['status'] = 'active' except (ValueError, TypeError): @@ -314,7 +405,7 @@ def _check_schedules(self): schedule['status'] = 'active' next_check = schedule.get('next_check') - if not next_check or now >= datetime.fromisoformat(next_check): + if not next_check or now >= self._parse_dt(next_check, tz): self._perform_check(schedule) elif now < start_dt: @@ -324,8 +415,7 @@ def _check_schedules(self): self._update_next_check(schedule) else: try: - next_check_dt = datetime.fromisoformat(next_check) - if next_check_dt < now: + if self._parse_dt(next_check, tz) < now: self._update_next_check(schedule) except (ValueError, TypeError): self._update_next_check(schedule) @@ -347,21 +437,25 @@ def _check_daily_schedule(self, schedule, now): """ start_time_str = schedule['start_time'] end_time_str = schedule['end_time'] + tz = self._get_tz(schedule) today = now.date() start_hour, start_min = map(int, start_time_str.split(':')) end_hour, end_min = map(int, end_time_str.split(':')) - start_dt = datetime.combine(today, datetime.min.time().replace(hour=start_hour, minute=start_min)) - end_dt = datetime.combine(today, datetime.min.time().replace(hour=end_hour, minute=end_min)) + def _combine(d, h, m): + return datetime.combine(d, dtime(h, m), tzinfo=tz) if tz else datetime.combine(d, dtime(h, m)) + + start_dt = _combine(today, start_hour, start_min) + end_dt = _combine(today, end_hour, end_min) spans_midnight = end_hour < start_hour or (end_hour == start_hour and end_min < start_min) if spans_midnight: - if now.time() < datetime.min.time().replace(hour=start_hour, minute=start_min): + if now.time().replace(tzinfo=None) < dtime(start_hour, start_min): yesterday = today - timedelta(days=1) - start_dt = datetime.combine(yesterday, datetime.min.time().replace(hour=start_hour, minute=start_min)) - end_dt = datetime.combine(today, datetime.min.time().replace(hour=end_hour, minute=end_min)) + start_dt = _combine(yesterday, start_hour, start_min) + end_dt = _combine(today, end_hour, end_min) else: end_dt = end_dt + timedelta(days=1) @@ -374,12 +468,13 @@ def _check_daily_schedule(self, schedule, now): logger.info(f"Download {active_browser_id} stopped for schedule {schedule['id']}, resuming stream checks") schedule['status'] = 'active' schedule['active_browser_id'] = None + schedule['next_check'] = None # re-check immediately on next tick if schedule['status'] != 'active': next_check_val = schedule.get('next_check') if next_check_val: try: - next_check_dt = datetime.fromisoformat(next_check_val) + next_check_dt = self._parse_dt(next_check_val, tz) if next_check_dt <= end_dt: schedule['status'] = 'active' except (ValueError, TypeError): @@ -388,7 +483,7 @@ def _check_daily_schedule(self, schedule, now): schedule['status'] = 'active' next_check = schedule.get('next_check') - if not next_check or now >= datetime.fromisoformat(next_check): + if not next_check or now >= self._parse_dt(next_check, tz): self._perform_check(schedule) elif now < start_dt: @@ -398,8 +493,7 @@ def _check_daily_schedule(self, schedule, now): self._update_next_check(schedule) else: try: - next_check_dt = datetime.fromisoformat(next_check) - if next_check_dt < now: + if self._parse_dt(next_check, tz) < now: self._update_next_check(schedule) except (ValueError, TypeError): self._update_next_check(schedule) @@ -422,7 +516,11 @@ def _update_next_check(self, schedule): - After window: Schedule for next occurrence (daily/weekly) - Handles midnight-spanning windows correctly """ - now = datetime.now() + tz = self._get_tz(schedule) + now = self._now(schedule) + + def _store(dt): + return self._store_dt(dt) if schedule.get('daily'): start_time_str = schedule['start_time'] @@ -432,48 +530,50 @@ def _update_next_check(self, schedule): start_hour, start_min = map(int, start_time_str.split(':')) end_hour, end_min = map(int, end_time_str.split(':')) - start_dt = datetime.combine(today, datetime.min.time().replace(hour=start_hour, minute=start_min)) - end_dt = datetime.combine(today, datetime.min.time().replace(hour=end_hour, minute=end_min)) + def _combine(d, h, m): + return datetime.combine(d, dtime(h, m), tzinfo=tz) if tz else datetime.combine(d, dtime(h, m)) + + start_dt = _combine(today, start_hour, start_min) + end_dt = _combine(today, end_hour, end_min) spans_midnight = end_hour < start_hour or (end_hour == start_hour and end_min < start_min) if spans_midnight: - if now.time() < datetime.min.time().replace(hour=start_hour, minute=start_min): + if now.time().replace(tzinfo=None) < dtime(start_hour, start_min): yesterday = today - timedelta(days=1) - start_dt = datetime.combine(yesterday, datetime.min.time().replace(hour=start_hour, minute=start_min)) - end_dt = datetime.combine(today, datetime.min.time().replace(hour=end_hour, minute=end_min)) + start_dt = _combine(yesterday, start_hour, start_min) + end_dt = _combine(today, end_hour, end_min) else: end_dt = end_dt + timedelta(days=1) if now < start_dt: - schedule['next_check'] = start_dt.isoformat() + schedule['next_check'] = _store(start_dt) logger.debug(f"Schedule {schedule['id']}: next check set to window start: {start_dt}") elif start_dt <= now <= end_dt: minutes = random.uniform(5, 8) next_dt = min(now + timedelta(minutes=minutes), end_dt) - schedule['next_check'] = next_dt.isoformat() + schedule['next_check'] = _store(next_dt) logger.debug(f"Schedule {schedule['id']}: next check in {minutes:.1f} mins: {next_dt}") else: - if spans_midnight and now.time() < datetime.min.time().replace(hour=start_hour, minute=start_min): - next_start = datetime.combine(today, datetime.min.time().replace(hour=start_hour, minute=start_min)) + if spans_midnight and now.time().replace(tzinfo=None) < dtime(start_hour, start_min): + next_start = _combine(today, start_hour, start_min) else: - tomorrow = today + timedelta(days=1) - next_start = datetime.combine(tomorrow, datetime.min.time().replace(hour=start_hour, minute=start_min)) + next_start = _combine(today + timedelta(days=1), start_hour, start_min) - schedule['next_check'] = next_start.isoformat() + schedule['next_check'] = _store(next_start) logger.debug(f"Schedule {schedule['id']}: next check set to next window start: {next_start}") else: - start_dt = datetime.fromisoformat(schedule['start_time']) - end_dt = datetime.fromisoformat(schedule['end_time']) + start_dt = self._parse_dt(schedule['start_time'], tz) + end_dt = self._parse_dt(schedule['end_time'], tz) if now < start_dt: - schedule['next_check'] = start_dt.isoformat() + schedule['next_check'] = _store(start_dt) logger.debug(f"Schedule {schedule['id']}: next check set to window start: {start_dt}") elif start_dt <= now <= end_dt: minutes = random.uniform(5, 8) next_dt = min(now + timedelta(minutes=minutes), end_dt) - schedule['next_check'] = next_dt.isoformat() + schedule['next_check'] = _store(next_dt) logger.debug(f"Schedule {schedule['id']}: next check in {minutes:.1f} mins: {next_dt}") else: schedule['next_check'] = None @@ -501,25 +601,25 @@ def _is_download_active(self, browser_id): Check if a specific download is still active. Returns True if the download exists in the queue and has no completed_at. + Uses get_download_status (which holds _queue_lock) to avoid a TOCTOU race. """ try: if not browser_id: return False - download_queue = self.browser_service.download_service.download_queue + dl_status = self.browser_service.download_service.get_download_status(browser_id) - if browser_id in download_queue: - download_info = download_queue[browser_id] - is_active = 'completed_at' not in download_info - if is_active: - logger.debug(f"Download {browser_id} is still active") - else: - logger.debug(f"Download {browser_id} has completed") - return is_active - else: + if dl_status is None: logger.debug(f"Download {browser_id} not found in queue") return False + is_active = not dl_status.get('completed', False) + if is_active: + logger.debug(f"Download {browser_id} is still active") + else: + logger.debug(f"Download {browser_id} has completed") + return is_active + except Exception as e: logger.error(f"Error checking download status for {browser_id}: {e}") return False @@ -597,11 +697,19 @@ def _run_browser_check_task(self, schedule, duration): time.sleep(1) - # If no download started, revert to 'active' so next window check can retry + # Loop exited without detecting a download in the polling window. + # Check download_queue directly — the download callback may have fired + # just before the browser was closed externally (e.g. by _ensure_chrome_closed + # launching the next queued browser), causing the poll loop to miss it. with self.lock: for s in self.schedules: if s['id'] == schedule['id'] and s.get('status') == 'checking': - s['status'] = 'active' + if self._is_download_active(browser_id): + logger.info(f"Download {browser_id} found after loop exit for schedule {schedule['id']}, marking as started") + s['status'] = 'download_started' + s['active_browser_id'] = browser_id + else: + s['status'] = 'active' self._mark_dirty() break @@ -610,11 +718,15 @@ def _run_browser_check_task(self, schedule, duration): except Exception as e: logger.error(f"Error in browser check task: {e}") - # Revert checking status on error with self.lock: for s in self.schedules: if s['id'] == schedule['id'] and s.get('status') == 'checking': - s['status'] = 'active' + if self._is_download_active(browser_id): + logger.info(f"Download {browser_id} found after error for schedule {schedule['id']}, marking as started") + s['status'] = 'download_started' + s['active_browser_id'] = browser_id + else: + s['status'] = 'active' self._mark_dirty() break try: diff --git a/app/services/browser_service.py b/app/services/browser_service.py index 98bd6a6..cc2f8e1 100644 --- a/app/services/browser_service.py +++ b/app/services/browser_service.py @@ -24,6 +24,8 @@ def __init__(self, config, download_service): self.queue_running = False self.queue_lock = threading.Lock() + self._manual_active = False # set True while a manual session is running + # Start queue processor self._start_queue_processor() @@ -79,6 +81,11 @@ def _start_queue_processor(self): self.queue_processor_thread.start() logger.info("Browser queue processor started") + def set_manual_active(self, active: bool): + """Signal whether a manual browser/download session is in progress.""" + self._manual_active = active + logger.info(f"Manual session {'started' if active else 'ended'}") + def _process_browser_queue(self): """Background thread that processes browser launch requests one by one""" logger.info("Browser queue processor thread running") @@ -93,6 +100,16 @@ def _process_browser_queue(self): browser_id = request['browser_id'] + # Drop queued scheduled launches while a manual session is active + if self._manual_active and browser_id.startswith('sched_'): + logger.info(f"Dropping queued scheduled launch {browser_id} — manual session active") + with self.queue_lock: + self.active_browsers.pop(browser_id, None) + request['result']['success'] = False + request['completion_event'].set() + self.browser_queue.task_done() + continue + # Update status from 'queued' to 'launching' with self.queue_lock: if browser_id in self.active_browsers and isinstance(self.active_browsers[browser_id], dict): diff --git a/app/static/css/style.css b/app/static/css/style.css index ed3bea6..8ad7da4 100644 --- a/app/static/css/style.css +++ b/app/static/css/style.css @@ -575,3 +575,138 @@ select option { .stream-download-btn:active { background: #5664b2; } + +/* ── Time Picker ──────────────────────────────────────────── */ +.tp-widget { + position: relative; + display: inline-block; + width: 100%; +} + +.tp-display { + width: 100%; + padding: 12px; + background: #1e1e30; + border: 2px solid #4a4a6a; + border-radius: 8px; + color: #e0e0e0; + font-size: 1.15rem; + font-weight: 700; + letter-spacing: 2px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 1px; + transition: border-color 0.3s; +} + +.tp-display:hover, +.tp-display:focus { + outline: none; + border-color: #7e8ce0; +} + +.tp-sep { + color: #7e8ce0; + font-size: 1.3rem; +} + +.tp-popover { + position: fixed; + background: #2d2d44; + border: 1px solid #4a4a6a; + border-radius: 12px; + padding: 14px 16px 16px; + z-index: 99999; + box-shadow: 0 12px 40px rgba(0,0,0,0.7); + min-width: 252px; + max-width: calc(100vw - 16px); + box-sizing: border-box; +} + +.tp-digits { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + margin-bottom: 10px; +} + +.tp-d { + width: 42px; + height: 50px; + background: #1e1e30; + border: 2px solid #4a4a6a; + border-radius: 8px; + color: #e0e0e0; + font-size: 1.4rem; + font-weight: 700; + text-align: center; + transition: border-color 0.2s; + caret-color: transparent; +} + +.tp-d:focus { + outline: none; + border-color: #7e8ce0; +} + +.tp-dsep { + color: #7e8ce0; + font-size: 1.6rem; + font-weight: 700; + line-height: 1; + margin: 0 2px; + user-select: none; +} + +.tp-modes { + display: flex; + gap: 6px; + margin-bottom: 10px; +} + +.tp-mode-btn { + flex: 1; + padding: 5px 0; + background: #1e1e30; + border: 1px solid #4a4a6a; + border-radius: 6px; + color: #b8b8d1; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 1px; + cursor: pointer; + transition: background 0.2s, color 0.2s, border-color 0.2s; +} + +.tp-mode-btn.active { + background: #7e8ce0; + border-color: #7e8ce0; + color: #fff; +} + +.tp-clock { + display: flex; + justify-content: center; +} + +/* Date + time side-by-side */ +.datetime-picker-group { + display: flex; + gap: 8px; + align-items: stretch; +} + +.datetime-picker-group .date-input { + flex: 1; + min-width: 0; + width: auto; + color-scheme: dark; +} + +.datetime-picker-group .tp-widget { + width: auto; + flex-shrink: 0; +} diff --git a/app/static/js/init.js b/app/static/js/init.js index 8403f9c..3e85f3c 100644 --- a/app/static/js/init.js +++ b/app/static/js/init.js @@ -1,22 +1,7 @@ // Initialization and event listeners document.addEventListener('DOMContentLoaded', () => { - // Set default schedule times to now and +1 hour - const now = new Date(); - const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000); - - // Format as YYYY-MM-DDTHH:MM for datetime-local input - const formatDateTime = (date) => { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - return `${year}-${month}-${day}T${hours}:${minutes}`; - }; - - document.getElementById('sched-start').value = formatDateTime(now); - document.getElementById('sched-end').value = formatDateTime(oneHourLater); + initSchedulePickers(); loadDownloads(); loadSchedules(); diff --git a/app/static/js/schedules.js b/app/static/js/schedules.js index 96bd16b..bf44d71 100644 --- a/app/static/js/schedules.js +++ b/app/static/js/schedules.js @@ -1,5 +1,55 @@ // Schedule management functions +// Module-level TimePicker instances (initialised by initSchedulePickers) +let schedStartTP, schedEndTP, editStartTP, editEndTP; + +function initSchedulePickers() { + const now = new Date(); + const later = new Date(now.getTime() + 3600000); + + schedStartTP = new TimePicker('sched-start-time', { initialValue: _fmtTime(now) }); + schedEndTP = new TimePicker('sched-end-time', { initialValue: _fmtTime(later) }); + editStartTP = new TimePicker('edit-sched-start-time', { initialValue: '00:00' }); + editEndTP = new TimePicker('edit-sched-end-time', { initialValue: '01:00' }); + + document.getElementById('sched-start-date').value = _fmtDate(now); + document.getElementById('sched-end-date').value = _fmtDate(later); +} + +// ── Helpers ────────────────────────────────────────────────── + +function _fmtDate(d) { + return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; +} + +function _fmtTime(d) { + return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; +} + +function _getDatetime(dateId, tp) { + const date = document.getElementById(dateId).value; + const time = tp.getValue(); + return date && time ? `${date}T${time}` : ''; +} + +function _setDatetime(dateId, tp, iso) { + if (!iso) return; + if (iso.includes('T')) { + const [d, t] = iso.split('T'); + document.getElementById(dateId).value = d; + tp.setValue(t.substring(0, 5)); + } else { + tp.setValue(iso.substring(0, 5)); + } +} + +function _showDateInputs(groupId, visible) { + const el = document.getElementById(groupId)?.querySelector('.date-input'); + if (el) el.style.display = visible ? '' : 'none'; +} + +// ── Load / display ──────────────────────────────────────────── + async function loadSchedules() { try { const response = await fetch('/api/schedules/'); @@ -13,84 +63,46 @@ async function loadSchedules() { return; } - // Sort schedules: active first by next_check, paused always at bottom schedules.sort((a, b) => { if (a.paused && !b.paused) return 1; if (!a.paused && b.paused) return -1; - - // Helper function to get sortable timestamp - const getSortTime = (sched) => { - // If next_check exists, use it (it's already calculated by backend) - if (sched.next_check) { - return new Date(sched.next_check).getTime(); - } - - // Fallback to start_time for schedules without next_check - if (sched.daily) { - // Daily schedule - calculate next occurrence from time string + const getSortTime = (s) => { + if (s.next_check) return new Date(s.next_check).getTime(); + if (s.daily) { const now = new Date(); - const [hours, minutes] = sched.start_time.split(':').map(Number); - const nextRun = new Date(); - nextRun.setHours(hours, minutes, 0, 0); - - // If the time has passed today, schedule for tomorrow - if (nextRun <= now) { - nextRun.setDate(nextRun.getDate() + 1); - } - - return nextRun.getTime(); - } else { - // Regular schedule - use start_time directly - return new Date(sched.start_time).getTime(); + const [h, m] = s.start_time.split(':').map(Number); + const next = new Date(); next.setHours(h, m, 0, 0); + if (next <= now) next.setDate(next.getDate() + 1); + return next.getTime(); } + return new Date(s.start_time).getTime(); }; - - const timeA = getSortTime(a); - const timeB = getSortTime(b); - return timeA - timeB; + return getSortTime(a) - getSortTime(b); }); schedules.forEach(sched => { const item = document.createElement('div'); item.className = 'download-item'; - // Format dates as RFC 2822 without seconds (e.g., "Mon, 08 Dec 2025 14:03") const formatRFC2822 = (dateStr) => { const date = new Date(dateStr); - const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - - const day = days[date.getDay()]; - const dateNum = String(date.getDate()).padStart(2, '0'); - const month = months[date.getMonth()]; - const year = date.getFullYear(); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - - return `${day}, ${dateNum} ${month} ${year} ${hours}:${minutes}`; + const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; + const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + return `${days[date.getDay()]}, ${String(date.getDate()).padStart(2,'0')} ${months[date.getMonth()]} ${date.getFullYear()} ${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`; }; - let windowText = ''; - let repeatText = ''; - + let windowText = '', repeatText = ''; if (sched.daily) { - // Daily schedule - just show times - windowText = `${sched.start_time} - ${sched.end_time} (Daily)`; + windowText = `${sched.start_time} – ${sched.end_time} (Daily)`; repeatText = 'Daily'; } else { - // Regular schedule - show full datetime - const start = formatRFC2822(sched.start_time); - const end = formatRFC2822(sched.end_time); - windowText = `${start} - ${end}`; + windowText = `${formatRFC2822(sched.start_time)} – ${formatRFC2822(sched.end_time)}`; repeatText = sched.repeat ? 'Weekly' : 'Once'; } - let statusColor = '#666'; - let statusText = sched.status; - + let statusColor = '#666', statusText = sched.status; if (sched.paused) { - statusColor = '#888'; - statusText = 'paused'; + statusColor = '#888'; statusText = 'paused'; } else if (sched.status === 'active') { statusColor = '#28a745'; const nextCheck = sched.next_check ? formatRFC2822(sched.next_check) : 'Pending window'; @@ -98,146 +110,98 @@ async function loadSchedules() { } else if (sched.status === 'download_started') { statusColor = '#17a2b8'; statusText = 'Download started (Recording in progress)'; - } else if (sched.status === 'pending') { + } else if (sched.status === 'pending' && sched.next_check) { statusColor = '#666'; - if (sched.next_check) { - const nextCheck = formatRFC2822(sched.next_check); - statusText = `${sched.status} (Next check: ${nextCheck})`; - } else { - statusText = sched.status; - } - } else { - statusText = sched.status; + statusText = `${sched.status} (Next check: ${formatRFC2822(sched.next_check)})`; } item.innerHTML = ` -
-
-

${sched.url}

+
+
+

${sched.url}

Window: ${windowText}

Repeat: ${repeatText}

-

Status: ${statusText}

+

Status: ${statusText}

-
- - - +
+ + +
-
- `; +
`; container.appendChild(item); }); - } catch (error) { console.error('Error loading schedules:', error); } } +// ── Toggle daily mode ───────────────────────────────────────── + function toggleDailySchedule() { const daily = document.getElementById('sched-daily').checked; - const startInput = document.getElementById('sched-start'); - const endInput = document.getElementById('sched-end'); - const startLabel = document.getElementById('sched-start-label'); - const endLabel = document.getElementById('sched-end-label'); const repeatContainer = document.getElementById('sched-repeat-container'); - const repeatCheckbox = document.getElementById('sched-repeat'); - - if (daily) { - // Hide weekly repeat option (daily already repeats) - repeatContainer.style.display = 'none'; - repeatCheckbox.checked = false; - - // Convert to time inputs - startInput.type = 'time'; - endInput.type = 'time'; - startLabel.textContent = 'Start Time (Daily)'; - endLabel.textContent = 'End Time (Daily)'; - - // Set default time values if empty - if (!startInput.value) { - const now = new Date(); - startInput.value = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; - } - if (!endInput.value) { - const oneHourLater = new Date(new Date().getTime() + 60 * 60 * 1000); - endInput.value = `${String(oneHourLater.getHours()).padStart(2, '0')}:${String(oneHourLater.getMinutes()).padStart(2, '0')}`; - } - } else { - // Show weekly repeat option - repeatContainer.style.display = 'block'; - - // Convert to datetime-local inputs - startInput.type = 'datetime-local'; - endInput.type = 'datetime-local'; - startLabel.textContent = 'Start Time'; - endLabel.textContent = 'End Time'; - - // Set default datetime values - const now = new Date(); - const oneHourLater = new Date(now.getTime() + 60 * 60 * 1000); - const formatDateTime = (date) => { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const hours = String(date.getHours()).padStart(2, '0'); - const minutes = String(date.getMinutes()).padStart(2, '0'); - return `${year}-${month}-${day}T${hours}:${minutes}`; - }; - startInput.value = formatDateTime(now); - endInput.value = formatDateTime(oneHourLater); - } + document.getElementById('sched-repeat').checked = false; + + document.getElementById('sched-start-label').textContent = daily ? 'Start Time (Daily)' : 'Start Time'; + document.getElementById('sched-end-label').textContent = daily ? 'End Time (Daily)' : 'End Time'; + + _showDateInputs('sched-start-group', !daily); + _showDateInputs('sched-end-group', !daily); + repeatContainer.style.display = daily ? 'none' : 'block'; } +function toggleEditDailySchedule() { + const daily = document.getElementById('edit-sched-daily').checked; + document.getElementById('edit-sched-repeat').checked = false; + + document.getElementById('edit-sched-start-label').textContent = daily ? 'Start Time (Daily)' : 'Start Time'; + document.getElementById('edit-sched-end-label').textContent = daily ? 'End Time (Daily)' : 'End Time'; + + _showDateInputs('edit-sched-start-group', !daily); + _showDateInputs('edit-sched-end-group', !daily); + document.getElementById('edit-sched-repeat-container').style.display = daily ? 'none' : 'block'; +} + +// ── Add schedule ────────────────────────────────────────────── + async function addSchedule() { - let url = document.getElementById('sched-url').value.trim(); + let url = document.getElementById('sched-url').value.trim(); const name = document.getElementById('sched-name').value.trim(); - const start = document.getElementById('sched-start').value; - const end = document.getElementById('sched-end').value; - const repeat = document.getElementById('sched-repeat').checked; - const daily = document.getElementById('sched-daily').checked; + const repeat = document.getElementById('sched-repeat').checked; + const daily = document.getElementById('sched-daily').checked; const resolution = document.getElementById('sched-resolution').value; - const framerate = document.getElementById('sched-framerate').value; - const format = document.getElementById('sched-format').value; - const statusBox = document.getElementById('sched-status'); + const framerate = document.getElementById('sched-framerate').value; + const format = document.getElementById('sched-format').value; + const statusBox = document.getElementById('sched-status'); + + const start = daily ? schedStartTP.getValue() : _getDatetime('sched-start-date', schedStartTP); + const end = daily ? schedEndTP.getValue() : _getDatetime('sched-end-date', schedEndTP); if (!url || !start || !end) { showStatus(statusBox, 'Please fill all fields', 'error'); return; } - // Add https:// if no protocol specified - if (!url.startsWith('http://') && !url.startsWith('https://')) { - url = 'https://' + url; - } + if (!url.startsWith('http://') && !url.startsWith('https://')) url = 'https://' + url; - // Prepare request body const requestBody = { - url: url, - start_time: start, - end_time: end, - repeat: repeat, - daily: daily, - resolution: resolution, - framerate: framerate, - format: format + url, start_time: start, end_time: end, + repeat, daily, resolution, framerate, format, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, }; - - // Only include name if it's not empty - if (name) { - requestBody.name = name; - } + if (name) requestBody.name = name; try { const response = await fetch('/api/schedules/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody) + body: JSON.stringify(requestBody), }); - const data = await response.json(); if (data.success) { showStatus(statusBox, 'Schedule added!', 'success'); - document.getElementById('sched-url').value = ''; + document.getElementById('sched-url').value = ''; document.getElementById('sched-name').value = ''; loadSchedules(); } else { @@ -248,228 +212,103 @@ async function addSchedule() { } } +// ── Pause / delete / refresh ────────────────────────────────── + async function pauseSchedule(id, btn) { try { btn.disabled = true; const response = await fetch(`/api/schedules/${id}/pause`, { method: 'POST' }); const data = await response.json(); - - if (data.success) { - loadSchedules(); - } else { - alert('Error: ' + data.error); - btn.disabled = false; - } - } catch (error) { - console.error(error); - btn.disabled = false; - } + if (data.success) { loadSchedules(); } else { alert('Error: ' + data.error); btn.disabled = false; } + } catch (error) { console.error(error); btn.disabled = false; } } async function deleteSchedule(id, btn) { if (!confirm('Delete this schedule?')) return; - try { btn.disabled = true; const response = await fetch(`/api/schedules/${id}`, { method: 'DELETE' }); const data = await response.json(); - - if (data.success) { - loadSchedules(); - } else { - alert('Error: ' + data.error); - btn.disabled = false; - } - } catch (error) { - console.error(error); - btn.disabled = false; - } + if (data.success) { loadSchedules(); } else { alert('Error: ' + data.error); btn.disabled = false; } + } catch (error) { console.error(error); btn.disabled = false; } } async function refreshScheduleTimes() { try { const response = await fetch('/api/schedules/refresh', { method: 'POST' }); const data = await response.json(); - - if (data.success) { - alert(`✓ Refreshed ${data.count} schedule(s)`); - loadSchedules(); - } else { - alert('Error: ' + data.error); - } - } catch (error) { - console.error(error); - alert('Error refreshing schedules: ' + error.message); - } + if (data.success) { alert(`✓ Refreshed ${data.count} schedule(s)`); loadSchedules(); } + else { alert('Error: ' + data.error); } + } catch (error) { alert('Error refreshing schedules: ' + error.message); } } +// ── Edit schedule modal ─────────────────────────────────────── function editSchedule(schedule) { - // Store the schedule ID AppState.currentEditScheduleId = schedule.id; - // Populate the form fields - document.getElementById('edit-sched-url').value = schedule.url; - document.getElementById('edit-sched-name').value = schedule.name || ''; + document.getElementById('edit-sched-url').value = schedule.url; + document.getElementById('edit-sched-name').value = schedule.name || ''; document.getElementById('edit-sched-resolution').value = schedule.resolution || 'source'; - document.getElementById('edit-sched-framerate').value = schedule.framerate || 'any'; - document.getElementById('edit-sched-format').value = schedule.format || 'mp4'; - document.getElementById('edit-sched-repeat').checked = schedule.repeat || false; - document.getElementById('edit-sched-daily').checked = schedule.daily || false; - - // Set up time inputs based on whether it's a daily schedule - const startInput = document.getElementById('edit-sched-start'); - const endInput = document.getElementById('edit-sched-end'); - const startLabel = document.getElementById('edit-sched-start-label'); - const endLabel = document.getElementById('edit-sched-end-label'); - const repeatContainer = document.getElementById('edit-sched-repeat-container'); - - if (schedule.daily) { - // Daily schedule - use time inputs and hide weekly repeat - startInput.type = 'time'; - endInput.type = 'time'; - startLabel.textContent = 'Start Time (Daily)'; - endLabel.textContent = 'End Time (Daily)'; - startInput.value = schedule.start_time; - endInput.value = schedule.end_time; - repeatContainer.style.display = 'none'; - } else { - // Regular schedule - use datetime-local inputs and show weekly repeat - startInput.type = 'datetime-local'; - endInput.type = 'datetime-local'; - startLabel.textContent = 'Start Time'; - endLabel.textContent = 'End Time'; - startInput.value = schedule.start_time; - endInput.value = schedule.end_time; - repeatContainer.style.display = 'block'; - } - - // Clear status - const statusBox = document.getElementById('edit-sched-status'); - statusBox.classList.remove('active', 'success', 'error'); - - // Show modal + document.getElementById('edit-sched-framerate').value = schedule.framerate || 'any'; + document.getElementById('edit-sched-format').value = schedule.format || 'mp4'; + document.getElementById('edit-sched-repeat').checked = schedule.repeat || false; + document.getElementById('edit-sched-daily').checked = schedule.daily || false; + + const daily = schedule.daily || false; + document.getElementById('edit-sched-start-label').textContent = daily ? 'Start Time (Daily)' : 'Start Time'; + document.getElementById('edit-sched-end-label').textContent = daily ? 'End Time (Daily)' : 'End Time'; + _showDateInputs('edit-sched-start-group', !daily); + _showDateInputs('edit-sched-end-group', !daily); + document.getElementById('edit-sched-repeat-container').style.display = daily ? 'none' : 'block'; + + _setDatetime('edit-sched-start-date', editStartTP, schedule.start_time); + _setDatetime('edit-sched-end-date', editEndTP, schedule.end_time); + + document.getElementById('edit-sched-status').classList.remove('active', 'success', 'error'); document.getElementById('edit-schedule-modal').classList.add('active'); } -function toggleEditDailySchedule() { - const daily = document.getElementById('edit-sched-daily').checked; - const startInput = document.getElementById('edit-sched-start'); - const endInput = document.getElementById('edit-sched-end'); - const startLabel = document.getElementById('edit-sched-start-label'); - const endLabel = document.getElementById('edit-sched-end-label'); - const repeatContainer = document.getElementById('edit-sched-repeat-container'); - const repeatCheckbox = document.getElementById('edit-sched-repeat'); - - if (daily) { - // Hide weekly repeat option (daily already repeats) - repeatContainer.style.display = 'none'; - repeatCheckbox.checked = false; - - // Convert to time inputs - startInput.type = 'time'; - endInput.type = 'time'; - startLabel.textContent = 'Start Time (Daily)'; - endLabel.textContent = 'End Time (Daily)'; - - // If values are datetime, extract time part - if (startInput.value && startInput.value.includes('T')) { - startInput.value = startInput.value.split('T')[1]; - } - if (endInput.value && endInput.value.includes('T')) { - endInput.value = endInput.value.split('T')[1]; - } - } else { - // Show weekly repeat option - repeatContainer.style.display = 'block'; - - // Convert to datetime-local inputs - startInput.type = 'datetime-local'; - endInput.type = 'datetime-local'; - startLabel.textContent = 'Start Time'; - endLabel.textContent = 'End Time'; - - // If values are just times, convert to datetime - if (startInput.value && !startInput.value.includes('T')) { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, '0'); - const day = String(now.getDate()).padStart(2, '0'); - startInput.value = `${year}-${month}-${day}T${startInput.value}`; - } - if (endInput.value && !endInput.value.includes('T')) { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, '0'); - const day = String(now.getDate()).padStart(2, '0'); - endInput.value = `${year}-${month}-${day}T${endInput.value}`; - } - } -} - function closeEditScheduleModal() { document.getElementById('edit-schedule-modal').classList.remove('active'); AppState.currentEditScheduleId = null; } async function updateSchedule() { - if (!AppState.currentEditScheduleId) { - alert('Error: No schedule selected for editing'); - return; - } + if (!AppState.currentEditScheduleId) { alert('Error: No schedule selected for editing'); return; } - let url = document.getElementById('edit-sched-url').value.trim(); + let url = document.getElementById('edit-sched-url').value.trim(); const name = document.getElementById('edit-sched-name').value.trim(); - const start = document.getElementById('edit-sched-start').value; - const end = document.getElementById('edit-sched-end').value; - const repeat = document.getElementById('edit-sched-repeat').checked; - const daily = document.getElementById('edit-sched-daily').checked; + const repeat = document.getElementById('edit-sched-repeat').checked; + const daily = document.getElementById('edit-sched-daily').checked; const resolution = document.getElementById('edit-sched-resolution').value; - const framerate = document.getElementById('edit-sched-framerate').value; - const format = document.getElementById('edit-sched-format').value; - const statusBox = document.getElementById('edit-sched-status'); + const framerate = document.getElementById('edit-sched-framerate').value; + const format = document.getElementById('edit-sched-format').value; + const statusBox = document.getElementById('edit-sched-status'); - if (!url || !start || !end) { - showStatus(statusBox, 'Please fill all fields', 'error'); - return; - } + const start = daily ? editStartTP.getValue() : _getDatetime('edit-sched-start-date', editStartTP); + const end = daily ? editEndTP.getValue() : _getDatetime('edit-sched-end-date', editEndTP); - // Add https:// if no protocol specified - if (!url.startsWith('http://') && !url.startsWith('https://')) { - url = 'https://' + url; - } + if (!url || !start || !end) { showStatus(statusBox, 'Please fill all fields', 'error'); return; } + if (!url.startsWith('http://') && !url.startsWith('https://')) url = 'https://' + url; - // Prepare request body const requestBody = { - url: url, - start_time: start, - end_time: end, - repeat: repeat, - daily: daily, - resolution: resolution, - framerate: framerate, - format: format + url, start_time: start, end_time: end, + repeat, daily, resolution, framerate, format, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, }; - - // Only include name if it's not empty - if (name) { - requestBody.name = name; - } + if (name) requestBody.name = name; try { const response = await fetch(`/api/schedules/${AppState.currentEditScheduleId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(requestBody) + body: JSON.stringify(requestBody), }); - const data = await response.json(); if (data.success) { showStatus(statusBox, 'Schedule updated!', 'success'); - setTimeout(() => { - closeEditScheduleModal(); - loadSchedules(); - }, 1000); + setTimeout(() => { closeEditScheduleModal(); loadSchedules(); }, 1000); } else { showStatus(statusBox, 'Error: ' + data.error, 'error'); } @@ -477,5 +316,3 @@ async function updateSchedule() { showStatus(statusBox, 'Error: ' + error.message, 'error'); } } - -// Initialize diff --git a/app/static/js/timepicker.js b/app/static/js/timepicker.js new file mode 100644 index 0000000..51456cb --- /dev/null +++ b/app/static/js/timepicker.js @@ -0,0 +1,290 @@ +class TimePicker { + constructor(containerId, options = {}) { + this.containerId = containerId; + this.container = document.getElementById(containerId); + if (!this.container) return; + + this.hours = 0; + this.minutes = 0; + this.mode = 'hour'; + this.onChange = options.onChange || null; + this._pop = null; + this._outsideClick = this._onOutside.bind(this); + + if (options.initialValue) this._parse(options.initialValue); + this._build(); + } + + getValue() { + return `${String(this.hours).padStart(2,'0')}:${String(this.minutes).padStart(2,'0')}`; + } + + setValue(hhmm) { + this._parse(hhmm); + this._refreshBtn(); + } + + _parse(hhmm) { + if (!hhmm) return; + const [h, m] = hhmm.split(':').map(Number); + this.hours = isNaN(h) ? 0 : Math.min(23, Math.max(0, h)); + this.minutes = isNaN(m) ? 0 : Math.min(59, Math.max(0, m)); + } + + _build() { + this.container.innerHTML = ''; + this.container.className = 'tp-widget'; + this._btn = document.createElement('button'); + this._btn.type = 'button'; + this._btn.className = 'tp-display'; + this._btn.addEventListener('click', () => this._toggle()); + this.container.appendChild(this._btn); + this._refreshBtn(); + } + + _refreshBtn() { + const hh = String(this.hours).padStart(2,'0'); + const mm = String(this.minutes).padStart(2,'0'); + this._btn.innerHTML = + `${hh}:${mm}`; + } + + _toggle() { this._pop ? this._close() : this._open(); } + + _open() { + this.mode = 'hour'; + const pop = document.createElement('div'); + pop.className = 'tp-popover'; + const id = this.containerId; + pop.innerHTML = ` +
+ + + : + + +
+
+ + +
+
`; + pop.addEventListener('click', e => e.stopPropagation()); + this._pop = pop; + document.body.appendChild(pop); + this._syncDigits(); + this._hookDigits(); + this._hookModes(); + this._drawClock(); + requestAnimationFrame(() => this._reposition()); + setTimeout(() => document.addEventListener('click', this._outsideClick), 0); + } + + _reposition() { + if (!this._pop) return; + const btnRect = this._btn.getBoundingClientRect(); + const popW = this._pop.offsetWidth; + const popH = this._pop.offsetHeight; + const vw = window.innerWidth; + const vh = window.innerHeight; + + let top = btnRect.bottom + 6; + let left = btnRect.left + btnRect.width / 2 - popW / 2; + + // Clamp horizontally + if (left + popW > vw - 8) left = vw - popW - 8; + if (left < 8) left = 8; + + // Flip above if not enough room below + if (top + popH > vh - 8) top = btnRect.top - popH - 6; + + this._pop.style.top = top + 'px'; + this._pop.style.left = left + 'px'; + } + + _close() { + if (this._pop) { this._pop.remove(); this._pop = null; } + document.removeEventListener('click', this._outsideClick); + } + + _onOutside(e) { this._close(); } + + _d(i) { return document.getElementById(`${this.containerId}__d${i}`); } + + _syncDigits() { + const hh = String(this.hours).padStart(2,'0'); + const mm = String(this.minutes).padStart(2,'0'); + [hh[0], hh[1], mm[0], mm[1]].forEach((v, i) => { const el = this._d(i); if (el) el.value = v; }); + } + + _hookDigits() { + for (let i = 0; i < 4; i++) { + const inp = this._d(i); + if (!inp) continue; + inp.addEventListener('keydown', e => { + if (e.key === 'Backspace' && !inp.value && i > 0) { + const prev = this._d(i - 1); + if (prev) { prev.value = ''; prev.focus(); } + } + }); + inp.addEventListener('input', () => { + inp.value = inp.value.replace(/[^0-9]/g, '').slice(-1); + this._applyDigits(); + if (inp.value && i < 3) { const next = this._d(i + 1); if (next) next.focus(); } + }); + } + } + + _applyDigits() { + const v = [0,1,2,3].map(i => { const el = this._d(i); return el && el.value !== '' ? parseInt(el.value) : null; }); + if (v[0] !== null && v[1] !== null) { + const h = v[0] * 10 + v[1]; + if (h <= 23) this.hours = h; + } + if (v[2] !== null && v[3] !== null) { + const m = v[2] * 10 + v[3]; + if (m <= 59) this.minutes = m; + } + this._refreshBtn(); + this._drawClock(); + if (this.onChange) this.onChange(this.getValue()); + } + + _hookModes() { + const hr = document.getElementById(`${this.containerId}__hr`); + const mn = document.getElementById(`${this.containerId}__mn`); + hr?.addEventListener('click', e => { e.stopPropagation(); this.mode = 'hour'; this._drawClock(); }); + mn?.addEventListener('click', e => { e.stopPropagation(); this.mode = 'minute'; this._drawClock(); }); + } + + _polar(deg, r, cx, cy) { + const rad = (deg - 90) * Math.PI / 180; + return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) }; + } + + _drawClock() { + const wrap = document.getElementById(`${this.containerId}__clock`); + if (!wrap) return; + + const isHour = this.mode === 'hour'; + const S = 220, cx = S / 2, cy = S / 2; + const outerR = 88, innerR = 58; + const ns = 'http://www.w3.org/2000/svg'; + + const svg = document.createElementNS(ns, 'svg'); + svg.setAttribute('viewBox', `0 0 ${S} ${S}`); + svg.setAttribute('width', S); + svg.setAttribute('height', S); + svg.style.cursor = 'pointer'; + + // Background circle + const bg = document.createElementNS(ns, 'circle'); + bg.setAttribute('cx', cx); bg.setAttribute('cy', cy); bg.setAttribute('r', S / 2 - 2); + bg.setAttribute('fill', '#1e2040'); + svg.appendChild(bg); + + // Hand + let handAngle; + if (isHour) { + const h = this.hours; + handAngle = h === 0 ? 0 : h <= 12 ? h * 30 : (h - 12) * 30; + } else { + handAngle = (this.minutes / 60) * 360; + } + const handR = isHour + ? (this.hours >= 1 && this.hours <= 12 ? outerR : innerR) - 14 + : outerR - 14; + const handTip = this._polar(handAngle, handR, cx, cy); + + const line = document.createElementNS(ns, 'line'); + line.setAttribute('x1', cx); line.setAttribute('y1', cy); + line.setAttribute('x2', handTip.x); line.setAttribute('y2', handTip.y); + line.setAttribute('stroke', '#7e8ce0'); line.setAttribute('stroke-width', '2'); + svg.appendChild(line); + + const dot = document.createElementNS(ns, 'circle'); + dot.setAttribute('cx', cx); dot.setAttribute('cy', cy); dot.setAttribute('r', 3); + dot.setAttribute('fill', '#7e8ce0'); + svg.appendChild(dot); + + // Numbers + if (isHour) { + // Outer ring: 12, 1–11 + for (let slot = 0; slot < 12; slot++) { + const h = slot === 0 ? 12 : slot; + this._addNum(svg, ns, h, this._polar(slot * 30, outerR, cx, cy), this.hours === h, 13); + } + // Inner ring: 0, 13–23 + const inner = [0, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]; + for (let slot = 0; slot < 12; slot++) { + const h = inner[slot]; + this._addNum(svg, ns, h, this._polar(slot * 30, innerR, cx, cy), this.hours === h, 10); + } + } else { + for (let slot = 0; slot < 12; slot++) { + const m = slot * 5; + const label = String(m).padStart(2, '0'); + this._addNum(svg, ns, label, this._polar(slot * 30, outerR, cx, cy), this.minutes === m, 13); + } + } + + svg.addEventListener('click', e => this._clockClick(e, cx, cy, outerR, innerR, isHour, S)); + wrap.innerHTML = ''; + wrap.appendChild(svg); + + // Sync mode button active states + document.getElementById(`${this.containerId}__hr`)?.classList.toggle('active', isHour); + document.getElementById(`${this.containerId}__mn`)?.classList.toggle('active', !isHour); + } + + _addNum(svg, ns, label, pos, selected, fontSize) { + if (selected) { + const bg = document.createElementNS(ns, 'circle'); + bg.setAttribute('cx', pos.x); bg.setAttribute('cy', pos.y); + bg.setAttribute('r', fontSize >= 13 ? 14 : 11); + bg.setAttribute('fill', '#7e8ce0'); + svg.appendChild(bg); + } + const txt = document.createElementNS(ns, 'text'); + txt.setAttribute('x', pos.x); txt.setAttribute('y', pos.y); + txt.setAttribute('text-anchor', 'middle'); + txt.setAttribute('dominant-baseline', 'central'); + txt.setAttribute('fill', selected ? '#fff' : '#b8b8d1'); + txt.setAttribute('font-size', fontSize); + txt.setAttribute('font-family', 'system-ui, sans-serif'); + txt.textContent = label; + svg.appendChild(txt); + } + + _clockClick(e, cx, cy, outerR, innerR, isHour, S) { + const rect = e.currentTarget.getBoundingClientRect(); + const sx = S / rect.width, sy = S / rect.height; + const x = (e.clientX - rect.left) * sx - cx; + const y = (e.clientY - rect.top) * sy - cy; + const dist = Math.sqrt(x * x + y * y); + + let angle = Math.atan2(y, x) * 180 / Math.PI + 90; + if (angle < 0) angle += 360; + const slot = Math.round(angle / 30) % 12; + + if (isHour) { + if (dist < (outerR + innerR) / 2) { + this.hours = [0, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23][slot]; + } else { + this.hours = slot === 0 ? 12 : slot; + } + this.mode = 'minute'; + this._refreshBtn(); + this._syncDigits(); + this._drawClock(); + } else { + this.minutes = slot * 5; + this._refreshBtn(); + this._syncDigits(); + if (this.onChange) this.onChange(this.getValue()); + this._close(); + return; + } + if (this.onChange) this.onChange(this.getValue()); + } +} diff --git a/app/templates/index.html b/app/templates/index.html index 17b6e99..33fec5b 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -198,12 +198,18 @@

Add New Sche
- - + +
+ +
+
- - + +
+ +
+
@@ -307,6 +313,7 @@

Completed Downloads

+ @@ -409,12 +416,18 @@

✏️ Edit Schedule

- - + +
+ +
+
- - + +
+ +
+