Adibrino commited on
Commit
1038d6b
·
0 Parent(s):
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +7 -0
  2. .gitignore +29 -0
  3. App.tsx +83 -0
  4. README.md +161 -0
  5. components/LoadingScreen.tsx +80 -0
  6. components/LoginModal.tsx +144 -0
  7. components/Player.tsx +314 -0
  8. components/UpdateRefreshDataMode.tsx +59 -0
  9. data.json +1 -0
  10. index.html +27 -0
  11. index.ts +83 -0
  12. index.tsx +16 -0
  13. metadata.json +5 -0
  14. package-lock.json +0 -0
  15. package.json +34 -0
  16. public/assets/videos/real_0.mp4 +3 -0
  17. public/assets/videos/real_1.mp4 +3 -0
  18. public/assets/videos/real_10.mp4 +3 -0
  19. public/assets/videos/real_11.mp4 +3 -0
  20. public/assets/videos/real_12.mp4 +3 -0
  21. public/assets/videos/real_13.mp4 +3 -0
  22. public/assets/videos/real_14.mp4 +3 -0
  23. public/assets/videos/real_15.mp4 +3 -0
  24. public/assets/videos/real_16.mp4 +3 -0
  25. public/assets/videos/real_17.mp4 +3 -0
  26. public/assets/videos/real_18.mp4 +3 -0
  27. public/assets/videos/real_19.mp4 +3 -0
  28. public/assets/videos/real_2.mp4 +3 -0
  29. public/assets/videos/real_20.mp4 +3 -0
  30. public/assets/videos/real_21.mp4 +3 -0
  31. public/assets/videos/real_22.mp4 +3 -0
  32. public/assets/videos/real_23.mp4 +3 -0
  33. public/assets/videos/real_24.mp4 +3 -0
  34. public/assets/videos/real_25.mp4 +3 -0
  35. public/assets/videos/real_26.mp4 +3 -0
  36. public/assets/videos/real_27.mp4 +3 -0
  37. public/assets/videos/real_28.mp4 +3 -0
  38. public/assets/videos/real_29.mp4 +3 -0
  39. public/assets/videos/real_3.mp4 +3 -0
  40. public/assets/videos/real_30.mp4 +3 -0
  41. public/assets/videos/real_31.mp4 +3 -0
  42. public/assets/videos/real_32.mp4 +3 -0
  43. public/assets/videos/real_33.mp4 +3 -0
  44. public/assets/videos/real_34.mp4 +3 -0
  45. public/assets/videos/real_35.mp4 +3 -0
  46. public/assets/videos/real_36.mp4 +3 -0
  47. public/assets/videos/real_37.mp4 +3 -0
  48. public/assets/videos/real_38.mp4 +3 -0
  49. public/assets/videos/real_39.mp4 +3 -0
  50. public/assets/videos/real_4.mp4 +3 -0
.gitattributes ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ *.mp4 filter=lfs diff=lfs merge=lfs -text
2
+ *.zip filter=lfs diff=lfs merge=lfs -text
3
+ *.dll filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.crdownload filter=lfs diff=lfs merge=lfs -text
6
+ *.mp3 filter=lfs diff=lfs merge=lfs -text
7
+ *.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ python/results/*
11
+ python/results.zip
12
+ dist/*
13
+ public/assets/audios/*
14
+
15
+ node_modules
16
+ # dist
17
+ dist-ssr
18
+ *.local
19
+
20
+ # Editor directories and files
21
+ .vscode/*
22
+ !.vscode/extensions.json
23
+ .idea
24
+ .DS_Store
25
+ *.suo
26
+ *.ntvs*
27
+ *.njsproj
28
+ *.sln
29
+ *.sw?
App.tsx ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // src/App.tsx
2
+ import React, { useEffect, useRef, useState } from 'react';
3
+ import Player from './components/Player'; // Pastikan path ini benar
4
+ import { realPaths, reversePaths } from './types';
5
+ import LoadingScreen from './components/LoadingScreen';
6
+
7
+ // === Konfigurasi Awal ===
8
+ export const INITIAL_SEGMENT_COUNT = 46;
9
+
10
+ const App: React.FC = () => {
11
+ const [isPreloading, setIsPreloading] = useState<boolean>(true);
12
+ const [loadingProgress, setLoadingProgress] = useState<number>(0);
13
+ const preloadedUrlsRef = useRef<Record<string, string>>({});
14
+
15
+ useEffect(() => {
16
+ // Fungsi untuk melakukan pra-pemuatan semua video
17
+ const preloadVideos = async () => {
18
+ const videoPaths: string[] = [];
19
+ // Buat daftar semua path video yang akan di-load
20
+ for (let i = 0; i <= INITIAL_SEGMENT_COUNT; i++) {
21
+ videoPaths.push(`${realPaths}${i}.mp4`);
22
+ videoPaths.push(`${reversePaths}${i}.mp4`);
23
+ }
24
+
25
+ const totalVideos = videoPaths.length;
26
+
27
+ if (totalVideos === 0) {
28
+ setTimeout(() => {
29
+ setIsPreloading(false);
30
+ }, 1000)
31
+ return;
32
+ }
33
+
34
+ let loadedCount = 0;
35
+
36
+ // Fungsi untuk mengambil satu video
37
+ const fetchVideo = async (path: string) => {
38
+ try {
39
+ const response = await fetch(path);
40
+ if (!response.ok) {
41
+ throw new Error(`Gagal memuat video: ${path}`);
42
+ }
43
+ const blob = await response.blob();
44
+ const url = URL.createObjectURL(blob);
45
+ preloadedUrlsRef.current[path] = url;
46
+ } catch (error) {
47
+ console.error(error);
48
+ // Mungkin Anda ingin menangani error di sini, misal dengan placeholder
49
+ preloadedUrlsRef.current[path] = ''; // Tandai sebagai gagal
50
+ } finally {
51
+ // Perbarui progress bar setelah setiap video (berhasil atau gagal)
52
+ loadedCount++;
53
+ const progress = (loadedCount / totalVideos) * 100;
54
+ setLoadingProgress(prev => prev > progress ? prev : progress);
55
+ }
56
+ };
57
+
58
+ // Jalankan semua fetch secara paralel untuk mempercepat
59
+ await Promise.all(videoPaths.map(fetchVideo));
60
+
61
+ setTimeout(() => {
62
+ setIsPreloading(false);
63
+ }, 1000);
64
+ };
65
+
66
+ preloadVideos();
67
+
68
+ // Cleanup: Hapus Object URL saat komponen di-unmount untuk mencegah memory leak
69
+ return () => {
70
+ Object.values(preloadedUrlsRef.current).forEach(url => {
71
+ if (url) URL.revokeObjectURL(url);
72
+ });
73
+ };
74
+ }, [INITIAL_SEGMENT_COUNT]);
75
+
76
+ if (isPreloading) {
77
+ <LoadingScreen isBar progress={loadingProgress} />
78
+ }
79
+
80
+ return <Player preloadedUrls={preloadedUrlsRef.current} totalSegments={INITIAL_SEGMENT_COUNT} />;
81
+ };
82
+
83
+ export default App;
README.md ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: PPT BUDAYA INDONESIA HTML CONTROL LOGIC
3
+ emoji: 📚
4
+ colorFrom: indigo
5
+ colorTo: indigo
6
+ app_build_command: npm run build
7
+ app_file: dist/index.html
8
+ sdk: static
9
+ pinned: false
10
+ license: apache-2.0
11
+ short_description: 'Warna-Warni Nusantara​: Menyelami Kekayaan Budaya Indonesia​'
12
+ ---
13
+
14
+ # Interactive Video Presentation Controller
15
+
16
+ [![React](https://img.shields.io/badge/React-18-blue?logo=react)](https://reactjs.org/)
17
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5-blue?logo=typescript)](https://www.typescriptlang.org/)
18
+ [![Python](https://img.shields.io/badge/Python-3.11-blue?logo=python)](https://www.python.org/)
19
+ [![OpenCV](https://img.shields.io/badge/OpenCV-4-blue?logo=opencv)](https://opencv.org/)
20
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
21
+
22
+ Sebuah sistem presentasi video interaktif dengan mode "Controller" dan "Preview" yang disinkronkan secara real-time menggunakan GitHub Gist API.
23
+
24
+ **Tema Presentasi:** "Warna-Warni Nusantara: Menyelami Kekayaan Budaya Indonesia"
25
+
26
+ [**(Tautan Demo Langsung di Hugging Face Spaces)**](https://adityadn-ppt-budaya-indonesia-html-control-logic.static.hf.space/index.html)
27
+
28
+ ---
29
+
30
+ ## Konsep
31
+
32
+ Proyek ini memecahkan masalah presentasi jarak jauh di mana seorang presenter perlu mengontrol pemutaran video di layar audiens secara mulus. Ini dicapai dengan:
33
+
34
+ 1. **Pra-pemrosesan Video**: Sebuah skrip Python menggunakan OpenCV untuk memotong video presentasi utama menjadi segmen-segmen kecil, baik untuk pemutaran maju (`real`) maupun mundur (`reverse`).
35
+ 2. **Frontend Cerdas**: Aplikasi React yang melakukan pra-pemuatan (pre-loads) semua segmen video untuk transisi yang instan dan bebas kedipan.
36
+ 3. **Sinkronisasi Real-time**:
37
+ * **Mode Controller**: Digunakan oleh presenter. Memiliki kontrol penuh (maju, mundur, lompat segmen) dan menyimpan status halaman saat ini ke GitHub Gist setiap kali ada perubahan.
38
+ * **Mode Preview**: Digunakan oleh audiens. Secara berkala mengambil (polls) status terbaru dari GitHub Gist dan secara otomatis memutar segmen video yang sesuai.
39
+
40
+ ## Fitur Utama
41
+
42
+ - **Transisi Video Super Mulus**: Menggunakan teknik *triple-buffering* dengan 3 elemen video untuk menghilangkan kedipan saat berpindah segmen.
43
+ - **Pra-pemuatan Cerdas**: Semua segmen video diunduh terlebih dahulu saat aplikasi dimuat, memastikan tidak ada buffering saat presentasi.
44
+ - **Dua Mode Operasi**: Mode `controller` untuk presenter dan mode `preview` untuk audiens.
45
+ - **Navigasi Penuh**: Kontrol maju, mundur, lompat ke segmen tertentu, dan skip.
46
+ - **Kontrol Keyboard**: Navigasi menggunakan tombol panah (`ArrowLeft`, `ArrowRight`) di mode controller.
47
+ - **Backend Ringan**: Menggunakan GitHub Gist sebagai database real-time yang sederhana dan gratis.
48
+
49
+ ## Tumpukan Teknologi
50
+
51
+ - **Backend & Pemrosesan Video**:
52
+ - Python
53
+ - OpenCV
54
+ - tqdm
55
+ - **Frontend**:
56
+ - React
57
+ - TypeScript
58
+ - Vite (diasumsikan)
59
+ - **Sinkronisasi**:
60
+ - GitHub Gist API
61
+
62
+ ## Struktur Proyek
63
+
64
+ ```
65
+ .
66
+ ├── python/ # Skrip untuk pemrosesan video
67
+ │ ├── assets/ # Tempat menaruh video sumber (real & reverse)
68
+ │ ├── results/ # Folder output untuk segmen video (diabaikan oleh Git)
69
+ │ └── cut_video.py # Skrip utama untuk memotong video
70
+ ├── public/ # Aset statis untuk aplikasi React
71
+ │ └── assets/ # Folder hasil pemotongan video HARUS dipindahkan ke sini
72
+ ├── src/ # Kode sumber aplikasi React
73
+ │ ├── components/
74
+ │ │ ├── Player.tsx # Komponen inti pemutar video
75
+ │ │ └── ...
76
+ │ ├── services/
77
+ │ │ └── githubService.ts# Logika untuk interaksi dengan API Gist
78
+ │ ├── App.tsx # Komponen utama yang menangani preloading
79
+ │ └── types.ts # Definisi tipe TypeScript
80
+ ├── .env.example # Template untuk variabel lingkungan
81
+ ├── .gitignore # Mengabaikan file yang tidak perlu
82
+ └── README.md # Anda sedang membaca ini
83
+ ```
84
+
85
+ ## Panduan Instalasi dan Penggunaan
86
+
87
+ Proses ini terdiri dari dua bagian utama: mempersiapkan segmen video dan menjalankan aplikasi web.
88
+
89
+ ### Bagian 1: Mempersiapkan Segmen Video (Python)
90
+
91
+ Langkah ini hanya perlu dilakukan sekali atau setiap kali Anda memiliki video sumber baru.
92
+
93
+ 1. **Prasyarat**: Pastikan Anda memiliki Python 3.x dan `pip` terinstal.
94
+ 2. **Instal Dependensi**: Buka terminal di dalam folder `python/` dan instal pustaka yang diperlukan.
95
+ ```bash
96
+ pip install opencv-python tqdm
97
+ ```
98
+ 3. **Siapkan Video Sumber**:
99
+ - Letakkan video presentasi utama Anda di dalam folder `python/assets/` dengan nama `ppt_real.mp4`.
100
+ - Letakkan versi terbalik dari video tersebut di dalam folder yang sama dengan nama `ppt_reverse.mp4`.
101
+ 4. **Jalankan Skrip**: Jalankan skrip pemotongan video dari direktori root proyek.
102
+ ```bash
103
+ python python/cut_video.py
104
+ ```
105
+ Skrip ini akan membuat folder `python/results/` yang berisi semua segmen video `.mp4` yang sudah dipotong.
106
+
107
+ ### Bagian 2: Menjalankan Aplikasi Web (React)
108
+
109
+ 1. **Pindahkan Aset Video**: **Langkah Krusial!** Pindahkan folder `results` yang baru saja dibuat dari dalam `python/` ke dalam folder `public/`. Struktur akhir harus menjadi `public/results/`.
110
+ ```
111
+ public/
112
+ └── results/
113
+ ├── real/
114
+ │ ├── 0.mp4
115
+ │ └── ...
116
+ └── reverse/
117
+ ├── 0.mp4
118
+ └── ...
119
+ ```
120
+ 2. **Prasyarat**: Pastikan Anda memiliki Node.js dan `npm` (atau `yarn`) terinstal.
121
+ 3. **Instal Dependensi Frontend**: Buka terminal di direktori root proyek dan jalankan:
122
+ ```bash
123
+ npm install
124
+ ```
125
+ 4. **Konfigurasi Variabel Lingkungan**:
126
+ - Buat file bernama `.env` di direktori root proyek.
127
+ - Salin konten dari `.env.example` ke dalam `.env`.
128
+ - Isi file `.env` dengan kredensial Anda:
129
+ ```env
130
+ VITE_GIST_ID=ID_GIST_ANDA
131
+ VITE_GITHUB_TOKEN=TOKEN_AKSES_PRIBADI_GITHUB_ANDA
132
+ ```
133
+ - **`VITE_GIST_ID`**: ID dari Gist publik/rahasia yang akan Anda gunakan sebagai database.
134
+ - **`VITE_GITHUB_TOKEN`**: Personal Access Token (PAT) dari GitHub dengan izin (`scope`) untuk `gist`.
135
+ 5. **Jalankan Server Pengembangan**:
136
+ ```bash
137
+ npm run dev
138
+ ```
139
+ Buka browser Anda dan kunjungi `http://localhost:5173` (atau port yang ditampilkan).
140
+
141
+ 6. **Build untuk Produksi**:
142
+ ```bash
143
+ npm run build
144
+ ```
145
+ Hasilnya akan ada di dalam folder `dist/`, yang siap untuk di-deploy.
146
+
147
+ ## Cara Menggunakan
148
+
149
+ - **Mode Preview (Audiens)**: Cukup buka aplikasi. Ini adalah mode default. Layar akan secara pasif menunggu pembaruan dari Gist.
150
+ - **Mode Controller (Presenter)**:
151
+ 1. Klik tombol "Mode" atau ikon sejenisnya.
152
+ 2. Login (jika ada implementasi login).
153
+ 3. Sekarang Anda berada di mode `controller`. Gunakan tombol `⟨` dan `⟩` di layar atau tombol panah keyboard untuk bernavigasi. Setiap perubahan akan secara otomatis disimpan ke Gist, dan layar audiens akan mengikutinya.
154
+
155
+ ## Lisensi
156
+
157
+ Proyek ini dilisensikan di bawah Lisensi Apache 2.0. Lihat file `LICENSE` untuk detailnya.
158
+
159
+ ## Copyright
160
+
161
+ © 2025 Aditya Dwi Nugraha. All Rights Reserved.
components/LoadingScreen.tsx ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+
3
+ interface LoadingScreenProps {
4
+ isBar?: boolean;
5
+ progress?: number;
6
+ }
7
+
8
+ const LoadingScreen: React.FC<LoadingScreenProps> = ({ isBar = false, progress = 0 }) => {
9
+ const [dots, setDots] = useState('.');
10
+
11
+ useEffect(() => {
12
+ const dotAnimation = setInterval(() => {
13
+ setDots(prevDots => {
14
+ if (prevDots.length >= 5) {
15
+ return '.';
16
+ }
17
+ return prevDots + '.';
18
+ });
19
+ }, 1000);
20
+
21
+ return () => clearInterval(dotAnimation);
22
+ }, []);
23
+
24
+ return (
25
+ <div className="fixed inset-0 z-50 flex h-screen w-screen flex-col items-center justify-center bg-bg font-sans text-fg">
26
+ <h1 className="mb-8 text-3xl font-bold">Mempersiapkan Presentasi...</h1>
27
+
28
+ {isBar ? (
29
+ <>
30
+ <div style={styles.progressBarContainer}>
31
+ <div style={{ ...styles.progressBar, width: `${progress}%` }}></div>
32
+ </div>
33
+ <p className="mt-4 text-xl font-medium">{Math.round(progress)}%</p>
34
+ </>
35
+ ) : (
36
+ <div className='flex flex-row gap-10'>
37
+ <h2 className="mt-8 text-3xl font-bold">
38
+ LOADING
39
+ <span className="w-12 text-left">{dots}</span>
40
+ </h2>
41
+ </div>
42
+ )}
43
+ </div>
44
+ );
45
+ };
46
+
47
+ const styles: { [key: string]: React.CSSProperties } = {
48
+ container: {
49
+ width: '100vw',
50
+ height: '100vh',
51
+ display: 'flex',
52
+ flexDirection: 'column',
53
+ justifyContent: 'center',
54
+ alignItems: 'center',
55
+ backgroundColor: '#071026',
56
+ color: '#e6eef8',
57
+ fontFamily: 'Inter, system-ui, sans-serif',
58
+ },
59
+ title: {
60
+ marginBottom: '2rem',
61
+ },
62
+ progressBarContainer: {
63
+ width: '50%',
64
+ maxWidth: '500px',
65
+ height: '20px',
66
+ backgroundColor: '#0e2335',
67
+ borderRadius: '10px',
68
+ overflow: 'hidden',
69
+ },
70
+ progressBar: {
71
+ height: '100%',
72
+ backgroundColor: '#60a5fa',
73
+ },
74
+ progressText: {
75
+ marginTop: '1rem',
76
+ fontSize: '1.2rem',
77
+ }
78
+ };
79
+
80
+ export default LoadingScreen;
components/LoginModal.tsx ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { Mode } from '@/types';
3
+
4
+ interface LoginModalProps {
5
+ onLoginSuccess: (mode: Mode) => void;
6
+ onClose: () => void;
7
+ }
8
+
9
+ const LoginModal: React.FC<LoginModalProps> = ({ onLoginSuccess, onClose }) => {
10
+ const [username, setUsername] = useState('');
11
+ const [password, setPassword] = useState('');
12
+ const [hasFocused, setHasFocused] = useState(false);
13
+ const [error, setError] = useState<string | null>(null);
14
+ const [isSubmitting, setIsSubmitting] = useState(false);
15
+ const modalRef = useRef<HTMLDivElement>(null);
16
+ const usernameRef = useRef<HTMLInputElement>(null);
17
+
18
+ const CONTROLLER_NAME = process.env.CONTROLLER_NAME || '';
19
+ const CONTROLLER_PASSWORD = process.env.CONTROLLER_PASSWORD || '';
20
+
21
+ if (!CONTROLLER_NAME || !CONTROLLER_PASSWORD) {
22
+ throw new Error("Environment variables for login credentials are not set.");
23
+ }
24
+
25
+ useEffect(() => {
26
+ if (!hasFocused) {
27
+ setHasFocused(true);
28
+ usernameRef.current?.focus();
29
+ }
30
+
31
+ const handleEsc = (event: KeyboardEvent) => {
32
+ if (event.key === 'Escape') {
33
+ onClose();
34
+ }
35
+ };
36
+ window.addEventListener('keydown', handleEsc);
37
+
38
+ return () => {
39
+ window.removeEventListener('keydown', handleEsc);
40
+ };
41
+ }, [onClose]);
42
+
43
+ const handleSubmit = (e: React.FormEvent) => {
44
+ e.preventDefault();
45
+ setError(null);
46
+ setIsSubmitting(true);
47
+
48
+ setTimeout(() => {
49
+ if (username === CONTROLLER_NAME && password === CONTROLLER_PASSWORD) {
50
+ onLoginSuccess('controller');
51
+ } else {
52
+ setError('Username atau PIN/Password salah. Silakan coba lagi.');
53
+ }
54
+ setIsSubmitting(false);
55
+ }, 500);
56
+ };
57
+
58
+ const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
59
+ if (modalRef.current && e.target === modalRef.current) {
60
+ onClose();
61
+ }
62
+ };
63
+
64
+ return (
65
+ <div
66
+ ref={modalRef}
67
+ onClick={handleBackdropClick}
68
+ className="fixed inset-0 bg-black bg-opacity-70 flex items-center justify-center z-50 transition-opacity duration-300 animate-fade-in"
69
+ >
70
+ <div className="bg-gray-800 border border-gray-700 rounded-lg shadow-xl p-6 sm:p-8 w-full max-w-sm transform animate-scale-in">
71
+ <h2 className="text-2xl font-bold text-center text-indigo-400 mb-6">Login</h2>
72
+ <form onSubmit={handleSubmit} className="space-y-4">
73
+ <div>
74
+ <label htmlFor="username" className="block text-sm font-medium text-gray-300 mb-1">
75
+ Username
76
+ </label>
77
+ <input
78
+ ref={usernameRef}
79
+ type="text"
80
+ id="username"
81
+ value={username}
82
+ onChange={(e) => setUsername(e.target.value)}
83
+ className="w-full bg-gray-900 border border-gray-600 rounded-md px-3 py-2 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
84
+ required
85
+ />
86
+ </div>
87
+ <div>
88
+ <label htmlFor="password" className="block text-sm font-medium text-gray-300 mb-1">
89
+ PIN / Password
90
+ </label>
91
+ <input
92
+ type="password"
93
+ id="password"
94
+ value={password}
95
+ onChange={(e) => setPassword(e.target.value)}
96
+ className="w-full bg-gray-900 border border-gray-600 rounded-md px-3 py-2 focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
97
+ required
98
+ />
99
+ </div>
100
+ {error && <p className="text-red-400 text-sm text-center">{error}</p>}
101
+ <div className="pt-2 flex flex-col sm:flex-row-reverse gap-3">
102
+ <button
103
+ type="submit"
104
+ disabled={isSubmitting}
105
+ className="w-full px-4 py-2 bg-indigo-600 hover:bg-indigo-700 rounded-md font-semibold text-white transition-colors disabled:bg-indigo-800 disabled:cursor-not-allowed flex items-center justify-center"
106
+ >
107
+ {isSubmitting && <SpinnerIcon />}
108
+ {isSubmitting ? 'Memverifikasi...' : 'Login'}
109
+ </button>
110
+ <button
111
+ type="button"
112
+ onClick={onClose}
113
+ className="w-full px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded-md font-semibold text-white transition-colors"
114
+ >
115
+ Batal
116
+ </button>
117
+ </div>
118
+ </form>
119
+ </div>
120
+ <style>{`
121
+ @keyframes fade-in {
122
+ from { opacity: 0; }
123
+ to { opacity: 1; }
124
+ }
125
+ @keyframes scale-in {
126
+ from { opacity: 0; transform: scale(0.95); }
127
+ to { opacity: 1; transform: scale(1); }
128
+ }
129
+ .animate-fade-in { animation: fade-in 0.3s ease-out forwards; }
130
+ .animate-scale-in { animation: scale-in 0.3s ease-out forwards; }
131
+ `}</style>
132
+ </div>
133
+ );
134
+ };
135
+
136
+ const SpinnerIcon = () => (
137
+ <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
138
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
139
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
140
+ </svg>
141
+ );
142
+
143
+
144
+ export default LoginModal;
components/Player.tsx ADDED
@@ -0,0 +1,314 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
2
+ import { getData, saveData } from '../services/githubService';
3
+ import { type Mode, type GitHubData, defaultData, LastAction, reversePaths, realPaths } from '../types';
4
+ import LoginModal from './LoginModal';
5
+ import UpdateRefreshDataMode from './UpdateRefreshDataMode';
6
+ import { FaEllipsisV } from 'react-icons/fa';
7
+
8
+ // Tipe untuk props
9
+ interface PlayerProps {
10
+ preloadedUrls: Record<string, string>; // Kunci: path asli, Nilai: Object URL
11
+ totalSegments: number;
12
+ }
13
+
14
+ const Player: React.FC<PlayerProps> = ({ preloadedUrls, totalSegments }) => {
15
+ // === State & Refs ===
16
+ const [mode, setMode] = useState<Mode>('preview');
17
+ const [isMobileMenuOpen, setMobileMenuOpen] = useState(false)
18
+ const [isLoginVisible, setLoginVisible] = useState(false);
19
+ const [githubData, setGithubData] = useState<GitHubData>(defaultData);
20
+ const [sha, setSha] = useState("");
21
+ const [isPlaying, setPlaying] = useState(false);
22
+ const [currentSegmentIdx, setCurrentSegmentIdx] = useState(0);
23
+ const saveRef = useRef<HTMLButtonElement>(null);
24
+ const appWrapRef = useRef<HTMLDivElement>(null);
25
+ const [lastActionsState, setLastActionsState] = useState<[LastAction, LastAction]>([null, null]);
26
+
27
+ const [activePlayerIndex, setActivePlayerIndex] = useState(0);
28
+
29
+ const videoRefs = [
30
+ useRef<HTMLVideoElement>(null),
31
+ useRef<HTMLVideoElement>(null),
32
+ useRef<HTMLVideoElement>(null),
33
+ ];
34
+
35
+ const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));
36
+
37
+ useEffect(() => {
38
+ videoRefs.forEach((ref, index) => {
39
+ const video = ref.current;
40
+ if (video && video.src) {
41
+ if (index === activePlayerIndex) {
42
+ video.play().catch(e => {});
43
+ } else {
44
+ console.log({currentSegmentIdx});
45
+ if (currentSegmentIdx === 0) {
46
+ video.currentTime = 0;
47
+ }
48
+ video.pause();
49
+ }
50
+ }
51
+ });
52
+ }, [activePlayerIndex]);
53
+
54
+ const updatePlayerAndBuffers = useCallback((isSaveOnly: boolean, newIndex?: number, direction?: LastAction) => {
55
+ console.log({currentSegmentIdx, newIndex, direction});
56
+ if (newIndex < 0 || newIndex > totalSegments - 1) return;
57
+
58
+ (async () => {
59
+ if (mode !== 'controller') return;
60
+
61
+ const dataToSave: GitHubData = {
62
+ idx: newIndex,
63
+ direction: direction
64
+ };
65
+
66
+ let newSha = sha;
67
+
68
+ try {
69
+ const response = await getData();
70
+ newSha = response.sha;
71
+ } catch (error) {
72
+ console.error("Gagal mengambil data sebelum menyimpan:", error);
73
+ }
74
+
75
+ try {
76
+ await saveData(dataToSave, "Update data", newSha);
77
+ console.log('Data berhasil disimpan!');
78
+ } catch (error) {
79
+ console.error("Gagal menyimpan data:", error);
80
+ }
81
+ })();
82
+
83
+ if (isSaveOnly) return;
84
+
85
+ // Tentukan player mana yang akan menjadi aktif
86
+ const nextPlayerIndex = (activePlayerIndex + 1) % 3;
87
+
88
+ // Tentukan URL untuk player yang akan aktif
89
+ const activeUrlPath = direction === 'prev'
90
+ ? `${reversePaths}${newIndex}.mp4`
91
+ : `${realPaths}${newIndex}.mp4`;
92
+
93
+ // Set src untuk player yang akan aktif
94
+ const activePlayer = videoRefs[nextPlayerIndex].current;
95
+ if (activePlayer) {
96
+ activePlayer.src = preloadedUrls[activeUrlPath];
97
+ }
98
+
99
+ // Ganti player yang aktif
100
+ setActivePlayerIndex(nextPlayerIndex);
101
+ setCurrentSegmentIdx(newIndex);
102
+ setLastActionsState(prev => [prev[1], direction]);
103
+
104
+ // Update buffer untuk player yang tidak aktif
105
+ const nextBufferPlayer = videoRefs[(nextPlayerIndex + 1) % 3].current;
106
+ const prevBufferPlayer = videoRefs[(nextPlayerIndex + 2) % 3].current;
107
+
108
+ if (nextBufferPlayer && newIndex < totalSegments - 1) {
109
+ nextBufferPlayer.src = preloadedUrls[`${realPaths}${newIndex + 1}.mp4`];
110
+ }
111
+
112
+ if (prevBufferPlayer && newIndex > 0) {
113
+ prevBufferPlayer.src = preloadedUrls[`${reversePaths}${newIndex - 1}.mp4`];
114
+ }
115
+ }, [activePlayerIndex, totalSegments]);
116
+
117
+ const handleNext = useCallback(() => {
118
+ if (isPlaying) return;
119
+ const nextIdx = currentSegmentIdx + (lastActionsState[1] === null ? 0 : 1) - (lastActionsState[1] === 'prev' ? 1 : (lastActionsState[1] === null ? -1 : 0));
120
+ updatePlayerAndBuffers(false, nextIdx, 'next');
121
+ setLastActionsState(prev => [prev[1], 'next']);
122
+ }, [currentSegmentIdx, isPlaying, lastActionsState, totalSegments, preloadedUrls]);
123
+
124
+ const handlePrev = useCallback(() => {
125
+ if (isPlaying) return;
126
+ const prevIdx = currentSegmentIdx - (lastActionsState[1] === null ? 0 : 1) + (['next', 'jump'].includes(lastActionsState[1]) ? 1 : 0);
127
+ updatePlayerAndBuffers(false, prevIdx, 'prev');
128
+ setLastActionsState(prev => [prev[1], 'prev']);
129
+ }, [currentSegmentIdx, isPlaying, lastActionsState, totalSegments, preloadedUrls]);
130
+
131
+ const handleSkip = useCallback(() => {
132
+ const activePlayer = videoRefs[activePlayerIndex].current;
133
+ if (isPlaying && activePlayer) {
134
+ activePlayer.currentTime = activePlayer.duration - 0.01;
135
+ activePlayer.pause();
136
+ }
137
+ }, [currentSegmentIdx, isPlaying, lastActionsState, totalSegments, preloadedUrls]);
138
+
139
+ const handleSegmentInputChange = (e: React.ChangeEvent<HTMLInputElement> | number) => {
140
+ const page = clamp(Number(typeof e === 'number' ? e : e.target.value), 0, totalSegments - 1);
141
+ updatePlayerAndBuffers(false, page, 'jump');
142
+ setLastActionsState(prev => [prev[1], 'jump']);
143
+ }
144
+
145
+ const toggleFullScreen = () => {
146
+ if (!document.fullscreenElement) {
147
+ appWrapRef.current?.requestFullscreen();
148
+ } else {
149
+ document.exitFullscreen();
150
+ }
151
+ };
152
+
153
+ // === Logika Sinkronisasi Data (githubService) ===
154
+ const fetchData = useCallback(async () => {
155
+ try {
156
+ const response = await getData();
157
+ setGithubData(response.data);
158
+ setSha(response.sha);
159
+ } catch (error) {
160
+ console.error("Gagal mengambil data:", error);
161
+ }
162
+ }, []);
163
+
164
+ // Handler untuk event keyboard
165
+ useEffect(() => {
166
+ const handleKeyDown = (event: KeyboardEvent) => {
167
+ // Hanya aktifkan shortcut keyboard jika mode adalah presenter
168
+ if (mode === 'controller') {
169
+ if (event.key === 'ArrowRight') {
170
+ isPlaying ? handleSkip() : handleNext();
171
+ } else if (event.key === 'ArrowLeft') {
172
+ isPlaying ? handleSkip() : handlePrev();
173
+ } else if (event.key === 's') {
174
+ updatePlayerAndBuffers(true, currentSegmentIdx, lastActionsState[1])
175
+ }
176
+ }
177
+ };
178
+ window.addEventListener('keydown', handleKeyDown);
179
+ return () => window.removeEventListener('keydown', handleKeyDown);
180
+ }, [isPlaying, handleNext, handlePrev, mode]); // Tambahkan mode sebagai dependency
181
+
182
+ // Polling data untuk mode 'preview'
183
+ useEffect(() => {
184
+ if (mode === 'preview') {
185
+ fetchData(); // Ambil data saat pertama kali masuk mode preview
186
+ const intervalId = setInterval(fetchData, 5000);
187
+ return () => clearInterval(intervalId);
188
+ }
189
+ }, [mode, fetchData]);
190
+
191
+ // Reaksi terhadap perubahan data yang di-fetch dalam mode 'preview'
192
+ useEffect(() => {
193
+ const isPreviewMode = mode === 'preview';
194
+ const hasChangedGithubData = githubData && (githubData.idx !== currentSegmentIdx || githubData.direction !== lastActionsState[1]);
195
+ const someVideoIsNull = videoRefs.some(
196
+ videoRef => !videoRef.current || !videoRef.current.src || videoRef.current.src === "undefined"
197
+ );
198
+
199
+ console.log({ preloadedUrls: Object.values(preloadedUrls) })
200
+
201
+ console.log(isPreviewMode, hasChangedGithubData, someVideoIsNull);
202
+ console.log(mode, JSON.stringify(githubData), JSON.stringify(videoRefs.map(videoRef => videoRef.current.src || undefined)));
203
+
204
+ if (isPreviewMode && (hasChangedGithubData || someVideoIsNull)) {
205
+ console.log('Data baru terdeteksi:', githubData);
206
+ updatePlayerAndBuffers(false, githubData.idx, githubData.direction);
207
+ }
208
+ }, [githubData, mode, preloadedUrls]);
209
+
210
+ return (
211
+ <>
212
+ <main ref={appWrapRef} className="fixed inset-0 z-10 flex flex-col items-center justify-start w-screen h-screen bg-bg">
213
+ <div className="group fixed inset-0 flex h-screen w-screen items-end justify-center bg-black">
214
+ {videoRefs.map((ref, index) => (
215
+ <video
216
+ key={index}
217
+ ref={ref}
218
+ className="absolute inset-0 h-full w-full object-contain bg-black"
219
+ style={{ zIndex: activePlayerIndex === index ? 1 : 0 }}
220
+ onPlay={() => activePlayerIndex === index && setPlaying(true)}
221
+ onPause={() => activePlayerIndex === index && setPlaying(false)}
222
+ onEnded={() => activePlayerIndex === index && setPlaying(false)}
223
+ playsInline
224
+ preload="auto"
225
+ muted
226
+ />
227
+ ))}
228
+
229
+ <div className="absolute inset-x-0 bottom-0 sm:h-auto h-[25vh] z-20 flex w-full justify-center opacity-100 sm:opacity-0 transition-opacity duration-300 hover:opacity-100 backdrop-blur-sm">
230
+ <div className="flex w-full items-center justify-between rounded-t-2xl bg-bg/50 px-6 py-4 shadow-lg">
231
+ <div className="flex items-center justify-center gap-2.5">
232
+ <button
233
+ id="prevBtn"
234
+ className="btn"
235
+ onClick={handlePrev}
236
+ disabled={mode === 'preview' || isPlaying || currentSegmentIdx <= 0}
237
+ >⟨</button>
238
+ <button
239
+ id="nextBtn"
240
+ className="btn"
241
+ onClick={handleNext}
242
+ disabled={mode === 'preview' || isPlaying || currentSegmentIdx >= totalSegments - 1}
243
+ >⟩</button>
244
+ {isPlaying && <button id="skipBtn" className="btn" onClick={handleSkip}>⟨⟨ ⟩⟩</button>}
245
+ </div>
246
+
247
+ <div className="hidden items-center gap-4 sm:flex">
248
+ <UpdateRefreshDataMode
249
+ mode={mode}
250
+ saveRef={saveRef}
251
+ onFetch={fetchData}
252
+ onSave={() => updatePlayerAndBuffers(true, currentSegmentIdx, lastActionsState[1])}
253
+ onLogin={() => setLoginVisible(true)}
254
+ onLogout={() => setMode('preview')}
255
+ isMobile={isMobileMenuOpen}
256
+ />
257
+ </div>
258
+
259
+ <div className="flex items-center justify-center">
260
+ <input
261
+ type="number"
262
+ id="inputSegment"
263
+ className="btn w-[55px] text-center [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
264
+ min="0"
265
+ max={totalSegments - 1}
266
+ value={String(currentSegmentIdx)}
267
+ onChange={(e) => setCurrentSegmentIdx(Number(e.target.value))}
268
+ onBlur={handleSegmentInputChange}
269
+ onKeyDown={(e) => {
270
+ if (e.key === 'Enter') handleSegmentInputChange(currentSegmentIdx);
271
+ }}
272
+ disabled={mode === 'preview'}
273
+ />
274
+ <button id="fsBtn" className="btn small ml-2.5" onClick={toggleFullScreen}>⤢</button>
275
+ </div>
276
+ </div>
277
+ </div>
278
+
279
+ <div className="absolute top-0 right-0 z-30 p-4 sm:hidden">
280
+ <button onClick={() => setMobileMenuOpen(!isMobileMenuOpen)} className="btn small">
281
+ <FaEllipsisV />
282
+ </button>
283
+ </div>
284
+ </div>
285
+
286
+ <div className={`fixed bottom-0 z-40 w-full p-4 bg-bg/80 backdrop-blur-sm transition-transform duration-300 ease-in-out sm:hidden ${isMobileMenuOpen ? 'translate-y-0' : 'translate-y-full'}`}>
287
+ <div className="flex flex-col items-center gap-4">
288
+ <UpdateRefreshDataMode
289
+ mode={mode}
290
+ saveRef={saveRef}
291
+ onFetch={fetchData}
292
+ onSave={() => updatePlayerAndBuffers(true, currentSegmentIdx, lastActionsState[1])}
293
+ onLogin={() => setLoginVisible(true)}
294
+ onLogout={() => setMode('preview')}
295
+ isMobile={isMobileMenuOpen}
296
+ />
297
+ <button onClick={() => setMobileMenuOpen(false)} className="btn w-full mt-2">Tutup</button>
298
+ </div>
299
+ </div>
300
+ {isLoginVisible && (
301
+ <LoginModal
302
+ onClose={() => setLoginVisible(false)}
303
+ onLoginSuccess={(selectedMode) => {
304
+ setMode(selectedMode);
305
+ setLoginVisible(false);
306
+ }}
307
+ />
308
+ )}
309
+ </main>
310
+ </>
311
+ );
312
+ };
313
+
314
+ export default Player;
components/UpdateRefreshDataMode.tsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { Ref } from 'react';
2
+ import type { Mode } from '../types';
3
+ import { FaRedo, FaSave, FaSignInAlt, FaSignOutAlt } from 'react-icons/fa';
4
+
5
+ interface UpdateRefreshDataModeProps {
6
+ mode: Mode;
7
+ saveRef: Ref<HTMLButtonElement>;
8
+ onFetch: () => void;
9
+ onSave: () => void;
10
+ onLogin: () => void;
11
+ onLogout: () => void;
12
+ isMobile: boolean
13
+ }
14
+
15
+ const UpdateRefreshDataMode: React.FC<UpdateRefreshDataModeProps> = ({ mode, saveRef, onFetch, onSave, onLogin, onLogout, isMobile }) => {
16
+ return (
17
+ <>
18
+ <div className={`flex justify-between items-center gap-10 ${isMobile ? "flex-col" : ""}`}>
19
+ <button
20
+ onClick={onFetch}
21
+ className={`flex items-center justify-center px-6 py-3 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-indigo-500 transition-transform transform hover:scale-105`}
22
+ >
23
+ <FaRedo className="h-4 w-4 mr-2" />
24
+ Refresh
25
+ </button>
26
+
27
+ {mode === "controller" &&
28
+ <button
29
+ ref={saveRef}
30
+ onClick={onSave}
31
+ className={`flex items-center justify-center px-6 py-3 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-indigo-500 transition-transform transform hover:scale-105`}
32
+ >
33
+ <FaSave className="h-4 w-4 mr-2" />
34
+ Save
35
+ </button>
36
+ }
37
+
38
+ <button
39
+ onClick={mode === "preview" ? onLogin : onLogout}
40
+ className={`flex items-center justify-center px-6 py-3 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-900 focus:ring-indigo-500 transition-transform transform hover:scale-105`}
41
+ >
42
+ {mode === "preview" ? (
43
+ <>
44
+ <FaSignInAlt className="h-4 w-4 mr-2" />
45
+ Login
46
+ </>
47
+ ) : (
48
+ <>
49
+ <FaSignOutAlt className="h-4 w-4 mr-2" />
50
+ Logout
51
+ </>
52
+ )}
53
+ </button>
54
+ </div>
55
+ </>
56
+ )
57
+ }
58
+
59
+ export default UpdateRefreshDataMode;
data.json ADDED
@@ -0,0 +1 @@
 
 
1
+ []
index.html ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="id">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>PPT BUDAYA INDONESIA HTML CONTROL LOGIC</title>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <link rel="icon" href="/icon/Icon_Full.png" type="image/x-icon">
10
+ <link rel="stylesheet" href="styles.css">
11
+
12
+ <script type="importmap">
13
+ {
14
+ "imports": {
15
+ "react-dom/": "https://aistudiocdn.com/react-dom@^19.2.0/",
16
+ "@google/genai": "https://aistudiocdn.com/@google/genai@^1.25.0",
17
+ "react/": "https://aistudiocdn.com/react@^19.2.0/",
18
+ "react": "https://aistudiocdn.com/react@^19.2.0"
19
+ }
20
+ }
21
+ </script>
22
+ </head>
23
+ <body class="bg-gray-900 text-white">
24
+ <div id="root"></div>
25
+ <script type="module" src="/index.tsx"></script>
26
+ </body>
27
+ </html>
index.ts ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import cors from 'cors';
5
+ import { existsSync } from 'fs';
6
+ import { Buffer } from 'buffer';
7
+
8
+ const app = express();
9
+ const PORT = process.env.PORT || 4000;
10
+ // ES module compatible __dirname replacement
11
+ let dirname = decodeURIComponent(new URL(import.meta.url).pathname);
12
+ dirname = path.dirname(dirname);
13
+ // Windows: remove leading slash if present (e.g. /E:/...)
14
+ if (process.platform === 'win32') {
15
+ if (dirname.startsWith('/')) {
16
+ dirname = dirname.slice(1);
17
+ }
18
+ // Handle Windows backslashes in file URL
19
+ dirname = dirname.replace(/\\/g, '/');
20
+ }
21
+ const DATA_PATH = path.resolve(dirname, 'data.json');
22
+ console.log('Data path:', DATA_PATH);
23
+
24
+ app.use(cors());
25
+ app.use(express.json());
26
+
27
+ // Ensure data.json exists with empty array as default
28
+ async function ensureDataFile() {
29
+ try {
30
+ if (!existsSync(DATA_PATH)) {
31
+ await fs.writeFile(DATA_PATH, '[]', 'utf8');
32
+ }
33
+ } catch (err) {
34
+ console.error('Failed to create data.json:', err);
35
+ }
36
+ }
37
+
38
+ // Initialize data file
39
+ ensureDataFile();
40
+
41
+ const decodeStrToBase64 = (str: string):string => Buffer.from(str, 'base64').toString('binary');
42
+ const encodeBase64ToStr = (str: string):string => Buffer.from(str, 'binary').toString('base64');
43
+
44
+ const randomSha = () => Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
45
+
46
+ app.get('/api/data', async (_req, res) => {
47
+ try {
48
+ if (!existsSync(DATA_PATH)) {
49
+ return res.json({});
50
+ }
51
+ const data = await fs.readFile(DATA_PATH, 'utf8');
52
+ try {
53
+ const result = encodeBase64ToStr(data);
54
+ res.json({ content: result, sha: randomSha() });
55
+ } catch (parseErr) {
56
+ console.error('Invalid JSON in data.json:', parseErr);
57
+ res.status(500).json({ error: 'Invalid JSON in data.json' });
58
+ }
59
+ } catch (err) {
60
+ console.error('Failed to read data.json:', err);
61
+ if (err.code === 'ENOENT') {
62
+ return res.json([]);
63
+ } else {
64
+ return res.status(500).json({ error: 'Failed to read data.json' });
65
+ }
66
+ }
67
+ });
68
+
69
+ app.post('/api/data', async (req, res) => {
70
+ const data = req.body;
71
+ const result = decodeStrToBase64(data.content);
72
+ try {
73
+ await fs.writeFile(DATA_PATH, result || "{}", 'utf8');
74
+ res.json({ content: {sha: randomSha() } });
75
+ } catch (err) {
76
+ console.error('Failed to write data.json:', err);
77
+ res.status(500).json({ error: `Failed to write data.json: ${err.message}` });
78
+ }
79
+ });
80
+
81
+ app.listen(PORT, () => {
82
+ console.log(`✅ Express server running on http://localhost:${PORT}`);
83
+ });
index.tsx ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import React from 'react';
3
+ import ReactDOM from 'react-dom/client';
4
+ import App from './App';
5
+
6
+ const rootElement = document.getElementById('root');
7
+ if (!rootElement) {
8
+ throw new Error("Could not find root element to mount to");
9
+ }
10
+
11
+ const root = ReactDOM.createRoot(rootElement);
12
+ root.render(
13
+ <React.StrictMode>
14
+ <App />
15
+ </React.StrictMode>
16
+ );
metadata.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "name": "Interactive Video Presentation Controller",
3
+ "description": "Sebuah sistem presentasi video interaktif dengan mode 'Controller' dan 'Preview' yang disinkronkan secara real-time menggunakan GitHub Gist API.",
4
+ "requestFramePermissions": []
5
+ }
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "pencatat-soal",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "concurrently \"vite\" \"node index.ts\"",
8
+ "build": "vite build",
9
+ "preview": "vite preview"
10
+ },
11
+ "dependencies": {
12
+ "@google/genai": "^1.25.0",
13
+ "@octokit/core": "^7.0.5",
14
+ "axios": "^1.12.2",
15
+ "cors": "^2.8.5",
16
+ "jspdf": "^3.0.3",
17
+ "pdfjs-dist": "^5.4.296",
18
+ "react": "^19.2.0",
19
+ "react-dom": "^19.2.0",
20
+ "react-icons": "^5.5.0",
21
+ "react-spinners": "^0.17.0",
22
+ "vite-plugin-pwa": "^1.1.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.14.0",
26
+ "@types/react": "^19.2.2",
27
+ "@types/react-dom": "^19.2.2",
28
+ "@vitejs/plugin-react": "^5.0.0",
29
+ "concurrently": "^9.2.1",
30
+ "express": "^5.1.0",
31
+ "typescript": "~5.8.2",
32
+ "vite": "^6.2.0"
33
+ }
34
+ }
public/assets/videos/real_0.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9cbb93a17e0604ef0d76af1fbef28e7918cc421d149b0a6d84beee3ad2148320
3
+ size 3735246
public/assets/videos/real_1.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2e32aaa6d8cc6bdd7d371868282fa202ad2bc4042b3105388ab0f4c2af9b5874
3
+ size 4174300
public/assets/videos/real_10.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:62230adc0e9f270db6604a9c14e4ce8f6aa620c1483e0ea74adceeceb0c42fcd
3
+ size 4547384
public/assets/videos/real_11.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:25e6b1dc58313ffba4251956c3d00ed3f49c4da7d0d34536664e163558329671
3
+ size 4234298
public/assets/videos/real_12.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6eed3de82e0ec23c7ca3ae7b858d4fe7bbe4639ad57ffafa87d8e2d3c54b02ad
3
+ size 2090580
public/assets/videos/real_13.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e77c3645d494ec82dbc1df3312c7237200f2dea47dee406e86e72b592409e4c5
3
+ size 3972103
public/assets/videos/real_14.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c8c1d0f290a7d96d67d0a3934b3fee3567944026ec916ac71e66c2d70b16cd59
3
+ size 3968402
public/assets/videos/real_15.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:81f821c1c772c635a0905ff1e5806cc1860b9dba602907b85eebd74427549b86
3
+ size 4091631
public/assets/videos/real_16.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ad659eec47457bc8b76645bca13b3bcc092ad3ad1e381c4977e6965e3b8a57eb
3
+ size 2232686
public/assets/videos/real_17.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fee05ce457329d50966f5006a305a88aa5efcbbf62e2503201b0d3474fb305e0
3
+ size 3948220
public/assets/videos/real_18.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d5f1b509062996c05a1192febbd6972ae535b35ea9c04df83d15e4b457031c02
3
+ size 3950956
public/assets/videos/real_19.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1754f6fda1b2e2070a8b8ddbfc086385f2002cfd389a3d22266e93b5f9ab053e
3
+ size 3787307
public/assets/videos/real_2.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f026f3700cc72382f8bf023cf40a128c5296b840a3d1b5adf5b0ae0ac0aa928d
3
+ size 4359900
public/assets/videos/real_20.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:310ea2683a34fac942602650abcbc19e2f2cd84ceca626d3cf800ab6751072e1
3
+ size 1400941
public/assets/videos/real_21.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d79c59f56d8c6ebe63cc9bd7356f2f3add967a111759742c3cfea1393bf1ec23
3
+ size 3400946
public/assets/videos/real_22.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:843540a2a052edb37c99201bf74bd27d0ac73e2451c46d3c50a7fe87aa402932
3
+ size 3413783
public/assets/videos/real_23.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:16381f4e723d1b2856a16be75b6bd1270e9bdacafb84e6a89a1c01b4d9cbc247
3
+ size 3641821
public/assets/videos/real_24.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:49c0f77a522c8048abfd0eb1d38855ffd01b5e125d84cfe8203efa2d75b0a3ac
3
+ size 1717925
public/assets/videos/real_25.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:969f4a74242a2e93599b9d4524810dff2f73999189a5eabaf7c210eb0f0c1e95
3
+ size 3878854
public/assets/videos/real_26.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8f4ab908ca5af1e675fa470f12ed8cb649a0853c2fddf6461d140bfa613cb0f7
3
+ size 4244007
public/assets/videos/real_27.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7babc15b75a21c615c6a2bbe40ff795c9cce4eebd4fe2a59b38978e52a52d257
3
+ size 4391328
public/assets/videos/real_28.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:42d1b6b44bb5703c058c4a3d3d68a8c8c507682f4a198027214a1bb3551732ff
3
+ size 2529054
public/assets/videos/real_29.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:84c5c74a7c20b7f2ada11ca7bbb733626fca58171f995f73d3b64e25213c243b
3
+ size 3749539
public/assets/videos/real_3.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6432bf50db5fff7d8e5d2145a53720e416211a55ad99451d9502f2d74e9b014c
3
+ size 3469974
public/assets/videos/real_30.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:795242b270bc71c7e78ee24eb79417159104875b637f363d954b18f85721ab35
3
+ size 3820793
public/assets/videos/real_31.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:df6ba88622d8adc20f34e56deec9843ce1fb8aedd7b879290b02f59bdbb0e633
3
+ size 3798127
public/assets/videos/real_32.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a9fffb1a222585dfd9e038aadb3ef6eae67cd259045b69dc52d2689c000a399b
3
+ size 1617585
public/assets/videos/real_33.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ff8259dbf90a8eed497df8bd2984a6404c037b5c42d29744f2f57bd771144a75
3
+ size 4080431
public/assets/videos/real_34.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0aac3008d5772f3abda2e7d3160acf1e1dbf868c984ac512d5195cb9d43a3cd2
3
+ size 4155432
public/assets/videos/real_35.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3fd63898cc8cd0c0589fc0ff845a2970d840e1ad185fae66f42990acdb512e48
3
+ size 4049220
public/assets/videos/real_36.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:10bdab1d1e53a913cc68383d2fc767ab48973964f7f4b9bdfecc31533d6e7de5
3
+ size 1687502
public/assets/videos/real_37.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:afeb8d3e45dadb39cddba1597e1373f13eebead34a82f7337af2a586adf85c39
3
+ size 3943424
public/assets/videos/real_38.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:069d900723296ec706b9286fcf887589e44ab15e76437f8e28e9a05886194972
3
+ size 4048348
public/assets/videos/real_39.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7d9ceb9c2fc3ae7591a62a010a9ca39b6e35ef47f1e42896870bcf9d1e4e9684
3
+ size 4028342
public/assets/videos/real_4.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4c638e95c3c5ca0678c8269390f5e96ce3e9c13c52721c625ce0534ac30039dc
3
+ size 3152333