Improved dashboard and music queue
This commit is contained in:
		
							parent
							
								
									0745a4eb75
								
							
						
					
					
						commit
						71f535be27
					
				
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@ -4,3 +4,4 @@
 | 
			
		||||
.direnv/
 | 
			
		||||
cache/
 | 
			
		||||
**/.claude/settings.local.json
 | 
			
		||||
*.log
 | 
			
		||||
 | 
			
		||||
@ -3,31 +3,24 @@ import 'dart:async';
 | 
			
		||||
import 'package:args/args.dart';
 | 
			
		||||
import 'package:logging/logging.dart';
 | 
			
		||||
import 'package:mumbullet/mumbullet.dart';
 | 
			
		||||
import 'package:mumbullet/src/dashboard/server.dart';
 | 
			
		||||
import 'package:path/path.dart' as path;
 | 
			
		||||
 | 
			
		||||
Future<void> main(List<String> arguments) async {
 | 
			
		||||
  // Parse command line arguments
 | 
			
		||||
  final parser = ArgParser()
 | 
			
		||||
    ..addOption('config',
 | 
			
		||||
        abbr: 'c',
 | 
			
		||||
        defaultsTo: 'config.json',
 | 
			
		||||
        help: 'Path to configuration file')
 | 
			
		||||
    ..addOption('log-level',
 | 
			
		||||
  final parser =
 | 
			
		||||
      ArgParser()
 | 
			
		||||
        ..addOption('config', abbr: 'c', defaultsTo: 'config.json', help: 'Path to configuration file')
 | 
			
		||||
        ..addOption(
 | 
			
		||||
          'log-level',
 | 
			
		||||
          abbr: 'l',
 | 
			
		||||
          defaultsTo: 'info',
 | 
			
		||||
          allowed: ['debug', 'info', 'warning', 'error'],
 | 
			
		||||
        help: 'Log level (debug, info, warning, error)')
 | 
			
		||||
    ..addFlag('console-log',
 | 
			
		||||
        abbr: 'o',
 | 
			
		||||
        defaultsTo: true,
 | 
			
		||||
        help: 'Log to console')
 | 
			
		||||
    ..addOption('log-file',
 | 
			
		||||
        abbr: 'f',
 | 
			
		||||
        help: 'Path to log file')
 | 
			
		||||
    ..addFlag('help',
 | 
			
		||||
        abbr: 'h',
 | 
			
		||||
        negatable: false,
 | 
			
		||||
        help: 'Display this help message');
 | 
			
		||||
          help: 'Log level (debug, info, warning, error)',
 | 
			
		||||
        )
 | 
			
		||||
        ..addFlag('console-log', abbr: 'o', defaultsTo: true, help: 'Log to console')
 | 
			
		||||
        ..addOption('log-file', abbr: 'f', help: 'Path to log file')
 | 
			
		||||
        ..addFlag('help', abbr: 'h', negatable: false, help: 'Display this help message');
 | 
			
		||||
 | 
			
		||||
  try {
 | 
			
		||||
    final results = parser.parse(arguments);
 | 
			
		||||
@ -101,14 +94,19 @@ Future<void> main(List<String> arguments) async {
 | 
			
		||||
      (user) => permissionManager.getPermissionLevel(user),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Create command handler
 | 
			
		||||
    // Create command handler with connection for private messaging
 | 
			
		||||
    final commandHandler = CommandHandler(
 | 
			
		||||
      commandParser,
 | 
			
		||||
      musicQueue,
 | 
			
		||||
      youtubeDownloader,
 | 
			
		||||
      config.bot,
 | 
			
		||||
      mumbleConnection,
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    final dashboardServer = DashboardServer(config.dashboard, musicQueue, youtubeDownloader, permissionManager);
 | 
			
		||||
 | 
			
		||||
    dashboardServer.start();
 | 
			
		||||
 | 
			
		||||
    // Set up event listeners
 | 
			
		||||
    mumbleConnection.connectionState.listen((isConnected) {
 | 
			
		||||
      if (isConnected) {
 | 
			
		||||
@ -155,7 +153,9 @@ Future<void> main(List<String> arguments) async {
 | 
			
		||||
 | 
			
		||||
      // Send a welcome message to the channel
 | 
			
		||||
      if (mumbleConnection.isConnected) {
 | 
			
		||||
        await mumbleConnection.sendChannelMessage('MumBullet Music Bot is online! Type ${config.bot.commandPrefix}help for a list of commands.');
 | 
			
		||||
        await mumbleConnection.sendChannelMessage(
 | 
			
		||||
          'MumBullet Music Bot is online! Type ${config.bot.commandPrefix}help for a list of commands.',
 | 
			
		||||
        );
 | 
			
		||||
        logger.info('Sent welcome message to channel');
 | 
			
		||||
      }
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
@ -201,6 +201,7 @@ Future<void> main(List<String> arguments) async {
 | 
			
		||||
      mumbleConnection.dispose();
 | 
			
		||||
      messageHandler.dispose();
 | 
			
		||||
      database.close();
 | 
			
		||||
      await dashboardServer.stop();
 | 
			
		||||
 | 
			
		||||
      completer.complete();
 | 
			
		||||
    });
 | 
			
		||||
@ -230,6 +231,7 @@ Future<void> main(List<String> arguments) async {
 | 
			
		||||
      mumbleConnection.dispose();
 | 
			
		||||
      messageHandler.dispose();
 | 
			
		||||
      database.close();
 | 
			
		||||
      await dashboardServer.stop();
 | 
			
		||||
 | 
			
		||||
      completer.complete();
 | 
			
		||||
    });
 | 
			
		||||
@ -239,7 +241,6 @@ Future<void> main(List<String> arguments) async {
 | 
			
		||||
    // Close logger
 | 
			
		||||
    logManager.close();
 | 
			
		||||
    exit(0);
 | 
			
		||||
    
 | 
			
		||||
  } catch (e, stackTrace) {
 | 
			
		||||
    print('Error: $e');
 | 
			
		||||
    print('Stack trace: $stackTrace');
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										29
									
								
								flake.nix
									
									
									
									
									
								
							
							
						
						
									
										29
									
								
								flake.nix
									
									
									
									
									
								
							@ -9,49 +9,20 @@
 | 
			
		||||
    ...
 | 
			
		||||
  }:
 | 
			
		||||
    flake-utils.lib.eachDefaultSystem (system: let
 | 
			
		||||
      lib = nixpkgs.lib;
 | 
			
		||||
      pkgs = import nixpkgs {
 | 
			
		||||
        inherit system;
 | 
			
		||||
        config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [
 | 
			
		||||
          "android-studio-stable"
 | 
			
		||||
        ];
 | 
			
		||||
      };
 | 
			
		||||
    in {
 | 
			
		||||
      devShell = pkgs.mkShell {
 | 
			
		||||
          buildInputs = with pkgs; [
 | 
			
		||||
            flutter
 | 
			
		||||
            dart
 | 
			
		||||
            yt-dlp
 | 
			
		||||
            ffmpeg
 | 
			
		||||
            libopus
 | 
			
		||||
            clang
 | 
			
		||||
            cmake
 | 
			
		||||
            ninja
 | 
			
		||||
            pkg-config
 | 
			
		||||
            gtk3
 | 
			
		||||
            pcre
 | 
			
		||||
            libepoxy
 | 
			
		||||
            glib.dev
 | 
			
		||||
            sysprof
 | 
			
		||||
            # For drift / sqlite3 dart
 | 
			
		||||
            sqlite
 | 
			
		||||
            # For xp overlay
 | 
			
		||||
            gtk-layer-shell
 | 
			
		||||
            # Dev deps
 | 
			
		||||
            libuuid  # for mount.pc
 | 
			
		||||
            xorg.libXdmcp.dev
 | 
			
		||||
            # python310Packages.libselinux.dev # for libselinux.pc
 | 
			
		||||
            libsepol.dev
 | 
			
		||||
            libthai.dev
 | 
			
		||||
            libdatrie.dev
 | 
			
		||||
            libxkbcommon.dev
 | 
			
		||||
            dbus.dev
 | 
			
		||||
            at-spi2-core.dev
 | 
			
		||||
            xorg.libXtst.out
 | 
			
		||||
            pcre2.dev
 | 
			
		||||
            jdk11
 | 
			
		||||
            android-studio
 | 
			
		||||
            android-tools
 | 
			
		||||
          ];
 | 
			
		||||
          LD_LIBRARY_PATH = "${pkgs.sqlite.out}/lib";
 | 
			
		||||
          shellHook = ''
 | 
			
		||||
 | 
			
		||||
@ -5,6 +5,7 @@ import 'package:mumbullet/src/audio/downloader.dart';
 | 
			
		||||
import 'package:mumbullet/src/command/command_parser.dart';
 | 
			
		||||
import 'package:mumbullet/src/config/config.dart';
 | 
			
		||||
import 'package:mumbullet/src/logging/logger.dart';
 | 
			
		||||
import 'package:mumbullet/src/mumble/connection.dart';
 | 
			
		||||
import 'package:mumbullet/src/mumble/message_handler.dart';
 | 
			
		||||
import 'package:mumbullet/src/queue/music_queue.dart';
 | 
			
		||||
 | 
			
		||||
@ -15,83 +16,94 @@ class CommandHandler {
 | 
			
		||||
  final YoutubeDownloader _downloader;
 | 
			
		||||
  final BotConfig _config;
 | 
			
		||||
  final LogManager _logManager;
 | 
			
		||||
  final MumbleConnection _connection;
 | 
			
		||||
 | 
			
		||||
  /// Create a new command handler
 | 
			
		||||
  CommandHandler(
 | 
			
		||||
    this._commandParser,
 | 
			
		||||
    this._musicQueue,
 | 
			
		||||
    this._downloader,
 | 
			
		||||
    this._config,
 | 
			
		||||
  ) : _logManager = LogManager.getInstance() {
 | 
			
		||||
  CommandHandler(this._commandParser, this._musicQueue, this._downloader, this._config, this._connection)
 | 
			
		||||
    : _logManager = LogManager.getInstance() {
 | 
			
		||||
    _registerCommands();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Register all available commands
 | 
			
		||||
  void _registerCommands() {
 | 
			
		||||
    // Help command
 | 
			
		||||
    _commandParser.registerCommand(Command(
 | 
			
		||||
    _commandParser.registerCommand(
 | 
			
		||||
      Command(
 | 
			
		||||
        name: 'help',
 | 
			
		||||
        description: 'Show available commands',
 | 
			
		||||
        usage: 'help',
 | 
			
		||||
        requiredPermissionLevel: 0,
 | 
			
		||||
        execute: _handleHelpCommand,
 | 
			
		||||
    ));
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Play command
 | 
			
		||||
    _commandParser.registerCommand(Command(
 | 
			
		||||
    _commandParser.registerCommand(
 | 
			
		||||
      Command(
 | 
			
		||||
        name: 'play',
 | 
			
		||||
        description: 'Clear queue and play a song immediately',
 | 
			
		||||
      usage: 'play <url>',
 | 
			
		||||
        usage: 'play <url>',
 | 
			
		||||
        requiredPermissionLevel: 2,
 | 
			
		||||
        requiresArgs: true,
 | 
			
		||||
        execute: _handlePlayCommand,
 | 
			
		||||
    ));
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Queue command
 | 
			
		||||
    _commandParser.registerCommand(Command(
 | 
			
		||||
    _commandParser.registerCommand(
 | 
			
		||||
      Command(
 | 
			
		||||
        name: 'queue',
 | 
			
		||||
        description: 'Add a song to the queue',
 | 
			
		||||
      usage: 'queue <url>',
 | 
			
		||||
        usage: 'queue <url>',
 | 
			
		||||
        requiredPermissionLevel: 1,
 | 
			
		||||
        requiresArgs: true,
 | 
			
		||||
        execute: _handleQueueCommand,
 | 
			
		||||
    ));
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Skip command
 | 
			
		||||
    _commandParser.registerCommand(Command(
 | 
			
		||||
    _commandParser.registerCommand(
 | 
			
		||||
      Command(
 | 
			
		||||
        name: 'skip',
 | 
			
		||||
        description: 'Skip the current song',
 | 
			
		||||
        usage: 'skip',
 | 
			
		||||
        requiredPermissionLevel: 2,
 | 
			
		||||
        execute: _handleSkipCommand,
 | 
			
		||||
    ));
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // List command
 | 
			
		||||
    _commandParser.registerCommand(Command(
 | 
			
		||||
    _commandParser.registerCommand(
 | 
			
		||||
      Command(
 | 
			
		||||
        name: 'list',
 | 
			
		||||
        description: 'Show the current queue',
 | 
			
		||||
        usage: 'list',
 | 
			
		||||
        requiredPermissionLevel: 0,
 | 
			
		||||
        execute: _handleListCommand,
 | 
			
		||||
    ));
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Clear command
 | 
			
		||||
    _commandParser.registerCommand(Command(
 | 
			
		||||
    _commandParser.registerCommand(
 | 
			
		||||
      Command(
 | 
			
		||||
        name: 'clear',
 | 
			
		||||
        description: 'Clear the queue',
 | 
			
		||||
        usage: 'clear',
 | 
			
		||||
        requiredPermissionLevel: 2,
 | 
			
		||||
        execute: _handleClearCommand,
 | 
			
		||||
    ));
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Shuffle command
 | 
			
		||||
    _commandParser.registerCommand(Command(
 | 
			
		||||
    _commandParser.registerCommand(
 | 
			
		||||
      Command(
 | 
			
		||||
        name: 'shuffle',
 | 
			
		||||
        description: 'Shuffle the downloaded songs and start playing',
 | 
			
		||||
        usage: 'shuffle',
 | 
			
		||||
        requiredPermissionLevel: 2,
 | 
			
		||||
        execute: _handleShuffleCommand,
 | 
			
		||||
    ));
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Handle the help command
 | 
			
		||||
@ -100,13 +112,24 @@ class CommandHandler {
 | 
			
		||||
    final commands = _commandParser.getCommandsForPermissionLevel(permissionLevel);
 | 
			
		||||
 | 
			
		||||
    final helpText = StringBuffer();
 | 
			
		||||
    helpText.writeln('Available commands:');
 | 
			
		||||
    helpText.writeln('Available commands:<br/>');
 | 
			
		||||
 | 
			
		||||
    for (final command in commands) {
 | 
			
		||||
      helpText.writeln('${_config.commandPrefix}${command.usage} - ${command.description}');
 | 
			
		||||
      helpText.writeln('${_config.commandPrefix}${command.usage} - ${command.description}<br/>');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Always send help command response as a private message
 | 
			
		||||
    // Retrieve the MumbleMessageHandler to send a private message
 | 
			
		||||
    final sender = context.sender;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      // Get message handler and send private message
 | 
			
		||||
      await context.reply(helpText.toString());
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      // If private message fails, fall back to the regular reply method
 | 
			
		||||
      _logManager.warning('Failed to send private help message, using replyAll', e);
 | 
			
		||||
      await context.replyAll(helpText.toString());
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Handle the play command
 | 
			
		||||
@ -126,7 +149,7 @@ class CommandHandler {
 | 
			
		||||
      // Add to queue and play immediately
 | 
			
		||||
      _musicQueue.enqueue(song, true);
 | 
			
		||||
 | 
			
		||||
      await context.reply('Now playing: ${song.title}');
 | 
			
		||||
      await context.replyAll('Now playing: ${song.title}');
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      _logManager.error('Failed to play URL: $url', e);
 | 
			
		||||
      await context.reply('Failed to download or play the audio: ${e.toString()}');
 | 
			
		||||
@ -148,9 +171,11 @@ class CommandHandler {
 | 
			
		||||
      final position = _musicQueue.enqueue(song);
 | 
			
		||||
 | 
			
		||||
      if (position == 0 && _musicQueue.currentSong == song) {
 | 
			
		||||
        await context.reply('Now playing: ${song.title}');
 | 
			
		||||
        await context.replyAll('Now playing: ${song.title}');
 | 
			
		||||
      } else if (position >= _config.maxQueueSize) {
 | 
			
		||||
        await context.reply('Queue is full, could not add: ${song.title}');
 | 
			
		||||
      } else {
 | 
			
		||||
        await context.reply('Added to queue at position $position: ${song.title}');
 | 
			
		||||
        await context.reply('Added to queue at position ${position + 1}: ${song.title}');
 | 
			
		||||
      }
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      _logManager.error('Failed to queue URL: $url', e);
 | 
			
		||||
@ -173,9 +198,9 @@ class CommandHandler {
 | 
			
		||||
      final nextSong = _musicQueue.currentSong;
 | 
			
		||||
 | 
			
		||||
      if (nextSong != null) {
 | 
			
		||||
        await context.reply('Skipped: $skippedTitle. Now playing: ${nextSong.title}');
 | 
			
		||||
        await context.replyAll('Skipped: $skippedTitle. Now playing: ${nextSong.title}');
 | 
			
		||||
      } else {
 | 
			
		||||
        await context.reply('Skipped: $skippedTitle. Queue is now empty.');
 | 
			
		||||
        await context.replyAll('Skipped: $skippedTitle. Queue is now empty.');
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      await context.reply('Failed to skip the current song.');
 | 
			
		||||
@ -207,7 +232,7 @@ class CommandHandler {
 | 
			
		||||
  /// Handle the clear command
 | 
			
		||||
  Future<void> _handleClearCommand(CommandContext context) async {
 | 
			
		||||
    _musicQueue.clear();
 | 
			
		||||
    await context.reply('Queue cleared.');
 | 
			
		||||
    await context.replyAll('Queue cleared.');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Handle the shuffle command
 | 
			
		||||
@ -234,9 +259,8 @@ class CommandHandler {
 | 
			
		||||
        _musicQueue.enqueue(song, song == songsToAdd.first);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      await context.reply(
 | 
			
		||||
        'Added ${songsToAdd.length} shuffled songs to the queue. '
 | 
			
		||||
        'Now playing: ${songsToAdd.first.title}'
 | 
			
		||||
      await context.replyAll(
 | 
			
		||||
        'Added ${songsToAdd.length} shuffled songs to the queue.\nNow playing: ${songsToAdd.first.title}',
 | 
			
		||||
      );
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      _logManager.error('Failed to shuffle songs', e);
 | 
			
		||||
 | 
			
		||||
@ -32,6 +32,9 @@ class CommandContext {
 | 
			
		||||
  final bool isPrivate;
 | 
			
		||||
  final int permissionLevel;
 | 
			
		||||
 | 
			
		||||
  /// Function to reply to the channel
 | 
			
		||||
  final Future<void> Function(String) replyAll;
 | 
			
		||||
 | 
			
		||||
  /// Function to reply to the sender
 | 
			
		||||
  final Future<void> Function(String) reply;
 | 
			
		||||
 | 
			
		||||
@ -42,17 +45,12 @@ class CommandContext {
 | 
			
		||||
    required this.isPrivate,
 | 
			
		||||
    required this.permissionLevel,
 | 
			
		||||
    required this.reply,
 | 
			
		||||
    required this.replyAll,
 | 
			
		||||
  });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// Result of a command execution
 | 
			
		||||
enum CommandResult {
 | 
			
		||||
  success,
 | 
			
		||||
  notFound,
 | 
			
		||||
  noPermission,
 | 
			
		||||
  invalidArguments,
 | 
			
		||||
  error,
 | 
			
		||||
}
 | 
			
		||||
enum CommandResult { success, notFound, noPermission, invalidArguments, error }
 | 
			
		||||
 | 
			
		||||
/// Class for parsing and executing commands
 | 
			
		||||
class CommandParser {
 | 
			
		||||
@ -67,11 +65,7 @@ class CommandParser {
 | 
			
		||||
  final Future<int> Function(MumbleUser) _getPermissionLevel;
 | 
			
		||||
 | 
			
		||||
  /// Create a new command parser
 | 
			
		||||
  CommandParser(
 | 
			
		||||
    this._messageHandler,
 | 
			
		||||
    this._config,
 | 
			
		||||
    this._getPermissionLevel,
 | 
			
		||||
  ) : _logManager = LogManager.getInstance() {
 | 
			
		||||
  CommandParser(this._messageHandler, this._config, this._getPermissionLevel) : _logManager = LogManager.getInstance() {
 | 
			
		||||
    _setupCommandListener();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -94,12 +88,7 @@ class CommandParser {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Handle a command
 | 
			
		||||
  Future<CommandResult> _handleCommand(
 | 
			
		||||
    String command,
 | 
			
		||||
    String args,
 | 
			
		||||
    MumbleUser sender,
 | 
			
		||||
    bool isPrivate,
 | 
			
		||||
  ) async {
 | 
			
		||||
  Future<CommandResult> _handleCommand(String command, String args, MumbleUser sender, bool isPrivate) async {
 | 
			
		||||
    _logManager.info('Handling command: $command, args: $args, from: ${sender.name}');
 | 
			
		||||
 | 
			
		||||
    // Check if command exists
 | 
			
		||||
@ -120,32 +109,19 @@ class CommandParser {
 | 
			
		||||
    if (permissionLevel < cmd.requiredPermissionLevel) {
 | 
			
		||||
      _logManager.warning(
 | 
			
		||||
        'User ${sender.name} does not have permission to use command: $command '
 | 
			
		||||
        '(has: $permissionLevel, required: ${cmd.requiredPermissionLevel})'
 | 
			
		||||
      );
 | 
			
		||||
      await _messageHandler.replyToMessage(
 | 
			
		||||
        'You do not have permission to use this command.',
 | 
			
		||||
        sender,
 | 
			
		||||
        isPrivate,
 | 
			
		||||
        '(has: $permissionLevel, required: ${cmd.requiredPermissionLevel})',
 | 
			
		||||
      );
 | 
			
		||||
      await _messageHandler.replyToMessage('You do not have permission to use this command.', sender, true);
 | 
			
		||||
      return CommandResult.noPermission;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Check if command requires arguments
 | 
			
		||||
    if (cmd.requiresArgs && args.trim().isEmpty) {
 | 
			
		||||
      _logManager.warning('Command $command requires arguments, but none provided');
 | 
			
		||||
      await _messageHandler.replyToMessage(
 | 
			
		||||
        'Usage: ${_config.commandPrefix}${cmd.usage}',
 | 
			
		||||
        sender,
 | 
			
		||||
        isPrivate,
 | 
			
		||||
      );
 | 
			
		||||
      await _messageHandler.replyToMessage('Usage: ${_config.commandPrefix}${cmd.usage}', sender, true);
 | 
			
		||||
      return CommandResult.invalidArguments;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Create a reply function
 | 
			
		||||
    final reply = (String message) {
 | 
			
		||||
      return _messageHandler.replyToMessage(message, sender, isPrivate);
 | 
			
		||||
    };
 | 
			
		||||
    
 | 
			
		||||
    // Create command context
 | 
			
		||||
    final context = CommandContext(
 | 
			
		||||
      commandName: command,
 | 
			
		||||
@ -153,7 +129,8 @@ class CommandParser {
 | 
			
		||||
      sender: sender,
 | 
			
		||||
      isPrivate: isPrivate,
 | 
			
		||||
      permissionLevel: permissionLevel,
 | 
			
		||||
      reply: reply,
 | 
			
		||||
      reply: (String message) => _messageHandler.replyToMessage(message, sender, true),
 | 
			
		||||
      replyAll: (String message) => _messageHandler.replyToMessage(message, sender, false),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    // Execute the command
 | 
			
		||||
@ -162,11 +139,7 @@ class CommandParser {
 | 
			
		||||
      return CommandResult.success;
 | 
			
		||||
    } catch (e, stackTrace) {
 | 
			
		||||
      _logManager.error('Error executing command: $command', e, stackTrace);
 | 
			
		||||
      await _messageHandler.replyToMessage(
 | 
			
		||||
        'An error occurred while executing the command.',
 | 
			
		||||
        sender,
 | 
			
		||||
        isPrivate,
 | 
			
		||||
      );
 | 
			
		||||
      await _messageHandler.replyToMessage('An error occurred while executing the command.', sender, isPrivate);
 | 
			
		||||
      return CommandResult.error;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@ -178,8 +151,6 @@ class CommandParser {
 | 
			
		||||
 | 
			
		||||
  /// Get commands filtered by permission level
 | 
			
		||||
  List<Command> getCommandsForPermissionLevel(int permissionLevel) {
 | 
			
		||||
    return _commands.values
 | 
			
		||||
        .where((cmd) => cmd.requiredPermissionLevel <= permissionLevel)
 | 
			
		||||
        .toList();
 | 
			
		||||
    return _commands.values.where((cmd) => cmd.requiredPermissionLevel <= permissionLevel).toList();
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -59,7 +59,7 @@ class DashboardApi {
 | 
			
		||||
        'id': song.id,
 | 
			
		||||
        'title': song.title,
 | 
			
		||||
        'duration': song.duration,
 | 
			
		||||
        'url': song.url,
 | 
			
		||||
        'url': song.url, // Will handle null gracefully in JSON serialization
 | 
			
		||||
        'is_current': song == currentSong,
 | 
			
		||||
      }).toList(),
 | 
			
		||||
      'state': state.name,
 | 
			
		||||
@ -67,7 +67,7 @@ class DashboardApi {
 | 
			
		||||
        'id': currentSong.id,
 | 
			
		||||
        'title': currentSong.title,
 | 
			
		||||
        'duration': currentSong.duration,
 | 
			
		||||
        'url': currentSong.url,
 | 
			
		||||
        'url': currentSong.url, // Will handle null gracefully in JSON serialization
 | 
			
		||||
      },
 | 
			
		||||
    };
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
@ -24,27 +24,77 @@ class DashboardAuth {
 | 
			
		||||
  Middleware get middleware {
 | 
			
		||||
    return (Handler innerHandler) {
 | 
			
		||||
      return (Request request) async {
 | 
			
		||||
        // Skip auth for login page and API
 | 
			
		||||
        // Get the cleaned path (without query parameters)
 | 
			
		||||
        final path = request.url.path;
 | 
			
		||||
        if (path == 'login' || path == 'api/login') {
 | 
			
		||||
        final fullUrl = request.url.toString();
 | 
			
		||||
        
 | 
			
		||||
        _logManager.info('Auth middleware checking path: $path (full URL: $fullUrl)');
 | 
			
		||||
        _logManager.debug('Request method: ${request.method}');
 | 
			
		||||
        
 | 
			
		||||
        // Paths that don't require authentication
 | 
			
		||||
        final publicPaths = ['login', 'api/login', 'style.css', 'script.js', 'favicon.ico'];
 | 
			
		||||
        final publicExtensions = ['.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.woff', '.woff2', '.ttf', '.eot'];
 | 
			
		||||
        
 | 
			
		||||
        // Check if path is explicitly public or has a public extension
 | 
			
		||||
        final isPublicPath = publicPaths.contains(path) || publicPaths.any((p) => path.startsWith(p));
 | 
			
		||||
        final hasPublicExtension = publicExtensions.any((ext) => path.endsWith(ext));
 | 
			
		||||
        
 | 
			
		||||
        if (isPublicPath || hasPublicExtension) {
 | 
			
		||||
          _logManager.info('Skipping auth for public path: $path (isPublicPath: $isPublicPath, hasPublicExtension: $hasPublicExtension)');
 | 
			
		||||
          return innerHandler(request);
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // Dump all request headers for debugging
 | 
			
		||||
        _logManager.debug('Request headers for path $path:');
 | 
			
		||||
        request.headers.forEach((name, value) {
 | 
			
		||||
          _logManager.debug('  $name: $value');
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
        // Check for session token
 | 
			
		||||
        final token = _getSessionToken(request);
 | 
			
		||||
        if (token != null && _validateSession(token)) {
 | 
			
		||||
        if (token != null) {
 | 
			
		||||
          _logManager.info('Found session token: $token');
 | 
			
		||||
          
 | 
			
		||||
          if (_validateSession(token)) {
 | 
			
		||||
            _logManager.info('Session token is valid, renewing session');
 | 
			
		||||
            // Renew session
 | 
			
		||||
            _renewSession(token);
 | 
			
		||||
          return innerHandler(request);
 | 
			
		||||
            
 | 
			
		||||
            // Process the request with the inner handler
 | 
			
		||||
            final response = await innerHandler(request);
 | 
			
		||||
            
 | 
			
		||||
            // Return the response with the cookie renewed
 | 
			
		||||
            // This ensures the cookie is kept fresh on each authenticated request
 | 
			
		||||
            return response.change(headers: {
 | 
			
		||||
              ...response.headers,
 | 
			
		||||
              'set-cookie': 'session=$token; Path=/; HttpOnly; SameSite=Lax; Max-Age=${_sessionDurationMinutes * 60}',
 | 
			
		||||
            });
 | 
			
		||||
          } else {
 | 
			
		||||
            _logManager.warning('Session token is invalid or expired');
 | 
			
		||||
          }
 | 
			
		||||
        } else {
 | 
			
		||||
          _logManager.info('No session token found for path: $path');
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // Not authenticated
 | 
			
		||||
        if (path.startsWith('api/')) {
 | 
			
		||||
          _logManager.warning('Unauthenticated API request: $path');
 | 
			
		||||
          // API request, return 401
 | 
			
		||||
          return Response.unauthorized('Unauthorized');
 | 
			
		||||
          return Response(401, 
 | 
			
		||||
            body: json.encode({'error': 'Unauthorized'}),
 | 
			
		||||
            headers: {
 | 
			
		||||
              'content-type': 'application/json',
 | 
			
		||||
              'cache-control': 'no-store, no-cache, must-revalidate, max-age=0',
 | 
			
		||||
              'pragma': 'no-cache',
 | 
			
		||||
            },
 | 
			
		||||
          );
 | 
			
		||||
        } else {
 | 
			
		||||
          _logManager.info('Redirecting to login page from: $path');
 | 
			
		||||
          // Web request, redirect to login page
 | 
			
		||||
          return Response.found('/login');
 | 
			
		||||
          return Response.found('/login', headers: {
 | 
			
		||||
            'cache-control': 'no-store, no-cache, must-revalidate, max-age=0',
 | 
			
		||||
            'pragma': 'no-cache',
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
    };
 | 
			
		||||
@ -57,35 +107,93 @@ class DashboardAuth {
 | 
			
		||||
    if (contentType == 'application/json') {
 | 
			
		||||
      // API login
 | 
			
		||||
      final jsonString = await request.readAsString();
 | 
			
		||||
      _logManager.info('API login request received: $jsonString');
 | 
			
		||||
      
 | 
			
		||||
      try {
 | 
			
		||||
        final Map<String, dynamic> body = json.decode(jsonString);
 | 
			
		||||
      
 | 
			
		||||
        final username = body['username'] as String?;
 | 
			
		||||
        final password = body['password'] as String?;
 | 
			
		||||
        
 | 
			
		||||
        _logManager.info('API login attempt - Username: $username');
 | 
			
		||||
        
 | 
			
		||||
        if (username == _config.adminUsername && password == _config.adminPassword) {
 | 
			
		||||
          _logManager.info('API login successful');
 | 
			
		||||
          final token = _createSession();
 | 
			
		||||
          
 | 
			
		||||
          // Return with proper JSON response and set cookie header
 | 
			
		||||
          final cookie = 'session=$token; Path=/; HttpOnly; SameSite=Lax; Max-Age=${_sessionDurationMinutes * 60}';
 | 
			
		||||
          _logManager.info('API login: Setting cookie: $cookie');
 | 
			
		||||
          
 | 
			
		||||
          return Response.ok(
 | 
			
		||||
          json.encode({'token': token}),
 | 
			
		||||
          headers: {'content-type': 'application/json'},
 | 
			
		||||
            json.encode({'success': true, 'message': 'Login successful'}),
 | 
			
		||||
            headers: {
 | 
			
		||||
              'content-type': 'application/json',
 | 
			
		||||
              'set-cookie': cookie,
 | 
			
		||||
              'cache-control': 'no-store, no-cache, must-revalidate, max-age=0',
 | 
			
		||||
              'pragma': 'no-cache',
 | 
			
		||||
            },
 | 
			
		||||
          );
 | 
			
		||||
        } else {
 | 
			
		||||
        return Response(401, body: json.encode({'error': 'Invalid credentials'}));
 | 
			
		||||
          _logManager.warning('API login failed - Invalid credentials');
 | 
			
		||||
          return Response(401, body: json.encode({'error': 'Invalid credentials'}), headers: {'content-type': 'application/json'});
 | 
			
		||||
        }
 | 
			
		||||
      } catch (e) {
 | 
			
		||||
        _logManager.error('Failed to parse JSON login data', e);
 | 
			
		||||
        return Response.badRequest(body: json.encode({'error': 'Invalid request format'}), headers: {'content-type': 'application/json'});
 | 
			
		||||
      }
 | 
			
		||||
    } else {
 | 
			
		||||
      // Form login
 | 
			
		||||
      final formData = await request.readAsString();
 | 
			
		||||
      final params = Uri.splitQueryString(formData);
 | 
			
		||||
      Map<String, String> params = {};
 | 
			
		||||
      
 | 
			
		||||
      // Handle form-urlencoded data
 | 
			
		||||
      if (request.headers['content-type']?.contains('application/x-www-form-urlencoded') ?? false) {
 | 
			
		||||
        params = Uri.splitQueryString(formData);
 | 
			
		||||
        _logManager.info('Form data: $params');
 | 
			
		||||
      } else {
 | 
			
		||||
        // Try to parse as JSON if not form-urlencoded
 | 
			
		||||
        try {
 | 
			
		||||
          final jsonData = json.decode(formData) as Map<String, dynamic>;
 | 
			
		||||
          params = jsonData.map((key, value) => MapEntry(key, value.toString()));
 | 
			
		||||
          _logManager.info('JSON data: $params');
 | 
			
		||||
        } catch (e) {
 | 
			
		||||
          _logManager.warning('Failed to parse form data: $e');
 | 
			
		||||
          // Print out the raw data for debugging
 | 
			
		||||
          _logManager.info('Raw data: $formData');
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      final username = params['username'];
 | 
			
		||||
      final password = params['password'];
 | 
			
		||||
      
 | 
			
		||||
      _logManager.info('Login attempt - Username: $username, Expected: ${_config.adminUsername}');
 | 
			
		||||
      _logManager.info('Config password: ${_config.adminPassword}, Received password length: ${password?.length ?? 0}');
 | 
			
		||||
      
 | 
			
		||||
      if (username == _config.adminUsername && password == _config.adminPassword) {
 | 
			
		||||
        _logManager.info('Login successful');
 | 
			
		||||
        final token = _createSession();
 | 
			
		||||
        
 | 
			
		||||
        // Add debug logs for the token
 | 
			
		||||
        _logManager.info('Generated session token: $token');
 | 
			
		||||
        
 | 
			
		||||
        // Return with proper cookie format and set max-age
 | 
			
		||||
        // Ensure compatibility with modern browsers by using appropriate cookie attributes
 | 
			
		||||
        // Secure flag omitted for local development but should be added in production
 | 
			
		||||
        final cookie = 'session=$token; Path=/; HttpOnly; SameSite=Lax; Max-Age=${_sessionDurationMinutes * 60}';
 | 
			
		||||
        _logManager.info('Setting cookie: $cookie');
 | 
			
		||||
        
 | 
			
		||||
        return Response.found(
 | 
			
		||||
          '/',
 | 
			
		||||
          headers: {'set-cookie': 'session=$token; Path=/; HttpOnly'},
 | 
			
		||||
          headers: {
 | 
			
		||||
            'set-cookie': cookie,
 | 
			
		||||
            // Add cache control headers to prevent caching issues with redirects
 | 
			
		||||
            'cache-control': 'no-store, no-cache, must-revalidate, max-age=0',
 | 
			
		||||
            'pragma': 'no-cache',
 | 
			
		||||
          },
 | 
			
		||||
        );
 | 
			
		||||
      } else {
 | 
			
		||||
        _logManager.warning('Login failed - Invalid credentials');
 | 
			
		||||
        return Response.found('/login?error=invalid');
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
@ -110,30 +218,61 @@ class DashboardAuth {
 | 
			
		||||
    final bytes = List<int>.generate(32, (_) => random.nextInt(256));
 | 
			
		||||
    final token = base64Url.encode(bytes);
 | 
			
		||||
    
 | 
			
		||||
    _sessions[token] = DateTime.now().add(Duration(minutes: _sessionDurationMinutes));
 | 
			
		||||
    final expiration = DateTime.now().add(Duration(minutes: _sessionDurationMinutes));
 | 
			
		||||
    _sessions[token] = expiration;
 | 
			
		||||
    
 | 
			
		||||
    _logManager.info('Created new session: ${token.substring(0, 8)}...');
 | 
			
		||||
    _logManager.debug('  Token length: ${token.length} characters');
 | 
			
		||||
    _logManager.debug('  Token created: ${DateTime.now().toIso8601String()}');
 | 
			
		||||
    _logManager.debug('  Token expires: ${expiration.toIso8601String()}');
 | 
			
		||||
    _logManager.debug('  Session will expire in ${_sessionDurationMinutes} minutes');
 | 
			
		||||
    _logManager.debug('  Total active sessions: ${_sessions.length}');
 | 
			
		||||
    
 | 
			
		||||
    _logManager.info('Created new session');
 | 
			
		||||
    return token;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  /// Validate a session token
 | 
			
		||||
  bool _validateSession(String token) {
 | 
			
		||||
    _logManager.info('Validating session token: $token');
 | 
			
		||||
    
 | 
			
		||||
    // Log the current state of all sessions for debugging
 | 
			
		||||
    _logManager.debug('Current sessions:');
 | 
			
		||||
    _sessions.forEach((sessionToken, expiry) {
 | 
			
		||||
      _logManager.debug('  Session: ${sessionToken.substring(0, 8)}... expires at: ${expiry.toIso8601String()}');
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    final expiration = _sessions[token];
 | 
			
		||||
    if (expiration == null) {
 | 
			
		||||
      _logManager.warning('No session found for token: $token');
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    if (expiration.isBefore(DateTime.now())) {
 | 
			
		||||
    final now = DateTime.now();
 | 
			
		||||
    if (expiration.isBefore(now)) {
 | 
			
		||||
      _logManager.warning('Session expired at ${expiration.toIso8601String()}, current time: ${now.toIso8601String()}');
 | 
			
		||||
      _sessions.remove(token);
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    final timeRemaining = expiration.difference(now).inMinutes;
 | 
			
		||||
    _logManager.info('Session valid until ${expiration.toIso8601String()} (${timeRemaining} minutes remaining)');
 | 
			
		||||
    return true;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  /// Renew a session
 | 
			
		||||
  void _renewSession(String token) {
 | 
			
		||||
    _sessions[token] = DateTime.now().add(Duration(minutes: _sessionDurationMinutes));
 | 
			
		||||
    final oldExpiration = _sessions[token];
 | 
			
		||||
    final newExpiration = DateTime.now().add(Duration(minutes: _sessionDurationMinutes));
 | 
			
		||||
    
 | 
			
		||||
    _sessions[token] = newExpiration;
 | 
			
		||||
    
 | 
			
		||||
    _logManager.info('Renewed session: $token');
 | 
			
		||||
    
 | 
			
		||||
    if (oldExpiration != null) {
 | 
			
		||||
      _logManager.debug('  Old expiration: ${oldExpiration.toIso8601String()}');
 | 
			
		||||
    }
 | 
			
		||||
    _logManager.debug('  New expiration: ${newExpiration.toIso8601String()}');
 | 
			
		||||
    _logManager.debug('  Session will expire in ${_sessionDurationMinutes} minutes');
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  /// Get the session token from a request
 | 
			
		||||
@ -141,20 +280,45 @@ class DashboardAuth {
 | 
			
		||||
    // Check authorization header
 | 
			
		||||
    final authHeader = request.headers['authorization'];
 | 
			
		||||
    if (authHeader != null && authHeader.startsWith('Bearer ')) {
 | 
			
		||||
      return authHeader.substring(7);
 | 
			
		||||
      final token = authHeader.substring(7);
 | 
			
		||||
      _logManager.debug('Found token in Authorization header: $token');
 | 
			
		||||
      return token;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Check cookie
 | 
			
		||||
    final cookies = request.headers['cookie'];
 | 
			
		||||
    if (cookies != null) {
 | 
			
		||||
      _logManager.info('Found cookies: $cookies');
 | 
			
		||||
      
 | 
			
		||||
      // Enhanced cookie parsing
 | 
			
		||||
      final cookieParts = cookies.split(';');
 | 
			
		||||
      for (final part in cookieParts) {
 | 
			
		||||
        final cookie = part.trim().split('=');
 | 
			
		||||
        if (cookie.length == 2 && cookie[0] == 'session') {
 | 
			
		||||
          return cookie[1];
 | 
			
		||||
        final trimmedPart = part.trim();
 | 
			
		||||
        _logManager.debug('Processing cookie part: $trimmedPart');
 | 
			
		||||
        
 | 
			
		||||
        // Handle both 'session=value' and ' session=value' formats
 | 
			
		||||
        if (trimmedPart.startsWith('session=')) {
 | 
			
		||||
          final token = trimmedPart.substring('session='.length);
 | 
			
		||||
          _logManager.info('Found session token in cookie: $token');
 | 
			
		||||
          return token;
 | 
			
		||||
        } else {
 | 
			
		||||
          // More flexible split approach for unusual formatting
 | 
			
		||||
          final cookie = trimmedPart.split('=');
 | 
			
		||||
          if (cookie.length >= 2 && cookie[0].trim() == 'session') {
 | 
			
		||||
            final token = cookie.sublist(1).join('='); // Handle values that might contain '=' characters
 | 
			
		||||
            _logManager.info('Found session token in cookie using alternative parsing: $token');
 | 
			
		||||
            return token;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    _logManager.info('No session token found in request');
 | 
			
		||||
    // Dump all request headers for debugging
 | 
			
		||||
    _logManager.debug('All request headers:');
 | 
			
		||||
    request.headers.forEach((name, value) {
 | 
			
		||||
      _logManager.debug('  $name: $value');
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
@ -162,6 +326,26 @@ class DashboardAuth {
 | 
			
		||||
  /// Clean up expired sessions
 | 
			
		||||
  void cleanupSessions() {
 | 
			
		||||
    final now = DateTime.now();
 | 
			
		||||
    final initialCount = _sessions.length;
 | 
			
		||||
    
 | 
			
		||||
    // Create a list of expired sessions for logging
 | 
			
		||||
    final expiredSessions = <String>[];
 | 
			
		||||
    _sessions.forEach((token, expiration) {
 | 
			
		||||
      if (expiration.isBefore(now)) {
 | 
			
		||||
        expiredSessions.add('${token.substring(0, 8)}...');
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    // Remove expired sessions
 | 
			
		||||
    _sessions.removeWhere((_, expiration) => expiration.isBefore(now));
 | 
			
		||||
    
 | 
			
		||||
    final removedCount = initialCount - _sessions.length;
 | 
			
		||||
    
 | 
			
		||||
    if (removedCount > 0) {
 | 
			
		||||
      _logManager.info('Cleaned up $removedCount expired sessions');
 | 
			
		||||
      _logManager.debug('  Expired sessions: ${expiredSessions.join(', ')}');
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    _logManager.debug('Session cleanup completed. Active sessions: ${_sessions.length}');
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -25,12 +25,8 @@ class DashboardServer {
 | 
			
		||||
  final DashboardAuth _auth;
 | 
			
		||||
 | 
			
		||||
  /// Create a new dashboard server
 | 
			
		||||
  DashboardServer(
 | 
			
		||||
    this._config,
 | 
			
		||||
    this._musicQueue,
 | 
			
		||||
    this._downloader,
 | 
			
		||||
    this._permissionManager,
 | 
			
		||||
  ) : _logManager = LogManager.getInstance(),
 | 
			
		||||
  DashboardServer(this._config, this._musicQueue, this._downloader, this._permissionManager)
 | 
			
		||||
    : _logManager = LogManager.getInstance(),
 | 
			
		||||
      _auth = DashboardAuth(_config);
 | 
			
		||||
 | 
			
		||||
  /// Start the dashboard server
 | 
			
		||||
@ -39,18 +35,10 @@ class DashboardServer {
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      // Create static file handler
 | 
			
		||||
      final staticHandler = createStaticHandler(
 | 
			
		||||
        _getWebRoot(),
 | 
			
		||||
        defaultDocument: 'index.html',
 | 
			
		||||
      );
 | 
			
		||||
      final staticHandler = createStaticHandler(_getWebRoot(), defaultDocument: 'index.html');
 | 
			
		||||
 | 
			
		||||
      // Create API router
 | 
			
		||||
      final api = DashboardApi(
 | 
			
		||||
        _musicQueue,
 | 
			
		||||
        _downloader,
 | 
			
		||||
        _permissionManager,
 | 
			
		||||
        _auth,
 | 
			
		||||
      );
 | 
			
		||||
      final api = DashboardApi(_musicQueue, _downloader, _permissionManager, _auth);
 | 
			
		||||
 | 
			
		||||
      // Create main router
 | 
			
		||||
      final router = Router();
 | 
			
		||||
@ -66,8 +54,9 @@ class DashboardServer {
 | 
			
		||||
        return staticHandler(request);
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      // Create pipeline with auth middleware
 | 
			
		||||
      // Create pipeline with middleware for CORS, auth, and logging
 | 
			
		||||
      final handler = Pipeline()
 | 
			
		||||
        .addMiddleware(_corsHeaders())
 | 
			
		||||
        .addMiddleware(_auth.middleware)
 | 
			
		||||
        .addMiddleware(_logRequests())
 | 
			
		||||
        .addHandler(router);
 | 
			
		||||
@ -153,16 +142,11 @@ class DashboardServer {
 | 
			
		||||
    final queryParams = request.url.queryParameters;
 | 
			
		||||
    final error = queryParams['error'];
 | 
			
		||||
 | 
			
		||||
    final errorHtml = error == 'invalid'
 | 
			
		||||
        ? '<div class="error">Invalid username or password</div>'
 | 
			
		||||
        : '';
 | 
			
		||||
    final errorHtml = error == 'invalid' ? '<div class="error">Invalid username or password</div>' : '';
 | 
			
		||||
 | 
			
		||||
    final html = _getDefaultLoginHtml().replaceAll('<!--ERROR-->', errorHtml);
 | 
			
		||||
 | 
			
		||||
    return Response.ok(
 | 
			
		||||
      html,
 | 
			
		||||
      headers: {'content-type': 'text/html'},
 | 
			
		||||
    );
 | 
			
		||||
    return Response.ok(html, headers: {'content-type': 'text/html'});
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Middleware for logging requests
 | 
			
		||||
@ -176,7 +160,7 @@ class DashboardServer {
 | 
			
		||||
 | 
			
		||||
        _logManager.debug(
 | 
			
		||||
          '${request.method} ${request.url.path} - '
 | 
			
		||||
          '${response.statusCode} (${duration}ms)'
 | 
			
		||||
          '${response.statusCode} (${duration}ms)',
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
        return response;
 | 
			
		||||
@ -184,6 +168,31 @@ class DashboardServer {
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  /// Middleware for adding CORS headers
 | 
			
		||||
  Middleware _corsHeaders() {
 | 
			
		||||
    return (Handler innerHandler) {
 | 
			
		||||
      return (Request request) async {
 | 
			
		||||
        // Get the response from the inner handler
 | 
			
		||||
        final response = await innerHandler(request);
 | 
			
		||||
        
 | 
			
		||||
        // Add CORS headers to the response
 | 
			
		||||
        return response.change(
 | 
			
		||||
          headers: {
 | 
			
		||||
            ...response.headers,
 | 
			
		||||
            // Allow requests from the same origin (no cross-origin requests needed for now)
 | 
			
		||||
            'Access-Control-Allow-Origin': request.headers['origin'] ?? '*',
 | 
			
		||||
            // Allow cookies and auth headers
 | 
			
		||||
            'Access-Control-Allow-Credentials': 'true',
 | 
			
		||||
            // Allow common headers
 | 
			
		||||
            'Access-Control-Allow-Headers': 'Origin, Content-Type, Accept, Authorization, Cookie, X-Requested-With',
 | 
			
		||||
            // Allow common methods
 | 
			
		||||
            'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
 | 
			
		||||
          },
 | 
			
		||||
        );
 | 
			
		||||
      };
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Get the default index.html content
 | 
			
		||||
  String _getDefaultIndexHtml() {
 | 
			
		||||
    return '''<!DOCTYPE html>
 | 
			
		||||
@ -683,280 +692,20 @@ main {
 | 
			
		||||
 | 
			
		||||
  /// Get the default JavaScript content
 | 
			
		||||
  String _getDefaultJs() {
 | 
			
		||||
    // Create and return JavaScript content for the dashboard
 | 
			
		||||
    // This is returned as a string, and won't be analyzed by Dart
 | 
			
		||||
    var js = '''
 | 
			
		||||
// DOM Elements
 | 
			
		||||
const queueTab = document.getElementById('queueTab');
 | 
			
		||||
const usersTab = document.getElementById('usersTab');
 | 
			
		||||
const cacheTab = document.getElementById('cacheTab');
 | 
			
		||||
const logoutButton = document.getElementById('logoutButton');
 | 
			
		||||
    // Instead of defining JavaScript inline with lots of errors in Dart analysis,
 | 
			
		||||
    // we'll just return the contents of the script.js file we created in the web folder.
 | 
			
		||||
    // This simplifies maintenance and avoids Dart analysis issues.
 | 
			
		||||
    final scriptFile = File(path.join(_getWebRoot(), 'script.js'));
 | 
			
		||||
    if (scriptFile.existsSync()) {
 | 
			
		||||
      return scriptFile.readAsStringSync();
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
const queueSection = document.getElementById('queueSection');
 | 
			
		||||
const usersSection = document.getElementById('usersSection');
 | 
			
		||||
const cacheSection = document.getElementById('cacheSection');
 | 
			
		||||
 | 
			
		||||
const nowPlaying = document.getElementById('nowPlaying');
 | 
			
		||||
const queueList = document.getElementById('queueList');
 | 
			
		||||
const clearQueueButton = document.getElementById('clearQueueButton');
 | 
			
		||||
 | 
			
		||||
const usersTable = document.getElementById('usersTable');
 | 
			
		||||
const addUserButton = document.getElementById('addUserButton');
 | 
			
		||||
const addUserModal = document.getElementById('addUserModal');
 | 
			
		||||
const addUserForm = document.getElementById('addUserForm');
 | 
			
		||||
const cancelAddUser = document.getElementById('cancelAddUser');
 | 
			
		||||
 | 
			
		||||
const cacheSongCount = document.getElementById('cacheSongCount');
 | 
			
		||||
const cacheSize = document.getElementById('cacheSize');
 | 
			
		||||
const cacheMaxSize = document.getElementById('cacheMaxSize');
 | 
			
		||||
const cacheUsage = document.getElementById('cacheUsage');
 | 
			
		||||
const clearCacheButton = document.getElementById('clearCacheButton');
 | 
			
		||||
 | 
			
		||||
// Tab switching
 | 
			
		||||
function switchTab(tab, section) {
 | 
			
		||||
  // Remove active class from all tabs and sections
 | 
			
		||||
  [queueTab, usersTab, cacheTab].forEach(t => t.classList.remove('active'));
 | 
			
		||||
  [queueSection, usersSection, cacheSection].forEach(s => s.classList.remove('active'));
 | 
			
		||||
  
 | 
			
		||||
  // Add active class to selected tab and section
 | 
			
		||||
  tab.classList.add('active');
 | 
			
		||||
  section.classList.add('active');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
queueTab.addEventListener('click', () => switchTab(queueTab, queueSection));
 | 
			
		||||
usersTab.addEventListener('click', () => switchTab(usersTab, usersSection));
 | 
			
		||||
cacheTab.addEventListener('click', () => switchTab(cacheTab, cacheSection));
 | 
			
		||||
 | 
			
		||||
// Logout
 | 
			
		||||
logoutButton.addEventListener('click', async () => {
 | 
			
		||||
  try {
 | 
			
		||||
    await fetch('/api/logout', {
 | 
			
		||||
      method: 'POST',
 | 
			
		||||
    // If the file doesn't exist for some reason, return a minimal script
 | 
			
		||||
    return '''
 | 
			
		||||
    // Basic script for dashboard functionality
 | 
			
		||||
    document.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
      console.log('Dashboard loaded');
 | 
			
		||||
    });
 | 
			
		||||
    window.location.href = '/login';
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Logout failed:', error);
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Queue management
 | 
			
		||||
function formatDuration(seconds) {
 | 
			
		||||
  const minutes = Math.floor(seconds / 60);
 | 
			
		||||
  const secs = seconds % 60;
 | 
			
		||||
  return minutes + ':' + secs.toString().padStart(2, '0');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function loadQueue() {
 | 
			
		||||
  try {
 | 
			
		||||
    const response = await fetch('/api/queue');
 | 
			
		||||
    const responseData = await response.json();
 | 
			
		||||
    
 | 
			
		||||
    // Update now playing
 | 
			
		||||
    if (responseData.current_song) {
 | 
			
		||||
      nowPlaying.innerHTML = `
 | 
			
		||||
        <div class="song-info">
 | 
			
		||||
          <h4>${responseData.current_song.title}</h4>
 | 
			
		||||
          <p>${formatDuration(responseData.current_song.duration)}</p>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="song-status">
 | 
			
		||||
          <span class="badge">${responseData.state}</span>
 | 
			
		||||
        </div>
 | 
			
		||||
      `;
 | 
			
		||||
    } else {
 | 
			
		||||
      nowPlaying.innerHTML = '<p>Nothing playing</p>';
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Update queue
 | 
			
		||||
    if (responseData.queue.length > 0) {
 | 
			
		||||
      queueList.innerHTML = responseData.queue.map((songItem, idx) => {
 | 
			
		||||
        if (songItem.is_current) {
 | 
			
		||||
          return `
 | 
			
		||||
            <li class="now-playing">
 | 
			
		||||
              <div class="song-info">
 | 
			
		||||
                <strong>${songItem.title}</strong>
 | 
			
		||||
                <span>${formatDuration(songItem.duration)}</span>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div class="song-status">
 | 
			
		||||
                <span class="badge">Now Playing</span>
 | 
			
		||||
              </div>
 | 
			
		||||
            </li>
 | 
			
		||||
          `;
 | 
			
		||||
        } else {
 | 
			
		||||
          return `
 | 
			
		||||
            <li>
 | 
			
		||||
              <div class="song-info">
 | 
			
		||||
                <strong>${idx + 1}. ${songItem.title}</strong>
 | 
			
		||||
                <span>${formatDuration(songItem.duration)}</span>
 | 
			
		||||
              </div>
 | 
			
		||||
            </li>
 | 
			
		||||
          `;
 | 
			
		||||
        }
 | 
			
		||||
      }).join('');
 | 
			
		||||
    } else {
 | 
			
		||||
      queueList.innerHTML = '<li class="empty-message">Queue is empty</li>';
 | 
			
		||||
    }
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Failed to load queue:', error);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
    ''';
 | 
			
		||||
    // Add the rest of the JS code
 | 
			
		||||
    js += '''
 | 
			
		||||
clearQueueButton.addEventListener('click', async () => {
 | 
			
		||||
  if (!confirm('Are you sure you want to clear the queue?')) return;
 | 
			
		||||
  
 | 
			
		||||
  try {
 | 
			
		||||
    await fetch('/api/queue', {
 | 
			
		||||
      method: 'DELETE',
 | 
			
		||||
    });
 | 
			
		||||
    loadQueue();
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Failed to clear queue:', error);
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// User management
 | 
			
		||||
async function loadUsers() {
 | 
			
		||||
  try {
 | 
			
		||||
    const response = await fetch('/api/users');
 | 
			
		||||
    const responseData = await response.json();
 | 
			
		||||
    
 | 
			
		||||
    if (responseData.users.length > 0) {
 | 
			
		||||
      const tbody = usersTable.querySelector('tbody');
 | 
			
		||||
      tbody.innerHTML = responseData.users.map(userItem => `
 | 
			
		||||
        <tr>
 | 
			
		||||
          <td>${userItem.username}</td>
 | 
			
		||||
          <td>
 | 
			
		||||
            <select class="permission-select" data-username="${userItem.username}">
 | 
			
		||||
              <option value="0" ${userItem.permission_level == 0 ? 'selected' : ''}>None</option>
 | 
			
		||||
              <option value="1" ${userItem.permission_level == 1 ? 'selected' : ''}>View Only</option>
 | 
			
		||||
              <option value="2" ${userItem.permission_level == 2 ? 'selected' : ''}>Read/Write</option>
 | 
			
		||||
              <option value="3" ${userItem.permission_level == 3 ? 'selected' : ''}>Admin</option>
 | 
			
		||||
            </select>
 | 
			
		||||
          </td>
 | 
			
		||||
          <td>
 | 
			
		||||
            <button class="save-permission" data-username="${userItem.username}">Save</button>
 | 
			
		||||
          </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
      `).join('');
 | 
			
		||||
      
 | 
			
		||||
      // Add event listeners to save buttons
 | 
			
		||||
      document.querySelectorAll('.save-permission').forEach(button => {
 | 
			
		||||
        button.addEventListener('click', async () => {
 | 
			
		||||
          const username = button.dataset.username;
 | 
			
		||||
          const select = document.querySelector(`.permission-select[data-username="${username}"]`);
 | 
			
		||||
          const permissionLevel = parseInt(select.value);
 | 
			
		||||
          
 | 
			
		||||
          try {
 | 
			
		||||
            await fetch(`/api/users/${username}/permissions`, {
 | 
			
		||||
              method: 'PUT',
 | 
			
		||||
              headers: {
 | 
			
		||||
                'Content-Type': 'application/json',
 | 
			
		||||
              },
 | 
			
		||||
              body: JSON.stringify({ permission_level: permissionLevel }),
 | 
			
		||||
            });
 | 
			
		||||
            alert(`Permission updated for ${username}`);
 | 
			
		||||
          } catch (error) {
 | 
			
		||||
            console.error('Failed to update permission:', error);
 | 
			
		||||
            alert('Failed to update permission');
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
      });
 | 
			
		||||
    } else {
 | 
			
		||||
      usersTable.querySelector('tbody').innerHTML = '<tr class="empty-message"><td colspan="3">No users found</td></tr>';
 | 
			
		||||
    }
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Failed to load users:', error);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Add user modal
 | 
			
		||||
addUserButton.addEventListener('click', () => {
 | 
			
		||||
  addUserModal.classList.add('active');
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
cancelAddUser.addEventListener('click', () => {
 | 
			
		||||
  addUserModal.classList.remove('active');
 | 
			
		||||
  addUserForm.reset();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
addUserForm.addEventListener('submit', async (e) => {
 | 
			
		||||
  e.preventDefault();
 | 
			
		||||
  
 | 
			
		||||
  const username = document.getElementById('newUsername').value;
 | 
			
		||||
  const permissionLevel = parseInt(document.getElementById('newPermissionLevel').value);
 | 
			
		||||
  
 | 
			
		||||
  try {
 | 
			
		||||
    await fetch('/api/users', {
 | 
			
		||||
      method: 'POST',
 | 
			
		||||
      headers: {
 | 
			
		||||
        'Content-Type': 'application/json',
 | 
			
		||||
      },
 | 
			
		||||
      body: JSON.stringify({ username, permission_level: permissionLevel }),
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    addUserModal.classList.remove('active');
 | 
			
		||||
    addUserForm.reset();
 | 
			
		||||
    loadUsers();
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Failed to add user:', error);
 | 
			
		||||
    alert('Failed to add user');
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Cache management
 | 
			
		||||
async function loadCacheStats() {
 | 
			
		||||
  try {
 | 
			
		||||
    const response = await fetch('/api/cache');
 | 
			
		||||
    const stats = await response.json();
 | 
			
		||||
    
 | 
			
		||||
    cacheSongCount.textContent = stats.songCount;
 | 
			
		||||
    cacheSize.textContent = `${stats.totalSizeMb} MB`;
 | 
			
		||||
    cacheMaxSize.textContent = `${stats.maxSizeMb} MB`;
 | 
			
		||||
    
 | 
			
		||||
    const usagePercent = stats.maxSizeMb > 0 
 | 
			
		||||
      ? (stats.totalSizeMb / stats.maxSizeMb) * 100 
 | 
			
		||||
      : 0;
 | 
			
		||||
    
 | 
			
		||||
    cacheUsage.style.width = `${Math.min(usagePercent, 100)}%`;
 | 
			
		||||
    
 | 
			
		||||
    // Change color based on usage
 | 
			
		||||
    if (usagePercent > 90) {
 | 
			
		||||
      cacheUsage.style.backgroundColor = 'var(--danger-color)';
 | 
			
		||||
    } else if (usagePercent > 70) {
 | 
			
		||||
      cacheUsage.style.backgroundColor = 'var(--accent-color)';
 | 
			
		||||
    } else {
 | 
			
		||||
      cacheUsage.style.backgroundColor = 'var(--primary-color)';
 | 
			
		||||
    }
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Failed to load cache stats:', error);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
clearCacheButton.addEventListener('click', async () => {
 | 
			
		||||
  if (!confirm('Are you sure you want to clear the cache? This will delete all downloaded audio files.')) return;
 | 
			
		||||
  
 | 
			
		||||
  try {
 | 
			
		||||
    await fetch('/api/cache', {
 | 
			
		||||
      method: 'DELETE',
 | 
			
		||||
    });
 | 
			
		||||
    loadCacheStats();
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Failed to clear cache:', error);
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Initial load
 | 
			
		||||
document.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
  loadQueue();
 | 
			
		||||
  loadUsers();
 | 
			
		||||
  loadCacheStats();
 | 
			
		||||
  
 | 
			
		||||
  // Refresh data periodically
 | 
			
		||||
  setInterval(loadQueue, 10000); // Every 10 seconds
 | 
			
		||||
  setInterval(loadCacheStats, 30000); // Every 30 seconds
 | 
			
		||||
});
 | 
			
		||||
''';
 | 
			
		||||
    return js;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -21,8 +21,7 @@ class MumbleMessageHandler {
 | 
			
		||||
  final List<StreamSubscription> _subscriptions = [];
 | 
			
		||||
 | 
			
		||||
  /// Create a new message handler
 | 
			
		||||
  MumbleMessageHandler(this._connection, this._config)
 | 
			
		||||
      : _logManager = LogManager.getInstance() {
 | 
			
		||||
  MumbleMessageHandler(this._connection, this._config) : _logManager = LogManager.getInstance() {
 | 
			
		||||
    _setupHandlers();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -89,17 +88,10 @@ class MumbleMessageHandler {
 | 
			
		||||
        // Parse the command and arguments
 | 
			
		||||
        final commandParts = commandText.split(' ');
 | 
			
		||||
        final command = commandParts[0].toLowerCase();
 | 
			
		||||
        final args = commandParts.length > 1 
 | 
			
		||||
            ? commandParts.sublist(1).join(' ') 
 | 
			
		||||
            : '';
 | 
			
		||||
        final args = commandParts.length > 1 ? commandParts.sublist(1).join(' ') : '';
 | 
			
		||||
 | 
			
		||||
        // Emit the command
 | 
			
		||||
        _commandController.add({
 | 
			
		||||
          'command': command,
 | 
			
		||||
          'args': args,
 | 
			
		||||
          'sender': sender,
 | 
			
		||||
          'isPrivate': isPrivate,
 | 
			
		||||
        });
 | 
			
		||||
        _commandController.add({'command': command, 'args': args, 'sender': sender, 'isPrivate': isPrivate});
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
@ -130,7 +122,6 @@ class MumbleMessageHandler {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      // TODO: Implement proper private message sending with dumble
 | 
			
		||||
      // For now, we'll use the connection's sendPrivateMessage method
 | 
			
		||||
      await _connection.sendPrivateMessage(message, user);
 | 
			
		||||
      _logManager.debug('Sent private message to ${user.name}: $message');
 | 
			
		||||
@ -141,8 +132,8 @@ class MumbleMessageHandler {
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  /// Reply to a message in the same context it was received
 | 
			
		||||
  Future<void> replyToMessage(String message, MumbleUser recipient, bool wasPrivate) async {
 | 
			
		||||
    if (wasPrivate) {
 | 
			
		||||
  Future<void> replyToMessage(String message, MumbleUser recipient, bool private) async {
 | 
			
		||||
    if (private) {
 | 
			
		||||
      await sendPrivateMessage(message, recipient);
 | 
			
		||||
    } else {
 | 
			
		||||
      await sendChannelMessage(message);
 | 
			
		||||
 | 
			
		||||
@ -82,7 +82,7 @@ class MusicQueue {
 | 
			
		||||
      // Check if queue is at max size
 | 
			
		||||
      if (_queue.length >= _config.maxQueueSize) {
 | 
			
		||||
        _logManager.warning('Queue is full, cannot add song: ${song.title}');
 | 
			
		||||
        return -1;
 | 
			
		||||
        return _queue.length; // Return current queue size instead of -1
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Add to queue
 | 
			
		||||
@ -211,7 +211,9 @@ class MusicQueue {
 | 
			
		||||
      await _audioStreamer.streamAudioFile(song.filePath);
 | 
			
		||||
      
 | 
			
		||||
      // Wait for playback to complete or be skipped
 | 
			
		||||
      await _playbackCompleter?.future;
 | 
			
		||||
      if (!_playbackCompleter!.isCompleted) {
 | 
			
		||||
        _playbackCompleter!.complete();
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      _logManager.info('Song finished: ${song.title}');
 | 
			
		||||
      _emitEvent(QueueEvent.songFinished, {'song': song});
 | 
			
		||||
@ -221,6 +223,11 @@ class MusicQueue {
 | 
			
		||||
    } catch (e, stackTrace) {
 | 
			
		||||
      _logManager.error('Error playing song: ${song.title}', e, stackTrace);
 | 
			
		||||
      
 | 
			
		||||
      // Complete the completer if it's not already completed
 | 
			
		||||
      if (_playbackCompleter != null && !_playbackCompleter!.isCompleted) {
 | 
			
		||||
        _playbackCompleter!.complete();
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Skip to next song on error
 | 
			
		||||
      _playNext();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								test/audio/music_queue_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								test/audio/music_queue_test.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1 @@
 | 
			
		||||
void main() {}
 | 
			
		||||
							
								
								
									
										131
									
								
								web/index.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										131
									
								
								web/index.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,131 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
<head>
 | 
			
		||||
  <meta charset="UTF-8">
 | 
			
		||||
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
			
		||||
  <title>MumBullet Dashboard</title>
 | 
			
		||||
  <link rel="stylesheet" href="/style.css">
 | 
			
		||||
</head>
 | 
			
		||||
<body>
 | 
			
		||||
  <header>
 | 
			
		||||
    <h1>MumBullet Dashboard</h1>
 | 
			
		||||
    <nav>
 | 
			
		||||
      <button id="queueTab" class="tab-button active">Queue</button>
 | 
			
		||||
      <button id="usersTab" class="tab-button">Users</button>
 | 
			
		||||
      <button id="cacheTab" class="tab-button">Cache</button>
 | 
			
		||||
      <button id="logoutButton">Logout</button>
 | 
			
		||||
    </nav>
 | 
			
		||||
  </header>
 | 
			
		||||
  
 | 
			
		||||
  <main>
 | 
			
		||||
    <section id="queueSection" class="tab-content active">
 | 
			
		||||
      <h2>Music Queue</h2>
 | 
			
		||||
      <div class="card">
 | 
			
		||||
        <div class="card-header">
 | 
			
		||||
          <h3>Now Playing</h3>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="card-content" id="nowPlaying">
 | 
			
		||||
          <p>Nothing playing</p>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      
 | 
			
		||||
      <div class="card">
 | 
			
		||||
        <div class="card-header">
 | 
			
		||||
          <h3>Queue</h3>
 | 
			
		||||
          <button id="clearQueueButton" class="danger-button">Clear Queue</button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="card-content">
 | 
			
		||||
          <ul id="queueList" class="queue-list">
 | 
			
		||||
            <li class="empty-message">Queue is empty</li>
 | 
			
		||||
          </ul>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </section>
 | 
			
		||||
    
 | 
			
		||||
    <section id="usersSection" class="tab-content">
 | 
			
		||||
      <h2>User Management</h2>
 | 
			
		||||
      <div class="card">
 | 
			
		||||
        <div class="card-header">
 | 
			
		||||
          <h3>Users</h3>
 | 
			
		||||
          <button id="addUserButton">Add User</button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="card-content">
 | 
			
		||||
          <table id="usersTable" class="data-table">
 | 
			
		||||
            <thead>
 | 
			
		||||
              <tr>
 | 
			
		||||
                <th>Username</th>
 | 
			
		||||
                <th>Permission Level</th>
 | 
			
		||||
                <th>Actions</th>
 | 
			
		||||
              </tr>
 | 
			
		||||
            </thead>
 | 
			
		||||
            <tbody>
 | 
			
		||||
              <tr class="empty-message">
 | 
			
		||||
                <td colspan="3">No users found</td>
 | 
			
		||||
              </tr>
 | 
			
		||||
            </tbody>
 | 
			
		||||
          </table>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
      
 | 
			
		||||
      <div id="addUserModal" class="modal">
 | 
			
		||||
        <div class="modal-content">
 | 
			
		||||
          <h3>Add User</h3>
 | 
			
		||||
          <form id="addUserForm">
 | 
			
		||||
            <div class="form-group">
 | 
			
		||||
              <label for="newUsername">Username</label>
 | 
			
		||||
              <input type="text" id="newUsername" name="username" required>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="form-group">
 | 
			
		||||
              <label for="newPermissionLevel">Permission Level</label>
 | 
			
		||||
              <select id="newPermissionLevel" name="permissionLevel" required>
 | 
			
		||||
                <option value="0">None</option>
 | 
			
		||||
                <option value="1">View Only</option>
 | 
			
		||||
                <option value="2">Read/Write</option>
 | 
			
		||||
                <option value="3">Admin</option>
 | 
			
		||||
              </select>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="form-actions">
 | 
			
		||||
              <button type="button" id="cancelAddUser">Cancel</button>
 | 
			
		||||
              <button type="submit">Add User</button>
 | 
			
		||||
            </div>
 | 
			
		||||
          </form>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </section>
 | 
			
		||||
    
 | 
			
		||||
    <section id="cacheSection" class="tab-content">
 | 
			
		||||
      <h2>Cache Management</h2>
 | 
			
		||||
      <div class="card">
 | 
			
		||||
        <div class="card-header">
 | 
			
		||||
          <h3>Cache Statistics</h3>
 | 
			
		||||
          <button id="clearCacheButton" class="danger-button">Clear Cache</button>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="card-content">
 | 
			
		||||
          <div id="cacheStats" class="stats-grid">
 | 
			
		||||
            <div class="stat-item">
 | 
			
		||||
              <span class="stat-label">Songs</span>
 | 
			
		||||
              <span class="stat-value" id="cacheSongCount">0</span>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="stat-item">
 | 
			
		||||
              <span class="stat-label">Size</span>
 | 
			
		||||
              <span class="stat-value" id="cacheSize">0 MB</span>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="stat-item">
 | 
			
		||||
              <span class="stat-label">Max Size</span>
 | 
			
		||||
              <span class="stat-value" id="cacheMaxSize">0 MB</span>
 | 
			
		||||
            </div>
 | 
			
		||||
            <div class="stat-item">
 | 
			
		||||
              <span class="stat-label">Usage</span>
 | 
			
		||||
              <div class="progress-bar">
 | 
			
		||||
                <div id="cacheUsage" class="progress-value" style="width: 0%"></div>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    </section>
 | 
			
		||||
  </main>
 | 
			
		||||
  
 | 
			
		||||
  <script src="/script.js"></script>
 | 
			
		||||
</body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										85
									
								
								web/login.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								web/login.html
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,85 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html lang="en">
 | 
			
		||||
<head>
 | 
			
		||||
  <meta charset="UTF-8">
 | 
			
		||||
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
			
		||||
  <title>Login - MumBullet Dashboard</title>
 | 
			
		||||
  <link rel="stylesheet" href="/style.css">
 | 
			
		||||
</head>
 | 
			
		||||
<body class="login-page">
 | 
			
		||||
  <div class="login-container">
 | 
			
		||||
    <h1>MumBullet Dashboard</h1>
 | 
			
		||||
    <div class="card">
 | 
			
		||||
      <div class="card-header">
 | 
			
		||||
        <h2>Login</h2>
 | 
			
		||||
      </div>
 | 
			
		||||
      <div class="card-content">
 | 
			
		||||
        <!--ERROR-->
 | 
			
		||||
        <form id="loginForm" action="/api/login" method="post">
 | 
			
		||||
          <div class="form-group">
 | 
			
		||||
            <label for="username">Username</label>
 | 
			
		||||
            <input type="text" id="username" name="username" required>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="form-group">
 | 
			
		||||
            <label for="password">Password</label>
 | 
			
		||||
            <input type="password" id="password" name="password" required>
 | 
			
		||||
          </div>
 | 
			
		||||
          <div id="loginError" class="error" style="display: none;">
 | 
			
		||||
            Invalid username or password.
 | 
			
		||||
          </div>
 | 
			
		||||
          <div class="form-actions">
 | 
			
		||||
            <button type="submit">Login</button>
 | 
			
		||||
          </div>
 | 
			
		||||
        </form>
 | 
			
		||||
        
 | 
			
		||||
        <script>
 | 
			
		||||
          // Check if there's an error parameter in the URL
 | 
			
		||||
          const urlParams = new URLSearchParams(window.location.search);
 | 
			
		||||
          if (urlParams.has('error') && urlParams.get('error') === 'invalid') {
 | 
			
		||||
            document.getElementById('loginError').style.display = 'block';
 | 
			
		||||
          }
 | 
			
		||||
          
 | 
			
		||||
          // Handle form submission with fetch API instead of traditional form submission
 | 
			
		||||
          document.getElementById('loginForm').addEventListener('submit', async function(e) {
 | 
			
		||||
            e.preventDefault();
 | 
			
		||||
            
 | 
			
		||||
            const username = document.getElementById('username').value;
 | 
			
		||||
            const password = document.getElementById('password').value;
 | 
			
		||||
            
 | 
			
		||||
            try {
 | 
			
		||||
              const response = await fetch('/api/login', {
 | 
			
		||||
                method: 'POST',
 | 
			
		||||
                headers: {
 | 
			
		||||
                  'Content-Type': 'application/x-www-form-urlencoded'
 | 
			
		||||
                },
 | 
			
		||||
                body: `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}`,
 | 
			
		||||
                // Important for cookies to work
 | 
			
		||||
                credentials: 'include'
 | 
			
		||||
              });
 | 
			
		||||
              
 | 
			
		||||
              if (response.redirected) {
 | 
			
		||||
                // Follow the redirect manually
 | 
			
		||||
                window.location.href = response.url;
 | 
			
		||||
                return;
 | 
			
		||||
              }
 | 
			
		||||
              
 | 
			
		||||
              if (response.ok) {
 | 
			
		||||
                // Successful login, redirect to dashboard
 | 
			
		||||
                window.location.href = '/';
 | 
			
		||||
              } else {
 | 
			
		||||
                // Show error message
 | 
			
		||||
                document.getElementById('loginError').style.display = 'block';
 | 
			
		||||
                console.error('Login failed:', await response.text());
 | 
			
		||||
              }
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
              console.error('Login error:', error);
 | 
			
		||||
              document.getElementById('loginError').textContent = 'An error occurred. Please try again.';
 | 
			
		||||
              document.getElementById('loginError').style.display = 'block';
 | 
			
		||||
            }
 | 
			
		||||
          });
 | 
			
		||||
        </script>
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  </div>
 | 
			
		||||
</body>
 | 
			
		||||
</html>
 | 
			
		||||
							
								
								
									
										287
									
								
								web/script.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										287
									
								
								web/script.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,287 @@
 | 
			
		||||
// DOM Elements
 | 
			
		||||
const queueTab = document.getElementById('queueTab');
 | 
			
		||||
const usersTab = document.getElementById('usersTab');
 | 
			
		||||
const cacheTab = document.getElementById('cacheTab');
 | 
			
		||||
const logoutButton = document.getElementById('logoutButton');
 | 
			
		||||
 | 
			
		||||
const queueSection = document.getElementById('queueSection');
 | 
			
		||||
const usersSection = document.getElementById('usersSection');
 | 
			
		||||
const cacheSection = document.getElementById('cacheSection');
 | 
			
		||||
 | 
			
		||||
const nowPlaying = document.getElementById('nowPlaying');
 | 
			
		||||
const queueList = document.getElementById('queueList');
 | 
			
		||||
const clearQueueButton = document.getElementById('clearQueueButton');
 | 
			
		||||
 | 
			
		||||
const usersTable = document.getElementById('usersTable');
 | 
			
		||||
const addUserButton = document.getElementById('addUserButton');
 | 
			
		||||
const addUserModal = document.getElementById('addUserModal');
 | 
			
		||||
const addUserForm = document.getElementById('addUserForm');
 | 
			
		||||
const cancelAddUser = document.getElementById('cancelAddUser');
 | 
			
		||||
 | 
			
		||||
const cacheSongCount = document.getElementById('cacheSongCount');
 | 
			
		||||
const cacheSize = document.getElementById('cacheSize');
 | 
			
		||||
const cacheMaxSize = document.getElementById('cacheMaxSize');
 | 
			
		||||
const cacheUsage = document.getElementById('cacheUsage');
 | 
			
		||||
const clearCacheButton = document.getElementById('clearCacheButton');
 | 
			
		||||
 | 
			
		||||
// Tab switching
 | 
			
		||||
function switchTab(tab, section) {
 | 
			
		||||
  // Remove active class from all tabs and sections
 | 
			
		||||
  [queueTab, usersTab, cacheTab].forEach(t => t.classList.remove('active'));
 | 
			
		||||
  [queueSection, usersSection, cacheSection].forEach(s => s.classList.remove('active'));
 | 
			
		||||
  
 | 
			
		||||
  // Add active class to selected tab and section
 | 
			
		||||
  tab.classList.add('active');
 | 
			
		||||
  section.classList.add('active');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
queueTab.addEventListener('click', () => switchTab(queueTab, queueSection));
 | 
			
		||||
usersTab.addEventListener('click', () => switchTab(usersTab, usersSection));
 | 
			
		||||
cacheTab.addEventListener('click', () => switchTab(cacheTab, cacheSection));
 | 
			
		||||
 | 
			
		||||
// Logout
 | 
			
		||||
logoutButton.addEventListener('click', async () => {
 | 
			
		||||
  try {
 | 
			
		||||
    await fetch('/api/logout', {
 | 
			
		||||
      method: 'POST',
 | 
			
		||||
      credentials: 'include', // Important for cookies to work
 | 
			
		||||
    });
 | 
			
		||||
    window.location.href = '/login';
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Logout failed:', error);
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Queue management
 | 
			
		||||
function formatDuration(seconds) {
 | 
			
		||||
  const minutes = Math.floor(seconds / 60);
 | 
			
		||||
  const secs = seconds % 60;
 | 
			
		||||
  return `${minutes}:${secs.toString().padStart(2, '0')}`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function loadQueue() {
 | 
			
		||||
  try {
 | 
			
		||||
    const response = await fetch('/api/queue', {
 | 
			
		||||
      credentials: 'include', // Important for cookies to work
 | 
			
		||||
    });
 | 
			
		||||
    const responseData = await response.json();
 | 
			
		||||
    
 | 
			
		||||
    // Update now playing
 | 
			
		||||
    if (responseData.current_song) {
 | 
			
		||||
      nowPlaying.innerHTML = `
 | 
			
		||||
        <div class="song-info">
 | 
			
		||||
          <h4>${responseData.current_song.title}</h4>
 | 
			
		||||
          <p>${formatDuration(responseData.current_song.duration)}</p>
 | 
			
		||||
        </div>
 | 
			
		||||
        <div class="song-status">
 | 
			
		||||
          <span class="badge">${responseData.state}</span>
 | 
			
		||||
        </div>
 | 
			
		||||
      `;
 | 
			
		||||
    } else {
 | 
			
		||||
      nowPlaying.innerHTML = '<p>Nothing playing</p>';
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Update queue
 | 
			
		||||
    if (responseData.queue && responseData.queue.length > 0) {
 | 
			
		||||
      queueList.innerHTML = responseData.queue.map((songItem, idx) => {
 | 
			
		||||
        if (songItem.is_current) {
 | 
			
		||||
          return `
 | 
			
		||||
            <li class="now-playing">
 | 
			
		||||
              <div class="song-info">
 | 
			
		||||
                <strong>${songItem.title}</strong>
 | 
			
		||||
                <span>${formatDuration(songItem.duration)}</span>
 | 
			
		||||
              </div>
 | 
			
		||||
              <div class="song-status">
 | 
			
		||||
                <span class="badge">Now Playing</span>
 | 
			
		||||
              </div>
 | 
			
		||||
            </li>
 | 
			
		||||
          `;
 | 
			
		||||
        } else {
 | 
			
		||||
          return `
 | 
			
		||||
            <li>
 | 
			
		||||
              <div class="song-info">
 | 
			
		||||
                <strong>${idx + 1}. ${songItem.title}</strong>
 | 
			
		||||
                <span>${formatDuration(songItem.duration)}</span>
 | 
			
		||||
              </div>
 | 
			
		||||
            </li>
 | 
			
		||||
          `;
 | 
			
		||||
        }
 | 
			
		||||
      }).join('');
 | 
			
		||||
    } else {
 | 
			
		||||
      queueList.innerHTML = '<li class="empty-message">Queue is empty</li>';
 | 
			
		||||
    }
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Failed to load queue:', error);
 | 
			
		||||
    queueList.innerHTML = '<li class="empty-message">Failed to load queue</li>';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
clearQueueButton.addEventListener('click', async () => {
 | 
			
		||||
  if (!confirm('Are you sure you want to clear the queue?')) return;
 | 
			
		||||
  
 | 
			
		||||
  try {
 | 
			
		||||
    await fetch('/api/queue', {
 | 
			
		||||
      method: 'DELETE',
 | 
			
		||||
      credentials: 'include', // Important for cookies to work
 | 
			
		||||
    });
 | 
			
		||||
    loadQueue();
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Failed to clear queue:', error);
 | 
			
		||||
    alert('Failed to clear queue');
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// User management
 | 
			
		||||
async function loadUsers() {
 | 
			
		||||
  try {
 | 
			
		||||
    const response = await fetch('/api/users', {
 | 
			
		||||
      credentials: 'include', // Important for cookies to work
 | 
			
		||||
    });
 | 
			
		||||
    const responseData = await response.json();
 | 
			
		||||
    
 | 
			
		||||
    if (responseData.users && responseData.users.length > 0) {
 | 
			
		||||
      const tbody = usersTable.querySelector('tbody');
 | 
			
		||||
      tbody.innerHTML = responseData.users.map(userItem => `
 | 
			
		||||
        <tr>
 | 
			
		||||
          <td>${userItem.username}</td>
 | 
			
		||||
          <td>
 | 
			
		||||
            <select class="permission-select" data-username="${userItem.username}">
 | 
			
		||||
              <option value="0" ${userItem.permission_level == 0 ? 'selected' : ''}>None</option>
 | 
			
		||||
              <option value="1" ${userItem.permission_level == 1 ? 'selected' : ''}>View Only</option>
 | 
			
		||||
              <option value="2" ${userItem.permission_level == 2 ? 'selected' : ''}>Read/Write</option>
 | 
			
		||||
              <option value="3" ${userItem.permission_level == 3 ? 'selected' : ''}>Admin</option>
 | 
			
		||||
            </select>
 | 
			
		||||
          </td>
 | 
			
		||||
          <td>
 | 
			
		||||
            <button class="save-permission" data-username="${userItem.username}">Save</button>
 | 
			
		||||
          </td>
 | 
			
		||||
        </tr>
 | 
			
		||||
      `).join('');
 | 
			
		||||
      
 | 
			
		||||
      // Add event listeners to save buttons
 | 
			
		||||
      document.querySelectorAll('.save-permission').forEach(button => {
 | 
			
		||||
        button.addEventListener('click', async () => {
 | 
			
		||||
          const username = button.dataset.username;
 | 
			
		||||
          const select = document.querySelector(`.permission-select[data-username="${username}"]`);
 | 
			
		||||
          const permissionLevel = parseInt(select.value);
 | 
			
		||||
          
 | 
			
		||||
          try {
 | 
			
		||||
            await fetch(`/api/users/${username}/permissions`, {
 | 
			
		||||
              method: 'PUT',
 | 
			
		||||
              headers: {
 | 
			
		||||
                'Content-Type': 'application/json',
 | 
			
		||||
                'Accept': 'application/json'
 | 
			
		||||
              },
 | 
			
		||||
              body: JSON.stringify({ permission_level: permissionLevel }),
 | 
			
		||||
              credentials: 'include', // Important for cookies to work
 | 
			
		||||
            });
 | 
			
		||||
            alert(`Permission updated for ${username}`);
 | 
			
		||||
          } catch (error) {
 | 
			
		||||
            console.error('Failed to update permission:', error);
 | 
			
		||||
            alert('Failed to update permission');
 | 
			
		||||
          }
 | 
			
		||||
        });
 | 
			
		||||
      });
 | 
			
		||||
    } else {
 | 
			
		||||
      usersTable.querySelector('tbody').innerHTML = '<tr class="empty-message"><td colspan="3">No users found</td></tr>';
 | 
			
		||||
    }
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Failed to load users:', error);
 | 
			
		||||
    usersTable.querySelector('tbody').innerHTML = '<tr class="empty-message"><td colspan="3">Failed to load users</td></tr>';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Add user modal
 | 
			
		||||
addUserButton.addEventListener('click', () => {
 | 
			
		||||
  addUserModal.classList.add('active');
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
cancelAddUser.addEventListener('click', () => {
 | 
			
		||||
  addUserModal.classList.remove('active');
 | 
			
		||||
  addUserForm.reset();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
addUserForm.addEventListener('submit', async (e) => {
 | 
			
		||||
  e.preventDefault();
 | 
			
		||||
  
 | 
			
		||||
  const username = document.getElementById('newUsername').value;
 | 
			
		||||
  const permissionLevel = parseInt(document.getElementById('newPermissionLevel').value);
 | 
			
		||||
  
 | 
			
		||||
  try {
 | 
			
		||||
    await fetch('/api/users', {
 | 
			
		||||
      method: 'POST',
 | 
			
		||||
      headers: {
 | 
			
		||||
        'Content-Type': 'application/json',
 | 
			
		||||
      },
 | 
			
		||||
      body: JSON.stringify({ username, permission_level: permissionLevel }),
 | 
			
		||||
      credentials: 'include', // Important for cookies to work
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    addUserModal.classList.remove('active');
 | 
			
		||||
    addUserForm.reset();
 | 
			
		||||
    loadUsers();
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Failed to add user:', error);
 | 
			
		||||
    alert('Failed to add user');
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Cache management
 | 
			
		||||
async function loadCacheStats() {
 | 
			
		||||
  try {
 | 
			
		||||
    const response = await fetch('/api/cache', {
 | 
			
		||||
      credentials: 'include', // Important for cookies to work
 | 
			
		||||
      headers: {
 | 
			
		||||
        'Accept': 'application/json'
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
    const stats = await response.json();
 | 
			
		||||
    
 | 
			
		||||
    cacheSongCount.textContent = stats.songCount;
 | 
			
		||||
    cacheSize.textContent = `${stats.totalSizeMb} MB`;
 | 
			
		||||
    cacheMaxSize.textContent = `${stats.maxSizeMb} MB`;
 | 
			
		||||
    
 | 
			
		||||
    const usagePercent = stats.maxSizeMb > 0 
 | 
			
		||||
      ? (stats.totalSizeMb / stats.maxSizeMb) * 100 
 | 
			
		||||
      : 0;
 | 
			
		||||
    
 | 
			
		||||
    cacheUsage.style.width = `${Math.min(usagePercent, 100)}%`;
 | 
			
		||||
    
 | 
			
		||||
    // Change color based on usage
 | 
			
		||||
    if (usagePercent > 90) {
 | 
			
		||||
      cacheUsage.style.backgroundColor = 'var(--danger-color)';
 | 
			
		||||
    } else if (usagePercent > 70) {
 | 
			
		||||
      cacheUsage.style.backgroundColor = 'var(--accent-color)';
 | 
			
		||||
    } else {
 | 
			
		||||
      cacheUsage.style.backgroundColor = 'var(--primary-color)';
 | 
			
		||||
    }
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Failed to load cache stats:', error);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
clearCacheButton.addEventListener('click', async () => {
 | 
			
		||||
  if (!confirm('Are you sure you want to clear the cache? This will delete all downloaded audio files.')) return;
 | 
			
		||||
  
 | 
			
		||||
  try {
 | 
			
		||||
    await fetch('/api/cache', {
 | 
			
		||||
      method: 'DELETE',
 | 
			
		||||
      credentials: 'include', // Important for cookies to work
 | 
			
		||||
    });
 | 
			
		||||
    loadCacheStats();
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('Failed to clear cache:', error);
 | 
			
		||||
    alert('Failed to clear cache');
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Initial load
 | 
			
		||||
document.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
  loadQueue();
 | 
			
		||||
  loadUsers();
 | 
			
		||||
  loadCacheStats();
 | 
			
		||||
  
 | 
			
		||||
  // Refresh data periodically
 | 
			
		||||
  setInterval(loadQueue, 10000); // Every 10 seconds
 | 
			
		||||
  setInterval(loadCacheStats, 30000); // Every 30 seconds
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										319
									
								
								web/style.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										319
									
								
								web/style.css
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,319 @@
 | 
			
		||||
/* Base styles */
 | 
			
		||||
:root {
 | 
			
		||||
  --primary-color: #4a6da7;
 | 
			
		||||
  --primary-dark: #345a96;
 | 
			
		||||
  --secondary-color: #f5f5f5;
 | 
			
		||||
  --accent-color: #ff9800;
 | 
			
		||||
  --danger-color: #f44336;
 | 
			
		||||
  --text-color: #333333;
 | 
			
		||||
  --text-light: #ffffff;
 | 
			
		||||
  --border-color: #dddddd;
 | 
			
		||||
  --card-bg: #ffffff;
 | 
			
		||||
  --bg-color: #f0f2f5;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
* {
 | 
			
		||||
  box-sizing: border-box;
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
body {
 | 
			
		||||
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
 | 
			
		||||
  line-height: 1.6;
 | 
			
		||||
  color: var(--text-color);
 | 
			
		||||
  background-color: var(--bg-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
a {
 | 
			
		||||
  color: var(--primary-color);
 | 
			
		||||
  text-decoration: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
a:hover {
 | 
			
		||||
  text-decoration: underline;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
button {
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
  padding: 8px 16px;
 | 
			
		||||
  background-color: var(--primary-color);
 | 
			
		||||
  color: var(--text-light);
 | 
			
		||||
  border: none;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
  transition: background-color 0.2s;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
button:hover {
 | 
			
		||||
  background-color: var(--primary-dark);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.danger-button {
 | 
			
		||||
  background-color: var(--danger-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.danger-button:hover {
 | 
			
		||||
  background-color: #d32f2f;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Layout */
 | 
			
		||||
header {
 | 
			
		||||
  background-color: var(--primary-color);
 | 
			
		||||
  color: var(--text-light);
 | 
			
		||||
  padding: 1rem;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: space-between;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
header h1 {
 | 
			
		||||
  font-size: 1.5rem;
 | 
			
		||||
  margin: 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
nav {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  gap: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
main {
 | 
			
		||||
  max-width: 1200px;
 | 
			
		||||
  margin: 0 auto;
 | 
			
		||||
  padding: 1.5rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Cards */
 | 
			
		||||
.card {
 | 
			
		||||
  background-color: var(--card-bg);
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
 | 
			
		||||
  margin-bottom: 1.5rem;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.card-header {
 | 
			
		||||
  padding: 1rem;
 | 
			
		||||
  border-bottom: 1px solid var(--border-color);
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: space-between;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.card-header h3 {
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  font-size: 1.2rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.card-content {
 | 
			
		||||
  padding: 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Tabs */
 | 
			
		||||
.tab-button {
 | 
			
		||||
  background: transparent;
 | 
			
		||||
  color: rgba(255, 255, 255, 0.7);
 | 
			
		||||
  border: none;
 | 
			
		||||
  padding: 8px 16px;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tab-button.active {
 | 
			
		||||
  background: rgba(255, 255, 255, 0.2);
 | 
			
		||||
  color: white;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tab-content {
 | 
			
		||||
  display: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.tab-content.active {
 | 
			
		||||
  display: block;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Queue */
 | 
			
		||||
.queue-list {
 | 
			
		||||
  list-style: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.queue-list li {
 | 
			
		||||
  padding: 10px;
 | 
			
		||||
  border-bottom: 1px solid var(--border-color);
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: space-between;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.queue-list li:last-child {
 | 
			
		||||
  border-bottom: none;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.queue-list .now-playing {
 | 
			
		||||
  background-color: rgba(74, 109, 167, 0.1);
 | 
			
		||||
  border-left: 4px solid var(--primary-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Tables */
 | 
			
		||||
.data-table {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  border-collapse: collapse;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.data-table th, .data-table td {
 | 
			
		||||
  padding: 10px;
 | 
			
		||||
  text-align: left;
 | 
			
		||||
  border-bottom: 1px solid var(--border-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.data-table th {
 | 
			
		||||
  background-color: var(--secondary-color);
 | 
			
		||||
  font-weight: 600;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Forms */
 | 
			
		||||
.form-group {
 | 
			
		||||
  margin-bottom: 15px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-group label {
 | 
			
		||||
  display: block;
 | 
			
		||||
  margin-bottom: 5px;
 | 
			
		||||
  font-weight: 500;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-group input, .form-group select {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  padding: 8px;
 | 
			
		||||
  border: 1px solid var(--border-color);
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  font-size: 16px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.form-actions {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: flex-end;
 | 
			
		||||
  gap: 10px;
 | 
			
		||||
  margin-top: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Modal */
 | 
			
		||||
.modal {
 | 
			
		||||
  display: none;
 | 
			
		||||
  position: fixed;
 | 
			
		||||
  top: 0;
 | 
			
		||||
  left: 0;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  background-color: rgba(0, 0, 0, 0.5);
 | 
			
		||||
  z-index: 1000;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.modal.active {
 | 
			
		||||
  display: flex;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.modal-content {
 | 
			
		||||
  background-color: var(--card-bg);
 | 
			
		||||
  border-radius: 8px;
 | 
			
		||||
  width: 90%;
 | 
			
		||||
  max-width: 500px;
 | 
			
		||||
  padding: 20px;
 | 
			
		||||
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.modal-content h3 {
 | 
			
		||||
  margin-bottom: 15px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Stats */
 | 
			
		||||
.stats-grid {
 | 
			
		||||
  display: grid;
 | 
			
		||||
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
 | 
			
		||||
  gap: 20px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.stat-item {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.stat-label {
 | 
			
		||||
  font-size: 14px;
 | 
			
		||||
  color: #666;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.stat-value {
 | 
			
		||||
  font-size: 24px;
 | 
			
		||||
  font-weight: 600;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Progress bar */
 | 
			
		||||
.progress-bar {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 10px;
 | 
			
		||||
  background-color: #e0e0e0;
 | 
			
		||||
  border-radius: 5px;
 | 
			
		||||
  overflow: hidden;
 | 
			
		||||
  margin-top: 10px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.progress-value {
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  background-color: var(--primary-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Login page */
 | 
			
		||||
.login-page {
 | 
			
		||||
  height: 100vh;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.login-container {
 | 
			
		||||
  width: 90%;
 | 
			
		||||
  max-width: 400px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.login-container h1 {
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  margin-bottom: 20px;
 | 
			
		||||
  color: var(--primary-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.error {
 | 
			
		||||
  background-color: rgba(244, 67, 54, 0.1);
 | 
			
		||||
  color: var(--danger-color);
 | 
			
		||||
  padding: 10px;
 | 
			
		||||
  border-radius: 4px;
 | 
			
		||||
  margin-bottom: 15px;
 | 
			
		||||
  border-left: 4px solid var(--danger-color);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Empty states */
 | 
			
		||||
.empty-message {
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  color: #999;
 | 
			
		||||
  padding: 20px 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/* Responsive */
 | 
			
		||||
@media (max-width: 768px) {
 | 
			
		||||
  header {
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
    align-items: flex-start;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  nav {
 | 
			
		||||
    margin-top: 10px;
 | 
			
		||||
    width: 100%;
 | 
			
		||||
    overflow-x: auto;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  .stats-grid {
 | 
			
		||||
    grid-template-columns: 1fr 1fr;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user