Compare commits

..

52 commits
v0.1.0 ... main

Author SHA1 Message Date
84203db798 Aufnahme-Feature entfernt - libVLC sout-duplicate Hall-Problem nicht loesbar 2026-05-21 23:37:44 +02:00
282e5c3f5a Aufnahme: select=noaudio fuer display-dst 2026-05-21 23:34:08 +02:00
2ac99425af Aufnahme: --no-sout-audio verhindert Hall-Effekt beim Liveton 2026-05-21 23:20:36 +02:00
75526a7d43 Aufnahme: sout-duplicate - Livebild laeuft weiter waehrend Aufnahme 2026-05-21 23:01:21 +02:00
b0e9e1022f Aufnahme: Start/Stop Button, libVLC sout in Videos\HomeStream, REC-Badge 2026-05-21 22:47:51 +02:00
b9b5d1c923 WebView: CoreWebView2.Navigate erzwingt Navigation zur definierten URL 2026-05-17 23:01:27 +02:00
4880b25e90 ZDF Mediathek URL korrigiert 2026-05-17 22:57:30 +02:00
dbd7aad30d EPG: Scroll-Position nach Erinnerung-Rebuild beibehalten 2026-05-17 17:53:16 +02:00
951e02f0fb EPG: nach Senderwechsel durch Erinnerung automatisch schliessen 2026-05-17 17:44:23 +02:00
d033411dfc EPG-Erinnerung: Jetzt einschalten Button wechselt automatisch den Sender 2026-05-17 17:39:37 +02:00
4db5dd6f23 EPG-Erinnerung: logical child Fehler behoben 2026-05-17 17:26:20 +02:00
b3d1e2e851 EPG-Erinnerung: Rebuild via BeginInvoke verzögert, Canvas-Clear Race-Condition behoben 2026-05-17 17:24:12 +02:00
5bf1f06bfe EPG-Erinnerungen: Rechtsklick auf Sendung, Glocke-Indikator, MessageBox-Benachrichtigung 2026-05-17 16:55:16 +02:00
faa852016d Favoriten: Web-Sender nicht durch MergeHdSd filtern 2026-05-16 16:27:39 +02:00
5e8014c127 Favoriten medienuebergreifend, Startkategorie Favoriten 2026-05-13 12:39:49 +02:00
6e10f0b979 EPG: EIT alle 60s auf Background-Thread - kein UI-Block 2026-05-13 11:36:39 +02:00
6249c6d149 EPG wieder eingebaut - Problem war FritzBox-Reboot noetig, nicht EPG-Code 2026-05-13 01:21:54 +02:00
27b5b6805f MetaChanged throttle 2s - verhindert UI-Flooding durch DVB-Metadaten 2026-05-13 00:43:11 +02:00
58ffc7300b Revert: EIT-Polling entfernt (Einfrieren), Cache zurueck auf 1000ms 2026-05-13 00:34:46 +02:00
5e211cc038 VLC: 300ms Cache fuer RTSP/FritzBox, clock-jitter deaktiviert 2026-05-13 00:31:45 +02:00
d0f9df0a64 EPG-Overlay zurueck in VideoView (HWND-Fix), WebView wird beim EPG-Oeffnen versteckt 2026-05-13 00:09:21 +02:00
d22808497d publish.ps1: UTF-8 Encoding fuer API-Body 2026-05-12 23:44:30 +02:00
15e7182c22 EPG-Overlay aus VideoView raus - funktioniert jetzt auch ueber WebView und TV 2026-05-12 22:51:25 +02:00
d2eeffd200 EPG: WebView pausieren bei EPG-Overlay, zurueck zu WebView beim Schliessen 2026-05-12 22:30:34 +02:00
c1e832e08d EPG-Grid: Tooltip mit TextWrapping und 30s ShowDuration 2026-05-12 22:19:07 +02:00
362050820b Strg+B via WndProc-Hook - greift auch wenn WebView2 Fokus hat 2026-05-12 21:44:10 +02:00
c435c0fbe2 WebView: video/audio pausieren beim Wechsel zu TV/Radio 2026-05-11 21:44:20 +02:00
499c731bf6 Burger-Button entfernt - ueberall Strg+B, Titelleiste zeigt Hinweis wenn Sidebar zu 2026-05-11 18:49:53 +02:00
58711836e6 WebView: Fenstertitel zeigt Strg+B Hinweis im Browser-Modus 2026-05-11 18:45:20 +02:00
a3e7fb780e WebView: Burger-Button im Browser-Modus komplett ausblenden - Strg+B als Alternative 2026-05-11 18:31:39 +02:00
083c1e0e20 Burger-Button: unten rechts - kein Konflikt mit Browser-Elementen 2026-05-11 18:23:26 +02:00
449f69f835 Burger-Button: unten links, dunkler Hintergrund mit Border - nicht mit YT-Hamburger verwechselbar 2026-05-11 18:22:28 +02:00
33acf44d58 Burger-Button aus VideoOverlay raus - bleibt sichtbar auch wenn WebView aktiv 2026-05-11 18:09:41 +02:00
236cdcfc74 WebView2: VideoOverlay Collapsed - WPF-HWND-Airspace-Fix fuer Klicks 2026-05-11 18:03:55 +02:00
49adc5e15d WebView2: Klicks fix (VideoOverlay deaktivieren), Streaming umbenennen 2026-05-11 17:22:06 +02:00
72bafbcba6 Web-Sender: WebView2 Browser-Integration (YouTube, Netflix, Mediatheken mit persistentem Login) 2026-05-11 17:13:46 +02:00
ab1954e28c Fix: SldVolume vor Task.Run lesen - kein Cross-Thread DependencyObject Zugriff 2026-05-11 16:54:50 +02:00
6e172484f8 Startup: libVLC-Init auf Background-Thread, kein UI-Freeze beim Start 2026-05-11 16:52:50 +02:00
c91944e965 EPG: EIT alle 10s pollen, XMLTV alle 60s 2026-05-11 16:50:23 +02:00
c978d66c56 EPG-Refresh: periodischer Timer alle 60s aktualisiert Jetzt/Danach wenn Sendung endet 2026-05-11 15:08:28 +02:00
735ab0b489 4 Kategorien (TV/Radio x FritzBox/Online), Source-Badge, XMLTV-Jetzt fuer Online 2026-05-11 10:27:35 +02:00
a129f9b98e Online-Streams: OeR-TV + Webradio aus Assets-JSON, Sidebar-Kategorie, Settings-Toggle 2026-05-11 10:15:39 +02:00
7702330c9a publish.ps1: stderr von git tag/push abfangen, Tag-Existenz toleriert 2026-05-11 10:05:52 +02:00
4f8d924df5 EPG-Quelle gewechselt: epg.pw -> iptv-epg.org (sauberes UTC, korrekte Daten) 2026-05-11 09:51:34 +02:00
ed7a237e33 Reapply "EPG: epg.pw liefert lokale Zeit mit falschem +0000 - speziell behandeln"
This reverts commit 3804bb989d.
2026-05-11 09:15:10 +02:00
3804bb989d Revert "EPG: epg.pw liefert lokale Zeit mit falschem +0000 - speziell behandeln"
This reverts commit 45928dd97b.
2026-05-11 09:10:17 +02:00
45928dd97b EPG: epg.pw liefert lokale Zeit mit falschem +0000 - speziell behandeln 2026-05-11 08:41:44 +02:00
e2aeeb4332 EPG: IsCurrent prueft auch Cache-Datei-Timestamp 2026-05-11 07:38:54 +02:00
4bf42b96b5 EPG: async Grid-Build (kein UI-Hang), dunkle Scrollbars im Overlay 2026-05-11 07:26:49 +02:00
1ad2d35e58 EPG Zeitzone-Fix + --rtsp-tcp 2026-05-11 01:20:28 +02:00
22d09621ee _delete/ in gitignore aufnehmen 2026-05-10 23:50:31 +02:00
7fe19b90ee publish.ps1 + gitignore Hilfsskripte 2026-05-10 23:46:12 +02:00
15 changed files with 956 additions and 243 deletions

