Player: now-playing title with cover, start banner, one status pill; media art for everyone
The swapper sends the media's poster and banner once (`art`, Jellyfin images or AniList by parsed name); state carries their hashes and every member fetches them from /i/ like avatars. state.media.swapBy names who put the media up. uosc and the standard bar show cover, title and episode; a banner card shows before playback starts. The top-left health pill and the waiting pill become one frosted status pill in plain words. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d092ea371a
commit
b534c8c7ea
29 files changed
+1892
-236
No files matched your search
@@ -3,15 +3,38 @@ import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:sync_relay_core/core.dart';
|
||||
|
||||
import 'sync_client.dart';
|
||||
|
||||
/// Member pictures by content hash (`state.members[].avatarHash`), fetched
|
||||
/// once from `/a/<sid>/<token>/<hash>` on the host the client dialled and
|
||||
/// kept in memory. A hash names fixed bytes, so entries never go stale;
|
||||
/// listeners are notified when one arrives.
|
||||
class AvatarCache extends ChangeNotifier {
|
||||
/// Member pictures by content hash (`state.members[].avatarHash`), from `/a/`.
|
||||
class AvatarCache extends PictureCache {
|
||||
AvatarCache(super.sync, {super.fetch})
|
||||
: super(route: 'a', keep: 32, live: (s) => [for (final m in s.members) m.avatarHash]);
|
||||
}
|
||||
|
||||
/// The current media's art by content hash (`state.media.art`), from `/i/`.
|
||||
class ArtCache extends PictureCache {
|
||||
ArtCache(super.sync, {super.fetch}) : super(route: 'i', keep: 8, live: artHashesOf);
|
||||
|
||||
/// Poster and banner hashes of [s]'s media.
|
||||
static List<String> artHashesOf(StateBroadcast s) {
|
||||
final art = s.media?['art'];
|
||||
if (art is! Map) return const [];
|
||||
return [for (final v in art.values) if (v is String) v];
|
||||
}
|
||||
}
|
||||
|
||||
/// Pictures by content hash, fetched once from `/<route>/<sid>/<token>/<hash>`
|
||||
/// on the host the client dialled and kept in memory. A hash names fixed
|
||||
/// bytes, so entries never go stale; listeners are notified when one
|
||||
/// arrives. Hashes in each state ([live]) are fetched ahead of use.
|
||||
class PictureCache extends ChangeNotifier {
|
||||
final SyncClient sync;
|
||||
/// First path segment of the picture route: 'a' (avatars), 'i' (art).
|
||||
final String route;
|
||||
/// Above this many pictures, ones not in the latest state go.
|
||||
final int keep;
|
||||
/// Fetches one picture; null on any failure. Injectable for tests.
|
||||
final Future<Uint8List?> Function(Uri url) _fetch;
|
||||
final Map<String, Uint8List> _bytes = {};
|
||||
@@ -21,15 +44,17 @@ class AvatarCache extends ChangeNotifier {
|
||||
bool _disposed = false;
|
||||
|
||||
static const _retryAfter = Duration(seconds: 10);
|
||||
/// Above this many pictures, ones nobody in the latest state uses go.
|
||||
static const _keep = 32;
|
||||
|
||||
AvatarCache(this.sync, {Future<Uint8List?> Function(Uri url)? fetch})
|
||||
PictureCache(this.sync,
|
||||
{required this.route,
|
||||
required this.keep,
|
||||
required Iterable<String> Function(StateBroadcast state) live,
|
||||
Future<Uint8List?> Function(Uri url)? fetch})
|
||||
: _fetch = fetch ?? _httpGet {
|
||||
_sub = sync.states.listen((s) {
|
||||
final live = {for (final m in s.members) if (m.avatarHash.isNotEmpty) m.avatarHash};
|
||||
if (_bytes.length > _keep) _bytes.removeWhere((h, _) => !live.contains(h));
|
||||
live.forEach(_want);
|
||||
final hashes = {for (final h in live(s)) if (h.isNotEmpty) h};
|
||||
if (_bytes.length > keep) _bytes.removeWhere((h, _) => !hashes.contains(h));
|
||||
hashes.forEach(_want);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -53,7 +78,7 @@ class AvatarCache extends ChangeNotifier {
|
||||
scheme: ws.scheme == 'wss' ? 'https' : 'http',
|
||||
host: ws.host,
|
||||
port: ws.port,
|
||||
pathSegments: ['a', 'default', token, hash]);
|
||||
pathSegments: [route, 'default', token, hash]);
|
||||
_inFlight.add(hash);
|
||||
unawaited(_fetch(url).then((bytes) {
|
||||
_inFlight.remove(hash);
|
||||
|
||||
@@ -250,6 +250,11 @@ class SyncClient {
|
||||
_send(ProfileIntent(name: name, avatar: avatar).toJson());
|
||||
}
|
||||
|
||||
/// Art for media [mediaId] (base64 picture bytes; null = none).
|
||||
void sendArt(int mediaId, {String? poster, String? banner}) {
|
||||
_send(ArtMessage(mediaId: mediaId, poster: poster, banner: banner).toJson());
|
||||
}
|
||||
|
||||
void sendKick(String target, {String reason = ''}) {
|
||||
_send(KickRequest(target: target, reason: reason).toJson());
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user