audio/manager.js

import { h, render } from 'preact';
import { MusicToast } from "./toast.jsx";

import { generateRandomUUID } from '../utils/UUID.js';

const audio = {
    startup: {
        type: "sound",
        title: "Startup Sound",
        description: "Sound played when WikiShield starts up.",
        volume: 1,
        data: "https://raw.githubusercontent.com/LuniZunie/WikiShield-App/refs/heads/main/data/audio/startup.wav",
    },
    music: {
        type: "category",
        title: "Music",
        description: "Background music tracks.",
        volume: 1,
        properties: {
            zen_mode: {
                type: "playlist",
                title: "Zen Mode",
                description: "Music played in Zen mode.",
                volume: 1,
                tracks: [
                    {
                        type: "music",
                        title: "Whispers of Memories",
                        artist: "Restum-Anoush",
                        thumbnail: "https://upload.wikimedia.org/wikipedia/commons/3/3c/No-album-art.png",
                        length: 126,
                        data: "https://media.luni.me/audio/music/whispers-of-memories",
                    },
                    {
                        type: "music",
                        title: "Perfect Beauty",
                        artist: "Grand Project",
                        thumbnail: "https://media.luni.me/image/cover/perfect-beauty",
                        length: 440,
                        data: "https://media.luni.me/audio/music/perfect-beauty",
                    },
                    {
                        type: "music",
                        title: "Morning in the Forest",
                        artist: "Good B Music",
                        thumbnail: "https://media.luni.me/image/cover/morning-in-the-forest",
                        length: 655,
                        data: "https://media.luni.me/audio/music/morning-in-the-forest",
                    },
                ]
            }
        }
    },
    ui: {
        type: "category",
        title: "User Interface Sounds",
        description: "Sounds used for user interface interactions.",
        volume: 1,
        properties: {
            click: {
                type: "sound",
                title: "Click Sound",
                description: "Sound played when clicking on interface elements.",
                volume: 1,
                data: "https://raw.githubusercontent.com/LuniZunie/WikiShield-App/refs/heads/main/data/audio/click.wav"
            },
        }
    },
    queue: {
        type: "category",
        title: "Queue Sounds",
        description: "Sounds played for queue events.",
        volume: 1,
        properties: {
            ores: {
                type: "sound",
                title: "ORES Alert",
                description: "Sound played due to a high ORES score.",
                volume: 1,
                data: "https://raw.githubusercontent.com/LuniZunie/WikiShield-App/refs/heads/main/data/audio/ores.wav"
            },
            mention: {
                type: "sound",
                title: "Mention Alert",
                description: "Sound played when your username is mentioned in an edit.",
                volume: 1,
                data: "https://raw.githubusercontent.com/LuniZunie/WikiShield-App/refs/heads/main/data/audio/mention.wav"
            },
        }
    },
    notification: {
        type: "category",
        title: "Notification Sounds",
        description: "Sounds played for various notifications.",
        volume: 1,
        properties: {
            alert: {
                type: "sound",
                title: "Alert Sound",
                description: "Sound played for alerts.",
                volume: 1,
                data: "https://raw.githubusercontent.com/LuniZunie/WikiShield-App/refs/heads/main/data/audio/alert.wav"
            },
            notice: {
                type: "sound",
                title: "Notice Sound",
                description: "Sound played for notices.",
                volume: 1,
                data: "https://raw.githubusercontent.com/LuniZunie/WikiShield-App/refs/heads/main/data/audio/mention.wav"
            },
            toast: {
                type: "sound",
                title: "Toast Sound",
                description: "Sound played for toast notifications.",
                volume: 1,
                data: "https://raw.githubusercontent.com/LuniZunie/WikiShield-App/refs/heads/main/data/audio/toast.wav"
            },
        },
    },
    action: {
        type: "category",
        title: "Action Sounds",
        description: "Sounds played for various user actions.",
        volume: 1,
        properties: {
            default: {
                type: "sound",
                title: "Default Action Sound",
                description: "Sound played for default actions.",
                volume: 1,
                data: "https://raw.githubusercontent.com/LuniZunie/WikiShield-App/refs/heads/main/data/audio/action.wav"
            },
            failed: {
                type: "sound",
                title: "Failed Action Sound",
                description: "Sound played when an action fails.",
                volume: 1,
                data: "https://raw.githubusercontent.com/LuniZunie/WikiShield-App/refs/heads/main/data/audio/failed.wav"
            },
            report: {
                type: "sound",
                title: "Report Action Sound",
                description: "Sound played for report actions.",
                volume: 1,
                data: "https://raw.githubusercontent.com/LuniZunie/WikiShield-App/refs/heads/main/data/audio/report.wav"
            },
            block: {
                type: "sound",
                title: "Block Action Sound",
                description: "Sound played for block actions.",
                volume: 1,
                data: null
            },
            protect: {
                type: "sound",
                title: "Protect Action Sound",
                description: "Sound played for protect actions.",
                volume: 1,
                data: null
            }
        }
    },
};

