提交 Nontonki 短剧原型,准备推送到远程仓库。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
b1f6ce4bf9
148 files changed
+11175
No files matched your search
@@ -0,0 +1,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(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.',
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 演示';
|
||||
}
|
||||
@@ -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 演示"
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import '../../core/data/models/drama.dart';
|
||||
import 'home_logic.dart';
|
||||
|
||||
class HomeBinding {
|
||||
HomeLogic create(List<Drama> dramas) => HomeLogic(dramas);
|
||||
}
|
||||
@@ -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]];
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user