Web-Sender: WebView2 Browser-Integration (YouTube, Netflix, Mediatheken mit persistentem Login)

This commit is contained in:
administrator 2026-05-11 17:13:46 +02:00
parent ab1954e28c
commit 72bafbcba6
7 changed files with 214 additions and 364 deletions

View file

@ -1,191 +1,59 @@
{
"_comment": "Online-Streams für HomeStream. URLs Stand Mai 2026, Quelle: harryshomepage.de + rundfunkforum. Privatsender (RTL, ProSieben, Sat.1) NICHT enthalten wegen Lizenz/Paywall (Joyn, RTL+).",
"_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 Süd 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"
}
{ "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"
},
{
"name": "Radio Eins",
"url": "https://rbb-radioeins-live.cast.addradio.de/rbb/radioeins/live/mp3/128/stream.mp3"
}
{ "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/serien-und-filme" },
{ "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" }
]
}

View file

@ -19,6 +19,7 @@
<ItemGroup>
<PackageReference Include="LibVLCSharp.WPF" Version="3.9.7.1" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3967.48" />
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23.1" />
</ItemGroup>

View file

@ -1,6 +1,7 @@
<Window x:Class="FritzTV.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:wv2="clr-namespace:Microsoft.Web.WebView2.Wpf;assembly=Microsoft.Web.WebView2.Wpf"
xmlns:vlc="clr-namespace:LibVLCSharp.WPF;assembly=LibVLCSharp.WPF"
Title="HomeStream"
Height="720" Width="1280"
@ -147,6 +148,8 @@
ToolTip="DVB-C-Radio über die FritzBox"/>
<Button x:Name="BtnRadioOnline" Content="📡 Radio (Online)" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="radio-online"
ToolTip="Webradio (ÖR-Streams)"/>
<Button x:Name="BtnWeb" Content="🌐 Web-Sender" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="web"
ToolTip="YouTube, Netflix, Mediatheken etc. (Browser)"/>
<Separator Margin="8" Background="#333"/>
<Button x:Name="BtnFav" Content="⭐ Favoriten" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="fav"/>
<Separator Margin="8" Background="#333"/>
@ -224,8 +227,9 @@
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- VideoView -->
<!-- VideoView + WebView2: beide im gleichen Grid-Cell, Visibility steuert wer sichtbar ist -->
<Grid Grid.Row="0">
<!-- VLC-Player fuer TV/Radio -->
<vlc:VideoView x:Name="VideoView" Background="Black">
<Border x:Name="VideoOverlay"
Background="#01000000"
@ -350,6 +354,11 @@
</Grid>
</Border>
</vlc:VideoView>
<!-- WebView2 fuer Web-Sender (YouTube, Netflix, Mediatheken etc.) -->
<!-- Persistentes User-Data-Profil: Login bleibt erhalten -->
<wv2:WebView2 x:Name="WebView"
Visibility="Collapsed"/>
<TextBlock x:Name="TxtNoChannel"
Text="Wähle einen Sender aus der Liste"
Foreground="#666" FontSize="18"

View file

