Audio Recorder

We have chosen to open source our solution to provide transparent support for our customers. There are no start up or ongoing costs.

Here is a detailed guide on how to reproduce this in your organisation.

This is a WordPress Plugin that sends the transcribed message to a Google Sheet.

  1. Main Components:
  • audio_recorder.php: Core plugin functionality
  • recorder.js: Handles the frontend recording and upload logic
  • styles.css: Styling for the interface
  1. Key Features:- Audio recording with 5-minute limit – Offline support with IndexedDB storage – Automatic retry mechanism – Network status monitoring – Progress bar and countdown timer – Form validation for name and phone fields – Animated cassette icon during recording
  2. Data Flow: User Input -> Record Audio -> Local Storage -> Upload to Server -> Transcription -> Google Sheets
  3. Usage: Add this shortcode to any WordPress page/post: [yourshortcode] the shortcode will be audio_recorder

Practical Steps Needed:

You will need a Groq API Key and and an Open AI API key. You can get them from the Groq and Open AI websites. You will also need the google Sheet ID that the voice recorded transcription will be delivered to. Obtain this information and replace the place holder keys.

Use WP File manager to make a new folder called audio_recorder. Put the audio.php file (see below) into this folder.

Make 2 subfolders called css and js. Then copy and paste the js and css Files (see below) into their folders. Make sure you change the file extensions from .txt to .js and .css.

Make a Zip file of the the audio_recorder folder.

Then navigate to your Plugins page in WordPress. Select Add New Plugin, The Upload Plugin, Then choose file, and double click on your audio_recorder.zip file.

Next click on Activate to activate you plugin in.

Install Woody code snippets (PHP snippets | Insert PHP) to your wordpress website.

Add the text [audio-recorder] to the WordPress Page you want the voice recorder to be.

API Keys to be replaced:

Groq Key:

