Light theme default + yt-dlp search fallback

This commit is contained in:
iliasmouhcine
2026-08-22 13:02:32 +01:00
commit 7704cae4a0
282 changed files with 52324 additions and 0 deletions
+481
View File
@@ -0,0 +1,481 @@
import 'dart:convert';
import 'dart:io';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
import '../models/channel_details.dart';
import '../models/subscription_channel.dart';
import '../models/video_item.dart';
import 'windows_channel_metadata_service.dart';
enum ChannelUploadSort {
newest,
popularity,
oldest,
}
class ChannelUploadBatch {
const ChannelUploadBatch({
required this.videos,
required this.hasMore,
});
final List<VideoItem> videos;
final bool hasMore;
}
class YoutubeService {
final YoutubeExplode _youtube = YoutubeExplode();
ChannelUploadsList? _channelUploadsPage;
String? _channelUploadsChannelId;
ChannelUploadSort? _channelUploadsSort;
bool _channelUploadsExhausted = false;
Future<List<VideoItem>> searchVideos(String query) async {
final normalizedQuery = query.trim();
if (normalizedQuery.isEmpty) {
return const [];
}
try {
final results = await _youtube.search.search(normalizedQuery);
return results
.map(_toVideoItem)
.toList(growable: false);
} catch (error) {
return _searchWithYtDlp(normalizedQuery);
}
}
Future<List<VideoItem>> _searchWithYtDlp(String query) async {
try {
final result = await Process.run('yt-dlp', [
'--flat-playlist',
'-j',
'ytsearch10:$query',
]);
if (result.exitCode != 0) {
return const [];
}
final lines = (result.stdout as String)
.split('\n')
.where((l) => l.trim().isNotEmpty)
.toList();
final videos = <VideoItem>[];
for (final line in lines) {
try {
final json = jsonDecode(line) as Map<String, dynamic>;
videos.add(VideoItem(
id: json['id'] as String? ?? '',
title: json['title'] as String? ?? '',
author: json['channel'] as String? ?? json['uploader'] as String? ?? '',
channelId: json['channel_id'] as String? ?? '',
duration: json['duration'] != null
? Duration(seconds: (json['duration'] as num).toInt())
: null,
viewCount: json['view_count'] as num?,
likeCount: null,
description: json['description'] as String? ?? '',
uploadDate: null,
thumbnailUrl: json['thumbnails'] != null
? (json['thumbnails'] as List).isNotEmpty
? (json['thumbnails'][0] as Map<String, dynamic>)['url'] as String? ??
'https://i.ytimg.com/vi/${json['id']}/hqdefault.jpg'
: 'https://i.ytimg.com/vi/${json['id']}/hqdefault.jpg'
: 'https://i.ytimg.com/vi/${json['id']}/hqdefault.jpg',
));
} catch (_) {
continue;
}
}
return videos;
} catch (_) {
return const [];
}
}
Future<VideoItem> getVideoDetails(
String videoId,
) async {
final video =
await _youtube.videos.get(videoId);
return _toVideoItem(video);
}
/// Resolves the uploader directly from the video.
///
/// This is intentionally independent from Google OAuth, so clicking the
/// channel works for search results, Radar recommendations, history and
/// favorites on both Windows and Android.
Future<SubscriptionChannel> getChannelForVideo(
String videoId,
) async {
final channel =
await _youtube.channels.getByVideo(videoId);
return _toSubscriptionChannel(channel);
}
Future<SubscriptionChannel> getChannel(
String channelId,
) async {
final channel =
await _youtube.channels.get(channelId);
return _toSubscriptionChannel(channel);
}
Future<ChannelDetails?> getChannelPreviewDetails(
String channelId,
) async {
if (channelId.trim().isEmpty) {
return null;
}
try {
final channel =
await _youtube.channels.get(channelId);
return ChannelDetails(
channelId: channel.id.value,
title: channel.title,
description: '',
thumbnailUrl: channel.logoUrl,
subscriberCount: channel.subscribersCount,
hiddenSubscriberCount:
channel.subscribersCount == null,
);
} catch (_) {
return null;
}
}
/// Public channel metadata that does NOT require Google OAuth.
///
/// youtube_explode_dart exposes the channel's approximate subscriber
/// count and the public About page exposes description, channel views,
/// join date and country.
Future<ChannelDetails> getPublicChannelDetails(
String channelId,
) async {
Channel? channel;
ChannelAbout? about;
try {
channel = await _youtube.channels.get(channelId);
} catch (error) {
// The Windows yt-dlp fallback below can still recover channel metadata.
// ignore: avoid_print
print(
'[CHANNEL-PUBLIC][18.5.2] Channel.get échoué: $error',
);
}
try {
about = await _youtube.channels.getAboutPage(channelId);
} catch (error) {
// Current YouTube layouts sometimes make the public About parser return
// nothing. This is expected; yt-dlp is our Windows fallback.
// ignore: avoid_print
print(
'[CHANNEL-PUBLIC][18.5.2] About indisponible: $error',
);
}
final publicDetails = ChannelDetails(
channelId: channel?.id.value ?? channelId,
title: channel?.title ?? about?.title ?? '',
description: about?.description ?? '',
thumbnailUrl: channel?.logoUrl ??
(about != null && about.thumbnails.isNotEmpty
? about.thumbnails.last.url.toString()
: ''),
joinDateLabel: about?.joinDate,
viewCount: about?.viewCount,
subscriberCount: channel?.subscribersCount,
hiddenSubscriberCount:
channel?.subscribersCount == null,
country: about?.country,
);
final windowsDetails =
await WindowsChannelMetadataService.instance
.getDetails(channelId);
final merged = _mergeChannelDetails(
publicDetails,
windowsDetails,
);
// ignore: avoid_print
print(
'[CHANNEL-META][18.5.2] id=$channelId '
'subs=${merged.subscriberCount ?? 'NA'} '
'views=${merged.viewCount ?? 'NA'} '
'videos=${merged.videoCount ?? 'NA'} '
'date=${merged.publishedAt?.toIso8601String() ?? merged.joinDateLabel ?? 'NA'}',
);
return merged;
}
ChannelDetails _mergeChannelDetails(
ChannelDetails primary,
ChannelDetails? fallback,
) {
if (fallback == null) {
return primary;
}
String chooseString(
String first,
String second,
) {
return first.trim().isNotEmpty
? first
: second;
}
return ChannelDetails(
channelId: chooseString(
primary.channelId,
fallback.channelId,
),
title: chooseString(
primary.title,
fallback.title,
),
description: chooseString(
primary.description,
fallback.description,
),
thumbnailUrl: chooseString(
primary.thumbnailUrl,
fallback.thumbnailUrl,
),
publishedAt:
primary.publishedAt ?? fallback.publishedAt,
joinDateLabel:
primary.joinDateLabel ?? fallback.joinDateLabel,
viewCount:
primary.viewCount ?? fallback.viewCount,
subscriberCount:
primary.subscriberCount ?? fallback.subscriberCount,
hiddenSubscriberCount:
primary.subscriberCount == null &&
fallback.subscriberCount == null,
videoCount:
primary.videoCount ?? fallback.videoCount,
country:
primary.country ?? fallback.country,
);
}
VideoSorting _videoSortingFor(ChannelUploadSort sort) {
return switch (sort) {
ChannelUploadSort.newest => VideoSorting.newest,
ChannelUploadSort.popularity => VideoSorting.popularity,
ChannelUploadSort.oldest => VideoSorting.oldest,
};
}
/// Loads ONE public channel page at a time.
///
/// youtube_explode_dart exposes ChannelUploadsList.nextPage(), so the UI
/// can keep the first ~30 entries fast and explicitly request more instead
/// of pretending that the first page is the whole channel.
Future<ChannelUploadBatch> getChannelUploadsPage(
String channelId, {
ChannelUploadSort sort = ChannelUploadSort.newest,
bool reset = false,
}) async {
final normalizedId = channelId.trim();
if (normalizedId.isEmpty) {
return const ChannelUploadBatch(
videos: <VideoItem>[],
hasMore: false,
);
}
final mustReset =
reset ||
_channelUploadsPage == null ||
_channelUploadsChannelId != normalizedId ||
_channelUploadsSort != sort;
ChannelUploadsList? page;
if (mustReset) {
_channelUploadsPage =
await _youtube.channels.getUploadsFromPage(
normalizedId,
videoSorting: _videoSortingFor(sort),
);
_channelUploadsChannelId = normalizedId;
_channelUploadsSort = sort;
_channelUploadsExhausted = false;
page = _channelUploadsPage;
} else {
if (_channelUploadsExhausted) {
return const ChannelUploadBatch(
videos: <VideoItem>[],
hasMore: false,
);
}
final current = _channelUploadsPage;
if (current == null) {
return const ChannelUploadBatch(
videos: <VideoItem>[],
hasMore: false,
);
}
page = await current.nextPage();
if (page == null) {
_channelUploadsExhausted = true;
return const ChannelUploadBatch(
videos: <VideoItem>[],
hasMore: false,
);
}
_channelUploadsPage = page;
}
final resolvedPage = page;
if (resolvedPage == null) {
_channelUploadsExhausted = true;
return const ChannelUploadBatch(
videos: <VideoItem>[],
hasMore: false,
);
}
final videos = resolvedPage
.map(_toVideoItem)
.toList(growable: false);
// YouTube channel pages are normally around 30 items. There is no public
// `hasNextPage` flag on ChannelUploadsList, only nextPage(). A short page
// is therefore considered final; an exact full page keeps the button.
// If the last page contains exactly 30 entries, the next click simply
// receives null and hides the button without losing any data.
final hasMore = videos.length >= 30;
if (!hasMore) {
_channelUploadsExhausted = true;
}
// ignore: avoid_print
print(
'[CHANNEL-VIDEOS][18.7.20] '
'channel=$normalizedId '
'sort=${sort.name} '
'pageVideos=${videos.length} '
'hasMore=$hasMore',
);
return ChannelUploadBatch(
videos: videos,
hasMore: hasMore,
);
}
/// Streams the full public upload catalogue when a caller really needs it.
/// The channel UI uses [getChannelUploadsPage] instead for progressive load.
Stream<VideoItem> getChannelUploads(
String channelId, {
ChannelUploadSort sort = ChannelUploadSort.newest,
}) async* {
var page = await _youtube.channels.getUploadsFromPage(
channelId,
videoSorting: _videoSortingFor(sort),
);
while (true) {
for (final video in page) {
yield _toVideoItem(video);
}
final next = await page.nextPage();
if (next == null) {
break;
}
page = next;
}
}
Future<String> getPlayableMuxedUrl(
String videoId,
) async {
// Stable progressive fallback used by the existing player.
final manifest =
await _youtube.videos.streams.getManifest(videoId);
final muxed =
manifest.muxed.toList(growable: false);
if (muxed.isEmpty) {
throw StateError(
'Aucun flux audio + vidéo compatible n’est disponible.',
);
}
final sorted = [...muxed]
..sort((a, b) {
final height =
b.videoResolution.height.compareTo(
a.videoResolution.height,
);
if (height != 0) {
return height;
}
return b.bitrate.bitsPerSecond.compareTo(
a.bitrate.bitsPerSecond,
);
});
return sorted.first.url.toString();
}
VideoItem _toVideoItem(Video video) {
return VideoItem(
id: video.id.value,
title: video.title,
author: video.author,
channelId: video.channelId.value,
duration: video.duration,
viewCount: video.engagement.viewCount,
likeCount: video.engagement.likeCount,
description: video.description,
uploadDate: video.uploadDate,
thumbnailUrl:
'https://i.ytimg.com/vi/${video.id.value}/hqdefault.jpg',
);
}
SubscriptionChannel _toSubscriptionChannel(
Channel channel,
) {
return SubscriptionChannel(
channelId: channel.id.value,
title: channel.title,
thumbnailUrl: channel.logoUrl,
);
}
void close() {
_youtube.close();
}
}