Compare commits
28 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 59ab798b74 | |||
| 0da4161fa9 | |||
| 732ce0ce78 | |||
| 84203db798 | |||
| 282e5c3f5a | |||
| 2ac99425af | |||
| 75526a7d43 | |||
| b0e9e1022f | |||
| b9b5d1c923 | |||
| 4880b25e90 | |||
| dbd7aad30d | |||
| 951e02f0fb | |||
| d033411dfc | |||
| 4db5dd6f23 | |||
| b3d1e2e851 | |||
| 5bf1f06bfe | |||
| faa852016d | |||
| 5e8014c127 | |||
| 6e10f0b979 | |||
| 6249c6d149 | |||
| 27b5b6805f | |||
| 58ffc7300b | |||
| 5e211cc038 | |||
| d0f9df0a64 | |||
| d22808497d | |||
| 15e7182c22 | |||
| d2eeffd200 | |||
| c1e832e08d |
8 changed files with 331 additions and 60 deletions
|
|
@ -47,13 +47,14 @@
|
||||||
"web": [
|
"web": [
|
||||||
{ "name": "YouTube", "url": "https://www.youtube.com" },
|
{ "name": "YouTube", "url": "https://www.youtube.com" },
|
||||||
{ "name": "ARD Mediathek", "url": "https://www.ardmediathek.de" },
|
{ "name": "ARD Mediathek", "url": "https://www.ardmediathek.de" },
|
||||||
{ "name": "ZDF Mediathek", "url": "https://www.zdf.de/serien-und-filme" },
|
{ "name": "ZDF Mediathek", "url": "https://www.zdf.de/" },
|
||||||
{ "name": "Netflix", "url": "https://www.netflix.com" },
|
{ "name": "Netflix", "url": "https://www.netflix.com" },
|
||||||
{ "name": "Disney+", "url": "https://www.disneyplus.com" },
|
{ "name": "Disney+", "url": "https://www.disneyplus.com" },
|
||||||
{ "name": "Joyn", "url": "https://www.joyn.de" },
|
{ "name": "Joyn", "url": "https://www.joyn.de" },
|
||||||
{ "name": "RTL+", "url": "https://plus.rtl.de" },
|
{ "name": "RTL+", "url": "https://plus.rtl.de" },
|
||||||
{ "name": "ARTE", "url": "https://www.arte.tv/de" },
|
{ "name": "ARTE", "url": "https://www.arte.tv/de" },
|
||||||
{ "name": "Apple TV+", "url": "https://tv.apple.com" },
|
{ "name": "Apple TV+", "url": "https://tv.apple.com" },
|
||||||
{ "name": "Amazon Prime Video", "url": "https://www.amazon.de/gp/video/storefront" }
|
{ "name": "Amazon Prime Video", "url": "https://www.amazon.de/gp/video/storefront" },
|
||||||
|
{ "name": "n-tv", "url": "https://www.n-tv.de" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -227,41 +227,39 @@
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
<!-- VideoView + WebView2: beide im gleichen Grid-Cell, Visibility steuert wer sichtbar ist -->
|
<!-- VideoView + WebView2 + EPG-Overlay: alle im gleichen Grid-Cell -->
|
||||||
<Grid Grid.Row="0">
|
<Grid Grid.Row="0">
|
||||||
|
|
||||||
<!-- VLC-Player fuer TV/Radio -->
|
<!-- VLC-Player fuer TV/Radio -->
|
||||||
<vlc:VideoView x:Name="VideoView" Background="Black">
|
<vlc:VideoView x:Name="VideoView" Background="Black">
|
||||||
<Border x:Name="VideoOverlay"
|
<Border x:Name="VideoOverlay"
|
||||||
Background="#01000000"
|
Background="#01000000"
|
||||||
PreviewMouseLeftButtonDown="VideoClickCatcher_DoubleClick">
|
PreviewMouseLeftButtonDown="VideoClickCatcher_DoubleClick">
|
||||||
<Grid>
|
<Grid>
|
||||||
|
<!-- Radio-Cover bei Audio-Streams -->
|
||||||
<!-- Radio-Cover bei Audio-Streams — INNERHALB VideoView damit es die native HWND überdeckt -->
|
<Border x:Name="RadioCover" Visibility="Collapsed" Background="#1A1A1A">
|
||||||
<Border x:Name="RadioCover" Visibility="Collapsed"
|
|
||||||
Background="#1A1A1A">
|
|
||||||
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
|
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
<Image x:Name="RadioLogo" Width="200" Height="200" Stretch="Uniform"
|
<Image x:Name="RadioLogo" Width="200" Height="200" Stretch="Uniform"
|
||||||
RenderOptions.BitmapScalingMode="HighQuality"/>
|
RenderOptions.BitmapScalingMode="HighQuality"/>
|
||||||
<TextBlock x:Name="RadioFallbackIcon" Text="📻"
|
<TextBlock x:Name="RadioFallbackIcon" Text="📻"
|
||||||
FontSize="120" Foreground="#444"
|
FontSize="120" Foreground="#444"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center" Visibility="Collapsed"/>
|
||||||
Visibility="Collapsed"/>
|
|
||||||
<TextBlock x:Name="TxtRadioName"
|
<TextBlock x:Name="TxtRadioName"
|
||||||
FontSize="24" FontWeight="Bold" Foreground="White"
|
FontSize="24" FontWeight="Bold" Foreground="White"
|
||||||
HorizontalAlignment="Center" Margin="0,24,0,0"/>
|
HorizontalAlignment="Center" Margin="0,24,0,0"/>
|
||||||
<TextBlock x:Name="TxtRadioText"
|
<TextBlock x:Name="TxtRadioText"
|
||||||
FontSize="16" Foreground="#0078D4" FontWeight="SemiBold"
|
FontSize="16" Foreground="#0078D4" FontWeight="SemiBold"
|
||||||
HorizontalAlignment="Center" Margin="24,12,24,0"
|
HorizontalAlignment="Center" Margin="24,12,24,0"
|
||||||
TextWrapping="Wrap" TextAlignment="Center"
|
TextWrapping="Wrap" TextAlignment="Center" MaxWidth="600"/>
|
||||||
MaxWidth="600"/>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- EPG-Overlay (Joyn-Style): liegt INNERHALB der VideoView damit Klicks ber HWND funktionieren -->
|
<!-- EPG-Overlay: INNERHALB VideoView (HWND-Grenze)
|
||||||
|
VideoView wird beim EPG-Oeffnen immer sichtbar gemacht,
|
||||||
|
auch wenn WebView aktiv war -->
|
||||||
<Border x:Name="EpgOverlay" Visibility="Collapsed"
|
<Border x:Name="EpgOverlay" Visibility="Collapsed"
|
||||||
Background="#D8000000">
|
Background="#D8000000">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
<!-- Header: Programm + Datum/Zeit + Schließen -->
|
|
||||||
<Grid DockPanel.Dock="Top" Background="#0A0A0A" Height="60">
|
<Grid DockPanel.Dock="Top" Background="#0A0A0A" Height="60">
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="Auto"/>
|
<ColumnDefinition Width="Auto"/>
|
||||||
|
|
@ -277,20 +275,11 @@
|
||||||
<Button x:Name="BtnCloseEpg" Content="✕" FontSize="18"
|
<Button x:Name="BtnCloseEpg" Content="✕" FontSize="18"
|
||||||
Width="40" Height="40"
|
Width="40" Height="40"
|
||||||
Background="Transparent" Foreground="White" BorderThickness="0"
|
Background="Transparent" Foreground="White" BorderThickness="0"
|
||||||
Cursor="Hand"
|
Cursor="Hand" Click="BtnCloseEpg_Click" ToolTip="Schließen (Esc)"/>
|
||||||
Click="BtnCloseEpg_Click"
|
|
||||||
ToolTip="Schließen (Esc)"/>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Status-Zeile (Lade EPG… etc.) -->
|
|
||||||
<TextBlock x:Name="TxtEpgOverlayStatus" DockPanel.Dock="Bottom"
|
<TextBlock x:Name="TxtEpgOverlayStatus" DockPanel.Dock="Bottom"
|
||||||
Foreground="#888" FontSize="11" Padding="24,8"
|
Foreground="#888" FontSize="11" Padding="24,8" Background="#0A0A0A"/>
|
||||||
Background="#0A0A0A"/>
|
|
||||||
|
|
||||||
<!-- Scrollbares EPG-Grid (Canvas) -->
|
|
||||||
<!-- ScrollBar-Style explizit hier weil vlc:VideoView eine eigene HWND hat
|
|
||||||
und Window.Resources-Styles dort nicht greifen -->
|
|
||||||
<ScrollViewer x:Name="EpgScrollViewer"
|
<ScrollViewer x:Name="EpgScrollViewer"
|
||||||
HorizontalScrollBarVisibility="Auto"
|
HorizontalScrollBarVisibility="Auto"
|
||||||
VerticalScrollBarVisibility="Auto"
|
VerticalScrollBarVisibility="Auto"
|
||||||
|
|
@ -346,14 +335,9 @@
|
||||||
</Border>
|
</Border>
|
||||||
</vlc:VideoView>
|
</vlc:VideoView>
|
||||||
|
|
||||||
<!-- WebView2 fuer Web-Sender (YouTube, Netflix, Mediatheken etc.) -->
|
<!-- WebView2 fuer Web-Sender -->
|
||||||
<!-- Persistentes User-Data-Profil: Login bleibt erhalten -->
|
<wv2:WebView2 x:Name="WebView" Visibility="Collapsed" Panel.ZIndex="5"/>
|
||||||
<!-- Liegt VOR dem VideoView im Z-Order, damit Klicks nicht vom VideoOverlay gefangen werden -->
|
|
||||||
<wv2:WebView2 x:Name="WebView"
|
|
||||||
Visibility="Collapsed"
|
|
||||||
Panel.ZIndex="5"/>
|
|
||||||
|
|
||||||
<!-- Kein Burger-Button mehr - Strg+B zum Einblenden der Seitenleiste -->
|
|
||||||
<TextBlock x:Name="TxtNoChannel"
|
<TextBlock x:Name="TxtNoChannel"
|
||||||
Text="Wähle einen Sender aus der Liste"
|
Text="Wähle einen Sender aus der Liste"
|
||||||
Foreground="#666" FontSize="18"
|
Foreground="#666" FontSize="18"
|
||||||
|
|
@ -397,6 +381,9 @@
|
||||||
<Slider x:Name="SldVolume" Width="100" Minimum="0" Maximum="100" Value="80"
|
<Slider x:Name="SldVolume" Width="100" Minimum="0" Maximum="100" Value="80"
|
||||||
VerticalAlignment="Center" Margin="8,0"
|
VerticalAlignment="Center" Margin="8,0"
|
||||||
ValueChanged="SldVolume_ValueChanged"/>
|
ValueChanged="SldVolume_ValueChanged"/>
|
||||||
|
<Button x:Name="BtnAlwaysOnTop" Content="📌" FontSize="14" Width="40" Height="40"
|
||||||
|
Background="Transparent" Foreground="#888" BorderThickness="0"
|
||||||
|
Click="BtnAlwaysOnTop_Click" Cursor="Hand" ToolTip="Immer im Vordergrund (Strg+T)"/>
|
||||||
<Button x:Name="BtnFullscreen" Content="⛶" FontSize="16" Width="40" Height="40"
|
<Button x:Name="BtnFullscreen" Content="⛶" FontSize="16" Width="40" Height="40"
|
||||||
Background="Transparent" Foreground="White" BorderThickness="0"
|
Background="Transparent" Foreground="White" BorderThickness="0"
|
||||||
Click="BtnFullscreen_Click" Cursor="Hand" ToolTip="Vollbild (F11)"/>
|
Click="BtnFullscreen_Click" Cursor="Hand" ToolTip="Vollbild (F11)"/>
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,12 @@ public partial class MainWindow : Window
|
||||||
private Media? _currentMedia;
|
private Media? _currentMedia;
|
||||||
private AppSettings _settings = AppSettings.Load();
|
private AppSettings _settings = AppSettings.Load();
|
||||||
private readonly EpgService _epgService = new();
|
private readonly EpgService _epgService = new();
|
||||||
|
private readonly ReminderService _reminderService = new();
|
||||||
private readonly LogoService _logoService = new();
|
private readonly LogoService _logoService = new();
|
||||||
|
|
||||||
private readonly ObservableCollection<Channel> _allChannels = new();
|
private readonly ObservableCollection<Channel> _allChannels = new();
|
||||||
private readonly ObservableCollection<Channel> _filteredChannels = new();
|
private readonly ObservableCollection<Channel> _filteredChannels = new();
|
||||||
private string _currentCategory = "all";
|
private string _currentCategory = "fav"; // wird in MainWindow_Loaded aus Settings gesetzt
|
||||||
private string _searchTerm = "";
|
private string _searchTerm = "";
|
||||||
private Channel? _currentChannel;
|
private Channel? _currentChannel;
|
||||||
private double _volumeBeforeMute = 80;
|
private double _volumeBeforeMute = 80;
|
||||||
|
|
@ -56,6 +57,7 @@ public partial class MainWindow : Window
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
LstChannels.ItemsSource = _filteredChannels;
|
LstChannels.ItemsSource = _filteredChannels;
|
||||||
TxtFritzBox.Text = $"FritzBox: {_settings.FritzBoxIp}";
|
TxtFritzBox.Text = $"FritzBox: {_settings.FritzBoxIp}";
|
||||||
|
_currentCategory = _settings.StartCategory;
|
||||||
SldVolume.Value = _settings.Volume;
|
SldVolume.Value = _settings.Volume;
|
||||||
|
|
||||||
DarkTitleBar.EnableFor(this);
|
DarkTitleBar.EnableFor(this);
|
||||||
|
|
@ -66,21 +68,28 @@ public partial class MainWindow : Window
|
||||||
_epgTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
|
_epgTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(500) };
|
||||||
_epgTimer.Tick += EpgTimer_Tick;
|
_epgTimer.Tick += EpgTimer_Tick;
|
||||||
|
|
||||||
// Jede 10s: EIT-Daten aus Media neu lesen (libVLC feuert MetaChanged nicht bei laufendem EIT-Update)
|
// Jede 60s: XMLTV Jetzt/Danach refreshen + EIT neu lesen
|
||||||
// Jede 60s: XMLTV Jetzt/Danach refreshen
|
_epgRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(60) };
|
||||||
_epgRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(10) };
|
|
||||||
var _epgRefreshTick = 0;
|
|
||||||
_epgRefreshTimer.Tick += (_, _) =>
|
_epgRefreshTimer.Tick += (_, _) =>
|
||||||
{
|
{
|
||||||
if (_currentChannel == null) return;
|
if (_currentChannel == null) return;
|
||||||
|
// EIT auf Background-Thread lesen damit UI nicht blockiert
|
||||||
if (_currentChannel.Source == ChannelSource.FritzBox && _currentMedia != null)
|
if (_currentChannel.Source == ChannelSource.FritzBox && _currentMedia != null)
|
||||||
UpdateEpgFromMedia(_currentMedia);
|
|
||||||
_epgRefreshTick++;
|
|
||||||
if (_epgRefreshTick >= 6)
|
|
||||||
{
|
{
|
||||||
_epgRefreshTick = 0;
|
var media = _currentMedia;
|
||||||
UpdateNextFromEpgService(_currentChannel);
|
Task.Run(() =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var nowPlaying = media.Meta(MetadataType.NowPlaying) ?? "";
|
||||||
|
var showName = media.Meta(MetadataType.ShowName) ?? "";
|
||||||
|
if (!string.IsNullOrWhiteSpace(nowPlaying) || !string.IsNullOrWhiteSpace(showName))
|
||||||
|
Dispatcher.BeginInvoke(() => UpdateEpgFromMedia(media));
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
UpdateNextFromEpgService(_currentChannel);
|
||||||
};
|
};
|
||||||
_epgRefreshTimer.Start();
|
_epgRefreshTimer.Start();
|
||||||
|
|
||||||
|
|
@ -122,6 +131,22 @@ public partial class MainWindow : Window
|
||||||
_ = InitializeWebViewAsync();
|
_ = InitializeWebViewAsync();
|
||||||
|
|
||||||
await LoadChannelsAsync();
|
await LoadChannelsAsync();
|
||||||
|
// Erinnerungs-Callback: Sender automatisch einschalten
|
||||||
|
_reminderService.SwitchToChannel = channelName =>
|
||||||
|
{
|
||||||
|
var ch = _allChannels.FirstOrDefault(c =>
|
||||||
|
c.Name.Equals(channelName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (ch != null)
|
||||||
|
{
|
||||||
|
HideEpgOverlay();
|
||||||
|
var listEntry = _filteredChannels.FirstOrDefault(c =>
|
||||||
|
c.Name.Equals(channelName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (listEntry != null)
|
||||||
|
LstChannels.SelectedItem = listEntry;
|
||||||
|
else
|
||||||
|
PlayChannel(ch);
|
||||||
|
}
|
||||||
|
};
|
||||||
RestoreLastChannel();
|
RestoreLastChannel();
|
||||||
_ = LoadEpgInBackgroundAsync();
|
_ = LoadEpgInBackgroundAsync();
|
||||||
}
|
}
|
||||||
|
|
@ -311,7 +336,7 @@ public partial class MainWindow : Window
|
||||||
"radio-fritz" => q.Where(c => c.Kind == ChannelKind.Radio && c.Source == ChannelSource.FritzBox),
|
"radio-fritz" => q.Where(c => c.Kind == ChannelKind.Radio && c.Source == ChannelSource.FritzBox),
|
||||||
"radio-online" => q.Where(c => c.Kind == ChannelKind.Radio && c.Source == ChannelSource.Online),
|
"radio-online" => q.Where(c => c.Kind == ChannelKind.Radio && c.Source == ChannelSource.Online),
|
||||||
"web" => q.Where(c => c.Kind == ChannelKind.Web),
|
"web" => q.Where(c => c.Kind == ChannelKind.Web),
|
||||||
"fav" => MergeHdSd(q.Where(c => c.IsFavorite)),
|
"fav" => q.Where(c => c.IsFavorite), // kein MergeHdSd - Web-Sender wuerden sonst rausgefiltert
|
||||||
_ => MergeHdSd(q.Where(c => c.Kind != ChannelKind.Web)) // "all" ohne Web
|
_ => MergeHdSd(q.Where(c => c.Kind != ChannelKind.Web)) // "all" ohne Web
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -367,6 +392,8 @@ public partial class MainWindow : Window
|
||||||
TxtChannelSource.Foreground = ch.Source == ChannelSource.Online
|
TxtChannelSource.Foreground = ch.Source == ChannelSource.Online
|
||||||
? new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0x00, 0x78, 0xD4))
|
? new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0x00, 0x78, 0xD4))
|
||||||
: new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0x66, 0xBB, 0x6A));
|
: new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0x66, 0xBB, 0x6A));
|
||||||
|
// Aufnahme stoppen wenn Sender wechselt
|
||||||
|
// if (_isRecording) StopRecording();
|
||||||
TxtNoChannel.Visibility = Visibility.Collapsed;
|
TxtNoChannel.Visibility = Visibility.Collapsed;
|
||||||
UpdateFavButton();
|
UpdateFavButton();
|
||||||
|
|
||||||
|
|
@ -396,7 +423,7 @@ public partial class MainWindow : Window
|
||||||
_player.Media = media;
|
_player.Media = media;
|
||||||
_player.Play();
|
_player.Play();
|
||||||
|
|
||||||
TxtEpgNow.Text = "EPG wird geladen…";
|
TxtEpgNow.Text = "EPG wird geladen\u2026";
|
||||||
TxtEpgNext.Text = "";
|
TxtEpgNext.Text = "";
|
||||||
|
|
||||||
_epgTimer.Stop();
|
_epgTimer.Stop();
|
||||||
|
|
@ -430,7 +457,7 @@ public partial class MainWindow : Window
|
||||||
|
|
||||||
if (_webViewReady)
|
if (_webViewReady)
|
||||||
{
|
{
|
||||||
WebView.Source = new Uri(ch.Url);
|
if (_webViewReady) WebView.CoreWebView2.Navigate(ch.Url); else WebView.Source = new Uri(ch.Url);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|
@ -440,7 +467,7 @@ public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
retryTimer.Stop();
|
retryTimer.Stop();
|
||||||
if (_webViewReady && _currentChannel?.Kind == ChannelKind.Web)
|
if (_webViewReady && _currentChannel?.Kind == ChannelKind.Web)
|
||||||
WebView.Source = new Uri(ch.Url);
|
if (_webViewReady) WebView.CoreWebView2.Navigate(ch.Url); else WebView.Source = new Uri(ch.Url);
|
||||||
};
|
};
|
||||||
retryTimer.Start();
|
retryTimer.Start();
|
||||||
}
|
}
|
||||||
|
|
@ -540,8 +567,14 @@ public partial class MainWindow : Window
|
||||||
RadioFallbackIcon.Visibility = Visibility.Visible;
|
RadioFallbackIcon.Visibility = Visibility.Visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private DateTime _lastMetaUpdate = DateTime.MinValue;
|
||||||
|
|
||||||
private void OnMediaMetaChanged(object? sender, MediaMetaChangedEventArgs args)
|
private void OnMediaMetaChanged(object? sender, MediaMetaChangedEventArgs args)
|
||||||
{
|
{
|
||||||
|
// Throttle: maximal alle 2s verarbeiten damit der UI-Thread nicht geflutet wird
|
||||||
|
var now = DateTime.Now;
|
||||||
|
if ((now - _lastMetaUpdate).TotalSeconds < 2) return;
|
||||||
|
_lastMetaUpdate = now;
|
||||||
if (sender is Media m) Dispatcher.BeginInvoke(() => UpdateEpgFromMedia(m));
|
if (sender is Media m) Dispatcher.BeginInvoke(() => UpdateEpgFromMedia(m));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -647,15 +680,30 @@ public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
_volumeBeforeMute = SldVolume.Value;
|
_volumeBeforeMute = SldVolume.Value;
|
||||||
SldVolume.Value = 0;
|
SldVolume.Value = 0;
|
||||||
|
if (_webViewReady && WebView.Visibility == Visibility.Visible)
|
||||||
|
WebView.CoreWebView2.ExecuteScriptAsync("document.querySelectorAll('video,audio').forEach(m => m.muted=true)");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
SldVolume.Value = _volumeBeforeMute > 0 ? _volumeBeforeMute : 80;
|
SldVolume.Value = _volumeBeforeMute > 0 ? _volumeBeforeMute : 80;
|
||||||
|
if (_webViewReady && WebView.Visibility == Visibility.Visible)
|
||||||
|
WebView.CoreWebView2.ExecuteScriptAsync("document.querySelectorAll('video,audio').forEach(m => m.muted=false)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void BtnFullscreen_Click(object sender, RoutedEventArgs e) => ToggleFullscreen();
|
private void BtnFullscreen_Click(object sender, RoutedEventArgs e) => ToggleFullscreen();
|
||||||
|
|
||||||
|
private void BtnAlwaysOnTop_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
Topmost = !Topmost;
|
||||||
|
BtnAlwaysOnTop.Foreground = Topmost
|
||||||
|
? new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0x00, 0x78, 0xD4))
|
||||||
|
: new System.Windows.Media.SolidColorBrush(System.Windows.Media.Color.FromRgb(0x88, 0x88, 0x88));
|
||||||
|
BtnAlwaysOnTop.ToolTip = Topmost
|
||||||
|
? "Immer im Vordergrund: AN (Strg+T)"
|
||||||
|
: "Immer im Vordergrund (Strg+T)";
|
||||||
|
}
|
||||||
|
|
||||||
private void ToggleFullscreen()
|
private void ToggleFullscreen()
|
||||||
{
|
{
|
||||||
if (!_isFullscreen)
|
if (!_isFullscreen)
|
||||||
|
|
@ -761,12 +809,26 @@ public partial class MainWindow : Window
|
||||||
|
|
||||||
private async void BtnEpgGrid_Click(object sender, RoutedEventArgs e)
|
private async void BtnEpgGrid_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
// EPG-Overlay liegt innerhalb VideoView (HWND) - VideoView muss sichtbar sein
|
||||||
|
if (_currentChannel?.Kind == ChannelKind.Web && _webViewReady)
|
||||||
|
await WebView.CoreWebView2.ExecuteScriptAsync(
|
||||||
|
"document.querySelectorAll('video,audio').forEach(m => m.pause())");
|
||||||
|
WebView.Visibility = Visibility.Collapsed;
|
||||||
|
VideoView.Visibility = Visibility.Visible;
|
||||||
|
VideoOverlay.Visibility = Visibility.Visible;
|
||||||
await ShowEpgOverlayAsync();
|
await ShowEpgOverlayAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void BtnCloseEpg_Click(object sender, RoutedEventArgs e)
|
private void BtnCloseEpg_Click(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
HideEpgOverlay();
|
HideEpgOverlay();
|
||||||
|
// Wenn Web-Sender: WebView wieder zeigen
|
||||||
|
if (_currentChannel?.Kind == ChannelKind.Web)
|
||||||
|
{
|
||||||
|
VideoView.Visibility = Visibility.Collapsed;
|
||||||
|
VideoOverlay.Visibility = Visibility.Collapsed;
|
||||||
|
WebView.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────── EPG-Overlay (Joyn-Style) ──────────
|
// ────────── EPG-Overlay (Joyn-Style) ──────────
|
||||||
|
|
@ -778,6 +840,8 @@ public partial class MainWindow : Window
|
||||||
private const int EpgTotalHours = 8;
|
private const int EpgTotalHours = 8;
|
||||||
|
|
||||||
private DateTime _epgStartTime;
|
private DateTime _epgStartTime;
|
||||||
|
private double _epgScrollH = 0;
|
||||||
|
private double _epgScrollV = 0;
|
||||||
|
|
||||||
private async Task ShowEpgOverlayAsync()
|
private async Task ShowEpgOverlayAsync()
|
||||||
{
|
{
|
||||||
|
|
@ -799,7 +863,7 @@ public partial class MainWindow : Window
|
||||||
}
|
}
|
||||||
|
|
||||||
TxtEpgOverlayStatus.Text = "Baue Programm\u2026";
|
TxtEpgOverlayStatus.Text = "Baue Programm\u2026";
|
||||||
await BuildEpgGridAsync();
|
await BuildEpgGridAsync(resetScroll: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void HideEpgOverlay()
|
private void HideEpgOverlay()
|
||||||
|
|
@ -808,8 +872,13 @@ public partial class MainWindow : Window
|
||||||
EpgCanvas.Children.Clear();
|
EpgCanvas.Children.Clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task BuildEpgGridAsync()
|
|
||||||
|
private async Task BuildEpgGridAsync(bool resetScroll = false)
|
||||||
{
|
{
|
||||||
|
_epgScrollH = EpgScrollViewer.HorizontalOffset;
|
||||||
|
_epgScrollV = EpgScrollViewer.VerticalOffset;
|
||||||
|
await Dispatcher.InvokeAsync(() => EpgCanvas.Children.Clear(),
|
||||||
|
System.Windows.Threading.DispatcherPriority.Background);
|
||||||
EpgCanvas.Children.Clear();
|
EpgCanvas.Children.Clear();
|
||||||
|
|
||||||
var channels = _allChannels
|
var channels = _allChannels
|
||||||
|
|
@ -839,8 +908,16 @@ public partial class MainWindow : Window
|
||||||
}
|
}
|
||||||
|
|
||||||
TxtEpgOverlayStatus.Text = $"{channels.Count} Sender \u00b7 {EpgTotalHours} Stunden";
|
TxtEpgOverlayStatus.Text = $"{channels.Count} Sender \u00b7 {EpgTotalHours} Stunden";
|
||||||
EpgScrollViewer.ScrollToHorizontalOffset(0);
|
if (resetScroll)
|
||||||
EpgScrollViewer.ScrollToVerticalOffset(0);
|
{
|
||||||
|
EpgScrollViewer.ScrollToHorizontalOffset(0);
|
||||||
|
EpgScrollViewer.ScrollToVerticalOffset(0);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
EpgScrollViewer.ScrollToHorizontalOffset(_epgScrollH);
|
||||||
|
EpgScrollViewer.ScrollToVerticalOffset(_epgScrollV);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void BuildEpgHeader(double totalMinutes, double contentWidth, double contentHeight)
|
private void BuildEpgHeader(double totalMinutes, double contentWidth, double contentHeight)
|
||||||
|
|
@ -993,10 +1070,17 @@ public partial class MainWindow : Window
|
||||||
BorderBrush = new System.Windows.Media.SolidColorBrush(borderColor),
|
BorderBrush = new System.Windows.Media.SolidColorBrush(borderColor),
|
||||||
BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(3),
|
BorderThickness = new Thickness(1), CornerRadius = new CornerRadius(3),
|
||||||
Cursor = Cursors.Hand,
|
Cursor = Cursors.Hand,
|
||||||
ToolTip = $"{ev.StartTime:HH:mm}–{ev.EndTime:HH:mm}\n{ev.Title}" +
|
Tag = ch
|
||||||
(string.IsNullOrWhiteSpace(ev.Description) ? "" : $"\n\n{ev.Description}"),
|
|
||||||
Tag = ch, Child = content
|
|
||||||
};
|
};
|
||||||
|
box.Child = content;
|
||||||
|
|
||||||
|
// Tooltip mit Zeilenumbruch und Beschreibung
|
||||||
|
var ttText = $"{ev.StartTime:HH:mm}\u2013{ev.EndTime:HH:mm} {ev.Title}";
|
||||||
|
if (!string.IsNullOrWhiteSpace(ev.Description))
|
||||||
|
ttText += $"\n\n{ev.Description}";
|
||||||
|
var tt = new ToolTip { MaxWidth = 420, Content = new TextBlock { Text = ttText, TextWrapping = TextWrapping.Wrap, FontSize = 12 } };
|
||||||
|
ToolTipService.SetShowDuration(box, 30000);
|
||||||
|
box.ToolTip = tt;
|
||||||
|
|
||||||
box.MouseEnter += (_, _) => box.Background = new System.Windows.Media.SolidColorBrush(
|
box.MouseEnter += (_, _) => box.Background = new System.Windows.Media.SolidColorBrush(
|
||||||
isCurrent ? System.Windows.Media.Color.FromRgb(0xB8, 0x21, 0x3A)
|
isCurrent ? System.Windows.Media.Color.FromRgb(0xB8, 0x21, 0x3A)
|
||||||
|
|
@ -1016,6 +1100,67 @@ public partial class MainWindow : Window
|
||||||
PlayChannel(target);
|
PlayChannel(target);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Rechtsklick: Kontextmenu mit Erinnern-Option
|
||||||
|
box.MouseRightButtonDown += (_, args) =>
|
||||||
|
{
|
||||||
|
args.Handled = true;
|
||||||
|
var hasReminder = _reminderService.HasReminder(ch.Name, ev.StartTime);
|
||||||
|
var menu = new ContextMenu();
|
||||||
|
|
||||||
|
var remind = new MenuItem
|
||||||
|
{
|
||||||
|
Header = hasReminder ? "\u23f0 Erinnerung entfernen" : "\u23f0 5 min vorher erinnern",
|
||||||
|
FontSize = 13
|
||||||
|
};
|
||||||
|
remind.Click += (_, _) =>
|
||||||
|
{
|
||||||
|
if (hasReminder)
|
||||||
|
{
|
||||||
|
var existing = _reminderService.Reminders
|
||||||
|
.FirstOrDefault(r => r.ChannelName == ch.Name && r.StartTime == ev.StartTime);
|
||||||
|
if (existing != null) _reminderService.Remove(existing.Id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_reminderService.Add(new Reminder
|
||||||
|
{
|
||||||
|
ChannelName = ch.Name,
|
||||||
|
Title = ev.Title,
|
||||||
|
Description = ev.Description ?? "",
|
||||||
|
StartTime = ev.StartTime,
|
||||||
|
EndTime = ev.EndTime,
|
||||||
|
MinutesBefore = 5
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Verzoegert neu bauen damit Event-Handler sauber abgeschlossen ist
|
||||||
|
Dispatcher.BeginInvoke(async () => await BuildEpgGridAsync(resetScroll: false),
|
||||||
|
System.Windows.Threading.DispatcherPriority.Background);
|
||||||
|
};
|
||||||
|
menu.Items.Add(remind);
|
||||||
|
box.ContextMenu = menu;
|
||||||
|
menu.IsOpen = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Erinnerungs-Indikator: bell-Icon oben rechts
|
||||||
|
if (_reminderService.HasReminder(ch.Name, ev.StartTime))
|
||||||
|
{
|
||||||
|
// box.Child auf null damit content aus dem logical tree raus ist
|
||||||
|
box.Child = null;
|
||||||
|
var bell = new TextBlock
|
||||||
|
{
|
||||||
|
Text = "\u23f0",
|
||||||
|
FontSize = 11,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Right,
|
||||||
|
VerticalAlignment = VerticalAlignment.Top,
|
||||||
|
Margin = new Thickness(0, 3, 4, 0),
|
||||||
|
IsHitTestVisible = false
|
||||||
|
};
|
||||||
|
var overlay = new Grid();
|
||||||
|
overlay.Children.Add(content);
|
||||||
|
overlay.Children.Add(bell);
|
||||||
|
box.Child = overlay;
|
||||||
|
}
|
||||||
|
|
||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1073,7 +1218,6 @@ public partial class MainWindow : Window
|
||||||
{
|
{
|
||||||
if (msg == WM_KEYDOWN && (int)wParam == VK_B)
|
if (msg == WM_KEYDOWN && (int)wParam == VK_B)
|
||||||
{
|
{
|
||||||
// GetKeyState(VK_CONTROL) - High-bit gesetzt = Taste gedrueckt
|
|
||||||
var ctrl = (NativeMethods.GetKeyState(0x11) & 0x8000) != 0;
|
var ctrl = (NativeMethods.GetKeyState(0x11) & 0x8000) != 0;
|
||||||
if (ctrl)
|
if (ctrl)
|
||||||
Dispatcher.BeginInvoke(() => BtnSidebarToggle_Click(this, new RoutedEventArgs()));
|
Dispatcher.BeginInvoke(() => BtnSidebarToggle_Click(this, new RoutedEventArgs()));
|
||||||
|
|
@ -1101,8 +1245,19 @@ public partial class MainWindow : Window
|
||||||
switch (e.Key)
|
switch (e.Key)
|
||||||
{
|
{
|
||||||
case Key.F11: ToggleFullscreen(); e.Handled = true; break;
|
case Key.F11: ToggleFullscreen(); e.Handled = true; break;
|
||||||
|
case Key.T when Keyboard.Modifiers == ModifierKeys.Control:
|
||||||
|
BtnAlwaysOnTop_Click(this, new RoutedEventArgs()); e.Handled = true; break;
|
||||||
case Key.Escape:
|
case Key.Escape:
|
||||||
if (EpgOverlay.Visibility == Visibility.Visible) HideEpgOverlay();
|
if (EpgOverlay.Visibility == Visibility.Visible)
|
||||||
|
{
|
||||||
|
HideEpgOverlay();
|
||||||
|
if (_currentChannel?.Kind == ChannelKind.Web)
|
||||||
|
{
|
||||||
|
VideoView.Visibility = Visibility.Collapsed;
|
||||||
|
VideoOverlay.Visibility = Visibility.Collapsed;
|
||||||
|
WebView.Visibility = Visibility.Visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
else if (_isFullscreen) ToggleFullscreen();
|
else if (_isFullscreen) ToggleFullscreen();
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
16
Models/Reminder.cs
Normal file
16
Models/Reminder.cs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
namespace FritzTV.Models;
|
||||||
|
|
||||||
|
public class Reminder
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public required string ChannelName { get; set; }
|
||||||
|
public required string Title { get; set; }
|
||||||
|
public string Description { get; set; } = "";
|
||||||
|
public DateTime StartTime { get; set; }
|
||||||
|
public DateTime EndTime { get; set; }
|
||||||
|
public int MinutesBefore { get; set; } = 5;
|
||||||
|
public bool Fired { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>Zeitpunkt zu dem die Benachrichtigung ausgelöst wird</summary>
|
||||||
|
public DateTime NotifyAt => StartTime.AddMinutes(-MinutesBefore);
|
||||||
|
}
|
||||||
|
|
@ -36,4 +36,5 @@ public static class AppPaths
|
||||||
public static string Epg => Path.Combine(Root, "epg");
|
public static string Epg => Path.Combine(Root, "epg");
|
||||||
public static string CrashLog => Path.Combine(Root, "crash.log");
|
public static string CrashLog => Path.Combine(Root, "crash.log");
|
||||||
public static string WebView2Profile => Path.Combine(Root, "webview2");
|
public static string WebView2Profile => Path.Combine(Root, "webview2");
|
||||||
|
public static string Reminders => Path.Combine(Root, "reminders.json");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ public class AppSettings
|
||||||
/// <summary>Online-Sender (ÖR-TV + Webradio aus Assets\online-sources.json) zusätzlich zur FritzBox-Liste anzeigen</summary>
|
/// <summary>Online-Sender (ÖR-TV + Webradio aus Assets\online-sources.json) zusätzlich zur FritzBox-Liste anzeigen</summary>
|
||||||
public bool ShowOnlineSources { get; set; } = true;
|
public bool ShowOnlineSources { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Startkategorie beim App-Start (default: Favoriten)</summary>
|
||||||
|
public string StartCategory { get; set; } = "fav";
|
||||||
|
|
||||||
private static readonly string ConfigPath = AppPaths.Settings;
|
private static readonly string ConfigPath = AppPaths.Settings;
|
||||||
|
|
||||||
public static AppSettings Load()
|
public static AppSettings Load()
|
||||||
|
|
|
||||||
107
Services/ReminderService.cs
Normal file
107
Services/ReminderService.cs
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Windows.Threading;
|
||||||
|
using FritzTV.Models;
|
||||||
|
|
||||||
|
namespace FritzTV.Services;
|
||||||
|
|
||||||
|
public class ReminderService
|
||||||
|
{
|
||||||
|
private static readonly string RemindersPath = AppPaths.Reminders;
|
||||||
|
private readonly DispatcherTimer _checkTimer;
|
||||||
|
private List<Reminder> _reminders = new();
|
||||||
|
|
||||||
|
/// <summary>Feuert wenn eine Erinnerung faellig ist. Callback laeuft auf UI-Thread.</summary>
|
||||||
|
public event Action<Reminder>? ReminderDue;
|
||||||
|
|
||||||
|
public IReadOnlyList<Reminder> Reminders => _reminders.AsReadOnly();
|
||||||
|
|
||||||
|
public ReminderService()
|
||||||
|
{
|
||||||
|
Load();
|
||||||
|
_checkTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) };
|
||||||
|
_checkTimer.Tick += (_, _) => CheckReminders();
|
||||||
|
_checkTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Add(Reminder reminder)
|
||||||
|
{
|
||||||
|
_reminders.Add(reminder);
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Remove(Guid id)
|
||||||
|
{
|
||||||
|
_reminders.RemoveAll(r => r.Id == id);
|
||||||
|
Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasReminder(string channelName, DateTime startTime)
|
||||||
|
=> _reminders.Any(r => r.ChannelName == channelName && r.StartTime == startTime);
|
||||||
|
|
||||||
|
private void CheckReminders()
|
||||||
|
{
|
||||||
|
var now = DateTime.Now;
|
||||||
|
var due = _reminders
|
||||||
|
.Where(r => !r.Fired && r.NotifyAt <= now && r.StartTime > now.AddMinutes(-30))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var r in due)
|
||||||
|
{
|
||||||
|
r.Fired = true;
|
||||||
|
FireNotification(r);
|
||||||
|
ReminderDue?.Invoke(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
_reminders.RemoveAll(r => r.Fired && r.StartTime < now.AddHours(-1));
|
||||||
|
if (due.Any()) Save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void FireNotification(Reminder r)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var minLeft = (int)(r.StartTime - DateTime.Now).TotalMinutes;
|
||||||
|
var when = minLeft <= 0 ? "Jetzt" : $"In {minLeft} Minute{(minLeft == 1 ? "" : "n")}";
|
||||||
|
var msg = $"{when}: {r.Title}\n{r.ChannelName} {r.StartTime:HH:mm}\u2013{r.EndTime:HH:mm}";
|
||||||
|
|
||||||
|
var result = System.Windows.MessageBox.Show(
|
||||||
|
msg + "\n\nJetzt einschalten?",
|
||||||
|
"\u23f0 HomeStream-Erinnerung",
|
||||||
|
System.Windows.MessageBoxButton.YesNo,
|
||||||
|
System.Windows.MessageBoxImage.Information);
|
||||||
|
|
||||||
|
if (result == System.Windows.MessageBoxResult.Yes)
|
||||||
|
SwitchToChannel?.Invoke(r.ChannelName);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Wird aufgerufen wenn User bei Erinnerung "Jetzt einschalten" klickt.</summary>
|
||||||
|
public Action<string>? SwitchToChannel { get; set; }
|
||||||
|
|
||||||
|
private void Load()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (File.Exists(RemindersPath))
|
||||||
|
{
|
||||||
|
var json = File.ReadAllText(RemindersPath);
|
||||||
|
_reminders = JsonSerializer.Deserialize<List<Reminder>>(json) ?? new();
|
||||||
|
_reminders.RemoveAll(r => r.StartTime < DateTime.Now.AddHours(-1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { _reminders = new(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Save()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(RemindersPath)!);
|
||||||
|
var json = JsonSerializer.Serialize(_reminders, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
File.WriteAllText(RemindersPath, json);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
13
publish.ps1
13
publish.ps1
|
|
@ -77,16 +77,17 @@ if (-not $Tag) {
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "→ Forgejo-Release $Tag erstellen..."
|
Write-Host "→ Forgejo-Release $Tag erstellen..."
|
||||||
$headers = @{ 'Authorization' = "token $Token"; 'Content-Type' = 'application/json' }
|
$headers = @{ 'Authorization' = "token $Token"; 'Content-Type' = 'application/json; charset=utf-8' }
|
||||||
$body = @{
|
$bodyObj = @{
|
||||||
tag_name = $Tag
|
tag_name = $Tag
|
||||||
name = "HomeStream $version"
|
name = "HomeStream $version"
|
||||||
body = "Self-contained Release für Windows 10/11 (x64). Keine .NET-Installation nötig."
|
body = "Self-contained Release fuer Windows 10/11 (x64). Keine .NET-Installation noetig."
|
||||||
draft = $false
|
draft = $false
|
||||||
prerelease = $false
|
prerelease = $false
|
||||||
} | ConvertTo-Json
|
}
|
||||||
|
# Explizit UTF-8 kodieren damit Umlaute korrekt uebertragen werden
|
||||||
$release = Invoke-RestMethod -Uri "$ApiBase/releases" -Method Post -Headers $headers -Body $body
|
$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes(($bodyObj | ConvertTo-Json))
|
||||||
|
$release = Invoke-RestMethod -Uri "$ApiBase/releases" -Method Post -Headers $headers -Body $bodyBytes
|
||||||
Write-Host " Release-ID: $($release.id)"
|
Write-Host " Release-ID: $($release.id)"
|
||||||
|
|
||||||
# ── 5. ZIP als Asset hochladen ────────────────────────────────────────────
|
# ── 5. ZIP als Asset hochladen ────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue