mirror of
https://github.com/madari-media/madari-oss.git
synced 2026-05-15 12:02:10 +00:00
fix: added external subtitles
This commit is contained in:
parent
e772a2b815
commit
2a2874fb3a
15 changed files with 846 additions and 402 deletions
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class SeasonSelector extends StatelessWidget {
|
||||||
|
const SeasonSelector({
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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) {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 [];
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue