diff --git a/.gitignore b/.gitignore
index 0625409..6b96325 100644
--- a/.gitignore
+++ b/.gitignore
@@ -18,6 +18,9 @@ logs/
# Temp
*.tmp
*.temp
+response.json
+_*.ps1
+_delete/
# OS
Thumbs.db
diff --git a/Assets/online-sources.json b/Assets/online-sources.json
new file mode 100644
index 0000000..af63201
--- /dev/null
+++ b/Assets/online-sources.json
@@ -0,0 +1,59 @@
+{
+ "_comment": "Online-Streams fuer HomeStream. URLs Stand Mai 2026, Quelle: harryshomepage.de + rundfunkforum.",
+ "tv": [
+ { "name": "Das Erste HD", "url": "https://daserste-live.ard-mcdn.de/daserste/live/hls/de/master.m3u8", "kind": "TvHd" },
+ { "name": "ZDF HD", "url": "https://zdf-hls-15.akamaized.net/hls/live/2016498/de/veryhigh/master.m3u8", "kind": "TvHd" },
+ { "name": "3sat HD", "url": "https://zdf-hls-18.akamaized.net/hls/live/2016501/dach/veryhigh/master.m3u8", "kind": "TvHd" },
+ { "name": "ARTE HD", "url": "https://artesimulcast.akamaized.net/hls/live/2030993/artelive_de/master.m3u8", "kind": "TvHd" },
+ { "name": "ARD-alpha HD", "url": "https://mcdn.br.de/br/fs/ard_alpha/hls/de/master.m3u8", "kind": "TvHd" },
+ { "name": "ONE HD", "url": "https://mcdn-one.ard.de/ardone/hls/master.m3u8", "kind": "TvHd" },
+ { "name": "ZDFneo HD", "url": "https://zdf-hls-16.akamaized.net/hls/live/2016499/de/veryhigh/master.m3u8", "kind": "TvHd" },
+ { "name": "ZDFinfo HD", "url": "https://zdf-hls-17.akamaized.net/hls/live/2016500/de/veryhigh/master.m3u8", "kind": "TvHd" },
+ { "name": "Tagesschau24 HD", "url": "https://tagesschau.akamaized.net/hls/live/2020117/tagesschau/tagesschau_3/master.m3u8", "kind": "TvHd" },
+ { "name": "Phoenix HD", "url": "https://zdf-hls-19.akamaized.net/hls/live/2016502/de/veryhigh/master.m3u8", "kind": "TvHd" },
+ { "name": "BR Sued HD", "url": "https://livestreams.br.de/i/bfssued_germany@119890/master.m3u8", "kind": "TvHd" },
+ { "name": "WDR HD", "url": "https://wdr-wdrfernsehen-koeln.icecast.wdr.de/wdr/wdrfernsehen/koeln/hls/master.m3u8", "kind": "TvHd" },
+ { "name": "NDR HD", "url": "https://ndrfs-lh.akamaihd.net/i/ndrfs_nds@430233/master.m3u8", "kind": "TvHd" },
+ { "name": "HR HD", "url": "https://hr-hrfernsehen-live.akamaized.net/hls/live/2016105/hrfernsehen/master.m3u8", "kind": "TvHd" },
+ { "name": "MDR Sachsen HD", "url": "https://mdrsnhls-lh.akamaihd.net/i/livetvmdr_sachsen@439432/master.m3u8", "kind": "TvHd" },
+ { "name": "RBB Berlin HD", "url": "https://rbb_berlin-lh.akamaihd.net/i/rbbBerlin_Live@380294/master.m3u8", "kind": "TvHd" },
+ { "name": "SWR BW HD", "url": "https://swrbw-lh.akamaihd.net/i/swrbw_live@196738/master.m3u8", "kind": "TvHd" },
+ { "name": "SR Fernsehen HD", "url": "https://live2_sr-lh.akamaihd.net/i/sr_universal02@107595/master.m3u8", "kind": "TvHd" },
+ { "name": "KiKA HD", "url": "https://kikageohls.akamaized.net/hls/live/2022693/livetvkika_de/master.m3u8", "kind": "TvHd" },
+ { "name": "Deutsche Welle", "url": "https://dwamdstream102.akamaized.net/hls/live/2015525/dwstream102/index.m3u8", "kind": "TvHd" }
+ ],
+ "radio": [
+ { "name": "Bayern 1", "url": "https://dispatcher.rndfnk.com/br/br1/obb/mp3/high" },
+ { "name": "Bayern 2", "url": "https://dispatcher.rndfnk.com/br/br2/sued/mp3/high" },
+ { "name": "Bayern 3", "url": "https://dispatcher.rndfnk.com/br/br3/live/mp3/high" },
+ { "name": "BR Klassik", "url": "https://dispatcher.rndfnk.com/br/brklassik/live/mp3/high" },
+ { "name": "B5 aktuell", "url": "https://dispatcher.rndfnk.com/br/br5/live/mp3/high" },
+ { "name": "Deutschlandfunk", "url": "https://st01.sslstream.dlf.de/dlf/01/128/mp3/stream.mp3" },
+ { "name": "Deutschlandfunk Kultur", "url": "https://st02.sslstream.dlf.de/dlf/02/128/mp3/stream.mp3" },
+ { "name": "Deutschlandfunk Nova", "url": "https://st03.sslstream.dlf.de/dlf/03/128/mp3/stream.mp3" },
+ { "name": "WDR 2", "url": "https://wdr-wdr2-rheinland.icecastssl.wdr.de/wdr/wdr2/rheinland/mp3/128/stream.mp3" },
+ { "name": "WDR 4", "url": "https://wdr-wdr4-live.icecastssl.wdr.de/wdr/wdr4/live/mp3/128/stream.mp3" },
+ { "name": "1LIVE", "url": "https://wdr-1live-live.icecastssl.wdr.de/wdr/1live/live/mp3/128/stream.mp3" },
+ { "name": "NDR 2", "url": "https://icecast.ndr.de/ndr/ndr2/niedersachsen/mp3/128/stream.mp3" },
+ { "name": "N-JOY", "url": "https://icecast.ndr.de/ndr/njoy/live/mp3/128/stream.mp3" },
+ { "name": "SWR1 BW", "url": "https://liveradio.swr.de/sw282p3/swr1bw/play.mp3" },
+ { "name": "SWR3", "url": "https://liveradio.swr.de/sw282p3/swr3/play.mp3" },
+ { "name": "HR1", "url": "https://hr-hr1-live.cast.addradio.de/hr/hr1/live/mp3/128/stream.mp3" },
+ { "name": "HR3", "url": "https://hr-hr3-live.cast.addradio.de/hr/hr3/live/mp3/128/stream.mp3" },
+ { "name": "MDR Sachsen", "url": "https://mdr-284350-0.cast.mdr.de/mdr/284350/0/mp3/high/stream.mp3" },
+ { "name": "RBB Radio Eins", "url": "https://rbb-radioeins-live.cast.addradio.de/rbb/radioeins/live/mp3/128/stream.mp3" },
+ { "name": "Antenne Bayern", "url": "https://stream.antenne.de/antenne/stream/mp3" }
+ ],
+ "web": [
+ { "name": "YouTube", "url": "https://www.youtube.com" },
+ { "name": "ARD Mediathek", "url": "https://www.ardmediathek.de" },
+ { "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" }
+ ]
+}
diff --git a/HomeStream.csproj b/HomeStream.csproj
index 219c2d9..ef15772 100644
--- a/HomeStream.csproj
+++ b/HomeStream.csproj
@@ -19,6 +19,7 @@
+
@@ -27,4 +28,11 @@
+
+
+
+ PreserveNewest
+
+
+
diff --git a/MainWindow.xaml b/MainWindow.xaml
index 7cae4fc..11eb2cf 100644
--- a/MainWindow.xaml
+++ b/MainWindow.xaml
@@ -1,6 +1,7 @@
-
-
-
+
-
+
+
+
+
+
-
+
+
@@ -217,49 +227,39 @@
-
+
+
+
-
-
-
-
+
+
+ HorizontalAlignment="Center" Visibility="Collapsed"/>
+ TextWrapping="Wrap" TextAlignment="Center" MaxWidth="600"/>
-
+
-
@@ -275,22 +275,58 @@
+ Cursor="Hand" Click="BtnCloseEpg_Click" ToolTip="Schließen (Esc)"/>
-
-
-
-
+ Foreground="#888" FontSize="11" Padding="24,8" Background="#0A0A0A"/>
+
+
+
@@ -298,6 +334,10 @@
+
+
+
+
-
+
+
+
+
diff --git a/MainWindow.xaml.cs b/MainWindow.xaml.cs
index f7ccce1..9dc63c9 100644
--- a/MainWindow.xaml.cs
+++ b/MainWindow.xaml.cs
@@ -8,6 +8,7 @@ using System.Windows.Threading;
using FritzTV.Models;
using FritzTV.Services;
using LibVLCSharp.Shared;
+using Microsoft.Web.WebView2.Core;
namespace FritzTV;
@@ -18,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;
@@ -34,6 +36,15 @@ public partial class MainWindow : Window
// EPG-Fallback Timer (falls MetaChanged nicht feuert)
private readonly DispatcherTimer _epgTimer;
+ // Periodischer Refresh: aktualisiert Jetzt/Danach wenn Sendung endet
+ private readonly DispatcherTimer _epgRefreshTimer;
+
+ // WebView2 für Web-Sender (YouTube, Netflix etc.)
+ private bool _webViewReady = false;
+
+ // WndProc-Hook für globale Tastatureingaben (auch wenn WebView2 Fokus hat)
+ private System.Windows.Interop.HwndSource? _hwndSource;
+
// Pre-Fullscreen UI-State
private WindowStyle _prevStyle = WindowStyle.SingleBorderWindow;
private WindowState _prevState = WindowState.Normal;
@@ -46,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);
@@ -56,14 +68,39 @@ public partial class MainWindow : Window
_epgTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
_epgTimer.Tick += EpgTimer_Tick;
+ // 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)
+ {
+ 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();
+
Loaded += MainWindow_Loaded;
Closing += MainWindow_Closing;
KeyDown += MainWindow_KeyDown;
+ PreviewKeyDown += MainWindow_KeyDown;
}
private void OnVideoDoubleClick(object sender, MouseButtonEventArgs e)
{
- // Doppelklick auf VideoView → Vollbild
ToggleFullscreen();
e.Handled = true;
}
@@ -81,28 +118,58 @@ public partial class MainWindow : Window
private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
- InitializePlayer();
- await LoadChannelsAsync();
- RestoreLastChannel();
+ // WndProc-Hook registrieren damit Strg+B auch greift wenn WebView2 Fokus hat
+ _hwndSource = System.Windows.Interop.HwndSource.FromHwnd(
+ new System.Windows.Interop.WindowInteropHelper(this).Handle);
+ _hwndSource?.AddHook(WndProc);
- // EPG im Hintergrund laden — wird für "Danach"-Anzeige und EPG-Overlay gebraucht
+ var initialVolume = (int)SldVolume.Value;
+ await Task.Run(() => InitializePlayerNatives(initialVolume));
+ VideoView.MediaPlayer = _player;
+
+ // WebView2 async initialisieren (persistentes Profil in %APPDATA%\HomeStream\webview2)
+ _ = 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();
}
- private async Task LoadEpgInBackgroundAsync()
+ private async Task InitializeWebViewAsync()
{
- if (_epgService.IsCurrent) return;
try
{
- await _epgService.LoadAsync();
- // Nach EPG-Load: "Danach" für aktuellen Sender setzen
- if (_currentChannel != null)
- Dispatcher.BeginInvoke(() => UpdateNextFromEpgService(_currentChannel));
+ var profileDir = AppPaths.WebView2Profile;
+ Directory.CreateDirectory(profileDir);
+ var env = await CoreWebView2Environment.CreateAsync(null, profileDir);
+ await WebView.EnsureCoreWebView2Async(env);
+ _webViewReady = true;
+ }
+ catch (Exception ex)
+ {
+ // WebView2 nicht verfügbar — Web-Sender werden deaktiviert
+ System.Diagnostics.Debug.WriteLine($"WebView2 init failed: {ex.Message}");
}
- catch { }
}
- private void InitializePlayer()
+ /// Background-Thread: libVLC-Natives laden. Kein UI-Zugriff.
+ private void InitializePlayerNatives(int initialVolume)
{
var exeDir = Path.GetDirectoryName(
System.Reflection.Assembly.GetExecutingAssembly().Location)!;
@@ -115,19 +182,33 @@ public partial class MainWindow : Window
"--network-caching=1000",
"--no-video-title-show",
"--no-osd",
- "--no-snapshot-preview"
+ "--no-snapshot-preview",
+ "--rtsp-tcp"
);
_player = new MediaPlayer(_libVLC);
- VideoView.MediaPlayer = _player;
- _player.Volume = (int)SldVolume.Value;
+ _player.Volume = initialVolume;
+ }
+
+ private async Task LoadEpgInBackgroundAsync()
+ {
+ if (_epgService.IsCurrent) return;
+ try
+ {
+ await _epgService.LoadAsync();
+ if (_currentChannel != null)
+ Dispatcher.BeginInvoke(() => UpdateNextFromEpgService(_currentChannel));
+ }
+ catch { }
}
private void MainWindow_Closing(object? sender, CancelEventArgs e)
{
try
{
+ _hwndSource?.RemoveHook(WndProc);
_epgTimer.Stop();
_zapTimer.Stop();
+ _epgRefreshTimer.Stop();
_player?.Stop();
_settings.LastChannel = _currentChannel?.Name ?? "";
_settings.Volume = SldVolume.Value;
@@ -146,13 +227,31 @@ public partial class MainWindow : Window
TxtStatus.Text = "Lade Senderliste…";
try
{
- var client = new FritzBoxClient(_settings.FritzBoxIp);
- var channels = await client.LoadAllAsync();
+ var fritzClient = new FritzBoxClient(_settings.FritzBoxIp);
+ var onlineClient = new OnlineSourceClient();
+
+ var fritzTask = fritzClient.LoadAllAsync();
+ var onlineTask = _settings.ShowOnlineSources
+ ? onlineClient.LoadAllAsync()
+ : Task.FromResult(new List());
+
+ List fritzChannels;
+ try
+ {
+ fritzChannels = await fritzTask;
+ }
+ catch (Exception ex)
+ {
+ fritzChannels = new List();
+ TxtStatus.Text = $"FritzBox-Fehler: {ex.Message}";
+ }
+ var onlineChannels = await onlineTask;
+
+ var channels = fritzChannels.Concat(onlineChannels).ToList();
foreach (var ch in channels)
{
ch.IsFavorite = _settings.Favorites.Contains(ch.Name);
- // Cached Logo gleich setzen, fehlende werden async geladen
ch.LogoPath = _logoService.GetCachedLogoPath(ch.Name);
}
@@ -160,23 +259,23 @@ public partial class MainWindow : Window
foreach (var ch in channels) _allChannels.Add(ch);
ApplyFilter();
- TxtStatus.Text = $"{channels.Count} Sender geladen";
+ TxtStatus.Text = onlineChannels.Count > 0
+ ? $"{fritzChannels.Count} FritzBox + {onlineChannels.Count} Online = {channels.Count} Sender"
+ : $"{fritzChannels.Count} Sender geladen";
- // Logos im Hintergrund nachladen — UI updated automatisch via INotifyPropertyChanged
_ = LoadLogosInBackgroundAsync();
}
catch (Exception ex)
{
TxtStatus.Text = $"Fehler: {ex.Message}";
MessageBox.Show(
- $"Fehler beim Laden der Senderliste von {_settings.FritzBoxIp}:\n\n{ex.Message}",
- "FRITZ!TV", MessageBoxButton.OK, MessageBoxImage.Warning);
+ $"Fehler beim Laden der Senderliste:\n\n{ex.Message}",
+ "HomeStream", MessageBoxButton.OK, MessageBoxImage.Warning);
}
}
private async Task LoadLogosInBackgroundAsync()
{
- // Parallel laden, aber begrenzt (max 6 gleichzeitig) damit AVM nicht zum DDoS wird
using var sem = new SemaphoreSlim(6);
var tasks = _allChannels
.Where(c => c.LogoPath == null)
@@ -186,7 +285,6 @@ public partial class MainWindow : Window
try
{
var path = await _logoService.GetLogoPathAsync(ch.Name);
- // Property-Change läuft über INotifyPropertyChanged → UI updated
await Dispatcher.InvokeAsync(() => ch.LogoPath = path);
}
finally { sem.Release(); }
@@ -233,31 +331,29 @@ public partial class MainWindow : Window
q = _currentCategory switch
{
- "tv" => MergeHdSd(q.Where(c => c.Kind != ChannelKind.Radio)),
- "radio" => q.Where(c => c.Kind == ChannelKind.Radio),
- "fav" => MergeHdSd(q.Where(c => c.IsFavorite)),
- _ => MergeHdSd(q)
+ "tv-fritz" => MergeHdSd(q.Where(c => c.Kind != ChannelKind.Radio && c.Kind != ChannelKind.Web && c.Source == ChannelSource.FritzBox)),
+ "tv-online" => MergeHdSd(q.Where(c => c.Kind != ChannelKind.Radio && c.Kind != ChannelKind.Web && c.Source == ChannelSource.Online)),
+ "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" => q.Where(c => c.IsFavorite), // kein MergeHdSd - Web-Sender wuerden sonst rausgefiltert
+ _ => MergeHdSd(q.Where(c => c.Kind != ChannelKind.Web)) // "all" ohne Web
};
if (!string.IsNullOrEmpty(_searchTerm))
q = q.Where(c => c.Name.Contains(_searchTerm, StringComparison.OrdinalIgnoreCase));
- // Alle Listen alphabetisch
q = q.OrderBy(c => NormalizeName(c.Name), StringComparer.OrdinalIgnoreCase);
_filteredChannels.Clear();
foreach (var c in q) _filteredChannels.Add(c);
}
- ///
- /// HD + SD zusammenfassen: gleicher Basisname → HD-Variante bevorzugen.
- /// Radio-Sender werden nicht angefasst.
- ///
private static IEnumerable MergeHdSd(IEnumerable source)
{
var list = source.ToList();
var radio = list.Where(c => c.Kind == ChannelKind.Radio);
- var tv = list.Where(c => c.Kind != ChannelKind.Radio);
+ var tv = list.Where(c => c.Kind != ChannelKind.Radio && c.Kind != ChannelKind.Web);
var merged = tv
.GroupBy(c => NormalizeName(c.Name), StringComparer.OrdinalIgnoreCase)
@@ -266,9 +362,6 @@ public partial class MainWindow : Window
return merged.Concat(radio);
}
- ///
- /// Sendername normalisieren für Sort & Merge: " HD"-Suffix weg, klein, getrimmt.
- ///
private static string NormalizeName(string name)
{
var s = name.Trim();
@@ -285,7 +378,6 @@ public partial class MainWindow : Window
{
if (LstChannels.SelectedItem is Channel ch)
{
- // Debounce: schnelle ↑/↓-Navigation soll nicht jeden Sender starten
_pendingChannel = ch;
_zapTimer.Stop();
_zapTimer.Start();
@@ -294,17 +386,36 @@ public partial class MainWindow : Window
private void PlayChannel(Channel ch)
{
+ _currentChannel = ch;
+ TxtCurrentChannel.Text = ch.Name;
+ TxtChannelSource.Text = ch.Source == ChannelSource.Online ? "● Online" : "● FritzBox";
+ 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();
+
+ if (ch.Kind == ChannelKind.Web)
+ {
+ PlayWebChannel(ch);
+ return;
+ }
+
+ // VLC-Player aktiv, WebView stoppen und verstecken
+ if (WebView.Visibility == Visibility.Visible && _webViewReady)
+ WebView.CoreWebView2?.ExecuteScriptAsync("document.querySelectorAll('video,audio').forEach(m => m.pause())");
+ WebView.Visibility = Visibility.Collapsed;
+ VideoView.Visibility = Visibility.Visible;
+ VideoOverlay.Visibility = Visibility.Visible;
+
if (_libVLC == null || _player == null) return;
try
{
- _currentChannel = ch;
-
- // Radio-Cover (zeigt Logo + Sendername statt weißes Bild)
UpdateRadioCover(ch);
- // KEIN Stop() — libVLC ersetzt Media bei laufendem Player ohne Frame-Lücke.
var oldMedia = _currentMedia;
-
var media = new Media(_libVLC, new Uri(ch.Url));
media.MetaChanged += OnMediaMetaChanged;
@@ -312,19 +423,14 @@ public partial class MainWindow : Window
_player.Media = media;
_player.Play();
- TxtCurrentChannel.Text = ch.Name;
- TxtEpgNow.Text = "EPG wird geladen…";
+ TxtEpgNow.Text = "EPG wird geladen\u2026";
TxtEpgNext.Text = "";
- TxtNoChannel.Visibility = Visibility.Collapsed;
- UpdateFavButton();
_epgTimer.Stop();
_epgTimer.Tag = (DateTime.Now, ch);
_epgTimer.Start();
try { oldMedia?.Dispose(); } catch { }
-
- // "Danach"-Liste aus XMLTV-EPG vorbefüllen (überlebt bis EIT-Daten kommen)
UpdateNextFromEpgService(ch);
}
catch (Exception ex)
@@ -333,37 +439,80 @@ public partial class MainWindow : Window
}
}
- /// Zeigt die nächsten 3 Sendungen aus dem XMLTV-EPG in TxtEpgNext.
+ private void PlayWebChannel(Channel ch)
+ {
+ // VLC stoppen, VideoOverlay deaktivieren damit Klicks ins WebView durchkommen
+ _player?.Stop();
+ VideoView.Visibility = Visibility.Collapsed;
+ RadioCover.Visibility = Visibility.Collapsed;
+ VideoOverlay.Visibility = Visibility.Collapsed;
+ WebView.Visibility = Visibility.Visible;
+ // Titelleiste via ApplySidebarLayout gesetzt
+
+ TxtEpgNow.Text = "🌐 Browser";
+ TxtEpgNext.Text = "";
+ TxtChannelSource.Text = "● Web";
+ TxtChannelSource.Foreground = new System.Windows.Media.SolidColorBrush(
+ System.Windows.Media.Color.FromRgb(0xFF, 0x99, 0x00));
+
+ if (_webViewReady)
+ {
+ if (_webViewReady) WebView.CoreWebView2.Navigate(ch.Url); else WebView.Source = new Uri(ch.Url);
+ }
+ else
+ {
+ // WebView noch nicht bereit — kurz warten und retry
+ var retryTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
+ retryTimer.Tick += (_, _) =>
+ {
+ retryTimer.Stop();
+ if (_webViewReady && _currentChannel?.Kind == ChannelKind.Web)
+ if (_webViewReady) WebView.CoreWebView2.Navigate(ch.Url); else WebView.Source = new Uri(ch.Url);
+ };
+ retryTimer.Start();
+ }
+ }
+
private void UpdateNextFromEpgService(Channel ch)
{
- if (ch.Kind == ChannelKind.Radio) return; // Radio: kein EPG
+ if (ch.Kind == ChannelKind.Radio || ch.Kind == ChannelKind.Web) return;
- // EPG noch nicht geladen? Hintergrund-Load anstossen, kommt später wieder
if (!_epgService.IsCurrent)
{
_ = LoadEpgInBackgroundAsync();
return;
}
- var events = _epgService.GetEvents(ch.Name, hoursAhead: 12)
- .Where(e => e.StartTime > DateTime.Now)
- .Take(3)
- .ToList();
+ var allEvents = _epgService.GetEvents(ch.Name, hoursAhead: 12);
- if (events.Count > 0)
+ if (ch.Source == ChannelSource.Online)
{
- TxtEpgNext.Text = string.Join(" · ",
- events.Select(e => $"{e.StartTime:HH:mm} {e.Title}"));
+ var current = allEvents.FirstOrDefault(e => e.IsCurrent);
+ if (current != null)
+ {
+ TxtEpgNow.Text = $"▶ Jetzt: {current.Title}";
+ _epgTimer.Stop();
+ }
+ else if (TxtEpgNow.Text == "EPG wird geladen\u2026")
+ {
+ TxtEpgNow.Text = "(kein EPG verfügbar)";
+ _epgTimer.Stop();
+ }
}
+ else if (ch.Source == ChannelSource.FritzBox)
+ {
+ var current = allEvents.FirstOrDefault(e => e.IsCurrent);
+ if (current != null && (TxtEpgNow.Text == "EPG wird geladen\u2026" || TxtEpgNow.Text == "(kein EPG verfügbar)"))
+ TxtEpgNow.Text = $"▶ Jetzt: {current.Title}";
+ }
+
+ var next = allEvents.Where(e => e.StartTime > DateTime.Now).Take(3).ToList();
+ if (next.Count > 0)
+ TxtEpgNext.Text = string.Join(" · ", next.Select(e => $"{e.StartTime:HH:mm} {e.Title}"));
}
- ///
- /// Bei Radio-Sendern Cover-Bild anzeigen, bei TV verbergen.
- /// libVLC zeigt sonst bei reinen Audio-Streams einen weißen Default-Hintergrund.
- ///
private void UpdateRadioCover(Channel ch)
{
- // Bisher gebundener Channel abhängen damit wir nicht doppelt hören
if (_radioCoverBoundChannel != null)
_radioCoverBoundChannel.PropertyChanged -= OnCurrentChannelPropertyChanged;
_radioCoverBoundChannel = null;
@@ -374,8 +523,6 @@ public partial class MainWindow : Window
TxtRadioName.Text = ch.Name;
TxtRadioText.Text = "";
ApplyRadioLogo(ch);
-
- // Anhören falls Logo erst später (async) geladen wird
ch.PropertyChanged += OnCurrentChannelPropertyChanged;
_radioCoverBoundChannel = ch;
}
@@ -413,27 +560,38 @@ public partial class MainWindow : Window
RadioFallbackIcon.Visibility = Visibility.Collapsed;
return;
}
- catch { /* fallthrough zu Fallback-Icon */ }
+ catch { }
}
RadioLogo.Source = null;
RadioLogo.Visibility = Visibility.Collapsed;
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));
}
private void EpgTimer_Tick(object? sender, EventArgs e)
{
if (_currentMedia == null) { _epgTimer.Stop(); return; }
+ if (_currentChannel?.Source == ChannelSource.Online || _currentChannel?.Kind == ChannelKind.Web)
+ {
+ _epgTimer.Stop();
+ return;
+ }
if (_epgTimer.Tag is (DateTime started, Channel ch))
{
if ((DateTime.Now - started).TotalSeconds > 30)
{
_epgTimer.Stop();
- if (TxtEpgNow.Text == "EPG wird geladen…")
+ if (TxtEpgNow.Text == "EPG wird geladen\u2026")
TxtEpgNow.Text = "(kein EPG verfügbar)";
return;
}
@@ -445,19 +603,12 @@ public partial class MainWindow : Window
{
try
{
- // EIT-Daten (TV) oder RDS-Daten (Radio) aus dem Stream:
- // NowPlaying → TV: aktuelle Sendung; Radio: "Künstler - Titel"
- // ShowName → TV: manchmal Titel; Radio: meist Sendername
- // Description → TV: Untertitel
- // Title → Radio: Track-Titel (RDS RadioText+)
- // Artist → Radio: Künstler
var nowPlaying = m.Meta(MetadataType.NowPlaying) ?? "";
var showName = m.Meta(MetadataType.ShowName) ?? "";
var description = m.Meta(MetadataType.Description) ?? "";
var title = m.Meta(MetadataType.Title) ?? "";
var artist = m.Meta(MetadataType.Artist) ?? "";
- // URLs ausfiltern (libVLC packt manchmal die Stream-URL rein)
if (description.StartsWith("rtsp:", StringComparison.OrdinalIgnoreCase) ||
description.StartsWith("http:", StringComparison.OrdinalIgnoreCase))
description = "";
@@ -469,14 +620,6 @@ public partial class MainWindow : Window
if (isRadio)
{
- // Radio-RDS: verschiedene Sender befüllen unterschiedliche Felder.
- // Reihenfolge der Versuche:
- // 1. Artist + Title kombiniert (sauberster Fall)
- // 2. NowPlaying (oft "Künstler - Titel")
- // 3. Title alleine
- // 4. Artist alleine
- // 5. Description (manche Sender packen RDS-Text dort rein)
- // 6. ShowName (Fallback)
string nowText = "";
if (!string.IsNullOrWhiteSpace(artist) && !string.IsNullOrWhiteSpace(title))
nowText = $"{artist.Trim()} – {title.Trim()}";
@@ -501,7 +644,6 @@ public partial class MainWindow : Window
}
else
{
- // TV: EIT NowPlaying / ShowName
string nowText = !string.IsNullOrWhiteSpace(nowPlaying) ? nowPlaying : showName;
if (!string.IsNullOrWhiteSpace(nowText))
@@ -511,15 +653,12 @@ public partial class MainWindow : Window
jetzt += $" · {description.Trim()}";
TxtEpgNow.Text = $"▶ Jetzt: {jetzt}";
- // "Danach" lassen wir aus dem XMLTV-EPG (3 Sendungen) — das ist informativer
- // als die einzelne EIT-ShowName-Angabe. Nur fallback wenn EPG-Service leer ist.
if (string.IsNullOrEmpty(TxtEpgNext.Text))
{
var next = (!string.IsNullOrWhiteSpace(showName) && showName != nowPlaying)
? showName : "";
TxtEpgNext.Text = string.IsNullOrWhiteSpace(next) ? "" : $"⏭ Danach: {next.Trim()}";
}
-
_epgTimer.Stop();
}
}
@@ -561,7 +700,6 @@ public partial class MainWindow : Window
SidebarColumn.Width = new GridLength(0);
ChannelsColumn.Width = new GridLength(0);
BottomBar.Visibility = Visibility.Collapsed;
- BtnSidebarToggleOverlay.Visibility = Visibility.Collapsed;
ResizeMode = ResizeMode.NoResize;
WindowStyle = WindowStyle.None;
@@ -577,7 +715,6 @@ public partial class MainWindow : Window
ChannelsColumn.Width = new GridLength(280);
}
BottomBar.Visibility = Visibility.Visible;
- BtnSidebarToggleOverlay.Visibility = _sidebarHidden ? Visibility.Visible : Visibility.Collapsed;
ResizeMode = _prevResize;
WindowStyle = _prevStyle;
@@ -610,7 +747,6 @@ public partial class MainWindow : Window
}
else
{
- // ItemTemplate-Refresh ohne Stream-Reset
if (LstChannels.ItemContainerGenerator.ContainerFromItem(_currentChannel)
is ListBoxItem lbi)
{
@@ -649,7 +785,7 @@ public partial class MainWindow : Window
{
if (_currentChannel == null)
{
- MessageBox.Show("Erst einen Sender auswählen.", "FRITZ!TV");
+ MessageBox.Show("Erst einen Sender auswählen.", "HomeStream");
return;
}
var w = new EpgChannelWindow(_epgService, _currentChannel.Name) { Owner = this };
@@ -658,31 +794,46 @@ 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) ──────────
- // Layout-Konstanten fürs Grid
- private const double EpgPxPerMin = 4.0; // 4 px/min → 240 px/h
- private const double EpgChannelColWidth = 200; // Senderspalte links (Logo + Name)
- private const double EpgRowHeight = 64; // jede Senderzeile
- private const double EpgHeaderHeight = 40; // Zeit-Header oben
- private const int EpgTotalHours = 8; // 8 Stunden sichtbar/scrollbar
+ private const double EpgPxPerMin = 4.0;
+ private const double EpgChannelColWidth = 200;
+ private const double EpgRowHeight = 64;
+ private const double EpgHeaderHeight = 40;
+ private const int EpgTotalHours = 8;
private DateTime _epgStartTime;
+ private double _epgScrollH = 0;
+ private double _epgScrollV = 0;
private async Task ShowEpgOverlayAsync()
{
EpgOverlay.Visibility = Visibility.Visible;
- TxtEpgOverlayStatus.Text = "Lade EPG-Daten…";
+ EpgCanvas.Children.Clear();
+ TxtEpgOverlayStatus.Text = "Lade EPG-Daten\u2026";
- // Datum/Zeit-Anzeige im Header aktualisieren
TxtEpgDate.Text = DateTime.Now.ToString("d. MMM", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
TxtEpgTime.Text = DateTime.Now.ToString("HH:mm");
@@ -696,7 +847,8 @@ public partial class MainWindow : Window
}
}
- BuildEpgGrid();
+ TxtEpgOverlayStatus.Text = "Baue Programm\u2026";
+ await BuildEpgGridAsync(resetScroll: true);
}
private void HideEpgOverlay()
@@ -705,19 +857,22 @@ public partial class MainWindow : Window
EpgCanvas.Children.Clear();
}
- private void BuildEpgGrid()
+
+ 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();
- // Sender: TV-only, HD vor SD, alphabetisch wie in der Senderliste
var channels = _allChannels
- .Where(c => c.Kind != ChannelKind.Radio)
+ .Where(c => c.Kind != ChannelKind.Radio && c.Kind != ChannelKind.Web)
.GroupBy(c => NormalizeName(c.Name), StringComparer.OrdinalIgnoreCase)
.Select(g => g.FirstOrDefault(c => c.Kind == ChannelKind.TvHd) ?? g.First())
.OrderBy(c => NormalizeName(c.Name), StringComparer.OrdinalIgnoreCase)
.ToList();
- // Aktuelles Sendefenster: ab letzter ganzer Stunde
_epgStartTime = DateTime.Now.Date.AddHours(DateTime.Now.Hour);
var totalMinutes = EpgTotalHours * 60;
var contentWidth = EpgChannelColWidth + totalMinutes * EpgPxPerMin;
@@ -729,40 +884,45 @@ public partial class MainWindow : Window
BuildEpgHeader(totalMinutes, contentWidth, contentHeight);
BuildEpgNowLine(totalMinutes, contentHeight);
+ const int batchSize = 10;
for (int i = 0; i < channels.Count; i++)
+ {
BuildEpgRow(channels[i], i, contentWidth);
+ if (i % batchSize == batchSize - 1)
+ await Dispatcher.InvokeAsync(() => { }, System.Windows.Threading.DispatcherPriority.Background);
+ }
- TxtEpgOverlayStatus.Text = $"{channels.Count} Sender · {EpgTotalHours} Stunden";
-
- // Immer ganz nach links scrollen wenn Overlay geöffnet wird
- EpgScrollViewer.ScrollToHorizontalOffset(0);
- EpgScrollViewer.ScrollToVerticalOffset(0);
+ TxtEpgOverlayStatus.Text = $"{channels.Count} Sender \u00b7 {EpgTotalHours} Stunden";
+ if (resetScroll)
+ {
+ EpgScrollViewer.ScrollToHorizontalOffset(0);
+ EpgScrollViewer.ScrollToVerticalOffset(0);
+ }
+ else
+ {
+ EpgScrollViewer.ScrollToHorizontalOffset(_epgScrollH);
+ EpgScrollViewer.ScrollToVerticalOffset(_epgScrollV);
+ }
}
private void BuildEpgHeader(double totalMinutes, double contentWidth, double contentHeight)
{
- // Header-Hintergrund
var bg = new System.Windows.Shapes.Rectangle
{
- Width = contentWidth,
- Height = EpgHeaderHeight,
+ Width = contentWidth, Height = EpgHeaderHeight,
Fill = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0x10, 0x10, 0x10))
};
Canvas.SetLeft(bg, 0); Canvas.SetTop(bg, 0);
EpgCanvas.Children.Add(bg);
- // Stunden-Marker + vertikale Trennlinien
for (int h = 0; h <= EpgTotalHours; h++)
{
var t = _epgStartTime.AddHours(h);
var x = EpgChannelColWidth + h * 60 * EpgPxPerMin;
-
var label = new TextBlock
{
- Text = t.ToString("HH:mm"),
- Foreground = System.Windows.Media.Brushes.White,
- FontSize = 13,
- FontWeight = FontWeights.SemiBold
+ Text = t.ToString("HH:mm"), Foreground = System.Windows.Media.Brushes.White,
+ FontSize = 13, FontWeight = FontWeights.SemiBold
};
Canvas.SetLeft(label, x + 6); Canvas.SetTop(label, 11);
EpgCanvas.Children.Add(label);
@@ -797,11 +957,9 @@ public partial class MainWindow : Window
{
var y = EpgHeaderHeight + rowIndex * EpgRowHeight;
- // Sender-Spalte links: Logo + Name
var nameCell = new Border
{
- Width = EpgChannelColWidth,
- Height = EpgRowHeight,
+ Width = EpgChannelColWidth, Height = EpgRowHeight,
Background = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0x16, 0x16, 0x16)),
BorderBrush = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0x22, 0x22, 0x22)),
BorderThickness = new Thickness(0, 0, 1, 1)
@@ -824,10 +982,8 @@ public partial class MainWindow : Window
}
var nameTb = new TextBlock
{
- Text = ch.Name,
- Foreground = System.Windows.Media.Brushes.White,
- FontSize = 12,
- FontWeight = FontWeights.SemiBold,
+ Text = ch.Name, Foreground = System.Windows.Media.Brushes.White,
+ FontSize = 12, FontWeight = FontWeights.SemiBold,
VerticalAlignment = VerticalAlignment.Center,
Padding = new Thickness(8, 0, 8, 0),
TextTrimming = TextTrimming.CharacterEllipsis
@@ -838,17 +994,14 @@ public partial class MainWindow : Window
Canvas.SetLeft(nameCell, 0); Canvas.SetTop(nameCell, y);
EpgCanvas.Children.Add(nameCell);
- // Zeilen-Hintergrund (rechts der Sendernamen)
var rowBg = new System.Windows.Shapes.Rectangle
{
- Width = contentWidth - EpgChannelColWidth,
- Height = EpgRowHeight,
+ Width = contentWidth - EpgChannelColWidth, Height = EpgRowHeight,
Fill = new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0x1A, 0x1A, 0x1A))
};
Canvas.SetLeft(rowBg, EpgChannelColWidth); Canvas.SetTop(rowBg, y);
EpgCanvas.Children.Add(rowBg);
- // Events für diesen Sender
var events = _epgService.GetEvents(ch.Name, hoursAhead: EpgTotalHours);
var totalMinutes = EpgTotalHours * 60;
foreach (var ev in events)
@@ -873,91 +1026,131 @@ public partial class MainWindow : Window
{
var isCurrent = ev.IsCurrent;
var bgColor = isCurrent
- ? System.Windows.Media.Color.FromRgb(0x9E, 0x1B, 0x32) // Joyn-Rot für aktuell
+ ? System.Windows.Media.Color.FromRgb(0x9E, 0x1B, 0x32)
: System.Windows.Media.Color.FromRgb(0x2A, 0x2A, 0x2A);
var borderColor = isCurrent
? System.Windows.Media.Color.FromRgb(0xC9, 0x29, 0x42)
: System.Windows.Media.Color.FromRgb(0x44, 0x44, 0x44);
var content = new StackPanel { Margin = new Thickness(8, 6, 8, 6) };
- var timeLabel = new TextBlock
+ content.Children.Add(new TextBlock
{
Text = $"{ev.StartTime:HH:mm} – {ev.EndTime:HH:mm}",
Foreground = isCurrent ? System.Windows.Media.Brushes.White
- : new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xAA, 0xAA, 0xAA)),
- FontSize = 10,
- TextTrimming = TextTrimming.CharacterEllipsis
- };
- var titleLabel = new TextBlock
+ : new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0xAA, 0xAA, 0xAA)),
+ FontSize = 10, TextTrimming = TextTrimming.CharacterEllipsis
+ });
+ content.Children.Add(new TextBlock
{
- Text = ev.Title,
- Foreground = System.Windows.Media.Brushes.White,
- FontSize = 12,
- FontWeight = FontWeights.SemiBold,
+ Text = ev.Title, Foreground = System.Windows.Media.Brushes.White,
+ FontSize = 12, FontWeight = FontWeights.SemiBold,
TextTrimming = TextTrimming.CharacterEllipsis,
- TextWrapping = TextWrapping.Wrap,
- MaxHeight = 36
- };
- content.Children.Add(timeLabel);
- content.Children.Add(titleLabel);
+ TextWrapping = TextWrapping.Wrap, MaxHeight = 36
+ });
var box = new Border
{
- Width = width,
- Height = EpgRowHeight - 4,
+ Width = width, Height = EpgRowHeight - 4,
Background = new System.Windows.Media.SolidColorBrush(bgColor),
BorderBrush = new System.Windows.Media.SolidColorBrush(borderColor),
- BorderThickness = new Thickness(1),
- CornerRadius = new CornerRadius(3),
+ 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;
- // Hover-Effekt
- box.MouseEnter += (_, _) =>
- {
- var hoverColor = isCurrent
- ? System.Windows.Media.Color.FromRgb(0xB8, 0x21, 0x3A)
- : System.Windows.Media.Color.FromRgb(0x36, 0x36, 0x36);
- box.Background = new System.Windows.Media.SolidColorBrush(hoverColor);
- };
- box.MouseLeave += (_, _) =>
- box.Background = new System.Windows.Media.SolidColorBrush(bgColor);
+ // 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)
+ : System.Windows.Media.Color.FromRgb(0x36, 0x36, 0x36));
+ box.MouseLeave += (_, _) => box.Background = new System.Windows.Media.SolidColorBrush(bgColor);
- // Click → Sender wechseln + Overlay schließen
box.MouseLeftButtonDown += (_, _) =>
{
- // Channel-Match: gleicher normalisierter Name in der Senderliste
var target = _allChannels.FirstOrDefault(c =>
- NormalizeName(c.Name).Equals(NormalizeName(ch.Name), StringComparison.OrdinalIgnoreCase))
- ?? ch;
-
+ NormalizeName(c.Name).Equals(NormalizeName(ch.Name), StringComparison.OrdinalIgnoreCase)) ?? ch;
HideEpgOverlay();
-
- // Falls Sender schon selektiert ist, feuert SelectionChanged nicht
- // → Direkt PlayChannel aufrufen. Sonst über Listbox damit Highlight passt.
var listEntry = _filteredChannels.FirstOrDefault(c =>
NormalizeName(c.Name).Equals(NormalizeName(ch.Name), StringComparison.OrdinalIgnoreCase));
if (listEntry != null && LstChannels.SelectedItem != listEntry)
- {
LstChannels.SelectedItem = listEntry;
- }
else
- {
- // Direkt abspielen (Sender war schon selektiert ODER nicht in der gefilterten Liste)
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;
}
- // ────────── Sidebar (zwei separate Toggles für Kategorien und Liste) ──────────
+ // ────────── Sidebar ──────────
- // Beide Bereiche unabhängig: Kategorien zu/auf, Liste zu/auf
private bool _categoriesCollapsed;
private bool _listCollapsed;
@@ -973,9 +1166,6 @@ public partial class MainWindow : Window
ApplySidebarLayout();
}
- ///
- /// Strg+B / globaler Burger: öffnet beide wenn beide zu sind, sonst schließt beide.
- ///
private void BtnSidebarToggle_Click(object sender, RoutedEventArgs e)
{
if (_categoriesCollapsed && _listCollapsed)
@@ -995,22 +1185,41 @@ public partial class MainWindow : Window
{
SidebarColumn.Width = _categoriesCollapsed ? new GridLength(0) : new GridLength(180);
ChannelsColumn.Width = _listCollapsed ? new GridLength(0) : new GridLength(280);
-
- // Burger-Toggle (im Player-Bereich) nur sichtbar wenn beide Spalten zu sind
- BtnSidebarToggleOverlay.Visibility = (_categoriesCollapsed && _listCollapsed)
- ? Visibility.Visible : Visibility.Collapsed;
-
- // Internes Flag für Fullscreen-Restore
_sidebarHidden = _categoriesCollapsed && _listCollapsed;
+ // Titelleiste: Hinweis wenn Seitenleiste eingeklappt
+ if (_sidebarHidden)
+ Title = "HomeStream — Strg+B → Menü";
+ else if (_currentChannel?.Kind != ChannelKind.Web)
+ Title = "HomeStream";
}
// ────────── Hotkeys ──────────
+ // 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 IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled)
+ {
+ if (msg == WM_KEYDOWN && (int)wParam == VK_B)
+ {
+ var ctrl = (NativeMethods.GetKeyState(0x11) & 0x8000) != 0;
+ if (ctrl)
+ Dispatcher.BeginInvoke(() => BtnSidebarToggle_Click(this, new RoutedEventArgs()));
+ }
+ return IntPtr.Zero;
+ }
+
+ private static class NativeMethods
+ {
+ [System.Runtime.InteropServices.DllImport("user32.dll")]
+ internal static extern short GetKeyState(int nVirtKey);
+ }
+
private void MainWindow_KeyDown(object sender, KeyEventArgs e)
{
if (Keyboard.FocusedElement is TextBox) return;
- // Strg+B: Sidebar/Liste komplett toggle
if (e.Key == Key.B && Keyboard.Modifiers == ModifierKeys.Control)
{
BtnSidebarToggle_Click(this, new RoutedEventArgs());
@@ -1022,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;
@@ -1038,4 +1256,3 @@ public partial class MainWindow : Window
}
}
}
-
diff --git a/Models/Channel.cs b/Models/Channel.cs
index 603ee2b..65fa21e 100644
--- a/Models/Channel.cs
+++ b/Models/Channel.cs
@@ -3,7 +3,10 @@ using System.Runtime.CompilerServices;
namespace FritzTV.Models;
-public enum ChannelKind { TvSd, TvHd, Radio }
+public enum ChannelKind { TvSd, TvHd, Radio, Web }
+
+/// Woher kommt der Sender? FritzBox (DVB-C/lokal) oder Online (HLS-Stream)
+public enum ChannelSource { FritzBox, Online }
public class Channel : INotifyPropertyChanged
{
@@ -12,6 +15,9 @@ public class Channel : INotifyPropertyChanged
public required ChannelKind Kind { get; init; }
public int Number { get; set; }
+ /// Quelle: lokale FritzBox oder Online-Stream. Default ist FritzBox (Abwärtskompatibilität).
+ public ChannelSource Source { get; init; } = ChannelSource.FritzBox;
+
private bool _isFavorite;
public bool IsFavorite
{
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 958ea5c..bdb7dce 100644
--- a/Services/AppPaths.cs
+++ b/Services/AppPaths.cs
@@ -35,4 +35,6 @@ public static class AppPaths
public static string Logos => Path.Combine(Root, "logos");
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 336ad7b..87fd4c8 100644
--- a/Services/AppSettings.cs
+++ b/Services/AppSettings.cs
@@ -10,6 +10,12 @@ public class AppSettings
public string LastChannel { get; set; } = "";
public double Volume { get; set; } = 80;
+ /// 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/EpgService.cs b/Services/EpgService.cs
index 8c93a85..47e8a54 100644
--- a/Services/EpgService.cs
+++ b/Services/EpgService.cs
@@ -21,16 +21,33 @@ public class EpgService
/// Cache-Verzeichnis für heruntergeladene XMLTV-Dateien
private static readonly string CacheDir = AppPaths.Epg;
- /// EPG-Quelle: kostenloser XMLTV-Feed für deutschsprachige Sender (DE/AT/CH)
- private const string EpgUrl = "https://epg.pw/xmltv/epg_DE.xml.gz";
+ /// EPG-Quelle: kostenloser XMLTV-Feed fuer Deutschland
+ /// iptv-epg.org liefert sauberes UTC mit korrektem +0000 (Tagesschau 20:00 lokal = 1800 +0000).
+ /// Zuvor war epg.pw im Einsatz, das aber unsystematisch falsche Zeiten lieferte.
+ private const string EpgUrl = "https://iptv-epg.org/files/epg-de.xml.gz";
/// In-Memory-Index: Sendername (normalisiert) → Liste der Events, sortiert nach Startzeit
private Dictionary> _eventsByChannel = new();
private DateTime _loadedAt = DateTime.MinValue;
+ private string _loadedFile = "";
- /// True wenn Daten geladen sind und nicht älter als 12h
- public bool IsCurrent => _eventsByChannel.Count > 0
- && (DateTime.Now - _loadedAt).TotalHours < 12;
+ /// True wenn Daten geladen sind, nicht älter als 12h, und die Cache-Datei nicht geändert wurde
+ public bool IsCurrent
+ {
+ get
+ {
+ if (_eventsByChannel.Count == 0) return false;
+ if ((DateTime.Now - _loadedAt).TotalHours >= 12) return false;
+ // Cache-Datei neuer als letzter Parse? → neu laden
+ var todayFile = Path.Combine(CacheDir, $"epg_{DateTime.Today:yyyyMMdd}.xml");
+ if (File.Exists(todayFile))
+ {
+ var fileTime = new FileInfo(todayFile).LastWriteTime;
+ if (fileTime > _loadedAt) return false;
+ }
+ return true;
+ }
+ }
/// Lädt EPG-Daten (Cache-Hit oder Web), parst und indiziert sie
public async Task LoadAsync(IProgress? progress = null)
@@ -53,6 +70,7 @@ public class EpgService
progress?.Report("Parse EPG…");
ParseXmlTv(todayFile);
_loadedAt = DateTime.Now;
+ _loadedFile = todayFile;
progress?.Report($"EPG: {_eventsByChannel.Count} Sender, {_eventsByChannel.Values.Sum(l => l.Count)} Events");
}
@@ -156,13 +174,12 @@ public class EpgService
_eventsByChannel = events;
}
- /// XMLTV-Zeit "20260510120000 +0200" → DateTime (lokale Zeit)
+ /// XMLTV-Zeit "20260510120000 +0200" → lokale DateTime.
private static DateTime ParseXmltvTime(string s)
{
if (string.IsNullOrWhiteSpace(s)) return DateTime.MinValue;
try
{
- // Format: "yyyyMMddHHmmss +0200" oder ohne Offset
var parts = s.Trim().Split(' ', 2);
var dt = DateTime.ParseExact(parts[0], "yyyyMMddHHmmss",
System.Globalization.CultureInfo.InvariantCulture);
@@ -172,11 +189,10 @@ public class EpgService
var sign = parts[1][0] == '-' ? -1 : 1;
var hh = int.Parse(parts[1].Substring(1, 2));
var mm = int.Parse(parts[1].Substring(3, 2));
- var offsetMin = sign * (hh * 60 + mm);
- // dt war als UTC+offset interpretiert, nach lokaler Zeit konvertieren
- var asUtc = new DateTimeOffset(dt, TimeSpan.FromMinutes(offsetMin));
- return asUtc.LocalDateTime;
+ var offset = TimeSpan.FromMinutes(sign * (hh * 60 + mm));
+ return new DateTimeOffset(dt, offset).ToLocalTime().DateTime;
}
+
return dt;
}
catch { return DateTime.MinValue; }
@@ -186,7 +202,15 @@ public class EpgService
public static string NormalizeName(string name)
{
if (string.IsNullOrWhiteSpace(name)) return "";
- var s = name.Trim().ToLowerInvariant();
+ var s = name.Trim();
+
+ // Länder-Präfixe entfernen (iptv-epg.org Feed verwendet "DE - Sendername")
+ string[] prefixes = { "DE - ", "AT - ", "CH - " };
+ foreach (var pre in prefixes)
+ if (s.StartsWith(pre, StringComparison.OrdinalIgnoreCase))
+ s = s[pre.Length..].TrimStart();
+
+ s = s.ToLowerInvariant();
// HD/SD-Suffixe entfernen
string[] suffixes = { " hd", " uhd", " 4k", " sd", " austria", " österreich", " schweiz" };
foreach (var suf in suffixes)
diff --git a/Services/OnlineSourceClient.cs b/Services/OnlineSourceClient.cs
new file mode 100644
index 0000000..f496c81
--- /dev/null
+++ b/Services/OnlineSourceClient.cs
@@ -0,0 +1,98 @@
+using System.IO;
+using System.Text.Json;
+using FritzTV.Models;
+
+namespace FritzTV.Services;
+
+///
+/// Lädt eine kuratierte Liste von Online-Streams (öffentlich-rechtliche TV + Webradio)
+/// aus Assets\online-sources.json. Diese Datei wird beim Build neben die .exe kopiert.
+///
+public class OnlineSourceClient
+{
+ private static readonly string JsonPath = Path.Combine(
+ Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location)!,
+ "Assets", "online-sources.json");
+
+ public Task> LoadAllAsync()
+ {
+ // Synchron lesen ist OK, die Datei liegt lokal und ist klein (<10 KB)
+ try
+ {
+ if (!File.Exists(JsonPath))
+ return Task.FromResult(new List());
+
+ var json = File.ReadAllText(JsonPath);
+ var doc = JsonDocument.Parse(json);
+ var root = doc.RootElement;
+
+ var channels = new List();
+ int number = 1000; // Online-Sender bekommen Nummern ab 1000 damit sie nicht mit FritzBox-Nummern kollidieren
+
+ if (root.TryGetProperty("tv", out var tvArr))
+ {
+ foreach (var item in tvArr.EnumerateArray())
+ {
+ var name = item.GetProperty("name").GetString();
+ var url = item.GetProperty("url").GetString();
+ var kindStr = item.TryGetProperty("kind", out var k) ? k.GetString() : "TvHd";
+ if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url)) continue;
+
+ channels.Add(new Channel
+ {
+ Name = name,
+ Url = url,
+ Kind = Enum.TryParse(kindStr, out var kind) ? kind : ChannelKind.TvHd,
+ Number = number++,
+ Source = ChannelSource.Online
+ });
+ }
+ }
+
+ if (root.TryGetProperty("radio", out var radioArr))
+ {
+ foreach (var item in radioArr.EnumerateArray())
+ {
+ var name = item.GetProperty("name").GetString();
+ var url = item.GetProperty("url").GetString();
+ if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url)) continue;
+
+ channels.Add(new Channel
+ {
+ Name = name,
+ Url = url,
+ Kind = ChannelKind.Radio,
+ Number = number++,
+ Source = ChannelSource.Online
+ });
+ }
+ }
+
+ if (root.TryGetProperty("web", out var webArr))
+ {
+ foreach (var item in webArr.EnumerateArray())
+ {
+ var name = item.GetProperty("name").GetString();
+ var url = item.GetProperty("url").GetString();
+ if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url)) continue;
+
+ channels.Add(new Channel
+ {
+ Name = name,
+ Url = url,
+ Kind = ChannelKind.Web,
+ Number = number++,
+ Source = ChannelSource.Online
+ });
+ }
+ }
+
+ return Task.FromResult(channels);
+ }
+ catch
+ {
+ // Bei Parse-Fehler einfach leere Liste — App soll trotzdem laufen
+ return Task.FromResult(new List());
+ }
+ }
+}
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/SettingsWindow.xaml b/SettingsWindow.xaml
index a98fb53..c2d938d 100644
--- a/SettingsWindow.xaml
+++ b/SettingsWindow.xaml
@@ -1,11 +1,14 @@
+
+
+
@@ -19,7 +22,16 @@
-
+
+
+
+
+
+