Skip to content

Commit be8edbd

Browse files
Audio alerts for agent missions
Automatic full data refresh Continued attempts to mitigate crash on login Method of disabling login alerts Attempt to detect some odd edge cases in agent data Icon for viewer
1 parent 5ff27dd commit be8edbd

11 files changed

Lines changed: 305 additions & 150 deletions

File tree

App/Clockwatcher.csproj

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,13 @@
5151
<WarningLevel>4</WarningLevel>
5252
<Prefer32Bit>false</Prefer32Bit>
5353
</PropertyGroup>
54+
<PropertyGroup>
55+
<ApplicationIcon>Resources\CWIcon.ico</ApplicationIcon>
56+
</PropertyGroup>
5457
<ItemGroup>
5558
<Reference Include="System" />
5659
<Reference Include="System.Data" />
60+
<Reference Include="System.Drawing" />
5761
<Reference Include="System.Xml" />
5862
<Reference Include="Microsoft.CSharp" />
5963
<Reference Include="System.Core" />
@@ -125,5 +129,13 @@
125129
<Install>false</Install>
126130
</BootstrapperPackage>
127131
</ItemGroup>
132+
<ItemGroup>
133+
<None Include="Resources\CWIcon.ico" />
134+
</ItemGroup>
135+
<ItemGroup>
136+
<Content Include="sfx\AgentAlert.wav">
137+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
138+
</Content>
139+
</ItemGroup>
128140
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
129141
</Project>

App/Dataset.cs

Lines changed: 140 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -15,41 +15,45 @@
1515
using System.Xml.Linq;
1616

