Initial commit - HomeStream 0.1.0

This commit is contained in:
administrator 2026-05-10 23:25:29 +02:00
commit c0bb485a58
28 changed files with 2836 additions and 0 deletions

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Build-Ausgaben
bin/
obj/
publish/
*.zip
# Visual Studio
.vs/
*.user
*.suo
*.userosscache
*.sln.docobj
# Logs
logs/
*.log
# Temp
*.tmp
*.temp
# OS
Thumbs.db
.DS_Store

60
AboutWindow.xaml Normal file
View file

@ -0,0 +1,60 @@
<Window x:Class="FritzTV.AboutWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Über HomeStream"
Height="380" Width="440"
Background="#1A1A1A" Foreground="White"
ResizeMode="NoResize"
WindowStartupLocation="CenterOwner">
<Grid Margin="32">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Logo -->
<Image Grid.Row="0" Source="pack://application:,,,/Assets/logo.ico"
Width="80" Height="80"
HorizontalAlignment="Left" Margin="0,0,0,16"/>
<!-- Name + Version -->
<StackPanel Grid.Row="1" Margin="0,0,0,8">
<TextBlock Text="HomeStream" FontSize="28" FontWeight="Bold" Foreground="White"/>
<TextBlock x:Name="TxtVersion" FontSize="13" Foreground="#888"/>
</StackPanel>
<!-- Beschreibung -->
<TextBlock Grid.Row="2" Margin="0,8,0,16" FontSize="12" Foreground="#CCC"
TextWrapping="Wrap">
DVB-C Streaming-Client für FRITZ!Box-Router.
Empfängt TV- und Radio-Sender via RTSP, mit EPG, Favoriten und Senderlogos.
</TextBlock>
<!-- Links / Credits -->
<StackPanel Grid.Row="3">
<TextBlock FontSize="12" Foreground="#666" Margin="0,0,0,6">
<Run Text="© 2026 dimedtec GmbH"/>
</TextBlock>
<TextBlock FontSize="11" Foreground="#888" Margin="0,0,0,4">
<Hyperlink x:Name="LinkWeb" NavigateUri="https://dimedtec.net"
RequestNavigate="Hyperlink_RequestNavigate"
Foreground="#0078D4">dimedtec.net</Hyperlink>
</TextBlock>
<TextBlock FontSize="10" Foreground="#666" Margin="0,12,0,0" TextWrapping="Wrap">
Verwendet libVLC (LGPL) und Daten von tv.avm.de und epg.pw.
Nicht mit AVM GmbH oder Dritten verbunden oder von ihnen unterstützt.
</TextBlock>
</StackPanel>
<!-- OK Button -->
<Button Grid.Row="4" Content="OK" Width="80" Height="32"
HorizontalAlignment="Right"
Background="#0078D4" Foreground="White" BorderThickness="0"
Cursor="Hand"
Click="BtnOk_Click"/>
</Grid>
</Window>

33
AboutWindow.xaml.cs Normal file
View file

@ -0,0 +1,33 @@
using System.Diagnostics;
using System.Reflection;
using System.Windows;
using System.Windows.Navigation;
using FritzTV.Services;
namespace FritzTV;
public partial class AboutWindow : Window
{
public AboutWindow()
{
InitializeComponent();
DarkTitleBar.EnableFor(this);
// Version aus Assembly-Info laden
var asm = Assembly.GetExecutingAssembly();
var version = asm.GetName().Version?.ToString(3) ?? "?";
TxtVersion.Text = $"Version {version}";
}
private void Hyperlink_RequestNavigate(object sender, RequestNavigateEventArgs e)
{
try
{
Process.Start(new ProcessStartInfo(e.Uri.AbsoluteUri) { UseShellExecute = true });
e.Handled = true;
}
catch { }
}
private void BtnOk_Click(object sender, RoutedEventArgs e) => Close();
}

9
App.xaml Normal file
View file

@ -0,0 +1,9 @@
<Application x:Class="FritzTV.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=mscorlib"
StartupUri="MainWindow.xaml">
<Application.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis"/>
</Application.Resources>
</Application>

53
App.xaml.cs Normal file
View file

@ -0,0 +1,53 @@
using System.IO;
using System.Windows;
using System.Windows.Threading;
using FritzTV.Services;
namespace FritzTV;
public partial class App : Application
{
private static readonly string LogPath = AppPaths.CrashLog;
public App()
{
DispatcherUnhandledException += OnDispatcherUnhandledException;
AppDomain.CurrentDomain.UnhandledException += OnDomainUnhandledException;
TaskScheduler.UnobservedTaskException += OnTaskUnhandledException;
}
private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e)
{
LogAndShow("Dispatcher", e.Exception);
e.Handled = true;
}
private void OnDomainUnhandledException(object sender, UnhandledExceptionEventArgs e)
{
if (e.ExceptionObject is Exception ex) LogAndShow("Domain", ex);
}
private void OnTaskUnhandledException(object? sender, UnobservedTaskExceptionEventArgs e)
{
LogAndShow("Task", e.Exception);
e.SetObserved();
}
private static void LogAndShow(string source, Exception ex)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(LogPath)!);
File.AppendAllText(LogPath,
$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] [{source}] {ex}\n\n");
}
catch { }
try
{
MessageBox.Show($"{source} Exception:\n\n{ex.Message}\n\n{ex.StackTrace}",
"HomeStream — Fehler", MessageBoxButton.OK, MessageBoxImage.Error);
}
catch { }
}
}

10
AssemblyInfo.cs Normal file
View file

@ -0,0 +1,10 @@
using System.Windows;
[assembly:ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]

124
Assets/build-icon.ps1 Normal file
View file

