提交 Nontonki 短剧原型,准备推送到远程仓库。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
VickyandCursor committed 2026-10-13 16:23:08 +08:00
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();
}
}