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

View file

@ -0,0 +1,365 @@
// Callback fields are assigned from named required params, which can't be
// private initializing formals.
// ignore_for_file: prefer_initializing_formals
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import '../settings/settings_store.dart';
import '../subsonic/models.dart';
import '../subsonic/subsonic_client.dart';
/// Lifecycle of a single track download.
enum DownloadStatus { queued, downloading, done, failed }
/// Per-song download record. Only [DownloadStatus.done] entries are persisted
/// to the manifest; queued/downloading/failed entries are in-memory only.
class DownloadInfo {
const DownloadInfo({
required this.song,
required this.status,
this.path,
this.bitRate,
this.format,
this.sizeBytes,
this.progress = 0,
this.error,
});
final Song song;
final DownloadStatus status;
/// Absolute path to the downloaded file (set once [status] is done).
final String? path;
final int? bitRate;
final String? format;
final int? sizeBytes;
/// 0.0–1.0 while downloading (0 if the total size is unknown mid-flight).
final double progress;
final String? error;
bool get isDone => status == DownloadStatus.done;
bool get isActive =>
status == DownloadStatus.queued || status == DownloadStatus.downloading;
DownloadInfo copyWith({
DownloadStatus? status,
String? path,
int? bitRate,
String? format,
int? sizeBytes,
double? progress,
String? error,
}) =>
DownloadInfo(
song: song,
status: status ?? this.status,
path: path ?? this.path,
bitRate: bitRate ?? this.bitRate,
format: format ?? this.format,
sizeBytes: sizeBytes ?? this.sizeBytes,
progress: progress ?? this.progress,
error: error,
);
Map<String, dynamic> toJson() => {
'song': song.toJson(),
'path': path,
if (bitRate != null) 'bitRate': bitRate,
if (format != null) 'format': format,
if (sizeBytes != null) 'sizeBytes': sizeBytes,
};
/// Rebuild a completed record from the manifest.
factory DownloadInfo.fromJson(Map<String, dynamic> j) => DownloadInfo(
song: Song.fromJson((j['song'] as Map).cast<String, dynamic>()),
status: DownloadStatus.done,
path: j['path'] as String?,
bitRate: (j['bitRate'] as num?)?.toInt(),
format: j['format'] as String?,
sizeBytes: (j['sizeBytes'] as num?)?.toInt(),
progress: 1,
);
}
/// Snapshot of all known downloads for the active server, keyed by song id.
class DownloadState {
const DownloadState({this.byId = const {}});
final Map<String, DownloadInfo> byId;
DownloadInfo? operator [](String id) => byId[id];
bool isDownloaded(String id) => byId[id]?.isDone ?? false;
List<DownloadInfo> get completed =>
byId.values.where((d) => d.isDone).toList();
int get totalBytes => completed.fold(0, (sum, d) => sum + (d.sizeBytes ?? 0));
DownloadState copyWith({Map<String, DownloadInfo>? byId}) =>
DownloadState(byId: byId ?? this.byId);
}
/// Downloads tracks to disk for offline playback. Files live under
/// `<appSupport>/downloads/<serverKey>/` and a `downloads_<serverKey>.json`
/// manifest survives restarts. Modeled on `library/library_index.dart`
/// (per-server keying, atomic temp+rename writes, a generation guard so a
/// server switch mid-download can't clobber the new server's manifest).
class DownloadController extends StateNotifier<DownloadState> {
DownloadController({
required SubsonicClient? Function() clientGetter,
required AppSettings Function() settingsGetter,
required String? Function() serverKeyGetter,
}) : _clientGetter = clientGetter,
_settingsGetter = settingsGetter,
_serverKeyGetter = serverKeyGetter,
super(const DownloadState()) {
reloadForServer();
}
final SubsonicClient? Function() _clientGetter;
final AppSettings Function() _settingsGetter;
final String? Function() _serverKeyGetter;
final Dio _dio = Dio(BaseOptions(
receiveTimeout: const Duration(minutes: 5),
headers: {'User-Agent': 'ratune-mobile'},
));
static const int _maxConcurrent = 3;
int _generation = 0;
String? _loadedKey;
final List<String> _queue = [];
int _active = 0;
/// Path to a downloaded file if (and only if) it is fully downloaded — read
/// synchronously by the playback stream-URI resolver.
String? localPathFor(String id) {
final info = state.byId[id];
return info != null && info.isDone ? info.path : null;
}
bool isDownloaded(String id) => state.isDownloaded(id);
// ---- Server switching / manifest load ----------------------------------
Future<Directory> _downloadsDir(String key) async {
final dir = await getApplicationSupportDirectory();
final d = Directory('${dir.path}/downloads/$key');
if (!await d.exists()) await d.create(recursive: true);
return d;
}
Future<File> _manifestFile(String key) async {
final dir = await getApplicationSupportDirectory();
return File('${dir.path}/downloads_$key.json');
}
/// Load (or clear) the manifest when the active server changes. Verifies each
/// file still exists on disk and drops stale entries.
Future<void> reloadForServer() async {
final key = _serverKeyGetter();
if (key == _loadedKey) return;
_generation++;
final gen = _generation;
_loadedKey = key;
_queue.clear();
if (key == null) {
state = const DownloadState();
return;
}
try {
final file = await _manifestFile(key);
if (!await file.exists()) {
if (gen == _generation) state = const DownloadState();
return;
}
final raw = jsonDecode(await file.readAsString());
final byId = <String, DownloadInfo>{};
if (raw is List) {
for (final e in raw.whereType<Map>()) {
final info = DownloadInfo.fromJson(e.cast<String, dynamic>());
final path = info.path;
if (path != null && await File(path).exists()) {
byId[info.song.id] = info;
}
}
}
if (gen == _generation) state = DownloadState(byId: byId);
} catch (_) {
if (gen == _generation) state = const DownloadState();
}
}
// ---- Enqueue / download -------------------------------------------------
/// Queue [song] for download (no-op if already downloaded or in flight).
void download(Song song) {
if (song.id.isEmpty) return;
final existing = state.byId[song.id];
if (existing != null && (existing.isDone || existing.isActive)) return;
if (_clientGetter() == null) return; // offline — nothing to fetch.
_put(DownloadInfo(song: song, status: DownloadStatus.queued));
_queue.add(song.id);
_pump();
}
/// Queue every track (album / playlist "download all").
void downloadAll(List<Song> songs) {
for (final s in songs) {
download(s);
}
}
void _pump() {
while (_active < _maxConcurrent && _queue.isNotEmpty) {
final id = _queue.removeAt(0);
final info = state.byId[id];
if (info == null || info.status != DownloadStatus.queued) continue;
_active++;
_run(info.song).whenComplete(() {
_active--;
_pump();
});
}
}
Future<void> _run(Song song) async {
final gen = _generation;
final client = _clientGetter();
final key = _serverKeyGetter();
if (client == null || key == null) {
_put(state.byId[song.id]!
.copyWith(status: DownloadStatus.failed, error: 'Not connected'));
return;
}
final settings = _settingsGetter();
final rate = settings.downloadMaxBitRate;
final format = settings.downloadFormat;
final uri = client.streamUri(song.id, maxBitRate: rate, format: format);
// Choose a sensible extension; the stored path is authoritative regardless.
final ext = format ?? (rate > 0 ? 'mp3' : (song.suffix ?? 'mp3'));
try {
_put(state.byId[song.id]!
.copyWith(status: DownloadStatus.downloading, progress: 0));
final dir = await _downloadsDir(key);
final finalPath = '${dir.path}/${song.id}.$ext';
final tmpPath = '$finalPath.part';
await _dio.downloadUri(
uri,
tmpPath,
onReceiveProgress: (received, total) {
if (gen != _generation) return;
if (total > 0) {
final cur = state.byId[song.id];
if (cur != null && cur.status == DownloadStatus.downloading) {
_put(cur.copyWith(progress: received / total));
}
}
},
);
if (gen != _generation) {
// Server switched mid-download — discard the partial file.
await File(tmpPath).delete().catchError((_) => File(tmpPath));
return;
}
final tmp = File(tmpPath);
await tmp.rename(finalPath);
final size = await File(finalPath).length();
_put(DownloadInfo(
song: song,
status: DownloadStatus.done,
path: finalPath,
bitRate: rate == 0 ? null : rate,
format: format,
sizeBytes: size,
progress: 1,
));
await _persist();
} catch (e) {
if (gen != _generation) return;
final cur = state.byId[song.id];
if (cur != null) {
_put(cur.copyWith(status: DownloadStatus.failed, error: e.toString()));
}
}
}
// ---- Remove -------------------------------------------------------------
/// Delete a single download (file + manifest entry).
Future<void> remove(String songId) async {
final info = state.byId[songId];
if (info == null) return;
_queue.remove(songId);
if (info.path != null) {
try {
final f = File(info.path!);
if (await f.exists()) await f.delete();
} catch (_) {}
}
final next = Map<String, DownloadInfo>.from(state.byId)..remove(songId);
state = state.copyWith(byId: next);
await _persist();
}
/// Delete every download for the active server.
Future<void> clearAll() async {
_queue.clear();
final key = _serverKeyGetter();
for (final info in state.byId.values) {
if (info.path != null) {
try {
final f = File(info.path!);
if (await f.exists()) await f.delete();
} catch (_) {}
}
}
state = const DownloadState();
if (key != null) {
try {
final file = await _manifestFile(key);
if (await file.exists()) await file.delete();
} catch (_) {}
}
}
// ---- Internals ----------------------------------------------------------
void _put(DownloadInfo info) {
state = state.copyWith(
byId: {...state.byId, info.song.id: info},
);
}
/// Atomic write of the completed-downloads manifest (temp + rename).
Future<void> _persist() async {
final key = _serverKeyGetter();
if (key == null) return;
try {
final file = await _manifestFile(key);
final tmp = File('${file.path}.tmp');
await tmp.writeAsString(
jsonEncode(state.completed.map((d) => d.toJson()).toList()),
);
await tmp.rename(file.path);
} catch (_) {}
}
}

View file

@ -0,0 +1,244 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import '../subsonic/models.dart';
/// One play event, mirroring Ratune's `PlayRecord` (`history.rs`).
class PlayRecord {
PlayRecord({
required this.songId,
required this.title,
required this.playedAt,
this.album,
this.albumId,
this.artist,
this.artistId,
this.coverArt,
this.duration,
});
final String songId;
final String title;
final DateTime playedAt;
final String? album;
final String? albumId;
final String? artist;
final String? artistId;
final String? coverArt;
final int? duration;
Map<String, dynamic> toJson() => {
'songId': songId,
'title': title,
'playedAt': playedAt.toIso8601String(),
'album': album,
'albumId': albumId,
'artist': artist,
'artistId': artistId,
'coverArt': coverArt,
'duration': duration,
};
factory PlayRecord.fromJson(Map<String, dynamic> j) => PlayRecord(
songId: j['songId'] as String? ?? '',
title: j['title'] as String? ?? 'Untitled',
playedAt: DateTime.tryParse(j['playedAt'] as String? ?? '') ??
DateTime.fromMillisecondsSinceEpoch(0),
album: j['album'] as String?,
albumId: j['albumId'] as String?,
artist: j['artist'] as String?,
artistId: j['artistId'] as String?,
coverArt: j['coverArt'] as String?,
duration: (j['duration'] as num?)?.toInt(),
);
/// Reconstruct a playable [Song] from this record (enough for streaming).
Song toSong() => Song(
id: songId,
title: title,
album: album,
albumId: albumId,
artist: artist,
artistId: artistId,
coverArt: coverArt,
duration: duration,
);
factory PlayRecord.fromSong(Song s, DateTime at) => PlayRecord(
songId: s.id,
title: s.title ?? 'Untitled',
playedAt: at,
album: s.album,
albumId: s.albumId,
artist: s.artist,
artistId: s.artistId,
coverArt: s.coverArt,
duration: s.duration,
);
}
/// A rediscover suggestion — an artist not heard recently.
class RediscoverArtist {
RediscoverArtist({
required this.artistId,
required this.name,
required this.lastPlayed,
required this.playCount,
});
final String artistId;
final String name;
final DateTime lastPlayed;
final int playCount;
}
/// Owns the persistent play history (newest first, capped at [_maxRecords]).
class HistoryController extends StateNotifier<List<PlayRecord>> {
HistoryController() : super(const []) {
_load();
}
static const int _maxRecords = 10000;
File? _file;
Future<void> _load() async {
try {
final dir = await getApplicationSupportDirectory();
_file = File('${dir.path}/play_history.json');
if (await _file!.exists()) {
final raw = jsonDecode(await _file!.readAsString());
if (raw is List) {
state = raw
.whereType<Map>()
.map((e) => PlayRecord.fromJson(e.cast<String, dynamic>()))
.toList();
}
}
} catch (_) {
// Corrupt/missing history is non-fatal — start empty.
}
}
Future<void> record(Song song, {DateTime? at}) async {
if (song.id.isEmpty) return;
final rec = PlayRecord.fromSong(song, at ?? DateTime.now());
final next = [rec, ...state];
if (next.length > _maxRecords) next.removeRange(_maxRecords, next.length);
state = next;
unawaited(_persist());
}
Future<void> _persist() async {
try {
await _file?.writeAsString(
jsonEncode(state.map((e) => e.toJson()).toList()),
);
} catch (_) {}
}
}
// ---- Pure derivations over a history list -------------------------------
/// Distinct recently-played songs (most recent first).
List<PlayRecord> recentSongs(List<PlayRecord> history, {int limit = 20}) {
final seen = <String>{};
final out = <PlayRecord>[];
for (final r in history) {
if (seen.add(r.songId)) {
out.add(r);
if (out.length >= limit) break;
}
}
return out;
}
/// Distinct recently-played albums (most recent first).
List<PlayRecord> recentAlbums(List<PlayRecord> history, {int limit = 12}) {
final seen = <String>{};
final out = <PlayRecord>[];
for (final r in history) {
final key = r.albumId;
if (key == null) continue;
if (seen.add(key)) {
out.add(r);
if (out.length >= limit) break;
}
}
return out;
}
/// Rediscover: artists you've heard before but aren't listening to now, biased
/// toward low play counts, then sampled for variety (Ratune
/// `history.rs:101-167`). [seed] drives the re-roll.
///
/// The desktop original only surfaces artists last heard more than [minDays]
/// days ago. That's dead weight on a young history (everything is recent), so
/// we fall back to your least-recently-played artists — minus the couple you
/// just heard — whenever too few have genuinely aged out.
List<RediscoverArtist> rediscover(
List<PlayRecord> history, {
int count = 6,
int minDays = 3,
int seed = 0,
DateTime? now,
}) {
final ref = now ?? DateTime.now();
final cutoff = ref.subtract(Duration(days: minDays));
final byArtist = <String, RediscoverArtist>{};
for (final r in history) {
final id = r.artistId;
if (id == null || r.artist == null) continue;
final existing = byArtist[id];
if (existing == null) {
byArtist[id] = RediscoverArtist(
artistId: id,
name: r.artist!,
lastPlayed: r.playedAt,
playCount: 1,
);
} else {
byArtist[id] = RediscoverArtist(
artistId: id,
name: existing.name,
lastPlayed:
r.playedAt.isAfter(existing.lastPlayed) ? r.playedAt : existing.lastPlayed,
playCount: existing.playCount + 1,
);
}
}
final all = byArtist.values.toList();
if (all.length <= 1) return const [];
// Primary: artists genuinely not heard in the last [minDays].
var candidates = all.where((a) => a.lastPlayed.isBefore(cutoff)).toList();
// Fallback for young histories: everything except the artists you've heard
// most recently (so we don't suggest what's playing now), which keeps the
// "you're neglecting these" spirit without an absolute age wall.
if (candidates.length < count) {
final byRecency = [...all]..sort((a, b) => b.lastPlayed.compareTo(a.lastPlayed));
final recentlyHeard =
byRecency.take(2).map((a) => a.artistId).toSet();
candidates =
all.where((a) => !recentlyHeard.contains(a.artistId)).toList();
}
// Prefer low play counts, then longest since last heard.
candidates.sort((a, b) {
final byCount = a.playCount.compareTo(b.playCount);
if (byCount != 0) return byCount;
return a.lastPlayed.compareTo(b.lastPlayed);
});
// Weighted variety: sample from the top slice using the seed.
final pool = candidates.take(count * 3).toList();
pool.shuffle(Random(seed));
return pool.take(count).toList();
}

View file

@ -0,0 +1,193 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import '../subsonic/models.dart';
import '../subsonic/subsonic_client.dart';
/// Snapshot of the library-song index: the flat song list plus build progress.
class LibraryIndexState {
const LibraryIndexState({
this.songs = const [],
this.building = false,
this.done = 0,
this.total = 0,
this.error,
});
final List<Song> songs;
final bool building;
/// Albums crawled so far / total albums (drives "Indexing 120/340…").
final int done;
final int total;
final String? error;
bool get isEmpty => songs.isEmpty;
LibraryIndexState copyWith({
List<Song>? songs,
bool? building,
int? done,
int? total,
String? error,
}) =>
LibraryIndexState(
songs: songs ?? this.songs,
building: building ?? this.building,
done: done ?? this.done,
total: total ?? this.total,
error: error,
);
}
/// Builds and caches a flat "all songs" index — Subsonic has no all-songs
/// endpoint, so we crawl every album (mirrors Ratune's `library_index` /
/// `fetch_all_library_songs`). The cache is keyed to the server so switching
/// servers never serves a stale catalog, and writes are atomic so an
/// interrupted crawl can't leave a truncated file.
class LibraryIndexController extends StateNotifier<LibraryIndexState> {
LibraryIndexController(this._clientGetter) : super(const LibraryIndexState());
final SubsonicClient? Function() _clientGetter;
static const int _albumParallelism = 12; // Ratune's album_parallelism.
static const int _pageSize = 500; // Subsonic getAlbumList2 cap.
static const Duration _ttl = Duration(hours: 24);
/// Bumped whenever the server changes / disconnects. Every async step checks
/// it and bails, so a build against the old server can't clobber the new one.
int _generation = 0;
/// Server changed or went offline: cancel any in-flight build and drop the
/// in-memory index. Wired to the connection listener in `providers.dart`.
void onConnectionChanged() {
_generation++;
state = const LibraryIndexState();
}
String _keyFor(SubsonicClient c) =>
md5.convert(utf8.encode('${c.baseUrl}|${c.username}')).toString();
Future<File> _cacheFile(SubsonicClient c) async {
final dir = await getApplicationSupportDirectory();
return File('${dir.path}/library_index_${_keyFor(c)}.json');
}
/// Load from a fresh, server-matching cache if present, otherwise [build].
/// Called by the Tracks view on first display; a no-op if already built or
/// building.
Future<void> ensureBuilt() async {
if (state.building || state.songs.isNotEmpty) return;
final client = _clientGetter();
if (client == null) return;
final gen = _generation;
try {
final file = await _cacheFile(client);
if (await file.exists()) {
final fresh =
DateTime.now().difference((await file.stat()).modified) < _ttl;
if (fresh) {
final raw = jsonDecode(await file.readAsString());
if (raw is List && gen == _generation) {
state = state.copyWith(
songs: raw
.whereType<Map>()
.map((e) => Song.fromJson(e.cast<String, dynamic>()))
.toList(),
);
return;
}
}
}
} catch (_) {
// Missing/corrupt cache is non-fatal — fall through to a rebuild.
}
if (gen != _generation) return;
await build();
}
/// Force a rebuild, ignoring the cache (the refresh affordance).
Future<void> refresh() async {
state = const LibraryIndexState();
await build();
}
Future<void> build() async {
final client = _clientGetter();
if (client == null) return;
final gen = _generation;
state = const LibraryIndexState(building: true);
try {
// 1. Page through every album (no silent cap — loop until a short page).
final albums = <Album>[];
var offset = 0;
while (true) {
if (gen != _generation) return;
final page = await client.getAlbumList2(size: _pageSize, offset: offset);
albums.addAll(page);
if (page.length < _pageSize) break;
offset += _pageSize;
}
if (gen != _generation) return;
state = state.copyWith(total: albums.length);
// 2. Fetch each album's songs with bounded concurrency; dedupe by id.
final byId = <String, Song>{};
var done = 0;
for (var i = 0; i < albums.length; i += _albumParallelism) {
if (gen != _generation) return;
final batch = albums.skip(i).take(_albumParallelism).toList();
final results = await Future.wait(batch.map((a) async {
try {
return (await client.getAlbum(a.id)).songs;
} catch (_) {
return const <Song>[]; // soft-fail a single album, keep crawling.
}
}));
for (final songs in results) {
for (final s in songs) {
if (s.id.isNotEmpty) byId[s.id] = s;
}
}
done += batch.length;
if (gen != _generation) return;
state = state.copyWith(done: done);
}
final songs = byId.values.toList()
..sort((a, b) => (a.title ?? '')
.toLowerCase()
.compareTo((b.title ?? '').toLowerCase()));
if (gen != _generation) return;
state = state.copyWith(songs: songs, building: false, done: done);
await _persist(client, songs, gen);
} catch (e) {
if (gen != _generation) return;
state = state.copyWith(building: false, error: e.toString());
}
}
/// Atomic write: temp file then rename, so a crash mid-write can't truncate
/// the cache. Soft-fail — a missing cache just means a rebuild next launch.
Future<void> _persist(
SubsonicClient client,
List<Song> songs,
int gen,
) async {
try {
if (gen != _generation) return;
final file = await _cacheFile(client);
final tmp = File('${file.path}.tmp');
await tmp.writeAsString(jsonEncode(songs.map((s) => s.toJson()).toList()));
await tmp.rename(file.path);
} catch (_) {}
}
}

47
lib/main.dart Normal file
View file

@ -0,0 +1,47 @@
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio_background/just_audio_background.dart';
import 'shell/app_shell.dart';
import 'theme/accent.dart';
import 'theme/app_theme.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Lock-screen / notification transport is only available where audio_service
// has a backend — Android/iOS. Skipping init elsewhere keeps the Linux dev
// target (and tests) running.
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
// Never let background-audio setup block the first frame: on failure or
// hang we still start the app (playback just loses lock-screen controls).
try {
await JustAudioBackground.init(
androidNotificationChannelId: 'com.conversionpath.ratune_mobile.audio',
androidNotificationChannelName: 'Ratune playback',
androidNotificationOngoing: true,
).timeout(const Duration(seconds: 8));
} catch (e, st) {
debugPrint('JustAudioBackground.init failed: $e\n$st');
}
}
runApp(const ProviderScope(child: RatuneApp()));
}
class RatuneApp extends ConsumerWidget {
const RatuneApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// The theme rebuilds whenever the album-art accent changes.
final accent = ref.watch(accentProvider);
return MaterialApp(
title: 'Ratune',
debugShowCheckedModeBanner: false,
theme: buildRatuneTheme(accent),
home: const AppShell(),
);
}
}

View file

@ -0,0 +1,326 @@
// Named constructor params can't be private, so initializing formals aren't
// possible for the private callback fields below.
// ignore_for_file: prefer_initializing_formals
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart';
import 'package:just_audio_background/just_audio_background.dart';
import '../subsonic/models.dart';
/// Immutable snapshot of the player, mirroring Ratune's `QueueState` +
/// player-event stream (`ratune-player/src/engine.rs`).
class PlaybackState {
const PlaybackState({
this.queue = const [],
this.currentIndex,
this.playing = false,
this.position = Duration.zero,
this.duration = Duration.zero,
this.shuffle = false,
this.loop = LoopMode.off,
this.supported = true,
});
final List<Song> queue;
final int? currentIndex;
final bool playing;
final Duration position;
final Duration duration;
final bool shuffle;
final LoopMode loop;
/// False on platforms without a just_audio backend (e.g. Linux desktop
/// without media_kit). The UI still reflects the selected track; only audio
/// output is unavailable.
final bool supported;
Song? get current =>
(currentIndex != null && currentIndex! >= 0 && currentIndex! < queue.length)
? queue[currentIndex!]
: null;
double get progress {
final total = duration.inMilliseconds;
if (total <= 0) return 0;
return (position.inMilliseconds / total).clamp(0.0, 1.0);
}
PlaybackState copyWith({
List<Song>? queue,
int? currentIndex,
bool? playing,
Duration? position,
Duration? duration,
bool? shuffle,
LoopMode? loop,
bool? supported,
}) {
return PlaybackState(
queue: queue ?? this.queue,
currentIndex: currentIndex ?? this.currentIndex,
playing: playing ?? this.playing,
position: position ?? this.position,
duration: duration ?? this.duration,
shuffle: shuffle ?? this.shuffle,
loop: loop ?? this.loop,
supported: supported ?? this.supported,
);
}
}
/// Owns the just_audio player and translates Subsonic songs into a gapless
/// queue. Playback methods no-op where audio is unsupported, but queue/current
/// state and album-art accent extraction still run so the UI is fully alive on
/// the Linux dev target.
class PlaybackController extends StateNotifier<PlaybackState> {
PlaybackController({
required Uri? Function(Song) streamUriFor,
required Uri? Function(Song) coverArtUriFor,
required void Function(Uri artUri) onArt,
required void Function(Song song) onPlay,
}) : _streamUriFor = streamUriFor,
_coverArtUriFor = coverArtUriFor,
_onArt = onArt,
_onPlay = onPlay,
super(PlaybackState(supported: _audioSupported)) {
if (_audioSupported) {
_player = AudioPlayer();
_wireStreams();
}
}
final Uri? Function(Song) _streamUriFor;
final Uri? Function(Song) _coverArtUriFor;
final void Function(Uri artUri) _onArt;
final void Function(Song song) _onPlay;
AudioPlayer? _player;
static bool get _audioSupported =>
!kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS);
void _wireStreams() {
final player = _player!;
player.currentIndexStream.listen((i) {
if (i == null) return;
state = state.copyWith(currentIndex: i);
_notifyCurrent();
});
player.playerStateStream.listen((s) {
state = state.copyWith(playing: s.playing);
});
player.positionStream.listen((p) {
state = state.copyWith(position: p);
});
player.durationStream.listen((d) {
if (d != null) state = state.copyWith(duration: d);
});
}
/// Id of the song we last ran play side effects for. Queue edits shift
/// `currentIndex` (and re-emit `currentIndexStream`) without changing the
/// playing track, so we dedupe on song id to avoid re-scrobbling / re-running
/// accent extraction when the current song hasn't actually changed.
String? _lastNotifiedId;
/// Monotonic tag counter — every AudioSource gets a globally-unique MediaItem
/// id even when the same song appears in the queue twice. just_audio_background
/// keys its notification off the tag id, so duplicate ids would confuse it.
int _tagSeq = 0;
void _notifyCurrent() {
final song = state.current;
if (song == null) return;
if (song.id == _lastNotifiedId) return;
_lastNotifiedId = song.id;
final art = _coverArtUriFor(song);
if (art != null) _onArt(art);
_onPlay(song);
}
AudioSource _sourceFor(Song song) {
final uri = _streamUriFor(song)!;
final art = _coverArtUriFor(song);
return AudioSource.uri(
uri,
tag: MediaItem(
id: '${song.id}#${_tagSeq++}',
title: song.title ?? 'Unknown',
album: song.album,
artist: song.artist,
duration:
song.duration != null ? Duration(seconds: song.duration!) : null,
artUri: art,
),
);
}
/// Replace the queue with [songs] and start at [startIndex].
///
/// `state.queue` is set to exactly the *streamable* subset that gets loaded
/// into the player, so queue index == player source index 1:1. Every later
/// queue mutation (`playNext` / `addToQueue` / `removeAt`) relies on that
/// invariant to stay aligned.
Future<void> playSongs(List<Song> songs, {int startIndex = 0}) async {
if (songs.isEmpty) return;
final streamable = songs.where((s) => _streamUriFor(s) != null).toList();
if (streamable.isEmpty) {
// Not connected / nothing playable — reflect the selection for the UI
// only; no player sources exist to mutate.
state = state.copyWith(
queue: songs,
currentIndex: startIndex.clamp(0, songs.length - 1),
position: Duration.zero,
);
_lastNotifiedId = null;
_notifyCurrent();
return;
}
// Remap the requested start into the filtered list (the chosen song may
// itself have been unstreamable).
final target = songs[startIndex.clamp(0, songs.length - 1)];
final targetIndex = streamable.indexOf(target);
final start =
targetIndex >= 0 ? targetIndex : startIndex.clamp(0, streamable.length - 1);
state = state.copyWith(
queue: streamable,
currentIndex: start,
position: Duration.zero,
);
// An explicit play should always (re)scrobble, even if it's the same song.
_lastNotifiedId = null;
final player = _player;
if (player == null) {
// Linux/desktop: no streams will fire, so reflect the selection (art +
// history) directly. UI-only, no audio output.
_notifyCurrent();
return;
}
await player.setAudioSources(
streamable.map(_sourceFor).toList(),
initialIndex: start,
);
await player.play();
// currentIndexStream fires _notifyCurrent for the started track.
}
/// Insert [song] right after the current track (Ratune's "play next").
/// Falls back to [playSongs] when nothing is playing.
Future<void> playNext(Song song) async {
if (_streamUriFor(song) == null) return;
final q = state.queue;
final current = state.currentIndex;
if (q.isEmpty || current == null) {
await playSongs([song]);
return;
}
final at = (current + 1).clamp(0, q.length);
state = state.copyWith(queue: [...q]..insert(at, song));
await _player?.insertAudioSource(at, _sourceFor(song));
}
/// Append [song] to the end of the queue.
Future<void> addToQueue(Song song) async {
if (_streamUriFor(song) == null) return;
final q = state.queue;
if (q.isEmpty || state.currentIndex == null) {
await playSongs([song]);
return;
}
state = state.copyWith(queue: [...q, song]);
await _player?.addAudioSource(_sourceFor(song));
}
/// Remove the queue entry at [index], keeping `state.queue` and the player
/// playlist aligned. On 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.
Future<void> removeAt(int index) async {
final q = state.queue;
if (index < 0 || index >= q.length) return;
final next = [...q]..removeAt(index);
if (next.isEmpty) {
await _player?.clearAudioSources();
state = PlaybackState(
shuffle: state.shuffle,
loop: state.loop,
supported: state.supported,
);
_lastNotifiedId = null;
return;
}
final player = _player;
if (player == null) {
// Desktop/no-audio: no stream will correct the index for us.
final current = state.currentIndex;
var newIndex = current;
if (current != null) {
if (index < current) {
newIndex = current - 1;
} else if (index == current) {
// The removed slot now holds the following track.
newIndex = current.clamp(0, next.length - 1);
}
}
state = state.copyWith(queue: next, currentIndex: newIndex);
_notifyCurrent();
return;
}
state = state.copyWith(queue: next);
await player.removeAudioSourceAt(index);
}
Future<void> togglePlayPause() async {
final player = _player;
if (player == null) return;
if (player.playing) {
await player.pause();
} else {
await player.play();
}
}
Future<void> next() async => _player?.seekToNext();
Future<void> previous() async => _player?.seekToPrevious();
Future<void> seek(Duration position) async => _player?.seek(position);
Future<void> toggleShuffle() async {
final enabled = !state.shuffle;
state = state.copyWith(shuffle: enabled);
final player = _player;
if (player != null) {
await player.setShuffleModeEnabled(enabled);
}
}
/// Cycle off → all → one → off (Ratune's queue-loop toggle, extended with
/// single-track repeat).
Future<void> cycleLoop() async {
final nextMode = switch (state.loop) {
LoopMode.off => LoopMode.all,
LoopMode.all => LoopMode.one,
LoopMode.one => LoopMode.off,
};
state = state.copyWith(loop: nextMode);
await _player?.setLoopMode(nextMode);
}
@override
void dispose() {
_player?.dispose();
super.dispose();
}
}

View file

@ -0,0 +1,334 @@
// Callback fields are assigned from named required params, which can't be
// private initializing formals.
// ignore_for_file: prefer_initializing_formals
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
import '../subsonic/models.dart';
import '../subsonic/subsonic_client.dart';
/// Playlists snapshot: the summaries (from `getPlaylists`) plus any full details
/// that have been opened. Details are cached so an opened playlist keeps working
/// offline.
class PlaylistsState {
const PlaylistsState({
this.playlists = const [],
this.details = const {},
this.loading = false,
});
final List<Playlist> playlists;
final Map<String, PlaylistDetail> details;
final bool loading;
PlaylistsState copyWith({
List<Playlist>? playlists,
Map<String, PlaylistDetail>? details,
bool? loading,
}) =>
PlaylistsState(
playlists: playlists ?? this.playlists,
details: details ?? this.details,
loading: loading ?? this.loading,
);
}
/// Source of truth for playlists. The server is authoritative while online
/// (mutations are optimistic and reverted on failure, mirroring
/// `state/favorites.dart`); a per-server on-disk mirror
/// (`playlists_<serverKey>.json`, atomic temp+rename like
/// `library/library_index.dart`) keeps the last-known playlists + opened track
/// lists available offline.
class PlaylistsController extends StateNotifier<PlaylistsState> {
PlaylistsController({
required SubsonicClient? Function() clientGetter,
required String? Function() serverKeyGetter,
}) : _clientGetter = clientGetter,
_serverKeyGetter = serverKeyGetter,
super(const PlaylistsState()) {
reloadForServer();
}
final SubsonicClient? Function() _clientGetter;
final String? Function() _serverKeyGetter;
int _generation = 0;
String? _loadedKey;
// ---- Server switching ---------------------------------------------------
Future<File> _mirrorFile(String key) async {
final dir = await getApplicationSupportDirectory();
return File('${dir.path}/playlists_$key.json');
}
/// Load the offline mirror for the active server, then refresh from the
/// server when online.
Future<void> reloadForServer() async {
final key = _serverKeyGetter();
if (key == _loadedKey && state.playlists.isNotEmpty) {
// Same server, already loaded — just refresh from server if possible.
await hydrate();
return;
}
_generation++;
final gen = _generation;
_loadedKey = key;
if (key == null) {
state = const PlaylistsState();
return;
}
// 1. Offline mirror first (instant, works with no connection).
try {
final file = await _mirrorFile(key);
if (await file.exists()) {
final raw = jsonDecode(await file.readAsString());
if (raw is Map && gen == _generation) {
state = _fromMirror(raw.cast<String, dynamic>());
}
}
} catch (_) {
// Missing/corrupt mirror is non-fatal.
}
// 2. Refresh from server if connected.
await hydrate();
}
PlaylistsState _fromMirror(Map<String, dynamic> j) {
final playlists = (j['playlists'] as List? ?? const [])
.whereType<Map>()
.map((e) => Playlist.fromJson(e.cast<String, dynamic>()))
.toList();
final details = <String, PlaylistDetail>{};
final rawDetails = (j['details'] as Map?)?.cast<String, dynamic>() ?? const {};
rawDetails.forEach((id, v) {
if (v is Map) {
details[id] = PlaylistDetail.fromJson(v.cast<String, dynamic>());
}
});
return PlaylistsState(playlists: playlists, details: details);
}
// ---- Fetch --------------------------------------------------------------
/// Refresh the playlist list from the server (no-op offline). Keeps cached
/// details for playlists that still exist.
Future<void> hydrate() async {
final client = _clientGetter();
if (client == null) return;
final gen = _generation;
state = state.copyWith(loading: true);
try {
final playlists = await client.getPlaylists();
if (gen != _generation) return;
final liveIds = playlists.map((p) => p.id).toSet();
final details = {
for (final e in state.details.entries)
if (liveIds.contains(e.key)) e.key: e.value,
};
state = PlaylistsState(playlists: playlists, details: details);
await _persist();
} catch (_) {
if (gen == _generation) state = state.copyWith(loading: false);
}
}
/// Fetch (and cache) a playlist's full track list. Returns the cached copy
/// when offline, or null if never fetched.
Future<PlaylistDetail?> loadDetail(String id) async {
final client = _clientGetter();
if (client == null) return state.details[id];
final gen = _generation;
try {
final detail = await client.getPlaylist(id);
if (gen != _generation) return state.details[id];
state = state.copyWith(details: {...state.details, id: detail});
await _persist();
return detail;
} catch (_) {
return state.details[id];
}
}
// ---- Mutations (optimistic) --------------------------------------------
/// Create a playlist and return its id (null on failure / offline).
Future<String?> create(String name) async {
final client = _clientGetter();
if (client == null) return null;
try {
final created = await client.createPlaylist(name);
if (created != null) {
state = state.copyWith(
playlists: [...state.playlists, created.toSummary()],
details: {...state.details, created.id: created},
);
await _persist();
return created.id;
}
// Server didn't echo the new playlist — refetch and locate it by name.
final priorIds = state.playlists.map((p) => p.id).toSet();
await hydrate();
final match = state.playlists
.where((p) => !priorIds.contains(p.id) && p.name == name)
.toList();
return match.isNotEmpty ? match.last.id : null;
} catch (_) {
return null;
}
}
Future<void> rename(String id, String name) 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) _renamed(p, name) else p,
],
details: {
for (final e in state.details.entries)
e.key: e.key == id ? _renamedDetail(e.value, name) : e.value,
},
);
try {
await client.renamePlaylist(id, name);
await _persist();
} catch (_) {
state = prev; // revert
}
}
Future<void> delete(String id) async {
final client = _clientGetter();
if (client == null) return;
final prev = state;
state = state.copyWith(
playlists: state.playlists.where((p) => p.id != id).toList(),
details: {
for (final e in state.details.entries)
if (e.key != id) e.key: e.value,
},
);
try {
await client.deletePlaylist(id);
await _persist();
} catch (_) {
state = prev; // revert
}
}
Future<void> addTracks(String id, List<Song> songs) async {
final client = _clientGetter();
if (client == null || songs.isEmpty) return;
final prev = state;
final detail = state.details[id];
if (detail != null) {
state = state.copyWith(details: {
...state.details,
id: _withSongs(detail, [...detail.songs, ...songs]),
});
}
_bumpSummaryCount(id, songs.length);
try {
await client.addTracksToPlaylist(id, songs.map((s) => s.id).toList());
await _persist();
} catch (_) {
state = prev; // revert
}
}
Future<void> removeAt(String id, int index) async {
final client = _clientGetter();
if (client == null) return;
final prev = state;
final detail = state.details[id];
if (detail == null || index < 0 || index >= detail.songs.length) return;
final nextSongs = [...detail.songs]..removeAt(index);
state = state.copyWith(details: {
...state.details,
id: _withSongs(detail, nextSongs),
});
_bumpSummaryCount(id, -1);
try {
await client.removeTrackFromPlaylist(id, index);
await _persist();
} catch (_) {
state = prev; // revert
}
}
// ---- Helpers ------------------------------------------------------------
void _bumpSummaryCount(String id, int delta) {
state = state.copyWith(playlists: [
for (final p in state.playlists)
if (p.id == id)
Playlist(
id: p.id,
name: p.name,
songCount: ((p.songCount ?? 0) + delta).clamp(0, 1 << 30),
duration: p.duration,
owner: p.owner,
public: p.public,
coverArt: p.coverArt,
)
else
p,
]);
}
static Playlist _renamed(Playlist p, String name) => Playlist(
id: p.id,
name: name,
songCount: p.songCount,
duration: p.duration,
owner: p.owner,
public: p.public,
coverArt: p.coverArt,
);
static PlaylistDetail _renamedDetail(PlaylistDetail d, String name) =>
PlaylistDetail(
id: d.id,
name: name,
songCount: d.songCount,
duration: d.duration,
coverArt: d.coverArt,
songs: d.songs,
);
static PlaylistDetail _withSongs(PlaylistDetail d, List<Song> songs) =>
PlaylistDetail(
id: d.id,
name: d.name,
songCount: songs.length,
duration: d.duration,
coverArt: d.coverArt,
songs: songs,
);
Future<void> _persist() async {
final key = _serverKeyGetter();
if (key == null) return;
try {
final file = await _mirrorFile(key);
final tmp = File('${file.path}.tmp');
await tmp.writeAsString(jsonEncode({
'playlists': state.playlists.map((p) => p.toJson()).toList(),
'details': {
for (final e in state.details.entries) e.key: e.value.toJson(),
},
}));
await tmp.rename(file.path);
} catch (_) {}
}
}

View file

@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart';
import '../subsonic/models.dart';
import '../theme/tokens.dart';
/// Bottom sheet to add [songs] to an existing playlist or a new one. No-op when
/// offline (playlist mutations require the server).
Future<void> showAddToPlaylistSheet(
BuildContext context, {
required List<Song> songs,
}) {
return showModalBottomSheet<void>(
context: context,
backgroundColor: RatuneColors.background,
isScrollControlled: true,
builder: (_) => _AddToPlaylistSheet(songs: songs),
);
}
class _AddToPlaylistSheet extends ConsumerWidget {
const _AddToPlaylistSheet({required this.songs});
final List<Song> songs;
@override
Widget build(BuildContext context, WidgetRef ref) {
final accent = Theme.of(context).colorScheme.primary;
final playlists = ref.watch(playlistsProvider).playlists;
final connected = ref.watch(subsonicClientProvider) != null;
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding:
const EdgeInsets.symmetric(horizontal: RatuneSpacing.xl),
child: Text('Add to playlist',
style:
TextStyle(color: accent, fontWeight: FontWeight.w700)),
),
const SizedBox(height: RatuneSpacing.md),
if (!connected)
const Padding(
padding: EdgeInsets.all(RatuneSpacing.xl),
child: Text('Connect to a server to manage playlists.',
style: TextStyle(color: RatuneColors.dimmed)),
)
else ...[
_Tile(
icon: Icons.add,
label: 'New playlist…',
accent: accent,
onTap: () => _createAndAdd(context, ref),
),
Flexible(
child: ListView(
shrinkWrap: true,
children: [
for (final p in playlists)
_Tile(
icon: Icons.queue_music,
label: p.name,
trailing:
p.songCount != null ? '${p.songCount}' : null,
onTap: () async {
await ref
.read(playlistsProvider.notifier)
.addTracks(p.id, songs);
if (context.mounted) {
Navigator.of(context).pop();
_toast(context, 'Added to ${p.name}');
}
},
),
],
),
),
],
],
),
),
);
}
Future<void> _createAndAdd(BuildContext context, WidgetRef ref) async {
final name = await promptPlaylistName(context, title: 'New playlist');
if (name == null || name.isEmpty) return;
final id = await ref.read(playlistsProvider.notifier).create(name);
if (id != null) {
await ref.read(playlistsProvider.notifier).addTracks(id, songs);
}
if (context.mounted) {
Navigator.of(context).pop();
_toast(context, id != null ? 'Added to $name' : 'Could not create playlist');
}
}
}
class _Tile extends StatelessWidget {
const _Tile({
required this.icon,
required this.label,
required this.onTap,
this.trailing,
this.accent,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final String? trailing;
final Color? accent;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(horizontal: RatuneSpacing.xl),
child: Row(
children: [
Icon(icon, size: 20, color: accent ?? RatuneColors.dimmed),
const SizedBox(width: RatuneSpacing.lg),
Expanded(
child: Text(label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: accent ?? RatuneColors.foreground)),
),
if (trailing != null)
Text(trailing!,
style: const TextStyle(color: RatuneColors.dimmed)),
],
),
),
);
}
}
/// Shared name prompt used by create / rename flows. Returns the trimmed name
/// or null if cancelled.
Future<String?> promptPlaylistName(
BuildContext context, {
required String title,
String initial = '',
}) {
final controller = TextEditingController(text: initial);
return showDialog<String>(
context: context,
builder: (ctx) {
final accent = Theme.of(ctx).colorScheme.primary;
return AlertDialog(
backgroundColor: RatuneColors.surface,
title: Text(title),
content: TextField(
controller: controller,
autofocus: true,
style: const TextStyle(color: RatuneColors.foreground),
decoration: const InputDecoration(
hintText: 'Playlist name',
hintStyle: TextStyle(color: RatuneColors.dimmed),
),
onSubmitted: (v) => Navigator.pop(ctx, v.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, controller.text.trim()),
child: Text('Save', style: TextStyle(color: accent)),
),
],
);
},
);
}
void _toast(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), duration: const Duration(seconds: 2)),
);
}

View file

@ -0,0 +1,744 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../downloads/download_manager.dart';
import '../state/providers.dart';
import '../subsonic/models.dart';
import '../theme/tokens.dart';
import '../widgets/hairline_panel.dart';
import 'add_to_playlist_sheet.dart';
import 'downloads_screen.dart';
import 'favorites_screen.dart';
import 'playlists_screen.dart';
import 'search_screen.dart';
import 'settings_screen.dart';
/// Browser tab — Artists / Albums / Tracks browse modes over the live Subsonic
/// server. Artists and Albums drill down by pushing onto the (nested) navigator;
/// Tracks is a flat list backed by the cached library index.
class BrowserScreen extends ConsumerWidget {
const BrowserScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(subsonicClientProvider);
final mode = ref.watch(browseModeProvider);
return Padding(
padding: const EdgeInsets.fromLTRB(
RatuneSpacing.lg,
RatuneSpacing.xl,
RatuneSpacing.lg,
RatuneSpacing.lg,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Wrap(
spacing: RatuneSpacing.xl,
runSpacing: RatuneSpacing.xs,
children: [
_Action(
icon: Icons.search,
label: 'Search',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SearchScreen()),
),
),
_Action(
icon: Icons.favorite_border,
label: 'Favorites',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const FavoritesScreen()),
),
),
_Action(
icon: Icons.queue_music,
label: 'Playlists',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const PlaylistsScreen()),
),
),
_Action(
icon: Icons.download,
label: 'Downloads',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const DownloadsScreen()),
),
),
_Action(
icon: Icons.settings,
label: 'Settings',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
),
),
],
),
const SizedBox(height: RatuneSpacing.md),
_ModeSelector(mode: mode),
const SizedBox(height: RatuneSpacing.lg),
Expanded(
child: client == null
? const HairlinePanel(
title: 'Browse',
active: true,
child: _NotConnected(),
)
: switch (mode) {
BrowseMode.artists => const _ArtistsPanel(),
BrowseMode.albums => const _AlbumsPanel(),
BrowseMode.tracks => const _TracksPanel(),
},
),
],
),
);
}
}
/// Three mono labels; the active one is underlined in accent — same language as
/// the bottom `_TabBar`.
class _ModeSelector extends ConsumerWidget {
const _ModeSelector({required this.mode});
final BrowseMode mode;
@override
Widget build(BuildContext context, WidgetRef ref) {
final accent = Theme.of(context).colorScheme.primary;
Widget label(String text, BrowseMode m) {
final active = m == mode;
return InkWell(
onTap: () => ref.read(browseModeProvider.notifier).state = m,
child: Container(
constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(horizontal: RatuneSpacing.md),
alignment: Alignment.center,
child: Text(
text,
style: TextStyle(
color: active ? RatuneColors.foreground : RatuneColors.dimmed,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
decoration:
active ? TextDecoration.underline : TextDecoration.none,
decorationColor: accent,
decorationThickness: 2,
),
),
),
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
label('Artists', BrowseMode.artists),
const Text('|', style: TextStyle(color: RatuneColors.border)),
label('Albums', BrowseMode.albums),
const Text('|', style: TextStyle(color: RatuneColors.border)),
label('Tracks', BrowseMode.tracks),
],
);
}
}
class _ArtistsPanel extends ConsumerWidget {
const _ArtistsPanel();
@override
Widget build(BuildContext context, WidgetRef ref) {
final artists = ref.watch(artistsProvider);
return HairlinePanel(
title: 'Artists',
active: true,
trailing: artists.hasValue && artists.value!.isNotEmpty
? '(${artists.value!.length})'
: null,
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
child: artists.when(
loading: () => const _Centered(child: _Loading()),
error: (e, _) => _Centered(child: _ErrorText('$e')),
data: (list) => list.isEmpty
? const _Centered(child: _ErrorText('No artists on this server.'))
: ListView.builder(
padding: EdgeInsets.zero,
itemCount: list.length,
itemBuilder: (context, i) {
final a = list[i];
return BrowseRow(
title: a.name ?? 'Unknown artist',
trailing: a.albumCount != null ? '${a.albumCount}' : null,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ArtistScreen(id: a.id)),
),
);
},
),
),
);
}
}
class _AlbumsPanel extends ConsumerWidget {
const _AlbumsPanel();
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(albumsProvider);
final client = ref.watch(subsonicClientProvider);
return HairlinePanel(
title: 'Albums',
active: true,
trailing: albums.hasValue && albums.value!.isNotEmpty
? '(${albums.value!.length})'
: null,
padding: const EdgeInsets.all(RatuneSpacing.md),
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: RatuneSpacing.md,
crossAxisSpacing: RatuneSpacing.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,
),
);
},
),
),
);
}
}
class _AlbumTile extends StatelessWidget {
const _AlbumTile({required this.album, required this.artUri});
final Album album;
final String? artUri;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => AlbumScreen(id: album.id)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AspectRatio(
aspectRatio: 1,
child: ColoredBox(
color: RatuneColors.surface,
child: artUri != null
? Image.network(
artUri!,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const _AlbumArtFallback(),
)
: const _AlbumArtFallback(),
),
),
const SizedBox(height: RatuneSpacing.xs),
Text(
album.name ?? 'Unknown album',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground),
),
if (album.artist != null)
Text(
album.artist!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: RatuneColors.dimmed, fontSize: 12),
),
],
),
);
}
}
class _AlbumArtFallback extends StatelessWidget {
const _AlbumArtFallback();
@override
Widget build(BuildContext context) => const Center(
child: Icon(Icons.album_outlined,
color: RatuneColors.dimmed, size: 32),
);
}
/// Flat alphabetical list of every song, backed by the crawled+cached library
/// index. Kicks off the build on first display and shows progress.
class _TracksPanel extends ConsumerStatefulWidget {
const _TracksPanel();
@override
ConsumerState<_TracksPanel> createState() => _TracksPanelState();
}
class _TracksPanelState extends ConsumerState<_TracksPanel> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(libraryIndexProvider.notifier).ensureBuilt();
});
}
@override
Widget build(BuildContext context) {
final index = ref.watch(libraryIndexProvider);
final playback = ref.read(playbackProvider.notifier);
final Widget body;
if (index.building) {
final total = index.total;
final label = total > 0
? 'Indexing ${index.done}/$total albums…'
: 'Indexing library…';
body = _Centered(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const _Loading(),
const SizedBox(height: RatuneSpacing.md),
Text(label, style: const TextStyle(color: RatuneColors.dimmed)),
],
),
);
} else if (index.songs.isEmpty) {
body = _Centered(
child: _ErrorText(index.error != null
? 'Could not build the track index.'
: 'No tracks indexed yet.'),
);
} else {
body = ListView.builder(
padding: EdgeInsets.zero,
itemCount: index.songs.length,
itemBuilder: (context, i) {
final song = index.songs[i];
return BrowseRow(
title: song.title ?? 'Untitled',
trailing: song.artist,
onTap: () => playback.playSongs(index.songs, startIndex: i),
onPlayNext: () => playback.playNext(song),
onAddToQueue: () => playback.addToQueue(song),
);
},
);
}
return HairlinePanel(
title: 'Tracks',
active: true,
trailing: index.songs.isNotEmpty ? '(${index.songs.length})' : null,
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
child: Column(
children: [
Align(
alignment: Alignment.centerRight,
child: InkWell(
onTap: index.building
? null
: () => ref.read(libraryIndexProvider.notifier).refresh(),
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg,
vertical: RatuneSpacing.sm,
),
child: Text('↻ refresh',
style: TextStyle(color: RatuneColors.dimmed)),
),
),
),
Expanded(child: body),
],
),
);
}
}
class _Action extends StatelessWidget {
const _Action({required this.icon, required this.label, required this.onTap});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.sm),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: RatuneColors.dimmed),
const SizedBox(width: RatuneSpacing.sm),
Text(label, style: const TextStyle(color: RatuneColors.foreground)),
],
),
),
);
}
}
class ArtistScreen extends ConsumerWidget {
const ArtistScreen({super.key, required this.id});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final artist = ref.watch(artistProvider(id));
return _DetailScaffold(
title: artist.valueOrNull?.name ?? 'Artist',
child: artist.when(
loading: () => const _Centered(child: _Loading()),
error: (e, _) => _Centered(child: _ErrorText('$e')),
data: (a) => ListView.builder(
padding: EdgeInsets.zero,
itemCount: a.albums.length,
itemBuilder: (context, i) {
final album = a.albums[i];
return BrowseRow(
title: album.name ?? 'Unknown album',
trailing: album.year?.toString(),
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => AlbumScreen(id: album.id),
),
),
);
},
),
),
);
}
}
class AlbumScreen extends ConsumerWidget {
const AlbumScreen({super.key, required this.id});
final String id;
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(albumProvider(id));
return _DetailScaffold(
title: album.valueOrNull?.name ?? 'Album',
child: album.when(
loading: () => const _Centered(child: _Loading()),
error: (e, _) => _Centered(child: _ErrorText('$e')),
data: (a) => ListView.builder(
padding: EdgeInsets.zero,
itemCount: a.songs.length,
itemBuilder: (context, i) {
final song = a.songs[i];
final playback = ref.read(playbackProvider.notifier);
return BrowseRow(
leading: song.track?.toString(),
title: song.title ?? 'Untitled',
trailing: _fmtDuration(song.duration),
onTap: () => playback.playSongs(a.songs, startIndex: i),
onPlayNext: () => playback.playNext(song),
onAddToQueue: () => playback.addToQueue(song),
);
},
),
),
);
}
}
// ---- Shared bits --------------------------------------------------------
class BrowseRow extends StatelessWidget {
const BrowseRow({
super.key,
required this.title,
this.leading,
this.trailing,
this.onTap,
this.onPlayNext,
this.onAddToQueue,
this.onAddToPlaylist,
this.onDownload,
this.onRemoveDownload,
this.downloadStatus,
});
final String title;
final String? leading;
final String? trailing;
final VoidCallback? onTap;
/// When set, renders inline "play next" / "add to queue" icons (song rows).
final VoidCallback? onPlayNext;
final VoidCallback? onAddToQueue;
/// Secondary song actions, folded into a trailing overflow menu so the row
/// stays uncluttered.
final VoidCallback? onAddToPlaylist;
final VoidCallback? onDownload;
final VoidCallback? onRemoveDownload;
/// Current offline-download state for this row's track (drives the menu label
/// and the at-a-glance downloaded indicator).
final DownloadStatus? downloadStatus;
bool get _hasMenu =>
onAddToPlaylist != null || onDownload != null || onRemoveDownload != null;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
final isDone = downloadStatus == DownloadStatus.done;
final isActive = downloadStatus == DownloadStatus.queued ||
downloadStatus == DownloadStatus.downloading;
return InkWell(
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.only(left: RatuneSpacing.lg),
child: Row(
children: [
if (leading != null)
SizedBox(
width: 28,
child: Text(leading!,
style: const TextStyle(color: RatuneColors.dimmed)),
),
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground),
),
),
if (isDone)
Padding(
padding: const EdgeInsets.only(left: RatuneSpacing.sm),
child:
Icon(Icons.download_done, size: 14, color: accent),
)
else if (isActive)
const Padding(
padding: EdgeInsets.only(left: RatuneSpacing.sm),
child: SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(strokeWidth: 1.5),
),
),
if (trailing != null) ...[
const SizedBox(width: RatuneSpacing.md),
Text(trailing!,
style: const TextStyle(color: RatuneColors.dimmed)),
],
if (onPlayNext != null)
_RowIcon(icon: Icons.playlist_play, onTap: onPlayNext!),
if (onAddToQueue != null)
_RowIcon(icon: Icons.add, onTap: onAddToQueue!),
if (_hasMenu)
_RowMenu(
isDownloaded: isDone,
isDownloading: isActive,
onAddToPlaylist: onAddToPlaylist,
onDownload: onDownload,
onRemoveDownload: onRemoveDownload,
),
if (onPlayNext == null && onAddToQueue == null && !_hasMenu)
const SizedBox(width: RatuneSpacing.lg),
],
),
),
);
}
}
/// Trailing overflow menu for a song row's secondary actions.
class _RowMenu extends StatelessWidget {
const _RowMenu({
required this.isDownloaded,
required this.isDownloading,
this.onAddToPlaylist,
this.onDownload,
this.onRemoveDownload,
});
final bool isDownloaded;
final bool isDownloading;
final VoidCallback? onAddToPlaylist;
final VoidCallback? onDownload;
final VoidCallback? onRemoveDownload;
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, size: 20, color: RatuneColors.dimmed),
color: RatuneColors.surface,
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: RatuneSpacing.minTouchTarget),
onSelected: (v) {
switch (v) {
case 'playlist':
onAddToPlaylist?.call();
case 'download':
onDownload?.call();
case 'remove_download':
onRemoveDownload?.call();
}
},
itemBuilder: (_) => [
if (onAddToPlaylist != null)
const PopupMenuItem(
value: 'playlist', child: Text('Add to playlist')),
if (isDownloaded && onRemoveDownload != null)
const PopupMenuItem(
value: 'remove_download', child: Text('Remove download'))
else if (onDownload != null)
PopupMenuItem(
value: 'download',
enabled: !isDownloading,
child: Text(isDownloading ? 'Downloading…' : 'Download')),
],
);
}
}
/// Compact trailing action icon with a ≥44pt hit target.
class _RowIcon extends StatelessWidget {
const _RowIcon({required this.icon, required this.onTap});
final IconData icon;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: SizedBox(
width: RatuneSpacing.minTouchTarget,
height: RatuneSpacing.minTouchTarget,
child: Icon(icon, size: 20, color: RatuneColors.dimmed),
),
);
}
}
class _DetailScaffold extends StatelessWidget {
const _DetailScaffold({
required this.title,
required this.child,
this.actions,
});
final String title;
final Widget child;
final List<Widget>? actions;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700)),
actions: actions,
),
body: SafeArea(child: child),
);
}
}
class _NotConnected extends StatelessWidget {
const _NotConnected();
@override
Widget build(BuildContext context) {
return const _Centered(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Not connected.',
style: TextStyle(color: RatuneColors.foreground)),
SizedBox(height: RatuneSpacing.sm),
Text('Tap the status bar to add a Subsonic server.',
textAlign: TextAlign.center,
style: TextStyle(color: RatuneColors.dimmed)),
],
),
);
}
}
class _Centered extends StatelessWidget {
const _Centered({required this.child});
final Widget child;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.all(RatuneSpacing.xl),
child: Center(child: child),
);
}
class _Loading extends StatelessWidget {
const _Loading();
@override
Widget build(BuildContext context) => const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
);
}
class _ErrorText extends StatelessWidget {
const _ErrorText(this.message);
final String message;
@override
Widget build(BuildContext context) => Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(color: RatuneColors.dimmed),
);
}
String? _fmtDuration(int? seconds) {
if (seconds == null) return null;
final m = seconds ~/ 60;
final s = seconds % 60;
return '$m:${s.toString().padLeft(2, '0')}';
}