3
.gitignore vendored
View file

@ -18,6 +18,9 @@ logs/
# Temp
*.tmp
*.temp
response.json
_*.ps1
_delete/
# OS
Thumbs.db

View file

@ -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" }
]
}

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>
@ -27,4 +28,11 @@
<Resource Include="Assets\logo.svg" />
</ItemGroup>
<ItemGroup>
<!-- Online-Stream-Liste wird zur Laufzeit gelesen, daher Content (kein Resource) -->
<Content Include="Assets\online-sources.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

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"
@ -137,13 +138,22 @@
ToolTip="Kategorien einklappen"/>
</Grid>
<StackPanel DockPanel.Dock="Top">
<Button x:Name="BtnAll" Content="📺 Alle Sender" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="all"/>
<Button x:Name="BtnTv" Content="🎬 TV" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="tv"/>
<Button x:Name="BtnRadio" Content="📡 Radio" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="radio"/>
<Button x:Name="BtnAll" Content="📺 Alle Sender" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="all"/>
<Separator Margin="8" Background="#333"/>
<Button x:Name="BtnFav" Content="⭐ Favoriten" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="fav"/>
<Button x:Name="BtnTvFritz" Content="🎬 TV (FritzBox)" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="tv-fritz"
ToolTip="DVB-C über die FritzBox"/>
<Button x:Name="BtnTvOnline" Content="🎬 TV (Online)" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="tv-online"
ToolTip="ÖR-TV über Internet (HLS)"/>
<Button x:Name="BtnRadioFritz" Content="📡 Radio (FritzBox)" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="radio-fritz"
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="🌐 Streaming" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="web"
ToolTip="YouTube, Netflix, Mediatheken etc. (Browser)"/>
<Separator Margin="8" Background="#333"/>
<Button x:Name="BtnEpgGrid" Content="📅 Programm" Style="{StaticResource SidebarButton}" Click="BtnEpgGrid_Click"
<Button x:Name="BtnFav" Content="⭐ Favoriten" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="fav"/>
<Separator Margin="8" Background="#333"/>
<Button x:Name="BtnEpgGrid" Content="📅 Programm" Style="{StaticResource SidebarButton}" Click="BtnEpgGrid_Click"
ToolTip="Programmübersicht aller Sender"/>
</StackPanel>
@ -217,49 +227,39 @@
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- VideoView -->
<!-- VideoView + WebView2 + EPG-Overlay: alle im gleichen Grid-Cell -->
<Grid Grid.Row="0">
<!-- VLC-Player fuer TV/Radio -->
<vlc:VideoView x:Name="VideoView" Background="Black">
<Border x:Name="VideoOverlay"
Background="#01000000"
PreviewMouseLeftButtonDown="VideoClickCatcher_DoubleClick">
<Grid>
<Button x:Name="BtnSidebarToggleOverlay"
HorizontalAlignment="Left" VerticalAlignment="Top"
Width="36" Height="36" Margin="8"
Background="#80000000" Foreground="White" BorderThickness="0"
Content="☰" FontSize="16" Cursor="Hand"
Visibility="Collapsed"
Panel.ZIndex="1000"
Click="BtnSidebarToggle_Click"
ToolTip="Seitenleiste wieder einblenden (Strg+B)"/>
<!-- Radio-Cover bei Audio-Streams — INNERHALB VideoView damit es die native HWND überdeckt -->
<Border x:Name="RadioCover" Visibility="Collapsed"
Background="#1A1A1A">
<!-- Radio-Cover bei Audio-Streams -->
<Border x:Name="RadioCover" Visibility="Collapsed" Background="#1A1A1A">
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<Image x:Name="RadioLogo" Width="200" Height="200" Stretch="Uniform"
RenderOptions.BitmapScalingMode="HighQuality"/>
<TextBlock x:Name="RadioFallbackIcon" Text="📻"
FontSize="120" Foreground="#444"
HorizontalAlignment="Center"
Visibility="Collapsed"/>
HorizontalAlignment="Center" Visibility="Collapsed"/>
<TextBlock x:Name="TxtRadioName"
FontSize="24" FontWeight="Bold" Foreground="White"
HorizontalAlignment="Center" Margin="0,24,0,0"/>
<TextBlock x:Name="TxtRadioText"
FontSize="16" Foreground="#0078D4" FontWeight="SemiBold"
HorizontalAlignment="Center" Margin="24,12,24,0"
TextWrapping="Wrap" TextAlignment="Center"
MaxWidth="600"/>
TextWrapping="Wrap" TextAlignment="Center" MaxWidth="600"/>
</StackPanel>
</Border>
<!-- EPG-Overlay (Joyn-Style): liegt INNERHALB der VideoView damit Klicks ber HWND funktionieren -->
<!-- EPG-Overlay: INNERHALB VideoView (HWND-Grenze)
VideoView wird beim EPG-Oeffnen immer sichtbar gemacht,
auch wenn WebView aktiv war -->
<Border x:Name="EpgOverlay" Visibility="Collapsed"
Background="#D8000000">
<DockPanel>
<!-- Header: Programm + Datum/Zeit + Schließen -->
<Grid DockPanel.Dock="Top" Background="#0A0A0A" Height="60">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
@ -275,22 +275,58 @@
<Button x:Name="BtnCloseEpg" Content="✕" FontSize="18"
Width="40" Height="40"
Background="Transparent" Foreground="White" BorderThickness="0"
Cursor="Hand"
Click="BtnCloseEpg_Click"
ToolTip="Schließen (Esc)"/>
Cursor="Hand" Click="BtnCloseEpg_Click" ToolTip="Schließen (Esc)"/>
</StackPanel>
</Grid>
<!-- Status-Zeile (Lade EPG… etc.) -->
<TextBlock x:Name="TxtEpgOverlayStatus" DockPanel.Dock="Bottom"
Foreground="#888" FontSize="11" Padding="24,8"
Background="#0A0A0A"/>
<!-- Scrollbares EPG-Grid (Canvas) -->
Foreground="#888" FontSize="11" Padding="24,8" Background="#0A0A0A"/>
<ScrollViewer x:Name="EpgScrollViewer"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
Background="#0A0A0A">
<ScrollViewer.Resources>
<Style TargetType="ScrollBar">
<Setter Property="Background" Value="#0E0E0E"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Width" Value="10"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ScrollBar">
<Grid Background="{TemplateBinding Background}">
<Track Name="PART_Track" IsDirectionReversed="true">
<Track.Thumb>
<Thumb>
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Border x:Name="thumbBd" Background="#3A3A3A" CornerRadius="3" Margin="2"/>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="thumbBd" Property="Background" Value="#555"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Thumb.Template>
</Thumb>
</Track.Thumb>
<Track.IncreaseRepeatButton>
<RepeatButton Background="Transparent" BorderThickness="0" Command="ScrollBar.PageDownCommand" IsTabStop="False"/>
</Track.IncreaseRepeatButton>
<Track.DecreaseRepeatButton>
<RepeatButton Background="Transparent" BorderThickness="0" Command="ScrollBar.PageUpCommand" IsTabStop="False"/>
</Track.DecreaseRepeatButton>
</Track>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
<Style.Triggers>
<Trigger Property="Orientation" Value="Horizontal">
<Setter Property="Width" Value="Auto"/>
<Setter Property="Height" Value="10"/>
</Trigger>
</Style.Triggers>
</Style>
</ScrollViewer.Resources>
<Canvas x:Name="EpgCanvas"/>
</ScrollViewer>
</DockPanel>
@ -298,6 +334,10 @@
</Grid>
</Border>
</vlc:VideoView>
<!-- WebView2 fuer Web-Sender -->
<wv2:WebView2 x:Name="WebView" Visibility="Collapsed" Panel.ZIndex="5"/>
<TextBlock x:Name="TxtNoChannel"
Text="Wähle einen Sender aus der Liste"
Foreground="#666" FontSize="18"
@ -316,8 +356,13 @@
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock x:Name="TxtCurrentChannel"
Foreground="White" FontSize="18" FontWeight="Bold"/>
<StackPanel Orientation="Horizontal">
<TextBlock x:Name="TxtCurrentChannel"
Foreground="White" FontSize="18" FontWeight="Bold"/>
<TextBlock x:Name="TxtChannelSource"
Foreground="#888" FontSize="11" FontWeight="SemiBold"
VerticalAlignment="Center" Margin="10,0,0,0"/>
</StackPanel>
<TextBlock x:Name="TxtEpgNow"
Foreground="#DDD" FontSize="12" Margin="0,4,0,0"
TextWrapping="Wrap"/>

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,10 @@ 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 }
public class Channel : INotifyPropertyChanged
{
@ -12,6 +15,9 @@ public class Channel : INotifyPropertyChanged
public required ChannelKind Kind { get; init; }
public int Number { get; set; }
/// <summary>Quelle: lokale FritzBox oder Online-Stream. Default ist FritzBox (Abwärtskompatibilität).</summary>
public ChannelSource Source { get; init; } = ChannelSource.FritzBox;
private bool _isFavorite;
public bool IsFavorite
{

16
Models/Reminder.cs Normal file
View file

@ -0,0 +1,16 @@
namespace FritzTV.Models;
public class Reminder
{
public Guid Id { get; set; } = Guid.NewGuid();
public required string ChannelName { get; set; }
public required string Title { get; set; }
public string Description { get; set; } = "";
public DateTime StartTime { get; set; }
public DateTime EndTime { get; set; }
public int MinutesBefore { get; set; } = 5;
public bool Fired { get; set; } = false;
/// <summary>Zeitpunkt zu dem die Benachrichtigung ausgelöst wird</summary>
public DateTime NotifyAt => StartTime.AddMinutes(-MinutesBefore);
}

View file

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

View file

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

View file

@ -21,16 +21,33 @@ public class EpgService
/// <summary>Cache-Verzeichnis für heruntergeladene XMLTV-Dateien</summary>
private static readonly string CacheDir = AppPaths.Epg;
/// <summary>EPG-Quelle: kostenloser XMLTV-Feed für deutschsprachige Sender (DE/AT/CH)</summary>
private const string EpgUrl = "https://epg.pw/xmltv/epg_DE.xml.gz";
/// <summary>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.</summary>
private const string EpgUrl = "https://iptv-epg.org/files/epg-de.xml.gz";
/// <summary>In-Memory-Index: Sendername (normalisiert) → Liste der Events, sortiert nach Startzeit</summary>
private Dictionary<string, List<EpgEvent>> _eventsByChannel = new();
private DateTime _loadedAt = DateTime.MinValue;
private string _loadedFile = "";
/// <summary>True wenn Daten geladen sind und nicht älter als 12h</summary>
public bool IsCurrent => _eventsByChannel.Count > 0
&& (DateTime.Now - _loadedAt).TotalHours < 12;
/// <summary>True wenn Daten geladen sind, nicht älter als 12h, und die Cache-Datei nicht geändert wurde</summary>
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;
}
}
/// <summary>Lädt EPG-Daten (Cache-Hit oder Web), parst und indiziert sie</summary>
public async Task LoadAsync(IProgress<string>? 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;
}
/// <summary>XMLTV-Zeit "20260510120000 +0200" → DateTime (lokale Zeit)</summary>
/// <summary>XMLTV-Zeit "20260510120000 +0200" → lokale DateTime.</summary>
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)

View file

@ -0,0 +1,98 @@
using System.IO;
using System.Text.Json;
using FritzTV.Models;
namespace FritzTV.Services;
/// <summary>
/// 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.
/// </summary>
public class OnlineSourceClient
{
private static readonly string JsonPath = Path.Combine(
Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location)!,
"Assets", "online-sources.json");
public Task<List<Channel>> LoadAllAsync()
{
// Synchron lesen ist OK, die Datei liegt lokal und ist klein (<10 KB)
try
{
if (!File.Exists(JsonPath))
return Task.FromResult(new List<Channel>());
var json = File.ReadAllText(JsonPath);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var channels = new List<Channel>();
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<ChannelKind>(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<Channel>());
}
}
}

107
Services/ReminderService.cs Normal file
View file

@ -0,0 +1,107 @@
using System.IO;
using System.Text.Json;
using System.Windows.Threading;
using FritzTV.Models;
namespace FritzTV.Services;
public class ReminderService
{
private static readonly string RemindersPath = AppPaths.Reminders;
private readonly DispatcherTimer _checkTimer;
private List<Reminder> _reminders = new();
/// <summary>Feuert wenn eine Erinnerung faellig ist. Callback laeuft auf UI-Thread.</summary>
public event Action<Reminder>? ReminderDue;
public IReadOnlyList<Reminder> Reminders => _reminders.AsReadOnly();
public ReminderService()
{
Load();
_checkTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) };
_checkTimer.Tick += (_, _) => CheckReminders();
_checkTimer.Start();
}
public void Add(Reminder reminder)
{
_reminders.Add(reminder);
Save();
}
public void Remove(Guid id)
{
_reminders.RemoveAll(r => r.Id == id);
Save();
}
public bool HasReminder(string channelName, DateTime startTime)
=> _reminders.Any(r => r.ChannelName == channelName && r.StartTime == startTime);
private void CheckReminders()
{
var now = DateTime.Now;
var due = _reminders
.Where(r => !r.Fired && r.NotifyAt <= now && r.StartTime > now.AddMinutes(-30))
.ToList();
foreach (var r in due)
{
r.Fired = true;
FireNotification(r);
ReminderDue?.Invoke(r);
}
_reminders.RemoveAll(r => r.Fired && r.StartTime < now.AddHours(-1));
if (due.Any()) Save();
}
private void FireNotification(Reminder r)
{
try
{
var minLeft = (int)(r.StartTime - DateTime.Now).TotalMinutes;
var when = minLeft <= 0 ? "Jetzt" : $"In {minLeft} Minute{(minLeft == 1 ? "" : "n")}";
var msg = $"{when}: {r.Title}\n{r.ChannelName} {r.StartTime:HH:mm}\u2013{r.EndTime:HH:mm}";
var result = System.Windows.MessageBox.Show(
msg + "\n\nJetzt einschalten?",
"\u23f0 HomeStream-Erinnerung",
System.Windows.MessageBoxButton.YesNo,
System.Windows.MessageBoxImage.Information);
if (result == System.Windows.MessageBoxResult.Yes)
SwitchToChannel?.Invoke(r.ChannelName);
}
catch { }
}
/// <summary>Wird aufgerufen wenn User bei Erinnerung "Jetzt einschalten" klickt.</summary>
public Action<string>? SwitchToChannel { get; set; }
private void Load()
{
try
{
if (File.Exists(RemindersPath))
{
var json = File.ReadAllText(RemindersPath);
_reminders = JsonSerializer.Deserialize<List<Reminder>>(json) ?? new();
_reminders.RemoveAll(r => r.StartTime < DateTime.Now.AddHours(-1));
}
}
catch { _reminders = new(); }
}
private void Save()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(RemindersPath)!);
var json = JsonSerializer.Serialize(_reminders, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(RemindersPath, json);
}
catch { }
}
}

