Initial commit
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../core/infra/pip/android_pip_controller.dart';
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
|
||||
const _tag = 'AndroidPipManager';
|
||||
|
||||
|
||||
class AndroidPipManager {
|
||||
AndroidPipManager({required this._onStateChanged});
|
||||
|
||||
final VoidCallback _onStateChanged;
|
||||
|
||||
bool _isInPip = false;
|
||||
bool _isDisposed = false;
|
||||
bool _supported = false;
|
||||
StreamSubscription<bool>? _modeSub;
|
||||
StreamSubscription<PipAction>? _actionSub;
|
||||
|
||||
|
||||
void Function(PipAction action)? onAction;
|
||||
|
||||
bool get isInPip => _isInPip;
|
||||
bool get isSupported => _supported;
|
||||
|
||||
|
||||
Future<void> initialize() async {
|
||||
if (!Platform.isAndroid) return;
|
||||
final supported = await AndroidPipController.instance.isSupported();
|
||||
if (_isDisposed) return;
|
||||
if (_supported != supported) {
|
||||
_supported = supported;
|
||||
_onStateChanged();
|
||||
}
|
||||
if (!_supported) return;
|
||||
if (_modeSub != null || _actionSub != null) return;
|
||||
|
||||
final isInPip = await AndroidPipController.instance.isInPipMode();
|
||||
if (_isDisposed) return;
|
||||
if (_isInPip != isInPip) {
|
||||
_isInPip = isInPip;
|
||||
_onStateChanged();
|
||||
}
|
||||
|
||||
_modeSub = AndroidPipController.instance.pipModeChanges.listen(
|
||||
(isInPip) {
|
||||
if (_isDisposed) return;
|
||||
if (_isInPip == isInPip) return;
|
||||
_isInPip = isInPip;
|
||||
AppLogger.info(_tag, 'PiP mode changed: $isInPip');
|
||||
_onStateChanged();
|
||||
},
|
||||
onError: (Object e) {
|
||||
AppLogger.warn(_tag, 'PiP mode stream error', e);
|
||||
},
|
||||
);
|
||||
|
||||
_actionSub = AndroidPipController.instance.actionStream.listen(
|
||||
(action) {
|
||||
if (_isDisposed) return;
|
||||
AppLogger.debug(_tag, 'PiP action: $action');
|
||||
onAction?.call(action);
|
||||
},
|
||||
onError: (Object e) {
|
||||
AppLogger.warn(_tag, 'PiP action stream error', e);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<void> configure({
|
||||
int aspectX = 16,
|
||||
int aspectY = 9,
|
||||
bool autoEnterOnLeave = false,
|
||||
bool isPlaying = false,
|
||||
bool hasNext = false,
|
||||
bool hasPrev = false,
|
||||
bool useEpisodeActions = true,
|
||||
}) async {
|
||||
if (!_supported || _isDisposed) return;
|
||||
await AndroidPipController.instance.configure(
|
||||
aspectX: aspectX,
|
||||
aspectY: aspectY,
|
||||
autoEnterOnLeave: autoEnterOnLeave,
|
||||
isPlaying: isPlaying,
|
||||
hasNext: hasNext,
|
||||
hasPrev: hasPrev,
|
||||
useEpisodeActions: useEpisodeActions,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<bool> enter({int? aspectX, int? aspectY}) async {
|
||||
if (!_supported || _isDisposed || _isInPip) return false;
|
||||
return AndroidPipController.instance.enterPip(
|
||||
aspectX: aspectX,
|
||||
aspectY: aspectY,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Future<void> updatePlaybackState({
|
||||
required bool isPlaying,
|
||||
required bool hasNext,
|
||||
required bool hasPrev,
|
||||
required bool useEpisodeActions,
|
||||
}) async {
|
||||
if (!_supported || _isDisposed || !_isInPip) return;
|
||||
await AndroidPipController.instance.updatePlaybackState(
|
||||
isPlaying: isPlaying,
|
||||
hasNext: hasNext,
|
||||
hasPrev: hasPrev,
|
||||
useEpisodeActions: useEpisodeActions,
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
final modeSub = _modeSub;
|
||||
_modeSub = null;
|
||||
final actionSub = _actionSub;
|
||||
_actionSub = null;
|
||||
onAction = null;
|
||||
unawaited(() async {
|
||||
await AndroidPipController.instance.configure(
|
||||
autoEnterOnLeave: false,
|
||||
isPlaying: false,
|
||||
);
|
||||
await modeSub?.cancel();
|
||||
await actionSub?.cancel();
|
||||
await AndroidPipController.instance.dispose();
|
||||
}());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import 'package:canvas_danmaku/canvas_danmaku.dart';
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:flutter/material.dart' show Colors;
|
||||
|
||||
import '../../../../core/contracts/danmaku.dart';
|
||||
import '../../../../core/media/danmaku_comment_pipeline.dart';
|
||||
|
||||
|
||||
const double _kBackwardSeekThreshold = -1.0;
|
||||
const double _kForwardSeekThreshold = 5.0;
|
||||
|
||||
|
||||
const int _kMaxFeedPerTick = 10;
|
||||
|
||||
|
||||
const double _kMaxFeedLagSec = 1.0;
|
||||
|
||||
enum DanmakuPlaybackState { detached, disabled, playing, frozen, seeking }
|
||||
|
||||
class DanmakuFeeder {
|
||||
DanmakuController? _canvas;
|
||||
List<DanmakuComment> _comments = const [];
|
||||
DanmakuPanelSettings _settings = const DanmakuPanelSettings();
|
||||
int _feedIndex = 0;
|
||||
double _lastPosSec = -1;
|
||||
DanmakuPlaybackState _state = DanmakuPlaybackState.detached;
|
||||
double? _frozenPosSec;
|
||||
|
||||
void attach(DanmakuController controller) {
|
||||
_canvas = controller;
|
||||
_state = _settings.enabled
|
||||
? DanmakuPlaybackState.frozen
|
||||
: DanmakuPlaybackState.disabled;
|
||||
}
|
||||
|
||||
void detach() {
|
||||
_canvas = null;
|
||||
_state = DanmakuPlaybackState.detached;
|
||||
}
|
||||
|
||||
|
||||
void setComments(List<DanmakuComment> commentsSortedByTime) {
|
||||
_comments = commentsSortedByTime;
|
||||
_canvas?.clear();
|
||||
_feedIndex = 0;
|
||||
_lastPosSec = -1;
|
||||
_frozenPosSec = null;
|
||||
}
|
||||
|
||||
void setPanelSettings(
|
||||
DanmakuPanelSettings settings, {
|
||||
DanmakuOption? option,
|
||||
}) {
|
||||
_settings = settings;
|
||||
if (!_settings.enabled) {
|
||||
_state = DanmakuPlaybackState.disabled;
|
||||
} else if (_state == DanmakuPlaybackState.disabled) {
|
||||
_state = _canvas == null
|
||||
? DanmakuPlaybackState.detached
|
||||
: DanmakuPlaybackState.frozen;
|
||||
}
|
||||
if (option != null) {
|
||||
try {
|
||||
_canvas?.updateOption(option);
|
||||
} catch (e) {
|
||||
debugPrint('[DanmakuFeeder] updateOption failed: $e; detaching canvas');
|
||||
_canvas = null;
|
||||
_state = DanmakuPlaybackState.detached;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void onPosition(double posSec) {
|
||||
final canvas = _canvas;
|
||||
if (canvas == null ||
|
||||
_comments.isEmpty ||
|
||||
!_settings.enabled ||
|
||||
_state != DanmakuPlaybackState.playing) {
|
||||
return;
|
||||
}
|
||||
final effectivePos = posSec - _settings.offset;
|
||||
final last = _lastPosSec;
|
||||
if (last >= 0 && _isTimelineJump(posSec - last)) {
|
||||
canvas.clear();
|
||||
_feedIndex = lowerBoundByTime(_comments, effectivePos);
|
||||
}
|
||||
_lastPosSec = posSec;
|
||||
|
||||
var fedThisTick = 0;
|
||||
while (_feedIndex < _comments.length && fedThisTick < _kMaxFeedPerTick) {
|
||||
final comment = _comments[_feedIndex];
|
||||
if (comment.time > effectivePos) break;
|
||||
_feedIndex++;
|
||||
if (effectivePos - comment.time > _kMaxFeedLagSec) continue;
|
||||
canvas.addDanmaku(_toContentItem(comment));
|
||||
fedThisTick++;
|
||||
}
|
||||
}
|
||||
|
||||
void freezeAt(double posSec) {
|
||||
final canvas = _canvas;
|
||||
if (canvas == null) {
|
||||
_state = DanmakuPlaybackState.detached;
|
||||
return;
|
||||
}
|
||||
if (!_settings.enabled) {
|
||||
_state = DanmakuPlaybackState.disabled;
|
||||
return;
|
||||
}
|
||||
_state = DanmakuPlaybackState.frozen;
|
||||
_frozenPosSec = posSec;
|
||||
_lastPosSec = posSec;
|
||||
if (canvas.running) {
|
||||
canvas.pause();
|
||||
}
|
||||
}
|
||||
|
||||
void playFrom(double posSec) {
|
||||
final canvas = _canvas;
|
||||
if (canvas == null) {
|
||||
_state = DanmakuPlaybackState.detached;
|
||||
return;
|
||||
}
|
||||
if (!_settings.enabled) {
|
||||
_state = DanmakuPlaybackState.disabled;
|
||||
return;
|
||||
}
|
||||
final frozenPos = _frozenPosSec;
|
||||
if (_state == DanmakuPlaybackState.frozen &&
|
||||
frozenPos != null &&
|
||||
posSec > frozenPos) {
|
||||
final targetIndex = lowerBoundByTime(
|
||||
_comments,
|
||||
posSec - _settings.offset,
|
||||
);
|
||||
if (targetIndex > _feedIndex) {
|
||||
_feedIndex = targetIndex;
|
||||
}
|
||||
}
|
||||
_lastPosSec = posSec;
|
||||
_frozenPosSec = null;
|
||||
_state = DanmakuPlaybackState.playing;
|
||||
if (!canvas.running) {
|
||||
canvas.resume();
|
||||
}
|
||||
}
|
||||
|
||||
void seekTo(double posSec, {bool clear = true}) {
|
||||
if (clear) _canvas?.clear();
|
||||
_feedIndex = lowerBoundByTime(_comments, posSec - _settings.offset);
|
||||
_lastPosSec = posSec;
|
||||
_frozenPosSec = null;
|
||||
if (_canvas == null) {
|
||||
_state = DanmakuPlaybackState.detached;
|
||||
} else {
|
||||
_state = _settings.enabled
|
||||
? DanmakuPlaybackState.seeking
|
||||
: DanmakuPlaybackState.disabled;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isTimelineJump(double deltaSec) {
|
||||
return deltaSec < _kBackwardSeekThreshold ||
|
||||
deltaSec > _kForwardSeekThreshold;
|
||||
}
|
||||
|
||||
DanmakuContentItem _toContentItem(DanmakuComment c) {
|
||||
final baseColor = _settings.colored ? c.color : Colors.white;
|
||||
|
||||
|
||||
final opacity = _settings.opacity.clamp(0.0, 1.0);
|
||||
final color = opacity >= 1.0
|
||||
? baseColor
|
||||
: baseColor.withValues(alpha: opacity);
|
||||
return DanmakuContentItem(
|
||||
c.text,
|
||||
color: color,
|
||||
type: switch (c.type) {
|
||||
4 when !_settings.fixedToScroll => DanmakuItemType.bottom,
|
||||
5 when !_settings.fixedToScroll => DanmakuItemType.top,
|
||||
_ => DanmakuItemType.scroll,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import '../../../../core/contracts/cancellation.dart';
|
||||
import '../../../../core/contracts/danmaku.dart';
|
||||
import '../../../../core/domain/ports/danmaku_gateway.dart';
|
||||
import '../../../../core/infra/danmaku_api/danmaku_client.dart';
|
||||
import '../../../../core/media/danmaku_episode_parser.dart';
|
||||
import '../../../../core/media/danmaku_match_picker.dart';
|
||||
import '../../../../shared/utils/app_logger.dart';
|
||||
|
||||
const _tag = 'DanmakuMatcher';
|
||||
|
||||
typedef _SingleSourceMatcherResult = ({
|
||||
List<DanmakuMatch> matches,
|
||||
int pickedIndex,
|
||||
});
|
||||
|
||||
const _emptySingleSourceResult = (matches: <DanmakuMatch>[], pickedIndex: -1);
|
||||
|
||||
class DanmakuMatcherResult {
|
||||
final List<DanmakuMatchCandidate> matches;
|
||||
final int pickedIndex;
|
||||
|
||||
const DanmakuMatcherResult({
|
||||
required this.matches,
|
||||
required this.pickedIndex,
|
||||
});
|
||||
|
||||
static const empty = DanmakuMatcherResult(matches: [], pickedIndex: -1);
|
||||
}
|
||||
|
||||
class DanmakuMatcher {
|
||||
DanmakuMatcher({required this._gateway});
|
||||
|
||||
final DanmakuGateway _gateway;
|
||||
|
||||
Future<DanmakuMatcherResult> resolve({
|
||||
required List<DanmakuSource> sources,
|
||||
required DanmakuMatchContext context,
|
||||
CancellationToken? cancelToken,
|
||||
}) async {
|
||||
if (sources.isEmpty) return DanmakuMatcherResult.empty;
|
||||
|
||||
final allMatches = <DanmakuMatchCandidate>[];
|
||||
var pickedIndex = -1;
|
||||
|
||||
for (final source in sources) {
|
||||
cancelToken?.throwIfCancelled();
|
||||
_SingleSourceMatcherResult result;
|
||||
try {
|
||||
result = await _resolveSingle(
|
||||
sourceUrl: source.url,
|
||||
context: context,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
} on CancelledException {
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
AppLogger.warn(
|
||||
_tag,
|
||||
'source "${source.url}" match failed, skipping',
|
||||
e,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
cancelToken?.throwIfCancelled();
|
||||
if (result.matches.isEmpty) continue;
|
||||
|
||||
final baseIndex = allMatches.length;
|
||||
allMatches.addAll(
|
||||
result.matches.map(
|
||||
(match) => DanmakuMatchCandidate(source: source, match: match),
|
||||
),
|
||||
);
|
||||
if (pickedIndex < 0 && result.pickedIndex >= 0) {
|
||||
pickedIndex = baseIndex + result.pickedIndex;
|
||||
}
|
||||
}
|
||||
|
||||
if (allMatches.isEmpty) return DanmakuMatcherResult.empty;
|
||||
return DanmakuMatcherResult(
|
||||
matches: allMatches,
|
||||
pickedIndex: pickedIndex < 0 ? 0 : pickedIndex,
|
||||
);
|
||||
}
|
||||
|
||||
Future<_SingleSourceMatcherResult> _resolveSingle({
|
||||
required String sourceUrl,
|
||||
required DanmakuMatchContext context,
|
||||
CancellationToken? cancelToken,
|
||||
}) async {
|
||||
final fileName = buildDanmakuFileName(
|
||||
name: context.name,
|
||||
type: context.type,
|
||||
seriesName: context.seriesName,
|
||||
season: context.season,
|
||||
episode: context.episode,
|
||||
);
|
||||
final episodeIndex = context.episode;
|
||||
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'match request source="$sourceUrl" fileName="$fileName" '
|
||||
'durationSec=${context.durationSec} targetEp=$episodeIndex',
|
||||
);
|
||||
|
||||
var matches = await _gateway.match(
|
||||
sourceUrl,
|
||||
fileName,
|
||||
context.durationSec,
|
||||
);
|
||||
cancelToken?.throwIfCancelled();
|
||||
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'match received from "$sourceUrl": ${matches.length} entries',
|
||||
);
|
||||
|
||||
if (matches.isEmpty &&
|
||||
context.type == 'Episode' &&
|
||||
context.seriesName != null &&
|
||||
context.seriesName!.isNotEmpty) {
|
||||
AppLogger.info(
|
||||
_tag,
|
||||
'exact match empty, falling back to searchEpisodes source="$sourceUrl" '
|
||||
'seriesName="${context.seriesName}" ep=$episodeIndex',
|
||||
);
|
||||
matches = await _gateway.searchEpisodes(
|
||||
sourceUrl,
|
||||
context.seriesName!,
|
||||
episodeIndex,
|
||||
);
|
||||
cancelToken?.throwIfCancelled();
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'searchEpisodes fallback: ${matches.length} entries',
|
||||
);
|
||||
}
|
||||
|
||||
if (matches.isEmpty) return _emptySingleSourceResult;
|
||||
|
||||
final pickIndex = pickBestMatchIndex(matches, episodeIndex);
|
||||
|
||||
if (episodeIndex != null) {
|
||||
final pickedEp = extractEpisodeNumberFromTitle(
|
||||
matches[pickIndex].episodeTitle,
|
||||
);
|
||||
if (pickedEp != episodeIndex) {
|
||||
final corrected = await _correctViaBangumi(
|
||||
sourceUrl: sourceUrl,
|
||||
animeId: matches[pickIndex].animeId,
|
||||
targetEpisode: episodeIndex,
|
||||
animeTitle: matches[pickIndex].animeTitle,
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
if (corrected != null) {
|
||||
AppLogger.info(
|
||||
_tag,
|
||||
'bangumi correction applied: episodeId=${corrected.episodeId} '
|
||||
'title="${corrected.episodeTitle}"',
|
||||
);
|
||||
return (matches: [corrected, ...matches], pickedIndex: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (matches: matches, pickedIndex: pickIndex);
|
||||
}
|
||||
|
||||
Future<DanmakuMatch?> _correctViaBangumi({
|
||||
required String sourceUrl,
|
||||
required int animeId,
|
||||
required int targetEpisode,
|
||||
required String animeTitle,
|
||||
CancellationToken? cancelToken,
|
||||
}) async {
|
||||
if (animeId <= 0) {
|
||||
AppLogger.warn(
|
||||
_tag,
|
||||
'skip bangumi correction: animeId missing in match response',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'bangumi correction probing: animeId=$animeId targetEp=$targetEpisode',
|
||||
);
|
||||
final episodes = await _gateway.getBangumiEpisodes(sourceUrl, animeId);
|
||||
cancelToken?.throwIfCancelled();
|
||||
if (episodes.isEmpty) {
|
||||
AppLogger.warn(_tag, 'bangumi $animeId returned no episodes');
|
||||
return null;
|
||||
}
|
||||
for (final ep in episodes) {
|
||||
final n =
|
||||
int.tryParse(ep.episodeNumber) ??
|
||||
extractEpisodeNumberFromTitle(ep.episodeTitle);
|
||||
if (n == targetEpisode) {
|
||||
return DanmakuMatch(
|
||||
episodeId: ep.episodeId,
|
||||
animeId: animeId,
|
||||
animeTitle: animeTitle,
|
||||
episodeTitle: ep.episodeTitle,
|
||||
);
|
||||
}
|
||||
}
|
||||
AppLogger.warn(
|
||||
_tag,
|
||||
'bangumi $animeId has ${episodes.length} episodes but none matches '
|
||||
'targetEp=$targetEpisode',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../core/contracts/cancellation.dart';
|
||||
import '../../../../core/contracts/danmaku.dart';
|
||||
import '../../../../core/domain/ports/danmaku_gateway.dart';
|
||||
import '../../../../core/media/danmaku_comment_pipeline.dart';
|
||||
import '../../../../providers/danmaku_settings_provider.dart';
|
||||
import '../../../../providers/di_providers.dart';
|
||||
import '../../../../shared/utils/app_logger.dart';
|
||||
import 'danmaku_matcher.dart';
|
||||
|
||||
const _tag = 'DanmakuSession';
|
||||
|
||||
class DanmakuSessionNotifier extends AsyncNotifier<DanmakuSessionState> {
|
||||
late final DanmakuGateway _gateway;
|
||||
late final DanmakuMatcher _matcher;
|
||||
|
||||
CancellationToken? _token;
|
||||
String? _currentItemId;
|
||||
List<DanmakuSource> _sources = const [];
|
||||
int _chConvert = 0;
|
||||
|
||||
@override
|
||||
Future<DanmakuSessionState> build() async {
|
||||
_gateway = ref.read(danmakuGatewayProvider);
|
||||
_matcher = DanmakuMatcher(gateway: _gateway);
|
||||
ref.onDispose(() => _token?.cancel('notifier disposed'));
|
||||
return const DanmakuSessionIdle();
|
||||
}
|
||||
|
||||
Future<void> loadFor(DanmakuMatchContext context) async {
|
||||
final token = _swapToken('loadFor:${context.itemId}');
|
||||
_currentItemId = context.itemId;
|
||||
|
||||
final settings = _readSettings();
|
||||
final sources = settings.enabledSources;
|
||||
if (!settings.enabled || sources.isEmpty) {
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'loadFor(${context.itemId}) -> Idle: '
|
||||
'enabled=${settings.enabled} sources=${sources.length}',
|
||||
);
|
||||
_sources = const [];
|
||||
state = const AsyncData(DanmakuSessionIdle());
|
||||
return;
|
||||
}
|
||||
|
||||
_sources = sources;
|
||||
_chConvert = _chConvertFromMode(settings.chineseConvert);
|
||||
state = const AsyncData(DanmakuSessionMatching());
|
||||
|
||||
try {
|
||||
final result = await _matcher.resolve(
|
||||
sources: _sources,
|
||||
context: context,
|
||||
cancelToken: token,
|
||||
);
|
||||
if (_isStale(token, context.itemId)) return;
|
||||
|
||||
if (result.matches.isEmpty || result.pickedIndex < 0) {
|
||||
AppLogger.info(
|
||||
_tag,
|
||||
'loadFor(${context.itemId}) -> Empty: no matches found',
|
||||
);
|
||||
state = const AsyncData(DanmakuSessionEmpty());
|
||||
return;
|
||||
}
|
||||
|
||||
state = AsyncData(
|
||||
DanmakuSessionMatched(
|
||||
matches: result.matches,
|
||||
selectedIndex: result.pickedIndex,
|
||||
commentStatus: DanmakuCommentStatus.loading,
|
||||
),
|
||||
);
|
||||
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'auto-select index=${result.pickedIndex} '
|
||||
'(target ep=${context.episode})',
|
||||
);
|
||||
await _loadCommentsForCurrent(token: token);
|
||||
} on CancelledException {
|
||||
return;
|
||||
} catch (e, st) {
|
||||
if (_isStale(token, context.itemId)) return;
|
||||
AppLogger.error(_tag, 'loadFor failed', e, st);
|
||||
state = AsyncData(DanmakuSessionFailed(e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> selectMatch(int index) async {
|
||||
final current = state.value;
|
||||
if (current is! DanmakuSessionMatched) return;
|
||||
if (index < 0 || index >= current.matches.length) return;
|
||||
if (index == current.selectedIndex &&
|
||||
current.commentStatus == DanmakuCommentStatus.ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
final token = _swapToken('selectMatch:$index');
|
||||
state = AsyncData(
|
||||
current.copyWith(
|
||||
selectedIndex: index,
|
||||
commentStatus: DanmakuCommentStatus.loading,
|
||||
),
|
||||
);
|
||||
await _loadCommentsForCurrent(token: token);
|
||||
}
|
||||
|
||||
Future<void> applyManualSelection({
|
||||
required DanmakuSource source,
|
||||
required int episodeId,
|
||||
required int animeId,
|
||||
required String animeTitle,
|
||||
required String episodeTitle,
|
||||
}) async {
|
||||
if (source.url.isEmpty || episodeId <= 0) return;
|
||||
final current = state.value;
|
||||
final picked = DanmakuMatchCandidate(
|
||||
source: source,
|
||||
match: DanmakuMatch(
|
||||
episodeId: episodeId,
|
||||
animeId: animeId,
|
||||
animeTitle: animeTitle,
|
||||
episodeTitle: episodeTitle,
|
||||
),
|
||||
);
|
||||
|
||||
final previous = current is DanmakuSessionMatched ? current : null;
|
||||
final newMatches = previous == null
|
||||
? [picked]
|
||||
: [
|
||||
picked,
|
||||
...previous.matches.where(
|
||||
(m) => m.episodeId != episodeId || m.source.url != source.url,
|
||||
),
|
||||
];
|
||||
|
||||
final token = _swapToken('applyManual:$episodeId');
|
||||
state = AsyncData(
|
||||
DanmakuSessionMatched(
|
||||
matches: newMatches,
|
||||
selectedIndex: 0,
|
||||
commentStatus: DanmakuCommentStatus.loading,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await _loadCommentsForCurrent(token: token);
|
||||
} catch (_) {
|
||||
if (_isStale(token, _currentItemId)) return;
|
||||
if (previous != null) {
|
||||
state = AsyncData(previous);
|
||||
} else {
|
||||
state = const AsyncData(DanmakuSessionEmpty());
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<DanmakuMatchCandidate>?> manualSearch(String query) async {
|
||||
final trimmed = query.trim();
|
||||
if (_sources.isEmpty || trimmed.isEmpty) return const [];
|
||||
final token = _token;
|
||||
final results = <DanmakuMatchCandidate>[];
|
||||
|
||||
for (final source in _sources) {
|
||||
List<DanmakuMatch> matches;
|
||||
try {
|
||||
matches = await _gateway.match(source.url, trimmed, 0);
|
||||
} on CancelledException {
|
||||
return null;
|
||||
} catch (e) {
|
||||
if (token != null && token.isCancelled) return null;
|
||||
AppLogger.warn(_tag, 'manualSearch source "${source.url}" failed', e);
|
||||
continue;
|
||||
}
|
||||
if (token != null && token.isCancelled) return null;
|
||||
results.addAll(
|
||||
matches.map(
|
||||
(match) => DanmakuMatchCandidate(source: source, match: match),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
Future<List<DanmakuBangumiEpisode>?> listEpisodesForAnime(
|
||||
DanmakuSource source,
|
||||
int animeId,
|
||||
) async {
|
||||
if (source.url.isEmpty || animeId <= 0) return const [];
|
||||
final token = _token;
|
||||
final res = await _gateway.getBangumiEpisodes(source.url, animeId);
|
||||
if (token != null && token.isCancelled) return null;
|
||||
return res;
|
||||
}
|
||||
|
||||
Future<void> _loadCommentsForCurrent({
|
||||
required CancellationToken token,
|
||||
}) async {
|
||||
final current = state.value;
|
||||
if (current is! DanmakuSessionMatched) return;
|
||||
final selected = current.matches[current.selectedIndex];
|
||||
final episodeId = selected.episodeId;
|
||||
final settings = _readSettings();
|
||||
|
||||
try {
|
||||
final raw = await _gateway.fetchComments(
|
||||
selected.source.url,
|
||||
episodeId,
|
||||
chConvert: _chConvert,
|
||||
);
|
||||
token.throwIfCancelled();
|
||||
final comments = processDanmakuComments(
|
||||
raw,
|
||||
blockedKeywords: settings.blockedKeywords,
|
||||
mergeDuplicates: settings.mergeDuplicates,
|
||||
);
|
||||
if (_isStale(token, _currentItemId)) return;
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'comments ready from "${selected.source.displayName}" '
|
||||
'for ep=$episodeId: ${comments.length} items',
|
||||
);
|
||||
final cur = state.value;
|
||||
if (cur is DanmakuSessionMatched) {
|
||||
state = AsyncData(
|
||||
cur.copyWith(
|
||||
commentStatus: DanmakuCommentStatus.ready,
|
||||
comments: comments,
|
||||
),
|
||||
);
|
||||
}
|
||||
} on CancelledException {
|
||||
return;
|
||||
} catch (e) {
|
||||
if (_isStale(token, _currentItemId)) return;
|
||||
AppLogger.warn(_tag, 'load comments failed for ep=$episodeId', e);
|
||||
final cur = state.value;
|
||||
if (cur is DanmakuSessionMatched) {
|
||||
state = AsyncData(
|
||||
cur.copyWith(commentStatus: DanmakuCommentStatus.failed),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CancellationToken _swapToken(String reason) {
|
||||
_token?.cancel(reason);
|
||||
final t = CancellationToken();
|
||||
_token = t;
|
||||
return t;
|
||||
}
|
||||
|
||||
bool _isStale(CancellationToken token, String? itemIdAtStart) {
|
||||
if (token.isCancelled) return true;
|
||||
if (token != _token) return true;
|
||||
if (itemIdAtStart != null && itemIdAtStart != _currentItemId) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
DanmakuSettingsState _readSettings() =>
|
||||
ref.read(danmakuSettingsProvider).value ?? const DanmakuSettingsState();
|
||||
|
||||
static int _chConvertFromMode(ChineseConvertMode mode) => switch (mode) {
|
||||
ChineseConvertMode.toSimplified => 1,
|
||||
ChineseConvertMode.toTraditional => 2,
|
||||
ChineseConvertMode.none => 0,
|
||||
};
|
||||
}
|
||||
|
||||
final danmakuSessionProvider =
|
||||
AsyncNotifierProvider.autoDispose<
|
||||
DanmakuSessionNotifier,
|
||||
DanmakuSessionState
|
||||
>(DanmakuSessionNotifier.new);
|
||||
@@ -0,0 +1,105 @@
|
||||
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import '../../../core/contracts/playback.dart';
|
||||
import '../../../core/domain/ports/emby_gateway.dart';
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
|
||||
typedef PlaybackReportPayloadBuilder =
|
||||
PlaybackReportPayload Function({required bool isPaused, String? eventName});
|
||||
|
||||
class EmbyPlaybackReporter {
|
||||
EmbyPlaybackReporter({
|
||||
required EmbyGateway gateway,
|
||||
required AuthedRequestContext context,
|
||||
this.enabled = true,
|
||||
}) : _gateway = gateway,
|
||||
_context = context;
|
||||
|
||||
static const _tag = 'Reporter';
|
||||
|
||||
final EmbyGateway _gateway;
|
||||
final AuthedRequestContext _context;
|
||||
|
||||
|
||||
final bool enabled;
|
||||
|
||||
bool _startedReported = false;
|
||||
bool _stoppedReported = false;
|
||||
Timer? _progressTimer;
|
||||
|
||||
bool get startedReported => _startedReported;
|
||||
bool get stoppedReported => _stoppedReported;
|
||||
|
||||
Future<void> reportStarted(PlaybackReportPayload payload) async {
|
||||
if (!enabled) return;
|
||||
if (_startedReported) return;
|
||||
_startedReported = true;
|
||||
try {
|
||||
await _gateway.reportPlaybackStarted(ctx: _context, payload: payload);
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'Start report failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reportProgress(PlaybackReportPayload payload) async {
|
||||
if (!_startedReported) return;
|
||||
try {
|
||||
await _gateway.reportPlaybackProgress(ctx: _context, payload: payload);
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'Progress report failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> reportStopped(PlaybackReportPayload payload) async {
|
||||
if (!enabled) return;
|
||||
if (!_startedReported) {
|
||||
AppLogger.debug(_tag, 'stop report skipped: start not reported');
|
||||
return;
|
||||
}
|
||||
if (_stoppedReported) {
|
||||
AppLogger.debug(_tag, 'stop report skipped: already stopped');
|
||||
return;
|
||||
}
|
||||
_stoppedReported = true;
|
||||
_progressTimer?.cancel();
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
await _gateway.reportPlaybackStopped(ctx: _context, payload: payload);
|
||||
AppLogger.debug(_tag, 'stop report ok in ${sw.elapsedMilliseconds}ms');
|
||||
} catch (e) {
|
||||
AppLogger.warn(
|
||||
_tag,
|
||||
'Stop report failed after ${sw.elapsedMilliseconds}ms',
|
||||
e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void startProgressTimer(PlaybackReportPayloadBuilder payloadBuilder) {
|
||||
if (!enabled) return;
|
||||
if (_progressTimer != null) return;
|
||||
_progressTimer = Timer.periodic(const Duration(seconds: 8), (_) {
|
||||
if (_startedReported && !_stoppedReported) {
|
||||
unawaited(
|
||||
reportProgress(
|
||||
payloadBuilder(isPaused: false, eventName: 'TimeUpdate'),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = null;
|
||||
_startedReported = false;
|
||||
_stoppedReported = false;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_progressTimer?.cancel();
|
||||
_progressTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
|
||||
const _tag = 'FrameJank';
|
||||
|
||||
|
||||
@immutable
|
||||
class FrameDiag {
|
||||
|
||||
final double fps;
|
||||
|
||||
|
||||
final double lastBuildMs;
|
||||
|
||||
|
||||
final double lastRasterMs;
|
||||
|
||||
|
||||
final int jankCount;
|
||||
|
||||
const FrameDiag({
|
||||
required this.fps,
|
||||
required this.lastBuildMs,
|
||||
required this.lastRasterMs,
|
||||
required this.jankCount,
|
||||
});
|
||||
|
||||
static const zero = FrameDiag(
|
||||
fps: 0,
|
||||
lastBuildMs: 0,
|
||||
lastRasterMs: 0,
|
||||
jankCount: 0,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
class FrameJankMonitor {
|
||||
FrameJankMonitor({this.jankThreshold = const Duration(milliseconds: 32)});
|
||||
|
||||
|
||||
final Duration jankThreshold;
|
||||
|
||||
final ValueNotifier<FrameDiag> diag = ValueNotifier<FrameDiag>(
|
||||
FrameDiag.zero,
|
||||
);
|
||||
|
||||
TimingsCallback? _callback;
|
||||
int _jankCount = 0;
|
||||
int _windowFrames = 0;
|
||||
final Stopwatch _windowClock = Stopwatch()..start();
|
||||
double _fps = 0;
|
||||
|
||||
void start() {
|
||||
if (kReleaseMode || _callback != null) return;
|
||||
final cb = _onTimings;
|
||||
_callback = cb;
|
||||
SchedulerBinding.instance.addTimingsCallback(cb);
|
||||
}
|
||||
|
||||
void _onTimings(List<FrameTiming> timings) {
|
||||
if (timings.isEmpty) return;
|
||||
for (final t in timings) {
|
||||
if (t.buildDuration >= jankThreshold ||
|
||||
t.rasterDuration >= jankThreshold) {
|
||||
_jankCount++;
|
||||
AppLogger.warn(
|
||||
_tag,
|
||||
'janky frame build=${_ms(t.buildDuration)} '
|
||||
'raster=${_ms(t.rasterDuration)} total=${_ms(t.totalSpan)}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_windowFrames += timings.length;
|
||||
final elapsedMs = _windowClock.elapsedMilliseconds;
|
||||
if (elapsedMs >= 500) {
|
||||
_fps = _windowFrames * 1000 / elapsedMs;
|
||||
_windowFrames = 0;
|
||||
_windowClock.reset();
|
||||
}
|
||||
|
||||
final last = timings.last;
|
||||
diag.value = FrameDiag(
|
||||
fps: _fps,
|
||||
lastBuildMs: last.buildDuration.inMicroseconds / 1000,
|
||||
lastRasterMs: last.rasterDuration.inMicroseconds / 1000,
|
||||
jankCount: _jankCount,
|
||||
);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
final cb = _callback;
|
||||
if (cb != null) {
|
||||
SchedulerBinding.instance.removeTimingsCallback(cb);
|
||||
_callback = null;
|
||||
}
|
||||
diag.dispose();
|
||||
}
|
||||
|
||||
static String _ms(Duration d) =>
|
||||
'${(d.inMicroseconds / 1000).toStringAsFixed(1)}ms';
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:screen_retriever/screen_retriever.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
|
||||
class FullscreenManager {
|
||||
static const _tag = 'Fullscreen';
|
||||
|
||||
|
||||
static bool get _isDesktop => Platform.isWindows || Platform.isMacOS;
|
||||
|
||||
FullscreenManager({required this._onStateChanged});
|
||||
|
||||
final VoidCallback _onStateChanged;
|
||||
|
||||
bool _isFullscreen = false;
|
||||
bool _isChanging = false;
|
||||
Rect? _windowBoundsBeforeFullscreen;
|
||||
bool _wasMaximized = false;
|
||||
bool _wasResizable = true;
|
||||
bool _wasAlwaysOnTop = false;
|
||||
bool _isDisposed = false;
|
||||
Future<void>? _disposeRestoreFuture;
|
||||
|
||||
bool get isFullscreen => _isFullscreen;
|
||||
|
||||
Future<void> toggle() async {
|
||||
if (_isDisposed) return;
|
||||
if (!_isDesktop) return;
|
||||
if (_isChanging) return;
|
||||
_isChanging = true;
|
||||
final wasFullscreen = _isFullscreen;
|
||||
|
||||
try {
|
||||
if (Platform.isWindows) {
|
||||
if (wasFullscreen) {
|
||||
await _leaveWindowsFullscreen();
|
||||
} else {
|
||||
await _enterWindowsFullscreen();
|
||||
}
|
||||
} else {
|
||||
await _toggleNativeFullscreen();
|
||||
}
|
||||
} catch (error) {
|
||||
AppLogger.warn(_tag, 'Failed to toggle', error);
|
||||
if (Platform.isWindows && !wasFullscreen) {
|
||||
try {
|
||||
await _leaveWindowsFullscreen(updateState: false);
|
||||
} catch (_) {}
|
||||
_setFullscreenState(false);
|
||||
} else if (!Platform.isWindows) {
|
||||
final actual = await _readNativeFullscreenState();
|
||||
if (actual != null) _setFullscreenState(actual);
|
||||
}
|
||||
} finally {
|
||||
_isChanging = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> leave({bool updateState = true}) async {
|
||||
if (_isDisposed && updateState) return;
|
||||
if (!_isFullscreen) return;
|
||||
try {
|
||||
if (Platform.isWindows) {
|
||||
await _leaveWindowsFullscreen(updateState: updateState);
|
||||
} else {
|
||||
await windowManager.setFullScreen(false);
|
||||
}
|
||||
} catch (_) {}
|
||||
_isFullscreen = false;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
if (_isFullscreen) {
|
||||
_scheduleDisposeRestore();
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void>? get disposeRestoreFuture => _disposeRestoreFuture;
|
||||
|
||||
void _scheduleDisposeRestore() {
|
||||
if (_disposeRestoreFuture != null) return;
|
||||
final restore = _restoreAfterDispose();
|
||||
_disposeRestoreFuture = restore;
|
||||
unawaited(restore);
|
||||
}
|
||||
|
||||
Future<void> _restoreAfterDispose() async {
|
||||
while (_isChanging) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
if (!_isFullscreen) return;
|
||||
|
||||
_isChanging = true;
|
||||
try {
|
||||
if (Platform.isWindows) {
|
||||
await _leaveWindowsFullscreen(updateState: false);
|
||||
} else {
|
||||
await windowManager.setFullScreen(false);
|
||||
_isFullscreen = false;
|
||||
}
|
||||
} catch (error) {
|
||||
AppLogger.warn(_tag, 'Failed to restore window on dispose', error);
|
||||
} finally {
|
||||
_isChanging = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _setFullscreenState(bool fullscreen) {
|
||||
if (_isFullscreen == fullscreen) return;
|
||||
_isFullscreen = fullscreen;
|
||||
if (!_isDisposed) {
|
||||
_onStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool?> _readNativeFullscreenState() async {
|
||||
try {
|
||||
return await windowManager.isFullScreen();
|
||||
} catch (error) {
|
||||
AppLogger.warn(_tag, 'Failed to read state', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleNativeFullscreen() async {
|
||||
final current = await _readNativeFullscreenState() ?? _isFullscreen;
|
||||
final next = !current;
|
||||
await windowManager.setFullScreen(next);
|
||||
final actual = await _readNativeFullscreenState() ?? next;
|
||||
_setFullscreenState(actual);
|
||||
if (_isDisposed) {
|
||||
_scheduleDisposeRestore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _enterWindowsFullscreen() async {
|
||||
final currentBounds = await windowManager.getBounds();
|
||||
final display = await _displayForBounds(currentBounds);
|
||||
|
||||
_windowBoundsBeforeFullscreen = currentBounds;
|
||||
_wasMaximized = await windowManager.isMaximized();
|
||||
_wasResizable = await windowManager.isResizable();
|
||||
_wasAlwaysOnTop = await windowManager.isAlwaysOnTop();
|
||||
|
||||
if (_wasMaximized) {
|
||||
await windowManager.unmaximize();
|
||||
}
|
||||
await windowManager.setResizable(false);
|
||||
await windowManager.setAlwaysOnTop(true);
|
||||
await windowManager.setTitleBarStyle(
|
||||
TitleBarStyle.hidden,
|
||||
windowButtonVisibility: false,
|
||||
);
|
||||
await windowManager.setBounds(fullscreenBoundsForDisplay(display));
|
||||
await windowManager.focus();
|
||||
_setFullscreenState(true);
|
||||
if (_isDisposed) {
|
||||
_scheduleDisposeRestore();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _leaveWindowsFullscreen({bool updateState = true}) async {
|
||||
await windowManager.setTitleBarStyle(
|
||||
TitleBarStyle.hidden,
|
||||
windowButtonVisibility: false,
|
||||
);
|
||||
await windowManager.setResizable(_wasResizable);
|
||||
await windowManager.setAlwaysOnTop(_wasAlwaysOnTop);
|
||||
|
||||
if (_wasMaximized) {
|
||||
await windowManager.maximize();
|
||||
} else {
|
||||
final bounds = _windowBoundsBeforeFullscreen;
|
||||
if (bounds != null) {
|
||||
await windowManager.setBounds(bounds);
|
||||
}
|
||||
}
|
||||
await windowManager.focus();
|
||||
|
||||
if (updateState) {
|
||||
_setFullscreenState(false);
|
||||
} else {
|
||||
_isFullscreen = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Display> _displayForBounds(Rect bounds) async {
|
||||
final displays = await screenRetriever.getAllDisplays();
|
||||
final center = bounds.center;
|
||||
for (final display in displays) {
|
||||
final rect = fullscreenBoundsForDisplay(display);
|
||||
if (rect.contains(center)) return display;
|
||||
}
|
||||
if (displays.isNotEmpty) return displays.first;
|
||||
return screenRetriever.getPrimaryDisplay();
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static Rect fullscreenBoundsForDisplay(Display display) {
|
||||
final position = display.visiblePosition ?? Offset.zero;
|
||||
final size = display.size;
|
||||
if (Platform.isWindows) {
|
||||
const border = 8.0;
|
||||
return Rect.fromLTWH(
|
||||
position.dx - border,
|
||||
position.dy,
|
||||
size.width + border * 2,
|
||||
size.height + border,
|
||||
);
|
||||
}
|
||||
return position & size;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
|
||||
class NetworkSpeedMonitor {
|
||||
NetworkSpeedMonitor({required this._totalRxBytes, DateTime Function()? now})
|
||||
: _now = now ?? DateTime.now;
|
||||
|
||||
final Future<int?> Function() _totalRxBytes;
|
||||
final DateTime Function() _now;
|
||||
final ValueNotifier<double> speed = ValueNotifier<double>(0);
|
||||
final ValueNotifier<bool> available = ValueNotifier<bool>(false);
|
||||
|
||||
Timer? _timer;
|
||||
int _lastBytes = 0;
|
||||
int? _latestTotalBytes;
|
||||
int? get latestTotalBytes => _latestTotalBytes;
|
||||
DateTime? _lastSampleAt;
|
||||
int _sampleSessionId = 0;
|
||||
bool _disposed = false;
|
||||
bool _sampleInFlight = false;
|
||||
|
||||
|
||||
void start() {
|
||||
if (_disposed) return;
|
||||
_stopActiveSampling(resetValue: true);
|
||||
final sessionId = _sampleSessionId;
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (_disposed || sessionId != _sampleSessionId || _sampleInFlight) {
|
||||
return;
|
||||
}
|
||||
_sampleInFlight = true;
|
||||
unawaited(_sample(sessionId));
|
||||
});
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_stopActiveSampling(resetValue: true);
|
||||
}
|
||||
|
||||
Future<void> _sample(int sessionId) async {
|
||||
try {
|
||||
final totalBytes = await _totalRxBytes();
|
||||
if (_disposed || sessionId != _sampleSessionId) return;
|
||||
if (totalBytes == null) {
|
||||
available.value = false;
|
||||
speed.value = 0.0;
|
||||
return;
|
||||
}
|
||||
available.value = true;
|
||||
final sampled = _sampleSpeedFromTotalBytes(totalBytes);
|
||||
if (sampled != null) {
|
||||
speed.value = sampled;
|
||||
}
|
||||
} finally {
|
||||
if (!_disposed && sessionId == _sampleSessionId) {
|
||||
_sampleInFlight = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
double? _sampleSpeedFromTotalBytes(int totalBytes) {
|
||||
final now = _now();
|
||||
final lastSampleAt = _lastSampleAt;
|
||||
final lastBytes = _lastBytes;
|
||||
|
||||
_lastBytes = totalBytes;
|
||||
_latestTotalBytes = totalBytes;
|
||||
_lastSampleAt = now;
|
||||
|
||||
if (lastSampleAt == null) return null;
|
||||
if (totalBytes < lastBytes) return 0.0;
|
||||
|
||||
final elapsedMs = now.difference(lastSampleAt).inMilliseconds;
|
||||
final deltaBytes = totalBytes - lastBytes;
|
||||
if (elapsedMs <= 0 || deltaBytes <= 0) return 0.0;
|
||||
return deltaBytes * 1000.0 / elapsedMs;
|
||||
}
|
||||
|
||||
void _stopActiveSampling({required bool resetValue}) {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_sampleSessionId++;
|
||||
_sampleInFlight = false;
|
||||
_lastBytes = 0;
|
||||
_latestTotalBytes = null;
|
||||
_lastSampleAt = null;
|
||||
if (resetValue && !_disposed) {
|
||||
available.value = false;
|
||||
speed.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
if (_disposed) return;
|
||||
_stopActiveSampling(resetValue: false);
|
||||
_disposed = true;
|
||||
speed.dispose();
|
||||
available.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:screen_retriever/screen_retriever.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
|
||||
|
||||
class PipManager {
|
||||
static const _tag = 'Pip';
|
||||
static const double _aspectRatio = 16 / 9;
|
||||
static const Size _minPipSize = Size(360, 202.5);
|
||||
static const Size _defaultPipSize = Size(480, 270);
|
||||
static const Size _maxPipSize = Size(960, 540);
|
||||
static const Size _unboundedMaximumSize = Size(100000, 100000);
|
||||
static const double _edgeMargin = 24;
|
||||
|
||||
static const _keyPipX = 'smplayer.pip_last_x';
|
||||
static const _keyPipY = 'smplayer.pip_last_y';
|
||||
static const _keyPipWidth = 'smplayer.pip_last_width';
|
||||
static const _keyPipHeight = 'smplayer.pip_last_height';
|
||||
static const _keyPipAlwaysOnTop = 'smplayer.pip_always_on_top';
|
||||
|
||||
PipManager({required this._onStateChanged});
|
||||
|
||||
final VoidCallback _onStateChanged;
|
||||
|
||||
bool _isPip = false;
|
||||
bool _isChanging = false;
|
||||
bool _isDisposed = false;
|
||||
Rect? _windowBoundsBefore;
|
||||
bool _wasMaximized = false;
|
||||
bool _wasResizable = true;
|
||||
bool _wasAlwaysOnTop = false;
|
||||
Future<void>? _disposeRestoreFuture;
|
||||
Rect? _lastPipBounds;
|
||||
|
||||
|
||||
bool _pipAlwaysOnTop = true;
|
||||
|
||||
|
||||
static bool isActive = false;
|
||||
|
||||
bool get isPip => _isPip;
|
||||
|
||||
|
||||
bool get isAlwaysOnTop => _pipAlwaysOnTop;
|
||||
|
||||
Future<void> toggle() async {
|
||||
if (_isDisposed) return;
|
||||
if (_isChanging) return;
|
||||
if (_isPip) {
|
||||
await leave();
|
||||
} else {
|
||||
await enter();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> enter() async {
|
||||
if (_isDisposed) return;
|
||||
if (_isPip || _isChanging) return;
|
||||
_isChanging = true;
|
||||
try {
|
||||
_windowBoundsBefore = await windowManager.getBounds();
|
||||
_wasMaximized = await windowManager.isMaximized();
|
||||
_wasResizable = await windowManager.isResizable();
|
||||
_wasAlwaysOnTop = await windowManager.isAlwaysOnTop();
|
||||
_pipAlwaysOnTop = await _loadPersistedAlwaysOnTop();
|
||||
final display = await _tryPrimaryDisplay();
|
||||
|
||||
if (_wasMaximized) {
|
||||
await windowManager.unmaximize();
|
||||
}
|
||||
|
||||
await windowManager.setMinimumSize(_minPipSize);
|
||||
await windowManager.setMaximumSize(
|
||||
display == null ? _maxPipSize : pipMaximumSizeForDisplay(display),
|
||||
);
|
||||
await windowManager.setAspectRatio(_aspectRatio);
|
||||
await windowManager.setResizable(true);
|
||||
await windowManager.setAlwaysOnTop(_pipAlwaysOnTop);
|
||||
|
||||
final lastBounds = _lastPipBounds ?? await _loadPersistedPipBounds();
|
||||
final pipSize = lastBounds != null
|
||||
? Size(lastBounds.width, lastBounds.height)
|
||||
: _defaultPipSize;
|
||||
final bounds =
|
||||
lastBounds ??
|
||||
(display == null
|
||||
? _fallbackPipBounds(pipSize)
|
||||
: pipBoundsForDisplay(display, pipSize));
|
||||
await windowManager.setBounds(bounds);
|
||||
await windowManager.focus();
|
||||
|
||||
_setPipState(true);
|
||||
if (_isDisposed) {
|
||||
_scheduleDisposeRestore();
|
||||
}
|
||||
} catch (error, stack) {
|
||||
AppLogger.warn(_tag, 'Failed to enter pip', error);
|
||||
AppLogger.debug(_tag, stack.toString());
|
||||
try {
|
||||
await _restoreWindowState(updateState: false);
|
||||
} catch (_) {}
|
||||
} finally {
|
||||
_isChanging = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> leave({bool updateState = true}) async {
|
||||
if (_isDisposed && updateState) return;
|
||||
if (!_isPip) return;
|
||||
if (_isChanging) {
|
||||
return;
|
||||
}
|
||||
_isChanging = true;
|
||||
try {
|
||||
await _restoreWindowState(updateState: updateState);
|
||||
} finally {
|
||||
_isChanging = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> toggleAlwaysOnTop() => setAlwaysOnTop(!_pipAlwaysOnTop);
|
||||
|
||||
Future<void> setAlwaysOnTop(bool value) async {
|
||||
if (_isDisposed) return;
|
||||
if (!_isPip) return;
|
||||
if (_pipAlwaysOnTop == value) return;
|
||||
if (_isChanging) return;
|
||||
_isChanging = true;
|
||||
try {
|
||||
if (_isDisposed || !_isPip) return;
|
||||
await windowManager.setAlwaysOnTop(value);
|
||||
_pipAlwaysOnTop = value;
|
||||
await _persistAlwaysOnTop(value);
|
||||
if (!_isDisposed) {
|
||||
_onStateChanged();
|
||||
}
|
||||
} catch (error) {
|
||||
AppLogger.warn(_tag, 'Failed to set pip always-on-top', error);
|
||||
} finally {
|
||||
_isChanging = false;
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
if (_isDisposed) return;
|
||||
_isDisposed = true;
|
||||
if (_isPip) {
|
||||
_scheduleDisposeRestore();
|
||||
}
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<void>? get disposeRestoreFuture => _disposeRestoreFuture;
|
||||
|
||||
void _scheduleDisposeRestore() {
|
||||
if (_disposeRestoreFuture != null) return;
|
||||
final restore = _restoreAfterDispose();
|
||||
_disposeRestoreFuture = restore;
|
||||
unawaited(restore);
|
||||
}
|
||||
|
||||
Future<void> _restoreAfterDispose() async {
|
||||
while (_isChanging) {
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
}
|
||||
if (!_isPip) return;
|
||||
|
||||
_isChanging = true;
|
||||
try {
|
||||
await _restoreWindowState(updateState: false);
|
||||
} finally {
|
||||
_isChanging = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _restoreWindowState({required bool updateState}) async {
|
||||
try {
|
||||
final bounds = await windowManager.getBounds();
|
||||
_lastPipBounds = bounds;
|
||||
unawaited(_persistPipBounds(bounds));
|
||||
} catch (_) {}
|
||||
try {
|
||||
await windowManager.setAspectRatio(-1);
|
||||
await windowManager.setMaximumSize(_unboundedMaximumSize);
|
||||
|
||||
await windowManager.setMinimumSize(const Size(960, 640));
|
||||
await windowManager.setAlwaysOnTop(_wasAlwaysOnTop);
|
||||
await windowManager.setResizable(_wasResizable);
|
||||
|
||||
if (_wasMaximized) {
|
||||
await windowManager.maximize();
|
||||
} else {
|
||||
final bounds = _windowBoundsBefore;
|
||||
if (bounds != null) {
|
||||
await windowManager.setBounds(bounds);
|
||||
}
|
||||
}
|
||||
await windowManager.focus();
|
||||
} catch (error) {
|
||||
AppLogger.warn(_tag, 'Failed to restore window', error);
|
||||
}
|
||||
|
||||
_setPipState(false);
|
||||
}
|
||||
|
||||
void _setPipState(bool pip) {
|
||||
if (_isPip == pip) return;
|
||||
_isPip = pip;
|
||||
PipManager.isActive = pip;
|
||||
if (!_isDisposed) {
|
||||
_onStateChanged();
|
||||
}
|
||||
}
|
||||
|
||||
Future<Display?> _tryPrimaryDisplay() async {
|
||||
try {
|
||||
return await screenRetriever.getPrimaryDisplay();
|
||||
} catch (error) {
|
||||
AppLogger.warn(_tag, 'Failed to read primary display', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Rect _fallbackPipBounds(Size size) {
|
||||
final fallback =
|
||||
_windowBoundsBefore ?? Rect.fromLTWH(0, 0, size.width, size.height);
|
||||
return Rect.fromLTWH(fallback.left, fallback.top, size.width, size.height);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static Rect pipBoundsForDisplay(Display display, Size size) {
|
||||
final visibleRect = _visibleRectForDisplay(display);
|
||||
final fitted = _fitSizeToVisibleArea(size, visibleRect.size);
|
||||
var left = visibleRect.right - fitted.width - _edgeMargin;
|
||||
final top = visibleRect.bottom - fitted.height - _edgeMargin;
|
||||
|
||||
if (Platform.isWindows) {
|
||||
const border = 8.0;
|
||||
left -= border;
|
||||
}
|
||||
|
||||
return Rect.fromLTWH(left, top, fitted.width, fitted.height);
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
static Size pipMaximumSizeForDisplay(Display display) {
|
||||
final visibleRect = _visibleRectForDisplay(display);
|
||||
final fitted = _fitSizeToVisibleArea(_maxPipSize, visibleRect.size);
|
||||
if (fitted.width < _minPipSize.width ||
|
||||
fitted.height < _minPipSize.height) {
|
||||
return _minPipSize;
|
||||
}
|
||||
return fitted;
|
||||
}
|
||||
|
||||
static Rect _visibleRectForDisplay(Display display) {
|
||||
final visiblePos = display.visiblePosition ?? Offset.zero;
|
||||
final visibleSize = display.visibleSize ?? display.size;
|
||||
return visiblePos & visibleSize;
|
||||
}
|
||||
|
||||
static Size _fitSizeToVisibleArea(Size preferred, Size visibleSize) {
|
||||
final maxWidth = visibleSize.width - _edgeMargin * 2;
|
||||
final maxHeight = visibleSize.height - _edgeMargin * 2;
|
||||
if (maxWidth <= 0 || maxHeight <= 0) return preferred;
|
||||
|
||||
var width = preferred.width;
|
||||
var height = preferred.height;
|
||||
if (width > maxWidth) {
|
||||
width = maxWidth;
|
||||
height = width / _aspectRatio;
|
||||
}
|
||||
if (height > maxHeight) {
|
||||
height = maxHeight;
|
||||
width = height * _aspectRatio;
|
||||
}
|
||||
return Size(width, height);
|
||||
}
|
||||
|
||||
Future<Rect?> _loadPersistedPipBounds() async {
|
||||
try {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
final x = p.getDouble(_keyPipX);
|
||||
final y = p.getDouble(_keyPipY);
|
||||
final w = p.getDouble(_keyPipWidth);
|
||||
final h = p.getDouble(_keyPipHeight);
|
||||
if (x == null || y == null || w == null || h == null) return null;
|
||||
if (w <= 0 || h <= 0) return null;
|
||||
return Rect.fromLTWH(x, y, w, h);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _persistPipBounds(Rect bounds) async {
|
||||
try {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await Future.wait([
|
||||
p.setDouble(_keyPipX, bounds.left),
|
||||
p.setDouble(_keyPipY, bounds.top),
|
||||
p.setDouble(_keyPipWidth, bounds.width),
|
||||
p.setDouble(_keyPipHeight, bounds.height),
|
||||
]);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<bool> _loadPersistedAlwaysOnTop() async {
|
||||
try {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
return p.getBool(_keyPipAlwaysOnTop) ?? true;
|
||||
} catch (_) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _persistAlwaysOnTop(bool value) async {
|
||||
try {
|
||||
final p = await SharedPreferences.getInstance();
|
||||
await p.setBool(_keyPipAlwaysOnTop, value);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../../../core/contracts/trakt/trakt_response.dart';
|
||||
import '../../../core/contracts/trakt/trakt_scrobble_target.dart';
|
||||
import '../../../core/contracts/trakt/trakt_sync_job.dart';
|
||||
import '../../../core/domain/ports/authed_trakt_gateway.dart';
|
||||
import '../../../core/infra/trakt_api/trakt_pending_sync_queue.dart';
|
||||
import '../../../shared/utils/app_logger.dart';
|
||||
import 'trakt_scrobble_state_machine.dart';
|
||||
|
||||
const _tag = 'TraktScrobble';
|
||||
|
||||
|
||||
class TraktScrobbleReporter {
|
||||
TraktScrobbleReporter({
|
||||
required this._gateway,
|
||||
required this._queue,
|
||||
required this._target,
|
||||
required this._readProgress,
|
||||
this._appVersion,
|
||||
this._appDate,
|
||||
this._onSuccess,
|
||||
this._onError,
|
||||
});
|
||||
|
||||
final AuthedTraktGateway _gateway;
|
||||
final TraktPendingSyncQueue _queue;
|
||||
final TraktScrobbleTarget? _target;
|
||||
final double Function() _readProgress;
|
||||
final String? _appVersion;
|
||||
final String? _appDate;
|
||||
final void Function()? _onSuccess;
|
||||
final void Function(String error)? _onError;
|
||||
|
||||
final _sm = TraktScrobbleStateMachine();
|
||||
|
||||
bool get _enabled => _target != null;
|
||||
|
||||
Future<void> onPlay() => _dispatch(_sm.onPlay(), _readProgress());
|
||||
|
||||
Future<void> onPause() => _dispatch(_sm.onPause(), _readProgress());
|
||||
|
||||
Future<void> onCompleted() => _dispatch(_sm.onCompleted(), 100);
|
||||
|
||||
|
||||
Future<void> onStop() =>
|
||||
_dispatch(_sm.onStop(progress: _readProgress()), _readProgress());
|
||||
|
||||
Future<void> _dispatch(TraktScrobbleAction action, double progress) async {
|
||||
if (!_enabled) return;
|
||||
final type = _typeFor(action);
|
||||
if (type == null) return;
|
||||
|
||||
final clamped = progress.clamp(0.0, 100.0).toDouble();
|
||||
final body = _target!.toBody(
|
||||
progress: clamped,
|
||||
appVersion: _appVersion,
|
||||
appDate: _appDate,
|
||||
);
|
||||
|
||||
AppLogger.debug(
|
||||
_tag,
|
||||
'scrobble ${type.wire} progress=${clamped.toStringAsFixed(1)} '
|
||||
'(${_target.kind.name})',
|
||||
);
|
||||
|
||||
TraktScrobbleOutcome outcome;
|
||||
try {
|
||||
outcome = await _gateway.scrobble(type, body);
|
||||
} catch (e) {
|
||||
AppLogger.debug(_tag, 'scrobble threw, enqueue', e);
|
||||
await _enqueue(type, body);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (outcome.cls) {
|
||||
case TraktResponseClass.success:
|
||||
case TraktResponseClass.duplicate:
|
||||
AppLogger.debug(_tag, 'scrobble ${type.wire} ok (${outcome.cls.name})');
|
||||
await _queue.discardSupersededBy(type: type, body: body);
|
||||
_onSuccess?.call();
|
||||
case TraktResponseClass.invalid:
|
||||
AppLogger.debug(_tag, 'scrobble ${type.wire} invalid → drop');
|
||||
case TraktResponseClass.rateLimited:
|
||||
case TraktResponseClass.unauthorized:
|
||||
case TraktResponseClass.transient:
|
||||
await _enqueue(type, body);
|
||||
_onError?.call(outcome.cls.name);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _enqueue(
|
||||
TraktSyncJobType type,
|
||||
Map<String, dynamic> body,
|
||||
) async {
|
||||
try {
|
||||
await _queue.enqueue(type: type, body: body);
|
||||
} catch (e) {
|
||||
AppLogger.warn(_tag, 'enqueue failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
static TraktSyncJobType? _typeFor(TraktScrobbleAction action) {
|
||||
switch (action) {
|
||||
case TraktScrobbleAction.none:
|
||||
return null;
|
||||
case TraktScrobbleAction.start:
|
||||
return TraktSyncJobType.scrobbleStart;
|
||||
case TraktScrobbleAction.pause:
|
||||
return TraktSyncJobType.scrobblePause;
|
||||
case TraktScrobbleAction.stop:
|
||||
return TraktSyncJobType.scrobbleStop;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
|
||||
|
||||
enum TraktScrobbleState { idle, started, paused, stopped }
|
||||
|
||||
enum TraktScrobbleAction { none, start, pause, stop }
|
||||
|
||||
class TraktScrobbleStateMachine {
|
||||
TraktScrobbleState _state = TraktScrobbleState.idle;
|
||||
|
||||
TraktScrobbleState get state => _state;
|
||||
|
||||
|
||||
TraktScrobbleAction onPlay() {
|
||||
if (_state == TraktScrobbleState.started) return TraktScrobbleAction.none;
|
||||
if (_state == TraktScrobbleState.stopped) return TraktScrobbleAction.none;
|
||||
_state = TraktScrobbleState.started;
|
||||
return TraktScrobbleAction.start;
|
||||
}
|
||||
|
||||
|
||||
TraktScrobbleAction onPause() {
|
||||
if (_state != TraktScrobbleState.started) return TraktScrobbleAction.none;
|
||||
_state = TraktScrobbleState.paused;
|
||||
return TraktScrobbleAction.pause;
|
||||
}
|
||||
|
||||
|
||||
TraktScrobbleAction onCompleted() {
|
||||
if (_state == TraktScrobbleState.stopped) return TraktScrobbleAction.none;
|
||||
if (_state == TraktScrobbleState.idle) return TraktScrobbleAction.none;
|
||||
_state = TraktScrobbleState.stopped;
|
||||
return TraktScrobbleAction.stop;
|
||||
}
|
||||
|
||||
|
||||
TraktScrobbleAction onStop({required double progress}) {
|
||||
if (_state == TraktScrobbleState.idle ||
|
||||
_state == TraktScrobbleState.stopped) {
|
||||
return TraktScrobbleAction.none;
|
||||
}
|
||||
_state = TraktScrobbleState.stopped;
|
||||
if (progress < 1) return TraktScrobbleAction.none;
|
||||
return TraktScrobbleAction.stop;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user