Compare commits

...

25 commits
v0.1.2 ... main

Author SHA1 Message Date
84203db798 Aufnahme-Feature entfernt - libVLC sout-duplicate Hall-Problem nicht loesbar 2026-05-21 23:37:44 +02:00
282e5c3f5a Aufnahme: select=noaudio fuer display-dst 2026-05-21 23:34:08 +02:00
2ac99425af Aufnahme: --no-sout-audio verhindert Hall-Effekt beim Liveton 2026-05-21 23:20:36 +02:00
75526a7d43 Aufnahme: sout-duplicate - Livebild laeuft weiter waehrend Aufnahme 2026-05-21 23:01:21 +02:00
b0e9e1022f Aufnahme: Start/Stop Button, libVLC sout in Videos\HomeStream, REC-Badge 2026-05-21 22:47:51 +02:00
b9b5d1c923 WebView: CoreWebView2.Navigate erzwingt Navigation zur definierten URL 2026-05-17 23:01:27 +02:00
4880b25e90 ZDF Mediathek URL korrigiert 2026-05-17 22:57:30 +02:00
dbd7aad30d EPG: Scroll-Position nach Erinnerung-Rebuild beibehalten 2026-05-17 17:53:16 +02:00
951e02f0fb EPG: nach Senderwechsel durch Erinnerung automatisch schliessen 2026-05-17 17:44:23 +02:00
d033411dfc EPG-Erinnerung: Jetzt einschalten Button wechselt automatisch den Sender 2026-05-17 17:39:37 +02:00
4db5dd6f23 EPG-Erinnerung: logical child Fehler behoben 2026-05-17 17:26:20 +02:00
b3d1e2e851 EPG-Erinnerung: Rebuild via BeginInvoke verzögert, Canvas-Clear Race-Condition behoben 2026-05-17 17:24:12 +02:00
5bf1f06bfe EPG-Erinnerungen: Rechtsklick auf Sendung, Glocke-Indikator, MessageBox-Benachrichtigung 2026-05-17 16:55:16 +02:00
faa852016d Favoriten: Web-Sender nicht durch MergeHdSd filtern 2026-05-16 16:27:39 +02:00
5e8014c127 Favoriten medienuebergreifend, Startkategorie Favoriten 2026-05-13 12:39:49 +02:00
6e10f0b979 EPG: EIT alle 60s auf Background-Thread - kein UI-Block 2026-05-13 11:36:39 +02:00
6249c6d149 EPG wieder eingebaut - Problem war FritzBox-Reboot noetig, nicht EPG-Code 2026-05-13 01:21:54 +02:00
27b5b6805f MetaChanged throttle 2s - verhindert UI-Flooding durch DVB-Metadaten 2026-05-13 00:43:11 +02:00
58ffc7300b Revert: EIT-Polling entfernt (Einfrieren), Cache zurueck auf 1000ms 2026-05-13 00:34:46 +02:00
5e211cc038 VLC: 300ms Cache fuer RTSP/FritzBox, clock-jitter deaktiviert 2026-05-13 00:31:45 +02:00
d0f9df0a64 EPG-Overlay zurueck in VideoView (HWND-Fix), WebView wird beim EPG-Oeffnen versteckt 2026-05-13 00:09:21 +02:00
d22808497d publish.ps1: UTF-8 Encoding fuer API-Body 2026-05-12 23:44:30 +02:00
15e7182c22 EPG-Overlay aus VideoView raus - funktioniert jetzt auch ueber WebView und TV 2026-05-12 22:51:25 +02:00
d2eeffd200 EPG: WebView pausieren bei EPG-Overlay, zurueck zu WebView beim Schliessen 2026-05-12 22:30:34 +02:00
c1e832e08d EPG-Grid: Tooltip mit TextWrapping und 30s ShowDuration 2026-05-12 22:19:07 +02:00
8 changed files with 309 additions and 59 deletions

View file

@ -47,7 +47,7 @@
"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" },

View file