/**
 * Playlist controller class that manages a single playlist's state and playback
 */
class PlaylistController {
    constructor(playlist, playlistKey, tracks, audioManager) {
        this.playlist = playlist;
        this.playlistKey = playlistKey;
        this.tracks = tracks;
        this.audioManager = audioManager;

        this.history = [];
        this.future = [];
        this.currentTrackIndex = null;
        this.currentAudio = null;
        this.isActive = false;
        this.toast = null;
        this.consecutiveErrors = 0;
    }

    start() {
        this.isActive = true;
        const index = this._pickRandomIndex();
        this._playTrack(index);
    }

    stop() {
        this.isActive = false;

        if (this.currentAudio) {
            this.currentAudio.pause();
            this.currentAudio.src = "";
            this.currentAudio = null;
        }

        if (this.toast) {
            this.toast.remove();
            this.toast = null;
        }

        this.currentTrackIndex = null;
        this.history = [];
        this.future = [];
    }

    next() {
        if (!this.isActive) return;

        let nextIndex;
        if (this.future.length > 0) {
            nextIndex = this.future.pop();
        } else {
            nextIndex = this._pickRandomIndex();
        }

        this._playTrack(nextIndex);
    }

    previous() {
        if (!this.isActive) return;

        if (this.history.length <= 1) {
            // Restart current track
            if (this.currentAudio) {
                this.currentAudio.currentTime = 0;
                this.currentAudio.play();
            }
            return;
        }

        const current = this.history.pop();
        this.future.push(current);
        const prevIndex = this.history[this.history.length - 1];

        this._playTrack(prevIndex, true);
    }

    _pickRandomIndex() {
        const length = this.tracks.length;
        if (length <= 1) return 0;

        const avoidWindow = Math.max(1, Math.floor(length / 2));
        const recentIndices = new Set(this.history.slice(-avoidWindow));

        for (let i = 0; i < 8; i++) {
            const idx = Math.floor(Math.random() * length);
            if (!recentIndices.has(idx)) return idx;
        }

        return Math.floor(Math.random() * length);
    }

    _playTrack(index, skipHistory = false) {
        if (!this.isActive) return;

        // Stop current audio
        if (this.currentAudio) {
            this.currentAudio.pause();
            this.currentAudio.src = "";
        }

        // Remove old toast
        if (this.toast) {
            this.toast.remove();
            this.toast = null;
        }

        // Update state
        this.currentTrackIndex = index;
        if (!skipHistory) {
            if (this.history[this.history.length - 1] !== index) {
                this.history.push(index);
                this.future = [];
            }
        }

        // Get track data
        const track = this.tracks[index];
        const volume = this.audioManager.getVolume([...this.playlistKey.split('.'), String(index)]);

        // Create audio element
        const audio = new Audio(track.data);
        audio.volume = volume;
        this.currentAudio = audio;

        if ('mediaSession' in navigator) {
            navigator.mediaSession.metadata = new MediaMetadata({
                title: track.title,
                artist: track.artist,
                album: `${this.playlist.title} – ${track.title}`,
                artwork: [
                    { src: track.thumbnail, sizes: "512x512", type: "image/png" }
                ]
            });

            navigator.mediaSession.setActionHandler('play', () => {
                return;
            });

            navigator.mediaSession.setActionHandler('pause', () => {
                return;
            });

            navigator.mediaSession.setActionHandler('previoustrack', () => {
                this.previous();
            });

            navigator.mediaSession.setActionHandler('nexttrack', () => {
                this.next();
            });
        }

        // Setup event handlers
        audio.onended = () => {
            if (this.isActive && this.currentAudio === audio) {
                this.consecutiveErrors = 0; // Reset on success
                this.next();
            }
        };

        audio.onerror = (e) => {
            if (this.isActive && this.currentAudio === audio) {
                this.consecutiveErrors++;

                // Stop after 3 consecutive errors to prevent infinite loop
                if (this.consecutiveErrors >= 3) {
                    console.error('Too many consecutive audio errors. Stopping playlist.');
                    this.stop();
                    return;
                }

                this.next();
            }
        };

        // Play audio and reset error counter on successful play
        audio.play()
            .then(() => {
                this.consecutiveErrors = 0; // Reset when play succeeds

                // Show toast only after successful play
                if (this.isActive && this.currentAudio === audio) {
                    this.toast = this.audioManager._createToast(track, audio, this);
                }
            })
            .catch((e) => {
                console.error('Failed to start playback:', e);
                if (this.isActive && this.currentAudio === audio) {
                    this.consecutiveErrors++;

                    // Stop after 3 consecutive errors to prevent infinite loop
                    if (this.consecutiveErrors >= 3) {
                        console.error('Too many consecutive playback errors. Stopping playlist.');
                        this.stop();
                        return;
                    }

                    // Try next track
                    this.next();
                }
            });
    }
}

