PageSourceSearch

https://pnw-tools.uhopeinsta.workers.dev/js/app.js

js pnw-tools.uhopeinsta.workers.dev collected 2026-10-03 08:09:23 UTC 338,541 bytes, 10,586 lines download raw bytes

1/* PNW-FILE-GUIDE
2   js/app.js — inti aplikasi (daftar lagu, transpose, render chord, menu, dsb).
3   Butuh: firebase (dimuat index.html), window.PNWMotion (js/motion.js), window.PNWRec (js/recorder.js).
4   Fungsi penting: render(), transposeToken(), chordQuality(), buildCopyText(), initLottieIcons().
5   Firebase RTDB: pujianYouth/songs, /songBank, /config/locked, /live.
6 */
7
8/* v83: output CastFlow kini HANYA hidup di youthviews.html (melanjutkan
9   pemisahan tahap 1 di v82). URL lama (?mode=youthviews / youth-views /
10   views / display) dialihkan otomatis supaya bookmark lama tetap nyambung.
11   ?mode=stage (Stage Display musisi, kanal Spectate) TETAP di sini. */
12(function () {
13  var s = location.search || "";
14  if (!/[?&]mode=(youthviews|youth-views|views|display)([&#]|$)/.test(s))
15    return;
16  var q = s.replace(
17    /([?&])mode=(youthviews|youth-views|views|display)/,
18    "$1mode=display",
19  );
20  location.replace("./castflow.html" + q + (location.hash || ""));
21})();
22
23(function () {
24  // ================= KONFIGURASI ONLINE (FIREBASE) =================
25  // Biarkan KOSONG = mode lokal (edit hanya tersimpan di perangkat ini).
26  // Isi dari Firebase Console (Project settings) + aktifkan Realtime Database
27  // agar semua perubahan tampil real-time di semua perangkat.
28  const firebaseConfig = {
29    apiKey: "AIzaSyAj9FTRA3y_ZJQ3si-VU6doIV7OttTOpeM",
30    authDomain: "chord-youth-hosana.firebaseapp.com",
31    databaseURL:
32      "https://chord-youth-hosana-default-rtdb.asia-southeast1.firebasedatabase.app",
33    projectId: "chord-youth-hosana",
34    storageBucket: "chord-youth-hosana.firebasestorage.app",
35    messagingSenderId: "692806682904",
36    appId: "1:692806682904:web:dc50c1c97ea5c72797f66d",
37  };
38  // ==== FORM IZIN (Google Sheet) ==============================
39  // Tempel URL Web App dari Google Apps Script kamu di antara tanda kutip.
40  // Kalau kosong, form Izin akan meminta agar dikonfigurasi dulu.
41  const IZIN_ENDPOINT =
42    "https://script.google.com/macros/s/AKfycbxybNV64J0u8VrzeACTyAAB-KAbx67fKlzCY0fIXowsJyfp1gCKSUETKy9r3bVaPCIV/exec";
43  // ================================================================
44  // Data lagu bawaan (dipakai saat pertama kali dibuka / belum ada data tersimpan).
45  const defaultSongs = [
46    {
47      id: "ada-kuasa",
48      num: 1,
49      title: "Ada Kuasa (Symphony Worship)",
50      originalKey: "F",
51      source: "Revisi PDF JRChord - Key F",
52      lines: [
53        "Intro :",
54        "Bb F C Dm",
55        "Bb F C",
56        "Bb F C Dm",
57        "Bb F C",
58        "Bait :",
59        "Bb F C Dm",
60        "Nama-Nya Tuhanku, Menara Yang Teguh",
61        "Bb F C",
62        "Kota Benteng Perlindunganku",
63        "Bb F C Dm",
64        "Allah Yang Perkasa, Dia Bapa Yang Kekal",
65        "Bb F C",
66        "Gunung Batu Kes'lamatanku",
67        "Reff :",
68        "F F/A Bb F",
69        "Engkau Yang Termulia Di Bumi Di Surga",
70        "Dm Bb F",
71        "Termahsyur, Perkasa Selamanya",
72        "Dm C/E F Bb F/A",
73        "Engkau Yang Berkua - sa Di Dalam S'gala Hal",
74        "Gm C F",
75        "Ada Kuasa Dalam Nama Yesusku",
76        "Musik :",
77        "Cm F Cm F",
78        "Cm F Cm F",
79        "Bridge :",
80        "Cm F",
81        "Nama Di Atas Segala Nama",
82        "Cm F",
83        "Nama Yang T'lah Membebaskanku",
84        "Bb F/A Bb Dm",
85        "S'gala Kuasa Tunduk Pada Nama Itu",
86        "Bb Gm C",
87        "Yesus Tuhan Segala Tuhan",
88        "Cm F",
89        "Nama Di Atas Segala Nama",
90        "Cm F",
91        "Nama Yang T'lah Membebaskanku",
92        "Bb F/A Bb Dm",
93        "S'gala Kuasa Tunduk, Pada Nama Itu",
94        "Bb Gm C",
95        "Yesus Raja Segala Raja",
96      ],
97    },
98    {
99      id: "di-badai",
100      num: 2,
101      title: "Di Badai Topan Dunia",
102      originalKey: "G",
103      source:
104        "Chordify: Key G, chords D G C Bm. Susunan lirik dibuat ringkas untuk bacaan.",
105      lines: [
106        "Bait 1 :",
107        "G",
108        "Di badai topan dunia",
109        "D",
110        "Tuhanlah Perlindunganmu",
111        "G",
112        "Kendati goncang semesta",
113        "D G",
114        "Tuhanlah Perlindunganmu",
115        "Reff :",
116        "C G Bm",
117        "Ya, Yesus Gunung Batu di dunia",
118        "C D G",
119        "Di dunia, di dunia",
120        "C G Bm",
121        "Ya, Yesus Gunung Batu di dunia",
122        "C D G",
123        "Tempat berlindung yang teguh",
124        "Bait 2 :",
125        "G",
126        "Baik siang maupun malam g'lap",
127        "D",
128        "Tuhanlah Perlindunganmu",
129        "G",
130        "Niscaya takutmu lenyap",
131        "D G",
132        "Tuhanlah Perlindunganmu",
133        "Reff :",
134        "C G Bm",
135        "Ya, Yesus Gunung Batu di dunia",
136        "C D G",
137        "Di dunia, di dunia",
138        "C G Bm",
139        "Ya, Yesus Gunung Batu di dunia",
140        "C D G",
141        "Tempat berlindung yang teguh",
142      ],
143    },
144    {
145      id: "tiada-terukur",
146      num: 3,
147      title: "Tiada Terukur (Welyar Kauntu)",
148      originalKey: "D",
149      source:
150        "Chordify GSJS Worship Glady Febe: Key D, chords A D G Bm. Progression disusun dari chart umum dan ditranspose ke D.",
151      lines: [
152        "Bait :",
153        "D G",
154        "Tiada Terukur Besar",
155        "A D G A",
156        "Kasih SetiaMu Tuhan",
157        "D",
158        "Panjang Dan Lebarnya",
159        "G A Bm",
160        "Melebihi Lautan",
161        "Em A",
162        "Jauh Tinggi Mengatasi Langit",
163        "Em A",
164        "Dalamnya Tak Dapat Kuselami",
165        "Em D/F#",
166        "Kasih SetiaMu",
167        "G A Bm",
168        "Besar Selamanya",
169        "Em D/F#",
170        "Kasih SetiaMu",
171        "G A D",
172        "Besar Selamanya",
173        "Reff :",
174        "A D A D",
175        "Hatiku Bersyukur Jiwaku Memuji",
176        "A D",
177        "Mulutku Pun Bersorak",
178        "A G D/F# Em",
179        "Memuji Engkau Yesus",
180        "A D A D",
181        "Ajaib PerbuatanMu Besar AnugrahMu",
182        "A",
183        "Tak Kan Berhenti",
184        "D A G D/F# Em",
185        "Ku Memuji NamaMu",
186      ],
187    },
188    {
189      id: "kami-perlu",
190      num: 4,
191      title: "Kami Perlu Kau Tuhan (Nikita)",
192      originalKey: "G",
193      source: "Revisi PDF JRChord - Key G",
194      lines: [
195        "Bait :",
196        "G C/G",
197        "Ke Manakah Kami Mencari",
198        "D/F# C/G G",
199        "Kasih Seja - ti",
200        "G C/G",
201        "Ke Manakah Kami Berseru",
202        "B Em",
203        "Saat Badai Datang Menderu",
204        "C",
205        "Yang Kami Tahu",
206        "Bm Em",
207        "Hanya Kau Yang Mampu",
208        "Am D",
209        "Pulihkan S’gala Sesuatu",
210        "G C/G",
211        "Ke Manakah Kami Mencari",
212        "D/F# C/G G",
213        "Kasih Seja - ti",
214        "G C/G",
215        "Ke Manakah Kami Berseru",
216        "B Em",
217        "Saat Badai Datang Menderu",
218        "C",
219        "Yang Kami Tahu",
220        "Bm Em",
221        "Hanya Kau Yang Mampu",
222        "F C/E D",
223        "Pulihkan S’gala Sesuatu",
224        "Reff :",
225        "G G/B C",
226        "Kami Perlukan KeajaibanMu",
227        "G/B Am A/C# D",
228        "Kami Butuhkan Sentuhan TanganMu",
229        "G G/B C G/B",
230        "Kami Tak Dapat Berjalan Sendiri",
231        "Am D G",
232        "Kami Perlu Kau Tuhan",
233      ],
234    },
235    {
236      id: "ku-tak-akan-menyerah",
237      num: 5,
238      title: "Ku Tak Akan Menyerah (Medley)",
239      originalKey: "F",
240      source: "Chart user - Key F, modulasi ke G di Chorus 2",
241      lines: [
242        "Intro :",
243        "F C Gm D | Bb F Bb C | F Bb F .",
244        "Verse 1 :",
245        "F Am",
246        "Dalam s'gala perkara",
247        "A Dm C",
248        "Tuhan punya rencana",
249        "Bb C/Bb Am",
250        "yang lebih besar dari",
251        "A Dm G C",
252        "semua yang terpikirkan.",
253        "Verse 2 :",
254        "F C/E",
255        "Apapun yang kau perbuat",
256        "A C Dm C",
257        "tak ada maksud jahat",
258        "Bb C/Bb Am",
259        "s'bab itu kulaku-kan",
260        "A Dm G C",
261        "semua denganMu Tuhan.",
262        "Chorus :",
263        "F C/E Gm Dm C Bb",
264        "Ku tak akan menyerah, pada apapun ju-ga",
265        "Am Gm C",
266        "sebelum kucoba semua yang kubisa",
267        "F C Gm Dm C Bb",
268        "Tetapi ku berserah kepada kehen-dakMu",
269        "F/A Gm C F",
270        "hatiku percaya Tuhan punya rencana.",
271        "Instrumen :",
272        "Bb Dm F Gm | Am . Bb .",
273        "Verse 2 :",
274        "(ulang Verse 2)",
275        "Chorus :",
276        "(ulang Chorus)",
277        "Interlude :",
278        "Bb C Am Dm | Eb . Bb . | F . C .",
279        "Chorus :",
280        "(ulang Chorus)",
281        "Transition :",
282        "Dm D",
283        "Chorus 2 (Modulasi ke G) :",
284        "G D Am Em D C",
285        "Ku tak akan menyerah, pada apapun ju-ga",
286        "Bm Am D",
287        "sebelum kucoba semua yang kubisa",
288        "G D Am Em F C",
289        "Tetapi ku berserah kepada kehen-dakMu",
290        "G/B Am D G",
291        "Hatiku percaya Tuhan punya rencana.",
292        "Ending :",
293        "C G/B Am D G",
294        "Hatiku percaya Tuhan punya rencana.",
295        "Outro :",
296        "C Bm Am G",
297      ],
298    },
299  ];
300  const storageKey = "pujianYouthChordSongs.v3";
301  const bankKey = "pujianYouthSongBank.v1";
302  let songs = loadSongs();
303  let bankSongs = loadBank();
304  // --- Tabel nada & util untuk transpose chord ---
305  const keyList = [
306    "C",
307    "Db",
308    "D",
309    "Eb",
310    "E",
311    "F",
312    "Gb",
313    "G",
314    "Ab",
315    "A",
316    "Bb",
317    "B",
318  ];
319  const noteToIndex = {
320    C: 0,
321    "C#": 1,
322    Db: 1,
323    D: 2,
324    "D#": 3,
325    Eb: 3,
326    E: 4,
327    F: 5,
328    "F#": 6,
329    Gb: 6,
330    G: 7,
331    "G#": 8,
332    Ab: 8,
333    A: 9,
334    "A#": 10,
335    Bb: 10,
336    B: 11,
337  };
338  const sharpNames = [
339    "C",
340    "C#",
341    "D",
342    "D#",
343    "E",
344    "F",
345    "F#",
346    "G",
347    "G#",
348    "A",
349    "A#",
350    "B",
351  ];
352  const flatNames = [
353    "C",
354    "Db",
355    "D",
356    "Eb",
357    "E",
358    "F",
359    "Gb",
360    "G",
361    "Ab",
362    "A",
363    "Bb",
364    "B",
365  ];
366  const sharpKeys = new Set(["G", "D", "A", "E", "B", "F#", "C#"]);
367  const sectionWords =
368    /^(Intro|Bait|Verse|Reff|Refrain|Chorus|Pre-?Chorus|Post-?Chorus|Breakdown|Modulation|Overtune|Key ?Change|Bridge|Musik|Instrumen(tal)?|Interlude|Transition|Transisi|Solo|Ending|Outro|Outtro|Coda)(\s|:|$)/i;
369  let selectedSongId = songs[0]?.id || "song1";
370  let selectedKey = songs[0]?.originalKey || "C";
371  let numberMode = false;
372  const degreeNames = [
373    "1",
374    "b2",
375    "2",
376    "b3",
377    "3",
378    "4",
379    "b5",
380    "5",
381    "b6",
382    "6",
383    "b7",
384    "7",
385  ];
386  const songButtons = document.getElementById("songButtons"),
387    keyButtons = document.getElementById("keyButtons"),
388    content = document.getElementById("content");
389  function loadSongs() {
390    try {
391      const raw = localStorage.getItem(storageKey);
392      if (raw) return JSON.parse(raw);
393    } catch (e) {}
394    return JSON.parse(JSON.stringify(defaultSongs));
395  }
396  function loadBank() {
397    try {
398      const raw = localStorage.getItem(bankKey);
399      if (raw) {
400        var v = JSON.parse(raw);
401        if (Array.isArray(v)) return v.filter(Boolean);
402      }
403    } catch (e) {}
404    return [];
405  }
406  function ensureBankId(s) {
407    if (!s.bankId)
408      s.bankId =
409        "bank-" + Date.now() + "-" + Math.floor(Math.random() * 100000);
410    return s.bankId;
411  }
412  function mirrorToBank(song) {
413    var bid = ensureBankId(song);
414    var master = {
415      bankId: bid,
416      title: song.title,
417      num: song.num || "",
418      originalKey: song.originalKey,
419      source: song.source || "",
420      youtube: song.youtube || "",
421      bg: song.bg || "",
422      lines: (song.lines || []).slice(),
423    };
424    var i = -1;
425    for (var k = 0; k < bankSongs.length; k++) {
426      if (bankSongs[k] && bankSongs[k].bankId === bid) {
427        i = k;
428        break;
429      }
430    }
431    if (i >= 0) {
432      master.cat = bankSongs[i].cat || song.cat || "other";
433      bankSongs[i] = master;
434    } else {
435      master.cat = song.cat || "other";
436      bankSongs.push(master);
437    }
438  }
439  // ============ v62: PENGAMAN ANTI-TIMPA-KOSONG ============
440  // Masalah lama: data kosong dari perangkat lain bisa menimpa Song Bank
441  // di cloud sehingga seluruh lagu hilang. Sekarang setiap penerimaan /
442  // pengiriman data melewati pemeriksaan jumlah minimum.
443  var BANK_HWM_KEY = "pujianYouthBankHWM.v1";
444  var SONGS_HWM_KEY = "pujianYouthSongsHWM.v1";
445  function hwmGet(k) {
446    try {
447      return parseInt(localStorage.getItem(k) || "0", 10) || 0;
448    } catch (e) {
449      return 0;
450    }
451  }
452  function hwmSet(k, n) {
453    try {
454      if (n > hwmGet(k)) localStorage.setItem(k, String(n));
455    } catch (e) {}
456  }
457  function bankMarkHadData() {
458    hwmSet(BANK_HWM_KEY, (bankSongs || []).length);
459  }
460  function songsMarkHadData() {
461    hwmSet(SONGS_HWM_KEY, (songs || []).length);
462  }
463  // true = boleh diterapkan, false = tolak
464  function guardAccept(incomingLen, localLen, hwmKey, label) {
465    var hwm = Math.max(hwmGet(hwmKey), localLen);
466    if (incomingLen === 0 && hwm > 0) {
467      try {
468        toast(
469          "Data " +
470            label +
471            " kosong dari server ditolak - data di perangkat ini dipertahankan.",
472          "error",
473          6000,
474        );
475      } catch (e) {}
476      return false;
477    }
478    if (hwm >= 5 && incomingLen > 0 && incomingLen < Math.ceil(hwm * 0.6)) {
479      try {
480        backupForce("menyusut: " + label);
481      } catch (e) {}
482      try {
483        toast(
484          label +
485            " menyusut dari " +
486            hwm +
487            " ke " +
488            incomingLen +
489            " lagu. Cadangan otomatis dibuat.",
490          "info",
491          6000,
492        );
493      } catch (e) {}
494    }
495    return true;
496  }
497  // Boleh mengirim daftar kosong ke cloud? Hanya kalau memang belum pernah ada isi.
498  function guardPush(arr, hwmKey, label) {
499    var n = (arr || []).length;
500    if (n > 0) return true;
501    if (hwmGet(hwmKey) > 0) {
502      try {
503        console.warn("[v62] batal mengirim " + label + " kosong ke server");
504      } catch (e) {}
505      return false;
506    }
507    return true;
508  }
509  function saveBank() {
510    try {
511      localStorage.setItem(bankKey, JSON.stringify(bankSongs));
512    } catch (e) {}
513    bankMarkHadData();
514    if (cloudReady && bankRef && !applyingRemote) {
515      if (!guardPush(bankSongs, BANK_HWM_KEY, "Song Bank")) return;
516      try {
517        bankRef.set(bankSongs);
518      } catch (e) {}
519    }
520  }
521  function seedBankFromSongs() {
522    if (bankSongs.length) return;
523    songs.forEach(mirrorToBank);
524    saveBank();
525    if (cloudReady && dbRef && !applyingRemote) {
526      try {
527        dbRef.set(songs);
528      } catch (e) {}
529    }
530    refreshLibrary();
531  }
532  // --- Sinkronisasi online (Firebase) + penyimpanan lokal ---
533  let dbRef = null,
534    bankRef = null,
535    cloudReady = false,
536    applyingRemote = false;
537  function setCloudPill(t) {
538    const p = document.getElementById("cloudPill");
539    if (p) p.textContent = t;
540  }
541  function initCloud() {
542    if (!firebaseConfig.databaseURL || typeof firebase === "undefined") {
543      setCloudPill("Mode lokal (perangkat ini)");
544      // v3.2 - status admin HARUS tetap dipulihkan walau Firebase gagal
545      // dimuat (offline / CDN diblokir). Tanpa ini pengurus kehilangan
546      // hak edit padahal sudah login.
547      initAuthLock();
548      return;
549    }
550    try {
551      firebase.initializeApp(firebaseConfig);
552      try {
553        if (window.PNWLog) window.PNWLog.ready(firebase, { version: "3.9.2" });
554      } catch (e) {}
555      dbRef = firebase.database().ref("pujianYouth/songs");
556      bankRef = firebase.database().ref("pujianYouth/songBank");
557      bankRef.on("value", function (snap) {
558        var val = snap.val();
559        if (val) {
560          var arr = Array.isArray(val)
561            ? val.filter(Boolean)
562            : Object.values(val).filter(Boolean);
563          // v62 - tolak data kosong dari perangkat lain
564          if (
565            !guardAccept(
566              arr.length,
567              (bankSongs || []).length,
568              BANK_HWM_KEY,
569              "Song Bank",
570            )
571          ) {
572            if ((bankSongs || []).length && !applyingRemote) {
573              try {
574                bankRef.set(bankSongs);
575              } catch (e) {}
576            }
577            return;
578          }
579          bankSongs = arr;
580          bankMarkHadData();
581          try {
582            localStorage.setItem(bankKey, JSON.stringify(bankSongs));
583          } catch (e) {}
584          refreshLibrary();
585          try {
586            backupMaybe("bank");
587          } catch (e) {}
588        } else {
589          setTimeout(seedBankFromSongs, 700);
590        }
591      });
592      initAuthLock();
593      initSpectate();
594      cloudReady = true;
595      // v2.4 - pasang sinkronisasi jadwal sejak awal, bukan hanya saat
596      // halaman jadwal dibuka, supaya perubahan orang lain langsung masuk.
597      try {
598        initScheduleCloud();
599      } catch (e) {}
600      setCloudPill("Menghubungkan...");
601      var _syncToast = toast("Menyinkronkan data...", "loading", 0);
602      dbRef.on(
603        "value",
604        (snap) => {
605          const val = snap.val();
606          if (val) {
607            const arr = Array.isArray(val)
608              ? val.filter(Boolean)
609              : Object.values(val);
610            if (arr.length) {
611              applyingRemote = true;
612              songs = arr;
613              try {
614                localStorage.setItem(storageKey, JSON.stringify(songs));
615              } catch (e) {}
616              if (!songs.find((s) => s.id === selectedSongId)) {
617                selectedSongId = songs[0].id;
618                selectedKey = songs[0].originalKey;
619              }
620              makeButtons();
621              render();
622              applyingRemote = false;
623              try {
624                backupMaybe("songs");
625              } catch (e) {}
626            }
627            setCloudPill("Online - tersinkron");
628            if (_syncToast) {
629              _syncToast.close();
630              _syncToast = null;
631            }
632          } else {
633            dbRef.set(songs);
634            setCloudPill("Online - tersinkron");
635            if (_syncToast) {
636              _syncToast.close();
637              _syncToast = null;
638            }
639          }
640        },
641        (err) => {
642          console.error(err);
643          cloudReady = false;
644          dbRef = null;
645          setCloudPill("Gagal konek - mode lokal");
646          if (_syncToast) {
647            _syncToast.close();
648            _syncToast = null;
649          }
650        },
651      );
652    } catch (e) {
653      console.error(e);
654      setCloudPill("Mode lokal (perangkat ini)");
655    }
656  }
657  var isAdmin = false,
658    localAdmin = false,
659    fbAdmin = false,
660    locked = false,
661    izinFormOpen = true,
662    authRef = null,
663    lockRef = null,
664    izinRef = null,
665    notesRef = null,
666    noteOvRef = null,
667    likesRef = null,
668    noteLikes = {};
669  var OWNER_UID = "l9U1ktYog2X3vSA81JdsjHln5qu1";
670  var reqRef = null,
671    pendingApproval = false,
672    _reqInit = false;
673  // v113: akun lokal hardcoded DICOPOT demi keamanan (kredensial terbaca
674  // publik lewat view-source). Login admin kini HANYA lewat Firebase:
675  // email/password atau tombol Google di modal login.
676  function recomputeAdmin() {
677    isAdmin = localAdmin || fbAdmin;
678    try {
679      if (window.PNWLog) window.PNWLog.setContext({ isAdmin: isAdmin });
680    } catch (e) {}
681    applyAdminUI();
682    applyIzinUI();
683    updatePendingUI();
684  }
685  function canEdit() {
686    return isAdmin || !locked;
687  }
688  function applyAdminUI() {
689    document.body.classList.toggle("noEdit", !canEdit());
690    var panel = document.getElementById("adminPanel");
691    if (panel) panel.hidden = !isAdmin;
692    try {
693      initBackupUI();
694    } catch (e) {}
695    var loginBtn = document.getElementById("adminLoginBtn");
696    if (loginBtn) loginBtn.style.display = isAdmin ? "none" : "";
697    var schedSec = document.getElementById("schedMenuSec");
698    if (schedSec) schedSec.style.display = "";
699    var lt = document.getElementById("lockToggle");
700    if (lt) lt.checked = locked;
701    var ls = document.getElementById("lockStatus");
702    if (ls)
703      ls.textContent = locked
704        ? isAdmin
705          ? "Status: terkunci. Hanya admin yang bisa mengubah daftar lagu."
706          : "Daftar lagu sedang dikunci oleh admin."
707        : "Status: terbuka. Semua orang bisa mengubah daftar lagu.";
708  }
709  function initAuthLock() {
710    try {
711      // v113: sesi akun lokal lama tidak berlaku lagi — selalu dibersihkan.
712      localStorage.removeItem("ptAdminUser");
713    } catch (e) {}
714    recomputeAdmin();
715    try {
716      if (firebase.auth) {
717        authRef = firebase.auth();
718        authRef.onAuthStateChanged(function (u) {
719          try {
720            if (window.PNWLog)
721              window.PNWLog.setContext({ uid: u ? u.uid : null });
722          } catch (e) {}
723          if (u && !u.isAnonymous) {
724            // Akun admin sungguhan: verifikasi lewat allowlist di DB.
725            firebase
726              .database()
727              .ref("pujianYouth/admins")
728              .child(u.uid)
729              .once("value")
730              .then(function (s) {
731                fbAdmin = s.exists();
732                if (fbAdmin) {
733                  pendingApproval = false;
734                } else {
735                  pendingApproval = true;
736                  ensureAdminRequest(u);
737                }
738                if (u.uid === OWNER_UID) initAdminRequests();
739                recomputeAdmin();
740              })
741              .catch(function () {
742                fbAdmin = false;
743                recomputeAdmin();
744              });
745          } else {
746            // Belum login / sesi anonim -> bukan admin. Pastikan ada
747            // sesi anonim agar Rules bisa menuntut auth != null nanti.
748            fbAdmin = false;
749            pendingApproval = false;
750            recomputeAdmin();
751            if (!u) authRef.signInAnonymously().catch(function () {});
752          }
753        });
754      }
755    } catch (e) {}
756    try {
757      lockRef = firebase.database().ref("pujianYouth/config/locked");
758      lockRef.on("value", function (s) {
759        locked = s.val() === true;
760        applyAdminUI();
761      });
762      notesRef = firebase.database().ref("pujianYouth/notes");
763      notesRef.on("value", function (s) {
764        var v = s.val();
765        if (v) {
766          var arr;
767          if (Array.isArray(v)) {
768            arr = [];
769            v.forEach(function (it, i) {
770              if (it) arr.push(Object.assign({ _key: String(i) }, it));
771            });
772          } else {
773            arr = Object.keys(v).map(function (k) {
774              return Object.assign({ _key: k }, v[k]);
775            });
776          }
777          arr.sort(function (a, b) {
778            return (b.t || 0) - (a.t || 0);
779          });
780          CLOUD_NOTES = arr;
781        } else {
782          CLOUD_NOTES = [];
783        }
784        refreshInfoUI();
785      });
786      // v3.2 - lapisan penimpa untuk catatan bawaan, supaya hasil edit
787      // dan penghapusan ikut terbawa ke perangkat lain.
788      noteOvRef = firebase.database().ref("pujianYouth/noteOverrides");
789      noteOvRef.on("value", function (s) {
790        var v = s.val();
791        if (v && typeof v === "object") {
792          Object.keys(v).forEach(function (k) {
793            noteOv[k] = v[k];
794          });
795          try {
796            localStorage.setItem(NOTE_OV_KEY, JSON.stringify(noteOv));
797          } catch (e) {}
798          refreshInfoUI();
799        }
800      });
801      izinRef = firebase.database().ref("pujianYouth/config/izinOpen");
802      izinRef.on("value", function (s) {
803        var val = s.val();
804        izinFormOpen = val === false ? false : true;
805        applyIzinUI();
806      });
807      likesRef = firebase.database().ref("pujianYouth/noteLikes");
808      likesRef.on("value", function (s) {
809        noteLikes = s.val() || {};
810        updateLikeCounts();
811      });
812    } catch (e) {}
813  }
814  function escReq(s) {
815    return String(s == null ? "" : s)
816      .replace(/&/g, "&amp;")
817      .replace(/</g, "&lt;")
818      .replace(/>/g, "&gt;")
819      .replace(/"/g, "&quot;");
820  }
821  function updatePendingUI() {
822    var b = document.getElementById("pendingBanner");
823    if (b) b.hidden = !(pendingApproval && !isAdmin);
824  }
825  function ensureAdminRequest(u) {
826    if (!u || u.isAnonymous) return;
827    try {
828      var rr = firebase
829        .database()
830        .ref("pujianYouth/adminRequests")
831        .child(u.uid);
832      rr.once("value")
833        .then(function (s) {
834          if (!s.exists()) {
835            rr.set({
836              email: u.email || "",
837              nama:
838                u.displayName || (u.email || "").split("@")[0] || "Tanpa nama",
839              status: "pending",
840              ts: Date.now(),
841            });
842          }
843        })
844        .catch(function () {});
845    } catch (e) {}
846  }
847  function initAdminRequests() {
848    if (_reqInit) return;
849    _reqInit = true;
850    try {
851      reqRef = firebase.database().ref("pujianYouth/adminRequests");
852      reqRef.on("value", function (s) {
853        renderAdminRequests(s.val() || {});
854      });
855    } catch (e) {}
856  }
857  function renderAdminRequests(v) {
858    var sec = document.getElementById("adminReqSec");
859    var list = document.getElementById("adminReqList");
860    if (!sec || !list) return;
861    var isOwner =
862      authRef && authRef.currentUser && authRef.currentUser.uid === OWNER_UID;
863    sec.hidden = !isOwner;
864    if (!isOwner) return;
865    var keys = Object.keys(v || {});
866    var pend = keys.filter(function (k) {
867      return (v[k] || {}).status === "pending";
868    });
869    var appr = keys.filter(function (k) {
870      return (v[k] || {}).status === "approved";
871    });
872    var html = "";
873    if (!pend.length && !appr.length) {
874      html = '<p class="small">Belum ada permintaan admin.</p>';
875    } else {
876      if (pend.length) {
877        html +=
878          '<p class="small" style="font-weight:600">Menunggu persetujuan (' +
879          pend.length +
880          ")</p>";
881        pend.forEach(function (k) {
882          var r = v[k] || {};
883          html +=
884            '<div class="reqRow"><div class="reqInfo"><b>' +
885            escReq(r.nama || "-") +
886            "</b><span>" +
887            escReq(r.email || "") +
888            "</span></div>" +
889            '<div class="reqBtns"><button class="actionBtn reqOk" data-uid="' +
890            k +
891            '" data-nama="' +
892            escReq(r.nama || "") +
893            '">Setujui</button>' +
894            '<button class="actionBtn secondary reqNo" data-uid="' +
895            k +
896            '">Tolak</button></div></div>';
897        });
898      }
899      if (appr.length) {
900        html +=
901          '<p class="small" style="font-weight:600;margin-top:8px">Admin aktif (' +
902          appr.length +
903          ")</p>";
904        appr.forEach(function (k) {
905          var r = v[k] || {};
906          var own = k === OWNER_UID;
907          html +=
908            '<div class="reqRow"><div class="reqInfo"><b>' +
909            escReq(r.nama || "-") +
910            (own ? " (pemilik)" : "") +
911            "</b><span>" +
912            escReq(r.email || "") +
913            "</span></div>" +
914            (own
915              ? ""
916              : '<div class="reqBtns"><button class="actionBtn secondary reqRevoke" data-uid="' +
917                k +
918                '">Cabut</button></div>') +
919            "</div>";
920        });
921      }
922    }
923    list.innerHTML = html;
924    Array.prototype.forEach.call(list.querySelectorAll(".reqOk"), function (b) {
925      b.onclick = function () {
926        approveAdmin(b.getAttribute("data-uid"), b.getAttribute("data-nama"));
927      };
928    });
929    Array.prototype.forEach.call(list.querySelectorAll(".reqNo"), function (b) {
930      b.onclick = function () {
931        rejectAdmin(b.getAttribute("data-uid"));
932      };
933    });
934    Array.prototype.forEach.call(
935      list.querySelectorAll(".reqRevoke"),
936      function (b) {
937        b.onclick = function () {
938          revokeAdmin(b.getAttribute("data-uid"));
939        };
940      },
941    );
942  }
943  function approveAdmin(uid, nama) {
944    if (!uid) return;
945    firebase
946      .database()
947      .ref("pujianYouth/admins")
948      .child(uid)
949      .set(nama || true)
950      .then(function () {
951        return firebase
952          .database()
953          .ref("pujianYouth/adminRequests")
954          .child(uid)
955          .child("status")
956          .set("approved");
957      })
958      .then(function () {
959        toast("Admin disetujui: " + (nama || uid), "success");
960      })
961      .catch(function (e) {
962        toast("Gagal menyetujui: " + ((e && e.message) || ""), "error");
963      });
964  }
965  function rejectAdmin(uid) {
966    if (!uid) return;
967    firebase
968      .database()
969      .ref("pujianYouth/adminRequests")
970      .child(uid)
971      .child("status")
972      .set("rejected")
973      .then(function () {
974        toast("Permintaan ditolak.", "info");
975      })
976      .catch(function () {
977        toast("Gagal menolak.", "error");
978      });
979  }
980  function revokeAdmin(uid) {
981    if (!uid) return;
982    firebase
983      .database()
984      .ref("pujianYouth/admins")
985      .child(uid)
986      .remove()
987      .then(function () {
988        return firebase
989          .database()
990          .ref("pujianYouth/adminRequests")
991          .child(uid)
992          .child("status")
993          .set("rejected");
994      })
995      .then(function () {
996        toast("Akses admin dicabut.", "info");
997      })
998      .catch(function () {
999        toast("Gagal mencabut.", "error");
1000      });
1001  }
1002  function openSignup() {
1003    var lf = document.getElementById("loginForms");
1004    var sf = document.getElementById("signupForms");
1005    if (lf) lf.hidden = true;
1006    if (sf) sf.hidden = false;
1007    var sm = document.getElementById("signupMsg");
1008    if (sm) sm.textContent = "";
1009  }
1010  function backToLogin() {
1011    var lf = document.getElementById("loginForms");
1012    var sf = document.getElementById("signupForms");
1013    if (lf) lf.hidden = false;
1014    if (sf) sf.hidden = true;
1015  }
1016  function doSignup() {
1017    var name = (document.getElementById("signupName").value || "").trim();
1018    var email = (document.getElementById("signupEmail").value || "").trim();
1019    var pass = document.getElementById("signupPass").value;
1020    var msg = document.getElementById("signupMsg");
1021    if (!name || !email || !pass) {
1022      if (msg) msg.textContent = "Isi nama, email, dan password.";
1023      return;
1024    }
1025    if (email.indexOf("@") < 0) {
1026      if (msg) msg.textContent = "Email tidak valid.";
1027      return;
1028    }
1029    if (pass.length < 6) {
1030      if (msg) msg.textContent = "Password minimal 6 karakter.";
1031      return;
1032    }
1033    if (!authRef) {
1034      if (msg) msg.textContent = "Koneksi Firebase belum siap.";
1035      return;
1036    }
1037    if (msg) msg.textContent = "Memproses...";
1038    showLoading("Mendaftar...");
1039    authRef
1040      .createUserWithEmailAndPassword(email, pass)
1041      .then(function (cred) {
1042        var u = cred.user;
1043        return u
1044          .updateProfile({ displayName: name })
1045          .catch(function () {})
1046          .then(function () {
1047            return firebase
1048              .database()
1049              .ref("pujianYouth/adminRequests")
1050              .child(u.uid)
1051              .set({
1052                email: email,
1053                nama: name,
1054                status: "pending",
1055                ts: Date.now(),
1056              });
1057          });
1058      })
1059      .then(function () {
1060        document.getElementById("signupPass").value = "";
1061        pendingApproval = true;
1062        closeLogin();
1063        loadingSuccess("Pendaftaran terkirim!", function () {
1064          toast(
1065            "Pendaftaran terkirim. Menunggu persetujuan pemilik.",
1066            "success",
1067          );
1068          updatePendingUI();
1069        });
1070      })
1071      .catch(function (err) {
1072        hideLoading();
1073        if (msg)
1074          msg.textContent = "Gagal: " + ((err && err.message) || "coba lagi");
1075        toast("Pendaftaran gagal.", "error");
1076      });
1077  }
1078  function doGoogleLogin() {
1079    if (!authRef || !firebase.auth || !firebase.auth.GoogleAuthProvider) {
1080      toast("Login Google tidak tersedia.", "error");
1081      return;
1082    }
1083    var prov = new firebase.auth.GoogleAuthProvider();
1084    showLoading("Menghubungkan Google...");
1085    authRef
1086      .signInWithPopup(prov)
1087      .then(function () {
1088        closeLogin();
1089        hideLoading();
1090      })
1091      .catch(function (err) {
1092        hideLoading();
1093        var m = document.getElementById("loginMsg");
1094        if (m) m.textContent = "Gagal Google: " + ((err && err.message) || "");
1095        toast("Login Google gagal.", "error");
1096      });
1097  }
1098  function openLogin() {
1099    closeMenu();
1100    document.getElementById("loginMsg").textContent = "";
1101    backToLogin();
1102    document.getElementById("loginModal").classList.add("open");
1103  }
1104  function closeLogin() {
1105    document.getElementById("loginModal").classList.remove("open");
1106  }
1107  function doLogin() {
1108    var e = document.getElementById("loginEmail").value.trim();
1109    var p = document.getElementById("loginPass").value;
1110    var msg = document.getElementById("loginMsg");
1111    if (!e || !p) {
1112      if (msg) msg.textContent = "Isi email & password.";
1113      return;
1114    }
1115    if (!authRef || e.indexOf("@") < 0) {
1116      if (msg) msg.textContent = "Email atau password salah.";
1117      toast("Login gagal. Periksa username/password.", "error");
1118      return;
1119    }
1120    if (msg) msg.textContent = "Memproses...";
1121    showLoading("Memproses login...");
1122    authRef
1123      .signInWithEmailAndPassword(e, p)
1124      .then(function () {
1125        if (msg) msg.textContent = "";
1126        document.getElementById("loginPass").value = "";
1127        closeLogin();
1128        loadingSuccess("Berhasil masuk!", function () {
1129          toast("Login berhasil. Anda masuk sebagai admin.", "success");
1130        });
1131      })
1132      .catch(function (err) {
1133        hideLoading();
1134        if (msg)
1135          msg.textContent =
1136            "Gagal: " + ((err && err.message) || "cek username/password");
1137        toast("Login gagal. Periksa username/password.", "error");
1138      });
1139  }
1140  function doLogout() {
1141    localAdmin = false;
1142    fbAdmin = false;
1143    try {
1144      localStorage.removeItem("ptAdminUser");
1145    } catch (x) {}
1146    if (authRef) authRef.signOut();
1147    recomputeAdmin();
1148    closeMenu();
1149    toast("Anda keluar dari mode admin.", "info");
1150  }
1151  function toggleLock() {
1152    if (!isAdmin) {
1153      toast("Masuk sebagai admin dulu untuk membuka kunci.", "info");
1154      return;
1155    }
1156    if (!lockRef) {
1157      toast(
1158        "Cloud belum terhubung, jadi status kunci belum bisa diubah.",
1159        "error",
1160      );
1161      return;
1162    }
1163    var want = !locked;
1164    lockRef.set(want, function (err) {
1165      if (err) {
1166        console.error("ubah kunci gagal", err);
1167        toast(
1168          fbDenied(err)
1169            ? "Gagal mengubah kunci: akun ini belum punya izin admin di Firebase."
1170            : "Gagal mengubah kunci: cloud tidak bisa dihubungi.",
1171          "error",
1172          6000,
1173        );
1174        return;
1175      }
1176      toast(want ? "Daftar lagu dikunci." : "Daftar lagu dibuka.", "success");
1177    });
1178  }
1179  var noteEditKey = null,
1180    noteEditT = 0;
1181  // v3.8 - tanggal catatan rilis kini bisa diedit.
1182  //
1183  // Dulu ada DUA sumber yang tidak pernah bertemu: `d` hanya teks hiasan
1184  // yang dicetak saat menyimpan, sedangkan pengurutan daftar memakai `t`
1185  // (waktu simpan). Akibatnya mengedit tanggal tidak mungkin dan urutan
1186  // selalu mengikuti kapan tombol Simpan ditekan. Sekarang keduanya
1187  // dihitung dari satu tanggal yang sama, jadi daftar otomatis tersortir
1188  // mengikuti tanggal yang kamu isi.
1189  var NOTE_MONTHS = [
1190    "Jan",
1191    "Feb",
1192    "Mar",
1193    "Apr",
1194    "Mei",
1195    "Jun",
1196    "Jul",
1197    "Agu",
1198    "Sep",
1199    "Okt",
1200    "Nov",
1201    "Des",
1202  ];
1203  function noteFmtTanggal(ts) {
1204    var dt = new Date(ts);
1205    return (
1206      dt.getDate() + " " + NOTE_MONTHS[dt.getMonth()] + " " + dt.getFullYear()
1207    );
1208  }
1209  function noteInputVal(ts) {
1210    var dt = new Date(ts);
1211    var m = dt.getMonth() + 1,
1212      d = dt.getDate();
1213    return (
1214      dt.getFullYear() +
1215      "-" +
1216      (m < 10 ? "0" + m : m) +
1217      "-" +
1218      (d < 10 ? "0" + d : d)
1219    );
1220  }
1221  // Jamnya diwarisi dari catatan aslinya, bukan direset ke 00:00. Tanpa
1222  // ini dua catatan bertanggal sama akan bertukar posisi tiap disimpan.
1223  function noteTsDari(str, jamRef) {
1224    var m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(str || "");
1225    if (!m) return 0;
1226    var j = jamRef ? new Date(jamRef) : new Date();
1227    return new Date(
1228      +m[1],
1229      +m[2] - 1,
1230      +m[3],
1231      j.getHours(),
1232      j.getMinutes(),
1233      j.getSeconds(),
1234    ).getTime();
1235  }
1236  function openNoteModal(note) {
1237    if (!isAdmin) return;
1238    closeMenu();
1239    document.getElementById("noteMsg").textContent = "";
1240    var h = document.querySelector("#noteModal h2");
1241    var sb = document.getElementById("noteSubmit");
1242    if (note && note._key) {
1243      noteEditKey = note._key;
1244      noteEditT = note.t || 0;
1245      document.getElementById("noteVer").value = note.v || "";
1246      document.getElementById("noteItems").value = (note.items || []).join(
1247        "\n",
1248      );
1249      document.getElementById("noteDate").value = noteInputVal(
1250        note.t || Date.now(),
1251      );
1252      if (h) h.textContent = "Edit catatan rilis";
1253      if (sb) sb.textContent = "Simpan perubahan";
1254    } else {
1255      noteEditKey = null;
1256      noteEditT = 0;
1257      document.getElementById("noteVer").value = "";
1258      document.getElementById("noteItems").value = "";
1259      document.getElementById("noteDate").value = noteInputVal(Date.now());
1260      if (h) h.textContent = "Tambah catatan rilis";
1261      if (sb) sb.textContent = "Publikasikan";
1262    }
1263    document.getElementById("noteModal").classList.add("open");
1264  }
1265  function deleteNote(key) {
1266    if (!isAdmin || !key) return;
1267    // Catatan bawaan tidak bisa dihapus dari Firebase karena memang
1268    // tidak tersimpan di sana. Yang ditulis adalah penanda sembunyi.
1269    if (isSeedKey(key)) {
1270      saveNoteOv(key, { hidden: true });
1271      toast("Catatan rilis dihapus.", "success");
1272      return;
1273    }
1274    if (!notesRef) return;
1275    notesRef
1276      .child(key)
1277      .remove()
1278      .then(function () {
1279        toast("Catatan rilis dihapus.", "success");
1280      })
1281      .catch(function (err) {
1282        toast("Gagal menghapus: " + ((err && err.message) || ""), "error");
1283      });
1284  }
1285  function closeNoteModal() {
1286    document.getElementById("noteModal").classList.remove("open");
1287  }
1288  function submitNote() {
1289    if (!isAdmin) return;
1290    var ver = document.getElementById("noteVer").value.trim();
1291    var items = document
1292      .getElementById("noteItems")
1293      .value.split("\n")
1294      .map(function (s) {
1295        return s.trim();
1296      })
1297      .filter(Boolean);
1298    var msg = document.getElementById("noteMsg");
1299    if (!ver || !items.length) {
1300      if (msg) msg.textContent = "Isi versi & minimal satu perubahan.";
1301      return;
1302    }
1303    var now = new Date();
1304    // v3.8 - tanggal diambil dari kolom Tanggal, bukan dari waktu simpan.
1305    // `d` (yang tampil) dan `t` (yang dipakai mengurutkan) dihitung dari
1306    // angka yang sama, jadi tampilan dan urutan tidak mungkin berbeda.
1307    var tglInp = document.getElementById("noteDate");
1308    var ts = noteTsDari(tglInp && tglInp.value, noteEditT || now.getTime());
1309    if (!ts) {
1310      if (msg) msg.textContent = "Tanggal belum diisi atau tidak valid.";
1311      return;
1312    }
1313    var d = noteFmtTanggal(ts);
1314    if (msg) msg.textContent = "Menyimpan...";
1315    var payload = { v: ver, d: d, items: items, t: ts };
1316    if (isSeedKey(noteEditKey)) {
1317      saveNoteOv(noteEditKey, payload);
1318      if (msg) msg.textContent = "";
1319      toast("Catatan rilis diperbarui.", "success");
1320      noteEditKey = null;
1321      noteEditT = 0;
1322      document.getElementById("noteVer").value = "";
1323      document.getElementById("noteItems").value = "";
1324      closeNoteModal();
1325      return;
1326    }
1327    if (!notesRef) {
1328      if (msg)
1329        msg.textContent = "Cloud belum siap. Tunggu sebentar lalu ulangi.";
1330      return;
1331    }
1332    var op = noteEditKey
1333      ? notesRef.child(noteEditKey).set(payload)
1334      : notesRef.push(payload);
1335    op.then(function () {
1336      if (msg) msg.textContent = "";
1337      toast(
1338        noteEditKey
1339          ? "Catatan rilis diperbarui."
1340          : "Catatan rilis dipublikasikan.",
1341        "success",
1342      );
1343      noteEditKey = null;
1344      noteEditT = 0;
1345      document.getElementById("noteVer").value = "";
1346      document.getElementById("noteItems").value = "";
1347      closeNoteModal();
1348    }).catch(function (err) {
1349      if (msg) msg.textContent = "Gagal: " + ((err && err.message) || "");
1350    });
1351  }
1352  var spectateOn = false,
1353    liveRef = null,
1354    applyingLive = false,
1355    liveThrottle = 0,
1356    liveShowChords = false,
1357    _lastLive = null,
1358    _dispSig = "",
1359    _wakeLock = null,
1360    DISPLAY_MODE = /[?&]mode=(display|stage|youthviews|youth-views|views)/.test(location.search);
1361  var STAGE_MODE = /[?&]mode=stage/.test(location.search);
1362  // v81: pemisahan kanal TEGAS. mode=stage mengikuti Spectate (pemusik, butuh
1363  // chord). mode=display/youthviews/views HANYA mengikuti kanal CastFlow.
1364  // Tidak ada lagi percampuran antar kanal.
1365  // v113: watcher kanal CastFlow di app.js dicopot — output sudah pindah total
1366  // ke castflow.html sejak v83; ?mode=display di sini langsung dialihkan.
1367  function initSpectate() {
1368    try {
1369      liveRef = firebase.database().ref("pujianYouth/live");
1370      liveRef.on("value", function (s) {
1371        var v = s.val();
1372        _lastLive = v;
1373        applyLive(v);
1374        // v81: HANYA mode=stage yang menayangkan siaran Spectate.
1375        if (STAGE_MODE) renderDisplay(v);
1376      });
1377    } catch (e) {}
1378  }
1379  function currentScrollFrac() {
1380    var el = document.getElementById("sheet");
1381    if (!el) return 0;
1382    var m = el.scrollHeight - el.clientHeight;
1383    return m > 0 ? el.scrollTop / m : 0;
1384  }
1385  function broadcastLive() {
1386    if (!spectateOn || !isAdmin || !liveRef || applyingLive) return;
1387    var now = Date.now();
1388    if (now - liveThrottle < 180) return;
1389    liveThrottle = now;
1390    try {
1391      liveRef.set({
1392        active: true,
1393        src: "spectate",
1394        songId: selectedSongId,
1395        key: selectedKey,
1396        scroll: currentScrollFrac(),
1397        showChords: liveShowChords,
1398        bg: (currentSong() || {}).bg || "",
1399        songTitle: (currentSong() || {}).title || "",
1400        t: now,
1401      });
1402    } catch (e) {}
1403  }
1404  function applyLive(v) {
1405    var banner = document.getElementById("liveBanner");
1406    // v5.8: HANYA siaran spectate (src "spectate") yang boleh mengunci layar
1407    // anggota. Siaran CastFlow (src "youthviews") diabaikan sepenuhnya.
1408    if (v && v.src && v.src !== "spectate") {
1409      if (banner) banner.hidden = true;
1410      document.body.classList.remove("spectating");
1411      return;
1412    }
1413    if (!v || !v.active || isAdmin) {
1414      if (banner) banner.hidden = true;
1415      document.body.classList.remove("spectating");
1416      return;
1417    }
1418    if (v.kind && v.kind !== "song") return;
1419    document.body.classList.add("spectating");
1420    if (banner) banner.hidden = false;
1421    applyingLive = true;
1422    try {
1423      if (v.songId && v.songId !== selectedSongId) {
1424        var s = songs.find(function (x) {
1425          return x.id === v.songId;
1426        });
1427        if (s) selectedSongId = v.songId;
1428      }
1429      if (v.key) selectedKey = v.key;
1430      makeButtons();
1431      render();
1432      var el = document.getElementById("sheet");
1433      if (el && typeof v.scroll === "number") {
1434        var m = el.scrollHeight - el.clientHeight;
1435        el.scrollTop = m * v.scroll;
1436      }
1437    } catch (e) {}
1438    applyingLive = false;
1439  }
1440  function toggleSpectate() {
1441    if (!isAdmin) return;
1442    spectateOn = !spectateOn;
1443    if (spectateOn) {
1444      broadcastLive();
1445    } else if (liveRef) {
1446      try {
1447        liveRef.set({ active: false, t: Date.now() });
1448      } catch (e) {}
1449    }
1450    var t = document.getElementById("spectateToggle");
1451    if (t) t.checked = spectateOn;
1452    var st = document.getElementById("spectateStatus");
1453    if (st)
1454      st.textContent = spectateOn
1455        ? "Spectate AKTIF - layar member mengikuti Anda."
1456        : "";
1457    var nv = document.getElementById("baitNav");
1458    if (nv) nv.hidden = !spectateOn;
1459  }
1460  function sheetSections() {
1461    var sh = document.getElementById("sheet");
1462    if (!sh) return { sh: null, tops: [] };
1463    var blocks = sh.querySelectorAll(".secBlock");
1464    var base = sh.getBoundingClientRect().top;
1465    var tops = [];
1466    for (var i = 0; i < blocks.length; i++) {
1467      if (!blocks[i].textContent || !blocks[i].textContent.trim()) continue;
1468      var r = blocks[i].getBoundingClientRect();
1469      tops.push(Math.max(0, Math.round(r.top - base + sh.scrollTop)));
1470    }
1471    return { sh: sh, tops: tops };
1472  }
1473  function gotoSection(dir) {
1474    var s = sheetSections();
1475    if (!s.sh || !s.tops.length) return;
1476    var max = s.sh.scrollHeight - s.sh.clientHeight;
1477    var cur = s.sh.scrollTop;
1478    var tol = 6;
1479    var target = null;
1480    if (dir > 0) {
1481      for (var i = 0; i < s.tops.length; i++) {
1482        if (s.tops[i] > cur + tol) {
1483          target = s.tops[i];
1484          break;
1485        }
1486      }
1487      if (target === null) target = max;
1488    } else {
1489      for (var j = s.tops.length - 1; j >= 0; j--) {
1490        if (s.tops[j] < cur - tol) {
1491          target = s.tops[j];
1492          break;
1493        }
1494      }
1495      if (target === null) target = 0;
1496    }
1497    target = Math.max(0, Math.min(target, max));
1498    try {
1499      s.sh.scrollTo({ top: target, behavior: "smooth" });
1500    } catch (e) {
1501      s.sh.scrollTop = target;
1502    }
1503    if (typeof broadcastLive === "function") {
1504      setTimeout(broadcastLive, 320);
1505      setTimeout(broadcastLive, 640);
1506    }
1507  }
1508  var _lastBg = "";
1509  // ---- CastFlow engine: bikin slide ala ProPresenter/EasyWorship ----
1510  var YV_CHORD_RE = /^[A-G](#|b)?(maj|min|m|M|sus|add|dim|aug|\+|°)?\d*(sus\d)?(\/[A-G](#|b)?)?$/;
1511  function yvStripChords(line) {
1512    return String(line || "").replace(/\[[^\]]*\]/g, " ").replace(/\s+/g, " ").trim();
1513  }
1514  function yvIsChordLine(line) {
1515    var t = String(line || "").trim();
1516    if (!t) return false;
1517    if (sectionWords.test(t)) return false;
1518    var toks = t.replace(/\[[^\]]*\]/g, " ").split(/\s+/).filter(Boolean);
1519    if (!toks.length) return true;
1520    for (var i = 0; i < toks.length; i++) {
1521      if (!YV_CHORD_RE.test(toks[i].replace(/[|,.]/g, ""))) return false;
1522    }
1523    return true;
1524  }
1525  function yvLabel(line) {
1526    return yvStripChords(line).replace(/[:\-—]+$/, "").replace(/\s*\+\s*\d+\s*$/, "").trim();
1527  }
1528  // Pecah lagu jadi slide kecil: lirik saja, maksimal N baris per slide,
1529  // baris kosong / ganti bagian = ganti slide. Lirik keluar berkala.
1530  function yvBuildSlides(song, maxLines) {
1531    var max = Math.max(1, parseInt(maxLines, 10) || 4);
1532    var out = [];
1533    var label = "";
1534    var buf = [];
1535    function flush() {
1536      if (buf.length) {
1537        out.push({ label: label, lines: buf.slice() });
1538        buf = [];
1539      }
1540    }
1541    ((song && song.lines) || []).forEach(function (raw) {
1542      var t = String(raw == null ? "" : raw);
1543      if (sectionWords.test(t.trim())) {
1544        flush();
1545        label = yvLabel(t);
1546        return;
1547      }
1548      if (yvIsChordLine(t)) return;
1549      var lyric = yvStripChords(t);
1550      if (!lyric) {
1551        flush();
1552        return;
1553      }
1554      buf.push(lyric);
1555      if (buf.length >= max) flush();
1556    });
1557    flush();
1558    if (!out.length) out.push({ label: "", lines: [(song && song.title) || ""] });
1559    return out;
1560  }
1561  function applyViewStyle(style) {
1562    var screen = document.getElementById("displayScreen");
1563    if (!screen) return;
1564    var st = style || {};
1565    screen.style.setProperty("--yv-font", st.font ? '"' + st.font + '"' : "Inter");
1566    screen.style.setProperty("--yv-size", (parseInt(st.size, 10) || 56) + "px");
1567    screen.style.setProperty("--yv-align", st.align || "center");
1568    screen.classList.remove("yvShadowNone", "yvShadowSoft", "yvShadowStrong");
1569    screen.classList.add(st.shadow === "none" ? "yvShadowNone" : st.shadow === "soft" ? "yvShadowSoft" : "yvShadowStrong");
1570    if (st.font) yvEnsureFont(st.font);
1571  }
1572  var _yvFonts = {};
1573  function yvEnsureFont(name) {
1574    var f = String(name || "").trim();
1575    if (!f || _yvFonts[f] || f === "Inter" || f === "JetBrains Mono" || f === "Satoshi") return;
1576    _yvFonts[f] = true;
1577    try {
1578      var l = document.createElement("link");
1579      l.rel = "stylesheet";
1580      l.href = "https://fonts.googleapis.com/css2?family=" + f.replace(/ /g, "+") + ":wght@400;600;700;800&display=swap";
1581      document.head.appendChild(l);
1582    } catch (e) {}
1583  }
1584  // ---- lapisan animasi (canvas) + Lottie + media lokal ----
1585  var _yvMotion = null, _yvLottie = null;
1586  function yvMotionInst() {
1587    var cv = document.getElementById("dispMotion");
1588    if (!cv || !window.PNWMotion) return null;
1589    if (!_yvMotion) _yvMotion = window.PNWMotion.create(cv);
1590    return _yvMotion;
1591  }
1592  function yvStopMotion() {
1593    if (_yvMotion) { try { _yvMotion.stop(); } catch (e) {} }
1594    var cv = document.getElementById("dispMotion");
1595    if (cv) cv.classList.remove("on");
1596  }
1597  function yvStopLottie() {
1598    if (_yvLottie) { try { _yvLottie.destroy(); } catch (e) {} _yvLottie = null; }
1599    var host = document.getElementById("dispLottie");
1600    if (host) { host.classList.remove("on"); host.innerHTML = ""; }
1601  }
1602  function yvNeedLottie() {
1603    if (window.lottie) return Promise.resolve(window.lottie);
1604    return new Promise(function (res, rej) {
1605      var sc = document.createElement("script");
1606      sc.src = "./lottie.min.js";
1607      sc.onload = function () { res(window.lottie); };
1608      sc.onerror = function () { rej(new Error("lottie gagal dimuat")); };
1609      document.head.appendChild(sc);
1610    });
1611  }
1612  function yvPlayLottie(src) {
1613    var host = document.getElementById("dispLottie");
1614    if (!host) return;
1615    host.classList.add("on");
1616    yvNeedLottie()
1617      .then(function (lot) {
1618        return (window.PNWMedia ? window.PNWMedia.resolve(src) : Promise.resolve(src)).then(function (url) {
1619          yvStopLottie();
1620          host.classList.add("on");
1621          _yvLottie = lot.loadAnimation({ container: host, renderer: "svg", loop: true, autoplay: true, path: url });
1622        });
1623      })
1624      .catch(function (e) {
1625        window.PNWDiag.push({ feature: "youthviews.lottie", error: String((e && e.message) || e), at: Date.now() });
1626      });
1627  }
1628  // hentakan latar mengikuti alur lirik
1629  function yvMotionCue(label, strength) {
1630    if (!_yvMotion) return;
1631    try {
1632      if (label != null) _yvMotion.setMood(label);
1633      _yvMotion.pulse(typeof strength === "number" ? strength : 1);
1634    } catch (e) {}
1635  }
1636  // ---- latar belakang output: warna / gambar / video / animasi ----
1637  function yvBgFromLive(v, song) {
1638    if (v && v.bg && typeof v.bg === "object" && v.bg.kind) return v.bg;
1639    if (v && typeof v.bg === "string" && v.bg) return { kind: "image", value: v.bg };
1640    if (v && v.bgPreset) return { kind: "motion", value: v.bgPreset };
1641    if (song && song.bg) return { kind: "image", value: song.bg };
1642    return null;
1643  }
1644  function applyDispBackground(bg) {
1645    var layer = document.getElementById("dispBg");
1646    var vid = document.getElementById("dispVideo");
1647    if (!layer) return;
1648    var b = bg && bg.kind ? bg : null;
1649    var sig = b ? b.kind + "|" + b.value + "|" + (b.params ? JSON.stringify(b.params) : "") : "none";
1650    if (sig === _lastBg) return;
1651    _lastBg = sig;
1652    layer.className = "dispBg";
1653    layer.style.backgroundImage = "";
1654    layer.style.background = "";
1655    layer.removeAttribute("data-bg");
1656    if (vid) {
1657      try { vid.pause(); } catch (e) {}
1658      vid.classList.remove("on");
1659      try { vid.removeAttribute("src"); vid.load(); } catch (e) {}
1660    }
1661    if (!b || b.kind !== "studio") yvStopMotion();
1662    if (!b || b.kind !== "lottie") yvStopLottie();
1663    if (!b) return;
1664    if (b.kind === "studio") {
1665      var mo = yvMotionInst();
1666      var cvm = document.getElementById("dispMotion");
1667      if (mo && cvm) {
1668        layer.classList.add("on");
1669        layer.setAttribute("data-bg", "studio");
1670        cvm.classList.add("on");
1671        var base = window.PNWMotion ? window.PNWMotion.preset(b.value) : {};
1672        mo.start(Object.assign({}, base, b.params || {}));
1673      }
1674      return;
1675    }
1676    if (b.kind === "lottie") {
1677      layer.classList.add("on");
1678      layer.setAttribute("data-bg", "lottie");
1679      yvPlayLottie(b.value);
1680      return;
1681    }
1682    if (b.kind === "upload") {
1683      layer.classList.add("on");
1684      layer.setAttribute("data-bg", "custom");
1685      if (window.PNWMedia) {
1686        window.PNWMedia.resolve(b.value)
1687          .then(function (url) {
1688            var isVid = /video/i.test(b.mime || "") || /\.(mp4|webm|mov)(\?|$)/i.test(url);
1689            if (isVid && vid) {
1690              layer.setAttribute("data-bg", "video");
1691              vid.src = url;
1692              vid.classList.add("on");
1693              var pv = vid.play();
1694              if (pv && pv.catch) pv.catch(function () {});
1695            } else {
1696              layer.style.backgroundImage = 'url("' + url + '")';
1697            }
1698          })
1699          .catch(function (e) {
1700            window.PNWDiag.push({ feature: "youthviews.media", error: String((e && e.message) || e), at: Date.now() });
1701          });
1702      }
1703      return;
1704    }
1705    if (b.kind === "video" && vid) {
1706      layer.classList.add("on");
1707      layer.setAttribute("data-bg", "video");
1708      try {
1709        vid.src = b.value;
1710        vid.classList.add("on");
1711        var p = vid.play();
1712        if (p && p.catch) p.catch(function () {});
1713      } catch (e) {}
1714      return;
1715    }
1716    if (b.kind === "motion") {
1717      layer.classList.add("on");
1718      layer.setAttribute("data-bg", b.value || "aurora");
1719      return;
1720    }
1721    if (b.kind === "color") {
1722      layer.classList.add("on");
1723      layer.setAttribute("data-bg", "solid");
1724      layer.style.background = b.value || "#0b0e14";
1725      return;
1726    }
1727    layer.classList.add("on");
1728    layer.setAttribute("data-bg", "custom");
1729    layer.style.backgroundImage = 'url("' + String(b.value || "").replace(/"/g, "%22") + '")';
1730  }
1731  function applyDispBg(url, preset) {
1732    applyDispBackground(url ? { kind: "image", value: url } : preset ? { kind: "motion", value: preset } : null);
1733  }
1734  // ---- render 1 slide output: lirik bertahap + auto-fit ----
1735  var _yvLastLines = null;
1736  function yvAutoFit(container, lines) {
1737    if (!container) return;
1738    var maxLen = 1;
1739    (lines || []).forEach(function (l) { maxLen = Math.max(maxLen, String(l).length); });
1740    var n = Math.max(1, (lines || []).length);
1741    var w = window.innerWidth || 1280;
1742    var h = window.innerHeight || 720;
1743    var byWidth = (w * 0.9) / (maxLen * 0.52);
1744    var byHeight = (h * 0.72) / (n * 1.34);
1745    var px = Math.max(22, Math.min(byWidth, byHeight, h * 0.2));
1746    container.style.setProperty("--yv-fit", Math.round(px) + "px");
1747  }
1748  function renderYvSlide(container, slide, opts) {
1749    if (!container) return;
1750    var o = opts || {};
1751    container.innerHTML = "";
1752    var wrap = document.createElement("div");
1753    wrap.className = "yvSlide";
1754    if (o.showLabel && slide && slide.label) {
1755      var lb = document.createElement("div");
1756      lb.className = "yvSlideLabel";
1757      lb.textContent = slide.label;
1758      wrap.appendChild(lb);
1759    }
1760    ((slide && slide.lines) || []).forEach(function (l, i) {
1761      var d = document.createElement("div");
1762      d.className = "yvLine";
1763      d.textContent = l;
1764      d.style.animationDelay = i * 110 + "ms";
1765      wrap.appendChild(d);
1766    });
1767    container.appendChild(wrap);
1768    _yvLastLines = (slide && slide.lines) || [];
1769    yvAutoFit(container, _yvLastLines);
1770    yvMotionCue(slide && slide.label, 1);
1771    ((slide && slide.lines) || []).forEach(function (l, i) {
1772      setTimeout(function () { yvMotionCue(null, 0.3); }, 140 + i * 110);
1773    });
1774  }
1775  // hitung ulang ukuran teks saat layar berubah (rotasi HP, resize / minimize di PC)
1776  var _yvFitTimer = 0;
1777  function yvRefit() {
1778    clearTimeout(_yvFitTimer);
1779    _yvFitTimer = setTimeout(function () {
1780      try {
1781        var c = document.getElementById("dispContent");
1782        if (c && _yvLastLines && _yvLastLines.length) yvAutoFit(c, _yvLastLines);
1783        if (_yvMotion) _yvMotion.resize();
1784      } catch (e) {}
1785    }, 120);
1786  }
1787  window.addEventListener("resize", yvRefit);
1788  window.addEventListener("orientationchange", yvRefit);
1789  try {
1790    if (window.visualViewport) window.visualViewport.addEventListener("resize", yvRefit);
1791  } catch (e) {}
1792
1793  // === MODE PROYEKTOR / LIVE (Fitur 3) ===
1794  function renderDisplay(v) {
1795    try {
1796      return renderDisplayInner(v);
1797    } catch (e) {
1798      window.PNWDiag.push({ feature: "youthviews.output", error: String((e && e.message) || e), at: Date.now() });
1799      return null;
1800    }
1801  }
1802  function renderDisplayInner(v) {
1803    var screen = document.getElementById("displayScreen");
1804    if (!screen) return;
1805    var idle = document.getElementById("dispIdle");
1806    var stage = document.getElementById("dispStage");
1807    var titleEl = document.getElementById("dispTitle");
1808    var keyEl = document.getElementById("dispKey");
1809    var body = document.getElementById("dispContent");
1810    var wait = document.getElementById("dispWait");
1811    // v81: satu jalur keluar yang benar-benar membersihkan layar. Dulu class
1812    // yvSlideMode dan isi #dispContent dibiarkan basi, jadi lirik lama tetap
1813    // menempel di belakang overlay idle.
1814    function yvGoIdle(msg) {
1815      _dispSig = "";
1816      applyDispBackground(null);
1817      applyViewStyle({});
1818      screen.classList.remove("yvSlideMode");
1819      screen.classList.remove("dispTextMode");
1820      screen.classList.remove("hideChords");
1821      if (body) body.innerHTML = "";
1822      if (titleEl) titleEl.textContent = "";
1823      if (keyEl) keyEl.textContent = "";
1824      _yvLastLines = null;
1825      if (stage) stage.hidden = true;
1826      if (idle) idle.hidden = false;
1827      if (wait) wait.textContent = msg;
1828    }
1829    if (v && v.active) applyViewStyle(v.style || {});
1830    if (v && v.active && v.kind === "text") {
1831      if (idle) idle.hidden = true;
1832      if (stage) stage.hidden = false;
1833      applyDispBackground(yvBgFromLive(v, null));
1834      screen.classList.remove("yvSlideMode");
1835      screen.classList.add("dispTextMode");
1836      screen.classList.add("hideChords");
1837      var _tsig = "text|" + (v.text || "");
1838      if (_tsig !== _dispSig) {
1839        _dispSig = _tsig;
1840        if (titleEl) titleEl.textContent = "";
1841        if (keyEl) keyEl.textContent = "";
1842        renderDispText(body, v.text || "");
1843        if (window.PNWMotion && window.PNWMotion.revealLines) window.PNWMotion.revealLines(body);
1844      }
1845      return;
1846    }
1847    if (v && v.active && v.kind === "verse") {
1848      if (idle) idle.hidden = true;
1849      if (stage) stage.hidden = false;
1850      applyDispBackground(yvBgFromLive(v, null));
1851      screen.classList.remove("yvSlideMode");
1852      screen.classList.add("dispTextMode");
1853      screen.classList.add("hideChords");
1854      var _vsig = "verse|" + (v.ref || "") + "|" + (v.text || "");
1855      if (_vsig !== _dispSig) {
1856        _dispSig = _vsig;
1857        if (titleEl) titleEl.textContent = "";
1858        if (keyEl) keyEl.textContent = "";
1859        renderDispVerse(body, v.text || "", v.ref || "");
1860        if (window.PNWMotion && window.PNWMotion.revealLines) window.PNWMotion.revealLines(body);
1861      }
1862      return;
1863    }
1864    screen.classList.remove("dispTextMode");
1865    if (!v || !v.active || (!v.songId && !(v.lines && v.lines.length))) {
1866      yvGoIdle("Menunggu live dimulai\u2026");
1867      return;
1868    }
1869    // v81: PAYLOAD MANDIRI. Kalau siaran sudah membawa lirik jadi, output tidak
1870    // perlu membaca pujianYouth/songs -> perangkat proyektor TIDAK wajib login.
1871    if (v.lines && v.lines.length) {
1872      if (idle) idle.hidden = true;
1873      if (stage) stage.hidden = false;
1874      applyDispBackground(yvBgFromLive(v, null));
1875      var pUse = typeof v.slideIndex === "number";
1876      screen.classList.toggle("yvSlideMode", pUse);
1877      screen.classList.add("hideChords");
1878      var pIdx = Math.max(0, parseInt(v.slideIndex, 10) || 0);
1879      var pSig = "mandiri|" + (v.songId || "") + "|" + pIdx + "|" + v.lines.join("\n") + "|" + JSON.stringify(v.style || {});
1880      if (pSig !== _dispSig) {
1881        _dispSig = pSig;
1882        if (titleEl) titleEl.textContent = v.showTitle === false ? "" : v.songTitle || "";
1883        if (keyEl)
1884          keyEl.textContent =
1885            v.showMeta === false
1886              ? ""
1887              : pUse
1888                ? "Slide " + (pIdx + 1) + " / " + (v.slideTotal || pIdx + 1) + (v.label ? " \u00b7 " + v.label : "")
1889                : v.label || "";
1890        renderYvSlide(body, { lines: v.lines, label: v.label || "" }, { showLabel: false });
1891      }
1892      return;
1893    }
1894    var song = (songs || []).find(function (x) {
1895      return x.id === v.songId;
1896    });
1897    if (!song) {
1898      yvGoIdle(v.songTitle ? "Memuat: " + v.songTitle : "Memuat lagu\u2026");
1899      return;
1900    }
1901    if (idle) idle.hidden = true;
1902    if (stage) stage.hidden = false;
1903    applyDispBackground(yvBgFromLive(v, song));
1904    var target = v.key || song.originalKey;
1905    var shift =
1906      (noteToIndex[target] || 0) - (noteToIndex[song.originalKey] || 0);
1907    var showCh = STAGE_MODE ? true : !!v.showChords;
1908    var useSlide = typeof v.slideIndex === "number";
1909    var sig = song.id + "|" + target + "|" + (showCh ? "1" : "0") + "|" + (useSlide ? "s" + v.slideIndex + "/" + (v.slideMax || 4) : "scroll") + "|" + JSON.stringify(v.style || {});
1910    // v81: toggle di LUAR penjaga tanda tangan, supaya class tidak pernah
1911    // tertinggal basi ketika payload tidak berubah.
1912    screen.classList.toggle("yvSlideMode", useSlide);
1913    if (sig !== _dispSig) {
1914      _dispSig = sig;
1915      if (useSlide) {
1916        // Output presenter: satu halaman, lirik saja, keluar bertahap
1917        var deck = yvBuildSlides(song, v.slideMax || 4);
1918        var idx = Math.max(0, Math.min(deck.length - 1, parseInt(v.slideIndex, 10) || 0));
1919        var slide = deck[idx] || deck[0];
1920        if (titleEl) titleEl.textContent = v.showTitle === false ? "" : song.title || "";
1921        if (keyEl) keyEl.textContent = v.showMeta === false ? "" : "Slide " + (idx + 1) + " / " + deck.length + (slide.label ? " · " + slide.label : "");
1922        screen.classList.add("hideChords");
1923        renderYvSlide(body, slide, { showLabel: false });
1924      } else {
1925        if (titleEl) titleEl.textContent = (song.num || "") + ". " + song.title;
1926        if (keyEl)
1927          keyEl.textContent =
1928            target === song.originalKey
1929              ? "Nada " + target
1930              : "Nada " + target + " (asli " + song.originalKey + ")";
1931        renderLinesInto(body, song.lines || [], shift, target);
1932        screen.classList.toggle("hideChords", !showCh);
1933        if (window.PNWMotion && window.PNWMotion.revealLines) window.PNWMotion.revealLines(body);
1934      }
1935    }
1936    if (!useSlide && typeof v.scroll === "number" && stage) {
1937      var m = stage.scrollHeight - stage.clientHeight;
1938      if (m > 0) stage.scrollTop = m * v.scroll;
1939    }
1940  }
1941  function dispSetStatus(online) {
1942    var d = document.getElementById("dispDot");
1943    if (!d) return;
1944    d.className = "dispDot " + (online ? "on" : "off");
1945    d.title = online ? "Terhubung" : "Terputus \u2014 menyambung ulang\u2026";
1946  }
1947  function requestWake() {
1948    try {
1949      if (
1950        "wakeLock" in navigator &&
1951        navigator.wakeLock &&
1952        navigator.wakeLock.request
1953      ) {
1954        navigator.wakeLock
1955          .request("screen")
1956          .then(function (wl) {
1957            _wakeLock = wl;
1958            if (wl && wl.addEventListener)
1959              wl.addEventListener("release", function () {
1960                _wakeLock = null;
1961              });
1962          })
1963          .catch(function () {});
1964      }
1965    } catch (e) {}
1966  }
1967  function toggleLiveChords() {
1968    var c = document.getElementById("liveChordsToggle");
1969    liveShowChords = c ? !!c.checked : !liveShowChords;
1970    if (typeof broadcastLive === "function") broadcastLive();
1971  }
1972  function renderDispText(container, text) {
1973    if (!container) return;
1974    container.innerHTML = "";
1975    var wrap = document.createElement("div");
1976    wrap.className = "dispTextBlock";
1977    String(text || "")
1978      .replace(/\r/g, "")
1979      .split("\n")
1980      .forEach(function (line) {
1981        var d = document.createElement("div");
1982        d.className = "dispTextLine";
1983        d.textContent = line;
1984        wrap.appendChild(d);
1985      });
1986    container.appendChild(wrap);
1987  }
1988  function renderDispVerse(container, text, ref) {
1989    if (!container) return;
1990    container.innerHTML = "";
1991    var wrap = document.createElement("div");
1992    wrap.className = "dispVerseBlock";
1993    var t = document.createElement("div");
1994    t.className = "dispVerseText";
1995    String(text || "")
1996      .replace(/\r/g, "")
1997      .split("\n")
1998      .forEach(function (line) {
1999        var d = document.createElement("div");
2000        d.className = "dispTextLine";
2001        d.textContent = line;
2002        t.appendChild(d);
2003      });
2004    wrap.appendChild(t);
2005    if (ref && String(ref).trim()) {
2006      var r = document.createElement("div");
2007      r.className = "dispVerseRef";
2008      r.textContent = "\u2014 " + String(ref).trim();
2009      wrap.appendChild(r);
2010    }
2011    container.appendChild(wrap);
2012  }
2013  function initDisplayMode() {
2014    if (!DISPLAY_MODE) return;
2015    document.body.classList.add("display-mode");
2016    if (STAGE_MODE) document.body.classList.add("stage-mode");
2017    var screen = document.getElementById("displayScreen");
2018    if (screen) screen.hidden = false;
2019    requestWake();
2020    document.addEventListener("visibilitychange", function () {
2021      if (document.visibilityState === "visible") {
2022        requestWake();
2023        renderDisplay(_lastLive);
2024      }
2025    });
2026    try {
2027      firebase
2028        .database()
2029        .ref(".info/connected")
2030        .on("value", function (s) {
2031          dispSetStatus(!!s.val());
2032        });
2033    } catch (e) {}
2034    renderDisplay(_lastLive);
2035    setInterval(function () {
2036      renderDisplay(_lastLive);
2037    }, 1500);
2038  }
2039
2040  function toast(msg, type, ms) {
2041    var wrap = document.getElementById("toastWrap");
2042    if (!wrap) return null;
2043    var el = document.createElement("div");
2044    el.className = "toast " + (type || "info");
2045    var ico = document.createElement("span");
2046    if (type === "loading") {
2047      ico.className = "tSpin";
2048    } else {
2049      ico.className = "tIco";
2050      // ICON LOCATION #1 (toast): ganti SVG di bawah untuk icon success/error/info.
2051      ico.innerHTML =
2052        type === "success"
2053          ? '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>'
2054          : type === "error"
2055            ? '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/></svg>'
2056            : '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><path d="M12 8h.01"/></svg>';
2057    }
2058    var tx = document.createElement("span");
2059    tx.textContent = msg;
2060    el.appendChild(ico);
2061    el.appendChild(tx);
2062    wrap.appendChild(el);
2063    requestAnimationFrame(function () {
2064      el.classList.add("show");
2065    });
2066    var dur = ms == null ? 2600 : ms;
2067    var handle = {
2068      el: el,
2069      close: function () {
2070        if (!el.parentNode) return;
2071        el.classList.remove("show");
2072        setTimeout(function () {
2073          if (el.parentNode) el.parentNode.removeChild(el);
2074        }, 240);
2075      },
2076    };
2077    if (type !== "loading" && dur > 0) setTimeout(handle.close, dur);
2078    return handle;
2079  }
2080  // ===== FASE C: Penjadwal Pelayanan =====
2081  var SCHED_KEY = "pujianYouthSchedule.v4";
2082  var scheduleRef = null;
2083  var schedInited = false;
2084  var schedTab = "jadwal";
2085  var schedAutoWarned = false;
2086  var SCHED_TEAMS = [
2087    {
2088      key: "hosp",
2089      label: "Tim Hospitality",
2090      roles: [
2091        { key: "doaBuka", label: "Doa Pembuka", slots: 1, dbl: true },
2092        { key: "usher", label: "Usher", slots: 3, dbl: true },
2093        { key: "pengumuman", label: "Pengumuman", slots: 1, dbl: true },
2094        { key: "doaTutup", label: "Doa Penutup", slots: 1, dbl: true },
2095      ],
2096    },
2097    {
2098      key: "mm",
2099      label: "Sound & Multimedia",
2100      roles: [
2101        { key: "presenter", label: "Presenter", slots: 1 },
2102        { key: "streaming", label: "Streaming", slots: 1 },
2103        { key: "sound", label: "Sound", slots: 1 },
2104        { key: "camera", label: "Camera", slots: 1 },
2105        { key: "liveReport", label: "Live Report", slots: 1 },
2106      ],
2107    },
2108    {
2109      key: "wPNW",
2110      label: "Worship - Praise & Worship",
2111      roles: [
2112        { key: "wlPNW", label: "Worship Leader", slots: 2, dbl: true },
2113        { key: "singPNW", label: "Singers", slots: 3, dbl: true },
2114        {
2115          key: "kbPNW",
2116          label: "Pianist",
2117          slots: 1,
2118          dbl: true,
2119          group: "musik",
2120          groupLabel: "Pemusik",
2121        },
2122        {
2123          key: "gtPNW",
2124          label: "Guitarist",
2125          slots: 1,
2126          dbl: true,
2127          group: "musik",
2128          groupLabel: "Pemusik",
2129        },
2130        {
2131          key: "bsPNW",
2132          label: "Bassist",
2133          slots: 1,
2134          dbl: true,
2135          group: "musik",
2136          groupLabel: "Pemusik",
2137        },
2138        {
2139          key: "drPNW",
2140          label: "Drummer",
2141          slots: 1,
2142          dbl: true,
2143          group: "musik",
2144          groupLabel: "Pemusik",
2145        },
2146      ],
2147    },
2148    {
2149      key: "wPsb",
2150      label: "Worship - Persembahan",
2151      roles: [
2152        { key: "wlPsb", label: "Worship Leader", slots: 1, dbl: true },
2153        { key: "singPsb", label: "Singers", slots: 3, dbl: true },
2154        {
2155          key: "kbPsb",
2156          label: "Pianist",
2157          slots: 1,
2158          dbl: true,
2159          group: "musik",
2160          groupLabel: "Pemusik",
2161        },
2162        {
2163          key: "gtPsb",
2164          label: "Guitarist",
2165          slots: 1,
2166          dbl: true,
2167          group: "musik",
2168          groupLabel: "Pemusik",
2169        },
2170        {
2171          key: "bsPsb",
2172          label: "Bassist",
2173          slots: 1,
2174          dbl: true,
2175          group: "musik",
2176          groupLabel: "Pemusik",
2177        },
2178        {
2179          key: "drPsb",
2180          label: "Drummer",
2181          slots: 1,
2182          dbl: true,
2183          group: "musik",
2184          groupLabel: "Pemusik",
2185        },
2186      ],
2187    },
2188    {
2189      key: "wUmum",
2190      label: "Worship - Tambourine & Banners",
2191      roles: [
2192        { key: "tamb", label: "Tambourine", slots: 3, dbl: true },
2193        { key: "ban", label: "Banners", slots: 2, dbl: true },
2194      ],
2195    },
2196  ];
2197  function allSchedRoles() {
2198    var out = [];
2199    SCHED_TEAMS.forEach(function (t) {
2200      t.roles.forEach(function (r) {
2201        out.push(r);
2202      });
2203    });
2204    return out;
2205  }
2206  function schedRole(key) {
2207    var f = null;
2208    allSchedRoles().forEach(function (r) {
2209      if (r.key === key) f = r;
2210    });
2211    return f;
2212  }
2213  // ===== Data prioritas pelayan (mudah diedit) =====
2214  // Tier: 1=Actual(hijau), 2=Secondary(kuning), 3=Third/putih(oranye).
2215  // Nama yang merah / tidak ada di daftar = tidak masuk pool role itu.
2216  var ROLE_PICKS = {
2217    wlsing: {
2218      t1: ["Erica", "Joshua", "Martin", "Selmia", "Yemima"],
2219      t2: ["Ester", "Gilberth", "Karin", "Tessa"],
2220      t3: ["Florie", "Grace", "Natha", "Shinta", "Daniel"],
2221    },
2222    keyboard: { t1: ["Alex", "Joshua"], t2: ["Billy"], t3: ["George"] },
2223    guitar: {
2224      t1: ["Jamie", "Ebenhaezer"],
2225      t2: ["George", "Gilberth"],
2226      t3: ["Alex", "Mishael", "Samuel"],
2227    },
2228    bass: {
2229      t1: ["Martin", "Selmia"],
2230      t2: ["Alex", "George", "Natha", "Samuel", "Jamie"],
2231      t3: [],
2232    },
2233    drum: {
2234      t1: ["Billy", "Mishael", "Sadrian", "Selmia", "Samuel"],
2235      t2: ["Ester", "Jamie"],
2236      t3: ["George", "Alex"],
2237    },
2238    banners: {
2239      t1: ["Amos", "Aurel", "Raisa", "Ressa", "Samuel", "Daniel", "Gio"],
2240      t2: ["Bunga", "Chintya"],
2241      t3: ["Christian", "Shinta", "Alex"],
2242    },
2243    tambourine: {
2244      t1: [
2245        "Aurel",
2246        "Bunga",
2247        "Cahya",
2248        "Dini",
2249        "Leora",
2250        "Neva",
2251        "Raisa",
2252        "Ressa",
2253        "Shinta",
2254      ],
2255      t2: [],
2256      t3: [],
2257    },
2258    usher: {
2259      t1: ["Adit", "Anugerah"],
2260      t2: [
2261        "Gilberth",
2262        "Ibrani",
2263        "Karin",
2264        "Kethrin",
2265        "Martin",
2266        "Nico",
2267        "Yesika",
2268      ],
2269      t3: ["Anggi"],
2270    },
2271    streaming: {
2272      t1: ["Alex", "Florie", "Shinta"],
2273      t2: ["Yesika"],
2274      t3: [],
2275    },
2276    sound: { t1: ["George", "Sadrian"], t2: ["Alex"], t3: ["Joshua"] },
2277  };
2278  // Role tanpa data prioritas baru -> pakai daftar lama sebagai tier 1.
2279  // v2.3 - tingkat & pool khusus (tabel senior/junior)
2280  var WL_SENIOR = ["Gilberth", "Joshua", "Natha", "Selmia", "Tessa", "Yemima"];
2281  var WL_JUNIOR = ["Erica", "Ester", "Florie", "Karin", "Martin"];
2282  var MUS_SENIOR = ["Alex", "George", "Jamie", "Mishael", "Ebenhaezer"];
2283  var MUS_JUNIOR = ["Samuel", "Gilberth", "Natha"];
2284  var PRESENTER_TETAP = ["Yemima", "Yesika"];
2285  var PRESENTER_TRIAL = ["Fingken", "Chintya", "Ester", "Joshua", "Florie"];
2286  var SOUND_SENIOR = ["George", "Sadrian"];
2287  var SOUND_JUNIOR = ["Alex", "Joshua"];
2288  var PENGURUS = ["Alex", "Selmia", "Tessa"];
2289  var SOUND_MM_ROLES = [
2290    "presenter",
2291    "streaming",
2292    "sound",
2293    "camera",
2294    "liveReport",
2295  ];
2296  var TECH_ROLES = ["streaming", "sound", "camera", "liveReport"];
2297  var MUS_PNW_ROLES = ["kbPNW", "gtPNW", "bsPNW", "drPNW"];
2298  var MUS_PSB_ROLES = ["kbPsb", "gtPsb", "bsPsb", "drPsb"];
2299  var MUS_ALL_ROLES = MUS_PNW_ROLES.concat(MUS_PSB_ROLES);
2300  // v3.3 - pemetaan role musik ke ALAT-nya. Satu orang boleh memegang
2301  // alat yang SAMA di dua sesi (Drum P&W + Drum Persembahan), tetapi
2302  // tidak boleh dua alat berbeda karena tangannya hanya sepasang.
2303  var MUS_INSTRUMEN = {
2304    keyboard: ["kbPNW", "kbPsb"],
2305    gitar: ["gtPNW", "gtPsb"],
2306    bass: ["bsPNW", "bsPsb"],
2307    drum: ["drPNW", "drPsb"],
2308  };
2309  function musInstrumen(rk) {
2310    for (var k in MUS_INSTRUMEN)
2311      if (MUS_INSTRUMEN[k].indexOf(rk) >= 0) return k;
2312    return null;
2313  }
2314  // Seluruh role tim Worship (kedua sesi). Dipakai aturan role mandiri.
2315  var WORSHIP_ALL_ROLES = ["wlPNW", "singPNW"]
2316    .concat(MUS_PNW_ROLES)
2317    .concat(["wlPsb", "singPsb"])
2318    .concat(MUS_PSB_ROLES);
2319  // ===== v3.0 - KELOMPOK EKSKLUSIF (datar) =====
2320  // Dalam satu kelompok, satu orang hanya boleh memegang SATU role per
2321  // minggu. Sesi P&W dan Persembahan sengaja DIPISAH, karena merangkap
2322  // antar sesi memang boleh - bahkan H5 mewajibkan WL Persembahan
2323  // diambil dari Singers P&W.
2324  var EXCLUSIVE_GROUPS = [
2325    {
2326      key: "vokalPNW",
2327      roles: ["wlPNW", "singPNW"],
2328      alasan: "Worship Leader tidak merangkap Singers di sesi P&W",
2329    },
2330    {
2331      key: "vokalPsb",
2332      roles: ["wlPsb", "singPsb"],
2333      alasan: "Worship Leader tidak merangkap Singers di sesi Persembahan",
2334    },
2335  ];
2336  // ===== v3.3 - PASANGAN SILANG =====
2337  // Bentuk aturan yang berbeda: tidak boleh memegang role dari sisi A
2338  // DAN sisi B sekaligus, sementara di dalam masing-masing sisi aturan
2339  // lain tetap berlaku. Kelompok datar tidak bisa menyatakan ini -
2340  // memaksakannya akan ikut melarang rangkap antar sesi yang justru
2341  // diwajibkan H5.
2342  var EXCLUSIVE_PAIRS = [
2343    // H12 - pemusik P&W tidak naik sebagai WL/Singers di Persembahan.
2344    {
2345      key: "musPNWvokalPsb",
2346      a: MUS_PNW_ROLES,
2347      b: ["wlPsb", "singPsb"],
2348      alasan: "pemusik P&W tidak merangkap WL atau Singers di Persembahan",
2349    },
2350    // H13 - Tambourine dan Banners adalah role mandiri: siapa pun yang
2351    // sudah melayani di tim Worship (sesi mana pun) tidak boleh masuk
2352    // ke sini, dan sebaliknya.
2353    {
2354      key: "mandiriTambBan",
2355      a: WORSHIP_ALL_ROLES,
2356      b: ["tamb", "ban"],
2357      alasan:
2358        "Tambourine dan Banners role mandiri, tidak merangkap tim Worship",
2359    },
2360  ];
2361  // Mengembalikan { rk, alasan } bila bentrok, atau null bila aman.
2362  // v3.5 - sesi dari sebuah role musik: "PNW", "Psb", atau null.
2363  function musSesi(rk) {
2364    if (MUS_PNW_ROLES.indexOf(rk) >= 0) return "PNW";
2365    if (MUS_PSB_ROLES.indexOf(rk) >= 0) return "Psb";
2366    return null;
2367  }
2368  // v3.5 - opts.musLintasSesi dipakai HANYA oleh pengisian manual dan
2369  // melonggarkan aturan alat SEBATAS kedua role berada di sesi berbeda.
2370  // Di dalam satu sesi larangan tetap mutlak, karena tangan memang hanya
2371  // sepasang. Penjadwal otomatis memanggil fungsi ini TANPA opts, jadi
2372  // logika penyusunan otomatis sama sekali tidak berubah.
2373  function konflikEksklusif(pid, wk, roleKey, opts) {
2374    opts = opts || {};
2375    // 1. Alat musik: alat sama di dua sesi boleh, alat berbeda tidak.
2376    var ins = musInstrumen(roleKey);
2377    if (ins) {
2378      for (var im = 0; im < MUS_ALL_ROLES.length; im++) {
2379        var rkm = MUS_ALL_ROLES[im];
2380        if (rkm === roleKey) continue;
2381        if (musInstrumen(rkm) === ins) continue;
2382        if (getAssign(wk, rkm).indexOf(pid) >= 0) {
2383          var sA = musSesi(rkm),
2384            sB = musSesi(roleKey);
2385          if (opts.musLintasSesi && sA && sB && sA !== sB) continue;
2386          return {
2387            rk: rkm,
2388            alasan: "satu orang hanya bisa memainkan satu alat musik",
2389          };
2390        }
2391      }
2392    }
2393    // 2. Kelompok datar.
2394    for (var ig = 0; ig < EXCLUSIVE_GROUPS.length; ig++) {
2395      var g = EXCLUSIVE_GROUPS[ig];
2396      if (g.roles.indexOf(roleKey) < 0) continue;
2397      for (var ir = 0; ir < g.roles.length; ir++) {
2398        var rk2 = g.roles[ir];
2399        if (rk2 === roleKey) continue;
2400        if (getAssign(wk, rk2).indexOf(pid) >= 0)
2401          return { rk: rk2, alasan: g.alasan };
2402      }
2403    }
2404    // 3. Pasangan silang.
2405    for (var ip = 0; ip < EXCLUSIVE_PAIRS.length; ip++) {
2406      var pr = EXCLUSIVE_PAIRS[ip];
2407      var sisi =
2408        pr.a.indexOf(roleKey) >= 0
2409          ? pr.b
2410          : pr.b.indexOf(roleKey) >= 0
2411            ? pr.a
2412            : null;
2413      if (!sisi) continue;
2414      for (var is = 0; is < sisi.length; is++) {
2415        if (sisi[is] === roleKey) continue;
2416        if (getAssign(wk, sisi[is]).indexOf(pid) >= 0)
2417          return { rk: sisi[is], alasan: pr.alasan };
2418      }
2419    }
2420    return null;
2421  }
2422  function musRoleLabel(rk) {
2423    var r = schedRole(rk);
2424    return r && r.label ? r.label : rk;
2425  }
2426  // ===== v2.9 - role yang ditutup per minggu =====
2427  // Bentuk data: sched.closed = { "w1": { "tamb": true } }
2428  // v3.6 - penutupan SATU MINGGU penuh. Disimpan terpisah dari
2429  // sched.closed (per role) supaya saat minggu dibuka kembali,
2430  // penutupan per role yang dipasang manual tidak ikut hilang.
2431  function isWeekClosed(wk) {
2432    return !!(sched.closedWeek && sched.closedWeek["w" + wk]);
2433  }
2434  function setWeekClosed(wk, on) {
2435    if (!sched.closedWeek) sched.closedWeek = {};
2436    if (on) sched.closedWeek["w" + wk] = true;
2437    else delete sched.closedWeek["w" + wk];
2438  }
2439  function weekReason(wk) {
2440    return (sched.closedNote && sched.closedNote["w" + wk]) || "";
2441  }
2442  function setWeekReason(wk, txt) {
2443    if (!sched.closedNote) sched.closedNote = {};
2444    sched.closedNote["w" + wk] = txt;
2445  }
2446  function isRoleClosed(wk, roleKey) {
2447    // Minggu yang ditutup penuh menutup SEMUA role minggu itu, termasuk
2448    // bagi penjadwal otomatis - fill() memakai fungsi ini.
2449    if (isWeekClosed(wk)) return true;
2450    var c = sched.closed && sched.closed["w" + wk];
2451    return !!(c && c[roleKey]);
2452  }
2453  function setRoleClosed(wk, roleKey, on) {
2454    if (!sched.closed) sched.closed = {};
2455    var k = "w" + wk;
2456    if (!sched.closed[k]) sched.closed[k] = {};
2457    if (on) sched.closed[k][roleKey] = true;
2458    else delete sched.closed[k][roleKey];
2459  }
2460  function buildTutupMingguBtn(wk) {
2461    var b = document.createElement("button");
2462    b.type = "button";
2463    var tutup = isWeekClosed(wk);
2464    b.className = "tutupBtn wkTutupBtn" + (tutup ? " on" : "");
2465    b.textContent = tutup ? "Buka minggu" : "Tutup minggu";
2466    b.title = tutup
2467      ? "Buka kembali seluruh pelayanan di minggu ini"
2468      : "Tutup seluruh pelayanan minggu ini, dari Doa Pembuka sampai Banners";
2469    b.style.cssText =
2470      "margin-top:5px;width:100%;padding:2px 7px;border-radius:7px;" +
2471      "border:1px solid " +
2472      (tutup ? "#0d9488" : "#d1d5db") +
2473      ";background:" +
2474      (tutup ? "#ccfbf1" : "transparent") +
2475      ";color:" +
2476      (tutup ? "#0f766e" : "#9ca3af") +
2477      ";font-size:10px;cursor:pointer";
2478    b.onclick = function () {
2479      var akan = !tutup;
2480      if (akan) {
2481        var jml = 0;
2482        allSchedRoles().forEach(function (r) {
2483          jml += getAssign(wk, r.key).length;
2484        });
2485        if (
2486          jml &&
2487          !confirm(
2488            "Tutup seluruh Minggu " +
2489              wk +
2490              "? " +
2491              jml +
2492              " nama yang sudah terpasang hanya disembunyikan, tidak dihapus - akan muncul lagi kalau minggu ini dibuka.",
2493          )
2494        )
2495          return;
2496      }
2497      setWeekClosed(wk, akan);
2498      if (akan && !weekReason(wk)) setWeekReason(wk, "Tidak ada ibadah");
2499      saveSched(true);
2500      schedHistPush();
2501      renderSchedule();
2502    };
2503    return b;
2504  }
2505  function buildWeekReason(wk, admin) {
2506    var box = document.createElement("div");
2507    box.className = "wkTutupBox";
2508    var tg = document.createElement("span");
2509    tg.className = "tutupTag";
2510    tg.textContent = "Minggu ditutup";
2511    tg.style.cssText =
2512      "display:inline-block;padding:2px 8px;border-radius:8px;" +
2513      "background:#e5e7eb;color:#6b7280;font-size:11px;font-style:italic";
2514    box.appendChild(tg);
2515    if (admin) {
2516      var ta = document.createElement("textarea");
2517      ta.className = "wkTutupInp";
2518      ta.rows = 3;
2519      ta.placeholder = "Alasan ditutup (dibaca semua member)...";
2520      ta.value = weekReason(wk);
2521      ta.onchange = function () {
2522        setWeekReason(wk, ta.value);
2523        saveSched(true);
2524      };
2525      box.appendChild(ta);
2526    } else {
2527      var pv = document.createElement("div");
2528      pv.className = "wkTutupTxt";
2529      pv.textContent = weekReason(wk) || "-";
2530      box.appendChild(pv);
2531    }
2532    return box;
2533  }
2534  function buildTutupBtn(wk, roleKey, tutup) {
2535    var b = document.createElement("button");
2536    b.type = "button";
2537    b.className = "tutupBtn";
2538    b.textContent = tutup ? "Buka" : "Tutup";
2539    b.title = tutup
2540      ? "Buka kembali pelayanan ini di minggu ini"
2541      : "Tutup pelayanan ini di minggu ini";
2542    b.style.cssText =
2543      "display:block;margin-top:4px;padding:1px 7px;border-radius:7px;" +
2544      "border:1px solid " +
2545      (tutup ? "#0d9488" : "#d1d5db") +
2546      ";background:" +
2547      (tutup ? "#ccfbf1" : "transparent") +
2548      ";color:" +
2549      (tutup ? "#0f766e" : "#9ca3af") +
2550      ";font-size:10px;cursor:pointer";
2551    b.onclick = function () {
2552      var akan = !tutup;
2553      if (akan) {
2554        var isi = getAssign(wk, roleKey);
2555        if (
2556          isi.length &&
2557          !confirm(
2558            "Tutup pelayanan ini di minggu " +
2559              wk +
2560              "? " +
2561              isi.length +
2562              " nama yang sudah terisi akan dikosongkan.",
2563          )
2564        )
2565          return;
2566        setAssign(wk, roleKey, []);
2567      }
2568      setRoleClosed(wk, roleKey, akan);
2569      // saveSched penuh, bukan saveSchedCell: yang berubah bukan hanya
2570      // isi sel tapi juga peta sched.closed.
2571      saveSched();
2572      schedHistPush();
2573      renderSchedule();
2574    };
2575    return b;
2576  }
2577  var LEGACY_CAP = {
2578    doaBuka: [
2579      "Martin",
2580      "Yesika",
2581      "Anugerah",
2582      "Adit",
2583      "Ibrani",
2584      "Nico",
2585      "Kethrin",
2586      "Anggi",
2587    ],
2588    pengumuman: [
2589      "Adit",
2590      "Ibrani",
2591      "Martin",
2592      "Yesika",
2593      "Anugerah",
2594      "Nico",
2595      "Karin",
2596    ],
2597    doaTutup: PENGURUS,
2598    camera: ["Florie", "Chintya", "Grace"],
2599    liveReport: ["Mia", "Billy"],
2600  };
2601  // v2.5 - hanya ada satu role Presenter. Presenter tetap jadi pilihan
2602  // utama (tier 1), yang trial tetap dipakai sebagai cadangan (tier 2).
2603  ROLE_PICKS.presenter = {
2604    t1: PRESENTER_TETAP,
2605    t2: PRESENTER_TRIAL,
2606    t3: [],
2607  };
2608  // Peta role aktual -> sumber prioritas di ROLE_PICKS.
2609  var ROLE_SRC = {
2610    usher: "usher",
2611    presenter: "presenter",
2612    streaming: "streaming",
2613    sound: "sound",
2614    wlPNW: "wlsing",
2615    singPNW: "wlsing",
2616    wlPsb: "wlsing",
2617    singPsb: "wlsing",
2618    kbPNW: "keyboard",
2619    kbPsb: "keyboard",
2620    gtPNW: "guitar",
2621    gtPsb: "guitar",
2622    bsPNW: "bass",
2623    bsPsb: "bass",
2624    drPNW: "drum",
2625    drPsb: "drum",
2626    tamb: "tambourine",
2627    ban: "banners",
2628  };
2629  // Izin: alias menyatukan ejaan berbeda ke satu nama roster.
2630  var IZIN_ALIAS = {
2631    "Bill Stephen": "Billy",
2632    Yessica: "Yesika",
2633    Gilberto: "Gilberth",
2634    Gilbert: "Gilberth",
2635  };
2636  var RAW_IZIN_INPUT = [
2637    ["Bill Stephen", [1, 2, 3]],
2638    ["Florie", [1, 4]],
2639    ["Natha", [4]],
2640    ["Yemima", [1, 2, 3, 4, 5]],
2641    ["Gilberto", [1, 4, 5]],
2642    ["Chintya", [1, 2, 3, 4, 5]],
2643    ["Mishael", [1, 4, 5]],
2644    ["Adit", [1, 2, 3]],
2645    ["Joshua", [1, 4]],
2646    ["Amos", [1, 2]],
2647    ["Mia", [1, 2, 3, 4]],
2648    ["Tessa", [2]],
2649    ["Yessica", [1]],
2650    ["Gilbert", [1, 2, 3, 4, 5]],
2651    ["Florie", [1, 4, 5]],
2652    ["Martin", [3, 4]],
2653  ];
2654  // Request khusus (dikunci sebelum auto-fill).
2655  var REQUESTS = [{ name: "Natha", roleKey: "wlPNW", week: 2 }];
2656  function defaultSched() {
2657    var people = {};
2658    function addCap(name, roleKey, tier) {
2659      if (!people[name]) people[name] = {};
2660      if (!people[name][roleKey] || tier < people[name][roleKey])
2661        people[name][roleKey] = tier;
2662    }
2663    Object.keys(ROLE_SRC).forEach(function (rk) {
2664      var src = ROLE_PICKS[ROLE_SRC[rk]];
2665      if (!src) return;
2666      (src.t1 || []).forEach(function (n) {
2667        addCap(n, rk, 1);
2668      });
2669      (src.t2 || []).forEach(function (n) {
2670        addCap(n, rk, 2);
2671      });
2672      (src.t3 || []).forEach(function (n) {
2673        addCap(n, rk, 3);
2674      });
2675    });
2676    Object.keys(LEGACY_CAP).forEach(function (rk) {
2677      LEGACY_CAP[rk].forEach(function (n) {
2678        addCap(n, rk, 1);
2679      });
2680    });
2681    var izinByName = {};
2682    RAW_IZIN_INPUT.forEach(function (row) {
2683      var nm = IZIN_ALIAS[row[0]] || row[0];
2684      if (!izinByName[nm]) izinByName[nm] = [];
2685      row[1].forEach(function (wk) {
2686        if (izinByName[nm].indexOf(wk) < 0) izinByName[nm].push(wk);
2687      });
2688    });
2689    Object.keys(izinByName).forEach(function (nm) {
2690      if (!people[nm]) people[nm] = {};
2691    });
2692    var names = Object.keys(people).sort(function (a, b) {
2693      return a.localeCompare(b, "id", { sensitivity: "base" });
2694    });
2695    var roster = names.map(function (nm, i) {
2696      var tiers = people[nm];
2697      return {
2698        id: "p-" + i + "-" + Math.floor(Math.random() * 100000),
2699        name: nm,
2700        roles: Object.keys(tiers),
2701        tiers: tiers,
2702      };
2703    });
2704    var izin = {};
2705    roster.forEach(function (p) {
2706      if (izinByName[p.name]) {
2707        izinByName[p.name].sort(function (a, b) {
2708          return a - b;
2709        });
2710        izin[p.id] = izinByName[p.name].slice();
2711      }
2712    });
2713    return {
2714      weeks: 5,
2715      roster: roster,
2716      izin: izin,
2717      assign: {},
2718      auto: false,
2719      allowDouble: false,
2720      allowMusLintasSesi: false,
2721      weekLabels: {},
2722      startISO: "",
2723      requests: REQUESTS.slice(),
2724    };
2725  }
2726  function loadSched() {
2727    try {
2728      var raw = localStorage.getItem(SCHED_KEY);
2729      if (raw) {
2730        var v = JSON.parse(raw);
2731        if (v && v.roster) return v;
2732      }
2733    } catch (e) {}
2734    return defaultSched();
2735  }
2736  var sched = schedNormalize(loadSched());
2737  function saveSched(silent) {
2738    try {
2739      localStorage.setItem(SCHED_KEY, JSON.stringify(sched));
2740    } catch (e) {}
2741    try {
2742      backupMaybe("sched");
2743    } catch (e) {}
2744    if (cloudReady && scheduleRef) {
2745      // v3.1 - catat cap waktu tulisan kita sendiri supaya gema
2746      // (echo) dari Firebase bisa dikenali dan diabaikan.
2747      window.__schedWriteAt = Date.now();
2748      sched.meta = { by: schedWho(), at: window.__schedWriteAt };
2749      try {
2750        scheduleRef.set(schedClean(sched), function (err) {
2751          if (err) {
2752            console.error("simpan jadwal gagal", err);
2753            var den = fbDenied(err);
2754            schedCloudStatus(
2755              den ? "Cloud: perlu masuk admin" : "Cloud tidak terhubung",
2756            );
2757            toast(
2758              den
2759                ? "Jadwal tersimpan di perangkat ini. Untuk ikut tersimpan ke cloud, masuk sebagai admin dulu."
2760                : "Jadwal tersimpan di perangkat ini. Cloud sedang tidak bisa dihubungi.",
2761              den ? "info" : "error",
2762              6000,
2763            );
2764          } else schedCloudStatus("Realtime aktif");
2765        });
2766      } catch (e) {
2767        console.error("simpan jadwal gagal", e);
2768        toast("Gagal menyimpan jadwal ke cloud.", "error");
2769      }
2770    } else {
2771      initScheduleCloud();
2772    }
2773    if (!silent) {
2774      var sp = document.getElementById("schedSaveStatus");
2775      if (sp) {
2776        sp.textContent = "Tersimpan";
2777        setTimeout(function () {
2778          if (sp) sp.textContent = "";
2779        }, 1500);
2780      }
2781    }
2782  }
2783  function schedWho() {
2784    try {
2785      return localStorage.getItem("ptAdminUser") || "pengurus";
2786    } catch (e) {
2787      return "pengurus";
2788    }
2789  }
2790  // v3.1 - tulis HANYA daftar pelayan, bukan seluruh objek jadwal.
2791  // Menulis seluruh objek membuat Firebase memantulkan balik data besar
2792  // yang lalu mengganti variabel `sched`, sehingga kartu pelayan yang
2793  // sedang dibuka menunjuk ke objek usang.
2794  function saveSchedRoster() {
2795    try {
2796      localStorage.setItem(SCHED_KEY, JSON.stringify(sched));
2797    } catch (e) {}
2798    if (cloudReady && scheduleRef) {
2799      try {
2800        window.__schedWriteAt = Date.now();
2801        scheduleRef
2802          .child("meta")
2803          .set({ by: schedWho(), at: window.__schedWriteAt });
2804        scheduleRef.child("roster").set(schedClean(sched.roster));
2805        schedCloudStatus("Realtime aktif");
2806      } catch (e) {
2807        console.error("simpan pelayan gagal", e);
2808        schedCloudStatus("Gagal simpan ke cloud");
2809      }
2810    } else {
2811      initScheduleCloud();
2812    }
2813  }
2814  // v2.3 - tulis HANYA satu sel ke Firebase supaya beberapa pengurus bisa
2815  // mengedit bersamaan tanpa saling menimpa (penggabungan per-path).
2816  function saveSchedCell(wk, roleKey) {
2817    try {
2818      localStorage.setItem(SCHED_KEY, JSON.stringify(sched));
2819    } catch (e) {}
2820    if (cloudReady && scheduleRef) {
2821      try {
2822        // v3.1 - meta ditulis LEBIH DULU supaya setiap gema yang
2823        // menyusul sudah membawa cap waktu kita dan bisa diabaikan.
2824        window.__schedWriteAt = Date.now();
2825        scheduleRef
2826          .child("meta")
2827          .set({ by: schedWho(), at: window.__schedWriteAt });
2828        scheduleRef
2829          .child("assign/w" + wk + "/" + roleKey)
2830          .set(schedClean(getAssign(wk, roleKey)), function (err) {
2831            if (err) {
2832              console.error("simpan sel gagal", err);
2833              schedCloudStatus("Gagal simpan ke cloud");
2834              toast("Gagal menyimpan perubahan ke cloud.", "error");
2835            } else schedCloudStatus("Realtime aktif");
2836          });
2837      } catch (e) {
2838        console.error("simpan sel gagal", e);
2839      }
2840    } else {
2841      initScheduleCloud();
2842    }
2843    var sp = document.getElementById("schedSaveStatus");
2844    if (sp) {
2845      sp.textContent = "Tersimpan";
2846      setTimeout(function () {
2847        if (sp) sp.textContent = "";
2848      }, 1500);
2849    }
2850  }
2851  // v2.4 - status sinkronisasi supaya kegagalan tidak lagi tersembunyi.
2852  function schedCloudStatus(txt) {
2853    window.__schedSyncTxt = txt;
2854    var sp = document.getElementById("schedSyncPill");
2855    if (sp) sp.textContent = txt;
2856  }
2857  // Firebase menolak nilai undefined; bersihkan dulu agar set() tidak gagal.
2858  function schedWeekDate(w) {
2859    var iso = sched && sched.startISO;
2860    if (!iso || !/^\d{4}-\d{2}-\d{2}
2860$/.test(iso)) return null;
2861    var p = iso.split("-");
2862    var d = new Date(Number(p[0]), Number(p[1]) - 1, Number(p[2]));
2863    if (isNaN(d.getTime())) return null;
2864    d.setDate(d.getDate() + 7 * (w - 1));
2865    d.setHours(0, 0, 0, 0);
2866    return d;
2867  }
2868  function reminderAssignHtml(w) {
2869    var out = "";
2870    SCHED_TEAMS.forEach(function (team) {
2871      var lines = "";
2872      team.roles.forEach(function (role) {
2873        try {
2874          if (isRoleClosed(w, role.key)) return;
2875        } catch (e) {}
2876        var names = (getAssign(w, role.key) || [])
2877          .map(personName)
2878          .filter(Boolean);
2879        if (!names.length) return;
2880        lines +=
2881          '<div class="remRole"><span class="remRoleName">' +
2882          escReq(role.label) +
2883          "</span> " +
2884          escReq(names.join(", ")) +
2885          "</div>";
2886      });
2887      if (lines)
2888        out +=
2889          '<div class="remTeam"><b>' +
2890          escReq(team.label) +
2891          "</b>" +
2892          lines +
2893          "</div>";
2894    });
2895    return (
2896      out ||
2897      '<p class="small">Belum ada petugas yang dijadwalkan untuk minggu ini.</p>'
2898    );
2899  }
2900  function buildReminder() {
2901    var card = document.getElementById("reminderCard");
2902    if (!card) return;
2903    var weeks = (sched && sched.weeks) || 0;
2904    var found = -1,
2905      foundDate = null,
2906      days = -1;
2907    for (var w = 1; w <= weeks; w++) {
2908      var dt = schedWeekDate(w);
2909      if (!dt) continue;
2910      var today = new Date();
2911      today.setHours(0, 0, 0, 0);
2912      var du = Math.round((dt.getTime() - today.getTime()) / 86400000);
2913      if (du === 0 || du === 1) {
2914        found = w;
2915        foundDate = dt;
2916        days = du;
2917        break;
2918      }
2919    }
2920    if (found < 0) {
2921      card.hidden = true;
2922      return;
2923    }
2924    var iso =
2925      foundDate.getFullYear() +
2926      "-" +
2927      ("0" + (foundDate.getMonth() + 1)).slice(-2) +
2928      "-" +
2929      ("0" + foundDate.getDate()).slice(-2);
2930    var dkey = "w" + found + "|" + iso;
2931    try {
2932      if (localStorage.getItem("pnwReminderDismiss") === dkey) {
2933        card.hidden = true;
2934        return;
2935      }
2936    } catch (e) {}
2937    card._dkey = dkey;
2938    var head = document.getElementById("reminderHead");
2939    var dl = document.getElementById("reminderDate");
2940    var body = document.getElementById("reminderBody");
2941    if (head)
2942      head.textContent =
2943        days === 1
2944          ? "\uD83D\uDD14 Besok ada ibadah!"
2945          : "\uD83D\uDD14 Hari ini ada ibadah!";
2946    if (dl) {
2947      try {
2948        dl.textContent = foundDate.toLocaleDateString("id-ID", {
2949          weekday: "long",
2950          day: "numeric",
2951          month: "long",
2952          year: "numeric",
2953        });
2954      } catch (e) {
2955        dl.textContent = iso;
2956      }
2957    }
2958    if (body) body.innerHTML = reminderAssignHtml(found);
2959    card.hidden = false;
2960  }
2961  function dismissReminder() {
2962    var card = document.getElementById("reminderCard");
2963    if (card && card._dkey) {
2964      try {
2965        localStorage.setItem("pnwReminderDismiss", card._dkey);
2966      } catch (e) {}
2967    }
2968    if (card) card.hidden = true;
2969  }
2970  function syncStartDateInput() {
2971    var i = document.getElementById("schedStartDate");
2972    if (i) i.value = (sched && sched.startISO) || "";
2973  }
2974  function schedClean(v) {
2975    if (v === undefined || v === null) return null;
2976    if (Array.isArray(v)) {
2977      var a = [];
2978      v.forEach(function (x) {
2979        if (x !== undefined && x !== null) a.push(schedClean(x));
2980      });
2981      return a;
2982    }
2983    if (typeof v === "object") {
2984      var o = {};
2985      Object.keys(v).forEach(function (k) {
2986        if (v[k] === undefined) return;
2987        o[k] = schedClean(v[k]);
2988      });
2989      return o;
2990    }
2991    if (typeof v === "number" && !isFinite(v)) return 0;
2992    return v;
2993  }
2994  // Firebase mengembalikan array jarang sebagai objek berkunci angka.
2995  function schedToArr(v) {
2996    if (Array.isArray(v))
2997      return v.filter(function (x) {
2998        return x !== null && x !== undefined;
2999      });
3000    if (v && typeof v === "object")
3001      return Object.keys(v)
3002        .sort(function (a, b) {
3003          return Number(a) - Number(b);
3004        })
3005        .map(function (k) {
3006          return v[k];
3007        })
3008        .filter(function (x) {
3009          return x !== null && x !== undefined;
3010        });
3011    return [];
3012  }
3013  // Kembalikan data cloud ke bentuk yang diharapkan aplikasi.
3014  function schedNormalize(s) {
3015    if (!s || typeof s !== "object") return s;
3016    s.weeks = Number(s.weeks) || 5;
3017    s.roster = schedToArr(s.roster);
3018    s.roster.forEach(function (p) {
3019      if (!p.tiers || typeof p.tiers !== "object") p.tiers = {};
3020      p.roles = schedToArr(p.roles);
3021    });
3022    if (!s.izin || typeof s.izin !== "object") s.izin = {};
3023    Object.keys(s.izin).forEach(function (k) {
3024      s.izin[k] = schedToArr(s.izin[k]).map(Number);
3025    });
3026    if (!s.assign || typeof s.assign !== "object") s.assign = {};
3027    Object.keys(s.assign).forEach(function (wk) {
3028      var wa = s.assign[wk];
3029      if (!wa || typeof wa !== "object") {
3030        s.assign[wk] = {};
3031        return;
3032      }
3033      delete wa.mulmed; // v2.4 - Multimedia dilebur ke Presenter.
3034      Object.keys(wa).forEach(function (rk) {
3035        wa[rk] = schedToArr(wa[rk]);
3036      });
3037    });
3038    if (!s.weekLabels || typeof s.weekLabels !== "object") s.weekLabels = {};
3039    if (typeof s.startISO !== "string")
3040      s.startISO = s.startISO ? String(s.startISO) : "";
3041    if (!s.closed || typeof s.closed !== "object") s.closed = {};
3042    Object.keys(s.closed).forEach(function (wk) {
3043      if (!s.closed[wk] || typeof s.closed[wk] !== "object") s.closed[wk] = {};
3044    });
3045    return s;
3046  }
3047  // v2.9 - render ulang akibat sinkronisasi ditunda SELAMA pengurus masih
3048  // mengetik atau memilih di halaman jadwal. Versi lama memaksa render
3049  // setelah 4 detik, yang menghapus ketikan pencarian di tengah jalan.
3050  function schedRenderSaatSenggang() {
3051    var pg = document.getElementById("schedulePage");
3052    if (!pg || !pg.classList.contains("open")) return;
3053    var ae = document.activeElement;
3054    var sibuk =
3055      ae &&
3056      pg.contains(ae) &&
3057      ((ae.tagName === "INPUT" &&
3058        ae.type !== "checkbox" &&
3059        ae.type !== "radio") ||
3060        ae.tagName === "SELECT" ||
3061        ae.tagName === "TEXTAREA");
3062    if (sibuk) {
3063      clearTimeout(window.__schedRT);
3064      window.__schedRT = setTimeout(schedRenderSaatSenggang, 1500);
3065      return;
3066    }
3067    renderSchedule();
3068  }
3069  // ===== Rate limiting (v44+) =====
3070  var __rl = {};
3071  function rateLimited(key, ms) {
3072    var now = Date.now();
3073    var last = __rl[key] || 0;
3074    if (now - last < ms) return true;
3075    __rl[key] = now;
3076    return false;
3077  }
3078  var __wbursts = [];
3079  function writeBurstOk(limit, windowMs) {
3080    var now = Date.now();
3081    __wbursts = __wbursts.filter(function (t) {
3082      return now - t < windowMs;
3083    });
3084    if (__wbursts.length >= limit) return false;
3085    __wbursts.push(now);
3086    return true;
3087  }
3088  // ===== end rate limiting =====
3089  // ===== Backup & pemulihan data (v42+) =====
3090  var BACKUP_KEY = "pujianYouthBackups.v1";
3091  var BACKUP_MAX = 8;
3092  var BACKUP_MIN_GAP = 25000;
3093  var __lastBackupAt = 0;
3094  function schedHasContent(s) {
3095    try {
3096      if (!s || !s.roster) return false;
3097      var j = JSON.stringify(s.roster) || "";
3098      return /[A-Za-z0-9]/.test(j.replace(/[\[\]{}",:\s]/g, ""));
3099    } catch (e) {
3100      return false;
3101    }
3102  }
3103  function backupSnapshotObj() {
3104    return {
3105      app: "pnw-tools",
3106      kind: "backup",
3107      at: new Date().toISOString(),
3108      ts: Date.now(),
3109      counts: { songs: (songs || []).length, bank: (bankSongs || []).length },
3110      data: {
3111        songs: songs || [],
3112        songBank: bankSongs || [],
3113        schedule: sched || null,
3114      },
3115    };
3116  }
3117  function backupList() {
3118    try {
3119      var raw = localStorage.getItem(BACKUP_KEY);
3120      var arr = raw ? JSON.parse(raw) : [];
3121      return Array.isArray(arr) ? arr : [];
3122    } catch (e) {
3123      return [];
3124    }
3125  }
3126  function backupStore(list) {
3127    var copy = list.slice();
3128    while (copy.length) {
3129      try {
3130        localStorage.setItem(BACKUP_KEY, JSON.stringify(copy));
3131        return true;
3132      } catch (e) {
3133        copy.shift();
3134      }
3135    }
3136    try {
3137      localStorage.removeItem(BACKUP_KEY);
3138    } catch (e) {}
3139    return false;
3140  }
3141  function backupMaybe(reason) {
3142    var now = Date.now();
3143    if (now - __lastBackupAt < BACKUP_MIN_GAP) return;
3144    var snap = backupSnapshotObj();
3145    if (!snap.counts.songs && !snap.counts.bank && !schedHasContent(sched))
3146      return;
3147    snap.reason = reason || "auto";
3148    __lastBackupAt = now;
3149    var list = backupList();
3150    list.push(snap);
3151    while (list.length > BACKUP_MAX) list.shift();
3152    backupStore(list);
3153  }
3154  // v62 - cadangan paksa (abaikan jeda) untuk kejadian berisiko
3155  function backupForce(reason) {
3156    try {
3157      var snap = backupSnapshotObj();
3158      if (!snap.counts.songs && !snap.counts.bank) return;
3159      snap.reason = reason || "jaga-jaga";
3160      __lastBackupAt = Date.now();
3161      var list = backupList();
3162      list.push(snap);
3163      while (list.length > BACKUP_MAX) list.shift();
3164      backupStore(list);
3165    } catch (e) {}
3166  }
3167  function backupDownload(obj, name) {
3168    try {
3169      var blob = new Blob([JSON.stringify(obj, null, 2)], {
3170        type: "application/json",
3171      });
3172      var url = URL.createObjectURL(blob);
3173      var a = document.createElement("a");
3174      a.href = url;
3175      a.download = name;
3176      document.body.appendChild(a);
3177      a.click();
3178      setTimeout(function () {
3179        document.body.removeChild(a);
3180        URL.revokeObjectURL(url);
3181      }, 500);
3182    } catch (e) {
3183      console.error("unduh backup gagal", e);
3184    }
3185  }
3186  function backupExport() {
3187    var snap = backupSnapshotObj();
3188    var d = new Date();
3189    function p(n) {
3190      return (n < 10 ? "0" : "") + n;
3191    }
3192    var stamp =
3193      d.getFullYear() +
3194      p(d.getMonth() + 1) +
3195      p(d.getDate()) +
3196      "-" +
3197      p(d.getHours()) +
3198      p(d.getMinutes());
3199    backupDownload(snap, "pnw-backup-" + stamp + ".json");
3200    toast(
3201      "Backup diunduh (" +
3202        snap.counts.songs +
3203        " lagu, " +
3204        snap.counts.bank +
3205        " bank).",
3206      "success",
3207    );
3208  }
3209  function backupApply(obj, label) {
3210    if (!obj || !obj.data) {
3211      toast("File backup tidak valid.", "error");
3212      return;
3213    }
3214    if (!isAdmin) {
3215      toast("Hanya admin yang bisa memulihkan backup.", "error");
3216      return;
3217    }
3218    var d = obj.data;
3219    var msg =
3220      "Pulihkan dari " +
3221      (label || "backup") +
3222      "?\n\nLagu: " +
3223      ((d.songs && d.songs.length) || 0) +
3224      "\nBank: " +
3225      ((d.songBank && d.songBank.length) || 0) +
3226      "\n\nData saat ini akan DIGANTI (lokal & cloud).";
3227    if (!window.confirm(msg)) return;
3228    try {
3229      if (Array.isArray(d.songs)) {
3230        songs = d.songs.filter(Boolean);
3231        localStorage.setItem(storageKey, JSON.stringify(songs));
3232        if (cloudReady && dbRef) dbRef.set(songs);
3233      }
3234      if (Array.isArray(d.songBank)) {
3235        bankSongs = d.songBank.filter(Boolean);
3236        localStorage.setItem(bankKey, JSON.stringify(bankSongs));
3237        if (cloudReady && bankRef) bankRef.set(bankSongs);
3238      }
3239      if (d.schedule) {
3240        sched = schedNormalize(d.schedule);
3241        localStorage.setItem(SCHED_KEY, JSON.stringify(sched));
3242        if (cloudReady && scheduleRef) scheduleRef.set(schedClean(sched));
3243      }
3244      try {
3245        makeButtons();
3246        render();
3247        refreshLibrary();
3248        renderSchedule();
3249      } catch (e) {}
3250      toast("Backup dipulihkan.", "success");
3251    } catch (e) {
3252      console.error("pulihkan backup gagal", e);
3253      toast("Gagal memulihkan backup.", "error");
3254    }
3255  }
3256  function backupImportFile() {
3257    var inp = document.createElement("input");
3258    inp.type = "file";
3259    inp.accept = "application/json,.json";
3260    inp.onchange = function () {
3261      var f = inp.files && inp.files[0];
3262      if (!f) return;
3263      var rd = new FileReader();
3264      rd.onload = function () {
3265        try {
3266          var obj = JSON.parse(String(rd.result));
3267          backupApply(obj, "file " + f.name);
3268        } catch (e) {
3269          toast("File backup tidak bisa dibaca.", "error");
3270        }
3271      };
3272      rd.readAsText(f);
3273    };
3274    inp.click();
3275  }
3276  function backupRestoreLocalMenu() {
3277    var list = backupList();
3278    if (!list.length) {
3279      toast("Belum ada snapshot lokal.");
3280      return;
3281    }
3282    var lines = list
3283      .map(function (s, i) {
3284        var t = new Date(s.ts || s.at);
3285        return (
3286          i +
3287          1 +
3288          ". " +
3289          t.toLocaleString() +
3290          " - " +
3291          ((s.counts && s.counts.songs) || 0) +
3292          " lagu, " +
3293          ((s.counts && s.counts.bank) || 0) +
3294          " bank (" +
3295          (s.reason || "auto") +
3296          ")"
3297        );
3298      })
3299      .join("\n");
3300    var pick = window.prompt(
3301      "Snapshot lokal (terbaru di bawah):\n\n" +
3302        lines +
3303        "\n\nKetik nomor untuk memulihkan:",
3304      String(list.length),
3305    );
3306    if (!pick) return;
3307    var idx = parseInt(pick, 10) - 1;
3308    if (isNaN(idx) || idx < 0 || idx >= list.length) {
3309      toast("Nomor tidak valid.", "error");
3310      return;
3311    }
3312    var s = list[idx];
3313    backupApply(s, "snapshot " + new Date(s.ts || s.at).toLocaleString());
3314  }
3315  function initBackupUI() {
3316    var panel = document.getElementById("adminPanel");
3317    if (!panel || document.getElementById("backupSec")) return;
3318    var sec = document.createElement("div");
3319    sec.id = "backupSec";
3320    sec.style.marginTop = "14px";
3321    sec.style.paddingTop = "12px";
3322    sec.style.borderTop = "1px solid var(--line)";
3323    var h = document.createElement("div");
3324    h.textContent = "Backup & pemulihan data";
3325    h.style.fontWeight = "700";
3326    h.style.marginBottom = "8px";
3327    sec.appendChild(h);
3328    var row = document.createElement("div");
3329    row.style.display = "flex";
3330    row.style.flexWrap = "wrap";
3331    row.style.gap = "8px";
3332    function mkBtn(label, fn) {
3333      var b = document.createElement("button");
3334      b.type = "button";
3335      b.textContent = label;
3336      b.style.padding = "8px 12px";
3337      b.style.borderRadius = "10px";
3338      b.style.border = "1px solid var(--line)";
3339      b.style.background = "var(--soft)";
3340      b.style.cursor = "pointer";
3341      b.onclick = fn;
3342      return b;
3343    }
3344    row.appendChild(mkBtn("Export backup", backupExport));
3345    row.appendChild(mkBtn("Import backup", backupImportFile));
3346    row.appendChild(mkBtn("Pulihkan snapshot lokal", backupRestoreLocalMenu));
3347    sec.appendChild(row);
3348    var note = document.createElement("div");
3349    note.style.fontSize = "12px";
3350    note.style.color = "var(--muted)";
3351    note.style.marginTop = "6px";
3352    note.textContent =
3353      "Snapshot lokal otomatis tersimpan di perangkat ini (maks " +
3354      BACKUP_MAX +
3355      "). Export untuk simpanan permanen.";
3356    sec.appendChild(note);
3357    panel.appendChild(sec);
3358  }
3359  // ===== end backup =====
3360  function schedApplyRemote(v) {
3361    if (v == null) {
3362      if (scheduleRef && schedHasContent(sched))
3363        scheduleRef.set(schedClean(sched));
3364      return;
3365    }
3366    if (!v.roster) return;
3367    var meta = v.meta || {};
3368    // v3.1 - INI gema tulisan kita sendiri. Kalau tetap diproses,
3369    // variabel `sched` diganti objek baru sehingga closure kartu
3370    // pelayan menunjuk ke objek usang, dan centang yang baru dilepas
3371    // muncul kembali. Keadaan lokal sudah benar, jadi berhenti di sini.
3372    if (meta.at && meta.at === window.__schedWriteAt) {
3373      schedCloudStatus("Realtime aktif");
3374      return;
3375    }
3376    sched = schedNormalize(v);
3377    try {
3378      syncStartDateInput();
3379      buildReminder();
3380    } catch (e) {}
3381    PIDX = null;
3382    // Kalau perubahan datang dari pengurus LAIN, riwayat undo lokal
3383    // dikosongkan. Tanpa ini, menekan Undo akan mengembalikan data lama
3384    // dan menimpa pekerjaan orang tersebut.
3385    if (meta.by && meta.by !== schedWho()) schedHistReset();
3386    else schedHistPush();
3387    try {
3388      localStorage.setItem(SCHED_KEY, JSON.stringify(sched));
3389    } catch (e) {}
3390    schedCloudStatus("Realtime aktif");
3391    if (
3392      meta.by &&
3393      meta.by !== schedWho() &&
3394      Date.now() - (meta.at || 0) < 20000
3395    )
3396      toast("Jadwal diperbarui oleh " + meta.by, "info", 2600);
3397    var pg = document.getElementById("schedulePage");
3398    if (pg && pg.classList.contains("open")) {
3399      // Tidak ada lagi batas waktu 4 detik.
3400      schedRenderSaatSenggang();
3401    }
3402  }
3403  function initScheduleCloud() {
3404    if (schedInited) return;
3405    if (
3406      !cloudReady ||
3407      typeof firebase === "undefined" ||
3408      !firebase.apps ||
3409      !firebase.apps.length
3410    ) {
3411      // v2.4 - cloud belum siap: coba lagi, jangan berhenti diam-diam.
3412      schedCloudStatus("Menyambung...");
3413      clearTimeout(window.__schedInitRT);
3414      window.__schedInitRT = setTimeout(initScheduleCloud, 800);
3415      return;
3416    }
3417    try {
3418      scheduleRef = firebase.database().ref("pujianYouth/schedule4");
3419      schedInited = true;
3420      schedCloudStatus("Realtime aktif");
3421      scheduleRef.on(
3422        "value",
3423        function (snap) {
3424          try {
3425            schedApplyRemote(snap.val());
3426          } catch (e) {
3427            console.error("sinkron jadwal", e);
3428          }
3429        },
3430        function (err) {
3431          console.error("sinkron jadwal gagal", err);
3432          var code = (err && (err.code || err.message)) || "UNKNOWN";
3433          window.__schedErr = String(code);
3434          schedInited = false;
3435          scheduleRef = null;
3436          if (/permission|denied/i.test(String(code))) {
3437            // Aturan Realtime Database menolak akses. Mencoba lagi
3438            // tidak ada gunanya sampai aturannya diperbaiki.
3439            schedCloudStatus("Ditolak Firebase (izin akses)");
3440            if (!window.__schedPermToast) {
3441              window.__schedPermToast = 1;
3442              toast(
3443                "Firebase menolak akses jadwal (PERMISSION_DENIED). Aturan database harus mengizinkan pujianYouth/schedule4.",
3444                "error",
3445                9000,
3446              );
3447            }
3448            return;
3449          }
3450          schedCloudStatus("Sinkron gagal: " + code);
3451          clearTimeout(window.__schedInitRT);
3452          window.__schedInitRT = setTimeout(initScheduleCloud, 3000);
3453        },
3454      );
3455    } catch (e) {
3456      console.error(e);
3457      schedInited = false;
3458      schedCloudStatus("Sinkron gagal");
3459    }
3460  }
3461  // v2.5 - indeks roster: pencarian nama jadi O(1). Sebelumnya setiap
3462  // chip memindai seluruh roster, dan grid bisa berisi ratusan chip.
3463  var PIDX = null;
3464  function rebuildPidx() {
3465    PIDX = {};
3466    (sched.roster || []).forEach(function (p) {
3467      PIDX[p.id] = p;
3468    });
3469  }
3470  function personById(id) {
3471    if (!PIDX) rebuildPidx();
3472    return PIDX[id] || null;
3473  }
3474  function personName(id) {
3475    var p = personById(id);
3476    return p ? p.name : "?";
3477  }
3478  function isIzin(pid, wk) {
3479    return !!(sched.izin[pid] && sched.izin[pid].indexOf(wk) >= 0);
3480  }
3481  function weekAssignments(wk) {
3482    return sched.assign["w" + wk] || {};
3483  }
3484  function rolesOfPersonInWeek(pid, wk) {
3485    var wa = weekAssignments(wk);
3486    var out = [];
3487    Object.keys(wa).forEach(function (rk) {
3488      if ((wa[rk] || []).indexOf(pid) >= 0) out.push(rk);
3489    });
3490    return out;
3491  }
3492  function canDouble(roleKey) {
3493    var r = schedRole(roleKey);
3494    return !!(r && r.dbl);
3495  }
3496  function eligible(roleKey, wk, allowIzin) {
3497    return sched.roster.filter(function (p) {
3498      if (p.roles.indexOf(roleKey) < 0) return false;
3499      if (!allowIzin && isIzin(p.id, wk)) return false;
3500      return true;
3501    });
3502  }
3503  // ===== Riwayat Undo / Redo jadwal (v2.7) =====
3504  // Yang disimpan hanya cuplikan sched.assign, BUKAN seluruh sched.
3505  // Jadi Undo hanya membatalkan penempatan orang - data roster, izin,
3506  // dan label minggu tidak pernah ikut termundurkan.
3507  var schedHist = { stack: [], idx: -1, max: 40 };
3508  var schedHistBusy = false;
3509  function schedSnap() {
3510    // v2.9 - cuplikan mencakup penempatan DAN peta role tertutup,
3511    // supaya menutup/membuka role juga bisa dibatalkan.
3512    try {
3513      return JSON.stringify({
3514        a: sched.assign || {},
3515        c: sched.closed || {},
3516      });
3517    } catch (e) {
3518      return '{"a":{},"c":{}}';
3519    }
3520  }
3521  function schedHistReset() {
3522    schedHist.stack = [schedSnap()];
3523    schedHist.idx = 0;
3524    schedHistBtns();
3525  }
3526  function schedHistPush() {
3527    if (schedHistBusy) return;
3528    var snap = schedSnap();
3529    if (schedHist.idx >= 0 && schedHist.stack[schedHist.idx] === snap) return;
3530    // Setelah aksi baru, cabang "maju" yang lama tidak berlaku lagi.
3531    schedHist.stack = schedHist.stack.slice(0, schedHist.idx + 1);
3532    schedHist.stack.push(snap);
3533    if (schedHist.stack.length > schedHist.max) schedHist.stack.shift();
3534    schedHist.idx = schedHist.stack.length - 1;
3535    schedHistBtns();
3536  }
3537  function schedHistApply(snap) {
3538    schedHistBusy = true;
3539    try {
3540      var o = JSON.parse(snap) || {};
3541      sched.assign = o.a || {};
3542      sched.closed = o.c || {};
3543    } catch (e) {
3544      sched.assign = {};
3545      sched.closed = {};
3546    }
3547    // Undo mengubah banyak sel sekaligus, jadi simpan PENUH ke cloud;
3548    // saveSchedCell yang per-sel tidak cukup untuk kasus ini.
3549    saveSched(true);
3550    renderSchedule();
3551    schedHistBusy = false;
3552    schedHistBtns();
3553  }
3554  function schedUndo() {
3555    if (!isAdmin) return;
3556    if (schedHist.idx <= 0) {
3557      toast("Tidak ada lagi yang bisa dibatalkan.", "info", 2000);
3558      return;
3559    }
3560    schedHist.idx--;
3561    schedHistApply(schedHist.stack[schedHist.idx]);
3562    toast("Dibatalkan (undo).", "info", 1800);
3563  }
3564  function schedRedo() {
3565    if (!isAdmin) return;
3566    if (schedHist.idx >= schedHist.stack.length - 1) {
3567      toast("Tidak ada lagi yang bisa diulang.", "info", 2000);
3568      return;
3569    }
3570    schedHist.idx++;
3571    schedHistApply(schedHist.stack[schedHist.idx]);
3572    toast("Diulang (redo).", "info", 1800);
3573  }
3574  function schedHistBtns() {
3575    var u = document.getElementById("schedUndoBtn");
3576    var r = document.getElementById("schedRedoBtn");
3577    if (u) u.disabled = schedHist.idx <= 0;
3578    if (r) r.disabled = schedHist.idx >= schedHist.stack.length - 1;
3579  }
3580  // Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y - hanya saat halaman jadwal terbuka.
3581  document.addEventListener("keydown", function (e) {
3582    if (!(e.ctrlKey || e.metaKey)) return;
3583    var pg = document.getElementById("schedulePage");
3584    if (!pg || !pg.classList.contains("open") || !isAdmin) return;
3585    var k = (e.key || "").toLowerCase();
3586    if (k === "z" && !e.shiftKey) {
3587      e.preventDefault();
3588      schedUndo();
3589    } else if ((k === "z" && e.shiftKey) || k === "y") {
3590      e.preventDefault();
3591      schedRedo();
3592    }
3593  });
3594  function setAssign(wk, roleKey, arr) {
3595    if (!sched.assign["w" + wk]) sched.assign["w" + wk] = {};
3596    sched.assign["w" + wk][roleKey] = arr;
3597  }
3598  function getAssign(wk, roleKey) {
3599    var wa = weekAssignments(wk);
3600    return (wa[roleKey] || []).slice();
3601  }
3602  // v3.6 (aturan A & B) - kuota LUNAK. Tiap role di Sound & Multimedia
3603  // dan di tim Worship idealnya diisi sesuai kuota, tapi admin boleh
3604  // menambah SATU orang lagi di atas kuota dengan peringatan.
3605  // Hospitality tetap keras (usher 3, doa, pengumuman).
3606  function bolehSlotLebih(rk) {
3607    return (
3608      SOUND_MM_ROLES.indexOf(rk) >= 0 ||
3609      WORSHIP_ALL_ROLES.indexOf(rk) >= 0 ||
3610      rk === "tamb" ||
3611      rk === "ban"
3612    );
3613  }
3614  function batasSlot(rk) {
3615    var r = schedRole(rk);
3616    var sl = (r && r.slots) || 1;
3617    return bolehSlotLebih(rk) ? sl + 1 : sl;
3618  }
3619  function assignPerson(wk, roleKey, pid) {
3620    var arr = getAssign(wk, roleKey);
3621    if (arr.indexOf(pid) >= 0) return false;
3622    var role = schedRole(roleKey);
3623    var slots = (role && role.slots) || 1;
3624    var batas = batasSlot(roleKey);
3625    // Ditandai SEBELUM push, tapi toastnya baru muncul setelah semua
3626    // gerbang aturan lolos - supaya tidak ada peringatan kuota untuk
3627    // penempatan yang ujungnya ditolak H9/H10/H11.
3628    var lebihKuota = arr.length >= slots;
3629    if (arr.length >= batas) {
3630      toast(
3631        "Slot " +
3632          (role ? role.label : roleKey) +
3633          " sudah penuh (" +
3634          batas +
3635          ").",
3636        "info",
3637      );
3638      return false;
3639    }
3640    // H9 - tim Sound & Multimedia tidak boleh merangkap ke tim lain pada
3641    // minggu yang sama, dan sebaliknya. Ini aturan KERAS, jadi pengisian
3642    // manual ikut diblokir - sebelumnya hanya penjadwal otomatis yang
3643    // memeriksanya, sehingga admin masih bisa melanggar lewat + tambah.
3644    var rpW9 = rolesOfPersonInWeek(pid, wk);
3645    var hasMM9 = false,
3646      hasOther9 = false;
3647    for (var i9 = 0; i9 < rpW9.length; i9++) {
3648      if (isSoundMMRole(rpW9[i9])) hasMM9 = true;
3649      else hasOther9 = true;
3650    }
3651    if (isSoundMMRole(roleKey) ? hasOther9 : hasMM9) {
3652      toast(
3653        personName(pid) +
3654          (isSoundMMRole(roleKey)
3655            ? " sudah bertugas di tim lain minggu ini, jadi tidak bisa masuk Sound & Multimedia."
3656            : " ada di tim Sound & Multimedia minggu ini, jadi tidak bisa merangkap tugas di tim lain."),
3657        "error",
3658        4200,
3659      );
3660      return false;
3661    }
3662    // H10/H11 - kelompok eksklusif. Diblokir keras, termasuk saat
3663    // opsi "Izinkan double role" sedang aktif.
3664    var kfl = konflikEksklusif(pid, wk, roleKey, {
3665      musLintasSesi: !!sched.allowMusLintasSesi,
3666    });
3667    if (kfl) {
3668      toast(
3669        personName(pid) +
3670          " sudah mengambil " +
3671          musRoleLabel(kfl.rk) +
3672          " minggu ini - " +
3673          kfl.alasan +
3674          ".",
3675        "error",
3676        4600,
3677      );
3678      return false;
3679    }
3680    // Double role: beri PERINGATAN saja (tidak memblokir). Bisa dimatikan
3681    // lewat opsi "Izinkan double role" (sched.allowDouble).
3682    var existingRoles = rolesOfPersonInWeek(pid, wk);
3683    if (existingRoles.length && !sched.allowDouble) {
3684      var whitelisted = canDouble(roleKey);
3685      existingRoles.forEach(function (rk) {
3686        if (!canDouble(rk)) whitelisted = false;
3687      });
3688      toast(
3689        personName(pid) +
3690          " sudah punya tugas lain minggu ini" +
3691          (whitelisted ? "" : " (di luar whitelist rangkap)") +
3692          ". Ditandai rangkap (kuning).",
3693        "info",
3694        3600,
3695      );
3696    }
3697    arr.push(pid);
3698    setAssign(wk, roleKey, arr);
3699    if (lebihKuota)
3700      toast(
3701        (role ? role.label : roleKey) +
3702          " minggu ini kini diisi " +
3703          arr.length +
3704          " orang - di atas kuota " +
3705          slots +
3706          ". Pastikan ini memang disengaja.",
3707        "info",
3708        5000,
3709      );
3710    if (sched.auto && !schedAutoWarned) {
3711      schedAutoWarned = true;
3712      toast(
3713        "Anda mengubah jadwal yang dibuat otomatis. Perubahan manual disimpan.",
3714        "info",
3715        4000,
3716      );
3717    }
3718    saveSchedCell(wk, roleKey);
3719    schedHistPush();
3720    return true;
3721  }
3722  function removeAssign(wk, roleKey, pid) {
3723    var arr = getAssign(wk, roleKey).filter(function (x) {
3724      return x !== pid;
3725    });
3726    setAssign(wk, roleKey, arr);
3727    saveSchedCell(wk, roleKey);
3728    schedHistPush();
3729  }
3730  // v3.4 - p.roles adalah SATU-SATUNYA sumber kebenaran untuk
3731  // "apakah orang ini boleh mengisi role tsb". Sebelumnya p.tiers
3732  // diperiksa LEBIH DULU, sehingga role yang baru dilepas centangnya di
3733  // tab Pelayan masih dianggap sah - centang hanya mengubah p.roles dan
3734  // tidak pernah menyentuh p.tiers. Itu sebabnya menghapus nama lalu
3735  // membuat ulang "berhasil" (orang baru tidak punya p.tiers sama
3736  // sekali), padahal melepas centang tidak berpengaruh.
3737  // p.tiers sengaja TIDAK dihapus: dia disimpan sebagai ingatan
3738  // prioritas, jadi kalau role dicentang lagi tingkat aslinya
3739  // (hijau/kuning/oranye) kembali seperti semula.
3740  function personTier(pid, roleKey) {
3741    var p = personById(pid);
3742    if (!p) return 0;
3743    var punya = !!p.roles && p.roles.indexOf(roleKey) >= 0;
3744    if (!punya) return 0;
3745    if (p.tiers && p.tiers[roleKey]) return p.tiers[roleKey];
3746    return 2;
3747  }
3748  function schedNameIn(list, pid) {
3749    return !!list && list.indexOf(personName(pid)) >= 0;
3750  }
3751  function isSoundMMRole(rk) {
3752    return SOUND_MM_ROLES.indexOf(rk) >= 0;
3753  }
3754  // v2.3 - penjadwal berbasis constraint (H1-H8) + fallback berjenjang.
3755  // mode "lanjut" (bawaan) = pertahankan semua nama yang sudah ada,
3756  // termasuk isian manual, dan hanya isi slot yang masih kosong.
3757  // mode "ulang" = susun dari nol.
3758  function autoSchedule(mode) {
3759    if (!canEdit()) {
3760      toast("Hanya admin yang bisa membuat jadwal.", "error");
3761      return;
3762    }
3763    mode = mode === "ulang" ? "ulang" : "lanjut";
3764    var weeks = sched.weeks;
3765    var adaIsi = false;
3766    for (var cw = 1; cw <= weeks; cw++) {
3767      allSchedRoles().forEach(function (r) {
3768        if (getAssign(cw, r.key).length) adaIsi = true;
3769      });
3770    }
3771    if (
3772      !confirm(
3773        mode === "ulang"
3774          ? "Susun ulang jadwal dari nol? SEMUA nama yang ada sekarang, termasuk isian manual, akan dihapus dan diganti."
3775          : adaIsi
3776            ? "Lengkapi jadwal otomatis? Nama yang sudah ada (termasuk isian manual) DIPERTAHANKAN di tempatnya, hanya kolom kosong yang diisi."
3777            : "Buat jadwal otomatis dengan aturan H1-H11 (hospitality, pengurus, presenter, senior/junior, selang-seling, satu alat musik, WL bukan singers)?",
3778      )
3779    )
3780      return;
3781    // v3.6 - jaring pembersih. Mode "lanjut" mempertahankan nama yang
3782    // sudah terpasang, jadi orang yang centang rolenya baru dilepas di
3783    // tab Pelayan (atau yang kini izin) akan bertahan di kolomnya karena
3784    // tidak pernah diperiksa lagi. Dibersihkan lebih dulu supaya tab
3785    // Pelayan dan jadwal selalu satu sumber kebenaran.
3786    var dibuang = 0;
3787    // v3.8.1 - doaBuka & pengumuman TIDAK dinilai dari centangan role.
3788    // eligible() mengisinya dari daftar usher minggu itu (H1), jadi
3789    // menghakiminya pakai personTier akan mengeluarkan orang yang sah.
3790    function bersihSah(pid, w, rk) {
3791      if (isIzin(pid, w)) return false;
3792      if (rk === "doaBuka" || rk === "pengumuman")
3793        return getAssign(w, "usher").indexOf(pid) >= 0;
3794      return personTier(pid, rk) !== 0;
3795    }
3796    // Dua tahap: usher dibersihkan lebih dulu supaya penilaian
3797    // doaBuka/pengumuman memakai daftar usher yang sudah bersih.
3798    var TAHAP_BERSIH = [
3799      function (rk) {
3800        return rk !== "doaBuka" && rk !== "pengumuman";
3801      },
3802      function (rk) {
3803        return rk === "doaBuka" || rk === "pengumuman";
3804      },
3805    ];
3806    TAHAP_BERSIH.forEach(function (pilih) {
3807      for (var pw = 1; pw <= weeks; pw++) {
3808        (function (w) {
3809          allSchedRoles().forEach(function (r) {
3810            if (!pilih(r.key)) return;
3811            var isi = getAssign(w, r.key);
3812            var sisa = isi.filter(function (pid) {
3813              return bersihSah(pid, w, r.key);
3814            });
3815            if (sisa.length !== isi.length) {
3816              dibuang += isi.length - sisa.length;
3817              setAssign(w, r.key, sisa);
3818            }
3819          });
3820        })(pw);
3821      }
3822    });
3823    if (dibuang)
3824      toast(
3825        dibuang +
3826          " nama dikeluarkan karena pelayanannya sudah tidak dicentang atau sedang izin.",
3827        "info",
3828        5000,
3829      );
3830    var lastServed = {};
3831    var roleUse = {};
3832    var load = {};
3833    if (mode === "ulang") {
3834      sched.assign = {};
3835    } else {
3836      // Hitung beban dari nama yang SUDAH terpasang. Tanpa ini penilaian
3837      // keadilan menganggap mereka belum pernah melayani, sehingga orang
3838      // yang sudah diisi manual bisa dipilih lagi berkali-kali.
3839      for (var sw = 1; sw <= weeks; sw++) {
3840        allSchedRoles().forEach(function (r) {
3841          getAssign(sw, r.key).forEach(function (pid) {
3842            roleUse[pid + "|" + r.key] = (roleUse[pid + "|" + r.key] || 0) + 1;
3843            load[pid] = (load[pid] || 0) + 1;
3844          });
3845        });
3846      }
3847    }
3848    var empties = [];
3849    var relaxed = [];
3850
3851    function cntWeek(pid, w) {
3852      return rolesOfPersonInWeek(pid, w).length;
3853    }
3854    function place(w, rk, pid) {
3855      var arr = getAssign(w, rk);
3856      if (arr.indexOf(pid) >= 0) return false;
3857      // Jaring pengaman terakhir: request khusus tidak melewati
3858      // eligible(), jadi bentrok kelompok tetap harus ditolak di sini.
3859      if (konflikEksklusif(pid, w, rk)) return false;
3860      arr.push(pid);
3861      setAssign(w, rk, arr);
3862      roleUse[pid + "|" + rk] = (roleUse[pid + "|" + rk] || 0) + 1;
3863      load[pid] = (load[pid] || 0) + 1;
3864      return true;
3865    }
3866    function maxRole(rk, o) {
3867      // H1: usher boleh merangkap 1 role tambahan (doa pembuka / pengumuman)
3868      if (rk === "doaBuka" || rk === "pengumuman") return 2;
3869      return o && o.allowDouble ? 2 : 1;
3870    }
3871    function eligible(pid, w, rk, o) {
3872      o = o || {};
3873      if (isIzin(pid, w)) return false;
3874      if (getAssign(w, rk).indexOf(pid) >= 0) return false;
3875      if (cntWeek(pid, w) >= maxRole(rk, o)) return false;
3876      // H1 - doa pembuka & pengumuman wajib dari 3 usher minggu itu
3877      if (rk === "doaBuka" || rk === "pengumuman") {
3878        if (getAssign(w, "usher").indexOf(pid) < 0) return false;
3879        if (rk === "pengumuman" && getAssign(w, "doaBuka").indexOf(pid) >= 0)
3880          return false;
3881      } else if (personTier(pid, rk) === 0) {
3882        return false;
3883      }
3884      // H2 - doa penutup khusus pengurus, bukan usher
3885      if (rk === "doaTutup") {
3886        if (!schedNameIn(PENGURUS, pid)) return false;
3887        if (getAssign(w, "usher").indexOf(pid) >= 0) return false;
3888      }
3889      // H3 - presenter terkunci dari role teknis
3890      var isPres = getAssign(w, "presenter").indexOf(pid) >= 0;
3891      if (TECH_ROLES.indexOf(rk) >= 0 && isPres) return false;
3892      if (rk === "presenter") {
3893        for (var i = 0; i < TECH_ROLES.length; i++) {
3894          if (getAssign(w, TECH_ROLES[i]).indexOf(pid) >= 0) return false;
3895        }
3896        if (
3897          !o.crossPool &&
3898          !schedNameIn(PRESENTER_TETAP, pid) &&
3899          !schedNameIn(PRESENTER_TRIAL, pid)
3900        )
3901          return false;
3902      }
3903      // H9 - tim Sound & Multimedia terkunci penuh: tidak boleh merangkap
3904      // tugas di tim lain (P&W, Persembahan, Hospitality) pada minggu sama.
3905      var rpW = rolesOfPersonInWeek(pid, w);
3906      var hasMM = false,
3907        hasOther = false;
3908      for (var j9 = 0; j9 < rpW.length; j9++) {
3909        if (isSoundMMRole(rpW[j9])) hasMM = true;
3910        else hasOther = true;
3911      }
3912      if (isSoundMMRole(rk) ? hasOther : hasMM) return false;
3913      // H10/H11 - kelompok eksklusif. Sengaja ditulis tanpa cek
3914      // o.allowDouble / o.crossPool supaya tangga pelonggaran pun tidak
3915      // bisa menembusnya.
3916      if (konflikEksklusif(pid, w, rk)) return false;
3917      // H6 - musisi P&W senior, Persembahan junior
3918      if (MUS_PNW_ROLES.indexOf(rk) >= 0 && !schedNameIn(MUS_SENIOR, pid))
3919        return false;
3920      if (
3921        MUS_PSB_ROLES.indexOf(rk) >= 0 &&
3922        !o.crossPool &&
3923        !schedNameIn(MUS_JUNIOR, pid)
3924      )
3925        return false;
3926      // H5 - WL Persembahan wajib beda dengan WL P&W, diambil dari singers P&W
3927      if (rk === "wlPsb") {
3928        if (getAssign(w, "wlPNW").indexOf(pid) >= 0) return false;
3929        if (!o.crossPool && getAssign(w, "singPNW").indexOf(pid) < 0)
3930          return false;
3931      }
3932      // H8 - selang-seling (Sound & Multimedia dikecualikan)
3933      if (!o.relaxH8 && !isSoundMMRole(rk) && lastServed[pid] === w - 1)
3934        return false;
3935      return true;
3936    }
3937    function score(pid, w, rk) {
3938      var last = lastServed[pid];
3939      var gap = last ? w - last : 99;
3940      var s = gap * 10 - cntWeek(pid, w) * 5;
3941      // v3.3 - kalau orang ini sudah memegang alat yang sama di sesi
3942      // lain, dia masih SAH tetapi harus kalah dari siapa pun yang
3943      // belum bertugas. Penaltinya besar supaya benar-benar cadangan.
3944      var _ins = musInstrumen(rk);
3945      if (_ins) {
3946        var _pas = MUS_INSTRUMEN[_ins];
3947        for (var _ip = 0; _ip < _pas.length; _ip++) {
3948          if (_pas[_ip] === rk) continue;
3949          if (getAssign(w, _pas[_ip]).indexOf(pid) >= 0) s -= 40;
3950        }
3951      }
3952      s -= (roleUse[pid + "|" + rk] || 0) * 7;
3953      s -= (load[pid] || 0) * 2;
3954      var t = personTier(pid, rk);
3955      if (t) s += (4 - t) * 3;
3956      if (rk === "presenter" && schedNameIn(PRESENTER_TETAP, pid)) s += 4;
3957      return s;
3958    }
3959    function pickBest(w, rk, o) {
3960      var best = null,
3961        bs = -1e9;
3962      sched.roster.forEach(function (p) {
3963        if (!eligible(p.id, w, rk, o)) return;
3964        var s = score(p.id, w, rk) + Math.random();
3965        if (s > bs) {
3966          bs = s;
3967          best = p.id;
3968        }
3969      });
3970      return best;
3971    }
3972    // fallback 3.4: ideal -> longgarkan H8 -> lintas pool
3973    var LADDER = [
3974      {},
3975      { relaxH8: true },
3976      { relaxH8: true, crossPool: true },
3977      { relaxH8: true, crossPool: true, allowDouble: true },
3978    ];
3979    // Tangga khusus WL Persembahan. H5 mewajibkan orangnya diambil dari
3980    // Singers P&W, dan orang itu pasti sudah punya 1 tugas - jadi tanpa
3981    // allowDouble dia selalu tertahan cek cntWeek. Dengan tangga biasa,
3982    // tahap crossPool (yang membuang syarat singPNW) selalu berhasil
3983    // lebih dulu, sehingga H5 tidak pernah terpenuhi. Di sini
3984    // allowDouble sengaja didahulukan daripada crossPool.
3985    var LADDER_WLPSB = [
3986      { allowDouble: true },
3987      { relaxH8: true, allowDouble: true },
3988      { relaxH8: true, crossPool: true, allowDouble: true },
3989    ];
3990    function fill(w, rk, n, soft) {
3991      // Role yang ditutup tidak diisi dan tidak dihitung sebagai kosong.
3992      if (isRoleClosed(w, rk)) return;
3993      var LD = rk === "wlPsb" ? LADDER_WLPSB : LADDER;
3994      for (var k = getAssign(w, rk).length; k < n; k++) {
3995        var got = null,
3996          step = 0;
3997        for (var li = 0; li < LD.length && !got; li++) {
3998          got = pickBest(w, rk, LD[li]);
3999          step = li;
4000        }
4001        if (got) {
4002          place(w, rk, got);
4003          if (step > 0) relaxed.push("M" + w + " " + roleLabel(rk));
4004        } else {
4005          if (!soft) empties.push("M" + w + " " + roleLabel(rk));
4006          break;
4007        }
4008      }
4009    }
4010    function roleLabel(rk) {
4011      var r = schedRole(rk);
4012      return r && r.label ? r.label : rk;
4013    }
4014
4015    var reqs =
4016      sched.requests && sched.requests.length ? sched.requests : REQUESTS || [];
4017
4018    for (var w = 1; w <= weeks; w++) {
4019      // 1. request khusus dikunci lebih dulu
4020      reqs.forEach(function (rq) {
4021        if (!rq || rq.week !== w) return;
4022        var p = null;
4023        sched.roster.forEach(function (x) {
4024          if (x.name === rq.name) p = x;
4025        });
4026        if (p && !isIzin(p.id, w)) place(w, rq.roleKey, p.id);
4027      });
4028      // 2. Sound & Multimedia lebih dulu: pool terkecil & bebas H8 (H3)
4029      fill(w, "presenter", 1);
4030      TECH_ROLES.forEach(function (rk) {
4031        fill(w, rk, 1);
4032      });
4033      // 3. Hospitality (H1 + H2)
4034      fill(w, "usher", 3);
4035      fill(w, "doaBuka", 1);
4036      fill(w, "pengumuman", 1);
4037      fill(w, "doaTutup", 1);
4038      // 4. Praise & Worship (H4 + H6 + H7 urutan baris Piano-Gitar-Bass-Drum)
4039      fill(w, "wlPNW", 2, true);
4040      var nWL = getAssign(w, "wlPNW").length;
4041      if (nWL === 0) empties.push("M" + w + " " + roleLabel("wlPNW"));
4042      fill(w, "singPNW", nWL >= 2 ? 2 : 3);
4043      MUS_PNW_ROLES.forEach(function (rk) {
4044        fill(w, rk, 1);
4045      });
4046      // 5. Persembahan (H5 + H6)
4047      fill(w, "wlPsb", 1);
4048      fill(w, "singPsb", 2, true);
4049      MUS_PSB_ROLES.forEach(function (rk) {
4050        fill(w, rk, 1);
4051      });
4052      // 6. Tambourine & Banners (ikut selang-seling H8)
4053      fill(w, "tamb", 3);
4054      fill(w, "ban", 2);
4055      // 7. commit lastServedWeek
4056      sched.roster.forEach(function (p) {
4057        if (cntWeek(p.id, w) > 0) lastServed[p.id] = w;
4058      });
4059    }
4060
4061    sched.auto = true;
4062    saveSched();
4063    schedHistPush();
4064    renderSchedule();
4065    if (empties.length) {
4066      toast(
4067        "Jadwal dibuat. " +
4068          empties.length +
4069          " slot belum terisi (stok orang kurang): " +
4070          empties.slice(0, 6).join(", ") +
4071          (empties.length > 6 ? ", ..." : "") +
4072          ".",
4073        "info",
4074        6000,
4075      );
4076    } else {
4077      toast(
4078        (mode === "ulang" ? "Jadwal disusun ulang" : "Jadwal dilengkapi") +
4079          " - semua aturan H1-H11 terpenuhi.",
4080        "success",
4081      );
4082    }
4083  }
4084  // v2.3 - garis penghubung 3D untuk Double Role (orthogonal, hindari box lain)
4085  // v2.3 - penanda Double Role ringan: warna tetap per orang + sorot terkait.
4086  // Menggantikan garis SVG 3D (path-finding) yang berat dan berantakan.
4087  function schedDblColor(pid) {
4088    var h = 0;
4089    for (var i = 0; i < pid.length; i++) h = (h * 31 + pid.charCodeAt(i)) % 12;
4090    return "hsl(" + h * 30 + ", 78%, 42%)";
4091  }
4092  function schedHotLines(pid, wk, on) {
4093    var wrap = document.querySelector("#schedulePage .schedScroll");
4094    if (!wrap) return;
4095    wrap.classList.toggle("dimOn", !!on);
4096    var list = wrap.querySelectorAll(".chip[data-pid]");
4097    for (var i = 0; i < list.length; i++) {
4098      var c = list[i];
4099      var m =
4100        c.getAttribute("data-pid") === pid &&
4101        c.getAttribute("data-wk") === String(wk);
4102      c.classList.toggle("hot", !!on && m);
4103    }
4104  }
4105  function schedTeamPrintClass(key) {
4106    if (key === "hosp") return "hosp";
4107    if (key === "mm") return "mm";
4108    return "wor";
4109  }
4110  function buildSchedPrint() {
4111    var area = document.getElementById("schedPrintArea");
4112    if (!area) {
4113      area = document.createElement("div");
4114      area.id = "schedPrintArea";
4115      document.body.appendChild(area);
4116    }
4117    area.innerHTML = "";
4118    var h1 = document.createElement("h1");
4119    h1.className = "spTitle";
4120    h1.textContent = "HOSANA YOUTH";
4121    area.appendChild(h1);
4122    var table = document.createElement("table");
4123    table.className = "spTable";
4124    var thead = document.createElement("thead");
4125    var htr = document.createElement("tr");
4126    htr.className = "spHead";
4127    var c0 = document.createElement("th");
4128    c0.textContent = "SYNERGY";
4129    htr.appendChild(c0);
4130    for (var w = 1; w <= sched.weeks; w++) {
4131      var th = document.createElement("th");
4132      th.textContent =
4133        (sched.weekLabels && sched.weekLabels[w]) || "Minggu " + w;
4134      htr.appendChild(th);
4135    }
4136    thead.appendChild(htr);
4137    table.appendChild(thead);
4138    var tb = document.createElement("tbody");
4139    SCHED_TEAMS.forEach(function (team) {
4140      var band = document.createElement("tr");
4141      band.className = "spBand " + schedTeamPrintClass(team.key);
4142      var bc = document.createElement("td");
4143      bc.colSpan = sched.weeks + 1;
4144      bc.textContent = team.label;
4145      band.appendChild(bc);
4146      tb.appendChild(band);
4147      team.roles.forEach(function (role) {
4148        var tr = document.createElement("tr");
4149        var rl = document.createElement("td");
4150        rl.className = "spRole";
4151        rl.textContent = role.label;
4152        tr.appendChild(rl);
4153        for (var w2 = 1; w2 <= sched.weeks; w2++) {
4154          var td = document.createElement("td");
4155          td.textContent = isRoleClosed(w2, role.key)
4156            ? "-"
4157            : getAssign(w2, role.key).map(personName).join(", ");
4158          tr.appendChild(td);
4159        }
4160        tb.appendChild(tr);
4161      });
4162    });
4163    table.appendChild(tb);
4164    area.appendChild(table);
4165  }
4166  function printSchedule() {
4167    buildSchedPrint();
4168    document.body.classList.add("printing-sched");
4169    var done = function () {
4170      document.body.classList.remove("printing-sched");
4171      window.removeEventListener("afterprint", done);
4172    };
4173    window.addEventListener("afterprint", done);
4174    setTimeout(function () {
4175      window.print();
4176    }, 60);
4177    setTimeout(done, 60000);
4178  }
4179  function openSchedulePage() {
4180    closeMenu();
4181    if (!isAdmin)
4182      toast(
4183        "Mode lihat saja. Login pengurus untuk mengubah jadwal.",
4184        "info",
4185        3000,
4186      );
4187    initScheduleCloud();
4188    document.getElementById("schedulePage").classList.add("open");
4189    renderSchedule();
4190  }
4191  function closeSchedulePage() {
4192    var pg = document.getElementById("schedulePage");
4193    if (pg) pg.classList.remove("open");
4194  }
4195  function schedSetTab(t) {
4196    schedTab = t;
4197    renderSchedule();
4198  }
4199  function renderSchedule() {
4200    var pg = document.getElementById("schedulePage");
4201    if (!pg) return;
4202    ["jadwal", "pelayan", "izin"].forEach(function (t) {
4203      var b = document.getElementById("schedTab_" + t);
4204      if (b) b.classList.toggle("on", schedTab === t);
4205    });
4206    rebuildPidx();
4207    // v2.3 - hak edit jadwal mengikuti status pengurus, bukan gembok lagu.
4208    var admin = isAdmin;
4209    var wc = document.getElementById("schedWeekCount");
4210    if (wc) wc.value = String(sched.weeks);
4211    var body = document.getElementById("schedBody");
4212    if (!body) return;
4213    // v2.9 - simpan keadaan UI supaya render ulang (mis. dari realtime)
4214    // tidak menghapus ketikan pencarian atau melompatkan posisi gulir.
4215    var _cariEl = document.getElementById("rosterSearch");
4216    var _cariVal = _cariEl ? _cariEl.value : "";
4217    var _gulir = body.scrollTop;
4218    body.innerHTML = "";
4219    if (schedTab === "jadwal") {
4220      renderSchedGrid(body, admin);
4221      applyGridFilter();
4222    } else if (schedTab === "pelayan") renderSchedRoster(body, admin);
4223    else renderSchedIzin(body, admin);
4224    if (_cariVal) {
4225      var _c2 = document.getElementById("rosterSearch");
4226      if (_c2) {
4227        _c2.value = _cariVal;
4228        _c2.dispatchEvent(new Event("input"));
4229      }
4230    }
4231    if (_gulir) body.scrollTop = _gulir;
4232  }
4233  var gridNameFilter = "";
4234  // v2.9 - keadaan urutan & saringan daftar pelayan.
4235  var rosterSort = "az";
4236  var rosterRoleFilter = "";
4237  function applyGridFilter() {
4238    var q = (gridNameFilter || "").trim().toLowerCase();
4239    document
4240      .querySelectorAll("#schedBody select.cellSel")
4241      .forEach(function (sel) {
4242        Array.prototype.forEach.call(sel.options, function (o) {
4243          if (!o.value) return;
4244          o.hidden = q ? o.textContent.toLowerCase().indexOf(q) < 0 : false;
4245        });
4246      });
4247  }
4248  // v2.5 - pemilih nama dengan pencarian sendiri. <datalist> tidak bisa
4249  // diandalkan di HP (Safari iOS tidak mendukungnya sama sekali), dan
4250  // membangun daftar penuh di setiap sel juga memberatkan halaman.
4251  function buildAddPicker(wk, roleKey, arr) {
4252    var wrap = document.createElement("span");
4253    wrap.className = "cellPick";
4254    var inp = document.createElement("input");
4255    inp.type = "text";
4256    inp.className = "cellPickInp";
4257    inp.placeholder = "+ tambah / cari";
4258    inp.autocomplete = "off";
4259    inp.setAttribute("autocapitalize", "off");
4260    inp.setAttribute("autocorrect", "off");
4261    var panel = document.createElement("div");
4262    panel.className = "pickList";
4263    panel.style.display = "none";
4264    function marksFor(p) {
4265      var m = [];
4266      if (isIzin(p.id, wk)) m.push("IZIN");
4267      if ((p.roles || []).indexOf(roleKey) < 0) m.push("di luar kemampuan");
4268      if (rolesOfPersonInWeek(p.id, wk).length) m.push("sudah bertugas");
4269      return m.join(" - ");
4270    }
4271    function hidePanel() {
4272      panel.style.display = "none";
4273      panel.innerHTML = "";
4274    }
4275    function choose(p) {
4276      if (isIzin(p.id, wk)) {
4277        var reason = prompt(
4278          p.name +
4279            " sedang IZIN minggu " +
4280            wk +
4281            ". Ketik alasan urgent untuk tetap menugaskan (kosongkan untuk batal):",
4282          "",
4283        );
4284        if (!reason) {
4285          inp.value = "";
4286          hidePanel();
4287          return;
4288        }
4289      }
4290      inp.value = "";
4291      hidePanel();
4292      if (assignPerson(wk, roleKey, p.id)) renderSchedule();
4293    }
4294    function refresh() {
4295      var q = (inp.value || "").trim().toLowerCase();
4296      var hits = [];
4297      for (var i = 0; i < sched.roster.length; i++) {
4298        var p = sched.roster[i];
4299        if (arr.indexOf(p.id) >= 0) continue;
4300        if (q && (p.name || "").toLowerCase().indexOf(q) < 0) continue;
4301        hits.push(p);
4302      }
4303      hits.sort(function (a, b) {
4304        var ta = personTier(a.id, roleKey) || 9;
4305        var tb = personTier(b.id, roleKey) || 9;
4306        if (ta !== tb) return ta - tb;
4307        return (a.name || "").localeCompare(b.name || "", "id", {
4308          sensitivity: "base",
4309        });
4310      });
4311      panel.innerHTML = "";
4312      if (!hits.length) {
4313        var no = document.createElement("div");
4314        no.className = "pickEmpty";
4315        no.textContent = "Tidak ada nama cocok";
4316        panel.appendChild(no);
4317      }
4318      hits.slice(0, 12).forEach(function (p) {
4319        var o = document.createElement("div");
4320        o.className = "pickOpt t" + (personTier(p.id, roleKey) || 0);
4321        var nm = document.createElement("b");
4322        nm.textContent = p.name;
4323        o.appendChild(nm);
4324        var mk = marksFor(p);
4325        if (mk) {
4326          var s = document.createElement("i");
4327          s.className = "pickMark";
4328          s.textContent = mk;
4329          o.appendChild(s);
4330        }
4331        o.onmousedown = function (e) {
4332          if (e && e.preventDefault) e.preventDefault();
4333          choose(p);
4334        };
4335        panel.appendChild(o);
4336      });
4337      panel.style.display = "block";
4338    }
4339    inp.onfocus = refresh;
4340    inp.oninput = refresh;
4341    inp.onblur = function () {
4342      setTimeout(hidePanel, 200);
4343    };
4344    inp.onkeydown = function (e) {
4345      if (e.key === "Escape") {
4346        inp.value = "";
4347        hidePanel();
4348        return;
4349      }
4350      if (e.key === "Enter") {
4351        var first = panel.querySelector(".pickOpt");
4352        if (first && first.onmousedown) first.onmousedown(e);
4353      }
4354    };
4355    wrap.appendChild(inp);
4356    wrap.appendChild(panel);
4357    return wrap;
4358  }
4359  // v2.4 - satu tempat pembuatan chip nama, dipakai semua baris.
4360  function schedBuildChip(pid, wk, roleKey, admin) {
4361    var chip = document.createElement("span");
4362    var myRoles = rolesOfPersonInWeek(pid, wk);
4363    var isDbl = myRoles.length >= 2;
4364    chip.className =
4365      "chip" + (isIzin(pid, wk) ? " warn" : "") + (isDbl ? " dbl" : "");
4366    chip.textContent = personName(pid);
4367    chip.setAttribute("data-pid", pid);
4368    chip.setAttribute("data-wk", wk);
4369    if (isDbl) {
4370      chip.style.setProperty("--dblc", schedDblColor(pid));
4371      chip.title =
4372        personName(pid) +
4373        " - Double Role (" +
4374        myRoles.length +
4375        " tugas): " +
4376        myRoles
4377          .map(function (rk2) {
4378            var r2 = schedRole(rk2);
4379            return r2 ? r2.label : rk2;
4380          })
4381          .join(", ");
4382      var tag = document.createElement("i");
4383      tag.className = "dblTag";
4384      tag.textContent = String(myRoles.length);
4385      chip.appendChild(tag);
4386    }
4387    chip.onmouseenter = function () {
4388      schedHotLines(pid, wk, true);
4389    };
4390    chip.onmouseleave = function () {
4391      schedHotLines(pid, wk, false);
4392    };
4393    chip.onclick = function () {
4394      schedHotLines(pid, wk, true);
4395      clearTimeout(window.__hotRT);
4396      window.__hotRT = setTimeout(function () {
4397        schedHotLines(pid, wk, false);
4398      }, 2200);
4399    };
4400    if (admin) {
4401      var x = document.createElement("b");
4402      x.className = "chipX";
4403      x.textContent = "x";
4404      x.onclick = function () {
4405        removeAssign(wk, roleKey, pid);
4406        renderSchedule();
4407      };
4408      chip.appendChild(x);
4409    }
4410    return chip;
4411  }
4412  // v2.5 - ekspor Excel memakai tabel HTML ber-gaya. Excel dan Google
4413  // Sheets membukanya mulus, dan tidak perlu pustaka tambahan sama sekali.
4414  function schedExcelHtml() {
4415    function esc(s) {
4416      return String(s == null ? "" : s)
4417        .replace(/&/g, "&amp;")
4418        .replace(/</g, "&lt;")
4419        .replace(/>/g, "&gt;");
4420    }
4421    var COL = {
4422      hosp: "#f6c445",
4423      mm: "#7ec8f2",
4424      wPNW: "#f2a2c0",
4425      wPsb: "#c3a6e0",
4426      wUmum: "#a8d5a2",
4427    };
4428    var n = sched.weeks;
4429    var h =
4430      '<tr><td colspan="' +
4431      (n + 1) +
4432      '" style="background:#1f3864;color:#ffffff;font-size:16pt;font-weight:bold;text-align:center;height:34px">HOSANA YOUTH SYNERGY</td></tr>';
4433    h +=
4434      '<tr><td style="background:#1f3864;color:#ffffff;font-weight:bold;text-align:center">SYNERGY</td>';
4435    for (var w = 1; w <= n; w++)
4436      h +=
4437        '<td style="background:#1f3864;color:#ffffff;font-weight:bold;text-align:center">' +
4438        esc((sched.weekLabels && sched.weekLabels[w]) || "Minggu " + w) +
4439        "</td>";
4440    h += "</tr>";
4441    SCHED_TEAMS.forEach(function (team) {
4442      var c = COL[team.key] || "#dddddd";
4443      h +=
4444        '<tr><td colspan="' +
4445        (n + 1) +
4446        '" style="background:' +
4447        c +
4448        ';font-weight:bold;text-align:center">' +
4449        esc(team.label) +
4450        "</td></tr>";
4451      team.roles.forEach(function (role) {
4452        h +=
4453          '<tr><td style="background:' +
4454          c +
4455          ';font-weight:bold">' +
4456          esc(role.label) +
4457          "</td>";
4458        for (var w2 = 1; w2 <= n; w2++)
4459          h +=
4460            '<td style="text-align:center">' +
4461            esc(getAssign(w2, role.key).map(personName).join(", ")) +
4462            "</td>";
4463        h += "</tr>";
4464      });
4465    });
4466    return (
4467      '<html xmlns:x="urn:schemas-microsoft-com:office:excel">' +
4468      '<head><meta charset="utf-8">' +
4469      "<!--[if gte mso 9]><xml><x:ExcelWorkbook><x:ExcelWorksheets>" +
4470      "<x:ExcelWorksheet><x:Name>Jadwal Youth</x:Name><x:WorksheetOptions>" +
4471      "<x:DisplayGridlines/></x:WorksheetOptions></x:ExcelWorksheet>" +
4472      "</x:ExcelWorksheets></x:ExcelWorkbook></xml><![endif]-->" +
4473      "</head><body>" +
4474      '<table border="1" cellspacing="0" cellpadding="4" style="border-collapse:collapse;font-family:Calibri,Arial;font-size:11pt">' +
4475      h +
4476      "</table></body></html>"
4477    );
4478  }
4479  function exportSchedExcel() {
4480    try {
4481      var blob = new Blob(["\ufeff" + schedExcelHtml()], {
4482        type: "application/vnd.ms-excel;charset=utf-8",
4483      });
4484      var url = URL.createObjectURL(blob);
4485      var a = document.createElement("a");
4486      a.href = url;
4487      a.download = "Jadwal-Youth.xls";
4488      document.body.appendChild(a);
4489      a.click();
4490      setTimeout(function () {
4491        if (a.parentNode) a.parentNode.removeChild(a);
4492        URL.revokeObjectURL(url);
4493      }, 800);
4494      toast("Berkas Excel diunduh.", "success");
4495    } catch (e) {
4496      console.error(e);
4497      toast("Gagal membuat berkas Excel.", "error");
4498    }
4499  }
4500  // v2.6 - kirim jadwal jadi ke Google Sheets lewat Apps Script yang
4501  // sudah dipakai form izin & saran. Muatan dikirim per blok tim supaya
4502  // Apps Script bisa menulis ulang sheet lengkap dengan warnanya.
4503  function schedSheetPayload() {
4504    var COL = {
4505      hosp: "#f6c445",
4506      mm: "#7ec8f2",
4507      wPNW: "#f2a2c0",
4508      wPsb: "#c3a6e0",
4509      wUmum: "#a8d5a2",
4510    };
4511    var n = sched.weeks;
4512    var head = ["SYNERGY"];
4513    for (var w = 1; w <= n; w++)
4514      head.push((sched.weekLabels && sched.weekLabels[w]) || "Minggu " + w);
4515    var blocks = [];
4516    SCHED_TEAMS.forEach(function (team) {
4517      var b = {
4518        tim: team.label,
4519        warna: COL[team.key] || "#dddddd",
4520        baris: [],
4521      };
4522      team.roles.forEach(function (role) {
4523        var sel = [];
4524        for (var w2 = 1; w2 <= n; w2++)
4525          sel.push(
4526            isRoleClosed(w2, role.key)
4527              ? "-"
4528              : getAssign(w2, role.key).map(personName).join(", "),
4529          );
4530        b.baris.push({ role: role.label, sel: sel });
4531      });
4532      blocks.push(b);
4533    });
4534    return {
4535      type: "jadwal",
4536      sheet: "Jadwal Youth",
4537      judul: "HOSANA YOUTH SYNERGY",
4538      minggu: n,
4539      head: head,
4540      blocks: blocks,
4541      oleh: schedWho(),
4542      waktu: new Date().toLocaleString("id-ID"),
4543    };
4544  }
4545  function sendSchedToSheets() {
4546    if (!IZIN_ENDPOINT) {
4547      toast("Alamat Google Sheets belum diatur.", "error", 6000);
4548      return;
4549    }
4550    var btn = document.getElementById("schedSheetBtn");
4551    function busy(on) {
4552      if (!btn) return;
4553      btn.disabled = on;
4554      btn.textContent = on ? "Mengirim..." : "Kirim ke Sheets";
4555    }
4556    function done(ok, msg) {
4557      busy(false);
4558      toast(msg, ok ? "success" : "error", ok ? 3600 : 8000);
4559    }
4560    busy(true);
4561    var body = JSON.stringify(schedSheetPayload());
4562    fetch(IZIN_ENDPOINT, {
4563      method: "POST",
4564      headers: { "Content-Type": "text/plain;charset=utf-8" },
4565      body: body,
4566    })
4567      .then(function (r) {
4568        return r.text();
4569      })
4570      .then(function (t) {
4571        if (/ok/i.test(t)) done(true, "Jadwal masuk ke Google Sheets.");
4572        else
4573          done(false, "Apps Script menolak: " + String(t || "").slice(0, 140));
4574      })
4575      .catch(function () {
4576        // Sebagian browser memblokir pembacaan balasan Apps Script.
4577        // Kirim ulang tanpa membaca balasan. Apps Script menulis ulang
4578        // sheet yang sama, jadi kiriman ganda tidak menggandakan data.
4579        fetch(IZIN_ENDPOINT, {
4580          method: "POST",
4581          mode: "no-cors",
4582          headers: { "Content-Type": "text/plain;charset=utf-8" },
4583          body: body,
4584        })
4585          .then(function () {
4586            done(
4587              true,
4588              "Jadwal dikirim. Silakan cek Google Sheets untuk memastikan.",
4589            );
4590          })
4591          .catch(function () {
4592            done(false, "Gagal mengirim ke Google Sheets.");
4593          });
4594      });
4595  }
4596  // v3.6 - toolbar dirapikan TANPA mengubah tombolnya: anak-anak
4597  // toolbar dipindah ke gugus (aksi / riwayat / aturan / bagikan /
4598  // status) supaya tidak berderet panjang jadi satu baris membingungkan.
4599  // Gugus bagikan + status didorong ke kanan.
4600  function schedRapikanToolbar(tb) {
4601    var anak = [].slice.call(tb.children);
4602    var urut = ["aksi", "riwayat", "aturan", "bagikan", "status"];
4603    var g = {};
4604    urut.forEach(function (k) {
4605      g[k] = document.createElement("div");
4606      g[k].className = "tbGrp" + (k === "bagikan" ? " tbShare" : "");
4607    });
4608    anak.forEach(function (el) {
4609      var id = el.id || "";
4610      var t = (el.textContent || "").trim();
4611      var grup = "status";
4612      if (id === "schedUndoBtn" || id === "schedRedoBtn") grup = "riwayat";
4613      else if (el.classList && el.classList.contains("dblToggle"))
4614        grup = "aturan";
4615      else if (
4616        id === "schedXlsBtn" ||
4617        id === "schedSheetBtn" ||
4618        t === "Cetak / PDF"
4619      )
4620        grup = "bagikan";
4621      else if (el.tagName === "BUTTON") grup = "aksi";
4622      g[grup].appendChild(el);
4623    });
4624    urut.forEach(function (k) {
4625      if (g[k].children.length) tb.appendChild(g[k]);
4626    });
4627  }
4628  function renderSchedGrid(body, admin) {
4629    if (!schedHist.stack.length) schedHistReset();
4630    var toolbar = document.createElement("div");
4631    toolbar.className = "schedToolbar";
4632    if (admin) {
4633      var autoBtn = document.createElement("button");
4634      autoBtn.className = "actionBtn";
4635      autoBtn.textContent = "Buat jadwal otomatis";
4636      autoBtn.title =
4637        "Isi kolom yang masih kosong. Nama yang sudah ada dipertahankan.";
4638      // Dibungkus fungsi: kalau dipasang langsung, objek event terkirim
4639      // sebagai argumen mode.
4640      autoBtn.onclick = function () {
4641        autoSchedule("lanjut");
4642      };
4643      toolbar.appendChild(autoBtn);
4644      var ulangBtn = document.createElement("button");
4645      ulangBtn.className = "actionBtn secondary";
4646      ulangBtn.id = "schedUlangBtn";
4647      ulangBtn.textContent = "Susun ulang";
4648      ulangBtn.title = "Hapus semua nama lalu susun jadwal dari nol.";
4649      ulangBtn.onclick = function () {
4650        autoSchedule("ulang");
4651      };
4652      toolbar.appendChild(ulangBtn);
4653      var clearBtn = document.createElement("button");
4654      clearBtn.className = "actionBtn secondary";
4655      clearBtn.textContent = "Kosongkan";
4656      clearBtn.onclick = function () {
4657        if (confirm("Kosongkan semua jadwal?")) {
4658          sched.assign = {};
4659          sched.auto = false;
4660          saveSched();
4661          schedHistPush();
4662          renderSchedule();
4663        }
4664      };
4665      toolbar.appendChild(clearBtn);
4666      var undoBtn = document.createElement("button");
4667      undoBtn.className = "actionBtn secondary";
4668      undoBtn.id = "schedUndoBtn";
4669      undoBtn.textContent = "\u21B6 Undo";
4670      undoBtn.title = "Batalkan perubahan terakhir (Ctrl+Z)";
4671      undoBtn.onclick = schedUndo;
4672      // Status mati/hidup dipasang LANGSUNG di sini. Memakai
4673      // schedHistBtns() belum bisa, karena toolbar baru masuk ke DOM
4674      // setelah fungsi ini selesai - getElementById masih null.
4675      undoBtn.disabled = schedHist.idx <= 0;
4676      toolbar.appendChild(undoBtn);
4677      var redoBtn = document.createElement("button");
4678      redoBtn.className = "actionBtn secondary";
4679      redoBtn.id = "schedRedoBtn";
4680      redoBtn.textContent = "Maju \u21B7";
4681      redoBtn.title = "Ulangi perubahan (Ctrl+Shift+Z)";
4682      redoBtn.onclick = schedRedo;
4683      redoBtn.disabled = schedHist.idx >= schedHist.stack.length - 1;
4684      toolbar.appendChild(redoBtn);
4685      var dblLab = document.createElement("label");
4686      dblLab.className = "dblToggle";
4687      var dblCb = document.createElement("input");
4688      dblCb.type = "checkbox";
4689      dblCb.checked = !!sched.allowDouble;
4690      dblCb.onchange = function () {
4691        sched.allowDouble = dblCb.checked;
4692        saveSched(true);
4693        toast(
4694          dblCb.checked
4695            ? "Double role diizinkan (tanpa peringatan)."
4696            : "Double role akan diberi peringatan.",
4697          "info",
4698        );
4699        renderSchedule();
4700      };
4701      dblLab.appendChild(dblCb);
4702      dblLab.appendChild(document.createTextNode(" Izinkan double role"));
4703      toolbar.appendChild(dblLab);
4704      // v3.5 - sakelar terpisah dari "Izinkan double role" supaya bisa
4705      // dinyalakan tanpa ikut mematikan peringatan rangkap.
4706      var musLab = document.createElement("label");
4707      musLab.className = "dblToggle";
4708      musLab.title =
4709        "Hanya untuk pengisian manual. Jadwal otomatis tetap memakai aturan lama.";
4710      var musCb = document.createElement("input");
4711      musCb.type = "checkbox";
4712      musCb.id = "schedMusLintasCb";
4713      musCb.checked = !!sched.allowMusLintasSesi;
4714      musCb.onchange = function () {
4715        sched.allowMusLintasSesi = musCb.checked;
4716        saveSched(true);
4717        toast(
4718          musCb.checked
4719            ? "Pemusik P&W kini boleh mengisi alat berbeda di Persembahan (isi manual)."
4720            : "Pemusik kembali dibatasi satu alat per hari.",
4721          "info",
4722          4200,
4723        );
4724        renderSchedule();
4725      };
4726      musLab.appendChild(musCb);
4727      musLab.appendChild(document.createTextNode(" Pemusik lintas sesi"));
4728      toolbar.appendChild(musLab);
4729    } else {
4730      var note = document.createElement("span");
4731      note.className = "small";
4732      note.textContent = "Mode lihat. Login admin untuk mengubah jadwal.";
4733      toolbar.appendChild(note);
4734    }
4735    var printBtn = document.createElement("button");
4736    printBtn.className = "actionBtn secondary";
4737    printBtn.textContent = "Cetak / PDF";
4738    printBtn.onclick = printSchedule;
4739    toolbar.appendChild(printBtn);
4740    var xlsBtn = document.createElement("button");
4741    xlsBtn.className = "actionBtn secondary";
4742    xlsBtn.id = "schedXlsBtn";
4743    xlsBtn.textContent = "Ekspor Excel";
4744    xlsBtn.onclick = exportSchedExcel;
4745    toolbar.appendChild(xlsBtn);
4746    var shBtn = document.createElement("button");
4747    shBtn.className = "actionBtn";
4748    shBtn.id = "schedSheetBtn";
4749    shBtn.textContent = "Kirim ke Sheets";
4750    shBtn.onclick = sendSchedToSheets;
4751    toolbar.appendChild(shBtn);
4752    var syncPill = document.createElement("span");
4753    syncPill.id = "schedSyncPill";
4754    syncPill.textContent = window.__schedSyncTxt || "Menyambung...";
4755    toolbar.appendChild(syncPill);
4756    schedRapikanToolbar(toolbar);
4757    body.appendChild(toolbar);
4758    var legend = document.createElement("div");
4759    legend.className = "schedLegend";
4760    legend.innerHTML =
4761      '<span class="lgItem"><i class="lgSw dbl"></i>Double Role</span>' +
4762      '<span class="lgItem"><i class="lgSw warn"></i>Sedang izin</span>' +
4763      '<span class="lgItem">Angka kecil = jumlah tugas orang itu di minggu tersebut. Arahkan kursor atau ketuk nama untuk menyorot semua tugasnya.</span>' +
4764      (admin
4765        ? '<span class="lgItem">Perubahan tersimpan realtime untuk semua pengurus.</span>'
4766        : '<span class="schedRO">Mode lihat saja</span>');
4767    body.appendChild(legend);
4768    var wrap = document.createElement("div");
4769    wrap.className = "schedScroll";
4770    var table = document.createElement("table");
4771    table.className = "schedGrid";
4772    var thead = document.createElement("thead");
4773    var htr = document.createElement("tr");
4774    var corner = document.createElement("th");
4775    corner.textContent = "Pelayanan";
4776    htr.appendChild(corner);
4777    for (var w = 1; w <= sched.weeks; w++) {
4778      (function (wk) {
4779        var th = document.createElement("th");
4780        var cap = document.createElement("div");
4781        cap.textContent = "Minggu " + wk;
4782        th.appendChild(cap);
4783        if (admin) {
4784          var wi = document.createElement("input");
4785          wi.className = "wkLabel";
4786          wi.placeholder = "tanggal";
4787          wi.value = (sched.weekLabels && sched.weekLabels[wk]) || "";
4788          wi.onchange = function () {
4789            if (!sched.weekLabels) sched.weekLabels = {};
4790            sched.weekLabels[wk] = wi.value;
4791            saveSched(true);
4792          };
4793          th.appendChild(wi);
4794        } else if (sched.weekLabels && sched.weekLabels[wk]) {
4795          var wl = document.createElement("div");
4796          wl.className = "wkLabelText";
4797          wl.textContent = sched.weekLabels[wk];
4798          th.appendChild(wl);
4799        }
4800        // v3.6 - tutup/buka seluruh kolom minggu dari header.
4801        if (admin) th.appendChild(buildTutupMingguBtn(wk));
4802        else if (isWeekClosed(wk)) {
4803          var wt = document.createElement("div");
4804          wt.className = "wkLabelText";
4805          wt.textContent = "Ditutup";
4806          th.appendChild(wt);
4807        }
4808        htr.appendChild(th);
4809      })(w);
4810    }
4811    thead.appendChild(htr);
4812    table.appendChild(thead);
4813    var tbody = document.createElement("tbody");
4814    SCHED_TEAMS.forEach(function (team) {
4815      var trh = document.createElement("tr");
4816      trh.className = "teamRow";
4817      var tdh = document.createElement("td");
4818      tdh.colSpan = sched.weeks + 1;
4819      tdh.textContent = team.label;
4820      tdh.style.textAlign = "center";
4821      trh.appendChild(tdh);
4822      tbody.appendChild(trh);
4823      // v2.4 - role bertanda group digabung jadi SATU baris, berjejer.
4824      var rows = [];
4825      team.roles.forEach(function (role) {
4826        var last = rows[rows.length - 1];
4827        if (role.group && last && last.group === role.group) {
4828          last.roles.push(role);
4829          return;
4830        }
4831        rows.push({
4832          group: role.group || null,
4833          label: role.group
4834            ? role.groupLabel || role.group
4835            : role.label + (role.slots > 1 ? " (x" + role.slots + ")" : ""),
4836          roles: [role],
4837        });
4838      });
4839      rows.forEach(function (row, ri) {
4840        var tr = document.createElement("tr");
4841        var rl = document.createElement("td");
4842        rl.className = "roleCell";
4843        rl.textContent = row.label;
4844        tr.appendChild(rl);
4845        for (var w2 = 1; w2 <= sched.weeks; w2++) {
4846          (function (wk) {
4847            // v3.6 - minggu yang ditutup dari header tidak digambar per
4848            // sel. Tiap blok tim memakai SATU sel gabungan (rowSpan)
4849            // berisi alasan yang bisa diedit admin. Digabung per tim,
4850            // bukan satu sel untuk seluruh tabel, karena baris judul tim
4851            // memakai colSpan penuh sehingga rowSpan tidak bisa
4852            // melewatinya.
4853            if (isWeekClosed(wk)) {
4854              if (ri !== 0) return;
4855              var tdW = document.createElement("td");
4856              tdW.className = "cell cellTutup weekTutup";
4857              tdW.rowSpan = rows.length;
4858              tdW.appendChild(buildWeekReason(wk, admin));
4859              tr.appendChild(tdW);
4860              return;
4861            }
4862            var td = document.createElement("td");
4863            td.className = "cell" + (row.group ? " musCell" : "");
4864            row.roles.forEach(function (role) {
4865              var box = td;
4866              if (row.group) {
4867                box = document.createElement("div");
4868                box.className = "musSlot";
4869                var lb = document.createElement("i");
4870                lb.className = "musLb";
4871                lb.textContent = role.label;
4872                box.appendChild(lb);
4873              }
4874              var tutup = isRoleClosed(wk, role.key);
4875              if (tutup) {
4876                box.classList.add("cellTutup");
4877                var tg = document.createElement("span");
4878                tg.className = "tutupTag";
4879                tg.textContent = "Ditutup";
4880                tg.style.cssText =
4881                  "display:inline-block;padding:2px 8px;border-radius:8px;" +
4882                  "background:#e5e7eb;color:#6b7280;font-size:11px;font-style:italic";
4883                box.appendChild(tg);
4884              } else {
4885                var arr = getAssign(wk, role.key);
4886                arr.forEach(function (pid) {
4887                  box.appendChild(schedBuildChip(pid, wk, role.key, admin));
4888                });
4889                if (admin && arr.length < batasSlot(role.key))
4890                  box.appendChild(buildAddPicker(wk, role.key, arr));
4891              }
4892              if (admin) box.appendChild(buildTutupBtn(wk, role.key, tutup));
4893              if (row.group) td.appendChild(box);
4894            });
4895            tr.appendChild(td);
4896          })(w2);
4897        }
4898        tbody.appendChild(tr);
4899      });
4900    });
4901    table.appendChild(tbody);
4902    wrap.appendChild(table);
4903    body.appendChild(wrap);
4904  }
4905  // v3.7 - enam nama role muncul di dua sesi (Worship Leader, Singers,
4906  // Pianist, Guitarist, Bassist, Drummer). Di tab Pelayan tidak ada baris
4907  // judul tim seperti di tabel jadwal, jadi keterangan sesi ditempelkan
4908  // ke labelnya supaya tidak ada dua checkbox bernama sama.
4909  var ROLE_SESI_SUFFIX = { PNW: " (P&W)", Psb: " (Persembahan)" };
4910  function schedRoleLabelPenuh(rk) {
4911    var r = schedRole(rk);
4912    var lb = r ? r.label : rk;
4913    var m = /(PNW|Psb)$/.exec(rk);
4914    return lb + (m ? ROLE_SESI_SUFFIX[m[1]] : "");
4915  }
4916  // v3.7 - permintaan role dari member. Member TIDAK pernah menulis ke
4917  // roster: centangannya dikirim sebagai USULAN ke sched.roleReq, dan
4918  // baru masuk ke roster kalau pengurus menyetujui. Ini yang menahan
4919  // orang usil tanpa perlu sistem login untuk tiap member.
4920  function sayaPid() {
4921    return localStorage.getItem("ptSayaPid") || "";
4922  }
4923  function setSayaPid(v) {
4924    if (v) localStorage.setItem("ptSayaPid", v);
4925    else localStorage.removeItem("ptSayaPid");
4926  }
4927  function roleReqAll() {
4928    return sched.roleReq || {};
4929  }
4930  function roleReqOf(pid) {
4931    return roleReqAll()[pid] || null;
4932  }
4933  function roleReqDiff(pid, minta) {
4934    var p = personById(pid);
4935    var punya = (p && p.roles) || [];
4936    return {
4937      tambah: minta.filter(function (k) {
4938        return punya.indexOf(k) < 0;
4939      }),
4940      hapus: punya.filter(function (k) {
4941        return minta.indexOf(k) < 0;
4942      }),
4943    };
4944  }
4945  function reqAmbilCentang(kartu) {
4946    return [].slice
4947      .call(kartu.querySelectorAll(".roleCheck input[type=checkbox]"))
4948      .filter(function (c) {
4949        return c.checked;
4950      })
4951      .map(function (c) {
4952        return c.getAttribute("data-rk");
4953      });
4954  }
4955  // Panel pengurus: daftar permintaan + selisihnya, bukan sekadar daftar
4956  // role, supaya sekali lihat langsung paham apa yang berubah.
4957  function buildReqPanel() {
4958    var reqs = roleReqAll();
4959    var ids = Object.keys(reqs);
4960    var box = document.createElement("div");
4961    box.className = "reqPanel";
4962    var h = document.createElement("div");
4963    h.className = "reqPanelHead";
4964    h.textContent =
4965      "Permintaan pelayanan dari member" +
4966      (ids.length ? " (" + ids.length + " menunggu)" : "");
4967    box.appendChild(h);
4968    if (!ids.length) {
4969      var kos = document.createElement("div");
4970      kos.className = "reqStatus";
4971      kos.textContent =
4972        "Belum ada permintaan. Kalau member mengubah centangan pelayanannya, permintaannya muncul di sini untuk kamu setujui.";
4973      box.appendChild(kos);
4974      return box;
4975    }
4976    ids.forEach(function (pid) {
4977      var r = reqs[pid] || {};
4978      var p = personById(pid);
4979      var d = roleReqDiff(pid, r.roles || []);
4980      var it = document.createElement("div");
4981      it.className = "reqItem";
4982      var top = document.createElement("div");
4983      top.className = "reqItemHead";
4984      var nm = document.createElement("b");
4985      nm.textContent = (p && p.name) || r.name || pid;
4986      top.appendChild(nm);
4987      it.appendChild(top);
4988      var chips = document.createElement("div");
4989      chips.className = "reqChips";
4990      d.tambah.forEach(function (k) {
4991        var c = document.createElement("span");
4992        c.className = "reqChip add";
4993        c.textContent = "+ " + schedRoleLabelPenuh(k);
4994        chips.appendChild(c);
4995      });
4996      d.hapus.forEach(function (k) {
4997        var c = document.createElement("span");
4998        c.className = "reqChip del";
4999        c.textContent = "− " + schedRoleLabelPenuh(k);
5000        chips.appendChild(c);
5001      });
5002      if (!d.tambah.length && !d.hapus.length) {
5003        var c0 = document.createElement("span");
5004        c0.className = "reqChip";
5005        c0.textContent = "Tidak ada perubahan";
5006        chips.appendChild(c0);
5007      }
5008      it.appendChild(chips);
5009      if (r.note) {
5010        var nt = document.createElement("div");
5011        nt.className = "reqStatus";
5012        nt.textContent = "Catatan member: " + r.note;
5013        it.appendChild(nt);
5014      }
5015      var act = document.createElement("div");
5016      act.className = "reqRow";
5017      var ya = document.createElement("button");
5018      ya.className = "actionBtn";
5019      ya.textContent = "Setujui";
5020      ya.onclick = function () {
5021        var pv = personById(pid);
5022        if (pv) {
5023          pv.roles = (r.roles || []).slice();
5024          saveSchedRoster();
5025        }
5026        if (sched.roleReq) delete sched.roleReq[pid];
5027        saveSched(true);
5028        toast(
5029          ((pv && pv.name) || "Pelayan") +
5030            ": permintaan disetujui, pelayanannya sudah diperbarui.",
5031          "success",
5032          4600,
5033        );
5034        renderSchedule();
5035      };
5036      var no = document.createElement("button");
5037      no.className = "actionBtn secondary";
5038      no.textContent = "Tolak";
5039      no.onclick = function () {
5040        if (sched.roleReq) delete sched.roleReq[pid];
5041        saveSched(true);
5042        toast("Permintaan ditolak. Roster tidak diubah.", "info");
5043        renderSchedule();
5044      };
5045      act.appendChild(ya);
5046      act.appendChild(no);
5047      it.appendChild(act);
5048      box.appendChild(it);
5049    });
5050    return box;
5051  }
5052  // Panel member: pilih identitas dulu. Tanpa login per member, ini cara
5053  // paling sederhana yang tetap aman - efeknya hanya usulan.
5054  function buildSayaBar() {
5055    var box = document.createElement("div");
5056    box.className = "reqPanel";
5057    var h = document.createElement("div");
5058    h.className = "reqPanelHead";
5059    h.textContent = "Ubah pelayanan saya";
5060    box.appendChild(h);
5061    var st = document.createElement("div");
5062    st.className = "reqStatus";
5063    st.textContent =
5064      "Pilih namamu, centang pelayanan yang kamu bisa, lalu tekan Kirim permintaan. Perubahan baru berlaku setelah disetujui pengurus. Kartu orang lain tidak bisa kamu ubah.";
5065    box.appendChild(st);
5066    var row = document.createElement("div");
5067    row.className = "reqRow";
5068    var lb = document.createElement("span");
5069    lb.className = "small";
5070    lb.textContent = "Saya adalah:";
5071    var sel = document.createElement("select");
5072    var op0 = document.createElement("option");
5073    op0.value = "";
5074    op0.textContent = "- pilih nama -";
5075    sel.appendChild(op0);
5076    sched.roster
5077      .slice()
5078      .sort(function (a, b) {
5079        return (a.name || "").localeCompare(b.name || "");
5080      })
5081      .forEach(function (p) {
5082        var op = document.createElement("option");
5083        op.value = p.id;
5084        op.textContent = p.name;
5085        sel.appendChild(op);
5086      });
5087    sel.value = sayaPid();
5088    sel.onchange = function () {
5089      setSayaPid(sel.value);
5090      renderSchedule();
5091    };
5092    row.appendChild(lb);
5093    row.appendChild(sel);
5094    box.appendChild(row);
5095    var ada = roleReqOf(sayaPid());
5096    if (ada) {
5097      var pend = document.createElement("div");
5098      pend.className = "reqStatus pend";
5099      pend.textContent =
5100        "Permintaanmu sudah terkirim dan sedang menunggu persetujuan pengurus.";
5101      box.appendChild(pend);
5102    }
5103    return box;
5104  }
5105  function buildKirimReq(p, card) {
5106    var box = document.createElement("div");
5107    box.className = "reqBox";
5108    var ada = roleReqOf(p.id);
5109    var st = document.createElement("div");
5110    st.className = "reqStatus" + (ada ? " pend" : "");
5111    st.textContent = ada
5112      ? "Menunggu persetujuan pengurus. Kamu masih bisa mengubah centangan lalu mengirim ulang."
5113      : "Ubah centangan di atas, lalu kirim untuk ditinjau pengurus.";
5114    box.appendChild(st);
5115    var row = document.createElement("div");
5116    row.className = "reqRow";
5117    var note = document.createElement("input");
5118    note.className = "reqNote";
5119    note.placeholder = "Catatan untuk pengurus (opsional)";
5120    note.value = ada ? ada.note || "" : "";
5121    var btn = document.createElement("button");
5122    btn.className = "actionBtn reqSend";
5123    btn.textContent = "Kirim permintaan";
5124    // Mati sampai benar-benar ada perubahan, supaya tidak ada permintaan
5125    // kosong yang membebani pengurus.
5126    btn.disabled = true;
5127    btn.onclick = function () {
5128      if (rateLimited("req_" + p.id, 5000)) {
5129        toast(
5130          "Tunggu sebentar sebelum mengirim ulang permintaan.",
5131          "error",
5132          3000,
5133        );
5134        return;
5135      }
5136      var minta = reqAmbilCentang(card);
5137      if (!sched.roleReq) sched.roleReq = {};
5138      sched.roleReq[p.id] = {
5139        roles: minta,
5140        note: (note.value || "").trim(),
5141        at: Date.now(),
5142        name: p.name,
5143      };
5144      saveSched(true);
5145      toast(
5146        "Permintaan terkirim. Pengurus akan meninjau centanganmu.",
5147        "success",
5148        4600,
5149      );
5150      renderSchedule();
5151    };
5152    row.appendChild(note);
5153    row.appendChild(btn);
5154    box.appendChild(row);
5155    return box;
5156  }
5157  function renderSchedRoster(body, admin) {
5158    var info = document.createElement("p");
5159    info.className = "small";
5160    info.textContent = admin
5161      ? "Centang pelayanan yang bisa dilakukan tiap orang. Perubahan langsung dipakai oleh jadwal otomatis."
5162      : "Kamu bisa mengusulkan perubahan pelayanan untuk dirimu sendiri. Pengurus yang menyetujui.";
5163    body.appendChild(info);
5164    // v3.7 - pengurus melihat kotak persetujuan, member melihat pemilih
5165    // identitas untuk mengusulkan pelayanannya sendiri.
5166    body.appendChild(admin ? buildReqPanel() : buildSayaBar());
5167    var searchRow = document.createElement("div");
5168    searchRow.className = "schedToolbar";
5169    var searchInp = document.createElement("input");
5170    searchInp.type = "search";
5171    searchInp.placeholder = "Cari nama pelayan...";
5172    searchInp.id = "rosterSearch";
5173    searchRow.appendChild(searchInp);
5174    body.appendChild(searchRow);
5175    if (admin) {
5176      var addRow = document.createElement("div");
5177      addRow.className = "schedToolbar";
5178      var inp = document.createElement("input");
5179      inp.placeholder = "Nama pelayan baru";
5180      var addBtn = document.createElement("button");
5181      addBtn.className = "actionBtn";
5182      addBtn.textContent = "Tambah";
5183      addBtn.onclick = function () {
5184        var nm = (inp.value || "").trim();
5185        if (!nm) return;
5186        sched.roster.push({
5187          id: "p-" + Date.now() + "-" + Math.floor(Math.random() * 10000),
5188          name: nm,
5189          roles: [],
5190        });
5191        saveSched();
5192        renderSchedule();
5193      };
5194      addRow.appendChild(inp);
5195      addRow.appendChild(addBtn);
5196      body.appendChild(addRow);
5197    }
5198    var urutRow = document.createElement("div");
5199    urutRow.className = "schedToolbar";
5200    var lbUrut = document.createElement("span");
5201    lbUrut.className = "small";
5202    lbUrut.textContent = "Urutkan:";
5203    var sortSel = document.createElement("select");
5204    [
5205      ["az", "Nama A-Z"],
5206      ["za", "Nama Z-A"],
5207    ].forEach(function (o) {
5208      var op = document.createElement("option");
5209      op.value = o[0];
5210      op.textContent = o[1];
5211      sortSel.appendChild(op);
5212    });
5213    sortSel.value = rosterSort;
5214    sortSel.onchange = function () {
5215      rosterSort = sortSel.value;
5216      drawRoster();
5217    };
5218    var lbRole = document.createElement("span");
5219    lbRole.className = "small";
5220    lbRole.textContent = "Pelayanan:";
5221    var roleSel = document.createElement("select");
5222    var opAll = document.createElement("option");
5223    opAll.value = "";
5224    opAll.textContent = "Semua pelayanan";
5225    roleSel.appendChild(opAll);
5226    allSchedRoles().forEach(function (r) {
5227      var op = document.createElement("option");
5228      op.value = r.key;
5229      op.textContent = schedRoleLabelPenuh(r.key);
5230      roleSel.appendChild(op);
5231    });
5232    roleSel.value = rosterRoleFilter;
5233    roleSel.onchange = function () {
5234      rosterRoleFilter = roleSel.value;
5235      drawRoster();
5236    };
5237    var jml = document.createElement("span");
5238    jml.className = "small";
5239    jml.id = "rosterCount";
5240    urutRow.appendChild(lbUrut);
5241    urutRow.appendChild(sortSel);
5242    urutRow.appendChild(lbRole);
5243    urutRow.appendChild(roleSel);
5244    urutRow.appendChild(jml);
5245    body.appendChild(urutRow);
5246    var listWrap = document.createElement("div");
5247    listWrap.className = "rosterList";
5248    // Daftar diurutkan dari SALINAN roster, bukan mengubah urutan aslinya,
5249    // supaya urutan tampilan tidak ikut tersimpan & tersebar ke pengurus lain.
5250    function rosterUrut() {
5251      var arr = sched.roster.slice();
5252      if (rosterRoleFilter)
5253        arr = arr.filter(function (p) {
5254          return p.roles.indexOf(rosterRoleFilter) >= 0;
5255        });
5256      arr.sort(function (a, b) {
5257        var x = (a.name || "").toLowerCase(),
5258          y = (b.name || "").toLowerCase();
5259        if (x < y) return rosterSort === "za" ? 1 : -1;
5260        if (x > y) return rosterSort === "za" ? -1 : 1;
5261        return 0;
5262      });
5263      return arr;
5264    }
5265    function terapkanCari() {
5266      var q = (searchInp.value || "").trim().toLowerCase();
5267      listWrap.querySelectorAll(".rosterCard").forEach(function (cd) {
5268        var nm = cd.getAttribute("data-name") || "";
5269        cd.style.display = !q || nm.indexOf(q) >= 0 ? "" : "none";
5270      });
5271    }
5272    function gambarKartu(p) {
5273      var card = document.createElement("div");
5274      card.className = "rosterCard";
5275      card.setAttribute("data-name", (p.name || "").toLowerCase());
5276      var head = document.createElement("div");
5277      head.className = "rosterHead";
5278      var nm = document.createElement("b");
5279      nm.textContent = p.name;
5280      head.appendChild(nm);
5281      if (admin) {
5282        var del = document.createElement("button");
5283        del.className = "scBtn danger";
5284        del.textContent = "Hapus";
5285        del.onclick = function () {
5286          if (confirm("Hapus " + p.name + "?")) {
5287            sched.roster = sched.roster.filter(function (x) {
5288              return x.id !== p.id;
5289            });
5290            delete sched.izin[p.id];
5291            saveSched();
5292            renderSchedule();
5293          }
5294        };
5295        head.appendChild(del);
5296      }
5297      card.appendChild(head);
5298      var rolesWrap = document.createElement("div");
5299      rolesWrap.className = "roleChecks";
5300      allSchedRoles().forEach(function (role) {
5301        var lab = document.createElement("label");
5302        lab.className = "roleCheck";
5303        var cb = document.createElement("input");
5304        cb.type = "checkbox";
5305        cb.setAttribute("data-rk", role.key);
5306        var akuSendiri = !admin && p.id === sayaPid();
5307        // Kalau sudah ada permintaan menunggu, yang ditampilkan ke member
5308        // adalah usulannya sendiri - bukan roster resmi - supaya dia tahu
5309        // apa yang sedang ditunggu.
5310        var reqSaya = akuSendiri ? roleReqOf(p.id) : null;
5311        var dasar = reqSaya && reqSaya.roles ? reqSaya.roles : p.roles;
5312        cb.checked = dasar.indexOf(role.key) >= 0;
5313        cb.disabled = !admin && !akuSendiri;
5314        cb.onchange = function () {
5315          if (!admin) {
5316            // Member: centangan TIDAK menyentuh roster sama sekali.
5317            // Hanya menyalakan tombol kirim kalau memang ada bedanya.
5318            var kartu = lab.parentElement && lab.parentElement.parentElement;
5319            if (!kartu) return;
5320            var kini = reqAmbilCentang(kartu).slice().sort().join(",");
5321            var asli = (kartu.getAttribute("data-asli") || "")
5322              .split(",")
5323              .filter(function (x) {
5324                return x;
5325              })
5326              .sort()
5327              .join(",");
5328            var b = kartu.querySelector(".reqSend");
5329            if (b) b.disabled = kini === asli;
5330            return;
5331          }
5332          // v3.1 - ambil ulang dari sched.roster. Objek `p` bisa jadi
5333          // salinan usang bila sempat ada pembaruan dari cloud.
5334          var pv = personById(p.id) || p;
5335          if (cb.checked) {
5336            if (pv.roles.indexOf(role.key) < 0) pv.roles.push(role.key);
5337          } else {
5338            pv.roles = pv.roles.filter(function (k) {
5339              return k !== role.key;
5340            });
5341          }
5342          p.roles = pv.roles;
5343          saveSchedRoster();
5344        };
5345        lab.appendChild(cb);
5346        lab.appendChild(
5347          document.createTextNode(" " + schedRoleLabelPenuh(role.key)),
5348        );
5349        rolesWrap.appendChild(lab);
5350      });
5351      card.appendChild(rolesWrap);
5352      if (!admin && p.id === sayaPid()) {
5353        card.classList.add("sayaCard");
5354        card.setAttribute(
5355          "data-asli",
5356          (p.roles || []).slice().sort().join(","),
5357        );
5358        var tag = document.createElement("span");
5359        tag.className = "sayaTag";
5360        tag.textContent = "Kartu saya";
5361        head.appendChild(tag);
5362        card.appendChild(buildKirimReq(p, card));
5363      }
5364      listWrap.appendChild(card);
5365    }
5366    function drawRoster() {
5367      listWrap.innerHTML = "";
5368      var arr = rosterUrut();
5369      if (!arr.length) {
5370        var kos = document.createElement("p");
5371        kos.className = "small";
5372        kos.textContent = "Tidak ada pelayan yang cocok.";
5373        listWrap.appendChild(kos);
5374      }
5375      arr.forEach(gambarKartu);
5376      var jc = document.getElementById("rosterCount");
5377      if (jc) jc.textContent = arr.length + " pelayan";
5378      terapkanCari();
5379    }
5380    drawRoster();
5381    body.appendChild(listWrap);
5382    searchInp.addEventListener("input", function () {
5383      var q = (searchInp.value || "").trim().toLowerCase();
5384      listWrap.querySelectorAll(".rosterCard").forEach(function (cd) {
5385        var nm = cd.getAttribute("data-name") || "";
5386        cd.style.display = !q || nm.indexOf(q) >= 0 ? "" : "none";
5387      });
5388    });
5389  }
5390  function renderSchedIzin(body, admin) {
5391    var info = document.createElement("p");
5392    info.className = "small";
5393    info.textContent =
5394      "Centang minggu di mana pelayan IZIN / tidak bisa melayani. Nama yang izin otomatis merah dan tidak bisa dijadwalkan (kecuali admin override dengan alasan).";
5395    body.appendChild(info);
5396    var wrap = document.createElement("div");
5397    wrap.className = "schedScroll";
5398    var table = document.createElement("table");
5399    table.className = "schedGrid";
5400    var thead = document.createElement("thead");
5401    var htr = document.createElement("tr");
5402    var c0 = document.createElement("th");
5403    c0.textContent = "Pelayan";
5404    htr.appendChild(c0);
5405    for (var w = 1; w <= sched.weeks; w++) {
5406      var th = document.createElement("th");
5407      th.textContent = "M" + w;
5408      htr.appendChild(th);
5409    }
5410    thead.appendChild(htr);
5411    table.appendChild(thead);
5412    var tb = document.createElement("tbody");
5413    sched.roster.forEach(function (p) {
5414      var tr = document.createElement("tr");
5415      var nmc = document.createElement("td");
5416      nmc.className = "roleCell";
5417      nmc.textContent = p.name;
5418      tr.appendChild(nmc);
5419      for (var w2 = 1; w2 <= sched.weeks; w2++) {
5420        (function (wk) {
5421          var td = document.createElement("td");
5422          td.style.textAlign = "center";
5423          var cb = document.createElement("input");
5424          cb.type = "checkbox";
5425          cb.checked = isIzin(p.id, wk);
5426          cb.disabled = !admin;
5427          cb.onchange = function () {
5428            if (!sched.izin[p.id]) sched.izin[p.id] = [];
5429            if (cb.checked) {
5430              if (sched.izin[p.id].indexOf(wk) < 0) sched.izin[p.id].push(wk);
5431            } else {
5432              sched.izin[p.id] = sched.izin[p.id].filter(function (x) {
5433                return x !== wk;
5434              });
5435            }
5436            saveSched(true);
5437          };
5438          td.appendChild(cb);
5439          tr.appendChild(td);
5440        })(w2);
5441      }
5442      tb.appendChild(tr);
5443    });
5444    table.appendChild(tb);
5445    wrap.appendChild(table);
5446    body.appendChild(wrap);
5447  }
5448  function saveSongs() {
5449    songs.forEach(mirrorToBank);
5450    saveBank();
5451    songsMarkHadData();
5452    if (
5453      cloudReady &&
5454      dbRef &&
5455      !applyingRemote &&
5456      guardPush(songs, SONGS_HWM_KEY, "Daftar lagu")
5457    ) {
5458      try {
5459        dbRef.set(songs);
5460      } catch (e) {}
5461    } else {
5462      try {
5463        localStorage.setItem(storageKey, JSON.stringify(songs));
5464      } catch (e) {}
5465    }
5466    const sp = document.getElementById("savePill");
5467    if (sp) {
5468      sp.style.display = "";
5469      sp.textContent = "Tersimpan";
5470    }
5471  }
5472  function mod(n, m) {
5473    return ((n % m) + m) % m;
5474  }
5475  function currentSong() {
5476    return songs.find((s) => s.id === selectedSongId) || songs[0];
5477  }
5478  function namesForKey(k) {
5479    return sharpKeys.has(k) ? sharpNames : flatNames;
5480  }
5481  function leadingNote(s) {
5482    if (!s) return "";
5483    const c = s[0].toUpperCase();
5484    if (!"ABCDEFG".includes(c)) return "";
5485    if (s.length >= 2 && (s[1] === "#" || s[1] === "b")) return c + s[1];
5486    return c;
5487  }
5488  function transposeRoot(chord, shift, targetKey) {
5489    const root = leadingNote(chord);
5490    if (!root || noteToIndex[root] === undefined) return chord;
5491    const idx = mod(noteToIndex[root] + shift, 12);
5492    if (numberMode) {
5493      const deg = mod(idx - (noteToIndex[targetKey] ?? 0), 12);
5494      return degreeNames[deg] + chord.slice(root.length);
5495    }
5496    return namesForKey(targetKey)[idx] + chord.slice(root.length);
5497  }
5498  function transposeToken(token, shift, targetKey) {
5499    let prefix = "",
5500      suffix = "",
5501      core = token;
5502    while (core && "|()[]{}".includes(core[0])) {
5503      prefix += core[0];
5504      core = core.slice(1);
5505    }
5506    while (core && "|,.;:)]}".includes(core[core.length - 1])) {
5507      suffix = core[core.length - 1] + suffix;
5508      core = core.slice(0, -1);
5509    }
5510    if (!core || core === "." || /^[\u2013\u2014-]+$/.test(core)) return token;
5511    if (core.includes("/")) {
5512      const parts = core.split("/");
5513      core =
5514        parts.length === 2
5515          ? transposeRoot(parts[0], shift, targetKey) +
5516            "/" +
5517            transposeRoot(parts[1], shift, targetKey)
5518          : transposeRoot(core, shift, targetKey);
5519    } else {
5520      core = transposeRoot(core, shift, targetKey);
5521    }
5522    return prefix + core + suffix;
5523  }
5524  function transposeLine(line, shift, targetKey) {
5525    return line
5526      .split(/(\s+)/)
5527      .map((part) =>
5528        /^\s+$/.test(part) ? part : transposeToken(part, shift, targetKey),
5529      )
5530      .join("");
5531  }
5532  /* Klasifikasi kualitas chord: "maj" atau "min". Mengenali semua jenis
5533   * chord (Bb, sus, dim, aug, maj7, add9, dst) -> mayor biru, minor merah. */
5534  function chordQuality(token) {
5535    var t = String(token == null ? "" : token);
5536    t = t.replace(/^[|([{]+/, "").replace(/[|,.;:)\]}]+$/, "");
5537    if (!t || t === ".") return "";
5538    var root = leadingNote(t);
5539    if (!root) return "";
5540    var rest = t.slice(root.length);
5541    var slash = rest.indexOf("/");
5542    if (slash >= 0) rest = rest.slice(0, slash);
5543    var r = rest.toLowerCase();
5544    if (r === "" || r.indexOf("maj") === 0) return "maj";
5545    if (r.charAt(0) === "m") return "min";
5546    if (r.indexOf("dim") === 0) return "min";
5547    return "maj";
5548  }
5549  function transposeChordLine(line, shift, targetKey) {
5550    var re = /\S+/g,
5551      m,
5552      toks = [];
5553    while ((m = re.exec(line)) !== null) {
5554      toks.push({
5555        start: m.index,
5556        text: transposeToken(m[0], shift, targetKey),
5557      });
5558    }
5559    var out = "";
5560    for (var i = 0; i < toks.length; i++) {
5561      if (out.length < toks[i].start) {
5562        out += " ".repeat(toks[i].start - out.length);
5563      } else if (out.length > 0) {
5564        out += " ";
5565      }
5566      out += toks[i].text;
5567    }
5568    return out;
5569  }
5570  // ---- v57: Rapikan otomatis (lyric formatter + chord finder) ----
5571  var AF_SEC_MAP = [
5572    [/^intro\b/, "Intro"],
5573    [/^(pre[\s\-]?chorus|pre[\s\-]?reff?|prechorus)\b/, "Pre-Chorus"],
5574    [/^(reffrain|refrain|reff|ref|chorus|korus)\b/, "Chorus"],
5575    [/^(bait|verse|ayat)\b/, "Verse"],
5576    [/^bridge\b/, "Bridge"],
5577    [/^(interlude|inter)\b/, "Interlude"],
5578    [/^(instrumental|instrumen|musik|music|solo)\b/, "Instrumental"],
5579    [/^(outro|outtro|ending|penutup|coda)\b/, "Outro"],
5580  ];
5581  function afCleanLabel(line) {
5582    return String(line)
5583      .replace(/[\[\]\(\)\{\}\*_#>]/g, " ")
5584      .replace(/[:\-\u2013\u2014.]+\s*$/, " ")
5585      .replace(/\s+/g, " ")
5586      .trim();
5587  }
5588  function afSectionLabel(line) {
5589    var c = afCleanLabel(line);
5590    if (!c || c.length > 26) return null;
5591    if (c.split(" ").length > 3) return null;
5592    var low = c.toLowerCase();
5593    for (var i = 0; i < AF_SEC_MAP.length; i++) {
5594      if (AF_SEC_MAP[i][0].test(low)) {
5595        var base = AF_SEC_MAP[i][1];
5596        var num = low.match(/(\d+)/);
5597        if (num && base !== "Pre-Chorus") return base + " " + num[1];
5598        return base;
5599      }
5600    }
5601    return null;
5602  }
5603  function afIsJunk(line) {
5604    var t = line.trim();
5605    if (!t) return false;
5606    if (/^[\-=_~*\u2022\u00b7\u2014\u2013\s]+$/.test(t)) return true;
5607    return /(chords?\s*(by|:)|kunci\s*gitar|lirik\s*lagu|copyright|all\s+rights|\u00a9|www\.|https?:\/\/|baca\s*juga|terpopuler|iklan|advertis|klik\s*di\s*sini|selengkapnya|share\s*this)/i.test(
5608      t,
5609    );
5610  }
5611  function afChordsIn(line) {
5612    var out = [];
5613    var inl = line.match(/\[([^\]]+)\]/g);
5614    if (inl) {
5615      for (var i = 0; i < inl.length; i++) {
5616        var v = inl[i].slice(1, -1).trim();
5617        if (v) out.push(v);
5618      }
5619      return out;
5620    }
5621    if (isChordLine(line)) {
5622      var m = line.match(/\S+/g) || [];
5623      for (var j = 0; j < m.length; j++) out.push(m[j]);
5624    }
5625    return out;
5626  }
5627  function afRootOf(chord) {
5628    var m = String(chord).match(/^([A-G])(#|b)?/);
5629    if (!m) return null;
5630    var n = m[1] + (m[2] || "");
5631    return noteToIndex[n] === undefined ? null : noteToIndex[n];
5632  }
5633  function afIsMinor(chord) {
5634    return /^[A-G](#|b)?m(?!aj)/.test(String(chord));
5635  }
5636  function afDetectKey(chords) {
5637    if (!chords || !chords.length) return "";
5638    var deg = [0, 2, 4, 5, 7, 9, 11];
5639    var isMinDeg = [0, 1, 1, 0, 0, 1, 2];
5640    var best = "",
5641      bestScore = -1;
5642    for (var k = 0; k < keyList.length; k++) {
5643      var root = noteToIndex[keyList[k]];
5644      var score = 0;
5645      for (var i = 0; i < chords.length; i++) {
5646        var r = afRootOf(chords[i]);
5647        if (r === null) continue;
5648        var w = i === 0 || i === chords.length - 1 ? 2 : 1;
5649        for (var d = 0; d < 7; d++) {
5650          if ((root + deg[d]) % 12 === r) {
5651            var mn = afIsMinor(chords[i]);
5652            if (isMinDeg[d] === 2) score += 0.5 * w;
5653            else if ((isMinDeg[d] === 1) === mn) score += 1 * w;
5654            else score += 0.3 * w;
5655            if (d === 0 && w === 2 && !mn) score += 1.5;
5656            break;
5657          }
5658        }
5659      }
5660      if (score > bestScore) {
5661        bestScore = score;
5662        best = keyList[k];
5663      }
5664    }
5665    return best;
5666  }
5667  function afSignature(lines) {
5668    var out = [];
5669    for (var i = 0; i < lines.length; i++) {
5670      var l = lines[i];
5671      if (!l.trim() || isChordLine(l)) continue;
5672      var t = l
5673        .replace(/\[[^\]]*\]/g, "")
5674        .toLowerCase()
5675        .replace(/[^a-z0-9\s]/g, "")
5676        .replace(/\s+/g, " ")
5677        .trim();
5678      if (t) out.push(t);
5679    }
5680    return out.join(" | ");
5681  }
5682  function afAllChordLines(b) {
5683    for (var i = 0; i < b.lines.length; i++) {
5684      var l = b.lines[i];
5685      if (l.trim() && !isChordLine(l)) return false;
5686    }
5687    return true;
5688  }
5689  function afInferLabels(blocks) {
5690    var sigs = [],
5691      count = {};
5692    for (var i = 0; i < blocks.length; i++) {
5693      sigs.push(afSignature(blocks[i].lines));
5694      if (sigs[i]) count[sigs[i]] = (count[sigs[i]] || 0) + 1;
5695    }
5696    var chorusSig = "",
5697      bestN = 1;
5698    for (var s in count) {
5699      if (
5700        count[s] >= 2 &&
5701        (count[s] > bestN ||
5702          (count[s] === bestN && s.length > chorusSig.length))
5703      ) {
5704        bestN = count[s];
5705        chorusSig = s;
5706      }
5707    }
5708    var vn = 0,
5709      cn = 0,
5710      seenChorus = false,
5711      usedBridge = false;
5712    for (var j = 0; j < blocks.length; j++) {
5713      var b = blocks[j];
5714      if (!sigs[j] && afAllChordLines(b)) {
5715        b.label =
5716          j === 0 ? "Intro" : j === blocks.length - 1 ? "Outro" : "Interlude";
5717        continue;
5718      }
5719      if (chorusSig && sigs[j] === chorusSig) {
5720        cn++;
5721        b.label = cn > 1 ? "Chorus " + cn : "Chorus";
5722        seenChorus = true;
5723        continue;
5724      }
5725      if (
5726        seenChorus &&
5727        !usedBridge &&
5728        count[sigs[j]] === 1 &&
5729        j >= blocks.length - 2
5730      ) {
5731        usedBridge = true;
5732        b.label = "Bridge";
5733        continue;
5734      }
5735      vn++;
5736      b.label = "Verse " + vn;
5737    }
5738  }
5739  function autoFormatLyrics(raw) {
5740    var txt = String(raw || "")
5741      .replace(/\r\n?/g, "\n")
5742      .replace(/\u00a0/g, " ")
5743      .replace(/[\u200b-\u200d\ufeff]/g, "")
5744      .replace(/\t/g, "    ");
5745    var src = txt.split("\n"),
5746      lines = [];
5747    for (var i = 0; i < src.length; i++) {
5748      var l = src[i].replace(/\s+$/, "");
5749      if (afIsJunk(l)) continue;
5750      lines.push(l);
5751    }
5752    var blocks = [],
5753      cur = null,
5754      hadLabel = false;
5755    for (var j = 0; j < lines.length; j++) {
5756      var ln = lines[j],
5757        lbl = null;
5758      if (ln.trim() && !isChordLine(ln)) lbl = afSectionLabel(ln);
5759      if (lbl) {
5760        hadLabel = true;
5761        cur = { label: lbl, lines: [] };
5762        blocks.push(cur);
5763        continue;
5764      }
5765      if (!ln.trim()) {
5766        if (!hadLabel) cur = null;
5767        else if (cur) cur.lines.push("");
5768        continue;
5769      }
5770      if (!cur) {
5771        cur = { label: null, lines: [] };
5772        blocks.push(cur);
5773      }
5774      cur.lines.push(ln);
5775    }
5776    for (var b = 0; b < blocks.length; b++) {
5777      var L = blocks[b].lines;
5778      while (L.length && !L[0].trim()) L.shift();
5779      while (L.length && !L[L.length - 1].trim()) L.pop();
5780    }
5781    var kept = [];
5782    for (var c = 0; c < blocks.length; c++)
5783      if (blocks[c].lines.length) kept.push(blocks[c]);
5784    blocks = kept;
5785    if (!blocks.length) return null;
5786    if (!hadLabel) afInferLabels(blocks);
5787    else
5788      for (var d = 0; d < blocks.length; d++)
5789        if (!blocks[d].label)
5790          blocks[d].label = afAllChordLines(blocks[d]) ? "Intro" : "Verse 1";
5791    var outLines = [],
5792      allChords = [];
5793    for (var e = 0; e < blocks.length; e++) {
5794      if (e) outLines.push("");
5795      outLines.push(blocks[e].label);
5796      var BL = blocks[e].lines,
5797        prevBlank = false;
5798      for (var f = 0; f < BL.length; f++) {
5799        var ll = BL[f];
5800        if (!ll.trim()) {
5801          if (!prevBlank) {
5802            outLines.push("");
5803            prevBlank = true;
5804          }
5805          continue;
5806        }
5807        prevBlank = false;
5808        outLines.push(ll);
5809        var cc = afChordsIn(ll);
5810        for (var g = 0; g < cc.length; g++) allChords.push(cc[g]);
5811      }
5812    }
5813    var secCount = {};
5814    for (var h = 0; h < blocks.length; h++) {
5815      var bn = blocks[h].label.replace(/\s*\d+$/, "");
5816      secCount[bn] = (secCount[bn] || 0) + 1;
5817    }
5818    return {
5819      text: outLines.join("\n"),
5820      blocks: blocks.length,
5821      chords: allChords.length,
5822      key: afDetectKey(allChords),
5823      secCount: secCount,
5824    };
5825  }
5826  var afPrevText = null;
5827  function afUndo() {
5828    if (afPrevText === null) return;
5829    var ta = document.getElementById("editLines");
5830    if (ta) ta.value = afPrevText;
5831    afPrevText = null;
5832    var bar = document.getElementById("afBar");
5833    if (bar) bar.hidden = true;
5834    if (structMode) renderCanvas();
5835    updateEditPreview();
5836    toast("Dikembalikan ke teks semula", "info");
5837  }
5838  function runAutoFormat() {
5839    var ta = document.getElementById("editLines");
5840    if (!ta) return;
5841    try {
5842      replayIn(document.getElementById("autoFormatBtn"));
5843    } catch (e) {}
5844    if (structMode) serializeCanvas();
5845    var raw = ta.value || "";
5846    if (!raw.trim()) {
5847      toast("Tempel lirik dulu di kotak Chord & lirik", "error");
5848      return;
5849    }
5850    var res = autoFormatLyrics(raw);
5851    if (!res) {
5852      toast("Tidak ada lirik yang bisa dirapikan", "error");
5853      return;
5854    }
5855    afPrevText = raw;
5856    ta.value = res.text;
5857    var keyEl = document.getElementById("editOriginalKey"),
5858      keySet = false;
5859    if (keyEl && res.key && !keyEl.value.trim()) {
5860      keyEl.value = res.key;
5861      keySet = true;
5862    }
5863    var parts = [];
5864    for (var k in res.secCount) parts.push(res.secCount[k] + " " + k);
5865    var msg = "Terdeteksi: " + parts.join(" \u00b7 ");
5866    if (res.chords)
5867      msg +=
5868        " \u00b7 " +
5869        res.chords +
5870        " chord" +
5871        (res.key ? " \u00b7 nada dasar " + res.key : "");
5872    else msg += " \u00b7 tanpa chord (tempel dari situs chord agar terdeteksi)";
5873    if (keySet) msg += " \u2014 nada dasar diisi otomatis";
5874    var bar = document.getElementById("afBar"),
5875      mEl = document.getElementById("afMsg");
5876    if (bar && mEl) {
5877      mEl.textContent = msg;
5878      bar.hidden = false;
5879    }
5880    setStructMode(true);
5881    updateEditPreview();
5882    toast("Lirik dirapikan jadi " + res.blocks + " bagian", "info");
5883  }
5884  function formatChordSpacing(line) {
5885    return line.trim().split(/\s+/).join("      ");
5886  }
5887  function isChordLine(line) {
5888    const t = line.trim();
5889    if (!t) return false;
5890    if (sectionWords.test(t)) return false;
5891    const tokens = t.split(/\s+/);
5892    let chordLike = 0;
5893    for (const tok of tokens) {
5894      const c = tok.replace(/^[|([{]+|[|,.;:)\]}]+$/g, "");
5895      if (c === "." || c === "") continue;
5896      if (/^[A-G](#|b)?(m|maj|min|dim|aug|sus|add|\d|\/|\(|\)|\+|-)*$/i.test(c))
5897        chordLike++;
5898      else return false;
5899    }
5900    return chordLike > 0;
5901  }
5902  // --- Membuat tombol lagu & nada, lalu menggambar lembar chord ---
5903  function makeButtons() {
5904    songButtons.innerHTML = "";
5905    songs.forEach((song, idx) => {
5906      song.num = idx + 1;
5907      const row = document.createElement("div");
5908      row.className = "songRow";
5909      row.dataset.id = song.id;
5910      const handle = document.createElement("span");
5911      handle.className = "dragHandle";
5912      handle.textContent = "⋮";
5913      handle.title = "Tahan lalu geser untuk mengubah urutan";
5914      const b = document.createElement("button");
5915      b.type = "button";
5916      b.className = "songBtn";
5917      b.dataset.id = song.id;
5918      b.textContent = song.num + ". " + song.title;
5919      b.onclick = () => {
5920        if (selectedSongId !== song.id) stopYt();
5921        selectedSongId = song.id;
5922        selectedKey = song.originalKey;
5923        try {
5924          if (window.__pnwSemiUi)
5925            window.__pnwSemiUi(song.originalKey, song.originalKey);
5926        } catch (e) {}
5927        closeEditor();
5928        render();
5929      };
5930      row.appendChild(handle);
5931      row.appendChild(b);
5932      enableRowDrag(handle, row);
5933      songButtons.appendChild(row);
5934    });
5935    keyButtons.innerHTML = "";
5936    keyList.forEach((key) => {
5937      const b = document.createElement("button");
5938      b.type = "button";
5939      b.className = "keyBtn";
5940      b.dataset.key = key;
5941      b.textContent = key;
5942      b.onclick = () => {
5943        selectedKey = key;
5944        render();
5945      };
5946      keyButtons.appendChild(b);
5947    });
5948    refreshLibrary();
5949    try {
5950      initV62();
5951    } catch (e) {}
5952    try {
5953      if (window.PNWRec) window.PNWRec.decorate();
5954    } catch (e) {}
5955  }
5956  function hasInlineChords(line) {
5957    return /\[[^\]]*\]/.test(line);
5958  }
5959  function parseInline(line) {
5960    var out = [];
5961    line.split(/(\[[^\]]*\])/).forEach(function (tk) {
5962      if (!tk) return;
5963      var mm = tk.match(/^\[([^\]]*)\]$/);
5964      if (mm) {
5965        out.push({ chord: mm[1].trim(), text: "" });
5966      } else if (
5967        out.length &&
5968        out[out.length - 1].chord !== "" &&
5969        out[out.length - 1].text === ""
5970      ) {
5971        out[out.length - 1].text = tk;
5972      } else {
5973        out.push({ chord: "", text: tk });
5974      }
5975    });
5976    return out;
5977  }
5978  function parseSecMod(line) {
5979    var off = 0;
5980    var clean = line.replace(/\(([+-]\d{1,2})\)/, function (m, g) {
5981      off = parseInt(g, 10) || 0;
5982      return "";
5983    });
5984    clean = clean.replace(/\s+$/, "");
5985    return { offset: off, clean: clean };
5986  }
5987  function secClass(line) {
5988    var t = line.trim().toLowerCase();
5989    if (/^intro/.test(t)) return "sec-intro";
5990    if (/^pre-?chorus/.test(t)) return "sec-pre";
5991    if (/^(reff|refrain|chorus)/.test(t)) return "sec-chorus";
5992    if (/^post-?chorus/.test(t)) return "sec-post";
5993    if (/^(bait|verse)/.test(t)) return "sec-verse";
5994    if (/^bridge/.test(t)) return "sec-bridge";
5995    if (/^(instrumen|musik|solo)/.test(t)) return "sec-inst";
5996    if (/^(interlude|transition|transisi)/.test(t)) return "sec-inter";
5997    if (/^breakdown/.test(t)) return "sec-break";
5998    if (/^(modulation|key ?change|overtune)/.test(t)) return "sec-mod";
5999    if (/^(ending|outro|outtro|coda)/.test(t)) return "sec-outro";
6000    return "sec-none";
6001  }
6002  function renderLineEl(line, shift, target) {
6003    var div = document.createElement("div");
6004    if (hasInlineChords(line)) {
6005      div.className = "line inlineChord";
6006      parseInline(line).forEach(function (sg) {
6007        var col = document.createElement("span");
6008        col.className = "ic";
6009        var ch = document.createElement("span");
6010        var _cq = chordQuality(sg.chord || "");
6011        ch.className = "ic-ch" + (_cq ? " chord-" + _cq : "");
6012        ch.textContent = sg.chord
6013          ? transposeToken(sg.chord, shift, target)
6014          : "";
6015        var tx = document.createElement("span");
6016        tx.className = "ic-tx";
6017        tx.textContent = sg.text;
6018        col.appendChild(ch);
6019        col.appendChild(tx);
6020        div.appendChild(col);
6021      });
6022    } else if (isChordLine(line)) {
6023      div.className = "line chord";
6024      var _re = /\S+/g,
6025        _m,
6026        _last = 0;
6027      while ((_m = _re.exec(line)) !== null) {
6028        if (_m.index > _last)
6029          div.appendChild(document.createTextNode(line.slice(_last, _m.index)));
6030        var _sp = document.createElement("span");
6031        var _q = chordQuality(_m[0]);
6032        _sp.className = "chTok" + (_q ? " chord-" + _q : "");
6033        _sp.textContent = transposeToken(_m[0], shift, target);
6034        div.appendChild(_sp);
6035        _last = _m.index + _m[0].length;
6036      }
6037      if (_last < line.length)
6038        div.appendChild(document.createTextNode(line.slice(_last)));
6039    } else if (sectionWords.test(line.trim())) {
6040      div.className = "line section";
6041      var pm = parseSecMod(line);
6042      div.textContent = pm.clean;
6043      if (pm.offset) {
6044        var mb = document.createElement("span");
6045        mb.className = "modBadge";
6046        mb.textContent = " (" + (pm.offset > 0 ? "+" : "") + pm.offset + ")";
6047        div.appendChild(mb);
6048      }
6049    } else {
6050      div.className = line.trim() ? "line" : "line blankLine";
6051      div.textContent = line;
6052    }
6053    return div;
6054  }
6055  function renderLinesInto(container, lines, shift, target) {
6056    container.innerHTML = "";
6057    var wrap = document.createElement("div");
6058    wrap.className = "secBlock sec-none";
6059    container.appendChild(wrap);
6060    var secMod = 0;
6061    lines.forEach(function (raw) {
6062      var ln = raw || "";
6063      if (sectionWords.test(ln.trim())) {
6064        var pm = parseSecMod(ln);
6065        secMod = pm.offset;
6066        wrap = document.createElement("div");
6067        wrap.className = "secBlock " + secClass(ln);
6068        container.appendChild(wrap);
6069        wrap.appendChild(renderLineEl(ln, shift, target));
6070      } else {
6071        wrap.appendChild(renderLineEl(ln, shift + secMod, target));
6072      }
6073    });
6074  }
6075  function updateEditPreview() {
6076    var body = document.getElementById("editPreviewBody");
6077    if (!body) return;
6078    var key = (document.getElementById("editOriginalKey").value || "C").trim();
6079    var target = noteToIndex[key] === undefined ? "C" : key;
6080    var text = document.getElementById("editLines").value || "";
6081    renderLinesInto(body, text.replace(/\r/g, "").split("\n"), 0, target);
6082  }
6083  function render() {
6084    if (!songs.length) {
6085      content.textContent = "Belum ada lagu.";
6086      return;
6087    }
6088    const song = currentSong();
6089    if (!song) return;
6090    const target = selectedKey || song.originalKey;
6091    const shift =
6092      (noteToIndex[target] ?? 0) - (noteToIndex[song.originalKey] ?? 0);
6093    document
6094      .querySelectorAll(".songBtn")
6095      .forEach((b) => b.classList.toggle("active", b.dataset.id === song.id));
6096    document
6097      .querySelectorAll(".keyBtn")
6098      .forEach((b) => b.classList.toggle("active", b.dataset.key === target));
6099    document.getElementById("currentSongPill").textContent =
6100      (song.num || "") + ". " + song.title;
6101    document.getElementById("currentKeyPill").textContent =
6102      target === song.originalKey
6103        ? "Nada " + target + " (asli)"
6104        : "Nada " + target + " (asli " + song.originalKey + ")";
6105    try {
6106      if (window.__pnwSemiUi) window.__pnwSemiUi(target, song.originalKey);
6107    } catch (e) {}
6108    document.getElementById("songTitle").textContent = song.title;
6109    document.getElementById("keyLine").textContent =
6110      "Nada asli " + song.originalKey + " \u2192 ditampilkan di " + target;
6111    document.getElementById("sourceLine").textContent = song.source
6112      ? "Keterangan: " + song.source
6113      : "";
6114    renderLinesInto(content, song.lines || [], shift, target);
6115    try {
6116      if (window.PNWRec && window.PNWRec.mountDock)
6117        window.PNWRec.mountDock(song.id, song.title);
6118    } catch (e) {}
6119    if (typeof broadcastLive === "function") broadcastLive();
6120  }
6121  // --- Editor lagu: ubah, tambah, duplikat, dan hapus ---
6122  function openEditor() {
6123    const song = currentSong();
6124    document.getElementById("editTitle").value = song.title;
6125    document.getElementById("editOriginalKey").value = song.originalKey;
6126    document.getElementById("editSource").value = song.source || "";
6127    document.getElementById("editYoutube").value = song.youtube || "";
6128    document.getElementById("editBg").value = song.bg || "";
6129    document.getElementById("editLines").value = (song.lines || []).join("\n");
6130    document.getElementById("editor").classList.add("open");
6131    document
6132      .getElementById("editor")
6133      .scrollIntoView({ behavior: "smooth", block: "start" });
6134    setStructMode(false);
6135    updateEditPreview();
6136  }
6137  function closeEditor() {
6138    document.getElementById("editor").classList.remove("open");
6139  }
6140  var SEC_TYPES = [
6141    {
6142      key: "Intro",
6143      desc: "Pembuka lagu; biasanya instrumen atau melodi utama.",
6144    },
6145    {
6146      key: "Verse",
6147      desc: "Menceritakan isi/alur lagu; lirik tiap verse berbeda.",
6148    },
6149    {
6150      key: "Pre-Chorus",
6151      desc: "Jembatan verse ke chorus; membangun ketegangan.",
6152    },
6153    {
6154      key: "Chorus",
6155      desc: "Bagian paling mudah diingat; sering diulang.",
6156    },
6157    {
6158      key: "Post-Chorus",
6159      desc: "Setelah chorus; hook tambahan / instrumental singkat.",
6160    },
6161    {
6162      key: "Bridge",
6163      desc: "Nuansa baru menjelang akhir; chord/melodi berbeda.",
6164    },
6165    {
6166      key: "Instrumental",
6167      desc: "Bagian tanpa vokal; solo gitar/piano/dll.",
6168    },
6169    {
6170      key: "Breakdown",
6171      desc: "Dibuat lebih kosong/pelan sebagai kontras.",
6172    },
6173    {
6174      key: "Interlude",
6175      desc: "Sisipan instrumental pendek antar bagian.",
6176    },
6177    {
6178      key: "Modulation",
6179      desc: "Perubahan nada dasar untuk menaikkan energi.",
6180    },
6181    { key: "Outro", desc: "Penutup lagu; fade out / ending tegas." },
6182  ];
6183  function secDesc(name) {
6184    var t = (name || "").toLowerCase();
6185    for (var i = 0; i < SEC_TYPES.length; i++) {
6186      if (t.indexOf(SEC_TYPES[i].key.toLowerCase()) === 0)
6187        return SEC_TYPES[i].desc;
6188    }
6189    return "";
6190  }
6191  function parseBlocks(text) {
6192    var lines = (text || "").replace(/\r/g, "").split("\n");
6193    var blocks = [];
6194    var cur = { header: "", mod: 0, body: [] };
6195    lines.forEach(function (ln) {
6196      if (sectionWords.test(ln.trim())) {
6197        if (cur.header !== "" || cur.body.length) blocks.push(cur);
6198        var pm = parseSecMod(ln);
6199        cur = { header: pm.clean.trim(), mod: pm.offset, body: [] };
6200      } else {
6201        cur.body.push(ln);
6202      }
6203    });
6204    blocks.push(cur);
6205    if (
6206      blocks.length > 1 &&
6207      blocks[0].header === "" &&
6208      blocks[0].body.join("").trim() === ""
6209    )
6210      blocks.shift();
6211    return blocks;
6212  }
6213  function serializeCanvas() {
6214    var canvas = document.getElementById("structCanvas");
6215    if (!canvas) return;
6216    var out = [];
6217    canvas.querySelectorAll(".structBlock").forEach(function (bl) {
6218      var header = bl.dataset.header || "";
6219      var mod = parseInt(bl.dataset.mod || "0", 10) || 0;
6220      var body = bl.querySelector("textarea").value.replace(/\r/g, "");
6221      if (header)
6222        out.push(header + (mod ? " (" + (mod > 0 ? "+" : "") + mod + ")" : ""));
6223      if (body.length)
6224        body.split("\n").forEach(function (l) {
6225          out.push(l);
6226        });
6227    });
6228    document.getElementById("editLines").value = out.join("\n");
6229    updateEditPreview();
6230  }
6231  var dragBlock = null;
6232  function attachBlockDrag(wrap, handle) {
6233    handle.addEventListener("dragstart", function (e) {
6234      dragBlock = wrap;
6235      wrap.classList.add("dragging");
6236      if (e.dataTransfer) {
6237        e.dataTransfer.effectAllowed = "move";
6238        try {
6239          e.dataTransfer.setData("text/plain", "b");
6240        } catch (x) {}
6241      }
6242    });
6243    handle.addEventListener("dragend", function () {
6244      if (dragBlock) dragBlock.classList.remove("dragging");
6245      dragBlock = null;
6246      serializeCanvas();
6247    });
6248    wrap.addEventListener("dragover", function (e) {
6249      if (!dragBlock || dragBlock === wrap) return;
6250      e.preventDefault();
6251      var canvas = document.getElementById("structCanvas");
6252      var rect = wrap.getBoundingClientRect();
6253      var after = e.clientY - rect.top > rect.height / 2;
6254      canvas.insertBefore(dragBlock, after ? wrap.nextSibling : wrap);
6255    });
6256  }
6257  function setBlockMod(wrap, delta) {
6258    var m = (parseInt(wrap.dataset.mod || "0", 10) || 0) + delta;
6259    if (m > 12) m = 12;
6260    if (m < -12) m = -12;
6261    wrap.dataset.mod = m;
6262    var v = wrap.querySelector(".sbModVal");
6263    if (v) v.textContent = m ? (m > 0 ? "+" + m : "" + m) : "0";
6264    serializeCanvas();
6265  }
6266  function sbCleanName(v) {
6267    return (v || "")
6268      .replace(/[\r\n\t]+/g, " ")
6269      .replace(/\(\s*[+-]\d{1,2}\s*\)/g, "")
6270      .replace(/\s{2,}/g, " ")
6271      .trim();
6272  }
6273  function sbApplyName(wrap, h) {
6274    wrap.dataset.header = h;
6275    var cls = "structBlock";
6276    if (h) cls += " " + secClass(h);
6277    if (wrap.classList.contains("dragging")) cls += " dragging";
6278    wrap.className = cls;
6279    var dp = wrap.querySelector(".sbDesc");
6280    if (dp) {
6281      var d = secDesc(h);
6282      dp.textContent = d;
6283      dp.hidden = !d;
6284    }
6285  }
6286  function sbRename(wrap, inp, isBlur) {
6287    var h = sbCleanName(inp.value);
6288    var ok = !!h && sectionWords.test(h);
6289    inp.classList.toggle("bad", !ok);
6290    if (ok) {
6291      sbApplyName(wrap, h);
6292      serializeCanvas();
6293      return;
6294    }
6295    if (isBlur) {
6296      inp.value = wrap.dataset.header || "";
6297      inp.classList.remove("bad");
6298      toast(
6299        "Nama bagian harus diawali kata baku: Intro, Verse, Chorus, Pre-Chorus, Bridge, Interlude, Instrumental, Outro. Contoh benar: Intro (Drum), Verse 2",
6300        "error",
6301        4800,
6302      );
6303    }
6304  }
6305  function nextSecName(base) {
6306    var canvas = document.getElementById("structCanvas");
6307    if (!canvas) return base;
6308    var b = base.toLowerCase(),
6309      n = 0;
6310    canvas.querySelectorAll(".structBlock").forEach(function (bl) {
6311      var h = (bl.dataset.header || "").toLowerCase();
6312      if (h === b || h.indexOf(b + " ") === 0) n++;
6313    });
6314    return n ? base + " " + (n + 1) : base;
6315  }
6316  function buildBlockEl(header, mod, bodyText) {
6317    var wrap = document.createElement("div");
6318    wrap.className = "structBlock";
6319    if (header) wrap.classList.add(secClass(header));
6320    wrap.dataset.header = header || "";
6321    wrap.dataset.mod = mod || 0;
6322    var head = document.createElement("div");
6323    head.className = "sbHead";
6324    var handle = document.createElement("span");
6325    handle.className = "sbHandle";
6326    handle.textContent = "\u2807";
6327    handle.setAttribute("draggable", "true");
6328    head.appendChild(handle);
6329    var name;
6330    if (header) {
6331      name = document.createElement("input");
6332      name.type = "text";
6333      name.className = "sbName sbNameIn";
6334      name.value = header;
6335      name.spellcheck = false;
6336      name.setAttribute("aria-label", "Nama bagian");
6337      name.title = "Klik untuk ubah nama bagian, mis. Intro (Drum), Verse 2";
6338      name.addEventListener("input", function () {
6339        sbRename(wrap, name, false);
6340      });
6341      name.addEventListener("blur", function () {
6342        sbRename(wrap, name, true);
6343      });
6344      name.addEventListener("keydown", function (e) {
6345        if (e.key === "Enter") {
6346          e.preventDefault();
6347          name.blur();
6348        }
6349      });
6350    } else {
6351      name = document.createElement("span");
6352      name.className = "sbName";
6353      name.textContent = "(pembuka)";
6354    }
6355    head.appendChild(name);
6356    if (header) {
6357      var modWrap = document.createElement("span");
6358      modWrap.className = "sbMod";
6359      var down = document.createElement("button");
6360      down.type = "button";
6361      down.textContent = "\u25BC";
6362      var val = document.createElement("span");
6363      val.className = "sbModVal";
6364      val.textContent = mod ? (mod > 0 ? "+" + mod : "" + mod) : "0";
6365      var up = document.createElement("button");
6366      up.type = "button";
6367      up.textContent = "\u25B2";
6368      down.onclick = function () {
6369        setBlockMod(wrap, -1);
6370      };
6371      up.onclick = function () {
6372        setBlockMod(wrap, 1);
6373      };
6374      modWrap.appendChild(down);
6375      modWrap.appendChild(val);
6376      modWrap.appendChild(up);
6377      head.appendChild(modWrap);
6378    }
6379    var del = document.createElement("button");
6380    del.type = "button";
6381    del.className = "sbDel";
6382    del.textContent = "\u2715";
6383    del.onclick = function () {
6384      wrap.remove();
6385      serializeCanvas();
6386    };
6387    head.appendChild(del);
6388    wrap.appendChild(head);
6389    if (header) {
6390      var dp = document.createElement("p");
6391      dp.className = "sbDesc";
6392      var d = secDesc(header);
6393      dp.textContent = d;
6394      dp.hidden = !d;
6395      wrap.appendChild(dp);
6396    }
6397    var ta = document.createElement("textarea");
6398    ta.value = bodyText || "";
6399    ta.placeholder = "Tulis chord & lirik... mis. [G]Na-ma-[D]Nya";
6400    ta.addEventListener("input", serializeCanvas);
6401    wrap.appendChild(ta);
6402    attachBlockDrag(wrap, handle);
6403    return wrap;
6404  }
6405  function renderCanvas() {
6406    var canvas = document.getElementById("structCanvas");
6407    if (!canvas) return;
6408    canvas.innerHTML = "";
6409    parseBlocks(document.getElementById("editLines").value).forEach(
6410      function (b) {
6411        canvas.appendChild(buildBlockEl(b.header, b.mod, b.body.join("\n")));
6412      },
6413    );
6414  }
6415  function addSection(name) {
6416    var canvas = document.getElementById("structCanvas");
6417    var el = buildBlockEl(nextSecName(name), 0, "");
6418    canvas.appendChild(el);
6419    serializeCanvas();
6420    el.scrollIntoView({ block: "nearest" });
6421    var ta = el.querySelector("textarea");
6422    if (ta) ta.focus();
6423  }
6424  function buildPalette() {
6425    var pal = document.getElementById("structPalette");
6426    if (!pal) return;
6427    pal.innerHTML = "";
6428    SEC_TYPES.forEach(function (s) {
6429      var c = document.createElement("span");
6430      c.className = "palChip " + secClass(s.key);
6431      c.textContent = s.key;
6432      c.title = s.desc;
6433      c.setAttribute("draggable", "true");
6434      c.onclick = function () {
6435        addSection(s.key);
6436      };
6437      c.addEventListener("dragstart", function (e) {
6438        if (e.dataTransfer) {
6439          try {
6440            e.dataTransfer.setData("text/plain", "pal:" + s.key);
6441          } catch (x) {}
6442        }
6443      });
6444      pal.appendChild(c);
6445    });
6446    var canvas = document.getElementById("structCanvas");
6447    canvas.addEventListener("dragover", function (e) {
6448      e.preventDefault();
6449    });
6450    canvas.addEventListener("drop", function (e) {
6451      var d = "";
6452      try {
6453        d = e.dataTransfer.getData("text/plain");
6454      } catch (x) {}
6455      if (d && d.indexOf("pal:") === 0) {
6456        e.preventDefault();
6457        addSection(d.slice(4));
6458      }
6459    });
6460  }
6461  var structMode = false;
6462  function setStructMode(on) {
6463    structMode = !!on;
6464    var wrap = document.getElementById("structWrap");
6465    var ta = document.getElementById("editLines");
6466    var hint = document.getElementById("editHint");
6467    var btn = document.getElementById("structToggleBtn");
6468    var sug = document.getElementById("chordSuggest");
6469    if (structMode) renderCanvas();
6470    if (wrap) wrap.hidden = !structMode;
6471    if (ta) ta.style.display = structMode ? "none" : "";
6472    if (hint) hint.style.display = structMode ? "none" : "";
6473    if (sug && structMode) sug.hidden = true;
6474    if (btn) {
6475      btn.classList.toggle("active", structMode);
6476      var lbl = document.getElementById("structBtnLbl");
6477      if (lbl) lbl.textContent = structMode ? "Mode teks" : "Mode struktur";
6478      else
6479        btn.innerHTML =
6480          '<span class="lottieIco btnIco" data-anim="struktur"></span>' +
6481          (structMode ? "Mode teks" : "Mode struktur");
6482      try {
6483        initLottieIcons();
6484        replayIn(btn);
6485      } catch (e) {}
6486    }
6487  }
6488  function toggleStructMode() {
6489    setStructMode(!structMode);
6490  }
6491  function saveEdit() {
6492    const song = currentSong();
6493    const key = document.getElementById("editOriginalKey").value.trim();
6494    if (noteToIndex[key] === undefined) {
6495      alert(
6496        "Nada dasar tidak dikenali. Pakai C, Db, D, Eb, E, F, Gb, G, Ab, A, Bb, atau B.",
6497      );
6498      return;
6499    }
6500    song.title =
6501      document.getElementById("editTitle").value.trim() || song.title;
6502    song.originalKey = key;
6503    song.source = document.getElementById("editSource").value.trim();
6504    song.youtube = document.getElementById("editYoutube").value.trim();
6505    song.bg = document.getElementById("editBg").value.trim();
6506    song.lines = document
6507      .getElementById("editLines")
6508      .value.replace(/\r/g, "")
6509      .split("\n");
6510    selectedKey = key;
6511    saveSongs();
6512    makeButtons();
6513    render();
6514    closeEditor();
6515  }
6516  function addSong() {
6517    const s = {
6518      id: "song-" + Date.now(),
6519      title: "Lagu Baru",
6520      originalKey: "C",
6521      source: "",
6522      youtube: "",
6523      bg: "",
6524      lines: [
6525        "Intro :",
6526        "[C] [G] [Am] [F]",
6527        "Bait :",
6528        "[C]Tulis lirik di sini, [G]ganti chord sebelum suku kata",
6529        "[Am]Baris lirik berikut[F]nya di sini",
6530        "Reff :",
6531        "[F]Tulis lirik [G]reff di [C]sini",
6532      ],
6533    };
6534    songs.push(s);
6535    selectedSongId = s.id;
6536    selectedKey = s.originalKey;
6537    saveSongs();
6538    makeButtons();
6539    render();
6540    openEditor();
6541  }
6542  function duplicateSong() {
6543    const song = currentSong();
6544    const copy = JSON.parse(JSON.stringify(song));
6545    copy.id = "song-" + Date.now();
6546    copy.title = copy.title + " (Salinan)";
6547    copy.bankId = "";
6548    songs.push(copy);
6549    selectedSongId = copy.id;
6550    selectedKey = copy.originalKey;
6551    saveSongs();
6552    makeButtons();
6553    render();
6554    openEditor();
6555  }
6556  function deleteSong() {
6557    if (songs.length <= 1) {
6558      alert("Minimal harus ada 1 lagu.");
6559      return;
6560    }
6561    const song = currentSong();
6562    if (!confirm('Hapus lagu \"' + song.title + '\"?')) return;
6563    songs = songs.filter((s) => s.id !== song.id);
6564    selectedSongId = songs[0].id;
6565    selectedKey = songs[0].originalKey;
6566    saveSongs();
6567    makeButtons();
6568    render();
6569    closeEditor();
6570  }
6571  // --- Cadangan: salin, muat, dan unduh data lagu ---
6572  function openBackup() {
6573    document.getElementById("backupText").value = JSON.stringify(
6574      songs,
6575      null,
6576      2,
6577    );
6578    document.getElementById("backupModal").classList.add("open");
6579  }
6580  function closeBackup() {
6581    document.getElementById("backupModal").classList.remove("open");
6582  }
6583  function importBackup() {
6584    try {
6585      const data = JSON.parse(document.getElementById("backupText").value);
6586      if (!Array.isArray(data)) throw new Error("Data bukan array");
6587      songs = data;
6588      selectedSongId = songs[0].id;
6589      selectedKey = songs[0].originalKey;
6590      saveSongs();
6591      makeButtons();
6592      render();
6593      closeBackup();
6594      alert("Berhasil dimuat.");
6595    } catch (e) {
6596      alert("Gagal memuat: " + e.message);
6597    }
6598  }
6599  function downloadBackup() {
6600    const blob = new Blob([JSON.stringify(songs, null, 2)], {
6601      type: "application/json",
6602    });
6603    const a = document.createElement("a");
6604    a.href = URL.createObjectURL(blob);
6605    a.download = "cadangan-chord-pujian-youth.json";
6606    a.click();
6607    URL.revokeObjectURL(a.href);
6608  }
6609  // --- Memasang aksi ke setiap tombol ---
6610  document.getElementById("printBtn").onclick = () => window.print();
6611  // --- Salin: susun teks rapi, bukan innerText mentah ---
6612  function buildCopyText() {
6613    const sheet = document.getElementById("sheet");
6614    if (!sheet) return "";
6615    const RULE = "=".repeat(44);
6616    const out = [];
6617    const txtOf = (id) => {
6618      const el = document.getElementById(id);
6619      return el ? (el.textContent || "").trim() : "";
6620    };
6621    const title = txtOf("songTitle") || "Tanpa judul";
6622    out.push(title.toUpperCase());
6623    const key = txtOf("keyLine");
6624    const src = txtOf("sourceLine");
6625    if (key) out.push(key);
6626    if (src) out.push(src);
6627    out.push(RULE);
6628    out.push("");
6629    let blank = true;
6630    sheet.querySelectorAll(".line").forEach((el) => {
6631      const raw = (el.innerText || "").replace(/[ \t]+$/g, "");
6632      if (el.classList.contains("section")) {
6633        if (!blank) out.push("");
6634        const name = raw.trim().replace(/^\[|\]$/g, "");
6635        out.push("[" + name.toUpperCase() + "]");
6636        blank = false;
6637        return;
6638      }
6639      if (!raw.trim()) {
6640        if (!blank) {
6641          out.push("");
6642          blank = true;
6643        }
6644        return;
6645      }
6646      out.push(raw);
6647      blank = false;
6648    });
6649    while (out.length && !out[out.length - 1].trim()) out.pop();
6650    out.push("", "-".repeat(44), "HOSANA YOUTH TOOLS");
6651    return out.join("\n");
6652  }
6653  document.getElementById("copyBtn").onclick = async () => {
6654    const t = buildCopyText();
6655    try {
6656      await navigator.clipboard.writeText(t);
6657      if (typeof toast === "function")
6658        toast("Lagu disalin dengan format rapi.");
6659      else alert("Lagu disalin dengan format rapi.");
6660    } catch (e) {
6661      alert("Penyalinan otomatis gagal. Blok teks lagu lalu salin manual.");
6662    }
6663  };
6664  document.getElementById("editBtn").onclick = openEditor;
6665  document.getElementById("saveEditBtn").onclick = saveEdit;
6666  document.getElementById("cancelEditBtn").onclick = closeEditor;
6667  document.getElementById("duplicateBtn").onclick = duplicateSong;
6668  document.getElementById("deleteSongBtn").onclick = deleteSong;
6669  document.getElementById("addBtn").onclick = addSong;
6670  document.getElementById("backupBtn").onclick = openBackup;
6671  document.getElementById("closeBackupBtn").onclick = closeBackup;
6672  document.getElementById("importBackupBtn").onclick = importBackup;
6673  document.getElementById("downloadBackupBtn").onclick = downloadBackup;
6674  document.getElementById("copyBackupBtn").onclick = async () => {
6675    try {
6676      await navigator.clipboard.writeText(
6677        document.getElementById("backupText").value,
6678      );
6679      alert("Cadangan sudah disalin.");
6680    } catch (e) {
6681      alert("Penyalinan gagal. Blok teks cadangan lalu salin manual.");
6682    }
6683  };
6684  // --- Menu (laci kanan atas) ---
6685  function openMenu() {
6686    document.getElementById("menuBackdrop").classList.add("open");
6687  }
6688  function closeMenu() {
6689    document.getElementById("menuBackdrop").classList.remove("open");
6690  }
6691  function applyIzinUI() {
6692    var btn = document.getElementById("openIzinBtn");
6693    if (btn) {
6694      var izinTitle = btn.querySelector(".menuRowTitle");
6695      var izinDesc = btn.querySelector(".menuRowDesc");
6696      if (!izinFormOpen && !isAdmin) {
6697        btn.disabled = true;
6698        if (izinTitle) izinTitle.textContent = "Kirim Izin";
6699        if (izinDesc) izinDesc.textContent = "Form izin ditutup admin";
6700        btn.style.opacity = "0.55";
6701      } else {
6702        btn.disabled = false;
6703        if (izinTitle) izinTitle.textContent = "Kirim Izin";
6704        if (izinDesc) izinDesc.textContent = "Ajukan izin tidak hadir";
6705        btn.style.opacity = "";
6706      }
6707    }
6708    var tgl = document.getElementById("izinOpenToggle");
6709    if (tgl) tgl.checked = !!izinFormOpen;
6710  }
6711  function toggleIzinForm() {
6712    if (!isAdmin) return;
6713    var want = document.getElementById("izinOpenToggle").checked;
6714    izinFormOpen = want;
6715    if (izinRef) izinRef.set(want);
6716    applyIzinUI();
6717    toast(
6718      want
6719        ? "Form izin dibuka. Member bisa kirim izin."
6720        : "Form izin ditutup. Member tidak bisa kirim izin.",
6721      "success",
6722    );
6723  }
6724  function openIzin() {
6725    closeMenu();
6726    if (!izinFormOpen && !isAdmin) {
6727      toast("Form izin sedang ditutup oleh admin.", "info");
6728      return;
6729    }
6730    document.getElementById("izinStatus").textContent = "";
6731    document.getElementById("izinModal").classList.add("open");
6732  }
6733  function closeIzin() {
6734    document.getElementById("izinModal").classList.remove("open");
6735  }
6736  // --- Bantu saran ---
6737  function animData(key) {
6738    if (key === "chat") return window.__ANIM_CHAT;
6739    if (key === "saran") return window.__ANIM_SARAN;
6740    return (window.__ANIM && window.__ANIM[key]) || null;
6741  }
6742  var _io = null;
6743  function getIO() {
6744    if (_io || typeof IntersectionObserver === "undefined") return _io;
6745    _io = new IntersectionObserver(
6746      function (entries) {
6747        entries.forEach(function (en) {
6748          if (en.isIntersecting && en.target._lottie) {
6749            try {
6750              playLottieTwice(en.target._lottie);
6751            } catch (e) {}
6752          }
6753        });
6754      },
6755      { threshold: 0.2 },
6756    );
6757    return _io;
6758  }
6759  function playLottieTwice(anim) {
6760    if (!anim) return;
6761    anim._loops = 0;
6762    anim.loop = true;
6763    try {
6764      anim.goToAndPlay(0, true);
6765    } catch (e) {}
6766  }
6767  function loadLottieIcon(el, data) {
6768    var anim = lottie.loadAnimation({
6769      container: el,
6770      renderer: "svg",
6771      loop: true,
6772      autoplay: false,
6773      animationData: data,
6774    });
6775    anim._times = el.classList.contains("lkIco") ? 1 : 2;
6776    try {
6777      anim.setSpeed(0.75);
6778    } catch (e) {}
6779    anim.addEventListener("loopComplete", function () {
6780      anim._loops = (anim._loops || 0) + 1;
6781      if (anim._loops >= (anim._times || 2)) anim.loop = false;
6782    });
6783    el._lottie = anim;
6784    playLottieTwice(anim);
6785    var io = getIO();
6786    if (io) io.observe(el);
6787  }
6788  function loadSeqIcon(el, seq) {
6789    el.innerHTML = "";
6790    var intro = lottie.loadAnimation({
6791      container: el,
6792      renderer: "svg",
6793      loop: false,
6794      autoplay: true,
6795      animationData: seq.intro,
6796      rendererSettings: { preserveAspectRatio: "xMidYMid meet" },
6797    });
6798    try {
6799      intro.setSpeed(0.9);
6800    } catch (e) {}
6801    el._lottie = intro;
6802    el._seqPhase = "intro";
6803    intro.addEventListener("complete", function () {
6804      try {
6805        intro.destroy();
6806      } catch (e) {}
6807      el.innerHTML = "";
6808      var idle = lottie.loadAnimation({
6809        container: el,
6810        renderer: "svg",
6811        loop: true,
6812        autoplay: true,
6813        animationData: seq.idle,
6814        rendererSettings: { preserveAspectRatio: "xMidYMid meet" },
6815      });
6816      try {
6817        idle.setSpeed(0.8);
6818      } catch (e) {}
6819      el._lottie = idle;
6820      el._seqPhase = "idle";
6821    });
6822  }
6823  function initLottieIcons() {
6824    if (typeof lottie === "undefined") return;
6825    var list = document.querySelectorAll(".lottieIco");
6826    for (var i = 0; i < list.length; i++) {
6827      var el = list[i];
6828      if (el.getAttribute("data-loaded")) continue;
6829      var seqKey = el.getAttribute("data-seq");
6830      if (seqKey && window.__SEQ && window.__SEQ[seqKey]) {
6831        el.setAttribute("data-loaded", "1");
6832        el.innerHTML = "";
6833        try {
6834          loadSeqIcon(el, window.__SEQ[seqKey]);
6835        } catch (e) {}
6836        continue;
6837      }
6838      var data = animData(el.getAttribute("data-anim"));
6839      if (!data) continue;
6840      el.setAttribute("data-loaded", "1");
6841      el.innerHTML = "";
6842      try {
6843        loadLottieIcon(el, data);
6844      } catch (e) {}
6845    }
6846  }
6847  function replayLottie(el) {
6848    if (!el) return;
6849    var seqKey = el.getAttribute("data-seq");
6850    if (seqKey && window.__SEQ && window.__SEQ[seqKey]) {
6851      try {
6852        if (el._lottie) el._lottie.destroy();
6853      } catch (e) {}
6854      el.innerHTML = "";
6855      try {
6856        loadSeqIcon(el, window.__SEQ[seqKey]);
6857      } catch (e) {}
6858      return;
6859    }
6860    if (el._lottie) {
6861      try {
6862        playLottieTwice(el._lottie);
6863      } catch (e) {}
6864    }
6865  }
6866  function replayIn(container) {
6867    if (!container) return;
6868    var list = container.querySelectorAll(".lottieIco");
6869    for (var i = 0; i < list.length; i++) replayLottie(list[i]);
6870  }
6871  function ensureLottie(tries) {
6872    initLottieIcons();
6873    if (typeof lottie === "undefined" && (tries || 0) < 25) {
6874      setTimeout(function () {
6875        ensureLottie((tries || 0) + 1);
6876      }, 300);
6877    }
6878  }
6879  /* v66: dipanggil loader malas di index.html setelah animations.js siap */
6880  window.__pnwInitIcons = function () {
6881    ensureLottie(0);
6882  };
6883  // ---- Loading overlay (loading.json -> centang) ----
6884  var _loadInst = null;
6885  function showLoading(msg) {
6886    var ov = document.getElementById("loadOverlay");
6887    var host = document.getElementById("loadAnim");
6888    if (!ov || !host) return;
6889    var m = document.getElementById("loadMsg");
6890    if (m) m.textContent = msg || "Memuat...";
6891    ov.classList.add("on");
6892    host.innerHTML = "";
6893    host.style.opacity = "1";
6894    _loadInst = null;
6895    var data = animData("loadingbefore") || animData("loading");
6896    if (typeof lottie !== "undefined" && data) {
6897      try {
6898        _loadInst = lottie.loadAnimation({
6899          container: host,
6900          renderer: "svg",
6901          loop: true,
6902          autoplay: true,
6903          animationData: data,
6904          rendererSettings: { preserveAspectRatio: "xMidYMid meet" },
6905        });
6906      } catch (e) {}
6907    }
6908  }
6909  function loadingSuccess(msg, done) {
6910    var ov = document.getElementById("loadOverlay");
6911    var host = document.getElementById("loadAnim");
6912    if (!ov || !host) {
6913      if (done) done();
6914      return;
6915    }
6916    var m = document.getElementById("loadMsg");
6917    if (m && msg) m.textContent = msg;
6918    if (_loadInst) {
6919      try {
6920        _loadInst.destroy();
6921      } catch (e) {}
6922      _loadInst = null;
6923    }
6924    host.style.opacity = "0";
6925    setTimeout(function () {
6926      if (_loadInst) {
6927        try {
6928          _loadInst.destroy();
6929        } catch (e) {}
6930        _loadInst = null;
6931      }
6932      host.innerHTML = "";
6933      var data =
6934        animData("loadingafter") || animData("centang") || animData("check");
6935      if (typeof lottie !== "undefined" && data) {
6936        try {
6937          var a = lottie.loadAnimation({
6938            container: host,
6939            renderer: "svg",
6940            loop: false,
6941            autoplay: true,
6942            animationData: data,
6943            rendererSettings: { preserveAspectRatio: "xMidYMid meet" },
6944          });
6945          host.style.opacity = "1";
6946          a.addEventListener("complete", function () {
6947            setTimeout(function () {
6948              hideLoading();
6949              if (done) done();
6950            }, 500);
6951          });
6952          return;
6953        } catch (e) {}
6954      }
6955      host.innerHTML =
6956        '<i class="fi fi-sr-check" style="font-size:60px;color:var(--green);line-height:96px"></i>';
6957      host.style.opacity = "1";
6958      setTimeout(function () {
6959        hideLoading();
6960        if (done) done();
6961      }, 850);
6962    }, 220);
6963  }
6964  function hideLoading() {
6965    var ov = document.getElementById("loadOverlay");
6966    if (ov) ov.classList.remove("on");
6967    if (_loadInst) {
6968      try {
6969        _loadInst.destroy();
6970      } catch (e) {}
6971      _loadInst = null;
6972    }
6973    var host = document.getElementById("loadAnim");
6974    if (host) host.innerHTML = "";
6975  }
6976  function openSaran() {
6977    closeMenu();
6978    document.getElementById("saranStatus").textContent = "";
6979    document.getElementById("saranModal").classList.add("open");
6980    initLottieIcons();
6981  }
6982  function closeSaran() {
6983    document.getElementById("saranModal").classList.remove("open");
6984  }
6985  function readSaranFoto(cb) {
6986    var inp = document.getElementById("saranFoto");
6987    var f = inp && inp.files && inp.files[0];
6988    if (!f) {
6989      cb(null);
6990      return;
6991    }
6992    var reader = new FileReader();
6993    reader.onload = function (e) {
6994      var img = new Image();
6995      img.onload = function () {
6996        var maxDim = 900;
6997        var scale = Math.min(1, maxDim / Math.max(img.width, img.height));
6998        var cw = Math.round(img.width * scale);
6999        var ch = Math.round(img.height * scale);
7000        var cv = document.createElement("canvas");
7001        cv.width = cw;
7002        cv.height = ch;
7003        cv.getContext("2d").drawImage(img, 0, 0, cw, ch);
7004        try {
7005          cb(cv.toDataURL("image/jpeg", 0.7));
7006        } catch (er) {
7007          cb(null);
7008        }
7009      };
7010      img.onerror = function () {
7011        cb(null);
7012      };
7013      img.src = e.target.result;
7014    };
7015    reader.onerror = function () {
7016      cb(null);
7017    };
7018    reader.readAsDataURL(f);
7019  }
7020  function submitSaran() {
7021    var nama = (document.getElementById("saranNama").value || "").trim();
7022    var teks = (document.getElementById("saranTeks").value || "").trim();
7023    var st = document.getElementById("saranStatus");
7024    if (!teks) {
7025      st.style.color = "var(--red)";
7026      st.textContent = "Tulis saran kamu dulu ya.";
7027      return;
7028    }
7029    if (rateLimited("saran", 12000)) {
7030      st.style.color = "var(--red)";
7031      st.textContent =
7032        "Sabar ya, tunggu beberapa detik sebelum mengirim saran lagi.";
7033      return;
7034    }
7035    st.style.color = "var(--muted)";
7036    st.textContent = "Menyiapkan...";
7037    var btn = document.getElementById("sendSaranBtn");
7038    btn.disabled = true;
7039    readSaranFoto(function (foto) {
7040      var payload = {
7041        type: "saran",
7042        nama: nama || "(tanpa nama)",
7043        saran: teks,
7044        foto: foto || "",
7045        waktu: new Date().toLocaleString("id-ID"),
7046      };
7047      var sentSomething = false;
7048      try {
7049        if (
7050          cloudReady &&
7051          typeof firebase !== "undefined" &&
7052          firebase.apps &&
7053          firebase.apps.length
7054        ) {
7055          firebase
7056            .database()
7057            .ref("pujianYouth/saran")
7058            .push({
7059              nama: payload.nama,
7060              saran: payload.saran,
7061              foto: foto || null,
7062              waktu: payload.waktu,
7063              t: Date.now(),
7064            });
7065          sentSomething = true;
7066        }
7067      } catch (e) {}
7068      function doDone() {
7069        st.style.color = "var(--green)";
7070        st.textContent = "Terima kasih! Saran kamu sudah terkirim.";
7071        document.getElementById("saranNama").value = "";
7072        document.getElementById("saranTeks").value = "";
7073        var fi = document.getElementById("saranFoto");
7074        if (fi) fi.value = "";
7075        var pv = document.getElementById("saranFotoPreview");
7076        if (pv) pv.innerHTML = "";
7077        btn.disabled = false;
7078      }
7079      if (IZIN_ENDPOINT) {
7080        st.textContent = "Mengirim...";
7081        fetch(IZIN_ENDPOINT, {
7082          method: "POST",
7083          mode: "no-cors",
7084          headers: { "Content-Type": "text/plain;charset=utf-8" },
7085          body: JSON.stringify(payload),
7086        })
7087          .then(doDone)
7088          .catch(function () {
7089            if (sentSomething) {
7090              doDone();
7091              return;
7092            }
7093            st.style.color = "var(--red)";
7094            st.textContent = "Gagal mengirim. Coba lagi ya.";
7095            btn.disabled = false;
7096          });
7097      } else if (sentSomething) {
7098        doDone();
7099      } else {
7100        st.style.color = "var(--red)";
7101        st.textContent = "Belum ada tujuan pengiriman yang aktif.";
7102        btn.disabled = false;
7103      }
7104    });
7105  }
7106  // --- Info & pembaruan (release notes) ---
7107  var SEED_NOTES = [
7108    {
7109      v: "v3.0",
7110      d: "28 Jul 2026",
7111      items: [
7112        "Tombol Kirim ke Sheets: jadwal langsung tertulis ke Google Sheets Jadwal Youth.",
7113        "Ekspor Excel tetap ada sebagai cadangan bila sedang offline.",
7114      ],
7115      t: 1785293400000,
7116    },
7117    {
7118      v: "v3.0",
7119      d: "28 Jul 2026",
7120      items: [
7121        "Kolom + tambah kini bisa dicari di HP, lengkap dengan penanda tier.",
7122        "Role Presenter (Trial) dihapus, tinggal satu Presenter.",
7123        "Tampilan jadwal dioptimalkan untuk HP dan tablet.",
7124        "Tombol Ekspor Excel untuk jadwal yang sudah jadi.",
7125        "Halaman jadwal lebih ringan dan cepat dibuka.",
7126      ],
7127      t: 1785291600000,
7128    },
7129    {
7130      v: "v3.0",
7131      d: "28 Jul 2026",
7132      items: [
7133        "Pemusik jadi satu baris berjejer: Pianist - Guitarist - Bassist - Drummer.",
7134        "Role Multimedia dihapus dan dilebur ke Presenter.",
7135        "Perbaikan sinkronisasi realtime jadwal antar perangkat.",
7136        "Ada lencana status sinkron di halaman jadwal.",
7137        "Pesan error sinkron kini menyebut penyebab aslinya.",
7138        "Form izin menulis ke jalur data yang sama dengan jadwal.",
7139      ],
7140      t: 1785288000000,
7141    },
7142    {
7143      v: "v3.0",
7144      d: "28 Jul 2026",
7145      items: [
7146        "Tim Sound & Multimedia kini terkunci: tidak merangkap tugas di P&W, Persembahan, atau Hospitality pada minggu yang sama.",
7147        "Penanda Double Role diganti dari garis 3D menjadi warna tetap per orang + angka jumlah tugas.",
7148        "Arahkan kursor atau ketuk sebuah nama untuk menyorot semua tugasnya di minggu itu.",
7149        "Jadwal kini realtime: beberapa pengurus bisa mengedit bersamaan tanpa saling menimpa.",
7150        "Member sekarang bisa membuka jadwal dalam mode lihat saja.",
7151      ],
7152      t: 1785300000000,
7153    },
7154    {
7155      v: "v3.0",
7156      d: "28 Jul 2026",
7157      items: [
7158        "Jadwal otomatis memakai aturan resmi H1-H8 (bukan lagi coba-coba).",
7159        "Hospitality: 3 Usher, Doa Pembuka & Pengumuman diambil dari usher (Double Role).",
7160        "Doa Penutup khusus pengurus dan tidak boleh merangkap usher.",
7161        "Presenter dipisah tetap & trial, dan tidak boleh pegang role teknis.",
7162        "Musisi P&W dari pool senior, Persembahan dari pool junior.",
7163        "WL Persembahan wajib beda orang dengan WL P&W.",
7164        "Selang-seling: yang melayani minggu ini dilewati minggu depan.",
7165        "Garis 3D otomatis menghubungkan nama yang dobel dalam satu minggu.",
7166      ],
7167    },
7168    {
7169      v: "2.1",
7170      d: "28 Jul 2026",
7171      items: [
7172        "Jadwal otomatis kini pakai sistem prioritas sederhana (Actual -> Secondary -> Cadangan) sesuai daftar pelayan.",
7173        "Pemusik dipisah jadi Keyboard, Guitar, Bass, dan Drum.",
7174        "Data izin & permintaan khusus dipakai otomatis; semua role diusahakan terisi.",
7175      ],
7176    },
7177    {
7178      v: "2.0",
7179      d: "28 Jul 2026",
7180      items: [
7181        "Kotak cari nama dipindah ke tombol + tambah saat mengisi jadwal.",
7182        "Efek tap tombol dipercepat (60ms).",
7183      ],
7184    },
7185    {
7186      v: "1.9",
7187      d: "28 Jul 2026",
7188      items: [
7189        "Drag daftar lagu stabil: lagu lain tidak bergeser sampai lagu dilepas.",
7190        "Jadwal otomatis pakai algoritma genetik (belajar dari data, semua role terisi).",
7191        "Like pembaruan: animasi sekali, jumlah update real-time.",
7192        "Ikon saran & pembaruan diperbesar dan sedikit bersinar.",
7193        "Efek tap tombol dipercepat.",
7194      ],
7195    },
7196    {
7197      v: "1.8",
7198      d: "28 Jul 2026",
7199      items: [
7200        "Ikon kalender & centang animasi (Lottie) ditambahkan.",
7201        "Animasi ikon kini main 2x dengan kecepatan 0.75x lalu diam.",
7202        "Efek tap bouncy + kilau biru saat menekan tombol fitur.",
7203      ],
7204    },
7205    {
7206      v: "1.7",
7207      d: "28 Jul 2026",
7208      items: [
7209        "Ikon animasi (Lottie) untuk kirim izin, pembaruan, saran, like.",
7210        'Menu saran kini "Beri Saran & lapor bug".',
7211        "Animasi main sekali saat muncul (tidak loop).",
7212        "Loading beranimasi saat login, lalu tanda sukses.",
7213      ],
7214    },
7215    {
7216      v: "1.6",
7217      d: "28 Jul 2026",
7218      items: [
7219        "Ikon menu, info, dan pembaruan diganti ikon Flaticon.",
7220        "Ikon beri saran & fitur saran kini animasi (Lottie).",
7221        "Form saran bisa melampirkan foto (masuk ke Firebase/Sheet).",
7222        "Bisa memberi like pada catatan Info & pembaruan.",
7223      ],
7224    },
7225    {
7226      v: "1.5",
7227      d: "28 Jul 2026",
7228      items: [
7229        "Login username & password untuk pengurus.",
7230        "Penjadwalan khusus admin + admin bisa tutup form izin.",
7231        "Kotak cari + urut abjad saat mengisi kolom jadwal.",
7232        "Admin bisa edit/hapus catatan Info & pembaruan.",
7233        "Drag-and-drop daftar lagu lebih stabil (tidak sensitif).",
7234      ],
7235    },
7236    {
7237      v: "1.4",
7238      d: "28 Jul 2026",
7239      items: [
7240        "Izin member langsung masuk matriks jadwal (Firebase).",
7241        "Jadwal otomatis lebih variatif + hindari role sama berulang.",
7242        "Semua nama tampil saat menambah pelayan (termasuk Persembahan).",
7243        "Chip kuning untuk double role + opsi izinkan double role.",
7244        "Kotak cari pelayan, tampilan HP lebih pas, font Inter.",
7245      ],
7246    },
7247    {
7248      v: "1.3",
7249      d: "26 Jul 2026",
7250      items: [
7251        "Autocomplete chord canggih saat mengedit lagu.",
7252        "Mode chord jadi angka (Nashville 1-7).",
7253        "Kotak 'Bantu saran' untuk usulan fitur.",
7254        "Tampilan menu dirapikan agar lebih ringan.",
7255      ],
7256    },
7257    {
7258      v: "1.2",
7259      d: "Juli 2026",
7260      items: [
7261        "Metronome dengan aksen ketukan pertama + ketuk tempo.",
7262        "Eye saver untuk kenyamanan mata di panggung.",
7263        "Form izin pelayanan langsung dari aplikasi.",
7264      ],
7265    },
7266    {
7267      v: "1.1",
7268      d: "Juli 2026",
7269      items: [
7270        "Song Bank dengan pencarian & urutkan lagu.",
7271        "Pemutar YouTube dengan progress bar.",
7272        "Sinkronisasi realtime antar perangkat.",
7273      ],
7274    },
7275  ];
7276  // ===== v3.2 - KONTROL PENUH ATAS CATATAN RILIS =====
7277  // SEED_NOTES ditulis langsung di berkas ini setiap kali ada revisi,
7278  // jadi catatan baru muncul otomatis. Supaya tetap bisa diedit dan
7279  // dihapus dari web tanpa menyentuh berkas, setiap perubahan disimpan
7280  // sebagai LAPISAN PENIMPA (override) yang ditempel di atas catatan
7281  // bawaan saat digambar. Bawaan aslinya tidak pernah ikut berubah,
7282  // sehingga "Pulihkan bawaan" selalu mungkin dilakukan.
7283  var NOTE_OV_KEY = "ptNoteOverrides";
7284  var noteOv = {};
7285  var CLOUD_NOTES = [];
7286  var RELEASE_NOTES = [];
7287  function seedId(n) {
7288    return (
7289      "seed_" +
7290      String(n.v || "").replace(/[^a-zA-Z0-9]/g, "_") +
7291      "_" +
7292      (n.t || 0)
7293    );
7294  }
7295  function isSeedKey(k) {
7296    return typeof k === "string" && k.indexOf("seed_") === 0;
7297  }
7298  function loadNoteOv() {
7299    try {
7300      noteOv = JSON.parse(localStorage.getItem(NOTE_OV_KEY) || "{}") || {};
7301    } catch (e) {
7302      noteOv = {};
7303    }
7304  }
7305  function refreshInfoUI() {
7306    buildReleaseNotes();
7307    renderInfoList();
7308    updateNoteDot();
7309    syncRestoreBtn();
7310  }
7311  // Lokal ditulis lebih dulu supaya kontrol penuh tetap jalan walau
7312  // aturan Firebase menolak tulisan. Cloud sifatnya pelengkap agar
7313  // perubahan ikut terlihat di perangkat lain.
7314  function saveNoteOv(id, val) {
7315    if (val === null) delete noteOv[id];
7316    else noteOv[id] = val;
7317    try {
7318      localStorage.setItem(NOTE_OV_KEY, JSON.stringify(noteOv));
7319    } catch (e) {}
7320    if (noteOvRef) {
7321      try {
7322        if (val === null) noteOvRef.child(id).remove();
7323        else noteOvRef.child(id).set(val);
7324      } catch (e) {}
7325    }
7326    refreshInfoUI();
7327  }
7328  function buildReleaseNotes() {
7329    var out = CLOUD_NOTES.slice();
7330    SEED_NOTES.forEach(function (n) {
7331      var id = seedId(n);
7332      var o = noteOv[id];
7333      if (o && o.hidden) return;
7334      var base = o && o.items ? o : n;
7335      out.push({
7336        v: base.v,
7337        d: base.d,
7338        items: base.items || [],
7339        t: base.t || n.t || 0,
7340        _key: id,
7341        _seed: true,
7342        _edited: !!(o && o.items),
7343      });
7344    });
7345    // Terbaru di atas. Kalau tanggalnya sama persis, versi yang lebih
7346    // besar didahulukan supaya urutannya tidak berubah sendiri.
7347    out.sort(function (a, b) {
7348      var s = (b.t || 0) - (a.t || 0);
7349      if (s) return s;
7350      return String(b.v || "").localeCompare(String(a.v || ""), undefined, {
7351        numeric: true,
7352      });
7353    });
7354    RELEASE_NOTES = out;
7355  }
7356  function noteOvCount() {
7357    return Object.keys(noteOv || {}).length;
7358  }
7359  function syncRestoreBtn() {
7360    var b = document.getElementById("restoreNotesBtn");
7361    if (!b) return;
7362    b.style.display = isAdmin && noteOvCount() ? "" : "none";
7363  }
7364  function restoreSeedNotes() {
7365    if (!isAdmin) return;
7366    if (
7367      !confirm(
7368        "Pulihkan semua catatan rilis bawaan? Hasil edit dan penghapusan pada catatan bawaan akan dibatalkan.",
7369      )
7370    )
7371      return;
7372    var ids = Object.keys(noteOv || {});
7373    noteOv = {};
7374    try {
7375      localStorage.setItem(NOTE_OV_KEY, "{}");
7376    } catch (e) {}
7377    if (noteOvRef) {
7378      try {
7379        ids.forEach(function (id) {
7380          noteOvRef.child(id).remove();
7381        });
7382      } catch (e) {}
7383    }
7384    refreshInfoUI();
7385    toast("Catatan bawaan dipulihkan.", "success");
7386  }
7387  loadNoteOv();
7388  buildReleaseNotes();
7389  function latestNoteVersion() {
7390    return RELEASE_NOTES.length ? RELEASE_NOTES[0].v : "";
7391  }
7392  function likeKeyFor(n) {
7393    return n._key || "v" + String(n.v).replace(/[^a-zA-Z0-9]/g, "_");
7394  }
7395  function toggleNoteLike(n) {
7396    var key = likeKeyFor(n);
7397    if (rateLimited("like_" + key, 600) || !writeBurstOk(30, 10000)) return;
7398    var lk = "ptLike_" + key;
7399    var liked = false;
7400    try {
7401      liked = localStorage.getItem(lk) === "1";
7402    } catch (e) {}
7403    var delta = liked ? -1 : 1;
7404    try {
7405      localStorage.setItem(lk, liked ? "0" : "1");
7406    } catch (e) {}
7407    noteLikes[key] = Math.max(0, (noteLikes[key] || 0) + delta);
7408    updateLikeCounts();
7409    if (likesRef) {
7410      likesRef.child(key).transaction(function (cur) {
7411        return Math.max(0, (cur || 0) + delta);
7412      });
7413    }
7414  }
7415  function updateLikeCounts() {
7416    var host = document.getElementById("infoList");
7417    if (!host) return;
7418    var btns = host.querySelectorAll(".noteLike");
7419    Array.prototype.forEach.call(btns, function (btn) {
7420      var key = btn.dataset.lkey;
7421      if (!key) return;
7422      var cnt = (noteLikes && noteLikes[key]) || 0;
7423      var nEl = btn.querySelector(".lkN");
7424      if (nEl) nEl.textContent = cnt;
7425      var liked = false;
7426      try {
7427        liked = localStorage.getItem("ptLike_" + key) === "1";
7428      } catch (e) {}
7429      btn.classList.toggle("liked", liked);
7430    });
7431  }
7432  function renderInfoList() {
7433    var host = document.getElementById("infoList");
7434    if (!host) return;
7435    host.innerHTML = "";
7436    RELEASE_NOTES.forEach(function (n) {
7437      var item = document.createElement("div");
7438      item.className = "infoItem";
7439      var head = document.createElement("div");
7440      head.className = "iv";
7441      var b = document.createElement("b");
7442      b.textContent = "Versi " + n.v;
7443      var s = document.createElement("span");
7444      s.textContent = n.d;
7445      head.appendChild(b);
7446      head.appendChild(s);
7447      var likeWrap = document.createElement("button");
7448      likeWrap.type = "button";
7449      likeWrap.className = "noteLike";
7450      var _lkey = likeKeyFor(n);
7451      var _liked = false;
7452      try {
7453        _liked = localStorage.getItem("ptLike_" + _lkey) === "1";
7454      } catch (e) {}
7455      if (_liked) likeWrap.classList.add("liked");
7456      var _cnt = (noteLikes && noteLikes[_lkey]) || 0;
7457      likeWrap.dataset.lkey = _lkey;
7458      likeWrap.innerHTML =
7459        '<span class="lottieIco lkIco" data-anim="like"></span><span class="lkN">' +
7460        _cnt +
7461        "</span>";
7462      likeWrap.onclick = function () {
7463        toggleNoteLike(n);
7464      };
7465      head.appendChild(likeWrap);
7466      if (n._edited) {
7467        var em = document.createElement("span");
7468        em.className = "noteEditedTag";
7469        em.textContent = "diedit";
7470        head.appendChild(em);
7471      }
7472      if (isAdmin && n._key) {
7473        var act = document.createElement("span");
7474        act.className = "noteActs";
7475        var ed = document.createElement("button");
7476        ed.type = "button";
7477        ed.className = "noteAct";
7478        ed.textContent = "Edit";
7479        ed.onclick = function () {
7480          openNoteModal(n);
7481        };
7482        var dl = document.createElement("button");
7483        dl.type = "button";
7484        dl.className = "noteAct danger";
7485        dl.textContent = "Hapus";
7486        dl.onclick = function () {
7487          if (confirm("Hapus catatan versi " + n.v + "?")) deleteNote(n._key);
7488        };
7489        act.appendChild(ed);
7490        act.appendChild(dl);
7491        head.appendChild(act);
7492      }
7493      var ul = document.createElement("ul");
7494      n.items.forEach(function (t) {
7495        var li = document.createElement("li");
7496        li.textContent = t;
7497        ul.appendChild(li);
7498      });
7499      item.appendChild(head);
7500      item.appendChild(ul);
7501      host.appendChild(item);
7502    });
7503    initLottieIcons();
7504  }
7505  function updateNoteDot() {
7506    var dot = document.getElementById("menuDot");
7507    if (!dot) return;
7508    var seen = "";
7509    try {
7510      seen = localStorage.getItem("ptSeenNote") || "";
7511    } catch (e) {}
7512    if (seen !== latestNoteVersion()) dot.classList.add("on");
7513    else dot.classList.remove("on");
7514  }
7515  function markNotesSeen() {
7516    try {
7517      localStorage.setItem("ptSeenNote", latestNoteVersion());
7518    } catch (e) {}
7519    var dot = document.getElementById("menuDot");
7520    if (dot) dot.classList.remove("on");
7521  }
7522  function openInfo() {
7523    closeMenu();
7524    buildReleaseNotes();
7525    renderInfoList();
7526    syncRestoreBtn();
7527    markNotesSeen();
7528    document.getElementById("infoModal").classList.add("open");
7529  }
7530  function closeInfo() {
7531    document.getElementById("infoModal").classList.remove("open");
7532  }
7533  function izinSelectedWeeks() {
7534    var out = [];
7535    document.querySelectorAll("#izinWeeks .weekChip.on").forEach(function (b) {
7536      out.push(b.getAttribute("data-week"));
7537    });
7538    return out;
7539  }
7540  function findRosterByName(name) {
7541    var q = (name || "").trim().toLowerCase();
7542    var found = null;
7543    sched.roster.forEach(function (p) {
7544      if ((p.name || "").trim().toLowerCase() === q) found = p;
7545    });
7546    return found;
7547  }
7548  function mergeIzinWeeks(target, weeks) {
7549    weeks.forEach(function (wStr) {
7550      var wk = parseInt(wStr, 10);
7551      if (!wk) return;
7552      if (target.indexOf(wk) < 0) target.push(wk);
7553    });
7554    target.sort(function (a, b) {
7555      return a - b;
7556    });
7557  }
7558  // Simpan izin member langsung ke matriks penjadwalan (Firebase
7559  // pujianYouth/schedule4). Membaca data terbaru lebih dulu agar tidak
7560  // menimpa jadwal/roster admin.
7561  function saveIzinToCloud(nama, weeks, cb) {
7562    var online =
7563      cloudReady &&
7564      typeof firebase !== "undefined" &&
7565      firebase.apps &&
7566      firebase.apps.length;
7567    if (online) {
7568      var ref = firebase.database().ref("pujianYouth/schedule4");
7569      ref
7570        .once("value")
7571        .then(function (snap) {
7572          var cloud = snap.val();
7573          if (!cloud || !cloud.roster) cloud = sched;
7574          if (!cloud.izin) cloud.izin = {};
7575          var q = (nama || "").trim().toLowerCase();
7576          var person = null;
7577          cloud.roster.forEach(function (p) {
7578            if ((p.name || "").trim().toLowerCase() === q) person = p;
7579          });
7580          if (!person) {
7581            person = {
7582              id: "p-" + Date.now() + "-" + Math.floor(Math.random() * 10000),
7583              name: nama,
7584              roles: [],
7585            };
7586            cloud.roster.push(person);
7587          }
7588          if (!cloud.izin[person.id]) cloud.izin[person.id] = [];
7589          mergeIzinWeeks(cloud.izin[person.id], weeks);
7590          return ref.set(cloud);
7591        })
7592        .then(function () {
7593          cb(true);
7594        })
7595        .catch(function () {
7596          cb(false);
7597        });
7598    } else {
7599      // Offline: simpan ke matriks lokal, akan tersinkron saat online.
7600      try {
7601        var person2 = findRosterByName(nama);
7602        if (!person2) {
7603          person2 = {
7604            id: "p-" + Date.now() + "-" + Math.floor(Math.random() * 10000),
7605            name: nama,
7606            roles: [],
7607          };
7608          sched.roster.push(person2);
7609        }
7610        if (!sched.izin[person2.id]) sched.izin[person2.id] = [];
7611        mergeIzinWeeks(sched.izin[person2.id], weeks);
7612        localStorage.setItem(SCHED_KEY, JSON.stringify(sched));
7613      } catch (e) {}
7614      cb(false);
7615    }
7616  }
7617  function submitIzin() {
7618    var nama = (document.getElementById("izinNama").value || "").trim();
7619    var weeks = izinSelectedWeeks();
7620    var ket = (document.getElementById("izinKet").value || "").trim();
7621    var st = document.getElementById("izinStatus");
7622    if (!izinFormOpen && !isAdmin) {
7623      st.style.color = "var(--red)";
7624      st.textContent = "Form izin sedang ditutup oleh admin.";
7625      return;
7626    }
7627    if (!nama) {
7628      st.style.color = "var(--red)";
7629      st.textContent = "Isi nama dulu ya.";
7630      return;
7631    }
7632    if (weeks.length === 0) {
7633      st.style.color = "var(--red)";
7634      st.textContent = "Pilih minggu ke berapa kamu izin.";
7635      return;
7636    }
7637    var btn = document.getElementById("sendIzinBtn");
7638    btn.disabled = true;
7639    st.style.color = "var(--muted)";
7640    st.textContent = "Menyimpan izin ke jadwal...";
7641    initScheduleCloud();
7642    var resetForm = function () {
7643      document.getElementById("izinNama").value = "";
7644      document.getElementById("izinKet").value = "";
7645      document
7646        .querySelectorAll("#izinWeeks .weekChip.on")
7647        .forEach(function (b) {
7648          b.classList.remove("on");
7649        });
7650    };
7651    saveIzinToCloud(nama, weeks, function () {
7652      var finish = function () {
7653        st.style.color = "var(--green)";
7654        st.textContent =
7655          "Terima kasih! Izin kamu sudah tercatat di matriks jadwal.";
7656        resetForm();
7657        btn.disabled = false;
7658        setTimeout(closeIzin, 1500);
7659      };
7660      // Opsional: kirim juga ke Google Sheet sebagai log bila dikonfigurasi.
7661      if (!IZIN_ENDPOINT) {
7662        finish();
7663        return;
7664      }
7665      var payload = {
7666        nama: nama,
7667        minggu: weeks.join(", "),
7668        keterangan: ket,
7669        waktu: new Date().toLocaleString("id-ID"),
7670      };
7671      fetch(IZIN_ENDPOINT, {
7672        method: "POST",
7673        mode: "no-cors",
7674        headers: { "Content-Type": "text/plain;charset=utf-8" },
7675        body: JSON.stringify(payload),
7676      })
7677        .then(finish)
7678        .catch(finish);
7679    });
7680  }
7681  function ytId(url) {
7682    if (!url) return "";
7683    var s = String(url).trim();
7684    var marks = [
7685      "watch?v=",
7686      "youtu.be/",
7687      "/shorts/",
7688      "/embed/",
7689      "/live/",
7690      "v=",
7691    ];
7692    for (var i = 0; i < marks.length; i++) {
7693      var k = s.indexOf(marks[i]);
7694      if (k < 0) continue;
7695      var rest = s.slice(k + marks[i].length),
7696        id = "";
7697      for (var j = 0; j < rest.length; j++) {
7698        var c = rest[j];
7699        if (
7700          (c >= "A" && c <= "Z") ||
7701          (c >= "a" && c <= "z") ||
7702          (c >= "0" && c <= "9") ||
7703          c === "_" ||
7704          c === "-"
7705        ) {
7706          id += c;
7707        } else {
7708          break;
7709        }
7710      }
7711      if (id.length >= 6) return id;
7712    }
7713    if (s.length === 11) {
7714      var ok = true;
7715      for (var m = 0; m < s.length; m++) {
7716        var cc = s[m];
7717        if (!(
7718          (cc >= "A" && cc <= "Z") ||
7719          (cc >= "a" && cc <= "z") ||
7720          (cc >= "0" && cc <= "9") ||
7721          cc === "_" ||
7722          cc === "-"
7723        )) {
7724          ok = false;
7725          break;
7726        }
7727      }
7728      if (ok) return s;
7729    }
7730    return "";
7731  }
7732  function openYouTube() {
7733    var song = currentSong();
7734    if (!song) return;
7735    var id = ytId(song.youtube);
7736    if (id) {
7737      loadYtApi();
7738      if (ytReady && window.YT && window.YT.Player) {
7739        startYtPlayer(id);
7740      } else {
7741        ytPending = id;
7742      }
7743    } else {
7744      var q = encodeURIComponent(
7745        (song.title || "") + " " + (song.source || ""),
7746      );
7747      window.open(
7748        "https://www.youtube.com/results?search_query=" + q,
7749        "_blank",
7750      );
7751    }
7752  }
7753  var ytPlayer = null,
7754    ytPoll = 0,
7755    ytReady = false,
7756    ytPending = null;
7757  function loadYtApi() {
7758    if (window.YT && window.YT.Player) {
7759      ytReady = true;
7760      return;
7761    }
7762    if (document.getElementById("ytApiScript")) return;
7763    var t = document.createElement("script");
7764    t.id = "ytApiScript";
7765    t.src = "https://www.youtube.com/iframe_api";
7766    document.head.appendChild(t);
7767  }
7768  window.onYouTubeIframeAPIReady = function () {
7769    ytReady = true;
7770    if (ytPending) {
7771      var v = ytPending;
7772      ytPending = null;
7773      startYtPlayer(v);
7774    }
7775  };
7776  function fmtTime(s) {
7777    s = Math.max(0, Math.floor(s || 0));
7778    var m = Math.floor(s / 60),
7779      r = s % 60;
7780    return m + ":" + (r < 10 ? "0" : "") + r;
7781  }
7782  function startYtPlayer(id) {
7783    showYtBar();
7784    if (ytPlayer && ytPlayer.loadVideoById) {
7785      ytPlayer.loadVideoById(id);
7786      startYtPoll();
7787      return;
7788    }
7789    var host = document.getElementById("ytHost");
7790    host.innerHTML = '<div id="ytPlayerEl"></div>';
7791    ytPlayer = new YT.Player("ytPlayerEl", {
7792      videoId: id,
7793      playerVars: {
7794        autoplay: 1,
7795        playsinline: 1,
7796        rel: 0,
7797        controls: 0,
7798        modestbranding: 1,
7799      },
7800      events: {
7801        onReady: function (e) {
7802          try {
7803            e.target.playVideo();
7804          } catch (_) {}
7805          startYtPoll();
7806        },
7807        onStateChange: onYtState,
7808      },
7809    });
7810  }
7811  function onYtState(e) {
7812    var pp = document.getElementById("ytPP");
7813    if (!pp) return;
7814    if (e.data === YT.PlayerState.PLAYING) {
7815      pp.innerHTML = "&#10074;&#10074;";
7816      startYtPoll();
7817    } else {
7818      pp.innerHTML = "&#9654;";
7819    }
7820  }
7821  function startYtPoll() {
7822    if (ytPoll) return;
7823    ytPoll = setInterval(updateYtBar, 250);
7824  }
7825  function stopYtPoll() {
7826    if (ytPoll) {
7827      clearInterval(ytPoll);
7828      ytPoll = 0;
7829    }
7830  }
7831  function updateYtBar() {
7832    if (!ytPlayer || !ytPlayer.getDuration) return;
7833    var d = ytPlayer.getDuration() || 0,
7834      c = ytPlayer.getCurrentTime() || 0;
7835    var pct = d > 0 ? Math.min(100, (c / d) * 100) : 0;
7836    var fill = document.getElementById("ytFill"),
7837      dot = document.getElementById("ytDot"),
7838      tm = document.getElementById("ytTime");
7839    if (fill) fill.style.width = pct + "%";
7840    if (dot) dot.style.left = pct + "%";
7841    if (tm) tm.textContent = fmtTime(c) + " / " + fmtTime(d);
7842  }
7843  function showYtBar() {
7844    var b = document.getElementById("ytBar");
7845    if (b) b.classList.add("open");
7846    var p = document.getElementById("ytPitch");
7847    if (p) p.classList.add("open");
7848  }
7849  function hideYtBar() {
7850    var b = document.getElementById("ytBar");
7851    if (b) b.classList.remove("open");
7852    var p = document.getElementById("ytPitch");
7853    if (p) p.classList.remove("open");
7854    try {
7855      ytRateSet(1);
7856    } catch (e) {}
7857  }
7858  function ytTogglePlay() {
7859    if (!ytPlayer || !ytPlayer.getPlayerState) return;
7860    if (ytPlayer.getPlayerState() === YT.PlayerState.PLAYING) {
7861      ytPlayer.pauseVideo();
7862    } else {
7863      ytPlayer.playVideo();
7864    }
7865  }
7866  function ytSeek(e) {
7867    if (!ytPlayer || !ytPlayer.getDuration) return;
7868    var tr = document.getElementById("ytTrack"),
7869      r = tr.getBoundingClientRect();
7870    var f = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
7871    ytPlayer.seekTo(f * (ytPlayer.getDuration() || 0), true);
7872    updateYtBar();
7873  }
7874  function stopYt() {
7875    stopYtPoll();
7876    if (ytPlayer && ytPlayer.stopVideo) {
7877      try {
7878        ytPlayer.stopVideo();
7879      } catch (_) {}
7880    }
7881    hideYtBar();
7882  }
7883
7884  // --- Bank Lagu: lagu populer siap ditambahkan ---
7885  const songBank = [
7886    {
7887      title: "Bapa Yang Kekal",
7888      by: "Cover/asli: JPCC Worship",
7889      key: "C",
7890      link: "https://www.youtube.com/results?search_query=Bapa+Yang+Kekal+JPCC+Worship",
7891    },
7892    {
7893      title: "Kupilih Yesus",
7894      by: "Cover/asli: NDC Worship",
7895      key: "G",
7896      link: "https://www.youtube.com/results?search_query=Kupilih+Yesus+NDC+Worship",
7897    },
7898    {
7899      title: "Tak Terbatas",
7900      by: "Cover/asli: Symphony Worship",
7901      key: "D",
7902      link: "https://www.youtube.com/results?search_query=Tak+Terbatas+Symphony+Worship",
7903    },
7904    {
7905      title: "Kaulah Harapan",
7906      by: "Cover/asli: JPCC Worship",
7907      key: "A",
7908      link: "https://www.youtube.com/results?search_query=Kaulah+Harapan+JPCC+Worship",
7909    },
7910    {
7911      title: "Cinta Paling Berharga",
7912      by: "Cover/asli: True Worshippers",
7913      key: "G",
7914      link: "https://www.youtube.com/results?search_query=Cinta+Paling+Berharga+True+Worshippers",
7915    },
7916    {
7917      title: "Besar SetiaMu",
7918      by: "Cover/asli: Franky Sihombing",
7919      key: "D",
7920      link: "https://www.youtube.com/results?search_query=Besar+SetiaMu",
7921    },
7922    {
7923      title: "Mujizat Itu Nyata",
7924      by: "Cover/asli: NDC Worship",
7925      key: "E",
7926      link: "https://www.youtube.com/results?search_query=Mujizat+Itu+Nyata+NDC+Worship",
7927    },
7928    {
7929      title: "Seperti Yang Kau Ingini",
7930      by: "Cover/asli: Nikita",
7931      key: "F",
7932      link: "https://www.youtube.com/results?search_query=Seperti+Yang+Kau+Ingini",
7933    },
7934  ];
7935  function addFromBank(item) {
7936    const s = {
7937      id: "song-" + Date.now(),
7938      title: item.title,
7939      originalKey: item.key,
7940      source: item.by + " - " + item.link,
7941      link: item.link,
7942      youtube: "",
7943      lines: [
7944        "Intro :",
7945        "(isi chord di sini)",
7946        "Verse :",
7947        "(tulis chord lalu liriknya)",
7948      ],
7949    };
7950    songs.push(s);
7951    selectedSongId = s.id;
7952    selectedKey = s.originalKey;
7953    saveSongs();
7954    makeButtons();
7955    render();
7956    closeMenu();
7957    openEditor();
7958  }
7959  function renderBank() {
7960    const box = document.getElementById("songBank");
7961    if (!box) return;
7962    box.innerHTML = "";
7963    songBank.forEach((item) => {
7964      const row = document.createElement("div");
7965      row.className = "bankItem";
7966      const grip = document.createElement("span");
7967      grip.className = "bankGrip";
7968      grip.textContent = "☰";
7969      grip.title = "Tarik ke Daftar lagu";
7970      const info = document.createElement("div");
7971      info.className = "bankInfo";
7972      const b = document.createElement("b");
7973      b.textContent = item.title;
7974      const meta = document.createElement("span");
7975      meta.textContent = item.by + " • Nada " + item.key;
7976      const a = document.createElement("a");
7977      a.href = item.link;
7978      a.target = "_blank";
7979      a.rel = "noopener";
7980      a.textContent = "Buka referensi chord";
7981      info.appendChild(b);
7982      info.appendChild(meta);
7983      info.appendChild(a);
7984      const add = document.createElement("button");
7985      add.type = "button";
7986      add.className = "bankAdd";
7987      add.textContent = "Tambah";
7988      add.onclick = () => addFromBank(item);
7989      row.appendChild(grip);
7990      row.appendChild(info);
7991      row.appendChild(add);
7992      enableBankDrag(grip, item);
7993      box.appendChild(row);
7994    });
7995  }
7996
7997  // --- Song Bank: pencarian, lompat lagu, dan halaman database ---
7998  function gotoSong(id) {
7999    if (selectedSongId !== id) stopYt();
8000    var s = songs.find(function (x) {
8001      return x.id === id;
8002    });
8003    if (!s) return;
8004    selectedSongId = id;
8005    selectedKey = s.originalKey;
8006    closeMenu();
8007    closeBankPage();
8008    closeEditor();
8009    render();
8010    var sh = document.getElementById("sheet");
8011    if (sh) sh.scrollIntoView({ behavior: "smooth", block: "start" });
8012  }
8013  function renderSongJump() {
8014    var sel = document.getElementById("songJump");
8015    if (!sel) return;
8016    sel.innerHTML = '<option value="">Pilih lagu…</option>';
8017    songs.forEach(function (s) {
8018      var o = document.createElement("option");
8019      o.value = s.id;
8020      o.textContent = (s.num || "") + ". " + s.title;
8021      sel.appendChild(o);
8022    });
8023    sel.value = "";
8024  }
8025  function renderSearch(q) {
8026    var box = document.getElementById("searchResults");
8027    if (!box) return;
8028    box.innerHTML = "";
8029    q = (q || "").trim().toLowerCase();
8030    if (!q) return;
8031    songs
8032      .filter(function (s) {
8033        return (s.title || "").toLowerCase().indexOf(q) >= 0;
8034      })
8035      .slice(0, 8)
8036      .forEach(function (s) {
8037        var it = document.createElement("div");
8038        it.className = "srItem";
8039        var b = document.createElement("b");
8040        b.textContent = (s.num || "") + ". " + s.title;
8041        var tag = document.createElement("span");
8042        tag.className = "srTag";
8043        tag.textContent = "Buka";
8044        it.appendChild(b);
8045        it.appendChild(tag);
8046        it.onclick = function () {
8047          gotoSong(s.id);
8048        };
8049        box.appendChild(it);
8050      });
8051    songBank
8052      .filter(function (item) {
8053        return (
8054          item.title.toLowerCase().indexOf(q) >= 0 &&
8055          !songs.find(function (s) {
8056            return (s.title || "").toLowerCase() === item.title.toLowerCase();
8057          })
8058        );
8059      })
8060      .slice(0, 6)
8061      .forEach(function (item) {
8062        var it = document.createElement("div");
8063        it.className = "srItem";
8064        var b = document.createElement("b");
8065        b.textContent = item.title;
8066        var tag = document.createElement("span");
8067        tag.className = "srTag";
8068        tag.textContent = "+ Tambah";
8069        it.appendChild(b);
8070        it.appendChild(tag);
8071        it.onclick = function () {
8072          addFromBank(item);
8073        };
8074        box.appendChild(it);
8075      });
8076  }
8077  // ===== Cari lagu online (iTunes + lyrics.ovh) — v48 =====
8078  var _olAudio = null,
8079    _olBtn = null;
8080  function openOnlineSearch() {
8081    closeMenu();
8082    var box = document.getElementById("onlineResults");
8083    if (box) box.innerHTML = "";
8084    var qi = document.getElementById("onlineQuery");
8085    if (qi) qi.value = "";
8086    var pg = document.getElementById("onlinePage");
8087    if (pg) pg.classList.add("open");
8088    if (qi)
8089      setTimeout(function () {
8090        qi.focus();
8091      }, 80);
8092  }
8093  function closeOnlineSearch() {
8094    if (_olAudio) {
8095      try {
8096        _olAudio.pause();
8097      } catch (e) {}
8098    }
8099    if (_olBtn) _olBtn.textContent = "▶";
8100    var pg = document.getElementById("onlinePage");
8101    if (pg) pg.classList.remove("open");
8102  }
8103  function itunesSearch(term, cb) {
8104    var url =
8105      "https://itunes.apple.com/search?media=music&entity=song&limit=24&term=" +
8106      encodeURIComponent(term);
8107    var done = false;
8108    function finish(results, err) {
8109      if (done) return;
8110      done = true;
8111      cb(results, err);
8112    }
8113    // 1) Coba CORS fetch dulu (iTunes kirim Access-Control-Allow-Origin: *)
8114    var ctrl =
8115      typeof AbortController !== "undefined" ? new AbortController() : null;
8116    var to = setTimeout(function () {
8117      if (ctrl) ctrl.abort();
8118    }, 8000);
8119    fetch(url, ctrl ? { signal: ctrl.signal } : {})
8120      .then(function (r) {
8121        return r && r.ok ? r.json() : null;
8122      })
8123      .then(function (j) {
8124        clearTimeout(to);
8125        if (j && j.results) finish(j.results, null);
8126        else jsonp();
8127      })
8128      .catch(function () {
8129        clearTimeout(to);
8130        jsonp();
8131      });
8132    // 2) Fallback JSONP kalau fetch gagal/diblokir
8133    function jsonp() {
8134      if (done) return;
8135      var cbName = "__itunesCb" + Date.now();
8136      var sc = document.createElement("script");
8137      var timer = setTimeout(function () {
8138        cleanup();
8139        finish(null, "timeout");
8140      }, 10000);
8141      function cleanup() {
8142        clearTimeout(timer);
8143        try {
8144          delete window[cbName];
8145        } catch (e) {
8146          window[cbName] = undefined;
8147        }
8148        if (sc.parentNode) sc.parentNode.removeChild(sc);
8149      }
8150      window[cbName] = function (data) {
8151        cleanup();
8152        finish(data && data.results ? data.results : [], null);
8153      };
8154      sc.onerror = function () {
8155        cleanup();
8156        finish(null, "network");
8157      };
8158      sc.src = url + "&callback=" + cbName;
8159      document.body.appendChild(sc);
8160    }
8161  }
8162  function fetchLyrics(artist, title) {
8163    return new Promise(function (resolve) {
8164      var url =
8165        "https://api.lyrics.ovh/v1/" +
8166        encodeURIComponent(artist) +
8167        "/" +
8168        encodeURIComponent(title);
8169      var ctrl =
8170        typeof AbortController !== "undefined" ? new AbortController() : null;
8171      var to = setTimeout(function () {
8172        if (ctrl) ctrl.abort();
8173        resolve(null);
8174      }, 8000);
8175      fetch(url, ctrl ? { signal: ctrl.signal }
8175 : {})
8176        .then(function (r) {
8177          return r && r.ok ? r.json() : null;
8178        })
8179        .then(function (j) {
8180          clearTimeout(to);
8181          resolve(j && j.lyrics ? j.lyrics : null);
8182        })
8183        .catch(function () {
8184          clearTimeout(to);
8185          resolve(null);
8186        });
8187    });
8188  }
8189  function olPlay(url, btn) {
8190    if (!url) {
8191      toast("Pratinjau tidak tersedia untuk lagu ini.", "info");
8192      return;
8193    }
8194    if (_olAudio && _olBtn === btn) {
8195      if (_olAudio.paused) {
8196        _olAudio.play();
8197        btn.textContent = "⏸";
8198      } else {
8199        _olAudio.pause();
8200        btn.textContent = "▶";
8201      }
8202      return;
8203    }
8204    if (_olAudio) {
8205      try {
8206        _olAudio.pause();
8207      } catch (e) {}
8208      if (_olBtn) _olBtn.textContent = "▶";
8209    }
8210    _olAudio = new Audio(url);
8211    _olBtn = btn;
8212    _olAudio.onended = function () {
8213      btn.textContent = "▶";
8214    };
8215    var p = _olAudio.play();
8216    if (p && p.then)
8217      p.then(function () {
8218        btn.textContent = "⏸";
8219      }).catch(function () {
8220        toast("Gagal memutar pratinjau.", "error");
8221      });
8222    else btn.textContent = "⏸";
8223  }
8224  function olAddToBank(item, btn) {
8225    var title =
8226      (item.trackName || "Lagu") +
8227      (item.artistName ? " (" + item.artistName + ")" : "");
8228    if (btn) {
8229      btn.disabled = true;
8230      btn.textContent = "Menambahkan...";
8231    }
8232    var master = {
8233      bankId: "bank-" + Date.now() + "-" + Math.floor(Math.random() * 100000),
8234      title: title,
8235      num: "",
8236      originalKey: "C",
8237      source:
8238        "iTunes: " +
8239        (item.artistName || "") +
8240        (item.collectionName ? " — " + item.collectionName : ""),
8241      youtube: "",
8242      lines: ["(Lirik sedang diambil...)"],
8243      cat: "other",
8244    };
8245    bankSongs.push(master);
8246    saveBank();
8247    fetchLyrics(item.artistName || "", item.trackName || "").then(
8248      function (lyr) {
8249        var i = bankSongs.findIndex(function (x) {
8250          return x && x.bankId === master.bankId;
8251        });
8252        if (i < 0) return;
8253        if (lyr) {
8254          var lines = lyr.split(/\r?\n/).map(function (x) {
8255            return x.replace(/\s+$/, "");
8256          });
8257          while (lines.length && !lines[0].trim()) lines.shift();
8258          while (lines.length && !lines[lines.length - 1].trim()) lines.pop();
8259          bankSongs[i].lines = lines.length
8260            ? lines
8261            : ["(Lirik kosong — isi manual)"];
8262        } else {
8263          bankSongs[i].lines = [
8264            "Bait :",
8265            "(Lirik tidak ditemukan otomatis — isi manual di sini)",
8266          ];
8267        }
8268        saveBank();
8269        try {
8270          backupMaybe("bank");
8271        } catch (e) {}
8272        refreshLibrary();
8273        toast(
8274          "'" + title + "' ditambahkan ke Bank (folder Lainnya).",
8275          "success",
8276        );
8277        if (btn) {
8278          btn.textContent = "Ditambahkan";
8279        }
8280      },
8281    );
8282  }
8283  function renderOnlineResults(list) {
8284    var box = document.getElementById("onlineResults");
8285    if (!box) return;
8286    box.innerHTML = "";
8287    if (list === null) {
8288      var e0 = document.createElement("p");
8289      e0.className = "small bankEmpty";
8290      e0.textContent =
8291        "Gagal terhubung ke sumber pencarian. Periksa koneksi lalu coba lagi.";
8292      box.appendChild(e0);
8293      return;
8294    }
8295    if (!list.length) {
8296      var e1 = document.createElement("p");
8297      e1.className = "small bankEmpty";
8298      e1.textContent = "Tidak ada hasil.";
8299      box.appendChild(e1);
8300      return;
8301    }
8302    list.forEach(function (item) {
8303      var card = document.createElement("div");
8304      card.className = "olCard";
8305      if (item.artworkUrl100) {
8306        var img = document.createElement("img");
8307        img.className = "olArt";
8308        img.src = item.artworkUrl100;
8309        img.alt = "";
8310        img.loading = "lazy";
8311        card.appendChild(img);
8312      }
8313      var info = document.createElement("div");
8314      info.className = "olInfo scInfo";
8315      var t = document.createElement("b");
8316      t.textContent = item.trackName || "(Tanpa judul)";
8317      var m = document.createElement("span");
8318      m.textContent =
8319        (item.artistName || "") +
8320        (item.collectionName ? " • " + item.collectionName : "");
8321      info.appendChild(t);
8322      info.appendChild(m);
8323      var btns = document.createElement("div");
8324      btns.className = "scBtns";
8325      var play = document.createElement("button");
8326      play.type = "button";
8327      play.className = "scBtn";
8328      play.textContent = "▶";
8329      play.onclick = function () {
8330        olPlay(item.previewUrl, play);
8331      };
8332      var add = document.createElement("button");
8333      add.type = "button";
8334      add.className = "scBtn primary";
8335      add.textContent = "+ Bank";
8336      add.onclick = function () {
8337        olAddToBank(item, add);
8338      };
8339      btns.appendChild(play);
8340      btns.appendChild(add);
8341      card.appendChild(info);
8342      card.appendChild(btns);
8343      box.appendChild(card);
8344    });
8345  }
8346  function onlineSearchGo() {
8347    var qi = document.getElementById("onlineQuery");
8348    if (!qi) return;
8349    var term = (qi.value || "").trim();
8350    var box = document.getElementById("onlineResults");
8351    if (!term) {
8352      if (box) box.innerHTML = "";
8353      return;
8354    }
8355    if (box) box.innerHTML = "<p class='small bankEmpty'>Mencari...</p>";
8356    itunesSearch(term, function (results, err) {
8357      if (err) {
8358        renderOnlineResults(null);
8359        return;
8360      }
8361      renderOnlineResults(results);
8362    });
8363  }
8364  function addSongFromBank() {
8365    registerNewBankSong();
8366  }
8367  function openBankPage() {
8368    // v5.8: BUKA HALAMAN DULU, render isinya belakangan di dalam try/catch.
8369    // Dulu satu error kecil di renderBankPage() membuat baris .add("open")
8370    // tidak pernah jalan, jadi Song Bank sama sekali tidak bisa dibuka.
8371    try {
8372      closeMenu();
8373    } catch (e) {}
8374    currentBankFolder = null;
8375    var si = document.getElementById("bankPageSearch");
8376    if (si) si.value = "";
8377    var pg = document.getElementById("bankPage");
8378    if (pg) pg.classList.add("open");
8379    try {
8380      renderBankPage("");
8381    } catch (e) {
8382      window.PNWDiag.push({
8383        feature: "songbank.render",
8384        error: String((e && e.message) || e),
8385        at: Date.now(),
8386      });
8387      if (typeof toast === "function")
8388        toast("Song Bank terbuka, tapi daftar gagal dimuat.", "info");
8389    }
8390  }
8391  function closeBankPage() {
8392    var pg = document.getElementById("bankPage");
8393    if (pg) pg.classList.remove("open");
8394  }
8395  // ===== Bank lagu berbentuk folder (v47) =====
8396  var BANK_CATS = [
8397    { key: "worship", label: "Worship", img: "./img/worship.jpg" },
8398    { key: "praise", label: "Praise", img: "./img/praise.jpg" },
8399    { key: "other", label: "Lainnya", img: "" },
8400  ];
8401  var currentBankFolder = null;
8402  function bankCatOf(s) {
8403    var c = s && s.cat;
8404    return c === "worship" || c === "praise" ? c : "other";
8405  }
8406  function bankCatLabel(key) {
8407    for (var i = 0; i < BANK_CATS.length; i++)
8408      if (BANK_CATS[i].key === key) return BANK_CATS[i].label;
8409    return "Lainnya";
8410  }
8411  function setBankCat(master, cat) {
8412    if (!isAdmin) {
8413      toast("Hanya admin yang bisa memindahkan folder.", "error");
8414      return;
8415    }
8416    var i = -1;
8417    for (var k = 0; k < bankSongs.length; k++)
8418      if (bankSongs[k] && bankSongs[k].bankId === master.bankId) {
8419        i = k;
8420        break;
8421      }
8422    if (i < 0) return;
8423    bankSongs[i].cat = cat === "worship" || cat === "praise" ? cat : "other";
8424    master.cat = bankSongs[i].cat;
8425    saveBank();
8426    try {
8427      backupMaybe("bank");
8428    } catch (e) {}
8429    var si = document.getElementById("bankPageSearch");
8430    renderBankPage(si ? si.value : "");
8431    toast(
8432      "Dipindahkan ke folder " + bankCatLabel(bankSongs[i].cat) + ".",
8433      "success",
8434    );
8435  }
8436  var SEC_ALIGN_KEY = "pnwSecAlign";
8437  function applySecAlign(v) {
8438    var b = document.body;
8439    b.classList.remove("secAlignLeft", "secAlignCenter", "secAlignRight");
8440    b.classList.add(
8441      v === "center"
8442        ? "secAlignCenter"
8443        : v === "right"
8444          ? "secAlignRight"
8445          : "secAlignLeft",
8446    );
8447    var map = { left: "secAlignL", center: "secAlignC", right: "secAlignR" };
8448    ["secAlignL", "secAlignC", "secAlignR"].forEach(function (id) {
8449      var el = document.getElementById(id);
8450      if (el) el.classList.toggle("isOn", id === map[v]);
8451    });
8452  }
8453  function setSecAlign(v) {
8454    try {
8455      localStorage.setItem(SEC_ALIGN_KEY, v);
8456    } catch (e) {}
8457    applySecAlign(v);
8458  }
8459  function initSecAlign() {
8460    var v = "left";
8461    try {
8462      v = localStorage.getItem(SEC_ALIGN_KEY) || "left";
8463    } catch (e) {}
8464    applySecAlign(v);
8465    var L = document.getElementById("secAlignL");
8466    var C = document.getElementById("secAlignC");
8467    var R = document.getElementById("secAlignR");
8468    if (L)
8469      L.onclick = function () {
8470        setSecAlign("left");
8471      };
8472    if (C)
8473      C.onclick = function () {
8474        setSecAlign("center");
8475      };
8476    if (R)
8477      R.onclick = function () {
8478        setSecAlign("right");
8479      };
8480  }
8481  var bankMoveMode = false;
8482  var bankMoveSel = {};
8483  function bankRerender() {
8484    var si = document.getElementById("bankPageSearch");
8485    renderBankPage(si ? si.value : "");
8486  }
8487  function moveSelectedBank(cat) {
8488    if (!isAdmin) {
8489      toast("Hanya admin yang bisa memindahkan folder.", "error");
8490      return;
8491    }
8492    var ids = Object.keys(bankMoveSel).filter(function (k) {
8493      return bankMoveSel[k];
8494    });
8495    if (!ids.length) {
8496      toast("Pilih dulu lagu yang mau dipindahkan.", "info");
8497      return;
8498    }
8499    if (
8500      !confirm(
8501        "Pindahkan " +
8502          ids.length +
8503          " lagu ke folder " +
8504          bankCatLabel(cat) +
8505          "?\n\nLagu TIDAK dihapus, hanya berpindah folder.",
8506      )
8507    )
8508      return;
8509    try {
8510      backupForce("sebelum pindah folder");
8511    } catch (e) {}
8512    var n = 0;
8513    for (var k = 0; k < bankSongs.length; k++) {
8514      var b = bankSongs[k];
8515      if (!b || ids.indexOf(String(b.bankId)) < 0) continue;
8516      b.cat = cat === "worship" || cat === "praise" ? cat : "other";
8517      n++;
8518    }
8519    if (!n) return;
8520    saveBank();
8521    try {
8522      backupMaybe("bank");
8523    } catch (e) {}
8524    bankMoveSel = {};
8525    bankMoveMode = false;
8526    bankRerender();
8527    toast(
8528      n + " lagu dipindahkan ke folder " + bankCatLabel(cat) + ".",
8529      "success",
8530    );
8531  }
8532  function buildBankMoveBar() {
8533    var bar = document.createElement("div");
8534    bar.className = "bankMoveBar";
8535    var t = document.createElement("button");
8536    t.type = "button";
8537    t.className = "bmBtn" + (bankMoveMode ? " isOn" : "");
8538    t.textContent = bankMoveMode ? "Batal" : "Pindahkan lagu";
8539    t.onclick = function () {
8540      bankMoveMode = !bankMoveMode;
8541      bankMoveSel = {};
8542      bankRerender();
8543    };
8544    bar.appendChild(t);
8545    if (bankMoveMode) {
8546      var lb = document.createElement("span");
8547      lb.className = "bmLbl";
8548      lb.textContent = "Pindahkan ke";
8549      bar.appendChild(lb);
8550      BANK_CATS.forEach(function (c) {
8551        if (c.key === currentBankFolder) return;
8552        var b = document.createElement("button");
8553        b.type = "button";
8554        b.className = "bmBtn go";
8555        b.textContent = c.label;
8556        b.onclick = function () {
8557          moveSelectedBank(c.key);
8558        };
8559        bar.appendChild(b);
8560      });
8561    }
8562    return bar;
8563  }
8564  function buildBankCard(s) {
8565    var setSong = songs.find(function (x) {
8566      return x.bankId === s.bankId;
8567    });
8568    var inSet = !!setSong;
8569    var card = document.createElement("div");
8570    card.className = "songCard";
8571    if (bankMoveMode && isAdmin) {
8572      card.classList.add("selectable");
8573      var chk = document.createElement("input");
8574      chk.type = "checkbox";
8575      chk.className = "bmChk";
8576      chk.checked = !!bankMoveSel[s.bankId];
8577      chk.onchange = function () {
8578        bankMoveSel[s.bankId] = chk.checked;
8579      };
8580      card.appendChild(chk);
8581    }
8582    var info = document.createElement("div");
8583    info.className = "scInfo";
8584    var t = document.createElement("b");
8585    t.textContent = s.title;
8586    if (inSet) {
8587      var tag = document.createElement("span");
8588      tag.className = "inSet";
8589      tag.textContent = " · di daftar";
8590      t.appendChild(tag);
8591    }
8592    var meta = document.createElement("span");
8593    meta.textContent =
8594      "Nada dasar " + s.originalKey + (s.source ? " • " + s.source : "");
8595    info.appendChild(t);
8596    info.appendChild(meta);
8597    var btns = document.createElement("div");
8598    btns.className = "scNav";
8599    var pull = document.createElement("button");
8600    pull.type = "button";
8601    pull.className = "scBtn primary";
8602    pull.textContent = inSet ? "Buka" : "Tarik";
8603    pull.onclick = function () {
8604      if (inSet) gotoSong(setSong.id);
8605      else pullFromBank(s);
8606    };
8607    var edit = document.createElement("button");
8608    edit.type = "button";
8609    edit.className = "scBtn";
8610    edit.textContent = "Ubah";
8611    edit.onclick = function () {
8612      editBankSong(s);
8613    };
8614    btns.appendChild(pull);
8615    btns.appendChild(edit);
8616    if (isAdmin) {
8617      var sel = document.createElement("select");
8618      sel.className = "scBtn bankCatSel";
8619      BANK_CATS.forEach(function (c) {
8620        var o = document.createElement("option");
8621        o.value = c.key;
8622        o.textContent = c.label;
8623        if (bankCatOf(s) === c.key) o.selected = true;
8624        sel.appendChild(o);
8625      });
8626      sel.onchange = function () {
8627        setBankCat(s, sel.value);
8628      };
8629      btns.appendChild(sel);
8630      var del = document.createElement("button");
8631      del.type = "button";
8632      del.className = "scBtn danger";
8633      del.textContent = "Hapus";
8634      del.onclick = function () {
8635        removeFromBank(s);
8636      };
8637      btns.appendChild(del);
8638    }
8639    card.appendChild(info);
8640    card.appendChild(btns);
8641    return card;
8642  }
8643  function buildFolderCard(c) {
8644    var n = bankSongs.filter(function (s) {
8645      return bankCatOf(s) === c.key;
8646    }).length;
8647    var f = document.createElement("button");
8648    f.type = "button";
8649    f.className = "folderCard" + (c.img ? "" : " noPhoto");
8650    if (c.img) {
8651      var im = document.createElement("img");
8652      im.className = "fcImg";
8653      im.src = c.img;
8654      im.alt = "";
8655      im.loading = "lazy";
8656      im.decoding = "async";
8657      f.appendChild(im);
8658    }
8659    var sh = document.createElement("span");
8660    sh.className = "fcShade";
8661    f.appendChild(sh);
8662    var meta = document.createElement("span");
8663    meta.className = "fcMeta";
8664    var nm = document.createElement("b");
8665    nm.className = "fcName";
8666    nm.textContent = c.label;
8667    var ct = document.createElement("span");
8668    ct.className = "fcCount";
8669    ct.textContent = n + " lagu";
8670    meta.appendChild(nm);
8671    meta.appendChild(ct);
8672    f.appendChild(meta);
8673    f.addEventListener(
8674      "touchstart",
8675      function () {
8676        f.classList.add("isOn");
8677      },
8678      { passive: true },
8679    );
8680    f.addEventListener("touchend", function () {
8681      setTimeout(function () {
8682        f.classList.remove("isOn");
8683      }, 450);
8684    });
8685    f.onclick = function () {
8686      currentBankFolder = c.key;
8687      renderBankPage("");
8688    };
8689    return f;
8690  }
8691  function renderBankEmpty(box, msg) {
8692    var e = document.createElement("p");
8693    e.className = "small bankEmpty";
8694    e.textContent = msg;
8695    box.appendChild(e);
8696  }
8697  function renderBankCrumb(box, label, isSearch) {
8698    var bar = document.createElement("div");
8699    bar.className = "bankCrumb";
8700    if (isSearch) {
8701      var hint = document.createElement("span");
8702      hint.className = "small";
8703      hint.textContent = "Hasil pencarian di semua folder";
8704      bar.appendChild(hint);
8705    } else {
8706      var back = document.createElement("button");
8707      back.type = "button";
8708      back.className = "scBtn";
8709      back.textContent = "← Semua folder";
8710      back.onclick = function () {
8711        currentBankFolder = null;
8712        bankMoveMode = false;
8713        bankMoveSel = {};
8714        renderBankPage("");
8715      };
8716      bar.appendChild(back);
8717      var lb = document.createElement("b");
8718      lb.textContent = " " + label;
8719      bar.appendChild(lb);
8720    }
8721    box.appendChild(bar);
8722  }
8723  function renderBankPage(q) {
8724    var box = document.getElementById("bankPageList");
8725    if (!box) return;
8726    box.innerHTML = "";
8727    q = (q || "").trim().toLowerCase();
8728    var emptyMsg =
8729      "Bank lagu masih kosong. Tekan tombol Daftarkan lagu untuk menambah.";
8730    if (q) {
8731      var results = bankSongs.filter(function (s) {
8732        return s && (s.title || "").toLowerCase().indexOf(q) >= 0;
8733      });
8734      renderBankCrumb(box, null, true);
8735      if (!results.length) {
8736        renderBankEmpty(
8737          box,
8738          bankSongs.length ? "Tidak ada lagu yang cocok." : emptyMsg,
8739        );
8740        return;
8741      }
8742      results.forEach(function (s) {
8743        box.appendChild(buildBankCard(s));
8744      });
8745      if (window.PNWMotion && window.PNWMotion.stagger) window.PNWMotion.stagger(box);
8746      return;
8747    }
8748    if (!currentBankFolder) {
8749      if (!bankSongs.length) {
8750        renderBankEmpty(box, emptyMsg);
8751        return;
8752      }
8753      BANK_CATS.forEach(function (c) {
8754        box.appendChild(buildFolderCard(c));
8755      });
8756      if (window.PNWMotion && window.PNWMotion.stagger) window.PNWMotion.stagger(box);
8757      return;
8758    }
8759    renderBankCrumb(box, bankCatLabel(currentBankFolder), false);
8760    if (isAdmin) box.appendChild(buildBankMoveBar());
8761    var inFolder = bankSongs.filter(function (s) {
8762      return bankCatOf(s) === currentBankFolder;
8763    });
8764    if (!inFolder.length) {
8765      renderBankEmpty(box, "Folder ini masih kosong.");
8766      return;
8767    }
8768    inFolder.forEach(function (s) {
8769      box.appendChild(buildBankCard(s));
8770    });
8771    if (window.PNWMotion && window.PNWMotion.stagger) window.PNWMotion.stagger(box);
8772  }
8773  function pullFromBank(master) {
8774    if (
8775      songs.find(function (x) {
8776        return x.bankId === master.bankId;
8777      })
8778    ) {
8779      toast("Lagu sudah ada di daftar.", "info");
8780      return;
8781    }
8782    var s = {
8783      id: "song-" + Date.now(),
8784      bankId: master.bankId,
8785      num: master.num || "",
8786      title: master.title,
8787      originalKey: master.originalKey,
8788      source: master.source || "",
8789      youtube: master.youtube || "",
8790      lines: (master.lines || []).slice(),
8791    };
8792    songs.push(s);
8793    selectedSongId = s.id;
8794    selectedKey = s.originalKey;
8795    saveSongs();
8796    makeButtons();
8797    render();
8798    refreshLibrary();
8799    toast("'" + master.title + "' ditarik ke daftar lagu.", "success");
8800  }
8801  function editBankSong(master) {
8802    var ex = songs.find(function (x) {
8803      return x.bankId === master.bankId;
8804    });
8805    closeBankPage();
8806    if (ex) {
8807      gotoSong(ex.id);
8808      setTimeout(openEditor, 90);
8809    } else {
8810      pullFromBank(master);
8811      setTimeout(openEditor, 140);
8812    }
8813  }
8814  function removeFromBank(master) {
8815    if (!isAdmin) {
8816      toast("Hanya admin yang bisa menghapus dari bank.", "error");
8817      return;
8818    }
8819    if (
8820      !confirm(
8821        "Hapus '" + master.title + "' dari BANK lagu? Tindakan ini permanen.",
8822      )
8823    )
8824      return;
8825    bankSongs = bankSongs.filter(function (x) {
8826      return x.bankId !== master.bankId;
8827    });
8828    saveBank();
8829    var si = document.getElementById("bankPageSearch");
8830    renderBankPage(si ? si.value : "");
8831    toast("Lagu dihapus dari bank.", "info");
8832  }
8833  function registerNewBankSong() {
8834    var master = {
8835      bankId: "bank-" + Date.now() + "-" + Math.floor(Math.random() * 100000),
8836      title: "Lagu Baru",
8837      num: "",
8838      originalKey: "C",
8839      source: "",
8840      youtube: "",
8841      lines: [
8842        "Intro :",
8843        "[C] [G] [Am] [F]",
8844        "Bait :",
8845        "[C]Tulis lirik di sini, [G]ganti chord sebelum suku kata",
8846        "Reff :",
8847        "[F]Tulis lirik [G]reff di [C]sini",
8848      ],
8849    };
8850    bankSongs.push(master);
8851    saveBank();
8852    toast("Lagu baru terdaftar di bank.", "success");
8853    pullFromBank(master);
8854    setTimeout(openEditor, 140);
8855    closeBankPage();
8856  }
8857  /* ================= v62: semitone + rekaman ================= */
8858  var SEMI_MAP = {
8859    C: 0,
8860    "B#": 0,
8861    "C#": 1,
8862    Db: 1,
8863    Db: 1,
8864    D: 2,
8865    "D#": 3,
8866    Eb: 3,
8867    E: 4,
8868    Fb: 4,
8869    "E#": 5,
8870    F: 5,
8871    "F#": 6,
8872    Gb: 6,
8873    G: 7,
8874    "G#": 8,
8875    Ab: 8,
8876    A: 9,
8877    "A#": 10,
8878    Bb: 10,
8879    B: 11,
8880    Cb: 11,
8881  };
8882  // v63: kenali penolakan izin Firebase supaya pesannya jujur
8883  function fbDenied(err) {
8884    var s = "";
8885    try {
8886      s = ((err && (err.code || err.message)) || String(err)).toLowerCase();
8887    } catch (e) {}
8888    return s.indexOf("permission") >= 0 || s.indexOf("denied") >= 0;
8889  }
8890  function semiOf(k) {
8891    var s = String(k == null ? "" : k).trim();
8892    if (SEMI_MAP[s] != null) return SEMI_MAP[s];
8893    var m = s.match(/^([A-Ga-g])([#b]?)/);
8894    if (!m) return 0;
8895    var key = m[1].toUpperCase() + (m[2] || "");
8896    return SEMI_MAP[key] != null ? SEMI_MAP[key] : 0;
8897  }
8898  function v62Song() {
8899    for (var i = 0; i < songs.length; i++) {
8900      if (songs[i].id === selectedSongId) return songs[i];
8901    }
8902    return null;
8903  }
8904  function updateSemiUi(target, orig) {
8905    var el = document.getElementById("semiVal");
8906    if (!el) return;
8907    var d = (((semiOf(target) - semiOf(orig)) % 12) + 12) % 12;
8908    if (d > 6) d -= 12;
8909    el.classList.remove("up", "down");
8910    if (d > 0) {
8911      el.textContent = "+" + d;
8912      el.classList.add("up");
8913    } else if (d < 0) {
8914      el.textContent = String(d);
8915      el.classList.add("down");
8916    } else {
8917      el.textContent = "0 asli";
8918    }
8919    var rs = document.getElementById("semiReset");
8920    if (rs) rs.disabled = d === 0;
8921  }
8922  function semiShift(step) {
8923    var song = v62Song();
8924    if (!song) {
8925      toast("Pilih lagu dulu.", "info");
8926      return;
8927    }
8928    var cur = selectedKey || song.originalKey || "C";
8929    selectedKey = keyList[(((semiOf(cur) + step) % 12) + 12) % 12];
8930    render();
8931    updateSemiUi(selectedKey, song.originalKey);
8932    var el = document.getElementById("semiVal");
8933    if (el) {
8934      el.classList.remove("bump");
8935      void el.offsetWidth;
8936      el.classList.add("bump");
8937    }
8938  }
8939  function semiReset() {
8940    var song = v62Song();
8941    if (!song) return;
8942    selectedKey = song.originalKey || "C";
8943    render();
8944    updateSemiUi(selectedKey, song.originalKey);
8945  }
8946  // --- v63: nada acuan (pitch pipe) lewat Web Audio ---
8947  var __ac = null;
8948  function playRefTone(key) {
8949    try {
8950      var AC = window.AudioContext || window.webkitAudioContext;
8951      if (!AC) {
8952        toast("Peramban ini tidak mendukung nada acuan.", "error");
8953        return;
8954      }
8955      if (!__ac) __ac = new AC();
8956      if (__ac.state === "suspended") __ac.resume();
8957      var base = 261.63 * Math.pow(2, semiOf(key) / 12);
8958      var t0 = __ac.currentTime;
8959      [base, base * 2].forEach(function (f, i) {
8960        var o = __ac.createOscillator();
8961        var g = __ac.createGain();
8962        o.type = i ? "sine" : "triangle";
8963        o.frequency.value = f;
8964        g.gain.setValueAtTime(0.0001, t0);
8965        g.gain.exponentialRampToValueAtTime(i ? 0.06 : 0.16, t0 + 0.03);
8966        g.gain.exponentialRampToValueAtTime(0.0001, t0 + 1.5);
8967        o.connect(g);
8968        g.connect(__ac.destination);
8969        o.start(t0);
8970        o.stop(t0 + 1.6);
8971      });
8972    } catch (e) {
8973      toast("Nada acuan gagal dibunyikan.", "error");
8974    }
8975  }
8976  function semiToneRef() {
8977    var song = v62Song();
8978    if (!song) {
8979      toast("Pilih lagu dulu.", "info");
8980      return;
8981    }
8982    var k = selectedKey || song.originalKey || "C";
8983    playRefTone(k);
8984    toast("Nada acuan: " + k, "info", 1800);
8985  }
8986
8987  // --- v63: kecepatan pemutar YouTube ---
8988  var YT_RATES = [0.5, 0.75, 1, 1.25, 1.5];
8989  var __ytRate = 1;
8990  function ytRateSet(r) {
8991    __ytRate = r;
8992    var el = document.getElementById("ytRateVal");
8993    if (el) el.textContent = String(r) + "\u00d7";
8994    try {
8995      if (ytPlayer && ytPlayer.setPlaybackRate) {
8996        ytPlayer.setPlaybackRate(r);
8997      }
8998    } catch (e) {}
8999  }
9000  function ytRateStep(d) {
9001    var i = YT_RATES.indexOf(__ytRate);
9002    if (i < 0) i = 2;
9003    i = Math.min(YT_RATES.length - 1, Math.max(0, i + d));
9004    ytRateSet(YT_RATES[i]);
9005  }
9006
9007  function openRecorderForCurrent() {
9008    if (!window.PNWRec) {
9009      toast("Fitur rekaman belum siap.", "error");
9010      return;
9011    }
9012    var song = v62Song();
9013    if (!song) {
9014      toast("Pilih lagu dulu untuk menyimpan rekaman.", "info");
9015      return;
9016    }
9017    window.PNWRec.open(song.id, song.title);
9018  }
9019  var __v62Ready = false;
9020  function initV62() {
9021    if (__v62Ready) return;
9022    __v62Ready = true;
9023    window.__pnwSemiUi = updateSemiUi;
9024    window.pnwToast = function (m, t, ms) {
9025      try {
9026        toast(m, t, ms);
9027      } catch (e) {}
9028    };
9029    var up = document.getElementById("semiUp");
9030    var dn = document.getElementById("semiDown");
9031    var rs = document.getElementById("semiReset");
9032    var rb = document.getElementById("recBtn");
9033    if (up)
9034      up.onclick = function () {
9035        semiShift(1);
9036      };
9037    if (dn)
9038      dn.onclick = function () {
9039        semiShift(-1);
9040      };
9041    if (rs) rs.onclick = semiReset;
9042    var st = document.getElementById("semiTone");
9043    if (st) st.onclick = semiToneRef;
9044    var ru = document.getElementById("ytRateUp");
9045    if (ru)
9046      ru.onclick = function () {
9047        ytRateStep(1);
9048      };
9049    var rd2 = document.getElementById("ytRateDown");
9050    if (rd2)
9051      rd2.onclick = function () {
9052        ytRateStep(-1);
9053      };
9054    if (rb) rb.onclick = openRecorderForCurrent;
9055    document.addEventListener("keydown", function (e) {
9056      if (e.ctrlKey || e.metaKey || e.altKey) return;
9057      var t = e.target || {};
9058      var tag = (t.tagName || "").toUpperCase();
9059      if (tag === "INPUT" || tag === "TEXTAREA" || t.isContentEditable) return;
9060      if (e.key === "+" || e.key === "=") {
9061        e.preventDefault();
9062        semiShift(1);
9063      } else if (e.key === "-" || e.key === "_") {
9064        e.preventDefault();
9065        semiShift(-1);
9066      }
9067    });
9068  }
9069  /* =============== akhir blok v62 =============== */
9070  function refreshLibrary() {
9071    renderSongJump();
9072    var ss = document.getElementById("songSearch");
9073    if (ss && ss.value) renderSearch(ss.value);
9074    var bp = document.getElementById("bankPage");
9075    if (bp && bp.classList.contains("open")) {
9076      var si = document.getElementById("bankPageSearch");
9077      renderBankPage(si ? si.value : "");
9078    }
9079  }
9080  // --- Metronome (bantuan musik) ---
9081  var metroCtx = null,
9082    metroTimer = 0,
9083    bpm = 80,
9084    bpmStep = 5,
9085    metroBeat = 0,
9086    metroOn = false,
9087    tapTimes = [];
9088  function ensureCtx() {
9089    if (!metroCtx) {
9090      try {
9091        metroCtx = new (window.AudioContext || window.webkitAudioContext)();
9092      } catch (e) {}
9093    }
9094    return metroCtx;
9095  }
9096  function metroClick() {
9097    var ctx = ensureCtx();
9098    if (!ctx) return;
9099    var accent = metroBeat % 4 === 0;
9100    var o = ctx.createOscillator(),
9101      g = ctx.createGain();
9102    o.frequency.value = accent ? 1600 : 1000;
9103    o.connect(g);
9104    g.connect(ctx.destination);
9105    var t = ctx.currentTime;
9106    g.gain.setValueAtTime(0.0001, t);
9107    g.gain.exponentialRampToValueAtTime(accent ? 0.7 : 0.35, t + 0.001);
9108    g.gain.exponentialRampToValueAtTime(0.0001, t + 0.05);
9109    o.start(t);
9110    o.stop(t + 0.06);
9111    metroBeat = (metroBeat + 1) % 4;
9112  }
9113  function startMetro() {
9114    if (metroOn) return;
9115    var ctx = ensureCtx();
9116    if (ctx && ctx.state === "suspended") {
9117      try {
9118        ctx.resume();
9119      } catch (e) {}
9120    }
9121    metroOn = true;
9122    metroBeat = 0;
9123    var b = document.getElementById("metroToggle");
9124    if (b) {
9125      b.textContent = "Stop";
9126      b.classList.add("active");
9127    }
9128    metroClick();
9129    metroTimer = setInterval(metroClick, 60000 / bpm);
9130  }
9131  function stopMetro() {
9132    metroOn = false;
9133    if (metroTimer) {
9134      clearInterval(metroTimer);
9135      metroTimer = 0;
9136    }
9137    var b = document.getElementById("metroToggle");
9138    if (b) {
9139      b.textContent = "Mulai";
9140      b.classList.remove("active");
9141    }
9142  }
9143  function toggleMetro() {
9144    metroOn ? stopMetro() : startMetro();
9145  }
9146  function fmtBpm(v) {
9147    return Number.isInteger(v) ? String(v) : String(Math.round(v * 100) / 100);
9148  }
9149  function setBpm(v) {
9150    v = Number(v) || 0;
9151    bpm = Math.min(240, Math.max(30, Math.round(v * 100) / 100));
9152    var el = document.getElementById("bpmVal");
9153    if (el) el.textContent = fmtBpm(bpm);
9154    if (metroOn) {
9155      clearInterval(metroTimer);
9156      metroTimer = setInterval(metroClick, 60000 / bpm);
9157    }
9158    try {
9159      localStorage.setItem("ptBpm", String(bpm));
9160    } catch (e) {}
9161  }
9162  function editBpm() {
9163    var ans = window.prompt(
9164      "Masukkan BPM (30-240, boleh koma, mis. 90,5):",
9165      fmtBpm(bpm),
9166    );
9167    if (ans == null) return;
9168    var n = parseFloat(String(ans).replace(",", ".").trim());
9169    if (!isNaN(n)) setBpm(n);
9170  }
9171  function setStep(s) {
9172    bpmStep = s === 1 ? 1 : 5;
9173    var b1 = document.getElementById("step1"),
9174      b5 = document.getElementById("step5");
9175    if (b1) b1.classList.toggle("on", bpmStep === 1);
9176    if (b5) b5.classList.toggle("on", bpmStep === 5);
9177    try {
9178      localStorage.setItem("ptStep", String(bpmStep));
9179    } catch (e) {}
9180  }
9181  function tapTempo() {
9182    var now = Date.now();
9183    tapTimes.push(now);
9184    tapTimes = tapTimes.filter(function (t) {
9185      return now - t < 3000;
9186    });
9187    if (tapTimes.length >= 2) {
9188      var iv = [];
9189      for (var i = 1; i < tapTimes.length; i++)
9190        iv.push(tapTimes[i] - tapTimes[i - 1]);
9191      var avg =
9192        iv.reduce(function (a, b) {
9193          return a + b;
9194        }, 0) / iv.length;
9195      if (avg > 0) setBpm(Math.round(60000 / avg));
9196    }
9197  }
9198  // --- Bantuan capo untuk gitar ---
9199  // --- Geser untuk mengubah urutan Daftar lagu ---
9200  let dragRow = null,
9201    pendingRow = null,
9202    pendStartX = 0,
9203    pendStartY = 0,
9204    DRAG_THRESHOLD = 8,
9205    dragGhostEl = null,
9206    dragStartOrder = "",
9207    dragGX = 0,
9208    dragGY = 0,
9209    ghostOX = 0,
9210    ghostOY = 0,
9211    rowRaf = 0,
9212    activePid = null,
9213    dropTargetRow = null,
9214    dropAfter = false;
9215  function enableRowDrag(handle, row) {
9216    handle.addEventListener("pointerdown", (e) => {
9217      if (e.button && e.button !== 0) return;
9218      if (dragRow || pendingRow) return;
9219      activePid = e.pointerId;
9220      pendingRow = row;
9221      pendStartX = e.clientX;
9222      pendStartY = e.clientY;
9223      window.addEventListener("pointermove", onPendMove, {
9224        passive: false,
9225      });
9226      window.addEventListener("pointerup", onPendUp);
9227      window.addEventListener("pointercancel", onPendUp);
9228    });
9229  }
9230  function onPendMove(e) {
9231    if (!pendingRow) return;
9232    if (activePid !== null && e.pointerId !== activePid) return;
9233    var dx = e.clientX - pendStartX,
9234      dy = e.clientY - pendStartY;
9235    if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
9236    var row = pendingRow;
9237    clearPend();
9238    startRowDrag(row, e);
9239  }
9240  function onPendUp() {
9241    clearPend();
9242  }
9243  function clearPend() {
9244    pendingRow = null;
9245    window.removeEventListener("pointermove", onPendMove);
9246    window.removeEventListener("pointerup", onPendUp);
9247    window.removeEventListener("pointercancel", onPendUp);
9248  }
9249  function startRowDrag(row, e) {
9250    if (dragRow) return;
9251    if (e.cancelable) e.preventDefault();
9252    activePid = e.pointerId;
9253    dragRow = row;
9254    dragStartOrder = Array.from(songButtons.children)
9255      .map((r) => r.dataset.id)
9256      .join(",");
9257    const rect = row.getBoundingClientRect();
9258    dragGX = pendStartX - rect.left;
9259    dragGY = pendStartY - rect.top;
9260    ghostOX = rect.left;
9261    ghostOY = rect.top;
9262    dragGhostEl = row.cloneNode(true);
9263    dragGhostEl.className = "songRow rowGhost";
9264    dragGhostEl.style.width = rect.width + "px";
9265    dragGhostEl.style.height = rect.height + "px";
9266    dragGhostEl.style.left = rect.left + "px";
9267    dragGhostEl.style.top = rect.top + "px";
9268    dragGhostEl.style.transform = "translate(0px,0px) scale(1.04)";
9269    document.body.appendChild(dragGhostEl);
9270    row.classList.add("placeholder");
9271    document.body.style.touchAction = "none";
9272    window.addEventListener("pointermove", onRowMove, { passive: false });
9273    window.addEventListener("pointerup", onRowUp);
9274    window.addEventListener("pointercancel", onRowUp);
9275    positionGhost(e.clientX, e.clientY);
9276    updateDropIndicator(e.clientX, e.clientY);
9277  }
9278  function positionGhost(x, y) {
9279    if (!dragGhostEl) return;
9280    dragGhostEl.style.transform =
9281      "translate(" +
9282      (x - dragGX - ghostOX) +
9283      "px," +
9284      (y - dragGY - ghostOY) +
9285      "px) scale(1.04)";
9286  }
9287  function onRowMove(e) {
9288    if (!dragRow) return;
9289    if (activePid !== null && e.pointerId !== activePid) return;
9290    if (e.cancelable) e.preventDefault();
9291    const x = e.clientX,
9292      y = e.clientY;
9293    if (rowRaf) cancelAnimationFrame(rowRaf);
9294    rowRaf = requestAnimationFrame(() => {
9295      rowRaf = 0;
9296      positionGhost(x, y);
9297      updateDropIndicator(x, y);
9298    });
9299  }
9300  function clearDropIndicator() {
9301    Array.from(songButtons.children).forEach((r) => {
9302      r.classList.remove("dropBefore", "dropAfter");
9303    });
9304  }
9305  function updateDropIndicator(x, y) {
9306    if (!dragRow) return;
9307    const rows = Array.from(songButtons.children).filter((r) => r !== dragRow);
9308    let best = null,
9309      bestDist = Infinity,
9310      after = false;
9311    rows.forEach((r) => {
9312      const rect = r.getBoundingClientRect();
9313      const cy = rect.top + rect.height / 2;
9314      const d = Math.abs(y - cy);
9315      if (d < bestDist) {
9316        bestDist = d;
9317        best = r;
9318        after = y > cy;
9319      }
9320    });
9321    clearDropIndicator();
9322    dropTargetRow = best;
9323    dropAfter = after;
9324    if (best) best.classList.add(after ? "dropAfter" : "dropBefore");
9325  }
9326  function flipMove(mutate) {
9327    const rows = Array.from(songButtons.children);
9328    const first = new Map();
9329    rows.forEach((r) => first.set(r, r.getBoundingClientRect()));
9330    mutate();
9331    Array.from(songButtons.children).forEach((r) => {
9332      if (r === dragRow) return;
9333      const f = first.get(r);
9334      if (!f) return;
9335      const l = r.getBoundingClientRect();
9336      const dx = f.left - l.left,
9337        dy = f.top - l.top;
9338      if (dx || dy) {
9339        r.style.transition = "none";
9340        r.style.transform = "translate(" + dx + "px," + dy + "px)";
9341        requestAnimationFrame(() => {
9342          r.style.transition = "transform .2s cubic-bezier(.2,.9,.25,1.25)";
9343          r.style.transform = "";
9344        });
9345      }
9346    });
9347  }
9348  function onRowUp(e) {
9349    if (activePid !== null && e.pointerId !== activePid) return;
9350    window.removeEventListener("pointermove", onRowMove);
9351    window.removeEventListener("pointerup", onRowUp);
9352    window.removeEventListener("pointercancel", onRowUp);
9353    document.body.style.touchAction = "";
9354    activePid = null;
9355    if (rowRaf) {
9356      cancelAnimationFrame(rowRaf);
9357      rowRaf = 0;
9358    }
9359    if (!dragRow) return;
9360    const row = dragRow,
9361      ghost = dragGhostEl;
9362    dragRow = null;
9363    dragGhostEl = null;
9364    row.classList.remove("placeholder");
9365    clearDropIndicator();
9366    if (dropTargetRow && dropTargetRow !== row) {
9367      if (dropAfter) dropTargetRow.after(row);
9368      else dropTargetRow.before(row);
9369    }
9370    dropTargetRow = null;
9371    const slot = row.getBoundingClientRect();
9372    if (ghost) {
9373      ghost.classList.add("landing");
9374      ghost.style.transform =
9375        "translate(" +
9376        (slot.left - ghostOX) +
9377        "px," +
9378        (slot.top - ghostOY) +
9379        "px) scale(1)";
9380      setTimeout(() => {
9381        if (ghost && ghost.parentNode) ghost.parentNode.removeChild(ghost);
9382      }, 210);
9383    }
9384    const order = Array.from(songButtons.children).map((r) => r.dataset.id);
9385    if (order.join(",") === dragStartOrder) return;
9386    songs.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
9387    songs.forEach((s, i) => {
9388      s.num = i + 1;
9389    });
9390    Array.from(songButtons.children).forEach((r) => {
9391      const btn = r.querySelector(".songBtn");
9392      const s = songs.find((x) => x.id === r.dataset.id);
9393      if (btn && s) btn.textContent = s.num + ". " + s.title;
9394    });
9395    saveSongs();
9396    if (currentSong()) render();
9397  }
9398
9399  // --- Tarik lagu dari Bank ke Daftar lagu ---
9400  let bankGhost = null,
9401    bankDragItem = null;
9402  function enableBankDrag(grip, item) {
9403    grip.addEventListener("pointerdown", (e) => {
9404      e.preventDefault();
9405      bankDragItem = item;
9406      grip.setPointerCapture(e.pointerId);
9407      document.getElementById("menuBackdrop").classList.add("dragging-bank");
9408      bankGhost = document.createElement("div");
9409      bankGhost.className = "dragGhost";
9410      bankGhost.textContent = item.title;
9411      document.body.appendChild(bankGhost);
9412      moveGhost(e);
9413      grip.addEventListener("pointermove", onBankMove);
9414      grip.addEventListener("pointerup", onBankUp);
9415    });
9416  }
9417  function moveGhost(e) {
9418    if (bankGhost) {
9419      bankGhost.style.left = e.clientX + 12 + "px";
9420      bankGhost.style.top = e.clientY + 12 + "px";
9421    }
9422  }
9423  function overSongList(e) {
9424    const r = songButtons.getBoundingClientRect();
9425    return (
9426      e.clientX >= r.left &&
9427      e.clientX <= r.right &&
9428      e.clientY >= r.top &&
9429      e.clientY <= r.bottom
9430    );
9431  }
9432  function onBankMove(e) {
9433    moveGhost(e);
9434    songButtons.classList.toggle("dropHint", overSongList(e));
9435  }
9436  function onBankUp(e) {
9437    const grip = e.currentTarget;
9438    grip.removeEventListener("pointermove", onBankMove);
9439    grip.removeEventListener("pointerup", onBankUp);
9440    try {
9441      grip.releasePointerCapture(e.pointerId);
9442    } catch (_) {}
9443    const drop = overSongList(e);
9444    if (bankGhost) {
9445      bankGhost.remove();
9446      bankGhost = null;
9447    }
9448    songButtons.classList.remove("dropHint");
9449    document.getElementById("menuBackdrop").classList.remove("dragging-bank");
9450    const item = bankDragItem;
9451    bankDragItem = null;
9452    if (drop && item) addFromBank(item);
9453  }
9454
9455  // --- Gulir otomatis (mode manggung) ---
9456  let scrollTimer = null,
9457    scrollSpeed = 1.2;
9458  function startScroll() {
9459    if (scrollTimer) return;
9460    scrollTimer = setInterval(() => {
9461      window.scrollBy(0, scrollSpeed);
9462      if (window.innerHeight + window.scrollY >= document.body.scrollHeight - 2)
9463        stopScroll();
9464    }, 30);
9465    const b = document.getElementById("scrollToggle");
9466    if (b) {
9467      b.textContent = "⏸";
9468      b.classList.add("active");
9469    }
9470  }
9471  function stopScroll() {
9472    if (scrollTimer) {
9473      clearInterval(scrollTimer);
9474      scrollTimer = null;
9475    }
9476    const b = document.getElementById("scrollToggle");
9477    if (b) {
9478      b.textContent = "▶";
9479      b.classList.remove("active");
9480    }
9481  }
9482  function toggleScroll() {
9483    scrollTimer ? stopScroll() : startScroll();
9484  }
9485
9486  // --- Mode gelap & ukuran teks ---
9487  function applyDark(on) {
9488    document.body.classList.toggle("stage", on);
9489    try {
9490      localStorage.setItem("ptStage", on ? "1" : "0");
9491    } catch (e) {}
9492    const b = document.getElementById("darkToggle");
9493    if (b) {
9494      b.textContent = on ? "Mode panggung: aktif" : "Mode panggung";
9495      b.classList.toggle("active", !!on);
9496    }
9497  }
9498  function toggleDark() {
9499    applyDark(!document.body.classList.contains("stage"));
9500  }
9501  function applyEye(on) {
9502    document.body.classList.toggle("eye", on);
9503    try {
9504      localStorage.setItem("ptEye", on ? "1" : "0");
9505    } catch (e) {}
9506    const b = document.getElementById("eyeToggle");
9507    if (b) {
9508      b.textContent = on ? "Eye saver: aktif" : "Eye saver";
9509      b.classList.toggle("active", !!on);
9510    }
9511  }
9512  function toggleEye() {
9513    applyEye(!document.body.classList.contains("eye"));
9514  }
9515  function applyNum(on) {
9516    numberMode = !!on;
9517    try {
9518      localStorage.setItem("ptNum", on ? "1" : "0");
9519    } catch (e) {}
9520    const b = document.getElementById("numToggle");
9521    if (b) {
9522      b.textContent = on ? "Chord jadi huruf (A-G)" : "Chord jadi angka (1-7)";
9523      b.classList.toggle("active", !!on);
9524    }
9525    render();
9526    var ed = document.getElementById("editor");
9527    if (ed && ed.classList.contains("open")) updateEditPreview();
9528  }
9529  function toggleNum() {
9530    applyNum(!numberMode);
9531  }
9532  const textSteps = ["xs", "sm", "", "big", "xl"];
9533  const textLabels = [
9534    "Sangat kecil",
9535    "Kecil",
9536    "Sedang",
9537    "Besar",
9538    "Sangat besar",
9539  ];
9540  let textIdx = 2;
9541  function applyText() {
9542    document.body.classList.remove("xs", "sm", "big", "xl");
9543    const c = textSteps[textIdx];
9544    if (c) document.body.classList.add(c);
9545    const lbl = document.getElementById("textSizeLabel");
9546    if (lbl) lbl.textContent = textLabels[textIdx];
9547    try {
9548      localStorage.setItem("ptTextName", c);
9549    } catch (e) {}
9550  }
9551  function textBigger() {
9552    textIdx = Math.min(textSteps.length - 1, textIdx + 1);
9553    applyText();
9554  }
9555  function textSmaller() {
9556    textIdx = Math.max(0, textIdx - 1);
9557    applyText();
9558  }
9559  function applyCompact(on) {
9560    document.body.classList.toggle("compact", !!on);
9561    var b = document.getElementById("compactToggle");
9562    if (b) b.classList.toggle("active", !!on);
9563    try {
9564      localStorage.setItem("ptCompact", on ? "1" : "0");
9565    } catch (e) {}
9566  }
9567  function toggleCompact() {
9568    applyCompact(!document.body.classList.contains("compact"));
9569  }
9570  function restorePrefs() {
9571    try {
9572      applyDark(localStorage.getItem("ptStage") === "1");
9573      applyEye(localStorage.getItem("ptEye") === "1");
9574      const nm = localStorage.getItem("ptTextName");
9575      const i = textSteps.indexOf(nm == null ? "" : nm);
9576      textIdx = i >= 0 ? i : 2;
9577      applyText();
9578      var sv = parseInt(localStorage.getItem("ptStep"), 10);
9579      setStep(sv === 1 ? 1 : 5);
9580      var bv = parseFloat(localStorage.getItem("ptBpm"));
9581      if (!isNaN(bv)) setBpm(bv);
9582      applyNum(localStorage.getItem("ptNum") === "1");
9583      applyCompact(localStorage.getItem("ptCompact") === "1");
9584    } catch (e) {}
9585  }
9586
9587  // --- Pasang aksi menu & fitur baru ---
9588
9589  // === Isolasi error per fitur: 1 fitur gagal tidak mematikan fitur lain ===
9590  window.PNWDiag = window.PNWDiag || [];
9591  window.PNWSafe = window.PNWSafe || {
9592    run: function (name, fn) {
9593      try {
9594        return { ok: true, value: fn() };
9595      } catch (e) {
9596        window.PNWDiag.push({ feature: name, error: String((e && e.message) || e), at: Date.now() });
9597        if (window.console && console.warn) console.warn("[PNW] fitur gagal:", name, e);
9598        return { ok: false, error: e };
9599      }
9600    },
9601  };
9602  try {
9603    window.addEventListener("error", function (ev) {
9604      window.PNWDiag.push({ feature: "window", error: String((ev && ev.message) || "error"), at: Date.now() });
9605    });
9606    window.addEventListener("unhandledrejection", function (ev) {
9607      window.PNWDiag.push({ feature: "promise", error: String((ev && ev.reason && ev.reason.message) || ev.reason || "rejection"), at: Date.now() });
9608    });
9609  } catch (e) {}
9610
9611  // === Mesin CastFlow yang dipakai js/projector.js ===
9612  window.PNWYouthViews = {
9613    version: "v84",
9614    getSongs: function () {
9615      return Array.isArray(songs) ? songs : [];
9616    },
9617    getSong: function (id) {
9618      return (Array.isArray(songs) ? songs : []).find(function (s) {
9619        return s && String(s.id) === String(id);
9620      }) || null;
9621    },
9622    buildSlides: function (song, maxLines) {
9623      return yvBuildSlides(song, maxLines);
9624    },
9625    stripChords: yvStripChords,
9626    isAdmin: function () {
9627      return !!isAdmin;
9628    },
9629    canBroadcast: function () {
9630      // v113: kanal CastFlow tidak lagi dikelola app.js (output pindah total
9631      // ke castflow.html sejak v83) — halaman utama tidak menyiarkan ke sana.
9632      return false;
9633    },
9634    broadcast: function () {
9635      // v113: lihat canBroadcast — menyiarkan hanya bisa dari castflow.html.
9636      return false;
9637    },
9638    clear: function () {
9639      // v113: lihat canBroadcast.
9640      return false;
9641    },
9642    selectedKey: function () {
9643      return selectedKey;
9644    },
9645    ensureFont: yvEnsureFont,
9646    motion: function () { return _yvMotion; },
9647    cue: yvMotionCue,
9648    diagnostics: function () {
9649      return window.PNWDiag.slice(-50);
9650    },
9651  };
9652
9653  function initExtras() {
9654    document.getElementById("menuBtn").onclick = openMenu;
9655    ensureLottie(0);
9656    document.getElementById("closeMenuBtn").onclick = closeMenu;
9657    document.getElementById("openIzinBtn").onclick = openIzin;
9658    document.getElementById("closeIzinBtn").onclick = closeIzin;
9659    document.getElementById("sendIzinBtn").onclick = submitIzin;
9660    document.getElementById("openSaranBtn").onclick = openSaran;
9661    document.getElementById("sendSaranBtn").onclick = submitSaran;
9662    var _sf = document.getElementById("saranFoto");
9663    if (_sf)
9664      _sf.onchange = function () {
9665        var pv = document.getElementById("saranFotoPreview");
9666        if (!pv) return;
9667        pv.innerHTML = "";
9668        var f = _sf.files && _sf.files[0];
9669        if (!f) return;
9670        var r = new FileReader();
9671        r.onload = function (e) {
9672          var im = document.createElement("img");
9673          im.src = e.target.result;
9674          pv.appendChild(im);
9675        };
9676        r.readAsDataURL(f);
9677      };
9678    document.getElementById("closeSaranBtn").onclick = closeSaran;
9679    document.getElementById("openInfoBtn").onclick = openInfo;
9680    document.getElementById("closeInfoBtn").onclick = closeInfo;
9681    document.getElementById("restoreNotesBtn").onclick = restoreSeedNotes;
9682    document.getElementById("adminLoginBtn").onclick = openLogin;
9683    document.getElementById("adminLogoutBtn").onclick = doLogout;
9684    document.getElementById("lockToggle").onclick = toggleLock;
9685    var izt = document.getElementById("izinOpenToggle");
9686    if (izt) izt.onclick = toggleIzinForm;
9687    document.getElementById("addNoteBtn").onclick = openNoteModal;
9688    document.getElementById("loginSubmit").onclick = doLogin;
9689    document.getElementById("loginClose").onclick = closeLogin;
9690    var _gbtn = document.getElementById("googleLoginBtn");
9691    if (_gbtn) _gbtn.onclick = doGoogleLogin;
9692    var _sbtn = document.getElementById("signupSubmit");
9693    if (_sbtn) _sbtn.onclick = doSignup;
9694    var _sopen = document.getElementById("toSignupBtn");
9695    if (_sopen)
9696      _sopen.onclick = function (ev) {
9697        ev.preventDefault();
9698        openSignup();
9699      };
9700    var _sback = document.getElementById("toLoginBtn");
9701    if (_sback)
9702      _sback.onclick = function (ev) {
9703        ev.preventDefault();
9704        backToLogin();
9705      };
9706    var _plo = document.getElementById("pendingLogout");
9707    if (_plo)
9708      _plo.onclick = function (ev) {
9709        ev.preventDefault();
9710        doLogout();
9711      };
9712    var _sd = document.getElementById("schedStartDate");
9713    if (_sd)
9714      _sd.onchange = function () {
9715        sched.startISO = _sd.value || "";
9716        try {
9717          saveSched();
9718        } catch (e) {}
9719        buildReminder();
9720      };
9721    var _rc = document.getElementById("reminderClose");
9722    if (_rc) _rc.onclick = dismissReminder;
9723    syncStartDateInput();
9724    try {
9725      buildReminder();
9726    } catch (e) {}
9727    document.getElementById("noteSubmit").onclick = submitNote;
9728    window.addEventListener("online", function () {
9729      toast("Kembali online - perubahan tersinkron.", "success");
9730    });
9731    window.addEventListener("offline", function () {
9732      toast(
9733        "Mode offline - perubahan disimpan, dikirim saat online.",
9734        "info",
9735        3500,
9736      );
9737    });
9738    setTimeout(function () { window.PNWSafe.run("song-bank-seed", seedBankFromSongs); }, 1800);
9739    var spx = document.getElementById("spectateToggle");
9740    if (spx) spx.onclick = toggleSpectate;
9741    var odb = document.getElementById("openDisplayBtn");
9742    if (odb)
9743      odb.onclick = function () {
9744        // v83: output CastFlow hanya ada di halaman mandiri.
9745        window.open("./castflow.html?mode=display", "_blank");
9746      };
9747    var osb = document.getElementById("openStageBtn");
9748    if (osb)
9749      osb.onclick = function () {
9750        window.open(
9751          location.origin + location.pathname + "?mode=stage",
9752          "_blank",
9753        );
9754      };
9755    var pbb = document.getElementById("prevBaitBtn");
9756    if (pbb)
9757      pbb.onclick = function () {
9758        gotoSection(-1);
9759      };
9760    var nbb = document.getElementById("nextBaitBtn");
9761    if (nbb)
9762      nbb.onclick = function () {
9763        gotoSection(1);
9764      };
9765    var lct = document.getElementById("liveChordsToggle");
9766    if (lct) lct.onclick = toggleLiveChords;
9767    window.PNWSafe.run("display-mode", initDisplayMode);
9768    var shEl = document.getElementById("sheet");
9769    if (shEl)
9770      shEl.addEventListener("scroll", function () {
9771        if (typeof broadcastLive === "function") broadcastLive();
9772      });
9773    if ("serviceWorker" in navigator) {
9774      navigator.serviceWorker
9775        .register("sw.js")
9776        .then(function (reg) {
9777          reg.update();
9778          setInterval(function () {
9779            reg.update();
9780          }, 60000);
9781          reg.addEventListener("updatefound", function () {
9782            var nw = reg.installing;
9783            if (!nw) return;
9784            nw.addEventListener("statechange", function () {
9785              if (
9786                nw.state === "installed" &&
9787                navigator.serviceWorker.controller
9788              ) {
9789                if (!window.__swReloaded) {
9790                  window.__swReloaded = true;
9791                  try {
9792                    toast("Versi baru dimuat, menyegarkan...", "info", 1400);
9793                  } catch (e) {}
9794                  setTimeout(function () {
9795                    window.location.reload();
9796                  }, 700);
9797                }
9798              }
9799            });
9800          });
9801        })
9802        .catch(function () {});
9803      var swRefreshing = false;
9804      navigator.serviceWorker.addEventListener("controllerchange", function () {
9805        if (swRefreshing) return;
9806        swRefreshing = true;
9807        window.location.reload();
9808      });
9809    }
9810    document.getElementById("noteClose").onclick = closeNoteModal;
9811    var lgm = document.getElementById("loginModal");
9812    if (lgm)
9813      lgm.addEventListener("click", function (e) {
9814        if (e.target === lgm) closeLogin();
9815      });
9816    var ntm = document.getElementById("noteModal");
9817    if (ntm)
9818      ntm.addEventListener("click", function (e) {
9819        if (e.target === ntm) closeNoteModal();
9820      });
9821    var srm = document.getElementById("saranModal");
9822    if (srm)
9823      srm.addEventListener("click", function (e) {
9824        if (e.target === srm) closeSaran();
9825      });
9826    var ifm = document.getElementById("infoModal");
9827    if (ifm)
9828      ifm.addEventListener("click", function (e) {
9829        if (e.target === ifm) closeInfo();
9830      });
9831    updateNoteDot();
9832    document.querySelectorAll("#izinWeeks .weekChip").forEach(function (b) {
9833      b.onclick = function () {
9834        b.classList.toggle("on");
9835      };
9836    });
9837    var izm = document.getElementById("izinModal");
9838    if (izm)
9839      izm.addEventListener("click", function (e) {
9840        if (e.target === izm) closeIzin();
9841      });
9842    var mb = document.getElementById("menuBackdrop");
9843    mb.addEventListener("click", function (e) {
9844      if (e.target === mb) closeMenu();
9845    });
9846    document.getElementById("ytPlay").onclick = openYouTube;
9847    document.getElementById("ytPP").onclick = ytTogglePlay;
9848    document.getElementById("ytStop").onclick = stopYt;
9849    document.getElementById("ytTrack").addEventListener("click", ytSeek);
9850    document.getElementById("darkToggle").onclick = toggleDark;
9851    document.getElementById("eyeToggle").onclick = toggleEye;
9852    document.getElementById("numToggle").onclick = toggleNum;
9853    document.getElementById("textBigger").onclick = textBigger;
9854    document.getElementById("textSmaller").onclick = textSmaller;
9855    document.getElementById("compactToggle").onclick = toggleCompact;
9856    document
9857      .getElementById("songSearch")
9858      .addEventListener("input", function (e) {
9859        renderSearch(e.target.value);
9860      });
9861    document
9862      .getElementById("songJump")
9863      .addEventListener("change", function (e) {
9864        if (e.target.value) gotoSong(e.target.value);
9865      });
9866    document.getElementById("openBankBtn").onclick = openBankPage;
9867    document.getElementById("bankPageClose").onclick = closeBankPage;
9868    document.getElementById("openSchedBtn").onclick = openSchedulePage;
9869    (function () {
9870      function tapFx(btn) {
9871        if (!btn) return;
9872        btn.classList.remove("tapping");
9873        void btn.offsetWidth;
9874        btn.classList.add("tapping");
9875        setTimeout(function () {
9876          btn.classList.remove("tapping");
9877        }, 60);
9878      }
9879      document.addEventListener(
9880        "pointerdown",
9881        function (e) {
9882          var btn =
9883            e.target && e.target.closest
9884              ? e.target.closest(".actionBtn")
9885              : null;
9886          if (btn) tapFx(btn);
9887        },
9888        true,
9889      );
9890    })();
9891    document.getElementById("schedClose").onclick = closeSchedulePage;
9892    document.getElementById("schedTab_jadwal").onclick = function () {
9893      schedSetTab("jadwal");
9894    };
9895    document.getElementById("schedTab_pelayan").onclick = function () {
9896      schedSetTab("pelayan");
9897    };
9898    document.getElementById("schedTab_izin").onclick = function () {
9899      schedSetTab("izin");
9900    };
9901    document.getElementById("schedWeekCount").onchange = function (e) {
9902      var n = parseInt(e.target.value, 10) || 5;
9903      sched.weeks = n;
9904      saveSched(true);
9905      renderSchedule();
9906    };
9907    setTimeout(initScheduleCloud, 2200);
9908    document
9909      .getElementById("bankPageSearch")
9910      .addEventListener("input", function (e) {
9911        renderBankPage(e.target.value);
9912      });
9913    document.getElementById("metroToggle").onclick = toggleMetro;
9914    document.getElementById("bpmUp").onclick = function () {
9915      setBpm(bpm + bpmStep);
9916    };
9917    document.getElementById("bpmDown").onclick = function () {
9918      setBpm(bpm - bpmStep);
9919    };
9920    document.getElementById("bpmVal").onclick = editBpm;
9921    document.getElementById("step1").onclick = function () {
9922      setStep(1);
9923    };
9924    document.getElementById("step5").onclick = function () {
9925      setStep(5);
9926    };
9927    document.getElementById("tapTempo").onclick = tapTempo;
9928    document.getElementById("bankAddBtn").onclick = addSongFromBank;
9929    document.getElementById("onlineOpenBtn").onclick = openOnlineSearch;
9930    initSecAlign();
9931    document.getElementById("onlineClose").onclick = closeOnlineSearch;
9932    document.getElementById("onlineGo").onclick = onlineSearchGo;
9933    document
9934      .getElementById("onlineQuery")
9935      .addEventListener("keydown", function (e) {
9936        if (e.key === "Enter") onlineSearchGo();
9937      });
9938    document
9939      .getElementById("editLines")
9940      .addEventListener("input", updateEditPreview);
9941    document.getElementById("structToggleBtn").onclick = toggleStructMode;
9942    var afBtnEl = document.getElementById("autoFormatBtn");
9943    if (afBtnEl) afBtnEl.onclick = runAutoFormat;
9944    var afUndoEl = document.getElementById("afUndoBtn");
9945    if (afUndoEl) afUndoEl.onclick = afUndo;
9946    buildPalette();
9947    document
9948      .getElementById("editOriginalKey")
9949      .addEventListener("input", updateEditPreview);
9950    // --- Autocomplete / autocorrect chord canggih di editor ---
9951    var CHORD_SUFFIXES = [
9952      "",
9953      "m",
9954      "7",
9955      "m7",
9956      "maj7",
9957      "sus4",
9958      "sus2",
9959      "6",
9960      "m6",
9961      "9",
9962      "m9",
9963      "maj9",
9964      "add9",
9965      "madd9",
9966      "11",
9967      "m11",
9968      "13",
9969      "m13",
9970      "dim",
9971      "dim7",
9972      "m7b5",
9973      "aug",
9974      "+",
9975      "5",
9976      "6/9",
9977      "m6/9",
9978      "7sus4",
9979      "9sus4",
9980      "7b5",
9981      "7#5",
9982      "7b9",
9983      "7#9",
9984      "7#11",
9985      "7b13",
9986      "13sus4",
9987      "mmaj7",
9988      "maj7#11",
9989      "maj13",
9990      "sus4add9",
9991      "add11",
9992      "add13",
9993      "sus",
9994      "m7#5",
9995      "7sus2",
9996      "dim9",
9997      "aug7",
9998      "augmaj7",
9999      "maj7b5",
10000      "maj9#11",
10001      "13#11",
10002      "13b9",
10003      "9b5",
10004      "9#5",
10005      "6add9",
10006      "7sus",
10007      "sus2add9",
10008    ];
10009    function csIsBoundary(ch) {
10010      if (ch === undefined) return true;
10011      var c = ch.charCodeAt(0);
10012      return (
10013        c === 32 || c === 9 || c === 10 || c === 13 || ch === "[" || ch === "]"
10014      );
10015    }
10016    function csRootOf(tok) {
10017      if (!tok) return "";
10018      var c = tok[0].toUpperCase();
10019      if (!"ABCDEFG".includes(c)) return "";
10020      if (tok.length >= 2 && (tok[1] === "#" || tok[1] === "b"))
10021        return c + tok[1];
10022      return c;
10023    }
10024    function csTokenAtCursor(ta) {
10025      if (ta.selectionStart !== ta.selectionEnd) return null;
10026      var pos = ta.selectionStart;
10027      var val = ta.value;
10028      var start = pos;
10029      while (start > 0 && !csIsBoundary(val[start - 1])) start--;
10030      var tok = val.slice(start, pos);
10031      var root = csRootOf(tok);
10032      if (!root) return null;
10033      var inBracket = start > 0 && val[start - 1] === "[";
10034      return {
10035        start: start,
10036        end: pos,
10037        root: root,
10038        partial: tok.slice(root.length),
10039        inBracket: inBracket,
10040      };
10041    }
10042    function csBuild(root, partial) {
10043      var p = partial.toLowerCase();
10044      var out = [];
10045      var seen = {};
10046      for (var i = 0; i < CHORD_SUFFIXES.length; i++) {
10047        var suf = CHORD_SUFFIXES[i];
10048        if (p === "" || suf.toLowerCase().indexOf(p) === 0) {
10049          var full = root + suf;
10050          if (!seen[full]) {
10051            seen[full] = 1;
10052            out.push(full);
10053          }
10054        }
10055      }
10056      return out.slice(0, 16);
10057    }
10058    var csLastTok = null;
10059    function csHide() {
10060      var box = document.getElementById("chordSuggest");
10061      if (box) box.hidden = true;
10062      csLastTok = null;
10063    }
10064    function csApply(chord) {
10065      var ta = document.getElementById("editLines");
10066      var t = csLastTok || csTokenAtCursor(ta);
10067      if (!t) return;
10068      var val = ta.value;
10069      var before = val.slice(0, t.start);
10070      var after = val.slice(t.end);
10071      var addClose = t.inBracket && after[0] !== "]";
10072      ta.value = before + chord + (addClose ? "]" : "") + after;
10073      var np = (before + chord + (addClose ? "]" : "")).length;
10074      ta.focus();
10075      ta.setSelectionRange(np, np);
10076      csHide();
10077      updateEditPreview();
10078    }
10079    function csRefresh() {
10080      var ta = document.getElementById("editLines");
10081      var box = document.getElementById("chordSuggest");
10082      if (!ta || !box) return;
10083      var t = csTokenAtCursor(ta);
10084      if (!t || (!t.inBracket && t.partial === "")) {
10085        csHide();
10086        return;
10087      }
10088      var list = csBuild(t.root, t.partial);
10089      if (!list.length) {
10090        csHide();
10091        return;
10092      }
10093      csLastTok = t;
10094      box.innerHTML = "";
10095      list.forEach(function (ch) {
10096        var b = document.createElement("button");
10097        b.type = "button";
10098        b.className = "csChip";
10099        b.textContent = ch;
10100        b.addEventListener("mousedown", function (e) {
10101          e.preventDefault();
10102        });
10103        b.addEventListener("click", function () {
10104          csApply(ch);
10105        });
10106        box.appendChild(b);
10107      });
10108      box.hidden = false;
10109    }
10110    (function () {
10111      var ta = document.getElementById("editLines");
10112      if (!ta) return;
10113      ta.addEventListener("input", csRefresh);
10114      ta.addEventListener("click", csRefresh);
10115      ta.addEventListener("keyup", function (e) {
10116        if (
10117          e.key === "ArrowLeft" ||
10118          e.key === "ArrowRight" ||
10119          e.key === "ArrowUp" ||
10120          e.key === "ArrowDown" ||
10121          e.key === "Home" ||
10122          e.key === "End"
10123        )
10124          csRefresh();
10125      });
10126      ta.addEventListener("keydown", function (e) {
10127        var box = document.getElementById("chordSuggest");
10128        if (box && !box.hidden && e.key === "Tab") {
10129          var first = box.querySelector(".csChip");
10130          if (first) {
10131            e.preventDefault();
10132            csApply(first.textContent);
10133          }
10134        } else if (e.key === "Escape") {
10135          csHide();
10136        }
10137      });
10138      ta.addEventListener("blur", function () {
10139        setTimeout(csHide, 200);
10140      });
10141    })();
10142    document.getElementById("dragInfoBtn").onclick = function () {
10143      var t = document.getElementById("dragTip");
10144      if (t) t.style.display = t.style.display === "none" ? "block" : "none";
10145    };
10146    loadYtApi();
10147    renderBank();
10148    refreshLibrary();
10149    restorePrefs();
10150  }
10151
10152  /* ============ v60: Tombol melayang ala AssistiveTouch + Wheel ============ */
10153  var atBtnEl = null,
10154    atOvEl = null,
10155    atWheelEl = null,
10156    atItemsEl = null,
10157    atHubEl = null,
10158    atLevel = "root",
10159    atPage = 0,
10160    atIdleT = null;
10161  var AT_POS_KEY = "pnwWheelPos";
10162  var AT_PER_PAGE = 6;
10163
10164  function atClamp(v, lo, hi) {
10165    return Math.max(lo, Math.min(hi, v));
10166  }
10167  function atSavePos(x, y) {
10168    try {
10169      localStorage.setItem(AT_POS_KEY, JSON.stringify({ x: x, y: y }));
10170    } catch (e) {}
10171  }
10172  function atPlaceBtn(x, y) {
10173    if (!atBtnEl) return { x: 0, y: 0 };
10174    var m = 8,
10175      w = atBtnEl.offsetWidth || 52,
10176      h = atBtnEl.offsetHeight || 52;
10177    x = atClamp(x, m, Math.max(m, window.innerWidth - w - m));
10178    y = atClamp(y, m, Math.max(m, window.innerHeight - h - m));
10179    atBtnEl.style.left = x + "px";
10180    atBtnEl.style.top = y + "px";
10181    atBtnEl.style.right = "auto";
10182    return { x: x, y: y };
10183  }
10184  function atSnapEdge() {
10185    if (!atBtnEl) return;
10186    var r = atBtnEl.getBoundingClientRect();
10187    var toLeft = r.left + r.width / 2 < window.innerWidth / 2;
10188    var x = toLeft ? 8 : window.innerWidth - r.width - 8;
10189    var p = atPlaceBtn(x, r.top);
10190    atSavePos(p.x, p.y);
10191  }
10192  function atWake() {
10193    if (!atBtnEl) return;
10194    atBtnEl.classList.remove("idle");
10195    clearTimeout(atIdleT);
10196    atIdleT = setTimeout(function () {
10197      if (atOvEl && atOvEl.hidden) atBtnEl.classList.add("idle");
10198    }, 3000);
10199  }
10200
10201  /* ---- isi menu ---- */
10202  function atRootItems() {
10203    return [
10204      { k: "lagu", lbl: "Lagu", anim: "songbank", sub: true },
10205      { k: "metro", lbl: "Metronom", ico: "\u266A", sub: true },
10206      { k: "tampilan", lbl: "Tampilan", anim: "layer", sub: true },
10207      {
10208        k: "proyektor",
10209        lbl: "CastFlow",
10210        ico: "\u25A3",
10211        act: function () {
10212          var b = document.getElementById("openDisplayBtn");
10213          if (b) b.click();
10214          atClose();
10215        },
10216      },
10217    ];
10218  }
10219  function atSongItems() {
10220    var list = (songs || []).slice();
10221    var pages = Math.max(1, Math.ceil(list.length / AT_PER_PAGE));
10222    if (atPage >= pages) atPage = 0;
10223    var slice = list.slice(
10224      atPage * AT_PER_PAGE,
10225      atPage * AT_PER_PAGE + AT_PER_PAGE,
10226    );
10227    var out = slice.map(function (s) {
10228      return {
10229        k: "song:" + s.id,
10230        lbl: s.title || "(tanpa judul)",
10231        ico: String(s.num || "\u266B"),
10232        cur: s.id === selectedSongId,
10233        act: function () {
10234          gotoSong(s.id);
10235          atClose();
10236        },
10237      };
10238    });
10239    if (pages > 1) {
10240      out.push({
10241        k: "prev",
10242        lbl: "Sebelum",
10243        ico: "\u2039",
10244        keep: true,
10245        act: function () {
10246          atPage = (atPage - 1 + pages) % pages;
10247          atRender(true);
10248        },
10249      });
10250      out.push({
10251        k: "next",
10252        lbl: "Lanjut",
10253        ico: "\u203A",
10254        keep: true,
10255        act: function () {
10256          atPage = (atPage + 1) % pages;
10257          atRender(true);
10258        },
10259      });
10260    }
10261    return out;
10262  }
10263  function atMetroItems() {
10264    return [
10265      {
10266        k: "mstart",
10267        lbl: metroOn ? "Stop" : "Mulai",
10268        ico: metroOn ? "\u25A0" : "\u25B6",
10269        on: metroOn,
10270        keep: true,
10271        act: function () {
10272          toggleMetro();
10273          atRender(false);
10274        },
10275      },
10276      {
10277        k: "mminus",
10278        lbl: "BPM \u2212",
10279        ico: "\u2212",
10280        keep: true,
10281        act: function () {
10282          setBpm(bpm - 5);
10283          atRender(false);
10284        },
10285      },
10286      {
10287        k: "mplus",
10288        lbl: "BPM +",
10289        ico: "+",
10290        keep: true,
10291        act: function () {
10292          setBpm(bpm + 5);
10293          atRender(false);
10294        },
10295      },
10296      {
10297        k: "mtap",
10298        lbl: "Tap",
10299        ico: "\u25CE",
10300        keep: true,
10301        act: function () {
10302          tapTempo();
10303          atRender(false);
10304        },
10305      },
10306    ];
10307  }
10308  function atViewItems() {
10309    return [
10310      {
10311        k: "vdark",
10312        lbl: "Gelap",
10313        ico: "\u25D1",
10314        on: document.body.classList.contains("dark"),
10315        keep: true,
10316        act: function () {
10317          toggleDark();
10318          atRender(false);
10319        },
10320      },
10321      {
10322        k: "vnum",
10323        lbl: "Nomor",
10324        ico: "#",
10325        on: document.body.classList.contains("num"),
10326        keep: true,
10327        act: function () {
10328          toggleNum();
10329          atRender(false);
10330        },
10331      },
10332      {
10333        k: "vcomp",
10334        lbl: "Ringkas",
10335        ico: "\u2263",
10336        on: document.body.classList.contains("compact"),
10337        keep: true,
10338        act: function () {
10339          toggleCompact();
10340          atRender(false);
10341        },
10342      },
10343      {
10344        k: "vbig",
10345        lbl: "Teks +",
10346        ico: "A",
10347        keep: true,
10348        act: function () {
10349          textBigger();
10350        },
10351      },
10352      {
10353        k: "vsmall",
10354        lbl: "Teks \u2212",
10355        ico: "a",
10356        keep: true,
10357        act: function () {
10358          textSmaller();
10359        },
10360      },
10361    ];
10362  }
10363  function atItemsFor(level) {
10364    if (level === "lagu") return atSongItems();
10365    if (level === "metro") return atMetroItems();
10366    if (level === "tampilan") return atViewItems();
10367    return atRootItems();
10368  }
10369  function atHubText(level) {
10370    if (level === "lagu") return { lbl: "Pilih lagu", sub: "kembali" };
10371    if (level === "metro") return { lbl: fmtBpm(bpm) + " BPM", sub: "kembali" };
10372    if (level === "tampilan") return { lbl: "Tampilan", sub: "kembali" };
10373    return { lbl: "Menu", sub: "" };
10374  }
10375
10376  /* ---- gambar wheel ---- */
10377  function atRender(spin) {
10378    if (!atItemsEl) return;
10379    var items = atItemsFor(atLevel);
10380    var ht = atHubText(atLevel);
10381    var lbl = atHubEl.querySelector(".atHubLbl");
10382    if (lbl) lbl.textContent = ht.lbl;
10383    var sub = atHubEl.querySelector(".atHubSub");
10384    if (!sub) {
10385      sub = document.createElement("span");
10386      sub.className = "atHubSub";
10387      atHubEl.appendChild(sub);
10388    }
10389    sub.textContent = ht.sub;
10390
10391    atItemsEl.innerHTML = "";
10392    var n = items.length;
10393    var R = n > 6 ? 108 : 100;
10394    items.forEach(function (it, i) {
10395      var ang = -Math.PI / 2 + (i * 2 * Math.PI) / n;
10396      var b = document.createElement("button");
10397      b.type = "button";
10398      b.className = "atItem" + (it.on ? " on" : "") + (it.cur ? " cur" : "");
10399      b.style.setProperty("--x", (Math.cos(ang) * R).toFixed(1) + "px");
10400      b.style.setProperty("--y", (Math.sin(ang) * R).toFixed(1) + "px");
10401      b.style.setProperty("--d", i * 42 + "ms");
10402      var ico;
10403      if (it.anim) {
10404        ico = document.createElement("span");
10405        ico.className = "lottieIco";
10406        ico.setAttribute("data-anim", it.anim);
10407      } else {
10408        ico = document.createElement("span");
10409        ico.className = "atIco";
10410        ico.textContent = it.ico || "\u2022";
10411      }
10412      var tx = document.createElement("span");
10413      tx.className = "atLbl";
10414      tx.textContent = it.lbl;
10415      b.appendChild(ico);
10416      b.appendChild(tx);
10417      b.onclick = function (ev) {
10418        ev.stopPropagation();
10419        b.classList.remove("pressed");
10420        void b.offsetWidth;
10421        b.classList.add("pressed");
10422        if (it.sub) {
10423          atPage = 0;
10424          atGoLevel(it.k);
10425        } else if (it.act) {
10426          setTimeout(it.act, it.keep ? 0 : 120);
10427        }
10428      };
10429      atItemsEl.appendChild(b);
10430    });
10431
10432    try {
10433      initLottieIcons();
10434    } catch (e) {}
10435    if (spin !== false) {
10436      try {
10437        replayIn(atHubEl);
10438      } catch (e) {}
10439    }
10440  }
10441
10442  function atGoLevel(level) {
10443    atLevel = level;
10444    atItemsEl.classList.add("leaving");
10445    setTimeout(function () {
10446      atItemsEl.classList.remove("leaving");
10447      atRender(true);
10448    }, 150);
10449  }
10450
10451  function atOpen() {
10452    if (!atOvEl) return;
10453    atLevel = "root";
10454    atPage = 0;
10455    atOvEl.hidden = false;
10456    atWheelEl.classList.remove("closing");
10457    atBtnEl.classList.remove("idle");
10458    clearTimeout(atIdleT);
10459    atRender(true);
10460  }
10461  function atClose() {
10462    if (!atOvEl || atOvEl.hidden) return;
10463    atWheelEl.classList.add("closing");
10464    setTimeout(function () {
10465      atOvEl.hidden = true;
10466      atWheelEl.classList.remove("closing");
10467      atWake();
10468    }, 150);
10469  }
10470  function atToggle() {
10471    if (atOvEl && atOvEl.hidden) atOpen();
10472    else atClose();
10473  }
10474
10475  function initWheel() {
10476    atBtnEl = document.getElementById("atBtn");
10477    atOvEl = document.getElementById("atOverlay");
10478    atWheelEl = document.getElementById("atWheel");
10479    if (!atBtnEl || !atOvEl || !atWheelEl) return;
10480    atItemsEl = atWheelEl.querySelector(".atItems");
10481    atHubEl = atWheelEl.querySelector(".atHub");
10482
10483    // posisi tersimpan
10484    var saved = null;
10485    try {
10486      saved = JSON.parse(localStorage.getItem(AT_POS_KEY) || "null");
10487    } catch (e) {}
10488    if (saved && typeof saved.x === "number") atPlaceBtn(saved.x, saved.y);
10489    else
10490      atPlaceBtn(
10491        window.innerWidth - (atBtnEl.offsetWidth || 52) - 14,
10492        Math.round(window.innerHeight * 0.6),
10493      );
10494
10495    // geser bebas ala iPhone
10496    var sx = 0,
10497      sy = 0,
10498      ox = 0,
10499      oy = 0,
10500      moved = false,
10501      downT = 0,
10502      pid = null;
10503    atBtnEl.addEventListener("pointerdown", function (e) {
10504      pid = e.pointerId;
10505      moved = false;
10506      downT = Date.now();
10507      var r = atBtnEl.getBoundingClientRect();
10508      sx = e.clientX;
10509      sy = e.clientY;
10510      ox = r.left;
10511      oy = r.top;
10512      try {
10513        atBtnEl.setPointerCapture(pid);
10514      } catch (err) {}
10515      atBtnEl.classList.add("dragging");
10516      atWake();
10517    });
10518    atBtnEl.addEventListener("pointermove", function (e) {
10519      if (pid === null) return;
10520      var dx = e.clientX - sx,
10521        dy = e.clientY - sy;
10522      if (!moved && Math.abs(dx) + Math.abs(dy) > 6) moved = true;
10523      if (moved) {
10524        e.preventDefault();
10525        atPlaceBtn(ox + dx, oy + dy);
10526      }
10527    });
10528    function atUp() {
10529      if (pid === null) return;
10530      atBtnEl.classList.remove("dragging");
10531      try {
10532        atBtnEl.releasePointerCapture(pid);
10533      } catch (err) {}
10534      pid = null;
10535      if (!moved && Date.now() - downT < 700) atToggle();
10536      else atSnapEdge();
10537    }
10538    atBtnEl.addEventListener("pointerup", atUp);
10539    atBtnEl.addEventListener("pointercancel", atUp);
10540
10541    // tutup bila tap di luar lingkaran wheel
10542    atOvEl.addEventListener("pointerdown", function (e) {
10543      var r = atWheelEl.getBoundingClientRect();
10544      var cx = r.left + r.width / 2,
10545        cy = r.top + r.height / 2;
10546      var dx = e.clientX - cx,
10547        dy = e.clientY - cy;
10548      if (Math.sqrt(dx * dx + dy * dy) > r.width / 2) atClose();
10549    });
10550
10551    // hub: kembali satu tingkat, atau tutup bila sudah di akar
10552    atHubEl.onclick = function (e) {
10553      e.stopPropagation();
10554      if (atLevel === "root") atClose();
10555      else {
10556        atPage = 0;
10557        atGoLevel("root");
10558      }
10559    };
10560
10561    document.addEventListener("keydown", function (e) {
10562      if (e.key === "Escape" && atOvEl && !atOvEl.hidden) atClose();
10563    });
10564    window.addEventListener("resize", function () {
10565      var r = atBtnEl.getBoundingClientRect();
10566      atPlaceBtn(r.left, r.top);
10567    });
10568
10569    atWake();
10570  }
10571
10572  // Jalankan aplikasi: gambar tampilan, lalu coba sambungkan ke server online.
10573  window.PNWSafe.run("buttons", makeButtons);
10574  window.PNWSafe.run("render", render);
10575  window.PNWSafe.run("extras", initExtras);
10576  if (document.readyState === "loading") {
10577    document.addEventListener("DOMContentLoaded", initWheel);
10578  } else {
10579    initWheel();
10580  }
10581  if (document.readyState === "loading") {
10582    document.addEventListener("DOMContentLoaded", initCloud);
10583  } else {
10584    initCloud();
10585  }
10586})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.