add new ui and others

This commit is contained in:
Genteure 2020-11-27 18:51:02 +08:00
parent f6ad6b8593
commit a09e729178
73 changed files with 2562 additions and 3512 deletions

View File

@ -6,4 +6,151 @@ root = true
# all files
[*]
indent_style = space
indent_size = 4
indent_size = 4
charset = utf-8
insert_final_newline = true
[*.cs]
#Core editorconfig formatting - indentation
#use soft tabs (spaces) for indentation
indent_style = space
#Formatting - indentation options
#indent switch case contents.
csharp_indent_case_contents = true
#csharp_indent_case_contents_when_block
csharp_indent_case_contents_when_block = true
#indent switch labels
csharp_indent_switch_labels = true
#Formatting - new line options
#place catch statements on a new line
csharp_new_line_before_catch = true
#place else statements on a new line
csharp_new_line_before_else = true
#require finally statements to be on a new line after the closing brace
csharp_new_line_before_finally = true
#require members of anonymous types to be on separate lines
csharp_new_line_before_members_in_anonymous_types = true
#require members of object intializers to be on separate lines
csharp_new_line_before_members_in_object_initializers = true
#require braces to be on a new line for anonymous_types, lambdas, properties, accessors, methods, object_collection_array_initializers, control_blocks, and types (also known as "Allman" style)
csharp_new_line_before_open_brace = anonymous_types, lambdas, properties, accessors, methods, object_collection_array_initializers, control_blocks, types
#Formatting - organize using options
#place System.* using directives before other using directives
dotnet_sort_system_directives_first = true
#Formatting - spacing options
#require NO space between a cast and the value
csharp_space_after_cast = false
#require a space before the colon for bases or interfaces in a type declaration
csharp_space_after_colon_in_inheritance_clause = true
#require a space after a keyword in a control flow statement such as a for loop
csharp_space_after_keywords_in_control_flow_statements = true
#require a space before the colon for bases or interfaces in a type declaration
csharp_space_before_colon_in_inheritance_clause = true
#remove space within empty argument list parentheses
csharp_space_between_method_call_empty_parameter_list_parentheses = false
#remove space between method call name and opening parenthesis
csharp_space_between_method_call_name_and_opening_parenthesis = false
#do not place space characters after the opening parenthesis and before the closing parenthesis of a method call
csharp_space_between_method_call_parameter_list_parentheses = false
#remove space within empty parameter list parentheses for a method declaration
csharp_space_between_method_declaration_empty_parameter_list_parentheses = false
#place a space character after the opening parenthesis and before the closing parenthesis of a method declaration parameter list.
csharp_space_between_method_declaration_parameter_list_parentheses = false
#Formatting - wrapping options
#leave code block on single line
csharp_preserve_single_line_blocks = true
#leave statements and member declarations on the same line
csharp_preserve_single_line_statements = true
#Style - Code block preferences
#prefer curly braces even for one line of code
csharp_prefer_braces = true:suggestion
#Style - expression bodied member options
#prefer expression-bodied members for accessors
csharp_style_expression_bodied_accessors = true:suggestion
#prefer block bodies for constructors
csharp_style_expression_bodied_constructors = false:suggestion
#prefer expression-bodied members for indexers
csharp_style_expression_bodied_indexers = true:suggestion
#prefer block bodies for methods
csharp_style_expression_bodied_methods = false:suggestion
#prefer expression-bodied members for properties
csharp_style_expression_bodied_properties = true:suggestion
#Style - expression level options
#prefer out variables to be declared inline in the argument list of a method call when possible
csharp_style_inlined_variable_declaration = true:suggestion
#prefer tuple names to ItemX properties
dotnet_style_explicit_tuple_names = true:suggestion
#prefer the language keyword for member access expressions, instead of the type name, for types that have a keyword to represent them
dotnet_style_predefined_type_for_member_access = true:suggestion
#Style - Expression-level preferences
#prefer objects to be initialized using object initializers when possible
dotnet_style_object_initializer = true:suggestion
#prefer inferred anonymous type member names
dotnet_style_prefer_inferred_anonymous_type_member_names = false:suggestion
#prefer inferred tuple element names
dotnet_style_prefer_inferred_tuple_names = true:suggestion
#Style - implicit and explicit types
#prefer var over explicit type in all cases, unless overridden by another code style rule
csharp_style_var_elsewhere = true:suggestion
#prefer var is used to declare variables with built-in system types such as int
csharp_style_var_for_built_in_types = true:suggestion
#prefer var when the type is already mentioned on the right-hand side of a declaration expression
csharp_style_var_when_type_is_apparent = true:suggestion
#Style - language keyword and framework type options
#prefer the language keyword for local variables, method parameters, and class members, instead of the type name, for types that have a keyword to represent them
dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
#Style - Miscellaneous preferences
#prefer local functions over anonymous functions
csharp_style_pattern_local_over_anonymous_function = true:suggestion
#Style - modifier options
#prefer accessibility modifiers to be declared except for public interface members. This will currently not differ from always and will act as future proofing for if C# adds default interface methods.
dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion
#Style - Modifier preferences
#when this rule is set to a list of modifiers, prefer the specified ordering.
csharp_preferred_modifier_order = public,private,internal,protected,static,readonly,async,virtual,unsafe,override:suggestion
#Style - Pattern matching
#prefer pattern matching instead of is expression with type casts
csharp_style_pattern_matching_over_as_with_null_check = true:suggestion
#Style - qualification options
#prefer events not to be prefaced with this. or Me. in Visual Basic
dotnet_style_qualification_for_event = false:suggestion
#prefer fields not to be prefaced with this. or Me. in Visual Basic
dotnet_style_qualification_for_field = false:suggestion
#prefer methods not to be prefaced with this. or Me. in Visual Basic
dotnet_style_qualification_for_method = false:suggestion
#prefer properties not to be prefaced with this. or Me. in Visual Basic
dotnet_style_qualification_for_property = false:suggestion

View File

@ -1,4 +1,4 @@
using BililiveRecorder.Core.Config;
using BililiveRecorder.Core.Config;
using System;
using System.IO;
using System.Text;
@ -84,8 +84,6 @@ namespace BililiveRecorder.Core
{
if (xmlWriter != null)
{
// TimeSpan diff = DateTimeOffset.UtcNow - offset;
switch (danmakuModel.MsgType)
{
case MsgTypeEnum.Comment:
@ -93,13 +91,14 @@ namespace BililiveRecorder.Core
var type = danmakuModel.RawObj?["info"]?[0]?[1]?.ToObject<int>() ?? 1;
var size = danmakuModel.RawObj?["info"]?[0]?[2]?.ToObject<int>() ?? 25;
var color = danmakuModel.RawObj?["info"]?[0]?[3]?.ToObject<int>() ?? 0XFFFFFF;
long st = danmakuModel.RawObj?["info"]?[0]?[4]?.ToObject<long>() ?? 0L;
var st = danmakuModel.RawObj?["info"]?[0]?[4]?.ToObject<long>() ?? 0L;
var ts = Math.Max((DateTimeOffset.FromUnixTimeMilliseconds(st) - offset).TotalSeconds, 0d);
xmlWriter.WriteStartElement("d");
xmlWriter.WriteAttributeString("p", $"{ts},{type},{size},{color},{st},0,{danmakuModel.UserID},0");
xmlWriter.WriteAttributeString("user", danmakuModel.UserName);
xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["info"]?.ToString(Newtonsoft.Json.Formatting.None));
if (config.RecordDanmakuRaw)
xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["info"]?.ToString(Newtonsoft.Json.Formatting.None));
xmlWriter.WriteValue(danmakuModel.CommentText);
xmlWriter.WriteEndElement();
}
@ -108,10 +107,13 @@ namespace BililiveRecorder.Core
if (config.RecordDanmakuSuperChat)
{
xmlWriter.WriteStartElement("sc");
var ts = Math.Max((DateTimeOffset.UtcNow - offset).TotalSeconds, 0d);
xmlWriter.WriteAttributeString("ts", ts.ToString());
xmlWriter.WriteAttributeString("user", danmakuModel.UserName);
xmlWriter.WriteAttributeString("price", danmakuModel.Price.ToString());
xmlWriter.WriteAttributeString("time", danmakuModel.SCKeepTime.ToString());
xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
if (config.RecordDanmakuRaw)
xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
xmlWriter.WriteValue(danmakuModel.CommentText);
xmlWriter.WriteEndElement();
}
@ -120,10 +122,13 @@ namespace BililiveRecorder.Core
if (config.RecordDanmakuGift)
{
xmlWriter.WriteStartElement("gift");
var ts = Math.Max((DateTimeOffset.UtcNow - offset).TotalSeconds, 0d);
xmlWriter.WriteAttributeString("ts", ts.ToString());
xmlWriter.WriteAttributeString("user", danmakuModel.UserName);
xmlWriter.WriteAttributeString("giftname", danmakuModel.GiftName);
xmlWriter.WriteAttributeString("giftcount", danmakuModel.GiftCount.ToString());
xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
if (config.RecordDanmakuRaw)
xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
xmlWriter.WriteEndElement();
}
break;
@ -131,10 +136,13 @@ namespace BililiveRecorder.Core
if (config.RecordDanmakuGuard)
{
xmlWriter.WriteStartElement("guard");
var ts = Math.Max((DateTimeOffset.UtcNow - offset).TotalSeconds, 0d);
xmlWriter.WriteAttributeString("ts", ts.ToString());
xmlWriter.WriteAttributeString("user", danmakuModel.UserName);
xmlWriter.WriteAttributeString("level", danmakuModel.UserGuardLevel.ToString()); ;
xmlWriter.WriteAttributeString("count", danmakuModel.GiftCount.ToString());
xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
if (config.RecordDanmakuRaw)
xmlWriter.WriteAttributeString("raw", danmakuModel.RawObj?["data"]?.ToString(Newtonsoft.Json.Formatting.None));
xmlWriter.WriteEndElement();
}
break;

View File

