314 lines
10 KiB
Dart
314 lines
10 KiB
Dart
// lib/services/audio_player_handler.dart
|
||
import 'dart:io';
|
||
import 'dart:typed_data';
|
||
import 'package:flutter/foundation.dart';
|
||
import 'package:path_provider/path_provider.dart';
|
||
import 'package:audio_service/audio_service.dart' as audio_service;
|
||
import 'package:crypto/crypto.dart';
|
||
import 'dart:convert';
|
||
|
||
import 'playback_service.dart';
|
||
import 'audio_service.dart';
|
||
import '../utils/file_provider_utils.dart';
|
||
|
||
/// 音频服务处理器:连接 media_kit 播放引擎与 Android 系统媒体会话
|
||
/// 职责:发布播放状态到系统通知栏,接收系统媒体按钮事件
|
||
/// 它不拥有 Player 实例,所有播放控制均委托给 PlaybackService
|
||
class AudioPlayerHandler extends audio_service.BaseAudioHandler {
|
||
final PlaybackService _playback = PlaybackService();
|
||
|
||
// 当前媒体信息
|
||
String? _currentId;
|
||
String? _currentTitle;
|
||
String? _currentArtist;
|
||
Duration _currentPosition = Duration.zero;
|
||
DateTime _lastPublishTime = DateTime.now();
|
||
static const Duration _publishInterval = Duration(milliseconds: 500);
|
||
String? _currentArtworkPath;
|
||
bool _isPublishing = false;
|
||
|
||
AudioPlayerHandler() {
|
||
_bindToPlayer();
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// 绑定播放器状态流
|
||
// ═══════════════════════════════════════════════════════════════
|
||
void _bindToPlayer() {
|
||
final player = _playback.player;
|
||
|
||
player.stream.playing.listen((playing) {
|
||
_publishPlaybackState(
|
||
playing: playing,
|
||
position: _currentPosition,
|
||
duration: player.state.duration,
|
||
);
|
||
});
|
||
|
||
player.stream.position.listen((position) {
|
||
_currentPosition = position;
|
||
final now = DateTime.now();
|
||
if (now.difference(_lastPublishTime) >= _publishInterval) {
|
||
_lastPublishTime = now;
|
||
_publishPlaybackState(
|
||
playing: player.state.playing,
|
||
position: position,
|
||
duration: player.state.duration,
|
||
onlyPosition: true,
|
||
);
|
||
}
|
||
});
|
||
|
||
player.stream.duration.listen((duration) {
|
||
if (_currentId != null && _currentTitle != null) {
|
||
_updateMediaItem(
|
||
id: _currentId!,
|
||
title: _currentTitle!,
|
||
artist: _currentArtist!,
|
||
duration: duration,
|
||
);
|
||
}
|
||
_publishPlaybackState(
|
||
playing: player.state.playing,
|
||
position: _currentPosition,
|
||
duration: duration,
|
||
);
|
||
});
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// 外部同步接口(由 AudioService 调用)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
void syncState(Song? song) {
|
||
if (song == null) {
|
||
_currentId = null;
|
||
_currentTitle = null;
|
||
_currentArtist = null;
|
||
return;
|
||
}
|
||
|
||
_updateMediaItem(
|
||
id: song.id,
|
||
title: song.title,
|
||
artist: song.artist,
|
||
artwork: song.artwork,
|
||
);
|
||
|
||
final player = _playback.player;
|
||
_publishPlaybackState(
|
||
playing: player.state.playing,
|
||
position: _currentPosition,
|
||
duration: player.state.duration,
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// 状态发布
|
||
// ═══════════════════════════════════════════════════════════════
|
||
void _publishPlaybackState({
|
||
required bool playing,
|
||
required Duration position,
|
||
required Duration duration,
|
||
bool onlyPosition = false,
|
||
}) {
|
||
if (_isPublishing) return;
|
||
_isPublishing = true;
|
||
|
||
try {
|
||
final controls = _buildControls(playing);
|
||
final state = audio_service.PlaybackState(
|
||
controls: controls,
|
||
processingState: duration.inMilliseconds > 0
|
||
? audio_service.AudioProcessingState.ready
|
||
: audio_service.AudioProcessingState.idle,
|
||
playing: playing,
|
||
updatePosition: position,
|
||
updateTime: DateTime.now(),
|
||
systemActions: const {audio_service.MediaAction.seek},
|
||
);
|
||
playbackState.add(state);
|
||
} finally {
|
||
_isPublishing = false;
|
||
}
|
||
}
|
||
|
||
List<audio_service.MediaControl> _buildControls(bool playing) {
|
||
return [
|
||
const audio_service.MediaControl(
|
||
androidIcon: 'drawable/ic_previous',
|
||
label: '上一曲',
|
||
action: audio_service.MediaAction.skipToPrevious,
|
||
),
|
||
audio_service.MediaControl(
|
||
androidIcon: playing ? 'drawable/ic_pause' : 'drawable/ic_play',
|
||
label: playing ? '暂停' : '播放',
|
||
action: audio_service.MediaAction.playPause,
|
||
),
|
||
const audio_service.MediaControl(
|
||
androidIcon: 'drawable/ic_next',
|
||
label: '下一曲',
|
||
action: audio_service.MediaAction.skipToNext,
|
||
),
|
||
];
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// 媒体信息更新
|
||
// ═══════════════════════════════════════════════════════════════
|
||
void _updateMediaItem({
|
||
required String id,
|
||
required String title,
|
||
required String artist,
|
||
Duration? duration,
|
||
Uint8List? artwork,
|
||
}) {
|
||
_currentId = id;
|
||
_currentTitle = title;
|
||
_currentArtist = artist;
|
||
|
||
final position = _playback.player.state.position;
|
||
|
||
_handleArtwork(id, artwork).then((artUri) {
|
||
final current = mediaItem.value;
|
||
if (current != null && current.artUri != artUri) {
|
||
mediaItem.add(audio_service.MediaItem(
|
||
id: current.id,
|
||
title: current.title,
|
||
artist: current.artist,
|
||
duration: current.duration,
|
||
artUri: artUri,
|
||
extras: current.extras,
|
||
));
|
||
}
|
||
});
|
||
|
||
mediaItem.add(audio_service.MediaItem(
|
||
id: id,
|
||
title: title,
|
||
artist: artist,
|
||
duration: duration ?? _playback.player.state.duration,
|
||
extras: {'position': position.inMilliseconds},
|
||
));
|
||
}
|
||
|
||
Future<Uri?> _handleArtwork(String id, Uint8List? artwork) async {
|
||
if (artwork == null || artwork.isEmpty) return null;
|
||
try {
|
||
final dir = await getApplicationDocumentsDirectory();
|
||
final artworkDir = Directory('${dir.path}/artworks');
|
||
if (!await artworkDir.exists()) {
|
||
await artworkDir.create(recursive: true);
|
||
}
|
||
final bytes = utf8.encode(id);
|
||
final digest = md5.convert(bytes);
|
||
final fileName = '$digest.jpg';
|
||
final path = '${artworkDir.path}/$fileName';
|
||
final file = File(path);
|
||
if (await file.exists()) {
|
||
final existingBytes = await file.readAsBytes();
|
||
if (existingBytes.length == artwork.length &&
|
||
existingBytes.hashCode == artwork.hashCode) {
|
||
_currentArtworkPath = path;
|
||
return await FileProviderUtils.getContentUri(file);
|
||
}
|
||
}
|
||
await file.writeAsBytes(artwork);
|
||
_currentArtworkPath = path;
|
||
return await FileProviderUtils.getContentUri(file);
|
||
} catch (e) {
|
||
debugPrint('⚠️ [handler] artwork handling failed: $e');
|
||
return null;
|
||
}
|
||
}
|
||
|
||
void updateNotification({
|
||
required String id,
|
||
required String title,
|
||
required String artist,
|
||
Uint8List? artwork,
|
||
}) {
|
||
_updateMediaItem(id: id, title: title, artist: artist, artwork: artwork);
|
||
final player = _playback.player;
|
||
_publishPlaybackState(
|
||
playing: player.state.playing,
|
||
position: _currentPosition,
|
||
duration: player.state.duration,
|
||
);
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// audio_service 控制命令(全部委托给 PlaybackService)
|
||
// ═══════════════════════════════════════════════════════════════
|
||
@override
|
||
Future<void> play() async {
|
||
debugPrint('▶️ [handler] play() called');
|
||
await _playback.resume();
|
||
}
|
||
|
||
@override
|
||
Future<void> pause() async {
|
||
debugPrint('⏸️ [handler] pause() called');
|
||
await _playback.pause();
|
||
}
|
||
|
||
@override
|
||
Future<void> stop() async {
|
||
debugPrint('⏹️ [handler] stop() called');
|
||
await _playback.stop();
|
||
}
|
||
|
||
@override
|
||
Future<void> seek(Duration position) async {
|
||
debugPrint('⏩ [handler] seek() called: $position');
|
||
await _playback.seek(position);
|
||
_currentPosition = position;
|
||
final player = _playback.player;
|
||
_publishPlaybackState(
|
||
playing: player.state.playing,
|
||
position: position,
|
||
duration: player.state.duration,
|
||
onlyPosition: true,
|
||
);
|
||
}
|
||
|
||
@override
|
||
Future<void> skipToNext() async {
|
||
debugPrint('⏭️ [handler] skipToNext() called');
|
||
AudioService().next();
|
||
}
|
||
|
||
@override
|
||
Future<void> skipToPrevious() async {
|
||
debugPrint('⏮️ [handler] skipToPrevious() called');
|
||
AudioService().previous();
|
||
}
|
||
|
||
@override
|
||
Future<void> click(
|
||
[audio_service.MediaButton button =
|
||
audio_service.MediaButton.media]) async {
|
||
debugPrint('🎯 [handler] click() called with button: ${button.name}');
|
||
switch (button) {
|
||
case audio_service.MediaButton.next:
|
||
await skipToNext();
|
||
break;
|
||
case audio_service.MediaButton.previous:
|
||
await skipToPrevious();
|
||
break;
|
||
case audio_service.MediaButton.media:
|
||
if (_playback.player.state.playing) {
|
||
await pause();
|
||
} else {
|
||
await play();
|
||
}
|
||
break;
|
||
default:
|
||
if (_playback.player.state.playing) {
|
||
await pause();
|
||
} else {
|
||
await play();
|
||
}
|
||
}
|
||
}
|
||
}
|