osu-play 1.0.7 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +93 -39
  2. package/dist/cli.cjs +1579 -0
  3. package/dist/cli.cjs.map +25 -0
  4. package/dist/cli.d.cts +2 -0
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +1569 -0
  7. package/dist/cli.js.map +25 -0
  8. package/dist/core/cli/main.d.ts +20 -0
  9. package/dist/core/lazer/compat.d.ts +24 -0
  10. package/dist/core/lazer/mod.d.ts +10 -0
  11. package/dist/core/lazer/schema/beatmap.d.ts +35 -0
  12. package/dist/core/lazer/schema/beatmapMetadata.d.ts +16 -0
  13. package/dist/core/lazer/schema/beatmapSet.d.ts +18 -0
  14. package/{src/realm/schema/mod.ts → dist/core/lazer/schema/mod.d.ts} +0 -1
  15. package/dist/core/lazer/schema/realmFile.d.ts +6 -0
  16. package/dist/core/lazer/schema/realmNamedFileUsage.d.ts +9 -0
  17. package/dist/core/lazer/schema/realmUser.d.ts +9 -0
  18. package/dist/core/player/mod.d.ts +3 -0
  19. package/dist/core/player/mpv.d.ts +37 -0
  20. package/dist/core/player/session.d.ts +45 -0
  21. package/dist/core/player/types.d.ts +46 -0
  22. package/dist/core/playlist/mod.d.ts +28 -0
  23. package/dist/core/tui/player-screen.d.ts +26 -0
  24. package/dist/core/utils/mod.d.ts +16 -0
  25. package/dist/index.cjs +529 -0
  26. package/dist/index.cjs.map +21 -0
  27. package/dist/index.d.cts +4 -0
  28. package/dist/index.d.ts +4 -0
  29. package/dist/index.js +512 -0
  30. package/dist/index.js.map +21 -0
  31. package/package.json +54 -25
  32. package/bin/osu-play.js +0 -7
  33. package/index.ts +0 -1
  34. package/src/cli/main.ts +0 -156
  35. package/src/mod.ts +0 -2
  36. package/src/realm/mod.ts +0 -54
  37. package/src/realm/schema/beatmap.ts +0 -85
  38. package/src/realm/schema/beatmapMetadata.ts +0 -33
  39. package/src/realm/schema/beatmapSet.ts +0 -36
  40. package/src/realm/schema/realmFile.ts +0 -15
  41. package/src/realm/schema/realmNamedFileUsage.ts +0 -19
  42. package/src/realm/schema/realmUser.ts +0 -21
  43. package/src/utils/mod.ts +0 -77
  44. package/tsconfig.json +0 -109
