Merge branch 'madari-media:main' into Test

This commit is contained in:
Abinanthankv 2025-01-15 16:38:48 +05:30 committed by GitHub
commit 6b9b9a8789
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 3485 additions and 1727 deletions

View file

@ -0,0 +1 @@
final List<String> globalLogs = [];

View file

@ -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),

View file

@ -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

View file

@ -105,8 +105,6 @@ abstract class BaseConnectionService {
Future<void> getStreams(
LibraryItem id, {
String? season,
String? episode,
OnStreamCallback? callback,
});

View file

@ -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,
};
}
}

View file

@ -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({

View file

@ -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,

View file

@ -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,
),
),

View file

@ -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);

View file

@ -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,

View file

@ -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,

View file

@ -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;
}
}

View file

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

View file

@ -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) {

View file

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

View file

@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
class SeasonSelector extends StatelessWidget {
const SeasonSelector({
super.key,
});
@override
Widget build(BuildContext context) {
return Container();
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {

View file

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

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

View file

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

View file

@ -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),

View file

@ -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

View file

@ -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");
}
}
}

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

View file

@ -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) {

View file

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

View file

@ -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"),

View file

@ -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
View 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;
}
}

View file

@ -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 {

View file

@ -918,7 +918,7 @@ packages:
source: hosted
version: "4.0.0"
logging:
dependency: transitive
dependency: "direct main"
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61

View file

@ -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