diff --git a/Assets/online-sources.json b/Assets/online-sources.json
index 3f497be..316a0aa 100644
--- a/Assets/online-sources.json
+++ b/Assets/online-sources.json
@@ -47,13 +47,14 @@
"web": [
{ "name": "YouTube", "url": "https://www.youtube.com" },
{ "name": "ARD Mediathek", "url": "https://www.ardmediathek.de" },
- { "name": "ZDF Mediathek", "url": "https://www.zdf.de/serien-und-filme" },
+ { "name": "ZDF Mediathek", "url": "https://www.zdf.de/" },
{ "name": "Netflix", "url": "https://www.netflix.com" },
{ "name": "Disney+", "url": "https://www.disneyplus.com" },
{ "name": "Joyn", "url": "https://www.joyn.de" },
{ "name": "RTL+", "url": "https://plus.rtl.de" },
{ "name": "ARTE", "url": "https://www.arte.tv/de" },
{ "name": "Apple TV+", "url": "https://tv.apple.com" },
- { "name": "Amazon Prime Video", "url": "https://www.amazon.de/gp/video/storefront" }
+ { "name": "Amazon Prime Video", "url": "https://www.amazon.de/gp/video/storefront" },
+ { "name": "n-tv", "url": "https://www.n-tv.de" }
]
}
diff --git a/ClockWindow.xaml b/ClockWindow.xaml
new file mode 100644
index 0000000..ef4fe54
--- /dev/null
+++ b/ClockWindow.xaml
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/ClockWindow.xaml.cs b/ClockWindow.xaml.cs
new file mode 100644
index 0000000..169c1ef
--- /dev/null
+++ b/ClockWindow.xaml.cs
@@ -0,0 +1,13 @@
+using System.Windows;
+
+namespace FritzTV;
+
+public partial class ClockWindow : Window
+{
+ public ClockWindow()
+ {
+ InitializeComponent();
+ }
+
+ public void UpdateTime(string time) => TxtClock.Text = time;
+}
diff --git a/MainWindow.xaml b/MainWindow.xaml
index 9769afd..ef7e74d 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -227,41 +227,50 @@
-
+
+
+
+
+
+
-
-
+
+
+ HorizontalAlignment="Center" Visibility="Collapsed"/>
+ TextWrapping="Wrap" TextAlignment="Center" MaxWidth="600"/>
-
+
-
@@ -277,20 +286,11 @@
+ Cursor="Hand" Click="BtnCloseEpg_Click" ToolTip="Schließen (Esc)"/>
-
-
-
-
-
+ Foreground="#888" FontSize="11" Padding="24,8" Background="#0A0A0A"/>
-
-
-
-
+
+
-
+
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index 74dc07d..d07c37f 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -19,11 +19,12 @@ public partial class MainWindow : Window
private Media? _currentMedia;
private AppSettings _settings = AppSettings.Load();
private readonly EpgService _epgService = new();
+ private readonly ReminderService _reminderService = new();
private readonly LogoService _logoService = new();
private readonly ObservableCollection _allChannels = new();
private readonly ObservableCollection _filteredChannels = new();
- private string _currentCategory = "all";
+ private string _currentCategory = "fav"; // wird in MainWindow_Loaded aus Settings gesetzt
private string _searchTerm = "";
private Channel? _currentChannel;
private double _volumeBeforeMute = 80;
@@ -41,6 +42,11 @@ public partial class MainWindow : Window
// WebView2 für Web-Sender (YouTube, Netflix etc.)
private bool _webViewReady = false;
+ // Uhr
+ private readonly DispatcherTimer _clockTimer;
+ private bool _clockVisible = false;
+ private ClockWindow? _clockWindow;
+
// WndProc-Hook für globale Tastatureingaben (auch wenn WebView2 Fokus hat)
private System.Windows.Interop.HwndSource? _hwndSource;
@@ -56,6 +62,7 @@ public partial class MainWindow : Window
InitializeComponent();
LstChannels.ItemsSource = _filteredChannels;
TxtFritzBox.Text = $"FritzBox: {_settings.FritzBoxIp}";
+ _currentCategory = _settings.StartCategory;
SldVolume.Value = _settings.Volume;
DarkTitleBar.EnableFor(this);
@@ -66,28 +73,44 @@ public partial class MainWindow : Window
_epgTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
_epgTimer.Tick += EpgTimer_Tick;
- // Jede 10s: EIT-Daten aus Media neu lesen (libVLC feuert MetaChanged nicht bei laufendem EIT-Update)
- // Jede 60s: XMLTV Jetzt/Danach refreshen
- _epgRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(10) };
- var _epgRefreshTick = 0;
+ // Jede 60s: XMLTV Jetzt/Danach refreshen + EIT neu lesen
+ _epgRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(60) };
_epgRefreshTimer.Tick += (_, _) =>
{
if (_currentChannel == null) return;
+ // EIT auf Background-Thread lesen damit UI nicht blockiert
if (_currentChannel.Source == ChannelSource.FritzBox && _currentMedia != null)
- UpdateEpgFromMedia(_currentMedia);
- _epgRefreshTick++;
- if (_epgRefreshTick >= 6)
{
- _epgRefreshTick = 0;
- UpdateNextFromEpgService(_currentChannel);
+ var media = _currentMedia;
+ Task.Run(() =>
+ {
+ try
+ {
+ var nowPlaying = media.Meta(MetadataType.NowPlaying) ?? "";
+ var showName = media.Meta(MetadataType.ShowName) ?? "";
+ if (!string.IsNullOrWhiteSpace(nowPlaying) || !string.IsNullOrWhiteSpace(showName))
+ Dispatcher.BeginInvoke(() => UpdateEpgFromMedia(media));
+ }
+ catch { }
+ });
}
+ UpdateNextFromEpgService(_currentChannel);
};
_epgRefreshTimer.Start();
+ // Uhr-Timer (jede Sekunde)
+ _clockTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
+ _clockTimer.Tick += (_, _) =>
+ {
+ var time = DateTime.Now.ToString("HH:mm:ss");
+ TxtClock.Text = time;
+ _clockWindow?.UpdateTime(time);
+ PositionClockWindow();
+ };
+
Loaded += MainWindow_Loaded;
Closing += MainWindow_Closing;
KeyDown += MainWindow_KeyDown;
- PreviewKeyDown += MainWindow_KeyDown;
}
private void OnVideoDoubleClick(object sender, MouseButtonEventArgs e)
@@ -122,6 +145,22 @@ public partial class MainWindow : Window
_ = InitializeWebViewAsync();
await LoadChannelsAsync();
+ // Erinnerungs-Callback: Sender automatisch einschalten
+ _reminderService.SwitchToChannel = channelName =>
+ {
+ var ch = _allChannels.FirstOrDefault(c =>
+ c.Name.Equals(channelName, StringComparison.OrdinalIgnoreCase));
+ if (ch != null)
+ {
+ HideEpgOverlay();
+ var listEntry = _filteredChannels.FirstOrDefault(c =>
+ c.Name.Equals(channelName, StringComparison.OrdinalIgnoreCase));
+ if (listEntry != null)
+ LstChannels.SelectedItem = listEntry;
+ else
+ PlayChannel(ch);
+ }
+ };
RestoreLastChannel();
_ = LoadEpgInBackgroundAsync();
}
@@ -184,6 +223,8 @@ public partial class MainWindow : Window
_epgTimer.Stop();
_zapTimer.Stop();
_epgRefreshTimer.Stop();
+ _clockTimer.Stop();
+ _clockWindow?.Close();
_player?.Stop();
_settings.LastChannel = _currentChannel?.Name ?? "";
_settings.Volume = SldVolume.Value;
@@ -311,7 +352,7 @@ public partial class MainWindow : Window
"radio-fritz" => q.Where(c => c.Kind == ChannelKind.Radio && c.Source == ChannelSource.FritzBox),
"radio-online" => q.Where(c => c.Kind == ChannelKind.Radio && c.Source == ChannelSource.Online),
"web" => q.Where(c => c.Kind == ChannelKind.Web),
- "fav" => MergeHdSd(q.Where(c => c.IsFavorite)),
+ "fav" => q.Where(c => c.IsFavorite), // kein MergeHdSd - Web-Sender wuerden sonst rausgefiltert
_ => MergeHdSd(q.Where(c => c.Kind != ChannelKind.Web)) // "all" ohne Web
};
@@ -367,6 +408,8 @@ public partial class MainWindow : Window
TxtChannelSource.Foreground = ch.Source == ChannelSource.Online
? new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0x00, 0x78, 0xD4))
: new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0x66, 0xBB, 0x6A));
+ // Aufnahme stoppen wenn Sender wechselt
+ // if (_isRecording) StopRecording();
TxtNoChannel.Visibility = Visibility.Collapsed;
UpdateFavButton();
@@ -396,7 +439,7 @@ public partial class MainWindow : Window
_player.Media = media;
_player.Play();
- TxtEpgNow.Text = "EPG wird geladen…";
+ TxtEpgNow.Text = "EPG wird geladen\u2026";
TxtEpgNext.Text = "";
_epgTimer.Stop();
@@ -430,7 +473,7 @@ public partial class MainWindow : Window
if (_webViewReady)
{
- WebView.Source = new Uri(ch.Url);
+ if (_webViewReady) WebView.CoreWebView2.Navigate(ch.Url); else WebView.Source = new Uri(ch.Url);
}
else
{
@@ -440,7 +483,7 @@ public partial class MainWindow : Window
{
retryTimer.Stop();
if (_webViewReady && _currentChannel?.Kind == ChannelKind.Web)
- WebView.Source = new Uri(ch.Url);
+ if (_webViewReady) WebView.CoreWebView2.Navigate(ch.Url); else WebView.Source = new Uri(ch.Url);
};
retryTimer.Start();
}
@@ -540,8 +583,14 @@ public partial class MainWindow : Window
RadioFallbackIcon.Visibility = Visibility.Visible;
}
+ private DateTime _lastMetaUpdate = DateTime.MinValue;
+
private void OnMediaMetaChanged(object? sender, MediaMetaChangedEventArgs args)
{
+ // Throttle: maximal alle 2s verarbeiten damit der UI-Thread nicht geflutet wird
+ var now = DateTime.Now;
+ if ((now - _lastMetaUpdate).TotalSeconds < 2) return;
+ _lastMetaUpdate = now;
if (sender is Media m) Dispatcher.BeginInvoke(() => UpdateEpgFromMedia(m));
}
@@ -647,15 +696,82 @@ public partial class MainWindow : Window
{
_volumeBeforeMute = SldVolume.Value;
SldVolume.Value = 0;
+ if (_webViewReady && WebView.Visibility == Visibility.Visible)
+ WebView.CoreWebView2.ExecuteScriptAsync("document.querySelectorAll('video,audio').forEach(m => m.muted=true)");
}
else
{
SldVolume.Value = _volumeBeforeMute > 0 ? _volumeBeforeMute : 80;
+ if (_webViewReady && WebView.Visibility == Visibility.Visible)
+ WebView.CoreWebView2.ExecuteScriptAsync("document.querySelectorAll('video,audio').forEach(m => m.muted=false)");
}
}
private void BtnFullscreen_Click(object sender, RoutedEventArgs e) => ToggleFullscreen();
+ private void BtnAlwaysOnTop_Click(object sender, RoutedEventArgs e)
+ {
+ Topmost = !Topmost;
+ BtnAlwaysOnTop.Foreground = Topmost
+ ? new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0x00, 0x78, 0xD4))
+ : new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0x88, 0x88, 0x88));
+ BtnAlwaysOnTop.ToolTip = Topmost
+ ? "Immer im Vordergrund: AN (Strg+T)"
+ : "Immer im Vordergrund (Strg+T)";
+ }
+
+ private void BtnClock_Click(object sender, RoutedEventArgs e) => ToggleClock();
+
+ private void ToggleClock()
+ {
+ _clockVisible = !_clockVisible;
+ var time = DateTime.Now.ToString("HH:mm:ss");
+ if (_clockVisible)
+ {
+ // WPF-Overlay (fuer TV-Modus innerhalb VideoView)
+ TxtClock.Text = time;
+ ClockOverlay.Visibility = Visibility.Visible;
+
+ // Separates Fenster (fuer WebView-Modus, schwebt ueber allem)
+ _clockWindow = new ClockWindow { Owner = this };
+ _clockWindow.UpdateTime(time);
+ _clockWindow.Show();
+ // Nach Show() ist ActualWidth bekannt
+ _clockWindow.Dispatcher.BeginInvoke(() => PositionClockWindow(),
+ System.Windows.Threading.DispatcherPriority.Render);
+
+ _clockTimer.Start();
+ BtnClock.Foreground = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromRgb(0x00, 0x78, 0xD4));
+ }
+ else
+ {
+ ClockOverlay.Visibility = Visibility.Collapsed;
+ _clockWindow?.Close();
+ _clockWindow = null;
+ _clockTimer.Stop();
+ BtnClock.Foreground = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromRgb(0x88, 0x88, 0x88));
+ }
+ }
+
+ private void PositionClockWindow()
+ {
+ if (_clockWindow == null) return;
+ try
+ {
+ // Oben rechts im Player-Bereich positionieren
+ var area = VideoView.Visibility == Visibility.Visible
+ ? (System.Windows.FrameworkElement)VideoView
+ : WebView;
+ var pt = area.PointToScreen(new System.Windows.Point(area.ActualWidth, 0));
+ // 16px Margin, Fensterbreite abziehen (wird erst nach Show() bekannt)
+ _clockWindow.Left = pt.X - _clockWindow.ActualWidth - 16;
+ _clockWindow.Top = pt.Y + 16;
+ }
+ catch { }
+ }
+
private void ToggleFullscreen()
{
if (!_isFullscreen)
@@ -761,12 +877,26 @@ public partial class MainWindow : Window
private async void BtnEpgGrid_Click(object sender, RoutedEventArgs e)
{
+ // EPG-Overlay liegt innerhalb VideoView (HWND) - VideoView muss sichtbar sein
+ if (_currentChannel?.Kind == ChannelKind.Web && _webViewReady)
+ await WebView.CoreWebView2.ExecuteScriptAsync(
+ "document.querySelectorAll('video,audio').forEach(m => m.pause())");
+ WebView.Visibility = Visibility.Collapsed;
+ VideoView.Visibility = Visibility.Visible;
+ VideoOverlay.Visibility = Visibility.Visible;
await ShowEpgOverlayAsync();
}
private void BtnCloseEpg_Click(object sender, RoutedEventArgs e)
{
HideEpgOverlay();
+ // Wenn Web-Sender: WebView wieder zeigen
+ if (_currentChannel?.Kind == ChannelKind.Web)
+ {
+ VideoView.Visibility = Visibility.Collapsed;
+ VideoOverlay.Visibility = Visibility.Collapsed;
+ WebView.Visibility = Visibility.Visible;
+ }
}
// ────────── EPG-Overlay (Joyn-Style) ──────────
@@ -778,6 +908,8 @@ public partial class MainWindow : Window
private const int EpgTotalHours = 8;
private DateTime _epgStartTime;
+ private double _epgScrollH = 0;
+ private double _epgScrollV = 0;
private async Task ShowEpgOverlayAsync()
{
@@ -799,7 +931,7 @@ public partial class MainWindow : Window
}
TxtEpgOverlayStatus.Text = "Baue Programm\u2026";
- await BuildEpgGridAsync();
+ await BuildEpgGridAsync(resetScroll: true);
}
private void HideEpgOverlay()
@@ -808,8 +940,13 @@ public partial class MainWindow : Window
EpgCanvas.Children.Clear();
}
- private async Task BuildEpgGridAsync()
+
+ private async Task BuildEpgGridAsync(bool resetScroll = false)
{
+ _epgScrollH = EpgScrollViewer.HorizontalOffset;
+ _epgScrollV = EpgScrollViewer.VerticalOffset;
+ await Dispatcher.InvokeAsync(() => EpgCanvas.Children.Clear(),
+ System.Windows.Threading.DispatcherPriority.Background);
EpgCanvas.Children.Clear();
var channels = _allChannels
@@ -839,8 +976,16 @@ public partial class MainWindow : Window
}
TxtEpgOverlayStatus.Text = $"{channels.Count} Sender \u00b7 {EpgTotalHours} Stunden";
- EpgScrollViewer.ScrollToHorizontalOffset(0);
- EpgScrollViewer.ScrollToVerticalOffset(0);
+ if (resetScroll)
+ {
+ EpgScrollViewer.ScrollToHorizontalOffset(0);
+ EpgScrollViewer.ScrollToVerticalOffset(0);
+ }
+ else
+ {
+ EpgScrollViewer.ScrollToHorizontalOffset(_epgScrollH);
+ EpgScrollViewer.ScrollToVerticalOffset(_epgScrollV);
+ }
}
private void BuildEpgHeader(double totalMinutes, double contentWidth, double contentHeight)
@@ -993,10 +1138,17 @@ public partial class MainWindow : Window
BorderBrush = new System.Windows.Media.SolidColorBrush(borderColor),
BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(3),
Cursor = Cursors.Hand,
- ToolTip = $"{ev.StartTime:HH:mm}–{ev.EndTime:HH:mm}\n{ev.Title}" +
- (string.IsNullOrWhiteSpace(ev.Description) ? "" : $"\n\n{ev.Description}"),
- Tag = ch, Child = content
+ Tag = ch
};
+ box.Child = content;
+
+ // Tooltip mit Zeilenumbruch und Beschreibung
+ var ttText = $"{ev.StartTime:HH:mm}\u2013{ev.EndTime:HH:mm} {ev.Title}";
+ if (!string.IsNullOrWhiteSpace(ev.Description))
+ ttText += $"\n\n{ev.Description}";
+ var tt = new ToolTip { MaxWidth = 420, Content = new TextBlock { Text = ttText, TextWrapping = TextWrapping.Wrap, FontSize = 12 } };
+ ToolTipService.SetShowDuration(box, 30000);
+ box.ToolTip = tt;
box.MouseEnter += (_, _) => box.Background = new System.Windows.Media.SolidColorBrush(
isCurrent ? System.Windows.Media.Color.FromRgb(0xB8, 0x21, 0x3A)
@@ -1016,6 +1168,67 @@ public partial class MainWindow : Window
PlayChannel(target);
};
+ // Rechtsklick: Kontextmenu mit Erinnern-Option
+ box.MouseRightButtonDown += (_, args) =>
+ {
+ args.Handled = true;
+ var hasReminder = _reminderService.HasReminder(ch.Name, ev.StartTime);
+ var menu = new ContextMenu();
+
+ var remind = new MenuItem
+ {
+ Header = hasReminder ? "\u23f0 Erinnerung entfernen" : "\u23f0 5 min vorher erinnern",
+ FontSize = 13
+ };
+ remind.Click += (_, _) =>
+ {
+ if (hasReminder)
+ {
+ var existing = _reminderService.Reminders
+ .FirstOrDefault(r => r.ChannelName == ch.Name && r.StartTime == ev.StartTime);
+ if (existing != null) _reminderService.Remove(existing.Id);
+ }
+ else
+ {
+ _reminderService.Add(new Reminder
+ {
+ ChannelName = ch.Name,
+ Title = ev.Title,
+ Description = ev.Description ?? "",
+ StartTime = ev.StartTime,
+ EndTime = ev.EndTime,
+ MinutesBefore = 5
+ });
+ }
+ // Verzoegert neu bauen damit Event-Handler sauber abgeschlossen ist
+ Dispatcher.BeginInvoke(async () => await BuildEpgGridAsync(resetScroll: false),
+ System.Windows.Threading.DispatcherPriority.Background);
+ };
+ menu.Items.Add(remind);
+ box.ContextMenu = menu;
+ menu.IsOpen = true;
+ };
+
+ // Erinnerungs-Indikator: bell-Icon oben rechts
+ if (_reminderService.HasReminder(ch.Name, ev.StartTime))
+ {
+ // box.Child auf null damit content aus dem logical tree raus ist
+ box.Child = null;
+ var bell = new TextBlock
+ {
+ Text = "\u23f0",
+ FontSize = 11,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ VerticalAlignment = VerticalAlignment.Top,
+ Margin = new Thickness(0, 3, 4, 0),
+ IsHitTestVisible = false
+ };
+ var overlay = new Grid();
+ overlay.Children.Add(content);
+ overlay.Children.Add(bell);
+ box.Child = overlay;
+ }
+
return box;
}
@@ -1068,15 +1281,20 @@ public partial class MainWindow : Window
// WM_KEYDOWN abfangen damit Strg+B auch funktioniert wenn WebView2 den Fokus hat
private const int WM_KEYDOWN = 0x0100;
private const int VK_B = 0x42;
+ private const int VK_U = 0x55;
private IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
{
- if (msg == WM_KEYDOWN && (int)wParam == VK_B)
+ if (msg == WM_KEYDOWN)
{
- // GetKeyState(VK_CONTROL) - High-bit gesetzt = Taste gedrueckt
var ctrl = (NativeMethods.GetKeyState(0x11) & 0x8000) != 0;
if (ctrl)
- Dispatcher.BeginInvoke(() => BtnSidebarToggle_Click(this, new RoutedEventArgs()));
+ {
+ if ((int)wParam == VK_B)
+ Dispatcher.BeginInvoke(() => BtnSidebarToggle_Click(this, new RoutedEventArgs()));
+ else if ((int)wParam == VK_U)
+ Dispatcher.BeginInvoke(() => ToggleClock());
+ }
}
return IntPtr.Zero;
}
@@ -1101,8 +1319,19 @@ public partial class MainWindow : Window
switch (e.Key)
{
case Key.F11: ToggleFullscreen(); e.Handled = true; break;
+ case Key.T when Keyboard.Modifiers == ModifierKeys.Control:
+ BtnAlwaysOnTop_Click(this, new RoutedEventArgs()); e.Handled = true; break;
case Key.Escape:
- if (EpgOverlay.Visibility == Visibility.Visible) HideEpgOverlay();
+ if (EpgOverlay.Visibility == Visibility.Visible)
+ {
+ HideEpgOverlay();
+ if (_currentChannel?.Kind == ChannelKind.Web)
+ {
+ VideoView.Visibility = Visibility.Collapsed;
+ VideoOverlay.Visibility = Visibility.Collapsed;
+ WebView.Visibility = Visibility.Visible;
+ }
+ }
else if (_isFullscreen) ToggleFullscreen();
e.Handled = true;
break;
diff --git a/Models/Reminder.cs b/Models/Reminder.cs
new file mode 100644
index 0000000..f465581
--- /dev/null
+++ b/Models/Reminder.cs
@@ -0,0 +1,16 @@
+namespace FritzTV.Models;
+
+public class Reminder
+{
+ public Guid Id { get; set; } = Guid.NewGuid();
+ public required string ChannelName { get; set; }
+ public required string Title { get; set; }
+ public string Description { get; set; } = "";
+ public DateTime StartTime { get; set; }
+ public DateTime EndTime { get; set; }
+ public int MinutesBefore { get; set; } = 5;
+ public bool Fired { get; set; } = false;
+
+ /// Zeitpunkt zu dem die Benachrichtigung ausgelöst wird
+ public DateTime NotifyAt => StartTime.AddMinutes(-MinutesBefore);
+}
diff --git a/Services/AppPaths.cs b/Services/AppPaths.cs
index 6e55dc1..bdb7dce 100644
--- a/Services/AppPaths.cs
+++ b/Services/AppPaths.cs
@@ -36,4 +36,5 @@ public static class AppPaths
public static string Epg => Path.Combine(Root, "epg");
public static string CrashLog => Path.Combine(Root, "crash.log");
public static string WebView2Profile => Path.Combine(Root, "webview2");
+ public static string Reminders => Path.Combine(Root, "reminders.json");
}
diff --git a/Services/AppSettings.cs b/Services/AppSettings.cs
index c9ae0b8..87fd4c8 100644
--- a/Services/AppSettings.cs
+++ b/Services/AppSettings.cs
@@ -13,6 +13,9 @@ public class AppSettings
/// Online-Sender (ÖR-TV + Webradio aus Assets\online-sources.json) zusätzlich zur FritzBox-Liste anzeigen
public bool ShowOnlineSources { get; set; } = true;
+ /// Startkategorie beim App-Start (default: Favoriten)
+ public string StartCategory { get; set; } = "fav";
+
private static readonly string ConfigPath = AppPaths.Settings;
public static AppSettings Load()
diff --git a/Services/ReminderService.cs b/Services/ReminderService.cs
new file mode 100644
index 0000000..d21a571
--- /dev/null
+++ b/Services/ReminderService.cs
@@ -0,0 +1,107 @@
+using System.IO;
+using System.Text.Json;
+using System.Windows.Threading;
+using FritzTV.Models;
+
+namespace FritzTV.Services;
+
+public class ReminderService
+{
+ private static readonly string RemindersPath = AppPaths.Reminders;
+ private readonly DispatcherTimer _checkTimer;
+ private List _reminders = new();
+
+ /// Feuert wenn eine Erinnerung faellig ist. Callback laeuft auf UI-Thread.
+ public event Action? ReminderDue;
+
+ public IReadOnlyList Reminders => _reminders.AsReadOnly();
+
+ public ReminderService()
+ {
+ Load();
+ _checkTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) };
+ _checkTimer.Tick += (_, _) => CheckReminders();
+ _checkTimer.Start();
+ }
+
+ public void Add(Reminder reminder)
+ {
+ _reminders.Add(reminder);
+ Save();
+ }
+
+ public void Remove(Guid id)
+ {
+ _reminders.RemoveAll(r => r.Id == id);
+ Save();
+ }
+
+ public bool HasReminder(string channelName, DateTime startTime)
+ => _reminders.Any(r => r.ChannelName == channelName && r.StartTime == startTime);
+
+ private void CheckReminders()
+ {
+ var now = DateTime.Now;
+ var due = _reminders
+ .Where(r => !r.Fired && r.NotifyAt <= now && r.StartTime > now.AddMinutes(-30))
+ .ToList();
+
+ foreach (var r in due)
+ {
+ r.Fired = true;
+ FireNotification(r);
+ ReminderDue?.Invoke(r);
+ }
+
+ _reminders.RemoveAll(r => r.Fired && r.StartTime < now.AddHours(-1));
+ if (due.Any()) Save();
+ }
+
+ private void FireNotification(Reminder r)
+ {
+ try
+ {
+ var minLeft = (int)(r.StartTime - DateTime.Now).TotalMinutes;
+ var when = minLeft <= 0 ? "Jetzt" : $"In {minLeft} Minute{(minLeft == 1 ? "" : "n")}";
+ var msg = $"{when}: {r.Title}\n{r.ChannelName} {r.StartTime:HH:mm}\u2013{r.EndTime:HH:mm}";
+
+ var result = System.Windows.MessageBox.Show(
+ msg + "\n\nJetzt einschalten?",
+ "\u23f0 HomeStream-Erinnerung",
+ System.Windows.MessageBoxButton.YesNo,
+ System.Windows.MessageBoxImage.Information);
+
+ if (result == System.Windows.MessageBoxResult.Yes)
+ SwitchToChannel?.Invoke(r.ChannelName);
+ }
+ catch { }
+ }
+
+ /// Wird aufgerufen wenn User bei Erinnerung "Jetzt einschalten" klickt.
+ public Action? SwitchToChannel { get; set; }
+
+ private void Load()
+ {
+ try
+ {
+ if (File.Exists(RemindersPath))
+ {
+ var json = File.ReadAllText(RemindersPath);
+ _reminders = JsonSerializer.Deserialize>(json) ?? new();
+ _reminders.RemoveAll(r => r.StartTime < DateTime.Now.AddHours(-1));
+ }
+ }
+ catch { _reminders = new(); }
+ }
+
+ private void Save()
+ {
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(RemindersPath)!);
+ var json = JsonSerializer.Serialize(_reminders, new JsonSerializerOptions { WriteIndented = true });
+ File.WriteAllText(RemindersPath, json);
+ }
+ catch { }
+ }
+}
diff --git a/_wiki_home.md b/_wiki_home.md
new file mode 100644
index 0000000..75ec965
--- /dev/null
+++ b/_wiki_home.md
@@ -0,0 +1,291 @@
+# HomeStream – Benutzerhandbuch
+
+HomeStream ist ein DVB-C Streaming-Client für Windows, der AVM FRITZ!Box-Router als Quelle nutzt und zusätzlich öffentlich-rechtliche Online-Streams sowie Streaming-Dienste (YouTube, Netflix, Mediatheken) integriert.
+
+---
+
+## Inhaltsverzeichnis
+
+1. [Systemvoraussetzungen](#1-systemvoraussetzungen)
+2. [Installation](#2-installation)
+3. [Erster Start](#3-erster-start)
+4. [Oberfläche](#4-oberfläche)
+5. [Sender & Kategorien](#5-sender--kategorien)
+6. [Wiedergabe & Steuerung](#6-wiedergabe--steuerung)
+7. [Favoriten](#7-favoriten)
+8. [EPG – Programmführer](#8-epg--programmführer)
+9. [Erinnerungen](#9-erinnerungen)
+10. [Streaming-Browser](#10-streaming-browser)
+11. [Einstellungen](#11-einstellungen)
+12. [Tastenkürzel](#12-tastenkürzel)
+13. [Häufige Probleme](#13-häufige-probleme)
+
+---
+
+## 1. Systemvoraussetzungen
+
+| Anforderung | Details |
+|---|---|
+| Betriebssystem | Windows 10 oder Windows 11 (64-Bit) |
+| .NET-Runtime | Nicht erforderlich – selbst-enthaltener Build |
+| FRITZ!Box | DVB-C muss aktiviert und konfiguriert sein |
+| Netzwerk | App und FRITZ!Box im gleichen Heimnetzwerk |
+| WebView2 Runtime | Für Streaming-Browser (auf Win10/11 meist vorinstalliert) |
+
+---
+
+## 2. Installation
+
+1. Neueste Version von der [Release-Seite](https://www.dimedtec.net/dimedtec/HomeStream/releases) herunterladen
+2. ZIP-Datei entpacken (z.B. nach `C:\Programme\HomeStream\`)
+3. `HomeStream.exe` starten – keine Installation erforderlich
+
+> **Hinweis:** Windows SmartScreen kann beim ersten Start eine Warnung anzeigen. „Weitere Informationen" → „Trotzdem ausführen" wählen.
+
+---
+
+## 3. Erster Start
+
+Beim ersten Start lädt HomeStream automatisch:
+- Die Senderliste von der FRITZ!Box (`192.168.178.1` ist der Standard)
+- Online-Sender (ÖR-TV und Webradio) aus der integrierten Quellenliste
+- EPG-Daten von iptv-epg.org (~6 MB, einmalig pro Tag)
+
+Falls die FRITZ!Box-Adresse abweicht, unter **Einstellungen** die korrekte IP eintragen.
+
+---
+
+## 4. Oberfläche
+
+```
+┌─────────────┬──────────────────────┬────────────────────────────────┐
+│ Kategorien │ Senderliste │ Videobild │
+│ (Sidebar) │ │ │
+│ │ ▶ Das Erste HD │ │
+│ 📺 TV Fritz │ ZDF HD │ │
+│ 📺 TV Online│ 3sat HD │ │
+│ 📡 Radio │ ... │ │
+│ 🌐 Streaming│ │ │
+│ ⭐ Favoriten│ │ │
+│ 📅 Programm │ │ │
+├─────────────┴──────────────────────┴────────────────────────────────┤
+│ Das Erste HD ● FritzBox ▶ Jetzt: Tagesschau │
+│ 🔊 ────────────── 📌 ⛶ │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+**Bereiche:**
+- **Linke Spalte:** Kategorie-Buttons zur Filterung
+- **Mittlere Spalte:** Senderliste mit Suchfeld oben
+- **Hauptbereich:** Videobild / Streaming-Browser
+- **Untere Leiste:** Aktueller Sender, EPG-Info, Lautstärke, Buttons
+
+---
+
+## 5. Sender & Kategorien
+
+### Kategorien
+
+| Kategorie | Inhalt |
+|---|---|
+| 📺 TV (FritzBox) | DVB-C Fernsehen über die FRITZ!Box |
+| 📺 TV (Online) | Öffentlich-rechtliche TV-Streams (ARD, ZDF, 3sat, ARTE …) |
+| 📡 Radio (FritzBox) | DVB-C Radiosender über die FRITZ!Box |
+| 📡 Radio (Online) | Webradio (Bayern, DLF, WDR, NDR, SWR …) |
+| 🌐 Streaming | Browser-basierte Streaming-Dienste |
+| ⭐ Favoriten | Alle als Favorit markierten Sender |
+
+> Die App startet immer mit der **Favoriten**-Ansicht.
+
+### Sendersuche
+
+Im Suchfeld oben in der Senderliste tippen – die Liste filtert in Echtzeit.
+
+### Sidebar ein-/ausblenden
+
+**Strg+B** blendet die beiden linken Spalten ein oder aus. Wenn ausgeblendet, erscheint ein Hinweis in der Titelleiste.
+
+---
+
+## 6. Wiedergabe & Steuerung
+
+### Sender wechseln
+
+Sender in der Liste anklicken – nach einer kurzen Verzögerung (250 ms Debounce) startet die Wiedergabe.
+
+### Lautstärke
+
+- Schieberegler in der Statusleiste unten
+- **🔊-Button** für Stummschalten (Toggle)
+- Tastenkürzel: **M** für Stummschalten
+
+### Vollbild
+
+- **F11** oder Doppelklick auf das Video
+- **ESC** beendet den Vollbildmodus
+
+### Immer im Vordergrund
+
+- **📌-Button** (Pin) in der Statusleiste
+- Oder **Strg+T**
+- Aktiv: Button leuchtet blau
+
+---
+
+## 7. Favoriten
+
+- **⭐-Button** in der Statusleiste neben dem Sendernamen klicken
+- Favorit ist gesetzt wenn der Stern gefüllt ist (★)
+- Favoriten sind **medienübergreifend** – FritzBox-, Online- und Streaming-Sender können gleichermaßen als Favorit gespeichert werden
+- Favoriten werden in `%APPDATA%\HomeStream\settings.json` gespeichert
+
+---
+
+## 8. EPG – Programmführer
+
+### Aktuelles Programm
+
+Unter dem Sendernamen in der Statusleiste wird angezeigt:
+- **▶ Jetzt:** Aktuelle Sendung (FritzBox: aus EIT-Signal; Online: aus EPG-Daten)
+- Danach: Die nächsten 2–3 Sendungen mit Uhrzeit
+
+Diese Anzeige aktualisiert sich automatisch alle 60 Sekunden.
+
+### Programmübersicht (Grid)
+
+Klick auf **📅 Programm** in der Sidebar öffnet ein Joyn-ähnliches EPG-Grid:
+
+- Alle TV-Sender in einer Übersicht
+- 8 Stunden Programmvorschau
+- Aktuelle Sendung rot hervorgehoben
+- **Linksklick** auf eine Sendung: sofort einschalten
+- **Rechtsklick** auf eine Sendung: Erinnerung setzen
+- Tooltip zeigt Titel, Uhrzeit und Beschreibung
+
+**Navigation im Grid:**
+- Horizontales Scrollen: Mausrad oder Scrollbar
+- Vertikales Scrollen: Mausrad oder Scrollbar
+
+> EPG-Daten werden täglich frisch von [iptv-epg.org](https://iptv-epg.org) geladen und lokal in `%APPDATA%\HomeStream\epg\` gecacht.
+
+---
+
+## 9. Erinnerungen
+
+Im EPG-Grid eine zukünftige Sendung **rechts klicken** → „⏰ 5 min vorher erinnern".
+
+- Die Sendung zeigt ein ⏰-Symbol im Grid
+- 5 Minuten vor Sendungsstart erscheint eine Benachrichtigung
+- Auf **„Ja"** klicken schaltet automatisch auf den Sender um und schließt das EPG
+- Erinnerungen werden in `%APPDATA%\HomeStream\reminders.json` gespeichert und überleben App-Neustarts
+
+Zum Entfernen: Rechtsklick auf die Sendung → „⏰ Erinnerung entfernen".
+
+---
+
+## 10. Streaming-Browser
+
+Unter **🌐 Streaming** sind Browser-basierte Dienste verfügbar:
+
+| Dienst | URL |
+|---|---|
+| YouTube | youtube.com |
+| ARD Mediathek | ardmediathek.de |
+| ZDF Mediathek | zdf.de |
+| Netflix | netflix.com |
+| Disney+ | disneyplus.com |
+| Joyn | joyn.de |
+| RTL+ | plus.rtl.de |
+| ARTE | arte.tv/de |
+| Apple TV+ | tv.apple.com |
+| Amazon Prime Video | amazon.de/gp/video |
+| n-tv | n-tv.de |
+
+### Login speichern
+
+Der Streaming-Browser verwendet ein **persistentes WebView2-Profil** unter `%APPDATA%\HomeStream\webview2\`. Einmal einloggen genügt – der Login bleibt beim nächsten App-Start erhalten.
+
+### Steuerung im Browser-Modus
+
+- Normale Browser-Navigation per Maus
+- **Strg+B** öffnet die Sidebar wieder wenn sie eingeklappt ist
+- **🔊-Button** (Stummschalten) funktioniert auch im Browser-Modus
+- **📌-Button** (Immer im Vordergrund) funktioniert auch im Browser-Modus
+
+### Wechsel zurück zu TV
+
+Einfach einen TV- oder Radio-Sender in der Senderliste anklicken – der Browser wird automatisch gestoppt und der VLC-Player übernimmt.
+
+---
+
+## 11. Einstellungen
+
+Erreichbar über den **⚙-Button** in der Statusleiste.
+
+| Einstellung | Beschreibung |
+|---|---|
+| FritzBox-Adresse | IP oder Hostname der FRITZ!Box (Standard: 192.168.178.1) |
+| Online-Sender anzeigen | ÖR-TV und Webradio ein-/ausschalten |
+
+---
+
+## 12. Tastenkürzel
+
+| Taste | Funktion |
+|---|---|
+| **Strg+B** | Sidebar ein-/ausblenden |
+| **Strg+T** | Immer im Vordergrund an/aus |
+| **F11** | Vollbild an/aus |
+| **ESC** | Vollbild beenden / EPG schließen |
+| **M** | Stummschalten |
+| **↑ / ↓** | Sender in der Liste wechseln |
+
+---
+
+## 13. Häufige Probleme
+
+### Senderliste leer / FRITZ!Box nicht erreichbar
+
+- FRITZ!Box-Adresse in den Einstellungen prüfen
+- DVB-C in der FRITZ!Box aktiviert? → FRITZ!Box-Oberfläche → Heimnetz → Multimedia → DVB-C
+- App und FRITZ!Box im gleichen Netzwerk?
+- Online-Sender funktionieren auch ohne FRITZ!Box
+
+### Bild friert ein / ruckelt
+
+- FRITZ!Box neu starten
+- Prüfen ob ein anderes Gerät gleichzeitig streamt (FRITZ!Box erlaubt nur einen Stream pro IP-Adresse)
+- Im originalen VLC-Player testen: wenn dort auch Probleme auftreten, liegt es am DVB-Signal
+
+### EPG zeigt falsche Zeiten oder keine Daten
+
+- EPG-Cache löschen: `%APPDATA%\HomeStream\epg\` leeren
+- App neu starten – EPG wird automatisch neu geladen
+
+### Streaming-Browser startet nicht / WebView2 fehlt
+
+- WebView2 Runtime installieren: [Microsoft WebView2 Runtime](https://developer.microsoft.com/de-de/microsoft-edge/webview2/)
+- Auf Windows 10 Version 1803 oder neuer aktualisieren
+
+### Login im Streaming-Browser geht verloren
+
+- Das WebView2-Profil liegt in `%APPDATA%\HomeStream\webview2\`
+- Wird dieser Ordner gelöscht, gehen alle gespeicherten Logins verloren
+
+---
+
+## Datenspeicherung
+
+Alle Benutzerdaten werden lokal gespeichert unter `%APPDATA%\HomeStream\`:
+
+| Datei/Ordner | Inhalt |
+|---|---|
+| `settings.json` | Einstellungen, Favoriten, letzter Sender |
+| `reminders.json` | Gespeicherte EPG-Erinnerungen |
+| `epg\` | Gecachte EPG-Daten (täglich aktualisiert) |
+| `logos\` | Gecachte Sender-Logos |
+| `webview2\` | Browser-Profil (Logins, Cookies) |
+
+---
+
+*HomeStream wird entwickelt von [dimedtec GmbH](https://www.dimedtec.net) · [Quellcode & Releases](https://www.dimedtec.net/dimedtec/HomeStream)*
diff --git a/publish.ps1 b/publish.ps1
index 4b3daec..b80b4b5 100644
--- a/publish.ps1
+++ b/publish.ps1
@@ -77,16 +77,17 @@ if (-not $Tag) {
}
Write-Host "→ Forgejo-Release $Tag erstellen..."
-$headers = @{ 'Authorization' = "token $Token"; 'Content-Type' = 'application/json' }
-$body = @{
+$headers = @{ 'Authorization' = "token $Token"; 'Content-Type' = 'application/json; charset=utf-8' }
+$bodyObj = @{
tag_name = $Tag
name = "HomeStream $version"
- body = "Self-contained Release für Windows 10/11 (x64). Keine .NET-Installation nötig."
+ body = "Self-contained Release fuer Windows 10/11 (x64). Keine .NET-Installation noetig."
draft = $false
prerelease = $false
-} | ConvertTo-Json
-
-$release = Invoke-RestMethod -Uri "$ApiBase/releases" -Method Post -Headers $headers -Body $body
+}
+# Explizit UTF-8 kodieren damit Umlaute korrekt uebertragen werden
+$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes(($bodyObj | ConvertTo-Json))
+$release = Invoke-RestMethod -Uri "$ApiBase/releases" -Method Post -Headers $headers -Body $bodyBytes
Write-Host " Release-ID: $($release.id)"
# ── 5. ZIP als Asset hochladen ────────────────────────────────────────────