Files

1139 lines
41 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
require __DIR__ . '/vendor/autoload.php';
session_start();
error_reporting(E_ALL);
ini_set('display_errors', '1');
const STREAM_SOURCE = 'test_video.m3u8';
const LOGO_PATH = 'logo.png';
const IMAGE_DIR = __DIR__ . '/image';
const UPLOAD_DIR = __DIR__ . '/uploads';
const VIDEO_DIR = __DIR__ . '/videos';
const GALLERY_DIR = __DIR__ . '/gallery';
const COMMENTS_FILE = __DIR__ . '/comments.json';
const GUESTBOOK_FILE = __DIR__ . '/guestbook.json';
const FEEDBACK_FILE = __DIR__ . '/feedbacks.json';
if (!is_dir(IMAGE_DIR)) {
@mkdir(IMAGE_DIR, 0777, true);
}
if (!is_dir(UPLOAD_DIR)) {
@mkdir(UPLOAD_DIR, 0777, true);
}
if (!is_dir(VIDEO_DIR)) {
@mkdir(VIDEO_DIR, 0777, true);
}
if (!is_dir(GALLERY_DIR)) {
@mkdir(GALLERY_DIR, 0777, true);
}
if (!file_exists(COMMENTS_FILE)) {
file_put_contents(COMMENTS_FILE, json_encode([]));
}
if (!file_exists(GUESTBOOK_FILE)) {
file_put_contents(GUESTBOOK_FILE, json_encode([]));
}
if (!file_exists(FEEDBACK_FILE)) {
file_put_contents(FEEDBACK_FILE, json_encode([]));
}
$oldDomains = [
'www.aurora-wetter-lifecam.ch',
'www.aurora-wetter-livecam.ch'
];
$newDomain = 'www.aurora-weather-livecam.com';
if (isset($_SERVER['HTTP_HOST']) && in_array($_SERVER['HTTP_HOST'], $oldDomains, true)) {
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$newUrl = $protocol . '://' . $newDomain . ($_SERVER['REQUEST_URI'] ?? '/');
header('HTTP/1.1 301 Moved Permanently');
header('Location: ' . $newUrl);
exit;
}
function respond_json(array $payload, int $code = 200): void
{
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
class LanguageManager
{
private array $translations = [
'de' => [
'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',
],
'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',
],
'fr' => [
'title' => 'Aurora Weather Livecam',
'welcome' => 'Bienvenue sur la webcam Aurora',
'subline' => 'Images en direct, time-lapse, archive et communauté réunis dans un tableau de bord ensoleillé.',
'live' => 'Direct',
'timelapse' => 'Accéléré',
'archive' => 'Archive',
'gallery' => 'Galerie',
'community' => 'Communauté',
'contact' => 'Contact',
'guestbook' => 'Livre dor',
'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' => 'Picture-in-Picture',
'stats' => 'Statut du flux',
],
'it' => [
'title' => 'Aurora Weather Livecam',
'welcome' => 'Benvenuti alla webcam Aurora',
'subline' => 'Live, time-lapse, archivio e community tutto in un dashboard soleggiato.',
'live' => 'Live',
'timelapse' => 'Time-lapse',
'archive' => 'Archivio',
'gallery' => 'Galleria',
'community' => 'Community',
'contact' => 'Contatto',
'guestbook' => 'Libro degli ospiti',
'send' => 'Invia',
'name' => 'Nome',
'email' => 'E-mail',
'message' => 'Messaggio',
'screenshot' => 'Screenshot',
'clip' => 'Registra clip',
'download' => 'Scarica lultima registrazione',
'calendar_title' => 'Calendario Meteo Visivo',
'language' => 'Lingua',
'rating' => 'Valutazione',
'comment' => 'Commento',
'add_entry' => 'Aggiungi',
'view_all' => 'Vedi tutto',
'privacy' => 'Privacy',
'share' => 'Condividi',
'pip' => 'Picture-in-Picture',
'stats' => 'Stato del flusso',
],
];
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');
rsort($files);
return $files;
}
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) {
$images[] = [
'src' => str_replace(__DIR__ . '/', '', $file),
'date' => date('Y-m-d H:i', filemtime($file))
];
}
usort($images, static fn(array $a, array $b) => strcmp($b['date'], $a['date']));
return array_slice($images, 0, 20);
}
}
class VisualCalendarManager
{
private array $monthNames = [
1 => ['de' => 'Januar', 'en' => 'January', 'it' => 'Gennaio', 'fr' => 'Janvier'],
2 => ['de' => 'Februar', 'en' => 'February', 'it' => 'Febbraio', 'fr' => 'Février'],
3 => ['de' => 'März', 'en' => 'March', 'it' => 'Marzo', 'fr' => 'Mars'],
4 => ['de' => 'April', 'en' => 'April', 'it' => 'Aprile', 'fr' => 'Avril'],
5 => ['de' => 'Mai', 'en' => 'May', 'it' => 'Maggio', 'fr' => 'Mai'],
6 => ['de' => 'Juni', 'en' => 'June', 'it' => 'Giugno', 'fr' => 'Juin'],
7 => ['de' => 'Juli', 'en' => 'July', 'it' => 'Luglio', 'fr' => 'Juillet'],
8 => ['de' => 'August', 'en' => 'August', 'it' => 'Agosto', 'fr' => 'Août'],
9 => ['de' => 'September', 'en' => 'September', 'it' => 'Settembre', 'fr' => 'Septembre'],
10 => ['de' => 'Oktober', 'en' => 'October', 'it' => 'Ottobre', 'fr' => 'Octobre'],
11 => ['de' => 'November', 'en' => 'November', 'it' => 'Novembre', 'fr' => 'Novembre'],
12 => ['de' => 'Dezember', 'en' => 'December', 'it' => 'Dicembre', 'fr' => 'Décembre'],
];
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 = VIDEO_DIR . "/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(VIDEO_DIR . "/daily_video_{$date}_*.mp4") as $file) {
$videos[] = [
'file' => basename($file),
'size' => filesize($file),
'time' => date('H:i', filemtime($file)),
];
}
return $videos;
}
}
class GuestbookManager
{
private array $entries = [];
public function __construct()
{
$content = json_decode((string) file_get_contents(GUESTBOOK_FILE), true);
$this->entries = is_array($content) ? $content : [];
}
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;
file_put_contents(GUESTBOOK_FILE, json_encode($this->entries, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
return $entry;
}
public function getEntries(int $limit = 10): array
{
return array_slice(array_reverse($this->entries), 0, $limit);
}
}
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 = '<h2>Aurora Kontakt</h2>' .
'<p><strong>Name:</strong> ' . $payload['name'] . '</p>' .
'<p><strong>E-Mail:</strong> ' . $payload['email'] . '</p>' .
'<p><strong>Nachricht:</strong><br>' . nl2br($payload['message']) . '</p>' .
'<hr><small>Gesendet am ' . $payload['date'] . ' | IP ' . $payload['ip'] . '</small>';
$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;
}
}
$languageManager = new LanguageManager();
$locale = $languageManager->getCurrentLocale();
$webcamManager = new WebcamManager();
$calendarManager = new VisualCalendarManager();
$guestbookManager = new GuestbookManager();
$contactManager = new ContactManager();
$adminManager = new AdminManager();
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 '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 '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 'set_language':
$_SESSION['lang'] = $_POST['language'] ?? 'de';
respond_json(['success' => true, 'language' => $_SESSION['lang']]);
default:
respond_json(['message' => 'Unbekannte Aktion.'], 400);
}
}
$translations = $languageManager->getAllTranslations();
?>
<!DOCTYPE html>
<html lang="<?= htmlspecialchars($locale) ?>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= htmlspecialchars($languageManager->get('title', $locale)) ?></title>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<style>
:root {
--sunrise-100: #fff9db;
--sunrise-200: #ffe782;
--sunrise-300: #ffcf5c;
--sunrise-400: #f9b233;
--sunrise-500: #f2921d;
--sky-500: #2c7be5;
--text-primary: #2c2c2c;
--text-secondary: #4c4c4c;
--card-bg: rgba(255, 255, 255, 0.85);
--shadow: 0 25px 45px rgba(255, 204, 0, 0.15);
--radius-lg: 24px;
--radius-md: 18px;
color-scheme: light;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: linear-gradient(135deg, var(--sunrise-200), var(--sunrise-500));
min-height: 100vh;
color: var(--text-primary);
display: flex;
flex-direction: column;
}
header {
padding: 48px 5vw 32px;
text-align: center;
color: var(--text-primary);
}
header h1 {
font-size: clamp(2.5rem, 5vw, 3.75rem);
margin: 0;
letter-spacing: -1px;
text-shadow: 0 12px 45px rgba(0,0,0,0.2);
}
header p {
margin: 16px auto 0;
max-width: 640px;
font-size: 1.1rem;
color: rgba(40,40,40,0.85);
}
main {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 32px;
padding: 0 5vw 80px;
}
section {
background: var(--card-bg);
backdrop-filter: blur(20px);
border-radius: var(--radius-lg);
padding: 24px 28px;
box-shadow: var(--shadow);
}
.hero-section {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
align-items: start;
}
.video-wrapper {
position: relative;
background: linear-gradient(135deg, rgba(255,255,255,0.9), rgba(255,255,255,0.6));
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: 0 30px 60px rgba(255, 165, 0, 0.25);
}
#webcamPlayer {
width: 100%;
aspect-ratio: 16/9;
display: block;
background: #000;
}
.player-controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 18px;
}
.control-btn {
flex: 1 1 160px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 14px 18px;
border-radius: var(--radius-md);
border: none;
font-weight: 600;
letter-spacing: 0.01em;
color: #1f1f1f;
cursor: pointer;
background: linear-gradient(135deg, var(--sunrise-300), var(--sunrise-500));
box-shadow: 0 16px 35px rgba(242, 146, 29, 0.2);
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.control-btn.secondary {
background: rgba(255, 255, 255, 0.95);
border: 1px solid rgba(0,0,0,0.06);
}
.control-btn:hover {
transform: translateY(-3px);
box-shadow: 0 25px 45px rgba(242, 146, 29, 0.25);
}
.meta-info {
display: grid;
gap: 16px;
}
.meta-card {
background: rgba(255,255,255,0.95);
border-radius: var(--radius-md);
padding: 18px;
box-shadow: inset 0 0 0 1px rgba(255, 204, 0, 0.2);
}
.meta-card h3 {
margin: 0 0 8px;
font-size: 1.05rem;
display: flex;
justify-content: space-between;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(44, 123, 229, 0.12);
color: var(--sky-500);
padding: 6px 12px;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 600;
}
.media-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
}
.media-grid img {
width: 100%;
border-radius: 14px;
aspect-ratio: 16/9;
object-fit: cover;
box-shadow: 0 12px 22px rgba(0,0,0,0.15);
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 10px;
margin-top: 16px;
}
.calendar-day {
padding: 12px 10px;
text-align: center;
border-radius: 16px;
background: rgba(255, 255, 255, 0.85);
min-height: 72px;
display: flex;
flex-direction: column;
justify-content: space-between;
font-weight: 600;
transition: transform 0.18s ease, box-shadow 0.18s ease;
cursor: pointer;
}
.calendar-day.has-video {
background: rgba(255, 255, 255, 0.95);
box-shadow: inset 0 0 0 2px rgba(242, 146, 29, 0.45);
}
.calendar-day.selected {
background: var(--sunrise-400);
color: white;
transform: translateY(-4px);
box-shadow: 0 12px 24px rgba(242, 146, 29, 0.3);
}
.calendar-day .count {
font-size: 0.75rem;
color: rgba(0,0,0,0.6);
}
form {
display: grid;
gap: 12px;
}
input, textarea, select {
border-radius: 14px;
border: 1px solid rgba(0,0,0,0.08);
padding: 12px 14px;
background: rgba(255,255,255,0.95);
font-size: 1rem;
}
textarea {
min-height: 140px;
resize: vertical;
}
.guestbook-entry {
padding: 16px 18px;
border-radius: 18px;
background: rgba(255,255,255,0.9);
box-shadow: inset 0 0 0 1px rgba(255, 204, 0, 0.2);
}
.language-switch {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.language-switch button {
border: none;
background: rgba(255,255,255,0.85);
padding: 8px 14px;
border-radius: 999px;
cursor: pointer;
font-weight: 600;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.08);
}
footer {
text-align: center;
padding: 32px 5vw 48px;
color: rgba(0,0,0,0.75);
font-size: 0.9rem;
}
@media (max-width: 768px) {
header {
padding-top: 36px;
}
.hero-section {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<header>
<h1><?= htmlspecialchars($languageManager->get('welcome', $locale)) ?></h1>
<p><?= htmlspecialchars($languageManager->get('subline', $locale)) ?></p>
</header>
<main>
<section class="hero-section">
<div class="video-wrapper">
<video id="webcamPlayer" playsinline muted></video>
<div class="player-controls">
<button class="control-btn" data-action="screenshot">
📸 <?= htmlspecialchars($languageManager->get('screenshot', $locale)) ?>
</button>
<button class="control-btn" data-action="clip">
🎬 <?= htmlspecialchars($languageManager->get('clip', $locale)) ?>
</button>
<a class="control-btn secondary" href="?download_video=latest">
⬇️ <?= htmlspecialchars($languageManager->get('download', $locale)) ?>
</a>
<button class="control-btn secondary" data-action="pip">
📺 <?= htmlspecialchars($languageManager->get('pip', $locale)) ?>
</button>
<button class="control-btn secondary" data-action="share">
☀️ <?= htmlspecialchars($languageManager->get('share', $locale)) ?>
</button>
</div>
</div>
<div class="meta-info">
<div class="meta-card">
<h3>
<span><?= htmlspecialchars($languageManager->get('stats', $locale)) ?></span>
<span class="badge" id="streamQuality">--</span>
</h3>
<p id="streamLatency" style="margin: 0; font-size: 0.95rem;">
--
</p>
<small id="streamUpdated" style="color: rgba(0,0,0,0.55);"></small>
</div>
<div class="meta-card">
<h3>
<span><?= htmlspecialchars($languageManager->get('language', $locale)) ?></span>
</h3>
<div class="language-switch" id="languageSwitch">
<?php foreach ($translations as $code => $values): ?>
<button type="button" data-lang="<?= htmlspecialchars($code) ?>" <?= $code === $locale ? 'style="background: var(--sunrise-400); color: white;"' : '' ?>><?= strtoupper($code) ?></button>
<?php endforeach; ?>
</div>
</div>
<div class="meta-card">
<h3><?= htmlspecialchars($languageManager->get('gallery', $locale)) ?></h3>
<div class="media-grid" id="imageGrid"></div>
</div>
</div>
</section>
<section>
<h2><?= htmlspecialchars($languageManager->get('calendar_title', $locale)) ?></h2>
<div class="calendar-grid" id="calendarGrid"></div>
<div id="calendarVideos" style="margin-top: 18px; display: grid; gap: 10px;"></div>
</section>
<section>
<h2><?= htmlspecialchars($languageManager->get('guestbook', $locale)) ?></h2>
<form id="guestbookForm">
<input type="hidden" name="action" value="guestbook_add">
<label>
<?= htmlspecialchars($languageManager->get('name', $locale)) ?>
<input type="text" name="name" required>
</label>
<label>
<?= htmlspecialchars($languageManager->get('comment', $locale)) ?>
<textarea name="message" required></textarea>
</label>
<label>
<?= htmlspecialchars($languageManager->get('rating', $locale)) ?>
<select name="rating">
<option value="5">★★★★★</option>
<option value="4">★★★★☆</option>
<option value="3">★★★☆☆</option>
<option value="2">★★☆☆☆</option>
<option value="1">★☆☆☆☆</option>
</select>
</label>
<button class="control-btn" type="submit" style="width: fit-content;">
✅ <?= htmlspecialchars($languageManager->get('add_entry', $locale)) ?>
</button>
</form>
<div id="guestbookEntries" style="margin-top: 18px; display: grid; gap: 14px;"></div>
</section>
<section>
<h2><?= htmlspecialchars($languageManager->get('contact', $locale)) ?></h2>
<form id="contactForm">
<input type="hidden" name="action" value="contact_send">
<label>
<?= htmlspecialchars($languageManager->get('name', $locale)) ?>
<input type="text" name="name" required>
</label>
<label>
<?= htmlspecialchars($languageManager->get('email', $locale)) ?>
<input type="email" name="email" required>
</label>
<label>
<?= htmlspecialchars($languageManager->get('message', $locale)) ?>
<textarea name="message" required></textarea>
</label>
<button class="control-btn" type="submit" style="width: fit-content;">
✉️ <?= htmlspecialchars($languageManager->get('send', $locale)) ?>
</button>
</form>
<div id="contactFeedback" style="margin-top: 14px; font-weight: 600;"></div>
</section>
</main>
<footer>
© <?= date('Y') ?> Aurora Weather Livecam · <?= htmlspecialchars($languageManager->get('privacy', $locale)) ?> · <a href="tiny.php" style="color: inherit; font-weight: 600;">tiny view</a>
</footer>
<script type="module">
const video = document.querySelector('#webcamPlayer');
const hlsSource = <?= json_encode($webcamManager->getVideoSrc()) ?>;
const isHlsNative = video.canPlayType('application/vnd.apple.mpegurl');
const controlButtons = document.querySelectorAll('.control-btn[data-action]');
const imageGrid = document.querySelector('#imageGrid');
const calendarGrid = document.querySelector('#calendarGrid');
const calendarVideos = document.querySelector('#calendarVideos');
const guestbookContainer = document.querySelector('#guestbookEntries');
const guestbookForm = document.querySelector('#guestbookForm');
const contactForm = document.querySelector('#contactForm');
const contactFeedback = document.querySelector('#contactFeedback');
const streamQuality = document.querySelector('#streamQuality');
const uploadBase = <?php echo json_encode('uploads/'); ?>;
const videoBase = <?php echo json_encode('videos/'); ?>;
const streamLatency = document.querySelector('#streamLatency');
const streamUpdated = document.querySelector('#streamUpdated');
async function fetchJSON(url, options = {}) {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error('Request failed');
}
return response.json();
}
async function populateImages() {
try {
const data = await fetchJSON('?api=images');
imageGrid.innerHTML = data.images.map(src => `<img src="${src}" loading="lazy" alt="Aurora capture">`).join('');
} catch (error) {
imageGrid.innerHTML = '<small>Keine Bilder verfügbar.</small>';
}
}
async function populateCalendar(year = new Date().getFullYear(), month = new Date().getMonth() + 1) {
const data = await fetchJSON(`?api=calendar&year=${year}&month=${month}`);
const { calendar } = data;
calendarGrid.innerHTML = '';
const firstDay = new Date(year, month - 1, 1).getDay();
const startIndex = firstDay === 0 ? 6 : firstDay - 1;
for (let i = 0; i < startIndex; i++) {
const placeholder = document.createElement('div');
calendarGrid.appendChild(placeholder);
}
calendar.days.forEach(day => {
const el = document.createElement('div');
el.className = `calendar-day${day.hasVideos ? ' has-video' : ''}`;
el.dataset.day = day.day;
el.innerHTML = `<span>${day.day}</span><span class="count">${day.count || ''}</span>`;
el.addEventListener('click', () => {
document.querySelectorAll('.calendar-day.selected').forEach(sel => sel.classList.remove('selected'));
el.classList.add('selected');
populateCalendarVideos(year, month, day.day);
});
calendarGrid.appendChild(el);
});
}
async function populateCalendarVideos(year, month, day) {
const data = await fetchJSON(`?api=calendar_videos&year=${year}&month=${month}&day=${day}`);
if (!data.videos.length) {
calendarVideos.innerHTML = '<small>Keine Videos für diesen Tag.</small>';
return;
}
calendarVideos.innerHTML = data.videos.map(video => `
<div class="guestbook-entry">
<strong>${video.time} Uhr</strong>
<span>${(video.size / (1024 * 1024)).toFixed(2)} MB</span>
<a class="control-btn secondary" style="margin-top:10px; text-decoration:none;" href="${videoBase + video.file}" download>Download</a>
</div>
`).join('');
}
async function populateGuestbook() {
const data = await fetchJSON('?api=guestbook');
guestbookContainer.innerHTML = data.entries.map(entry => `
<div class="guestbook-entry">
<strong>${entry.name}</strong>
<span>${'★'.repeat(entry.rating)}${'☆'.repeat(5 - entry.rating)}</span>
<p>${entry.message}</p>
<small>${entry.created}</small>
</div>
`).join('');
}
async function refreshStreamStats() {
const data = await fetchJSON('?api=stream_stats');
streamQuality.textContent = `${(data.stats.bitrate / 1000).toFixed(1)} Mbps`;
streamLatency.textContent = `Latency: ${data.stats.latency} s`;
streamUpdated.textContent = `Updated ${data.stats.updated}`;
}
function initPlayer() {
if (isHlsNative) {
video.src = hlsSource;
video.play().catch(() => {});
return;
}
if (window.Hls && window.Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
liveSyncDurationCount: 3,
maxLiveSyncPlaybackRate: 1.2,
});
hls.loadSource(hlsSource);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {}));
}
}
controlButtons.forEach(btn => {
btn.addEventListener('click', async () => {
const action = btn.dataset.action;
if (action === 'screenshot') {
btn.disabled = true;
const response = await fetchJSON('', {
method: 'POST',
body: new URLSearchParams({ action: 'capture_snapshot' })
}).catch(() => null);
if (response?.success) {
window.location.href = uploadBase + response.file;
}
btn.disabled = false;
}
if (action === 'clip') {
btn.disabled = true;
const response = await fetchJSON('', {
method: 'POST',
body: new URLSearchParams({ action: 'capture_clip', duration: 12 })
}).catch(() => null);
if (response?.success) {
window.location.href = uploadBase + response.file;
}
btn.disabled = false;
}
if (action === 'pip' && document.pictureInPictureEnabled) {
if (document.pictureInPictureElement) {
document.exitPictureInPicture();
} else {
video.requestPictureInPicture().catch(() => {});
}
}
if (action === 'share' && navigator.share) {
navigator.share({
title: document.title,
text: 'Aurora Weather Livecam',
url: window.location.href
}).catch(() => {});
}
});
});
guestbookForm.addEventListener('submit', async (event) => {
event.preventDefault();
const data = new FormData(guestbookForm);
const response = await fetchJSON('', {
method: 'POST',
body: new URLSearchParams(data)
}).catch(() => null);
if (response?.success) {
guestbookForm.reset();
populateGuestbook();
}
});
contactForm.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(contactForm);
const response = await fetchJSON('', {
method: 'POST',
body: new URLSearchParams(formData)
}).catch(() => null);
if (response) {
contactFeedback.textContent = response.message;
contactFeedback.style.color = response.success ? 'green' : 'red';
if (response.success) {
contactForm.reset();
}
}
});
document.querySelectorAll('#languageSwitch button').forEach(button => {
button.addEventListener('click', async () => {
const lang = button.dataset.lang;
await fetchJSON('', {
method: 'POST',
body: new URLSearchParams({ action: 'set_language', language: lang })
});
location.reload();
});
});
initPlayer();
populateImages();
populateCalendar();
populateGuestbook();
refreshStreamStats();
setInterval(refreshStreamStats, 15000);
</script>
</body>
</html>