fix: added external subtitles

This commit is contained in:
omkar 2025-01-12 16:17:09 +05:30
parent e772a2b815
commit 2a2874fb3a
15 changed files with 846 additions and 402 deletions

View file

@ -10,6 +10,7 @@ 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';
@ -127,6 +128,63 @@ class StremioConnectionService extends BaseConnectionService {
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, {
@ -586,3 +644,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

@ -298,8 +298,7 @@ class __RenderLibraryListState extends State<_RenderLibraryList> {
query.getNextPage();
},
itemScrollController: _scrollController,
isLoadingMore: data.status == QueryStatus.loading ||
data.status == QueryStatus.loading && items.isEmpty,
isLoadingMore: data.status == QueryStatus.loading && items.isEmpty,
isGrid: widget.isGrid,
items: items,
heroPrefix: widget.item.id,
@ -440,10 +439,6 @@ class RenderListItems extends StatelessWidget {
),
),
] else ...[
if (isLoadingMore)
const SliverToBoxAdapter(
child: SpinnerCards(),
),
if (!isLoadingMore)
SliverToBoxAdapter(
child: SizedBox(
@ -469,6 +464,12 @@ class RenderListItems extends StatelessWidget {
),
),
),
if (isLoadingMore)
SliverToBoxAdapter(
child: SpinnerCards(
isWide: isWide,
),
),
],
SliverPadding(
padding: EdgeInsets.only(

View file

@ -95,7 +95,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(),
);
@ -138,11 +142,13 @@ class _StremioItemSeasonSelectorState extends State<StremioItemSeasonSelector>
),
body: RenderStreamList(
service: widget.service!,
id: meta.copyWith(
episodeExternalIds: {
"tvdb": episode.tvdbId,
},
),
id: episode.tvdbId != null
? meta.copyWith(
episodeExternalIds: {
"tvdb": episode.tvdbId,
},
)
: meta,
season: currentSeason.toString(),
episode: episode.number?.toString(),
shouldPop: widget.shouldPop,

View file

@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
@ -7,9 +6,7 @@ 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';
@ -20,8 +17,7 @@ import '../../trakt/service/trakt.service.dart';
import '../../trakt/types/common.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;
@ -44,7 +40,6 @@ class VideoViewer extends StatefulWidget {
}
class _VideoViewerState extends State<VideoViewer> {
StreamSubscription? _subTracks;
final zeeeWatchHistory = ZeeeWatchHistoryStatic.service;
Timer? _timer;
late final Player player = Player(
@ -52,7 +47,6 @@ class _VideoViewerState extends State<VideoViewer> {
title: "Madari",
),
);
late final GlobalKey<VideoState> key = GlobalKey<VideoState>();
final Logger _logger = Logger('VideoPlayer');
double get currentProgressInPercentage {
@ -120,64 +114,8 @@ class _VideoViewerState extends State<VideoViewer> {
),
);
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;
}
defaultConfigSelected = true;
controller.player.setRate(config.playbackSpeed);
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;
}
}
if (config.disableSubtitle) {
for (final item in tracks.subtitle) {
if (item.id == "no" || item.language == "no" || item.title == "no") {
controller.player.setSubtitleTrack(item);
}
}
} else {
for (final item in tracks.subtitle) {
if (defaultSubtitle == item.id ||
defaultSubtitle == item.language ||
defaultSubtitle == item.title) {
controller.player.setSubtitleTrack(item);
break;
}
}
}
}
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;
int? traktId;
@ -222,8 +160,6 @@ class _VideoViewerState extends State<VideoViewer> {
PlaybackConfig config = getPlaybackConfig();
bool defaultConfigSelected = false;
@override
void initState() {
super.initState();
@ -234,14 +170,6 @@ 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) async {
if (item.inSeconds != 0) {
await setDurationFromTrakt();
@ -249,27 +177,6 @@ class _VideoViewerState extends State<VideoViewer> {
}
});
_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) {
@ -288,7 +195,7 @@ class _VideoViewerState extends State<VideoViewer> {
}
});
if (widget.meta is types.Meta) {
if (widget.meta is types.Meta && TraktService.isEnabled()) {
traktProgress = TraktService.instance!.getProgress(
widget.meta as types.Meta,
);
@ -343,8 +250,6 @@ class _VideoViewerState extends State<VideoViewer> {
}
}
bool isScaled = false;
late StreamSubscription<bool> _streamComplete;
late StreamSubscription<bool> _streamListen;
late StreamSubscription<dynamic> _duration;
@ -400,8 +305,6 @@ class _VideoViewerState extends State<VideoViewer> {
overlays: [],
);
_timer?.cancel();
_subTracks?.cancel();
_streamComplete.cancel();
_streamListen.cancel();
_duration.cancel();
@ -422,226 +325,15 @@ class _VideoViewerState extends State<VideoViewer> {
@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('#', '');
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: key,
onExitFullscreen: () async {
await defaultExitNativeFullscreen();
if (context.mounted) Navigator.of(context).pop();
},
body: VideoViewerUi(
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() {
_logger.info('Subtitle selection triggered.');
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() {
_logger.info('Audio track selection triggered.');
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: onLibrarySelect,
title: _source.title,
service: widget.service,
meta: widget.meta,
),
);
}

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

@ -12,8 +12,6 @@ import 'package:window_manager/window_manager.dart';
MaterialDesktopVideoControlsThemeData getDesktopControls(
BuildContext context, {
required DocSource source,
required List<SubtitleTrack> subtitles,
required List<AudioTrack> audioTracks,
required Player player,
Widget? library,
required Function() onSubtitleSelect,

View file

@ -9,13 +9,10 @@ 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);
@ -37,13 +34,6 @@ MaterialVideoControlsThemeData getMobileVideoPlayer(
style: Theme.of(context).textTheme.bodyLarge,
),
const Spacer(),
if (hasLibrary)
MaterialCustomButton(
icon: const Icon(Icons.library_books),
onPressed: () {
onLibrarySelect();
},
),
],
bufferingIndicatorBuilder: (source is TorrentSource)
? (ctx) {

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,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";
}).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)" : ""}",
),
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,103 @@
import 'package:flutter/material.dart';
import 'package:logging/logging.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 'mobile_video_player.dart';
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;
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,
});
@override
State<VideoViewerMobile> createState() => _VideoViewerMobileState();
}
class _VideoViewerMobileState extends State<VideoViewerMobile> {
final Logger _logger = Logger('_VideoViewerMobileState');
bool isScaled = false;
@override
build(BuildContext context) {
final mobile = getMobileVideoPlayer(
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();
if (context.mounted) Navigator.of(context).pop();
},
controller: widget.controller,
controls: MaterialVideoControls,
),
);
}
}

