[verified] Publish ReelBox source under GPLv3
This commit is contained in:
commit
db2b505ca3
445 files changed
+105274
No files matched your search
@@ -0,0 +1,293 @@
|
||||
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());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user