This commit is contained in:
Forrest 2026-08-04 16:08:51 -04:00
parent d558aba246
commit 3bd713d667
17 changed files with 1566 additions and 132 deletions

View file

@ -138,8 +138,6 @@ class DownloadController extends StateNotifier<DownloadState> {
headers: {'User-Agent': 'timbre'},
));
static const int _maxConcurrent = 3;
int _generation = 0;
String? _loadedKey;
final List<String> _queue = [];
@ -271,8 +269,17 @@ class DownloadController extends StateNotifier<DownloadState> {
}
}
/// Re-run the pump — used when the concurrency setting is raised so queued
/// tracks start immediately instead of waiting for the next enqueue/finish.
void onConcurrencyChanged() => _pump();
void _pump() {
while (_active < _maxConcurrent && _queue.isNotEmpty) {
// Re-read the concurrency cap each pump so a settings change takes effect
// mid-session: raising it starts more downloads immediately; lowering it
// stops launching new ones while in-flight downloads drain naturally.
final maxConcurrent =
AppSettings.clampConcurrentDownloads(_settingsGetter().maxConcurrentDownloads);
while (_active < maxConcurrent && _queue.isNotEmpty) {
final id = _queue.removeAt(0);
final info = state.byId[id];
if (info == null || info.status != DownloadStatus.queued) continue;

View file

@ -0,0 +1,149 @@
import '../settings/settings_store.dart';
import '../subsonic/models.dart';
/// Session-scoped filter for a browse view: an optional genre and release year.
/// Held in `state/providers.dart` (not persisted) so the app never silently
/// reopens filtered. Sort order *is* persisted (see [AppSettings]).
class BrowseFilter {
const BrowseFilter({this.genre, this.year});
/// Case-insensitive genre match, or null for "all genres".
final String? genre;
/// Exact release-year match, or null for "all years".
final int? year;
bool get isActive => genre != null || year != null;
int get activeCount => (genre != null ? 1 : 0) + (year != null ? 1 : 0);
BrowseFilter copyWith({Object? genre = _unset, Object? year = _unset}) =>
BrowseFilter(
genre: identical(genre, _unset) ? this.genre : genre as String?,
year: identical(year, _unset) ? this.year : year as int?,
);
static const Object _unset = Object();
}
/// Distinct, display-cased genres from a set of raw genre strings, sorted
/// alphabetically. Dedupes case-insensitively (keeping first-seen casing) so a
/// server that mixes "Rock"/"rock" collapses to one entry.
List<String> distinctGenres(Iterable<String?> raw) {
final byKey = <String, String>{};
for (final g in raw) {
final v = g?.trim();
if (v == null || v.isEmpty) continue;
byKey.putIfAbsent(v.toLowerCase(), () => v);
}
final out = byKey.values.toList()
..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
return out;
}
/// Distinct release years present, newest first.
List<int> distinctYears(Iterable<int?> raw) {
final set = <int>{};
for (final y in raw) {
if (y != null && y > 0) set.add(y);
}
final out = set.toList()..sort((a, b) => b.compareTo(a));
return out;
}
bool _genreMatches(String? itemGenre, String? filterGenre) {
if (filterGenre == null) return true;
return itemGenre != null &&
itemGenre.toLowerCase() == filterGenre.toLowerCase();
}
int _byString(String? a, String? b) =>
(a ?? '').toLowerCase().compareTo((b ?? '').toLowerCase());
/// Compare where a null [a]/[b] always sorts *last*, regardless of [descending].
/// Takes bare [Comparable] so both `int` (`Comparable<num>`) and `DateTime` work.
int _nullsLast(Comparable? a, Comparable? b, {bool descending = false}) {
if (a == null && b == null) return 0;
if (a == null) return 1;
if (b == null) return -1;
final c = a.compareTo(b);
return descending ? -c : c;
}
/// Filter then sort albums for the browse grid. Pure — no I/O.
List<Album> applyAlbumQuery(
List<Album> albums,
BrowseFilter filter,
AlbumSort sort,
) {
final out = albums
.where((a) => _genreMatches(a.genre, filter.genre))
.where((a) => filter.year == null || a.year == filter.year)
.toList();
switch (sort) {
case AlbumSort.nameAsc:
out.sort((a, b) => _byString(a.name, b.name));
case AlbumSort.artistAsc:
out.sort((a, b) {
final c = _byString(a.artist, b.artist);
return c != 0 ? c : _byString(a.name, b.name);
});
case AlbumSort.yearDesc:
out.sort((a, b) {
final c = _nullsLast(a.year, b.year, descending: true);
return c != 0 ? c : _byString(a.name, b.name);
});
case AlbumSort.yearAsc:
out.sort((a, b) {
final c = _nullsLast(a.year, b.year);
return c != 0 ? c : _byString(a.name, b.name);
});
case AlbumSort.recentlyAdded:
out.sort((a, b) {
final c = _nullsLast(a.createdAt, b.createdAt, descending: true);
return c != 0 ? c : _byString(a.name, b.name);
});
}
return out;
}
/// Filter then sort tracks for the browse list. Pure — no I/O.
List<Song> applyTrackQuery(
List<Song> songs,
BrowseFilter filter,
TrackSort sort,
) {
final out = songs
.where((s) => _genreMatches(s.genre, filter.genre))
.where((s) => filter.year == null || s.year == filter.year)
.toList();
switch (sort) {
case TrackSort.titleAsc:
out.sort((a, b) => _byString(a.title, b.title));
case TrackSort.artistAsc:
out.sort((a, b) {
final c = _byString(a.artist, b.artist);
return c != 0 ? c : _byString(a.title, b.title);
});
case TrackSort.albumAsc:
out.sort((a, b) {
final c = _byString(a.album, b.album);
if (c != 0) return c;
final t = _nullsLast(a.track, b.track);
return t != 0 ? t : _byString(a.title, b.title);
});
case TrackSort.yearDesc:
out.sort((a, b) {
final c = _nullsLast(a.year, b.year, descending: true);
return c != 0 ? c : _byString(a.title, b.title);
});
case TrackSort.recentlyAdded:
out.sort((a, b) {
final c = _nullsLast(a.createdAt, b.createdAt, descending: true);
return c != 0 ? c : _byString(a.title, b.title);
});
}
return out;
}

View file

@ -406,6 +406,52 @@ class PlaybackController extends StateNotifier<PlaybackState> {
await player.removeAudioSourceAt(index);
}
/// Move the queue entry from [oldIndex] to [newIndex] (drag-and-drop reorder).
///
/// Indices are "clean" post-removal targets — the same convention as
/// just_audio's `moveAudioSource` and Flutter's `ReorderableListView` after
/// its standard `newIndex -= 1` adjustment (done by the caller). Reorder
/// mutates the currently-active order: applied while shuffled, the move sticks
/// among the shuffled tail; hitting shuffle afterwards re-randomizes as usual.
/// A single targeted `moveAudioSource` keeps the current track playing without
/// the re-buffer a full source rebuild ([_applyOrder]) would cause.
Future<void> reorderQueue(int oldIndex, int newIndex) async {
final q = state.queue;
if (oldIndex < 0 || oldIndex >= q.length) return;
if (newIndex < 0 || newIndex >= q.length) return;
if (oldIndex == newIndex) return;
final next = [...q];
next.insert(newIndex, next.removeAt(oldIndex));
final player = _player;
if (player == null) {
// Desktop/no-audio: no stream will correct the index for us, so follow
// the current track to its new slot by hand.
final cur = state.currentIndex;
var newCur = cur;
if (cur != null) {
if (cur == oldIndex) {
newCur = newIndex;
} else {
var c = cur;
if (oldIndex < c) c -= 1;
if (newIndex <= c) c += 1;
newCur = c;
}
}
state = state.copyWith(queue: next, currentIndex: newCur);
_notifyCurrent();
return;
}
// Mobile: just_audio adjusts its own current index and re-emits
// `currentIndexStream`; the id dedupe in [_notifyCurrent] avoids a spurious
// re-scrobble when the playing track didn't actually change.
state = state.copyWith(queue: next);
await player.moveAudioSource(oldIndex, newIndex);
}
Future<void> togglePlayPause() async {
final player = _player;
if (player == null) return;

View file

@ -24,6 +24,12 @@ const String kTagMarker = 'timbre:tag';
/// Whether [p] is a Timbre tag (vs a user-facing playlist).
bool isTagPlaylist(Playlist p) => p.comment == kTagMarker;
/// Whether [p] belongs to the user [me] (vs a *shared* playlist owned by
/// someone else on the same server). An unknown owner — or unknown viewer —
/// counts as "mine" so the split never hides a playlist the user can edit.
bool isOwnedBy(Playlist p, String? me) =>
p.owner == null || me == null || p.owner == me;
/// Playlists snapshot: the summaries (from `getPlaylists`) plus any full details
/// that have been opened. Details are cached so an opened playlist keeps working
/// offline.
@ -279,6 +285,50 @@ class PlaylistsController extends StateNotifier<PlaylistsState> {
songs: d.songs,
);
/// Share (make server-wide public) or unshare a playlist. Optimistic like
/// [rename]; reverts on failure. Owner/public live on the summary only.
Future<void> setPublic(String id, bool value) async {
final client = _clientGetter();
if (client == null) return;
final prev = state;
state = state.copyWith(playlists: [
for (final p in state.playlists)
if (p.id == id) _withPublic(p, value) else p,
]);
try {
await client.setPlaylistPublic(id, value);
await _persist();
} catch (_) {
state = prev; // revert
}
}
/// Clone a (typically shared) playlist into a new one owned by the current
/// user — the client-only alternative to editing someone else's playlist.
/// Loads [source]'s tracks (server, falling back to cache), creates a fresh
/// playlist, and copies them in. Returns the new id, or null on failure.
Future<String?> saveCopy(Playlist source) async {
final client = _clientGetter();
if (client == null) return null;
final detail = await loadDetail(source.id);
final songs = detail?.songs ?? const <Song>[];
final newId = await create(source.name);
if (newId == null) return null;
if (songs.isNotEmpty) await addTracks(newId, songs);
return newId;
}
static Playlist _withPublic(Playlist p, bool value) => Playlist(
id: p.id,
name: p.name,
songCount: p.songCount,
duration: p.duration,
owner: p.owner,
public: value,
coverArt: p.coverArt,
comment: p.comment,
);
Future<void> rename(String id, String name) async {
final client = _clientGetter();
if (client == null) return;

View file

@ -28,7 +28,9 @@ class _AddToPlaylistSheet extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final accent = Theme.of(context).colorScheme.primary;
final playlists = ref.watch(realPlaylistsProvider);
// Only your own playlists — the server rejects adding tracks to another
// user's shared playlist.
final playlists = ref.watch(myPlaylistsProvider);
final connected = ref.watch(subsonicClientProvider) != null;
return SafeArea(

View file

@ -0,0 +1,370 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../settings/settings_store.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
/// Filter/sort chip bar for the Albums browse grid. Sort is persisted; the
/// genre/year filters are session-only (see `state/providers.dart`).
class AlbumControlBar extends ConsumerWidget {
const AlbumControlBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final sort = ref.watch(settingsProvider.select((s) => s.albumSort));
final filter = ref.watch(albumFilterProvider);
final genres = ref.watch(albumGenresProvider);
final years = ref.watch(albumYearsProvider);
return _ControlBar(
children: [
_ControlChip(
label: 'Sort: ${AppSettings.albumSortLabel(sort)}',
onTap: () async {
final picked = await _showPicker<AlbumSort>(
context,
title: 'Sort albums',
current: sort,
options: [
for (final s in AlbumSort.values)
_Opt(AppSettings.albumSortLabel(s), s),
],
);
if (picked != null) {
ref.read(settingsProvider.notifier).setAlbumSort(picked.value);
}
},
),
_ControlChip(
label: filter.genre ?? 'Genre',
active: filter.genre != null,
onTap: genres.isEmpty
? null
: () async {
final picked = await _showPicker<String?>(
context,
title: 'Filter by genre',
current: filter.genre,
options: [
const _Opt('All genres', null),
for (final g in genres) _Opt(g, g),
],
);
if (picked != null) {
ref.read(albumFilterProvider.notifier).state =
filter.copyWith(genre: picked.value);
}
},
),
_ControlChip(
label: filter.year?.toString() ?? 'Year',
active: filter.year != null,
onTap: years.isEmpty
? null
: () async {
final picked = await _showPicker<int?>(
context,
title: 'Filter by year',
current: filter.year,
options: [
const _Opt('All years', null),
for (final y in years) _Opt('$y', y),
],
);
if (picked != null) {
ref.read(albumFilterProvider.notifier).state =
filter.copyWith(year: picked.value);
}
},
),
if (filter.isActive)
_ClearChip(onTap: () => ref.read(albumFilterProvider.notifier).state =
const BrowseFilter()),
],
);
}
}
/// Filter/sort chip bar for the Tracks browse list.
class TrackControlBar extends ConsumerWidget {
const TrackControlBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final sort = ref.watch(settingsProvider.select((s) => s.trackSort));
final filter = ref.watch(trackFilterProvider);
final genres = ref.watch(trackGenresProvider);
final years = ref.watch(trackYearsProvider);
return _ControlBar(
children: [
_ControlChip(
label: 'Sort: ${AppSettings.trackSortLabel(sort)}',
onTap: () async {
final picked = await _showPicker<TrackSort>(
context,
title: 'Sort tracks',
current: sort,
options: [
for (final s in TrackSort.values)
_Opt(AppSettings.trackSortLabel(s), s),
],
);
if (picked != null) {
ref.read(settingsProvider.notifier).setTrackSort(picked.value);
}
},
),
_ControlChip(
label: filter.genre ?? 'Genre',
active: filter.genre != null,
onTap: genres.isEmpty
? null
: () async {
final picked = await _showPicker<String?>(
context,
title: 'Filter by genre',
current: filter.genre,
options: [
const _Opt('All genres', null),
for (final g in genres) _Opt(g, g),
],
);
if (picked != null) {
ref.read(trackFilterProvider.notifier).state =
filter.copyWith(genre: picked.value);
}
},
),
_ControlChip(
label: filter.year?.toString() ?? 'Year',
active: filter.year != null,
onTap: years.isEmpty
? null
: () async {
final picked = await _showPicker<int?>(
context,
title: 'Filter by year',
current: filter.year,
options: [
const _Opt('All years', null),
for (final y in years) _Opt('$y', y),
],
);
if (picked != null) {
ref.read(trackFilterProvider.notifier).state =
filter.copyWith(year: picked.value);
}
},
),
if (filter.isActive)
_ClearChip(onTap: () => ref.read(trackFilterProvider.notifier).state =
const BrowseFilter()),
],
);
}
}
/// Horizontally-scrolling row of control chips.
class _ControlBar extends StatelessWidget {
const _ControlBar({required this.children});
final List<Widget> children;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md),
child: Row(
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) const SizedBox(width: TimbreSpacing.sm),
children[i],
],
],
),
);
}
}
/// A bordered dropdown-style chip; tints toward accent when [active] (a filter
/// is applied). A null [onTap] renders it disabled/dimmed.
class _ControlChip extends StatelessWidget {
const _ControlChip({
required this.label,
required this.onTap,
this.active = false,
});
final String label;
final VoidCallback? onTap;
final bool active;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
final enabled = onTap != null;
final fg = !enabled
? TimbreColors.dimmed
: (active ? accent : TimbreColors.foreground);
return InkWell(
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(
horizontal: TimbreSpacing.md,
vertical: TimbreSpacing.sm,
),
decoration: BoxDecoration(
border: Border.all(color: active ? accent : TimbreColors.border),
color:
active ? accent.withValues(alpha: 0.12) : TimbreColors.surface,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(label,
style: TextStyle(
color: fg,
fontWeight: active ? FontWeight.w700 : FontWeight.w400)),
Icon(Icons.arrow_drop_down, size: 18, color: fg),
],
),
),
);
}
}
class _ClearChip extends StatelessWidget {
const _ClearChip({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.md),
alignment: Alignment.center,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.close, size: 14, color: TimbreColors.dimmed),
SizedBox(width: TimbreSpacing.xs),
Text('Clear', style: TextStyle(color: TimbreColors.dimmed)),
],
),
),
);
}
}
/// One option in a picker sheet.
class _Opt<T> {
const _Opt(this.label, this.value);
final String label;
final T value;
}
/// Wraps the picked value so a chosen `null` ("All") is distinguishable from a
/// dismissed sheet (which resolves to a bare `null`).
class _Picked<T> {
const _Picked(this.value);
final T value;
}
/// A titled bottom-sheet list picker. Returns the wrapped selection, or null if
/// the sheet was dismissed without a choice.
Future<_Picked<T>?> _showPicker<T>(
BuildContext context, {
required String title,
required List<_Opt<T>> options,
required T current,
}) {
final accent = Theme.of(context).colorScheme.primary;
return showModalBottomSheet<_Picked<T>>(
context: context,
backgroundColor: TimbreColors.background,
isScrollControlled: true,
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding:
const EdgeInsets.symmetric(horizontal: TimbreSpacing.xl),
child: Text(title,
style:
TextStyle(color: accent, fontWeight: FontWeight.w700)),
),
const SizedBox(height: TimbreSpacing.md),
Flexible(
child: ListView(
shrinkWrap: true,
children: [
for (final o in options)
_PickerTile(
label: o.label,
selected: o.value == current,
accent: accent,
onTap: () => Navigator.pop(ctx, _Picked<T>(o.value)),
),
],
),
),
],
),
),
),
);
}
class _PickerTile extends StatelessWidget {
const _PickerTile({
required this.label,
required this.selected,
required this.accent,
required this.onTap,
});
final String label;
final bool selected;
final Color accent;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: TimbreSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(
horizontal: TimbreSpacing.xl,
vertical: TimbreSpacing.md,
),
child: Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(
color: selected ? accent : TimbreColors.foreground,
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
),
),
),
if (selected) Icon(Icons.check, size: 16, color: accent),
],
),
),
);
}
}

View file

@ -9,6 +9,7 @@ import '../widgets/hairline_panel.dart';
import '../widgets/toast.dart';
import 'add_tag_sheet.dart';
import 'add_to_playlist_sheet.dart';
import 'browse_controls.dart';
import 'downloads_screen.dart';
import 'favorites_screen.dart';
import 'playlists_screen.dart';
@ -188,7 +189,8 @@ class _AlbumsPanel extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(albumsProvider);
final albums = ref.watch(visibleAlbumsProvider);
final filter = ref.watch(albumFilterProvider);
final client = ref.watch(subsonicClientProvider);
return HairlinePanel(
title: 'Albums',
@ -200,34 +202,56 @@ class _AlbumsPanel extends ConsumerWidget {
child: albums.when(
loading: () => const _Centered(child: _Loading()),
error: (e, _) => _Centered(child: _ErrorText('$e')),
data: (list) => list.isEmpty
? const _Centered(child: _ErrorText('No albums on this server.'))
: LayoutBuilder(
builder: (context, constraints) {
final cols = (constraints.maxWidth / 180).floor().clamp(2, 6);
return GridView.builder(
padding: EdgeInsets.zero,
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisSpacing: TimbreSpacing.md,
crossAxisSpacing: TimbreSpacing.md,
// Square art + two caption lines; extra vertical slack so
// the tile never sub-pixel-overflows.
childAspectRatio: 0.68,
),
itemCount: list.length,
itemBuilder: (context, i) => _AlbumTile(
album: list[i],
artUri: (client != null && list[i].coverArt != null)
? client
.coverArtUri(list[i].coverArt!, size: 300)
.toString()
: null,
),
);
},
data: (list) {
// Hide the control bar only on a genuinely empty server (nothing to
// filter); keep it visible if a filter is what emptied the list.
final showControls = list.isNotEmpty || filter.isActive;
return Column(
children: [
if (showControls) ...[
const AlbumControlBar(),
const SizedBox(height: TimbreSpacing.md),
],
Expanded(
child: list.isEmpty
? _Centered(
child: _ErrorText(filter.isActive
? 'No albums match these filters.'
: 'No albums on this server.'),
)
: LayoutBuilder(
builder: (context, constraints) {
final cols =
(constraints.maxWidth / 180).floor().clamp(2, 6);
return GridView.builder(
padding: EdgeInsets.zero,
gridDelegate:
SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: cols,
mainAxisSpacing: TimbreSpacing.md,
crossAxisSpacing: TimbreSpacing.md,
// Square art + two caption lines; extra vertical
// slack so the tile never sub-pixel-overflows.
childAspectRatio: 0.68,
),
itemCount: list.length,
itemBuilder: (context, i) => _AlbumTile(
album: list[i],
artUri:
(client != null && list[i].coverArt != null)
? client
.coverArtUri(list[i].coverArt!,
size: 300)
.toString()
: null,
),
);
},
),
),
],
);
},
),
);
}
@ -314,6 +338,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
@override
Widget build(BuildContext context) {
final index = ref.watch(libraryIndexProvider);
final visible = ref.watch(visibleTracksProvider);
final playback = ref.read(playbackProvider.notifier);
final client = ref.watch(subsonicClientProvider);
@ -341,61 +366,132 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
);
} else {
final downloads = ref.watch(downloadManagerProvider);
body = ListView.builder(
padding: EdgeInsets.zero,
itemCount: index.songs.length,
itemBuilder: (context, i) {
final song = index.songs[i];
final artUri = (client != null && song.coverArt != null)
? client.coverArtUri(song.coverArt!, size: 128).toString()
: null;
return BrowseRow(
title: song.title ?? 'Untitled',
subtitle: song.artist,
artUri: artUri,
downloadStatus: downloads.byId[song.id]?.status,
onTap: () => playback.playSongs(index.songs, startIndex: i),
onPlayNext: () => playback.playNext(song),
onAddToQueue: () => playback.addToQueue(song),
onAddToPlaylist: () =>
showAddToPlaylistSheet(context, songs: [song]),
onAddToTag: () => showAddTagSheet(context, songs: [song]),
onDownload: () =>
ref.read(downloadManagerProvider.notifier).download(song),
onRemoveDownload: () =>
ref.read(downloadManagerProvider.notifier).remove(song.id),
);
},
body = Column(
children: [
const TrackControlBar(),
const SizedBox(height: TimbreSpacing.md),
Expanded(
child: visible.isEmpty
? const _Centered(
child: _ErrorText('No tracks match these filters.'))
: ListView.builder(
padding: EdgeInsets.zero,
itemCount: visible.length,
itemBuilder: (context, i) {
final song = visible[i];
final artUri = (client != null && song.coverArt != null)
? client
.coverArtUri(song.coverArt!, size: 128)
.toString()
: null;
return BrowseRow(
title: song.title ?? 'Untitled',
subtitle: song.artist,
artUri: artUri,
downloadStatus: downloads.byId[song.id]?.status,
onTap: () =>
playback.playSongs(visible, startIndex: i),
onPlayNext: () => playback.playNext(song),
onAddToQueue: () => playback.addToQueue(song),
onAddToPlaylist: () =>
showAddToPlaylistSheet(context, songs: [song]),
onAddToTag: () =>
showAddTagSheet(context, songs: [song]),
onDownload: () => ref
.read(downloadManagerProvider.notifier)
.download(song),
onRemoveDownload: () => ref
.read(downloadManagerProvider.notifier)
.remove(song.id),
);
},
),
),
],
);
}
return HairlinePanel(
title: 'Tracks',
active: true,
trailing: index.songs.isNotEmpty ? '(${index.songs.length})' : null,
trailing: index.songs.isNotEmpty ? '(${visible.length})' : null,
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: Column(
action: Row(
mainAxisSize: MainAxisSize.min,
children: [
Align(
alignment: Alignment.centerRight,
child: InkWell(
onTap: index.building
? null
: () => ref.read(libraryIndexProvider.notifier).refresh(),
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: TimbreSpacing.lg,
vertical: TimbreSpacing.sm,
),
child: Text('↻ refresh',
style: TextStyle(color: TimbreColors.dimmed)),
),
InkWell(
onTap: index.building
? null
: () => ref.read(libraryIndexProvider.notifier).refresh(),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: TimbreSpacing.xs),
child: Text('↻ refresh',
style: TextStyle(color: TimbreColors.dimmed, fontSize: 12)),
),
),
Expanded(child: body),
if (visible.isNotEmpty)
PopupMenuButton<String>(
tooltip: 'More',
color: TimbreColors.surface,
padding: EdgeInsets.zero,
onSelected: (value) {
if (value == 'download-all') {
_confirmDownloadAll(context, visible);
}
},
itemBuilder: (_) => const [
PopupMenuItem<String>(
value: 'download-all',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.download,
size: 16, color: TimbreColors.foreground),
SizedBox(width: TimbreSpacing.sm),
Text('Download all',
style: TextStyle(color: TimbreColors.foreground)),
],
),
),
],
child: const Padding(
padding: EdgeInsets.all(TimbreSpacing.xs),
child: Icon(Icons.more_vert,
size: 18, color: TimbreColors.dimmed),
),
),
],
),
child: body,
);
}
/// Confirm before enqueueing a large batch of tracks — this can be the whole
/// library, so it's gated behind a dialog unlike per-album download-all.
/// [songs] is the currently-visible (filtered/sorted) set.
Future<void> _confirmDownloadAll(
BuildContext context, List<Song> songs) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: TimbreColors.surface,
title: const Text('Download these tracks?'),
content: Text(
'This queues all ${songs.length} listed tracks for offline '
'download. It may use significant storage and data.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Download all')),
],
),
);
if (ok != true || !context.mounted) return;
ref.read(downloadManagerProvider.notifier).downloadAll(songs);
showToast(context, 'Downloading tracks…');
}
}