@ -0,0 +1,124 @@
# SVG → multi-size .ico mittels WPF (Bordmittel, kein extra Tool nötig)
# Generiert logo.ico mit 16, 32, 48, 64, 128, 256 Pixeln
Add-Type -AssemblyName PresentationCore, PresentationFramework, WindowsBase
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$IcoPath = Join-Path $ScriptDir 'logo.ico'
function New-LogoBitmap {
param([int]$Size)
$dv = [System.Windows.Media.DrawingVisual]::new()
$dc = $dv.RenderOpen()
$scale = $Size / 256.0
# Hintergrund
$bgRect = [System.Windows.Rect]::new(8 * $scale, 8 * $scale, 240 * $scale, 240 * $scale)
$bgBrush = [System.Windows.Media.SolidColorBrush]::new(
[System.Windows.Media.Color]::FromRgb(0x1A, 0x1A, 0x1A))
$dc.DrawRoundedRectangle($bgBrush, $null, $bgRect, 40 * $scale, 40 * $scale)
# Blauer Gradient
$gradStart = [System.Windows.Media.Color]::FromRgb(0x00, 0x78, 0xD4)
$gradEnd = [System.Windows.Media.Color]::FromRgb(0x00, 0x5A, 0x9E)
$grad = [System.Windows.Media.LinearGradientBrush]::new($gradStart, $gradEnd, 90)
$grad.Freeze()
# Bildschirm-Rahmen
$screenRect = [System.Windows.Rect]::new(40 * $scale, 56 * $scale, 176 * $scale, 120 * $scale)
$screenPen = [System.Windows.Media.Pen]::new($grad, 6 * $scale)
$dc.DrawRoundedRectangle($null, $screenPen, $screenRect, 10 * $scale, 10 * $scale)
# Streaming-Wellen
$wavePen = [System.Windows.Media.Pen]::new($grad, 5 * $scale)
$wavePen.StartLineCap = 'Round'
$wavePen.EndLineCap = 'Round'
foreach ($wave in @(@(70, 140, 90, 110, 120, 110), @(70, 140, 100, 90, 145, 90), @(70, 140, 110, 70, 170, 70))) {
$geom = [System.Windows.Media.StreamGeometry]::new()
$ctx = $geom.Open()
$ctx.BeginFigure([System.Windows.Point]::new($wave[0] * $scale, $wave[1] * $scale), $false, $false)
$ctx.QuadraticBezierTo(
[System.Windows.Point]::new($wave[2] * $scale, $wave[3] * $scale),
[System.Windows.Point]::new($wave[4] * $scale, $wave[5] * $scale), $true, $false)
$ctx.Close()
$geom.Freeze()
$dc.DrawGeometry($null, $wavePen, $geom)
}
# Punkt am Ursprung
$dc.DrawEllipse($grad, $null,
[System.Windows.Point]::new(70 * $scale, 140 * $scale),
6 * $scale, 6 * $scale)
# Standfuß
$foot1 = [System.Windows.Rect]::new(100 * $scale, 184 * $scale, 56 * $scale, 8 * $scale)
$foot2 = [System.Windows.Rect]::new( 80 * $scale, 196 * $scale, 96 * $scale, 8 * $scale)
$dc.DrawRoundedRectangle($grad, $null, $foot1, 2 * $scale, 2 * $scale)
$dc.DrawRoundedRectangle($grad, $null, $foot2, 3 * $scale, 3 * $scale)
$dc.Close()
$rtb = [System.Windows.Media.Imaging.RenderTargetBitmap]::new(
$Size, $Size, 96, 96, [System.Windows.Media.PixelFormats]::Pbgra32)
$rtb.Render($dv)
return $rtb
}
function Get-PngBytes {
param($bitmap)
$encoder = [System.Windows.Media.Imaging.PngBitmapEncoder]::new()
$encoder.Frames.Add([System.Windows.Media.Imaging.BitmapFrame]::Create($bitmap))
$ms = [System.IO.MemoryStream]::new()
$encoder.Save($ms)
$bytes = $ms.ToArray()
$ms.Dispose()
return ,$bytes
}
# Generierung mit ArrayList damit Wrapping vorhersehbar ist
$sizes = @(16, 32, 48, 64, 128, 256)
$pngList = New-Object System.Collections.ArrayList
foreach ($size in $sizes) {
Write-Host " Rendere ${size}x${size}..."
$bmp = New-LogoBitmap -Size $size
$bytes = Get-PngBytes $bmp
[void]$pngList.Add($bytes)
Write-Host " PNG: $($bytes.Length) bytes"
}
# ICO bauen
$out = [System.IO.MemoryStream]::new()
$bw = [System.IO.BinaryWriter]::new($out)
$bw.Write([UInt16]0) # reserved
$bw.Write([UInt16]1) # type = ICO
$bw.Write([UInt16]$pngList.Count) # count
$dataOffset = 6 + ($pngList.Count * 16)
for ($i = 0; $i -lt $pngList.Count; $i++) {
$size = $sizes[$i]
$w = if ($size -eq 256) { 0 } else { $size }
$h = if ($size -eq 256) { 0 } else { $size }
$len = $pngList[$i].Length
$bw.Write([byte]$w)
$bw.Write([byte]$h)
$bw.Write([byte]0)
$bw.Write([byte]0)
$bw.Write([UInt16]1)
$bw.Write([UInt16]32)
$bw.Write([UInt32]$len)
$bw.Write([UInt32]$dataOffset)
$dataOffset += $len
}
for ($i = 0; $i -lt $pngList.Count; $i++) {
$bytes = $pngList[$i]
$bw.Write($bytes, 0, $bytes.Length)
}
$bw.Flush()
[System.IO.File]::WriteAllBytes($IcoPath, $out.ToArray())
$bw.Close()
$out.Dispose()
$icoSize = (Get-Item $IcoPath).Length
Write-Host "`nFertig: $IcoPath ($icoSize Bytes, $($pngList.Count) Größen)"

BIN
Assets/logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

29
Assets/logo.svg Normal file
View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<!-- HomeStream Logo: stilisierter Bildschirm mit Streaming-Wellen -->
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#0078D4"/>
<stop offset="100%" stop-color="#005A9E"/>
</linearGradient>
</defs>
<!-- Hintergrund (rounded square) -->
<rect x="8" y="8" width="240" height="240" rx="40" ry="40" fill="#1A1A1A"/>
<!-- Bildschirm-Rahmen -->
<rect x="40" y="56" width="176" height="120" rx="10" ry="10"
fill="none" stroke="url(#grad)" stroke-width="6"/>
<!-- Bildschirm-Inhalt: Wellen (Streaming) -->
<g stroke="url(#grad)" stroke-width="5" stroke-linecap="round" fill="none">
<path d="M 70 140 Q 90 110, 120 110"/>
<path d="M 70 140 Q 100 90, 145 90"/>
<path d="M 70 140 Q 110 70, 170 70"/>
<circle cx="70" cy="140" r="6" fill="url(#grad)"/>
</g>
<!-- Standfuß -->
<rect x="100" y="184" width="56" height="8" rx="2" ry="2" fill="url(#grad)"/>
<rect x="80" y="196" width="96" height="8" rx="3" ry="3" fill="url(#grad)"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

20
Directory.Build.props Normal file
View file

@ -0,0 +1,20 @@
<Project>
<PropertyGroup>
<!-- Default-Version, falls kein Git-Tag vorhanden -->
<Version>0.1.0</Version>
</PropertyGroup>
<Target Name="SetVersionFromGit" BeforeTargets="GetAssemblyVersion;Build;Publish">
<Exec Command="git describe --tags --abbrev=0 2&gt;nul" ConsoleToMSBuild="true" IgnoreExitCode="true">
<Output TaskParameter="ConsoleOutput" PropertyName="GitTagRaw" />
</Exec>
<PropertyGroup>
<!-- v1.2.3 → 1.2.3 -->
<GitTagClean>$(GitTagRaw.TrimStart('v').Trim())</GitTagClean>
<Version Condition="'$(GitTagClean)' != ''">$(GitTagClean)</Version>
<AssemblyVersion Condition="'$(GitTagClean)' != ''">$(GitTagClean).0</AssemblyVersion>
<FileVersion Condition="'$(GitTagClean)' != ''">$(GitTagClean).0</FileVersion>
</PropertyGroup>
<Message Text="Build-Version: $(Version)" Importance="high" />
</Target>
</Project>

68
EpgChannelWindow.xaml Normal file
View file

@ -0,0 +1,68 @@
<Window x:Class="FritzTV.EpgChannelWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="Programm" Height="600" Width="540"
Background="#1A1A1A" Foreground="White"
WindowStartupLocation="CenterOwner">
<Window.Resources>
<Style x:Key="EpgItem" TargetType="ListBoxItem">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="#DDD"/>
<Setter Property="Padding" Value="12,8"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="bd" Background="{TemplateBinding Background}"
BorderBrush="#222" BorderThickness="0,0,0,1"
Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="#2A2A2A"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<DockPanel>
<Border DockPanel.Dock="Top" Background="#0A0A0A" Padding="16,12">
<StackPanel>
<TextBlock x:Name="TxtTitle" Foreground="White" FontSize="18" FontWeight="Bold"/>
<TextBlock x:Name="TxtStatus" Foreground="#888" FontSize="11" Margin="0,4,0,0"/>
</StackPanel>
</Border>
<ListBox x:Name="LstEvents"
Background="Transparent" BorderThickness="0"
ItemContainerStyle="{StaticResource EpgItem}"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="80"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock Text="{Binding TimeRange}" FontSize="13" FontWeight="SemiBold" Foreground="#0078D4"/>
<TextBlock Text="{Binding DurationLabel}" FontSize="10" Foreground="#666"/>
</StackPanel>
<StackPanel Grid.Column="1">
<TextBlock Text="{Binding Title}" FontSize="13" FontWeight="SemiBold"
Foreground="{Binding TitleBrush}" TextWrapping="Wrap"/>
<TextBlock Text="{Binding Description}" FontSize="11" Foreground="#888"
TextWrapping="Wrap" Margin="0,2,0,0"
MaxHeight="40" TextTrimming="CharacterEllipsis"/>
</StackPanel>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Window>

82
EpgChannelWindow.xaml.cs Normal file
View file

@ -0,0 +1,82 @@
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Media;
using FritzTV.Models;
using FritzTV.Services;
namespace FritzTV;
public partial class EpgChannelWindow : Window
{
private readonly EpgService _epg;
private readonly string _channelName;
public EpgChannelWindow(EpgService epg, string channelName)
{
InitializeComponent();
Services.DarkTitleBar.EnableFor(this);
_epg = epg;
_channelName = channelName;
TxtTitle.Text = $"Programm: {channelName}";
Loaded += async (_, _) => await LoadAsync();
}
private async Task LoadAsync()
{
if (!_epg.IsCurrent)
{
TxtStatus.Text = "Lade EPG-Daten...";
try
{
await _epg.LoadAsync();
}
catch (Exception ex)
{
TxtStatus.Text = $"Fehler: {ex.Message}";
return;
}
}
var events = _epg.GetEvents(_channelName, hoursAhead: 24 * 7);
if (events.Count == 0)
{
TxtStatus.Text = $"Keine EPG-Daten für '{_channelName}' gefunden.";
return;
}
TxtStatus.Text = $"{events.Count} Sendungen, nächste 7 Tage";
var items = new ObservableCollection<EpgItem>();
foreach (var e in events) items.Add(new EpgItem(e));
LstEvents.ItemsSource = items;
// Aktuell laufendes Event in den Sichtbereich scrollen
var current = items.FirstOrDefault(i => i.IsCurrent);
if (current != null) LstEvents.ScrollIntoView(current);
}
}
/// <summary>ViewModel für ein EPG-Event in der Listbox</summary>
public class EpgItem
{
public string TimeRange { get; }
public string DurationLabel { get; }
public string Title { get; }
public string? Description { get; }
public bool IsCurrent { get; }
public Brush TitleBrush { get; }
public EpgItem(EpgEvent e)
{
IsCurrent = e.IsCurrent;
var dayLabel = e.StartTime.Date == DateTime.Today
? ""
: e.StartTime.ToString("ddd ", System.Globalization.CultureInfo.GetCultureInfo("de-DE"));
TimeRange = $"{dayLabel}{e.StartTime:HH:mm}";
DurationLabel = $"{(int)e.Duration.TotalMinutes} min";
Title = e.Title;
Description = e.Description;
TitleBrush = IsCurrent
? new SolidColorBrush(Color.FromRgb(0x00, 0x78, 0xD4))
: Brushes.White;
}
}

30
HomeStream.csproj Normal file
View file

@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<!-- Produkt-Metadaten -->
<AssemblyName>HomeStream</AssemblyName>
<Product>HomeStream</Product>
<Company>dimedtec GmbH</Company>
<Copyright>© 2026 dimedtec GmbH</Copyright>
<!-- Window- und Taskleisten-Icon -->
<ApplicationIcon>Assets\logo.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LibVLCSharp.WPF" Version="3.9.7.1" />
<PackageReference Include="VideoLAN.LibVLC.Windows" Version="3.0.23.1" />
</ItemGroup>
<ItemGroup>
<Resource Include="Assets\logo.ico" />
<Resource Include="Assets\logo.svg" />
</ItemGroup>
</Project>

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 dimedtec GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

348
MainWindow.xaml Normal file
View file

