This commit is contained in:
Forrest 2026-07-29 14:14:18 -04:00
commit d205277cdd
182 changed files with 22978 additions and 0 deletions

View 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,
),
),
);
}),
),
);
}
}

View 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,
),
),
),
],
);
}
}

View 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),
);
}