@ -1,30 +1,40 @@
using Newtonsoft.Json.Linq;
using NLog;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BililiveRecorder.Core.Config;
using Newtonsoft.Json.Linq;
using NLog;
namespace BililiveRecorder.Core
{
internal static class BililiveAPI
public class BililiveAPI
{
private const string HTTP_HEADER_ACCEPT = "application/json, text/javascript, */*; q=0.01";
private const string HTTP_HEADER_REFERER = "https://live.bilibili.com/";
private const string DEFAULT_SERVER_HOST = "broadcastlv.chat.bilibili.com";
private const int DEFAULT_SERVER_PORT = 2243;
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
private static readonly Random random = new Random();
private static readonly SemaphoreSlim semaphoreSlim = new SemaphoreSlim(1, 1);
private static HttpClient httpclient;
private static HttpClient danmakuhttpclient;
internal static Config.ConfigV1 Config = null; // TODO: 以后有空把整个 class 改成非 static 的然后用 DI 获取 config
static BililiveAPI()
private readonly ConfigV1 Config;
private readonly HttpClient danmakuhttpclient;
private HttpClient httpclient;
public BililiveAPI(ConfigV1 config)
{
Config = config;
Config.PropertyChanged += (sender, e) =>
{
if (e.PropertyName == nameof(Config.Cookie))
{
ApplyCookieSettings(Config.Cookie);
}
};
httpclient = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
httpclient.DefaultRequestHeaders.Add("Accept", HTTP_HEADER_ACCEPT);
httpclient.DefaultRequestHeaders.Add("Referer", HTTP_HEADER_REFERER);
@ -36,11 +46,11 @@ namespace BililiveRecorder.Core
danmakuhttpclient.DefaultRequestHeaders.Add("User-Agent", Utils.UserAgent);
}
public static async Task ApplyCookieSettings(string cookie_string)
public void ApplyCookieSettings(string cookie_string)
{
await semaphoreSlim.WaitAsync();
try
{
logger.Trace("设置 Cookie 信息...");
if (!string.IsNullOrWhiteSpace(cookie_string))
{
var pclient = new HttpClient(handler: new HttpClientHandler
@ -65,15 +75,12 @@ namespace BililiveRecorder.Core
cleanclient.DefaultRequestHeaders.Add("User-Agent", Utils.UserAgent);
httpclient = cleanclient;
}
logger.Debug("设置 Cookie 成功");
}
catch (Exception ex)
{
logger.Error(ex, "设置 Cookie 时发生错误");
}
finally
{
semaphoreSlim.Release();
}
}
/// <summary>
@ -83,9 +90,8 @@ namespace BililiveRecorder.Core
/// <returns>数据</returns>
/// <exception cref="ArgumentNullException"/>
/// <exception cref="WebException"/>
private static async Task<JObject> HttpGetJsonAsync(HttpClient client, string url)
private async Task<JObject> HttpGetJsonAsync(HttpClient client, string url)
{
await semaphoreSlim.WaitAsync();
try
{
var s = await client.GetStringAsync(url);
@ -96,10 +102,6 @@ namespace BililiveRecorder.Core
{
return null;
}
finally
{
semaphoreSlim.Release();
}
}
/// <summary>
@ -109,49 +111,20 @@ namespace BililiveRecorder.Core
/// <returns>FLV播放地址</returns>
/// <exception cref="WebException"/>
/// <exception cref="Exception"/>
public static async Task<string> GetPlayUrlAsync(int roomid)
public async Task<string> GetPlayUrlAsync(int roomid)
{
string url = $@"{Config.LiveApiHost}/room/v1/Room/playUrl?cid={roomid}&quality=4&platform=web";
if (Config.AvoidTxy)
var url = $@"{Config.LiveApiHost}/room/v1/Room/playUrl?cid={roomid}&quality=4&platform=web";
// 随机选择一个 url
if ((await HttpGetJsonAsync(httpclient, url))?["data"]?["durl"] is JArray array)
{
// 尽量避开腾讯云
int attempt_left = 2;
while (true)
var urls = array.Select(t => t?["url"]?.ToObject<string>());
var distinct = urls.Distinct().ToArray();
if (distinct.Length > 0)
{
attempt_left--;
if ((await HttpGetJsonAsync(httpclient, url))?["data"]?["durl"] is JArray all_jtoken && all_jtoken.Count > 0)
{
var all = all_jtoken.Select(x => x["url"].ToObject<string>()).ToArray();
var withoutTxy = all.Where(x => !x.Contains("txy.")).ToArray();
if (withoutTxy.Length > 0)
{
return withoutTxy[random.Next(withoutTxy.Length)];
}
else if (attempt_left <= 0)
{
return all[random.Next(all.Length)];
}
}
else
{
throw new Exception("没有直播播放地址");
}
return distinct[random.Next(distinct.Length)];
}
}
else
{
// 随机选择一个 url
if ((await HttpGetJsonAsync(httpclient, url))?["data"]?["durl"] is JArray array)
{
var urls = array.Select(t => t?["url"]?.ToObject<string>());
var distinct = urls.Distinct().ToArray();
if (distinct.Length > 0)
{
return distinct[random.Next(distinct.Length)];
}
}
throw new Exception("没有直播播放地址");
}
throw new Exception("没有直播播放地址");
}
/// <summary>
@ -161,7 +134,7 @@ namespace BililiveRecorder.Core
/// <returns>直播间信息</returns>
/// <exception cref="WebException"/>
/// <exception cref="Exception"/>
public static async Task<RoomInfo> GetRoomInfoAsync(int roomid)
public async Task<RoomInfo> GetRoomInfoAsync(int roomid)
{
try
{
@ -201,7 +174,7 @@ namespace BililiveRecorder.Core
/// </summary>
/// <param name="roomid"></param>
/// <returns></returns>
public static async Task<(string token, string host, int port)> GetDanmuConf(int roomid)
public async Task<(string token, string host, int port)> GetDanmuConf(int roomid)
{
try
{

View File

@ -21,7 +21,7 @@
<Compile Include="..\TempBuildInfo\BuildInfo.Core.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Autofac" Version="4.8.1" />
<PackageReference Include="Autofac" Version="4.9.4" />
<PackageReference Include="Newtonsoft.Json" Version="11.0.2" />
<PackageReference Include="NLog" Version="4.5.10" />
</ItemGroup>

View File

@ -1,9 +1,9 @@
using BililiveRecorder.FlvProcessor;
using Newtonsoft.Json;
using NLog;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using BililiveRecorder.FlvProcessor;
using Newtonsoft.Json;
using NLog;
namespace BililiveRecorder.Core.Config
{
@ -98,6 +98,12 @@ namespace BililiveRecorder.Core.Config
[JsonProperty("record_danmaku")]
public bool RecordDanmaku { get => _recordDanmaku; set => SetField(ref _recordDanmaku, value); }
/// <summary>
/// 是否记录弹幕原始数据
/// </summary>
[JsonProperty("record_danmaku_raw")]
public bool RecordDanmakuRaw { get => _recordDanmakuRaw; set => SetField(ref _recordDanmakuRaw, value); }
/// <summary>
/// 是否同时录制 SuperChat
/// </summary>
@ -116,12 +122,6 @@ namespace BililiveRecorder.Core.Config
[JsonProperty("record_danmaku_guard")]
public bool RecordDanmakuGuard { get => _recordDanmakuGuard; set => SetField(ref _recordDanmakuGuard, value); }
/// <summary>
/// 尽量避开腾讯云服务器,可有效提升录制文件能正常播放的概率。(垃圾腾讯云直播服务)
/// </summary>
[JsonProperty("avoidtxy")]
public bool AvoidTxy { get => _avoidTxy; set => SetField(ref _avoidTxy, value); }
/// <summary>
/// 替换api.live.bilibili.com服务器为其他反代可以支持在云服务器上录制
/// </summary>
@ -153,12 +153,13 @@ namespace BililiveRecorder.Core.Config
}
#endregion
private string _workDirectory;
private uint _clipLengthPast = 20;
private uint _clipLengthFuture = 10;
private uint _cuttingNumber = 10;
private EnabledFeature _enabledFeature = EnabledFeature.RecordOnly;
private AutoCuttingMode _cuttingMode = AutoCuttingMode.Disabled;
private string _workDirectory;
private uint _timingWatchdogTimeout = 10 * 1000;
private uint _timingStreamRetry = 6 * 1000;
@ -172,12 +173,11 @@ namespace BililiveRecorder.Core.Config
private string _clip_filename_format = @"{roomid}-{name}/剪辑片段-{roomid}-{date}-{time}-{title}.flv";
private bool _recordDanmaku = false;
private bool _recordDanmakuRaw = false;
private bool _recordDanmakuSuperChat = false;
private bool _recordDanmakuGift = false;
private bool _recordDanmakuGuard = false;
private bool _avoidTxy = false;
private string _liveApiHost = "https://api.live.bilibili.com";
}
}

View File

@ -1,6 +1,6 @@
using Autofac;
using BililiveRecorder.Core.Config;
using System.Net.Sockets;
using Autofac;
using BililiveRecorder.Core.Config;
namespace BililiveRecorder.Core
{
@ -14,6 +14,7 @@ namespace BililiveRecorder.Core
protected override void Load(ContainerBuilder builder)
{
builder.RegisterType<ConfigV1>().AsSelf().InstancePerMatchingLifetimeScope("recorder_root");
builder.RegisterType<BililiveAPI>().AsSelf().InstancePerMatchingLifetimeScope("recorder_root");
builder.RegisterType<TcpClient>().AsSelf().ExternallyOwned();
builder.RegisterType<StreamMonitor>().As<IStreamMonitor>().ExternallyOwned();
builder.RegisterType<RecordedRoom>().As<IRecordedRoom>().ExternallyOwned();

View File

@ -1,11 +1,12 @@
using BililiveRecorder.Core.Config;
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel;
using BililiveRecorder.Core.Config;
namespace BililiveRecorder.Core
{
public interface IRecorder : INotifyPropertyChanged, INotifyCollectionChanged, IEnumerable<IRecordedRoom>, ICollection<IRecordedRoom>
public interface IRecorder : INotifyPropertyChanged, INotifyCollectionChanged, IEnumerable<IRecordedRoom>, ICollection<IRecordedRoom>, IDisposable
{
ConfigV1 Config { get; }
@ -19,6 +20,6 @@ namespace BililiveRecorder.Core
void SaveConfigToFile();
void Shutdown();
// void Shutdown();
}
}

View File

@ -1,4 +1,4 @@
using BililiveRecorder.Core.Config;
using BililiveRecorder.Core.Config;
using BililiveRecorder.FlvProcessor;
using NLog;
using System;
@ -85,6 +85,7 @@ namespace BililiveRecorder.Core
}
private ConfigV1 _config { get; }
private BililiveAPI BililiveAPI { get; }
public IStreamMonitor StreamMonitor { get; }
private bool _retry = true;
@ -115,11 +116,13 @@ namespace BililiveRecorder.Core
IBasicDanmakuWriter basicDanmakuWriter,
Func<int, IStreamMonitor> newIStreamMonitor,
Func<IFlvStreamProcessor> newIFlvStreamProcessor,
BililiveAPI bililiveAPI,
int roomid)
{
this.newIFlvStreamProcessor = newIFlvStreamProcessor;
_config = config;
BililiveAPI = bililiveAPI;
this.basicDanmakuWriter = basicDanmakuWriter;

View File

@ -1,5 +1,3 @@
using BililiveRecorder.Core.Config;
using NLog;
using System;
using System.Collections;
using System.Collections.Generic;
@ -9,6 +7,8 @@ using System.ComponentModel;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BililiveRecorder.Core.Config;
using NLog;
namespace BililiveRecorder.Core
{
@ -16,24 +16,18 @@ namespace BililiveRecorder.Core
{
private static readonly Logger logger = LogManager.GetCurrentClassLogger();
private ObservableCollection<IRecordedRoom> Rooms { get; } = new ObservableCollection<IRecordedRoom>();
public ConfigV1 Config { get; }
ConfigV1 IRecorder.Config => Config;
public int Count => Rooms.Count;
public bool IsReadOnly => true;
int ICollection<IRecordedRoom>.Count => Rooms.Count;
bool ICollection<IRecordedRoom>.IsReadOnly => true;
private readonly Func<int, IRecordedRoom> newIRecordedRoom;
private CancellationTokenSource tokenSource;
private readonly CancellationTokenSource tokenSource;
private bool _valid = false;
private bool disposedValue;
private ObservableCollection<IRecordedRoom> Rooms { get; } = new ObservableCollection<IRecordedRoom>();
public ConfigV1 Config { get; }
public int Count => Rooms.Count;
public bool IsReadOnly => true;
public IRecordedRoom this[int index] => Rooms[index];
public Recorder(ConfigV1 config, Func<int, IRecordedRoom> iRecordedRoom)
@ -41,8 +35,6 @@ namespace BililiveRecorder.Core
newIRecordedRoom = iRecordedRoom;
Config = config;
BililiveAPI.Config = config;
tokenSource = new CancellationTokenSource();
Repeat.Interval(TimeSpan.FromSeconds(3), DownloadWatchdog, tokenSource.Token);
@ -52,57 +44,8 @@ namespace BililiveRecorder.Core
$"O:{e.OldItems?.Cast<IRecordedRoom>()?.Select(rr => rr.RoomId.ToString())?.Aggregate((current, next) => current + "," + next)};" +
$"N:{e.NewItems?.Cast<IRecordedRoom>()?.Select(rr => rr.RoomId.ToString())?.Aggregate((current, next) => current + "," + next)}");
};
var debouncing = new SemaphoreSlim(1, 1);
Config.PropertyChanged += async (sender, e) =>
{
if (e.PropertyName == nameof(Config.Cookie))
{
if (await debouncing.WaitAsync(0))
{
try
{
logger.Trace("设置 Cookie 等待...");
await Task.Delay(100);
logger.Trace("设置 Cookie 信息...");
await BililiveAPI.ApplyCookieSettings(Config.Cookie);
logger.Debug("设置 Cookie 成功");
}
finally
{
debouncing.Release();
}
}
}
};
}
public event PropertyChangedEventHandler PropertyChanged
{
add => (Rooms as INotifyPropertyChanged).PropertyChanged += value;
remove => (Rooms as INotifyPropertyChanged).PropertyChanged -= value;
}
public event NotifyCollectionChangedEventHandler CollectionChanged
{
add => (Rooms as INotifyCollectionChanged).CollectionChanged += value;
remove => (Rooms as INotifyCollectionChanged).CollectionChanged -= value;
}
event PropertyChangedEventHandler INotifyPropertyChanged.PropertyChanged
{
add => (Rooms as INotifyPropertyChanged).PropertyChanged += value;
remove => (Rooms as INotifyPropertyChanged).PropertyChanged -= value;
}
event NotifyCollectionChangedEventHandler INotifyCollectionChanged.CollectionChanged
{
add => (Rooms as INotifyCollectionChanged).CollectionChanged += value;
remove => (Rooms as INotifyCollectionChanged).CollectionChanged -= value;
}
bool IRecorder.Initialize(string workdir) => Initialize(workdir);
public bool Initialize(string workdir)
{
logger.Debug("Initialize: " + workdir);
@ -167,15 +110,16 @@ namespace BililiveRecorder.Core
/// <param name="rr">直播间</param>
public void RemoveRoom(IRecordedRoom rr)
{
if (rr is null) return;
if (!_valid) { throw new InvalidOperationException("Not Initialized"); }
rr.Shutdown();
logger.Debug("RemoveRoom 移除了直播间 {roomid}", rr.RoomId);
Rooms.Remove(rr);
}
public void Shutdown()
private void Shutdown()
{
if (!_valid) { throw new InvalidOperationException("Not Initialized"); }
if (!_valid) { return; }
logger.Debug("Shutdown called.");
tokenSource.Cancel();
@ -185,6 +129,8 @@ namespace BililiveRecorder.Core
{
rr.Shutdown();
});
Rooms.Clear();
}
public void SaveConfigToFile()
@ -227,19 +173,54 @@ namespace BililiveRecorder.Core
}
void ICollection<IRecordedRoom>.Add(IRecordedRoom item) => throw new NotSupportedException("Collection is readonly");
void ICollection<IRecordedRoom>.Clear() => throw new NotSupportedException("Collection is readonly");
bool ICollection<IRecordedRoom>.Remove(IRecordedRoom item) => throw new NotSupportedException("Collection is readonly");
bool ICollection<IRecordedRoom>.Contains(IRecordedRoom item) => Rooms.Contains(item);
void ICollection<IRecordedRoom>.CopyTo(IRecordedRoom[] array, int arrayIndex) => Rooms.CopyTo(array, arrayIndex);
public IEnumerator<IRecordedRoom> GetEnumerator() => Rooms.GetEnumerator();
IEnumerator<IRecordedRoom> IEnumerable<IRecordedRoom>.GetEnumerator() => Rooms.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => Rooms.GetEnumerator();
public event PropertyChangedEventHandler PropertyChanged
{
add => (Rooms as INotifyPropertyChanged).PropertyChanged += value;
remove => (Rooms as INotifyPropertyChanged).PropertyChanged -= value;
}
public event NotifyCollectionChangedEventHandler CollectionChanged
{
add => (Rooms as INotifyCollectionChanged).CollectionChanged += value;
remove => (Rooms as INotifyCollectionChanged).CollectionChanged -= value;
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
// dispose managed state (managed objects)
Shutdown();
}
// free unmanaged resources (unmanaged objects) and override finalizer
// set large fields to null
disposedValue = true;
}
}
// override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
// ~Recorder()
// {
// // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
// Dispose(disposing: false);
// }
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@ -1,15 +1,14 @@
using BililiveRecorder.Core.Config;
using Newtonsoft.Json;
using NLog;
using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using BililiveRecorder.Core.Config;
using Newtonsoft.Json;
using NLog;
using Timer = System.Timers.Timer;
namespace BililiveRecorder.Core
@ -32,6 +31,7 @@ namespace BililiveRecorder.Core
private readonly Func<TcpClient> funcTcpClient;
private readonly ConfigV1 config;
private readonly BililiveAPI bililiveAPI;
#pragma warning disable IDE1006 // 命名样式
private bool dmTcpConnected => dmClient?.Connected ?? false;
@ -40,7 +40,7 @@ namespace BililiveRecorder.Core
private TcpClient dmClient;
private NetworkStream dmNetStream;
private Thread dmReceiveMessageLoopThread;
private CancellationTokenSource dmTokenSource = null;
private readonly CancellationTokenSource dmTokenSource = null;
private bool dmConnectionTriggered = false;
private readonly Timer httpTimer;
@ -50,10 +50,11 @@ namespace BililiveRecorder.Core
public event StreamStartedEvent StreamStarted;
public event ReceivedDanmakuEvt ReceivedDanmaku;
public StreamMonitor(int roomid, Func<TcpClient> funcTcpClient, ConfigV1 config)
public StreamMonitor(int roomid, Func<TcpClient> funcTcpClient, ConfigV1 config, BililiveAPI bililiveAPI)
{
this.funcTcpClient = funcTcpClient;
this.config = config;
this.bililiveAPI = bililiveAPI;
Roomid = roomid;
@ -178,7 +179,7 @@ namespace BililiveRecorder.Core
public async Task<RoomInfo> FetchRoomInfoAsync()
{
RoomInfo roomInfo = await BililiveAPI.GetRoomInfoAsync(Roomid).ConfigureAwait(false);
RoomInfo roomInfo = await bililiveAPI.GetRoomInfoAsync(Roomid).ConfigureAwait(false);
RoomInfoUpdated?.Invoke(this, new RoomInfoUpdatedArgs { RoomInfo = roomInfo });
return roomInfo;
}
@ -208,7 +209,7 @@ namespace BililiveRecorder.Core
try
{
var (token, host, port) = await BililiveAPI.GetDanmuConf(Roomid);
var (token, host, port) = await bililiveAPI.GetDanmuConf(Roomid);
logger.Log(Roomid, LogLevel.Debug, $"连接弹幕服务器 {host}:{port} {(string.IsNullOrWhiteSpace(token) ? "" : "")} token");

View File

@ -20,7 +20,7 @@
<Compile Include="..\TempBuildInfo\BuildInfo.FlvProcessor.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Autofac" Version="4.8.1" />
<PackageReference Include="Autofac" Version="4.9.4" />
<PackageReference Include="NLog" Version="4.5.10" />
</ItemGroup>
<PropertyGroup>

View File

@ -1,6 +1,19 @@
<Application x:Class="BililiveRecorder.WPF.App"
<Application
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:BililiveRecorder.WPF"
xmlns:ui="http://schemas.modernwpf.com/2019"
x:Class="BililiveRecorder.WPF.App"
Startup="CheckUpdate"
StartupUri="MainWindow.xaml"/>
StartupUri="NewMainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ui:ThemeResources />
<ui:XamlControlsResources />
<!-- Other merged dictionaries here -->
</ResourceDictionary.MergedDictionaries>
<!-- Other app resources here -->
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@ -13,6 +13,8 @@
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
<WarningLevel>4</WarningLevel>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
<NuGetPackageImportStamp>
</NuGetPackageImportStamp>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<PlatformTarget>AnyCPU</PlatformTarget>
@ -23,6 +25,7 @@
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
<Prefer32Bit>false</Prefer32Bit>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<AllowedReferenceRelatedFileExtensions>-</AllowedReferenceRelatedFileExtensions>
@ -50,65 +53,12 @@
<ApplicationManifest>Properties\app.manifest</ApplicationManifest>
</PropertyGroup>
<ItemGroup>
<Reference Include="Autofac, Version=4.8.1.0, Culture=neutral, PublicKeyToken=17863af14b0044da, processorArchitecture=MSIL">
<HintPath>..\packages\Autofac.4.8.1\lib\net45\Autofac.dll</HintPath>
</Reference>
<Reference Include="CommandLine, Version=2.4.3.0, Culture=neutral, PublicKeyToken=de6f01bd326f8c32, processorArchitecture=MSIL">
<HintPath>..\packages\CommandLineParser.2.4.3\lib\netstandard2.0\CommandLine.dll</HintPath>
</Reference>
<Reference Include="DeltaCompressionDotNet, Version=1.1.0.0, Culture=neutral, PublicKeyToken=1d14d6e5194e7f4a, processorArchitecture=MSIL">
<HintPath>..\packages\DeltaCompressionDotNet.1.1.0\lib\net20\DeltaCompressionDotNet.dll</HintPath>
</Reference>
<Reference Include="DeltaCompressionDotNet.MsDelta, Version=1.1.0.0, Culture=neutral, PublicKeyToken=46b2138a390abf55, processorArchitecture=MSIL">
<HintPath>..\packages\DeltaCompressionDotNet.1.1.0\lib\net20\DeltaCompressionDotNet.MsDelta.dll</HintPath>
</Reference>
<Reference Include="DeltaCompressionDotNet.PatchApi, Version=1.1.0.0, Culture=neutral, PublicKeyToken=3e8888ee913ed789, processorArchitecture=MSIL">
<HintPath>..\packages\DeltaCompressionDotNet.1.1.0\lib\net20\DeltaCompressionDotNet.PatchApi.dll</HintPath>
</Reference>
<Reference Include="Hardcodet.Wpf.TaskbarNotification, Version=1.0.5.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\Hardcodet.NotifyIcon.Wpf.1.0.8\lib\net451\Hardcodet.Wpf.TaskbarNotification.dll</HintPath>
</Reference>
<Reference Include="Microsoft.WindowsAPICodePack, Version=1.1.2.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\WindowsAPICodePack-Core.1.1.2\lib\Microsoft.WindowsAPICodePack.dll</HintPath>
</Reference>
<Reference Include="Microsoft.WindowsAPICodePack.Shell, Version=1.1.0.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\WindowsAPICodePack-Shell.1.1.1\lib\Microsoft.WindowsAPICodePack.Shell.dll</HintPath>
</Reference>
<Reference Include="Mono.Cecil, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL">
<HintPath>..\packages\Mono.Cecil.0.9.6.1\lib\net45\Mono.Cecil.dll</HintPath>
</Reference>
<Reference Include="Mono.Cecil.Mdb, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL">
<HintPath>..\packages\Mono.Cecil.0.9.6.1\lib\net45\Mono.Cecil.Mdb.dll</HintPath>
</Reference>
<Reference Include="Mono.Cecil.Pdb, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL">
<HintPath>..\packages\Mono.Cecil.0.9.6.1\lib\net45\Mono.Cecil.Pdb.dll</HintPath>
</Reference>
<Reference Include="Mono.Cecil.Rocks, Version=0.9.6.0, Culture=neutral, PublicKeyToken=0738eb9f132ed756, processorArchitecture=MSIL">
<HintPath>..\packages\Mono.Cecil.0.9.6.1\lib\net45\Mono.Cecil.Rocks.dll</HintPath>
</Reference>
<Reference Include="Newtonsoft.Json, Version=11.0.0.0, Culture=neutral, PublicKeyToken=30ad4fe6b2a6aeed, processorArchitecture=MSIL">
<HintPath>..\packages\Newtonsoft.Json.11.0.2\lib\net45\Newtonsoft.Json.dll</HintPath>
</Reference>
<Reference Include="NLog, Version=4.0.0.0, Culture=neutral, PublicKeyToken=5120e14c03d0593c, processorArchitecture=MSIL">
<HintPath>..\packages\NLog.4.5.10\lib\net45\NLog.dll</HintPath>
</Reference>
<Reference Include="NuGet.Squirrel, Version=3.0.0.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\squirrel.windows.1.9.0\lib\Net45\NuGet.Squirrel.dll</HintPath>
</Reference>
<Reference Include="SharpCompress, Version=0.17.1.0, Culture=neutral, PublicKeyToken=afb0a02973931d96, processorArchitecture=MSIL">
<HintPath>..\packages\SharpCompress.0.17.1\lib\net45\SharpCompress.dll</HintPath>
</Reference>
<Reference Include="Splat, Version=1.6.2.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\Splat.1.6.2\lib\Net45\Splat.dll</HintPath>
</Reference>
<Reference Include="Squirrel, Version=1.9.0.0, Culture=neutral, processorArchitecture=MSIL">
<HintPath>..\packages\squirrel.windows.1.9.0\lib\Net45\Squirrel.dll</HintPath>
</Reference>
<Reference Include="System" />
<Reference Include="System.Configuration" />
<Reference Include="System.Data" />
<Reference Include="System.Deployment" />
<Reference Include="System.Drawing" />
<Reference Include="System.Runtime.Remoting" />
<Reference Include="System.Runtime.Serialization" />
<Reference Include="System.ServiceModel" />
<Reference Include="System.Transactions" />
@ -132,29 +82,95 @@
<SubType>Designer</SubType>
</ApplicationDefinition>
<Compile Include="..\TempBuildInfo\BuildInfo.WPF.cs" />
<Compile Include="ClickSelectTextBox.xaml.cs">
<Compile Include="Controls\AddRoomCard.xaml.cs">
<DependentUpon>AddRoomCard.xaml</DependentUpon>
</Compile>
<Compile Include="Controls\AddRoomFailedDialog.xaml.cs">
<DependentUpon>AddRoomFailedDialog.xaml</DependentUpon>
</Compile>
<Compile Include="Controls\CloseWindowConfirmDialog.xaml.cs">
<DependentUpon>CloseWindowConfirmDialog.xaml</DependentUpon>
</Compile>
<Compile Include="Controls\DeleteRoomConfirmDialog.xaml.cs">
<DependentUpon>DeleteRoomConfirmDialog.xaml</DependentUpon>
</Compile>
<Compile Include="Controls\RoomCard.xaml.cs">
<DependentUpon>RoomCard.xaml</DependentUpon>
</Compile>
<Compile Include="Controls\WorkDirectorySelectorDialog.xaml.cs">
<DependentUpon>WorkDirectorySelectorDialog.xaml</DependentUpon>
</Compile>
<Compile Include="Converters\ClipEnabledToBooleanConverter.cs" />
<Compile Include="Converters\EnumToBooleanConverter.cs" />
<Compile Include="Converters\ShortRoomIdToVisibilityConverter.cs" />
<Compile Include="Converters\ValueConverterGroup.cs" />
<Compile Include="Models\LogModel.cs" />
<Compile Include="Models\RootModel.cs" />
<Compile Include="Pages\RootPage.xaml.cs">
<DependentUpon>RootPage.xaml</DependentUpon>
</Compile>
<Compile Include="Converters\BooleanAndConverter.cs" />
<Compile Include="Converters\BooleanInverterConverter.cs" />
<Compile Include="Converters\BoolToValueConverter.cs" />
<Compile Include="Converters\MultiBoolToValueConverter.cs" />
<Compile Include="Converters\NullValueTemplateSelector.cs" />
<Compile Include="Converters\PercentageToColorBrushConverter.cs" />
<Compile Include="Converters\RoomListInterceptConverter.cs" />
<Compile Include="Legacy\ClickSelectTextBox.xaml.cs">
<DependentUpon>ClickSelectTextBox.xaml</DependentUpon>
</Compile>
<Compile Include="CommandLineOption.cs" />
<Compile Include="EnumerableExtensions.cs" />
<Compile Include="TimedMessageBox.xaml.cs">
<Compile Include="Legacy\TimedMessageBox.xaml.cs">
<DependentUpon>TimedMessageBox.xaml</DependentUpon>
</Compile>
<Compile Include="ValueConverters.cs" />
<Compile Include="SettingsWindow.xaml.cs">
<Compile Include="Legacy\ValueConverters.cs" />
<Compile Include="Legacy\SettingsWindow.xaml.cs">
<DependentUpon>SettingsWindow.xaml</DependentUpon>
</Compile>
<Compile Include="UpdateBarUserControl.xaml.cs">
<Compile Include="Legacy\UpdateBarUserControl.xaml.cs">
<DependentUpon>UpdateBarUserControl.xaml</DependentUpon>
</Compile>
<Compile Include="WorkDirectoryWindow.xaml.cs">
<Compile Include="Legacy\WorkDirectoryWindow.xaml.cs">
<DependentUpon>WorkDirectoryWindow.xaml</DependentUpon>
</Compile>
<Page Include="ClickSelectTextBox.xaml">
<Compile Include="SingleInstance.cs" />
<Page Include="Controls\AddRoomCard.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="MainWindow.xaml">
<Page Include="Controls\AddRoomFailedDialog.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Controls\CloseWindowConfirmDialog.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Controls\DeleteRoomConfirmDialog.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Controls\RoomCard.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Controls\WorkDirectorySelectorDialog.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Pages\RootPage.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Legacy\ClickSelectTextBox.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="DesignTimeResources.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="Legacy\MainWindow.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
@ -162,28 +178,65 @@
<DependentUpon>App.xaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Include="MainWindow.xaml.cs">
<Compile Include="Legacy\MainWindow.xaml.cs">
<DependentUpon>MainWindow.xaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Page Include="SettingsWindow.xaml">
<Page Include="NewMainWindow.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="TimedMessageBox.xaml">
<Page Include="Legacy\SettingsWindow.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="UpdateBarUserControl.xaml">
<Page Include="Legacy\TimedMessageBox.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="WorkDirectoryWindow.xaml">
<Page Include="Legacy\UpdateBarUserControl.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Legacy\WorkDirectoryWindow.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Pages\LogPage.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Pages\RoomListPage.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="Pages\AdvancedSettingsPage.xaml">
<Generator>MSBuild:Compile</Generator>
<SubType>Designer</SubType>
</Page>
<Page Include="Pages\SettingsPage.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<ItemGroup>
<Compile Include="MockData\MockRecordedRoom.cs" />
<Compile Include="MockData\MockRecorder.cs" />
<Compile Include="NewMainWindow.xaml.cs">
<DependentUpon>NewMainWindow.xaml</DependentUpon>
</Compile>
<Compile Include="Pages\LogPage.xaml.cs">
<DependentUpon>LogPage.xaml</DependentUpon>
</Compile>
<Compile Include="Pages\RoomListPage.xaml.cs">
<DependentUpon>RoomListPage.xaml</DependentUpon>
</Compile>
<Compile Include="Pages\AdvancedSettingsPage.xaml.cs">
<DependentUpon>AdvancedSettingsPage.xaml</DependentUpon>
</Compile>
<Compile Include="Pages\SettingsPage.xaml.cs">
<DependentUpon>SettingsPage.xaml</DependentUpon>
</Compile>
<Compile Include="Properties\AssemblyInfo.cs">
<SubType>Code</SubType>
</Compile>
@ -203,10 +256,6 @@
</Content>
<None Include="Nlog.Release.config" />
<None Include="NLog.Debug.config" />
<None Include="NLog.xsd">
<SubType>Designer</SubType>
</None>
<None Include="packages.config" />
</ItemGroup>
<ItemGroup>
<None Include="App.config" />
@ -221,6 +270,41 @@
<Name>BililiveRecorder.FlvProcessor</Name>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Autofac">
<Version>4.9.4</Version>
</PackageReference>
<PackageReference Include="CommandLineParser">
<Version>2.4.3</Version>
</PackageReference>
<PackageReference Include="Hardcodet.NotifyIcon.Wpf">
<Version>1.0.8</Version>
</PackageReference>
<PackageReference Include="ModernWpfUI">
<Version>0.9.2</Version>
</PackageReference>
<PackageReference Include="Newtonsoft.Json">
<Version>11.0.2</Version>
</PackageReference>
<PackageReference Include="NLog.Config">
<Version>4.5.10</Version>
</PackageReference>
<PackageReference Include="NuGet.CommandLine">
<Version>4.7.1</Version>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="squirrel.windows">
<Version>2.0.1</Version>
</PackageReference>
<PackageReference Include="WindowsAPICodePack-Core">
<Version>1.1.2</Version>
</PackageReference>
<PackageReference Include="WindowsAPICodePack-Shell">
<Version>1.1.1</Version>
</PackageReference>
</ItemGroup>
<ItemGroup />
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
<PropertyGroup>
<PreBuildEvent>cd $(SolutionDir)

View File

@ -0,0 +1,27 @@
<UserControl
x:Class="BililiveRecorder.WPF.Controls.AddRoomCard"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ui="http://schemas.modernwpf.com/2019"
xmlns:local="clr-namespace:BililiveRecorder.WPF.Controls"
mc:Ignorable="d"
d:DesignHeight="110" d:DesignWidth="210">
<Grid Margin="0,15">
<Grid.RowDefinitions>
<RowDefinition Height="2*"/>
<RowDefinition Height="3.5*"/>
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<ui:PathIcon Margin="5" Data="M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z"/>
<TextBlock FontSize="20" VerticalAlignment="Center" Text="添加房间"/>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Margin="10,0"
VerticalAlignment="Center" HorizontalAlignment="Center">
<TextBox Width="140" x:Name="InputTextBox" KeyDown="InputTextBox_KeyDown"
ui:ControlHelper.PlaceholderText="房间号或房间链接"/>
<Button Content="确定" Click="Button_Click" />
</StackPanel>
</Grid>
</UserControl>

View File

@ -0,0 +1,40 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
namespace BililiveRecorder.WPF.Controls
{
/// <summary>
/// Interaction logic for AddRoomCard.xaml
/// </summary>
public partial class AddRoomCard : UserControl
{
public event EventHandler<string> AddRoomRequested;
public AddRoomCard()
{
InitializeComponent();
}
private void AddRoom()
{
AddRoomRequested?.Invoke(this, InputTextBox.Text);
InputTextBox.Text = string.Empty;
InputTextBox.Focus();
}
private void Button_Click(object sender, RoutedEventArgs e)
{
AddRoom();
}
private void InputTextBox_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
AddRoom();
}
}
}
}

View File

@ -0,0 +1,20 @@
<ui:ContentDialog
x:Class="BililiveRecorder.WPF.Controls.AddRoomFailedDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ui="http://schemas.modernwpf.com/2019"
xmlns:local="clr-namespace:BililiveRecorder.WPF.Controls"
CloseButtonText="知道了"
DefaultButton="Close"
mc:Ignorable="d">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition/>
</Grid.RowDefinitions>
<TextBlock Text="添加房间失败" TextAlignment="Center" FontSize="20"/>
<TextBlock Grid.Row="1" Text="{Binding}" VerticalAlignment="Center" TextAlignment="Center" FontSize="16"/>
</Grid>
</ui:ContentDialog>

View File

@ -0,0 +1,13 @@
namespace BililiveRecorder.WPF.Controls
{
/// <summary>
/// Interaction logic for AddRoomFailedDialog.xaml
/// </summary>
public partial class AddRoomFailedDialog
{
public AddRoomFailedDialog()
{
InitializeComponent();
}
}
}

View File

@ -0,0 +1,14 @@
<ui:ContentDialog
x:Class="BililiveRecorder.WPF.Controls.CloseWindowConfirmDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ui="http://schemas.modernwpf.com/2019"
xmlns:local="clr-namespace:BililiveRecorder.WPF.Controls"
PrimaryButtonText="退出"
CloseButtonText="取消"
DefaultButton="Close"
mc:Ignorable="d">
<TextBlock Text="确定要退出吗?" TextAlignment="Center" VerticalAlignment="Center" FontSize="26"/>
</ui:ContentDialog>

View File

@ -0,0 +1,13 @@
namespace BililiveRecorder.WPF.Controls
{
/// <summary>
/// Interaction logic for CloseWindowConfirmDialog.xaml
/// </summary>
public partial class CloseWindowConfirmDialog
{
public CloseWindowConfirmDialog()
{
InitializeComponent();
}
}
}

View File

@ -0,0 +1,29 @@
<ui:ContentDialog
x:Class="BililiveRecorder.WPF.Controls.DeleteRoomConfirmDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ui="http://schemas.modernwpf.com/2019"
xmlns:local="clr-namespace:BililiveRecorder.WPF.Controls"
xmlns:mock="clr-namespace:BililiveRecorder.WPF.MockData"
xmlns:conv="clr-namespace:BililiveRecorder.WPF.Converters"
DefaultButton="Close"
PrimaryButtonText="删除"
CloseButtonText="取消"
d:DataContext="{d:DesignInstance Type=mock:MockRecordedRoom,IsDesignTimeCreatable=True}"
mc:Ignorable="d" >
<StackPanel>
<StackPanel.Resources>
<conv:ShortRoomIdToVisibilityConverter x:Key="ShortRoomIdToVisibilityConverter"/>
</StackPanel.Resources>
<TextBlock TextAlignment="Center" FontSize="18" Text="确定要删除这个直播间吗?" Margin="0,0,0,5"/>
<TextBlock VerticalAlignment="Center" Style="{DynamicResource SubtitleTextBlockStyle}" TextWrapping="NoWrap" HorizontalAlignment="Center"
TextAlignment="Center" TextTrimming="CharacterEllipsis" Text="{Binding StreamerName,Mode=OneWay}" ToolTip="{Binding StreamerName,Mode=OneWay}"/>
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Orientation="Horizontal" Grid.Row="1" Grid.ColumnSpan="2" Margin="5,0,0,0">
<ui:PathIcon Height="10" Data="M6,18V8H8V18H6M6,4.5H8V6.5H6V4.5M17,4H19V18H17V17.75C17,17.75 15.67,18 15,18A5,5 0 0,1 10,13A5,5 0 0,1 15,8C15.67,8 17,8.25 17,8.25V4M17,10.25C17,10.25 15.67,10 15,10A3,3 0 0,0 12,13A3,3 0 0,0 15,16C15.67,16 17,15.75 17,15.75V10.25Z" />
<TextBlock Text="{Binding RoomId, StringFormat=\{0\},Mode=OneWay}" Margin="5,0"/>
<TextBlock Text="{Binding ShortRoomId, StringFormat=(\{0\}),Mode=OneWay}" Visibility="{Binding ShortRoomId,Converter={StaticResource ShortRoomIdToVisibilityConverter}}"/>
</StackPanel>
</StackPanel>
</ui:ContentDialog>

View File

@ -0,0 +1,13 @@
namespace BililiveRecorder.WPF.Controls
{
/// <summary>
/// Interaction logic for DeleteRoomConfirmDialog.xaml
/// </summary>
public partial class DeleteRoomConfirmDialog
{
public DeleteRoomConfirmDialog()
{
InitializeComponent();
}
}
}

View File

@ -0,0 +1,125 @@
<UserControl
x:Class="BililiveRecorder.WPF.Controls.RoomCard"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ui="http://schemas.modernwpf.com/2019"
xmlns:local="clr-namespace:BililiveRecorder.WPF.Controls"
xmlns:mock="clr-namespace:BililiveRecorder.WPF.MockData"
xmlns:converter="clr-namespace:BililiveRecorder.WPF.Converters"
xmlns:pages="clr-namespace:BililiveRecorder.WPF.Pages"
xmlns:core="clr-namespace:BililiveRecorder.Core;assembly=BililiveRecorder.Core"
d:DesignWidth="230" d:DesignHeight="120"
d:DataContext="{d:DesignInstance Type=mock:MockRecordedRoom,IsDesignTimeCreatable=True}"
mc:Ignorable="d">
<UserControl.Resources>
<ResourceDictionary>
<BooleanToVisibilityConverter x:Key="b2vConverter" />
<converter:BooleanInverterConverter x:Key="biConverter"/>
<converter:MultiBoolToValueConverter x:Key="mbtvConverter" TrueValue="{x:Static Visibility.Visible}" FalseValue="{x:Static Visibility.Collapsed}"/>
<converter:PercentageToColorBrushConverter x:Key="PercentageToColorBrushConverter"/>
<converter:ClipEnabledToBooleanConverter x:Key="ClipEnabledToBooleanConverter"/>
<converter:ShortRoomIdToVisibilityConverter x:Key="ShortRoomIdToVisibilityConverter"/>
</ResourceDictionary>
</UserControl.Resources>
<Grid Margin="5">
<Grid.RowDefinitions>
<RowDefinition Height="2*"/>
<RowDefinition Height="2*"/>
<RowDefinition Height="2*"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="0.8*"/>
<ColumnDefinition Width="1*"/>
<ColumnDefinition Width="0.7*"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.ColumnSpan="3" VerticalAlignment="Center" Margin="5,0,0,0"
Style="{DynamicResource SubtitleTextBlockStyle}"
TextWrapping="NoWrap" TextTrimming="CharacterEllipsis"
Text="{Binding StreamerName,Mode=OneWay}"
ToolTip="{Binding StreamerName,Mode=OneWay}"/>
<Menu Grid.Row="1" Grid.Column="3" HorizontalAlignment="Right">
<MenuItem ToolTip="操作">
<MenuItem.Header>
<ui:PathIcon Height="20" Width="20" Data="M16,12A2,2 0 0,1 18,10A2,2 0 0,1 20,12A2,2 0 0,1 18,14A2,2 0 0,1 16,12M10,12A2,2 0 0,1 12,10A2,2 0 0,1 14,12A2,2 0 0,1 12,14A2,2 0 0,1 10,12M4,12A2,2 0 0,1 6,10A2,2 0 0,1 8,12A2,2 0 0,1 6,14A2,2 0 0,1 4,12Z"/>
</MenuItem.Header>
<MenuItem Header="开始录制" Click="MenuItem_StartRecording_Click">
<MenuItem.Icon>
<ui:PathIcon Data="M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M10,16.5L16,12L10,7.5V16.5Z"/>
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="停止录制" Click="MenuItem_StopRecording_Click">
<MenuItem.Icon>
<ui:PathIcon Data="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4M9,9V15H15V9"/>
</MenuItem.Icon>
</MenuItem>
<MenuItem Header="刷新直播间信息" Click="MenuItem_RefreshInfo_Click">
<MenuItem.Icon>
<ui:PathIcon Data="M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z"/>
</MenuItem.Icon>
</MenuItem>
<Separator/>
<ui:RadioMenuItem Header="自动录制" GroupName="自动录制" IsChecked="{Binding IsMonitoring,Mode=OneWay}" Click="MenuItem_StartMonitor_Click">
<ui:RadioMenuItem.Icon>
<ui:PathIcon Foreground="Orange" Data="M18.15,4.94C17.77,4.91 17.37,5 17,5.2L8.35,10.2C7.39,10.76 7.07,12 7.62,12.94L9.12,15.53C9.67,16.5 10.89,16.82 11.85,16.27L13.65,15.23C13.92,15.69 14.32,16.06 14.81,16.27V18.04C14.81,19.13 15.7,20 16.81,20H22V18.04H16.81V16.27C17.72,15.87 18.31,14.97 18.31,14C18.31,13.54 18.19,13.11 17.97,12.73L20.5,11.27C21.47,10.71 21.8,9.5 21.24,8.53L19.74,5.94C19.4,5.34 18.79,5 18.15,4.94M6.22,13.17L2,13.87L2.75,15.17L4.75,18.63L5.5,19.93L8.22,16.63L6.22,13.17Z"/>
</ui:RadioMenuItem.Icon>
</ui:RadioMenuItem>
<ui:RadioMenuItem Header="不自动录制" GroupName="自动录制" IsChecked="{Binding IsMonitoring,Mode=OneWay,Converter={StaticResource biConverter}}" Click="MenuItem_StopMonitor_Click"/>
<Separator/>
<MenuItem Header="删除房间" Click="MenuItem_DeleteRoom_Click">
<MenuItem.Icon>
<ui:PathIcon Foreground="DarkRed" Data="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z"/>
</MenuItem.Icon>
</MenuItem>
</MenuItem>
</Menu>
<StackPanel VerticalAlignment="Center" Orientation="Horizontal" Grid.Row="1" Grid.ColumnSpan="2" Margin="5,0,0,0">
<ui:PathIcon Height="10" Data="M6,18V8H8V18H6M6,4.5H8V6.5H6V4.5M17,4H19V18H17V17.75C17,17.75 15.67,18 15,18A5,5 0 0,1 10,13A5,5 0 0,1 15,8C15.67,8 17,8.25 17,8.25V4M17,10.25C17,10.25 15.67,10 15,10A3,3 0 0,0 12,13A3,3 0 0,0 15,16C15.67,16 17,15.75 17,15.75V10.25Z" />
<TextBlock Text="{Binding RoomId, StringFormat=\{0\},Mode=OneWay}" Margin="5,0"/>
<TextBlock Text="{Binding ShortRoomId, StringFormat=(\{0\}),Mode=OneWay}" Visibility="{Binding ShortRoomId,Converter={StaticResource ShortRoomIdToVisibilityConverter}}"/>
</StackPanel>
<Border Grid.Row="2" Grid.Column="1" VerticalAlignment="Center" Visibility="{Binding Visibility, ElementName=RecordingIcon}"
Background="{Binding DownloadSpeedPersentage, Converter={StaticResource PercentageToColorBrushConverter},Mode=OneWay}"
BorderThickness="1" BorderBrush="Black" Margin="2,0" CornerRadius="5">
<Border.ToolTip>
<StackPanel>
<TextBlock Text="录制的速度与质量"/>
<TextBlock Text="绿色为良好,红色为差"/>
<TextBlock Margin="0,5,0,0" Text="{Binding DownloadSpeedPersentage,StringFormat=当前速度比: 0.## %,Mode=OneWay}"/>
</StackPanel>
</Border.ToolTip>
<TextBlock HorizontalAlignment="Center" Text="{Binding DownloadSpeedMegaBitps, StringFormat=0.## Mbps,Mode=OneWay}"/>
</Border>
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Orientation="Horizontal" Grid.Row="2" Grid.Column="0">
<StackPanel.Visibility>
<MultiBinding Converter="{StaticResource mbtvConverter}" Mode="OneWay">
<Binding Path="IsRecording" Converter="{StaticResource biConverter}" Mode="OneWay"/>
<Binding Path="IsMonitoring" Mode="OneWay"/>
</MultiBinding>
</StackPanel.Visibility>
<ui:PathIcon Data="M18.15,4.94C17.77,4.91 17.37,5 17,5.2L8.35,10.2C7.39,10.76 7.07,12 7.62,12.94L9.12,15.53C9.67,16.5 10.89,16.82 11.85,16.27L13.65,15.23C13.92,15.69 14.32,16.06 14.81,16.27V18.04C14.81,19.13 15.7,20 16.81,20H22V18.04H16.81V16.27C17.72,15.87 18.31,14.97 18.31,14C18.31,13.54 18.19,13.11 17.97,12.73L20.5,11.27C21.47,10.71 21.8,9.5 21.24,8.53L19.74,5.94C19.4,5.34 18.79,5 18.15,4.94M6.22,13.17L2,13.87L2.75,15.17L4.75,18.63L5.5,19.93L8.22,16.63L6.22,13.17Z" Foreground="DarkOrange" Width="15"/>
<TextBlock Foreground="DarkOrange" Margin="3,0,0,0" Text="监控中"/>
</StackPanel>
<StackPanel x:Name="RecordingIcon" Visibility="{Binding IsRecording, Converter={StaticResource b2vConverter},Mode=OneWay}"
VerticalAlignment="Center" HorizontalAlignment="Center" Orientation="Horizontal" Grid.Row="2" Grid.Column="0">
<ui:PathIcon Data="M12.5,5A7.5,7.5 0 0,0 5,12.5A7.5,7.5 0 0,0 12.5,20A7.5,7.5 0 0,0 20,12.5A7.5,7.5 0 0,0 12.5,5M7,10H9A1,1 0 0,1 10,11V12C10,12.5 9.62,12.9 9.14,12.97L10.31,15H9.15L8,13V15H7M12,10H14V11H12V12H14V13H12V14H14V15H12A1,1 0 0,1 11,14V11A1,1 0 0,1 12,10M16,10H18V11H16V14H18V15H16A1,1 0 0,1 15,14V11A1,1 0 0,1 16,10M8,11V12H9V11" Foreground="Red" Width="15"/>
<TextBlock Foreground="Red" Margin="3,0,0,0" Text="录制中"/>
</StackPanel>
<Button Grid.Column="2" Grid.Row="2" Padding="3,1" HorizontalAlignment="Right" Margin="5,0" ToolTip="回放剪辑(正在处理中的数量)" Click="Button_Clip_Click">
<Button.Visibility>
<MultiBinding Converter="{StaticResource mbtvConverter}" Mode="OneWay">
<Binding Path="IsRecording" Mode="OneWay"/>
<Binding RelativeSource="{RelativeSource Mode=FindAncestor,AncestorType=pages:RootPage}"
Path="DataContext.Recorder.Config.EnabledFeature" Mode="OneWay"
Converter="{StaticResource ClipEnabledToBooleanConverter}"/>
</MultiBinding>
</Button.Visibility>
<StackPanel Orientation="Horizontal" VerticalAlignment="Center">
<ui:PathIcon Width="10" Data="M19,3L13,9L15,11L22,4V3M12,12.5A0.5,0.5 0 0,1 11.5,12A0.5,0.5 0 0,1 12,11.5A0.5,0.5 0 0,1 12.5,12A0.5,0.5 0 0,1 12,12.5M6,20A2,2 0 0,1 4,18C4,16.89 4.9,16 6,16A2,2 0 0,1 8,18C8,19.11 7.1,20 6,20M6,8A2,2 0 0,1 4,6C4,4.89 4.9,4 6,4A2,2 0 0,1 8,6C8,7.11 7.1,8 6,8M9.64,7.64C9.87,7.14 10,6.59 10,6A4,4 0 0,0 6,2A4,4 0 0,0 2,6A4,4 0 0,0 6,10C6.59,10 7.14,9.87 7.64,9.64L10,12L7.64,14.36C7.14,14.13 6.59,14 6,14A4,4 0 0,0 2,18A4,4 0 0,0 6,22A4,4 0 0,0 10,18C10,17.41 9.87,16.86 9.64,16.36L12,14L19,21H22V20L9.64,7.64Z"/>
<TextBlock VerticalAlignment="Center" Margin="2,0,0,2" Text="{Binding Processor.Clips.Count,FallbackValue=(-),StringFormat=({0}),Mode=OneWay}"/>
</StackPanel>
</Button>
</Grid>
</UserControl>

View File

@ -0,0 +1,55 @@
using System;
using System.Windows;
using System.Windows.Controls;
using BililiveRecorder.Core;
namespace BililiveRecorder.WPF.Controls
{
/// <summary>
/// Interaction logic for RoomCard.xaml
/// </summary>
public partial class RoomCard : UserControl
{
public RoomCard()
{
InitializeComponent();
}
public event EventHandler DeleteRequested;
private void MenuItem_StartRecording_Click(object sender, RoutedEventArgs e)
{
(DataContext as IRecordedRoom)?.StartRecord();
}
private void MenuItem_StopRecording_Click(object sender, RoutedEventArgs e)
{
(DataContext as IRecordedRoom)?.StopRecord();
}
private void MenuItem_RefreshInfo_Click(object sender, RoutedEventArgs e)
{
(DataContext as IRecordedRoom)?.RefreshRoomInfo();
}
private void MenuItem_StartMonitor_Click(object sender, RoutedEventArgs e)
{
(DataContext as IRecordedRoom)?.Start();
}
private void MenuItem_StopMonitor_Click(object sender, RoutedEventArgs e)
{
(DataContext as IRecordedRoom)?.Stop();
}
private void MenuItem_DeleteRoom_Click(object sender, RoutedEventArgs e)
{
DeleteRequested?.Invoke(DataContext, EventArgs.Empty);
}
private void Button_Clip_Click(object sender, RoutedEventArgs e)
{
(DataContext as IRecordedRoom)?.Clip();
}
}
}

View File

@ -0,0 +1,32 @@
<ui:ContentDialog
x:Class="BililiveRecorder.WPF.Controls.WorkDirectorySelectorDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ui="http://schemas.modernwpf.com/2019"
xmlns:local="clr-namespace:BililiveRecorder.WPF.Controls"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=local:WorkDirectorySelectorDialog}"
PrimaryButtonText="确定"
IsPrimaryButtonEnabled="True"
CloseButtonText="退出">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition/>
</Grid.RowDefinitions>
<TextBlock Text="选择工作目录" TextAlignment="Center" FontSize="26"/>
<TextBlock Grid.Row="1" Text="{Binding Error}" Margin="0,5" TextAlignment="Center" FontSize="16" Foreground="#FFFF2828"/>
<Grid Grid.Row="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox VerticalAlignment="Center" FontSize="14" Text="{Binding Path}"/>
<Button Grid.Column="1" Click="Button_Click">浏览...</Button>
</Grid>
</Grid>
</ui:ContentDialog>

View File

@ -0,0 +1,52 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Microsoft.WindowsAPICodePack.Dialogs;
namespace BililiveRecorder.WPF.Controls
{
/// <summary>
/// Interaction logic for WorkDirectorySelectorDialog.xaml
/// </summary>
public partial class WorkDirectorySelectorDialog : INotifyPropertyChanged
{
private string error = string.Empty;
private string path = string.Empty;
public string Error { get => error; set => SetField(ref error, value); }
public string Path { get => path; set => SetField(ref path, value); }
public WorkDirectorySelectorDialog()
{
DataContext = this;
InitializeComponent();
}
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
protected bool SetField<T>(ref T field, T value, [CallerMemberName] string propertyName = "")
{
if (EqualityComparer<T>.Default.Equals(field, value)) { return false; }
field = value; OnPropertyChanged(propertyName); return true;
}
private void Button_Click(object sender, System.Windows.RoutedEventArgs e)
{
var fileDialog = new CommonOpenFileDialog()
{
IsFolderPicker = true,
Multiselect = false,
Title = "选择录播姬工作目录路径",
AddToMostRecentlyUsedList = false,
EnsurePathExists = true,
NavigateToShortcut = true,
InitialDirectory = Path,
};
if (fileDialog.ShowDialog() == CommonFileDialogResult.Ok)
{
Path = fileDialog.FileName;
}
}
}
}

View File

@ -0,0 +1,30 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace BililiveRecorder.WPF.Converters
{
internal class BoolToValueConverter : IValueConverter
{
public object FalseValue { get; set; }
public object TrueValue { get; set; }
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value == null)
{
return FalseValue;
}
else
{
return (bool)value ? TrueValue : FalseValue;
}
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
return value != null ? value.Equals(TrueValue) : false;
}
}
}

View File

@ -0,0 +1,26 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace BililiveRecorder.WPF.Converters
{
internal class BooleanAndConverter : IMultiValueConverter
{
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
foreach (var value in values)
{
if ((value is bool boolean) && boolean == false)
{
return false;
}
}
return true;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@ -0,0 +1,20 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace BililiveRecorder.WPF.Converters
{
internal class BooleanInverterConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return !(bool)value;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
return !(bool)value;
}
}
}

View File

@ -0,0 +1,20 @@
using System;
using System.Globalization;
using System.Windows.Data;
using BililiveRecorder.FlvProcessor;
namespace BililiveRecorder.WPF.Converters
{
internal class ClipEnabledToBooleanConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return !(value is EnabledFeature v) || (EnabledFeature.RecordOnly != v);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@ -0,0 +1,19 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace BililiveRecorder.WPF.Converters
{
internal class EnumToBooleanConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return value.Equals(parameter);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
return value.Equals(true) ? parameter : Binding.DoNothing;
}
}
}

View File

@ -0,0 +1,29 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace BililiveRecorder.WPF.Converters
{
public class MultiBoolToValueConverter : IMultiValueConverter
{
public object FalseValue { get; set; }
public object TrueValue { get; set; }
public object Convert(object[] values, Type targetType, object parameter, CultureInfo culture)
{
foreach (var value in values)
{
if ((value is bool boolean) && boolean == false)
{
return FalseValue;
}
}
return TrueValue;
}
public object[] ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@ -0,0 +1,13 @@
using System.Windows;
using System.Windows.Controls;
namespace BililiveRecorder.WPF.Converters
{
internal class NullValueTemplateSelector : DataTemplateSelector
{
public DataTemplate Normal { get; set; }
public DataTemplate Null { get; set; }
public override DataTemplate SelectTemplate(object item, DependencyObject container) => item is null ? Null : Normal;
}
}

View File

@ -0,0 +1,30 @@
using System;
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
namespace BililiveRecorder.WPF.Converters
{
internal class PercentageToColorBrushConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
const double a = 1d;
const double b = 6d;
const double c = 100d;
const double d = 2.2d;
var x = (double)value * 100d;
var y = x < (c - a) ? c - Math.Pow(Math.Abs(x - c + a), d) / b : x > (c + a) ? c - Math.Pow(x - c - a, d) / b : c;
return new SolidColorBrush(GradientPick(Math.Max(y, 0d) / 100d, Colors.Red, Colors.Yellow, Colors.Lime));
Color GradientPick(double percentage, Color c1, Color c2, Color c3) => percentage < 0.5 ? ColorInterp(c1, c2, percentage / 0.5) : percentage == 0.5 ? c2 : ColorInterp(c2, c3, (percentage - 0.5) / 0.5);
Color ColorInterp(Color start, Color end, double percentage) => Color.FromRgb(LinearInterp(start.R, end.R, percentage), LinearInterp(start.G, end.G, percentage), LinearInterp(start.B, end.B, percentage));
byte LinearInterp(byte start, byte end, double percentage) => (byte)(start + Math.Round(percentage * (end - start)));
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@ -0,0 +1,59 @@
using System;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Globalization;
using System.Windows.Data;
using BililiveRecorder.Core;
using BililiveRecorder.Core.Config;
namespace BililiveRecorder.WPF.Converters
{
internal class RoomListInterceptConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return value is RecorderWrapper ? value : value is IRecorder recorder ? new RecorderWrapper(recorder) : value;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
return value;
}
private class RecorderWrapper : ObservableCollection<IRecordedRoom>
{
private readonly IRecorder recorder;
public RecorderWrapper(IRecorder recorder) : base(recorder)
{
this.recorder = recorder;
Add(null);
recorder.CollectionChanged += (sender, e) =>
{
switch (e.Action)
{
case NotifyCollectionChangedAction.Add:
if (e.NewItems.Count != 1) throw new NotImplementedException("Wrapper Add Item Count != 1");
InsertItem(e.NewStartingIndex, e.NewItems[0] as IRecordedRoom);
break;
case NotifyCollectionChangedAction.Remove:
if (e.OldItems.Count != 1) throw new NotImplementedException("Wrapper Remove Item Count != 1");
if (!Remove(e.OldItems[0] as IRecordedRoom)) throw new NotImplementedException("Wrapper Remove Item Sync Fail");
break;
case NotifyCollectionChangedAction.Replace:
throw new NotImplementedException("Wrapper Replace Item");
case NotifyCollectionChangedAction.Move:
throw new NotImplementedException("Wrapper Move Item");
case NotifyCollectionChangedAction.Reset:
ClearItems();
Add(null);
break;
default:
break;
}
};
}
}
}
}

View File

@ -0,0 +1,20 @@
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace BililiveRecorder.WPF.Converters
{
internal class ShortRoomIdToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return value is int i ? i == 0 ? Visibility.Collapsed : Visibility.Visible : Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@ -0,0 +1,21 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Windows.Data;
namespace BililiveRecorder.WPF.Converters
{
internal class ValueConverterGroup : List<IValueConverter>, IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return this.Aggregate(value, (current, converter) => converter.Convert(current, targetType, parameter, culture));
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@ -0,0 +1,8 @@
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.modernwpf.com/2019">
<ResourceDictionary.MergedDictionaries>
<ui:IntellisenseResources Source="/ModernWpf;component/DesignTime/DesignTimeResources.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>

View File

@ -1,71 +0,0 @@
using System;
using System.Collections.Generic;
namespace BililiveRecorder.WPF
{
internal static class EnumerableExtensions
{
/// <summary>
/// Returns the elements with the maximum key value by using the default comparer to compare key values.
/// </summary>
/// <typeparam name="TSource">Source sequence element type.</typeparam>
/// <typeparam name="TKey">Key type.</typeparam>
/// <param name="source">Source sequence.</param>
/// <param name="keySelector">Key selector used to extract the key for each element in the sequence.</param>
/// <returns>List with the elements that share the same maximum key value.</returns>
public static IList<TSource> MaxBy<TSource, TKey>(this IEnumerable<TSource> source, Func<TSource, TKey> keySelector)
{
if (source == null) { throw new ArgumentNullException("source"); }
if (keySelector == null) { throw new ArgumentNullException("keySelector"); }
return MaxBy(source, keySelector, Comparer<TKey>.Default);
}
/// <summary>
/// Returns the elements with the minimum key value by using the specified comparer to compare key values.
/// </summary>
/// <typeparam name="TSource">Source sequence element type.</typeparam>
/// <typeparam name="TKey">Key type.</typeparam>
/// <param name="source">Source sequence.</param>
/// <param name="keySelector">Key selector used to extract the key for each element in the sequence.</param>
/// <param name="comparer">Comparer used to determine the maximum key value.</param>
/// <returns>List with the elements that share the same maximum key value.</returns>
public static IList<TSource> MaxBy<TSource, TKey>(this IEnumerable<TSource> source, Func<TSource, TKey> keySelector, IComparer<TKey> comparer)
{
if (source == null) { throw new ArgumentNullException("source"); }
if (keySelector == null) { throw new ArgumentNullException("keySelector"); }
if (comparer == null) { throw new ArgumentNullException("comparer"); }
return ExtremaBy(source, keySelector, (key, minValue) => comparer.Compare(key, minValue));
}
private static IList<TSource> ExtremaBy<TSource, TKey>(IEnumerable<TSource> source, Func<TSource, TKey> keySelector, Func<TKey, TKey, int> compare)
{
var result = new List<TSource>();
using (var e = source.GetEnumerator())
{
if (!e.MoveNext()) { throw new InvalidOperationException("Source sequence doesn't contain any elements."); }
var current = e.Current;
var resKey = keySelector(current);
result.Add(current);
while (e.MoveNext())
{
var cur = e.Current;
var key = keySelector(cur);
var cmp = compare(key, resKey);
if (cmp == 0) { result.Add(cur); }
else if (cmp > 0)
{
result = new List<TSource> { cur };
resKey = key;
}
}
}
return result;
}
}
}

View File

@ -1,4 +1,4 @@
<Window x:Class="BililiveRecorder.WPF.MainWindow"
<Window x:Class="BililiveRecorder.WPF.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
@ -118,7 +118,9 @@
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<Grid Grid.Column="1" DataContext="{Binding ElementName=RoomList,Path=SelectedItem}" Grid.RowSpan="2">
<Grid Grid.Column="1" Grid.RowSpan="2"
DataContext="{Binding ElementName=RoomList,Path=SelectedItem}"
d:DataContext="{d:DesignInstance Type=core:RecordedRoom}">
<Grid.RowDefinitions>
<RowDefinition Height="10"/>
<RowDefinition Height="Auto"/>

View File

@ -1,4 +1,4 @@
using Autofac;
using Autofac;
using BililiveRecorder.Core;
using BililiveRecorder.FlvProcessor;
using CommandLine;
@ -140,7 +140,8 @@ namespace BililiveRecorder.WPF
}.ShowDialog() == true)
{
_AddLog = null;
Recorder.Shutdown();
// Recorder.Shutdown();
Recorder.Dispose();
try
{
File.WriteAllText(LAST_WORK_DIR_FILE, Recorder.Config.WorkDirectory);

View File

@ -1,4 +1,4 @@
<Window x:Class="BililiveRecorder.WPF.SettingsWindow"
<Window x:Class="BililiveRecorder.WPF.SettingsWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
@ -10,7 +10,7 @@
d:DataContext="{d:DesignInstance Type=config:ConfigV1}"
mc:Ignorable="d"
ShowInTaskbar="False" ResizeMode="NoResize"
Title="设置 - 录播姬" Height="630" Width="300">
Title="设置 - 录播姬" Height="930" Width="300">
<Window.Resources>
<local:ValueConverterGroup x:Key="EnumToInvertBooleanConverter">
<local:EnumToBooleanConverter/>

View File

@ -0,0 +1,113 @@
using System;
using System.ComponentModel;
using BililiveRecorder.Core;
using BililiveRecorder.FlvProcessor;
namespace BililiveRecorder.WPF.MockData
{
#if DEBUG
internal class MockRecordedRoom : IRecordedRoom
{
private bool disposedValue;
public MockRecordedRoom()
{
RoomId = 123456789;
ShortRoomId = 1234;
StreamerName = "Mock主播名Mock主播名Mock主播名Mock主播名";
IsMonitoring = false;
IsRecording = true;
DownloadSpeedPersentage = 100d;
DownloadSpeedMegaBitps = 2.45d;
}
public int ShortRoomId { get; set; }
public int RoomId { get; set; }
public string StreamerName { get; set; }
public IStreamMonitor StreamMonitor { get; set; }
public IFlvStreamProcessor Processor { get; set; }
public bool IsMonitoring { get; set; }
public bool IsRecording { get; set; }
public double DownloadSpeedPersentage { get; set; }
public double DownloadSpeedMegaBitps { get; set; }
public DateTime LastUpdateDateTime { get; set; }
public event PropertyChangedEventHandler PropertyChanged;
public void Clip()
{
}
public void RefreshRoomInfo()
{
}
public void Shutdown()
{
}
public bool Start()
{
IsMonitoring = true;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsMonitoring)));
return true;
}
public void StartRecord()
{
IsRecording = true;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsRecording)));
}
public void Stop()
{
IsMonitoring = false;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsMonitoring)));
}
public void StopRecord()
{
IsRecording = false;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsRecording)));
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
// TODO: dispose managed state (managed objects)
}
// TODO: free unmanaged resources (unmanaged objects) and override finalizer
// TODO: set large fields to null
disposedValue = true;
}
}
// // TODO: override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
// ~MockRecordedRoom()
// {
// // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
// Dispose(disposing: false);
// }
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
#endif
}