@ -0,0 +1,348 @@
<Window x:Class="FritzTV.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vlc="clr-namespace:LibVLCSharp.WPF;assembly=LibVLCSharp.WPF"
Title="HomeStream"
Height="720" Width="1280"
Background="#1A1A1A"
WindowStartupLocation="CenterScreen">
<Window.Resources>
<Style x:Key="SidebarButton" TargetType="Button">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="#DDD"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Padding" Value="16,10"/>
<Setter Property="HorizontalContentAlignment" Value="Left"/>
<Setter Property="FontSize" Value="14"/>
<Setter Property="Cursor" Value="Hand"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}">
<ContentPresenter VerticalAlignment="Center"/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="#2A2A2A"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<!-- Dunkle Scrollbars (sonst sind die Standard-WPF-Scrollbars hellgrau) -->
<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>
<Style x:Key="ChannelItem" TargetType="ListBoxItem">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Foreground" Value="#DDD"/>
<Setter Property="Padding" Value="12,8"/>
<Setter Property="BorderThickness" Value="0"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="bd" Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="bd" Property="Background" Value="#2A2A2A"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="bd" Property="Background" Value="#0078D4"/>
<Setter Property="Foreground" Value="White"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</Window.Resources>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition x:Name="SidebarColumn" Width="180"/>
<ColumnDefinition x:Name="ChannelsColumn" Width="280"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- Burger-Toggle: außerhalb der VideoView (sichtbar wenn Spalten eingeklappt + KEIN Sender läuft).
Wenn ein Sender läuft greift der Overlay-Burger innerhalb VideoView (HWND-Airspace). -->
<!-- (kein eigener Button hier mehr — BtnSidebarToggleOverlay reicht) -->
<!-- Sidebar: Kategorien -->
<Border Grid.Column="0" Background="#161616" BorderBrush="#0A0A0A" BorderThickness="0,0,1,0">
<DockPanel>
<Grid DockPanel.Dock="Top">
<TextBlock Text="HomeStream"
Foreground="White" FontSize="20" FontWeight="Bold"
Padding="16,20,16,16"/>
<Button x:Name="BtnCollapseSidebar"
HorizontalAlignment="Right" VerticalAlignment="Top"
Width="28" Height="28" Margin="0,16,8,0"
Background="Transparent" Foreground="#888" BorderThickness="0"
Content="" FontSize="14" Cursor="Hand"
Click="BtnCollapseSidebar_Click"
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"/>
<Separator Margin="8" Background="#333"/>
<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>
<StackPanel DockPanel.Dock="Bottom" Margin="16,0,16,16">
<Button x:Name="BtnSettings" Content="⚙ Einstellungen" Style="{StaticResource SidebarButton}" Click="BtnSettings_Click"/>
<Button x:Name="BtnAbout" Content=" Über" Style="{StaticResource SidebarButton}" Click="BtnAbout_Click"/>
<TextBlock x:Name="TxtFritzBox" Foreground="#666" FontSize="11" Margin="16,8,0,0"/>
</StackPanel>
</DockPanel>
</Border>
<!-- Kanal-Liste -->
<Border Grid.Column="1" Background="#1A1A1A" BorderBrush="#0A0A0A" BorderThickness="0,0,1,0">
<DockPanel>
<Grid DockPanel.Dock="Top">
<TextBox x:Name="TxtSearch"
Margin="12,12,40,4" Padding="8,8,28,8" FontSize="13"
Background="#2A2A2A" Foreground="White" BorderBrush="#444"
TextChanged="TxtSearch_TextChanged"
Tag="Suchen…"/>
<Button x:Name="BtnClearSearch" Content="✕" FontSize="11"
HorizontalAlignment="Right" VerticalAlignment="Center"
Width="22" Height="22" Margin="0,4,46,0"
Background="Transparent" Foreground="#888" BorderThickness="0"
Cursor="Hand" Visibility="Collapsed"
Click="BtnClearSearch_Click"
ToolTip="Suche zurücksetzen"/>
<Button x:Name="BtnCollapseList"
HorizontalAlignment="Right" VerticalAlignment="Top"
Width="28" Height="28" Margin="0,12,8,0"
Background="Transparent" Foreground="#888" BorderThickness="0"
Content="" FontSize="14" Cursor="Hand"
Click="BtnCollapseList_Click"
ToolTip="Senderliste einklappen"/>
</Grid>
<TextBlock x:Name="TxtStatus" DockPanel.Dock="Bottom"
Foreground="#666" FontSize="11" Padding="12,8"/>
<ListBox x:Name="LstChannels"
Background="Transparent" BorderThickness="0"
ItemContainerStyle="{StaticResource ChannelItem}"
SelectionChanged="LstChannels_SelectionChanged"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="40"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Image Grid.Column="0" Source="{Binding LogoPath}"
Width="32" Height="24" Stretch="Uniform"
VerticalAlignment="Center" HorizontalAlignment="Left"
RenderOptions.BitmapScalingMode="HighQuality"/>
<TextBlock Grid.Column="1" Text="{Binding Name}" FontSize="14" FontWeight="SemiBold"
VerticalAlignment="Center" Margin="6,0,0,0"/>
<TextBlock Grid.Column="2" VerticalAlignment="Center"
Text="⭐" FontSize="14"
Visibility="{Binding IsFavorite, Converter={StaticResource BoolToVis}}"/>
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
<!-- Player + EPG -->
<Grid Grid.Column="2" Background="#000">
<Grid.RowDefinitions>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- VideoView -->
<Grid Grid.Row="0">
<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">
<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"/>
<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"/>
</StackPanel>
</Border>
<!-- EPG-Overlay (Joyn-Style): liegt INNERHALB der VideoView damit Klicks ber HWND funktionieren -->
<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"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="Programm"
Foreground="White" FontSize="24" FontWeight="Bold"
VerticalAlignment="Center" Margin="24,0"/>
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center" Margin="0,0,16,0">
<TextBlock x:Name="TxtEpgDate" Foreground="#CCC" FontSize="14" VerticalAlignment="Center" Margin="0,0,16,0"/>
<TextBlock x:Name="TxtEpgTime" Foreground="White" FontSize="18" FontWeight="SemiBold" VerticalAlignment="Center" Margin="0,0,16,0"/>
<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)"/>
</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) -->
<ScrollViewer x:Name="EpgScrollViewer"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
Background="#0A0A0A">
<Canvas x:Name="EpgCanvas"/>
</ScrollViewer>
</DockPanel>
</Border>
</Grid>
</Border>
</vlc:VideoView>
<TextBlock x:Name="TxtNoChannel"
Text="Wähle einen Sender aus der Liste"
Foreground="#666" FontSize="18"
HorizontalAlignment="Center" VerticalAlignment="Center"
IsHitTestVisible="False"/>
</Grid>
<!-- Bottom Bar: EPG + Controls -->
<Border x:Name="BottomBar" Grid.Row="1" Background="#161616" BorderBrush="#0A0A0A" BorderThickness="0,1,0,0">
<DockPanel>
<!-- Hauptzeile mit Sender + Controls -->
<Grid Margin="16,12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0">
<TextBlock x:Name="TxtCurrentChannel"
Foreground="White" FontSize="18" FontWeight="Bold"/>
<TextBlock x:Name="TxtEpgNow"
Foreground="#DDD" FontSize="12" Margin="0,4,0,0"
TextWrapping="Wrap"/>
<TextBlock x:Name="TxtEpgNext"
Foreground="#888" FontSize="11" Margin="0,2,0,0"
TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" VerticalAlignment="Center">
<Button x:Name="BtnFavToggle" Content="☆" FontSize="20" Width="40" Height="40"
Background="Transparent" Foreground="White" BorderThickness="0"
Click="BtnFavToggle_Click" Cursor="Hand" ToolTip="Favorit"/>
<Button x:Name="BtnMute" Content="🔊" FontSize="16" Width="40" Height="40"
Background="Transparent" Foreground="White" BorderThickness="0"
Click="BtnMute_Click" Cursor="Hand" ToolTip="Stumm"/>
<Slider x:Name="SldVolume" Width="100" Minimum="0" Maximum="100" Value="80"
VerticalAlignment="Center" Margin="8,0"
ValueChanged="SldVolume_ValueChanged"/>
<Button x:Name="BtnFullscreen" Content="⛶" FontSize="16" Width="40" Height="40"
Background="Transparent" Foreground="White" BorderThickness="0"
Click="BtnFullscreen_Click" Cursor="Hand" ToolTip="Vollbild (F11)"/>
</StackPanel>
</Grid>
</DockPanel>
</Border>
</Grid>
</Grid>
</Window>

1041
MainWindow.xaml.cs Normal file

File diff suppressed because it is too large Load diff

34
Models/Channel.cs Normal file
View file

