Flutter 全局音频播放单例实现(附完整源码)——基于 just_audio 的零依赖方案

简介

本篇文章是关于 Flutter 平台中音频播放单例控制器的实现,用于在 App 中全局控制音频播放,存储音频播放信息、目录信息。 在 App 的任何位置都可以直接通过该控制器获取当前播放的音频信息、播放目录信息。 备注:本文中介绍的控制器是本人根据自身需求量身定制的,不可能符合每个人的期待,建议有相关需求的朋友自己编写符合自身需求的控制器,本文只提供一个思路。

依赖

该控制器是对 Flutter 平台中的 just_audio 库的封装。

控制器音频存储类 AudioInformation

AudioInformation 用于存储每个需要播放的音频信息,以及与 json 对象的转换方法。

dart 复制代码
class AudioInformation {
  final String image;
  final String name;
  final String artist;
  final String url;
  final AudioSource audioSource;

  AudioInformation({
    required this.image,
    required this.name,
    required this.artist,
    required this.url,
  }) : audioSource = AudioSource.uri(Uri.parse(url));

  AudioInformation.fromJson(Map<String, dynamic> json)
    : image = json['image'] ?? 'assets/images/default.jpg',
      name = json['name'] ?? '',
      artist = json['artist'] ?? '',
      url = json['url'] ?? '',
      audioSource = AudioSource.uri(Uri.parse(json['url']));

  Map<String, dynamic> toJson() {
    return {'image': image, 'name': name, 'artist': artist, 'url': url};
  }

  dynamic toJsonDynamic() {
    return toJson();
  }
}

控制器 AudioPlayerUtil 的单例实现、变量介绍、初始化

  • 单例实现
    • AudioPlayerUtil._() 函数是 AudioPlayerUtil 的私有构造函数。
    • _instance 是 AudioPlayerUtil 的单例对象。
    • static AudioPlayerUtil of() 静态函数用于获取 AudioPlayerUtil 的单例。
  • 本地存储
    • AUDIO_STRORAGE_KEY 用于本地存储播放列表的 key。
    • AUDIO_INDEX_KEY 用于本地存储播放索引 key。
  • Future<void> init() 函数用于恢复 App 上次播放信息、播放列表以及初始化播放索引流。
  • _player 是 just_audio 的 AudioPlayer 对象。
  • 播放位置更新
    • _positionTimer 是位置更新定时器,在构造函数中进行初始化
    • _positionController 是位置更新通知流控制器
  • 定时关闭
    • _countdown 用于记录定时关闭播放器的倒计时秒数。
    • _countdownTimer 是倒计时定时器。
    • _countdownController 是倒计时通知流控制器。
  • _audioInformationList 是播放目录列表。
  • 初始化
    • AudioPlayerUtil.of().init();
  • _streamSubscriptions 用来记录流订阅信息
dart 复制代码
class AudioPlayerUtil {
  AudioPlayerUtil._() {
    _positionTimer = Timer.periodic(const Duration(milliseconds: 40), (timer) {
      if (_player.playing) {
        _positionController.add(_player.position);
      }
    });
  }

  static final AudioPlayerUtil _instance = AudioPlayerUtil._();

  static AudioPlayerUtil of() {
    return _instance;
  }

  /// 音频列表存储 key
  static final String AUDIO_STRORAGE_KEY = 'AUDIO_UTIL:AUDIO_LIST';

  /// 播放索引存储 key
  static final String AUDIO_INDEX_KEY = 'AUDIO_UTIL:AUDIO_INDEX';

  final List<StreamSubscription> _streamSubscriptions = [];

  /// 初始化
  Future<void> init() async {
    String audioListJson = LocalStorage.of().getString(AUDIO_STRORAGE_KEY);
    if (audioListJson.isNotEmpty) {
      List<dynamic> jsonList = jsonDecode(audioListJson);
      await setAudioSource(jsonList);
    }
    int index = LocalStorage.of().getInt(AUDIO_INDEX_KEY);
    await seek(Duration.zero, index: index);
    // 监听播放索引变化
    _streamSubscriptions.add(
      currentIndexStream.listen((index) {
        LocalStorage.of().setInt(AUDIO_INDEX_KEY, index ?? 0);
      }),
    );
    return Future.value(null);
  }

  // 播放器
  final AudioPlayer _player = AudioPlayer(handleInterruptions: false);

