feat: do more

This commit is contained in:
Anna 2022-03-26 02:46:27 -04:00
parent f61747d0e2
commit 6d0da542ce
5 changed files with 510 additions and 120 deletions

View File

@ -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(&times);
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;

View File

@ -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))]

View File

@ -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.

View File

@ -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() {

74
src/splits_logic.rs Executable file
View File

@ -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('-')
}
}