package/dist/cli.js ADDED
@@ -0,0 +1,1569 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ var __create = Object.create;
4
+ var __getProtoOf = Object.getPrototypeOf;
5
+ var __defProp = Object.defineProperty;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __toESM = (mod, isNodeMode, target) => {
9
+ target = mod != null ? __create(__getProtoOf(mod)) : {};
10
+ const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
11
+ for (let key of __getOwnPropNames(mod))
12
+ if (!__hasOwnProp.call(to, key))
13
+ __defProp(to, key, {
14
+ get: () => mod[key],
15
+ enumerable: true
16
+ });
17
+ return to;
18
+ };
19
+ var __export = (target, all) => {
20
+ for (var name in all)
21
+ __defProp(target, name, {
22
+ get: all[name],
23
+ enumerable: true,
24
+ configurable: true,
25
+ set: (newValue) => all[name] = () => newValue
26
+ });
27
+ };
28
+ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
29
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
30
+
31
+ // src/core/utils/mod.ts
32
+ import path from "node:path";
33
+ import { existsSync } from "node:fs";
34
+ function getDataDir() {
35
+ switch (process.platform) {
36
+ case "linux":
37
+ case "openbsd":
38
+ case "freebsd": {
39
+ const xdg = process.env.XDG_DATA_HOME;
40
+ if (xdg)
41
+ return xdg;
42
+ const home = process.env.HOME;
43
+ if (home)
44
+ return path.join(home, ".local", "share");
45
+ break;
46
+ }
47
+ case "darwin": {
48
+ const home = process.env.HOME;
49
+ if (home)
50
+ return path.join(home, "Library", "Application Support");
51
+ break;
52
+ }
53
+ case "win32":
54
+ return process.env.LOCALAPPDATA ?? undefined;
55
+ }
56
+ return;
57
+ }
58
+ function getConfigDir() {
59
+ switch (process.platform) {
60
+ case "openbsd":
61
+ case "freebsd":
62
+ case "linux": {
63
+ const xdg = process.env.XDG_CONFIG_HOME;
64
+ if (xdg)
65
+ return xdg;
66
+ const home = process.env.HOME;
67
+ if (home)
68
+ return path.join(home, ".config");
69
+ break;
70
+ }
71
+ case "darwin": {
72
+ const home = process.env.HOME;
73
+ if (home)
74
+ return path.join(home, "Library", "Preferences");
75
+ break;
76
+ }
77
+ case "win32":
78
+ return process.env.APPDATA ?? undefined;
79
+ }
80
+ return;
81
+ }
82
+ function getDefaultOsuDataDir() {
83
+ return path.join(getDataDir() ?? ".", "osu");
84
+ }
85
+ function getDefaultConfigPath(appName = "osu-play") {
86
+ return path.join(getConfigDir() ?? ".", appName);
87
+ }
88
+ function hashedFilePath(hash, osuDataDir = getDefaultOsuDataDir()) {
89
+ return path.join(osuDataDir, "files", hash.slice(0, 1), hash.slice(0, 2), hash);
90
+ }
91
+ function getRealmDBPath(legacyAppConfigDirOrOptions = {}, maybeOptions = {}) {
92
+ const options = typeof legacyAppConfigDirOrOptions === "string" ? maybeOptions : legacyAppConfigDirOrOptions;
93
+ const osuDataDir = options.osuDataDir ?? getDefaultOsuDataDir();
94
+ const osuDBPath = path.join(osuDataDir, "client.realm");
95
+ if (!existsSync(osuDBPath)) {
96
+ console.log(`[getRealmDBPath]: ${osuDBPath} not found`);
97
+ return null;
98
+ }
99
+ return osuDBPath;
100
+ }
101
+ var init_mod = () => {};
102
+
103
+ // src/core/lazer/compat.ts
104
+ function describeRequirement(requirement) {
105
+ if (requirement.type === "object" || requirement.type === "list") {
106
+ return `${requirement.type}:${requirement.objectType ?? "unknown"}`;
107
+ }
108
+ return requirement.type ?? "unknown";
109
+ }
110
+ function describeProperty(property) {
111
+ if (!property?.type) {
112
+ return "missing";
113
+ }
114
+ if (property.type === "object" || property.type === "list") {
115
+ return `${property.type}:${property.objectType ?? "unknown"}`;
116
+ }
117
+ return property.type;
118
+ }
119
+ function inspectLazerSchemaEntries(schemaEntries, version) {
120
+ const schemaByName = new Map(schemaEntries.map((entry) => [entry.name, entry]));
121
+ const missingObjects = [];
122
+ const issues = [];
123
+ for (const [objectName, requirements] of Object.entries(REQUIRED_PLAYLIST_SCHEMA)) {
124
+ const schemaEntry = schemaByName.get(objectName);
125
+ if (!schemaEntry) {
126
+ missingObjects.push(objectName);
127
+ continue;
128
+ }
129
+ for (const [propertyName, requirement] of Object.entries(requirements)) {
130
+ const property = schemaEntry.properties[propertyName];
131
+ const typeMatches = !requirement.type || property?.type === requirement.type;
132
+ const objectTypeMatches = !requirement.objectType || property?.objectType === requirement.objectType;
133
+ if (!typeMatches || !objectTypeMatches) {
134
+ issues.push({
135
+ objectName,
136
+ propertyName,
137
+ expected: describeRequirement(requirement),
138
+ actual: describeProperty(property)
139
+ });
140
+ }
141
+ }
142
+ }
143
+ return {
144
+ compatible: missingObjects.length === 0 && issues.length === 0,
145
+ version,
146
+ missingObjects,
147
+ issues
148
+ };
149
+ }
150
+ function formatLazerSchemaCompatibilityError(report) {
151
+ const details = [
152
+ `Detected osu!lazer schema version ${report.version}, but the playlist reader is missing fields it needs.`
153
+ ];
154
+ if (report.missingObjects.length > 0) {
155
+ details.push(`Missing objects: ${report.missingObjects.join(", ")}`);
156
+ }
157
+ if (report.issues.length > 0) {
158
+ details.push(`Mismatched properties: ${report.issues.map((issue) => `${issue.objectName}.${issue.propertyName} expected ${issue.expected}, got ${issue.actual}`).join("; ")}`);
159
+ }
160
+ details.push("This app now reflects the live lazer schema and only depends on a small playlist-safe subset, so version bumps are fine until those required fields change.");
161
+ return details.join(`
162
+ `);
163
+ }
164
+ var LAST_STATIC_LAZER_SCHEMA_VERSION = 46, REQUIRED_PLAYLIST_SCHEMA;
165
+ var init_compat = __esm(() => {
166
+ REQUIRED_PLAYLIST_SCHEMA = {
167
+ BeatmapSet: {
168
+ Beatmaps: { type: "list", objectType: "Beatmap" },
169
+ Files: { type: "list", objectType: "RealmNamedFileUsage" }
170
+ },
171
+ Beatmap: {
172
+ Metadata: { type: "object", objectType: "BeatmapMetadata" }
173
+ },
174
+ BeatmapMetadata: {
175
+ AudioFile: { type: "string" },
176
+ Title: { type: "string" },
177
+ Artist: { type: "string" },
178
+ TitleUnicode: { type: "string" },
179
+ ArtistUnicode: { type: "string" }
180
+ },
181
+ RealmNamedFileUsage: {
182
+ File: { type: "object", objectType: "File" },
183
+ Filename: { type: "string" }
184
+ },
185
+ File: {
186
+ Hash: { type: "string" }
187
+ }
188
+ };
189
+ });
190
+
191
+ // src/core/lazer/schema/beatmap.ts
192
+ import Realm from "realm";
193
+ var Beatmap;
194
+ var init_beatmap = __esm(() => {
195
+ Beatmap = class Beatmap extends Realm.Object {
196
+ ID;
197
+ DifficultyName;
198
+ Metadata;
199
+ BeatmapSet;
200
+ Status;
201
+ OnlineID;
202
+ Length;
203
+ BPM;
204
+ Hash;
205
+ StarRating;
206
+ MD5Hash;
207
+ OnlineMD5Hash;
208
+ LastLocalUpdate;
209
+ LastOnlineUpdate;
210
+ Hidden;
211
+ AudioLeadIn;
212
+ StackLeniency;
213
+ SpecialStyle;
214
+ LetterboxInBreaks;
215
+ WidescreenStoryboard;
216
+ EpilepsyWarning;
217
+ SamplesMatchPlaybackRate;
218
+ LastPlayed;
219
+ DistanceSpacing;
220
+ BeatDivisor;
221
+ GridSize;
222
+ TimelineZoom;
223
+ EditorTimestamp;
224
+ CountdownOffset;
225
+ static schema = {
226
+ name: "Beatmap",
227
+ primaryKey: "ID",
228
+ properties: {
229
+ ID: { type: "uuid", default: "" },
230
+ DifficultyName: { type: "string", default: "", optional: true },
231
+ Metadata: {
232
+ type: "object",
233
+ objectType: "BeatmapMetadata",
234
+ default: null
235
+ },
236
+ BeatmapSet: { type: "object", objectType: "BeatmapSet", default: null },
237
+ Status: { type: "int", default: -3 },
238
+ OnlineID: { type: "int", default: -1 },
239
+ Length: { type: "double", default: 0 },
240
+ BPM: { type: "double", default: 0 },
241
+ Hash: { type: "string", default: "", optional: true },
242
+ StarRating: { type: "double", default: -1 },
243
+ MD5Hash: { type: "string", default: "", optional: true },
244
+ OnlineMD5Hash: { type: "string", default: "", optional: true },
245
+ LastLocalUpdate: { type: "date", optional: true },
246
+ LastOnlineUpdate: { type: "date", optional: true },
247
+ Hidden: { type: "bool", default: false },
248
+ AudioLeadIn: { type: "double", default: 0 },
249
+ StackLeniency: { type: "float", default: 0.7 },
250
+ SpecialStyle: { type: "bool", default: false },
251
+ LetterboxInBreaks: { type: "bool", default: false },
252
+ WidescreenStoryboard: { type: "bool", default: false },
253
+ EpilepsyWarning: { type: "bool", default: false },
254
+ SamplesMatchPlaybackRate: { type: "bool", default: false },
255
+ LastPlayed: { type: "date", optional: true },
256
+ DistanceSpacing: { type: "double", default: 0 },
257
+ BeatDivisor: { type: "int", default: 0 },
258
+ GridSize: { type: "int", default: 0 },
259
+ TimelineZoom: { type: "double", default: 0 },
260
+ EditorTimestamp: { type: "double", optional: true },
261
+ CountdownOffset: { type: "int", default: 0 }
262
+ }
263
+ };
264
+ };
265
+ });
266
+
267
+ // src/core/lazer/schema/beatmapMetadata.ts
268
+ import Realm2 from "realm";
269
+ var BeatmapMetadata;
270
+ var init_beatmapMetadata = __esm(() => {
271
+ BeatmapMetadata = class BeatmapMetadata extends Realm2.Object {
272
+ Title;
273
+ TitleUnicode;
274
+ Artist;
275
+ ArtistUnicode;
276
+ Author;
277
+ Source;
278
+ Tags;
279
+ PreviewTime;
280
+ AudioFile;
281
+ BackgroundFile;
282
+ static schema = {
283
+ name: "BeatmapMetadata",
284
+ properties: {
285
+ Title: { type: "string", default: "", optional: true },
286
+ TitleUnicode: { type: "string", default: "", optional: true },
287
+ Artist: { type: "string", default: "", optional: true },
288
+ ArtistUnicode: { type: "string", default: "", optional: true },
289
+ Author: { type: "object", objectType: "RealmUser", default: null },
290
+ Source: { type: "string", default: "", optional: true },
291
+ Tags: { type: "string", default: "", optional: true },
292
+ PreviewTime: { type: "int", default: 0 },
293
+ AudioFile: { type: "string", default: "", optional: true },
294
+ BackgroundFile: { type: "string", default: "", optional: true }
295
+ }
296
+ };
297
+ };
298
+ });
299
+
300
+ // src/core/lazer/schema/beatmapSet.ts
301
+ import Realm3 from "realm";
302
+ var BeatmapSet;
303
+ var init_beatmapSet = __esm(() => {
304
+ BeatmapSet = class BeatmapSet extends Realm3.Object {
305
+ ID;
306
+ OnlineID;
307
+ DateAdded;
308
+ DateSubmitted;
309
+ DateRanked;
310
+ Beatmaps;
311
+ Files;
312
+ Status;
313
+ DeletePending;
314
+ Hash;
315
+ Protected;
316
+ static schema = {
317
+ name: "BeatmapSet",
318
+ primaryKey: "ID",
319
+ properties: {
320
+ ID: { type: "uuid", default: "" },
321
+ OnlineID: { type: "int", default: -1 },
322
+ DateAdded: { type: "date" },
323
+ DateSubmitted: { type: "date", optional: true },
324
+ DateRanked: { type: "date", optional: true },
325
+ Beatmaps: { type: "list", objectType: "Beatmap", default: [] },
326
+ Files: { type: "list", objectType: "RealmNamedFileUsage", default: [] },
327
+ Status: { type: "int", default: -3 },
328
+ DeletePending: { type: "bool", default: false },
329
+ Hash: { type: "string", default: "", optional: true },
330
+ Protected: { type: "bool", default: false }
331
+ }
332
+ };
333
+ };
334
+ });
335
+
336
+ // src/core/lazer/schema/realmFile.ts
337
+ import Realm4 from "realm";
338
+ var RealmFile;
339
+ var init_realmFile = __esm(() => {
340
+ RealmFile = class RealmFile extends Realm4.Object {
341
+ Hash;
342
+ static schema = {
343
+ name: "File",
344
+ primaryKey: "Hash",
345
+ properties: {
346
+ Hash: { type: "string", default: "", optional: true }
347
+ }
348
+ };
349
+ };
350
+ });
351
+
352
+ // src/core/lazer/schema/realmUser.ts
353
+ import Realm5 from "realm";
354
+ var RealmUser;
355
+ var init_realmUser = __esm(() => {
356
+ RealmUser = class RealmUser extends Realm5.Object {
357
+ OnlineID;
358
+ Username;
359
+ CountryCode;
360
+ static embedded = true;
361
+ static schema = {
362
+ name: "RealmUser",
363
+ embedded: true,
364
+ properties: {
365
+ OnlineID: { type: "int", default: 1 },
366
+ Username: { type: "string", default: "", optional: true },
367
+ CountryCode: { type: "string", optional: true }
368
+ }
369
+ };
370
+ };
371
+ });
372
+
373
+ // src/core/lazer/schema/realmNamedFileUsage.ts
374
+ import Realm6 from "realm";
375
+ var RealmNamedFileUsage;
376
+ var init_realmNamedFileUsage = __esm(() => {
377
+ RealmNamedFileUsage = class RealmNamedFileUsage extends Realm6.Object {
378
+ File;
379
+ Filename;
380
+ static embedded = true;
381
+ static schema = {
382
+ name: "RealmNamedFileUsage",
383
+ embedded: true,
384
+ properties: {
385
+ File: "File",
386
+ Filename: { type: "string", optional: true }
387
+ }
388
+ };
389
+ };
390
+ });
391
+
392
+ // src/core/lazer/schema/mod.ts
393
+ var init_mod2 = __esm(() => {
394
+ init_beatmap();
395
+ init_beatmapMetadata();
396
+ init_beatmapSet();
397
+ init_realmFile();
398
+ init_realmUser();
399
+ init_realmNamedFileUsage();
400
+ });
401
+
402
+ // src/core/lazer/mod.ts
403
+ var exports_mod = {};
404
+ __export(exports_mod, {
405
+ inspectLazerSchemaEntries: () => inspectLazerSchemaEntries,
406
+ inspectLazerSchema: () => inspectLazerSchema,
407
+ hashedFilePath: () => hashedFilePath,
408
+ getNamedFileHash: () => getNamedFileHash2,
409
+ getLazerDB: () => getLazerDB,
410
+ getBeatmapSets: () => getBeatmapSets,
411
+ formatLazerSchemaCompatibilityError: () => formatLazerSchemaCompatibilityError,
412
+ RealmUser: () => RealmUser,
413
+ RealmNamedFileUsage: () => RealmNamedFileUsage,
414
+ RealmFile: () => RealmFile,
415
+ LAST_STATIC_LAZER_SCHEMA_VERSION: () => LAST_STATIC_LAZER_SCHEMA_VERSION,
416
+ BeatmapSet: () => BeatmapSet,
417
+ BeatmapMetadata: () => BeatmapMetadata,
418
+ Beatmap: () => Beatmap
419
+ });
420
+ import Realm7 from "realm";
421
+ function inspectLazerSchema(realm) {
422
+ return inspectLazerSchemaEntries(realm.schema, Realm7.schemaVersion(realm.path));
423
+ }
424
+ function getBeatmapSets(realm) {
425
+ return realm.objects("BeatmapSet");
426
+ }
427
+ var getLazerDB = async (realmDBPath) => {
428
+ return Realm7.open({
429
+ path: realmDBPath,
430
+ readOnly: true
431
+ });
432
+ }, getNamedFileHash2 = (fileName, beatmapSet) => {
433
+ const files = beatmapSet.Files;
434
+ for (const file of files) {
435
+ if (file.Filename == fileName)
436
+ return file.File.Hash;
437
+ }
438
+ return;
439
+ };
440
+ var init_mod3 = __esm(() => {
441
+ init_compat();
442
+ init_mod2();
443
+ init_compat();
444
+ init_mod();
445
+ });
446
+
447
+ // src/core/cli/main.ts
448
+ import path3 from "node:path";
449
+ import { writeFileSync } from "node:fs";
450
+ import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
451
+ import yargs from "yargs/yargs";
452
+ import { hideBin } from "yargs/helpers";
453
+
454
+ // src/core/playlist/mod.ts
455
+ init_mod();
456
+ function uniqueParts(parts) {
457
+ return [...new Set(parts.map((part) => part?.trim()).filter(Boolean))];
458
+ }
459
+ function formatTrackTitle(metadata) {
460
+ const title = uniqueParts([metadata.Title, metadata.TitleUnicode]).join(" / ");
461
+ const artist = uniqueParts([metadata.Artist, metadata.ArtistUnicode]).join(" / ");
462
+ return `${title || "Unknown Title"} - ${artist || "Unknown Artist"}`;
463
+ }
464
+ function getNamedFileHash(fileName, beatmapSet) {
465
+ for (const file of beatmapSet.Files) {
466
+ if (file.Filename === fileName && file.File?.Hash) {
467
+ return file.File.Hash;
468
+ }
469
+ }
470
+ return;
471
+ }
472
+ function buildPlaylist(beatmapSets, osuDataDir) {
473
+ const seenHashes = new Set;
474
+ const playlist = [];
475
+ for (const beatmapSet of beatmapSets) {
476
+ for (const beatmap of beatmapSet.Beatmaps) {
477
+ const hash = getNamedFileHash(beatmap.Metadata.AudioFile ?? "", beatmapSet);
478
+ if (!hash || seenHashes.has(hash)) {
479
+ continue;
480
+ }
481
+ seenHashes.add(hash);
482
+ playlist.push({
483
+ hash,
484
+ path: hashedFilePath(hash, osuDataDir),
485
+ title: formatTrackTitle(beatmap.Metadata)
486
+ });
487
+ }
488
+ }
489
+ return playlist;
490
+ }
491
+
492
+ // src/core/player/mpv.ts
493
+ import { spawn } from "node:child_process";
494
+ import { existsSync as existsSync2, rmSync } from "node:fs";
495
+ import net from "node:net";
496
+ import os from "node:os";
497
+ import path2 from "node:path";
498
+ import { setTimeout as delay } from "node:timers/promises";
499
+ function getErrorCode(error) {
500
+ if (error && typeof error === "object" && "code" in error) {
501
+ const { code } = error;
502
+ return typeof code === "string" ? code : null;
503
+ }
504
+ return null;
505
+ }
506
+ function isRetryableSocketError(error) {
507
+ const code = getErrorCode(error);
508
+ if (code) {
509
+ return code === "ENOENT" || code === "ECONNREFUSED" || code === "EPERM" || code === "EACCES";
510
+ }
511
+ const message = error instanceof Error ? error.message : String(error);
512
+ return message.includes("ENOENT") || message.includes("ECONNREFUSED") || message.includes("EPERM") || message.includes("EACCES");
513
+ }
514
+ function createSocketPath() {
515
+ const id = `osu-play-mpv-${process.pid}-${Date.now()}`;
516
+ if (process.platform === "win32") {
517
+ return `\\\\.\\pipe\\${id}`;
518
+ }
519
+ return path2.join(os.tmpdir(), `${id}.sock`);
520
+ }
521
+ function isControlText(value) {
522
+ return [...value].some((character) => {
523
+ const code = character.charCodeAt(0);
524
+ return code < 32 || code === 127 || code >= 128 && code <= 159;
525
+ });
526
+ }
527
+
528
+ class MpvPlayerBackend {
529
+ name = "mpv";
530
+ connectPromise = null;
531
+ disposePromise = null;
532
+ disposing = false;
533
+ listeners = new Set;
534
+ pendingCommands = new Map;
535
+ idleActive = true;
536
+ process = null;
537
+ requestId = 0;
538
+ responseBuffer = "";
539
+ snapshot = {
540
+ backendName: "mpv",
541
+ currentPath: null,
542
+ durationSeconds: null,
543
+ errorMessage: null,
544
+ status: "stopped",
545
+ timePositionSeconds: null
546
+ };
547
+ socket = null;
548
+ socketPath = createSocketPath();
549
+ stderrBuffer = "";
550
+ getSnapshot() {
551
+ return this.snapshot;
552
+ }
553
+ subscribe(listener) {
554
+ this.listeners.add(listener);
555
+ listener({
556
+ snapshot: this.snapshot,
557
+ type: "state"
558
+ });
559
+ return () => {
560
+ this.listeners.delete(listener);
561
+ };
562
+ }
563
+ async start() {
564
+ if (this.socket && !this.socket.destroyed) {
565
+ return;
566
+ }
567
+ if (this.connectPromise) {
568
+ return this.connectPromise;
569
+ }
570
+ this.connectPromise = this.startInternal();
571
+ try {
572
+ await this.connectPromise;
573
+ } finally {
574
+ this.connectPromise = null;
575
+ }
576
+ }
577
+ async play(filePath) {
578
+ await this.start();
579
+ const previousSnapshot = this.snapshot;
580
+ try {
581
+ await this.sendCommand(["loadfile", filePath, "replace"]);
582
+ await this.sendCommand(["set_property", "pause", false]);
583
+ } catch (error) {
584
+ this.snapshot = previousSnapshot;
585
+ this.emit({
586
+ snapshot: this.snapshot,
587
+ type: "state"
588
+ });
589
+ throw error;
590
+ }
591
+ this.snapshot = {
592
+ ...this.snapshot,
593
+ currentPath: filePath,
594
+ errorMessage: null,
595
+ status: this.idleActive ? "stopped" : "playing",
596
+ timePositionSeconds: this.snapshot.timePositionSeconds ?? 0
597
+ };
598
+ this.emit({
599
+ snapshot: this.snapshot,
600
+ type: "state"
601
+ });
602
+ }
603
+ async togglePause() {
604
+ await this.start();
605
+ await this.sendCommand(["cycle", "pause"]);
606
+ }
607
+ async seekBy(seconds) {
608
+ await this.start();
609
+ await this.sendCommand(["seek", seconds, "relative"]);
610
+ }
611
+ async stop() {
612
+ if (!this.socket || this.socket.destroyed) {
613
+ return;
614
+ }
615
+ await this.sendCommand(["stop"]);
616
+ this.idleActive = true;
617
+ this.snapshot = {
618
+ ...this.snapshot,
619
+ status: "stopped",
620
+ timePositionSeconds: null
621
+ };
622
+ this.emit({
623
+ snapshot: this.snapshot,
624
+ type: "state"
625
+ });
626
+ }
627
+ async dispose() {
628
+ if (this.disposePromise) {
629
+ return this.disposePromise;
630
+ }
631
+ this.disposePromise = this.disposeInternal();
632
+ try {
633
+ await this.disposePromise;
634
+ } finally {
635
+ this.disposePromise = null;
636
+ }
637
+ }
638
+ emit(event) {
639
+ for (const listener of this.listeners) {
640
+ listener(event);
641
+ }
642
+ }
643
+ async startInternal() {
644
+ this.cleanupSocketPath();
645
+ this.disposing = false;
646
+ this.stderrBuffer = "";
647
+ const processHandle = spawn("mpv", [
648
+ "--no-config",
649
+ "--no-terminal",
650
+ "--idle=yes",
651
+ "--force-window=no",
652
+ "--audio-display=no",
653
+ "--really-quiet",
654
+ `--input-ipc-server=${this.socketPath}`
655
+ ], {
656
+ stdio: ["ignore", "ignore", "pipe"]
657
+ });
658
+ this.process = processHandle;
659
+ processHandle.stderr.setEncoding("utf8");
660
+ processHandle.stderr.on("data", (chunk) => {
661
+ this.stderrBuffer = `${this.stderrBuffer}${chunk}`.slice(-4000);
662
+ });
663
+ processHandle.once("error", (error) => {
664
+ this.handleError(error);
665
+ });
666
+ processHandle.once("exit", (code, signal) => {
667
+ if (this.disposing) {
668
+ return;
669
+ }
670
+ const detail = this.stderrBuffer.trim();
671
+ const suffix = detail ? `
672
+ ${detail}` : "";
673
+ this.handleError(new Error(`mpv exited unexpectedly with ${signal ? `signal ${signal}` : `code ${code}`}.${suffix}`));
674
+ });
675
+ await this.connectSocket();
676
+ await Promise.all([
677
+ this.sendCommand(["observe_property", 1, "pause"]),
678
+ this.sendCommand(["observe_property", 2, "time-pos"]),
679
+ this.sendCommand(["observe_property", 3, "duration"]),
680
+ this.sendCommand(["observe_property", 4, "idle-active"])
681
+ ]);
682
+ }
683
+ async connectSocket() {
684
+ const start = Date.now();
685
+ const timeoutMs = 5000;
686
+ while (Date.now() - start < timeoutMs) {
687
+ if (!this.process || this.process.exitCode !== null) {
688
+ throw new Error(this.formatStartupError("mpv exited before opening its IPC socket."));
689
+ }
690
+ try {
691
+ await this.attachSocket();
692
+ return;
693
+ } catch (error) {
694
+ if (!isRetryableSocketError(error)) {
695
+ throw error;
696
+ }
697
+ }
698
+ await delay(50);
699
+ }
700
+ throw new Error(this.formatStartupError("Timed out waiting for mpv IPC to become ready."));
701
+ }
702
+ async attachSocket() {
703
+ await new Promise((resolve, reject) => {
704
+ const socket = net.createConnection(this.socketPath);
705
+ const onError = (error) => {
706
+ socket.destroy();
707
+ reject(error);
708
+ };
709
+ socket.once("error", onError);
710
+ socket.once("connect", () => {
711
+ socket.removeListener("error", onError);
712
+ socket.setEncoding("utf8");
713
+ socket.on("data", (chunk) => {
714
+ this.handleSocketData(chunk);
715
+ });
716
+ socket.on("error", (error) => {
717
+ if (!this.disposing) {
718
+ this.handleError(error);
719
+ }
720
+ });
721
+ socket.on("close", () => {
722
+ if (!this.disposing) {
723
+ this.handleError(new Error("mpv IPC socket closed unexpectedly."));
724
+ }
725
+ });
726
+ this.socket = socket;
727
+ resolve();
728
+ });
729
+ });
730
+ }
731
+ async sendCommand(command) {
732
+ const socket = this.socket;
733
+ if (!socket || socket.destroyed) {
734
+ throw new Error("mpv IPC socket is not connected.");
735
+ }
736
+ const requestId = ++this.requestId;
737
+ return new Promise((resolve, reject) => {
738
+ this.pendingCommands.set(requestId, { reject, resolve });
739
+ socket.write(`${JSON.stringify({ command, request_id: requestId })}
740
+ `, (error) => {
741
+ if (!error) {
742
+ return;
743
+ }
744
+ this.pendingCommands.delete(requestId);
745
+ reject(error);
746
+ });
747
+ });
748
+ }
749
+ handleSocketData(chunk) {
750
+ this.responseBuffer += chunk;
751
+ while (true) {
752
+ const newlineIndex = this.responseBuffer.indexOf(`
753
+ `);
754
+ if (newlineIndex === -1) {
755
+ return;
756
+ }
757
+ const line = this.responseBuffer.slice(0, newlineIndex).trim();
758
+ this.responseBuffer = this.responseBuffer.slice(newlineIndex + 1);
759
+ if (!line) {
760
+ continue;
761
+ }
762
+ try {
763
+ const response = JSON.parse(line);
764
+ this.handleResponse(response);
765
+ } catch (error) {
766
+ this.handleError(new Error(`Failed to parse mpv IPC message: ${error instanceof Error ? error.message : String(error)}`));
767
+ }
768
+ }
769
+ }
770
+ handleResponse(response) {
771
+ if (typeof response.request_id === "number") {
772
+ const pending = this.pendingCommands.get(response.request_id);
773
+ if (pending) {
774
+ this.pendingCommands.delete(response.request_id);
775
+ if (response.error && response.error !== "success") {
776
+ pending.reject(new Error(`mpv command failed: ${response.error}`));
777
+ } else {
778
+ pending.resolve(response.data);
779
+ }
780
+ }
781
+ return;
782
+ }
783
+ if (response.event === "property-change") {
784
+ this.applyPropertyChange(response.name, response.data);
785
+ return;
786
+ }
787
+ if (response.event === "end-file") {
788
+ this.snapshot = {
789
+ ...this.snapshot,
790
+ status: "stopped",
791
+ timePositionSeconds: this.snapshot.durationSeconds
792
+ };
793
+ this.emit({
794
+ snapshot: this.snapshot,
795
+ type: "state"
796
+ });
797
+ this.emit({
798
+ reason: response.reason ?? "unknown",
799
+ type: "ended"
800
+ });
801
+ }
802
+ }
803
+ applyPropertyChange(name, data) {
804
+ switch (name) {
805
+ case "pause":
806
+ this.snapshot = {
807
+ ...this.snapshot,
808
+ status: data === true ? "paused" : this.idleActive ? "stopped" : "playing"
809
+ };
810
+ break;
811
+ case "time-pos":
812
+ this.snapshot = {
813
+ ...this.snapshot,
814
+ timePositionSeconds: typeof data === "number" ? data : null
815
+ };
816
+ break;
817
+ case "duration":
818
+ this.snapshot = {
819
+ ...this.snapshot,
820
+ durationSeconds: typeof data === "number" ? data : null
821
+ };
822
+ break;
823
+ case "idle-active":
824
+ this.idleActive = data === true;
825
+ this.snapshot = {
826
+ ...this.snapshot,
827
+ status: data === true ? "stopped" : this.snapshot.status === "paused" ? "paused" : "playing",
828
+ timePositionSeconds: data === true ? this.snapshot.durationSeconds : this.snapshot.timePositionSeconds
829
+ };
830
+ break;
831
+ default:
832
+ return;
833
+ }
834
+ this.emit({
835
+ snapshot: this.snapshot,
836
+ type: "state"
837
+ });
838
+ }
839
+ handleError(error) {
840
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
841
+ this.snapshot = {
842
+ ...this.snapshot,
843
+ errorMessage: normalizedError.message,
844
+ status: "stopped"
845
+ };
846
+ this.emit({
847
+ error: normalizedError,
848
+ type: "error"
849
+ });
850
+ for (const [requestId, pending] of this.pendingCommands) {
851
+ this.pendingCommands.delete(requestId);
852
+ pending.reject(normalizedError);
853
+ }
854
+ }
855
+ async disposeInternal() {
856
+ this.disposing = true;
857
+ if (this.socket && !this.socket.destroyed) {
858
+ try {
859
+ await this.sendCommand(["quit"]);
860
+ } catch {}
861
+ }
862
+ this.socket?.destroy();
863
+ this.socket = null;
864
+ const processHandle = this.process;
865
+ this.process = null;
866
+ if (processHandle && processHandle.exitCode === null) {
867
+ processHandle.kill("SIGTERM");
868
+ await Promise.race([
869
+ new Promise((resolve) => {
870
+ processHandle.once("exit", () => resolve());
871
+ }),
872
+ delay(1000)
873
+ ]);
874
+ if (processHandle.exitCode === null) {
875
+ processHandle.kill("SIGKILL");
876
+ }
877
+ }
878
+ this.cleanupSocketPath();
879
+ }
880
+ cleanupSocketPath() {
881
+ if (process.platform === "win32") {
882
+ return;
883
+ }
884
+ if (existsSync2(this.socketPath)) {
885
+ rmSync(this.socketPath, { force: true });
886
+ }
887
+ }
888
+ formatStartupError(message) {
889
+ const stderr = this.stderrBuffer.trim();
890
+ const cleanStderr = stderr && !isControlText(stderr) ? `
891
+ ${stderr}` : "";
892
+ return [
893
+ message,
894
+ "osu-play now expects `mpv` to be installed and available on PATH for TUI playback.",
895
+ cleanStderr
896
+ ].filter(Boolean).join(`
897
+ `);
898
+ }
899
+ }
900
+ // src/core/player/session.ts
901
+ function clampIndex(index, length) {
902
+ if (length <= 0) {
903
+ return 0;
904
+ }
905
+ return Math.max(0, Math.min(index, length - 1));
906
+ }
907
+ function wrapIndex(index, length) {
908
+ if (length <= 0) {
909
+ return 0;
910
+ }
911
+ return (index % length + length) % length;
912
+ }
913
+ function normalizeSearchText(text) {
914
+ return text.trim().toLowerCase();
915
+ }
916
+ function findTrackIndexByQuery(playlist, query) {
917
+ const normalizedQuery = normalizeSearchText(query);
918
+ if (!normalizedQuery) {
919
+ return -1;
920
+ }
921
+ return playlist.findIndex((track) => normalizeSearchText(track.title).includes(normalizedQuery));
922
+ }
923
+
924
+ class PlaylistPlayerSession {
925
+ backend;
926
+ listeners = new Set;
927
+ currentIndex = null;
928
+ errorMessage = null;
929
+ loop;
930
+ playlist;
931
+ searchQuery = "";
932
+ selectedIndex = 0;
933
+ unsubscribeBackend;
934
+ constructor(playlist, backend, options = {}) {
935
+ this.backend = backend;
936
+ this.playlist = playlist;
937
+ this.loop = options.loop ?? false;
938
+ this.unsubscribeBackend = backend.subscribe((event) => {
939
+ this.handleBackendEvent(event);
940
+ });
941
+ }
942
+ getSnapshot() {
943
+ const backendSnapshot = this.backend.getSnapshot();
944
+ const currentTrack = this.currentIndex !== null ? this.playlist[this.currentIndex] ?? null : null;
945
+ return {
946
+ backendName: backendSnapshot.backendName,
947
+ currentIndex: this.currentIndex,
948
+ currentTrack,
949
+ durationSeconds: backendSnapshot.durationSeconds,
950
+ errorMessage: this.errorMessage ?? backendSnapshot.errorMessage,
951
+ loop: this.loop,
952
+ playlist: this.playlist,
953
+ searchQuery: this.searchQuery,
954
+ selectedIndex: this.selectedIndex,
955
+ status: backendSnapshot.status,
956
+ timePositionSeconds: backendSnapshot.timePositionSeconds
957
+ };
958
+ }
959
+ subscribe(listener) {
960
+ this.listeners.add(listener);
961
+ listener(this.getSnapshot());
962
+ return () => {
963
+ this.listeners.delete(listener);
964
+ };
965
+ }
966
+ async start() {
967
+ await this.backend.start();
968
+ this.emit();
969
+ }
970
+ async dispose() {
971
+ this.unsubscribeBackend();
972
+ await this.backend.dispose();
973
+ }
974
+ moveSelection(delta) {
975
+ if (this.playlist.length === 0) {
976
+ return;
977
+ }
978
+ this.selectedIndex = wrapIndex(this.selectedIndex + delta, this.playlist.length);
979
+ this.emit();
980
+ }
981
+ moveSelectionPage(delta, pageSize) {
982
+ this.moveSelection(delta * Math.max(1, pageSize));
983
+ }
984
+ selectHome() {
985
+ if (this.playlist.length === 0) {
986
+ return;
987
+ }
988
+ this.selectedIndex = 0;
989
+ this.emit();
990
+ }
991
+ selectEnd() {
992
+ if (this.playlist.length === 0) {
993
+ return;
994
+ }
995
+ this.selectedIndex = this.playlist.length - 1;
996
+ this.emit();
997
+ }
998
+ setSelectionIndex(index) {
999
+ if (this.playlist.length === 0) {
1000
+ return;
1001
+ }
1002
+ this.selectedIndex = clampIndex(index, this.playlist.length);
1003
+ this.emit();
1004
+ }
1005
+ toggleLoop() {
1006
+ this.loop = !this.loop;
1007
+ this.emit();
1008
+ }
1009
+ appendSearchQuery(text) {
1010
+ if (!text) {
1011
+ return;
1012
+ }
1013
+ this.searchQuery += text;
1014
+ this.syncSelectionToSearch();
1015
+ }
1016
+ deleteSearchCharacter() {
1017
+ if (!this.searchQuery) {
1018
+ return;
1019
+ }
1020
+ this.searchQuery = this.searchQuery.slice(0, -1);
1021
+ this.syncSelectionToSearch();
1022
+ }
1023
+ clearSearch() {
1024
+ if (!this.searchQuery) {
1025
+ return;
1026
+ }
1027
+ this.searchQuery = "";
1028
+ this.emit();
1029
+ }
1030
+ async playSelected() {
1031
+ await this.playIndex(this.selectedIndex);
1032
+ }
1033
+ async playNext() {
1034
+ const nextIndex = this.getAdjacentIndex(1);
1035
+ if (nextIndex === null) {
1036
+ return;
1037
+ }
1038
+ await this.playIndex(nextIndex);
1039
+ }
1040
+ async playPrevious() {
1041
+ const previousIndex = this.getAdjacentIndex(-1);
1042
+ if (previousIndex === null) {
1043
+ return;
1044
+ }
1045
+ await this.playIndex(previousIndex);
1046
+ }
1047
+ async togglePause() {
1048
+ if (this.playlist.length === 0) {
1049
+ return;
1050
+ }
1051
+ const { status } = this.backend.getSnapshot();
1052
+ if (status === "stopped") {
1053
+ const restartIndex = this.currentIndex ?? this.selectedIndex;
1054
+ await this.playIndex(restartIndex);
1055
+ return;
1056
+ }
1057
+ try {
1058
+ this.clearError();
1059
+ await this.backend.togglePause();
1060
+ } catch (error) {
1061
+ this.reportError(error);
1062
+ }
1063
+ }
1064
+ async seekBy(seconds) {
1065
+ if (seconds === 0) {
1066
+ return;
1067
+ }
1068
+ const { status } = this.backend.getSnapshot();
1069
+ if (status === "stopped") {
1070
+ return;
1071
+ }
1072
+ try {
1073
+ this.clearError();
1074
+ await this.backend.seekBy(seconds);
1075
+ } catch (error) {
1076
+ this.reportError(error);
1077
+ }
1078
+ }
1079
+ async stop() {
1080
+ try {
1081
+ this.clearError();
1082
+ await this.backend.stop();
1083
+ } catch (error) {
1084
+ this.reportError(error);
1085
+ }
1086
+ }
1087
+ reportError(error) {
1088
+ this.errorMessage = error instanceof Error ? error.message : String(error);
1089
+ this.emit();
1090
+ }
1091
+ clearError() {
1092
+ if (this.errorMessage === null) {
1093
+ return;
1094
+ }
1095
+ this.errorMessage = null;
1096
+ }
1097
+ emit() {
1098
+ const snapshot = this.getSnapshot();
1099
+ for (const listener of this.listeners) {
1100
+ listener(snapshot);
1101
+ }
1102
+ }
1103
+ async playIndex(index) {
1104
+ const track = this.playlist[index];
1105
+ if (!track) {
1106
+ return;
1107
+ }
1108
+ const previousIndex = this.currentIndex;
1109
+ this.currentIndex = index;
1110
+ try {
1111
+ this.clearError();
1112
+ await this.backend.play(track.path);
1113
+ } catch (error) {
1114
+ this.currentIndex = previousIndex;
1115
+ this.reportError(error);
1116
+ return;
1117
+ }
1118
+ this.emit();
1119
+ }
1120
+ getAdjacentIndex(delta) {
1121
+ if (this.playlist.length === 0) {
1122
+ return null;
1123
+ }
1124
+ const baseIndex = this.currentIndex ?? this.selectedIndex;
1125
+ const nextIndex = baseIndex + delta;
1126
+ if (nextIndex < 0) {
1127
+ return this.loop ? this.playlist.length - 1 : null;
1128
+ }
1129
+ if (nextIndex >= this.playlist.length) {
1130
+ return this.loop ? 0 : null;
1131
+ }
1132
+ return nextIndex;
1133
+ }
1134
+ async handleBackendEvent(event) {
1135
+ switch (event.type) {
1136
+ case "state":
1137
+ this.emit();
1138
+ return;
1139
+ case "error":
1140
+ this.reportError(event.error);
1141
+ return;
1142
+ case "ended":
1143
+ if (event.reason === "eof") {
1144
+ const nextIndex = this.getAdjacentIndex(1);
1145
+ if (nextIndex !== null) {
1146
+ await this.playIndex(nextIndex);
1147
+ return;
1148
+ }
1149
+ }
1150
+ this.emit();
1151
+ }
1152
+ }
1153
+ syncSelectionToSearch() {
1154
+ const matchedIndex = findTrackIndexByQuery(this.playlist, this.searchQuery);
1155
+ if (matchedIndex !== -1) {
1156
+ this.selectedIndex = matchedIndex;
1157
+ }
1158
+ this.emit();
1159
+ }
1160
+ }
1161
+ // src/core/tui/player-screen.ts
1162
+ import {
1163
+ decodeKittyPrintable,
1164
+ Key,
1165
+ matchesKey,
1166
+ truncateToWidth
1167
+ } from "@mariozechner/pi-tui";
1168
+ var RESET = "\x1B[0m";
1169
+ var DIM = "\x1B[2m";
1170
+ var RED = "\x1B[31m";
1171
+ var CYAN = "\x1B[36m";
1172
+ var INVERSE = "\x1B[7m";
1173
+ var MIN_LIST_ROWS = 4;
1174
+ var RESERVED_ROWS = 6;
1175
+ var PAGE_SIZE = 10;
1176
+ var PENDING_G_TIMEOUT_MS = 400;
1177
+ var SEEK_SECONDS = 5;
1178
+ function style(text, code) {
1179
+ return `${code}${text}${RESET}`;
1180
+ }
1181
+ function padIndex(index, total) {
1182
+ const width = String(Math.max(total, 1)).length;
1183
+ return String(index + 1).padStart(width, " ");
1184
+ }
1185
+ function formatSeconds(seconds) {
1186
+ if (seconds === null || Number.isNaN(seconds)) {
1187
+ return "--:--";
1188
+ }
1189
+ const rounded = Math.max(0, Math.floor(seconds));
1190
+ const minutes = Math.floor(rounded / 60);
1191
+ const remainingSeconds = rounded % 60;
1192
+ return `${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`;
1193
+ }
1194
+ function decodePrintableText(data) {
1195
+ const kittyPrintable = decodeKittyPrintable(data);
1196
+ if (kittyPrintable !== undefined) {
1197
+ return kittyPrintable;
1198
+ }
1199
+ const hasControlChars = [...data].some((character) => {
1200
+ const code = character.charCodeAt(0);
1201
+ return code < 32 || code === 127 || code >= 128 && code <= 159;
1202
+ });
1203
+ return hasControlChars ? undefined : data;
1204
+ }
1205
+ function getVisibleTrackRange(selectedIndex, totalTracks, maxVisible) {
1206
+ if (totalTracks <= 0 || maxVisible <= 0) {
1207
+ return { end: 0, start: 0 };
1208
+ }
1209
+ const clampedSelectedIndex = Math.max(0, Math.min(selectedIndex, totalTracks - 1));
1210
+ const start = Math.max(0, Math.min(clampedSelectedIndex - Math.floor(maxVisible / 2), totalTracks - maxVisible));
1211
+ return {
1212
+ end: Math.min(totalTracks, start + maxVisible),
1213
+ start
1214
+ };
1215
+ }
1216
+
1217
+ class PlaylistPlayerScreen {
1218
+ session;
1219
+ getViewportHeight;
1220
+ pendingGoToTop = false;
1221
+ pendingGoToTopTimer = null;
1222
+ searchMode = false;
1223
+ snapshot;
1224
+ onQuit;
1225
+ constructor(session, getViewportHeight) {
1226
+ this.session = session;
1227
+ this.getViewportHeight = getViewportHeight;
1228
+ this.snapshot = session.getSnapshot();
1229
+ }
1230
+ setSnapshot(snapshot) {
1231
+ this.snapshot = snapshot;
1232
+ }
1233
+ invalidate() {}
1234
+ handleInput(data) {
1235
+ if (this.searchMode) {
1236
+ this.handleSearchInput(data);
1237
+ return;
1238
+ }
1239
+ if (matchesKey(data, Key.slash)) {
1240
+ this.searchMode = true;
1241
+ return;
1242
+ }
1243
+ if (matchesKey(data, "g")) {
1244
+ if (this.pendingGoToTop) {
1245
+ this.clearPendingGoToTop();
1246
+ this.session.selectHome();
1247
+ } else {
1248
+ this.armPendingGoToTop();
1249
+ }
1250
+ return;
1251
+ }
1252
+ this.clearPendingGoToTop();
1253
+ if (matchesKey(data, Key.ctrl("c")) || matchesKey(data, "q")) {
1254
+ this.onQuit?.();
1255
+ return;
1256
+ }
1257
+ if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
1258
+ this.session.moveSelection(-1);
1259
+ return;
1260
+ }
1261
+ if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
1262
+ this.session.moveSelection(1);
1263
+ return;
1264
+ }
1265
+ if (matchesKey(data, Key.left) || matchesKey(data, "h")) {
1266
+ this.session.seekBy(-SEEK_SECONDS);
1267
+ return;
1268
+ }
1269
+ if (matchesKey(data, Key.right) || matchesKey(data, "l")) {
1270
+ this.session.seekBy(SEEK_SECONDS);
1271
+ return;
1272
+ }
1273
+ if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("u")) || matchesKey(data, Key.ctrl("b"))) {
1274
+ this.session.moveSelectionPage(-1, PAGE_SIZE);
1275
+ return;
1276
+ }
1277
+ if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("d")) || matchesKey(data, Key.ctrl("f"))) {
1278
+ this.session.moveSelectionPage(1, PAGE_SIZE);
1279
+ return;
1280
+ }
1281
+ if (matchesKey(data, Key.home) || matchesKey(data, "0")) {
1282
+ this.session.selectHome();
1283
+ return;
1284
+ }
1285
+ if (matchesKey(data, Key.end) || matchesKey(data, Key.shift("g"))) {
1286
+ this.session.selectEnd();
1287
+ return;
1288
+ }
1289
+ if (matchesKey(data, Key.shift("h"))) {
1290
+ this.moveSelectionToVisibleAnchor("top");
1291
+ return;
1292
+ }
1293
+ if (matchesKey(data, Key.shift("m"))) {
1294
+ this.moveSelectionToVisibleAnchor("middle");
1295
+ return;
1296
+ }
1297
+ if (matchesKey(data, Key.shift("l"))) {
1298
+ this.moveSelectionToVisibleAnchor("bottom");
1299
+ return;
1300
+ }
1301
+ if (matchesKey(data, Key.enter)) {
1302
+ this.session.playSelected();
1303
+ return;
1304
+ }
1305
+ if (matchesKey(data, Key.space)) {
1306
+ this.session.togglePause();
1307
+ return;
1308
+ }
1309
+ if (matchesKey(data, "n")) {
1310
+ this.session.playNext();
1311
+ return;
1312
+ }
1313
+ if (matchesKey(data, "p")) {
1314
+ this.session.playPrevious();
1315
+ return;
1316
+ }
1317
+ if (matchesKey(data, "s")) {
1318
+ this.session.stop();
1319
+ return;
1320
+ }
1321
+ if (matchesKey(data, "r")) {
1322
+ this.session.toggleLoop();
1323
+ return;
1324
+ }
1325
+ if (matchesKey(data, Key.backspace)) {
1326
+ this.session.deleteSearchCharacter();
1327
+ return;
1328
+ }
1329
+ if (matchesKey(data, Key.escape)) {
1330
+ this.session.clearSearch();
1331
+ return;
1332
+ }
1333
+ }
1334
+ render(width) {
1335
+ const { playlist } = this.snapshot;
1336
+ const viewportHeight = Math.max(this.getViewportHeight(), RESERVED_ROWS);
1337
+ const listHeight = this.getListHeight(viewportHeight);
1338
+ const { start, end } = getVisibleTrackRange(this.snapshot.selectedIndex, playlist.length, listHeight);
1339
+ const lines = [
1340
+ truncateToWidth(`osu-play | tracks ${playlist.length} | backend ${this.snapshot.backendName} | loop ${this.snapshot.loop ? "on" : "off"}`, width),
1341
+ truncateToWidth(style(`now ${this.snapshot.status}: ${this.snapshot.currentTrack?.title ?? "nothing selected"}`, CYAN), width),
1342
+ truncateToWidth(style(`${formatSeconds(this.snapshot.timePositionSeconds)} / ${formatSeconds(this.snapshot.durationSeconds)} | selected ${this.snapshot.selectedIndex + 1}/${Math.max(playlist.length, 1)}${this.snapshot.searchQuery ? ` | search "${this.snapshot.searchQuery}"${this.searchMode ? " [input]" : ""}` : this.searchMode ? " | search [input]" : ""}`, DIM), width)
1343
+ ];
1344
+ if (this.snapshot.errorMessage) {
1345
+ lines.push(truncateToWidth(style(`error: ${this.snapshot.errorMessage}`, RED), width));
1346
+ } else {
1347
+ lines.push("");
1348
+ }
1349
+ if (playlist.length === 0) {
1350
+ lines.push(truncateToWidth("No tracks were found in your osu!lazer library.", width));
1351
+ } else {
1352
+ for (let index = start;index < end; index += 1) {
1353
+ const track = playlist[index];
1354
+ if (!track) {
1355
+ continue;
1356
+ }
1357
+ const isCurrent = index === this.snapshot.currentIndex;
1358
+ const isSelected = index === this.snapshot.selectedIndex;
1359
+ const prefix = `${isSelected ? ">" : " "} ${isCurrent ? "*" : " "} `;
1360
+ const line = `${prefix}${padIndex(index, playlist.length)}. ${track.title}`;
1361
+ lines.push(truncateToWidth(isSelected ? style(line, INVERSE) : line, width));
1362
+ }
1363
+ }
1364
+ while (lines.length < viewportHeight - 1) {
1365
+ lines.push("");
1366
+ }
1367
+ lines.push(truncateToWidth(style(this.searchMode ? "/ search | type to jump | backspace edit | enter keep | esc leave" : "h/l seek | j/k wrap | gg/G bounds | C-u/C-d page | H/M/L viewport | n/p track | r loop | / search", DIM), width));
1368
+ return lines;
1369
+ }
1370
+ armPendingGoToTop() {
1371
+ this.pendingGoToTop = true;
1372
+ this.pendingGoToTopTimer = setTimeout(() => {
1373
+ this.pendingGoToTop = false;
1374
+ this.pendingGoToTopTimer = null;
1375
+ }, PENDING_G_TIMEOUT_MS);
1376
+ }
1377
+ clearPendingGoToTop() {
1378
+ if (this.pendingGoToTopTimer) {
1379
+ clearTimeout(this.pendingGoToTopTimer);
1380
+ this.pendingGoToTopTimer = null;
1381
+ }
1382
+ this.pendingGoToTop = false;
1383
+ }
1384
+ handleSearchInput(data) {
1385
+ if (matchesKey(data, Key.ctrl("c"))) {
1386
+ this.onQuit?.();
1387
+ return;
1388
+ }
1389
+ if (matchesKey(data, Key.enter)) {
1390
+ this.searchMode = false;
1391
+ return;
1392
+ }
1393
+ if (matchesKey(data, Key.escape)) {
1394
+ this.searchMode = false;
1395
+ return;
1396
+ }
1397
+ if (matchesKey(data, Key.backspace)) {
1398
+ this.session.deleteSearchCharacter();
1399
+ return;
1400
+ }
1401
+ if (matchesKey(data, Key.ctrl("u"))) {
1402
+ this.session.clearSearch();
1403
+ return;
1404
+ }
1405
+ const printable = decodePrintableText(data);
1406
+ if (!printable || printable === " ") {
1407
+ return;
1408
+ }
1409
+ this.session.appendSearchQuery(printable);
1410
+ }
1411
+ getListHeight(viewportHeight) {
1412
+ return Math.max(MIN_LIST_ROWS, viewportHeight - RESERVED_ROWS);
1413
+ }
1414
+ moveSelectionToVisibleAnchor(anchor) {
1415
+ if (this.snapshot.playlist.length === 0) {
1416
+ return;
1417
+ }
1418
+ const { start, end } = getVisibleTrackRange(this.snapshot.selectedIndex, this.snapshot.playlist.length, this.getListHeight(Math.max(this.getViewportHeight(), RESERVED_ROWS)));
1419
+ const lastVisibleIndex = Math.max(start, end - 1);
1420
+ switch (anchor) {
1421
+ case "top":
1422
+ this.session.setSelectionIndex(start);
1423
+ return;
1424
+ case "middle":
1425
+ this.session.setSelectionIndex(start + Math.floor((lastVisibleIndex - start) / 2));
1426
+ return;
1427
+ case "bottom":
1428
+ this.session.setSelectionIndex(lastVisibleIndex);
1429
+ }
1430
+ }
1431
+ }
1432
+
1433
+ // src/core/cli/main.ts
1434
+ init_mod();
1435
+ function formatRealmLoadError(error) {
1436
+ return [
1437
+ "Realm native bindings could not be loaded.",
1438
+ `Current Node runtime: ${process.version}.`,
1439
+ "To repair Realm bindings in this project, run `bun run repair:realm` (or `bun run setup` to reinstall dependencies).",
1440
+ "If you installed dependencies on Node 22, switch to Node 20 LTS and rerun the repair command.",
1441
+ `Original error: ${error instanceof Error ? error.message : String(error)}`
1442
+ ].join(`
1443
+ `);
1444
+ }
1445
+ async function loadRealmDependencies() {
1446
+ try {
1447
+ const [{ default: Realm8 }, lazerModule] = await Promise.all([
1448
+ import("realm"),
1449
+ Promise.resolve().then(() => (init_mod3(), exports_mod))
1450
+ ]);
1451
+ return {
1452
+ Realm: Realm8,
1453
+ ...lazerModule
1454
+ };
1455
+ } catch (error) {
1456
+ throw new Error(formatRealmLoadError(error), { cause: error });
1457
+ }
1458
+ }
1459
+ function getArgs() {
1460
+ return yargs(hideBin(process.argv)).scriptName("osu-play").usage(`Play music from your osu!lazer beatmaps in a minimal terminal player
1461
+ Usage: $0 [options]`).option("reload", {
1462
+ type: "boolean",
1463
+ default: false,
1464
+ alias: "r",
1465
+ describe: "Deprecated: ignored. osu-play now reads the live lazer database directly"
1466
+ }).option("exportPlaylist", {
1467
+ type: "string",
1468
+ describe: "Export playlist to a file instead of launching the player"
1469
+ }).option("osuDataDir", {
1470
+ type: "string",
1471
+ default: getDefaultOsuDataDir(),
1472
+ alias: "d",
1473
+ describe: "Osu!lazer data directory"
1474
+ }).option("configDir", {
1475
+ type: "string",
1476
+ alias: "c",
1477
+ describe: "Deprecated: ignored. osu-play no longer copies the lazer database"
1478
+ }).option("loop", {
1479
+ type: "boolean",
1480
+ default: false,
1481
+ alias: "l",
1482
+ describe: "Loop the playlist when playback reaches the end"
1483
+ }).alias("help", "h").help().parse();
1484
+ }
1485
+ async function createPlaylist(osuDataDir) {
1486
+ const realmDBPath = getRealmDBPath({ osuDataDir });
1487
+ if (!realmDBPath) {
1488
+ throw new Error("Realm DB not found");
1489
+ }
1490
+ const {
1491
+ Realm: Realm8,
1492
+ formatLazerSchemaCompatibilityError: formatLazerSchemaCompatibilityError2,
1493
+ getBeatmapSets: getBeatmapSets2,
1494
+ getLazerDB: getLazerDB2,
1495
+ inspectLazerSchema: inspectLazerSchema2
1496
+ } = await loadRealmDependencies();
1497
+ Realm8.flags.ALLOW_CLEAR_TEST_STATE = true;
1498
+ const realm = await getLazerDB2(realmDBPath);
1499
+ try {
1500
+ const schemaReport = inspectLazerSchema2(realm);
1501
+ if (!schemaReport.compatible) {
1502
+ throw new Error(formatLazerSchemaCompatibilityError2(schemaReport));
1503
+ }
1504
+ return buildPlaylist(getBeatmapSets2(realm), osuDataDir);
1505
+ } finally {
1506
+ realm.close();
1507
+ }
1508
+ }
1509
+ async function runTuiPlayer(playlist, loop) {
1510
+ const backend = new MpvPlayerBackend;
1511
+ const session = new PlaylistPlayerSession(playlist, backend, { loop });
1512
+ const terminal = new ProcessTerminal;
1513
+ const tui = new TUI(terminal);
1514
+ const screen = new PlaylistPlayerScreen(session, () => terminal.rows);
1515
+ let started = false;
1516
+ const exitPromise = new Promise((resolve) => {
1517
+ screen.onQuit = () => resolve();
1518
+ });
1519
+ const unsubscribe = session.subscribe((snapshot) => {
1520
+ screen.setSnapshot(snapshot);
1521
+ if (started) {
1522
+ tui.requestRender();
1523
+ }
1524
+ });
1525
+ try {
1526
+ await session.start();
1527
+ tui.addChild(screen);
1528
+ tui.setFocus(screen);
1529
+ tui.start();
1530
+ started = true;
1531
+ await exitPromise;
1532
+ } finally {
1533
+ unsubscribe();
1534
+ if (started) {
1535
+ await terminal.drainInput().catch(() => {});
1536
+ tui.stop();
1537
+ }
1538
+ await session.dispose();
1539
+ }
1540
+ }
1541
+ async function main() {
1542
+ const argv = await getArgs();
1543
+ if (argv.reload) {
1544
+ console.log("[INFO] `--reload` is deprecated and ignored because osu-play reads osu!lazer's live Realm DB directly.");
1545
+ }
1546
+ if (argv.configDir) {
1547
+ console.log("[INFO] `--configDir` is deprecated and ignored because osu-play no longer copies the Realm DB.");
1548
+ }
1549
+ const playlist = await createPlaylist(argv.osuDataDir);
1550
+ if (argv.exportPlaylist) {
1551
+ const playlistContents = playlist.map((track) => track.path).join(`
1552
+ `);
1553
+ writeFileSync(argv.exportPlaylist, playlistContents);
1554
+ console.log(`[INFO] Exported ${playlist.length} tracks to ${argv.exportPlaylist}.`);
1555
+ console.log(`[INFO] Use something like \`mpv --playlist=${path3.resolve(argv.exportPlaylist)}\` to play the playlist.`);
1556
+ return;
1557
+ }
1558
+ await runTuiPlayer(playlist, argv.loop);
1559
+ }
1560
+
1561
+ // src/cli.ts
1562
+ main().then(() => {
1563
+ process.exit(0);
1564
+ }).catch((error) => {
1565
+ console.error("Error:", error);
1566
+ process.exit(1);
1567
+ });
1568
+
1569
+ //# debugId=FF1120811E982FF064756E2164756E21