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:
foxyzandClaude Opus 5.5 committed 2026-10-09 01:19:34 +03:00
1 parent d092ea371a
commit b534c8c7ea
29 files changed
+1892 -236

No files matched your search

@@ -0,0 +1,95 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:sync_relay_core/core.dart';
import '../files/name_parser.dart';
import '../schedule/anilist.dart';
// Art for media this client swapped in (`art`, see PROTOCOL.md "Media
// art"): Jellyfin images with this install's account, else an AniList
// title search. Everything here fails quietly to "no art".
/// Poster and banner bytes; either may be null.
typedef ArtBytes = ({Uint8List? poster, Uint8List? banner});
/// Series poster (the item's own for movies) and series/item backdrop,
/// else its Banner image, scaled by Jellyfin to fit the caps.
Future<ArtBytes> jellyfinArt(JellyfinClient client, String itemId,
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
final item = await client.item(itemId).timeout(const Duration(seconds: 10));
final seriesPoster = item.seriesId != null && item.seriesPrimaryImageTag != null
? JellyfinImageRef(item.seriesId!, JellyfinImageType.primary, item.seriesPrimaryImageTag!)
: null;
final poster = seriesPoster ?? item.image(JellyfinImageType.primary);
var banner = item.image(JellyfinImageType.backdrop);
if (banner == null) {
var owner = item;
if (item.seriesId != null && item.ownImage(JellyfinImageType.banner) == null) {
try {
owner = await client.item(item.seriesId!).timeout(const Duration(seconds: 10));
} catch (_) {}
}
banner = owner.ownImage(JellyfinImageType.banner);
}
final headers = client.authHeaders;
final (p, b) = await (
poster == null
? Future<Uint8List?>.value()
: fetch(client.imageUrl(poster, maxHeight: 450, quality: 85), headers, RelayConstants.maxPosterBytes),
banner == null
? Future<Uint8List?>.value()
: fetch(client.imageUrl(banner, maxWidth: 1280, quality: 85), headers, RelayConstants.maxBannerBytes),
).wait;
return (poster: p, banner: b);
}
/// The series of release name [name] on AniList: cover and banner; null
/// when the name says nothing or AniList knows no match.
Future<ArtBytes?> aniListArt(AniListClient aniList, String name,
{Future<Uint8List?> Function(Uri url, Map<String, String> headers, int maxBytes) fetch = fetchImage}) async {
final series = parseReleaseName(name).series.trim();
if (series.isEmpty) return null;
final match = await aniList.searchMedia(series);
if (match == null) return null;
Future<Uint8List?> get(String? url, int maxBytes) async {
final uri = url == null ? null : Uri.tryParse(url);
return uri == null ? null : fetch(uri, const {}, maxBytes);
}
final (cover, banner) = await (
get(match.cover, RelayConstants.maxPosterBytes),
get(match.banner, RelayConstants.maxBannerBytes),
).wait;
final poster = cover ?? (match.coverSmall == match.cover ? null : await get(match.coverSmall, RelayConstants.maxPosterBytes));
return (poster: poster, banner: banner);
}
/// GET [url]: the bytes when 200, at most [maxBytes] and a known image
/// type (see `avatarImageType`); else null.
Future<Uint8List?> fetchImage(Uri url, Map<String, String> headers, int maxBytes) async {
final client = HttpClient()..connectionTimeout = const Duration(seconds: 10);
try {
final req = await client.getUrl(url);
headers.forEach(req.headers.set);
final resp = await req.close().timeout(const Duration(seconds: 15));
if (resp.statusCode != 200 || resp.contentLength > maxBytes) {
await resp.drain<void>().catchError((_) {});
return null;
}
final builder = BytesBuilder(copy: false);
await for (final chunk in resp.timeout(const Duration(seconds: 15))) {
builder.add(chunk);
if (builder.length > maxBytes) return null;
}
final bytes = builder.takeBytes();
return avatarImageType(bytes) == null ? null : bytes;
} catch (e) {
debugPrint('art: fetch failed: $e');
return null;
} finally {
client.close(force: true);
}
}