1717
namespace Clockwatcher {
18-
internal class Dataset {
18+
internal sealed class Dataset {
1919
internal Dataset() {
20-
ClearReadyCommand = new CommandWrapper(o => ClearReady(), o => true);
21-
RefreshCommand = new CommandWrapper(o => Refresh(true), o => true);
22-
RefreshTimer.Elapsed += (sender, e) => Refresh(false);
23-
Refresh(true);
20+
TabPanels.Add(Settings);
21+
22+
RefreshTimer.Elapsed += (sender, e) => Refresh();
23+
24+
Refresh();
2425
}
2526

26-
private void Refresh(bool reload) {
27+
private void Refresh() {
2728
RefreshTimer.Enabled = false;
28-
if (reload) { // Load settings files
29+
try {
30+
// Load settings files
2931
foreach (var charFile in FindCharacterFiles()) {
3032
var charData = ExtractCharacterMissions(charFile);
3133
if (charData != null) {
3234
var existing = (from e in CharacterMissionLists
33-
where e.CharName == charData.CharName
35+
where e.TabName == charData.TabName
3436
select e).SingleOrDefault();
3537
if (existing != null) { // Merge with existing character record
3638
existing.Merge(charData.TimerList);
3739
} else { // Add new character
3840
CharacterMissionLists.Add(charData);
41+
TabPanels.Insert(TabPanels.Count - 1, charData);
3942
}
4043
}
4144
}
42-
}
43-
// Update time displays
44-
foreach (var m in from c in CharacterMissionLists
45-
from m in c.TimerList
46-
select m) { m.Refresh(reload); }
47-
RefreshTimer.Enabled = true;
48-
}
4945

50-
private void ClearReady() {
51-
foreach (var character in CharacterMissionLists) {
52-
character.ClearReady();
46+
// Update time displays and trigger alert states
47+
if ((from c in CharacterMissionLists
48+
from m in c.TimerList
49+
where m.Refresh(Settings.AlertFilter)
50+
select m).ToList().Any()) {
51+
RaiseAlert?.Invoke(this, EventArgs.Empty);
52+
}
53+
} finally {
54+
// Unhandled exceptions were causing the refresh timer to stop
55+
// A bit of an issue now that there's no way to restart it
56+
RefreshTimer.Enabled = true;
5357
}
5458
}
5559

@@ -62,23 +66,25 @@ from character in Directory.EnumerateDirectories(account, CharDirFilter)
6266
private CharacterTimers ExtractCharacterMissions(string settingsFilePath) {
6367
Debug.Assert(File.Exists(settingsFilePath));
6468
try {
65-
var trackerData = (from e in XElement.Load(settingsFilePath).Elements("Archive")
66-
where (string)e.Attribute("name") == CWArchiveName
67-
select e).SingleOrDefault();
68-
// Mod not in use on this character (no mod archive), no tab required
69-
// Characters who only lack mission entries, are listed to show they have no cooldowns active
70-
if (trackerData == null) { return null; }
71-
// Character name should be in saved data, otherwise use the Char# folder name which isn't so informative
72-
var charName = ((string)(from e in trackerData.Elements("String")
73-
where (string)e.Attribute("name") == "CharName"
74-
select e).SingleOrDefault()?.Attribute("value"))?.Trim('"')
75-
?? Directory.GetParent(settingsFilePath).Name;
76-
// Coerce the serialization difference between a single and multi element array into a single IEnumerable
77-
var missions = from e in EnumerateArchiveEntry(trackerData, "MissionCD")
78-
select new MissionTimer(((string)e.Attribute("value")).Trim('"'));
79-
var agents = from e in EnumerateArchiveEntry(trackerData, "AgentCD")
80-
select new AgentTimer(((string)e.Attribute("value")).Trim('"'));
81-
return new CharacterTimers(charName, Enumerable.Empty<TimerEntry>().Concat(missions).Concat(agents));
69+
using (Stream file = new FileStream(settingsFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) {
70+
var trackerData = (from e in XElement.Load(file).Elements("Archive")
71+
where e.Attribute("name").Value == CWArchiveName
72+
select e).SingleOrDefault();
73+
// Mod not in use on this character (no mod archive), no tab required
74+
// Characters who only lack mission entries, are listed to show they have no cooldowns active
75+
if (trackerData == null) { return null; }
76+
// Character name should be in saved data, otherwise use the Char# folder name which isn't so informative
77+
var charName = (from e in trackerData.Elements("String")
78+
where e.Attribute("name").Value == "CharName"
79+
select e).SingleOrDefault()?.Attribute("value").Value?.Trim('"')
80+
?? Directory.GetParent(settingsFilePath).Name;
81+
// Coerce the serialization difference between a single and multi element array into a single IEnumerable
82+
var missions = from e in EnumerateArchiveEntry(trackerData, "MissionCD")
83+
select new MissionTimer((e.Attribute("value").Value).Trim('"'));
84+
var agents = from e in EnumerateArchiveEntry(trackerData, "AgentCD")
85+
select new AgentTimer((e.Attribute("value").Value).Trim('"'));
86+
return new CharacterTimers(charName, Enumerable.Empty<TimerEntry>().Concat(missions).Concat(agents));
87+
}
8288
} catch (IOException) {
8389
// File in use or other issue... either way no data from this one, so skip it
8490
return null;
@@ -95,9 +101,13 @@ private IEnumerable<XElement> EnumerateArchiveEntry(XElement archive, string tag
95101
select e);
96102
}
97103

98-
public ICollection<CharacterTimers> CharacterMissionLists { get; } = new ObservableCollection<CharacterTimers>();
104+
public ConfigPanel Settings { get; } = new ConfigPanel();
105+
public IList<TabPanelData> TabPanels { get; } = new ObservableCollection<TabPanelData>();
99106
public ICommand RefreshCommand { get; }
100-
public ICommand ClearReadyCommand { get; }
107+
108+
public event EventHandler RaiseAlert;
109+
110+
private ICollection<CharacterTimers> CharacterMissionLists = new List<CharacterTimers>();
101111
private readonly Timer RefreshTimer = new Timer(5000);
102112

103113
private static readonly string PrefsPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Funcom", "SWL", "Prefs");
@@ -107,19 +117,77 @@ private IEnumerable<XElement> EnumerateArchiveEntry(XElement archive, string tag
107117
private const string CWArchiveName = "efdClockwatcherMissionList";
108118
}
109119

110-
// Nested data
120+
internal abstract class TabPanelData : INotifyPropertyChanged {
121+
protected internal TabPanelData(string name) {
122+
TabName = name;
123+
}
124+
125+
public string TabName { get; }
126+
127+
public event PropertyChangedEventHandler PropertyChanged;
128+
protected virtual void RaisePropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
129+
}
111130

112-
internal class CharacterTimers : INotifyPropertyChanged {
113-
internal CharacterTimers(string charName, IEnumerable<TimerEntry> timers) {
114-
CharName = charName;
131+
internal sealed class ConfigPanel : TabPanelData {
132+
internal ConfigPanel() : base("Settings") {
133+
LoadConfig();
134+
}
135+
136+
private void LoadConfig() {
137+
var settingsFile = Path.Combine(SettingsDir, SettingsFile);
138+
if (File.Exists(settingsFile)) {
139+
using (var stream = new FileStream(settingsFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) {
140+
var settings = XElement.Load(stream);
141+
EnableAudioAlerts = Boolean.Parse(settings.Element("AgentAudioAlert").Attribute("Enabled").Value);
142+
}
143+
}
144+
}
115145

116-
TimerList = new ObservableCollection<TimerEntry>(timers);
146+
private void SaveConfig() {
147+
var settings = new XElement("Settings",
148+
new XElement("AgentAudioAlert",
149+
new XAttribute("Enabled", EnableAudioAlerts)
150+
));
151+
Directory.CreateDirectory(SettingsDir);
152+
settings.Save(Path.Combine(SettingsDir, SettingsFile));
153+
}
154+
155+
protected override sealed void RaisePropertyChanged(string propertyName) {
156+
base.RaisePropertyChanged(propertyName);
157+
SaveConfig();
158+
}
159+
160+
public bool EnableAudioAlerts {
161+
get { return _EnableAudioAlerts; }
162+
set {
163+
if (value != _EnableAudioAlerts) {
164+
_EnableAudioAlerts = value;
165+
RaisePropertyChanged(nameof(EnableAudioAlerts));
166+
}
167+
}
168+
}
169+
public string AlertSoundFileName { get; } = "sfx\\AgentAlert.wav";
170+
171+
internal TimerClass AlertFilter { get; } = TimerClass.Agent;
172+
173+
private bool _EnableAudioAlerts = true;
174+
private string SettingsDir = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Funcom", "SWL", "Mods", "Clockwatcher");
175+
private string SettingsFile = "ViewerSettings.xml";
176+
}
177+
178+
// Nested data
179+
180+
internal sealed class CharacterTimers : TabPanelData {
181+
internal CharacterTimers(string charName, IEnumerable<TimerEntry> timers) :
182+
base(charName) {
117183

118184
var view = CollectionViewSource.GetDefaultView(TimerList);
119185
view.SortDescriptions.Add(new SortDescription(nameof(TimerEntry.RemainingTime), ListSortDirection.Ascending));
120186
((ICollectionViewLiveShaping)view).IsLiveSorting = true;
121187

122-
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(TimerList)));
188+
foreach (var timer in timers) { TimerList.Add(timer); }
189+
190+
ClearReadyCommand = new CommandWrapper(o => ClearReady(), o => true);
123191
}
124192

125193
internal void Merge(IEnumerable<TimerEntry> other) {
@@ -128,25 +196,33 @@ internal void Merge(IEnumerable<TimerEntry> other) {
128196
if (existing != null) {
129197
existing.UnlockTime = t.UnlockTime;
130198
if (existing.Class != t.Class) { existing.ChangeTimerClass(t.Class); }
131-
} else { TimerList.Add(t); }
199+
} else if (!t.IsReady) {
200+
App.Current.Dispatcher.Invoke(() => TimerList.Add(t));
201+
}
132202
}
133203
}
134204

135-
internal void ClearReady() {
205+
private void ClearReady() {
136206
foreach (var tReady in (from t in TimerList
137-
where t.IsReady
138-
select t).ToList()) {
207+
where t.IsReady
208+
select t).ToList()) {
139209
TimerList.Remove(tReady);
140210
}
141211
}
142212

143-
public event PropertyChangedEventHandler PropertyChanged;
144-
145-
public string CharName { get; }
146-
public ICollection<TimerEntry> TimerList { get; }
213+
public ICommand ClearReadyCommand { get; }
214+
public ICollection<TimerEntry> TimerList { get; } = new ObservableCollection<TimerEntry>();
147215
}
148216

149-
internal enum TimerClass { AgentMission, AgentRecovery, Lair, Mission }
217+
[Flags]
218+
internal enum TimerClass {
219+
None = 0,
220+
AgentMission = 1,
221+
AgentRecovery = 2,
222+
Agent = AgentMission | AgentRecovery,
223+
Lair = 4,
224+
Mission = 8
225+
}
150226

151227
internal abstract class TimerEntry : INotifyPropertyChanged {
152228

@@ -157,7 +233,11 @@ protected internal TimerEntry(string entryData) {
157233
UnlockTime = DateTimeOffset.FromUnixTimeSeconds(long.Parse(dataFields[1])).LocalDateTime;
158234
}
159235

160-
internal void Refresh(bool reload) => RaisePropertyChanged(nameof(RemainingTime));
236+
internal bool Refresh(TimerClass alertFilter) {
237+
RaisePropertyChanged(nameof(RemainingTime));
238+
return IsReady != AlertDone ? (AlertDone = IsReady) && ((alertFilter & Class) != TimerClass.None) : false;
239+
}
240+
161241
internal abstract void ChangeTimerClass(TimerClass newClass);
162242

163243
internal int ID { get; }
@@ -167,12 +247,14 @@ protected internal TimerEntry(string entryData) {
167247
internal bool IsReady => RemainingTime.TotalSeconds <= 0;
168248

169249
public event PropertyChangedEventHandler PropertyChanged;
170-
protected void RaisePropertyChanged(string propertyName) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); }
250+
protected void RaisePropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
171251

172252
public abstract TimerClass Class { get; }
253+
254+
private bool AlertDone = false;
173255
}
174256

175-
internal class AgentTimer : TimerEntry, INotifyPropertyChanged {
257+
internal sealed class AgentTimer : TimerEntry, INotifyPropertyChanged {
176258
internal AgentTimer(string agentInfo) : base(agentInfo) {
177259
IsRecovering = Boolean.Parse(agentInfo.Split('|')[3]);
178260
}
@@ -186,18 +268,18 @@ internal override void ChangeTimerClass(TimerClass newClass) {
186268
public override TimerClass Class => IsRecovering ? TimerClass.AgentRecovery : TimerClass.AgentMission;
187269
}
188270

189-
internal class MissionTimer : TimerEntry {
271+
internal sealed class MissionTimer : TimerEntry {
190272
internal MissionTimer(string missionInfo) : base(missionInfo) { }
191273

192274
// Mission classes are unique by ID, they should not change
193-
internal override void ChangeTimerClass(TimerClass newClass) { throw new NotSupportedException(); }
275+
internal override void ChangeTimerClass(TimerClass newClass) => throw new NotSupportedException();
194276

195277
public override TimerClass Class => ID > 0 ? TimerClass.Mission : TimerClass.Lair;
196278
}
197279

198280
// Helper classes
199281

200-
internal class CommandWrapper : ICommand {
282+
internal sealed class CommandWrapper : ICommand {
201283
public event EventHandler CanExecuteChanged;
202284

203285
internal CommandWrapper(Action<object> execute, Func<object, bool> canExecute) {

0 commit comments

Comments
 (0)