diff --git a/README.md b/README.md index 06e619c15b..168ca9867a 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,18 @@ # VLC for Android +## About this branch: Lyrics +I made the just work patch for adding lyrics function for audio. + +But I'm sorry about the "dirty" code, so It will not be merged to the original repo. +But anyone need the lyrics is welcome to download the apk from the release section of this repo. + +* support .lrc external lyrics +* support for remember play position for each audio is not work (why?) +* small issue like lyrics not shown when pause is pressed + +Thanks + +------ This is the official **Android** port of [VLC](https://videolan.org/vlc/). VLC on Android plays all the same files as the classical version of VLC, and features a media database diff --git a/application/vlc-android/AndroidManifest.xml b/application/vlc-android/AndroidManifest.xml index ded55e531d..817e5ada42 100644 --- a/application/vlc-android/AndroidManifest.xml +++ b/application/vlc-android/AndroidManifest.xml @@ -52,8 +52,10 @@ android:name="android.hardware.bluetooth" android:required="false"/> - - + + + + + + + + + + + + + + @@ -120,4 +167,5 @@ + \ No newline at end of file diff --git a/application/vlc-android/res/layout-large-land/cover_media_switcher_item.xml b/application/vlc-android/res/layout-large-land/cover_media_switcher_item.xml index 74c959c307..771871ab11 100644 --- a/application/vlc-android/res/layout-large-land/cover_media_switcher_item.xml +++ b/application/vlc-android/res/layout-large-land/cover_media_switcher_item.xml @@ -22,12 +22,58 @@ ~ ~ --> + + + + + + + + + + + @@ -102,4 +148,6 @@ app:layout_goneMarginBottom="32dp" tools:text="Beethoven" /> + + \ No newline at end of file diff --git a/application/vlc-android/res/layout/cover_media_switcher_item.xml b/application/vlc-android/res/layout/cover_media_switcher_item.xml index cc1c338673..f330a7da6a 100644 --- a/application/vlc-android/res/layout/cover_media_switcher_item.xml +++ b/application/vlc-android/res/layout/cover_media_switcher_item.xml @@ -2,33 +2,79 @@ - + - + + + + + + + + + + @@ -121,4 +167,5 @@ tools:text="Bitrate: 22.4 KB/s - Codec: Vorbis audio - Sample rate 8000 Hz" /> + \ No newline at end of file diff --git a/application/vlc-android/src/debug/AndroidManifest.xml b/application/vlc-android/src/debug/AndroidManifest.xml index b113d18231..e25e3ccec1 100644 --- a/application/vlc-android/src/debug/AndroidManifest.xml +++ b/application/vlc-android/src/debug/AndroidManifest.xml @@ -9,4 +9,7 @@ + + + \ No newline at end of file diff --git a/application/vlc-android/src/org/videolan/vlc/PlaybackService.kt b/application/vlc-android/src/org/videolan/vlc/PlaybackService.kt index a189ae8cc8..fb862a8109 100644 --- a/application/vlc-android/src/org/videolan/vlc/PlaybackService.kt +++ b/application/vlc-android/src/org/videolan/vlc/PlaybackService.kt @@ -74,6 +74,7 @@ import org.videolan.resources.util.registerReceiverCompat import org.videolan.resources.util.startForegroundCompat import org.videolan.tools.* import org.videolan.vlc.car.VLCCarService +import org.videolan.vlc.gui.audio.Lyrics import org.videolan.vlc.gui.dialogs.VideoTracksDialog import org.videolan.vlc.gui.dialogs.adapters.VlcTrack import org.videolan.vlc.gui.helpers.AudioUtil @@ -1597,6 +1598,7 @@ class PlaybackService : MediaBrowserServiceCompat(), LifecycleOwner, CoroutineSc setPosition((time.toFloat() / NO_LENGTH_PROGRESS_MAX.toFloat())) if (fromUser) publishState(time) } + Lyrics.seek(time); // Required to update timeline when paused if (fromUser && isPaused) showNotification() } diff --git a/application/vlc-android/src/org/videolan/vlc/gui/audio/AudioPlayer.kt b/application/vlc-android/src/org/videolan/vlc/gui/audio/AudioPlayer.kt index b3afaf3aa3..1dfed95cb5 100644 --- a/application/vlc-android/src/org/videolan/vlc/gui/audio/AudioPlayer.kt +++ b/application/vlc-android/src/org/videolan/vlc/gui/audio/AudioPlayer.kt @@ -261,6 +261,7 @@ class AudioPlayer : Fragment(), PlaylistAdapter.IPlayer, TextWatcher, IAudioPlay } setBottomMargin() + Lyrics.setUI(binding); } override fun onDestroy() { @@ -476,6 +477,7 @@ class AudioPlayer : Fragment(), PlaylistAdapter.IPlayer, TextWatcher, IAudioPlay binding.time.text = displayTime if (!isDragging) binding.timeline.progress = progress.time.toInt() binding.progressBar.progress = progress.time.toInt() + Lyrics.progress(progress.time); } lifecycleScope.launchWhenStarted { diff --git a/application/vlc-android/src/org/videolan/vlc/gui/audio/Lyrics.java b/application/vlc-android/src/org/videolan/vlc/gui/audio/Lyrics.java new file mode 100644 index 0000000000..2ac08abf56 --- /dev/null +++ b/application/vlc-android/src/org/videolan/vlc/gui/audio/Lyrics.java @@ -0,0 +1,389 @@ +package org.videolan.vlc.gui.audio; + +import android.net.Uri; +import android.util.Log; +import android.view.View; +import android.widget.TextView; + +import org.jetbrains.annotations.NotNull; +import org.videolan.vlc.PlaybackService; +import org.videolan.vlc.R; +import org.videolan.vlc.databinding.AudioPlayerBinding; + +import java.io.BufferedReader; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + + +public class Lyrics { + private static final String TAG = "Lyrics"; + private static final int QSIZE = 5; + private static AudioPlayerBinding binding; + private static PlaybackService service; + private static boolean justOpened; + + public static class Line { + String text; + long ts; + } + + static List lines; + public static int lastPos; + static Map> cache = Collections.synchronizedMap(new HashMap<>()); + + public static void open(Uri uri, PlaybackService service0) { + service = service0; + clear(); + String fn = uri.toString(); // maybe also work for url? + Log.d(TAG, "open: " + fn); + int p1 = fn.lastIndexOf('.'); + if (p1 < 0) { + + return; + } + fn = fn.substring(0, p1) + ".lrc"; + try { + openFile(Uri.parse(fn).getPath()); + } catch (Exception ex) { + Log.w(TAG, "open: " + ex); + } + + } + + static String url; + + private static int openFile(String url) { + Log.d(TAG, "open: " + url); + Lyrics.url = url; + lines = cache.get(url); + if (lines != null) { + justOpened = true; + return 0; + } + int p1 = url.lastIndexOf("."); + if (p1 < 0) return 0; + String url2 = url.substring(0, p1) + ".lrc"; + try { + lines = readLyrics(url2); + cache.put(url, lines); + Log.d(TAG, String.format("open:read %,d lines of lyrics", lines.size())); + if (lines.isEmpty()) lines = null; + else { + setLinePos(0); + justOpened = true; + } + } catch (Throwable ex) { + Log.e(TAG, "open: ", ex); + } + return 0; + } + + public static void setLinePos(int p) { + if (lines == null) return; + if (p < 0) p = 0; + p = Math.min(p, lines.size() - 1); + if (p != lastPos || p == 0) { + if (setUILines(p)) lastPos = p; + } + } + + public static boolean setUILines(int p) { + try { + if (binding == null) { + Log.d(TAG, "setUILines: exit cos binding==null"); + return false; + } + View v = binding.coverMediaSwitcher.getRootView(); + if (v != null && v.findViewById(R.id.line_current) != null) {// init timing + v.post(() -> { + try { + setText(v.findViewById(R.id.line_current), getLineText(p)); + setText(v.findViewById(R.id.line_n1), getLineText(p + 1)); + setText(v.findViewById(R.id.line_p1), getLineText(p - 1)); + setText(v.findViewById(R.id.line_p2), getLineText(p - 2)); + Log.d(TAG, "setUILines: OK"); + } catch (Exception ex) { + Log.w(TAG, "setUILines: " + ex); + } + }); + return true; + } else { + Log.d(TAG, "setUILines: exit cos v==null"); + return false; + } + } catch (Exception ex) { + Log.w(TAG, "setUILines: " + ex); + } + return false; + } + + private static void setText(TextView view, CharSequence text) { + view.setText(text); + } + + private static String getLineText(int p) { + if (p < 0 || p >= lines.size()) return ""; + return specialFilter1(lines.get(p).text); + } + + private static String specialFilter1(String s) { + if (s == null) return ""; + //step1 + { + int p1 = s.indexOf("--("); + if (p1 > 0) { + s = s.substring(p1 + 3); + if (s.endsWith(")")) s = s.substring(0, s.length() - 1); + } + } + //step2 + int safe = 0; + while (true) { + if (safe++ > 10) break; + int p1 = s.indexOf("("); + if (p1 < 0) break; + int p2 = s.indexOf(")", p1 + 1); + if (p2 < 0) break; + String s2 = s.substring(p1 + 1, p2); + if (s2.startsWith("st ") || allIn(s2, "0123456789,/")) { + s = s.substring(0, p1) + s.substring(p2 + 1); + } else break; + } + return s; + } + + private static boolean allIn(String s, String pat) { + for (char c : s.toCharArray()) { + if (pat.indexOf(c) < 0) return false; + } + return true; + } + + private static List readLyrics(String url) throws IOException { + String text0 = readString(new FileInputStream(url), null); + List res = new ArrayList<>(); + Finder f = new Finder(text0); + f.find("["); + while (true) { + if (f.finished()) break; + String ts = f.readUntil("]"); + long ts1 = parseLrcTime(ts); + if (ts1 < 0) {//failed + f.find("["); + continue; + } + String line0 = f.readUntil("\n") + f.readUntil("[").trim(); + Line line = new Line(); + line.ts = ts1; + line.text = line0; + res.add(line); + } + //no sort by ts, assume source data is sorted + return res; + } + + private static long parseLrcTime(String ts) { + int p1 = ts.indexOf(":"); + if (p1 <= 0) return -1; + try { + return Long.parseLong(ts.substring(0, p1).trim()) * 60000 + (long) (1000 * Float.parseFloat(ts.substring(p1 + 1).trim())); + } catch (Exception ex) { + } + return -1; + } + + + static class FixSizedQueue { + int maxsize; + + FixSizedQueue(int size) { + this.maxsize = size; + } + + void add(T v) { + queue.add(v); + if (queue.size() > maxsize) queue.removeFirst(); + } + + int size() { + return queue.size(); + } + + LinkedList queue = new LinkedList(); + } + + static FixSizedQueue queue = new FixSizedQueue(QSIZE); + + public static void clear() { + Log.d(TAG, "clear"); + hint = 0; + lines = null; + lastPos = -1; + url = null; + } + + public static void seek(long time) { + hint = 0; + lastPos = -1; + } + + private static int locate(long pos, int hint0) { + int size = lines.size(); + int f = size - 1; + for (int i = hint0; i < size; i++) { + if (lines.get(i).ts > pos) { + f = i - 1; + break; + } + } + if (f < 0) f = 0; + if (f == size - 1 && hint0 > 0) { + f = locate(pos, 0); + } + Log.d(TAG, String.format("process: pos=%,d and linepos=%,d, lastPos=%,d hint=%,d", pos, f, lastPos, hint)); + Lyrics.hint = f; + return f; + } + + public static int hint; + + public static int progress(long pos) { + + if (lines == null) return 0; + if (justOpened) { + justOpened = false; + if (resume()) return 0; + } + int f = locate(pos, hint); + + setLinePos(f); + queue.add(f); + if (f > 0 && queue.size() >= QSIZE + && ((Integer) queue.queue.getLast()) - ((Integer) queue.queue.getFirst()) + <= QSIZE * 2) { + try { + SmallPersistantMap.put(url, f); + Log.d(TAG, "record linepos=" + f); + } catch (Exception ex) { + Log.e(TAG, "SmallPersistantMap.save " + ex); + } + queue.queue.clear();// wait for another round + } + return 0; + + } + + + public static void setUI(@NotNull AudioPlayerBinding binding0) { + binding = binding0; + binding.coverMediaSwitcher.setVisibility(View.GONE); + } + + public static String readString(InputStream ins, String enc) throws IOException { + if (enc == null) + enc = "UTF-8"; + BufferedReader in = new BufferedReader(new InputStreamReader(ins, enc)); + char[] buf = new char[1000]; + int len; + StringBuilder sb = new StringBuilder(); + while ((len = in.read(buf)) > 0) { + sb.append(buf, 0, len); + } + in.close(); + return sb.toString(); + } + + static + class Finder { + public int i; + public String text; + + public Finder(String page) { + this.text = page; + i = 0; + } + + public void find(String s) { + int p1 = text.indexOf(s, i); + if (p1 < 0) + i = text.length(); + else + i = p1 + s.length(); + } + + public void findReverse(String s) { + int p1 = text.substring(0, i).lastIndexOf(s); + if (p1 < 0) + i = text.length(); + else + i = p1 + s.length(); + } + + public String readUntil(String s) { + int p1 = text.indexOf(s, i); + if (p1 < 0) { + return readRemain(); + } else { + String r = text.substring(i, p1); + i = p1 + s.length(); + return r; + } + } + + public boolean finished() { + return i >= text.length(); + } + + public void reset() { + i = 0; + } + + public void setPos(int pos) { + i = pos; + } + + public int getPos() { + return i; + } + + public String readRemain() { + String r = text.substring(i); + i = text.length(); + return r; + } + + public char readChar() { + if (finished()) { + return (char) -1; + } + char c = text.charAt(i++); + return c; + } + } + + private static boolean resume() { + if (lines == null) return false; + try { + Integer pos = SmallPersistantMap.getInt(url); + Log.d(TAG, String.format("try resume:(%s) for %s", pos, url)); + if (pos == null) return false; + int ms = (int) lines.get(pos).ts; + ms -= 1000;//why, but needed... + if (ms < 0) ms = 0; + service.seek(ms); + Log.d(TAG, "resume: linepos=" + pos); + return true; + } catch (Exception ex) { + Log.w("lyrics", "resume: " + ex); + } + return false; + } +} diff --git a/application/vlc-android/src/org/videolan/vlc/gui/audio/SmallPersistantMap.java b/application/vlc-android/src/org/videolan/vlc/gui/audio/SmallPersistantMap.java new file mode 100644 index 0000000000..570056647d --- /dev/null +++ b/application/vlc-android/src/org/videolan/vlc/gui/audio/SmallPersistantMap.java @@ -0,0 +1,59 @@ +package org.videolan.vlc.gui.audio; + +import android.os.Environment; +import android.util.Log; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; + +public class SmallPersistantMap { + public static File path; + + public synchronized static void put(String key, Object v) throws IOException { + initPath(); + key = toFn(key); + Log.d("SmallMap", String.format("put[%s]=%s", key, v)); + File f = new File(path, key); + FileOutputStream out = new FileOutputStream(f); + out.write(v.toString().getBytes("utf8")); + out.close(); + } + + private static String toFn(String key) { + int p1 = key.lastIndexOf('/'); + if (p1 >= 0) key = key.substring(p1 + 1); + + return "v_" + key.replace('/', '_') + .replace('\\', '_') + ".txt"; + } + + private synchronized static void initPath() { + if (path == null) { + File dir = Environment.getExternalStorageDirectory(); + File dir2 = new File(dir, "neoesmallmap"); + dir2.mkdirs(); + path = dir2; + } + } + + public synchronized static String get(String key) throws IOException { + initPath(); + key = toFn(key); + Log.d("SmallMap", String.format("get[%s]", key)); + File f = new File(path, key); + if (!f.isFile()) return null; + return Lyrics.readString(new FileInputStream(f), null); + } + + public static Integer getInt(String key) throws IOException { + String v = get(key); + if (v == null) return null; + try { + return Integer.parseInt(v.trim()); + } catch (Exception ex) { + return null; + } + } +} \ No newline at end of file diff --git a/application/vlc-android/src/org/videolan/vlc/media/PlayerController.kt b/application/vlc-android/src/org/videolan/vlc/media/PlayerController.kt index b5a35a5033..42309591dc 100644 --- a/application/vlc-android/src/org/videolan/vlc/media/PlayerController.kt +++ b/application/vlc-android/src/org/videolan/vlc/media/PlayerController.kt @@ -20,6 +20,7 @@ import org.videolan.resources.VLCInstance import org.videolan.resources.VLCOptions import org.videolan.tools.* import org.videolan.vlc.* +import org.videolan.vlc.gui.audio.Lyrics import org.videolan.vlc.gui.dialogs.VideoTracksDialog import org.videolan.vlc.gui.dialogs.adapters.VlcTrack import org.videolan.vlc.repository.SlaveRepository @@ -108,6 +109,7 @@ class PlayerController(val context: Context) : IVLCVout.Callback, MediaPlayer.Ev } fun setPosition(position: Float) { + Lyrics.hint = 0; if (seekable && mediaplayer.hasMedia() && !mediaplayer.isReleased) mediaplayer.position = position } diff --git a/application/vlc-android/src/org/videolan/vlc/media/PlaylistManager.kt b/application/vlc-android/src/org/videolan/vlc/media/PlaylistManager.kt index 7a5335845d..cf70c2db3b 100644 --- a/application/vlc-android/src/org/videolan/vlc/media/PlaylistManager.kt +++ b/application/vlc-android/src/org/videolan/vlc/media/PlaylistManager.kt @@ -75,6 +75,7 @@ import org.videolan.tools.putSingle import org.videolan.vlc.BuildConfig import org.videolan.vlc.PlaybackService import org.videolan.vlc.R +import org.videolan.vlc.gui.audio.Lyrics import org.videolan.vlc.gui.video.VideoPlayerActivity import org.videolan.vlc.util.FileUtils import org.videolan.vlc.util.awaitMedialibraryStarted @@ -328,6 +329,10 @@ class PlaylistManager(val service: PlaybackService) : MediaWrapperList.EventList //needed to save the current media in audio mode when it's a video played as audio saveCurrentMedia() saveMediaList() + // the UI is recreated? + val pos=Lyrics.lastPos; + Lyrics.seek(0) + Lyrics.setUILines(pos) } if (getCurrentMedia()?.isPodcast == true) saveMediaMeta() } @@ -459,6 +464,7 @@ class PlaylistManager(val service: PlaybackService) : MediaWrapperList.EventList } suspend fun playIndex(index: Int, flags: Int = 0, forceResume:Boolean = false, forceRestart:Boolean = false) { + Lyrics.clear(); videoBackground = videoBackground || (!player.isVideoPlaying() && player.canSwitchToVideo()) || !isAppStarted() if (mediaList.size() == 0) { Log.w(TAG, "Warning: empty media list, nothing to play !") @@ -526,6 +532,7 @@ class PlaylistManager(val service: PlaybackService) : MediaWrapperList.EventList newMedia = true determinePrevAndNextIndices() service.onNewPlayback() + Lyrics.open(uri, service); } else { //Start VideoPlayer for first video, it will trigger playIndex when ready. if (player.isPlaying()) player.stop() VideoPlayerActivity.startOpened(ctx, mw.uri, currentIndex)