add media naming and nfo normalization
This commit is contained in:
@@ -487,6 +487,11 @@ class ParsedMediaName {
|
|||||||
final int? season;
|
final int? season;
|
||||||
final int? episode;
|
final int? episode;
|
||||||
final bool isEpisode;
|
final bool isEpisode;
|
||||||
|
final String? resolution;
|
||||||
|
final String? source;
|
||||||
|
final String? videoCodec;
|
||||||
|
final String? audio;
|
||||||
|
final String? dynamicRange;
|
||||||
|
|
||||||
const ParsedMediaName({
|
const ParsedMediaName({
|
||||||
required this.title,
|
required this.title,
|
||||||
@@ -494,50 +499,82 @@ class ParsedMediaName {
|
|||||||
this.season,
|
this.season,
|
||||||
this.episode,
|
this.episode,
|
||||||
this.isEpisode = false,
|
this.isEpisode = false,
|
||||||
|
this.resolution,
|
||||||
|
this.source,
|
||||||
|
this.videoCodec,
|
||||||
|
this.audio,
|
||||||
|
this.dynamicRange,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ParsedMediaName.parse(String name) {
|
factory ParsedMediaName.parse(String name) {
|
||||||
var stem = name.replaceFirst(RegExp(r'\.[^.]+$'), '');
|
final stem = name.replaceFirst(RegExp(r'\.[^.]+$'), '');
|
||||||
stem = stem.replaceAll(RegExp(r'[\._]+'), ' ');
|
final normalized = stem.replaceAll(RegExp(r'[._]+'), ' ');
|
||||||
|
final episodeMatch = RegExp(
|
||||||
final episodeMatch =
|
r'\bS(\d{1,2})[ ._-]*E(\d{1,3})\b|\b(\d{1,2})x(\d{1,3})\b|第\s*(\d{1,2})\s*季\s*第?\s*(\d{1,3})\s*[集话]',
|
||||||
RegExp(
|
caseSensitive: false,
|
||||||
r'(?:S|第)\s*(\d{1,2})\s*(?:E|季\s*第?)\s*(\d{1,3})',
|
).firstMatch(normalized);
|
||||||
caseSensitive: false,
|
int? season;
|
||||||
).firstMatch(stem) ??
|
int? episode;
|
||||||
RegExp(r'(\d{1,2})x(\d{1,3})', caseSensitive: false).firstMatch(stem);
|
if (episodeMatch != null) {
|
||||||
final yearMatch = RegExp(r'(19\d{2}|20\d{2})').firstMatch(stem);
|
final groups = [
|
||||||
|
[1, 2],
|
||||||
final cutIndex =
|
[3, 4],
|
||||||
[
|
[5, 6],
|
||||||
episodeMatch?.start,
|
];
|
||||||
yearMatch?.start,
|
for (final pair in groups) {
|
||||||
RegExp(
|
final a = episodeMatch.group(pair[0]);
|
||||||
r'\b(2160p|1080p|720p|bluray|web[- ]?dl|hdtv|x264|x265|hevc)\b',
|
final b = episodeMatch.group(pair[1]);
|
||||||
|
if (a != null && b != null) {
|
||||||
|
season = int.tryParse(a);
|
||||||
|
episode = int.tryParse(b);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final episodeOnly = season == null
|
||||||
|
? RegExp(
|
||||||
|
r'\b(?:E|EP|Episode)[ ._-]*(\d{1,3})\b|第\s*(\d{1,3})\s*[集话]',
|
||||||
caseSensitive: false,
|
caseSensitive: false,
|
||||||
).firstMatch(stem)?.start,
|
).firstMatch(normalized)
|
||||||
].whereType<int>().fold<int?>(
|
: null;
|
||||||
null,
|
if (episodeOnly != null) {
|
||||||
(min, value) => min == null || value < min ? value : min,
|
season = 1;
|
||||||
);
|
episode = int.tryParse(
|
||||||
|
episodeOnly.group(1) ?? episodeOnly.group(2) ?? '',
|
||||||
var title = cutIndex == null ? stem : stem.substring(0, cutIndex);
|
);
|
||||||
|
}
|
||||||
|
String? first(String pattern) =>
|
||||||
|
RegExp(pattern, caseSensitive: false).firstMatch(normalized)?.group(0);
|
||||||
|
final yearMatch = RegExp(r'\b(19\d{2}|20\d{2})\b').firstMatch(normalized);
|
||||||
|
final boundary = RegExp(
|
||||||
|
r'\b(?:19\d{2}|20\d{2}|S\d{1,2}[ ._-]*E\d{1,3}|\d{1,2}x\d{1,3}|2160p|1080p|720p|480p|4k|web[- ]?(?:dl|rip)?|bluray|bdrip|remux|hdtv|dvd|x26[45]|h\.?26[45]|hevc|av1|aac|ac3|eac3|flac|truehd|dts|ddp|atmos|hdr|dv)\b',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
final boundaryMatch = boundary.firstMatch(normalized);
|
||||||
|
var title = boundaryMatch != null
|
||||||
|
? normalized.substring(0, boundaryMatch.start)
|
||||||
|
: normalized;
|
||||||
title = title
|
title = title
|
||||||
.replaceAll(RegExp(r'[\[\]\(\)]'), ' ')
|
.replaceAll(RegExp(r'[\[\]【】(){}]'), ' ')
|
||||||
.replaceAll(RegExp(r'\s+'), ' ')
|
.replaceAll(RegExp(r'\s+'), ' ')
|
||||||
.trim();
|
.trim();
|
||||||
if (title.isEmpty) title = stem.trim();
|
if (title.isEmpty) title = normalized.trim();
|
||||||
|
|
||||||
return ParsedMediaName(
|
return ParsedMediaName(
|
||||||
title: title,
|
title: title,
|
||||||
year: yearMatch == null ? null : int.tryParse(yearMatch.group(1)!),
|
year: yearMatch == null ? null : int.tryParse(yearMatch.group(1)!),
|
||||||
season: episodeMatch == null
|
season: season,
|
||||||
? null
|
episode: episode,
|
||||||
: int.tryParse(episodeMatch.group(1)!),
|
isEpisode: season != null && episode != null,
|
||||||
episode: episodeMatch == null
|
resolution: first(r'\b(?:2160p|1080p|720p|480p|4k)\b'),
|
||||||
? null
|
source: first(
|
||||||
: int.tryParse(episodeMatch.group(2)!),
|
r'\b(?:WEB[- ]?DL|WEBRip|BluRay|BDRip|REMUX|HDTV|DVD|UHD)\b',
|
||||||
isEpisode: episodeMatch != null,
|
),
|
||||||
|
videoCodec: first(r'\b(?:x26[45]|h\.?26[45]|HEVC|AV1|VC-1)\b'),
|
||||||
|
audio: first(
|
||||||
|
r'\b(?:Atmos|TrueHD|DTS(?:-HD)?|DDP?(?: ?[0-9.]+)?|AAC|FLAC)\b',
|
||||||
|
),
|
||||||
|
dynamicRange: first(r'\b(?:HDR10?\+?|Dolby[ .-]?Vision|DV)\b'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/legacy.dart';
|
import 'package:flutter_riverpod/legacy.dart';
|
||||||
|
|
||||||
@@ -311,21 +312,23 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
|
|||||||
Future<void> pendingPersistence = Future.value();
|
Future<void> pendingPersistence = Future.value();
|
||||||
|
|
||||||
Future<void> indexBatch(List<CloudFile> files) async {
|
Future<void> indexBatch(List<CloudFile> files) async {
|
||||||
final pending = files
|
final pending = files.toList();
|
||||||
.where((file) => !unique.containsKey(file.id))
|
|
||||||
.toList();
|
|
||||||
if (pending.isEmpty || _cancelScan) return;
|
if (pending.isEmpty || _cancelScan) return;
|
||||||
var next = 0;
|
var next = 0;
|
||||||
Future<void> worker() async {
|
Future<void> worker() async {
|
||||||
while (!_cancelScan && next < pending.length) {
|
while (!_cancelScan && next < pending.length) {
|
||||||
final file = pending[next++];
|
final file = pending[next++];
|
||||||
final fallback = MediaLibraryItem.fromFile(library.id, file);
|
final fallback = MediaLibraryItem.fromFile(library.id, file);
|
||||||
final item = await _recognizeMediaItem(
|
final existing = unique[file.id];
|
||||||
fallback,
|
var item = existing == null
|
||||||
tmdbApiKey,
|
? await _recognizeMediaItem(
|
||||||
proxyHost: tmdbProxyHost,
|
fallback,
|
||||||
proxyPort: tmdbProxyPort,
|
tmdbApiKey,
|
||||||
);
|
proxyHost: tmdbProxyHost,
|
||||||
|
proxyPort: tmdbProxyPort,
|
||||||
|
)
|
||||||
|
: existing.copyWith(file: file);
|
||||||
|
item = await _renameMatchedMediaFile(item);
|
||||||
unique[file.id] = item;
|
unique[file.id] = item;
|
||||||
completed += 1;
|
completed += 1;
|
||||||
final visible = unique.values.toList()
|
final visible = unique.values.toList()
|
||||||
@@ -347,7 +350,9 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
|
|||||||
_appendScanLog(
|
_appendScanLog(
|
||||||
item.tmdbID == null
|
item.tmdbID == null
|
||||||
? '已入库:${file.name}(未匹配 TMDB)'
|
? '已入库:${file.name}(未匹配 TMDB)'
|
||||||
: '已识别并入库:${file.name} → ${item.title}',
|
: item.file.name == file.name
|
||||||
|
? '已识别并入库:${file.name} → ${item.title}'
|
||||||
|
: '已识别并重命名:${file.name} → ${item.file.name}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -645,6 +650,114 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<MediaLibraryItem> _renameMatchedMediaFile(
|
||||||
|
MediaLibraryItem item,
|
||||||
|
) async {
|
||||||
|
if (_api == null || item.tmdbID == null || item.mediaKind == null) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
final parsed = ParsedMediaName.parse(item.file.name);
|
||||||
|
final extension = _extensionOf(item.file.name);
|
||||||
|
final year = item.year.isEmpty ? '' : '.${item.year}';
|
||||||
|
final episode = item.mediaKind == TMDBMediaKind.tv && parsed.isEpisode
|
||||||
|
? '.S${parsed.season!.toString().padLeft(2, '0')}E${parsed.episode!.toString().padLeft(2, '0')}'
|
||||||
|
: '';
|
||||||
|
final targetName =
|
||||||
|
'${_safeCloudName('${item.title}$year$episode')}${extension.isEmpty ? '' : '.$extension'}';
|
||||||
|
if (targetName == item.file.name) return item;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _api!.fsRename(item.file.id, targetName);
|
||||||
|
final updated = item.copyWith(file: item.file.copyWith(name: targetName));
|
||||||
|
await _writeNfoForRenamedMedia(updated, parsed);
|
||||||
|
return updated;
|
||||||
|
} catch (_) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _writeNfoForRenamedMedia(
|
||||||
|
MediaLibraryItem item,
|
||||||
|
ParsedMediaName parsed,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final detail = await _api!.fsDetail(item.file.id);
|
||||||
|
final parentID = _findStringDeep(detail, const [
|
||||||
|
'parentId',
|
||||||
|
'parent_id',
|
||||||
|
'parentFileId',
|
||||||
|
]);
|
||||||
|
if (parentID == null || parentID.isEmpty) return;
|
||||||
|
final isEpisode = item.mediaKind == TMDBMediaKind.tv && parsed.isEpisode;
|
||||||
|
final nfoName = isEpisode
|
||||||
|
? '${_safeCloudName(item.title)}.S${parsed.season!.toString().padLeft(2, '0')}E${parsed.episode!.toString().padLeft(2, '0')}.nfo'
|
||||||
|
: item.mediaKind == TMDBMediaKind.tv
|
||||||
|
? 'tvshow.nfo'
|
||||||
|
: 'movie.nfo';
|
||||||
|
final root = isEpisode
|
||||||
|
? 'episodedetails'
|
||||||
|
: item.mediaKind == TMDBMediaKind.tv
|
||||||
|
? 'tvshow'
|
||||||
|
: 'movie';
|
||||||
|
final technical = <String>[
|
||||||
|
if (parsed.resolution != null)
|
||||||
|
'<resolution>${_xml(parsed.resolution!)}</resolution>',
|
||||||
|
if (parsed.source != null) '<source>${_xml(parsed.source!)}</source>',
|
||||||
|
if (parsed.videoCodec != null)
|
||||||
|
'<codec>${_xml(parsed.videoCodec!)}</codec>',
|
||||||
|
if (parsed.dynamicRange != null)
|
||||||
|
'<hdr>${_xml(parsed.dynamicRange!)}</hdr>',
|
||||||
|
if (parsed.audio != null) '<audio>${_xml(parsed.audio!)}</audio>',
|
||||||
|
].join();
|
||||||
|
final episodeFields = isEpisode
|
||||||
|
? '<season>${parsed.season}</season><episode>${parsed.episode}</episode>'
|
||||||
|
: '';
|
||||||
|
final xml =
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>'
|
||||||
|
'<$root><uniqueid type="tmdb" default="true">${item.tmdbID}</uniqueid>'
|
||||||
|
'<title>${_xml(item.title)}</title>'
|
||||||
|
'<originaltitle>${_xml(item.originalTitle)}</originaltitle>'
|
||||||
|
'<year>${_xml(item.year)}</year>'
|
||||||
|
'<plot>${_xml(item.overview)}</plot>$episodeFields'
|
||||||
|
'<fileinfo><streamdetails><video>$technical</video></streamdetails></fileinfo>'
|
||||||
|
'</$root>';
|
||||||
|
final temp = File('${Directory.systemTemp.path}/$nfoName');
|
||||||
|
await temp.writeAsString(xml, flush: true);
|
||||||
|
try {
|
||||||
|
await _api!.fileUpload(
|
||||||
|
temp,
|
||||||
|
parentID: parentID,
|
||||||
|
contentType: 'application/xml',
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (await temp.exists()) await temp.delete();
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// File renaming remains successful if sidecar upload is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _extensionOf(String value) {
|
||||||
|
final index = value.lastIndexOf('.');
|
||||||
|
return index <= 0 ? '' : value.substring(index + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _safeCloudName(String value) {
|
||||||
|
final cleaned = value
|
||||||
|
.replaceAll(RegExp(r'[\\/:*?"<>|\x00-\x1F]'), ' ')
|
||||||
|
.replaceAll(RegExp(r'\s+'), ' ')
|
||||||
|
.trim();
|
||||||
|
final fallback = cleaned.isEmpty ? 'Untitled' : cleaned;
|
||||||
|
return fallback.length > 180 ? fallback.substring(0, 180).trim() : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _xml(String value) => value
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
|
||||||
MediaLibraryItem _itemFromTMDBCandidate(
|
MediaLibraryItem _itemFromTMDBCandidate(
|
||||||
MediaLibraryItem fallback,
|
MediaLibraryItem fallback,
|
||||||
Map<String, dynamic> candidate,
|
Map<String, dynamic> candidate,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ Future<void> showMediaPlayerDialog(
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
CloudFile file, {
|
CloudFile file, {
|
||||||
List<CloudFile> episodeCandidates = const [],
|
List<CloudFile> episodeCandidates = const [],
|
||||||
|
CloudFile? initialSubtitle,
|
||||||
}) async {
|
}) async {
|
||||||
Future<void> openExternalPlayer() async {
|
Future<void> openExternalPlayer() async {
|
||||||
await Future<void>.delayed(Duration.zero);
|
await Future<void>.delayed(Duration.zero);
|
||||||
@@ -34,6 +35,7 @@ Future<void> showMediaPlayerDialog(
|
|||||||
builder: (_) => MediaPlayerDialog(
|
builder: (_) => MediaPlayerDialog(
|
||||||
file: file,
|
file: file,
|
||||||
episodeCandidates: episodeCandidates,
|
episodeCandidates: episodeCandidates,
|
||||||
|
initialSubtitle: initialSubtitle,
|
||||||
onPlaybackFailure: openExternalPlayer,
|
onPlaybackFailure: openExternalPlayer,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -45,12 +47,14 @@ Future<void> showMediaPlayerDialog(
|
|||||||
class MediaPlayerDialog extends ConsumerStatefulWidget {
|
class MediaPlayerDialog extends ConsumerStatefulWidget {
|
||||||
final CloudFile file;
|
final CloudFile file;
|
||||||
final List<CloudFile> episodeCandidates;
|
final List<CloudFile> episodeCandidates;
|
||||||
|
final CloudFile? initialSubtitle;
|
||||||
final Future<void> Function()? onPlaybackFailure;
|
final Future<void> Function()? onPlaybackFailure;
|
||||||
|
|
||||||
const MediaPlayerDialog({
|
const MediaPlayerDialog({
|
||||||
super.key,
|
super.key,
|
||||||
required this.file,
|
required this.file,
|
||||||
this.episodeCandidates = const [],
|
this.episodeCandidates = const [],
|
||||||
|
this.initialSubtitle,
|
||||||
this.onPlaybackFailure,
|
this.onPlaybackFailure,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,6 +135,10 @@ class _MediaPlayerDialogState extends ConsumerState<MediaPlayerDialog> {
|
|||||||
.read(fileProvider.notifier)
|
.read(fileProvider.notifier)
|
||||||
.playbackUrl(_currentFile);
|
.playbackUrl(_currentFile);
|
||||||
await _player.open(Media(url.toString()), play: true);
|
await _player.open(Media(url.toString()), play: true);
|
||||||
|
final initialSubtitle = widget.initialSubtitle;
|
||||||
|
if (initialSubtitle != null) {
|
||||||
|
await _setDirectorySubtitle(initialSubtitle);
|
||||||
|
}
|
||||||
_videoDimensionFallbackTimer?.cancel();
|
_videoDimensionFallbackTimer?.cancel();
|
||||||
_videoDimensionFallbackTimer = Timer(const Duration(seconds: 4), () {
|
_videoDimensionFallbackTimer = Timer(const Duration(seconds: 4), () {
|
||||||
if (mounted && _loading && _error == null) {
|
if (mounted && _loading && _error == null) {
|
||||||
@@ -236,6 +244,16 @@ class _MediaPlayerDialogState extends ConsumerState<MediaPlayerDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _searchDirectorySubtitles() async {
|
||||||
|
final siblings = await ref
|
||||||
|
.read(fileProvider.notifier)
|
||||||
|
.siblingFiles(_currentFile);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_subtitleCandidates = _matchingSubtitles(_currentFile, siblings);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _loadLocalSubtitle() async {
|
Future<void> _loadLocalSubtitle() async {
|
||||||
final result = await FilePicker.pickFiles(
|
final result = await FilePicker.pickFiles(
|
||||||
type: FileType.custom,
|
type: FileType.custom,
|
||||||
@@ -343,6 +361,8 @@ class _MediaPlayerDialogState extends ConsumerState<MediaPlayerDialog> {
|
|||||||
player: _player,
|
player: _player,
|
||||||
directorySubtitles: _subtitleCandidates,
|
directorySubtitles: _subtitleCandidates,
|
||||||
onSelectDirectorySubtitle: _setDirectorySubtitle,
|
onSelectDirectorySubtitle: _setDirectorySubtitle,
|
||||||
|
onSearchDirectorySubtitles:
|
||||||
|
_searchDirectorySubtitles,
|
||||||
onLoadLocalSubtitle: _loadLocalSubtitle,
|
onLoadLocalSubtitle: _loadLocalSubtitle,
|
||||||
onToggleFullscreen: videoState.toggleFullscreen,
|
onToggleFullscreen: videoState.toggleFullscreen,
|
||||||
),
|
),
|
||||||
@@ -422,6 +442,7 @@ class _MediaPlaybackControls extends StatefulWidget {
|
|||||||
final Player player;
|
final Player player;
|
||||||
final List<CloudFile> directorySubtitles;
|
final List<CloudFile> directorySubtitles;
|
||||||
final Future<void> Function(CloudFile subtitle) onSelectDirectorySubtitle;
|
final Future<void> Function(CloudFile subtitle) onSelectDirectorySubtitle;
|
||||||
|
final Future<void> Function() onSearchDirectorySubtitles;
|
||||||
final Future<void> Function() onLoadLocalSubtitle;
|
final Future<void> Function() onLoadLocalSubtitle;
|
||||||
final Future<void> Function() onToggleFullscreen;
|
final Future<void> Function() onToggleFullscreen;
|
||||||
|
|
||||||
@@ -429,6 +450,7 @@ class _MediaPlaybackControls extends StatefulWidget {
|
|||||||
required this.player,
|
required this.player,
|
||||||
required this.directorySubtitles,
|
required this.directorySubtitles,
|
||||||
required this.onSelectDirectorySubtitle,
|
required this.onSelectDirectorySubtitle,
|
||||||
|
required this.onSearchDirectorySubtitles,
|
||||||
required this.onLoadLocalSubtitle,
|
required this.onLoadLocalSubtitle,
|
||||||
required this.onToggleFullscreen,
|
required this.onToggleFullscreen,
|
||||||
});
|
});
|
||||||
@@ -524,6 +546,7 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> {
|
|||||||
final tracks = widget.player.state.tracks.subtitle;
|
final tracks = widget.player.state.tracks.subtitle;
|
||||||
return ShadPopover(
|
return ShadPopover(
|
||||||
controller: _subtitlePopover,
|
controller: _subtitlePopover,
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
popover: (_) => _subtitlePopoverContent(tracks),
|
popover: (_) => _subtitlePopoverContent(tracks),
|
||||||
child: _menuButton('字幕', () async => _subtitlePopover.toggle()),
|
child: _menuButton('字幕', () async => _subtitlePopover.toggle()),
|
||||||
);
|
);
|
||||||
@@ -531,13 +554,22 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> {
|
|||||||
|
|
||||||
Widget _subtitlePopoverContent(List<SubtitleTrack> tracks) {
|
Widget _subtitlePopoverContent(List<SubtitleTrack> tracks) {
|
||||||
final selectedID = widget.player.state.track.subtitle.id;
|
final selectedID = widget.player.state.track.subtitle.id;
|
||||||
|
final cs = ShadTheme.of(context).colorScheme;
|
||||||
|
final itemStyle = TextStyle(fontSize: 13, color: cs.foreground);
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: 300,
|
width: 320,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text('字幕', style: TextStyle(fontWeight: FontWeight.w700)),
|
Text(
|
||||||
|
'字幕',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: cs.foreground,
|
||||||
|
),
|
||||||
|
),
|
||||||
if (tracks.isNotEmpty) ...[
|
if (tracks.isNotEmpty) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
@@ -550,6 +582,11 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> {
|
|||||||
final selected = track.id == selectedID;
|
final selected = track.id == selectedID;
|
||||||
return ShadButton.ghost(
|
return ShadButton.ghost(
|
||||||
size: ShadButtonSize.sm,
|
size: ShadButtonSize.sm,
|
||||||
|
height: 32,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
foregroundColor: selected ? cs.primary : cs.foreground,
|
||||||
|
textStyle: itemStyle,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await widget.player.setSubtitleTrack(track);
|
await widget.player.setSubtitleTrack(track);
|
||||||
_subtitlePopover.hide();
|
_subtitlePopover.hide();
|
||||||
@@ -575,10 +612,14 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (widget.directorySubtitles.isNotEmpty) ...[
|
if (widget.directorySubtitles.isNotEmpty) ...[
|
||||||
const Divider(height: 16),
|
const Divider(height: 14),
|
||||||
Text(
|
Text(
|
||||||
'同目录字幕 (${widget.directorySubtitles.length})',
|
'同目录字幕 (${widget.directorySubtitles.length})',
|
||||||
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700),
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: cs.mutedForeground,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
ConstrainedBox(
|
ConstrainedBox(
|
||||||
@@ -590,6 +631,11 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> {
|
|||||||
final subtitle = widget.directorySubtitles[index];
|
final subtitle = widget.directorySubtitles[index];
|
||||||
return ShadButton.ghost(
|
return ShadButton.ghost(
|
||||||
size: ShadButtonSize.sm,
|
size: ShadButtonSize.sm,
|
||||||
|
height: 32,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
foregroundColor: cs.foreground,
|
||||||
|
textStyle: itemStyle,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await widget.onSelectDirectorySubtitle(subtitle);
|
await widget.onSelectDirectorySubtitle(subtitle);
|
||||||
_subtitlePopover.hide();
|
_subtitlePopover.hide();
|
||||||
@@ -608,9 +654,28 @@ class _MediaPlaybackControlsState extends State<_MediaPlaybackControls> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const Divider(height: 16),
|
const Divider(height: 14),
|
||||||
ShadButton.ghost(
|
ShadButton.ghost(
|
||||||
size: ShadButtonSize.sm,
|
size: ShadButtonSize.sm,
|
||||||
|
height: 32,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
foregroundColor: cs.foreground,
|
||||||
|
textStyle: itemStyle,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
onPressed: () async {
|
||||||
|
await widget.onSearchDirectorySubtitles();
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
},
|
||||||
|
leading: const Icon(Icons.search_rounded, size: 15),
|
||||||
|
child: const Text('搜索同目录字幕'),
|
||||||
|
),
|
||||||
|
ShadButton.ghost(
|
||||||
|
size: ShadButtonSize.sm,
|
||||||
|
height: 32,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
foregroundColor: cs.foreground,
|
||||||
|
textStyle: itemStyle,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
_subtitlePopover.hide();
|
_subtitlePopover.hide();
|
||||||
await widget.onLoadLocalSubtitle();
|
await widget.onLoadLocalSubtitle();
|
||||||
|
|||||||
Reference in New Issue
Block a user