국비교육(22-23)

97일차(1)/Android App(61) : mp3 파일 재생 예제 / 되감기, 빨리감기 기능 구현

서리/Seori 2023. 2. 24. 14:48

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"를 붙이도록 작성했지만,

  보통은 맨 뒤의 확장자 텍스트를 추출해 붙이는 방식으로 한다.