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.js
CHANGED
|
@@ -445,11 +445,9 @@ var init_mod3 = __esm(() => {
|
|
|
445
445
|
});
|
|
446
446
|
|
|
447
447
|
// src/core/cli/main.ts
|
|
448
|
-
|
|
449
|
-
import
|
|
450
|
-
import {
|
|
451
|
-
import { existsSync as existsSync2, writeFileSync } from "node:fs";
|
|
452
|
-
import { promisify } from "node:util";
|
|
448
|
+
import path3 from "node:path";
|
|
449
|
+
import { writeFileSync } from "node:fs";
|
|
450
|
+
import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
|
|
453
451
|
import yargs from "yargs/yargs";
|
|
454
452
|
import { hideBin } from "yargs/helpers";
|
|
455
453
|
|
|
@@ -491,8 +489,959 @@ function buildPlaylist(beatmapSets, osuDataDir) {
|
|
|
491
489
|
return playlist;
|
|
492
490
|
}
|
|
493
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
|
+
|
|
494
1433
|
// src/core/cli/main.ts
|
|
495
|
-
|
|
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
|
+
}
|
|
496
1445
|
async function loadRealmDependencies() {
|
|
497
1446
|
try {
|
|
498
1447
|
const [{ default: Realm8 }, lazerModule] = await Promise.all([
|
|
@@ -504,18 +1453,11 @@ async function loadRealmDependencies() {
|
|
|
504
1453
|
...lazerModule
|
|
505
1454
|
};
|
|
506
1455
|
} catch (error) {
|
|
507
|
-
throw new Error(
|
|
508
|
-
"Realm native bindings could not be loaded.",
|
|
509
|
-
`Current Node runtime: ${process.version}.`,
|
|
510
|
-
"To repair Realm bindings in this project, run `bun run repair:realm` (or `bun run setup` to reinstall dependencies).",
|
|
511
|
-
"If you installed dependencies on Node 22, switch to Node 20 LTS and rerun the repair command.",
|
|
512
|
-
`Original error: ${error instanceof Error ? error.message : String(error)}`
|
|
513
|
-
].join(`
|
|
514
|
-
`), { cause: error });
|
|
1456
|
+
throw new Error(formatRealmLoadError(error), { cause: error });
|
|
515
1457
|
}
|
|
516
1458
|
}
|
|
517
1459
|
function getArgs() {
|
|
518
|
-
return yargs(hideBin(process.argv)).scriptName("osu-play").usage(`Play music from your osu!lazer beatmaps
|
|
1460
|
+
return yargs(hideBin(process.argv)).scriptName("osu-play").usage(`Play music from your osu!lazer beatmaps in a minimal terminal player
|
|
519
1461
|
Usage: $0 [options]`).option("reload", {
|
|
520
1462
|
type: "boolean",
|
|
521
1463
|
default: false,
|
|
@@ -523,7 +1465,7 @@ Usage: $0 [options]`).option("reload", {
|
|
|
523
1465
|
describe: "Deprecated: ignored. osu-play now reads the live lazer database directly"
|
|
524
1466
|
}).option("exportPlaylist", {
|
|
525
1467
|
type: "string",
|
|
526
|
-
describe: "Export playlist to a file"
|
|
1468
|
+
describe: "Export playlist to a file instead of launching the player"
|
|
527
1469
|
}).option("osuDataDir", {
|
|
528
1470
|
type: "string",
|
|
529
1471
|
default: getDefaultOsuDataDir(),
|
|
@@ -537,55 +1479,16 @@ Usage: $0 [options]`).option("reload", {
|
|
|
537
1479
|
type: "boolean",
|
|
538
1480
|
default: false,
|
|
539
1481
|
alias: "l",
|
|
540
|
-
describe: "Loop the playlist
|
|
1482
|
+
describe: "Loop the playlist when playback reaches the end"
|
|
541
1483
|
}).alias("help", "h").help().parse();
|
|
542
1484
|
}
|
|
543
|
-
async function
|
|
544
|
-
const {
|
|
545
|
-
const response = await prompts({
|
|
546
|
-
type: "autocomplete",
|
|
547
|
-
name: "beatmap",
|
|
548
|
-
message: "Which map do you want to play:",
|
|
549
|
-
choices: uniqueBeatmaps.map((beatmap, index) => ({
|
|
550
|
-
title: beatmap.title,
|
|
551
|
-
value: index
|
|
552
|
-
}))
|
|
553
|
-
});
|
|
554
|
-
return response.beatmap;
|
|
555
|
-
}
|
|
556
|
-
async function openBeatmap(filePath) {
|
|
557
|
-
switch (process.platform) {
|
|
558
|
-
case "darwin":
|
|
559
|
-
await execFilePromise("open", [filePath]);
|
|
560
|
-
return;
|
|
561
|
-
case "win32":
|
|
562
|
-
await execFilePromise("cmd", ["/c", "start", "", filePath]);
|
|
563
|
-
return;
|
|
564
|
-
default:
|
|
565
|
-
await execFilePromise("xdg-open", [filePath]);
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
async function main() {
|
|
569
|
-
const argv = await getArgs();
|
|
570
|
-
console.log("[INFO] osu-play");
|
|
571
|
-
if (argv.reload) {
|
|
572
|
-
console.log("[INFO] `--reload` is deprecated and ignored because osu-play reads osu!lazer's live Realm DB directly.");
|
|
573
|
-
}
|
|
574
|
-
if (argv.configDir) {
|
|
575
|
-
console.log("[INFO] `--configDir` is deprecated and ignored because osu-play no longer copies the Realm DB.");
|
|
576
|
-
}
|
|
577
|
-
if (argv.osuDataDir !== getDefaultOsuDataDir()) {
|
|
578
|
-
console.log(`[INFO] Using osu!lazer data directory: ${argv.osuDataDir}`);
|
|
579
|
-
}
|
|
580
|
-
const realmDBPath = getRealmDBPath({
|
|
581
|
-
osuDataDir: argv.osuDataDir
|
|
582
|
-
});
|
|
1485
|
+
async function createPlaylist(osuDataDir) {
|
|
1486
|
+
const realmDBPath = getRealmDBPath({ osuDataDir });
|
|
583
1487
|
if (!realmDBPath) {
|
|
584
1488
|
throw new Error("Realm DB not found");
|
|
585
1489
|
}
|
|
586
1490
|
const {
|
|
587
1491
|
Realm: Realm8,
|
|
588
|
-
LAST_STATIC_LAZER_SCHEMA_VERSION: LAST_STATIC_LAZER_SCHEMA_VERSION2,
|
|
589
1492
|
formatLazerSchemaCompatibilityError: formatLazerSchemaCompatibilityError2,
|
|
590
1493
|
getBeatmapSets: getBeatmapSets2,
|
|
591
1494
|
getLazerDB: getLazerDB2,
|
|
@@ -595,55 +1498,65 @@ async function main() {
|
|
|
595
1498
|
const realm = await getLazerDB2(realmDBPath);
|
|
596
1499
|
try {
|
|
597
1500
|
const schemaReport = inspectLazerSchema2(realm);
|
|
598
|
-
console.log(`currentSchema: ${schemaReport.version}`);
|
|
599
1501
|
if (!schemaReport.compatible) {
|
|
600
1502
|
throw new Error(formatLazerSchemaCompatibilityError2(schemaReport));
|
|
601
1503
|
}
|
|
602
|
-
|
|
603
|
-
console.log(`[INFO] Detected osu!lazer schema version ${schemaReport.version}; continuing with reflected compatibility checks.`);
|
|
604
|
-
}
|
|
605
|
-
const beatmapSets = getBeatmapSets2(realm);
|
|
606
|
-
const uniqueBeatmaps = buildPlaylist(beatmapSets, argv.osuDataDir);
|
|
607
|
-
console.log(`beatmap songs: ${uniqueBeatmaps.length}`);
|
|
608
|
-
if (argv.exportPlaylist) {
|
|
609
|
-
console.log(`[INFO] Exporting playlist to ${argv.exportPlaylist}`);
|
|
610
|
-
const playlist = uniqueBeatmaps.map((beatmap) => beatmap.path).join(`
|
|
611
|
-
`);
|
|
612
|
-
writeFileSync(argv.exportPlaylist, playlist);
|
|
613
|
-
console.log(`[INFO] Done. Use something like \`mpv --playlist=${path2.resolve(argv.exportPlaylist)}\` to play the playlist`);
|
|
614
|
-
return;
|
|
615
|
-
}
|
|
616
|
-
const selectedBeatmap = await promptForBeatmap(uniqueBeatmaps);
|
|
617
|
-
if (selectedBeatmap === undefined) {
|
|
618
|
-
console.log("[INFO] Cancelled");
|
|
619
|
-
return;
|
|
620
|
-
}
|
|
621
|
-
console.log(`Selected: ${selectedBeatmap}`);
|
|
622
|
-
for (let i = selectedBeatmap;i < uniqueBeatmaps.length; i += 1) {
|
|
623
|
-
const beatmap = uniqueBeatmaps[i];
|
|
624
|
-
if (!beatmap) {
|
|
625
|
-
continue;
|
|
626
|
-
}
|
|
627
|
-
console.log(`Map: ${beatmap.title}`);
|
|
628
|
-
if (beatmap.path && existsSync2(beatmap.path)) {
|
|
629
|
-
console.log(`Playing ${beatmap.title}`);
|
|
630
|
-
await openBeatmap(beatmap.path);
|
|
631
|
-
} else {
|
|
632
|
-
console.log(`File does not exist: ${beatmap.path}`);
|
|
633
|
-
}
|
|
634
|
-
if (i < uniqueBeatmaps.length - 1) {
|
|
635
|
-
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
636
|
-
} else if (argv.loop) {
|
|
637
|
-
console.log("[INFO] Looping playlist");
|
|
638
|
-
i = -1;
|
|
639
|
-
} else {
|
|
640
|
-
console.log("[INFO] Done. Use --loop to loop the playlist");
|
|
641
|
-
}
|
|
642
|
-
}
|
|
1504
|
+
return buildPlaylist(getBeatmapSets2(realm), osuDataDir);
|
|
643
1505
|
} finally {
|
|
644
1506
|
realm.close();
|
|
645
1507
|
}
|
|
646
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
|
+
}
|
|
647
1560
|
|
|
648
1561
|
// src/cli.ts
|
|
649
1562
|
main().then(() => {
|
|
@@ -653,4 +1566,4 @@ main().then(() => {
|
|
|
653
1566
|
process.exit(1);
|
|
654
1567
|
});
|
|
655
1568
|
|
|
656
|
-
//# debugId=
|
|
1569
|
+
//# debugId=FF1120811E982FF064756E2164756E21
|