@ -8,6 +8,7 @@ using System.Windows.Threading;
using FritzTV.Models;
using FritzTV.Services;
using LibVLCSharp.Shared;
using Microsoft.Web.WebView2.Core;
namespace FritzTV;
@ -37,6 +38,9 @@ public partial class MainWindow : Window
// Periodischer Refresh: aktualisiert Jetzt/Danach wenn Sendung endet
private readonly DispatcherTimer _epgRefreshTimer;
// WebView2 für Web-Sender (YouTube, Netflix etc.)
private bool _webViewReady = false;
// Pre-Fullscreen UI-State
private WindowStyle _prevStyle = WindowStyle.SingleBorderWindow;
private WindowState _prevState = WindowState.Normal;
@ -66,10 +70,8 @@ public partial class MainWindow : Window
_epgRefreshTimer.Tick += (_, _) =>
{
if (_currentChannel == null) return;
// EIT alle 10s neu lesen (FritzBox-TV)
if (_currentChannel.Source == ChannelSource.FritzBox && _currentMedia != null)
UpdateEpgFromMedia(_currentMedia);
// XMLTV nur alle 60s (Online-TV + Danach für alle)
_epgRefreshTick++;
if (_epgRefreshTick >= 6)
{
@ -86,7 +88,6 @@ public partial class MainWindow : Window
private void OnVideoDoubleClick(object sender, MouseButtonEventArgs e)
{
// Doppelklick auf VideoView → Vollbild
ToggleFullscreen();
e.Handled = true;
}
@ -104,16 +105,35 @@ public partial class MainWindow : Window
private async void MainWindow_Loaded(object sender, RoutedEventArgs e)
{
// Core.Initialize lädt libVLC-Natives (~5-10s) und blockiert sonst den UI-Thread.
// Volume vorab auf UI-Thread lesen, dann Natives auf Background-Thread initialisieren.
var initialVolume = (int)SldVolume.Value;
await Task.Run(() => InitializePlayerNatives(initialVolume));
VideoView.MediaPlayer = _player; // UI-Zuweisung auf UI-Thread
VideoView.MediaPlayer = _player;
// WebView2 async initialisieren (persistentes Profil in %APPDATA%\HomeStream\webview2)
_ = InitializeWebViewAsync();
await LoadChannelsAsync();
RestoreLastChannel();
_ = LoadEpgInBackgroundAsync();
}
private async Task InitializeWebViewAsync()
{
try
{
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}");
}
}
/// <summary>Background-Thread: libVLC-Natives laden. Kein UI-Zugriff.</summary>
private void InitializePlayerNatives(int initialVolume)
{
@ -172,7 +192,6 @@ public partial class MainWindow : Window
TxtStatus.Text = "Lade Senderliste…";
try
{
// FritzBox + Online-Sender parallel laden
var fritzClient = new FritzBoxClient(_settings.FritzBoxIp);
var onlineClient = new OnlineSourceClient();
@ -188,7 +207,6 @@ public partial class MainWindow : Window
}
catch (Exception ex)
{
// FritzBox nicht erreichbar (z.B. unterwegs)? Trotzdem mit Online-Sendern weitermachen
fritzChannels = new List<Channel>();
TxtStatus.Text = $"FritzBox-Fehler: {ex.Message}";
}
@ -223,7 +241,6 @@ public partial class MainWindow : Window
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)
@ -233,7 +250,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(); }
@ -278,36 +294,31 @@ public partial class MainWindow : Window
{
IEnumerable<Channel> q = _allChannels;
// Kategorie-Filter
q = _currentCategory switch
{
"tv-fritz" => MergeHdSd(q.Where(c => c.Kind != ChannelKind.Radio && c.Source == ChannelSource.FritzBox)),
"tv-online" => MergeHdSd(q.Where(c => c.Kind != ChannelKind.Radio && c.Source == ChannelSource.Online)),
"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" => MergeHdSd(q.Where(c => c.IsFavorite)),
_ => MergeHdSd(q) // "all"
_ => 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);
}
/// <summary>
/// HD + SD zusammenfassen: gleicher Basisname → HD-Variante bevorzugen.
/// Radio-Sender werden nicht angefasst.
/// </summary>
private static IEnumerable<Channel> MergeHdSd(IEnumerable<Channel> 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)
@ -316,9 +327,6 @@ public partial class MainWindow : Window
return merged.Concat(radio);
}
/// <summary>
/// Sendername normalisieren für Sort & Merge: " HD"-Suffix weg, klein, getrimmt.
/// </summary>
private static string NormalizeName(string name)
{
var s = name.Trim();
@ -335,7 +343,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();
@ -344,17 +351,31 @@ 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));
TxtNoChannel.Visibility = Visibility.Collapsed;
UpdateFavButton();
if (ch.Kind == ChannelKind.Web)
{
PlayWebChannel(ch);
return;
}
// VLC-Player aktiv, WebView verstecken
WebView.Visibility = Visibility.Collapsed;
VideoView.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;
@ -362,24 +383,14 @@ public partial class MainWindow : Window
_player.Media = media;
_player.Play();
TxtCurrentChannel.Text = ch.Name;
// Quelle-Badge anzeigen (FritzBox/Online)
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)) // Blau für Online
: new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0x66, 0xBB, 0x6A)); // Grün für FritzBox
TxtEpgNow.Text = "EPG wird geladen…";
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)
@ -388,17 +399,42 @@ public partial class MainWindow : Window
}
}
/// <summary>
/// Füllt Jetzt+Danach aus dem XMLTV-EPG.
///
/// Online-Streams (HLS) haben kein EIT, daher müssen wir hier auch das AKTUELLE
/// Programm aus XMLTV setzen. FritzBox-TV nutzt primär EIT, XMLTV nur als "Danach"-Vorschau.
/// </summary>
private void PlayWebChannel(Channel ch)
{
// VLC stoppen, WebView zeigen
_player?.Stop();
VideoView.Visibility = Visibility.Collapsed;
RadioCover.Visibility = Visibility.Collapsed;
WebView.Visibility = Visibility.Visible;
TxtEpgNow.Text = "🌐 Browser";
TxtEpgNext.Text = "";
TxtChannelSource.Text = "● Web";
TxtChannelSource.Foreground = new System.Windows.Media.SolidColorBrush(
System.Windows.Media.Color.FromRgb(0xFF, 0x99, 0x00)); // Orange für Web
if (_webViewReady)
{
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)
WebView.Source = new Uri(ch.Url);
};
retryTimer.Start();
}
}
private void UpdateNextFromEpgService(Channel ch)
{
if (ch.Kind == ChannelKind.Radio) return; // Radio: kein TV-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();
@ -407,41 +443,34 @@ public partial class MainWindow : Window
var allEvents = _epgService.GetEvents(ch.Name, hoursAhead: 12);
// Aktuelle Sendung aus XMLTV: nur für Online-TV setzen (FritzBox: EIT überschreibt)
if (ch.Source == ChannelSource.Online)
{
var current = allEvents.FirstOrDefault(e => e.IsCurrent);
if (current != null)
{
var jetzt = current.Title;
if (!string.IsNullOrWhiteSpace(current.Description))
jetzt += $" · {current.Description.Trim()}";
TxtEpgNow.Text = $"▶ Jetzt: {jetzt}";
_epgTimer.Stop(); // keine EIT-Updates erwarten
TxtEpgNow.Text = $"▶ Jetzt: {current.Title}";
_epgTimer.Stop();
}
else if (TxtEpgNow.Text == "EPG wird geladen\u2026")
{
TxtEpgNow.Text = "(kein EPG verf\u00fcgbar)";
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}";
}
// Danach-Liste (gleich für beide Quellen)
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}"));
}
TxtEpgNext.Text = string.Join(" · ", next.Select(e => $"{e.StartTime:HH:mm} {e.Title}"));
}
/// <summary>
/// Bei Radio-Sendern Cover-Bild anzeigen, bei TV verbergen.
/// libVLC zeigt sonst bei reinen Audio-Streams einen weißen Default-Hintergrund.
/// </summary>
private void UpdateRadioCover(Channel ch)
{
// Bisher gebundener Channel abhängen damit wir nicht doppelt hören
if (_radioCoverBoundChannel != null)
_radioCoverBoundChannel.PropertyChanged -= OnCurrentChannelPropertyChanged;
_radioCoverBoundChannel = null;
@ -452,8 +481,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;
}
@ -491,7 +518,7 @@ public partial class MainWindow : Window
RadioFallbackIcon.Visibility = Visibility.Collapsed;
return;
}
catch { /* fallthrough zu Fallback-Icon */ }
catch { }
}
RadioLogo.Source = null;
RadioLogo.Visibility = Visibility.Collapsed;
@ -506,20 +533,17 @@ public partial class MainWindow : Window
private void EpgTimer_Tick(object? sender, EventArgs e)
{
if (_currentMedia == null) { _epgTimer.Stop(); return; }
// Online-Streams haben kein EIT — EpgService liefert Daten, EIT-Polling unnötig
if (_currentChannel?.Source == ChannelSource.Online)
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;
}
@ -531,19 +555,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 = "";
@ -555,14 +572,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()}";
@ -587,7 +596,6 @@ public partial class MainWindow : Window
}
else
{
// TV: EIT NowPlaying / ShowName
string nowText = !string.IsNullOrWhiteSpace(nowPlaying) ? nowPlaying : showName;
if (!string.IsNullOrWhiteSpace(nowText))
@ -597,15 +605,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();
}
}
@ -696,7 +701,6 @@ public partial class MainWindow : Window
}
else
{
// ItemTemplate-Refresh ohne Stream-Reset
if (LstChannels.ItemContainerGenerator.ContainerFromItem(_currentChannel)
is ListBoxItem lbi)
{
@ -735,7 +739,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 };
@ -754,12 +758,11 @@ public partial class MainWindow : Window
// ────────── 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;
@ -797,7 +800,7 @@ public partial class MainWindow : Window
EpgCanvas.Children.Clear();
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)
@ -814,7 +817,6 @@ public partial class MainWindow : Window
BuildEpgHeader(totalMinutes, contentWidth, contentHeight);
BuildEpgNowLine(totalMinutes, contentHeight);
// Zeilen in Batches einf\u00fcgen damit UI-Thread nicht h\u00e4ngt (82 Sender w\u00e4ren >5s)
const int batchSize = 10;
for (int i = 0; i < channels.Count; i++)
{
@ -830,28 +832,22 @@ public partial class MainWindow : Window
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);
@ -886,11 +882,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)
@ -913,10 +907,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
@ -927,17 +919,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)
@ -962,91 +951,63 @@ 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, 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);
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);
}
};
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;
@ -1062,9 +1023,6 @@ public partial class MainWindow : Window
ApplySidebarLayout();
}
/// <summary>
/// Strg+B / globaler Burger: öffnet beide wenn beide zu sind, sonst schließt beide.
/// </summary>
private void BtnSidebarToggle_Click(object sender, RoutedEventArgs e)
{
if (_categoriesCollapsed && _listCollapsed)
@ -1084,12 +1042,8 @@ 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;
}
@ -1099,7 +1053,6 @@ public partial class MainWindow : Window
{
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());
@ -1127,4 +1080,3 @@ public partial class MainWindow : Window
}
}
}

View file

@ -3,7 +3,7 @@ using System.Runtime.CompilerServices;
namespace FritzTV.Models;
public enum ChannelKind { TvSd, TvHd, Radio }
public enum ChannelKind { TvSd, TvHd, Radio, Web }
/// <summary>Woher kommt der Sender? FritzBox (DVB-C/lokal) oder Online (HLS-Stream)</summary>
public enum ChannelSource { FritzBox, Online }

View file

@ -35,4 +35,5 @@ 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");
}

View file

@ -68,6 +68,25 @@ public class OnlineSourceClient
}
}
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