feat: do more
This commit is contained in:
parent
f61747d0e2
commit
6d0da542ce
527
src/app.rs
527
src/app.rs
|
@ -1,28 +1,35 @@
|
|||
use anyhow::Result;
|
||||
use chrono::{Duration, Local, Utc, TimeZone, FixedOffset, DateTime};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
cell::{Cell, RefCell},
|
||||
collections::HashMap,
|
||||
io::Cursor,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc::{SyncSender, Receiver},
|
||||
mpsc::{Receiver, SyncSender},
|
||||
},
|
||||
};
|
||||
use livesplit::{
|
||||
Run,
|
||||
model::Attempt,
|
||||
};
|
||||
use nom::Finish;
|
||||
use url::Url;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Duration, FixedOffset, Local, TimeZone, Utc};
|
||||
use eframe::{
|
||||
egui, epi,
|
||||
egui::{CtxRef, Sense, Rgba, Widget},
|
||||
egui, egui::{CtxRef, Rgba, Sense, Widget},
|
||||
epi,
|
||||
epi::{Frame, RepaintSignal, Storage},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use eframe::egui::Ui;
|
||||
use itertools::Itertools;
|
||||
use livesplit::{
|
||||
model::Attempt,
|
||||
Run,
|
||||
};
|
||||
use livesplit::model::Segment;
|
||||
use nom::Finish;
|
||||
use serde::Deserialize;
|
||||
use url::Url;
|
||||
|
||||
use crate::splits_logic::{SegmentLogic, SplitsLogic};
|
||||
|
||||
#[derive(Default)]
|
||||
#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))]
|
||||
|
@ -37,14 +44,19 @@ pub struct App {
|
|||
struct State {
|
||||
last_error: Option<anyhow::Error>,
|
||||
|
||||
selected_tab: AtomicUsize,
|
||||
|
||||
picking_splits: bool,
|
||||
processing_splits: bool,
|
||||
splits_file_name: Option<String>,
|
||||
splits: Option<Run>,
|
||||
splits: Option<(Run, RunStats)>,
|
||||
|
||||
processing_videos: AtomicBool,
|
||||
videos: Option<Vec<VideosResponseVideo>>,
|
||||
highlight_urls: RefCell<HashMap<u32, Url>>,
|
||||
no_video: RefCell<HashSet<u32>>,
|
||||
|
||||
attempt_to_show: Cell<Option<u32>>,
|
||||
|
||||
channel: (SyncSender<Message>, Receiver<Message>),
|
||||
}
|
||||
|
@ -54,6 +66,8 @@ impl Default for State {
|
|||
Self {
|
||||
last_error: Default::default(),
|
||||
|
||||
selected_tab: Default::default(),
|
||||
|
||||
picking_splits: Default::default(),
|
||||
processing_splits: Default::default(),
|
||||
splits_file_name: Default::default(),
|
||||
|
@ -62,6 +76,9 @@ impl Default for State {
|
|||
processing_videos: Default::default(),
|
||||
videos: Default::default(),
|
||||
highlight_urls: Default::default(),
|
||||
no_video: Default::default(),
|
||||
|
||||
attempt_to_show: Default::default(),
|
||||
|
||||
channel: std::sync::mpsc::sync_channel(100),
|
||||
}
|
||||
|
@ -69,7 +86,7 @@ impl Default for State {
|
|||
}
|
||||
|
||||
enum Message {
|
||||
Run(Result<Run>),
|
||||
Run(Result<(Run, RunStats)>),
|
||||
Videos(u32, Result<Vec<VideosResponseVideo>>),
|
||||
SplitsPath(Option<(String, Vec<u8>)>),
|
||||
}
|
||||
|
@ -82,11 +99,14 @@ impl epi::App for App {
|
|||
self.state.processing_splits = false;
|
||||
|
||||
match run {
|
||||
Ok(run) => {
|
||||
self.state.splits = Some(run);
|
||||
Ok((run, stats)) => {
|
||||
self.state.splits = Some((run, stats));
|
||||
self.state.last_error = None;
|
||||
|
||||
self.state.highlight_urls.borrow_mut().clear();
|
||||
self.state.no_video.borrow_mut().clear();
|
||||
self.state.attempt_to_show.set(None);
|
||||
// TODO: clear comparisons
|
||||
self.state.videos = None;
|
||||
}
|
||||
Err(e) => {
|
||||
|
@ -100,9 +120,11 @@ impl epi::App for App {
|
|||
|
||||
match videos {
|
||||
Ok(videos) => {
|
||||
if let Some(splits) = &self.state.splits {
|
||||
if let Some((splits, _)) = &self.state.splits {
|
||||
if let Some(attempt) = splits.attempt_history.attempts.iter().find(|attempt| attempt.id == id) {
|
||||
self.create_highlight_link(attempt, &*videos);
|
||||
if !self.create_highlight_link(attempt, &*videos) {
|
||||
self.state.no_video.borrow_mut().insert(attempt.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -125,10 +147,11 @@ impl epi::App for App {
|
|||
let repaint_signal = frame.repaint_signal();
|
||||
let sender = self.state.channel.0.clone();
|
||||
self.spawn(async move {
|
||||
let res: Result<Run> = try {
|
||||
let res: Result<(Run, RunStats)> = try {
|
||||
let bytes = Cursor::new(data);
|
||||
let run: Run = quick_xml::de::from_reader(bytes)?;
|
||||
run
|
||||
let stats = RunStats::from(&run);
|
||||
(run, stats)
|
||||
};
|
||||
sender.send(Message::Run(res)).ok();
|
||||
repaint_signal.request_repaint();
|
||||
|
@ -143,7 +166,13 @@ impl epi::App for App {
|
|||
|
||||
ui.separator();
|
||||
|
||||
ui.collapsing("Client", |ui| {
|
||||
ui.collapsing("Twitch", |ui| {
|
||||
ui.label("Channel");
|
||||
let mut s = self.config.twitch.channel.clone().unwrap_or_default();
|
||||
ui.text_edit_singleline(&mut s);
|
||||
let s = s.trim();
|
||||
self.config.twitch.channel = if s.is_empty() { None } else { Some(s.to_string()) };
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Client ID");
|
||||
let question_mark = egui::Label::new("❓")
|
||||
|
@ -176,34 +205,20 @@ impl epi::App for App {
|
|||
|
||||
ui.separator();
|
||||
|
||||
ui.collapsing("Twitch", |ui| {
|
||||
ui.label("Channel");
|
||||
let mut s = self.config.twitch.channel.clone().unwrap_or_default();
|
||||
ui.text_edit_singleline(&mut s);
|
||||
let s = s.trim();
|
||||
self.config.twitch.channel = if s.is_empty() { None } else { Some(s.to_string()) };
|
||||
});
|
||||
ui.collapsing("Highlighter", |ui| {
|
||||
ui.checkbox(&mut self.config.highlighter.show_incomplete, "Show incomplete runs");
|
||||
|
||||
ui.separator();
|
||||
|
||||
ui.collapsing("Padding", |ui| {
|
||||
ui.label("Start");
|
||||
ui.label("Start padding");
|
||||
ui.add(egui::Slider::new(&mut self.config.padding.start, 0..=30));
|
||||
|
||||
ui.label("End");
|
||||
ui.label("End padding");
|
||||
ui.add(egui::Slider::new(&mut self.config.padding.end, 0..=30));
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
ui.collapsing("Display", |ui| {
|
||||
ui.checkbox(&mut self.config.display.show_incomplete, "Show incomplete runs");
|
||||
})
|
||||
});
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if !self.config.ready() || self.state.picking_splits {
|
||||
if self.state.picking_splits {
|
||||
ui.set_enabled(false);
|
||||
}
|
||||
if ui.button("Open splits").clicked() && !self.state.picking_splits {
|
||||
|
@ -219,12 +234,6 @@ impl epi::App for App {
|
|||
}
|
||||
});
|
||||
|
||||
if !self.config.ready() {
|
||||
egui::Label::new("Fill out the configuration first.")
|
||||
.text_color(Rgba::from_rgb(1.0, 0.0, 0.0))
|
||||
.ui(ui);
|
||||
}
|
||||
|
||||
if let Some(e) = &self.state.last_error {
|
||||
egui::Label::new(format!("Error: {}", e))
|
||||
.text_color(Rgba::from_rgb(1.0, 0.0, 0.0))
|
||||
|
@ -236,68 +245,38 @@ impl epi::App for App {
|
|||
});
|
||||
}
|
||||
|
||||
if let Some(run) = &self.state.splits {
|
||||
if let Some((run, stats)) = &self.state.splits {
|
||||
ui.separator();
|
||||
|
||||
ui.heading(&run.game_name);
|
||||
ui.label(&run.category_name);
|
||||
ui.add_space(ui.spacing().item_spacing.y);
|
||||
|
||||
egui::ScrollArea::auto_sized().show(ui, |ui| {
|
||||
for (key, attempts) in &run.attempt_history.attempts.iter().rev().group_by(|attempt| Local.from_utc_datetime(&attempt.started).date()) {
|
||||
let attempts: Vec<&Attempt> = attempts
|
||||
.filter(|attempt| self.config.display.show_incomplete || attempt.real_time.or(attempt.game_time).is_some())
|
||||
.collect();
|
||||
if attempts.is_empty() {
|
||||
continue;
|
||||
let mut items: Vec<(&str, Box<dyn FnMut(&mut Ui)>)> = vec![
|
||||
("Runs", Box::new(|ui: &mut Ui| self.show_attempts(ui, ctx, frame, run))),
|
||||
("Average", Box::new(|ui: &mut Ui| self.show_simple_stats(ui, run, &stats.means, &stats.sub_means))),
|
||||
("Median", Box::new(|ui: &mut Ui| self.show_simple_stats(ui, run, &stats.medians, &stats.sub_medians))),
|
||||
];
|
||||
|
||||
if self.state.attempt_to_show.get().is_some() {
|
||||
items.insert(1, ("Run", Box::new(|ui: &mut Ui| self.show_attempt(ui, run))));
|
||||
}
|
||||
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for i in 0..items.len() {
|
||||
let name = items[i].0;
|
||||
if ui.selectable_label(self.state.selected_tab.load(Ordering::SeqCst) == i, name).clicked() {
|
||||
self.state.selected_tab.store(i, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
ui.collapsing(key.format("%d %B %Y"), |ui| {
|
||||
for attempt in attempts {
|
||||
let mut output = vec![Local.from_utc_datetime(&attempt.started).format("%H:%M:%S").to_string()];
|
||||
if let Some(real) = attempt.real_time {
|
||||
output.push(format!("RTA: {}", to_human(&real)));
|
||||
}
|
||||
if let Some(igt) = attempt.game_time {
|
||||
output.push(format!("IGT: {}", to_human(&igt)));
|
||||
}
|
||||
if attempt.real_time.or(attempt.game_time).is_none() {
|
||||
output.push(format!("Length: {}", to_human(&(attempt.ended - attempt.started))))
|
||||
}
|
||||
|
||||
let sender = self.state.channel.0.clone();
|
||||
let channel = self.config.twitch.channel.clone();
|
||||
let id = attempt.id;
|
||||
if ui.selectable_label(false, output.join(" / ")).clicked() && channel.is_some() && !self.state.processing_videos.load(Ordering::SeqCst) {
|
||||
match &self.state.videos {
|
||||
Some(videos) => {
|
||||
self.create_highlight_link(attempt, &**videos);
|
||||
ctx.request_repaint();
|
||||
}
|
||||
None => {
|
||||
self.state.processing_videos.store(true, Ordering::SeqCst);
|
||||
|
||||
let repaint_signal = frame.repaint_signal();
|
||||
let client_id = self.config.client.id.clone();
|
||||
let client_secret = self.config.client.secret.clone();
|
||||
self.spawn(async move {
|
||||
let res = get_videos(&channel.unwrap(), client_id, client_secret).await;
|
||||
sender.send(Message::Videos(id, res)).ok();
|
||||
repaint_signal.request_repaint();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(url) = self.state.highlight_urls.borrow().get(&attempt.id) {
|
||||
ui.indent(format!("attempt-{}-links", attempt.id), |ui| {
|
||||
ui.hyperlink_to("Create highlight", url);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
egui::ScrollArea::auto_sized().show(ui, |ui| {
|
||||
let func = &mut items[self.state.selected_tab.load(Ordering::SeqCst)].1;
|
||||
func(ui);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -321,15 +300,17 @@ impl epi::App for App {
|
|||
}
|
||||
|
||||
impl App {
|
||||
fn create_highlight_link(&self, attempt: &Attempt, videos: &[VideosResponseVideo]) {
|
||||
fn create_highlight_link(&self, attempt: &Attempt, videos: &[VideosResponseVideo]) -> bool {
|
||||
self.state.highlight_urls.borrow_mut().remove(&attempt.id);
|
||||
|
||||
let channel = match &self.config.twitch.channel {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let splits = match &self.state.splits {
|
||||
Some(s) => s,
|
||||
None => return,
|
||||
Some((s, _)) => s,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let video = videos
|
||||
|
@ -343,7 +324,7 @@ impl App {
|
|||
|
||||
let (video, duration) = match video {
|
||||
Some(v) => v,
|
||||
None => return,
|
||||
None => return false,
|
||||
};
|
||||
|
||||
let start = (Utc.from_utc_datetime(&attempt.started) - video.created_at.with_timezone(&Utc)).num_seconds();
|
||||
|
@ -363,7 +344,7 @@ impl App {
|
|||
};
|
||||
let mut url = match url {
|
||||
Ok(u) => u,
|
||||
Err(_) => return,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let run_time = match attempt.game_time.or(attempt.real_time) {
|
||||
Some(t) => t,
|
||||
|
@ -374,6 +355,234 @@ impl App {
|
|||
.append_pair("end", &end.to_string())
|
||||
.append_pair("title", &format!("{} PB in {}", splits.game_name, to_string(&run_time)));
|
||||
self.state.highlight_urls.borrow_mut().insert(attempt.id, url);
|
||||
true
|
||||
}
|
||||
|
||||
fn show_attempts<'a>(&self, ui: &mut Ui, ctx: &CtxRef, frame: &mut Frame<'a>, run: &Run) {
|
||||
for (key, attempts) in &run.attempt_history.attempts.iter().rev().group_by(|attempt| Local.from_utc_datetime(&attempt.started).date()) {
|
||||
let attempts: Vec<&Attempt> = attempts
|
||||
.filter(|attempt| self.config.highlighter.show_incomplete || attempt.real_time.or(attempt.game_time).is_some())
|
||||
.collect();
|
||||
if attempts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
ui.collapsing(key.format("%d %B %Y"), |ui| {
|
||||
ui.columns(2, |ui| {
|
||||
ui[0].label("Date");
|
||||
ui[1].label("Duration");
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
for attempt in attempts {
|
||||
ui.columns(2, |ui| {
|
||||
let channel = self.config.twitch.channel.clone();
|
||||
ui[0].collapsing(Local.from_utc_datetime(&attempt.started).format("%H:%M:%S"), |ui| {
|
||||
if ui.button("Use run").clicked() {
|
||||
self.state.attempt_to_show.set(Some(attempt.id));
|
||||
}
|
||||
|
||||
if let Some(id) = self.state.attempt_to_show.get() {
|
||||
if id != attempt.id {
|
||||
if ui.button("Compare").clicked() {
|
||||
// self.state.attempt_to_compare.set(Some(attempt.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ui.button("Attempt to create highlighter link").clicked() && !self.state.processing_videos.load(Ordering::SeqCst) {
|
||||
match &self.state.videos {
|
||||
Some(videos) => {
|
||||
if !self.create_highlight_link(attempt, &**videos) {
|
||||
self.state.no_video.borrow_mut().insert(attempt.id);
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
None => {
|
||||
self.state.processing_videos.store(true, Ordering::SeqCst);
|
||||
|
||||
let id = attempt.id;
|
||||
let sender = self.state.channel.0.clone();
|
||||
let repaint_signal = frame.repaint_signal();
|
||||
let client_id = self.config.client.id.clone();
|
||||
let client_secret = self.config.client.secret.clone();
|
||||
self.spawn(async move {
|
||||
let res = get_videos(&channel.unwrap(), client_id, client_secret).await;
|
||||
sender.send(Message::Videos(id, res)).ok();
|
||||
repaint_signal.request_repaint();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(url) = self.state.highlight_urls.borrow().get(&attempt.id) {
|
||||
ui.hyperlink_to("Create highlight", url);
|
||||
}
|
||||
|
||||
if self.state.no_video.borrow().contains(&attempt.id) {
|
||||
egui::Label::new("Could not find a VOD for this run")
|
||||
.text_color(Rgba::from_rgb(1.0, 0.0, 0.0))
|
||||
.ui(ui);
|
||||
}
|
||||
});
|
||||
|
||||
let mut output = vec![];
|
||||
if let Some(real) = attempt.real_time {
|
||||
output.push(format!("RTA: {}", to_human(&real)));
|
||||
}
|
||||
if let Some(igt) = attempt.game_time {
|
||||
output.push(format!("IGT: {}", to_human(&igt)));
|
||||
}
|
||||
if attempt.real_time.or(attempt.game_time).is_none() {
|
||||
output.push(format!("Length: {}", to_human(&(attempt.ended - attempt.started))))
|
||||
}
|
||||
|
||||
ui[1].label(output.join(" / "));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn show_attempt(&self, ui: &mut Ui, run: &Run) {
|
||||
let attempt_id = match self.state.attempt_to_show.get() {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let attempt = match run.attempt_history.attempts.iter().find(|attempt| attempt.id == attempt_id) {
|
||||
Some(attempt) => attempt,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let mut running = Duration::seconds(0);
|
||||
|
||||
let mut show_segment = |ui: &mut Ui, segment: &Segment| {
|
||||
ui.columns(3, |ui| {
|
||||
ui[0].label(segment.clean_name());
|
||||
if let Some(time) = segment.segment_history.times.iter().find(|time| time.id == attempt.id) {
|
||||
if let Some(dur) = time.real_time.or(time.game_time) {
|
||||
running = running + dur;
|
||||
|
||||
ui[1].label(to_human(&dur));
|
||||
ui[2].label(to_human(&running));
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
ui.columns(3, |ui| {
|
||||
ui[0].label("Split");
|
||||
ui[1].label("Duration");
|
||||
ui[2].label("Finished");
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
egui::ScrollArea::auto_sized().show(ui, |ui| {
|
||||
match run.subsplits() {
|
||||
Some(subs) => {
|
||||
let mut sub_finish = Duration::seconds(0);
|
||||
|
||||
for sub in subs {
|
||||
ui.columns(3, |ui| {
|
||||
let mut sub_time = Duration::seconds(0);
|
||||
|
||||
for segment in &sub.splits {
|
||||
if let Some(time) = segment.segment_history.times.iter().find(|time| time.id == attempt.id) {
|
||||
if let Some(dur) = time.real_time.or(time.game_time) {
|
||||
sub_time = sub_time + dur;
|
||||
sub_finish = sub_finish + dur;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
egui::Label::new(sub.name)
|
||||
.strong()
|
||||
.ui(&mut ui[0]);
|
||||
|
||||
egui::Label::new(to_human(&sub_time))
|
||||
.strong()
|
||||
.ui(&mut ui[1]);
|
||||
|
||||
egui::Label::new(to_human(&sub_finish))
|
||||
.strong()
|
||||
.ui(&mut ui[2]);
|
||||
});
|
||||
// ui.heading(sub.name);
|
||||
for segment in &sub.splits {
|
||||
show_segment(ui, segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
for segment in &run.segments.segments {
|
||||
show_segment(ui, segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn show_simple_stats(&self, ui: &mut Ui, run: &Run, stats: &HashMap<String, f64>, sub_stats: &HashMap<String, f64>) {
|
||||
let mut running = Duration::seconds(0);
|
||||
|
||||
let mut show_segment = |ui: &mut Ui, segment: &Segment| {
|
||||
ui.columns(3, |ui| {
|
||||
ui[0].label(segment.clean_name());
|
||||
if let Some(&median) = stats.get(&segment.name) {
|
||||
let dur = Duration::nanoseconds((median * 1_000_000.0) as i64);
|
||||
running = running + dur;
|
||||
ui[1].label(to_human(&dur));
|
||||
ui[2].label(to_human(&running));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
ui.columns(3, |ui| {
|
||||
ui[0].label("Split");
|
||||
ui[1].label("Duration");
|
||||
ui[2].label("Finished");
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
egui::ScrollArea::auto_sized().show(ui, |ui| {
|
||||
if let Some(subsplits) = run.subsplits() {
|
||||
for sub in subsplits {
|
||||
ui.columns(3, |ui| {
|
||||
egui::Label::new(sub.name)
|
||||
.strong()
|
||||
.ui(&mut ui[0]);
|
||||
|
||||
if let Some(sub_stat) = sub_stats.get(sub.name) {
|
||||
let dur = Duration::nanoseconds((*sub_stat * 1_000_000.0) as i64);
|
||||
egui::Label::new(to_human(&dur))
|
||||
.strong()
|
||||
.ui(&mut ui[1]);
|
||||
}
|
||||
});
|
||||
|
||||
for segment in sub.splits {
|
||||
show_segment(ui, segment);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for segment in &run.segments.segments {
|
||||
show_segment(ui, segment);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
|
||||
ui.columns(3, |ui| {
|
||||
ui[0].label("Total");
|
||||
let sum: f64 = stats.values().sum();
|
||||
let dur = Duration::nanoseconds((sum * 1_000_000.0) as i64);
|
||||
ui[1].label(to_human(&dur));
|
||||
});
|
||||
}
|
||||
|
||||
async fn choose_splits(sender: SyncSender<Message>, repaint_signal: Arc<dyn RepaintSignal>) {
|
||||
|
@ -461,6 +670,108 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
struct RunStats {
|
||||
means: HashMap<String, f64>,
|
||||
sub_means: HashMap<String, f64>,
|
||||
|
||||
medians: HashMap<String, f64>,
|
||||
sub_medians: HashMap<String, f64>,
|
||||
}
|
||||
|
||||
impl RunStats {
|
||||
fn from(run: &Run) -> Self {
|
||||
let mut means = HashMap::with_capacity(run.segments.segments.len());
|
||||
let mut sub_means = HashMap::with_capacity(run.segments.segments.len());
|
||||
|
||||
let mut medians = HashMap::with_capacity(run.segments.segments.len());
|
||||
let mut sub_medians = HashMap::with_capacity(run.segments.segments.len());
|
||||
|
||||
let get_median = |times: &[i64]| -> f64 {
|
||||
if times.len() % 2 == 1 {
|
||||
times[times.len() / 2] as f64
|
||||
} else if times.len() > 1 {
|
||||
let idx = times.len() / 2;
|
||||
(times[idx] + times[idx - 1]) as f64 / 2.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
};
|
||||
|
||||
let do_stats = |segment: &Segment| -> (f64, f64) {
|
||||
let times: Vec<i64> = segment.segment_history.times
|
||||
.iter()
|
||||
.filter_map(|time| time.real_time.or(time.game_time))
|
||||
.map(|dur| dur.num_milliseconds())
|
||||
.sorted_unstable()
|
||||
.collect();
|
||||
|
||||
let median = get_median(×);
|
||||
let mean = times.iter().sum::<i64>() as f64 / times.len() as f64;
|
||||
(median, mean)
|
||||
};
|
||||
|
||||
match run.subsplits() {
|
||||
Some(subs) => {
|
||||
for sub in subs {
|
||||
// for each split in the category
|
||||
// collect their time in each attempt
|
||||
// sum them together to get a category time
|
||||
// collect all category times
|
||||
// calc stats
|
||||
|
||||
let mut attempt_times = Vec::with_capacity(run.attempt_history.attempts.len());
|
||||
for attempt in &run.attempt_history.attempts {
|
||||
if attempt.real_time.or(attempt.game_time).is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let category_time = run.segments.segments
|
||||
.iter()
|
||||
// only consider segments in this category
|
||||
.filter(|seg| sub.splits.iter().any(|segment| segment.name == seg.name))
|
||||
.filter_map(|seg| seg.segment_history.times
|
||||
.iter()
|
||||
// find the time for this attempt
|
||||
.find(|time| time.id == attempt.id)
|
||||
.and_then(|time| time.real_time.or(time.game_time))
|
||||
.map(|dur| dur.num_milliseconds())
|
||||
)
|
||||
.sum::<i64>();
|
||||
attempt_times.push(category_time);
|
||||
}
|
||||
|
||||
let sub_median = get_median(&attempt_times);
|
||||
let sub_mean = attempt_times.iter().sum::<i64>() as f64 / attempt_times.len() as f64;
|
||||
|
||||
sub_medians.insert(sub.name.to_string(), sub_median);
|
||||
sub_means.insert(sub.name.to_string(), sub_mean);
|
||||
|
||||
for segment in &sub.splits {
|
||||
let (median, mean) = do_stats(segment);
|
||||
medians.insert(segment.name.clone(), median);
|
||||
means.insert(segment.name.clone(), mean);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
for segment in &run.segments.segments {
|
||||
let (median, mean) = do_stats(segment);
|
||||
medians.insert(segment.name.clone(), median);
|
||||
means.insert(segment.name.clone(), mean);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
means,
|
||||
sub_means,
|
||||
|
||||
medians,
|
||||
sub_medians,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AppCredentials {
|
||||
access_token: String,
|
||||
|
@ -543,7 +854,9 @@ fn to_string(duration: &Duration) -> String {
|
|||
}
|
||||
|
||||
fn to_human(duration: &Duration) -> String {
|
||||
let mut secs = duration.num_seconds();
|
||||
let millis = duration.num_milliseconds();
|
||||
|
||||
let mut secs = millis / 1000;
|
||||
|
||||
let mut mins = secs / 60;
|
||||
secs %= 60;
|
||||
|
|
|
@ -4,16 +4,16 @@ pub struct Config {
|
|||
pub client: Client,
|
||||
pub twitch: Twitch,
|
||||
pub padding: Padding,
|
||||
#[serde(default)]
|
||||
pub display: Display,
|
||||
#[serde(default, rename = "display")]
|
||||
pub highlighter: Display,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn ready(&self) -> bool {
|
||||
!self.client.id.is_empty()
|
||||
&& !self.client.secret.is_empty()
|
||||
&& self.twitch.channel.is_some()
|
||||
}
|
||||
// pub fn twitch_ready(&self) -> bool {
|
||||
// !self.client.id.is_empty()
|
||||
// && !self.client.secret.is_empty()
|
||||
// && self.twitch.channel.is_some()
|
||||
// }
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))]
|
||||
|
|
10
src/lib.rs
10
src/lib.rs
|
@ -3,16 +3,18 @@
|
|||
#![cfg_attr(not(debug_assertions), deny(warnings))] // Forbid warnings in release builds
|
||||
#![warn(clippy::all, rust_2018_idioms)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use eframe::wasm_bindgen::{self, prelude::*};
|
||||
|
||||
pub use crate::app::App;
|
||||
|
||||
mod app;
|
||||
mod config;
|
||||
pub use crate::app::App;
|
||||
mod splits_logic;
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// When compiling for web:
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use eframe::wasm_bindgen::{self, prelude::*};
|
||||
|
||||
/// This is the entry-point for all the web-assembly.
|
||||
/// This is called once from the HTML.
|
||||
/// It loads the app, installs some callbacks, then returns.
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
#![feature(try_blocks, async_closure)]
|
||||
#![cfg_attr(target_os = "windows", windows_subsystem = "windows")]
|
||||
|
||||
use crate::app::App;
|
||||
|
||||
mod app;
|
||||
mod config;
|
||||
|
||||
use crate::app::App;
|
||||
mod splits_logic;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
use livesplit::model::Segment;
|
||||
use livesplit::Run;
|
||||
|
||||
pub trait SplitsLogic {
|
||||
fn subsplits(&self) -> Option<Vec<SplitCategory<'_>>>;
|
||||
}
|
||||
|
||||
impl SplitsLogic for Run {
|
||||
fn subsplits(&self) -> Option<Vec<SplitCategory<'_>>> {
|
||||
let mut output = Vec::new();
|
||||
let mut current_category = SplitCategory::default();
|
||||
for segment in &self.segments.segments {
|
||||
if !segment.is_subsplit() {
|
||||
if current_category.splits.is_empty() {
|
||||
return None;
|
||||
} else {
|
||||
if let Some(category) = segment.category_name() {
|
||||
current_category.name = category;
|
||||
}
|
||||
|
||||
current_category.splits.push(segment);
|
||||
|
||||
let mut insert = SplitCategory::default();
|
||||
std::mem::swap(&mut insert, &mut current_category);
|
||||
|
||||
output.push(insert);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
current_category.splits.push(segment);
|
||||
}
|
||||
|
||||
Some(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SplitCategory<'a> {
|
||||
pub name: &'a str,
|
||||
pub splits: Vec<&'a Segment>,
|
||||
}
|
||||
|
||||
pub trait SegmentLogic {
|
||||
fn clean_name(&self) -> &str;
|
||||
fn category_name(&self) -> Option<&str>;
|
||||
fn is_subsplit(&self) -> bool;
|
||||
}
|
||||
|
||||
impl SegmentLogic for Segment {
|
||||
fn clean_name(&self) -> &str {
|
||||
let mut output = &*self.name;
|
||||
|
||||
if output.chars().filter(|&c| c == '}' || c == '{').count() > 1 {
|
||||
if let Some(idx) = output.find('}') {
|
||||
output = output[idx + 1..].trim_start();
|
||||
}
|
||||
}
|
||||
|
||||
output.trim_start_matches(|c| c == '-')
|
||||
}
|
||||
|
||||
fn category_name(&self) -> Option<&str> {
|
||||
let open = self.name.find('{')?;
|
||||
let close = self.name[open + 1..].find('}')? + open + 1;
|
||||
|
||||
Some(&self.name[open + 1..close])
|
||||
}
|
||||
|
||||
fn is_subsplit(&self) -> bool {
|
||||
self.name.starts_with('-')
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue