294 lines
9.5 KiB
Dart
294 lines
9.5 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../data/content_policy.dart';
|
|
import '../data/models.dart';
|
|
import '../state/app_state.dart';
|
|
import 'cast_media_share.dart';
|
|
import 'media_helpers.dart';
|
|
import 'media_platform.dart';
|
|
import 'native_player.dart';
|
|
|
|
/// Android playback uses a standalone LibVLC activity. Its exit future owns the
|
|
/// checkpoint and any device-only offline-package HTTP lease.
|
|
class NativePlaybackPage extends StatefulWidget {
|
|
const NativePlaybackPage({
|
|
super.key,
|
|
required this.title,
|
|
required this.source,
|
|
this.episode,
|
|
this.playlist = const [],
|
|
this.alternatives = const [],
|
|
this.localFile,
|
|
});
|
|
final TitleRecord title;
|
|
final MediaSource source;
|
|
final EpisodeRecord? episode;
|
|
final List<EpisodeRecord> playlist;
|
|
final List<MediaSource> alternatives;
|
|
final String? localFile;
|
|
@override
|
|
State<NativePlaybackPage> createState() => _NativePlaybackPageState();
|
|
}
|
|
|
|
class _NativePlaybackPageState extends State<NativePlaybackPage> {
|
|
late MediaSource _source = widget.source;
|
|
late EpisodeRecord? _episode = widget.episode;
|
|
AppState? _app;
|
|
bool _started = false, _opening = false;
|
|
String? _error;
|
|
int _generation = 0;
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_app = AppScope.read(context);
|
|
if (!_started) {
|
|
_started = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) unawaited(_open());
|
|
});
|
|
}
|
|
}
|
|
|
|
bool _current(int generation) => mounted && generation == _generation;
|
|
Duration _resume(EpisodeRecord? episode) {
|
|
final app = _app!;
|
|
final saved = app.history
|
|
.where(
|
|
(entry) =>
|
|
entry.title.libraryKey == widget.title.libraryKey &&
|
|
entry.episodeId == episode?.id,
|
|
)
|
|
.firstOrNull;
|
|
if (!app.prefs.resumePlayback || saved == null) return Duration.zero;
|
|
return resumePosition(
|
|
position: Duration(milliseconds: (saved.positionSeconds * 1000).round()),
|
|
duration: Duration(milliseconds: (saved.durationSeconds * 1000).round()),
|
|
rewindSeconds: app.prefs.resumeRewindSeconds,
|
|
);
|
|
}
|
|
|
|
Future<void> _open() async {
|
|
if (_opening) return;
|
|
final generation = ++_generation;
|
|
setState(() {
|
|
_opening = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
while (_current(generation)) {
|
|
final source = _source;
|
|
final episode = _episode;
|
|
if (!ContentPolicy.allowsTitleJson(widget.title.toJson()) ||
|
|
!ContentPolicy.allowsUrl(source.url)) {
|
|
throw StateError('This source is excluded from ReelBox.');
|
|
}
|
|
final prefs = _app!.prefs;
|
|
var target = widget.localFile ?? source.url;
|
|
CastMediaLease? lease;
|
|
// Native HLS/DASH reads need a device-only HTTP origin for package
|
|
// resources. Never bind a LAN interface or relay remote movie content.
|
|
if (widget.localFile != null &&
|
|
RegExp(
|
|
r'\.(m3u8|mpd)$',
|
|
caseSensitive: false,
|
|
).hasMatch(widget.localFile!)) {
|
|
lease = await CastMediaShare.prepare(
|
|
payload: {'url': source.url},
|
|
localFile: widget.localFile,
|
|
bindAddress: InternetAddress.loopbackIPv4,
|
|
advertisedHost: '127.0.0.1',
|
|
);
|
|
|
|
if (!_current(generation)) {
|
|
await lease.close();
|
|
return;
|
|
}
|
|
target = lease.payload['url'] as String;
|
|
}
|
|
NativePlaybackResult result;
|
|
try {
|
|
result = await NativePlayerBridge.open(
|
|
url: target,
|
|
title: episode == null
|
|
? widget.title.title
|
|
: '${widget.title.title} — ${episode.title}',
|
|
position: _resume(episode),
|
|
autoplay: prefs.autoplay,
|
|
externalPackage: prefs.externalPlayerPackage,
|
|
orientation: prefs.orientation,
|
|
videoFit: prefs.videoFit,
|
|
speed: prefs.playbackSpeed,
|
|
subtitleEnabled: prefs.subtitleEnabled,
|
|
);
|
|
} finally {
|
|
// The pending native call owns this lease, even if the Flutter route
|
|
// was removed. Revoke only after the activity has exited.
|
|
await lease?.close();
|
|
}
|
|
if (!_current(generation)) return;
|
|
// Launch success or an initial zero position is not evidence of viewing.
|
|
if (result.opened &&
|
|
result.position > Duration.zero &&
|
|
!source.isTrailer) {
|
|
await _app!.recordWatch(
|
|
widget.title,
|
|
episodeId: episode?.id,
|
|
episodeTitle: episode?.title ?? '',
|
|
sourceUrl: source.url,
|
|
positionSeconds: result.position.inMilliseconds / 1000,
|
|
durationSeconds: result.duration.inMilliseconds / 1000,
|
|
);
|
|
}
|
|
if (!_current(generation)) return;
|
|
if (!result.opened) {
|
|
throw StateError('The native decoder did not open this source.');
|
|
}
|
|
final next =
|
|
result.completed && prefs.autoNext && widget.localFile == null
|
|
? nextEpisode(widget.playlist, episode?.id)
|
|
: null;
|
|
final nextSource = next == null
|
|
? null
|
|
: selectSource(next.sources, preferredQuality: source.quality);
|
|
if (next != null && nextSource != null) {
|
|
setState(() {
|
|
_episode = next;
|
|
_source = nextSource;
|
|
});
|
|
continue;
|
|
}
|
|
setState(() => _opening = false);
|
|
if ((!result.completed || prefs.closeOnCompletion) && mounted) {
|
|
await Navigator.maybePop(context);
|
|
}
|
|
return;
|
|
}
|
|
} catch (_) {
|
|
if (_current(generation)) {
|
|
setState(() {
|
|
_opening = false;
|
|
_error = tr(
|
|
context,
|
|
'Could not play this source. Retry, choose another quality, or use an external player.',
|
|
'پخش این منبع ممکن نشد. دوباره تلاش کنید، کیفیت دیگری انتخاب کنید یا از پخشکننده خارجی استفاده کنید.',
|
|
);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _external() async {
|
|
try {
|
|
if (widget.localFile != null &&
|
|
RegExp(
|
|
r'\.(m3u8|mpd)$',
|
|
caseSensitive: false,
|
|
).hasMatch(widget.localFile!)) {
|
|
throw UnsupportedError('Use the in-app player for an offline package.');
|
|
}
|
|
await MediaPlatform.openExternal(
|
|
widget.localFile ?? _source.url,
|
|
packageName: _app!.prefs.externalPlayerPackage,
|
|
title: widget.title.title,
|
|
position: _resume(_episode),
|
|
);
|
|
} catch (_) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
tr(
|
|
context,
|
|
'No external player could open this source.',
|
|
'پخشکننده خارجی نتوانست این منبع را باز کند.',
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_generation++;
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Scaffold(
|
|
appBar: AppBar(title: Text(_episode?.title ?? widget.title.title)),
|
|
body: ListView(
|
|
padding: const EdgeInsets.all(24),
|
|
children: [
|
|
if (_opening) const Center(child: CircularProgressIndicator()),
|
|
const SizedBox(height: 24),
|
|
if (_error != null)
|
|
Text(
|
|
tr(context, 'Unable to play this source', 'پخش این منبع ممکن نیست'),
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
Text(
|
|
_error ??
|
|
tr(
|
|
context,
|
|
_opening
|
|
? 'Opening the embedded VLC player'
|
|
: 'Playback finished',
|
|
_opening
|
|
? 'در حال باز کردن پخشکننده داخلی VLC'
|
|
: 'پخش پایان یافت',
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
children: [
|
|
FilledButton.icon(
|
|
onPressed: _opening ? null : _open,
|
|
icon: const Icon(Icons.play_arrow),
|
|
label: Text(
|
|
tr(
|
|
context,
|
|
_error == null ? 'Play again' : 'Retry',
|
|
_error == null ? 'پخش دوباره' : 'تلاش دوباره',
|
|
),
|
|
),
|
|
),
|
|
TextButton.icon(
|
|
onPressed: _opening ? null : _external,
|
|
icon: const Icon(Icons.open_in_new),
|
|
label: Text(tr(context, 'External player', 'پخشکننده خارجی')),
|
|
),
|
|
],
|
|
),
|
|
if (!_opening && widget.localFile == null)
|
|
for (final source in (_episode?.sources ?? widget.alternatives).where(
|
|
(source) =>
|
|
source.url != _source.url &&
|
|
!source.isTrailer &&
|
|
isRemoteMedia(source.url) &&
|
|
ContentPolicy.allowsUrl(source.url),
|
|
))
|
|
ListTile(
|
|
title: Text(
|
|
source.quality.isEmpty
|
|
? tr(context, 'Another source', 'منبع دیگر')
|
|
: source.quality,
|
|
),
|
|
trailing: const Icon(Icons.play_arrow),
|
|
onTap: () {
|
|
_source = source;
|
|
unawaited(_open());
|
|
},
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|