updates and bug fixes
This commit is contained in:
parent
8aacce5aa8
commit
351d47b3ff
9 changed files with 419 additions and 64 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
package com.laforrestchurch.timbre
|
package com.troglodyte.timbre
|
||||||
|
|
||||||
import com.ryanheise.audioservice.AudioServiceActivity
|
import com.ryanheise.audioservice.AudioServiceActivity
|
||||||
|
|
||||||
|
|
@ -29,10 +29,10 @@ workflows:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
android:
|
android:
|
||||||
name: Android (APK + AAB)
|
name: Android (APK + AAB)
|
||||||
# mac_mini_m1 is the free-tier / most widely available instance and builds
|
# mac_mini_m2 is the free-tier / most widely available instance and builds
|
||||||
# Android fine. If your plan includes Linux, `linux_x2` is cheaper/faster
|
# Android fine. If your plan includes Linux, `linux_x2` is cheaper/faster
|
||||||
# for Android-only builds.
|
# for Android-only builds.
|
||||||
instance_type: mac_mini_m1
|
instance_type: mac_mini_m2
|
||||||
max_build_duration: 60
|
max_build_duration: 60
|
||||||
environment:
|
environment:
|
||||||
flutter: 3.44.8
|
flutter: 3.44.8
|
||||||
|
|
@ -82,7 +82,7 @@ workflows:
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
ios:
|
ios:
|
||||||
name: iOS (TestFlight)
|
name: iOS (TestFlight)
|
||||||
instance_type: mac_mini_m1
|
instance_type: mac_mini_m2
|
||||||
max_build_duration: 60
|
max_build_duration: 60
|
||||||
# Reference NAME of the App Store Connect API key integration you added in
|
# Reference NAME of the App Store Connect API key integration you added in
|
||||||
# Codemagic (Teams → Integrations → App Store Connect). Replace the value
|
# Codemagic (Teams → Integrations → App Store Connect). Replace the value
|
||||||
|
|
|
||||||
13
lib/layout/breakpoints.dart
Normal file
13
lib/layout/breakpoints.dart
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// Width at/above which the UI switches to a wide, side-by-side layout —
|
||||||
|
/// Material's "expanded" window class, i.e. tablet landscape (iPad). Keyed off
|
||||||
|
/// width rather than raw orientation so a phone in landscape (wide but short)
|
||||||
|
/// stays on the compact layout.
|
||||||
|
const double kWideBreakpoint = 840;
|
||||||
|
|
||||||
|
/// Whether the current window is wide enough for the side-by-side layout.
|
||||||
|
/// Uses [MediaQuery.sizeOf] so callers rebuild only when the size actually
|
||||||
|
/// changes, not on every unrelated MediaQuery update.
|
||||||
|
bool isWideLayout(BuildContext context) =>
|
||||||
|
MediaQuery.sizeOf(context).width >= kWideBreakpoint;
|
||||||
|
|
@ -2,12 +2,15 @@
|
||||||
// possible for the private callback fields below.
|
// possible for the private callback fields below.
|
||||||
// ignore_for_file: prefer_initializing_formals
|
// ignore_for_file: prefer_initializing_formals
|
||||||
|
|
||||||
import 'dart:io' show Platform;
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io' show File, Platform;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
import 'package:just_audio_background/just_audio_background.dart';
|
import 'package:just_audio_background/just_audio_background.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
import '../subsonic/models.dart';
|
import '../subsonic/models.dart';
|
||||||
|
|
||||||
|
|
@ -80,10 +83,12 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
||||||
PlaybackController({
|
PlaybackController({
|
||||||
required Uri? Function(Song) streamUriFor,
|
required Uri? Function(Song) streamUriFor,
|
||||||
required Uri? Function(Song) coverArtUriFor,
|
required Uri? Function(Song) coverArtUriFor,
|
||||||
|
required String? Function() serverKeyGetter,
|
||||||
required void Function(Uri artUri) onArt,
|
required void Function(Uri artUri) onArt,
|
||||||
required void Function(Song song) onPlay,
|
required void Function(Song song) onPlay,
|
||||||
}) : _streamUriFor = streamUriFor,
|
}) : _streamUriFor = streamUriFor,
|
||||||
_coverArtUriFor = coverArtUriFor,
|
_coverArtUriFor = coverArtUriFor,
|
||||||
|
_serverKeyGetter = serverKeyGetter,
|
||||||
_onArt = onArt,
|
_onArt = onArt,
|
||||||
_onPlay = onPlay,
|
_onPlay = onPlay,
|
||||||
super(PlaybackState(supported: _audioSupported)) {
|
super(PlaybackState(supported: _audioSupported)) {
|
||||||
|
|
@ -91,15 +96,26 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
||||||
_player = AudioPlayer();
|
_player = AudioPlayer();
|
||||||
_wireStreams();
|
_wireStreams();
|
||||||
}
|
}
|
||||||
|
// Persist the queue on any change (throttled) so it survives an app kill.
|
||||||
|
addListener((_) => _scheduleSave(), fireImmediately: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
final Uri? Function(Song) _streamUriFor;
|
final Uri? Function(Song) _streamUriFor;
|
||||||
final Uri? Function(Song) _coverArtUriFor;
|
final Uri? Function(Song) _coverArtUriFor;
|
||||||
|
final String? Function() _serverKeyGetter;
|
||||||
final void Function(Uri artUri) _onArt;
|
final void Function(Uri artUri) _onArt;
|
||||||
final void Function(Song song) _onPlay;
|
final void Function(Song song) _onPlay;
|
||||||
|
|
||||||
AudioPlayer? _player;
|
AudioPlayer? _player;
|
||||||
|
|
||||||
|
/// Coalesces rapid state changes (a position tick every second) into at most
|
||||||
|
/// one disk write per window.
|
||||||
|
Timer? _saveTimer;
|
||||||
|
|
||||||
|
/// Server key whose queue we've already restored (or adopted). Gates saves so
|
||||||
|
/// the empty launch state can't clobber a snapshot before restore runs.
|
||||||
|
String? _restoredKey;
|
||||||
|
|
||||||
static bool get _audioSupported =>
|
static bool get _audioSupported =>
|
||||||
!kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS);
|
!kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS);
|
||||||
|
|
||||||
|
|
@ -318,8 +334,133 @@ class PlaybackController extends StateNotifier<PlaybackState> {
|
||||||
await _player?.setLoopMode(nextMode);
|
await _player?.setLoopMode(nextMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Persistence --------------------------------------------------------
|
||||||
|
|
||||||
|
/// Per-server queue snapshot file, alongside the playlists/downloads mirrors.
|
||||||
|
Future<File> _snapshotFile(String key) async {
|
||||||
|
final dir = await getApplicationSupportDirectory();
|
||||||
|
return File('${dir.path}/queue_$key.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Throttle a save: schedule one write per window, capturing the latest state
|
||||||
|
/// when it fires (so continuous position ticks don't hammer the disk).
|
||||||
|
void _scheduleSave() {
|
||||||
|
if (_saveTimer?.isActive ?? false) return;
|
||||||
|
_saveTimer = Timer(const Duration(seconds: 3), _persist);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _persist() async {
|
||||||
|
final key = _serverKeyGetter();
|
||||||
|
// Only persist once the active server's queue has been restored/adopted.
|
||||||
|
if (key == null || key != _restoredKey) return;
|
||||||
|
try {
|
||||||
|
final file = await _snapshotFile(key);
|
||||||
|
final tmp = File('${file.path}.tmp');
|
||||||
|
await tmp.writeAsString(jsonEncode({
|
||||||
|
'queue': state.queue.map((s) => s.toJson()).toList(),
|
||||||
|
'currentIndex': state.currentIndex,
|
||||||
|
'positionMs': state.position.inMilliseconds,
|
||||||
|
'shuffle': state.shuffle,
|
||||||
|
'loop': state.loop.name,
|
||||||
|
}));
|
||||||
|
await tmp.rename(file.path); // atomic swap
|
||||||
|
} catch (_) {
|
||||||
|
// Best effort — a failed save just means a slightly staler queue.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restore the saved queue for the active server (on connect / server switch).
|
||||||
|
/// The tracks, shuffle/loop and position are loaded back into the player
|
||||||
|
/// **paused**, so the queue auto-populates without surprising the user with
|
||||||
|
/// sudden playback. Called from the [serverKeyProvider] listener; a no-op once
|
||||||
|
/// the current server has already been restored.
|
||||||
|
Future<void> restoreForServer() async {
|
||||||
|
final key = _serverKeyGetter();
|
||||||
|
if (key == null || key == _restoredKey) return;
|
||||||
|
|
||||||
|
Map<String, dynamic>? snap;
|
||||||
|
try {
|
||||||
|
final file = await _snapshotFile(key);
|
||||||
|
if (await file.exists()) {
|
||||||
|
final raw = jsonDecode(await file.readAsString());
|
||||||
|
if (raw is Map) snap = raw.cast<String, dynamic>();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Missing/corrupt snapshot is non-fatal.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark restored *after* reading, so saves for this server are now allowed.
|
||||||
|
_restoredKey = key;
|
||||||
|
|
||||||
|
// Don't clobber an already-loaded queue (user started playback first).
|
||||||
|
if (state.queue.isNotEmpty || snap == null) return;
|
||||||
|
|
||||||
|
final songs = (snap['queue'] as List? ?? const [])
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => Song.fromJson(e.cast<String, dynamic>()))
|
||||||
|
.toList();
|
||||||
|
if (songs.isEmpty) return;
|
||||||
|
|
||||||
|
final loop = LoopMode.values.firstWhere(
|
||||||
|
(m) => m.name == snap!['loop'],
|
||||||
|
orElse: () => LoopMode.off,
|
||||||
|
);
|
||||||
|
final shuffle = snap['shuffle'] == true;
|
||||||
|
final savedIndex = (snap['currentIndex'] as int?) ?? 0;
|
||||||
|
final savedPos = Duration(milliseconds: (snap['positionMs'] as int?) ?? 0);
|
||||||
|
|
||||||
|
final streamable = songs.where((s) => _streamUriFor(s) != null).toList();
|
||||||
|
if (streamable.isEmpty) {
|
||||||
|
// Offline / nothing streamable: reflect the queue for the UI only.
|
||||||
|
final start = savedIndex.clamp(0, songs.length - 1);
|
||||||
|
_lastNotifiedId = songs[start].id;
|
||||||
|
state = state.copyWith(
|
||||||
|
queue: songs,
|
||||||
|
currentIndex: start,
|
||||||
|
position: savedPos,
|
||||||
|
shuffle: shuffle,
|
||||||
|
loop: loop,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remap the saved index onto the streamable subset (same invariant as
|
||||||
|
// [playSongs]: state.queue == the sources loaded into the player).
|
||||||
|
final target = songs[savedIndex.clamp(0, songs.length - 1)];
|
||||||
|
final targetIndex = streamable.indexOf(target);
|
||||||
|
final start = targetIndex >= 0
|
||||||
|
? targetIndex
|
||||||
|
: savedIndex.clamp(0, streamable.length - 1);
|
||||||
|
|
||||||
|
// Suppress a spurious scrobble/history entry for the restored (not actually
|
||||||
|
// played) track, but still drive the album-art accent.
|
||||||
|
_lastNotifiedId = streamable[start].id;
|
||||||
|
final art = _coverArtUriFor(streamable[start]);
|
||||||
|
if (art != null) _onArt(art);
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
queue: streamable,
|
||||||
|
currentIndex: start,
|
||||||
|
position: savedPos,
|
||||||
|
shuffle: shuffle,
|
||||||
|
loop: loop,
|
||||||
|
);
|
||||||
|
|
||||||
|
final player = _player;
|
||||||
|
if (player == null) return; // desktop: UI-only, already reflected above.
|
||||||
|
await player.setAudioSources(
|
||||||
|
streamable.map(_sourceFor).toList(),
|
||||||
|
initialIndex: start,
|
||||||
|
);
|
||||||
|
await player.setShuffleModeEnabled(shuffle);
|
||||||
|
await player.setLoopMode(loop);
|
||||||
|
if (savedPos > Duration.zero) await player.seek(savedPos, index: start);
|
||||||
|
// Intentionally no play() — restore leaves the queue paused.
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_saveTimer?.cancel();
|
||||||
_player?.dispose();
|
_player?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -315,6 +315,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final index = ref.watch(libraryIndexProvider);
|
final index = ref.watch(libraryIndexProvider);
|
||||||
final playback = ref.read(playbackProvider.notifier);
|
final playback = ref.read(playbackProvider.notifier);
|
||||||
|
final client = ref.watch(subsonicClientProvider);
|
||||||
|
|
||||||
final Widget body;
|
final Widget body;
|
||||||
if (index.building) {
|
if (index.building) {
|
||||||
|
|
@ -345,9 +346,13 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
|
||||||
itemCount: index.songs.length,
|
itemCount: index.songs.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final song = index.songs[i];
|
final song = index.songs[i];
|
||||||
|
final artUri = (client != null && song.coverArt != null)
|
||||||
|
? client.coverArtUri(song.coverArt!, size: 128).toString()
|
||||||
|
: null;
|
||||||
return BrowseRow(
|
return BrowseRow(
|
||||||
title: song.title ?? 'Untitled',
|
title: song.title ?? 'Untitled',
|
||||||
trailing: song.artist,
|
subtitle: song.artist,
|
||||||
|
artUri: artUri,
|
||||||
downloadStatus: downloads.byId[song.id]?.status,
|
downloadStatus: downloads.byId[song.id]?.status,
|
||||||
onTap: () => playback.playSongs(index.songs, startIndex: i),
|
onTap: () => playback.playSongs(index.songs, startIndex: i),
|
||||||
onPlayNext: () => playback.playNext(song),
|
onPlayNext: () => playback.playNext(song),
|
||||||
|
|
@ -527,6 +532,8 @@ class BrowseRow extends StatelessWidget {
|
||||||
required this.title,
|
required this.title,
|
||||||
this.leading,
|
this.leading,
|
||||||
this.trailing,
|
this.trailing,
|
||||||
|
this.subtitle,
|
||||||
|
this.artUri,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.onPlayNext,
|
this.onPlayNext,
|
||||||
this.onAddToQueue,
|
this.onAddToQueue,
|
||||||
|
|
@ -540,6 +547,14 @@ class BrowseRow extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final String? leading;
|
final String? leading;
|
||||||
final String? trailing;
|
final String? trailing;
|
||||||
|
|
||||||
|
/// Secondary line rendered below [title] in a smaller, dimmed font (e.g. the
|
||||||
|
/// artist name on track rows).
|
||||||
|
final String? subtitle;
|
||||||
|
|
||||||
|
/// When set, a small square album-cover thumbnail is shown at the start of
|
||||||
|
/// the row.
|
||||||
|
final String? artUri;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
/// When set, renders inline "play next" / "add to queue" icons (song rows).
|
/// When set, renders inline "play next" / "add to queue" icons (song rows).
|
||||||
|
|
@ -578,6 +593,23 @@ class BrowseRow extends StatelessWidget {
|
||||||
padding: const EdgeInsets.only(left: TimbreSpacing.lg),
|
padding: const EdgeInsets.only(left: TimbreSpacing.lg),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
if (artUri != null) ...[
|
||||||
|
SizedBox(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
child: ColoredBox(
|
||||||
|
color: TimbreColors.surface,
|
||||||
|
child: Image.network(
|
||||||
|
artUri!,
|
||||||
|
key: ValueKey(artUri),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
errorBuilder: (_, _, _) => const _AlbumArtFallback(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: TimbreSpacing.md),
|
||||||
|
],
|
||||||
if (leading != null)
|
if (leading != null)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 28,
|
width: 28,
|
||||||
|
|
@ -585,12 +617,26 @@ class BrowseRow extends StatelessWidget {
|
||||||
style: const TextStyle(color: TimbreColors.dimmed)),
|
style: const TextStyle(color: TimbreColors.dimmed)),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
title,
|
title,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(color: TimbreColors.foreground),
|
style: const TextStyle(color: TimbreColors.foreground),
|
||||||
),
|
),
|
||||||
|
if (subtitle != null)
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: TimbreColors.dimmed, fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (isDone)
|
if (isDone)
|
||||||
Padding(
|
Padding(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:just_audio/just_audio.dart' show LoopMode;
|
import 'package:just_audio/just_audio.dart' show LoopMode;
|
||||||
|
|
||||||
import '../downloads/download_manager.dart';
|
import '../downloads/download_manager.dart';
|
||||||
|
import '../layout/breakpoints.dart';
|
||||||
import '../playback/playback_engine.dart';
|
import '../playback/playback_engine.dart';
|
||||||
import '../settings/settings_store.dart';
|
import '../settings/settings_store.dart';
|
||||||
import '../state/providers.dart';
|
import '../state/providers.dart';
|
||||||
|
|
@ -14,9 +15,11 @@ import '../widgets/hairline_panel.dart';
|
||||||
import 'add_to_playlist_sheet.dart';
|
import 'add_to_playlist_sheet.dart';
|
||||||
|
|
||||||
/// Now Playing tab — album art + info strip + transport, bound to the live
|
/// Now Playing tab — album art + info strip + transport, bound to the live
|
||||||
/// playback engine. The top region shows the full-size album art by default and
|
/// playback engine. On compact screens the top region shows the full-size album
|
||||||
/// swaps to the queue when toggled, so the art keeps its original size while the
|
/// art by default and swaps to the queue when toggled. On wide screens
|
||||||
/// queue still gets a usable amount of space on demand.
|
/// (>= [kWideBreakpoint], i.e. tablet landscape) it splits into two panes: the
|
||||||
|
/// art + controls on the left and a permanently-visible queue on the right, so
|
||||||
|
/// the queue toggle is dropped.
|
||||||
class NowPlayingScreen extends ConsumerStatefulWidget {
|
class NowPlayingScreen extends ConsumerStatefulWidget {
|
||||||
const NowPlayingScreen({super.key});
|
const NowPlayingScreen({super.key});
|
||||||
|
|
||||||
|
|
@ -27,12 +30,34 @@ class NowPlayingScreen extends ConsumerStatefulWidget {
|
||||||
class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||||
bool _showQueue = false;
|
bool _showQueue = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Seed the already-current track once the first frame is up (the ref.listen
|
||||||
|
// below only fires on *changes*, so it would miss the launch track).
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _seedFavorites());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reconcile the current song's own `starred` / `userRating` metadata into
|
||||||
|
/// the favorites store, so a previously-favorited track shows a filled heart
|
||||||
|
/// even when it was surfaced outside the Favorites tab. See
|
||||||
|
/// [FavoritesController.seedSong].
|
||||||
|
void _seedFavorites() {
|
||||||
|
if (!mounted) return;
|
||||||
|
final song = ref.read(playbackProvider).current;
|
||||||
|
if (song != null) ref.read(favoritesProvider.notifier).seedSong(song);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final state = ref.watch(playbackProvider);
|
final state = ref.watch(playbackProvider);
|
||||||
final accent = Theme.of(context).colorScheme.primary;
|
final accent = Theme.of(context).colorScheme.primary;
|
||||||
final current = state.current;
|
final current = state.current;
|
||||||
|
|
||||||
|
// Re-seed the favorites store whenever the track changes.
|
||||||
|
ref.listen(playbackProvider.select((s) => s.current?.id),
|
||||||
|
(_, _) => _seedFavorites());
|
||||||
|
|
||||||
if (current == null) {
|
if (current == null) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: Text('Nothing playing.',
|
child: Text('Nothing playing.',
|
||||||
|
|
@ -40,23 +65,13 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Padding(
|
// Everything below the art region — shared by both layouts. The queue
|
||||||
padding: const EdgeInsets.fromLTRB(
|
// toggle is deliberately excluded: it belongs only to the compact layout
|
||||||
TimbreSpacing.lg,
|
// (where art and queue share one region), so it's appended separately.
|
||||||
TimbreSpacing.xl,
|
final controls = <Widget>[
|
||||||
TimbreSpacing.lg,
|
const _NowPlayingProgress(),
|
||||||
TimbreSpacing.lg,
|
const SizedBox(height: TimbreSpacing.md),
|
||||||
),
|
_InfoStrip(song: current, accent: accent),
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
// The album art keeps its natural (width-bound square) size; when the
|
|
||||||
// queue is toggled on it takes over this same region.
|
|
||||||
Expanded(
|
|
||||||
child: _showQueue ? const _QueuePanel() : const _AlbumArtPanel(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: TimbreSpacing.xl),
|
|
||||||
_InfoStrip(song: current, state: state, accent: accent),
|
|
||||||
const SizedBox(height: TimbreSpacing.sm),
|
const SizedBox(height: TimbreSpacing.sm),
|
||||||
_FavRating(song: current),
|
_FavRating(song: current),
|
||||||
const SizedBox(height: TimbreSpacing.xs),
|
const SizedBox(height: TimbreSpacing.xs),
|
||||||
|
|
@ -69,13 +84,77 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||||
style: TextStyle(color: TimbreColors.dimmed, fontSize: 11),
|
style: TextStyle(color: TimbreColors.dimmed, fontSize: 11),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: TimbreSpacing.sm),
|
];
|
||||||
_QueueToggle(
|
|
||||||
|
const padding = EdgeInsets.fromLTRB(
|
||||||
|
TimbreSpacing.lg,
|
||||||
|
TimbreSpacing.xl,
|
||||||
|
TimbreSpacing.lg,
|
||||||
|
TimbreSpacing.lg,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wide (tablet landscape): art + controls on the left, always-visible queue
|
||||||
|
// on the right. The left column reuses the same scroll-view trick as the
|
||||||
|
// compact art view so the square art stays width-bound within its pane.
|
||||||
|
if (isWideLayout(context)) {
|
||||||
|
return Padding(
|
||||||
|
padding: padding,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 5,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Art takes the leftover height and is capped to a square, so
|
||||||
|
// the controls below (esp. the transport row) stay on screen.
|
||||||
|
const Expanded(child: _FittedArt()),
|
||||||
|
const SizedBox(height: TimbreSpacing.lg),
|
||||||
|
...controls,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: TimbreSpacing.xl),
|
||||||
|
const Expanded(flex: 4, child: _QueuePanel()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact: art and queue share the top region, swapped by the toggle.
|
||||||
|
final queueToggle = _QueueToggle(
|
||||||
showQueue: _showQueue,
|
showQueue: _showQueue,
|
||||||
queueLength: state.queue.length,
|
queueLength: state.queue.length,
|
||||||
accent: accent,
|
accent: accent,
|
||||||
onTap: () => setState(() => _showQueue = !_showQueue),
|
onTap: () => setState(() => _showQueue = !_showQueue),
|
||||||
),
|
);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: padding,
|
||||||
|
child: _showQueue
|
||||||
|
// Queue takes over the flexible region and scrolls internally.
|
||||||
|
? Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Expanded(child: _QueuePanel()),
|
||||||
|
const SizedBox(height: TimbreSpacing.lg),
|
||||||
|
...controls,
|
||||||
|
const SizedBox(height: TimbreSpacing.sm),
|
||||||
|
queueToggle,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
// Art absorbs the leftover height, capped to a square, so the
|
||||||
|
// transport and queue toggle beneath it always stay on screen
|
||||||
|
// instead of being pushed below the fold by a full-width square.
|
||||||
|
: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
const Expanded(child: _FittedArt()),
|
||||||
|
const SizedBox(height: TimbreSpacing.lg),
|
||||||
|
...controls,
|
||||||
|
const SizedBox(height: TimbreSpacing.sm),
|
||||||
|
queueToggle,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -200,6 +279,34 @@ class _QueuePanel extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The album-art panel sized to the largest square that fits the space it's
|
||||||
|
/// given, top-aligned so the controls beneath it always stay on screen. When
|
||||||
|
/// height is unbounded (e.g. inside a scroll view) it falls back to the full
|
||||||
|
/// available width. This is what keeps the transport from being pushed below
|
||||||
|
/// the fold when the art region would otherwise render a giant full-width
|
||||||
|
/// square.
|
||||||
|
class _FittedArt extends StatelessWidget {
|
||||||
|
const _FittedArt();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, c) {
|
||||||
|
final side =
|
||||||
|
c.maxHeight.isFinite ? c.maxHeight.clamp(0.0, c.maxWidth) : c.maxWidth;
|
||||||
|
return SizedBox(
|
||||||
|
width: side,
|
||||||
|
height: side,
|
||||||
|
child: const _AlbumArtPanel(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Album art isolated into its own `const` widget that watches only the
|
/// Album art isolated into its own `const` widget that watches only the
|
||||||
/// current track's cover art — so position-tick rebuilds of the parent don't
|
/// current track's cover art — so position-tick rebuilds of the parent don't
|
||||||
/// touch it. `gaplessPlayback` + a URL-keyed element keep the previous frame
|
/// touch it. `gaplessPlayback` + a URL-keyed element keep the previous frame
|
||||||
|
|
@ -333,11 +440,9 @@ class _FavRating extends ConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
class _InfoStrip extends StatelessWidget {
|
class _InfoStrip extends StatelessWidget {
|
||||||
const _InfoStrip(
|
const _InfoStrip({required this.song, required this.accent});
|
||||||
{required this.song, required this.state, required this.accent});
|
|
||||||
|
|
||||||
final Song song;
|
final Song song;
|
||||||
final PlaybackState state;
|
|
||||||
final Color accent;
|
final Color accent;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -357,18 +462,34 @@ class _InfoStrip extends StatelessWidget {
|
||||||
style: const TextStyle(color: TimbreColors.foreground)),
|
style: const TextStyle(color: TimbreColors.foreground)),
|
||||||
if (album.isNotEmpty)
|
if (album.isNotEmpty)
|
||||||
Text(album, style: const TextStyle(color: TimbreColors.dimmed)),
|
Text(album, style: const TextStyle(color: TimbreColors.dimmed)),
|
||||||
const SizedBox(height: TimbreSpacing.md),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text(_fmtDur(state.position),
|
|
||||||
style: const TextStyle(color: TimbreColors.dimmed)),
|
|
||||||
const SizedBox(width: TimbreSpacing.md),
|
|
||||||
Expanded(child: BlockProgressBar(progress: state.progress)),
|
|
||||||
const SizedBox(width: TimbreSpacing.md),
|
|
||||||
Text(_fmtDur(state.duration),
|
|
||||||
style: const TextStyle(color: TimbreColors.dimmed)),
|
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chunky block progress bar for the Now Playing screen, sitting in the gap
|
||||||
|
/// between the album art and the title with the elapsed / total times flanking
|
||||||
|
/// it. Isolated as its own ConsumerWidget (like the mini-player and home hero)
|
||||||
|
/// so position ticks rebuild only this strip, not the art or title text.
|
||||||
|
class _NowPlayingProgress extends ConsumerWidget {
|
||||||
|
const _NowPlayingProgress();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final position = ref.watch(playbackProvider.select((s) => s.position));
|
||||||
|
final duration = ref.watch(playbackProvider.select((s) => s.duration));
|
||||||
|
final progress = ref.watch(playbackProvider.select((s) => s.progress));
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Text(_fmtDur(position),
|
||||||
|
style: const TextStyle(color: TimbreColors.dimmed)),
|
||||||
|
const SizedBox(width: TimbreSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: BlockProgressBar(progress: progress, cells: 28, height: 18),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: TimbreSpacing.md),
|
||||||
|
Text(_fmtDur(duration),
|
||||||
|
style: const TextStyle(color: TimbreColors.dimmed)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,27 @@ class FavoritesController extends StateNotifier<FavoritesState> {
|
||||||
|
|
||||||
void clear() => state = const FavoritesState();
|
void clear() => state = const FavoritesState();
|
||||||
|
|
||||||
|
/// Fold a song's own server-provided star / rating into local state when it
|
||||||
|
/// isn't tracked yet. Songs surfaced outside the Favorites tab (browse, home,
|
||||||
|
/// queue) carry their `starred` flag directly, so this lets the heart light
|
||||||
|
/// up for previously-favorited tracks even when `getStarred2` hydration is
|
||||||
|
/// incomplete. Only fills gaps — it never re-adds a star the user cleared
|
||||||
|
/// this session, since callers invoke it on track change (not on toggle) and
|
||||||
|
/// it only adds when the song itself reports starred.
|
||||||
|
void seedSong(Song song) {
|
||||||
|
Set<String>? songIds;
|
||||||
|
Map<String, int>? ratings;
|
||||||
|
if (song.starred && !state.songIds.contains(song.id)) {
|
||||||
|
songIds = {...state.songIds, song.id};
|
||||||
|
}
|
||||||
|
if (song.userRating != null && !state.ratings.containsKey(song.id)) {
|
||||||
|
ratings = {...state.ratings, song.id: song.userRating!};
|
||||||
|
}
|
||||||
|
if (songIds != null || ratings != null) {
|
||||||
|
state = state.copyWith(songIds: songIds, ratings: ratings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> toggleSong(Song song) async {
|
Future<void> toggleSong(Song song) async {
|
||||||
final client = _clientGetter();
|
final client = _clientGetter();
|
||||||
if (client == null) return;
|
if (client == null) return;
|
||||||
|
|
|
||||||
|
|
@ -474,9 +474,10 @@ final playbackProvider =
|
||||||
return client.coverArtUri(s.coverArt!, size: 512);
|
return client.coverArtUri(s.coverArt!, size: 512);
|
||||||
}
|
}
|
||||||
|
|
||||||
return PlaybackController(
|
final controller = PlaybackController(
|
||||||
streamUriFor: streamUriFor,
|
streamUriFor: streamUriFor,
|
||||||
coverArtUriFor: coverArtUriFor,
|
coverArtUriFor: coverArtUriFor,
|
||||||
|
serverKeyGetter: () => ref.read(serverKeyProvider),
|
||||||
onArt: (artUri) async {
|
onArt: (artUri) async {
|
||||||
// Skip extraction entirely when the user has pinned a static accent.
|
// Skip extraction entirely when the user has pinned a static accent.
|
||||||
if (ref.read(settingsProvider).useStaticAccent) return;
|
if (ref.read(settingsProvider).useStaticAccent) return;
|
||||||
|
|
@ -489,4 +490,11 @@ final playbackProvider =
|
||||||
ref.read(subsonicClientProvider)?.scrobble(song.id).ignore();
|
ref.read(subsonicClientProvider)?.scrobble(song.id).ignore();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
// Restore the persisted queue on connect / server switch, and catch the
|
||||||
|
// already-connected case at creation (the listener only fires on changes).
|
||||||
|
ref.listen<String?>(serverKeyProvider, (_, _) {
|
||||||
|
controller.restoreForServer();
|
||||||
|
});
|
||||||
|
controller.restoreForServer();
|
||||||
|
return controller;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,11 @@ class BlockProgressBar extends StatelessWidget {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: height,
|
height: height,
|
||||||
child: Row(
|
child: Row(
|
||||||
|
// Stretch each cell to the bar's full height. Without this the childless
|
||||||
|
// ColoredBox cells get loose (min-height 0) constraints under the Row's
|
||||||
|
// default center alignment and collapse to zero height — making the whole
|
||||||
|
// bar invisible.
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: List.generate(cells, (i) {
|
children: List.generate(cells, (i) {
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue