Files
reelbox/lib/media/native_playback_page.dart
T

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());
},
),
],
),
);
}