@ -227,41 +227,39 @@
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- VideoView + WebView2: beide im gleichen Grid-Cell, Visibility steuert wer sichtbar ist -->
<!-- VideoView + WebView2 + EPG-Overlay: alle im gleichen Grid-Cell -->
<Grid Grid.Row="0">
<!-- VLC-Player fuer TV/Radio -->
<vlc:VideoView x:Name="VideoView" Background="Black">
<Border x:Name="VideoOverlay"
Background="#01000000"
PreviewMouseLeftButtonDown="VideoClickCatcher_DoubleClick">
<Grid>
<!-- Radio-Cover bei Audio-Streams — INNERHALB VideoView damit es die native HWND überdeckt -->
<Border x:Name="RadioCover" Visibility="Collapsed"
Background="#1A1A1A">
<!-- Radio-Cover bei Audio-Streams -->
<Border x:Name="RadioCover" Visibility="Collapsed" Background="#1A1A1A">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<Image x:Name="RadioLogo" Width="200" Height="200" Stretch="Uniform"
RenderOptions.BitmapScalingMode="HighQuality"/>
<TextBlock x:Name="RadioFallbackIcon" Text="📻"
FontSize="120" Foreground="#444"
HorizontalAlignment="Center"
Visibility="Collapsed"/>
HorizontalAlignment="Center" Visibility="Collapsed"/>
<TextBlock x:Name="TxtRadioName"
FontSize="24" FontWeight="Bold" Foreground="White"
HorizontalAlignment="Center" Margin="0,24,0,0"/>
<TextBlock x:Name="TxtRadioText"
FontSize="16" Foreground="#0078D4" FontWeight="SemiBold"
HorizontalAlignment="Center" Margin="24,12,24,0"
TextWrapping="Wrap" TextAlignment="Center"
MaxWidth="600"/>
TextWrapping="Wrap" TextAlignment="Center" MaxWidth="600"/>
</StackPanel>
</Border>
<!-- EPG-Overlay (Joyn-Style): liegt INNERHALB der VideoView damit Klicks ber HWND funktionieren -->
<!-- EPG-Overlay: INNERHALB VideoView (HWND-Grenze)
VideoView wird beim EPG-Oeffnen immer sichtbar gemacht,
auch wenn WebView aktiv war -->
<Border x:Name="EpgOverlay" Visibility="Collapsed"
Background="#D8000000">
<DockPanel>
<!-- Header: Programm + Datum/Zeit + Schließen -->
<Grid DockPanel.Dock="Top" Background="#0A0A0A" Height="60">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
@ -277,20 +275,11 @@
<Button x:Name="BtnCloseEpg" Content="✕" FontSize="18"
Width="40" Height="40"
Background="Transparent" Foreground="White" BorderThickness="0"
Cursor="Hand"
Click="BtnCloseEpg_Click"
ToolTip="Schließen (Esc)"/>
Cursor="Hand" Click="BtnCloseEpg_Click" ToolTip="Schließen (Esc)"/>
</StackPanel>
</Grid>
<!-- Status-Zeile (Lade EPG… etc.) -->
<TextBlock x:Name="TxtEpgOverlayStatus" DockPanel.Dock="Bottom"
Foreground="#888" FontSize="11" Padding="24,8"
Background="#0A0A0A"/>
<!-- Scrollbares EPG-Grid (Canvas) -->
<!-- ScrollBar-Style explizit hier weil vlc:VideoView eine eigene HWND hat
und Window.Resources-Styles dort nicht greifen -->
Foreground="#888" FontSize="11" Padding="24,8" Background="#0A0A0A"/>
<ScrollViewer x:Name="EpgScrollViewer"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
@ -346,14 +335,9 @@
</Border>
</vlc:VideoView>
<!-- WebView2 fuer Web-Sender (YouTube, Netflix, Mediatheken etc.) -->
<!-- Persistentes User-Data-Profil: Login bleibt erhalten -->
<!-- Liegt VOR dem VideoView im Z-Order, damit Klicks nicht vom VideoOverlay gefangen werden -->
<wv2:WebView2 x:Name="WebView"
Visibility="Collapsed"
Panel.ZIndex="5"/>
<!-- WebView2 fuer Web-Sender -->
<wv2:WebView2 x:Name="WebView" Visibility="Collapsed" Panel.ZIndex="5"/>
<!-- Kein Burger-Button mehr - Strg+B zum Einblenden der Seitenleiste -->
<TextBlock x:Name="TxtNoChannel"
Text="Wähle einen Sender aus der Liste"
Foreground="#666" FontSize="18"

View file

@ -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<Channel> _allChannels = new();
private readonly ObservableCollection<Channel> _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;
@ -56,6 +57,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,21 +68,28 @@ 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();
@ -122,6 +131,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();
}
@ -311,7 +336,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 +392,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 +423,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 +457,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 +467,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 +567,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));
}
@ -761,12 +794,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 +825,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 +848,7 @@ public partial class MainWindow : Window
}
TxtEpgOverlayStatus.Text = "Baue Programm\u2026";
await BuildEpgGridAsync();
await BuildEpgGridAsync(resetScroll: true);
}
private void HideEpgOverlay()
@ -808,8 +857,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 +893,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 +1055,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 +1085,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;
}
@ -1073,7 +1203,6 @@ public partial class MainWindow : Window
{
if (msg == WM_KEYDOWN && (int)wParam == VK_B)
{
// GetKeyState(VK_CONTROL) - High-bit gesetzt = Taste gedrueckt
var ctrl = (NativeMethods.GetKeyState(0x11) & 0x8000) != 0;
if (ctrl)
Dispatcher.BeginInvoke(() => BtnSidebarToggle_Click(this, new RoutedEventArgs()));
@ -1102,7 +1231,16 @@ public partial class MainWindow : Window
{
case Key.F11: ToggleFullscreen(); 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;

16
Models/Reminder.cs Normal file
View file

@ -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;
/// <summary>Zeitpunkt zu dem die Benachrichtigung ausgelöst wird</summary>
public DateTime NotifyAt => StartTime.AddMinutes(-MinutesBefore);
}

View file

@ -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");
}

View file

@ -13,6 +13,9 @@ public class AppSettings
/// <summary>Online-Sender (ÖR-TV + Webradio aus Assets\online-sources.json) zusätzlich zur FritzBox-Liste anzeigen</summary>
public bool ShowOnlineSources { get; set; } = true;
/// <summary>Startkategorie beim App-Start (default: Favoriten)</summary>
public string StartCategory { get; set; } = "fav";
private static readonly string ConfigPath = AppPaths.Settings;
public static AppSettings Load()

107
Services/ReminderService.cs Normal file
View file

@ -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<Reminder> _reminders = new();
/// <summary>Feuert wenn eine Erinnerung faellig ist. Callback laeuft auf UI-Thread.</summary>
public event Action<Reminder>? ReminderDue;
public IReadOnlyList<Reminder> 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 { }
}
/// <summary>Wird aufgerufen wenn User bei Erinnerung "Jetzt einschalten" klickt.</summary>
public Action<string>? SwitchToChannel { get; set; }
private void Load()
{
try
{
if (File.Exists(RemindersPath))
{
var json = File.ReadAllText(RemindersPath);
_reminders = JsonSerializer.Deserialize<List<Reminder>>(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 { }
}
}

View file

@ -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 ────────────────────────────────────────────