@ -0,0 +1,34 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace FritzTV.Models;
public enum ChannelKind { TvSd, TvHd, Radio }
public class Channel : INotifyPropertyChanged
{
public required string Name { get; init; }
public required string Url { get; init; }
public required ChannelKind Kind { get; init; }
public int Number { get; set; }
private bool _isFavorite;
public bool IsFavorite
{
get => _isFavorite;
set { if (_isFavorite != value) { _isFavorite = value; OnChanged(); } }
}
private string? _logoPath;
public string? LogoPath
{
get => _logoPath;
set { if (_logoPath != value) { _logoPath = value; OnChanged(); } }
}
public override string ToString() => $"{Number,3}. {Name}";
public event PropertyChangedEventHandler? PropertyChanged;
private void OnChanged([CallerMemberName] string? name = null)
=> PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

12
Models/EpgEvent.cs Normal file
View file

@ -0,0 +1,12 @@
namespace FritzTV.Models;
public class EpgEvent
{
public required string ChannelName { get; init; }
public required string Title { get; init; }
public string? Description { get; init; }
public DateTime StartTime { get; init; }
public TimeSpan Duration { get; init; }
public DateTime EndTime => StartTime + Duration;
public bool IsCurrent => DateTime.Now >= StartTime && DateTime.Now < EndTime;
}

95
README.md Normal file
View file

@ -0,0 +1,95 @@
# HomeStream
**DVB-C Streaming-Client für AVM FRITZ!Box-Router unter Windows.**
Empfängt das TV- und Radio-Signal aus dem Kabelanschluss deiner FRITZ!Box per RTSP und zeigt es nativ am PC an. Inklusive EPG, Favoriten, Sender-Logos und RDS-Radiotext.
Kostenlos und quelloffen.
---
## Features
- 📺 **Live-TV** alle SD- und HD-Sender deiner FRITZ!Box (HD bevorzugt, automatisch zusammengeführt)
- 📻 **Radio** mit Cover, Sendername und RDS-Radiotext (Künstler Titel)
- 🖼️ **Senderlogos** automatisch geladen von AVM (TV + Radio)
- 📅 **EPG** Now/Next-Anzeige + scrollbares Joyn-Style-Programmraster aller Sender (8h)
- ⭐ **Favoriten** persistent gespeichert
- 🔍 **Sender-Suche** Live-Filter mit X-Button
- ↔️ **Sidebar einklappbar** beide Seitenleisten unabhängig (Strg+B togglet)
- 🖥️ **Vollbild** per Doppelklick oder F11
- 🌙 **Dark Mode** Titelleiste, Scrollbars, alles dunkel
- 🔊 **Lautstärke und Sender werden gemerkt** beim nächsten Start
---
## Voraussetzungen
- Windows 10 / 11
- AVM FRITZ!Box mit aktivem DVB-C-Empfang (Kabel-Tuner)
- Erreichbar im LAN (Standard-IP `192.168.178.1`, einstellbar)
---
## Installation
### 1. Release herunterladen
Aktuelles Release von [dimedtec.net/dimedtec/HomeStream](https://www.dimedtec.net/dimedtec/HomeStream/releases) als ZIP herunterladen.
### 2. Entpacken
Inhalt in einen beliebigen Ordner entpacken, z.B.:
```
C:\Programme\HomeStream\
```
### 3. Starten
`HomeStream.exe` doppelklicken. Beim ersten Start unter **⚙ Einstellungen** die FritzBox-IP eintragen.
---
## Hotkeys
| Taste | Funktion |
|---|---|
| **F11** oder **Doppelklick** | Vollbild umschalten |
| **Esc** | Vollbild verlassen / EPG schließen |
| **Strg+B** | Sidebar + Senderliste umschalten |
| **↑ / ↓** | Sender vor/zurück |
| **M** | Stumm |
---
## Daten
HomeStream speichert lokal unter `%APPDATA%\HomeStream\`:
- `settings.json` FritzBox-IP, Favoriten, letzter Sender, Lautstärke
- `logos\` Sender-Logos (von tv.avm.de und download.avm.de)
- `epg\` EPG-Cache (24 h)
- `crash.log` falls die App abstürzt
---
## Build aus dem Source
```powershell
git clone https://www.dimedtec.net/dimedtec/HomeStream.git
cd HomeStream
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true
```
Die Versionsnummer kommt automatisch aus dem aktuellen Git-Tag (`git describe --tags`).
---
## Hinweis
HomeStream verwendet [libVLC](https://www.videolan.org/vlc/libvlc.html) (LGPL) und Daten von `tv.avm.de` und `epg.pw`. Die Software ist nicht mit AVM GmbH verbunden oder von dieser unterstützt; "FRITZ!" und "FRITZ!Box" sind eingetragene Marken der AVM GmbH.
---
## Lizenz
MIT siehe [LICENSE](LICENSE)

38
Services/AppPaths.cs Normal file
View file

@ -0,0 +1,38 @@
using System.IO;
namespace FritzTV.Services;
/// <summary>
/// Liefert den AppData-Pfad der App. Migriert beim ersten Start automatisch
/// alle Daten vom alten "FritzTV"-Pfad herüber, falls vorhanden.
/// </summary>
public static class AppPaths
{
private const string AppFolderName = "HomeStream";
private const string LegacyFolderName = "FritzTV";
private static readonly Lazy<string> _root = new(() =>
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
var newDir = Path.Combine(appData, AppFolderName);
var oldDir = Path.Combine(appData, LegacyFolderName);
// One-Time Migration: alter Ordner existiert, neuer noch nicht
if (Directory.Exists(oldDir) && !Directory.Exists(newDir))
{
try { Directory.Move(oldDir, newDir); }
catch { Directory.CreateDirectory(newDir); }
}
else
{
Directory.CreateDirectory(newDir);
}
return newDir;
});
public static string Root => _root.Value;
public static string Settings => Path.Combine(Root, "settings.json");
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");
}

40
Services/AppSettings.cs Normal file
View file

@ -0,0 +1,40 @@
using System.IO;
using System.Text.Json;
namespace FritzTV.Services;
public class AppSettings
{
public string FritzBoxIp { get; set; } = "192.168.4.254";
public List<string> Favorites { get; set; } = new();
public string LastChannel { get; set; } = "";
public double Volume { get; set; } = 80;
private static readonly string ConfigPath = AppPaths.Settings;
public static AppSettings Load()
{
try
{
if (File.Exists(ConfigPath))
{
var json = File.ReadAllText(ConfigPath);
return JsonSerializer.Deserialize<AppSettings>(json) ?? new AppSettings();
}
}
catch { /* fallback auf Defaults */ }
return new AppSettings();
}
public void Save()
{
try
{
var dir = Path.GetDirectoryName(ConfigPath)!;
Directory.CreateDirectory(dir);
var json = JsonSerializer.Serialize(this, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(ConfigPath, json);
}
catch { /* nicht kritisch */ }
}
}

33
Services/DarkTitleBar.cs Normal file
View file

@ -0,0 +1,33 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Interop;
namespace FritzTV.Services;
/// <summary>
/// Schaltet die Windows-Titelleiste eines WPF-Fensters auf Dark Mode.
/// Funktioniert ab Windows 10 Build 1809; ältere Versionen behalten Standard-Look.
/// </summary>
public static class DarkTitleBar
{
[DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int attrValue, int attrSize);
private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20; // Win10 20H1+
private const int DWMWA_USE_IMMERSIVE_DARK_MODE_OLD = 19; // Win10 18091909
/// <summary>Im SourceInitialized-Handler des Windows aufrufen.</summary>
public static void Apply(Window w)
{
var hwnd = new WindowInteropHelper(w).Handle;
if (hwnd == IntPtr.Zero) return;
int useDark = 1;
if (DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ref useDark, sizeof(int)) != 0)
DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE_OLD, ref useDark, sizeof(int));
}
/// <summary>Hängt sich an SourceInitialized — bequemer Helper.</summary>
public static void EnableFor(Window w)
{
w.SourceInitialized += (_, _) => Apply(w);
}
}

214
Services/EpgService.cs Normal file
View file

@ -0,0 +1,214 @@
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Xml.Linq;
using FritzTV.Models;
namespace FritzTV.Services;
/// <summary>
/// Lädt EPG-Daten als XMLTV von einer öffentlichen Quelle.
/// Cache-Strategie: pro Tag eine Datei in %APPDATA%\FritzTV\epg\
/// XMLTV-Format: https://wiki.xmltv.org/index.php/XMLTVFormat
///
/// Quelle: epg.pw bietet kostenlose XMLTV-Feeds für DE (DVB-T/Cable Lineup),
/// alternative: xmltv.se (für deutsche Sender funktioniert Mappingname).
/// </summary>
public class EpgService
{
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromMinutes(2) };
/// <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>In-Memory-Index: Sendername (normalisiert) → Liste der Events, sortiert nach Startzeit</summary>
private Dictionary<string, List<EpgEvent>> _eventsByChannel = new();
private DateTime _loadedAt = DateTime.MinValue;
/// <summary>True wenn Daten geladen sind und nicht älter als 12h</summary>
public bool IsCurrent => _eventsByChannel.Count > 0
&& (DateTime.Now - _loadedAt).TotalHours < 12;
/// <summary>Lädt EPG-Daten (Cache-Hit oder Web), parst und indiziert sie</summary>
public async Task LoadAsync(IProgress<string>? progress = null)
{
Directory.CreateDirectory(CacheDir);
var todayFile = Path.Combine(CacheDir, $"epg_{DateTime.Today:yyyyMMdd}.xml");
if (!File.Exists(todayFile) || new FileInfo(todayFile).Length < 1000)
{
progress?.Report("Lade EPG-Daten…");
await DownloadAndExtractAsync(todayFile);
CleanupOldCache();
}
else
{
progress?.Report("EPG-Cache verwendet");
}
progress?.Report("Parse EPG…");
ParseXmlTv(todayFile);
_loadedAt = DateTime.Now;
progress?.Report($"EPG: {_eventsByChannel.Count} Sender, {_eventsByChannel.Values.Sum(l => l.Count)} Events");
}
/// <summary>Hole alle Events für einen Sender ab jetzt für N Stunden</summary>
public List<EpgEvent> GetEvents(string channelName, int hoursAhead = 24)
{
var key = NormalizeName(channelName);
if (!_eventsByChannel.TryGetValue(key, out var list)) return new();
var now = DateTime.Now;
var until = now.AddHours(hoursAhead);
return list.Where(e => e.EndTime >= now && e.StartTime <= until)
.OrderBy(e => e.StartTime)
.ToList();
}
/// <summary>Aktuelles Event für einen Sender (jetzt laufend)</summary>
public EpgEvent? GetCurrent(string channelName)
=> GetEvents(channelName, 1).FirstOrDefault(e => e.IsCurrent);
// ────────── Internals ──────────
private static async Task DownloadAndExtractAsync(string targetFile)
{
var tmpGz = targetFile + ".gz";
try
{
using (var resp = await _http.GetAsync(EpgUrl, HttpCompletionOption.ResponseHeadersRead))
{
resp.EnsureSuccessStatusCode();
using var fs = File.Create(tmpGz);
await resp.Content.CopyToAsync(fs);
}
// Gzip entpacken
using var inStream = File.OpenRead(tmpGz);
using var gz = new GZipStream(inStream, CompressionMode.Decompress);
using var outStream = File.Create(targetFile);
await gz.CopyToAsync(outStream);
}
finally
{
try { if (File.Exists(tmpGz)) File.Delete(tmpGz); } catch { }
}
}
private void ParseXmlTv(string path)
{
// Sender-ID → Sendername (xmltv hat <channel id="..."><display-name>...</display-name></channel>)
var idToName = new Dictionary<string, string>();
var events = new Dictionary<string, List<EpgEvent>>(StringComparer.OrdinalIgnoreCase);
using var fs = File.OpenRead(path);
var doc = XDocument.Load(fs);
var root = doc.Root;
if (root == null) return;
foreach (var ch in root.Elements("channel"))
{
var id = ch.Attribute("id")?.Value ?? "";
var name = ch.Element("display-name")?.Value ?? "";
if (id.Length > 0 && name.Length > 0)
idToName[id] = name;
}
foreach (var prog in root.Elements("programme"))
{
var channelId = prog.Attribute("channel")?.Value;
if (channelId == null || !idToName.TryGetValue(channelId, out var channelName))
continue;
var startStr = prog.Attribute("start")?.Value ?? "";
var stopStr = prog.Attribute("stop")?.Value ?? "";
var start = ParseXmltvTime(startStr);
var stop = ParseXmltvTime(stopStr);
if (start == DateTime.MinValue || stop == DateTime.MinValue) continue;
var title = prog.Element("title")?.Value ?? "";
var desc = prog.Element("desc")?.Value;
var subtitle = prog.Element("sub-title")?.Value;
var ev = new EpgEvent
{
ChannelName = channelName,
Title = title,
Description = !string.IsNullOrWhiteSpace(subtitle) ? subtitle : desc,
StartTime = start,
Duration = stop - start
};
var key = NormalizeName(channelName);
if (!events.TryGetValue(key, out var list))
events[key] = list = new List<EpgEvent>();
list.Add(ev);
}
// Sortieren je Sender
foreach (var k in events.Keys.ToList())
events[k] = events[k].OrderBy(e => e.StartTime).ToList();
_eventsByChannel = events;
}
/// <summary>XMLTV-Zeit "20260510120000 +0200" → DateTime (lokale Zeit)</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);
if (parts.Length == 2 && parts[1].Length == 5)
{
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;
}
return dt;
}
catch { return DateTime.MinValue; }
}
/// <summary>Sendername normalisieren für Matching (FritzBox vs XMLTV)</summary>
public static string NormalizeName(string name)
{
if (string.IsNullOrWhiteSpace(name)) return "";
var s = name.Trim().ToLowerInvariant();
// HD/SD-Suffixe entfernen
string[] suffixes = { " hd", " uhd", " 4k", " sd", " austria", " österreich", " schweiz" };
foreach (var suf in suffixes)
if (s.EndsWith(suf)) s = s[..^suf.Length].TrimEnd();
// Sonderzeichen rauswerfen
s = new string(s.Where(c => char.IsLetterOrDigit(c) || c == ' ').ToArray());
s = string.Join(" ", s.Split(' ', StringSplitOptions.RemoveEmptyEntries));
return s;
}
private static void CleanupOldCache()
{
try
{
var files = Directory.GetFiles(CacheDir, "epg_*.xml*");
foreach (var f in files)
{
var info = new FileInfo(f);
if ((DateTime.Now - info.LastWriteTime).TotalDays > 2)
info.Delete();
}
}
catch { }
}
}

View file

@ -0,0 +1,41 @@
using System.Net.Http;
using System.Text;
using FritzTV.Models;
namespace FritzTV.Services;
/// <summary>
/// Lädt M3U-Listen von der FritzBox und parst sie in Channel-Objekte.
/// FritzBox-Endpunkte:
/// /dvb/m3u/tvsd.m3u
/// /dvb/m3u/tvhd.m3u
/// /dvb/m3u/radio.m3u
/// </summary>
public class FritzBoxClient
{
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(15) };
private readonly string _baseUrl;
public FritzBoxClient(string fritzBoxIp)
{
_baseUrl = $"http://{fritzBoxIp}";
}
public Task<List<Channel>> LoadTvSdAsync() => LoadAsync("/dvb/m3u/tvsd.m3u", ChannelKind.TvSd);
public Task<List<Channel>> LoadTvHdAsync() => LoadAsync("/dvb/m3u/tvhd.m3u", ChannelKind.TvHd);
public Task<List<Channel>> LoadRadioAsync() => LoadAsync("/dvb/m3u/radio.m3u", ChannelKind.Radio);
public async Task<List<Channel>> LoadAllAsync()
{
var tasks = new[] { LoadTvSdAsync(), LoadTvHdAsync(), LoadRadioAsync() };
var results = await Task.WhenAll(tasks);
return results.SelectMany(r => r).ToList();
}
private async Task<List<Channel>> LoadAsync(string path, ChannelKind kind)
{
var url = _baseUrl + path;
var content = await _http.GetStringAsync(url);
return M3UParser.Parse(content, kind);
}
}

250
Services/LogoService.cs Normal file
View file

@ -0,0 +1,250 @@
using System.IO;
using System.Net.Http;
using System.Text.RegularExpressions;
namespace FritzTV.Services;
/// <summary>
/// Lädt Sender-Logos von AVM und cacht sie lokal in %APPDATA%\FritzTV\logos\
///
/// Zwei Quellen:
/// 1. http://tv.avm.de/tvapp/logos/ (von der FRITZ!App TV genutzt, aktuell, hat auch Radio)
/// 2. https://download.avm.de/tv/logos/ (alter Server, Stand 2017, nur TV)
///
/// Logo-Filename-Konvention: <sendername lowercased, special chars zu '_'>.png
/// Beispiele:
/// "Das Erste HD" -> "das_erste_hd.png" (mit fallback "das_erste.png")
/// "Bayern 1" -> "bayern_1.png"
/// "MDR SPUTNIK" -> "mdr_sputnik.png"
///
/// Wir probieren mehrere Filename-Varianten pro Sender und cachen sowohl Treffer
/// als auch "kein Logo verfügbar" (negativer Cache, vermeidet wiederholte 404er).
/// </summary>
public class LogoService
{
private static readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(8) };
private static readonly string CacheDir = AppPaths.Logos;
private static readonly string[] BaseUrls =
{
"http://tv.avm.de/tvapp/logos/", // primär: aktuell, hat Radio
"https://download.avm.de/tv/logos/" // fallback: älter, nur TV
};
/// <summary>Negative-Cache-Datei: Sender für die kein Logo gefunden wurde</summary>
private static string MissingCachePath => Path.Combine(CacheDir, "_missing.txt");
private readonly HashSet<string> _missingCache;
public LogoService()
{
Directory.CreateDirectory(CacheDir);
_missingCache = LoadMissingCache();
}
/// <summary>
/// Lokaler Pfad zum Logo. Lädt im Hintergrund nach falls nicht im Cache.
/// Gibt null zurück wenn kein Logo verfügbar ist.
/// </summary>
public async Task<string?> GetLogoPathAsync(string channelName)
{
if (string.IsNullOrWhiteSpace(channelName)) return null;
// 1. Erst im File-Cache schauen — schon mal heruntergeladen?
var cached = GetCachedLogoPath(channelName);
if (cached != null) return cached;
// 2. Negativer Cache: vor kurzem 404 gehabt? Dann nicht erneut probieren.
var nameKey = NormalizeForCacheKey(channelName);
if (_missingCache.Contains(nameKey)) return null;
// 3. Online probieren — verschiedene Filename-Varianten, alle BaseUrls
var candidates = GenerateFilenameCandidates(channelName);
foreach (var fn in candidates)
{
foreach (var baseUrl in BaseUrls)
{
var url = baseUrl + fn;
try
{
var bytes = await _http.GetByteArrayAsync(url);
if (bytes.Length > 100) // Sanity-Check (404-Page wäre kleiner oder anders)
{
var localPath = Path.Combine(CacheDir, fn);
await File.WriteAllBytesAsync(localPath, bytes);
// Auch unter dem Sender-Namen verlinken (für GetCachedLogoPath)
SaveNameMapping(channelName, fn);
return localPath;
}
}
catch { /* 404 oder Netzwerkfehler -> nächster Kandidat */ }
}
}
// Kein Logo gefunden — im Negativ-Cache merken
_missingCache.Add(nameKey);
SaveMissingCache();
return null;
}
/// <summary>Synchroner Cache-Lookup ohne Netzwerk (für UI-Initial-Bindung)</summary>
public string? GetCachedLogoPath(string channelName)
{
if (string.IsNullOrWhiteSpace(channelName)) return null;
// Über Mapping-Datei: SendernameNormalisiert -> Filename
var mapping = LoadNameMapping();
var key = NormalizeForCacheKey(channelName);
if (mapping.TryGetValue(key, out var fn))
{
var p = Path.Combine(CacheDir, fn);
if (File.Exists(p)) return p;
}
// Fallback: erste Filename-Variante direkt probieren
foreach (var fn2 in GenerateFilenameCandidates(channelName))
{
var p = Path.Combine(CacheDir, fn2);
if (File.Exists(p)) return p;
}
return null;
}
// ────────── Filename-Kandidaten ──────────
/// <summary>
/// Generiert plausible Filename-Varianten für einen Sender.
/// Reihenfolge: voller Name → ohne Region → ohne Suffix → compact.
/// Bei mehreren Wörtern werden vom Ende Wörter abgeschnitten,
/// damit "WDR HD Köln" → "wdr_hd_koeln", dann "wdr_hd", dann "wdr".
/// </summary>
private static IEnumerable<string> GenerateFilenameCandidates(string name)
{
var seen = new HashSet<string>();
var lower = ReplaceUmlauts(name.ToLowerInvariant().Trim());
var noSuffix = StripResolutionSuffix(lower);
// 1. Volle Form mit HD/SD-Suffix
foreach (var c in CandidatesFor(lower, seen)) yield return c;
// 2. Ohne Auflösungs-Suffix
foreach (var c in CandidatesFor(noSuffix, seen)) yield return c;
// 3. Mit explizitem _hd ergänzt
foreach (var c in CandidatesFor(noSuffix + " hd", seen)) yield return c;
// 4. Bei mehreren Wörtern: vom Ende abkürzen
// "wdr hd köln" → "wdr hd" → "wdr"
var words = noSuffix.Split(new[] { ' ', '_', '-' }, StringSplitOptions.RemoveEmptyEntries);
for (int n = words.Length - 1; n >= 1; n--)
{
var shortened = string.Join(" ", words.Take(n));
foreach (var c in CandidatesFor(shortened, seen)) yield return c;
// mit _hd Variante
foreach (var c in CandidatesFor(shortened + " hd", seen)) yield return c;
}
}
private static IEnumerable<string> CandidatesFor(string input, HashSet<string> seen)
{
var slug = ToSlug(input);
if (slug.Length == 0) yield break;
// Underscore-Variante
var fn1 = slug + ".png";
if (seen.Add(fn1)) yield return fn1;
// Compact ohne Underscore (z.B. "n-tv" → "ntv")
var compact = slug.Replace("_", "");
if (compact.Length > 0)
{
var fn2 = compact + ".png";
if (seen.Add(fn2)) yield return fn2;
}
}
/// <summary>Deutsche Umlaute zu ASCII-Equivalenten (ö → oe etc.)</summary>
private static string ReplaceUmlauts(string s)
{
return s.Replace("ä", "ae").Replace("ö", "oe").Replace("ü", "ue")
.Replace("ß", "ss");
}
private static string ToSlug(string s)
{
// Zeichen die kein Buchstabe/Ziffer sind → "_", dann mehrfache "_" auf eins reduzieren
var slug = Regex.Replace(s, @"[^a-z0-9]+", "_").Trim('_');
return slug;
}
private static string StripResolutionSuffix(string lower)
{
// Suffix mit Whitespace davor: " hd", " uhd", " sd", " 4k"
return Regex.Replace(lower, @"\s+(hd|uhd|sd|4k)$", "").Trim();
}
// ────────── Negativ-Cache (vermeidet wiederholte 404er) ──────────
private static string NormalizeForCacheKey(string name)
=> StripResolutionSuffix(name.ToLowerInvariant().Trim());
private HashSet<string> LoadMissingCache()
{
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
try
{
if (File.Exists(MissingCachePath))
{
// Datei zu alt? Nach 7 Tagen verwerfen — vielleicht hat AVM Logos ergänzt
var age = DateTime.Now - new FileInfo(MissingCachePath).LastWriteTime;
if (age.TotalDays < 7)
{
foreach (var line in File.ReadAllLines(MissingCachePath))
if (!string.IsNullOrWhiteSpace(line)) set.Add(line.Trim());
}
}
}
catch { }
return set;
}
private void SaveMissingCache()
{
try { File.WriteAllLines(MissingCachePath, _missingCache); } catch { }
}
// ────────── Name-Mapping (Senderalias -> Filename) ──────────
private static string MappingPath => Path.Combine(CacheDir, "_mapping.txt");
private static Dictionary<string, string> LoadNameMapping()
{
var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
if (File.Exists(MappingPath))
{
foreach (var line in File.ReadAllLines(MappingPath))
{
var parts = line.Split('|', 2);
if (parts.Length == 2) dict[parts[0]] = parts[1];
}
}
}
catch { }
return dict;
}
private static void SaveNameMapping(string channelName, string filename)
{
try
{
var dict = LoadNameMapping();
dict[NormalizeForCacheKey(channelName)] = filename;
var lines = dict.Select(kv => $"{kv.Key}|{kv.Value}");
File.WriteAllLines(MappingPath, lines);
}
catch { }
}
}

61
Services/M3UParser.cs Normal file
View file

@ -0,0 +1,61 @@
using FritzTV.Models;
namespace FritzTV.Services;
/// <summary>
/// Parser für M3U-Playlists der FritzBox.
/// Format:
/// #EXTM3U
/// #EXTINF:0,Sendername
/// #EXTVLCOPT:network-caching=1000
/// rtsp://192.168.x.x:554/?avm=1&...
/// </summary>
public static class M3UParser
{
public static List<Channel> Parse(string content, ChannelKind kind)
{
var channels = new List<Channel>();
var lines = content.Split('\n', StringSplitOptions.RemoveEmptyEntries);
string? pendingName = null;
int number = 1;
foreach (var rawLine in lines)
{
var line = rawLine.Trim();
if (line.Length == 0) continue;
if (line.StartsWith("#EXTINF:"))
{
var commaIdx = line.IndexOf(',');
pendingName = commaIdx >= 0 ? line[(commaIdx + 1)..].Trim() : null;
}
else if (line.StartsWith("#")) // andere Direktiven (#EXTM3U, #EXTVLCOPT)
{
continue;
}
else if (pendingName != null)
{
// Leere Programmslots ausblenden (Sender deren Name nur aus Punkten
// oder Sonderzeichen besteht, z.B. ".", "...", "-")
var trimmed = pendingName.Trim();
bool isPlaceholder = trimmed.Length == 0
|| trimmed.All(c => c == '.' || c == '-' || c == '_' || char.IsWhiteSpace(c));
if (!isPlaceholder)
{
channels.Add(new Channel
{
Name = pendingName,
Url = line,
Kind = kind,
Number = number++
});
}
pendingName = null;
}
}
return channels;
}
}

29
SettingsWindow.xaml Normal file
View file

@ -0,0 +1,29 @@
<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"
Background="#1A1A1A" Foreground="White"
WindowStartupLocation="CenterOwner" ResizeMode="NoResize">
<Grid Margin="24">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="FritzBox-Adresse" FontSize="13" Foreground="#AAA"/>
<TextBox Grid.Row="1" x:Name="TxtIp" Margin="0,4,0,0" Padding="8" FontSize="14"
Background="#2A2A2A" Foreground="White" BorderBrush="#444"/>
<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">
<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"
Click="BtnSave_Click" Background="#0078D4" Foreground="White" BorderThickness="0"/>
</StackPanel>
</Grid>
</Window>

37
SettingsWindow.xaml.cs Normal file
View file

@ -0,0 +1,37 @@
using System.Windows;
using FritzTV.Services;
namespace FritzTV;
public partial class SettingsWindow : Window
{
private readonly AppSettings _settings;
public SettingsWindow(AppSettings settings)
{
InitializeComponent();
DarkTitleBar.EnableFor(this);
_settings = settings;
TxtIp.Text = settings.FritzBoxIp;
}
private void BtnSave_Click(object sender, RoutedEventArgs e)
{
var ip = TxtIp.Text.Trim();
if (string.IsNullOrEmpty(ip))
{
MessageBox.Show("Bitte FritzBox-Adresse eingeben.", "Fehler",
MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
_settings.FritzBoxIp = ip;
DialogResult = true;
Close();
}
private void BtnCancel_Click(object sender, RoutedEventArgs e)
{
DialogResult = false;
Close();
}
}