View file

@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart';
import '../subsonic/credentials.dart';
import '../theme/tokens.dart';
/// Open the connect-server sheet.
Future<void> showConnectSheet(BuildContext context) {
return showModalBottomSheet<void>(
context: context,
backgroundColor: RatuneColors.background,
isScrollControlled: true,
builder: (_) => const _ConnectSheet(),
);
}
class _ConnectSheet extends ConsumerStatefulWidget {
const _ConnectSheet();
@override
ConsumerState<_ConnectSheet> createState() => _ConnectSheetState();
}
class _ConnectSheetState extends ConsumerState<_ConnectSheet> {
final _url = TextEditingController();
final _user = TextEditingController();
final _pass = TextEditingController();
bool _busy = false;
@override
void initState() {
super.initState();
final creds = ref.read(connectionProvider).credentials;
if (creds != null) {
_url.text = creds.url;
_user.text = creds.username;
}
}
@override
void dispose() {
_url.dispose();
_user.dispose();
_pass.dispose();
super.dispose();
}
Future<void> _connect() async {
setState(() => _busy = true);
final ok = await ref.read(connectionProvider.notifier).connect(
SubsonicCredentials(
url: _url.text.trim(),
username: _user.text.trim(),
password: _pass.text,
),
);
if (!mounted) return;
setState(() => _busy = false);
if (ok) Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
final conn = ref.watch(connectionProvider);
final accent = Theme.of(context).colorScheme.primary;
return Padding(
padding: EdgeInsets.only(
left: RatuneSpacing.xl,
right: RatuneSpacing.xl,
top: RatuneSpacing.xl,
bottom: MediaQuery.of(context).viewInsets.bottom + RatuneSpacing.xl,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Connect to server',
style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
const SizedBox(height: RatuneSpacing.lg),
_field(_url, 'Server URL', 'https://navidrome.example.com',
keyboard: TextInputType.url),
_field(_user, 'Username', 'you'),
_field(_pass, 'Password', '••••••••', obscure: true),
if (conn.status == ConnStatus.error && conn.error != null) ...[
const SizedBox(height: RatuneSpacing.md),
Text(conn.error!,
style: const TextStyle(color: Color(0xFFE06C75))),
],
const SizedBox(height: RatuneSpacing.lg),
FilledButton(
onPressed: _busy ? null : _connect,
style: FilledButton.styleFrom(
backgroundColor: accent,
foregroundColor: RatuneColors.background,
shape: const RoundedRectangleBorder(),
),
child: _busy
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Connect'),
),
],
),
);
}
Widget _field(
TextEditingController c,
String label,
String hint, {
bool obscure = false,
TextInputType? keyboard,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: RatuneSpacing.md),
child: TextField(
controller: c,
obscureText: obscure,
keyboardType: keyboard,
autocorrect: false,
enableSuggestions: false,
style: const TextStyle(color: RatuneColors.foreground),
decoration: InputDecoration(
labelText: label,
hintText: hint,
labelStyle: const TextStyle(color: RatuneColors.dimmed),
hintStyle: const TextStyle(color: RatuneColors.dimmed),
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: RatuneColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: Theme.of(context).colorScheme.primary),
),
),
),
);
}
}

View file

@ -0,0 +1,234 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../downloads/download_manager.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
import '../widgets/hairline_panel.dart';
/// Manage offline downloads: what's saved, how much space it uses, and any
/// in-flight transfers. Tapping a completed track plays it.
class DownloadsScreen extends ConsumerWidget {
const DownloadsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final downloads = ref.watch(downloadManagerProvider);
final controller = ref.read(downloadManagerProvider.notifier);
final playback = ref.read(playbackProvider.notifier);
final active = downloads.byId.values.where((d) => d.isActive).toList();
final completed = downloads.completed;
return Scaffold(
appBar: AppBar(
title: const Text('Downloads',
style: TextStyle(fontWeight: FontWeight.w700)),
actions: [
if (completed.isNotEmpty)
TextButton(
onPressed: () => _confirmClear(context, controller),
child: const Text('Clear all',
style: TextStyle(color: RatuneColors.dimmed)),
),
],
),
body: SafeArea(
child: (active.isEmpty && completed.isEmpty)
? const Center(
child: Text('No downloads yet.',
style: TextStyle(color: RatuneColors.dimmed)),
)
: ListView(
padding: const EdgeInsets.all(RatuneSpacing.lg),
children: [
if (active.isNotEmpty) ...[
HairlinePanel(
title: 'Downloading',
trailing: '(${active.length})',
padding: const EdgeInsets.symmetric(
vertical: RatuneSpacing.md),
child: Column(
children: [
for (final d in active) _ActiveRow(info: d),
],
),
),
const SizedBox(height: RatuneSpacing.xl),
],
HairlinePanel(
title: 'Saved',
active: true,
trailing: completed.isEmpty
? null
: '${completed.length} · ${_fmtBytes(downloads.totalBytes)}',
padding:
const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
child: completed.isEmpty
? const Padding(
padding: EdgeInsets.all(RatuneSpacing.lg),
child: Text('Nothing saved for offline yet.',
style: TextStyle(color: RatuneColors.dimmed)),
)
: Column(
children: [
for (final d in completed)
_SavedRow(
info: d,
onPlay: () =>
playback.playSongs([d.song]),
onRemove: () => controller.remove(d.song.id),
),
],
),
),
],
),
),
);
}
Future<void> _confirmClear(
BuildContext context, DownloadController controller) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: RatuneColors.surface,
title: const Text('Remove all downloads?'),
content: const Text(
'This deletes every saved file for this server. It cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Remove all')),
],
),
);
if (ok == true) await controller.clearAll();
}
}
class _ActiveRow extends StatelessWidget {
const _ActiveRow({required this.info});
final DownloadInfo info;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
final failed = info.status == DownloadStatus.failed;
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg, vertical: RatuneSpacing.xs),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(info.song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground)),
const SizedBox(height: RatuneSpacing.xs),
if (failed)
const Text('Failed',
style: TextStyle(color: Color(0xFFE06C75), fontSize: 12))
else
LinearProgressIndicator(
value: info.progress > 0 ? info.progress : null,
minHeight: 3,
backgroundColor: RatuneColors.border,
color: accent,
),
],
),
),
const SizedBox(width: RatuneSpacing.md),
Text(
failed
? '—'
: (info.status == DownloadStatus.queued ? 'Queued' : ''),
style: const TextStyle(color: RatuneColors.dimmed, fontSize: 12),
),
],
),
);
}
}
class _SavedRow extends StatelessWidget {
const _SavedRow({
required this.info,
required this.onPlay,
required this.onRemove,
});
final DownloadInfo info;
final VoidCallback onPlay;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
final quality = info.format ??
(info.bitRate != null ? '${info.bitRate} kbps' : 'Original');
return InkWell(
onTap: onPlay,
child: Container(
constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.only(left: RatuneSpacing.lg),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(info.song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground)),
Text(
[
info.song.artist,
'$quality · ${_fmtBytes(info.sizeBytes ?? 0)}',
].where((e) => e != null && e.isNotEmpty).join(' · '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
const TextStyle(color: RatuneColors.dimmed, fontSize: 12),
),
],
),
),
InkWell(
onTap: onRemove,
customBorder: const CircleBorder(),
child: const SizedBox(
width: RatuneSpacing.minTouchTarget,
height: RatuneSpacing.minTouchTarget,
child: Icon(Icons.delete_outline,
size: 20, color: RatuneColors.dimmed),
),
),
],
),
),
);
}
}
String _fmtBytes(int bytes) {
if (bytes <= 0) return '0 MB';
const units = ['B', 'KB', 'MB', 'GB'];
var size = bytes.toDouble();
var i = 0;
while (size >= 1024 && i < units.length - 1) {
size /= 1024;
i++;
}
return '${size.toStringAsFixed(size >= 10 || i == 0 ? 0 : 1)} ${units[i]}';
}

View file

@ -0,0 +1,112 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
import 'browser_screen.dart';
/// Favorites — starred songs / albums / artists from `getStarred2`.
class FavoritesScreen extends ConsumerWidget {
const FavoritesScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final starred = ref.watch(starredProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Favorites',
style: TextStyle(fontWeight: FontWeight.w700)),
),
body: SafeArea(
child: starred.when(
loading: () => const Center(
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
error: (e, _) => Center(
child: Text('$e',
style: const TextStyle(color: RatuneColors.dimmed)),
),
data: (s) {
if (s.songs.isEmpty && s.albums.isEmpty && s.artists.isEmpty) {
return const Center(
child: Text('No favorites yet.',
style: TextStyle(color: RatuneColors.dimmed)),
);
}
return ListView(
children: [
if (s.songs.isNotEmpty) ...[
const _SectionLabel('Songs'),
for (var i = 0; i < s.songs.length; i++)
BrowseRow(
title: s.songs[i].title ?? 'Untitled',
trailing: s.songs[i].artist,
onTap: () => ref
.read(playbackProvider.notifier)
.playSongs(s.songs, startIndex: i),
onPlayNext: () => ref
.read(playbackProvider.notifier)
.playNext(s.songs[i]),
onAddToQueue: () => ref
.read(playbackProvider.notifier)
.addToQueue(s.songs[i]),
),
],
if (s.albums.isNotEmpty) ...[
const _SectionLabel('Albums'),
for (final a in s.albums)
BrowseRow(
title: a.name ?? 'Unknown album',
trailing: a.artist,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => AlbumScreen(id: a.id),
),
),
),
],
if (s.artists.isNotEmpty) ...[
const _SectionLabel('Artists'),
for (final a in s.artists)
BrowseRow(
title: a.name ?? 'Unknown artist',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ArtistScreen(id: a.id),
),
),
),
],
],
);
},
),
),
);
}
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel(this.text);
final String text;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(
RatuneSpacing.lg,
RatuneSpacing.lg,
RatuneSpacing.lg,
RatuneSpacing.sm,
),
child: Text(
text,
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
);
}

View file

@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
import '../widgets/hairline_panel.dart';
import 'browser_screen.dart';
/// Home tab — Recently Played (album-art strip), Recent Tracks, and Rediscover,
/// all derived from the local play history (mirrors Ratune's home tab).
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(subsonicClientProvider);
final recentAlbums = ref.watch(recentAlbumsProvider);
final recentSongs = ref.watch(recentSongsProvider);
final rediscover = ref.watch(rediscoverProvider);
return ListView(
padding: const EdgeInsets.fromLTRB(
RatuneSpacing.lg,
RatuneSpacing.xl,
RatuneSpacing.lg,
RatuneSpacing.lg,
),
children: [
HairlinePanel(
title: 'Recently Played',
child: SizedBox(
height: 120,
child: recentAlbums.isEmpty
? const _Empty('No listening history yet.')
: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: recentAlbums.length,
separatorBuilder: (_, _) =>
const SizedBox(width: RatuneSpacing.md),
itemBuilder: (_, i) {
final rec = recentAlbums[i];
final art = (client != null && rec.coverArt != null)
? client.coverArtUri(rec.coverArt!, size: 240).toString()
: null;
return GestureDetector(
onTap: rec.albumId == null
? null
: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
AlbumScreen(id: rec.albumId!),
),
),
child: SizedBox(
width: 120,
child: ColoredBox(
color: RatuneColors.surface,
child: art != null
? Image.network(art,
fit: BoxFit.cover, gaplessPlayback: true)
: const Icon(Icons.album_outlined,
color: RatuneColors.dimmed),
),
),
);
},
),
),
),
const SizedBox(height: RatuneSpacing.xl),
HairlinePanel(
title: 'Recent Tracks',
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
child: recentSongs.isEmpty
? const Padding(
padding: EdgeInsets.all(RatuneSpacing.lg),
child: _Empty('Nothing played recently.'),
)
: Column(
children: [
for (final rec in recentSongs.take(8))
BrowseRow(
title: rec.title,
trailing: rec.artist,
onTap: () => ref
.read(playbackProvider.notifier)
.playSongs([rec.toSong()]),
onPlayNext: () => ref
.read(playbackProvider.notifier)
.playNext(rec.toSong()),
onAddToQueue: () => ref
.read(playbackProvider.notifier)
.addToQueue(rec.toSong()),
),
],
),
),
const SizedBox(height: RatuneSpacing.xl),
HairlinePanel(
title: 'Rediscover',
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (rediscover.isEmpty)
const Padding(
padding: EdgeInsets.all(RatuneSpacing.lg),
child: _Empty('Listen to more music to unlock suggestions.'),
)
else
for (final a in rediscover)
BrowseRow(
title: a.name,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ArtistScreen(id: a.artistId),
),
),
),
InkWell(
onTap: () =>
ref.read(rediscoverSeedProvider.notifier).state++,
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg,
vertical: RatuneSpacing.md,
),
child: Text('↻ re-roll',
style: TextStyle(color: RatuneColors.dimmed)),
),
),
],
),
),
],
);
}
}
class _Empty extends StatelessWidget {
const _Empty(this.message);
final String message;
@override
Widget build(BuildContext context) => Align(
alignment: Alignment.centerLeft,
child: Text(message, style: const TextStyle(color: RatuneColors.dimmed)),
);
}

View file

@ -0,0 +1,388 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart' show LoopMode;
import '../playback/playback_engine.dart';
import '../state/providers.dart';
import '../subsonic/models.dart';
import '../theme/tokens.dart';
import '../widgets/block_progress_bar.dart';
import '../widgets/hairline_panel.dart';
/// 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
/// swaps to the queue when toggled, so the art keeps its original size while the
/// queue still gets a usable amount of space on demand.
class NowPlayingScreen extends ConsumerStatefulWidget {
const NowPlayingScreen({super.key});
@override
ConsumerState<NowPlayingScreen> createState() => _NowPlayingScreenState();
}
class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
bool _showQueue = false;
@override
Widget build(BuildContext context) {
final state = ref.watch(playbackProvider);
final accent = Theme.of(context).colorScheme.primary;
final current = state.current;
if (current == null) {
return const Center(
child: Text('Nothing playing.',
style: TextStyle(color: RatuneColors.dimmed)),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(
RatuneSpacing.lg,
RatuneSpacing.xl,
RatuneSpacing.lg,
RatuneSpacing.lg,
),
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: RatuneSpacing.xl),
_InfoStrip(song: current, state: state, accent: accent),
const SizedBox(height: RatuneSpacing.sm),
_FavRating(song: current),
const SizedBox(height: RatuneSpacing.xs),
_Transport(state: state, ref: ref, accent: accent),
if (!state.supported)
const Padding(
padding: EdgeInsets.only(top: RatuneSpacing.sm),
child: Text(
'Audio output unavailable on this platform — test on Android/iOS.',
style: TextStyle(color: RatuneColors.dimmed, fontSize: 11),
),
),
const SizedBox(height: RatuneSpacing.sm),
_QueueToggle(
showQueue: _showQueue,
queueLength: state.queue.length,
accent: accent,
onTap: () => setState(() => _showQueue = !_showQueue),
),
],
),
);
}
}
/// Text toggle that swaps the top region between album art and the queue.
/// Styled like the app's other "↻ re-roll" / "↻ refresh" affordances.
class _QueueToggle extends StatelessWidget {
const _QueueToggle({
required this.showQueue,
required this.queueLength,
required this.accent,
required this.onTap,
});
final bool showQueue;
final int queueLength;
final Color accent;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return Center(
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg,
vertical: RatuneSpacing.sm,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
showQueue ? Icons.album_outlined : Icons.queue_music,
size: 16,
color: showQueue ? accent : RatuneColors.dimmed,
),
const SizedBox(width: RatuneSpacing.sm),
Text(
showQueue ? 'Album art' : 'Queue ($queueLength)',
style: TextStyle(
color: showQueue ? accent : RatuneColors.foreground,
),
),
],
),
),
),
);
}
}
/// The play queue with reorder-free remove controls. Fills whichever space the
/// top region gives it.
class _QueuePanel extends ConsumerWidget {
const _QueuePanel();
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(playbackProvider);
final accent = Theme.of(context).colorScheme.primary;
return HairlinePanel(
title: 'Queue',
trailing: '(${state.queue.length})',
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: state.queue.length,
itemBuilder: (context, i) {
final song = state.queue[i];
final isCurrent = i == state.currentIndex;
return InkWell(
onTap: () => ref
.read(playbackProvider.notifier)
.playSongs(state.queue, startIndex: i),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg,
vertical: RatuneSpacing.xs,
),
child: Row(
children: [
SizedBox(
width: 28,
child: Text('${i + 1}',
style: const TextStyle(color: RatuneColors.dimmed)),
),
Expanded(
child: Text(
song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: isCurrent ? accent : RatuneColors.foreground,
fontWeight:
isCurrent ? FontWeight.w700 : FontWeight.w400,
),
),
),
Text(_fmt(song.duration),
style: const TextStyle(color: RatuneColors.dimmed)),
InkWell(
onTap: () =>
ref.read(playbackProvider.notifier).removeAt(i),
customBorder: const CircleBorder(),
child: const SizedBox(
width: RatuneSpacing.minTouchTarget,
height: RatuneSpacing.minTouchTarget,
child: Icon(Icons.close,
size: 18, color: RatuneColors.dimmed),
),
),
],
),
),
);
},
),
);
}
}
/// 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
/// touch it. `gaplessPlayback` + a URL-keyed element keep the previous frame
/// on screen until a genuinely new image is ready (no flashing).
class _AlbumArtPanel extends ConsumerWidget {
const _AlbumArtPanel();
@override
Widget build(BuildContext context, WidgetRef ref) {
final coverArt =
ref.watch(playbackProvider.select((s) => s.current?.coverArt));
final client = ref.watch(subsonicClientProvider);
final artUri = (client != null && coverArt != null)
? client.coverArtUri(coverArt, size: 512).toString()
: null;
return HairlinePanel(
title: 'Album Art',
padding: const EdgeInsets.all(RatuneSpacing.md),
child: AspectRatio(
aspectRatio: 1,
child: ColoredBox(
color: RatuneColors.surface,
child: artUri != null
? Image.network(
artUri,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const _ArtFallback(),
)
: const _ArtFallback(),
),
),
);
}
}
/// Favorite (star) + 1–5 rating for the current track. Watches favorites only,
/// so it updates on star/rating changes independent of position ticks.
class _FavRating extends ConsumerWidget {
const _FavRating({required this.song});
final Song song;
@override
Widget build(BuildContext context, WidgetRef ref) {
final fav = ref.watch(favoritesProvider);
final accent = Theme.of(context).colorScheme.primary;
final starred = fav.isSongStarred(song.id);
final rating =
fav.ratingFor(song.id) != 0 ? fav.ratingFor(song.id) : (song.userRating ?? 0);
return Row(
children: [
InkWell(
onTap: () => ref.read(favoritesProvider.notifier).toggleSong(song),
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(RatuneSpacing.sm),
child: Icon(
starred ? Icons.favorite : Icons.favorite_border,
color: starred ? accent : RatuneColors.dimmed,
size: 22,
),
),
),
const SizedBox(width: RatuneSpacing.md),
for (int i = 1; i <= 5; i++)
GestureDetector(
onTap: () => ref
.read(favoritesProvider.notifier)
.rateSong(song.id, i == rating ? 0 : i),
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(
i <= rating ? Icons.star : Icons.star_border,
size: 18,
color: i <= rating ? accent : RatuneColors.dimmed,
),
),
),
],
);
}
}
class _InfoStrip extends StatelessWidget {
const _InfoStrip(
{required this.song, required this.state, required this.accent});
final Song song;
final PlaybackState state;
final Color accent;
@override
Widget build(BuildContext context) {
final album = [
if (song.album != null) song.album,
if (song.year != null) '${song.year}',
].join(' · ');
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: accent, fontWeight: FontWeight.w700)),
Text(song.artist ?? 'Unknown artist',
style: const TextStyle(color: RatuneColors.foreground)),
if (album.isNotEmpty)
Text(album, style: const TextStyle(color: RatuneColors.dimmed)),
const SizedBox(height: RatuneSpacing.md),
Row(
children: [
Text(_fmtDur(state.position),
style: const TextStyle(color: RatuneColors.dimmed)),
const SizedBox(width: RatuneSpacing.md),
Expanded(child: BlockProgressBar(progress: state.progress)),
const SizedBox(width: RatuneSpacing.md),
Text(_fmtDur(state.duration),
style: const TextStyle(color: RatuneColors.dimmed)),
],
),
],
);
}
}
class _Transport extends StatelessWidget {
const _Transport(
{required this.state, required this.ref, required this.accent});
final PlaybackState state;
final WidgetRef ref;
final Color accent;
@override
Widget build(BuildContext context) {
final controller = ref.read(playbackProvider.notifier);
final loopIcon = switch (state.loop) {
LoopMode.one => Icons.repeat_one,
_ => Icons.repeat,
};
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_btn(Icons.shuffle, controller.toggleShuffle,
color: state.shuffle ? accent : RatuneColors.dimmed),
_btn(Icons.skip_previous, controller.previous),
_btn(state.playing ? Icons.pause : Icons.play_arrow,
controller.togglePlayPause,
color: accent, size: 40),
_btn(Icons.skip_next, controller.next),
_btn(loopIcon, controller.cycleLoop,
color: state.loop != LoopMode.off ? accent : RatuneColors.dimmed),
],
);
}
Widget _btn(IconData icon, VoidCallback onTap,
{Color color = RatuneColors.foreground, double size = 28}) {
return IconButton(
onPressed: onTap,
icon: Icon(icon, color: color, size: size),
);
}
}
class _ArtFallback extends StatelessWidget {
const _ArtFallback();
@override
Widget build(BuildContext context) => const Center(
child: Icon(Icons.album_outlined,
color: RatuneColors.dimmed, size: 48),
);
}
String _fmt(int? seconds) {
if (seconds == null) return '';
final m = seconds ~/ 60;
final s = seconds % 60;
return '$m:${s.toString().padLeft(2, '0')}';
}
String _fmtDur(Duration d) {
final m = d.inMinutes;
final s = d.inSeconds % 60;
return '$m:${s.toString().padLeft(2, '0')}';
}

View file

@ -0,0 +1,359 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart';
import '../subsonic/models.dart';
import '../theme/tokens.dart';
import '../widgets/hairline_panel.dart';
import 'add_to_playlist_sheet.dart';
/// Playlists list — server-backed with an offline mirror. Create from the app
/// bar; each row opens its detail. Rename/delete via the row overflow menu.
class PlaylistsScreen extends ConsumerWidget {
const PlaylistsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(playlistsProvider);
final controller = ref.read(playlistsProvider.notifier);
final connected = ref.watch(subsonicClientProvider) != null;
return Scaffold(
appBar: AppBar(
title: const Text('Playlists',
style: TextStyle(fontWeight: FontWeight.w700)),
actions: [
IconButton(
tooltip: 'New playlist',
onPressed: connected ? () => _create(context, ref) : null,
icon: const Icon(Icons.add),
),
],
),
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(RatuneSpacing.lg),
child: HairlinePanel(
title: 'Playlists',
active: true,
trailing:
state.playlists.isEmpty ? null : '(${state.playlists.length})',
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
child: state.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: RatuneColors.dimmed),
),
)
: ListView.builder(
padding: EdgeInsets.zero,
itemCount: state.playlists.length,
itemBuilder: (context, i) {
final p = state.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),
),
),
onRename: connected
? () async {
final name = await promptPlaylistName(context,
title: 'Rename playlist', initial: p.name);
if (name != null && name.isNotEmpty) {
controller.rename(p.id, name);
}
}
: null,
onDelete: connected
? () => _confirmDelete(context, controller, p)
: null,
);
},
),
),
),
),
);
}
Future<void> _create(BuildContext context, WidgetRef ref) async {
final name = await promptPlaylistName(context, title: 'New playlist');
if (name != null && name.isNotEmpty) {
await ref.read(playlistsProvider.notifier).create(name);
}
}
Future<void> _confirmDelete(
BuildContext context, PlaylistsController controller, Playlist p) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
backgroundColor: RatuneColors.surface,
title: Text('Delete "${p.name}"?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel')),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete')),
],
),
);
if (ok == true) controller.delete(p.id);
}
}
class _PlaylistRow extends StatelessWidget {
const _PlaylistRow({
required this.name,
required this.onTap,
this.subtitle,
this.onRename,
this.onDelete,
});
final String name;
final String? subtitle;
final VoidCallback onTap;
final VoidCallback? onRename;
final VoidCallback? onDelete;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.only(left: RatuneSpacing.lg),
child: Row(
children: [
const Icon(Icons.queue_music, size: 18, color: RatuneColors.dimmed),
const SizedBox(width: RatuneSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground)),
if (subtitle != null)
Text(subtitle!,
style: const TextStyle(
color: RatuneColors.dimmed, fontSize: 12)),
],
),
),
if (onRename != null || onDelete != null)
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert,
size: 20, color: RatuneColors.dimmed),
color: RatuneColors.surface,
onSelected: (v) {
if (v == 'rename') onRename?.call();
if (v == 'delete') onDelete?.call();
},
itemBuilder: (_) => [
if (onRename != null)
const PopupMenuItem(value: 'rename', child: Text('Rename')),
if (onDelete != null)
const PopupMenuItem(value: 'delete', child: Text('Delete')),
],
),
],
),
),
);
}
}
/// One playlist's tracks: play all / download all from the app bar, remove a
/// track via its trailing control.
class PlaylistDetailScreen extends ConsumerStatefulWidget {
const PlaylistDetailScreen({super.key, required this.id});
final String id;
@override
ConsumerState<PlaylistDetailScreen> createState() =>
_PlaylistDetailScreenState();
}
class _PlaylistDetailScreenState extends ConsumerState<PlaylistDetailScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(playlistsProvider.notifier).loadDetail(widget.id);
});
}
@override
Widget build(BuildContext context) {
final detail = ref.watch(
playlistsProvider.select((s) => s.details[widget.id]));
final summary = ref.watch(playlistsProvider.select((s) =>
s.playlists.where((p) => p.id == widget.id).firstOrNull));
final connected = ref.watch(subsonicClientProvider) != null;
final playback = ref.read(playbackProvider.notifier);
final songs = detail?.songs ?? const <Song>[];
return Scaffold(
appBar: AppBar(
title: Text(detail?.name ?? summary?.name ?? 'Playlist',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w700)),
actions: [
IconButton(
tooltip: 'Play all',
onPressed:
songs.isEmpty ? null : () => playback.playSongs(songs),
icon: const Icon(Icons.play_arrow),
),
IconButton(
tooltip: 'Download all',
onPressed: songs.isEmpty
? null
: () {
ref
.read(downloadManagerProvider.notifier)
.downloadAll(songs);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Downloading playlist…'),
duration: Duration(seconds: 2)),
);
},
icon: const Icon(Icons.download),
),
],
),
body: SafeArea(
child: detail == null
? const Center(
child: Text('Loading…',
style: TextStyle(color: RatuneColors.dimmed)),
)
: songs.isEmpty
? const Center(
child: Text('This playlist is empty.',
style: TextStyle(color: RatuneColors.dimmed)),
)
: ListView.builder(
padding: const EdgeInsets.symmetric(
vertical: RatuneSpacing.md),
itemCount: songs.length,
itemBuilder: (context, i) {
final song = songs[i];
return _TrackRow(
index: i + 1,
song: song,
onTap: () => playback.playSongs(songs, startIndex: i),
onPlayNext: () => playback.playNext(song),
onAddToQueue: () => playback.addToQueue(song),
onRemove: connected
? () => ref
.read(playlistsProvider.notifier)
.removeAt(widget.id, i)
: null,
);
},
),
),
);
}
}
class _TrackRow extends StatelessWidget {
const _TrackRow({
required this.index,
required this.song,
required this.onTap,
required this.onPlayNext,
required this.onAddToQueue,
this.onRemove,
});
final int index;
final Song song;
final VoidCallback onTap;
final VoidCallback onPlayNext;
final VoidCallback onAddToQueue;
final VoidCallback? onRemove;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.only(left: RatuneSpacing.lg),
child: Row(
children: [
SizedBox(
width: 28,
child: Text('$index',
style: const TextStyle(color: RatuneColors.dimmed)),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(song.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground)),
if (song.artist != null)
Text(song.artist!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: RatuneColors.dimmed, fontSize: 12)),
],
),
),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert,
size: 20, color: RatuneColors.dimmed),
color: RatuneColors.surface,
onSelected: (v) {
switch (v) {
case 'next':
onPlayNext();
case 'queue':
onAddToQueue();
case 'remove':
onRemove?.call();
}
},
itemBuilder: (_) => [
const PopupMenuItem(value: 'next', child: Text('Play next')),
const PopupMenuItem(
value: 'queue', child: Text('Add to queue')),
if (onRemove != null)
const PopupMenuItem(
value: 'remove', child: Text('Remove from playlist')),
],
),
],
),
),
);
}
}

View file

@ -0,0 +1,119 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
import 'browser_screen.dart';
/// Search — `search3` across artists / albums / songs.
class SearchScreen extends ConsumerStatefulWidget {
const SearchScreen({super.key});
@override
ConsumerState<SearchScreen> createState() => _SearchScreenState();
}
class _SearchScreenState extends ConsumerState<SearchScreen> {
final _controller = TextEditingController();
String _query = '';
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
return Scaffold(
appBar: AppBar(
title: TextField(
controller: _controller,
autofocus: true,
textInputAction: TextInputAction.search,
style: const TextStyle(color: RatuneColors.foreground),
cursorColor: accent,
decoration: const InputDecoration(
hintText: 'Search artists, albums, songs…',
hintStyle: TextStyle(color: RatuneColors.dimmed),
border: InputBorder.none,
),
onSubmitted: (v) => setState(() => _query = v),
),
),
body: SafeArea(
child: _query.trim().isEmpty
? const Center(
child: Text('Type and press search.',
style: TextStyle(color: RatuneColors.dimmed)),
)
: _Results(query: _query),
),
);
}
}
class _Results extends ConsumerWidget {
const _Results({required this.query});
final String query;
@override
Widget build(BuildContext context, WidgetRef ref) {
final results = ref.watch(searchProvider(query));
return results.when(
loading: () => const Center(
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
error: (e, _) => Center(
child: Text('$e', style: const TextStyle(color: RatuneColors.dimmed)),
),
data: (r) {
if (r.artists.isEmpty && r.albums.isEmpty && r.songs.isEmpty) {
return const Center(
child: Text('No results.',
style: TextStyle(color: RatuneColors.dimmed)),
);
}
return ListView(
children: [
for (final a in r.artists)
BrowseRow(
title: a.name ?? 'Unknown artist',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ArtistScreen(id: a.id)),
),
),
for (final a in r.albums)
BrowseRow(
title: a.name ?? 'Unknown album',
trailing: a.artist,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id)),
),
),
for (var i = 0; i < r.songs.length; i++)
BrowseRow(
title: r.songs[i].title ?? 'Untitled',
trailing: r.songs[i].artist,
onTap: () => ref
.read(playbackProvider.notifier)
.playSongs(r.songs, startIndex: i),
onPlayNext: () => ref
.read(playbackProvider.notifier)
.playNext(r.songs[i]),
onAddToQueue: () => ref
.read(playbackProvider.notifier)
.addToQueue(r.songs[i]),
),
],
);
},
);
}
}

View file

@ -0,0 +1,174 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../settings/settings_store.dart';
import '../theme/tokens.dart';
import '../widgets/hairline_panel.dart';
/// Audio-quality settings: independent streaming and download knobs, both
/// driven by Subsonic's `stream` transcode params (see `settings/settings_store.dart`).
class SettingsScreen extends ConsumerWidget {
const SettingsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final controller = ref.read(settingsProvider.notifier);
return Scaffold(
appBar: AppBar(
title: const Text('Settings',
style: TextStyle(fontWeight: FontWeight.w700)),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(RatuneSpacing.lg),
children: [
HairlinePanel(
title: 'Streaming',
active: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Caption(
'Quality used when playing tracks that are not downloaded.'),
const SizedBox(height: RatuneSpacing.md),
_ChoiceChips<int>(
label: 'Max bitrate',
values: AppSettings.bitrateChoices,
selected: settings.streamMaxBitRate,
labelFor: AppSettings.bitrateLabel,
onSelect: controller.setStreamMaxBitRate,
),
],
),
),
const SizedBox(height: RatuneSpacing.xl),
HairlinePanel(
title: 'Downloads',
active: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const _Caption(
'Quality used when saving tracks for offline playback. '
'"Original" keeps the source file (best quality, largest).'),
const SizedBox(height: RatuneSpacing.md),
_ChoiceChips<int>(
label: 'Max bitrate',
values: AppSettings.bitrateChoices,
selected: settings.downloadMaxBitRate,
labelFor: AppSettings.bitrateLabel,
onSelect: controller.setDownloadMaxBitRate,
),
const SizedBox(height: RatuneSpacing.lg),
_ChoiceChips<String?>(
label: 'Format',
values: AppSettings.formatChoices,
selected: settings.downloadFormat,
labelFor: AppSettings.formatLabel,
onSelect: controller.setDownloadFormat,
),
],
),
),
],
),
),
);
}
}
class _Caption extends StatelessWidget {
const _Caption(this.text);
final String text;
@override
Widget build(BuildContext context) => Text(
text,
style: const TextStyle(color: RatuneColors.dimmed, fontSize: 12),
);
}
/// A labelled row of selectable value chips — the app's underline-accent
/// language applied to compact bordered chips.
class _ChoiceChips<T> extends StatelessWidget {
const _ChoiceChips({
required this.label,
required this.values,
required this.selected,
required this.labelFor,
required this.onSelect,
});
final String label;
final List<T> values;
final T selected;
final String Function(T) labelFor;
final ValueChanged<T> onSelect;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(color: RatuneColors.foreground)),
const SizedBox(height: RatuneSpacing.sm),
Wrap(
spacing: RatuneSpacing.sm,
runSpacing: RatuneSpacing.sm,
children: [
for (final v in values)
_Chip(
text: labelFor(v),
active: v == selected,
accent: accent,
onTap: () => onSelect(v),
),
],
),
],
);
}
}
class _Chip extends StatelessWidget {
const _Chip({
required this.text,
required this.active,
required this.accent,
required this.onTap,
});
final String text;
final bool active;
final Color accent;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg,
vertical: RatuneSpacing.md,
),
decoration: BoxDecoration(
border: Border.all(
color: active ? accent : RatuneColors.border,
),
color: active ? accent.withValues(alpha: 0.12) : RatuneColors.surface,
),
child: Text(
text,
style: TextStyle(
color: active ? accent : RatuneColors.foreground,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
),
),
),
);
}
}

