-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.lua
More file actions
1794 lines (1564 loc) · 74.9 KB
/
main.lua
File metadata and controls
1794 lines (1564 loc) · 74.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
--[[
jpdb.lua — JPDB MPV Plugin (v3 — Full redesign)
DESIGN:
· Richer visual hierarchy: accent bar, gloss alternation, frequency chip
· Layered shadow system for depth (3-layer: ambient + key + fill)
· Pill-shaped state badge with tinted background
· Hover highlight on subtitle uses both underline AND brightness lift
· Dimmed non-hovered tokens for focus contrast
UX:
· Popup vertical position never clips: clamps to both top AND bottom edges
· Popup horizontal position aware of right-edge AND left-edge simultaneously
· Button click race-condition fixed: dispatch happens BEFORE close_popup()
· Action feedback rendered INSIDE the popup (inline toast row) for 1.2s
before the card auto-dismisses — no more toast over subtitle
· Scrollable meanings: if meanings > 6, a "… N more" line is shown
· Adaptive debounce: 80 ms when re-hovering the SAME token after a short
gap; 150 ms for a new token; 220 ms for leaving entirely
· All async — refresh_after_action no longer stalls the main thread
ACCURACY:
· Per-line byte→pixel map (inherited from v2) kept and improved:
- Punctuation (、。・「」) classified as full-width
- Halfwidth katakana block (U+FF65..U+FF9F) classified as half-width
- Fullwidth Latin block (U+FF01..U+FF60) classified as full-width
· Subtitle vertical hit region now tracks ASS font size precisely
(CHAR_PX_FULL = 48, border = 2, so real glyph cap ≈ 46px)
· Token overlap resolved by shortest-span-first priority
ARCHITECTURE:
· refresh_after_action is fully async (http_request_async)
· String building uses a pre-allocated table flushed per-frame
· render_subtitles and render_popup both guard on data equality
before calling :update() to avoid redundant OSD redraws
]]
local mp = require('mp')
local msg = require('mp.msg')
local assdraw = require('mp.assdraw')
local utils = require('mp.utils')
-- ─── Debug log ────────────────────────────────────────────────────────────────
-- main.lua lives inside scripts/jpdb-mpv-plugin/ so the script dir IS the plugin dir.
local PLUGIN_DIR = mp.get_script_directory()
local LOG_PATH = PLUGIN_DIR .. '/jpdb-debug.log'
local conf = dofile(PLUGIN_DIR .. '/jpdb-config.lua')
-- Load kanji semantic color categories from separate file
local kanji_semantic_colors = dofile(PLUGIN_DIR .. '/kanji-semantic-colors.lua')
-- Set to true to write a debug log file (jpdb-debug.log) and verbose messages.
-- Leave false in production — no file is created, dlog() is a no-op.
local DEBUG_LOG = conf.DEBUG_LOG
local log_file = DEBUG_LOG and io.open(LOG_PATH, 'w') or nil
if log_file then
log_file:write('=== jpdb.lua v3 started ' .. os.date('%Y-%m-%dT%H:%M:%S') .. ' ===\n')
log_file:flush()
end
local function dlog(...)
if not DEBUG_LOG then return end
local parts = {}
for _, v in ipairs({...}) do parts[#parts+1] = tostring(v) end
local line = table.concat(parts, ' ')
if log_file then
log_file:write('[' .. os.date('%H:%M:%S') .. '] ' .. line .. '\n')
log_file:flush()
end
msg.info(line)
end
-- ─── Load kanji meanings ──────────────────────────────────────────────────────
local kanji_meanings_map = {}
local function load_kanji_meanings()
local kanji_file = io.open(PLUGIN_DIR .. '/kanji_meanings.json', 'r')
if not kanji_file then
dlog('[kanji] kanji_meanings.json not found')
return
end
local content = kanji_file:read('*all')
kanji_file:close()
local ok, data = pcall(utils.parse_json, content)
if not ok or not data then
dlog('[kanji] Failed to parse kanji_meanings.json')
return
end
for _, entry in ipairs(data) do
if entry.kanji and entry.meaning then
kanji_meanings_map[entry.kanji] = entry.meaning
end
end
dlog('[kanji] Loaded ' .. tostring(#data) .. ' kanji meanings')
end
load_kanji_meanings()
local SERVER_URL = conf.SERVER_URL
local FONT_FAMILY = conf.FONT_FAMILY
-- ─── Server auto-start ────────────────────────────────────────────────────────
-- Finds jpdb-server.exe next to this script, launches it if the server is not
-- already listening on SERVER_URL, then registers this MPV instance.
-- Multiple MPV windows share the same server process safely.
-- jpdb-server.exe lives in the jpdb-mpv-plugin subfolder.
local SERVER_BIN = PLUGIN_DIR .. '/jpdb-server.exe'
local function server_ping(on_result)
-- Quick /status check; on_result(true) if server is up, on_result(false) otherwise
mp.command_native_async({
name='subprocess',
args={'curl','-s','--max-time','2', SERVER_URL..'/status'},
capture_stdout=true, capture_stderr=true, playback_only=false,
}, function(success, res)
on_result(success and res and res.status == 0 and res.stdout ~= '')
end)
end
local function register_with_server()
mp.command_native_async({
name='subprocess',
args={'curl','-s','-X','POST','--max-time','5', SERVER_URL..'/register'},
capture_stdout=true, capture_stderr=true, playback_only=false,
}, function(success, res)
if success and res and res.status == 0 then
dlog('[jpdb] Registered with server')
else
dlog('[jpdb] WARNING: could not register with server')
end
end)
end
local function launch_server_then_register()
dlog('[jpdb] Starting jpdb-server.exe...')
-- detached=true so the process outlives this Lua call;
-- playback_only=false so it keeps running even when paused.
mp.command_native_async({
name='subprocess',
args={SERVER_BIN},
detach=true, playback_only=false,
}, function() end) -- fire and forget
-- Poll until the server responds (up to ~5 s)
local attempts = 0
local function poll()
attempts = attempts + 1
server_ping(function(up)
if up then
dlog('[jpdb] Server is up after ' .. attempts .. ' poll(s)')
register_with_server()
mp.osd_message('[jpdb] Server started ✓', 2)
elseif attempts < 20 then
mp.add_timeout(0.3, poll)
else
dlog('[jpdb] ERROR: server did not start in time')
mp.osd_message('[jpdb] ERROR: server failed to start!', 5)
end
end)
end
mp.add_timeout(0.5, poll) -- give the process a moment before first ping
end
-- On mpv startup: check if server is running; if yes just register,
-- if no launch it first.
mp.add_timeout(0.3, function()
server_ping(function(up)
if up then
dlog('[jpdb] Server already running — registering')
register_with_server()
mp.osd_message('[jpdb v3] JPDB ready ✔', 2)
else
launch_server_then_register()
end
end)
end)
-- ══════════════════════════════════════════════════════════════════════════════
-- ─── Design Tokens ────────────────────────────────────────────────────────────
-- ══════════════════════════════════════════════════════════════════════════════
--
-- All colours are in ASS BGR hex (&HBBGGRR&).
-- Alpha 00 = fully opaque, FF = fully transparent.
local DS = conf.DS
local STATE_COLORS = conf.STATE_COLORS
-- Dimmer palette for non-hovered tokens (subtle — just slightly muted)
local STATE_COLORS_DIM = conf.STATE_COLORS_DIM
local STATE_ALPHA = conf.STATE_ALPHA
local STATE_ALPHA_DIM = conf.STATE_ALPHA_DIM
local STATE_LABELS = conf.STATE_LABELS
-- ─── Runtime state ────────────────────────────────────────────────────────────
local current_tokens = {}
local current_text = ''
local last_parsed_text = nil
local hovered_token = nil
local hover_x = 0
local hover_y = 0
local subtitle_regions = {}
local jpdb_did_pause = false
local popup_visible = false
local popup_token = nil
local popup_osd = nil
local sub_osd = nil
local popup_buttons = {}
local hovered_button = nil -- key string or nil
local popup_rect = nil
-- Debounce
local hover_pending_token = nil
local hover_debounce_timer = nil
local last_hover_time = 0 -- for adaptive debounce
-- OSD canvas
local osd_w = 1280
local osd_h = 720
-- Subtitle ASS cache
local cached_sub_ass = nil
local cached_sub_token_id = nil
-- Inline toast inside popup
local popup_toast_text = nil
local popup_toast_ok = true
local popup_toast_timer = nil
-- ─── HTTP helpers ─────────────────────────────────────────────────────────────
local function http_request_async(method, path, body_table, on_done)
local body_json = body_table and utils.format_json(body_table) or ''
local args = {
'curl', '-s', '-X', method,
'--max-time', '15',
'-H', 'Content-Type: application/json',
SERVER_URL .. path,
}
if body_json ~= '' then args[#args+1] = '-d'; args[#args+1] = body_json end
mp.command_native_async({
name = 'subprocess', args = args,
capture_stdout = true, capture_stderr = true, playback_only = false,
}, function(success, res, err)
if not success or res.status ~= 0 then
msg.error('[jpdb] request failed: ' .. (err or (res and res.stderr) or 'unknown'))
if on_done then on_done(nil, 'request failed') end
return
end
local ok, data = pcall(utils.parse_json, res.stdout)
if not ok or data == nil then
if on_done then on_done(nil, 'bad JSON') end
return
end
if on_done then on_done(data, data.error) end
end)
end
local function url_encode(str)
if not str then return '' end
str = string.gsub(str, "\n", "\r\n")
str = string.gsub(str, "([^%w _%%%-%.~])", function(c)
return string.format("%%%02X", string.byte(c))
end)
str = string.gsub(str, " ", "+")
return str
end
-- ══════════════════════════════════════════════════════════════════════════════
-- ─── UTF-8 / Unicode Width ────────────────────────────────────────────────────
-- ══════════════════════════════════════════════════════════════════════════════
--
-- Glyph widths matched to \\fs48 rendering in Yu Gothic UI:
-- Full-width (48 px): CJK ideographs, Hiragana, Katakana, fullwidth Latin/punct
-- Half-width (26 px): ASCII, Latin extensions, Greek, halfwidth Katakana
local SUB_CONF = conf.SUBTITLE_OVERLAY
local PX_FULL = SUB_CONF.px_full
local PX_HALF = SUB_CONF.px_half
local LINE_H = SUB_CONF.line_h
-- Returns (pixel_width, next_byte_index) for the UTF-8 character at byte i.
-- Refined classification vs v2:
-- · Halfwidth Katakana (U+FF65..U+FF9F) → half [3-byte, lead E0..EF range check]
-- · Fullwidth Latin (U+FF01..U+FF60) → full
-- · CJK Compat Ideographs (U+F900..U+FAFF) → full
local function char_px(s, i)
local b = s:byte(i)
if b < 0x80 then return PX_HALF, i + 1 end -- ASCII
if b < 0xE0 then return PX_HALF, i + 2 end -- 2-byte (Latin, Greek, etc.)
if b < 0xF0 then
-- 3-byte: decode first 2 bytes to get codepoint block
local b2 = s:byte(i + 1) or 0x80
-- U+3000..U+FFFF → lead E3..EF is full-width
-- BUT U+FF65..U+FF9F (halfwidth Katakana) lead=EF, b2=BD
if b >= 0xE3 then
if b == 0xEF and b2 == 0xBD then
-- U+FF40..U+FF7F — halfwidth Katakana starts at 0xEF 0xBD 0xA5
local b3 = s:byte(i + 2) or 0x80
if b3 >= 0xA5 then return PX_HALF, i + 3 end -- halfwidth kana
end
return PX_FULL, i + 3
end
return PX_HALF, i + 3 -- U+0800..U+2FFF
end
return PX_FULL, i + 4 -- 4-byte CJK ext
end
-- Build byte→pixel cumulative map for one line of text.
-- map[k] = px offset of the character whose first byte is at 1-indexed position k.
-- map[#text+1] = total pixel width (sentinel).
local function build_px_map(text)
local map = {}
local i, px = 1, 0
local len = #text
while i <= len do
map[i] = px
local cw, ni = char_px(text, i)
px = px + cw
i = ni
end
map[len + 1] = px
return map, px
end
local function utf8_len(s)
local n, i, len = 0, 1, #s
while i <= len do
local b = s:byte(i)
if b < 0x80 then i = i + 1
elseif b < 0xE0 then i = i + 2
elseif b < 0xF0 then i = i + 3
else i = i + 4 end
n = n + 1
end
return n
end
local function split_lines(text)
local result, i, ls = {}, 1, 1
local len = #text
while i <= len do
local b = text:byte(i)
if b == 10 then
result[#result+1] = { text = text:sub(ls, i-1), byte_start = ls - 1 }
ls = i + 1
i = i + 1
else
if b < 0x80 then i = i + 1
elseif b < 0xE0 then i = i + 2
elseif b < 0xF0 then i = i + 3
else i = i + 4 end
end
end
result[#result+1] = { text = text:sub(ls), byte_start = ls - 1 }
return result
end
-- ─── ASS helpers ──────────────────────────────────────────────────────────────
local function esc(s)
if not s then return '' end
return (s:gsub('\\','\\\\'):gsub('{','\\{'):gsub('}','\\}'):gsub('\n','\\N'))
end
local fmt = string.format
-- Filled rectangle
local function rect(ev, x, y, w, h, col, al)
if w <= 0 or h <= 0 then return end
ev[#ev+1] = fmt(
'{\\an7\\pos(%d,%d)\\bord0\\shad0\\1c%s\\1a%s\\p1}m 0 0 l %d 0 %d %d 0 %d{\\p0}',
x, y, col, al, w, w, h, h)
end
-- Horizontal divider
local function divider(ev, x, y, w)
rect(ev, x, y, w, DS.divider_h, DS.bg_divider, '&H00&')
end
-- Left-aligned text
local function text(ev, x, y, fn, sz, bold, col, al, s)
ev[#ev+1] = fmt(
'{\\an7\\pos(%d,%d)\\fn%s\\fs%d\\b%d\\bord0\\shad0\\1c%s\\1a%s}%s',
x, y, fn, sz, bold and 1 or 0, col, al, esc(s))
end
-- Centre-aligned text
local function textc(ev, cx, cy, fn, sz, bold, col, al, s)
ev[#ev+1] = fmt(
'{\\an5\\pos(%d,%d)\\fn%s\\fs%d\\b%d\\bord0\\shad0\\1c%s\\1a%s}%s',
cx, cy, fn, sz, bold and 1 or 0, col, al, esc(s))
end
-- ─── Helpers ──────────────────────────────────────────────────────────────────
local function get_primary_state(card_state)
if not card_state or #card_state == 0 then return 'not-in-deck' end
for _, s in ipairs(card_state) do
if STATE_COLORS[s] then return s end
end
return card_state[1] or 'not-in-deck'
end
local PARTS_OF_SPEECH = {
n='Noun', pn='Pronoun', pref='Prefix', suf='Suffix',
name='Name', ['name-fem']='Feminine Name', ['name-male']='Masculine Name',
['name-surname']='Surname', ['name-person']='Personal Name',
['name-place']='Place Name', ['name-company']='Company Name',
['adj-i']='い-Adj', ['adj-na']='な-Adj', ['adj-no']='の-Adj',
['adj-pn']='Adjectival', adv='Adverb',
aux='Auxiliary', ['aux-v']='Aux Verb', ['aux-adj']='Aux Adj',
conj='Conjunction', cop='Copula', ctr='Counter',
exp='Expression', int='Interjection', num='Numeric', prt='Particle',
vt='Trans. Verb', vi='Intrans. Verb',
v1='Ichidan Verb', v5='Godan Verb', vk='Irreg. Verb (くる)',
vs='する Verb', vz='ずる Verb',
}
local function pos_label(pos_list)
if not pos_list or #pos_list == 0 then return '' end
local out = {}
for _, p in ipairs(pos_list) do out[#out+1] = PARTS_OF_SPEECH[p] or p end
return table.concat(out, ' · ')
end
local function wrap_text(s, max_ch)
local lines, cur, cur_n = {}, {}, 0
for word in s:gmatch('%S+') do
local wn = utf8_len(word)
if cur_n > 0 and cur_n + 1 + wn > max_ch then
lines[#lines+1] = table.concat(cur, ' ')
cur = { word }; cur_n = wn
else
cur[#cur+1] = word
cur_n = cur_n + (cur_n > 0 and 1 or 0) + wn
end
end
if #cur > 0 then lines[#lines+1] = table.concat(cur, ' ') end
return lines
end
-- ─── Pitch Accent Helpers ─────────────────────────────────────────────────────
-- Split a hiragana/katakana reading string into morae.
-- Handles digraphs (e.g. き+ゃ = きゃ, シ+ョ = ショ) and lone characters.
local DIGRAPH_SMALL = {
-- small hiragana
['ぁ']=true,['ぃ']=true,['ぅ']=true,['ぇ']=true,['ぉ']=true,
['ゃ']=true,['ゅ']=true,['ょ']=true,['ゎ']=true,
-- small katakana
['ァ']=true,['ィ']=true,['ゥ']=true,['ェ']=true,['ォ']=true,
['ャ']=true,['ュ']=true,['ョ']=true,['ヮ']=true,
}
local function split_morae(reading)
local morae = {}
-- Guard: only accept actual strings
if type(reading) ~= 'string' or reading == '' then return morae end
local i, len = 1, #reading
while i <= len do
local b = reading:byte(i)
local clen
if b < 0x80 then clen = 1
elseif b < 0xE0 then clen = 2
elseif b < 0xF0 then clen = 3
else clen = 4 end
local ch = reading:sub(i, i + clen - 1)
i = i + clen
-- peek at next char — if it is a small kana it merges with current
if i <= len then
local nb = reading:byte(i)
local nlen
if nb < 0x80 then nlen = 1
elseif nb < 0xE0 then nlen = 2
elseif nb < 0xF0 then nlen = 3
else nlen = 4 end
local nch = reading:sub(i, i + nlen - 1)
if DIGRAPH_SMALL[nch] then
morae[#morae+1] = ch .. nch
i = i + nlen
else
morae[#morae+1] = ch
end
else
morae[#morae+1] = ch
end
end
return morae
end
-- Parse JPDB's pitch_accent field.
-- JPDB returns: an array of strings like ["LHH"] or ["LHHL"]
-- where L=low mora, H=high mora.
-- We use the first pattern in the array.
-- Returns: (pattern_string, label_string) or (nil, nil)
-- e.g. "LHH" -> ('LHH', '平') "HLL" -> ('HLL', '頭')
local function parse_pitch_accent(pa)
if not pa then return nil, nil end
-- Handle: array of strings
if type(pa) == 'table' and #pa > 0 then
local first = pa[1]
if type(first) == 'string' and #first > 0 then
-- Classify pattern for label
local pat = first:upper()
local label
if pat:match('^LH*$') then
label = '平' -- heiban: starts low, all rest high (LHHH...)
elseif pat:match('^H') then
label = '頭' -- atamadaka: starts high
elseif pat:match('H+L') then
label = '中' -- nakadaka: rises then drops in middle
else
label = '平' -- default to heiban
end
return pat, label
end
end
-- Handle: single string (shouldn't happen but be safe)
if type(pa) == 'string' and #pa > 0 then
return pa:upper(), '?'
end
return nil, nil
end
-- Pitch accent layout constants (JPDB line-style)
-- Row contains: 3px top line space + kana text + 3px bottom line space + gap
local PA_KANA_FS = 18 -- font size for mora kana in the pitch bar
local PA_KANA_H = 22 -- pixel height of the kana line
local PA_LINE_T = 2 -- thickness of over/underline
local PA_VERT_T = 2 -- thickness of vertical connector
local PA_TOP_PAD = 4 -- space above kana for the overline
local PA_BOT_PAD = 4 -- space below kana for the underline
local PA_MORA_W = 22 -- fixed width per mora cell
local PA_GAP = 2 -- horizontal gap between mora cells (for vertical line)
local PA_ROW_H = PA_TOP_PAD + PA_KANA_H + PA_BOT_PAD + PA_LINE_T + 6
-- Extract individual kanji characters from a string
local function extract_kanji(text)
local kanji_list = {}
local i = 1
local len = #text
while i <= len do
local b = text:byte(i)
local char_len
if b < 0x80 then
char_len = 1
elseif b < 0xE0 then
char_len = 2
elseif b < 0xF0 then
char_len = 3
else
char_len = 4
end
local char = text:sub(i, i + char_len - 1)
-- Check if this character has a meaning (is a kanji)
if kanji_meanings_map[char] then
kanji_list[#kanji_list + 1] = {
kanji = char,
meaning = kanji_meanings_map[char]
}
end
i = i + char_len
end
return kanji_list
end
-- ══════════════════════════════════════════════════════════════════════════════
-- ─── Subtitle Overlay ─────────────────────────────────────────────────────────
-- ══════════════════════════════════════════════════════════════════════════════
-- Build the ASS inline tag string for a subtitle.
-- Hovered token → full colour + underline
-- Other tokens → dim colour (focus contrast)
-- Gaps → white
local function build_subtitle_ass(tokens, raw_text)
if not tokens or #tokens == 0 then return nil end
local parts = {}
local last = 0
-- Sort by start, resolve overlaps: prefer shorter span
local sorted = {}
for _, t in ipairs(tokens) do sorted[#sorted+1] = t end
table.sort(sorted, function(a, b)
if a.start ~= b.start then return a.start < b.start end
return (a['end'] - a.start) < (b['end'] - b.start)
end)
local has_hovered = (hovered_token ~= nil)
for _, tok in ipairs(sorted) do
if tok.start >= last then
if tok.start > last then
-- gap text: pure white, slightly dim if something is hovered
local gap_al = has_hovered and '&H22&' or '&H00&'
parts[#parts+1] = '{\\c&HFFFFFF&\\1a' .. gap_al .. '\\u0}'
parts[#parts+1] = esc(raw_text:sub(last + 1, tok.start))
end
local state = get_primary_state(tok.card.state)
local is_hov = (tok == hovered_token)
local color = is_hov and (STATE_COLORS[state] or '&HFFFFFF&')
or (STATE_COLORS_DIM[state] or '&HFFFFFF&')
local alpha = is_hov and (STATE_ALPHA[state] or '&H00&')
or (STATE_ALPHA_DIM[state] or '&H00&')
local under = is_hov and '\\u1' or '\\u0'
parts[#parts+1] = '{\\c' .. color .. '\\1a' .. alpha .. under .. '}'
parts[#parts+1] = esc(raw_text:sub(tok.start + 1, tok['end']))
last = tok['end']
end
end
if last < #raw_text then
local gap_al = has_hovered and '&H22&' or '&H00&'
parts[#parts+1] = '{\\c&HFFFFFF&\\1a' .. gap_al .. '\\u0}'
parts[#parts+1] = esc(raw_text:sub(last + 1))
end
return table.concat(parts)
end
local function subtitle_layout()
local sub_y = osd_h - SUB_CONF.pos_y_offset
local lines = split_lines(current_text)
local n = #lines
return {
n = n,
sub_y = sub_y,
sub_text_top = sub_y - n * LINE_H,
lines = lines,
}
end
local function render_subtitles()
if not sub_osd then return end
subtitle_regions = {}
dlog('[render_subtitles] tokens=' .. #current_tokens .. ' hovered=' .. tostring(hovered_token ~= nil))
if #current_tokens == 0 then
if sub_osd.data ~= '' then sub_osd.data = ''; sub_osd:update() end
return
end
local tok_id = #current_tokens .. ':' .. tostring(hovered_token)
local ass_content
if cached_sub_token_id == tok_id and cached_sub_ass then
ass_content = cached_sub_ass
else
ass_content = build_subtitle_ass(current_tokens, current_text)
cached_sub_ass = ass_content
cached_sub_token_id = tok_id
end
if not ass_content then
if sub_osd.data ~= '' then sub_osd.data = ''; sub_osd:update() end
return
end
local layout = subtitle_layout()
local sub_x = math.floor(osd_w / 2)
for li, ln in ipairs(layout.lines) do
local from_bottom = layout.n - li
local line_bottom = layout.sub_y - from_bottom * LINE_H
-- Vertical hit region: cap height of glyph with 2px border
local y1 = line_bottom - math.floor(SUB_CONF.font_size * 0.96) - 4
local y2 = line_bottom + 8
local px_map, total_px = build_px_map(ln.text)
local line_left = sub_x - total_px / 2
local lbs = ln.byte_start
local lbe = lbs + #ln.text
-- Build hit regions sorted by span length (shorter = higher priority)
local line_toks = {}
for _, tok in ipairs(current_tokens) do
if tok.start >= lbs and tok['end'] <= lbe + 1 then
line_toks[#line_toks+1] = tok
end
end
table.sort(line_toks, function(a, b)
return (a['end'] - a.start) < (b['end'] - b.start)
end)
for _, tok in ipairs(line_toks) do
local ls = tok.start - lbs -- 0-indexed in line
local le = tok['end'] - lbs -- 0-indexed
local px0 = px_map[ls + 1] or 0
local px1 = px_map[le + 1] or total_px
subtitle_regions[#subtitle_regions+1] = {
x1 = math.floor(line_left + px0),
x2 = math.floor(line_left + px1),
y1 = y1,
y2 = y2,
token = tok,
span = le - ls,
}
end
end
local a = assdraw.ass_new()
a:new_event()
a:append('{\\an2\\pos(' .. sub_x .. ',' .. layout.sub_y .. ')\\fs' .. SUB_CONF.font_size .. '\\bord2\\shad1\\b0}')
a:append(ass_content)
if sub_osd.data ~= a.text then
sub_osd.data = a.text
sub_osd:update()
end
end
-- ══════════════════════════════════════════════════════════════════════════════
-- ─── Popup Rendering ──────────────────────────────────────────────────────────
-- ══════════════════════════════════════════════════════════════════════════════
local BTN_MAP = conf.BTN_MAP
local BTN_SHORTCUTS = conf.BTN_SHORTCUTS
local WRAP_CHARS = 44
local MAX_MEANINGS = 6
-- Forward declaration
local render_popup
-- Show inline toast inside the popup
local function show_popup_toast(msg_text, ok)
dlog('[show_popup_toast] msg=' .. msg_text .. ' ok=' .. tostring(ok))
popup_toast_text = msg_text
popup_toast_ok = (ok ~= false)
if popup_toast_timer then popup_toast_timer:kill() end
popup_toast_timer = mp.add_timeout(1.4, function()
popup_toast_timer = nil
popup_toast_text = nil
dlog('[show_popup_toast] Toast timer expired')
if popup_visible then render_popup() end
end)
render_popup()
end
render_popup = function()
if not popup_osd then return end
if not popup_visible or not popup_token then
popup_rect = nil
if popup_osd.data ~= '' then popup_osd.data = ''; popup_osd:update() end
return
end
popup_buttons = {}
local card = popup_token.card
local state = get_primary_state(card.state)
local s_color = STATE_COLORS[state] or '&HFFFFFF&'
local W = DS.width
local LB = DS.lbar_w
local PAD = DS.pad_h
local cx0 = LB + PAD -- content x offset from popup left
-- ── Height measurement pass ──────────────────────────────────────────────
-- Pre-parse pitch accent so measure_h and render both use it
-- Use pcall to make absolutely sure a bad PA value can't crash the popup
local pa_raw = card.pitchAccent
local pa_reading = type(card.reading) == 'string' and card.reading or ''
local pa_morae = split_morae(pa_reading)
local pa_pat, pa_label = parse_pitch_accent(pa_raw)
local pa_pat_len = pa_pat and #pa_pat or 0
local has_pitch = (pa_pat ~= nil and pa_pat_len > 0 and #pa_morae > 0)
local function measure_h()
local h = DS.pad_v + DS.lh_kanji
if card.spelling ~= card.reading then h = h + DS.lh_reading end
h = h + DS.lh_badge + DS.unit -- state badges row
h = h + DS.divider_h + DS.unit -- first divider
-- Kanji breakdown section - BEYOND THE IMPOSSIBLE
local kanji_list = extract_kanji(card.spelling)
if #kanji_list > 0 and #kanji_list <= 4 then
-- GIGA DRILL BREAKER: Maximum impact mode
h = h + DS.lh_kanji + DS.lh_gloss + 16 + DS.unit
h = h + DS.divider_h + DS.unit
elseif #kanji_list > 4 and #kanji_list <= 8 then
-- ARC-GURREN LAGANN: Adaptive grid
local cols = (#kanji_list <= 6) and 3 or 4
local rows = math.ceil(#kanji_list / cols)
h = h + rows * (DS.lh_kanji + DS.lh_gloss + 10 + 4) + DS.unit
h = h + DS.divider_h + DS.unit
elseif #kanji_list > 8 then
-- SUPER TENGEN TOPPA: Cosmic ribbon
h = h + DS.lh_gloss + 8 + DS.unit
h = h + DS.divider_h + DS.unit
end
local shown, lp = 0, nil
local total = #(card.meanings or {})
for _, m in ipairs(card.meanings or {}) do
if shown >= MAX_MEANINGS then break end
local pl = pos_label(m.partOfSpeech)
if pl ~= '' and pl ~= lp then h = h + DS.lh_pos; lp = pl end
h = h + #wrap_text(table.concat(m.glosses or {}, '; '), WRAP_CHARS) * DS.lh_gloss
shown = shown + 1
end
if total > MAX_MEANINGS then h = h + DS.lh_gloss end -- "…N more" line
-- Always allocate space for toast so the popup never changes size
h = h + DS.unit + DS.lh_toast
h = h + DS.unit * 2 + DS.divider_h + DS.unit
-- Only one button row now
h = h + DS.bh_action + DS.pad_v
return h
end
local total_h = measure_h()
local layout = subtitle_layout()
-- ── Popup position: never clip on any edge ───────────────────────────────
local margin = 10
-- Vertical: try to sit above subtitles; fall back to below if not enough room
local py_above = layout.sub_text_top - DS.unit - total_h
local py_below = layout.sub_y + DS.unit + 6
local py
if py_above >= margin then
py = py_above
elseif py_below + total_h <= osd_h - margin then
py = py_below
else
py = math.max(margin, math.min(py_above, osd_h - total_h - margin))
end
-- Horizontal: centre on the token; clamp to edges
local tok_cx = hover_x
for _, region in ipairs(subtitle_regions) do
if region.token == popup_token then
tok_cx = (region.x1 + region.x2) / 2; break
end
end
local px = math.max(margin, math.min(tok_cx - W / 2, osd_w - W - margin))
popup_rect = { x1=px, y1=py, x2=px+W, y2=py+total_h }
local bg = {}
local fg = {}
-- ── Layered shadow (ambient + key + fill) ────────────────────────────────
rect(bg, px+10, py+14, W, total_h, DS.shadow_ambient, '&HE0&') -- ambient
rect(bg, px+5, py+7, W, total_h, DS.shadow_key, '&HA0&') -- key
rect(bg, px+2, py+3, W+2, total_h+2, DS.shadow_fill, '&H60&') -- fill
-- ── Card body ─────────────────────────────────────────────────────────────
rect(bg, px, py, W, total_h, DS.bg_surface, '&H00&')
-- accent bar
rect(bg, px, py, LB, total_h, s_color, '&H00&')
-- top accent line (full width, very subtle)
rect(bg, px+LB, py, W-LB, 1, s_color, '&HC8&')
-- ── Header zone (slightly different bg) ──────────────────────────────────
local hdr_h = DS.pad_v + DS.lh_kanji
if card.spelling ~= card.reading then hdr_h = hdr_h + DS.lh_reading end
hdr_h = hdr_h + DS.lh_badge + DS.unit
rect(bg, px+LB, py, W-LB, hdr_h, DS.bg_header, '&H00&')
local cx = px + cx0
local cy = py + DS.pad_v
-- ── Kanji / Spelling ─────────────────────────────────────────────────────
text(fg, cx, cy, FONT_FAMILY, DS.fs_kanji, true, s_color, '&H00&', card.spelling)
cy = cy + DS.lh_kanji
-- ── Reading ──────────────────────────────────────────────────────────────
if card.spelling ~= card.reading then
text(fg, cx, cy, FONT_FAMILY, DS.fs_reading, false, DS.col_secondary, '&H00&', card.reading)
cy = cy + DS.lh_reading
end
-- ── Pitch Accent Visualization — RIGHT SIDE of header (JPDB style) ───────
-- Floats to the right of the kanji/reading, vertically centred in header.
-- Red overline = HIGH, Blue underline = LOW, Red vertical = transition.
if has_pitch then
local ok, err_pa = pcall(function()
local n = #pa_morae
if n == 0 or not pa_pat then return end
-- is_high(i): read directly from LH string, clamp at end
local function is_high(mi)
local idx = math.min(mi, pa_pat_len)
return pa_pat:sub(idx, idx) == 'H'
end
-- ASS colors (BGR format)
local COL_HIGH = '&H4343E0&' -- red #E04343
local COL_LOW = '&HE16941&' -- blue #4169E1
local COL_CONN = '&H4343E0&' -- vertical connector = red
local COL_LABEL = '&HC0A880&' -- warm-gray label
local AL_SOLID = '&H00&'
-- Right-side anchor: right-aligned inside popup
local right_edge = px + W - PAD -- right margin
-- Mora cell sizing: fit up to 10 moras, clamp between 18-26px wide
local max_bar_w = math.min(W - LB - PAD * 2 - 10, 160) -- max bar takes right portion
local mora_w = math.min(26, math.max(18,
math.floor((max_bar_w - n * PA_GAP) / n)))
local total_w = n * mora_w + (n - 1) * PA_GAP
-- Horizontal: right-align the bar
local bar_right = right_edge
local bar_x = bar_right - total_w
-- Vertical: center within the kanji+reading text zone
local hdr_text_h = DS.lh_kanji + (card.spelling ~= card.reading and DS.lh_reading or 0)
local center_y = py + DS.pad_v + hdr_text_h / 2
local over_y = math.floor(center_y - PA_KANA_H / 2 - PA_TOP_PAD)
local kana_y = over_y + PA_TOP_PAD
local under_y = kana_y + PA_KANA_H + 2
local cur_x = bar_x
for mi = 1, n do
local high = is_high(mi)
local cell_x = cur_x
local cell_end = cell_x + mora_w
-- Horizontal line: RED overline (H) or BLUE underline (L)
if high then
rect(bg, cell_x, over_y, mora_w, PA_LINE_T, COL_HIGH, AL_SOLID)
else
rect(bg, cell_x, under_y, mora_w, PA_LINE_T, COL_LOW, AL_SOLID)
end
-- Kana text centred in cell
textc(fg, cell_x + mora_w / 2, kana_y + PA_KANA_H / 2,
FONT_FAMILY, PA_KANA_FS - 2, false,
'&HFAF8F2&', AL_SOLID, tostring(pa_morae[mi]))
-- Red vertical connector at pitch transitions
if mi > 1 and is_high(mi - 1) ~= high then
local vx = cell_x - PA_GAP
local vy1 = over_y
local vy2 = under_y + PA_LINE_T
rect(bg, vx, vy1, PA_VERT_T, vy2 - vy1, COL_CONN, AL_SOLID)
end
-- Odaka trailing drop after last mora
if mi == n and high and pa_label == '尾' then
rect(bg, cell_end, over_y, PA_VERT_T,
under_y - over_y + PA_LINE_T, COL_CONN, AL_SOLID)
end
cur_x = cur_x + mora_w + PA_GAP
end
-- Pattern label to the LEFT of the bar — larger, bold, easy to read
textc(fg, bar_x - 14, kana_y + PA_KANA_H / 2,
FONT_FAMILY, 20, true, '&HEED8B0&', AL_SOLID, pa_label or '?')
end)
if not ok then dlog('[pitch_accent] render error: ' .. tostring(err_pa)) end
end
-- ── State badges ─────────────────────────────────────────────────────────
local bx = cx
for _, s in ipairs(card.state) do
local sc = STATE_COLORS[s] or DS.col_tertiary
local label = STATE_LABELS[s] or s
-- tinted badge bg pill
local bw = utf8_len(label) * 9 + 22
rect(bg, bx - 4, cy - 1, bw, DS.lh_badge, sc, DS.badge_alpha)
text(fg, bx, cy, FONT_FAMILY, DS.fs_badge, true, sc, '&H00&', label)
bx = bx + bw + 6
end
if card.frequencyRank then
text(fg, bx, cy, FONT_FAMILY, DS.fs_badge, false, DS.col_freq, '&H00&',
' · #' .. tostring(card.frequencyRank))
end
cy = cy + DS.lh_badge + DS.unit
-- ── Divider ──────────────────────────────────────────────────────────────
divider(fg, px+LB+2, cy, W-LB-4)
cy = cy + DS.divider_h + DS.unit
-- ══════════════════════════════════════════════════════════════════════════
-- ── KANJI BREAKDOWN: SUPER GALAXY DAI-GURREN MODE ─────────────────────────
-- ══════════════════════════════════════════════════════════════════════════
-- WHO THE HELL DO YOU THINK WE ARE?!
local kanji_list = extract_kanji(card.spelling)
-- Color coding by semantic category for MAXIMUM INFORMATION DENSITY
-- Based on comprehensive linguistic analysis of kanji meanings
-- Configuration loaded from kanji-semantic-colors.lua