Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -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;
}
}