97일차(1)/Android App(61) : mp3 파일 재생 예제 / 되감기, 빨리감기 기능 구현
- 되감기, 빨리감기 기능 추가
- 파일 저장시 UUID 기능 사용
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/infoText"
android:background="#cecece"
android:textSize="20sp"/>
<ListView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:id="@+id/listView"
android:choiceMode="singleChoice"/>
<ImageView
android:layout_width="match_parent"
android:layout_height="200dp"
android:id="@+id/imageView"/>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<ImageButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@android:drawable/ic_media_rew"
android:id="@+id/rawBtn"/>
<ImageButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@android:drawable/ic_media_play"
android:tooltipText="재생버튼"
android:id="@+id/playBtn"/>
<ImageButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@android:drawable/ic_media_pause"
android:tooltipText="일시정지"
android:id="@+id/pauseBtn"/>
<ImageButton
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@android:drawable/ic_media_ff"
android:id="@+id/ffBtn"/>
</LinearLayout>
<ProgressBar
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:id="@+id/progress"/>
<SeekBar
android:layout_width="match_parent"
android:layout_height="wrap_content"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:id="@+id/seek"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="20sp"
android:id="@+id/time"/>
</LinearLayout>
- gravity="center" 하면 버튼이 가운데 정렬된다.
- LinearLayout으로 수평 정렬 + gravity로 가운데 정렬한 것
- rewind, fast-forward 버튼을 추가해주었다.
MainActivity
package com.example.step23mp3player;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.media.MediaMetadataRetriever;
import android.media.MediaPlayer;
import android.os.AsyncTask;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Message;
import android.preference.PreferenceManager;
import android.util.Log;
import android.view.View;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.ListView;
import android.widget.ProgressBar;
import android.widget.SeekBar;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
public class MainActivity extends AppCompatActivity implements AdapterView.OnItemClickListener,
MusicService.OnMoveToListener{
MediaPlayer mp;
//재생 준비가 되었는지 여부
boolean isPrepared=false;
ImageButton playBtn;
ProgressBar progress;
TextView time;
SeekBar seek;
//서비스의 참조값을 저장할 필드
MusicService service;
//서비스에 연결되었는지 여부
boolean isConnected;
//Adapter 에 연결된 모델 (단순 문자열)
List<String> songs;
//Adapter 의 참조값
ArrayAdapter<String> adapter;
SharedPreferences pref;
String sessionId;
String id;
//재생음악 목록(자세한 정보가 들어있는 목록)
List<MusicDto> musicList=new ArrayList<>();
//listView의 참조값을 저장할 필드
ListView listView;
//서비스 연결객체
ServiceConnection sConn=new ServiceConnection() {
//서비스에 연결이 되었을때 호출되는 메소드
@Override
public void onServiceConnected(ComponentName name, IBinder binder) {
//MusicService 객체의 참조값을 얻어와서 필드에 저장
//IBinder 객체를 원래 type 으로 casting
MusicService.LocalBinder lBinder=(MusicService.LocalBinder)binder;
service=lBinder.getService();
//연결되었다고 표시
isConnected=true;
//재생 음악 목록을 서비스에도 전달을 해준다.
service.setMusicList(musicList);
//재생 위치가 다음 곡으로 이동했을 때 해당 리스너를 감시할 리스너 등록
service.setOnMoveToListener(MainActivity.this);
//현재 재생 위치를 읽어와서
int currentIndex=service.getCurrentIndex();
listView.setItemChecked(currentIndex, true);
adapter.notifyDataSetChanged();
listView.smoothScrollToPosition(currentIndex);
loadTitleImage(currentIndex);
//핸들러에 메세지 보내기
handler.removeMessages(0); //만일 핸들러가 동작중에 있으면 메세지를 제거하고
handler.sendEmptyMessageDelayed(0, 100); //다시 보내기
}
//서비스에 연결이 해제 되었을때 호출되는 메소드
@Override
public void onServiceDisconnected(ComponentName name) {
//연결 해제 되었다고 표시
isConnected=false;
}
};
//UI 를 주기적으로 업데이트 하기 위한 Handler
Handler handler=new Handler(){
/*
이 Handler 에 메세지를 한번만 보내면 아래의 handleMessage() 메소드가
1/10 초 마다 반복적으로 호출된다.
handleMessage() 메소드는 UI 스레드 상에서 실행되기 때문에
마음대로 UI 를 업데이트 할수가 있다.
*/
@Override
public void handleMessage(@NonNull Message msg) {
if(service.isPrepared()){
//전체 재생시간
int maxTime=service.getMp().getDuration();
progress.setMax(maxTime);
seek.setMax(maxTime);
//현재 재생 위치
int currentTime=service.getMp().getCurrentPosition();
//음악 재생이 시작된 이후에 주기적으로 계속 실행이 되어야 한다.
progress.setProgress(currentTime);
seek.setProgress(currentTime);
//현재 재생 시간을 TextView 에 출력하기
String info=String.format("%d min, %d sec",
TimeUnit.MILLISECONDS.toMinutes(currentTime),
TimeUnit.MILLISECONDS.toSeconds(currentTime)
-TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS. toMinutes(currentTime)) );
time.setText(info);
}
//자신의 객체에 다시 빈 메세제를 보내서 handleMessage() 가 일정시간 이후에 호출 되도록 한다.
handler.sendEmptyMessageDelayed(0, 100); // 1/10 초 이후에
}
};
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
//TextView 의 참조값 얻어와서 필드에 저장
time=findViewById(R.id.time);
// %d 는 숫자, %s 문자
String info=String.format("%d min, %d sec", 0, 0);
time.setText(info);
//ProgressBar 의 참조값 얻어오기
progress=findViewById(R.id.progress);
seek=findViewById(R.id.seek);
//재생 버튼
playBtn=findViewById(R.id.playBtn);
//재생버튼을 눌렀을때
playBtn.setOnClickListener(v->{
//서비스의 playMusic() 메소드를 호출해서 음악이 재생 되도록 한다.
service.playMusic();
});
//일시 중지 버튼
ImageButton pauseBtn=findViewById(R.id.pauseBtn);
pauseBtn.setOnClickListener(v->{
service.pauseMusic();
});
//뒤로 감기 버튼
ImageButton rewBtn=findViewById(R.id.rewBtn);
rewBtn.setOnClickListener(v -> {
service.rewMusic();
});
//앞으로 감기 버튼
ImageButton ffBtn=findViewById(R.id.ffBtn);
ffBtn.setOnClickListener(v -> {
service.ffMusic();
});
//알림체널만들기
createNotificationChannel();
//ListView 관련 작업
listView=findViewById(R.id.listView);
//셈플 데이터
songs=new ArrayList<>();
//ListView 에 연결할 아답타
adapter=new ArrayAdapter<>(this, android.R.layout.simple_list_item_activated_1, songs);
listView.setAdapter(adapter);
//ListView 에 아이템 클릭 리스너 등록
listView.setOnItemClickListener(this);
}
@Override
protected void onStart() {
super.onStart();
// MusicService 에 연결할 인텐트 객체
Intent intent=new Intent(this, MusicService.class);
intent.setAction("Dummy Action");
//서비스 시작 시키기
//이미 서비스가 동작 중이라면 onStartCommand() 메소드만 다시 호출한다.
startService(intent);
pref= PreferenceManager.getDefaultSharedPreferences(this);
sessionId=pref.getString("sessionId", "");
//로그인 했는지 체크하기
new LoginCheckTask().execute(AppConstants.BASE_URL+"/music/logincheck");
}
@Override
protected void onStop() {
super.onStop();
if(isConnected){
//서비스 바인딩 해제
unbindService(sConn);
isConnected=false;
}
}
//앱의 사용자가 알림을 직접 관리 할수 있도록 알림 체널을 만들어야한다.
public void createNotificationChannel(){
//알림 체널을 지원하는 기기인지 확인해서
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
//알림 체널을 만들기
//셈플 데이터
String name="Music Player";
String text="Control";
//알림체널 객체를 얻어내서
//알림을 1/10 초마다 새로 보낼 예정이기 때문에 진동은 울리지 않도록 IMPORTANCE_LOW 로 설정한다
NotificationChannel channel=
new NotificationChannel(AppConstants.CHANNEL_ID, name, NotificationManager.IMPORTANCE_LOW);
//체널의 설명을 적고
channel.setDescription(text);
//알림 메니저 객체를 얻어내서
NotificationManager notiManager=(NotificationManager)getSystemService(Context.NOTIFICATION_SERVICE);
//알림 체널을 만든다.
notiManager.createNotificationChannel(channel);
}
}
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
switch (requestCode){
case 0:
//권한을 부여 했다면
if(grantResults[0] == PackageManager.PERMISSION_GRANTED){
}else{//권한을 부여 하지 않았다면
Toast.makeText(this, "알림을 띄울 권한이 필요합니다.",
Toast.LENGTH_SHORT).show();
}
break;
}
}
//ListView 의 cell 을 클릭하면 호출되는 메소드
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
// position 은 클릭한 셀의 인덱스를 서비스에 연결해서 해당 음악을 재생하도록 한다.
service.initMusic(position);
//타이틀 이미지 바꾸기
loadTitleImage(position);
}
//타이틀 이미지를 로딩하는 메소드
public void loadTitleImage(int index){
//mp3 파일의 title 이미지를 얻어내는 작업
MediaMetadataRetriever mmr=new MediaMetadataRetriever();
//재생할 음악의 저장된 파일명
String fileName=musicList.get(index).getSaveFileName();
//mp3파일 로딩
mmr.setDataSource(AppConstants.MUSIC_URL+fileName);
//image data를 byte[] 로 얻어내서
byte[] imageData=mmr.getEmbeddedPicture();
//만일 이미지 데이터가 있다면
if(imageData != null) {
//byte[] 를 활용해서 Bitmap 이미지를 얻어내고
Bitmap image = BitmapFactory.decodeByteArray(imageData, 0, imageData.length);
//Bitmap 이미지를 출력할 ImageView
ImageView imageView = findViewById(R.id.imageView);
imageView.setImageBitmap(image);
}else{
//기본 이미지를 출력한다
}
}
//MusicService 클래스 안에 정의한 OnMoveToListener 인터페이스를 구현해서 강제 오버라이드한 메소드
@Override
public void moved(int index) {
//재생위치가 다음으로 이동했을 때 호출되는 메소드로 만들 예정
//listView의 selection을 index로 이동시킨다.
listView.setItemChecked(index, true);
//해당 인덱스로 부드럽게 스크롤되게 한다.
listView.smoothScrollToPosition(index);
adapter.notifyDataSetChanged();
//타이틀 이미지 바꾸기
loadTitleImage(index);
}
//로그인 여부를 체크하는 작업을 할 비동기 task
class LoginCheckTask extends AsyncTask<String, Void, Boolean> {
@Override
protected Boolean doInBackground(String... strings) {
//로그인 체크 url
String requestUrl=strings[0];
//서버가 http 요청에 대해서 응답하는 문자열을 누적할 객체
StringBuilder builder=new StringBuilder();
HttpURLConnection conn=null;
InputStreamReader isr=null;
BufferedReader br=null;
boolean isLogin=false;
try{
//URL 객체 생성
URL url=new URL(requestUrl);
//HttpURLConnection 객체의 참조값 얻어오기
conn=(HttpURLConnection)url.openConnection();
if(conn!=null){//연결이 되었다면
conn.setConnectTimeout(20000); //응답을 기다리는 최대 대기 시간
conn.setRequestMethod("GET");//Default 설정
conn.setUseCaches(false);//케쉬 사용 여부
//App 에 저장된 session id 가 있다면 요청할때 쿠키로 같이 보내기
if(!sessionId.equals("")) {
// JSESSIONID=xxx 형식의 문자열을 쿠키로 보내기
conn.setRequestProperty("Cookie", sessionId);
}
//응답 코드를 읽어온다.
int responseCode=conn.getResponseCode();
if(responseCode==200){//정상 응답이라면...
//서버가 출력하는 문자열을 읽어오기 위한 객체
isr=new InputStreamReader(conn.getInputStream());
br=new BufferedReader(isr);
//반복문 돌면서 읽어오기
while(true){
//한줄씩 읽어들인다.
String line=br.readLine();
//더이상 읽어올 문자열이 없으면 반복문 탈출
if(line==null)break;
//읽어온 문자열 누적 시키기
builder.append(line);
}
}
}
//서버가 응답한 쿠키 목록을 읽어온다.
List<String> cookList=conn.getHeaderFields().get("Set-Cookie");
//만일 쿠키가 존재 한다면
if(cookList != null){
//반복문 돌면서
for(String tmp : cookList){
//session id 가 들어 있는 쿠키를 찾아내서
if(tmp.contains("JSESSIONID")){
//session id 만 추출해서
String sessionId=tmp.split(";")[0];
//SharedPreferences 을 편집할수 있는 객체를 활용해서
SharedPreferences.Editor editor=pref.edit();
//sessionId 라는 키값으로 session id 값을 저장한다.
editor.putString("sessionId", sessionId);
editor.apply();//apply() 는 비동기로 저장하기 때문에 실행의 흐름이 잡혀 있지 않다(지연이 없음)
//필드에도 담아둔다.
MainActivity.this.sessionId=sessionId;
}
}
}
//출력받은 문자열 전체 얻어내기
JSONObject obj=new JSONObject(builder.toString());
/*
{"isLogin":false} or {"isLogin":true, "id":"kimgura"}
서버에서 위와 같은 형식의 json 문자열을 응답할 예정이다.
*/
Log.d("서버가 응답한 문자열", builder.toString());
//로그인 여부를 읽어와서
isLogin=obj.getBoolean("isLogin");
//만일 로그인을 했다면
if(isLogin){
//필드에 로그인된 아이디를 담아둔다.
id=obj.getString("id");
}
}catch(Exception e){//예외가 발생하면
Log.e("LoginCheckTask", e.getMessage());
}finally {
try{
if(isr!=null)isr.close();
if(br!=null)br.close();
if(conn!=null)conn.disconnect();
}catch(Exception e){}
}
//로그인 여부를 리턴하면 아래의 onPostExecute() 메소드에 전달된다.
return isLogin;
}
@Override
protected void onPostExecute(Boolean isLogin) {
super.onPostExecute(isLogin);
//여기는 UI 스레드 이기 때문에 UI 와 관련된 작업을 할수 있다.
//TextView 에 로그인 여부를 출력하기
if(isLogin){
TextView infoText=findViewById(R.id.infoText);
infoText.setText(id+" 님 로그인중...");
//재생목록 받아오기
new MusicListTask().execute(AppConstants.BASE_URL+"/api/music/list");
//액티비티의 bindService() 메소드를 이용해서 연결한다.
Intent intent=new Intent(MainActivity.this, MusicService.class);
intent.setAction("Dummy Action");
bindService(intent, sConn, Context.BIND_AUTO_CREATE);
}else{
//로그인 액티비티로 이동
Intent intent=new Intent(MainActivity.this, LoginActivity.class);
startActivity(intent);
}
}
}
//재생목록을 얻어올 작업을 할 비동기 task
class MusicListTask extends AsyncTask<String, Void, String> {
@Override
protected String doInBackground(String... strings) {
//요청 url
String requestUrl=strings[0];
//서버가 http 요청에 대해서 응답하는 문자열을 누적할 객체
StringBuilder builder=new StringBuilder();
HttpURLConnection conn=null;
InputStreamReader isr=null;
BufferedReader br=null;
try{
//URL 객체 생성
URL url=new URL(requestUrl);
//HttpURLConnection 객체의 참조값 얻어오기
conn=(HttpURLConnection)url.openConnection();
if(conn!=null){//연결이 되었다면
conn.setConnectTimeout(20000); //응답을 기다리는 최대 대기 시간
conn.setRequestMethod("GET");//Default 설정
conn.setUseCaches(false);//케쉬 사용 여부
//App 에 저장된 session id 가 있다면 요청할때 쿠키로 같이 보내기
if(!sessionId.equals("")) {
// JSESSIONID=xxx 형식의 문자열을 쿠키로 보내기
conn.setRequestProperty("Cookie", sessionId);
}
//응답 코드를 읽어온다.
int responseCode=conn.getResponseCode();
if(responseCode==200){//정상 응답이라면...
//서버가 출력하는 문자열을 읽어오기 위한 객체
isr=new InputStreamReader(conn.getInputStream());
br=new BufferedReader(isr);
//반복문 돌면서 읽어오기
while(true){
//한줄씩 읽어들인다.
String line=br.readLine();
//더이상 읽어올 문자열이 없으면 반복문 탈출
if(line==null)break;
//읽어온 문자열 누적 시키기
builder.append(line);
}
}
}
//서버가 응답한 쿠키 목록을 읽어온다.
List<String> cookList=conn.getHeaderFields().get("Set-Cookie");
//만일 쿠키가 존대 한다면
if(cookList != null){
//반복문 돌면서
for(String tmp : cookList){
//session id 가 들어 있는 쿠키를 찾아내서
if(tmp.contains("JSESSIONID")){
//session id 만 추출해서
String sessionId=tmp.split(";")[0];
//SharedPreferences 을 편집할수 있는 객체를 활용해서
SharedPreferences.Editor editor=pref.edit();
//sessionId 라는 키값으로 session id 값을 저장한다.
editor.putString("sessionId", sessionId);
editor.apply();//apply() 는 비동기로 저장하기 때문에 실행의 흐름이 잡혀 있지 않다(지연이 없음)
//필드에도 담아둔다.
MainActivity.this.sessionId=sessionId;
}
}
}
}catch(Exception e){//예외가 발생하면
Log.e("MusicListTask", e.getMessage());
}finally {
try{
if(isr!=null)isr.close();
if(br!=null)br.close();
if(conn!=null)conn.disconnect();
}catch(Exception e){}
}
//응답받은 문자열을 리턴한다.
return builder.toString();
}
@Override
protected void onPostExecute(String jsonStr) {
super.onPostExecute(jsonStr);
//여기는 UI 스레드 (자유롭게 UI작업을 할수있다)
//jsonStr 은 [{},{},...] 형식의 문자열이기 때문에 JSONArray 객체를 생성한다.
songs.clear();
musicList.clear();
try {
JSONArray arr=new JSONArray(jsonStr);
for(int i=0; i<arr.length(); i++){
//i번째 JSONObject 객체를 참조
JSONObject tmp=arr.getJSONObject(i);
int num=tmp.getInt("num");
String writer=tmp.getString("writer");
//"title" 이라는 키값으로 저장된 문자열 읽어오기
String title=tmp.getString("title");
String artist=tmp.getString("artist");
String orgFileName=tmp.getString("orgFileName");
String saveFileName=tmp.getString("saveFileName");
String regdate=tmp.getString("regdate");
//ListView에 연결된 모델에 곡의 제목을 담는다.
songs.add(title);
//음악 하나의 자세한 정보를 MusicDto에 담고
MusicDto dto=new MusicDto();
dto.setNum(num);
dto.setWriter(writer);
dto.setTitle(title);
dto.setArtist(artist);
dto.setOrgFileName(orgFileName);
dto.setSaveFileName(saveFileName);
dto.setRegdate(regdate);
//MusicDto를 list에 누적시킨다.
musicList.add(dto);
}
//모델의 데이터가 바뀌었다고 아답타에 알려서 listView가 업데이트 되도록 한다.
adapter.notifyDataSetChanged();
} catch (JSONException je) {
Log.e("onPostExecute()", je.getMessage());
}
}
}
}
- MediaPlayer의 참조값은 service.getMp(); 로 얻어낼 수 있다.
seekTo();
- 특정 위치로 이동시키는 메소드
getCurrentPosition()
- 현재 포지션(음악의 현재위치)을 얻어낼 수 있다.
seekTo(current + 10*1000)
- 현재 초+10초 와 같이 설정하면 재생 위치 변경이 가능하다.
- 단 재생위치가 0보다 작아지거나 곡의 전체 시간보다 커지면 안된다!
- onClickListener의 참조값을 익명의 이너클래스를 사용해서 얻어냈다.
- 오버라이드할 메소드가 한개이면 이렇게 줄여서 쓸 수 있다. 호출되는, 사용되는 메소드는 오직 하나이다!
- 이 버튼도 재생, 일시정지와 동일하게 서비스에 기능을 만들어놓고 호출하는 구조로 만들 것이다.
MusicService
package com.example.step23mp3player;
import android.app.Notification;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.os.Binder;
import android.os.Handler;
import android.os.IBinder;
import android.os.Message;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.ActivityCompat;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import java.util.List;
import java.util.concurrent.TimeUnit;
/*
MusicService를 이용해서 음악을 재생하는 방법
- initMusic() 메소드를 호출하면서 음원의 위치를 넣어주고
- 음원 로딩이 완료되면 자동으로 play 된다.
*/
public class MusicService extends Service implements MediaPlayer.OnPreparedListener, MediaPlayer.OnCompletionListener {
//다음 곡으로 자동 이동했는지 감시할 리스너 인터페이스
public interface OnMoveToListener{
public void moved(int index);
}
//필요한 필드 정의하기
MediaPlayer mp;
boolean isPrepared; //음원 재생 준비가 완료되었는지 여부
//음악 재생 목록
List<MusicDto> musicList;
//현재 재생중인 음악 목록 인덱스
int currentIndex;
public OnMoveToListener listener;
//현재 재생 위치를 리턴하는 메소드(액티비티가 호출해서 받앙갈 예정)
public int getCurrentIndex(){
return currentIndex;
}
//MainActivity의 참조값이 OnMoveToListener type 으로 전달되는 메소드
public void setOnMoveToListener(OnMoveToListener listener){
this.listener=listener;
}
//액티비티로부터 재생할 음악목록을 전달받는 메소드
public void setMusicList(List<MusicDto> musicList) {
this.musicList = musicList;
}
//음원을 로딩하는 메소드 url을 넣어주면 해당 url의 음악을 로딩하는 메소드
public void initMusic(int index) {
//현재 재생중인 인덱스 수정
currentIndex=index;
isPrepared = false;
if (mp == null) {
mp = new MediaPlayer();
mp.setAudioStreamType(AudioManager.STREAM_MUSIC);
mp.setOnPreparedListener(this); //음원 로딩이 완료되었는지 감시할 리스너 등록
mp.setOnCompletionListener(this);
mp.setLooping(false);
}
//만일 현재 재생중이면
if (mp.isPlaying()) {
mp.stop(); //재생을 중지하고
}
mp.reset(); //초기화
try {
//로딩할 음원의 위치 구성하기
String url=AppConstants.MUSIC_URL+musicList.get(index).getSaveFileName();
//로딩할 음원의 위치를 넣어주고
mp.setDataSource(url);
} catch (Exception e) {
Log.e("initMusic()", e.getMessage());
}
//비동기로 로딩을 시킨다.
mp.prepareAsync();
}
//재생하는 메소드
public void playMusic() {
//만일 음악이 준비되지 않았다면
if(!isPrepared)return; //메소드를 여기서 끝내라
mp.start();
}
//일시정지하는 메소드
public void pauseMusic() {
//만일 음악이 준비되지 않았다면
if(!isPrepared)return; //메소드를 여기서 끝내라
mp.pause();
}
//정지하는 메소드
public void stopMusic() {
//만일 음악이 준비되지 않았다면
if(!isPrepared)return; //메소드를 여기서 끝내라
mp.stop();
}
//뒤로 되감는 기능
public void rewMusic(){
//만일 음악이 준비되지 않았다면
if(!isPrepared)return; //메소드를 여기서 끝내라
//현재 재생 위치에서 뒤로 10초
int current=mp.getCurrentPosition();
int backPoint=current-10*1000;
//음수가 되면 안되기 때문에 backPoint가 0 이상일 때만 동작하도록 한다.
if(backPoint >= 0){
mp.seekTo(backPoint);
}
}
//앞으로 감는 기능
public void ffMusic(){
//만일 음악이 준비되지 않았다면
if(!isPrepared)return; //메소드를 여기서 끝내라
//현재 재생 위치에서 앞으로 10초
int current=mp.getCurrentPosition();
int frontPoint=current+10*1000;
//전체 재생 시간보다는 작아야 되기 때문에
if(frontPoint <= mp.getDuration()){
mp.seekTo(frontPoint);
}
}
//재생이 준비되었는지 여부를 리턴하는 메소드
public boolean isPrepared() {
return isPrepared;
}
//MediaPlayer 객체의 참조값을 리턴하는 메소드
public MediaPlayer getMp() {
return mp;
}
//서비스가 최초 활성화될 때 한번 호출되는 메소드
@Override
public void onCreate() {
super.onCreate();
}
//최초 활성화 혹은 이미 활성화된 이후 이 서비스를 활성화 하는 Intent가 도착하면 호출되는 메소드
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
//알림에 띄워진 액션 버튼을 눌렀을때 분기해서 필요한 동작을 한다.
switch (intent.getAction()) {
case AppConstants.ACTION_PLAY:
Log.d("onStartCommand()", "play!");
playMusic();
mp.seekTo(200000);
break;
case AppConstants.ACTION_PAUSE:
Log.d("onStartCommand()", "pause!");
pauseMusic();
break;
case AppConstants.ACTION_STOP:
Log.d("onStartCommand()", "stop!");
stopMusic();
break;
}
return START_NOT_STICKY;
}
//음원 재생이 완료되었을 때 호출되는 메소드
@Override
public void onCompletion(MediaPlayer mp) {
//재생할 음악 목록의 마지막 인덱스
int lastIndex=musicList.size() - 1;
//만일 현재 재생중인 인덱스가 마지막 번째 인덱스보다 작다면(마지막 인덱스가 아니라면)
if(currentIndex < lastIndex){
currentIndex++;
initMusic(currentIndex);
}else{
//만일 무한 플레이를 하려면
currentIndex=0;
initMusic(currentIndex);
}
if(listener != null){
// OnMoveToListener(MainActivity) 객체의 moved 메소드를 호출하면서 현재 위치 전달
listener.moved(currentIndex);
}
}
//Binder 클래스를 상속받아서 LocalBinder 클래스를 정의한다.
public class LocalBinder extends Binder {
//서비스의 참조값을 리턴해주는 메소드
public MusicService getService() {
Log.e("####", "리턴함");
return MusicService.this;
}
}
//필드에 바인더 객체의 참조값 넣어두기
final IBinder binder = new LocalBinder();
//어디에선가(액티비티) 바인딩(연결)이 되면 호출되는 메소드
@Override
public IBinder onBind(Intent intent) {
return binder;
}
//어디에선가(액티비티) 바인딩(연결)이 해제되면 호출되는 메소드
@Override
public boolean onUnbind(Intent intent) {
//OnMoveToListener 를 제거한다.
listener=null;
return super.onUnbind(intent);
}
//새로운 음원 로딩이 완료되면 호출되는 메소드
@Override
public void onPrepared(MediaPlayer mp) {
//재생할 준비가 되었다고 상태값을 바꿔준다.
isPrepared = true;
//준비가 되면 자동으로 재생을 시작한다.
playMusic();
handler.removeMessages(0);
handler.sendEmptyMessageDelayed(0,100);
}
@Override
public void onDestroy() {
if(mp != null){
//MediaPlayer 해제하기
mp.stop();
mp.release();
mp = null;
}
handler.removeMessages(0);
super.onDestroy();
}
Handler handler=new Handler(){
@Override
public void handleMessage(@NonNull Message msg) {
//음악을 control 할 수 있는 알림을 띄운다.
makeManualCancelNoti();
handler.sendEmptyMessageDelayed(0,100);
}
};
//수동으로 취소하는 알림을 띄우는 메소드
public void makeManualCancelNoti() {
if(!isPrepared)return;
//현재 재생 시간을 문자열로 얻어낸다.
int currentTime = mp.getCurrentPosition();
String info = String.format("%d min, %d sec",
TimeUnit.MILLISECONDS.toMinutes(currentTime),
TimeUnit.MILLISECONDS.toSeconds(currentTime)
- TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(currentTime)));
Intent iPlay = new Intent(this, MusicService.class);
iPlay.setAction(AppConstants.ACTION_PLAY);
PendingIntent pIntentPlay = PendingIntent.getService(this, 1, iPlay, PendingIntent.FLAG_MUTABLE);
Intent iPause = new Intent(this, MusicService.class);
iPlay.setAction(AppConstants.ACTION_PAUSE);
PendingIntent pIntentPause = PendingIntent.getService(this, 1, iPlay, PendingIntent.FLAG_MUTABLE);
Intent iStop = new Intent(this, MusicService.class);
iPlay.setAction(AppConstants.ACTION_STOP);
PendingIntent pIntentStop = PendingIntent.getService(this, 1, iPlay, PendingIntent.FLAG_MUTABLE);
//재생중인 음악의 제목
String songTitle=musicList.get(currentIndex).getTitle();
//띄울 알림을 구성하기
NotificationCompat.Builder builder = new NotificationCompat.Builder(this, AppConstants.CHANNEL_ID)
.setSmallIcon(android.R.drawable.star_on) //알림의 아이콘
.setContentTitle(songTitle) //알림의 제목
.setContentText(info)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) //알림의 우선순위
.addAction(new NotificationCompat.Action(android.R.drawable.ic_media_play, "Play", pIntentPlay))
.addAction(new NotificationCompat.Action(android.R.drawable.ic_media_play, "Pause", pIntentPause))
.addAction(new NotificationCompat.Action(android.R.drawable.ic_media_play, "Stop", pIntentStop))
.setProgress(mp.getDuration(), mp.getCurrentPosition(), false)
//.setContentIntent(pendingIntent) //인텐트 전달자 객체
.setAutoCancel(false); //자동 취소 되는 알림인지 여부
//알림 만들기
Notification noti = builder.build();
//만일 알림 권한이 없다면
if (ActivityCompat.checkSelfPermission(this, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
//메소드를 여기서 종료
return;
}
//알림 매니저를 이용해서 알림을 띄운다.
NotificationManagerCompat.from(this).notify(AppConstants.NOTI_ID, noti);
}
}
- 이 기능들은 모두 MediaPlayer가 재생준비가 완료되어야만 동작하도록 설정되어야 한다.
- if문으로 음악이 준비되지 않았다면 리턴하는 구조로 만들어준다.
- AOP를 사용한다면 이런 작업을 간편하게 할 수 있다.
- MediaPlayer의 메소드 seekTo() : 특정 재생 위치로 찾아들어가는 기능이 있다.
- 현재 재생 위치를 알아내고, 특정 재생위치로 이동시키면 된다.
//뒤로 되감는 기능
public void rewMusic(){
//만일 음악이 준비되지 않았다면
if(!isPrepared)return; //메소드를 여기서 끝내라
//현재 재생 위치에서 뒤로 10초
int current=mp.getCurrentPosition();
int backPoint=current-10*1000;
//음수가 되면 안되기 때문에 backPoint가 0 이상일 때만 동작하도록 한다.
if(backPoint >= 0){
mp.seekTo(backPoint);
}
}
//앞으로 감는 기능
public void ffMusic(){
//만일 음악이 준비되지 않았다면
if(!isPrepared)return; //메소드를 여기서 끝내라
//현재 재생 위치에서 앞으로 10초
int current=mp.getCurrentPosition();
int frontPoint=current+10*1000;
//전체 재생 시간보다는 작아야 되기 때문에
if(frontPoint <= mp.getDuration()){
mp.seekTo(frontPoint);
}
}
- 되감기 버튼: 재생지점이 음수가 되면 안되기 때문에 backPoint가 0 이상일 때만 동작하도록 한다.
- 빨리감기 버튼: frontPoint는 전체 재생시간보다 작을 때에만 동작하도록 설정한다.
- 이처럼 반대되는 기능을 만들어야 할 일이 종종 있다. 비교해서 만들기
- MainActivity에서 특정 시점에(버튼 클릭시) service의 해당 메소드가 수행되도록 넣어준다.
- 각각의 버튼을 누르면 10초 앞으로, 10초 뒤로 이동한다.
- 현재 앨범이미지는 원본 파일명에 숫자를 붙여서 DB에 저장하고 있다.
- 파일명에 띄어쓰기, 한글, 특수문자 등 텍스트가 들어있으면 요청 URL이 잘못된 곳을 가리키거나 에러가 날 수 있다.
- 또한 currentTimeMillis() 는 시간 정보를 바탕으로 저장하므로 저장시의 정보가 노출되는 부분이 있다.
→ 즉 saveFileName을 저런 방식으로 저장하는 것은 그다지 안전하지 않다. 다른 방식으로 바꿔보기!
MusicServiceImpl
package com.sy.boot07.music.service;
import java.io.File;
import java.util.List;
import java.util.UUID;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
import org.jaudiotagger.audio.AudioFileIO;
import org.jaudiotagger.audio.mp3.MP3File;
import org.jaudiotagger.tag.FieldKey;
import org.jaudiotagger.tag.Tag;
import org.jaudiotagger.tag.id3.AbstractID3Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.ModelAndView;
import com.sy.boot07.music.dao.MusicDao;
import com.sy.boot07.music.dto.MusicDto;
@Service
public class MusicServiceImpl implements MusicService {
@Autowired MusicDao dao;
@Override
public void saveFile(MultipartFile file, HttpServletRequest request) {
//업로드한 음악파일의 정보를 담을 dto 객체 생성
MusicDto dto=new MusicDto();
//업로드한 클라이언트의 아이디(=writer)
String id=(String)request.getSession().getAttribute("id");
dto.setWriter(id);
//원본 파일명 -> 저장할 파일 이름 만들기위해서 사용됨
String orgFileName = file.getOriginalFilename();
dto.setOrgFileName(orgFileName);
//파일 크기 -> 다운로드가 없으므로, 여기서는 필요 없다.
long fileSize = file.getSize();
// webapp/upload 폴더 까지의 실제 경로(서버의 파일 시스템 상에서의 경로)
String realPath = request.getServletContext().getRealPath("/resources/upload");
//db 에 저장할 저장할 파일의 상세 경로
String filePath = realPath + File.separator;
//디렉토리를 만들 파일 객체 생성
File upload = new File(filePath);
if(!upload.exists()) {
//만약 디렉토리가 존재하지X
upload.mkdir();//폴더 생성
}
//저장할 파일의 이름을 구성한다. -> 우리가 직접 구성해줘야한다.
//String saveFileName = System.currentTimeMillis() + orgFileName;
//파일명이 겹치지 않도록 무작위의 UUID 문자열을 얻어내서 저장할 파일명으로 사용한다.
String randomId=UUID.randomUUID().toString();
String saveFileName = randomId+".mp3";
dto.setSaveFileName(saveFileName);
try {
File mp3File=new File(filePath + saveFileName);
//upload 폴더에 파일을 저장한다.
file.transferTo(mp3File);
//mp3 파일에서 meta data 추출하기
MP3File mp3=(MP3File)AudioFileIO.read(mp3File);
//AbstractID3Tag aTag=mp3.getID3v2Tag();
Tag tag=mp3.getTag();
//제목
String title=tag.getFirst(FieldKey.TITLE);
//만일 제목 정보가 없으면
if(title==null) {
//원본 파일명을 제목으로 설정
title=orgFileName;
}
dto.setTitle(title);
//artist
String artist=tag.getFirst(FieldKey.ARTIST);
//만일 아티스트 정보가 없으면
if(artist==null) {
artist="정보없음";
}
dto.setArtist(artist);
}catch(Exception e) {
e.printStackTrace();
}
//DB에 저장
dao.insert(dto);
}
@Override
public void getList(ModelAndView mView, HttpSession session) {
//로그인된 아이디
String id=(String)session.getAttribute("id");
//아이디를 이용해서 로그인된 클라이언트가 업로드한 음악 파일 목록만 얻어낸다.
List<MusicDto> list=dao.getList(id);
//ModelAndView 객체에 담기
mView.addObject("list", list);
}
@Override
public MusicDto getDetail(int num) {
return dao.getData(num);
}
@Override
public void deleteFile(int num, HttpServletRequest request) {
//삭제할 파일의 정보를 읽어온다.
MusicDto dto=dao.getData(num);
//1. 파일 시스템에서 삭제 (삭제할 파일을 가리키는 파일 객체가 필요하다)
// webapp/upload 폴더 까지의 실제 경로(서버의 파일 시스템 상에서의 경로)
String realPath = request.getServletContext().getRealPath("/resources/upload");
//db 에 저장할 저장할 파일의 상세 경로
String filePath = realPath + File.separator;
//파일 객체를 생성해서
File f=new File(filePath);
//메소드를 이송해서 삭제
f.delete();
//2. DB에서도 삭제
dao.delete(num);
}
}
- java.util 패키지에 UUID라는 클래스가 있다.
- UUID를 리턴하는 메소드가 들어있다.
UUID.randomUUID().toString();
- 겹치지 않는 특정 타입의 랜덤한 문자열을 얻어낼 수 있다.
- 파일 제목이 랜덤 문자열 처리되어 저장되었다.
- UUID는 기본 파일 업로드시에도 사용할 수 있다.
- 단 지금은 mp3파일이 확실하므로 뒤에 ".mp3"를 붙이도록 작성했지만,
보통은 맨 뒤의 확장자 텍스트를 추출해 붙이는 방식으로 한다.
'국비교육(22-23)' 카테고리의 다른 글
98일차(1)/Android App(63) : Internal, External Storage에서 읽기 (read) (1) | 2023.03.02 |
---|---|
97일차(2)/Android App(62) : Internal, External Storage 저장 (write) (0) | 2023.02.25 |
96일차(1)/Android App(60) : mp3 파일 재생 예제 / 곡 연속 재생 설정 (0) | 2023.02.24 |
95일차(1)/Android App(59) : mp3 파일 재생 예제 / 곡 목록 출력 (0) | 2023.02.22 |
94일차(1)/Android App(58) : mp3 파일 재생 예제 / Metadata 추출 (0) | 2023.02.21 |