/**
 * Main audio manager class
 */
export class AudioManager {
    constructor(wikishield) {
        this.wikishield = wikishield;
        this.audio = audio;

        // Simple tracking maps
        this.activePlaylists = new Map();
        this.soundEffects = new Map();
        this.previews = new Map();

        this.previewing = false;
    }

    // ========================================
    // PUBLIC API
    // ========================================

    async playSound(soundPath, abortController, preview = false) {
        if (!preview) {
            const zenMode = this.wikishield.storage.data.settings.zen_mode;
            if (zenMode.enabled && !zenMode.sound.enabled) {
                return;
            }
        }

        const sound = this.getSound(soundPath);
        if (!sound || !sound.data) return;

        const volume = this.getVolume(soundPath);
        const audio = new Audio(sound.data);
        audio.volume = !preview && this.previewing ? 0 : volume;

        if ('mediaSession' in navigator) {
            navigator.mediaSession.setActionHandler('play', () => {
                return;
            });

            navigator.mediaSession.setActionHandler('pause', () => {
                return;
            });

            navigator.mediaSession.setActionHandler('previoustrack', () => {
                return;
            });

            navigator.mediaSession.setActionHandler('nexttrack', () => {
                return;
            });
        }

        const muteId = generateRandomUUID();
        if (preview) {
            this.muteId = muteId;
            this.previewing = true;

            this.stopPreviews();
            this._muteAll();

            this.previews.set(audio, soundPath);
        }

        this.soundEffects.set(audio, soundPath);

        const promise = new Promise((resolve, reject) => {
            audio.resolve = resolve;
            audio.reject = reject;
        });

        audio.onended = () => {
            audio.resolve();
            this.soundEffects.delete(audio);

            if (preview) {
                this.previewing = false;

                setTimeout(() => {
                    if (this.muteId === muteId) {
                        this._unmuteAll();
                    }
                }, 250);

                this.previews.delete(audio);
            }
        };

        audio.onerror = () => {
            audio.resolve();
            this.soundEffects.delete(audio);

            if (preview) {
                this.previewing = false;

                setTimeout(() => {
                    if (this.muteId === muteId) {
                        this._unmuteAll();
                    }
                }, 250);

                this.previews.delete(audio);
            }
        };

        abortController?.signal?.addEventListener('abort', () => {
            audio.pause();
            audio.src = "";
            audio.resolve();
            this.soundEffects.delete(audio);

            if (preview) {
                this.previewing = false;

                setTimeout(() => {
                    if (this.muteId === muteId) {
                        this._unmuteAll();
                    }
                }, 250);

                this.previews.delete(audio);
            }
        });

        audio.play();

        return promise;
    }

    async playPlaylist(soundPath) {
        const sound = this.getSound(soundPath);
        if (!sound || sound.type !== 'playlist') return;

        const playlistKey = soundPath.join('.');

        // Stop existing playlist if running
        if (this.activePlaylists.has(playlistKey)) {
            this.stopPlaylist(soundPath);
        }

        // Stop all other music
        this._stopAllMusic();

        // Create and start new playlist controller
        const controller = new PlaylistController(sound, playlistKey, sound.tracks, this);
        this.activePlaylists.set(playlistKey, controller);
        controller.start();
    }

    stopPlaylist(soundPath) {
        const playlistKey = soundPath.join('.');
        const controller = this.activePlaylists.get(playlistKey);

        if (controller) {
            controller.stop();
            this.activePlaylists.delete(playlistKey);
        }
    }

