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_card.dart';
import 'package:madari_client/features/connections/widget/stremio/stremio_list_item.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/features/doc_viewer/types/doc_source.dart';
import 'package:madari_client/utils/common.dart';
import 'package:pocketbase/pocketbase.dart'; import 'package:pocketbase/pocketbase.dart';
import '../../connection/services/stremio_service.dart'; import '../../connection/services/stremio_service.dart';
@ -127,6 +128,63 @@ class StremioConnectionService extends BaseConnectionService {
return 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 @override
Future<PaginatedResult<LibraryItem>> getItems( Future<PaginatedResult<LibraryItem>> getItems(
LibraryRecord library, { LibraryRecord library, {
@ -586,3 +644,69 @@ class StremioConfig {
Map<String, dynamic> toJson() => _$StremioConfigToJson(this); 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(); query.getNextPage();
}, },
itemScrollController: _scrollController, itemScrollController: _scrollController,
isLoadingMore: data.status == QueryStatus.loading || isLoadingMore: data.status == QueryStatus.loading && items.isEmpty,
data.status == QueryStatus.loading && items.isEmpty,
isGrid: widget.isGrid, isGrid: widget.isGrid,
items: items, items: items,
heroPrefix: widget.item.id, heroPrefix: widget.item.id,
@ -440,10 +439,6 @@ class RenderListItems extends StatelessWidget {
), ),
), ),
] else ...[ ] else ...[
if (isLoadingMore)
const SliverToBoxAdapter(
child: SpinnerCards(),
),
if (!isLoadingMore) if (!isLoadingMore)
SliverToBoxAdapter( SliverToBoxAdapter(
child: SizedBox( child: SizedBox(
@ -469,6 +464,12 @@ class RenderListItems extends StatelessWidget {
), ),
), ),
), ),
if (isLoadingMore)
SliverToBoxAdapter(
child: SpinnerCards(
isWide: isWide,
),
),
], ],
SliverPadding( SliverPadding(
padding: EdgeInsets.only( padding: EdgeInsets.only(

View file

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

View file

@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -7,9 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:madari_client/features/connections/service/base_connection_service.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/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/media_kit.dart';
import 'package:media_kit_video/media_kit_video.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 '../../trakt/types/common.dart';
import '../../watch_history/service/zeee_watch_history.dart'; import '../../watch_history/service/zeee_watch_history.dart';
import '../types/doc_source.dart'; import '../types/doc_source.dart';
import 'video_viewer/desktop_video_player.dart'; import 'video_viewer/video_viewer_ui.dart';
import 'video_viewer/mobile_video_player.dart';
class VideoViewer extends StatefulWidget { class VideoViewer extends StatefulWidget {
final DocSource source; final DocSource source;
@ -44,7 +40,6 @@ class VideoViewer extends StatefulWidget {
} }
class _VideoViewerState extends State<VideoViewer> { class _VideoViewerState extends State<VideoViewer> {
StreamSubscription? _subTracks;
final zeeeWatchHistory = ZeeeWatchHistoryStatic.service; final zeeeWatchHistory = ZeeeWatchHistoryStatic.service;
Timer? _timer; Timer? _timer;
late final Player player = Player( late final Player player = Player(
@ -52,7 +47,6 @@ class _VideoViewerState extends State<VideoViewer> {
title: "Madari", title: "Madari",
), ),
); );
late final GlobalKey<VideoState> key = GlobalKey<VideoState>();
final Logger _logger = Logger('VideoPlayer'); final Logger _logger = Logger('VideoPlayer');
double get currentProgressInPercentage { 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; 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; bool canCallOnce = false;
int? traktId; int? traktId;
@ -222,8 +160,6 @@ class _VideoViewerState extends State<VideoViewer> {
PlaybackConfig config = getPlaybackConfig(); PlaybackConfig config = getPlaybackConfig();
bool defaultConfigSelected = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -234,14 +170,6 @@ class _VideoViewerState extends State<VideoViewer> {
overlays: [], overlays: [],
); );
if (!kIsWeb) {
if (Platform.isAndroid || Platform.isIOS) {
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
key.currentState?.enterFullscreen();
});
}
}
_duration = player.stream.duration.listen((item) async { _duration = player.stream.duration.listen((item) async {
if (item.inSeconds != 0) { if (item.inSeconds != 0) {
await setDurationFromTrakt(); 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(); loadFile();
if (player.platform is NativePlayer && !kIsWeb) { 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( traktProgress = TraktService.instance!.getProgress(
widget.meta as types.Meta, widget.meta as types.Meta,
); );
@ -343,8 +250,6 @@ class _VideoViewerState extends State<VideoViewer> {
} }
} }
bool isScaled = false;
late StreamSubscription<bool> _streamComplete; late StreamSubscription<bool> _streamComplete;
late StreamSubscription<bool> _streamListen; late StreamSubscription<bool> _streamListen;
late StreamSubscription<dynamic> _duration; late StreamSubscription<dynamic> _duration;
@ -400,8 +305,6 @@ class _VideoViewerState extends State<VideoViewer> {
overlays: [], overlays: [],
); );
_timer?.cancel(); _timer?.cancel();
_subTracks?.cancel();
_streamComplete.cancel();
_streamListen.cancel(); _streamListen.cancel();
_duration.cancel(); _duration.cancel();
@ -422,226 +325,15 @@ class _VideoViewerState extends State<VideoViewer> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: _buildBody(context), body: VideoViewerUi(
);
}
_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();
},
controller: controller, controller: controller,
controls: MaterialVideoControls, player: player,
), config: config,
); source: _source,
} onLibrarySelect: onLibrarySelect,
title: _source.title,
_buildDesktop(BuildContext context) { service: widget.service,
final desktop = getDesktopControls( meta: widget.meta,
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);
},
);
},
),
),
],
),
),
), ),
); );
} }

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( MaterialDesktopVideoControlsThemeData getDesktopControls(
BuildContext context, { BuildContext context, {
required DocSource source, required DocSource source,
required List<SubtitleTrack> subtitles,
required List<AudioTrack> audioTracks,
required Player player, required Player player,
Widget? library, Widget? library,
required Function() onSubtitleSelect, required Function() onSubtitleSelect,

View file

@ -9,13 +9,10 @@ import '../../types/doc_source.dart';
MaterialVideoControlsThemeData getMobileVideoPlayer( MaterialVideoControlsThemeData getMobileVideoPlayer(
BuildContext context, { BuildContext context, {
required DocSource source, required DocSource source,
required List<SubtitleTrack> subtitles,
required List<AudioTrack> audioTracks,
required Player player, required Player player,
required VoidCallback onSubtitleClick, required VoidCallback onSubtitleClick,
required VoidCallback onAudioClick, required VoidCallback onAudioClick,
required VoidCallback toggleScale, required VoidCallback toggleScale,
required bool hasLibrary,
required VoidCallback onLibrarySelect, required VoidCallback onLibrarySelect,
}) { }) {
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
@ -37,13 +34,6 @@ MaterialVideoControlsThemeData getMobileVideoPlayer(
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
const Spacer(), const Spacer(),
if (hasLibrary)
MaterialCustomButton(
icon: const Icon(Icons.library_books),
onPressed: () {
onLibrarySelect();
},
),
], ],
bufferingIndicatorBuilder: (source is TorrentSource) bufferingIndicatorBuilder: (source is TorrentSource)
? (ctx) { ? (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({ const VideoViewerUi({
super.key, 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 @override
Widget build(BuildContext context) { 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(); _loadSelectedCategories();
} }
// Check if the user is logged in
checkIsLoggedIn() { checkIsLoggedIn() {
final traktToken = pb.authStore.record!.getStringValue("trakt_token"); 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 { void _loadSelectedCategories() async {
final record = pb.authStore.record!; final record = pb.authStore.record!;
final config = record.get("config") ?? {}; final config = record.get("config") ?? {};
@ -52,7 +50,6 @@ class _TraktIntegrationState extends State<TraktIntegration> {
}); });
} }
// Save selected categories to the database
void _saveSelectedCategories() async { void _saveSelectedCategories() async {
final record = pb.authStore.record!; final record = pb.authStore.record!;
final config = record.get("config") ?? {}; final config = record.get("config") ?? {};
@ -120,7 +117,6 @@ class _TraktIntegrationState extends State<TraktIntegration> {
checkIsLoggedIn(); checkIsLoggedIn();
} }
// Show the "Add Category" dialog
Future<void> _showAddCategoryDialog() async { Future<void> _showAddCategoryDialog() async {
return showDialog( return showDialog(
context: context, context: context,
@ -167,7 +163,6 @@ class _TraktIntegrationState extends State<TraktIntegration> {
); );
} }
// Reorder categories
void _onReorder(int oldIndex, int newIndex) { void _onReorder(int oldIndex, int newIndex) {
setState(() { setState(() {
if (newIndex > oldIndex) { if (newIndex > oldIndex) {

View file

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

View file

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

View file

@ -159,7 +159,7 @@ class _HomeTabPageState extends State<HomeTabPage> {
return const Text("Loading"); return const Text("Loading");
} }
if (data.data.isEmpty) { if (data.data.isEmpty && widget.defaultLibraries != null) {
return Padding( return Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
bottom: 24, bottom: 24,