  // 位置更新定时器
  late final Timer _positionTimer;

  // 位置更新通知流控制器
  final StreamController<Duration> _positionController =
      StreamController<Duration>.broadcast();

  // 定时关闭倒计时
  int _countdown = -1;

  // 定时关闭倒计时定时器
  Timer? _countdownTimer;

  // 定时关闭倒计时通知流控制器
  final StreamController<int> _countdownController =
      StreamController<int>.broadcast();

  // 播放列表
  final List<AudioInformation> _audioInformationList = [];
  // ......
}

方法介绍

  • 设置播放源,该方法会清空现有播放目录。
    • Future<Duration?> setAudioSource(List<dynamic> jsonList, {int initialIndex = 0,})
      • jsonList 播放源列表
      • initialIndex 初始化索引
  • 添加播放源,该方法会将新的播放源添加在现有的播放源末尾。
    • Future<void> addAudioSource(List<dynamic> jsonList)
    • jsonList 播放源列表
  • 添加音频至下一个播放
    • void addNext(AudioInformation audio)
    • audio 音频信息
  • 移除指定索引的播放信息
    • void removeAt(int index)
  • 播放下一首:Future<void> next()
  • 播放上一首:Future<void> previous()
  • 指定播放音频位置和播放源:Future<void> seek(Duration position, {int? index})
  • 播放 or 播放指定音频:Future<void> play({int? index})
  • 暂停:Future<void> pause()
  • 停止:Future<void> stop(),该方法会释放解码器和播放音频所需的其他本地平台资源
  • 设置循环模式:Future<void> setLoopMode(LoopMode loopMode)
  • 设置随机播放:Future<void> setShuffleModeEnabled(bool randomModeEnabled)
  • 获取随机播放启用状态:bool get shuffleModeEnabled
  • 获取循环模式:LoopMode get loopMode
  • 获取播放列表:List<AudioInformation> get audioInformationList
  • 获取当前播放进度:Duration get position
  • 获取当前播放时长:Duration? get duration
  • 获取当前播放状态:bool get playing
  • 获取当前播放音频索引:int? get currentIndex
  • 获取当前播放音频:AudioInformation? get currentAudio
  • 获取播放器状态流:Stream<PlayerState> get playerStateStream
  • 获取当前索引流:Stream<int?> get currentIndexStream
  • 获取播放进度流:Stream<Duration> get positionStream
  • 获取播放时长流:Stream<Duration?> get durationStream
  • 设置定时关闭:void setCountdown(int countdownSeconds)
  • 取消定时关闭:void cancelCountdown()
  • 定时关闭倒计时通知流:Stream<int> get countdownStream
  • 释放播放器资源: Future<void> dispose()
dart 复制代码
class AudioPlayerUtil {
  // ......

  /// 设置播放源
  Future<Duration?> setAudioSource(
    List<dynamic> jsonList, {
    int initialIndex = 0,
  }) async {
    if (jsonList.isEmpty) {
      return Future.value(null);
    }
    // 转换为 AudioInformation 列表
    List<AudioInformation> audioList = jsonList
        .map((json) => AudioInformation.fromJson(json))
        .toList();
    _audioInformationList.clear();
    // 停止播放
    pause();
    stop();
    _player.clearAudioSources();
    _audioInformationList.addAll(audioList);
    // 本地存储
    LocalStorage.of().setString(
      AUDIO_STRORAGE_KEY,
      jsonEncode(_audioInformationList.map((it) => it.toJson()).toList()),
    );
    return await _player.setAudioSources(
      audioList.map((it) => it.audioSource).toList(),
      initialIndex: initialIndex,
      initialPosition: Duration.zero,
      shuffleOrder: DefaultShuffleOrder(), // Customise the shuffle algorithm
    );
  }

  /// 添加播放源
  Future<void> addAudioSource(List<dynamic> jsonList) async {
    if (jsonList.isEmpty) {
      return Future.value(null);
    }
    // 添加音频
    List<AudioInformation> audioList = jsonList
        .map((json) => AudioInformation.fromJson(json))
        .toList();
    _audioInformationList.addAll(audioList);
    // 本地存储
    LocalStorage.of().setString(
      AUDIO_STRORAGE_KEY,
      jsonEncode(_audioInformationList.map((it) => it.toJson()).toList()),
    );
    return await _player.addAudioSources(
      audioList.map((it) => it.audioSource).toList(),
    );
  }

  /// 添加到下一个播放
  void addNext(AudioInformation audio) {
    int nextIndex = (_player.currentIndex ?? -1) + 1;
    _audioInformationList.insert(nextIndex, audio);
    _player.insertAudioSource(
      nextIndex,
      _audioInformationList[nextIndex].audioSource,
    );
    // 本地存储
    LocalStorage.of().setString(
      AUDIO_STRORAGE_KEY,
      jsonEncode(_audioInformationList.map((it) => it.toJson()).toList()),
    );
  }

  /// 移除指定索引的播放信息
  void removeAt(int index) {
    if (index < 0 ||
        index >= _audioInformationList.length ||
        index == _player.currentIndex) {
      return;
    }
    _audioInformationList.removeAt(index);
    _player.removeAudioSourceAt(index);
  }

  /// 播放下一首
  Future<void> next() async {
    if (_audioInformationList.isEmpty) {
      return Future.value();
    }
    if (_player.hasNext) {
      return await _player.seekToNext();
    } else {
      return await _player.seek(Duration.zero, index: 0);
    }
  }

  /// 播放上一首
  Future<void> previous() async {
    if (_audioInformationList.isEmpty) {
      return Future.value();
    }
    if (_player.hasPrevious) {
      return await _player.seekToPrevious();
    } else {
      return await _player.seek(
        Duration.zero,
        index: _audioInformationList.length - 1,
      );
    }
  }

  /// 指定播放音频位置和播放源
  Future<void> seek(Duration position, {int? index}) async {
    return await _player.seek(position, index: index);
  }

  /// 播放 or 播放指定音频
  Future<void> play({int? index}) async {
    if (null == index) {
      return await _player.play();
    }
    if (index < 0 || index >= _audioInformationList.length) {
      return;
    }
    await seek(Duration.zero, index: index);
    return await _player.play();
  }

  /// 暂停
  Future<void> pause() async {
    return await _player.pause();
  }

  /// 停止
  Future<void> stop() async {
    return await _player.stop();
  }

  /// 设置循环模式
  Future<void> setLoopMode(LoopMode loopMode) async {
    return await _player.setLoopMode(loopMode);
  }

  /// 设置随机播放
  Future<void> setShuffleModeEnabled(bool randomModeEnabled) async {
    return await _player.setShuffleModeEnabled(randomModeEnabled);
  }

  /// 获取随机播放
  bool get shuffleModeEnabled => _player.shuffleModeEnabled;

  /// 获取循环模式
  LoopMode get loopMode => _player.loopMode;

  /// 获取播放列表
  List<AudioInformation> get audioInformationList => _audioInformationList;

  /// 获取当前播放进度
  Duration get position => _player.position;

  /// 获取当前播放时长
  Duration? get duration => _player.duration;

  /// 获取当前播放状态
  bool get playing => _player.playing;

  /// 获取当前播放音频索引
  int? get currentIndex => _player.currentIndex;

  /// 获取当前播放音频
  AudioInformation? get currentAudio {
    if (null == _player.currentIndex) {
      return null;
    }
    return _audioInformationList[_player.currentIndex!];
  }

  /// 获取播放器状态流
  Stream<PlayerState> get playerStateStream => _player.playerStateStream;

  /// 获取当前索引流
  Stream<int?> get currentIndexStream => _player.currentIndexStream;

  /// 获取播放进度流
  Stream<Duration> get positionStream {
    // return _player.positionStream;
    return _positionController.stream;
  }

  /// 获取播放时长流
  Stream<Duration?> get durationStream => _player.durationStream;

  /// 设置定时关闭
  void setCountdown(int countdownSeconds) {
    _countdown = countdownSeconds;
    _countdownTimer?.cancel();
    _countdownController.add(_countdown);
    _countdownTimer = Timer.periodic(Duration(seconds: 1), (timer) {
      _countdown = _countdown - 1;
      _countdownController.add(_countdown);
      if (_countdown < 0) {
        // 暂停播放
        pause();
        stop();
        timer.cancel();
      }
    });
  }

  /// 取消定时关闭
  void cancelCountdown() {
    _countdown = -1;
    _countdownController.add(_countdown);
    _countdownTimer?.cancel();
  }

  /// 倒计时流
  Stream<int> get countdownStream => _countdownController.stream;