View File

@ -0,0 +1,165 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using BililiveRecorder.Core;
using BililiveRecorder.Core.Config;
namespace BililiveRecorder.WPF.MockData
{
#if DEBUG
internal class MockRecorder : IRecorder
{
private bool disposedValue;
public MockRecorder()
{
Rooms.Add(new MockRecordedRoom
{
IsMonitoring = false,
IsRecording = false
});
Rooms.Add(new MockRecordedRoom
{
IsMonitoring = true,
IsRecording = false
});
Rooms.Add(new MockRecordedRoom
{
DownloadSpeedPersentage = 100,
DownloadSpeedMegaBitps = 12.45
});
Rooms.Add(new MockRecordedRoom
{
DownloadSpeedPersentage = 95,
DownloadSpeedMegaBitps = 789.45
});
Rooms.Add(new MockRecordedRoom
{
DownloadSpeedPersentage = 90
});
Rooms.Add(new MockRecordedRoom
{
DownloadSpeedPersentage = 85
});
Rooms.Add(new MockRecordedRoom
{
DownloadSpeedPersentage = 80
});
Rooms.Add(new MockRecordedRoom
{
DownloadSpeedPersentage = 75
});
Rooms.Add(new MockRecordedRoom
{
DownloadSpeedPersentage = 70
});
Rooms.Add(new MockRecordedRoom
{
DownloadSpeedPersentage = 109
});
}
private ObservableCollection<IRecordedRoom> Rooms { get; } = new ObservableCollection<IRecordedRoom>();
public ConfigV1 Config { get; } = new ConfigV1();
public int Count => Rooms.Count;
public bool IsReadOnly => true;
int ICollection<IRecordedRoom>.Count => Rooms.Count;
bool ICollection<IRecordedRoom>.IsReadOnly => true;
public IRecordedRoom this[int index] => Rooms[index];
public event PropertyChangedEventHandler PropertyChanged
{
add => (Rooms as INotifyPropertyChanged).PropertyChanged += value;
remove => (Rooms as INotifyPropertyChanged).PropertyChanged -= value;
}
public event NotifyCollectionChangedEventHandler CollectionChanged
{
add => (Rooms as INotifyCollectionChanged).CollectionChanged += value;
remove => (Rooms as INotifyCollectionChanged).CollectionChanged -= value;
}
void ICollection<IRecordedRoom>.Add(IRecordedRoom item) => throw new NotSupportedException("Collection is readonly");
void ICollection<IRecordedRoom>.Clear() => throw new NotSupportedException("Collection is readonly");
bool ICollection<IRecordedRoom>.Remove(IRecordedRoom item) => throw new NotSupportedException("Collection is readonly");
bool ICollection<IRecordedRoom>.Contains(IRecordedRoom item) => Rooms.Contains(item);
void ICollection<IRecordedRoom>.CopyTo(IRecordedRoom[] array, int arrayIndex) => Rooms.CopyTo(array, arrayIndex);
public IEnumerator<IRecordedRoom> GetEnumerator() => Rooms.GetEnumerator();
IEnumerator<IRecordedRoom> IEnumerable<IRecordedRoom>.GetEnumerator() => Rooms.GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => Rooms.GetEnumerator();
public bool Initialize(string workdir)
{
Config.WorkDirectory = workdir;
return true;
}
public void AddRoom(int roomid)
{
AddRoom(roomid, false);
}
public void AddRoom(int roomid, bool enabled)
{
Rooms.Add(new MockRecordedRoom { RoomId = roomid, IsMonitoring = enabled });
}
public void RemoveRoom(IRecordedRoom rr)
{
Rooms.Remove(rr);
}
public void SaveConfigToFile()
{
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
// dispose managed state (managed objects)
Rooms.Clear();
}
// free unmanaged resources (unmanaged objects) and override finalizer
// set large fields to null
disposedValue = true;
}
}
// override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
// ~MockRecorder()
// {
// // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
// Dispose(disposing: false);
// }
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
#endif
}

