diff --git a/CHANGELOG.md b/CHANGELOG.md index 15079b80..bb0a5ee1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - 网页:解析网页时,若处在深色模式下,将难以看清的浅色转换为深色。 - 网页:现在在渲染网页时,不会变更文字换行位置。 - 看起来可能和网页端不同,但这是为了后续能够复制文本内容。 +- 网页:支持解析网易云音乐的外链播放器(仅显示歌曲信息并提供点击跳转功能)。 - 认证:支持使用UID和邮箱登录。 - 认证:登录失败时自动刷新验证码。 - 帖子:显示楼层用户的昵称和分组,回复时间调整为精确格式。 diff --git a/lib/utils/html/html_muncher.dart b/lib/utils/html/html_muncher.dart index 438a237f..8a96e3f0 100644 --- a/lib/utils/html/html_muncher.dart +++ b/lib/utils/html/html_muncher.dart @@ -1,12 +1,12 @@ import 'package:collection/collection.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; -import 'package:tsdm_client/constants/layout.dart'; import 'package:tsdm_client/extensions/build_context.dart'; import 'package:tsdm_client/extensions/universal_html.dart'; import 'package:tsdm_client/shared/models/models.dart'; import 'package:tsdm_client/utils/html/adaptive_color.dart'; import 'package:tsdm_client/utils/html/css_parser.dart'; +import 'package:tsdm_client/utils/html/netease_card.dart'; import 'package:tsdm_client/utils/html/types.dart'; import 'package:tsdm_client/utils/logger.dart'; import 'package:tsdm_client/utils/show_bottom_sheet.dart'; @@ -19,7 +19,6 @@ import 'package:tsdm_client/widgets/card/spoiler_card.dart'; import 'package:tsdm_client/widgets/network_indicator_image.dart'; import 'package:tsdm_client/widgets/quoted_text.dart'; import 'package:universal_html/html.dart' as uh; -import 'package:url_launcher/url_launcher_string.dart'; /// Use the same span to append line break. const emptySpan = TextSpan(text: '\n'); @@ -1005,25 +1004,7 @@ final class _Muncher with LoggerMixin { ?.namedGroup('id'); if (neteasePlayerId != null) { // Recognized netease player iframe. - // But only a song id here, nothing with song title/artist or other info. - // TODO: Check and get song info. - final url = 'https://music.163.com/#/song?id=$neteasePlayerId'; - return [ - WidgetSpan( - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () async => launchUrlString(url), - child: Card( - child: Padding( - padding: edgeInsetsL12T12R12B12, - child: Text('netease music here (id=$neteasePlayerId)'), - ), - ), - ), - ), - ), - ]; + return [WidgetSpan(child: NeteaseCard(neteasePlayerId))]; } return null; } diff --git a/lib/utils/html/netease_card.dart b/lib/utils/html/netease_card.dart new file mode 100644 index 00000000..85fe81ff --- /dev/null +++ b/lib/utils/html/netease_card.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:fpdart/fpdart.dart' as fp; +import 'package:tsdm_client/constants/layout.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; +import 'package:tsdm_client/shared/providers/providers.dart'; +import 'package:tsdm_client/utils/logger.dart'; +import 'package:universal_html/parsing.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + +/// Card to show a parsed netease iframe player. +class NeteaseCard extends StatefulWidget { + /// Constructor. + const NeteaseCard(this.id, {super.key}); + + /// Song id. + final String id; + + @override + State createState() => _NeteaseCardState(); +} + +class _NeteaseCardState extends State with LoggerMixin { + static const urlPrefix = 'https://music.163.com/song?id='; + + late String fallbackInfo; + + String _parseMusicInfo(String rawDocument) { + final doc = parseHtmlDocument(rawDocument); + final title = doc.querySelector('em.f-ff2')?.innerText; + final artist = doc.querySelector('p.des.s-fc4 > span')?.title; + + if (title == null || artist == null) { + error('failed to parse netease music info (id ${widget.id}), ' + 'title=$title, artist=$artist'); + return fallbackInfo; + } + + return '$artist - $title'; + } + + @override + void initState() { + super.initState(); + fallbackInfo = 'id: ${widget.id}'; + } + + @override + Widget build(BuildContext context) { + final infoStyle = Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.secondary, + ); + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () async => launchUrlString('$urlPrefix${widget.id}'), + child: Card( + child: Padding( + padding: edgeInsetsL12T12R12B12, + child: Row( + children: [ + Icon( + Icons.music_note_outlined, + color: Theme.of(context).colorScheme.secondary, + ), + sizedBoxW8H8, + Expanded( + child: FutureBuilder( + future: getIt + .get( + instanceName: ServiceKeys.noCookie, + ) + .get('$urlPrefix${widget.id}') + .run(), + builder: (context, snapshot) { + if (snapshot.hasData) { + return switch (snapshot.data!) { + fp.Left() => Text(fallbackInfo, style: infoStyle), + fp.Right(:final value) => Text( + _parseMusicInfo(value.data as String), + style: infoStyle, + ), + }; + } + + return sizedCircularProgressIndicator; + }, + ), + ), + ], + ), + ), + ), + ), + ); + } +}