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, "&") 817 .replace(/</g, "<") 818 .replace(/>/g, ">") 819 .replace(/"/g, """); 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, "&") 4418 .replace(/</g, "<") 4419 .replace(/>/g, ">"); 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 = "❚❚"; 7816 startYtPoll(); 7817 } else { 7818 pp.innerHTML = "▶"; 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.