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
@@ -56,9 +56,12 @@ corner; double-click or ⤢ makes it big again, × stops watching (you stay in
|
|||||||
the server, muted) and the strip's screen button brings it back. F or
|
the server, muted) and the strip's screen button brings it back. F or
|
||||||
double-click on the picture goes fullscreen (the strip hides, the party
|
double-click on the picture goes fullscreen (the strip hides, the party
|
||||||
shows in a corner while paused, the window buttons sit in the player's top
|
shows in a corner while paused, the window buttons sit in the player's top
|
||||||
bar while its controls are up), Esc leaves it. While playback waits, a
|
bar while its controls are up), Esc leaves it. The controls show the
|
||||||
pill under the picture says for whom ("Waiting for you · press Space when
|
show's cover, title and episode top left (guests get the cover too); before
|
||||||
ready"). Chat and activity open in a panel on the right (chat button or
|
a video first plays, its banner fills the picture until playback starts.
|
||||||
|
One pill above the controls says what playback waits for ("Press Space
|
||||||
|
when you're ready", "Waiting for Bob and Mika · 2/3 ready", "Bob is
|
||||||
|
loading…") or what went wrong, with Retry or Cancel when it helps. Chat and activity open in a panel on the right (chat button or
|
||||||
Tab); joins, leaves, chat and other news also pop up bottom left with the
|
Tab); joins, leaves, chat and other news also pop up bottom left with the
|
||||||
person's avatar.
|
person's avatar.
|
||||||
|
|
||||||
@@ -181,7 +184,7 @@ chapter title and the time there. With the controls hidden it stays as a
|
|||||||
thin line (unless "Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the
|
thin line (unless "Progress line when hidden" is off). Above it flat buttons: ≡ menu, Subtitles and Audio (with the
|
||||||
track count), the shader badge (the active Anime4K mode, e.g. "A HQ"),
|
track count), the shader badge (the active Anime4K mode, e.g. "A HQ"),
|
||||||
ready, chapters, 1:30, previous / next episode, change video, volume, ⚙
|
ready, chapters, 1:30, previous / next episode, change video, volume, ⚙
|
||||||
and fullscreen; chat and back-to-library top right. Keys and skip buttons work
|
and fullscreen; the title top left, chat and back-to-library top right. Keys and skip buttons work
|
||||||
the same.
|
the same.
|
||||||
|
|
||||||
Skipping: during the opening a "Skip OP" button floats in the corner (it
|
Skipping: during the opening a "Skip OP" button floats in the corner (it
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import 'package:signals/signals_flutter.dart';
|
|||||||
import '../../state/settings.dart';
|
import '../../state/settings.dart';
|
||||||
|
|
||||||
// The mpv-style (uosc) controls frame: a full-width timeline at the
|
// The mpv-style (uosc) controls frame: a full-width timeline at the
|
||||||
// bottom with a row of flat buttons above it, a corner widget top right.
|
// bottom with a row of flat buttons above it, the title top left and a
|
||||||
|
// corner widget top right.
|
||||||
// Mouse movement shows them; after [UoscControls.hideAfter] without any
|
// Mouse movement shows them; after [UoscControls.hideAfter] without any
|
||||||
// they fade out, the timeline shrinks to a 2 px line (gone when "Progress
|
// they fade out, the timeline shrinks to a 2 px line (gone when "Progress
|
||||||
// line when hidden" is off) and the cursor hides. Our own frame: no
|
// line when hidden" is off) and the cursor hides. Our own frame: no
|
||||||
@@ -20,6 +21,9 @@ class UoscControls extends StatefulWidget {
|
|||||||
final List<Widget> right;
|
final List<Widget> right;
|
||||||
/// Top-right corner (chat, back to the library), shown with the controls.
|
/// Top-right corner (chat, back to the library), shown with the controls.
|
||||||
final Widget? top;
|
final Widget? top;
|
||||||
|
/// Top left (what is playing), shown with the controls; gets the width
|
||||||
|
/// [top] leaves.
|
||||||
|
final Widget? topLeft;
|
||||||
final VoidCallback? onDoubleTap;
|
final VoidCallback? onDoubleTap;
|
||||||
final Duration hideAfter;
|
final Duration hideAfter;
|
||||||
const UoscControls({
|
const UoscControls({
|
||||||
@@ -28,6 +32,7 @@ class UoscControls extends StatefulWidget {
|
|||||||
this.left = const [],
|
this.left = const [],
|
||||||
this.right = const [],
|
this.right = const [],
|
||||||
this.top,
|
this.top,
|
||||||
|
this.topLeft,
|
||||||
this.onDoubleTap,
|
this.onDoubleTap,
|
||||||
this.hideAfter = const Duration(seconds: 3),
|
this.hideAfter = const Duration(seconds: 3),
|
||||||
});
|
});
|
||||||
@@ -136,13 +141,54 @@ class UoscControlsState extends State<UoscControls> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.top != null)
|
if (widget.topLeft != null)
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
height: 120,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: fade,
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
child: const DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0x8C000000), Color(0x00000000)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (widget.top != null || widget.topLeft != null)
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 0,
|
top: 0,
|
||||||
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
|
key: const ValueKey('uosc-top'),
|
||||||
ignoring: !visible,
|
ignoring: !visible,
|
||||||
child: AnimatedOpacity(duration: fade, opacity: visible ? 1 : 0, child: widget.top),
|
child: AnimatedOpacity(
|
||||||
|
duration: fade,
|
||||||
|
opacity: visible ? 1 : 0,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
child: widget.topLeft == null
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: Align(alignment: AlignmentDirectional.centerStart, child: widget.topLeft),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
?widget.top,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import 'package:sync_relay_core/core.dart';
|
|||||||
import '../update/updater.dart' show appVersion;
|
import '../update/updater.dart' show appVersion;
|
||||||
|
|
||||||
// AniList airing schedule (public GraphQL, no login): every episode airing
|
// AniList airing schedule (public GraphQL, no login): every episode airing
|
||||||
// in a time range, with covers, banners and popularity.
|
// in a time range, with covers, banners and popularity; and a title search
|
||||||
|
// (cover + banner of a show, for the media art of files and links).
|
||||||
//
|
//
|
||||||
// AniList allows ~90 requests/min (sometimes degraded to 30), so the raw
|
// AniList allows ~90 requests/min (sometimes degraded to 30), so the raw
|
||||||
// pages are cached: in memory for 10 min (tab switches) and on disk for an
|
// pages are cached: in memory for 10 min (tab switches) and on disk for an
|
||||||
@@ -76,6 +77,35 @@ class Airing {
|
|||||||
String toString() => 'Airing($anilistId "$title" ep $episode @ $airingAt)';
|
String toString() => 'Airing($anilistId "$title" ep $episode @ $airingAt)';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An anime found by title ([AniListClient.searchMedia]).
|
||||||
|
class AniListMedia {
|
||||||
|
final int id;
|
||||||
|
/// English title, else romaji.
|
||||||
|
final String title;
|
||||||
|
/// Cover (portrait) URL: extraLarge, else large.
|
||||||
|
final String? cover;
|
||||||
|
/// The smaller cover, for when [cover] is too big to send on.
|
||||||
|
final String? coverSmall;
|
||||||
|
final String? banner;
|
||||||
|
const AniListMedia({required this.id, required this.title, this.cover, this.coverSmall, this.banner});
|
||||||
|
|
||||||
|
static AniListMedia? fromJson(Object? m) {
|
||||||
|
if (m is! Map || m['id'] is! int) return null;
|
||||||
|
final t = m['title'] is Map ? m['title'] as Map : const {};
|
||||||
|
final romaji = t['romaji'] is String ? t['romaji'] as String : '';
|
||||||
|
final english = t['english'] is String ? t['english'] as String : '';
|
||||||
|
final cov = m['coverImage'] is Map ? m['coverImage'] as Map : const {};
|
||||||
|
final large = cov['large'] is String ? cov['large'] as String : null;
|
||||||
|
return AniListMedia(
|
||||||
|
id: m['id'] as int,
|
||||||
|
title: english.isNotEmpty ? english : romaji,
|
||||||
|
cover: cov['extraLarge'] is String ? cov['extraLarge'] as String : large,
|
||||||
|
coverSmall: large,
|
||||||
|
banner: m['bannerImage'] is String ? m['bannerImage'] as String : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// "#e4a15d" -> opaque [Color]; anything else -> null.
|
/// "#e4a15d" -> opaque [Color]; anything else -> null.
|
||||||
Color? parseHexColor(Object? s) {
|
Color? parseHexColor(Object? s) {
|
||||||
if (s is! String || s.length != 7 || !s.startsWith('#')) return null;
|
if (s is! String || s.length != 7 || !s.startsWith('#')) return null;
|
||||||
@@ -217,11 +247,19 @@ media{id title{romaji english} coverImage{large color} bannerImage isAdult popul
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Map> _page(int from, int to, int page) async {
|
Future<Map> _page(int from, int to, int page) async {
|
||||||
|
final (data, status, j) = await _graphql(query, {'from': from - 1, 'to': to, 'page': page});
|
||||||
|
final p = data?['Page'];
|
||||||
|
if (p is! Map) {
|
||||||
|
throw AniListException('AniList: bad response${_errorText(j)}', statusCode: status);
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One GraphQL request: `data` (null when missing), status and body. A
|
||||||
|
/// 429 waits out Retry-After once; other non-200s throw.
|
||||||
|
Future<(Map?, int, Object?)> _graphql(String query, Map<String, Object?> variables) async {
|
||||||
for (var attempt = 0;; attempt++) {
|
for (var attempt = 0;; attempt++) {
|
||||||
final (status, body, retryAfter) = await _post(jsonEncode({
|
final (status, body, retryAfter) = await _post(jsonEncode({'query': query, 'variables': variables}));
|
||||||
'query': query,
|
|
||||||
'variables': {'from': from - 1, 'to': to, 'page': page},
|
|
||||||
}));
|
|
||||||
if (status == 429 && attempt == 0) {
|
if (status == 429 && attempt == 0) {
|
||||||
final wait = retryAfter ?? 60;
|
final wait = retryAfter ?? 60;
|
||||||
_log.info('AniList rate limited, retrying in ${wait}s');
|
_log.info('AniList rate limited, retrying in ${wait}s');
|
||||||
@@ -235,11 +273,37 @@ media{id title{romaji english} coverImage{large color} bannerImage isAdult popul
|
|||||||
if (status != 200) {
|
if (status != 200) {
|
||||||
throw AniListException('AniList HTTP $status${_errorText(j)}', statusCode: status);
|
throw AniListException('AniList HTTP $status${_errorText(j)}', statusCode: status);
|
||||||
}
|
}
|
||||||
final data = j is Map && j['data'] is Map ? (j['data'] as Map)['Page'] : null;
|
final data = j is Map && j['data'] is Map ? j['data'] as Map : null;
|
||||||
if (data is! Map) {
|
return (data, status, j);
|
||||||
throw AniListException('AniList: bad response${_errorText(j)}', statusCode: status);
|
}
|
||||||
}
|
}
|
||||||
return data;
|
|
||||||
|
static const searchQuery = r'''
|
||||||
|
query($search:String){Media(search:$search,type:ANIME,isAdult:false){id title{romaji english}
|
||||||
|
coverImage{extraLarge large} bannerImage}}''';
|
||||||
|
|
||||||
|
final _search = <String, Future<AniListMedia?>>{};
|
||||||
|
|
||||||
|
/// AniList's best anime match for [title], or null when there is none.
|
||||||
|
/// One request per title for this client's life (a failure is not kept,
|
||||||
|
/// so a later call asks again).
|
||||||
|
Future<AniListMedia?> searchMedia(String title) {
|
||||||
|
final key = title.trim();
|
||||||
|
if (key.isEmpty) return Future.value(null);
|
||||||
|
return _search[key] ??= _searchMedia(key).catchError((Object e) {
|
||||||
|
_search.remove(key);
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AniListMedia?> _searchMedia(String title) async {
|
||||||
|
try {
|
||||||
|
final (data, _, _) = await _graphql(searchQuery, {'search': title});
|
||||||
|
return AniListMedia.fromJson(data?['Media']);
|
||||||
|
} on AniListException catch (e) {
|
||||||
|
// No match is a 404 with "Not Found." in errors.
|
||||||
|
if (e.statusCode == 404) return null;
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/// What the player shows about the current media: a title, an episode line
|
||||||
|
/// and art. Art is by content hash (`state.media.art`), fetched by everyone
|
||||||
|
/// (guests included) from the server they dialled; '' when there is none.
|
||||||
|
class NowPlaying {
|
||||||
|
/// Series name for episodes, else the movie, file or link name.
|
||||||
|
final String title;
|
||||||
|
/// "S2 E4 · Episode title", "E12", or null (movies, links).
|
||||||
|
final String? episode;
|
||||||
|
/// Portrait cover (2:3) and wide banner; '' when none.
|
||||||
|
final String posterHash;
|
||||||
|
final String bannerHash;
|
||||||
|
const NowPlaying({required this.title, this.episode, this.posterHash = '', this.bannerHash = ''});
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
other is NowPlaying &&
|
||||||
|
other.title == title &&
|
||||||
|
other.episode == episode &&
|
||||||
|
other.posterHash == posterHash &&
|
||||||
|
other.bannerHash == bannerHash;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(title, episode, posterHash, bannerHash);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import '../files/name_parser.dart';
|
||||||
|
import '../jellyfin/session_media.dart';
|
||||||
|
import 'now_playing.dart';
|
||||||
|
|
||||||
|
// Title and episode line of `state.media` (pure; see [NowPlaying]).
|
||||||
|
|
||||||
|
// "S1E6" (pickFor) or "S01E02".
|
||||||
|
final _episodeLabel = RegExp(r'^S(\d{1,3})\s?E(\d{1,4})$', caseSensitive: false);
|
||||||
|
final _year = RegExp(r'^(?:19|20)\d{2}$');
|
||||||
|
final _ext = RegExp(r'\.(?=[a-z0-9]*[a-z])[a-z0-9]{2,4}$', caseSensitive: false);
|
||||||
|
|
||||||
|
/// What the player shows for [media] (`state.media`); null with nothing up.
|
||||||
|
NowPlaying? nowPlayingOf(Map<String, Object?>? media) {
|
||||||
|
if (media == null) return null;
|
||||||
|
final name = media['name'] is String ? media['name'] as String : '';
|
||||||
|
final art = media['art'] is Map ? media['art'] as Map : const {};
|
||||||
|
final poster = art['poster'] is String ? art['poster'] as String : '';
|
||||||
|
final banner = art['banner'] is String ? art['banner'] as String : '';
|
||||||
|
final ref = refOfStateMedia(media);
|
||||||
|
final (title, episode) = ref != null
|
||||||
|
? jellyfinTitle(ref.title.isNotEmpty ? ref.title : name, ref.subtitle)
|
||||||
|
: fileTitle(name);
|
||||||
|
return NowPlaying(title: title, episode: episode, posterHash: poster, bannerHash: banner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Jellyfin: [name] is the item's, [subtitle] "Series · S1E6" (or
|
||||||
|
/// "Series - S01E02") for episodes, the year for movies. Episodes give the
|
||||||
|
/// series and "S1 E6 · Name"; anything else the name alone.
|
||||||
|
(String, String?) jellyfinTitle(String name, String? subtitle) {
|
||||||
|
final sub = subtitle?.trim() ?? '';
|
||||||
|
if (sub.isEmpty || _year.hasMatch(sub)) return (name, null);
|
||||||
|
// The label is after the last separator (or the whole subtitle).
|
||||||
|
final cut = RegExp(r' [·-] ').allMatches(sub).lastOrNull;
|
||||||
|
final m = _episodeLabel.firstMatch(cut == null ? sub : sub.substring(cut.end).trim());
|
||||||
|
final series = m == null ? sub : (cut == null ? '' : sub.substring(0, cut.start).trim());
|
||||||
|
final label = m == null ? null : 'S${int.parse(m.group(1)!)} E${int.parse(m.group(2)!)}';
|
||||||
|
if (series.isEmpty) return (name, label);
|
||||||
|
final episode = [?label, if (name.isNotEmpty) name].join(' · ');
|
||||||
|
return (series, episode.isEmpty ? null : episode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Files and links: the release name's series and "S1 E3", "E25" or its
|
||||||
|
/// extra marker (NCOP1, OVA 2); the name minus extension when it says
|
||||||
|
/// nothing better.
|
||||||
|
(String, String?) fileTitle(String name) {
|
||||||
|
final p = parseReleaseName(name);
|
||||||
|
final series = p.series.trim();
|
||||||
|
if (series.isEmpty) return (name.replaceFirst(_ext, ''), null);
|
||||||
|
final String? episode;
|
||||||
|
if (p.episode != null) {
|
||||||
|
episode = p.season != null ? 'S${p.season} E${p.episode}' : 'E${p.episode}';
|
||||||
|
} else {
|
||||||
|
episode = p.extra;
|
||||||
|
}
|
||||||
|
return (series, episode);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
@@ -11,6 +12,7 @@ import '../jellyfin/session_media.dart';
|
|||||||
import '../jellyfin/track_prefs.dart';
|
import '../jellyfin/track_prefs.dart';
|
||||||
import '../playback/media_marks.dart';
|
import '../playback/media_marks.dart';
|
||||||
import '../playback/playback_controller.dart';
|
import '../playback/playback_controller.dart';
|
||||||
|
import '../schedule/anilist.dart';
|
||||||
import '../screens/home/picked_media.dart';
|
import '../screens/home/picked_media.dart';
|
||||||
import '../sfx/sfx.dart';
|
import '../sfx/sfx.dart';
|
||||||
import '../state/settings.dart';
|
import '../state/settings.dart';
|
||||||
@@ -20,6 +22,9 @@ import '../sync/sync_client.dart';
|
|||||||
import '../sync/user_seek.dart';
|
import '../sync/user_seek.dart';
|
||||||
import 'health.dart';
|
import 'health.dart';
|
||||||
import 'idle.dart';
|
import 'idle.dart';
|
||||||
|
import 'media_art.dart';
|
||||||
|
import 'now_playing.dart';
|
||||||
|
import 'now_playing_info.dart';
|
||||||
|
|
||||||
/// Thrown when a connect is superseded by cancel/leave before it finished.
|
/// Thrown when a connect is superseded by cancel/leave before it finished.
|
||||||
class ConnectCancelled implements Exception {
|
class ConnectCancelled implements Exception {
|
||||||
@@ -146,6 +151,8 @@ class SessionCoordinator {
|
|||||||
final SyncClient sync = SyncClient();
|
final SyncClient sync = SyncClient();
|
||||||
/// Member pictures by `avatarHash`, fetched from the server.
|
/// Member pictures by `avatarHash`, fetched from the server.
|
||||||
late final AvatarCache avatars = AvatarCache(sync);
|
late final AvatarCache avatars = AvatarCache(sync);
|
||||||
|
/// The current media's poster and banner by `state.media.art` hash.
|
||||||
|
late final ArtCache art = ArtCache(sync);
|
||||||
final PlaybackController playback = PlaybackController();
|
final PlaybackController playback = PlaybackController();
|
||||||
final StateDiff diff = StateDiff();
|
final StateDiff diff = StateDiff();
|
||||||
|
|
||||||
@@ -160,6 +167,9 @@ class SessionCoordinator {
|
|||||||
|
|
||||||
/// The current media's Jellyfin ref (null for links and files).
|
/// The current media's Jellyfin ref (null for links and files).
|
||||||
final currentRef = signal<JellyfinRef?>(null);
|
final currentRef = signal<JellyfinRef?>(null);
|
||||||
|
/// Title, episode line and art hashes of the current media; null with
|
||||||
|
/// nothing up.
|
||||||
|
late final nowPlaying = computed<NowPlaying?>(() => nowPlayingOf(serverState.value?.media));
|
||||||
/// How this viewer plays [currentRef]; null while resolving.
|
/// How this viewer plays [currentRef]; null while resolving.
|
||||||
final jellyfinPlan = signal<JellyfinPlan?>(null);
|
final jellyfinPlan = signal<JellyfinPlan?>(null);
|
||||||
/// Set when this viewer cannot play the Jellyfin media: no account and
|
/// Set when this viewer cannot play the Jellyfin media: no account and
|
||||||
@@ -221,6 +231,10 @@ class SessionCoordinator {
|
|||||||
String _wsHost = '';
|
String _wsHost = '';
|
||||||
int _wsPort = 0;
|
int _wsPort = 0;
|
||||||
_RelayOrigin? _pendingOrigin;
|
_RelayOrigin? _pendingOrigin;
|
||||||
|
// This client's last swap, until a state shows it landed (a newer
|
||||||
|
// mediaId than [after] with this member as `swapBy`): its art goes up then.
|
||||||
|
({HelloMedia media, int after})? _pendingArt;
|
||||||
|
AniListClient? _aniList;
|
||||||
// What this member serves for media [$1]; survives a reconnect (the
|
// What this member serves for media [$1]; survives a reconnect (the
|
||||||
// state then looks new again).
|
// state then looks new again).
|
||||||
(int, _RelayOrigin)? _activeOrigin;
|
(int, _RelayOrigin)? _activeOrigin;
|
||||||
@@ -386,7 +400,7 @@ class SessionCoordinator {
|
|||||||
final media = params.media ??
|
final media = params.media ??
|
||||||
(params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl));
|
(params.linkUrl.isEmpty ? null : await mediaForLink(jellyfin, params.linkUrl));
|
||||||
if (epoch != _epoch) throw const ConnectCancelled();
|
if (epoch != _epoch) throw const ConnectCancelled();
|
||||||
if (media != null) sync.sendSwap(media);
|
if (media != null) _swap(media);
|
||||||
_startTicker();
|
_startTicker();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (epoch != _epoch) throw const ConnectCancelled();
|
if (epoch != _epoch) throw const ConnectCancelled();
|
||||||
@@ -522,6 +536,7 @@ class SessionCoordinator {
|
|||||||
void _onState(StateBroadcast r) {
|
void _onState(StateBroadcast r) {
|
||||||
_lastStateAt = DateTime.now().toUtc();
|
_lastStateAt = DateTime.now().toUtc();
|
||||||
serverState.value = r;
|
serverState.value = r;
|
||||||
|
_maybeSendArt(r);
|
||||||
_queuedState = r;
|
_queuedState = r;
|
||||||
if (_applying) return;
|
if (_applying) return;
|
||||||
_applying = true;
|
_applying = true;
|
||||||
@@ -984,6 +999,43 @@ class SessionCoordinator {
|
|||||||
sync.sendKick(memberId, reason: reason);
|
sync.sendKick(memberId, reason: reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _swap(HelloMedia media) {
|
||||||
|
_pendingArt = (media: media, after: serverState.peek()?.mediaId ?? 0);
|
||||||
|
sync.sendSwap(media);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _maybeSendArt(StateBroadcast r) {
|
||||||
|
final pending = _pendingArt;
|
||||||
|
if (pending == null || r.mediaId <= pending.after) return;
|
||||||
|
if (sync.memberId.isEmpty || r.media?['swapBy'] != sync.memberId) return;
|
||||||
|
_pendingArt = null;
|
||||||
|
unawaited(_sendArt(r.mediaId, pending.media));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches the art of [media] (this client's swap, now [mediaId]) and
|
||||||
|
/// sends it; quietly nothing on any failure.
|
||||||
|
Future<void> _sendArt(int mediaId, HelloMedia media) async {
|
||||||
|
try {
|
||||||
|
final ref = media.jellyfin;
|
||||||
|
final client = ref == null ? null : jellyfin.clientFor(ref.serverId);
|
||||||
|
final ArtBytes? bytes;
|
||||||
|
if (ref != null && client != null) {
|
||||||
|
bytes = await jellyfinArt(client, ref.itemId);
|
||||||
|
} else {
|
||||||
|
final name = ref == null ? media.name : jellyfinTitle(ref.title, ref.subtitle).$1;
|
||||||
|
bytes = await aniListArt(_aniList ??= AniListClient(), name);
|
||||||
|
}
|
||||||
|
final poster = bytes?.poster, banner = bytes?.banner;
|
||||||
|
if (poster == null && banner == null) return;
|
||||||
|
if (serverState.peek()?.mediaId != mediaId) return;
|
||||||
|
sync.sendArt(mediaId,
|
||||||
|
poster: poster == null ? null : base64Encode(poster),
|
||||||
|
banner: banner == null ? null : base64Encode(banner));
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('art for media $mediaId: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A pasted link: a recognised Jellyfin item link goes up as a ref
|
/// A pasted link: a recognised Jellyfin item link goes up as a ref
|
||||||
/// (see [mediaForLink]) that this member relays for viewers without an
|
/// (see [mediaForLink]) that this member relays for viewers without an
|
||||||
/// account (with the link's key, which stays here); anything else as the
|
/// account (with the link's key, which stays here); anything else as the
|
||||||
@@ -993,11 +1045,11 @@ class SessionCoordinator {
|
|||||||
final media = await mediaForLink(jellyfin, url);
|
final media = await mediaForLink(jellyfin, url);
|
||||||
if (media.jellyfin != null) {
|
if (media.jellyfin != null) {
|
||||||
_pendingOrigin = _JellyfinOrigin(url);
|
_pendingOrigin = _JellyfinOrigin(url);
|
||||||
sync.sendSwap(media.copyWith(relay: const MediaRelay()));
|
_swap(media.copyWith(relay: const MediaRelay()));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (relay == LinkRelay.direct) {
|
if (relay == LinkRelay.direct) {
|
||||||
sync.sendSwap(media);
|
_swap(media);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Relayed: it must be a single file the relay can range-read (not a
|
// Relayed: it must be a single file the relay can range-read (not a
|
||||||
@@ -1015,9 +1067,9 @@ class SessionCoordinator {
|
|||||||
}
|
}
|
||||||
if (relay == LinkRelay.me) {
|
if (relay == LinkRelay.me) {
|
||||||
_pendingOrigin = _LinkOrigin(url);
|
_pendingOrigin = _LinkOrigin(url);
|
||||||
sync.sendSwap(HelloMedia(name: media.name, size: size, duration: 0, relay: const MediaRelay()));
|
_swap(HelloMedia(name: media.name, size: size, duration: 0, relay: const MediaRelay()));
|
||||||
} else {
|
} else {
|
||||||
sync.sendSwap(HelloMedia(
|
_swap(HelloMedia(
|
||||||
name: media.name, size: size, duration: 0, url: url, relay: const MediaRelay(fetch: true)));
|
name: media.name, size: size, duration: 0, url: url, relay: const MediaRelay(fetch: true)));
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -1028,7 +1080,7 @@ class SessionCoordinator {
|
|||||||
void swapJellyfin(JellyfinMedia media) {
|
void swapJellyfin(JellyfinMedia media) {
|
||||||
markInput();
|
markInput();
|
||||||
_pendingOrigin = const _JellyfinOrigin(null);
|
_pendingOrigin = const _JellyfinOrigin(null);
|
||||||
sync.sendSwap(mediaForPick(media).copyWith(relay: const MediaRelay()));
|
_swap(mediaForPick(media).copyWith(relay: const MediaRelay()));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A file on this computer. The host's app serves it itself; anyone
|
/// A file on this computer. The host's app serves it itself; anyone
|
||||||
@@ -1040,13 +1092,13 @@ class SessionCoordinator {
|
|||||||
if (host != null) {
|
if (host != null) {
|
||||||
final file = await host(path);
|
final file = await host(path);
|
||||||
if (file == null) return 'file not found — check the path';
|
if (file == null) return 'file not found — check the path';
|
||||||
sync.sendSwap(HelloMedia(name: file.name, size: file.size, duration: 0));
|
_swap(HelloMedia(name: file.name, size: file.size, duration: 0));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final file = File(path);
|
final file = File(path);
|
||||||
if (!await file.exists()) return 'file not found — check the path';
|
if (!await file.exists()) return 'file not found — check the path';
|
||||||
_pendingOrigin = _FileOrigin(path);
|
_pendingOrigin = _FileOrigin(path);
|
||||||
sync.sendSwap(HelloMedia(
|
_swap(HelloMedia(
|
||||||
name: file.uri.pathSegments.last, size: await file.length(), duration: 0, relay: const MediaRelay()));
|
name: file.uri.pathSegments.last, size: await file.length(), duration: 0, relay: const MediaRelay()));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1541,6 +1593,7 @@ class SessionCoordinator {
|
|||||||
await _stopJellyfin();
|
await _stopJellyfin();
|
||||||
_stopRelaySource();
|
_stopRelaySource();
|
||||||
_pendingOrigin = null;
|
_pendingOrigin = null;
|
||||||
|
_pendingArt = null;
|
||||||
_activeOrigin = null;
|
_activeOrigin = null;
|
||||||
_playerReady = false;
|
_playerReady = false;
|
||||||
try {
|
try {
|
||||||
@@ -1567,6 +1620,7 @@ class SessionCoordinator {
|
|||||||
await _kickSub?.cancel();
|
await _kickSub?.cancel();
|
||||||
await _errSub?.cancel();
|
await _errSub?.cancel();
|
||||||
await _statusSub?.cancel();
|
await _statusSub?.cancel();
|
||||||
|
_aniList?.close();
|
||||||
await sync.dispose();
|
await sync.dispose();
|
||||||
await playback.dispose();
|
await playback.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,11 +15,16 @@ const fadeOutPart = 0.35;
|
|||||||
/// Fade through (Material motion): the old view is gone after the first
|
/// Fade through (Material motion): the old view is gone after the first
|
||||||
/// 35%, then the new one fades and scales in; they never overlap. Key
|
/// 35%, then the new one fades and scales in; they never overlap. Key
|
||||||
/// [child] by what it shows so a change of key runs the transition.
|
/// [child] by what it shows so a change of key runs the transition.
|
||||||
Widget fadeThrough(Widget child, {Key? key}) => AnimatedSwitcher(
|
/// [expand] false: sized by the views (at [alignment]) instead of filling
|
||||||
|
/// the box, for unbounded places (a row, a pill).
|
||||||
|
Widget fadeThrough(Widget child,
|
||||||
|
{Key? key, bool expand = true, AlignmentGeometry alignment = AlignmentDirectional.topStart}) =>
|
||||||
|
AnimatedSwitcher(
|
||||||
key: key,
|
key: key,
|
||||||
duration: fadeThroughTime,
|
duration: fadeThroughTime,
|
||||||
layoutBuilder: (current, previous) => Stack(
|
layoutBuilder: (current, previous) => Stack(
|
||||||
fit: StackFit.expand,
|
fit: expand ? StackFit.expand : StackFit.loose,
|
||||||
|
alignment: alignment,
|
||||||
children: [...previous, ?current],
|
children: [...previous, ?current],
|
||||||
),
|
),
|
||||||
transitionBuilder: (c, anim) {
|
transitionBuilder: (c, anim) {
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../session/now_playing.dart';
|
||||||
|
import '../state/session_state.dart';
|
||||||
|
import 'motion.dart';
|
||||||
|
|
||||||
|
/// Tests: art bytes by hash instead of the session's cache.
|
||||||
|
@visibleForTesting
|
||||||
|
Uint8List? Function(String hash)? debugArtBytes;
|
||||||
|
|
||||||
|
/// The one lookup for media art (cover, banner) by content hash: null
|
||||||
|
/// when there is none or it has not arrived yet (listeners of
|
||||||
|
/// [artChanges] hear when it lands).
|
||||||
|
Uint8List? artBytes(String hash) {
|
||||||
|
if (hash.isEmpty) return null;
|
||||||
|
final debug = debugArtBytes;
|
||||||
|
if (debug != null) return debug(hash);
|
||||||
|
return SessionState.instance.coordinator.art.bytesFor(hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Notifies when art arrives.
|
||||||
|
Listenable artChanges() => SessionState.instance.coordinator.art;
|
||||||
|
|
||||||
|
final _images = <String, MemoryImage>{};
|
||||||
|
|
||||||
|
/// [hash]'s art as an image, the same provider while the bytes are the
|
||||||
|
/// same (so a rebuild never decodes again).
|
||||||
|
ImageProvider? artImage(String hash) {
|
||||||
|
final bytes = artBytes(hash);
|
||||||
|
if (bytes == null) return null;
|
||||||
|
final hit = _images[hash];
|
||||||
|
if (hit != null && identical(hit.bytes, bytes)) return hit;
|
||||||
|
if (_images.length > 16) _images.clear();
|
||||||
|
return _images[hash] = MemoryImage(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [image] fading in (ease) once decoded; nothing before.
|
||||||
|
Widget fadeInImage(ImageProvider image, {BoxFit fit = BoxFit.cover}) => Image(
|
||||||
|
image: image,
|
||||||
|
fit: fit,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
frameBuilder: (context, child, frame, sync) => sync
|
||||||
|
? child
|
||||||
|
: AnimatedOpacity(opacity: frame == null ? 0 : 1, duration: easeTime, curve: ease, child: child),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The portrait cover (2:3) of the current media, a quiet tile with a
|
||||||
|
/// film icon until (or unless) its art arrives.
|
||||||
|
class ArtCover extends StatelessWidget {
|
||||||
|
final String hash;
|
||||||
|
final double width;
|
||||||
|
const ArtCover({super.key, required this.hash, this.width = 40});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final image = artImage(hash);
|
||||||
|
final r = BorderRadius.circular(width >= 80 ? 10 : 6);
|
||||||
|
return Container(
|
||||||
|
width: width,
|
||||||
|
height: width * 1.5,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: r,
|
||||||
|
boxShadow: const [BoxShadow(color: Color(0x66000000), blurRadius: 10, offset: Offset(0, 3))],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: r,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
DecoratedBox(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF2A2D35), Color(0xFF17191E)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Icon(Icons.movie_outlined, color: Colors.white38, size: width * 0.45),
|
||||||
|
),
|
||||||
|
if (image != null) fadeInImage(image),
|
||||||
|
DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: r,
|
||||||
|
border: Border.all(color: Colors.white12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const _shadow = [Shadow(color: Color(0x99000000), blurRadius: 8)];
|
||||||
|
|
||||||
|
/// Title and episode line of [now] (with its cover unless [cover] is
|
||||||
|
/// off), ellipsized; cross-fades when the media changes.
|
||||||
|
class NowPlayingBlock extends StatelessWidget {
|
||||||
|
final NowPlaying? now;
|
||||||
|
final bool cover;
|
||||||
|
/// Smaller type (the mini player).
|
||||||
|
final bool compact;
|
||||||
|
const NowPlayingBlock({super.key, required this.now, this.cover = true, this.compact = false});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final n = now;
|
||||||
|
final Widget child;
|
||||||
|
if (n == null) {
|
||||||
|
child = const SizedBox.shrink(key: ValueKey('now-playing-none'));
|
||||||
|
} else {
|
||||||
|
final episode = n.episode;
|
||||||
|
child = Row(
|
||||||
|
key: ValueKey('now-playing:${n.title}\n${episode ?? ''}'),
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (cover) ...[ArtCover(hash: n.posterHash), const SizedBox(width: 12)],
|
||||||
|
Flexible(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
n.title,
|
||||||
|
key: const ValueKey('now-playing-title'),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
fontSize: compact ? 13 : 16,
|
||||||
|
shadows: _shadow),
|
||||||
|
),
|
||||||
|
if (episode != null && episode.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
episode,
|
||||||
|
key: const ValueKey('now-playing-episode'),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: compact ? 11 : 13, shadows: _shadow),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fadeThrough(child, expand: false, alignment: AlignmentDirectional.centerStart);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [NowPlayingBlock] of the session's current media.
|
||||||
|
class NowPlayingTitle extends StatelessWidget {
|
||||||
|
final bool cover;
|
||||||
|
final bool compact;
|
||||||
|
const NowPlayingTitle({super.key, this.cover = true, this.compact = false});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => ListenableBuilder(
|
||||||
|
listenable: artChanges(),
|
||||||
|
builder: (context, _) => SignalBuilder(
|
||||||
|
builder: (context) => NowPlayingBlock(
|
||||||
|
now: SessionState.instance.coordinator.nowPlaying.value,
|
||||||
|
cover: cover,
|
||||||
|
compact: compact,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:ui' show ImageFilter;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
|
import '../session/health.dart';
|
||||||
|
import '../session/session_coordinator.dart';
|
||||||
|
import '../state/session_state.dart';
|
||||||
|
import 'avatar.dart';
|
||||||
|
import 'motion.dart';
|
||||||
|
|
||||||
|
/// What the player's status pill says, most pressing first.
|
||||||
|
enum StatusKind { kicked, reconnecting, problem, connecting, finished, loading, you, waiting }
|
||||||
|
|
||||||
|
/// The pill's button: stop reconnecting, retry, or mark yourself ready
|
||||||
|
/// (the whole pill).
|
||||||
|
enum StatusAction { cancel, retry, ready }
|
||||||
|
|
||||||
|
/// One status line: [text], a muted [detail], a coloured [tone] for
|
||||||
|
/// problems (warn, error), the members it is about ([who]; [self] for you)
|
||||||
|
/// and a loading [progress] (0..1).
|
||||||
|
class StatusView {
|
||||||
|
final StatusKind kind;
|
||||||
|
final String text;
|
||||||
|
final String? detail;
|
||||||
|
final HealthSeverity? tone;
|
||||||
|
final List<MemberView> who;
|
||||||
|
final bool self;
|
||||||
|
final double? progress;
|
||||||
|
final StatusAction? action;
|
||||||
|
/// The mini player's shorter line.
|
||||||
|
final String short;
|
||||||
|
const StatusView(
|
||||||
|
this.kind,
|
||||||
|
this.text, {
|
||||||
|
this.detail,
|
||||||
|
this.tone,
|
||||||
|
this.who = const [],
|
||||||
|
this.self = false,
|
||||||
|
this.progress,
|
||||||
|
this.action,
|
||||||
|
String? short,
|
||||||
|
}) : short = short ?? text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Bob", "Bob and Mika", "Bob, Mika and Cleo", "Bob, Mika and 3 others".
|
||||||
|
String nameList(List<String> names) {
|
||||||
|
if (names.length <= 1) return names.join();
|
||||||
|
if (names.length <= 3) return '${names.sublist(0, names.length - 1).join(', ')} and ${names.last}';
|
||||||
|
return '${names[0]}, ${names[1]} and ${names.length - 2} others';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _sentence(String s) => s.isEmpty ? s : s[0].toUpperCase() + s.substring(1);
|
||||||
|
|
||||||
|
/// The player's status from the session's signals; null when there is
|
||||||
|
/// nothing to say (playing, connected, everyone ready). [slowConnect]:
|
||||||
|
/// connecting has taken over 2 s.
|
||||||
|
StatusView? playerStatusFor({
|
||||||
|
required HealthStatus health,
|
||||||
|
bool reconnecting = false,
|
||||||
|
String? kickReason,
|
||||||
|
StateBroadcast? state,
|
||||||
|
MemberView? self,
|
||||||
|
bool slowConnect = false,
|
||||||
|
}) {
|
||||||
|
if (kickReason != null) {
|
||||||
|
return StatusView(StatusKind.kicked,
|
||||||
|
kickReason.isEmpty ? 'You were removed from the server' : 'You were removed: $kickReason',
|
||||||
|
tone: HealthSeverity.error, short: 'Removed from the server');
|
||||||
|
}
|
||||||
|
if (reconnecting) {
|
||||||
|
return const StatusView(StatusKind.reconnecting, 'Reconnecting…',
|
||||||
|
tone: HealthSeverity.warn, action: StatusAction.cancel);
|
||||||
|
}
|
||||||
|
if (health.severity == HealthSeverity.warn || health.severity == HealthSeverity.error) {
|
||||||
|
return StatusView(StatusKind.problem, _sentence(health.message),
|
||||||
|
tone: health.severity, action: StatusAction.retry);
|
||||||
|
}
|
||||||
|
if (slowConnect && health.severity == HealthSeverity.info && health.message.startsWith('connecting')) {
|
||||||
|
return const StatusView(StatusKind.connecting, 'Connecting…');
|
||||||
|
}
|
||||||
|
final r = state;
|
||||||
|
if (r == null || r.media == null || r.transport == 'playing') return null;
|
||||||
|
if (r.transport == 'finished') {
|
||||||
|
return const StatusView(StatusKind.finished, 'Finished · seek back or pick the next video', short: 'Finished');
|
||||||
|
}
|
||||||
|
// As the server holds playback: someone in the ready set short of the
|
||||||
|
// minimum buffer.
|
||||||
|
for (final m in r.members) {
|
||||||
|
if (m.inSet && m.buffered < RelayConstants.minBufferSecs) {
|
||||||
|
final you = self != null && m.id == self.id;
|
||||||
|
return StatusView(StatusKind.loading, you ? 'Loading the video…' : '${m.name} is loading…',
|
||||||
|
who: [m], self: you, progress: (m.buffered / RelayConstants.minBufferSecs).clamp(0.0, 1.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (self != null && !self.ready) {
|
||||||
|
return StatusView(StatusKind.you, "Press Space when you're ready",
|
||||||
|
who: [self], self: true, action: StatusAction.ready, short: 'Press Space when ready');
|
||||||
|
}
|
||||||
|
final active = [for (final m in r.members) if (!m.stale) m];
|
||||||
|
final others = [
|
||||||
|
for (final m in active)
|
||||||
|
if ((self == null || m.id != self.id) && !(m.inSet && m.ready)) m,
|
||||||
|
];
|
||||||
|
if (others.isEmpty) return null;
|
||||||
|
final ready = active.where((m) => m.ready).length;
|
||||||
|
return StatusView(StatusKind.waiting, 'Waiting for ${nameList([for (final m in others) m.name])}',
|
||||||
|
who: others, detail: '$ready/${active.length} ready');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [playerStatusFor] from the session (tracked: call inside a builder).
|
||||||
|
StatusView? playerStatusNow({bool slowConnect = false}) {
|
||||||
|
final c = SessionState.instance.coordinator;
|
||||||
|
return playerStatusFor(
|
||||||
|
health: c.health.value,
|
||||||
|
reconnecting: c.reconnecting.value,
|
||||||
|
kickReason: c.kickReason.value,
|
||||||
|
state: c.serverState.value,
|
||||||
|
self: c.selfMember.value,
|
||||||
|
slowConnect: slowConnect,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The player's one status pill (bottom centre): frosted dark glass with
|
||||||
|
/// one short line, the members it is about and at most one button. Fades
|
||||||
|
/// in and out; a different kind of status fades through, the pill resizing
|
||||||
|
/// around it.
|
||||||
|
class PlayerStatus extends StatefulWidget {
|
||||||
|
const PlayerStatus({super.key});
|
||||||
|
|
||||||
|
static const connectGrace = Duration(seconds: 2);
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlayerStatus> createState() => _PlayerStatusState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlayerStatusState extends State<PlayerStatus> {
|
||||||
|
Timer? _connectTimer;
|
||||||
|
bool _slow = false;
|
||||||
|
StatusView? _last;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_connectTimer?.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Connecting…" only once it takes a while.
|
||||||
|
void _trackConnecting(HealthStatus h) {
|
||||||
|
final connecting = h.severity == HealthSeverity.info && h.message.startsWith('connecting');
|
||||||
|
if (!connecting) {
|
||||||
|
_connectTimer?.cancel();
|
||||||
|
_connectTimer = null;
|
||||||
|
_slow = false;
|
||||||
|
} else if (_connectTimer == null && !_slow) {
|
||||||
|
_connectTimer = Timer(PlayerStatus.connectGrace, () {
|
||||||
|
if (mounted) setState(() => _slow = true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _act(SessionCoordinator coord, StatusAction a) {
|
||||||
|
switch (a) {
|
||||||
|
case StatusAction.cancel:
|
||||||
|
coord.cancelReconnect();
|
||||||
|
case StatusAction.retry:
|
||||||
|
coord.retry();
|
||||||
|
case StatusAction.ready:
|
||||||
|
coord.toggleReady();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return PartyWatch(builder: (context) {
|
||||||
|
final coord = SessionState.instance.coordinator;
|
||||||
|
_trackConnecting(coord.health.value);
|
||||||
|
final view = playerStatusNow(slowConnect: _slow);
|
||||||
|
if (view != null) _last = view;
|
||||||
|
final shown = _last;
|
||||||
|
final ready = view?.action == StatusAction.ready;
|
||||||
|
return AnimatedOpacity(
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
opacity: view == null ? 0 : 1,
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: view == null,
|
||||||
|
child: shown == null
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: MouseRegion(
|
||||||
|
cursor: ready ? SystemMouseCursors.click : MouseCursor.defer,
|
||||||
|
child: GestureDetector(
|
||||||
|
key: const ValueKey('player-status'),
|
||||||
|
onTap: ready ? coord.toggleReady : null,
|
||||||
|
child: _Glass(
|
||||||
|
child: AnimatedSize(
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
child: fadeThrough(
|
||||||
|
_StatusLine(
|
||||||
|
key: ValueKey(shown.kind),
|
||||||
|
view: shown,
|
||||||
|
coord: coord,
|
||||||
|
onAction: (a) => _act(coord, a),
|
||||||
|
),
|
||||||
|
expand: false,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Glass extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
const _Glass({required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final r = BorderRadius.circular(999);
|
||||||
|
return ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: r,
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.55),
|
||||||
|
borderRadius: r,
|
||||||
|
border: Border.all(color: Colors.white12, width: 0.5),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusLine extends StatelessWidget {
|
||||||
|
final StatusView view;
|
||||||
|
final SessionCoordinator coord;
|
||||||
|
final ValueChanged<StatusAction> onAction;
|
||||||
|
const _StatusLine({super.key, required this.view, required this.coord, required this.onAction});
|
||||||
|
|
||||||
|
static const _avatar = 24.0;
|
||||||
|
|
||||||
|
Widget? _lead() {
|
||||||
|
final v = view;
|
||||||
|
switch (v.kind) {
|
||||||
|
case StatusKind.kicked:
|
||||||
|
case StatusKind.problem:
|
||||||
|
final error = v.tone == HealthSeverity.error;
|
||||||
|
return Icon(error ? Icons.error_outline_rounded : Icons.warning_amber_rounded,
|
||||||
|
size: 18, color: error ? Colors.redAccent.shade100 : Colors.amber);
|
||||||
|
case StatusKind.reconnecting:
|
||||||
|
return const _Spinner(color: Colors.amber);
|
||||||
|
case StatusKind.connecting:
|
||||||
|
return const _Spinner(color: Colors.white70);
|
||||||
|
case StatusKind.finished:
|
||||||
|
return null;
|
||||||
|
case StatusKind.loading:
|
||||||
|
case StatusKind.you:
|
||||||
|
case StatusKind.waiting:
|
||||||
|
final r = coord.serverState.value;
|
||||||
|
final infos = [
|
||||||
|
for (final m in v.who)
|
||||||
|
v.self ? selfAvatarInfo(ready: m.ready, host: m.host) : memberAvatarInfo(m, r, coord),
|
||||||
|
];
|
||||||
|
if (infos.isEmpty) return null;
|
||||||
|
return infos.length == 1
|
||||||
|
? StatusAvatar(info: infos.first, size: _avatar, badges: false)
|
||||||
|
: AvatarStack(members: infos, shown: 4, size: _avatar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final v = view;
|
||||||
|
final lead = _lead();
|
||||||
|
final action = v.action == StatusAction.ready ? null : v.action;
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(lead == null ? 16 : 6, 6, action == null ? 16 : 6, 6),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (lead != null) ...[
|
||||||
|
SizedBox(height: _avatar, child: Center(child: lead)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
v.text,
|
||||||
|
key: const ValueKey('player-status-text'),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 13, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (v.progress != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SizedBox.square(
|
||||||
|
dimension: 14,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: v.progress,
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
backgroundColor: Colors.white24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (v.detail != null) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(v.detail!, style: const TextStyle(color: Colors.white54, fontSize: 13, fontWeight: FontWeight.w500)),
|
||||||
|
],
|
||||||
|
if (action != null) ...[
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
_PillButton(
|
||||||
|
label: action == StatusAction.cancel ? 'Cancel' : 'Retry',
|
||||||
|
onPressed: () => onAction(action),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Spinner extends StatelessWidget {
|
||||||
|
final Color color;
|
||||||
|
const _Spinner({required this.color});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) =>
|
||||||
|
SizedBox.square(dimension: 14, child: CircularProgressIndicator(strokeWidth: 2, color: color));
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PillButton extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
const _PillButton({required this.label, required this.onPressed});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => TextButton(
|
||||||
|
key: ValueKey('player-status-$label'),
|
||||||
|
onPressed: onPressed,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
backgroundColor: Colors.white12,
|
||||||
|
shape: const StadiumBorder(),
|
||||||
|
minimumSize: const Size(0, 26),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
textStyle: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
child: Text(label),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ import '../controls/uosc/timeline.dart';
|
|||||||
import '../playback/media_marks.dart' show SkipOffer, formatClock;
|
import '../playback/media_marks.dart' show SkipOffer, formatClock;
|
||||||
import '../screens/session/jellyfin_widgets.dart';
|
import '../screens/session/jellyfin_widgets.dart';
|
||||||
import '../screens/session/player_controls.dart';
|
import '../screens/session/player_controls.dart';
|
||||||
import '../session/health.dart';
|
|
||||||
import '../session/session_coordinator.dart';
|
import '../session/session_coordinator.dart';
|
||||||
import '../shaders/shader_menu.dart';
|
import '../shaders/shader_menu.dart';
|
||||||
import '../shaders/shader_presets.dart';
|
import '../shaders/shader_presets.dart';
|
||||||
@@ -24,6 +23,9 @@ import '../widgets/text_focus.dart';
|
|||||||
import '../widgets/window_buttons.dart';
|
import '../widgets/window_buttons.dart';
|
||||||
import 'avatar.dart';
|
import 'avatar.dart';
|
||||||
import 'motion.dart';
|
import 'motion.dart';
|
||||||
|
import 'now_playing_title.dart';
|
||||||
|
import 'player_status.dart';
|
||||||
|
import 'start_banner.dart';
|
||||||
|
|
||||||
/// Where the session's video is shown: nowhere, the floating mini player,
|
/// Where the session's video is shown: nowhere, the floating mini player,
|
||||||
/// or filling the body (and, with fullscreen, the window).
|
/// or filling the body (and, with fullscreen, the window).
|
||||||
@@ -36,8 +38,8 @@ typedef VideoViewBuilder = Widget Function(BuildContext context, VideoControlsBu
|
|||||||
|
|
||||||
/// The session's player: the one `Video` of the current player and
|
/// The session's player: the one `Video` of the current player and
|
||||||
/// everything over it. Full: our bars on media_kit's controls or the uosc
|
/// everything over it. Full: our bars on media_kit's controls or the uosc
|
||||||
/// set (Settings), health / waiting / Jellyfin overlays and the player
|
/// set (Settings), the start card, the status pill, Jellyfin overlays and
|
||||||
/// keys. Mini: hover controls and a progress line. The shell keeps this
|
/// the player keys. Mini: hover controls and a progress line. The shell keeps this
|
||||||
/// widget mounted at one place in the tree while the mode changes, so the
|
/// widget mounted at one place in the tree while the mode changes, so the
|
||||||
/// `Video` is never mounted twice nor rebuilt for a move.
|
/// `Video` is never mounted twice nor rebuilt for a move.
|
||||||
class SessionPlayer extends StatefulWidget {
|
class SessionPlayer extends StatefulWidget {
|
||||||
@@ -277,8 +279,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
child: Stack(fit: StackFit.expand, children: [video, _frameProbe.anchor()]),
|
child: Stack(fit: StackFit.expand, children: [video, _frameProbe.anchor()]),
|
||||||
),
|
),
|
||||||
if (_full) ...[
|
if (_full) ...[
|
||||||
_healthOverlay(),
|
_status(),
|
||||||
_waitingPill(bottom: 124),
|
|
||||||
_progressLine(),
|
_progressLine(),
|
||||||
_jellyfinOverlay(),
|
_jellyfinOverlay(),
|
||||||
if (widget.fullscreen) _pausedParty(),
|
if (widget.fullscreen) _pausedParty(),
|
||||||
@@ -306,6 +307,8 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static const _barHeight = 56.0;
|
static const _barHeight = 56.0;
|
||||||
|
/// The standard top bar: room for the cover (2:3, 60 px tall).
|
||||||
|
static const _topBarHeight = 72.0;
|
||||||
static const _controlsHide = Duration(seconds: 3);
|
static const _controlsHide = Duration(seconds: 3);
|
||||||
|
|
||||||
/// Fullscreen hides the strip and its window buttons: the top bar
|
/// Fullscreen hides the strip and its window buttons: the top bar
|
||||||
@@ -346,14 +349,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
String get _title {
|
|
||||||
final ref = _coord.currentRef.value;
|
|
||||||
if (ref != null && ref.title.isNotEmpty) {
|
|
||||||
return [ref.title, ?ref.subtitle].join(' · ');
|
|
||||||
}
|
|
||||||
return (_coord.serverState.value?.media?['name'] as String?) ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
MaterialDesktopVideoControlsThemeData _controlsTheme() {
|
MaterialDesktopVideoControlsThemeData _controlsTheme() {
|
||||||
final coord = _coord;
|
final coord = _coord;
|
||||||
return MaterialDesktopVideoControlsThemeData(
|
return MaterialDesktopVideoControlsThemeData(
|
||||||
@@ -375,19 +370,10 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
child: Align(
|
child: Align(
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: _barHeight,
|
height: _topBarHeight,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
const Expanded(child: NowPlayingTitle()),
|
||||||
child: SignalBuilder(
|
|
||||||
builder: (context) => Text(
|
|
||||||
_title,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 16),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MaterialDesktopCustomButton(
|
MaterialDesktopCustomButton(
|
||||||
icon: const Icon(Icons.video_library_outlined),
|
icon: const Icon(Icons.video_library_outlined),
|
||||||
onPressed: widget.onChangeVideo,
|
onPressed: widget.onChangeVideo,
|
||||||
@@ -510,7 +496,12 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _sessionControls(VideoState state) {
|
Widget _sessionControls(VideoState state) {
|
||||||
|
// The start card sits under the controls (they stay usable on it).
|
||||||
// The "Player design" setting swaps them live.
|
// The "Player design" setting swaps them live.
|
||||||
|
return Stack(fit: StackFit.expand, children: [const StartBanner(), _controlsSwitch(state)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _controlsSwitch(VideoState state) {
|
||||||
return SessionControlsSwitch(
|
return SessionControlsSwitch(
|
||||||
material: (_) => ControlsHoverProbe(
|
material: (_) => ControlsHoverProbe(
|
||||||
shown: _standardShown,
|
shown: _standardShown,
|
||||||
@@ -518,7 +509,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
child: DoubleClickListener(
|
child: DoubleClickListener(
|
||||||
onDoubleClick: widget.onToggleFullscreen,
|
onDoubleClick: widget.onToggleFullscreen,
|
||||||
// Clicks on the bars are button clicks, not the video.
|
// Clicks on the bars are button clicks, not the video.
|
||||||
skipTop: _barHeight,
|
skipTop: _topBarHeight,
|
||||||
skipBottom: _barHeight + ChapterSeekBar.height,
|
skipBottom: _barHeight + ChapterSeekBar.height,
|
||||||
child: MaterialDesktopVideoControls(state),
|
child: MaterialDesktopVideoControls(state),
|
||||||
),
|
),
|
||||||
@@ -546,6 +537,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
});
|
});
|
||||||
return UoscControls(
|
return UoscControls(
|
||||||
onDoubleTap: widget.onToggleFullscreen,
|
onDoubleTap: widget.onToggleFullscreen,
|
||||||
|
topLeft: const NowPlayingTitle(),
|
||||||
top: Row(
|
top: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -655,80 +647,9 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _healthOverlay() {
|
/// The one status pill, bottom centre above the controls.
|
||||||
final coord = _coord;
|
Widget _status() =>
|
||||||
return Positioned(
|
const Positioned(left: 16, right: 16, bottom: 124, child: Center(child: PlayerStatus()));
|
||||||
top: _barHeight + 8,
|
|
||||||
left: 12,
|
|
||||||
right: 12,
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.topLeft,
|
|
||||||
child: SignalBuilder(
|
|
||||||
builder: (context) {
|
|
||||||
final h = coord.health.value;
|
|
||||||
final starve = coord.starving.value;
|
|
||||||
final retrying = coord.reconnecting.value;
|
|
||||||
final kicked = coord.kickReason.value;
|
|
||||||
final unwell = h.severity == HealthSeverity.warn ||
|
|
||||||
h.severity == HealthSeverity.error ||
|
|
||||||
retrying ||
|
|
||||||
starve != null;
|
|
||||||
if (!unwell) return const SizedBox.shrink();
|
|
||||||
final color = switch (h.severity) {
|
|
||||||
HealthSeverity.ok => Colors.green,
|
|
||||||
HealthSeverity.info => Colors.grey,
|
|
||||||
HealthSeverity.warn => Colors.orange,
|
|
||||||
HealthSeverity.error => Colors.red,
|
|
||||||
};
|
|
||||||
final IconData icon;
|
|
||||||
if (retrying) {
|
|
||||||
icon = Icons.sync;
|
|
||||||
} else if (h.severity == HealthSeverity.error) {
|
|
||||||
icon = Icons.error_outline;
|
|
||||||
} else if (h.severity == HealthSeverity.warn) {
|
|
||||||
icon = Icons.warning_amber_outlined;
|
|
||||||
} else {
|
|
||||||
icon = Icons.hourglass_empty;
|
|
||||||
}
|
|
||||||
final text = [h.message, ?starve, if (retrying) 'reconnecting…'].join(' · ');
|
|
||||||
final canRetry = !retrying &&
|
|
||||||
kicked == null &&
|
|
||||||
(h.severity == HealthSeverity.error || h.severity == HealthSeverity.warn);
|
|
||||||
final button = TextButton.styleFrom(
|
|
||||||
foregroundColor: Colors.white, tapTargetSize: MaterialTapTargetSize.shrinkWrap);
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.65), borderRadius: BorderRadius.circular(999)),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(icon, color: color, size: 16),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Flexible(child: Text(text, style: const TextStyle(color: Colors.white, fontSize: 12))),
|
|
||||||
if (retrying)
|
|
||||||
TextButton(
|
|
||||||
style: button,
|
|
||||||
onPressed: coord.cancelReconnect,
|
|
||||||
child: const Text('Cancel', style: TextStyle(fontSize: 12)),
|
|
||||||
),
|
|
||||||
if (canRetry)
|
|
||||||
TextButton(
|
|
||||||
style: button,
|
|
||||||
onPressed: coord.retry,
|
|
||||||
child: const Text('Retry', style: TextStyle(fontSize: 12)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _waitingPill({required double bottom}) =>
|
|
||||||
Positioned(left: 0, right: 0, bottom: bottom, child: const Center(child: WaitingPill()));
|
|
||||||
|
|
||||||
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and
|
/// Jellyfin: the blocked card, the end-of-episode card (countdown) and
|
||||||
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED /
|
/// the "Next episode" pill; for any media the skip pill (Skip OP / ED /
|
||||||
@@ -807,7 +728,6 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
Widget _miniOverlay() {
|
Widget _miniOverlay() {
|
||||||
final coord = _coord;
|
final coord = _coord;
|
||||||
final playback = coord.playback;
|
final playback = coord.playback;
|
||||||
const white = TextStyle(color: Colors.white, fontWeight: FontWeight.w600);
|
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
onEnter: (_) => setState(() => _miniHover = true),
|
onEnter: (_) => setState(() => _miniHover = true),
|
||||||
onExit: (_) => setState(() => _miniHover = false),
|
onExit: (_) => setState(() => _miniHover = false),
|
||||||
@@ -832,12 +752,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
const Expanded(child: NowPlayingTitle(cover: false, compact: true)),
|
||||||
child: SignalBuilder(
|
|
||||||
builder: (context) =>
|
|
||||||
Text(_title, maxLines: 1, overflow: TextOverflow.ellipsis, style: white),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
focusNode: _noFocus,
|
focusNode: _noFocus,
|
||||||
onPressed: widget.onExpand,
|
onPressed: widget.onExpand,
|
||||||
@@ -871,7 +786,7 @@ class _SessionPlayerState extends State<SessionPlayer> {
|
|||||||
style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
style: const TextStyle(color: Colors.white70, fontSize: 12)),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Text(waitingText(compact: true),
|
child: Text(playerStatusNow()?.short ?? '',
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(color: Colors.amber, fontSize: 12)),
|
style: const TextStyle(color: Colors.amber, fontSize: 12)),
|
||||||
@@ -904,81 +819,6 @@ const _shaderKeys = [
|
|||||||
LogicalKeyboardKey.digit6,
|
LogicalKeyboardKey.digit6,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Who the server waits for: "Waiting for you · press Space when ready",
|
|
||||||
/// "Waiting for Alex, Mika", or '' while playing or with nothing up.
|
|
||||||
String waitingText({bool compact = false}) {
|
|
||||||
final coord = SessionState.instance.coordinator;
|
|
||||||
final r = coord.serverState.value;
|
|
||||||
if (r == null || r.media == null || r.transport != 'held') return '';
|
|
||||||
final self = coord.selfMember.value;
|
|
||||||
if (self != null && !self.ready) {
|
|
||||||
return compact ? 'waiting for you' : 'Waiting for you · press Space when ready';
|
|
||||||
}
|
|
||||||
final waiting = [for (final m in r.members) if (!m.ready && !m.stale) m.name];
|
|
||||||
if (waiting.isEmpty) return compact ? '' : 'Paused';
|
|
||||||
return '${compact ? 'waiting' : 'Waiting'} for ${waiting.join(', ')}';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// While held: who everyone is waiting for, with their avatars. When it
|
|
||||||
/// is you, a click marks you ready.
|
|
||||||
class WaitingPill extends StatelessWidget {
|
|
||||||
const WaitingPill({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return PartyWatch(builder: (context) {
|
|
||||||
final coord = SessionState.instance.coordinator;
|
|
||||||
final text = waitingText();
|
|
||||||
final r = coord.serverState.value;
|
|
||||||
final self = coord.selfMember.value;
|
|
||||||
final meWaiting = self != null && !self.ready;
|
|
||||||
final who = meWaiting
|
|
||||||
? [selfAvatarInfo()]
|
|
||||||
: [
|
|
||||||
for (final m in r?.members ?? const <MemberView>[])
|
|
||||||
if (!m.ready && !m.stale) memberAvatarInfo(m, r, coord),
|
|
||||||
];
|
|
||||||
return AnimatedOpacity(
|
|
||||||
duration: easeTime,
|
|
||||||
curve: ease,
|
|
||||||
opacity: text.isEmpty ? 0 : 1,
|
|
||||||
child: IgnorePointer(
|
|
||||||
ignoring: text.isEmpty || !meWaiting,
|
|
||||||
child: GestureDetector(
|
|
||||||
key: const ValueKey('waiting-pill'),
|
|
||||||
onTap: coord.toggleReady,
|
|
||||||
child: MouseRegion(
|
|
||||||
cursor: meWaiting ? SystemMouseCursors.click : MouseCursor.defer,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.fromLTRB(8, 6, 16, 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.black.withValues(alpha: 0.72),
|
|
||||||
borderRadius: BorderRadius.circular(30),
|
|
||||||
border: Border.all(color: Colors.white12),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
for (final m in who.take(4))
|
|
||||||
Padding(padding: const EdgeInsets.only(right: 4), child: StatusAvatar(info: m, size: 28)),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Flexible(
|
|
||||||
child: Text(text,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w600)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mini player progress: a 2 px line, or (hovered) a 4 px bar that seeks.
|
/// Mini player progress: a 2 px line, or (hovered) a 4 px bar that seeks.
|
||||||
class _MiniSeekBar extends StatelessWidget {
|
class _MiniSeekBar extends StatelessWidget {
|
||||||
final bool thin;
|
final bool thin;
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import 'dart:ui' show ImageFilter;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:signals/signals_flutter.dart';
|
||||||
|
|
||||||
|
import '../session/now_playing.dart';
|
||||||
|
import '../state/session_state.dart';
|
||||||
|
import 'motion.dart';
|
||||||
|
import 'now_playing_title.dart';
|
||||||
|
|
||||||
|
/// Space kept free at the bottom of the start card for the controls and
|
||||||
|
/// the status pill.
|
||||||
|
const startCardBottom = 176.0;
|
||||||
|
|
||||||
|
/// The start card of [now]: its banner (cover-fit, darkened), else a soft
|
||||||
|
/// blur of the cover, else dark; the cover, a big title and the episode
|
||||||
|
/// line bottom left, above the controls.
|
||||||
|
class StartCard extends StatelessWidget {
|
||||||
|
final NowPlaying now;
|
||||||
|
const StartCard({super.key, required this.now});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final banner = artImage(now.bannerHash);
|
||||||
|
final cover = banner == null ? artImage(now.posterHash) : null;
|
||||||
|
final episode = now.episode;
|
||||||
|
return LayoutBuilder(builder: (context, box) {
|
||||||
|
final h = box.maxHeight;
|
||||||
|
final coverWidth = (h * 0.26).clamp(84.0, 240.0) / 1.5;
|
||||||
|
final titleSize = (h * 0.05).clamp(22.0, 44.0);
|
||||||
|
final side = (box.maxWidth * 0.05).clamp(20.0, 72.0);
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
const ColoredBox(color: Color(0xFF0C0D11)),
|
||||||
|
if (banner != null)
|
||||||
|
fadeInImage(banner)
|
||||||
|
else if (cover != null)
|
||||||
|
Opacity(
|
||||||
|
opacity: 0.5,
|
||||||
|
child: ImageFiltered(
|
||||||
|
imageFilter: ImageFilter.blur(sigmaX: 48, sigmaY: 48, tileMode: TileMode.decal),
|
||||||
|
child: Transform.scale(scale: 1.3, child: fadeInImage(cover)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: RadialGradient(
|
||||||
|
center: Alignment(-0.7, 0.5),
|
||||||
|
radius: 1.2,
|
||||||
|
colors: [Color(0xFF23262E), Color(0xFF0C0D11)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
stops: [0, 0.35, 1],
|
||||||
|
colors: [Color(0x55000000), Color(0x22000000), Color(0xE6000000)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.centerLeft,
|
||||||
|
end: Alignment.centerRight,
|
||||||
|
stops: [0, 0.65],
|
||||||
|
colors: [Color(0x99000000), Color(0x00000000)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: side,
|
||||||
|
right: side,
|
||||||
|
bottom: startCardBottom,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
ArtCover(hash: now.posterHash, width: coverWidth),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
now.title,
|
||||||
|
key: const ValueKey('start-card-title'),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: titleSize,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
height: 1.1,
|
||||||
|
shadows: const [Shadow(color: Color(0x99000000), blurRadius: 16)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (episode != null && episode.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
episode,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: titleSize * 0.42),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [StartCard] over the picture until playback starts on this media
|
||||||
|
/// (the server plays, or its position passed 1 s); then it fades out and
|
||||||
|
/// stays gone for that media, whatever happens later. Never takes input.
|
||||||
|
class StartBanner extends StatefulWidget {
|
||||||
|
const StartBanner({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StartBanner> createState() => _StartBannerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StartBannerState extends State<StartBanner> {
|
||||||
|
int? _mediaId;
|
||||||
|
bool _started = false;
|
||||||
|
// Faded out: the card leaves the tree (and its images with it).
|
||||||
|
bool _gone = true;
|
||||||
|
NowPlaying? _last;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final coord = SessionState.instance.coordinator;
|
||||||
|
return IgnorePointer(
|
||||||
|
child: ListenableBuilder(
|
||||||
|
listenable: artChanges(),
|
||||||
|
builder: (context, _) => SignalBuilder(builder: (context) {
|
||||||
|
final r = coord.serverState.value;
|
||||||
|
final now = coord.nowPlaying.value;
|
||||||
|
if (r != null && r.mediaId != _mediaId) {
|
||||||
|
_mediaId = r.mediaId;
|
||||||
|
_started = false;
|
||||||
|
}
|
||||||
|
if (r != null && (r.transport == 'playing' || r.position >= 1)) _started = true;
|
||||||
|
final show = now != null && r?.media != null && !_started;
|
||||||
|
if (show) {
|
||||||
|
_last = now;
|
||||||
|
_gone = false;
|
||||||
|
}
|
||||||
|
final last = _last;
|
||||||
|
if (last == null || _gone) return const SizedBox.shrink();
|
||||||
|
return AnimatedOpacity(
|
||||||
|
key: const ValueKey('start-banner'),
|
||||||
|
opacity: show ? 1 : 0,
|
||||||
|
duration: easeTime,
|
||||||
|
curve: ease,
|
||||||
|
onEnd: () {
|
||||||
|
if (!show && mounted) setState(() => _gone = true);
|
||||||
|
},
|
||||||
|
child: StartCard(now: last),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,15 +3,38 @@ import 'dart:io';
|
|||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
|
||||||
import 'sync_client.dart';
|
import 'sync_client.dart';
|
||||||
|
|
||||||
/// Member pictures by content hash (`state.members[].avatarHash`), fetched
|
/// Member pictures by content hash (`state.members[].avatarHash`), from `/a/`.
|
||||||
/// once from `/a/<sid>/<token>/<hash>` on the host the client dialled and
|
class AvatarCache extends PictureCache {
|
||||||
/// kept in memory. A hash names fixed bytes, so entries never go stale;
|
AvatarCache(super.sync, {super.fetch})
|
||||||
/// listeners are notified when one arrives.
|
: super(route: 'a', keep: 32, live: (s) => [for (final m in s.members) m.avatarHash]);
|
||||||
class AvatarCache extends ChangeNotifier {
|
}
|
||||||
|
|
||||||
|
/// 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;
|
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.
|
/// Fetches one picture; null on any failure. Injectable for tests.
|
||||||
final Future<Uint8List?> Function(Uri url) _fetch;
|
final Future<Uint8List?> Function(Uri url) _fetch;
|
||||||
final Map<String, Uint8List> _bytes = {};
|
final Map<String, Uint8List> _bytes = {};
|
||||||
@@ -21,15 +44,17 @@ class AvatarCache extends ChangeNotifier {
|
|||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
static const _retryAfter = Duration(seconds: 10);
|
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 {
|
: _fetch = fetch ?? _httpGet {
|
||||||
_sub = sync.states.listen((s) {
|
_sub = sync.states.listen((s) {
|
||||||
final live = {for (final m in s.members) if (m.avatarHash.isNotEmpty) m.avatarHash};
|
final hashes = {for (final h in live(s)) if (h.isNotEmpty) h};
|
||||||
if (_bytes.length > _keep) _bytes.removeWhere((h, _) => !live.contains(h));
|
if (_bytes.length > keep) _bytes.removeWhere((h, _) => !hashes.contains(h));
|
||||||
live.forEach(_want);
|
hashes.forEach(_want);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +78,7 @@ class AvatarCache extends ChangeNotifier {
|
|||||||
scheme: ws.scheme == 'wss' ? 'https' : 'http',
|
scheme: ws.scheme == 'wss' ? 'https' : 'http',
|
||||||
host: ws.host,
|
host: ws.host,
|
||||||
port: ws.port,
|
port: ws.port,
|
||||||
pathSegments: ['a', 'default', token, hash]);
|
pathSegments: [route, 'default', token, hash]);
|
||||||
_inFlight.add(hash);
|
_inFlight.add(hash);
|
||||||
unawaited(_fetch(url).then((bytes) {
|
unawaited(_fetch(url).then((bytes) {
|
||||||
_inFlight.remove(hash);
|
_inFlight.remove(hash);
|
||||||
|
|||||||
@@ -250,6 +250,11 @@ class SyncClient {
|
|||||||
_send(ProfileIntent(name: name, avatar: avatar).toJson());
|
_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 = ''}) {
|
void sendKick(String target, {String reason = ''}) {
|
||||||
_send(KickRequest(target: target, reason: reason).toJson());
|
_send(KickRequest(target: target, reason: reason).toJson());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,4 +56,35 @@ void main() {
|
|||||||
await server.stop();
|
await server.stop();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('media art arrives by hash over /i/; a swap clears it', () async {
|
||||||
|
final server = SyncServer(const AppConfig(port: 0, secret: 's', name: 'art'));
|
||||||
|
await server.start();
|
||||||
|
final client = SyncClient();
|
||||||
|
final cache = ArtCache(client);
|
||||||
|
final poster = Uint8List.fromList([0xFF, 0xD8, 0xFF, ...List<int>.generate(197, (i) => i % 256)]);
|
||||||
|
try {
|
||||||
|
await client.connect(
|
||||||
|
wsUri: Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'),
|
||||||
|
secret: 's',
|
||||||
|
name: 'alice',
|
||||||
|
);
|
||||||
|
client.sendSwap(const HelloMedia(name: 'Show - 01.mkv', size: 0, duration: 0, url: 'http://example.com/a.mkv'));
|
||||||
|
await _poll(() => client.lastState?.media?['swapBy'] == client.memberId, 'swap');
|
||||||
|
client.sendArt(client.lastState!.mediaId, poster: base64Encode(poster));
|
||||||
|
await _poll(() => ArtCache.artHashesOf(client.lastState!).isNotEmpty, 'art in state');
|
||||||
|
final hash = ArtCache.artHashesOf(client.lastState!).single;
|
||||||
|
expect(hash, avatarHashOf(poster));
|
||||||
|
await _poll(() => cache.bytesFor(hash) != null, 'art bytes');
|
||||||
|
expect(cache.bytesFor(hash), poster);
|
||||||
|
|
||||||
|
client.sendSwap(const HelloMedia(name: 'Other.mkv', size: 0, duration: 0, url: 'http://example.com/b.mkv'));
|
||||||
|
await _poll(() => client.lastState?.media?['name'] == 'Other.mkv', 'second swap');
|
||||||
|
expect(ArtCache.artHashesOf(client.lastState!), isEmpty);
|
||||||
|
} finally {
|
||||||
|
cache.dispose();
|
||||||
|
await client.dispose();
|
||||||
|
await server.stop();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:watch_together/src/session/now_playing.dart';
|
||||||
|
import 'package:watch_together/src/session/now_playing_info.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('jellyfinTitle', () {
|
||||||
|
test('episodes: series on top, "S1 E2 · Name" below', () {
|
||||||
|
expect(jellyfinTitle('The Pilot', 'Show · S1E2'), ('Show', 'S1 E2 · The Pilot'));
|
||||||
|
expect(jellyfinTitle('The Pilot', 'Show - S01E02'), ('Show', 'S1 E2 · The Pilot'));
|
||||||
|
expect(jellyfinTitle('Ep', 'Re:Zero - Starting Life · S2E10'), ('Re:Zero - Starting Life', 'S2 E10 · Ep'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('movies and partial subtitles', () {
|
||||||
|
expect(jellyfinTitle('Movie', '2019'), ('Movie', null));
|
||||||
|
expect(jellyfinTitle('Movie', null), ('Movie', null));
|
||||||
|
expect(jellyfinTitle('Pilot', 'S1E1'), ('Pilot', 'S1 E1'));
|
||||||
|
expect(jellyfinTitle('Special', 'Show'), ('Show', 'Special'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('fileTitle', () {
|
||||||
|
test('release names', () {
|
||||||
|
expect(fileTitle('[SubsPlease] Frieren - 05 (1080p) [ABCD1234].mkv'), ('Frieren', 'E5'));
|
||||||
|
expect(fileTitle('Show.Name.S01E03.1080p.WEB-DL.x264-GRP.mkv'), ('Show Name', 'S1 E3'));
|
||||||
|
expect(fileTitle('[Grp] Show - NCOP1 [1080p].mkv'), ('Show', 'NCOP1'));
|
||||||
|
expect(fileTitle('Some Movie (2019).mkv'), ('Some Movie', null));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nothing sensible: the raw name minus extension', () {
|
||||||
|
expect(fileTitle('[1080p].mkv').$1, '[1080p]');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nowPlayingOf: media map with ref and art', () {
|
||||||
|
expect(nowPlayingOf(null), isNull);
|
||||||
|
expect(
|
||||||
|
nowPlayingOf({
|
||||||
|
'name': 'The Pilot',
|
||||||
|
'jellyfin': {'serverId': 's', 'itemId': 'i', 'title': 'The Pilot', 'subtitle': 'Show · S1E2'},
|
||||||
|
'art': {'poster': 'aaaa', 'banner': 'bbbb'},
|
||||||
|
}),
|
||||||
|
const NowPlaying(title: 'Show', episode: 'S1 E2 · The Pilot', posterHash: 'aaaa', bannerHash: 'bbbb'));
|
||||||
|
expect(nowPlayingOf({'name': 'Show - 12.mkv'}), const NowPlaying(title: 'Show', episode: 'E12'));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:sync_relay_core/core.dart';
|
||||||
|
import 'package:watch_together/src/controls/uosc/buttons.dart';
|
||||||
|
import 'package:watch_together/src/controls/uosc/controls.dart';
|
||||||
|
import 'package:watch_together/src/controls/uosc/timeline.dart';
|
||||||
|
import 'package:watch_together/src/session/health.dart';
|
||||||
|
import 'package:watch_together/src/session/now_playing.dart';
|
||||||
|
import 'package:watch_together/src/shell/now_playing_title.dart';
|
||||||
|
import 'package:watch_together/src/shell/player_status.dart';
|
||||||
|
import 'package:watch_together/src/shell/session_player.dart';
|
||||||
|
import 'package:watch_together/src/shell/start_banner.dart';
|
||||||
|
import 'package:watch_together/src/state/session_state.dart';
|
||||||
|
|
||||||
|
// The player's title block, start card and status pill (never a media_kit
|
||||||
|
// `Video`).
|
||||||
|
|
||||||
|
/// A 1x1 transparent PNG.
|
||||||
|
final png = Uint8List.fromList(const [
|
||||||
|
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, //
|
||||||
|
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4,
|
||||||
|
0x89, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00,
|
||||||
|
0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
|
||||||
|
0x42, 0x60, 0x82,
|
||||||
|
]);
|
||||||
|
|
||||||
|
MemberView mv(String id, String name, {bool ready = false, bool? inSet, double buffered = 30, bool stale = false}) =>
|
||||||
|
MemberView(id: id, name: name, ready: ready, inSet: inSet ?? ready, buffered: buffered, stale: stale);
|
||||||
|
|
||||||
|
StateBroadcast held(List<MemberView> members, {String transport = 'held', double position = 0, int mediaId = 1}) =>
|
||||||
|
StateBroadcast(
|
||||||
|
eventId: 1,
|
||||||
|
transport: transport,
|
||||||
|
position: position,
|
||||||
|
serverTime: DateTime.now().toUtc(),
|
||||||
|
mediaId: mediaId,
|
||||||
|
media: {'name': 'Movie.mkv', 'size': 1, 'duration': 0},
|
||||||
|
members: members,
|
||||||
|
);
|
||||||
|
|
||||||
|
const ok = HealthStatus(HealthSeverity.ok, 'connected');
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final coord = SessionState.instance.coordinator;
|
||||||
|
|
||||||
|
tearDown(() {
|
||||||
|
debugArtBytes = null;
|
||||||
|
coord.serverState.value = null;
|
||||||
|
coord.health.value = const HealthStatus(HealthSeverity.info, 'idle');
|
||||||
|
coord.playback.alive.value = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
group('status', () {
|
||||||
|
final me = mv('me', 'Me', ready: true);
|
||||||
|
|
||||||
|
test('problems first: removed, reconnecting, health', () {
|
||||||
|
var v = playerStatusFor(
|
||||||
|
health: const HealthStatus(HealthSeverity.error, 'kicked: spam'),
|
||||||
|
kickReason: 'spam',
|
||||||
|
reconnecting: true,
|
||||||
|
state: held([me]));
|
||||||
|
expect(v!.kind, StatusKind.kicked);
|
||||||
|
expect(v.text, 'You were removed: spam');
|
||||||
|
expect(v.tone, HealthSeverity.error);
|
||||||
|
expect(v.action, isNull);
|
||||||
|
v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'connection lost — retrying…'),
|
||||||
|
reconnecting: true);
|
||||||
|
expect(v!.text, 'Reconnecting…');
|
||||||
|
expect(v.action, StatusAction.cancel);
|
||||||
|
v = playerStatusFor(health: const HealthStatus(HealthSeverity.warn, 'waiting for Bob to pass the video on'),
|
||||||
|
state: held([me]), self: me);
|
||||||
|
expect(v!.kind, StatusKind.problem);
|
||||||
|
expect(v.text, 'Waiting for Bob to pass the video on');
|
||||||
|
expect(v.tone, HealthSeverity.warn);
|
||||||
|
expect(v.action, StatusAction.retry);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('info never shows; connecting only once slow', () {
|
||||||
|
expect(playerStatusFor(health: const HealthStatus(HealthSeverity.info, 'connected — waiting for host media…')),
|
||||||
|
isNull);
|
||||||
|
const connecting = HealthStatus(HealthSeverity.info, 'connecting…');
|
||||||
|
expect(playerStatusFor(health: connecting), isNull);
|
||||||
|
expect(playerStatusFor(health: connecting, slowConnect: true)!.text, 'Connecting…');
|
||||||
|
expect(playerStatusFor(health: ok, state: held([me], transport: 'playing'), self: me), isNull);
|
||||||
|
expect(playerStatusFor(health: ok, state: held([me]), self: me), isNull, reason: 'everyone ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finished, then whoever is loading', () {
|
||||||
|
expect(playerStatusFor(health: ok, state: held([me], transport: 'finished'), self: me)!.text,
|
||||||
|
'Finished · seek back or pick the next video');
|
||||||
|
final slow = mv('b', 'Bob', ready: true, buffered: RelayConstants.minBufferSecs / 2);
|
||||||
|
final v = playerStatusFor(health: ok, state: held([me, slow, mv('c', 'Cleo')]), self: me)!;
|
||||||
|
expect(v.kind, StatusKind.loading);
|
||||||
|
expect(v.text, 'Bob is loading…');
|
||||||
|
expect(v.who.single.id, 'b');
|
||||||
|
expect(v.progress, closeTo(0.5, 1e-9));
|
||||||
|
final mine = mv('me', 'Me', ready: true, buffered: 0);
|
||||||
|
expect(playerStatusFor(health: ok, state: held([mine]), self: mine)!.text, 'Loading the video…');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('you, then the others with a ready count', () {
|
||||||
|
final notMe = mv('me', 'Me');
|
||||||
|
final v = playerStatusFor(health: ok, state: held([notMe, mv('b', 'Bob')]), self: notMe)!;
|
||||||
|
expect(v.kind, StatusKind.you);
|
||||||
|
expect(v.text, "Press Space when you're ready");
|
||||||
|
expect(v.action, StatusAction.ready);
|
||||||
|
expect(v.self, isTrue);
|
||||||
|
final w = playerStatusFor(
|
||||||
|
health: ok,
|
||||||
|
state: held([me, mv('b', 'Bob'), mv('m', 'Mika'), mv('z', 'Zed', stale: true)]),
|
||||||
|
self: me)!;
|
||||||
|
expect(w.kind, StatusKind.waiting);
|
||||||
|
expect(w.text, 'Waiting for Bob and Mika');
|
||||||
|
expect(w.detail, '1/3 ready');
|
||||||
|
expect(w.who.map((m) => m.name), ['Bob', 'Mika']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('names read as a sentence', () {
|
||||||
|
expect(nameList(['Bob']), 'Bob');
|
||||||
|
expect(nameList(['Bob', 'Mika']), 'Bob and Mika');
|
||||||
|
expect(nameList(['Bob', 'Mika', 'Cleo']), 'Bob, Mika and Cleo');
|
||||||
|
expect(nameList(['Bob', 'Mika', 'Cleo', 'Dan', 'Eve']), 'Bob, Mika and 3 others');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('title block', () {
|
||||||
|
Widget host(Widget child, {double width = 400}) => MaterialApp(
|
||||||
|
home: Scaffold(backgroundColor: Colors.black, body: Center(child: SizedBox(width: width, child: child))),
|
||||||
|
);
|
||||||
|
const now = NowPlaying(title: 'Frieren', episode: 'S1 E4 · The Land Where Souls Rest', posterHash: 'p');
|
||||||
|
|
||||||
|
testWidgets('title, episode and a placeholder cover without art', (tester) async {
|
||||||
|
await tester.pumpWidget(host(const NowPlayingBlock(now: now)));
|
||||||
|
expect(find.text('Frieren'), findsOneWidget);
|
||||||
|
expect(find.text('S1 E4 · The Land Where Souls Rest'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.movie_outlined), findsOneWidget);
|
||||||
|
expect(find.byType(Image), findsNothing);
|
||||||
|
final cover = tester.getSize(find.byType(ArtCover));
|
||||||
|
expect(cover, const Size(40, 60));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('the cover image once its bytes are here', (tester) async {
|
||||||
|
debugArtBytes = (hash) => hash == 'p' ? png : null;
|
||||||
|
await tester.pumpWidget(host(const NowPlayingBlock(now: now)));
|
||||||
|
final image = tester.widget<Image>(find.byType(Image));
|
||||||
|
expect((image.image as MemoryImage).bytes, png);
|
||||||
|
expect(image.gaplessPlayback, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('no cover, no episode: just the title; long titles ellipsize', (tester) async {
|
||||||
|
await tester.pumpWidget(host(
|
||||||
|
NowPlayingBlock(now: NowPlaying(title: 'A very long title ' * 10), cover: false, compact: true),
|
||||||
|
width: 200));
|
||||||
|
expect(find.byType(ArtCover), findsNothing);
|
||||||
|
expect(find.byKey(const ValueKey('now-playing-episode')), findsNothing);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('a new media fades through', (tester) async {
|
||||||
|
await tester.pumpWidget(host(const NowPlayingBlock(now: now)));
|
||||||
|
await tester.pumpWidget(host(const NowPlayingBlock(now: NowPlaying(title: 'Dune'))));
|
||||||
|
await tester.pump(const Duration(milliseconds: 60));
|
||||||
|
expect(find.text('Frieren'), findsOneWidget, reason: 'still fading out');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('Frieren'), findsNothing);
|
||||||
|
expect(find.text('Dune'), findsOneWidget);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('start banner: up before playback, fades on play, never back for that media', (tester) async {
|
||||||
|
await tester.pumpWidget(const MaterialApp(home: SizedBox(width: 1280, height: 720, child: StartBanner())));
|
||||||
|
final card = find.byKey(const ValueKey('start-card-title'));
|
||||||
|
expect(card, findsNothing);
|
||||||
|
coord.serverState.value = held([mv('a', 'Alex')]);
|
||||||
|
await tester.pump();
|
||||||
|
expect(card, findsOneWidget);
|
||||||
|
expect(find.text(coord.nowPlaying.value!.title), findsWidgets);
|
||||||
|
expect(tester.widget<AnimatedOpacity>(find.byKey(const ValueKey('start-banner'))).opacity, 1);
|
||||||
|
coord.serverState.value = held([mv('a', 'Alex', ready: true)], transport: 'playing');
|
||||||
|
await tester.pump();
|
||||||
|
expect(tester.widget<AnimatedOpacity>(find.byKey(const ValueKey('start-banner'))).opacity, 0);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(card, findsNothing);
|
||||||
|
// Paused back at the start: still gone.
|
||||||
|
coord.serverState.value = held([mv('a', 'Alex')]);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(card, findsNothing);
|
||||||
|
// The next media gets its own card.
|
||||||
|
coord.serverState.value = held([mv('a', 'Alex')], mediaId: 2);
|
||||||
|
await tester.pump();
|
||||||
|
expect(card, findsOneWidget);
|
||||||
|
// Joined mid-way: none.
|
||||||
|
coord.serverState.value = held([mv('a', 'Alex')], mediaId: 3, position: 600);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(card, findsNothing);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('no overflow', () {
|
||||||
|
const sizes = [Size(900, 600), Size(2560, 1080)];
|
||||||
|
final long = NowPlaying(title: 'An Extremely Long Series Title ' * 4, episode: 'S12 E104 · ${'Episode ' * 20}',
|
||||||
|
posterHash: 'p', bannerHash: 'b');
|
||||||
|
final crowd = [
|
||||||
|
for (var i = 0; i < 9; i++) mv('$i', 'Someone With A Long Name $i'),
|
||||||
|
];
|
||||||
|
|
||||||
|
Future<void> sized(WidgetTester tester, Size size, Widget child) async {
|
||||||
|
tester.view.physicalSize = size;
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.reset);
|
||||||
|
await tester.pumpWidget(MaterialApp(theme: ThemeData.dark(), home: Scaffold(body: child)));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final size in sizes) {
|
||||||
|
testWidgets('session player, status pill: ${size.width.toInt()}', (tester) async {
|
||||||
|
coord.playback.alive.value = true;
|
||||||
|
coord.serverState.value = held(crowd);
|
||||||
|
await sized(
|
||||||
|
tester,
|
||||||
|
size,
|
||||||
|
SessionPlayer(
|
||||||
|
mode: PlayerMode.full,
|
||||||
|
fullscreen: false,
|
||||||
|
onExpand: () {},
|
||||||
|
onCollapse: () {},
|
||||||
|
onClose: () {},
|
||||||
|
onToggleFullscreen: () {},
|
||||||
|
onToggleChat: () {},
|
||||||
|
onChangeVideo: () {},
|
||||||
|
onOpenIntegrations: () {},
|
||||||
|
onLeave: () {},
|
||||||
|
windowControls: false,
|
||||||
|
videoBuilder: (context, controls) => const ColoredBox(color: Colors.black),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.byKey(const ValueKey('player-status')), findsOneWidget);
|
||||||
|
expect(find.textContaining('Waiting for Someone With A Long Name 0'), findsOneWidget);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('uosc top bar and start card: ${size.width.toInt()}', (tester) async {
|
||||||
|
debugArtBytes = (hash) => png;
|
||||||
|
await sized(
|
||||||
|
tester,
|
||||||
|
size,
|
||||||
|
Stack(fit: StackFit.expand, children: [
|
||||||
|
StartCard(now: long),
|
||||||
|
UoscControls(
|
||||||
|
topLeft: NowPlayingBlock(now: long),
|
||||||
|
top: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
for (final i in [Icons.chat_bubble_outline, Icons.fullscreen, Icons.close])
|
||||||
|
UoscButton(icon: i, tooltip: '', onPressed: () {}),
|
||||||
|
]),
|
||||||
|
timeline: (context, expanded) =>
|
||||||
|
UoscTimeline(position: Duration.zero, duration: const Duration(minutes: 24), expanded: expanded, onSeek: (_) {}),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(find.byType(ArtCover), findsNWidgets(2));
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('standard top bar row: ${size.width.toInt()}', (tester) async {
|
||||||
|
await sized(
|
||||||
|
tester,
|
||||||
|
size,
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 72,
|
||||||
|
child: Row(children: [
|
||||||
|
Expanded(child: NowPlayingBlock(now: long)),
|
||||||
|
for (final i in [Icons.video_library_outlined, Icons.chat_bubble_outline, Icons.close_fullscreen])
|
||||||
|
IconButton(onPressed: () {}, icon: Icon(i)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:watch_together/src/schedule/anilist.dart';
|
import 'package:watch_together/src/schedule/anilist.dart';
|
||||||
|
import 'package:watch_together/src/session/media_art.dart';
|
||||||
|
|
||||||
import 'schedule_fake_server.dart';
|
import 'schedule_fake_server.dart';
|
||||||
|
|
||||||
@@ -163,6 +165,52 @@ void main() {
|
|||||||
await expectLater(client().schedule(from, to), throwsA(isA<AniListException>()));
|
await expectLater(client().schedule(from, to), throwsA(isA<AniListException>()));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('searchMedia: one request per title, 404 is no match, art via aniListArt', () async {
|
||||||
|
server.handler = (req, res) async {
|
||||||
|
final search = ((jsonDecode(req.body) as Map)['variables'] as Map)['search'];
|
||||||
|
if (search != 'Frieren') {
|
||||||
|
json(res, {'errors': [{'message': 'Not Found.', 'status': 404}], 'data': {'Media': null}}, status: 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
json(res, {
|
||||||
|
'data': {
|
||||||
|
'Media': {
|
||||||
|
'id': 154587,
|
||||||
|
'title': {'romaji': 'Sousou no Frieren', 'english': 'Frieren: Beyond Journey\'s End'},
|
||||||
|
'coverImage': {'extraLarge': 'https://img/xl.jpg', 'large': 'https://img/l.jpg'},
|
||||||
|
'bannerImage': 'https://img/banner.jpg',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
final c = client();
|
||||||
|
final m = await c.searchMedia('Frieren');
|
||||||
|
expect(m!.id, 154587);
|
||||||
|
expect(m.title, "Frieren: Beyond Journey's End");
|
||||||
|
expect(m.cover, 'https://img/xl.jpg');
|
||||||
|
expect(m.coverSmall, 'https://img/l.jpg');
|
||||||
|
expect(m.banner, 'https://img/banner.jpg');
|
||||||
|
await c.searchMedia('Frieren');
|
||||||
|
expect(server.seen, hasLength(1), reason: 'cached per title');
|
||||||
|
expect(await c.searchMedia('Nothing Like It'), isNull);
|
||||||
|
expect(await c.searchMedia(' '), isNull);
|
||||||
|
expect(server.seen, hasLength(2));
|
||||||
|
|
||||||
|
// Files: the release name's series; the extraLarge cover too big falls
|
||||||
|
// back to large.
|
||||||
|
final jpeg = Uint8List.fromList([0xFF, 0xD8, 0xFF, 1, 2, 3]);
|
||||||
|
final asked = <String>[];
|
||||||
|
final art = await aniListArt(c, '[SubsPlease] Frieren - 05 (1080p) [ABCD1234].mkv', fetch: (url, headers, max) async {
|
||||||
|
asked.add('$url');
|
||||||
|
return url.path == '/xl.jpg' ? null : jpeg;
|
||||||
|
});
|
||||||
|
expect(art!.poster, jpeg);
|
||||||
|
expect(art.banner, jpeg);
|
||||||
|
expect(asked, unorderedEquals(['https://img/xl.jpg', 'https://img/banner.jpg', 'https://img/l.jpg']));
|
||||||
|
expect(await aniListArt(c, 'Nothing Like It.mkv', fetch: (_, _, _) async => jpeg), isNull);
|
||||||
|
c.close();
|
||||||
|
});
|
||||||
|
|
||||||
test('parseHexColor', () {
|
test('parseHexColor', () {
|
||||||
expect(parseHexColor('#000000')?.toARGB32(), 0xff000000);
|
expect(parseHexColor('#000000')?.toARGB32(), 0xff000000);
|
||||||
expect(parseHexColor('e4a15d'), isNull);
|
expect(parseHexColor('e4a15d'), isNull);
|
||||||
|
|||||||
@@ -290,12 +290,13 @@ void main() {
|
|||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('waiting pill: who is not ready, or you', (tester) async {
|
testWidgets('status pill: who is not ready, with the ready count', (tester) async {
|
||||||
coord.playback.alive.value = true;
|
coord.playback.alive.value = true;
|
||||||
await pumpShell(tester, const Size(1280, 800));
|
await pumpShell(tester, const Size(1280, 800));
|
||||||
online([member('a', 'Alex', ready: true), member('b', 'Bob'), member('c', 'Cleo')]);
|
online([member('a', 'Alex', ready: true), member('b', 'Bob'), member('c', 'Cleo')]);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.text('Waiting for Bob, Cleo'), findsOneWidget);
|
expect(find.text('Waiting for Bob and Cleo'), findsOneWidget);
|
||||||
|
expect(find.text('1/3 ready'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('toasts: feed toasts carry the actor, the rest are the app\'s', () {
|
test('toasts: feed toasts carry the actor, the rest are the app\'s', () {
|
||||||
|
|||||||
+26
-5
@@ -604,9 +604,22 @@ sideways until an end, then the page).
|
|||||||
The player keys (Settings → Keyboard shortcuts) work while the player is
|
The player keys (Settings → Keyboard shortcuts) work while the player is
|
||||||
full and nothing is in front of it. Something coming up while the player
|
full and nothing is in front of it. Something coming up while the player
|
||||||
is closed opens it: full right after joining or after putting it up
|
is closed opens it: full right after joining or after putting it up
|
||||||
here, else mini. While held, a pill under the picture names who
|
here, else mini. Both control designs show `NowPlayingTitle` top left
|
||||||
everyone waits for, or says "Waiting for you · press Space" (a click
|
(`now_playing_title.dart`: 2:3 cover, title, episode line from
|
||||||
marks you ready); the mini shows it in its hover bar. ⚙ in both
|
`coordinator.nowPlaying`, art through `artBytes`; it fades through when
|
||||||
|
the media changes); the mini shows title and episode without the cover.
|
||||||
|
Until playback first starts on a media (server not playing, position
|
||||||
|
under 1 s) `StartBanner` (`start_banner.dart`) lies under the controls:
|
||||||
|
the banner (else a blur of the cover, else dark), darkened, with cover,
|
||||||
|
big title and episode; it fades out on play and stays gone for that
|
||||||
|
media. One status pill (`player_status.dart`) sits bottom centre above
|
||||||
|
the controls: `playerStatusFor` (pure) picks the most pressing of
|
||||||
|
removed, reconnecting (Cancel), a warn/error health message (Retry),
|
||||||
|
connecting (only after 2 s), finished, someone loading (their avatar
|
||||||
|
and buffer progress), you not ready (a click marks you ready) and who
|
||||||
|
everyone waits for (avatars, "2/3 ready"); nothing otherwise, info
|
||||||
|
never. Frosted glass, colour only for problems; it fades through
|
||||||
|
between kinds. The mini shows its short line in its hover bar. ⚙ in both
|
||||||
control designs opens the player settings card (`controls/
|
control designs opens the player settings card (`controls/
|
||||||
player_settings.dart`, `showPopover`): player design, progress line
|
player_settings.dart`, `showPopover`): player design, progress line
|
||||||
when hidden, upscaling shader, hardware decoding (set live on mpv's
|
when hidden, upscaling shader, hardware decoding (set live on mpv's
|
||||||
@@ -686,7 +699,7 @@ Session controls (the Standard player design): media_kit's
|
|||||||
`screens/session/player_controls.dart`); its own double-click and keys are
|
`screens/session/player_controls.dart`); its own double-click and keys are
|
||||||
off (its double-click pushes a fullscreen route with a second `Video`), the
|
off (its double-click pushes a fullscreen route with a second `Video`), the
|
||||||
picture's double-click is a raw pointer observer (`DoubleClickListener`).
|
picture's double-click is a raw pointer observer (`DoubleClickListener`).
|
||||||
The top bar holds the title, Change video, chat and back-to-library
|
The top bar holds the now-playing title, Change video, chat and back-to-library
|
||||||
(fullscreen: exit, then the window buttons); the bottom bar ends with the
|
(fullscreen: exit, then the window buttons); the bottom bar ends with the
|
||||||
shader button, ⚙ and fullscreen. Its seek bar is off; ours
|
shader button, ⚙ and fullscreen. Its seek bar is off; ours
|
||||||
(`ChapterSeekBar`: chapter diamonds, hover time with the chapter title)
|
(`ChapterSeekBar`: chapter diamonds, hover time with the chapter title)
|
||||||
@@ -714,7 +727,8 @@ change video, chat & activity, reload, mark everyone ready, fullscreen,
|
|||||||
leave),
|
leave),
|
||||||
subtitles and audio with track counts, a shader badge ("A HQ", opening the
|
subtitles and audio with track counts, a shader badge ("A HQ", opening the
|
||||||
shared `showShaderMenu`), ready, mark everyone ready, reload; 1:30,
|
shared `showShaderMenu`), ready, mark everyone ready, reload; 1:30,
|
||||||
chapters, previous / next episode, change video, volume, ⚙, fullscreen; chat
|
chapters, previous / next episode, change video, volume, ⚙, fullscreen; the
|
||||||
|
now-playing title top left (`UoscControls.topLeft`, with a top gradient), chat
|
||||||
and back-to-library (fullscreen: exit) top right. Menus (`showUoscMenu`: a popup route above the button, the
|
and back-to-library (fullscreen: exit) top right. Menus (`showUoscMenu`: a popup route above the button, the
|
||||||
current row highlighted) list tracks and chapters with start times. All
|
current row highlighted) list tracks and chapters with start times. All
|
||||||
actions are the coordinator's (seeks through `player.seek`, the user path;
|
actions are the coordinator's (seeks through `player.seek`, the user path;
|
||||||
@@ -791,3 +805,10 @@ this repo, written by `tool/release.sh`.
|
|||||||
the bytes of current members in memory and serves them at `/a/...`, which
|
the bytes of current members in memory and serves them at `/a/...`, which
|
||||||
clients fetch once per hash (`AvatarCache`). Uplink per broadcast stays
|
clients fetch once per hash (`AvatarCache`). Uplink per broadcast stays
|
||||||
small whatever the picture size (cap 256 KiB base64 per avatar).
|
small whatever the picture size (cap 256 KiB base64 per avatar).
|
||||||
|
- Media art the same way (`/i/...`, `ArtCache` as `coordinator.art`): the
|
||||||
|
member who swapped the media in fetches poster and banner once the state
|
||||||
|
shows its swap landed (`media_art.dart`: Jellyfin series/item images with
|
||||||
|
its own account, else an AniList title search on the parsed release
|
||||||
|
name) and sends `art`, so guests without Jellyfin get covers too.
|
||||||
|
`coordinator.nowPlaying` derives title, episode line and art hashes from
|
||||||
|
`state.media` (`now_playing_info.dart`).
|
||||||
+28
-3
@@ -24,7 +24,8 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
|
|||||||
`GET /j/<sid>/<token>/<itemId>` relays the current Jellyfin media (see
|
`GET /j/<sid>/<token>/<itemId>` relays the current Jellyfin media (see
|
||||||
"Jellyfin media" below). `/r/<sid>/<token>/...` is the relay of a
|
"Jellyfin media" below). `/r/<sid>/<token>/...` is the relay of a
|
||||||
member's media (see "Relay" below). `GET /a/<sid>/<token>/<hash>` serves
|
member's media (see "Relay" below). `GET /a/<sid>/<token>/<hash>` serves
|
||||||
a member's picture (see "Avatars" below).
|
a member's picture (see "Avatars" below), `GET /i/<sid>/<token>/<hash>`
|
||||||
|
the current media's art (see "Media art" below).
|
||||||
|
|
||||||
## Client to server
|
## Client to server
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ with different `proto` refuse each other at `hello` (`proto mismatch`).
|
|||||||
{"t":"chat","text":"..."}
|
{"t":"chat","text":"..."}
|
||||||
{"t":"kick","target":"<memberId>","reason":""}
|
{"t":"kick","target":"<memberId>","reason":""}
|
||||||
{"t":"profile","name":"...","avatar":"<base64>"}
|
{"t":"profile","name":"...","avatar":"<base64>"}
|
||||||
|
{"t":"art","mediaId":2,"poster":"<base64>","banner":"<base64>"}
|
||||||
```
|
```
|
||||||
|
|
||||||
Field notes:
|
Field notes:
|
||||||
@@ -77,6 +79,7 @@ Field notes:
|
|||||||
that is absent (or null) stays as it is; `avatar: ""` removes the picture.
|
that is absent (or null) stays as it is; `avatar: ""` removes the picture.
|
||||||
Same rules as `hello.user`; a too large avatar gets `err`
|
Same rules as `hello.user`; a too large avatar gets `err`
|
||||||
`rejected: avatar too large` (the socket stays open, nothing changes).
|
`rejected: avatar too large` (the socket stays open, nothing changes).
|
||||||
|
- `art`: pictures of the current media (see "Media art").
|
||||||
- `chat.text` is cut to 500 characters.
|
- `chat.text` is cut to 500 characters.
|
||||||
- `swap.media.url` is the link; `name`, `size`, `duration` are informational.
|
- `swap.media.url` is the link; `name`, `size`, `duration` are informational.
|
||||||
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
|
`swap.media.jellyfin` makes it Jellyfin media (see below); then `url` is
|
||||||
@@ -91,7 +94,7 @@ Field notes:
|
|||||||
"state":{<state>}}
|
"state":{<state>}}
|
||||||
{"t":"state","eventId":7,"transport":"playing"|"held"|"finished","position":12.3,
|
{"t":"state","eventId":7,"transport":"playing"|"held"|"finished","position":12.3,
|
||||||
"serverTime":"<ISO-8601 UTC>","mediaId":1,
|
"serverTime":"<ISO-8601 UTC>","mediaId":1,
|
||||||
"media":{name,size,duration,url?,jellyfin?}|absent,
|
"media":{name,size,duration,url?,jellyfin?,relay?,swapBy?,art?}|absent,
|
||||||
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?,
|
"members":[{"id","name","avatarHash"?,"ready","inSet","buffered","stale","jf"?,
|
||||||
"host"?}],
|
"host"?}],
|
||||||
"seekBy":"m1","seekSeq":3,"relay":true}
|
"seekBy":"m1","seekSeq":3,"relay":true}
|
||||||
@@ -116,7 +119,9 @@ Field notes:
|
|||||||
`eventId` increases on seek, swap and transport changes.
|
`eventId` increases on seek, swap and transport changes.
|
||||||
`mediaId` increases on swap. `media.relay` is present for relayed media
|
`mediaId` increases on swap. `media.relay` is present for relayed media
|
||||||
(see "Relay"). `media.duration` is 0 until a client reports
|
(see "Relay"). `media.duration` is 0 until a client reports
|
||||||
it. `seekBy` (member id) and `seekSeq` are present only while the current
|
it. `media.swapBy`: member id of who swapped it in (omitted once that
|
||||||
|
member left, and for the media the server started with). `media.art`:
|
||||||
|
see "Media art". `seekBy` (member id) and `seekSeq` are present only while the current
|
||||||
`eventId` came from a seek. `relay` (omitted when false): the server can
|
`eventId` came from a seek. `relay` (omitted when false): the server can
|
||||||
relay the current Jellyfin media. `members[].jf` is the member's last
|
relay the current Jellyfin media. `members[].jf` is the member's last
|
||||||
`report.jf`, omitted when it never sent one. `members[].avatarHash`
|
`report.jf`, omitted when it never sent one. `members[].avatarHash`
|
||||||
@@ -150,6 +155,26 @@ Field notes:
|
|||||||
picture, 410 unknown session. Pictures go with their member (leave,
|
picture, 410 unknown session. Pictures go with their member (leave,
|
||||||
change, removal).
|
change, removal).
|
||||||
|
|
||||||
|
## Media art
|
||||||
|
|
||||||
|
```
|
||||||
|
client {"t":"art","mediaId":2,"poster":"<base64>","banner":"<base64>"}
|
||||||
|
state media.art = {"poster":"<hash>","banner":"<hash>"}
|
||||||
|
```
|
||||||
|
|
||||||
|
- A portrait poster and a wide banner for the current media, so every
|
||||||
|
member (guests without Jellyfin included) can show them. Sent by the
|
||||||
|
member who made the current swap (`media.swapBy`) or the host, for the
|
||||||
|
current `mediaId`; from anyone else, or for another `mediaId`, it is
|
||||||
|
ignored. Each field is optional; one that is not a picture (same check
|
||||||
|
as avatars) or larger than its cap (poster 256 KiB, banner 768 KiB of
|
||||||
|
decoded bytes) counts as none. A later `art` replaces the earlier one
|
||||||
|
whole; any swap clears it.
|
||||||
|
- `media.art` carries hashes like `avatarHash`; a key is omitted when that
|
||||||
|
picture is absent, `art` when both are.
|
||||||
|
- `GET /i/<sid>/<token>/<hash>`: as `/a/` (see "Avatars"); 404 when the
|
||||||
|
current media has no such art.
|
||||||
|
|
||||||
## Jellyfin media
|
## Jellyfin media
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ abstract class RelayConstants {
|
|||||||
// over-size for private-server use. PNG keeps transparency; JPEG did
|
// over-size for private-server use. PNG keeps transparency; JPEG did
|
||||||
// not. Only the hash rides in `state`; the bytes are fetched once.
|
// not. Only the hash rides in `state`; the bytes are fetched once.
|
||||||
static const int maxAvatarBytes = 256 * 1024;
|
static const int maxAvatarBytes = 256 * 1024;
|
||||||
|
/// Decoded-byte caps of the current media's art (`art` message).
|
||||||
|
static const int maxPosterBytes = 256 * 1024;
|
||||||
|
static const int maxBannerBytes = 768 * 1024;
|
||||||
static const int maxNameLength = 32;
|
static const int maxNameLength = 32;
|
||||||
static const int maxChatLength = 500;
|
static const int maxChatLength = 500;
|
||||||
}
|
}
|
||||||
@@ -132,7 +132,8 @@ enum JellyfinImageType {
|
|||||||
primary('Primary'),
|
primary('Primary'),
|
||||||
backdrop('Backdrop'),
|
backdrop('Backdrop'),
|
||||||
thumb('Thumb'),
|
thumb('Thumb'),
|
||||||
logo('Logo');
|
logo('Logo'),
|
||||||
|
banner('Banner');
|
||||||
|
|
||||||
final String apiName;
|
final String apiName;
|
||||||
const JellyfinImageType(this.apiName);
|
const JellyfinImageType(this.apiName);
|
||||||
@@ -361,6 +362,8 @@ class JellyfinItem {
|
|||||||
if (parentLogoItemId != null && parentLogoImageTag != null) {
|
if (parentLogoItemId != null && parentLogoImageTag != null) {
|
||||||
return JellyfinImageRef(parentLogoItemId!, type, parentLogoImageTag!);
|
return JellyfinImageRef(parentLogoItemId!, type, parentLogoImageTag!);
|
||||||
}
|
}
|
||||||
|
case JellyfinImageType.banner:
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -478,6 +478,32 @@ class ProfileIntent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover art for the current media, sent by the member who swapped it in
|
||||||
|
/// (or the host). Base64 picture bytes; null = none.
|
||||||
|
class ArtMessage {
|
||||||
|
final int mediaId;
|
||||||
|
final String? poster;
|
||||||
|
final String? banner;
|
||||||
|
const ArtMessage({required this.mediaId, this.poster, this.banner});
|
||||||
|
|
||||||
|
Map<String, Object?> toJson() => {
|
||||||
|
't': 'art',
|
||||||
|
'mediaId': mediaId,
|
||||||
|
if (poster != null) 'poster': poster,
|
||||||
|
if (banner != null) 'banner': banner,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory ArtMessage.fromJson(Map<String, Object?> json) {
|
||||||
|
if (json['t'] != 'art') throw const ProtocolException('not an art message');
|
||||||
|
final poster = json['poster'];
|
||||||
|
final banner = json['banner'];
|
||||||
|
return ArtMessage(
|
||||||
|
mediaId: finiteInt(json['mediaId'], 'mediaId'),
|
||||||
|
poster: poster is String ? poster : null,
|
||||||
|
banner: banner is String ? banner : null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class MemberView {
|
class MemberView {
|
||||||
final String id;
|
final String id;
|
||||||
final String name;
|
final String name;
|
||||||
|
|||||||
@@ -97,6 +97,27 @@ class EngineMember {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Picture bytes from base64 when they decode, are at most [maxBytes] and
|
||||||
|
/// are a known image type (see [avatarImageType]); else null.
|
||||||
|
Uint8List? decodePicture(String base64, int maxBytes) {
|
||||||
|
// Base64 is 4 chars per 3 bytes: skip decoding what cannot fit.
|
||||||
|
if (base64.isEmpty || base64.length > (maxBytes + 2) ~/ 3 * 4) return null;
|
||||||
|
try {
|
||||||
|
final bytes = base64Decode(base64);
|
||||||
|
if (bytes.length > maxBytes || avatarImageType(bytes) == null) return null;
|
||||||
|
return bytes;
|
||||||
|
} on FormatException {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One picture of the current media's art, by content hash.
|
||||||
|
class ArtImage {
|
||||||
|
final String hash;
|
||||||
|
final Uint8List bytes;
|
||||||
|
ArtImage(this.bytes) : hash = avatarHashOf(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
class EngineFeed {
|
class EngineFeed {
|
||||||
final List<FeedMessage> pending = [];
|
final List<FeedMessage> pending = [];
|
||||||
void add(FeedMessage message) => pending.add(message);
|
void add(FeedMessage message) => pending.add(message);
|
||||||
@@ -120,6 +141,12 @@ class SyncEngine {
|
|||||||
DateTime serverTime;
|
DateTime serverTime;
|
||||||
int mediaId = 1;
|
int mediaId = 1;
|
||||||
HelloMedia? media;
|
HelloMedia? media;
|
||||||
|
/// Token of the member who made the current swap; null before any swap.
|
||||||
|
String? swapBy;
|
||||||
|
/// Art of the current media (`art`), served at `/i/...`; only the hashes
|
||||||
|
/// ride in `state.media.art`. Every swap clears it.
|
||||||
|
ArtImage? poster;
|
||||||
|
ArtImage? banner;
|
||||||
int _nextId = 1;
|
int _nextId = 1;
|
||||||
// Who sent the seek that produced [_seekEventId], for the echo check.
|
// Who sent the seek that produced [_seekEventId], for the echo check.
|
||||||
String? _seekBy;
|
String? _seekBy;
|
||||||
@@ -207,6 +234,38 @@ class SyncEngine {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Art for the current media from [token]: only from the member who made
|
||||||
|
/// the current swap (or the host) and only for the current [forMediaId].
|
||||||
|
/// It replaces any earlier art; an absent or invalid field is none.
|
||||||
|
/// Returns whether it was taken.
|
||||||
|
bool applyArt(String token, int forMediaId, {String? poster, String? banner}) {
|
||||||
|
final member = members[token];
|
||||||
|
if (member == null || media == null || forMediaId != mediaId) return false;
|
||||||
|
if (token != swapBy && !member.host) return false;
|
||||||
|
_seen(member);
|
||||||
|
final p = poster == null ? null : decodePicture(poster, RelayConstants.maxPosterBytes);
|
||||||
|
final b = banner == null ? null : decodePicture(banner, RelayConstants.maxBannerBytes);
|
||||||
|
this.poster = p == null ? null : ArtImage(p);
|
||||||
|
this.banner = b == null ? null : ArtImage(b);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes of the current media's art by hash.
|
||||||
|
Uint8List? artFor(String hash) {
|
||||||
|
if (hash.isEmpty) return null;
|
||||||
|
if (poster?.hash == hash) return poster!.bytes;
|
||||||
|
if (banner?.hash == hash) return banner!.bytes;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `state.media.art`; null when there is none.
|
||||||
|
Map<String, Object?>? get artJson => poster == null && banner == null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
if (poster != null) 'poster': poster!.hash,
|
||||||
|
if (banner != null) 'banner': banner!.hash,
|
||||||
|
};
|
||||||
|
|
||||||
/// A reconnect took over [token]'s member (see `hello.resume`).
|
/// A reconnect took over [token]'s member (see `hello.resume`).
|
||||||
void applyResume(String token) {
|
void applyResume(String token) {
|
||||||
final member = members[token];
|
final member = members[token];
|
||||||
@@ -301,6 +360,9 @@ class SyncEngine {
|
|||||||
final actor = members[actorToken];
|
final actor = members[actorToken];
|
||||||
mediaId++;
|
mediaId++;
|
||||||
media = next;
|
media = next;
|
||||||
|
swapBy = actorToken;
|
||||||
|
poster = null;
|
||||||
|
banner = null;
|
||||||
position = 0;
|
position = 0;
|
||||||
serverTime = now;
|
serverTime = now;
|
||||||
eventId++;
|
eventId++;
|
||||||
@@ -409,7 +471,13 @@ class SyncEngine {
|
|||||||
position: position,
|
position: position,
|
||||||
serverTime: serverTime,
|
serverTime: serverTime,
|
||||||
mediaId: mediaId,
|
mediaId: mediaId,
|
||||||
media: media?.toJson(),
|
media: media == null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
...media!.toJson(),
|
||||||
|
if (members[swapBy]?.id case final id?) 'swapBy': id,
|
||||||
|
if (artJson != null) 'art': artJson,
|
||||||
|
},
|
||||||
members: members.values.map((m) => m.view(stale: isStale(m))).toList(),
|
members: members.values.map((m) => m.view(stale: isStale(m))).toList(),
|
||||||
seekBy: bySeek ? _seekBy : null,
|
seekBy: bySeek ? _seekBy : null,
|
||||||
seekSeq: bySeek ? _seekSeq : null,
|
seekSeq: bySeek ? _seekSeq : null,
|
||||||
|
|||||||
@@ -606,4 +606,68 @@ void main() {
|
|||||||
expect(engine.members['a']!.avatarHash, isEmpty);
|
expect(engine.members['a']!.avatarHash, isEmpty);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('media art', () {
|
||||||
|
final jpeg = base64Encode([0xFF, 0xD8, 0xFF, ...List<int>.generate(61, (i) => i)]);
|
||||||
|
final png = base64Encode([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(60, (i) => 255 - i)]);
|
||||||
|
const media = HelloMedia(name: 'Show - 01.mkv', size: 1, duration: 0, url: 'http://x/a.mkv');
|
||||||
|
Map<String, Object?>? art() => engine.broadcast().media?['art'] as Map<String, Object?>?;
|
||||||
|
|
||||||
|
test('swapper sends art for the current media; state carries hashes only', () {
|
||||||
|
join('a', 'alice');
|
||||||
|
join('b', 'bob');
|
||||||
|
engine.applySwap('a', media);
|
||||||
|
expect(art(), isNull, reason: 'omitted when empty');
|
||||||
|
expect(engine.broadcast().media?['swapBy'], 'm1');
|
||||||
|
expect(engine.applyArt('a', engine.mediaId, poster: jpeg, banner: png), isTrue);
|
||||||
|
final hashes = art()!;
|
||||||
|
expect(hashes['poster'], avatarHashOf(base64Decode(jpeg)));
|
||||||
|
expect(hashes['banner'], avatarHashOf(base64Decode(png)));
|
||||||
|
expect(engine.artFor(hashes['poster'] as String), base64Decode(jpeg));
|
||||||
|
expect(engine.artFor('0123456789abcdef'), isNull);
|
||||||
|
expect(jsonEncode(engine.broadcast().toJson()), isNot(contains(jpeg)));
|
||||||
|
// A later art replaces it; a field left out is none.
|
||||||
|
expect(engine.applyArt('a', engine.mediaId, poster: png), isTrue);
|
||||||
|
expect(art(), {'poster': avatarHashOf(base64Decode(png))});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects other members, stale mediaId; host may send', () {
|
||||||
|
join('a', 'alice');
|
||||||
|
join('b', 'bob');
|
||||||
|
join('h', 'host');
|
||||||
|
engine.members['h']!.host = true;
|
||||||
|
expect(engine.applyArt('a', engine.mediaId, poster: jpeg), isFalse, reason: 'no media yet');
|
||||||
|
engine.applySwap('a', media);
|
||||||
|
final id = engine.mediaId;
|
||||||
|
expect(engine.applyArt('b', id, poster: jpeg), isFalse);
|
||||||
|
expect(engine.applyArt('a', id - 1, poster: jpeg), isFalse);
|
||||||
|
expect(art(), isNull);
|
||||||
|
expect(engine.applyArt('h', id, poster: jpeg), isTrue);
|
||||||
|
expect(art(), contains('poster'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bad bytes and oversize fields are dropped; any swap clears', () {
|
||||||
|
join('a', 'alice');
|
||||||
|
engine.applySwap('a', media);
|
||||||
|
final big = base64Encode([0xFF, 0xD8, 0xFF, ...List<int>.filled(RelayConstants.maxPosterBytes, 1)]);
|
||||||
|
engine.applyArt('a', engine.mediaId,
|
||||||
|
poster: big, banner: base64Encode(List<int>.generate(64, (i) => i)));
|
||||||
|
expect(art(), isNull);
|
||||||
|
engine.applyArt('a', engine.mediaId, poster: 'not base64!', banner: big);
|
||||||
|
expect(art(), {'banner': avatarHashOf(base64Decode(big))}, reason: 'banner cap is larger');
|
||||||
|
join('b', 'bob');
|
||||||
|
engine.applySwap('b', media);
|
||||||
|
expect(art(), isNull);
|
||||||
|
expect(engine.artFor(avatarHashOf(base64Decode(big))), isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ArtMessage round trip', () {
|
||||||
|
final m = ArtMessage.fromJson(Map<String, Object?>.from(
|
||||||
|
jsonDecode(jsonEncode(ArtMessage(mediaId: 3, poster: jpeg).toJson())) as Map));
|
||||||
|
expect(m.mediaId, 3);
|
||||||
|
expect(m.poster, jpeg);
|
||||||
|
expect(m.banner, isNull);
|
||||||
|
expect(const ArtMessage(mediaId: 1).toJson(), {'t': 'art', 'mediaId': 1});
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
import 'package:relic/relic.dart';
|
import 'package:relic/relic.dart';
|
||||||
import 'package:sync_relay_core/core.dart';
|
import 'package:sync_relay_core/core.dart';
|
||||||
@@ -162,9 +163,17 @@ class ServerHub {
|
|||||||
/// `GET /a/<sid>/<token>/<hash>`: a current member's picture by its
|
/// `GET /a/<sid>/<token>/<hash>`: a current member's picture by its
|
||||||
/// content hash, for members (valid, unkicked token) only. The bytes for a
|
/// content hash, for members (valid, unkicked token) only. The bytes for a
|
||||||
/// hash never change, so clients may cache them for good.
|
/// hash never change, so clients may cache them for good.
|
||||||
void mountAvatarRoute(RelicApp app, ServerHub hub) {
|
void mountAvatarRoute(RelicApp app, ServerHub hub) =>
|
||||||
|
_mountPictureRoute(app, hub, 'a', hub.engine.avatarFor);
|
||||||
|
|
||||||
|
/// `GET /i/<sid>/<token>/<hash>`: the current media's art (`art`) by hash,
|
||||||
|
/// same rules as [mountAvatarRoute].
|
||||||
|
void mountArtRoute(RelicApp app, ServerHub hub) =>
|
||||||
|
_mountPictureRoute(app, hub, 'i', hub.engine.artFor);
|
||||||
|
|
||||||
|
void _mountPictureRoute(RelicApp app, ServerHub hub, String prefix, Uint8List? Function(String hash) lookup) {
|
||||||
Response status(int code) => Response(code, body: Body.fromString('status $code'));
|
Response status(int code) => Response(code, body: Body.fromString('status $code'));
|
||||||
app.get('/a/:sid/:token/:hash', (final req) {
|
app.get('/$prefix/:sid/:token/:hash', (final req) {
|
||||||
final sid = req.pathParameters.raw[#sid] ?? '';
|
final sid = req.pathParameters.raw[#sid] ?? '';
|
||||||
final token = req.pathParameters.raw[#token] ?? '';
|
final token = req.pathParameters.raw[#token] ?? '';
|
||||||
final hash = req.pathParameters.raw[#hash] ?? '';
|
final hash = req.pathParameters.raw[#hash] ?? '';
|
||||||
@@ -178,7 +187,7 @@ void mountAvatarRoute(RelicApp app, ServerHub hub) {
|
|||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
final bytes = hub.engine.avatarFor(hash);
|
final bytes = lookup(hash);
|
||||||
final type = bytes == null ? null : avatarImageType(bytes);
|
final type = bytes == null ? null : avatarImageType(bytes);
|
||||||
if (bytes == null || type == null) return status(404);
|
if (bytes == null || type == null) return status(404);
|
||||||
return Response.ok(
|
return Response.ok(
|
||||||
@@ -402,6 +411,12 @@ void mountSyncRoute(
|
|||||||
if (name != null) viewer.name = name;
|
if (name != null) viewer.name = name;
|
||||||
hub.engine.applyProfile(token, name: name, avatar: avatar);
|
hub.engine.applyProfile(token, name: name, avatar: avatar);
|
||||||
hub.changed();
|
hub.changed();
|
||||||
|
case 'art':
|
||||||
|
if (token == null) return;
|
||||||
|
final art = ArtMessage.fromJson(decoded);
|
||||||
|
if (hub.engine.applyArt(token, art.mediaId, poster: art.poster, banner: art.banner)) {
|
||||||
|
hub.broadcastState();
|
||||||
|
}
|
||||||
case 'report':
|
case 'report':
|
||||||
if (token == null) return;
|
if (token == null) return;
|
||||||
final report = ReportMessage.fromJson(decoded);
|
final report = ReportMessage.fromJson(decoded);
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class SyncServer {
|
|||||||
mountSessionMediaRoute(_app, sessions);
|
mountSessionMediaRoute(_app, sessions);
|
||||||
mountRelayRoutes(_app, sessions, () => hub.relay);
|
mountRelayRoutes(_app, sessions, () => hub.relay);
|
||||||
mountAvatarRoute(_app, hub);
|
mountAvatarRoute(_app, hub);
|
||||||
|
mountArtRoute(_app, hub);
|
||||||
mountJellyfinRelayRoute(
|
mountJellyfinRelayRoute(
|
||||||
_app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient());
|
_app, sessions, () => hub.jellyfin, () => _relayClient ??= HttpClient());
|
||||||
// advertiseHost is only an explicit override; otherwise echo what the client dialled.
|
// advertiseHost is only an explicit override; otherwise echo what the client dialled.
|
||||||
|
|||||||
@@ -390,6 +390,98 @@ void main() {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('art: swapper only, current mediaId, hashes in state, /i serves', () async {
|
||||||
|
final jpeg = Uint8List.fromList([0xFF, 0xD8, 0xFF, ...List<int>.generate(200, (i) => (i * 3) % 256)]);
|
||||||
|
final png = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(200, (i) => i % 256)]);
|
||||||
|
final clients = <WebSocketChannel>[];
|
||||||
|
final subs = <StreamSubscription<Object?>>[];
|
||||||
|
Future<(WebSocketChannel, List<Map<String, Object?>>, String)> connect(String name) async {
|
||||||
|
final ch = WebSocketChannel.connect(Uri.parse('ws://127.0.0.1:${server.boundPort}/sync'));
|
||||||
|
final messages = <Map<String, Object?>>[];
|
||||||
|
clients.add(ch);
|
||||||
|
subs.add(ch.stream.listen((m) => messages.add(Map<String, Object?>.from(jsonDecode('$m') as Map))));
|
||||||
|
ch.sink.add(jsonEncode(HelloMessage(
|
||||||
|
proto: RelayConstants.protoVersion,
|
||||||
|
secret: 'shh',
|
||||||
|
user: HelloUser(name: name),
|
||||||
|
media: const HelloMedia(name: 'f.bin', size: 8192, duration: 10.0)).toJson()));
|
||||||
|
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||||
|
while (!messages.any((m) => m['t'] == 'welcome')) {
|
||||||
|
if (DateTime.now().isAfter(deadline)) fail('no welcome');
|
||||||
|
await Future.delayed(const Duration(milliseconds: 20));
|
||||||
|
}
|
||||||
|
return (ch, messages, messages.firstWhere((m) => m['t'] == 'welcome')['token'] as String);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, Object?>> waitFor(
|
||||||
|
List<Map<String, Object?>> messages, bool Function(Map<String, Object?>) test) async {
|
||||||
|
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||||
|
while (DateTime.now().isBefore(deadline)) {
|
||||||
|
for (final m in messages) {
|
||||||
|
if (test(m)) return m;
|
||||||
|
}
|
||||||
|
await Future.delayed(const Duration(milliseconds: 20));
|
||||||
|
}
|
||||||
|
fail('timed out; got $messages');
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object?>? artOf(Map<String, Object?> state) =>
|
||||||
|
(state['media'] as Map?)?['art'] as Map<String, Object?>?;
|
||||||
|
Future<HttpClientResponse> artGet(String token, String hash) async =>
|
||||||
|
(await http.get('127.0.0.1', server.boundPort, '/i/default/$token/$hash')).close();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final (a, aMsgs, aToken) = await connect('alice');
|
||||||
|
final (b, bMsgs, bToken) = await connect('bob');
|
||||||
|
a.sink.add(jsonEncode(const SwapIntent(
|
||||||
|
media: HelloMedia(name: 'Show - 01.mkv', size: 1, duration: 0, url: 'http://example.com/a.mkv')).toJson()));
|
||||||
|
final swapped = await waitFor(bMsgs, (m) => m['t'] == 'state' && (m['media'] as Map?)?['name'] == 'Show - 01.mkv');
|
||||||
|
final mediaId = swapped['mediaId'] as int;
|
||||||
|
|
||||||
|
// Not the swapper, or a stale mediaId: ignored.
|
||||||
|
b.sink.add(jsonEncode(ArtMessage(mediaId: mediaId, poster: base64Encode(png)).toJson()));
|
||||||
|
a.sink.add(jsonEncode(ArtMessage(mediaId: mediaId - 1, poster: base64Encode(png)).toJson()));
|
||||||
|
await Future.delayed(const Duration(milliseconds: 300));
|
||||||
|
expect(server.hub.engine.poster, isNull);
|
||||||
|
|
||||||
|
a.sink.add(jsonEncode(ArtMessage(mediaId: mediaId, poster: base64Encode(jpeg), banner: base64Encode(png)).toJson()));
|
||||||
|
final withArt = await waitFor(bMsgs, (m) => m['t'] == 'state' && artOf(m) != null);
|
||||||
|
final art = artOf(withArt)!;
|
||||||
|
expect(art, {'poster': avatarHashOf(jpeg), 'banner': avatarHashOf(png)});
|
||||||
|
|
||||||
|
// Any member (a guest without Jellyfin) fetches it.
|
||||||
|
final ok = await artGet(bToken, art['poster'] as String);
|
||||||
|
expect(ok.statusCode, 200);
|
||||||
|
expect(ok.headers.contentType?.mimeType, 'image/jpeg');
|
||||||
|
expect(ok.headers.value('cache-control'), contains('immutable'));
|
||||||
|
expect(await ok.expand((c) => c).toList(), jpeg);
|
||||||
|
final unknown = await artGet(bToken, '0123456789abcdef');
|
||||||
|
expect(unknown.statusCode, 404);
|
||||||
|
await unknown.drain();
|
||||||
|
final noToken = await artGet('nope', art['poster'] as String);
|
||||||
|
expect(noToken.statusCode, 401);
|
||||||
|
await noToken.drain();
|
||||||
|
|
||||||
|
// Any swap clears the art.
|
||||||
|
b.sink.add(jsonEncode(const SwapIntent(
|
||||||
|
media: HelloMedia(name: 'Other.mkv', size: 1, duration: 0, url: 'http://example.com/b.mkv')).toJson()));
|
||||||
|
final cleared = await waitFor(aMsgs, (m) => m['t'] == 'state' && (m['media'] as Map?)?['name'] == 'Other.mkv');
|
||||||
|
expect(artOf(cleared), isNull);
|
||||||
|
final gone = await artGet(aToken, art['poster'] as String);
|
||||||
|
expect(gone.statusCode, 404);
|
||||||
|
await gone.drain();
|
||||||
|
} finally {
|
||||||
|
for (final sub in subs) {
|
||||||
|
await sub.cancel();
|
||||||
|
}
|
||||||
|
for (final ch in clients) {
|
||||||
|
try {
|
||||||
|
await ch.sink.close();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('profile: rename + avatar feed, state carries hash, /a gated', () async {
|
test('profile: rename + avatar feed, state carries hash, /a gated', () async {
|
||||||
final png = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(296, (i) => (i * 7) % 256)]);
|
final png = Uint8List.fromList([0x89, 0x50, 0x4E, 0x47, ...List<int>.generate(296, (i) => (i * 7) % 256)]);
|
||||||
final ch = WebSocketChannel.connect(
|
final ch = WebSocketChannel.connect(
|
||||||
|
|||||||
Reference in new issue
Block a user