osu-play 1.1.0 → 1.2.1

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/dist/cli.cjs CHANGED
@@ -460,11 +460,9 @@ var init_mod3 = __esm(() => {
460
460
  });
461
461
 
462
462
  // src/core/cli/main.ts
463
- init_mod();
464
- var import_node_path2 = __toESM(require("node:path"));
465
- var import_node_child_process = require("node:child_process");
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
- var execFilePromise = import_node_util.promisify(import_node_child_process.execFile);
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 from the terminal
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 on end"
1492
+ describe: "Loop the playlist when playback reaches the end"
556
1493
  }).alias("help", "h").help().parse();
557
1494
  }
558
- async function promptForBeatmap(uniqueBeatmaps) {
559
- const { default: prompts } = await import("prompts");
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
- if (schemaReport.version !== LAST_STATIC_LAZER_SCHEMA_VERSION2) {
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=6FDBB7EDEE4CC0EA64756E2164756E21
1579
+ //# debugId=092198B3F20C100364756E2164756E21