Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

More progress on broadcast feature #944

Open
wants to merge 39 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
29eca30
add a new screen with tabs for broadcast screen, move the broadcast r…
julien4215 Aug 16, 2024
c1ddc11
add a dropdown menu to choose tournament in a group and round
julien4215 Aug 22, 2024
82063f6
use user defined locale instead of default locale
julien4215 Aug 23, 2024
1eaae20
add broadcast board screen
julien4215 Aug 19, 2024
b292db3
use adaptive circular progress indicator for broadcast list screen
julien4215 Aug 25, 2024
acf391f
tweak start round date on broadcast cards
julien4215 Aug 26, 2024
4c69c2c
make status of game nullable
julien4215 Aug 27, 2024
8ba6d4d
fix round date starts show on broadcast grid card
julien4215 Aug 27, 2024
8474de8
add a link for broadcast in nb of hours and minutes translations
julien4215 Aug 28, 2024
1913546
handle case where tournament description is null
julien4215 Aug 29, 2024
92e4da1
use Cupertino style for the broadcast screen
julien4215 Aug 29, 2024
af05482
add eval bar to board
julien4215 Aug 31, 2024
0b05ebe
add broadcast own game analysis screen with player and clock widget
julien4215 Aug 31, 2024
996b7e7
update comments and hide eval bar until it is ready
julien4215 Sep 1, 2024
da08315
fix timer by moving it in the controller
julien4215 Sep 6, 2024
25dcbc6
merge main into broadcast branch to fix android impeller issue
julien4215 Sep 6, 2024
5adb49a
rename broadcast game screeen to broadcast analysis screen
julien4215 Sep 6, 2024
f9ff0c4
refactor player widget and fix negative clocks
julien4215 Sep 6, 2024
488d520
fix text overflow and rating null case
julien4215 Sep 6, 2024
9ff8f79
fix broadcast analysis screen, live move are now played on the analys…
julien4215 Sep 7, 2024
34428a1
fix linting
julien4215 Sep 7, 2024
b0d1c32
fix formatting
julien4215 Sep 7, 2024
10fca53
add an orange border around the latest move of a broadcast game
julien4215 Sep 7, 2024
040eca0
show correct clocks on analysis screen board
julien4215 Sep 8, 2024
b012e4c
improve player widget design on broadcast analysis screen and show on…
julien4215 Sep 8, 2024
83ca78e
fix negative duration case
julien4215 Sep 9, 2024
971a430
merge main into broadcast branch to fix conflicts
julien4215 Sep 10, 2024
d30d5a8
revert format change to avoid conflict
julien4215 Sep 17, 2024
b66c827
merge upstream into broadcast
julien4215 Sep 17, 2024
954e2c9
revert the sync of date formatting with selected user locale
julien4215 Sep 17, 2024
5fde6b4
remove Intl.getCurentLocale() to complete the revert
julien4215 Sep 17, 2024
f1c1e95
fix deprecation warnings
julien4215 Sep 17, 2024
f6666b9
add a try/catch when getting broadcast preferences
julien4215 Sep 17, 2024
958c7d4
fix null clock on broadcast analysis screen
julien4215 Sep 21, 2024
7815bdb
remove isUtc since local time is used
julien4215 Sep 22, 2024
c3e36c0
fix test
julien4215 Sep 22, 2024
931efe4
merge upstream main into broadcast
julien4215 Oct 7, 2024
2eace64
format code
julien4215 Oct 7, 2024
2c9be6f
tweak scroll behavior of overview tab
julien4215 Oct 7, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 56 additions & 1 deletion lib/src/model/analysis/analysis_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,15 @@ part 'analysis_controller.g.dart';

const standaloneAnalysisId = StringId('standalone_analysis');
const standaloneOpeningExplorerId = StringId('standalone_opening_explorer');
const standaloneBroadcastId = StringId('standalone_broadcast');

final _dateFormat = DateFormat('yyyy.MM.dd');

/// Whether the analysis is a standalone analysis (not a lichess game analysis).
bool _isStandaloneAnalysis(StringId id) =>
id == standaloneAnalysisId || id == standaloneOpeningExplorerId;
id == standaloneAnalysisId ||
id == standaloneOpeningExplorerId ||
id == standaloneBroadcastId;

@freezed
class AnalysisOptions with _$AnalysisOptions {
Expand All @@ -46,6 +49,7 @@ class AnalysisOptions with _$AnalysisOptions {
int? initialMoveCursor,
LightOpening? opening,
Division? division,
@Default(false) bool isBroadcast,
julien4215 marked this conversation as resolved.
Show resolved Hide resolved

/// Optional server analysis to display player stats.
({PlayerAnalysis white, PlayerAnalysis black})? serverAnalysis,
Expand Down Expand Up @@ -148,6 +152,9 @@ class AnalysisController extends _$AnalysisController {
variant: options.variant,
id: options.id,
currentPath: currentPath,
livePath: options.isBroadcast && pgnHeaders['Result'] == '*'
? currentPath
: null,
isOnMainline: _root.isOnMainline(currentPath),
root: _root.view,
currentNode: AnalysisCurrentNode.fromNode(currentNode),
Expand All @@ -162,6 +169,7 @@ class AnalysisController extends _$AnalysisController {
playersAnalysis: options.serverAnalysis,
acplChartData:
options.serverAnalysis != null ? _makeAcplChartData() : null,
clocks: options.isBroadcast ? _makeClocks(currentPath) : null,
);

if (analysisState.isEngineAvailable) {
Expand Down Expand Up @@ -210,6 +218,24 @@ class AnalysisController extends _$AnalysisController {
}
}

void onBroadcastMove(UciPath path, Move move, Duration? clock) {
final (newPath, isNewNode) = _root.addMoveAt(path, move, clock: clock);

if (newPath != null) {
if (state.livePath == state.currentPath) {
_setPath(
newPath,
shouldRecomputeRootView: isNewNode,
shouldForceShowVariation: true,
isBroadcastMove: true,
);
} else {
_root.promoteAt(newPath, toMainline: true);
state = state.copyWith(livePath: newPath, root: _root.view);
}
}
}

void onPromotionSelection(Role? role) {
if (role == null) {
state = state.copyWith(promotionMove: null);
Expand Down Expand Up @@ -400,6 +426,7 @@ class AnalysisController extends _$AnalysisController {
bool shouldForceShowVariation = false,
bool shouldRecomputeRootView = false,
bool replaying = false,
bool isBroadcastMove = false,
}) {
final pathChange = state.currentPath != path;
final (currentNode, opening) = _nodeOpeningAt(_root, path);
Expand Down Expand Up @@ -448,22 +475,26 @@ class AnalysisController extends _$AnalysisController {

state = state.copyWith(
currentPath: path,
livePath: isBroadcastMove ? path : state.livePath,
isOnMainline: _root.isOnMainline(path),
currentNode: AnalysisCurrentNode.fromNode(currentNode),
currentBranchOpening: opening,
lastMove: currentNode.sanMove.move,
promotionMove: null,
root: rootView,
clocks: options.isBroadcast ? _makeClocks(path) : null,
);
} else {
state = state.copyWith(
currentPath: path,
livePath: isBroadcastMove ? path : state.livePath,
isOnMainline: _root.isOnMainline(path),
currentNode: AnalysisCurrentNode.fromNode(currentNode),
currentBranchOpening: opening,
lastMove: null,
promotionMove: null,
root: rootView,
clocks: options.isBroadcast ? _makeClocks(path) : null,
);
}

Expand Down Expand Up @@ -625,6 +656,16 @@ class AnalysisController extends _$AnalysisController {
).toList(growable: false);
return list.isEmpty ? null : IList(list);
}

({Duration? parentClock, Duration? clock}) _makeClocks(UciPath path) {
final nodeView = _root.nodeAt(path).view;
final parentView = _root.parentAt(path).view;

return (
parentClock: (parentView is ViewBranch) ? parentView.clock : null,
clock: (nodeView is ViewBranch) ? nodeView.clock : null,
);
}
}

enum DisplayMode {
Expand Down Expand Up @@ -656,6 +697,9 @@ class AnalysisState with _$AnalysisState {
/// The path to the current node in the analysis view.
required UciPath currentPath,

// The path to the current broadcast live move.
required UciPath? livePath,

/// Whether the current path is on the mainline.
required bool isOnMainline,

Expand All @@ -673,6 +717,9 @@ class AnalysisState with _$AnalysisState {
/// It can be either moves, summary or opening explorer.
required DisplayMode displayMode,

/// Clocks if avaible. Only used by the broadcast analysis screen.
({Duration? parentClock, Duration? clock})? clocks,

/// The last move played.
Move? lastMove,

Expand Down Expand Up @@ -755,6 +802,14 @@ class AnalysisState with _$AnalysisState {
variant: variant,
initialMoveCursor: currentPath.size,
);

static AnalysisOptions get broadcastOptions => const AnalysisOptions(
id: standaloneAnalysisId,
isLocalEvaluationAllowed: true,
orientation: Side.white,
variant: Variant.standard,
isBroadcast: true,
);
}

@freezed
Expand Down
67 changes: 56 additions & 11 deletions lib/src/model/broadcast/broadcast.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class Broadcast with _$Broadcast {
const Broadcast._();

const factory Broadcast({
required BroadcastTournament tour,
required BroadcastTournamentData tour,
required BroadcastRound round,
required String? group,

Expand All @@ -32,9 +32,42 @@ class Broadcast with _$Broadcast {
String get title => group ?? tour.name;
}

typedef BroadcastTournament = ({
@freezed
class BroadcastTournament with _$BroadcastTournament {
const factory BroadcastTournament({
required BroadcastTournamentData data,
required IList<BroadcastRound> rounds,
required BroadcastRoundId defaultRoundId,
required IList<BroadcastTournamentGroup>? group,
}) = _BroadcastTournament;
}

@freezed
class BroadcastTournamentData with _$BroadcastTournamentData {
const factory BroadcastTournamentData({
required BroadcastTournamentId id,
required String name,
required String? imageUrl,
required String? description,
required BroadcastTournamentInformation information,
}) = _BroadcastTournamentData;
}

typedef BroadcastTournamentInformation = ({
String? format,
String? timeControl,
String? players,
BroadcastTournamentDates? dates,
});

typedef BroadcastTournamentDates = ({
DateTime startsAt,
DateTime? endsAt,
});

typedef BroadcastTournamentGroup = ({
BroadcastTournamentId id,
String name,
String? imageUrl,
});

@freezed
Expand All @@ -45,25 +78,37 @@ class BroadcastRound with _$BroadcastRound {
required BroadcastRoundId id,
required String name,
required RoundStatus status,
required DateTime startsAt,
required DateTime? startsAt,
}) = _BroadcastRound;
}

typedef BroadcastRoundGames = IMap<BroadcastGameId, BroadcastGameSnapshot>;
typedef BroadcastRoundGames = IMap<BroadcastGameId, BroadcastGame>;

@freezed
class BroadcastGameSnapshot with _$BroadcastGameSnapshot {
const BroadcastGameSnapshot._();
class BroadcastGame with _$BroadcastGame {
const BroadcastGame._();

const factory BroadcastGameSnapshot({
const factory BroadcastGame({
required BroadcastGameId id,
required IMap<Side, BroadcastPlayer> players,
required String fen,
required Move? lastMove,
required String status,
required String? status,

/// The amount of time that the player whose turn it is has been thinking since his last move
required Duration? thinkTime,
}) = _BroadcastGameSnapshot;
required Duration thinkTime,
}) = _BroadcastGame;

bool get isPlaying => status == '*';
Side get playingSide => Setup.parseFen(fen).turn;
Duration? get timeLeft {
final clock = players[playingSide]!.clock;
if (clock == null) return null;
final timeLeftMaybeNegative = clock - thinkTime;
return timeLeftMaybeNegative.isNegative
? Duration.zero
: timeLeftMaybeNegative;
}
}

@freezed
Expand Down
111 changes: 111 additions & 0 deletions lib/src/model/broadcast/broadcast_game_controller.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import 'dart:async';

import 'package:deep_pick/deep_pick.dart';
import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart';
import 'package:lichess_mobile/src/model/broadcast/broadcast_providers.dart';
import 'package:lichess_mobile/src/model/common/chess.dart';
import 'package:lichess_mobile/src/model/common/id.dart';
import 'package:lichess_mobile/src/network/socket.dart';
import 'package:lichess_mobile/src/utils/json.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'broadcast_game_controller.g.dart';

@riverpod
class BroadcastGameController extends _$BroadcastGameController {
static Uri broadcastSocketUri(BroadcastRoundId broadcastRoundId) =>
Uri(path: 'study/$broadcastRoundId/socket/v6');

StreamSubscription<SocketEvent>? _subscription;

late SocketClient _socketClient;

@override
Future<String> build(BroadcastRoundId roundId, BroadcastGameId gameId) async {
_socketClient = ref
.watch(socketPoolProvider)
.open(BroadcastGameController.broadcastSocketUri(roundId));

_subscription = _socketClient.stream.listen(_handleSocketEvent);

ref.onDispose(() {
_subscription?.cancel();
});

final pgn = await ref.watch(
broadcastGameProvider(roundId: roundId, gameId: gameId).future,
);
return pgn;
}

void _handleSocketEvent(SocketEvent event) {
if (!state.hasValue) return;

switch (event.topic) {
// Sent when a node is recevied from the broadcast
case 'addNode':
_handleAddNodeEvent(event);
// Sent when a pgn tag changes
case 'setTags':
_handleSetTagsEvent(event);
}
}

void _handleAddNodeEvent(SocketEvent event) {
final broadcastGameId =
pick(event.data, 'p', 'chapterId').asBroadcastGameIdOrThrow();

// We check if the event is for this game
if (broadcastGameId != gameId) return;

// The path of the last and current move of the broadcasted game
// Its value is "!" if the path is identical to one of the node that was received
final currentPath = pick(event.data, 'relayPath').asUciPathOrThrow();

// We check that the event we received is for the last move of the game
if (currentPath.value != '!') return;

// The path for the node that was received
final path = pick(event.data, 'p', 'path').asUciPathOrThrow();
final uciMove = pick(event.data, 'n', 'uci').asUciMoveOrThrow();
final clock =
pick(event.data, 'n', 'clock').asDurationFromCentiSecondsOrNull();

final ctrlProviderNotifier = ref.read(
analysisControllerProvider(
state.requireValue,
AnalysisState.broadcastOptions,
).notifier,
);

ctrlProviderNotifier.onBroadcastMove(path, uciMove, clock);
}

void _handleSetTagsEvent(SocketEvent event) {
final broadcastGameId =
pick(event.data, 'chapterId').asBroadcastGameIdOrThrow();

// We check if the event is for this game
if (broadcastGameId != gameId) return;

final ctrlProviderNotifier = ref.read(
analysisControllerProvider(
state.requireValue,
AnalysisState.broadcastOptions,
).notifier,
);

final headers = Map.fromEntries(
pick(event.data, 'tags').asListOrThrow(
(header) => MapEntry(
header(0).asStringOrThrow(),
header(1).asStringOrThrow(),
),
),
);

for (final entry in headers.entries) {
ctrlProviderNotifier.updatePgnHeader(entry.key, entry.value);
}
}
}
Loading