大二学java一个多月了,正好2.14情人节想做的点什么东西,于是心血来潮写了个粉嫩粉嫩的播放器布局(这个也放github里面了),后来一发不可收拾不断改进,前后近三个星期遂步进新世界。
界面清新简洁,代码注释详实,适合作为入门项目
采用JavaFX组件完成的一款小巧、界面精美的本地音乐播放器,支持(拖动 or 文件选择器)添加本地音乐、歌曲以及删除它们、具有歌词文件解析、三种播放模式选择、歌词滚动、歌单列表控制、频谱图展示、歌词海报显示、自定义背景、系统托盘控制、一些快捷键等。同时使用.ini文件记录应用设置信息,数据库用sqlite 。
(soplayer 源码Github地址)
(soplayer 文档地址)
支持系统托盘控制和全屏控制
界面简洁而精美且支持自定义背景
支持播放的音乐格式:.mp3 文件、.wav 文件、.aac 文件
支持解析歌词并展示(.lrc文件)
支持解析歌词文件(缩略图、专辑、时长等)
支持频谱图动效
支持拖动添加文件
支持歌单列表控制
sqlite数据库支持(sqlite-jdbc-3.7.2.jar)
documentation content
function exposition
communication
在此想给大家分享一下实现思路,交流学习,实现过程也经历了挺多坑,花了很多时间从stackOverflow、官方文档、csdn翻阅资料。JavaFX在csdn资源挺少的,建议可以多逛逛stackOverflow。
首先我们来分析对象,驱动最核心的播放模块,需要的是歌曲、歌词(.lrc)两种文件对象。我们创建个库存储,为拓展数据库提供方便的接口
播放模块:
歌曲文件放进mediaPlayer的对象mPlayer中即可。既然是播放器,那就有很多首歌可以放进mediaPlayer,在这里我们把media文件放进容器数组里,这样我们就可以通过index引索来访问它们了!
//填充mediaPlayer
private void fillPlayer() {
try{
SongFile sF=mList.get(index);
Media curMedia=sF.use();
//填充来自Database得media
// mPlayer.stop();
mPlayer = new MediaPlayer(curMedia);
//异常处理事件
mPlayer.setOnError(()->System.out.println("media error"
+ mPlayer.getError().toString()));
//填充歌曲文件之后填充(匹配)歌词文件
setAutoPlay(); //recommend
}catch (Exception e){
e.printStackTrace();
}
}
音乐的播放和暂停都有对应方法,具体通过事件激活即可。我们谈一下上一曲、下一曲的实现。围绕引索展开,首先设置两个标记,我这里用MARK来表示。如果不添加 mPlayer.dispose(); ,切歌的时候会有重叠声音,所以事先dispose()当前播放。判断标记,下一曲则index加一,反之减。
//计算下一首的index
private void getPlayListIndex(){
if (mPlayer != null) {
mPlayer.dispose();
System.gc();
//控制下一首和后一首
if (MARK==Mark.NEXT) {
index++;
if (index >= mList.size()) {
index = 0;
}
}
else if(MARK==Mark.LAST){
index--;
if (index < 0) {
index = mList.size() - 1;
}
}
}
else {
//如果此前无播放(程序刚刚初始化)则定义从第一首开始放
index = 0;
}
}
获取歌曲数据,可以解析到歌曲海报、歌名、专辑等,以hash表形式存储在下面代码的map里。
在mPlayer的监听事件里,
mPlayer.setOnReady(new Runnable() {
@Override
public void run() {
ObservableMap<String,Object> map=mList.get(index).use().getMetadata();
}
}
获取播放的时间更新播放文本、进度条,到时则根据当前播放模式决定index怎么变
还是在mPlayer的监听事件里,
mPlayer.currentTimeProperty().addListener(new ChangeListener<Duration>() {
// ContinuousAudioDataStream
@Override
public void changed(ObservableValue<? extends Duration> observable, Duration oldValue, Duration newValue) {
if(!mouse){
sPlayBack.setValue(newValue.toSeconds());
current.refurbish(mPlayer.getCurrentTime());
total.refurbish(mPlayer.getTotalDuration());
//歌曲时间文本
TimeOfPlayBack.timeShowStart(text,current,total);
//歌词滚动
timeOfPlayBack.lyricsStart(installLyrics.getLyricText(),current,installLyrics.getLyrics());
}
//歌曲结束,下一步做什么依赖此时播放模式
double exp=10E-2;
if(Math.abs(newValue.toSeconds()-mPlayer.getStopTime().toSeconds()) < exp ){
playPattern();
}
}
});
三种播放模式功能,单曲就调用mPlayer方法 无限制循环,循环播放默认index一直加一,随机播放有两种实现方式(用shuffle算法打乱播放队列,index取随机数)
//循环 单曲 随机播放
private void playPattern(){
if(PLAYPATTERN==PlayPattern.LOOP) loop();
else if(PLAYPATTERN==PlayPattern.SINGLELOOP) singleLoop();
else if(PLAYPATTERN==PlayPattern.RANDOM) randomPlay();
}
//因为数据结构暂时采用实现了Collections的数组容器,随机播放可以采用shuffle算法
private void randomPlay(){
// int i= (int)Math.floor(Math.random()*(mList.size()+1));
int i=0;
while(true) {
i = (int) (Math.random() * mList.size());
//不可重复播放同一首歌,size等于1则可以重复播放同一首歌
if(i!=index || mList.size()==1) break;
}
// Collections.shuffle(mList);
//计算随机数
initMusic_Index(i);
}
private void singleLoop(){
//无限循环,当前时间的监听事件的一个参数,不以媒体结束时间为上限,可用来计算播放该音乐的总时间
mPlayer.setCycleCount(MediaPlayer.INDEFINITE);
mPlayer.play();
}
private void loop(){
MARK=Mark.NEXT;
initMusic();
}
频谱图通过事件监听获取频谱数组magnitudes,是一个size约有150的值-60到0浮点数组。一般将值进行处理后,建矩形实现频谱动态效果,也可以加动画效果。
mPlayer.setAudioSpectrumListener(new AudioSpectrumListener() {
@Override
public void spectrumDataUpdate(double timestamp, double duration, float[] magnitudes, float[] phases) {
//更新高度数据并绘制矩形阵频谱 showPic为false代表展示频谱,不展示海报
chartsSpectrumData.refurbishRecChart(magnitudes,showPic);
//更新高度数据并绘制环形阵频谱 showPic为false代表展示频谱,不展示海报
chartsSpectrumData.refurbishCirChart(magnitudes,showPic);
}
});
频谱图刷新示例
/**
* 刷新环形图表
* @param magnitudes 频谱
* @param showPic 代表海报是否显示
*/
public void refurbishCirChart(float[] magnitudes,boolean showPic){
//更新高度数据并绘制环形阵频谱 showPic为false代表展示频谱,不展示海报
//一层if
if(showPic) {
for (int i = 0; i < rectangle_num; i++) {
rectangles2[i].setHeight(0);
}
return;
}
for(int i=0;i<rectangle_num;i++){
rectangles2[i].setHeight(ChartsSpectrumData.handleMagnitudes(magnitudes[i])*IniConfig.getD("heightRatio"));
}
}
歌单列表的抽屉动画效果,menuBox是VBox,放菜单的面板。
时间轴动画实现,凭借AnchorPane随意设置的特点移动面板
/**
* 菜单创建
* @param ap 创建的位置
*/
public void createMenu(AnchorPane ap){
menuBox=new VBox();
menuBox.setVisible(true);
menuBox.setPrefWidth(IniConfig.getI("menuBoxPrefWidth"));
menuBox.setPrefHeight(IniConfig.getI("menuBoxPrefHeight"));
menuBox.setTranslateY(IniConfig.getI("menuBoxTranslateY"));
menuBox.setBackground(new Background(new BackgroundFill(Color.GREY,null,null)));
menuBox.setTranslateX(-menuBox.getPrefWidth());
AnchorPane.setTopAnchor(menuBox,IniConfig.getD("menuBoxTop"));
AnchorPane.setBottomAnchor(menuBox,IniConfig.getD("menuBoxBottom"));
ap.getChildren().addAll(menuBox);
}
/**
* 显示菜单
* @param btList 列表控制按钮
* @param pListPic 列表控制按钮图片
*/
private void showMenu(Button btList,ImageView pListPic){
btList.setDisable(true);
menuBox.setTranslateX(-menuBox.getPrefWidth());
Timeline t1=new Timeline();
KeyValue kv=new KeyValue(menuBox.translateXProperty(),0, Interpolator.EASE_IN);
KeyFrame kf=new KeyFrame(Duration.seconds(IniConfig.getD("AnimationTimeSeconds")),kv);
t1.getKeyFrames().add(kf);
t1.setOnFinished(e->{
isShow=true;
btList.setGraphic(pListPic);
btList.setDisable(false);
});
t1.play();
}
/**
* 隐藏菜单
* @param btList 列表控制按钮
* @param pListRPic 列表控制按钮图片
*/
private void hideMenu(Button btList,ImageView pListRPic){
btList.setDisable(true);
Timeline t1=new Timeline();
KeyValue kv=new KeyValue(menuBox.translateXProperty(),-menuBox.getPrefWidth());
KeyFrame kf=new KeyFrame(Duration.seconds(IniConfig.getD("AnimationTimeSeconds")),kv);
t1.getKeyFrames().add(kf);
t1.setOnFinished(e->{
isShow=false;
btList.setGraphic(pListRPic);
btList.setDisable(false);
});
t1.play();
}
歌词显示是一个小难点
通过遍历去匹配歌曲的合适歌词文件,以 GBK 编码格式读取歌词文件,用正则处理歌 词并存储入 TreeMap(按键值升序排列的映射) 显示歌词则比对 key 和播放时间即可。
这是先匹配到歌词文件,再用正则匹配歌词:
public void fillLyrics(SongFile songFile, ArrayList<LyricsFile> lFile) {
//Text initiate-------------------------
try {
//词曲匹配
File f = matchLyricsFile(songFile,lFile);
//删除之前存放的歌词
killLyrics(lyrics);
//放置null指针错误
if(f==null) return;
//读取文件
FileInputStream fis = new FileInputStream(f);
InputStreamReader isr = new InputStreamReader(fis,"gbk"); //指定以gbk编码读入
BufferedReader br = new BufferedReader(isr);
String thisLine;
//正则
Pattern pattern = Pattern.compile("\\d{2}:\\d{2}.\\d{2}");
// BufferedReader br = new BufferedReader(new FileReader(f));
while ((thisLine = br.readLine()) != null) {
handleLyricsFile(thisLine, pattern); //URLDecoder.decode( thisLine, "GBK" )
}
} catch (Exception e) {
e.printStackTrace();
}
//test result: result 不能填装key为空的歌词
//利用迭代器刷新余下文本
iter = lyrics.keySet().iterator();
//initiate mediaPlayer on Ready && lyric introduce successfully---------------------------
for (int i = 3; i < nn; i++) {
if (iter.hasNext()) {
Object o = iter.next();
lyricText[i].setText(lyrics.get(o));
}
}
//至此完成nn个文本的全部初始化刷新
}
这里为刷新歌词文本显示的核心代码:
/**
* 刷新歌词文本
* @param lyricText 歌词文本
*/
void open(Text[] lyricText) throws Exception{
Long key=this.getTimeScroll();
if(key==null || !lyrics.containsKey(key) ) //key重复或未查询到key,则退出函数
return ;
String center = lyrics.get(key); //value
lyricText[1].setText(center); //初始化高亮核心层
if (lyrics.lowerKey(key) != null) {
//刷新高亮层的上一层的text
lyricText[0].setText(lyrics.get(lyrics.lowerKey(key)));
}
if (lyrics.higherKey(key) != null) {
//刷新高亮层的下三层的text
try {
SortedMap<Long, String> map =lyrics.tailMap(lyrics.higherKey(key)); //剩余未播放的歌词填入排序map
// WeakHashMap<> whm=lyrics.tailMap(lyrics.higherKey(key));
System.gc();
//遍历刷新三个text
int index = 2;
int limit=map.size(); //剩余未播放的歌词数量
for (Map.Entry entry : map.entrySet()) {
if (index > 4) break; //只能刷新2、3、4三层
String value = (String) entry.getValue();
if(limit>=index) {
lyricText[index].setText(value);
}
else {
//拒绝剩余刷新歌词 因为foreach循环遍历 map.entrySet()
lyricText[index].setText("");
}
// int key = (int) entry.getKey();
index++;
}
}catch (ArrayIndexOutOfBoundsException e){
e.printStackTrace();
}catch (Exception e){
e.printStackTrace();
}
}
}
文件拖动添加功能实现,调用面板四个监听,再用拖拽板接收文件。进而把文件传进数据库类的对象
/**
* 歌曲歌词文件拖动添加
* @param pane 支持的面板区域
* @param menuService 操作歌曲列表菜单
*/
public void invokeFileDrag(Pane pane, MenuService menuService){
pane.setOnDragEntered(new EventHandler<DragEvent>() {
@Override
public void handle(DragEvent event) {
//提供视觉效果 下同
pane.setBorder(new Border(new BorderStroke(Paint.valueOf("#FFF0F5"), BorderStrokeStyle.SOLID,new CornerRadii(0),new BorderWidths(1))));
}
});
pane.setOnDragExited(new EventHandler<DragEvent>() {
@Override
public void handle(DragEvent event) {
pane.setBorder(new Border(new BorderStroke(Paint.valueOf("#FFF0F500"), BorderStrokeStyle.SOLID,new CornerRadii(0),new BorderWidths(1))));
}
});
pane.setOnDragOver(new EventHandler<DragEvent>() {
@Override
public void handle(DragEvent event) {
event.acceptTransferModes(TransferMode.COPY);
}
});
pane.setOnDragDropped(new EventHandler<DragEvent>(){
@Override
public void handle(DragEvent event) {
pane.setBorder(new Border(new BorderStroke(Paint.valueOf("#FFF0F500"), BorderStrokeStyle.SOLID,new CornerRadii(0),new BorderWidths(1))));
// event.acceptTransferModes(TransferMode.COPY);
//拖拽板
Dragboard db=event.getDragboard();
//收集文件,对文件进行处理
if(db.hasFiles()) {
// System.out.println("666");
List<File> rawFiles = db.getFiles();
// List files = filter(rawFiles);
// FileHandler fileHandler=new FileHandler();
FileHandler.effect(rawFiles,database,menuService); //过滤+去重
}
}
// return null;
});
}
系统托盘实现是套用awt,javafx引入awt有个线程问题,用这个子线程方法解决报错
Platform.runLater(new Runnable() {
@Override
public void run() {
//你的线程
}
}
太晚了,实在没别的想到的,暂时先写到这里了,以后有想到再补充。
关于类的设计:
建议播放器主类(Player)模块继承实现。