View file

@ -0,0 +1,119 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path_provider/path_provider.dart';
/// User-tunable audio quality. Streaming and offline downloads have independent
/// knobs, both driven by Subsonic's `stream` transcode params (`maxBitRate` +
/// `format`; a rate of 0 means original / no transcode). These are global (not
/// per-server) and persisted to a single atomic JSON file, following the
/// write pattern in `library/library_index.dart`.
class AppSettings {
const AppSettings({
this.streamMaxBitRate = 0,
this.downloadMaxBitRate = 0,
this.downloadFormat,
});
/// Cap for live streaming, in kbps. 0 = original / no transcode.
final int streamMaxBitRate;
/// Cap for downloaded files, in kbps. 0 = original (best offline quality).
final int downloadMaxBitRate;
/// Transcode container for downloads (`mp3`, `opus`, `aac`), or null to keep
/// the original file / let the server decide.
final String? downloadFormat;
/// Offered bitrate choices (kbps); 0 renders as "Original".
static const List<int> bitrateChoices = [0, 96, 128, 192, 256, 320];
/// Offered download containers; null renders as "Original".
static const List<String?> formatChoices = [null, 'mp3', 'opus', 'aac'];
static String bitrateLabel(int rate) => rate == 0 ? 'Original' : '$rate kbps';
static String formatLabel(String? f) => f ?? 'Original';
AppSettings copyWith({
int? streamMaxBitRate,
int? downloadMaxBitRate,
// Sentinel so an explicit null (→ original) is distinguishable from "unset".
Object? downloadFormat = _unset,
}) =>
AppSettings(
streamMaxBitRate: streamMaxBitRate ?? this.streamMaxBitRate,
downloadMaxBitRate: downloadMaxBitRate ?? this.downloadMaxBitRate,
downloadFormat: identical(downloadFormat, _unset)
? this.downloadFormat
: downloadFormat as String?,
);
static const Object _unset = Object();
Map<String, dynamic> toJson() => {
'streamMaxBitRate': streamMaxBitRate,
'downloadMaxBitRate': downloadMaxBitRate,
if (downloadFormat != null) 'downloadFormat': downloadFormat,
};
factory AppSettings.fromJson(Map<String, dynamic> j) => AppSettings(
streamMaxBitRate: (j['streamMaxBitRate'] as num?)?.toInt() ?? 0,
downloadMaxBitRate: (j['downloadMaxBitRate'] as num?)?.toInt() ?? 0,
downloadFormat: j['downloadFormat'] as String?,
);
}
/// Loads settings on launch and persists every change (atomic temp+rename).
class SettingsController extends StateNotifier<AppSettings> {
SettingsController() : super(const AppSettings()) {
_load();
}
File? _file;
Future<void> _load() async {
try {
final dir = await getApplicationSupportDirectory();
_file = File('${dir.path}/settings.json');
if (await _file!.exists()) {
final raw = jsonDecode(await _file!.readAsString());
if (raw is Map) {
state = AppSettings.fromJson(raw.cast<String, dynamic>());
}
}
} catch (_) {
// Missing/corrupt settings are non-fatal — keep the defaults.
}
}
void setStreamMaxBitRate(int rate) {
state = state.copyWith(streamMaxBitRate: rate);
_persist();
}
void setDownloadMaxBitRate(int rate) {
state = state.copyWith(downloadMaxBitRate: rate);
_persist();
}
void setDownloadFormat(String? format) {
state = state.copyWith(downloadFormat: format);
_persist();
}
Future<void> _persist() async {
try {
final file = _file;
if (file == null) return;
final tmp = File('${file.path}.tmp');
await tmp.writeAsString(jsonEncode(state.toJson()));
await tmp.rename(file.path);
} catch (_) {}
}
}
final settingsProvider =
StateNotifierProvider<SettingsController, AppSettings>(
(ref) => SettingsController(),
);

216
lib/shell/app_shell.dart Normal file
View file

@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../screens/browser_screen.dart';
import '../screens/connect_sheet.dart';
import '../screens/home_screen.dart';
import '../screens/now_playing_screen.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
import '../widgets/mini_player.dart';
/// Top-level shell: the three Ratune tabs (Home / Browse / Now Playing) with a
/// bottom tab bar and a status bar, mirroring the terminal layout.
class AppShell extends ConsumerStatefulWidget {
const AppShell({super.key});
@override
ConsumerState<AppShell> createState() => _AppShellState();
}
class _AppShellState extends ConsumerState<AppShell> {
static const _tabs = ['Home', 'Browse', 'Now Playing'];
// Home and Browse push detail screens, so each owns a nested Navigator whose
// routes render *inside* the tab, beneath the persistent mini-player/tab bar.
// Now Playing never pushes, so it needs none.
final _homeNavKey = GlobalKey<NavigatorState>();
final _browseNavKey = GlobalKey<NavigatorState>();
GlobalKey<NavigatorState>? _navKeyForTab(int tab) => switch (tab) {
0 => _homeNavKey,
1 => _browseNavKey,
_ => null,
};
/// Android system-back: pop the active tab's nested stack first, then fall
/// back to Home, and only exit the app from the Home root.
void _handleBack(int tab) {
final nav = _navKeyForTab(tab)?.currentState;
if (nav != null && nav.canPop()) {
nav.pop();
} else if (tab != 0) {
ref.read(selectedTabProvider.notifier).state = 0;
} else {
SystemNavigator.pop();
}
}
@override
Widget build(BuildContext context) {
// Keep favorites alive from launch so its connect/disconnect listener runs
// and hydrates stars/ratings as soon as a server connects.
ref.watch(favoritesProvider);
final index = ref.watch(selectedTabProvider);
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _handleBack(index);
},
child: Scaffold(
body: SafeArea(
bottom: false,
child: IndexedStack(
index: index,
children: [
_TabNavigator(navigatorKey: _homeNavKey, child: const HomeScreen()),
_TabNavigator(
navigatorKey: _browseNavKey, child: const BrowserScreen()),
const NowPlayingScreen(),
],
),
),
bottomNavigationBar: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const MiniPlayer(),
_TabBar(
tabs: _tabs,
index: index,
onSelect: (i) =>
ref.read(selectedTabProvider.notifier).state = i,
),
const _StatusBar(),
],
),
),
),
);
}
}
/// Wraps a tab root in its own [Navigator] so `Navigator.of(context).push`
/// calls from within the tab resolve here (nested) rather than the root
/// navigator — keeping the mini-player and tab bar on screen.
class _TabNavigator extends StatelessWidget {
const _TabNavigator({required this.navigatorKey, required this.child});
final GlobalKey<NavigatorState> navigatorKey;
final Widget child;
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey,
onGenerateRoute: (settings) =>
MaterialPageRoute(builder: (_) => child, settings: settings),
);
}
}
class _TabBar extends StatelessWidget {
const _TabBar({
required this.tabs,
required this.index,
required this.onSelect,
});
final List<String> tabs;
final int index;
final ValueChanged<int> onSelect;
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
final children = <Widget>[];
for (var i = 0; i < tabs.length; i++) {
final active = i == index;
children.add(
InkWell(
onTap: () => onSelect(i),
child: Container(
constraints:
const BoxConstraints(minHeight: RatuneSpacing.minTouchTarget),
padding: const EdgeInsets.symmetric(
horizontal: RatuneSpacing.md,
vertical: RatuneSpacing.md,
),
child: Center(
child: Text(
tabs[i],
style: TextStyle(
color: active ? RatuneColors.foreground : RatuneColors.dimmed,
fontWeight: active ? FontWeight.w700 : FontWeight.w400,
decoration:
active ? TextDecoration.underline : TextDecoration.none,
decorationColor: accent,
decorationThickness: 2,
),
),
),
),
),
);
if (i < tabs.length - 1) {
children.add(
const Text('|', style: TextStyle(color: RatuneColors.border)),
);
}
}
return Container(
decoration: const BoxDecoration(
border: Border(top: BorderSide(color: RatuneColors.border)),
),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: children),
);
}
}
class _StatusBar extends ConsumerWidget {
const _StatusBar();
@override
Widget build(BuildContext context, WidgetRef ref) {
final conn = ref.watch(connectionProvider);
final accent = Theme.of(context).colorScheme.primary;
final (glyph, label, color) = switch (conn.status) {
ConnStatus.online => (
'●',
conn.credentials?.display ?? 'online',
accent,
),
ConnStatus.connecting => ('◐', 'connecting…', RatuneColors.dimmed),
ConnStatus.error => ('○', 'offline', const Color(0xFFE06C75)),
ConnStatus.disconnected => ('○', 'tap to connect', RatuneColors.dimmed),
};
return InkWell(
onTap: () => showConnectSheet(context),
child: Container(
height: 22,
padding: const EdgeInsets.symmetric(horizontal: RatuneSpacing.lg),
color: RatuneColors.surface,
child: Row(
children: [
Text('$glyph ', style: TextStyle(color: color)),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: color),
),
),
const SizedBox(width: RatuneSpacing.md),
const Text('i — help', style: TextStyle(color: RatuneColors.dimmed)),
],
),
),
);
}
}

98
lib/state/favorites.dart Normal file
View file

@ -0,0 +1,98 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../subsonic/models.dart';
import '../subsonic/subsonic_client.dart';
/// Client-side mirror of the server's stars + ratings, so toggles feel instant
/// (optimistic update, reverted on failure). Hydrated from `getStarred2`.
class FavoritesState {
const FavoritesState({
this.songIds = const {},
this.albumIds = const {},
this.artistIds = const {},
this.ratings = const {},
});
final Set<String> songIds;
final Set<String> albumIds;
final Set<String> artistIds;
final Map<String, int> ratings;
bool isSongStarred(String id) => songIds.contains(id);
int ratingFor(String id) => ratings[id] ?? 0;
FavoritesState copyWith({
Set<String>? songIds,
Set<String>? albumIds,
Set<String>? artistIds,
Map<String, int>? ratings,
}) =>
FavoritesState(
songIds: songIds ?? this.songIds,
albumIds: albumIds ?? this.albumIds,
artistIds: artistIds ?? this.artistIds,
ratings: ratings ?? this.ratings,
);
}
class FavoritesController extends StateNotifier<FavoritesState> {
FavoritesController(this._clientGetter) : super(const FavoritesState()) {
if (_clientGetter() != null) hydrate();
}
final SubsonicClient? Function() _clientGetter;
Future<void> hydrate() async {
final client = _clientGetter();
if (client == null) return;
try {
final starred = await client.getStarred2();
final ratings = <String, int>{};
for (final s in starred.songs) {
if (s.userRating != null) ratings[s.id] = s.userRating!;
}
state = FavoritesState(
songIds: starred.songs.map((s) => s.id).toSet(),
albumIds: starred.albums.map((a) => a.id).toSet(),
artistIds: starred.artists.map((a) => a.id).toSet(),
ratings: ratings,
);
} catch (_) {
// Leave current state on failure.
}
}
void clear() => state = const FavoritesState();
Future<void> toggleSong(Song song) async {
final client = _clientGetter();
if (client == null) return;
final wasStarred = state.isSongStarred(song.id);
final next = Set<String>.from(state.songIds);
wasStarred ? next.remove(song.id) : next.add(song.id);
state = state.copyWith(songIds: next); // optimistic
try {
await client.setStarred(starred: !wasStarred, songId: song.id);
} catch (_) {
// Revert on failure.
final reverted = Set<String>.from(state.songIds);
wasStarred ? reverted.add(song.id) : reverted.remove(song.id);
state = state.copyWith(songIds: reverted);
}
}
Future<void> rateSong(String songId, int rating) async {
final client = _clientGetter();
if (client == null) return;
final previous = state.ratings[songId] ?? 0;
final next = Map<String, int>.from(state.ratings)..[songId] = rating;
state = state.copyWith(ratings: next); // optimistic
try {
await client.setRating(songId, rating);
} catch (_) {
final reverted = Map<String, int>.from(state.ratings)
..[songId] = previous;
state = state.copyWith(ratings: reverted);
}
}
}

340
lib/state/providers.dart Normal file
View file

@ -0,0 +1,340 @@
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:flutter/widgets.dart' show NetworkImage;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../downloads/download_manager.dart';
import '../history/play_history.dart';
import '../library/library_index.dart';
import '../playback/playback_engine.dart';
import '../playlists/playlists.dart';
import '../settings/settings_store.dart';
import '../subsonic/credentials.dart';
import '../subsonic/models.dart';
import '../subsonic/subsonic_client.dart';
import '../theme/accent.dart';
import '../theme/accent_extract.dart';
import 'favorites.dart';
// ---- Connection ---------------------------------------------------------
enum ConnStatus { disconnected, connecting, online, error }
class ConnectionState {
const ConnectionState({
required this.status,
this.client,
this.credentials,
this.error,
});
final ConnStatus status;
final SubsonicClient? client;
final SubsonicCredentials? credentials;
final String? error;
bool get isOnline => status == ConnStatus.online;
}
final credentialStoreProvider =
Provider<CredentialStore>((_) => CredentialStore());
/// Owns the active server connection: builds the client, pings, persists
/// credentials, and auto-restores on launch.
class ConnectionController extends StateNotifier<ConnectionState> {
ConnectionController(this._store)
: super(const ConnectionState(status: ConnStatus.disconnected)) {
_restore();
}
final CredentialStore _store;
Future<void> _restore() async {
try {
final creds = await _store.load();
if (creds != null) {
await connect(creds, persist: false);
}
} catch (_) {
// No secure-storage backend available (e.g. Linux without a running
// keyring daemon) — start disconnected rather than crashing.
}
}
Future<bool> connect(SubsonicCredentials creds, {bool persist = true}) async {
state = const ConnectionState(status: ConnStatus.connecting);
final client = SubsonicClient(
baseUrl: creds.url,
username: creds.username,
password: creds.password,
);
try {
final ok = await client.ping();
if (!ok) {
// Keep the credentials on a reachability failure so the app still knows
// *which* server it is (offline downloads / playlists key off this) and
// can retry — only auth failures below drop them.
state = ConnectionState(
status: ConnStatus.error,
credentials: creds,
error: 'Could not reach the server.',
);
return false;
}
// Persistence is best-effort: a locked/absent secure-storage backend
// (e.g. a locked Linux keyring) must not stop this session connecting.
if (persist) {
try {
await _store.save(creds);
} catch (_) {}
}
state = ConnectionState(
status: ConnStatus.online,
client: client,
credentials: creds,
);
return true;
} on SubsonicError catch (e) {
// Auth failures drop the credentials (they're wrong); any other server
// error keeps them so offline features still resolve the server key.
state = ConnectionState(
status: ConnStatus.error,
credentials: e.isAuthFailure ? null : creds,
error: e.isAuthFailure ? 'Wrong username or password.' : e.message,
);
return false;
} catch (e) {
state = ConnectionState(
status: ConnStatus.error,
credentials: creds,
error: e.toString(),
);
return false;
}
}
Future<void> disconnect() async {
await _store.clear();
state = const ConnectionState(status: ConnStatus.disconnected);
}
}
final connectionProvider =
StateNotifierProvider<ConnectionController, ConnectionState>(
(ref) => ConnectionController(ref.watch(credentialStoreProvider)),
);
/// The active client, or null when not connected.
final subsonicClientProvider = Provider<SubsonicClient?>(
(ref) => ref.watch(connectionProvider).client,
);
/// Stable per-server cache key (`md5(baseUrl|username)`), derived from the
/// current credentials so it also resolves while offline (the error state
/// retains credentials). Null when no server has ever been configured. Used to
/// scope on-disk downloads/playlists to the server they belong to.
final serverKeyProvider = Provider<String?>((ref) {
final creds = ref.watch(connectionProvider).credentials;
if (creds == null) return null;
final base = creds.url.endsWith('/')
? creds.url.substring(0, creds.url.length - 1)
: creds.url;
return md5.convert(utf8.encode('$base|${creds.username}')).toString();
});
// ---- Navigation / browse mode -------------------------------------------
/// The active bottom-tab index. Single source of truth shared by the tab bar
/// and the mini-player (which jumps to Now Playing on tap).
final selectedTabProvider = StateProvider<int>((_) => 0);
/// Index of the Now Playing tab in the shell's tab list.
const int nowPlayingTabIndex = 2;
/// Top-level Browse mode selector.
enum BrowseMode { artists, albums, tracks }
final browseModeProvider = StateProvider<BrowseMode>((_) => BrowseMode.artists);
// ---- Library ------------------------------------------------------------
final artistsProvider = FutureProvider<List<Artist>>((ref) async {
final client = ref.watch(subsonicClientProvider);
if (client == null) return const [];
final result = await client.getArtists();
return result.all;
});
/// All albums (alphabetical), paged through fully so nothing is silently
/// truncated. Backs the Albums cover-art grid.
final albumsProvider = FutureProvider<List<Album>>((ref) async {
final client = ref.watch(subsonicClientProvider);
if (client == null) return const [];
const pageSize = 500;
final all = <Album>[];
var offset = 0;
while (true) {
final page = await client.getAlbumList2(size: pageSize, offset: offset);
all.addAll(page);
if (page.length < pageSize) break;
offset += pageSize;
}
return all;
});
/// The crawled + cached flat song index that backs the Tracks view. Cleared
/// (and any in-flight build cancelled) whenever the server changes.
final libraryIndexProvider =
StateNotifierProvider<LibraryIndexController, LibraryIndexState>((ref) {
final controller =
LibraryIndexController(() => ref.read(subsonicClientProvider));
ref.listen<ConnectionState>(connectionProvider, (_, _) {
controller.onConnectionChanged();
});
return controller;
});
final artistProvider = FutureProvider.family<Artist, String>((ref, id) async {
final client = ref.watch(subsonicClientProvider);
if (client == null) throw StateError('Not connected');
return client.getArtist(id);
});
final albumProvider = FutureProvider.family<Album, String>((ref, id) async {
final client = ref.watch(subsonicClientProvider);
if (client == null) throw StateError('Not connected');
return client.getAlbum(id);
});
final searchProvider =
FutureProvider.family<SearchResult3, String>((ref, query) async {
final client = ref.watch(subsonicClientProvider);
if (client == null || query.trim().isEmpty) {
return SearchResult3(artists: const [], albums: const [], songs: const []);
}
return client.search3(query.trim());
});
// ---- History (Home tab) -------------------------------------------------
final playHistoryProvider =
StateNotifierProvider<HistoryController, List<PlayRecord>>(
(ref) => HistoryController(),
);
final recentSongsProvider = Provider<List<PlayRecord>>(
(ref) => recentSongs(ref.watch(playHistoryProvider)),
);
final recentAlbumsProvider = Provider<List<PlayRecord>>(
(ref) => recentAlbums(ref.watch(playHistoryProvider)),
);
/// Bump to re-roll the rediscover suggestions.
final rediscoverSeedProvider = StateProvider<int>((_) => 0);
final rediscoverProvider = Provider<List<RediscoverArtist>>((ref) {
final history = ref.watch(playHistoryProvider);
final seed = ref.watch(rediscoverSeedProvider);
return rediscover(history, seed: seed);
});
// ---- Favorites / ratings ------------------------------------------------
final favoritesProvider =
StateNotifierProvider<FavoritesController, FavoritesState>((ref) {
final controller =
FavoritesController(() => ref.read(subsonicClientProvider));
// Re-hydrate on connect, clear on disconnect.
ref.listen<ConnectionState>(connectionProvider, (prev, next) {
if (next.isOnline) {
controller.hydrate();
} else {
controller.clear();
}
});
return controller;
});
/// Full starred set for the Favorites screen.
final starredProvider = FutureProvider<Starred2>((ref) async {
// Re-run when the local favorites set changes (e.g. after a toggle).
ref.watch(favoritesProvider);
final client = ref.watch(subsonicClientProvider);
if (client == null) {
return Starred2(artists: const [], albums: const [], songs: const []);
}
return client.getStarred2();
});
// ---- Downloads ----------------------------------------------------------
/// Offline download store: fetches tracks to disk (per-server) and exposes
/// their status. Reloads its manifest whenever the server key changes.
final downloadManagerProvider =
StateNotifierProvider<DownloadController, DownloadState>((ref) {
final controller = DownloadController(
clientGetter: () => ref.read(subsonicClientProvider),
settingsGetter: () => ref.read(settingsProvider),
serverKeyGetter: () => ref.read(serverKeyProvider),
);
ref.listen<String?>(serverKeyProvider, (_, _) {
controller.reloadForServer();
});
return controller;
});
// ---- Playlists ----------------------------------------------------------
/// Server-backed playlists with an offline mirror. Reloads/refreshes whenever
/// the server key changes (connect / disconnect / server switch).
final playlistsProvider =
StateNotifierProvider<PlaylistsController, PlaylistsState>((ref) {
final controller = PlaylistsController(
clientGetter: () => ref.read(subsonicClientProvider),
serverKeyGetter: () => ref.read(serverKeyProvider),
);
ref.listen<String?>(serverKeyProvider, (_, _) {
controller.reloadForServer();
});
return controller;
});
// ---- Playback -----------------------------------------------------------
final playbackProvider =
StateNotifierProvider<PlaybackController, PlaybackState>((ref) {
// Prefer a local downloaded file when one exists (works offline / survives
// service interruptions); otherwise stream at the configured bitrate.
Uri? streamUriFor(Song s) {
final local = ref.read(downloadManagerProvider.notifier).localPathFor(s.id);
if (local != null) return Uri.file(local);
final client = ref.read(subsonicClientProvider);
if (client == null) return null;
return client.streamUri(
s.id,
maxBitRate: ref.read(settingsProvider).streamMaxBitRate,
);
}
Uri? coverArtUriFor(Song s) {
final client = ref.read(subsonicClientProvider);
if (client == null || s.coverArt == null) return null;
return client.coverArtUri(s.coverArt!, size: 512);
}
return PlaybackController(
streamUriFor: streamUriFor,
coverArtUriFor: coverArtUriFor,
onArt: (artUri) async {
final color = await extractAccent(NetworkImage(artUri.toString()));
if (color != null) ref.read(accentProvider.notifier).set(color);
},
onPlay: (song) {
// Local history (drives the Home tab) + best-effort server scrobble.
ref.read(playHistoryProvider.notifier).record(song);
ref.read(subsonicClientProvider)?.scrobble(song.id).ignore();
},
);
});

