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/
|
.direnv/
|
||||||
cache/
|
cache/
|
||||||
**/.claude/settings.local.json
|
**/.claude/settings.local.json
|
||||||
|
*.log
|
||||||
|
|||||||
@ -3,31 +3,24 @@ import 'dart:async';
|
|||||||
import 'package:args/args.dart';
|
import 'package:args/args.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:mumbullet/mumbullet.dart';
|
import 'package:mumbullet/mumbullet.dart';
|
||||||
|
import 'package:mumbullet/src/dashboard/server.dart';
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
|
|
||||||
Future<void> main(List<String> arguments) async {
|
Future<void> main(List<String> arguments) async {
|
||||||
// Parse command line arguments
|
// Parse command line arguments
|
||||||
final parser = ArgParser()
|
final parser =
|
||||||
..addOption('config',
|
ArgParser()
|
||||||
abbr: 'c',
|
..addOption('config', abbr: 'c', defaultsTo: 'config.json', help: 'Path to configuration file')
|
||||||
defaultsTo: 'config.json',
|
..addOption(
|
||||||
help: 'Path to configuration file')
|
'log-level',
|
||||||
..addOption('log-level',
|
abbr: 'l',
|
||||||
abbr: 'l',
|
defaultsTo: 'info',
|
||||||
defaultsTo: 'info',
|
allowed: ['debug', 'info', 'warning', 'error'],
|
||||||
allowed: ['debug', 'info', 'warning', 'error'],
|
help: 'Log level (debug, info, warning, error)',
|
||||||
help: 'Log level (debug, info, warning, error)')
|
)
|
||||||
..addFlag('console-log',
|
..addFlag('console-log', abbr: 'o', defaultsTo: true, help: 'Log to console')
|
||||||
abbr: 'o',
|
..addOption('log-file', abbr: 'f', help: 'Path to log file')
|
||||||
defaultsTo: true,
|
..addFlag('help', abbr: 'h', negatable: false, help: 'Display this help message');
|
||||||
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 {
|
try {
|
||||||
final results = parser.parse(arguments);
|
final results = parser.parse(arguments);
|
||||||
@ -101,14 +94,19 @@ Future<void> main(List<String> arguments) async {
|
|||||||
(user) => permissionManager.getPermissionLevel(user),
|
(user) => permissionManager.getPermissionLevel(user),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create command handler
|
// Create command handler with connection for private messaging
|
||||||
final commandHandler = CommandHandler(
|
final commandHandler = CommandHandler(
|
||||||
commandParser,
|
commandParser,
|
||||||
musicQueue,
|
musicQueue,
|
||||||
youtubeDownloader,
|
youtubeDownloader,
|
||||||
config.bot,
|
config.bot,
|
||||||
|
mumbleConnection,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final dashboardServer = DashboardServer(config.dashboard, musicQueue, youtubeDownloader, permissionManager);
|
||||||
|
|
||||||
|
dashboardServer.start();
|
||||||
|
|
||||||
// Set up event listeners
|
// Set up event listeners
|
||||||
mumbleConnection.connectionState.listen((isConnected) {
|
mumbleConnection.connectionState.listen((isConnected) {
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
@ -155,7 +153,9 @@ Future<void> main(List<String> arguments) async {
|
|||||||
|
|
||||||
// Send a welcome message to the channel
|
// Send a welcome message to the channel
|
||||||
if (mumbleConnection.isConnected) {
|
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');
|
logger.info('Sent welcome message to channel');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -201,6 +201,7 @@ Future<void> main(List<String> arguments) async {
|
|||||||
mumbleConnection.dispose();
|
mumbleConnection.dispose();
|
||||||
messageHandler.dispose();
|
messageHandler.dispose();
|
||||||
database.close();
|
database.close();
|
||||||
|
await dashboardServer.stop();
|
||||||
|
|
||||||
completer.complete();
|
completer.complete();
|
||||||
});
|
});
|
||||||
@ -230,6 +231,7 @@ Future<void> main(List<String> arguments) async {
|
|||||||
mumbleConnection.dispose();
|
mumbleConnection.dispose();
|
||||||
messageHandler.dispose();
|
messageHandler.dispose();
|
||||||
database.close();
|
database.close();
|
||||||
|
await dashboardServer.stop();
|
||||||
|
|
||||||
completer.complete();
|
completer.complete();
|
||||||
});
|
});
|
||||||
@ -239,7 +241,6 @@ Future<void> main(List<String> arguments) async {
|
|||||||
// Close logger
|
// Close logger
|
||||||
logManager.close();
|
logManager.close();
|
||||||
exit(0);
|
exit(0);
|
||||||
|
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
print('Error: $e');
|
print('Error: $e');
|
||||||
print('Stack trace: $stackTrace');
|
print('Stack trace: $stackTrace');
|
||||||
|
|||||||
29
flake.nix
29
flake.nix
@ -9,49 +9,20 @@
|
|||||||
...
|
...
|
||||||
}:
|
}:
|
||||||
flake-utils.lib.eachDefaultSystem (system: let
|
flake-utils.lib.eachDefaultSystem (system: let
|
||||||
lib = nixpkgs.lib;
|
|
||||||
pkgs = import nixpkgs {
|
pkgs = import nixpkgs {
|
||||||
inherit system;
|
inherit system;
|
||||||
config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [
|
|
||||||
"android-studio-stable"
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
in {
|
in {
|
||||||
devShell = pkgs.mkShell {
|
devShell = pkgs.mkShell {
|
||||||
buildInputs = with pkgs; [
|
buildInputs = with pkgs; [
|
||||||
flutter
|
|
||||||
dart
|
dart
|
||||||
yt-dlp
|
yt-dlp
|
||||||
ffmpeg
|
ffmpeg
|
||||||
libopus
|
libopus
|
||||||
clang
|
|
||||||
cmake
|
|
||||||
ninja
|
|
||||||
pkg-config
|
pkg-config
|
||||||
gtk3
|
|
||||||
pcre
|
|
||||||
libepoxy
|
|
||||||
glib.dev
|
|
||||||
sysprof
|
|
||||||
# For drift / sqlite3 dart
|
|
||||||
sqlite
|
sqlite
|
||||||
# For xp overlay
|
|
||||||
gtk-layer-shell
|
|
||||||
# Dev deps
|
|
||||||
libuuid # for mount.pc
|
libuuid # for mount.pc
|
||||||
xorg.libXdmcp.dev
|
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";
|
LD_LIBRARY_PATH = "${pkgs.sqlite.out}/lib";
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import 'package:mumbullet/src/audio/downloader.dart';
|
|||||||
import 'package:mumbullet/src/command/command_parser.dart';
|
import 'package:mumbullet/src/command/command_parser.dart';
|
||||||
import 'package:mumbullet/src/config/config.dart';
|
import 'package:mumbullet/src/config/config.dart';
|
||||||
import 'package:mumbullet/src/logging/logger.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/mumble/message_handler.dart';
|
||||||
import 'package:mumbullet/src/queue/music_queue.dart';
|
import 'package:mumbullet/src/queue/music_queue.dart';
|
||||||
|
|
||||||
@ -15,83 +16,94 @@ class CommandHandler {
|
|||||||
final YoutubeDownloader _downloader;
|
final YoutubeDownloader _downloader;
|
||||||
final BotConfig _config;
|
final BotConfig _config;
|
||||||
final LogManager _logManager;
|
final LogManager _logManager;
|
||||||
|
final MumbleConnection _connection;
|
||||||
|
|
||||||
/// Create a new command handler
|
/// Create a new command handler
|
||||||
CommandHandler(
|
CommandHandler(this._commandParser, this._musicQueue, this._downloader, this._config, this._connection)
|
||||||
this._commandParser,
|
: _logManager = LogManager.getInstance() {
|
||||||
this._musicQueue,
|
|
||||||
this._downloader,
|
|
||||||
this._config,
|
|
||||||
) : _logManager = LogManager.getInstance() {
|
|
||||||
_registerCommands();
|
_registerCommands();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Register all available commands
|
/// Register all available commands
|
||||||
void _registerCommands() {
|
void _registerCommands() {
|
||||||
// Help command
|
// Help command
|
||||||
_commandParser.registerCommand(Command(
|
_commandParser.registerCommand(
|
||||||
name: 'help',
|
Command(
|
||||||
description: 'Show available commands',
|
name: 'help',
|
||||||
usage: 'help',
|
description: 'Show available commands',
|
||||||
requiredPermissionLevel: 0,
|
usage: 'help',
|
||||||
execute: _handleHelpCommand,
|
requiredPermissionLevel: 0,
|
||||||
));
|
execute: _handleHelpCommand,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Play command
|
// Play command
|
||||||
_commandParser.registerCommand(Command(
|
_commandParser.registerCommand(
|
||||||
name: 'play',
|
Command(
|
||||||
description: 'Clear queue and play a song immediately',
|
name: 'play',
|
||||||
usage: 'play <url>',
|
description: 'Clear queue and play a song immediately',
|
||||||
requiredPermissionLevel: 2,
|
usage: 'play <url>',
|
||||||
requiresArgs: true,
|
requiredPermissionLevel: 2,
|
||||||
execute: _handlePlayCommand,
|
requiresArgs: true,
|
||||||
));
|
execute: _handlePlayCommand,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Queue command
|
// Queue command
|
||||||
_commandParser.registerCommand(Command(
|
_commandParser.registerCommand(
|
||||||
name: 'queue',
|
Command(
|
||||||
description: 'Add a song to the queue',
|
name: 'queue',
|
||||||
usage: 'queue <url>',
|
description: 'Add a song to the queue',
|
||||||
requiredPermissionLevel: 1,
|
usage: 'queue <url>',
|
||||||
requiresArgs: true,
|
requiredPermissionLevel: 1,
|
||||||
execute: _handleQueueCommand,
|
requiresArgs: true,
|
||||||
));
|
execute: _handleQueueCommand,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Skip command
|
// Skip command
|
||||||
_commandParser.registerCommand(Command(
|
_commandParser.registerCommand(
|
||||||
name: 'skip',
|
Command(
|
||||||
description: 'Skip the current song',
|
name: 'skip',
|
||||||
usage: 'skip',
|
description: 'Skip the current song',
|
||||||
requiredPermissionLevel: 2,
|
usage: 'skip',
|
||||||
execute: _handleSkipCommand,
|
requiredPermissionLevel: 2,
|
||||||
));
|
execute: _handleSkipCommand,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// List command
|
// List command
|
||||||
_commandParser.registerCommand(Command(
|
_commandParser.registerCommand(
|
||||||
name: 'list',
|
Command(
|
||||||
description: 'Show the current queue',
|
name: 'list',
|
||||||
usage: 'list',
|
description: 'Show the current queue',
|
||||||
requiredPermissionLevel: 0,
|
usage: 'list',
|
||||||
execute: _handleListCommand,
|
requiredPermissionLevel: 0,
|
||||||
));
|
execute: _handleListCommand,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Clear command
|
// Clear command
|
||||||
_commandParser.registerCommand(Command(
|
_commandParser.registerCommand(
|
||||||
name: 'clear',
|
Command(
|
||||||
description: 'Clear the queue',
|
name: 'clear',
|
||||||
usage: 'clear',
|
description: 'Clear the queue',
|
||||||
requiredPermissionLevel: 2,
|
usage: 'clear',
|
||||||
execute: _handleClearCommand,
|
requiredPermissionLevel: 2,
|
||||||
));
|
execute: _handleClearCommand,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// Shuffle command
|
// Shuffle command
|
||||||
_commandParser.registerCommand(Command(
|
_commandParser.registerCommand(
|
||||||
name: 'shuffle',
|
Command(
|
||||||
description: 'Shuffle the downloaded songs and start playing',
|
name: 'shuffle',
|
||||||
usage: 'shuffle',
|
description: 'Shuffle the downloaded songs and start playing',
|
||||||
requiredPermissionLevel: 2,
|
usage: 'shuffle',
|
||||||
execute: _handleShuffleCommand,
|
requiredPermissionLevel: 2,
|
||||||
));
|
execute: _handleShuffleCommand,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle the help command
|
/// Handle the help command
|
||||||
@ -100,13 +112,24 @@ class CommandHandler {
|
|||||||
final commands = _commandParser.getCommandsForPermissionLevel(permissionLevel);
|
final commands = _commandParser.getCommandsForPermissionLevel(permissionLevel);
|
||||||
|
|
||||||
final helpText = StringBuffer();
|
final helpText = StringBuffer();
|
||||||
helpText.writeln('Available commands:');
|
helpText.writeln('Available commands:<br/>');
|
||||||
|
|
||||||
for (final command in commands) {
|
for (final command in commands) {
|
||||||
helpText.writeln('${_config.commandPrefix}${command.usage} - ${command.description}');
|
helpText.writeln('${_config.commandPrefix}${command.usage} - ${command.description}<br/>');
|
||||||
}
|
}
|
||||||
|
|
||||||
await context.reply(helpText.toString());
|
// 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
|
/// Handle the play command
|
||||||
@ -126,7 +149,7 @@ class CommandHandler {
|
|||||||
// Add to queue and play immediately
|
// Add to queue and play immediately
|
||||||
_musicQueue.enqueue(song, true);
|
_musicQueue.enqueue(song, true);
|
||||||
|
|
||||||
await context.reply('Now playing: ${song.title}');
|
await context.replyAll('Now playing: ${song.title}');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logManager.error('Failed to play URL: $url', e);
|
_logManager.error('Failed to play URL: $url', e);
|
||||||
await context.reply('Failed to download or play the audio: ${e.toString()}');
|
await context.reply('Failed to download or play the audio: ${e.toString()}');
|
||||||
@ -148,9 +171,11 @@ class CommandHandler {
|
|||||||
final position = _musicQueue.enqueue(song);
|
final position = _musicQueue.enqueue(song);
|
||||||
|
|
||||||
if (position == 0 && _musicQueue.currentSong == 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 {
|
} 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) {
|
} catch (e) {
|
||||||
_logManager.error('Failed to queue URL: $url', e);
|
_logManager.error('Failed to queue URL: $url', e);
|
||||||
@ -173,9 +198,9 @@ class CommandHandler {
|
|||||||
final nextSong = _musicQueue.currentSong;
|
final nextSong = _musicQueue.currentSong;
|
||||||
|
|
||||||
if (nextSong != null) {
|
if (nextSong != null) {
|
||||||
await context.reply('Skipped: $skippedTitle. Now playing: ${nextSong.title}');
|
await context.replyAll('Skipped: $skippedTitle. Now playing: ${nextSong.title}');
|
||||||
} else {
|
} else {
|
||||||
await context.reply('Skipped: $skippedTitle. Queue is now empty.');
|
await context.replyAll('Skipped: $skippedTitle. Queue is now empty.');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await context.reply('Failed to skip the current song.');
|
await context.reply('Failed to skip the current song.');
|
||||||
@ -207,7 +232,7 @@ class CommandHandler {
|
|||||||
/// Handle the clear command
|
/// Handle the clear command
|
||||||
Future<void> _handleClearCommand(CommandContext context) async {
|
Future<void> _handleClearCommand(CommandContext context) async {
|
||||||
_musicQueue.clear();
|
_musicQueue.clear();
|
||||||
await context.reply('Queue cleared.');
|
await context.replyAll('Queue cleared.');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle the shuffle command
|
/// Handle the shuffle command
|
||||||
@ -234,9 +259,8 @@ class CommandHandler {
|
|||||||
_musicQueue.enqueue(song, song == songsToAdd.first);
|
_musicQueue.enqueue(song, song == songsToAdd.first);
|
||||||
}
|
}
|
||||||
|
|
||||||
await context.reply(
|
await context.replyAll(
|
||||||
'Added ${songsToAdd.length} shuffled songs to the queue. '
|
'Added ${songsToAdd.length} shuffled songs to the queue.\nNow playing: ${songsToAdd.first.title}',
|
||||||
'Now playing: ${songsToAdd.first.title}'
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logManager.error('Failed to shuffle songs', e);
|
_logManager.error('Failed to shuffle songs', e);
|
||||||
|
|||||||
@ -32,6 +32,9 @@ class CommandContext {
|
|||||||
final bool isPrivate;
|
final bool isPrivate;
|
||||||
final int permissionLevel;
|
final int permissionLevel;
|
||||||
|
|
||||||
|
/// Function to reply to the channel
|
||||||
|
final Future<void> Function(String) replyAll;
|
||||||
|
|
||||||
/// Function to reply to the sender
|
/// Function to reply to the sender
|
||||||
final Future<void> Function(String) reply;
|
final Future<void> Function(String) reply;
|
||||||
|
|
||||||
@ -42,17 +45,12 @@ class CommandContext {
|
|||||||
required this.isPrivate,
|
required this.isPrivate,
|
||||||
required this.permissionLevel,
|
required this.permissionLevel,
|
||||||
required this.reply,
|
required this.reply,
|
||||||
|
required this.replyAll,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of a command execution
|
/// Result of a command execution
|
||||||
enum CommandResult {
|
enum CommandResult { success, notFound, noPermission, invalidArguments, error }
|
||||||
success,
|
|
||||||
notFound,
|
|
||||||
noPermission,
|
|
||||||
invalidArguments,
|
|
||||||
error,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Class for parsing and executing commands
|
/// Class for parsing and executing commands
|
||||||
class CommandParser {
|
class CommandParser {
|
||||||
@ -67,11 +65,7 @@ class CommandParser {
|
|||||||
final Future<int> Function(MumbleUser) _getPermissionLevel;
|
final Future<int> Function(MumbleUser) _getPermissionLevel;
|
||||||
|
|
||||||
/// Create a new command parser
|
/// Create a new command parser
|
||||||
CommandParser(
|
CommandParser(this._messageHandler, this._config, this._getPermissionLevel) : _logManager = LogManager.getInstance() {
|
||||||
this._messageHandler,
|
|
||||||
this._config,
|
|
||||||
this._getPermissionLevel,
|
|
||||||
) : _logManager = LogManager.getInstance() {
|
|
||||||
_setupCommandListener();
|
_setupCommandListener();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,12 +88,7 @@ class CommandParser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a command
|
/// Handle a command
|
||||||
Future<CommandResult> _handleCommand(
|
Future<CommandResult> _handleCommand(String command, String args, MumbleUser sender, bool isPrivate) async {
|
||||||
String command,
|
|
||||||
String args,
|
|
||||||
MumbleUser sender,
|
|
||||||
bool isPrivate,
|
|
||||||
) async {
|
|
||||||
_logManager.info('Handling command: $command, args: $args, from: ${sender.name}');
|
_logManager.info('Handling command: $command, args: $args, from: ${sender.name}');
|
||||||
|
|
||||||
// Check if command exists
|
// Check if command exists
|
||||||
@ -120,32 +109,19 @@ class CommandParser {
|
|||||||
if (permissionLevel < cmd.requiredPermissionLevel) {
|
if (permissionLevel < cmd.requiredPermissionLevel) {
|
||||||
_logManager.warning(
|
_logManager.warning(
|
||||||
'User ${sender.name} does not have permission to use command: $command '
|
'User ${sender.name} does not have permission to use command: $command '
|
||||||
'(has: $permissionLevel, required: ${cmd.requiredPermissionLevel})'
|
'(has: $permissionLevel, required: ${cmd.requiredPermissionLevel})',
|
||||||
);
|
|
||||||
await _messageHandler.replyToMessage(
|
|
||||||
'You do not have permission to use this command.',
|
|
||||||
sender,
|
|
||||||
isPrivate,
|
|
||||||
);
|
);
|
||||||
|
await _messageHandler.replyToMessage('You do not have permission to use this command.', sender, true);
|
||||||
return CommandResult.noPermission;
|
return CommandResult.noPermission;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if command requires arguments
|
// Check if command requires arguments
|
||||||
if (cmd.requiresArgs && args.trim().isEmpty) {
|
if (cmd.requiresArgs && args.trim().isEmpty) {
|
||||||
_logManager.warning('Command $command requires arguments, but none provided');
|
_logManager.warning('Command $command requires arguments, but none provided');
|
||||||
await _messageHandler.replyToMessage(
|
await _messageHandler.replyToMessage('Usage: ${_config.commandPrefix}${cmd.usage}', sender, true);
|
||||||
'Usage: ${_config.commandPrefix}${cmd.usage}',
|
|
||||||
sender,
|
|
||||||
isPrivate,
|
|
||||||
);
|
|
||||||
return CommandResult.invalidArguments;
|
return CommandResult.invalidArguments;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a reply function
|
|
||||||
final reply = (String message) {
|
|
||||||
return _messageHandler.replyToMessage(message, sender, isPrivate);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create command context
|
// Create command context
|
||||||
final context = CommandContext(
|
final context = CommandContext(
|
||||||
commandName: command,
|
commandName: command,
|
||||||
@ -153,7 +129,8 @@ class CommandParser {
|
|||||||
sender: sender,
|
sender: sender,
|
||||||
isPrivate: isPrivate,
|
isPrivate: isPrivate,
|
||||||
permissionLevel: permissionLevel,
|
permissionLevel: permissionLevel,
|
||||||
reply: reply,
|
reply: (String message) => _messageHandler.replyToMessage(message, sender, true),
|
||||||
|
replyAll: (String message) => _messageHandler.replyToMessage(message, sender, false),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Execute the command
|
// Execute the command
|
||||||
@ -162,11 +139,7 @@ class CommandParser {
|
|||||||
return CommandResult.success;
|
return CommandResult.success;
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
_logManager.error('Error executing command: $command', e, stackTrace);
|
_logManager.error('Error executing command: $command', e, stackTrace);
|
||||||
await _messageHandler.replyToMessage(
|
await _messageHandler.replyToMessage('An error occurred while executing the command.', sender, isPrivate);
|
||||||
'An error occurred while executing the command.',
|
|
||||||
sender,
|
|
||||||
isPrivate,
|
|
||||||
);
|
|
||||||
return CommandResult.error;
|
return CommandResult.error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -178,8 +151,6 @@ class CommandParser {
|
|||||||
|
|
||||||
/// Get commands filtered by permission level
|
/// Get commands filtered by permission level
|
||||||
List<Command> getCommandsForPermissionLevel(int permissionLevel) {
|
List<Command> getCommandsForPermissionLevel(int permissionLevel) {
|
||||||
return _commands.values
|
return _commands.values.where((cmd) => cmd.requiredPermissionLevel <= permissionLevel).toList();
|
||||||
.where((cmd) => cmd.requiredPermissionLevel <= permissionLevel)
|
|
||||||
.toList();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -59,7 +59,7 @@ class DashboardApi {
|
|||||||
'id': song.id,
|
'id': song.id,
|
||||||
'title': song.title,
|
'title': song.title,
|
||||||
'duration': song.duration,
|
'duration': song.duration,
|
||||||
'url': song.url,
|
'url': song.url, // Will handle null gracefully in JSON serialization
|
||||||
'is_current': song == currentSong,
|
'is_current': song == currentSong,
|
||||||
}).toList(),
|
}).toList(),
|
||||||
'state': state.name,
|
'state': state.name,
|
||||||
@ -67,7 +67,7 @@ class DashboardApi {
|
|||||||
'id': currentSong.id,
|
'id': currentSong.id,
|
||||||
'title': currentSong.title,
|
'title': currentSong.title,
|
||||||
'duration': currentSong.duration,
|
'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 {
|
Middleware get middleware {
|
||||||
return (Handler innerHandler) {
|
return (Handler innerHandler) {
|
||||||
return (Request request) async {
|
return (Request request) async {
|
||||||
// Skip auth for login page and API
|
// Get the cleaned path (without query parameters)
|
||||||
final path = request.url.path;
|
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);
|
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
|
// Check for session token
|
||||||
final token = _getSessionToken(request);
|
final token = _getSessionToken(request);
|
||||||
if (token != null && _validateSession(token)) {
|
if (token != null) {
|
||||||
// Renew session
|
_logManager.info('Found session token: $token');
|
||||||
_renewSession(token);
|
|
||||||
return innerHandler(request);
|
if (_validateSession(token)) {
|
||||||
|
_logManager.info('Session token is valid, renewing session');
|
||||||
|
// Renew session
|
||||||
|
_renewSession(token);
|
||||||
|
|
||||||
|
// 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
|
// Not authenticated
|
||||||
if (path.startsWith('api/')) {
|
if (path.startsWith('api/')) {
|
||||||
|
_logManager.warning('Unauthenticated API request: $path');
|
||||||
// API request, return 401
|
// 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 {
|
} else {
|
||||||
|
_logManager.info('Redirecting to login page from: $path');
|
||||||
// Web request, redirect to login page
|
// 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') {
|
if (contentType == 'application/json') {
|
||||||
// API login
|
// API login
|
||||||
final jsonString = await request.readAsString();
|
final jsonString = await request.readAsString();
|
||||||
final Map<String, dynamic> body = json.decode(jsonString);
|
_logManager.info('API login request received: $jsonString');
|
||||||
|
|
||||||
final username = body['username'] as String?;
|
try {
|
||||||
final password = body['password'] as String?;
|
final Map<String, dynamic> body = json.decode(jsonString);
|
||||||
|
|
||||||
if (username == _config.adminUsername && password == _config.adminPassword) {
|
final username = body['username'] as String?;
|
||||||
final token = _createSession();
|
final password = body['password'] as String?;
|
||||||
return Response.ok(
|
|
||||||
json.encode({'token': token}),
|
_logManager.info('API login attempt - Username: $username');
|
||||||
headers: {'content-type': 'application/json'},
|
|
||||||
);
|
if (username == _config.adminUsername && password == _config.adminPassword) {
|
||||||
} else {
|
_logManager.info('API login successful');
|
||||||
return Response(401, body: json.encode({'error': 'Invalid credentials'}));
|
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({'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 {
|
||||||
|
_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 {
|
} else {
|
||||||
// Form login
|
// Form login
|
||||||
final formData = await request.readAsString();
|
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 username = params['username'];
|
||||||
final password = params['password'];
|
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) {
|
if (username == _config.adminUsername && password == _config.adminPassword) {
|
||||||
|
_logManager.info('Login successful');
|
||||||
final token = _createSession();
|
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(
|
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 {
|
} else {
|
||||||
|
_logManager.warning('Login failed - Invalid credentials');
|
||||||
return Response.found('/login?error=invalid');
|
return Response.found('/login?error=invalid');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -110,30 +218,61 @@ class DashboardAuth {
|
|||||||
final bytes = List<int>.generate(32, (_) => random.nextInt(256));
|
final bytes = List<int>.generate(32, (_) => random.nextInt(256));
|
||||||
final token = base64Url.encode(bytes);
|
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;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Validate a session token
|
/// Validate a session token
|
||||||
bool _validateSession(String 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];
|
final expiration = _sessions[token];
|
||||||
if (expiration == null) {
|
if (expiration == null) {
|
||||||
|
_logManager.warning('No session found for token: $token');
|
||||||
return false;
|
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);
|
_sessions.remove(token);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final timeRemaining = expiration.difference(now).inMinutes;
|
||||||
|
_logManager.info('Session valid until ${expiration.toIso8601String()} (${timeRemaining} minutes remaining)');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renew a session
|
/// Renew a session
|
||||||
void _renewSession(String token) {
|
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
|
/// Get the session token from a request
|
||||||
@ -141,27 +280,72 @@ class DashboardAuth {
|
|||||||
// Check authorization header
|
// Check authorization header
|
||||||
final authHeader = request.headers['authorization'];
|
final authHeader = request.headers['authorization'];
|
||||||
if (authHeader != null && authHeader.startsWith('Bearer ')) {
|
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
|
// Check cookie
|
||||||
final cookies = request.headers['cookie'];
|
final cookies = request.headers['cookie'];
|
||||||
if (cookies != null) {
|
if (cookies != null) {
|
||||||
|
_logManager.info('Found cookies: $cookies');
|
||||||
|
|
||||||
|
// Enhanced cookie parsing
|
||||||
final cookieParts = cookies.split(';');
|
final cookieParts = cookies.split(';');
|
||||||
for (final part in cookieParts) {
|
for (final part in cookieParts) {
|
||||||
final cookie = part.trim().split('=');
|
final trimmedPart = part.trim();
|
||||||
if (cookie.length == 2 && cookie[0] == 'session') {
|
_logManager.debug('Processing cookie part: $trimmedPart');
|
||||||
return cookie[1];
|
|
||||||
|
// 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clean up expired sessions
|
/// Clean up expired sessions
|
||||||
void cleanupSessions() {
|
void cleanupSessions() {
|
||||||
final now = DateTime.now();
|
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));
|
_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;
|
final DashboardAuth _auth;
|
||||||
|
|
||||||
/// Create a new dashboard server
|
/// Create a new dashboard server
|
||||||
DashboardServer(
|
DashboardServer(this._config, this._musicQueue, this._downloader, this._permissionManager)
|
||||||
this._config,
|
: _logManager = LogManager.getInstance(),
|
||||||
this._musicQueue,
|
|
||||||
this._downloader,
|
|
||||||
this._permissionManager,
|
|
||||||
) : _logManager = LogManager.getInstance(),
|
|
||||||
_auth = DashboardAuth(_config);
|
_auth = DashboardAuth(_config);
|
||||||
|
|
||||||
/// Start the dashboard server
|
/// Start the dashboard server
|
||||||
@ -39,18 +35,10 @@ class DashboardServer {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Create static file handler
|
// Create static file handler
|
||||||
final staticHandler = createStaticHandler(
|
final staticHandler = createStaticHandler(_getWebRoot(), defaultDocument: 'index.html');
|
||||||
_getWebRoot(),
|
|
||||||
defaultDocument: 'index.html',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create API router
|
// Create API router
|
||||||
final api = DashboardApi(
|
final api = DashboardApi(_musicQueue, _downloader, _permissionManager, _auth);
|
||||||
_musicQueue,
|
|
||||||
_downloader,
|
|
||||||
_permissionManager,
|
|
||||||
_auth,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create main router
|
// Create main router
|
||||||
final router = Router();
|
final router = Router();
|
||||||
@ -66,8 +54,9 @@ class DashboardServer {
|
|||||||
return staticHandler(request);
|
return staticHandler(request);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create pipeline with auth middleware
|
// Create pipeline with middleware for CORS, auth, and logging
|
||||||
final handler = Pipeline()
|
final handler = Pipeline()
|
||||||
|
.addMiddleware(_corsHeaders())
|
||||||
.addMiddleware(_auth.middleware)
|
.addMiddleware(_auth.middleware)
|
||||||
.addMiddleware(_logRequests())
|
.addMiddleware(_logRequests())
|
||||||
.addHandler(router);
|
.addHandler(router);
|
||||||
@ -153,16 +142,11 @@ class DashboardServer {
|
|||||||
final queryParams = request.url.queryParameters;
|
final queryParams = request.url.queryParameters;
|
||||||
final error = queryParams['error'];
|
final error = queryParams['error'];
|
||||||
|
|
||||||
final errorHtml = error == 'invalid'
|
final errorHtml = error == 'invalid' ? '<div class="error">Invalid username or password</div>' : '';
|
||||||
? '<div class="error">Invalid username or password</div>'
|
|
||||||
: '';
|
|
||||||
|
|
||||||
final html = _getDefaultLoginHtml().replaceAll('<!--ERROR-->', errorHtml);
|
final html = _getDefaultLoginHtml().replaceAll('<!--ERROR-->', errorHtml);
|
||||||
|
|
||||||
return Response.ok(
|
return Response.ok(html, headers: {'content-type': 'text/html'});
|
||||||
html,
|
|
||||||
headers: {'content-type': 'text/html'},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Middleware for logging requests
|
/// Middleware for logging requests
|
||||||
@ -176,7 +160,7 @@ class DashboardServer {
|
|||||||
|
|
||||||
_logManager.debug(
|
_logManager.debug(
|
||||||
'${request.method} ${request.url.path} - '
|
'${request.method} ${request.url.path} - '
|
||||||
'${response.statusCode} (${duration}ms)'
|
'${response.statusCode} (${duration}ms)',
|
||||||
);
|
);
|
||||||
|
|
||||||
return response;
|
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
|
/// Get the default index.html content
|
||||||
String _getDefaultIndexHtml() {
|
String _getDefaultIndexHtml() {
|
||||||
return '''<!DOCTYPE html>
|
return '''<!DOCTYPE html>
|
||||||
@ -683,280 +692,20 @@ main {
|
|||||||
|
|
||||||
/// Get the default JavaScript content
|
/// Get the default JavaScript content
|
||||||
String _getDefaultJs() {
|
String _getDefaultJs() {
|
||||||
// Create and return JavaScript content for the dashboard
|
// Instead of defining JavaScript inline with lots of errors in Dart analysis,
|
||||||
// This is returned as a string, and won't be analyzed by Dart
|
// we'll just return the contents of the script.js file we created in the web folder.
|
||||||
var js = '''
|
// This simplifies maintenance and avoids Dart analysis issues.
|
||||||
// DOM Elements
|
final scriptFile = File(path.join(_getWebRoot(), 'script.js'));
|
||||||
const queueTab = document.getElementById('queueTab');
|
if (scriptFile.existsSync()) {
|
||||||
const usersTab = document.getElementById('usersTab');
|
return scriptFile.readAsStringSync();
|
||||||
const cacheTab = document.getElementById('cacheTab');
|
}
|
||||||
const logoutButton = document.getElementById('logoutButton');
|
|
||||||
|
|
||||||
const queueSection = document.getElementById('queueSection');
|
// If the file doesn't exist for some reason, return a minimal script
|
||||||
const usersSection = document.getElementById('usersSection');
|
return '''
|
||||||
const cacheSection = document.getElementById('cacheSection');
|
// Basic script for dashboard functionality
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const nowPlaying = document.getElementById('nowPlaying');
|
console.log('Dashboard loaded');
|
||||||
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',
|
|
||||||
});
|
});
|
||||||
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 = [];
|
final List<StreamSubscription> _subscriptions = [];
|
||||||
|
|
||||||
/// Create a new message handler
|
/// Create a new message handler
|
||||||
MumbleMessageHandler(this._connection, this._config)
|
MumbleMessageHandler(this._connection, this._config) : _logManager = LogManager.getInstance() {
|
||||||
: _logManager = LogManager.getInstance() {
|
|
||||||
_setupHandlers();
|
_setupHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,17 +88,10 @@ class MumbleMessageHandler {
|
|||||||
// Parse the command and arguments
|
// Parse the command and arguments
|
||||||
final commandParts = commandText.split(' ');
|
final commandParts = commandText.split(' ');
|
||||||
final command = commandParts[0].toLowerCase();
|
final command = commandParts[0].toLowerCase();
|
||||||
final args = commandParts.length > 1
|
final args = commandParts.length > 1 ? commandParts.sublist(1).join(' ') : '';
|
||||||
? commandParts.sublist(1).join(' ')
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// Emit the command
|
// Emit the command
|
||||||
_commandController.add({
|
_commandController.add({'command': command, 'args': args, 'sender': sender, 'isPrivate': isPrivate});
|
||||||
'command': command,
|
|
||||||
'args': args,
|
|
||||||
'sender': sender,
|
|
||||||
'isPrivate': isPrivate,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -130,7 +122,6 @@ class MumbleMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// TODO: Implement proper private message sending with dumble
|
|
||||||
// For now, we'll use the connection's sendPrivateMessage method
|
// For now, we'll use the connection's sendPrivateMessage method
|
||||||
await _connection.sendPrivateMessage(message, user);
|
await _connection.sendPrivateMessage(message, user);
|
||||||
_logManager.debug('Sent private message to ${user.name}: $message');
|
_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
|
/// Reply to a message in the same context it was received
|
||||||
Future<void> replyToMessage(String message, MumbleUser recipient, bool wasPrivate) async {
|
Future<void> replyToMessage(String message, MumbleUser recipient, bool private) async {
|
||||||
if (wasPrivate) {
|
if (private) {
|
||||||
await sendPrivateMessage(message, recipient);
|
await sendPrivateMessage(message, recipient);
|
||||||
} else {
|
} else {
|
||||||
await sendChannelMessage(message);
|
await sendChannelMessage(message);
|
||||||
|
|||||||
@ -82,7 +82,7 @@ class MusicQueue {
|
|||||||
// Check if queue is at max size
|
// Check if queue is at max size
|
||||||
if (_queue.length >= _config.maxQueueSize) {
|
if (_queue.length >= _config.maxQueueSize) {
|
||||||
_logManager.warning('Queue is full, cannot add song: ${song.title}');
|
_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
|
// Add to queue
|
||||||
@ -211,7 +211,9 @@ class MusicQueue {
|
|||||||
await _audioStreamer.streamAudioFile(song.filePath);
|
await _audioStreamer.streamAudioFile(song.filePath);
|
||||||
|
|
||||||
// Wait for playback to complete or be skipped
|
// Wait for playback to complete or be skipped
|
||||||
await _playbackCompleter?.future;
|
if (!_playbackCompleter!.isCompleted) {
|
||||||
|
_playbackCompleter!.complete();
|
||||||
|
}
|
||||||
|
|
||||||
_logManager.info('Song finished: ${song.title}');
|
_logManager.info('Song finished: ${song.title}');
|
||||||
_emitEvent(QueueEvent.songFinished, {'song': song});
|
_emitEvent(QueueEvent.songFinished, {'song': song});
|
||||||
@ -221,6 +223,11 @@ class MusicQueue {
|
|||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
_logManager.error('Error playing song: ${song.title}', 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
|
// Skip to next song on error
|
||||||
_playNext();
|
_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