diff --git a/assets/preview/library.png b/assets/preview/library.png deleted file mode 100755 index d11bc60..0000000 Binary files a/assets/preview/library.png and /dev/null differ diff --git a/assets/preview/library_detail.png b/assets/preview/library_detail.png new file mode 100644 index 0000000..e7fa575 Binary files /dev/null and b/assets/preview/library_detail.png differ diff --git a/assets/preview/library_grid.png b/assets/preview/library_grid.png new file mode 100644 index 0000000..a01b816 Binary files /dev/null and b/assets/preview/library_grid.png differ diff --git a/assets/preview/playing.png b/assets/preview/playing.png old mode 100755 new mode 100644 index 6fafcd2..d7cb91f Binary files a/assets/preview/playing.png and b/assets/preview/playing.png differ diff --git a/changes.md b/changes.md index 0aaaa3d..fbd6c1d 100755 --- a/changes.md +++ b/changes.md @@ -10,18 +10,21 @@ This project loosely follows Keep a Changelog and uses Semantic Versioning. ### Added -- Find button discord RPC now links to YouTube Music search -- Added irclib fallback for lyrics +- [Backend-DiscordRPC] Find button discord RPC now links to YouTube Music search +- [Frontend-Lyrics] Irclib fallback for lyrics +- [Frontend-Albums] Albums screen - [Android] Storage permissions handler - [Android] URI path resolution - [Android] Folder manager access on mobile ### Fixed -- Call backend only on drag end to prevent seek throttle -- All button now should has pointer now -- Discord button label overflow -- Added helpers to prevent backend crash +- [Frontend] Call backend only on drag end to prevent seek throttle +- [Frontend] All button now should has pointer now +- [Backend-DiscordRPC] Discord button label overflow +- [Backend-Audio] Added helpers to prevent backend crash +- [Frontend-DiscordRPC] Validate activity fields and reconnect after error +- [Frontend-DiscordRPC] Sanitize album field sent as large_text - [Android] Library scan empty - [Android] Status bar overlap - [Android] window_manager crash on Android diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart new file mode 100644 index 0000000..9454415 --- /dev/null +++ b/lib/screens/album_screen.dart @@ -0,0 +1,1150 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:aqloss/models/track.dart'; +import 'package:aqloss/providers/library_provider.dart'; +import 'package:aqloss/providers/player_provider.dart'; +import 'package:aqloss/widgets/now_playing_header.dart'; +import 'package:aqloss/src/rust/api.dart' as backend; + +// Helpers +bool get _isDesktop => + Platform.isWindows || Platform.isLinux || Platform.isMacOS; + +String _fmtDuration(Duration d) { + final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); + final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); + return '$m:$s'; +} + +// Album model +class _Album { + final String name; + final String artist; + final List tracks; + + const _Album({ + required this.name, + required this.artist, + required this.tracks, + }); + + Duration get totalDuration => + tracks.fold(Duration.zero, (s, t) => s + t.duration); + + String get durationLabel { + final d = totalDuration; + if (d.inHours > 0) return '${d.inHours}h ${d.inMinutes.remainder(60)}m'; + return '${d.inMinutes}m'; + } +} + +List<_Album> _groupAlbums(List tracks) { + final map = >{}; + for (final t in tracks) { + final key = '${t.album ?? ''}|||${t.albumArtist ?? t.artist ?? ''}'; + map.putIfAbsent(key, () => []).add(t); + } + return map.entries.map((e) { + final parts = e.key.split('|||'); + final sorted = e.value + ..sort((a, b) { + final tn = (a.trackNumber ?? 0).compareTo(b.trackNumber ?? 0); + return tn != 0 ? tn : a.displayTitle.compareTo(b.displayTitle); + }); + return _Album( + name: parts[0].isEmpty ? 'Unknown Album' : parts[0], + artist: parts[1].isEmpty ? 'Unknown Artist' : parts[1], + tracks: sorted, + ); + }).toList() + ..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); +} + +// Albums screen +class AlbumsScreen extends ConsumerStatefulWidget { + const AlbumsScreen({super.key}); + + @override + ConsumerState createState() => _AlbumsScreenState(); +} + +class _AlbumsScreenState extends ConsumerState { + final _searchCtrl = TextEditingController(); + final _focusNode = FocusNode(); + String _query = ''; + + @override + void dispose() { + _searchCtrl.dispose(); + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final library = ref.watch(libraryProvider); + final cs = Theme.of(context).colorScheme; + + final allAlbums = _groupAlbums(library.tracks); + final albums = _query.isEmpty + ? allAlbums + : allAlbums + .where( + (a) => + a.name.toLowerCase().contains(_query.toLowerCase()) || + a.artist.toLowerCase().contains(_query.toLowerCase()), + ) + .toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const NowPlayingHeader(), + + // Search + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 0), + child: _SearchBar( + controller: _searchCtrl, + focusNode: _focusNode, + onChanged: (q) => setState(() => _query = q), + onClear: () { + _searchCtrl.clear(); + setState(() => _query = ''); + }, + ), + ), + + // Stats + if (allAlbums.isNotEmpty) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Text( + _query.isEmpty + ? '${allAlbums.length} albums' + : '${albums.length} of ${allAlbums.length} albums', + style: TextStyle( + fontSize: 11, + color: cs.onSurface.withValues(alpha: 0.28), + ), + ), + ), + + const SizedBox(height: 6), + + Expanded( + child: library.tracks.isEmpty + ? const _EmptyState() + : albums.isEmpty + ? Center( + child: Text( + 'No results', + style: TextStyle( + fontSize: 12, + color: cs.onSurface.withValues(alpha: 0.24), + ), + ), + ) + : _AlbumGrid( + albums: albums, + onTap: (album) => Navigator.of( + context, + ).push(_fadeRoute(_AlbumDetailScreen(album: album))), + ), + ), + ], + ); + } +} + +// Fade page route +PageRoute _fadeRoute(Widget page) => PageRouteBuilder( + pageBuilder: (_, _, _) => page, + transitionDuration: const Duration(milliseconds: 250), + reverseTransitionDuration: const Duration(milliseconds: 200), + transitionsBuilder: (_, anim, _, child) => FadeTransition( + opacity: CurvedAnimation(parent: anim, curve: Curves.easeOut), + child: child, + ), +); + +// Search bar +class _SearchBar extends StatelessWidget { + final TextEditingController controller; + final FocusNode focusNode; + final ValueChanged onChanged; + final VoidCallback onClear; + + const _SearchBar({ + required this.controller, + required this.focusNode, + required this.onChanged, + required this.onClear, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return GestureDetector( + onTap: focusNode.requestFocus, + child: Container( + height: 36, + decoration: BoxDecoration( + color: cs.onSurface.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: cs.onSurface.withValues(alpha: 0.08)), + ), + child: Row( + children: [ + const SizedBox(width: 10), + Icon( + Icons.search_rounded, + size: 15, + color: cs.onSurface.withValues(alpha: 0.28), + ), + const SizedBox(width: 8), + Expanded( + child: EditableText( + controller: controller, + focusNode: focusNode, + onChanged: onChanged, + style: TextStyle(color: cs.onSurface, fontSize: 13), + cursorColor: cs.onSurface.withValues(alpha: 0.55), + backgroundCursorColor: Colors.transparent, + cursorWidth: 1.2, + cursorRadius: const Radius.circular(1), + selectionColor: cs.onSurface.withValues(alpha: 0.14), + ), + ), + if (controller.text.isNotEmpty) + GestureDetector( + onTap: onClear, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Icon( + Icons.close_rounded, + size: 13, + color: cs.onSurface.withValues(alpha: 0.28), + ), + ), + ), + ], + ), + ), + ); + } +} + +// Album grid +class _AlbumGrid extends StatelessWidget { + final List<_Album> albums; + final void Function(_Album) onTap; + + const _AlbumGrid({required this.albums, required this.onTap}); + + @override + Widget build(BuildContext context) { + final cols = _isDesktop ? 6 : 3; + return GridView.builder( + padding: const EdgeInsets.fromLTRB(12, 2, 12, 24), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: cols, + mainAxisSpacing: 6, + crossAxisSpacing: 6, + childAspectRatio: 0.72, + ), + itemCount: albums.length, + itemBuilder: (_, i) => + _AlbumCard(album: albums[i], onTap: () => onTap(albums[i])), + ); + } +} + +// Album card +class _AlbumCard extends StatefulWidget { + final _Album album; + final VoidCallback onTap; + + const _AlbumCard({required this.album, required this.onTap}); + + @override + State<_AlbumCard> createState() => _AlbumCardState(); +} + +class _AlbumCardState extends State<_AlbumCard> + with SingleTickerProviderStateMixin { + Uint8List? _art; + bool _artLoaded = false; + bool _pressed = false; + + // Controls hover overlay visibility + late final AnimationController _hoverCtrl; + late final Animation _hoverAnim; + + @override + void initState() { + super.initState(); + _hoverCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 160), + ); + _hoverAnim = CurvedAnimation(parent: _hoverCtrl, curve: Curves.easeOut); + _loadArt(); + } + + Future _loadArt() async { + try { + final bytes = await backend.readAlbumArtThumbnail( + path: widget.album.tracks.first.path, + ); + if (mounted) { + setState(() { + _art = bytes != null ? Uint8List.fromList(bytes) : null; + _artLoaded = true; + }); + } + } catch (_) { + if (mounted) setState(() => _artLoaded = true); + } + } + + @override + void dispose() { + _hoverCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => _hoverCtrl.forward(), + onExit: (_) => _hoverCtrl.reverse(), + child: GestureDetector( + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: widget.onTap, + child: AnimatedScale( + scale: _pressed ? 0.955 : 1.0, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Art square + Expanded( + child: Stack( + fit: StackFit.expand, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(9), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + child: _artLoaded + ? (_art != null + ? Image.memory( + _art!, + fit: BoxFit.cover, + key: const ValueKey('art'), + ) + : _PlaceholderArt( + key: const ValueKey('ph'), + isDark: isDark, + )) + : Container( + key: const ValueKey('loading'), + color: cs.onSurface.withValues(alpha: 0.05), + ), + ), + ), + + // Hover + ClipRRect( + borderRadius: BorderRadius.circular(9), + child: FadeTransition( + opacity: _hoverAnim, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.62), + ], + stops: const [0.38, 1.0], + ), + ), + child: Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: const EdgeInsets.all(8), + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.92), + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues( + alpha: 0.25, + ), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: const Icon( + Icons.play_arrow_rounded, + size: 18, + color: Colors.black87, + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + + // Info + const SizedBox(height: 7), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 1), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.album.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w500, + letterSpacing: -0.2, + color: cs.onSurface.withValues(alpha: 0.88), + height: 1.2, + ), + ), + const SizedBox(height: 2), + Text( + "${widget.album.artist} · ${widget.album.tracks.length} tracks", + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10, + color: cs.onSurface.withValues(alpha: 0.36), + ), + ), + ], + ), + ), + const SizedBox(height: 6), + ], + ), + ), + ), + ); + } +} + +// Placeholder art +class _PlaceholderArt extends StatelessWidget { + final bool isDark; + + const _PlaceholderArt({super.key, required this.isDark}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + color: cs.onSurface.withValues(alpha: isDark ? 0.06 : 0.04), + child: Center( + child: Icon( + Icons.album_rounded, + size: 28, + color: cs.onSurface.withValues(alpha: 0.10), + ), + ), + ); + } +} + +// Empty state +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.album_outlined, + size: 36, + color: cs.onSurface.withValues(alpha: 0.10), + ), + const SizedBox(height: 14), + Text( + 'No albums yet', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w300, + color: cs.onSurface.withValues(alpha: 0.36), + ), + ), + const SizedBox(height: 5), + Text( + 'Add music from the Library tab', + style: TextStyle( + fontSize: 11, + color: cs.onSurface.withValues(alpha: 0.18), + ), + ), + ], + ), + ); + } +} + +// ── Album detail screen ─────────────────────────────────────────────────────── + +class _AlbumDetailScreen extends ConsumerStatefulWidget { + final _Album album; + + const _AlbumDetailScreen({required this.album}); + + @override + ConsumerState<_AlbumDetailScreen> createState() => _AlbumDetailScreenState(); +} + +class _AlbumDetailScreenState extends ConsumerState<_AlbumDetailScreen> + with SingleTickerProviderStateMixin { + Uint8List? _art; + bool _artLoaded = false; + final _scrollCtrl = ScrollController(); + + late final AnimationController _enterCtrl; + late final Animation _enterAnim; + + @override + void initState() { + super.initState(); + _enterCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 340), + )..forward(); + _enterAnim = CurvedAnimation( + parent: _enterCtrl, + curve: Curves.easeOutCubic, + ); + _loadArt(); + } + + Future _loadArt() async { + try { + final bytes = await backend.readAlbumArt( + path: widget.album.tracks.first.path, + ); + if (mounted) { + setState(() { + _art = bytes != null ? Uint8List.fromList(bytes) : null; + _artLoaded = true; + }); + } + } catch (_) { + if (mounted) setState(() => _artLoaded = true); + } + } + + @override + void dispose() { + _enterCtrl.dispose(); + _scrollCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final player = ref.watch(playerProvider); + final playerNotifier = ref.read(playerProvider.notifier); + final album = widget.album; + final hPad = _isDesktop ? 24.0 : 14.0; + final artSize = _isDesktop ? 148.0 : 110.0; + + return Scaffold( + body: FadeTransition( + opacity: _enterAnim, + child: CustomScrollView( + controller: _scrollCtrl, + slivers: [ + // Header + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB( + hPad, + _isDesktop ? 20 : 12, + hPad, + 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _BackButton( + onTap: () => Navigator.of(context).pop(), + label: 'Albums', + ), + SizedBox(height: _isDesktop ? 16 : 12), + + // Art + metadata + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _DetailArt( + art: _art, + artLoaded: _artLoaded, + size: artSize, + ), + SizedBox(width: _isDesktop ? 20 : 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + album.name, + style: TextStyle( + fontSize: _isDesktop ? 22 : 17, + fontWeight: FontWeight.w600, + letterSpacing: -0.5, + color: cs.onSurface, + height: 1.15, + ), + ), + const SizedBox(height: 5), + Text( + album.artist, + style: TextStyle( + fontSize: 12, + color: cs.onSurface.withValues(alpha: 0.46), + letterSpacing: -0.1, + ), + ), + const SizedBox(height: 4), + Text( + '${album.tracks.length} tracks · ${album.durationLabel}', + style: TextStyle( + fontSize: 10.5, + color: cs.onSurface.withValues(alpha: 0.26), + ), + ), + SizedBox(height: _isDesktop ? 14 : 10), + Row( + children: [ + _ActionButton( + icon: Icons.play_arrow_rounded, + label: 'Play', + filled: true, + onTap: () => playerNotifier.loadWithQueue( + album.tracks.first, + album.tracks, + ), + ), + const SizedBox(width: 7), + _ActionButton( + icon: Icons.shuffle_rounded, + label: 'Shuffle', + filled: false, + onTap: () { + final shuffled = List.from( + album.tracks, + )..shuffle(); + playerNotifier.loadWithQueue( + shuffled.first, + shuffled, + ); + }, + ), + ], + ), + ], + ), + ), + ], + ), + + SizedBox(height: _isDesktop ? 22 : 16), + Container( + height: 1, + color: cs.onSurface.withValues(alpha: 0.06), + ), + ], + ), + ), + ), + + // Column headers + SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.fromLTRB(hPad, 10, hPad, 2), + child: Row( + children: [ + SizedBox( + width: 32, + child: Text( + '#', + style: TextStyle( + fontSize: 10, + color: cs.onSurface.withValues(alpha: 0.22), + letterSpacing: 0.4, + ), + ), + ), + Expanded( + child: Text( + 'TITLE', + style: TextStyle( + fontSize: 10, + letterSpacing: 0.6, + fontWeight: FontWeight.w600, + color: cs.onSurface.withValues(alpha: 0.22), + ), + ), + ), + Text( + 'TIME', + style: TextStyle( + fontSize: 10, + letterSpacing: 0.6, + fontWeight: FontWeight.w600, + color: cs.onSurface.withValues(alpha: 0.22), + ), + ), + SizedBox(width: hPad), + ], + ), + ), + ), + + // Track list + SliverList( + delegate: SliverChildBuilderDelegate((_, i) { + final track = album.tracks[i]; + final isActive = + player.status == PlayerStatus.playing && + player.currentTrack?.path == track.path; + return _DetailTrackRow( + track: track, + index: i, + isActive: isActive, + hPad: hPad, + onTap: () => + playerNotifier.loadWithQueue(track, album.tracks), + ); + }, childCount: album.tracks.length), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 48)), + ], + ), + ), + ); + } +} + +// Back button +class _BackButton extends StatefulWidget { + final VoidCallback onTap; + final String label; + + const _BackButton({required this.onTap, required this.label}); + + @override + State<_BackButton> createState() => _BackButtonState(); +} + +class _BackButtonState extends State<_BackButton> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 110), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + decoration: BoxDecoration( + color: _hovered + ? cs.onSurface.withValues(alpha: 0.06) + : Colors.transparent, + borderRadius: BorderRadius.circular(7), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.chevron_left_rounded, + size: 16, + color: cs.onSurface.withValues(alpha: 0.45), + ), + const SizedBox(width: 3), + Text( + widget.label, + style: TextStyle( + fontSize: 12, + color: cs.onSurface.withValues(alpha: 0.45), + ), + ), + ], + ), + ), + ), + ); + } +} + +// Detail art +class _DetailArt extends StatelessWidget { + final Uint8List? art; + final bool artLoaded; + final double size; + + const _DetailArt({ + required this.art, + required this.artLoaded, + required this.size, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Container( + width: size, + height: size, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.28), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: artLoaded + ? (art != null + ? Image.memory( + art!, + fit: BoxFit.cover, + key: const ValueKey('art'), + ) + : Container( + key: const ValueKey('ph'), + color: cs.onSurface.withValues(alpha: 0.06), + child: Icon( + Icons.album_rounded, + size: size * 0.36, + color: cs.onSurface.withValues(alpha: 0.12), + ), + )) + : Container( + key: const ValueKey('loading'), + color: cs.onSurface.withValues(alpha: 0.04), + ), + ), + ), + ); + } +} + +// Action button +class _ActionButton extends StatefulWidget { + final IconData icon; + final String label; + final bool filled; + final VoidCallback onTap; + + const _ActionButton({ + required this.icon, + required this.label, + required this.filled, + required this.onTap, + }); + + @override + State<_ActionButton> createState() => _ActionButtonState(); +} + +class _ActionButtonState extends State<_ActionButton> { + bool _hovered = false; + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final bgAlpha = widget.filled + ? (_hovered ? 0.14 : 0.09) + : (_hovered ? 0.06 : 0.0); + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTapDown: (_) => setState(() => _pressed = true), + onTapUp: (_) => setState(() => _pressed = false), + onTapCancel: () => setState(() => _pressed = false), + onTap: widget.onTap, + child: AnimatedScale( + scale: _pressed ? 0.95 : 1.0, + duration: const Duration(milliseconds: 110), + curve: Curves.easeOut, + child: AnimatedContainer( + duration: const Duration(milliseconds: 110), + padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 7), + decoration: BoxDecoration( + color: cs.onSurface.withValues(alpha: bgAlpha), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: cs.onSurface.withValues( + alpha: widget.filled ? 0.14 : 0.10, + ), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + widget.icon, + size: 14, + color: cs.onSurface.withValues(alpha: 0.68), + ), + const SizedBox(width: 5), + Text( + widget.label, + style: TextStyle( + fontSize: 11.5, + fontWeight: FontWeight.w500, + color: cs.onSurface.withValues(alpha: 0.68), + letterSpacing: -0.1, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +// Detail track row +class _DetailTrackRow extends StatefulWidget { + final Track track; + final int index; + final bool isActive; + final double hPad; + final VoidCallback onTap; + + const _DetailTrackRow({ + required this.track, + required this.index, + required this.isActive, + required this.hPad, + required this.onTap, + }); + + @override + State<_DetailTrackRow> createState() => _DetailTrackRowState(); +} + +class _DetailTrackRowState extends State<_DetailTrackRow> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final isActive = widget.isActive; + + return MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: GestureDetector( + onTap: widget.onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 110), + color: _hovered + ? cs.onSurface.withValues(alpha: 0.035) + : Colors.transparent, + padding: EdgeInsets.symmetric(horizontal: widget.hPad, vertical: 9), + child: Row( + children: [ + // Track number or playing indicator + SizedBox( + width: 32, + child: isActive + ? const _PlayingBars() + : Text( + '${widget.index + 1}', + style: TextStyle( + fontSize: 11, + color: cs.onSurface.withValues( + alpha: _hovered ? 0.0 : 0.22, + ), + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + + // Title + guest artist + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.track.displayTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12.5, + fontWeight: isActive + ? FontWeight.w500 + : FontWeight.w400, + letterSpacing: -0.1, + color: isActive + ? cs.onSurface + : cs.onSurface.withValues(alpha: 0.82), + ), + ), + if (widget.track.artist != null && + widget.track.artist != widget.track.albumArtist) ...[ + const SizedBox(height: 1), + Text( + widget.track.displayArtist, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 10.5, + color: cs.onSurface.withValues(alpha: 0.32), + ), + ), + ], + ], + ), + ), + + const SizedBox(width: 12), + + // Duration + Text( + _fmtDuration(widget.track.duration), + style: TextStyle( + fontSize: 11, + color: cs.onSurface.withValues(alpha: 0.26), + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + ), + ), + ); + } +} + +// Animated playing bars +class _PlayingBars extends StatefulWidget { + const _PlayingBars(); + + @override + State<_PlayingBars> createState() => _PlayingBarsState(); +} + +class _PlayingBarsState extends State<_PlayingBars> + with TickerProviderStateMixin { + late final List _ctrls; + late final List> _anims; + + // Base heights and stagger delays per bar + static const List _baseH = [0.50, 0.80, 0.60]; + static const List _delaysMs = [0, 160, 80]; + + @override + void initState() { + super.initState(); + _ctrls = List.generate(3, (i) { + final c = AnimationController( + vsync: this, + duration: Duration(milliseconds: 420 + i * 80), + ); + Future.delayed(Duration(milliseconds: _delaysMs[i]), () { + if (mounted) c.repeat(reverse: true); + }); + return c; + }); + _anims = List.generate( + 3, + (i) => Tween( + begin: _baseH[i] * 0.25, + end: _baseH[i], + ).animate(CurvedAnimation(parent: _ctrls[i], curve: Curves.easeInOut)), + ); + } + + @override + void dispose() { + for (final c in _ctrls) { + c.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return SizedBox( + width: 14, + height: 14, + child: AnimatedBuilder( + animation: Listenable.merge(_ctrls), + builder: (_, _) => Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: List.generate(3, (i) { + return Container( + width: 2.5, + height: 14 * _anims[i].value, + margin: const EdgeInsets.symmetric(horizontal: 0.8), + decoration: BoxDecoration( + color: cs.onSurface.withValues(alpha: 0.70), + borderRadius: BorderRadius.circular(1.5), + ), + ); + }), + ), + ), + ); + } +} diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index eff798a..2e80102 100755 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -6,6 +6,7 @@ import 'package:window_manager/window_manager.dart'; import 'package:file_picker/file_picker.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:aqloss/util/android_path_helper.dart'; +import 'album_screen.dart'; import 'library_screen.dart'; import 'player_screen.dart'; import 'settings_screen.dart'; @@ -73,7 +74,8 @@ class _HomeScreenState extends ConsumerState with WindowListener { Widget _buildScreen() { if (_route == 0) return const PlayerScreen(); if (_route == 1) return const LibraryScreen(); - if (_route == 2) return const SettingsScreen(); + if (_route == 2) return const AlbumsScreen(); + if (_route == 3) return const SettingsScreen(); final playlists = ref.read(playlistProvider); final idx = _route - 10; @@ -101,6 +103,10 @@ class _HomeScreenState extends ConsumerState with WindowListener { setState(() => _route = 2); return KeyEventResult.handled; } + if (ctrl && event.logicalKey == LogicalKeyboardKey.digit4) { + setState(() => _route = 3); + return KeyEventResult.handled; + } if (ctrl && event.logicalKey == LogicalKeyboardKey.keyB) { _toggleSidebar(); return KeyEventResult.handled; @@ -208,7 +214,7 @@ class _HomeScreenState extends ConsumerState with WindowListener { bottomNavigationBar: isWide ? null : _MobileNavBar( - selectedIndex: _route.clamp(0, 2), + selectedIndex: _route.clamp(0, 3), onDestinationSelected: (i) => setState(() => _route = i), hasTrack: hasTrack && _route != 0, onMiniPlayerTap: () => setState(() => _route = 0), @@ -263,12 +269,19 @@ class _MobileNavBar extends StatelessWidget { isSelected: selectedIndex == 1, onTap: () => onDestinationSelected(1), ), + _NavTab( + icon: Icons.album_outlined, + activeIcon: Icons.album_rounded, + label: 'Albums', + isSelected: selectedIndex == 2, + onTap: () => onDestinationSelected(2), + ), _NavTab( icon: Icons.tune_outlined, activeIcon: Icons.tune_rounded, label: 'Settings', - isSelected: selectedIndex == 2, - onTap: () => onDestinationSelected(2), + isSelected: selectedIndex == 3, + onTap: () => onDestinationSelected(3), ), ], ), @@ -483,6 +496,16 @@ class _SideNavState extends ConsumerState<_SideNav> onTap: () => widget.onSelect(1), ), + // Albums + _NavItem( + icon: Icons.album_outlined, + activeIcon: Icons.album_rounded, + label: 'Albums', + isActive: widget.route == 2, + collapsed: collapsed, + onTap: () => widget.onSelect(2), + ), + if (!collapsed) _SectionLabel('LIBRARY') else @@ -650,9 +673,9 @@ class _SideNavState extends ConsumerState<_SideNav> icon: Icons.settings_outlined, activeIcon: Icons.settings_rounded, label: 'Settings', - isActive: widget.route == 2, + isActive: widget.route == 3, collapsed: collapsed, - onTap: () => widget.onSelect(2), + onTap: () => widget.onSelect(3), ), const SizedBox(height: 6), @@ -728,6 +751,16 @@ class _SideNavState extends ConsumerState<_SideNav> onTap: () => widget.onSelect(1), ), + // Albums + _NavItem( + icon: Icons.album_outlined, + activeIcon: Icons.album_rounded, + label: 'Albums', + isActive: widget.route == 2, + collapsed: collapsed, + onTap: () => widget.onSelect(2), + ), + if (!collapsed) _SectionLabel('LIBRARY') else @@ -895,9 +928,9 @@ class _SideNavState extends ConsumerState<_SideNav> icon: Icons.settings_outlined, activeIcon: Icons.settings_rounded, label: 'Settings', - isActive: widget.route == 2, + isActive: widget.route == 3, collapsed: collapsed, - onTap: () => widget.onSelect(2), + onTap: () => widget.onSelect(3), ), const SizedBox(height: 6), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 0087180..6b41bb4 100755 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:aqloss/widgets/q_spinner.dart'; import 'package:aqloss/widgets/eq_panel.dart'; import 'package:aqloss/widgets/lastfm_auth_row.dart'; @@ -32,6 +34,8 @@ class _SettingsScreenState extends ConsumerState { final cs = Theme.of(context).colorScheme; final narrow = MediaQuery.of(context).size.width < 600; final hPad = narrow ? 20.0 : 32.0; + final isDesktop = + Platform.isWindows || Platform.isLinux || Platform.isMacOS; return CustomScrollView( slivers: [ @@ -268,35 +272,7 @@ class _SettingsScreenState extends ConsumerState { _gap(narrow), // Keyboard shortcuts - _SectionHeader( - icon: Icons.keyboard_outlined, - title: 'Keyboard Shortcuts', - ), - const SizedBox(height: 10), - _SettingsCard( - children: [ - _ShortcutRow(label: 'Play / Pause', shortcut: 'Space'), - _Div(), - _ShortcutRow(label: 'Previous track', shortcut: 'Ctrl ←'), - _Div(), - _ShortcutRow(label: 'Next track', shortcut: 'Ctrl →'), - _Div(), - _ShortcutRow(label: 'Volume up 5%', shortcut: 'Ctrl ↑'), - _Div(), - _ShortcutRow(label: 'Volume down 5%', shortcut: 'Ctrl ↓'), - _Div(), - _ShortcutRow(label: 'Toggle sidebar', shortcut: 'Ctrl B'), - _Div(), - _ShortcutRow(label: 'Now Playing', shortcut: 'Ctrl 1'), - _Div(), - _ShortcutRow(label: 'Library', shortcut: 'Ctrl 2'), - _Div(), - _ShortcutRow(label: 'Settings', shortcut: 'Ctrl 3'), - _Div(), - _ShortcutRow(label: 'New playlist', shortcut: 'Ctrl N'), - ], - ), - _gap(narrow), + if (isDesktop) _ShortcutContent(narrow: narrow), // About _SectionHeader(icon: Icons.info_outline_rounded, title: 'About'), @@ -1138,6 +1114,50 @@ class _RangeSliderState extends State<_RangeSlider> { } } +class _ShortcutContent extends StatelessWidget { + final bool narrow; + const _ShortcutContent({required this.narrow}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _SectionHeader( + icon: Icons.keyboard_outlined, + title: 'Keyboard Shortcuts', + ), + const SizedBox(height: 10), + _SettingsCard( + children: [ + _ShortcutRow(label: 'Play / Pause', shortcut: 'Space'), + _Div(), + _ShortcutRow(label: 'Previous track', shortcut: 'Ctrl ←'), + _Div(), + _ShortcutRow(label: 'Next track', shortcut: 'Ctrl →'), + _Div(), + _ShortcutRow(label: 'Volume up 5%', shortcut: 'Ctrl ↑'), + _Div(), + _ShortcutRow(label: 'Volume down 5%', shortcut: 'Ctrl ↓'), + _Div(), + _ShortcutRow(label: 'Toggle sidebar', shortcut: 'Ctrl B'), + _Div(), + _ShortcutRow(label: 'Now Playing', shortcut: 'Ctrl 1'), + _Div(), + _ShortcutRow(label: 'Library', shortcut: 'Ctrl 2'), + _Div(), + _ShortcutRow(label: 'Albums', shortcut: 'Ctrl 3'), + _Div(), + _ShortcutRow(label: 'Settings', shortcut: 'Ctrl 4'), + _Div(), + _ShortcutRow(label: 'New playlist', shortcut: 'Ctrl N'), + ], + ), + SizedBox(height: narrow ? 28 : 36), + ], + ); + } +} + class _ShortcutRow extends StatelessWidget { final String label, shortcut; const _ShortcutRow({required this.label, required this.shortcut}); diff --git a/lib/services/discord_service.dart b/lib/services/discord_service.dart index f2c4746..89fba70 100755 --- a/lib/services/discord_service.dart +++ b/lib/services/discord_service.dart @@ -8,15 +8,36 @@ import 'package:http/http.dart' as http; class DiscordService { static bool _enabled = true; static Timer? _refreshTimer; + static Timer? _reconnectTimer; static final Map _artCache = {}; static final Map _onlineArtCache = {}; static String _lastFingerprint = ''; + static bool _discordErrored = false; + static int _reconnectAttempts = 0; + static const int _maxReconnectAttempts = 5; + static PlayerState? _pendingState; + static double? _pendingPositionSecs; + static bool get enabled => _enabled; static set enabled(bool v) { _enabled = v; if (!v) clear(); } + static String _sanitize(String s, {String fallback = '-'}) { + final trimmed = s.trim(); + if (trimmed.length >= 2) return trimmed; + if (trimmed.isEmpty) return fallback; + return '$trimmed '; + } + + static String _sanitizeAlbum(String? s) { + final trimmed = (s ?? '').trim(); + if (trimmed.isEmpty) return ''; + if (trimmed.length >= 2) return trimmed; + return '$trimmed '; + } + static void update(PlayerState state, {double? positionSecs}) { if (!_enabled) return; @@ -26,11 +47,18 @@ class DiscordService { return; } - final title = track.title ?? track.path.split(RegExp(r'[/\\]')).last; - final artist = track.artist ?? 'Unknown Artist'; - final album = track.album ?? ''; + final title = _sanitize( + track.title ?? track.path.split(RegExp(r'[/\\]')).last, + ); + final artist = _sanitize(track.artist ?? 'Unknown Artist'); + final album = _sanitizeAlbum(track.album); final durSec = track.duration.inMilliseconds / 1000.0; + _pendingState = state; + _pendingPositionSecs = positionSecs; + + if (_discordErrored) return; + switch (state.status) { case PlayerStatus.playing: final posSec = positionSecs ?? state.position.inMilliseconds / 1000.0; @@ -43,7 +71,7 @@ class DiscordService { _sendPlayingWithArt(track.path, title, artist, album, posSec, durSec); _refreshTimer = Timer.periodic(const Duration(seconds: 15), (_) { - if (!_enabled) return; + if (!_enabled || _discordErrored) return; backend .getPosition() .then((pos) { @@ -82,12 +110,18 @@ class DiscordService { static void clear() { _cancelRefresh(); + _cancelReconnect(); _lastFingerprint = ''; + _pendingState = null; + _pendingPositionSecs = null; + _discordErrored = false; + _reconnectAttempts = 0; backend.discordClear().catchError((_) {}); } static void dispose() { _cancelRefresh(); + _cancelReconnect(); } static void _cancelRefresh() { @@ -95,15 +129,55 @@ class DiscordService { _refreshTimer = null; } + static void _cancelReconnect() { + _reconnectTimer?.cancel(); + _reconnectTimer = null; + } + + static void _onDiscordError(Object error) { + error.toString(); + if (!_discordErrored) { + _discordErrored = true; + _reconnectAttempts = 0; + _cancelRefresh(); + _lastFingerprint = ''; + _scheduleReconnect(); + } + } + + static void _scheduleReconnect() { + if (_reconnectAttempts >= _maxReconnectAttempts) { + _discordErrored = false; + _reconnectAttempts = 0; + return; + } + + final delaySecs = 1 << (_reconnectAttempts + 1); + _reconnectTimer = Timer(Duration(seconds: delaySecs), () async { + _reconnectAttempts++; + try { + await backend.discordClear(); + _discordErrored = false; + _reconnectAttempts = 0; + + final pending = _pendingState; + final pendingPos = _pendingPositionSecs; + if (pending != null && _enabled) { + update(pending, positionSecs: pendingPos); + } + } catch (_) { + _scheduleReconnect(); + } + }); + } + static Future _resolveArtUrl( String filePath, String artist, String album, ) async { - // Check cache if (_artCache.containsKey(filePath)) return _artCache[filePath]!; - // Try to upload embedded art try { final artBytes = await backend.readAlbumArt(path: filePath); if (artBytes != null && artBytes.isNotEmpty) { @@ -115,7 +189,6 @@ class DiscordService { } } catch (_) {} - // Fallback to online search final cacheKey = '${artist.toLowerCase()}||${album.toLowerCase()}'; if (_onlineArtCache.containsKey(cacheKey)) { final url = _onlineArtCache[cacheKey]!; @@ -129,7 +202,6 @@ class DiscordService { return onlineUrl; } - // Upload image to catbox static Future _uploadImageBytes(Uint8List bytes) async { try { final request = http.MultipartRequest( @@ -151,7 +223,6 @@ class DiscordService { } } catch (_) {} - // Fallback to 0x0.st try { final request = http.MultipartRequest( 'POST', @@ -203,7 +274,7 @@ class DiscordService { album: album, albumArtUrl: artUrl, ) - .catchError((_) {}); + .catchError(_onDiscordError); }); } @@ -224,7 +295,7 @@ class DiscordService { positionSecs: positionSecs, durationSecs: durationSecs, ) - .catchError((_) {}); + .catchError(_onDiscordError); } static Future _fetchItunesArtUrl(String artist, String album) async { diff --git a/linux/xyz.nokarin.aqloss.metainfo.xml b/linux/xyz.nokarin.aqloss.metainfo.xml index 4113e8d..511163d 100755 --- a/linux/xyz.nokarin.aqloss.metainfo.xml +++ b/linux/xyz.nokarin.aqloss.metainfo.xml @@ -40,12 +40,16 @@ - https://raw.githubusercontent.com/nokarin-dev/Aqloss/9983f4250b97936e7fe6dca50a41ac7e270d9419/assets/preview/playing.png + https://raw.githubusercontent.com/nokarin-dev/Aqloss/60c72bc0900bc1cd415732399ffa5c90be5aa2f4/assets/preview/playing.png Playing - https://raw.githubusercontent.com/nokarin-dev/Aqloss/9983f4250b97936e7fe6dca50a41ac7e270d9419/assets/preview/library.png - Library + https://raw.githubusercontent.com/nokarin-dev/Aqloss/60c72bc0900bc1cd415732399ffa5c90be5aa2f4/assets/preview/library_detail.png + Library - Detail View + + + https://raw.githubusercontent.com/nokarin-dev/Aqloss/60c72bc0900bc1cd415732399ffa5c90be5aa2f4/assets/preview/library_grid.png + Library - Grid View @@ -60,6 +64,7 @@
  • All button now should has pointer now
  • Discord button label overflow
  • Added helpers to prevent backend crash
  • +
  • Added albums screen