This commit is contained in:
2025-05-13 00:22:39 +00:00
parent 31b51ea690
commit b9e84e1503
30 changed files with 4484 additions and 636 deletions

223
README.md
View File

@ -1,41 +1,58 @@
# Podcast Recorder
# 🎙️ Podcast Recorder
A professional desktop application built with Electron and React for recording high-quality podcast audio with automatic backup functionality.
A modern, user-friendly desktop application for recording and managing podcast episodes. Built with Electron and React, this application provides a seamless experience for podcast creators.
## Features
![Podcast Recorder Screenshot](screenshot.png)
- 🎙️ High-quality audio recording (48kHz, stereo)
- 💾 Automatic backup system with separate save locations
- 📊 Real-time audio level monitoring
- ⚙️ Configurable settings
- 🔄 Automatic file format conversion (WebM to WAV)
- 🎚️ Professional audio processing:
- Noise suppression
- Echo cancellation
- Automatic gain control
## ✨ Features
## Technology Stack
### 🎯 Core Functionality
- **High-Quality Audio Recording**
- Professional-grade audio capture
- Configurable bitrate, sample rate, and channels
- Real-time audio monitoring
- Automatic backup during recording
- **Frontend**: React + TypeScript
- **Styling**: Tailwind CSS
- **Desktop Framework**: Electron
- **Audio Processing**: Web Audio API
- **File Format Conversion**: audiobuffer-to-wav
- **Settings Management**: electron-store
- **Build Tools**: Vite
### 🎛️ Audio Settings
- **Customizable Recording Parameters**
- Bitrate: Up to 256kbps
- Sample Rate: Up to 48kHz
- Channel Configuration: Mono/Stereo
- Microphone Selection
## Getting Started
### 💾 File Management
- **Organized Storage**
- User-specific directories
- Automatic file naming
- Backup system during recording
- Easy file organization
### 🔄 Upload Integration
- **Seamless Publishing**
- Direct upload to podcast hosting
- Progress tracking
- Automatic metadata handling
- Support for multiple environments
### 🛠️ Technical Features
- **Built with Modern Technologies**
- Electron for cross-platform support
- React for responsive UI
- FFmpeg integration for audio processing
- Secure file handling
## 🚀 Getting Started
### Prerequisites
- Node.js (v16 or higher)
- npm (v7 or higher)
- Node.js (v14 or higher)
- npm or yarn
- FFmpeg (automatically installed by the application)
### Installation
1. Clone the repository:
```bash
git clone [repository-url]
git clone https://github.com/yourusername/podcast-recorder.git
cd podcast-recorder
```
@ -46,113 +63,99 @@ npm install
3. Start the development server:
```bash
npm run electron:dev
npm run dev
```
### Building for Production
To create a production build:
4. Build the application:
```bash
npm run electron:build
npm run build
```
## Project Structure
## 🎨 Usage
```
podcast-recorder/
├── electron/
│ ├── main.cjs # Main Electron process
│ └── preload.cjs # Preload script for IPC
├── src/
│ ├── App.tsx # Main React component
│ ├── audioUtils.ts # Audio processing utilities
│ ├── main.tsx # React entry point
│ └── index.css # Global styles
├── public/ # Static assets
└── package.json # Project configuration
```
1. **Launch the Application**
- Start the application
- Enter your email for user-specific storage
## How It Works
2. **Configure Settings**
- Select your microphone
- Adjust audio settings
- Choose save directory
### Recording Process
3. **Start Recording**
- Click the record button
- Monitor audio levels
- Use the pause feature when needed
1. **Initialization**:
- User enters their email for session identification
- Application creates unique session directories for both final recordings and backups
4. **Save and Upload**
- Stop recording when finished
- Review the recording
- Upload to your podcast hosting platform
2. **Audio Capture**:
- Uses the system's default audio input device
- Captures audio at 48kHz with stereo channels
- Applies real-time audio processing:
- Noise suppression
- Echo cancellation
- Automatic gain control
## 🔧 Configuration
3. **Backup System**:
- Records are saved in 1-second chunks during recording
- Backup chunks are stored in a separate directory
- Provides data safety in case of crashes or power failures
4. **File Management**:
- Original recording is saved in WebM format
- Final output is converted to WAV format
- Files are named using email and timestamp for easy organization
### Directory Structure
- **Save Directory**: Contains completed, processed recordings
- **Backup Directory**: Stores individual audio chunks during recording
## Configuration
The application stores its settings using `electron-store`. Default settings include:
```javascript
### Audio Settings
```json
{
saveDirectory: "<user-documents>/PodcastRecordings",
backupDirectory: "<user-documents>/PodcastBackups",
audioBitrate: 256000,
autoBackupInterval: 300000, // 5 minutes
fileFormat: "wav",
rodecasterEnabled: true
"audioSettings": {
"bitrate": 256000,
"sampleRate": 48000,
"channels": 2
}
}
```
## Development
### Directory Structure
```
PodcastUpptokur/
└── [email]/
├── recording_name.webm
└── recording_name_saved.webm
```
### IPC Communication
## 🛠️ Development
The application uses Electron's IPC (Inter-Process Communication) for communication between the main and renderer processes:
### Project Structure
```
podcast-recorder/
├── src/
│ ├── components/
│ ├── services/
│ └── App.tsx
├── electron/
│ ├── main.cjs
│ └── preload.cjs
└── public/
```
- `initRecordingSession`: Creates new recording session directories
- `saveAudioChunk`: Saves individual audio chunks during recording
- `finishRecordingSession`: Cleans up after recording completion
- `selectDirectory`: Handles directory selection dialog
- `getSettings`: Retrieves application settings
- `saveSettings`: Updates application settings
### Available Scripts
- `npm run dev` - Start development server
- `npm run build` - Build for production
- `npm run package` - Package the application
### Audio Processing Pipeline
1. **Capture**: Raw audio from input device
2. **Processing**: Apply noise suppression and echo cancellation
3. **Monitoring**: Real-time audio level visualization
4. **Chunking**: Split into 1-second segments
5. **Backup**: Save chunks to backup directory
6. **Conversion**: Convert final recording to WAV format
## Contributing
## 🤝 Contributing
1. Fork the repository
2. Create a feature branch
3. Commit your changes
4. Push to the branch
5. Create a Pull Request
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
4. Push to the branch (`git push origin feature/AmazingFeature`)
5. Open a Pull Request
## License
## 📝 License
[Add your license here]
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Support
## 🙏 Acknowledgments
[Add support information here]
- FFmpeg for audio processing
- Electron team for the framework
- React team for the UI library
- All contributors and users
## 📞 Support
For support, please open an issue in the GitHub repository or contact the maintainers.
---
Made with ❤️ by [Your Name/Organization]

View File

@ -1,31 +1,357 @@
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const { join } = require('path');
const { writeFile, mkdir, access } = require('fs').promises;
const { writeFile, mkdir, access, unlink, readFile } = require('fs').promises;
const { constants } = require('fs');
const Store = require('electron-store');
const path = require('path');
const fs = require('fs');
const https = require('https');
const FormData = require('form-data');
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
const { spawn } = require('child_process');
const store = new Store();
// Log file path
const LOG_FILE_PATH = path.join(app.getPath('userData'), 'app.log');
// Ensure log file exists
if (!fs.existsSync(LOG_FILE_PATH)) {
fs.writeFileSync(LOG_FILE_PATH, '');
}
// Log file management functions
function saveLog(logEntry) {
const logLine = JSON.stringify(logEntry) + '\n';
fs.appendFileSync(LOG_FILE_PATH, logLine);
}
function getLogs() {
try {
const logContent = fs.readFileSync(LOG_FILE_PATH, 'utf8');
return logContent
.split('\n')
.filter(line => line.trim())
.map(line => JSON.parse(line));
} catch (error) {
console.error('Error reading logs:', error);
return [];
}
}
function clearLogs() {
fs.writeFileSync(LOG_FILE_PATH, '');
}
// Check if ffmpeg is installed
async function checkFFmpeg() {
try {
// Look in extraResources path in packaged app, otherwise use relative dev path
const basePath = app.isPackaged
? process.resourcesPath
: join(__dirname, '..');
const ffmpegPath = join(basePath, 'ffmpeg', 'bin', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg');
console.log(`[Main] Checking for FFmpeg at: ${ffmpegPath}`); // Add log
await fs.promises.access(ffmpegPath);
console.log('[Main] FFmpeg found.'); // Add log
return true;
} catch (error) {
console.error('[Main] FFmpeg check failed:', error); // Add log
return false;
}
}
// Install FFmpeg
async function installFFmpeg() {
try {
console.log('Installing FFmpeg...');
// Create temp directory for download
const tempDir = join(app.getPath('temp'), 'ffmpeg-install');
await mkdir(tempDir, { recursive: true });
let downloadUrl;
let extractPath;
let ffmpegDir = join(app.getPath('userData'), 'ffmpeg');
if (process.platform === 'win32') {
downloadUrl = 'https://github.com/GyanD/codexffmpeg/releases/download/7.1.1/ffmpeg-7.1.1-full_build.zip';
const zipPath = join(tempDir, 'ffmpeg.zip');
console.log('Downloading FFmpeg from:', downloadUrl);
await new Promise((resolve, reject) => {
const file = fs.createWriteStream(zipPath);
https.get(downloadUrl, (response) => {
response.pipe(file);
file.on('finish', () => {
file.close();
resolve(null);
});
}).on('error', (err) => {
fs.unlink(zipPath, () => {});
reject(err);
});
});
// Extract the zip file
console.log('Extracting FFmpeg...');
extractPath = join(tempDir, 'extracted');
await mkdir(extractPath, { recursive: true });
const { stdout: extractOutput } = await execAsync(`powershell -command "Expand-Archive -Path '${zipPath}' -DestinationPath '${extractPath}' -Force"`);
console.log('Extraction output:', extractOutput);
// Copy FFmpeg files to a permanent location
await mkdir(ffmpegDir, { recursive: true });
const ffmpegFiles = ['ffmpeg.exe', 'ffprobe.exe', 'ffplay.exe'];
for (const file of ffmpegFiles) {
const sourcePath = join(extractPath, 'ffmpeg-7.1.1-full_build', 'bin', file);
const targetPath = join(ffmpegDir, file);
await fs.promises.copyFile(sourcePath, targetPath);
}
} else if (process.platform === 'darwin') {
// For macOS, we'll use Homebrew
try {
await execAsync('brew install ffmpeg');
ffmpegDir = '/usr/local/bin';
} catch (error) {
console.error('Error installing FFmpeg via Homebrew:', error);
throw new Error('Failed to install FFmpeg via Homebrew. Please install it manually.');
}
} else {
// For Linux, we'll use apt-get
try {
await execAsync('sudo apt-get update && sudo apt-get install -y ffmpeg');
ffmpegDir = '/usr/bin';
} catch (error) {
console.error('Error installing FFmpeg via apt-get:', error);
throw new Error('Failed to install FFmpeg via apt-get. Please install it manually.');
}
}
// Add FFmpeg directory to PATH
const userProfile = process.platform === 'win32' ? process.env.USERPROFILE : process.env.HOME;
const pathFile = join(userProfile, '.ffmpeg-path');
await fs.promises.writeFile(pathFile, ffmpegDir);
// Clean up
if (process.platform === 'win32') {
await fs.promises.rm(tempDir, { recursive: true, force: true });
}
console.log('FFmpeg installation completed successfully');
return true;
} catch (error) {
console.error('Error installing FFmpeg:', error);
throw error;
}
}
// Get FFmpeg download URL based on platform
function getFFmpegDownloadUrl() {
return process.platform === 'win32'
? 'https://www.gyan.dev/ffmpeg/builds/'
: process.platform === 'darwin'
? 'https://evermeet.cx/ffmpeg/'
: 'https://ffmpeg.org/download.html';
}
// Initialize default settings if they don't exist
if (!store.get('settings')) {
store.set('settings', {
saveDirectory: join(app.getPath('documents'), 'PodcastRecordings'),
backupDirectory: join(app.getPath('documents'), 'PodcastBackups'),
audioBitrate: 256000,
autoBackupInterval: 300000, // 5 minutes in milliseconds
fileFormat: 'wav',
rodecasterEnabled: true
});
async function initializeDefaultSettings() {
const defaultSaveDir = join(app.getPath('music'), 'PodcastUpptokur');
console.log('Initializing default save directory:', defaultSaveDir);
// Ensure the base directory exists
try {
await access(defaultSaveDir, constants.F_OK);
} catch {
console.log('Creating default directory:', defaultSaveDir);
await mkdir(defaultSaveDir, { recursive: true });
}
// Initialize settings with default values
const defaultSettings = {
saveDirectory: defaultSaveDir,
borgEnvironment: 'borg.unak.is',
audioSettings: {
bitrate: 256000,
sampleRate: 48000,
channels: 2
},
userSettings: {} // Store user-specific settings
};
// Get current settings or use defaults
const currentSettings = store.get('settings') || defaultSettings;
// Merge current settings with defaults, preserving user-specific settings
const mergedSettings = {
...defaultSettings,
...currentSettings,
userSettings: {
...defaultSettings.userSettings,
...(currentSettings.userSettings || {})
}
};
// Save the merged settings
store.set('settings', mergedSettings);
console.log('Settings initialized:', mergedSettings);
}
// Get FFmpeg path
async function getFFmpegPath() {
try {
// Look in extraResources path in packaged app, otherwise use relative dev path
const basePath = app.isPackaged
? process.resourcesPath
: join(__dirname, '..');
const ffmpegPath = join(basePath, 'ffmpeg', 'bin', process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg');
// Check access again to be sure
try {
await fs.promises.access(ffmpegPath);
console.log('[Main] Found FFmpeg path for execution:', ffmpegPath);
return ffmpegPath;
} catch (accessError) {
console.error('[Main] FFmpeg not accessible at expected path:', ffmpegPath, accessError);
// Attempt fallback or throw error? For now, return null
return null;
}
} catch (error) {
console.error('[Main] Error getting FFmpeg path:', error);
return null;
}
}
// Process audio file with ffmpeg to ensure proper metadata
async function processAudioFile(inputPath, outputPath, email) {
try {
// Check if FFmpeg is installed
const ffmpegPath = await getFFmpegPath();
if (!ffmpegPath) {
throw new Error('FFmpeg is not installed');
}
// Get current date in ISO format
const currentDate = new Date().toISOString();
// Create FFmpeg command with explicit metadata
const ffmpegCommand = [
'-i', inputPath,
'-c:a', 'libopus', // Use Opus codec for WebM
'-b:a', '256k', // Set bitrate to 256k
'-ar', '48000', // Set sample rate to 48kHz
'-ac', '2', // Set to stereo
'-metadata', 'title=Podcast Recording',
'-metadata', `artist=${email}`,
'-metadata', 'album=PodcastUpptokur',
'-metadata', `date=${currentDate}`,
'-metadata', 'encoder=PodcastUpptokur',
'-metadata', 'copyright=© 2025 PodcastUpptokur',
'-metadata', 'genre=Podcast',
'-metadata', 'language=is', // Icelandic language code
'-y', // Overwrite output file if it exists
outputPath
];
console.log('Running FFmpeg command:', ffmpegCommand.join(' '));
// Run FFmpeg command
await new Promise((resolve, reject) => {
const ffmpeg = spawn(ffmpegPath, ffmpegCommand);
let errorOutput = '';
ffmpeg.stderr.on('data', (data) => {
errorOutput += data.toString();
console.log('FFmpeg progress:', data.toString());
});
ffmpeg.on('close', (code) => {
if (code === 0) {
console.log('FFmpeg processing completed successfully');
resolve();
} else {
console.error('FFmpeg error output:', errorOutput);
reject(new Error(`FFmpeg process exited with code ${code}`));
}
});
ffmpeg.on('error', (err) => {
console.error('FFmpeg process error:', err);
reject(err);
});
});
// Verify the output file exists and has content
const stats = await fs.promises.stat(outputPath);
if (stats.size === 0) {
throw new Error('FFmpeg produced an empty file');
}
// Verify metadata using FFprobe
const ffprobePath = path.join(path.dirname(ffmpegPath), process.platform === 'win32' ? 'ffprobe.exe' : 'ffprobe');
const ffprobeCommand = [
'-v', 'error',
'-show_entries', 'format_tags',
'-of', 'json',
outputPath
];
const metadata = await new Promise((resolve, reject) => {
const ffprobe = spawn(ffprobePath, ffprobeCommand);
let output = '';
ffprobe.stdout.on('data', (data) => {
output += data.toString();
});
ffprobe.on('close', (code) => {
if (code === 0) {
try {
const metadata = JSON.parse(output);
resolve(metadata);
} catch (err) {
reject(new Error('Failed to parse FFprobe output'));
}
} else {
reject(new Error(`FFprobe process exited with code ${code}`));
}
});
});
console.log('File metadata:', metadata);
// Verify essential metadata fields
const tags = metadata.format?.tags || {};
const tagsLower = {};
for (const [key, value] of Object.entries(tags)) {
tagsLower[key.toLowerCase()] = value;
}
const requiredFields = ['title', 'artist', 'date'];
const missingFields = requiredFields.filter(field => !tagsLower[field]);
if (missingFields.length > 0) {
throw new Error(`Missing required metadata fields: ${missingFields.join(', ')}`);
}
return outputPath;
} catch (error) {
console.error('Error in processAudioFile:', error);
throw error;
}
}
let mainWindow;
let currentRecordingDir = null;
let currentBackupDir = null;
async function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
frame: false,
webPreferences: {
preload: join(__dirname, 'preload.cjs'),
contextIsolation: true,
@ -47,44 +373,43 @@ async function createWindow() {
mainWindow.loadFile(join(__dirname, '../dist/index.html'));
}
// Add CSS to make the top bar draggable
mainWindow.webContents.insertCSS(`
.window-drag {
-webkit-app-region: drag;
}
.window-no-drag {
-webkit-app-region: no-drag;
}
`);
mainWindow.on('closed', () => {
mainWindow = null;
});
}
app.whenReady().then(async () => {
// Start Vite dev server in development
if (!app.isPackaged) {
const { spawn } = require('child_process');
const viteProcess = spawn('npm', ['run', 'dev'], {
shell: true,
stdio: 'inherit'
});
viteProcess.on('error', (err) => {
console.error('Failed to start Vite dev server:', err);
app.quit();
});
// Wait for dev server to be ready
await new Promise((resolve) => {
const checkServer = async () => {
try {
const response = await fetch('http://127.0.0.1:5173');
if (response.status === 200) {
resolve();
} else {
setTimeout(checkServer, 100);
}
} catch {
setTimeout(checkServer, 100);
}
};
checkServer();
});
// Initialize default settings
await initializeDefaultSettings();
// Ensure the default directory exists
const settings = store.get('settings');
console.log('Current settings:', settings);
try {
await access(settings.saveDirectory, constants.F_OK);
console.log('Directory exists:', settings.saveDirectory);
} catch {
console.log('Creating directory:', settings.saveDirectory);
await mkdir(settings.saveDirectory, { recursive: true });
}
await createWindow();
createWindow();
// Check for ffmpeg after window is created
const hasFFmpeg = await checkFFmpeg();
// Notify renderer about ffmpeg status
mainWindow.webContents.send('ffmpeg-status', hasFFmpeg);
});
app.on('window-all-closed', () => {
@ -99,119 +424,451 @@ app.on('activate', () => {
}
});
async function validateDirectory(directory) {
// IPC handlers
ipcMain.handle('get-default-directory', async () => {
const settings = store.get('settings');
console.log('Getting default directory:', settings.saveDirectory);
// Ensure the directory exists
try {
await access(directory, constants.W_OK);
return true;
await access(settings.saveDirectory, constants.F_OK);
} catch {
try {
await mkdir(directory, { recursive: true });
await access(directory, constants.W_OK);
return true;
} catch (error) {
console.error('Error validating directory:', error);
return false;
}
console.log('Creating default directory:', settings.saveDirectory);
await mkdir(settings.saveDirectory, { recursive: true });
}
}
return settings.saveDirectory;
});
// Handle directory selection
ipcMain.handle('select-directory', async () => {
if (!mainWindow) {
throw new Error('Main window is not available');
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory']
});
if (!result.canceled && result.filePaths.length > 0) {
const newDir = result.filePaths[0];
console.log('Selected new directory:', newDir);
store.set('settings.saveDirectory', newDir);
return newDir;
}
throw new Error('No directory selected');
});
ipcMain.handle('save-recording', async (event, fileName, data, email) => {
try {
const result = await dialog.showOpenDialog(mainWindow, {
properties: ['openDirectory', 'createDirectory'],
title: 'Select Directory',
buttonLabel: 'Choose Directory'
});
const settings = store.get('settings');
if (!settings || !settings.saveDirectory) {
throw new Error('No save directory configured');
}
// Defensive check for email
if (!email || typeof email !== 'string' || email.trim() === '') {
console.error('No email provided to save-recording!');
throw new Error('Email is required to save a recording.');
}
// Create email-specific subfolder
const userDir = join(settings.saveDirectory, email);
console.log('User directory:', userDir);
if (result.canceled) {
return null;
// Ensure the user directory exists
try {
await access(userDir, constants.F_OK);
console.log('User directory exists:', userDir);
} catch {
console.log('Creating user directory:', userDir);
await mkdir(userDir, { recursive: true });
}
// Use the user directory for both temp and final files
const tempFilePath = join(userDir, `temp_${fileName}`);
const finalFilePath = join(userDir, fileName);
console.log('Saving recording to:', tempFilePath);
// Validate base64 data
if (!data || typeof data !== 'string') {
throw new Error('Invalid recording data');
}
if (result.filePaths.length > 0) {
const directory = result.filePaths[0];
const isValid = await validateDirectory(directory);
if (!isValid) {
throw new Error('Cannot write to selected directory');
}
return directory;
// Write to temporary file first
const buffer = Buffer.from(data, 'base64');
await writeFile(tempFilePath, buffer);
console.log('Temporary file written successfully');
// Process the audio file with FFmpeg
try {
await processAudioFile(tempFilePath, finalFilePath, email);
console.log('Audio processing completed');
} catch (error) {
console.error('Error processing audio:', error);
throw new Error('Failed to process audio file');
}
return null;
// Clean up temporary file
try {
await unlink(tempFilePath);
console.log('Temporary file cleaned up');
} catch (error) {
console.warn('Could not delete temporary file:', error);
}
return finalFilePath;
} catch (error) {
console.error('Error selecting directory:', error);
console.error('Error saving recording:', error);
throw error;
}
});
// Handle recording session initialization
ipcMain.handle('init-recording-session', async (event, { email }) => {
const settings = store.get('settings');
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const sessionDir = join(settings.saveDirectory, `${email}_${timestamp}`);
const backupDir = join(settings.backupDirectory, `${email}_${timestamp}_backup`);
ipcMain.handle('get-recording-path', async (event, fileName, email) => {
try {
await mkdir(sessionDir, { recursive: true });
await mkdir(backupDir, { recursive: true });
currentRecordingDir = sessionDir;
currentBackupDir = backupDir;
return { success: true, directory: sessionDir, backupDirectory: backupDir };
const settings = store.get('settings');
if (!settings || !settings.saveDirectory) {
throw new Error('No save directory configured');
}
// Use email-specific subfolder
const userDir = join(settings.saveDirectory, email);
const filePath = join(userDir, fileName);
console.log('Getting recording path:', filePath);
return filePath;
} catch (error) {
console.error('Failed to create recording session directories:', error);
return { success: false, error: error.message };
console.error('Error getting recording path:', error);
throw error;
}
});
// Handle audio chunk saves
ipcMain.handle('save-audio-chunk', async (event, { buffer, chunkIndex }) => {
if (!currentBackupDir) {
throw new Error('No active recording session');
}
ipcMain.handle('read-recording-data', async (event, fileName, email) => {
try {
const chunkFileName = `chunk_${chunkIndex.toString().padStart(5, '0')}.webm`;
const savePath = join(currentBackupDir, chunkFileName);
await writeFile(savePath, Buffer.from(buffer));
return { success: true, path: savePath };
} catch (error) {
console.error('Failed to save audio chunk:', error);
return { success: false, error: error.message };
}
});
// Handle recording session cleanup
ipcMain.handle('finish-recording-session', async () => {
currentRecordingDir = null;
currentBackupDir = null;
return { success: true };
});
// Handle settings management
ipcMain.handle('get-settings', () => {
return store.get('settings');
});
ipcMain.handle('save-settings', async (event, settings) => {
try {
// Validate both directories before saving settings
const isSaveValid = await validateDirectory(settings.saveDirectory);
const isBackupValid = await validateDirectory(settings.backupDirectory);
const settings = store.get('settings');
const userDir = path.join(settings.saveDirectory, email);
const filePath = path.join(userDir, fileName);
console.log('Reading file:', filePath);
if (!isSaveValid || !isBackupValid) {
throw new Error('Cannot write to one or both directories');
// Read file using fs.promises
const buffer = await fs.promises.readFile(filePath);
console.log('File read successfully, size:', buffer.length);
if (buffer.length === 0) {
throw new Error('File is empty');
}
// Convert buffer to base64
const base64 = buffer.toString('base64');
console.log('Base64 data length:', base64.length);
// Create data URL with proper MIME type
const dataUrl = `data:audio/webm;codecs=opus;base64,${base64}`;
console.log('Data URL length:', dataUrl.length);
return dataUrl;
} catch (error) {
console.error('Error reading recording:', error);
throw error;
}
});
ipcMain.handle('delete-recording', async (event, fileName, email) => {
try {
const settings = store.get('settings');
const userDir = join(settings.saveDirectory, email);
const filePath = join(userDir, fileName);
console.log('Deleting recording:', filePath);
try {
await unlink(filePath);
console.log('Recording deleted successfully');
} catch (error) {
// If the file doesn't exist, we can consider this a successful deletion
if (error.code === 'ENOENT') {
console.log('File already deleted:', filePath);
return;
}
throw error;
}
} catch (error) {
console.error('Error deleting recording:', error);
throw error;
}
});
ipcMain.handle('list-recordings', async (event, email) => {
try {
const settings = store.get('settings');
if (!settings || !settings.saveDirectory) {
throw new Error('No save directory configured');
}
const userDir = join(settings.saveDirectory, email);
console.log('Listing recordings in:', userDir);
// Ensure directory exists
try {
await access(userDir, constants.F_OK);
console.log('User directory exists:', userDir);
} catch {
console.log('User directory does not exist, creating:', userDir);
await mkdir(userDir, { recursive: true });
console.log('Created user directory, returning empty list');
return [];
}
const files = await fs.promises.readdir(userDir);
console.log('Found files:', files);
const recordings = await Promise.all(
files
.filter(file => file.endsWith('.webm'))
.map(async file => {
const filePath = join(userDir, file);
const stats = await fs.promises.stat(filePath);
return {
fileName: file,
filePath: filePath,
date: stats.mtime,
size: stats.size
};
})
);
// Sort by date, newest first
recordings.sort((a, b) => b.date - a.date);
console.log('Returning recordings:', recordings);
return recordings;
} catch (error) {
console.error('Error listing recordings:', error);
throw error;
}
});
ipcMain.handle('upload-recording', async (event, fileName, email, borgEnvironment) => {
try {
const settings = store.get('settings');
const userDir = join(settings.saveDirectory, email);
const filePath = join(userDir, fileName);
console.log('Uploading file:', filePath);
// Get file stats
const stats = await fs.promises.stat(filePath);
console.log('File stats:', {
size: stats.size,
created: stats.birthtime,
modified: stats.mtime
});
// Use the final file directly, do not process again
// Create form data using form-data package
const form = new FormData();
// Append final file using createReadStream with proper content type
form.append('audioFile', fs.createReadStream(filePath), {
filename: fileName,
contentType: 'audio/webm' // Always use audio/webm for both regular and saved files
});
form.append('userEmail', email);
// Create request options
const options = {
hostname: borgEnvironment || 'borg.unak.is',
path: '/api/studio/podcasts',
method: 'POST',
headers: {
'X-API-Key': '0f4801033b3622cc2029e5f2159ff76ea0e3e326',
'Content-Type': 'multipart/form-data',
...form.getHeaders()
}
};
console.log('Request options:', {
hostname: options.hostname,
path: options.path,
method: options.method,
headers: options.headers
});
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
let data = '';
console.log('Response headers:', res.headers);
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
console.log('Server response status:', res.statusCode);
console.log('Server response:', data);
if (res.statusCode >= 200 && res.statusCode < 300) {
console.log('Upload completed successfully');
resolve();
} else {
console.error('Upload failed with status:', res.statusCode);
console.error('Response:', data);
reject(new Error(`Upload failed with status ${res.statusCode}: ${data}`));
}
});
});
req.on('error', (error) => {
console.error('Upload error:', error);
reject(error);
});
// Handle upload progress
form.on('progress', (bytesUploaded, bytesTotal) => {
if (bytesTotal) {
const progress = (bytesUploaded / bytesTotal) * 100;
console.log('Upload progress:', progress.toFixed(2) + '%');
event.sender.send('upload-progress', progress);
}
});
// Pipe the form data to the request
form.pipe(req);
});
} catch (error) {
console.error('Error in upload-recording handler:', error);
throw error;
}
});
// Add new IPC handlers for ffmpeg status
ipcMain.handle('check-ffmpeg', async () => {
const hasFFmpeg = await checkFFmpeg();
return hasFFmpeg;
});
// Add new IPC handler for FFmpeg download URL
ipcMain.handle('get-ffmpeg-download-url', () => {
return getFFmpegDownloadUrl();
});
// Add new IPC handler for FFmpeg installation
ipcMain.handle('install-ffmpeg', async () => {
try {
await installFFmpeg();
// Check if installation was successful
const hasFFmpeg = await checkFFmpeg();
if (hasFFmpeg) {
mainWindow.webContents.send('ffmpeg-status', true);
return true;
}
return false;
} catch (error) {
console.error('Error in install-ffmpeg handler:', error);
throw error;
}
});
// Add new IPC handler for app restart
ipcMain.handle('restart-app', () => {
app.relaunch();
app.exit(0);
});
// Add new IPC handler for getting user settings
ipcMain.handle('get-user-settings', async (event, email) => {
try {
console.log(`[Main] IPC: Handling get-user-settings for ${email}`);
const settings = store.get('settings');
// Ensure settings and userSettings exist before access
const userSpecificSettings = (settings && settings.userSettings && settings.userSettings[email])
? settings.userSettings[email]
: {};
console.log(`[Main] IPC: Returning user settings for ${email}:`, userSpecificSettings);
return userSpecificSettings;
} catch (error) {
console.error(`[Main] IPC: Error in get-user-settings for ${email}:`, error);
throw error;
}
});
// Add new IPC handler for saving user settings
ipcMain.handle('save-user-settings', async (event, email, userSettings) => {
try {
console.log(`[Main] IPC: Handling save-user-settings for ${email}:`, userSettings);
const settings = store.get('settings') || {}; // Ensure settings is at least an empty object
// Ensure userSettings container exists
if (!settings.userSettings) {
settings.userSettings = {};
}
settings.userSettings[email] = {
...(settings.userSettings[email] || {}), // Ensure existing settings for the user are merged correctly
...userSettings
};
store.set('settings', settings);
console.log(`[Main] IPC: Saved user settings for ${email}:`, settings.userSettings[email]);
return true;
} catch (error) {
console.error('Error saving settings:', error);
return false;
console.error(`[Main] IPC: Error saving user settings for ${email}:`, error);
throw error;
}
});
// Handle renaming recordings
ipcMain.handle('rename-recording', async (event, oldFileName, newFileName, email) => {
try {
console.log('Renaming recording:', { oldFileName, newFileName, email });
// Get the save directory from settings
const settings = store.get('settings');
if (!settings || !settings.saveDirectory) {
throw new Error('No save directory configured');
}
// Ensure the new filename has .webm extension
if (!newFileName.endsWith('.webm')) {
newFileName = `${newFileName}.webm`;
}
// Construct full paths
const oldPath = join(settings.saveDirectory, email, oldFileName);
const newPath = join(settings.saveDirectory, email, newFileName);
// Check if old file exists
try {
await access(oldPath, constants.F_OK);
} catch {
throw new Error('Original file not found');
}
// Check if new file already exists
try {
await access(newPath, constants.F_OK);
throw new Error('A file with this name already exists');
} catch {
// File doesn't exist, which is what we want
}
// Perform the rename
await fs.promises.rename(oldPath, newPath);
console.log('File renamed successfully');
return true;
} catch (error) {
console.error('Error renaming recording:', error);
throw error;
}
});
// Add new IPC handlers for logging
ipcMain.handle('saveLog', (event, logEntry) => {
saveLog(logEntry);
});
ipcMain.handle('getLogs', () => {
return getLogs();
});
ipcMain.handle('clearLogs', () => {
clearLogs();
});
// Add IPC handler for app version
ipcMain.handle('get-app-version', () => {
return app.getVersion();
});

View File

@ -1,13 +1,53 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld(
'electronAPI',
{
saveAudioChunk: (data) => ipcRenderer.invoke('save-audio-chunk', data),
selectDirectory: () => ipcRenderer.invoke('select-directory'),
getSettings: () => ipcRenderer.invoke('get-settings'),
saveSettings: (settings) => ipcRenderer.invoke('save-settings', settings),
initRecordingSession: (data) => ipcRenderer.invoke('init-recording-session', data),
finishRecordingSession: () => ipcRenderer.invoke('finish-recording-session')
}
);
contextBridge.exposeInMainWorld('electronAPI', {
// File system operations
saveRecording: (fileName, data, email) => ipcRenderer.invoke('save-recording', fileName, data, email),
getRecordingPath: (fileName, email) => ipcRenderer.invoke('get-recording-path', fileName, email),
readRecordingData: (fileName, email) => ipcRenderer.invoke('read-recording-data', fileName, email),
deleteRecording: (fileName, email) => ipcRenderer.invoke('delete-recording', fileName, email),
selectDirectory: () => ipcRenderer.invoke('select-directory'),
getDefaultDirectory: () => ipcRenderer.invoke('get-default-directory'),
listRecordings: (email) => ipcRenderer.invoke('list-recordings', email),
uploadRecording: (fileName, userEmail, borgEnvironment) => ipcRenderer.invoke('upload-recording', fileName, userEmail, borgEnvironment),
renameRecording: (oldFileName, newFileName, email) => ipcRenderer.invoke('rename-recording', oldFileName, newFileName, email),
// Window controls
minimizeWindow: () => ipcRenderer.invoke('minimize-window'),
closeWindow: () => ipcRenderer.invoke('close-window'),
// Upload progress
onUploadProgress: (callback) => {
const subscription = (event, progress) => callback(progress);
ipcRenderer.on('upload-progress', subscription);
return () => {
ipcRenderer.removeListener('upload-progress', subscription);
};
},
// FFmpeg status
checkFFmpeg: () => ipcRenderer.invoke('check-ffmpeg'),
onFFmpegStatus: (callback) => {
const subscription = (event, status) => callback(status);
ipcRenderer.on('ffmpeg-status', subscription);
return () => {
ipcRenderer.removeListener('ffmpeg-status', subscription);
};
},
installFFmpeg: () => ipcRenderer.invoke('install-ffmpeg'),
getFFmpegDownloadUrl: () => ipcRenderer.invoke('get-ffmpeg-download-url'),
restartApp: () => ipcRenderer.invoke('restart-app'),
getPlatform: () => process.platform,
// User settings methods
saveUserSettings: (email, settings) => ipcRenderer.invoke('save-user-settings', email, settings),
getUserSettings: (email) => ipcRenderer.invoke('get-user-settings', email),
// Logging functions
saveLog: (logEntry) => ipcRenderer.invoke('saveLog', logEntry),
getLogs: () => ipcRenderer.invoke('getLogs'),
clearLogs: () => ipcRenderer.invoke('clearLogs'),
// App Info
getAppVersion: () => ipcRenderer.invoke('get-app-version')
});

BIN
ffmpeg/bin/ffmpeg.exe Normal file

Binary file not shown.

BIN
ffmpeg/bin/ffplay.exe Normal file

Binary file not shown.

BIN
ffmpeg/bin/ffprobe.exe Normal file

Binary file not shown.

View File

@ -5,6 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>Podcast Recording Studio</title>
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Oxanium:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>

651
package-lock.json generated
View File

@ -1,20 +1,26 @@
{
"name": "podcast-recorder",
"version": "0.0.0",
"name": "podcast-upptokur",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "podcast-recorder",
"version": "0.0.0",
"name": "podcast-upptokur",
"version": "1.0.0",
"dependencies": {
"@headlessui/react": "^2.2.2",
"@heroicons/react": "^2.1.1",
"@tailwindcss/forms": "^0.5.7",
"audiobuffer-to-wav": "^1.0.0",
"date-fns": "^3.3.1",
"electron-store": "^8.1.0",
"form-data": "^4.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-h5-audio-player": "^3.10.0-rc.1",
"react-icons": "^5.5.0",
"socket.io-client": "^4.8.1",
"wavesurfer.js": "^7.9.5",
"webmidi": "^3.1.8"
},
"devDependencies": {
@ -24,6 +30,7 @@
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"concurrently": "^9.1.2",
"electron": "^28.1.0",
"electron-builder": "^24.9.1",
"eslint": "^8.56.0",
@ -32,7 +39,8 @@
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.1.1"
"vite": "^5.1.1",
"wait-on": "^8.0.3"
}
},
"node_modules/@alloc/quick-lru": {
@ -1117,6 +1125,96 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz",
"integrity": "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.0.tgz",
"integrity": "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.0",
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/react": {
"version": "0.26.28",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.8",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
"integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@hapi/hoek": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz",
"integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@hapi/topo": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz",
"integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@hapi/hoek": "^9.0.0"
}
},
"node_modules/@headlessui/react": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.2.tgz",
"integrity": "sha512-zbniWOYBQ8GHSUIOPY7BbdIn6PzUOq0z41RFrF30HbjsxG6Rrfk+6QulR8Kgf2Vwj2a/rE6i62q5vo+2gI5dJA==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.26.16",
"@react-aria/focus": "^3.17.1",
"@react-aria/interactions": "^3.21.3",
"@tanstack/react-virtual": "^3.13.6",
"use-sync-external-store": "^1.5.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/@heroicons/react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
@ -1182,6 +1280,27 @@
"deprecated": "Use @eslint/object-schema instead",
"dev": true
},
"node_modules/@iconify/react": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/@iconify/react/-/react-5.2.1.tgz",
"integrity": "sha512-37GDR3fYDZmnmUn9RagyaX+zca24jfVOMY8E1IXTqJuE8pxNtN51KWPQe3VODOWvuUurq7q9uUu3CFrpqj5Iqg==",
"license": "MIT",
"dependencies": {
"@iconify/types": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/cyberalien"
},
"peerDependencies": {
"react": ">=16"
}
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -1385,6 +1504,103 @@
"node": ">=14"
}
},
"node_modules/@react-aria/focus": {
"version": "3.20.2",
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.20.2.tgz",
"integrity": "sha512-Q3rouk/rzoF/3TuH6FzoAIKrl+kzZi9LHmr8S5EqLAOyP9TXIKG34x2j42dZsAhrw7TbF9gA8tBKwnCNH4ZV+Q==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/interactions": "^3.25.0",
"@react-aria/utils": "^3.28.2",
"@react-types/shared": "^3.29.0",
"@swc/helpers": "^0.5.0",
"clsx": "^2.0.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/interactions": {
"version": "3.25.0",
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.0.tgz",
"integrity": "sha512-GgIsDLlO8rDU/nFn6DfsbP9rfnzhm8QFjZkB9K9+r+MTSCn7bMntiWQgMM+5O6BiA8d7C7x4zuN4bZtc0RBdXQ==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/ssr": "^3.9.8",
"@react-aria/utils": "^3.28.2",
"@react-stately/flags": "^3.1.1",
"@react-types/shared": "^3.29.0",
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/ssr": {
"version": "3.9.8",
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.8.tgz",
"integrity": "sha512-lQDE/c9uTfBSDOjaZUJS8xP2jCKVk4zjQeIlCH90xaLhHDgbpCdns3xvFpJJujfj3nI4Ll9K7A+ONUBDCASOuw==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
"engines": {
"node": ">= 12"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/utils": {
"version": "3.28.2",
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.28.2.tgz",
"integrity": "sha512-J8CcLbvnQgiBn54eeEvQQbIOfBF3A1QizxMw9P4cl9MkeR03ug7RnjTIdJY/n2p7t59kLeAB3tqiczhcj+Oi5w==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/ssr": "^3.9.8",
"@react-stately/flags": "^3.1.1",
"@react-stately/utils": "^3.10.6",
"@react-types/shared": "^3.29.0",
"@swc/helpers": "^0.5.0",
"clsx": "^2.0.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-stately/flags": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.1.tgz",
"integrity": "sha512-XPR5gi5LfrPdhxZzdIlJDz/B5cBf63l4q6/AzNqVWFKgd0QqY5LvWJftXkklaIUpKSJkIKQb8dphuZXDtkWNqg==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@react-stately/utils": {
"version": "3.10.6",
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.6.tgz",
"integrity": "sha512-O76ip4InfTTzAJrg8OaZxKU4vvjMDOpfA/PGNOytiXwBbkct2ZeZwaimJ8Bt9W1bj5VsZ81/o/tW4BacbdDOMA==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-types/shared": {
"version": "3.29.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.29.0.tgz",
"integrity": "sha512-IDQYu/AHgZimObzCFdNl1LpZvQW/xcfLt3v20sorl5qRucDVj4S9os98sVTZ4IRIBjmS+MkjqpR5E70xan7ooA==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.38.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.38.0.tgz",
@ -1645,6 +1861,30 @@
"win32"
]
},
"node_modules/@sideway/address": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
"integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@hapi/hoek": "^9.0.0"
}
},
"node_modules/@sideway/formula": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz",
"integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@sideway/pinpoint": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz",
"integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@sindresorhus/is": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
@ -1658,6 +1898,21 @@
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@szmarczak/http-timer": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
@ -1682,6 +1937,33 @@
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.8",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.8.tgz",
"integrity": "sha512-meS2AanUg50f3FBSNoAdBSRAh8uS0ue01qm7zrw65KGJtiXB9QXfybqZwkh4uFpRv2iX/eu5tjcH5wqUpwYLPg==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.8",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.8.tgz",
"integrity": "sha512-BT6w89Hqy7YKaWewYzmecXQzcJh6HTBbKYJIIkMaNU49DZ06LoTV3z32DWWEdUsgW6n1xTmwTLs4GtWrZC261w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@ -2503,7 +2785,6 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/at-least-node": {
@ -2567,6 +2848,18 @@
"postcss": "^8.1.0"
}
},
"node_modules/axios": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz",
"integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -2859,7 +3152,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -3099,6 +3391,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@ -3119,7 +3420,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@ -3169,6 +3469,48 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true
},
"node_modules/concurrently": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.1.2.tgz",
"integrity": "sha512-H9MWcoPsYddwbOGM6difjVwVZHl63nwMEwDJG/L7VGtuaJhb12h2caPG2tVPWs7emuYix252iGfqOyrz1GczTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"chalk": "^4.1.2",
"lodash": "^4.17.21",
"rxjs": "^7.8.1",
"shell-quote": "^1.8.1",
"supports-color": "^8.1.1",
"tree-kill": "^1.2.2",
"yargs": "^17.7.2"
},
"bin": {
"conc": "dist/bin/concurrently.js",
"concurrently": "dist/bin/concurrently.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
}
},
"node_modules/concurrently/node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-flag": "^4.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/conf": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/conf/-/conf-10.2.0.tgz",
@ -3474,7 +3816,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@ -3684,7 +4025,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@ -3955,6 +4295,45 @@
"once": "^1.4.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
"integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
@ -3975,7 +4354,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -3985,7 +4363,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -3995,7 +4372,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@ -4008,7 +4384,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -4479,6 +4854,27 @@
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true
},
"node_modules/follow-redirects": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz",
"integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@ -4498,7 +4894,6 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
"integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@ -4629,7 +5024,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@ -4654,7 +5048,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@ -4804,7 +5197,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -4879,7 +5271,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -4892,7 +5283,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@ -5292,6 +5682,20 @@
"jiti": "bin/jiti.js"
}
},
"node_modules/joi": {
"version": "17.13.3",
"resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz",
"integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@hapi/hoek": "^9.3.0",
"@hapi/topo": "^5.1.0",
"@sideway/address": "^4.1.5",
"@sideway/formula": "^3.0.1",
"@sideway/pinpoint": "^2.0.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -5596,7 +6000,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -5639,7 +6042,6 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@ -5649,7 +6051,6 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
@ -5768,8 +6169,7 @@
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/mz": {
"version": "2.7.0",
@ -6355,6 +6755,13 @@
"node": ">=10"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true,
"license": "MIT"
},
"node_modules/pump": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
@ -6430,6 +6837,29 @@
"react": "^18.3.1"
}
},
"node_modules/react-h5-audio-player": {
"version": "3.10.0-rc.1",
"resolved": "https://registry.npmjs.org/react-h5-audio-player/-/react-h5-audio-player-3.10.0-rc.1.tgz",
"integrity": "sha512-A/Okq9BoOfqUc1oQxcqZWeL5DMeBAMfb7aA7G64EAle3BM7PVYH9ZyES9PespfT2CgKRpAv855gUoMs0PWGhOQ==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.2",
"@iconify/react": "^5"
},
"peerDependencies": {
"react": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.3.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
@ -6704,6 +7134,16 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -6827,6 +7267,19 @@
"node": ">=8"
}
},
"node_modules/shell-quote": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.2.tgz",
"integrity": "sha512-AzqKpGKjrj7EM6rKVQEPpB288oCfnrEIuyoT9cyF4nmGa7V8Zk6f7RRqYisX8X9m+Q7bd632aZW4ky7EhbQztA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@ -6888,6 +7341,68 @@
"npm": ">= 3.0.0"
}
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
"integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-client/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/socket.io-parser": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
"integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser/node_modules/debug": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
"integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@ -7131,6 +7646,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
@ -7325,6 +7846,16 @@
"node": ">=8.0"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
"dev": true,
"license": "MIT",
"bin": {
"tree-kill": "cli.js"
}
},
"node_modules/truncate-utf8-bytes": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
@ -7352,6 +7883,12 @@
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -7445,6 +7982,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/utf8-byte-length": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
@ -7532,6 +8078,32 @@
}
}
},
"node_modules/wait-on": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.3.tgz",
"integrity": "sha512-nQFqAFzZDeRxsu7S3C7LbuxslHhk+gnJZHyethuGKAn2IVleIbTB9I3vJSQiSR+DifUqmdzfPMoMPJfLqMF2vw==",
"dev": true,
"license": "MIT",
"dependencies": {
"axios": "^1.8.2",
"joi": "^17.13.3",
"lodash": "^4.17.21",
"minimist": "^1.2.8",
"rxjs": "^7.8.2"
},
"bin": {
"wait-on": "bin/wait-on"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/wavesurfer.js": {
"version": "7.9.5",
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.9.5.tgz",
"integrity": "sha512-ioOG9chuAn0bF2NYYKkZtaxjcQK/hFskLg8ViLYbJHhWPk1N5wWtuqVhqeh2ZWT2SK3t0E8UkD7lLDLuZQQaSA==",
"license": "BSD-3-Clause"
},
"node_modules/webmidi": {
"version": "3.1.12",
"resolved": "https://registry.npmjs.org/webmidi/-/webmidi-3.1.12.tgz",
@ -7662,6 +8234,27 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
@ -7672,6 +8265,14 @@
"node": ">=8.0"
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -1,7 +1,7 @@
{
"name": "podcast-recorder",
"name": "podcast-upptokur",
"private": true,
"version": "0.0.0",
"version": "1.0.0",
"type": "module",
"main": "electron/main.cjs",
"scripts": {
@ -9,17 +9,72 @@
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"electron:dev": "vite dev & electron .",
"electron:dev": "concurrently \"vite\" \"wait-on tcp:5173 && electron .\"",
"electron:build": "vite build && electron-builder"
},
"build": {
"appId": "com.podcast.upptokur",
"productName": "Podcast Upptökur",
"directories": {
"output": "dist_electron"
},
"files": [
"dist/**/*",
"electron/**/*"
],
"extraResources": [
"ffmpeg/**/*"
],
"win": {
"target": [
{
"target": "nsis",
"arch": [
"x64"
]
},
{
"target": "portable",
"arch": [
"x64"
]
}
],
"icon": "public/images/icon.png"
},
"mac": {
"target": "dmg",
"icon": "public/images/icon.png"
},
"linux": {
"target": "AppImage",
"icon": "public/images/icon.png",
"category": "AudioVideo;Audio;Recorder;"
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true
},
"portable": {
"artifactName": "PodcastUpptokur-Portable.exe"
}
},
"dependencies": {
"@headlessui/react": "^2.2.2",
"@heroicons/react": "^2.1.1",
"@tailwindcss/forms": "^0.5.7",
"audiobuffer-to-wav": "^1.0.0",
"date-fns": "^3.3.1",
"electron-store": "^8.1.0",
"form-data": "^4.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-h5-audio-player": "^3.10.0-rc.1",
"react-icons": "^5.5.0",
"socket.io-client": "^4.8.1",
"wavesurfer.js": "^7.9.5",
"webmidi": "^3.1.8"
},
"devDependencies": {
@ -29,6 +84,7 @@
"@typescript-eslint/parser": "^6.21.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"concurrently": "^9.1.2",
"electron": "^28.1.0",
"electron-builder": "^24.9.1",
"eslint": "^8.56.0",
@ -37,6 +93,7 @@
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3",
"vite": "^5.1.1"
"vite": "^5.1.1",
"wait-on": "^8.0.3"
}
}
}

BIN
public/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,86 @@
import React, { useEffect, useRef, useState } from 'react';
import { useTheme } from '../contexts/ThemeContext';
interface AudioVisualizerProps {
audioUrl: string;
isPlaying: boolean;
audioElementRef?: React.RefObject<HTMLAudioElement>;
}
const AudioVisualizer: React.FC<AudioVisualizerProps> = ({
audioUrl,
isPlaying,
audioElementRef
}) => {
const audioRef = useRef<HTMLAudioElement | null>(null);
const { isDark } = useTheme();
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Initialize audio element
useEffect(() => {
if (!audioRef.current) {
audioRef.current = new Audio();
}
const audio = audioRef.current;
audio.src = audioUrl;
const handleCanPlay = () => {
console.log('Audio can play');
setIsLoading(false);
};
const handleError = (e: ErrorEvent) => {
console.error('Audio error:', e);
setError('Villa kom upp við að spila hljóðskrá');
setIsLoading(false);
};
audio.addEventListener('canplay', handleCanPlay);
audio.addEventListener('error', handleError);
return () => {
audio.removeEventListener('canplay', handleCanPlay);
audio.removeEventListener('error', handleError);
audio.pause();
audio.src = '';
};
}, [audioUrl]);
// Handle play/pause
useEffect(() => {
if (!audioRef.current) return;
try {
if (isPlaying) {
console.log('Playing audio');
audioRef.current.play();
} else {
console.log('Pausing audio');
audioRef.current.pause();
}
} catch (err) {
console.error('Error controlling playback:', err);
setError('Villa kom upp við að stýra spilun');
}
}, [isPlaying]);
return (
<div className="w-full h-24 mb-4 bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden relative">
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-gray-100/50 dark:bg-gray-700/50">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
)}
{error && (
<div className="absolute inset-0 flex items-center justify-center bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-200">
{error}
</div>
)}
<audio ref={audioRef} className="w-full h-full" />
</div>
);
};
export default AudioVisualizer;

View File

@ -0,0 +1,83 @@
import React from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
interface DeleteConfirmationModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
fileName: string;
}
export default function DeleteConfirmationModal({
isOpen,
onClose,
onConfirm,
fileName
}: DeleteConfirmationModalProps) {
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationTriangleIcon className="h-6 w-6 text-red-600" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
<Dialog.Title as="h3" className="text-base font-semibold leading-6 text-gray-900">
Eyða upptökunni
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500">
Ertu viss um þú viljir eyða upptökunni "{fileName}"? Þessi aðgerð er óafturkræf.
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button
type="button"
className="inline-flex w-full justify-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 sm:ml-3 sm:w-auto"
onClick={onConfirm}
>
Eyða
</button>
<button
type="button"
className="mt-3 inline-flex w-full justify-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 sm:mt-0 sm:w-auto"
onClick={onClose}
>
Hætta við
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
}

View File

@ -0,0 +1,104 @@
import React, { useState } from 'react';
import { XMarkIcon } from '@heroicons/react/24/solid';
interface FFmpegWarningModalProps {
isOpen: boolean;
onClose: () => void;
onInstall: () => Promise<void>;
onDownload: () => void;
platform: 'win32' | 'darwin' | 'linux';
}
const FFmpegWarningModal: React.FC<FFmpegWarningModalProps> = ({ isOpen, onClose, onInstall, onDownload, platform }) => {
const [isInstalling, setIsInstalling] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
const handleInstall = async () => {
setIsInstalling(true);
setInstallError(null);
try {
await onInstall();
} catch (error) {
setInstallError('Uppsetning tókst ekki. Vinsamlegast reyndu að setja upp handvirkt.');
} finally {
setIsInstalling(false);
}
};
if (!isOpen) return null;
const getInstallInstructions = () => {
switch (platform) {
case 'win32':
return 'Viltu setja upp FFmpeg núna? Þetta mun nota Windows Package Manager (winget).';
case 'darwin':
return 'Viltu setja upp FFmpeg núna? Þetta mun nota Homebrew.';
case 'linux':
return 'Viltu setja upp FFmpeg núna? Þetta mun nota apt-get.';
default:
return 'Viltu setja upp FFmpeg núna?';
}
};
const getManualInstallInstructions = () => {
switch (platform) {
case 'win32':
return 'Þú getur sett upp FFmpeg handvirkt með því að sækja það af https://www.gyan.dev/ffmpeg/builds/';
case 'darwin':
return 'Þú getur sett upp FFmpeg handvirkt með því að nota Homebrew: brew install ffmpeg';
case 'linux':
return 'Þú getur sett upp FFmpeg handvirkt með því að nota apt-get: sudo apt-get install ffmpeg';
default:
return 'Þú getur sett upp FFmpeg handvirkt af https://ffmpeg.org/download.html';
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md w-full mx-4">
<div className="flex justify-between items-start mb-4">
<h2 className="text-xl font-semibold text-gray-900">FFmpeg Nauðsynlegt</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-500"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<div className="space-y-4">
<p className="text-gray-700">
FFmpeg er nauðsynlegt fyrir hljóðvinnslu en fannst ekki á kerfinu þínu.
</p>
{installError ? (
<div className="bg-red-50 border border-red-200 rounded-md p-4">
<p className="text-red-700">{installError}</p>
<p className="text-sm text-red-600 mt-2">{getManualInstallInstructions()}</p>
</div>
) : (
<p className="text-gray-700">{getInstallInstructions()}</p>
)}
</div>
<div className="mt-6 flex justify-end space-x-3">
<button
onClick={onClose}
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Síðar
</button>
<button
onClick={handleInstall}
disabled={isInstalling}
className="px-4 py-2 border border-transparent rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isInstalling ? 'Set upp...' : 'Setja upp FFmpeg'}
</button>
</div>
</div>
</div>
);
};
export default FFmpegWarningModal;

77
src/components/Footer.tsx Normal file
View File

@ -0,0 +1,77 @@
import React, { useState, useEffect } from 'react';
import LogViewer from './LogViewer';
import { useTheme } from '../contexts/ThemeContext';
const Footer: React.FC = () => {
const [isLogViewerOpen, setIsLogViewerOpen] = useState(false);
const [appVersion, setAppVersion] = useState<string | null>(null);
const { theme, setTheme, isDark } = useTheme();
useEffect(() => {
window.electronAPI.getAppVersion()
.then(version => {
console.log('[Renderer] Fetched app version:', version);
setAppVersion(version);
})
.catch(error => {
console.error('[Renderer] Failed to fetch app version:', error);
setAppVersion('N/A');
});
}, []);
const toggleTheme = () => {
const newTheme = theme === 'dark' ? 'light' : 'dark';
setTheme(newTheme);
};
return (
<>
<footer className="fixed bottom-0 left-0 right-0 h-8 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between px-4 z-50">
<div className="flex items-center space-x-4">
<button
onClick={toggleTheme}
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors"
title={isDark ? "Switch to Light Mode" : "Switch to Dark Mode"}
>
{isDark ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clipRule="evenodd" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
</svg>
)}
</button>
{appVersion && (
<span className="text-xs text-gray-500 dark:text-gray-400">Útgáfa: {appVersion}</span>
)}
</div>
<button
onClick={() => setIsLogViewerOpen(true)}
className="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition-colors"
title="View Logs"
>
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-5 w-5"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
</button>
</footer>
<LogViewer
isOpen={isLogViewerOpen}
onClose={() => setIsLogViewerOpen(false)}
/>
</>
);
};
export default Footer;

56
src/components/Header.tsx Normal file
View File

@ -0,0 +1,56 @@
import React from 'react';
import { FaPodcast } from "react-icons/fa";
import { Cog6ToothIcon, ArrowRightOnRectangleIcon, XMarkIcon } from '@heroicons/react/24/solid';
interface HeaderProps {
onSettingsClick: () => void;
onLogout: () => void;
onExit: () => void;
hasSubmittedEmail: boolean;
}
const Header: React.FC<HeaderProps> = ({
onSettingsClick,
onLogout,
onExit,
hasSubmittedEmail
}) => {
return (
<div className="bg-white dark:bg-gray-800 shadow cursor-move app-region-drag sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center space-x-3">
<FaPodcast className="h-8 w-8 text-[#c4161c] dark:text-[#c4161c]" />
<h1 className="text-xl font-semibold text-gray-900 dark:text-white font-oxanium">Borg Hlaðvörp</h1>
</div>
<div className="flex items-center space-x-4 app-region-no-drag">
{hasSubmittedEmail && (
<button
onClick={onSettingsClick}
className="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
<Cog6ToothIcon className="h-6 w-6" />
</button>
)}
{hasSubmittedEmail && (
<button
onClick={onLogout}
className="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
<ArrowRightOnRectangleIcon className="h-6 w-6" />
</button>
)}
<button
onClick={onExit}
className="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
</div>
</div>
</div>
);
};
export default Header;

View File

@ -0,0 +1,72 @@
import React, { useState, useEffect, useRef } from 'react';
import LogService, { LogEntry } from '../services/LogService';
interface LogViewerProps {
isOpen: boolean;
onClose: () => void;
}
const LogViewer: React.FC<LogViewerProps> = ({ isOpen, onClose }) => {
const [logs, setLogs] = useState<LogEntry[]>([]);
const logContainerRef = useRef<HTMLDivElement>(null);
const logService = LogService.getInstance();
useEffect(() => {
if (!isOpen) return;
// Load logs when modal opens
setLogs(logService.getLogs());
// Auto-scroll to bottom when new logs arrive
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
}, [isOpen]);
const handleClear = () => {
logService.clearLogs();
setLogs([]);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg w-3/4 h-3/4 flex flex-col">
<div className="flex justify-between items-center p-4 border-b dark:border-gray-700">
<h2 className="text-xl font-semibold dark:text-white font-oxanium">Forritsskrá</h2>
<div className="flex gap-2">
<button
onClick={handleClear}
className="px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600 dark:text-white font-oxanium"
>
Hreinsa
</button>
<button
onClick={onClose}
className="px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 rounded hover:bg-gray-300 dark:hover:bg-gray-600 dark:text-white font-oxanium"
>
Loka
</button>
</div>
</div>
<div
ref={logContainerRef}
className="flex-1 overflow-auto p-4 font-mono text-sm bg-gray-50 dark:bg-gray-900 dark:text-gray-200"
>
{logs.length === 0 ? (
<div className="text-gray-500 dark:text-gray-400">Engar skrár til sýna</div>
) : (
logs.map((log, index) => (
<div key={index} className="whitespace-pre-wrap break-all">
[{log.timestamp}] {log.level}: {log.message}
</div>
))
)}
</div>
</div>
</div>
);
};
export default LogViewer;

View File

@ -0,0 +1,86 @@
import React, { useRef, useState, useEffect } from 'react';
import { StopIcon } from '@heroicons/react/24/solid';
import AudioPlayer from 'react-h5-audio-player';
import 'react-h5-audio-player/lib/styles.css';
import AudioVisualizer from './AudioVisualizer';
interface PlaybackProps {
audioUrl: string;
onStop: () => void;
}
export function Playback({ audioUrl, onStop }: PlaybackProps) {
const [error, setError] = useState<string | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const playerRef = useRef<any>(null);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
// File integrity check
const fileValid =
audioUrl &&
typeof audioUrl === 'string' &&
(audioUrl.startsWith('blob:') || audioUrl.startsWith('data:audio/webm')) &&
audioUrl.length > 20;
useEffect(() => {
if (playerRef.current && playerRef.current.audio) {
setAudioElement(playerRef.current.audio);
}
}, [playerRef, audioUrl]);
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div className="text-center">
<h2 className="text-lg font-medium mb-4 text-gray-900 dark:text-white">Spila upptöku</h2>
{(!fileValid || error) && (
<div className="mb-4 p-2 bg-red-100 dark:bg-red-900/50 text-red-700 dark:text-red-200 rounded">
{error || 'Skráin er gölluð eða ekki til staðar.'}
</div>
)}
{fileValid && (
<>
<AudioVisualizer
audioUrl={audioUrl}
isPlaying={isPlaying}
audioElementRef={audioElement instanceof HTMLAudioElement ? { current: audioElement } : undefined}
/>
<div className="dark:invert">
<AudioPlayer
src={audioUrl}
ref={playerRef}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => {
setIsPlaying(false);
onStop();
}}
onPlayError={e => {
setError('Villa kom upp við að spila upptöku. Athugaðu hvort skráin sé heil og í réttu sniði.');
console.error('Audio playback error:', e, 'URL:', audioUrl);
}}
showJumpControls={false}
customAdditionalControls={[]}
customVolumeControls={[]}
style={{
border: fileValid ? undefined : '2px solid red',
marginBottom: '1rem',
backgroundColor: 'transparent',
borderRadius: '0.5rem',
boxShadow: 'none'
}}
className="dark:bg-gray-700 dark:rounded-lg"
/>
</div>
</>
)}
<button
onClick={onStop}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-gray-600 hover:bg-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600"
>
<StopIcon className="h-5 w-5 mr-2" />
Hætta spilun
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,117 @@
import React from 'react';
import { XMarkIcon } from '@heroicons/react/24/solid';
import { format } from 'date-fns';
import { is } from 'date-fns/locale';
interface RecordingInfoModalProps {
isOpen: boolean;
onClose: () => void;
recording: {
fileName: string;
date: Date;
size: number;
filePath: string;
};
codecInfo?: {
codec: string;
bitrate: number;
sampleRate: number;
channels: number;
};
}
const RecordingInfoModal: React.FC<RecordingInfoModalProps> = ({
isOpen,
onClose,
recording,
codecInfo
}) => {
if (!isOpen) return null;
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-lg w-full mx-4">
<div className="flex justify-between items-center p-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-medium text-gray-900 dark:text-white font-oxanium">
Upplýsingar um upptöku
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<div className="p-4 space-y-4">
<div>
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 font-oxanium">Skráarnafn</h3>
<p className="mt-1 text-sm text-gray-900 dark:text-white">{recording.fileName}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 font-oxanium">Dagsetning</h3>
<p className="mt-1 text-sm text-gray-900 dark:text-white">
{format(recording.date, 'PPP', { locale: is })}
</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 font-oxanium">Stærð</h3>
<p className="mt-1 text-sm text-gray-900 dark:text-white">{formatFileSize(recording.size)}</p>
</div>
<div>
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 font-oxanium">Slóð</h3>
<p className="mt-1 text-sm text-gray-900 dark:text-white break-all">{recording.filePath}</p>
</div>
{codecInfo && (
<>
<div className="border-t border-gray-200 dark:border-gray-700 pt-4">
<h3 className="text-sm font-medium text-gray-500 dark:text-gray-400 font-oxanium">Upptökuupplýsingar</h3>
<div className="mt-2 grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Kóði</p>
<p className="mt-1 text-sm text-gray-900 dark:text-white">{codecInfo.codec}</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Bitahraði</p>
<p className="mt-1 text-sm text-gray-900 dark:text-white">{codecInfo.bitrate / 1000} kbps</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Sýnishraði</p>
<p className="mt-1 text-sm text-gray-900 dark:text-white">{codecInfo.sampleRate / 1000} kHz</p>
</div>
<div>
<p className="text-sm text-gray-500 dark:text-gray-400">Rásir</p>
<p className="mt-1 text-sm text-gray-900 dark:text-white">{codecInfo.channels}</p>
</div>
</div>
</div>
</>
)}
</div>
<div className="bg-gray-50 dark:bg-gray-700 px-4 py-3 rounded-b-lg">
<button
onClick={onClose}
className="w-full bg-[#c4161c] text-white py-2 px-4 rounded-md hover:bg-[#a31218] transition-colors font-oxanium"
>
Loka
</button>
</div>
</div>
</div>
);
};
export default RecordingInfoModal;

View File

@ -0,0 +1,262 @@
import React, { useState, useEffect } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { RecordingSettings } from '../services/api';
import { XMarkIcon } from '@heroicons/react/24/solid';
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
settings: RecordingSettings;
onSave: (settings: Partial<RecordingSettings>) => void;
onSelectDirectory: () => void;
}
const SettingsModal: React.FC<SettingsModalProps> = ({
isOpen,
onClose,
settings,
onSave,
onSelectDirectory
}) => {
const [localSettings, setLocalSettings] = useState<RecordingSettings>({
saveDirectory: settings?.saveDirectory || '',
borgEnvironment: settings?.borgEnvironment || 'borg.unak.is',
selectedMicrophone: settings?.selectedMicrophone,
audioSettings: {
bitrate: settings?.audioSettings?.bitrate || 256000,
sampleRate: settings?.audioSettings?.sampleRate || 48000,
channels: settings?.audioSettings?.channels || 2
}
});
const [availableMics, setAvailableMics] = useState<MediaDeviceInfo[]>([]);
useEffect(() => {
if (settings) {
setLocalSettings({
saveDirectory: settings.saveDirectory || '',
borgEnvironment: settings.borgEnvironment || 'borg.unak.is',
selectedMicrophone: settings.selectedMicrophone,
audioSettings: {
bitrate: settings.audioSettings?.bitrate || 256000,
sampleRate: settings.audioSettings?.sampleRate || 48000,
channels: settings.audioSettings?.channels || 2
}
});
}
}, [settings]);
useEffect(() => {
const getMicrophones = async () => {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const mics = devices.filter(device => device.kind === 'audioinput');
setAvailableMics(mics);
} catch (error) {
console.error('Error getting microphones:', error);
}
};
getMicrophones();
}, []);
const handleSave = () => {
onSave(localSettings);
};
if (!isOpen) return null;
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
{/* Overlay */}
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 dark:bg-gray-900 dark:bg-opacity-75 bg-opacity-75 transition-opacity" />
</Transition.Child>
{/* Modal Content Container */}
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
{/* Modal Panel */}
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
{/* Header */}
<div className="flex justify-between items-start mb-4">
<Dialog.Title as="h3" className="text-xl font-bold text-gray-900 dark:text-white font-oxanium">
Stillingar
</Dialog.Title>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
{/* Body */}
<div className="space-y-4">
{/* Save Directory */}
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-1 font-oxanium">
Vista upptökur í
</label>
<div className="flex space-x-2">
<input
type="text"
value={localSettings.saveDirectory}
readOnly
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 bg-gray-50 dark:bg-gray-700"
/>
<button
onClick={onSelectDirectory}
className="px-4 py-2 border border-gray-300 dark:border-gray-500 rounded-md text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-600 font-oxanium font-bold"
>
Velja
</button>
</div>
</div>
{/* Microphone */}
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-1 font-oxanium">
Hljóðnemi
</label>
<select
value={localSettings.selectedMicrophone || ''}
onChange={(e) => {
const value = e.target.value;
setLocalSettings(prev => ({
...prev,
selectedMicrophone: value === '' ? undefined : value
}));
}}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-100 bg-white dark:bg-gray-700 focus:ring-[#c4161c] focus:border-[#c4161c]"
>
<option value="" className="text-gray-500 dark:text-gray-400">Veldu hljóðnema...</option>
{availableMics.map((mic) => (
<option key={mic.deviceId} value={mic.deviceId}>
{mic.label || `Hljóðnemi ${mic.deviceId.slice(0, 5)}`}
</option>
))}
</select>
</div>
{/* Borg Environment */}
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-1 font-oxanium">
Borg Umhverfi
</label>
<select
value={localSettings.borgEnvironment}
onChange={(e) => setLocalSettings(prev => ({
...prev,
borgEnvironment: e.target.value as 'borg.unak.is' | 'test.borg.unak.is'
}))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-100 bg-white dark:bg-gray-700 focus:ring-[#c4161c] focus:border-[#c4161c]"
>
<option value="borg.unak.is">borg.unak.is</option>
<option value="test.borg.unak.is">test.borg.unak.is</option>
</select>
</div>
{/* Audio Settings */}
<div>
<label className="block text-sm font-bold text-gray-700 dark:text-gray-300 mb-1 font-oxanium">
Hljóðstillingar
</label>
<div className="space-y-2">
{/* Bitrate */}
<div>
<label className="block text-xs font-bold text-gray-500 dark:text-gray-400 font-oxanium">Bitrate</label>
<select
value={localSettings.audioSettings.bitrate}
onChange={(e) => setLocalSettings(prev => ({
...prev,
audioSettings: { ...prev.audioSettings, bitrate: parseInt(e.target.value) }
}))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-100 bg-white dark:bg-gray-700 focus:ring-[#c4161c] focus:border-[#c4161c]"
>
<option value="128000">128 kbps</option>
<option value="192000">192 kbps</option>
<option value="256000">256 kbps</option>
</select>
</div>
{/* Sample Rate */}
<div>
<label className="block text-xs font-bold text-gray-500 dark:text-gray-400 font-oxanium">Sýnishornshlutfall</label>
<select
value={localSettings.audioSettings.sampleRate}
onChange={(e) => setLocalSettings(prev => ({
...prev,
audioSettings: { ...prev.audioSettings, sampleRate: parseInt(e.target.value) }
}))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-100 bg-white dark:bg-gray-700 focus:ring-[#c4161c] focus:border-[#c4161c]"
>
<option value="44100">44.1 kHz</option>
<option value="48000">48 kHz</option>
</select>
</div>
{/* Channels */}
<div>
<label className="block text-xs font-bold text-gray-500 dark:text-gray-400 font-oxanium">Rásir</label>
<select
value={localSettings.audioSettings.channels}
onChange={(e) => setLocalSettings(prev => ({
...prev,
audioSettings: { ...prev.audioSettings, channels: parseInt(e.target.value) }
}))}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-100 bg-white dark:bg-gray-700 focus:ring-[#c4161c] focus:border-[#c4161c]"
>
<option value="1">Mono</option>
<option value="2">Stereo</option>
</select>
</div>
</div>
</div>
</div>
{/* Footer/Actions */}
<div className="mt-6 flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border border-gray-300 dark:border-gray-500 rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 font-oxanium font-bold"
>
Hætta við
</button>
<button
type="button"
onClick={handleSave}
className="px-4 py-2 border border-transparent rounded-md text-white bg-blue-600 hover:bg-blue-700 font-oxanium font-bold"
>
Vista
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default SettingsModal;

View File

@ -0,0 +1,78 @@
import React from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
interface UploadErrorModalProps {
isOpen: boolean;
onClose: () => void;
errorDetails: string | null;
}
const UploadErrorModal: React.FC<UploadErrorModalProps> = ({ isOpen, onClose, errorDetails }) => {
const displayError = errorDetails || 'Óþekkt villa kom upp.'; // Default message
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 dark:bg-gray-900 dark:bg-opacity-75 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900 sm:mx-0 sm:h-10 sm:w-10">
<ExclamationTriangleIcon className="h-6 w-6 text-red-600 dark:text-red-400" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
<Dialog.Title as="h3" className="text-base font-semibold leading-6 text-gray-900 dark:text-white font-oxanium">
Villa við upphleðslu
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500 dark:text-gray-400">
Ekki tókst hlaða upp upptökunni.
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
{/* Display specific error message */}
{displayError}
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button
type="button"
className="inline-flex w-full justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700 sm:w-auto font-oxanium font-bold"
onClick={onClose}
>
Loka
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default UploadErrorModal;

View File

@ -0,0 +1,78 @@
import React from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { CheckCircleIcon } from '@heroicons/react/24/outline'; // Use a check icon for success
interface UploadSuccessModalProps {
isOpen: boolean;
onClose: () => void;
fileName: string;
borgEnvironment: 'borg.unak.is' | 'test.borg.unak.is'; // Add prop for environment
}
const UploadSuccessModal: React.FC<UploadSuccessModalProps> = ({ isOpen, onClose, fileName, borgEnvironment }) => {
const borgUrl = `https://${borgEnvironment}`;
return (
<Transition.Root show={isOpen} as={React.Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-gray-500 dark:bg-gray-900 dark:bg-opacity-75 bg-opacity-75 transition-opacity" />
</Transition.Child>
<div className="fixed inset-0 z-10 overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
<Transition.Child
as={React.Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-white dark:bg-gray-800 px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:p-6">
<div className="sm:flex sm:items-start">
<div className="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-green-100 dark:bg-green-900 sm:mx-0 sm:h-10 sm:w-10">
<CheckCircleIcon className="h-6 w-6 text-green-600 dark:text-green-400" aria-hidden="true" />
</div>
<div className="mt-3 text-center sm:ml-4 sm:mt-0 sm:text-left">
<Dialog.Title as="h3" className="text-base font-semibold leading-6 text-gray-900 dark:text-white font-oxanium">
Upphleðsla tókst
</Dialog.Title>
<div className="mt-2">
<p className="text-sm text-gray-500 dark:text-gray-400">
Upptaka "<span className='font-semibold'>{fileName}</span>" hefur verið hlaðið upp.
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
Þú getur nálgast upptökuna á Borg <a href={borgUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400 hover:underline">{borgUrl}</a>.
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<button
type="button"
className="inline-flex w-full justify-center rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-700 sm:w-auto font-oxanium font-bold"
onClick={onClose}
>
Loka
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition.Root>
);
};
export default UploadSuccessModal;

View File

@ -0,0 +1,88 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
isDark: boolean;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [theme, setTheme] = useState<Theme>(() => {
// Check localStorage first
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'light' || savedTheme === 'dark' || savedTheme === 'system') {
return savedTheme;
}
// Default to system if no saved preference
return 'system';
});
const [isDark, setIsDark] = useState(() => {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return theme === 'dark';
});
// Apply theme class to html element
useEffect(() => {
const html = document.documentElement;
// Remove existing theme classes
html.classList.remove('light', 'dark');
if (theme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
html.classList.add(systemTheme);
setIsDark(systemTheme === 'dark');
} else {
html.classList.add(theme);
setIsDark(theme === 'dark');
}
// Save theme preference
localStorage.setItem('theme', theme);
}, [theme]);
// Listen for system theme changes
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
if (theme === 'system') {
const systemTheme = mediaQuery.matches ? 'dark' : 'light';
const html = document.documentElement;
html.classList.remove('light', 'dark');
html.classList.add(systemTheme);
setIsDark(systemTheme === 'dark');
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
const value = {
theme,
setTheme,
isDark
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};

View File

@ -2,6 +2,14 @@
@tailwind components;
@tailwind utilities;
.app-region-drag {
-webkit-app-region: drag;
}
.app-region-no-drag {
-webkit-app-region: no-drag;
}
body {
margin: 0;
padding: 0;

139
src/pages/RecordingPage.tsx Normal file
View File

@ -0,0 +1,139 @@
import React, { useState } from 'react';
import { format } from 'date-fns';
import { ApiService, Recording } from '../services/api';
import { MicrophoneIcon, StopIcon } from '@heroicons/react/24/solid';
interface RecordingPageProps {
email: string;
onRecordingsUpdated: () => void;
apiService: ApiService | null;
}
const RecordingPage: React.FC<RecordingPageProps> = ({ email, onRecordingsUpdated, apiService }) => {
const [isRecording, setIsRecording] = useState(false);
const [error, setError] = useState<string | null>(null);
const [recordingTime, setRecordingTime] = useState('00:00:00');
const [audioLevel, setAudioLevel] = useState(0);
const [recordingNameInput, setRecordingNameInput] = useState('');
const startRecording = async () => {
if (!apiService) {
setError('Þjónusta er ekki tilbúin');
return;
}
try {
setIsRecording(true);
setError(null);
setRecordingTime('00:00:00');
setAudioLevel(0);
// Get the recording name from the input
const recordingName = recordingNameInput.trim() || `podcast_${format(new Date(), 'yyyy-MM-dd_HH-mm-ss')}`;
await apiService.startRecording(
(time) => setRecordingTime(time),
(level) => setAudioLevel(level),
recordingName,
email
);
} catch (error) {
console.error('Error starting recording:', error);
setError(error instanceof Error ? error.message : 'Villa kom upp við að byrja upptöku');
setIsRecording(false);
}
};
const stopRecording = async () => {
if (!apiService) {
setError('Þjónusta er ekki tilbúin');
return;
}
if (!email) {
setError('Vinsamlegast sláðu inn netfang');
return;
}
try {
// Stop the recording and get the file path
const filePath = await apiService.stopRecording(email);
console.log('Recording saved to:', filePath);
// Reset all states
setIsRecording(false);
setRecordingTime('00:00:00');
setAudioLevel(0);
setRecordingNameInput(''); // Clear the recording name input
// Notify parent to refresh recordings list
onRecordingsUpdated();
} catch (error) {
console.error('Error stopping recording:', error);
setError(error instanceof Error ? error.message : 'Villa kom upp við að stöðva upptöku');
}
};
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
{error && (
<div className="bg-red-50 dark:bg-red-900/50 border-l-4 border-red-400 dark:border-red-500 p-4 mb-4">
<div className="flex">
<div className="ml-3">
<p className="text-sm text-red-700 dark:text-red-200">{error}</p>
</div>
</div>
</div>
)}
<div className="mb-6">
<label htmlFor="recordingName" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 font-oxanium">
Nafn upptöku
</label>
<input
type="text"
id="recordingName"
value={recordingNameInput}
onChange={(e) => setRecordingNameInput(e.target.value)}
placeholder="Sláðu inn nafn á upptökunni"
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-[#c4161c] focus:border-[#c4161c] dark:bg-gray-700 dark:text-white"
disabled={isRecording}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="text-2xl font-mono text-gray-900 dark:text-white">{recordingTime}</div>
<div className="w-32 h-4 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full bg-[#c4161c] dark:bg-[#c4161c] transition-all duration-100"
style={{ width: `${audioLevel}%` }}
/>
</div>
</div>
<div className="flex space-x-4">
{!isRecording ? (
<button
onClick={startRecording}
className="flex items-center px-4 py-2 bg-[#c4161c] text-white rounded-md hover:bg-[#a31218] focus:outline-none focus:ring-2 focus:ring-[#c4161c] focus:ring-offset-2 dark:focus:ring-offset-gray-800 font-oxanium"
disabled={!apiService}
>
<MicrophoneIcon className="h-5 w-5 mr-2" />
Byrja upptöku
</button>
) : (
<button
onClick={stopRecording}
className="flex items-center px-4 py-2 bg-[#c4161c] text-white rounded-md hover:bg-[#a31218] focus:outline-none focus:ring-2 focus:ring-[#c4161c] focus:ring-offset-2 dark:focus:ring-offset-gray-800 font-oxanium"
>
<StopIcon className="h-5 w-5 mr-2" />
Stöðva upptöku
</button>
)}
</div>
</div>
</div>
);
};
export default RecordingPage;

100
src/services/LogService.ts Normal file
View File

@ -0,0 +1,100 @@
import { format } from 'date-fns';
import { is } from 'date-fns/locale';
export type LogLevel = 'LOG' | 'VILLA' | 'VARÐVÍSSUN' | 'UPPLÝSING';
export interface LogEntry {
timestamp: string;
level: LogLevel;
message: string;
}
class LogService {
private static instance: LogService | null = null;
private logs: LogEntry[] = [];
private originalConsole: {
log: typeof console.log;
error: typeof console.error;
warn: typeof console.warn;
info: typeof console.info;
};
private constructor() {
this.originalConsole = {
log: console.log,
error: console.error,
warn: console.warn,
info: console.info,
};
this.setupConsoleOverrides();
this.loadLogs();
}
public static getInstance(): LogService {
if (!LogService.instance) {
LogService.instance = new LogService();
}
return LogService.instance;
}
private setupConsoleOverrides() {
console.log = (...args) => {
this.originalConsole.log(...args);
this.addLog('LOG', ...args);
};
console.error = (...args) => {
this.originalConsole.error(...args);
this.addLog('VILLA', ...args);
};
console.warn = (...args) => {
this.originalConsole.warn(...args);
this.addLog('VARÐVÍSSUN', ...args);
};
console.info = (...args) => {
this.originalConsole.info(...args);
this.addLog('UPPLÝSING', ...args);
};
}
private async loadLogs() {
try {
const logs = await window.electronAPI.getLogs();
this.logs = logs;
} catch (error) {
this.originalConsole.error('Error loading logs:', error);
}
}
private async addLog(level: LogLevel, ...args: any[]) {
const timestamp = format(new Date(), 'HH:mm:ss', { locale: is });
const message = args.map(arg =>
typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg)
).join(' ');
const logEntry: LogEntry = { timestamp, level, message };
this.logs.push(logEntry);
try {
await window.electronAPI.saveLog(logEntry);
} catch (error) {
this.originalConsole.error('Error saving log:', error);
}
}
public getLogs(): LogEntry[] {
return [...this.logs];
}
public clearLogs() {
this.logs = [];
window.electronAPI.clearLogs().catch(error => {
this.originalConsole.error('Error clearing logs:', error);
});
}
}
export default LogService;

645
src/services/api.ts Normal file
View File

@ -0,0 +1,645 @@
import { io, Socket } from 'socket.io-client';
import { format } from 'date-fns';
import { is } from 'date-fns/locale';
declare global {
interface Window {
electronAPI: {
saveRecording: (fileName: string, data: string, email: string) => Promise<string>;
getRecordingPath: (fileName: string, email: string) => Promise<string>;
readRecordingData: (fileName: string, email: string) => Promise<string>;
deleteRecording: (fileName: string, email: string) => Promise<void>;
selectDirectory: () => Promise<string>;
getDefaultDirectory: () => Promise<string>;
listRecordings: (email: string) => Promise<Recording[]>;
onUploadProgress: (onProgress: (progress: number) => void) => (() => void);
uploadRecording: (fileName: string, userEmail: string, borgEnvironment: string) => Promise<void>;
checkFFmpeg: () => Promise<boolean>;
onFFmpegStatus: (callback: (status: boolean) => void) => (() => void);
installFFmpeg: () => Promise<boolean>;
getFFmpegDownloadUrl: () => Promise<string>;
getPlatform: () => string;
getUserSettings: (email: string) => Promise<UserSettings>;
saveUserSettings: (email: string, settings: UserSettings) => Promise<void>;
renameRecording: (oldFileName: string, newFileName: string, email: string) => Promise<void>;
}
}
}
export interface Recording {
id: string;
fileName: string;
date: Date;
filePath: string;
email: string;
size: number;
uploaded?: boolean;
}
export interface RecordingSettings {
saveDirectory: string;
selectedMicrophone?: string;
email?: string;
borgEnvironment: 'borg.unak.is' | 'test.borg.unak.is';
audioSettings: {
bitrate: number;
sampleRate: number;
channels: number;
};
}
export interface UserSettings {
selectedMicrophone?: string;
audioSettings?: {
bitrate: number;
sampleRate: number;
channels: number;
};
borgEnvironment?: 'borg.unak.is' | 'test.borg.unak.is';
}
export class ApiService {
private static instance: ApiService | null = null;
private mediaRecorder: MediaRecorder | null = null;
private audioChunks: Blob[] = [];
private recordingStartTime: number = 0;
private recordingTimer: number | null = null;
private onTimeUpdate: ((time: string) => void) | null = null;
private onAudioLevelUpdate: ((level: number) => void) | null = null;
private audioContext: AudioContext | null = null;
private analyser: AnalyserNode | null = null;
private animationFrameId: number | null = null;
private settings: RecordingSettings = {
saveDirectory: '',
borgEnvironment: 'borg.unak.is',
audioSettings: {
bitrate: 256000,
sampleRate: 48000,
channels: 2
}
};
private currentMimeType: string = '';
private initialized: boolean = false;
private currentRecordingName: string | null = null;
private saveInterval: number | null = null;
private lastSavedChunks: Blob[] = [];
private currentEmail: string | null = null;
private constructor() {}
private async initializeSettings() {
if (this.initialized) return;
try {
const defaultDir = await window.electronAPI.getDefaultDirectory();
console.log('Initializing with default directory:', defaultDir);
this.settings = {
saveDirectory: defaultDir,
borgEnvironment: 'borg.unak.is',
selectedMicrophone: undefined,
audioSettings: {
bitrate: 256000,
sampleRate: 48000,
channels: 2
}
};
this.initialized = true;
} catch (error) {
console.error('Error initializing settings:', error);
throw new Error('Villa kom upp við að tengja við skráarkerfi');
}
}
public static async getInstance(): Promise<ApiService> {
if (!ApiService.instance) {
ApiService.instance = new ApiService();
await ApiService.instance.initializeSettings();
}
return ApiService.instance;
}
public async getSettings(): Promise<RecordingSettings> {
if (!this.initialized) {
await this.initializeSettings();
}
return { ...this.settings };
}
private async loadUserSettings(email: string): Promise<void> {
try {
const userSettings = await window.electronAPI.getUserSettings(email);
if (userSettings) {
this.settings = {
...this.settings,
...userSettings,
email,
borgEnvironment: userSettings.borgEnvironment || this.settings.borgEnvironment
};
}
} catch (error) {
console.error('Error loading user settings:', error);
}
}
public async setSettings(settings: Partial<RecordingSettings>) {
console.log('Updating settings:', settings);
this.settings = { ...this.settings, ...settings };
// Ensure saveDirectory is always set
if (!this.settings.saveDirectory) {
const defaultDir = await window.electronAPI.getDefaultDirectory();
this.settings.saveDirectory = defaultDir;
}
// Save user-specific settings if email is available
if (this.settings.email) {
const userSettings: UserSettings = {
selectedMicrophone: this.settings.selectedMicrophone,
audioSettings: this.settings.audioSettings
};
await window.electronAPI.saveUserSettings(this.settings.email, userSettings);
}
}
public async selectDirectory(): Promise<string> {
try {
const directory = await window.electronAPI.selectDirectory();
console.log('Selected directory:', directory);
this.settings.saveDirectory = directory;
return directory;
} catch (error) {
console.error('Error selecting directory:', error);
throw new Error('Villa kom upp við að velja möppu');
}
}
private async saveBackupRecording(): Promise<void> {
if (this.audioChunks.length === 0) {
console.log('No audio chunks, skipping backup');
return;
}
if (!this.currentEmail) {
console.error('No email set for backup recording');
return;
}
try {
console.log('Saving backup recording for email:', this.currentEmail);
const audioBlob = new Blob(this.audioChunks, { type: this.currentMimeType });
const fileName = this.currentRecordingName
? `${this.currentRecordingName}_saved.webm`
: `podcast_${format(new Date(), 'yyyy-MM-dd_HH-mm-ss')}_saved.webm`;
// Convert blob to array buffer
const arrayBuffer = await audioBlob.arrayBuffer();
// Convert array buffer to base64 using browser-compatible method
const base64 = btoa(
new Uint8Array(arrayBuffer)
.reduce((data, byte) => data + String.fromCharCode(byte), '')
);
// Save to filesystem through main process
await window.electronAPI.saveRecording(fileName, base64, this.currentEmail);
console.log('Backup saved:', fileName);
// Store the current chunks as last saved
this.lastSavedChunks = [...this.audioChunks];
} catch (error) {
console.error('Error saving backup:', error);
}
}
public async startRecording(
onTimeUpdate: (time: string) => void,
onAudioLevelUpdate: (level: number) => void,
recordingName: string,
email: string
): Promise<void> {
try {
// Load user settings before starting recording
await this.loadUserSettings(email);
console.log('Starting recording...');
console.log('Using microphone:', this.settings.selectedMicrophone);
console.log('Recording name:', recordingName);
console.log('Email:', email);
this.currentEmail = email;
// Basic audio constraints first
const constraints = {
audio: this.settings.selectedMicrophone ? {
deviceId: { exact: this.settings.selectedMicrophone }
} : {
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
channelCount: 2,
sampleRate: 48000
}
};
console.log('Media constraints:', constraints);
const stream = await navigator.mediaDevices.getUserMedia(constraints);
console.log('Got media stream:', stream);
// Check for supported MIME types
const mimeType = 'audio/webm;codecs=opus';
if (!MediaRecorder.isTypeSupported(mimeType)) {
console.error('audio/webm;codecs=opus is not supported');
throw new Error('Required audio format not supported');
}
console.log('Using MIME type:', mimeType);
this.currentMimeType = mimeType;
this.mediaRecorder = new MediaRecorder(stream, {
mimeType: mimeType,
audioBitsPerSecond: 320000 // 320 kbps for maximum quality
});
console.log('Created MediaRecorder:', this.mediaRecorder);
this.audioChunks = [];
this.lastSavedChunks = [];
this.recordingStartTime = Date.now();
this.onTimeUpdate = onTimeUpdate;
this.onAudioLevelUpdate = onAudioLevelUpdate;
this.currentRecordingName = recordingName;
// Set up audio analysis for level monitoring
this.audioContext = new AudioContext();
const source = this.audioContext.createMediaStreamSource(stream);
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 256;
this.analyser.smoothingTimeConstant = 0.3;
source.connect(this.analyser);
this.mediaRecorder.ondataavailable = (event) => {
console.log('Data available:', event.data.size, 'bytes');
if (event.data.size > 0) {
this.audioChunks.push(event.data);
} else {
console.warn('Received empty data chunk');
}
};
// Request data every 100ms to ensure we get chunks
this.mediaRecorder.start(100);
console.log('Started recording');
this.startTimer();
this.startAudioLevelMonitoring();
// Start periodic saving every 5 seconds
this.saveInterval = window.setInterval(() => {
this.saveBackupRecording();
}, 5000);
} catch (error) {
console.error('Error starting recording:', error);
throw new Error('Villa kom upp við að byrja upptöku');
}
}
public async stopRecording(email: string): Promise<string> {
if (!this.mediaRecorder) {
console.error('No active recording to stop');
throw new Error('Engin upptaka í gangi');
}
if (!email || typeof email !== 'string' || email.trim() === '') {
console.error('Invalid email provided to stopRecording');
throw new Error('Email is required to stop recording');
}
console.log('Stopping recording with email:', email);
return new Promise((resolve, reject) => {
this.mediaRecorder!.onstop = async () => {
try {
console.log('MediaRecorder stopped, processing chunks:', this.audioChunks.length);
console.log('Current email:', this.currentEmail);
// Stop all audio tracks
this.mediaRecorder!.stream.getTracks().forEach(track => {
console.log('Stopping track:', track.label);
track.stop();
});
// Stop the timer and audio level monitoring
this.stopTimer();
this.stopAudioLevelMonitoring();
// Stop the save interval
if (this.saveInterval) {
clearInterval(this.saveInterval);
this.saveInterval = null;
}
// Close the audio context
if (this.audioContext) {
await this.audioContext.close();
this.audioContext = null;
}
this.analyser = null;
// Verify we have data
if (this.audioChunks.length === 0) {
throw new Error('No audio data recorded');
}
const totalSize = this.audioChunks.reduce((acc, chunk) => acc + chunk.size, 0);
console.log('Total recorded data size:', totalSize, 'bytes');
if (totalSize < 1000) {
throw new Error('Recording too small, likely no audio captured');
}
const audioBlob = new Blob(this.audioChunks, { type: this.currentMimeType });
console.log('Created audio blob:', audioBlob.size, 'bytes');
// Use the custom recording name without _saved postfix for final save
const fileName = this.currentRecordingName
? `${this.currentRecordingName}.webm`
: `podcast_${format(new Date(), 'yyyy-MM-dd_HH-mm-ss')}.webm`;
console.log('Saving recording as:', fileName);
console.log('Using email for save:', email);
// Ensure we're using the email from the parameter, not this.currentEmail
const filePath = await this.saveRecordingToFile(audioBlob, fileName, email);
console.log('Recording saved to:', filePath);
// Delete the _saved file if it exists
if (this.currentRecordingName) {
const savedFileName = `${this.currentRecordingName}_saved.webm`;
try {
await window.electronAPI.deleteRecording(savedFileName, email);
console.log('Deleted backup file:', savedFileName);
} catch (error) {
console.warn('Could not delete backup file:', error);
}
}
// Clear the recording state AFTER all operations are complete
this.mediaRecorder = null;
this.audioChunks = [];
this.lastSavedChunks = [];
this.currentRecordingName = null;
this.currentEmail = null;
resolve(filePath);
} catch (error) {
console.error('Error processing recording:', error);
reject(error);
}
};
// Ensure we have at least 1 second of recording
const recordingDuration = Date.now() - this.recordingStartTime;
if (recordingDuration < 1000) {
setTimeout(() => {
if (this.mediaRecorder) {
this.mediaRecorder.stop();
}
}, 1000 - recordingDuration);
} else {
if (this.mediaRecorder) {
this.mediaRecorder.stop();
}
}
});
}
private async saveRecordingToFile(blob: Blob, fileName: string, email: string): Promise<string> {
try {
console.log('Saving recording to file:', fileName);
console.log('Using email:', email);
if (!email || typeof email !== 'string' || email.trim() === '') {
console.error('Invalid email provided to saveRecordingToFile');
throw new Error('Email is required to save a recording');
}
// Ensure we have a valid directory
if (!this.settings.saveDirectory) {
console.error('No save directory set');
throw new Error('No save directory configured');
}
console.log('Using directory:', this.settings.saveDirectory);
// Convert blob to array buffer
const arrayBuffer = await blob.arrayBuffer();
console.log('Converted blob to array buffer, size:', arrayBuffer.byteLength);
if (arrayBuffer.byteLength < 1000) {
throw new Error('Recording data too small');
}
// Convert array buffer to base64 using browser-compatible method
const base64 = btoa(
new Uint8Array(arrayBuffer)
.reduce((data, byte) => data + String.fromCharCode(byte), '')
);
console.log('Converted to base64, length:', base64.length);
// Save to filesystem
const filePath = await window.electronAPI.saveRecording(fileName, base64, email);
console.log('Recording saved to:', filePath);
// Verify the file was saved
const exists = await this.validateRecording(fileName, email);
if (!exists) {
throw new Error('File was not saved successfully');
}
return filePath;
} catch (error) {
console.error('Error saving recording:', error);
throw new Error('Villa kom upp við að vista upptöku');
}
}
public async getRecordingUrl(fileName: string, email: string): Promise<string> {
try {
console.log('Getting recording URL for:', fileName, 'email:', email);
const base64 = await window.electronAPI.readRecordingData(fileName, email);
console.log('Received base64, length:', base64.length);
if (!base64 || base64.length < 100) {
throw new Error('Invalid recording data received');
}
// Handle both data URL and raw base64 formats
const base64Data = base64.startsWith('data:') ? base64.split(',')[1] : base64;
// Convert base64 to Uint8Array
const byteArray = Uint8Array.from(atob(base64Data), c => c.charCodeAt(0));
// Create blob with explicit MIME type and codec
const blob = new Blob([byteArray], {
type: 'audio/webm;codecs=opus'
});
// Create object URL with explicit MIME type
const url = URL.createObjectURL(blob);
console.log('Created object URL:', url);
// Verify the blob is valid
if (blob.size === 0) {
throw new Error('Created blob is empty');
}
return url;
} catch (error) {
console.error('Error getting recording URL:', error);
throw new Error('Failed to get recording URL: ' + (error instanceof Error ? error.message : String(error)));
}
}
public async deleteRecording(fileName: string, email: string): Promise<void> {
try {
console.log('Deleting recording:', fileName);
await window.electronAPI.deleteRecording(fileName, email);
} catch (error) {
console.error('Error deleting recording:', error);
throw new Error('Villa kom upp við að eyða upptöku');
}
}
public async validateRecording(fileName: string, email: string): Promise<boolean> {
try {
await window.electronAPI.getRecordingPath(fileName, email);
return true;
} catch {
return false;
}
}
public async uploadRecording(fileName: string, userEmail: string, onProgress?: (progress: number) => void): Promise<void> {
try {
console.log('Starting upload of recording:', fileName);
// Set up progress listener if callback provided
let cleanupProgressListener: (() => void) | undefined;
if (onProgress) {
cleanupProgressListener = window.electronAPI.onUploadProgress(onProgress);
}
try {
// Upload through main process
await window.electronAPI.uploadRecording(fileName, userEmail, this.settings.borgEnvironment);
console.log('Upload completed successfully');
} finally {
// Clean up progress listener
if (cleanupProgressListener) {
cleanupProgressListener();
}
}
} catch (error) {
console.error('Error uploading recording:', error);
throw new Error('Villa kom upp við að senda upptöku');
}
}
private startTimer() {
if (this.recordingTimer) {
clearInterval(this.recordingTimer);
}
this.recordingTimer = window.setInterval(() => {
if (this.onTimeUpdate) {
const elapsed = Date.now() - this.recordingStartTime;
const minutes = Math.floor(elapsed / 60000);
const seconds = Math.floor((elapsed % 60000) / 1000);
this.onTimeUpdate(`${minutes}:${seconds.toString().padStart(2, '0')}`);
}
}, 1000);
}
private stopTimer() {
if (this.recordingTimer) {
clearInterval(this.recordingTimer);
this.recordingTimer = null;
}
}
private startAudioLevelMonitoring() {
if (!this.analyser) return;
const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
const updateLevel = () => {
if (!this.analyser || !this.onAudioLevelUpdate) return;
this.analyser.getByteFrequencyData(dataArray);
// Calculate RMS (Root Mean Square) of the frequency data
let sum = 0;
for (let i = 0; i < dataArray.length; i++) {
sum += (dataArray[i] / 255) ** 2;
}
const rms = Math.sqrt(sum / dataArray.length);
// Apply some scaling to make the meter more responsive
const scaledLevel = Math.min(1, rms * 2);
this.onAudioLevelUpdate(scaledLevel);
this.animationFrameId = requestAnimationFrame(updateLevel);
};
updateLevel();
}
private stopAudioLevelMonitoring() {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
}
public async listRecordings(email: string): Promise<Recording[]> {
try {
console.log('Listing recordings for email:', email);
const recordings = await window.electronAPI.listRecordings(email);
console.log('Found recordings:', recordings);
return recordings.map(recording => ({
id: recording.fileName, // Use filename as ID since it's unique
fileName: recording.fileName,
date: new Date(recording.date),
filePath: recording.filePath,
email: email,
size: recording.size,
uploaded: false // We'll implement this later
}));
} catch (error) {
console.error('Error listing recordings:', error);
throw new Error('Villa kom upp við að sækja upptökur');
}
}
public async renameRecording(oldFileName: string, newFileName: string, email: string): Promise<void> {
try {
console.log('Renaming recording:', { oldFileName, newFileName, email });
// Check if the function exists
if (typeof window.electronAPI.renameRecording !== 'function') {
console.error('renameRecording function not found in electronAPI');
throw new Error('Rename functionality not available');
}
// Ensure the new filename has .webm extension
if (!newFileName.endsWith('.webm')) {
newFileName = `${newFileName}.webm`;
}
console.log('Calling renameRecording with:', { oldFileName, newFileName, email });
await window.electronAPI.renameRecording(oldFileName, newFileName, email);
console.log('Rename successful');
} catch (error) {
console.error('Error renaming recording:', error);
throw new Error('Villa kom upp við að endurnefna upptöku');
}
}
}

124
src/types/electron.d.ts vendored Normal file
View File

@ -0,0 +1,124 @@
import { RecordingSettings } from '../services/api';
// Define interfaces used by the API
interface UserSettings {
selectedMicrophone?: string;
audioSettings?: {
bitrate: number;
sampleRate: number;
channels: number;
};
borgEnvironment?: 'borg.unak.is' | 'test.borg.unak.is';
}
interface Recording {
id: string;
fileName: string;
date: Date;
filePath: string;
email: string;
size: number;
uploaded?: boolean;
}
// Define the ElectronAPI interface
interface ElectronAPI {
// File system operations
saveRecording: (fileName: string, data: string, email: string) => Promise<string>;
getRecordingPath: (fileName: string, email: string) => Promise<string>;
readRecordingData: (fileName: string, email: string) => Promise<string>;
deleteRecording: (fileName: string, email: string) => Promise<void>;
selectDirectory: () => Promise<string>;
getDefaultDirectory: () => Promise<string>;
listRecordings: (email: string) => Promise<Recording[]>;
uploadRecording: (fileName: string, userEmail: string, borgEnvironment: string) => Promise<void>;
// Window controls
minimizeWindow: () => Promise<void>;
closeWindow: () => Promise<void>;
// Upload progress
onUploadProgress: (onProgress: (progress: number) => void) => (() => void);
// FFmpeg status
checkFFmpeg: () => Promise<boolean>;
onFFmpegStatus: (callback: (status: boolean) => void) => (() => void);
installFFmpeg: () => Promise<boolean>;
getFFmpegDownloadUrl: () => Promise<string>;
restartApp: () => void;
// Logging functions
saveLog: (logEntry: any) => Promise<void>;
getLogs: () => Promise<Array<{ timestamp: string; level: string; message: string }>>;
clearLogs: () => Promise<void>;
// App Info
getAppVersion: () => Promise<string>;
// User settings
getUserSettings: (email: string) => Promise<UserSettings>;
saveUserSettings: (email: string, settings: UserSettings) => Promise<void>;
renameRecording: (oldFileName: string, newFileName: string, email: string) => Promise<void>;
}
interface Window {
electronAPI: ElectronAPI;
}
declare global {
// Define UserSettings interface here if it's not imported from elsewhere
// Assuming it's similar to the one in api.ts
interface UserSettings {
selectedMicrophone?: string;
audioSettings?: {
bitrate: number;
sampleRate: number;
channels: number;
};
borgEnvironment?: 'borg.unak.is' | 'test.borg.unak.is';
}
// Assuming Recording is also defined or imported if needed by other functions
interface Recording {
id: string;
fileName: string;
date: Date;
filePath: string;
email: string;
size: number;
uploaded?: boolean;
}
interface Window {
electronAPI: {
saveRecording: (fileName: string, data: string, email: string) => Promise<string>;
getRecordingPath: (fileName: string, email: string) => Promise<string>;
readRecordingData: (fileName: string, email: string) => Promise<string>;
deleteRecording: (fileName: string, email: string) => Promise<void>;
selectDirectory: () => Promise<string>;
getDefaultDirectory: () => Promise<string>;
listRecordings: (email: string) => Promise<Recording[]>;
onUploadProgress: (onProgress: (progress: number) => void) => (() => void);
uploadRecording: (fileName: string, userEmail: string, borgEnvironment: string) => Promise<void>;
checkFFmpeg: () => Promise<boolean>;
onFFmpegStatus: (callback: (status: boolean) => void) => (() => void);
installFFmpeg: () => Promise<boolean>;
getFFmpegDownloadUrl: () => Promise<string>;
getPlatform: () => string;
restartApp: () => void;
// User settings
getUserSettings: (email: string) => Promise<UserSettings>;
saveUserSettings: (email: string, settings: UserSettings) => Promise<void>;
renameRecording: (oldFileName: string, newFileName: string, email: string) => Promise<void>;
// Logging functions
saveLog: (logEntry: any) => Promise<void>;
getLogs: () => Promise<Array<{ timestamp: string; level: string; message: string }>>;
clearLogs: () => Promise<void>;
// App Info
getAppVersion: () => Promise<string>;
}
}
}
// Export an empty object to satisfy the module requirement if this is a module file
export {};

View File

@ -4,8 +4,13 @@ export default {
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class',
theme: {
extend: {},
extend: {
fontFamily: {
'oxanium': ['Oxanium', 'sans-serif'],
},
},
},
plugins: [
require('@tailwindcss/forms'),