// Function to call Groq API for transcription
function arp_call_transcription_api($audio_data, $audio_mime_type) {
$api_key = ‘gsk_y3YDDxESxxxxxx6pZ4KhWGxxx5XXXxxKEZFxFJxP8CxZNRbFxxxx‘; // Replace with your actual Groq API key that you can get from the Groq website.

Open AI Key:

$service_account_path = dirname(ABSPATH) . ‘/text-to-speech-aves-99999b9999a2.json‘;// Replace with your actual OPEN API key that you can get from the Open AI website.

Your Google Sheet ID:

$service = new Sheets($client);
$spreadsheetId = ‘1aaAAl5AaaaaAaa0jWaAaRNaaATTdSIgXYK4FLGa9Y4U‘;
$range = ‘Sheet1!A:E’; // Changed to A:E to include all five columns



Source code of the php:

<?php
/**

  • Plugin Name: Audio Recording and Transcription
  • Description: Records audio, transcribes it using Groq API, and submits to Google Sheets
  • Version: 1.4
  • Author: Your Name
    */

// Increase PHP timeout for long-running requests
set_time_limit(120); // 2 minutes

// Increase memory limit
ini_set(‘memory_limit’, ‘256M’);

// Increase post max size and upload max filesize
ini_set(‘post_max_size’, ’64M’);
ini_set(‘upload_max_filesize’, ’64M’);

// Log PHP errors
ini_set(‘log_errors’, 1);
ini_set(‘error_log’, ABSPATH . ‘wp-content/uploads/php-error.log’); // Update this path

// Attempt to load the Google API Client Library
$autoload_path = ABSPATH . ‘vendor/autoload.php’;
if (file_exists($autoload_path)) {
require_once $autoload_path;
} else {
function arp_admin_notice_autoload_missing() {
?>

<?php
}
add_action(‘admin_notices’, ‘arp_admin_notice_autoload_missing’);
return; // Stop execution of the plugin if autoload.php is missing
}

use Google\Client;
use Google\Service\Sheets;

// Function to print service account email
function arp_print_service_account_email() {
$service_account_path = dirname(ABSPATH) . ‘/text-to-speech-aves-99999b9999a2.json‘;
if (file_exists($service_account_path)) {
$service_account_info = json_decode(file_get_contents($service_account_path), true);
if (isset($service_account_info[‘client_email’])) {
error_log(‘Service Account Email: ‘ . $service_account_info[‘client_email’]);
return $service_account_info[‘client_email’];
} else {
error_log(‘Client email not found in service account key file’);
return false;
}
} else {
error_log(‘Service account key file not found at: ‘ . $service_account_path);
return false;
}
}

// Call this function when the plugin initializes
add_action(‘plugins_loaded’, ‘arp_print_service_account_email’);

// Shortcode for the recording buttons
function arp_shortcode() {
// Add a simple flag before returning the HTML
wp_enqueue_script(‘arp-recorder’, plugin_dir_url(FILE) . ‘js/recorder.js’, array(‘jquery’), ‘1.0’, true);
wp_enqueue_style(‘arp-styles’, plugin_dir_url(FILE) . ‘css/styles.css’);
wp_localize_script(‘arp-recorder’, ‘arp_ajax’, array(
‘ajax_url’ => admin_url(‘admin-ajax.php’),
‘nonce’ => wp_create_nonce(‘arp-ajax-nonce’)
));

ob_start();
?>
<div id="arp-container">
    <div class="arp-input-container">
        <input type="text" id="arp-first-name" placeholder="First Name" class="arp-input" required>
        <input type="text" id="arp-family-name" placeholder="Family Name" class="arp-input" required>
        <input type="tel" id="arp-phone-number" placeholder="Phone Number" class="arp-input" required maxlength="11" pattern="[0-9]*">
    </div>
    <div class="arp-button-container">
        <button id="arp-start" class="arp-button" disabled>Start Recording</button>
        <button id="arp-stop" class="arp-button" disabled>Submit Recording</button>
    </div>
    <div class="progress-with-countdown">
        <div id="arp-progress-container">
            <div id="arp-progress-bar"></div>
        </div>
        <div id="arp-countdown" class="arp-countdown">5:00</div>
    </div>
    <!-- Replace the existing status div with our new structure -->
    <div id="arp-status-container" class="flex items-center gap-4">
      <div id="arp-status" class="flex-1"></div>
      <div class="cassette-icon-wrapper flex-shrink-0 w-[80px] h-[80px] flex items-center justify-center">
         <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="0.5" stroke-linecap="round" stroke-line-join="round" width="80" height="80">
            <style>
              @keyframes rotateReel {
                 from { transform: rotate(0deg); }
                 to { transform: rotate(360deg); }
              }

              @keyframes recordingPulse {
                 0% { stroke: currentColor; }
                 50% { stroke: #ff0000; }
                 100% { stroke: currentColor; }
              }

             .cassette-icon-wrapper.recording .reel-left,
             .cassette-icon-wrapper.recording .reel-right {
               animation: rotateReel 2s linear infinite;
              }

             .reel-left {
              transform-origin: 8px 10px;
              }

             .reel-right {
              transform-origin: 16px 10px;
             }        

            .cassette-icon-wrapper.recording .recording {
                animation: recordingPulse 2s ease-in-out infinite;
             }

             /* Inactive state */
             .cassette-icon-wrapper:not(.recording) svg {
                stroke: #cccccc;
             }

             /* Transition between states */
             .cassette-icon-wrapper svg {
                transition: stroke 0.3s ease;
             }
            </style>

            <!-- Main cassette body with rounded corners -->
            <rect x="2" y="4" width="20" height="14" rx="2" />

            <!-- Inner rectangle -->
            <rect x="4" y="6" width="16" height="8" rx="1" />

            <!-- Reels with double circles and rotation indicators -->
           <circle cx="8" cy="10" r="2.5" class="recording" />
           <g class="reel-left">
             <circle cx="8" cy="10" r="1.2" class="recording" />
             <!-- 0° (up) -->
             <line x1="8" y1="8.8" x2="8" y2="9.2" class="recording" />
             <!-- 90° (right) -->
             <line x1="9.2" y1="10" x2="8.8" y2="10" class="recording"/>
             <!-- 180° (down) -->
             <line x1="8" y1="11.2" x2="8" y2="10.8" class="recording"/>
             <!-- 270° (left) -->
             <line x1="6.8" y1="10" x2="7.2" y2="10" class="recording"/>
           </g>

           <!-- Right reel with 4 indicators -->
           <circle cx="16" cy="10" r="2.5" class="recording" />
           <g class="reel-right">
               <circle cx="16" cy="10" r="1.2" class="recording" />
               <!-- 0° (up) -->
               <line x1="16" y1="8.8" x2="16" y2="9.2" class="recording"  />
               <!-- 90° (right) -->
               <line x1="17.2" y1="10" x2="16.8" y2="10" class="recording"  />
               <!-- 180° (down) -->
               <line x1="16" y1="11.2" x2="16" y2="10.8" class="recording" />
               <!-- 270° (left) -->
               <line x1="14.8" y1="10" x2="15.2" y2="10" class="recording" />
           </g>

           <!-- Simplified bottom protrusion with single path -->
           <path d="M5 18 L6 16 L18 16 L19 18" />
           <circle cx="8" cy="17" r="0.3" fill="currentColor" />
           <line x1="10" y1="17" x2="14" y2="17" />
           <circle cx="16" cy="17" r="0.3" fill="currentColor" />
        </svg>
     </div>
  </div>
  <template id="audio-icon-template">
     <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" class="listen-icon">
          <path d="M39.6,108.1c-8.8,0-16-7.2-16-16c0-2.2,1.8-4,4-4s4,1.8,4,4c0,4.4,3.6,8,8,8c2.8,0,5.4-1.5,6.9-3.9 c0.6-1,1.4-2.8,2.3-4.7c1.7-3.8,3.6-8.1,6-11.2c1.9-2.5,4.7-5,7.4-7.5c2.7-2.5,5.8-5.4,6.8-7.2c2-3.5,3-7.4,3-11.4 c0-12.7-10.3-23-23-23s-23,10.3-23,23c0,2.2-1.8,4-4,4s-4-1.8-4-4c0-17.1,13.9-31,31-31c17.1,0,31,13.9,31,31 c0,5.4-1.4,10.7-4.1,15.4c-1.6,2.9-4.9,5.9-8.4,9.1c-2.4,2.3-5,4.6-6.5,6.5c-1.8,2.3-3.6,6.3-5,9.5c-1,2.2-1.8,4.1-2.7,5.6 C50.5,105.1,45.2,108.1,39.6,108.1z"/>
          <path d="M34,79.6c-2.2,0-4-1.8-4-4s1.8-4,4-4c2,0,3.7-1.6,3.7-3.7c0-2-1.6-3.7-3.7-3.7c-2.2,0-4-1.8-4-4v-6 c0-10.5,8.5-19,19-19s19,8.5,19,19c0,3.3-0.8,6.5-2.4,9.3c-0.1,0.1-0.1,0.2-0.2,0.3c-0.8,1.2-2.7,3-5.9,6l-1.2,1.1 c-1.6,1.5-4.2,1.4-5.7-0.2c-1.5-1.6-1.4-4.2,0.2-5.7L54,64c2.6-2.4,4.2-3.9,4.7-4.6c0.8-1.6,1.3-3.4,1.3-5.2c0-6.1-4.9-11-11-11 s-11,4.9-11,11v2.7c4.5,1.6,7.7,5.9,7.7,11C45.7,74.3,40.5,79.6,34,79.6z"/>
          <path d="M85,78.7c-0.6,0-1.2-0.2-1.7-0.5c-1.7-1-2.2-3.1-1.3-4.8c3.3-5.8,5.1-12.3,5.1-19c0-6.8-1.8-13.4-5.2-19.2 c-1-1.7-0.4-3.8,1.2-4.8c1.7-1,3.8-0.4,4.8,1.2c4.1,6.9,6.2,14.8,6.2,22.8c0,7.9-2.1,15.6-6,22.5C87.4,78.1,86.2,78.7,85,78.7z"/>
          <path d="M95.9,90.1c-0.8,0-1.5-0.2-2.2-0.7c-1.8-1.2-2.3-3.7-1.1-5.6c5.9-8.7,9-18.8,9-29.4 c0-10.6-3.2-20.9-9.2-29.6c-1.2-1.8-0.8-4.3,1-5.6c1.8-1.2,4.3-0.8,5.6,1c6.9,10.1,10.6,21.9,10.6,34.1c0,12.1-3.6,23.8-10.3,33.8 C98.4,89.5,97.1,90.1,95.9,90.1z"/>
     </svg>
</template>
</div>
<?php
return ob_get_clean();

}
add_shortcode(‘audio_recorder’, ‘arp_shortcode’);

// In your main plugin PHP file
add_action(‘wp_ajax_refresh_nonce’, ‘refresh_nonce’);
add_action(‘wp_ajax_nopriv_refresh_nonce’, ‘refresh_nonce’);

function refresh_nonce() {
wp_send_json_success(array(‘nonce’ => wp_create_nonce(‘arp-ajax-nonce’)));
}

// AJAX handler for transcription and Google Sheets submission
function arp_handle_transcription() {
if (!check_ajax_referer(‘arp-ajax-nonce’, ‘nonce’, false)) {
wp_send_json_error([‘message’ => ‘Security check failed’]);
return;
}

$required_fields = ['audio_data', 'first_name', 'family_name', 'phone_number', 'audio_mime_type'];
foreach ($required_fields as $field) {
    if (empty($_POST[$field])) {
        wp_send_json_error(['message' => "Missing required field: $field"]);
        return;
    }
}

$transcription = arp_call_transcription_api($_POST['audio_data'], $_POST['audio_mime_type']);

if ($transcription) {
    $result = arp_submit_to_google_sheets($transcription, $_POST['first_name'], $_POST['family_name'], $_POST['phone_number']);
    if ($result === true) {
        wp_send_json_success(['message' => 'Transcription submitted successfully']);
    } else {
        wp_send_json_error(['message' => 'Error submitting to Google Sheets']);
    }
} else {
    wp_send_json_error(['message' => 'Error getting transcription from Groq API']);
}

}

add_action(‘wp_ajax_arp_transcribe’, ‘arp_handle_transcription’);
add_action(‘wp_ajax_nopriv_arp_transcribe’, ‘arp_handle_transcription’);

error_log('API key last 4 characters: ' . substr($api_key, -4));
error_log('Audio data size: ' . strlen($audio_data) . ' bytes');
error_log('Audio MIME type: ' . $audio_mime_type);

if (empty($api_key)) {
    error_log('Groq API key is not set');
    return false;
}

// Determine file extension based on MIME type
$file_extension = 'wav'; // Default
switch ($audio_mime_type) {
    case 'audio/webm':
        $file_extension = 'webm';
        break;
    case 'audio/mp4':
        $file_extension = 'mp4';
        break;
    case 'audio/ogg':
        $file_extension = 'ogg';
        break;
}

$temp_file = wp_tempnam('audio_recording_') . '.' . $file_extension;
$decoded_data = base64_decode($audio_data);
if ($decoded_data === false || empty($decoded_data)) {
    error_log('Failed to decode audio data or empty result');
    return false;
}

if (file_put_contents($temp_file, $decoded_data) === false) {
    error_log('Failed to write audio data to temp file');
    return false;
}

if (!file_exists($temp_file) || filesize($temp_file) === 0) {
    error_log('Temp file is empty or not created');
    return false;
}

 error_log('Initiating Groq API request with: ' . print_r([
    'file_size' => filesize($temp_file),
    'mime_type' => $audio_mime_type,
    'file_exists' => file_exists($temp_file),
    'file_readable' => is_readable($temp_file)
], true));

$ch = curl_init('https://api.groq.com/openai/v1/audio/transcriptions');

curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array(
    'Authorization: Bearer ' . $api_key
));
curl_setopt($ch, CURLOPT_POSTFIELDS, array(
    'file' => new CURLFile($temp_file, $audio_mime_type, 'audio.' . $file_extension),
    'model' => 'whisper-large-v3'
));

error_log('Sending request to Groq API with file size: ' . filesize($temp_file) . ' bytes, MIME type: ' . $audio_mime_type);

$response = curl_exec($ch);

if ($response === false || empty($response)) {
    error_log('Empty or null response from Groq API');
    curl_close($ch);
    unlink($temp_file);
    return false;
}

$curl_info = curl_getinfo($ch);
error_log('Groq API curl details: ' . print_r([
    'total_time' => $curl_info['total_time'],
    'http_code' => $curl_info['http_code'],
    'content_type' => $curl_info['content_type'],
    'size_upload' => $curl_info['size_upload'],
    'size_download' => $curl_info['size_download'],
    'raw_response' => substr($response, 0, 1000) // First 1000 chars only
], true));

if (curl_errno($ch)) {
     error_log('Curl error details: ' . print_r([
        'error_code' => curl_errno($ch),
        'error_message' => curl_error($ch),
        'file_size' => filesize($temp_file),
        'mime_type' => $audio_mime_type,
        'curl_info' => curl_getinfo($ch)
    ], true));

    error_log('Curl error when calling Groq API: ' . curl_error($ch));
    curl_close($ch);
    unlink($temp_file);
    return false;
}

$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
unlink($temp_file);

error_log('Groq API raw response: ' . $response);

error_log('Groq API HTTP response code: ' . $http_code);


if ($http_code != 200) {
    error_log('Groq API returned non-200 status code: ' . $http_code);
    return false;
}

$result = json_decode($response, true);

if (json_last_error() !== JSON_ERROR_NONE) {
    error_log('JSON decode error: ' . json_last_error_msg());
    error_log('Raw response: ' . substr($response, 0, 1000));
    return false;
}

 if (!isset($result['text']) || !is_string($result['text'])) {
    error_log('Missing or invalid text field in response: ' . print_r($result, true));
    return false;
}

error_log('API Response parsing details: ' . print_r([
    'json_decode_error' => json_last_error(),
    'json_decode_message' => json_last_error_msg(),
    'response_length' => strlen($response),
    'decoded_structure' => $result ? array_keys($result) : 'null'
], true));

if (isset($result['text'])) {
    return $result['text'];
} else {
    error_log('Unexpected response from Groq API: ' . print_r($result, true));
    return false;
}

}

// Function to clean the transcription text
function clean_transcription_text($text) {
// Remove question marks
$text = str_replace(‘?’, ‘QUESTION’, $text);

// You can add more replacements here if needed
// For example, to replace & with 'and':
$text = str_replace('&', 'and', $text);

return $text;

}

function arp_submit_to_google_sheets($transcription, $first_name, $family_name, $phone_number) {
// Clean the transcription text
$cleaned_transcription = clean_transcription_text($transcription);

$service_account_path = dirname(ABSPATH) . '/text-to-speech-aves-12644b1807a2.json';

if (!file_exists($service_account_path)) {
    error_log('Service account key file not found at: ' . $service_account_path);
    return 'Service account key file not found';
}

$client = new Client();
$client->setAuthConfig($service_account_path);
$client->addScope('https://www.googleapis.com/auth/spreadsheets');

$service = new Sheets($client);
$spreadsheetId = '1aaAAl5AaaaaAaa0jWaAaRNaaATTdSIgXYK4FLGa9Y4U';
$range = 'Sheet1!A:E'; // Changed to A:E to include all five columns

$values = [
    [$_POST['timestamp'], $cleaned_transcription, $first_name, $family_name, $phone_number]
];

$body = new Sheets\ValueRange([
    'values' => $values
]);

try {
    $result = $service->spreadsheets_values->append($spreadsheetId, $range, $body, ['valueInputOption' => 'RAW']);
    error_log('Successfully submitted to Google Sheets. Result: ' . print_r($result, true));
    return true;
} catch (Exception $e) {
    error_log('Error submitting to Google Sheets: ' . $e->getMessage());
    error_log('Error details: ' . print_r($e->getTrace(), true));
    return $e->getMessage();
}

}



Source code for the Java Script js

(function($) {
let mediaRecorder;
let isRetrying = false;
let audioChunks = [];
let recordingInterval;
let recordingTime = 0;
let isOnline = navigator.onLine;
let isInUploadDelay = false;
let uploadDelayTimer = null;
let currentAudio = null;
const MAX_RECORDING_TIME = 300; // 300 seconds = 5 minutes
let db;

// Timer and Progress Functions
function updateCountdown() {
    const remainingTime = MAX_RECORDING_TIME - recordingTime;
    const minutes = Math.floor(remainingTime / 60);
    const seconds = remainingTime % 60;
    const formattedTime = `${minutes}:${seconds.toString().padStart(2, '0')}`;

    console.log('[Countdown] Updating countdown', {
       remainingTime,
       formattedTime,
       recordingTime
    });

    $('#arp-countdown').text(formattedTime);

    // Add warning class when less than 20 seconds remain
    if (remainingTime <= 20) {
        $('#arp-countdown').addClass('warning');
    } else {
        $('#arp-countdown').removeClass('warning');
    }
}

// Add network connection quality check
function checkConnectionQuality() {
if (!navigator.connection) {
return ‘unknown’;
}

const connection = navigator.connection;
if (connection.saveData) {
    return 'low'; // Data saver is enabled
}

// Check connection type
const type = connection.effectiveType || connection.type;
switch (type) {
    case '4g':
    case 'wifi':
        return 'high';
    case '3g':
        return 'medium';
    default:
        return 'low';
}

}

function updateProgressBar() {
    const progress = (recordingTime / MAX_RECORDING_TIME) * 100;
    const $bar = $('#arp-progress-bar');
    const $container = $('#arp-progress-container');

    // Stringify important values to ensure they're visible in console
    console.log('[Progress] Updating progress bar:', JSON.stringify({
        recordingTime,
        MAX_RECORDING_TIME,
        calculatedProgress: progress,
        currentWidth: $bar.css('width'),
        elementExists: $bar.length > 0,
        containerWidth: $('#arp-progress-container').css('width'),
        isVisible: $bar.is(':visible'),
        cssClasses: {
            container: $container.attr('class'),
            bar: $bar.attr('class')
        },
        styles: {
            container: {
                display: $container.css('display'),
                position: $container.css('position'),
                opacity: $container.css('opacity'),
                width: $container.css('width'),
                height: $container.css('height'),
            },
            bar: {
                display: $bar.css('display'),
                position: $bar.css('position'),
                backgroundColor: $bar.css('background-color'),
                width: $bar.css('width'),
                height: $bar.css('height'),
            }

        }
     }, null, 2));

    $bar.css('width', progress + '%');
    updateCountdown();
}

function startTimer() {
    const containerStyles = $('#arp-progress-container');
    const barStyles = $('#arp-progress-bar');

    console.log('[Timer] Starting timer - Computed Styles:', {
        container: {
            display: containerStyles.css('display'),
            width: containerStyles.css('width'),
            visibility: containerStyles.css('visibility'),
            position: containerStyles.css('position'),
            opacity: containerStyles.css('opacity')
        },
        bar: {
            display: barStyles.css('display'),
            width: barStyles.css('width'),
            visibility: barStyles.css('visibility'),
            position: barStyles.css('position'),
            backgroundColor: barStyles.css('background-color')
        },
        exists: {
            container: containerStyles.length > 0,
            bar: barStyles.length > 0
        }
    });

    recordingTime = 0;
    $('#arp-progress-container, .arp-countdown').addClass('recording');

    console.log('[Timer] Elements after class addition', {
        containerClasses: $('#arp-progress-container').attr('class'),
        countdownClasses: $('.arp-countdown').attr('class')
    });

    updateCountdown();
    recordingInterval = setInterval(() => {
        recordingTime++;
        console.log('[Timer] Interval tick', {
            recordingTime,
           progress: (recordingTime / MAX_RECORDING_TIME) * 100
        });
        updateProgressBar();
        if (recordingTime >= MAX_RECORDING_TIME) {
            stopRecording();
        }
    }, 1000);
}

function stopTimer() {
    console.log('[Timer] Stopping timer', {
       hasInterval: !!recordingInterval,
       finalRecordingTime: recordingTime
    });

    clearInterval(recordingInterval);
    $('#arp-progress-container, .arp-countdown').removeClass('recording');

    console.log('[Timer] Elements after class removal', {
        containerClasses: $('#arp-progress-container').attr('class'),
        countdownClasses: $('.arp-countdown').attr('class'),
        progressBarWidth: $('#arp-progress-bar').css('width')
    });

    $('#arp-progress-bar').css('width', '0%');
}

// Database Functions
function cleanupCompletedUploads() {
    const transaction = db.transaction(["recordings"], "readwrite");
    const objectStore = transaction.objectStore("recordings");
    const request = objectStore.getAll();

    request.onsuccess = (event) => {
       const recordings = event.target.result;
       recordings.forEach(recording => {
           if (recording.uploaded === true) {
               objectStore.delete(recording.id);
           }
       });
    };
}

// Database Functions
function initDB() {
const dbName = 'AudioRecordingsDB';
const request = indexedDB.open(dbName, 1);

request.onerror = (event) => {
    console.error("IndexedDB error:", event.target.error);
};

request.onsuccess = (event) => {
    db = event.target.result;
    console.log("IndexedDB initialized successfully");
    updatePendingRecordingsList();
};

request.onupgradeneeded = (event) => {
    const db = event.target.result;
    const objectStore = db.createObjectStore("recordings", { keyPath: "id", autoIncrement: true });
    objectStore.createIndex("timestamp", "timestamp", { unique: false });
};

}

function saveRecordingToDB(recording) {
const transaction = db.transaction([“recordings”], “readwrite”);
const objectStore = transaction.objectStore(“recordings”);
const request = objectStore.add(recording);

request.onsuccess = () => {
    console.log("[Storage] Recording saved locally");
    updateStatus('Audio saved pending upload...', 0);
    updatePendingRecordingsList();

    // Only attempt upload if we're online and not in delay period
    if (navigator.onLine && !isInUploadDelay) {
        console.log("[Storage] Attempting immediate upload");
        setTimeout(attemptUpload, 100);
    } else {
        console.log("[Storage] Upload deferred - Online:", navigator.onLine, "In delay:", isInUploadDelay);
    }
};

request.onerror = () => {
    console.error("Error saving recording locally");
    $('#arp-status').text('Error: Could not save recording locally.');
};

}

// Update the updatePendingRecordingsList function to remove redundant message
function updatePendingRecordingsList() {
if (!db) return;

const transaction = db.transaction(["recordings"], "readonly");
const objectStore = transaction.objectStore("recordings");
const request = objectStore.getAll();

request.onsuccess = (event) => {
    const recordings = event.target.result;
    const pendingRecordings = recordings.filter(rec => !rec.uploaded);

    let pendingList = $('#arp-pending-list');

    if (pendingList.length === 0) {
        $('#arp-container').append(`
            <div id="arp-pending-container">
                <div class="pending-header">
                    <h3>Pending Uploads</h3>
                    <div class="pending-header-status"></div>
                </div>
                <div class="status-row">
                    <div id="offline-status-message" class="offline-status-banner"></div>
                    <div class="connection-icon-container">
                        <div class="icon-offline">
                            <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
                                <path d="M12 20h.01"/>
                                <path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
                                <path d="M5 12.55a10.94 10.94 0 0 1 14 0"/>
                                <path d="M1.42 9a16 16 0 0 1 21.16 0"/>
                                <path d="M18 18L6 6" />
                            </svg>
                        </div>
                        <div class="icon-online">
                            <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
                                <path d="M12 20h.01"/>
                                <path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
                                <path d="M5 12.55a10.94 10.94 0 0 1 14 0"/>
                                <path d="M1.42 9a16 16 0 0 1 21.16 0"/>
                            </svg>
                        </div>
                    </div>
                </div>
                <ul id="arp-pending-list"></ul>
            </div>
        `);
        pendingList = $('#arp-pending-list');
    }

    pendingList.empty();

    if (pendingRecordings.length === 0) {
        $('#arp-pending-container').hide();
       // Only clear the text content while keeping the cassette icon
       $('#arp-status').text('');
       $('.cassette-icon-wrapper')
          .removeClass('recording')
          .addClass('show'); // Keep the icon visible but in grey state
    } else {
        $('#arp-pending-container').show();
        updateConnectionStatus();

        pendingRecordings.forEach(recording => {
            const timestamp = new Date(recording.timestamp).toLocaleString();
            const isRecording = mediaRecorder && mediaRecorder.state === 'recording';
            const listItem = $('<li>')
                .addClass('pending-recording-item')
                .attr('data-recording-id', recording.id)
                .html(`
                     <button class="listen-button" data-recording-id="${recording.id}" onclick="handlePlayback(${recording.id})" ${isRecording ? 'disabled' : ''}>
                        ${$('#audio-icon-template').html()}
                    </button>
                    <div class="recording-info">
                        <span class="pending-name">${recording.firstName} ${recording.familyName}</span>
                        <span class="pending-time">${timestamp}</span>
                    </div>
                    <button type="button" class="clear-button" onclick="window.clearRecording(${recording.id})" title="Delete recording">
                      <svg xmlns="http://www.w3.org/2000/svg" width="27" height="27" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                        <path d="M3 6h18"></path>
                        <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path>
                        <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
                        <line x1="10" y1="11" x2="10" y2="17"></line>
                        <line x1="14" y1="11" x2="14" y2="17"></line>
                      </svg>
                    </button>
                `);
            pendingList.append(listItem);

            if (recording.isUploading) {
                updateUploadStatus(recording.id, 'uploading');
            }
        });
    }
};

}

// Make handlePlayback available globally
window.handlePlayback = handlePlayback;

// Add this function near other utility functions
function handlePlayback(recordingId) {
const $button = $(.listen-button[data-recording-id="${recordingId}"]);

if (mediaRecorder && mediaRecorder.state === 'recording') {
return; // Don't allow playback while recording
}

// Stop any currently playing audio
if (currentAudio) {
    currentAudio.pause();
    $('.listen-button').removeClass('playing');
}

const transaction = db.transaction(["recordings"], "readonly");
const objectStore = transaction.objectStore("recordings");

objectStore.get(recordingId).onsuccess = (event) => {
    const recording = event.target.result;
    if (!recording) return;

    const audio = new Audio(`data:${recording.mimeType};base64,${recording.audio}`);

    audio.onended = () => {
        $button.removeClass('playing');
        currentAudio = null;
    };

    audio.play();
    currentAudio = audio;
    $button.addClass('playing');
};

}

// Make retryAllUploads available globally
window.retryAllUploads = retryAllUploads;

// Add a new function to handle retry logic
function retryUpload(recordingId) {
// Check connection before retry
const connectionQuality = checkConnectionQuality();

const transaction = db.transaction(["recordings"], "readwrite");
const objectStore = transaction.objectStore("recordings");
const request = objectStore.get(recordingId);

request.onsuccess = (event) => {
    const recording = event.target.result;
    if (!recording || recording.uploaded) {
        updatePendingRecordingsList();
        return;
    }

    // If it was an API error, try immediately
    if (recording.errorType === 'api') {
        submitTranscription(recording);
        return;
    }

    // For connection errors, check connection quality
    if (connectionQuality === 'low') {
        updateStatus('Poor connection detected. Please try with better signal.', 5000);
        return;
    }

    // Reset retry count if last attempt was more than 5 minutes ago
    const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
    if (recording.lastRetryTime && new Date(recording.lastRetryTime) < fiveMinutesAgo) {
        recording.retryCount = 0;
    }

    recording.lastRetryTime = new Date().toISOString();
    objectStore.put(recording).onsuccess = () => {
        submitTranscription(recording);
    };
};

}

// Add clearRecording to window object
window.clearRecording = function(recordingId) {
event.preventDefault();
if (!recordingId) {
console.error(‘No recording ID provided for clearing’);
return;
}

// Find the button that was clicked
const button = event.currentTarget;
const listItem = button.closest(‘.pending-recording-item’);

console.log(‘1. Initial button state:’, {
hasClass: button.classList.contains(‘deleting’),
button: button,
svgElement: button.querySelector(‘svg’),
lineElements: button.querySelectorAll(‘line’),
computedStyles: window.getComputedStyle(button),
svgComputedStyles: window.getComputedStyle(button.querySelector(‘svg’)),
lineComputedStyles: Array.from(button.querySelectorAll(‘line’)).map(line =>
window.getComputedStyle(line)
),
animationName: window.getComputedStyle(button).animationName,
svgAnimationName: window.getComputedStyle(button.querySelector(‘svg’)).animationName
});

// Add animation event listeners to both SVG and lines
const svg = button.querySelector(‘svg’);
const lines = button.querySelectorAll(‘line’);

svg.addEventListener(‘animationstart’, (e) => {
console.log(‘SVG animation event:’, {
target: e.target,
animationName: e.animationName,
elapsedTime: e.elapsedTime,
timeStamp: e.timeStamp
});
});

lines.forEach(line => {
    line.addEventListener('animationstart', (e) => {
        console.log('Line animation started:', e.animationName);
    });
});

if (!confirm(‘Are you sure you want to remove this recording? This cannot be undone.’)) {
console.log(‘6a. Delete cancelled’);
return;
}
console.log(‘6b. Delete confirmed’);

button.classList.add(‘deleting’);
console.log(‘7. Starting deletion process, class state:’, button.classList.contains(‘deleting’));

// Add new log here after class is added
console.log(‘7a. Styles after adding deleting class:’, {
buttonStyles: {
animation: window.getComputedStyle(button).animation,
animationName: window.getComputedStyle(button).animationName,
animationDuration: window.getComputedStyle(button).animationDuration,
animationTimingFunction: window.getComputedStyle(button).animationTimingFunction,
animationFillMode: window.getComputedStyle(button).animationFillMode
},
svgStyles: {
animation: window.getComputedStyle(svg).animation,
animationName: window.getComputedStyle(svg).animationName,
animationDuration: window.getComputedStyle(svg).animationDuration,
animationTimingFunction: window.getComputedStyle(svg).animationTimingFunction,
animationFillMode: window.getComputedStyle(svg).animationFillMode
},
lineStyles: Array.from(lines).map(line => ({
animation: window.getComputedStyle(line).animation,
animationName: window.getComputedStyle(line).animationName,
animationDuration: window.getComputedStyle(line).animationDuration,
animationTimingFunction: window.getComputedStyle(line).animationTimingFunction,
animationFillMode: window.getComputedStyle(line).animationFillMode
})),
cssRules: Array.from(document.styleSheets)
.filter(sheet => sheet.href === null || sheet.href.includes(‘styles.css’))
.flatMap(sheet => Array.from(sheet.cssRules))
.filter(rule => rule.selectorText?.includes(‘deleting’))
.map(rule => ({
selector: rule.selectorText,
cssText: rule.cssText
}))
});

console.log(‘7b. Stylesheets and Animation Rules:’, {
sheets: Array.from(document.styleSheets).map(sheet => ({
href: sheet.href,
type: sheet.ownerNode?.tagName,
media: sheet.media?.mediaText
})),
buttonSelector: button.matches(‘.clear-button.deleting’),
svgSelector: svg.closest(‘.clear-button.deleting’),
lineSelectors: Array.from(lines).map(line =>
line.closest(‘.clear-button.deleting’) !== null
)
});

console.log(‘7c. Computed styles for svg:’, {
beforeDelete: window.getComputedStyle(svg).cssText,
afterDelete: window.getComputedStyle(button.querySelector(‘svg’)).cssText,
transitionStyles: window.getComputedStyle(svg).getPropertyValue(‘transition’),
animationStyles: window.getComputedStyle(svg).getPropertyValue(‘animation’)
});

const transaction = db.transaction([“recordings”], “readwrite”);
const objectStore = transaction.objectStore(“recordings”);

objectStore.get(recordingId).onsuccess = (event) => {
const recording = event.target.result;
if (!recording) {
console.error(‘Recording not found:’, recordingId);
return;
}

   objectStore.delete(recordingId).onsuccess = () => {
       console.log('8. Recording cleared:', recordingId);
       console.log('9. Before timeout, button state:', {
           hasClass: button.classList.contains('deleting'),
           button: button,
           exists: document.contains(button)
       });

       setTimeout(() => {
           console.log('10. Inside timeout, button state:', {
               hasClass: button?.classList?.contains('deleting'),
               buttonExists: document.contains(button),
               listItemExists: document.contains(listItem)
           });   
           updatePendingRecordingsList();
       }, 800);
   };

};
};

// Update the updateUploadStatus function for the new layout
function updateUploadStatus(recordingId, status, message = ”) {
const listItem = $(.pending-recording-item[data-recording-id="${recordingId}"]);
const timestamp = new Date().toLocaleString(); // Already in current code

switch (status) {
    case 'uploading':
        listItem.html(`
            <div class="recording-info">
                <span class="pending-name">${listItem.find('.pending-name').text()}</span>
                <span class="pending-time">${timestamp}<div class="spinner"></div></span>
            </div>
            <button type="button" class="clear-button" disabled>
                <svg xmlns="http://www.w3.org/2000/svg" width="27" height="27" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                    <path d="M3 6h18"></path>
                    <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path>
                    <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
                    <line x1="10" y1="11" x2="10" y2="17"></line>
                    <line x1="14" y1="11" x2="14" y2="17"></line>
                </svg>
            </button>
        `);
        updateStatus(message || 'Starting upload...', 0);
        break;

    // Rest of the cases remain exactly the same
    case 'error':
        const errorMessage = !isOnline ? 'Connection lost' : (message || 'Upload failed');
        listItem.html(`
            <div class="recording-info">
                <span class="pending-name">${listItem.find('.pending-name').text() || errorMessage}</span>
                <span class="pending-time">${timestamp}</span>
            </div>
            <button type="button" class="clear-button" onclick="window.clearRecording(${recordingId})">
                <svg xmlns="http://www.w3.org/2000/svg" width="27" height="27" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                    <path d="M3 6h18"></path>
                    <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path>
                    <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
                    <line x1="10" y1="11" x2="10" y2="17"></line>
                    <line x1="14" y1="11" x2="14" y2="17"></line>
                </svg>
            </button>
        `);
        break;

    case 'success':
        // The item will be removed on next list update
        break;
}

}

// Add function to check for and cleanup old stuck recordings
function checkStuckRecordings() {
const ONE_DAY = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
const transaction = db.transaction([“recordings”], “readwrite”);
const objectStore = transaction.objectStore(“recordings”);

objectStore.getAll().onsuccess = (event) => {
    const recordings = event.target.result;
    const now = new Date().getTime();

    recordings.forEach(recording => {
        if (!recording.uploaded && recording.retryCount > 3) {
            const recordingAge = now - new Date(recording.timestamp).getTime();
            if (recordingAge > ONE_DAY) {
                // Add a special flag for old recordings
                recording.isStuck = true;
                objectStore.put(recording);

                // Update the UI to show it's stuck
                const displayMessage = 'This recording appears to be stuck. Please try again or clear it.';
                updateUploadStatus(recording.id, 'error', displayMessage);
            }
        }
    });
};

}

// Add clean up check on initialization
initDB = (function(original) {
return function() {
original.apply(this, arguments);
// Run cleanup check after DB is initialized
setTimeout(checkStuckRecordings, 1000);
// Check periodically
setInterval(checkStuckRecordings, ONE_HOUR);
};
})(initDB);

// Add constants
const ONE_HOUR = 60 * 60 * 1000;

// Validation Functions
function validatePhoneNumber(phoneNumber) {
    return /^\d{11}$/.test(phoneNumber);
}

function validateInputs() {
    const firstName = $('#arp-first-name').val().trim();
    const familyName = $('#arp-family-name').val().trim();
    const phoneNumber = $('#arp-phone-number').val().trim();
    return firstName !== '' && familyName !== '' && validatePhoneNumber(phoneNumber);
}

function updateStartButton() {
    $('#arp-start').prop('disabled', !validateInputs());
}

// Recording Functions
function startRecording() {
     console.log('[Recording] Starting recording process', {
        inputsValid: validateInputs(),
        mediaRecorderState: mediaRecorder?.state
    });

    if (!validateInputs()) {
        $('#arp-status').text('Please enter your first name, family name, and phone number.');
        $('#arp-status').show();
        return;
    }

    if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
        $('#arp-status').text('Error: Your browser does not support microphone access.');
        $('#arp-status').show();
        return;
    }

    if (currentAudio) {
        currentAudio.pause();
        $('.listen-button').removeClass('playing');
    }


    // Add cassette icon animation
    $('.cassette-icon-wrapper').addClass('show recording');

    navigator.mediaDevices.getUserMedia({ audio: true })
        .then(stream => {
            console.log('[Recording] Got media stream');

            let options = {};
            if (MediaRecorder.isTypeSupported('audio/webm')) {
                options = { mimeType: 'audio/webm' };
            } else if (MediaRecorder.isTypeSupported('audio/mp4')) {
                options = { mimeType: 'audio/mp4' };
            } else if (MediaRecorder.isTypeSupported('audio/ogg')) {
                options = { mimeType: 'audio/ogg' };
            }

            mediaRecorder = new MediaRecorder(stream, options);

            console.log('[Recording] MediaRecorder initialized', {
               mimeType: mediaRecorder.mimeType,
               state: mediaRecorder.state
            });

            mediaRecorder.start();
            startTimer();

            updateStatus('Recording...', 0);
            $('#arp-start').prop('disabled', true);
            $('#arp-stop').prop('disabled', false);

            audioChunks = [];
            mediaRecorder.addEventListener("dataavailable", event => {
                audioChunks.push(event.data);
            });

            mediaRecorder.addEventListener("stop", () => {
                const audioBlob = new Blob(audioChunks, { type: mediaRecorder.mimeType });
                console.log('[Recording] Recording stopped', {
                   blobSize: audioBlob.size,
                   mimeType: audioBlob.type
                });
                saveRecording(audioBlob);
            });
        })
        .catch(error => {
            console.error('[Recording] Error accessing microphone:', {
               name: error.name,
               message: error.message,
               stack: error.stack
           });


            let errorMessage = 'Error: Unable to access the microphone. ';
            if (error.name === 'NotAllowedError') {
                errorMessage += 'Permission denied. Please allow microphone access in your browser settings.';
            } else if (error.name === 'NotFoundError') {
                errorMessage += 'No microphone found. Please check your device.';
            } else {
                errorMessage += 'Please check your browser settings. Error: ' + error.name;
            }
            $('#arp-status').text(errorMessage);
            $('#arp-status').show();
        });
}

function stopRecording() {
    if (mediaRecorder && mediaRecorder.state === 'recording') {
        mediaRecorder.stop();
        stopTimer();
        updateStatus('Processing...', 0);
        $('#arp-start').prop('disabled', false);
        $('#arp-stop').prop('disabled', true);
        $('.cassette-icon-wrapper')
       .removeClass('recording')
       .addClass('show'); // Keep visible but stop animation
    }
}

// Status and UI Functions
let statusTimeout;

function updateStatus(message, duration = 3000) {
clearTimeout(statusTimeout);
const $status = $('#arp-status');

// Show message with full opacity
$status.css('opacity', '1').text(message);

if (duration > 0) {
    statusTimeout = setTimeout(() => {
        // Fade the text instead of hiding the box
        $status.css('opacity', '0');
    }, duration);
}

}

function clearInputFields() {
    $('#arp-first-name').val('');
    $('#arp-family-name').val('');
    $('#arp-phone-number').val('');
    $('.cassette-icon-wrapper').removeClass('show recording');
    updateStartButton();
}

// Server Communication Functions
function refreshNonce() {
    $.ajax({
        url: arp_ajax.ajax_url,
        type: 'POST',
        data: {
            action: 'refresh_nonce'
        },
        success: function(response) {
            if (response.success) {
                arp_ajax.nonce = response.data.nonce;
            }
        }
    });
}

function saveRecording(audioBlob) {
const firstName = $('#arp-first-name').val().trim();
const familyName = $('#arp-family-name').val().trim();
const phoneNumber = $('#arp-phone-number').val().trim();

if (!firstName || !familyName || !phoneNumber) {
    $('#arp-status').text('Error: Missing required fields. Please fill in all fields.');
    return;
}

updateStatus('Saving recording...', 0);

const reader = new FileReader();
reader.readAsDataURL(audioBlob);
reader.onloadend = function() {
    console.log('Audio Blob MIME type:', audioBlob.type);        
    const base64Audio = reader.result.split(',')[1];
    const now = new Date();
    const twoDigitMs = Math.floor(now.getMilliseconds() / 10).toString().padStart(2, '0');
    const recording = {
        audio: base64Audio,
        mimeType: audioBlob.type,
        timestamp: `${now.toLocaleString('en-GB', {
            year: 'numeric',
            month: '2-digit',
            day: '2-digit',
            hour: '2-digit',
            minute: '2-digit',
            second: '2-digit',
            hour12: false
        }).replace(/(\d+)\/(\d+)\/(\d+),/, '$3-$2-$1').replace(/,/g, '')}.${twoDigitMs}`,
        firstName: firstName,
        familyName: familyName,
        phoneNumber: phoneNumber,
        uploaded: false
    };

    const transaction = db.transaction(["recordings"], "readwrite");
    const objectStore = transaction.objectStore("recordings");
    const request = objectStore.add(recording);

    request.onsuccess = () => {
        console.log("Recording saved locally");
        updateStatus('Audio saved pending upload...', 0);
        updatePendingRecordingsList();
        setTimeout(attemptUpload, 100);
    };

    request.onerror = () => {
        console.error("Error saving recording locally");
        $('#arp-status').text('Error: Could not save recording locally.');
    };
};

}

function attemptUpload() {
    console.log('Attempting upload...');
    if (navigator.onLine) {
        const transaction = db.transaction(["recordings"], "readwrite");
        const objectStore = transaction.objectStore("recordings");
        const request = objectStore.getAll();

        request.onsuccess = (event) => {
            const recordings = event.target.result;
            console.log('All recordings:', recordings.map(rec => ({
                id: rec.id,
                timestamp: rec.timestamp,
                uploaded: rec.uploaded,
                firstName: rec.firstName,
                isUploading: rec.isUploading,
                lastError: rec.lastError,
                errorType: rec.errorType
            })));

            const pendingRecordings = recordings.filter(rec => !rec.uploaded);
            console.log(`Found ${recordings.length} total recordings, ${pendingRecordings.length} pending upload`);

             if (pendingRecordings.length === 0) {
                updateStatus('No recordings to upload.');
                return;
            }
            pendingRecordings.forEach(recording => {
                console.log('Submitting recording:', recording.id);
                submitTranscription(recording);
            });
        };
    }
}

// Add semaphore to control chunk uploads
// Enhanced Upload Manager with automatic retry and connection monitoring
const UploadManager = {
uploading: new Map(),
retryTimeouts: new Map(),
maxRetries: 5,
retryDelay: 5000,

init() {
    console.log('[UploadManager] Initializing', {
        initialConnectionState: navigator.onLine ? 'online' : 'offline'
    });

    window.addEventListener('online', () => {
        console.log('[UploadManager] Online event received', {
            currentUploads: this.uploading.size,
            pendingRetries: this.retryTimeouts.size
        });
        handleConnectionChange();
    });

    window.addEventListener('offline', () => {
        console.log('[UploadManager] Offline event received');
        handleConnectionChange();
    });
},

async startUpload(recordingId) {
    console.log('[UploadManager] Start upload requested', {
        recordingId,
        isOnline: navigator.onLine,
        isInUploadDelay,
        hasDelayTimer: uploadDelayTimer ? 'yes' : 'no',
        timestamp: new Date().toISOString()
    });

    if (this.uploading.has(recordingId)) {
        console.log('[UploadManager] Upload already in progress', { recordingId });
        return false;
    }

    this.uploading.set(recordingId, {
        status: 'uploading',
        retryCount: 0,
        timestamp: Date.now()
    });

    console.log('[UploadManager] Upload started', {
        recordingId,
        activeUploads: this.uploading.size
    });
    return true;
},

finishUpload(recordingId, success = true) {
    if (success) {
        this.uploading.delete(recordingId);
        this.retryTimeouts.delete(recordingId);
    } else {
        const upload = this.uploading.get(recordingId);
        if (upload) {
            upload.status = 'failed';
            upload.lastFailure = Date.now();
        }
    }
    this.updateUI(recordingId, success);
},

async retryAllFailedUploads() {
    if (!navigator.onLine || isInUploadDelay) {
        console.log('Still offline or in delay period, skipping retry');
        return;
    }

    console.log('Attempting to retry all failed uploads');

    const transaction = db.transaction(["recordings"], "readonly");
    const objectStore = transaction.objectStore("recordings");
    const request = objectStore.getAll();

    request.onsuccess = (event) => {
        const recordings = event.target.result;
        recordings.forEach(recording => {
            if (!recording.uploaded && !this.isActiveUpload(recording.id)) {
                this.scheduleRetry(recording);
            }
        });
    };
},

isActiveUpload(recordingId) {
    const upload = this.uploading.get(recordingId);
    return upload && upload.status === 'uploading';
},

scheduleRetry(recording) {
    if (this.retryTimeouts.has(recording.id)) {
        return; // Already scheduled
    }

    const upload = this.uploading.get(recording.id) || { retryCount: 0 };
    const delay = Math.min(this.retryDelay * Math.pow(2, upload.retryCount), 300000); // Max 5 minutes

    console.log(`Scheduling retry for recording ${recording.id} in ${delay}ms`);

    this.retryTimeouts.set(recording.id, setTimeout(() => {
        this.retryTimeouts.delete(recording.id);
        if (navigator.onLine) {
            submitTranscription(recording);
        }
    }, delay));
},

updateUI(recordingId, success) {
    const upload = this.uploading.get(recordingId);
    const listItem = $(`.pending-recording-item[data-recording-id="${recordingId}"]`);

    if (!listItem.length) return;

    if (success) {
        listItem.fadeOut(() => listItem.remove());
    } else {
        const retryCount = upload ? upload.retryCount : 0;
        const message = retryCount >= this.maxRetries ? 
            'Maximum retries reached. Please try manually.' :
            `Upload failed (${retryCount}/${this.maxRetries}). Will auto-retry when online.`;

        updateUploadStatus(recordingId, 'error', message);
    }
},

// Add connection quality monitoring
checkConnectionQuality() {
    if (!navigator.connection) return 'unknown';

    const connection = navigator.connection;
    if (connection.saveData) return 'low';

    const type = connection.effectiveType || connection.type;
    switch (type) {
        case '4g':
        case 'wifi':
            return 'high';
        case '3g':
            return 'medium';
        default:
            return 'low';
    }
}

};

// Modified handleConnectionChange function
function handleConnectionChange() {
const wasOffline = !isOnline;
isOnline = navigator.onLine;

console.log('[ConnectionChange] State change detected:', {
    wasOffline,
    isNowOnline: isOnline,
    hasDelayTimer: uploadDelayTimer ? 'yes' : 'no',
    isInUploadDelay,
    timestamp: new Date().toISOString()
});

if (isOnline && wasOffline) {
    if (uploadDelayTimer) {
        console.log('[ConnectionChange] Clearing existing delay timer');
        clearTimeout(uploadDelayTimer);
    }

    isInUploadDelay = true;
    let delaySeconds = 20;

    console.log('[ConnectionChange] Starting 20-second delay period');

    updateConnectionStatus();

    uploadDelayTimer = setTimeout(() => {
        console.log('[ConnectionChange] Delay period complete', {
            timestamp: new Date().toISOString(),
            isOnline: navigator.onLine,
            isInUploadDelay
        });

        isInUploadDelay = false;
        uploadDelayTimer = null;
        updateConnectionStatus();
        UploadManager.retryAllFailedUploads();
    }, delaySeconds * 1000);

    const countdownInterval = setInterval(() => {
        delaySeconds--;
        console.log('[ConnectionChange] Delay countdown:', {
            secondsRemaining: delaySeconds,
            isOnline: navigator.onLine,
            isInUploadDelay
        });
        updateConnectionStatus(delaySeconds);
        if (delaySeconds <= 0) {
            console.log('[ConnectionChange] Countdown complete');
            clearInterval(countdownInterval);
        }
    }, 1000);
} else {
    if (uploadDelayTimer) {
        console.log('[ConnectionChange] Connection lost or unchanged, clearing timer');
        clearTimeout(uploadDelayTimer);
        uploadDelayTimer = null;
    }
    isInUploadDelay = false;
}

updateConnectionStatus();
updatePendingRecordingsList();

}

// Modified retryAllUploads function
function retryAllUploads() {
console.log(‘[RetryAll] Retry all uploads called’, {
isOnline: navigator.onLine,
isRetrying,
isInUploadDelay,
timestamp: new Date().toISOString()
});

if (!isOnline || isRetrying || isInUploadDelay) {
    console.log('[RetryAll] Retry blocked', {
        reason: !isOnline ? 'offline' : isRetrying ? 'already retrying' : 'in upload delay'
    });
    return;
}

isRetrying = true;
updateConnectionStatus();

const transaction = db.transaction(["recordings"], "readonly");
const objectStore = transaction.objectStore("recordings");
const request = objectStore.getAll();

request.onsuccess = (event) => {
    const recordings = event.target.result;
    const pendingRecordings = recordings.filter(rec => !rec.uploaded);

    if (pendingRecordings.length === 0) {
        isRetrying = false;
        updateConnectionStatus();
        return;
    }

    // Attempt uploads
    pendingRecordings.forEach(recording => {
        submitTranscription(recording);
    });

    // Reset retry state after all attempts
    setTimeout(() => {
        isRetrying = false;
        updateConnectionStatus();
    }, 2000);
};

}

// Add this function to handle only icon state transitions
function updateConnectionIcon(isOnline) {
const iconContainer = $(‘.connection-icon-container’);
const iconOnline = $(‘.icon-online’);
const iconOffline = $(‘.icon-offline’);

// Stop any ongoing animations
iconOnline.stop(true, true);
iconOffline.stop(true, true);

// Update container class for state management
iconContainer.removeClass('connection-online connection-offline')
            .addClass(isOnline ? 'connection-online' : 'connection-offline');

if (!isOnline) {
    iconOnline.css('opacity', 0);
    iconOffline.css('opacity', 1);
} else {
    iconOffline.css('opacity', 0);
    iconOnline.css('opacity', 1);
}

}

// Add this function to handle only banner messages
function updateStatusBanner(isOnline, countdown = null) {
const offlineMessage = $(‘#offline-status-message’);
const statusContainer = $(‘.pending-header-status’);

// Stop any ongoing animations for banner
offlineMessage.stop(true, true);

statusContainer.html(`
    <button type="button" 
        class="retry-all-button" 
        onclick="window.retryAllUploads()"
        ${(isRetrying || !isOnline) ? 'disabled' : ''}>
        ${isRetrying ? 'Retrying...' : 'Retry'}
    </button>
`);

if (!isOnline) {
    offlineMessage
        .text('Will auto-retry when online')
        .fadeIn(300);
} else {
    if (isInUploadDelay && countdown !== null) {
        offlineMessage
            .text(`Resuming uploads in ${countdown}s`)
            .fadeIn(300);
    } else {
        offlineMessage.fadeOut(300, function() {
            $(this).text('');
        });
    }
}

}

// Update the connection status management
// Update the main connection status function to use the separated functions
function updateConnectionStatus(countdown = null) {
if (!$(‘.pending-header-status’).length) return;

console.log('[Banner] State Update:', {
    isOnline: isOnline,
    hasCountdown: countdown !== null,
    isDelay: isInUploadDelay
});

// Handle icon and banner updates independently
updateConnectionIcon(isOnline);
updateStatusBanner(isOnline, countdown);

// Logging for debugging
requestAnimationFrame(() => {
    console.log('[Banner] After Update:', {
        bannerText: $('#offline-status-message').text(),
        onlineIconVisible: $('.icon-online').css('opacity') === '1',
        offlineIconVisible: $('.icon-offline').css('opacity') === '1',
        hasCountdown: countdown !== null,
        isDelay: isInUploadDelay,
        isOnline: isOnline
    });
});

}

// Initialize the enhanced upload manager
$(document).ready(() => {
UploadManager.init();
});

function submitTranscription(recording) {
console.log(‘[Upload] Submit transcription called’, {
recordingId: recording.id,
isOnline: navigator.onLine,
timestamp: new Date().toISOString()
});

if (!UploadManager.startUpload(recording.id)) {
    console.log('[Upload] Upload already in progress');
    return;
}

console.log('[Security] Submitting with nonce:', {
nonce: arp_ajax.nonce,
recordingId: recording.id,
timestamp: new Date().toISOString()
});

$.ajax({
    url: arp_ajax.ajax_url,
    type: 'POST',
    data: {
        action: 'arp_transcribe',
        nonce: arp_ajax.nonce,
        audio_data: recording.audio,
        audio_mime_type: encodeURIComponent(recording.mimeType),
        first_name: recording.firstName,
        family_name: recording.familyName,
        phone_number: recording.phoneNumber,
        timestamp: recording.timestamp
    },
    success: async (response) => {
        if (response.success) {
            await updateRecordingStatusAsync(recording.id, true);
            UploadManager.finishUpload(recording.id, true);
            updateStatus('Upload completed successfully', 3000);
            clearInputFields();
        } else {
            handleUploadError({
                type: 'api',
                message: response.data?.message || 'API error'
            });
        }
    },
    error: (jqXHR, textStatus, errorThrown) => {
        handleUploadError({
            type: 'network',
            message: textStatus
        });

        console.log('[AJAX Error] Details:', {
            status: jqXHR.status,
            responseText: jqXHR.responseText,
            textStatus,
            errorThrown,
        recordingId: recording.id
        });
    }
});

}

// Helper function to update recording status with Promise
function updateRecordingStatusAsync(id, uploaded) {
return new Promise((resolve, reject) => {
const transaction = db.transaction([“recordings”], “readwrite”);
const objectStore = transaction.objectStore(“recordings”);

    const request = objectStore.get(id);
    request.onsuccess = (event) => {
        const recording = event.target.result;
        recording.uploaded = uploaded;
        recording.isUploading = false;

        const updateRequest = objectStore.put(recording);
        updateRequest.onsuccess = () => {
            updatePendingRecordingsList();
            if (uploaded) {
                cleanupCompletedUploads();
            }
            resolve();
        };
        updateRequest.onerror = () => reject(updateRequest.error);
    };
    request.onerror = () => reject(request.error);
});

}

// Modified retry logic
function retryUpload(recordingId) {
if (!recordingId || UploadManager.uploading.has(recordingId)) {
console.log(‘Upload in progress or invalid ID’);
return;
}

const transaction = db.transaction(["recordings"], "readwrite");
const objectStore = transaction.objectStore("recordings");

objectStore.get(recordingId).onsuccess = (event) => {
    const recording = event.target.result;
    if (!recording || recording.uploaded) {
        updatePendingRecordingsList();
        return;
    }

    // Reset upload state
    recording.isUploading = false;
    recording.lastFailedChunk = recording.lastCompletedChunk || 0;

    if (recording.errorType === 'api') {
        recording.lastFailedChunk = 0;
        recording.lastCompletedChunk = 0;
    }

    objectStore.put(recording).onsuccess = () => {
        submitTranscription(recording);
    };
};

}

// Add timeout handling for the upload process
function addUploadTimeout(recordingId, timeout = 300000) { // 5 minutes default
const timeoutId = setTimeout(() => {
const transaction = db.transaction([“recordings”], “readwrite”);
const objectStore = transaction.objectStore(“recordings”);
const request = objectStore.get(recordingId);

    request.onsuccess = (event) => {
        const record = event.target.result;
        if (record && record.isUploading) {
            handleUploadError(record, 'Upload timed out. Please try again.', 'timeout');
        }
    };
}, timeout);

return timeoutId;

}

// Update handleUploadError to include better Groq API error handling
function handleUploadError(error) {
console.error(‘Upload error:’, error);

const transaction = db.transaction(["recordings"], "readwrite");
const objectStore = transaction.objectStore("recordings");
const request = objectStore.get(recording.id);

 console.log('[Error Handler] Received error:', {
    error,
    errorType: typeof error,
    hasRecordingId: error?.recordingId ? 'yes' : 'no',
    fullError: JSON.stringify(error, null, 2),
    stack: new Error().stack
});

request.onsuccess = (event) => {
    const record = event.target.result;
    record.isUploading = false;
    record.lastError = error.message;
    record.errorType = error.type;

    objectStore.put(record);

    let displayMessage = error.type === 'api' ? 
        'Server error getting transcription from Groq API. Please try again.' : 
        error.message;

    updateUploadStatus(record.id, 'error', displayMessage);
    updateStatus(displayMessage, 5000);
};

}

// Add error logging
window.addEventListener(‘unhandledrejection’, (event) => {
console.error(‘[Error] Unhandled Promise Rejection:’, {
error: event.reason,
timestamp: new Date().toISOString()
});
});

// Log API responses
const originalUpdateUploadStatus = updateUploadStatus;
updateUploadStatus = function(recordingId, status, message) {
console.log(‘[Status] Upload status update’, {
recordingId,
status,
message,
timestamp: new Date().toISOString()
});
return originalUpdateUploadStatus.apply(this, arguments);
};

// Add the retryUpload function to the window object so it can be called from the onclick handler
window.retryUpload = retryUpload;

// Initialize and Event Listeners
initDB();

$('#arp-phone-number').on('input', function(e) {
    this.value = this.value.replace(/\D/g, '');
    if (this.value.length > 11) {
        this.value = this.value.slice(0, 11);
    }
    updateStartButton();
});

$('#arp-start').on('click', startRecording);
$('#arp-stop').on('click', stopRecording);
$('#arp-first-name, #arp-family-name, #arp-phone-number').on('input', function() {
    updateStartButton();
    $('#arp-status').text('');
});

setInterval(refreshNonce, 600000); // Refresh nonce every 10 minutes
updateStartButton(); // Initial button state

})(jQuery);



Source Code for the css

/* Base Container */

arp-container {

max-width: 99%;
margin: 0 auto;
padding: 5px;
font-family: Arial, sans-serif;

}

/* Input Styles */
.arp-input-container {
display: flex;
flex-direction: column;
gap: 10px;
margin-bottom: 20px;
}

.arp-input {
font-size: 16px;
padding: 12px;
border: 1px solid #ccc;
border-radius: 5px;
width: 100%;
box-sizing: border-box;
}

/* Button Styles */
.arp-button-container {
display: flex;
flex-direction: column;
gap: 15px;
margin-bottom: 1px;
}

.arp-button {
font-size: 18px;
padding: 15px 30px;
border: none;
border-radius: 25px;
cursor: pointer;
transition: background-color 0.3s ease, opacity 0.3s ease;
}

arp-start {

background-color: #4CAF50;
color: white;

}

arp-stop {

background-color: #57bdcc;
color: white;

}

.arp-button:disabled {
background-color: #cccccc;
cursor: not-allowed;
opacity: 0.7;
}

/* Add to your existing styles.css */

arp-status-container {

margin-top: 10px;
width: 100%;
position: relative;  /* Create positioning context for absolute icon */

}

.cassette-icon-wrapper {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
opacity: 0;
transition: opacity 0.3s ease;
}

.cassette-icon-wrapper.show {
opacity: 1;
}

/* Progress and Recording Status */

arp-status {

margin-top: 0px;
font-size: 14px;
line-height: 1.5;
padding: 10px;
border-radius: 5px;
background-color: #ffffff;
border: 1px solid #ffffff;
min-height: 45px;
transition: opacity 1.0s ease;

}

arp-status:empty {

opacity: 0;
display: flex;

}

.progress-with-countdown {
display: flex;
align-items: center;
gap: 15px;
margin-top: 0px;
width: 100%;
margin-bottom: 20px;
flex-wrap: nowrap;
}

arp-progress-container {

flex: 1;
height: 20px;
background-color: #f0f0f0;
border-radius: 10px;
overflow: hidden;
min-width: 100px;
max-width: calc(100% - 85px);

}

arp-progress-container.recording {

opacity: 1;

}

arp-progress-bar {

height: 100%;
background-color: #cccccc;
width: 0;
transition: width 0.5s ease;

}

arp-progress-container.recording #arp-progress-bar {

background-color: #4CAF50;

}

/* Countdown Styles */
.arp-countdown {
width: 70px;
flex: 0 0 70px;
text-align: center;
font-size: 18px;
font-weight: bold;
color: #cccccc;
font-family: monospace;
white-space: nowrap;
}

.arp-countdown.recording {
color: #4CAF50;
}

.arp-countdown.warning {
color: #ff4444;
animation: countdown-pulse 1s infinite;
}

@keyframes countdown-pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}

/* Status Row and Connection Indicators */
.status-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 6px 3px;
min-height: 38px;
border-bottom: 1px solid #e0e0e0;
position: relative;
}

.offline-status-banner {
flex: 1;
min-width: 0;
padding: 2px 8px; /* Reduced from 0 8px – adding minimal 2px vertical padding / margin: 0; display: flex; align-items: center; opacity: 1; transition: opacity 0.3s ease; height: auto; / Remove fixed height of 38px / min-height: 24px; / Set minimum height for when empty */
font-size: 14px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.connection-icon-container {
position: relative;
width: 32px;
height: 32px;
flex-shrink: 0;
margin-left: auto;
z-index: 2;
}

.icon-online,
.icon-offline {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s ease;
pointer-events: none;
background-color: transparent;
}

/* Initial state – offline icon visible by default / .icon-offline { opacity: 1; / Make offline icon visible by default */
}

/* Active states for icons */
.connection-online .icon-online {
opacity: 1;
}

.connection-offline .icon-offline {
opacity: 1;
}

.icon-online svg,
.icon-offline svg {
width: 32px;
height: 32px;
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
position: relative;
right: -8px; /* Add right offset to align with trash icon */
}

.icon-online svg {
stroke: #4CAF50;
}

.icon-offline svg {
stroke: #666;
}

/* Pending Records Container */

arp-pending-container {

margin-top: 5px;
padding: 10px 8px;
background-color: #f8f8f8;
border-radius: 8px;
border: 1px solid #e0e0e0;
transition: opacity 0.3s ease, visibility 0.3s ease;
display: flex;
flex-direction: column;
gap: 5px;

}

arp-pending-container h3 {

margin: 0;
color: #333;
font-size: 16px;

}

/* Pending Header */
.pending-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0;
padding-bottom: 8px;
border-bottom: 1px solid #e0e0e0;
}

/* Retry All Button */
.retry-all-button {
background-color: #57bdcc;
color: white;
border: none;
border-radius: 25px;
padding: 8px 20px;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
}

.retry-all-button:hover {
background-color: #489dad;
}

.retry-all-button:disabled {
background-color: #cccccc;
cursor: not-allowed;
opacity: 0.7;
}

.retry-all-button::before {
content: “↻”;
font-size: 16px;
font-weight: bold;
}

/* Pending Recordings List */

arp-pending-list {

list-style: none;
padding: 0;
margin: 0;
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;

}

.pending-recording-item {
display: flex;
align-items: center;
padding: 12px 8px;
border-bottom: 1px solid #e0e0e0;
font-size: 14px;
position: relative;
gap: 12px;
width: 100%;
}

.pending-recording-item:last-child {
border-bottom: none;
}

/* Recording Info */
.recording-info {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
min-width: 0;
padding: 0 5px;
margin-right: 40px;
overflow: hidden;
}

.pending-name {
font-weight: bold;
color: #4CAF50;
font-size: 15.4px;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
}

.pending-time {
white-space: nowrap;
color: #666;
font-size: 13.2px;
}

/* Clear Button Base Styles / .clear-button { position: absolute; right: 8px; padding: 4px; / Reduced from 6px / margin-right: -2px; background: none; border: 2px solid #666; / Added border / border-radius: 12px; / Added border radius / cursor: pointer; display: flex; align-items: center; justify-content: center; min-width: auto; color: #666; transition: color 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; flex-shrink: 0; z-index: 1; width: 39px; / Explicitly set width / height: 39px; / Explicitly set height / box-shadow: 0 2px 4px rgba(0,0,0,0.05); / Added subtle shadow */
}

/* Hover state */
.clear-button:hover {
border-color: #333;
box-shadow: 0 3px 6px rgba(0,0,0,0.1);
transform: translateY(-1px);
}

/* Disabled state */
.clear-button:disabled {
opacity: 0.5;
cursor: not-allowed;
border-color: #ccc;
box-shadow: none;
transform: none;
}

/* SVG Icon Size / .clear-button svg { width: 31px; / Increased from 27px / height: 31px; / Increased from 27px */
}

/* Animation states / .clear-button.deleting { border-color: transparent; / Hide border during deletion / background-color: #ffeb3b; / Yellow background */
animation: button-pulse 0.8s ease-in-out;
}

.clear-button.deleting line {
animation: blink-line 1.2s ease-in-out;
transform-origin: center;
}

.clear-button.deleting svg {
animation: fade-stroke 1.0s ease-in-out;
animation-delay: 0.1s;
}

/* Animation keyframes */
@keyframes button-pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}

@keyframes blink-line {
0% { transform: scaleY(1); }
50% { transform: scaleY(0.1); }
100% { transform: scaleY(1); }
}

@keyframes fade-stroke {
0% { stroke: #666; }
50% { stroke: #0066ff; }
100% { stroke: #666; }
}

/* Add to your existing styles.css / .listen-button { background: none; border: 2px solid #666; border-radius: 12px; padding: 2px; width: 50px; height: 50px; cursor: pointer; flex-shrink: 0; margin-right: 12px; / Add subtle shadow and transition */
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
transition: border-color 0.3s ease, opacity 0.3s ease;
}

.listen-button:disabled {
opacity: 0.5;
border-color: #ccc;
cursor: default;
}

.listen-button:not(:disabled):hover {
border-color: #4CAF50;
transform: translateY(-1px);
box-shadow: 0 3px 6px rgba(0,0,0,0.1);
}

.listen-button.playing {
border-color: #4CAF50;
background-color: rgba(76, 175, 80, 0.05);
}

.listen-button.playing .listen-icon {
fill: #4CAF50;
animation: pulse 2s infinite;
}

.listen-icon {
width: 100%;
height: 100%;
fill: #666;
transition: fill 0.3s ease;
}

@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
}
}

/* Optional: Add hover state even though button is disabled */
.listen-button:hover {
border-color: #999;
}

/* Spinner Animation */
.spinner {
position: relative;
width: 18px;
height: 18px;
border: 2px solid rgba(76, 175, 80, 0.3);
border-radius: 50%;
display: inline-block;
vertical-align: middle;
margin-left: 8px;
}

.spinner:after {
content: ”;
position: absolute;
top: -2px;
left: -2px;
width: 18px;
height: 18px;
border: 2px solid transparent;
border-top-color: #4CAF50;
border-radius: 50%;
animation: spin 1s linear infinite;
}

@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

/* Mobile Styles */
@media (max-width: 480px) { //////////////////////
#arp-container {
padding: 1px;
width: 99%;
}

.offline-status-banner {
    padding: 0 8px; /* Slightly reduced padding for mobile */
}

#arp-pending-list {
    max-height: 60vh;
}

.progress-with-countdown {
    margin: 15px 0;
    gap: 10px;
}

#arp-progress-container {
    max-width: calc(100% - 65px);
}

.recording-info {
    font-size: 13px;
    margin-right: 35px;
    padding: 0 3px;
}

.arp-countdown {
    width: 55px;
    flex: 0 0 55px;
    font-size: 16px;
}

.status-row {
    padding: 6px 4px;
}

.connection-icon-container {
    width: 28px;
    height: 28px;
}

.icon-online svg,
.icon-offline svg {
    width: 28px;
    height: 28px;
}

.offline-status-banner {
    font-size: 13px;
}

}

/* Desktop Styles */
@media (min-width: 768px) {
.arp-input-container {
flex-direction: row;
}

.arp-input {
    flex: 1;
}

.arp-button-container {
    flex-direction: row;
}

.arp-button {
    flex: 1;
}

.button-container {
    min-width: 140px;
}

}