mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-04 19:57:55 +02:00
v2.2.5: In-app logging, ISP blocking detection, Latin script fix
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '2.2.0';
|
||||
static const String buildNumber = '46';
|
||||
static const String version = '2.2.5';
|
||||
static const String buildNumber = '47';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ class AppSettings {
|
||||
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
||||
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
||||
final String metadataSource; // spotify, deezer - source for search and metadata
|
||||
final bool enableLogging; // Enable detailed logging for debugging
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
@@ -44,6 +45,7 @@ class AppSettings {
|
||||
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
||||
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
||||
this.enableLogging = false, // Default: disabled for performance
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -66,6 +68,7 @@ class AppSettings {
|
||||
String? spotifyClientSecret,
|
||||
bool? useCustomSpotifyCredentials,
|
||||
String? metadataSource,
|
||||
bool? enableLogging,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -87,6 +90,7 @@ class AppSettings {
|
||||
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||
metadataSource: metadataSource ?? this.metadataSource,
|
||||
enableLogging: enableLogging ?? this.enableLogging,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
useCustomSpotifyCredentials:
|
||||
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
||||
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
||||
enableLogging: json['enableLogging'] as bool? ?? false,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
@@ -50,4 +51,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||
'metadataSource': instance.metadataSource,
|
||||
'enableLogging': instance.enableLogging,
|
||||
};
|
||||
|
||||
@@ -770,7 +770,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
Future<void> _embedMetadataAndCover(String flacPath, Track track) async {
|
||||
// Download cover first
|
||||
String? coverPath;
|
||||
if (track.coverUrl != null && track.coverUrl!.isNotEmpty) {
|
||||
final coverUrl = track.coverUrl;
|
||||
if (coverUrl != null && coverUrl.isNotEmpty) {
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final uniqueId = '${DateTime.now().millisecondsSinceEpoch}_${Random().nextInt(10000)}';
|
||||
@@ -778,10 +779,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
// Download cover using HTTP
|
||||
final httpClient = HttpClient();
|
||||
final request = await httpClient.getUrl(Uri.parse(track.coverUrl!));
|
||||
final request = await httpClient.getUrl(Uri.parse(coverUrl));
|
||||
final response = await request.close();
|
||||
if (response.statusCode == 200) {
|
||||
final file = File(coverPath!);
|
||||
final file = File(coverPath);
|
||||
final sink = file.openWrite();
|
||||
await response.pipe(sink);
|
||||
await sink.close();
|
||||
@@ -845,7 +846,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
filePath: '', // No local file path yet (processed in memory)
|
||||
);
|
||||
|
||||
if (lrcContent != null && lrcContent.isNotEmpty) {
|
||||
if (lrcContent.isNotEmpty) {
|
||||
metadata['LYRICS'] = lrcContent;
|
||||
metadata['UNSYNCEDLYRICS'] = lrcContent; // Fallback for some players
|
||||
_log.d('Lyrics fetched for embedding (${lrcContent.length} chars)');
|
||||
@@ -1250,11 +1251,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
// M4A files from Tidal DASH streams - try to convert to FLAC
|
||||
// M4A files from Tidal DASH streams - try to convert to FLAC
|
||||
if (filePath != null && filePath!.endsWith('.m4a')) {
|
||||
if (filePath != null && filePath.endsWith('.m4a')) {
|
||||
_log.d('M4A file detected (Hi-Res DASH stream), attempting conversion to FLAC...');
|
||||
|
||||
try {
|
||||
final file = File(filePath!);
|
||||
final file = File(filePath);
|
||||
if (!await file.exists()) {
|
||||
_log.e('File does not exist at path: $filePath');
|
||||
} else {
|
||||
@@ -1265,7 +1266,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.w('File is too small (<1KB), skipping conversion. Download might be corrupt.');
|
||||
} else {
|
||||
updateItemStatus(item.id, DownloadStatus.downloading, progress: 0.95);
|
||||
final flacPath = await FFmpegService.convertM4aToFlac(filePath!);
|
||||
final flacPath = await FFmpegService.convertM4aToFlac(filePath);
|
||||
|
||||
if (flacPath != null) {
|
||||
filePath = flacPath;
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
const _settingsKey = 'app_settings';
|
||||
const _migrationVersionKey = 'settings_migration_version';
|
||||
@@ -26,6 +27,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
|
||||
// Apply Spotify credentials to Go backend on load
|
||||
_applySpotifyCredentials();
|
||||
|
||||
// Sync logging state
|
||||
LogBuffer.loggingEnabled = state.enableLogging;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +191,13 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
state = state.copyWith(metadataSource: source);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setEnableLogging(bool enabled) {
|
||||
state = state.copyWith(enableLogging: enabled);
|
||||
_saveSettings();
|
||||
// Sync logging state to LogBuffer
|
||||
LogBuffer.loggingEnabled = enabled;
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||
|
||||
@@ -159,6 +159,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme colorScheme) {
|
||||
// Validate image URL - must be non-null, non-empty, and have a valid host
|
||||
final hasValidImage = widget.coverUrl != null &&
|
||||
widget.coverUrl!.isNotEmpty &&
|
||||
Uri.tryParse(widget.coverUrl!)?.hasAuthority == true;
|
||||
|
||||
return SliverAppBar(
|
||||
expandedHeight: 280,
|
||||
pinned: true,
|
||||
@@ -169,8 +174,15 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (widget.coverUrl != null)
|
||||
CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, color: Colors.black.withValues(alpha: 0.5), colorBlendMode: BlendMode.darken, memCacheWidth: 600),
|
||||
if (hasValidImage)
|
||||
CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
colorBlendMode: BlendMode.darken,
|
||||
memCacheWidth: 600,
|
||||
errorWidget: (context, url, error) => Container(color: colorScheme.surfaceContainerHighest),
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
@@ -192,8 +204,16 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
boxShadow: [BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10))],
|
||||
),
|
||||
child: ClipOval(
|
||||
child: widget.coverUrl != null
|
||||
? CachedNetworkImage(imageUrl: widget.coverUrl!, fit: BoxFit.cover, memCacheWidth: 280)
|
||||
child: hasValidImage
|
||||
? CachedNetworkImage(
|
||||
imageUrl: widget.coverUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 280,
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant),
|
||||
),
|
||||
)
|
||||
: Container(color: colorScheme.surfaceContainerHighest, child: Icon(Icons.person, size: 48, color: colorScheme.onSurfaceVariant)),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -651,6 +651,11 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
}
|
||||
|
||||
Widget _buildArtistCard(SearchArtist artist, ColorScheme colorScheme) {
|
||||
// Validate image URL - must be non-null, non-empty, and have a valid host
|
||||
final hasValidImage = artist.imageUrl != null &&
|
||||
artist.imageUrl!.isNotEmpty &&
|
||||
Uri.tryParse(artist.imageUrl!)?.hasAuthority == true;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _navigateToArtist(artist.id, artist.name, artist.imageUrl),
|
||||
child: Container(
|
||||
@@ -666,12 +671,17 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
child: ClipOval(
|
||||
child: artist.imageUrl != null
|
||||
child: hasValidImage
|
||||
? CachedNetworkImage(
|
||||
imageUrl: artist.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
memCacheWidth: 200,
|
||||
memCacheHeight: 200,
|
||||
errorWidget: (context, url, error) => Icon(
|
||||
Icons.person,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
size: 44,
|
||||
),
|
||||
)
|
||||
: Icon(Icons.person, color: colorScheme.onSurfaceVariant, size: 44),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,801 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:share_plus/share_plus.dart' show ShareParams, SharePlus;
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class LogScreen extends StatefulWidget {
|
||||
const LogScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LogScreen> createState() => _LogScreenState();
|
||||
}
|
||||
|
||||
class _LogScreenState extends State<LogScreen> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _selectedLevel = 'ALL';
|
||||
String _searchQuery = '';
|
||||
bool _autoScroll = true;
|
||||
|
||||
final List<String> _levels = ['ALL', 'DEBUG', 'INFO', 'WARN', 'ERROR'];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
LogBuffer().addListener(_onLogUpdate);
|
||||
// Start polling Go backend logs
|
||||
LogBuffer().startGoLogPolling();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
LogBuffer().removeListener(_onLogUpdate);
|
||||
// Stop polling when leaving screen
|
||||
LogBuffer().stopGoLogPolling();
|
||||
_scrollController.dispose();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onLogUpdate() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
if (_autoScroll && _scrollController.hasClients) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
List<LogEntry> get _filteredLogs {
|
||||
return LogBuffer().filter(
|
||||
level: _selectedLevel,
|
||||
search: _searchQuery.isEmpty ? null : _searchQuery,
|
||||
);
|
||||
}
|
||||
|
||||
void _copyLogs() {
|
||||
final logs = LogBuffer().export();
|
||||
Clipboard.setData(ClipboardData(text: logs));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Logs copied to clipboard'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _shareLogs() {
|
||||
final logs = LogBuffer().export();
|
||||
SharePlus.instance.share(ShareParams(text: logs, subject: 'SpotiFLAC Logs'));
|
||||
}
|
||||
|
||||
void _clearLogs() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Clear Logs'),
|
||||
content: const Text('Are you sure you want to clear all logs?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
LogBuffer().clear();
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getLevelColor(String level, ColorScheme colorScheme) {
|
||||
switch (level) {
|
||||
case 'ERROR':
|
||||
case 'FATAL':
|
||||
return colorScheme.error;
|
||||
case 'WARN':
|
||||
return Colors.orange;
|
||||
case 'INFO':
|
||||
return colorScheme.primary;
|
||||
case 'DEBUG':
|
||||
default:
|
||||
return colorScheme.onSurfaceVariant;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final topPadding = MediaQuery.of(context).padding.top;
|
||||
final logs = _filteredLogs;
|
||||
|
||||
return PopScope(
|
||||
canPop: true,
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
// Collapsing App Bar with back button - same as other settings pages
|
||||
SliverAppBar(
|
||||
expandedHeight: 120 + topPadding,
|
||||
collapsedHeight: kToolbarHeight,
|
||||
floating: false,
|
||||
pinned: true,
|
||||
backgroundColor: colorScheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(_autoScroll ? Icons.vertical_align_bottom : Icons.vertical_align_center),
|
||||
tooltip: _autoScroll ? 'Auto-scroll ON' : 'Auto-scroll OFF',
|
||||
onPressed: () => setState(() => _autoScroll = !_autoScroll),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.copy),
|
||||
tooltip: 'Copy logs',
|
||||
onPressed: _copyLogs,
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'share':
|
||||
_shareLogs();
|
||||
break;
|
||||
case 'clear':
|
||||
_clearLogs();
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'share',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.share),
|
||||
title: Text('Share logs'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'clear',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.delete_outline),
|
||||
title: Text('Clear logs'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
flexibleSpace: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxHeight = 120 + topPadding;
|
||||
final minHeight = kToolbarHeight + topPadding;
|
||||
final expandRatio = ((constraints.maxHeight - minHeight) / (maxHeight - minHeight)).clamp(0.0, 1.0);
|
||||
final leftPadding = 56 - (32 * expandRatio);
|
||||
return FlexibleSpaceBar(
|
||||
expandedTitleScale: 1.0,
|
||||
titlePadding: EdgeInsets.only(left: leftPadding, bottom: 16),
|
||||
title: Text(
|
||||
'Logs',
|
||||
style: TextStyle(
|
||||
fontSize: 20 + (8 * expandRatio),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Filter section
|
||||
const SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(title: 'Filter'),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
// Level filter
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.filter_list, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Level', style: Theme.of(context).textTheme.bodyLarge),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Filter logs by severity',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
DropdownButton<String>(
|
||||
value: _selectedLevel,
|
||||
underline: const SizedBox(),
|
||||
items: _levels.map((level) {
|
||||
return DropdownMenuItem(
|
||||
value: level,
|
||||
child: Text(
|
||||
level,
|
||||
style: TextStyle(
|
||||
color: level == 'ALL'
|
||||
? colorScheme.onSurface
|
||||
: _getLevelColor(level, colorScheme),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() => _selectedLevel = value);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
indent: 56,
|
||||
endIndent: 20,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
// Search field
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.search, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search logs...',
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: colorScheme.surfaceContainerHighest,
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear, size: 20),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
setState(() => _searchQuery = '');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() => _searchQuery = value);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Log entries section
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: 'Entries (${logs.length}${_selectedLevel != 'ALL' || _searchQuery.isNotEmpty ? ' filtered' : ''})',
|
||||
),
|
||||
),
|
||||
|
||||
// Error summary card - shows detected issues
|
||||
SliverToBoxAdapter(
|
||||
child: _LogSummaryCard(logs: LogBuffer().entries),
|
||||
),
|
||||
|
||||
// Log list
|
||||
logs.isEmpty
|
||||
? SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.article_outlined,
|
||||
size: 48,
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No logs yet',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Logs will appear here as you use the app',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
...logs.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final log = entry.value;
|
||||
return _LogEntryTile(
|
||||
entry: log,
|
||||
levelColor: _getLevelColor(log.level, colorScheme),
|
||||
showDivider: index < logs.length - 1,
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Bottom padding
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogEntryTile extends StatelessWidget {
|
||||
final LogEntry entry;
|
||||
final Color levelColor;
|
||||
final bool showDivider;
|
||||
|
||||
const _LogEntryTile({
|
||||
required this.entry,
|
||||
required this.levelColor,
|
||||
this.showDivider = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isError = entry.level == 'ERROR' || entry.level == 'FATAL';
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isError
|
||||
? colorScheme.errorContainer.withValues(alpha: 0.2)
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header: time, level, tag
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
entry.formattedTime,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: levelColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
entry.level,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: levelColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (entry.isFromGo) ...[
|
||||
const SizedBox(width: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.teal.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'Go',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.teal,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.tag,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// Message
|
||||
Text(
|
||||
entry.message,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
color: colorScheme.onSurface,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
// Error if present
|
||||
if (entry.error != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
entry.error!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
color: colorScheme.error,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
Divider(
|
||||
height: 1,
|
||||
indent: 20,
|
||||
endIndent: 20,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary card showing detected issues in logs
|
||||
class _LogSummaryCard extends StatelessWidget {
|
||||
final List<LogEntry> logs;
|
||||
|
||||
const _LogSummaryCard({required this.logs});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Analyze logs for issues
|
||||
final analysis = _analyzeLogs();
|
||||
|
||||
// Don't show if no issues detected
|
||||
if (!analysis.hasIssues) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: analysis.hasISPBlocking
|
||||
? colorScheme.errorContainer.withValues(alpha: 0.5)
|
||||
: colorScheme.tertiaryContainer.withValues(alpha: 0.5),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
analysis.hasISPBlocking ? Icons.block : Icons.warning_amber_rounded,
|
||||
size: 20,
|
||||
color: analysis.hasISPBlocking ? colorScheme.error : colorScheme.tertiary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Issue Summary',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ISP Blocking detected
|
||||
if (analysis.hasISPBlocking) ...[
|
||||
_IssueBadge(
|
||||
icon: Icons.block,
|
||||
label: 'ISP BLOCKING DETECTED',
|
||||
description: 'Your ISP may be blocking access to download services',
|
||||
suggestion: 'Try using a VPN or change DNS to 1.1.1.1 or 8.8.8.8',
|
||||
color: colorScheme.error,
|
||||
domains: analysis.blockedDomains,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
// Rate limiting
|
||||
if (analysis.hasRateLimit) ...[
|
||||
_IssueBadge(
|
||||
icon: Icons.speed,
|
||||
label: 'RATE LIMITED',
|
||||
description: 'Too many requests to the service',
|
||||
suggestion: 'Wait a few minutes before trying again',
|
||||
color: Colors.orange,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
// Network errors
|
||||
if (analysis.hasNetworkError && !analysis.hasISPBlocking) ...[
|
||||
_IssueBadge(
|
||||
icon: Icons.wifi_off,
|
||||
label: 'NETWORK ERROR',
|
||||
description: 'Connection issues detected',
|
||||
suggestion: 'Check your internet connection',
|
||||
color: colorScheme.tertiary,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
// Track not found
|
||||
if (analysis.hasNotFound) ...[
|
||||
_IssueBadge(
|
||||
icon: Icons.search_off,
|
||||
label: 'TRACK NOT FOUND',
|
||||
description: 'Some tracks could not be found on download services',
|
||||
suggestion: 'The track may not be available in lossless quality',
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
],
|
||||
|
||||
// Error count
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Total errors: ${analysis.errorCount}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_LogAnalysis _analyzeLogs() {
|
||||
int errorCount = 0;
|
||||
bool hasISPBlocking = false;
|
||||
bool hasRateLimit = false;
|
||||
bool hasNetworkError = false;
|
||||
bool hasNotFound = false;
|
||||
final Set<String> blockedDomains = {};
|
||||
|
||||
for (final log in logs) {
|
||||
if (log.level == 'ERROR' || log.level == 'FATAL') {
|
||||
errorCount++;
|
||||
}
|
||||
|
||||
final msgLower = log.message.toLowerCase();
|
||||
final errorLower = (log.error ?? '').toLowerCase();
|
||||
final combined = '$msgLower $errorLower';
|
||||
|
||||
// Check for ISP blocking (detected by Go backend)
|
||||
if (combined.contains('isp blocking') ||
|
||||
combined.contains('isp may be') ||
|
||||
combined.contains('blocked by isp') ||
|
||||
combined.contains('connection reset') ||
|
||||
combined.contains('connection refused')) {
|
||||
hasISPBlocking = true;
|
||||
|
||||
// Try to extract domain
|
||||
final domainMatch = RegExp(r'domain:\s*([^\s,]+)', caseSensitive: false).firstMatch(combined);
|
||||
if (domainMatch != null) {
|
||||
blockedDomains.add(domainMatch.group(1)!);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for rate limiting
|
||||
if (combined.contains('rate limit') ||
|
||||
combined.contains('429') ||
|
||||
combined.contains('too many requests')) {
|
||||
hasRateLimit = true;
|
||||
}
|
||||
|
||||
// Check for network errors
|
||||
if (combined.contains('connection') ||
|
||||
combined.contains('timeout') ||
|
||||
combined.contains('network') ||
|
||||
combined.contains('dial')) {
|
||||
hasNetworkError = true;
|
||||
}
|
||||
|
||||
// Check for not found
|
||||
if (combined.contains('not found') ||
|
||||
combined.contains('no results') ||
|
||||
combined.contains('could not find')) {
|
||||
hasNotFound = true;
|
||||
}
|
||||
}
|
||||
|
||||
return _LogAnalysis(
|
||||
errorCount: errorCount,
|
||||
hasISPBlocking: hasISPBlocking,
|
||||
hasRateLimit: hasRateLimit,
|
||||
hasNetworkError: hasNetworkError,
|
||||
hasNotFound: hasNotFound,
|
||||
blockedDomains: blockedDomains.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogAnalysis {
|
||||
final int errorCount;
|
||||
final bool hasISPBlocking;
|
||||
final bool hasRateLimit;
|
||||
final bool hasNetworkError;
|
||||
final bool hasNotFound;
|
||||
final List<String> blockedDomains;
|
||||
|
||||
_LogAnalysis({
|
||||
required this.errorCount,
|
||||
required this.hasISPBlocking,
|
||||
required this.hasRateLimit,
|
||||
required this.hasNetworkError,
|
||||
required this.hasNotFound,
|
||||
required this.blockedDomains,
|
||||
});
|
||||
|
||||
bool get hasIssues => errorCount > 0 || hasISPBlocking || hasRateLimit || hasNetworkError || hasNotFound;
|
||||
}
|
||||
|
||||
class _IssueBadge extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String description;
|
||||
final String suggestion;
|
||||
final Color color;
|
||||
final List<String>? domains;
|
||||
|
||||
const _IssueBadge({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.description,
|
||||
required this.suggestion,
|
||||
required this.color,
|
||||
this.domains,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: color.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
description,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
if (domains != null && domains!.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Affected: ${domains!.join(", ")}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.lightbulb_outline, size: 14, color: colorScheme.primary),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
suggestion,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -168,6 +168,25 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Debug section
|
||||
const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Debug')),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.bug_report,
|
||||
title: 'Detailed Logging',
|
||||
subtitle: settings.enableLogging
|
||||
? 'Detailed logs are being recorded'
|
||||
: 'Enable for bug reports',
|
||||
value: settings.enableLogging,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setEnableLogging(v),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 32)),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:spotiflac_android/screens/settings/appearance_settings_page.dart
|
||||
import 'package:spotiflac_android/screens/settings/download_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/options_settings_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/about_page.dart';
|
||||
import 'package:spotiflac_android/screens/settings/log_screen.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
class SettingsTab extends ConsumerWidget {
|
||||
@@ -67,10 +68,16 @@ class SettingsTab extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Second group: About
|
||||
// Second group: Logs & About
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.article_outlined,
|
||||
title: 'Logs',
|
||||
subtitle: 'View app logs for debugging',
|
||||
onTap: () => _navigateTo(context, const LogScreen()),
|
||||
),
|
||||
SettingsItem(
|
||||
icon: Icons.info_outline,
|
||||
title: 'About',
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('PlatformBridge');
|
||||
|
||||
/// Bridge to communicate with Go backend via platform channels
|
||||
class PlatformBridge {
|
||||
@@ -7,18 +10,21 @@ class PlatformBridge {
|
||||
|
||||
/// Parse and validate Spotify URL
|
||||
static Future<Map<String, dynamic>> parseSpotifyUrl(String url) async {
|
||||
_log.d('parseSpotifyUrl: $url');
|
||||
final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get Spotify metadata from URL
|
||||
static Future<Map<String, dynamic>> getSpotifyMetadata(String url) async {
|
||||
_log.d('getSpotifyMetadata: $url');
|
||||
final result = await _channel.invokeMethod('getSpotifyMetadata', {'url': url});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Search Spotify
|
||||
static Future<Map<String, dynamic>> searchSpotify(String query, {int limit = 10}) async {
|
||||
_log.d('searchSpotify: "$query" (limit: $limit)');
|
||||
final result = await _channel.invokeMethod('searchSpotify', {
|
||||
'query': query,
|
||||
'limit': limit,
|
||||
@@ -28,6 +34,7 @@ class PlatformBridge {
|
||||
|
||||
/// Search Spotify for tracks and artists
|
||||
static Future<Map<String, dynamic>> searchSpotifyAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
|
||||
_log.d('searchSpotifyAll: "$query"');
|
||||
final result = await _channel.invokeMethod('searchSpotifyAll', {
|
||||
'query': query,
|
||||
'track_limit': trackLimit,
|
||||
@@ -38,6 +45,7 @@ class PlatformBridge {
|
||||
|
||||
/// Check track availability on streaming services
|
||||
static Future<Map<String, dynamic>> checkAvailability(String spotifyId, String isrc) async {
|
||||
_log.d('checkAvailability: $spotifyId (ISRC: $isrc)');
|
||||
final result = await _channel.invokeMethod('checkAvailability', {
|
||||
'spotify_id': spotifyId,
|
||||
'isrc': isrc,
|
||||
@@ -67,6 +75,7 @@ class PlatformBridge {
|
||||
String? itemId,
|
||||
int durationMs = 0,
|
||||
}) async {
|
||||
_log.i('downloadTrack: "$trackName" by $artistName via $service');
|
||||
final request = jsonEncode({
|
||||
'isrc': isrc,
|
||||
'service': service,
|
||||
@@ -90,7 +99,13 @@ class PlatformBridge {
|
||||
});
|
||||
|
||||
final result = await _channel.invokeMethod('downloadTrack', request);
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
final response = jsonDecode(result as String) as Map<String, dynamic>;
|
||||
if (response['success'] == true) {
|
||||
_log.i('Download success: ${response['file_path']}');
|
||||
} else {
|
||||
_log.w('Download failed: ${response['error']}');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/// Download with automatic fallback to other services
|
||||
@@ -115,6 +130,7 @@ class PlatformBridge {
|
||||
String? itemId,
|
||||
int durationMs = 0,
|
||||
}) async {
|
||||
_log.i('downloadWithFallback: "$trackName" by $artistName (preferred: $preferredService)');
|
||||
final request = jsonEncode({
|
||||
'isrc': isrc,
|
||||
'service': preferredService,
|
||||
@@ -138,7 +154,22 @@ class PlatformBridge {
|
||||
});
|
||||
|
||||
final result = await _channel.invokeMethod('downloadWithFallback', request);
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
final response = jsonDecode(result as String) as Map<String, dynamic>;
|
||||
if (response['success'] == true) {
|
||||
final service = response['service'] ?? 'unknown';
|
||||
final filePath = response['file_path'] ?? '';
|
||||
final bitDepth = response['actual_bit_depth'];
|
||||
final sampleRate = response['actual_sample_rate'];
|
||||
final qualityStr = bitDepth != null && sampleRate != null
|
||||
? ' ($bitDepth-bit/${(sampleRate / 1000).toStringAsFixed(1)}kHz)'
|
||||
: '';
|
||||
_log.i('Download success via $service$qualityStr: $filePath');
|
||||
} else {
|
||||
final error = response['error'] ?? 'Unknown error';
|
||||
final errorType = response['error_type'] ?? '';
|
||||
_log.e('Download failed: $error (type: $errorType)');
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/// Get download progress (legacy single download)
|
||||
@@ -377,4 +408,35 @@ class PlatformBridge {
|
||||
final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
// ==================== GO BACKEND LOGS ====================
|
||||
|
||||
/// Get all logs from Go backend
|
||||
static Future<List<Map<String, dynamic>>> getGoLogs() async {
|
||||
final result = await _channel.invokeMethod('getLogs');
|
||||
final logs = jsonDecode(result as String) as List<dynamic>;
|
||||
return logs.map((e) => e as Map<String, dynamic>).toList();
|
||||
}
|
||||
|
||||
/// Get logs since a specific index (for incremental updates)
|
||||
static Future<Map<String, dynamic>> getGoLogsSince(int index) async {
|
||||
final result = await _channel.invokeMethod('getLogsSince', {'index': index});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Clear Go backend logs
|
||||
static Future<void> clearGoLogs() async {
|
||||
await _channel.invokeMethod('clearLogs');
|
||||
}
|
||||
|
||||
/// Get Go backend log count
|
||||
static Future<int> getGoLogCount() async {
|
||||
final result = await _channel.invokeMethod('getLogCount');
|
||||
return result as int;
|
||||
}
|
||||
|
||||
/// Enable or disable Go backend logging
|
||||
static Future<void> setGoLoggingEnabled(bool enabled) async {
|
||||
await _channel.invokeMethod('setLoggingEnabled', {'enabled': enabled});
|
||||
}
|
||||
}
|
||||
|
||||
+269
-9
@@ -1,7 +1,233 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:spotiflac_android/services/platform_bridge.dart';
|
||||
|
||||
/// Log entry with timestamp and level
|
||||
class LogEntry {
|
||||
final DateTime timestamp;
|
||||
final String level;
|
||||
final String tag;
|
||||
final String message;
|
||||
final String? error;
|
||||
final bool isFromGo; // Track if this log came from Go backend
|
||||
|
||||
LogEntry({
|
||||
required this.timestamp,
|
||||
required this.level,
|
||||
required this.tag,
|
||||
required this.message,
|
||||
this.error,
|
||||
this.isFromGo = false,
|
||||
});
|
||||
|
||||
String get formattedTime {
|
||||
final h = timestamp.hour.toString().padLeft(2, '0');
|
||||
final m = timestamp.minute.toString().padLeft(2, '0');
|
||||
final s = timestamp.second.toString().padLeft(2, '0');
|
||||
final ms = timestamp.millisecond.toString().padLeft(3, '0');
|
||||
return '$h:$m:$s.$ms';
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final errorPart = error != null ? ' | $error' : '';
|
||||
final goPart = isFromGo ? ' [Go]' : '';
|
||||
return '[$formattedTime] [$level]$goPart [$tag] $message$errorPart';
|
||||
}
|
||||
}
|
||||
|
||||
/// Circular buffer for storing logs in memory
|
||||
class LogBuffer extends ChangeNotifier {
|
||||
static final LogBuffer _instance = LogBuffer._internal();
|
||||
factory LogBuffer() => _instance;
|
||||
LogBuffer._internal();
|
||||
|
||||
static const int maxEntries = 500;
|
||||
final Queue<LogEntry> _entries = Queue<LogEntry>();
|
||||
Timer? _goLogTimer;
|
||||
int _lastGoLogIndex = 0;
|
||||
|
||||
/// Whether logging is enabled (controlled by settings)
|
||||
static bool _loggingEnabled = false;
|
||||
static bool get loggingEnabled => _loggingEnabled;
|
||||
static set loggingEnabled(bool value) {
|
||||
_loggingEnabled = value;
|
||||
// Also notify Go backend about logging state
|
||||
if (value) {
|
||||
PlatformBridge.setGoLoggingEnabled(true).catchError((_) {});
|
||||
} else {
|
||||
PlatformBridge.setGoLoggingEnabled(false).catchError((_) {});
|
||||
}
|
||||
}
|
||||
|
||||
List<LogEntry> get entries => _entries.toList();
|
||||
int get length => _entries.length;
|
||||
|
||||
void add(LogEntry entry) {
|
||||
// Skip adding if logging is disabled (except for errors which are always logged)
|
||||
if (!_loggingEnabled && entry.level != 'ERROR' && entry.level != 'FATAL') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_entries.length >= maxEntries) {
|
||||
_entries.removeFirst();
|
||||
}
|
||||
_entries.add(entry);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Start polling Go backend logs
|
||||
void startGoLogPolling() {
|
||||
_goLogTimer?.cancel();
|
||||
_goLogTimer = Timer.periodic(const Duration(milliseconds: 500), (_) async {
|
||||
await _fetchGoLogs();
|
||||
});
|
||||
}
|
||||
|
||||
/// Stop polling Go backend logs
|
||||
void stopGoLogPolling() {
|
||||
_goLogTimer?.cancel();
|
||||
_goLogTimer = null;
|
||||
}
|
||||
|
||||
/// Fetch logs from Go backend since last index
|
||||
Future<void> _fetchGoLogs() async {
|
||||
try {
|
||||
final result = await PlatformBridge.getGoLogsSince(_lastGoLogIndex);
|
||||
final logs = result['logs'] as List<dynamic>? ?? [];
|
||||
final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex;
|
||||
|
||||
for (final log in logs) {
|
||||
final timestamp = log['timestamp'] as String? ?? '';
|
||||
final level = log['level'] as String? ?? 'INFO';
|
||||
final tag = log['tag'] as String? ?? 'Go';
|
||||
final message = log['message'] as String? ?? '';
|
||||
|
||||
// Parse timestamp (format: "15:04:05.000")
|
||||
DateTime parsedTime = DateTime.now();
|
||||
if (timestamp.isNotEmpty) {
|
||||
try {
|
||||
final parts = timestamp.split(':');
|
||||
if (parts.length >= 3) {
|
||||
final secParts = parts[2].split('.');
|
||||
parsedTime = DateTime(
|
||||
parsedTime.year, parsedTime.month, parsedTime.day,
|
||||
int.parse(parts[0]), int.parse(parts[1]),
|
||||
int.parse(secParts[0]),
|
||||
secParts.length > 1 ? int.parse(secParts[1]) : 0,
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
// Use current time if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
add(LogEntry(
|
||||
timestamp: parsedTime,
|
||||
level: level,
|
||||
tag: tag,
|
||||
message: message,
|
||||
isFromGo: true,
|
||||
));
|
||||
}
|
||||
|
||||
_lastGoLogIndex = nextIndex;
|
||||
} catch (e) {
|
||||
// Ignore errors - Go backend might not be ready
|
||||
if (kDebugMode) {
|
||||
debugPrint('Failed to fetch Go logs: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_entries.clear();
|
||||
_lastGoLogIndex = 0;
|
||||
// Also clear Go backend logs
|
||||
PlatformBridge.clearGoLogs().catchError((_) {});
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String export() {
|
||||
final buffer = StringBuffer();
|
||||
buffer.writeln('SpotiFLAC Log Export');
|
||||
buffer.writeln('Generated: ${DateTime.now().toIso8601String()}');
|
||||
buffer.writeln('Entries: ${_entries.length}');
|
||||
buffer.writeln('=' * 60);
|
||||
buffer.writeln();
|
||||
for (final entry in _entries) {
|
||||
buffer.writeln(entry.toString());
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
List<LogEntry> filter({String? level, String? tag, String? search}) {
|
||||
return _entries.where((entry) {
|
||||
if (level != null && level != 'ALL' && entry.level != level) {
|
||||
return false;
|
||||
}
|
||||
if (tag != null && !entry.tag.toLowerCase().contains(tag.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (search != null && search.isNotEmpty) {
|
||||
final searchLower = search.toLowerCase();
|
||||
return entry.message.toLowerCase().contains(searchLower) ||
|
||||
entry.tag.toLowerCase().contains(searchLower) ||
|
||||
(entry.error?.toLowerCase().contains(searchLower) ?? false);
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom log output that writes to both console and buffer
|
||||
class BufferedOutput extends LogOutput {
|
||||
final String tag;
|
||||
|
||||
BufferedOutput(this.tag);
|
||||
|
||||
@override
|
||||
void output(OutputEvent event) {
|
||||
// Print to console in debug mode
|
||||
if (kDebugMode) {
|
||||
for (final line in event.lines) {
|
||||
debugPrint(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Add to buffer
|
||||
final level = _levelToString(event.level);
|
||||
final message = event.lines.join('\n');
|
||||
|
||||
LogBuffer().add(LogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
level: level,
|
||||
tag: tag,
|
||||
message: message,
|
||||
));
|
||||
}
|
||||
|
||||
String _levelToString(Level level) {
|
||||
switch (level) {
|
||||
case Level.debug:
|
||||
return 'DEBUG';
|
||||
case Level.info:
|
||||
return 'INFO';
|
||||
case Level.warning:
|
||||
return 'WARN';
|
||||
case Level.error:
|
||||
return 'ERROR';
|
||||
case Level.fatal:
|
||||
return 'FATAL';
|
||||
default:
|
||||
return 'LOG';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Global logger instance for the app
|
||||
/// Uses pretty printer in debug mode for readable output
|
||||
final log = Logger(
|
||||
printer: PrettyPrinter(
|
||||
methodCount: 0,
|
||||
@@ -15,14 +241,48 @@ final log = Logger(
|
||||
);
|
||||
|
||||
/// Logger with class/tag prefix for better traceability
|
||||
/// Now also writes to LogBuffer for in-app viewing
|
||||
class AppLogger {
|
||||
final String _tag;
|
||||
|
||||
AppLogger(this._tag);
|
||||
|
||||
void d(String message) => log.d('[$_tag] $message');
|
||||
void i(String message) => log.i('[$_tag] $message');
|
||||
void w(String message) => log.w('[$_tag] $message');
|
||||
void e(String message, [Object? error, StackTrace? stackTrace]) =>
|
||||
log.e('[$_tag] $message', error: error, stackTrace: stackTrace);
|
||||
late final Logger _logger;
|
||||
|
||||
AppLogger(this._tag) {
|
||||
_logger = Logger(
|
||||
printer: SimplePrinter(printTime: false, colors: false),
|
||||
output: BufferedOutput(_tag),
|
||||
level: Level.debug,
|
||||
);
|
||||
}
|
||||
|
||||
void d(String message) {
|
||||
_logger.d(message);
|
||||
}
|
||||
|
||||
void i(String message) {
|
||||
_logger.i(message);
|
||||
}
|
||||
|
||||
void w(String message) {
|
||||
_logger.w(message);
|
||||
}
|
||||
|
||||
void e(String message, [Object? error, StackTrace? stackTrace]) {
|
||||
if (error != null) {
|
||||
LogBuffer().add(LogEntry(
|
||||
timestamp: DateTime.now(),
|
||||
level: 'ERROR',
|
||||
tag: _tag,
|
||||
message: message,
|
||||
error: error.toString(),
|
||||
));
|
||||
if (kDebugMode) {
|
||||
debugPrint('[$_tag] ERROR: $message | $error');
|
||||
if (stackTrace != null) {
|
||||
debugPrint(stackTrace.toString());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_logger.e(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user