Online-Streams: OeR-TV + Webradio aus Assets-JSON, Sidebar-Kategorie, Settings-Toggle
This commit is contained in:
parent
7702330c9a
commit
a129f9b98e
9 changed files with 341 additions and 17 deletions
191
Assets/online-sources.json
Normal file
191
Assets/online-sources.json
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
{
|
||||
"_comment": "Online-Streams für HomeStream. URLs Stand Mai 2026, Quelle: harryshomepage.de + rundfunkforum. Privatsender (RTL, ProSieben, Sat.1) NICHT enthalten wegen Lizenz/Paywall (Joyn, RTL+).",
|
||||
"tv": [
|
||||
{
|
||||
"name": "Das Erste HD",
|
||||
"url": "https://daserste-live.ard-mcdn.de/daserste/live/hls/de/master.m3u8",
|
||||
"kind": "TvHd"
|
||||
},
|
||||
{
|
||||
"name": "ZDF HD",
|
||||
"url": "https://zdf-hls-15.akamaized.net/hls/live/2016498/de/veryhigh/master.m3u8",
|
||||
"kind": "TvHd"
|
||||
},
|
||||
{
|
||||
"name": "3sat HD",
|
||||
"url": "https://zdf-hls-18.akamaized.net/hls/live/2016501/dach/veryhigh/master.m3u8",
|
||||
"kind": "TvHd"
|
||||
},
|
||||
{
|
||||
"name": "ARTE HD",
|
||||
"url": "https://artesimulcast.akamaized.net/hls/live/2030993/artelive_de/master.m3u8",
|
||||
"kind": "TvHd"
|
||||
},
|
||||
{
|
||||
"name": "ARD-alpha HD",
|
||||
"url": "https://mcdn.br.de/br/fs/ard_alpha/hls/de/master.m3u8",
|
||||
"kind": "TvHd"
|
||||
},
|
||||
{
|
||||
"name": "ONE HD",
|
||||
"url": "https://mcdn-one.ard.de/ardone/hls/master.m3u8",
|
||||
"kind": "TvHd"
|
||||
},
|
||||
{
|
||||
"name": "ZDFneo HD",
|
||||
"url": "https://zdf-hls-16.akamaized.net/hls/live/2016499/de/veryhigh/master.m3u8",
|
||||
"kind": "TvHd"
|
||||
},
|
||||
{
|
||||
"name": "ZDFinfo HD",
|
||||
"url": "https://zdf-hls-17.akamaized.net/hls/live/2016500/de/veryhigh/master.m3u8",
|
||||
"kind": "TvHd"
|
||||
},
|
||||
{
|
||||
"name": "Tagesschau24 HD",
|
||||
"url": "https://tagesschau.akamaized.net/hls/live/2020117/tagesschau/tagesschau_3/master.m3u8",
|
||||
"kind": "TvHd"
|
||||
},
|
||||
{
|
||||
"name": "Phoenix HD",
|
||||
"url": "https://zdf-hls-19.akamaized.net/hls/live/2016502/de/veryhigh/master.m3u8",
|
||||
"kind": "TvHd"
|
||||
},
|
||||
{
|
||||
"name": "BR Süd HD",
|
||||
"url": "https://livestreams.br.de/i/bfssued_germany@119890/master.m3u8",
|
||||
"kind": "TvHd"
|
||||
},
|
||||
{
|
||||
"name": "WDR HD",
|
||||
"url": "https://wdr-wdrfernsehen-koeln.icecast.wdr.de/wdr/wdrfernsehen/koeln/hls/master.m3u8",
|
||||
"kind": "TvHd"
|
||||
},
|
||||
{
|
||||
"name": "NDR HD",
|
||||
"url": "https://ndrfs-lh.akamaihd.net/i/ndrfs_nds@430233/master.m3u8",
|
||||
"kind": "TvHd"
|
||||
},
|
||||
{
|
||||
"name": "HR HD",
|
||||
"url": "https://hr-hrfernsehen-live.akamaized.net/hls/live/2016105/hrfernsehen/master.m3u8",
|
||||
"kind": "TvHd"
|
||||
},
|
||||
{
|
||||
"name": "MDR Sachsen HD",
|
||||
"url": "https://mdrsnhls-lh.akamaihd.net/i/livetvmdr_sachsen@439432/master.m3u8",
|
||||
"kind": "TvHd"
|
||||
},
|
||||
{
|
||||
"name": "RBB Berlin HD",
|
||||
"url": "https://rbb_berlin-lh.akamaihd.net/i/rbbBerlin_Live@380294/master.m3u8",
|
||||
"kind": "TvHd"
|
||||
},
|
||||
{
|
||||
"name": "SWR BW HD",
|
||||
"url": "https://swrbw-lh.akamaihd.net/i/swrbw_live@196738/master.m3u8",
|
||||
"kind": "TvHd"
|
||||
},
|
||||
{
|
||||
"name": "SR Fernsehen HD",
|
||||
"url": "https://live2_sr-lh.akamaihd.net/i/sr_universal02@107595/master.m3u8",
|
||||
"kind": "TvHd"
|
||||
},
|
||||
{
|
||||
"name": "KiKA HD",
|
||||
"url": "https://kikageohls.akamaized.net/hls/live/2022693/livetvkika_de/master.m3u8",
|
||||
"kind": "TvHd"
|
||||
},
|
||||
{
|
||||
"name": "Deutsche Welle",
|
||||
"url": "https://dwamdstream102.akamaized.net/hls/live/2015525/dwstream102/index.m3u8",
|
||||
"kind": "TvHd"
|
||||
}
|
||||
],
|
||||
"radio": [
|
||||
{
|
||||
"name": "Bayern 1",
|
||||
"url": "https://dispatcher.rndfnk.com/br/br1/obb/mp3/high"
|
||||
},
|
||||
{
|
||||
"name": "Bayern 2",
|
||||
"url": "https://dispatcher.rndfnk.com/br/br2/sued/mp3/high"
|
||||
},
|
||||
{
|
||||
"name": "Bayern 3",
|
||||
"url": "https://dispatcher.rndfnk.com/br/br3/live/mp3/high"
|
||||
},
|
||||
{
|
||||
"name": "BR Klassik",
|
||||
"url": "https://dispatcher.rndfnk.com/br/brklassik/live/mp3/high"
|
||||
},
|
||||
{
|
||||
"name": "B5 aktuell",
|
||||
"url": "https://dispatcher.rndfnk.com/br/br5/live/mp3/high"
|
||||
},
|
||||
{
|
||||
"name": "Deutschlandfunk",
|
||||
"url": "https://st01.sslstream.dlf.de/dlf/01/128/mp3/stream.mp3"
|
||||
},
|
||||
{
|
||||
"name": "Deutschlandfunk Kultur",
|
||||
"url": "https://st02.sslstream.dlf.de/dlf/02/128/mp3/stream.mp3"
|
||||
},
|
||||
{
|
||||
"name": "Deutschlandfunk Nova",
|
||||
"url": "https://st03.sslstream.dlf.de/dlf/03/128/mp3/stream.mp3"
|
||||
},
|
||||
{
|
||||
"name": "WDR 2",
|
||||
"url": "https://wdr-wdr2-rheinland.icecastssl.wdr.de/wdr/wdr2/rheinland/mp3/128/stream.mp3"
|
||||
},
|
||||
{
|
||||
"name": "WDR 4",
|
||||
"url": "https://wdr-wdr4-live.icecastssl.wdr.de/wdr/wdr4/live/mp3/128/stream.mp3"
|
||||
},
|
||||
{
|
||||
"name": "1LIVE",
|
||||
"url": "https://wdr-1live-live.icecastssl.wdr.de/wdr/1live/live/mp3/128/stream.mp3"
|
||||
},
|
||||
{
|
||||
"name": "NDR 2",
|
||||
"url": "https://icecast.ndr.de/ndr/ndr2/niedersachsen/mp3/128/stream.mp3"
|
||||
},
|
||||
{
|
||||
"name": "N-JOY",
|
||||
"url": "https://icecast.ndr.de/ndr/njoy/live/mp3/128/stream.mp3"
|
||||
},
|
||||
{
|
||||
"name": "SWR1 BW",
|
||||
"url": "https://liveradio.swr.de/sw282p3/swr1bw/play.mp3"
|
||||
},
|
||||
{
|
||||
"name": "SWR3",
|
||||
"url": "https://liveradio.swr.de/sw282p3/swr3/play.mp3"
|
||||
},
|
||||
{
|
||||
"name": "HR1",
|
||||
"url": "https://hr-hr1-live.cast.addradio.de/hr/hr1/live/mp3/128/stream.mp3"
|
||||
},
|
||||
{
|
||||
"name": "HR3",
|
||||
"url": "https://hr-hr3-live.cast.addradio.de/hr/hr3/live/mp3/128/stream.mp3"
|
||||
},
|
||||
{
|
||||
"name": "MDR Sachsen",
|
||||
"url": "https://mdr-284350-0.cast.mdr.de/mdr/284350/0/mp3/high/stream.mp3"
|
||||
},
|
||||
{
|
||||
"name": "RBB Radio Eins",
|
||||
"url": "https://rbb-radioeins-live.cast.addradio.de/rbb/radioeins/live/mp3/128/stream.mp3"
|
||||
},
|
||||
{
|
||||
"name": "Antenne Bayern",
|
||||
"url": "https://stream.antenne.de/antenne/stream/mp3"
|
||||
},
|
||||
{
|
||||
"name": "Radio Eins",
|
||||
"url": "https://rbb-radioeins-live.cast.addradio.de/rbb/radioeins/live/mp3/128/stream.mp3"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -27,4 +27,11 @@
|
|||
<Resource Include="Assets\logo.svg" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Online-Stream-Liste wird zur Laufzeit gelesen, daher Content (kein Resource) -->
|
||||
<Content Include="Assets\online-sources.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -137,11 +137,13 @@
|
|||
ToolTip="Kategorien einklappen"/>
|
||||
</Grid>
|
||||
<StackPanel DockPanel.Dock="Top">
|
||||
<Button x:Name="BtnAll" Content="📺 Alle Sender" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="all"/>
|
||||
<Button x:Name="BtnTv" Content="🎬 TV" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="tv"/>
|
||||
<Button x:Name="BtnRadio" Content="📡 Radio" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="radio"/>
|
||||
<Button x:Name="BtnAll" Content="📺 Alle Sender" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="all"/>
|
||||
<Button x:Name="BtnTv" Content="🎬 TV" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="tv"/>
|
||||
<Button x:Name="BtnRadio" Content="📡 Radio" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="radio"/>
|
||||
<Button x:Name="BtnOnline" Content="🌐 Online" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="online"
|
||||
ToolTip="ÖR-TV und Webradio (Internet-Streams)"/>
|
||||
<Separator Margin="8" Background="#333"/>
|
||||
<Button x:Name="BtnFav" Content="⭐ Favoriten" Style="{StaticResource SidebarButton}" Click="BtnCategory_Click" Tag="fav"/>
|
||||
<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"/>
|
||||
|
|
|
|||
|
|
@ -148,13 +148,33 @@ public partial class MainWindow : Window
|
|||
TxtStatus.Text = "Lade Senderliste…";
|
||||
try
|
||||
{
|
||||
var client = new FritzBoxClient(_settings.FritzBoxIp);
|
||||
var channels = await client.LoadAllAsync();
|
||||
// FritzBox + Online-Sender parallel laden
|
||||
var fritzClient = new FritzBoxClient(_settings.FritzBoxIp);
|
||||
var onlineClient = new OnlineSourceClient();
|
||||
|
||||
var fritzTask = fritzClient.LoadAllAsync();
|
||||
var onlineTask = _settings.ShowOnlineSources
|
||||
? onlineClient.LoadAllAsync()
|
||||
: Task.FromResult(new List<Channel>());
|
||||
|
||||
List<Channel> fritzChannels;
|
||||
try
|
||||
{
|
||||
fritzChannels = await fritzTask;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// FritzBox nicht erreichbar (z.B. unterwegs)? Trotzdem mit Online-Sendern weitermachen
|
||||
fritzChannels = new List<Channel>();
|
||||
TxtStatus.Text = $"FritzBox-Fehler: {ex.Message}";
|
||||
}
|
||||
var onlineChannels = await onlineTask;
|
||||
|
||||
var channels = fritzChannels.Concat(onlineChannels).ToList();
|
||||
|
||||
foreach (var ch in channels)
|
||||
{
|
||||
ch.IsFavorite = _settings.Favorites.Contains(ch.Name);
|
||||
// Cached Logo gleich setzen, fehlende werden async geladen
|
||||
ch.LogoPath = _logoService.GetCachedLogoPath(ch.Name);
|
||||
}
|
||||
|
||||
|
|
@ -162,17 +182,18 @@ public partial class MainWindow : Window
|
|||
foreach (var ch in channels) _allChannels.Add(ch);
|
||||
|
||||
ApplyFilter();
|
||||
TxtStatus.Text = $"{channels.Count} Sender geladen";
|
||||
TxtStatus.Text = onlineChannels.Count > 0
|
||||
? $"{fritzChannels.Count} FritzBox + {onlineChannels.Count} Online = {channels.Count} Sender"
|
||||
: $"{fritzChannels.Count} Sender geladen";
|
||||
|
||||
// Logos im Hintergrund nachladen — UI updated automatisch via INotifyPropertyChanged
|
||||
_ = LoadLogosInBackgroundAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
TxtStatus.Text = $"Fehler: {ex.Message}";
|
||||
MessageBox.Show(
|
||||
$"Fehler beim Laden der Senderliste von {_settings.FritzBoxIp}:\n\n{ex.Message}",
|
||||
"FRITZ!TV", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
$"Fehler beim Laden der Senderliste:\n\n{ex.Message}",
|
||||
"HomeStream", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -235,10 +256,11 @@ public partial class MainWindow : Window
|
|||
|
||||
q = _currentCategory switch
|
||||
{
|
||||
"tv" => MergeHdSd(q.Where(c => c.Kind != ChannelKind.Radio)),
|
||||
"radio" => q.Where(c => c.Kind == ChannelKind.Radio),
|
||||
"fav" => MergeHdSd(q.Where(c => c.IsFavorite)),
|
||||
_ => MergeHdSd(q)
|
||||
"tv" => MergeHdSd(q.Where(c => c.Kind != ChannelKind.Radio)),
|
||||
"radio" => q.Where(c => c.Kind == ChannelKind.Radio),
|
||||
"fav" => MergeHdSd(q.Where(c => c.IsFavorite)),
|
||||
"online" => MergeHdSd(q.Where(c => c.Source == ChannelSource.Online)),
|
||||
_ => MergeHdSd(q)
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(_searchTerm))
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ namespace FritzTV.Models;
|
|||
|
||||
public enum ChannelKind { TvSd, TvHd, Radio }
|
||||
|
||||
/// <summary>Woher kommt der Sender? FritzBox (DVB-C/lokal) oder Online (HLS-Stream)</summary>
|
||||
public enum ChannelSource { FritzBox, Online }
|
||||
|
||||
public class Channel : INotifyPropertyChanged
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
|
|
@ -12,6 +15,9 @@ public class Channel : INotifyPropertyChanged
|
|||
public required ChannelKind Kind { get; init; }
|
||||
public int Number { get; set; }
|
||||
|
||||
/// <summary>Quelle: lokale FritzBox oder Online-Stream. Default ist FritzBox (Abwärtskompatibilität).</summary>
|
||||
public ChannelSource Source { get; init; } = ChannelSource.FritzBox;
|
||||
|
||||
private bool _isFavorite;
|
||||
public bool IsFavorite
|
||||
{
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ public class AppSettings
|
|||
public string LastChannel { get; set; } = "";
|
||||
public double Volume { get; set; } = 80;
|
||||
|
||||
/// <summary>Online-Sender (ÖR-TV + Webradio aus Assets\online-sources.json) zusätzlich zur FritzBox-Liste anzeigen</summary>
|
||||
public bool ShowOnlineSources { get; set; } = true;
|
||||
|
||||
private static readonly string ConfigPath = AppPaths.Settings;
|
||||
|
||||
public static AppSettings Load()
|
||||
|
|
|
|||
79
Services/OnlineSourceClient.cs
Normal file
79
Services/OnlineSourceClient.cs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using FritzTV.Models;
|
||||
|
||||
namespace FritzTV.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Lädt eine kuratierte Liste von Online-Streams (öffentlich-rechtliche TV + Webradio)
|
||||
/// aus Assets\online-sources.json. Diese Datei wird beim Build neben die .exe kopiert.
|
||||
/// </summary>
|
||||
public class OnlineSourceClient
|
||||
{
|
||||
private static readonly string JsonPath = Path.Combine(
|
||||
Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location)!,
|
||||
"Assets", "online-sources.json");
|
||||
|
||||
public Task<List<Channel>> LoadAllAsync()
|
||||
{
|
||||
// Synchron lesen ist OK, die Datei liegt lokal und ist klein (<10 KB)
|
||||
try
|
||||
{
|
||||
if (!File.Exists(JsonPath))
|
||||
return Task.FromResult(new List<Channel>());
|
||||
|
||||
var json = File.ReadAllText(JsonPath);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var channels = new List<Channel>();
|
||||
int number = 1000; // Online-Sender bekommen Nummern ab 1000 damit sie nicht mit FritzBox-Nummern kollidieren
|
||||
|
||||
if (root.TryGetProperty("tv", out var tvArr))
|
||||
{
|
||||
foreach (var item in tvArr.EnumerateArray())
|
||||
{
|
||||
var name = item.GetProperty("name").GetString();
|
||||
var url = item.GetProperty("url").GetString();
|
||||
var kindStr = item.TryGetProperty("kind", out var k) ? k.GetString() : "TvHd";
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url)) continue;
|
||||
|
||||
channels.Add(new Channel
|
||||
{
|
||||
Name = name,
|
||||
Url = url,
|
||||
Kind = Enum.TryParse<ChannelKind>(kindStr, out var kind) ? kind : ChannelKind.TvHd,
|
||||
Number = number++,
|
||||
Source = ChannelSource.Online
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("radio", out var radioArr))
|
||||
{
|
||||
foreach (var item in radioArr.EnumerateArray())
|
||||
{
|
||||
var name = item.GetProperty("name").GetString();
|
||||
var url = item.GetProperty("url").GetString();
|
||||
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(url)) continue;
|
||||
|
||||
channels.Add(new Channel
|
||||
{
|
||||
Name = name,
|
||||
Url = url,
|
||||
Kind = ChannelKind.Radio,
|
||||
Number = number++,
|
||||
Source = ChannelSource.Online
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(channels);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Bei Parse-Fehler einfach leere Liste — App soll trotzdem laufen
|
||||
return Task.FromResult(new List<Channel>());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
<Window x:Class="FritzTV.SettingsWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="Einstellungen" Height="240" Width="420"
|
||||
Title="Einstellungen" Height="320" Width="440"
|
||||
Background="#1A1A1A" Foreground="White"
|
||||
WindowStartupLocation="CenterOwner" ResizeMode="NoResize">
|
||||
<Grid Margin="24">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
|
|
@ -19,7 +22,16 @@
|
|||
<TextBlock Grid.Row="2" Margin="0,4,0,0" FontSize="11" Foreground="#666"
|
||||
Text="z.B. 192.168.178.1 oder fritz.box · DVB-C muss aktiv sein"/>
|
||||
|
||||
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Separator Grid.Row="3" Margin="0,16,0,16" Background="#333"/>
|
||||
|
||||
<CheckBox Grid.Row="4" x:Name="ChkOnlineSources"
|
||||
Content="Online-Sender anzeigen (ÖR-TV + Webradio)"
|
||||
Foreground="White" FontSize="13"/>
|
||||
<TextBlock Grid.Row="5" Margin="22,4,0,0" FontSize="11" Foreground="#666"
|
||||
TextWrapping="Wrap"
|
||||
Text="Zusätzliche Sender aus dem Internet (ARD, ZDF, 3sat, arte, Webradios). Funktioniert auch ohne FritzBox."/>
|
||||
|
||||
<StackPanel Grid.Row="7" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button Content="Abbrechen" Width="100" Height="32" Margin="0,0,8,0"
|
||||
Click="BtnCancel_Click" Background="#333" Foreground="White" BorderThickness="0"/>
|
||||
<Button Content="Speichern" Width="100" Height="32"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ public partial class SettingsWindow : Window
|
|||
DarkTitleBar.EnableFor(this);
|
||||
_settings = settings;
|
||||
TxtIp.Text = settings.FritzBoxIp;
|
||||
ChkOnlineSources.IsChecked = settings.ShowOnlineSources;
|
||||
}
|
||||
|
||||
private void BtnSave_Click(object sender, RoutedEventArgs e)
|
||||
|
|
@ -25,6 +26,7 @@ public partial class SettingsWindow : Window
|
|||
return;
|
||||
}
|
||||
_settings.FritzBoxIp = ip;
|
||||
_settings.ShowOnlineSources = ChkOnlineSources.IsChecked == true;
|
||||
DialogResult = true;
|
||||
Close();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue