mirror of
https://github.com/madari-media/madari-oss.git
synced 2026-04-20 22:32:05 +00:00
Merge branch 'madari-media:main' into Test
This commit is contained in:
commit
6b9b9a8789
37 changed files with 3485 additions and 1727 deletions
1
lib/data/global_logs.dart
Normal file
1
lib/data/global_logs.dart
Normal file
|
|
@ -0,0 +1 @@
|
|||
final List<String> globalLogs = [];
|
||||
|
|
@ -24,13 +24,13 @@ class _AutoImportState extends State<AutoImport> {
|
|||
late StremioService _stremio;
|
||||
final List<FolderItem> _selected = [];
|
||||
bool _isLoading = false;
|
||||
bool _selectAll = false;
|
||||
|
||||
Future<List<FolderItem>>? _folders;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
initialValueImport();
|
||||
}
|
||||
|
||||
|
|
@ -89,6 +89,21 @@ class _AutoImportState extends State<AutoImport> {
|
|||
}
|
||||
}
|
||||
|
||||
void toggleSelectAll() async {
|
||||
final folders = await _folders;
|
||||
if (folders == null) return;
|
||||
|
||||
setState(() {
|
||||
_selectAll = !_selectAll;
|
||||
if (_selectAll) {
|
||||
_selected.clear();
|
||||
_selected.addAll(folders);
|
||||
} else {
|
||||
_selected.clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
|
@ -97,6 +112,17 @@ class _AutoImportState extends State<AutoImport> {
|
|||
title: const Text("Import Libraries"),
|
||||
backgroundColor: Colors.transparent,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(_selectAll
|
||||
? Icons.check_box_outlined
|
||||
: Icons.check_box_outline_blank),
|
||||
onPressed: () {
|
||||
toggleSelectAll();
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
width: 12,
|
||||
),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _selected.isNotEmpty
|
||||
? () {
|
||||
|
|
@ -130,25 +156,28 @@ class _AutoImportState extends State<AutoImport> {
|
|||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data?.length ?? 0,
|
||||
itemBuilder: (item, index) {
|
||||
final item = snapshot.data![index];
|
||||
final folders = snapshot.data!;
|
||||
|
||||
final selected =
|
||||
_selected.where((selected) => selected.id == item.id);
|
||||
return ListView.builder(
|
||||
itemCount: folders.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = folders[index];
|
||||
|
||||
final isSelected =
|
||||
_selected.any((selected) => selected.id == item.id);
|
||||
|
||||
return ListTile(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (selected.isEmpty) {
|
||||
_selected.add(item);
|
||||
if (isSelected) {
|
||||
_selected
|
||||
.removeWhere((selected) => selected.id == item.id);
|
||||
} else {
|
||||
_selected.remove(item);
|
||||
_selected.add(item);
|
||||
}
|
||||
});
|
||||
},
|
||||
leading: selected.isNotEmpty
|
||||
leading: isSelected
|
||||
? const Icon(Icons.check)
|
||||
: const Icon(Icons.check_box_outline_blank),
|
||||
title: Text(item.title),
|
||||
|
|
|
|||
|
|
@ -87,10 +87,10 @@ class StremioService extends BaseConnectionService {
|
|||
},
|
||||
).toList();
|
||||
|
||||
if (resources.contains("catalog")) {
|
||||
if (resources.contains("catalog") ||
|
||||
manifest.catalogs?.isNotEmpty == true) {
|
||||
for (final item
|
||||
in (manifest.catalogs ?? [] as List<StremioManifestCatalog>)) {
|
||||
print(item.toJson());
|
||||
result.add(
|
||||
FolderItem(
|
||||
title: item.name == null
|
||||
|
|
|
|||
|
|
@ -105,8 +105,6 @@ abstract class BaseConnectionService {
|
|||
|
||||
Future<void> getStreams(
|
||||
LibraryItem id, {
|
||||
String? season,
|
||||
String? episode,
|
||||
OnStreamCallback? callback,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -5,10 +5,12 @@ import 'package:cached_query/cached_query.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:madari_client/features/connections/types/base/base.dart';
|
||||
import 'package:madari_client/features/connections/widget/stremio/stremio_card.dart';
|
||||
import 'package:madari_client/features/connections/widget/stremio/stremio_list_item.dart';
|
||||
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
|
||||
import 'package:madari_client/utils/common.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
import '../../connection/services/stremio_service.dart';
|
||||
|
|
@ -23,87 +25,96 @@ typedef OnStreamCallback = void Function(List<StreamList>? items, Error?);
|
|||
|
||||
class StremioConnectionService extends BaseConnectionService {
|
||||
final StremioConfig config;
|
||||
final Logger _logger = Logger('StremioConnectionService');
|
||||
|
||||
StremioConnectionService({
|
||||
required super.connectionId,
|
||||
required this.config,
|
||||
});
|
||||
}) {
|
||||
_logger.info('StremioConnectionService initialized with config: $config');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryItem?> getItemById(LibraryItem id) async {
|
||||
_logger.fine('Fetching item by ID: ${id.id}');
|
||||
return Query<LibraryItem?>(
|
||||
key: "meta_${id.id}",
|
||||
config: QueryConfig(
|
||||
cacheDuration: const Duration(days: 30),
|
||||
refetchDuration: (id as Meta).type == "movie"
|
||||
? const Duration(days: 30)
|
||||
: const Duration(
|
||||
days: 1,
|
||||
),
|
||||
),
|
||||
queryFn: () async {
|
||||
for (final addon in config.addons) {
|
||||
final manifest = await _getManifest(addon);
|
||||
key: "meta_${id.id}",
|
||||
config: QueryConfig(
|
||||
cacheDuration: const Duration(days: 30),
|
||||
refetchDuration: (id as Meta).type == "movie"
|
||||
? const Duration(days: 30)
|
||||
: const Duration(days: 1),
|
||||
),
|
||||
queryFn: () async {
|
||||
for (final addon in config.addons) {
|
||||
_logger.finer('Checking addon: $addon');
|
||||
final manifest = await _getManifest(addon);
|
||||
|
||||
if (manifest.resources == null) {
|
||||
continue;
|
||||
}
|
||||
if (manifest.resources == null) {
|
||||
_logger.finer('No resources found in manifest for addon: $addon');
|
||||
continue;
|
||||
}
|
||||
|
||||
List<String> idPrefixes = [];
|
||||
List<String> idPrefixes = [];
|
||||
bool isMeta = false;
|
||||
|
||||
bool isMeta = false;
|
||||
for (final item in manifest.resources!) {
|
||||
if (item.name == "meta") {
|
||||
idPrefixes.addAll(
|
||||
(item.idPrefix ?? []) + (item.idPrefixes ?? []));
|
||||
isMeta = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (final item in manifest.resources!) {
|
||||
if (item.name == "meta") {
|
||||
idPrefixes
|
||||
.addAll((item.idPrefix ?? []) + (item.idPrefixes ?? []));
|
||||
isMeta = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMeta == false) {
|
||||
continue;
|
||||
}
|
||||
if (!isMeta) {
|
||||
_logger
|
||||
.finer('No meta resource found in manifest for addon: $addon');
|
||||
continue;
|
||||
}
|
||||
|
||||
final ids = ((manifest.idPrefixes ?? []) + idPrefixes)
|
||||
.firstWhere((item) => id.id.startsWith(item),
|
||||
orElse: () => "");
|
||||
final ids = ((manifest.idPrefixes ?? []) + idPrefixes)
|
||||
.firstWhere((item) => id.id.startsWith(item), orElse: () => "");
|
||||
|
||||
if (ids.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
if (ids.isEmpty) {
|
||||
_logger.finer('No matching ID prefix found for addon: $addon');
|
||||
continue;
|
||||
}
|
||||
|
||||
final result = await http.get(
|
||||
Uri.parse(
|
||||
"${_getAddonBaseURL(addon)}/meta/${id.type}/${id.id}.json",
|
||||
),
|
||||
);
|
||||
final result = await http.get(
|
||||
Uri.parse(
|
||||
"${_getAddonBaseURL(addon)}/meta/${id.type}/${id.id}.json"),
|
||||
);
|
||||
|
||||
final item = jsonDecode(result.body);
|
||||
final item = jsonDecode(result.body);
|
||||
|
||||
if (item['meta'] == null) {
|
||||
return null;
|
||||
}
|
||||
if (item['meta'] == null) {
|
||||
_logger.finer(
|
||||
'No meta data found for item: ${id.id} in addon: $addon');
|
||||
return null;
|
||||
}
|
||||
|
||||
return StreamMetaResponse.fromJson(item).meta;
|
||||
}
|
||||
return StreamMetaResponse.fromJson(item).meta;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
_logger.warning('No meta data found for item: ${id.id} in any addon');
|
||||
return null;
|
||||
},
|
||||
)
|
||||
.stream
|
||||
.where((item) {
|
||||
return item.status != QueryStatus.loading;
|
||||
})
|
||||
.where((item) => item.status != QueryStatus.loading)
|
||||
.first
|
||||
.then((docs) {
|
||||
if (docs.error != null) {
|
||||
throw docs.error;
|
||||
}
|
||||
return docs.data;
|
||||
});
|
||||
if (docs.error != null) {
|
||||
_logger.severe('Error fetching item by ID: ${docs.error}');
|
||||
throw docs.error!;
|
||||
}
|
||||
return docs.data;
|
||||
});
|
||||
}
|
||||
|
||||
List<InternalManifestItemConfig> getConfig(dynamic configOutput) {
|
||||
_logger.fine('Parsing config output');
|
||||
final List<InternalManifestItemConfig> configItems = [];
|
||||
|
||||
for (final item in configOutput) {
|
||||
|
|
@ -113,9 +124,67 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
configItems.add(itemToPush);
|
||||
}
|
||||
|
||||
_logger.finer('Config parsed successfully: $configItems');
|
||||
return configItems;
|
||||
}
|
||||
|
||||
Stream<List<Subtitle>> getSubtitles(Meta record) async* {
|
||||
final List<Subtitle> subtitles = [];
|
||||
|
||||
_logger.info('getting subtitles');
|
||||
|
||||
for (final addon in config.addons) {
|
||||
final manifest = await _getManifest(addon);
|
||||
|
||||
final resource = manifest.resources
|
||||
?.firstWhereOrNull((res) => res.name == "subtitles");
|
||||
|
||||
if (resource == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final types = resource.types ?? manifest.types ?? [];
|
||||
final idPrefixes =
|
||||
resource.idPrefixes ?? resource.idPrefix ?? manifest.idPrefixes;
|
||||
|
||||
if (!types.contains(record.type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final hasPrefixMatch = idPrefixes?.firstWhereOrNull((item) {
|
||||
return record.id.startsWith(item);
|
||||
});
|
||||
|
||||
if (hasPrefixMatch == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final addonBase = _getAddonBaseURL(addon);
|
||||
|
||||
final url =
|
||||
"$addonBase/subtitles/${record.type}/${Uri.encodeQueryComponent(record.currentVideo?.id ?? record.id)}.json";
|
||||
|
||||
_logger.info('loading subtitles from $url');
|
||||
|
||||
final body = await http.get(Uri.parse(url));
|
||||
|
||||
if (body.statusCode != 200) {
|
||||
_logger.warning('failed due to status code ${body.statusCode}');
|
||||
continue;
|
||||
}
|
||||
|
||||
final dataBody = jsonDecode(body.body);
|
||||
|
||||
try {
|
||||
final responses = SubtitleResponse.fromJson(dataBody);
|
||||
subtitles.addAll(responses.subtitles);
|
||||
yield subtitles;
|
||||
} catch (e) {
|
||||
_logger.warning("failed to parse subtitle response");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PaginatedResult<LibraryItem>> getItems(
|
||||
LibraryRecord library, {
|
||||
|
|
@ -124,6 +193,7 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
int? perPage,
|
||||
String? cursor,
|
||||
}) async {
|
||||
_logger.fine('Fetching items for library: ${library.id}');
|
||||
final List<Meta> returnValue = [];
|
||||
final configItems = getConfig(library.config);
|
||||
|
||||
|
|
@ -160,35 +230,38 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
|
||||
final result = await Query(
|
||||
config: QueryConfig(
|
||||
cacheDuration: const Duration(
|
||||
hours: 8,
|
||||
),
|
||||
cacheDuration: const Duration(hours: 8),
|
||||
),
|
||||
queryFn: () async {
|
||||
final httpBody = await http.get(
|
||||
Uri.parse(url),
|
||||
);
|
||||
|
||||
return StrmioMeta.fromJson(jsonDecode(httpBody.body));
|
||||
try {
|
||||
_logger.finer('Fetching catalog from URL: $url');
|
||||
final httpBody = await http.get(Uri.parse(url));
|
||||
return StrmioMeta.fromJson(
|
||||
jsonDecode(httpBody.body),
|
||||
);
|
||||
} catch (e, stack) {
|
||||
_logger.severe('Error parsing catalog $url', e, stack);
|
||||
rethrow;
|
||||
}
|
||||
},
|
||||
key: url,
|
||||
)
|
||||
.stream
|
||||
.where((item) {
|
||||
return item.status != QueryStatus.loading;
|
||||
})
|
||||
.where((item) => item.status != QueryStatus.loading)
|
||||
.first
|
||||
.then((docs) {
|
||||
if (docs.error != null) {
|
||||
throw docs.error;
|
||||
}
|
||||
return docs.data!;
|
||||
});
|
||||
if (docs.error != null) {
|
||||
_logger.severe('Error fetching catalog', docs.error);
|
||||
throw docs.error!;
|
||||
}
|
||||
return docs.data!;
|
||||
});
|
||||
|
||||
hasMore = result.hasMore ?? false;
|
||||
returnValue.addAll(result.metas ?? []);
|
||||
}
|
||||
|
||||
_logger.finer('Items fetched successfully: ${returnValue.length} items');
|
||||
return PagePaginatedResult(
|
||||
items: returnValue.toList(),
|
||||
currentPage: page ?? 1,
|
||||
|
|
@ -199,6 +272,7 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
|
||||
@override
|
||||
Widget renderCard(LibraryItem item, String heroPrefix) {
|
||||
_logger.fine('Rendering card for item: ${item.id}');
|
||||
return StremioCard(
|
||||
item: item,
|
||||
prefix: heroPrefix,
|
||||
|
|
@ -209,7 +283,9 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
|
||||
@override
|
||||
Future<List<LibraryItem>> getBulkItem(List<LibraryItem> ids) async {
|
||||
_logger.fine('Fetching bulk items: ${ids.length} items');
|
||||
if (ids.isEmpty) {
|
||||
_logger.finer('No items to fetch');
|
||||
return [];
|
||||
}
|
||||
|
||||
|
|
@ -218,19 +294,17 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
(res) async {
|
||||
return getItemById(res).then((item) {
|
||||
if (item == null) {
|
||||
_logger.finer('Item not found: ${res.id}');
|
||||
return null;
|
||||
}
|
||||
|
||||
return (item as Meta).copyWith(
|
||||
progress: (res as Meta).progress,
|
||||
nextSeason: res.nextSeason,
|
||||
nextEpisode: res.nextEpisode,
|
||||
nextEpisodeTitle: res.nextEpisodeTitle,
|
||||
selectedVideoIndex: res.selectedVideoIndex,
|
||||
);
|
||||
}).catchError((err, stack) {
|
||||
print(err);
|
||||
print(stack);
|
||||
return null;
|
||||
_logger.severe('Error fetching item: ${res.id}', err, stack);
|
||||
return (res as Meta);
|
||||
});
|
||||
},
|
||||
),
|
||||
|
|
@ -241,44 +315,48 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
|
||||
@override
|
||||
Widget renderList(LibraryItem item, String heroPrefix) {
|
||||
_logger.fine('Rendering list item: ${item.id}');
|
||||
return StremioListItem(item: item);
|
||||
}
|
||||
|
||||
Future<StremioManifest> _getManifest(String url) async {
|
||||
_logger.fine('Fetching manifest from URL: $url');
|
||||
return Query(
|
||||
key: url,
|
||||
config: QueryConfig(
|
||||
cacheDuration: const Duration(days: 30),
|
||||
refetchDuration: const Duration(days: 1),
|
||||
),
|
||||
queryFn: () async {
|
||||
final String result;
|
||||
if (manifestCache.containsKey(url)) {
|
||||
result = manifestCache[url]!;
|
||||
} else {
|
||||
result = (await http.get(Uri.parse(url))).body;
|
||||
manifestCache[url] = result;
|
||||
}
|
||||
key: url,
|
||||
config: QueryConfig(
|
||||
cacheDuration: const Duration(days: 30),
|
||||
refetchDuration: const Duration(days: 1),
|
||||
),
|
||||
queryFn: () async {
|
||||
final String result;
|
||||
if (manifestCache.containsKey(url)) {
|
||||
_logger.finer('Manifest found in cache for URL: $url');
|
||||
result = manifestCache[url]!;
|
||||
} else {
|
||||
_logger.finer('Fetching manifest from network for URL: $url');
|
||||
result = (await http.get(Uri.parse(url))).body;
|
||||
manifestCache[url] = result;
|
||||
}
|
||||
|
||||
final body = jsonDecode(result);
|
||||
final resultFinal = StremioManifest.fromJson(body);
|
||||
return resultFinal;
|
||||
})
|
||||
final body = jsonDecode(result);
|
||||
final resultFinal = StremioManifest.fromJson(body);
|
||||
_logger.finer('Manifest successfully parsed for URL: $url');
|
||||
return resultFinal;
|
||||
},
|
||||
)
|
||||
.stream
|
||||
.where((item) {
|
||||
return item.status != QueryStatus.loading;
|
||||
})
|
||||
.where((item) => item.status != QueryStatus.loading)
|
||||
.first
|
||||
.then((docs) {
|
||||
if (docs.error != null) {
|
||||
throw docs.error;
|
||||
}
|
||||
return docs.data!;
|
||||
});
|
||||
;
|
||||
if (docs.error != null) {
|
||||
_logger.severe('Error fetching manifest: ${docs.error}');
|
||||
throw docs.error!;
|
||||
}
|
||||
return docs.data!;
|
||||
});
|
||||
}
|
||||
|
||||
_getAddonBaseURL(String input) {
|
||||
String _getAddonBaseURL(String input) {
|
||||
return input.endsWith("/manifest.json")
|
||||
? input.replaceAll("/manifest.json", "")
|
||||
: input;
|
||||
|
|
@ -286,6 +364,7 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
|
||||
@override
|
||||
Future<List<ConnectionFilter<T>>> getFilters<T>(LibraryRecord library) async {
|
||||
_logger.fine('Fetching filters for library: ${library.id}');
|
||||
final configItems = getConfig(library.config);
|
||||
List<ConnectionFilter<T>> filters = [];
|
||||
|
||||
|
|
@ -294,6 +373,7 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
final addonManifest = await _getManifest(addon.addon);
|
||||
|
||||
if ((addonManifest.catalogs?.isEmpty ?? true) == true) {
|
||||
_logger.finer('No catalogs found for addon: ${addon.addon}');
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -303,6 +383,7 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
|
||||
for (final catalog in catalogs) {
|
||||
if (catalog.extra == null) {
|
||||
_logger.finer('No extra filters found for catalog: ${catalog.id}');
|
||||
continue;
|
||||
}
|
||||
for (final extraItem in catalog.extra!) {
|
||||
|
|
@ -326,18 +407,20 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
_logger.severe('Error fetching filters', e);
|
||||
}
|
||||
|
||||
_logger.finer('Filters fetched successfully: $filters');
|
||||
return filters;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> getStreams(
|
||||
LibraryItem id, {
|
||||
String? season,
|
||||
String? episode,
|
||||
OnStreamCallback? callback,
|
||||
}) async {
|
||||
_logger.fine('Fetching streams for item: ${id.id}');
|
||||
final List<StreamList> streams = [];
|
||||
final meta = id as Meta;
|
||||
|
||||
|
|
@ -351,6 +434,9 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
final resource = resource_ as ResourceObject;
|
||||
|
||||
if (!doesAddonSupportStream(resource, addonManifest, meta)) {
|
||||
_logger.finer(
|
||||
'Addon does not support stream: ${addonManifest.name}',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -363,6 +449,9 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
final result = await http.get(Uri.parse(url), headers: {});
|
||||
|
||||
if (result.statusCode == 404) {
|
||||
_logger.warning(
|
||||
'Invalid status code for addon: ${addonManifest.name}',
|
||||
);
|
||||
if (callback != null) {
|
||||
callback(
|
||||
null,
|
||||
|
|
@ -377,15 +466,14 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
},
|
||||
)
|
||||
.stream
|
||||
.where((item) {
|
||||
return item.status != QueryStatus.loading;
|
||||
})
|
||||
.where((item) => item.status != QueryStatus.loading)
|
||||
.first
|
||||
.then((docs) {
|
||||
return docs.data;
|
||||
});
|
||||
return docs.data;
|
||||
});
|
||||
|
||||
if (result == null) {
|
||||
_logger.finer('No stream data found for URL: $url');
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -397,8 +485,6 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
(item) => videoStreamToStreamList(
|
||||
item,
|
||||
meta,
|
||||
season,
|
||||
episode,
|
||||
addonManifest,
|
||||
),
|
||||
)
|
||||
|
|
@ -411,7 +497,7 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
}
|
||||
}
|
||||
}).catchError((error, stacktrace) {
|
||||
print(stacktrace);
|
||||
_logger.severe('Error fetching streams', error, stacktrace);
|
||||
if (callback != null) callback(null, error);
|
||||
});
|
||||
|
||||
|
|
@ -419,7 +505,7 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
}
|
||||
|
||||
await Future.wait(promises);
|
||||
|
||||
_logger.finer('Streams fetched successfully: ${streams.length} streams');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -429,6 +515,7 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
Meta meta,
|
||||
) {
|
||||
if (resource.name != "stream") {
|
||||
_logger.finer('Resource is not a stream: ${resource.name}');
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -438,10 +525,12 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
final types = resource.types ?? addonManifest.types;
|
||||
|
||||
if (types == null || !types.contains(meta.type)) {
|
||||
_logger.finer('Addon does not support type: ${meta.type}');
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((idPrefixes ?? []).isEmpty == true) {
|
||||
_logger.finer('No ID prefixes found, assuming support');
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -450,17 +539,17 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
);
|
||||
|
||||
if (hasIdPrefix.isEmpty) {
|
||||
_logger.finer('No matching ID prefix found');
|
||||
return false;
|
||||
}
|
||||
|
||||
_logger.finer('Addon supports stream');
|
||||
return true;
|
||||
}
|
||||
|
||||
StreamList? videoStreamToStreamList(
|
||||
VideoStream item,
|
||||
Meta meta,
|
||||
String? season,
|
||||
String? episode,
|
||||
StremioManifest addonManifest,
|
||||
) {
|
||||
String streamTitle = (item.name != null
|
||||
|
|
@ -476,9 +565,7 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
|
||||
try {
|
||||
streamDescription = item.description != null
|
||||
? utf8.decode(
|
||||
(item.description!).runes.toList(),
|
||||
)
|
||||
? utf8.decode((item.description!).runes.toList())
|
||||
: null;
|
||||
} catch (e) {}
|
||||
|
||||
|
|
@ -500,23 +587,23 @@ class StremioConnectionService extends BaseConnectionService {
|
|||
infoHash: item.infoHash!,
|
||||
id: meta.id,
|
||||
fileName: "$title.mp4",
|
||||
season: season,
|
||||
episode: episode,
|
||||
);
|
||||
}
|
||||
|
||||
if (source == null) {
|
||||
_logger.finer('No valid source found for stream');
|
||||
return null;
|
||||
}
|
||||
|
||||
String addonName = addonManifest.name;
|
||||
|
||||
try {
|
||||
addonName = utf8.decode(
|
||||
(addonName).runes.toList(),
|
||||
);
|
||||
} catch (e) {}
|
||||
addonName = utf8.decode((addonName).runes.toList());
|
||||
} catch (e) {
|
||||
_logger.warning('Failed to decode addon name', e);
|
||||
}
|
||||
|
||||
_logger.finer('Stream list created successfully');
|
||||
return StreamList(
|
||||
title: streamTitle,
|
||||
description: streamDescription,
|
||||
|
|
@ -545,3 +632,69 @@ class StremioConfig {
|
|||
|
||||
Map<String, dynamic> toJson() => _$StremioConfigToJson(this);
|
||||
}
|
||||
|
||||
class Subtitle {
|
||||
final String id;
|
||||
final String url;
|
||||
final String? subEncoding;
|
||||
final String? lang;
|
||||
final String? m;
|
||||
final String? g; // Making g optional since some entries have empty string
|
||||
|
||||
const Subtitle({
|
||||
required this.id,
|
||||
required this.url,
|
||||
required this.subEncoding,
|
||||
required this.lang,
|
||||
required this.m,
|
||||
this.g,
|
||||
});
|
||||
|
||||
factory Subtitle.fromJson(Map<String, dynamic> json) {
|
||||
return Subtitle(
|
||||
id: json['id'] as String,
|
||||
url: json['url'] as String,
|
||||
subEncoding: json['SubEncoding'] as String?,
|
||||
lang: json['lang'] as String?,
|
||||
m: json['m'] as String?,
|
||||
g: json['g'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'url': url,
|
||||
'SubEncoding': subEncoding,
|
||||
'lang': lang,
|
||||
'm': m,
|
||||
'g': g,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class SubtitleResponse {
|
||||
final List<Subtitle> subtitles;
|
||||
final int? cacheMaxAge;
|
||||
|
||||
const SubtitleResponse({
|
||||
required this.subtitles,
|
||||
required this.cacheMaxAge,
|
||||
});
|
||||
|
||||
factory SubtitleResponse.fromJson(Map<String, dynamic> json) {
|
||||
return SubtitleResponse(
|
||||
subtitles: (json['subtitles'] as List)
|
||||
.map((e) => Subtitle.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
cacheMaxAge: json['cacheMaxAge'] as int?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'subtitles': subtitles.map((e) => e.toJson()).toList(),
|
||||
'cacheMaxAge': cacheMaxAge,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
import 'package:pocketbase/pocketbase.dart';
|
||||
|
||||
|
|
@ -279,7 +278,7 @@ class Meta extends LibraryItem {
|
|||
@JsonKey(name: "id")
|
||||
final String id;
|
||||
@JsonKey(name: "videos")
|
||||
final List<Video>? videos;
|
||||
List<Video>? videos;
|
||||
@JsonKey(name: "genres")
|
||||
final List<String>? genres;
|
||||
@JsonKey(name: "releaseInfo")
|
||||
|
|
@ -299,24 +298,14 @@ class Meta extends LibraryItem {
|
|||
@JsonKey(name: "dvdRelease")
|
||||
final DateTime? dvdRelease;
|
||||
|
||||
final int? traktProgressId;
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final double? progress;
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final int? nextSeason;
|
||||
final int? selectedVideoIndex;
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final int? nextEpisode;
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final String? nextEpisodeTitle;
|
||||
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final int? traktId;
|
||||
|
||||
final dynamic externalIds;
|
||||
|
||||
bool forceRegularMode;
|
||||
bool? forceRegular = false;
|
||||
|
||||
String get imdbRating {
|
||||
return (imdbRating_ ?? "").toString();
|
||||
|
|
@ -331,11 +320,9 @@ class Meta extends LibraryItem {
|
|||
return null;
|
||||
}
|
||||
|
||||
return videos?.firstWhereOrNull(
|
||||
(episode) {
|
||||
return nextEpisode == episode.episode && nextSeason == episode.season;
|
||||
},
|
||||
);
|
||||
if (selectedVideoIndex != null) return videos![selectedVideoIndex!];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Meta({
|
||||
|
|
@ -344,22 +331,18 @@ class Meta extends LibraryItem {
|
|||
this.popularities,
|
||||
required this.type,
|
||||
this.cast,
|
||||
this.traktId,
|
||||
this.forceRegular,
|
||||
this.country,
|
||||
this.forceRegularMode = false,
|
||||
this.externalIds,
|
||||
this.description,
|
||||
this.selectedVideoIndex,
|
||||
this.genre,
|
||||
this.imdbRating_,
|
||||
this.poster,
|
||||
this.nextEpisode,
|
||||
this.nextSeason,
|
||||
this.released,
|
||||
this.slug,
|
||||
this.year,
|
||||
this.status,
|
||||
this.tvdbId,
|
||||
this.nextEpisodeTitle,
|
||||
this.director,
|
||||
this.writer,
|
||||
this.background,
|
||||
|
|
@ -381,6 +364,7 @@ class Meta extends LibraryItem {
|
|||
this.language,
|
||||
this.dvdRelease,
|
||||
this.progress,
|
||||
this.traktProgressId,
|
||||
}) : super(id: id);
|
||||
|
||||
Meta copyWith({
|
||||
|
|
@ -401,8 +385,11 @@ class Meta extends LibraryItem {
|
|||
dynamic tvdbId,
|
||||
List<dynamic>? director,
|
||||
List<String>? writer,
|
||||
final dynamic traktInfo,
|
||||
String? background,
|
||||
String? logo,
|
||||
dynamic externalIds,
|
||||
dynamic episodeExternalIds,
|
||||
String? awards,
|
||||
int? moviedbId,
|
||||
String? runtime,
|
||||
|
|
@ -411,6 +398,7 @@ class Meta extends LibraryItem {
|
|||
String? id,
|
||||
List<Video>? videos,
|
||||
List<String>? genres,
|
||||
int? selectedVideoIndex,
|
||||
String? releaseInfo,
|
||||
List<TrailerStream>? trailerStreams,
|
||||
List<Link>? links,
|
||||
|
|
@ -419,10 +407,9 @@ class Meta extends LibraryItem {
|
|||
List<CreditsCrew>? creditsCrew,
|
||||
String? language,
|
||||
DateTime? dvdRelease,
|
||||
int? nextSeason,
|
||||
int? nextEpisode,
|
||||
String? nextEpisodeTitle,
|
||||
double? progress,
|
||||
bool? forceRegular,
|
||||
int? traktProgressId,
|
||||
}) =>
|
||||
Meta(
|
||||
imdbId: imdbId ?? this.imdbId,
|
||||
|
|
@ -431,13 +418,16 @@ class Meta extends LibraryItem {
|
|||
type: type ?? this.type,
|
||||
cast: cast ?? this.cast,
|
||||
country: country ?? this.country,
|
||||
selectedVideoIndex: selectedVideoIndex ?? this.selectedVideoIndex,
|
||||
description: description ?? this.description,
|
||||
genre: genre ?? this.genre,
|
||||
imdbRating_: imdbRating ?? imdbRating_.toString(),
|
||||
poster: poster ?? this.poster,
|
||||
released: released ?? this.released,
|
||||
traktProgressId: traktProgressId ?? this.traktProgressId,
|
||||
slug: slug ?? this.slug,
|
||||
year: year ?? this.year,
|
||||
forceRegular: forceRegular ?? this.forceRegular,
|
||||
status: status ?? this.status,
|
||||
tvdbId: tvdbId ?? this.tvdbId,
|
||||
director: director ?? this.director,
|
||||
|
|
@ -460,9 +450,6 @@ class Meta extends LibraryItem {
|
|||
creditsCrew: creditsCrew ?? this.creditsCrew,
|
||||
language: language ?? this.language,
|
||||
dvdRelease: dvdRelease ?? this.dvdRelease,
|
||||
nextEpisode: nextEpisode ?? this.nextEpisode,
|
||||
nextEpisodeTitle: nextEpisodeTitle ?? this.nextEpisodeTitle,
|
||||
nextSeason: nextSeason ?? this.nextSeason,
|
||||
progress: progress ?? this.progress,
|
||||
);
|
||||
|
||||
|
|
@ -473,13 +460,20 @@ class Meta extends LibraryItem {
|
|||
}
|
||||
|
||||
Map<String, dynamic> toJson() => _$MetaToJson(this);
|
||||
|
||||
String toString() {
|
||||
if (currentVideo != null) {
|
||||
return "$name ${currentVideo!.name} S${currentVideo!.season} E${currentVideo!.episode}";
|
||||
}
|
||||
return name ?? "No name";
|
||||
}
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class BehaviorHints {
|
||||
@JsonKey(name: "defaultVideoId")
|
||||
final dynamic defaultVideoId;
|
||||
@JsonKey(name: "hasScheduledVideos")
|
||||
@JsonKey(name: "hasScheduledVideos", defaultValue: false)
|
||||
final bool hasScheduledVideos;
|
||||
|
||||
BehaviorHints({
|
||||
|
|
@ -669,7 +663,7 @@ class Trailer {
|
|||
@JsonSerializable()
|
||||
class Video {
|
||||
@JsonKey(name: "name")
|
||||
final String? name;
|
||||
String? name;
|
||||
@JsonKey(name: "season")
|
||||
final int season;
|
||||
@JsonKey(name: "number")
|
||||
|
|
@ -677,7 +671,7 @@ class Video {
|
|||
@JsonKey(name: "firstAired")
|
||||
final DateTime? firstAired;
|
||||
@JsonKey(name: "tvdb_id")
|
||||
final int? tvdbId;
|
||||
int? tvdbId;
|
||||
@JsonKey(name: "overview")
|
||||
final String? overview;
|
||||
@JsonKey(name: "thumbnail")
|
||||
|
|
@ -694,6 +688,8 @@ class Video {
|
|||
final String? title;
|
||||
@JsonKey(name: "moviedb_id")
|
||||
final int? moviedbId;
|
||||
double? progress;
|
||||
dynamic ids;
|
||||
|
||||
Video({
|
||||
this.name,
|
||||
|
|
@ -702,13 +698,15 @@ class Video {
|
|||
this.firstAired,
|
||||
this.tvdbId,
|
||||
this.overview,
|
||||
required this.thumbnail,
|
||||
this.thumbnail,
|
||||
required this.id,
|
||||
required this.released,
|
||||
this.released,
|
||||
this.episode,
|
||||
this.description,
|
||||
this.title,
|
||||
this.moviedbId,
|
||||
this.progress,
|
||||
this.ids,
|
||||
});
|
||||
|
||||
Video copyWith({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'package:cached_query_flutter/cached_query_flutter.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:madari_client/engine/engine.dart';
|
||||
|
|
@ -289,21 +290,16 @@ class __RenderLibraryListState extends State<_RenderLibraryList> {
|
|||
.whereType<LibraryItem>()
|
||||
.toList();
|
||||
|
||||
if (data.status == QueryStatus.loading && items.isEmpty) {
|
||||
return const CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: SpinnerCards(),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return RenderListItems(
|
||||
hasError: data.status == QueryStatus.error,
|
||||
onRefresh: () {
|
||||
query.refetch();
|
||||
},
|
||||
loadMore: () {
|
||||
query.getNextPage();
|
||||
},
|
||||
itemScrollController: _scrollController,
|
||||
isLoadingMore: data.status == QueryStatus.loading && items.isEmpty,
|
||||
isGrid: widget.isGrid,
|
||||
items: items,
|
||||
heroPrefix: widget.item.id,
|
||||
|
|
@ -315,7 +311,30 @@ class __RenderLibraryListState extends State<_RenderLibraryList> {
|
|||
}
|
||||
}
|
||||
|
||||
class RenderListItems extends StatelessWidget {
|
||||
typedef OnContextTap = void Function(
|
||||
String actionId,
|
||||
LibraryItem item,
|
||||
);
|
||||
|
||||
class ContextMenuItem {
|
||||
final String id;
|
||||
final String title;
|
||||
final bool isDefaultAction;
|
||||
final bool isDestructiveAction;
|
||||
final IconData? icon;
|
||||
final OnContextTap? onCallback;
|
||||
|
||||
ContextMenuItem({
|
||||
required this.title,
|
||||
this.isDefaultAction = false,
|
||||
this.isDestructiveAction = false,
|
||||
this.icon,
|
||||
required this.id,
|
||||
this.onCallback,
|
||||
});
|
||||
}
|
||||
|
||||
class RenderListItems extends StatefulWidget {
|
||||
final ScrollController? controller;
|
||||
final ScrollController? itemScrollController;
|
||||
final bool isGrid;
|
||||
|
|
@ -326,6 +345,10 @@ class RenderListItems extends StatelessWidget {
|
|||
final String heroPrefix;
|
||||
final dynamic error;
|
||||
final bool isWide;
|
||||
final bool isLoadingMore;
|
||||
final VoidCallback? loadMore;
|
||||
final List<ContextMenuItem> contextMenuItems;
|
||||
final OnContextTap? onContextMenu;
|
||||
|
||||
const RenderListItems({
|
||||
super.key,
|
||||
|
|
@ -339,21 +362,32 @@ class RenderListItems extends StatelessWidget {
|
|||
this.itemScrollController,
|
||||
this.error,
|
||||
this.isWide = false,
|
||||
this.isLoadingMore = false,
|
||||
this.loadMore,
|
||||
this.contextMenuItems = const [],
|
||||
this.onContextMenu,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RenderListItems> createState() => _RenderListItemsState();
|
||||
}
|
||||
|
||||
class _RenderListItemsState extends State<RenderListItems> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final listHeight = getListHeight(context);
|
||||
final itemWidth = getItemWidth(
|
||||
context,
|
||||
isWide: isWide,
|
||||
isWide: widget.isWide,
|
||||
);
|
||||
|
||||
return CustomScrollView(
|
||||
controller: controller,
|
||||
physics: isGrid ? null : const NeverScrollableScrollPhysics(),
|
||||
controller: widget.controller,
|
||||
physics: widget.isGrid
|
||||
? const AlwaysScrollableScrollPhysics()
|
||||
: const NeverScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
if (hasError)
|
||||
if (widget.hasError)
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: listHeight,
|
||||
|
|
@ -369,7 +403,7 @@ class RenderListItems extends StatelessWidget {
|
|||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"Something went wrong while loading the library \n$error",
|
||||
"Something went wrong while loading the library \n${widget.error}",
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
|
|
@ -378,7 +412,7 @@ class RenderListItems extends StatelessWidget {
|
|||
),
|
||||
TextButton.icon(
|
||||
label: const Text("Retry"),
|
||||
onPressed: onRefresh,
|
||||
onPressed: widget.onRefresh,
|
||||
icon: const Icon(
|
||||
Icons.refresh,
|
||||
),
|
||||
|
|
@ -389,7 +423,7 @@ class RenderListItems extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
if (isGrid)
|
||||
if (widget.isGrid) ...[
|
||||
SliverGrid.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: getGridResponsiveColumnCount(context),
|
||||
|
|
@ -397,40 +431,115 @@ class RenderListItems extends StatelessWidget {
|
|||
crossAxisSpacing: getGridResponsiveSpacing(context),
|
||||
childAspectRatio: 2 / 3,
|
||||
),
|
||||
itemCount: items.length,
|
||||
itemCount: widget.items.length,
|
||||
itemBuilder: (ctx, index) {
|
||||
final item = items[index];
|
||||
final item = widget.items[index];
|
||||
|
||||
return service.renderCard(
|
||||
return widget.service.renderCard(
|
||||
item,
|
||||
"${index}_$heroPrefix",
|
||||
"${index}_${widget.heroPrefix}",
|
||||
);
|
||||
},
|
||||
),
|
||||
if (!isGrid)
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: listHeight,
|
||||
child: ListView.builder(
|
||||
itemBuilder: (ctx, index) {
|
||||
final item = items[index];
|
||||
|
||||
return SizedBox(
|
||||
width: itemWidth,
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(),
|
||||
child: service.renderCard(
|
||||
item,
|
||||
"${index}_${heroPrefix}",
|
||||
if (widget.isLoadingMore)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 8.0,
|
||||
right: 8.0,
|
||||
),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: getGridResponsiveColumnCount(context),
|
||||
mainAxisSpacing: getGridResponsiveSpacing(context),
|
||||
crossAxisSpacing: getGridResponsiveSpacing(context),
|
||||
childAspectRatio: 2 / 3,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(ctx, index) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Shimmer.fromColors(
|
||||
baseColor: Colors.grey[800]!,
|
||||
highlightColor: Colors.grey[700]!,
|
||||
child: Container(
|
||||
color: Colors.grey[800],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: items.length,
|
||||
);
|
||||
},
|
||||
childCount: 4, // Fixed number of loading items
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
if (!widget.isLoadingMore)
|
||||
SliverToBoxAdapter(
|
||||
child: CupertinoPageScaffold(
|
||||
resizeToAvoidBottomInset: true,
|
||||
child: SizedBox(
|
||||
height: listHeight,
|
||||
child: ListView.builder(
|
||||
controller: widget.itemScrollController,
|
||||
itemBuilder: (ctx, index) {
|
||||
final item = widget.items[index];
|
||||
|
||||
if (widget.contextMenuItems.isEmpty) {
|
||||
return SizedBox(
|
||||
width: itemWidth,
|
||||
child: Container(
|
||||
child: widget.service.renderCard(
|
||||
item,
|
||||
"${index}_${widget.heroPrefix}",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return CupertinoContextMenu(
|
||||
enableHapticFeedback: true,
|
||||
actions: widget.contextMenuItems.map((menu) {
|
||||
return CupertinoContextMenuAction(
|
||||
isDefaultAction: menu.isDefaultAction,
|
||||
isDestructiveAction: menu.isDestructiveAction,
|
||||
trailingIcon: menu.icon,
|
||||
onPressed: () {
|
||||
if (widget.onContextMenu != null) {
|
||||
widget.onContextMenu!(
|
||||
menu.id,
|
||||
item,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text(menu.title),
|
||||
);
|
||||
}).toList(),
|
||||
child: SizedBox(
|
||||
width: itemWidth,
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: listHeight,
|
||||
),
|
||||
child: widget.service.renderCard(
|
||||
item,
|
||||
"${index}_${widget.heroPrefix}",
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: widget.items.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.isLoadingMore)
|
||||
SliverToBoxAdapter(
|
||||
child: SpinnerCards(
|
||||
isWide: widget.isWide,
|
||||
),
|
||||
),
|
||||
],
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).padding.bottom,
|
||||
|
|
|
|||
|
|
@ -18,8 +18,6 @@ const kIsWeb = bool.fromEnvironment('dart.library.js_util');
|
|||
class RenderStreamList extends StatefulWidget {
|
||||
final BaseConnectionService service;
|
||||
final LibraryItem id;
|
||||
final String? episode;
|
||||
final String? season;
|
||||
final bool shouldPop;
|
||||
final double? progress;
|
||||
|
||||
|
|
@ -27,8 +25,6 @@ class RenderStreamList extends StatefulWidget {
|
|||
super.key,
|
||||
required this.service,
|
||||
required this.id,
|
||||
this.season,
|
||||
this.episode,
|
||||
this.progress,
|
||||
required this.shouldPop,
|
||||
});
|
||||
|
|
@ -173,8 +169,6 @@ class _RenderStreamListState extends State<RenderStreamList> {
|
|||
|
||||
await widget.service.getStreams(
|
||||
widget.id,
|
||||
episode: widget.episode,
|
||||
season: widget.season,
|
||||
callback: (items, error) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
|
|
@ -303,25 +297,7 @@ class _RenderStreamListState extends State<RenderStreamList> {
|
|||
}
|
||||
}
|
||||
|
||||
int? season;
|
||||
int? episode;
|
||||
|
||||
if (widget.season != null) {
|
||||
season = int.parse(widget.season!);
|
||||
} else if ((widget.id as Meta).nextSeason != null) {
|
||||
season = (widget.id as Meta).nextSeason!;
|
||||
}
|
||||
|
||||
if (widget.episode != null) {
|
||||
episode = int.parse(widget.episode!);
|
||||
} else if ((widget.id as Meta).nextEpisode != null) {
|
||||
episode = (widget.id as Meta).nextEpisode!;
|
||||
}
|
||||
|
||||
final meta = (widget.id as Meta).copyWith(
|
||||
nextSeason: season,
|
||||
nextEpisode: episode,
|
||||
);
|
||||
final meta = (widget.id as Meta).copyWith();
|
||||
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
|
|
@ -329,7 +305,6 @@ class _RenderStreamListState extends State<RenderStreamList> {
|
|||
source: item.source,
|
||||
service: widget.service,
|
||||
meta: meta,
|
||||
season: meta.nextSeason?.toString(),
|
||||
progress: widget.progress,
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import 'package:intl/intl.dart';
|
|||
import 'package:madari_client/features/connection/types/stremio.dart';
|
||||
import 'package:madari_client/features/connections/service/base_connection_service.dart';
|
||||
|
||||
class StremioCard extends StatelessWidget {
|
||||
class StremioCard extends StatefulWidget {
|
||||
final LibraryItem item;
|
||||
final String prefix;
|
||||
final String connectionId;
|
||||
|
|
@ -20,9 +20,14 @@ class StremioCard extends StatelessWidget {
|
|||
required this.service,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StremioCard> createState() => _StremioCardState();
|
||||
}
|
||||
|
||||
class _StremioCardState extends State<StremioCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final meta = item as Meta;
|
||||
final meta = widget.item as Meta;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
|
|
@ -36,14 +41,15 @@ class StremioCard extends StatelessWidget {
|
|||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () {
|
||||
context.push(
|
||||
"/info/stremio/$connectionId/${meta.type}/${meta.id}?hero=$prefix${meta.type}${item.id}",
|
||||
"/info/stremio/${widget.connectionId}/${meta.type}/${meta.id}?hero=${widget.prefix}${meta.type}${widget.item.id}",
|
||||
extra: {
|
||||
'meta': meta,
|
||||
'service': service,
|
||||
'service': widget.service,
|
||||
},
|
||||
);
|
||||
},
|
||||
child: (meta.nextSeason == null || meta.progress != null)
|
||||
child: ((meta.currentVideo == null || meta.progress != null) ||
|
||||
(meta.forceRegular == true))
|
||||
? _buildRegular(context, meta)
|
||||
: _buildWideCard(context, meta),
|
||||
),
|
||||
|
|
@ -51,195 +57,15 @@ class StremioCard extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
|
||||
bool get isInFuture {
|
||||
final video = (item as Meta).currentVideo;
|
||||
return video != null &&
|
||||
video.firstAired != null &&
|
||||
video.firstAired!.isAfter(DateTime.now());
|
||||
}
|
||||
|
||||
_buildWideCard(BuildContext context, Meta meta) {
|
||||
if (meta.background == null) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
final video = meta.currentVideo;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
"https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent(
|
||||
meta.currentVideo?.thumbnail ?? meta.background!,
|
||||
)}@webp",
|
||||
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (isInFuture)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.black,
|
||||
Colors.black54,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.black,
|
||||
Colors.transparent,
|
||||
],
|
||||
begin: Alignment.bottomLeft,
|
||||
end: Alignment.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"${meta.name}",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
"S${meta.nextSeason} E${meta.nextEpisode}",
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${meta.nextEpisodeTitle}".trim(),
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isInFuture)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Text(
|
||||
getRelativeDate(video!.firstAired!),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
if (isInFuture)
|
||||
const Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.calendar_month,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Positioned(
|
||||
child: Center(
|
||||
child: IconButton.filled(
|
||||
onPressed: null,
|
||||
icon: Icon(
|
||||
Icons.play_arrow,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
meta.imdbRating != ""
|
||||
? Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.star,
|
||||
color: Colors.amber,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
meta.imdbRating,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
);
|
||||
return WideCardStremio(meta: meta);
|
||||
}
|
||||
|
||||
String? getBackgroundImage(Meta meta) {
|
||||
String? backgroundImage;
|
||||
|
||||
if (meta.nextEpisode != null &&
|
||||
meta.nextSeason != null &&
|
||||
meta.videos != null) {
|
||||
for (final video in meta.videos!) {
|
||||
if (video.season == meta.nextSeason &&
|
||||
video.episode == meta.nextEpisode) {
|
||||
return video.thumbnail ?? meta.poster;
|
||||
}
|
||||
}
|
||||
if (meta.currentVideo != null) {
|
||||
return meta.currentVideo?.thumbnail ?? meta.poster;
|
||||
}
|
||||
|
||||
if (meta.poster != null) {
|
||||
|
|
@ -254,7 +80,7 @@ class StremioCard extends StatelessWidget {
|
|||
meta.poster ?? meta.logo ?? getBackgroundImage(meta);
|
||||
|
||||
return Hero(
|
||||
tag: "$prefix${meta.type}${item.id}",
|
||||
tag: "${widget.prefix}${meta.type}${widget.item.id}",
|
||||
child: (backgroundImage == null)
|
||||
? Center(
|
||||
child: Column(
|
||||
|
|
@ -326,7 +152,7 @@ class StremioCard extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
meta.imdbRating!,
|
||||
meta.imdbRating,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
|
|
@ -360,7 +186,7 @@ class StremioCard extends StatelessWidget {
|
|||
minHeight: 5,
|
||||
),
|
||||
),
|
||||
if (meta.nextEpisode != null && meta.nextSeason != null)
|
||||
if (meta.currentVideo != null)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
|
|
@ -393,7 +219,7 @@ class StremioCard extends StatelessWidget {
|
|||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
Text(
|
||||
"S${meta.nextSeason} E${meta.nextEpisode}",
|
||||
"S${meta.currentVideo?.season} E${meta.currentVideo?.episode}",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
|
|
@ -410,6 +236,210 @@ class StremioCard extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class WideCardStremio extends StatefulWidget {
|
||||
final Meta meta;
|
||||
final Video? video;
|
||||
|
||||
const WideCardStremio({
|
||||
super.key,
|
||||
required this.meta,
|
||||
this.video,
|
||||
});
|
||||
|
||||
@override
|
||||
State<WideCardStremio> createState() => _WideCardStremioState();
|
||||
}
|
||||
|
||||
class _WideCardStremioState extends State<WideCardStremio> {
|
||||
bool hasErrorWhileLoading = false;
|
||||
|
||||
bool get isInFuture {
|
||||
final video = widget.video ?? widget.meta.currentVideo;
|
||||
return video != null &&
|
||||
video.firstAired != null &&
|
||||
video.firstAired!.isAfter(DateTime.now());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.meta.background == null) {
|
||||
return Container();
|
||||
}
|
||||
|
||||
final video = widget.video ?? widget.meta.currentVideo;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
image: CachedNetworkImageProvider(
|
||||
"https://proxy-image.syncws.com/insecure/plain/${Uri.encodeQueryComponent(
|
||||
hasErrorWhileLoading
|
||||
? widget.meta.background!
|
||||
: (widget.meta.currentVideo?.thumbnail ??
|
||||
widget.meta.background!),
|
||||
)}@webp",
|
||||
errorListener: (error) {
|
||||
setState(() {
|
||||
hasErrorWhileLoading = true;
|
||||
});
|
||||
},
|
||||
imageRenderMethodForWeb: ImageRenderMethodForWeb.HttpGet,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (isInFuture)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.black,
|
||||
Colors.black54,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.black,
|
||||
Colors.transparent,
|
||||
],
|
||||
begin: Alignment.bottomLeft,
|
||||
end: Alignment.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"${widget.meta.name}",
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 4,
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
"S${video?.season} E${video?.episode}",
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${video?.name ?? video?.title}".trim(),
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isInFuture)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Text(
|
||||
getRelativeDate(video!.firstAired!),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
if (isInFuture)
|
||||
const Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.calendar_month,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Positioned(
|
||||
child: Center(
|
||||
child: IconButton.filled(
|
||||
onPressed: null,
|
||||
icon: Icon(
|
||||
Icons.play_arrow,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
widget.meta.imdbRating != "" && widget.video == null
|
||||
? Align(
|
||||
alignment: Alignment.topRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.star,
|
||||
color: Colors.amber,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.meta.imdbRating,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String getRelativeDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
|
|
|
|||
|
|
@ -279,7 +279,9 @@ class _StremioItemViewerState extends State<StremioItemViewer> {
|
|||
widget.original?.type == "series" &&
|
||||
widget.original?.videos?.isNotEmpty == true)
|
||||
StremioItemSeasonSelector(
|
||||
meta: (item as Meta),
|
||||
meta: (item as Meta).copyWith(
|
||||
selectedVideoIndex: widget.meta?.selectedVideoIndex,
|
||||
),
|
||||
service: widget.service,
|
||||
),
|
||||
SliverPadding(
|
||||
|
|
@ -294,7 +296,6 @@ class _StremioItemViewerState extends State<StremioItemViewer> {
|
|||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
// Description
|
||||
Text(
|
||||
'Description',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:madari_client/features/connection/types/stremio.dart';
|
|||
import 'package:madari_client/features/connections/service/base_connection_service.dart';
|
||||
import 'package:madari_client/features/connections/widget/base/render_stream_list.dart';
|
||||
import 'package:madari_client/features/trakt/service/trakt.service.dart';
|
||||
import 'package:madari_client/utils/common.dart';
|
||||
|
||||
import '../../../doc_viewer/types/doc_source.dart';
|
||||
import '../../../watch_history/service/base_watch_history.dart';
|
||||
|
|
@ -37,53 +38,64 @@ class _StremioItemSeasonSelectorState extends State<StremioItemSeasonSelector>
|
|||
late final Map<int, List<Video>> seasonMap;
|
||||
final zeeeWatchHistory = ZeeeWatchHistoryStatic.service;
|
||||
|
||||
late Meta meta = widget.meta;
|
||||
|
||||
final Map<String, double> _progress = {};
|
||||
final Map<int, Map<int, double>> _traktProgress = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
seasonMap = _organizeEpisodes();
|
||||
selectedSeason = widget.season;
|
||||
|
||||
if (seasonMap.keys.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final index = getSelectedSeason();
|
||||
|
||||
_tabController = TabController(
|
||||
length: seasonMap.keys.length,
|
||||
vsync: this,
|
||||
initialIndex: selectedSeason != null
|
||||
? selectedSeason! - 1
|
||||
: (seasonMap.keys.first == 0 ? 1 : 0),
|
||||
initialIndex: index.clamp(
|
||||
0,
|
||||
seasonMap.keys.isNotEmpty ? seasonMap.keys.length - 1 : 0,
|
||||
),
|
||||
);
|
||||
|
||||
_tabController?.addListener(() {
|
||||
// This is for rendering the component again for the selection of another tab
|
||||
_tabController!.addListener(() {
|
||||
setState(() {});
|
||||
});
|
||||
|
||||
getWatchHistory();
|
||||
}
|
||||
|
||||
int getSelectedSeason() {
|
||||
return widget.meta.currentVideo?.season ??
|
||||
widget.meta.videos?.lastWhereOrNull((item) {
|
||||
return item.progress != null;
|
||||
})?.season ??
|
||||
widget.season ??
|
||||
0;
|
||||
}
|
||||
|
||||
getWatchHistory() async {
|
||||
final traktService = TraktService.instance;
|
||||
|
||||
try {
|
||||
if (traktService!.isEnabled()) {
|
||||
final result = await traktService.getProgress(widget.meta);
|
||||
if (TraktService.isEnabled()) {
|
||||
final result = await traktService!.getProgress(
|
||||
widget.meta,
|
||||
bypassCache: false,
|
||||
);
|
||||
|
||||
for (final item in result) {
|
||||
if (!_traktProgress.containsKey(item.season)) {
|
||||
_traktProgress.addAll(<int, Map<int, double>>{
|
||||
item.season!: {},
|
||||
});
|
||||
}
|
||||
_traktProgress[item.season!] = _traktProgress[item.season] ?? {};
|
||||
_traktProgress[item.season]![item.episode!] = item.progress;
|
||||
}
|
||||
setState(() {
|
||||
meta = result;
|
||||
});
|
||||
|
||||
setState(() {});
|
||||
final index = getSelectedSeason();
|
||||
_tabController?.animateTo(index);
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -95,7 +107,11 @@ class _StremioItemSeasonSelectorState extends State<StremioItemSeasonSelector>
|
|||
|
||||
final docs = await zeeeWatchHistory!.getItemWatchHistory(
|
||||
ids: widget.meta.videos!.map((item) {
|
||||
return WatchHistoryGetRequest(id: item.id);
|
||||
return WatchHistoryGetRequest(
|
||||
id: item.id,
|
||||
episode: item.episode.toString(),
|
||||
season: item.season.toString(),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
|
||||
|
|
@ -103,7 +119,9 @@ class _StremioItemSeasonSelectorState extends State<StremioItemSeasonSelector>
|
|||
_progress[item.id] = item.progress.toDouble();
|
||||
}
|
||||
|
||||
setState(() {});
|
||||
final index = getSelectedSeason();
|
||||
|
||||
_tabController?.animateTo(index);
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -113,13 +131,12 @@ class _StremioItemSeasonSelectorState extends State<StremioItemSeasonSelector>
|
|||
}
|
||||
|
||||
Map<int, List<Video>> _organizeEpisodes() {
|
||||
final episodes = widget.meta.videos ?? [];
|
||||
final episodes = meta.videos ?? [];
|
||||
return groupBy(episodes, (Video video) => video.season);
|
||||
}
|
||||
|
||||
void openEpisode({
|
||||
required int currentSeason,
|
||||
required Video episode,
|
||||
required int index,
|
||||
}) async {
|
||||
if (widget.service == null) {
|
||||
return;
|
||||
|
|
@ -127,18 +144,19 @@ class _StremioItemSeasonSelectorState extends State<StremioItemSeasonSelector>
|
|||
final onClose = showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final meta = widget.meta.copyWith(
|
||||
id: episode.id,
|
||||
);
|
||||
final meta = this.meta.copyWith(
|
||||
selectedVideoIndex: index,
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text("Streams for S$currentSeason E${episode.episode}"),
|
||||
title: Text(
|
||||
"Streams for S${meta.currentVideo?.season} E${meta.currentVideo?.episode}",
|
||||
),
|
||||
),
|
||||
body: RenderStreamList(
|
||||
service: widget.service!,
|
||||
id: meta,
|
||||
season: currentSeason.toString(),
|
||||
shouldPop: widget.shouldPop,
|
||||
),
|
||||
);
|
||||
|
|
@ -148,7 +166,7 @@ class _StremioItemSeasonSelectorState extends State<StremioItemSeasonSelector>
|
|||
if (widget.shouldPop) {
|
||||
final val = await onClose;
|
||||
|
||||
if (val is MediaURLSource && context.mounted) {
|
||||
if (val is MediaURLSource && context.mounted && mounted) {
|
||||
Navigator.pop(
|
||||
context,
|
||||
val,
|
||||
|
|
@ -213,11 +231,7 @@ class _StremioItemSeasonSelectorState extends State<StremioItemSeasonSelector>
|
|||
widget.meta.videos!.length,
|
||||
);
|
||||
|
||||
openEpisode(
|
||||
currentSeason:
|
||||
widget.meta.videos![randomIndex].season,
|
||||
episode: widget.meta.videos![randomIndex],
|
||||
);
|
||||
openEpisode(index: randomIndex);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
|
@ -259,19 +273,24 @@ class _StremioItemSeasonSelectorState extends State<StremioItemSeasonSelector>
|
|||
final episodes = seasonMap[currentSeason]!;
|
||||
final episode = episodes[index];
|
||||
|
||||
final progress = _traktProgress[episode.season]
|
||||
?[episode.episode] ==
|
||||
null
|
||||
? (_progress[episode.id] ?? 0) / 100
|
||||
: (_traktProgress[episode.season]![episode.episode]! / 100);
|
||||
final videoIndex = meta.videos?.indexOf(episode);
|
||||
|
||||
final progress = ((!TraktService.isEnabled()
|
||||
? (_progress[episode.id] ?? 0) / 100
|
||||
: videoIndex != -1
|
||||
? (meta.videos![videoIndex!].progress)
|
||||
: 0.toDouble()) ??
|
||||
0) /
|
||||
100;
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () async {
|
||||
openEpisode(
|
||||
currentSeason: currentSeason,
|
||||
episode: episode,
|
||||
);
|
||||
if (videoIndex != null) {
|
||||
openEpisode(
|
||||
index: videoIndex,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
|
|||
|
|
@ -1,25 +1,20 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:madari_client/features/connections/service/base_connection_service.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/video_viewer/tv_controls.dart';
|
||||
import 'package:madari_client/features/watch_history/service/base_watch_history.dart';
|
||||
import 'package:madari_client/utils/tv_detector.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
import '../../../utils/load_language.dart';
|
||||
import '../../connections/types/stremio/stremio_base.types.dart' as types;
|
||||
import '../../connections/widget/stremio/stremio_season_selector.dart';
|
||||
import '../../trakt/service/trakt.service.dart';
|
||||
import '../../watch_history/service/zeee_watch_history.dart';
|
||||
import '../types/doc_source.dart';
|
||||
import 'video_viewer/desktop_video_player.dart';
|
||||
import 'video_viewer/mobile_video_player.dart';
|
||||
import 'video_viewer/video_viewer_ui.dart';
|
||||
|
||||
class VideoViewer extends StatefulWidget {
|
||||
final DocSource source;
|
||||
|
|
@ -42,7 +37,8 @@ class VideoViewer extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _VideoViewerState extends State<VideoViewer> {
|
||||
StreamSubscription? _subTracks;
|
||||
late LibraryItem? meta = widget.meta;
|
||||
|
||||
final zeeeWatchHistory = ZeeeWatchHistoryStatic.service;
|
||||
Timer? _timer;
|
||||
late final Player player = Player(
|
||||
|
|
@ -50,7 +46,7 @@ class _VideoViewerState extends State<VideoViewer> {
|
|||
title: "Madari",
|
||||
),
|
||||
);
|
||||
late final GlobalKey<VideoState> key = GlobalKey<VideoState>();
|
||||
final Logger _logger = Logger('VideoPlayer');
|
||||
|
||||
double get currentProgressInPercentage {
|
||||
final duration = player.state.duration.inSeconds;
|
||||
|
|
@ -58,162 +54,220 @@ class _VideoViewerState extends State<VideoViewer> {
|
|||
return duration > 0 ? (position / duration * 100) : 0;
|
||||
}
|
||||
|
||||
Future<List<TraktProgress>>? traktProgress;
|
||||
bool timeLoaded = false;
|
||||
|
||||
Future<types.Meta>? traktProgress;
|
||||
|
||||
Future<void> saveWatchHistory() async {
|
||||
_logger.info('Starting to save watch history...');
|
||||
|
||||
saveWatchHistory() {
|
||||
final duration = player.state.duration.inSeconds;
|
||||
|
||||
if (duration < 30) {
|
||||
if (duration <= 30) {
|
||||
_logger.info('Video is too short to track.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (gotFromTraktDuration == false) {
|
||||
_logger.info(
|
||||
"Did not start the scrobbling because initially time is not retrieved from the API.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final position = player.state.position.inSeconds;
|
||||
final progress = duration > 0 ? (position / duration * 100).round() : 0;
|
||||
final progress = duration > 0 ? (position / duration * 100) : 0;
|
||||
|
||||
if (progress == 0) {
|
||||
if (progress < 0.01) {
|
||||
_logger.info('No progress to save.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (widget.meta is types.Meta) {
|
||||
if (meta is types.Meta && TraktService.instance != null) {
|
||||
try {
|
||||
if (player.state.playing) {
|
||||
TraktService.instance!.startScrobbling(
|
||||
meta: widget.meta as types.Meta,
|
||||
_logger.info('Starting scrobbling...');
|
||||
await TraktService.instance!.startScrobbling(
|
||||
meta: meta as types.Meta,
|
||||
progress: currentProgressInPercentage,
|
||||
);
|
||||
} else {
|
||||
TraktService.instance!.stopScrobbling(
|
||||
meta: widget.meta as types.Meta,
|
||||
_logger.info('Stopping scrobbling...');
|
||||
await TraktService.instance!.stopScrobbling(
|
||||
meta: meta as types.Meta,
|
||||
progress: currentProgressInPercentage,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print(e);
|
||||
_logger.severe('Error during scrobbling: $e');
|
||||
TraktService.instance!.debugLogs.add(e.toString());
|
||||
}
|
||||
} else {
|
||||
_logger.warning('Meta is not valid or TraktService is not initialized.');
|
||||
}
|
||||
|
||||
zeeeWatchHistory!.saveWatchHistory(
|
||||
await zeeeWatchHistory!.saveWatchHistory(
|
||||
history: WatchHistory(
|
||||
id: _source.id,
|
||||
progress: progress,
|
||||
progress: progress.round(),
|
||||
duration: duration.toDouble(),
|
||||
episode: _source.episode,
|
||||
season: _source.season,
|
||||
),
|
||||
);
|
||||
|
||||
_logger.info('Watch history saved successfully.');
|
||||
}
|
||||
|
||||
late final controller = VideoController(
|
||||
player,
|
||||
configuration: const VideoControllerConfiguration(
|
||||
enableHardwareAcceleration: true,
|
||||
configuration: VideoControllerConfiguration(
|
||||
enableHardwareAcceleration: !config.softwareAcceleration,
|
||||
),
|
||||
);
|
||||
|
||||
List<SubtitleTrack> subtitles = [];
|
||||
List<AudioTrack> audioTracks = [];
|
||||
Map<String, String> languages = {};
|
||||
|
||||
late DocSource _source;
|
||||
|
||||
void setDefaultAudioTracks(Tracks tracks) {
|
||||
if (defaultConfigSelected == true &&
|
||||
(tracks.audio.length <= 1 || tracks.audio.length <= 1)) {
|
||||
return;
|
||||
}
|
||||
bool gotFromTraktDuration = false;
|
||||
|
||||
defaultConfigSelected = true;
|
||||
int? traktId;
|
||||
|
||||
controller.player.setRate(config.playbackSpeed);
|
||||
Future<void> setDurationFromTrakt({
|
||||
Future<types.Meta>? traktProgress,
|
||||
}) async {
|
||||
_logger.info('Setting duration from Trakt...');
|
||||
|
||||
final defaultSubtitle = config.defaultSubtitleTrack;
|
||||
final defaultAudio = config.defaultAudioTrack;
|
||||
|
||||
for (final item in tracks.audio) {
|
||||
if (defaultAudio == item.id ||
|
||||
defaultAudio == item.language ||
|
||||
defaultAudio == item.title) {
|
||||
controller.player.setAudioTrack(item);
|
||||
break;
|
||||
try {
|
||||
if (player.state.duration.inSeconds < 2) {
|
||||
_logger.info('Duration is too short to set from Trakt.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (config.disableSubtitle) {
|
||||
for (final item in tracks.subtitle) {
|
||||
if (item.id == "no" || item.language == "no" || item.title == "no") {
|
||||
controller.player.setSubtitleTrack(item);
|
||||
}
|
||||
if (gotFromTraktDuration) {
|
||||
_logger.info('Duration already set from Trakt.');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
for (final item in tracks.subtitle) {
|
||||
if (defaultSubtitle == item.id ||
|
||||
defaultSubtitle == item.language ||
|
||||
defaultSubtitle == item.title) {
|
||||
controller.player.setSubtitleTrack(item);
|
||||
break;
|
||||
}
|
||||
|
||||
gotFromTraktDuration = true;
|
||||
|
||||
if (!TraktService.isEnabled() ||
|
||||
(traktProgress ?? this.traktProgress) == null) {
|
||||
_logger.info(
|
||||
'Trakt service is not enabled or progress is null. Playing video.');
|
||||
player.play();
|
||||
return;
|
||||
}
|
||||
|
||||
final progress = await (traktProgress ?? this.traktProgress);
|
||||
|
||||
if (this.meta is! types.Meta) {
|
||||
_logger.info('Meta is not of type types.Meta.');
|
||||
return;
|
||||
}
|
||||
|
||||
final meta = (progress ?? this.meta) as types.Meta;
|
||||
|
||||
final duration = Duration(
|
||||
seconds: calculateSecondsFromProgress(
|
||||
player.state.duration.inSeconds.toDouble(),
|
||||
meta.currentVideo?.progress ?? meta.progress ?? 0,
|
||||
),
|
||||
);
|
||||
|
||||
if (duration.inSeconds > 10) {
|
||||
_logger.info('Seeking to duration: $duration');
|
||||
await player.seek(duration);
|
||||
}
|
||||
|
||||
await player.play();
|
||||
_logger.info('Video started playing.');
|
||||
} catch (e) {
|
||||
_logger.severe('Error setting duration from Trakt: $e');
|
||||
await player.play();
|
||||
}
|
||||
}
|
||||
|
||||
void onPlaybackReady(Tracks tracks) {
|
||||
setState(() {
|
||||
audioTracks = tracks.audio.where((item) {
|
||||
return item.id != "auto" && item.id != "no";
|
||||
}).toList();
|
||||
|
||||
subtitles = tracks.subtitle.where((item) {
|
||||
return item.id != "auto";
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
bool canCallOnce = false;
|
||||
|
||||
setDurationFromTrakt() async {
|
||||
if (player.state.duration.inSeconds < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canCallOnce) {
|
||||
return;
|
||||
}
|
||||
|
||||
canCallOnce = true;
|
||||
|
||||
if (!TraktService.instance!.isEnabled() || traktProgress == null) {
|
||||
player.play();
|
||||
return;
|
||||
}
|
||||
|
||||
final progress = await traktProgress;
|
||||
|
||||
if ((progress ?? []).isEmpty) {
|
||||
player.play();
|
||||
return;
|
||||
}
|
||||
|
||||
final duration = Duration(
|
||||
seconds: calculateSecondsFromProgress(
|
||||
player.state.duration.inSeconds.toDouble(),
|
||||
progress!.first.progress,
|
||||
),
|
||||
);
|
||||
|
||||
player.seek(duration);
|
||||
player.play();
|
||||
}
|
||||
|
||||
List<StreamSubscription> listener = [];
|
||||
|
||||
PlaybackConfig config = getPlaybackConfig();
|
||||
|
||||
bool defaultConfigSelected = false;
|
||||
Future setupVideoThings() async {
|
||||
_logger.info('Setting up video things...');
|
||||
|
||||
traktProgress = null;
|
||||
traktProgress = TraktService.instance!.getProgress(
|
||||
meta as types.Meta,
|
||||
bypassCache: true,
|
||||
);
|
||||
|
||||
_duration = player.stream.duration.listen((item) async {
|
||||
if (meta is types.Meta) {
|
||||
setDurationFromTrakt(traktProgress: traktProgress);
|
||||
}
|
||||
|
||||
if (item.inSeconds != 0) {
|
||||
_logger.info('Duration updated: $item');
|
||||
await saveWatchHistory();
|
||||
}
|
||||
});
|
||||
|
||||
_timer = Timer.periodic(const Duration(seconds: 30), (timer) {
|
||||
_logger.info('Periodic save watch history triggered.');
|
||||
saveWatchHistory();
|
||||
});
|
||||
|
||||
_streamListen = player.stream.playing.listen((playing) {
|
||||
_logger.info('Playing state changed: $playing');
|
||||
saveWatchHistory();
|
||||
});
|
||||
|
||||
_logger.info('Loading file...');
|
||||
|
||||
return loadFile();
|
||||
}
|
||||
|
||||
destroyVideoThing() async {
|
||||
_logger.info('Destroying video things...');
|
||||
|
||||
timeLoaded = false;
|
||||
gotFromTraktDuration = false;
|
||||
traktProgress = null;
|
||||
|
||||
for (final item in listener) {
|
||||
item.cancel();
|
||||
}
|
||||
listener = [];
|
||||
_timer?.cancel();
|
||||
_streamListen?.cancel();
|
||||
_duration?.cancel();
|
||||
|
||||
if (meta is types.Meta && player.state.duration.inSeconds > 30) {
|
||||
_logger.info('Stopping scrobbling and clearing cache...');
|
||||
await TraktService.instance!.stopScrobbling(
|
||||
meta: meta as types.Meta,
|
||||
progress: currentProgressInPercentage,
|
||||
shouldClearCache: true,
|
||||
traktId: traktId,
|
||||
);
|
||||
}
|
||||
|
||||
_logger.info('Video things destroyed.');
|
||||
}
|
||||
|
||||
GlobalKey videoKey = GlobalKey();
|
||||
|
||||
generateNewKey() {
|
||||
_logger.info('Generating new key...');
|
||||
videoKey = GlobalKey();
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_logger.info('Initializing VideoViewer...');
|
||||
|
||||
_source = widget.source;
|
||||
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
|
|
@ -221,89 +275,55 @@ class _VideoViewerState extends State<VideoViewer> {
|
|||
overlays: [],
|
||||
);
|
||||
|
||||
if (!kIsWeb) {
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
key.currentState?.enterFullscreen();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_duration = player.stream.duration.listen((item) {
|
||||
if (item.inSeconds != 0) {
|
||||
setDurationFromTrakt();
|
||||
}
|
||||
});
|
||||
|
||||
_streamComplete = player.stream.completed.listen((completed) {
|
||||
if (completed) {
|
||||
onLibrarySelect();
|
||||
}
|
||||
});
|
||||
|
||||
_subTracks = player.stream.tracks.listen((tracks) {
|
||||
if (mounted) {
|
||||
setDefaultAudioTracks(tracks);
|
||||
onPlaybackReady(tracks);
|
||||
}
|
||||
});
|
||||
|
||||
loadLanguages(context).then((language) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
languages = language;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
loadFile();
|
||||
|
||||
if (player.platform is NativePlayer && !kIsWeb) {
|
||||
Future.microtask(() async {
|
||||
_logger.info('Setting network timeout...');
|
||||
await (player.platform as dynamic).setProperty('network-timeout', '60');
|
||||
});
|
||||
}
|
||||
|
||||
_timer = Timer.periodic(const Duration(seconds: 30), (timer) {
|
||||
saveWatchHistory();
|
||||
});
|
||||
onVideoChange(
|
||||
_source,
|
||||
widget.meta!,
|
||||
);
|
||||
|
||||
_streamListen = player.stream.playing.listen((playing) {
|
||||
if (playing) {
|
||||
saveWatchHistory();
|
||||
}
|
||||
});
|
||||
|
||||
if (widget.meta is types.Meta) {
|
||||
traktProgress = TraktService.instance!.getProgress(
|
||||
widget.meta as types.Meta,
|
||||
);
|
||||
}
|
||||
_logger.info('VideoViewer initialized.');
|
||||
}
|
||||
|
||||
loadFile() async {
|
||||
final item = await zeeeWatchHistory!.getItemWatchHistory(
|
||||
ids: [
|
||||
WatchHistoryGetRequest(
|
||||
id: _source.id,
|
||||
season: _source.season,
|
||||
episode: _source.episode,
|
||||
),
|
||||
],
|
||||
);
|
||||
Future<void> loadFile() async {
|
||||
_logger.info('Loading file...');
|
||||
|
||||
final duration = Duration(
|
||||
seconds: item.isEmpty
|
||||
? 0
|
||||
: calculateSecondsFromProgress(
|
||||
item.first.duration,
|
||||
item.first.progress.toDouble(),
|
||||
),
|
||||
);
|
||||
Duration duration = const Duration(seconds: 0);
|
||||
|
||||
if (meta is types.Meta && TraktService.isEnabled()) {
|
||||
_logger.info("Playing video ${(meta as types.Meta).selectedVideoIndex}");
|
||||
} else {
|
||||
final item = await zeeeWatchHistory!.getItemWatchHistory(
|
||||
ids: [
|
||||
WatchHistoryGetRequest(
|
||||
id: _source.id,
|
||||
season: _source.season,
|
||||
episode: _source.episode,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
duration = Duration(
|
||||
seconds: item.isEmpty
|
||||
? 0
|
||||
: calculateSecondsFromProgress(
|
||||
item.first.duration,
|
||||
item.first.progress.toDouble(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_logger.info('Loading file for source: ${_source.id}');
|
||||
|
||||
switch (_source.runtimeType) {
|
||||
case const (FileSource):
|
||||
if (kIsWeb) {
|
||||
_logger.info('FileSource is not supported on web.');
|
||||
return;
|
||||
}
|
||||
player.open(
|
||||
|
|
@ -325,289 +345,66 @@ class _VideoViewerState extends State<VideoViewer> {
|
|||
play: false,
|
||||
);
|
||||
}
|
||||
|
||||
_logger.info('File loaded successfully.');
|
||||
}
|
||||
|
||||
bool isScaled = false;
|
||||
|
||||
late StreamSubscription<bool> _streamComplete;
|
||||
late StreamSubscription<bool> _streamListen;
|
||||
late StreamSubscription<dynamic> _duration;
|
||||
|
||||
onLibrarySelect() async {
|
||||
controller.player.pause();
|
||||
|
||||
final result = await showCupertinoDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Seasons"),
|
||||
),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
StremioItemSeasonSelector(
|
||||
service: widget.service,
|
||||
meta: widget.meta as types.Meta,
|
||||
shouldPop: true,
|
||||
season: int.tryParse(widget.currentSeason!),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (result is MediaURLSource) {
|
||||
_source = result;
|
||||
|
||||
loadFile();
|
||||
}
|
||||
}
|
||||
StreamSubscription<bool>? _streamListen;
|
||||
StreamSubscription<dynamic>? _duration;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_logger.info('Disposing VideoViewer...');
|
||||
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
for (final item in listener) {
|
||||
item.cancel();
|
||||
}
|
||||
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.edgeToEdge,
|
||||
overlays: [],
|
||||
);
|
||||
_timer?.cancel();
|
||||
_subTracks?.cancel();
|
||||
_streamComplete.cancel();
|
||||
_streamListen.cancel();
|
||||
_duration.cancel();
|
||||
|
||||
if (widget.meta is types.Meta && player.state.duration.inSeconds > 30) {
|
||||
TraktService.instance!.stopScrobbling(
|
||||
meta: widget.meta as types.Meta,
|
||||
progress: currentProgressInPercentage,
|
||||
);
|
||||
}
|
||||
|
||||
destroyVideoThing();
|
||||
player.dispose();
|
||||
|
||||
super.dispose();
|
||||
|
||||
_logger.info('VideoViewer disposed.');
|
||||
}
|
||||
|
||||
onVideoChange(DocSource source, LibraryItem item) async {
|
||||
setState(() {});
|
||||
await destroyVideoThing();
|
||||
|
||||
_logger.info('Changing video source...');
|
||||
|
||||
_source = source;
|
||||
meta = item;
|
||||
setState(() {});
|
||||
await setupVideoThings();
|
||||
setState(() {});
|
||||
generateNewKey();
|
||||
|
||||
_logger.info('Video source changed successfully.');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: _buildBody(context),
|
||||
);
|
||||
}
|
||||
|
||||
_buildMobileView(BuildContext context) {
|
||||
final mobile = getMobileVideoPlayer(
|
||||
context,
|
||||
onLibrarySelect: onLibrarySelect,
|
||||
hasLibrary: widget.service != null &&
|
||||
widget.library != null &&
|
||||
widget.meta != null,
|
||||
audioTracks: audioTracks,
|
||||
player: player,
|
||||
source: _source,
|
||||
subtitles: subtitles,
|
||||
onSubtitleClick: onSubtitleSelect,
|
||||
onAudioClick: onAudioSelect,
|
||||
toggleScale: () {
|
||||
setState(() {
|
||||
isScaled = !isScaled;
|
||||
});
|
||||
},
|
||||
);
|
||||
String subtitleStyleName = config.subtitleStyle ?? 'Normal';
|
||||
String subtitleStyleColor = config.subtitleColor ?? 'white';
|
||||
double subtitleSize = config.subtitleSize ;
|
||||
Color hexToColor(String hexColor) {
|
||||
final hexCode = hexColor.replaceAll('#', '');
|
||||
return Color(int.parse('0x$hexCode'));
|
||||
}
|
||||
FontStyle getFontStyleFromString(String styleName) {
|
||||
switch (styleName.toLowerCase()) {
|
||||
case 'italic':
|
||||
return FontStyle.italic;
|
||||
case 'normal': // Explicitly handle 'normal' (good practice)
|
||||
default: // Default case for any other string or null
|
||||
return FontStyle.normal;
|
||||
}
|
||||
}
|
||||
FontStyle currentFontStyle = getFontStyleFromString(subtitleStyleName);
|
||||
return MaterialVideoControlsTheme(
|
||||
fullscreen: mobile,
|
||||
normal: mobile,
|
||||
child: Video(
|
||||
subtitleViewConfiguration: SubtitleViewConfiguration(
|
||||
style: TextStyle(color: hexToColor(subtitleStyleColor),
|
||||
fontSize: subtitleSize,
|
||||
fontStyle: currentFontStyle,
|
||||
fontWeight: FontWeight.bold),
|
||||
),
|
||||
fit: isScaled ? BoxFit.fitWidth : BoxFit.fitHeight,
|
||||
pauseUponEnteringBackgroundMode: true,
|
||||
key: key,
|
||||
onExitFullscreen: () async {
|
||||
await defaultExitNativeFullscreen();
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
},
|
||||
body: VideoViewerUi(
|
||||
key: videoKey,
|
||||
controller: controller,
|
||||
controls: MaterialVideoControls,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_buildDesktop(BuildContext context) {
|
||||
final desktop = getDesktopControls(
|
||||
context,
|
||||
audioTracks: audioTracks,
|
||||
player: player,
|
||||
source: _source,
|
||||
subtitles: subtitles,
|
||||
onAudioSelect: onAudioSelect,
|
||||
onSubtitleSelect: onSubtitleSelect,
|
||||
);
|
||||
|
||||
return MaterialDesktopVideoControlsTheme(
|
||||
normal: desktop,
|
||||
fullscreen: desktop,
|
||||
child: Video(
|
||||
key: key,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
fit: BoxFit.fitWidth,
|
||||
controller: controller,
|
||||
controls: MaterialDesktopVideoControls,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_buildBody(BuildContext context) {
|
||||
if (DeviceDetector.isTV()) {
|
||||
return MaterialTvVideoControlsTheme(
|
||||
fullscreen: const MaterialTvVideoControlsThemeData(),
|
||||
normal: const MaterialTvVideoControlsThemeData(),
|
||||
child: Video(
|
||||
key: key,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
fit: BoxFit.fitWidth,
|
||||
controller: controller,
|
||||
controls: MaterialTvVideoControls,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
switch (Theme.of(context).platform) {
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.iOS:
|
||||
return _buildMobileView(context);
|
||||
default:
|
||||
return _buildDesktop(context);
|
||||
}
|
||||
}
|
||||
|
||||
onSubtitleSelect() {
|
||||
showCupertinoModalPopup(
|
||||
context: context,
|
||||
builder: (ctx) => Card(
|
||||
child: Container(
|
||||
height: MediaQuery.of(context).size.height * 0.4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).dialogBackgroundColor,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'Select Subtitle',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: subtitles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final currentItem = subtitles[index];
|
||||
|
||||
final title = currentItem.language ??
|
||||
currentItem.title ??
|
||||
currentItem.id;
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
languages.containsKey(title)
|
||||
? languages[title]!
|
||||
: title,
|
||||
),
|
||||
selected:
|
||||
player.state.track.subtitle.id == currentItem.id,
|
||||
onTap: () {
|
||||
player.setSubtitleTrack(currentItem);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
onAudioSelect() {
|
||||
showCupertinoModalPopup(
|
||||
context: context,
|
||||
builder: (ctx) => Card(
|
||||
child: Container(
|
||||
height: MediaQuery.of(context).size.height * 0.4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).dialogBackgroundColor,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'Select Audio Track',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: audioTracks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final currentItem = audioTracks[index];
|
||||
final title = currentItem.language ??
|
||||
currentItem.title ??
|
||||
currentItem.id;
|
||||
return ListTile(
|
||||
title: Text(
|
||||
languages.containsKey(title)
|
||||
? languages[title]!
|
||||
: title,
|
||||
),
|
||||
selected: player.state.track.audio.id == currentItem.id,
|
||||
onTap: () {
|
||||
player.setAudioTrack(currentItem);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
player: player,
|
||||
config: config,
|
||||
source: _source,
|
||||
onLibrarySelect: () {},
|
||||
service: widget.service,
|
||||
meta: meta,
|
||||
onSourceChange: (source, meta) => onVideoChange(source, meta),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -620,4 +417,4 @@ int calculateSecondsFromProgress(
|
|||
final clampedProgress = progressPercentage.clamp(0.0, 100.0);
|
||||
final currentSeconds = (duration * (clampedProgress / 100)).round();
|
||||
return currentSeconds;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/utils/load_language.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
|
||||
class AudioTrackSelector extends StatefulWidget {
|
||||
final Player player;
|
||||
final PlaybackConfig config;
|
||||
|
||||
const AudioTrackSelector({
|
||||
super.key,
|
||||
required this.player,
|
||||
required this.config,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AudioTrackSelector> createState() => _AudioTrackSelectorState();
|
||||
}
|
||||
|
||||
class _AudioTrackSelectorState extends State<AudioTrackSelector> {
|
||||
List<AudioTrack> audioTracks = [];
|
||||
Map<String, String> languages = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
audioTracks = widget.player.state.tracks.audio.where((item) {
|
||||
return item.id != "auto" && item.id != "no";
|
||||
}).toList();
|
||||
|
||||
loadLanguages(context).then((language) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
languages = language;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: Container(
|
||||
height: MediaQuery.of(context).size.height * 0.4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).dialogBackgroundColor,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'Select Audio Track',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: audioTracks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final currentItem = audioTracks[index];
|
||||
final title = currentItem.language ??
|
||||
currentItem.title ??
|
||||
currentItem.id;
|
||||
return ListTile(
|
||||
title: Text(
|
||||
languages.containsKey(title) ? languages[title]! : title,
|
||||
),
|
||||
selected:
|
||||
widget.player.state.track.audio.id == currentItem.id,
|
||||
onTap: () {
|
||||
widget.player.setAudioTrack(currentItem);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,24 +3,28 @@ import 'dart:io';
|
|||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/features/connections/service/base_connection_service.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/video_viewer/season_source.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/video_viewer/torrent_stat.dart';
|
||||
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import '../../../connections/types/stremio/stremio_base.types.dart';
|
||||
|
||||
MaterialDesktopVideoControlsThemeData getDesktopControls(
|
||||
BuildContext context, {
|
||||
required DocSource source,
|
||||
required List<SubtitleTrack> subtitles,
|
||||
required List<AudioTrack> audioTracks,
|
||||
required Player player,
|
||||
Widget? library,
|
||||
required Function() onSubtitleSelect,
|
||||
required Function() onAudioSelect,
|
||||
LibraryItem? meta,
|
||||
required Function(int index) onVideoChange,
|
||||
}) {
|
||||
return MaterialDesktopVideoControlsThemeData(
|
||||
toggleFullscreenOnDoublePress: false,
|
||||
toggleFullscreenOnDoublePress: true,
|
||||
displaySeekBar: true,
|
||||
topButtonBar: [
|
||||
SafeArea(
|
||||
|
|
@ -36,14 +40,25 @@ MaterialDesktopVideoControlsThemeData getDesktopControls(
|
|||
child: SizedBox(
|
||||
width: MediaQuery.of(context).size.width - 120,
|
||||
child: Text(
|
||||
source.title.endsWith(".mp4")
|
||||
? source.title.substring(0, source.title.length - 4)
|
||||
: source.title,
|
||||
(meta is Meta && meta.currentVideo != null)
|
||||
? "${meta.name ?? ""} S${meta.currentVideo?.season} E${meta.currentVideo?.episode}"
|
||||
: source.title.endsWith(".mp4")
|
||||
? source.title.substring(0, source.title.length - 4)
|
||||
: source.title,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (meta is Meta)
|
||||
if (meta.type == "series")
|
||||
SeasonSource(
|
||||
meta: meta,
|
||||
isMobile: false,
|
||||
player: player,
|
||||
onVideoChange: onVideoChange,
|
||||
),
|
||||
],
|
||||
bufferingIndicatorBuilder: source is TorrentSource
|
||||
? (ctx) {
|
||||
|
|
|
|||
|
|
@ -1,164 +0,0 @@
|
|||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/video_viewer/torrent_stat.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
import '../../types/doc_source.dart';
|
||||
|
||||
MaterialVideoControlsThemeData getMobileVideoPlayer(
|
||||
BuildContext context, {
|
||||
required DocSource source,
|
||||
required List<SubtitleTrack> subtitles,
|
||||
required List<AudioTrack> audioTracks,
|
||||
required Player player,
|
||||
required VoidCallback onSubtitleClick,
|
||||
required VoidCallback onAudioClick,
|
||||
required VoidCallback toggleScale,
|
||||
required bool hasLibrary,
|
||||
required VoidCallback onLibrarySelect,
|
||||
}) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
return MaterialVideoControlsThemeData(
|
||||
topButtonBar: [
|
||||
MaterialCustomButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.arrow_back,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
source.title.endsWith(".mp4")
|
||||
? source.title.substring(0, source.title.length - 4)
|
||||
: source.title,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
if (hasLibrary)
|
||||
MaterialCustomButton(
|
||||
icon: const Icon(Icons.library_books),
|
||||
onPressed: () {
|
||||
onLibrarySelect();
|
||||
},
|
||||
),
|
||||
],
|
||||
bufferingIndicatorBuilder: (source is TorrentSource)
|
||||
? (ctx) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
child: TorrentStats(
|
||||
torrentHash: (source).infoHash,
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
brightnessGesture: true,
|
||||
seekGesture: true,
|
||||
seekOnDoubleTap: true,
|
||||
gesturesEnabledWhileControlsVisible: true,
|
||||
shiftSubtitlesOnControlsVisibilityChange: true,
|
||||
seekBarMargin: const EdgeInsets.only(bottom: 54),
|
||||
speedUpOnLongPress: true,
|
||||
speedUpFactor: 2,
|
||||
volumeGesture: true,
|
||||
bottomButtonBar: [
|
||||
const MaterialPlayOrPauseButton(),
|
||||
const MaterialPositionIndicator(),
|
||||
const Spacer(),
|
||||
MaterialCustomButton(
|
||||
onPressed: () {
|
||||
final speeds = [
|
||||
0.5,
|
||||
0.75,
|
||||
1.0,
|
||||
1.25,
|
||||
1.5,
|
||||
1.75,
|
||||
2.0,
|
||||
2.25,
|
||||
2.5,
|
||||
3.0,
|
||||
3.25,
|
||||
3.5,
|
||||
3.75,
|
||||
4.0,
|
||||
4.25,
|
||||
4.5,
|
||||
4.75,
|
||||
5.0
|
||||
];
|
||||
showCupertinoModalPopup(
|
||||
context: context,
|
||||
builder: (ctx) => Card(
|
||||
child: Container(
|
||||
height: MediaQuery.of(context).size.height * 0.4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).dialogBackgroundColor,
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'Select Playback Speed',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: speeds.length,
|
||||
itemBuilder: (context, index) {
|
||||
final speed = speeds[index];
|
||||
return ListTile(
|
||||
title: Text('${speed}x'),
|
||||
selected: player.state.rate == speed,
|
||||
onTap: () {
|
||||
player.setRate(speed);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.speed),
|
||||
),
|
||||
MaterialCustomButton(
|
||||
onPressed: () {
|
||||
onSubtitleClick();
|
||||
},
|
||||
icon: const Icon(Icons.subtitles),
|
||||
),
|
||||
MaterialCustomButton(
|
||||
onPressed: () {
|
||||
onAudioClick();
|
||||
},
|
||||
icon: const Icon(Icons.audio_file),
|
||||
),
|
||||
MaterialCustomButton(
|
||||
onPressed: () {
|
||||
toggleScale();
|
||||
},
|
||||
icon: const Icon(Icons.fit_screen_outlined),
|
||||
),
|
||||
],
|
||||
topButtonBarMargin: EdgeInsets.only(
|
||||
top: mediaQuery.padding.top,
|
||||
),
|
||||
bottomButtonBarMargin: EdgeInsets.only(
|
||||
bottom: mediaQuery.viewInsets.bottom,
|
||||
left: 4.0,
|
||||
right: 4.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class SeasonSelector extends StatelessWidget {
|
||||
const SeasonSelector({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
import '../../../connections/types/stremio/stremio_base.types.dart';
|
||||
|
||||
class SeasonSource extends StatelessWidget {
|
||||
final Meta meta;
|
||||
final bool isMobile;
|
||||
final Player player;
|
||||
final Function(int index) onVideoChange;
|
||||
|
||||
const SeasonSource({
|
||||
super.key,
|
||||
required this.meta,
|
||||
required this.isMobile,
|
||||
required this.player,
|
||||
required this.onVideoChange,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialCustomButton(
|
||||
onPressed: () => onSelectMobile(context),
|
||||
icon: const Icon(Icons.list_alt),
|
||||
);
|
||||
}
|
||||
|
||||
onSelectDesktop(BuildContext context) {
|
||||
showCupertinoDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return VideoSelectView(
|
||||
meta: meta,
|
||||
onVideoChange: onVideoChange,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
onSelectMobile(BuildContext context) {
|
||||
showCupertinoDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return VideoSelectView(
|
||||
meta: meta,
|
||||
onVideoChange: onVideoChange,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class VideoSelectView extends StatefulWidget {
|
||||
final Meta meta;
|
||||
final Function(int index) onVideoChange;
|
||||
|
||||
const VideoSelectView({
|
||||
super.key,
|
||||
required this.meta,
|
||||
required this.onVideoChange,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VideoSelectView> createState() => _VideoSelectViewState();
|
||||
}
|
||||
|
||||
class _VideoSelectViewState extends State<VideoSelectView> {
|
||||
final ScrollController controller = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.meta.selectedVideoIndex != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
const itemWidth = 240.0 + 16.0;
|
||||
final offset = widget.meta.selectedVideoIndex! * itemWidth;
|
||||
|
||||
controller.jumpTo(offset);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
controller.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onVerticalDragEnd: (details) {
|
||||
if (details.primaryVelocity! > 0) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black38,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
title: const Text("Episodes"),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 150,
|
||||
child: ListView.builder(
|
||||
controller: controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemBuilder: (context, index) {
|
||||
final video = widget.meta.videos![index];
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
widget.onVideoChange(index);
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
image: DecorationImage(
|
||||
fit: BoxFit.fill,
|
||||
image: CachedNetworkImageProvider(
|
||||
video.thumbnail ??
|
||||
widget.meta.poster ??
|
||||
widget.meta.background ??
|
||||
""),
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 240,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.black,
|
||||
Colors.black54,
|
||||
Colors.black38,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"S${video.season} E${video.episode}",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge,
|
||||
),
|
||||
Text(
|
||||
video.name ?? video.title ?? "",
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.meta.selectedVideoIndex == index)
|
||||
Positioned(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.black,
|
||||
Colors.black54,
|
||||
Colors.black38,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text("Playing"),
|
||||
Icon(Icons.play_arrow),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: (widget.meta.videos ?? []).length,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/features/connections/service/stremio_connection_service.dart';
|
||||
import 'package:madari_client/utils/load_language.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
import '../../../connections/service/base_connection_service.dart';
|
||||
import '../../../connections/types/stremio/stremio_base.types.dart';
|
||||
|
||||
Map<String, List<Subtitle>> externalSubtitlesCache = {};
|
||||
|
||||
class SubtitleSelector extends StatefulWidget {
|
||||
final Player player;
|
||||
final PlaybackConfig config;
|
||||
final BaseConnectionService? service;
|
||||
final LibraryItem? meta;
|
||||
|
||||
const SubtitleSelector({
|
||||
super.key,
|
||||
required this.player,
|
||||
required this.config,
|
||||
required this.service,
|
||||
this.meta,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SubtitleSelector> createState() => _SubtitleSelectorState();
|
||||
}
|
||||
|
||||
class _SubtitleSelectorState extends State<SubtitleSelector> {
|
||||
List<SubtitleTrack> subtitles = [];
|
||||
Map<String, String> languages = {};
|
||||
Stream<List<Subtitle>>? externalSubtitles;
|
||||
|
||||
late StreamSubscription<List<String>> _subtitles;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.service is StremioConnectionService && widget.meta is Meta) {
|
||||
final meta = widget.meta as Meta;
|
||||
|
||||
if (externalSubtitlesCache.containsKey(meta.id)) {
|
||||
externalSubtitles = Stream.value(externalSubtitlesCache[meta.id]!);
|
||||
} else {
|
||||
externalSubtitles = (widget.service as StremioConnectionService)
|
||||
.getSubtitles(meta)
|
||||
.map((item) {
|
||||
externalSubtitlesCache[meta.id] = item;
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onPlaybackReady(widget.player.state.tracks);
|
||||
_subtitles = widget.player.stream.subtitle.listen((item) {
|
||||
onPlaybackReady(widget.player.state.tracks);
|
||||
});
|
||||
|
||||
loadLanguages(context).then((language) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
languages = language;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
|
||||
_subtitles.cancel();
|
||||
}
|
||||
|
||||
void onPlaybackReady(Tracks tracks) {
|
||||
setState(() {
|
||||
subtitles = tracks.subtitle.where((item) {
|
||||
return item.id != "auto" && item.id != "no";
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 520,
|
||||
),
|
||||
child: Card(
|
||||
child: Container(
|
||||
height: max(MediaQuery.of(context).size.height * 0.4, 400),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).dialogBackgroundColor,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'Select Subtitle',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: StreamBuilder<List<Subtitle>>(
|
||||
stream: externalSubtitles,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: Colors.black54,
|
||||
highlightColor: Colors.black54,
|
||||
child: ListView.builder(
|
||||
itemCount: 5,
|
||||
itemBuilder: (context, index) {
|
||||
return ListTile(
|
||||
title: Container(
|
||||
height: 20,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
return Center(child: Text('Error: ${snapshot.error}'));
|
||||
} else if (!snapshot.hasData || snapshot.data!.isEmpty) {
|
||||
return ListView.builder(
|
||||
itemCount: subtitles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final currentItem = subtitles[index];
|
||||
final title = currentItem.language ??
|
||||
currentItem.title ??
|
||||
currentItem.id;
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
languages.containsKey(title)
|
||||
? languages[title]!
|
||||
: title,
|
||||
),
|
||||
selected: widget.player.state.track.subtitle.id ==
|
||||
currentItem.id,
|
||||
onTap: () {
|
||||
widget.player.setSubtitleTrack(currentItem);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
final externalSubtitlesList = snapshot.data!;
|
||||
final allSubtitles = [
|
||||
SubtitleTrack.no(),
|
||||
...subtitles,
|
||||
...externalSubtitlesList.map(
|
||||
(subtitle) {
|
||||
return SubtitleTrack.uri(
|
||||
subtitle.url,
|
||||
language: subtitle.lang,
|
||||
title:
|
||||
"${languages[subtitle.lang] ?? subtitle.lang} ${subtitle.id}",
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: allSubtitles.length,
|
||||
itemBuilder: (context, index) {
|
||||
final currentItem = allSubtitles[index];
|
||||
final title = currentItem.language ??
|
||||
currentItem.title ??
|
||||
currentItem.id;
|
||||
|
||||
final isExternal = currentItem.uri;
|
||||
|
||||
return ListTile(
|
||||
title: Text(
|
||||
"${languages.containsKey(title) ? languages[title]! : title == "no" ? "No subtitle" : title} ${isExternal ? "(External) (${Uri.parse(currentItem.id).host})" : ""}",
|
||||
),
|
||||
selected: widget.player.state.track.subtitle.id ==
|
||||
currentItem.id,
|
||||
onTap: () async {
|
||||
await widget.player.setSubtitleTrack(currentItem);
|
||||
if (context.mounted) Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:madari_client/features/connections/service/base_connection_service.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/video_viewer/season_source.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/video_viewer/torrent_stat.dart';
|
||||
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
|
||||
import 'package:madari_client/utils/load_language.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
import '../../../connections/types/stremio/stremio_base.types.dart' as types;
|
||||
|
||||
class VideoViewerMobile extends StatefulWidget {
|
||||
final VoidCallback onSubtitleSelect;
|
||||
final VoidCallback onLibrarySelect;
|
||||
final Player player;
|
||||
final DocSource source;
|
||||
final VideoController controller;
|
||||
final VoidCallback onAudioSelect;
|
||||
final PlaybackConfig config;
|
||||
final GlobalKey<VideoState> videoKey;
|
||||
final LibraryItem? meta;
|
||||
final Future<void> Function(int index) onVideoChange;
|
||||
|
||||
const VideoViewerMobile({
|
||||
super.key,
|
||||
required this.onLibrarySelect,
|
||||
required this.onSubtitleSelect,
|
||||
required this.player,
|
||||
required this.source,
|
||||
required this.controller,
|
||||
required this.onAudioSelect,
|
||||
required this.config,
|
||||
required this.videoKey,
|
||||
required this.meta,
|
||||
required this.onVideoChange,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VideoViewerMobile> createState() => _VideoViewerMobileState();
|
||||
}
|
||||
|
||||
class _VideoViewerMobileState extends State<VideoViewerMobile> {
|
||||
final Logger _logger = Logger('_VideoViewerMobileState');
|
||||
bool isScaled = false;
|
||||
|
||||
@override
|
||||
build(BuildContext context) {
|
||||
final mobile = _getMobileControls(
|
||||
context,
|
||||
onLibrarySelect: widget.onLibrarySelect,
|
||||
player: widget.player,
|
||||
source: widget.source,
|
||||
onSubtitleClick: widget.onSubtitleSelect,
|
||||
onAudioClick: widget.onAudioSelect,
|
||||
toggleScale: () {
|
||||
setState(() {
|
||||
isScaled = !isScaled;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
String subtitleStyleName = widget.config.subtitleStyle ?? 'Normal';
|
||||
String subtitleStyleColor = widget.config.subtitleColor ?? 'white';
|
||||
double subtitleSize = widget.config.subtitleSize;
|
||||
|
||||
Color hexToColor(String hexColor) {
|
||||
final hexCode = hexColor.replaceAll('#', '');
|
||||
try {
|
||||
return Color(int.parse('0x$hexCode'));
|
||||
} catch (e) {
|
||||
return Colors.white;
|
||||
}
|
||||
}
|
||||
|
||||
FontStyle getFontStyleFromString(String styleName) {
|
||||
switch (styleName.toLowerCase()) {
|
||||
case 'italic':
|
||||
return FontStyle.italic;
|
||||
case 'normal':
|
||||
default:
|
||||
return FontStyle.normal;
|
||||
}
|
||||
}
|
||||
|
||||
FontStyle currentFontStyle = getFontStyleFromString(subtitleStyleName);
|
||||
return MaterialVideoControlsTheme(
|
||||
fullscreen: mobile,
|
||||
normal: mobile,
|
||||
child: Video(
|
||||
subtitleViewConfiguration: SubtitleViewConfiguration(
|
||||
style: TextStyle(
|
||||
color: hexToColor(subtitleStyleColor),
|
||||
fontSize: subtitleSize,
|
||||
fontStyle: currentFontStyle,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
fit: isScaled ? BoxFit.fitWidth : BoxFit.fitHeight,
|
||||
pauseUponEnteringBackgroundMode: true,
|
||||
key: widget.videoKey,
|
||||
onExitFullscreen: () async {
|
||||
await defaultExitNativeFullscreen();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
controller: widget.controller,
|
||||
controls: MaterialVideoControls,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_getMobileControls(
|
||||
BuildContext context, {
|
||||
required DocSource source,
|
||||
required Player player,
|
||||
required VoidCallback onSubtitleClick,
|
||||
required VoidCallback onAudioClick,
|
||||
required VoidCallback toggleScale,
|
||||
required VoidCallback onLibrarySelect,
|
||||
}) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final meta = widget.meta;
|
||||
|
||||
return MaterialVideoControlsThemeData(
|
||||
topButtonBar: [
|
||||
MaterialCustomButton(
|
||||
onPressed: () {
|
||||
Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).pop();
|
||||
Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).pop();
|
||||
},
|
||||
icon: const Icon(
|
||||
Icons.arrow_back,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
meta.toString(),
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
if (meta is types.Meta)
|
||||
if (meta.type == "series")
|
||||
SeasonSource(
|
||||
meta: meta,
|
||||
isMobile: true,
|
||||
player: player,
|
||||
onVideoChange: (index) async {
|
||||
await widget.onVideoChange(index);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
bufferingIndicatorBuilder: (source is TorrentSource)
|
||||
? (ctx) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
child: TorrentStats(
|
||||
torrentHash: (source).infoHash,
|
||||
),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
brightnessGesture: true,
|
||||
seekGesture: true,
|
||||
seekOnDoubleTap: true,
|
||||
gesturesEnabledWhileControlsVisible: true,
|
||||
shiftSubtitlesOnControlsVisibilityChange: true,
|
||||
seekBarMargin: const EdgeInsets.only(bottom: 54),
|
||||
speedUpOnLongPress: true,
|
||||
speedUpFactor: 2,
|
||||
volumeGesture: true,
|
||||
bottomButtonBar: [
|
||||
const MaterialPlayOrPauseButton(),
|
||||
const MaterialPositionIndicator(),
|
||||
const Spacer(),
|
||||
MaterialCustomButton(
|
||||
onPressed: () {
|
||||
final speeds = [
|
||||
0.5,
|
||||
0.75,
|
||||
1.0,
|
||||
1.25,
|
||||
1.5,
|
||||
1.75,
|
||||
2.0,
|
||||
2.25,
|
||||
2.5,
|
||||
3.0,
|
||||
3.25,
|
||||
3.5,
|
||||
3.75,
|
||||
4.0,
|
||||
4.25,
|
||||
4.5,
|
||||
4.75,
|
||||
5.0
|
||||
];
|
||||
showCupertinoModalPopup(
|
||||
context: context,
|
||||
builder: (ctx) => Card(
|
||||
child: Container(
|
||||
height: MediaQuery.of(context).size.height * 0.4,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).dialogBackgroundColor,
|
||||
borderRadius:
|
||||
const BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
'Select Playback Speed',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: speeds.length,
|
||||
itemBuilder: (context, index) {
|
||||
final speed = speeds[index];
|
||||
return ListTile(
|
||||
title: Text('${speed}x'),
|
||||
selected: player.state.rate == speed,
|
||||
onTap: () {
|
||||
player.setRate(speed);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.speed),
|
||||
),
|
||||
MaterialCustomButton(
|
||||
onPressed: () {
|
||||
onSubtitleClick();
|
||||
},
|
||||
icon: const Icon(Icons.subtitles),
|
||||
),
|
||||
MaterialCustomButton(
|
||||
onPressed: () {
|
||||
onAudioClick();
|
||||
},
|
||||
icon: const Icon(Icons.audio_file),
|
||||
),
|
||||
MaterialCustomButton(
|
||||
onPressed: () {
|
||||
toggleScale();
|
||||
},
|
||||
icon: const Icon(Icons.fit_screen_outlined),
|
||||
),
|
||||
],
|
||||
topButtonBarMargin: EdgeInsets.only(
|
||||
top: mediaQuery.padding.top,
|
||||
),
|
||||
bottomButtonBarMargin: EdgeInsets.only(
|
||||
bottom: mediaQuery.viewInsets.bottom,
|
||||
left: 4.0,
|
||||
right: 4.0,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,308 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:madari_client/features/connections/service/base_connection_service.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/video_viewer/audio_track_selector.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/video_viewer/subtitle_selector.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/video_viewer/tv_controls.dart';
|
||||
import 'package:madari_client/features/doc_viewer/container/video_viewer/video_viewer_mobile_ui.dart';
|
||||
import 'package:madari_client/features/doc_viewer/types/doc_source.dart';
|
||||
import 'package:madari_client/utils/load_language.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
import '../../../../utils/tv_detector.dart';
|
||||
import '../../../connections/types/stremio/stremio_base.types.dart' as types;
|
||||
import '../../../connections/widget/base/render_stream_list.dart';
|
||||
import 'desktop_video_player.dart';
|
||||
|
||||
class VideoViewerUi extends StatefulWidget {
|
||||
final VideoController controller;
|
||||
final Player player;
|
||||
final PlaybackConfig config;
|
||||
final DocSource source;
|
||||
final VoidCallback onLibrarySelect;
|
||||
final BaseConnectionService? service;
|
||||
final LibraryItem? meta;
|
||||
final Function(
|
||||
DocSource source,
|
||||
LibraryItem item,
|
||||
) onSourceChange;
|
||||
|
||||
const VideoViewerUi({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.player,
|
||||
required this.config,
|
||||
required this.source,
|
||||
required this.onLibrarySelect,
|
||||
required this.service,
|
||||
this.meta,
|
||||
required this.onSourceChange,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VideoViewerUi> createState() => _VideoViewerUiState();
|
||||
}
|
||||
|
||||
class _VideoViewerUiState extends State<VideoViewerUi> {
|
||||
late final GlobalKey<VideoState> key = GlobalKey<VideoState>();
|
||||
final Logger _logger = Logger('_VideoViewerUiState');
|
||||
|
||||
final List<StreamSubscription> listeners = [];
|
||||
|
||||
bool defaultConfigSelected = false;
|
||||
|
||||
bool subtitleSelectionHandled = false;
|
||||
bool audioSelectionHandled = false;
|
||||
|
||||
void setDefaultAudioTracks(Tracks tracks) {
|
||||
if (defaultConfigSelected == true &&
|
||||
(tracks.audio.length <= 1 || tracks.audio.length <= 1)) {
|
||||
return;
|
||||
}
|
||||
|
||||
defaultConfigSelected = true;
|
||||
|
||||
widget.controller.player.setRate(widget.config.playbackSpeed);
|
||||
|
||||
final defaultSubtitle = widget.config.defaultSubtitleTrack;
|
||||
final defaultAudio = widget.config.defaultAudioTrack;
|
||||
|
||||
for (final item in tracks.audio) {
|
||||
if ((defaultAudio == item.id ||
|
||||
defaultAudio == item.language ||
|
||||
defaultAudio == item.title) &&
|
||||
audioSelectionHandled == false) {
|
||||
widget.controller.player.setAudioTrack(item);
|
||||
audioSelectionHandled = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (widget.config.disableSubtitle) {
|
||||
for (final item in tracks.subtitle) {
|
||||
if ((item.id == "no" || item.language == "no" || item.title == "no") &&
|
||||
subtitleSelectionHandled == false) {
|
||||
widget.controller.player.setSubtitleTrack(item);
|
||||
subtitleSelectionHandled = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (final item in tracks.subtitle) {
|
||||
if ((defaultSubtitle == item.id ||
|
||||
defaultSubtitle == item.language ||
|
||||
defaultSubtitle == item.title) &&
|
||||
subtitleSelectionHandled == false) {
|
||||
subtitleSelectionHandled = true;
|
||||
widget.controller.player.setSubtitleTrack(item);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final listenerComplete = widget.player.stream.completed.listen((completed) {
|
||||
if (completed) {
|
||||
widget.onLibrarySelect();
|
||||
key.currentState?.exitFullscreen();
|
||||
}
|
||||
});
|
||||
|
||||
listeners.add(listenerComplete);
|
||||
|
||||
if (!kIsWeb) {
|
||||
if (Platform.isAndroid || Platform.isIOS) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
|
||||
key.currentState?.enterFullscreen();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final listener = widget.player.stream.tracks.listen((tracks) {
|
||||
if (mounted) {
|
||||
setDefaultAudioTracks(tracks);
|
||||
}
|
||||
});
|
||||
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
||||
print(widget.meta.toString());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
|
||||
for (final listener in listeners) {
|
||||
listener.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _buildBody(context);
|
||||
}
|
||||
|
||||
_buildBody(BuildContext context) {
|
||||
if (DeviceDetector.isTV()) {
|
||||
return MaterialTvVideoControlsTheme(
|
||||
fullscreen: const MaterialTvVideoControlsThemeData(),
|
||||
normal: const MaterialTvVideoControlsThemeData(),
|
||||
child: Video(
|
||||
width: MediaQuery.of(context).size.width,
|
||||
fit: BoxFit.fitWidth,
|
||||
controller: widget.controller,
|
||||
controls: MaterialTvVideoControls,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
switch (Theme.of(context).platform) {
|
||||
case TargetPlatform.android:
|
||||
case TargetPlatform.iOS:
|
||||
return VideoViewerMobile(
|
||||
onLibrarySelect: widget.onLibrarySelect,
|
||||
onSubtitleSelect: onSubtitleSelect,
|
||||
player: widget.player,
|
||||
source: widget.source,
|
||||
controller: widget.controller,
|
||||
onAudioSelect: onAudioSelect,
|
||||
config: widget.config,
|
||||
videoKey: key,
|
||||
meta: widget.meta,
|
||||
onVideoChange: (index) async {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
widget.player.pause();
|
||||
|
||||
final result = await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height,
|
||||
),
|
||||
builder: (context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: RenderStreamList(
|
||||
service: widget.service!,
|
||||
id: (widget.meta as types.Meta).copyWith(
|
||||
selectedVideoIndex: index,
|
||||
),
|
||||
shouldPop: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
widget.onSourceChange(
|
||||
result,
|
||||
(widget.meta as types.Meta).copyWith(
|
||||
selectedVideoIndex: index,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
default:
|
||||
return _buildDesktop(context);
|
||||
}
|
||||
}
|
||||
|
||||
_buildDesktop(BuildContext context) {
|
||||
final desktop = getDesktopControls(
|
||||
context,
|
||||
player: widget.player,
|
||||
source: widget.source,
|
||||
onAudioSelect: onAudioSelect,
|
||||
onSubtitleSelect: onSubtitleSelect,
|
||||
meta: widget.meta,
|
||||
onVideoChange: (index) async {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
widget.player.pause();
|
||||
|
||||
final result = await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height,
|
||||
),
|
||||
builder: (context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: RenderStreamList(
|
||||
service: widget.service!,
|
||||
id: (widget.meta as types.Meta).copyWith(
|
||||
selectedVideoIndex: index,
|
||||
),
|
||||
shouldPop: true,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (result != null) {
|
||||
widget.onSourceChange(
|
||||
result,
|
||||
(widget.meta as types.Meta).copyWith(
|
||||
selectedVideoIndex: index,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return MaterialDesktopVideoControlsTheme(
|
||||
normal: desktop,
|
||||
fullscreen: desktop,
|
||||
child: Video(
|
||||
key: key,
|
||||
width: MediaQuery.of(context).size.width,
|
||||
fit: BoxFit.fitWidth,
|
||||
controller: widget.controller,
|
||||
controls: MaterialDesktopVideoControls,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
onAudioSelect() {
|
||||
_logger.info('Audio track selection triggered.');
|
||||
|
||||
showCupertinoModalPopup(
|
||||
context: context,
|
||||
builder: (ctx) => AudioTrackSelector(
|
||||
player: widget.player,
|
||||
config: widget.config,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
onSubtitleSelect() {
|
||||
_logger.info('Subtitle selection triggered.');
|
||||
|
||||
showCupertinoModalPopup(
|
||||
context: context,
|
||||
builder: (ctx) => SubtitleSelector(
|
||||
player: widget.player,
|
||||
config: widget.config,
|
||||
service: widget.service,
|
||||
meta: widget.meta,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -291,6 +291,7 @@ class _CreateConnectionStepState extends State<CreateConnectionStep> {
|
|||
final Map<String, String> _items = {
|
||||
"Cinemeta": "https://v3-cinemeta.strem.io/manifest.json",
|
||||
"Watchhub": "https://watchhub.strem.io/manifest.json",
|
||||
"Subtitles": "https://opensubtitles-v3.strem.io/manifest.json",
|
||||
};
|
||||
|
||||
void _removeAddon(int index) {
|
||||
|
|
|
|||
|
|
@ -246,8 +246,10 @@ class _StremioStreamSelectorState extends State<StremioStreamSelector> {
|
|||
url: url,
|
||||
title: widget.item.name!,
|
||||
id: widget.item.id,
|
||||
season: widget.season,
|
||||
episode: widget.episode,
|
||||
season: widget.item.currentVideo?.season.toString() ??
|
||||
widget.season,
|
||||
episode: widget.item.currentVideo?.episode.toString() ??
|
||||
widget.episode,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -258,8 +260,10 @@ class _StremioStreamSelectorState extends State<StremioStreamSelector> {
|
|||
infoHash: item.infoHash!,
|
||||
fileName:
|
||||
"${item.behaviorHints?["filename"] as String}.mp4",
|
||||
season: widget.season,
|
||||
episode: widget.episode,
|
||||
season: widget.item.currentVideo?.season.toString() ??
|
||||
widget.season,
|
||||
episode: widget.item.currentVideo?.episode.toString() ??
|
||||
widget.episode,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -271,8 +275,10 @@ class _StremioStreamSelectorState extends State<StremioStreamSelector> {
|
|||
url: item.url!,
|
||||
id: widget.item.id,
|
||||
fileName: "${_getFileName(item)}.mp4",
|
||||
season: widget.season,
|
||||
episode: widget.episode,
|
||||
season: widget.item.currentVideo?.season.toString() ??
|
||||
widget.season,
|
||||
episode: widget.item.currentVideo?.episode.toString() ??
|
||||
widget.episode,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
194
lib/features/settings/screen/logs_screen.dart
Normal file
194
lib/features/settings/screen/logs_screen.dart
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../data/global_logs.dart';
|
||||
|
||||
class LogsPage extends StatefulWidget {
|
||||
const LogsPage({super.key});
|
||||
|
||||
@override
|
||||
State<LogsPage> createState() => _LogsPageState();
|
||||
}
|
||||
|
||||
class _LogsPageState extends State<LogsPage> {
|
||||
List<LogEntry> parsedLogs = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_parseLogs();
|
||||
}
|
||||
|
||||
void _parseLogs() {
|
||||
parsedLogs = globalLogs.reversed.map((log) => LogEntry.parse(log)).toList();
|
||||
}
|
||||
|
||||
void _copyToClipboard() {
|
||||
Clipboard.setData(ClipboardData(text: globalLogs.join('\n')));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Logs copied to clipboard'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _getLevelColor(String level) {
|
||||
switch (level.toUpperCase()) {
|
||||
case 'ERROR':
|
||||
return Colors.red;
|
||||
case 'WARN':
|
||||
case 'WARNING':
|
||||
return Colors.orange;
|
||||
case 'INFO':
|
||||
return Colors.blue;
|
||||
case 'DEBUG':
|
||||
return Colors.grey;
|
||||
default:
|
||||
return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
"Application Logs",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _copyToClipboard,
|
||||
icon: const Icon(Icons.copy),
|
||||
tooltip: 'Copy all logs',
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_parseLogs();
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Refresh logs',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: parsedLogs.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'No logs available',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: parsedLogs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final log = parsedLogs[index];
|
||||
return Container(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: _getLevelColor(log.level).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
border: Border.all(
|
||||
color:
|
||||
_getLevelColor(log.level).withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
log.level,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _getLevelColor(log.level),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${log.service} • ',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[700],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
log.timestamp,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
log.message,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LogEntry {
|
||||
final String level;
|
||||
final String service;
|
||||
final String timestamp;
|
||||
final String message;
|
||||
|
||||
LogEntry({
|
||||
required this.level,
|
||||
required this.service,
|
||||
required this.timestamp,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
factory LogEntry.parse(String logLine) {
|
||||
final parts = logLine.split(RegExp(r'\s+'));
|
||||
if (parts.length >= 3) {
|
||||
final level = parts[0];
|
||||
final service = parts[1];
|
||||
final timestamp = parts[2];
|
||||
final message = parts.skip(3).join(' ');
|
||||
return LogEntry(
|
||||
level: level,
|
||||
service: service,
|
||||
timestamp: timestamp,
|
||||
message: message,
|
||||
);
|
||||
}
|
||||
return LogEntry(
|
||||
level: 'UNKNOWN',
|
||||
service: 'Unknown',
|
||||
timestamp: '',
|
||||
message: logLine,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|||
'normal',
|
||||
'italic',
|
||||
];
|
||||
bool _softwareAcceleration = false;
|
||||
String? _selectedSubtitleStyle;
|
||||
String colorToHex(Color color) {
|
||||
return '#${color.value.toRadixString(16).padLeft(8, '0').toUpperCase()}';
|
||||
|
|
@ -122,6 +123,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|||
_defaultAudioTrack = playbackConfig.defaultAudioTrack;
|
||||
_defaultSubtitleTrack = playbackConfig.defaultSubtitleTrack;
|
||||
_enableExternalPlayer = playbackConfig.externalPlayer;
|
||||
_softwareAcceleration = playbackConfig.softwareAcceleration;
|
||||
_defaultPlayerId =
|
||||
playbackConfig.externalPlayerId?.containsKey(currentPlatform) == true
|
||||
? playbackConfig.externalPlayerId![currentPlatform]
|
||||
|
|
@ -175,6 +177,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|||
'subtitleStyle': _selectedSubtitleStyle,
|
||||
'subtitleColor': colorToHex(_selectedSubtitleColor),
|
||||
'subtitleSize': _subtitleSize,
|
||||
'softwareAcceleration': _softwareAcceleration,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -233,184 +236,200 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|||
appBar: AppBar(
|
||||
title: const Text('Playback Settings'),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: const Text('Auto-play'),
|
||||
subtitle: const Text('Automatically play next content'),
|
||||
value: _autoPlay,
|
||||
onChanged: (value) {
|
||||
setState(() => _autoPlay = value);
|
||||
_debouncedSave();
|
||||
},
|
||||
body: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 600,
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Playback Speed'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Slider(
|
||||
value: _playbackSpeed,
|
||||
min: 0.5,
|
||||
max: 5.0,
|
||||
divisions: 18,
|
||||
label: '${_playbackSpeed.toStringAsFixed(2)}x',
|
||||
onChanged: (value) {
|
||||
HapticFeedback.mediumImpact();
|
||||
setState(() => _playbackSpeed =
|
||||
double.parse(value.toStringAsFixed(2)));
|
||||
_debouncedSave();
|
||||
},
|
||||
),
|
||||
Text('Current: ${_playbackSpeed.toStringAsFixed(2)}x'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: const Text('Default Audio Track'),
|
||||
trailing: DropdownButton<String>(
|
||||
value: _defaultAudioTrack,
|
||||
items: dropdown,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() => _defaultAudioTrack = value);
|
||||
_debouncedSave();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Disable Subtitle'),
|
||||
value: _disabledSubtitle,
|
||||
onChanged: (value) {
|
||||
setState(() => _disabledSubtitle = value);
|
||||
_debouncedSave();
|
||||
},
|
||||
),
|
||||
if (!_disabledSubtitle) ...[
|
||||
ListTile(
|
||||
title: const Text('Default Subtitle Track'),
|
||||
trailing: DropdownButton<String>(
|
||||
value: _defaultSubtitleTrack,
|
||||
items: dropdown,
|
||||
child: ListView(
|
||||
children: [
|
||||
SwitchListTile(
|
||||
title: const Text('Auto-play'),
|
||||
subtitle: const Text('Automatically play next content'),
|
||||
value: _autoPlay,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() => _defaultSubtitleTrack = value);
|
||||
_debouncedSave();
|
||||
}
|
||||
setState(() => _autoPlay = value);
|
||||
_debouncedSave();
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Material(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.8,
|
||||
ListTile(
|
||||
title: const Text('Playback Speed'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Slider(
|
||||
value: _playbackSpeed,
|
||||
min: 0.5,
|
||||
max: 5.0,
|
||||
divisions: 18,
|
||||
label: '${_playbackSpeed.toStringAsFixed(2)}x',
|
||||
onChanged: (value) {
|
||||
HapticFeedback.mediumImpact();
|
||||
setState(() => _playbackSpeed =
|
||||
double.parse(value.toStringAsFixed(2)));
|
||||
_debouncedSave();
|
||||
},
|
||||
),
|
||||
Text('Current: ${_playbackSpeed.toStringAsFixed(2)}x'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: const Text('Default Audio Track'),
|
||||
trailing: DropdownButton<String>(
|
||||
value: _defaultAudioTrack,
|
||||
items: dropdown,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() => _defaultAudioTrack = value);
|
||||
_debouncedSave();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Software Acceleration'),
|
||||
value: _softwareAcceleration,
|
||||
onChanged: (value) {
|
||||
setState(() => _softwareAcceleration = value);
|
||||
_debouncedSave();
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
title: const Text('Disable Subtitle'),
|
||||
value: _disabledSubtitle,
|
||||
onChanged: (value) {
|
||||
setState(() => _disabledSubtitle = value);
|
||||
_debouncedSave();
|
||||
},
|
||||
),
|
||||
if (!_disabledSubtitle) ...[
|
||||
ListTile(
|
||||
title: const Text('Default Subtitle Track'),
|
||||
trailing: DropdownButton<String>(
|
||||
value: _defaultSubtitleTrack,
|
||||
items: dropdown,
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() => _defaultSubtitleTrack = value);
|
||||
_debouncedSave();
|
||||
}
|
||||
},
|
||||
),
|
||||
child: Text(
|
||||
'Sample Text',
|
||||
textAlign: TextAlign.center, // Center text within its box
|
||||
style: TextStyle(
|
||||
fontSize: _subtitleSize / 2,
|
||||
color: _selectedSubtitleColor,
|
||||
fontStyle: _subtitleStyle[0].toLowerCase() == 'italic'
|
||||
? FontStyle.italic
|
||||
: FontStyle.normal,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Material(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.8,
|
||||
),
|
||||
child: Text(
|
||||
'Sample Text',
|
||||
textAlign:
|
||||
TextAlign.center, // Center text within its box
|
||||
style: TextStyle(
|
||||
fontSize: _subtitleSize / 2,
|
||||
color: _selectedSubtitleColor,
|
||||
fontStyle: _subtitleStyle[0].toLowerCase() == 'italic'
|
||||
? FontStyle.italic
|
||||
: FontStyle.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Subtitle Style'),
|
||||
trailing: DropdownButton<String>(
|
||||
value: _selectedSubtitleStyle,
|
||||
items: dropdownstyle,
|
||||
onChanged: (value) {
|
||||
HapticFeedback.mediumImpact();
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedSubtitleStyle = value;
|
||||
});
|
||||
_debouncedSave();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Subtitle Color'),
|
||||
trailing: GestureDetector(
|
||||
// Use GestureDetector to make the color display tappable
|
||||
onTap: () => _showColorPickerDialog(context),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: _selectedSubtitleColor,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
border: Border.all(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Font Size'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Slider(
|
||||
value: _subtitleSize,
|
||||
min: 10.0,
|
||||
max: 60.0,
|
||||
divisions: 18,
|
||||
label: '${_subtitleSize.toStringAsFixed(2)}x',
|
||||
ListTile(
|
||||
title: const Text('Subtitle Style'),
|
||||
trailing: DropdownButton<String>(
|
||||
value: _selectedSubtitleStyle,
|
||||
items: dropdownstyle,
|
||||
onChanged: (value) {
|
||||
HapticFeedback.lightImpact();
|
||||
setState(
|
||||
() => _subtitleSize =
|
||||
double.parse(value.toStringAsFixed(2)),
|
||||
);
|
||||
_debouncedSave();
|
||||
HapticFeedback.mediumImpact();
|
||||
if (value != null) {
|
||||
setState(() {
|
||||
_selectedSubtitleStyle = value;
|
||||
});
|
||||
_debouncedSave();
|
||||
}
|
||||
},
|
||||
),
|
||||
Text('Current: ${_subtitleSize.toStringAsFixed(2)}x'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const Divider(),
|
||||
if (!isWeb)
|
||||
SwitchListTile(
|
||||
title: const Text('External Player'),
|
||||
subtitle: const Text('Always open video in external player?'),
|
||||
value: _enableExternalPlayer,
|
||||
onChanged: (value) {
|
||||
setState(() => _enableExternalPlayer = value);
|
||||
_debouncedSave();
|
||||
},
|
||||
),
|
||||
if (_enableExternalPlayer &&
|
||||
externalPlayers[currentPlatform]?.isNotEmpty == true)
|
||||
ListTile(
|
||||
title: const Text('Default Player'),
|
||||
trailing: DropdownButton<String>(
|
||||
value: _defaultPlayerId == "" ? null : _defaultPlayerId,
|
||||
items: externalPlayers[currentPlatform]!
|
||||
.map(
|
||||
(item) => item.toDropdownMenuItem(),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() => _defaultPlayerId = value);
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Subtitle Color'),
|
||||
trailing: GestureDetector(
|
||||
// Use GestureDetector to make the color display tappable
|
||||
onTap: () => _showColorPickerDialog(context),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: _selectedSubtitleColor,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
border: Border.all(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Font Size'),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Slider(
|
||||
value: _subtitleSize,
|
||||
min: 10.0,
|
||||
max: 60.0,
|
||||
divisions: 18,
|
||||
label: '${_subtitleSize.toStringAsFixed(2)}x',
|
||||
onChanged: (value) {
|
||||
HapticFeedback.lightImpact();
|
||||
setState(
|
||||
() => _subtitleSize =
|
||||
double.parse(value.toStringAsFixed(2)),
|
||||
);
|
||||
_debouncedSave();
|
||||
},
|
||||
),
|
||||
Text('Current: ${_subtitleSize.toStringAsFixed(2)}x'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const Divider(),
|
||||
if (!isWeb)
|
||||
SwitchListTile(
|
||||
title: const Text('External Player'),
|
||||
subtitle: const Text('Always open video in external player?'),
|
||||
value: _enableExternalPlayer,
|
||||
onChanged: (value) {
|
||||
setState(() => _enableExternalPlayer = value);
|
||||
_debouncedSave();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
},
|
||||
),
|
||||
if (_enableExternalPlayer &&
|
||||
externalPlayers[currentPlatform]?.isNotEmpty == true)
|
||||
ListTile(
|
||||
title: const Text('Default Player'),
|
||||
trailing: DropdownButton<String>(
|
||||
value: _defaultPlayerId == "" ? null : _defaultPlayerId,
|
||||
items: externalPlayers[currentPlatform]!
|
||||
.map(
|
||||
(item) => item.toDropdownMenuItem(),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) {
|
||||
setState(() => _defaultPlayerId = value);
|
||||
_debouncedSave();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:madari_client/engine/engine.dart';
|
||||
import 'package:madari_client/features/trakt/service/trakt.service.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../utils/auth_refresh.dart';
|
||||
|
|
@ -27,7 +26,6 @@ class _TraktIntegrationState extends State<TraktIntegration> {
|
|||
_loadSelectedCategories();
|
||||
}
|
||||
|
||||
// Check if the user is logged in
|
||||
checkIsLoggedIn() {
|
||||
final traktToken = pb.authStore.record!.getStringValue("trakt_token");
|
||||
|
||||
|
|
@ -36,7 +34,6 @@ class _TraktIntegrationState extends State<TraktIntegration> {
|
|||
});
|
||||
}
|
||||
|
||||
// Load selected categories from the database
|
||||
void _loadSelectedCategories() async {
|
||||
final record = pb.authStore.record!;
|
||||
final config = record.get("config") ?? {};
|
||||
|
|
@ -53,7 +50,6 @@ class _TraktIntegrationState extends State<TraktIntegration> {
|
|||
});
|
||||
}
|
||||
|
||||
// Save selected categories to the database
|
||||
void _saveSelectedCategories() async {
|
||||
final record = pb.authStore.record!;
|
||||
final config = record.get("config") ?? {};
|
||||
|
|
@ -121,7 +117,6 @@ class _TraktIntegrationState extends State<TraktIntegration> {
|
|||
checkIsLoggedIn();
|
||||
}
|
||||
|
||||
// Show the "Add Category" dialog
|
||||
Future<void> _showAddCategoryDialog() async {
|
||||
return showDialog(
|
||||
context: context,
|
||||
|
|
@ -168,7 +163,6 @@ class _TraktIntegrationState extends State<TraktIntegration> {
|
|||
);
|
||||
}
|
||||
|
||||
// Reorder categories
|
||||
void _onReorder(int oldIndex, int newIndex) {
|
||||
setState(() {
|
||||
if (newIndex > oldIndex) {
|
||||
|
|
@ -190,30 +184,6 @@ class _TraktIntegrationState extends State<TraktIntegration> {
|
|||
),
|
||||
centerTitle: true,
|
||||
elevation: 0,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text("Logs"),
|
||||
),
|
||||
body: ListView.builder(
|
||||
itemCount: TraktService.instance!.debugLogs.length,
|
||||
itemBuilder: (context, item) {
|
||||
return Text(
|
||||
TraktService.instance!.debugLogs[item],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
child: Text("Debug logs"),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
|
|
|
|||
|
|
@ -1,18 +1,24 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:madari_client/features/connections/service/base_connection_service.dart';
|
||||
import 'package:madari_client/features/trakt/service/trakt.service.dart';
|
||||
import 'package:madari_client/utils/common.dart';
|
||||
|
||||
import '../../connections/types/stremio/stremio_base.types.dart';
|
||||
import '../../connections/widget/base/render_library_list.dart';
|
||||
import '../../settings/screen/trakt_integration_screen.dart';
|
||||
import '../service/trakt_cache.service.dart';
|
||||
|
||||
class TraktContainer extends StatefulWidget {
|
||||
final String loadId;
|
||||
final int itemsPerPage;
|
||||
|
||||
const TraktContainer({
|
||||
super.key,
|
||||
required this.loadId,
|
||||
this.itemsPerPage = 5,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -20,48 +26,129 @@ class TraktContainer extends StatefulWidget {
|
|||
}
|
||||
|
||||
class TraktContainerState extends State<TraktContainer> {
|
||||
late final TraktCacheService _cacheService;
|
||||
final Logger _logger = Logger('TraktContainerState');
|
||||
|
||||
List<LibraryItem>? _cachedItems;
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
|
||||
late Timer _timer;
|
||||
int _currentPage = 1;
|
||||
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
StreamSubscription<List<String>>? _steam;
|
||||
|
||||
bool get _isBottom {
|
||||
if (!_scrollController.hasClients) return false;
|
||||
final maxScroll = _scrollController.position.maxScrollExtent;
|
||||
final currentScroll = _scrollController.offset;
|
||||
return currentScroll >= (maxScroll * 0.9);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cacheService = TraktCacheService();
|
||||
_logger.info('Initializing TraktContainerState');
|
||||
_loadData();
|
||||
|
||||
_timer = Timer.periodic(
|
||||
const Duration(seconds: 30),
|
||||
(timer) {
|
||||
_loadData();
|
||||
},
|
||||
);
|
||||
_steam = TraktService.instance?.refetchKey.stream.listen((item) {
|
||||
if (item.contains(widget.loadId)) {
|
||||
_logger.info("refreshing widget ${widget.loadId}");
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
|
||||
_scrollController.addListener(() {
|
||||
if (_isBottom) {
|
||||
_loadData(isLoadMore: true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_logger.info('Disposing TraktContainerState');
|
||||
_scrollController.dispose();
|
||||
_steam?.cancel();
|
||||
super.dispose();
|
||||
_timer.cancel();
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
Future<void> _loadData({
|
||||
bool isLoadMore = false,
|
||||
}) async {
|
||||
_logger.info('Started loading data for the _loadData');
|
||||
if (_isLoading) {
|
||||
_logger.warning('Load data called while already loading');
|
||||
return;
|
||||
}
|
||||
_isLoading = true;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final items = await _cacheService.fetchData(widget.loadId);
|
||||
final page = isLoadMore ? _currentPage + 1 : _currentPage;
|
||||
|
||||
List<LibraryItem>? newItems;
|
||||
|
||||
_logger.info('Loading data for loadId: ${widget.loadId}, page: $page');
|
||||
|
||||
switch (widget.loadId) {
|
||||
case "up_next_series":
|
||||
newItems = await TraktService.instance!
|
||||
.getUpNextSeries(
|
||||
page: page,
|
||||
itemsPerPage: widget.itemsPerPage,
|
||||
)
|
||||
.first;
|
||||
break;
|
||||
case "continue_watching":
|
||||
newItems = await TraktService.instance!.getContinueWatching(
|
||||
page: page,
|
||||
itemsPerPage: widget.itemsPerPage,
|
||||
);
|
||||
break;
|
||||
case "upcoming_schedule":
|
||||
newItems = await TraktService.instance!.getUpcomingSchedule(
|
||||
page: page,
|
||||
itemsPerPage: widget.itemsPerPage,
|
||||
);
|
||||
break;
|
||||
case "watchlist":
|
||||
newItems = await TraktService.instance!.getWatchlist(
|
||||
page: page,
|
||||
itemsPerPage: widget.itemsPerPage,
|
||||
);
|
||||
break;
|
||||
case "show_recommendations":
|
||||
newItems = await TraktService.instance!.getShowRecommendations(
|
||||
page: page,
|
||||
itemsPerPage: widget.itemsPerPage,
|
||||
);
|
||||
break;
|
||||
case "movie_recommendations":
|
||||
newItems = await TraktService.instance!.getMovieRecommendations(
|
||||
page: page,
|
||||
itemsPerPage: widget.itemsPerPage,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
_logger.severe('Invalid loadId: ${widget.loadId}');
|
||||
throw Exception("Invalid loadId: ${widget.loadId}");
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_cachedItems = items;
|
||||
_currentPage = page;
|
||||
_cachedItems = [...?_cachedItems, ...?newItems];
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
_logger.info('Data loaded successfully for loadId: ${widget.loadId}');
|
||||
}
|
||||
} catch (e) {
|
||||
_logger.severe('Error loading data: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
|
|
@ -71,9 +158,56 @@ class TraktContainerState extends State<TraktContainer> {
|
|||
}
|
||||
}
|
||||
|
||||
late final Map<String, List<ContextMenuItem>> actions = {
|
||||
"continue_watching": [
|
||||
ContextMenuItem(
|
||||
id: "remove",
|
||||
icon: CupertinoIcons.clear,
|
||||
title: 'Remove',
|
||||
isDestructiveAction: true,
|
||||
onCallback: (action, key) async {
|
||||
if (key is! Meta) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.traktProgressId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await TraktService.instance!.removeFromContinueWatching(
|
||||
key.traktProgressId!.toString(),
|
||||
);
|
||||
|
||||
if (context.mounted && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Removed successfully"),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
"watchlist": [
|
||||
ContextMenuItem(
|
||||
id: "remove",
|
||||
icon: CupertinoIcons.clear,
|
||||
title: 'Remove',
|
||||
isDestructiveAction: true,
|
||||
onCallback: (action, key) {
|
||||
TraktService.instance!.removeFromWatchlist(key as Meta);
|
||||
},
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
Future<void> refresh() async {
|
||||
await _cacheService.refresh(widget.loadId);
|
||||
await _loadData();
|
||||
try {
|
||||
_logger.info('Refreshing data for ${widget.loadId}');
|
||||
_cachedItems = [];
|
||||
_currentPage = 1;
|
||||
await _loadData();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
String get title {
|
||||
|
|
@ -103,6 +237,7 @@ class TraktContainerState extends State<TraktContainer> {
|
|||
height: 30,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
_logger.info('Navigating to Trakt details page');
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) {
|
||||
|
|
@ -113,13 +248,29 @@ class TraktContainerState extends State<TraktContainer> {
|
|||
body: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: RenderListItems(
|
||||
loadMore: () {
|
||||
_loadData(
|
||||
isLoadMore: true,
|
||||
);
|
||||
},
|
||||
items: _cachedItems ?? [],
|
||||
error: _error,
|
||||
contextMenuItems:
|
||||
actions.containsKey(widget.loadId)
|
||||
? actions[widget.loadId]!
|
||||
: [],
|
||||
onContextMenu: (action, items) {
|
||||
actions[widget.loadId]!
|
||||
.firstWhereOrNull((item) {
|
||||
return item.id == action;
|
||||
})?.onCallback!(action, items);
|
||||
},
|
||||
isLoadingMore: _isLoading,
|
||||
hasError: _error != null,
|
||||
heroPrefix: "trakt_up_next${widget.loadId}",
|
||||
service: TraktService.stremioService!,
|
||||
isGrid: true,
|
||||
isWide: false,
|
||||
isWide: widget.loadId == "up_next_series",
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -142,27 +293,65 @@ class TraktContainerState extends State<TraktContainer> {
|
|||
),
|
||||
Stack(
|
||||
children: [
|
||||
if ((_cachedItems ?? []).isEmpty && !_isLoading)
|
||||
if ((_cachedItems ?? []).isEmpty && !_isLoading && _error != null)
|
||||
const Positioned.fill(
|
||||
child: Center(
|
||||
child: Text("Nothing to see here"),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: getListHeight(context),
|
||||
child: _isLoading
|
||||
? SpinnerCards(
|
||||
isWide: widget.loadId == "up_next_series",
|
||||
)
|
||||
: RenderListItems(
|
||||
isWide: widget.loadId == "up_next_series",
|
||||
items: _cachedItems ?? [],
|
||||
error: _error,
|
||||
hasError: _error != null,
|
||||
heroPrefix: "trakt_up_next${widget.loadId}",
|
||||
service: TraktService.stremioService!,
|
||||
),
|
||||
),
|
||||
if (_isLoading && (_cachedItems ?? []).isEmpty)
|
||||
const SpinnerCards(),
|
||||
if (_error != null) Text(_error!),
|
||||
if (_error != null)
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"Error: $_error",
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.red,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_error = null;
|
||||
});
|
||||
_loadData();
|
||||
},
|
||||
child: const Text("Retry"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_error == null)
|
||||
SizedBox(
|
||||
height: getListHeight(context),
|
||||
child: RenderListItems(
|
||||
isWide: widget.loadId == "up_next_series" ||
|
||||
widget.loadId == "upcoming_schedule",
|
||||
items: _cachedItems ?? [],
|
||||
error: _error,
|
||||
contextMenuItems: actions.containsKey(widget.loadId)
|
||||
? actions[widget.loadId]!
|
||||
: [],
|
||||
onContextMenu: (action, items) async {
|
||||
actions[widget.loadId]!.firstWhereOrNull((item) {
|
||||
return item.id == action;
|
||||
})?.onCallback!(action, items);
|
||||
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
},
|
||||
itemScrollController: _scrollController,
|
||||
hasError: _error != null,
|
||||
heroPrefix: "trakt_up_next${widget.loadId}",
|
||||
service: TraktService.stremioService!,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,65 +0,0 @@
|
|||
import 'package:madari_client/features/trakt/service/trakt.service.dart';
|
||||
|
||||
import '../../connections/service/base_connection_service.dart';
|
||||
|
||||
class TraktCacheService {
|
||||
static final TraktCacheService _instance = TraktCacheService._internal();
|
||||
factory TraktCacheService() => _instance;
|
||||
TraktCacheService._internal();
|
||||
|
||||
final Map<String, List<LibraryItem>> _cache = {};
|
||||
final Map<String, bool> _isLoading = {};
|
||||
final Map<String, String?> _errors = {};
|
||||
|
||||
Future<List<LibraryItem>> fetchData(String loadId) async {
|
||||
if (_cache.containsKey(loadId)) {
|
||||
return _cache[loadId]!;
|
||||
}
|
||||
|
||||
_isLoading[loadId] = true;
|
||||
_errors[loadId] = null;
|
||||
|
||||
try {
|
||||
final data = await _fetchFromTrakt(loadId);
|
||||
_cache[loadId] = data;
|
||||
return data;
|
||||
} catch (e) {
|
||||
_errors[loadId] = e.toString();
|
||||
rethrow;
|
||||
} finally {
|
||||
_isLoading[loadId] = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refresh(String loadId) async {
|
||||
_cache.remove(loadId);
|
||||
_errors.remove(loadId);
|
||||
await fetchData(loadId);
|
||||
}
|
||||
|
||||
List<LibraryItem>? getCachedData(String loadId) => _cache[loadId];
|
||||
|
||||
bool isLoading(String loadId) => _isLoading[loadId] ?? false;
|
||||
|
||||
String? getError(String loadId) => _errors[loadId];
|
||||
|
||||
Future<List<LibraryItem>> _fetchFromTrakt(String loadId) async {
|
||||
switch (loadId) {
|
||||
case "up_next":
|
||||
case "up_next_series":
|
||||
return TraktService.instance!.getUpNextSeries();
|
||||
case "continue_watching":
|
||||
return TraktService.instance!.getContinueWatching();
|
||||
case "upcoming_schedule":
|
||||
return TraktService.instance!.getUpcomingSchedule();
|
||||
case "watchlist":
|
||||
return TraktService.instance!.getWatchlist();
|
||||
case "show_recommendations":
|
||||
return TraktService.instance!.getShowRecommendations();
|
||||
case "movie_recommendations":
|
||||
return TraktService.instance!.getMovieRecommendations();
|
||||
default:
|
||||
throw Exception("Invalid loadId: $loadId");
|
||||
}
|
||||
}
|
||||
}
|
||||
15
lib/features/trakt/types/common.dart
Normal file
15
lib/features/trakt/types/common.dart
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
class TraktProgress {
|
||||
final String id;
|
||||
final int? episode;
|
||||
final int? season;
|
||||
final double progress;
|
||||
final int? traktId;
|
||||
|
||||
TraktProgress({
|
||||
required this.id,
|
||||
this.episode,
|
||||
this.season,
|
||||
required this.progress,
|
||||
this.traktId,
|
||||
});
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import 'package:media_kit/media_kit.dart';
|
|||
import 'package:path/path.dart' as path;
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
import 'data/global_logs.dart';
|
||||
import 'features/doc_viewer/container/iframe.dart';
|
||||
import 'features/downloads/service/service.dart';
|
||||
import 'features/watch_history/service/zeee_watch_history.dart';
|
||||
|
|
@ -29,15 +30,34 @@ void main() async {
|
|||
print("Unable");
|
||||
}
|
||||
|
||||
Logger.root.level = Level.ALL;
|
||||
Logger.root.level = Level.INFO;
|
||||
|
||||
Logger.root.onRecord.listen((record) {
|
||||
print('${record.level.name}: ${record.time}: ${record.message}');
|
||||
final logs =
|
||||
'${record.level.name.padRight(10)}${record.loggerName.padRight(30)}${record.time.hour}:${record.time.minute}:${record.time.second}:${record.time.millisecond}: ${record.message}';
|
||||
|
||||
print(logs);
|
||||
|
||||
globalLogs.add(logs);
|
||||
if (globalLogs.length > 1000) {
|
||||
globalLogs.removeAt(0);
|
||||
}
|
||||
|
||||
if (record.error != null) {
|
||||
print('Error: ${record.error}');
|
||||
final error = 'Error: ${record.time} ${record.error}';
|
||||
print(error);
|
||||
globalLogs.add(error);
|
||||
if (globalLogs.length > 1000) {
|
||||
globalLogs.removeAt(0);
|
||||
}
|
||||
}
|
||||
if (record.stackTrace != null) {
|
||||
print('StackTrace: ${record.stackTrace}');
|
||||
final error = 'StackTrace: ${record.stackTrace}';
|
||||
print(error);
|
||||
globalLogs.add(error);
|
||||
if (globalLogs.length > 1000) {
|
||||
globalLogs.removeAt(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -113,7 +133,6 @@ class _MadariAppState extends State<MadariApp> {
|
|||
void _initializeFileHandling() {
|
||||
platform.setMethodCallHandler((call) async {
|
||||
if (call.method == "openFile") {
|
||||
// Handle the new file data structure
|
||||
_openedFileData = call.arguments as Map<String, dynamic>?;
|
||||
|
||||
if (_openedFileData != null) {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,14 @@ class HomeTabPage extends StatefulWidget {
|
|||
class _HomeTabPageState extends State<HomeTabPage> {
|
||||
late final query = Query(
|
||||
queryFn: () async {
|
||||
await TraktService.ensureInitialized();
|
||||
try {
|
||||
if (TraktService.isEnabled() == true) {
|
||||
await TraktService.ensureInitialized();
|
||||
}
|
||||
} catch (e, stack) {
|
||||
print(e);
|
||||
print(stack);
|
||||
}
|
||||
|
||||
if (widget.defaultLibraries != null) {
|
||||
return Future.value(
|
||||
|
|
@ -67,7 +74,11 @@ class _HomeTabPageState extends State<HomeTabPage> {
|
|||
|
||||
if (state == null) continue;
|
||||
|
||||
promises.add(state.refresh());
|
||||
promises.add(() async {
|
||||
try {
|
||||
state.refresh();
|
||||
} catch (e) {}
|
||||
}());
|
||||
}
|
||||
|
||||
await Future.wait(promises);
|
||||
|
|
@ -132,7 +143,9 @@ class _HomeTabPageState extends State<HomeTabPage> {
|
|||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {},
|
||||
onRefresh: () async {
|
||||
return reloadPage();
|
||||
},
|
||||
child: QueryBuilder(
|
||||
query: query,
|
||||
builder: (context, state) {
|
||||
|
|
@ -146,7 +159,7 @@ class _HomeTabPageState extends State<HomeTabPage> {
|
|||
return const Text("Loading");
|
||||
}
|
||||
|
||||
if (data.data.isEmpty) {
|
||||
if (data.data.isEmpty && widget.defaultLibraries != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 24,
|
||||
|
|
@ -171,6 +184,8 @@ class _HomeTabPageState extends State<HomeTabPage> {
|
|||
);
|
||||
}
|
||||
|
||||
final mediaQuery = MediaQuery.of(context).size.width;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8.0,
|
||||
|
|
@ -183,6 +198,8 @@ class _HomeTabPageState extends State<HomeTabPage> {
|
|||
|
||||
return TraktContainer(
|
||||
key: _getKey(index),
|
||||
itemsPerPage:
|
||||
(mediaQuery / getItemWidth(context)).toInt() + 1,
|
||||
loadId: category,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import 'package:madari_client/pages/sign_in.page.dart';
|
|||
|
||||
import '../features/settings/screen/account_screen.dart';
|
||||
import '../features/settings/screen/connection_screen.dart';
|
||||
import '../features/settings/screen/logs_screen.dart';
|
||||
import '../features/settings/screen/playback_settings_screen.dart';
|
||||
import '../features/settings/screen/profile_button.dart';
|
||||
|
||||
|
|
@ -76,6 +77,35 @@ class MoreContainer extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
),
|
||||
_buildListHeader('Debug'),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.text_snippet),
|
||||
title: const Text("Show logs"),
|
||||
onTap: () async {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) {
|
||||
return const LogsPage();
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.clear),
|
||||
title: const Text("Clear Local Watch History"),
|
||||
onTap: () async {
|
||||
await ZeeeWatchHistoryStatic.service?.clear();
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text("Removed local watch history"),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.clear_all),
|
||||
title: const Text("Clear Cache"),
|
||||
|
|
|
|||
|
|
@ -66,57 +66,51 @@ class _StremioItemPageState extends State<StremioItemPage> {
|
|||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.meta?.progress != null || widget.meta?.nextEpisode != null) {
|
||||
Future.delayed(
|
||||
if (widget.meta?.currentVideo != null) {
|
||||
openVideo();
|
||||
}
|
||||
}
|
||||
|
||||
openVideo() async {
|
||||
if (widget.meta != null && widget.service != null) {
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: 500),
|
||||
() {
|
||||
if (widget.meta != null && widget.service != null) {
|
||||
if (mounted) {
|
||||
final season = widget.meta?.nextSeason == null
|
||||
? ""
|
||||
: "S${widget.meta?.nextSeason}";
|
||||
|
||||
final episode = widget.meta?.nextEpisode == null
|
||||
? ""
|
||||
: "E${widget.meta?.nextEpisode}";
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
title: Text(
|
||||
"Streams $season $episode".trim(),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14.0),
|
||||
child: RenderStreamList(
|
||||
progress: widget.meta!.progress != null
|
||||
? widget.meta!.progress! * 100
|
||||
: null,
|
||||
service: widget.service!,
|
||||
id: widget.meta as LibraryItem,
|
||||
season: widget.meta?.nextSeason?.toString(),
|
||||
episode: widget.meta?.nextEpisode?.toString(),
|
||||
shouldPop: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
icon: const Icon(Icons.close),
|
||||
),
|
||||
title: Text(
|
||||
"Streams S${widget.meta?.currentVideo?.season ?? 0} E${widget.meta?.currentVideo?.episode ?? 0}"
|
||||
.trim(),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14.0),
|
||||
child: RenderStreamList(
|
||||
progress: widget.meta!.progress != null
|
||||
? widget.meta!.progress! * 100
|
||||
: null,
|
||||
service: widget.service!,
|
||||
id: widget.meta as LibraryItem,
|
||||
shouldPop: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -160,7 +154,8 @@ class _StremioItemPageState extends State<StremioItemPage> {
|
|||
|
||||
return StremioItemViewer(
|
||||
hero: widget.hero,
|
||||
meta: meta ?? widget.meta,
|
||||
meta: (meta ?? widget.meta)
|
||||
?.copyWith(selectedVideoIndex: widget.meta?.selectedVideoIndex),
|
||||
original: meta,
|
||||
progress: widget.meta?.progress != null ? widget.meta!.progress : 0,
|
||||
service: state.data == null
|
||||
|
|
|
|||
23
lib/utils/common.dart
Normal file
23
lib/utils/common.dart
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
extension FirstWhereOrNullExtension<T> on Iterable<T> {
|
||||
T? firstWhereOrNull(bool Function(T) test) {
|
||||
for (var element in this) {
|
||||
if (test(element)) {
|
||||
return element;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
extension LastWhereOrNullExtension<T> on Iterable<T> {
|
||||
T? lastWhereOrNull(bool Function(T) test) {
|
||||
T? elementItem;
|
||||
|
||||
for (var element in this) {
|
||||
if (test(element)) {
|
||||
elementItem = element;
|
||||
}
|
||||
}
|
||||
return elementItem;
|
||||
}
|
||||
}
|
||||
|
|
@ -55,6 +55,8 @@ class PlaybackConfig {
|
|||
final String? subtitleColor;
|
||||
@JsonKey(defaultValue: 11)
|
||||
final double subtitleSize;
|
||||
@JsonKey(defaultValue: false)
|
||||
final bool softwareAcceleration;
|
||||
|
||||
PlaybackConfig({
|
||||
required this.autoPlay,
|
||||
|
|
@ -67,6 +69,7 @@ class PlaybackConfig {
|
|||
this.subtitleStyle,
|
||||
this.subtitleColor,
|
||||
required this.subtitleSize,
|
||||
required this.softwareAcceleration,
|
||||
});
|
||||
|
||||
String? get currentPlayerPackage {
|
||||
|
|
|
|||
|
|
@ -918,7 +918,7 @@ packages:
|
|||
source: hosted
|
||||
version: "4.0.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
name: madari_client
|
||||
description: "Madari Media Manager"
|
||||
publish_to: 'none'
|
||||
version: 1.0.2+4
|
||||
version: 1.0.3+5
|
||||
environment:
|
||||
sdk: ^3.5.3
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue