提交 Nontonki 短剧原型,准备推送到远程仓库。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
b1f6ce4bf9
148 files changed
+11175
No files matched your search
@@ -0,0 +1,30 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Native Android activity PiP; the Flutter playback session keeps the player.
|
||||
class AndroidPictureInPicture {
|
||||
AndroidPictureInPicture({MethodChannel? channel})
|
||||
: _channel = channel ?? const MethodChannel('com.stormg.lumadrama/pip');
|
||||
|
||||
final MethodChannel _channel;
|
||||
|
||||
bool get isPlatformSupported =>
|
||||
!kIsWeb && defaultTargetPlatform == TargetPlatform.android;
|
||||
|
||||
void setStateListener(ValueChanged<bool>? listener) {
|
||||
_channel.setMethodCallHandler(
|
||||
listener == null
|
||||
? null
|
||||
: (call) async {
|
||||
if (call.method == 'onPipChanged') {
|
||||
listener(call.arguments == true);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> enter() async {
|
||||
if (!isPlatformSupported) return false;
|
||||
return await _channel.invokeMethod<bool>('enter') ?? false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/drama.dart';
|
||||
import '../repositories/catalog_repository.dart';
|
||||
|
||||
class CatalogController extends ChangeNotifier {
|
||||
CatalogController(this.repository);
|
||||
final CatalogRepository repository;
|
||||
|
||||
List<Drama> dramas = const [];
|
||||
bool loading = false;
|
||||
String? error;
|
||||
|
||||
Future<void> load() async {
|
||||
loading = true;
|
||||
error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
dramas = await repository.fetchDramas();
|
||||
} catch (_) {
|
||||
error = 'The catalog could not be loaded.';
|
||||
} finally {
|
||||
loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/engagement_snapshot.dart';
|
||||
import '../repositories/engagement_repository.dart';
|
||||
|
||||
/// Local demo interactions, indexed by drama ID. No public/social backend.
|
||||
class EngagementController extends ChangeNotifier {
|
||||
EngagementController(this._repository);
|
||||
|
||||
final EngagementRepository _repository;
|
||||
final Set<String> _saved = {};
|
||||
final Set<String> _liked = {};
|
||||
final Map<String, List<String>> _comments = {};
|
||||
Future<void> _loadFuture = Future<void>.value();
|
||||
Future<void> _pendingSave = Future<void>.value();
|
||||
|
||||
bool isSaved(String dramaId) => _saved.contains(dramaId);
|
||||
bool isLiked(String dramaId) => _liked.contains(dramaId);
|
||||
List<String> commentsFor(String dramaId) =>
|
||||
List.unmodifiable(_comments[dramaId] ?? const []);
|
||||
|
||||
Future<void> load() => _loadFuture = _loadFromRepository();
|
||||
|
||||
Future<void> _loadFromRepository() async {
|
||||
try {
|
||||
final snapshot = await _repository.load();
|
||||
_saved.addAll(snapshot.savedDramaIds);
|
||||
_liked.addAll(snapshot.likedDramaIds);
|
||||
for (final entry in snapshot.commentsByDrama.entries) {
|
||||
_comments[entry.key] = List.of(entry.value);
|
||||
}
|
||||
notifyListeners();
|
||||
} catch (_) {
|
||||
// Local preferences are best effort for this demo.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleSaved(String dramaId) async {
|
||||
await _loadFuture;
|
||||
if (!_saved.add(dramaId)) _saved.remove(dramaId);
|
||||
notifyListeners();
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> toggleLiked(String dramaId) async {
|
||||
await _loadFuture;
|
||||
if (!_liked.add(dramaId)) _liked.remove(dramaId);
|
||||
notifyListeners();
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> addComment(String dramaId, String text) async {
|
||||
await _loadFuture;
|
||||
final clean = text.trim();
|
||||
if (clean.isEmpty) return;
|
||||
(_comments[dramaId] ??= []).add(
|
||||
clean.length > 300 ? clean.substring(0, 300) : clean,
|
||||
);
|
||||
notifyListeners();
|
||||
await _persist();
|
||||
}
|
||||
|
||||
Future<void> _persist() {
|
||||
final snapshot = EngagementSnapshot(
|
||||
savedDramaIds: Set.of(_saved),
|
||||
likedDramaIds: Set.of(_liked),
|
||||
commentsByDrama: {
|
||||
for (final entry in _comments.entries) entry.key: List.of(entry.value),
|
||||
},
|
||||
);
|
||||
_pendingSave = _pendingSave
|
||||
.then((_) => _repository.save(snapshot))
|
||||
.catchError((Object _) {
|
||||
// Keep the current in-memory state if local persistence fails.
|
||||
});
|
||||
return _pendingSave;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class PlatformShareService {
|
||||
const PlatformShareService();
|
||||
|
||||
static const _channel = MethodChannel('com.stormg.lumadrama/share');
|
||||
|
||||
Future<void> shareDrama({
|
||||
required String title,
|
||||
required String synopsis,
|
||||
required String footer,
|
||||
}) => _channel.invokeMethod<void>('shareText', {
|
||||
'text': '$title\n$synopsis\n$footer',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import '../models/drama.dart';
|
||||
|
||||
/// A player session has one navigation meaning: browse dramas or browse episodes.
|
||||
class PlaybackQueue {
|
||||
PlaybackQueue.home(List<Drama> dramas)
|
||||
: entries = List.unmodifiable(
|
||||
dramas
|
||||
.where((drama) => drama.episodes.isNotEmpty)
|
||||
.map((drama) => PlaybackEntry(drama, drama.episodes.first)),
|
||||
);
|
||||
|
||||
PlaybackQueue.series(Drama drama)
|
||||
: entries = List.unmodifiable(
|
||||
drama.episodes.map((episode) => PlaybackEntry(drama, episode)),
|
||||
);
|
||||
|
||||
final List<PlaybackEntry> entries;
|
||||
|
||||
int get length => entries.length;
|
||||
PlaybackEntry entryAt(int index) => entries[index];
|
||||
int? nextIndex(int index) => index + 1 < entries.length ? index + 1 : null;
|
||||
}
|
||||
|
||||
class PlaybackEntry {
|
||||
const PlaybackEntry(this.drama, this.episode);
|
||||
final Drama drama;
|
||||
final Episode episode;
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/drama.dart';
|
||||
import '../models/player_port.dart';
|
||||
|
||||
typedef PlayerFactory = PlayerPort Function(Episode episode);
|
||||
typedef PlayerPreparation =
|
||||
Future<void> Function(Episode episode, PlayerPort player);
|
||||
|
||||
/// Shared by routes: the previous session releases all native players before
|
||||
/// another session allocates. The app therefore has at most two, not two per page.
|
||||
class PlaybackCoordinator {
|
||||
PlaybackSession? _owner;
|
||||
Future<void> _handoff = Future<void>.value();
|
||||
|
||||
Future<bool> _acquire(PlaybackSession session, bool Function() valid) {
|
||||
final result = _handoff.then((_) async {
|
||||
if (!valid()) return false;
|
||||
if (_owner != session) {
|
||||
final previous = _owner;
|
||||
await previous?._releasePlayers();
|
||||
// A failed native disposal cannot be treated as a free decoder slot.
|
||||
if (previous != null && previous.livePlayerCount != 0) return false;
|
||||
_owner = null;
|
||||
if (!valid()) return false;
|
||||
_owner = session;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
_handoff = result.then<void>((_) {});
|
||||
return result;
|
||||
}
|
||||
|
||||
void _forget(PlaybackSession session) {
|
||||
if (_owner == session && session.livePlayerCount == 0) _owner = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns current + one prepared successor. All allocation, playback and disposal
|
||||
/// operations are serialized; initialization can run while current plays.
|
||||
class PlaybackSession extends ChangeNotifier {
|
||||
PlaybackSession({
|
||||
required this.episodes,
|
||||
required this.factory,
|
||||
required this.canPlay,
|
||||
this.prepare,
|
||||
this.activate,
|
||||
PlaybackCoordinator? coordinator,
|
||||
}) : _coordinator = coordinator ?? PlaybackCoordinator();
|
||||
|
||||
final List<Episode> episodes;
|
||||
final PlayerFactory factory;
|
||||
final bool Function(Episode) canPlay;
|
||||
|
||||
/// Runs once before readiness, also for silent preloads (e.g. resume seek).
|
||||
final PlayerPreparation? prepare;
|
||||
|
||||
/// Runs only when a slot becomes current (e.g. progress listeners).
|
||||
final PlayerPreparation? activate;
|
||||
final PlaybackCoordinator _coordinator;
|
||||
final Set<_PlayerSlot> _live = {};
|
||||
_PlayerSlot? _active;
|
||||
_PlayerSlot? _next;
|
||||
Future<void> _operations = Future<void>.value();
|
||||
Future<void> _preloadTask = Future<void>.value();
|
||||
int _generation = 0;
|
||||
int _currentIndex = 0;
|
||||
int? _nextIndex;
|
||||
bool _selected = false;
|
||||
bool _loading = false;
|
||||
bool _closed = false;
|
||||
bool _appResumed = true;
|
||||
bool _routeVisible = true;
|
||||
bool _overlayOpen = false;
|
||||
bool _userWantsPlay = true;
|
||||
String? _error;
|
||||
|
||||
int get currentIndex => _currentIndex;
|
||||
Episode get currentEpisode => episodes[_currentIndex];
|
||||
PlayerPort? get active =>
|
||||
_active?.index == _currentIndex ? _active?.player : null;
|
||||
bool get loading => _loading;
|
||||
bool get userWantsPlay => _userWantsPlay;
|
||||
bool get canAutoAdvance => _mayPlay && !_loading && active != null;
|
||||
bool get isLocked => !canPlay(currentEpisode);
|
||||
String? get error => _error;
|
||||
// Includes initialization and asynchronous disposal, until native release ends.
|
||||
int get livePlayerCount => _live.length;
|
||||
int? get preloadedIndex => _next?.prepared == true ? _next?.index : null;
|
||||
Future<void> get playbackSettled => _operations;
|
||||
Future<void> get preloadSettled => _preloadTask;
|
||||
|
||||
bool get _visible => !_closed && _appResumed && _routeVisible;
|
||||
bool get _mayPlay => _visible && !_overlayOpen && _userWantsPlay;
|
||||
|
||||
/// Exposes only prepared slots so the adjacent page can mount its video texture
|
||||
/// during a swipe without starting audio or registering history listeners.
|
||||
PlayerPort? preparedPlayerAt(int index) {
|
||||
for (final slot in [_active, _next]) {
|
||||
if (slot?.index == index && slot!.prepared) return slot.player;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _enqueue(Future<void> Function() operation) {
|
||||
final task = _operations.then((_) => operation());
|
||||
// A plugin failure must not poison subsequent transitions or cleanup.
|
||||
_operations = task.catchError((Object error) {
|
||||
if (!_closed) {
|
||||
_error = error.toString();
|
||||
_loading = false;
|
||||
_notify();
|
||||
}
|
||||
});
|
||||
return _operations;
|
||||
}
|
||||
|
||||
_PlayerSlot _create(int index) {
|
||||
if (_live.length >= 2) throw StateError('Playback resource limit reached');
|
||||
final slot = _PlayerSlot(index, factory(episodes[index]));
|
||||
_live.add(slot);
|
||||
slot.ready = () async {
|
||||
try {
|
||||
await slot.player.initialize();
|
||||
if (slot.retiring || _closed) return;
|
||||
await prepare?.call(episodes[index], slot.player);
|
||||
if (!slot.retiring && !_closed) slot.prepared = true;
|
||||
} catch (error) {
|
||||
slot.failure = error;
|
||||
}
|
||||
}();
|
||||
return slot;
|
||||
}
|
||||
|
||||
Future<void> _retire(_PlayerSlot? slot) async {
|
||||
if (slot == null || !_live.contains(slot)) return;
|
||||
slot.retiring = true;
|
||||
await slot.ready;
|
||||
try {
|
||||
await slot.player.pause();
|
||||
} finally {
|
||||
await slot.player.dispose();
|
||||
_live.remove(slot);
|
||||
}
|
||||
}
|
||||
|
||||
/// preloadIndex comes from the visible queue, so filtered categories and series
|
||||
/// use exactly the same mechanism without warming an unrelated episode.
|
||||
Future<void> select(int index, {int? preloadIndex}) async {
|
||||
if (_closed || index < 0 || index >= episodes.length) return;
|
||||
final request = ++_generation;
|
||||
_selected = true;
|
||||
_currentIndex = index;
|
||||
_nextIndex = preloadIndex;
|
||||
_error = null;
|
||||
_loading = preparedPlayerAt(index) == null && !isLocked;
|
||||
_notify();
|
||||
if (!_visible) return;
|
||||
final acquired = await _coordinator._acquire(
|
||||
this,
|
||||
() => _visible && request == _generation,
|
||||
);
|
||||
if (!acquired) {
|
||||
if (!_closed && request == _generation && _visible) {
|
||||
_loading = false;
|
||||
_error = 'Previous playback resources could not be released';
|
||||
_notify();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_PlayerSlot? target;
|
||||
await _enqueue(() async {
|
||||
if (_closed || request != _generation) return;
|
||||
final old = _active;
|
||||
if (old != null &&
|
||||
old.index == index &&
|
||||
old.failure == null &&
|
||||
!isLocked) {
|
||||
target = old;
|
||||
return;
|
||||
}
|
||||
if (old != null) await old.player.pause();
|
||||
final warm = _next;
|
||||
_active = null;
|
||||
_next = null;
|
||||
final hit = warm?.index == index && warm?.failure == null && !isLocked;
|
||||
if (hit) {
|
||||
target = _active = warm;
|
||||
_loading = !warm!.prepared;
|
||||
_notify();
|
||||
// Start a ready successor before awaiting slow disposal of the old one.
|
||||
if (warm.prepared) await _start(warm, request);
|
||||
}
|
||||
await _retire(old);
|
||||
if (!hit) await _retire(warm);
|
||||
if (_closed || request != _generation || isLocked) return;
|
||||
if (target == null) {
|
||||
target = _active = _create(index);
|
||||
_loading = true;
|
||||
_notify();
|
||||
}
|
||||
});
|
||||
final slot = target;
|
||||
if (slot == null) return;
|
||||
await slot.ready;
|
||||
if (_closed || request != _generation || _active != slot) return;
|
||||
await _enqueue(() => _start(slot, request));
|
||||
if (!_closed && request == _generation) _schedulePreload();
|
||||
}
|
||||
|
||||
Future<void> _start(_PlayerSlot slot, int request) async {
|
||||
bool valid() => !_closed && request == _generation && _active == slot;
|
||||
if (!valid()) return;
|
||||
if (slot.failure != null) {
|
||||
_error = slot.failure.toString();
|
||||
_loading = false;
|
||||
_notify();
|
||||
return;
|
||||
}
|
||||
if (!slot.prepared) return;
|
||||
if (!slot.activated) {
|
||||
await activate?.call(episodes[slot.index], slot.player);
|
||||
if (!valid()) return;
|
||||
slot.activated = true;
|
||||
}
|
||||
_loading = false;
|
||||
_notify();
|
||||
await _applyPlayback(slot);
|
||||
}
|
||||
|
||||
Future<void> _applyPlayback(_PlayerSlot slot) async {
|
||||
if (_closed || _active != slot || !slot.activated) return;
|
||||
final shouldPlay = _mayPlay && slot.index == _currentIndex && !isLocked;
|
||||
if (shouldPlay == slot.playing) return;
|
||||
if (shouldPlay) {
|
||||
await slot.player.play();
|
||||
} else {
|
||||
await slot.player.pause();
|
||||
}
|
||||
slot.playing = shouldPlay;
|
||||
}
|
||||
|
||||
void _schedulePreload() {
|
||||
final request = _generation;
|
||||
final index = _nextIndex;
|
||||
_preloadTask = () async {
|
||||
_PlayerSlot? candidate;
|
||||
await _enqueue(() async {
|
||||
if (_closed || request != _generation) return;
|
||||
final allowed =
|
||||
index != null &&
|
||||
index >= 0 &&
|
||||
index < episodes.length &&
|
||||
index != _currentIndex &&
|
||||
canPlay(episodes[index]);
|
||||
if (!allowed || _next?.index != index) {
|
||||
final stale = _next;
|
||||
_next = null;
|
||||
await _retire(stale);
|
||||
}
|
||||
if (!_visible ||
|
||||
_overlayOpen ||
|
||||
request != _generation ||
|
||||
_active?.prepared != true ||
|
||||
!allowed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
candidate = _next ??= _create(index);
|
||||
} catch (_) {
|
||||
// Optional allocation failure must not interrupt the playing item.
|
||||
}
|
||||
});
|
||||
final slot = candidate;
|
||||
if (slot == null) return;
|
||||
await slot.ready;
|
||||
if (_closed || _next != slot) return;
|
||||
if (slot.failure != null) {
|
||||
// Optional work fails silently. A later explicit selection retries.
|
||||
await _enqueue(() async {
|
||||
if (_next != slot) return;
|
||||
_next = null;
|
||||
await _retire(slot);
|
||||
});
|
||||
} else {
|
||||
_notify();
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
void setAppResumed(bool value) {
|
||||
_appResumed = value;
|
||||
_syncPlayback();
|
||||
}
|
||||
|
||||
void setRouteVisible(bool value) {
|
||||
_routeVisible = value;
|
||||
_syncPlayback();
|
||||
}
|
||||
|
||||
void setOverlayOpen(bool value) {
|
||||
_overlayOpen = value;
|
||||
_syncPlayback();
|
||||
}
|
||||
|
||||
void setUserWantsPlay(bool value) {
|
||||
_userWantsPlay = value;
|
||||
_syncPlayback();
|
||||
_notify();
|
||||
}
|
||||
|
||||
void _syncPlayback() {
|
||||
if (_closed) return;
|
||||
if (_visible && _selected && active == null) {
|
||||
unawaited(select(_currentIndex, preloadIndex: _nextIndex));
|
||||
return;
|
||||
}
|
||||
final slot = _active;
|
||||
if (slot != null) unawaited(_enqueue(() => _applyPlayback(slot)));
|
||||
if (_visible && !_overlayOpen && !_loading) _schedulePreload();
|
||||
}
|
||||
|
||||
Future<void> _releasePlayers() {
|
||||
_generation++;
|
||||
return _enqueue(() async {
|
||||
_active = _next = null;
|
||||
_loading = false;
|
||||
_notify();
|
||||
for (final slot in _live.toList()) {
|
||||
await _retire(slot);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
if (!_closed) notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
if (_closed) return;
|
||||
_closed = true;
|
||||
await _releasePlayers();
|
||||
_coordinator._forget(this);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayerSlot {
|
||||
_PlayerSlot(this.index, this.player);
|
||||
final int index;
|
||||
final PlayerPort player;
|
||||
late final Future<void> ready;
|
||||
bool prepared = false;
|
||||
bool activated = false;
|
||||
bool playing = false;
|
||||
bool retiring = false;
|
||||
Object? failure;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../models/drama.dart';
|
||||
import '../models/player_port.dart';
|
||||
|
||||
class VideoPlayerPort implements PlayerPort {
|
||||
VideoPlayerPort(
|
||||
Episode episode, {
|
||||
Future<ClosedCaptionFile>? closedCaptionFile,
|
||||
}) : controller = VideoPlayerController.asset(
|
||||
episode.assetPath,
|
||||
closedCaptionFile: closedCaptionFile,
|
||||
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: false),
|
||||
);
|
||||
|
||||
final VideoPlayerController controller;
|
||||
Future<void>? _initialization;
|
||||
bool _disposed = false;
|
||||
|
||||
@override
|
||||
Future<void> initialize() {
|
||||
return _initialization ??= controller.initialize().then((_) async {
|
||||
if (!_disposed) await controller.setLooping(false);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> play() async {
|
||||
if (!_disposed && controller.value.isInitialized) await controller.play();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> pause() async {
|
||||
if (!_disposed && controller.value.isInitialized) await controller.pause();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
try {
|
||||
await _initialization;
|
||||
} catch (_) {
|
||||
// The native controller still needs disposal after failed initialization.
|
||||
}
|
||||
await controller.dispose();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user