v2.2.5: In-app logging, ISP blocking detection, Latin script fix

This commit is contained in:
zarzet
2026-01-10 19:03:39 +07:00
parent f12c18d76b
commit 11e7034cec
25 changed files with 2327 additions and 185 deletions
+2 -2
View File
@@ -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';
+4
View File
@@ -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,
);
}
+2
View File
@@ -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,
};
+8 -7
View File
@@ -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;
+11
View File
@@ -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>(
+24 -4
View File
@@ -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)),
),
),
+11 -1
View File
@@ -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),
),
+801
View File
@@ -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)),
],
),
+8 -1
View File
@@ -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',
+64 -2
View File
@@ -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
View File
@@ -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);
}
}
}