Initial commit - HomeStream 0.1.0
This commit is contained in:
commit
c0bb485a58
28 changed files with 2836 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
60
AboutWindow.xaml
Normal 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
33
AboutWindow.xaml.cs
Normal 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
9
App.xaml
Normal 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
53
App.xaml.cs
Normal 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
10
AssemblyInfo.cs
Normal 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
124
Assets/build-icon.ps1
Normal 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
BIN
Assets/logo.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
29
Assets/logo.svg
Normal file
29
Assets/logo.svg
Normal 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
20
Directory.Build.props
Normal 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>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
68
EpgChannelWindow.xaml
Normal 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
82
EpgChannelWindow.xaml.cs
Normal 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
30
HomeStream.csproj
Normal 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
21
LICENSE
Normal 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
348
MainWindow.xaml
Normal 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
1041
MainWindow.xaml.cs
Normal file
File diff suppressed because it is too large
Load diff
34
Models/Channel.cs
Normal file
34
Models/Channel.cs
Normal 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
12
Models/EpgEvent.cs
Normal 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
95
README.md
Normal 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
38
Services/AppPaths.cs
Normal 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
40
Services/AppSettings.cs
Normal 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
33
Services/DarkTitleBar.cs
Normal 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 1809–1909
|
||||
|
||||
/// <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
214
Services/EpgService.cs
Normal 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 { }
|
||||
}
|
||||
}
|
||||
41
Services/FritzBoxClient.cs
Normal file
41
Services/FritzBoxClient.cs
Normal 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
250
Services/LogoService.cs
Normal 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
61
Services/M3UParser.cs
Normal 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
29
SettingsWindow.xaml
Normal 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
37
SettingsWindow.xaml.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue