init
This commit is contained in:
commit
d205277cdd
182 changed files with 22978 additions and 0 deletions
48
lib/widgets/block_progress_bar.dart
Normal file
48
lib/widgets/block_progress_bar.dart
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
/// The segmented block progress bar from Ratune's now-playing strip
|
||||
/// (`progress_style = "██░"`). Discrete cells: filled cells use the accent,
|
||||
/// empty cells the border grey, with hairline gaps between them.
|
||||
class BlockProgressBar extends StatelessWidget {
|
||||
const BlockProgressBar({
|
||||
super.key,
|
||||
required this.progress,
|
||||
this.cells = 40,
|
||||
this.height = 10,
|
||||
this.color,
|
||||
}) : assert(progress >= 0 && progress <= 1);
|
||||
|
||||
/// 0.0–1.0 elapsed fraction.
|
||||
final double progress;
|
||||
|
||||
/// Number of block cells to render.
|
||||
final int cells;
|
||||
|
||||
final double height;
|
||||
|
||||
/// Filled color; defaults to the theme accent.
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final filledColor = color ?? Theme.of(context).colorScheme.primary;
|
||||
final filled = (progress * cells).round();
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: Row(
|
||||
children: List.generate(cells, (i) {
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 0.5),
|
||||
child: ColoredBox(
|
||||
color: i < filled ? filledColor : RatuneColors.border,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
91
lib/widgets/hairline_panel.dart
Normal file
91
lib/widgets/hairline_panel.dart
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
/// A thin-stroked panel with its title label sitting *on* the top border —
|
||||
/// Ratune's signature container ("Album Art", "Queue (127)", "Lyrics",
|
||||
/// "Visualizer"). Recreated with the fieldset trick: draw a full 1px border,
|
||||
/// then overlay the title with a background that occludes the segment behind
|
||||
/// it, so the label appears to break the line.
|
||||
class HairlinePanel extends StatelessWidget {
|
||||
const HairlinePanel({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.child,
|
||||
this.trailing,
|
||||
this.active = false,
|
||||
this.padding = const EdgeInsets.all(RatuneSpacing.lg),
|
||||
this.backgroundColor = RatuneColors.background,
|
||||
});
|
||||
|
||||
/// Panel label, e.g. "Queue". Rendered uppercase-ish in mono.
|
||||
final String title;
|
||||
|
||||
/// Optional trailing bit of the label, e.g. "(127)" — dimmed.
|
||||
final String? trailing;
|
||||
|
||||
/// Panel content.
|
||||
final Widget child;
|
||||
|
||||
/// When focused, the border and title tint toward the accent.
|
||||
final bool active;
|
||||
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
/// Color painted behind the title to "cut" the border. Must match whatever
|
||||
/// sits behind this panel (the canvas by default).
|
||||
final Color backgroundColor;
|
||||
|
||||
static const double _titleStraddle = 8;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final borderColor =
|
||||
active ? RatuneColors.borderActive : RatuneColors.border;
|
||||
final titleColor =
|
||||
active ? Theme.of(context).colorScheme.primary : RatuneColors.dimmed;
|
||||
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: _titleStraddle),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: borderColor, width: 1),
|
||||
),
|
||||
child: Padding(padding: padding, child: child),
|
||||
),
|
||||
Positioned(
|
||||
left: RatuneSpacing.lg,
|
||||
top: 0,
|
||||
child: Container(
|
||||
color: backgroundColor,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: RatuneSpacing.md,
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: title,
|
||||
style: TextStyle(
|
||||
color: titleColor,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
if (trailing != null)
|
||||
TextSpan(
|
||||
text: ' $trailing',
|
||||
style: const TextStyle(color: RatuneColors.dimmed),
|
||||
),
|
||||
],
|
||||
),
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
136
lib/widgets/mini_player.dart
Normal file
136
lib/widgets/mini_player.dart
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../state/providers.dart';
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
/// Persistent mini-player pinned above the tab bar. Visible only while a track
|
||||
/// is loaded; tapping the body jumps to the Now Playing tab.
|
||||
///
|
||||
/// Watches `current`/`playing` via `.select()` so it doesn't rebuild on every
|
||||
/// position tick — the moving progress line is isolated in [_MiniProgress].
|
||||
class MiniPlayer extends ConsumerWidget {
|
||||
const MiniPlayer({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Redundant (and steals vertical space) on the Now Playing tab itself.
|
||||
if (ref.watch(selectedTabProvider) == nowPlayingTabIndex) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final current = ref.watch(playbackProvider.select((s) => s.current));
|
||||
if (current == null) return const SizedBox.shrink();
|
||||
|
||||
final playing = ref.watch(playbackProvider.select((s) => s.playing));
|
||||
final controller = ref.read(playbackProvider.notifier);
|
||||
final client = ref.watch(subsonicClientProvider);
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
final artUri = (client != null && current.coverArt != null)
|
||||
? client.coverArtUri(current.coverArt!, size: 128).toString()
|
||||
: null;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const _MiniProgress(),
|
||||
InkWell(
|
||||
onTap: () =>
|
||||
ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex,
|
||||
child: Container(
|
||||
height: 52,
|
||||
color: RatuneColors.surface,
|
||||
padding: const EdgeInsets.only(left: RatuneSpacing.md),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: ColoredBox(
|
||||
color: RatuneColors.background,
|
||||
child: artUri != null
|
||||
? Image.network(
|
||||
artUri,
|
||||
key: ValueKey(artUri),
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => const _ArtFallback(),
|
||||
)
|
||||
: const _ArtFallback(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: RatuneSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
current.title ?? 'Untitled',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: RatuneColors.foreground,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
current.artist ?? 'Unknown artist',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: RatuneColors.dimmed),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_btn(Icons.skip_previous, controller.previous),
|
||||
_btn(playing ? Icons.pause : Icons.play_arrow,
|
||||
controller.togglePlayPause,
|
||||
color: accent),
|
||||
_btn(Icons.skip_next, controller.next),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _btn(IconData icon, VoidCallback onTap,
|
||||
{Color color = RatuneColors.foreground}) {
|
||||
return IconButton(
|
||||
onPressed: onTap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: Icon(icon, color: color, size: 24),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The hairline progress line on the top edge. Isolated so only this 2px strip
|
||||
/// rebuilds on position ticks.
|
||||
class _MiniProgress extends ConsumerWidget {
|
||||
const _MiniProgress();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final progress = ref.watch(playbackProvider.select((s) => s.progress));
|
||||
final accent = Theme.of(context).colorScheme.primary;
|
||||
return SizedBox(
|
||||
height: 2,
|
||||
child: LinearProgressIndicator(
|
||||
value: progress,
|
||||
minHeight: 2,
|
||||
backgroundColor: RatuneColors.border,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(accent),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArtFallback extends StatelessWidget {
|
||||
const _ArtFallback();
|
||||
@override
|
||||
Widget build(BuildContext context) => const Center(
|
||||
child: Icon(Icons.album_outlined,
|
||||
color: RatuneColors.dimmed, size: 22),
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue