Commit
·
1038d6b
0
Parent(s):
Start
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .gitattributes +7 -0
- .gitignore +29 -0
- App.tsx +83 -0
- README.md +161 -0
- components/LoadingScreen.tsx +80 -0
- components/LoginModal.tsx +144 -0
- components/Player.tsx +314 -0
- components/UpdateRefreshDataMode.tsx +59 -0
- data.json +1 -0
- index.html +27 -0
- index.ts +83 -0
- index.tsx +16 -0
- metadata.json +5 -0
- package-lock.json +0 -0
- package.json +34 -0
- public/assets/videos/real_0.mp4 +3 -0
- public/assets/videos/real_1.mp4 +3 -0
- public/assets/videos/real_10.mp4 +3 -0
- public/assets/videos/real_11.mp4 +3 -0
- public/assets/videos/real_12.mp4 +3 -0
- public/assets/videos/real_13.mp4 +3 -0
- public/assets/videos/real_14.mp4 +3 -0
- public/assets/videos/real_15.mp4 +3 -0
- public/assets/videos/real_16.mp4 +3 -0
- public/assets/videos/real_17.mp4 +3 -0
- public/assets/videos/real_18.mp4 +3 -0
- public/assets/videos/real_19.mp4 +3 -0
- public/assets/videos/real_2.mp4 +3 -0
- public/assets/videos/real_20.mp4 +3 -0
- public/assets/videos/real_21.mp4 +3 -0
- public/assets/videos/real_22.mp4 +3 -0
- public/assets/videos/real_23.mp4 +3 -0
- public/assets/videos/real_24.mp4 +3 -0
- public/assets/videos/real_25.mp4 +3 -0
- public/assets/videos/real_26.mp4 +3 -0
- public/assets/videos/real_27.mp4 +3 -0
- public/assets/videos/real_28.mp4 +3 -0
- public/assets/videos/real_29.mp4 +3 -0
- public/assets/videos/real_3.mp4 +3 -0
- public/assets/videos/real_30.mp4 +3 -0
- public/assets/videos/real_31.mp4 +3 -0
- public/assets/videos/real_32.mp4 +3 -0
- public/assets/videos/real_33.mp4 +3 -0
- public/assets/videos/real_34.mp4 +3 -0
- public/assets/videos/real_35.mp4 +3 -0
- public/assets/videos/real_36.mp4 +3 -0
- public/assets/videos/real_37.mp4 +3 -0
- public/assets/videos/real_38.mp4 +3 -0
- public/assets/videos/real_39.mp4 +3 -0
- 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 |
+
[](https://reactjs.org/)
|
| 17 |
+
[](https://www.typescriptlang.org/)
|
| 18 |
+
[](https://www.python.org/)
|
| 19 |
+
[](https://opencv.org/)
|
| 20 |
+
[](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
|