blyuecode 0.7.1 → 0.8.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/dist/core/deps.js +99 -0
- package/dist/core/image.js +69 -0
- package/dist/core/media.js +210 -0
- package/dist/core/player.js +208 -0
- package/dist/core/videoPlayer.js +44 -0
- package/dist/core/ytdlp.js +153 -0
- package/dist/index.js +36 -5
- package/dist/ui/Banner.js +10 -3
- package/dist/ui/Chat.js +438 -1
- package/dist/ui/InputBar.js +8 -2
- package/dist/ui/MascotPrompt.js +27 -6
- package/dist/ui/SplashBanner.js +119 -0
- package/dist/ui/splash.js +121 -0
- package/package.json +1 -1
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
const execAsync = promisify(exec);
|
|
5
|
+
export const MEDIA_DEPS = [
|
|
6
|
+
{ name: 'yt-dlp', role: 'search + download' },
|
|
7
|
+
{ name: 'ffmpeg', role: 'extract audio, merge video' },
|
|
8
|
+
{ name: 'mpv', role: 'playback' },
|
|
9
|
+
];
|
|
10
|
+
export function detectPlatform() {
|
|
11
|
+
const isTermux = !!process.env.TERMUX_VERSION ||
|
|
12
|
+
!!process.env.PREFIX?.includes('com.termux');
|
|
13
|
+
if (isTermux) {
|
|
14
|
+
return {
|
|
15
|
+
os: 'termux',
|
|
16
|
+
installCmd: 'pkg install -y yt-dlp ffmpeg mpv',
|
|
17
|
+
updateHint: 'Termux packages (native ARM builds)',
|
|
18
|
+
approximateSizeMb: 180,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const p = os.platform();
|
|
22
|
+
if (p === 'linux') {
|
|
23
|
+
return {
|
|
24
|
+
os: 'linux',
|
|
25
|
+
installCmd: 'sudo apt install -y yt-dlp ffmpeg mpv',
|
|
26
|
+
updateHint: 'apt (Debian/Ubuntu/MX)',
|
|
27
|
+
approximateSizeMb: 220,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (p === 'darwin') {
|
|
31
|
+
return {
|
|
32
|
+
os: 'darwin',
|
|
33
|
+
installCmd: 'brew install yt-dlp ffmpeg mpv',
|
|
34
|
+
updateHint: 'Homebrew',
|
|
35
|
+
approximateSizeMb: 250,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
if (p === 'win32') {
|
|
39
|
+
return {
|
|
40
|
+
os: 'win32',
|
|
41
|
+
installCmd: 'winget install yt-dlp.yt-dlp Gyan.FFmpeg mpv.net',
|
|
42
|
+
updateHint: 'winget',
|
|
43
|
+
approximateSizeMb: 260,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
os: 'unknown',
|
|
48
|
+
installCmd: null,
|
|
49
|
+
updateHint: 'install yt-dlp, ffmpeg, mpv manually',
|
|
50
|
+
approximateSizeMb: 0,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export async function hasBinary(name) {
|
|
54
|
+
try {
|
|
55
|
+
await execAsync(`command -v ${name}`);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export async function checkMediaDeps() {
|
|
63
|
+
const results = await Promise.all(MEDIA_DEPS.map(async (d) => ({ name: d.name, ok: await hasBinary(d.name) })));
|
|
64
|
+
const missing = results.filter((r) => !r.ok).map((r) => r.name);
|
|
65
|
+
const present = results.filter((r) => r.ok).map((r) => r.name);
|
|
66
|
+
return {
|
|
67
|
+
missing,
|
|
68
|
+
present,
|
|
69
|
+
allPresent: missing.length === 0,
|
|
70
|
+
platform: detectPlatform(),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
export async function installMediaDeps(onOutput) {
|
|
74
|
+
const platform = detectPlatform();
|
|
75
|
+
const cmd = platform.installCmd;
|
|
76
|
+
if (!cmd) {
|
|
77
|
+
return { ok: false, output: 'no install command for this platform' };
|
|
78
|
+
}
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const proc = exec(cmd, { maxBuffer: 10 * 1024 * 1024 });
|
|
81
|
+
let buf = '';
|
|
82
|
+
proc.stdout?.on('data', (c) => {
|
|
83
|
+
const s = c.toString();
|
|
84
|
+
buf += s;
|
|
85
|
+
onOutput?.(s);
|
|
86
|
+
});
|
|
87
|
+
proc.stderr?.on('data', (c) => {
|
|
88
|
+
const s = c.toString();
|
|
89
|
+
buf += s;
|
|
90
|
+
onOutput?.(s);
|
|
91
|
+
});
|
|
92
|
+
proc.on('exit', (code) => {
|
|
93
|
+
resolve({ ok: code === 0, output: buf.slice(-2000) });
|
|
94
|
+
});
|
|
95
|
+
proc.on('error', (err) => {
|
|
96
|
+
resolve({ ok: false, output: err.message });
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
const API = 'https://api.replicate.com/v1';
|
|
5
|
+
const MODEL = 'black-forest-labs/flux-schnell';
|
|
6
|
+
const IMAGES_DIR = path.join(os.homedir(), '.blyue', 'images');
|
|
7
|
+
function apiKey() {
|
|
8
|
+
const k = process.env.REPLICATE_API_KEY || process.env.IMAGE_API_KEY;
|
|
9
|
+
if (!k) {
|
|
10
|
+
throw new Error('No image API key. Set REPLICATE_API_KEY in ~/.blyue/.env — get one at replicate.com/account/api-tokens');
|
|
11
|
+
}
|
|
12
|
+
return k;
|
|
13
|
+
}
|
|
14
|
+
async function predict(prompt) {
|
|
15
|
+
const res = await fetch(`${API}/models/${MODEL}/predictions`, {
|
|
16
|
+
method: 'POST',
|
|
17
|
+
headers: {
|
|
18
|
+
Authorization: `Bearer ${apiKey()}`,
|
|
19
|
+
'Content-Type': 'application/json',
|
|
20
|
+
Prefer: 'wait=60',
|
|
21
|
+
},
|
|
22
|
+
body: JSON.stringify({
|
|
23
|
+
input: { prompt, num_outputs: 1, aspect_ratio: '1:1', output_format: 'png' },
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
const text = await res.text();
|
|
28
|
+
throw new Error(`Replicate ${res.status}: ${text.slice(0, 200)}`);
|
|
29
|
+
}
|
|
30
|
+
return (await res.json());
|
|
31
|
+
}
|
|
32
|
+
async function poll(id, timeoutMs = 120_000) {
|
|
33
|
+
const start = Date.now();
|
|
34
|
+
while (Date.now() - start < timeoutMs) {
|
|
35
|
+
const res = await fetch(`${API}/predictions/${id}`, {
|
|
36
|
+
headers: { Authorization: `Bearer ${apiKey()}` },
|
|
37
|
+
});
|
|
38
|
+
const p = (await res.json());
|
|
39
|
+
if (p.status === 'succeeded' || p.status === 'failed' || p.status === 'canceled') {
|
|
40
|
+
return p;
|
|
41
|
+
}
|
|
42
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
43
|
+
}
|
|
44
|
+
throw new Error('image generation timed out after 120s');
|
|
45
|
+
}
|
|
46
|
+
export async function generateImage(prompt) {
|
|
47
|
+
if (!prompt.trim())
|
|
48
|
+
throw new Error('empty prompt');
|
|
49
|
+
let p = await predict(prompt);
|
|
50
|
+
if (p.status === 'starting' || p.status === 'processing')
|
|
51
|
+
p = await poll(p.id);
|
|
52
|
+
if (p.status !== 'succeeded' || !p.output) {
|
|
53
|
+
throw new Error(`generation failed: ${p.error || p.status}`);
|
|
54
|
+
}
|
|
55
|
+
const url = Array.isArray(p.output) ? p.output[0] : p.output;
|
|
56
|
+
const res = await fetch(url);
|
|
57
|
+
if (!res.ok)
|
|
58
|
+
throw new Error(`download failed: HTTP ${res.status}`);
|
|
59
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
60
|
+
fs.mkdirSync(IMAGES_DIR, { recursive: true });
|
|
61
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
62
|
+
const slug = prompt.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
|
|
63
|
+
const file = path.join(IMAGES_DIR, `${stamp}-${slug}.png`);
|
|
64
|
+
fs.writeFileSync(file, buf);
|
|
65
|
+
return { path: file, url, prompt };
|
|
66
|
+
}
|
|
67
|
+
export function imagesDir() {
|
|
68
|
+
return IMAGES_DIR;
|
|
69
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { exec } from 'child_process';
|
|
2
|
+
import { runYtdlp } from './ytdlp.js';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
const execAsync = promisify(exec);
|
|
8
|
+
const MUSIC_DIR = path.join(os.homedir(), '.blyue', 'music');
|
|
9
|
+
const VIDEO_DIR = path.join(os.homedir(), '.blyue', 'video');
|
|
10
|
+
export function musicDir() { return MUSIC_DIR; }
|
|
11
|
+
export function videoDir() { return VIDEO_DIR; }
|
|
12
|
+
export function keepMedia() {
|
|
13
|
+
return process.env.BLYUE_KEEP_MEDIA === '1';
|
|
14
|
+
}
|
|
15
|
+
export async function hasBinary(name) {
|
|
16
|
+
try {
|
|
17
|
+
await execAsync(`command -v ${name}`);
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function checkDeps() {
|
|
25
|
+
const [ytdlp, ffmpeg, mpv] = await Promise.all([
|
|
26
|
+
hasBinary('yt-dlp'), hasBinary('ffmpeg'), hasBinary('mpv'),
|
|
27
|
+
]);
|
|
28
|
+
const missing = [];
|
|
29
|
+
if (!ytdlp)
|
|
30
|
+
missing.push('yt-dlp');
|
|
31
|
+
if (!ffmpeg)
|
|
32
|
+
missing.push('ffmpeg');
|
|
33
|
+
if (!mpv)
|
|
34
|
+
missing.push('mpv');
|
|
35
|
+
return { ytdlp, ffmpeg, mpv, ok: missing.length === 0, missing };
|
|
36
|
+
}
|
|
37
|
+
export async function searchYouTubeN(query, n = 5) {
|
|
38
|
+
const r = await runYtdlp([
|
|
39
|
+
'ytsearch' + n + ':' + query,
|
|
40
|
+
'--dump-json',
|
|
41
|
+
'--no-warnings',
|
|
42
|
+
'--skip-download',
|
|
43
|
+
'--default-search', 'ytsearch' + n,
|
|
44
|
+
]);
|
|
45
|
+
if (!r.ok) {
|
|
46
|
+
throw new Error(r.error?.userMessage || 'yt-dlp search failed');
|
|
47
|
+
}
|
|
48
|
+
const lines = r.stdout.trim().split('\n').filter(Boolean);
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
try {
|
|
52
|
+
const info = JSON.parse(line);
|
|
53
|
+
if (!info.id)
|
|
54
|
+
continue;
|
|
55
|
+
out.push({
|
|
56
|
+
id: String(info.id),
|
|
57
|
+
title: String(info.title || 'unknown'),
|
|
58
|
+
duration: Number(info.duration) || 0,
|
|
59
|
+
uploader: String(info.uploader || info.channel || ''),
|
|
60
|
+
kind: 'audio',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch { }
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
export async function searchYouTube(query) {
|
|
68
|
+
const r = await runYtdlp([
|
|
69
|
+
'ytsearch1:' + query,
|
|
70
|
+
'--dump-json',
|
|
71
|
+
'--no-warnings',
|
|
72
|
+
'--skip-download',
|
|
73
|
+
'--default-search', 'ytsearch1',
|
|
74
|
+
]);
|
|
75
|
+
if (!r.ok) {
|
|
76
|
+
throw new Error(r.error?.userMessage || 'yt-dlp search failed');
|
|
77
|
+
}
|
|
78
|
+
const first = r.stdout.trim().split('\n')[0];
|
|
79
|
+
if (!first)
|
|
80
|
+
throw new Error('no results');
|
|
81
|
+
let info;
|
|
82
|
+
try {
|
|
83
|
+
info = JSON.parse(first);
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
throw new Error('could not parse yt-dlp output: ' + err.message);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
id: String(info.id || ''),
|
|
90
|
+
title: String(info.title || 'unknown'),
|
|
91
|
+
duration: Number(info.duration) || 0,
|
|
92
|
+
uploader: String(info.uploader || info.channel || ''),
|
|
93
|
+
kind: 'audio',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export async function downloadAudio(track, onProgress) {
|
|
97
|
+
fs.mkdirSync(MUSIC_DIR, { recursive: true });
|
|
98
|
+
const outTemplate = path.join(MUSIC_DIR, '%(id)s.%(ext)s');
|
|
99
|
+
onProgress?.({ phase: 'downloading' });
|
|
100
|
+
const r = await runYtdlp([
|
|
101
|
+
'-f', 'bestaudio[ext=m4a]/bestaudio[ext=webm]/bestaudio/best',
|
|
102
|
+
'--no-part',
|
|
103
|
+
'--no-mtime',
|
|
104
|
+
'--no-cache-dir',
|
|
105
|
+
'--socket-timeout', '10',
|
|
106
|
+
'--concurrent-fragments', '16',
|
|
107
|
+
'--buffer-size', '16K',
|
|
108
|
+
'--http-chunk-size', '10M',
|
|
109
|
+
'-o', outTemplate,
|
|
110
|
+
'--no-playlist',
|
|
111
|
+
'--no-warnings',
|
|
112
|
+
'https://www.youtube.com/watch?v=' + track.id,
|
|
113
|
+
]);
|
|
114
|
+
if (!r.ok)
|
|
115
|
+
throw new Error(r.error?.userMessage || 'yt-dlp download failed');
|
|
116
|
+
const candidates = fs
|
|
117
|
+
.readdirSync(MUSIC_DIR)
|
|
118
|
+
.filter((f) => f.startsWith(track.id + '.'))
|
|
119
|
+
.map((f) => path.join(MUSIC_DIR, f));
|
|
120
|
+
if (candidates.length === 0)
|
|
121
|
+
throw new Error('download completed but file missing');
|
|
122
|
+
return { ...track, file: candidates[0], kind: 'audio' };
|
|
123
|
+
}
|
|
124
|
+
export const VIDEO_QUALITIES = ['360p', '480p', '720p', '1080p', 'best'];
|
|
125
|
+
export function estimateSizeMb(durationSec, quality) {
|
|
126
|
+
if (!durationSec || durationSec <= 0)
|
|
127
|
+
return 0;
|
|
128
|
+
const bitrateMbps = {
|
|
129
|
+
'360p': 0.6,
|
|
130
|
+
'480p': 1.1,
|
|
131
|
+
'720p': 2.5,
|
|
132
|
+
'1080p': 5.0,
|
|
133
|
+
best: 8.0,
|
|
134
|
+
};
|
|
135
|
+
return Math.round((bitrateMbps[quality] * durationSec) / 8);
|
|
136
|
+
}
|
|
137
|
+
export function parseQuality(s) {
|
|
138
|
+
if (!s)
|
|
139
|
+
return '720p';
|
|
140
|
+
const t = s.toLowerCase().trim();
|
|
141
|
+
if (t === 'best' || t === 'max')
|
|
142
|
+
return 'best';
|
|
143
|
+
if (t === '1080' || t === '1080p' || t === 'fhd')
|
|
144
|
+
return '1080p';
|
|
145
|
+
if (t === '720' || t === '720p' || t === 'hd')
|
|
146
|
+
return '720p';
|
|
147
|
+
if (t === '480' || t === '480p')
|
|
148
|
+
return '480p';
|
|
149
|
+
if (t === '360' || t === '360p')
|
|
150
|
+
return '360p';
|
|
151
|
+
return '720p';
|
|
152
|
+
}
|
|
153
|
+
function formatSelector(q) {
|
|
154
|
+
if (q === 'best')
|
|
155
|
+
return 'bestvideo+bestaudio/best';
|
|
156
|
+
const h = q.replace('p', '');
|
|
157
|
+
return `bestvideo[height<=${h}]+bestaudio/best[height<=${h}]`;
|
|
158
|
+
}
|
|
159
|
+
export async function downloadVideo(track, quality, onProgress) {
|
|
160
|
+
fs.mkdirSync(VIDEO_DIR, { recursive: true });
|
|
161
|
+
const outTemplate = path.join(VIDEO_DIR, '%(id)s.%(ext)s');
|
|
162
|
+
onProgress?.({ phase: 'downloading' });
|
|
163
|
+
const formatSel = quality === '360p'
|
|
164
|
+
? '18/best[height<=360][ext=mp4]/best[height<=360]'
|
|
165
|
+
: quality === '480p'
|
|
166
|
+
? '22/best[height<=480][ext=mp4]/best[height<=480]'
|
|
167
|
+
: formatSelector(quality);
|
|
168
|
+
const args = [
|
|
169
|
+
'-f', formatSel,
|
|
170
|
+
'--extractor-args', 'youtube:player_client=android,web_safari',
|
|
171
|
+
'--no-part',
|
|
172
|
+
'--no-mtime',
|
|
173
|
+
'--no-cache-dir',
|
|
174
|
+
'--socket-timeout', '15',
|
|
175
|
+
'--concurrent-fragments', '8',
|
|
176
|
+
'--buffer-size', '16K',
|
|
177
|
+
'--http-chunk-size', '5M',
|
|
178
|
+
'--retries', '5',
|
|
179
|
+
'--fragment-retries', '5',
|
|
180
|
+
'-o', outTemplate,
|
|
181
|
+
'--no-playlist',
|
|
182
|
+
'--no-warnings',
|
|
183
|
+
'https://www.youtube.com/watch?v=' + track.id,
|
|
184
|
+
];
|
|
185
|
+
if (quality !== '360p' && quality !== '480p') {
|
|
186
|
+
args.splice(2, 0, '--merge-output-format', 'mp4');
|
|
187
|
+
}
|
|
188
|
+
const r = await runYtdlp(args);
|
|
189
|
+
if (!r.ok)
|
|
190
|
+
throw new Error(r.error?.userMessage || 'yt-dlp download failed');
|
|
191
|
+
const candidates = fs.readdirSync(VIDEO_DIR)
|
|
192
|
+
.filter((f) => f.startsWith(track.id + '.'))
|
|
193
|
+
.map((f) => path.join(VIDEO_DIR, f));
|
|
194
|
+
if (candidates.length === 0)
|
|
195
|
+
throw new Error('download completed but file missing');
|
|
196
|
+
return { ...track, file: candidates[0], kind: 'video' };
|
|
197
|
+
}
|
|
198
|
+
export function deleteTrackFile(track) {
|
|
199
|
+
if (!track.file)
|
|
200
|
+
return;
|
|
201
|
+
if (keepMedia())
|
|
202
|
+
return;
|
|
203
|
+
if (track.keep)
|
|
204
|
+
return;
|
|
205
|
+
try {
|
|
206
|
+
if (fs.existsSync(track.file))
|
|
207
|
+
fs.unlinkSync(track.file);
|
|
208
|
+
}
|
|
209
|
+
catch { }
|
|
210
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import net from 'net';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { EventEmitter } from 'events';
|
|
7
|
+
import { deleteTrackFile } from './media.js';
|
|
8
|
+
const SOCK = path.join(os.tmpdir(), `blyue-mpv-${process.pid}.sock`);
|
|
9
|
+
class Player extends EventEmitter {
|
|
10
|
+
proc = null;
|
|
11
|
+
sock = null;
|
|
12
|
+
queue = [];
|
|
13
|
+
state = {
|
|
14
|
+
playing: false, paused: false, current: null,
|
|
15
|
+
position: 0, duration: 0, loop: false,
|
|
16
|
+
autoplay: false,
|
|
17
|
+
lastQuery: '',
|
|
18
|
+
lastUploader: '',
|
|
19
|
+
};
|
|
20
|
+
connectAttempts = 0;
|
|
21
|
+
isRunning() { return this.proc !== null; }
|
|
22
|
+
getState() {
|
|
23
|
+
return {
|
|
24
|
+
...this.state,
|
|
25
|
+
queue: [...this.queue],
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
setLoop(on) { this.state.loop = on; }
|
|
29
|
+
setAutoplay(on) { this.state.autoplay = on; }
|
|
30
|
+
isAutoplay() { return this.state.autoplay; }
|
|
31
|
+
setLastQuery(q, uploader) {
|
|
32
|
+
this.state.lastQuery = q;
|
|
33
|
+
this.state.lastUploader = uploader;
|
|
34
|
+
}
|
|
35
|
+
getLastQuery() { return this.state.lastQuery; }
|
|
36
|
+
getLastUploader() { return this.state.lastUploader; }
|
|
37
|
+
isLoop() { return this.state.loop; }
|
|
38
|
+
async start() {
|
|
39
|
+
if (this.proc)
|
|
40
|
+
return;
|
|
41
|
+
try {
|
|
42
|
+
if (fs.existsSync(SOCK))
|
|
43
|
+
fs.unlinkSync(SOCK);
|
|
44
|
+
}
|
|
45
|
+
catch { }
|
|
46
|
+
this.proc = spawn('mpv', ['--no-video', '--idle=yes', `--input-ipc-server=${SOCK}`,
|
|
47
|
+
'--really-quiet', '--no-terminal'], { stdio: 'ignore', detached: false });
|
|
48
|
+
this.proc.on('exit', () => {
|
|
49
|
+
this.proc = null;
|
|
50
|
+
this.state.playing = false;
|
|
51
|
+
this.state.current = null;
|
|
52
|
+
this.emit('exit');
|
|
53
|
+
});
|
|
54
|
+
const deadline = Date.now() + 3000;
|
|
55
|
+
while (Date.now() < deadline) {
|
|
56
|
+
if (fs.existsSync(SOCK))
|
|
57
|
+
break;
|
|
58
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
59
|
+
}
|
|
60
|
+
await this.connect();
|
|
61
|
+
}
|
|
62
|
+
connect() {
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const sock = net.createConnection(SOCK);
|
|
65
|
+
sock.on('connect', () => {
|
|
66
|
+
this.sock = sock;
|
|
67
|
+
this.attachHandlers();
|
|
68
|
+
resolve();
|
|
69
|
+
});
|
|
70
|
+
sock.on('error', (err) => {
|
|
71
|
+
if (this.connectAttempts++ < 5) {
|
|
72
|
+
setTimeout(() => this.connect().then(resolve, reject), 200);
|
|
73
|
+
}
|
|
74
|
+
else
|
|
75
|
+
reject(err);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
attachHandlers() {
|
|
80
|
+
if (!this.sock)
|
|
81
|
+
return;
|
|
82
|
+
let buf = '';
|
|
83
|
+
this.sock.on('data', (chunk) => {
|
|
84
|
+
buf += chunk.toString('utf8');
|
|
85
|
+
let idx;
|
|
86
|
+
while ((idx = buf.indexOf('\n')) >= 0) {
|
|
87
|
+
const line = buf.slice(0, idx).trim();
|
|
88
|
+
buf = buf.slice(idx + 1);
|
|
89
|
+
if (!line)
|
|
90
|
+
continue;
|
|
91
|
+
try {
|
|
92
|
+
this.handleMessage(JSON.parse(line));
|
|
93
|
+
}
|
|
94
|
+
catch { }
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
handleMessage(msg) {
|
|
99
|
+
if (msg.event === 'start-file') {
|
|
100
|
+
this.state.playing = true;
|
|
101
|
+
this.state.paused = false;
|
|
102
|
+
}
|
|
103
|
+
else if (msg.event === 'end-file') {
|
|
104
|
+
void this.onEndFile();
|
|
105
|
+
}
|
|
106
|
+
else if (msg.event === 'property-change') {
|
|
107
|
+
if (msg.name === 'pause')
|
|
108
|
+
this.state.paused = msg.data === true;
|
|
109
|
+
if (msg.name === 'time-pos')
|
|
110
|
+
this.state.position = Number(msg.data) || 0;
|
|
111
|
+
if (msg.name === 'duration')
|
|
112
|
+
this.state.duration = Number(msg.data) || 0;
|
|
113
|
+
this.emit('update', this.state);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async onEndFile() {
|
|
117
|
+
const finished = this.state.current;
|
|
118
|
+
if (this.state.loop && finished?.file) {
|
|
119
|
+
this.send(['loadfile', finished.file, 'replace']);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (this.queue.length > 0) {
|
|
123
|
+
if (finished)
|
|
124
|
+
deleteTrackFile(finished);
|
|
125
|
+
const next = this.queue.shift();
|
|
126
|
+
this.state.current = next;
|
|
127
|
+
this.send(['loadfile', next.file, 'replace']);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (finished)
|
|
131
|
+
deleteTrackFile(finished);
|
|
132
|
+
this.state.playing = false;
|
|
133
|
+
this.state.current = null;
|
|
134
|
+
this.state.position = 0;
|
|
135
|
+
this.state.duration = 0;
|
|
136
|
+
this.emit('queue-empty');
|
|
137
|
+
}
|
|
138
|
+
send(cmd) {
|
|
139
|
+
if (!this.sock)
|
|
140
|
+
return;
|
|
141
|
+
this.sock.write(JSON.stringify({ command: cmd }) + '\n');
|
|
142
|
+
}
|
|
143
|
+
async loadPath(file, replace) {
|
|
144
|
+
this.send(['loadfile', file, replace ? 'replace' : 'append-play']);
|
|
145
|
+
this.send(['observe_property', 1, 'time-pos']);
|
|
146
|
+
this.send(['observe_property', 2, 'duration']);
|
|
147
|
+
this.send(['observe_property', 3, 'pause']);
|
|
148
|
+
}
|
|
149
|
+
async play(track) {
|
|
150
|
+
if (!track.file)
|
|
151
|
+
throw new Error('track has no file path');
|
|
152
|
+
await this.start();
|
|
153
|
+
if (!this.state.playing && this.queue.length === 0) {
|
|
154
|
+
await this.loadPath(track.file, true);
|
|
155
|
+
this.state.current = track;
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
await this.loadPath(track.file, false);
|
|
159
|
+
this.queue.push(track);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async togglePause() { this.send(['cycle', 'pause']); }
|
|
163
|
+
async next() {
|
|
164
|
+
const cur = this.state.current;
|
|
165
|
+
if (cur)
|
|
166
|
+
deleteTrackFile(cur);
|
|
167
|
+
this.send(['playlist-next']);
|
|
168
|
+
}
|
|
169
|
+
async stop() {
|
|
170
|
+
const cur = this.state.current;
|
|
171
|
+
if (cur)
|
|
172
|
+
deleteTrackFile(cur);
|
|
173
|
+
for (const t of this.queue)
|
|
174
|
+
deleteTrackFile(t);
|
|
175
|
+
this.queue = [];
|
|
176
|
+
this.send(['stop']);
|
|
177
|
+
this.state.current = null;
|
|
178
|
+
this.state.playing = false;
|
|
179
|
+
}
|
|
180
|
+
async kill() {
|
|
181
|
+
const cur = this.state.current;
|
|
182
|
+
if (cur)
|
|
183
|
+
deleteTrackFile(cur);
|
|
184
|
+
for (const t of this.queue)
|
|
185
|
+
deleteTrackFile(t);
|
|
186
|
+
this.queue = [];
|
|
187
|
+
try {
|
|
188
|
+
this.sock?.end();
|
|
189
|
+
}
|
|
190
|
+
catch { }
|
|
191
|
+
try {
|
|
192
|
+
this.sock?.destroy();
|
|
193
|
+
}
|
|
194
|
+
catch { }
|
|
195
|
+
this.sock = null;
|
|
196
|
+
try {
|
|
197
|
+
this.proc?.kill('SIGTERM');
|
|
198
|
+
}
|
|
199
|
+
catch { }
|
|
200
|
+
this.proc = null;
|
|
201
|
+
try {
|
|
202
|
+
if (fs.existsSync(SOCK))
|
|
203
|
+
fs.unlinkSync(SOCK);
|
|
204
|
+
}
|
|
205
|
+
catch { }
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
export const player = new Player();
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { deleteTrackFile } from './media.js';
|
|
3
|
+
export function isTermux() {
|
|
4
|
+
return (!!process.env.TERMUX_VERSION ||
|
|
5
|
+
!!process.env.PREFIX?.includes('com.termux'));
|
|
6
|
+
}
|
|
7
|
+
export function playVideo(track) {
|
|
8
|
+
if (isTermux()) {
|
|
9
|
+
return Promise.resolve({
|
|
10
|
+
cleanup: () => deleteTrackFile(track),
|
|
11
|
+
notice: 'video playback is desktop-only — file saved at ' +
|
|
12
|
+
(track.file || '(unknown)') +
|
|
13
|
+
' · run /cleanup to delete',
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
if (!track.file)
|
|
17
|
+
return Promise.resolve({ cleanup: () => { } });
|
|
18
|
+
return new Promise((resolve) => {
|
|
19
|
+
const file = track.file;
|
|
20
|
+
const proc = spawn('mpv', [
|
|
21
|
+
file,
|
|
22
|
+
'--force-window=yes',
|
|
23
|
+
'--keep-open=no',
|
|
24
|
+
'--really-quiet',
|
|
25
|
+
'--no-terminal',
|
|
26
|
+
'--title=BLYUE',
|
|
27
|
+
], { stdio: 'ignore', detached: false });
|
|
28
|
+
const cleanup = () => {
|
|
29
|
+
try {
|
|
30
|
+
proc.kill('SIGTERM');
|
|
31
|
+
}
|
|
32
|
+
catch { }
|
|
33
|
+
deleteTrackFile(track);
|
|
34
|
+
};
|
|
35
|
+
proc.on('exit', () => {
|
|
36
|
+
deleteTrackFile(track);
|
|
37
|
+
resolve({ cleanup });
|
|
38
|
+
});
|
|
39
|
+
proc.on('error', () => {
|
|
40
|
+
deleteTrackFile(track);
|
|
41
|
+
resolve({ cleanup });
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|