  /// 释放播放器资源
  Future<void> dispose() async {
    while (_streamSubscriptions.isNotEmpty) {
      _streamSubscriptions.removeLast().cancel();
    }
    // 停止位置更新
    _positionTimer.cancel();
    _positionController.close();
    // 停止倒计时
    _countdownTimer?.cancel();
    _countdownController.close();
    // 先停止并清除所有音频源
    await _player.pause();
    await _player.stop();
    await _player.clearAudioSources();
    // 释放播放器资源
    await _player.dispose();
    // 清理播放列表
    _audioInformationList.clear();
  }
}

控制器中使用的本地存储工具类 LocalStorage

LocalStorage 是对 Flutter 平台中的 shared_preferences: ^2.5.3 库的封装。 使用前要进行初始化,LocalStorage.of().init();

dart 复制代码
class LocalStorage {
  LocalStorage._();

  static final LocalStorage _instance = LocalStorage._();

  SharedPreferences? _prefs;

  static LocalStorage of() {
    return _instance;
  }

  // 初始化
  static Future<LocalStorage> init() async {
    _instance._prefs = await SharedPreferences.getInstance();
    return Future.value(_instance);
  }

  /// 数据存储
  Future<bool?> setInt(String key, int value) async {
    return await _prefs!.setInt(key, value);
  }

  Future<bool?> setBool(String key, bool value) async {
    return await _prefs!.setBool(key, value);
  }

  Future<bool?> setDouble(String key, double value) async {
    return await _prefs!.setDouble(key, value);
  }

  Future<bool?> setString(String key, String value) async {
    return await _prefs!.setString(key, value);
  }

  Future<bool?> setStringList(String key, List<String> value) async {
    return await _prefs!.setStringList(key, value);
  }

  /// 数据获取
  int getInt(String key) {
    return _prefs!.getInt(key) ?? 0;
  }

  double getDouble(String key) {
    return _prefs!.getDouble(key) ?? 0;
  }

  bool getBool(String key) {
    return _prefs!.getBool(key) ?? false;
  }

  String getString(String key) {
    return _prefs!.getString(key) ?? '';
  }

  List<String> getStringList(String key) {
    return _prefs!.getStringList(key) ?? [];
  }

  bool containsKey(String key) {
    return _prefs!.containsKey(key);
  }

  void remove(String key) {
    _prefs!.remove(key);
  }

  void clear() {
    _prefs!.clear();
  }

}

附上源码

dart 复制代码
import 'dart:async';
import 'dart:convert';

import 'package:ephemeris_mobile/utils/LocalStorage.dart';
import 'package:just_audio/just_audio.dart';

class AudioInformation {
  final String image;
  final String name;
  final String artist;
  final String url;
  final AudioSource audioSource;

  AudioInformation({
    required this.image,
    required this.name,
    required this.artist,
    required this.url,
  }) : audioSource = AudioSource.uri(Uri.parse(url));

  AudioInformation.fromJson(Map<String, dynamic> json)
    : image = json['image'] ?? 'assets/images/default.jpg',
      name = json['name'] ?? '',
      artist = json['artist'] ?? '',
      url = json['url'] ?? '',
      audioSource = AudioSource.uri(Uri.parse(json['url']));

  Map<String, dynamic> toJson() {
    return {'image': image, 'name': name, 'artist': artist, 'url': url};
  }

  dynamic toJsonDynamic() {
    return toJson();
  }
}

class AudioPlayerUtil {
  AudioPlayerUtil._() {
    _positionTimer = Timer.periodic(const Duration(milliseconds: 40), (timer) {
      if (_player.playing) {
        _positionController.add(_player.position);
      }
    });
  }

  static final AudioPlayerUtil _instance = AudioPlayerUtil._();

  static AudioPlayerUtil of() {
    return _instance;
  }

  /// 音频列表存储 key
  static final String AUDIO_STRORAGE_KEY = 'AUDIO_UTIL:AUDIO_LIST';

  /// 播放索引存储 key
  static final String AUDIO_INDEX_KEY = 'AUDIO_UTIL:AUDIO_INDEX';

  final List<StreamSubscription> _streamSubscriptions = [];

