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