View file

@ -1,12 +1,226 @@
import 'package:flutter/material.dart';
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/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 String title;
final BaseConnectionService? service;
final LibraryItem? meta;
class VideoViewerUi extends StatelessWidget {
const VideoViewerUi({
super.key,
required this.controller,
required this.player,
required this.config,
required this.source,
required this.onLibrarySelect,
required this.title,
required this.service,
this.meta,
});
@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();
}
});
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 dispose() {
super.dispose();
for (final listener in listeners) {
listener.cancel();
}
}
@override
Widget build(BuildContext context) {
return Container();
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,
);
default:
return _buildDesktop(context);
}
}
_buildDesktop(BuildContext context) {
final desktop = getDesktopControls(
context,
player: widget.player,
source: widget.source,
onAudioSelect: onAudioSelect,
onSubtitleSelect: onSubtitleSelect,
);
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

@ -26,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");
@ -35,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") ?? {};
@ -52,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") ?? {};
@ -120,7 +117,6 @@ class _TraktIntegrationState extends State<TraktIntegration> {
checkIsLoggedIn();
}
// Show the "Add Category" dialog
Future<void> _showAddCategoryDialog() async {
return showDialog(
context: context,
@ -167,7 +163,6 @@ class _TraktIntegrationState extends State<TraktIntegration> {
);
}
// Reorder categories
void _onReorder(int oldIndex, int newIndex) {
setState(() {
if (newIndex > oldIndex) {

View file

@ -159,6 +159,7 @@ class TraktContainerState extends State<TraktContainer> {
try {
_logger.info('Refreshing data for ${widget.loadId}');
_cachedItems = [];
_currentPage = 0;
await _loadData();
} catch (e) {}
}

View file

@ -205,8 +205,10 @@ class TraktService {
}
}
Stream<List<LibraryItem>> getUpNextSeries(
{int page = 1, int itemsPerPage = 5}) async* {
Stream<List<LibraryItem>> getUpNextSeries({
int page = 1,
int itemsPerPage = 5,
}) async* {
await initStremioService();
if (!isEnabled()) {
@ -220,7 +222,30 @@ class TraktService {
final List<dynamic> watchedShows =
await _makeRequest('$_baseUrl/sync/watched/shows');
final progressFutures = watchedShows.map((show) async {
final startIndex = (page - 1) * itemsPerPage;
final endIndex = startIndex + itemsPerPage;
final items = watchedShows.where((show) {
try {
show['show']['ids']['trakt'];
show['show']['ids']['imdb'];
return true;
} catch (e) {
return false;
}
}).toList();
if (startIndex >= items.length) {
yield [];
return;
}
final paginatedItems = items.sublist(
startIndex,
endIndex > items.length ? items.length : endIndex,
);
final progressFutures = paginatedItems.map((show) async {
final showId = show['show']['ids']['trakt'];
final imdb = show['show']['ids']['imdb'];
@ -262,19 +287,7 @@ class TraktService {
final results = await Future.wait(progressFutures);
final validResults = results.whereType<Meta>().toList();
// Pagination logic
final startIndex = (page - 1) * itemsPerPage;
final endIndex = startIndex + itemsPerPage;
if (startIndex >= validResults.length) {
yield [];
return;
}
final paginatedResults = validResults.sublist(
startIndex,
endIndex > validResults.length ? validResults.length : endIndex,
);
final paginatedResults = validResults;
yield paginatedResults;
} catch (e, stack) {
@ -298,54 +311,57 @@ class TraktService {
final Map<String, double> progress = {};
final result = await stremioService!.getBulkItem(
continueWatching
.map((movie) {
try {
if (movie['type'] == 'episode') {
progress[movie['show']['ids']['imdb']] = movie['progress'];
return Meta(
type: "series",
id: movie['show']['ids']['imdb'],
progress: movie['progress'],
nextSeason: movie['episode']['season'],
nextEpisode: movie['episode']['number'],
nextEpisodeTitle: movie['episode']['title'],
externalIds: movie['show']['ids'],
episodeExternalIds: movie['episode']['ids'],
);
}
final imdb = movie['movie']['ids']['imdb'];
progress[imdb] = movie['progress'];
final metaList = continueWatching
.map((movie) {
try {
if (movie['type'] == 'episode') {
progress[movie['show']['ids']['imdb']] = movie['progress'];
return Meta(
type: "movie",
id: imdb,
type: "series",
id: movie['show']['ids']['imdb'],
progress: movie['progress'],
nextSeason: movie['episode']['season'],
nextEpisode: movie['episode']['number'],
nextEpisodeTitle: movie['episode']['title'],
externalIds: movie['show']['ids'],
episodeExternalIds: movie['episode']['ids'],
);
} catch (e) {
_logger.warning('Error mapping movie: $e');
return null;
}
})
.whereType<Meta>()
.toList(),
);
// Pagination logic
final imdb = movie['movie']['ids']['imdb'];
progress[imdb] = movie['progress'];
return Meta(
type: "movie",
id: imdb,
progress: movie['progress'],
);
} catch (e) {
_logger.warning('Error mapping movie: $e');
return null;
}
})
.whereType<Meta>()
.toList();
final startIndex = (page - 1) * itemsPerPage;
final endIndex = startIndex + itemsPerPage;
if (startIndex >= result.length) {
if (startIndex >= metaList.length) {
return [];
}
return result.sublist(
startIndex,
endIndex > result.length ? result.length : endIndex,
final result = await stremioService!.getBulkItem(
metaList
.sublist(
startIndex,
endIndex > metaList.length ? metaList.length : endIndex,
)
.toList(),
);
return result;
} catch (e, stack) {
_logger.severe('Error fetching continue watching: $e', stack);
return [];

View file

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