View File

@ -0,0 +1,67 @@
using System;
using System.Collections.ObjectModel;
using System.Windows;
using System.Windows.Threading;
namespace BililiveRecorder.WPF.Models
{
internal class LogModel : ObservableCollection<string>, IDisposable
{
private const int MAX_LINE = 50;
private bool disposedValue;
public static void AddLog(string log) => LogReceived?.Invoke(null, log);
public static event EventHandler<string> LogReceived;
public LogModel() : base(new[] { "" })
{
LogReceived += LogModel_LogReceived;
}
private void LogModel_LogReceived(object sender, string e)
{
_ = Application.Current.Dispatcher.BeginInvoke(DispatcherPriority.DataBind, (Action<string>)AddLogToCollection, e);
}
private void AddLogToCollection(string e)
{
Add(e);
while (Count > MAX_LINE)
{
RemoveItem(0);
}
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
// dispose managed state (managed objects)
LogReceived -= LogModel_LogReceived;
ClearItems();
}
// free unmanaged resources (unmanaged objects) and override finalizer
// set large fields to null
disposedValue = true;
}
}
// override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
// ~LogModel()
// {
// // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
// Dispose(disposing: false);
// }
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@ -0,0 +1,50 @@
using System;
using BililiveRecorder.Core;
namespace BililiveRecorder.WPF.Models
{
internal class RootModel : IDisposable
{
private bool disposedValue;
public LogModel Logs { get; } = new LogModel();
public IRecorder Recorder { get; internal set; }
public RootModel()
{
}
protected virtual void Dispose(bool disposing)
{
if (!disposedValue)
{
if (disposing)
{
// dispose managed state (managed objects)
Recorder?.Dispose();
Logs.Dispose();
}
// free unmanaged resources (unmanaged objects) and override finalizer
// set large fields to null
disposedValue = true;
}
}
// override finalizer only if 'Dispose(bool disposing)' has code to free unmanaged resources
// ~RootModel()
// {
// // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
// Dispose(disposing: false);
// }
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@ -16,7 +16,7 @@
for information on customizing logging rules and outputs.
-->
<targets>
<target name="WPFLogger" xsi:type="MethodCall" className="BililiveRecorder.WPF.MainWindow, BililiveRecorder.WPF" methodName="AddLog">
<target name="WPFLogger" xsi:type="MethodCall" className="BililiveRecorder.WPF.Models.LogModel, BililiveRecorder.WPF" methodName="AddLog">
<parameter layout="[${date:format=HH\:mm\:ss}] ${uppercase:${level}} ${event-properties:item=roomid} ${message} ${exception:format=Message}" />
</target>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,20 @@
<Window x:Class="BililiveRecorder.WPF.NewMainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="http://schemas.modernwpf.com/2019"
xmlns:local="clr-namespace:BililiveRecorder.WPF"
xmlns:pages="clr-namespace:BililiveRecorder.WPF.Pages"
mc:Ignorable="d"
WindowStartupLocation="CenterScreen"
Background="{DynamicResource SystemControlPageBackgroundAltHighBrush}"
Foreground="{DynamicResource SystemControlPageTextBaseHighBrush}"
ui:ThemeManager.IsThemeAware="True"
ui:WindowHelper.UseModernWindowStyle="True"
Width="1000" Height="650"
MinHeight="400" MinWidth="340"
Closing="Window_Closing"
Title="录播姬">
<pages:RootPage x:Name="RootPage" CloseWindowRequested="RootPage_CloseWindowRequested"/>
</Window>

View File

@ -0,0 +1,86 @@
using System;
using System.Threading;
using System.Windows;
using BililiveRecorder.WPF.Controls;
using ModernWpf.Controls;
namespace BililiveRecorder.WPF
{
/// <summary>
/// Interaction logic for NewMainWindow.xaml
/// </summary>
public partial class NewMainWindow : Window
{
public NewMainWindow()
{
InitializeComponent();
Title = "录播姬 " + BuildInfo.Version + " " + BuildInfo.HeadShaShort;
BeforeWindowClose += NewMainWindow_BeforeWindowClose;
SingleInstance.NotificationReceived += SingleInstance_NotificationReceived;
}
private void SingleInstance_NotificationReceived(object sender, EventArgs e)
{
WindowState = WindowState.Normal;
Topmost = true;
Activate();
Topmost = false;
Focus();
}
private bool CloseConfirmed = false;
private readonly SemaphoreSlim CloseWindowSemaphoreSlim = new SemaphoreSlim(1, 1);
public event EventHandler BeforeWindowClose;
public bool PromptCloseConfirm { get; set; } = true;
private void NewMainWindow_BeforeWindowClose(object sender, EventArgs e)
{
RootPage?.Shutdown();
SingleInstance.Cleanup();
}
private async void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
if (PromptCloseConfirm && !CloseConfirmed)
{
e.Cancel = true;
if (CloseWindowSemaphoreSlim.Wait(0))
{
try
{
if (await new CloseWindowConfirmDialog().ShowAsync() == ContentDialogResult.Primary)
{
CloseConfirmed = true;
CloseWindowSemaphoreSlim.Release();
Close();
return;
}
}
catch (Exception) { }
CloseWindowSemaphoreSlim.Release();
}
}
else
{
BeforeWindowClose?.Invoke(this, EventArgs.Empty);
return;
}
}
public void CloseWithoutConfirm()
{
CloseConfirmed = true;
Close();
}
private void RootPage_CloseWindowRequested(object sender, EventArgs e)
{
CloseWithoutConfirm();
}
}
}

View File

@ -6,7 +6,7 @@
throwExceptions="false"
internalLogLevel="Off" internalLogFile="c:\temp\nlog-internal.log">
<targets>
<target name="WPFLogger" xsi:type="MethodCall" className="BililiveRecorder.WPF.MainWindow, BililiveRecorder.WPF" methodName="AddLog">
<target name="WPFLogger" xsi:type="MethodCall" className="BililiveRecorder.WPF.Models.LogModel, BililiveRecorder.WPF" methodName="AddLog">
<parameter layout="[${date:format=HH\:mm\:ss}] ${uppercase:${level}} ${event-properties:item=roomid} ${message} ${exception:format=Message}" />
</target>
<target name="file" xsi:type="File"

View File

@ -0,0 +1,86 @@
<ui:Page
x:Class="BililiveRecorder.WPF.Pages.AdvancedSettingsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ui="http://schemas.modernwpf.com/2019"
xmlns:local="clr-namespace:BililiveRecorder.WPF.Pages"
xmlns:conv="clr-namespace:BililiveRecorder.WPF.Converters"
xmlns:config="clr-namespace:BililiveRecorder.Core.Config;assembly=BililiveRecorder.Core"
mc:Ignorable="d"
d:DesignHeight="1500" d:DesignWidth="500"
DataContext="{Binding RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=local:RootPage},Path=DataContext.Recorder.Config}"
Title="SettingsPage">
<ui:Page.Resources>
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter"/>
<conv:ValueConverterGroup x:Key="InvertBooleanToVisibilityConverter">
<conv:BooleanInverterConverter/>
<BooleanToVisibilityConverter/>
</conv:ValueConverterGroup>
<Style TargetType="TextBlock">
<Setter Property="FontSize" Value="14"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<Style TargetType="ui:NumberBox">
<Setter Property="Width" Value="250"/>
<Setter Property="HorizontalAlignment" Value="Left"/>
<Setter Property="ValidationMode" Value="InvalidInputOverwritten"/>
<Setter Property="SpinButtonPlacementMode" Value="Inline"/>
</Style>
</ui:Page.Resources>
<ScrollViewer d:DataContext="{d:DesignInstance Type=config:ConfigV1}">
<ui:SimpleStackPanel Orientation="Vertical" Spacing="5" Margin="20">
<TextBlock Text="高级设置" Style="{StaticResource TitleTextBlockStyle}" Margin="0,10"/>
<GroupBox Header="Cookie">
<StackPanel>
<TextBlock Text="请求API时使用此 Cookie"/>
<TextBox Text="{Binding Cookie,Delay=500}" Width="250" HorizontalAlignment="Left"/>
</StackPanel>
</GroupBox>
<GroupBox Header="Timing">
<ui:SimpleStackPanel Spacing="10">
<ui:NumberBox Minimum="1000" Header="录制重试间隔" Description="单位: 毫秒" SmallChange="100" Text="{Binding TimingStreamRetry,Delay=500}">
<ui:NumberBox.ToolTip>
<TextBlock>录制断开后等待多长时间再尝试开始录制</TextBlock>
</ui:NumberBox.ToolTip>
</ui:NumberBox>
<ui:NumberBox Minimum="1000" Header="录制连接超时" Description="单位: 毫秒" SmallChange="100" Text="{Binding TimingStreamConnect,Delay=500}">
<ui:NumberBox.ToolTip>
<TextBlock>
发出连接直播服务器的请求后等待多长时间<LineBreak/>
防止直播服务器长时间不返回数据导致卡住
</TextBlock>
</ui:NumberBox.ToolTip>
</ui:NumberBox>
<ui:NumberBox Minimum="1000" Header="弹幕重连间隔" Description="单位: 毫秒" SmallChange="100" Text="{Binding TimingDanmakuRetry,Delay=500}">
<ui:NumberBox.ToolTip>
<TextBlock>
弹幕服务器被断开后等待多长时间再尝试连接<LineBreak/>
监控开播的主要途径是通过弹幕服务器发送的信息
</TextBlock>
</ui:NumberBox.ToolTip>
</ui:NumberBox>
<ui:NumberBox Minimum="1000" Header="接收数据超时" Description="单位: 毫秒" SmallChange="100" Text="{Binding TimingWatchdogTimeout,Delay=500}">
<ui:NumberBox.ToolTip>
<TextBlock>
在一定时间没有收到直播服务器发送的数据后断开重连<LineBreak/>
用于防止因为玄学网络问题导致卡住
</TextBlock>
</ui:NumberBox.ToolTip>
</ui:NumberBox>
<ui:NumberBox Minimum="60" Header="开播检查间隔" Description="单位: 秒" SmallChange="10" Text="{Binding TimingCheckInterval,Delay=500}">
<ui:NumberBox.ToolTip>
<TextBlock>
此项影响的时间间隔是定时请求HTTP接口的间隔
主要目的是防止没有从弹幕服务器收到开播消息,
所以此项不需要设置太短。<LineBreak/>
时间间隔设置太短会被B站服务器屏蔽导致无法录制。
</TextBlock>
</ui:NumberBox.ToolTip>
</ui:NumberBox>
</ui:SimpleStackPanel>
</GroupBox>
</ui:SimpleStackPanel>
</ScrollViewer>
</ui:Page>

View File

@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
namespace BililiveRecorder.WPF.Pages
{
/// <summary>
/// Interaction logic for AdvancedSettingsPage.xaml
/// </summary>
public partial class AdvancedSettingsPage
{
public AdvancedSettingsPage()
{
InitializeComponent();
}
}
}

View File

@ -0,0 +1,81 @@
<ui:Page
x:Class="BililiveRecorder.WPF.Pages.LogPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ui="http://schemas.modernwpf.com/2019"
xmlns:wpf="clr-namespace:BililiveRecorder.WPF"
xmlns:local="clr-namespace:BililiveRecorder.WPF.Pages"
xmlns:models="clr-namespace:BililiveRecorder.WPF.Models"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800"
d:DataContext="{d:DesignInstance Type=models:LogModel}"
DataContext="{Binding RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=local:RootPage},Path=DataContext.Logs,Mode=OneWay}"
Title="LogPage">
<Grid Margin="5">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition/>
</Grid.RowDefinitions>
<ui:ThemeShadowChrome Margin="5">
<Border Background="White" CornerRadius="5">
<StackPanel Margin="10" Orientation="Vertical">
<StackPanel.Resources>
<Style TargetType="TextBlock">
<Setter Property="FontSize" Value="15"/>
<Setter Property="TextAlignment" Value="Center"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<Style TargetType="ui:HyperlinkButton">
<Setter Property="Padding" Value="0"/>
</Style>
<Style TargetType="StackPanel">
<Setter Property="HorizontalAlignment" Value="Center"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Orientation" Value="Horizontal"/>
</Style>
</StackPanel.Resources>
<StackPanel>
<TextBlock Text="B站录播姬 "/>
<TextBlock x:Name="VersionTextBlock"/>
</StackPanel>
<StackPanel>
<TextBlock Text="官网: "/>
<ui:HyperlinkButton Content="https://rec.danmuji.org" NavigateUri="https://rec.danmuji.org"/>
</StackPanel>
<StackPanel>
<TextBlock Text="联系方式/问题反馈: "/>
<ui:HyperlinkButton Content="rec@danmuji.org" NavigateUri="mailto:rec@danmuji.org"/>
</StackPanel>
<TextBlock Text="QQ群: 689636812"/>
</StackPanel>
</Border>
</ui:ThemeShadowChrome>
<ui:ThemeShadowChrome Grid.Row="1" IsShadowEnabled="True" Depth="10" Margin="5">
<Border Background="White" CornerRadius="5">
<ItemsControl x:Name="Log" ItemsSource="{Binding Mode=OneWay}" Margin="5" ToolTip="右键点击可以复制单行日志">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border BorderThickness="0,0,0,1" BorderBrush="#FFCCCCCC">
<TextBlock Text="{Binding Mode=OneWay}" TextWrapping="Wrap" MouseRightButtonUp="TextBlock_MouseRightButtonUp"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
<ItemsControl.Template>
<ControlTemplate>
<ScrollViewer Loaded="ScrollViewer_Loaded" CanContentScroll="True">
<ItemsPresenter/>
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel IsItemsHost="True"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</Border>
</ui:ThemeShadowChrome>
</Grid>
</ui:Page>

View File

@ -0,0 +1,38 @@
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
namespace BililiveRecorder.WPF.Pages
{
/// <summary>
/// Interaction logic for LogPage.xaml
/// </summary>
public partial class LogPage
{
public LogPage()
{
InitializeComponent();
VersionTextBlock.Text = BuildInfo.Version + " " + BuildInfo.HeadShaShort;
}
private void TextBlock_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
{
try
{
if (sender is TextBlock textBlock)
{
Clipboard.SetText(textBlock.Text);
}
}
catch (Exception)
{
}
}
private void ScrollViewer_Loaded(object sender, RoutedEventArgs e)
{
(sender as ScrollViewer)?.ScrollToEnd();
}
}
}

View File

@ -0,0 +1,54 @@
<ui:Page
x:Class="BililiveRecorder.WPF.Pages.RoomListPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ui="http://schemas.modernwpf.com/2019"
xmlns:local="clr-namespace:BililiveRecorder.WPF.Pages"
xmlns:mock="clr-namespace:BililiveRecorder.WPF.MockData"
xmlns:controls="clr-namespace:BililiveRecorder.WPF.Controls"
xmlns:converters="clr-namespace:BililiveRecorder.WPF.Converters"
mc:Ignorable="d"
d:DesignHeight="1000" d:DesignWidth="960"
d:DataContext="{d:DesignInstance mock:MockRecorder,IsDesignTimeCreatable=True}"
DataContext="{Binding RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=local:RootPage},Path=DataContext.Recorder,Mode=OneWay}"
Title="房间列表">
<ui:Page.Resources>
<DataTemplate x:Key="NormalRoomCardTemplate">
<ui:ThemeShadowChrome IsShadowEnabled="True" Depth="10">
<Border Background="White" CornerRadius="5">
<controls:RoomCard DeleteRequested="RoomCard_DeleteRequested" />
</Border>
</ui:ThemeShadowChrome>
</DataTemplate>
<DataTemplate x:Key="AddRoomCardTemplate">
<ui:ThemeShadowChrome IsShadowEnabled="True" Depth="10">
<Border Background="White" CornerRadius="5">
<controls:AddRoomCard AddRoomRequested="AddRoomCard_AddRoomRequested"/>
</Border>
</ui:ThemeShadowChrome>
</DataTemplate>
<converters:NullValueTemplateSelector
x:Key="SelectorTemplate"
Normal="{StaticResource NormalRoomCardTemplate}"
Null="{StaticResource AddRoomCardTemplate}"/>
<ui:UniformGridLayout
x:Key="UniformGridLayout"
MinItemWidth="230"
MinItemHeight="120"
MinRowSpacing="7"
MinColumnSpacing="5" />
<converters:RoomListInterceptConverter x:Key="RoomListInterceptConverter"/>
</ui:Page.Resources>
<ui:ItemsRepeaterScrollHost>
<ScrollViewer>
<ui:ItemsRepeater
HorizontalAlignment="Stretch" Margin="8"
Layout="{StaticResource UniformGridLayout}"
ItemsSource="{Binding Converter={StaticResource RoomListInterceptConverter},Mode=OneWay}"
ItemTemplate="{StaticResource SelectorTemplate}" />
</ScrollViewer>
</ui:ItemsRepeaterScrollHost>
</ui:Page>

View File

@ -0,0 +1,80 @@
using System;
using System.Text.RegularExpressions;
using System.Linq;
using BililiveRecorder.Core;
using BililiveRecorder.WPF.Controls;
namespace BililiveRecorder.WPF.Pages
{
/// <summary>
/// Interaction logic for RoomList.xaml
/// </summary>
public partial class RoomListPage
{
private static readonly Regex RoomIdRegex
= new Regex(@"^(?:https?:\/\/)?live\.bilibili\.com\/(?:blanc\/|h5\/)?(\d*)(?:\?.*)?$",
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
public RoomListPage()
{
InitializeComponent();
}
private async void RoomCard_DeleteRequested(object sender, EventArgs e)
{
if (DataContext is IRecorder rec && sender is IRecordedRoom room)
{
var dialog = new DeleteRoomConfirmDialog
{
DataContext = room
};
var result = await dialog.ShowAsync();
if (result == ModernWpf.Controls.ContentDialogResult.Primary)
{
rec.RemoveRoom(room);
}
}
}
private async void AddRoomCard_AddRoomRequested(object sender, string e)
{
var input = e.Trim();
if (string.IsNullOrWhiteSpace(input) || !(DataContext is IRecorder rec)) return;
if (!int.TryParse(input, out var roomid))
{
var m = RoomIdRegex.Match(input);
if (m.Success && m.Groups.Count > 1 && int.TryParse(m.Groups[1].Value, out var result2))
{
roomid = result2;
}
else
{
await new AddRoomFailedDialog { DataContext = "请输入B站直播房间号或直播间链接" }.ShowAsync();
return;
}
}
if (roomid < 0)
{
await new AddRoomFailedDialog { DataContext = "房间号不能是负数" }.ShowAsync();
return;
}
else if (roomid == 0)
{
await new AddRoomFailedDialog { DataContext = "房间号不能是 0" }.ShowAsync();
return;
}
if (rec.Any(x => x.RoomId == roomid || x.ShortRoomId == roomid))
{
await new AddRoomFailedDialog { DataContext = "这个直播间已经被添加过了" }.ShowAsync();
return;
}
rec.AddRoom(roomid);
}
}
}

View File

@ -0,0 +1,76 @@
<UserControl
x:Class="BililiveRecorder.WPF.Pages.RootPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ui="http://schemas.modernwpf.com/2019"
xmlns:local="clr-namespace:BililiveRecorder.WPF.Pages"
xmlns:mock="clr-namespace:BililiveRecorder.WPF.MockData"
xmlns:models="clr-namespace:BililiveRecorder.WPF.Models"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="600"
d:DataContext="{d:DesignInstance Type=models:RootModel,IsDesignTimeCreatable=True}"
Background="{DynamicResource SystemControlPageBackgroundAltHighBrush}">
<UserControl.Resources>
<Style x:Key="CascadeDataContextFrame" TargetType="{x:Type ui:Frame}">
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="{x:Type ui:Frame}">
<Border
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
Background="{TemplateBinding Background}"
ClipToBounds="True"
Padding="{TemplateBinding Padding}">
<Grid>
<ContentPresenter
x:Name="FirstContentPresenter"
Content="{x:Null}"
DataContext="{TemplateBinding DataContext}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/>
<ContentPresenter
x:Name="SecondContentPresenter"
Content="{x:Null}"
DataContext="{TemplateBinding DataContext}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"/>
</Grid>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>
<ui:NavigationView IsBackEnabled="False" IsBackButtonVisible="Collapsed"
IsPaneOpen="False" OpenPaneLength="150"
IsSettingsVisible="False"
SelectionChanged="NavigationView_SelectionChanged"
>
<ui:NavigationView.MenuItems>
<ui:NavigationViewItem Content="房间列表" Tag="RoomListPage" x:Name="RoomListPageNavigationViewItem">
<ui:NavigationViewItem.Icon>
<ui:PathIcon Data="M12 5.69L17 10.19V18H15V12H9V18H7V10.19L12 5.69M12 3L2 12H5V20H11V14H13V20H19V12H22L12 3Z"/>
</ui:NavigationViewItem.Icon>
</ui:NavigationViewItem>
</ui:NavigationView.MenuItems>
<ui:NavigationView.FooterMenuItems>
<ui:NavigationViewItem Content="高级设置" Tag="AdvancedSettingsPage" x:Name="AdvancedSettingsPageItem">
<ui:NavigationViewItem.Icon>
<ui:PathIcon Data="M15.9,18.45C17.25,18.45 18.35,17.35 18.35,16C18.35,14.65 17.25,13.55 15.9,13.55C14.54,13.55 13.45,14.65 13.45,16C13.45,17.35 14.54,18.45 15.9,18.45M21.1,16.68L22.58,17.84C22.71,17.95 22.75,18.13 22.66,18.29L21.26,20.71C21.17,20.86 21,20.92 20.83,20.86L19.09,20.16C18.73,20.44 18.33,20.67 17.91,20.85L17.64,22.7C17.62,22.87 17.47,23 17.3,23H14.5C14.32,23 14.18,22.87 14.15,22.7L13.89,20.85C13.46,20.67 13.07,20.44 12.71,20.16L10.96,20.86C10.81,20.92 10.62,20.86 10.54,20.71L9.14,18.29C9.05,18.13 9.09,17.95 9.22,17.84L10.7,16.68L10.65,16L10.7,15.31L9.22,14.16C9.09,14.05 9.05,13.86 9.14,13.71L10.54,11.29C10.62,11.13 10.81,11.07 10.96,11.13L12.71,11.84C13.07,11.56 13.46,11.32 13.89,11.15L14.15,9.29C14.18,9.13 14.32,9 14.5,9H17.3C17.47,9 17.62,9.13 17.64,9.29L17.91,11.15C18.33,11.32 18.73,11.56 19.09,11.84L20.83,11.13C21,11.07 21.17,11.13 21.26,11.29L22.66,13.71C22.75,13.86 22.71,14.05 22.58,14.16L21.1,15.31L21.15,16L21.1,16.68M6.69,8.07C7.56,8.07 8.26,7.37 8.26,6.5C8.26,5.63 7.56,4.92 6.69,4.92A1.58,1.58 0 0,0 5.11,6.5C5.11,7.37 5.82,8.07 6.69,8.07M10.03,6.94L11,7.68C11.07,7.75 11.09,7.87 11.03,7.97L10.13,9.53C10.08,9.63 9.96,9.67 9.86,9.63L8.74,9.18L8,9.62L7.81,10.81C7.79,10.92 7.7,11 7.59,11H5.79C5.67,11 5.58,10.92 5.56,10.81L5.4,9.62L4.64,9.18L3.5,9.63C3.41,9.67 3.3,9.63 3.24,9.53L2.34,7.97C2.28,7.87 2.31,7.75 2.39,7.68L3.34,6.94L3.31,6.5L3.34,6.06L2.39,5.32C2.31,5.25 2.28,5.13 2.34,5.03L3.24,3.47C3.3,3.37 3.41,3.33 3.5,3.37L4.63,3.82L5.4,3.38L5.56,2.19C5.58,2.08 5.67,2 5.79,2H7.59C7.7,2 7.79,2.08 7.81,2.19L8,3.38L8.74,3.82L9.86,3.37C9.96,3.33 10.08,3.37 10.13,3.47L11.03,5.03C11.09,5.13 11.07,5.25 11,5.32L10.03,6.06L10.06,6.5L10.03,6.94Z"/>
</ui:NavigationViewItem.Icon>
</ui:NavigationViewItem>
<ui:NavigationViewItem Content="日志" Tag="LogPage">
<ui:NavigationViewItem.Icon>
<ui:PathIcon Data="M9 3C5.69 3 3.14 5.69 3 9V21H12V13.46C13.1 14.45 14.5 15 16 15C19.31 15 22 12.31 22 9C22 5.69 19.31 3 16 3H9M9 5H11.54C10.55 6.1 10 7.5 10 9V12H9V13H10V19H5V13H6V12H5V9C5 6.79 6.79 5 9 5M16 5C18.21 5 20 6.79 20 9C20 11.21 18.21 13 16 13C13.79 13 12 11.21 12 9C12 6.79 13.79 5 16 5M16 7.25C15.03 7.25 14.25 8.03 14.25 9C14.25 9.97 15.03 10.75 16 10.75C16.97 10.75 17.75 9.97 17.75 9C17.75 8.03 16.97 7.25 16 7.25M7 12V13H8V12H7Z"/>
</ui:NavigationViewItem.Icon>
</ui:NavigationViewItem>
<ui:NavigationViewItem Content="设置" Tag="SettingsPage" MouseRightButtonUp="NavigationViewItem_MouseRightButtonUp">
<ui:NavigationViewItem.Icon>
<ui:PathIcon Data="M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M12,10A2,2 0 0,0 10,12A2,2 0 0,0 12,14A2,2 0 0,0 14,12A2,2 0 0,0 12,10M10,22C9.75,22 9.54,21.82 9.5,21.58L9.13,18.93C8.5,18.68 7.96,18.34 7.44,17.94L4.95,18.95C4.73,19.03 4.46,18.95 4.34,18.73L2.34,15.27C2.21,15.05 2.27,14.78 2.46,14.63L4.57,12.97L4.5,12L4.57,11L2.46,9.37C2.27,9.22 2.21,8.95 2.34,8.73L4.34,5.27C4.46,5.05 4.73,4.96 4.95,5.05L7.44,6.05C7.96,5.66 8.5,5.32 9.13,5.07L9.5,2.42C9.54,2.18 9.75,2 10,2H14C14.25,2 14.46,2.18 14.5,2.42L14.87,5.07C15.5,5.32 16.04,5.66 16.56,6.05L19.05,5.05C19.27,4.96 19.54,5.05 19.66,5.27L21.66,8.73C21.79,8.95 21.73,9.22 21.54,9.37L19.43,11L19.5,12L19.43,13L21.54,14.63C21.73,14.78 21.79,15.05 21.66,15.27L19.66,18.73C19.54,18.95 19.27,19.04 19.05,18.95L16.56,17.95C16.04,18.34 15.5,18.68 14.87,18.93L14.5,21.58C14.46,21.82 14.25,22 14,22H10M11.25,4L10.88,6.61C9.68,6.86 8.62,7.5 7.85,8.39L5.44,7.35L4.69,8.65L6.8,10.2C6.4,11.37 6.4,12.64 6.8,13.8L4.68,15.36L5.43,16.66L7.86,15.62C8.63,16.5 9.68,17.14 10.87,17.38L11.24,20H12.76L13.13,17.39C14.32,17.14 15.37,16.5 16.14,15.62L18.57,16.66L19.32,15.36L17.2,13.81C17.6,12.64 17.6,11.37 17.2,10.2L19.31,8.65L18.56,7.35L16.15,8.39C15.38,7.5 14.32,6.86 13.12,6.62L12.75,4H11.25Z"/>
</ui:NavigationViewItem.Icon>
</ui:NavigationViewItem>
</ui:NavigationView.FooterMenuItems>
<ui:Frame x:Name="MainFrame" Style="{DynamicResource CascadeDataContextFrame}" />
</ui:NavigationView>
</UserControl>

View File

@ -0,0 +1,188 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using Autofac;
using BililiveRecorder.Core;
using BililiveRecorder.FlvProcessor;
using BililiveRecorder.WPF.Controls;
using BililiveRecorder.WPF.Models;
using CommandLine;
using ModernWpf.Controls;
using Path = System.IO.Path;
namespace BililiveRecorder.WPF.Pages
{
/// <summary>
/// Interaction logic for RootPage.xaml
/// </summary>
public partial class RootPage : UserControl
{
private readonly Dictionary<string, Type> PageMap = new Dictionary<string, Type>();
private readonly string lastdir_path = Path.Combine(Path.GetDirectoryName(typeof(RootPage).Assembly.Location), "lastdir.txt");
private IContainer Container { get; set; }
private ILifetimeScope RootScope { get; set; }
private int SettingsClickCount = 0;
internal RootModel Model { get; private set; }
public event EventHandler CloseWindowRequested;
public RootPage()
{
void AddType(Type t) => PageMap.Add(t.Name, t);
AddType(typeof(RoomListPage));
AddType(typeof(LogPage));
AddType(typeof(SettingsPage));
AddType(typeof(AdvancedSettingsPage));
Model = new RootModel();
DataContext = Model;
var builder = new ContainerBuilder();
builder.RegisterModule<FlvProcessorModule>();
builder.RegisterModule<CoreModule>();
Container = builder.Build();
RootScope = Container.BeginLifetimeScope("recorder_root");
InitializeComponent();
AdvancedSettingsPageItem.Visibility = Visibility.Hidden;
Loaded += RootPage_Loaded;
}
public void Shutdown()
{
Model.Dispose();
}
private async void RootPage_Loaded(object sender, RoutedEventArgs e)
{
bool first_time = true;
var recorder = RootScope.Resolve<IRecorder>();
var error = string.Empty;
string path;
while (true)
{
CommandLineOption commandLineOption = null;
if (first_time)
{
first_time = false;
Parser.Default
.ParseArguments<CommandLineOption>(Environment.GetCommandLineArgs())
.WithParsed(x => commandLineOption = x);
if (!string.IsNullOrWhiteSpace(commandLineOption.WorkDirectory))
{
path = Path.GetFullPath(commandLineOption.WorkDirectory);
goto check_path;
}
}
string lastdir = string.Empty;
try
{
if (File.Exists(lastdir_path))
{
lastdir = File.ReadAllText(lastdir_path).Replace("\r", "").Replace("\n", "").Trim();
}
}
catch (Exception) { }
var w = new WorkDirectorySelectorDialog
{
Error = error,
Path = lastdir
};
var result = await w.ShowAsync();
if (result != ContentDialogResult.Primary)
{
CloseWindowRequested?.Invoke(this, EventArgs.Empty);
return;
}
path = Path.GetFullPath(w.Path);
check_path:
var config = Path.Combine(path, "config.json");
if (!Directory.Exists(path))
{
error = "目录不存在";
continue;
}
else if (!Directory.EnumerateFiles(path).Any())
{
// 可用的空文件夹
}
else if (!File.Exists(config))
{
error = "目录已有其他文件";
continue;
}
try
{
if (string.IsNullOrWhiteSpace(commandLineOption?.WorkDirectory))
{
File.WriteAllText(lastdir_path, path);
}
}
catch (Exception) { }
// 检查已经在同目录运行的其他进程
if (SingleInstance.CheckMutex(path))
{
if (recorder.Initialize(path))
{
Model.Recorder = recorder;
RoomListPageNavigationViewItem.IsSelected = true;
break;
}
else
{
error = "配置文件加载失败";
continue;
}
}
else
{
CloseWindowRequested?.Invoke(this, EventArgs.Empty);
return;
}
}
}
private void NavigationView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args)
{
SettingsClickCount = 0;
if (args.IsSettingsSelected)
{
MainFrame.Navigate(typeof(SettingsPage));
}
else
{
var selectedItem = (NavigationViewItem)args.SelectedItem;
var selectedItemTag = (string)selectedItem.Tag;
if (PageMap.ContainsKey(selectedItemTag))
{
var pageType = PageMap[selectedItemTag];
MainFrame.Navigate(pageType);
}
}
}
private void NavigationViewItem_MouseRightButtonUp(object sender, MouseButtonEventArgs e)
{
if (++SettingsClickCount > 3)
{
SettingsClickCount = 0;
AdvancedSettingsPageItem.Visibility = AdvancedSettingsPageItem.Visibility != Visibility.Visible ? Visibility.Visible : Visibility.Hidden;
}
}
}
}

View File

@ -0,0 +1,125 @@
<ui:Page
x:Class="BililiveRecorder.WPF.Pages.SettingsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:ui="http://schemas.modernwpf.com/2019"
xmlns:local="clr-namespace:BililiveRecorder.WPF.Pages"
xmlns:conv="clr-namespace:BililiveRecorder.WPF.Converters"
xmlns:config="clr-namespace:BililiveRecorder.Core.Config;assembly=BililiveRecorder.Core"
xmlns:flv="clr-namespace:BililiveRecorder.FlvProcessor;assembly=BililiveRecorder.FlvProcessor"
mc:Ignorable="d"
d:DesignHeight="1500" d:DesignWidth="500"
DataContext="{Binding RelativeSource={RelativeSource Mode=FindAncestor,AncestorType=local:RootPage},Path=DataContext.Recorder.Config}"
Title="SettingsPage">
<ui:Page.Resources>
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter"/>
<conv:EnumToBooleanConverter x:Key="EnumToBooleanConverter"/>
<conv:ValueConverterGroup x:Key="InvertBooleanToVisibilityConverter">
<conv:BooleanInverterConverter/>
<BooleanToVisibilityConverter/>
</conv:ValueConverterGroup>
<Style TargetType="TextBlock">
<Setter Property="FontSize" Value="14"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
</ui:Page.Resources>
<ScrollViewer d:DataContext="{d:DesignInstance Type=config:ConfigV1}">
<ui:SimpleStackPanel Orientation="Vertical" Spacing="5" Margin="20">
<TextBlock Text="设置" Style="{StaticResource TitleTextBlockStyle}" Margin="0,10"/>
<GroupBox Header="弹幕录制">
<StackPanel>
<ui:ToggleSwitch IsOn="{Binding RecordDanmaku}" Name="RecordDanmakuCheckbox" OnContent="录播时保存弹幕" OffContent="录播时不保存弹幕"/>
<StackPanel Margin="0,7,0,0" Visibility="{Binding ElementName=RecordDanmakuCheckbox,Path=IsOn,Converter={StaticResource BooleanToVisibilityConverter}}">
<ui:ToggleSwitch IsEnabled="{Binding RecordDanmaku}" IsOn="{Binding RecordDanmakuRaw}"
OnContent="同时保存 弹幕原始数据" OffContent="不保存 弹幕原始数据"/>
<ui:ToggleSwitch IsEnabled="{Binding RecordDanmaku}" IsOn="{Binding RecordDanmakuSuperChat}"
OnContent="同时保存 SuperChat" OffContent="不保存 SuperChat"/>
<ui:ToggleSwitch IsEnabled="{Binding RecordDanmaku}" IsOn="{Binding RecordDanmakuGift}"
OnContent="同时保存 送礼信息" OffContent="不保存 送礼信息"/>
<ui:ToggleSwitch IsEnabled="{Binding RecordDanmaku}" IsOn="{Binding RecordDanmakuGuard}"
OnContent="同时保存 舰长购买" OffContent="不保存 舰长购买"/>
</StackPanel>
</StackPanel>
</GroupBox>
<GroupBox Header="自动分段">
<StackPanel>
<RadioButton GroupName="自动分段" Name="CutDisabledRadioButton" Content="不自动分段"
IsChecked="{Binding Path=CuttingMode, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:AutoCuttingMode.Disabled}}" />
<RadioButton GroupName="自动分段" Name="CutBySizeRadioButton" Content="根据文件大小自动分段"
IsChecked="{Binding Path=CuttingMode, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:AutoCuttingMode.BySize}}" />
<RadioButton GroupName="自动分段" Name="CutByTimeRadioButton" Content="根据视频时间自动分段"
IsChecked="{Binding Path=CuttingMode, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:AutoCuttingMode.ByTime}}" />
<StackPanel Orientation="Horizontal" Margin="0,5,0,0"
Visibility="{Binding ElementName=CutDisabledRadioButton,Path=IsChecked,Converter={StaticResource InvertBooleanToVisibilityConverter}}">
<TextBlock Text="每"/>
<TextBox Margin="5,0" Width="100" Text="{Binding CuttingNumber,Delay=500}" ui:TextBoxHelper.IsDeleteButtonVisible="False"/>
<TextBlock Text="MiB 保存为一个文件"
Visibility="{Binding ElementName=CutBySizeRadioButton,Path=IsChecked,Converter={StaticResource BooleanToVisibilityConverter}}"/>
<TextBlock Text="分 保存为一个文件"
Visibility="{Binding ElementName=CutByTimeRadioButton,Path=IsChecked,Converter={StaticResource BooleanToVisibilityConverter}}"/>
</StackPanel>
</StackPanel>
</GroupBox>
<GroupBox Header="录制功能">
<StackPanel>
<RadioButton GroupName="录制功能" Name="EnabledFeatureRecordOnlyRadioButton" Content="只启用录制功能" ToolTip="(默认)占内存更少,但不能使用即时剪辑"
IsChecked="{Binding Path=EnabledFeature, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:EnabledFeature.RecordOnly}}" />
<RadioButton GroupName="录制功能" Content="只启用即时剪辑功能" ToolTip="不保存所有直播数据"
IsChecked="{Binding Path=EnabledFeature, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:EnabledFeature.ClipOnly}}" />
<RadioButton GroupName="录制功能" Content="同时启用两个功能"
IsChecked="{Binding Path=EnabledFeature, Converter={StaticResource EnumToBooleanConverter},
ConverterParameter={x:Static flv:EnabledFeature.Both}}" />
<StackPanel Margin="0,5,0,0" Visibility="{Binding ElementName=EnabledFeatureRecordOnlyRadioButton,Path=IsChecked,Converter={StaticResource InvertBooleanToVisibilityConverter}}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="剪辑过去时长"/>
<TextBox Margin="5,0" Width="80" Text="{Binding ClipLengthPast,Delay=500}" ui:TextBoxHelper.IsDeleteButtonVisible="False"/>
<TextBlock Text="秒"/>
</StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Text="剪辑将来时长"/>
<TextBox Margin="5,0" Width="80" Text="{Binding ClipLengthFuture,Delay=500}" ui:TextBoxHelper.IsDeleteButtonVisible="False"/>
<TextBlock Text="秒"/>
</StackPanel>
</StackPanel>
</StackPanel>
</GroupBox>
<GroupBox Header="文件名">
<StackPanel MaxWidth="400" HorizontalAlignment="Left">
<StackPanel Orientation="Horizontal" Margin="0,0,0,10">
<StackPanel.ToolTip>
<TextBlock FontSize="13">
文件名变量说明<LineBreak/>
<LineBreak/>
日期: {date} <LineBreak/>
时间: {time} <LineBreak/>
房间号: {roomid} <LineBreak/>
标题: {title} <LineBreak/>
主播名: {name} <LineBreak/>
随机数字: {random} <LineBreak/>
<LineBreak/>
所有 { } 大括号均为英文半角括号 <LineBreak/>
只支持 flv 格式
</TextBlock>
</StackPanel.ToolTip>
<TextBlock Text="说明"/>
<ui:PathIcon Margin="2,0" VerticalAlignment="Center" Height="15" Data="M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z"/>
</StackPanel>
<TextBlock Text="录制文件名格式"/>
<TextBox Text="{Binding RecordFilenameFormat,Delay=500}" ui:TextBoxHelper.IsDeleteButtonVisible="False"/>
<TextBlock Text="剪辑文件名格式" Margin="0,5,0,0" Visibility="{Binding ElementName=EnabledFeatureRecordOnlyRadioButton,Path=IsChecked,Converter={StaticResource InvertBooleanToVisibilityConverter}}"/>
<TextBox Text="{Binding ClipFilenameFormat,Delay=500}" ui:TextBoxHelper.IsDeleteButtonVisible="False"
Visibility="{Binding ElementName=EnabledFeatureRecordOnlyRadioButton,Path=IsChecked,Converter={StaticResource InvertBooleanToVisibilityConverter}}"/>
</StackPanel>
</GroupBox>
</ui:SimpleStackPanel>
</ScrollViewer>
</ui:Page>