    async previewSound(soundPath) {
        const sound = this.getSound(soundPath);
        if (!sound || !sound.data) return;

        // Mute all active sounds
        this._muteAll();

        const audio = new Audio(sound.data);
        audio.volume = this.getVolume(soundPath);
        this.previews.set(audio, soundPath);

        const cleanup = () => {
            this.previews.delete(audio);
            if (this.previews.size === 0) {
                this._unmuteAll();
            }
        };

        audio.onended = cleanup;
        audio.onerror = cleanup;

        await audio.play();
    }

    stopPreviews() {
        for (const audio of this.previews.keys()) {
            audio.pause();
            audio.onended = null;
            audio.onerror = null;
            audio.src = "";
        }
        this.previews.clear();
        this._unmuteAll();
    }

    onvolumechanged() {
        // Update all active playlist volumes (unless previewing)
        if (this.previewing) {
            for (const [audio, soundPath] of this.previews.entries()) {
                const newVolume = this.getVolume(soundPath);
                audio.volume = newVolume;
            }
        } else {
            for (const controller of this.activePlaylists.values()) {
                if (controller.currentAudio) {
                    const newVolume = this.getVolume(controller.playlistKey.split('.'));
                    controller.currentAudio.volume = newVolume;
                }
            }

            for (const [audio, soundPath] of this.soundEffects.entries()) {
                const newVolume = this.getVolume(soundPath);
                audio.volume = newVolume;
            }
        }
    }

    // ========================================
    // INTERNAL METHODS
    // ========================================

    _stopAllMusic() {
        for (const [key, controller] of this.activePlaylists.entries()) {
            controller.stop();
            this.activePlaylists.delete(key);
        }
    }

    _muteAll() {
        for (const controller of this.activePlaylists.values()) {
            if (controller.currentAudio) {
                controller.currentAudio.volume = 0;
            }
        }

        for (const audio of this.soundEffects.keys()) {
            audio.volume = 0;
        }
    }

    _unmuteAll() {
        for (const controller of this.activePlaylists.values()) {
            if (controller.currentAudio) {
                const soundPath = [...controller.playlistKey.split('.'), String(controller.currentTrackIndex)];
                controller.currentAudio.volume = this.getVolume(soundPath);
            }
        }

        // Restore sound effect volumes
        for (const [audio, soundPath] of this.soundEffects.entries()) {
            audio.volume = this.getVolume(soundPath);
        }
    }

    _createToast(track, audio, controller) {
        const container = document.createElement("div");
        document.body.appendChild(container);

        const toast = {
            timeoutId: null,
            remove: () => {
                if (toast.timeoutId) {
                    clearTimeout(toast.timeoutId);
                    toast.timeoutId = null;
                }

                const toastEl = container.querySelector(".music-toast");
                if (toastEl) {
                    toastEl.classList.add("music-toast-leave");
                }

                container.onanimationend = () => {
                    try {
                        render(null, container);
                    } catch {}

                    if (container.parentNode) {
                        container.parentNode.removeChild(container);
                    }
                };
            }
        };

        const onPrevious = () => {
            if (controller.isActive) controller.previous();
        };
        const onNext = () => {
            if (controller.isActive) controller.next();
        };

        render(
            <MusicToast
                title={track.title}
                artist={track.artist}
                thumbnail={track.thumbnail}
                audio={audio}
                onPrevious={onPrevious}
                onNext={onNext}
            />,
            container
        );

        toast.timeoutId = setTimeout(() => {
            if (controller.isActive) {
                toast.remove();
            }
        }, 3000);

        return toast;
    }

    // ========================================
    // UTILITY METHODS
    // ========================================

    getSound(path) {
        let current = { type: "category", properties: this.audio };

        for (const segment of path) {
            if (current.type === "category") {
                current = current.properties[segment];
            } else if (current.type === "playlist") {
                current = current.tracks[parseInt(segment)];
            } else {
                return null;
            }

            if (!current) return null;
        }

        return current;
    }

    getVolume(path) {
        const volumes = this.wikishield.storage.data.settings.audio.volume;

        let volume = volumes.master;
        let current = { type: "category", properties: this.audio };
        const pathParts = [ "master" ];
        for (const segment of path) {
            pathParts.push(segment);

            if (current.type === "category") {
                current = current.properties[segment];
            } else if (current.type === "playlist") {
                return volume;
            } else {
                return volume;
            }

            if (!current) break;

            const specificVolume = volumes[pathParts.join(".")];
            if (specificVolume !== undefined) {
                volume *= specificVolume;
            }
        }

        return volume;
    }
}