diff --git a/program/JacketData.cs b/program/JacketData.cs new file mode 100644 index 0000000..fd9883c --- /dev/null +++ b/program/JacketData.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; + +/// One chip position. Mirrors Form2.Circle. +public class Circle +{ + public float Number { get; set; } + public int X { get; set; } // screen-space: OriginalX * 50 + MarginX + public int Y { get; set; } + public float OriginalX { get; set; } + public float OriginalY { get; set; } + public float OriginalZ { get; set; } + public bool Inner { get; set; } // non-integer Number +} + +/// Loads the 8 CSVs from JacketData/ — front|back|right|left x L|XL. +public class JacketData +{ + public const int CoordScale = 50; // Form2: originalX * 50 + public const int MarginX = 30; + public const int MarginY = 30; + + private const string ConfigFile = "config.txt"; + private const string DefaultDir = "JacketData"; + + private static readonly string[] Views = { "front", "back", "right", "left" }; + private static readonly string[] Sizes = { "L", "XL" }; + + private readonly Dictionary> _l = new Dictionary>(); + private readonly Dictionary> _xl = new Dictionary>(); + + public string Folder { get; private set; } + public bool Loaded { get; private set; } + + /// Form2.GetCurrentCSVData() + public List Get(string size, string view) + { + var dict = (size == "XL") ? _xl : _l; + List list; + return dict.TryGetValue(view, out list) ? list : new List(); + } + + public bool Has(string size, string view) + { + var dict = (size == "XL") ? _xl : _l; + return dict.ContainsKey(view) && dict[view].Count > 0; + } + + // ── folder resolution: config.txt, else JacketData/ next to the exe ── + + public static string ResolveFolder() + { + string cfg = SoundWindow.AppFile(ConfigFile); + + if (File.Exists(cfg)) + { + try + { + string saved = File.ReadAllText(cfg).Trim(); + if (Directory.Exists(saved) && Validate(saved)) + { + Logger.Write("jacket", "folder from config.txt: " + saved); + return saved; + } + + Logger.Write("jacket", "config.txt path invalid, ignoring: " + saved); + } + catch (Exception ex) + { + Logger.Write("jacket", "config.txt unreadable: " + ex.Message); + } + } + + return SoundWindow.AppFile(DefaultDir); + } + + public static void SaveFolder(string folder) + { + try { File.WriteAllText(SoundWindow.AppFile(ConfigFile), folder); } + catch (Exception ex) { Logger.Write("jacket", "could not save config.txt: " + ex.Message); } + } + + /// Form1.ValidateCSVFiles — all 8 must be present. + public static bool Validate(string folder) + { + foreach (string v in Views) + foreach (string s in Sizes) + if (!File.Exists(Path.Combine(folder, v + "_" + s + ".csv"))) + return false; + return true; + } + + public static string[] MissingFiles(string folder) + { + var missing = new List(); + foreach (string v in Views) + foreach (string s in Sizes) + { + string name = v + "_" + s + ".csv"; + if (!File.Exists(Path.Combine(folder, name))) missing.Add(name); + } + return missing.ToArray(); + } + + // ── loading ───────────────────────────────────────────────────────── + + public static JacketData Load() + { + return Load(ResolveFolder()); + } + + public static JacketData Load(string folder) + { + var data = new JacketData(); + data.Folder = folder; + + if (!Directory.Exists(folder)) + { + Logger.Write("jacket", "folder not found: " + folder); + return data; + } + + string[] missing = MissingFiles(folder); + if (missing.Length > 0) + Logger.Write("jacket", "missing " + missing.Length + " file(s): " + string.Join(", ", missing)); + + foreach (string view in Views) + { + foreach (string size in Sizes) + { + string path = Path.Combine(folder, view + "_" + size + ".csv"); + var circles = ReadCsv(path); + if (circles.Count == 0) continue; + + if (size == "XL") data._xl[view] = circles; + else data._l[view] = circles; + + Logger.Write("jacket", "loaded " + circles.Count + " chips from " + view + "_" + size + ".csv"); + } + } + + data.Loaded = (data._l.Count > 0 || data._xl.Count > 0); + if (!data.Loaded) Logger.Write("jacket", "NO chip data loaded from " + folder); + + return data; + } + + /// Columns: Number, OriginalX, OriginalY, OriginalZ. Header optional. + private static List ReadCsv(string path) + { + var circles = new List(); + + if (!File.Exists(path)) + { + Logger.Write("jacket", "file not found: " + path); + return circles; + } + + string[] lines; + try { lines = File.ReadAllLines(path); } + catch (Exception ex) + { + Logger.Write("jacket", "read failed " + path + ": " + ex.Message); + return circles; + } + + int start = 0; + if (lines.Length > 0) + { + string h = lines[0].ToLower(); + if (h.Contains("number") || h.Contains("originalx")) start = 1; + } + + for (int i = start; i < lines.Length; i++) + { + string line = lines[i].Trim(); + if (line.Length == 0) continue; + + string[] p = line.Split(','); + if (p.Length < 4) + { + Logger.Write("jacket", path + " line " + (i + 1) + ": only " + p.Length + " columns"); + continue; + } + + float number, ox, oy, oz; + var inv = CultureInfo.InvariantCulture; + + // InvariantCulture matters — under ja_JP/de_DE, "1.5" can fail to parse. + if (!float.TryParse(p[0].Trim(), NumberStyles.Float, inv, out number) || + !float.TryParse(p[1].Trim(), NumberStyles.Float, inv, out ox) || + !float.TryParse(p[2].Trim(), NumberStyles.Float, inv, out oy) || + !float.TryParse(p[3].Trim(), NumberStyles.Float, inv, out oz)) + { + Logger.Write("jacket", path + " line " + (i + 1) + ": bad number format"); + continue; + } + + circles.Add(new Circle + { + Number = number, + X = (int)(ox * CoordScale) + MarginX, + Y = (int)(oy * CoordScale) + MarginY, + OriginalX = ox, + OriginalY = oy, + OriginalZ = oz, + Inner = (number % 1 != 0) + }); + } + + return circles; + } +} \ No newline at end of file diff --git a/program/MainWindow.cs b/program/MainWindow.cs index 2455c89..9a2b13a 100644 --- a/program/MainWindow.cs +++ b/program/MainWindow.cs @@ -71,7 +71,9 @@ // make it full screen this.Fullscreen(); - + // //use the linux computer resolution + // this.SetDefaultSize(1280, 800); + // this.SetPosition(WindowPosition.Center); // RootStack.TransitionType = StackTransitionType.None; // RootStack.TransitionType = StackTransitionType.SlideLeftRight; @@ -329,12 +331,14 @@ private void OnCaseSelected(object sender, CaseDefinition def) { - _mapWindow.ShowCase( - string.Join(" : ", def.TreePath), // no DisplayName on CaseDefinition - SoundWindow.AppFile("body/front.png"), - SoundWindow.AppFile(def.MapFront), - SoundWindow.AppFile(def.SoundPath)); + // _mapWindow.ShowCase( + // string.Join(" : ", def.TreePath), // no DisplayName on CaseDefinition + // SoundWindow.AppFile("body/front.png"), + // SoundWindow.AppFile(def.MapFront), + // SoundWindow.AppFile(def.SoundPath)); + // RootNoteBook.CurrentPage = PageMap; + _mapWindow.ShowCase(def, string.Join(" : ", def.TreePath)); RootNoteBook.CurrentPage = PageMap; } diff --git a/program/MapWindow.cs b/program/MapWindow.cs index e18eb72..b3915ed 100644 --- a/program/MapWindow.cs +++ b/program/MapWindow.cs @@ -5,11 +5,27 @@ public class MapWindow : IDisposable { - [UI] private DrawingArea MapArea = null; - [UI] private Label MapTitleLabel = null; - [UI] private Button MapBackButton = null; + // Form2 view offsets — L size + private const float FRONT_OFFSET_X = 25.0f, FRONT_OFFSET_Y = 50.0f; + private const float BACK_OFFSET_X = 20.0f, BACK_OFFSET_Y = 20.0f; + private const float RIGHT_OFFSET_X = 10.0f, RIGHT_OFFSET_Y = -150.0f; + private const float LEFT_OFFSET_X = -50.0f, LEFT_OFFSET_Y = -150.0f; + private const float FRONT_SIZE = 1.0f, BACK_SIZE = 0.5f, + RIGHT_SIZE = 1.0f, LEFT_SIZE = 1.0f; + + // XL deltas + private const float XL_FRONT_OFFSET_X = -28.0f, XL_FRONT_OFFSET_Y = 24.0f; + private const float XL_BACK_OFFSET_X = 8.0f, XL_BACK_OFFSET_Y = -15.0f; + private const float XL_RIGHT_OFFSET_X = -41.0f, XL_RIGHT_OFFSET_Y = 8.0f; + private const float XL_LEFT_OFFSET_X = 54.0f, XL_LEFT_OFFSET_Y = 0.0f; + + private const int CircleDiameter = 50; + // ── glade widgets ── + [UI] private DrawingArea MapArea = null; + [UI] private Label MapTitleLabel = null; [UI] private Label MapCaseLabel = null; + [UI] private Button MapBackButton = null; [UI] private RadioButton BodySkeletonRadio = null; [UI] private RadioButton BodyJacketRadio = null; [UI] private ComboBoxText JacketSizeCombo = null; @@ -18,56 +34,400 @@ [UI] private RadioButton ViewRightRadio = null; [UI] private RadioButton ViewLeftRadio = null; [UI] private CheckButton HideCircleCheck = null; - [UI] private CheckButton BlackAudioCheck = null; - [UI] private Button SoundTestButton = null; - [UI] private TextView MapLogView = null; - [UI] private Box MapControlSlots = null; - private readonly Language _lang; + private readonly Language _lang; private readonly SoundLooper _sound = new SoundLooper(); - private Gdk.Pixbuf _body; - private Gdk.Pixbuf _map; + // ── state ── + private Gdk.Pixbuf _body; // background, native size + private Gdk.Pixbuf _map; // 546x546, black keyed out — also the audio reference + private CaseDefinition _case; + private string _view = "front"; - private const double MapAlpha = 0.7; + private const int MapSize = 546; // Form2: new Bitmap(mapImage, 546, 546) + private const double MapAlpha = 0.7; // Form2: CreateOverlayImage(..., 0.7f, ...) public event EventHandler BackRequested; + private JacketData _jacket; + private System.Collections.Generic.List _highlighted = new System.Collections.Generic.List(); + private static bool _listed; + public MapWindow(Builder builder, Language language) { builder.Autoconnect(this); + + _jacket = JacketData.Load(); + Logger.Write("jacket", "folder: " + _jacket.Folder + " loaded=" + _jacket.Loaded); + + if (MapArea == null || MapTitleLabel == null || MapBackButton == null) + throw new InvalidOperationException( + "Glade id mismatch — MapArea=" + (MapArea != null) + + " MapTitleLabel=" + (MapTitleLabel != null) + + " MapBackButton=" + (MapBackButton != null)); + _lang = language; - MapArea.Drawn += OnMapDrawn; + MapArea.Drawn += OnMapDrawn; MapBackButton.Clicked += OnBackClicked; - _lang.Register("MapBackButton", MapBackButton); - _lang.RegisterDynamic("MapTitleLabel", MapTitleLabel); + BodySkeletonRadio.Toggled += (s, e) => { if (BodySkeletonRadio.Active) ReloadBody(); }; + BodyJacketRadio.Toggled += (s, e) => { if (BodyJacketRadio.Active) ReloadBody(); }; + JacketSizeCombo.Changed += (s, e) => { ReloadBody(); MapArea.QueueDraw(); }; + + HookView(ViewFrontRadio, "front"); + HookView(ViewBackRadio, "back"); + HookView(ViewRightRadio, "right"); + HookView(ViewLeftRadio, "left"); + + HideCircleCheck.Toggled += (s, e) => MapArea.QueueDraw(); + _sound.Log += msg => Logger.Write("sound", msg); } - /// Called when a disease is picked. Loads images, starts the loop. - public void ShowCase(string title, string bodyPath, string mapPath, string soundPath) + private void HookView(RadioButton rb, string view) { - MapTitleLabel.Text = title ?? ""; - - DisposePixbufs(); - _body = LoadPixbuf(bodyPath, false); - _map = LoadPixbuf(mapPath, true); // true = key out black - - Logger.Write("case", "open: " + title + " | map=" + mapPath + " | sound=" + soundPath); - - MapArea.QueueDraw(); - _sound.Start(soundPath); + if (rb == null) return; + rb.Toggled += (s, e) => { if (rb.Active) SetView(view); }; } - /// Kills audio without touching the images. Call from OnBackClicked. + // ── public API ───────────────────────────────────────────────────── + + public void ShowCase(CaseDefinition def, string title) + { + _case = def; + _view = "front"; + if (ViewFrontRadio != null) ViewFrontRadio.Active = true; + + MapTitleLabel.Text = title ?? ""; + MapCaseLabel.Text = title ?? "-"; + + DisposePixbufs(); + _body = LoadBody(); + _map = LoadMap(MapPathForView()); + + MapArea.QueueDraw(); + _sound.Start(SoundWindow.AppFile(def.SoundPath)); + + Logger.Write("case", "open: " + title + " | map=" + MapPathForView()); + } + public void StopSession() { _sound.Stop(); - Logger.Write("case", "closed"); + Logger.Write("case", "closed"); } + /// Raw MAP pixbuf — Form2's originalMapForAudio. Never composited. + public Gdk.Pixbuf AudioReference { get { return _map; } } + + // ── the paint ────────────────────────────────────────────────────── + + private void OnMapDrawn(object o, DrawnArgs args) + { + var cr = args.Cr; + int w = MapArea.AllocatedWidth; + int h = MapArea.AllocatedHeight; + + if (_body == null) { args.RetVal = true; return; } + + // ONE scale factor, derived from the body — this is what keeps the + // overlay registered. Zoom-to-fit, same as PictureBoxSizeMode.Zoom. + double s = Math.Min((double)w / _body.Width, (double)h / _body.Height); + double ox = (w - _body.Width * s) / 2.0; + double oy = (h - _body.Height * s) / 2.0; + + cr.Save(); + cr.Translate(ox, oy); + cr.Scale(s, s); // everything below is in BODY PIXELS + + Gdk.CairoHelper.SetSourcePixbuf(cr, _body, 0, 0); + cr.Paint(); + + if (_map != null) + { + // CreateOverlayImage(position: null) == centre the MAP on the body + double mx = (_body.Width - _map.Width) / 2.0; + double my = (_body.Height - _map.Height) / 2.0; + + Gdk.CairoHelper.SetSourcePixbuf(cr, _map, mx, my); + cr.PaintWithAlpha(MapAlpha); + } + + if (!HideCircleCheck.Active) + DrawCircles(cr); // body-pixel coords; transform does the rest + + cr.Restore(); + args.RetVal = true; + } + + private void DrawCircles(Cairo.Context cr) + { + if (_jacket == null || _body == null) return; + + var circles = _jacket.Get(JacketSize, _view); + if (circles.Count == 0) return; + + // "panel" is the body pixbuf — Form2's gridPictureBox was 546x546 + int panelW = _body.Width; + int panelH = _body.Height; + + float scale = ScalingFactor(circles, panelW, panelH); + + float minX = float.MaxValue, maxX = float.MinValue; + float minY = float.MaxValue, maxY = float.MinValue; + foreach (var c in circles) + { + if (c.X < minX) minX = c.X; + if (c.X > maxX) maxX = c.X; + if (c.Y < minY) minY = c.Y; + if (c.Y > maxY) maxY = c.Y; + } + + float offsetX = (panelW - (maxX - minX) * scale) / 2 - minX * scale; + float offsetY = (panelH - (maxY - minY) * scale) / 2 - minY * scale; + + cr.SelectFontFace("Sans", Cairo.FontSlant.Normal, Cairo.FontWeight.Bold); + + foreach (var c in circles) + DrawOneCircle(cr, c, scale, offsetX, offsetY); + } + + private float ScalingFactor(System.Collections.Generic.List circles, int panelW, int panelH) + { + if (circles.Count == 0) return 1.0f; + + int availW = panelW - 40, availH = panelH - 40; + if (_view == "front") { availW = panelW - 100; availH = panelH - 100; } + + float minX = float.MaxValue, maxX = float.MinValue; + float minY = float.MaxValue, maxY = float.MinValue; + foreach (var c in circles) + { + if (c.X < minX) minX = c.X; + if (c.X > maxX) maxX = c.X; + if (c.Y < minY) minY = c.Y; + if (c.Y > maxY) maxY = c.Y; + } + + const float circleRadius = 2.1f / 2; + float dataW = (maxX - minX) + circleRadius * 2; + float dataH = (maxY - minY) + circleRadius * 2; + + if (dataW <= 0 || dataH <= 0) return 1.0f; + + return Math.Min(availW / dataW, availH / dataH) * 0.9f; + } + + private void DrawOneCircle(Cairo.Context cr, Circle c, float scale, float ox, float oy) + { + float x, y, sizeScale; + + switch (_view) + { + case "back": + x = c.X * scale + ox - BACK_OFFSET_X; + y = c.Y * scale + oy - BACK_OFFSET_Y; + sizeScale = BACK_SIZE; + break; + case "right": + x = c.X * scale + ox - RIGHT_OFFSET_X; + y = c.Y * scale + oy - RIGHT_OFFSET_Y; + sizeScale = RIGHT_SIZE; + break; + case "left": + x = c.X * scale + ox - LEFT_OFFSET_X; + y = c.Y * scale + oy - LEFT_OFFSET_Y; + sizeScale = LEFT_SIZE; + break; + default: + x = c.X * scale + ox - FRONT_OFFSET_X; + y = c.Y * scale + oy - FRONT_OFFSET_Y; + sizeScale = FRONT_SIZE; + break; + } + + if (JacketSize == "XL") + { + switch (_view) + { + case "back": x += XL_BACK_OFFSET_X; y += XL_BACK_OFFSET_Y; break; + case "right": x += XL_RIGHT_OFFSET_X; y += XL_RIGHT_OFFSET_Y; break; + case "left": x += XL_LEFT_OFFSET_X; y += XL_LEFT_OFFSET_Y; break; + default: x += XL_FRONT_OFFSET_X; y += XL_FRONT_OFFSET_Y; break; + } + } + + double d = CircleDiameter * scale * sizeScale; + double r = d / 2; + + bool hot = false; + foreach (float n in _highlighted) + if (Math.Abs(c.Number - n) < 0.001f) { hot = true; break; } + + cr.NewPath(); + cr.Arc(x, y, r, 0, 2 * Math.PI); + + if (hot) + { + // cr.SetSourceRGBA(1.0, 0.922, 0.231, 1.0); // Color.FromArgb(255,235,59) + // cr.Arc(x, y, r, 0, 2 * Math.PI); + // cr.FillPreserve(); + cr.SetSourceRGBA(1.0, 0.922, 0.231, 1.0); + cr.FillPreserve(); + } + // else + // { + // cr.Arc(x, y, r, 0, 2 * Math.PI); + // } + + cr.SetSourceRGBA(0.5, 0.5, 0.5, 1.0); // BorderColor = Gray + cr.LineWidth = Math.Max(1.0, scale); + cr.Stroke(); + + string text = (c.Number % 1 == 0) + ? ((int)c.Number).ToString() + : c.Number.ToString("F1"); + + cr.SetFontSize(Math.Max(6.0, d / (c.Inner ? 3 : 4))); + var ext = cr.TextExtents(text); + cr.MoveTo(x - ext.Width / 2 - ext.XBearing, y + ext.Height / 2); + cr.SetSourceRGBA(0.2, 0.2, 0.2, 1.0); // TextColor + cr.ShowText(text); + cr.NewPath(); + } + + // ── loading ──────────────────────────────────────────────────────── + + private void SetView(string view) + { + if (_view == view) return; + _view = view; + + ReloadBody(); + + if (_map != null) { _map.Dispose(); _map = null; } + _map = LoadMap(MapPathForView()); + + MapArea.QueueDraw(); + Logger.Write("view", "-> " + view); + } + + private void ReloadBody() + { + if (_body != null) { _body.Dispose(); _body = null; } + _body = LoadBody(); + MapArea.QueueDraw(); + } + + private Gdk.Pixbuf LoadBody() + { + string stem = BodyStem(); + string full = SoundWindow.AppFile(Path.Combine("map", stem + ".png")); + + if (!File.Exists(full)) + { + Logger.Write("body", "not found: map/" + stem + ".png"); + ListFolderOnce("map"); + return null; + } + + return Load(full); + // string type = (BodyJacketRadio != null && BodyJacketRadio.Active) ? "jacket" : "skel"; + // string size = (JacketSizeCombo != null ? JacketSizeCombo.ActiveText : null) ?? "L"; + + // // Adjust to your actual body/ filenames. + // string rel = Path.Combine("body", type + "_" + _view + "_" + size + ".png"); + // return Load(SoundWindow.AppFile(rel)); + } + private static void ListFolderOnce(string rel) + { + if (_listed) return; + _listed = true; + + string dir = SoundWindow.AppFile(rel); + if (!Directory.Exists(dir)) { Logger.Write("body", "folder missing: " + dir); return; } + + foreach (string f in Directory.GetFiles(dir)) + Logger.Write("body", "found: " + Path.GetFileName(f)); + } + + private string BodyStem() + { + bool skeleton = (BodySkeletonRadio != null && BodySkeletonRadio.Active); + + if (skeleton) + { + switch (_view) + { + case "back": return "jacketBackBody"; + case "right": return "jacketRightBody"; + case "left": return "jacketLeftBody"; + default: return "jacketFrontBody"; + } + } + + switch (_view) + { + case "back": return "jacketBack"; + case "right": return "jacketRight"; + case "left": return "jacketLeft"; + default: return "jacketFront"; + } + } + + private string MapPathForView() + { + if (_case == null) return null; + + string rel; + switch (_view) + { + case "back": rel = _case.MapBack; break; + case "right": rel = _case.MapRight; break; + case "left": rel = _case.MapLeft; break; + default: rel = _case.MapFront; break; + } + + // Form2 falls back to a mirrored front map when there's no dedicated back. + if (string.IsNullOrEmpty(rel)) rel = _case.MapFront; + + return string.IsNullOrEmpty(rel) ? null : SoundWindow.AppFile(rel); + } + + private static Gdk.Pixbuf Load(string path) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + { + Logger.Write("map", "image not found: " + path); + return null; + } + + try { return new Gdk.Pixbuf(path); } + catch (Exception ex) + { + Logger.Write("map", "load failed " + path + ": " + ex.Message); + return null; + } + } + + private static Gdk.Pixbuf LoadMap(string path) + { + var raw = Load(path); + if (raw == null) return null; + + // Key out black FIRST, then scale. Scaling first leaves near-black + // edge pixels (1,0,2) that AddAlpha won't match — that's the dark halo. + var keyed = raw.AddAlpha(true, 0, 0, 0); + raw.Dispose(); + + var sized = keyed.ScaleSimple(MapSize, MapSize, Gdk.InterpType.Bilinear); + keyed.Dispose(); + return sized; + } + + // ── plumbing ─────────────────────────────────────────────────────── + private void OnBackClicked(object sender, EventArgs e) { StopSession(); @@ -75,59 +435,6 @@ if (h != null) h(this, EventArgs.Empty); } - private void OnMapDrawn(object o, DrawnArgs args) - { - var cr = args.Cr; - int w = MapArea.AllocatedWidth; - int h = MapArea.AllocatedHeight; - - DrawFitted(cr, _body, w, h, 1.0); - DrawFitted(cr, _map, w, h, MapAlpha); - - args.RetVal = true; - } - - private static void DrawFitted(Cairo.Context cr, Gdk.Pixbuf pb, int w, int h, double alpha) - { - if (pb == null) return; - - double s = Math.Min((double)w / pb.Width, (double)h / pb.Height); - cr.Save(); - cr.Translate((w - pb.Width * s) / 2, (h - pb.Height * s) / 2); - cr.Scale(s, s); - Gdk.CairoHelper.SetSourcePixbuf(cr, pb, 0, 0); - cr.PaintWithAlpha(alpha); - cr.Restore(); - } - - private static Gdk.Pixbuf LoadPixbuf(string path, bool keyOutBlack) - { - if (string.IsNullOrEmpty(path) || !File.Exists(path)) - { - // Console.WriteLine("[MapWindow] image not found: " + path); - Logger.Write("map", "image not found: " + path); - - return null; - } - - try - { - var pb = new Gdk.Pixbuf(path); - if (!keyOutBlack) return pb; - - var keyed = pb.AddAlpha(true, 0, 0, 0); // == MakeTransparent(Color.Black) - pb.Dispose(); - return keyed; - } - catch (Exception ex) - { - // Console.WriteLine("[MapWindow] load failed " + path + ": " + ex.Message); - Logger.Write("map", "image not found: " + path); - - return null; - } - } - private void DisposePixbufs() { if (_body != null) { _body.Dispose(); _body = null; } @@ -139,4 +446,9 @@ _sound.Dispose(); DisposePixbufs(); } + + private string JacketSize + { + get { return (JacketSizeCombo != null ? JacketSizeCombo.ActiveText : null) ?? "L"; } + } } \ No newline at end of file diff --git a/program/SoundLooper.cs b/program/SoundLooper.cs new file mode 100644 index 0000000..311d4b6 --- /dev/null +++ b/program/SoundLooper.cs @@ -0,0 +1,165 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Media; +using System.Threading; + +public class SoundLooper : IDisposable +{ + // ── unix backend ── + private Process _proc; + private Thread _thread; + private volatile bool _running; + private string _path; + + // ── windows backend ── + private SoundPlayer _player; + + public event Action Log; + + public bool IsPlaying { get { return _running; } } + + private static bool IsWindows + { + get + { + var p = Environment.OSVersion.Platform; + return p != PlatformID.Unix && p != PlatformID.MacOSX; + } + } + + public void Start(string wavPath) + { + Stop(); + + if (string.IsNullOrEmpty(wavPath) || !File.Exists(wavPath)) + { + Emit("Sound file not found: " + wavPath); + return; + } + + _path = wavPath; + + if (IsWindows) StartWindows(); + else StartUnix(); + } + + public void Stop() + { + if (!_running) return; + _running = false; + + if (_player != null) + { + try { _player.Stop(); } catch { } + try { _player.Dispose(); } catch { } + _player = null; + } + + try + { + var p = _proc; + if (p != null && !p.HasExited) p.Kill(); + } + catch { } + _proc = null; + + if (_thread != null && _thread.IsAlive) _thread.Join(500); + _thread = null; + + Emit("Looping stopped"); + } + + // ── windows: SoundPlayer loops natively, no thread needed ────────── + + private void StartWindows() + { + try + { + _player = new SoundPlayer(_path); + _player.Load(); // throws here if the WAV isn't plain PCM + _player.PlayLooping(); // gapless, runs until Stop() + _running = true; + Emit("Looping started (SoundPlayer): " + _path); + } + catch (Exception ex) + { + Emit("SoundPlayer failed for " + _path + ": " + ex.Message); + _player = null; + _running = false; + } + } + + // ── unix: respawn a CLI player each pass ─────────────────────────── + + private void StartUnix() + { + _running = true; + _thread = new Thread(LoopWorker); + _thread.IsBackground = true; + _thread.Start(); + Emit("Looping started (" + PlayerCommand() + "): " + _path); + } + + private void LoopWorker() + { + while (_running) + { + try + { + var psi = new ProcessStartInfo + { + FileName = PlayerCommand(), + Arguments = "\"" + _path + "\"", + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + CreateNoWindow = true + }; + + _proc = Process.Start(psi); + _proc.WaitForExit(); + } + catch (Exception ex) + { + Emit("Playback error: " + ex.Message); + _running = false; + return; + } + } + } + + private static string _player_cmd; + private static string PlayerCommand() + { + if (_player_cmd != null) return _player_cmd; + _player_cmd = Exists("paplay") ? "paplay" : "aplay"; + return _player_cmd; + } + + private static bool Exists(string cmd) + { + try + { + var p = Process.Start(new ProcessStartInfo + { + FileName = "which", + Arguments = cmd, + UseShellExecute = false, + RedirectStandardOutput = true + }); + p.WaitForExit(); + return p.ExitCode == 0; + } + catch { return false; } + } + + private void Emit(string msg) + { + var h = Log; + if (h != null) h(msg); + else Console.WriteLine("[SoundLooper] " + msg); + } + + public void Dispose() { Stop(); } +} \ No newline at end of file diff --git a/program/SoundLopper.cs b/program/SoundLopper.cs deleted file mode 100644 index 311d4b6..0000000 --- a/program/SoundLopper.cs +++ /dev/null @@ -1,165 +0,0 @@ -using System; -using System.Diagnostics; -using System.IO; -using System.Media; -using System.Threading; - -public class SoundLooper : IDisposable -{ - // ── unix backend ── - private Process _proc; - private Thread _thread; - private volatile bool _running; - private string _path; - - // ── windows backend ── - private SoundPlayer _player; - - public event Action Log; - - public bool IsPlaying { get { return _running; } } - - private static bool IsWindows - { - get - { - var p = Environment.OSVersion.Platform; - return p != PlatformID.Unix && p != PlatformID.MacOSX; - } - } - - public void Start(string wavPath) - { - Stop(); - - if (string.IsNullOrEmpty(wavPath) || !File.Exists(wavPath)) - { - Emit("Sound file not found: " + wavPath); - return; - } - - _path = wavPath; - - if (IsWindows) StartWindows(); - else StartUnix(); - } - - public void Stop() - { - if (!_running) return; - _running = false; - - if (_player != null) - { - try { _player.Stop(); } catch { } - try { _player.Dispose(); } catch { } - _player = null; - } - - try - { - var p = _proc; - if (p != null && !p.HasExited) p.Kill(); - } - catch { } - _proc = null; - - if (_thread != null && _thread.IsAlive) _thread.Join(500); - _thread = null; - - Emit("Looping stopped"); - } - - // ── windows: SoundPlayer loops natively, no thread needed ────────── - - private void StartWindows() - { - try - { - _player = new SoundPlayer(_path); - _player.Load(); // throws here if the WAV isn't plain PCM - _player.PlayLooping(); // gapless, runs until Stop() - _running = true; - Emit("Looping started (SoundPlayer): " + _path); - } - catch (Exception ex) - { - Emit("SoundPlayer failed for " + _path + ": " + ex.Message); - _player = null; - _running = false; - } - } - - // ── unix: respawn a CLI player each pass ─────────────────────────── - - private void StartUnix() - { - _running = true; - _thread = new Thread(LoopWorker); - _thread.IsBackground = true; - _thread.Start(); - Emit("Looping started (" + PlayerCommand() + "): " + _path); - } - - private void LoopWorker() - { - while (_running) - { - try - { - var psi = new ProcessStartInfo - { - FileName = PlayerCommand(), - Arguments = "\"" + _path + "\"", - UseShellExecute = false, - RedirectStandardError = true, - RedirectStandardOutput = true, - CreateNoWindow = true - }; - - _proc = Process.Start(psi); - _proc.WaitForExit(); - } - catch (Exception ex) - { - Emit("Playback error: " + ex.Message); - _running = false; - return; - } - } - } - - private static string _player_cmd; - private static string PlayerCommand() - { - if (_player_cmd != null) return _player_cmd; - _player_cmd = Exists("paplay") ? "paplay" : "aplay"; - return _player_cmd; - } - - private static bool Exists(string cmd) - { - try - { - var p = Process.Start(new ProcessStartInfo - { - FileName = "which", - Arguments = cmd, - UseShellExecute = false, - RedirectStandardOutput = true - }); - p.WaitForExit(); - return p.ExitCode == 0; - } - catch { return false; } - } - - private void Emit(string msg) - { - var h = Log; - if (h != null) h(msg); - else Console.WriteLine("[SoundLooper] " + msg); - } - - public void Dispose() { Stop(); } -} \ No newline at end of file