diff --git a/.vscode/sftp.json b/.vscode/sftp.json
new file mode 100644
index 0000000..7ac08e6
--- /dev/null
+++ b/.vscode/sftp.json
@@ -0,0 +1,12 @@
+{
+ "name": "Mein Webserver",
+ "host": "192.168.178.88",
+ "protocol": "sftp",
+ "port": 22,
+ "username": "root",
+ "password": "934290",
+ "remotePath": "/var/www/html/",
+ "uploadOnSave": true,
+ "useTempFile": false,
+ "openSsh": false
+}
diff --git a/aurora.php b/aurora.php
new file mode 100644
index 0000000..a1de8eb
--- /dev/null
+++ b/aurora.php
@@ -0,0 +1,1720 @@
+ [
+ 'title' => 'Aurora Weather Livecam',
+ 'welcome' => 'Willkommen bei der Aurora Live Webcam',
+ 'subline' => 'Livebilder, Zeitraffer, Archiv & Community – alles in einem sonnigen Dashboard.',
+ 'live' => 'Live',
+ 'timelapse' => 'Zeitraffer',
+ 'archive' => 'Archiv',
+ 'gallery' => 'Galerie',
+ 'community' => 'Community',
+ 'contact' => 'Kontakt',
+ 'guestbook' => 'Gästebuch',
+ 'send' => 'Senden',
+ 'name' => 'Name',
+ 'email' => 'E-Mail',
+ 'message' => 'Nachricht',
+ 'screenshot' => 'Screenshot',
+ 'clip' => 'Clip aufnehmen',
+ 'download' => 'Neueste Aufnahme laden',
+ 'calendar_title' => 'Visueller Wetterkalender',
+ 'language' => 'Sprache',
+ 'rating' => 'Bewertung',
+ 'comment' => 'Kommentar',
+ 'add_entry' => 'Eintrag hinzufügen',
+ 'view_all' => 'Alle anzeigen',
+ 'privacy' => 'Privatsphäre',
+ 'share' => 'Teilen',
+ 'pip' => 'Bild-in-Bild',
+ 'stats' => 'Stream-Status',
+ 'starlink' => 'Starlink-Verbindung',
+ 'starlink_caption' => 'Scanne den QR-Code für das Starlink Satelliteninternet.',
+ 'starlink_alt' => 'Starlink QR-Code',
+ 'timelapse_title' => 'Zeitraffer-Magie',
+ 'timelapse_caption' => 'Die letzten Keyframes werden automatisch abgespielt.',
+ 'archive_title' => 'Video-Archiv',
+ 'archive_hint' => 'Wähle Jahr und Monat um gespeicherte Clips zu laden.',
+ 'no_videos' => 'Keine Videos verfügbar.',
+ 'download_video' => 'Video herunterladen',
+ ],
+ 'en' => [
+ 'title' => 'Aurora Weather Livecam',
+ 'welcome' => 'Welcome to the Aurora Live Webcam',
+ 'subline' => 'Live footage, timelapse, archive & community – all in one sunny dashboard.',
+ 'live' => 'Live',
+ 'timelapse' => 'Timelapse',
+ 'archive' => 'Archive',
+ 'gallery' => 'Gallery',
+ 'community' => 'Community',
+ 'contact' => 'Contact',
+ 'guestbook' => 'Guestbook',
+ 'send' => 'Send',
+ 'name' => 'Name',
+ 'email' => 'Email',
+ 'message' => 'Message',
+ 'screenshot' => 'Screenshot',
+ 'clip' => 'Record clip',
+ 'download' => 'Download latest capture',
+ 'calendar_title' => 'Visual Weather Calendar',
+ 'language' => 'Language',
+ 'rating' => 'Rating',
+ 'comment' => 'Comment',
+ 'add_entry' => 'Add entry',
+ 'view_all' => 'View all',
+ 'privacy' => 'Privacy',
+ 'share' => 'Share',
+ 'pip' => 'Picture-in-Picture',
+ 'stats' => 'Stream status',
+ 'starlink' => 'Starlink Connection',
+ 'starlink_caption' => 'Scan the QR code to reach Starlink satellite internet.',
+ 'starlink_alt' => 'Starlink QR code',
+ 'timelapse_title' => 'Timelapse magic',
+ 'timelapse_caption' => 'Latest keyframes will autoplay.',
+ 'archive_title' => 'Video archive',
+ 'archive_hint' => 'Pick a year and month to browse stored clips.',
+ 'no_videos' => 'No videos available.',
+ 'download_video' => 'Download clip',
+ ],
+ 'it' => [
+ 'title' => 'Aurora Weather Livecam',
+ 'welcome' => 'Benvenuto alla webcam Aurora',
+ 'subline' => 'Immagini live, time-lapse, archivio e community – tutto in un unico cruscotto soleggiato.',
+ 'live' => 'Live',
+ 'timelapse' => 'Time-lapse',
+ 'archive' => 'Archivio',
+ 'gallery' => 'Galleria',
+ 'community' => 'Community',
+ 'contact' => 'Contatto',
+ 'guestbook' => 'Guestbook',
+ 'send' => 'Invia',
+ 'name' => 'Nome',
+ 'email' => 'Email',
+ 'message' => 'Messaggio',
+ 'screenshot' => 'Screenshot',
+ 'clip' => 'Registra clip',
+ 'download' => 'Scarica ultima cattura',
+ 'calendar_title' => 'Calendario Meteo Visivo',
+ 'language' => 'Lingua',
+ 'rating' => 'Valutazione',
+ 'comment' => 'Commento',
+ 'add_entry' => 'Aggiungi voce',
+ 'view_all' => 'Vedi tutto',
+ 'privacy' => 'Privacy',
+ 'share' => 'Condividi',
+ 'pip' => 'Picture-in-Picture',
+ 'stats' => 'Stato stream',
+ 'starlink' => 'Connessione Starlink',
+ 'starlink_caption' => 'Scansiona il QR code per internet satellitare Starlink.',
+ 'starlink_alt' => 'QR code Starlink',
+ 'timelapse_title' => 'Magia time-lapse',
+ 'timelapse_caption' => 'Gli ultimi keyframe vengono riprodotti automaticamente.',
+ 'archive_title' => 'Archivio video',
+ 'archive_hint' => 'Scegli anno e mese per sfogliare le clip.',
+ 'no_videos' => 'Nessun video disponibile.',
+ 'download_video' => 'Scarica clip',
+ ],
+ 'fr' => [
+ 'title' => 'Aurora Weather Livecam',
+ 'welcome' => 'Bienvenue sur la webcam Aurora',
+ 'subline' => 'Images en direct, time-lapse, archives et communauté – dans un tableau de bord ensoleillé.',
+ 'live' => 'Direct',
+ 'timelapse' => 'Time-lapse',
+ 'archive' => 'Archive',
+ 'gallery' => 'Galerie',
+ 'community' => 'Communauté',
+ 'contact' => 'Contact',
+ 'guestbook' => 'Livre d’or',
+ 'send' => 'Envoyer',
+ 'name' => 'Nom',
+ 'email' => 'E-mail',
+ 'message' => 'Message',
+ 'screenshot' => 'Capture',
+ 'clip' => 'Enregistrer un clip',
+ 'download' => 'Télécharger la dernière capture',
+ 'calendar_title' => 'Calendrier météo visuel',
+ 'language' => 'Langue',
+ 'rating' => 'Évaluation',
+ 'comment' => 'Commentaire',
+ 'add_entry' => 'Ajouter une entrée',
+ 'view_all' => 'Tout voir',
+ 'privacy' => 'Confidentialité',
+ 'share' => 'Partager',
+ 'pip' => 'Image dans l’image',
+ 'stats' => 'Statut du flux',
+ 'starlink' => 'Connexion Starlink',
+ 'starlink_caption' => 'Scannez le QR code pour internet par satellite Starlink.',
+ 'starlink_alt' => 'QR code Starlink',
+ 'timelapse_title' => 'Magie time-lapse',
+ 'timelapse_caption' => 'Les derniers keyframes se jouent automatiquement.',
+ 'archive_title' => 'Archive vidéo',
+ 'archive_hint' => 'Choisissez année et mois pour parcourir les clips.',
+ 'no_videos' => 'Aucune vidéo disponible.',
+ 'download_video' => 'Télécharger la vidéo',
+ ],
+ 'zh' => [
+ 'title' => '极光天气直播摄像头',
+ 'welcome' => '欢迎来到极光直播摄像头',
+ 'subline' => '实时画面、延时摄影、档案与社区——尽在阳光仪表盘。',
+ 'live' => '直播',
+ 'timelapse' => '延时摄影',
+ 'archive' => '档案',
+ 'gallery' => '图集',
+ 'community' => '社区',
+ 'contact' => '联系',
+ 'guestbook' => '留言簿',
+ 'send' => '发送',
+ 'name' => '姓名',
+ 'email' => '邮箱',
+ 'message' => '留言',
+ 'screenshot' => '截图',
+ 'clip' => '录制剪辑',
+ 'download' => '下载最新捕获',
+ 'calendar_title' => '可视化天气日历',
+ 'language' => '语言',
+ 'rating' => '评分',
+ 'comment' => '评论',
+ 'add_entry' => '添加条目',
+ 'view_all' => '查看全部',
+ 'privacy' => '隐私',
+ 'share' => '分享',
+ 'pip' => '画中画',
+ 'stats' => '流状态',
+ 'starlink' => 'Starlink 连接',
+ 'starlink_caption' => '扫描二维码访问 Starlink 卫星网络。',
+ 'starlink_alt' => 'Starlink 二维码',
+ 'timelapse_title' => '延时魔法',
+ 'timelapse_caption' => '自动播放最新关键帧。',
+ 'archive_title' => '视频档案',
+ 'archive_hint' => '选择年份和月份来浏览存档剪辑。',
+ 'no_videos' => '暂无可用视频。',
+ 'download_video' => '下载视频',
+ ],
+ ];
+
+ public function getCurrentLocale(): string
+ {
+ if (isset($_POST['language'])) {
+ $_SESSION['lang'] = $_POST['language'];
+ }
+ return $_SESSION['lang'] ?? 'de';
+ }
+
+ public function get(string $key, ?string $locale = null): string
+ {
+ $locale = $locale ?? $this->getCurrentLocale();
+ $locale = array_key_exists($locale, $this->translations) ? $locale : 'de';
+ return $this->translations[$locale][$key] ?? $this->translations['de'][$key] ?? $key;
+ }
+
+ public function getAllTranslations(): array
+ {
+ return $this->translations;
+ }
+}
+
+class WebcamManager
+{
+ private string $videoSrc;
+
+ public function __construct(string $videoSrc = STREAM_SOURCE)
+ {
+ $this->videoSrc = $videoSrc;
+ }
+
+ public function getVideoSrc(): string
+ {
+ return $this->videoSrc;
+ }
+
+ public function getImageFiles(): array
+ {
+ $files = glob(IMAGE_DIR . '/screenshot_*.jpg') ?: [];
+ usort($files, static fn(string $a, string $b) => filemtime($b) <=> filemtime($a));
+ return array_slice($files, 0, 10);
+ }
+
+ public function getLatestVideo(): ?string
+ {
+ $videos = glob(VIDEO_DIR . '/*.mp4');
+ if (!$videos) {
+ return null;
+ }
+ usort($videos, static fn(string $a, string $b) => filemtime($b) <=> filemtime($a));
+ return $videos[0];
+ }
+
+ public function captureSnapshot(): array
+ {
+ $outputFile = 'snapshot_' . date('YmdHis') . '.jpg';
+ $targetPath = UPLOAD_DIR . '/' . $outputFile;
+ $command = sprintf(
+ "ffmpeg -y -i %s -i %s -filter_complex 'overlay=main_w-overlay_w-10:10' -frames:v 1 -q:v 2 %s",
+ escapeshellarg($this->videoSrc),
+ escapeshellarg(LOGO_PATH),
+ escapeshellarg($targetPath)
+ );
+ exec($command, $output, $returnVar);
+ if ($returnVar !== 0 || !file_exists($targetPath)) {
+ return ['success' => false, 'message' => 'Snapshot konnte nicht erstellt werden.'];
+ }
+ return ['success' => true, 'file' => basename($targetPath)];
+ }
+
+ public function captureClip(int $duration = 10): array
+ {
+ $outputFile = 'sequence_' . date('YmdHis') . '.mp4';
+ $targetPath = UPLOAD_DIR . '/' . $outputFile;
+ $command = sprintf(
+ "ffmpeg -y -i %s -i %s -filter_complex 'overlay=10:10' -t %d -c:v libx264 -preset fast -crf 23 %s",
+ escapeshellarg($this->videoSrc),
+ escapeshellarg(LOGO_PATH),
+ $duration,
+ escapeshellarg($targetPath)
+ );
+ exec($command, $output, $returnVar);
+ if ($returnVar !== 0 || !file_exists($targetPath)) {
+ return ['success' => false, 'message' => 'Clip konnte nicht erstellt werden.'];
+ }
+ return ['success' => true, 'file' => basename($targetPath)];
+ }
+
+ public function getStreamStats(): array
+ {
+ return [
+ 'bitrate' => rand(4200, 6200),
+ 'latency' => rand(3, 9),
+ 'updated' => date('H:i:s'),
+ ];
+ }
+
+ public function getGallery(): array
+ {
+ $images = [];
+ foreach (glob(GALLERY_DIR . '/*.{jpg,jpeg,png,gif}', GLOB_BRACE) ?: [] as $file) {
+ $timestamp = filemtime($file) ?: 0;
+ $images[] = [
+ 'src' => str_replace(__DIR__ . '/', '', $file),
+ 'date' => date('Y-m-d H:i', $timestamp),
+ 'timestamp' => $timestamp,
+ ];
+ }
+ usort($images, static fn(array $a, array $b) => $b['timestamp'] <=> $a['timestamp']);
+ $images = array_slice($images, 0, 10);
+ return array_map(static fn(array $image) => [
+ 'src' => $image['src'],
+ 'date' => $image['date'],
+ ], $images);
+ }
+}
+
+class VisualCalendarManager
+{
+ private string $videoDir;
+ private array $monthNames = [
+ 1 => ['de' => 'Januar', 'en' => 'January', 'it' => 'Gennaio', 'fr' => 'Janvier', 'zh' => '一月'],
+ 2 => ['de' => 'Februar', 'en' => 'February', 'it' => 'Febbraio', 'fr' => 'Février', 'zh' => '二月'],
+ 3 => ['de' => 'März', 'en' => 'March', 'it' => 'Marzo', 'fr' => 'Mars', 'zh' => '三月'],
+ 4 => ['de' => 'April', 'en' => 'April', 'it' => 'Aprile', 'fr' => 'Avril', 'zh' => '四月'],
+ 5 => ['de' => 'Mai', 'en' => 'May', 'it' => 'Maggio', 'fr' => 'Mai', 'zh' => '五月'],
+ 6 => ['de' => 'Juni', 'en' => 'June', 'it' => 'Giugno', 'fr' => 'Juin', 'zh' => '六月'],
+ 7 => ['de' => 'Juli', 'en' => 'July', 'it' => 'Luglio', 'fr' => 'Juillet', 'zh' => '七月'],
+ 8 => ['de' => 'August', 'en' => 'August', 'it' => 'Agosto', 'fr' => 'Août', 'zh' => '八月'],
+ 9 => ['de' => 'September', 'en' => 'September', 'it' => 'Settembre', 'fr' => 'Septembre', 'zh' => '九月'],
+ 10 => ['de' => 'Oktober', 'en' => 'October', 'it' => 'Ottobre', 'fr' => 'Octobre', 'zh' => '十月'],
+ 11 => ['de' => 'November', 'en' => 'November', 'it' => 'Novembre', 'fr' => 'Novembre', 'zh' => '十一月'],
+ 12 => ['de' => 'Dezember', 'en' => 'December', 'it' => 'Dicembre', 'fr' => 'Décembre', 'zh' => '十二月'],
+ ];
+
+ public function __construct(string $videoDir = VIDEO_DIR)
+ {
+ $this->videoDir = rtrim($videoDir, '/');
+ }
+
+ public function getMonthData(int $year, int $month): array
+ {
+ $firstDay = new DateTimeImmutable(sprintf('%04d-%02d-01', $year, $month));
+ $daysInMonth = (int) $firstDay->format('t');
+ $days = [];
+ for ($day = 1; $day <= $daysInMonth; $day++) {
+ $date = sprintf('%04d%02d%02d', $year, $month, $day);
+ $pattern = $this->videoDir . "/daily_video_{$date}_*.mp4";
+ $matches = glob($pattern) ?: [];
+ $days[] = [
+ 'day' => $day,
+ 'hasVideos' => !empty($matches),
+ 'count' => count($matches)
+ ];
+ }
+ return [
+ 'year' => $year,
+ 'month' => $month,
+ 'monthName' => $this->monthNames[$month] ?? $this->monthNames[date('n')],
+ 'days' => $days,
+ ];
+ }
+
+ public function getVideosForDate(int $year, int $month, int $day): array
+ {
+ $date = sprintf('%04d%02d%02d', $year, $month, $day);
+ $videos = [];
+ foreach (glob($this->videoDir . "/daily_video_{$date}_*.mp4") as $file) {
+ $fileName = basename($file);
+ $token = generate_download_token($fileName);
+ $videos[] = [
+ 'file' => $fileName,
+ 'size' => filesize($file),
+ 'time' => date('H:i', filemtime($file)),
+ 'sizeFormatted' => round(filesize($file) / (1024 * 1024), 2),
+ 'token' => $token,
+ 'download' => '?download_specific_video=' . rawurlencode($fileName) . '&token=' . $token,
+ 'day' => $day,
+ ];
+ }
+ return $videos;
+ }
+}
+
+class VideoArchiveManager
+{
+ private string $videoDir;
+ private array $monthNames = [
+ '01' => 'Januar',
+ '02' => 'Februar',
+ '03' => 'März',
+ '04' => 'April',
+ '05' => 'Mai',
+ '06' => 'Juni',
+ '07' => 'Juli',
+ '08' => 'August',
+ '09' => 'September',
+ '10' => 'Oktober',
+ '11' => 'November',
+ '12' => 'Dezember',
+ ];
+
+ public function __construct(string $videoDir = VIDEO_DIR)
+ {
+ $this->videoDir = rtrim($videoDir, '/');
+ }
+
+ public function getVideosGroupedByDate(): array
+ {
+ $videos = [];
+ foreach (glob($this->videoDir . '/daily_video_*.mp4') ?: [] as $video) {
+ if (!preg_match('/daily_video_(\d{8})_\d{6}\.mp4$/', basename($video), $matches)) {
+ continue;
+ }
+ $dateStr = $matches[1];
+ $year = substr($dateStr, 0, 4);
+ $month = substr($dateStr, 4, 2);
+ $videos[$year][$month][] = $this->formatVideoRecord($video);
+ }
+ foreach ($videos as $year => $months) {
+ foreach ($months as $month => $items) {
+ usort($videos[$year][$month], static fn(array $a, array $b) => $b['timestamp'] <=> $a['timestamp']);
+ }
+ }
+ krsort($videos);
+ return $videos;
+ }
+
+ public function getAvailableYearsAndMonths(): array
+ {
+ $grouped = $this->getVideosGroupedByDate();
+ $result = [];
+ foreach ($grouped as $year => $months) {
+ $result[$year] = array_values(array_keys($months));
+ rsort($result[$year]);
+ }
+ return $result;
+ }
+
+ public function getVideosForYearAndMonth(int $year, string $month): array
+ {
+ $month = str_pad($month, 2, '0', STR_PAD_LEFT);
+ $grouped = $this->getVideosGroupedByDate();
+ return $grouped[$year][$month] ?? [];
+ }
+
+ public function getMonthName(string $month): string
+ {
+ $month = str_pad($month, 2, '0', STR_PAD_LEFT);
+ return $this->monthNames[$month] ?? $month;
+ }
+
+ public function handleDownloadRequest(?string $fileName, ?string $token): void
+ {
+ if ($fileName === null || $token === null) {
+ return;
+ }
+ $cleanFile = basename($fileName);
+ $expected = generate_download_token($cleanFile);
+ if (!hash_equals($expected, $token)) {
+ respond_json(['message' => 'Ungültiger Download-Token.'], 403);
+ }
+ $videoDir = realpath($this->videoDir);
+ $fullPath = realpath($this->videoDir . '/' . $cleanFile);
+ if (!$videoDir || !$fullPath || strpos($fullPath, $videoDir) !== 0 || !file_exists($fullPath)) {
+ respond_json(['message' => 'Datei nicht gefunden.'], 404);
+ }
+ header('Content-Description: File Transfer');
+ header('Content-Type: video/mp4');
+ header('Content-Disposition: attachment; filename="' . basename($fullPath) . '"');
+ header('Content-Length: ' . filesize($fullPath));
+ readfile($fullPath);
+ exit;
+ }
+
+ private function formatVideoRecord(string $path): array
+ {
+ $fileName = basename($path);
+ $timestamp = filemtime($path) ?: time();
+ $size = filesize($path) ?: 0;
+ $date = DateTimeImmutable::createFromFormat('YmdHis', preg_replace('/[^0-9]/', '', $fileName)) ?: new DateTimeImmutable('@' . $timestamp);
+ $token = generate_download_token($fileName);
+ return [
+ 'file' => $fileName,
+ 'path' => $path,
+ 'day' => (int) $date->format('d'),
+ 'time' => $date->format('H:i'),
+ 'size' => $size,
+ 'sizeFormatted' => round($size / (1024 * 1024), 2),
+ 'timestamp' => $timestamp,
+ 'download' => '?download_specific_video=' . rawurlencode($fileName) . '&token=' . $token,
+ 'token' => $token,
+ ];
+ }
+}
+
+class GuestbookManager
+{
+ private array $entries = [];
+
+ public function __construct()
+ {
+ $content = json_decode((string) file_get_contents(GUESTBOOK_FILE), true);
+ $this->entries = is_array($content) ? $content : [];
+ }
+
+ private function persist(): void
+ {
+ file_put_contents(GUESTBOOK_FILE, json_encode($this->entries, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
+ }
+
+ public function addEntry(string $name, string $message, int $rating = 5): array
+ {
+ $entry = [
+ 'name' => htmlspecialchars($name, ENT_QUOTES, 'UTF-8'),
+ 'message' => htmlspecialchars($message, ENT_QUOTES, 'UTF-8'),
+ 'rating' => max(1, min(5, $rating)),
+ 'created' => date('Y-m-d H:i:s')
+ ];
+ $this->entries[] = $entry;
+ $this->persist();
+ return $entry;
+ }
+
+ public function getEntries(int $limit = 10): array
+ {
+ return array_slice(array_reverse($this->entries), 0, $limit);
+ }
+
+ public function deleteEntry(int $index): bool
+ {
+ if (!isset($this->entries[$index])) {
+ return false;
+ }
+ unset($this->entries[$index]);
+ $this->entries = array_values($this->entries);
+ $this->persist();
+ return true;
+ }
+}
+
+class ContactManager
+{
+ private string $adminEmail = 'metacube@gmail.com';
+ private string $gmailUser = 'metacube@gmail.com';
+ private string $gmailAppPassword = 'qggk hsxz fdkq jgxa';
+
+ public function handle(string $name, string $email, string $message): array
+ {
+ if ($name === '' || $email === '' || $message === '') {
+ return ['success' => false, 'message' => 'Bitte alle Felder ausfüllen.'];
+ }
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ return ['success' => false, 'message' => 'Bitte eine gültige E-Mail-Adresse verwenden.'];
+ }
+ if (mb_strlen($message) < 10) {
+ return ['success' => false, 'message' => 'Die Nachricht ist zu kurz.'];
+ }
+
+ $payload = [
+ 'name' => htmlspecialchars(trim($name), ENT_QUOTES, 'UTF-8'),
+ 'email' => filter_var(trim($email), FILTER_SANITIZE_EMAIL),
+ 'message' => htmlspecialchars(trim($message), ENT_QUOTES, 'UTF-8'),
+ 'date' => date('Y-m-d H:i:s'),
+ 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
+ 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown',
+ ];
+
+ $existing = json_decode((string) file_get_contents(FEEDBACK_FILE), true);
+ $existing = is_array($existing) ? $existing : [];
+ $existing[] = $payload;
+ file_put_contents(FEEDBACK_FILE, json_encode($existing, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
+
+ $mail = new PHPMailer(true);
+ try {
+ $mail->isSMTP();
+ $mail->Host = 'smtp.gmail.com';
+ $mail->SMTPAuth = true;
+ $mail->Username = $this->gmailUser;
+ $mail->Password = $this->gmailAppPassword;
+ $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
+ $mail->Port = 587;
+ $mail->setFrom($this->gmailUser, 'Aurora Livecam');
+ $mail->addAddress($this->adminEmail);
+ $mail->addReplyTo($payload['email'], $payload['name']);
+ $mail->isHTML(true);
+ $mail->CharSet = 'UTF-8';
+ $mail->Subject = 'Neue Kontaktanfrage von ' . $payload['name'];
+ $mail->Body = '
Aurora Kontakt ' .
+ 'Name: ' . $payload['name'] . '
' .
+ 'E-Mail: ' . $payload['email'] . '
' .
+ 'Nachricht: ' . nl2br($payload['message']) . '
' .
+ 'Gesendet am ' . $payload['date'] . ' | IP ' . $payload['ip'] . ' ';
+ $mail->send();
+ } catch (Exception $e) {
+ error_log('Mail error: ' . $mail->ErrorInfo);
+ return ['success' => false, 'message' => 'Nachricht gespeichert, E-Mail konnte nicht gesendet werden.'];
+ }
+
+ return ['success' => true, 'message' => 'Vielen Dank! Wir melden uns zeitnah.'];
+ }
+}
+
+class AdminManager
+{
+ public function isAdmin(): bool
+ {
+ return isset($_SESSION['admin']) && $_SESSION['admin'] === true;
+ }
+
+ public function login(string $username, string $password): bool
+ {
+ if ($username === 'admin' && $password === 'sonne4000$$$$Q') {
+ $_SESSION['admin'] = true;
+ return true;
+ }
+ return false;
+ }
+
+ public function logout(): void
+ {
+ unset($_SESSION['admin']);
+ }
+
+ public function handleImageUpload(array $file): array
+ {
+ if (!$this->isAdmin()) {
+ return ['success' => false, 'message' => 'Nicht autorisiert.'];
+ }
+ if (!isset($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
+ return ['success' => false, 'message' => 'Keine Datei empfangen.'];
+ }
+ $allowed = ['jpg', 'jpeg', 'png', 'gif'];
+ $extension = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION));
+ if (!in_array($extension, $allowed, true)) {
+ return ['success' => false, 'message' => 'Nur JPG, PNG oder GIF sind erlaubt.'];
+ }
+ if ($file['size'] > 5 * 1024 * 1024) {
+ return ['success' => false, 'message' => 'Datei ist größer als 5 MB.'];
+ }
+ $target = UPLOAD_DIR . '/' . uniqid('admin_', true) . '.' . $extension;
+ if (!move_uploaded_file($file['tmp_name'], $target)) {
+ return ['success' => false, 'message' => 'Upload fehlgeschlagen.'];
+ }
+ return ['success' => true, 'file' => basename($target)];
+ }
+
+ public function updateSocialLink(string $platform, string $url): array
+ {
+ if (!$this->isAdmin()) {
+ return ['success' => false, 'message' => 'Nicht autorisiert.'];
+ }
+ $links = json_decode((string) file_get_contents(SOCIAL_LINKS_FILE), true);
+ $links = is_array($links) ? $links : [];
+ $links[$platform] = $url;
+ file_put_contents(SOCIAL_LINKS_FILE, json_encode($links, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
+ return ['success' => true, 'links' => $links];
+ }
+
+ public function getSocialLinks(): array
+ {
+ $links = json_decode((string) file_get_contents(SOCIAL_LINKS_FILE), true);
+ return is_array($links) ? $links : [];
+ }
+}
+
+$languageManager = new LanguageManager();
+$locale = $languageManager->getCurrentLocale();
+$webcamManager = new WebcamManager();
+$calendarManager = new VisualCalendarManager();
+$guestbookManager = new GuestbookManager();
+$contactManager = new ContactManager();
+$adminManager = new AdminManager();
+$archiveManager = new VideoArchiveManager();
+
+if (isset($_GET['download_specific_video'])) {
+ $archiveManager->handleDownloadRequest($_GET['download_specific_video'], $_GET['token'] ?? null);
+}
+
+if (isset($_GET['download_video']) && $_GET['download_video'] === 'latest') {
+ $latest = $webcamManager->getLatestVideo();
+ if ($latest && file_exists($latest)) {
+ header('Content-Description: File Transfer');
+ header('Content-Type: application/octet-stream');
+ header('Content-Disposition: attachment; filename="' . basename($latest) . '"');
+ header('Content-Length: ' . filesize($latest));
+ readfile($latest);
+ exit;
+ }
+ echo 'Kein Video gefunden.';
+ exit;
+}
+
+if (isset($_GET['api'])) {
+ $action = $_GET['api'];
+ switch ($action) {
+ case 'images':
+ respond_json(['images' => array_map(static fn(string $p) => str_replace(__DIR__ . '/', '', $p), $webcamManager->getImageFiles())]);
+ case 'gallery':
+ respond_json(['gallery' => $webcamManager->getGallery()]);
+ case 'calendar':
+ $year = isset($_GET['year']) ? (int) $_GET['year'] : (int) date('Y');
+ $month = isset($_GET['month']) ? (int) $_GET['month'] : (int) date('n');
+ respond_json(['calendar' => $calendarManager->getMonthData($year, $month)]);
+ case 'calendar_videos':
+ $year = (int) ($_GET['year'] ?? date('Y'));
+ $month = (int) ($_GET['month'] ?? date('n'));
+ $day = (int) ($_GET['day'] ?? date('j'));
+ respond_json(['videos' => $calendarManager->getVideosForDate($year, $month, $day)]);
+ case 'archive':
+ $year = (int) ($_GET['year'] ?? date('Y'));
+ $month = str_pad((string) ($_GET['month'] ?? date('n')), 2, '0', STR_PAD_LEFT);
+ respond_json([
+ 'available' => $archiveManager->getAvailableYearsAndMonths(),
+ 'monthName' => $archiveManager->getMonthName($month),
+ 'videos' => $archiveManager->getVideosForYearAndMonth($year, $month)
+ ]);
+ case 'guestbook':
+ respond_json(['entries' => $guestbookManager->getEntries(50)]);
+ case 'stream_stats':
+ respond_json(['stats' => $webcamManager->getStreamStats()]);
+ default:
+ respond_json(['message' => 'Unbekannte API-Anfrage.'], 404);
+ }
+}
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $action = $_POST['action'] ?? '';
+ switch ($action) {
+ case 'capture_snapshot':
+ respond_json($webcamManager->captureSnapshot());
+ case 'capture_clip':
+ $duration = isset($_POST['duration']) ? max(5, min(120, (int) $_POST['duration'])) : 10;
+ respond_json($webcamManager->captureClip($duration));
+ case 'guestbook_add':
+ $name = $_POST['name'] ?? '';
+ $message = $_POST['message'] ?? '';
+ $rating = isset($_POST['rating']) ? (int) $_POST['rating'] : 5;
+ if ($name === '' || $message === '') {
+ respond_json(['success' => false, 'message' => 'Name und Nachricht sind erforderlich.'], 422);
+ }
+ respond_json(['success' => true, 'entry' => $guestbookManager->addEntry($name, $message, $rating)]);
+ case 'guestbook_delete':
+ if (!$adminManager->isAdmin()) {
+ respond_json(['success' => false, 'message' => 'Nicht autorisiert.'], 403);
+ }
+ $index = isset($_POST['entry']) ? (int) $_POST['entry'] : -1;
+ respond_json(['success' => $guestbookManager->deleteEntry($index)]);
+ case 'contact_send':
+ $name = $_POST['name'] ?? '';
+ $email = $_POST['email'] ?? '';
+ $message = $_POST['message'] ?? '';
+ respond_json($contactManager->handle($name, $email, $message));
+ case 'admin_login':
+ $username = $_POST['username'] ?? '';
+ $password = $_POST['password'] ?? '';
+ respond_json(['success' => $adminManager->login($username, $password)]);
+ case 'admin_logout':
+ $adminManager->logout();
+ respond_json(['success' => true]);
+ case 'admin_upload':
+ respond_json($adminManager->handleImageUpload($_FILES['file'] ?? []));
+ case 'social_update':
+ $platform = $_POST['platform'] ?? '';
+ $url = $_POST['url'] ?? '';
+ respond_json($adminManager->updateSocialLink($platform, $url));
+ case 'set_language':
+ $_SESSION['lang'] = $_POST['language'] ?? 'de';
+ respond_json(['success' => true, 'language' => $_SESSION['lang']]);
+ default:
+ respond_json(['message' => 'Unbekannte Aktion.'], 400);
+ }
+}
+
+$translations = $languageManager->getAllTranslations();
+?>
+
+
+
+
+
+ = htmlspecialchars($languageManager->get('title', $locale)) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 📸 = htmlspecialchars($languageManager->get('screenshot', $locale)) ?>
+
+
+ 🎬 = htmlspecialchars($languageManager->get('clip', $locale)) ?>
+
+
+ ⬇️ = htmlspecialchars($languageManager->get('download', $locale)) ?>
+
+
+ 📺 = htmlspecialchars($languageManager->get('pip', $locale)) ?>
+
+
+ ☀️ = htmlspecialchars($languageManager->get('share', $locale)) ?>
+
+
+
+
+
+
+
+ = htmlspecialchars($languageManager->get('timelapse_title', $locale)) ?>
+ = htmlspecialchars($languageManager->get('timelapse_caption', $locale)) ?>
+
+
+
+
+
+
+
+ = htmlspecialchars($languageManager->get('archive_title', $locale)) ?>
+
+
+
+
+
+
+
+
+
+ = htmlspecialchars($languageManager->get('calendar_title', $locale)) ?>
+
+
+
+
+
+ = htmlspecialchars($languageManager->get('guestbook', $locale)) ?>
+
+
+
+
+
+
+
+
+ © = date('Y') ?> Aurora Weather Livecam · = htmlspecialchars($languageManager->get('privacy', $locale)) ?> · tiny view
+
+
+
+
+
diff --git a/auroraalt.php b/auroraalt.php
new file mode 100644
index 0000000..3fe33f0
--- /dev/null
+++ b/auroraalt.php
@@ -0,0 +1,4045 @@
+ $latestTime) {
+ $latestTime = $mtime;
+ $latestVideo = $video;
+ }
+ }
+
+ if ($latestVideo) {
+ header('Content-Description: File Transfer');
+ header('Content-Type: application/octet-stream');
+ header('Content-Disposition: attachment; filename="'.basename($latestVideo).'"');
+ header('Expires: 0');
+ header('Cache-Control: must-revalidate');
+ header('Pragma: public');
+ header('Content-Length: ' . filesize($latestVideo));
+ readfile($latestVideo);
+ exit;
+ } else {
+ echo "Kein Video zum Herunterladen gefunden.";
+ exit;
+ }
+}
+
+
+
+
+// Funktion zur sicheren Umleitung
+function safeRedirect($url) {
+ if (!headers_sent()) {
+ header("HTTP/1.1 301 Moved Permanently");
+ header("Location: " . $url);
+ } else {
+ echo '';
+ }
+ exit();
+}
+
+// Hauptlogik
+$oldDomains = [
+ 'www.aurora-wetter-lifecam.ch',
+ 'www.aurora-wetter-livecam.ch'
+];
+$newDomain = 'www.aurora-weather-livecam.com';
+
+if (in_array($_SERVER['HTTP_HOST'], $oldDomains)) {
+ $protocol = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http';
+ $newUrl = $protocol . '://' . $newDomain . $_SERVER['REQUEST_URI'];
+
+ // Logging für Debugging
+ error_log("Umleitung von {$_SERVER['HTTP_HOST']} nach $newUrl");
+
+ if (!headers_sent()) {
+ header("HTTP/1.1 301 Moved Permanently");
+ header("Location: " . $newUrl);
+ } else {
+ echo '';
+ }
+ exit();
+}
+
+
+
+
+
+session_start();
+error_reporting(E_ALL);
+ini_set('display_errors', 1);
+$imageDir = "./image"; // Angepasst an das Ausgabeverzeichnis des Bash-Skripts
+$imageFiles = glob("$imageDir/screenshot_*.jpg");
+rsort($imageFiles); // Sortiert die Dateien in umgekehrter Reihenfolge (neueste zuerst)
+$imageFilesJson = json_encode($imageFiles);
+
+class WebcamManager {
+
+ private $videoSrc = 'test_video.m3u8';
+ private $logoPath = 'logo.png';
+
+ public function displayWebcam() {
+ return '
+ ';
+}
+
+
+
+ public function captureSnapshot() {
+
+ $outputFile = 'snapshot_' . date('YmdHis') . '.jpg';
+ $command = "ffmpeg -i {$this->videoSrc} -i {$this->logoPath} -filter_complex 'overlay=main_w-overlay_w-10:10' -vframes 1 -q:v 2 {$outputFile}";
+
+
+ exec($command, $output, $returnVar);
+
+ if ($returnVar !== 0) {
+ return "Fehler beim Erstellen des Snapshots.";
+ }
+
+ // Kopieren des Snapshots in den Uploads-Ordner
+ $uploadDir = "uploads/";
+ if (!file_exists($uploadDir)) {
+ mkdir($uploadDir, 0777, true);
+
+ }
+
+ $uploadFile = $uploadDir . $outputFile;
+ if (copy($outputFile, $uploadFile)) {
+
+ } else {
+
+ }
+
+
+
+ header('Content-Type: application/octet-stream');
+ header('Content-Disposition: attachment; filename="' . $outputFile . '"');
+ readfile($outputFile);
+ unlink($outputFile);
+ exit;
+ }
+// public function getImageFiles() {
+// // Nur JPG-Dateien aus uploads/, KEINE MP4-Dateien
+// $imageFiles = glob("uploads/*.{jpg,jpeg,png,gif}", GLOB_BRACE);
+
+// // Filtere unerwünschte Dateien aus
+// $imageFiles = array_filter($imageFiles, function($file) {
+// $basename = basename($file);
+// // Blockiere sequence_*.mp4 und andere unerwünschte Dateien
+// return pathinfo($file, PATHINFO_EXTENSION) !== 'mp4' &&
+// strpos($basename, 'sequence_') !== 0;
+// });
+
+// return json_encode(array_values($imageFiles));
+// }
+public function getImageFiles() {
+ // Screenshots aus dem image/ Ordner holen
+ $imageFiles = glob("image/screenshot_*.jpg");
+ sort($imageFiles); // Neueste zuerst
+ return json_encode($imageFiles);
+}
+
+
+
+
+
+ public function captureVideoSequence($duration = 10) {
+ $outputFile = 'sequence_' . date('YmdHis') . '.mp4';
+ $command = "ffmpeg -i {$this->videoSrc} -i {$this->logoPath} -filter_complex 'overlay=10:10' -t {$duration} -c:v libx264 -preset fast -crf 23 {$outputFile}";
+
+ exec($command, $output, $returnVar);
+
+ if ($returnVar !== 0) {
+ return "Fehler beim Erstellen der Video-Sequenz.";
+ }
+
+ // Kopieren des Videoclips in den Uploads-Ordner
+ $uploadDir = "uploads/";
+ if (!file_exists($uploadDir)) {
+ mkdir($uploadDir, 0777, true);
+ echo "Uploads-Ordner erstellt. ";
+ }
+
+ $uploadFile = $uploadDir . $outputFile;
+ if (copy($outputFile, $uploadFile)) {
+
+ } else {
+ echo "Fehler beim Kopieren des Videoclips in den Uploads-Ordner. ";
+ }
+
+
+
+ header('Content-Type: video/mp4');
+ header('Content-Disposition: attachment; filename="' . $outputFile . '"');
+ readfile($outputFile);
+ unlink($outputFile);
+ exit;
+ }
+public function getJavaScript() {
+ return "
+ document.addEventListener('DOMContentLoaded', function () {
+ var video = document.getElementById('webcam-player');
+ var videoSrc = '{$this->videoSrc}';
+
+ // Mobile Detection
+ var isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
+ var isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
+
+ // Controls NUR auf Desktop verstecken
+ video.controls = false;
+
+ if (isIOS) {
+ // iOS native HLS
+ video.src = videoSrc;
+ video.setAttribute('playsinline', '');
+ video.setAttribute('webkit-playsinline', '');
+ video.muted = true;
+
+ video.addEventListener('loadedmetadata', function() {
+ video.play().catch(function(e) {
+ console.log('iOS Autoplay blockiert');
+ });
+ });
+
+ } else if (Hls.isSupported()) {
+ var hls = new Hls({
+ // Mobile-optimierte Einstellungen
+ liveSyncDurationCount: isMobile ? 2 : 3,
+ liveMaxLatencyDurationCount: isMobile ? 5 : 10,
+ liveDurationInfinity: true,
+ enableWorker: !isMobile,
+ lowLatencyMode: false,
+ backBufferLength: isMobile ? 30 : 90,
+ maxBufferLength: isMobile ? 30 : 60,
+ maxMaxBufferLength: isMobile ? 60 : 120,
+ maxBufferSize: isMobile ? 60*1000*1000 : 120*1000*1000,
+
+ // Mobile-spezifische Timeouts
+ manifestLoadingTimeOut: isMobile ? 20000 : 10000,
+ manifestLoadingMaxRetry: 8,
+ levelLoadingTimeOut: isMobile ? 20000 : 10000,
+ levelLoadingMaxRetry: 8,
+ fragLoadingTimeOut: isMobile ? 20000 : 10000,
+ fragLoadingMaxRetry: 8,
+
+ // Qualität für Mobile
+ startLevel: isMobile ? 0 : -1,
+ abrEwmaDefaultEstimate: isMobile ? 500000 : 1000000
+ });
+
+ hls.loadSource(videoSrc);
+ hls.attachMedia(video);
+
+ hls.on(Hls.Events.MANIFEST_PARSED, function () {
+ console.log('Stream geladen');
+
+ if (isMobile) {
+ video.muted = true;
+ }
+
+ // Live-Position anpassen
+ if (hls.liveSyncPosition !== null) {
+ var targetPosition = hls.liveSyncPosition - 60;
+ console.log('Setze Position auf: ' + targetPosition);
+ video.currentTime = targetPosition;
+ }
+
+ video.play().catch(function(e) {
+ console.log('Autoplay blockiert');
+ });
+ });
+
+ // Fehlerbehandlung
+ hls.on(Hls.Events.ERROR, function(event, data) {
+ if (data.fatal) {
+ switch(data.type) {
+ case Hls.ErrorTypes.NETWORK_ERROR:
+ console.log('Netzwerkfehler - versuche erneut...');
+ setTimeout(function() {
+ hls.startLoad();
+ }, 3000);
+ break;
+ case Hls.ErrorTypes.MEDIA_ERROR:
+ console.log('Media-Fehler - Recovery...');
+ hls.recoverMediaError();
+ break;
+ default:
+ console.log('Kritischer Fehler - Neustart...');
+ hls.destroy();
+ location.reload();
+ break;
+ }
+ }
+ });
+
+ } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
+ // Fallback für andere Browser
+ video.src = videoSrc;
+ video.muted = true;
+ video.addEventListener('loadedmetadata', function () {
+ video.currentTime = Math.max(0, video.duration - 60);
+ video.play();
+ });
+ }
+ });
+ ";
+}
+
+
+
+
+
+
+
+ public function setVideoSrc($src) {
+ $this->videoSrc = $src;
+ }
+}
+
+
+
+
+
+
+
+
+
+
+class VisualCalendarManager {
+ private $videoDir;
+ private $monthNames;
+
+ public function __construct($videoDir = './videos/') {
+ $this->videoDir = $videoDir;
+ $this->monthNames = [
+ 1 => ['de' => 'Januar', 'en' => 'January', 'it' => 'Gennaio', 'fr' => 'Janvier', 'zh' => '一月'],
+ 2 => ['de' => 'Februar', 'en' => 'February', 'it' => 'Febbraio', 'fr' => 'Février', 'zh' => '二月'],
+ 3 => ['de' => 'März', 'en' => 'March', 'it' => 'Marzo', 'fr' => 'Mars', 'zh' => '三月'],
+ 4 => ['de' => 'April', 'en' => 'April', 'it' => 'Aprile', 'fr' => 'Avril', 'zh' => '四月'],
+ 5 => ['de' => 'Mai', 'en' => 'May', 'it' => 'Maggio', 'fr' => 'Mai', 'zh' => '五月'],
+ 6 => ['de' => 'Juni', 'en' => 'June', 'it' => 'Giugno', 'fr' => 'Juin', 'zh' => '六月'],
+ 7 => ['de' => 'Juli', 'en' => 'July', 'it' => 'Luglio', 'fr' => 'Juillet', 'zh' => '七月'],
+ 8 => ['de' => 'August', 'en' => 'August', 'it' => 'Agosto', 'fr' => 'Août', 'zh' => '八月'],
+ 9 => ['de' => 'September', 'en' => 'September', 'it' => 'Settembre', 'fr' => 'Septembre', 'zh' => '九月'],
+ 10 => ['de' => 'Oktober', 'en' => 'October', 'it' => 'Ottobre', 'fr' => 'Octobre', 'zh' => '十月'],
+ 11 => ['de' => 'November', 'en' => 'November', 'it' => 'Novembre', 'fr' => 'Novembre', 'zh' => '十一月'],
+ 12 => ['de' => 'Dezember', 'en' => 'December', 'it' => 'Dicembre', 'fr' => 'Décembre', 'zh' => '十二月']
+ ];
+ }
+
+ public function getVideosForDate($year, $month, $day) {
+ $videos = [];
+ $dateStr = sprintf('%04d%02d%02d', $year, $month, $day);
+
+ foreach (glob($this->videoDir . "daily_video_{$dateStr}_*.mp4") as $video) {
+ $videos[] = [
+ 'path' => $video,
+ 'filename' => basename($video),
+ 'filesize' => filesize($video),
+ 'time' => date('H:i', filemtime($video))
+ ];
+ }
+
+ return $videos;
+ }
+
+ public function hasVideosForDate($year, $month, $day) {
+ $dateStr = sprintf('%04d%02d%02d', $year, $month, $day);
+ $pattern = $this->videoDir . "daily_video_{$dateStr}_*.mp4";
+ return count(glob($pattern)) > 0;
+ }
+
+ public function displayVisualCalendar() {
+ $currentYear = isset($_GET['cal_year']) ? intval($_GET['cal_year']) : date('Y');
+ $currentMonth = isset($_GET['cal_month']) ? intval($_GET['cal_month']) : date('n');
+ $selectedDay = isset($_GET['cal_day']) ? intval($_GET['cal_day']) : null;
+
+ $output = '';
+
+ // Navigation
+ $output .= '
';
+ $output .= '◀ ';
+ $output .= '
' . $this->monthNames[$currentMonth]['de'] . ' ' . $currentYear . ' ';
+ $output .= '▶ ';
+ $output .= '';
+
+ // Kalender-Grid
+ $output .= '
';
+
+ // Wochentage Header
+ $weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
+ foreach ($weekdays as $day) {
+ $output .= '
' . $day . '
';
+ }
+
+ // Tage des Monats
+ $firstDay = mktime(0, 0, 0, $currentMonth, 1, $currentYear);
+ $daysInMonth = date('t', $firstDay);
+ $dayOfWeek = date('N', $firstDay) - 1; // 0 = Montag
+
+ // Leere Zellen vor dem ersten Tag
+ for ($i = 0; $i < $dayOfWeek; $i++) {
+ $output .= '
';
+ }
+
+ // Tage mit Videos markieren
+ for ($day = 1; $day <= $daysInMonth; $day++) {
+ $hasVideos = $this->hasVideosForDate($currentYear, $currentMonth, $day);
+ $isSelected = ($selectedDay == $day);
+ $isToday = ($currentYear == date('Y') && $currentMonth == date('n') && $day == date('j'));
+
+ $classes = 'calendar-day';
+ if ($hasVideos) $classes .= ' has-video';
+ if ($isSelected) $classes .= ' selected';
+ if ($isToday) $classes .= ' today';
+
+ $output .= '
';
+ $output .= '' . $day . ' ';
+ if ($hasVideos) {
+ $output .= '📹 ';
+ }
+ $output .= '
';
+ }
+
+ $output .= '
'; // calendar-grid
+
+ // Video-Liste für ausgewählten Tag
+ if ($selectedDay) {
+ $videos = $this->getVideosForDate($currentYear, $currentMonth, $selectedDay);
+ if (!empty($videos)) {
+ $output .= '
';
+ $output .= '
Videos vom ' . sprintf('%02d.%02d.%04d', $selectedDay, $currentMonth, $currentYear) . ' ';
+ $output .= '
';
+
+ foreach ($videos as $video) {
+ $sizeInMb = round($video['filesize'] / (1024 * 1024), 2);
+ $token = hash_hmac('sha256', $video['path'], session_id());
+
+ $output .= '';
+ $output .= '🕐 ' . $video['time'] . ' Uhr ';
+ $output .= '' . $sizeInMb . ' MB ';
+ $output .= '';
+ $output .= '⬇️ Download';
+ $output .= ' ';
+ $output .= ' ';
+ }
+
+ $output .= ' ';
+ $output .= '
';
+ } else {
+ $output .= '
Keine Videos für diesen Tag verfügbar.
';
+ }
+ }
+
+ $output .= '
'; // visual-calendar-container
+
+ return $output;
+ }
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+class GuestbookManager {
+ private $entries = [];
+ private $dbFile = 'guestbook.json';
+
+ public function __construct() {
+ if (file_exists($this->dbFile)) {
+ $this->entries = json_decode(file_get_contents($this->dbFile), true);
+ }
+ }
+
+ public function handleFormSubmission() {
+ if (isset($_POST['guestbook'], $_POST['guest-name'], $_POST['guest-message'])) {
+ $this->addEntry($_POST['guest-name'], $_POST['guest-message']);
+ $this->saveEntries();
+ }
+ }
+
+ private function addEntry($name, $message) {
+ $this->entries[] = [
+ 'name' => $name,
+ 'message' => $message,
+ 'date' => date('Y-m-d H:i:s')
+ ];
+ }
+
+ public function deleteEntry($index) {
+ if (isset($this->entries[$index])) {
+ unset($this->entries[$index]);
+ $this->entries = array_values($this->entries); // Re-indizieren des Arrays
+ $this->saveEntries();
+ return true;
+ }
+ return false;
+ }
+
+
+
+ private function saveEntries() {
+ file_put_contents($this->dbFile, json_encode($this->entries));
+ }
+
+ public function displayForm() {
+ return '
+
+
+
+
+ Name:
+
+
+
+ Nachricht:
+
+
+
+ Eintrag hinzufügen
+
+
+ ';
+ }
+public function displayEntries($isAdmin = false) {
+ $output = '';
+ foreach ($this->entries as $index => $entry) {
+ $output .= "
+
+
{$entry['name']}
+
{$entry['message']}
+
{$entry['date']}";
+ if ($isAdmin) {
+ $output .= "
+
+
+ Löschen
+ ";
+ }
+
+ }
+ $output .= '
';
+ return $output;
+}
+
+
+}
+
+
+class ContactManager {
+ //private $adminEmail = 'ingo.kohler.zh@gmail.com'; // ← Empfänger
+ private $adminEmail = 'metacube@gmail.com'; // ← Empfänger
+ private $feedbackFile = 'feedbacks.json';
+ private $gmailUser = 'metacube@gmail.com'; // ← DEINE GMAIL-ADRESSE
+ private $gmailAppPassword = 'qggk hsxz fdkq jgxa'; // ← APP-PASSWORT VON GMAIL
+
+ public function displayForm() {
+ return '
+
+
+
+ Name:
+
+
+
+
+ E-Mail:
+
+
+
+
+ Nachricht:
+
+
+
+
+ Nachricht senden
+
+
+
';
+ }
+
+ public function handleSubmission($name, $email, $message) {
+ // Validierung
+ if (empty($name) || empty($email) || empty($message)) {
+ return [
+ 'success' => false,
+ 'message' => 'Alle Felder sind erforderlich'
+ ];
+ }
+
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ return [
+ 'success' => false,
+ 'message' => 'Ungültige E-Mail-Adresse'
+ ];
+ }
+
+ if (strlen($message) < 10) {
+ return [
+ 'success' => false,
+ 'message' => 'Nachricht zu kurz (mindestens 10 Zeichen)'
+ ];
+ }
+
+ // Sanitize
+ $name = htmlspecialchars(trim($name), ENT_QUOTES, 'UTF-8');
+ $email = filter_var(trim($email), FILTER_SANITIZE_EMAIL);
+ $message = htmlspecialchars(trim($message), ENT_QUOTES, 'UTF-8');
+
+ // Feedback speichern
+ $feedback = [
+ 'name' => $name,
+ 'email' => $email,
+ 'message' => $message,
+ 'date' => date('Y-m-d H:i:s'),
+ 'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
+ 'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown'
+ ];
+
+ $feedbacks = file_exists($this->feedbackFile)
+ ? json_decode(file_get_contents($this->feedbackFile), true)
+ : [];
+
+ if (!is_array($feedbacks)) {
+ $feedbacks = [];
+ }
+
+ $feedbacks[] = $feedback;
+ file_put_contents($this->feedbackFile, json_encode($feedbacks, JSON_PRETTY_PRINT));
+
+ // E-MAIL SENDEN MIT GMAIL SMTP
+ $mailSent = $this->sendEmailViaGmail($name, $email, $message, $feedback['date'], $feedback['ip']);
+
+ if ($mailSent) {
+ return [
+ 'success' => true,
+ 'message' => 'Vielen Dank! Ihre Nachricht wurde gesendet.'
+ ];
+ } else {
+ error_log("Mail-Fehler: Nachricht von {$email} konnte nicht gesendet werden");
+ return [
+ 'success' => false,
+ 'message' => 'Nachricht wurde gespeichert, aber E-Mail konnte nicht gesendet werden.'
+ ];
+ }
+ }
+
+ private function sendEmailViaGmail($name, $email, $message, $date, $ip) {
+ $mail = new PHPMailer(true);
+
+
+ try {
+ // DEBUG-MODUS AKTIVIEREN
+ $mail->SMTPDebug = 2;
+ $mail->Debugoutput = function($str, $level) {
+ error_log("PHPMailer Debug: $str");
+ };
+
+ // SMTP Konfiguration
+ $mail->isSMTP();
+ $mail->Host = 'smtp.gmail.com';
+ $mail->SMTPAuth = true;
+ $mail->Username = $this->gmailUser; // metacube@gmail.com
+ $mail->Password = $this->gmailAppPassword; // qggk hsxz fdkq jgxa
+ $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
+ $mail->Port = 587;
+
+
+
+
+ // Absender & Empfänger
+ $mail->setFrom($this->gmailUser, 'Aurora Livecam');
+ $mail->addAddress($this->adminEmail); // admin@aurora-live-weathercam.com
+ $mail->addReplyTo($email, $name);
+
+ // Inhalt
+ $mail->isHTML(true);
+ $mail->CharSet = 'UTF-8';
+ $mail->Subject = '🔔 Neue Kontaktanfrage von ' . $name;
+ $mail->Body = $this->getEmailTemplate($name, $email, $message, $date, $ip);
+
+ $mail->send();
+ return true;
+ } catch (Exception $e) {
+ error_log("PHPMailer Error: {$mail->ErrorInfo}");
+ return false;
+ }
+ }
+
+ private function getEmailTemplate($name, $email, $message, $date, $ip) {
+ return "
+
+
+
+
+
+
+
+
+
+
+
+
+ 👤 Name:
+ {$name}
+
+
+
+
+
+ 💬 Nachricht:
+
+
+
+ " . nl2br($message) . "
+
+
+
+
+
+
+
+
+
+ ";
+ }
+}
+
+class AdminManager {
+ public function isAdmin() {
+ return isset($_SESSION['admin']) && $_SESSION['admin'] === true;
+ }
+ public function handleLogin($username, $password) {
+ echo "Login-Versuch: Username = $username, Passwort = $password"; // Debugging
+ if ($username === 'admin' && $password === 'sonne4000$$$$Q') {
+ $_SESSION['admin'] = true;
+ return true;
+ }
+ return false;
+ }
+
+ public function handleImageUpload($file) {
+ if (!$this->isAdmin()) {
+ return false; // Nur Admins dürfen Bilder hochladen
+ }
+
+ if (!isset($file['tmp_name']) || empty($file['tmp_name'])) {
+ echo "Keine Datei hochgeladen.";
+ return false;
+ }
+
+ $target_dir = "uploads/";
+ if (!file_exists($target_dir)) {
+ mkdir($target_dir, 0777, true);
+ }
+
+ $target_file = $target_dir . basename($file["name"]);
+ $uploadOk = 1;
+ $imageFileType = strtolower(pathinfo($target_file,PATHINFO_EXTENSION));
+
+
+ $check = @getimagesize($file["tmp_name"]);
+ if($check === false) {
+ echo "Die Datei ist kein Bild.";
+ return false;
+ }
+
+
+ if ($file["size"] > 5000000) { // 5MB Limit
+ echo "Die Datei ist zu groß.";
+ return false;
+ }
+
+ // Erlauben Sie nur bestimmte Dateiformate
+ if($imageFileType != "jpg" && $imageFileType != "png" && $imageFileType != "jpeg"
+ && $imageFileType != "gif" ) {
+ echo "Nur JPG, JPEG, PNG & GIF Dateien sind erlaubt.";
+ return false;
+ }
+
+ // Wenn alles in Ordnung ist, versuchen Sie, die Datei hochzuladen
+ if (move_uploaded_file($file["tmp_name"], $target_file)) {
+ echo "Die Datei ". basename( $file["name"]). " wurde hochgeladen.";
+ return true;
+ } else {
+ echo "Es gab einen Fehler beim Hochladen der Datei.";
+ return false;
+ }
+ }
+
+
+ public function displayLoginForm() {
+ return '
+
+
+ Benutzername:
+
+ Passwort:
+
+ Einloggen
+ ';
+ }
+
+ public function displayAdminContent() {
+ $feedbacks = json_decode(file_get_contents('feedbacks.json') ?: '[]', true);
+ $output = '
Admin-Bereich ';
+ foreach ($feedbacks as $feedback) {
+ $output .= "
";
+ $output .= "
{$feedback['name']} ({$feedback['email']}) ";
+ $output .= "
{$feedback['message']}
";
+ $output .= "
{$feedback['date']} ";
+ $output .= "
";
+ }
+ $output .= '
';
+
+ $output .= '
+
Social Media Links verwalten
+
+
+
+ Facebook
+ Instagram
+ TikTok
+
+
+ Aktualisieren
+ ';
+ $output .= '
+
Bild hochladen
+
+
+
+ ';
+ return $output;
+ }
+
+ public function displayGalleryImages() {
+ $output = '
';
+ $files = glob("uploads/*.*");
+ foreach($files as $file) {
+ $filename = basename($file);
+ $extension = pathinfo($file, PATHINFO_EXTENSION);
+
+ // NUR Bilddateien anzeigen, KEINE Videos
+ if (in_array(strtolower($extension), ['jpg', 'jpeg', 'png', 'gif'])) {
+ $output .= '
';
+ }
+ }
+ $output .= '
';
+ return $output;
+}
+
+
+
+
+ public function handleSocialMediaUpdate($platform, $url) {
+ $socialLinks = json_decode(file_get_contents('social_links.json') ?: '{}', true);
+ $socialLinks[$platform] = $url;
+ file_put_contents('social_links.json', json_encode($socialLinks));
+ }
+}
+
+
+
+// Weather Bingo Manager
+//require_once 'weather_bingo.php';
+//$weatherBingo = new WeatherBingo();
+
+
+
+
+
+
+// Neue VideoArchiveManager Klasse
+
+
+class VideoArchiveManager {
+ private $videoDir;
+ private $monthNames;
+
+ public function __construct($videoDir = './videos/') {
+ $this->videoDir = $videoDir;
+ $this->monthNames = [
+ '01' => 'Januar',
+ '02' => 'Februar',
+ '03' => 'März',
+ '04' => 'April',
+ '05' => 'Mai',
+ '06' => 'Juni',
+ '07' => 'Juli',
+ '08' => 'August',
+ '09' => 'September',
+ '10' => 'Oktober',
+ '11' => 'November',
+ '12' => 'Dezember'
+ ];
+ }
+
+ public function getVideosGroupedByDate() {
+ $videos = [];
+
+ foreach (glob($this->videoDir . 'daily_video_*.mp4') as $video) {
+ // Dateinamenformat: daily_video_YYYYMMDD_HHMMSS.mp4
+ if (preg_match('/daily_video_(\d{8})_\d{6}\.mp4/', basename($video), $matches)) {
+ $dateStr = $matches[1]; // YYYYMMDD
+ $year = substr($dateStr, 0, 4);
+ $month = substr($dateStr, 4, 2);
+ $day = substr($dateStr, 6, 2);
+
+ // Gruppiere nach Jahr und Monat
+ $videos[$year][$month][] = [
+ 'path' => $video,
+ 'filename' => basename($video),
+ 'day' => $day,
+ 'filesize' => filesize($video),
+ 'modified' => filemtime($video)
+ ];
+ }
+ }
+
+ // Nach Tag sortieren
+ foreach ($videos as $year => $months) {
+ foreach ($months as $month => $days) {
+ usort($videos[$year][$month], function($a, $b) {
+ return $b['day'] - $a['day']; // Absteigend sortieren (neueste zuerst)
+ });
+ }
+ }
+
+ return $videos;
+ }
+
+ public function getAvailableYearsAndMonths() {
+ $videos = $this->getVideosGroupedByDate();
+ $result = [];
+
+ foreach ($videos as $year => $months) {
+ $result[$year] = array_keys($months);
+ }
+
+ return $result;
+ }
+
+ public function getVideosForYearAndMonth($year, $month) {
+ $videos = $this->getVideosGroupedByDate();
+ return isset($videos[$year][$month]) ? $videos[$year][$month] : [];
+ }
+
+ public function displayCalendarInterface() {
+ $yearsAndMonths = $this->getAvailableYearsAndMonths();
+
+ $output = '
';
+ $output .= '
Video-Archiv ';
+
+ if (empty($yearsAndMonths)) {
+ $output .= '
Keine Videos verfügbar.
';
+ } else {
+ $output .= '
';
+ $output .= '
';
+
+ // Jahr-Auswahl
+ $output .= 'Jahr: ';
+ $output .= '';
+
+ foreach ($yearsAndMonths as $year => $months) {
+ $selected = (isset($_GET['calendar_year']) && $_GET['calendar_year'] == $year) ? 'selected' : '';
+ $output .= "$year ";
+ }
+
+ $output .= ' ';
+
+ // Monats-Auswahl
+ $output .= 'Monat: ';
+ $output .= '';
+
+ // Wenn ein Jahr ausgewählt wurde, zeige die verfügbaren Monate
+ if (isset($_GET['calendar_year']) && isset($yearsAndMonths[$_GET['calendar_year']])) {
+ foreach ($yearsAndMonths[$_GET['calendar_year']] as $month) {
+ $selected = (isset($_GET['calendar_month']) && $_GET['calendar_month'] == $month) ? 'selected' : '';
+ $output .= "{$this->monthNames[$month]} ";
+ }
+ }
+
+ $output .= ' ';
+ $output .= 'Anzeigen ';
+ $output .= ' ';
+ $output .= '';
+
+ // Wenn Jahr und Monat ausgewählt wurden, zeige die Videos
+ if (isset($_GET['calendar_year']) && isset($_GET['calendar_month'])) {
+ $year = $_GET['calendar_year'];
+ $month = $_GET['calendar_month'];
+ $videos = $this->getVideosForYearAndMonth($year, $month);
+
+ if (!empty($videos)) {
+ $output .= '
';
+ $output .= "
Videos für {$this->monthNames[$month]} $year ";
+ $output .= '
';
+ $output .= '
';
+ } else {
+ $output .= "
Keine Videos für {$this->monthNames[$month]} $year gefunden.
";
+ }
+ }
+ }
+
+ $output .= '
';
+ return $output;
+ }
+
+ public function handleSpecificVideoDownload() {
+ if (isset($_GET['download_specific_video']) && isset($_GET['token'])) {
+ $videoPath = $_GET['download_specific_video'];
+ $token = $_GET['token'];
+
+ // Token-Validierung
+ $expectedToken = hash_hmac('sha256', $videoPath, session_id());
+ if (!hash_equals($expectedToken, $token)) {
+ echo "Ungültiger Token. Zugriff verweigert.";
+ exit;
+ }
+
+ // Sicherheitsüberprüfung: Stelle sicher, dass das Video im erlaubten Verzeichnis liegt
+ $videoDir = realpath($this->videoDir);
+ $requestedPath = realpath($videoPath);
+
+ if ($requestedPath && strpos($requestedPath, $videoDir) === 0 && file_exists($requestedPath)) {
+ // Nur MP4-Dateien erlauben
+ $extension = pathinfo($requestedPath, PATHINFO_EXTENSION);
+ if (strtolower($extension) !== 'mp4') {
+ echo "Nur MP4-Dateien können heruntergeladen werden.";
+ exit;
+ }
+
+ header('Content-Description: File Transfer');
+ header('Content-Type: video/mp4');
+ header('Content-Disposition: attachment; filename="'.basename($requestedPath).'"');
+ header('Expires: 0');
+ header('Cache-Control: must-revalidate');
+ header('Pragma: public');
+ header('Content-Length: ' . filesize($requestedPath));
+ readfile($requestedPath);
+ exit;
+ } else {
+ echo "Datei nicht gefunden oder ungültiger Dateipfad.";
+ exit;
+ }
+ }
+ }
+}
+
+
+
+
+
+
+
+$webcamManager = new WebcamManager();
+$imageFilesJson = $webcamManager->getImageFiles();
+$guestbookManager = new GuestbookManager();
+$contactManager = new ContactManager();
+$adminManager = new AdminManager();
+
+// Nach den anderen Manager-Instanzen hinzufügen
+$videoArchiveManager = new VideoArchiveManager('./videos/');
+
+// Video-Download-Handler nach dem existierenden Download-Handler hinzufügen
+$videoArchiveManager->handleSpecificVideoDownload();
+
+
+if (isset($_GET['action'])) {
+ switch ($_GET['action']) {
+ case 'snapshot':
+ $webcamManager->captureSnapshot();
+
+ break;
+ case 'sequence':
+ $webcamManager->captureVideoSequence();
+
+ break;
+
+ }
+
+}
+
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'delete_guestbook') {
+ if ($adminManager->isAdmin() && isset($_POST['delete_entry'])) {
+ $index = $_POST['delete_entry'];
+ if ($guestbookManager->deleteEntry($index)) {
+ $_SESSION['message'] = "Eintrag erfolgreich gelöscht.";
+ } else {
+ $_SESSION['error'] = "Fehler beim Löschen des Eintrags.";
+ }
+ // Umleitung zur gleichen Seite, um Neuladen des Formulars zu verhindern
+ header("Location: " . $_SERVER['PHP_SELF'] . "#guestbook");
+ exit();
+ }
+}
+
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ if (isset($_POST['guestbook'])) {
+ $guestbookManager->handleFormSubmission();
+ } elseif (isset($_POST['contact'])) {
+ $result = $contactManager->handleSubmission($_POST['name'], $_POST['email'], $_POST['message']);
+
+ // JSON-Response für AJAX
+ if (isset($_SERVER['HTTP_X_REQUESTED_WITH']) &&
+ strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest') {
+ header('Content-Type: application/json');
+ echo json_encode($result);
+ exit;
+ }
+
+ // Normale Formular-Submission
+ $_SESSION['contact_result'] = $result;
+ header('Location: ' . $_SERVER['PHP_SELF'] . '#kontakt');
+ exit;
+
+ } elseif (isset($_POST['admin-login'])) {
+ $adminManager->handleLogin($_POST['username'], $_POST['password']);
+ } elseif (isset($_POST['update-social-media'])) {
+ $adminManager->handleSocialMediaUpdate($_POST['social-platform'], $_POST['social-url']);
+
+
+ } elseif (isset($_FILES["fileToUpload"]) && $adminManager->isAdmin()) {
+ $adminManager->handleImageUpload($_FILES["fileToUpload"]);
+}}
+?>
+
+
+
+
+
+
+
+
+
+
Aurora Livecam - Einzigartige Live-Webcam und Wetter>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Willkommen bei Aurora Wetter Livecam
+
+
+
+
+
+
+ Erleben Sie faszinierende Ausblicke der Züricher Region - in Echtzeit!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ displayWebcam(); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Videoarchiv Tagesvideos
+
+ displayVisualCalendar();
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Folge uns und kopiere den Code und sende es deinen Freunden in Tiktok, Facebook, Instagram usw.
+
+
+
+
+
+ Klicke auf den QR-Code, um die URL zu kopieren
+
+
+
+
+
+
+
+
📋 Nutzungsbedingungen & Chat-Regeln
+
+
+
⚠️ Wichtige Verhaltensregeln:
+
+ Keine sexuellen, pornografischen oder anzüglichen Inhalte
+ Keine Gewaltdarstellungen oder -androhungen
+ Kein Rassismus, Diskriminierung oder Hassrede
+ Keine persönlichen Daten (Telefonnummern, Adressen) teilen
+ Keine Werbung oder Spam
+ Respektvoller Umgang miteinander
+ Keine illegalen Aktivitäten oder Inhalte
+
+
+
+
+
📸 Webcam-Nutzung:
+
Die Webcam zeigt öffentlichen Raum. Es werden keine Personen gezielt aufgenommen. Die Nutzung erfolgt auf eigene Verantwortung.
+
+
+
+
💬 Chat-Nutzung:
+
Mit der Nutzung des Chats akzeptieren Sie diese Regeln. Verstöße führen zur sofortigen Sperrung. Chat-Nachrichten werden 24 Stunden gespeichert.
+
+
+
+
⚖️ Rechtliches:
+
Haftungsausschluss: Der Betreiber übernimmt keine Haftung für Inhalte von Nutzern. Jeder Nutzer ist für seine Beiträge selbst verantwortlich.
+
Datenschutz: Es werden nur technisch notwendige Daten gespeichert (IP-Adresse für 24h zur Missbrauchsprävention).
+
+
+
+
+
+ Ich akzeptiere die Nutzungsbedingungen
+
+
+
+
+
+ Akzeptieren & Chat nutzen
+
+
+ Ablehnen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Gästebuch
+ {$_SESSION['message'][$currentLang]}";
+ unset($_SESSION['message']);
+ }
+ if (isset($_SESSION['error'])) {
+ echo "
{$_SESSION['error'][$currentLang]}
";
+ unset($_SESSION['error']);
+ }
+
+ echo $guestbookManager->displayForm();
+ echo $guestbookManager->displayEntries($adminManager->isAdmin());
+ ?>
+
+
+
+
+
+
+
+
+
+
+
Kontakt
+
Haben Sie Fragen, Anregungen oder möchten uns unterstützen? Wir freuen uns auf Ihre Nachricht!
+
+
+
+
+
+ displayForm(); ?>
+
+
+
+
+
+
+
+
+ displayGalleryImages(); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+ Aurora Wetter Livecam ist ein Herzensprojekt von Wetterbegeisterten. Wir möchten Ihnen die Schönheit der Natur und Faszination des Wetters näher bringen.
+
+
+ Dazu betreiben wir seit 2010 rund um die Uhr hochauflösende Webcams. Besonders stolz sind wir auf einzigartige Einblicke, wie z.B. die Trainingsflüge der Patrouille Suisse jeden Montagmorgen.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ isAdmin()): ?>
+
+
+
Admin-Bereich
+ displayAdminContent(); ?>
+
+
+
+
+
+
Admin Login
+ displayLoginForm(); ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Impressum
+
+
Aurora Wetter Livecam
+
M. Kessler
+
Dürnten
+
Schweiz
+
+ Anfragen per Kontaktformular
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/synth/index.php b/synth/index.php
index 924c5d0..38169f9 100644
--- a/synth/index.php
+++ b/synth/index.php
@@ -1,39 +1,34 @@
+
+
Mouse Synth Lab
@@ -102,7 +97,7 @@ $btcCmcLabel = $btcCoinmarketcapPrice ? number_format($btcCoinmarketcapPrice, 2)
}
.controls {
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
@@ -143,42 +138,22 @@ $btcCmcLabel = $btcCoinmarketcapPrice ? number_format($btcCoinmarketcapPrice, 2)
font-weight: 600;
color: #7fffd4;
}
- select,
- option {
- font-size: 1rem;
- border-radius: 999px;
- padding: 0.4rem 0.75rem;
- border: 1px solid rgba(255,255,255,0.15);
- background: rgba(5, 6, 8, 0.4);
- color: inherit;
- }
-
+
Mouse Synth Lab
Zieh deine Maus durch den Pad und verwandle Bewegungen in Klang. Drei LFOs,
FM-Experimente und ein Delay/Distortion-Hybrid werden live verschaltet.
- Die Bitcoin-Feeds von Coindesk (= htmlspecialchars($btcLabel) ?> )
- und CoinMarketCap (= htmlspecialchars($btcCmcLabel) ?> )
- mischen das Routing zusätzlich – sogar die Uhrzeit entscheidet, welches Instrument
- sich meldet und wie die Resonanzen pulsieren.
+ Der aktuelle Bitcoin-Kurs (= htmlspecialchars($btcLabel) ?> )
+ steuert, wie aggressiv der Mix moduliert und rückgekoppelt wird.
-
Coindesk: = htmlspecialchars($btcLabel) ?>
-
CoinMarketCap: = htmlspecialchars($btcCmcLabel) ?>
-
Lokale Zeit: ---
+
Bitcoin Quelle: = htmlspecialchars($btcSource) ?>
+
Letzter Wert: = htmlspecialchars($btcLabel) ?>
diff --git a/synth/synth.js b/synth/synth.js
index bc3ab6c..1dc0ebe 100644
--- a/synth/synth.js
+++ b/synth/synth.js
@@ -1,30 +1,15 @@
const btcPrice = parseFloat(document.body.dataset.btcPrice || 'NaN');
-const btcCmcPrice = parseFloat(document.body.dataset.btcCmcPrice || 'NaN');
-const coinPool = [btcPrice, btcCmcPrice].filter((value) => Number.isFinite(value));
-const normalizedCoin = coinPool.length
- ? coinPool
- .map((value) => Math.min(Math.max((value - 14000) / 28000, 0), 1))
- .reduce((acc, value) => acc + value, 0) / coinPool.length
+const normalizedCoin = Number.isFinite(btcPrice)
+ ? Math.min(Math.max((btcPrice - 15000) / 25000, 0), 1)
: 0.5;
const pad = document.getElementById('synth-pad');
const indicator = document.getElementById('pad-indicator');
-const instrumentSelect = document.getElementById('instrument');
const fmDepthInput = document.getElementById('fm-depth');
const lfoSpeedInput = document.getElementById('lfo-speed');
const textureInput = document.getElementById('texture');
-const noiseInput = document.getElementById('noise-level');
-const chorusInput = document.getElementById('chorus-depth');
-const phaserInput = document.getElementById('phaser-mix');
-const crusherInput = document.getElementById('crusher-fold');
-const delayMixInput = document.getElementById('delay-mix');
-const reverbInput = document.getElementById('reverb-mix');
-const clockReactivityInput = document.getElementById('clock-reactivity');
-const coinReactivityInput = document.getElementById('coin-reactivity');
-const driveInput = document.getElementById('drive');
const startBtn = document.getElementById('start-btn');
const randomizeBtn = document.getElementById('randomize-btn');
-const clockInfo = document.getElementById('clock-info');
class MouseSynth {
constructor(options = {}) {
@@ -33,13 +18,6 @@ class MouseSynth {
this.started = false;
this.coinBlend = options.coinBlend ?? 0.5;
- this.clockDepth = options.clockDepth ?? 0.5;
- this.driveCeiling = options.driveCeiling ?? 0.35;
- this.instrument = 'supersaw';
- this.instrumentNoiseScale = 1;
- this.chorusAmount = chorusInput ? parseFloat(chorusInput.value) : 0.45;
- this.phaserAmount = phaserInput ? parseFloat(phaserInput.value) : 0.5;
- this.crushFold = crusherInput ? parseFloat(crusherInput.value) : 12;
this.setupNodes();
this.#bindEvents();
@@ -51,15 +29,6 @@ class MouseSynth {
this.masterGain = ctx.createGain();
this.masterGain.gain.value = 0.0;
- this.compressor = ctx.createDynamicsCompressor();
- this.compressor.threshold.value = -8;
- this.compressor.knee.value = 12;
- this.compressor.ratio.value = 12;
- this.compressor.attack.value = 0.004;
- this.compressor.release.value = 0.25;
-
- this.panner = ctx.createStereoPanner();
-
this.carrier = ctx.createOscillator();
this.carrier.type = 'sawtooth';
@@ -106,52 +75,6 @@ class MouseSynth {
this.distortion = ctx.createWaveShaper();
this.#setDrive(400);
- this.chorusDelay = ctx.createDelay(0.05);
- this.chorusDelay.delayTime.value = 0.018;
- this.chorusDepthGain = ctx.createGain();
- this.chorusDepthGain.gain.value = 0.003;
- this.chorusLfo = ctx.createOscillator();
- this.chorusLfo.frequency.value = 0.25;
- this.chorusLfo.connect(this.chorusDepthGain).connect(this.chorusDelay.delayTime);
- this.chorusDry = ctx.createGain();
- this.chorusDry.gain.value = 0.6;
- this.chorusWet = ctx.createGain();
- this.chorusWet.gain.value = 0.4;
- this.chorusSum = ctx.createGain();
-
- this.phaserInput = ctx.createGain();
- this.phaserDry = ctx.createGain();
- this.phaserDry.gain.value = 0.5;
- this.phaserWet = ctx.createGain();
- this.phaserWet.gain.value = 0.5;
- this.phaserLfo = ctx.createOscillator();
- this.phaserLfo.frequency.value = 0.12;
- this.phaserLfoGain = ctx.createGain();
- this.phaserLfoGain.gain.value = 280;
- this.phaserStages = Array.from({ length: 4 }, (_, index) => {
- const stage = ctx.createBiquadFilter();
- stage.type = 'allpass';
- stage.frequency.value = 300 + index * 220;
- stage.Q.value = 2.5;
- return stage;
- });
- this.phaserStages.forEach((stage, index, arr) => {
- if (index > 0) {
- arr[index - 1].connect(stage);
- }
- this.phaserLfoGain.connect(stage.frequency);
- });
- this.phaserLfo.connect(this.phaserLfoGain);
- this.postPhaser = ctx.createGain();
-
- this.crusher = ctx.createWaveShaper();
- this.#updateCrusher(this.crushFold);
- this.crusherDry = ctx.createGain();
- this.crusherDry.gain.value = 0.55;
- this.crusherWet = ctx.createGain();
- this.crusherWet.gain.value = 0.45;
- this.crusherOutput = ctx.createGain();
-
this.coinMorph = ctx.createGain();
this.coinMorph.gain.value = this.coinBlend;
@@ -160,11 +83,6 @@ class MouseSynth {
this.reverbGain = ctx.createGain();
this.reverbGain.gain.value = 0.25;
- this.dryGain = ctx.createGain();
- this.dryGain.gain.value = 1 - (delayMixInput ? parseFloat(delayMixInput.value) : 0.6);
- this.delayWet = ctx.createGain();
- this.delayWet.gain.value = delayMixInput ? parseFloat(delayMixInput.value) : 0.6;
-
// Connections
this.fmOsc.connect(this.fmGain).connect(this.carrier.frequency);
this.harmonic.connect(this.filter);
@@ -176,24 +94,17 @@ class MouseSynth {
this.filter.connect(this.distortion);
this.noise.connect(this.noiseGain).connect(this.filter);
- this.distortion.connect(this.chorusDry).connect(this.chorusSum);
- this.distortion.connect(this.chorusDelay).connect(this.chorusWet).connect(this.chorusSum);
- this.chorusSum.connect(this.phaserInput);
- this.phaserInput.connect(this.phaserDry).connect(this.postPhaser);
- this.phaserInput.connect(this.phaserStages[0]);
- this.phaserStages[this.phaserStages.length - 1].connect(this.phaserWet).connect(this.postPhaser);
- this.postPhaser.connect(this.crusherDry).connect(this.crusherOutput);
- this.postPhaser.connect(this.crusher).connect(this.crusherWet).connect(this.crusherOutput);
-
- this.crusherOutput.connect(this.dryGain).connect(this.masterGain);
- this.crusherOutput.connect(this.delay);
+ const wet = ctx.createGain();
+ const dry = ctx.createGain();
+ this.distortion.connect(dry).connect(this.masterGain);
+ this.distortion.connect(this.delay);
this.delay.connect(this.feedback).connect(this.delay);
this.delay.connect(this.coinMorph);
- this.coinMorph.connect(this.delayWet);
- this.delayWet.connect(this.reverb);
+ this.coinMorph.connect(wet);
+ wet.connect(this.reverb);
this.reverb.connect(this.reverbGain).connect(this.masterGain);
- this.masterGain.connect(this.panner).connect(this.compressor).connect(ctx.destination);
+ this.masterGain.connect(ctx.destination);
this.carrier.start();
this.harmonic.start();
@@ -202,25 +113,16 @@ class MouseSynth {
this.filterLfo.start();
this.sampleHold.start();
this.noise.start();
- this.chorusLfo.start();
- this.phaserLfo.start();
}
async start() {
if (this.started) return;
await this.ctx.resume();
- this.masterGain.gain.linearRampToValueAtTime(0.65, this.ctx.currentTime + 0.5);
+ this.masterGain.gain.linearRampToValueAtTime(0.8, this.ctx.currentTime + 0.5);
this.started = true;
- this.tickClock();
}
#bindEvents() {
- if (instrumentSelect) {
- instrumentSelect.addEventListener('change', () => {
- this.setInstrument(instrumentSelect.value);
- });
- }
-
fmDepthInput.addEventListener('input', () => {
this.fmGain.gain.setTargetAtTime(parseFloat(fmDepthInput.value), this.ctx.currentTime, 0.05);
});
@@ -235,43 +137,6 @@ class MouseSynth {
this.#updateTexture(parseFloat(textureInput.value));
});
- noiseInput.addEventListener('input', () => {
- this.setNoiseLevel(parseFloat(noiseInput.value));
- });
-
- chorusInput.addEventListener('input', () => {
- this.setChorusDepth(parseFloat(chorusInput.value));
- });
-
- phaserInput.addEventListener('input', () => {
- this.setPhaserMix(parseFloat(phaserInput.value));
- });
-
- crusherInput.addEventListener('input', () => {
- this.setCrusherFold(parseFloat(crusherInput.value));
- });
-
- delayMixInput.addEventListener('input', () => {
- this.setDelayMix(parseFloat(delayMixInput.value));
- });
-
- reverbInput.addEventListener('input', () => {
- this.setReverbMix(parseFloat(reverbInput.value));
- });
-
- clockReactivityInput.addEventListener('input', () => {
- this.setClockDepth(parseFloat(clockReactivityInput.value));
- });
-
- coinReactivityInput.addEventListener('input', () => {
- this.setCoinBlend(parseFloat(coinReactivityInput.value));
- });
-
- driveInput.addEventListener('input', () => {
- this.setDriveCeiling(parseFloat(driveInput.value));
- this.#updateTexture(parseFloat(textureInput.value));
- });
-
randomizeBtn.addEventListener('click', () => this.randomize());
}
@@ -293,10 +158,7 @@ class MouseSynth {
this.feedback.gain.setTargetAtTime(0.2 + x * 0.7 * this.coinBlend, this.ctx.currentTime, 0.2);
this.coinMorph.gain.setTargetAtTime(this.coinBlend * (0.4 + y * 0.6), this.ctx.currentTime, 0.3);
this.fmGain.gain.setTargetAtTime(parseFloat(fmDepthInput.value) + x * 200, this.ctx.currentTime, 0.05);
- this.chorusLfo.frequency.setTargetAtTime(0.1 + x * 2.4, this.ctx.currentTime, 0.2);
- this.phaserLfo.frequency.setTargetAtTime(0.05 + y * 1.2 + this.phaserAmount * 0.4, this.ctx.currentTime, 0.2);
this.#updateTexture(textureInput.value, x, y);
- this.tickClock();
}
handlePointerLeave() {
@@ -306,11 +168,6 @@ class MouseSynth {
}
randomize() {
- const instruments = Array.from(instrumentSelect.options).map((option) => option.value);
- const instrument = instruments[Math.floor(Math.random() * instruments.length)];
- instrumentSelect.value = instrument;
- this.setInstrument(instrument);
-
const fm = 80 + Math.random() * 720;
fmDepthInput.value = fm.toFixed(0);
this.fmGain.gain.setTargetAtTime(fm, this.ctx.currentTime, 0.1);
@@ -323,53 +180,15 @@ class MouseSynth {
const texture = Math.random();
textureInput.value = texture.toFixed(2);
this.#updateTexture(texture);
-
- const noise = Math.random();
- noiseInput.value = noise.toFixed(2);
- this.setNoiseLevel(noise);
-
- const delayMix = Math.random();
- delayMixInput.value = delayMix.toFixed(2);
- this.setDelayMix(delayMix);
-
- const reverbMix = Math.random();
- reverbInput.value = reverbMix.toFixed(2);
- this.setReverbMix(reverbMix);
-
- const chorusAmt = Math.random();
- chorusInput.value = chorusAmt.toFixed(2);
- this.setChorusDepth(chorusAmt);
-
- const phaserAmt = Math.random();
- phaserInput.value = phaserAmt.toFixed(2);
- this.setPhaserMix(phaserAmt);
-
- const crushFold = Math.floor(2 + Math.random() * 30);
- crusherInput.value = crushFold.toString();
- this.setCrusherFold(crushFold);
-
- const clockDepth = Math.random();
- clockReactivityInput.value = clockDepth.toFixed(2);
- this.setClockDepth(clockDepth);
-
- const coinDepth = Math.random();
- coinReactivityInput.value = coinDepth.toFixed(2);
- this.setCoinBlend(coinDepth);
-
- const drive = Math.random();
- driveInput.value = drive.toFixed(2);
- this.setDriveCeiling(drive);
- this.#updateTexture(texture);
}
#updateTexture(value, x = 0.5, y = 0.5) {
const amount = parseFloat(value);
const drive = 150 + amount * 850 + this.coinBlend * 400;
- const driveLimit = 150 + this.driveCeiling * 700;
- this.#setDrive(Math.min(drive, driveLimit));
+ this.#setDrive(drive);
const morph = amount * (0.6 + this.coinBlend * 0.8);
this.distortion.oversample = morph > 0.5 ? '4x' : '2x';
- this.reverbGain.gain.setTargetAtTime(0.1 + morph * 0.5, this.ctx.currentTime, 0.3);
+ this.reverbGain.gain.setTargetAtTime(0.15 + morph * 0.6, this.ctx.currentTime, 0.3);
const filterType = morph > 0.7 ? 'notch' : morph > 0.35 ? 'bandpass' : 'lowpass';
this.filter.type = filterType;
this.coinMorph.gain.setTargetAtTime(this.coinBlend * (0.4 + morph), this.ctx.currentTime, 0.3);
@@ -385,18 +204,6 @@ class MouseSynth {
this.distortion.curve = curve;
}
- #updateCrusher(levels) {
- const steps = Math.max(2, levels);
- const curve = new Float32Array(1024);
- for (let i = 0; i < curve.length; i++) {
- const x = (i / (curve.length - 1)) * 2 - 1;
- const normalized = (x + 1) / 2;
- const quantized = Math.round(normalized * (steps - 1)) / (steps - 1);
- curve[i] = quantized * 2 - 1;
- }
- this.crusher.curve = curve;
- }
-
#createNoise() {
const buffer = this.ctx.createBuffer(1, this.ctx.sampleRate * 4, this.ctx.sampleRate);
const data = buffer.getChannelData(0);
@@ -421,139 +228,9 @@ class MouseSynth {
}
return impulse;
}
-
- setInstrument(mode) {
- this.instrument = mode;
- switch (mode) {
- case 'fm-bell':
- this.carrier.type = 'sine';
- this.harmonic.type = 'sine';
- this.harmonic.detune.setTargetAtTime(1200, this.ctx.currentTime, 0.2);
- this.filter.Q.setTargetAtTime(14, this.ctx.currentTime, 0.2);
- this.filter.type = 'bandpass';
- this.instrumentNoiseScale = 0.2;
- break;
- case 'pulse-pluck':
- this.carrier.type = 'square';
- this.harmonic.type = 'square';
- this.harmonic.detune.setTargetAtTime(305, this.ctx.currentTime, 0.2);
- this.filter.type = 'lowpass';
- this.filter.Q.setTargetAtTime(6, this.ctx.currentTime, 0.2);
- this.instrumentNoiseScale = 0.6;
- break;
- default:
- this.carrier.type = 'sawtooth';
- this.harmonic.type = 'triangle';
- this.harmonic.detune.setTargetAtTime(702, this.ctx.currentTime, 0.2);
- this.filter.type = 'bandpass';
- this.filter.Q.setTargetAtTime(10, this.ctx.currentTime, 0.2);
- this.instrumentNoiseScale = 1;
- }
- this.setNoiseLevel(parseFloat(noiseInput.value));
- }
-
- setNoiseLevel(amount) {
- const scale = this.instrumentNoiseScale ?? 1;
- this.noiseGain.gain.setTargetAtTime(amount * 0.6 * scale, this.ctx.currentTime, 0.1);
- }
-
- setDelayMix(amount) {
- this.dryGain.gain.setTargetAtTime(Math.max(0.05, 1 - amount), this.ctx.currentTime, 0.2);
- this.delayWet.gain.setTargetAtTime(amount, this.ctx.currentTime, 0.2);
- }
-
- setReverbMix(amount) {
- this.reverbGain.gain.setTargetAtTime(0.05 + amount * 0.7, this.ctx.currentTime, 0.2);
- }
-
- setCoinBlend(amount) {
- this.coinBlend = amount;
- this.coinMorph.gain.setTargetAtTime(amount * 0.8, this.ctx.currentTime, 0.2);
- }
-
- setClockDepth(amount) {
- this.clockDepth = amount;
- this.tickClock();
- }
-
- setDriveCeiling(amount) {
- this.driveCeiling = amount;
- }
-
- setChorusDepth(amount) {
- this.chorusAmount = amount;
- const depth = 0.001 + amount * 0.01;
- this.chorusDepthGain.gain.setTargetAtTime(depth, this.ctx.currentTime, 0.2);
- const wet = 0.15 + amount * 0.85;
- const dry = Math.max(0.15, 1 - wet * 0.8);
- this.chorusWet.gain.setTargetAtTime(wet, this.ctx.currentTime, 0.2);
- this.chorusDry.gain.setTargetAtTime(dry, this.ctx.currentTime, 0.2);
- }
-
- setPhaserMix(amount) {
- this.phaserAmount = amount;
- this.phaserWet.gain.setTargetAtTime(amount, this.ctx.currentTime, 0.2);
- this.phaserDry.gain.setTargetAtTime(1 - amount, this.ctx.currentTime, 0.2);
- this.phaserLfo.frequency.setTargetAtTime(0.05 + amount * 1.2, this.ctx.currentTime, 0.3);
- this.phaserLfoGain.gain.setTargetAtTime(120 + amount * 620, this.ctx.currentTime, 0.3);
- }
-
- setCrusherFold(levels) {
- const fold = Math.max(2, Math.round(levels));
- this.crushFold = fold;
- this.#updateCrusher(fold);
- const wet = Math.min(0.85, 0.2 + fold / 40);
- this.crusherWet.gain.setTargetAtTime(wet, this.ctx.currentTime, 0.2);
- this.crusherDry.gain.setTargetAtTime(1 - wet * 0.7, this.ctx.currentTime, 0.2);
- }
-
- tickClock() {
- const now = new Date();
- const hourFactor = now.getHours() / 23 || 0;
- const secondFactor = now.getSeconds() / 59 || 0;
- const milliFactor = now.getMilliseconds() / 999 || 0;
- const depth = this.clockDepth;
- const detune = (hourFactor - 0.5) * 600 * depth;
- this.filter.detune.setTargetAtTime(detune, this.ctx.currentTime, 0.4);
- this.harmonic.detune.setTargetAtTime(702 + detune * 0.4, this.ctx.currentTime, 0.4);
- const pan = (secondFactor - 0.5) * 1.8 * depth;
- this.panner.pan.setTargetAtTime(pan, this.ctx.currentTime, 0.3);
- const ampMod = 0.2 + hourFactor * 0.7 * depth;
- this.ampLfoGain.gain.setTargetAtTime(ampMod, this.ctx.currentTime, 0.3);
- const shRate = 2 + milliFactor * 14 * depth;
- this.sampleHold.frequency.setTargetAtTime(shRate, this.ctx.currentTime, 0.2);
- }
}
-const synth = new MouseSynth({
- coinBlend: normalizedCoin,
- clockDepth: parseFloat(clockReactivityInput.value),
- driveCeiling: parseFloat(driveInput.value),
-});
-
-coinReactivityInput.value = normalizedCoin.toFixed(2);
-synth.setCoinBlend(normalizedCoin);
-if (instrumentSelect) {
- synth.setInstrument(instrumentSelect.value);
-}
-if (noiseInput) {
- synth.setNoiseLevel(parseFloat(noiseInput.value));
-}
-if (delayMixInput) {
- synth.setDelayMix(parseFloat(delayMixInput.value));
-}
-if (reverbInput) {
- synth.setReverbMix(parseFloat(reverbInput.value));
-}
-if (chorusInput) {
- synth.setChorusDepth(parseFloat(chorusInput.value));
-}
-if (phaserInput) {
- synth.setPhaserMix(parseFloat(phaserInput.value));
-}
-if (crusherInput) {
- synth.setCrusherFold(parseFloat(crusherInput.value));
-}
+const synth = new MouseSynth({ coinBlend: normalizedCoin });
startBtn.addEventListener('click', () => synth.start());
@@ -576,22 +253,10 @@ pad.addEventListener('pointerup', (event) => {
pad.addEventListener('pointerleave', () => synth.handlePointerLeave());
-function updateClockStatus() {
- const now = new Date();
- const formatted = now.toLocaleTimeString('de-DE', { hour12: false });
- if (clockInfo) {
- clockInfo.innerHTML = `Lokale Zeit:
${formatted}.${now.getMilliseconds().toString().padStart(3, '0')} `;
- }
- synth.tickClock();
-}
-
-setInterval(updateClockStatus, 1000);
-updateClockStatus();
-
-if (!coinPool.length) {
+if (!Number.isFinite(btcPrice)) {
const status = document.getElementById('btc-status');
if (status) {
- status.insertAdjacentHTML('beforeend', '
BTC Feeds nicht erreichbar – Synth läuft im Fantasy-Modus.
');
+ status.insertAdjacentHTML('beforeend', '
BTC Feed nicht erreichbar – Synth läuft im Fantasy-Modus.
');
}
}