  /// 初始化
  Future<void> init() async {
    String audioListJson = LocalStorage.of().getString(AUDIO_STRORAGE_KEY);
    if (audioListJson.isNotEmpty) {
      List<dynamic> jsonList = jsonDecode(audioListJson);
      await setAudioSource(jsonList);
    }
    int index = LocalStorage.of().getInt(AUDIO_INDEX_KEY);
    await seek(Duration.zero, index: index);
    // 监听播放索引变化
    _streamSubscriptions.add(
      currentIndexStream.listen((index) {
        LocalStorage.of().setInt(AUDIO_INDEX_KEY, index ?? 0);
      }),
    );
    return Future.value(null);
  }

  // 播放器
  final AudioPlayer _player = AudioPlayer(handleInterruptions: false);

  // 位置更新定时器
  late final Timer _positionTimer;

  // 位置更新通知流控制器
  final StreamController<Duration> _positionController =
      StreamController<Duration>.broadcast();

  // 定时关闭倒计时
  int _countdown = -1;

  // 定时关闭倒计时定时器
  Timer? _countdownTimer;

  // 定时关闭倒计时通知流控制器
  final StreamController<int> _countdownController =
      StreamController<int>.broadcast();

  // 播放列表
  final List<AudioInformation> _audioInformationList = [];

  /// 设置播放源
  Future<Duration?> setAudioSource(
    List<dynamic> jsonList, {
    int initialIndex = 0,
  }) async {
    if (jsonList.isEmpty) {
      return Future.value(null);
    }
    // 转换为 AudioInformation 列表
    List<AudioInformation> audioList = jsonList
        .map((json) => AudioInformation.fromJson(json))
        .toList();
    _audioInformationList.clear();
    // 停止播放
    pause();
    stop();
    _player.clearAudioSources();
    _audioInformationList.addAll(audioList);
    // 本地存储
    LocalStorage.of().setString(
      AUDIO_STRORAGE_KEY,
      jsonEncode(_audioInformationList.map((it) => it.toJson()).toList()),
    );
    return await _player.setAudioSources(
      audioList.map((it) => it.audioSource).toList(),
      initialIndex: initialIndex,
      initialPosition: Duration.zero,
      shuffleOrder: DefaultShuffleOrder(), // Customise the shuffle algorithm
    );
  }

  /// 添加播放源
  Future<void> addAudioSource(List<dynamic> jsonList) async {
    if (jsonList.isEmpty) {
      return Future.value(null);
    }
    // 添加音频
    List<AudioInformation> audioList = jsonList
        .map((json) => AudioInformation.fromJson(json))
        .toList();
    _audioInformationList.addAll(audioList);
    // 本地存储
    LocalStorage.of().setString(
      AUDIO_STRORAGE_KEY,
      jsonEncode(_audioInformationList.map((it) => it.toJson()).toList()),
    );
    return await _player.addAudioSources(
      audioList.map((it) => it.audioSource).toList(),
    );
  }

  /// 添加到下一个播放
  void addNext(AudioInformation audio) {
    int nextIndex = (_player.currentIndex ?? -1) + 1;
    _audioInformationList.insert(nextIndex, audio);
    _player.insertAudioSource(
      nextIndex,
      _audioInformationList[nextIndex].audioSource,
    );
    // 本地存储
    LocalStorage.of().setString(
      AUDIO_STRORAGE_KEY,
      jsonEncode(_audioInformationList.map((it) => it.toJson()).toList()),
    );
  }

  /// 移除指定索引的播放信息
  void removeAt(int index) {
    if (index < 0 ||
        index >= _audioInformationList.length ||
        index == _player.currentIndex) {
      return;
    }
    _audioInformationList.removeAt(index);
    _player.removeAudioSourceAt(index);
  }

  /// 播放下一首
  Future<void> next() async {
    if (_audioInformationList.isEmpty) {
      return Future.value();
    }
    if (_player.hasNext) {
      return await _player.seekToNext();
    } else {
      return await _player.seek(Duration.zero, index: 0);
    }
  }

  /// 播放上一首
  Future<void> previous() async {
    if (_audioInformationList.isEmpty) {
      return Future.value();
    }
    if (_player.hasPrevious) {
      return await _player.seekToPrevious();
    } else {
      return await _player.seek(
        Duration.zero,
        index: _audioInformationList.length - 1,
      );
    }
  }

  /// 指定播放音频位置和播放源
  Future<void> seek(Duration position, {int? index}) async {
    return await _player.seek(position, index: index);
  }

  /// 播放 or 播放指定音频
  Future<void> play({int? index}) async {
    if (null == index) {
      return await _player.play();
    }
    if (index < 0 || index >= _audioInformationList.length) {
      return;
    }
    await seek(Duration.zero, index: index);
    return await _player.play();
  }

  /// 暂停
  Future<void> pause() async {
    return await _player.pause();
  }

  /// 停止
  Future<void> stop() async {
    return await _player.stop();
  }

  /// 设置循环模式
  Future<void> setLoopMode(LoopMode loopMode) async {
    return await _player.setLoopMode(loopMode);
  }

  /// 设置随机播放
  Future<void> setShuffleModeEnabled(bool randomModeEnabled) async {
    return await _player.setShuffleModeEnabled(randomModeEnabled);
  }

  /// 获取随机播放
  bool get shuffleModeEnabled => _player.shuffleModeEnabled;

  /// 获取循环模式
  LoopMode get loopMode => _player.loopMode;

  /// 获取播放列表
  List<AudioInformation> get audioInformationList => _audioInformationList;

  /// 获取当前播放进度
  Duration get position => _player.position;

  /// 获取当前播放时长
  Duration? get duration => _player.duration;

  /// 获取当前播放状态
  bool get playing => _player.playing;

  /// 获取当前播放音频索引
  int? get currentIndex => _player.currentIndex;

  /// 获取当前播放音频
  AudioInformation? get currentAudio {
    if (null == _player.currentIndex) {
      return null;
    }
    return _audioInformationList[_player.currentIndex!];
  }

  /// 获取播放器状态流
  Stream<PlayerState> get playerStateStream => _player.playerStateStream;

  /// 获取当前索引流
  Stream<int?> get currentIndexStream => _player.currentIndexStream;

  /// 获取播放进度流
  Stream<Duration> get positionStream {
    // return _player.positionStream;
    return _positionController.stream;
  }

  /// 获取播放时长流
  Stream<Duration?> get durationStream => _player.durationStream;

  /// 设置定时关闭
  void setCountdown(int countdownSeconds) {
    _countdown = countdownSeconds;
    _countdownTimer?.cancel();
    _countdownController.add(_countdown);
    _countdownTimer = Timer.periodic(Duration(seconds: 1), (timer) {
      _countdown = _countdown - 1;
      _countdownController.add(_countdown);
      if (_countdown < 0) {
        // 暂停播放
        pause();
        stop();
        timer.cancel();
      }
    });
  }

  /// 取消定时关闭
  void cancelCountdown() {
    _countdown = -1;
    _countdownController.add(_countdown);
    _countdownTimer?.cancel();
  }

  /// 倒计时流
  Stream<int> get countdownStream => _countdownController.stream;

  /// 释放播放器资源
  Future<void> dispose() async {
    while (_streamSubscriptions.isNotEmpty) {
      _streamSubscriptions.removeLast().cancel();
    }
    // 停止位置更新
    _positionTimer.cancel();
    _positionController.close();
    // 停止倒计时
    _countdownTimer?.cancel();
    _countdownController.close();
    // 先停止并清除所有音频源
    await _player.pause();
    await _player.stop();
    await _player.clearAudioSources();
    // 释放播放器资源
    await _player.dispose();
    // 清理播放列表
    _audioInformationList.clear();
  }
}
相关推荐
im_AMBER6 小时前
React 09
前端·javascript·笔记·学习·react.js·前端框架
用户4099322502126 小时前
快速入门Vue模板里的JS表达式有啥不能碰?计算属性为啥比方法更能打?
前端·ai编程·trae
非专业程序员6 小时前
HarfBuzz 实战:五大核心API 实例详解【附iOS/Swift实战示例】
前端·程序员
DreamMachine7 小时前
Flutter 开发的极简风格音乐播放器
前端·flutter
前端老宋Running7 小时前
前端防抖与节流一篇讲清楚
前端·面试
ejinxian7 小时前
Rust UI 框架GPUI 与 Electron 的对比
前端·javascript·electron
小马哥learn7 小时前
Vue3 + Electron + Node.js 桌面项目完整开发指南
前端·javascript·electron
znhy@1237 小时前
CSS3属性(三)
前端·css·css3
凌泽7 小时前
「让规范驱动代码」——我如何用 Cursor + Spec Kit 在5小时内完成一个智能学习分析平台的
前端