osu-play 1.1.0 → 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.
- package/README.md +39 -7
- package/dist/cli.cjs +1009 -101
- package/dist/cli.cjs.map +7 -4
- package/dist/cli.js +1014 -101
- package/dist/cli.js.map +7 -4
- package/dist/core/player/mod.d.ts +3 -0
- package/dist/core/player/mpv.d.ts +37 -0
- package/dist/core/player/session.d.ts +45 -0
- package/dist/core/player/types.d.ts +46 -0
- package/dist/core/tui/player-screen.d.ts +26 -0
- package/package.json +2 -3
package/dist/cli.cjs
CHANGED
|
@@ -460,11 +460,9 @@ var init_mod3 = __esm(() => {
|
|
|
460
460
|
});
|
|
461
461
|
|
|
462
462
|
// src/core/cli/main.ts
|
|
463
|
-
|
|
464
|
-
var
|
|
465
|
-
var
|
|
466
|
-
var import_node_fs2 = require("node:fs");
|
|
467
|
-
var import_node_util = require("node:util");
|
|
463
|
+
var import_node_path3 = __toESM(require("node:path"));
|
|
464
|
+
var import_node_fs3 = require("node:fs");
|
|
465
|
+
var import_pi_tui2 = require("@mariozechner/pi-tui");
|
|
468
466
|
var import_yargs = __toESM(require("yargs/yargs"));
|
|
469
467
|
var import_helpers = require("yargs/helpers");
|
|
470
468
|
|
|
@@ -506,8 +504,954 @@ function buildPlaylist(beatmapSets, osuDataDir) {
|
|
|
506
504
|
return playlist;
|
|
507
505
|
}
|
|
508
506
|
|
|
507
|
+
// src/core/player/mpv.ts
|
|
508
|
+
var import_node_child_process = require("node:child_process");
|
|
509
|
+
var import_node_fs2 = require("node:fs");
|
|
510
|
+
var import_node_net = __toESM(require("node:net"));
|
|
511
|
+
var import_node_os = __toESM(require("node:os"));
|
|
512
|
+
var import_node_path2 = __toESM(require("node:path"));
|
|
513
|
+
var import_promises = require("node:timers/promises");
|
|
514
|
+
function getErrorCode(error) {
|
|
515
|
+
if (error && typeof error === "object" && "code" in error) {
|
|
516
|
+
const { code } = error;
|
|
517
|
+
return typeof code === "string" ? code : null;
|
|
518
|
+
}
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
function isRetryableSocketError(error) {
|
|
522
|
+
const code = getErrorCode(error);
|
|
523
|
+
if (code) {
|
|
524
|
+
return code === "ENOENT" || code === "ECONNREFUSED" || code === "EPERM" || code === "EACCES";
|
|
525
|
+
}
|
|
526
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
527
|
+
return message.includes("ENOENT") || message.includes("ECONNREFUSED") || message.includes("EPERM") || message.includes("EACCES");
|
|
528
|
+
}
|
|
529
|
+
function createSocketPath() {
|
|
530
|
+
const id = `osu-play-mpv-${process.pid}-${Date.now()}`;
|
|
531
|
+
if (process.platform === "win32") {
|
|
532
|
+
return `\\\\.\\pipe\\${id}`;
|
|
533
|
+
}
|
|
534
|
+
return import_node_path2.default.join(import_node_os.default.tmpdir(), `${id}.sock`);
|
|
535
|
+
}
|
|
536
|
+
function isControlText(value) {
|
|
537
|
+
return [...value].some((character) => {
|
|
538
|
+
const code = character.charCodeAt(0);
|
|
539
|
+
return code < 32 || code === 127 || code >= 128 && code <= 159;
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
class MpvPlayerBackend {
|
|
544
|
+
name = "mpv";
|
|
545
|
+
connectPromise = null;
|
|
546
|
+
disposePromise = null;
|
|
547
|
+
disposing = false;
|
|
548
|
+
listeners = new Set;
|
|
549
|
+
pendingCommands = new Map;
|
|
550
|
+
idleActive = true;
|
|
551
|
+
process = null;
|
|
552
|
+
requestId = 0;
|
|
553
|
+
responseBuffer = "";
|
|
554
|
+
snapshot = {
|
|
555
|
+
backendName: "mpv",
|
|
556
|
+
currentPath: null,
|
|
557
|
+
durationSeconds: null,
|
|
558
|
+
errorMessage: null,
|
|
559
|
+
status: "stopped",
|
|
560
|
+
timePositionSeconds: null
|
|
561
|
+
};
|
|
562
|
+
socket = null;
|
|
563
|
+
socketPath = createSocketPath();
|
|
564
|
+
stderrBuffer = "";
|
|
565
|
+
getSnapshot() {
|
|
566
|
+
return this.snapshot;
|
|
567
|
+
}
|
|
568
|
+
subscribe(listener) {
|
|
569
|
+
this.listeners.add(listener);
|
|
570
|
+
listener({
|
|
571
|
+
snapshot: this.snapshot,
|
|
572
|
+
type: "state"
|
|
573
|
+
});
|
|
574
|
+
return () => {
|
|
575
|
+
this.listeners.delete(listener);
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
async start() {
|
|
579
|
+
if (this.socket && !this.socket.destroyed) {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (this.connectPromise) {
|
|
583
|
+
return this.connectPromise;
|
|
584
|
+
}
|
|
585
|
+
this.connectPromise = this.startInternal();
|
|
586
|
+
try {
|
|
587
|
+
await this.connectPromise;
|
|
588
|
+
} finally {
|
|
589
|
+
this.connectPromise = null;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
async play(filePath) {
|
|
593
|
+
await this.start();
|
|
594
|
+
const previousSnapshot = this.snapshot;
|
|
595
|
+
try {
|
|
596
|
+
await this.sendCommand(["loadfile", filePath, "replace"]);
|
|
597
|
+
await this.sendCommand(["set_property", "pause", false]);
|
|
598
|
+
} catch (error) {
|
|
599
|
+
this.snapshot = previousSnapshot;
|
|
600
|
+
this.emit({
|
|
601
|
+
snapshot: this.snapshot,
|
|
602
|
+
type: "state"
|
|
603
|
+
});
|
|
604
|
+
throw error;
|
|
605
|
+
}
|
|
606
|
+
this.snapshot = {
|
|
607
|
+
...this.snapshot,
|
|
608
|
+
currentPath: filePath,
|
|
609
|
+
errorMessage: null,
|
|
610
|
+
status: this.idleActive ? "stopped" : "playing",
|
|
611
|
+
timePositionSeconds: this.snapshot.timePositionSeconds ?? 0
|
|
612
|
+
};
|
|
613
|
+
this.emit({
|
|
614
|
+
snapshot: this.snapshot,
|
|
615
|
+
type: "state"
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
async togglePause() {
|
|
619
|
+
await this.start();
|
|
620
|
+
await this.sendCommand(["cycle", "pause"]);
|
|
621
|
+
}
|
|
622
|
+
async seekBy(seconds) {
|
|
623
|
+
await this.start();
|
|
624
|
+
await this.sendCommand(["seek", seconds, "relative"]);
|
|
625
|
+
}
|
|
626
|
+
async stop() {
|
|
627
|
+
if (!this.socket || this.socket.destroyed) {
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
await this.sendCommand(["stop"]);
|
|
631
|
+
this.idleActive = true;
|
|
632
|
+
this.snapshot = {
|
|
633
|
+
...this.snapshot,
|
|
634
|
+
status: "stopped",
|
|
635
|
+
timePositionSeconds: null
|
|
636
|
+
};
|
|
637
|
+
this.emit({
|
|
638
|
+
snapshot: this.snapshot,
|
|
639
|
+
type: "state"
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
async dispose() {
|
|
643
|
+
if (this.disposePromise) {
|
|
644
|
+
return this.disposePromise;
|
|
645
|
+
}
|
|
646
|
+
this.disposePromise = this.disposeInternal();
|
|
647
|
+
try {
|
|
648
|
+
await this.disposePromise;
|
|
649
|
+
} finally {
|
|
650
|
+
this.disposePromise = null;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
emit(event) {
|
|
654
|
+
for (const listener of this.listeners) {
|
|
655
|
+
listener(event);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
async startInternal() {
|
|
659
|
+
this.cleanupSocketPath();
|
|
660
|
+
this.disposing = false;
|
|
661
|
+
this.stderrBuffer = "";
|
|
662
|
+
const processHandle = import_node_child_process.spawn("mpv", [
|
|
663
|
+
"--no-config",
|
|
664
|
+
"--no-terminal",
|
|
665
|
+
"--idle=yes",
|
|
666
|
+
"--force-window=no",
|
|
667
|
+
"--audio-display=no",
|
|
668
|
+
"--really-quiet",
|
|
669
|
+
`--input-ipc-server=${this.socketPath}`
|
|
670
|
+
], {
|
|
671
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
672
|
+
});
|
|
673
|
+
this.process = processHandle;
|
|
674
|
+
processHandle.stderr.setEncoding("utf8");
|
|
675
|
+
processHandle.stderr.on("data", (chunk) => {
|
|
676
|
+
this.stderrBuffer = `${this.stderrBuffer}${chunk}`.slice(-4000);
|
|
677
|
+
});
|
|
678
|
+
processHandle.once("error", (error) => {
|
|
679
|
+
this.handleError(error);
|
|
680
|
+
});
|
|
681
|
+
processHandle.once("exit", (code, signal) => {
|
|
682
|
+
if (this.disposing) {
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const detail = this.stderrBuffer.trim();
|
|
686
|
+
const suffix = detail ? `
|
|
687
|
+
${detail}` : "";
|
|
688
|
+
this.handleError(new Error(`mpv exited unexpectedly with ${signal ? `signal ${signal}` : `code ${code}`}.${suffix}`));
|
|
689
|
+
});
|
|
690
|
+
await this.connectSocket();
|
|
691
|
+
await Promise.all([
|
|
692
|
+
this.sendCommand(["observe_property", 1, "pause"]),
|
|
693
|
+
this.sendCommand(["observe_property", 2, "time-pos"]),
|
|
694
|
+
this.sendCommand(["observe_property", 3, "duration"]),
|
|
695
|
+
this.sendCommand(["observe_property", 4, "idle-active"])
|
|
696
|
+
]);
|
|
697
|
+
}
|
|
698
|
+
async connectSocket() {
|
|
699
|
+
const start = Date.now();
|
|
700
|
+
const timeoutMs = 5000;
|
|
701
|
+
while (Date.now() - start < timeoutMs) {
|
|
702
|
+
if (!this.process || this.process.exitCode !== null) {
|
|
703
|
+
throw new Error(this.formatStartupError("mpv exited before opening its IPC socket."));
|
|
704
|
+
}
|
|
705
|
+
try {
|
|
706
|
+
await this.attachSocket();
|
|
707
|
+
return;
|
|
708
|
+
} catch (error) {
|
|
709
|
+
if (!isRetryableSocketError(error)) {
|
|
710
|
+
throw error;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
await import_promises.setTimeout(50);
|
|
714
|
+
}
|
|
715
|
+
throw new Error(this.formatStartupError("Timed out waiting for mpv IPC to become ready."));
|
|
716
|
+
}
|
|
717
|
+
async attachSocket() {
|
|
718
|
+
await new Promise((resolve, reject) => {
|
|
719
|
+
const socket = import_node_net.default.createConnection(this.socketPath);
|
|
720
|
+
const onError = (error) => {
|
|
721
|
+
socket.destroy();
|
|
722
|
+
reject(error);
|
|
723
|
+
};
|
|
724
|
+
socket.once("error", onError);
|
|
725
|
+
socket.once("connect", () => {
|
|
726
|
+
socket.removeListener("error", onError);
|
|
727
|
+
socket.setEncoding("utf8");
|
|
728
|
+
socket.on("data", (chunk) => {
|
|
729
|
+
this.handleSocketData(chunk);
|
|
730
|
+
});
|
|
731
|
+
socket.on("error", (error) => {
|
|
732
|
+
if (!this.disposing) {
|
|
733
|
+
this.handleError(error);
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
socket.on("close", () => {
|
|
737
|
+
if (!this.disposing) {
|
|
738
|
+
this.handleError(new Error("mpv IPC socket closed unexpectedly."));
|
|
739
|
+
}
|
|
740
|
+
});
|
|
741
|
+
this.socket = socket;
|
|
742
|
+
resolve();
|
|
743
|
+
});
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
async sendCommand(command) {
|
|
747
|
+
const socket = this.socket;
|
|
748
|
+
if (!socket || socket.destroyed) {
|
|
749
|
+
throw new Error("mpv IPC socket is not connected.");
|
|
750
|
+
}
|
|
751
|
+
const requestId = ++this.requestId;
|
|
752
|
+
return new Promise((resolve, reject) => {
|
|
753
|
+
this.pendingCommands.set(requestId, { reject, resolve });
|
|
754
|
+
socket.write(`${JSON.stringify({ command, request_id: requestId })}
|
|
755
|
+
`, (error) => {
|
|
756
|
+
if (!error) {
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
this.pendingCommands.delete(requestId);
|
|
760
|
+
reject(error);
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
handleSocketData(chunk) {
|
|
765
|
+
this.responseBuffer += chunk;
|
|
766
|
+
while (true) {
|
|
767
|
+
const newlineIndex = this.responseBuffer.indexOf(`
|
|
768
|
+
`);
|
|
769
|
+
if (newlineIndex === -1) {
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
const line = this.responseBuffer.slice(0, newlineIndex).trim();
|
|
773
|
+
this.responseBuffer = this.responseBuffer.slice(newlineIndex + 1);
|
|
774
|
+
if (!line) {
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
try {
|
|
778
|
+
const response = JSON.parse(line);
|
|
779
|
+
this.handleResponse(response);
|
|
780
|
+
} catch (error) {
|
|
781
|
+
this.handleError(new Error(`Failed to parse mpv IPC message: ${error instanceof Error ? error.message : String(error)}`));
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
handleResponse(response) {
|
|
786
|
+
if (typeof response.request_id === "number") {
|
|
787
|
+
const pending = this.pendingCommands.get(response.request_id);
|
|
788
|
+
if (pending) {
|
|
789
|
+
this.pendingCommands.delete(response.request_id);
|
|
790
|
+
if (response.error && response.error !== "success") {
|
|
791
|
+
pending.reject(new Error(`mpv command failed: ${response.error}`));
|
|
792
|
+
} else {
|
|
793
|
+
pending.resolve(response.data);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (response.event === "property-change") {
|
|
799
|
+
this.applyPropertyChange(response.name, response.data);
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (response.event === "end-file") {
|
|
803
|
+
this.snapshot = {
|
|
804
|
+
...this.snapshot,
|
|
805
|
+
status: "stopped",
|
|
806
|
+
timePositionSeconds: this.snapshot.durationSeconds
|
|
807
|
+
};
|
|
808
|
+
this.emit({
|
|
809
|
+
snapshot: this.snapshot,
|
|
810
|
+
type: "state"
|
|
811
|
+
});
|
|
812
|
+
this.emit({
|
|
813
|
+
reason: response.reason ?? "unknown",
|
|
814
|
+
type: "ended"
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
applyPropertyChange(name, data) {
|
|
819
|
+
switch (name) {
|
|
820
|
+
case "pause":
|
|
821
|
+
this.snapshot = {
|
|
822
|
+
...this.snapshot,
|
|
823
|
+
status: data === true ? "paused" : this.idleActive ? "stopped" : "playing"
|
|
824
|
+
};
|
|
825
|
+
break;
|
|
826
|
+
case "time-pos":
|
|
827
|
+
this.snapshot = {
|
|
828
|
+
...this.snapshot,
|
|
829
|
+
timePositionSeconds: typeof data === "number" ? data : null
|
|
830
|
+
};
|
|
831
|
+
break;
|
|
832
|
+
case "duration":
|
|
833
|
+
this.snapshot = {
|
|
834
|
+
...this.snapshot,
|
|
835
|
+
durationSeconds: typeof data === "number" ? data : null
|
|
836
|
+
};
|
|
837
|
+
break;
|
|
838
|
+
case "idle-active":
|
|
839
|
+
this.idleActive = data === true;
|
|
840
|
+
this.snapshot = {
|
|
841
|
+
...this.snapshot,
|
|
842
|
+
status: data === true ? "stopped" : this.snapshot.status === "paused" ? "paused" : "playing",
|
|
843
|
+
timePositionSeconds: data === true ? this.snapshot.durationSeconds : this.snapshot.timePositionSeconds
|
|
844
|
+
};
|
|
845
|
+
break;
|
|
846
|
+
default:
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
this.emit({
|
|
850
|
+
snapshot: this.snapshot,
|
|
851
|
+
type: "state"
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
handleError(error) {
|
|
855
|
+
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
|
856
|
+
this.snapshot = {
|
|
857
|
+
...this.snapshot,
|
|
858
|
+
errorMessage: normalizedError.message,
|
|
859
|
+
status: "stopped"
|
|
860
|
+
};
|
|
861
|
+
this.emit({
|
|
862
|
+
error: normalizedError,
|
|
863
|
+
type: "error"
|
|
864
|
+
});
|
|
865
|
+
for (const [requestId, pending] of this.pendingCommands) {
|
|
866
|
+
this.pendingCommands.delete(requestId);
|
|
867
|
+
pending.reject(normalizedError);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
async disposeInternal() {
|
|
871
|
+
this.disposing = true;
|
|
872
|
+
if (this.socket && !this.socket.destroyed) {
|
|
873
|
+
try {
|
|
874
|
+
await this.sendCommand(["quit"]);
|
|
875
|
+
} catch {}
|
|
876
|
+
}
|
|
877
|
+
this.socket?.destroy();
|
|
878
|
+
this.socket = null;
|
|
879
|
+
const processHandle = this.process;
|
|
880
|
+
this.process = null;
|
|
881
|
+
if (processHandle && processHandle.exitCode === null) {
|
|
882
|
+
processHandle.kill("SIGTERM");
|
|
883
|
+
await Promise.race([
|
|
884
|
+
new Promise((resolve) => {
|
|
885
|
+
processHandle.once("exit", () => resolve());
|
|
886
|
+
}),
|
|
887
|
+
import_promises.setTimeout(1000)
|
|
888
|
+
]);
|
|
889
|
+
if (processHandle.exitCode === null) {
|
|
890
|
+
processHandle.kill("SIGKILL");
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
this.cleanupSocketPath();
|
|
894
|
+
}
|
|
895
|
+
cleanupSocketPath() {
|
|
896
|
+
if (process.platform === "win32") {
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (import_node_fs2.existsSync(this.socketPath)) {
|
|
900
|
+
import_node_fs2.rmSync(this.socketPath, { force: true });
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
formatStartupError(message) {
|
|
904
|
+
const stderr = this.stderrBuffer.trim();
|
|
905
|
+
const cleanStderr = stderr && !isControlText(stderr) ? `
|
|
906
|
+
${stderr}` : "";
|
|
907
|
+
return [
|
|
908
|
+
message,
|
|
909
|
+
"osu-play now expects `mpv` to be installed and available on PATH for TUI playback.",
|
|
910
|
+
cleanStderr
|
|
911
|
+
].filter(Boolean).join(`
|
|
912
|
+
`);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
// src/core/player/session.ts
|
|
916
|
+
function clampIndex(index, length) {
|
|
917
|
+
if (length <= 0) {
|
|
918
|
+
return 0;
|
|
919
|
+
}
|
|
920
|
+
return Math.max(0, Math.min(index, length - 1));
|
|
921
|
+
}
|
|
922
|
+
function wrapIndex(index, length) {
|
|
923
|
+
if (length <= 0) {
|
|
924
|
+
return 0;
|
|
925
|
+
}
|
|
926
|
+
return (index % length + length) % length;
|
|
927
|
+
}
|
|
928
|
+
function normalizeSearchText(text) {
|
|
929
|
+
return text.trim().toLowerCase();
|
|
930
|
+
}
|
|
931
|
+
function findTrackIndexByQuery(playlist, query) {
|
|
932
|
+
const normalizedQuery = normalizeSearchText(query);
|
|
933
|
+
if (!normalizedQuery) {
|
|
934
|
+
return -1;
|
|
935
|
+
}
|
|
936
|
+
return playlist.findIndex((track) => normalizeSearchText(track.title).includes(normalizedQuery));
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
class PlaylistPlayerSession {
|
|
940
|
+
backend;
|
|
941
|
+
listeners = new Set;
|
|
942
|
+
currentIndex = null;
|
|
943
|
+
errorMessage = null;
|
|
944
|
+
loop;
|
|
945
|
+
playlist;
|
|
946
|
+
searchQuery = "";
|
|
947
|
+
selectedIndex = 0;
|
|
948
|
+
unsubscribeBackend;
|
|
949
|
+
constructor(playlist, backend, options = {}) {
|
|
950
|
+
this.backend = backend;
|
|
951
|
+
this.playlist = playlist;
|
|
952
|
+
this.loop = options.loop ?? false;
|
|
953
|
+
this.unsubscribeBackend = backend.subscribe((event) => {
|
|
954
|
+
this.handleBackendEvent(event);
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
getSnapshot() {
|
|
958
|
+
const backendSnapshot = this.backend.getSnapshot();
|
|
959
|
+
const currentTrack = this.currentIndex !== null ? this.playlist[this.currentIndex] ?? null : null;
|
|
960
|
+
return {
|
|
961
|
+
backendName: backendSnapshot.backendName,
|
|
962
|
+
currentIndex: this.currentIndex,
|
|
963
|
+
currentTrack,
|
|
964
|
+
durationSeconds: backendSnapshot.durationSeconds,
|
|
965
|
+
errorMessage: this.errorMessage ?? backendSnapshot.errorMessage,
|
|
966
|
+
loop: this.loop,
|
|
967
|
+
playlist: this.playlist,
|
|
968
|
+
searchQuery: this.searchQuery,
|
|
969
|
+
selectedIndex: this.selectedIndex,
|
|
970
|
+
status: backendSnapshot.status,
|
|
971
|
+
timePositionSeconds: backendSnapshot.timePositionSeconds
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
subscribe(listener) {
|
|
975
|
+
this.listeners.add(listener);
|
|
976
|
+
listener(this.getSnapshot());
|
|
977
|
+
return () => {
|
|
978
|
+
this.listeners.delete(listener);
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
async start() {
|
|
982
|
+
await this.backend.start();
|
|
983
|
+
this.emit();
|
|
984
|
+
}
|
|
985
|
+
async dispose() {
|
|
986
|
+
this.unsubscribeBackend();
|
|
987
|
+
await this.backend.dispose();
|
|
988
|
+
}
|
|
989
|
+
moveSelection(delta) {
|
|
990
|
+
if (this.playlist.length === 0) {
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
this.selectedIndex = wrapIndex(this.selectedIndex + delta, this.playlist.length);
|
|
994
|
+
this.emit();
|
|
995
|
+
}
|
|
996
|
+
moveSelectionPage(delta, pageSize) {
|
|
997
|
+
this.moveSelection(delta * Math.max(1, pageSize));
|
|
998
|
+
}
|
|
999
|
+
selectHome() {
|
|
1000
|
+
if (this.playlist.length === 0) {
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
this.selectedIndex = 0;
|
|
1004
|
+
this.emit();
|
|
1005
|
+
}
|
|
1006
|
+
selectEnd() {
|
|
1007
|
+
if (this.playlist.length === 0) {
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
this.selectedIndex = this.playlist.length - 1;
|
|
1011
|
+
this.emit();
|
|
1012
|
+
}
|
|
1013
|
+
setSelectionIndex(index) {
|
|
1014
|
+
if (this.playlist.length === 0) {
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
this.selectedIndex = clampIndex(index, this.playlist.length);
|
|
1018
|
+
this.emit();
|
|
1019
|
+
}
|
|
1020
|
+
toggleLoop() {
|
|
1021
|
+
this.loop = !this.loop;
|
|
1022
|
+
this.emit();
|
|
1023
|
+
}
|
|
1024
|
+
appendSearchQuery(text) {
|
|
1025
|
+
if (!text) {
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
this.searchQuery += text;
|
|
1029
|
+
this.syncSelectionToSearch();
|
|
1030
|
+
}
|
|
1031
|
+
deleteSearchCharacter() {
|
|
1032
|
+
if (!this.searchQuery) {
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
this.searchQuery = this.searchQuery.slice(0, -1);
|
|
1036
|
+
this.syncSelectionToSearch();
|
|
1037
|
+
}
|
|
1038
|
+
clearSearch() {
|
|
1039
|
+
if (!this.searchQuery) {
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
this.searchQuery = "";
|
|
1043
|
+
this.emit();
|
|
1044
|
+
}
|
|
1045
|
+
async playSelected() {
|
|
1046
|
+
await this.playIndex(this.selectedIndex);
|
|
1047
|
+
}
|
|
1048
|
+
async playNext() {
|
|
1049
|
+
const nextIndex = this.getAdjacentIndex(1);
|
|
1050
|
+
if (nextIndex === null) {
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
await this.playIndex(nextIndex);
|
|
1054
|
+
}
|
|
1055
|
+
async playPrevious() {
|
|
1056
|
+
const previousIndex = this.getAdjacentIndex(-1);
|
|
1057
|
+
if (previousIndex === null) {
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
await this.playIndex(previousIndex);
|
|
1061
|
+
}
|
|
1062
|
+
async togglePause() {
|
|
1063
|
+
if (this.playlist.length === 0) {
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
const { status } = this.backend.getSnapshot();
|
|
1067
|
+
if (status === "stopped") {
|
|
1068
|
+
const restartIndex = this.currentIndex ?? this.selectedIndex;
|
|
1069
|
+
await this.playIndex(restartIndex);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
try {
|
|
1073
|
+
this.clearError();
|
|
1074
|
+
await this.backend.togglePause();
|
|
1075
|
+
} catch (error) {
|
|
1076
|
+
this.reportError(error);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
async seekBy(seconds) {
|
|
1080
|
+
if (seconds === 0) {
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
const { status } = this.backend.getSnapshot();
|
|
1084
|
+
if (status === "stopped") {
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
try {
|
|
1088
|
+
this.clearError();
|
|
1089
|
+
await this.backend.seekBy(seconds);
|
|
1090
|
+
} catch (error) {
|
|
1091
|
+
this.reportError(error);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
async stop() {
|
|
1095
|
+
try {
|
|
1096
|
+
this.clearError();
|
|
1097
|
+
await this.backend.stop();
|
|
1098
|
+
} catch (error) {
|
|
1099
|
+
this.reportError(error);
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
reportError(error) {
|
|
1103
|
+
this.errorMessage = error instanceof Error ? error.message : String(error);
|
|
1104
|
+
this.emit();
|
|
1105
|
+
}
|
|
1106
|
+
clearError() {
|
|
1107
|
+
if (this.errorMessage === null) {
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
this.errorMessage = null;
|
|
1111
|
+
}
|
|
1112
|
+
emit() {
|
|
1113
|
+
const snapshot = this.getSnapshot();
|
|
1114
|
+
for (const listener of this.listeners) {
|
|
1115
|
+
listener(snapshot);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
async playIndex(index) {
|
|
1119
|
+
const track = this.playlist[index];
|
|
1120
|
+
if (!track) {
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
const previousIndex = this.currentIndex;
|
|
1124
|
+
this.currentIndex = index;
|
|
1125
|
+
try {
|
|
1126
|
+
this.clearError();
|
|
1127
|
+
await this.backend.play(track.path);
|
|
1128
|
+
} catch (error) {
|
|
1129
|
+
this.currentIndex = previousIndex;
|
|
1130
|
+
this.reportError(error);
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
this.emit();
|
|
1134
|
+
}
|
|
1135
|
+
getAdjacentIndex(delta) {
|
|
1136
|
+
if (this.playlist.length === 0) {
|
|
1137
|
+
return null;
|
|
1138
|
+
}
|
|
1139
|
+
const baseIndex = this.currentIndex ?? this.selectedIndex;
|
|
1140
|
+
const nextIndex = baseIndex + delta;
|
|
1141
|
+
if (nextIndex < 0) {
|
|
1142
|
+
return this.loop ? this.playlist.length - 1 : null;
|
|
1143
|
+
}
|
|
1144
|
+
if (nextIndex >= this.playlist.length) {
|
|
1145
|
+
return this.loop ? 0 : null;
|
|
1146
|
+
}
|
|
1147
|
+
return nextIndex;
|
|
1148
|
+
}
|
|
1149
|
+
async handleBackendEvent(event) {
|
|
1150
|
+
switch (event.type) {
|
|
1151
|
+
case "state":
|
|
1152
|
+
this.emit();
|
|
1153
|
+
return;
|
|
1154
|
+
case "error":
|
|
1155
|
+
this.reportError(event.error);
|
|
1156
|
+
return;
|
|
1157
|
+
case "ended":
|
|
1158
|
+
if (event.reason === "eof") {
|
|
1159
|
+
const nextIndex = this.getAdjacentIndex(1);
|
|
1160
|
+
if (nextIndex !== null) {
|
|
1161
|
+
await this.playIndex(nextIndex);
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
this.emit();
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
syncSelectionToSearch() {
|
|
1169
|
+
const matchedIndex = findTrackIndexByQuery(this.playlist, this.searchQuery);
|
|
1170
|
+
if (matchedIndex !== -1) {
|
|
1171
|
+
this.selectedIndex = matchedIndex;
|
|
1172
|
+
}
|
|
1173
|
+
this.emit();
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
// src/core/tui/player-screen.ts
|
|
1177
|
+
var import_pi_tui = require("@mariozechner/pi-tui");
|
|
1178
|
+
var RESET = "\x1B[0m";
|
|
1179
|
+
var DIM = "\x1B[2m";
|
|
1180
|
+
var RED = "\x1B[31m";
|
|
1181
|
+
var CYAN = "\x1B[36m";
|
|
1182
|
+
var INVERSE = "\x1B[7m";
|
|
1183
|
+
var MIN_LIST_ROWS = 4;
|
|
1184
|
+
var RESERVED_ROWS = 6;
|
|
1185
|
+
var PAGE_SIZE = 10;
|
|
1186
|
+
var PENDING_G_TIMEOUT_MS = 400;
|
|
1187
|
+
var SEEK_SECONDS = 5;
|
|
1188
|
+
function style(text, code) {
|
|
1189
|
+
return `${code}${text}${RESET}`;
|
|
1190
|
+
}
|
|
1191
|
+
function padIndex(index, total) {
|
|
1192
|
+
const width = String(Math.max(total, 1)).length;
|
|
1193
|
+
return String(index + 1).padStart(width, " ");
|
|
1194
|
+
}
|
|
1195
|
+
function formatSeconds(seconds) {
|
|
1196
|
+
if (seconds === null || Number.isNaN(seconds)) {
|
|
1197
|
+
return "--:--";
|
|
1198
|
+
}
|
|
1199
|
+
const rounded = Math.max(0, Math.floor(seconds));
|
|
1200
|
+
const minutes = Math.floor(rounded / 60);
|
|
1201
|
+
const remainingSeconds = rounded % 60;
|
|
1202
|
+
return `${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`;
|
|
1203
|
+
}
|
|
1204
|
+
function decodePrintableText(data) {
|
|
1205
|
+
const kittyPrintable = import_pi_tui.decodeKittyPrintable(data);
|
|
1206
|
+
if (kittyPrintable !== undefined) {
|
|
1207
|
+
return kittyPrintable;
|
|
1208
|
+
}
|
|
1209
|
+
const hasControlChars = [...data].some((character) => {
|
|
1210
|
+
const code = character.charCodeAt(0);
|
|
1211
|
+
return code < 32 || code === 127 || code >= 128 && code <= 159;
|
|
1212
|
+
});
|
|
1213
|
+
return hasControlChars ? undefined : data;
|
|
1214
|
+
}
|
|
1215
|
+
function getVisibleTrackRange(selectedIndex, totalTracks, maxVisible) {
|
|
1216
|
+
if (totalTracks <= 0 || maxVisible <= 0) {
|
|
1217
|
+
return { end: 0, start: 0 };
|
|
1218
|
+
}
|
|
1219
|
+
const clampedSelectedIndex = Math.max(0, Math.min(selectedIndex, totalTracks - 1));
|
|
1220
|
+
const start = Math.max(0, Math.min(clampedSelectedIndex - Math.floor(maxVisible / 2), totalTracks - maxVisible));
|
|
1221
|
+
return {
|
|
1222
|
+
end: Math.min(totalTracks, start + maxVisible),
|
|
1223
|
+
start
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
class PlaylistPlayerScreen {
|
|
1228
|
+
session;
|
|
1229
|
+
getViewportHeight;
|
|
1230
|
+
pendingGoToTop = false;
|
|
1231
|
+
pendingGoToTopTimer = null;
|
|
1232
|
+
searchMode = false;
|
|
1233
|
+
snapshot;
|
|
1234
|
+
onQuit;
|
|
1235
|
+
constructor(session, getViewportHeight) {
|
|
1236
|
+
this.session = session;
|
|
1237
|
+
this.getViewportHeight = getViewportHeight;
|
|
1238
|
+
this.snapshot = session.getSnapshot();
|
|
1239
|
+
}
|
|
1240
|
+
setSnapshot(snapshot) {
|
|
1241
|
+
this.snapshot = snapshot;
|
|
1242
|
+
}
|
|
1243
|
+
invalidate() {}
|
|
1244
|
+
handleInput(data) {
|
|
1245
|
+
if (this.searchMode) {
|
|
1246
|
+
this.handleSearchInput(data);
|
|
1247
|
+
return;
|
|
1248
|
+
}
|
|
1249
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.slash)) {
|
|
1250
|
+
this.searchMode = true;
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
if (import_pi_tui.matchesKey(data, "g")) {
|
|
1254
|
+
if (this.pendingGoToTop) {
|
|
1255
|
+
this.clearPendingGoToTop();
|
|
1256
|
+
this.session.selectHome();
|
|
1257
|
+
} else {
|
|
1258
|
+
this.armPendingGoToTop();
|
|
1259
|
+
}
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
this.clearPendingGoToTop();
|
|
1263
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.ctrl("c")) || import_pi_tui.matchesKey(data, "q")) {
|
|
1264
|
+
this.onQuit?.();
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.up) || import_pi_tui.matchesKey(data, "k")) {
|
|
1268
|
+
this.session.moveSelection(-1);
|
|
1269
|
+
return;
|
|
1270
|
+
}
|
|
1271
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.down) || import_pi_tui.matchesKey(data, "j")) {
|
|
1272
|
+
this.session.moveSelection(1);
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.left) || import_pi_tui.matchesKey(data, "h")) {
|
|
1276
|
+
this.session.seekBy(-SEEK_SECONDS);
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.right) || import_pi_tui.matchesKey(data, "l")) {
|
|
1280
|
+
this.session.seekBy(SEEK_SECONDS);
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.pageUp) || import_pi_tui.matchesKey(data, import_pi_tui.Key.ctrl("u")) || import_pi_tui.matchesKey(data, import_pi_tui.Key.ctrl("b"))) {
|
|
1284
|
+
this.session.moveSelectionPage(-1, PAGE_SIZE);
|
|
1285
|
+
return;
|
|
1286
|
+
}
|
|
1287
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.pageDown) || import_pi_tui.matchesKey(data, import_pi_tui.Key.ctrl("d")) || import_pi_tui.matchesKey(data, import_pi_tui.Key.ctrl("f"))) {
|
|
1288
|
+
this.session.moveSelectionPage(1, PAGE_SIZE);
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.home) || import_pi_tui.matchesKey(data, "0")) {
|
|
1292
|
+
this.session.selectHome();
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.end) || import_pi_tui.matchesKey(data, import_pi_tui.Key.shift("g"))) {
|
|
1296
|
+
this.session.selectEnd();
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.shift("h"))) {
|
|
1300
|
+
this.moveSelectionToVisibleAnchor("top");
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.shift("m"))) {
|
|
1304
|
+
this.moveSelectionToVisibleAnchor("middle");
|
|
1305
|
+
return;
|
|
1306
|
+
}
|
|
1307
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.shift("l"))) {
|
|
1308
|
+
this.moveSelectionToVisibleAnchor("bottom");
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.enter)) {
|
|
1312
|
+
this.session.playSelected();
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.space)) {
|
|
1316
|
+
this.session.togglePause();
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
if (import_pi_tui.matchesKey(data, "n")) {
|
|
1320
|
+
this.session.playNext();
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
if (import_pi_tui.matchesKey(data, "p")) {
|
|
1324
|
+
this.session.playPrevious();
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
if (import_pi_tui.matchesKey(data, "s")) {
|
|
1328
|
+
this.session.stop();
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1331
|
+
if (import_pi_tui.matchesKey(data, "r")) {
|
|
1332
|
+
this.session.toggleLoop();
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.backspace)) {
|
|
1336
|
+
this.session.deleteSearchCharacter();
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.escape)) {
|
|
1340
|
+
this.session.clearSearch();
|
|
1341
|
+
return;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
render(width) {
|
|
1345
|
+
const { playlist } = this.snapshot;
|
|
1346
|
+
const viewportHeight = Math.max(this.getViewportHeight(), RESERVED_ROWS);
|
|
1347
|
+
const listHeight = this.getListHeight(viewportHeight);
|
|
1348
|
+
const { start, end } = getVisibleTrackRange(this.snapshot.selectedIndex, playlist.length, listHeight);
|
|
1349
|
+
const lines = [
|
|
1350
|
+
import_pi_tui.truncateToWidth(`osu-play | tracks ${playlist.length} | backend ${this.snapshot.backendName} | loop ${this.snapshot.loop ? "on" : "off"}`, width),
|
|
1351
|
+
import_pi_tui.truncateToWidth(style(`now ${this.snapshot.status}: ${this.snapshot.currentTrack?.title ?? "nothing selected"}`, CYAN), width),
|
|
1352
|
+
import_pi_tui.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)
|
|
1353
|
+
];
|
|
1354
|
+
if (this.snapshot.errorMessage) {
|
|
1355
|
+
lines.push(import_pi_tui.truncateToWidth(style(`error: ${this.snapshot.errorMessage}`, RED), width));
|
|
1356
|
+
} else {
|
|
1357
|
+
lines.push("");
|
|
1358
|
+
}
|
|
1359
|
+
if (playlist.length === 0) {
|
|
1360
|
+
lines.push(import_pi_tui.truncateToWidth("No tracks were found in your osu!lazer library.", width));
|
|
1361
|
+
} else {
|
|
1362
|
+
for (let index = start;index < end; index += 1) {
|
|
1363
|
+
const track = playlist[index];
|
|
1364
|
+
if (!track) {
|
|
1365
|
+
continue;
|
|
1366
|
+
}
|
|
1367
|
+
const isCurrent = index === this.snapshot.currentIndex;
|
|
1368
|
+
const isSelected = index === this.snapshot.selectedIndex;
|
|
1369
|
+
const prefix = `${isSelected ? ">" : " "} ${isCurrent ? "*" : " "} `;
|
|
1370
|
+
const line = `${prefix}${padIndex(index, playlist.length)}. ${track.title}`;
|
|
1371
|
+
lines.push(import_pi_tui.truncateToWidth(isSelected ? style(line, INVERSE) : line, width));
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
while (lines.length < viewportHeight - 1) {
|
|
1375
|
+
lines.push("");
|
|
1376
|
+
}
|
|
1377
|
+
lines.push(import_pi_tui.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));
|
|
1378
|
+
return lines;
|
|
1379
|
+
}
|
|
1380
|
+
armPendingGoToTop() {
|
|
1381
|
+
this.pendingGoToTop = true;
|
|
1382
|
+
this.pendingGoToTopTimer = setTimeout(() => {
|
|
1383
|
+
this.pendingGoToTop = false;
|
|
1384
|
+
this.pendingGoToTopTimer = null;
|
|
1385
|
+
}, PENDING_G_TIMEOUT_MS);
|
|
1386
|
+
}
|
|
1387
|
+
clearPendingGoToTop() {
|
|
1388
|
+
if (this.pendingGoToTopTimer) {
|
|
1389
|
+
clearTimeout(this.pendingGoToTopTimer);
|
|
1390
|
+
this.pendingGoToTopTimer = null;
|
|
1391
|
+
}
|
|
1392
|
+
this.pendingGoToTop = false;
|
|
1393
|
+
}
|
|
1394
|
+
handleSearchInput(data) {
|
|
1395
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.ctrl("c"))) {
|
|
1396
|
+
this.onQuit?.();
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.enter)) {
|
|
1400
|
+
this.searchMode = false;
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.escape)) {
|
|
1404
|
+
this.searchMode = false;
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.backspace)) {
|
|
1408
|
+
this.session.deleteSearchCharacter();
|
|
1409
|
+
return;
|
|
1410
|
+
}
|
|
1411
|
+
if (import_pi_tui.matchesKey(data, import_pi_tui.Key.ctrl("u"))) {
|
|
1412
|
+
this.session.clearSearch();
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
const printable = decodePrintableText(data);
|
|
1416
|
+
if (!printable || printable === " ") {
|
|
1417
|
+
return;
|
|
1418
|
+
}
|
|
1419
|
+
this.session.appendSearchQuery(printable);
|
|
1420
|
+
}
|
|
1421
|
+
getListHeight(viewportHeight) {
|
|
1422
|
+
return Math.max(MIN_LIST_ROWS, viewportHeight - RESERVED_ROWS);
|
|
1423
|
+
}
|
|
1424
|
+
moveSelectionToVisibleAnchor(anchor) {
|
|
1425
|
+
if (this.snapshot.playlist.length === 0) {
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
const { start, end } = getVisibleTrackRange(this.snapshot.selectedIndex, this.snapshot.playlist.length, this.getListHeight(Math.max(this.getViewportHeight(), RESERVED_ROWS)));
|
|
1429
|
+
const lastVisibleIndex = Math.max(start, end - 1);
|
|
1430
|
+
switch (anchor) {
|
|
1431
|
+
case "top":
|
|
1432
|
+
this.session.setSelectionIndex(start);
|
|
1433
|
+
return;
|
|
1434
|
+
case "middle":
|
|
1435
|
+
this.session.setSelectionIndex(start + Math.floor((lastVisibleIndex - start) / 2));
|
|
1436
|
+
return;
|
|
1437
|
+
case "bottom":
|
|
1438
|
+
this.session.setSelectionIndex(lastVisibleIndex);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
509
1443
|
// src/core/cli/main.ts
|
|
510
|
-
|
|
1444
|
+
init_mod();
|
|
1445
|
+
function formatRealmLoadError(error) {
|
|
1446
|
+
return [
|
|
1447
|
+
"Realm native bindings could not be loaded.",
|
|
1448
|
+
`Current Node runtime: ${process.version}.`,
|
|
1449
|
+
"To repair Realm bindings in this project, run `bun run repair:realm` (or `bun run setup` to reinstall dependencies).",
|
|
1450
|
+
"If you installed dependencies on Node 22, switch to Node 20 LTS and rerun the repair command.",
|
|
1451
|
+
`Original error: ${error instanceof Error ? error.message : String(error)}`
|
|
1452
|
+
].join(`
|
|
1453
|
+
`);
|
|
1454
|
+
}
|
|
511
1455
|
async function loadRealmDependencies() {
|
|
512
1456
|
try {
|
|
513
1457
|
const [{ default: Realm8 }, lazerModule] = await Promise.all([
|
|
@@ -519,18 +1463,11 @@ async function loadRealmDependencies() {
|
|
|
519
1463
|
...lazerModule
|
|
520
1464
|
};
|
|
521
1465
|
} catch (error) {
|
|
522
|
-
throw new Error(
|
|
523
|
-
"Realm native bindings could not be loaded.",
|
|
524
|
-
`Current Node runtime: ${process.version}.`,
|
|
525
|
-
"To repair Realm bindings in this project, run `bun run repair:realm` (or `bun run setup` to reinstall dependencies).",
|
|
526
|
-
"If you installed dependencies on Node 22, switch to Node 20 LTS and rerun the repair command.",
|
|
527
|
-
`Original error: ${error instanceof Error ? error.message : String(error)}`
|
|
528
|
-
].join(`
|
|
529
|
-
`), { cause: error });
|
|
1466
|
+
throw new Error(formatRealmLoadError(error), { cause: error });
|
|
530
1467
|
}
|
|
531
1468
|
}
|
|
532
1469
|
function getArgs() {
|
|
533
|
-
return import_yargs.default(import_helpers.hideBin(process.argv)).scriptName("osu-play").usage(`Play music from your osu!lazer beatmaps
|
|
1470
|
+
return import_yargs.default(import_helpers.hideBin(process.argv)).scriptName("osu-play").usage(`Play music from your osu!lazer beatmaps in a minimal terminal player
|
|
534
1471
|
Usage: $0 [options]`).option("reload", {
|
|
535
1472
|
type: "boolean",
|
|
536
1473
|
default: false,
|
|
@@ -538,7 +1475,7 @@ Usage: $0 [options]`).option("reload", {
|
|
|
538
1475
|
describe: "Deprecated: ignored. osu-play now reads the live lazer database directly"
|
|
539
1476
|
}).option("exportPlaylist", {
|
|
540
1477
|
type: "string",
|
|
541
|
-
describe: "Export playlist to a file"
|
|
1478
|
+
describe: "Export playlist to a file instead of launching the player"
|
|
542
1479
|
}).option("osuDataDir", {
|
|
543
1480
|
type: "string",
|
|
544
1481
|
default: getDefaultOsuDataDir(),
|
|
@@ -552,55 +1489,16 @@ Usage: $0 [options]`).option("reload", {
|
|
|
552
1489
|
type: "boolean",
|
|
553
1490
|
default: false,
|
|
554
1491
|
alias: "l",
|
|
555
|
-
describe: "Loop the playlist
|
|
1492
|
+
describe: "Loop the playlist when playback reaches the end"
|
|
556
1493
|
}).alias("help", "h").help().parse();
|
|
557
1494
|
}
|
|
558
|
-
async function
|
|
559
|
-
const {
|
|
560
|
-
const response = await prompts({
|
|
561
|
-
type: "autocomplete",
|
|
562
|
-
name: "beatmap",
|
|
563
|
-
message: "Which map do you want to play:",
|
|
564
|
-
choices: uniqueBeatmaps.map((beatmap, index) => ({
|
|
565
|
-
title: beatmap.title,
|
|
566
|
-
value: index
|
|
567
|
-
}))
|
|
568
|
-
});
|
|
569
|
-
return response.beatmap;
|
|
570
|
-
}
|
|
571
|
-
async function openBeatmap(filePath) {
|
|
572
|
-
switch (process.platform) {
|
|
573
|
-
case "darwin":
|
|
574
|
-
await execFilePromise("open", [filePath]);
|
|
575
|
-
return;
|
|
576
|
-
case "win32":
|
|
577
|
-
await execFilePromise("cmd", ["/c", "start", "", filePath]);
|
|
578
|
-
return;
|
|
579
|
-
default:
|
|
580
|
-
await execFilePromise("xdg-open", [filePath]);
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
async function main() {
|
|
584
|
-
const argv = await getArgs();
|
|
585
|
-
console.log("[INFO] osu-play");
|
|
586
|
-
if (argv.reload) {
|
|
587
|
-
console.log("[INFO] `--reload` is deprecated and ignored because osu-play reads osu!lazer's live Realm DB directly.");
|
|
588
|
-
}
|
|
589
|
-
if (argv.configDir) {
|
|
590
|
-
console.log("[INFO] `--configDir` is deprecated and ignored because osu-play no longer copies the Realm DB.");
|
|
591
|
-
}
|
|
592
|
-
if (argv.osuDataDir !== getDefaultOsuDataDir()) {
|
|
593
|
-
console.log(`[INFO] Using osu!lazer data directory: ${argv.osuDataDir}`);
|
|
594
|
-
}
|
|
595
|
-
const realmDBPath = getRealmDBPath({
|
|
596
|
-
osuDataDir: argv.osuDataDir
|
|
597
|
-
});
|
|
1495
|
+
async function createPlaylist(osuDataDir) {
|
|
1496
|
+
const realmDBPath = getRealmDBPath({ osuDataDir });
|
|
598
1497
|
if (!realmDBPath) {
|
|
599
1498
|
throw new Error("Realm DB not found");
|
|
600
1499
|
}
|
|
601
1500
|
const {
|
|
602
1501
|
Realm: Realm8,
|
|
603
|
-
LAST_STATIC_LAZER_SCHEMA_VERSION: LAST_STATIC_LAZER_SCHEMA_VERSION2,
|
|
604
1502
|
formatLazerSchemaCompatibilityError: formatLazerSchemaCompatibilityError2,
|
|
605
1503
|
getBeatmapSets: getBeatmapSets2,
|
|
606
1504
|
getLazerDB: getLazerDB2,
|
|
@@ -610,55 +1508,65 @@ async function main() {
|
|
|
610
1508
|
const realm = await getLazerDB2(realmDBPath);
|
|
611
1509
|
try {
|
|
612
1510
|
const schemaReport = inspectLazerSchema2(realm);
|
|
613
|
-
console.log(`currentSchema: ${schemaReport.version}`);
|
|
614
1511
|
if (!schemaReport.compatible) {
|
|
615
1512
|
throw new Error(formatLazerSchemaCompatibilityError2(schemaReport));
|
|
616
1513
|
}
|
|
617
|
-
|
|
618
|
-
console.log(`[INFO] Detected osu!lazer schema version ${schemaReport.version}; continuing with reflected compatibility checks.`);
|
|
619
|
-
}
|
|
620
|
-
const beatmapSets = getBeatmapSets2(realm);
|
|
621
|
-
const uniqueBeatmaps = buildPlaylist(beatmapSets, argv.osuDataDir);
|
|
622
|
-
console.log(`beatmap songs: ${uniqueBeatmaps.length}`);
|
|
623
|
-
if (argv.exportPlaylist) {
|
|
624
|
-
console.log(`[INFO] Exporting playlist to ${argv.exportPlaylist}`);
|
|
625
|
-
const playlist = uniqueBeatmaps.map((beatmap) => beatmap.path).join(`
|
|
626
|
-
`);
|
|
627
|
-
import_node_fs2.writeFileSync(argv.exportPlaylist, playlist);
|
|
628
|
-
console.log(`[INFO] Done. Use something like \`mpv --playlist=${import_node_path2.default.resolve(argv.exportPlaylist)}\` to play the playlist`);
|
|
629
|
-
return;
|
|
630
|
-
}
|
|
631
|
-
const selectedBeatmap = await promptForBeatmap(uniqueBeatmaps);
|
|
632
|
-
if (selectedBeatmap === undefined) {
|
|
633
|
-
console.log("[INFO] Cancelled");
|
|
634
|
-
return;
|
|
635
|
-
}
|
|
636
|
-
console.log(`Selected: ${selectedBeatmap}`);
|
|
637
|
-
for (let i = selectedBeatmap;i < uniqueBeatmaps.length; i += 1) {
|
|
638
|
-
const beatmap = uniqueBeatmaps[i];
|
|
639
|
-
if (!beatmap) {
|
|
640
|
-
continue;
|
|
641
|
-
}
|
|
642
|
-
console.log(`Map: ${beatmap.title}`);
|
|
643
|
-
if (beatmap.path && import_node_fs2.existsSync(beatmap.path)) {
|
|
644
|
-
console.log(`Playing ${beatmap.title}`);
|
|
645
|
-
await openBeatmap(beatmap.path);
|
|
646
|
-
} else {
|
|
647
|
-
console.log(`File does not exist: ${beatmap.path}`);
|
|
648
|
-
}
|
|
649
|
-
if (i < uniqueBeatmaps.length - 1) {
|
|
650
|
-
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
651
|
-
} else if (argv.loop) {
|
|
652
|
-
console.log("[INFO] Looping playlist");
|
|
653
|
-
i = -1;
|
|
654
|
-
} else {
|
|
655
|
-
console.log("[INFO] Done. Use --loop to loop the playlist");
|
|
656
|
-
}
|
|
657
|
-
}
|
|
1514
|
+
return buildPlaylist(getBeatmapSets2(realm), osuDataDir);
|
|
658
1515
|
} finally {
|
|
659
1516
|
realm.close();
|
|
660
1517
|
}
|
|
661
1518
|
}
|
|
1519
|
+
async function runTuiPlayer(playlist, loop) {
|
|
1520
|
+
const backend = new MpvPlayerBackend;
|
|
1521
|
+
const session = new PlaylistPlayerSession(playlist, backend, { loop });
|
|
1522
|
+
const terminal = new import_pi_tui2.ProcessTerminal;
|
|
1523
|
+
const tui = new import_pi_tui2.TUI(terminal);
|
|
1524
|
+
const screen = new PlaylistPlayerScreen(session, () => terminal.rows);
|
|
1525
|
+
let started = false;
|
|
1526
|
+
const exitPromise = new Promise((resolve) => {
|
|
1527
|
+
screen.onQuit = () => resolve();
|
|
1528
|
+
});
|
|
1529
|
+
const unsubscribe = session.subscribe((snapshot) => {
|
|
1530
|
+
screen.setSnapshot(snapshot);
|
|
1531
|
+
if (started) {
|
|
1532
|
+
tui.requestRender();
|
|
1533
|
+
}
|
|
1534
|
+
});
|
|
1535
|
+
try {
|
|
1536
|
+
await session.start();
|
|
1537
|
+
tui.addChild(screen);
|
|
1538
|
+
tui.setFocus(screen);
|
|
1539
|
+
tui.start();
|
|
1540
|
+
started = true;
|
|
1541
|
+
await exitPromise;
|
|
1542
|
+
} finally {
|
|
1543
|
+
unsubscribe();
|
|
1544
|
+
if (started) {
|
|
1545
|
+
await terminal.drainInput().catch(() => {});
|
|
1546
|
+
tui.stop();
|
|
1547
|
+
}
|
|
1548
|
+
await session.dispose();
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
async function main() {
|
|
1552
|
+
const argv = await getArgs();
|
|
1553
|
+
if (argv.reload) {
|
|
1554
|
+
console.log("[INFO] `--reload` is deprecated and ignored because osu-play reads osu!lazer's live Realm DB directly.");
|
|
1555
|
+
}
|
|
1556
|
+
if (argv.configDir) {
|
|
1557
|
+
console.log("[INFO] `--configDir` is deprecated and ignored because osu-play no longer copies the Realm DB.");
|
|
1558
|
+
}
|
|
1559
|
+
const playlist = await createPlaylist(argv.osuDataDir);
|
|
1560
|
+
if (argv.exportPlaylist) {
|
|
1561
|
+
const playlistContents = playlist.map((track) => track.path).join(`
|
|
1562
|
+
`);
|
|
1563
|
+
import_node_fs3.writeFileSync(argv.exportPlaylist, playlistContents);
|
|
1564
|
+
console.log(`[INFO] Exported ${playlist.length} tracks to ${argv.exportPlaylist}.`);
|
|
1565
|
+
console.log(`[INFO] Use something like \`mpv --playlist=${import_node_path3.default.resolve(argv.exportPlaylist)}\` to play the playlist.`);
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
await runTuiPlayer(playlist, argv.loop);
|
|
1569
|
+
}
|
|
662
1570
|
|
|
663
1571
|
// src/cli.ts
|
|
664
1572
|
main().then(() => {
|
|
@@ -668,4 +1576,4 @@ main().then(() => {
|
|
|
668
1576
|
process.exit(1);
|
|
669
1577
|
});
|
|
670
1578
|
|
|
671
|
-
//# debugId=
|
|
1579
|
+
//# debugId=092198B3F20C100364756E2164756E21
|