View file

@ -293,11 +293,16 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> {
title: 'Queue',
trailing: '(${state.queue.length})',
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: ListView.builder(
controller: _controller,
child: ReorderableListView.builder(
scrollController: _controller,
padding: EdgeInsets.zero,
itemExtent: _rowExtent,
itemCount: state.queue.length,
// onReorderItem already reports newIndex as the post-removal target
// index — exactly the convention reorderQueue (and just_audio's
// moveAudioSource) expects — so no off-by-one adjustment is needed.
onReorderItem: (oldIndex, newIndex) =>
ref.read(playbackProvider.notifier).reorderQueue(oldIndex, newIndex),
itemBuilder: (context, i) {
final song = state.queue[i];
final current = state.currentIndex;
@ -307,6 +312,11 @@ class _QueuePanelState extends ConsumerState<_QueuePanel> {
? accent
: (isPast ? TimbreColors.dimmed : TimbreColors.foreground);
return InkWell(
// Position-based key: the same Song instance can legitimately sit in
// the queue twice (e.g. added twice), so an identity key would
// collide and ReorderableListView requires unique keys. These rows
// are stateless, so keying by index leaks no state.
key: ValueKey(i),
onTap: () => ref.read(playbackProvider.notifier).jumpTo(i),
child: Padding(
padding: const EdgeInsets.symmetric(

View file

@ -17,10 +17,15 @@ class PlaylistsScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final playlists = ref.watch(realPlaylistsProvider);
final mine = ref.watch(myPlaylistsProvider);
final shared = ref.watch(sharedPlaylistsProvider);
final controller = ref.read(playlistsProvider.notifier);
final connected = ref.watch(subsonicClientProvider) != null;
void open(Playlist p) => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => PlaylistDetailScreen(id: p.id)),
);
return Scaffold(
appBar: AppBar(
title: const Text('Playlists',
@ -34,54 +39,73 @@ class PlaylistsScreen extends ConsumerWidget {
],
),
body: SafeArea(
child: Padding(
child: ListView(
padding: const EdgeInsets.all(TimbreSpacing.lg),
child: HairlinePanel(
title: 'Playlists',
active: true,
trailing: playlists.isEmpty ? null : '(${playlists.length})',
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: playlists.isEmpty
? Center(
child: Text(
connected
? 'No playlists yet. Tap + to create one.'
: 'Connect to a server to see playlists.',
textAlign: TextAlign.center,
style: const TextStyle(color: TimbreColors.dimmed),
),
)
: ListView.builder(
padding: EdgeInsets.zero,
itemCount: playlists.length,
itemBuilder: (context, i) {
final p = playlists[i];
return _PlaylistRow(
name: p.name,
subtitle: p.songCount != null
? '${p.songCount} tracks'
: null,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => PlaylistDetailScreen(id: p.id),
children: [
HairlinePanel(
title: 'Playlists',
active: true,
trailing: mine.isEmpty ? null : '(${mine.length})',
padding: const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: mine.isEmpty
? Padding(
padding: const EdgeInsets.all(TimbreSpacing.lg),
child: Text(
connected
? 'No playlists yet. Tap + to create one.'
: 'Connect to a server to see playlists.',
textAlign: TextAlign.center,
style: const TextStyle(color: TimbreColors.dimmed),
),
)
: Column(
children: [
for (final p in mine)
_PlaylistRow(
name: p.name,
subtitle: p.songCount != null
? '${p.songCount} tracks'
: null,
badge: (p.public ?? false) ? 'Public' : null,
isPublic: p.public ?? false,
onTap: () => open(p),
onToggleShare: connected
? () => _toggleShare(context, controller, p)
: null,
onRename: connected
? () => _rename(context, controller, p)
: null,
onDelete: connected
? () => _confirmDelete(context, controller, p)
: null,
),
),
onRename: connected
? () async {
final name = await promptPlaylistName(context,
title: 'Rename playlist', initial: p.name);
if (name != null && name.isNotEmpty) {
controller.rename(p.id, name);
}
}
],
),
),
if (shared.isNotEmpty) ...[
const SizedBox(height: TimbreSpacing.xl),
HairlinePanel(
title: 'Shared',
active: true,
trailing: '(${shared.length})',
padding:
const EdgeInsets.symmetric(vertical: TimbreSpacing.md),
child: Column(
children: [
for (final p in shared)
_PlaylistRow(
name: p.name,
subtitle: p.owner != null ? 'by ${p.owner}' : null,
onTap: () => open(p),
onSaveCopy: connected
? () => _saveCopy(context, controller, p)
: null,
onDelete: connected
? () => _confirmDelete(context, controller, p)
: null,
);
},
),
),
),
],
),
),
],
],
),
),
);
@ -94,6 +118,32 @@ class PlaylistsScreen extends ConsumerWidget {
}
}
Future<void> _rename(
BuildContext context, PlaylistsController controller, Playlist p) async {
final name = await promptPlaylistName(context,
title: 'Rename playlist', initial: p.name);
if (name != null && name.isNotEmpty) controller.rename(p.id, name);
}
Future<void> _toggleShare(
BuildContext context, PlaylistsController controller, Playlist p) async {
final next = !(p.public ?? false);
await controller.setPublic(p.id, next);
if (context.mounted) {
showToast(context,
next ? 'Shared — now public' : 'No longer shared',
icon: Icons.check);
}
}
Future<void> _saveCopy(
BuildContext context, PlaylistsController controller, Playlist p) async {
final id = await controller.saveCopy(p);
if (!context.mounted) return;
showToast(context, id != null ? 'Saved a copy' : 'Could not save copy',
icon: id != null ? Icons.check : null);
}
Future<void> _confirmDelete(
BuildContext context, PlaylistsController controller, Playlist p) async {
final ok = await showDialog<bool>(
@ -120,18 +170,34 @@ class _PlaylistRow extends StatelessWidget {
required this.name,
required this.onTap,
this.subtitle,
this.badge,
this.isPublic = false,
this.onRename,
this.onDelete,
this.onToggleShare,
this.onSaveCopy,
});
final String name;
final String? subtitle;
/// Optional pill after the name, e.g. "Public".
final String? badge;
/// Current share state, so the toggle can label itself correctly.
final bool isPublic;
final VoidCallback onTap;
final VoidCallback? onRename;
final VoidCallback? onDelete;
final VoidCallback? onToggleShare;
final VoidCallback? onSaveCopy;
@override
Widget build(BuildContext context) {
final hasMenu = onRename != null ||
onDelete != null ||
onToggleShare != null ||
onSaveCopy != null;
return InkWell(
onTap: onTap,
child: Container(
@ -147,10 +213,21 @@ class _PlaylistRow extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: TimbreColors.foreground)),
Row(
children: [
Flexible(
child: Text(name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: TimbreColors.foreground)),
),
if (badge != null) ...[
const SizedBox(width: TimbreSpacing.sm),
_Pill(badge!),
],
],
),
if (subtitle != null)
Text(subtitle!,
style: const TextStyle(
@ -158,16 +235,32 @@ class _PlaylistRow extends StatelessWidget {
],
),
),
if (onRename != null || onDelete != null)
if (hasMenu)
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert,
size: 20, color: TimbreColors.dimmed),
color: TimbreColors.surface,
onSelected: (v) {
if (v == 'rename') onRename?.call();
if (v == 'delete') onDelete?.call();
switch (v) {
case 'share':
onToggleShare?.call();
case 'copy':
onSaveCopy?.call();
case 'rename':
onRename?.call();
case 'delete':
onDelete?.call();
}
},
itemBuilder: (_) => [
if (onToggleShare != null)
PopupMenuItem(
value: 'share',
child:
Text(isPublic ? 'Make private' : 'Make public')),
if (onSaveCopy != null)
const PopupMenuItem(
value: 'copy', child: Text('Save a copy')),
if (onRename != null)
const PopupMenuItem(value: 'rename', child: Text('Rename')),
if (onDelete != null)
@ -181,6 +274,28 @@ class _PlaylistRow extends StatelessWidget {
}
}
/// A small accent-outlined label — used to flag a playlist as "Public".
class _Pill extends StatelessWidget {
const _Pill(this.text);
final String text;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: TimbreSpacing.sm, vertical: 1),
decoration: BoxDecoration(
border: Border.all(color: accent),
color: accent.withValues(alpha: 0.12),
),
child: Text(text,
style: TextStyle(
color: accent, fontSize: 11, fontWeight: FontWeight.w700)),
);
}
}
/// One playlist's tracks: play all / download all from the app bar, remove a
/// track via its trailing control.
class PlaylistDetailScreen extends ConsumerStatefulWidget {
@ -209,9 +324,15 @@ class _PlaylistDetailScreenState extends ConsumerState<PlaylistDetailScreen> {
final summary = ref.watch(playlistsProvider.select((s) =>
s.playlists.where((p) => p.id == widget.id).firstOrNull));
final connected = ref.watch(subsonicClientProvider) != null;
final me = ref.watch(currentUsernameProvider);
final playback = ref.read(playbackProvider.notifier);
final songs = detail?.songs ?? const <Song>[];
// Ownership drives which sharing affordance shows: owner → share toggle;
// someone else's shared playlist → save-a-copy.
final isMine = summary == null || isOwnedBy(summary, me);
final isPublic = summary?.public ?? false;
return Scaffold(
appBar: AppBar(
title: Text(detail?.name ?? summary?.name ?? 'Playlist',
@ -237,6 +358,19 @@ class _PlaylistDetailScreenState extends ConsumerState<PlaylistDetailScreen> {
},
icon: const Icon(Icons.download),
),
if (connected && summary != null)
if (isMine)
IconButton(
tooltip: isPublic ? 'Make private' : 'Make public',
onPressed: () => _toggleShare(summary, isPublic),
icon: Icon(isPublic ? Icons.public : Icons.public_off),
)
else
IconButton(
tooltip: 'Save a copy',
onPressed: () => _saveCopy(summary),
icon: const Icon(Icons.save_alt),
),
],
),
body: SafeArea(
@ -273,6 +407,21 @@ class _PlaylistDetailScreenState extends ConsumerState<PlaylistDetailScreen> {
),
);
}
Future<void> _toggleShare(Playlist p, bool isPublic) async {
await ref.read(playlistsProvider.notifier).setPublic(p.id, !isPublic);
if (mounted) {
showToast(context, !isPublic ? 'Shared — now public' : 'No longer shared',
icon: Icons.check);
}
}
Future<void> _saveCopy(Playlist p) async {
final id = await ref.read(playlistsProvider.notifier).saveCopy(p);
if (!mounted) return;
showToast(context, id != null ? 'Saved a copy' : 'Could not save copy',
icon: id != null ? Icons.check : null);
}
}
class _TrackRow extends StatelessWidget {

View file

@ -73,6 +73,18 @@ class SettingsScreen extends ConsumerWidget {
labelFor: AppSettings.formatLabel,
onSelect: controller.setDownloadFormat,
),
const SizedBox(height: TimbreSpacing.lg),
const _Caption(
'How many tracks download at once. Higher is faster on '
'a strong connection; lower is gentler on the server.'),
const SizedBox(height: TimbreSpacing.md),
_Stepper(
label: 'Simultaneous downloads',
value: settings.maxConcurrentDownloads,
min: AppSettings.minConcurrentDownloads,
max: AppSettings.maxConcurrentDownloadsCap,
onChanged: controller.setMaxConcurrentDownloads,
),
],
),
),
@ -380,6 +392,101 @@ class _Chip extends StatelessWidget {
}
}
/// A labelled −/value/+ stepper bounded to [[min], [max]], styled to match the
/// bordered-chip language of [_ChoiceChips].
class _Stepper extends StatelessWidget {
const _Stepper({
required this.label,
required this.value,
required this.min,
required this.max,
required this.onChanged,
});
final String label;
final int value;
final int min;
final int max;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(color: TimbreColors.foreground)),
const SizedBox(height: TimbreSpacing.sm),
Row(
children: [
_StepButton(
icon: Icons.remove,
accent: accent,
enabled: value > min,
onTap: () => onChanged(value - 1),
),
Container(
constraints: const BoxConstraints(
minWidth: TimbreSpacing.minTouchTarget,
minHeight: TimbreSpacing.minTouchTarget),
alignment: Alignment.center,
child: Text(
'$value',
style: const TextStyle(
color: TimbreColors.foreground,
fontWeight: FontWeight.w700,
),
),
),
_StepButton(
icon: Icons.add,
accent: accent,
enabled: value < max,
onTap: () => onChanged(value + 1),
),
],
),
],
);
}
}
class _StepButton extends StatelessWidget {
const _StepButton({
required this.icon,
required this.accent,
required this.enabled,
required this.onTap,
});
final IconData icon;
final Color accent;
final bool enabled;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: enabled ? onTap : null,
child: Container(
constraints: const BoxConstraints(
minWidth: TimbreSpacing.minTouchTarget,
minHeight: TimbreSpacing.minTouchTarget),
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border.all(color: TimbreColors.border),
color: TimbreColors.surface,
),
child: Icon(
icon,
size: 18,
color: enabled ? accent : TimbreColors.dimmed,
),
),
);
}
}
/// A row of accent-color chips — like [_ChoiceChips] but each chip shows a
/// color swatch and the color's own hue drives the active border/fill.
class _ColorChips extends StatelessWidget {

View file

@ -15,6 +15,12 @@ enum BrowseMode { artists, albums, tracks }
/// post-filters to items whose *own* name/title matches the query.
enum SearchMode { discovery, standard }
/// Ordering applied to the Albums browse grid (filtered client-side).
enum AlbumSort { nameAsc, artistAsc, yearDesc, yearAsc, recentlyAdded }
/// Ordering applied to the Tracks browse list (filtered client-side).
enum TrackSort { titleAsc, artistAsc, albumAsc, yearDesc, recentlyAdded }
/// A selectable static accent color offered by the theme override (TODO #1).
class AccentChoice {
const AccentChoice(this.name, this.color);
@ -33,10 +39,13 @@ class AppSettings {
this.streamMaxBitRate = 0,
this.downloadMaxBitRate = 0,
this.downloadFormat,
this.maxConcurrentDownloads = defaultConcurrentDownloads,
this.useStaticAccent = false,
this.staticAccentColor = _defaultStaticAccent,
this.defaultBrowseMode = BrowseMode.artists,
this.searchMode = SearchMode.discovery,
this.albumSort = AlbumSort.nameAsc,
this.trackSort = TrackSort.titleAsc,
this.nowPlayingCassette = false,
});
@ -50,6 +59,11 @@ class AppSettings {
/// the original file / let the server decide.
final String? downloadFormat;
/// How many tracks may download at once. Re-read live by the download pump
/// (see `downloads/download_manager.dart`) so changes apply mid-session.
/// Clamped to [[minConcurrentDownloads], [maxConcurrentDownloadsCap]].
final int maxConcurrentDownloads;
/// When true, ignore album-art-derived accent and use [staticAccentColor]
/// as the sole accent color. When false (default), the accent tracks the
/// currently-playing album art.
@ -65,6 +79,11 @@ class AppSettings {
/// Which search matching strategy to apply globally.
final SearchMode searchMode;
/// Persisted sort order for the Albums / Tracks browse views. (Filters —
/// genre/year — are session-only and live in `state/providers.dart`.)
final AlbumSort albumSort;
final TrackSort trackSort;
/// When true, the Now Playing "art" view shows the animated cassette (cover
/// art on the label, reels driven by playback) instead of the plain square
/// album cover.
@ -76,6 +95,16 @@ class AppSettings {
/// Offered download containers; null renders as "Original".
static const List<String?> formatChoices = [null, 'mp3', 'opus', 'aac'];
/// Bounds and default for [maxConcurrentDownloads].
static const int minConcurrentDownloads = 1;
static const int maxConcurrentDownloadsCap = 10;
static const int defaultConcurrentDownloads = 3;
/// Clamp any value into the allowed concurrent-download range.
static int clampConcurrentDownloads(int v) => v < minConcurrentDownloads
? minConcurrentDownloads
: (v > maxConcurrentDownloadsCap ? maxConcurrentDownloadsCap : v);
/// Static accent palette offered when the override is enabled (TODO #1).
static const List<AccentChoice> accentChoices = [
AccentChoice('Red', Color(0xFFDF3535)),
@ -100,16 +129,33 @@ class AppSettings {
SearchMode.standard => 'Standard',
SearchMode.discovery => 'Discovery',
};
static String albumSortLabel(AlbumSort s) => switch (s) {
AlbumSort.nameAsc => 'Name',
AlbumSort.artistAsc => 'Artist',
AlbumSort.yearDesc => 'Year (newest)',
AlbumSort.yearAsc => 'Year (oldest)',
AlbumSort.recentlyAdded => 'Recently added',
};
static String trackSortLabel(TrackSort s) => switch (s) {
TrackSort.titleAsc => 'Title',
TrackSort.artistAsc => 'Artist',
TrackSort.albumAsc => 'Album',
TrackSort.yearDesc => 'Year (newest)',
TrackSort.recentlyAdded => 'Recently added',
};
AppSettings copyWith({
int? streamMaxBitRate,
int? downloadMaxBitRate,
// Sentinel so an explicit null (→ original) is distinguishable from "unset".
Object? downloadFormat = _unset,
int? maxConcurrentDownloads,
bool? useStaticAccent,
Color? staticAccentColor,
BrowseMode? defaultBrowseMode,
SearchMode? searchMode,
AlbumSort? albumSort,
TrackSort? trackSort,
bool? nowPlayingCassette,
}) =>
AppSettings(
@ -118,10 +164,14 @@ class AppSettings {
downloadFormat: identical(downloadFormat, _unset)
? this.downloadFormat
: downloadFormat as String?,
maxConcurrentDownloads:
maxConcurrentDownloads ?? this.maxConcurrentDownloads,
useStaticAccent: useStaticAccent ?? this.useStaticAccent,
staticAccentColor: staticAccentColor ?? this.staticAccentColor,
defaultBrowseMode: defaultBrowseMode ?? this.defaultBrowseMode,
searchMode: searchMode ?? this.searchMode,
albumSort: albumSort ?? this.albumSort,
trackSort: trackSort ?? this.trackSort,
nowPlayingCassette: nowPlayingCassette ?? this.nowPlayingCassette,
);
@ -131,10 +181,13 @@ class AppSettings {
'streamMaxBitRate': streamMaxBitRate,
'downloadMaxBitRate': downloadMaxBitRate,
if (downloadFormat != null) 'downloadFormat': downloadFormat,
'maxConcurrentDownloads': maxConcurrentDownloads,
'useStaticAccent': useStaticAccent,
'staticAccentColor': _hexOf(staticAccentColor),
'defaultBrowseMode': defaultBrowseMode.name,
'searchMode': searchMode.name,
'albumSort': albumSort.name,
'trackSort': trackSort.name,
'nowPlayingCassette': nowPlayingCassette,
};
@ -142,6 +195,9 @@ class AppSettings {
streamMaxBitRate: (j['streamMaxBitRate'] as num?)?.toInt() ?? 0,
downloadMaxBitRate: (j['downloadMaxBitRate'] as num?)?.toInt() ?? 0,
downloadFormat: j['downloadFormat'] as String?,
maxConcurrentDownloads: clampConcurrentDownloads(
(j['maxConcurrentDownloads'] as num?)?.toInt() ??
defaultConcurrentDownloads),
useStaticAccent: j['useStaticAccent'] as bool? ?? false,
staticAccentColor:
_colorOf(j['staticAccentColor'] as String?) ?? _defaultStaticAccent,
@ -151,6 +207,10 @@ class AppSettings {
searchMode:
_enumByName(SearchMode.values, j['searchMode'] as String?) ??
SearchMode.discovery,
albumSort: _enumByName(AlbumSort.values, j['albumSort'] as String?) ??
AlbumSort.nameAsc,
trackSort: _enumByName(TrackSort.values, j['trackSort'] as String?) ??
TrackSort.titleAsc,
nowPlayingCassette: j['nowPlayingCassette'] as bool? ?? false,
);
@ -214,6 +274,13 @@ class SettingsController extends StateNotifier<AppSettings> {
_persist();
}
void setMaxConcurrentDownloads(int count) {
state = state.copyWith(
maxConcurrentDownloads:
AppSettings.clampConcurrentDownloads(count));
_persist();
}
void setUseStaticAccent(bool value) {
state = state.copyWith(useStaticAccent: value);
_persist();
@ -234,6 +301,16 @@ class SettingsController extends StateNotifier<AppSettings> {
_persist();
}
void setAlbumSort(AlbumSort sort) {
state = state.copyWith(albumSort: sort);
_persist();
}
void setTrackSort(TrackSort sort) {
state = state.copyWith(trackSort: sort);
_persist();
}
void setNowPlayingCassette(bool value) {
state = state.copyWith(nowPlayingCassette: value);
_persist();

View file

@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../downloads/download_manager.dart';
import '../history/play_history.dart';
import '../library/browse_query.dart';
import '../library/library_index.dart';
import '../playback/playback_engine.dart';
import '../playlists/playlists.dart';
@ -17,7 +18,9 @@ import 'favorites.dart';
// `BrowseMode`/`SearchMode` are defined in the settings store (so they can be
// persisted) but historically lived here — re-export so existing importers of
// this file keep resolving them.
export '../settings/settings_store.dart' show BrowseMode, SearchMode;
export '../settings/settings_store.dart'
show BrowseMode, SearchMode, AlbumSort, TrackSort;
export '../library/browse_query.dart' show BrowseFilter;
// ---- Connection ---------------------------------------------------------
@ -296,6 +299,58 @@ final libraryIndexProvider =
return controller;
});
// ---- Browse filtering / sorting -----------------------------------------
/// Session-only genre/year filter for the Albums grid (resets on restart).
final albumFilterProvider =
StateProvider<BrowseFilter>((_) => const BrowseFilter());
/// Session-only genre/year filter for the Tracks list (resets on restart).
final trackFilterProvider =
StateProvider<BrowseFilter>((_) => const BrowseFilter());
/// Distinct genres present across all albums, for the album genre picker.
final albumGenresProvider = Provider<List<String>>((ref) {
final albums = ref.watch(albumsProvider).valueOrNull ?? const <Album>[];
return distinctGenres(albums.map((a) => a.genre));
});
/// Distinct release years present across all albums, newest first.
final albumYearsProvider = Provider<List<int>>((ref) {
final albums = ref.watch(albumsProvider).valueOrNull ?? const <Album>[];
return distinctYears(albums.map((a) => a.year));
});
/// Albums after applying the session filter and the persisted sort. Async so
/// callers keep the underlying load/error states from [albumsProvider].
final visibleAlbumsProvider = Provider<AsyncValue<List<Album>>>((ref) {
final filter = ref.watch(albumFilterProvider);
final sort = ref.watch(settingsProvider.select((s) => s.albumSort));
return ref
.watch(albumsProvider)
.whenData((albums) => applyAlbumQuery(albums, filter, sort));
});
/// Distinct genres present across all indexed tracks.
final trackGenresProvider = Provider<List<String>>((ref) {
final songs = ref.watch(libraryIndexProvider).songs;
return distinctGenres(songs.map((s) => s.genre));
});
/// Distinct release years present across all indexed tracks, newest first.
final trackYearsProvider = Provider<List<int>>((ref) {
final songs = ref.watch(libraryIndexProvider).songs;
return distinctYears(songs.map((s) => s.year));
});
/// Indexed tracks after applying the session filter and the persisted sort.
final visibleTracksProvider = Provider<List<Song>>((ref) {
final filter = ref.watch(trackFilterProvider);
final sort = ref.watch(settingsProvider.select((s) => s.trackSort));
final songs = ref.watch(libraryIndexProvider).songs;
return applyTrackQuery(songs, filter, sort);
});
/// Recently-added albums (`getAlbumList2` type `newest`) — a discovery shelf on
/// the Home tab. Small page; the Home shelf shows the first handful.
final newestAlbumsProvider = FutureProvider<List<Album>>((ref) async {
@ -418,6 +473,11 @@ final downloadManagerProvider =
ref.listen<String?>(serverKeyProvider, (_, _) {
controller.reloadForServer();
});
// Raising the concurrency cap should launch queued downloads right away.
ref.listen<int>(
settingsProvider.select((s) => s.maxConcurrentDownloads),
(_, _) => controller.onConcurrencyChanged(),
);
return controller;
});
@ -451,6 +511,30 @@ final realPlaylistsProvider = Provider<List<Playlist>>((ref) => ref
final tagsProvider = Provider<List<Playlist>>(
(ref) => ref.watch(playlistsProvider).playlists.where(isTagPlaylist).toList());
/// The signed-in user's name on the active server, or null when disconnected.
/// Used to split owned playlists from ones shared by other users.
final currentUsernameProvider = Provider<String?>(
(ref) => ref.watch(connectionProvider).credentials?.username);
/// The user's own playlists (owned, or owner unknown). Backs the main list and
/// the "add to playlist" sheet — you can only add tracks to your own playlists.
final myPlaylistsProvider = Provider<List<Playlist>>((ref) {
final me = ref.watch(currentUsernameProvider);
return ref
.watch(realPlaylistsProvider)
.where((p) => isOwnedBy(p, me))
.toList();
});
/// Playlists shared by *other* users on the same server (public, owner != me).
final sharedPlaylistsProvider = Provider<List<Playlist>>((ref) {
final me = ref.watch(currentUsernameProvider);
return ref
.watch(realPlaylistsProvider)
.where((p) => !isOwnedBy(p, me))
.toList();
});
// ---- Playback -----------------------------------------------------------
final playbackProvider =

View file

@ -23,6 +23,7 @@ class Song {
this.suffix,
this.size,
this.path,
this.created,
this.starred = false,
this.userRating,
});
@ -46,11 +47,19 @@ class Song {
final String? suffix;
final int? size;
final String? path;
/// ISO8601 date the item was added to the server (`created`), or null.
/// Powers "recently added" sort / "date added" filtering.
final String? created;
final bool starred;
/// 1–5, or null if unrated.
final int? userRating;
/// Parsed form of [created], or null if absent/unparseable.
DateTime? get createdAt =>
created == null ? null : DateTime.tryParse(created!);
factory Song.fromJson(Map<String, dynamic> j) => Song(
id: asString(j['id']) ?? '',
title: asString(j['title']),
@ -69,6 +78,7 @@ class Song {
suffix: asString(j['suffix']),
size: asInt(j['size']),
path: asString(j['path']),
created: asString(j['created']),
starred: j['starred'] != null,
userRating: asInt(j['userRating']),
);
@ -93,6 +103,7 @@ class Song {
if (suffix != null) 'suffix': suffix,
if (size != null) 'size': size,
if (path != null) 'path': path,
if (created != null) 'created': created,
if (starred) 'starred': true,
if (userRating != null) 'userRating': userRating,
};
@ -109,6 +120,7 @@ class Album {
this.duration,
this.year,
this.genre,
this.created,
this.starred = false,
this.userRating,
this.songs = const [],
@ -123,12 +135,19 @@ class Album {
final int? duration;
final int? year;
final String? genre;
/// ISO8601 date the album was added to the server (`created`), or null.
final String? created;
final bool starred;
final int? userRating;
/// Populated only by `getAlbum`.
final List<Song> songs;
/// Parsed form of [created], or null if absent/unparseable.
DateTime? get createdAt =>
created == null ? null : DateTime.tryParse(created!);
factory Album.fromJson(Map<String, dynamic> j) => Album(
id: asString(j['id']) ?? '',
name: asString(j['name']) ?? asString(j['album']),
@ -139,6 +158,7 @@ class Album {
duration: asInt(j['duration']),
year: asInt(j['year']),
genre: asString(j['genre']),
created: asString(j['created']),
starred: j['starred'] != null,
userRating: asInt(j['userRating']),
songs: oneOrMany(j['song'], Song.fromJson),

View file

@ -273,6 +273,13 @@ class SubsonicClient {
'updatePlaylist', {'playlistId': playlistId, 'comment': comment});
}
/// `updatePlaylist` + `public` — share (server-wide) or unshare a playlist.
/// Navidrome honors this; other users then see it in their `getPlaylists`.
Future<void> setPlaylistPublic(String playlistId, bool isPublic) async {
await _get(
'updatePlaylist', {'playlistId': playlistId, 'public': '$isPublic'});
}
/// `deletePlaylist` — delete a playlist by id.
Future<void> deletePlaylist(String id) async {
await _get('deletePlaylist', {'id': id});

View file

@ -13,6 +13,7 @@ class HairlinePanel extends StatelessWidget {
required this.title,
required this.child,
this.trailing,
this.action,
this.active = false,
this.padding = const EdgeInsets.all(TimbreSpacing.lg),
this.backgroundColor = TimbreColors.background,
@ -24,6 +25,11 @@ class HairlinePanel extends StatelessWidget {
/// Optional trailing bit of the label, e.g. "(127)" — dimmed.
final String? trailing;
/// Optional compact controls straddling the *right* end of the top border,
/// mirroring the title's treatment. Keep it short (icons / small text) so it
/// sits cleanly on the hairline.
final Widget? action;
/// Panel content.
final Widget child;
@ -85,6 +91,16 @@ class HairlinePanel extends StatelessWidget {
),
),
),
if (action != null)
Positioned(
right: TimbreSpacing.md,
top: 0,
child: Container(
color: backgroundColor,
padding: const EdgeInsets.symmetric(horizontal: TimbreSpacing.xs),
child: action,
),
),
],
);
}

View file

@ -1,5 +1,6 @@
import 'package:flutter/widgets.dart' show Color;
import 'package:flutter_test/flutter_test.dart';
import 'package:timbre/library/browse_query.dart';
import 'package:timbre/playlists/playlists.dart';
import 'package:timbre/settings/settings_store.dart';
import 'package:timbre/state/providers.dart';
@ -69,13 +70,14 @@ class _FakeClient extends SubsonicClient {
}
}
static Playlist _copy(Playlist p, {String? comment}) => Playlist(
static Playlist _copy(Playlist p, {String? comment, bool? public}) =>
Playlist(
id: p.id,
name: p.name,
songCount: p.songCount,
duration: p.duration,
owner: p.owner,
public: p.public,
public: public ?? p.public,
coverArt: p.coverArt,
comment: comment ?? p.comment,
);
@ -92,6 +94,13 @@ class _FakeClient extends SubsonicClient {
songs: songs ?? d.songs,
);
@override
Future<void> setPlaylistPublic(String playlistId, bool isPublic) async {
_maybeThrow();
final i = playlists.indexWhere((p) => p.id == playlistId);
if (i >= 0) playlists[i] = _copy(playlists[i], public: isPublic);
}
@override
Future<void> removeTrackFromPlaylist(String playlistId, int index) async {
_maybeThrow();
@ -163,6 +172,9 @@ void main() {
staticAccentColor: Color(0xFF926CE9), // Purple
defaultBrowseMode: BrowseMode.albums,
searchMode: SearchMode.standard,
maxConcurrentDownloads: 7,
albumSort: AlbumSort.recentlyAdded,
trackSort: TrackSort.yearDesc,
nowPlayingCassette: true,
);
final back = AppSettings.fromJson(s.toJson());
@ -170,6 +182,9 @@ void main() {
expect(back.staticAccentColor, const Color(0xFF926CE9));
expect(back.defaultBrowseMode, BrowseMode.albums);
expect(back.searchMode, SearchMode.standard);
expect(back.maxConcurrentDownloads, 7);
expect(back.albumSort, AlbumSort.recentlyAdded);
expect(back.trackSort, TrackSort.yearDesc);
expect(back.nowPlayingCassette, isTrue);
});
@ -181,6 +196,10 @@ void main() {
expect(back.staticAccentColor, const Color(0xFFDF7E35)); // Orange
expect(back.defaultBrowseMode, BrowseMode.artists);
expect(back.searchMode, SearchMode.discovery);
expect(back.maxConcurrentDownloads,
AppSettings.defaultConcurrentDownloads);
expect(back.albumSort, AlbumSort.nameAsc);
expect(back.trackSort, TrackSort.titleAsc);
expect(back.nowPlayingCassette, isFalse);
// An unrecognised enum name also falls back rather than throwing.
expect(
@ -188,6 +207,13 @@ void main() {
BrowseMode.artists,
);
});
test('fromJson clamps an out-of-range concurrent-download count', () {
expect(AppSettings.fromJson({'maxConcurrentDownloads': 99})
.maxConcurrentDownloads, AppSettings.maxConcurrentDownloadsCap);
expect(AppSettings.fromJson({'maxConcurrentDownloads': 0})
.maxConcurrentDownloads, AppSettings.minConcurrentDownloads);
});
});
group('SubsonicCredentials', () {
@ -359,4 +385,153 @@ void main() {
expect(controller.state.details[id]!.songs.length, 3);
});
});
group('Playlist sharing (TODO #5)', () {
late _FakeClient client;
late PlaylistsController controller;
setUp(() {
client = _FakeClient();
controller = PlaylistsController(
clientGetter: () => client,
serverKeyGetter: () => null,
);
});
test('isOwnedBy treats unknown owner/viewer as mine', () {
expect(isOwnedBy(Playlist(id: 'a', name: 'A'), 'me'), isTrue);
expect(
isOwnedBy(Playlist(id: 'a', name: 'A', owner: 'bob'), null), isTrue);
expect(
isOwnedBy(Playlist(id: 'a', name: 'A', owner: 'me'), 'me'), isTrue);
expect(
isOwnedBy(Playlist(id: 'a', name: 'A', owner: 'bob'), 'me'), isFalse);
});
test('setPublic flips the summary flag optimistically', () async {
final id = (await controller.create('Mix'))!;
await controller.setPublic(id, true);
expect(controller.state.playlists.firstWhere((p) => p.id == id).public,
isTrue);
await controller.setPublic(id, false);
expect(controller.state.playlists.firstWhere((p) => p.id == id).public,
isFalse);
});
test('setPublic reverts when the server call fails', () async {
final id = (await controller.create('Mix'))!;
client.failNext = true;
await controller.setPublic(id, true);
expect(controller.state.playlists.firstWhere((p) => p.id == id).public,
isNot(true));
});
test('saveCopy clones tracks into a new owned playlist', () async {
final srcId = (await controller.create('Shared Mix'))!;
await controller.addTracks(srcId, [_song('a'), _song('b'), _song('c')]);
final source =
controller.state.playlists.firstWhere((p) => p.id == srcId);
final copyId = await controller.saveCopy(source);
expect(copyId, isNotNull);
expect(copyId, isNot(srcId));
// Two playlists named "Shared Mix"; the copy carries all three tracks.
expect(controller.state.playlists.where((p) => p.name == 'Shared Mix'),
hasLength(2));
expect(controller.state.details[copyId]!.songs.map((s) => s.id),
['a', 'b', 'c']);
});
});
group('browse filtering & sorting (TODO #2)', () {
Album album(String id,
{String? name, String? genre, int? year, String? created}) =>
Album(id: id, name: name ?? id, genre: genre, year: year, created: created);
Song song(String id,
{String? title,
String? artist,
String? album,
String? genre,
int? year,
String? created}) =>
Song(
id: id,
title: title ?? id,
artist: artist,
album: album,
genre: genre,
year: year,
created: created);
test('distinctGenres dedupes case-insensitively and sorts', () {
final g = distinctGenres(['Rock', 'rock', 'Jazz', null, '', ' Pop ']);
expect(g, ['Jazz', 'Pop', 'Rock']); // first-seen casing, trimmed
});
test('distinctYears drops null/zero and sorts newest-first', () {
expect(distinctYears([1999, null, 2020, 0, 1999]), [2020, 1999]);
});
test('album genre filter is case-insensitive', () {
final albums = [
album('a', genre: 'Rock'),
album('b', genre: 'jazz'),
album('c', genre: 'ROCK'),
];
final out = applyAlbumQuery(
albums, const BrowseFilter(genre: 'rock'), AlbumSort.nameAsc);
expect(out.map((a) => a.id), ['a', 'c']);
});
test('album year filter matches exactly', () {
final albums = [album('a', year: 2001), album('b', year: 2002)];
final out = applyAlbumQuery(
albums, const BrowseFilter(year: 2002), AlbumSort.nameAsc);
expect(out.map((a) => a.id), ['b']);
});
test('album recentlyAdded sorts newest first, nulls last', () {
final albums = [
album('old', created: '2001-01-01T00:00:00'),
album('new', created: '2020-01-01T00:00:00'),
album('none'),
];
final out =
applyAlbumQuery(albums, const BrowseFilter(), AlbumSort.recentlyAdded);
expect(out.map((a) => a.id), ['new', 'old', 'none']);
});
test('album yearDesc keeps null years last', () {
final albums = [
album('a', year: 1990),
album('b'),
album('c', year: 2010),
];
final out =
applyAlbumQuery(albums, const BrowseFilter(), AlbumSort.yearDesc);
expect(out.map((a) => a.id), ['c', 'a', 'b']);
});
test('track album sort falls back to track number then title', () {
final songs = [
song('s2', album: 'X', title: 'Zed'),
song('s1', album: 'X', title: 'Abe'),
];
final out =
applyTrackQuery(songs, const BrowseFilter(), TrackSort.albumAsc);
// Same album, no track numbers → tie-break by title.
expect(out.map((s) => s.id), ['s1', 's2']);
});
test('track filter + sort compose', () {
final songs = [
song('a', genre: 'Rock', year: 2000, title: 'B'),
song('b', genre: 'Rock', year: 2000, title: 'A'),
song('c', genre: 'Jazz', year: 2000, title: 'C'),
];
final out = applyTrackQuery(
songs, const BrowseFilter(genre: 'rock'), TrackSort.titleAsc);
expect(out.map((s) => s.id), ['b', 'a']);
});
});
}

69
updates-features.md Normal file
View file

@ -0,0 +1,69 @@
# Updates and Features
These are ranked in order of importance and should be tackled one element at a time before moving onto the next one. This stands as the top level orchestration for improving the application.
### Completion Checklist
- [x] 1. Drag n Drop Queue Reorder
- [x] 2. Filtering and Sorting Options
- [ ] 3. Network Connectivity
- [x] 4. Simoultaneous Download Setting
- [x] 5. Playlist Sharing
- [ ] 6. In-App Purchase
### Details
1. Drag n Drop Queue Reorder
Currently the queue only allows the user to add utilizing the dedicated buttons throughout the app or remove elements using the 'X' option in the queue itself. Once an item has been added to the queue there is no method of moving it to another point. For example, if I decided I wwanted to shift the next song dowwn a handful of places in the queue it is currently not possible. We should add a drag and drop functionality to reorder elements in the queue.
2. Filtering and Sorting Options
Within the Albums and Tracks section of the app library there should be more options for sorting and filtering. I would like the ability to filter based on genre, release year, and when it wwas added to the server. That wwway it can be easier to find what I am looking for without have to directly search for anything. All of those elements should be available in the metadata.
3. Network Connectivity
This is probably the largest task of this group. I wwwant there to be connectivity through instances of the application. For example I have the app on my ipad as ell as my phone nd I would like the ability to control the device that is currently playing with the other. Basically a shared state in the app that allows for wireless control. This is a common feature of all music apps and should be included in ours as well.
4. Simoultaneous Download Setting
Currently the app will only dowwnload 3 songs concurrently. There should be an option in settings to specify the number of concurrent tracks that can be dowwnloaded at a time to help speed up the process. In conjunction with this there should be an option on tracks to download all tracks.
5. Playlist Sharing
I have multiple users on my server that I have active in the application and I want to be able to share playlists amongst us all. Given the fact we are sharing the same server this should be possible. This wwwill require thinking to figure out the guardrails and application of sharing but I wwant this feature.
6. In-App Purchase
I want a "Buy the dev a coffee" type payment availbale in the app. Android this is not too much of a concern for review etc but for Apple IOS review it wmight be more of a hassle. Basically I want an optional button/toggle in settings that would let a user pay 5$ for the app voluntarily to help with its development.
---
## Execution Plan
**Stack:** Flutter/Dart · Riverpod · dio · just_audio · Subsonic API (Navidrome). **Hard constraint: client-only** — no server changes; every feature must ride existing Subsonic endpoints or live entirely in-app.
**Build order:** `1 → 4 → 2 → 5 → 6 → (3 as its own project)`. (Reordered from #4 before #2 — #4 is the smallest win and warms up the settings plumbing #2 reuses.)
### 1. Drag-n-Drop Queue Reorder — Small, pure client
- Add `reorderQueue(oldIndex, newIndex)` to `PlaybackController` (`lib/playback/playback_engine.dart`): move item in `queue`, adjust `currentIndex` so the now-playing track keeps playing, mirror the move onto just_audio's `ConcatenatingAudioSource` (reuse the existing `_applyOrder` path).
- Swap queue UI to `ReorderableListView`.
- **Resolved semantics:** reorder mutates the currently-active order. Reorder-then-shuffle → shuffle re-randomizes upcoming tail as normal. Shuffle-then-reorder → the manual move sticks, rest stays shuffled. (Open edge case: whether a reorder made while shuffled should also rewrite `_unshuffledOrder` so it survives un-shuffle — deferred.)
### 4. Simultaneous Download Setting + Download-All — Small, pure client
- Replace `static const int _maxConcurrent = 3` (`lib/downloads/download_manager.dart:141`) with a settings-backed value; ensure the pump loop re-reads it so changes apply live. Sane cap ~1–10.
- Add stepper setting in `lib/screens/settings_screen.dart` + `lib/settings/settings_store.dart`.
- Add "Download all" bulk-enqueue actions on album / playlist / tracks contexts.
### 2. Filtering & Sorting (genre / year / date-added) — Medium, client-only feasible
- **Prereq:** parse `created` (date-added) into Album/Song models in `lib/subsonic/models.dart` — not present today, required for "when added."
- **Albums:** extend `getAlbumList2` (`lib/subsonic/subsonic_client.dart`) to pass `genre`, `fromYear`/`toYear`; use `type=newest` for date-added; add `getGenres` for the genre picker.
- **Tracks:** sort/filter client-side over cached `lib/library/library_index.dart` (currently alphabetical-only).
- Add filter/sort control bar to `lib/screens/browser_screen.dart` panels; persist last choice in `settings_store.dart`.
- **Risks:** genre is free-text (casing / multi-genre inconsistency); server-side album lists paginate at 500.
### 5. Playlist Sharing (same-server users) — Medium, client-only feasible
- `Playlist` model already exposes `owner` + `public` (`lib/subsonic/models.dart:253-263`).
- Add `updatePlaylist(public: true)` support to client; add a "Share / make public" toggle in playlist UI. Surface a "Shared" section for playlists where `owner != currentUser`.
- **Resolved:** public (server-wide) sharing is acceptable. A non-owner **saves a copy** — clone the public playlist into their own via `createPlaylist` + `addTracksToPlaylist` (avoids edit-permission issues; owner-only edit/delete on originals).
- **Risks:** server-dependent — confirm Navidrome honors `public`. No targeted per-user sharing possible client-only (all-or-public only).
### 6. In-App "Buy the Dev a Coffee" ($5) — Medium, client + store config
- **Resolved:** route through IAP (review-safe). Flutter `in_app_purchase`, modeled as a **consumable** $5 product configured in App Store Connect + Google Play Console. Apple requires digital tips to go through IAP (no external payment link on iOS). Optional button in `lib/screens/settings_screen.dart`.
### 3. Network Connectivity / Cross-Device Control — Deferred deep-dive
Biggest lift. Client-only + Subsonic offers no shared-state or device-to-device channel, and the app has zero realtime code. Candidate directions to evaluate in a dedicated session: (a) Subsonic **jukebox mode** (server-side playback control, not another client — limited), (b) **LAN mDNS discovery + direct socket** (no server, same-Wi-Fi only), (c) relay service (would break client-only). No work until scoped separately.