View file

@ -1,11 +1,14 @@
<Window x:Class="FritzTV.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Einstellungen" Height="240" Width="420"
Title="Einstellungen" Height="320" Width="440"
Background="#1A1A1A" Foreground="White"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize">
<Grid Margin="24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
@ -19,7 +22,16 @@
<TextBlock Grid.Row="2" Margin="0,4,0,0" FontSize="11" Foreground="#666"
Text="z.B. 192.168.178.1 oder fritz.box · DVB-C muss aktiv sein"/>
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right">
<Separator Grid.Row="3" Margin="0,16,0,16" Background="#333"/>
<CheckBox Grid.Row="4" x:Name="ChkOnlineSources"
Content="Online-Sender anzeigen (ÖR-TV + Webradio)"
Foreground="White" FontSize="13"/>
<TextBlock Grid.Row="5" Margin="22,4,0,0" FontSize="11" Foreground="#666"
TextWrapping="Wrap"
Text="Zusätzliche Sender aus dem Internet (ARD, ZDF, 3sat, arte, Webradios). Funktioniert auch ohne FritzBox."/>
<StackPanel Grid.Row="7" Orientation="Horizontal" HorizontalAlignment="Right">
<Button Content="Abbrechen" Width="100" Height="32" Margin="0,0,8,0"
Click="BtnCancel_Click" Background="#333" Foreground="White" BorderThickness="0"/>
<Button Content="Speichern" Width="100" Height="32"

View file

@ -13,6 +13,7 @@ public partial class SettingsWindow : Window
DarkTitleBar.EnableFor(this);
_settings = settings;
TxtIp.Text = settings.FritzBoxIp;
ChkOnlineSources.IsChecked = settings.ShowOnlineSources;
}
private void BtnSave_Click(object sender, RoutedEventArgs e)
@ -25,6 +26,7 @@ public partial class SettingsWindow : Window
return;
}
_settings.FritzBoxIp = ip;
_settings.ShowOnlineSources = ChkOnlineSources.IsChecked == true;
DialogResult = true;
Close();
}

108
publish.ps1 Normal file
View file

@ -0,0 +1,108 @@
# HomeStream Release-Build und Upload zu Forgejo
# Verwendung:
# .\publish.ps1 # baut + zippt
# .\publish.ps1 -Tag v1.0.0 # taggt + baut + zippt + erstellt Release auf Forgejo
#
# Token wird aus Umgebungsvariable HOMESTREAM_TOKEN gelesen, ansonsten aus dem
# Default-Token unten (gleicher Token wie MailPrint).
param(
[string]$Tag = '',
[string]$Token = $env:HOMESTREAM_TOKEN
)
if (-not $Token) {
$Token = '63f650934f69d5684cb556a9a9e7d8e65495e257'
}
$ErrorActionPreference = 'Stop'
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$RepoUser = 'dimedtec'
$RepoName = 'HomeStream'
$ApiBase = "https://www.dimedtec.net/api/v1/repos/$RepoUser/$RepoName"
# ── 1. Tag setzen falls angegeben ─────────────────────────────────────────
if ($Tag) {
if ($Tag -notmatch '^v\d+\.\d+\.\d+') {
throw "Tag muss Format v1.2.3 haben"
}
Write-Host "→ Tag $Tag setzen..."
# Git schreibt Status auf stderr; mit try-catch abfangen damit PS nicht abbricht.
# Tag existiert evtl. schon — das ist OK, wir ignorieren den Fehler.
try { git -C $ScriptDir tag $Tag 2>$null | Out-Null } catch { }
try { git -C $ScriptDir push origin $Tag 2>$null | Out-Null } catch { }
Write-Host " OK"
}
# Aktuelle Version aus Git-Tag bestimmen
$currentTag = $null
try { $currentTag = (git -C $ScriptDir describe --tags --abbrev=0 2>$null) } catch { }
if (-not $currentTag) { $currentTag = 'v0.1.0' }
$version = $currentTag -replace '^v',''
# ── 2. Publish ────────────────────────────────────────────────────────────
$PublishDir = Join-Path $ScriptDir 'publish'
if (Test-Path $PublishDir) { Remove-Item -Recurse -Force $PublishDir }
Write-Host "→ Publish (self-contained, single-file, win-x64)..."
dotnet publish $ScriptDir\HomeStream.csproj `
-c Release `
-r win-x64 `
--self-contained true `
-p:PublishSingleFile=true `
-p:IncludeNativeLibrariesForSelfExtract=true `
-p:DebugType=None `
-p:DebugSymbols=false `
-o $PublishDir
if ($LASTEXITCODE -ne 0) { throw "Publish fehlgeschlagen" }
# Aufräumen: PDBs raus
Get-ChildItem $PublishDir -Filter *.pdb -Recurse | Remove-Item -Force
$exeSize = [math]::Round((Get-Item "$PublishDir\HomeStream.exe").Length / 1MB, 1)
Write-Host " HomeStream.exe: $exeSize MB"
# ── 3. ZIP packen ─────────────────────────────────────────────────────────
$ZipPath = Join-Path $ScriptDir "HomeStream-$version-win-x64.zip"
if (Test-Path $ZipPath) { Remove-Item $ZipPath }
Compress-Archive -Path "$PublishDir\*" -DestinationPath $ZipPath -CompressionLevel Optimal
$zipSize = [math]::Round((Get-Item $ZipPath).Length / 1MB, 1)
Write-Host "→ ZIP: $ZipPath ($zipSize MB)"
# ── 4. Release auf Forgejo erstellen (nur bei explizitem Tag) ─────────────
if (-not $Tag) {
Write-Host "`nFertig. Zum Veröffentlichen: .\publish.ps1 -Tag v$version"
exit 0
}
Write-Host "→ Forgejo-Release $Tag erstellen..."
$headers = @{ 'Authorization' = "token $Token"; 'Content-Type' = 'application/json; charset=utf-8' }
$bodyObj = @{
tag_name = $Tag
name = "HomeStream $version"
body = "Self-contained Release fuer Windows 10/11 (x64). Keine .NET-Installation noetig."
draft = $false
prerelease = $false
}
# Explizit UTF-8 kodieren damit Umlaute korrekt uebertragen werden
$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes(($bodyObj | ConvertTo-Json))
$release = Invoke-RestMethod -Uri "$ApiBase/releases" -Method Post -Headers $headers -Body $bodyBytes
Write-Host " Release-ID: $($release.id)"
# ── 5. ZIP als Asset hochladen ────────────────────────────────────────────
Write-Host "→ ZIP hochladen..."
$uploadUrl = "$ApiBase/releases/$($release.id)/assets?name=" + [uri]::EscapeDataString((Split-Path $ZipPath -Leaf))
$bytes = [System.IO.File]::ReadAllBytes($ZipPath)
# Multipart form upload via curl (PowerShell Invoke-WebRequest hat Issues mit grossen Files)
& curl.exe -X POST `
-H "Authorization: token $Token" `
-F "attachment=@$ZipPath" `
"$ApiBase/releases/$($release.id)/assets?name=$(Split-Path $ZipPath -Leaf)" `
--silent --show-error --output - | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Upload fehlgeschlagen" }
Write-Host "`n✅ Release veröffentlicht:"
Write-Host " $($release.html_url)"