View file

@ -0,0 +1,64 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// A Subsonic server connection. The password is only ever held in memory and
/// in the platform secure store — never in plain app storage (mirrors Ratune's
/// keyring-first secret handling).
class SubsonicCredentials {
const SubsonicCredentials({
required this.url,
required this.username,
required this.password,
this.alias,
});
final String url;
final String username;
final String password;
/// Optional display label shown instead of the raw URL (Ratune `[server].alias`).
final String? alias;
String get display => alias?.isNotEmpty == true ? alias! : Uri.parse(url).host;
}
/// Persists [SubsonicCredentials] to the platform secure store
/// (Keychain / Keystore / libsecret).
class CredentialStore {
CredentialStore([FlutterSecureStorage? storage])
: _storage = storage ?? const FlutterSecureStorage();
final FlutterSecureStorage _storage;
static const _kUrl = 'subsonic_url';
static const _kUser = 'subsonic_username';
static const _kPass = 'subsonic_password';
static const _kAlias = 'subsonic_alias';
Future<void> save(SubsonicCredentials creds) async {
await _storage.write(key: _kUrl, value: creds.url);
await _storage.write(key: _kUser, value: creds.username);
await _storage.write(key: _kPass, value: creds.password);
await _storage.write(key: _kAlias, value: creds.alias ?? '');
}
Future<SubsonicCredentials?> load() async {
final url = await _storage.read(key: _kUrl);
final user = await _storage.read(key: _kUser);
final pass = await _storage.read(key: _kPass);
if (url == null || user == null || pass == null) return null;
final alias = await _storage.read(key: _kAlias);
return SubsonicCredentials(
url: url,
username: user,
password: pass,
alias: (alias == null || alias.isEmpty) ? null : alias,
);
}
Future<void> clear() async {
await _storage.delete(key: _kUrl);
await _storage.delete(key: _kUser);
await _storage.delete(key: _kPass);
await _storage.delete(key: _kAlias);
}
}

View file

@ -0,0 +1,59 @@
/// Defensive JSON helpers for the Subsonic protocol.
///
/// Subsonic servers are inconsistent (Ratune handles the same quirks in
/// `ratune-subsonic/src/models.rs` via `OneOrMany` / `deserialize_flexible_id`):
/// * a field that is sometimes a single object and sometimes an array
/// * IDs that are sometimes strings and sometimes integers
/// Every accessor below tolerates nulls and mixed types rather than throwing.
library;
/// Coerce a value that may be a single object, a list, or null into a list of
/// `T`, applying [fromJson] to each map element.
List<T> oneOrMany<T>(
dynamic value,
T Function(Map<String, dynamic>) fromJson,
) {
if (value == null) return const [];
if (value is List) {
return value
.whereType<Map>()
.map((e) => fromJson(e.cast<String, dynamic>()))
.toList();
}
if (value is Map) {
return [fromJson(value.cast<String, dynamic>())];
}
return const [];
}
/// Read a string that the server might encode as an int (e.g. IDs).
String? asString(dynamic value) {
if (value == null) return null;
if (value is String) return value;
return value.toString();
}
/// Read an int from a value that might be an int, num, or numeric string.
int? asInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value);
return null;
}
/// Read a double from an int/num/numeric-string.
double? asDouble(dynamic value) {
if (value == null) return null;
if (value is double) return value;
if (value is num) return value.toDouble();
if (value is String) return double.tryParse(value);
return null;
}
/// Read a bool that might arrive as a bool or the string "true"/"false".
bool asBool(dynamic value, {bool orElse = false}) {
if (value is bool) return value;
if (value is String) return value.toLowerCase() == 'true';
return orElse;
}

331
lib/subsonic/models.dart Normal file
View file

@ -0,0 +1,331 @@
import 'json_helpers.dart';
/// Subsonic domain models, ported from `ratune-subsonic/src/models.rs`.
/// Nearly every field is nullable — servers omit fields freely. All parsing
/// goes through the defensive helpers in `json_helpers.dart`.
class Song {
Song({
required this.id,
this.title,
this.album,
this.artist,
this.albumId,
this.artistId,
this.track,
this.discNumber,
this.year,
this.genre,
this.coverArt,
this.duration,
this.bitRate,
this.contentType,
this.suffix,
this.size,
this.path,
this.starred = false,
this.userRating,
});
final String id;
final String? title;
final String? album;
final String? artist;
final String? albumId;
final String? artistId;
final int? track;
final int? discNumber;
final int? year;
final String? genre;
final String? coverArt;
/// Track length in seconds.
final int? duration;
final int? bitRate;
final String? contentType;
final String? suffix;
final int? size;
final String? path;
final bool starred;
/// 1–5, or null if unrated.
final int? userRating;
factory Song.fromJson(Map<String, dynamic> j) => Song(
id: asString(j['id']) ?? '',
title: asString(j['title']),
album: asString(j['album']),
artist: asString(j['artist']),
albumId: asString(j['albumId']),
artistId: asString(j['artistId']),
track: asInt(j['track']),
discNumber: asInt(j['discNumber']),
year: asInt(j['year']),
genre: asString(j['genre']),
coverArt: asString(j['coverArt']),
duration: asInt(j['duration']),
bitRate: asInt(j['bitRate']),
contentType: asString(j['contentType']),
suffix: asString(j['suffix']),
size: asInt(j['size']),
path: asString(j['path']),
starred: j['starred'] != null,
userRating: asInt(j['userRating']),
);
/// Round-trips through [Song.fromJson]. Backs the persisted library index
/// (`library_index.dart`); only emits set fields to keep the cache compact.
Map<String, dynamic> toJson() => {
'id': id,
if (title != null) 'title': title,
if (album != null) 'album': album,
if (artist != null) 'artist': artist,
if (albumId != null) 'albumId': albumId,
if (artistId != null) 'artistId': artistId,
if (track != null) 'track': track,
if (discNumber != null) 'discNumber': discNumber,
if (year != null) 'year': year,
if (genre != null) 'genre': genre,
if (coverArt != null) 'coverArt': coverArt,
if (duration != null) 'duration': duration,
if (bitRate != null) 'bitRate': bitRate,
if (contentType != null) 'contentType': contentType,
if (suffix != null) 'suffix': suffix,
if (size != null) 'size': size,
if (path != null) 'path': path,
if (starred) 'starred': true,
if (userRating != null) 'userRating': userRating,
};
}
class Album {
Album({
required this.id,
this.name,
this.artist,
this.artistId,
this.coverArt,
this.songCount,
this.duration,
this.year,
this.genre,
this.starred = false,
this.userRating,
this.songs = const [],
});
final String id;
final String? name;
final String? artist;
final String? artistId;
final String? coverArt;
final int? songCount;
final int? duration;
final int? year;
final String? genre;
final bool starred;
final int? userRating;
/// Populated only by `getAlbum`.
final List<Song> songs;
factory Album.fromJson(Map<String, dynamic> j) => Album(
id: asString(j['id']) ?? '',
name: asString(j['name']) ?? asString(j['album']),
artist: asString(j['artist']),
artistId: asString(j['artistId']),
coverArt: asString(j['coverArt']),
songCount: asInt(j['songCount']),
duration: asInt(j['duration']),
year: asInt(j['year']),
genre: asString(j['genre']),
starred: j['starred'] != null,
userRating: asInt(j['userRating']),
songs: oneOrMany(j['song'], Song.fromJson),
);
}
class Artist {
Artist({
required this.id,
this.name,
this.albumCount,
this.coverArt,
this.starred = false,
this.userRating,
this.albums = const [],
});
final String id;
final String? name;
final int? albumCount;
final String? coverArt;
final bool starred;
final int? userRating;
/// Populated only by `getArtist`.
final List<Album> albums;
factory Artist.fromJson(Map<String, dynamic> j) => Artist(
id: asString(j['id']) ?? '',
name: asString(j['name']),
albumCount: asInt(j['albumCount']),
coverArt: asString(j['coverArt']),
starred: j['starred'] != null,
userRating: asInt(j['userRating']),
albums: oneOrMany(j['album'], Album.fromJson),
);
}
/// A lettered bucket of artists from `getArtists` (`<index name="A">`).
class ArtistIndex {
ArtistIndex({required this.name, required this.artists});
final String name;
final List<Artist> artists;
factory ArtistIndex.fromJson(Map<String, dynamic> j) => ArtistIndex(
name: asString(j['name']) ?? '',
artists: oneOrMany(j['artist'], Artist.fromJson),
);
}
/// Flattened result of `getArtists`.
class ArtistsResult {
ArtistsResult({required this.indexes});
final List<ArtistIndex> indexes;
List<Artist> get all => [for (final i in indexes) ...i.artists];
factory ArtistsResult.fromJson(Map<String, dynamic> j) => ArtistsResult(
indexes: oneOrMany(j['index'], ArtistIndex.fromJson),
);
}
/// Result of `getStarred2` — the user's favorites.
class Starred2 {
Starred2({
required this.artists,
required this.albums,
required this.songs,
});
final List<Artist> artists;
final List<Album> albums;
final List<Song> songs;
factory Starred2.fromJson(Map<String, dynamic> j) => Starred2(
artists: oneOrMany(j['artist'], Artist.fromJson),
albums: oneOrMany(j['album'], Album.fromJson),
songs: oneOrMany(j['song'], Song.fromJson),
);
}
/// Result of `search3`.
class SearchResult3 {
SearchResult3({
required this.artists,
required this.albums,
required this.songs,
});
final List<Artist> artists;
final List<Album> albums;
final List<Song> songs;
factory SearchResult3.fromJson(Map<String, dynamic> j) => SearchResult3(
artists: oneOrMany(j['artist'], Artist.fromJson),
albums: oneOrMany(j['album'], Album.fromJson),
songs: oneOrMany(j['song'], Song.fromJson),
);
}
/// A playlist summary from `getPlaylists` (no tracks), ported from
/// `ratune-subsonic/src/models.rs`. [toJson] backs the offline playlist mirror
/// (`playlists/playlists.dart`).
class Playlist {
Playlist({
required this.id,
required this.name,
this.songCount,
this.duration,
this.owner,
this.public,
this.coverArt,
});
final String id;
final String name;
final int? songCount;
final int? duration;
final String? owner;
final bool? public;
final String? coverArt;
factory Playlist.fromJson(Map<String, dynamic> j) => Playlist(
id: asString(j['id']) ?? '',
name: asString(j['name']) ?? 'Untitled',
songCount: asInt(j['songCount']),
duration: asInt(j['duration']),
owner: asString(j['owner']),
public: j['public'] is bool ? j['public'] as bool : null,
coverArt: asString(j['coverArt']),
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
if (songCount != null) 'songCount': songCount,
if (duration != null) 'duration': duration,
if (owner != null) 'owner': owner,
if (public != null) 'public': public,
if (coverArt != null) 'coverArt': coverArt,
};
}
/// A playlist with its full track list from `getPlaylist`. The Subsonic API
/// nests the tracks under the key `entry` (not `song`).
class PlaylistDetail {
PlaylistDetail({
required this.id,
required this.name,
this.songCount,
this.duration,
this.coverArt,
this.songs = const [],
});
final String id;
final String name;
final int? songCount;
final int? duration;
final String? coverArt;
final List<Song> songs;
factory PlaylistDetail.fromJson(Map<String, dynamic> j) => PlaylistDetail(
id: asString(j['id']) ?? '',
name: asString(j['name']) ?? 'Untitled',
songCount: asInt(j['songCount']),
duration: asInt(j['duration']),
coverArt: asString(j['coverArt']),
songs: oneOrMany(j['entry'], Song.fromJson),
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
if (songCount != null) 'songCount': songCount,
if (duration != null) 'duration': duration,
if (coverArt != null) 'coverArt': coverArt,
'entry': songs.map((s) => s.toJson()).toList(),
};
Playlist toSummary() => Playlist(
id: id,
name: name,
songCount: songCount ?? songs.length,
duration: duration,
coverArt: coverArt,
);
}

View file

@ -0,0 +1,292 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:dio/dio.dart';
import 'json_helpers.dart';
import 'models.dart';
/// A Subsonic API error (server returned `status: "failed"`), ported from
/// `ratune-subsonic/src/error.rs`. Code 40 is the auth-failure code.
class SubsonicError implements Exception {
SubsonicError(this.code, this.message);
final int code;
final String message;
static const int authErrorCode = 40;
bool get isAuthFailure => code == authErrorCode;
@override
String toString() => 'SubsonicError($code): $message';
}
/// Subsonic HTTP client, ported from `ratune-subsonic/src/client.rs`.
///
/// Auth is the classic token scheme: a fresh random salt per request and
/// `token = MD5(password + salt)`, sent as query params
/// `u / t / s / v / c / f=json`. The password is never sent in the clear.
class SubsonicClient {
SubsonicClient({
required String baseUrl,
required this.username,
required String password,
Dio? dio,
}) : baseUrl = _trimTrailingSlash(baseUrl),
// ignore: prefer_initializing_formals — field is private, param can't be
_password = password,
_dio = dio ??
Dio(BaseOptions(
connectTimeout: const Duration(seconds: 30),
receiveTimeout: const Duration(seconds: 30),
headers: {'User-Agent': 'ratune-mobile'},
));
final String baseUrl;
final String username;
final String _password;
final Dio _dio;
static const String apiVersion = '1.16.1';
static const String clientName = 'ratune-mobile';
static const String _saltAlphabet =
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
static String _trimTrailingSlash(String url) =>
url.endsWith('/') ? url.substring(0, url.length - 1) : url;
/// Cryptographically-random 12-char salt (Dart's `Random.secure()` stands in
/// for the platform CSPRNG; Ratune uses a weaker LCG here).
String _makeSalt([int length = 12]) {
final rng = Random.secure();
return List.generate(
length,
(_) => _saltAlphabet[rng.nextInt(_saltAlphabet.length)],
).join();
}
String _makeToken(String salt) =>
md5.convert(utf8.encode('$_password$salt')).toString();
/// The auth + protocol query params appended to every request.
Map<String, String> _authParams() {
final salt = _makeSalt();
return {
'u': username,
't': _makeToken(salt),
's': salt,
'v': apiVersion,
'c': clientName,
'f': 'json',
};
}
/// Build a signed request URI. [params] values may be a `String` or an
/// `Iterable<String>` (the latter emits a repeated query param, e.g. several
/// `songIdToAdd` for `updatePlaylist`).
Uri _uri(String endpoint, [Map<String, dynamic> params = const {}]) {
return Uri.parse('$baseUrl/rest/$endpoint').replace(
queryParameters: {..._authParams(), ...params},
);
}
/// Perform a GET and unwrap the `{"subsonic-response": {...}}` envelope,
/// throwing [SubsonicError] on `status: "failed"`.
Future<Map<String, dynamic>> _get(
String endpoint, [
Map<String, dynamic> params = const {},
]) async {
final response = await _dio.getUri(_uri(endpoint, params));
final data = response.data;
final Map<String, dynamic> body = data is String
? (jsonDecode(data) as Map).cast<String, dynamic>()
: (data as Map).cast<String, dynamic>();
final inner =
(body['subsonic-response'] as Map?)?.cast<String, dynamic>() ?? {};
final status = asString(inner['status']);
if (status == 'failed') {
final err = (inner['error'] as Map?)?.cast<String, dynamic>() ?? {};
throw SubsonicError(
asInt(err['code']) ?? -1,
asString(err['message']) ?? 'Unknown Subsonic error',
);
}
return inner;
}
// ---- Connectivity -------------------------------------------------------
/// `ping` — returns true if the server responds ok with valid credentials.
Future<bool> ping() async {
try {
await _get('ping');
return true;
} on SubsonicError {
rethrow;
} catch (_) {
return false;
}
}
// ---- Browsing -----------------------------------------------------------
/// `getArtists` — the full artist index.
Future<ArtistsResult> getArtists() async {
final r = await _get('getArtists');
final artists =
(r['artists'] as Map?)?.cast<String, dynamic>() ?? const {};
return ArtistsResult.fromJson(artists);
}
/// `getArtist` — one artist with its albums.
Future<Artist> getArtist(String id) async {
final r = await _get('getArtist', {'id': id});
return Artist.fromJson(
(r['artist'] as Map).cast<String, dynamic>());
}
/// `getAlbum` — one album with its songs.
Future<Album> getAlbum(String id) async {
final r = await _get('getAlbum', {'id': id});
return Album.fromJson((r['album'] as Map).cast<String, dynamic>());
}
/// `getAlbumList2` — a page of albums by [type] (`alphabeticalByName`,
/// `newest`, `frequent`, …). Subsonic caps [size] at 500; page with [offset].
/// Albums here carry metadata + cover art but no songs (use `getAlbum`).
Future<List<Album>> getAlbumList2({
String type = 'alphabeticalByName',
int size = 500,
int offset = 0,
}) async {
final r = await _get('getAlbumList2', {
'type': type,
'size': '${size.clamp(1, 500)}',
'offset': '$offset',
});
final list = (r['albumList2'] as Map?)?.cast<String, dynamic>() ?? const {};
return oneOrMany(list['album'], Album.fromJson);
}
// ---- Search -------------------------------------------------------------
/// `search3` — combined artist/album/song search.
Future<SearchResult3> search3(String query) async {
final r = await _get('search3', {'query': query});
final result =
(r['searchResult3'] as Map?)?.cast<String, dynamic>() ?? const {};
return SearchResult3.fromJson(result);
}
// ---- Favorites / ratings ------------------------------------------------
/// `getStarred2` — the user's starred artists/albums/songs.
Future<Starred2> getStarred2() async {
final r = await _get('getStarred2');
final s = (r['starred2'] as Map?)?.cast<String, dynamic>() ?? const {};
return Starred2.fromJson(s);
}
/// `star` / `unstar`. Dispatches to the right param (id / albumId / artistId)
/// exactly like Ratune's `set_starred` (`client.rs`).
Future<void> setStarred({
required bool starred,
String? songId,
String? albumId,
String? artistId,
}) async {
final param = <String, String>{
'id': ?songId,
'albumId': ?albumId,
'artistId': ?artistId,
};
if (param.isEmpty) return;
await _get(starred ? 'star' : 'unstar', param);
}
/// `setRating` — 1–5, or 0 to clear.
Future<void> setRating(String id, int rating) async {
await _get('setRating', {'id': id, 'rating': '${rating.clamp(0, 5)}'});
}
/// `scrobble` — record a play on the server (increments play counts / marks
/// now-playing). `submission=true` is a completed listen.
Future<void> scrobble(String id, {bool submission = true}) async {
await _get('scrobble', {'id': id, 'submission': '$submission'});
}
// ---- Playlists ----------------------------------------------------------
// Ported from `ratune-subsonic/src/client.rs`. Track mutations all go through
// `updatePlaylist` with `songIdToAdd` / `songIndexToRemove` / `name`.
/// `getPlaylists` — every playlist visible to the authenticated user.
Future<List<Playlist>> getPlaylists() async {
final r = await _get('getPlaylists');
final list = (r['playlists'] as Map?)?.cast<String, dynamic>() ?? const {};
return oneOrMany(list['playlist'], Playlist.fromJson);
}
/// `getPlaylist` — one playlist including its full track list.
Future<PlaylistDetail> getPlaylist(String id) async {
final r = await _get('getPlaylist', {'id': id});
return PlaylistDetail.fromJson(
(r['playlist'] as Map).cast<String, dynamic>());
}
/// `createPlaylist` — create an empty playlist. Navidrome echoes the created
/// playlist under `playlist` (same shape as `getPlaylist`); returns it when
/// present so the caller gets the new id, else null (caller refetches).
Future<PlaylistDetail?> createPlaylist(String name) async {
final r = await _get('createPlaylist', {'name': name});
final p = (r['playlist'] as Map?)?.cast<String, dynamic>();
return p == null ? null : PlaylistDetail.fromJson(p);
}
/// `updatePlaylist` + one or more `songIdToAdd` — append tracks.
Future<void> addTracksToPlaylist(String playlistId, List<String> songIds) async {
if (songIds.isEmpty) return;
await _get('updatePlaylist', {
'playlistId': playlistId,
'songIdToAdd': songIds,
});
}
/// `updatePlaylist` + `songIndexToRemove` — remove the track at [index].
Future<void> removeTrackFromPlaylist(String playlistId, int index) async {
await _get('updatePlaylist', {
'playlistId': playlistId,
'songIndexToRemove': '$index',
});
}
/// `updatePlaylist` + `name` — rename a playlist.
Future<void> renamePlaylist(String playlistId, String name) async {
await _get('updatePlaylist', {'playlistId': playlistId, 'name': name});
}
/// `deletePlaylist` — delete a playlist by id.
Future<void> deletePlaylist(String id) async {
await _get('deletePlaylist', {'id': id});
}
// ---- Stream / art URLs (self-contained signed URLs) --------------------
/// Signed streaming URL, handed straight to the audio engine (or the download
/// manager, which fetches these bytes to disk). `maxBitRate == 0` means
/// original / no transcode; [format] requests a specific transcode container
/// (e.g. `mp3`, `opus`), or null for the server default / original.
Uri streamUri(String id, {int maxBitRate = 0, String? format}) =>
_uri('stream', {
'id': id,
if (maxBitRate > 0) 'maxBitRate': '$maxBitRate',
if (format != null && format.isNotEmpty) 'format': format,
});
/// Signed cover-art URL. [size] is clamped to Subsonic's 32–2048 range.
Uri coverArtUri(String id, {int? size}) => _uri('getCoverArt', {
'id': id,
if (size != null) 'size': '${size.clamp(32, 2048)}',
});
}

24
lib/theme/accent.dart Normal file
View file

@ -0,0 +1,24 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'tokens.dart';
/// Holds the current accent color — the "hero" of Ratune's aesthetic.
///
/// In the `dynamic` theme this is extracted from the playing track's album
/// art and boosted for readability in OKLab space (Ratune `color.rs:11-64`),
/// then interpolated toward over ~400ms. Phase 1 just exposes the default and
/// a setter; the extraction + animation land in Phase 2 with playback.
class AccentNotifier extends StateNotifier<Color> {
AccentNotifier() : super(RatuneColors.accentDefault);
/// Snap to a new accent (e.g. from album-art extraction).
void set(Color color) => state = color;
/// Return to the fixed default (e.g. no art / `static` theme).
void reset() => state = RatuneColors.accentDefault;
}
final accentProvider = StateNotifierProvider<AccentNotifier, Color>(
(ref) => AccentNotifier(),
);

View file

@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:palette_generator/palette_generator.dart';
/// Extract a lively accent color from album art, mirroring Ratune's
/// art-driven `dynamic` theme (`color.rs`): pick the most vibrant swatch, then
/// nudge it into a readable lightness/saturation band. Ratune does the boost in
/// OKLab; this HSL approximation is close enough for now (OKLab is a later
/// refinement noted in the roadmap).
Future<Color?> extractAccent(ImageProvider image) async {
final palette = await PaletteGenerator.fromImageProvider(
image,
size: const Size(200, 200),
maximumColorCount: 8,
);
final picked = palette.vibrantColor?.color ??
palette.lightVibrantColor?.color ??
palette.dominantColor?.color;
if (picked == null) return null;
return _ensureReadable(picked);
}
Color _ensureReadable(Color c) {
final hsl = HSLColor.fromColor(c);
// Keep it bright enough to read on near-black, saturated enough to feel alive.
final lightness = hsl.lightness.clamp(0.45, 0.70);
final saturation = hsl.saturation < 0.40 ? 0.55 : hsl.saturation;
return hsl.withLightness(lightness).withSaturation(saturation).toColor();
}

52
lib/theme/app_theme.dart Normal file
View file

@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'tokens.dart';
/// Builds the app [ThemeData] from the Ratune tokens.
///
/// Monospace type is core to the identity — every surface uses JetBrains Mono
/// (a close analog to the terminal fonts in Ratune's screenshots). The [accent]
/// is passed in so the theme rebuilds when album-art extraction changes it.
ThemeData buildRatuneTheme(Color accent) {
final mono = GoogleFonts.jetBrainsMonoTextTheme(
ThemeData.dark().textTheme,
).apply(
bodyColor: RatuneColors.foreground,
displayColor: RatuneColors.foreground,
);
final scheme = ColorScheme.fromSeed(
seedColor: accent,
brightness: Brightness.dark,
surface: RatuneColors.surface,
).copyWith(
primary: accent,
secondary: accent,
onSurface: RatuneColors.foreground,
);
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
scaffoldBackgroundColor: RatuneColors.background,
canvasColor: RatuneColors.background,
colorScheme: scheme,
textTheme: mono,
primaryTextTheme: mono,
dividerColor: RatuneColors.border,
splashFactory: NoSplash.splashFactory,
highlightColor: Colors.transparent,
// Keep chrome flat and quiet — the content (and accent) carry the look.
appBarTheme: const AppBarTheme(
backgroundColor: RatuneColors.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
centerTitle: false,
),
listTileTheme: const ListTileThemeData(
dense: true,
minVerticalPadding: RatuneSpacing.sm,
),
);
}

49
lib/theme/tokens.dart Normal file
View file

@ -0,0 +1,49 @@
import 'package:flutter/widgets.dart';
/// Design tokens mirroring Ratune's `dynamic`/`static` theme defaults
/// (see ratune `docs/sample-config.toml`, `theme.rs`, `color.rs`).
///
/// The palette is intentionally near-black and low-contrast; the *accent*
/// is the one lively color and, in the `dynamic` theme, is extracted live
/// from the current album art (Phase 2). Everything else stays fixed.
class RatuneColors {
const RatuneColors._();
/// App canvas — `background = #1a1a1a`.
static const Color background = Color(0xFF1A1A1A);
/// Panel/surface fill — `surface = #161616` (slightly darker than canvas).
static const Color surface = Color(0xFF161616);
/// Primary text — `foreground = #d4d0c8` (warm off-white, not pure white).
static const Color foreground = Color(0xFFD4D0C8);
/// Secondary/muted text — `dimmed = #5a5858`.
static const Color dimmed = Color(0xFF5A5858);
/// Hairline borders (inactive) — `border = #252525`.
static const Color border = Color(0xFF252525);
/// Hairline borders when a pane is focused — `border_active`.
/// Ratune tints this toward the accent; we start with a lighter grey and
/// swap in the accent at runtime once art extraction lands.
static const Color borderActive = Color(0xFF3A3A3A);
/// Default accent — `accent = #ff8c00`. Overridden per-track in `dynamic`.
static const Color accentDefault = Color(0xFFFF8C00);
}
/// Spacing scale — dense, "player as instrument" rather than airy mobile cards.
class RatuneSpacing {
const RatuneSpacing._();
static const double xs = 2;
static const double sm = 4;
static const double md = 8;
static const double lg = 12;
static const double xl = 16;
/// Minimum touch target per Apple/Material guidance — keep rows dense
/// visually but pad hit areas to at least this.
static const double minTouchTarget = 44;
}

View file

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import '../theme/tokens.dart';
/// The segmented block progress bar from Ratune's now-playing strip
/// (`progress_style = "██░"`). Discrete cells: filled cells use the accent,
/// empty cells the border grey, with hairline gaps between them.
class BlockProgressBar extends StatelessWidget {
const BlockProgressBar({
super.key,
required this.progress,
this.cells = 40,
this.height = 10,
this.color,
}) : assert(progress >= 0 && progress <= 1);
/// 0.0–1.0 elapsed fraction.
final double progress;
/// Number of block cells to render.
final int cells;
final double height;
/// Filled color; defaults to the theme accent.
final Color? color;
@override
Widget build(BuildContext context) {
final filledColor = color ?? Theme.of(context).colorScheme.primary;
final filled = (progress * cells).round();
return SizedBox(
height: height,
child: Row(
children: List.generate(cells, (i) {
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 0.5),
child: ColoredBox(
color: i < filled ? filledColor : RatuneColors.border,
),
),
);
}),
),
);
}
}

View file

@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import '../theme/tokens.dart';
/// A thin-stroked panel with its title label sitting *on* the top border —
/// Ratune's signature container ("Album Art", "Queue (127)", "Lyrics",
/// "Visualizer"). Recreated with the fieldset trick: draw a full 1px border,
/// then overlay the title with a background that occludes the segment behind
/// it, so the label appears to break the line.
class HairlinePanel extends StatelessWidget {
const HairlinePanel({
super.key,
required this.title,
required this.child,
this.trailing,
this.active = false,
this.padding = const EdgeInsets.all(RatuneSpacing.lg),
this.backgroundColor = RatuneColors.background,
});
/// Panel label, e.g. "Queue". Rendered uppercase-ish in mono.
final String title;
/// Optional trailing bit of the label, e.g. "(127)" — dimmed.
final String? trailing;
/// Panel content.
final Widget child;
/// When focused, the border and title tint toward the accent.
final bool active;
final EdgeInsetsGeometry padding;
/// Color painted behind the title to "cut" the border. Must match whatever
/// sits behind this panel (the canvas by default).
final Color backgroundColor;
static const double _titleStraddle = 8;
@override
Widget build(BuildContext context) {
final borderColor =
active ? RatuneColors.borderActive : RatuneColors.border;
final titleColor =
active ? Theme.of(context).colorScheme.primary : RatuneColors.dimmed;
return Stack(
clipBehavior: Clip.none,
children: [
Container(
margin: const EdgeInsets.only(top: _titleStraddle),
decoration: BoxDecoration(
border: Border.all(color: borderColor, width: 1),
),
child: Padding(padding: padding, child: child),
),
Positioned(
left: RatuneSpacing.lg,
top: 0,
child: Container(
color: backgroundColor,
padding: const EdgeInsets.symmetric(
horizontal: RatuneSpacing.md,
),
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: title,
style: TextStyle(
color: titleColor,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
if (trailing != null)
TextSpan(
text: ' $trailing',
style: const TextStyle(color: RatuneColors.dimmed),
),
],
),
style: Theme.of(context).textTheme.labelMedium,
),
),
),
],
);
}
}

View file

@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
/// Persistent mini-player pinned above the tab bar. Visible only while a track
/// is loaded; tapping the body jumps to the Now Playing tab.
///
/// Watches `current`/`playing` via `.select()` so it doesn't rebuild on every
/// position tick — the moving progress line is isolated in [_MiniProgress].
class MiniPlayer extends ConsumerWidget {
const MiniPlayer({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Redundant (and steals vertical space) on the Now Playing tab itself.
if (ref.watch(selectedTabProvider) == nowPlayingTabIndex) {
return const SizedBox.shrink();
}
final current = ref.watch(playbackProvider.select((s) => s.current));
if (current == null) return const SizedBox.shrink();
final playing = ref.watch(playbackProvider.select((s) => s.playing));
final controller = ref.read(playbackProvider.notifier);
final client = ref.watch(subsonicClientProvider);
final accent = Theme.of(context).colorScheme.primary;
final artUri = (client != null && current.coverArt != null)
? client.coverArtUri(current.coverArt!, size: 128).toString()
: null;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const _MiniProgress(),
InkWell(
onTap: () =>
ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex,
child: Container(
height: 52,
color: RatuneColors.surface,
padding: const EdgeInsets.only(left: RatuneSpacing.md),
child: Row(
children: [
SizedBox(
width: 40,
height: 40,
child: ColoredBox(
color: RatuneColors.background,
child: artUri != null
? Image.network(
artUri,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const _ArtFallback(),
)
: const _ArtFallback(),
),
),
const SizedBox(width: RatuneSpacing.md),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
current.title ?? 'Untitled',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: RatuneColors.foreground,
fontWeight: FontWeight.w700,
),
),
Text(
current.artist ?? 'Unknown artist',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.dimmed),
),
],
),
),
_btn(Icons.skip_previous, controller.previous),
_btn(playing ? Icons.pause : Icons.play_arrow,
controller.togglePlayPause,
color: accent),
_btn(Icons.skip_next, controller.next),
],
),
),
),
],
);
}
Widget _btn(IconData icon, VoidCallback onTap,
{Color color = RatuneColors.foreground}) {
return IconButton(
onPressed: onTap,
visualDensity: VisualDensity.compact,
icon: Icon(icon, color: color, size: 24),
);
}
}
/// The hairline progress line on the top edge. Isolated so only this 2px strip
/// rebuilds on position ticks.
class _MiniProgress extends ConsumerWidget {
const _MiniProgress();
@override
Widget build(BuildContext context, WidgetRef ref) {
final progress = ref.watch(playbackProvider.select((s) => s.progress));
final accent = Theme.of(context).colorScheme.primary;
return SizedBox(
height: 2,
child: LinearProgressIndicator(
value: progress,
minHeight: 2,
backgroundColor: RatuneColors.border,
valueColor: AlwaysStoppedAnimation<Color>(accent),
),
);
}
}
class _ArtFallback extends StatelessWidget {
const _ArtFallback();
@override
Widget build(BuildContext context) => const Center(
child: Icon(Icons.album_outlined,
color: RatuneColors.dimmed, size: 22),
);
}