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.js CHANGED
@@ -445,11 +445,9 @@ var init_mod3 = __esm(() => {
445
445
  });
446
446
 
447
447
  // src/core/cli/main.ts
448
- init_mod();
449
- import path2 from "node:path";
450
- import { execFile } from "node:child_process";
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
- var execFilePromise = promisify(execFile);
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 from the terminal
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 on end"
1482
+ describe: "Loop the playlist when playback reaches the end"
541
1483
  }).alias("help", "h").help().parse();
542
1484
  }
543
- async function promptForBeatmap(uniqueBeatmaps) {
544
- const { default: prompts } = await import("prompts");
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
- if (schemaReport.version !== LAST_STATIC_LAZER_SCHEMA_VERSION2) {
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=C02C7B41BEFC6C2964756E2164756E21
1569
+ //# debugId=FF1120811E982FF064756E2164756E21