提交 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

+71
View File
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'core/config/locale_controller.dart';
import 'core/data/repositories/catalog_repository.dart';
import 'core/data/repositories/engagement_repository.dart';
import 'core/data/services/catalog_controller.dart';
import 'core/data/services/engagement_controller.dart';
import 'core/data/services/playback_session.dart';
import 'core/data/services/video_player_port.dart';
import 'l10n/app_localizations.dart';
import 'pages/home/home_view.dart';
class LumaDramaApp extends StatelessWidget {
const LumaDramaApp({super.key, this.playerFactory = VideoPlayerPort.new});
final PlayerFactory playerFactory;
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider(create: (_) => PlaybackCoordinator()),
Provider<PlayerFactory>(create: (_) => playerFactory),
ChangeNotifierProvider(
create: (_) => CatalogController(DemoCatalogRepository())..load(),
),
ChangeNotifierProvider(create: (_) => LocaleController()..load()),
ChangeNotifierProvider(
create: (_) =>
EngagementController(SharedPreferencesEngagementRepository())
..load(),
),
],
child: Consumer<LocaleController>(
builder: (context, locale, _) => MaterialApp(
onGenerateTitle: (context) => AppLocalizations.of(context)!.appName,
locale: locale.locale,
supportedLocales: AppLocalizations.supportedLocales,
localizationsDelegates: AppLocalizations.localizationsDelegates,
localeResolutionCallback: (deviceLocale, supported) {
final resolved = locale.locale ?? deviceLocale;
if (resolved == null) return supported.first;
for (final item in supported) {
if (item.languageCode == resolved.languageCode) return item;
}
return supported.first;
},
debugShowCheckedModeBanner: false,
themeMode: ThemeMode.system,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF6D4AFF),
),
scaffoldBackgroundColor: const Color(0xFFF8F7FC),
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF9B86FF),
brightness: Brightness.dark,
),
scaffoldBackgroundColor: const Color(0xFF101019),
),
home: const HomePage(),
),
),
);
}
}
+39
View File
@@ -0,0 +1,39 @@
import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';
class LocaleController extends ChangeNotifier {
LocaleController({SharedPreferencesAsync? preferences})
: _preferences = preferences ?? SharedPreferencesAsync();
final SharedPreferencesAsync _preferences;
Locale? _locale;
Locale? get locale => _locale;
Future<void> load() async {
try {
final code = await _preferences.getString('ui.locale');
_locale = switch (code) {
'en' => const Locale('en'),
'zh' => const Locale('zh'),
_ => null,
};
notifyListeners();
} catch (_) {
// The system locale remains usable if preferences are unavailable.
}
}
Future<void> select(String code) async {
_locale = switch (code) {
'en' => const Locale('en'),
'zh' => const Locale('zh'),
_ => null,
};
notifyListeners();
try {
await _preferences.setString('ui.locale', code);
} catch (_) {
// Preference persistence is best effort for this prototype.
}
}
}
+35
View File
@@ -0,0 +1,35 @@
enum DramaFormat { animated, liveAction }
class Episode {
const Episode({
required this.id,
required this.number,
required this.title,
required this.assetPath,
required this.isFree,
});
final String id;
final int number;
final String title;
final String assetPath;
final bool isFree;
}
class Drama {
const Drama({
required this.id,
required this.title,
required this.synopsis,
required this.posterAsset,
required this.episodes,
this.format = DramaFormat.liveAction,
});
final String id;
final String title;
final String synopsis;
final String posterAsset;
final List<Episode> episodes;
final DramaFormat format;
}
@@ -0,0 +1,11 @@
class EngagementSnapshot {
const EngagementSnapshot({
this.savedDramaIds = const {},
this.likedDramaIds = const {},
this.commentsByDrama = const {},
});
final Set<String> savedDramaIds;
final Set<String> likedDramaIds;
final Map<String, List<String>> commentsByDrama;
}
+6
View File
@@ -0,0 +1,6 @@
abstract interface class PlayerPort {
Future<void> initialize();
Future<void> play();
Future<void> pause();
Future<void> dispose();
}
@@ -0,0 +1,104 @@
import '../models/drama.dart';
abstract interface class CatalogRepository {
Future<List<Drama>> fetchDramas();
}
class DemoCatalogRepository implements CatalogRepository {
@override
Future<List<Drama>> fetchDramas() async => const [
Drama(
id: 'the-signal-demo',
title: 'The Signal',
synopsis:
'A mock series for testing vertical episode playback with one reused local clip.',
posterAsset: 'assets/images/stitch_signal_poster_clean.png',
format: DramaFormat.animated,
episodes: [
Episode(
id: 'signal-01',
number: 1,
title: 'Episode 1',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
Episode(
id: 'signal-02',
number: 2,
title: 'Episode 2',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
Episode(
id: 'signal-03',
number: 3,
title: 'Episode 3',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
],
),
Drama(
id: 'city-lights-demo',
title: 'City Lights & Velvet Nights',
synopsis:
'A mock romance series using the same local video for navigation testing.',
posterAsset: 'assets/images/stitch_city_poster_clean.png',
format: DramaFormat.liveAction,
episodes: [
Episode(
id: 'city-01',
number: 1,
title: 'Episode 1',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
Episode(
id: 'city-02',
number: 2,
title: 'Episode 2',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
Episode(
id: 'city-03',
number: 3,
title: 'Episode 3',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
],
),
Drama(
id: 'vengeance-demo',
title: 'Vengeance Rising',
synopsis:
'A mock action series using the same local video for navigation testing.',
posterAsset: 'assets/images/stitch_vengeance_poster_clean.png',
format: DramaFormat.liveAction,
episodes: [
Episode(
id: 'vengeance-01',
number: 1,
title: 'Episode 1',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
Episode(
id: 'vengeance-02',
number: 2,
title: 'Episode 2',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
Episode(
id: 'vengeance-03',
number: 3,
title: 'Episode 3',
assetPath: 'assets/media/demo_clip.mp4',
isFree: true,
),
],
),
];
}
@@ -0,0 +1,68 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/engagement_snapshot.dart';
abstract interface class EngagementRepository {
Future<EngagementSnapshot> load();
Future<void> save(EngagementSnapshot snapshot);
}
class SharedPreferencesEngagementRepository implements EngagementRepository {
SharedPreferencesEngagementRepository({SharedPreferencesAsync? preferences})
: _preferences = preferences ?? SharedPreferencesAsync();
static const _key = 'engagement.v1.demo-local-v1';
final SharedPreferencesAsync _preferences;
@override
Future<EngagementSnapshot> load() async {
final raw = await _preferences.getString(_key);
if (raw == null) return const EngagementSnapshot();
try {
final json = jsonDecode(raw);
if (json is! Map<String, dynamic>) return const EngagementSnapshot();
Set<String> ids(Object? value) =>
value is List ? value.whereType<String>().toSet() : <String>{};
final comments = <String, List<String>>{};
final rawComments = json['comments'];
if (rawComments is Map<String, dynamic>) {
for (final entry in rawComments.entries) {
if (entry.value is List) {
comments[entry.key] = (entry.value as List)
.whereType<String>()
.toList();
}
}
}
return EngagementSnapshot(
savedDramaIds: ids(json['saved']),
likedDramaIds: ids(json['liked']),
commentsByDrama: comments,
);
} catch (_) {
return const EngagementSnapshot();
}
}
@override
Future<void> save(EngagementSnapshot snapshot) => _preferences.setString(
_key,
jsonEncode({
'saved': snapshot.savedDramaIds.toList(),
'liked': snapshot.likedDramaIds.toList(),
'comments': snapshot.commentsByDrama,
}),
);
}
class InMemoryEngagementRepository implements EngagementRepository {
EngagementSnapshot snapshot = const EngagementSnapshot();
@override
Future<EngagementSnapshot> load() async => snapshot;
@override
Future<void> save(EngagementSnapshot value) async => snapshot = value;
}
@@ -0,0 +1,69 @@
import 'package:shared_preferences/shared_preferences.dart';
const demoProfileId = 'demo-local-v1';
abstract interface class WatchHistoryRepository {
Future<Duration?> positionFor(
String profileId,
String dramaId,
String episodeId,
);
Future<String?> lastEpisodeId(String profileId, String dramaId);
Future<void> save(
String profileId,
String dramaId,
String episodeId,
Duration position,
);
}
class SharedPreferencesWatchHistoryRepository
implements WatchHistoryRepository {
SharedPreferencesWatchHistoryRepository({SharedPreferencesAsync? preferences})
: _preferences = preferences ?? SharedPreferencesAsync();
final SharedPreferencesAsync _preferences;
String _positionKey(String profile, String drama, String episode) =>
'watch.v1.$profile.$drama.$episode.positionMs';
String _lastKey(String profile, String drama) =>
'watch.v1.$profile.$drama.lastEpisode';
@override
Future<Duration?> positionFor(
String profileId,
String dramaId,
String episodeId,
) async {
final milliseconds = await _preferences.getInt(
_positionKey(profileId, dramaId, episodeId),
);
return milliseconds == null ? null : Duration(milliseconds: milliseconds);
}
@override
Future<String?> lastEpisodeId(String profileId, String dramaId) =>
_preferences.getString(_lastKey(profileId, dramaId));
@override
Future<void> save(
String profileId,
String dramaId,
String episodeId,
Duration position,
) async {
await _preferences.setInt(
_positionKey(profileId, dramaId, episodeId),
position.inMilliseconds,
);
await _preferences.setString(_lastKey(profileId, dramaId), episodeId);
}
}
Duration clampResumePosition(Duration saved, Duration duration) {
if (duration <= const Duration(milliseconds: 500)) return Duration.zero;
if (saved <= Duration.zero) return Duration.zero;
final lastUsefulPosition = duration - const Duration(milliseconds: 500);
if (saved >= lastUsefulPosition) return Duration.zero;
return saved;
}
@@ -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();
}
}
@@ -0,0 +1,27 @@
import '../../l10n/app_localizations.dart';
import '../data/models/drama.dart';
String localizedDramaTitle(AppLocalizations l10n, Drama drama) =>
switch (drama.id) {
'the-signal-demo' => l10n.dramaSignalTitle,
'city-lights-demo' => l10n.dramaCityTitle,
'vengeance-demo' => l10n.dramaVengeanceTitle,
_ => drama.title,
};
String localizedDramaSynopsis(AppLocalizations l10n, Drama drama) =>
switch (drama.id) {
'the-signal-demo' => l10n.dramaSignalSynopsis,
'city-lights-demo' => l10n.dramaCitySynopsis,
'vengeance-demo' => l10n.dramaVengeanceSynopsis,
_ => drama.synopsis,
};
String localizedEpisodeTitle(AppLocalizations l10n, int number) =>
switch (number) {
1 => l10n.episodeOneTitle,
2 => l10n.episodeTwoTitle,
3 => l10n.episodeThreeTitle,
4 => l10n.episodeFourTitle,
_ => l10n.episodeLabel(number),
};
+134
View File
@@ -0,0 +1,134 @@
{
"@@locale": "en",
"appName": "Nontonki",
"demo": "DEMO",
"homeHeadline": "Watch now",
"homeSubtitle": "Free short drama preview.",
"mediaNotice": "User-provided local demo video · Free preview",
"featuredDemo": "FEATURED · TECHNICAL DEMO",
"demoTitle": "The Signal",
"demoSynopsis": "A short local video preview. More episodes can be added when media is available.",
"homeTab": "Home",
"openQuickMenu": "Open quick menu",
"quickAccess": "Quick access",
"currentSeries": "Current series",
"discoverTab": "Discover",
"searchDiscover": "Search dramas, actors, genres...",
"featured": "Featured",
"suspense": "Suspense",
"romance": "Romance",
"actionGenre": "Action",
"filter": "Filter",
"topCharts": "Top Charts",
"newReleases": "New Releases",
"trendingMicroDramas": "Trending Micro-Dramas",
"seeAll": "See All",
"artworkOnly": "Artwork",
"artworkOnlyNotice": "This is a visual catalog item. No video is available yet.",
"videoPending": "Video pending",
"watchNow": "Watch free",
"noSearchResults": "No matching dramas.",
"demoCatalogNotice": "Three demo dramas share the same local video.",
"myTab": "My",
"localViewer": "Local Viewer",
"localDemoProfile": "Local demo profile",
"continueWatching": "Continue Watching",
"watchedPercent": "Watched {percent}%",
"@watchedPercent": {"placeholders": {"percent": {"type": "int"}}},
"resume": "Resume",
"history": "History",
"watchlist": "Watchlist",
"likedTab": "Liked",
"all": "All",
"inProgress": "In Progress",
"completed": "Completed",
"noSavedShows": "No shows here yet.",
"preferences": "Preferences",
"appLanguage": "App Language",
"followingTab": "Following",
"animatedTab": "Animated",
"recommendedTab": "Recommended",
"noCategoryDramas": "No dramas in this category yet.",
"trendingTab": "Trending",
"liveActionTab": "Live Action",
"search": "Search",
"free": "Free",
"mute": "Mute",
"unmute": "Unmute",
"like": "Like",
"save": "Save",
"comments": "Comments",
"share": "Share",
"localOnlyComments": "Only visible on this device",
"noComments": "No comments yet.",
"writeComment": "Write a comment",
"postComment": "Post",
"shareFailed": "Could not open the share sheet.",
"nowPlaying": "Playing now",
"watchFullscreen": "Watch in landscape",
"exitFullscreen": "Exit fullscreen",
"pictureInPicture": "Picture in picture",
"pipUnavailable": "Picture in picture is unavailable on this device",
"originalTag": "Original",
"dramaTag": "Short drama",
"episodeLabel": "Episode {number}",
"@episodeLabel": {"placeholders": {"number": {"type": "int"}}},
"exploreSeries": "Explore series",
"episodeCount": "{count} EPISODES",
"@episodeCount": {"placeholders": {"count": {"type": "int"}}},
"technicalDemoCount": "FREE PREVIEW · {count} EPISODES",
"@technicalDemoCount": {"placeholders": {"count": {"type": "int"}}},
"episodes": "Episodes",
"watchEpisodeOne": "Watch episode 1",
"continueEpisode": "Continue episode {number}",
"@continueEpisode": {"placeholders": {"number": {"type": "int"}}},
"freeTestMedia": "Same demo clip · free",
"lockedStoreUnavailable": "Locked · store unavailable",
"freeTestClip": "Free test clip",
"locked": "Locked",
"freeSamplesNotice": "Two free samples · store setup pending",
"demoTestMedia": "FREE · SAME DEMO CLIP",
"episodeProgress": "EPISODE {number} / {total}",
"@episodeProgress": {"placeholders": {"number": {"type": "int"}, "total": {"type": "int"}}},
"testVideoNotice": "Self-generated technical test video",
"lockedTitle": "Episode {number} is locked",
"@lockedTitle": {"placeholders": {"number": {"type": "int"}}},
"lockedBody": "Unlocking will be available after store and verification setup. This demo does not process payments.",
"playbackError": "This clip could not be played.",
"catalogError": "The catalog could not be loaded.",
"noStories": "No stories available yet.",
"retry": "Retry",
"backToSeries": "Back to series",
"nextEpisode": "Next episode",
"pause": "Pause",
"play": "Play",
"seekVideo": "Seek video",
"seekFailed": "Could not change playback position.",
"captions": "Captions",
"captionsOff": "Captions off",
"englishCaptions": "English captions",
"chineseCaptions": "Chinese captions",
"language": "Language",
"systemLanguage": "System",
"english": "English",
"simplifiedChinese": "简体中文",
"episodeOneTitle": "First Light",
"episodeTwoTitle": "A New Frequency",
"episodeThreeTitle": "Crossing Over",
"episodeFourTitle": "After the Echo",
"dramaSignalTitle": "The Signal",
"dramaSignalSynopsis": "A demo series for vertical playback. Every episode reuses one local clip.",
"dramaCityTitle": "City Lights & Velvet Nights",
"dramaCitySynopsis": "A demo romance series. The same local video is used to try switching and resume.",
"dramaVengeanceTitle": "Vengeance Rising",
"dramaVengeanceSynopsis": "A demo action series. The same local video is used to try switching and resume.",
"dramaEchoesTitle": "Echoes of Fate",
"genreSuspenseMystery": "Suspense · Mystery",
"genreUrbanRomance": "Urban Romance",
"genreActionThriller": "Action · Thriller",
"genreDramaMystery": "Drama · Mystery",
"continueWatchingHeading": "CONTINUE WATCHING",
"preferencesHeading": "PREFERENCES",
"brandMark": "NONTONKI · DEMO",
"shareFooter": "Nontonki demo"
}
+878
View File
@@ -0,0 +1,878 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:intl/intl.dart' as intl;
import 'app_localizations_en.dart';
import 'app_localizations_zh.dart';
// ignore_for_file: type=lint
/// Callers can lookup localized strings with an instance of AppLocalizations
/// returned by `AppLocalizations.of(context)`.
///
/// Applications need to include `AppLocalizations.delegate()` in their app's
/// `localizationDelegates` list, and the locales they support in the app's
/// `supportedLocales` list. For example:
///
/// ```dart
/// import 'l10n/app_localizations.dart';
///
/// return MaterialApp(
/// localizationsDelegates: AppLocalizations.localizationsDelegates,
/// supportedLocales: AppLocalizations.supportedLocales,
/// home: MyApplicationHome(),
/// );
/// ```
///
/// ## Update pubspec.yaml
///
/// Please make sure to update your pubspec.yaml to include the following
/// packages:
///
/// ```yaml
/// dependencies:
/// # Internationalization support.
/// flutter_localizations:
/// sdk: flutter
/// intl: any # Use the pinned version from flutter_localizations
///
/// # Rest of dependencies
/// ```
///
/// ## iOS Applications
///
/// iOS applications define key application metadata, including supported
/// locales, in an Info.plist file that is built into the application bundle.
/// To configure the locales supported by your app, you’ll need to edit this
/// file.
///
/// First, open your project’s ios/Runner.xcworkspace Xcode workspace file.
/// Then, in the Project Navigator, open the Info.plist file under the Runner
/// project’s Runner folder.
///
/// Next, select the Information Property List item, select Add Item from the
/// Editor menu, then select Localizations from the pop-up menu.
///
/// Select and expand the newly-created Localizations item then, for each
/// locale your application supports, add a new item and select the locale
/// you wish to add from the pop-up menu in the Value field. This list should
/// be consistent with the languages listed in the AppLocalizations.supportedLocales
/// property.
abstract class AppLocalizations {
AppLocalizations(String locale)
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName;
static AppLocalizations? of(BuildContext context) {
return Localizations.of<AppLocalizations>(context, AppLocalizations);
}
static const LocalizationsDelegate<AppLocalizations> delegate =
_AppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations
/// delegates.
///
/// Returns a list of localizations delegates containing this delegate along with
/// GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
/// and GlobalWidgetsLocalizations.delegate.
///
/// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates =
<LocalizationsDelegate<dynamic>>[
delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
];
/// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[
Locale('en'),
Locale('zh'),
];
/// No description provided for @appName.
///
/// In en, this message translates to:
/// **'Nontonki'**
String get appName;
/// No description provided for @demo.
///
/// In en, this message translates to:
/// **'DEMO'**
String get demo;
/// No description provided for @homeHeadline.
///
/// In en, this message translates to:
/// **'Watch now'**
String get homeHeadline;
/// No description provided for @homeSubtitle.
///
/// In en, this message translates to:
/// **'Free short drama preview.'**
String get homeSubtitle;
/// No description provided for @mediaNotice.
///
/// In en, this message translates to:
/// **'User-provided local demo video · Free preview'**
String get mediaNotice;
/// No description provided for @featuredDemo.
///
/// In en, this message translates to:
/// **'FEATURED · TECHNICAL DEMO'**
String get featuredDemo;
/// No description provided for @demoTitle.
///
/// In en, this message translates to:
/// **'The Signal'**
String get demoTitle;
/// No description provided for @demoSynopsis.
///
/// In en, this message translates to:
/// **'A short local video preview. More episodes can be added when media is available.'**
String get demoSynopsis;
/// No description provided for @homeTab.
///
/// In en, this message translates to:
/// **'Home'**
String get homeTab;
/// No description provided for @openQuickMenu.
///
/// In en, this message translates to:
/// **'Open quick menu'**
String get openQuickMenu;
/// No description provided for @quickAccess.
///
/// In en, this message translates to:
/// **'Quick access'**
String get quickAccess;
/// No description provided for @currentSeries.
///
/// In en, this message translates to:
/// **'Current series'**
String get currentSeries;
/// No description provided for @discoverTab.
///
/// In en, this message translates to:
/// **'Discover'**
String get discoverTab;
/// No description provided for @searchDiscover.
///
/// In en, this message translates to:
/// **'Search dramas, actors, genres...'**
String get searchDiscover;
/// No description provided for @featured.
///
/// In en, this message translates to:
/// **'Featured'**
String get featured;
/// No description provided for @suspense.
///
/// In en, this message translates to:
/// **'Suspense'**
String get suspense;
/// No description provided for @romance.
///
/// In en, this message translates to:
/// **'Romance'**
String get romance;
/// No description provided for @actionGenre.
///
/// In en, this message translates to:
/// **'Action'**
String get actionGenre;
/// No description provided for @filter.
///
/// In en, this message translates to:
/// **'Filter'**
String get filter;
/// No description provided for @topCharts.
///
/// In en, this message translates to:
/// **'Top Charts'**
String get topCharts;
/// No description provided for @newReleases.
///
/// In en, this message translates to:
/// **'New Releases'**
String get newReleases;
/// No description provided for @trendingMicroDramas.
///
/// In en, this message translates to:
/// **'Trending Micro-Dramas'**
String get trendingMicroDramas;
/// No description provided for @seeAll.
///
/// In en, this message translates to:
/// **'See All'**
String get seeAll;
/// No description provided for @artworkOnly.
///
/// In en, this message translates to:
/// **'Artwork'**
String get artworkOnly;
/// No description provided for @artworkOnlyNotice.
///
/// In en, this message translates to:
/// **'This is a visual catalog item. No video is available yet.'**
String get artworkOnlyNotice;
/// No description provided for @videoPending.
///
/// In en, this message translates to:
/// **'Video pending'**
String get videoPending;
/// No description provided for @watchNow.
///
/// In en, this message translates to:
/// **'Watch free'**
String get watchNow;
/// No description provided for @noSearchResults.
///
/// In en, this message translates to:
/// **'No matching dramas.'**
String get noSearchResults;
/// No description provided for @demoCatalogNotice.
///
/// In en, this message translates to:
/// **'Three demo dramas share the same local video.'**
String get demoCatalogNotice;
/// No description provided for @myTab.
///
/// In en, this message translates to:
/// **'My'**
String get myTab;
/// No description provided for @localViewer.
///
/// In en, this message translates to:
/// **'Local Viewer'**
String get localViewer;
/// No description provided for @localDemoProfile.
///
/// In en, this message translates to:
/// **'Local demo profile'**
String get localDemoProfile;
/// No description provided for @continueWatching.
///
/// In en, this message translates to:
/// **'Continue Watching'**
String get continueWatching;
/// No description provided for @watchedPercent.
///
/// In en, this message translates to:
/// **'Watched {percent}%'**
String watchedPercent(int percent);
/// No description provided for @resume.
///
/// In en, this message translates to:
/// **'Resume'**
String get resume;
/// No description provided for @history.
///
/// In en, this message translates to:
/// **'History'**
String get history;
/// No description provided for @watchlist.
///
/// In en, this message translates to:
/// **'Watchlist'**
String get watchlist;
/// No description provided for @likedTab.
///
/// In en, this message translates to:
/// **'Liked'**
String get likedTab;
/// No description provided for @all.
///
/// In en, this message translates to:
/// **'All'**
String get all;
/// No description provided for @inProgress.
///
/// In en, this message translates to:
/// **'In Progress'**
String get inProgress;
/// No description provided for @completed.
///
/// In en, this message translates to:
/// **'Completed'**
String get completed;
/// No description provided for @noSavedShows.
///
/// In en, this message translates to:
/// **'No shows here yet.'**
String get noSavedShows;
/// No description provided for @preferences.
///
/// In en, this message translates to:
/// **'Preferences'**
String get preferences;
/// No description provided for @appLanguage.
///
/// In en, this message translates to:
/// **'App Language'**
String get appLanguage;
/// No description provided for @followingTab.
///
/// In en, this message translates to:
/// **'Following'**
String get followingTab;
/// No description provided for @animatedTab.
///
/// In en, this message translates to:
/// **'Animated'**
String get animatedTab;
/// No description provided for @recommendedTab.
///
/// In en, this message translates to:
/// **'Recommended'**
String get recommendedTab;
/// No description provided for @noCategoryDramas.
///
/// In en, this message translates to:
/// **'No dramas in this category yet.'**
String get noCategoryDramas;
/// No description provided for @trendingTab.
///
/// In en, this message translates to:
/// **'Trending'**
String get trendingTab;
/// No description provided for @liveActionTab.
///
/// In en, this message translates to:
/// **'Live Action'**
String get liveActionTab;
/// No description provided for @search.
///
/// In en, this message translates to:
/// **'Search'**
String get search;
/// No description provided for @free.
///
/// In en, this message translates to:
/// **'Free'**
String get free;
/// No description provided for @mute.
///
/// In en, this message translates to:
/// **'Mute'**
String get mute;
/// No description provided for @unmute.
///
/// In en, this message translates to:
/// **'Unmute'**
String get unmute;
/// No description provided for @like.
///
/// In en, this message translates to:
/// **'Like'**
String get like;
/// No description provided for @save.
///
/// In en, this message translates to:
/// **'Save'**
String get save;
/// No description provided for @comments.
///
/// In en, this message translates to:
/// **'Comments'**
String get comments;
/// No description provided for @share.
///
/// In en, this message translates to:
/// **'Share'**
String get share;
/// No description provided for @localOnlyComments.
///
/// In en, this message translates to:
/// **'Only visible on this device'**
String get localOnlyComments;
/// No description provided for @noComments.
///
/// In en, this message translates to:
/// **'No comments yet.'**
String get noComments;
/// No description provided for @writeComment.
///
/// In en, this message translates to:
/// **'Write a comment'**
String get writeComment;
/// No description provided for @postComment.
///
/// In en, this message translates to:
/// **'Post'**
String get postComment;
/// No description provided for @shareFailed.
///
/// In en, this message translates to:
/// **'Could not open the share sheet.'**
String get shareFailed;
/// No description provided for @nowPlaying.
///
/// In en, this message translates to:
/// **'Playing now'**
String get nowPlaying;
/// No description provided for @watchFullscreen.
///
/// In en, this message translates to:
/// **'Watch in landscape'**
String get watchFullscreen;
/// No description provided for @exitFullscreen.
///
/// In en, this message translates to:
/// **'Exit fullscreen'**
String get exitFullscreen;
/// No description provided for @pictureInPicture.
///
/// In en, this message translates to:
/// **'Picture in picture'**
String get pictureInPicture;
/// No description provided for @pipUnavailable.
///
/// In en, this message translates to:
/// **'Picture in picture is unavailable on this device'**
String get pipUnavailable;
/// No description provided for @originalTag.
///
/// In en, this message translates to:
/// **'Original'**
String get originalTag;
/// No description provided for @dramaTag.
///
/// In en, this message translates to:
/// **'Short drama'**
String get dramaTag;
/// No description provided for @episodeLabel.
///
/// In en, this message translates to:
/// **'Episode {number}'**
String episodeLabel(int number);
/// No description provided for @exploreSeries.
///
/// In en, this message translates to:
/// **'Explore series'**
String get exploreSeries;
/// No description provided for @episodeCount.
///
/// In en, this message translates to:
/// **'{count} EPISODES'**
String episodeCount(int count);
/// No description provided for @technicalDemoCount.
///
/// In en, this message translates to:
/// **'FREE PREVIEW · {count} EPISODES'**
String technicalDemoCount(int count);
/// No description provided for @episodes.
///
/// In en, this message translates to:
/// **'Episodes'**
String get episodes;
/// No description provided for @watchEpisodeOne.
///
/// In en, this message translates to:
/// **'Watch episode 1'**
String get watchEpisodeOne;
/// No description provided for @continueEpisode.
///
/// In en, this message translates to:
/// **'Continue episode {number}'**
String continueEpisode(int number);
/// No description provided for @freeTestMedia.
///
/// In en, this message translates to:
/// **'Same demo clip · free'**
String get freeTestMedia;
/// No description provided for @lockedStoreUnavailable.
///
/// In en, this message translates to:
/// **'Locked · store unavailable'**
String get lockedStoreUnavailable;
/// No description provided for @freeTestClip.
///
/// In en, this message translates to:
/// **'Free test clip'**
String get freeTestClip;
/// No description provided for @locked.
///
/// In en, this message translates to:
/// **'Locked'**
String get locked;
/// No description provided for @freeSamplesNotice.
///
/// In en, this message translates to:
/// **'Two free samples · store setup pending'**
String get freeSamplesNotice;
/// No description provided for @demoTestMedia.
///
/// In en, this message translates to:
/// **'FREE · SAME DEMO CLIP'**
String get demoTestMedia;
/// No description provided for @episodeProgress.
///
/// In en, this message translates to:
/// **'EPISODE {number} / {total}'**
String episodeProgress(int number, int total);
/// No description provided for @testVideoNotice.
///
/// In en, this message translates to:
/// **'Self-generated technical test video'**
String get testVideoNotice;
/// No description provided for @lockedTitle.
///
/// In en, this message translates to:
/// **'Episode {number} is locked'**
String lockedTitle(int number);
/// No description provided for @lockedBody.
///
/// In en, this message translates to:
/// **'Unlocking will be available after store and verification setup. This demo does not process payments.'**
String get lockedBody;
/// No description provided for @playbackError.
///
/// In en, this message translates to:
/// **'This clip could not be played.'**
String get playbackError;
/// No description provided for @catalogError.
///
/// In en, this message translates to:
/// **'The catalog could not be loaded.'**
String get catalogError;
/// No description provided for @noStories.
///
/// In en, this message translates to:
/// **'No stories available yet.'**
String get noStories;
/// No description provided for @retry.
///
/// In en, this message translates to:
/// **'Retry'**
String get retry;
/// No description provided for @backToSeries.
///
/// In en, this message translates to:
/// **'Back to series'**
String get backToSeries;
/// No description provided for @nextEpisode.
///
/// In en, this message translates to:
/// **'Next episode'**
String get nextEpisode;
/// No description provided for @pause.
///
/// In en, this message translates to:
/// **'Pause'**
String get pause;
/// No description provided for @play.
///
/// In en, this message translates to:
/// **'Play'**
String get play;
/// No description provided for @seekVideo.
///
/// In en, this message translates to:
/// **'Seek video'**
String get seekVideo;
/// No description provided for @seekFailed.
///
/// In en, this message translates to:
/// **'Could not change playback position.'**
String get seekFailed;
/// No description provided for @captions.
///
/// In en, this message translates to:
/// **'Captions'**
String get captions;
/// No description provided for @captionsOff.
///
/// In en, this message translates to:
/// **'Captions off'**
String get captionsOff;
/// No description provided for @englishCaptions.
///
/// In en, this message translates to:
/// **'English captions'**
String get englishCaptions;
/// No description provided for @chineseCaptions.
///
/// In en, this message translates to:
/// **'Chinese captions'**
String get chineseCaptions;
/// No description provided for @language.
///
/// In en, this message translates to:
/// **'Language'**
String get language;
/// No description provided for @systemLanguage.
///
/// In en, this message translates to:
/// **'System'**
String get systemLanguage;
/// No description provided for @english.
///
/// In en, this message translates to:
/// **'English'**
String get english;
/// No description provided for @simplifiedChinese.
///
/// In en, this message translates to:
/// **'简体中文'**
String get simplifiedChinese;
/// No description provided for @episodeOneTitle.
///
/// In en, this message translates to:
/// **'First Light'**
String get episodeOneTitle;
/// No description provided for @episodeTwoTitle.
///
/// In en, this message translates to:
/// **'A New Frequency'**
String get episodeTwoTitle;
/// No description provided for @episodeThreeTitle.
///
/// In en, this message translates to:
/// **'Crossing Over'**
String get episodeThreeTitle;
/// No description provided for @episodeFourTitle.
///
/// In en, this message translates to:
/// **'After the Echo'**
String get episodeFourTitle;
/// No description provided for @dramaSignalTitle.
///
/// In en, this message translates to:
/// **'The Signal'**
String get dramaSignalTitle;
/// No description provided for @dramaSignalSynopsis.
///
/// In en, this message translates to:
/// **'A demo series for vertical playback. Every episode reuses one local clip.'**
String get dramaSignalSynopsis;
/// No description provided for @dramaCityTitle.
///
/// In en, this message translates to:
/// **'City Lights & Velvet Nights'**
String get dramaCityTitle;
/// No description provided for @dramaCitySynopsis.
///
/// In en, this message translates to:
/// **'A demo romance series. The same local video is used to try switching and resume.'**
String get dramaCitySynopsis;
/// No description provided for @dramaVengeanceTitle.
///
/// In en, this message translates to:
/// **'Vengeance Rising'**
String get dramaVengeanceTitle;
/// No description provided for @dramaVengeanceSynopsis.
///
/// In en, this message translates to:
/// **'A demo action series. The same local video is used to try switching and resume.'**
String get dramaVengeanceSynopsis;
/// No description provided for @dramaEchoesTitle.
///
/// In en, this message translates to:
/// **'Echoes of Fate'**
String get dramaEchoesTitle;
/// No description provided for @genreSuspenseMystery.
///
/// In en, this message translates to:
/// **'Suspense · Mystery'**
String get genreSuspenseMystery;
/// No description provided for @genreUrbanRomance.
///
/// In en, this message translates to:
/// **'Urban Romance'**
String get genreUrbanRomance;
/// No description provided for @genreActionThriller.
///
/// In en, this message translates to:
/// **'Action · Thriller'**
String get genreActionThriller;
/// No description provided for @genreDramaMystery.
///
/// In en, this message translates to:
/// **'Drama · Mystery'**
String get genreDramaMystery;
/// No description provided for @continueWatchingHeading.
///
/// In en, this message translates to:
/// **'CONTINUE WATCHING'**
String get continueWatchingHeading;
/// No description provided for @preferencesHeading.
///
/// In en, this message translates to:
/// **'PREFERENCES'**
String get preferencesHeading;
/// No description provided for @brandMark.
///
/// In en, this message translates to:
/// **'NONTONKI · DEMO'**
String get brandMark;
/// No description provided for @shareFooter.
///
/// In en, this message translates to:
/// **'Nontonki demo'**
String get shareFooter;
}
class _AppLocalizationsDelegate
extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate();
@override
Future<AppLocalizations> load(Locale locale) {
return SynchronousFuture<AppLocalizations>(lookupAppLocalizations(locale));
}
@override
bool isSupported(Locale locale) =>
<String>['en', 'zh'].contains(locale.languageCode);
@override
bool shouldReload(_AppLocalizationsDelegate old) => false;
}
AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when only language code is specified.
switch (locale.languageCode) {
case 'en':
return AppLocalizationsEn();
case 'zh':
return AppLocalizationsZh();
}
throw FlutterError(
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.',
);
}
+404
View File
@@ -0,0 +1,404 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for English (`en`).
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get appName => 'Nontonki';
@override
String get demo => 'DEMO';
@override
String get homeHeadline => 'Watch now';
@override
String get homeSubtitle => 'Free short drama preview.';
@override
String get mediaNotice => 'User-provided local demo video · Free preview';
@override
String get featuredDemo => 'FEATURED · TECHNICAL DEMO';
@override
String get demoTitle => 'The Signal';
@override
String get demoSynopsis =>
'A short local video preview. More episodes can be added when media is available.';
@override
String get homeTab => 'Home';
@override
String get openQuickMenu => 'Open quick menu';
@override
String get quickAccess => 'Quick access';
@override
String get currentSeries => 'Current series';
@override
String get discoverTab => 'Discover';
@override
String get searchDiscover => 'Search dramas, actors, genres...';
@override
String get featured => 'Featured';
@override
String get suspense => 'Suspense';
@override
String get romance => 'Romance';
@override
String get actionGenre => 'Action';
@override
String get filter => 'Filter';
@override
String get topCharts => 'Top Charts';
@override
String get newReleases => 'New Releases';
@override
String get trendingMicroDramas => 'Trending Micro-Dramas';
@override
String get seeAll => 'See All';
@override
String get artworkOnly => 'Artwork';
@override
String get artworkOnlyNotice =>
'This is a visual catalog item. No video is available yet.';
@override
String get videoPending => 'Video pending';
@override
String get watchNow => 'Watch free';
@override
String get noSearchResults => 'No matching dramas.';
@override
String get demoCatalogNotice =>
'Three demo dramas share the same local video.';
@override
String get myTab => 'My';
@override
String get localViewer => 'Local Viewer';
@override
String get localDemoProfile => 'Local demo profile';
@override
String get continueWatching => 'Continue Watching';
@override
String watchedPercent(int percent) {
return 'Watched $percent%';
}
@override
String get resume => 'Resume';
@override
String get history => 'History';
@override
String get watchlist => 'Watchlist';
@override
String get likedTab => 'Liked';
@override
String get all => 'All';
@override
String get inProgress => 'In Progress';
@override
String get completed => 'Completed';
@override
String get noSavedShows => 'No shows here yet.';
@override
String get preferences => 'Preferences';
@override
String get appLanguage => 'App Language';
@override
String get followingTab => 'Following';
@override
String get animatedTab => 'Animated';
@override
String get recommendedTab => 'Recommended';
@override
String get noCategoryDramas => 'No dramas in this category yet.';
@override
String get trendingTab => 'Trending';
@override
String get liveActionTab => 'Live Action';
@override
String get search => 'Search';
@override
String get free => 'Free';
@override
String get mute => 'Mute';
@override
String get unmute => 'Unmute';
@override
String get like => 'Like';
@override
String get save => 'Save';
@override
String get comments => 'Comments';
@override
String get share => 'Share';
@override
String get localOnlyComments => 'Only visible on this device';
@override
String get noComments => 'No comments yet.';
@override
String get writeComment => 'Write a comment';
@override
String get postComment => 'Post';
@override
String get shareFailed => 'Could not open the share sheet.';
@override
String get nowPlaying => 'Playing now';
@override
String get watchFullscreen => 'Watch in landscape';
@override
String get exitFullscreen => 'Exit fullscreen';
@override
String get pictureInPicture => 'Picture in picture';
@override
String get pipUnavailable =>
'Picture in picture is unavailable on this device';
@override
String get originalTag => 'Original';
@override
String get dramaTag => 'Short drama';
@override
String episodeLabel(int number) {
return 'Episode $number';
}
@override
String get exploreSeries => 'Explore series';
@override
String episodeCount(int count) {
return '$count EPISODES';
}
@override
String technicalDemoCount(int count) {
return 'FREE PREVIEW · $count EPISODES';
}
@override
String get episodes => 'Episodes';
@override
String get watchEpisodeOne => 'Watch episode 1';
@override
String continueEpisode(int number) {
return 'Continue episode $number';
}
@override
String get freeTestMedia => 'Same demo clip · free';
@override
String get lockedStoreUnavailable => 'Locked · store unavailable';
@override
String get freeTestClip => 'Free test clip';
@override
String get locked => 'Locked';
@override
String get freeSamplesNotice => 'Two free samples · store setup pending';
@override
String get demoTestMedia => 'FREE · SAME DEMO CLIP';
@override
String episodeProgress(int number, int total) {
return 'EPISODE $number / $total';
}
@override
String get testVideoNotice => 'Self-generated technical test video';
@override
String lockedTitle(int number) {
return 'Episode $number is locked';
}
@override
String get lockedBody =>
'Unlocking will be available after store and verification setup. This demo does not process payments.';
@override
String get playbackError => 'This clip could not be played.';
@override
String get catalogError => 'The catalog could not be loaded.';
@override
String get noStories => 'No stories available yet.';
@override
String get retry => 'Retry';
@override
String get backToSeries => 'Back to series';
@override
String get nextEpisode => 'Next episode';
@override
String get pause => 'Pause';
@override
String get play => 'Play';
@override
String get seekVideo => 'Seek video';
@override
String get seekFailed => 'Could not change playback position.';
@override
String get captions => 'Captions';
@override
String get captionsOff => 'Captions off';
@override
String get englishCaptions => 'English captions';
@override
String get chineseCaptions => 'Chinese captions';
@override
String get language => 'Language';
@override
String get systemLanguage => 'System';
@override
String get english => 'English';
@override
String get simplifiedChinese => '简体中文';
@override
String get episodeOneTitle => 'First Light';
@override
String get episodeTwoTitle => 'A New Frequency';
@override
String get episodeThreeTitle => 'Crossing Over';
@override
String get episodeFourTitle => 'After the Echo';
@override
String get dramaSignalTitle => 'The Signal';
@override
String get dramaSignalSynopsis =>
'A demo series for vertical playback. Every episode reuses one local clip.';
@override
String get dramaCityTitle => 'City Lights & Velvet Nights';
@override
String get dramaCitySynopsis =>
'A demo romance series. The same local video is used to try switching and resume.';
@override
String get dramaVengeanceTitle => 'Vengeance Rising';
@override
String get dramaVengeanceSynopsis =>
'A demo action series. The same local video is used to try switching and resume.';
@override
String get dramaEchoesTitle => 'Echoes of Fate';
@override
String get genreSuspenseMystery => 'Suspense · Mystery';
@override
String get genreUrbanRomance => 'Urban Romance';
@override
String get genreActionThriller => 'Action · Thriller';
@override
String get genreDramaMystery => 'Drama · Mystery';
@override
String get continueWatchingHeading => 'CONTINUE WATCHING';
@override
String get preferencesHeading => 'PREFERENCES';
@override
String get brandMark => 'NONTONKI · DEMO';
@override
String get shareFooter => 'Nontonki demo';
}
+396
View File
@@ -0,0 +1,396 @@
// ignore: unused_import
import 'package:intl/intl.dart' as intl;
import 'app_localizations.dart';
// ignore_for_file: type=lint
/// The translations for Chinese (`zh`).
class AppLocalizationsZh extends AppLocalizations {
AppLocalizationsZh([String locale = 'zh']) : super(locale);
@override
String get appName => 'Nontonki';
@override
String get demo => '演示';
@override
String get homeHeadline => '即刻观看';
@override
String get homeSubtitle => '免费短剧预览。';
@override
String get mediaNotice => '用户提供的本地演示视频 · 免费观看';
@override
String get featuredDemo => '精选 · 技术演示';
@override
String get demoTitle => '信号';
@override
String get demoSynopsis => '使用本地视频进行播放预览,后续有素材再增加剧集。';
@override
String get homeTab => '首页';
@override
String get openQuickMenu => '打开快捷菜单';
@override
String get quickAccess => '快捷入口';
@override
String get currentSeries => '当前短剧';
@override
String get discoverTab => '找剧';
@override
String get searchDiscover => '搜索短剧、演员、类型…';
@override
String get featured => '精选';
@override
String get suspense => '悬疑';
@override
String get romance => '都市爱情';
@override
String get actionGenre => '动作';
@override
String get filter => '筛选';
@override
String get topCharts => '排行榜';
@override
String get newReleases => '新剧';
@override
String get trendingMicroDramas => '热门短剧';
@override
String get seeAll => '查看全部';
@override
String get artworkOnly => '海报展示';
@override
String get artworkOnlyNotice => '这张海报仅用于目录展示,暂时没有可播放视频。';
@override
String get videoPending => '视频待提供';
@override
String get watchNow => '免费观看';
@override
String get noSearchResults => '没有匹配的短剧。';
@override
String get demoCatalogNotice => '三部演示短剧共用同一条本地视频。';
@override
String get myTab => '我的';
@override
String get localViewer => '本地观众';
@override
String get localDemoProfile => '本地演示档案';
@override
String get continueWatching => '继续观看';
@override
String watchedPercent(int percent) {
return '已观看 $percent%';
}
@override
String get resume => '继续播放';
@override
String get history => '历史';
@override
String get watchlist => '追剧';
@override
String get likedTab => '喜欢';
@override
String get all => '全部';
@override
String get inProgress => '未看完';
@override
String get completed => '已看完';
@override
String get noSavedShows => '这里还没有剧集。';
@override
String get preferences => '偏好设置';
@override
String get appLanguage => '应用语言';
@override
String get followingTab => '关注';
@override
String get animatedTab => '漫剧';
@override
String get recommendedTab => '推荐';
@override
String get noCategoryDramas => '这个分类暂时没有剧集。';
@override
String get trendingTab => '热门';
@override
String get liveActionTab => '真人剧';
@override
String get search => '搜索';
@override
String get free => '免费';
@override
String get mute => '静音';
@override
String get unmute => '取消静音';
@override
String get like => '喜欢';
@override
String get save => '收藏';
@override
String get comments => '评论';
@override
String get share => '分享';
@override
String get localOnlyComments => '仅在本机可见';
@override
String get noComments => '还没有评论。';
@override
String get writeComment => '写评论';
@override
String get postComment => '发布';
@override
String get shareFailed => '无法打开系统分享面板。';
@override
String get nowPlaying => '正在播放';
@override
String get watchFullscreen => '横屏全屏观看';
@override
String get exitFullscreen => '退出全屏';
@override
String get pictureInPicture => '画中画';
@override
String get pipUnavailable => '此设备无法使用画中画';
@override
String get originalTag => '原创';
@override
String get dramaTag => '短剧';
@override
String episodeLabel(int number) {
return '第 $number 集';
}
@override
String get exploreSeries => '查看剧集';
@override
String episodeCount(int count) {
return '$count 集';
}
@override
String technicalDemoCount(int count) {
return '免费试看 · $count 集';
}
@override
String get episodes => '选集';
@override
String get watchEpisodeOne => '观看第 1 集';
@override
String continueEpisode(int number) {
return '续看第 $number 集';
}
@override
String get freeTestMedia => '复用同一演示视频 · 免费';
@override
String get lockedStoreUnavailable => '已锁定 · 商店未配置';
@override
String get freeTestClip => '免费测试片';
@override
String get locked => '已锁定';
@override
String get freeSamplesNotice => '前两集免费 · 商店配置待完成';
@override
String get demoTestMedia => '免费 · 同一演示视频';
@override
String episodeProgress(int number, int total) {
return '第 $number / $total 集';
}
@override
String get testVideoNotice => '自行生成的技术测试视频';
@override
String lockedTitle(int number) {
return '第 $number 集已锁定';
}
@override
String get lockedBody => '完成商店与服务端验单配置后才能解锁。本演示目前不处理付款。';
@override
String get playbackError => '此视频暂时无法播放。';
@override
String get catalogError => '剧目列表加载失败。';
@override
String get noStories => '暂无剧目。';
@override
String get retry => '重试';
@override
String get backToSeries => '返回剧集详情';
@override
String get nextEpisode => '下一集';
@override
String get pause => '暂停';
@override
String get play => '播放';
@override
String get seekVideo => '拖动播放进度';
@override
String get seekFailed => '无法调整播放进度。';
@override
String get captions => '字幕';
@override
String get captionsOff => '关闭字幕';
@override
String get englishCaptions => '英文字幕';
@override
String get chineseCaptions => '简体中文字幕';
@override
String get language => '界面语言';
@override
String get systemLanguage => '跟随系统';
@override
String get english => 'English';
@override
String get simplifiedChinese => '简体中文';
@override
String get episodeOneTitle => '初见微光';
@override
String get episodeTwoTitle => '新的频率';
@override
String get episodeThreeTitle => '越过边界';
@override
String get episodeFourTitle => '回声之后';
@override
String get dramaSignalTitle => '信号';
@override
String get dramaSignalSynopsis => '一部竖屏连播的演示短剧,每一集都使用同一段本地视频。';
@override
String get dramaCityTitle => '都市灯火与丝绒夜';
@override
String get dramaCitySynopsis => '一部都市爱情演示短剧,用同一段本地视频体验切换和续看。';
@override
String get dramaVengeanceTitle => '复仇崛起';
@override
String get dramaVengeanceSynopsis => '一部动作演示短剧,用同一段本地视频体验切换和续看。';
@override
String get dramaEchoesTitle => '命运回声';
@override
String get genreSuspenseMystery => '悬疑 · 推理';
@override
String get genreUrbanRomance => '都市爱情';
@override
String get genreActionThriller => '动作 · 惊悚';
@override
String get genreDramaMystery => '剧情 · 悬疑';
@override
String get continueWatchingHeading => '继续观看';
@override
String get preferencesHeading => '偏好设置';
@override
String get brandMark => 'NONTONKI · 演示';
@override
String get shareFooter => 'Nontonki 演示';
}
+129
View File
@@ -0,0 +1,129 @@
{
"@@locale": "zh",
"appName": "Nontonki",
"demo": "演示",
"homeHeadline": "即刻观看",
"homeSubtitle": "免费短剧预览。",
"mediaNotice": "用户提供的本地演示视频 · 免费观看",
"featuredDemo": "精选 · 技术演示",
"demoTitle": "信号",
"demoSynopsis": "使用本地视频进行播放预览,后续有素材再增加剧集。",
"homeTab": "首页",
"openQuickMenu": "打开快捷菜单",
"quickAccess": "快捷入口",
"currentSeries": "当前短剧",
"discoverTab": "找剧",
"searchDiscover": "搜索短剧、演员、类型…",
"featured": "精选",
"suspense": "悬疑",
"romance": "都市爱情",
"actionGenre": "动作",
"filter": "筛选",
"topCharts": "排行榜",
"newReleases": "新剧",
"trendingMicroDramas": "热门短剧",
"seeAll": "查看全部",
"artworkOnly": "海报展示",
"artworkOnlyNotice": "这张海报仅用于目录展示,暂时没有可播放视频。",
"videoPending": "视频待提供",
"watchNow": "免费观看",
"noSearchResults": "没有匹配的短剧。",
"demoCatalogNotice": "三部演示短剧共用同一条本地视频。",
"myTab": "我的",
"localViewer": "本地观众",
"localDemoProfile": "本地演示档案",
"continueWatching": "继续观看",
"watchedPercent": "已观看 {percent}%",
"@watchedPercent": {"placeholders": {"percent": {"type": "int"}}},
"resume": "继续播放",
"history": "历史",
"watchlist": "追剧",
"likedTab": "喜欢",
"all": "全部",
"inProgress": "未看完",
"completed": "已看完",
"noSavedShows": "这里还没有剧集。",
"preferences": "偏好设置",
"appLanguage": "应用语言",
"followingTab": "关注",
"animatedTab": "漫剧",
"recommendedTab": "推荐",
"noCategoryDramas": "这个分类暂时没有剧集。",
"trendingTab": "热门",
"liveActionTab": "真人剧",
"search": "搜索",
"free": "免费",
"mute": "静音",
"unmute": "取消静音",
"like": "喜欢",
"save": "收藏",
"comments": "评论",
"share": "分享",
"localOnlyComments": "仅在本机可见",
"noComments": "还没有评论。",
"writeComment": "写评论",
"postComment": "发布",
"shareFailed": "无法打开系统分享面板。",
"nowPlaying": "正在播放",
"watchFullscreen": "横屏全屏观看",
"exitFullscreen": "退出全屏",
"pictureInPicture": "画中画",
"pipUnavailable": "此设备无法使用画中画",
"originalTag": "原创",
"dramaTag": "短剧",
"episodeLabel": "第 {number} 集",
"@episodeLabel": {"placeholders": {"number": {"type": "int"}}},
"exploreSeries": "查看剧集",
"episodeCount": "{count} 集",
"technicalDemoCount": "免费试看 · {count} 集",
"episodes": "选集",
"watchEpisodeOne": "观看第 1 集",
"continueEpisode": "续看第 {number} 集",
"freeTestMedia": "复用同一演示视频 · 免费",
"lockedStoreUnavailable": "已锁定 · 商店未配置",
"freeTestClip": "免费测试片",
"locked": "已锁定",
"freeSamplesNotice": "前两集免费 · 商店配置待完成",
"demoTestMedia": "免费 · 同一演示视频",
"episodeProgress": "第 {number} / {total} 集",
"testVideoNotice": "自行生成的技术测试视频",
"lockedTitle": "第 {number} 集已锁定",
"lockedBody": "完成商店与服务端验单配置后才能解锁。本演示目前不处理付款。",
"playbackError": "此视频暂时无法播放。",
"catalogError": "剧目列表加载失败。",
"noStories": "暂无剧目。",
"retry": "重试",
"backToSeries": "返回剧集详情",
"nextEpisode": "下一集",
"pause": "暂停",
"play": "播放",
"seekVideo": "拖动播放进度",
"seekFailed": "无法调整播放进度。",
"captions": "字幕",
"captionsOff": "关闭字幕",
"englishCaptions": "英文字幕",
"chineseCaptions": "简体中文字幕",
"language": "界面语言",
"systemLanguage": "跟随系统",
"english": "English",
"simplifiedChinese": "简体中文",
"episodeOneTitle": "初见微光",
"episodeTwoTitle": "新的频率",
"episodeThreeTitle": "越过边界",
"episodeFourTitle": "回声之后",
"dramaSignalTitle": "信号",
"dramaSignalSynopsis": "一部竖屏连播的演示短剧,每一集都使用同一段本地视频。",
"dramaCityTitle": "都市灯火与丝绒夜",
"dramaCitySynopsis": "一部都市爱情演示短剧,用同一段本地视频体验切换和续看。",
"dramaVengeanceTitle": "复仇崛起",
"dramaVengeanceSynopsis": "一部动作演示短剧,用同一段本地视频体验切换和续看。",
"dramaEchoesTitle": "命运回声",
"genreSuspenseMystery": "悬疑 · 推理",
"genreUrbanRomance": "都市爱情",
"genreActionThriller": "动作 · 惊悚",
"genreDramaMystery": "剧情 · 悬疑",
"continueWatchingHeading": "继续观看",
"preferencesHeading": "偏好设置",
"brandMark": "NONTONKI · 演示",
"shareFooter": "Nontonki 演示"
}
+9
View File
@@ -0,0 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'app.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
runApp(const LumaDramaApp());
}
+13
View File
@@ -0,0 +1,13 @@
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import 'discover_logic.dart';
class DiscoverBinding {
DiscoverLogic create({
required Drama drama,
WatchHistoryRepository? history,
}) => DiscoverLogic(
drama: drama,
history: history ?? SharedPreferencesWatchHistoryRepository(),
);
}
+32
View File
@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
class DiscoverLogic {
DiscoverLogic({required this.history, required this.drama}) {
refreshPosition();
}
final WatchHistoryRepository history;
Drama drama;
late Future<Duration?> position;
final TextEditingController search = TextEditingController();
final FocusNode searchFocus = FocusNode();
final ScrollController scroll = ScrollController();
int category = 0;
void refreshPosition() {
position = history.positionFor(
demoProfileId,
drama.id,
drama.episodes.first.id,
);
}
void dispose() {
search.dispose();
searchFocus.dispose();
scroll.dispose();
}
}
+731
View File
@@ -0,0 +1,731 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../core/data/models/drama.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../l10n/app_localizations.dart';
import 'discover_binding.dart';
import 'discover_logic.dart';
enum DiscoverPageAction { home, my, watchlist, detail, resume }
class DiscoverPage extends StatefulWidget {
const DiscoverPage({
super.key,
required this.drama,
required this.duration,
this.focusSearch = false,
this.history,
this.embedded = false,
this.active = true,
this.searchRequest = 0,
this.onAction,
this.dramas,
this.onDramaSelected,
});
final Drama drama;
final Duration duration;
final bool focusSearch;
final WatchHistoryRepository? history;
final bool embedded;
final bool active;
final int searchRequest;
final ValueChanged<DiscoverPageAction>? onAction;
final List<Drama>? dramas;
final ValueChanged<Drama>? onDramaSelected;
@override
State<DiscoverPage> createState() => _DiscoverPageState();
}
class _DiscoverPageState extends State<DiscoverPage> {
late final DiscoverLogic logic;
@override
void initState() {
super.initState();
logic = DiscoverBinding().create(
drama: widget.drama,
history: widget.history,
);
}
@override
void didUpdateWidget(covariant DiscoverPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.active &&
(!oldWidget.active || oldWidget.drama.id != widget.drama.id)) {
logic.drama = widget.drama;
setState(logic.refreshPosition);
}
if (widget.active && widget.searchRequest != oldWidget.searchRequest) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.active) logic.searchFocus.requestFocus();
});
}
}
void _act(DiscoverPageAction action) {
if (widget.embedded) {
widget.onAction?.call(action);
} else {
Navigator.pop(context, action);
}
}
Drama? _dramaFor(_ShowcaseCard card) {
for (final drama in widget.dramas ?? [logic.drama]) {
if (drama.id == card.dramaId) return drama;
}
return null;
}
void _openCard(_ShowcaseCard card) {
final drama = _dramaFor(card);
if (drama == null) {
_unavailable();
} else if (widget.embedded && widget.onDramaSelected != null) {
widget.onDramaSelected!(drama);
} else {
_act(DiscoverPageAction.detail);
}
}
@override
void dispose() {
logic.dispose();
super.dispose();
}
void _unavailable() {
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.artworkOnlyNotice)));
}
void _catalogInfo() {
final l10n = AppLocalizations.of(context)!;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.demoCatalogNotice)));
}
void _showFilters() {
final l10n = AppLocalizations.of(context)!;
final labels = [
l10n.featured,
l10n.suspense,
l10n.romance,
l10n.actionGenre,
];
showModalBottomSheet<void>(
context: context,
backgroundColor: const Color(0xFF202126),
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(title: Text(l10n.filter)),
for (var index = 0; index < labels.length; index++)
ListTile(
title: Text(labels[index]),
trailing: index == logic.category
? const Icon(Icons.check_rounded, color: _orange)
: null,
onTap: () {
setState(() => logic.category = index);
Navigator.pop(sheetContext);
},
),
const SizedBox(height: 8),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final cards = _cardsFor(l10n, logic.category, logic.search.text);
final content = SafeArea(
bottom: !widget.embedded,
child: Column(
children: [
_topBar(l10n),
Expanded(
child: ListView(
controller: logic.scroll,
padding: const EdgeInsets.fromLTRB(16, 4, 16, 24),
children: [
_searchBar(l10n),
const SizedBox(height: 12),
_categories(l10n),
const SizedBox(height: 15),
_continueCard(l10n),
const SizedBox(height: 15),
_quickActions(l10n),
const SizedBox(height: 20),
_sectionTitle(l10n),
const SizedBox(height: 13),
if (cards.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: Text(
l10n.noSearchResults,
style: const TextStyle(color: Color(0xFFE6E1E3)),
),
)
else
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: cards.length,
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 10,
mainAxisSpacing: 14,
childAspectRatio: 0.59,
),
itemBuilder: (context, index) =>
_posterCard(l10n, cards[index]),
),
],
),
),
if (!widget.embedded) _bottomNav(l10n),
],
),
);
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: Theme(
data: ThemeData.dark(useMaterial3: true),
child: widget.embedded
? content
: Scaffold(backgroundColor: const Color(0xFF0B0C10), body: content),
),
);
}
Widget _topBar(AppLocalizations l10n) => SizedBox(
height: 51,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Container(
width: 29,
height: 29,
decoration: BoxDecoration(
color: _orange,
borderRadius: BorderRadius.circular(7),
),
child: const Icon(Icons.play_arrow_rounded, color: Colors.white),
),
const SizedBox(width: 7),
Text(
l10n.discoverTab,
style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w800),
),
const Spacer(),
IconButton(
tooltip: l10n.search,
onPressed: () => logic.searchFocus.requestFocus(),
icon: const Icon(Icons.search_rounded, size: 22),
),
const SizedBox(width: 4),
InkWell(
onTap: () => _act(DiscoverPageAction.my),
child: const CircleAvatar(
radius: 14,
backgroundColor: Color(0xFF34353B),
child: Icon(Icons.person_rounded, size: 19),
),
),
],
),
),
);
Widget _searchBar(AppLocalizations l10n) => Container(
height: 43,
decoration: BoxDecoration(
color: const Color(0xFF25252A),
borderRadius: BorderRadius.circular(24),
),
child: Row(
children: [
const SizedBox(width: 11),
const Icon(Icons.search_rounded, color: Color(0xFFF2EEEF), size: 20),
const SizedBox(width: 7),
Expanded(
child: TextField(
controller: logic.search,
focusNode: logic.searchFocus,
autofocus: widget.focusSearch,
onChanged: (_) => setState(() {}),
style: const TextStyle(fontSize: 12),
decoration: InputDecoration(
hintText: l10n.searchDiscover,
hintStyle: const TextStyle(
color: Color(0xFFE6E1E3),
fontSize: 12,
),
border: InputBorder.none,
isDense: true,
),
),
),
IconButton(
tooltip: l10n.filter,
onPressed: _showFilters,
icon: const Icon(Icons.tune_rounded, size: 20),
),
],
),
);
Widget _categories(AppLocalizations l10n) {
final labels = [
l10n.featured,
l10n.suspense,
l10n.romance,
l10n.actionGenre,
];
return SizedBox(
height: 30,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: labels.length,
separatorBuilder: (_, _) => const SizedBox(width: 7),
itemBuilder: (context, index) => ChoiceChip(
label: Text(labels[index]),
selected: logic.category == index,
onSelected: (_) => setState(() => logic.category = index),
showCheckmark: false,
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 7),
backgroundColor: const Color(0xFF29272B),
selectedColor: _orange,
side: BorderSide.none,
labelStyle: TextStyle(
color: logic.category == index ? Colors.white : const Color(0xFFDDD5D7),
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
),
);
}
Widget _continueCard(AppLocalizations l10n) => FutureBuilder<Duration?>(
future: logic.position,
builder: (context, snapshot) {
final position = snapshot.data ?? Duration.zero;
final total = widget.duration > Duration.zero
? widget.duration
: const Duration(seconds: 12);
final progress = (position.inMilliseconds / total.inMilliseconds).clamp(
0.0,
1.0,
);
return Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: const Color(0xFF1C1D21),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(5),
child: SizedBox(
width: 55,
height: 69,
child: Stack(
fit: StackFit.expand,
children: [
Image.asset(widget.drama.posterAsset, fit: BoxFit.cover),
const Positioned(
left: 0,
right: 0,
bottom: 0,
height: 12,
child: ColoredBox(color: Color(0xFF1C1D21)),
),
],
),
),
),
const SizedBox(width: 9),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.continueWatchingHeading,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFFFA490),
fontSize: 9,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 3),
Text(
localizedDramaTitle(l10n, widget.drama),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
l10n.episodeProgress(1, widget.drama.episodes.length),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFE6E1E3),
fontSize: 11,
),
),
const SizedBox(height: 7),
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: progress,
minHeight: 3,
backgroundColor: const Color(0xFF474248),
color: _orange,
),
),
],
),
),
const SizedBox(width: 9),
FilledButton.icon(
onPressed: () => _act(DiscoverPageAction.resume),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFFFFBD5C),
foregroundColor: const Color(0xFF2A160B),
padding: const EdgeInsets.symmetric(horizontal: 10),
minimumSize: const Size(0, 32),
textStyle: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
icon: const Icon(Icons.play_arrow_rounded, size: 14),
label: Text(l10n.resume),
),
],
),
);
},
);
Widget _quickActions(AppLocalizations l10n) {
final actions = [
(Icons.tune_rounded, l10n.filter, _showFilters),
(Icons.bar_chart_rounded, l10n.topCharts, _catalogInfo),
(Icons.auto_awesome_rounded, l10n.newReleases, _catalogInfo),
(
Icons.bookmark_outline_rounded,
l10n.watchlist,
() {
_act(DiscoverPageAction.watchlist);
},
),
];
return Row(
children: [
for (var index = 0; index < actions.length; index++) ...[
Expanded(
child: InkWell(
onTap: actions[index].$3,
borderRadius: BorderRadius.circular(12),
child: Container(
height: 77,
padding: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
color: const Color(0xFF202126),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleAvatar(
radius: 16,
backgroundColor: const Color(0xFF333036),
child: Icon(
actions[index].$1,
size: 19,
color: index == 1
? const Color(0xFFFFC15E)
: const Color(0xFFFFAB96),
),
),
const SizedBox(height: 7),
Text(
actions[index].$2,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
),
if (index < actions.length - 1) const SizedBox(width: 7),
],
],
);
}
Widget _sectionTitle(AppLocalizations l10n) => Row(
children: [
Container(width: 3, height: 20, color: _orange),
const SizedBox(width: 7),
Expanded(
child: Text(
l10n.trendingMicroDramas,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800),
),
),
TextButton(
onPressed: () => setState(() => logic.category = 0),
child: Text(
l10n.seeAll,
style: const TextStyle(color: Color(0xFFFFB3A1)),
),
),
],
);
Widget _posterCard(AppLocalizations l10n, _ShowcaseCard card) {
final playable = _dramaFor(card) != null;
return InkWell(
onTap: () => _openCard(card),
borderRadius: BorderRadius.circular(11),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Stack(
fit: StackFit.expand,
children: [
Image.asset(card.image, fit: BoxFit.cover),
const Positioned(
left: 0,
right: 0,
bottom: 0,
height: 37,
child: ColoredBox(color: Color(0xFF101116)),
),
Positioned(
top: 6,
left: 6,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
decoration: BoxDecoration(
color: playable ? _orange : const Color(0xDD37343B),
borderRadius: BorderRadius.circular(7),
),
child: Text(
playable ? l10n.free : l10n.artworkOnly,
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.w800,
),
),
),
),
Positioned(
bottom: 10,
left: 8,
right: 8,
child: Text(
playable ? l10n.freeTestMedia : l10n.videoPending,
style: const TextStyle(
color: Color(0xFFFFC789),
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
const SizedBox(height: 6),
Text(
card.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800),
),
const SizedBox(height: 3),
Text(
card.genre,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 11),
),
],
),
);
}
Widget _bottomNav(AppLocalizations l10n) => Container(
height: 63,
decoration: const BoxDecoration(
color: Color(0xFF0D0D11),
border: Border(top: BorderSide(color: Color(0xFF242329))),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_DiscoverNavItem(
icon: Icons.play_circle_outline_rounded,
label: l10n.homeTab,
onTap: () => Navigator.pop(context, DiscoverPageAction.home),
),
_DiscoverNavItem(
icon: Icons.explore_outlined,
label: l10n.discoverTab,
selected: true,
onTap: () {},
),
_DiscoverNavItem(
icon: Icons.video_library_outlined,
label: l10n.myTab,
onTap: () => Navigator.pop(context, DiscoverPageAction.my),
),
],
),
);
}
class _DiscoverNavItem extends StatelessWidget {
const _DiscoverNavItem({
required this.icon,
required this.label,
required this.onTap,
this.selected = false,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final bool selected;
@override
Widget build(BuildContext context) => InkWell(
onTap: onTap,
child: SizedBox(
width: 72,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 21,
color: selected ? _orange : const Color(0xFFCAC4C7),
),
Text(
label,
style: TextStyle(
color: selected ? _orange : const Color(0xFFCAC4C7),
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
],
),
),
);
}
class _ShowcaseCard {
const _ShowcaseCard({
required this.title,
required this.genre,
required this.image,
required this.category,
this.dramaId,
});
final String title;
final String genre;
final String image;
final int category;
final String? dramaId;
}
List<_ShowcaseCard> _cardsFor(
AppLocalizations l10n,
int category,
String query,
) {
final cards = [
_ShowcaseCard(
title: l10n.dramaSignalTitle,
genre: l10n.genreSuspenseMystery,
image: 'assets/images/stitch_signal_poster_clean.png',
category: 1,
dramaId: 'the-signal-demo',
),
_ShowcaseCard(
title: l10n.dramaCityTitle,
genre: l10n.genreUrbanRomance,
image: 'assets/images/stitch_city_poster_clean.png',
category: 2,
dramaId: 'city-lights-demo',
),
_ShowcaseCard(
title: l10n.dramaVengeanceTitle,
genre: l10n.genreActionThriller,
image: 'assets/images/stitch_vengeance_poster_clean.png',
category: 3,
dramaId: 'vengeance-demo',
),
_ShowcaseCard(
title: l10n.dramaEchoesTitle,
genre: l10n.genreDramaMystery,
image: 'assets/images/stitch_rain_scene.jpg',
category: 1,
),
];
final normalized = query.trim().toLowerCase();
return cards.where((card) {
final categoryMatches = category == 0 || card.category == category;
final queryMatches =
normalized.isEmpty ||
card.title.toLowerCase().contains(normalized) ||
card.genre.toLowerCase().contains(normalized);
return categoryMatches && queryMatches;
}).toList();
}
const _orange = Color(0xFFFF5B3D);
@@ -0,0 +1,13 @@
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import 'drama_detail_logic.dart';
class DramaDetailBinding {
DramaDetailLogic create({
required Drama drama,
WatchHistoryRepository? history,
}) => DramaDetailLogic(
drama: drama,
history: history ?? SharedPreferencesWatchHistoryRepository(),
);
}
@@ -0,0 +1,19 @@
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
class DramaDetailLogic {
DramaDetailLogic({required this.drama, required this.history});
final Drama drama;
final WatchHistoryRepository history;
int? continueIndex;
Future<void> loadContinue() async {
final id = await history.lastEpisodeId(demoProfileId, drama.id);
if (id == null) return;
final index = drama.episodes.indexWhere((episode) => episode.id == id);
if (index >= 0 && drama.episodes[index].isFree) {
continueIndex = index;
}
}
}
@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart';
import '../../core/formatting/localized_content.dart';
import '../../l10n/app_localizations.dart';
import '../player/player_view.dart';
import 'drama_detail_binding.dart';
import 'drama_detail_logic.dart';
class DramaDetailPage extends StatefulWidget {
const DramaDetailPage({super.key, required this.drama});
final Drama drama;
@override
State<DramaDetailPage> createState() => _DramaDetailPageState();
}
class _DramaDetailPageState extends State<DramaDetailPage> {
late final DramaDetailLogic logic;
@override
void initState() {
super.initState();
logic = DramaDetailBinding().create(drama: widget.drama);
_loadContinue();
}
Future<void> _loadContinue() async {
try {
await logic.loadContinue();
if (mounted) setState(() {});
} catch (_) {
// Keep the first episode available if preferences cannot be read.
}
}
Future<void> _openPlayer(BuildContext context, int index) async {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PlayerPage(drama: logic.drama, initialIndex: index),
),
);
await _loadContinue();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final drama = logic.drama;
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 300,
pinned: true,
backgroundColor: const Color(0xFF111111),
foregroundColor: Colors.white,
flexibleSpace: FlexibleSpaceBar(
background: Image.asset(drama.posterAsset, fit: BoxFit.cover),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 40),
sliver: SliverList(
delegate: SliverChildListDelegate([
Text(
l10n.technicalDemoCount(drama.episodes.length),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
letterSpacing: 1.4,
color: const Color(0xFF9278EA),
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
localizedDramaTitle(l10n, drama),
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 10),
Text(
localizedDramaSynopsis(l10n, drama),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: () => _openPlayer(context, logic.continueIndex ?? 0),
icon: const Icon(Icons.play_arrow_rounded),
label: Text(
logic.continueIndex == null
? l10n.watchEpisodeOne
: l10n.continueEpisode(logic.continueIndex! + 1),
),
),
const SizedBox(height: 30),
Text(
l10n.episodes,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
]),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 40),
sliver: SliverList.builder(
itemCount: drama.episodes.length,
itemBuilder: (context, index) {
final episode = drama.episodes[index];
return Card(
margin: const EdgeInsets.only(bottom: 10),
child: ListTile(
minTileHeight: 76,
leading: CircleAvatar(
backgroundColor: const Color(
0xFFFF6B45,
).withValues(alpha: 0.18),
child: Text('${episode.number}'),
),
title: Text(l10n.episodeLabel(episode.number)),
subtitle: Text(l10n.freeTestMedia),
trailing: const Icon(Icons.play_circle_outline_rounded),
onTap: () => _openPlayer(context, index),
),
);
},
),
),
],
),
);
}
}
+6
View File
@@ -0,0 +1,6 @@
import '../../core/data/models/drama.dart';
import 'home_logic.dart';
class HomeBinding {
HomeLogic create(List<Drama> dramas) => HomeLogic(dramas);
}
+30
View File
@@ -0,0 +1,30 @@
import '../../core/data/models/drama.dart';
enum HomeFeedCategory { following, animated, liveAction, recommended }
/// Maps catalog entries into home feeds without owning playback or UI state.
class HomeFeedSections {
HomeFeedSections(this.dramas, {this.followedDramaIds = const {}});
final List<Drama> dramas;
final Set<String> followedDramaIds;
List<int> indicesFor(HomeFeedCategory category) => [
for (var index = 0; index < dramas.length; index++)
if (dramas[index].episodes.isNotEmpty &&
switch (category) {
HomeFeedCategory.following => followedDramaIds.contains(
dramas[index].id,
),
HomeFeedCategory.animated =>
dramas[index].format == DramaFormat.animated,
HomeFeedCategory.liveAction =>
dramas[index].format == DramaFormat.liveAction,
HomeFeedCategory.recommended => true,
})
index,
];
Drama dramaAt(HomeFeedCategory category, int categoryIndex) =>
dramas[indicesFor(category)[categoryIndex]];
}
+36
View File
@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart';
class HomeLogic {
HomeLogic(List<Drama> dramas) : currentDrama = dramas.first;
final scaffoldKey = GlobalKey<ScaffoldState>();
Drama currentDrama;
int tab = 0;
int searchRequest = 0;
int sectionRequest = 0;
int resumeRequest = 0;
int backRequest = 0;
int mySection = 0;
bool drawerOpen = false;
bool detailOpen = false;
bool videoImmersive = false;
void select(
int nextTab, {
bool search = false,
bool watchlist = false,
bool resume = false,
int? mySection,
}) {
FocusManager.instance.primaryFocus?.unfocus();
tab = nextTab;
if (search) searchRequest++;
if (watchlist || mySection != null) {
this.mySection = mySection ?? 1;
sectionRequest++;
}
if (resume) resumeRequest++;
}
}
+325
View File
@@ -0,0 +1,325 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/services/catalog_controller.dart';
import '../../l10n/app_localizations.dart';
import '../discover/discover_view.dart';
import '../drama_detail/drama_detail_view.dart';
import '../mine/mine_view.dart';
import '../player/player_view.dart';
import 'home_binding.dart';
import 'home_logic.dart';
import 'widgets/home_quick_drawer.dart';
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
final catalog = context.watch<CatalogController>();
final l10n = AppLocalizations.of(context)!;
return Theme(
data: ThemeData.dark(useMaterial3: true),
child: _buildHome(context, catalog, l10n),
);
}
Widget _buildHome(
BuildContext context,
CatalogController catalog,
AppLocalizations l10n,
) {
if (catalog.loading) {
return const Scaffold(
backgroundColor: Colors.black,
body: Center(child: CircularProgressIndicator()),
);
}
if (catalog.error != null || catalog.dramas.isEmpty) {
return Scaffold(
backgroundColor: Colors.black,
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(catalog.error == null ? l10n.noStories : l10n.catalogError),
const SizedBox(height: 12),
FilledButton(onPressed: catalog.load, child: Text(l10n.retry)),
],
),
),
);
}
return _HomeTabs(
key: ValueKey(catalog.dramas.first.id),
dramas: catalog.dramas,
);
}
}
/// The three primary destinations share a single route and keep their state.
class _HomeTabs extends StatefulWidget {
const _HomeTabs({super.key, required this.dramas});
final List<Drama> dramas;
@override
State<_HomeTabs> createState() => _HomeTabsState();
}
class _HomeTabsState extends State<_HomeTabs> {
late final HomeLogic logic;
@override
void initState() {
super.initState();
logic = HomeBinding().create(widget.dramas);
}
void _select(
int tab, {
bool search = false,
bool watchlist = false,
bool resume = false,
int? mySection,
}) {
setState(
() => logic.select(
tab,
search: search,
watchlist: watchlist,
resume: resume,
mySection: mySection,
),
);
}
void _openMenu() {
setState(() => logic.drawerOpen = true);
logic.scaffoldKey.currentState?.openDrawer();
}
void _selectFromMenu(int tab, {int? mySection}) {
logic.scaffoldKey.currentState?.closeDrawer();
_select(tab, mySection: mySection);
}
Future<void> _openCurrentSeries() async {
setState(() => logic.detailOpen = true);
logic.scaffoldKey.currentState?.closeDrawer();
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
try {
await Navigator.of(context).push<void>(
MaterialPageRoute(
builder: (_) => DramaDetailPage(drama: logic.currentDrama),
),
);
} finally {
if (mounted) setState(() => logic.detailOpen = false);
}
}
void _discoverAction(DiscoverPageAction action) {
switch (action) {
case DiscoverPageAction.home:
_select(0);
case DiscoverPageAction.my:
_select(2);
case DiscoverPageAction.watchlist:
_select(2, watchlist: true);
case DiscoverPageAction.resume:
_select(0, resume: true);
case DiscoverPageAction.detail:
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DramaDetailPage(drama: logic.currentDrama),
),
);
}
}
void _myAction(MyPageAction action) {
switch (action) {
case MyPageAction.home:
_select(0);
case MyPageAction.discover:
_select(1, search: true);
case MyPageAction.resume:
_select(0, resume: true);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
if (logic.drawerOpen) {
logic.scaffoldKey.currentState?.closeDrawer();
} else if (logic.videoImmersive) {
setState(() => logic.backRequest++);
} else if (logic.tab != 0) {
_select(0);
} else {
setState(() => logic.backRequest++);
}
},
child: Scaffold(
key: logic.scaffoldKey,
backgroundColor: const Color(0xFF0B0C10),
drawer: HomeQuickDrawer(
drama: logic.currentDrama,
onOpenSeries: _openCurrentSeries,
onDiscover: () => _selectFromMenu(1),
onHistory: () => _selectFromMenu(2, mySection: 0),
onWatchlist: () => _selectFromMenu(2, mySection: 1),
onLiked: () => _selectFromMenu(2, mySection: 2),
),
drawerEnableOpenDragGesture: false,
onDrawerChanged: (opened) {
if (logic.drawerOpen != opened) {
setState(() => logic.drawerOpen = opened);
}
},
body: IndexedStack(
index: logic.tab,
children: [
PlayerPage(
drama: widget.dramas.first,
feedDramas: widget.dramas,
onDramaChanged: (drama) =>
setState(() => logic.currentDrama = drama),
initialIndex: 0,
homeMode: true,
embedded: true,
active: logic.tab == 0 && !logic.drawerOpen && !logic.detailOpen,
resumeRequest: logic.resumeRequest,
backRequest: logic.backRequest,
onDiscover: () => _select(1),
onMy: () => _select(2),
onSearch: () => _select(1, search: true),
onMenu: _openMenu,
onImmersiveChanged: (value) =>
setState(() => logic.videoImmersive = value),
),
DiscoverPage(
drama: logic.currentDrama,
dramas: widget.dramas,
onDramaSelected: (drama) => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DramaDetailPage(drama: drama),
),
),
duration: Duration.zero,
embedded: true,
active: logic.tab == 1,
searchRequest: logic.searchRequest,
onAction: _discoverAction,
),
MyPage(
drama: logic.currentDrama,
dramas: widget.dramas,
onDramaSelected: (drama) => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DramaDetailPage(drama: drama),
),
),
duration: Duration.zero,
initialSection: logic.mySection,
sectionRequest: logic.sectionRequest,
embedded: true,
active: logic.tab == 2,
onAction: _myAction,
),
],
),
bottomNavigationBar: logic.videoImmersive
? null
: SafeArea(
top: false,
child: Container(
key: const Key('home-tabs-bar'),
height: 63,
decoration: const BoxDecoration(
color: Color(0xFF0D0D11),
border: Border(top: BorderSide(color: Color(0xFF242329))),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_TabButton(
key: const Key('tab-home'),
icon: Icons.play_circle_outline_rounded,
label: l10n.homeTab,
selected: logic.tab == 0,
onTap: () => _select(0),
),
_TabButton(
key: const Key('tab-discover'),
icon: Icons.explore_outlined,
label: l10n.discoverTab,
selected: logic.tab == 1,
onTap: () => _select(1),
),
_TabButton(
key: const Key('tab-my'),
icon: Icons.video_library_outlined,
label: l10n.myTab,
selected: logic.tab == 2,
onTap: () => _select(2),
),
],
),
),
),
),
);
}
}
class _TabButton extends StatelessWidget {
const _TabButton({
super.key,
required this.icon,
required this.label,
required this.selected,
required this.onTap,
});
final IconData icon;
final String label;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => InkWell(
onTap: onTap,
child: SizedBox(
width: 80,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 21,
color: selected ? const Color(0xFFFF5C37) : const Color(0xFFE6E1DE),
),
Text(
label,
style: TextStyle(
fontSize: 10,
color: selected
? const Color(0xFFFF5C37)
: const Color(0xFFE6E1DE),
),
),
],
),
),
);
}
@@ -0,0 +1,35 @@
import 'package:flutter/material.dart';
/// Converts horizontal drags into a previous/next category request.
/// Placement decides where the gesture is active; the seek bar sits outside it.
class CategorySwipeRegion extends StatefulWidget {
const CategorySwipeRegion({
super.key,
required this.onShift,
required this.child,
});
final ValueChanged<int> onShift;
final Widget child;
@override
State<CategorySwipeRegion> createState() => _CategorySwipeRegionState();
}
class _CategorySwipeRegionState extends State<CategorySwipeRegion> {
double _distance = 0;
@override
Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.translucent,
onHorizontalDragStart: (_) => _distance = 0,
onHorizontalDragUpdate: (details) => _distance += details.delta.dx,
onHorizontalDragEnd: (details) {
final velocity = details.primaryVelocity ?? 0;
if (_distance.abs() < 44 && velocity.abs() < 300) return;
final direction = _distance.abs() >= 44 ? _distance : velocity;
widget.onShift(direction < 0 ? 1 : -1);
},
child: widget.child,
);
}
@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import '../../../l10n/app_localizations.dart';
import '../home_feed_sections.dart';
import 'category_swipe_region.dart';
/// Reused by playable and empty home feeds.
class HomeFeedHeader extends StatelessWidget {
const HomeFeedHeader({
super.key,
required this.l10n,
required this.category,
required this.onCategorySelected,
required this.onCategoryShift,
required this.onSearch,
required this.onMy,
this.onMenu,
this.onMute,
this.muted = false,
});
final AppLocalizations l10n;
final HomeFeedCategory category;
final ValueChanged<HomeFeedCategory> onCategorySelected;
final ValueChanged<int> onCategoryShift;
final VoidCallback onSearch;
final VoidCallback onMy;
final VoidCallback? onMenu;
final VoidCallback? onMute;
final bool muted;
String _label(HomeFeedCategory category) => switch (category) {
HomeFeedCategory.following => l10n.followingTab,
HomeFeedCategory.animated => l10n.animatedTab,
HomeFeedCategory.liveAction => l10n.liveActionTab,
HomeFeedCategory.recommended => l10n.recommendedTab,
};
@override
Widget build(BuildContext context) => IconTheme(
data: const IconThemeData(color: Colors.white),
child: DefaultTextStyle.merge(
style: const TextStyle(color: Colors.white),
child: _header(context),
),
);
Widget _header(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Row(
children: [
if (onMenu != null)
IconButton(
key: const Key('home-open-menu'),
tooltip: l10n.openQuickMenu,
onPressed: onMenu,
icon: const Icon(Icons.menu_rounded),
),
Container(
width: 29,
height: 29,
decoration: BoxDecoration(
color: const Color(0xFFFF5C37),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.play_arrow_rounded, color: Colors.white),
),
const SizedBox(width: 8),
Text(
l10n.homeTab,
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700),
),
const Spacer(),
IconButton(
tooltip: l10n.search,
onPressed: onSearch,
icon: const Icon(Icons.search_rounded),
),
IconButton(
tooltip: l10n.myTab,
onPressed: onMy,
icon: const CircleAvatar(
radius: 15,
backgroundColor: Color(0xFF3C3A3A),
child: Icon(
Icons.person_rounded,
color: Colors.white,
size: 19,
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 0),
child: Row(
children: [
Expanded(
child: CategorySwipeRegion(
key: const Key('home-category-swipe'),
onShift: onCategoryShift,
child: Row(
children: [
for (final item in HomeFeedCategory.values)
Expanded(
child: InkWell(
key: Key('home-category-${item.name}'),
onTap: () => onCategorySelected(item),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 9),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_label(item),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: category == item
? const Color(0xFFFFC2B0)
: const Color(0xFFF7F4F2),
shadows: const [
Shadow(blurRadius: 8, color: Colors.black),
],
fontSize: 12,
fontWeight: category == item
? FontWeight.w700
: FontWeight.w500,
),
),
const SizedBox(height: 4),
SizedBox(
width: 18,
height: 2,
child: DecoratedBox(
decoration: BoxDecoration(
color: category == item
? const Color(0xFFFF5C37)
: Colors.transparent,
),
),
),
],
),
),
),
),
],
),
),
),
if (onMute != null)
IconButton(
tooltip: muted ? l10n.unmute : l10n.mute,
onPressed: onMute,
icon: Icon(
muted ? Icons.volume_off_rounded : Icons.volume_up_rounded,
size: 21,
),
),
],
),
),
],
);
}
@@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/config/locale_controller.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/formatting/localized_content.dart';
import '../../../l10n/app_localizations.dart';
/// Shortcuts for the existing root tabs; this widget owns no navigation state.
class HomeQuickDrawer extends StatelessWidget {
const HomeQuickDrawer({
super.key,
required this.drama,
required this.onOpenSeries,
required this.onDiscover,
required this.onHistory,
required this.onWatchlist,
required this.onLiked,
});
final Drama drama;
final VoidCallback onOpenSeries;
final VoidCallback onDiscover;
final VoidCallback onHistory;
final VoidCallback onWatchlist;
final VoidCallback onLiked;
static const _panel = Color(0xFF222225);
static const _card = Color(0xFF303033);
static const _orange = Color(0xFFFF5C37);
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final languageCode = Localizations.localeOf(context).languageCode;
return Drawer(
width: (MediaQuery.sizeOf(context).width * 0.83).clamp(0.0, 360.0),
backgroundColor: _panel,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.horizontal(right: Radius.circular(20)),
),
child: SafeArea(
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
children: [
Row(
children: [
const CircleAvatar(
radius: 30,
backgroundColor: Color(0xFF4B4142),
child: Icon(
Icons.person_rounded,
size: 34,
color: Colors.white,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.localViewer,
style: const TextStyle(
color: Colors.white,
fontSize: 19,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(
l10n.localDemoProfile,
style: const TextStyle(color: Color(0xFFE7E2E0)),
),
],
),
),
],
),
const SizedBox(height: 26),
_SectionCard(
title: l10n.currentSeries,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.asset(
drama.posterAsset,
width: 74,
height: 100,
fit: BoxFit.cover,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedDramaTitle(l10n, drama),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
const SizedBox(height: 7),
Text(
l10n.episodeCount(drama.episodes.length),
style: const TextStyle(color: Color(0xFFE7E2E0)),
),
const SizedBox(height: 5),
Text(
l10n.freeTestMedia,
maxLines: 2,
style: const TextStyle(
color: Color(0xFFFFB99E),
fontSize: 11,
),
),
],
),
),
],
),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
key: const Key('drawer-open-series'),
onPressed: onOpenSeries,
style: FilledButton.styleFrom(
backgroundColor: _orange,
foregroundColor: Colors.white,
),
icon: const Icon(Icons.play_arrow_rounded),
label: Text(l10n.exploreSeries),
),
),
],
),
),
const SizedBox(height: 14),
_SectionCard(
title: l10n.quickAccess,
child: Column(
children: [
Row(
children: [
Expanded(
child: _QuickAction(
key: const Key('drawer-discover'),
icon: Icons.explore_outlined,
label: l10n.discoverTab,
onTap: onDiscover,
),
),
const SizedBox(width: 9),
Expanded(
child: _QuickAction(
key: const Key('drawer-history'),
icon: Icons.history_rounded,
label: l10n.history,
onTap: onHistory,
),
),
],
),
const SizedBox(height: 9),
Row(
children: [
Expanded(
child: _QuickAction(
key: const Key('drawer-watchlist'),
icon: Icons.bookmark_border_rounded,
label: l10n.watchlist,
onTap: onWatchlist,
),
),
const SizedBox(width: 9),
Expanded(
child: _QuickAction(
key: const Key('drawer-liked'),
icon: Icons.favorite_border_rounded,
label: l10n.likedTab,
onTap: onLiked,
),
),
],
),
],
),
),
const SizedBox(height: 14),
_SectionCard(
title: l10n.appLanguage,
child: Row(
children: [
for (final (code, label) in [
('en', l10n.english),
('zh', l10n.simplifiedChinese),
]) ...[
Expanded(
child: ChoiceChip(
label: Text(label, maxLines: 1),
selected: languageCode == code,
onSelected: (_) =>
context.read<LocaleController>().select(code),
),
),
if (code == 'en') const SizedBox(width: 8),
],
],
),
),
],
),
),
);
}
}
class _SectionCard extends StatelessWidget {
const _SectionCard({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: HomeQuickDrawer._card,
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700),
),
const SizedBox(height: 14),
child,
],
),
);
}
class _QuickAction extends StatelessWidget {
const _QuickAction({
super.key,
required this.icon,
required this.label,
required this.onTap,
});
final IconData icon;
final String label;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Material(
color: const Color(0xFF3B3B3F),
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 7),
child: Column(
children: [
Icon(icon, color: HomeQuickDrawer._orange, size: 25),
const SizedBox(height: 6),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
],
),
),
),
);
}
+15
View File
@@ -0,0 +1,15 @@
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import 'mine_logic.dart';
class MineBinding {
MineLogic create({
required Drama drama,
required int initialSection,
WatchHistoryRepository? history,
}) => MineLogic(
drama: drama,
initialSection: initialSection,
history: history ?? SharedPreferencesWatchHistoryRepository(),
);
}
+26
View File
@@ -0,0 +1,26 @@
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
class MineLogic {
MineLogic({
required this.history,
required this.drama,
required int initialSection,
}) : section = initialSection {
refreshPosition();
}
final WatchHistoryRepository history;
Drama drama;
late Future<Duration?> position;
int section;
int filter = 0;
void refreshPosition() {
position = history.positionFor(
demoProfileId,
drama.id,
drama.episodes.first.id,
);
}
}
+682
View File
@@ -0,0 +1,682 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../core/config/locale_controller.dart';
import '../../core/formatting/localized_content.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/engagement_controller.dart';
import '../../l10n/app_localizations.dart';
import 'mine_binding.dart';
import 'mine_logic.dart';
enum MyPageAction { home, discover, resume }
class MyPage extends StatefulWidget {
const MyPage({
super.key,
required this.drama,
required this.duration,
this.initialSection = 0,
this.history,
this.embedded = false,
this.active = true,
this.sectionRequest = 0,
this.onAction,
this.dramas,
this.onDramaSelected,
});
final Drama drama;
final Duration duration;
final int initialSection;
final WatchHistoryRepository? history;
final bool embedded;
final bool active;
final int sectionRequest;
final ValueChanged<MyPageAction>? onAction;
final List<Drama>? dramas;
final ValueChanged<Drama>? onDramaSelected;
@override
State<MyPage> createState() => _MyPageState();
}
class _MyPageState extends State<MyPage> {
late final MineLogic logic;
@override
void initState() {
super.initState();
logic = MineBinding().create(
drama: widget.drama,
initialSection: widget.initialSection,
history: widget.history,
);
}
@override
void didUpdateWidget(covariant MyPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.active &&
(!oldWidget.active || oldWidget.drama.id != widget.drama.id)) {
logic.drama = widget.drama;
setState(logic.refreshPosition);
}
if (widget.sectionRequest != oldWidget.sectionRequest) {
setState(() => logic.section = widget.initialSection);
}
}
void _act(MyPageAction action) {
if (widget.embedded) {
widget.onAction?.call(action);
} else {
Navigator.pop(context, action);
}
}
Future<void> _showLanguage() async {
final l10n = AppLocalizations.of(context)!;
await showModalBottomSheet<void>(
context: context,
backgroundColor: const Color(0xFF1C1D22),
showDragHandle: true,
builder: (sheetContext) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(title: Text(l10n.language)),
for (final (code, label) in [
('en', l10n.english),
('zh', l10n.simplifiedChinese),
])
ListTile(
title: Text(label),
onTap: () {
context.read<LocaleController>().select(code);
Navigator.pop(sheetContext);
},
),
const SizedBox(height: 8),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final content = SafeArea(
bottom: !widget.embedded,
child: Column(
children: [
_topBar(l10n),
Expanded(
child: FutureBuilder<Duration?>(
future: logic.position,
builder: (context, snapshot) {
final position = snapshot.data ?? Duration.zero;
final total = widget.duration > Duration.zero
? widget.duration
: const Duration(seconds: 12);
final fraction =
(position.inMilliseconds / total.inMilliseconds).clamp(
0.0,
1.0,
);
return ListView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
children: [
_profile(l10n),
const SizedBox(height: 22),
_continueCard(l10n, fraction, position, total),
const SizedBox(height: 26),
_library(l10n, position, total, fraction),
const SizedBox(height: 26),
_preferences(l10n),
const SizedBox(height: 24),
Center(
child: Text(
l10n.brandMark,
style: TextStyle(
color: Color(0xFFD2CDD0),
fontSize: 10,
letterSpacing: 1.3,
),
),
),
],
);
},
),
),
if (!widget.embedded) _bottomNav(l10n),
],
),
);
return Theme(
data: ThemeData.dark(useMaterial3: true),
child: widget.embedded
? content
: Scaffold(backgroundColor: const Color(0xFF0B0C10), body: content),
);
}
Widget _topBar(AppLocalizations l10n) => SizedBox(
height: 54,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
const Icon(Icons.play_circle_fill_rounded, color: _orange, size: 27),
const SizedBox(width: 7),
Text(
l10n.myTab,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w800),
),
const Spacer(),
IconButton(
tooltip: l10n.search,
onPressed: () => _act(MyPageAction.discover),
icon: const Icon(Icons.search_rounded, size: 23),
),
const SizedBox(width: 6),
const CircleAvatar(
radius: 14,
backgroundColor: Color(0xFF2C2D33),
child: Icon(Icons.person_rounded, color: Colors.white, size: 19),
),
],
),
),
);
Widget _profile(AppLocalizations l10n) => Row(
children: [
const CircleAvatar(
radius: 30,
backgroundColor: Color(0xFF32343A),
child: Icon(Icons.person_rounded, color: Colors.white, size: 34),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.localViewer,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w800),
),
const SizedBox(height: 4),
Text(
l10n.localDemoProfile,
style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 12),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF24252A),
borderRadius: BorderRadius.circular(20),
),
child: Text(l10n.demo, style: const TextStyle(fontSize: 11)),
),
],
);
Widget _continueCard(
AppLocalizations l10n,
double fraction,
Duration position,
Duration total,
) => Container(
padding: const EdgeInsets.all(13),
decoration: BoxDecoration(
color: const Color(0xFF201B1E),
borderRadius: BorderRadius.circular(13),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.history_rounded, color: _orange, size: 17),
const SizedBox(width: 6),
Expanded(
child: Text(
l10n.continueWatching,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
),
Text(
l10n.episodeProgress(1, widget.drama.episodes.length),
style: const TextStyle(color: Color(0xFFFFA38F), fontSize: 10),
),
],
),
const SizedBox(height: 12),
Row(
children: [
_poster(width: 75, height: 96),
const SizedBox(width: 11),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedDramaTitle(l10n, widget.drama),
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 3),
Text(
l10n.dramaTag,
style: const TextStyle(
color: Color(0xFFE6E1E3),
fontSize: 12,
),
),
const SizedBox(height: 10),
Text(
l10n.watchedPercent((fraction * 100).round()),
style: const TextStyle(
color: Color(0xFFE7DEDD),
fontSize: 11,
),
),
const SizedBox(height: 5),
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(
value: fraction,
minHeight: 4,
backgroundColor: const Color(0xFF444046),
color: _orange,
),
),
const SizedBox(height: 6),
Text(
'${_clock(position)} / ${_clock(total)}',
style: const TextStyle(
color: Color(0xFFE6E1E3),
fontSize: 12,
),
),
],
),
),
],
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => _act(MyPageAction.resume),
style: FilledButton.styleFrom(
backgroundColor: _orange,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(37),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(9),
),
),
icon: const Icon(Icons.play_arrow_rounded, size: 18),
label: Text(l10n.resume),
),
),
],
),
);
Widget _library(
AppLocalizations l10n,
Duration position,
Duration total,
double fraction,
) {
final tabs = [l10n.history, l10n.watchlist, l10n.likedTab];
final filters = [l10n.all, l10n.inProgress, l10n.completed];
final hasHistory =
logic.filter == 0 ||
(logic.filter == 1 && fraction < 0.99) ||
(logic.filter == 2 && fraction >= 0.99);
final engagement = context.watch<EngagementController>();
final markedDramas = (widget.dramas ?? [widget.drama]).where((drama) {
return logic.section == 1
? engagement.isSaved(drama.id)
: engagement.isLiked(drama.id);
}).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var index = 0; index < tabs.length; index++) ...[
InkWell(
onTap: () => setState(() => logic.section = index),
child: Text(
tabs[index],
style: TextStyle(
color: logic.section == index
? _orange
: const Color(0xFFE4DBDD),
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
),
if (index < tabs.length - 1) const SizedBox(width: 18),
],
],
),
),
const SizedBox(height: 17),
if (logic.section == 0)
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (var index = 0; index < filters.length; index++)
Padding(
padding: const EdgeInsets.only(right: 7),
child: ChoiceChip(
label: Text(filters[index]),
selected: logic.filter == index,
onSelected: (_) => setState(() => logic.filter = index),
showCheckmark: false,
visualDensity: VisualDensity.compact,
backgroundColor: const Color(0xFF242328),
selectedColor: _orange,
labelStyle: TextStyle(
color: logic.filter == index
? Colors.white
: const Color(0xFFD5CED0),
fontSize: 11,
fontWeight: FontWeight.w700,
),
side: BorderSide.none,
),
),
],
),
),
if (logic.section == 0) const SizedBox(height: 11),
if (logic.section == 0 && hasHistory)
InkWell(
onTap: () => _act(MyPageAction.resume),
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: const Color(0xFF1B1C21),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
_poster(width: 74, height: 103),
const SizedBox(width: 11),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedDramaTitle(l10n, widget.drama),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 5),
Text(
l10n.dramaTag,
style: const TextStyle(
color: Color(0xFFE6E1E3),
fontSize: 12,
),
),
const SizedBox(height: 32),
Text(
'${l10n.episodeLabel(1)} · ${_clock(position)} / ${_clock(total)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFFFA18D),
fontSize: 10,
),
),
],
),
),
const CircleAvatar(
radius: 15,
backgroundColor: Color(0xFF313137),
child: Icon(
Icons.play_arrow_rounded,
color: _orange,
size: 20,
),
),
],
),
),
)
else if (logic.section != 0 && markedDramas.isNotEmpty)
for (final drama in markedDramas) ...[
_markedDramaCard(drama, l10n),
const SizedBox(height: 9),
]
else
Padding(
padding: const EdgeInsets.symmetric(vertical: 22),
child: Text(
l10n.noSavedShows,
style: const TextStyle(color: Color(0xFFE6E1E3)),
),
),
],
);
}
Widget _markedDramaCard(Drama drama, AppLocalizations l10n) => InkWell(
onTap: () {
if (widget.onDramaSelected != null) {
widget.onDramaSelected!(drama);
} else if (drama.id == widget.drama.id) {
_act(MyPageAction.resume);
}
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: const Color(0xFF1B1C21),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(
drama.posterAsset,
width: 74,
height: 103,
fit: BoxFit.cover,
),
),
const SizedBox(width: 11),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedDramaTitle(l10n, drama),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 7),
Text(
l10n.episodeCount(drama.episodes.length),
style: const TextStyle(
color: Color(0xFFE6E1E3),
fontSize: 12,
),
),
],
),
),
const Icon(Icons.chevron_right_rounded, color: Color(0xFFF2EEEF)),
],
),
),
);
Widget _preferences(AppLocalizations l10n) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.preferencesHeading,
style: const TextStyle(
color: Color(0xFFCEBBB9),
fontSize: 11,
fontWeight: FontWeight.w800,
letterSpacing: 0.5,
),
),
const SizedBox(height: 12),
Material(
color: const Color(0xFF1B1C21),
borderRadius: BorderRadius.circular(12),
clipBehavior: Clip.antiAlias,
child: ListTile(
leading: const Icon(
Icons.translate_rounded,
color: Color(0xFFFFBC65),
),
title: Text(l10n.appLanguage),
subtitle: Text(l10n.language),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
Localizations.localeOf(context).languageCode == 'zh'
? l10n.simplifiedChinese
: l10n.english,
style: const TextStyle(color: Color(0xFFFFB2A1), fontSize: 11),
),
const SizedBox(width: 4),
const Icon(Icons.chevron_right_rounded, size: 18),
],
),
onTap: _showLanguage,
),
),
],
);
Widget _bottomNav(AppLocalizations l10n) => Container(
height: 63,
decoration: const BoxDecoration(
color: Color(0xFF0D0D11),
border: Border(top: BorderSide(color: Color(0xFF242329))),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_NavItem(
icon: Icons.play_circle_outline_rounded,
label: l10n.homeTab,
onTap: () => Navigator.pop(context, MyPageAction.home),
),
_NavItem(
icon: Icons.explore_outlined,
label: l10n.discoverTab,
onTap: () => Navigator.pop(context, MyPageAction.discover),
),
_NavItem(
icon: Icons.video_library_outlined,
label: l10n.myTab,
selected: true,
onTap: () {},
),
],
),
);
Widget _poster({required double width, required double height}) => ClipRRect(
borderRadius: BorderRadius.circular(7),
child: Image.asset(
widget.drama.posterAsset,
width: width,
height: height,
fit: BoxFit.cover,
),
);
}
class _NavItem extends StatelessWidget {
const _NavItem({
required this.icon,
required this.label,
required this.onTap,
this.selected = false,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final bool selected;
@override
Widget build(BuildContext context) => InkWell(
onTap: onTap,
child: SizedBox(
width: 72,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 21,
color: selected ? _orange : const Color(0xFFCAC4C7),
),
Text(
label,
style: TextStyle(
color: selected ? _orange : const Color(0xFFCAC4C7),
fontSize: 10,
fontWeight: FontWeight.w700,
),
),
],
),
),
);
}
String _clock(Duration duration) {
final seconds = duration.inSeconds;
return '${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}';
}
const _orange = Color(0xFFFF5B3D);
+10
View File
@@ -0,0 +1,10 @@
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/android_picture_in_picture.dart';
import 'player_logic.dart';
class PlayerBinding {
PlayerLogic create() => PlayerLogic(
history: SharedPreferencesWatchHistoryRepository(),
pictureInPicture: AndroidPictureInPicture(),
);
}
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import '../../core/data/models/drama.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/services/android_picture_in_picture.dart';
import '../../core/data/services/playback_queue.dart';
import '../../core/data/services/playback_session.dart';
import '../../core/data/services/video_player_port.dart';
import '../home/home_feed_sections.dart';
class PlayerLogic {
PlayerLogic({required this.history, required this.pictureInPicture});
final WatchHistoryRepository history;
final AndroidPictureInPicture pictureInPicture;
late PageController pages;
late PlaybackQueue queue;
late HomeFeedSections homeSections;
late List<int> visibleIndices;
HomeFeedCategory category = HomeFeedCategory.recommended;
int categoryRequest = 0;
late PlaybackSession session;
VideoPlayerPort? trackedPlayer;
Episode? trackedEpisode;
Drama? trackedDrama;
DateTime lastProgressWrite = DateTime.fromMillisecondsSinceEpoch(0);
Future<void> pendingWrite = Future<void>.value();
int pageRequest = 0;
bool muted = false;
bool stoppedAtFeedEnd = false;
VideoPlayerPort? scrubbingPlayer;
bool fullscreen = false;
bool pip = false;
bool pipPreparing = false;
bool get immersive => fullscreen || pip || pipPreparing;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../core/data/models/drama.dart';
import '../../../core/data/services/engagement_controller.dart';
import '../../../l10n/app_localizations.dart';
class DramaCommentsSheet extends StatefulWidget {
const DramaCommentsSheet({super.key, required this.drama});
final Drama drama;
@override
State<DramaCommentsSheet> createState() => _DramaCommentsSheetState();
}
class _DramaCommentsSheetState extends State<DramaCommentsSheet> {
final _input = TextEditingController();
@override
void dispose() {
_input.dispose();
super.dispose();
}
void _post() {
final text = _input.text.trim();
if (text.isEmpty) return;
context.read<EngagementController>().addComment(widget.drama.id, text);
_input.clear();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
final comments = context.watch<EngagementController>().commentsFor(
widget.drama.id,
);
final available =
MediaQuery.sizeOf(context).height -
MediaQuery.viewInsetsOf(context).bottom -
24;
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: SizedBox(
height: available.clamp(240.0, 460.0),
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.comments,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 3),
Text(
l10n.localOnlyComments,
style: const TextStyle(color: Color(0xFFE6E1E3), fontSize: 13),
),
const SizedBox(height: 14),
Expanded(
child: comments.isEmpty
? Center(
child: Text(
l10n.noComments,
style: const TextStyle(color: Color(0xFFE6E1E3)),
),
)
: ListView.separated(
itemCount: comments.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) => ListTile(
leading: const CircleAvatar(
radius: 16,
child: Icon(Icons.person_rounded, size: 18),
),
title: Text(comments[index]),
contentPadding: EdgeInsets.zero,
),
),
),
Row(
children: [
Expanded(
child: TextField(
key: const Key('comment-input'),
controller: _input,
maxLength: 300,
maxLines: 1,
decoration: InputDecoration(
hintText: l10n.writeComment,
counterText: '',
filled: true,
fillColor: const Color(0xFF35353A),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
),
onSubmitted: (_) => _post(),
),
),
const SizedBox(width: 8),
IconButton.filled(
key: const Key('comment-post'),
tooltip: l10n.postComment,
onPressed: _post,
icon: const Icon(Icons.send_rounded),
),
],
),
],
),
),
),
),
);
}
}
@@ -0,0 +1,147 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
/// Keeps seeks within the playable range so dragging to the edge does not
/// trigger the player's completion callback before playback resumes.
Duration seekPositionForFraction(double fraction, Duration duration) {
final total = duration.inMilliseconds;
if (total <= 0) return Duration.zero;
final lastPlayable = total > 250 ? total - 250 : total - 1;
final requested = (fraction.clamp(0.0, 1.0) * total).round();
return Duration(milliseconds: requested.clamp(0, lastPlayable));
}
class SeekProgressBar extends StatefulWidget {
const SeekProgressBar({
super.key,
required this.valueListenable,
required this.semanticsLabel,
this.onScrubStart,
this.onSeek,
this.showTimes = true,
this.compactIdle = false,
});
final ValueListenable<VideoPlayerValue>? valueListenable;
final String semanticsLabel;
final VoidCallback? onScrubStart;
final Future<void> Function(Duration)? onSeek;
final bool showTimes;
final bool compactIdle;
@override
State<SeekProgressBar> createState() => _SeekProgressBarState();
}
class _SeekProgressBarState extends State<SeekProgressBar> {
double? _previewFraction;
@override
void didUpdateWidget(covariant SeekProgressBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.valueListenable != widget.valueListenable) {
_previewFraction = null;
}
}
Future<void> _finish(double fraction, Duration duration) async {
final source = widget.valueListenable;
setState(() => _previewFraction = fraction);
try {
await widget.onSeek?.call(seekPositionForFraction(fraction, duration));
} finally {
if (mounted && widget.valueListenable == source) {
setState(() => _previewFraction = null);
}
}
}
@override
Widget build(BuildContext context) {
final source = widget.valueListenable;
if (source == null) {
return _buildTrack(context, const VideoPlayerValue.uninitialized());
}
return ValueListenableBuilder<VideoPlayerValue>(
valueListenable: source,
builder: (context, value, _) => _buildTrack(context, value),
);
}
Widget _buildTrack(BuildContext context, VideoPlayerValue video) {
final total = video.duration.inMilliseconds;
final enabled = video.isInitialized && total > 0 && widget.onSeek != null;
final played = total > 0
? (video.position.inMilliseconds / total).clamp(0.0, 1.0)
: 0.0;
final fraction = _previewFraction ?? played;
final previewPosition = _previewFraction == null
? video.position
: seekPositionForFraction(fraction, video.duration);
final scrubbing = _previewFraction != null;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: widget.compactIdle && !scrubbing ? 20 : 36,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: widget.compactIdle && !scrubbing ? 2 : 3,
activeTrackColor: const Color(0xFFFF5C37),
inactiveTrackColor: Colors.white30,
thumbColor: Colors.white,
thumbShape: RoundSliderThumbShape(
enabledThumbRadius: widget.compactIdle && !scrubbing ? 0 : 6,
),
overlayShape: RoundSliderOverlayShape(
overlayRadius: widget.compactIdle && !scrubbing ? 0 : 11,
),
),
child: Semantics(
label: widget.semanticsLabel,
child: Slider(
value: fraction,
onChangeStart: enabled
? (value) {
setState(() => _previewFraction = value);
widget.onScrubStart?.call();
}
: null,
onChanged: enabled
? (value) => setState(() => _previewFraction = value)
: null,
onChangeEnd: enabled
? (value) => unawaited(_finish(value, video.duration))
: null,
),
),
),
),
if (widget.showTimes && (!widget.compactIdle || scrubbing))
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_format(previewPosition),
style: const TextStyle(fontSize: 12, color: Colors.white),
),
Text(
_format(video.duration),
style: const TextStyle(fontSize: 12, color: Colors.white),
),
],
),
],
);
}
String _format(Duration value) {
final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$minutes:$seconds';
}
}