View File

@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;
namespace BililiveRecorder.WPF.Pages
{
/// <summary>
/// Interaction logic for SettingsPage.xaml
/// </summary>
public partial class SettingsPage
{
public SettingsPage()
{
InitializeComponent();
}
}
}

View File

@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.Runtime.Remoting;
using System.Runtime.Remoting.Channels;
using System.Runtime.Remoting.Channels.Ipc;
using System.Runtime.Serialization.Formatters;
using System.Text;
using System.Threading;
using System.Windows;
using System.Windows.Threading;
namespace BililiveRecorder.WPF
{
public static class SingleInstance
{
private static Mutex singleInstanceMutex;
private static IpcServerChannel channel;
public static event EventHandler NotificationReceived;
public static bool CheckMutex(string path)
{
const string RemoteServiceName = "SingleInstanceApplicationService";
var b64path = Convert.ToBase64String(Encoding.UTF8.GetBytes(path)).Replace('+', '_').Replace('/', '-');
var identifier = "BililiveRecorder:SingeInstance:" + b64path;
singleInstanceMutex = new Mutex(true, identifier, out var createdNew);
if (createdNew)
{
channel = new IpcServerChannel(new Dictionary<string, string>
{
["name"] = identifier,
["portName"] = identifier,
["exclusiveAddressUse"] = "false"
}, new BinaryServerFormatterSinkProvider
{
TypeFilterLevel = TypeFilterLevel.Full
});
ChannelServices.RegisterChannel(channel, true);
RemotingServices.Marshal(new IPCRemoteService(), RemoteServiceName);
}
else
{
ChannelServices.RegisterChannel(new IpcClientChannel(), true);
var remote = (IPCRemoteService)RemotingServices.Connect(typeof(IPCRemoteService), $"ipc://{identifier}/{RemoteServiceName}");
remote?.Notify();
}
return createdNew;
}
public static void Cleanup()
{
singleInstanceMutex?.Close();
singleInstanceMutex = null;
if (channel != null)
{
ChannelServices.UnregisterChannel(channel);
channel = null;
}
}
private static void ActivateFirstInstanceCallback() => NotificationReceived?.Invoke(null, EventArgs.Empty);
private class IPCRemoteService : MarshalByRefObject
{
public void Notify() => Application.Current?.Dispatcher.BeginInvoke(DispatcherPriority.Normal, (Action)ActivateFirstInstanceCallback);
public override object InitializeLifetimeService() => null;
}
}
}

View File

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Autofac" version="4.8.1" targetFramework="net462" />
<package id="CommandLineParser" version="2.4.3" targetFramework="net462" />
<package id="DeltaCompressionDotNet" version="1.1.0" targetFramework="net462" />
<package id="Hardcodet.NotifyIcon.Wpf" version="1.0.8" targetFramework="net462" />
<package id="Mono.Cecil" version="0.9.6.1" targetFramework="net462" />
<package id="Newtonsoft.Json" version="11.0.2" targetFramework="net462" />
<package id="NLog" version="4.5.10" targetFramework="net462" />
<package id="NLog.Config" version="4.5.10" targetFramework="net462" />
<package id="NLog.Schema" version="4.5.10" targetFramework="net462" />
<package id="NuGet.CommandLine" version="4.7.1" targetFramework="net462" developmentDependency="true" />
<package id="SharpCompress" version="0.17.1" targetFramework="net462" />
<package id="Splat" version="1.6.2" targetFramework="net462" />
<package id="squirrel.windows" version="1.9.0" targetFramework="net462" />
<package id="WindowsAPICodePack-Core" version="1.1.2" targetFramework="net462" />
<package id="WindowsAPICodePack-Shell" version="1.1.1" targetFramework="net462" />
</packages>

View File

@ -14,6 +14,11 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BililiveRecorder.FlvProcess
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BililiveRecorder.Cli", "BililiveRecorder.Cli\BililiveRecorder.Cli.csproj", "{1B626335-283F-4313-9045-B5B96FAAB2DF}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{FD13D9F6-94CD-4CBA-AEA8-EF71002EAC6B}"
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU